@jxsuite/ui 0.0.0 → 0.2.0

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 (105) hide show
  1. package/components/jx-accordion-item.json +281 -0
  2. package/components/jx-accordion.json +33 -0
  3. package/components/jx-action-button.json +268 -34
  4. package/components/jx-action-group.json +131 -0
  5. package/components/jx-button.json +141 -26
  6. package/components/jx-checkbox.json +222 -0
  7. package/components/jx-color-area.json +263 -0
  8. package/components/jx-color-field.json +565 -0
  9. package/components/jx-color-slider.json +216 -0
  10. package/components/jx-combobox.json +551 -0
  11. package/components/jx-dialog.json +416 -0
  12. package/components/jx-divider.json +52 -0
  13. package/components/jx-dot.json +38 -0
  14. package/components/jx-field.json +162 -0
  15. package/components/jx-kbd.json +24 -0
  16. package/components/jx-listbox.json +76 -0
  17. package/components/jx-menu-item.json +6 -13
  18. package/components/jx-menu.json +45 -6
  19. package/components/jx-number-field.json +441 -0
  20. package/components/jx-option.json +350 -0
  21. package/components/jx-popover.json +227 -0
  22. package/components/jx-select.json +637 -0
  23. package/components/jx-spinner.json +129 -0
  24. package/components/jx-split.json +221 -0
  25. package/components/jx-swatch-group.json +107 -0
  26. package/components/jx-swatch.json +262 -0
  27. package/components/jx-switch.json +331 -0
  28. package/components/jx-tab-panel.json +58 -0
  29. package/components/jx-tab.json +296 -0
  30. package/components/jx-tabs.json +124 -0
  31. package/components/jx-textfield.json +645 -0
  32. package/components/jx-toast-host.json +121 -0
  33. package/components/jx-toast.json +255 -0
  34. package/components/jx-toolbar.json +81 -0
  35. package/components/jx-tooltip.json +207 -0
  36. package/components/jx-tree-item.json +325 -0
  37. package/components/jx-tree.json +202 -0
  38. package/icons/list.json +4 -0
  39. package/icons/manifest.json +12 -0
  40. package/package.json +27 -5
  41. package/project.json +164 -153
  42. package/src/behaviors/action-group.ts +255 -0
  43. package/src/behaviors/color-area.ts +303 -0
  44. package/src/behaviors/color-field.ts +465 -0
  45. package/src/behaviors/color-slider.ts +154 -0
  46. package/src/behaviors/combobox.ts +474 -0
  47. package/src/behaviors/dialog.ts +137 -0
  48. package/src/behaviors/listbox.ts +118 -0
  49. package/src/behaviors/menu.ts +80 -48
  50. package/src/behaviors/number-field.ts +161 -0
  51. package/src/behaviors/popover.ts +399 -0
  52. package/src/behaviors/select.ts +154 -0
  53. package/src/behaviors/split.ts +478 -0
  54. package/src/behaviors/swatch-group.ts +297 -0
  55. package/src/behaviors/tabs.ts +319 -0
  56. package/src/behaviors/textfield.ts +164 -0
  57. package/src/behaviors/toast-host.ts +241 -0
  58. package/src/behaviors/toast.ts +261 -0
  59. package/src/behaviors/toolbar.ts +413 -0
  60. package/src/behaviors/tooltip.ts +719 -0
  61. package/src/behaviors/tree.ts +830 -0
  62. package/src/color.ts +433 -0
  63. package/src/documents.ts +91 -1
  64. package/src/index.ts +41 -1
  65. package/src/loaders.ts +47 -0
  66. package/src/testing/anchor-support.ts +28 -0
  67. package/src/testing/popover-shim.ts +39 -6
  68. package/src/theme.ts +30 -6
  69. package/stylebook/jx-accordion-item.json +195 -0
  70. package/stylebook/jx-accordion.json +220 -0
  71. package/stylebook/jx-action-button.json +191 -23
  72. package/stylebook/jx-action-group.json +335 -0
  73. package/stylebook/jx-button.json +129 -26
  74. package/stylebook/jx-checkbox.json +245 -0
  75. package/stylebook/jx-color-area.json +272 -0
  76. package/stylebook/jx-color-field.json +261 -0
  77. package/stylebook/jx-color-slider.json +220 -0
  78. package/stylebook/jx-combobox.json +332 -0
  79. package/stylebook/jx-dialog.json +186 -0
  80. package/stylebook/jx-divider.json +125 -0
  81. package/stylebook/jx-dot.json +92 -0
  82. package/stylebook/jx-field.json +313 -0
  83. package/stylebook/jx-icon.json +4 -0
  84. package/stylebook/jx-kbd.json +90 -0
  85. package/stylebook/jx-listbox.json +337 -0
  86. package/stylebook/jx-menu.json +0 -1
  87. package/stylebook/jx-number-field.json +203 -0
  88. package/stylebook/jx-option.json +285 -0
  89. package/stylebook/jx-popover.json +123 -0
  90. package/stylebook/jx-select.json +407 -0
  91. package/stylebook/jx-spinner.json +111 -0
  92. package/stylebook/jx-split.json +170 -0
  93. package/stylebook/jx-swatch-group.json +175 -0
  94. package/stylebook/jx-swatch.json +249 -0
  95. package/stylebook/jx-switch.json +240 -0
  96. package/stylebook/jx-tab-panel.json +147 -0
  97. package/stylebook/jx-tab.json +201 -0
  98. package/stylebook/jx-tabs.json +278 -0
  99. package/stylebook/jx-textfield.json +309 -0
  100. package/stylebook/jx-toast-host.json +157 -0
  101. package/stylebook/jx-toast.json +266 -0
  102. package/stylebook/jx-toolbar.json +435 -0
  103. package/stylebook/jx-tooltip.json +162 -0
  104. package/stylebook/jx-tree-item.json +258 -0
  105. package/stylebook/jx-tree.json +429 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The field family's behaviour sidecar: focus and selection on `jx-textfield`'s inner native
