luxen-ui 0.21.1 → 0.21.2

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.
@@ -56,6 +56,16 @@ export declare class Tabs extends LuxenElement {
56
56
  private _tabs;
57
57
  private _panels;
58
58
  private _resizeObserver;
59
+ private _mutationObserver;
60
+ /** Custom-element tags already scheduled for a re-check once they upgrade. */
61
+ private _pendingTags;
62
+ /**
63
+ * Whether this element owns a panel's `tabindex`, decided the first time the
64
+ * panel is seen and never revisited: a panel that already carried one belongs
65
+ * to the consumer. Re-deciding on every setup would misread our own attribute
66
+ * after a detach/reattach and freeze the panel out of the sync.
67
+ */
68
+ private _ownsPanelTabindex;
59
69
  /** Visual variant. */
60
70
  variant: TabsVariant;
61
71
  /** Index of the active tab (0-based). */
@@ -70,6 +80,29 @@ export declare class Tabs extends LuxenElement {
70
80
  private _trySetup;
71
81
  updated(changed: Map<string, unknown>): void;
72
82
  private _setup;
83
+ private _onMutation;
84
+ /** Have panels been added, removed or reordered since setup? */
85
+ private _structureChanged;
86
+ /**
87
+ * Rebuild the tab↔panel pairing after a structural change: a panel added
88
+ * later has no role, no id and no stop until it is wired.
89
+ */
90
+ private _rewire;
91
+ /**
92
+ * A panel joins the tab sequence unless its first content already takes a tab
93
+ * stop of its own — then `Tab` out of the tablist reaches that control
94
+ * directly, instead of stopping on the panel box first. Per the APG Tabs
95
+ * pattern; `firstContentTakesTabStop` carries the reasoning.
96
+ */
97
+ private _syncPanelTabStops;
98
+ private _onPanelFocusOut;
99
+ /**
100
+ * A custom element rendering into its shadow root produces no light-DOM
101
+ * mutation, so nothing would otherwise retake a verdict that was blocked on
102
+ * it. Wait for the definition, then a task for the first render (Lit schedules
103
+ * its initial update on a microtask).
104
+ */
105
+ private _recheckOnUpgrade;
73
106
  private _teardown;
74
107
  private _selectTab;
75
108
  /**
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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;;;;;;;;OAQG;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
+ {"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../../src/html/elements/tabs/tabs.ts"],"names":[],"mappings":"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"}
@@ -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<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};
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=[`a[href]`,`area[href]`,`button`,`input:not([type="hidden"])`,`select`,`textarea`,`details > summary:first-of-type`,`audio[controls]`,`video[controls]`,`iframe`,`[contenteditable]:not([contenteditable="false"])`,`[tabindex]:not([tabindex^="-"])`].join(`,`),o=`img, picture, svg, canvas, video, object, embed, hr`,s=`[hidden], template, script, style`;function c(e){let t=e.checkVisibility;return typeof t==`function`?t.call(e,{checkVisibilityCSS:!0,visibilityProperty:!0}):e.getClientRects().length>0}function l(e){return e.matches(a)&&!e.matches(`:disabled`)?!0:!!e.shadowRoot?.querySelector(a)}function u(e,t){let n=e=>t?!c(e):e.matches(s),r=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT,{acceptNode:e=>e.nodeType===Node.ELEMENT_NODE&&n(e)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}),i=null;for(let e=r.nextNode();e;e=r.nextNode()){if(e.nodeType===Node.TEXT_NODE){if(e.nodeValue?.trim())return{takesTabStop:!1,pendingTag:i};continue}let t=e;if(l(t))return{takesTabStop:!0,pendingTag:null};if(t.matches(`[inert]`)||t.matches(o))return{takesTabStop:!1,pendingTag:i};!i&&t.tagName.includes(`-`)&&!t.shadowRoot&&(i=t.tagName.toLowerCase())}return{takesTabStop:!1,pendingTag:i}}var d=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._mutationObserver=null,this._pendingTags=new Set,this._ownsPanelTabindex=new WeakMap,this.variant=`line`,this.value=`0`,this.fullWidth=!1,this.orientation=`horizontal`,this._onPanelFocusOut=()=>this._syncPanelTabStops(),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),this._ownsPanelTabindex.has(r)||this._ownsPanelTabindex.set(r,!r.hasAttribute(`tabindex`)),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),this._syncPanelTabStops(),this._mutationObserver=new MutationObserver(e=>this._onMutation(e)),this._mutationObserver.observe(this,{childList:!0});for(let e of this._panels)this._mutationObserver.observe(e,{childList:!0,subtree:!0})}_onMutation(e){if(e.some(e=>e.target===this)&&this._structureChanged()){this._rewire();return}this._syncPanelTabStops()}_structureChanged(){let e=Array.from(this.children);return e.length!==this._panels.length+1||e[0]!==this._tablistEl?!0:this._panels.some((t,n)=>e[n+1]!==t)}_rewire(){this.children.length<2||(this._teardown(),this._setup())}_syncPanelTabStops(){for(let e of this._panels){if(!this._ownsPanelTabindex.get(e))continue;let t=u(e,c(e));t.pendingTag&&this._recheckOnUpgrade(t.pendingTag);let n=t.takesTabStop?null:`0`;if(e.getAttribute(`tabindex`)!==n)if(n===null){if(document.activeElement===e){e.addEventListener(`focusout`,this._onPanelFocusOut,{once:!0});continue}e.removeAttribute(`tabindex`)}else e.setAttribute(`tabindex`,n)}}_recheckOnUpgrade(e){this._pendingTags.has(e)||(this._pendingTags.add(e),customElements.whenDefined(e).then(()=>{setTimeout(()=>{this._pendingTags.delete(e),this._initialized&&this._syncPanelTabStops()},0)}))}_teardown(){this._tablistEl?.removeEventListener(`click`,this._onClick),this._tablistEl?.removeEventListener(`keydown`,this._onKeyDown),this._resizeObserver?.disconnect(),this._resizeObserver=null,this._mutationObserver?.disconnect(),this._mutationObserver=null;for(let e of this._panels)e.removeEventListener(`focusout`,this._onPanelFocusOut);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(),this._syncPanelTabStops(),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})],d.prototype,`variant`,void 0),n([t({reflect:!0})],d.prototype,`value`,void 0),n([t({type:Boolean,reflect:!0,attribute:`full-width`})],d.prototype,`fullWidth`,void 0),n([t({reflect:!0})],d.prototype,`orientation`,void 0);export{d 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 * @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, and they behave identically here. `aria-disabled`\n * leaves the button focusable in principle, but a roving tabindex hands\n * `tabindex=\"0\"` to the selected tab only — so a tab that can never be\n * selected is never in the tab sequence either, and the arrow keys skip it\n * like a native one. It is the escape hatch for consumers who cannot set the\n * native attribute (a framework binding, a non-`button` element), not a\n * \"reachable but inert\" variant.\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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"}
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 * Elements that take a tab stop of their own. `tabindex=\"-1\"` is deliberately\n * absent: it makes an element programmatically focusable but leaves it out of\n * the tab sequence, and the tab sequence is what the APG rule below is about.\n *\n * A selector is the wrong shape for this question and the platform offers\n * nothing better yet. `el.tabIndex >= 0` looks like the answer and is not: it\n * reports 0 for `<a>` without `href`, `<input type=\"hidden\">` and `<video>`\n * without `controls`, none of which take focus, and -1 for `[contenteditable]`,\n * which does. `:focusable` / `:tabbable` would settle it, but they are not in\n * Selectors Level 4 and no engine ships them (checked against Chrome 152) —\n * when they land, this whole list collapses to `:tabbable` and `takesTabStop`\n * becomes one `matches` call. Probing with `focus()` is the only exact answer\n * available today, and it is unusable here: this runs inside a MutationObserver\n * callback, where moving focus would be destructive.\n *\n * Two clauses are narrower than their element name suggests:\n * - `input[type=\"hidden\"]` renders nothing and takes no focus, yet is a routine\n * first child of a form panel (a CSRF field).\n * - Only the first `summary` child of a `details` is the disclosure control; a\n * `summary` anywhere else is inert content.\n */\nconst TAB_STOP_SELECTOR = [\n 'a[href]',\n 'area[href]',\n 'button',\n 'input:not([type=\"hidden\"])',\n 'select',\n 'textarea',\n 'details > summary:first-of-type',\n 'audio[controls]',\n 'video[controls]',\n // Focus enters an iframe's content, so it is a tab stop rather than the\n // opaque box it looks like.\n 'iframe',\n '[contenteditable]:not([contenteditable=\"false\"])',\n '[tabindex]:not([tabindex^=\"-\"])',\n].join(',');\n\n/**\n * Content that is not focusable and not text — the walk has to stop on these\n * rather than skip ahead, or a panel opening with an illustration would be read\n * as opening with whatever link happens to follow it.\n */\nconst OPAQUE_CONTENT_SELECTOR = 'img, picture, svg, canvas, video, object, embed, hr';\n\n/**\n * Markup that is never rendered. Only consulted for a panel that is itself\n * off-screen, where real visibility cannot be measured — see `panelIsRendered`.\n */\nconst NOT_RENDERED_SELECTOR = '[hidden], template, script, style';\n\n/**\n * Is this element actually rendered right now?\n *\n * Asking the platform rather than pattern-matching markup is what keeps\n * `display: none` content — a Tailwind `hidden` class, a framework's collapsed\n * block, a hidden input — from being mistaken for the panel's first content.\n * The option was renamed mid-standardisation and unknown keys are ignored, so\n * both spellings are passed; `getClientRects` covers engines without the method.\n */\nfunction isRendered(el: Element): boolean {\n const check = (el as Element & { checkVisibility?: (options?: object) => boolean })\n .checkVisibility;\n if (typeof check === 'function') {\n return check.call(el, { checkVisibilityCSS: true, visibilityProperty: true });\n }\n return el.getClientRects().length > 0;\n}\n\nfunction takesTabStop(el: Element): boolean {\n if (el.matches(TAB_STOP_SELECTOR) && !el.matches(':disabled')) return true;\n // A custom element keeps its control inside a shadow root, out of reach of a\n // light-DOM selector — `l-select` and `l-slider` both put their tab stop on a\n // shadow node.\n return Boolean(el.shadowRoot?.querySelector(TAB_STOP_SELECTOR));\n}\n\ninterface FirstContentVerdict {\n /** Whether the panel's first content already offers a tab stop. */\n takesTabStop: boolean;\n /**\n * Tag name of a custom element met before reaching a verdict whose shadow\n * root was not attached yet, so the verdict has to be retaken once it is.\n */\n pendingTag: string | null;\n}\n\n/**\n * Does the first thing a reader meets in this panel take a tab stop of its own?\n *\n * The APG asks for `tabindex=\"0\"` on a tabpanel \"when the tabpanel does not\n * contain any focusable elements **or the first element with content is not\n * focusable**\". One walk answers both clauses: stop at the first content and ask\n * whether it is focusable; a panel holding nothing focusable runs off the end\n * and returns false, which is the first clause.\n *\n * The walk descends through wrappers on purpose. The APG's own manual-activation\n * example is `<p><a href>…`, where the link is the first content even though the\n * `<p>` is visited first — and that example's panels carry no `tabindex`.\n *\n * `measured` says whether real visibility can be trusted. It cannot for a panel\n * that is itself hidden — every descendant would report invisible — so that case\n * falls back to the markup heuristic and is remeasured when the panel is shown.\n */\nfunction firstContentTakesTabStop(panel: Element, measured: boolean): FirstContentVerdict {\n const skipSubtree = (el: Element) =>\n measured ? !isRendered(el) : el.matches(NOT_RENDERED_SELECTOR);\n\n const walker = document.createTreeWalker(panel, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {\n acceptNode: (node) =>\n node.nodeType === Node.ELEMENT_NODE && skipSubtree(node as Element)\n ? // REJECT, not SKIP: a TreeWalker drops the whole subtree, so text\n // inside an unrendered block never reads as prose.\n NodeFilter.FILTER_REJECT\n : NodeFilter.FILTER_ACCEPT,\n });\n\n let pendingTag: string | null = null;\n\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n if (node.nodeType === Node.TEXT_NODE) {\n // Whitespace between tags is not content. Real text is — and is not focusable.\n if (node.nodeValue?.trim()) return { takesTabStop: false, pendingTag };\n continue;\n }\n const el = node as Element;\n if (takesTabStop(el)) return { takesTabStop: true, pendingTag: null };\n // Inert content is visible content that cannot be focused, so it settles the\n // question: this panel needs a stop of its own. (Unlike an unrendered block,\n // which is genuinely absent and is skipped above.)\n if (el.matches('[inert]')) return { takesTabStop: false, pendingTag };\n if (el.matches(OPAQUE_CONTENT_SELECTOR)) return { takesTabStop: false, pendingTag };\n // An un-upgraded custom element cannot be judged yet: its control may be\n // about to appear in a shadow root. Keep walking — it may be a light-DOM\n // element whose children answer the question — but remember to come back.\n if (!pendingTag && el.tagName.includes('-') && !el.shadowRoot) {\n pendingTag = el.tagName.toLowerCase();\n }\n }\n return { takesTabStop: false, pendingTag };\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 private _mutationObserver: MutationObserver | null = null;\n /** Custom-element tags already scheduled for a re-check once they upgrade. */\n private _pendingTags = new Set<string>();\n /**\n * Whether this element owns a panel's `tabindex`, decided the first time the\n * panel is seen and never revisited: a panel that already carried one belongs\n * to the consumer. Re-deciding on every setup would misread our own attribute\n * after a detach/reattach and freeze the panel out of the sync.\n */\n private _ownsPanelTabindex = new WeakMap<Element, boolean>();\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 if (!this._ownsPanelTabindex.has(panel)) {\n this._ownsPanelTabindex.set(panel, !panel.hasAttribute('tabindex'));\n }\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 // Panels are routinely filled after mount — a framework rendering into them\n // once its data lands — and whether a panel belongs in the tab sequence\n // depends on what arrives. So the decision is re-run on content changes\n // rather than frozen at setup. `childList` does not report attribute\n // mutations, so writing `tabindex` back cannot feed this.\n //\n // Two scopes, deliberately: our own child list (panels added or removed,\n // which needs the whole pairing rebuilt) and each panel's subtree (content\n // changes, which only need the stops retaken). The tablist is left out —\n // relabelling a tab cannot change any panel's verdict.\n this._syncPanelTabStops();\n this._mutationObserver = new MutationObserver((records) => this._onMutation(records));\n this._mutationObserver.observe(this, { childList: true });\n for (const panel of this._panels) {\n this._mutationObserver.observe(panel, { childList: true, subtree: true });\n }\n }\n\n private _onMutation(records: MutationRecord[]) {\n // Lit renders into this element's light DOM (`createRenderRoot` returns\n // `this`), so its marker comments land in our child list too — compare the\n // element children we wired instead of trusting the record's target.\n if (records.some((r) => r.target === this) && this._structureChanged()) {\n this._rewire();\n return;\n }\n this._syncPanelTabStops();\n }\n\n /** Have panels been added, removed or reordered since setup? */\n private _structureChanged(): boolean {\n const children = Array.from(this.children);\n if (children.length !== this._panels.length + 1) return true;\n if (children[0] !== this._tablistEl) return true;\n return this._panels.some((panel, i) => children[i + 1] !== panel);\n }\n\n /**\n * Rebuild the tab↔panel pairing after a structural change: a panel added\n * later has no role, no id and no stop until it is wired.\n */\n private _rewire() {\n // Too few children to pair up — keep the observer alive so the element\n // recovers when they arrive, rather than tearing down into an inert state.\n if (this.children.length < 2) return;\n this._teardown();\n this._setup();\n }\n\n /**\n * A panel joins the tab sequence unless its first content already takes a tab\n * stop of its own — then `Tab` out of the tablist reaches that control\n * directly, instead of stopping on the panel box first. Per the APG Tabs\n * pattern; `firstContentTakesTabStop` carries the reasoning.\n */\n private _syncPanelTabStops() {\n for (const panel of this._panels) {\n if (!this._ownsPanelTabindex.get(panel)) continue;\n\n const verdict = firstContentTakesTabStop(panel, isRendered(panel));\n if (verdict.pendingTag) this._recheckOnUpgrade(verdict.pendingTag);\n\n const wanted = verdict.takesTabStop ? null : '0';\n // Only write on a real change: an unconditional `setAttribute` would queue\n // a mutation record for every consumer observing this panel's attributes,\n // on every batch of DOM changes inside it.\n if (panel.getAttribute('tabindex') === wanted) continue;\n\n if (wanted === null) {\n // Taking `tabindex` off the focused panel blurs it and drops the user at\n // the top of the document. Keep the stop and retake the decision once\n // focus has moved on (WCAG 2.4.3).\n if (document.activeElement === panel) {\n panel.addEventListener('focusout', this._onPanelFocusOut, { once: true });\n continue;\n }\n panel.removeAttribute('tabindex');\n } else {\n panel.setAttribute('tabindex', wanted);\n }\n }\n }\n\n private _onPanelFocusOut = () => this._syncPanelTabStops();\n\n /**\n * A custom element rendering into its shadow root produces no light-DOM\n * mutation, so nothing would otherwise retake a verdict that was blocked on\n * it. Wait for the definition, then a task for the first render (Lit schedules\n * its initial update on a microtask).\n */\n private _recheckOnUpgrade(tag: string) {\n if (this._pendingTags.has(tag)) return;\n this._pendingTags.add(tag);\n void customElements.whenDefined(tag).then(() => {\n setTimeout(() => {\n this._pendingTags.delete(tag);\n if (this._initialized) this._syncPanelTabStops();\n }, 0);\n });\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._mutationObserver?.disconnect();\n this._mutationObserver = null;\n for (const panel of this._panels) {\n panel.removeEventListener('focusout', this._onPanelFocusOut);\n }\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 // The panel just shown can be measured for real now; the one just hidden\n // keeps whatever it had, which is moot while `hidden` holds it out of the\n // tab sequence anyway.\n this._syncPanelTabStops();\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, and they behave identically here. `aria-disabled`\n * leaves the button focusable in principle, but a roving tabindex hands\n * `tabindex=\"0\"` to the selected tab only — so a tab that can never be\n * selected is never in the tab sequence either, and the arrow keys skip it\n * like a native one. It is the escape hatch for consumers who cannot set the\n * native attribute (a framework binding, a non-`button` element), not a\n * \"reachable but inert\" variant.\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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"}
@@ -56,6 +56,16 @@ export declare class Tabs extends LuxenElement {
56
56
  private _tabs;
57
57
  private _panels;
58
58
  private _resizeObserver;
59
+ private _mutationObserver;
60
+ /** Custom-element tags already scheduled for a re-check once they upgrade. */
61
+ private _pendingTags;
62
+ /**
63
+ * Whether this element owns a panel's `tabindex`, decided the first time the
64
+ * panel is seen and never revisited: a panel that already carried one belongs
65
+ * to the consumer. Re-deciding on every setup would misread our own attribute
66
+ * after a detach/reattach and freeze the panel out of the sync.
67
+ */
68
+ private _ownsPanelTabindex;
59
69
  /** Visual variant. */
60
70
  variant: TabsVariant;
61
71
  /** Index of the active tab (0-based). */
@@ -70,6 +80,29 @@ export declare class Tabs extends LuxenElement {
70
80
  private _trySetup;
71
81
  updated(changed: Map<string, unknown>): void;
72
82
  private _setup;
83
+ private _onMutation;
84
+ /** Have panels been added, removed or reordered since setup? */
85
+ private _structureChanged;
86
+ /**
87
+ * Rebuild the tab↔panel pairing after a structural change: a panel added
88
+ * later has no role, no id and no stop until it is wired.
89
+ */
90
+ private _rewire;
91
+ /**
92
+ * A panel joins the tab sequence unless its first content already takes a tab
93
+ * stop of its own — then `Tab` out of the tablist reaches that control
94
+ * directly, instead of stopping on the panel box first. Per the APG Tabs
95
+ * pattern; `firstContentTakesTabStop` carries the reasoning.
96
+ */
97
+ private _syncPanelTabStops;
98
+ private _onPanelFocusOut;
99
+ /**
100
+ * A custom element rendering into its shadow root produces no light-DOM
101
+ * mutation, so nothing would otherwise retake a verdict that was blocked on
102
+ * it. Wait for the definition, then a task for the first render (Lit schedules
103
+ * its initial update on a microtask).
104
+ */
105
+ private _recheckOnUpgrade;
73
106
  private _teardown;
74
107
  private _selectTab;
75
108
  /**
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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;;;;;;;;OAQG;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
+ {"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../../src/html/elements/tabs/tabs.ts"],"names":[],"mappings":"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"}
@@ -15,6 +15,133 @@ export class TabsChangeEvent extends Event {
15
15
  this.name = name;
16
16
  }
17
17
  }
18
+ /**
19
+ * Elements that take a tab stop of their own. `tabindex="-1"` is deliberately
20
+ * absent: it makes an element programmatically focusable but leaves it out of
21
+ * the tab sequence, and the tab sequence is what the APG rule below is about.
22
+ *
23
+ * A selector is the wrong shape for this question and the platform offers
24
+ * nothing better yet. `el.tabIndex >= 0` looks like the answer and is not: it
25
+ * reports 0 for `<a>` without `href`, `<input type="hidden">` and `<video>`
26
+ * without `controls`, none of which take focus, and -1 for `[contenteditable]`,
27
+ * which does. `:focusable` / `:tabbable` would settle it, but they are not in
28
+ * Selectors Level 4 and no engine ships them (checked against Chrome 152) —
29
+ * when they land, this whole list collapses to `:tabbable` and `takesTabStop`
30
+ * becomes one `matches` call. Probing with `focus()` is the only exact answer
31
+ * available today, and it is unusable here: this runs inside a MutationObserver
32
+ * callback, where moving focus would be destructive.
33
+ *
34
+ * Two clauses are narrower than their element name suggests:
35
+ * - `input[type="hidden"]` renders nothing and takes no focus, yet is a routine
36
+ * first child of a form panel (a CSRF field).
37
+ * - Only the first `summary` child of a `details` is the disclosure control; a
38
+ * `summary` anywhere else is inert content.
39
+ */
40
+ const TAB_STOP_SELECTOR = [
41
+ 'a[href]',
42
+ 'area[href]',
43
+ 'button',
44
+ 'input:not([type="hidden"])',
45
+ 'select',
46
+ 'textarea',
47
+ 'details > summary:first-of-type',
48
+ 'audio[controls]',
49
+ 'video[controls]',
50
+ // Focus enters an iframe's content, so it is a tab stop rather than the
51
+ // opaque box it looks like.
52
+ 'iframe',
53
+ '[contenteditable]:not([contenteditable="false"])',
54
+ '[tabindex]:not([tabindex^="-"])',
55
+ ].join(',');
56
+ /**
57
+ * Content that is not focusable and not text — the walk has to stop on these
58
+ * rather than skip ahead, or a panel opening with an illustration would be read
59
+ * as opening with whatever link happens to follow it.
60
+ */
61
+ const OPAQUE_CONTENT_SELECTOR = 'img, picture, svg, canvas, video, object, embed, hr';
62
+ /**
63
+ * Markup that is never rendered. Only consulted for a panel that is itself
64
+ * off-screen, where real visibility cannot be measured — see `panelIsRendered`.
65
+ */
66
+ const NOT_RENDERED_SELECTOR = '[hidden], template, script, style';
67
+ /**
68
+ * Is this element actually rendered right now?
69
+ *
70
+ * Asking the platform rather than pattern-matching markup is what keeps
71
+ * `display: none` content — a Tailwind `hidden` class, a framework's collapsed
72
+ * block, a hidden input — from being mistaken for the panel's first content.
73
+ * The option was renamed mid-standardisation and unknown keys are ignored, so
74
+ * both spellings are passed; `getClientRects` covers engines without the method.
75
+ */
76
+ function isRendered(el) {
77
+ const check = el
78
+ .checkVisibility;
79
+ if (typeof check === 'function') {
80
+ return check.call(el, { checkVisibilityCSS: true, visibilityProperty: true });
81
+ }
82
+ return el.getClientRects().length > 0;
83
+ }
84
+ function takesTabStop(el) {
85
+ if (el.matches(TAB_STOP_SELECTOR) && !el.matches(':disabled'))
86
+ return true;
87
+ // A custom element keeps its control inside a shadow root, out of reach of a
88
+ // light-DOM selector — `l-select` and `l-slider` both put their tab stop on a
89
+ // shadow node.
90
+ return Boolean(el.shadowRoot?.querySelector(TAB_STOP_SELECTOR));
91
+ }
92
+ /**
93
+ * Does the first thing a reader meets in this panel take a tab stop of its own?
94
+ *
95
+ * The APG asks for `tabindex="0"` on a tabpanel "when the tabpanel does not
96
+ * contain any focusable elements **or the first element with content is not
97
+ * focusable**". One walk answers both clauses: stop at the first content and ask
98
+ * whether it is focusable; a panel holding nothing focusable runs off the end
99
+ * and returns false, which is the first clause.
100
+ *
101
+ * The walk descends through wrappers on purpose. The APG's own manual-activation
102
+ * example is `<p><a href>…`, where the link is the first content even though the
103
+ * `<p>` is visited first — and that example's panels carry no `tabindex`.
104
+ *
105
+ * `measured` says whether real visibility can be trusted. It cannot for a panel
106
+ * that is itself hidden — every descendant would report invisible — so that case
107
+ * falls back to the markup heuristic and is remeasured when the panel is shown.
108
+ */
109
+ function firstContentTakesTabStop(panel, measured) {
110
+ const skipSubtree = (el) => measured ? !isRendered(el) : el.matches(NOT_RENDERED_SELECTOR);
111
+ const walker = document.createTreeWalker(panel, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
112
+ acceptNode: (node) => node.nodeType === Node.ELEMENT_NODE && skipSubtree(node)
113
+ ? // REJECT, not SKIP: a TreeWalker drops the whole subtree, so text
114
+ // inside an unrendered block never reads as prose.
115
+ NodeFilter.FILTER_REJECT
116
+ : NodeFilter.FILTER_ACCEPT,
117
+ });
118
+ let pendingTag = null;
119
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
120
+ if (node.nodeType === Node.TEXT_NODE) {
121
+ // Whitespace between tags is not content. Real text is — and is not focusable.
122
+ if (node.nodeValue?.trim())
123
+ return { takesTabStop: false, pendingTag };
124
+ continue;
125
+ }
126
+ const el = node;
127
+ if (takesTabStop(el))
128
+ return { takesTabStop: true, pendingTag: null };
129
+ // Inert content is visible content that cannot be focused, so it settles the
130
+ // question: this panel needs a stop of its own. (Unlike an unrendered block,
131
+ // which is genuinely absent and is skipped above.)
132
+ if (el.matches('[inert]'))
133
+ return { takesTabStop: false, pendingTag };
134
+ if (el.matches(OPAQUE_CONTENT_SELECTOR))
135
+ return { takesTabStop: false, pendingTag };
136
+ // An un-upgraded custom element cannot be judged yet: its control may be
137
+ // about to appear in a shadow root. Keep walking — it may be a light-DOM
138
+ // element whose children answer the question — but remember to come back.
139
+ if (!pendingTag && el.tagName.includes('-') && !el.shadowRoot) {
140
+ pendingTag = el.tagName.toLowerCase();
141
+ }
142
+ }
143
+ return { takesTabStop: false, pendingTag };
144
+ }
18
145
  /**
19
146
  * @summary A tabs component that progressively enhances light DOM markup
20
147
  * with ARIA roles, keyboard navigation, and animated indicators.
@@ -63,6 +190,16 @@ export class Tabs extends LuxenElement {
63
190
  this._tabs = [];
64
191
  this._panels = [];
65
192
  this._resizeObserver = null;
193
+ this._mutationObserver = null;
194
+ /** Custom-element tags already scheduled for a re-check once they upgrade. */
195
+ this._pendingTags = new Set();
196
+ /**
197
+ * Whether this element owns a panel's `tabindex`, decided the first time the
198
+ * panel is seen and never revisited: a panel that already carried one belongs
199
+ * to the consumer. Re-deciding on every setup would misread our own attribute
200
+ * after a detach/reattach and freeze the panel out of the sync.
201
+ */
202
+ this._ownsPanelTabindex = new WeakMap();
66
203
  /** Visual variant. */
67
204
  this.variant = 'line';
68
205
  /** Index of the active tab (0-based). */
@@ -71,6 +208,7 @@ export class Tabs extends LuxenElement {
71
208
  this.fullWidth = false;
72
209
  /** Tab orientation. */
73
210
  this.orientation = 'horizontal';
211
+ this._onPanelFocusOut = () => this._syncPanelTabStops();
74
212
  // --- Event handlers ---
75
213
  this._onClick = (e) => {
76
214
  const tab = e.target.closest('[role="tab"]');
@@ -204,7 +342,9 @@ export class Tabs extends LuxenElement {
204
342
  panel.setAttribute('role', 'tabpanel');
205
343
  panel.setAttribute('id', panelId);
206
344
  panel.setAttribute('aria-labelledby', tabId);
207
- panel.setAttribute('tabindex', '0');
345
+ if (!this._ownsPanelTabindex.has(panel)) {
346
+ this._ownsPanelTabindex.set(panel, !panel.hasAttribute('tabindex'));
347
+ }
208
348
  if (i !== activeIndex) {
209
349
  panel.hidden = true;
210
350
  }
@@ -227,12 +367,116 @@ export class Tabs extends LuxenElement {
227
367
  this._updateIndicator();
228
368
  this._resizeObserver = new ResizeObserver(() => this._updateIndicator());
229
369
  this._resizeObserver.observe(this._tablistEl);
370
+ // Panels are routinely filled after mount — a framework rendering into them
371
+ // once its data lands — and whether a panel belongs in the tab sequence
372
+ // depends on what arrives. So the decision is re-run on content changes
373
+ // rather than frozen at setup. `childList` does not report attribute
374
+ // mutations, so writing `tabindex` back cannot feed this.
375
+ //
376
+ // Two scopes, deliberately: our own child list (panels added or removed,
377
+ // which needs the whole pairing rebuilt) and each panel's subtree (content
378
+ // changes, which only need the stops retaken). The tablist is left out —
379
+ // relabelling a tab cannot change any panel's verdict.
380
+ this._syncPanelTabStops();
381
+ this._mutationObserver = new MutationObserver((records) => this._onMutation(records));
382
+ this._mutationObserver.observe(this, { childList: true });
383
+ for (const panel of this._panels) {
384
+ this._mutationObserver.observe(panel, { childList: true, subtree: true });
385
+ }
386
+ }
387
+ _onMutation(records) {
388
+ // Lit renders into this element's light DOM (`createRenderRoot` returns
389
+ // `this`), so its marker comments land in our child list too — compare the
390
+ // element children we wired instead of trusting the record's target.
391
+ if (records.some((r) => r.target === this) && this._structureChanged()) {
392
+ this._rewire();
393
+ return;
394
+ }
395
+ this._syncPanelTabStops();
396
+ }
397
+ /** Have panels been added, removed or reordered since setup? */
398
+ _structureChanged() {
399
+ const children = Array.from(this.children);
400
+ if (children.length !== this._panels.length + 1)
401
+ return true;
402
+ if (children[0] !== this._tablistEl)
403
+ return true;
404
+ return this._panels.some((panel, i) => children[i + 1] !== panel);
405
+ }
406
+ /**
407
+ * Rebuild the tab↔panel pairing after a structural change: a panel added
408
+ * later has no role, no id and no stop until it is wired.
409
+ */
410
+ _rewire() {
411
+ // Too few children to pair up — keep the observer alive so the element
412
+ // recovers when they arrive, rather than tearing down into an inert state.
413
+ if (this.children.length < 2)
414
+ return;
415
+ this._teardown();
416
+ this._setup();
417
+ }
418
+ /**
419
+ * A panel joins the tab sequence unless its first content already takes a tab
420
+ * stop of its own — then `Tab` out of the tablist reaches that control
421
+ * directly, instead of stopping on the panel box first. Per the APG Tabs
422
+ * pattern; `firstContentTakesTabStop` carries the reasoning.
423
+ */
424
+ _syncPanelTabStops() {
425
+ for (const panel of this._panels) {
426
+ if (!this._ownsPanelTabindex.get(panel))
427
+ continue;
428
+ const verdict = firstContentTakesTabStop(panel, isRendered(panel));
429
+ if (verdict.pendingTag)
430
+ this._recheckOnUpgrade(verdict.pendingTag);
431
+ const wanted = verdict.takesTabStop ? null : '0';
432
+ // Only write on a real change: an unconditional `setAttribute` would queue
433
+ // a mutation record for every consumer observing this panel's attributes,
434
+ // on every batch of DOM changes inside it.
435
+ if (panel.getAttribute('tabindex') === wanted)
436
+ continue;
437
+ if (wanted === null) {
438
+ // Taking `tabindex` off the focused panel blurs it and drops the user at
439
+ // the top of the document. Keep the stop and retake the decision once
440
+ // focus has moved on (WCAG 2.4.3).
441
+ if (document.activeElement === panel) {
442
+ panel.addEventListener('focusout', this._onPanelFocusOut, { once: true });
443
+ continue;
444
+ }
445
+ panel.removeAttribute('tabindex');
446
+ }
447
+ else {
448
+ panel.setAttribute('tabindex', wanted);
449
+ }
450
+ }
451
+ }
452
+ /**
453
+ * A custom element rendering into its shadow root produces no light-DOM
454
+ * mutation, so nothing would otherwise retake a verdict that was blocked on
455
+ * it. Wait for the definition, then a task for the first render (Lit schedules
456
+ * its initial update on a microtask).
457
+ */
458
+ _recheckOnUpgrade(tag) {
459
+ if (this._pendingTags.has(tag))
460
+ return;
461
+ this._pendingTags.add(tag);
462
+ void customElements.whenDefined(tag).then(() => {
463
+ setTimeout(() => {
464
+ this._pendingTags.delete(tag);
465
+ if (this._initialized)
466
+ this._syncPanelTabStops();
467
+ }, 0);
468
+ });
230
469
  }
231
470
  _teardown() {
232
471
  this._tablistEl?.removeEventListener('click', this._onClick);
233
472
  this._tablistEl?.removeEventListener('keydown', this._onKeyDown);
234
473
  this._resizeObserver?.disconnect();
235
474
  this._resizeObserver = null;
475
+ this._mutationObserver?.disconnect();
476
+ this._mutationObserver = null;
477
+ for (const panel of this._panels) {
478
+ panel.removeEventListener('focusout', this._onPanelFocusOut);
479
+ }
236
480
  this._initialized = false;
237
481
  }
238
482
  // --- Tab selection ---
@@ -258,6 +502,10 @@ export class Tabs extends LuxenElement {
258
502
  }
259
503
  this.value = String(index);
260
504
  this._updateIndicator();
505
+ // The panel just shown can be measured for real now; the one just hidden
506
+ // keeps whatever it had, which is moot while `hidden` holds it out of the
507
+ // tab sequence anyway.
508
+ this._syncPanelTabStops();
261
509
  if (emitEvent && changed) {
262
510
  const name = this._tabs[index]?.getAttribute('name') ?? null;
263
511
  this.dispatchEvent(new TabsChangeEvent(index, name));
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.21.1",
2
+ "version": "0.21.2",
3
3
  "elements": [
4
4
  {
5
5
  "name": "alert",
@@ -163,6 +163,49 @@ Add `disabled` to a tab button to take it out of play. It is skipped by arrow ke
163
163
  </l-tabs>
164
164
  ```
165
165
 
166
+ ### Panels with interactive content
167
+
168
+ `Tab` moves focus out of the tablist and into the active panel, so the content at the top of the panel is reachable. A panel that _opens_ with something focusable needs no stop of its own: `l-tabs` leaves its `tabindex` off and `Tab` reaches that first control directly, instead of stopping on the panel box first.
169
+
170
+ Every other panel keeps `tabindex="0"` — including one that opens with text and holds a button further down. Dropping the stop there would send a keyboard user past everything above that button.
171
+
172
+ ```html
173
+ <l-tabs variant="line">
174
+ <div>
175
+ <button
176
+ name="report"
177
+ aria-selected="true"
178
+ >
179
+ Report
180
+ </button>
181
+ <button name="summary">Summary</button>
182
+ </div>
183
+ <div class="p-4 text-sm text-secondary">
184
+ <a
185
+ href="#"
186
+ class="font-semibold text-primary underline-offset-4 hover:underline"
187
+ >Download the report</a
188
+ >
189
+ <p class="mt-2">
190
+ This panel opens with a link, so pressing Tab from the tablist moves focus straight to it.
191
+ </p>
192
+ </div>
193
+ <div class="p-4 text-sm text-secondary">
194
+ <p>
195
+ This panel opens with text, so it takes a tab stop of its own — Tab lands on the panel, then
196
+ on the link.
197
+ </p>
198
+ <a
199
+ href="#"
200
+ class="mt-2 inline-block font-semibold text-primary underline-offset-4 hover:underline"
201
+ >Download the summary</a
202
+ >
203
+ </div>
204
+ </l-tabs>
205
+ ```
206
+
207
+ The check runs again whenever a panel's content changes, so a panel filled after mount — a framework rendering into it once its data lands — is treated the same as one present in the initial markup. A `tabindex` you write on a panel yourself is left alone.
208
+
166
209
  ## Accessibility
167
210
 
168
211
  ### Criteria
@@ -170,7 +213,7 @@ Add `disabled` to a tab button to take it out of play. It is skipped by arrow ke
170
213
  - **Role** — First child div gets `role="tablist"`, buttons get `role="tab"`, remaining divs get `role="tabpanel"`
171
214
  - **Linked controls** — Each tab has `aria-controls` pointing to its panel; each panel has `aria-labelledby` pointing back to its tab
172
215
  - **Selection state** — Active tab has `aria-selected="true"`; inactive tabs have `aria-selected="false"`
173
- - **Focus management** — Roving tabindex — active tab has `tabindex="0"`, others `tabindex="-1"`. Panels have `tabindex="0"` for keyboard access
216
+ - **Focus management** — Roving tabindex — active tab has `tabindex="0"`, others `tabindex="-1"`. A panel takes `tabindex="0"` unless its first content is already focusable, per the APG
174
217
  - **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
175
218
  - **Hidden content** — Inactive panels use the `hidden` attribute
176
219
  - **Motion** — Indicator animation respects `prefers-reduced-motion`
@@ -190,7 +233,7 @@ Add `disabled` to a tab button to take it out of play. It is skipped by arrow ke
190
233
  - `ArrowUp` — Moves focus to the previous enabled tab and activates it (vertical orientation)
191
234
  - `Home` — Moves focus to the first enabled tab and activates it
192
235
  - `End` — Moves focus to the last enabled tab and activates it
193
- - `Tab` — Moves focus out of the tablist to the active panel
236
+ - `Tab` — Moves focus out of the tablist — to the active panel, or straight to its first control when the panel opens with one
194
237
 
195
238
  ## API reference
196
239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luxen-ui",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Modern web components and CSS-first UI library built with Lit. Framework-agnostic, customizable prefix, design tokens.",
5
5
  "keywords": [
6
6
  "custom-elements",