@primitiv-ui/web 0.0.0-stage → 0.1.43
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/LICENSE +21 -0
- package/README.md +91 -2
- package/dist/bundle.d.ts +13 -0
- package/dist/bundle.js +13 -0
- package/dist/cdn/tabs.js +1 -0
- package/dist/custom-elements.json +314 -0
- package/dist/define/index.d.ts +5 -0
- package/dist/define/index.js +5 -0
- package/dist/define/tabs.d.ts +1 -0
- package/dist/define/tabs.js +11 -0
- package/dist/frameworks/events.d.ts +9 -0
- package/dist/frameworks/events.js +5 -0
- package/dist/frameworks/react.d.ts +50 -0
- package/dist/frameworks/react.js +1 -0
- package/dist/frameworks/svelte.d.ts +45 -0
- package/dist/frameworks/svelte.js +1 -0
- package/dist/frameworks/vue.d.ts +48 -0
- package/dist/frameworks/vue.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/primitiv-web.js +1 -0
- package/dist/tabs/TabsController.d.ts +57 -0
- package/dist/tabs/TabsController.js +106 -0
- package/dist/tabs/elements.d.ts +78 -0
- package/dist/tabs/elements.js +265 -0
- package/dist/tabs/index.d.ts +2 -0
- package/dist/tabs/index.js +2 -0
- package/dist/tags.d.ts +24 -0
- package/dist/tags.js +1 -0
- package/package.json +103 -3
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in Vue 3 template typings for the elements. With this in scope, `vue-tsc`
|
|
3
|
+
* checks each tag's attributes and its events:
|
|
4
|
+
*
|
|
5
|
+
* ```vue
|
|
6
|
+
* <script setup lang="ts">
|
|
7
|
+
* import type {} from "@primitiv-ui/web/vue";
|
|
8
|
+
* </script>
|
|
9
|
+
* <template>
|
|
10
|
+
* <primitiv-tabs :value="value" @primitiv-tabs-change="onChange">…</primitiv-tabs>
|
|
11
|
+
* </template>
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* The compiler must also be told the tags are custom elements, so Vue does not
|
|
15
|
+
* try to resolve them as components at runtime:
|
|
16
|
+
* `compilerOptions.isCustomElement: (tag) => tag.startsWith("primitiv-")`.
|
|
17
|
+
*
|
|
18
|
+
* npm-only, like `@primitiv-ui/web/tags`: it augments the `vue` module, which
|
|
19
|
+
* JSR does not allow a published module to do.
|
|
20
|
+
*/
|
|
21
|
+
import type { DefineComponent, EmitsOptions } from "vue";
|
|
22
|
+
import type { PrimitivTabsChangeEvent } from "./events.js";
|
|
23
|
+
/** A tag as Vue's template checker sees it: its props, and the events it emits. */
|
|
24
|
+
type Tag<Props, Events extends EmitsOptions = Record<never, never>> = DefineComponent<Props, Record<never, never>, Record<never, never>, Record<never, never>, Record<never, never>, Record<never, never>, Record<never, never>, Events>;
|
|
25
|
+
declare module "vue" {
|
|
26
|
+
interface GlobalComponents {
|
|
27
|
+
"primitiv-tabs": Tag<{
|
|
28
|
+
value?: string;
|
|
29
|
+
orientation?: "horizontal" | "vertical";
|
|
30
|
+
activationMode?: "automatic" | "manual";
|
|
31
|
+
}, {
|
|
32
|
+
"primitiv-tabs-change": (event: PrimitivTabsChangeEvent) => void;
|
|
33
|
+
}>;
|
|
34
|
+
"primitiv-tabs-list": Tag<{
|
|
35
|
+
label?: string;
|
|
36
|
+
}>;
|
|
37
|
+
"primitiv-tabs-trigger": Tag<{
|
|
38
|
+
value: string;
|
|
39
|
+
disabled?: boolean | "";
|
|
40
|
+
}>;
|
|
41
|
+
"primitiv-tabs-content": Tag<{
|
|
42
|
+
value: string;
|
|
43
|
+
}, {
|
|
44
|
+
"primitiv-tabs-show": (event: CustomEvent) => void;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@primitiv-ui/web` — Primitiv's headless components as standard custom
|
|
3
|
+
* elements. This entry exports classes only and has no side effects; import
|
|
4
|
+
* `@primitiv-ui/web/define` (or `/define/<component>`) to register the tags.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./tabs/index.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@primitiv-ui/web` — Primitiv's headless components as standard custom
|
|
3
|
+
* elements. This entry exports classes only and has no side effects; import
|
|
4
|
+
* `@primitiv-ui/web/define` (or `/define/<component>`) to register the tags.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./tabs/index.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function g(i,t,e){return`${i}-${t}-${e}`}function K({orientation:i,dir:t,homeEnd:e=!1,activate:o=!1}){let r={};if(i==="horizontal"||i==="both"){let l=t==="rtl"?"ArrowLeft":"ArrowRight",d=t==="rtl"?"ArrowRight":"ArrowLeft";r[l]="next",r[d]="prev"}return(i==="vertical"||i==="both")&&(r.ArrowDown="next",r.ArrowUp="prev"),e&&(r.Home="first",r.End="last"),o&&(r.Enter="activate",r[" "]="activate"),r}function C({action:i,navigable:t,currentKey:e}){let o=t.length;if(o===0)return null;switch(i){case"activate":return{target:e};case"first":return{target:t[0]};case"last":return{target:t[o-1]};case void 0:return null;case"next":case"prev":{let r=t.indexOf(e);if(r===-1)return null;let l=i==="next"?1:o-1;return{target:t[(r+l)%o]}}}}function x(i){return i.active??i.values[0]}function p(i,t){return i.disabled.has(t)||i.active===t?null:t}function A(i,t,e){let o=K({orientation:i.orientation,dir:i.dir,homeEnd:!0,activate:!0})[e],r=C({action:o,navigable:i.values,currentKey:t});if(!r)return null;let l=r.target,d=i.activationMode==="automatic"||o==="activate";return{focus:l,activate:d?p(i,l):null}}var v=class{#r;#i;#t=new Map;#e=null;constructor(t,e){this.#r=t,this.#i=e}registerTrigger(t,e,o={}){this.#t.set(e,{value:t,disabled:o.disabled??!1})}setTriggers(t){this.#t.clear();for(let{value:e,element:o,disabled:r}of t)this.registerTrigger(e,o,{disabled:r})}unregisterTrigger(t){this.#t.delete(t)}connect(){if(this.#e)return;this.#e=new AbortController;let{signal:t}=this.#e;this.#r.addEventListener("click",e=>this.#l(e),{signal:t}),this.#r.addEventListener("keydown",e=>this.#s(e),{signal:t})}disconnect(){this.#e?.abort(),this.#e=null}#o(){return[...this.#t].sort(([t],[e])=>t.compareDocumentPosition(e)&Node.DOCUMENT_POSITION_FOLLOWING?-1:1)}#a(){let t=new Set;for(let e of this.#t.values())e.disabled&&t.add(e.value);return t}#l(t){let e=this.#n(t.target);if(!e)return;let{active:o}=this.#i.state(),r=p({disabled:this.#a(),active:o},e.value);if(r!==null){let l=this.#o().map(([,d])=>d.value);this.#i.activate(r,l.indexOf(r))}}#s(t){let e=this.#n(t.target);if(!e)return;let{active:o,orientation:r,dir:l,activationMode:d}=this.#i.state(),b=this.#o(),a=b.map(([,c])=>c.value),n=A({values:a,disabled:this.#a(),active:o,orientation:r,dir:l,activationMode:d},e.value,t.key);n&&(t.preventDefault(),n.activate!==null&&this.#i.activate(n.activate,a.indexOf(n.activate)),b[a.indexOf(n.focus)][0].focus())}#n(t){for(let[e,o]of this.#t)if(e.contains(t))return o}};var y=globalThis.HTMLElement??class{},E="primitiv-tabs",H="primitiv-tabs-list",M="primitiv-tabs-trigger",k="primitiv-tabs-content",S=0,L=new WeakSet;function I(i){let t=i.getAttribute("value");return i.childElementCount===1&&t!==null?{tab:i.firstElementChild,value:t}:(L.has(i)||(L.add(i),console.error(`<${M}> needs a value and exactly one element child (a <button>, an <a href> or a router link) to decorate.`,i)),null)}function s(i,t,e){i.getAttribute(t)!==e&&(e===null?i.removeAttribute(t):i.setAttribute(t,e))}var f=class extends y{#r;#i;#t=!1;#e=new v(this,this.#a());#o=new MutationObserver(()=>this.#c());connectedCallback(){this.#e.connect(),this.#o.observe(this,{subtree:!0,childList:!0,attributes:!0,attributeFilter:["value","disabled","label","orientation"]}),this.#c()}disconnectedCallback(){this.#e.disconnect(),this.#o.disconnect()}get value(){return this.getAttribute("value")??void 0}set value(t){this.setAttribute("value",t)}#a(){return{state:()=>({active:this.value,orientation:this.#s(),dir:this.matches(":dir(rtl)")?"rtl":"ltr",activationMode:this.#l()}),activate:(t,e)=>{let o=new CustomEvent("primitiv-tabs-change",{bubbles:!0,cancelable:!0,detail:{value:t,index:e}});this.dispatchEvent(o)&&(this.value=t)}}}#l(){let t=this.getAttribute("activation-mode");return t==="manual"?t:"automatic"}#s(){return this.getAttribute("orientation")==="vertical"?"vertical":"horizontal"}#n(t){return[...this.querySelectorAll(t)].filter(e=>e.closest(E)===this)}#d(){return this.id||(this.#r??=`${E}-${++S}`)}#c(){let t=this.#s(),e=this.value,o=this.#d();s(this,"data-orientation",t);for(let a of this.#n(H)){s(a,"role","tablist"),s(a,"aria-orientation",t),s(a,"data-orientation",t);let n=a.getAttribute("label");n!==null&&s(a,"aria-label",n)}let r=new Map;for(let a of this.#n(k)){let n=a.getAttribute("value");n!==null&&r.set(n,a)}let l=this.#n(M).flatMap(a=>{let n=I(a);return n?[{trigger:a,...n}]:[]}),d=x({values:l.map(a=>a.value),active:e});this.#e.setTriggers(l.map(({trigger:a,tab:n,value:c})=>({value:c,element:n,disabled:a.hasAttribute("disabled")})));for(let{trigger:a,tab:n,value:c}of l){let u=r.get(c),w=c===e,O=a.hasAttribute("disabled");n.id||(n.id=g(o,"trigger",c)),u&&!u.id&&(u.id=g(o,"panel",c)),s(n,"role","tab"),s(n,"aria-selected",String(w)),s(n,"aria-controls",u?u.id:null),s(n,"tabindex",c===d?"0":"-1"),s(n,"aria-disabled",O?"true":null),s(n,"data-disabled",O?"":null),s(n,"data-state",w?"active":"inactive"),s(n,"data-orientation",t),u&&s(u,"aria-labelledby",n.id)}for(let[a,n]of r){let c=a===e;s(n,"role","tabpanel"),s(n,"tabindex","0"),s(n,"hidden",c?null:""),s(n,"data-state",c?"active":"inactive"),s(n,"data-orientation",t)}let b=r.get(e);this.#t&&e!==this.#i&&b&&b.dispatchEvent(new CustomEvent("primitiv-tabs-show",{bubbles:!0})),this.#i=e,this.#t=!0}},h=class extends y{},m=class extends y{},T=class extends y{};function R(i=globalThis.customElements){if(!i)return;let t=[[E,f],[H,h],[M,m],[k,T]];for(let[e,o]of t)i.get(e)||i.define(e,o)}R();export{T as PrimitivTabsContentElement,f as PrimitivTabsElement,h as PrimitivTabsListElement,m as PrimitivTabsTriggerElement,v as TabsController,R as defineTabs};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type TabsActivationMode, type TabsOrientation, type TabsReadingDirection } from "@primitiv-ui/core";
|
|
2
|
+
/**
|
|
3
|
+
* What a {@link TabsController} asks of whoever hosts it. The controller owns
|
|
4
|
+
* behaviour and never owns state (RFC 0033 D28): the custom elements keep state
|
|
5
|
+
* in reflected attributes, the React adapter keeps it in React, and each answers
|
|
6
|
+
* these two calls from wherever it keeps it.
|
|
7
|
+
*/
|
|
8
|
+
export type TabsControllerHost = {
|
|
9
|
+
/** The state the controller cannot see for itself. */
|
|
10
|
+
state(): {
|
|
11
|
+
active: string | undefined;
|
|
12
|
+
orientation: TabsOrientation;
|
|
13
|
+
dir: TabsReadingDirection;
|
|
14
|
+
activationMode: TabsActivationMode;
|
|
15
|
+
};
|
|
16
|
+
/** Apply an activation the user asked for. `index` is the tab's DOM position. */
|
|
17
|
+
activate(value: string, index: number): void;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The DOM behaviour of a tab set: listening, focus, and the ordered collection
|
|
21
|
+
* of triggers. It attaches to whatever elements register with it, so the same
|
|
22
|
+
* code runs under the custom elements and under the React adapter, which renders
|
|
23
|
+
* its own markup (RFC 0033 §3.4).
|
|
24
|
+
*/
|
|
25
|
+
export declare class TabsController {
|
|
26
|
+
#private;
|
|
27
|
+
constructor(root: HTMLElement, host: TabsControllerHost);
|
|
28
|
+
/**
|
|
29
|
+
* Register `element` as the trigger for `value`. A disabled trigger is still
|
|
30
|
+
* part of the collection — arrow keys land on it — but is never activated.
|
|
31
|
+
* Registration order does not matter: the collection follows DOM order, so
|
|
32
|
+
* parts that arrive in any order (a fragment swap, a framework render) line up.
|
|
33
|
+
* Registering an element again updates it.
|
|
34
|
+
*/
|
|
35
|
+
registerTrigger(value: string, element: HTMLElement, options?: {
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
}): void;
|
|
38
|
+
/**
|
|
39
|
+
* Replace the whole collection in one call. A host that re-reads its parts on
|
|
40
|
+
* every change (the custom elements, the React adapter) hands over what it
|
|
41
|
+
* has now; a trigger missing from the list is simply no longer registered.
|
|
42
|
+
*/
|
|
43
|
+
setTriggers(triggers: ReadonlyArray<{
|
|
44
|
+
value: string;
|
|
45
|
+
element: HTMLElement;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
}>): void;
|
|
48
|
+
/** Remove `element` from the collection. */
|
|
49
|
+
unregisterTrigger(element: HTMLElement): void;
|
|
50
|
+
/**
|
|
51
|
+
* Start listening on the root. Connecting again while connected does nothing,
|
|
52
|
+
* so a host whose lifecycle runs twice never stacks a second set of handlers.
|
|
53
|
+
*/
|
|
54
|
+
connect(): void;
|
|
55
|
+
/** Stop listening. The collection is kept, so a later `connect` resumes. */
|
|
56
|
+
disconnect(): void;
|
|
57
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { tabsClick, tabsKeydown, } from "@primitiv-ui/core";
|
|
2
|
+
/**
|
|
3
|
+
* The DOM behaviour of a tab set: listening, focus, and the ordered collection
|
|
4
|
+
* of triggers. It attaches to whatever elements register with it, so the same
|
|
5
|
+
* code runs under the custom elements and under the React adapter, which renders
|
|
6
|
+
* its own markup (RFC 0033 §3.4).
|
|
7
|
+
*/
|
|
8
|
+
export class TabsController {
|
|
9
|
+
#root;
|
|
10
|
+
#host;
|
|
11
|
+
#triggers = new Map();
|
|
12
|
+
#connection = null;
|
|
13
|
+
constructor(root, host) {
|
|
14
|
+
this.#root = root;
|
|
15
|
+
this.#host = host;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Register `element` as the trigger for `value`. A disabled trigger is still
|
|
19
|
+
* part of the collection — arrow keys land on it — but is never activated.
|
|
20
|
+
* Registration order does not matter: the collection follows DOM order, so
|
|
21
|
+
* parts that arrive in any order (a fragment swap, a framework render) line up.
|
|
22
|
+
* Registering an element again updates it.
|
|
23
|
+
*/
|
|
24
|
+
registerTrigger(value, element, options = {}) {
|
|
25
|
+
this.#triggers.set(element, { value, disabled: options.disabled ?? false });
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Replace the whole collection in one call. A host that re-reads its parts on
|
|
29
|
+
* every change (the custom elements, the React adapter) hands over what it
|
|
30
|
+
* has now; a trigger missing from the list is simply no longer registered.
|
|
31
|
+
*/
|
|
32
|
+
setTriggers(triggers) {
|
|
33
|
+
this.#triggers.clear();
|
|
34
|
+
for (const { value, element, disabled } of triggers) {
|
|
35
|
+
this.registerTrigger(value, element, { disabled });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Remove `element` from the collection. */
|
|
39
|
+
unregisterTrigger(element) {
|
|
40
|
+
this.#triggers.delete(element);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Start listening on the root. Connecting again while connected does nothing,
|
|
44
|
+
* so a host whose lifecycle runs twice never stacks a second set of handlers.
|
|
45
|
+
*/
|
|
46
|
+
connect() {
|
|
47
|
+
if (this.#connection)
|
|
48
|
+
return;
|
|
49
|
+
this.#connection = new AbortController();
|
|
50
|
+
const { signal } = this.#connection;
|
|
51
|
+
this.#root.addEventListener("click", (event) => this.#onClick(event), { signal });
|
|
52
|
+
this.#root.addEventListener("keydown", (event) => this.#onKeydown(event), { signal });
|
|
53
|
+
}
|
|
54
|
+
/** Stop listening. The collection is kept, so a later `connect` resumes. */
|
|
55
|
+
disconnect() {
|
|
56
|
+
this.#connection?.abort();
|
|
57
|
+
this.#connection = null;
|
|
58
|
+
}
|
|
59
|
+
/** Every registered trigger, in DOM order. */
|
|
60
|
+
#ordered() {
|
|
61
|
+
return [...this.#triggers].sort(([a], [b]) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
|
|
62
|
+
}
|
|
63
|
+
#disabled() {
|
|
64
|
+
const disabled = new Set();
|
|
65
|
+
for (const trigger of this.#triggers.values()) {
|
|
66
|
+
if (trigger.disabled)
|
|
67
|
+
disabled.add(trigger.value);
|
|
68
|
+
}
|
|
69
|
+
return disabled;
|
|
70
|
+
}
|
|
71
|
+
#onClick(event) {
|
|
72
|
+
const trigger = this.#triggerFor(event.target);
|
|
73
|
+
if (!trigger)
|
|
74
|
+
return;
|
|
75
|
+
const { active } = this.#host.state();
|
|
76
|
+
const target = tabsClick({ disabled: this.#disabled(), active }, trigger.value);
|
|
77
|
+
if (target !== null) {
|
|
78
|
+
const values = this.#ordered().map(([, t]) => t.value);
|
|
79
|
+
this.#host.activate(target, values.indexOf(target));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
#onKeydown(event) {
|
|
83
|
+
const trigger = this.#triggerFor(event.target);
|
|
84
|
+
if (!trigger)
|
|
85
|
+
return;
|
|
86
|
+
const { active, orientation, dir, activationMode } = this.#host.state();
|
|
87
|
+
const ordered = this.#ordered();
|
|
88
|
+
const values = ordered.map(([, t]) => t.value);
|
|
89
|
+
const outcome = tabsKeydown({ values, disabled: this.#disabled(), active, orientation, dir, activationMode }, trigger.value, event.key);
|
|
90
|
+
if (!outcome)
|
|
91
|
+
return;
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
if (outcome.activate !== null) {
|
|
94
|
+
this.#host.activate(outcome.activate, values.indexOf(outcome.activate));
|
|
95
|
+
}
|
|
96
|
+
// The focus target is always one of `values`, so its index is always valid.
|
|
97
|
+
ordered[values.indexOf(outcome.focus)][0].focus();
|
|
98
|
+
}
|
|
99
|
+
#triggerFor(target) {
|
|
100
|
+
for (const [element, trigger] of this.#triggers) {
|
|
101
|
+
if (element.contains(target))
|
|
102
|
+
return trigger;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base class every element extends. It falls back to an empty class where
|
|
3
|
+
* the DOM does not exist, so a server renderer (Next.js, Nuxt, Angular SSR,
|
|
4
|
+
* Astro) can import this module without `HTMLElement` throwing at load time
|
|
5
|
+
* (RFC 0033 D21).
|
|
6
|
+
*/
|
|
7
|
+
declare const ElementBase: typeof HTMLElement;
|
|
8
|
+
/**
|
|
9
|
+
* `<primitiv-tabs>` — the state owner of a WAI-ARIA tab set.
|
|
10
|
+
*
|
|
11
|
+
* The root finds its own parts, scoped with `closest()` so a tab set nested in a
|
|
12
|
+
* panel keeps its own, and reconciles their ARIA from its reflected state. It
|
|
13
|
+
* never caches the parts, so markup that arrives later is picked up on the next
|
|
14
|
+
* reconcile. One `MutationObserver` is the only trigger after connect: it sees
|
|
15
|
+
* parts arriving and leaving, and every attribute the root reads, including the
|
|
16
|
+
* root's own `value` and `orientation`, and it already delivers a task's changes
|
|
17
|
+
* as one callback, so no further batching is needed. `activation-mode` is read
|
|
18
|
+
* live at each key press, so a change to it needs no reconcile.
|
|
19
|
+
*
|
|
20
|
+
* @customElement primitiv-tabs
|
|
21
|
+
* @summary The state owner of a WAI-ARIA tab set.
|
|
22
|
+
* @attr {string} value - The active tab's value. Reflected: it moves as the user switches tabs, so a server re-render or a history snapshot sees the live selection.
|
|
23
|
+
* @attr {"horizontal" | "vertical"} orientation - Layout axis, which picks the arrow-key pair. Defaults to `horizontal`.
|
|
24
|
+
* @attr {"automatic" | "manual"} activation-mode - `automatic` activates a tab as focus arrives; `manual` waits for Enter or Space. Defaults to `automatic`.
|
|
25
|
+
* @fires {CustomEvent<{ value: string; index: number }>} primitiv-tabs-change - Bubbles, cancelable, fired before a user-driven change is applied. Call `preventDefault()` to veto it.
|
|
26
|
+
*/
|
|
27
|
+
export declare class PrimitivTabsElement extends ElementBase {
|
|
28
|
+
#private;
|
|
29
|
+
connectedCallback(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Leaving the document stops all listening. A move (an `hx-preserve`, a
|
|
32
|
+
* framework reparenting) is a disconnect followed by a connect, so handlers
|
|
33
|
+
* never stack.
|
|
34
|
+
*/
|
|
35
|
+
disconnectedCallback(): void;
|
|
36
|
+
/** The active tab's value, reflected as the `value` attribute. */
|
|
37
|
+
get value(): string | undefined;
|
|
38
|
+
set value(next: string);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `<primitiv-tabs-list>` — the tablist. Structural: the root writes its ARIA.
|
|
42
|
+
*
|
|
43
|
+
* @customElement primitiv-tabs-list
|
|
44
|
+
* @attr {string} label - The tablist's accessible name. Give this or `aria-labelledby`.
|
|
45
|
+
*/
|
|
46
|
+
export declare class PrimitivTabsListElement extends ElementBase {
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `<primitiv-tabs-trigger>` — an interactive part. It decorates exactly one
|
|
50
|
+
* child the consumer writes (a `<button>`, an `<a href>`, a router link), which
|
|
51
|
+
* receives the tab role, so the rendered DOM matches React's `<button role="tab">`
|
|
52
|
+
* (RFC 0033 D24).
|
|
53
|
+
*
|
|
54
|
+
* @customElement primitiv-tabs-trigger
|
|
55
|
+
* @attr {string} value - The tab's value, pairing it with the panel of the same value.
|
|
56
|
+
* @attr {boolean} disabled - Marks the tab disabled: arrow keys still land on it, but it never activates.
|
|
57
|
+
*/
|
|
58
|
+
export declare class PrimitivTabsTriggerElement extends ElementBase {
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* `<primitiv-tabs-content>` — a tab panel. Structural: the root writes its ARIA.
|
|
62
|
+
*
|
|
63
|
+
* @customElement primitiv-tabs-content
|
|
64
|
+
* @attr {string} value - The panel's value, pairing it with the tab of the same value.
|
|
65
|
+
* @fires {CustomEvent} primitiv-tabs-show - Bubbles from the panel as it becomes active (not on first render), so a lazy panel can fetch its content.
|
|
66
|
+
*/
|
|
67
|
+
export declare class PrimitivTabsContentElement extends ElementBase {
|
|
68
|
+
}
|
|
69
|
+
/** The two calls `defineTabs` makes on a custom-element registry. */
|
|
70
|
+
export type ElementRegistry = Pick<CustomElementRegistry, "get" | "define">;
|
|
71
|
+
/**
|
|
72
|
+
* Define the four Tabs elements on `registry` (the global `customElements` by
|
|
73
|
+
* default, so a scoped registry can be passed when browsers support one). Safe
|
|
74
|
+
* to call more than once, and a no-op where there is no registry (a server
|
|
75
|
+
* render).
|
|
76
|
+
*/
|
|
77
|
+
export declare function defineTabs(registry?: ElementRegistry | undefined): void;
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { deriveId, tabsTabStop, } from "@primitiv-ui/core";
|
|
2
|
+
import { TabsController } from "./TabsController.js";
|
|
3
|
+
/**
|
|
4
|
+
* The base class every element extends. It falls back to an empty class where
|
|
5
|
+
* the DOM does not exist, so a server renderer (Next.js, Nuxt, Angular SSR,
|
|
6
|
+
* Astro) can import this module without `HTMLElement` throwing at load time
|
|
7
|
+
* (RFC 0033 D21).
|
|
8
|
+
*/
|
|
9
|
+
const ElementBase = globalThis.HTMLElement ?? class {
|
|
10
|
+
};
|
|
11
|
+
// Stryker disable StringLiteral: static — evaluated once at import, so runtime mutant switching never reaches them; the names are pinned by the defineTabs registry test instead.
|
|
12
|
+
const ROOT = "primitiv-tabs";
|
|
13
|
+
const LIST = "primitiv-tabs-list";
|
|
14
|
+
const TRIGGER = "primitiv-tabs-trigger";
|
|
15
|
+
const CONTENT = "primitiv-tabs-content";
|
|
16
|
+
// Stryker restore StringLiteral
|
|
17
|
+
let generatedIds = 0;
|
|
18
|
+
/** Triggers already reported as misused, so each is reported once, not per reconcile. */
|
|
19
|
+
const reported = new WeakSet();
|
|
20
|
+
/**
|
|
21
|
+
* An interactive part decorates exactly one element child and needs a `value`.
|
|
22
|
+
* Anything else is reported once and left undecorated rather than thrown: a
|
|
23
|
+
* server-rendered page has no error boundary to catch a throw (RFC 0033 D24).
|
|
24
|
+
*/
|
|
25
|
+
function decoratable(trigger) {
|
|
26
|
+
const value = trigger.getAttribute("value");
|
|
27
|
+
if (trigger.childElementCount === 1 && value !== null) {
|
|
28
|
+
return { tab: trigger.firstElementChild, value };
|
|
29
|
+
}
|
|
30
|
+
if (!reported.has(trigger)) {
|
|
31
|
+
reported.add(trigger);
|
|
32
|
+
console.error(`<${TRIGGER}> needs a value and exactly one element child (a <button>, an <a href> or a router link) to decorate.`, trigger);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Write an attribute only when it would change: `null` removes it. The elements
|
|
38
|
+
* write only the attributes in their contract (RFC 0033 §5.3), and comparing
|
|
39
|
+
* first means a value a framework or the server already rendered is never
|
|
40
|
+
* rewritten (D20).
|
|
41
|
+
*/
|
|
42
|
+
function write(element, name, value) {
|
|
43
|
+
if (element.getAttribute(name) === value)
|
|
44
|
+
return;
|
|
45
|
+
if (value === null)
|
|
46
|
+
element.removeAttribute(name);
|
|
47
|
+
else
|
|
48
|
+
element.setAttribute(name, value);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `<primitiv-tabs>` — the state owner of a WAI-ARIA tab set.
|
|
52
|
+
*
|
|
53
|
+
* The root finds its own parts, scoped with `closest()` so a tab set nested in a
|
|
54
|
+
* panel keeps its own, and reconciles their ARIA from its reflected state. It
|
|
55
|
+
* never caches the parts, so markup that arrives later is picked up on the next
|
|
56
|
+
* reconcile. One `MutationObserver` is the only trigger after connect: it sees
|
|
57
|
+
* parts arriving and leaving, and every attribute the root reads, including the
|
|
58
|
+
* root's own `value` and `orientation`, and it already delivers a task's changes
|
|
59
|
+
* as one callback, so no further batching is needed. `activation-mode` is read
|
|
60
|
+
* live at each key press, so a change to it needs no reconcile.
|
|
61
|
+
*
|
|
62
|
+
* @customElement primitiv-tabs
|
|
63
|
+
* @summary The state owner of a WAI-ARIA tab set.
|
|
64
|
+
* @attr {string} value - The active tab's value. Reflected: it moves as the user switches tabs, so a server re-render or a history snapshot sees the live selection.
|
|
65
|
+
* @attr {"horizontal" | "vertical"} orientation - Layout axis, which picks the arrow-key pair. Defaults to `horizontal`.
|
|
66
|
+
* @attr {"automatic" | "manual"} activation-mode - `automatic` activates a tab as focus arrives; `manual` waits for Enter or Space. Defaults to `automatic`.
|
|
67
|
+
* @fires {CustomEvent<{ value: string; index: number }>} primitiv-tabs-change - Bubbles, cancelable, fired before a user-driven change is applied. Call `preventDefault()` to veto it.
|
|
68
|
+
*/
|
|
69
|
+
export class PrimitivTabsElement extends ElementBase {
|
|
70
|
+
#generatedId;
|
|
71
|
+
/** The value of the panel last shown, so a change fires primitiv-tabs-show. */
|
|
72
|
+
#shown;
|
|
73
|
+
#rendered = false;
|
|
74
|
+
#controller = new TabsController(this, this.#host());
|
|
75
|
+
/**
|
|
76
|
+
* Watches the subtree for parts arriving, leaving, or changing the attributes
|
|
77
|
+
* the root reads. The root's own writes (role, aria-*, ids, data-*) are not in
|
|
78
|
+
* the filter, so reconciling never re-triggers itself.
|
|
79
|
+
*/
|
|
80
|
+
#observer = new MutationObserver(() => this.#reconcile());
|
|
81
|
+
connectedCallback() {
|
|
82
|
+
this.#controller.connect();
|
|
83
|
+
this.#observer.observe(this, {
|
|
84
|
+
subtree: true,
|
|
85
|
+
childList: true,
|
|
86
|
+
attributes: true,
|
|
87
|
+
attributeFilter: ["value", "disabled", "label", "orientation"],
|
|
88
|
+
});
|
|
89
|
+
this.#reconcile();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Leaving the document stops all listening. A move (an `hx-preserve`, a
|
|
93
|
+
* framework reparenting) is a disconnect followed by a connect, so handlers
|
|
94
|
+
* never stack.
|
|
95
|
+
*/
|
|
96
|
+
disconnectedCallback() {
|
|
97
|
+
this.#controller.disconnect();
|
|
98
|
+
this.#observer.disconnect();
|
|
99
|
+
}
|
|
100
|
+
/** The active tab's value, reflected as the `value` attribute. */
|
|
101
|
+
get value() {
|
|
102
|
+
return this.getAttribute("value") ?? undefined;
|
|
103
|
+
}
|
|
104
|
+
set value(next) {
|
|
105
|
+
this.setAttribute("value", next);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The controller's view of this element. State lives in reflected attributes;
|
|
109
|
+
* an activation is announced first, as a cancelable event, so a listener can
|
|
110
|
+
* veto it — the elements' equivalent of React's controlled mode (D7).
|
|
111
|
+
*/
|
|
112
|
+
#host() {
|
|
113
|
+
return {
|
|
114
|
+
state: () => ({
|
|
115
|
+
active: this.value,
|
|
116
|
+
orientation: this.#orientation(),
|
|
117
|
+
// Stryker disable next-line StringLiteral: equivalent — core tests only for "rtl", so any other string reads as left-to-right.
|
|
118
|
+
dir: this.matches(":dir(rtl)") ? "rtl" : "ltr",
|
|
119
|
+
activationMode: this.#activationMode(),
|
|
120
|
+
}),
|
|
121
|
+
activate: (value, index) => {
|
|
122
|
+
const change = new CustomEvent("primitiv-tabs-change", {
|
|
123
|
+
bubbles: true,
|
|
124
|
+
cancelable: true,
|
|
125
|
+
detail: { value, index },
|
|
126
|
+
});
|
|
127
|
+
if (this.dispatchEvent(change))
|
|
128
|
+
this.value = value;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
#activationMode() {
|
|
133
|
+
const mode = this.getAttribute("activation-mode");
|
|
134
|
+
return mode === "manual" ? mode : "automatic";
|
|
135
|
+
}
|
|
136
|
+
#orientation() {
|
|
137
|
+
return this.getAttribute("orientation") === "vertical" ? "vertical" : "horizontal";
|
|
138
|
+
}
|
|
139
|
+
/** Parts of this tab set only, never those of a nested one. */
|
|
140
|
+
#parts(selector) {
|
|
141
|
+
return [...this.querySelectorAll(selector)].filter((part) => part.closest(ROOT) === this);
|
|
142
|
+
}
|
|
143
|
+
#rootId() {
|
|
144
|
+
// Stryker disable next-line UpdateOperator: equivalent — counting down yields ids just as unique.
|
|
145
|
+
return this.id || (this.#generatedId ??= `${ROOT}-${++generatedIds}`);
|
|
146
|
+
}
|
|
147
|
+
#reconcile() {
|
|
148
|
+
const orientation = this.#orientation();
|
|
149
|
+
const active = this.value;
|
|
150
|
+
const rootId = this.#rootId();
|
|
151
|
+
write(this, "data-orientation", orientation);
|
|
152
|
+
for (const list of this.#parts(LIST)) {
|
|
153
|
+
write(list, "role", "tablist");
|
|
154
|
+
write(list, "aria-orientation", orientation);
|
|
155
|
+
write(list, "data-orientation", orientation);
|
|
156
|
+
const label = list.getAttribute("label");
|
|
157
|
+
if (label !== null)
|
|
158
|
+
write(list, "aria-label", label);
|
|
159
|
+
}
|
|
160
|
+
const panels = new Map();
|
|
161
|
+
for (const panel of this.#parts(CONTENT)) {
|
|
162
|
+
const value = panel.getAttribute("value");
|
|
163
|
+
if (value !== null)
|
|
164
|
+
panels.set(value, panel);
|
|
165
|
+
}
|
|
166
|
+
const triggers = this.#parts(TRIGGER).flatMap((trigger) => {
|
|
167
|
+
const part = decoratable(trigger);
|
|
168
|
+
return part ? [{ trigger, ...part }] : [];
|
|
169
|
+
});
|
|
170
|
+
const stop = tabsTabStop({ values: triggers.map((t) => t.value), active });
|
|
171
|
+
// Replace the registration wholesale: a removed part, or a trigger whose child
|
|
172
|
+
// a framework re-rendered, simply is not registered again.
|
|
173
|
+
this.#controller.setTriggers(triggers.map(({ trigger, tab, value }) => ({
|
|
174
|
+
value,
|
|
175
|
+
element: tab,
|
|
176
|
+
disabled: trigger.hasAttribute("disabled"),
|
|
177
|
+
})));
|
|
178
|
+
for (const { trigger, tab, value } of triggers) {
|
|
179
|
+
const panel = panels.get(value);
|
|
180
|
+
const selected = value === active;
|
|
181
|
+
const disabled = trigger.hasAttribute("disabled");
|
|
182
|
+
if (!tab.id)
|
|
183
|
+
tab.id = deriveId(rootId, "trigger", value);
|
|
184
|
+
if (panel && !panel.id)
|
|
185
|
+
panel.id = deriveId(rootId, "panel", value);
|
|
186
|
+
write(tab, "role", "tab");
|
|
187
|
+
write(tab, "aria-selected", String(selected));
|
|
188
|
+
write(tab, "aria-controls", panel ? panel.id : null);
|
|
189
|
+
write(tab, "tabindex", value === stop ? "0" : "-1");
|
|
190
|
+
write(tab, "aria-disabled", disabled ? "true" : null);
|
|
191
|
+
write(tab, "data-disabled", disabled ? "" : null);
|
|
192
|
+
write(tab, "data-state", selected ? "active" : "inactive");
|
|
193
|
+
write(tab, "data-orientation", orientation);
|
|
194
|
+
if (panel)
|
|
195
|
+
write(panel, "aria-labelledby", tab.id);
|
|
196
|
+
}
|
|
197
|
+
for (const [value, panel] of panels) {
|
|
198
|
+
const selected = value === active;
|
|
199
|
+
write(panel, "role", "tabpanel");
|
|
200
|
+
write(panel, "tabindex", "0");
|
|
201
|
+
write(panel, "hidden", selected ? null : "");
|
|
202
|
+
write(panel, "data-state", selected ? "active" : "inactive");
|
|
203
|
+
write(panel, "data-orientation", orientation);
|
|
204
|
+
}
|
|
205
|
+
// A panel that becomes active announces it, so a lazy panel can fetch its
|
|
206
|
+
// content (`hx-trigger="primitiv-tabs-show once"`, RFC 0033 D9). The first
|
|
207
|
+
// render shows what the server already rendered, so it announces nothing.
|
|
208
|
+
// `Map.get(undefined)` is simply undefined, so no tab active means no panel.
|
|
209
|
+
const shownPanel = panels.get(active);
|
|
210
|
+
if (this.#rendered && active !== this.#shown && shownPanel) {
|
|
211
|
+
shownPanel.dispatchEvent(new CustomEvent("primitiv-tabs-show", { bubbles: true }));
|
|
212
|
+
}
|
|
213
|
+
this.#shown = active;
|
|
214
|
+
this.#rendered = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* `<primitiv-tabs-list>` — the tablist. Structural: the root writes its ARIA.
|
|
219
|
+
*
|
|
220
|
+
* @customElement primitiv-tabs-list
|
|
221
|
+
* @attr {string} label - The tablist's accessible name. Give this or `aria-labelledby`.
|
|
222
|
+
*/
|
|
223
|
+
export class PrimitivTabsListElement extends ElementBase {
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* `<primitiv-tabs-trigger>` — an interactive part. It decorates exactly one
|
|
227
|
+
* child the consumer writes (a `<button>`, an `<a href>`, a router link), which
|
|
228
|
+
* receives the tab role, so the rendered DOM matches React's `<button role="tab">`
|
|
229
|
+
* (RFC 0033 D24).
|
|
230
|
+
*
|
|
231
|
+
* @customElement primitiv-tabs-trigger
|
|
232
|
+
* @attr {string} value - The tab's value, pairing it with the panel of the same value.
|
|
233
|
+
* @attr {boolean} disabled - Marks the tab disabled: arrow keys still land on it, but it never activates.
|
|
234
|
+
*/
|
|
235
|
+
export class PrimitivTabsTriggerElement extends ElementBase {
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* `<primitiv-tabs-content>` — a tab panel. Structural: the root writes its ARIA.
|
|
239
|
+
*
|
|
240
|
+
* @customElement primitiv-tabs-content
|
|
241
|
+
* @attr {string} value - The panel's value, pairing it with the tab of the same value.
|
|
242
|
+
* @fires {CustomEvent} primitiv-tabs-show - Bubbles from the panel as it becomes active (not on first render), so a lazy panel can fetch its content.
|
|
243
|
+
*/
|
|
244
|
+
export class PrimitivTabsContentElement extends ElementBase {
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Define the four Tabs elements on `registry` (the global `customElements` by
|
|
248
|
+
* default, so a scoped registry can be passed when browsers support one). Safe
|
|
249
|
+
* to call more than once, and a no-op where there is no registry (a server
|
|
250
|
+
* render).
|
|
251
|
+
*/
|
|
252
|
+
export function defineTabs(registry = globalThis.customElements) {
|
|
253
|
+
if (!registry)
|
|
254
|
+
return;
|
|
255
|
+
const elements = [
|
|
256
|
+
[ROOT, PrimitivTabsElement],
|
|
257
|
+
[LIST, PrimitivTabsListElement],
|
|
258
|
+
[TRIGGER, PrimitivTabsTriggerElement],
|
|
259
|
+
[CONTENT, PrimitivTabsContentElement],
|
|
260
|
+
];
|
|
261
|
+
for (const [name, element] of elements) {
|
|
262
|
+
if (!registry.get(name))
|
|
263
|
+
registry.define(name, element);
|
|
264
|
+
}
|
|
265
|
+
}
|
package/dist/tags.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in TypeScript tag map: bring this module into scope and DOM APIs return
|
|
3
|
+
* the element classes for Primitiv's tags.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import type {} from "@primitiv-ui/web/tags";
|
|
7
|
+
* document.querySelector("primitiv-tabs"); // PrimitivTabsElement | null
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* or list `"@primitiv-ui/web/tags"` in `compilerOptions.types`.
|
|
11
|
+
*
|
|
12
|
+
* It is a separate, npm-only entry because it augments a global type, which
|
|
13
|
+
* JSR does not allow a published module to do (RFC 0033 §5.6.4).
|
|
14
|
+
*/
|
|
15
|
+
import type { PrimitivTabsContentElement, PrimitivTabsElement, PrimitivTabsListElement, PrimitivTabsTriggerElement } from "./tabs/elements.js";
|
|
16
|
+
declare global {
|
|
17
|
+
interface HTMLElementTagNameMap {
|
|
18
|
+
"primitiv-tabs": PrimitivTabsElement;
|
|
19
|
+
"primitiv-tabs-list": PrimitivTabsListElement;
|
|
20
|
+
"primitiv-tabs-trigger": PrimitivTabsTriggerElement;
|
|
21
|
+
"primitiv-tabs-content": PrimitivTabsContentElement;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export {};
|
package/dist/tags.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|