3
+ * control, the clear button's own three steps, and the id stems neither `jx-textfield` nor
4
+ * `jx-field` may let two instances share. Each of them is something a document body cannot express
5
+ * — it can neither move focus, nor dispatch from an element other than the one whose handler is
6
+ * running, nor count.
7
+ *
8
+ * `jx-field` reaches into this module rather than shipping one of its own because the two elements
9
+ * are one contract: the row names the control, the control names its own sentences, and a single
10
+ * module is what keeps their two id stems provably distinct.
11
+ *
12
+ * @docs extending/ui-kit
13
+ */
14
+
15
+ /** The element's own native control. */
16
+ function controlOf(host: HTMLElement): HTMLInputElement | HTMLTextAreaElement | null {
17
+ return host.querySelector<HTMLInputElement | HTMLTextAreaElement>('[part="input"]');
18
+ }
19
+
20
+ /**
21
+ * Focus the field and select its value: all of it, the stem before the last dot (a filename with
22
+ * its extension left alone), or nothing.
23
+ *
24
+ * @param host The `jx-textfield` element.
25
+ * @param mode What to select once focused.
26
+ */
27
+ export function selectValue(host: HTMLElement, mode: "all" | "stem" | "none" = "all"): void {
28
+ const control = controlOf(host);
29
+ if (!control) {
30
+ return;
31
+ }
32
+ control.focus();
33
+ if (mode === "none") {
34
+ return;
35
+ }
36
+ if (mode === "stem") {
37
+ const dot = control.value.lastIndexOf(".");
38
+ control.setSelectionRange(0, dot > 0 ? dot : control.value.length);
39
+ return;
40
+ }
41
+ control.select();
42
+ }
43
+
44
+ /** Focus the field without touching its selection. */
45
+ export function focusField(host: HTMLElement): void {
46
+ controlOf(host)?.focus();
47
+ }
48
+
49
+ /** The `jx-textfield` an event inside one came from. */
50
+ function hostOf(event: Event): HTMLElement | null {
51
+ const target = event.currentTarget;
52
+ return target instanceof Element ? target.closest<HTMLElement>("jx-textfield") : null;
53
+ }
54
+
55
+ /**
56
+ * Empty a clearable field: write "" into its value, put focus back on the control the reader was
57
+ * in, and say so with `input` and then `change`.
58
+ *
59
+ * Both events come from the ELEMENT rather than from the clear button, because every host reads
60
+ * `e.target.value` and the button's own `value` is not the field's. The platform fires nothing for
61
+ * a value the element wrote itself, so the pair is dispatched here.
62
+ *
63
+ * @param state The element's reactive scope, whose `value` this empties.
64
+ * @param event The click on the clear button.
65
+ */
66
+ export function clearField(state: Record<string, unknown>, event: Event): void {
67
+ const host = hostOf(event);
68
+ if (!host) {
69
+ return;
70
+ }
71
+ state["value"] = "";
72
+ focusField(host);
73
+ host.dispatchEvent(new Event("input", { bubbles: true }));
74
+ host.dispatchEvent(new Event("change", { bubbles: true }));
75
+ }
76
+
77
+ let minted = 0;
78
+
79
+ /**
80
+ * Give the instance an id stem no other field in the document shares, so its error and help
81
+ * sentences can carry ids the control names in `aria-describedby`.
82
+ *
83
+ * A document cannot mint one: the closed operator set has no counter and no identity, and two
84
+ * fields on one surface must not collide. This is the sidecar case specs/ui.md §3.2 sanctions.
85
+ *
86
+ * @param state The element's reactive scope, whose `uid` this writes.
87
+ */
88
+ export function mintFieldId(state: Record<string, unknown>): void {
89
+ minted += 1;
90
+ state["uid"] = `jx-tf-${minted}`;
91
+ }
92
+
93
+ let namedRows = 0;
94
+
95
+ /** Whether an element's DEFINITION says it can be named — that it observes `labelledby`. */
96
+ function observesNaming(el: Element): boolean {
97
+ const def = customElements.get(el.localName) as
98
+ | { observedAttributes?: readonly string[] }
99
+ | undefined;
100
+ return def?.observedAttributes?.includes("labelledby") === true;
101
+ }
102
+
103
+ /**
104
+ * Give a `jx-field` an id stem, and hand that stem to the control slotted into it.
105
+ *
106
+ * This is the whole reason the row is an element rather than four classes. The four classes could
107
+ * not name anything, so every call site hand-wrote an `id` on the label, a second one on the help
108
+ * sentence, and `labelledby` and `describedby` on the control repeating both — four strings a
109
+ * consumer had to keep in step, in a document where nothing checks that they are.
110
+ *
111
+ * The stem is written into `state.uid`, which the row's own `id` bindings read. The control is
112
+ * named through PROPERTIES rather than through `aria-labelledby` written onto it: every kit control
113
+ * observes `labelledby` and `describedby` and folds them into whatever else it has to say about
114
+ * itself (`jx-textfield` puts its own error and help sentences in front of the row's), so writing
115
+ * the ARIA attribute directly would silently overwrite that. A property write also needs no
116
+ * mutation observer and no upgrade timing: set on an element the runtime has not initialised yet,
117
+ * it is an own property, and `connectedCallback` reads own properties into state before it defines
118
+ * its accessors.
119
+ *
120
+ * A control is named when its DEFINITION says it can be — `observedAttributes` carries
121
+ * `labelledby`. A hyphen in the tag name says only that the element is custom, and the row would
122
+ * then write a dead expando onto whatever a consumer slotted first, an inner `jx-field` included.
123
+ * The definition is readable before the instance upgrades, so a control the runtime has not
124
+ * initialised yet is still named; an element whose definition is not registered when the row mounts
125
+ * is left alone, exactly as a native control is.
126
+ *
127
+ * A NATIVE control — a bare `<input>`, `<select>` or `<textarea>` — has no definition to ask, so
128
+ * the same rule leaves it alone: a write would be a dead expando rather than a name. Give one its
129
+ * own `aria-labelledby` pointing at the row's label, or wrap it in a kit element.
130
+ *
131
+ * `describedby` is written only when the help line HAS something in it — a sentence from
132
+ * `description`, or an element slotted into `help`. A row with nothing to say would otherwise point
133
+ * its control at an empty paragraph, which the class form never did and which `jx-textfield` would
134
+ * carry as a dead trailing id in its merged list. The presence of a slotted help element is enough,
135
+ * so a sentence whose text arrives later should be SLOTTED rather than passed as a `description`
136
+ * that starts empty: the row reads the help line once, here.
137
+ *
138
+ * Both are read once, at mount, over the control slotted then. A control appended to the row
139
+ * afterwards is neither placed in the control part nor named — see the `onMount` prop's own note.
140
+ *
141
+ * @param state The row's reactive scope, whose `uid` this writes.
142
+ * @param host The `jx-field` element, whose slotted control this names.
143
+ */
144
+ export function nameFieldControl(state: Record<string, unknown>, host: HTMLElement): void {
145
+ namedRows += 1;
146
+ const uid = `jx-field-${namedRows}`;
147
+ state["uid"] = uid;
148
+
149
+ /* The row's OWN parts, found among its children rather than by a descendant query. Both spellings
150
+ return the same node for a rendered row — the row's own parts precede everything slotted, so
151
+ nothing in the suite can tell them apart — but a `[part]` lookup that may descend is one
152
+ refactor away from reaching into a nested row's parts. */
153
+ const own = (name: string) => [...host.children].find((el) => el.getAttribute("part") === name);
154
+ const control = own("control")?.firstElementChild;
155
+ if (!control || !observesNaming(control)) {
156
+ return;
157
+ }
158
+ const target = control as unknown as Record<string, unknown>;
159
+ target["labelledby"] ||= `${uid}-label`;
160
+ const help = own("help");
161
+ if (help && (help.firstElementChild !== null || help.textContent !== "")) {
162
+ target["describedby"] ||= `${uid}-help`;
163
+ }
164
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * `jx-toast-host`'s behaviour sidecar: the stack's clock policy, and the one gesture that puts the
3
+ * keyboard into a toast.
4
+ *
5
+ * **A toast must not steal focus, and its recovery control must still be reachable before it
6
+ * expires.** Those two sentences are in tension, and this module is where the tension is resolved
7
+ * rather than picked. Three things do it together, and none of them is enough alone.
8
+ *
9
+ * 1. **The stack is read where it stands.** Nothing here moves focus when a toast arrives; the element
10
+ * appears beside the reader's work and the reader's caret does not move.
11
+ * 2. **Attention suspends every clock in the stack, not only the one under it.** Pointer or focus
12
+ * anywhere in the host pauses every toast in it, so an older toast cannot vanish out from under
13
+ * a reader who is answering a newer one — which would move the very control they were reaching
14
+ * for. `toast.ts` holds the clocks; this module tells them when.
15
+ * 3. **A key press is the door in.** `hotkey` (`F8` by default, and the same key Radix and Sonner's
16
+ * stacks settled on) moves focus to the first control in the stack and REMEMBERS where it came
17
+ * from; Escape puts it back, and so does a toast closing under the reader's hand. That is the
18
+ * reader asking for the toast rather than the toast taking the reader, which is the distinction
19
+ * "must not steal focus" is actually about. `hotkey=""` turns it off for a host whose
20
+ * application already owns a key for it — Studio's command registry, say.
21
+ *
22
+ * The reader reaches the stack in one press, and the moment they do, nothing in it is on a clock.
23
+ *
24
+ * **The one document listener is registered here and released at unmount**, which is the opposite
25
+ * of `select.ts`'s decision and for the opposite reason: that observer watches the element's own
26
+ * subtree and dies with it, while this one is on the document and would outlive a removed host — a
27
+ * dead stack answering a key press, holding the element it can no longer show. A re-parented host
28
+ * loses its hotkey (the runtime initialises an element once, so nothing re-binds it); a host that
29
+ * leaks one steals a key from the whole page, and of the two that is the worse.
30
+ *
31
+ * @docs extending/ui-kit
32
+ */
33
+
34
+ import { TOAST_PAUSE, TOAST_RESUME, staysInside } from "./toast.ts";
35
+
36
+ /** The tag this sidecar belongs to. */
37
+ const HOST = "jx-toast-host";
38
+
39
+ /** What a reader can put the keyboard on, before the stack's own state is consulted. */
40
+ const FOCUSABLE =
41
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
42
+
43
+ /** The reactive scope a `jx-toast-host` document hands its handlers. */
44
+ export interface ToastHostState {
45
+ /** The key that moves focus into the stack. Empty turns the gesture off. */
46
+ hotkey: string;
47
+ /** `polite`, `assertive` or `off`. */
48
+ live: string;
49
+ [key: string]: unknown;
50
+ }
51
+
52
+ /** Where focus was when the reader entered the stack, so it can be given back. */
53
+ const origins = new WeakMap<HTMLElement, HTMLElement>();
54
+
55
+ /** Each mounted host's document listener, so unmounting can take it off again. */
56
+ const listeners = new WeakMap<object, () => void>();
57
+
58
+ /** The `jx-toast-host` a listener bound at the host's root belongs to. */
59
+ function hostOf(event: Event): HTMLElement | null {
60
+ const target = event.currentTarget ?? event.target;
61
+ return target instanceof Element ? target.closest<HTMLElement>(HOST) : null;
62
+ }
63
+
64
+ /**
65
+ * The controls a reader can reach in this stack, in DOM order.
66
+ *
67
+ * DOM order and nothing else: the stack is drawn in the order it is written, never reversed, so the
68
+ * order a reader hears, the order they tab through and the order they see are one order. A closed
69
+ * toast contributes nothing, because a control inside a `display: none` toast is a stop on the way
70
+ * to nowhere.
71
+ *
72
+ * @param host The `jx-toast-host` element.
73
+ * @returns The focusable controls of every open toast in it.
74
+ */
75
+ export function focusablesIn(host: HTMLElement): HTMLElement[] {
76
+ return [...host.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
77
+ (control) => control.closest<HTMLElement>("jx-toast")?.dataset["open"] !== undefined,
78
+ );
79
+ }
80
+
81
+ /** Whether the keyboard is currently somewhere inside the stack. */
82
+ function holdsFocus(host: HTMLElement): boolean {
83
+ const active = host.ownerDocument.activeElement;
84
+ return active instanceof Node && host.contains(active);
85
+ }
86
+
87
+ /**
88
+ * Put the keyboard on the first control in the stack, remembering where it came from.
89
+ *
90
+ * The host door as well as the hotkey's: an application with its own command for "go to
91
+ * notifications" calls this and gets the same round trip, Escape included.
92
+ *
93
+ * @param host The `jx-toast-host` element.
94
+ * @returns True when there was something to focus.
95
+ */
96
+ export function focusStack(host: HTMLElement): boolean {
97
+ const [first] = focusablesIn(host);
98
+ if (!first) {
99
+ return false;
100
+ }
101
+ const active = host.ownerDocument.activeElement;
102
+ if (active instanceof HTMLElement && !host.contains(active)) {
103
+ origins.set(host, active);
104
+ }
105
+ first.focus();
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Give the keyboard back to whatever the reader left to come here.
111
+ *
112
+ * When there is nothing to give it back to — the stack was never entered by the hotkey, or the
113
+ * element that had focus has since left the document — the caret is only let go of, never moved
114
+ * somewhere of this module's choosing. Focus lands on the document, which is where the platform
115
+ * puts it when an element holding it is hidden, and the reader's next Tab starts from the top
116
+ * rather than from a toast that is no longer there.
117
+ *
118
+ * @param host The `jx-toast-host` element.
119
+ * @returns True when focus was returned to a remembered element.
120
+ */
121
+ export function returnFocus(host: HTMLElement): boolean {
122
+ const origin = origins.get(host);
123
+ origins.delete(host);
124
+ if (origin?.isConnected === true) {
125
+ origin.focus();
126
+ return true;
127
+ }
128
+ const active = host.ownerDocument.activeElement;
129
+ if (active instanceof HTMLElement && host.contains(active)) {
130
+ active.blur();
131
+ }
132
+ return false;
133
+ }
134
+
135
+ /** Say the same thing to every toast in the stack. */
136
+ function tell(host: HTMLElement, name: string): void {
137
+ for (const toast of host.querySelectorAll("jx-toast")) {
138
+ toast.dispatchEvent(new Event(name));
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The reader's attention arrived: suspend every clock in the stack.
144
+ *
145
+ * `pointerover` rather than `pointerenter`, because the host is `pointer-events: none` — the toasts
146
+ * are what the pointer can land on, and only a bubbling event carries the news up to the stack that
147
+ * owns them.
148
+ *
149
+ * @param state The host's reactive state.
150
+ * @param event The `pointerover` or `focusin`.
151
+ */
152
+ export function onStackHold(_state: ToastHostState, event: Event): void {
153
+ const host = hostOf(event);
154
+ if (host) {
155
+ tell(host, TOAST_PAUSE);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * The reader's attention left: let the clocks run again.
161
+ *
162
+ * A move between two toasts of one stack is not a departure, which is what {@link staysInside}
163
+ * settles — without it, crossing the stack with the pointer would restart every clock in it once
164
+ * per toast.
165
+ *
166
+ * @param state The host's reactive state.
167
+ * @param event The `pointerout` or `focusout`.
168
+ */
169
+ export function onStackRelease(_state: ToastHostState, event: Event): void {
170
+ const host = hostOf(event);
171
+ if (host && !staysInside(event)) {
172
+ tell(host, TOAST_RESUME);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * A toast closed. If it took the reader's focus with it, hand focus back.
178
+ *
179
+ * Only when the caret was inside the toast that closed, or has already fallen to the document:
180
+ * closing an older toast while the reader is answering a newer one must not move them at all.
181
+ *
182
+ * @param state The host's reactive state.
183
+ * @param event The `close` bubbling up from a toast.
184
+ */
185
+ export function onStackClose(_state: ToastHostState, event: Event): void {
186
+ const host = hostOf(event);
187
+ if (!host) {
188
+ return;
189
+ }
190
+ const active = host.ownerDocument.activeElement;
191
+ const closed = event.target;
192
+ const lost = active === null || active === host.ownerDocument.body;
193
+ const inClosed = closed instanceof Node && active instanceof Node && closed.contains(active);
194
+ if (lost || inClosed) {
195
+ returnFocus(host);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Register the stack's key: the one that brings the keyboard here, and the one that takes it back.
201
+ *
202
+ * It is on the document rather than on the host, and it has to be: a key that only worked once
203
+ * focus was already in the stack would answer the question it exists to answer with itself. A press
204
+ * carrying a modifier is left alone, so a host may claim `F8` without taking `Ctrl+F8` from the
205
+ * browser or the reader's own assistive software.
206
+ *
207
+ * @param state The host's reactive state.
208
+ * @param host The element itself.
209
+ */
210
+ export function onHostMount(state: ToastHostState, host: HTMLElement): void {
211
+ const root = host.getRootNode() as Document | ShadowRoot;
212
+ const onKeydown = (event: Event): void => {
213
+ const press = event as KeyboardEvent;
214
+ if (press.altKey || press.ctrlKey || press.metaKey) {
215
+ return;
216
+ }
217
+ const wanted = typeof state.hotkey === "string" ? state.hotkey : "";
218
+ if (wanted !== "" && press.key === wanted) {
219
+ if (focusStack(host)) {
220
+ press.preventDefault();
221
+ }
222
+ return;
223
+ }
224
+ if (press.key === "Escape" && holdsFocus(host)) {
225
+ returnFocus(host);
226
+ press.preventDefault();
227
+ }
228
+ };
229
+ root.addEventListener("keydown", onKeydown);
230
+ listeners.set(state, () => root.removeEventListener("keydown", onKeydown));
231
+ }
232
+
233
+ /**
234
+ * Take the key back when the stack leaves the document.
235
+ *
236
+ * @param state The host's reactive state.
237
+ */
238
+ export function onHostUnmount(state: ToastHostState): void {
239
+ listeners.get(state)?.();
240
+ listeners.delete(state);
241
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `jx-toast`'s behaviour sidecar: the timer that retires a toast, the holds that suspend it, and
3
+ * the one function that closes a toast and says why.
4
+ *
5
+ * A toast is the only element in the kit whose lifetime is a CLOCK rather than a state, and every
6
+ * decision here follows from that.
7
+ *
8
+ * **The timer is suspended, never merely restarted.** While the pointer rests on a toast, while
9
+ * focus is inside it, and while the stack it belongs to is being read, its clock does not run — and
10
+ * it resumes with the time that was LEFT rather than from the top. That is WCAG 2.2.1's "pause" as
11
+ * an author can honour it without a dialog, and it is what makes the element's other promise true:
12
+ * a recovery control the reader has reached cannot expire under their hand, because reaching it is
13
+ * itself what stopped the clock. A hold is a NAMED reason — `pointer`, `focus`, `stack` — held in a
14
+ * set rather than a boolean, because the three overlap: a pointer leaving a toast the reader has
15
+ * tabbed into must not restart a clock the focus is still holding.
16
+ *
17
+ * **Nothing here calls `focus()`.** A toast is a status message: it appears beside the reader's
18
+ * work and never takes the keyboard from it. Moving focus INTO a stack is the reader's own gesture
19
+ * and lives in `toast-host.ts`, behind a key they press.
20
+ *
21
+ * **A toast the host closes is silent.** `close` is dispatched only when the element itself decided
22
+ * — the clock ran out, the dismiss button was pressed, the recovery control was used — so a host
23
+ * writing `open = false` hears nothing back about a thing it just did.
24
+ *
25
+ * @docs extending/ui-kit
26
+ */
27
+
28
+ /** The tag this sidecar belongs to. */
29
+ const HOST = "jx-toast";
30
+
31
+ /** Why a toast closed, as the `close` event's `detail.reason`. */
32
+ export type ToastCloseReason = "timeout" | "dismissed" | "action";
33
+
34
+ /** The event a `jx-toast-host` dispatches at each toast to suspend its clock. */
35
+ export const TOAST_PAUSE = "jx-toast-pause";
36
+
37
+ /** The event a `jx-toast-host` dispatches at each toast to let its clock run again. */
38
+ export const TOAST_RESUME = "jx-toast-resume";
39
+
40
+ /** The reactive scope a `jx-toast` document hands its handlers. */
41
+ export interface ToastState {
42
+ /** Whether the toast is showing. The host writes it; the clock reads it. */
43
+ open: boolean;
44
+ /** How long the toast rests before retiring itself, in ms. `0` never retires. */
45
+ timeout: number;
46
+ /** `info`, `positive`, `negative` or `warning`. */
47
+ variant: string;
48
+ [key: string]: unknown;
49
+ }
50
+
51
+ /** One toast's clock: what is left of it, when this run started, and what is holding it. */
52
+ interface Clock {
53
+ timer: ReturnType<typeof setTimeout> | null;
54
+ /** Milliseconds still to run. Recomputed every time the clock is suspended. */
55
+ remaining: number;
56
+ /** When the current run began, for the subtraction that suspending does. */
57
+ startedAt: number;
58
+ /** The reasons the clock is suspended. Empty means it may run. */
59
+ holds: Set<string>;
60
+ }
61
+
62
+ /** Which hold an event asks for. Three channels, because they overlap and must not cancel. */
63
+ const HOLD_REASON: Readonly<Record<string, string>> = {
64
+ focusin: "focus",
65
+ focusout: "focus",
66
+ [TOAST_PAUSE]: "stack",
67
+ [TOAST_RESUME]: "stack",
68
+ pointerenter: "pointer",
69
+ pointerleave: "pointer",
70
+ pointerout: "pointer",
71
+ pointerover: "pointer",
72
+ };
73
+
74
+ /** Each scope's clock. */
75
+ const clocks = new WeakMap<object, Clock>();
76
+
77
+ /** The element each scope belongs to, learned at mount. */
78
+ const hosts = new WeakMap<object, HTMLElement>();
79
+
80
+ /** This scope's clock, minted on first sight. */
81
+ function clockOf(state: object): Clock {
82
+ const found = clocks.get(state);
83
+ if (found) {
84
+ return found;
85
+ }
86
+ const fresh: Clock = { holds: new Set(), remaining: 0, startedAt: 0, timer: null };
87
+ clocks.set(state, fresh);
88
+ return fresh;
89
+ }
90
+
91
+ /** The `jx-toast` a listener bound anywhere inside the element belongs to. */
92
+ function ownerOf(event: Event): HTMLElement | null {
93
+ const target = event.currentTarget ?? event.target;
94
+ return target instanceof Element ? target.closest<HTMLElement>(HOST) : null;
95
+ }
96
+
97
+ /**
98
+ * Whether a leaving event is really leaving.
99
+ *
100
+ * `focusout` fires when focus moves from a toast's recovery control to its dismiss button, and
101
+ * `pointerout` fires crossing between two toasts in one stack — both of them a departure only if
102
+ * you do not ask where the pointer or the focus WENT. Without this, a reader tabbing along a
103
+ * toast's own controls restarts the clock they are standing on at every step.
104
+ *
105
+ * @param event A `focusout` or `pointerout`, whose `relatedTarget` says where it went.
106
+ * @returns True when the destination is still inside the element the listener is bound to.
107
+ */
108
+ export function staysInside(event: Event): boolean {
109
+ const to = (event as { relatedTarget?: unknown }).relatedTarget;
110
+ const from = event.currentTarget;
111
+ return to instanceof Node && from instanceof Node && from.contains(to);
112
+ }
113
+
114
+ /** Stop the clock's timer without touching what is left of it. */
115
+ function park(clock: Clock): void {
116
+ if (clock.timer !== null) {
117
+ clearTimeout(clock.timer);
118
+ clock.timer = null;
119
+ }
120
+ }
121
+
122
+ /** Run the clock for whatever is left, unless it is held, closed or sticky. */
123
+ function schedule(state: ToastState): void {
124
+ const clock = clockOf(state);
125
+ park(clock);
126
+ if (state.open !== true || clock.holds.size > 0 || clock.remaining <= 0) {
127
+ return;
128
+ }
129
+ clock.startedAt = Date.now();
130
+ clock.timer = setTimeout(() => {
131
+ clock.timer = null;
132
+ const host = hosts.get(state);
133
+ if (host) {
134
+ closeToast(host, "timeout");
135
+ }
136
+ }, clock.remaining);
137
+ }
138
+
139
+ /**
140
+ * Close a toast and say why.
141
+ *
142
+ * The host door as well as the element's own: a host that retires a toast from its own code closes
143
+ * it through here, and the `close` its listeners hear is the same event the dismiss button raises.
144
+ * Closing a closed toast says nothing, so a dismiss that races the clock is one event rather than
145
+ * two.
146
+ *
147
+ * @param host The `jx-toast` element.
148
+ * @param reason Why it closed.
149
+ */
150
+ export function closeToast(host: HTMLElement, reason: ToastCloseReason): void {
151
+ const el = host as HTMLElement & { open?: boolean };
152
+ if (el.open !== true) {
153
+ return;
154
+ }
155
+ el.open = false;
156
+ host.dispatchEvent(new CustomEvent("close", { bubbles: true, detail: { reason } }));
157
+ }
158
+
159
+ /**
160
+ * Learn the element, start the clock, and follow `open` for the rest of the toast's life.
161
+ *
162
+ * `data-open` is what moves when anything writes `open` — the attribute, the property, or the
163
+ * element's own retirement — so one observer on it is the whole subscription. The same idiom
164
+ * `jx-color-field` uses for `data-value`, and for the same reason: a sidecar cannot watch reactive
165
+ * state, but it can watch the attribute that state writes.
166
+ *
167
+ * @param state The toast's reactive state.
168
+ * @param host The element itself.
169
+ */
170
+ export function onToastMount(state: ToastState, host: HTMLElement): void {
171
+ hosts.set(state, host);
172
+ const clock = clockOf(state);
173
+ clock.remaining = Number(state.timeout) || 0;
174
+ schedule(state);
175
+ const observer = new MutationObserver(() => {
176
+ if (state.open === true) {
177
+ clock.remaining = Number(state.timeout) || 0;
178
+ schedule(state);
179
+ return;
180
+ }
181
+ park(clock);
182
+ });
183
+ observer.observe(host, { attributeFilter: ["data-open"], attributes: true });
184
+ }
185
+
186
+ /**
187
+ * Stop the clock when the toast leaves the document.
188
+ *
189
+ * The element initialises ONCE — `connectedCallback` returns early on an element it has already
190
+ * built — so a re-parented toast comes back with its observer intact and its clock stopped, which
191
+ * is to say sticky. That is the safe direction and it is chosen rather than tolerated: a sticky
192
+ * toast still carries its dismiss button, whereas a timer that survived removal would retire a
193
+ * toast the host had already taken away and put back, dispatching `close` for a toast the reader
194
+ * never saw expire.
195
+ *
196
+ * @param state The toast's reactive state.
197
+ */
198
+ export function onToastUnmount(state: ToastState): void {
199
+ park(clockOf(state));
200
+ }
201
+
202
+ /**
203
+ * Suspend the clock: the pointer arrived, focus arrived, or the stack asked.
204
+ *
205
+ * @param state The toast's reactive state.
206
+ * @param event The `pointerenter`, `focusin` or `jx-toast-pause` that asked.
207
+ */
208
+ export function onToastHold(state: ToastState, event: Event): void {
209
+ const clock = clockOf(state);
210
+ const reason = HOLD_REASON[event.type] ?? event.type;
211
+ if (clock.timer !== null) {
212
+ clock.remaining = Math.max(1, clock.remaining - (Date.now() - clock.startedAt));
213
+ }
214
+ clock.holds.add(reason);
215
+ park(clock);
216
+ }
217
+
218
+ /**
219
+ * Let the clock run again, if nothing else is holding it.
220
+ *
221
+ * @param state The toast's reactive state.
222
+ * @param event The `pointerleave`, `focusout` or `jx-toast-resume` that released it.
223
+ */
224
+ export function onToastRelease(state: ToastState, event: Event): void {
225
+ if (staysInside(event)) {
226
+ return;
227
+ }
228
+ clockOf(state).holds.delete(HOLD_REASON[event.type] ?? event.type);
229
+ schedule(state);
230
+ }
231
+
232
+ /**
233
+ * The dismiss button.
234
+ *
235
+ * @param state The toast's reactive state.
236
+ * @param event The button's click.
237
+ */
238
+ export function onToastDismiss(_state: ToastState, event: Event): void {
239
+ const host = ownerOf(event);
240
+ if (host) {
241
+ closeToast(host, "dismissed");
242
+ }
243
+ }
244
+
245
+ /**
246
+ * A click on the recovery control the consumer slotted in.
247
+ *
248
+ * The click is NOT stopped: the control is the event's target, so its own handler has already run,
249
+ * and a host listening above the toast is entitled to hear it. What the element adds is the
250
+ * retirement — an answer that has been given is not still being asked, and a toast that stayed
251
+ * after its one recovery control was used would be a second thing to dismiss.
252
+ *
253
+ * @param state The toast's reactive state.
254
+ * @param event The click, from inside the `action` slot.
255
+ */
256
+ export function onToastAction(_state: ToastState, event: Event): void {
257
+ const host = ownerOf(event);
258
+ if (host) {
259
+ closeToast(host, "action");
260
+ }
261
+ }