luxen-ui 0.18.2 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cdn/custom-elements.json +6 -1
- package/cdn/elements/tabs/tabs.d.ts +23 -1
- package/cdn/elements/tabs/tabs.d.ts.map +1 -1
- package/cdn/elements/tabs/tabs.js +1 -1
- package/cdn/elements/tabs/tabs.js.map +1 -1
- package/cdn/standalone.css +23 -4
- package/cdn/styles/elements/tabs/enclosed.css +9 -1
- package/cdn/styles/elements/tabs/line.css +14 -3
- package/dist/css/elements/tabs/enclosed.css +9 -1
- package/dist/css/elements/tabs/line.css +14 -3
- package/dist/custom-elements.json +6 -1
- package/dist/elements/tabs/tabs.d.ts +23 -1
- package/dist/elements/tabs/tabs.d.ts.map +1 -1
- package/dist/elements/tabs/tabs.js +116 -26
- package/dist/metadata/index.json +7 -2
- package/dist/metadata/tabs.json +6 -1
- package/dist/templates/elements/tabs.md +37 -7
- package/package.json +1 -1
package/cdn/custom-elements.json
CHANGED
|
@@ -8634,7 +8634,7 @@
|
|
|
8634
8634
|
"events": [
|
|
8635
8635
|
{
|
|
8636
8636
|
"name": "change",
|
|
8637
|
-
"description": "Fired when the active tab changes. Bubbles. Properties: `index: number`, `name: string | null`.",
|
|
8637
|
+
"description": "Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.",
|
|
8638
8638
|
"cancelable": false
|
|
8639
8639
|
}
|
|
8640
8640
|
],
|
|
@@ -8688,6 +8688,11 @@
|
|
|
8688
8688
|
"title": "Mark the initially-active tab with `aria-selected=\"true\"` so it is styled as",
|
|
8689
8689
|
"language": "html",
|
|
8690
8690
|
"code": "selected before the script upgrades the element (progressive enhancement).\n```html\n<l-tabs variant=\"enclosed\">\n <div>\n <button aria-selected=\"true\">Tab 1</button>\n <button>Tab 2</button>\n </div>\n <div>Content 1</div>\n <div>Content 2</div>\n</l-tabs>\n```"
|
|
8691
|
+
},
|
|
8692
|
+
{
|
|
8693
|
+
"title": "A tab button marked `disabled` (or `aria-disabled=\"true\"`) is skipped by",
|
|
8694
|
+
"language": "html",
|
|
8695
|
+
"code": "arrow keys, `Home` and `End`, and cannot be selected by a click or by\n`value` — it still owns its panel, so the tab/panel order is unchanged.\n```html\n<button disabled>Billing</button>\n```"
|
|
8691
8696
|
}
|
|
8692
8697
|
]
|
|
8693
8698
|
}
|
|
@@ -28,7 +28,15 @@ interface TabsEventMap {
|
|
|
28
28
|
* </l-tabs>
|
|
29
29
|
* ```
|
|
30
30
|
*
|
|
31
|
-
* @
|
|
31
|
+
* @example
|
|
32
|
+
* A tab button marked `disabled` (or `aria-disabled="true"`) is skipped by
|
|
33
|
+
* arrow keys, `Home` and `End`, and cannot be selected by a click or by
|
|
34
|
+
* `value` — it still owns its panel, so the tab/panel order is unchanged.
|
|
35
|
+
* ```html
|
|
36
|
+
* <button disabled>Billing</button>
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @event change - Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.
|
|
32
40
|
*
|
|
33
41
|
* @cssproperty [--indicator-color=var(--l-color-text-primary)] - `line` variant: color of the active underline that slides under the selected tab.
|
|
34
42
|
* @cssproperty [--indicator-thickness=2px] - `line` variant: thickness of the active underline.
|
|
@@ -64,6 +72,20 @@ export declare class Tabs extends LuxenElement {
|
|
|
64
72
|
private _setup;
|
|
65
73
|
private _teardown;
|
|
66
74
|
private _selectTab;
|
|
75
|
+
/**
|
|
76
|
+
* Both spellings count: native `disabled` (unfocusable) and `aria-disabled`
|
|
77
|
+
* (focusable, so a user can still land on it — navigation must move off it).
|
|
78
|
+
*/
|
|
79
|
+
private _isDisabled;
|
|
80
|
+
private _isSelectable;
|
|
81
|
+
/** Index of the currently selected tab, read back from the DOM. */
|
|
82
|
+
private _activeIndex;
|
|
83
|
+
/** Resolve the initial selection from `value`, falling back to the first enabled tab. */
|
|
84
|
+
private _resolveActiveIndex;
|
|
85
|
+
/** Index of the next enabled tab from `from`, moving by `dir`, wrapping. */
|
|
86
|
+
private _nextEnabled;
|
|
87
|
+
/** Index of the first (`dir` 1) or last (`dir` -1) enabled tab. */
|
|
88
|
+
private _edgeEnabled;
|
|
67
89
|
private _updateIndicator;
|
|
68
90
|
private _onClick;
|
|
69
91
|
private _onKeyDown;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../../src/html/elements/tabs/tabs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAG7D,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,MAAM,CAAC;AAC9C,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,UAAU,CAAC;AAExD,gEAAgE;AAChE,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;gBACjB,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;CAK/C;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,eAAe,CAAC;CACzB;AAED
|
|
1
|
+
{"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../../src/html/elements/tabs/tabs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAG7D,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,MAAM,CAAC;AAC9C,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,UAAU,CAAC;AAExD,gEAAgE;AAChE,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;gBACjB,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;CAK/C;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,eAAe,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,qBAAa,IAAK,SAAQ,YAAY;IAC3B,gBAAgB;IAIzB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,WAAW,CAAoB;IACvC,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,eAAe,CAA+B;IAEtD,sBAAsB;IAEtB,OAAO,EAAE,WAAW,CAAU;IAE9B,yCAAyC;IAEzC,KAAK,SAAO;IAEZ,4CAA4C;IAE5C,SAAS,UAAS;IAElB,uBAAuB;IAEvB,WAAW,EAAE,eAAe,CAAgB;IAInC,iBAAiB;IAUjB,oBAAoB;IAM7B,mFAAmF;IACnF,OAAO,CAAC,SAAS;IAOR,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAuB9C,OAAO,CAAC,MAAM;IAgEd,OAAO,CAAC,SAAS;IAUjB,OAAO,CAAC,UAAU;IAmClB;;;OAGG;IACH,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,aAAa;IASrB,mEAAmE;IACnE,OAAO,CAAC,YAAY;IAKpB,yFAAyF;IACzF,OAAO,CAAC,mBAAmB;IAS3B,4EAA4E;IAC5E,OAAO,CAAC,YAAY;IASpB,mEAAmE;IACnE,OAAO,CAAC,YAAY;IAWpB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,QAAQ,CAUd;IAEF,OAAO,CAAC,UAAU,CAqChB;CACH;AA8BD,MAAM,WAAW,IAAI;IACnB,gBAAgB,CAAC,CAAC,SAAS,MAAM,YAAY,EAC3C,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IACR,gBAAgB,CACd,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,kCAAkC,EAC5C,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IACR,mBAAmB,CAAC,CAAC,SAAS,MAAM,YAAY,EAC9C,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IACR,mBAAmB,CACjB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,kCAAkC,EAC5C,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;CACT"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{uniqueId as e}from"../../registry.js";import{i as t,t as n}from"../../chunks/decorate.js";import{LuxenElement as r}from"../../shared/luxen-element.js";var i=class extends Event{constructor(e,t){super(`change`,{bubbles:!0,composed:!1,cancelable:!1}),this.index=e,this.name=t}},a=class extends r{constructor(...t){super(...t),this._initialized=!1,this._setupTimer=0,this._instanceId=e(`tabs`),this._tablistEl=null,this._tabs=[],this._panels=[],this._resizeObserver=null,this.variant=`line`,this.value=`0`,this.fullWidth=!1,this.orientation=`horizontal`,this._onClick=e=>{let t=e.target.closest(`[role="tab"]`);if(!t)return;let n=this._tabs.indexOf(t);n
|
|
1
|
+
import{uniqueId as e}from"../../registry.js";import{i as t,t as n}from"../../chunks/decorate.js";import{LuxenElement as r}from"../../shared/luxen-element.js";var i=class extends Event{constructor(e,t){super(`change`,{bubbles:!0,composed:!1,cancelable:!1}),this.index=e,this.name=t}},a=class extends r{constructor(...t){super(...t),this._initialized=!1,this._setupTimer=0,this._instanceId=e(`tabs`),this._tablistEl=null,this._tabs=[],this._panels=[],this._resizeObserver=null,this.variant=`line`,this.value=`0`,this.fullWidth=!1,this.orientation=`horizontal`,this._onClick=e=>{let t=e.target.closest(`[role="tab"]`);if(!t)return;let n=this._tabs.indexOf(t);n<0||this._isDisabled(t)||(this._selectTab(n),t.focus())},this._onKeyDown=e=>{let t=e.target;if(t.getAttribute(`role`)!==`tab`)return;let n=this.orientation===`horizontal`,r=n?`ArrowLeft`:`ArrowUp`,i=n?`ArrowRight`:`ArrowDown`,a=this._tabs.indexOf(t);if(a<0)return;let o;switch(e.key){case i:o=this._nextEnabled(a,1);break;case r:o=this._nextEnabled(a,-1);break;case`Home`:o=this._edgeEnabled(1);break;case`End`:o=this._edgeEnabled(-1);break;default:return}e.preventDefault(),this._isSelectable(o)&&(this._selectTab(o),this._tabs[o].focus())}}createRenderRoot(){return this}connectedCallback(){super.connectedCallback(),this._trySetup()||(this._setupTimer=window.setTimeout(()=>this._trySetup(),0))}disconnectedCallback(){super.disconnectedCallback(),clearTimeout(this._setupTimer),this._teardown()}_trySetup(){return this._initialized||!this.isConnected?!0:this.children.length<2?!1:(this._setup(),!0)}updated(e){if(e.has(`value`)&&this._tabs.length){let e=Number(this.value);if(this._isSelectable(e))this._selectTab(e,!1);else{let e=this._activeIndex();queueMicrotask(()=>{this.value=String(e)})}}e.has(`orientation`)&&this._tablistEl&&this._tablistEl.setAttribute(`aria-orientation`,this.orientation)}_setup(){let e=Array.from(this.children);if(e.length<2)return;this._initialized=!0,this._tablistEl=e[0],this._tablistEl.setAttribute(`role`,`tablist`),this._tablistEl.setAttribute(`aria-orientation`,this.orientation),this._tabs=Array.from(this._tablistEl.querySelectorAll(`button`)),this._panels=e.slice(1);let t=this._resolveActiveIndex();for(let e=0;e<this._tabs.length;e++){let n=this._tabs[e],r=this._panels[e],i=n.getAttribute(`name`)??String(e),a=`${this._instanceId}-tab-${i}`,o=`${this._instanceId}-panel-${i}`;n.setAttribute(`role`,`tab`),n.setAttribute(`id`,a),n.setAttribute(`aria-selected`,String(e===t)),n.setAttribute(`tabindex`,e===t?`0`:`-1`),r&&(n.setAttribute(`aria-controls`,o),r.setAttribute(`role`,`tabpanel`),r.setAttribute(`id`,o),r.setAttribute(`aria-labelledby`,a),r.setAttribute(`tabindex`,`0`),e===t?r.hidden=!1:r.hidden=!0)}Number(this.value)!==t&&(this.value=String(t)),this._tablistEl.addEventListener(`click`,this._onClick),this._tablistEl.addEventListener(`keydown`,this._onKeyDown),this._updateIndicator(),this._resizeObserver=new ResizeObserver(()=>this._updateIndicator()),this._resizeObserver.observe(this._tablistEl)}_teardown(){this._tablistEl?.removeEventListener(`click`,this._onClick),this._tablistEl?.removeEventListener(`keydown`,this._onKeyDown),this._resizeObserver?.disconnect(),this._resizeObserver=null,this._initialized=!1}_selectTab(e,t=!0){if(!this._isSelectable(e))return;let n=this._tabs[e].getAttribute(`aria-selected`)!==`true`;for(let t=0;t<this._tabs.length;t++){let n=this._tabs[t],r=this._panels[t],i=t===e;n.setAttribute(`aria-selected`,String(i)),n.setAttribute(`tabindex`,i?`0`:`-1`),r&&(r.hidden=!i)}if(this.value=String(e),this._updateIndicator(),t&&n){let t=this._tabs[e]?.getAttribute(`name`)??null;this.dispatchEvent(new i(e,t))}}_isDisabled(e){return e.disabled||e.getAttribute(`aria-disabled`)===`true`}_isSelectable(e){return Number.isInteger(e)&&e>=0&&e<this._tabs.length&&!this._isDisabled(this._tabs[e])}_activeIndex(){let e=this._tabs.findIndex(e=>e.getAttribute(`aria-selected`)===`true`);return e>=0?e:0}_resolveActiveIndex(){let e=Number(this.value)||0;if(this._isSelectable(e))return e;let t=this._tabs.findIndex(e=>!this._isDisabled(e));return t>=0?t:0}_nextEnabled(e,t){let n=this._tabs.length;for(let r=1;r<=n;r++){let i=(e+t*r+n*r)%n;if(!this._isDisabled(this._tabs[i]))return i}return e}_edgeEnabled(e){let t=this._tabs.length,n=e===1?0:t-1;for(let r=n;r>=0&&r<t;r+=e)if(!this._isDisabled(this._tabs[r]))return r;return n}_updateIndicator(){if(!this._tablistEl)return;let e=this._tabs[this._activeIndex()];e&&(this.orientation===`vertical`?(this._tablistEl.style.setProperty(`--_indicator-top`,`${e.offsetTop}px`),this._tablistEl.style.setProperty(`--_indicator-height`,`${e.offsetHeight}px`)):(this._tablistEl.style.setProperty(`--_indicator-left`,`${e.offsetLeft}px`),this._tablistEl.style.setProperty(`--_indicator-width`,`${e.offsetWidth}px`)))}};n([t({reflect:!0})],a.prototype,`variant`,void 0),n([t({reflect:!0})],a.prototype,`value`,void 0),n([t({type:Boolean,reflect:!0,attribute:`full-width`})],a.prototype,`fullWidth`,void 0),n([t({reflect:!0})],a.prototype,`orientation`,void 0);export{a as Tabs,i as TabsChangeEvent};
|
|
2
2
|
//# sourceMappingURL=tabs.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tabs.js","names":[],"sources":["../../../src/html/elements/tabs/tabs.ts"],"sourcesContent":["import { property } from 'lit/decorators.js';\nimport { LuxenElement } from '../../shared/luxen-element.js';\nimport { uniqueId } from '../../registry.js';\n\nexport type TabsVariant = 'enclosed' | 'line';\nexport type TabsOrientation = 'horizontal' | 'vertical';\n\n/** Fired when the active tab changes. Bubbles; not composed. */\nexport class TabsChangeEvent extends Event {\n readonly index: number;\n readonly name: string | null;\n constructor(index: number, name: string | null) {\n super('change', { bubbles: true, composed: false, cancelable: false });\n this.index = index;\n this.name = name;\n }\n}\n\ninterface TabsEventMap {\n change: TabsChangeEvent;\n}\n\n/**\n * @summary A tabs component that progressively enhances light DOM markup\n * with ARIA roles, keyboard navigation, and animated indicators.\n *\n * @example\n * Mark the initially-active tab with `aria-selected=\"true\"` so it is styled as\n * selected before the script upgrades the element (progressive enhancement).\n * ```html\n * <l-tabs variant=\"enclosed\">\n * <div>\n * <button aria-selected=\"true\">Tab 1</button>\n * <button>Tab 2</button>\n * </div>\n * <div>Content 1</div>\n * <div>Content 2</div>\n * </l-tabs>\n * ```\n *\n * @event change - Fired when the active tab changes. Bubbles. Properties: `index: number`, `name: string | null`.\n *\n * @cssproperty [--indicator-color=var(--l-color-text-primary)] - `line` variant: color of the active underline that slides under the selected tab.\n * @cssproperty [--indicator-thickness=2px] - `line` variant: thickness of the active underline.\n * @cssproperty [--track-color=var(--l-color-border)] - `line` variant: color of the static bottom border the tabs sit on.\n * @cssproperty [--track-thickness=1px] - `line` variant: thickness of the static bottom border.\n * @cssproperty [--hover-color=var(--l-color-bg-state-hover)] - `line` variant: fill of the pill that appears behind a hovered tab label.\n * @cssproperty [--hover-inset=4px] - `line` variant: block-axis gap between the hover pill and the tab edges, keeping it detached from the bottom border.\n *\n * @customElement l-tabs\n */\n// oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- typed addEventListener overloads merged below; no uninitialized properties.\nexport class Tabs extends LuxenElement {\n override createRenderRoot() {\n return this;\n }\n\n private _initialized = false;\n private _setupTimer = 0;\n private _instanceId = uniqueId('tabs');\n private _tablistEl: HTMLElement | null = null;\n private _tabs: HTMLButtonElement[] = [];\n private _panels: HTMLElement[] = [];\n private _resizeObserver: ResizeObserver | null = null;\n\n /** Visual variant. */\n @property({ reflect: true })\n variant: TabsVariant = 'line';\n\n /** Index of the active tab (0-based). */\n @property({ reflect: true })\n value = '0';\n\n /** Stretch tabs to fill container width. */\n @property({ type: Boolean, reflect: true, attribute: 'full-width' })\n fullWidth = false;\n\n /** Tab orientation. */\n @property({ reflect: true })\n orientation: TabsOrientation = 'horizontal';\n\n // --- Lifecycle ---\n\n override connectedCallback() {\n super.connectedCallback();\n // Children may not be parsed yet when the element upgrades mid-parse: try\n // synchronously, then retry once on a macrotask. setTimeout, not rAF — rAF\n // is suspended in hidden documents, so the element would stay inert there.\n if (!this._trySetup()) {\n this._setupTimer = window.setTimeout(() => this._trySetup(), 0);\n }\n }\n\n override disconnectedCallback() {\n super.disconnectedCallback();\n clearTimeout(this._setupTimer);\n this._teardown();\n }\n\n /** @returns true when setup ran or was already done; false to schedule a retry. */\n private _trySetup(): boolean {\n if (this._initialized || !this.isConnected) return true;\n if (this.children.length < 2) return false;\n this._setup();\n return true;\n }\n\n override updated(changed: Map<string, unknown>) {\n if (changed.has('value') && this._tabs.length) {\n this._selectTab(Number(this.value), false);\n }\n if (changed.has('orientation') && this._tablistEl) {\n this._tablistEl.setAttribute('aria-orientation', this.orientation);\n }\n }\n\n // --- Setup / Teardown ---\n\n private _setup() {\n const children = Array.from(this.children) as HTMLElement[];\n if (children.length < 2) return;\n this._initialized = true;\n\n // First child is the tablist container\n this._tablistEl = children[0];\n this._tablistEl.setAttribute('role', 'tablist');\n this._tablistEl.setAttribute('aria-orientation', this.orientation);\n\n // Buttons inside tablist become tabs\n this._tabs = Array.from(this._tablistEl.querySelectorAll('button'));\n\n // Remaining children become panels\n this._panels = children.slice(1);\n\n const activeIndex = Number(this.value) || 0;\n\n // Enhance tabs\n for (let i = 0; i < this._tabs.length; i++) {\n const tab = this._tabs[i];\n const panel = this._panels[i];\n const name = tab.getAttribute('name') ?? String(i);\n const tabId = `${this._instanceId}-tab-${name}`;\n const panelId = `${this._instanceId}-panel-${name}`;\n\n tab.setAttribute('role', 'tab');\n tab.setAttribute('id', tabId);\n tab.setAttribute('aria-selected', String(i === activeIndex));\n tab.setAttribute('tabindex', i === activeIndex ? '0' : '-1');\n\n if (panel) {\n tab.setAttribute('aria-controls', panelId);\n panel.setAttribute('role', 'tabpanel');\n panel.setAttribute('id', panelId);\n panel.setAttribute('aria-labelledby', tabId);\n panel.setAttribute('tabindex', '0');\n if (i !== activeIndex) {\n panel.hidden = true;\n } else {\n panel.hidden = false;\n }\n }\n }\n\n // Attach listeners\n this._tablistEl.addEventListener('click', this._onClick);\n this._tablistEl.addEventListener('keydown', this._onKeyDown);\n\n // Initial indicator position. Synchronous first (offset reads force\n // layout), then a ResizeObserver keeps it correct across hidden→visible\n // transitions and size changes — rAF is suspended in hidden documents,\n // so it must not be the only scheduling mechanism here.\n this._updateIndicator();\n this._resizeObserver = new ResizeObserver(() => this._updateIndicator());\n this._resizeObserver.observe(this._tablistEl);\n }\n\n private _teardown() {\n this._tablistEl?.removeEventListener('click', this._onClick);\n this._tablistEl?.removeEventListener('keydown', this._onKeyDown);\n this._resizeObserver?.disconnect();\n this._resizeObserver = null;\n this._initialized = false;\n }\n\n // --- Tab selection ---\n\n private _selectTab(index: number, emitEvent = true) {\n if (index < 0 || index >= this._tabs.length) return;\n\n for (let i = 0; i < this._tabs.length; i++) {\n const tab = this._tabs[i];\n const panel = this._panels[i];\n const isActive = i === index;\n\n tab.setAttribute('aria-selected', String(isActive));\n tab.setAttribute('tabindex', isActive ? '0' : '-1');\n\n if (panel) {\n panel.hidden = !isActive;\n }\n }\n\n this.value = String(index);\n this._updateIndicator();\n\n if (emitEvent) {\n const name = this._tabs[index]?.getAttribute('name') ?? null;\n this.dispatchEvent(new TabsChangeEvent(index, name));\n }\n }\n\n // --- Indicator ---\n\n private _updateIndicator() {\n if (!this._tablistEl) return;\n const activeTab = this._tabs[Number(this.value)];\n if (!activeTab) return;\n\n const isVertical = this.orientation === 'vertical';\n\n if (isVertical) {\n this._tablistEl.style.setProperty('--_indicator-top', `${activeTab.offsetTop}px`);\n this._tablistEl.style.setProperty('--_indicator-height', `${activeTab.offsetHeight}px`);\n } else {\n this._tablistEl.style.setProperty('--_indicator-left', `${activeTab.offsetLeft}px`);\n this._tablistEl.style.setProperty('--_indicator-width', `${activeTab.offsetWidth}px`);\n }\n }\n\n // --- Event handlers ---\n\n private _onClick = (e: Event) => {\n const tab = (e.target as HTMLElement).closest<HTMLButtonElement>('[role=\"tab\"]');\n if (!tab) return;\n const index = this._tabs.indexOf(tab);\n if (index >= 0) {\n this._selectTab(index);\n tab.focus();\n }\n };\n\n private _onKeyDown = (e: KeyboardEvent) => {\n const target = e.target as HTMLElement;\n if (target.getAttribute('role') !== 'tab') return;\n\n const isHorizontal = this.orientation === 'horizontal';\n const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';\n const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';\n\n let index = this._tabs.indexOf(target as HTMLButtonElement);\n\n switch (e.key) {\n case nextKey:\n e.preventDefault();\n index = (index + 1) % this._tabs.length;\n this._selectTab(index);\n this._tabs[index].focus();\n break;\n case prevKey:\n e.preventDefault();\n index = (index - 1 + this._tabs.length) % this._tabs.length;\n this._selectTab(index);\n this._tabs[index].focus();\n break;\n case 'Home':\n e.preventDefault();\n this._selectTab(0);\n this._tabs[0].focus();\n break;\n case 'End':\n e.preventDefault();\n this._selectTab(this._tabs.length - 1);\n this._tabs[this._tabs.length - 1].focus();\n break;\n }\n };\n}\n\n// Type `addEventListener('change', …)` on this element so the listener receives\n// a `TabsChangeEvent` (with `.index`/`.name`) instead of a bare `Event`, with no\n// cast. This is the canonical reference for the pattern used across the library.\n//\n// Why a same-named interface and not a global augmentation: `change` already\n// exists in lib.dom's `GlobalEventHandlersEventMap` (typed `Event`), and TS\n// forbids re-declaring an existing key with a different type — so colliding\n// names (`change`, `select`, `input`, `toggle`) can't be typed globally. Merging\n// a same-named `interface Tabs` into the `class Tabs` adds these overloads to\n// the element's own type only. (Non-colliding names like `show`/`story-change`\n// ARE augmented globally in their event-class module — no per-element block.)\n//\n// In `(type: K, listener: (ev: TabsEventMap[K]) => void)`, K is pinned to the\n// string literal you pass, and `TabsEventMap[K]` indexes the event type from it\n// (`'change'` → `TabsChangeEvent`). `this: Tabs` is a fake parameter that types\n// `this` inside a non-arrow listener; it emits no runtime code.\n//\n// Three load-bearing details:\n// 1. The `(type: string, …)` fallback overloads are REQUIRED: declaring our\n// own `addEventListener` hides ALL inherited `HTMLElement` overloads, so\n// without them `addEventListener('click', …)` would stop compiling. The\n// typed overload comes first so `'change'` resolves before the fallback.\n// 2. This interface sits AFTER the class: the CEM analyzer dedups on the first\n// declaration of the name, so an interface before the class would shadow it\n// and the manifest would extract nothing for this element.\n// 3. The merge is flagged by `no-unsafe-declaration-merging` (disabled on the\n// class above) — safe here because we merge only method overloads, never\n// uninitialized properties.\nexport interface Tabs {\n addEventListener<K extends keyof TabsEventMap>(\n type: K,\n listener: (this: Tabs, ev: TabsEventMap[K]) => void,\n options?: boolean | AddEventListenerOptions,\n ): void;\n addEventListener(\n type: string,\n listener: EventListenerOrEventListenerObject,\n options?: boolean | AddEventListenerOptions,\n ): void;\n removeEventListener<K extends keyof TabsEventMap>(\n type: K,\n listener: (this: Tabs, ev: TabsEventMap[K]) => void,\n options?: boolean | EventListenerOptions,\n ): void;\n removeEventListener(\n type: string,\n listener: EventListenerOrEventListenerObject,\n options?: boolean | EventListenerOptions,\n ): void;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tabs.js","names":[],"sources":["../../../src/html/elements/tabs/tabs.ts"],"sourcesContent":["import { property } from 'lit/decorators.js';\nimport { LuxenElement } from '../../shared/luxen-element.js';\nimport { uniqueId } from '../../registry.js';\n\nexport type TabsVariant = 'enclosed' | 'line';\nexport type TabsOrientation = 'horizontal' | 'vertical';\n\n/** Fired when the active tab changes. Bubbles; not composed. */\nexport class TabsChangeEvent extends Event {\n readonly index: number;\n readonly name: string | null;\n constructor(index: number, name: string | null) {\n super('change', { bubbles: true, composed: false, cancelable: false });\n this.index = index;\n this.name = name;\n }\n}\n\ninterface TabsEventMap {\n change: TabsChangeEvent;\n}\n\n/**\n * @summary A tabs component that progressively enhances light DOM markup\n * with ARIA roles, keyboard navigation, and animated indicators.\n *\n * @example\n * Mark the initially-active tab with `aria-selected=\"true\"` so it is styled as\n * selected before the script upgrades the element (progressive enhancement).\n * ```html\n * <l-tabs variant=\"enclosed\">\n * <div>\n * <button aria-selected=\"true\">Tab 1</button>\n * <button>Tab 2</button>\n * </div>\n * <div>Content 1</div>\n * <div>Content 2</div>\n * </l-tabs>\n * ```\n *\n * @example\n * A tab button marked `disabled` (or `aria-disabled=\"true\"`) is skipped by\n * arrow keys, `Home` and `End`, and cannot be selected by a click or by\n * `value` — it still owns its panel, so the tab/panel order is unchanged.\n * ```html\n * <button disabled>Billing</button>\n * ```\n *\n * @event change - Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.\n *\n * @cssproperty [--indicator-color=var(--l-color-text-primary)] - `line` variant: color of the active underline that slides under the selected tab.\n * @cssproperty [--indicator-thickness=2px] - `line` variant: thickness of the active underline.\n * @cssproperty [--track-color=var(--l-color-border)] - `line` variant: color of the static bottom border the tabs sit on.\n * @cssproperty [--track-thickness=1px] - `line` variant: thickness of the static bottom border.\n * @cssproperty [--hover-color=var(--l-color-bg-state-hover)] - `line` variant: fill of the pill that appears behind a hovered tab label.\n * @cssproperty [--hover-inset=4px] - `line` variant: block-axis gap between the hover pill and the tab edges, keeping it detached from the bottom border.\n *\n * @customElement l-tabs\n */\n// oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- typed addEventListener overloads merged below; no uninitialized properties.\nexport class Tabs extends LuxenElement {\n override createRenderRoot() {\n return this;\n }\n\n private _initialized = false;\n private _setupTimer = 0;\n private _instanceId = uniqueId('tabs');\n private _tablistEl: HTMLElement | null = null;\n private _tabs: HTMLButtonElement[] = [];\n private _panels: HTMLElement[] = [];\n private _resizeObserver: ResizeObserver | null = null;\n\n /** Visual variant. */\n @property({ reflect: true })\n variant: TabsVariant = 'line';\n\n /** Index of the active tab (0-based). */\n @property({ reflect: true })\n value = '0';\n\n /** Stretch tabs to fill container width. */\n @property({ type: Boolean, reflect: true, attribute: 'full-width' })\n fullWidth = false;\n\n /** Tab orientation. */\n @property({ reflect: true })\n orientation: TabsOrientation = 'horizontal';\n\n // --- Lifecycle ---\n\n override connectedCallback() {\n super.connectedCallback();\n // Children may not be parsed yet when the element upgrades mid-parse: try\n // synchronously, then retry once on a macrotask. setTimeout, not rAF — rAF\n // is suspended in hidden documents, so the element would stay inert there.\n if (!this._trySetup()) {\n this._setupTimer = window.setTimeout(() => this._trySetup(), 0);\n }\n }\n\n override disconnectedCallback() {\n super.disconnectedCallback();\n clearTimeout(this._setupTimer);\n this._teardown();\n }\n\n /** @returns true when setup ran or was already done; false to schedule a retry. */\n private _trySetup(): boolean {\n if (this._initialized || !this.isConnected) return true;\n if (this.children.length < 2) return false;\n this._setup();\n return true;\n }\n\n override updated(changed: Map<string, unknown>) {\n if (changed.has('value') && this._tabs.length) {\n const requested = Number(this.value);\n if (this._isSelectable(requested)) {\n this._selectTab(requested, false);\n } else {\n // The target is disabled or out of range, so the selection stays put —\n // and `value` must not keep claiming a tab that was never selected.\n // Reverting here would schedule an update from within an update, so\n // defer it (same reason as a vetoed dialog `show`).\n const current = this._activeIndex();\n queueMicrotask(() => {\n this.value = String(current);\n });\n }\n }\n if (changed.has('orientation') && this._tablistEl) {\n this._tablistEl.setAttribute('aria-orientation', this.orientation);\n }\n }\n\n // --- Setup / Teardown ---\n\n private _setup() {\n const children = Array.from(this.children) as HTMLElement[];\n if (children.length < 2) return;\n this._initialized = true;\n\n // First child is the tablist container\n this._tablistEl = children[0];\n this._tablistEl.setAttribute('role', 'tablist');\n this._tablistEl.setAttribute('aria-orientation', this.orientation);\n\n // Buttons inside tablist become tabs\n this._tabs = Array.from(this._tablistEl.querySelectorAll('button'));\n\n // Remaining children become panels. Disabled buttons stay in `_tabs` so the\n // tab↔panel pairing keeps following DOM order — they are filtered out at\n // navigation time, not here.\n this._panels = children.slice(1);\n\n const activeIndex = this._resolveActiveIndex();\n\n // Enhance tabs\n for (let i = 0; i < this._tabs.length; i++) {\n const tab = this._tabs[i];\n const panel = this._panels[i];\n const name = tab.getAttribute('name') ?? String(i);\n const tabId = `${this._instanceId}-tab-${name}`;\n const panelId = `${this._instanceId}-panel-${name}`;\n\n tab.setAttribute('role', 'tab');\n tab.setAttribute('id', tabId);\n tab.setAttribute('aria-selected', String(i === activeIndex));\n tab.setAttribute('tabindex', i === activeIndex ? '0' : '-1');\n\n if (panel) {\n tab.setAttribute('aria-controls', panelId);\n panel.setAttribute('role', 'tabpanel');\n panel.setAttribute('id', panelId);\n panel.setAttribute('aria-labelledby', tabId);\n panel.setAttribute('tabindex', '0');\n if (i !== activeIndex) {\n panel.hidden = true;\n } else {\n panel.hidden = false;\n }\n }\n }\n\n // `value` may have pointed at a disabled tab: canonicalize it to the tab\n // that is actually selected.\n if (Number(this.value) !== activeIndex) this.value = String(activeIndex);\n\n // Attach listeners\n this._tablistEl.addEventListener('click', this._onClick);\n this._tablistEl.addEventListener('keydown', this._onKeyDown);\n\n // Initial indicator position. Synchronous first (offset reads force\n // layout), then a ResizeObserver keeps it correct across hidden→visible\n // transitions and size changes — rAF is suspended in hidden documents,\n // so it must not be the only scheduling mechanism here.\n this._updateIndicator();\n this._resizeObserver = new ResizeObserver(() => this._updateIndicator());\n this._resizeObserver.observe(this._tablistEl);\n }\n\n private _teardown() {\n this._tablistEl?.removeEventListener('click', this._onClick);\n this._tablistEl?.removeEventListener('keydown', this._onKeyDown);\n this._resizeObserver?.disconnect();\n this._resizeObserver = null;\n this._initialized = false;\n }\n\n // --- Tab selection ---\n\n private _selectTab(index: number, emitEvent = true) {\n // A disabled tab is never selectable: it cannot take focus, so selecting it\n // would move the roving tabindex entry point onto an unfocusable button and\n // drop the whole tablist out of the tab sequence (WCAG 2.1.1).\n if (!this._isSelectable(index)) return;\n\n // Whether this is an actual change — re-selecting the current tab must not\n // re-fire `change` (the event is documented as firing when the active tab\n // changes, and this matches native `change` on radios / `<select>`).\n const changed = this._tabs[index].getAttribute('aria-selected') !== 'true';\n\n for (let i = 0; i < this._tabs.length; i++) {\n const tab = this._tabs[i];\n const panel = this._panels[i];\n const isActive = i === index;\n\n tab.setAttribute('aria-selected', String(isActive));\n tab.setAttribute('tabindex', isActive ? '0' : '-1');\n\n if (panel) {\n panel.hidden = !isActive;\n }\n }\n\n this.value = String(index);\n this._updateIndicator();\n\n if (emitEvent && changed) {\n const name = this._tabs[index]?.getAttribute('name') ?? null;\n this.dispatchEvent(new TabsChangeEvent(index, name));\n }\n }\n\n // --- Disabled tabs ---\n\n /**\n * Both spellings count: native `disabled` (unfocusable) and `aria-disabled`\n * (focusable, so a user can still land on it — navigation must move off it).\n */\n private _isDisabled(tab: HTMLButtonElement): boolean {\n return tab.disabled || tab.getAttribute('aria-disabled') === 'true';\n }\n\n private _isSelectable(index: number): boolean {\n return (\n Number.isInteger(index) &&\n index >= 0 &&\n index < this._tabs.length &&\n !this._isDisabled(this._tabs[index])\n );\n }\n\n /** Index of the currently selected tab, read back from the DOM. */\n private _activeIndex(): number {\n const i = this._tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');\n return i >= 0 ? i : 0;\n }\n\n /** Resolve the initial selection from `value`, falling back to the first enabled tab. */\n private _resolveActiveIndex(): number {\n const requested = Number(this.value) || 0;\n if (this._isSelectable(requested)) return requested;\n const firstEnabled = this._tabs.findIndex((t) => !this._isDisabled(t));\n // Every tab disabled: nothing is focusable anyway, so index 0 is as good as\n // any and the tablist is simply unreachable — which is what was asked for.\n return firstEnabled >= 0 ? firstEnabled : 0;\n }\n\n /** Index of the next enabled tab from `from`, moving by `dir`, wrapping. */\n private _nextEnabled(from: number, dir: 1 | -1): number {\n const len = this._tabs.length;\n for (let step = 1; step <= len; step++) {\n const i = (from + dir * step + len * step) % len;\n if (!this._isDisabled(this._tabs[i])) return i;\n }\n return from;\n }\n\n /** Index of the first (`dir` 1) or last (`dir` -1) enabled tab. */\n private _edgeEnabled(dir: 1 | -1): number {\n const len = this._tabs.length;\n const start = dir === 1 ? 0 : len - 1;\n for (let i = start; i >= 0 && i < len; i += dir) {\n if (!this._isDisabled(this._tabs[i])) return i;\n }\n return start;\n }\n\n // --- Indicator ---\n\n private _updateIndicator() {\n if (!this._tablistEl) return;\n const activeTab = this._tabs[this._activeIndex()];\n if (!activeTab) return;\n\n const isVertical = this.orientation === 'vertical';\n\n if (isVertical) {\n this._tablistEl.style.setProperty('--_indicator-top', `${activeTab.offsetTop}px`);\n this._tablistEl.style.setProperty('--_indicator-height', `${activeTab.offsetHeight}px`);\n } else {\n this._tablistEl.style.setProperty('--_indicator-left', `${activeTab.offsetLeft}px`);\n this._tablistEl.style.setProperty('--_indicator-width', `${activeTab.offsetWidth}px`);\n }\n }\n\n // --- Event handlers ---\n\n private _onClick = (e: Event) => {\n const tab = (e.target as HTMLElement).closest<HTMLButtonElement>('[role=\"tab\"]');\n if (!tab) return;\n const index = this._tabs.indexOf(tab);\n // A native `disabled` button dispatches no click at all, but an\n // `aria-disabled` one does — `_selectTab` refuses it, and focus must not\n // follow either.\n if (index < 0 || this._isDisabled(tab)) return;\n this._selectTab(index);\n tab.focus();\n };\n\n private _onKeyDown = (e: KeyboardEvent) => {\n const target = e.target as HTMLElement;\n if (target.getAttribute('role') !== 'tab') return;\n\n const isHorizontal = this.orientation === 'horizontal';\n const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';\n const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';\n\n const current = this._tabs.indexOf(target as HTMLButtonElement);\n if (current < 0) return;\n\n // Every move lands on an enabled tab: selection follows focus here\n // (automatic activation), so a disabled target would split the two apart.\n let index: number;\n switch (e.key) {\n case nextKey:\n index = this._nextEnabled(current, 1);\n break;\n case prevKey:\n index = this._nextEnabled(current, -1);\n break;\n case 'Home':\n index = this._edgeEnabled(1);\n break;\n case 'End':\n index = this._edgeEnabled(-1);\n break;\n default:\n return;\n }\n\n e.preventDefault();\n // Only reachable when every tab is disabled — then there is nowhere to go\n // and focus must not be dragged onto a disabled tab.\n if (!this._isSelectable(index)) return;\n this._selectTab(index);\n this._tabs[index].focus();\n };\n}\n\n// Type `addEventListener('change', …)` on this element so the listener receives\n// a `TabsChangeEvent` (with `.index`/`.name`) instead of a bare `Event`, with no\n// cast. This is the canonical reference for the pattern used across the library.\n//\n// Why a same-named interface and not a global augmentation: `change` already\n// exists in lib.dom's `GlobalEventHandlersEventMap` (typed `Event`), and TS\n// forbids re-declaring an existing key with a different type — so colliding\n// names (`change`, `select`, `input`, `toggle`) can't be typed globally. Merging\n// a same-named `interface Tabs` into the `class Tabs` adds these overloads to\n// the element's own type only. (Non-colliding names like `show`/`story-change`\n// ARE augmented globally in their event-class module — no per-element block.)\n//\n// In `(type: K, listener: (ev: TabsEventMap[K]) => void)`, K is pinned to the\n// string literal you pass, and `TabsEventMap[K]` indexes the event type from it\n// (`'change'` → `TabsChangeEvent`). `this: Tabs` is a fake parameter that types\n// `this` inside a non-arrow listener; it emits no runtime code.\n//\n// Three load-bearing details:\n// 1. The `(type: string, …)` fallback overloads are REQUIRED: declaring our\n// own `addEventListener` hides ALL inherited `HTMLElement` overloads, so\n// without them `addEventListener('click', …)` would stop compiling. The\n// typed overload comes first so `'change'` resolves before the fallback.\n// 2. This interface sits AFTER the class: the CEM analyzer dedups on the first\n// declaration of the name, so an interface before the class would shadow it\n// and the manifest would extract nothing for this element.\n// 3. The merge is flagged by `no-unsafe-declaration-merging` (disabled on the\n// class above) — safe here because we merge only method overloads, never\n// uninitialized properties.\nexport interface Tabs {\n addEventListener<K extends keyof TabsEventMap>(\n type: K,\n listener: (this: Tabs, ev: TabsEventMap[K]) => void,\n options?: boolean | AddEventListenerOptions,\n ): void;\n addEventListener(\n type: string,\n listener: EventListenerOrEventListenerObject,\n options?: boolean | AddEventListenerOptions,\n ): void;\n removeEventListener<K extends keyof TabsEventMap>(\n type: K,\n listener: (this: Tabs, ev: TabsEventMap[K]) => void,\n options?: boolean | EventListenerOptions,\n ): void;\n removeEventListener(\n type: string,\n listener: EventListenerOrEventListenerObject,\n options?: boolean | EventListenerOptions,\n ): void;\n}\n"],"mappings":"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"}
|
package/cdn/standalone.css
CHANGED
|
@@ -5406,11 +5406,19 @@ In dark mode, mixes the base color with black (default 15% black).
|
|
|
5406
5406
|
}
|
|
5407
5407
|
|
|
5408
5408
|
@media (hover: hover) {
|
|
5409
|
-
&:hover:not([aria-selected='true']) {
|
|
5409
|
+
&:hover:not([aria-selected='true']):not(:disabled):not([aria-disabled='true']) {
|
|
5410
5410
|
color: var(--l-color-text-primary);
|
|
5411
5411
|
}
|
|
5412
5412
|
}
|
|
5413
5413
|
|
|
5414
|
+
/* Placed after the selected and hover rules so a disabled tab still greys
|
|
5415
|
+
out when it is the selected one — equal specificity, source order wins. */
|
|
5416
|
+
&:disabled,
|
|
5417
|
+
&[aria-disabled='true'] {
|
|
5418
|
+
cursor: not-allowed;
|
|
5419
|
+
color: var(--l-color-text-disabled);
|
|
5420
|
+
}
|
|
5421
|
+
|
|
5414
5422
|
&:focus-visible {
|
|
5415
5423
|
outline: 2px solid var(--l-focus-ring);
|
|
5416
5424
|
outline-offset: -2px;
|
|
@@ -5561,11 +5569,19 @@ In dark mode, mixes the base color with black (default 15% black).
|
|
|
5561
5569
|
}
|
|
5562
5570
|
|
|
5563
5571
|
@media (hover: hover) {
|
|
5564
|
-
&:hover:not([aria-selected='true']) {
|
|
5572
|
+
&:hover:not([aria-selected='true']):not(:disabled):not([aria-disabled='true']) {
|
|
5565
5573
|
color: var(--l-color-text-primary);
|
|
5566
5574
|
}
|
|
5567
5575
|
}
|
|
5568
5576
|
|
|
5577
|
+
/* Placed after the selected and hover rules so a disabled tab still greys
|
|
5578
|
+
out when it is the selected one — equal specificity, source order wins. */
|
|
5579
|
+
&:disabled,
|
|
5580
|
+
&[aria-disabled='true'] {
|
|
5581
|
+
cursor: not-allowed;
|
|
5582
|
+
color: var(--l-color-text-disabled);
|
|
5583
|
+
}
|
|
5584
|
+
|
|
5569
5585
|
&:focus-visible {
|
|
5570
5586
|
outline: 2px solid var(--l-focus-ring);
|
|
5571
5587
|
outline-offset: -2px;
|
|
@@ -5649,9 +5665,12 @@ In dark mode, mixes the base color with black (default 15% black).
|
|
|
5649
5665
|
|
|
5650
5666
|
/* Applies to the selected tab too — the fill is click feedback, not a
|
|
5651
5667
|
selection cue (the underline carries that), so it would read as dead
|
|
5652
|
-
if the active tab were the one place it stopped responding.
|
|
5668
|
+
if the active tab were the one place it stopped responding. A disabled
|
|
5669
|
+
tab is the exception: `:hover` still matches it (the platform does not
|
|
5670
|
+
gate hover on `disabled`), and promising click feedback where no click
|
|
5671
|
+
will ever register is the wrong signal. */
|
|
5653
5672
|
@media (hover: hover) {
|
|
5654
|
-
&:hover::after {
|
|
5673
|
+
&:hover:not(:disabled):not([aria-disabled='true'])::after {
|
|
5655
5674
|
background: var(--hover-color, var(--l-color-bg-state-hover));
|
|
5656
5675
|
}
|
|
5657
5676
|
}
|
|
@@ -73,11 +73,19 @@
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
@media (hover: hover) {
|
|
76
|
-
&:hover:not([aria-selected='true']) {
|
|
76
|
+
&:hover:not([aria-selected='true']):not(:disabled):not([aria-disabled='true']) {
|
|
77
77
|
color: var(--l-color-text-primary);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/* Placed after the selected and hover rules so a disabled tab still greys
|
|
82
|
+
out when it is the selected one — equal specificity, source order wins. */
|
|
83
|
+
&:disabled,
|
|
84
|
+
&[aria-disabled='true'] {
|
|
85
|
+
cursor: not-allowed;
|
|
86
|
+
color: var(--l-color-text-disabled);
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
&:focus-visible {
|
|
82
90
|
outline: 2px solid var(--l-focus-ring);
|
|
83
91
|
outline-offset: -2px;
|
|
@@ -73,11 +73,19 @@
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
@media (hover: hover) {
|
|
76
|
-
&:hover:not([aria-selected='true']) {
|
|
76
|
+
&:hover:not([aria-selected='true']):not(:disabled):not([aria-disabled='true']) {
|
|
77
77
|
color: var(--l-color-text-primary);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/* Placed after the selected and hover rules so a disabled tab still greys
|
|
82
|
+
out when it is the selected one — equal specificity, source order wins. */
|
|
83
|
+
&:disabled,
|
|
84
|
+
&[aria-disabled='true'] {
|
|
85
|
+
cursor: not-allowed;
|
|
86
|
+
color: var(--l-color-text-disabled);
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
&:focus-visible {
|
|
82
90
|
outline: 2px solid var(--l-focus-ring);
|
|
83
91
|
outline-offset: -2px;
|
|
@@ -161,9 +169,12 @@
|
|
|
161
169
|
|
|
162
170
|
/* Applies to the selected tab too — the fill is click feedback, not a
|
|
163
171
|
selection cue (the underline carries that), so it would read as dead
|
|
164
|
-
if the active tab were the one place it stopped responding.
|
|
172
|
+
if the active tab were the one place it stopped responding. A disabled
|
|
173
|
+
tab is the exception: `:hover` still matches it (the platform does not
|
|
174
|
+
gate hover on `disabled`), and promising click feedback where no click
|
|
175
|
+
will ever register is the wrong signal. */
|
|
165
176
|
@media (hover: hover) {
|
|
166
|
-
&:hover::after {
|
|
177
|
+
&:hover:not(:disabled):not([aria-disabled='true'])::after {
|
|
167
178
|
background: var(--hover-color, var(--l-color-bg-state-hover));
|
|
168
179
|
}
|
|
169
180
|
}
|
|
@@ -73,11 +73,19 @@
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
@media (hover: hover) {
|
|
76
|
-
&:hover:not([aria-selected='true']) {
|
|
76
|
+
&:hover:not([aria-selected='true']):not(:disabled):not([aria-disabled='true']) {
|
|
77
77
|
color: var(--l-color-text-primary);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/* Placed after the selected and hover rules so a disabled tab still greys
|
|
82
|
+
out when it is the selected one — equal specificity, source order wins. */
|
|
83
|
+
&:disabled,
|
|
84
|
+
&[aria-disabled='true'] {
|
|
85
|
+
cursor: not-allowed;
|
|
86
|
+
color: var(--l-color-text-disabled);
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
&:focus-visible {
|
|
82
90
|
outline: 2px solid var(--l-focus-ring);
|
|
83
91
|
outline-offset: -2px;
|
|
@@ -73,11 +73,19 @@
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
@media (hover: hover) {
|
|
76
|
-
&:hover:not([aria-selected='true']) {
|
|
76
|
+
&:hover:not([aria-selected='true']):not(:disabled):not([aria-disabled='true']) {
|
|
77
77
|
color: var(--l-color-text-primary);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/* Placed after the selected and hover rules so a disabled tab still greys
|
|
82
|
+
out when it is the selected one — equal specificity, source order wins. */
|
|
83
|
+
&:disabled,
|
|
84
|
+
&[aria-disabled='true'] {
|
|
85
|
+
cursor: not-allowed;
|
|
86
|
+
color: var(--l-color-text-disabled);
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
&:focus-visible {
|
|
82
90
|
outline: 2px solid var(--l-focus-ring);
|
|
83
91
|
outline-offset: -2px;
|
|
@@ -161,9 +169,12 @@
|
|
|
161
169
|
|
|
162
170
|
/* Applies to the selected tab too — the fill is click feedback, not a
|
|
163
171
|
selection cue (the underline carries that), so it would read as dead
|
|
164
|
-
if the active tab were the one place it stopped responding.
|
|
172
|
+
if the active tab were the one place it stopped responding. A disabled
|
|
173
|
+
tab is the exception: `:hover` still matches it (the platform does not
|
|
174
|
+
gate hover on `disabled`), and promising click feedback where no click
|
|
175
|
+
will ever register is the wrong signal. */
|
|
165
176
|
@media (hover: hover) {
|
|
166
|
-
&:hover::after {
|
|
177
|
+
&:hover:not(:disabled):not([aria-disabled='true'])::after {
|
|
167
178
|
background: var(--hover-color, var(--l-color-bg-state-hover));
|
|
168
179
|
}
|
|
169
180
|
}
|
|
@@ -8634,7 +8634,7 @@
|
|
|
8634
8634
|
"events": [
|
|
8635
8635
|
{
|
|
8636
8636
|
"name": "change",
|
|
8637
|
-
"description": "Fired when the active tab changes. Bubbles. Properties: `index: number`, `name: string | null`.",
|
|
8637
|
+
"description": "Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.",
|
|
8638
8638
|
"cancelable": false
|
|
8639
8639
|
}
|
|
8640
8640
|
],
|
|
@@ -8688,6 +8688,11 @@
|
|
|
8688
8688
|
"title": "Mark the initially-active tab with `aria-selected=\"true\"` so it is styled as",
|
|
8689
8689
|
"language": "html",
|
|
8690
8690
|
"code": "selected before the script upgrades the element (progressive enhancement).\n```html\n<l-tabs variant=\"enclosed\">\n <div>\n <button aria-selected=\"true\">Tab 1</button>\n <button>Tab 2</button>\n </div>\n <div>Content 1</div>\n <div>Content 2</div>\n</l-tabs>\n```"
|
|
8691
|
+
},
|
|
8692
|
+
{
|
|
8693
|
+
"title": "A tab button marked `disabled` (or `aria-disabled=\"true\"`) is skipped by",
|
|
8694
|
+
"language": "html",
|
|
8695
|
+
"code": "arrow keys, `Home` and `End`, and cannot be selected by a click or by\n`value` — it still owns its panel, so the tab/panel order is unchanged.\n```html\n<button disabled>Billing</button>\n```"
|
|
8691
8696
|
}
|
|
8692
8697
|
]
|
|
8693
8698
|
}
|
|
@@ -28,7 +28,15 @@ interface TabsEventMap {
|
|
|
28
28
|
* </l-tabs>
|
|
29
29
|
* ```
|
|
30
30
|
*
|
|
31
|
-
* @
|
|
31
|
+
* @example
|
|
32
|
+
* A tab button marked `disabled` (or `aria-disabled="true"`) is skipped by
|
|
33
|
+
* arrow keys, `Home` and `End`, and cannot be selected by a click or by
|
|
34
|
+
* `value` — it still owns its panel, so the tab/panel order is unchanged.
|
|
35
|
+
* ```html
|
|
36
|
+
* <button disabled>Billing</button>
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @event change - Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.
|
|
32
40
|
*
|
|
33
41
|
* @cssproperty [--indicator-color=var(--l-color-text-primary)] - `line` variant: color of the active underline that slides under the selected tab.
|
|
34
42
|
* @cssproperty [--indicator-thickness=2px] - `line` variant: thickness of the active underline.
|
|
@@ -64,6 +72,20 @@ export declare class Tabs extends LuxenElement {
|
|
|
64
72
|
private _setup;
|
|
65
73
|
private _teardown;
|
|
66
74
|
private _selectTab;
|
|
75
|
+
/**
|
|
76
|
+
* Both spellings count: native `disabled` (unfocusable) and `aria-disabled`
|
|
77
|
+
* (focusable, so a user can still land on it — navigation must move off it).
|
|
78
|
+
*/
|
|
79
|
+
private _isDisabled;
|
|
80
|
+
private _isSelectable;
|
|
81
|
+
/** Index of the currently selected tab, read back from the DOM. */
|
|
82
|
+
private _activeIndex;
|
|
83
|
+
/** Resolve the initial selection from `value`, falling back to the first enabled tab. */
|
|
84
|
+
private _resolveActiveIndex;
|
|
85
|
+
/** Index of the next enabled tab from `from`, moving by `dir`, wrapping. */
|
|
86
|
+
private _nextEnabled;
|
|
87
|
+
/** Index of the first (`dir` 1) or last (`dir` -1) enabled tab. */
|
|
88
|
+
private _edgeEnabled;
|
|
67
89
|
private _updateIndicator;
|
|
68
90
|
private _onClick;
|
|
69
91
|
private _onKeyDown;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../../src/html/elements/tabs/tabs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAG7D,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,MAAM,CAAC;AAC9C,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,UAAU,CAAC;AAExD,gEAAgE;AAChE,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;gBACjB,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;CAK/C;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,eAAe,CAAC;CACzB;AAED
|
|
1
|
+
{"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../../src/html/elements/tabs/tabs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAG7D,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,MAAM,CAAC;AAC9C,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,UAAU,CAAC;AAExD,gEAAgE;AAChE,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;gBACjB,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;CAK/C;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,eAAe,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,qBAAa,IAAK,SAAQ,YAAY;IAC3B,gBAAgB;IAIzB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,WAAW,CAAoB;IACvC,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,eAAe,CAA+B;IAEtD,sBAAsB;IAEtB,OAAO,EAAE,WAAW,CAAU;IAE9B,yCAAyC;IAEzC,KAAK,SAAO;IAEZ,4CAA4C;IAE5C,SAAS,UAAS;IAElB,uBAAuB;IAEvB,WAAW,EAAE,eAAe,CAAgB;IAInC,iBAAiB;IAUjB,oBAAoB;IAM7B,mFAAmF;IACnF,OAAO,CAAC,SAAS;IAOR,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAuB9C,OAAO,CAAC,MAAM;IAgEd,OAAO,CAAC,SAAS;IAUjB,OAAO,CAAC,UAAU;IAmClB;;;OAGG;IACH,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,aAAa;IASrB,mEAAmE;IACnE,OAAO,CAAC,YAAY;IAKpB,yFAAyF;IACzF,OAAO,CAAC,mBAAmB;IAS3B,4EAA4E;IAC5E,OAAO,CAAC,YAAY;IASpB,mEAAmE;IACnE,OAAO,CAAC,YAAY;IAWpB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,QAAQ,CAUd;IAEF,OAAO,CAAC,UAAU,CAqChB;CACH;AA8BD,MAAM,WAAW,IAAI;IACnB,gBAAgB,CAAC,CAAC,SAAS,MAAM,YAAY,EAC3C,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IACR,gBAAgB,CACd,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,kCAAkC,EAC5C,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IACR,mBAAmB,CAAC,CAAC,SAAS,MAAM,YAAY,EAC9C,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IACR,mBAAmB,CACjB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,kCAAkC,EAC5C,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;CACT"}
|
|
@@ -33,7 +33,15 @@ export class TabsChangeEvent extends Event {
|
|
|
33
33
|
* </l-tabs>
|
|
34
34
|
* ```
|
|
35
35
|
*
|
|
36
|
-
* @
|
|
36
|
+
* @example
|
|
37
|
+
* A tab button marked `disabled` (or `aria-disabled="true"`) is skipped by
|
|
38
|
+
* arrow keys, `Home` and `End`, and cannot be selected by a click or by
|
|
39
|
+
* `value` — it still owns its panel, so the tab/panel order is unchanged.
|
|
40
|
+
* ```html
|
|
41
|
+
* <button disabled>Billing</button>
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @event change - Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.
|
|
37
45
|
*
|
|
38
46
|
* @cssproperty [--indicator-color=var(--l-color-text-primary)] - `line` variant: color of the active underline that slides under the selected tab.
|
|
39
47
|
* @cssproperty [--indicator-thickness=2px] - `line` variant: thickness of the active underline.
|
|
@@ -69,10 +77,13 @@ export class Tabs extends LuxenElement {
|
|
|
69
77
|
if (!tab)
|
|
70
78
|
return;
|
|
71
79
|
const index = this._tabs.indexOf(tab);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
// A native `disabled` button dispatches no click at all, but an
|
|
81
|
+
// `aria-disabled` one does — `_selectTab` refuses it, and focus must not
|
|
82
|
+
// follow either.
|
|
83
|
+
if (index < 0 || this._isDisabled(tab))
|
|
84
|
+
return;
|
|
85
|
+
this._selectTab(index);
|
|
86
|
+
tab.focus();
|
|
76
87
|
};
|
|
77
88
|
this._onKeyDown = (e) => {
|
|
78
89
|
const target = e.target;
|
|
@@ -81,31 +92,35 @@ export class Tabs extends LuxenElement {
|
|
|
81
92
|
const isHorizontal = this.orientation === 'horizontal';
|
|
82
93
|
const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
|
|
83
94
|
const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';
|
|
84
|
-
|
|
95
|
+
const current = this._tabs.indexOf(target);
|
|
96
|
+
if (current < 0)
|
|
97
|
+
return;
|
|
98
|
+
// Every move lands on an enabled tab: selection follows focus here
|
|
99
|
+
// (automatic activation), so a disabled target would split the two apart.
|
|
100
|
+
let index;
|
|
85
101
|
switch (e.key) {
|
|
86
102
|
case nextKey:
|
|
87
|
-
|
|
88
|
-
index = (index + 1) % this._tabs.length;
|
|
89
|
-
this._selectTab(index);
|
|
90
|
-
this._tabs[index].focus();
|
|
103
|
+
index = this._nextEnabled(current, 1);
|
|
91
104
|
break;
|
|
92
105
|
case prevKey:
|
|
93
|
-
|
|
94
|
-
index = (index - 1 + this._tabs.length) % this._tabs.length;
|
|
95
|
-
this._selectTab(index);
|
|
96
|
-
this._tabs[index].focus();
|
|
106
|
+
index = this._nextEnabled(current, -1);
|
|
97
107
|
break;
|
|
98
108
|
case 'Home':
|
|
99
|
-
|
|
100
|
-
this._selectTab(0);
|
|
101
|
-
this._tabs[0].focus();
|
|
109
|
+
index = this._edgeEnabled(1);
|
|
102
110
|
break;
|
|
103
111
|
case 'End':
|
|
104
|
-
|
|
105
|
-
this._selectTab(this._tabs.length - 1);
|
|
106
|
-
this._tabs[this._tabs.length - 1].focus();
|
|
112
|
+
index = this._edgeEnabled(-1);
|
|
107
113
|
break;
|
|
114
|
+
default:
|
|
115
|
+
return;
|
|
108
116
|
}
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
// Only reachable when every tab is disabled — then there is nowhere to go
|
|
119
|
+
// and focus must not be dragged onto a disabled tab.
|
|
120
|
+
if (!this._isSelectable(index))
|
|
121
|
+
return;
|
|
122
|
+
this._selectTab(index);
|
|
123
|
+
this._tabs[index].focus();
|
|
109
124
|
};
|
|
110
125
|
}
|
|
111
126
|
createRenderRoot() {
|
|
@@ -137,7 +152,20 @@ export class Tabs extends LuxenElement {
|
|
|
137
152
|
}
|
|
138
153
|
updated(changed) {
|
|
139
154
|
if (changed.has('value') && this._tabs.length) {
|
|
140
|
-
|
|
155
|
+
const requested = Number(this.value);
|
|
156
|
+
if (this._isSelectable(requested)) {
|
|
157
|
+
this._selectTab(requested, false);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// The target is disabled or out of range, so the selection stays put —
|
|
161
|
+
// and `value` must not keep claiming a tab that was never selected.
|
|
162
|
+
// Reverting here would schedule an update from within an update, so
|
|
163
|
+
// defer it (same reason as a vetoed dialog `show`).
|
|
164
|
+
const current = this._activeIndex();
|
|
165
|
+
queueMicrotask(() => {
|
|
166
|
+
this.value = String(current);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
141
169
|
}
|
|
142
170
|
if (changed.has('orientation') && this._tablistEl) {
|
|
143
171
|
this._tablistEl.setAttribute('aria-orientation', this.orientation);
|
|
@@ -155,9 +183,11 @@ export class Tabs extends LuxenElement {
|
|
|
155
183
|
this._tablistEl.setAttribute('aria-orientation', this.orientation);
|
|
156
184
|
// Buttons inside tablist become tabs
|
|
157
185
|
this._tabs = Array.from(this._tablistEl.querySelectorAll('button'));
|
|
158
|
-
// Remaining children become panels
|
|
186
|
+
// Remaining children become panels. Disabled buttons stay in `_tabs` so the
|
|
187
|
+
// tab↔panel pairing keeps following DOM order — they are filtered out at
|
|
188
|
+
// navigation time, not here.
|
|
159
189
|
this._panels = children.slice(1);
|
|
160
|
-
const activeIndex =
|
|
190
|
+
const activeIndex = this._resolveActiveIndex();
|
|
161
191
|
// Enhance tabs
|
|
162
192
|
for (let i = 0; i < this._tabs.length; i++) {
|
|
163
193
|
const tab = this._tabs[i];
|
|
@@ -183,6 +213,10 @@ export class Tabs extends LuxenElement {
|
|
|
183
213
|
}
|
|
184
214
|
}
|
|
185
215
|
}
|
|
216
|
+
// `value` may have pointed at a disabled tab: canonicalize it to the tab
|
|
217
|
+
// that is actually selected.
|
|
218
|
+
if (Number(this.value) !== activeIndex)
|
|
219
|
+
this.value = String(activeIndex);
|
|
186
220
|
// Attach listeners
|
|
187
221
|
this._tablistEl.addEventListener('click', this._onClick);
|
|
188
222
|
this._tablistEl.addEventListener('keydown', this._onKeyDown);
|
|
@@ -203,8 +237,15 @@ export class Tabs extends LuxenElement {
|
|
|
203
237
|
}
|
|
204
238
|
// --- Tab selection ---
|
|
205
239
|
_selectTab(index, emitEvent = true) {
|
|
206
|
-
|
|
240
|
+
// A disabled tab is never selectable: it cannot take focus, so selecting it
|
|
241
|
+
// would move the roving tabindex entry point onto an unfocusable button and
|
|
242
|
+
// drop the whole tablist out of the tab sequence (WCAG 2.1.1).
|
|
243
|
+
if (!this._isSelectable(index))
|
|
207
244
|
return;
|
|
245
|
+
// Whether this is an actual change — re-selecting the current tab must not
|
|
246
|
+
// re-fire `change` (the event is documented as firing when the active tab
|
|
247
|
+
// changes, and this matches native `change` on radios / `<select>`).
|
|
248
|
+
const changed = this._tabs[index].getAttribute('aria-selected') !== 'true';
|
|
208
249
|
for (let i = 0; i < this._tabs.length; i++) {
|
|
209
250
|
const tab = this._tabs[i];
|
|
210
251
|
const panel = this._panels[i];
|
|
@@ -217,16 +258,65 @@ export class Tabs extends LuxenElement {
|
|
|
217
258
|
}
|
|
218
259
|
this.value = String(index);
|
|
219
260
|
this._updateIndicator();
|
|
220
|
-
if (emitEvent) {
|
|
261
|
+
if (emitEvent && changed) {
|
|
221
262
|
const name = this._tabs[index]?.getAttribute('name') ?? null;
|
|
222
263
|
this.dispatchEvent(new TabsChangeEvent(index, name));
|
|
223
264
|
}
|
|
224
265
|
}
|
|
266
|
+
// --- Disabled tabs ---
|
|
267
|
+
/**
|
|
268
|
+
* Both spellings count: native `disabled` (unfocusable) and `aria-disabled`
|
|
269
|
+
* (focusable, so a user can still land on it — navigation must move off it).
|
|
270
|
+
*/
|
|
271
|
+
_isDisabled(tab) {
|
|
272
|
+
return tab.disabled || tab.getAttribute('aria-disabled') === 'true';
|
|
273
|
+
}
|
|
274
|
+
_isSelectable(index) {
|
|
275
|
+
return (Number.isInteger(index) &&
|
|
276
|
+
index >= 0 &&
|
|
277
|
+
index < this._tabs.length &&
|
|
278
|
+
!this._isDisabled(this._tabs[index]));
|
|
279
|
+
}
|
|
280
|
+
/** Index of the currently selected tab, read back from the DOM. */
|
|
281
|
+
_activeIndex() {
|
|
282
|
+
const i = this._tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
|
|
283
|
+
return i >= 0 ? i : 0;
|
|
284
|
+
}
|
|
285
|
+
/** Resolve the initial selection from `value`, falling back to the first enabled tab. */
|
|
286
|
+
_resolveActiveIndex() {
|
|
287
|
+
const requested = Number(this.value) || 0;
|
|
288
|
+
if (this._isSelectable(requested))
|
|
289
|
+
return requested;
|
|
290
|
+
const firstEnabled = this._tabs.findIndex((t) => !this._isDisabled(t));
|
|
291
|
+
// Every tab disabled: nothing is focusable anyway, so index 0 is as good as
|
|
292
|
+
// any and the tablist is simply unreachable — which is what was asked for.
|
|
293
|
+
return firstEnabled >= 0 ? firstEnabled : 0;
|
|
294
|
+
}
|
|
295
|
+
/** Index of the next enabled tab from `from`, moving by `dir`, wrapping. */
|
|
296
|
+
_nextEnabled(from, dir) {
|
|
297
|
+
const len = this._tabs.length;
|
|
298
|
+
for (let step = 1; step <= len; step++) {
|
|
299
|
+
const i = (from + dir * step + len * step) % len;
|
|
300
|
+
if (!this._isDisabled(this._tabs[i]))
|
|
301
|
+
return i;
|
|
302
|
+
}
|
|
303
|
+
return from;
|
|
304
|
+
}
|
|
305
|
+
/** Index of the first (`dir` 1) or last (`dir` -1) enabled tab. */
|
|
306
|
+
_edgeEnabled(dir) {
|
|
307
|
+
const len = this._tabs.length;
|
|
308
|
+
const start = dir === 1 ? 0 : len - 1;
|
|
309
|
+
for (let i = start; i >= 0 && i < len; i += dir) {
|
|
310
|
+
if (!this._isDisabled(this._tabs[i]))
|
|
311
|
+
return i;
|
|
312
|
+
}
|
|
313
|
+
return start;
|
|
314
|
+
}
|
|
225
315
|
// --- Indicator ---
|
|
226
316
|
_updateIndicator() {
|
|
227
317
|
if (!this._tablistEl)
|
|
228
318
|
return;
|
|
229
|
-
const activeTab = this._tabs[
|
|
319
|
+
const activeTab = this._tabs[this._activeIndex()];
|
|
230
320
|
if (!activeTab)
|
|
231
321
|
return;
|
|
232
322
|
const isVertical = this.orientation === 'vertical';
|
package/dist/metadata/index.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.18.
|
|
2
|
+
"version": "0.18.3",
|
|
3
3
|
"elements": [
|
|
4
4
|
{
|
|
5
5
|
"name": "alert",
|
|
@@ -5325,7 +5325,7 @@
|
|
|
5325
5325
|
"events": [
|
|
5326
5326
|
{
|
|
5327
5327
|
"name": "change",
|
|
5328
|
-
"description": "Fired when the active tab changes. Bubbles. Properties: `index: number`, `name: string | null`.",
|
|
5328
|
+
"description": "Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.",
|
|
5329
5329
|
"cancelable": false
|
|
5330
5330
|
}
|
|
5331
5331
|
],
|
|
@@ -5371,6 +5371,11 @@
|
|
|
5371
5371
|
"title": "Mark the initially-active tab with `aria-selected=\"true\"` so it is styled as",
|
|
5372
5372
|
"language": "html",
|
|
5373
5373
|
"code": "selected before the script upgrades the element (progressive enhancement).\n```html\n<l-tabs variant=\"enclosed\">\n <div>\n <button aria-selected=\"true\">Tab 1</button>\n <button>Tab 2</button>\n </div>\n <div>Content 1</div>\n <div>Content 2</div>\n</l-tabs>\n```"
|
|
5374
|
+
},
|
|
5375
|
+
{
|
|
5376
|
+
"title": "A tab button marked `disabled` (or `aria-disabled=\"true\"`) is skipped by",
|
|
5377
|
+
"language": "html",
|
|
5378
|
+
"code": "arrow keys, `Home` and `End`, and cannot be selected by a click or by\n`value` — it still owns its panel, so the tab/panel order is unchanged.\n```html\n<button disabled>Billing</button>\n```"
|
|
5374
5379
|
}
|
|
5375
5380
|
]
|
|
5376
5381
|
},
|
package/dist/metadata/tabs.json
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"events": [
|
|
56
56
|
{
|
|
57
57
|
"name": "change",
|
|
58
|
-
"description": "Fired when the active tab changes. Bubbles. Properties: `index: number`, `name: string | null`.",
|
|
58
|
+
"description": "Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.",
|
|
59
59
|
"cancelable": false
|
|
60
60
|
}
|
|
61
61
|
],
|
|
@@ -101,6 +101,11 @@
|
|
|
101
101
|
"title": "Mark the initially-active tab with `aria-selected=\"true\"` so it is styled as",
|
|
102
102
|
"language": "html",
|
|
103
103
|
"code": "selected before the script upgrades the element (progressive enhancement).\n```html\n<l-tabs variant=\"enclosed\">\n <div>\n <button aria-selected=\"true\">Tab 1</button>\n <button>Tab 2</button>\n </div>\n <div>Content 1</div>\n <div>Content 2</div>\n</l-tabs>\n```"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"title": "A tab button marked `disabled` (or `aria-disabled=\"true\"`) is skipped by",
|
|
107
|
+
"language": "html",
|
|
108
|
+
"code": "arrow keys, `Home` and `End`, and cannot be selected by a click or by\n`value` — it still owns its panel, so the tab/panel order is unchanged.\n```html\n<button disabled>Billing</button>\n```"
|
|
104
109
|
}
|
|
105
110
|
]
|
|
106
111
|
}
|
|
@@ -132,6 +132,35 @@ Set `value="1"` to activate a specific tab on load (0-based index).
|
|
|
132
132
|
</l-tabs>
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
### Disabled tab
|
|
136
|
+
|
|
137
|
+
Add `disabled` to a tab button to take it out of play. It is skipped by arrow keys, `Home` and `End`, it cannot be selected by a click or by `value`, and it never shows the hover pill. Use `aria-disabled="true"` instead if the tab should stay focusable so keyboard users can still discover it.
|
|
138
|
+
|
|
139
|
+
```html
|
|
140
|
+
<l-tabs variant="line">
|
|
141
|
+
<div>
|
|
142
|
+
<button
|
|
143
|
+
name="account"
|
|
144
|
+
aria-selected="true"
|
|
145
|
+
>
|
|
146
|
+
Account
|
|
147
|
+
</button>
|
|
148
|
+
<button name="password">Password</button>
|
|
149
|
+
<button
|
|
150
|
+
name="billing"
|
|
151
|
+
disabled
|
|
152
|
+
>
|
|
153
|
+
Billing
|
|
154
|
+
</button>
|
|
155
|
+
<button name="notifications">Notifications</button>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="p-4 text-sm text-secondary">Make changes to your account here.</div>
|
|
158
|
+
<div class="p-4 text-sm text-secondary">Change your password here.</div>
|
|
159
|
+
<div class="p-4 text-sm text-secondary">Upgrade your plan to manage billing.</div>
|
|
160
|
+
<div class="p-4 text-sm text-secondary">Manage your notification preferences.</div>
|
|
161
|
+
</l-tabs>
|
|
162
|
+
```
|
|
163
|
+
|
|
135
164
|
## Accessibility
|
|
136
165
|
|
|
137
166
|
### Criteria
|
|
@@ -140,6 +169,7 @@ Set `value="1"` to activate a specific tab on load (0-based index).
|
|
|
140
169
|
- **Linked controls** — Each tab has `aria-controls` pointing to its panel; each panel has `aria-labelledby` pointing back to its tab
|
|
141
170
|
- **Selection state** — Active tab has `aria-selected="true"`; inactive tabs have `aria-selected="false"`
|
|
142
171
|
- **Focus management** — Roving tabindex — active tab has `tabindex="0"`, others `tabindex="-1"`. Panels have `tabindex="0"` for keyboard access
|
|
172
|
+
- **Disabled tabs** — Tabs marked `disabled` or `aria-disabled="true"` are skipped by arrow keys, `Home` and `End`, and never take the roving `tabindex="0"` — so the tablist always keeps a reachable entry point
|
|
143
173
|
- **Hidden content** — Inactive panels use the `hidden` attribute
|
|
144
174
|
- **Motion** — Indicator animation respects `prefers-reduced-motion`
|
|
145
175
|
|
|
@@ -151,12 +181,12 @@ Set `value="1"` to activate a specific tab on load (0-based index).
|
|
|
151
181
|
|
|
152
182
|
### Keyboard interactions
|
|
153
183
|
|
|
154
|
-
- `ArrowRight` — Moves focus to the next tab and activates it (horizontal orientation)
|
|
155
|
-
- `ArrowLeft` — Moves focus to the previous tab and activates it (horizontal orientation)
|
|
156
|
-
- `ArrowDown` — Moves focus to the next tab and activates it (vertical orientation)
|
|
157
|
-
- `ArrowUp` — Moves focus to the previous tab and activates it (vertical orientation)
|
|
158
|
-
- `Home` — Moves focus to the first tab and activates it
|
|
159
|
-
- `End` — Moves focus to the last tab and activates it
|
|
184
|
+
- `ArrowRight` — Moves focus to the next enabled tab and activates it (horizontal orientation)
|
|
185
|
+
- `ArrowLeft` — Moves focus to the previous enabled tab and activates it (horizontal orientation)
|
|
186
|
+
- `ArrowDown` — Moves focus to the next enabled tab and activates it (vertical orientation)
|
|
187
|
+
- `ArrowUp` — Moves focus to the previous enabled tab and activates it (vertical orientation)
|
|
188
|
+
- `Home` — Moves focus to the first enabled tab and activates it
|
|
189
|
+
- `End` — Moves focus to the last enabled tab and activates it
|
|
160
190
|
- `Tab` — Moves focus out of the tablist to the active panel
|
|
161
191
|
|
|
162
192
|
## API reference
|
|
@@ -184,7 +214,7 @@ import 'luxen-ui/tabs';
|
|
|
184
214
|
|
|
185
215
|
### Events
|
|
186
216
|
|
|
187
|
-
- **change** — Fired when the active tab changes. Bubbles. Properties: `index: number`, `name: string | null`.
|
|
217
|
+
- **change** — Fired when the active tab changes. Bubbles. Not cancelable. Properties: `index: number`, `name: string | null`.
|
|
188
218
|
|
|
189
219
|
### CSS custom properties
|
|
190
220
|
|
package/package.json
CHANGED