diff --git a/.changeset/afraid-wolves-admire.md b/.changeset/afraid-wolves-admire.md new file mode 100644 index 0000000000..c19f344f1c --- /dev/null +++ b/.changeset/afraid-wolves-admire.md @@ -0,0 +1,5 @@ +--- +"@sl-design-system/shared": patch +--- + +Add `getScrollParent()` utility method diff --git a/.changeset/perfect-lemons-teach.md b/.changeset/perfect-lemons-teach.md new file mode 100644 index 0000000000..dc6f827a95 --- /dev/null +++ b/.changeset/perfect-lemons-teach.md @@ -0,0 +1,5 @@ +--- +"@sl-design-system/menu": patch +--- + +Add `--sl-menu-(min|max)-inline-size` CSS custom properties diff --git a/.changeset/poor-trees-attend.md b/.changeset/poor-trees-attend.md new file mode 100644 index 0000000000..750de2f0ae --- /dev/null +++ b/.changeset/poor-trees-attend.md @@ -0,0 +1,5 @@ +--- +"@sl-design-system/menu": patch +--- + +Add button part for customization diff --git a/.changeset/smart-lemons-thank.md b/.changeset/smart-lemons-thank.md new file mode 100644 index 0000000000..985e4c7b5a --- /dev/null +++ b/.changeset/smart-lemons-thank.md @@ -0,0 +1,5 @@ +--- +"@sl-design-system/shared": patch +--- + +When calculating the max size of a popover, do not overwrite any existing max size properties if they are present diff --git a/.changeset/soft-humans-wonder.md b/.changeset/soft-humans-wonder.md new file mode 100644 index 0000000000..47eae075e1 --- /dev/null +++ b/.changeset/soft-humans-wonder.md @@ -0,0 +1,5 @@ +--- +"@sl-design-system/menu": patch +--- + +Fix scrolling when menu overflows diff --git a/package.json b/package.json index b730ff1a39..d9008d7b93 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "command": "node scripts/build-packages.js checklist", "clean": "if-file-deleted", "dependencies": [ - "build:scss" + "build:scss", + "build:themes" ], "files": [ "packages/checklist/**/*.ts", @@ -241,7 +242,7 @@ "cascade": false }, { - "script": "build:checklist", + "script": "build:checklist:package", "cascade": false }, { diff --git a/packages/components/menu/custom-elements.json b/packages/components/menu/custom-elements.json index cab4daabfb..9aabdfc1b6 100644 --- a/packages/components/menu/custom-elements.json +++ b/packages/components/menu/custom-elements.json @@ -88,6 +88,12 @@ "kind": "class", "description": "Custom element that combines a button and a menu and automatically wires them up\ntogether.", "name": "MenuButton", + "cssParts": [ + { + "description": "The button element.", + "name": "button" + } + ], "slots": [ { "description": "The menu items should be slotted in the default slot.", @@ -288,15 +294,7 @@ "type": { "text": "void" } - }, - "parameters": [ - { - "name": "event", - "type": { - "text": "Event" - } - } - ] + } }, { "kind": "method", @@ -540,6 +538,12 @@ "kind": "class", "description": "Menu item component for use inside a menu.", "name": "MenuItem", + "cssParts": [ + { + "description": "The wrapper around the menu item content.", + "name": "wrapper" + } + ], "slots": [ { "description": "Content to display inside the menu item.", @@ -885,8 +889,24 @@ "declarations": [ { "kind": "class", - "description": "", + "description": "A menu that can be used as a context menu or as a dropdown menu.", "name": "Menu", + "cssProperties": [ + { + "description": "The maximum inline size of the menu.", + "name": "--sl-menu-max-inline-size" + }, + { + "description": "The minimum inline size of the menu.", + "name": "--sl-menu-min-inline-size" + } + ], + "slots": [ + { + "description": "The menu's content: menu items or menu item groups.", + "name": "" + } + ], "members": [ { "kind": "method", diff --git a/packages/components/menu/src/menu-button.ts b/packages/components/menu/src/menu-button.ts index 6a236deda8..c3a1e575fd 100644 --- a/packages/components/menu/src/menu-button.ts +++ b/packages/components/menu/src/menu-button.ts @@ -6,7 +6,6 @@ import { type PopoverPosition } from '@sl-design-system/shared'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import styles from './menu-button.scss.js'; -import { MenuItem } from './menu-item.js'; import { Menu } from './menu.js'; declare global { @@ -19,6 +18,8 @@ declare global { * Custom element that combines a button and a menu and automatically wires them up * together. * + * @csspart button - The button element. + * * @slot default - The menu items should be slotted in the default slot. * @slot button - Any content for the button should be slotted here. */ @@ -89,6 +90,7 @@ export class MenuButton extends ScopedElementsMixin(LitElement) { .fill=${this.fill} .size=${this.size} .variant=${this.variant} + part="button" > ${this.selects && this.selected ? html`${this.selected}` : nothing} @@ -125,10 +127,8 @@ export class MenuButton extends ScopedElementsMixin(LitElement) { } } - #onMenuClick(event: Event): void { - if (event.target instanceof MenuItem) { - this.menu.hidePopover(); - } + #onMenuClick(): void { + this.menu.hidePopover(); } #onSelect(): void { diff --git a/packages/components/menu/src/menu-item.scss b/packages/components/menu/src/menu-item.scss index 0139e7185c..18e02d3e89 100644 --- a/packages/components/menu/src/menu-item.scss +++ b/packages/components/menu/src/menu-item.scss @@ -62,7 +62,7 @@ slot[name='submenu']::slotted(sl-menu) { } } -.wrapper { +[part='wrapper'] { align-items: center; background: var(--_background); border-radius: var(--_border-radius); diff --git a/packages/components/menu/src/menu-item.ts b/packages/components/menu/src/menu-item.ts index 37a23e568d..c8ca66caaa 100644 --- a/packages/components/menu/src/menu-item.ts +++ b/packages/components/menu/src/menu-item.ts @@ -22,6 +22,8 @@ declare global { /** * Menu item component for use inside a menu. * + * @csspart wrapper - The wrapper around the menu item content. + * * @slot default - Content to display inside the menu item. * @slot submenu - The menu items that will be displayed when the menu item is shown. */ @@ -95,7 +97,7 @@ export class MenuItem extends ScopedElementsMixin(LitElement) { override render(): TemplateResult { return html` -
+
${this.selected ? html`` : nothing} ${this.shortcut ? html`${this.#shortcut.render(this.shortcut)}` : nothing} diff --git a/packages/components/menu/src/menu.scss b/packages/components/menu/src/menu.scss index 117c2363b9..bed86d2c0f 100644 --- a/packages/components/menu/src/menu.scss +++ b/packages/components/menu/src/menu.scss @@ -19,10 +19,13 @@ display: none; flex-direction: column; margin: 0; + max-inline-size: var(--sl-menu-max-inline-size, auto); + min-inline-size: var(--sl-menu-min-inline-size, fit-content); opacity: 0; - overflow: visible; + overflow: visible auto; padding-block: var(--_padding-block); padding-inline: 0; + scrollbar-width: thin; @media (prefers-reduced-motion: no-preference) { transition: opacity 0.2s cubic-bezier(0.25, 0, 0.3, 1); @@ -40,7 +43,7 @@ border: var(--_border) !important; color: var(--_color) !important; margin: 0 !important; - overflow: visible !important; + overflow: visible auto !important; padding-block: var(--_padding-block) !important; padding-inline: 0 !important; position: fixed; diff --git a/packages/components/menu/src/menu.ts b/packages/components/menu/src/menu.ts index 78a98a2c92..ef284b1410 100644 --- a/packages/components/menu/src/menu.ts +++ b/packages/components/menu/src/menu.ts @@ -19,6 +19,14 @@ declare global { } } +/** + * A menu that can be used as a context menu or as a dropdown menu. + * + * @cssprop --sl-menu-max-inline-size - The maximum inline size of the menu. + * @cssprop --sl-menu-min-inline-size - The minimum inline size of the menu. + * + * @slot - The menu's content: menu items or menu item groups. + */ export class Menu extends LitElement { /** The default offset of the menu to its anchor. */ static offset = 4; diff --git a/packages/components/shared/custom-elements.json b/packages/components/shared/custom-elements.json index 6105cb4585..3d6ff1c093 100644 --- a/packages/components/shared/custom-elements.json +++ b/packages/components/shared/custom-elements.json @@ -119,6 +119,14 @@ "package": "./src/decorators/observe.js" } }, + { + "kind": "js", + "name": "*", + "declaration": { + "name": "*", + "package": "./src/dom.js" + } + }, { "kind": "js", "name": "*", @@ -4516,6 +4524,41 @@ } ] }, + { + "kind": "javascript-module", + "path": "src/dom.ts", + "declarations": [ + { + "kind": "function", + "name": "getScrollParent", + "return": { + "type": { + "text": "" + } + }, + "parameters": [ + { + "name": "element", + "type": { + "text": "Element" + }, + "description": "The element to find the scrollable parent of." + } + ], + "description": "Returns the first scrollable parent of the given element." + } + ], + "exports": [ + { + "kind": "js", + "name": "getScrollParent", + "declaration": { + "name": "getScrollParent", + "module": "src/dom.ts" + } + } + ] + }, { "kind": "javascript-module", "path": "src/events.ts", diff --git a/packages/components/shared/index.ts b/packages/components/shared/index.ts index 67200ea860..906f332961 100644 --- a/packages/components/shared/index.ts +++ b/packages/components/shared/index.ts @@ -12,6 +12,7 @@ export * from './src/data-source/data-source.js'; export * from './src/decorators/base.js'; export * from './src/decorators/event.js'; export * from './src/decorators/observe.js'; +export * from './src/dom.js'; export * from './src/directives/anchor.js'; export * from './src/events.js'; export * from './src/path.js'; diff --git a/packages/components/shared/src/dom.ts b/packages/components/shared/src/dom.ts new file mode 100644 index 0000000000..5adf173417 --- /dev/null +++ b/packages/components/shared/src/dom.ts @@ -0,0 +1,14 @@ +/** + * Returns the first scrollable parent of the given element. + * @param element The element to find the scrollable parent of. + * @returns The first scrollable parent of the given element; if no explicit scroll parent, returns the html element. + */ +export const getScrollParent = (element: Element): Element => { + if (element.scrollHeight > element.clientHeight) { + return element; + } else if (element.parentElement) { + return getScrollParent(element.parentElement); + } else { + return element; + } +}; diff --git a/packages/components/shared/src/popover.ts b/packages/components/shared/src/popover.ts index 69a24804c8..fb17647226 100644 --- a/packages/components/shared/src/popover.ts +++ b/packages/components/shared/src/popover.ts @@ -54,11 +54,19 @@ export const positionPopover = ( padding: options.viewportMargin, apply: ({ availableWidth, availableHeight }) => { // Make sure that the overlay is contained by the visible page. - const maxHeight = Math.max(MIN_OVERLAY_HEIGHT, Math.floor(availableHeight)); + const maxBlockSize = + Math.max(MIN_OVERLAY_HEIGHT, Math.floor(availableHeight)) - (options.viewportMargin ?? 0), + maxInlineSize = options.maxWidth ?? Math.floor(availableWidth); + const style = getComputedStyle(element), + currentMaxBlockSize = parseInt(style.maxBlockSize) ?? 0, + currentMaxInlineSize = parseInt(style.maxInlineSize) ?? 0; + + // If the element already has a max inline or block size that is smaller + // than the available space, don't override it. Object.assign(element.style, { - maxWidth: `${options.maxWidth ?? Math.floor(availableWidth)}px`, - maxHeight: `${maxHeight - (options.viewportMargin ?? 0)}px` + maxInlineSize: maxInlineSize > currentMaxInlineSize ? '' : `${maxInlineSize}px`, + maxBlockSize: maxBlockSize > currentMaxBlockSize ? '' : `${maxBlockSize}px` }); } }) @@ -87,8 +95,8 @@ export const positionPopover = ( if (arrow && arrowElement) { Object.assign(arrowElement.style, { - 'inset-inline-start': typeof arrow.x === 'number' ? `${roundByDPR(arrow.x)}px` : '', - 'inset-block-start': typeof arrow.y === 'number' ? `${roundByDPR(arrow.y)}px` : '' + insetInlineStart: typeof arrow.x === 'number' ? `${roundByDPR(arrow.x)}px` : '', + insetBlockStart: typeof arrow.y === 'number' ? `${roundByDPR(arrow.y)}px` : '' }); } }); diff --git a/packages/components/tabs/custom-elements.json b/packages/components/tabs/custom-elements.json index 072569043e..830ebf30dc 100644 --- a/packages/components/tabs/custom-elements.json +++ b/packages/components/tabs/custom-elements.json @@ -70,42 +70,102 @@ "declarations": [ { "kind": "class", - "description": "A tab group component that can contain tabs and tab panels.\n\n```html\n \n First tab\n Content of the tab 1\n\n Second tab\n Content of the tab 2\n \n```", + "description": "A tab group component that can contain tabs and tab panels.\n\n```html\n \n First tab\n Second tab\n\n Content of tab 1\n Content of tab 2\n \n```", "name": "TabGroup", + "cssProperties": [ + { + "description": "The minimum inline size of the menu.", + "name": "--sl-tab-group-menu-min-inline-size" + }, + { + "description": "The maximum inline size of the menu.", + "name": "--sl-tab-group-menu-max-inline-size" + } + ], + "cssParts": [ + { + "description": "The container for the tabs.", + "name": "container" + }, + { + "description": "Wraps the scroll container and menu button.", + "name": "wrapper" + }, + { + "description": "The scroll container of the tabs.", + "name": "scroller" + }, + { + "description": "The tablist element which also contains the active tab indicator", + "name": "tablist" + }, + { + "description": "The container for the tab panels.", + "name": "panels" + } + ], "slots": [ { - "description": "a place for the tab group content.", + "description": "Tab panels or other tab content here.", "name": "default" + }, + { + "description": "The tabs to display.", + "name": "tabs" } ], "members": [ { "kind": "field", - "name": "alignment", + "name": "alignTabs", "type": { - "text": "TabsAlignment" + "text": "TabsAlignment | undefined" }, - "default": "'start'", - "description": "The alignment of tabs inside sl-tab-group", - "attribute": "alignment", + "description": "The alignment of tabs within the wrapper.", + "attribute": "align-tabs", "reflects": true }, { "kind": "field", - "name": "listbox", + "name": "menuItems", + "type": { + "text": "Array<{ tab: Tab; disabled?: boolean; title: string; subtitle?: string }> | undefined" + }, + "description": "The menu items to render when the tabs are overflowing." + }, + { + "kind": "field", + "name": "selectedTab", + "type": { + "text": "Tab | undefined" + }, + "description": "The currently selected tab." + }, + { + "kind": "field", + "name": "showMenu", "type": { - "text": "HTMLElement" + "text": "boolean" }, - "description": "The listbox element with all tabs list." + "default": "false", + "description": "Whether the menu button needs to be shown." }, { "kind": "event", - "name": "tabChange", + "name": "sl-tab-change", "type": { "text": "EventEmitter" }, "description": "Emits when the tab has been selected/changed." }, + { + "kind": "field", + "name": "tabPanels", + "type": { + "text": "TabPanel[] | undefined" + }, + "description": "The slotted tabs." + }, { "kind": "field", "name": "tabs", @@ -127,31 +187,30 @@ ], "events": [ { - "name": "tabChange", + "name": "tabChangeEvent", "type": { "text": "EventEmitter" }, "description": "Emits when the tab has been selected/changed.", - "fieldName": "tabChange" + "fieldName": "tabChangeEvent" } ], "attributes": [ { - "name": "vertical", + "name": "align-tabs", "type": { - "text": "boolean | undefined" + "text": "TabsAlignment | undefined" }, - "description": "Renders the tabs vertically instead of the default horizontal", - "fieldName": "vertical" + "description": "The alignment of tabs within the wrapper.", + "fieldName": "alignTabs" }, { - "name": "alignment", + "name": "vertical", "type": { - "text": "TabsAlignment" + "text": "boolean | undefined" }, - "default": "'start'", - "description": "The alignment of tabs inside sl-tab-group", - "fieldName": "alignment" + "description": "Renders the tabs vertically instead of the default horizontal", + "fieldName": "vertical" } ], "mixins": [ @@ -169,45 +228,75 @@ "methods": [ { "kind": "method", - "name": "#updateSlots", + "name": "#onClick", "return": { "type": { "text": "void" } - } + }, + "parameters": [ + { + "name": "event", + "type": { + "text": "Event & { target: HTMLElement }" + } + } + ] }, { "kind": "method", - "name": "#onClick", + "name": "#onKeydown", "return": { "type": { "text": "void" } - } + }, + "parameters": [ + { + "name": "event", + "type": { + "text": "KeyboardEvent & { target: HTMLElement }" + } + } + ] }, { "kind": "method", - "name": "#setupTabs", + "name": "#onMenuItemClick", "return": { "type": { "text": "void" } }, - "description": "Apply accessible attributes and values to the tab buttons." + "parameters": [ + { + "name": "tab", + "type": { + "text": "Tab" + } + } + ] }, { "kind": "method", - "name": "#setupPanels", + "name": "#onScroll", "return": { "type": { "text": "void" } }, - "description": "Apply accessible attributes and values to the tab panels." + "parameters": [ + { + "name": "event", + "type": { + "text": "Event & { target: HTMLElement }" + } + } + ] }, { "kind": "method", - "name": "#handleTabChange", + "name": "#onTabSlotchange", "return": { "type": { "text": "void" @@ -217,14 +306,14 @@ { "name": "event", "type": { - "text": "Event & { target: HTMLElement }" + "text": "Event & { target: HTMLSlotElement }" } } ] }, { "kind": "method", - "name": "#updateSelectedTab", + "name": "#onTabPanelSlotchange", "return": { "type": { "text": "void" @@ -232,17 +321,51 @@ }, "parameters": [ { - "name": "selectedTab", + "name": "event", + "type": { + "text": "Event & { target: HTMLSlotElement }" + } + } + ] + }, + { + "kind": "method", + "name": "#linkTabsWithPanels", + "return": { + "type": { + "text": "void" + } + } + }, + { + "kind": "method", + "name": "#scrollIntoViewIfNeeded", + "return": { + "type": { + "text": "void" + } + }, + "parameters": [ + { + "name": "tab", "type": { "text": "Tab" } } - ], - "description": "Update the selected tab button with attributes and values.\nUpdate the tab group state." + ] }, { "kind": "method", - "name": "#handleKeydown", + "name": "#scrollToTabPanelStart", + "return": { + "type": { + "text": "void" + } + } + }, + { + "kind": "method", + "name": "#updateSelectedTab", "return": { "type": { "text": "void" @@ -250,13 +373,12 @@ }, "parameters": [ { - "name": "event", + "name": "selectedTab", "type": { - "text": "KeyboardEvent" + "text": "Tab" } } - ], - "description": "Handle keyboard accessible controls." + ] }, { "kind": "method", @@ -266,130 +388,96 @@ "text": "void" } } + }, + { + "kind": "method", + "name": "#updateSize", + "return": { + "type": { + "text": "void" + } + } } ], "fields": [ { "kind": "field", - "name": "scopedElements", - "type": { - "text": "ScopedElementsMap" - }, - "static": true, - "privacy": "private", - "readonly": true - }, - { - "kind": "field", - "name": "#tabGroupId", + "name": "#idPrefix", "privacy": "private", "default": "`sl-tab-group-${nextUniqueId++}`", - "description": "Unique ID for each tab group component present." + "description": "Unique prefix ID for each component in the light DOM." }, { "kind": "field", - "name": "#observer", + "name": "#mutationObserver", "privacy": "private", - "type": { - "text": "MutationObserver | undefined" - } + "default": "new MutationObserver(entries => {\n entries.forEach(entry => {\n if (entry.attributeName === 'selected' && entry.oldValue === null) {\n this.#mutationObserver?.disconnect();\n\n // Update the selected tab with the observer turned off to avoid loops\n this.#updateSelectedTab(entry.target as Tab);\n\n this.#mutationObserver?.observe(this, OBSERVER_OPTIONS);\n }\n });\n })", + "description": "Observe changes to the selected tab and update accordingly. This observer\nis necessary for changes to the selected tab that are made programmatically.\nSelected changes made by the user are handled by the click event listener." }, { "kind": "field", - "name": "#rovingTabindexController", + "name": "#resizeObserver", "privacy": "private", - "default": "new RovingTabindexController(this, {\n focusInIndex: (elements: Tab[]) => elements.findIndex(el => el.selected),\n elements: () => (isPopoverOpen(this.listbox) ? this.#allTabs : this.tabs) || [],\n isFocusableElement: (el: Tab) => !el.disabled\n })" + "default": "new ResizeObserver(() => {\n this.#shouldAnimate = false;\n this.#updateSize();\n this.#shouldAnimate = true;\n })", + "description": "Observe changes to the size of the tablist so:\n- we can determine when to display an overflow menu with tab items\n- we know when we need to reposition the active tab indicator" }, { "kind": "field", - "name": "#allTabs", + "name": "#rovingTabindexController", "privacy": "private", - "type": { - "text": "Tab[]" - }, - "default": "[]", - "description": "All slotted tabs." + "default": "new RovingTabindexController(this, {\n focusInIndex: (elements: Tab[]) => elements.findIndex(el => el.selected),\n elements: () => this.tabs || [],\n isFocusableElement: (el: Tab) => !el.disabled\n })", + "description": "Manage keyboard navigation between tabs." }, { "kind": "field", - "name": "#showMore", + "name": "#shouldAnimate", "privacy": "private", "type": { "text": "boolean" }, "default": "false", - "description": "Whether more button needs to be shown" + "description": "Determines whether the active tab indicator should animate." }, { "kind": "field", - "name": "#moreButton", - "privacy": "private", + "name": "menuItems", "type": { - "text": "HTMLButtonElement" + "text": "Array<{ tab: Tab; disabled?: boolean; title: string; subtitle?: string }> | undefined" }, - "description": "Button used to show all tabs" - }, - { - "kind": "field", - "name": "#sizeObserver", - "privacy": "private", - "default": "new ResizeObserver(() => {\n this.#updateSelectionIndicator();\n })", - "description": "Observe the tablist width." - }, - { - "kind": "field", - "name": "tabs", - "type": { - "text": "Tab[] | undefined" - }, - "description": "The slotted tabs." + "description": "The menu items to render when the tabs are overflowing." }, { "kind": "field", "name": "selectedTab", "type": { - "text": "Tab | null" + "text": "Tab | undefined" }, - "privacy": "private", - "description": "The current tab node selected in the tab group." + "description": "The currently selected tab." }, { "kind": "field", - "name": "selectedTabInListbox", + "name": "showMenu", "type": { - "text": "Tab | null" + "text": "boolean" }, - "privacy": "private", - "description": "The current tab node selected in the tab listbox (dropdown)." + "default": "false", + "description": "Whether the menu button needs to be shown." }, { "kind": "field", - "name": "listbox", + "name": "tabPanels", "type": { - "text": "HTMLElement" + "text": "TabPanel[] | undefined" }, - "description": "The listbox element with all tabs list." + "description": "The slotted tabs." }, { "kind": "field", - "name": "#initialSelectedTab", - "privacy": "private", + "name": "tabs", "type": { - "text": "Tab | null" + "text": "Tab[] | undefined" }, - "description": "Get the selected tab button, or the first tab button.", - "readonly": true - }, - { - "kind": "field", - "name": "#onToggle", - "privacy": "private" - }, - { - "kind": "field", - "name": "#handleMutation", - "privacy": "private", - "description": "If the selected tab is selected programmatically update all the tabs." + "description": "The slotted tabs." } ] } @@ -411,7 +499,7 @@ "declarations": [ { "kind": "class", - "description": "A tab panel component - part of the tab group component, place for a tab content.\n\n```html\n \n Content of the tab\n \n```", + "description": "A tab panel component, to be used with the tab group component for your tab content.\n\n```html\n Content of the tab\n```", "name": "TabPanel", "slots": [ { @@ -445,7 +533,7 @@ "declarations": [ { "kind": "class", - "description": "A tab component - part of the tab group component.\n\n```html\n \n \n Tab label\n Tab subtitle\n 4\n \n```", + "description": "A tab component - part of the tab group component.\n\n```html\n \n \n Tab label\n Tab subtitle\n 4\n \n```", "name": "Tab", "slots": [ { @@ -470,31 +558,36 @@ "kind": "field", "name": "disabled", "type": { - "text": "boolean" + "text": "boolean | undefined" }, - "default": "false", "description": "Whether the tab item is disabled", "attribute": "disabled", "reflects": true }, + { + "kind": "field", + "name": "href", + "type": { + "text": "string | undefined" + }, + "description": "When set, it will render the tab contents in a link tag. Use this when\nyou want to render the tab contents using a router and to make the tab\nnavigatable by URL.", + "attribute": "href" + }, { "kind": "method", - "name": "handleSelectionChange", - "privacy": "protected", + "name": "renderContent", "return": { "type": { - "text": "void" + "text": "TemplateResult" } - }, - "description": "Apply accessible attributes and values to the tab button.\nObserve the selected property if it changes" + } }, { "kind": "field", "name": "selected", "type": { - "text": "boolean" + "text": "boolean | undefined" }, - "default": "false", "description": "Whether the tab item is selected", "attribute": "selected", "reflects": true @@ -502,22 +595,28 @@ ], "attributes": [ { - "name": "selected", + "name": "disabled", "type": { - "text": "boolean" + "text": "boolean | undefined" }, - "default": "false", - "description": "Whether the tab item is selected", - "fieldName": "selected" + "description": "Whether the tab item is disabled", + "fieldName": "disabled" }, { - "name": "disabled", + "name": "href", "type": { - "text": "boolean" + "text": "string | undefined" }, - "default": "false", - "description": "Whether the tab item is disabled", - "fieldName": "disabled" + "description": "When set, it will render the tab contents in a link tag. Use this when\nyou want to render the tab contents using a router and to make the tab\nnavigatable by URL.", + "fieldName": "href" + }, + { + "name": "selected", + "type": { + "text": "boolean | undefined" + }, + "description": "Whether the tab item is selected", + "fieldName": "selected" } ], "superclass": { @@ -529,14 +628,29 @@ "methods": [ { "kind": "method", - "name": "handleSelectionChange", - "privacy": "protected", + "name": "renderContent", + "return": { + "type": { + "text": "TemplateResult" + } + } + }, + { + "kind": "method", + "name": "#onSlotchange", "return": { "type": { "text": "void" } }, - "description": "Apply accessible attributes and values to the tab button.\nObserve the selected property if it changes" + "parameters": [ + { + "name": "event", + "type": { + "text": "Event & { target: HTMLSlotElement }" + } + } + ] } ], "fields": [] diff --git a/packages/components/tabs/package.json b/packages/components/tabs/package.json index 1e18692871..11ff1bba63 100644 --- a/packages/components/tabs/package.json +++ b/packages/components/tabs/package.json @@ -38,8 +38,8 @@ "test": "echo \"Error: run tests from monorepo root.\" && exit 1" }, "dependencies": { - "@sl-design-system/button": "0.0.24", "@sl-design-system/icon": "0.0.9", + "@sl-design-system/menu": "0.0.5", "@sl-design-system/shared": "0.2.7" }, "devDependencies": { diff --git a/packages/components/tabs/src/tab-group.scss b/packages/components/tabs/src/tab-group.scss index 659ccbe17d..288aa3647e 100644 --- a/packages/components/tabs/src/tab-group.scss +++ b/packages/components/tabs/src/tab-group.scss @@ -1,223 +1,221 @@ :host { - --_background-color: var(--sl-color-tab-default-background); - --_border-bottom: var(--sl-border-width-border-tab) solid var(--sl-color-tab-default-border); - --_button-color: var(--sl-color-tab-default-foreground); - --_button-color-active: var(--sl-color-tab-active-foreground); - --_button-color-hover: var(--sl-color-tab-hover-foreground); - --_button-inline-padding: var(--sl-space-tab-more-inline); - --_button-shadow: -4px 0px 4px -2px rgb(0 0 0 / 12%); - --_button-vertical-shadow: 0px -4px 4px -2px rgb(0 0 0 / 12%); - --_focus-radius: var(--sl-border-radius-focusring-default); + --_background: var(--sl-color-tab-default-background); + --_border: var(--sl-border-width-border-tab) solid var(--sl-color-tab-default-border); + --_fade-size: 6rem; --_indicator-animation-duration: var(--sl-animation-duration-slow); --_indicator-color: var(--sl-color-button-primary-solid-idle-background); --_indicator-size: var(--sl-size-select-indicator); - --_indicator-listbox-border-radius: var(--sl-border-radius-select-indicator); - --_listbox-background: var(--sl-color-select-listbox-background); - --_listbox-border: var(--sl-border-width-select-listbox) solid var(--sl-color-select-listbox-border); - --_listbox-border-radius: var(--sl-border-radius-select-listbox); - --_listbox-box-shadow: var(--sl-elevation-shadow-md); - --_listbox-gap: var(--sl-space-select-listbox-gap-lg); - --_listbox-padding: var(--sl-space-select-listbox-block-lg) var(--sl-space-select-listbox-inline-lg); - --_panel-horizontal-padding: calc(var(--sl-space-tab-content-horizontal-block) - var(--_wrapper-spacing)) + --_menu-button-inline-padding: calc( + var(--sl-border-width-focusring-offset) + var(--sl-border-width-focusring-default) + ); + --_panel-horizontal-padding: var(--sl-space-tab-content-horizontal-block) var(--sl-space-tab-content-horizontal-inline) var(--sl-space-tab-content-horizontal-block); --_panel-vertical-padding: var(--sl-space-tab-content-vertical-block) var(--sl-space-tab-content-vertical-inline); - --_tablist-vertical-max-width: var(--sl-size-tabbar-vertical-maxwidth); - --_wrapper-spacing: calc(var(--sl-border-width-focusring-default) + var(--sl-border-width-focusring-offset)); + --_tablist-vertical-max-width: var(--sl-size-tabbar-vertical-maxwidth, 200px); + --_tabs-alignment: start; display: flex; flex-direction: column; - isolation: isolate; + isolation: isolate; // Prevent any possible z-index clashes with other DOM elements } -.container { - display: grid; - grid-template-columns: 1fr auto; - inline-size: 100%; +:host([align-tabs='center']) { + --_tabs-alignment: center; } -.wrapper { - display: grid; - grid-template-columns: 1fr auto; - overflow: scroll; - overscroll-behavior-x: contain; - padding: var(--_wrapper-spacing); - position: relative; - scroll-snap-points-x: repeat(100%); - scroll-snap-type: x mandatory; - scrollbar-width: none; +:host([align-tabs='end']) { + --_tabs-alignment: end; +} - &::-webkit-scrollbar { - display: none; - } +:host([align-tabs='stretch']) { + --_tabs-alignment: stretch; - @media (prefers-reduced-motion: no-preference) { - scroll-behavior: smooth; + [part='scroller'], + ::slotted(sl-tab) { + flex-grow: 1; } -} -[role='tablist'] { - background-color: var(--_background-color); - border-block-end: var(--_border-bottom); - display: flex; - position: relative; + [part='tablist'] { + inline-size: 100%; + } } -::slotted(sl-tab-panel) { - padding: var(--_panel-horizontal-padding); +:host([scroll-start]) .fade-start, +:host([scroll-end]) .fade-end { + visibility: visible; } -sl-button { - background-color: var(--_background-color); - border-block-end: 1px solid var(--sl-color-tab-default-border); - border-inline-end: 0; - border-inline-start: 0; - border-radius: 0; - box-shadow: var(--_button-shadow); - inset-inline-end: 0; - margin: var(--_wrapper-spacing) 0; - padding-block: 0; - padding-inline: var(--_button-inline-padding); - position: sticky; - z-index: 1; +:host([vertical]) { + flex-direction: row; - sl-icon { - --sl-icon-fill-default: var(--_button-color); + [part='container'] { + border-block-end: 0; + border-inline-end: var(--_border); + display: inline-flex; + flex: 0 0 auto; + flex-direction: column; + max-inline-size: var(--_tablist-vertical-max-width); } - &:hover { - background-color: var(--sl-color-select-item-hover-background); - - sl-icon { - --sl-icon-fill-default: var(--_button-color-hover); - } + [part='wrapper'] { + block-size: 100%; + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 1fr) auto; } - &:active { - background-color: var(--sl-color-select-item-active-background); + .fade { + block-size: var(--_fade-size); + inline-size: 100%; + inset: auto 0; + } - sl-icon { - --sl-icon-fill-default: var(--_button-color-active); - } + .fade-start { + background: linear-gradient(0deg, transparent, var(--_background)); + inset-block-start: 0; } - &:focus-visible { - border-radius: var(--_focus-radius); + .fade-end { + background: linear-gradient(180deg, transparent, var(--_background)); + inset-block-end: 0; } -} -:host([vertical]) { - flex-direction: row; + sl-menu-button { + padding-block-start: var(--_menu-button-inline-padding); + padding-inline-start: 0; - .container { - display: flex; - flex: 0 0 auto; - flex-direction: column; - max-inline-size: var(--_tablist-vertical-max-width); + &::part(button) { + flex: 1; + } } - .wrapper { + [part='scroller'] { display: flex; flex: 1 1 auto; flex-direction: column; + overflow: clip scroll; overscroll-behavior-y: contain; scroll-snap-points-y: repeat(100%); } - [role='tablist'] { - border-block-end: 0; - border-inline-end: var(--_border-bottom); - flex: 0 0 auto; + [part='tablist'] { flex-direction: column; + inline-size: 100%; } .indicator { - block-size: 1px; + block-size: 100px; inline-size: var(--_indicator-size); inset: 0 0 auto auto; transform-origin: center top; } ::slotted(sl-tab) { - justify-content: flex-start; + flex-shrink: 1; + justify-content: start; } - ::slotted(sl-tab-panel) { + [part='panels'] { padding: var(--_panel-vertical-padding); } +} - sl-button { - border-block-end: 0; - border-block-start: 0; - border-inline-end: 1px solid var(--sl-color-tab-default-border); - box-shadow: var(--_button-vertical-shadow); - inset-block-end: 0; - margin: 0 var(--_wrapper-spacing); - padding-block: var(--_button-inline-padding); - padding-inline: 0; - z-index: 1; - } +[part='container'] { + border-block-end: var(--_border); + display: flex; + inline-size: 100%; } -:host(:not([vertical])) [role='tablist'] { - ::slotted(sl-tab) { - white-space: nowrap; +[part='wrapper'] { + display: grid; + grid-template-columns: 1fr auto; + inline-size: 100%; +} + +.fade-container { + display: flex; + justify-content: var(--_tabs-alignment); + min-inline-size: 0; + position: relative; +} + +.fade { + block-size: 100%; + inline-size: var(--_fade-size); + inset-block: 0; + pointer-events: none; + position: absolute; + visibility: hidden; + z-index: 1; // Make sure the fade is above the scroller +} + +.fade-start { + background: linear-gradient(-90deg, transparent, var(--_background)); + inset-inline-start: 0; +} + +.fade-end { + background: linear-gradient(90deg, transparent, var(--_background)); + inset-inline-end: 0; +} + +[part='scroller'] { + display: flex; + justify-content: var(--_tabs-alignment); + overflow: scroll clip; + overscroll-behavior-x: contain; + scroll-snap-points-x: repeat(100%); + scroll-snap-type: x mandatory; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; } - .indicator { - block-size: var(--_indicator-size); - inline-size: 1px; - inset: auto auto 0 0; - transform-origin: center left; + @media (prefers-reduced-motion: no-preference) { + scroll-behavior: smooth; } } -:host([alignment='filled']:not([vertical])) [role='tablist'] { - ::slotted(sl-tab) { - inline-size: 100%; - } +[part='tablist'] { + background: var(--_background); + display: flex; + inline-size: fit-content; + justify-content: var(--_tabs-alignment); + position: relative; } .indicator { - background-color: var(--_indicator-color); + background: var(--_indicator-color); + block-size: var(--_indicator-size); + inline-size: 100px; + inset: auto auto 0 0; position: absolute; + transform-origin: center left; + transition-property: scale, translate; + transition-timing-function: ease-in-out; /** z-index is set to 2 to make sure the indicator is above the tab, when that is selected, focused or hovered */ z-index: 2; -} -@media (prefers-reduced-motion: no-preference) { - .indicator { - transition: transform var(--_indicator-animation-duration) ease-in-out; + @media (prefers-reduced-motion: no-preference) { + transition-duration: var(--_indicator-animation-duration); } } -div[popover] { - background-color: var(--_listbox-background); - border: var(--_listbox-border); - border-radius: var(--_listbox-border-radius); - box-shadow: var(--_listbox-box-shadow); - box-sizing: border-box; - display: none; - flex-direction: column; - gap: var(--_listbox-gap); - margin: 0; - overflow: hidden auto; // because of firefox issue: https://bugzilla.mozilla.org/show_bug.cgi?id=764076 - padding: var(--_listbox-padding); - transition: opacity 0.2s cubic-bezier(0.25, 0, 0.3, 1); - - @supports not selector(:popover-open) { - position: fixed; - } - - /* stylelint-disable-next-line selector-class-pattern */ - &:where(:popover-open, .\\:popover-open) { - /* stylelint-disable-next-line max-nesting-depth, scss/at-rule-no-unknown */ - @starting-style { - display: flex; - opacity: 0; - } +::slotted(sl-tab:last-of-type):hover { + position: relative; + z-index: 2; +} - display: flex; - opacity: 1; - } +sl-menu-button { + --sl-menu-max-inline-size: var(--sl-tab-group-menu-max-inline-size, min(100vw, 200px)); + --sl-menu-min-inline-size: var(--sl-tab-group-menu-min-inline-size, 100px); + + align-self: center; + padding-inline-start: var(--_menu-button-inline-padding); + position: relative; +} + +[part='panels'] { + padding: var(--_panel-horizontal-padding); } diff --git a/packages/components/tabs/src/tab-group.spec.ts b/packages/components/tabs/src/tab-group.spec.ts index 87b1dbaae8..2e325da9c0 100644 --- a/packages/components/tabs/src/tab-group.spec.ts +++ b/packages/components/tabs/src/tab-group.spec.ts @@ -1,299 +1,319 @@ import { expect, fixture } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import { html } from 'lit'; +import { spy } from 'sinon'; import '../register.js'; -import { TabGroup } from './tab-group.js'; +import { TabGroup, type TabsAlignment } from './tab-group.js'; describe('sl-tab-group', () => { let el: TabGroup; - describe('empty', () => { + describe('defaults', () => { beforeEach(async () => { - el = await fixture(html``); + el = await fixture(html` + + Tab 1 + Tab 2 + Tab 3 + Panel 1 + Panel 2 + Panel 3 + + `); + + // We need to wait for the RovingTabindexController to do its thing + await new Promise(resolve => setTimeout(resolve, 100)); }); it('should not break', () => { expect(el).shadowDom.to.equalSnapshot(); }); - it('should have horizontal layout by default', () => { + it('should have a horizontal layout', () => { expect(el).not.to.have.attribute('vertical'); + expect(el.vertical).not.to.be.true; }); - it('should have start alignment by default', () => { - expect(el).to.have.attribute('alignment', 'start'); + it('should have a vertical layout when set', async () => { + el.vertical = true; + await el.updateComplete; + + expect(el).to.have.attribute('vertical'); }); - }); - describe('multiple panels', () => { - describe('no selected, no disabled', () => { - beforeEach(async () => { - el = await fixture(html` - - Tab 1 - Tab 2 - Tab 3 - Panel 1 - Panel 2 - Panel 3 - - `); - }); + it('should align tabs to start', () => { + expect(el).not.to.have.attribute('align-tabs'); + expect(el).to.have.style('--_tabs-alignment', 'start'); + expect(el.alignTabs).to.be.undefined; + }); - it('should render correctly', () => { - expect(el).shadowDom.to.equalSnapshot(); + ['start', 'center', 'end', 'stretch'].forEach(align => { + it(`should support ${align} alignment of tabs`, async () => { + el.alignTabs = align as TabsAlignment; + await el.updateComplete; + + expect(el).to.have.attribute('align-tabs', align); + expect(el).to.have.style('--_tabs-alignment', align); }); + }); - it('should select the first tab by default', () => { - const tabs = el.querySelectorAll('sl-tab[selected]'), - panels = el.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); + it('should link the tabs to the panels', () => { + const tabs = el.querySelectorAll('sl-tab'), + panels = el.querySelectorAll('sl-tab-panel'); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 1'); - expect(tabs[0]).to.have.attribute('aria-controls', 'sl-tab-group-4-panel-1'); - expect(panels.length).to.equal(1); - expect(panels[0].innerHTML).to.equal('Panel 1'); - expect(panels[0]).to.have.attribute('aria-labelledby', 'sl-tab-group-4-tab-1'); + tabs.forEach((tab, i) => { + expect(tab).to.have.attribute('id'); + expect(tab).to.have.attribute('aria-controls', panels[i].id); + expect(panels[i]).to.have.attribute('id'); + expect(panels[i]).to.have.attribute('aria-labelledby', tab.id); }); + }); - it('should handle the selecting of tabs by keyboard correctly', async () => { - (el.querySelector('sl-tab:nth-of-type(2)') as HTMLElement)?.focus(); - await sendKeys({ press: 'Space' }); + it('should have a tablist with role tablist', () => { + const tablist = el.renderRoot.querySelector('[part="tablist"]'); - let tabs = el.querySelectorAll('sl-tab[selected]'), - panels = el.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); + expect(tablist).to.exist; + expect(tablist).to.have.attribute('role', 'tablist'); + }); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 2'); - expect(panels.length).to.equal(1); - expect(panels[0].innerHTML).to.equal('Panel 2'); + it('should not have a menu button', () => { + const menuButton = el.renderRoot.querySelector('sl-menu-button'); - (el.querySelector('sl-tab:nth-of-type(3)') as HTMLElement)?.focus(); - await sendKeys({ press: 'Enter' }); + expect(menuButton).not.to.exist; + }); - tabs = el.querySelectorAll('sl-tab[selected]'); + it('should select the first tab by default', () => { + const tabs = el.querySelectorAll('sl-tab[selected]'), panels = el.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 3'); - expect(panels.length).to.equal(1); - expect(panels[0].innerHTML).to.equal('Panel 3'); + expect(tabs).to.have.lengthOf(1); + expect(tabs[0]).to.have.text('Tab 1'); + expect(panels).to.have.lengthOf(1); + expect(panels[0]).to.have.text('Panel 1'); + }); - (el.querySelector('sl-tab:nth-of-type(1)') as HTMLElement)?.focus(); - await sendKeys({ press: 'r' }); + it('should select the second tab when clicked', () => { + el.querySelector('sl-tab:nth-of-type(2)')?.click(); - tabs = el.querySelectorAll('sl-tab[selected]'); + const tabs = el.querySelectorAll('sl-tab[selected]'), panels = el.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 3'); - expect(panels.length).to.equal(1); - expect(panels[0].innerHTML).to.equal('Panel 3'); - }); - it('should select the right tab on click', async () => { - (el.querySelector('sl-tab:nth-of-type(2)') as HTMLElement).click(); - await el.updateComplete; + expect(tabs).to.have.lengthOf(1); + expect(tabs[0]).to.have.text('Tab 2'); + expect(panels).to.have.lengthOf(1); + expect(panels[0]).to.have.text('Panel 2'); + }); - const tabs = el.querySelectorAll('sl-tab[selected]'), - panels = el.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); + it('should emit an sl-tab-change event when the tab changes', () => { + const onTabChange = spy(); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 2'); - expect(panels.length).to.equal(1); - expect(panels[0].innerHTML).to.equal('Panel 2'); + let selectedTabIndex = -1; + el.addEventListener('sl-tab-change', event => { + onTabChange(); + + selectedTabIndex = event.detail; }); + + el.querySelector('sl-tab:nth-of-type(2)')?.click(); + + expect(onTabChange).to.have.been.calledOnce; + expect(selectedTabIndex).to.equal(1); }); - describe('no selected, first disabled', () => { - beforeEach(async () => { - el = await fixture( - html` - Tab 1 - Tab 2 - Tab 3 - Panel 1 - Panel 2 - Panel 3 - ` - ); - }); + it('should support keyboard navigation using the arrow keys', async () => { + const tabs = el.querySelectorAll('sl-tab'); - it('should render correctly', () => { - expect(el).shadowDom.to.equalSnapshot(); - }); + tabs[0].focus(); + expect(document.activeElement).to.equal(tabs[0]); - it('should select the first tab by default', () => { - const tabs = el.querySelectorAll('sl-tab[selected]'), - panels = el.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); + await sendKeys({ press: 'ArrowRight' }); + expect(document.activeElement).to.equal(tabs[1]); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 2'); - expect(panels.length).to.equal(1); - expect(panels[0].innerHTML).to.equal('Panel 2'); - }); + // Third tab is disabled, so it should be skipped + await sendKeys({ press: 'ArrowRight' }); + expect(document.activeElement).to.equal(tabs[0]); + + // Third tab is disabled, so it should be skipped + await sendKeys({ press: 'ArrowLeft' }); + expect(document.activeElement).to.equal(tabs[1]); }); - describe('second selected, last disabled', () => { - beforeEach(async () => { - el = await fixture( - html` - Tab 1 - Tab 2 - Tab 3 - Panel 1 - Panel 2 - Panel 3 - ` - ); - }); + it('should select a different tab when pressing the Enter key', async () => { + const first = el.querySelector('sl-tab:first-of-type'); - it('should render correctly', () => { - expect(el).shadowDom.to.equalSnapshot(); - }); + first?.focus(); - it('should select the first tab by default', () => { - const tabs = el.querySelectorAll('sl-tab[selected]'), - panels = el.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: 'Enter' }); + await el.updateComplete; + await new Promise(resolve => setTimeout(resolve)); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 2'); - expect(panels.length).to.equal(1); - expect(panels[0].innerHTML).to.equal('Panel 2'); - }); - }); - }); + const selectedTab = el.querySelector('sl-tab[selected]') as HTMLElement, + selectedPanel = el.querySelector('sl-tab-panel[aria-hidden="false"]') as HTMLElement; - describe('single panel', () => { - describe('no selected, no disabled', () => { - beforeEach(async () => { - el = await fixture( - html` - Tab 1 - Tab 2 - Tab 3 - Panel 1 - ` - ); - }); + expect(selectedTab).to.have.text('Tab 2'); + expect(selectedPanel).to.have.text('Panel 2'); + }); - it('should render correctly', () => { - expect(el).shadowDom.to.equalSnapshot(); - }); + it('should select a different tab when pressing the Space key', async () => { + const first = el.querySelector('sl-tab:first-of-type'); - it('should select the first tab by default', () => { - const tabs = el.querySelectorAll('sl-tab[selected]'), - panels = el.querySelectorAll('sl-tab-panel'); + first?.focus(); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 1'); - expect(tabs[0]).to.have.attribute('aria-controls', 'sl-tab-group-12-panel-1'); + await sendKeys({ press: 'ArrowLeft' }); + await sendKeys({ press: 'Space' }); - expect(panels.length).to.equal(1); - expect(panels[0]).to.have.attribute('aria-labelledby', 'sl-tab-group-12-tab-1'); - }); + const selectedTab = el.querySelector('sl-tab[selected]') as HTMLElement, + selectedPanel = el.querySelector('sl-tab-panel[aria-hidden="false"]') as HTMLElement; - it('should select the right tab on click', async () => { - (el.querySelector('sl-tab:nth-of-type(2)') as HTMLElement).click(); - await el.updateComplete; + expect(selectedTab).to.have.text('Tab 2'); + expect(selectedPanel).to.have.text('Panel 2'); + }); + }); - const tabs = el.querySelectorAll('sl-tab[selected]'), - panels = el.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); + describe('disabled', () => { + beforeEach(async () => { + el = await fixture(html` + + Tab 1 + Tab 2 + Tab 3 + Panel 1 + Panel 2 + Panel 3 + + `); + }); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 2'); - expect(panels.length).to.equal(1); - expect(panels[0]).to.have.attribute('aria-labelledby', 'sl-tab-group-13-tab-2'); - }); + it('should select the second tab by default', () => { + expect(el.querySelector('sl-tab[selected]')).to.have.text('Tab 2'); + expect(el.querySelector('sl-tab-panel[aria-hidden="false"]')).to.have.text('Panel 2'); }); }); - describe('with dropdown menu', () => { - let element: TabGroup; - let container: HTMLElement; - - const showListbox = async () => { - await element.updateComplete; - return await new Promise(resolve => setTimeout(resolve, 700)); - }; - - describe('with more button in a small container', () => { - beforeEach(async () => { - element = await fixture(html` - - Tab 1 - Tab 2 - Tab 3 - Panel 1 - Panel 2 - Panel 3 - - `); - container = element.shadowRoot?.querySelector('.container') as HTMLElement; - }); + describe('selected', () => { + beforeEach(async () => { + el = await fixture(html` + + Tab 1 + Tab 2 + Tab 3 + Panel 1 + Panel 2 + Panel 3 + + `); + }); - it('should render correctly', () => { - expect(element).shadowDom.to.equalSnapshot(); - }); + it('should select the second tab by default', () => { + expect(el.querySelector('sl-tab[selected]')).to.have.text('Tab 2'); + expect(el.querySelector('sl-tab-panel[aria-hidden="false"]')).to.have.text('Panel 2'); + }); + }); - it('should not show the listbox by default', () => { - const popover = element.shadowRoot?.querySelector('[popover]') as HTMLElement; + describe('links', () => { + beforeEach(async () => { + el = await fixture(html` + + Tab 1 + Tab 2 + + `); + }); - expect(popover.getBoundingClientRect().width).to.equal(0); - expect(popover.getBoundingClientRect().height).to.equal(0); - }); + it('should wrap the tabs content in a link tag with href', () => { + const tabs = Array.from(el.querySelectorAll('sl-tab')).map(tab => tab.renderRoot.querySelector('a')?.href); - it('should show the more button', async () => { - await showListbox(); - await element.updateComplete; - const slBtn = container.querySelector('sl-button'); + expect(tabs).to.eql(['javascript:void(0)', undefined]); + }); + }); - expect(slBtn).to.exist; - }); + describe('only tabs', () => { + beforeEach(async () => { + el = await fixture(html` + + Tab 1 + Tab 2 + + `); + }); - it('should show the listbox on click on the more button', async () => { - await showListbox(); - await element.updateComplete; - const slBtn = container.querySelector('sl-button'), - clickEvent = new Event('click'); + it('should not have put aria-controls attributes on the tabs', () => { + const noControls = Array.from(el.querySelectorAll('sl-tab')).every(tab => !tab.hasAttribute('aria-controls')); + + expect(noControls).to.be.true; + }); + }); - slBtn?.dispatchEvent(clickEvent); + describe('horizontal overflow', () => { + beforeEach(async () => { + el = await fixture(html` + + Tab 1 + Tab 2 + Tab 3 + Panel 1 + Panel 2 + Panel 3 + + `); + + // We need to wait for the RovingTabindexController to do its thing + await new Promise(resolve => setTimeout(resolve, 10)); + }); - const popover = element.shadowRoot?.querySelector('[popover]') as HTMLElement; + it('should have a menu button', () => { + const menuButton = el.renderRoot.querySelector('sl-menu-button'); - expect(popover.getBoundingClientRect().width).not.to.equal(0); - expect(popover.getBoundingClientRect().height).not.to.equal(0); - }); + expect(menuButton).to.exist; + }); - it('should handle the selecting of tabs by keyboard in the listbox correctly', async () => { - await showListbox(); - await element.updateComplete; - const slBtn = container.querySelector('sl-button'), - clickEvent = new Event('click'); + it('should have menu items for all the tabs', () => { + const menuItems = Array.from(el.renderRoot.querySelectorAll('sl-menu-item')).map(menuItem => + menuItem.textContent?.trim() + ); - slBtn?.dispatchEvent(clickEvent); + expect(menuItems).to.eql(['Tab 1', 'Tab 2', 'Tab 3']); + }); - await new Promise(resolve => setTimeout(resolve, 800)); + it('should disable the menu items for disabled tabs', () => { + const menuItems = Array.from(el.renderRoot.querySelectorAll('sl-menu-item')).map(menuItem => menuItem.disabled); - const popover = element.shadowRoot?.querySelector('[popover]') as HTMLElement; + expect(menuItems).to.eql([false, false, true]); + }); - (popover.querySelector('sl-tab:nth-of-type(2)') as HTMLElement)?.focus(); + it('should select the tab when clicking a menu item', () => { + el.renderRoot.querySelector('sl-menu-item:nth-of-type(2)')?.click(); - await sendKeys({ press: 'ArrowRight' }); - await element.updateComplete; + const selectedTab = el.querySelector('sl-tab[selected]') as HTMLElement, + selectedPanel = el.querySelector('sl-tab-panel[aria-hidden="false"]') as HTMLElement; - await sendKeys({ press: 'Enter' }); - await element.updateComplete; + expect(selectedTab).to.have.text('Tab 2'); + expect(selectedPanel).to.have.text('Panel 2'); + }); + }); - const tabs = element.querySelectorAll('sl-tab[selected]'), - panels = element.querySelectorAll('sl-tab-panel[aria-hidden="false"]'); + describe('vertical overflow', () => { + beforeEach(async () => { + el = await fixture(html` + + Tab 1 + Tab 2 + Tab 3 + Panel 1 + Panel 2 + Panel 3 + + `); + + // We need to wait for the RovingTabindexController to do its thing + await new Promise(resolve => setTimeout(resolve, 10)); + }); - await element.updateComplete; + it('should have a menu button', () => { + const menuButton = el.renderRoot.querySelector('sl-menu-button'); - expect(tabs.length).to.equal(1); - expect(tabs[0].innerHTML).to.equal('Tab 2'); - expect(panels.length).to.equal(1); - expect(panels[0].innerHTML).to.equal('Panel 2'); - }); + expect(menuButton).to.exist; }); }); }); diff --git a/packages/components/tabs/src/tab-group.stories.ts b/packages/components/tabs/src/tab-group.stories.ts index 34501addf9..c66c8445ee 100644 --- a/packages/components/tabs/src/tab-group.stories.ts +++ b/packages/components/tabs/src/tab-group.stories.ts @@ -1,276 +1,303 @@ import '@sl-design-system/badge/register.js'; +import '@sl-design-system/button/register.js'; import '@sl-design-system/icon/register.js'; -import { type StoryObj } from '@storybook/web-components'; -import { html } from 'lit'; +import { type Meta, type StoryObj } from '@storybook/web-components'; +import { type TemplateResult, html } from 'lit'; import '../register.js'; -import { type Tab } from './tab.js'; +import { type TabGroup } from './tab-group.js'; +import { type TabPanel } from './tab-panel.js'; + +type Props = Pick & { + tabs?(): TemplateResult; + tabPanels?(): TemplateResult; +}; +type Story = StoryObj; export default { title: 'Components/Tab Group', args: { - vertical: false, - alignment: 'start' + alignTabs: 'start', + vertical: false }, argTypes: { - alignment: { + alignTabs: { control: 'inline-radio', - options: ['start', 'filled'] + options: ['start', 'center', 'end', 'stretch'] + }, + tabs: { + table: { + disable: true + } + }, + tabPanels: { + table: { + disable: true + } } + }, + render: ({ alignTabs, tabs, tabPanels, vertical }) => { + return html`${tabs?.()}${tabPanels?.()}`; } -}; +} satisfies Meta; -const activateTab = (index: number): void => { - const tabs: Tab[] = Array.from(document.querySelectorAll('#externalInteraction sl-tab')); - if (tabs[index]) { - document.querySelector('#externalInteraction sl-tab[selected]')?.removeAttribute('selected'); - tabs[index].setAttribute('selected', 'true'); +export const Basic: Story = { + args: { + tabs: () => html` + First tab + Second tab + Disabled + Last tab that is longer than the rest + `, + tabPanels: () => html` + Contents tab 1 + Contents tab 2 + Contents tab 3 + Contents tab 4 + ` } }; -const tabChange = (event: CustomEvent): void => { - const output = (document.querySelector('.sb-errordisplay_code') || - document.createElement('pre')) as HTMLOutputElement; - - event.preventDefault(); - document.querySelector('#output')?.after(output); - - output.textContent = (event.detail as number)?.toString(); +export const InitialSelected: Story = { + args: { + ...Basic.args, + tabs: () => html` + First tab + Second tab + Third tab + Last tab that is longer than the rest + ` + } }; -const createLipsumParagraphs = (paragraphs: number): string => { - const text = [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam laoreet eget leo pretium congue. Aliquam pretium magna non varius mollis. Ut luctus finibus lectus eu dignissim. Vivamus tempus aliquam mauris eget egestas. Sed condimentum erat eget urna mollis finibus. Pellentesque eu urna eu est viverra porta. Nam at diam mollis enim posuere condimentum at vitae ex. Donec pulvinar suscipit turpis id aliquet. Sed nec neque eget purus ultrices porta. In pharetra velit sed neque gravida dignissim. Praesent vitae felis risus. Fusce id lobortis odio, a pretium mi.', - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus et convallis erat. Cras gravida hendrerit sapien ut sodales. Cras venenatis commodo tristique. In hac habitasse platea dictumst. Integer ut tincidunt nisi. Sed gravida tristique nisl. Suspendisse blandit orci sem, non pulvinar quam lacinia ac. Proin tellus sapien, ultrices at lorem vel, pellentesque sagittis ligula. Phasellus eget varius nulla. Suspendisse vitae nunc arcu. Integer et lectus semper, molestie risus ut, ultrices risus. Aenean et sapien non purus ultricies porta id ut sem. Curabitur non orci quis nisl iaculis laoreet.', - 'Phasellus viverra tristique metus nec vulputate. Curabitur in augue ut eros sagittis auctor. Donec odio ante, egestas vitae turpis id, congue faucibus magna. Donec tristique ante velit, at sollicitudin arcu placerat in. In dignissim libero erat, ornare euismod orci tincidunt nec. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aliquam eget vehicula diam, non efficitur lectus. Sed volutpat elit quis purus interdum, non venenatis massa luctus.', - 'Aenean elit enim, condimentum id tincidunt sit amet, accumsan ac ipsum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed condimentum augue et massa molestie, vel lobortis quam posuere. Nunc efficitur, diam ut sodales finibus, turpis enim tristique tortor, a ullamcorper urna leo a mi. Vestibulum turpis leo, maximus eu turpis quis, euismod aliquet odio. Duis quis neque sollicitudin, ultricies elit et, tempor libero. Nullam in turpis consequat, dictum ipsum et, consequat dolor. Maecenas porttitor auctor arcu, a lacinia ante dictum quis. Integer auctor venenatis semper. Praesent elementum iaculis velit, a ultrices neque lobortis ut. Pellentesque a tempus nisi. Phasellus eget porttitor sem, ut iaculis ex. Nam at dapibus erat, nec convallis lacus. Sed est quam, ornare id vestibulum ut, elementum id sem.', - 'In dui nunc, pharetra eget lorem vitae, interdum euismod tortor. Integer maximus semper nisl quis ullamcorper. Curabitur sit amet tortor gravida, suscipit est eu, sagittis felis. Sed vitae tincidunt sapien, ac volutpat augue. Duis tempor ex id pulvinar varius. Nullam pretium augue in scelerisque tempor. Integer et ex eu nisl consequat pretium et in lorem.', - 'Donec turpis risus, viverra vitae ultricies et, consectetur vitae ante. Aenean malesuada in urna sed auctor. Nulla libero tellus, maximus in est sit amet, dignissim efficitur eros. In a rhoncus lacus. Mauris a nunc facilisis, elementum lorem ut, finibus ex. Vestibulum et scelerisque magna. Mauris felis nunc, lacinia eget consequat tempor, mattis et neque. Ut fringilla urna eu sem gravida, eu vulputate eros accumsan. Etiam nec erat ipsum. Aenean non lorem mauris. Curabitur cursus est vitae turpis venenatis suscipit. Donec semper, ex in pretium tempor, metus ipsum tincidunt nulla, ut eleifend sapien nulla eu tortor.', - 'Curabitur accumsan augue risus, sit amet mollis arcu ullamcorper non. Ut viverra semper nisi et finibus. Nulla porta vehicula arcu eu venenatis. Donec ac turpis ornare quam fermentum mattis ac vel turpis. Quisque aliquam libero massa, ac tempus nibh imperdiet at. Sed gravida quis nibh nec dapibus. Mauris venenatis elit et viverra convallis. Nullam blandit eros id ligula ornare dignissim.', - 'Nunc commodo enim a lorem molestie elementum. Fusce blandit justo urna, sit amet placerat magna suscipit quis. Donec scelerisque metus vitae eros pharetra consequat. Aliquam vestibulum et sapien sed dictum. Nunc ultrices sit amet lacus at consequat. Integer a faucibus turpis. Nulla eu eros rutrum nulla aliquam tempor at vel augue. Donec a purus eu libero sollicitudin elementum tempus vel arcu. Aliquam pellentesque, odio in finibus ultricies, justo elit iaculis libero, sit amet varius massa dui at libero. Praesent nunc neque, gravida vel purus at, vulputate tincidunt lectus. Mauris sit amet augue nisi. Duis ac metus porttitor, mattis nulla accumsan, ullamcorper est. Vivamus ac hendrerit orci, eget tincidunt ligula. Morbi ac nunc sit amet tellus euismod aliquam. Donec iaculis metus quis tortor vulputate dictum. Integer semper cursus tincidunt.', - 'Donec posuere est vel felis consectetur, sit amet pharetra erat volutpat. Aliquam suscipit fermentum justo, tincidunt tincidunt enim hendrerit id. Praesent eleifend, ipsum ac volutpat ullamcorper, elit purus tristique lorem, in maximus mauris dolor volutpat orci. Phasellus gravida ut turpis quis vehicula. Cras mattis, velit sed sagittis rhoncus, urna est interdum massa, eget ultricies justo erat ut tortor. Curabitur rutrum lorem augue. Maecenas ipsum odio, iaculis ac placerat ut, sollicitudin vel nunc. Aliquam erat volutpat. Aliquam vitae velit placerat, pharetra lectus a, semper risus. Vivamus in lobortis nibh, in finibus risus. Aenean condimentum dapibus tortor, eget condimentum dolor commodo nec. Morbi eu dui lectus. Nullam congue ullamcorper orci. Ut laoreet rutrum sodales. In hac habitasse platea dictumst. Sed magna libero, ultrices vitae facilisis a, lobortis sed turpis.', - 'Nullam eu orci nunc. Sed at suscipit ante. Mauris nunc dui, pulvinar eu nibh nec, consequat semper felis. In accumsan consequat malesuada. Proin sem ex, porta ut massa ac, feugiat luctus nisi. Proin faucibus maximus nibh id vehicula. Mauris vel nisl condimentum, tristique nisl et, euismod purus. Integer ut dui libero. Nam ultricies felis et erat vulputate, non egestas nisl accumsan. Phasellus imperdiet tempus nulla sed vestibulum. Nunc vulputate dui mi, sed pretium diam vehicula a. In in enim pharetra, aliquet magna et, aliquam dolor.' - ]; - return text.splice(0, paragraphs).join(' '); -}; +export const Lazy: Story = { + render: ({ alignTabs, vertical }) => { + const onTabChange = (event: Event & { target: TabGroup }) => { + document.querySelector('strong')!.textContent = event.target.selectedTab!.textContent; + }; -export const API: StoryObj = { - render: ({ vertical, alignment }) => { return html` - - - - Tab 1 - Tab 1 subtitle - -

Contents tab 1

- - - - Tab 2 - Tab 2 subtitle - 4 - - -
Contents tab 2
-
- - - - Tab 3 - Tab 3 subtitle - 100 - - -
Contents tab 3
-
- - - - Tab 4 - - Contents tab 4 - - - - Tab 5 - - Contents tab 5 + + First tab + Second tab + Third tab + Last tab that is longer than the rest +

+ This example does not have any tab panels. Instead it listens for the sl-tab-change event on the + tab group and then programmatically updates this text. You can use this event to lazy load a tab's content. +

+

The selected tab is:

`; } }; -export const LongTitles: StoryObj = { - render: ({ vertical, alignment }) => - html` - This is the first tab a very looong example of the tab - Contents tab 1 ${createLipsumParagraphs(10)} - - This is the second tab - Contents tab 2 ${createLipsumParagraphs(3)} - - This is the third tab - Contents tab 3 ${createLipsumParagraphs(2)} - - This is the fourth tab (disabled) - Contents tab 4 - - This is the fifth tab - Contents tab 5 - - This is the sixth tab - Contents tab 6 - - This is the seventh tab - Contents tab 7 - - This is the eighth tab - Contents tab 8 - - This is the nineth tab - Contents tab 9 - - This is the tenth tab - Contents tab 10 - ` -}; - -export const StickyTabs: StoryObj = { - render: ({ vertical, alignment }) => - html` -
- - This is the first tab - Contents tab 1 ${createLipsumParagraphs(10)} - - This is the second tab - Contents tab 2 ${createLipsumParagraphs(3)} - - This is the third tab - Contents tab 3 ${createLipsumParagraphs(2)} - - This is the fourth tab (disabled) - Contents tab 4 - - This is the fifth tab - Contents tab 5 - - This is the sixth tab - Contents tab 6 - - This is the seventh tab - Contents tab 7 - - This is the eighth tab - Contents tab 8 - - This is the nineth tab - Contents tab 9 - - This is the tenth tab - Contents tab 10 - -
` -}; - -export const VerticalInSmallContainer: StoryObj = { - render: ({ alignment }) => - html` -
- - This is the first tab - Contents tab 1 ${createLipsumParagraphs(10)} - - This is the second tab - Contents tab 2 ${createLipsumParagraphs(3)} - - This is the third tab - Contents tab 3 ${createLipsumParagraphs(2)} - - This is the fourth tab (disabled) - Contents tab 4 - - This is the fifth tab - Contents tab 5 - - This is the sixth tab - Contents tab 6 - - This is the seventh tab - Contents tab 7 - - This is the eighth tab - Contents tab 8 - - This is the nineth tab - Contents tab 9 - - This is the tenth tab - Contents tab 10 - -
` +export const Links: Story = { + args: { + tabs: () => html` + First tab + Second tab + Disabled + Last tab that is longer than the rest +

+ The tabs in this example all have links. There are no tab panels present in this example. If you right click a + tab, you will notice the browser will prompt you to open the link in a new tab. This can be useful if you want + to use a router in your application for the tabs. +

+ ` + } }; -export const ExternalInteraction: StoryObj = { - render: ({ vertical }) => html` - - Tab 1 +export const OverflowHorizontal: Story = { + args: { + tabs: () => html` + Tab 1 Tab 2 - Tab 3 + Tab 3 Tab 4 Tab 5 - -
- activateTab(1)}> Activate tab 2 - activateTab(3)}> Activate tab 4 -

Active tab:

+ Tab 6 + Tab 7 + Tab 8 + Tab 9 + Tab 10 + Tab 11 + Tab 12 + Tab 13 + Tab 14 + Tab 15 + Tab 16 + Tab 17 + Tab 18 + Tab 19 + Tab 20 + ` + } +}; + +export const OverflowVertical: Story = { + args: { + ...OverflowHorizontal.args, + vertical: true + }, + render: ({ alignTabs, tabs, vertical }) => html` + + ${tabs?.()} ` }; -export const SingleTab: StoryObj = { - render: ({ vertical }) => html` - - Tab 1 - Tab 2 - Tab 3 - Tab 4 - Tab 5 +export const Responsive: Story = { + parameters: { + layout: 'fullscreen' + }, + render: ({ alignTabs }) => html` + + + First tab + Second tab + Third tab + Last tab that is longer than the rest -

place your router-outlet in this panel

-

Active tab:

+ This example demonstrates how you can have a responsive tab group that adjusts to the width of the page. The + scroll container of the tabs has a max width and is centered on the page. There will always be an inline margin, + so the tabs never contact the edge of the viewport.
` }; + +export const Selected: Story = { + args: { + tabs: () => html` + First tab + Second tab + Third tab + Last tab that is longer than the rest + `, + tabPanels: () => { + const onNext = (event: Event & { target: HTMLElement }) => { + const tabGroup = event.target.closest('sl-tab-group') as TabGroup, + panel = event.target.closest('sl-tab-panel') as TabPanel, + index = Array.from(tabGroup?.querySelectorAll('sl-tab-panel') ?? []).indexOf(panel); + + tabGroup.tabs?.at(index + 1)?.setAttribute('selected', ''); + }; + + const onPrevious = (event: Event & { target: HTMLElement }) => { + const tabGroup = event.target.closest('sl-tab-group') as TabGroup, + panel = event.target.closest('sl-tab-panel') as TabPanel, + index = Array.from(tabGroup?.querySelectorAll('sl-tab-panel') ?? []).indexOf(panel); + + tabGroup.tabs?.at(index - 1)?.setAttribute('selected', ''); + }; + + return html` + + Focus next tab + + + Focus previous tab + Focus next tab + + + Focus previous tab + Focus next tab + + + Focus previous tab + + `; + } + } +}; + +export const Sticky: Story = { + render: ({ alignTabs, vertical }) => html` + + + First tab + Contents tab 1 + + Second tab + Contents tab 2 + + Disabled + Contents tab 3 + + Last tab that is longer than the rest + Contents tab 4 + + ` +}; + +export const Subtitle: Story = { + args: { + ...Basic.args, + tabs: () => html` + + + Tab 1 + Tab 1 subtitle + + + + Tab 2 + Tab 2 subtitle + + + + Tab 3 + Tab 3 subtitle + 100 + + + + Tab 4 + Tab 4 subtitle + + ` + } +}; + +export const Vertical: Story = { + args: { + ...Basic.args, + vertical: true + } +}; diff --git a/packages/components/tabs/src/tab-group.ts b/packages/components/tabs/src/tab-group.ts index 3df87d976e..2bd5e0ff57 100644 --- a/packages/components/tabs/src/tab-group.ts +++ b/packages/components/tabs/src/tab-group.ts @@ -1,10 +1,10 @@ import { localized, msg } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; -import { Button } from '@sl-design-system/button'; import { Icon } from '@sl-design-system/icon'; -import { type EventEmitter, RovingTabindexController, anchor, event, isPopoverOpen } from '@sl-design-system/shared'; +import { MenuButton, MenuItem } from '@sl-design-system/menu'; +import { type EventEmitter, RovingTabindexController, event, getScrollParent } from '@sl-design-system/shared'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; -import { property, query, queryAssignedElements, state } from 'lit/decorators.js'; +import { property, state } from 'lit/decorators.js'; import styles from './tab-group.scss.js'; import { TabPanel } from './tab-panel.js'; import { Tab } from './tab.js'; @@ -21,9 +21,7 @@ declare global { export type SlTabChangeEvent = CustomEvent; -export type TabsAlignment = 'start' | 'filled'; - -let nextUniqueId = 0; +export type TabsAlignment = 'start' | 'center' | 'end' | 'stretch'; const OBSERVER_OPTIONS: MutationObserverInit = { attributes: true, @@ -32,380 +30,392 @@ const OBSERVER_OPTIONS: MutationObserverInit = { attributeOldValue: true }; +let nextUniqueId = 0; + /** * A tab group component that can contain tabs and tab panels. * * ```html - * - * First tab - * Content of the tab 1 + * + * First tab + * Second tab * - * Second tab - * Content of the tab 2 - * + * Content of tab 1 + * Content of tab 2 + * * ``` * - * @slot default - a place for the tab group content. + * @csspart container - The container for the tabs. + * @csspart wrapper - Wraps the scroll container and menu button. + * @csspart scroller - The scroll container of the tabs. + * @csspart tablist - The tablist element which also contains the active tab indicator + * @csspart panels - The container for the tab panels. + * + * @cssprop --sl-tab-group-menu-min-inline-size - The minimum inline size of the menu. + * @cssprop --sl-tab-group-menu-max-inline-size - The maximum inline size of the menu. + * + * @slot default - Tab panels or other tab content here. + * @slot tabs - The tabs to display. */ @localized() export class TabGroup extends ScopedElementsMixin(LitElement) { - /** @private */ + /** @internal */ static get scopedElements(): ScopedElementsMap { return { - 'sl-button': Button, 'sl-icon': Icon, + 'sl-menu-button': MenuButton, + 'sl-menu-item': MenuItem, 'sl-tab': Tab, 'sl-tab-panel': TabPanel }; } - /** @private */ + /** @internal */ static override styles: CSSResultGroup = styles; + /** Unique prefix ID for each component in the light DOM. */ + #idPrefix = `sl-tab-group-${nextUniqueId++}`; + /** - * Unique ID for each tab group component present. + * Observe changes to the selected tab and update accordingly. This observer + * is necessary for changes to the selected tab that are made programmatically. + * Selected changes made by the user are handled by the click event listener. */ - #tabGroupId = `sl-tab-group-${nextUniqueId++}`; + #mutationObserver = new MutationObserver(entries => { + entries.forEach(entry => { + if (entry.attributeName === 'selected' && entry.oldValue === null) { + this.#mutationObserver?.disconnect(); - #observer?: MutationObserver; + // Update the selected tab with the observer turned off to avoid loops + this.#updateSelectedTab(entry.target as Tab); + this.#mutationObserver?.observe(this, OBSERVER_OPTIONS); + } + }); + }); + + /** + * Observe changes to the size of the tablist so: + * - we can determine when to display an overflow menu with tab items + * - we know when we need to reposition the active tab indicator + */ + #resizeObserver = new ResizeObserver(() => { + this.#shouldAnimate = false; + this.#updateSize(); + this.#shouldAnimate = true; + }); + + /** Manage keyboard navigation between tabs. */ #rovingTabindexController = new RovingTabindexController(this, { focusInIndex: (elements: Tab[]) => elements.findIndex(el => el.selected), - elements: () => (isPopoverOpen(this.listbox) ? this.#allTabs : this.tabs) || [], + elements: () => this.tabs || [], isFocusableElement: (el: Tab) => !el.disabled }); - /** All slotted tabs. */ - #allTabs: Tab[] = []; + /** Determines whether the active tab indicator should animate. */ + #shouldAnimate = false; - /** Whether more button needs to be shown */ - #showMore = false; + /** The alignment of tabs within the wrapper. */ + @property({ attribute: 'align-tabs', reflect: true }) alignTabs?: TabsAlignment; - /** Button used to show all tabs */ - #moreButton!: HTMLButtonElement; + /** The menu items to render when the tabs are overflowing. */ + @state() menuItems?: Array<{ tab: Tab; disabled?: boolean; title: string; subtitle?: string }>; - /** Observe the tablist width. */ - #sizeObserver = new ResizeObserver(() => { - this.#updateSelectionIndicator(); - }); + /** The currently selected tab. */ + @state() selectedTab?: Tab; - /** The slotted tabs. */ - @queryAssignedElements({ slot: 'tabs' }) tabs?: Tab[]; + /** Whether the menu button needs to be shown. */ + @state() showMenu = false; - /** The current tab node selected in the tab group. */ - @state() private selectedTab: Tab | null = this.#initialSelectedTab; + /** Emits when the tab has been selected/changed. */ + @event({ name: 'sl-tab-change' }) tabChangeEvent!: EventEmitter; - /** The current tab node selected in the tab listbox (dropdown). */ - @state() private selectedTabInListbox: Tab | null = this.#initialSelectedTab; + /** The slotted tabs. */ + @state() tabPanels?: TabPanel[]; - /** Emits when the tab has been selected/changed. */ - @event() tabChange!: EventEmitter; + /** The slotted tabs. */ + @state() tabs?: Tab[]; /** Renders the tabs vertically instead of the default horizontal */ @property({ type: Boolean, reflect: true }) vertical?: boolean; - /** The alignment of tabs inside sl-tab-group */ - @property({ reflect: true }) alignment: TabsAlignment = 'start'; + override connectedCallback(): void { + super.connectedCallback(); + + this.#mutationObserver.observe(this, OBSERVER_OPTIONS); - /** The listbox element with all tabs list. */ - @query('[popover]') listbox!: HTMLElement; + // We need to wait for the next frame so the element has time to render + requestAnimationFrame(() => { + const tablist = this.renderRoot.querySelector('[part="tablist"]') as Element; - /** Get the selected tab button, or the first tab button. */ - get #initialSelectedTab(): Tab | null { - return this.querySelector('sl-tab[selected]') || this.querySelector('sl-tab:not([disabled])'); + // We want to observe the size of the tablist, not the + // container or wrapper. The tablist is the element that + // changes size for example when fonts are loaded. The + // other elements do not change size while the tablist does. + this.#resizeObserver.observe(tablist); + }); } - override render(): TemplateResult { - this.#moreButton = this.renderRoot.querySelector('#more-btn') as HTMLButtonElement; - this.#allTabs = Array.from(this.querySelectorAll('sl-tab'))?.map(tab => tab.cloneNode(true)) as Tab[]; - this.#allTabs.forEach(tab => tab.classList.add('listbox-tab')); + override disconnectedCallback(): void { + this.#resizeObserver.disconnect(); + this.#mutationObserver.disconnect(); + + super.disconnectedCallback(); + } + + override updated(changes: PropertyValues): void { + super.updated(changes); + + if (changes.has('alignTabs')) { + this.#shouldAnimate = false; + this.#updateSelectionIndicator(); + this.#shouldAnimate = true; + } + // In vertical mode, we need to observe the scroller for changes in size to + // determine when we need to show the menu button. + if (changes.has('vertical')) { + const scroller = this.renderRoot.querySelector('[part="scroller"]') as HTMLElement; + + if (this.vertical) { + this.#resizeObserver.observe(scroller); + } else { + this.#resizeObserver.unobserve(scroller); + } + } + } + + override render(): TemplateResult { return html` -
-
-
- - this.#rovingTabindexController.clearElementCache()}> -
- ${this.#allTabs} +
+
+
+
+
+
+
+ + +
+ + ${this.showMenu + ? html` + + + ${this.menuItems?.map( + menuItem => html` + this.#onMenuItemClick(menuItem.tab)} ?disabled=${menuItem.disabled}> + ${menuItem.title} + + ` + )} + + ` + : nothing}
- ${this.#showMore - ? html` - - ` - : nothing}
- +
+ +
`; } - override connectedCallback(): void { - super.connectedCallback(); + #onClick(event: Event & { target: HTMLElement }): void { + const tab = event.target.closest('sl-tab'); - this.#sizeObserver.observe(this); - this.#updateSlots(); - } - - override disconnectedCallback(): void { - this.#sizeObserver.disconnect(); + if (!tab) { + return; + } - super.disconnectedCallback(); + this.#updateSelectedTab(tab); + this.#scrollToTabPanelStart(); } - override firstUpdated(): void { - this.#observer = new MutationObserver(this.#handleMutation); - this.#observer?.observe(this, OBSERVER_OPTIONS); - } + #onKeydown(event: KeyboardEvent & { target: HTMLElement }): void { + const tab = event.target.closest('sl-tab'); - override updated(changes: PropertyValues): void { - super.updated(changes); + if (tab && ['Enter', ' '].includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); - if (changes.has('alignment') || changes.has('vertical')) { - this.#updateSelectionIndicator(); + this.#updateSelectedTab(tab); + this.#scrollToTabPanelStart(); } } - #updateSlots(): void { - this.#setupTabs(); - this.#setupPanels(); + #onMenuItemClick(tab: Tab): void { + this.#updateSelectedTab(tab); } - #onClick(): void { - this.listbox.togglePopover(); - } + #onScroll(event: Event & { target: HTMLElement }): void { + let scrollStart = false, + scrollEnd = false; - #onToggle = (event: ToggleEvent): void => { - this.#rovingTabindexController.clearElementCache(); + if (this.vertical) { + const { clientHeight, scrollTop, scrollHeight } = event.target, + scrollable = scrollHeight > clientHeight; - requestAnimationFrame(() => { - if (!this.listbox || !this.selectedTab) { - return; - } - this.selectedTabInListbox = this.listbox.querySelector(`#${this.selectedTab.id}`) as Tab; - }); + scrollStart = scrollable && scrollTop > 0; + scrollEnd = scrollable && Math.round(scrollTop + clientHeight) < scrollHeight; + } else { + const { clientWidth, scrollLeft, scrollWidth } = event.target, + scrollable = scrollWidth > clientWidth; - if ( - (event.newState === 'closed' && this.listbox.matches(':popover-open')) || - (event.newState === 'closed' && this.listbox.matches('.\\:popover-open')) || - event.newState === event.oldState - ) { - event.stopPropagation(); - this.listbox.hidePopover(); + scrollStart = scrollable && scrollLeft > 0; + scrollEnd = scrollable && Math.round(scrollLeft + clientWidth) < scrollWidth; } - this.#moreButton?.setAttribute('aria-expanded', isPopoverOpen(this.listbox).toString()); - }; + this.toggleAttribute('scroll-start', scrollStart); + this.toggleAttribute('scroll-end', scrollEnd); + } - /** - * If the selected tab is selected programmatically update all the tabs. - */ - #handleMutation = (mutations: MutationRecord[]): void => { - mutations.forEach(mutation => { - if (mutation.attributeName === 'selected' && mutation.oldValue === null) { - const selectedTab = mutation.target; - this.#observer?.disconnect(); - this.#updateSelectedTab(selectedTab); - this.#observer?.observe(this, OBSERVER_OPTIONS); - } + #onTabSlotchange(event: Event & { target: HTMLSlotElement }): void { + this.tabs = event.target.assignedElements({ flatten: true }).filter((el): el is Tab => el instanceof Tab); + this.tabs.forEach((tab, index) => { + tab.id ||= `${this.#idPrefix}-tab-${index + 1}`; }); - }; - /** - * Apply accessible attributes and values to the tab buttons. - */ - #setupTabs(): void { - const tabs = this.querySelectorAll('sl-tab'); + // If no tab is selected, select the first enabled one + this.selectedTab = this.tabs.find(tab => tab.selected) || this.tabs.find(tab => !tab.disabled); - tabs.forEach((tab, index) => { - tab.setAttribute('id', `${this.#tabGroupId}-tab-${index + 1}`); - tab.setAttribute('aria-controls', `${this.#tabGroupId}-panel-${index + 1}`); + this.#rovingTabindexController.clearElementCache(); + this.#linkTabsWithPanels(); + } + + #onTabPanelSlotchange(event: Event & { target: HTMLSlotElement }): void { + this.tabPanels = event.target + .assignedElements({ flatten: true }) + .filter((el): el is TabPanel => el instanceof TabPanel); + + this.tabPanels.forEach((panel, index) => { + panel.id ||= `${this.#idPrefix}-panel-${index + 1}`; + }); + + this.#linkTabsWithPanels(); + } + + #linkTabsWithPanels(): void { + this.tabs?.forEach((tab, index) => { tab.toggleAttribute('selected', tab === this.selectedTab); + + const panel = this.tabPanels?.at(index); + + if (panel) { + tab.setAttribute('aria-controls', `${this.#idPrefix}-panel-${index + 1}`); + panel.setAttribute('aria-hidden', tab === this.selectedTab ? 'false' : 'true'); + panel.setAttribute('aria-labelledby', `${this.#idPrefix}-tab-${index + 1}`); + } else { + tab.removeAttribute('aria-controls'); + } }); } - /** - * Apply accessible attributes and values to the tab panels. - */ - #setupPanels(): void { - const panels = this.querySelectorAll('sl-tab-panel'); - const selectedPanelId = this.selectedTab?.getAttribute('aria-controls'); - const tabIndex = this.selectedTab ? Array.from(this.querySelectorAll('sl-tab')).indexOf(this.selectedTab) : 0; - - if (panels.length === 1) { - panels[0].setAttribute('id', `${this.#tabGroupId}-panel-${tabIndex + 1}`); - panels[0].setAttribute('aria-labelledby', `${this.#tabGroupId}-tab-${tabIndex + 1}`); - panels[0].setAttribute('aria-hidden', 'false'); + #scrollIntoViewIfNeeded(tab: Tab): void { + const scroller = this.renderRoot.querySelector('[part="scroller"]') as HTMLElement, + scrollerRect = scroller.getBoundingClientRect(), + tabRect = tab.getBoundingClientRect(); + + if (this.vertical) { + if (tabRect.top < scrollerRect.top) { + // The tab is above the top edge of the scroller + scroller.scrollBy({ top: tabRect.top - scrollerRect.top }); + } else if (tabRect.bottom > scrollerRect.bottom) { + // The tab is below the bottom edge of the scroller + scroller.scrollBy({ top: tabRect.bottom - scrollerRect.bottom }); + } } else { - panels.forEach((panel, index) => { - panel.setAttribute('id', `${this.#tabGroupId}-panel-${index + 1}`); - panel.setAttribute('aria-labelledby', `${this.#tabGroupId}-tab-${index + 1}`); - panel.setAttribute('aria-hidden', `${panel.getAttribute('id') !== selectedPanelId ? 'true' : 'false'}`); - }); + if (tabRect.left < scrollerRect.left) { + // The tab is to the left of the left edge of the scroller + scroller.scrollBy({ left: tabRect.left - scrollerRect.left }); + } else if (tabRect.right > scrollerRect.right) { + // The tab is to the right of the right edge of the scroller + scroller.scrollBy({ left: tabRect.right - scrollerRect.right }); + } } } - #handleTabChange(event: Event & { target: HTMLElement }): void { - /** - * Return handler if it's not a tab - */ - if (!(event.target.closest('sl-tab') instanceof Tab)) { - return; - } - // Always reset the scroll when a tab is selected. - this.scrollTo({ top: 0 }); + #scrollToTabPanelStart(): void { + const { bottom: containerBottom = 0 } = + this.renderRoot.querySelector('[part="container"]')?.getBoundingClientRect() || {}, + { top: wrapperTop = 0 } = this.renderRoot.querySelector('[part="wrapper"]')?.getBoundingClientRect() || {}, + { top = 0 } = this.renderRoot.querySelector('[part="panels"]')?.getBoundingClientRect() || {}; - this.#updateSelectedTab(event.target.closest('sl-tab') as Tab); - this.listbox.hidePopover(); + // Scroll to make sure the top of the panel is visible, but don't scroll too far + // so the tab container/wrapper may become unstuck. + getScrollParent(this)?.scrollBy({ top: top - (this.vertical ? wrapperTop : containerBottom) }); } - /** - * Update the selected tab button with attributes and values. - * Update the tab group state. - */ #updateSelectedTab(selectedTab: Tab): void { - const controls = selectedTab.getAttribute('aria-controls'); - - if (selectedTab === this.selectedTab || !controls || selectedTab.disabled) return; - - const selectedPanel = this.querySelector(`#${controls}`); - const tabIndex = Array.from(this.querySelectorAll('sl-tab')).indexOf(selectedTab); - - /** - * Reset all the selected state of the tabs, and select the clicked tab - */ - this.querySelectorAll('sl-tab').forEach((tab: Tab) => { - tab.removeAttribute('selected'); - if (tab.id === selectedTab.id) { - tab.setAttribute('selected', ''); - tab.focus(); - tab.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - this.selectedTab = tab; - } - }); - - this.selectedTabInListbox = this.listbox?.querySelector(`#${selectedTab.id}`); - - /** - * Reset all the visibility of the panels, - * and show the panel related to the selected tab - */ - const panels = this.querySelectorAll('sl-tab-panel'); + if (selectedTab !== this.selectedTab) { + this.tabs?.forEach(tab => tab.toggleAttribute('selected', tab === selectedTab)); - if (panels.length === 1) { - panels[0].setAttribute('id', `${this.#tabGroupId}-panel-${tabIndex + 1}`); - panels[0].setAttribute('aria-labelledby', `${this.#tabGroupId}-tab-${tabIndex + 1}`); - } else { - panels.forEach(panel => { - panel.setAttribute('aria-hidden', `${panel !== selectedPanel ? 'true' : 'false'}`); + this.querySelectorAll('sl-tab-panel').forEach(panel => { + panel.setAttribute('aria-hidden', selectedTab.getAttribute('aria-controls') === panel.id ? 'false' : 'true'); }); - } - this.tabChange.emit(tabIndex); + this.selectedTab = selectedTab; + this.tabChangeEvent.emit(this.tabs?.indexOf(selectedTab) ?? 0); + this.#updateSelectionIndicator(); + } - this.#updateSelectionIndicator(); + this.#scrollIntoViewIfNeeded(selectedTab); } - /** - * Handle keyboard accessible controls. - */ - #handleKeydown(event: KeyboardEvent): void { - if (isPopoverOpen(this.listbox)) { - this.#rovingTabindexController.clearElementCache(); - this.#rovingTabindexController.hostContainsFocus(); + #updateSelectionIndicator(): void { + if (!this.selectedTab) { + return; } - if (['Enter', ' '].includes(event.key)) { - event.preventDefault(); - this.scrollTo({ top: 0 }); - this.#updateSelectedTab(event.target); + const indicator = this.renderRoot.querySelector('.indicator') as HTMLElement, + tablist = this.renderRoot.querySelector('[part="tablist"]') as HTMLElement, + rect = this.selectedTab.getBoundingClientRect(); - if (isPopoverOpen(this.listbox)) { - this.listbox.hidePopover(); - } + let start = 0; + if (this.vertical) { + start = rect.top - tablist.getBoundingClientRect().top; + } else { + start = rect.left - tablist.getBoundingClientRect().left; } - } - - #updateSelectionIndicator(): void { - requestAnimationFrame(() => { - if (!this.selectedTab || !this.selectedTabInListbox) { - return; - } - if (!this.#showMore && isPopoverOpen(this.listbox)) { - this.listbox.hidePopover(); - } + indicator.style.transitionDuration = this.#shouldAnimate ? '' : '0s'; - const axis = this.vertical ? 'Y' : 'X', - indicator = this.renderRoot.querySelector('.indicator') as HTMLElement, - wrapper = this.renderRoot.querySelector('.container') as HTMLElement, - tabsWrapper = this.renderRoot.querySelector('.wrapper') as HTMLElement, - tablist = this.renderRoot.querySelector('[role="tablist"]') as HTMLElement, - tabs = this.querySelectorAll('sl-tab'); - - let totalTabsWidth = 0; - let totalTabsHeight = 0; - tabs.forEach(tab => { - totalTabsWidth += tab.offsetWidth; - totalTabsHeight += tab.offsetHeight; - }); + if (this.vertical) { + indicator.style.scale = `1 ${rect.height}`; + indicator.style.translate = `0 ${start}px`; + } else { + indicator.style.scale = `${rect.width / 100} 1`; + indicator.style.translate = `${start}px`; + } + } - this.#showMore = axis === 'X' ? totalTabsWidth > tabsWrapper.offsetWidth : totalTabsHeight > wrapper.offsetHeight; + #updateSize(): void { + const scroller = this.renderRoot.querySelector('[part="scroller"]') as HTMLElement, + tablist = this.renderRoot.querySelector('[part="tablist"]') as HTMLElement; - this.requestUpdate(); + this.showMenu = this.vertical + ? tablist.scrollHeight > scroller.offsetHeight + : tablist.scrollWidth > scroller.offsetWidth; - let start = 0; - if (axis === 'X') { - start = this.selectedTab.getBoundingClientRect().left - tablist.getBoundingClientRect().left; - } else { - start = this.selectedTab.getBoundingClientRect().top - tablist.getBoundingClientRect().top; - } + if (this.showMenu) { + this.menuItems = this.tabs?.map(tab => { + const title = Array.from(tab.childNodes) + .filter(node => node instanceof Text || (node instanceof Element && !node.slot)) + .reduce((acc, node) => acc + node.textContent?.trim() || '', ''); - // Somehow on Chromium, the offsetParent is different than on FF and Safari - // If on Chromium, take the `wrapper.offsetLeft` into account as well - if (this.selectedTab.offsetParent === wrapper) { - start += axis === 'X' ? wrapper.offsetLeft : wrapper.offsetTop; - } + const subtitle = Array.from(tab.childNodes) + .filter(node => node instanceof Element && node.slot === 'subtitle') + .reduce((acc, node) => acc + node.textContent?.trim() || '', ''); - indicator.style.transform = `translate${axis}(${start}px)`; + return { tab, disabled: tab.disabled, title, subtitle }; + }); + } else { + this.menuItems = undefined; + } - if (axis === 'X') { - indicator.style.removeProperty('height'); - indicator.style.width = `${this.selectedTab.offsetWidth}px`; - const scrollLeft = Math.max( - this.selectedTab.offsetLeft + this.selectedTab.offsetWidth / 2 - tabsWrapper.clientWidth / 2, - 0 - ); + this.selectedTab?.scrollIntoView(); - if (scrollLeft !== tabsWrapper.scrollLeft) { - tabsWrapper.scrollTo({ left: scrollLeft, behavior: 'smooth' }); - } - } else { - indicator.style.removeProperty('width'); - indicator.style.height = `${this.selectedTab.offsetHeight}px`; - } - }); + this.#updateSelectionIndicator(); } } diff --git a/packages/components/tabs/src/tab-panel.scss b/packages/components/tabs/src/tab-panel.scss index a2d48b05c7..cdb478cd9e 100644 --- a/packages/components/tabs/src/tab-panel.scss +++ b/packages/components/tabs/src/tab-panel.scss @@ -1,3 +1,7 @@ +:host { + display: block; +} + :host([aria-hidden='true']) { display: none; pointer-events: none; diff --git a/packages/components/tabs/src/tab-panel.spec.ts b/packages/components/tabs/src/tab-panel.spec.ts index 644fb5a64b..c30b76e097 100644 --- a/packages/components/tabs/src/tab-panel.spec.ts +++ b/packages/components/tabs/src/tab-panel.spec.ts @@ -14,7 +14,7 @@ describe('sl-tab-panel', () => { expect(el).shadowDom.to.equalSnapshot(); }); - it('should have the correct attributes', () => { + it('should have a tabpanel role', () => { expect(el).to.have.attribute('role', 'tabpanel'); }); }); diff --git a/packages/components/tabs/src/tab-panel.ts b/packages/components/tabs/src/tab-panel.ts index 232c31c7d5..e3e7c1e11c 100644 --- a/packages/components/tabs/src/tab-panel.ts +++ b/packages/components/tabs/src/tab-panel.ts @@ -8,19 +8,17 @@ declare global { } /** - * A tab panel component - part of the tab group component, place for a tab content. + * A tab panel component, to be used with the tab group component for your tab content. * * ```html - * - * Content of the tab - * + * Content of the tab * ``` * * @slot default - a place for the tab panel content. * */ export class TabPanel extends LitElement { - /** @private */ + /** @internal */ static override styles: CSSResultGroup = styles; override render(): TemplateResult { @@ -29,6 +27,7 @@ export class TabPanel extends LitElement { override connectedCallback(): void { super.connectedCallback(); + this.setAttribute('role', 'tabpanel'); } } diff --git a/packages/components/tabs/src/tab.scss b/packages/components/tabs/src/tab.scss index ebc37e5d30..73f1cade17 100644 --- a/packages/components/tabs/src/tab.scss +++ b/packages/components/tabs/src/tab.scss @@ -14,32 +14,39 @@ --_font-subtitle: var(--sl-text-tab-subtitle); --_gap: var(--sl-space-tab-gap); --_icon-block-size: var(--sl-text-typeset-line-height-md); - --_listbox-color: var(--sl-color-select-item-default-foreground); - --_listbox-font: var(--sl-text-select-selectbox-text-lg); - --_listbox-item-border-radius: var(--sl-border-radius-select-item); - --_listbox-item-padding: var(--sl-space-select-item-block-lg) var(--sl-space-select-item-inline-lg); --_padding: var(--sl-space-tab-block) var(--sl-space-tab-inline); - align-items: flex-start; - background-color: var(--_background); + background: var(--_background); color: var(--_color); cursor: pointer; - display: flex; - font: var(--_font); - gap: var(--_gap); + display: inline-flex; + flex-shrink: 0; justify-content: center; - padding: var(--_padding); - position: relative; + min-inline-size: fit-content; scroll-snap-align: start; transition: background 300ms; } -slot[name='subtitle'] { - font: var(--_font-subtitle); +:host(:hover) { + background: var(--_background-hover); + position: relative; + z-index: 1; // Ensure the hover state is on top of any menu-button gradient +} + +:host(:active) { + background: var(--_background-active); +} + +:host(:focus-visible) { + border-radius: var(--_focus-radius); + outline: var(--_focus-outline); + outline-offset: calc(var(--_focus-outline-offset) * -1); + z-index: 1; } :host([disabled]) { - color: var(--_color-disabled); + --_color: var(--_color-disabled); + pointer-events: none; slot[name='icon']::slotted(sl-icon) { @@ -47,19 +54,32 @@ slot[name='subtitle'] { } } -:host(:hover) { - background-color: var(--_background-hover); +:host([has-subtitle]) .title { + font: var(--_font); } -:host(:active) { - background-color: var(--_background-active); +a, +.wrapper { + align-items: start; + display: flex; + gap: var(--_gap); + justify-content: center; + padding: var(--_padding); } -:host(:focus-visible) { - border-radius: var(--_focus-radius); - outline: var(--_focus-outline); - outline-offset: var(--_focus-outline-offset); - z-index: 1; +a { + color: var(--_color); + text-decoration: none; + + &:hover { + color: var(--_color); + text-decoration: none; + } +} + +slot[name='icon']::slotted(sl-icon) { + block-size: var(--_icon-block-size); + fill: var(--_color); } .content { @@ -68,19 +88,19 @@ slot[name='subtitle'] { } .title { + font: var(--_font-subtitle); inline-size: fit-content; position: relative; } -slot[name='icon']::slotted(sl-icon) { - block-size: var(--_icon-block-size); - fill: var(--_color); -} - slot[name='badge']::slotted(sl-badge) { transform: translate(var(--_badge-translateX), var(--_badge-translateY)); } +slot[name='subtitle'] { + font: var(--_font-subtitle); +} + :host(.listbox-tab) { border-radius: var(--_listbox-item-border-radius); color: var(--_listbox-color); diff --git a/packages/components/tabs/src/tab.spec.ts b/packages/components/tabs/src/tab.spec.ts index 29632952f9..0034f392a2 100644 --- a/packages/components/tabs/src/tab.spec.ts +++ b/packages/components/tabs/src/tab.spec.ts @@ -14,23 +14,36 @@ describe('sl-tab', () => { expect(el).shadowDom.to.equalSnapshot(); }); - it('should have the correct aria values', () => { - expect(el).to.have.attribute('aria-selected', 'false'); - expect(el).to.have.attribute('aria-disabled', 'false'); + it('should have a tab role', () => { + expect(el).to.have.attribute('role', 'tab'); }); - it('should have the correct attributes', () => { + it('should have a tabs slot', () => { expect(el).to.have.attribute('slot', 'tabs'); - expect(el).to.have.attribute('role', 'tab'); }); - it('should have the correct aria values when disabled and selected', async () => { - el.selected = true; + it('should not be disabled by default', () => { + expect(el).not.to.have.attribute('disabled'); + expect(el.disabled).not.to.be.true; + }); + + it('should be disabled when set', async () => { el.disabled = true; + await el.updateComplete; + + expect(el).to.have.attribute('disabled'); + }); + + it('should not be selected by default', () => { + expect(el).not.to.have.attribute('aria-selected'); + expect(el.selected).not.to.be.true; + }); + it('should be selected when set', async () => { + el.setAttribute('selected', ''); await el.updateComplete; expect(el).to.have.attribute('aria-selected', 'true'); - expect(el).to.have.attribute('aria-disabled', 'true'); + expect(el.selected).to.be.true; }); }); diff --git a/packages/components/tabs/src/tab.ts b/packages/components/tabs/src/tab.ts index de4fcbd658..f9a5bb057a 100644 --- a/packages/components/tabs/src/tab.ts +++ b/packages/components/tabs/src/tab.ts @@ -1,5 +1,4 @@ -import { observe } from '@sl-design-system/shared'; -import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; import { property } from 'lit/decorators.js'; import styles from './tab.scss.js'; @@ -13,12 +12,12 @@ declare global { * A tab component - part of the tab group component. * * ```html - * - * - * Tab label - * Tab subtitle - * 4 - * + * + * + * Tab label + * Tab subtitle + * 4 + * * ``` * * @slot default - a place for the tab group content. @@ -30,36 +29,57 @@ export class Tab extends LitElement { /** @private */ static override styles: CSSResultGroup = styles; + /** Whether the tab item is disabled */ + @property({ reflect: true, type: Boolean }) disabled?: boolean; + + /** + * When set, it will render the tab contents in a link tag. Use this when + * you want to render the tab contents using a router and to make the tab + * navigatable by URL. + */ + @property() href?: string; + /** Whether the tab item is selected */ - @property({ reflect: true, type: Boolean }) selected = false; + @property({ reflect: true, type: Boolean }) selected?: boolean; - /** Whether the tab item is disabled */ - @property({ reflect: true, type: Boolean }) disabled = false; + override connectedCallback(): void { + super.connectedCallback(); + + this.setAttribute('role', 'tab'); + this.slot ||= 'tabs'; + } override render(): TemplateResult { - return html` + return this.href + ? html`${this.renderContent()}` + : html`
${this.renderContent()}
`; + } + + renderContent(): TemplateResult { + return html` +
- -
`; + +
+ `; } - /** - * Apply accessible attributes and values to the tab button. - * Observe the selected property if it changes - */ - @observe('selected') - protected handleSelectionChange(): void { - this.setAttribute('aria-selected', this.selected ? 'true' : 'false'); - this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); + override updated(changes: PropertyValues): void { + super.updated(changes); + + if (changes.has('selected')) { + this.setAttribute('aria-selected', this.selected ? 'true' : 'false'); + } } - override connectedCallback(): void { - super.connectedCallback(); - this.setAttribute('role', 'tab'); - this.slot ||= 'tabs'; + #onSlotchange(event: Event & { target: HTMLSlotElement }): void { + const nodes = event.target.assignedNodes({ flatten: true }), + hasSubtitle = nodes.some(node => !!node.textContent?.trim()); + + this.toggleAttribute('has-subtitle', hasSubtitle); } } diff --git a/packages/locales/src/nl.xlf b/packages/locales/src/nl.xlf index 377dac9a5e..5ed197c390 100644 --- a/packages/locales/src/nl.xlf +++ b/packages/locales/src/nl.xlf @@ -62,10 +62,6 @@ Please fill in this field. Voer een waarde in. - - Show all - Toon alles - Please enter at least characters (you currently have character). Voer tenminste karakters in (je hebt op dit momoment karakter). @@ -94,6 +90,10 @@ Breadcrumb trail Kruimelpad + + Show all + Toon alles + diff --git a/packages/themes/bingel-dc/all.css b/packages/themes/bingel-dc/all.css index a068635642..0fd5ca69b6 100644 --- a/packages/themes/bingel-dc/all.css +++ b/packages/themes/bingel-dc/all.css @@ -325,7 +325,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/bingel-dc/base.css b/packages/themes/bingel-dc/base.css index 397f0d70c2..466ec38926 100644 --- a/packages/themes/bingel-dc/base.css +++ b/packages/themes/bingel-dc/base.css @@ -317,7 +317,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/bingel-dc/base.json b/packages/themes/bingel-dc/base.json index 5d25b1ca5e..370838b355 100644 --- a/packages/themes/bingel-dc/base.json +++ b/packages/themes/bingel-dc/base.json @@ -3484,7 +3484,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/bingel-dc/base.scss b/packages/themes/bingel-dc/base.scss index 2b8a44acd6..8b4f0ea9e1 100644 --- a/packages/themes/bingel-dc/base.scss +++ b/packages/themes/bingel-dc/base.scss @@ -318,7 +318,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/bingel-int/all.css b/packages/themes/bingel-int/all.css index ff920222fe..2adcd8bd18 100644 --- a/packages/themes/bingel-int/all.css +++ b/packages/themes/bingel-int/all.css @@ -325,7 +325,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/bingel-int/base.css b/packages/themes/bingel-int/base.css index ecda011c3d..bb3baca85a 100644 --- a/packages/themes/bingel-int/base.css +++ b/packages/themes/bingel-int/base.css @@ -317,7 +317,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/bingel-int/base.json b/packages/themes/bingel-int/base.json index 0435977d47..953dd80974 100644 --- a/packages/themes/bingel-int/base.json +++ b/packages/themes/bingel-int/base.json @@ -3526,7 +3526,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/bingel-int/base.scss b/packages/themes/bingel-int/base.scss index fec0569eef..a70adfc232 100644 --- a/packages/themes/bingel-int/base.scss +++ b/packages/themes/bingel-int/base.scss @@ -318,7 +318,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/bingel/all.css b/packages/themes/bingel/all.css index ca58fc60b3..3f9f9e1eda 100644 --- a/packages/themes/bingel/all.css +++ b/packages/themes/bingel/all.css @@ -254,7 +254,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/bingel/base.css b/packages/themes/bingel/base.css index 9bbe6322a0..3af6ef5066 100644 --- a/packages/themes/bingel/base.css +++ b/packages/themes/bingel/base.css @@ -246,7 +246,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/bingel/base.json b/packages/themes/bingel/base.json index 9c2353dfc6..1892add273 100644 --- a/packages/themes/bingel/base.json +++ b/packages/themes/bingel/base.json @@ -2075,7 +2075,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/bingel/base.scss b/packages/themes/bingel/base.scss index 48d1859e6b..5d12d0e45e 100644 --- a/packages/themes/bingel/base.scss +++ b/packages/themes/bingel/base.scss @@ -247,7 +247,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/clickedu/all.css b/packages/themes/clickedu/all.css index 238d40b059..efe6e875fb 100644 --- a/packages/themes/clickedu/all.css +++ b/packages/themes/clickedu/all.css @@ -327,7 +327,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/clickedu/base.css b/packages/themes/clickedu/base.css index ee7e773b4c..b8bb7f7295 100644 --- a/packages/themes/clickedu/base.css +++ b/packages/themes/clickedu/base.css @@ -319,7 +319,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/clickedu/base.json b/packages/themes/clickedu/base.json index 6578e0cb01..516d749c12 100644 --- a/packages/themes/clickedu/base.json +++ b/packages/themes/clickedu/base.json @@ -3488,7 +3488,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/clickedu/base.scss b/packages/themes/clickedu/base.scss index a957d216e7..9852184f30 100644 --- a/packages/themes/clickedu/base.scss +++ b/packages/themes/clickedu/base.scss @@ -320,7 +320,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/editorial-suite/all.css b/packages/themes/editorial-suite/all.css index 6703d320c7..206f90c9ef 100644 --- a/packages/themes/editorial-suite/all.css +++ b/packages/themes/editorial-suite/all.css @@ -313,7 +313,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/editorial-suite/base.css b/packages/themes/editorial-suite/base.css index f2e5604786..bf1148f6ed 100644 --- a/packages/themes/editorial-suite/base.css +++ b/packages/themes/editorial-suite/base.css @@ -305,7 +305,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/editorial-suite/base.json b/packages/themes/editorial-suite/base.json index 6d5269cd92..d0aad4b1c7 100644 --- a/packages/themes/editorial-suite/base.json +++ b/packages/themes/editorial-suite/base.json @@ -3738,7 +3738,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/editorial-suite/base.scss b/packages/themes/editorial-suite/base.scss index 5006be544c..eac0631b2c 100644 --- a/packages/themes/editorial-suite/base.scss +++ b/packages/themes/editorial-suite/base.scss @@ -306,7 +306,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/itslearning/all.css b/packages/themes/itslearning/all.css index e1373cb443..b8d9fd36ee 100644 --- a/packages/themes/itslearning/all.css +++ b/packages/themes/itslearning/all.css @@ -333,7 +333,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/itslearning/base.css b/packages/themes/itslearning/base.css index 92181f6078..60f319530a 100644 --- a/packages/themes/itslearning/base.css +++ b/packages/themes/itslearning/base.css @@ -325,7 +325,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/itslearning/base.json b/packages/themes/itslearning/base.json index 40bd613c88..44529deb84 100644 --- a/packages/themes/itslearning/base.json +++ b/packages/themes/itslearning/base.json @@ -3484,7 +3484,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/itslearning/base.scss b/packages/themes/itslearning/base.scss index e1f249b221..af5da19679 100644 --- a/packages/themes/itslearning/base.scss +++ b/packages/themes/itslearning/base.scss @@ -326,7 +326,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/kampus/all.css b/packages/themes/kampus/all.css index 3935f8e707..8895b88d3b 100644 --- a/packages/themes/kampus/all.css +++ b/packages/themes/kampus/all.css @@ -333,7 +333,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/kampus/base.css b/packages/themes/kampus/base.css index 08d0319a23..0c0edb9c17 100644 --- a/packages/themes/kampus/base.css +++ b/packages/themes/kampus/base.css @@ -325,7 +325,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/kampus/base.json b/packages/themes/kampus/base.json index d0150c4375..84acb9cbf5 100644 --- a/packages/themes/kampus/base.json +++ b/packages/themes/kampus/base.json @@ -3484,7 +3484,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/kampus/base.scss b/packages/themes/kampus/base.scss index a661bd76c2..b274529b98 100644 --- a/packages/themes/kampus/base.scss +++ b/packages/themes/kampus/base.scss @@ -326,7 +326,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/magister/all.css b/packages/themes/magister/all.css index 2d0822c95e..129936815e 100644 --- a/packages/themes/magister/all.css +++ b/packages/themes/magister/all.css @@ -330,7 +330,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/magister/base.css b/packages/themes/magister/base.css index a85e5b187d..496f35254f 100644 --- a/packages/themes/magister/base.css +++ b/packages/themes/magister/base.css @@ -322,7 +322,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/magister/base.json b/packages/themes/magister/base.json index 66a2ddaa06..96d1e25433 100644 --- a/packages/themes/magister/base.json +++ b/packages/themes/magister/base.json @@ -3504,7 +3504,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/magister/base.scss b/packages/themes/magister/base.scss index 69ae6d63f8..9427a86450 100644 --- a/packages/themes/magister/base.scss +++ b/packages/themes/magister/base.scss @@ -323,7 +323,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/max/all.css b/packages/themes/max/all.css index 7f7f66b919..419bb497cd 100644 --- a/packages/themes/max/all.css +++ b/packages/themes/max/all.css @@ -333,7 +333,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/max/base.css b/packages/themes/max/base.css index 64afd6d446..94bab00a36 100644 --- a/packages/themes/max/base.css +++ b/packages/themes/max/base.css @@ -325,7 +325,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/max/base.json b/packages/themes/max/base.json index 52ed39929a..afa44856bd 100644 --- a/packages/themes/max/base.json +++ b/packages/themes/max/base.json @@ -3484,7 +3484,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/max/base.scss b/packages/themes/max/base.scss index 69421e66c0..e9afa12838 100644 --- a/packages/themes/max/base.scss +++ b/packages/themes/max/base.scss @@ -326,7 +326,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/my-digital-book/all.css b/packages/themes/my-digital-book/all.css index 4802b0719f..48dfd4cfdc 100644 --- a/packages/themes/my-digital-book/all.css +++ b/packages/themes/my-digital-book/all.css @@ -311,7 +311,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/my-digital-book/base.css b/packages/themes/my-digital-book/base.css index 8621ee234c..1e76f87d69 100644 --- a/packages/themes/my-digital-book/base.css +++ b/packages/themes/my-digital-book/base.css @@ -303,7 +303,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/my-digital-book/base.json b/packages/themes/my-digital-book/base.json index ce36a94b7f..1cc1053b59 100644 --- a/packages/themes/my-digital-book/base.json +++ b/packages/themes/my-digital-book/base.json @@ -3736,7 +3736,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/my-digital-book/base.scss b/packages/themes/my-digital-book/base.scss index c3ad125114..1387edb06a 100644 --- a/packages/themes/my-digital-book/base.scss +++ b/packages/themes/my-digital-book/base.scss @@ -304,7 +304,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/myvanin/all.css b/packages/themes/myvanin/all.css index 083fdb8523..e8766ec8be 100644 --- a/packages/themes/myvanin/all.css +++ b/packages/themes/myvanin/all.css @@ -314,7 +314,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/myvanin/base.css b/packages/themes/myvanin/base.css index 96b9f908c7..b722131e2e 100644 --- a/packages/themes/myvanin/base.css +++ b/packages/themes/myvanin/base.css @@ -306,7 +306,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/myvanin/base.json b/packages/themes/myvanin/base.json index 87e5de2066..f99b587c35 100644 --- a/packages/themes/myvanin/base.json +++ b/packages/themes/myvanin/base.json @@ -3733,7 +3733,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/myvanin/base.scss b/packages/themes/myvanin/base.scss index 314424efc9..ce82510f42 100644 --- a/packages/themes/myvanin/base.scss +++ b/packages/themes/myvanin/base.scss @@ -307,7 +307,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/neon/all.css b/packages/themes/neon/all.css index c821312179..214455a28d 100644 --- a/packages/themes/neon/all.css +++ b/packages/themes/neon/all.css @@ -334,7 +334,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/neon/base.css b/packages/themes/neon/base.css index b1490e1d65..15b4a822ed 100644 --- a/packages/themes/neon/base.css +++ b/packages/themes/neon/base.css @@ -326,7 +326,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/neon/base.json b/packages/themes/neon/base.json index a2f33c43fc..d7a94b82d4 100644 --- a/packages/themes/neon/base.json +++ b/packages/themes/neon/base.json @@ -3484,7 +3484,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/neon/base.scss b/packages/themes/neon/base.scss index 42e08d40ba..a667347e42 100644 --- a/packages/themes/neon/base.scss +++ b/packages/themes/neon/base.scss @@ -327,7 +327,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/nowa-era/all.css b/packages/themes/nowa-era/all.css index eece92877d..1ba670cb87 100644 --- a/packages/themes/nowa-era/all.css +++ b/packages/themes/nowa-era/all.css @@ -341,7 +341,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/nowa-era/base.css b/packages/themes/nowa-era/base.css index 958d7957f9..43251f0ce2 100644 --- a/packages/themes/nowa-era/base.css +++ b/packages/themes/nowa-era/base.css @@ -333,7 +333,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/nowa-era/base.json b/packages/themes/nowa-era/base.json index 0394471301..5ba1f10a8e 100644 --- a/packages/themes/nowa-era/base.json +++ b/packages/themes/nowa-era/base.json @@ -2609,7 +2609,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/nowa-era/base.scss b/packages/themes/nowa-era/base.scss index e355f3c5fb..b9ac5d6cf4 100644 --- a/packages/themes/nowa-era/base.scss +++ b/packages/themes/nowa-era/base.scss @@ -334,7 +334,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/sanoma-learning/all.css b/packages/themes/sanoma-learning/all.css index 6a595d47b7..0466f36436 100644 --- a/packages/themes/sanoma-learning/all.css +++ b/packages/themes/sanoma-learning/all.css @@ -315,7 +315,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/sanoma-learning/base.css b/packages/themes/sanoma-learning/base.css index 74c2686520..bbae6290a8 100644 --- a/packages/themes/sanoma-learning/base.css +++ b/packages/themes/sanoma-learning/base.css @@ -307,7 +307,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/sanoma-learning/base.json b/packages/themes/sanoma-learning/base.json index a86190b83f..eb7538d617 100644 --- a/packages/themes/sanoma-learning/base.json +++ b/packages/themes/sanoma-learning/base.json @@ -3849,7 +3849,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/sanoma-learning/base.scss b/packages/themes/sanoma-learning/base.scss index a4eedd1be8..988aaa2547 100644 --- a/packages/themes/sanoma-learning/base.scss +++ b/packages/themes/sanoma-learning/base.scss @@ -308,7 +308,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/sanoma-utbildning/all.css b/packages/themes/sanoma-utbildning/all.css index 7fba4a4cc6..3792d11fae 100644 --- a/packages/themes/sanoma-utbildning/all.css +++ b/packages/themes/sanoma-utbildning/all.css @@ -311,7 +311,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/sanoma-utbildning/base.css b/packages/themes/sanoma-utbildning/base.css index c83054bd5c..3d095efa66 100644 --- a/packages/themes/sanoma-utbildning/base.css +++ b/packages/themes/sanoma-utbildning/base.css @@ -303,7 +303,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/sanoma-utbildning/base.json b/packages/themes/sanoma-utbildning/base.json index 08c42766c8..2b0208d39f 100644 --- a/packages/themes/sanoma-utbildning/base.json +++ b/packages/themes/sanoma-utbildning/base.json @@ -3733,7 +3733,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/sanoma-utbildning/base.scss b/packages/themes/sanoma-utbildning/base.scss index 1cc821f230..5142af061b 100644 --- a/packages/themes/sanoma-utbildning/base.scss +++ b/packages/themes/sanoma-utbildning/base.scss @@ -304,7 +304,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/teas/all.css b/packages/themes/teas/all.css index 746b56b302..697b40750f 100644 --- a/packages/themes/teas/all.css +++ b/packages/themes/teas/all.css @@ -319,7 +319,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/teas/base.css b/packages/themes/teas/base.css index 903813e046..2be31905c8 100644 --- a/packages/themes/teas/base.css +++ b/packages/themes/teas/base.css @@ -311,7 +311,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/themes/teas/base.json b/packages/themes/teas/base.json index 0e1acac18f..b2141eb3f5 100644 --- a/packages/themes/teas/base.json +++ b/packages/themes/teas/base.json @@ -3821,7 +3821,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/packages/themes/teas/base.scss b/packages/themes/teas/base.scss index 6b01f9a63f..88def7d4be 100644 --- a/packages/themes/teas/base.scss +++ b/packages/themes/teas/base.scss @@ -312,7 +312,7 @@ --sl-space-tab-more-block: var(--sl-space-sm); --sl-space-tab-more-inline: var(--sl-space-md); --sl-space-tab-gap: var(--sl-space-sm); - --sl-space-tab-block: var(--sl-space-sm); + --sl-space-tab-block: var(--sl-space-md); --sl-space-tab-inline: var(--sl-space-xl); --sl-space-inline-message-content-gap: var(--sl-space-xs); --sl-space-inline-message-gap: var(--sl-space-lg); diff --git a/packages/tokens/src/core.json b/packages/tokens/src/core.json index c9f88b14b6..63e78e9c8d 100644 --- a/packages/tokens/src/core.json +++ b/packages/tokens/src/core.json @@ -1599,7 +1599,7 @@ "type": "spacing" }, "block": { - "value": "{space.sm}", + "value": "{space.md}", "type": "spacing" }, "gap": { diff --git a/yarn.lock b/yarn.lock index 45277c5fcb..8b217ec119 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4764,8 +4764,8 @@ __metadata: dependencies: "@lit/localize": "npm:^0.12.1" "@open-wc/scoped-elements": "npm:^3.0.5" - "@sl-design-system/button": "npm:0.0.24" "@sl-design-system/icon": "npm:0.0.9" + "@sl-design-system/menu": "npm:0.0.5" "@sl-design-system/shared": "npm:0.2.7" peerDependencies: "@lit/localize": ^0.12.1 @@ -22494,8 +22494,8 @@ __metadata: linkType: hard "webpack-dev-middleware@npm:^5.3.1": - version: 5.3.3 - resolution: "webpack-dev-middleware@npm:5.3.3" + version: 5.3.4 + resolution: "webpack-dev-middleware@npm:5.3.4" dependencies: colorette: "npm:^2.0.10" memfs: "npm:^3.4.3" @@ -22504,7 +22504,7 @@ __metadata: schema-utils: "npm:^4.0.0" peerDependencies: webpack: ^4.0.0 || ^5.0.0 - checksum: 10c0/378ceed430b61c0b0eccdbb55a97173aa36231bb88e20ad12bafb3d553e542708fa31f08474b9c68d4ac95174a047def9e426e193b7134be3736afa66a0d1708 + checksum: 10c0/257df7d6bc5494d1d3cb66bba70fbdf5a6e0423e39b6420f7631aeb52435afbfbff8410a62146dcdf3d2f945c62e03193aae2ac1194a2f7d5a2523b9d194e9e1 languageName: node linkType: hard