@jxsuite/ui 0.0.0 → 0.1.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.
- package/components/jx-accordion-item.json +281 -0
- package/components/jx-accordion.json +33 -0
- package/components/jx-action-button.json +261 -33
- package/components/jx-action-group.json +123 -0
- package/components/jx-button.json +133 -25
- package/components/jx-checkbox.json +215 -0
- package/components/jx-color-area.json +263 -0
- package/components/jx-color-field.json +565 -0
- package/components/jx-color-slider.json +216 -0
- package/components/jx-combobox.json +544 -0
- package/components/jx-dialog.json +416 -0
- package/components/jx-divider.json +52 -0
- package/components/jx-dot.json +38 -0
- package/components/jx-field.json +162 -0
- package/components/jx-kbd.json +24 -0
- package/components/jx-listbox.json +76 -0
- package/components/jx-menu-item.json +5 -12
- package/components/jx-menu.json +45 -6
- package/components/jx-number-field.json +441 -0
- package/components/jx-option.json +350 -0
- package/components/jx-popover.json +227 -0
- package/components/jx-select.json +637 -0
- package/components/jx-spinner.json +129 -0
- package/components/jx-split.json +221 -0
- package/components/jx-swatch-group.json +107 -0
- package/components/jx-swatch.json +262 -0
- package/components/jx-switch.json +253 -0
- package/components/jx-tab-panel.json +58 -0
- package/components/jx-tab.json +296 -0
- package/components/jx-tabs.json +124 -0
- package/components/jx-textfield.json +639 -0
- package/components/jx-toast-host.json +121 -0
- package/components/jx-toast.json +255 -0
- package/components/jx-toolbar.json +81 -0
- package/components/jx-tooltip.json +207 -0
- package/components/jx-tree-item.json +325 -0
- package/components/jx-tree.json +202 -0
- package/icons/list.json +4 -0
- package/icons/manifest.json +12 -0
- package/package.json +27 -5
- package/project.json +164 -153
- package/src/behaviors/action-group.ts +255 -0
- package/src/behaviors/color-area.ts +303 -0
- package/src/behaviors/color-field.ts +465 -0
- package/src/behaviors/color-slider.ts +154 -0
- package/src/behaviors/combobox.ts +474 -0
- package/src/behaviors/dialog.ts +137 -0
- package/src/behaviors/listbox.ts +118 -0
- package/src/behaviors/menu.ts +80 -48
- package/src/behaviors/number-field.ts +161 -0
- package/src/behaviors/popover.ts +399 -0
- package/src/behaviors/select.ts +154 -0
- package/src/behaviors/split.ts +478 -0
- package/src/behaviors/swatch-group.ts +297 -0
- package/src/behaviors/tabs.ts +319 -0
- package/src/behaviors/textfield.ts +164 -0
- package/src/behaviors/toast-host.ts +241 -0
- package/src/behaviors/toast.ts +261 -0
- package/src/behaviors/toolbar.ts +413 -0
- package/src/behaviors/tooltip.ts +715 -0
- package/src/behaviors/tree.ts +830 -0
- package/src/color.ts +433 -0
- package/src/documents.ts +91 -1
- package/src/index.ts +41 -1
- package/src/loaders.ts +47 -0
- package/src/testing/anchor-support.ts +28 -0
- package/src/testing/popover-shim.ts +39 -6
- package/src/theme.ts +30 -6
- package/stylebook/jx-accordion-item.json +195 -0
- package/stylebook/jx-accordion.json +220 -0
- package/stylebook/jx-action-button.json +191 -23
- package/stylebook/jx-action-group.json +335 -0
- package/stylebook/jx-button.json +129 -26
- package/stylebook/jx-checkbox.json +245 -0
- package/stylebook/jx-color-area.json +272 -0
- package/stylebook/jx-color-field.json +261 -0
- package/stylebook/jx-color-slider.json +220 -0
- package/stylebook/jx-combobox.json +332 -0
- package/stylebook/jx-dialog.json +186 -0
- package/stylebook/jx-divider.json +125 -0
- package/stylebook/jx-dot.json +92 -0
- package/stylebook/jx-field.json +313 -0
- package/stylebook/jx-icon.json +4 -0
- package/stylebook/jx-kbd.json +90 -0
- package/stylebook/jx-listbox.json +337 -0
- package/stylebook/jx-menu.json +0 -1
- package/stylebook/jx-number-field.json +203 -0
- package/stylebook/jx-option.json +285 -0
- package/stylebook/jx-popover.json +123 -0
- package/stylebook/jx-select.json +407 -0
- package/stylebook/jx-spinner.json +111 -0
- package/stylebook/jx-split.json +170 -0
- package/stylebook/jx-swatch-group.json +175 -0
- package/stylebook/jx-swatch.json +249 -0
- package/stylebook/jx-switch.json +240 -0
- package/stylebook/jx-tab-panel.json +147 -0
- package/stylebook/jx-tab.json +201 -0
- package/stylebook/jx-tabs.json +278 -0
- package/stylebook/jx-textfield.json +309 -0
- package/stylebook/jx-toast-host.json +157 -0
- package/stylebook/jx-toast.json +266 -0
- package/stylebook/jx-toolbar.json +435 -0
- package/stylebook/jx-tooltip.json +162 -0
- package/stylebook/jx-tree-item.json +258 -0
- package/stylebook/jx-tree.json +429 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jx-swatch-group`'s behaviour sidecar: one tab stop, the radio group's arrow keys, and the single
|
|
3
|
+
* writer of every child swatch's chosen state.
|
|
4
|
+
*
|
|
5
|
+
* A `<jx-swatch-group>` IS the `role="radiogroup"` and its `<jx-swatch>` children are its radios,
|
|
6
|
+
* so every part of the APG's radio-group pattern that a native `<input type="radio">` set would
|
|
7
|
+
* supply is this module's: the roving caret with exactly one `tabindex="0"`, wrap at both ends,
|
|
8
|
+
* Home and End, disabled members skipped, and selection following focus. What it does beyond
|
|
9
|
+
* reading state is the allowed set (specs/ui.md §2): move focus, and write a child's own declared
|
|
10
|
+
* props through the accessors that child installs.
|
|
11
|
+
*
|
|
12
|
+
* SELECTION FOLLOWS FOCUS, AND IT DOES SO BY CLICKING. An arrow key moves the caret and then CLICKS
|
|
13
|
+
* the swatch it landed on, exactly as `behaviors/action-group.ts` does, rather than writing
|
|
14
|
+
* `checked` on it: a swatch dispatches `select` from inside its own click handler, so a group that
|
|
15
|
+
* wrote the child's state directly would move the selection with nothing announcing it and the host
|
|
16
|
+
* would never hear the key at all. One path for a pointer and a key is one behaviour to be wrong
|
|
17
|
+
* about.
|
|
18
|
+
*
|
|
19
|
+
* {@link syncSwatches} is the single writer of each child's `checked` and `tabindex`, and it is
|
|
20
|
+
* reached three ways that are all the same call: from `onSwatchGroupMount`, because `defineElement`
|
|
21
|
+
* runs `applyAttributes` BEFORE `distributeSlots` and no swatch exists when the root's bindings
|
|
22
|
+
* first evaluate; from the `sync` computed the host reads through `data-selection`, which is what
|
|
23
|
+
* makes a HOST write of `.value` re-sync with no method call; and from the observer the mount
|
|
24
|
+
* installs, because the swatch SET moves too — a palette filtered by a search box is the ordinary
|
|
25
|
+
* case, and a group whose caret-holder was filtered away has no `tabindex="0"` left at all.
|
|
26
|
+
*
|
|
27
|
+
* @docs extending/ui-kit
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The tag of the group. */
|
|
31
|
+
const GROUP = "jx-swatch-group";
|
|
32
|
+
/** The tag of one swatch. */
|
|
33
|
+
const SWATCH = "jx-swatch";
|
|
34
|
+
|
|
35
|
+
/** The reactive scope a `jx-swatch-group` document hands its handlers. */
|
|
36
|
+
export interface SwatchGroupState {
|
|
37
|
+
/** The value of the chosen swatch; the empty string when none is. */
|
|
38
|
+
value?: string;
|
|
39
|
+
/** The group's accessible name. */
|
|
40
|
+
label?: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A swatch element with the property accessors its own document installs. */
|
|
45
|
+
type SwatchElement = HTMLElement & {
|
|
46
|
+
value?: string;
|
|
47
|
+
color?: string;
|
|
48
|
+
checked?: string;
|
|
49
|
+
disabled?: boolean;
|
|
50
|
+
tabindex?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** A group element with the accessors its own document installs. */
|
|
54
|
+
type GroupElement = HTMLElement & { value?: string };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The element each scope belongs to, learned once at mount.
|
|
58
|
+
*
|
|
59
|
+
* A WeakMap rather than a key on the scope: the scope is a deep reactive proxy, and an element
|
|
60
|
+
* stored in one is an element every effect that reads it subscribes to.
|
|
61
|
+
*/
|
|
62
|
+
const hosts = new WeakMap<object, HTMLElement>();
|
|
63
|
+
|
|
64
|
+
/** The groups already watching their own swatch set, so a second mount never observes twice. */
|
|
65
|
+
const watched = new WeakMap<HTMLElement, MutationObserver>();
|
|
66
|
+
|
|
67
|
+
/** The group an event's `currentTarget` is. */
|
|
68
|
+
function hostOf(event: Event): GroupElement | null {
|
|
69
|
+
const target = event.currentTarget;
|
|
70
|
+
return target instanceof HTMLElement && target.localName === GROUP
|
|
71
|
+
? (target as GroupElement)
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether `group` is the nearest group around `node` — a key inside a nested group is its own. */
|
|
76
|
+
function ownsNode(group: HTMLElement, node: EventTarget | null): boolean {
|
|
77
|
+
return node instanceof Element && node.closest(GROUP) === group;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The swatches of one group, in order, excluding those of any group nested inside it.
|
|
82
|
+
*
|
|
83
|
+
* @param group The `jx-swatch-group` element.
|
|
84
|
+
* @returns Its own swatches, in document order.
|
|
85
|
+
*/
|
|
86
|
+
export function swatchesOf(group: HTMLElement): SwatchElement[] {
|
|
87
|
+
return [...group.querySelectorAll<SwatchElement>(SWATCH)].filter(
|
|
88
|
+
(swatch) => swatch.closest(GROUP) === group,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What a swatch stands for: its own `value`, falling back to the colour it draws. */
|
|
93
|
+
function valueOf(swatch: SwatchElement): string {
|
|
94
|
+
return String(swatch.value || swatch.color || "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether a swatch cannot be chosen.
|
|
99
|
+
*
|
|
100
|
+
* Read from the PROPERTY once the child has upgraded and from the attribute before it has, because
|
|
101
|
+
* the caret is placed from a lifecycle event and a slotted child's own `connectedCallback` is
|
|
102
|
+
* async: the group can legitimately be asked to rove either side of that moment.
|
|
103
|
+
*/
|
|
104
|
+
function isDisabled(swatch: SwatchElement): boolean {
|
|
105
|
+
return swatch.disabled === undefined ? swatch.hasAttribute("disabled") : swatch.disabled === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Put the caret ON a swatch: the focusable node is the button inside it, never the host. */
|
|
109
|
+
function focusControl(swatch: SwatchElement): void {
|
|
110
|
+
const control = swatch.querySelector<HTMLElement>('[part="control"]');
|
|
111
|
+
(control ?? swatch).focus();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Focus the enabled swatch at `index`, wrapping at both ends.
|
|
116
|
+
*
|
|
117
|
+
* It moves FOCUS and nothing else. The roving `tabindex` is {@link syncSwatches}'s alone, and the
|
|
118
|
+
* two stay together because focusing is followed by a click: the click moves the group's `value`,
|
|
119
|
+
* the `sync` computed reads that, and the caret lands on the swatch that took focus in the same
|
|
120
|
+
* synchronous turn. Writing the caret here as well would be a second writer of one fact — the shape
|
|
121
|
+
* the module header rules out — and it would be a writer no test could ever catch failing, because
|
|
122
|
+
* the first one always overwrites it a moment later.
|
|
123
|
+
*
|
|
124
|
+
* @param group The group whose focus is moving.
|
|
125
|
+
* @param index An index into the group's ENABLED swatches; negative counts from the end.
|
|
126
|
+
* @returns The swatch that took focus, or null when the group has none that can.
|
|
127
|
+
*/
|
|
128
|
+
export function focusSwatch(group: HTMLElement, index: number): SwatchElement | null {
|
|
129
|
+
const enabled = swatchesOf(group).filter((swatch) => !isDisabled(swatch));
|
|
130
|
+
if (enabled.length === 0) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const at = ((index % enabled.length) + enabled.length) % enabled.length;
|
|
134
|
+
const caret = enabled[at]!;
|
|
135
|
+
focusControl(caret);
|
|
136
|
+
return caret;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Write the group's selection onto its swatches: one `checked="true"`, and exactly one
|
|
141
|
+
* `tabindex="0"` so Tab enters and leaves the whole group in one step.
|
|
142
|
+
*
|
|
143
|
+
* Idempotent by construction — everything it writes is derived from the host's own `value`, and an
|
|
144
|
+
* equal write into a reactive proxy triggers nothing. That matters because it runs inside a render
|
|
145
|
+
* effect. The caret goes to the chosen swatch, or to the first that can be chosen when the value
|
|
146
|
+
* names none, which is what the APG asks of a radio group with no selection.
|
|
147
|
+
*
|
|
148
|
+
* @param group The `jx-swatch-group` element.
|
|
149
|
+
*/
|
|
150
|
+
export function syncSwatches(group: HTMLElement): void {
|
|
151
|
+
const swatches = swatchesOf(group);
|
|
152
|
+
if (swatches.length === 0) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const value = String((group as GroupElement).value ?? "");
|
|
156
|
+
const enabled = swatches.filter((swatch) => !isDisabled(swatch));
|
|
157
|
+
const chosen = enabled.find((swatch) => valueOf(swatch) === value);
|
|
158
|
+
const caret = chosen ?? enabled[0] ?? null;
|
|
159
|
+
for (const swatch of swatches) {
|
|
160
|
+
swatch.checked = valueOf(swatch) === value ? "true" : "false";
|
|
161
|
+
swatch.tabindex = swatch === caret ? "0" : "-1";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Keep the one-tab-stop invariant as the group's swatches come and go. */
|
|
166
|
+
function watchSwatches(group: HTMLElement): void {
|
|
167
|
+
if (watched.has(group)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
/* `syncSwatches` writes only attributes and properties, never children, so nothing it does can
|
|
171
|
+
feed this observer back into itself. */
|
|
172
|
+
const observer = new MutationObserver(() => {
|
|
173
|
+
syncSwatches(group);
|
|
174
|
+
});
|
|
175
|
+
observer.observe(group, { childList: true, subtree: true });
|
|
176
|
+
watched.set(group, observer);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The `sync` computed's body: read the selection, and put it on the swatches.
|
|
181
|
+
*
|
|
182
|
+
* The HOST reads it as `data-selection`, so it is an effect of `value` and re-runs whenever
|
|
183
|
+
* anything writes that — a click, a key, or a host assigning `el.value` from the outside. It takes
|
|
184
|
+
* the SCOPE rather than the value, because the runtime hands a `$src` function only its declared
|
|
185
|
+
* positional arguments and the element is reachable only through the scope it was learned against.
|
|
186
|
+
*
|
|
187
|
+
* @param scope The `jx-swatch-group` reactive scope.
|
|
188
|
+
* @returns The current value, for the attribute that reads it.
|
|
189
|
+
*/
|
|
190
|
+
export function applySwatchSelection(scope: SwatchGroupState): string {
|
|
191
|
+
const value = String(scope.value ?? "");
|
|
192
|
+
const host = hosts.get(scope);
|
|
193
|
+
if (host) {
|
|
194
|
+
syncSwatches(host);
|
|
195
|
+
}
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Learn the element, sync once the swatches exist, and keep watching them.
|
|
201
|
+
*
|
|
202
|
+
* Reached through the `jx-ready` the root dispatches from `onMount`, which is the kit's way of
|
|
203
|
+
* giving an element its first sight of itself: a mount handler is called with the scope alone, and
|
|
204
|
+
* an event handler's `currentTarget` is the host.
|
|
205
|
+
*
|
|
206
|
+
* @param scope The group's reactive scope.
|
|
207
|
+
* @param event The element's own `jx-ready`.
|
|
208
|
+
*/
|
|
209
|
+
export function onSwatchGroupMount(scope: SwatchGroupState, event: Event): void {
|
|
210
|
+
const host = hostOf(event);
|
|
211
|
+
if (!host) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
hosts.set(scope, host);
|
|
215
|
+
syncSwatches(host);
|
|
216
|
+
watchSwatches(host);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A swatch was activated: record it, then say so.
|
|
221
|
+
*
|
|
222
|
+
* `value` is written BEFORE `change` is dispatched, so a listener reading `e.target.value` sees the
|
|
223
|
+
* new one. The `select` a swatch dispatches is the group's internal protocol and stops here;
|
|
224
|
+
* `change` is the whole public event surface and it means THE SELECTION MOVED, so re-choosing the
|
|
225
|
+
* swatch that is already chosen dispatches nothing.
|
|
226
|
+
*
|
|
227
|
+
* @param scope The group's reactive scope.
|
|
228
|
+
* @param event The swatch's `select`.
|
|
229
|
+
*/
|
|
230
|
+
export function onSwatchGroupSelect(scope: SwatchGroupState, event: Event): void {
|
|
231
|
+
const host = hostOf(event);
|
|
232
|
+
if (!host || !ownsNode(host, event.target)) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
event.stopPropagation();
|
|
236
|
+
const value = String((event as CustomEvent<unknown>).detail ?? "");
|
|
237
|
+
if (value === String(scope.value ?? "")) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
scope.value = value;
|
|
241
|
+
host.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: value }));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The radio group's keyboard contract: either arrow pair moves the caret between enabled swatches
|
|
246
|
+
* with wrap, Home and End go to the ends, and the swatch the caret lands on is chosen.
|
|
247
|
+
*
|
|
248
|
+
* Both pairs move, because a palette wraps into rows and a reader pressing Down on a grid means
|
|
249
|
+
* "the next one". Space and Enter are the platform's, on each swatch's own `<button>`.
|
|
250
|
+
*
|
|
251
|
+
* The scope is not read: the caret is a fact about the DOM rather than about the group's value, and
|
|
252
|
+
* choosing what it lands on is a click, which comes back through `select` like any other.
|
|
253
|
+
*
|
|
254
|
+
* @param _scope The group's reactive scope, unread.
|
|
255
|
+
* @param event The keydown.
|
|
256
|
+
*/
|
|
257
|
+
export function onSwatchGroupKeydown(_scope: SwatchGroupState, event: KeyboardEvent): void {
|
|
258
|
+
const host = hostOf(event);
|
|
259
|
+
if (!host || !ownsNode(host, event.target)) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const enabled = swatchesOf(host).filter((swatch) => !isDisabled(swatch));
|
|
263
|
+
if (enabled.length === 0) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const active = enabled.findIndex(
|
|
267
|
+
(swatch) =>
|
|
268
|
+
swatch === event.target || (event.target instanceof Node && swatch.contains(event.target)),
|
|
269
|
+
);
|
|
270
|
+
let moved: number;
|
|
271
|
+
switch (event.key) {
|
|
272
|
+
case "ArrowDown":
|
|
273
|
+
case "ArrowRight": {
|
|
274
|
+
moved = active + 1;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "ArrowLeft":
|
|
278
|
+
case "ArrowUp": {
|
|
279
|
+
moved = active === -1 ? -1 : active - 1;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case "Home": {
|
|
283
|
+
moved = 0;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
case "End": {
|
|
287
|
+
moved = -1;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
default: {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
event.preventDefault();
|
|
295
|
+
event.stopPropagation();
|
|
296
|
+
focusSwatch(host, moved)?.click();
|
|
297
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tabs behaviour: what a `jx-tabs` needs that the platform gives a role-carrying custom element
|
|
3
|
+
* for free, which is nothing.
|
|
4
|
+
*
|
|
5
|
+
* A `<jx-tabs>` is the `role="tablist"` itself and its `<jx-tab>` children are the owned tabs, so
|
|
6
|
+
* every part of the APG's tab pattern that a native control would supply — the roving caret, wrap
|
|
7
|
+
* at both ends, Home and End, automatic versus manual activation, Delete on a closable tab — is
|
|
8
|
+
* this module's. What it does beyond reading state is exactly the allowed set (specs/ui.md §2):
|
|
9
|
+
* move focus, and write a child element's own declared props through the accessors that child
|
|
10
|
+
* installs. It measures nothing, listens on no document, and never reaches for a foreign
|
|
11
|
+
* attribute.
|
|
12
|
+
*
|
|
13
|
+
* `syncTabs` is the single writer of each child's `selected` and `tabIndex`. It is reached three
|
|
14
|
+
* ways and they are all the same call: once from `onTabsMount`, because `defineElement` runs
|
|
15
|
+
* `applyAttributes` BEFORE `distributeSlots` and no tab exists when the root's bindings first
|
|
16
|
+
* evaluate; again from the `sync` computed the HOST reads through `data-selection`, which is what
|
|
17
|
+
* makes a HOST write of `.selected` re-sync with no method call; and again from the observer
|
|
18
|
+
* `onTabsMount` installs, because the tab SET moves too. Closing a tab is the whole reason
|
|
19
|
+
* `closable` exists, and a strip that does not answer it is left with no `tabindex="0"` at all —
|
|
20
|
+
* unreachable by Tab, with nothing for a host to press to repair it.
|
|
21
|
+
*
|
|
22
|
+
* @docs extending/ui-kit
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The tag of the tablist. */
|
|
26
|
+
const TABS = "jx-tabs";
|
|
27
|
+
/** The tag of one tab. */
|
|
28
|
+
const TAB = "jx-tab";
|
|
29
|
+
|
|
30
|
+
/** The reactive scope a `jx-tabs` document hands its handlers. */
|
|
31
|
+
export interface TabsState {
|
|
32
|
+
selected?: string;
|
|
33
|
+
label?: string;
|
|
34
|
+
activation?: string;
|
|
35
|
+
orientation?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A tab element with the property accessors its document installs. */
|
|
40
|
+
type TabElement = HTMLElement & { value?: string; selected?: boolean };
|
|
41
|
+
/** A tablist element with the property accessors its document installs. */
|
|
42
|
+
type TabsElement = HTMLElement & { selected?: string };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The element each scope belongs to, learned once at mount.
|
|
46
|
+
*
|
|
47
|
+
* A WeakMap rather than a key on the scope: the scope is a deep reactive proxy, and an element
|
|
48
|
+
* stored in one is an element every effect that reads it subscribes to. The map is keyed by the
|
|
49
|
+
* scope object the runtime hands every handler and every template, which is one identity per
|
|
50
|
+
* instance and is released with it.
|
|
51
|
+
*/
|
|
52
|
+
const hosts = new WeakMap<object, HTMLElement>();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The strips already watching their own tab set, so a second mount never observes twice.
|
|
56
|
+
*
|
|
57
|
+
* Keyed by the element and holding the observer: an observer is kept alive by the node it watches,
|
|
58
|
+
* so this map only decides whether one already exists, and both die with the strip.
|
|
59
|
+
*/
|
|
60
|
+
const watched = new WeakMap<HTMLElement, MutationObserver>();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Watch the tab set, because the selection is not the only thing that moves.
|
|
64
|
+
*
|
|
65
|
+
* The `sync` computed re-runs when `selected` CHANGES VALUE and at no other time, so a strip whose
|
|
66
|
+
* tabs are added or removed underneath it — a closed document, a filtered dock, an array binding
|
|
67
|
+
* that grew — keeps writing a caret onto tabs that are gone. Removing the current tab is the bad
|
|
68
|
+
* case: no tab holds `tabindex="0"` afterwards and the host is `tabindex="-1"`, so the whole strip
|
|
69
|
+
* drops out of the tab order.
|
|
70
|
+
*
|
|
71
|
+
* `syncTabs` writes only attributes and properties, never children, so nothing it does can feed
|
|
72
|
+
* this observer back into itself.
|
|
73
|
+
*
|
|
74
|
+
* @param host The `jx-tabs` element.
|
|
75
|
+
*/
|
|
76
|
+
function watchTabs(host: HTMLElement): void {
|
|
77
|
+
if (watched.has(host)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const observer = new MutationObserver(() => {
|
|
81
|
+
syncTabs(host);
|
|
82
|
+
});
|
|
83
|
+
observer.observe(host, { childList: true, subtree: true });
|
|
84
|
+
watched.set(host, observer);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The tablist an event's `currentTarget` is. */
|
|
88
|
+
function hostOf(event: Event): TabsElement | null {
|
|
89
|
+
const target = event.currentTarget;
|
|
90
|
+
return target instanceof HTMLElement && target.localName === TABS
|
|
91
|
+
? (target as TabsElement)
|
|
92
|
+
: null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether `host` is the nearest tablist around `node` — a nested strip owns its own keys. */
|
|
96
|
+
function ownsNode(host: HTMLElement, node: EventTarget | null): boolean {
|
|
97
|
+
return node instanceof Element && node.closest(TABS) === host;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The tabs of one tablist, in order, excluding the tabs of any tablist nested inside it.
|
|
102
|
+
*
|
|
103
|
+
* The strip's own tabs are its direct children — a `<slot>` leaves no node, so a distributed tab
|
|
104
|
+
* stands where the slot stood — but a surface may still wrap one, so this walks descendants and
|
|
105
|
+
* keeps the ones whose nearest tablist is this one. That is also what excludes a nested strip's.
|
|
106
|
+
*
|
|
107
|
+
* @param host The `jx-tabs` element.
|
|
108
|
+
* @returns Its own tabs, in document order.
|
|
109
|
+
*/
|
|
110
|
+
export function tabsOf(host: HTMLElement): TabElement[] {
|
|
111
|
+
return [...host.querySelectorAll<TabElement>(TAB)].filter((tab) => tab.closest(TABS) === host);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A tab's value as a string; a tab with none stands for the empty selection. */
|
|
115
|
+
function valueOf(tab: TabElement): string {
|
|
116
|
+
return String(tab.value ?? "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Move the roving caret to `index`, wrapping at both ends.
|
|
121
|
+
*
|
|
122
|
+
* @param tabs The strip's tabs.
|
|
123
|
+
* @param index Where to put the caret; out of range wraps.
|
|
124
|
+
* @returns The tab that took it, or null when there are none.
|
|
125
|
+
*/
|
|
126
|
+
export function focusTab(tabs: TabElement[], index: number): TabElement | null {
|
|
127
|
+
if (tabs.length === 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const next = ((index % tabs.length) + tabs.length) % tabs.length;
|
|
131
|
+
for (const [at, tab] of tabs.entries()) {
|
|
132
|
+
tab.tabIndex = at === next ? 0 : -1;
|
|
133
|
+
}
|
|
134
|
+
const tab = tabs[next]!;
|
|
135
|
+
tab.focus();
|
|
136
|
+
return tab;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Write the strip's selection onto its tabs: one `selected`, and exactly one `tabIndex` of 0 so Tab
|
|
141
|
+
* enters and leaves the whole strip in one step.
|
|
142
|
+
*
|
|
143
|
+
* Idempotent by construction — it derives everything it writes from the host's own `selected` and
|
|
144
|
+
* writes equal values into a reactive proxy, which triggers nothing. That matters because it runs
|
|
145
|
+
* inside a render effect.
|
|
146
|
+
*
|
|
147
|
+
* @param host The `jx-tabs` element.
|
|
148
|
+
*/
|
|
149
|
+
export function syncTabs(host: HTMLElement): void {
|
|
150
|
+
const tabs = tabsOf(host);
|
|
151
|
+
if (tabs.length === 0) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const selected = String((host as TabsElement).selected ?? "");
|
|
155
|
+
const found = tabs.findIndex((tab) => valueOf(tab) === selected);
|
|
156
|
+
const caret = found === -1 ? 0 : found;
|
|
157
|
+
for (const [at, tab] of tabs.entries()) {
|
|
158
|
+
tab.selected = valueOf(tab) === selected;
|
|
159
|
+
tab.tabIndex = at === caret ? 0 : -1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The `sync` computed's body: read the selection, and put it on the tabs.
|
|
165
|
+
*
|
|
166
|
+
* The HOST reads it as `data-selection`, so it is an effect of `selected` and re-runs whenever
|
|
167
|
+
* anything writes that — a click, a key, or a host assigning `el.selected` from the outside.
|
|
168
|
+
*
|
|
169
|
+
* It rode the internal `<slot part="tabs">` until slot distribution stopped leaving a node: the
|
|
170
|
+
* slot is now REPLACED by the tabs it matched, so the binding's element was detached the moment the
|
|
171
|
+
* strip had any tabs at all. The effect went on firing at an orphan, which is a mechanism nothing
|
|
172
|
+
* in the document can see and no test can read. `data-orientation` and `data-compact` on
|
|
173
|
+
* `jx-action-group` are the same shape — a `data-` mirror of state the element already publishes —
|
|
174
|
+
* and this one is that. Before mount the element does not yet know itself, and there are no tabs to
|
|
175
|
+
* write to either; the value still comes back, so the attribute is right from the first paint.
|
|
176
|
+
*
|
|
177
|
+
* It takes the SCOPE rather than the selected value the plan spelled, because the runtime hands a
|
|
178
|
+
* `$src` function only its declared positional arguments and the element is reachable only through
|
|
179
|
+
* the scope it was learned against.
|
|
180
|
+
*
|
|
181
|
+
* @param scope The `jx-tabs` reactive scope.
|
|
182
|
+
* @returns The current selection, for the attribute that reads it.
|
|
183
|
+
*/
|
|
184
|
+
export function applySelection(scope: TabsState): string {
|
|
185
|
+
const selected = String(scope.selected ?? "");
|
|
186
|
+
const host = hosts.get(scope);
|
|
187
|
+
if (host) {
|
|
188
|
+
syncTabs(host);
|
|
189
|
+
}
|
|
190
|
+
return selected;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Learn the element, and sync once the tabs exist.
|
|
195
|
+
*
|
|
196
|
+
* Reached through the `jx-ready` the root dispatches from `onMount`, which is the kit's way of
|
|
197
|
+
* giving an element its first sight of itself: a mount handler is called with the scope alone, and
|
|
198
|
+
* an event handler's `currentTarget` is the host.
|
|
199
|
+
*
|
|
200
|
+
* @param {TabsState} scope
|
|
201
|
+
* @param {Event} event
|
|
202
|
+
*/
|
|
203
|
+
export function onTabsMount(scope: TabsState, event: Event): void {
|
|
204
|
+
const host = hostOf(event);
|
|
205
|
+
if (!host) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
hosts.set(scope, host);
|
|
209
|
+
syncTabs(host);
|
|
210
|
+
watchTabs(host);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A tab was activated: record it, then say so.
|
|
215
|
+
*
|
|
216
|
+
* `selected` is written BEFORE `change` is dispatched, so a listener reading `e.target.selected`
|
|
217
|
+
* sees the new value — the two Studio call sites that already read it keep working with no handler
|
|
218
|
+
* change. The `select` a tab dispatches is the strip's internal protocol and stops here; `change`
|
|
219
|
+
* is the whole public event surface, and it means THE SELECTION MOVED: re-activating the tab that
|
|
220
|
+
* is already current says nothing, so it dispatches nothing.
|
|
221
|
+
*
|
|
222
|
+
* @param {TabsState} scope
|
|
223
|
+
* @param {Event} event The tab's `select`
|
|
224
|
+
*/
|
|
225
|
+
export function onTabsSelect(scope: TabsState, event: Event): void {
|
|
226
|
+
const host = hostOf(event);
|
|
227
|
+
if (!host || !ownsNode(host, event.target)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
const value = String((event as CustomEvent<unknown>).detail ?? "");
|
|
232
|
+
if (value === String(scope.selected ?? "")) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
scope.selected = value;
|
|
236
|
+
host.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: value }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The tab keyboard contract: the arrows of the strip's own orientation move the caret with wrap,
|
|
241
|
+
* Home and End go to the ends, Enter and Space activate where activation is manual, and Delete
|
|
242
|
+
* closes a closable tab through the same close button a pointer would press.
|
|
243
|
+
*
|
|
244
|
+
* With `activation="auto"` a move activates as it lands, which is what a two-item dock strip wants;
|
|
245
|
+
* with `"manual"` the caret moves alone and Enter or Space commits — but only when the key was
|
|
246
|
+
* pressed on the TAB. On the close button inside it, Enter and Space belong to the platform's own
|
|
247
|
+
* button activation and this handler steps aside.
|
|
248
|
+
*
|
|
249
|
+
* @param {TabsState} scope
|
|
250
|
+
* @param {KeyboardEvent} event
|
|
251
|
+
*/
|
|
252
|
+
export function onTabsKeydown(scope: TabsState, event: KeyboardEvent): void {
|
|
253
|
+
const host = hostOf(event);
|
|
254
|
+
if (!host || !ownsNode(host, event.target)) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const tabs = tabsOf(host);
|
|
258
|
+
if (tabs.length === 0) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const active = tabs.findIndex(
|
|
262
|
+
(tab) => tab === event.target || (event.target instanceof Node && tab.contains(event.target)),
|
|
263
|
+
);
|
|
264
|
+
const vertical = String(scope.orientation ?? "horizontal") === "vertical";
|
|
265
|
+
const forward = vertical ? "ArrowDown" : "ArrowRight";
|
|
266
|
+
const back = vertical ? "ArrowUp" : "ArrowLeft";
|
|
267
|
+
let moved: number | null = null;
|
|
268
|
+
switch (event.key) {
|
|
269
|
+
case forward: {
|
|
270
|
+
moved = active + 1;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
case back: {
|
|
274
|
+
moved = active === -1 ? -1 : active - 1;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "Home": {
|
|
278
|
+
moved = 0;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case "End": {
|
|
282
|
+
moved = tabs.length - 1;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "Enter":
|
|
286
|
+
case " ": {
|
|
287
|
+
const tab = tabs[active];
|
|
288
|
+
// A key pressed on a control INSIDE the tab is the platform's to answer: the close button is
|
|
289
|
+
// A real `<button>` and Enter or Space on it must close. Activating the tab here and then
|
|
290
|
+
// Cancelling the default would leave a focusable control no keyboard can operate.
|
|
291
|
+
if (!tab || event.target !== tab) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
tab.click();
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
case "Delete": {
|
|
298
|
+
// The pointer's path, exactly: the close button stops the click, so activating it here
|
|
299
|
+
// Cannot also select the tab.
|
|
300
|
+
const close = tabs[active]?.querySelector<HTMLElement>('[part="close"]');
|
|
301
|
+
if (!close) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
close.click();
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
default: {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (moved !== null) {
|
|
312
|
+
const tab = focusTab(tabs, moved);
|
|
313
|
+
if (tab && String(scope.activation ?? "auto") !== "manual") {
|
|
314
|
+
tab.click();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
event.preventDefault();
|
|
318
|
+
event.stopPropagation();
|
|
319
|
+
}
|