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
-
-
-
- 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.
+
+ 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`
-