@fluixi/web-components 0.0.0-stage → 1.0.0-alpha.97

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -2
  3. package/dist/context.cjs +162 -0
  4. package/dist/context.d.ts +65 -0
  5. package/dist/context.d.ts.map +1 -0
  6. package/dist/context.js +192 -0
  7. package/dist/context.mjs +141 -0
  8. package/dist/element.cjs +847 -0
  9. package/dist/element.d.ts +213 -0
  10. package/dist/element.d.ts.map +1 -0
  11. package/dist/element.js +681 -0
  12. package/dist/element.mjs +835 -0
  13. package/dist/events.cjs +35 -0
  14. package/dist/events.d.ts +24 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +20 -0
  17. package/dist/events.mjs +14 -0
  18. package/dist/focus.cjs +156 -0
  19. package/dist/focus.d.ts +34 -0
  20. package/dist/focus.d.ts.map +1 -0
  21. package/dist/focus.js +185 -0
  22. package/dist/focus.mjs +135 -0
  23. package/dist/form.cjs +145 -0
  24. package/dist/form.d.ts +33 -0
  25. package/dist/form.d.ts.map +1 -0
  26. package/dist/form.js +113 -0
  27. package/dist/form.mjs +125 -0
  28. package/dist/index.cjs +1202 -0
  29. package/dist/index.d.ts +41 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +31 -0
  32. package/dist/index.mjs +1188 -0
  33. package/dist/props.cjs +114 -0
  34. package/dist/props.d.ts +93 -0
  35. package/dist/props.d.ts.map +1 -0
  36. package/dist/props.js +99 -0
  37. package/dist/props.mjs +93 -0
  38. package/dist/registry.cjs +77 -0
  39. package/dist/registry.d.ts +26 -0
  40. package/dist/registry.d.ts.map +1 -0
  41. package/dist/registry.js +91 -0
  42. package/dist/registry.mjs +56 -0
  43. package/dist/styles.cjs +59 -0
  44. package/dist/styles.d.ts +17 -0
  45. package/dist/styles.d.ts.map +1 -0
  46. package/dist/styles.js +53 -0
  47. package/dist/styles.mjs +38 -0
  48. package/dist/targets.cjs +42 -0
  49. package/dist/targets.d.ts +26 -0
  50. package/dist/targets.d.ts.map +1 -0
  51. package/dist/targets.js +35 -0
  52. package/dist/targets.mjs +21 -0
  53. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  54. package/dist/types.cjs +18 -0
  55. package/dist/types.d.ts +78 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +1 -0
  58. package/dist/types.mjs +0 -0
  59. package/package.json +53 -3
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/events.ts
21
+ var events_exports = {};
22
+ __export(events_exports, {
23
+ createDispatch: () => createDispatch
24
+ });
25
+ module.exports = __toCommonJS(events_exports);
26
+ function createDispatch(host) {
27
+ return (type, detail, init = {}) => host.dispatchEvent(
28
+ new CustomEvent(type, {
29
+ bubbles: init.bubbles ?? true,
30
+ composed: init.composed ?? true,
31
+ cancelable: init.cancelable ?? false,
32
+ detail
33
+ })
34
+ );
35
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Events out of the element.
3
+ *
4
+ * A custom element's public API is properties in, events out, and the events have to be
5
+ * ordinary `CustomEvent`s or nothing outside will hear them. React's `onFoo` props,
6
+ * Vue's `@foo` and Angular's `(foo)` all bind through `addEventListener`, so anything
7
+ * proprietary here would cut the component off from every consumer it exists for.
8
+ *
9
+ * The defaults are the ones a component author almost always wants and almost always
10
+ * forgets: an event that does not bubble is invisible to a parent, and one that is not
11
+ * `composed` stops at the shadow boundary, which is exactly where it needs to escape.
12
+ */
13
+ export interface DispatchInit extends Omit<CustomEventInit, 'detail'> {
14
+ /** Defaults to true. */
15
+ bubbles?: boolean;
16
+ /** Defaults to true, so the event crosses a shadow root. */
17
+ composed?: boolean;
18
+ /** Defaults to false. Set it when `preventDefault()` should mean something. */
19
+ cancelable?: boolean;
20
+ }
21
+ /** Emit a `CustomEvent` from the host. False when a listener cancelled it. */
22
+ export type Dispatch = <T = unknown>(type: string, detail?: T, init?: DispatchInit) => boolean;
23
+ export declare function createDispatch(host: EventTarget): Dispatch;
24
+ //# sourceMappingURL=events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,YAAa,SAAQ,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC;IACnE,wBAAwB;IACxB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+EAA+E;IAC/E,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,8EAA8E;AAC9E,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE,YAAY,KAAK,OAAO,CAAC;AAE/F,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,QAAQ,CAU1D"}
package/dist/events.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Events out of the element.
3
+ *
4
+ * A custom element's public API is properties in, events out, and the events have to be
5
+ * ordinary `CustomEvent`s or nothing outside will hear them. React's `onFoo` props,
6
+ * Vue's `@foo` and Angular's `(foo)` all bind through `addEventListener`, so anything
7
+ * proprietary here would cut the component off from every consumer it exists for.
8
+ *
9
+ * The defaults are the ones a component author almost always wants and almost always
10
+ * forgets: an event that does not bubble is invisible to a parent, and one that is not
11
+ * `composed` stops at the shadow boundary, which is exactly where it needs to escape.
12
+ */
13
+ export function createDispatch(host) {
14
+ return (type, detail, init = {}) => host.dispatchEvent(new CustomEvent(type, {
15
+ bubbles: init.bubbles ?? true,
16
+ composed: init.composed ?? true,
17
+ cancelable: init.cancelable ?? false,
18
+ detail: detail,
19
+ }));
20
+ }
@@ -0,0 +1,14 @@
1
+ // src/events.ts
2
+ function createDispatch(host) {
3
+ return (type, detail, init = {}) => host.dispatchEvent(
4
+ new CustomEvent(type, {
5
+ bubbles: init.bubbles ?? true,
6
+ composed: init.composed ?? true,
7
+ cancelable: init.cancelable ?? false,
8
+ detail
9
+ })
10
+ );
11
+ }
12
+ export {
13
+ createDispatch
14
+ };
package/dist/focus.cjs ADDED
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/focus.ts
21
+ var focus_exports = {};
22
+ __export(focus_exports, {
23
+ deepActiveElement: () => deepActiveElement,
24
+ rovingFocus: () => rovingFocus,
25
+ tabbables: () => tabbables,
26
+ trapFocus: () => trapFocus
27
+ });
28
+ module.exports = __toCommonJS(focus_exports);
29
+ var import_signal = require("@fluixi/reactive/signal");
30
+ var FOCUSABLE = 'a[href],area[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable]:not([contenteditable="false"])';
31
+ function isTabbable(el) {
32
+ if (!el.matches(FOCUSABLE) || el.tabIndex < 0) return false;
33
+ if (el.disabled || el.closest("[inert]")) return false;
34
+ if (el instanceof HTMLInputElement && el.type === "hidden") return false;
35
+ return el.checkVisibility?.({ visibilityProperty: true }) ?? true;
36
+ }
37
+ function tabbables(root) {
38
+ const out = [];
39
+ const visit = (node) => {
40
+ if (node instanceof HTMLSlotElement) {
41
+ const assigned = node.assignedElements({ flatten: true });
42
+ for (const child of assigned.length ? assigned : Array.from(node.children)) visit(child);
43
+ return;
44
+ }
45
+ const shadow = node.shadowRoot;
46
+ if (node instanceof HTMLElement && isTabbable(node) && !shadow?.delegatesFocus) out.push(node);
47
+ if (shadow) for (const child of Array.from(shadow.children)) visit(child);
48
+ else for (const child of Array.from(node.children)) visit(child);
49
+ };
50
+ for (const child of Array.from(root.children)) visit(child);
51
+ return out;
52
+ }
53
+ function deepActiveElement(doc = document) {
54
+ let active = doc.activeElement;
55
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
56
+ return active;
57
+ }
58
+ function rovingFocus(container, options) {
59
+ const { orientation = "vertical", loop = true } = options;
60
+ let active;
61
+ const enabled = () => options.items().filter((item) => !item.disabled && item.getAttribute("aria-disabled") !== "true");
62
+ const settle = (items) => {
63
+ if (!active || !items.includes(active)) active = enabled()[0];
64
+ for (const item of items) item.tabIndex = item === active ? 0 : -1;
65
+ };
66
+ const move = (to) => {
67
+ const items = options.items();
68
+ if (to === active) return;
69
+ active = to;
70
+ settle(items);
71
+ to.focus();
72
+ options.onMove?.(to, items.indexOf(to));
73
+ };
74
+ const keys = {
75
+ next: orientation === "horizontal" ? ["ArrowRight"] : orientation === "vertical" ? ["ArrowDown"] : ["ArrowRight", "ArrowDown"],
76
+ previous: orientation === "horizontal" ? ["ArrowLeft"] : orientation === "vertical" ? ["ArrowUp"] : ["ArrowLeft", "ArrowUp"]
77
+ };
78
+ const onKey = (event) => {
79
+ const list = enabled();
80
+ if (!list.length) return;
81
+ const at = active ? list.indexOf(active) : -1;
82
+ let next;
83
+ if (keys.next.includes(event.key)) next = list[at + 1] ?? (loop ? list[0] : void 0);
84
+ else if (keys.previous.includes(event.key)) next = list[at - 1] ?? (loop ? list[list.length - 1] : void 0);
85
+ else if (event.key === "Home") next = list[0];
86
+ else if (event.key === "End") next = list[list.length - 1];
87
+ else return;
88
+ event.preventDefault();
89
+ if (next) move(next);
90
+ };
91
+ const onFocus = (event) => {
92
+ const item = enabled().find((candidate) => event.composedPath().includes(candidate));
93
+ if (item && item !== active) move(item);
94
+ };
95
+ container.addEventListener("keydown", onKey);
96
+ container.addEventListener("focusin", onFocus);
97
+ const stopTracking = (0, import_signal.effect)(() => settle(options.items()), void 0, { name: "roving focus" });
98
+ let stopped = false;
99
+ const stop = () => {
100
+ if (stopped) return;
101
+ stopped = true;
102
+ container.removeEventListener("keydown", onKey);
103
+ container.removeEventListener("focusin", onFocus);
104
+ stopTracking();
105
+ };
106
+ (0, import_signal.onCleanup)(stop);
107
+ return stop;
108
+ }
109
+ function trapFocus(container, options = {}) {
110
+ const doc = container.ownerDocument;
111
+ const previous = deepActiveElement(doc);
112
+ const first = () => tabbables(container)[0];
113
+ const inside = (node) => node instanceof Node && (container.contains(node) || containsComposed(container, node));
114
+ const initial = typeof options.initial === "function" ? options.initial() : options.initial;
115
+ (initial ?? first() ?? container).focus();
116
+ const onKey = (event) => {
117
+ if (event.key !== "Tab") return;
118
+ const list = tabbables(container);
119
+ if (!list.length) {
120
+ event.preventDefault();
121
+ return;
122
+ }
123
+ const current = deepActiveElement(doc);
124
+ const at = list.indexOf(current);
125
+ if (event.shiftKey && at <= 0) {
126
+ event.preventDefault();
127
+ list[list.length - 1].focus();
128
+ } else if (!event.shiftKey && at === list.length - 1) {
129
+ event.preventDefault();
130
+ list[0].focus();
131
+ }
132
+ };
133
+ const onFocusIn = (event) => {
134
+ if (!inside(event.composedPath()[0] ?? event.target)) (first() ?? container).focus();
135
+ };
136
+ container.addEventListener("keydown", onKey);
137
+ doc.addEventListener("focusin", onFocusIn, true);
138
+ let released = false;
139
+ const release = () => {
140
+ if (released) return;
141
+ released = true;
142
+ container.removeEventListener("keydown", onKey);
143
+ doc.removeEventListener("focusin", onFocusIn, true);
144
+ if (options.restore !== false && previous?.isConnected) previous.focus();
145
+ };
146
+ (0, import_signal.onCleanup)(release);
147
+ return release;
148
+ }
149
+ function containsComposed(container, node) {
150
+ let at = node;
151
+ while (at) {
152
+ if (at === container) return true;
153
+ at = at.parentNode ?? (at instanceof ShadowRoot ? at.host : null);
154
+ }
155
+ return false;
156
+ }
@@ -0,0 +1,34 @@
1
+ /** Every element the Tab key reaches under `root`, in order, through shadow roots and slots. */
2
+ export declare function tabbables(root: ParentNode): HTMLElement[];
3
+ /** The element that really has focus, looking through shadow roots. */
4
+ export declare function deepActiveElement(doc?: Document): Element | null;
5
+ export interface RovingFocusOptions {
6
+ /** The items, in order. Read reactively: a list that changes is followed. */
7
+ items: () => readonly HTMLElement[];
8
+ /** Which arrow keys move. `vertical` for a menu, `horizontal` for tabs. */
9
+ orientation?: 'horizontal' | 'vertical' | 'both';
10
+ /** Past the last item wraps to the first. */
11
+ loop?: boolean;
12
+ /** Called when the active item changes, by key or by click. */
13
+ onMove?: (item: HTMLElement, index: number) => void;
14
+ }
15
+ /**
16
+ * One tab stop for a group: the active item has `tabindex="0"`, the rest `-1`, and the arrow
17
+ * keys, Home and End move between them. Disabled items are skipped. Stops when the reactive
18
+ * scope it was called in ends; the returned function stops it earlier.
19
+ */
20
+ export declare function rovingFocus(container: HTMLElement, options: RovingFocusOptions): () => void;
21
+ export interface FocusTrapOptions {
22
+ /** What to focus first. Defaults to the first tabbable element inside. */
23
+ initial?: HTMLElement | (() => HTMLElement | null | undefined);
24
+ /** Give focus back to what had it before, on release. Defaults to true. */
25
+ restore?: boolean;
26
+ }
27
+ /**
28
+ * Keep keyboard focus inside `container`: Tab and Shift+Tab wrap at the ends, and focus that
29
+ * lands outside is brought back. `<dialog>` opened with `showModal()` does this natively; this
30
+ * is for a panel that is not a modal dialog. Released when the reactive scope it was called in
31
+ * ends, or by the returned function.
32
+ */
33
+ export declare function trapFocus(container: HTMLElement, options?: FocusTrapOptions): () => void;
34
+ //# sourceMappingURL=focus.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"focus.d.ts","sourceRoot":"","sources":["../src/focus.ts"],"names":[],"mappings":"AAuBA,gGAAgG;AAChG,wBAAgB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,WAAW,EAAE,CAgBzD;AAED,uEAAuE;AACvE,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,QAAmB,GAAG,OAAO,GAAG,IAAI,CAI1E;AAED,MAAM,WAAW,kBAAkB;IACjC,6EAA6E;IAC7E,KAAK,EAAE,MAAM,SAAS,WAAW,EAAE,CAAC;IACpC,2EAA2E;IAC3E,WAAW,CAAC,EAAE,YAAY,GAAG,UAAU,GAAG,MAAM,CAAC;IACjD,6CAA6C;IAC7C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,+DAA+D;IAC/D,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,WAAW,EAAE,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI,CA4D3F;AAED,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,OAAO,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,WAAW,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IAC/D,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,WAAW,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,IAAI,CA6C5F"}
package/dist/focus.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Focus and keyboard behaviour a component needs and the platform leaves to it.
3
+ *
4
+ * `delegatesFocus` on the shadow root (see `HTMLReactiveElement.shadow`) covers a host that
5
+ * should hand focus to its inner input. This covers the other two: arrow keys moving through
6
+ * a group with one tab stop (a menu, a listbox, tabs), and keeping focus inside a panel until
7
+ * it closes (a dialog, a popover that behaves like one).
8
+ *
9
+ * Both follow the composed tree, so a focusable element inside another component's shadow
10
+ * root, or slotted in from the page, counts where the user sees it.
11
+ */
12
+ import { effect, onCleanup } from '@fluixi/reactive/signal';
13
+ const FOCUSABLE = 'a[href],area[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable]:not([contenteditable="false"])';
14
+ function isTabbable(el) {
15
+ if (!el.matches(FOCUSABLE) || el.tabIndex < 0)
16
+ return false;
17
+ if (el.disabled || el.closest('[inert]'))
18
+ return false;
19
+ if (el instanceof HTMLInputElement && el.type === 'hidden')
20
+ return false;
21
+ // Not rendered, so the browser skips it too. Absent in older engines: assume shown.
22
+ return el.checkVisibility?.({ visibilityProperty: true }) ?? true;
23
+ }
24
+ /** Every element the Tab key reaches under `root`, in order, through shadow roots and slots. */
25
+ export function tabbables(root) {
26
+ const out = [];
27
+ const visit = (node) => {
28
+ if (node instanceof HTMLSlotElement) {
29
+ const assigned = node.assignedElements({ flatten: true });
30
+ for (const child of assigned.length ? assigned : Array.from(node.children))
31
+ visit(child);
32
+ return;
33
+ }
34
+ const shadow = node.shadowRoot;
35
+ // A host that delegates focus is reached through what it delegates to, not itself.
36
+ if (node instanceof HTMLElement && isTabbable(node) && !shadow?.delegatesFocus)
37
+ out.push(node);
38
+ if (shadow)
39
+ for (const child of Array.from(shadow.children))
40
+ visit(child);
41
+ else
42
+ for (const child of Array.from(node.children))
43
+ visit(child);
44
+ };
45
+ for (const child of Array.from(root.children))
46
+ visit(child);
47
+ return out;
48
+ }
49
+ /** The element that really has focus, looking through shadow roots. */
50
+ export function deepActiveElement(doc = document) {
51
+ let active = doc.activeElement;
52
+ while (active?.shadowRoot?.activeElement)
53
+ active = active.shadowRoot.activeElement;
54
+ return active;
55
+ }
56
+ /**
57
+ * One tab stop for a group: the active item has `tabindex="0"`, the rest `-1`, and the arrow
58
+ * keys, Home and End move between them. Disabled items are skipped. Stops when the reactive
59
+ * scope it was called in ends; the returned function stops it earlier.
60
+ */
61
+ export function rovingFocus(container, options) {
62
+ const { orientation = 'vertical', loop = true } = options;
63
+ let active;
64
+ const enabled = () => options.items().filter((item) => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
65
+ const settle = (items) => {
66
+ if (!active || !items.includes(active))
67
+ active = enabled()[0];
68
+ for (const item of items)
69
+ item.tabIndex = item === active ? 0 : -1;
70
+ };
71
+ const move = (to) => {
72
+ const items = options.items();
73
+ if (to === active)
74
+ return;
75
+ active = to;
76
+ settle(items);
77
+ to.focus();
78
+ options.onMove?.(to, items.indexOf(to));
79
+ };
80
+ const keys = {
81
+ next: orientation === 'horizontal' ? ['ArrowRight'] : orientation === 'vertical' ? ['ArrowDown'] : ['ArrowRight', 'ArrowDown'],
82
+ previous: orientation === 'horizontal' ? ['ArrowLeft'] : orientation === 'vertical' ? ['ArrowUp'] : ['ArrowLeft', 'ArrowUp'],
83
+ };
84
+ const onKey = (event) => {
85
+ const list = enabled();
86
+ if (!list.length)
87
+ return;
88
+ const at = active ? list.indexOf(active) : -1;
89
+ let next;
90
+ if (keys.next.includes(event.key))
91
+ next = list[at + 1] ?? (loop ? list[0] : undefined);
92
+ else if (keys.previous.includes(event.key))
93
+ next = list[at - 1] ?? (loop ? list[list.length - 1] : undefined);
94
+ else if (event.key === 'Home')
95
+ next = list[0];
96
+ else if (event.key === 'End')
97
+ next = list[list.length - 1];
98
+ else
99
+ return;
100
+ event.preventDefault();
101
+ if (next)
102
+ move(next);
103
+ };
104
+ // A click or programmatic focus makes that item the tab stop, so Tab comes back to it.
105
+ const onFocus = (event) => {
106
+ const item = enabled().find((candidate) => event.composedPath().includes(candidate));
107
+ if (item && item !== active)
108
+ move(item);
109
+ };
110
+ container.addEventListener('keydown', onKey);
111
+ container.addEventListener('focusin', onFocus);
112
+ const stopTracking = effect(() => settle(options.items()), undefined, { name: 'roving focus' });
113
+ let stopped = false;
114
+ const stop = () => {
115
+ if (stopped)
116
+ return;
117
+ stopped = true;
118
+ container.removeEventListener('keydown', onKey);
119
+ container.removeEventListener('focusin', onFocus);
120
+ stopTracking();
121
+ };
122
+ onCleanup(stop);
123
+ return stop;
124
+ }
125
+ /**
126
+ * Keep keyboard focus inside `container`: Tab and Shift+Tab wrap at the ends, and focus that
127
+ * lands outside is brought back. `<dialog>` opened with `showModal()` does this natively; this
128
+ * is for a panel that is not a modal dialog. Released when the reactive scope it was called in
129
+ * ends, or by the returned function.
130
+ */
131
+ export function trapFocus(container, options = {}) {
132
+ const doc = container.ownerDocument;
133
+ const previous = deepActiveElement(doc);
134
+ const first = () => tabbables(container)[0];
135
+ const inside = (node) => node instanceof Node && (container.contains(node) || containsComposed(container, node));
136
+ const initial = typeof options.initial === 'function' ? options.initial() : options.initial;
137
+ (initial ?? first() ?? container).focus();
138
+ const onKey = (event) => {
139
+ if (event.key !== 'Tab')
140
+ return;
141
+ const list = tabbables(container);
142
+ if (!list.length) {
143
+ event.preventDefault();
144
+ return;
145
+ }
146
+ const current = deepActiveElement(doc);
147
+ const at = list.indexOf(current);
148
+ if (event.shiftKey && at <= 0) {
149
+ event.preventDefault();
150
+ list[list.length - 1].focus();
151
+ }
152
+ else if (!event.shiftKey && at === list.length - 1) {
153
+ event.preventDefault();
154
+ list[0].focus();
155
+ }
156
+ };
157
+ const onFocusIn = (event) => {
158
+ if (!inside(event.composedPath()[0] ?? event.target))
159
+ (first() ?? container).focus();
160
+ };
161
+ container.addEventListener('keydown', onKey);
162
+ doc.addEventListener('focusin', onFocusIn, true);
163
+ let released = false;
164
+ const release = () => {
165
+ if (released)
166
+ return;
167
+ released = true;
168
+ container.removeEventListener('keydown', onKey);
169
+ doc.removeEventListener('focusin', onFocusIn, true);
170
+ if (options.restore !== false && previous?.isConnected)
171
+ previous.focus();
172
+ };
173
+ onCleanup(release);
174
+ return release;
175
+ }
176
+ /** Whether `node` sits under `container` once shadow roots are looked through. */
177
+ function containsComposed(container, node) {
178
+ let at = node;
179
+ while (at) {
180
+ if (at === container)
181
+ return true;
182
+ at = at.parentNode ?? (at instanceof ShadowRoot ? at.host : null);
183
+ }
184
+ return false;
185
+ }
package/dist/focus.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // src/focus.ts
2
+ import { effect, onCleanup } from "@fluixi/reactive/signal";
3
+ var FOCUSABLE = 'a[href],area[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable]:not([contenteditable="false"])';
4
+ function isTabbable(el) {
5
+ if (!el.matches(FOCUSABLE) || el.tabIndex < 0) return false;
6
+ if (el.disabled || el.closest("[inert]")) return false;
7
+ if (el instanceof HTMLInputElement && el.type === "hidden") return false;
8
+ return el.checkVisibility?.({ visibilityProperty: true }) ?? true;
9
+ }
10
+ function tabbables(root) {
11
+ const out = [];
12
+ const visit = (node) => {
13
+ if (node instanceof HTMLSlotElement) {
14
+ const assigned = node.assignedElements({ flatten: true });
15
+ for (const child of assigned.length ? assigned : Array.from(node.children)) visit(child);
16
+ return;
17
+ }
18
+ const shadow = node.shadowRoot;
19
+ if (node instanceof HTMLElement && isTabbable(node) && !shadow?.delegatesFocus) out.push(node);
20
+ if (shadow) for (const child of Array.from(shadow.children)) visit(child);
21
+ else for (const child of Array.from(node.children)) visit(child);
22
+ };
23
+ for (const child of Array.from(root.children)) visit(child);
24
+ return out;
25
+ }
26
+ function deepActiveElement(doc = document) {
27
+ let active = doc.activeElement;
28
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
29
+ return active;
30
+ }
31
+ function rovingFocus(container, options) {
32
+ const { orientation = "vertical", loop = true } = options;
33
+ let active;
34
+ const enabled = () => options.items().filter((item) => !item.disabled && item.getAttribute("aria-disabled") !== "true");
35
+ const settle = (items) => {
36
+ if (!active || !items.includes(active)) active = enabled()[0];
37
+ for (const item of items) item.tabIndex = item === active ? 0 : -1;
38
+ };
39
+ const move = (to) => {
40
+ const items = options.items();
41
+ if (to === active) return;
42
+ active = to;
43
+ settle(items);
44
+ to.focus();
45
+ options.onMove?.(to, items.indexOf(to));
46
+ };
47
+ const keys = {
48
+ next: orientation === "horizontal" ? ["ArrowRight"] : orientation === "vertical" ? ["ArrowDown"] : ["ArrowRight", "ArrowDown"],
49
+ previous: orientation === "horizontal" ? ["ArrowLeft"] : orientation === "vertical" ? ["ArrowUp"] : ["ArrowLeft", "ArrowUp"]
50
+ };
51
+ const onKey = (event) => {
52
+ const list = enabled();
53
+ if (!list.length) return;
54
+ const at = active ? list.indexOf(active) : -1;
55
+ let next;
56
+ if (keys.next.includes(event.key)) next = list[at + 1] ?? (loop ? list[0] : void 0);
57
+ else if (keys.previous.includes(event.key)) next = list[at - 1] ?? (loop ? list[list.length - 1] : void 0);
58
+ else if (event.key === "Home") next = list[0];
59
+ else if (event.key === "End") next = list[list.length - 1];
60
+ else return;
61
+ event.preventDefault();
62
+ if (next) move(next);
63
+ };
64
+ const onFocus = (event) => {
65
+ const item = enabled().find((candidate) => event.composedPath().includes(candidate));
66
+ if (item && item !== active) move(item);
67
+ };
68
+ container.addEventListener("keydown", onKey);
69
+ container.addEventListener("focusin", onFocus);
70
+ const stopTracking = effect(() => settle(options.items()), void 0, { name: "roving focus" });
71
+ let stopped = false;
72
+ const stop = () => {
73
+ if (stopped) return;
74
+ stopped = true;
75
+ container.removeEventListener("keydown", onKey);
76
+ container.removeEventListener("focusin", onFocus);
77
+ stopTracking();
78
+ };
79
+ onCleanup(stop);
80
+ return stop;
81
+ }
82
+ function trapFocus(container, options = {}) {
83
+ const doc = container.ownerDocument;
84
+ const previous = deepActiveElement(doc);
85
+ const first = () => tabbables(container)[0];
86
+ const inside = (node) => node instanceof Node && (container.contains(node) || containsComposed(container, node));
87
+ const initial = typeof options.initial === "function" ? options.initial() : options.initial;
88
+ (initial ?? first() ?? container).focus();
89
+ const onKey = (event) => {
90
+ if (event.key !== "Tab") return;
91
+ const list = tabbables(container);
92
+ if (!list.length) {
93
+ event.preventDefault();
94
+ return;
95
+ }
96
+ const current = deepActiveElement(doc);
97
+ const at = list.indexOf(current);
98
+ if (event.shiftKey && at <= 0) {
99
+ event.preventDefault();
100
+ list[list.length - 1].focus();
101
+ } else if (!event.shiftKey && at === list.length - 1) {
102
+ event.preventDefault();
103
+ list[0].focus();
104
+ }
105
+ };
106
+ const onFocusIn = (event) => {
107
+ if (!inside(event.composedPath()[0] ?? event.target)) (first() ?? container).focus();
108
+ };
109
+ container.addEventListener("keydown", onKey);
110
+ doc.addEventListener("focusin", onFocusIn, true);
111
+ let released = false;
112
+ const release = () => {
113
+ if (released) return;
114
+ released = true;
115
+ container.removeEventListener("keydown", onKey);
116
+ doc.removeEventListener("focusin", onFocusIn, true);
117
+ if (options.restore !== false && previous?.isConnected) previous.focus();
118
+ };
119
+ onCleanup(release);
120
+ return release;
121
+ }
122
+ function containsComposed(container, node) {
123
+ let at = node;
124
+ while (at) {
125
+ if (at === container) return true;
126
+ at = at.parentNode ?? (at instanceof ShadowRoot ? at.host : null);
127
+ }
128
+ return false;
129
+ }
130
+ export {
131
+ deepActiveElement,
132
+ rovingFocus,
133
+ tabbables,
134
+ trapFocus
135
+ };