@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,255 @@
1
+ /**
2
+ * `jx-action-group`'s behaviour sidecar: the roving caret, and nothing else.
3
+ *
4
+ * The group owns focus movement because that is local state no store should hold, and because a
5
+ * button cannot write about its own position among its siblings. It does NOT own selection: every
6
+ * real call site keeps the selected value in the host, so in `selects="single"` an arrow does not
7
+ * write `checked` on a child — it CLICKS the child, exactly as `behaviors/menu.ts` does for Enter,
8
+ * and the host's own click handler then decides. That leaves one path for pointer and keyboard
9
+ * instead of two that can disagree.
10
+ *
11
+ * What it does beyond reading state is the allowed set (specs/ui.md §2): move focus, and write a
12
+ * child's own declared `tabindex` prop. It measures nothing, listens on no document, and calls no
13
+ * platform overlay API. The `tabindex` prop exists precisely so this module never has to reach into
14
+ * a child's internals to place the caret. The one observer it does own watches the group's OWN
15
+ * subtree, for the reason `watchRoving` gives.
16
+ *
17
+ * @docs extending/ui-kit
18
+ */
19
+
20
+ const GROUP = "jx-action-group";
21
+ const ITEM = "jx-action-button";
22
+
23
+ /** The reactive scope a `jx-action-group` document hands its handlers. */
24
+ export interface ActionGroupState {
25
+ selects?: string;
26
+ orientation?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /** A child action button, with the property accessors its own document installs. */
31
+ type ItemElement = HTMLElement & {
32
+ tabindex?: string;
33
+ disabled?: boolean;
34
+ selected?: boolean;
35
+ checked?: string;
36
+ };
37
+
38
+ /** The group an event's `currentTarget` is. */
39
+ function groupOf(event: Event): HTMLElement | null {
40
+ const target = event.currentTarget;
41
+ return target instanceof HTMLElement && target.localName === GROUP ? target : null;
42
+ }
43
+
44
+ /** Whether `group` is the nearest group around `node` — a key inside a nested group is its own. */
45
+ function ownsNode(group: HTMLElement, node: EventTarget | null): boolean {
46
+ return node instanceof Element && node.closest(GROUP) === group;
47
+ }
48
+
49
+ /**
50
+ * The action buttons of one group, in order, excluding those of any group nested inside it.
51
+ *
52
+ * A distributed child is a DIRECT child of the group — a `<slot>` leaves no node — but a surface
53
+ * may still wrap one, so this walks descendants and keeps the ones whose nearest group is this one.
54
+ * THE SEAM IS NARROWER: the segmented rules in `jx-action-group.json` are direct-child rules, so a
55
+ * button a surface has wrapped is roved but not joined, and a nested group's buttons are neither.
56
+ *
57
+ * @param group The `jx-action-group` element.
58
+ * @returns Its own action buttons, in document order.
59
+ */
60
+ export function itemsOf(group: HTMLElement): ItemElement[] {
61
+ return [...group.querySelectorAll<ItemElement>(ITEM)].filter(
62
+ (item) => item.closest(GROUP) === group,
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Whether a child cannot act.
68
+ *
69
+ * Read from the PROPERTY once the child has upgraded and from the attribute before it has, because
70
+ * the caret is placed from a lifecycle event and a slotted child's own `connectedCallback` is
71
+ * async: the group can legitimately be asked to rove either side of that moment. The property is
72
+ * consulted first where it exists, since a host that wrote `el.disabled = false` over an authored
73
+ * `disabled` attribute means the property.
74
+ */
75
+ function isDisabled(item: ItemElement): boolean {
76
+ return item.disabled === undefined ? item.hasAttribute("disabled") : item.disabled === true;
77
+ }
78
+
79
+ /** Whether a child is the one the host currently considers chosen — either spelling, as above. */
80
+ function isChosen(item: ItemElement): boolean {
81
+ if (item.selected === undefined && item.checked === undefined) {
82
+ return item.hasAttribute("selected") || item.getAttribute("checked") === "true";
83
+ }
84
+ return item.selected === true || item.checked === "true";
85
+ }
86
+
87
+ /** Put the caret ON a child: the focusable node is the button inside it, never the host. */
88
+ function focusControl(item: ItemElement): void {
89
+ const control = item.querySelector<HTMLElement>('[part="control"]');
90
+ (control ?? item).focus();
91
+ }
92
+
93
+ /** Write the roving tabindex: `0` on `caret`, `-1` on every other child, disabled ones included. */
94
+ function writeRoving(items: ItemElement[], caret: ItemElement | null): void {
95
+ for (const item of items) {
96
+ item.tabindex = item === caret ? "0" : "-1";
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The child that should hold the caret: the one that already holds it and still can, else the
102
+ * chosen one, else the first that can act — and null when nothing in the group can.
103
+ */
104
+ function caretFor(items: ItemElement[]): ItemElement | null {
105
+ const enabled = items.filter((item) => !isDisabled(item));
106
+ const held = enabled.find((item) => item.tabindex === "0");
107
+ return held ?? enabled.find((item) => isChosen(item)) ?? enabled[0] ?? null;
108
+ }
109
+
110
+ /**
111
+ * Move the caret to the enabled child at `index`, wrapping at both ends, and focus it.
112
+ *
113
+ * @param group The group whose caret is moving.
114
+ * @param index An index into the group's ENABLED children; negative counts from the end.
115
+ * @returns The child that took the caret, or null when the group has none that can.
116
+ */
117
+ export function focusItem(group: HTMLElement, index: number): ItemElement | null {
118
+ const items = itemsOf(group);
119
+ const enabled = items.filter((item) => !isDisabled(item));
120
+ if (enabled.length === 0) {
121
+ return null;
122
+ }
123
+ const at = ((index % enabled.length) + enabled.length) % enabled.length;
124
+ const caret = enabled[at]!;
125
+ writeRoving(items, caret);
126
+ focusControl(caret);
127
+ return caret;
128
+ }
129
+
130
+ /**
131
+ * Give the group exactly one tab stop.
132
+ *
133
+ * Without this every child is its own tab stop, because a native `<button>` is focusable with no
134
+ * `tabindex` at all — so the collapse to one is a WRITE, not the absence of one, and it has to
135
+ * happen before the reader's first Tab rather than on their first arrow key. The caret stays where
136
+ * it already is when that child can still take it, else it goes to the chosen child, else to the
137
+ * first that can act.
138
+ *
139
+ * Idempotent, which is what lets it be called from a lifecycle event that may arrive more than
140
+ * once, and from the observer below on every change to the group.
141
+ *
142
+ * @param group The `jx-action-group` element.
143
+ */
144
+ export function syncRoving(group: HTMLElement): void {
145
+ const items = itemsOf(group);
146
+ writeRoving(items, caretFor(items));
147
+ }
148
+
149
+ /** Whether the group's children already say what {@link syncRoving} would write. */
150
+ function isSettled(group: HTMLElement): boolean {
151
+ const items = itemsOf(group);
152
+ const caret = caretFor(items);
153
+ return items.every((item) => item.tabindex === (item === caret ? "0" : "-1"));
154
+ }
155
+
156
+ /** Groups already being watched, so a second `jx-ready` does not observe them twice. */
157
+ const watched = new WeakSet<HTMLElement>();
158
+
159
+ /**
160
+ * Keep the one-tab-stop invariant as the group's children change.
161
+ *
162
+ * Placing the caret once at mount is not enough, and the failure is not cosmetic: a child appended
163
+ * afterwards arrives with no `tabindex` at all, which is a SECOND tab stop in an element whose
164
+ * whole purpose is to have one; removing or disabling the child that holds the caret leaves ZERO,
165
+ * so a toolbar of enabled, operable buttons cannot be reached by Tab at all. Both are the ordinary
166
+ * life of a Studio toolbar, whose buttons enable and disable with the selection and whose rows are
167
+ * built from `$switch` and mapped arrays.
168
+ *
169
+ * The observer is on the group's own subtree only. It cannot see a `disabled` written as a PROPERTY
170
+ * rather than an attribute — a mutation record is an attribute record — so a host that flips
171
+ * `el.disabled = true` on the caret-holder is still asked to let the group hear about it, which an
172
+ * arrow key, a click or a re-render all do.
173
+ *
174
+ * @param group The `jx-action-group` element.
175
+ */
176
+ function watchRoving(group: HTMLElement): void {
177
+ if (watched.has(group)) {
178
+ return;
179
+ }
180
+ watched.add(group);
181
+ /* `isSettled` first, so the observer writes only when the invariant is actually broken: a
182
+ re-write would be a mutation of its own, and a callback that always writes is a loop. */
183
+ const observer = new MutationObserver(() => {
184
+ if (!isSettled(group)) {
185
+ syncRoving(group);
186
+ }
187
+ });
188
+ observer.observe(group, {
189
+ childList: true,
190
+ subtree: true,
191
+ attributes: true,
192
+ attributeFilter: ["disabled"],
193
+ });
194
+ }
195
+
196
+ /**
197
+ * The group's keyboard contract: the arrow along its own axis moves the caret with wrap, Home and
198
+ * End go to the ends, and in `selects="single"` the child that takes the caret is CLICKED so the
199
+ * host hears the same event it would hear from a pointer.
200
+ *
201
+ * Enter, Space, the focus ring and `:disabled` are the platform's, on each child's own `<button>`.
202
+ *
203
+ * @param {ActionGroupState} state
204
+ * @param {KeyboardEvent} event
205
+ */
206
+ export function onGroupKeydown(state: ActionGroupState, event: KeyboardEvent): void {
207
+ const group = groupOf(event);
208
+ if (!group || !ownsNode(group, event.target)) {
209
+ return;
210
+ }
211
+ const vertical = state.orientation === "vertical";
212
+ const enabled = itemsOf(group).filter((item) => !isDisabled(item));
213
+ const active = enabled.findIndex(
214
+ (item) =>
215
+ item === event.target || (event.target instanceof Node && item.contains(event.target)),
216
+ );
217
+ let caret: ItemElement | null = null;
218
+ if (event.key === (vertical ? "ArrowDown" : "ArrowRight")) {
219
+ caret = focusItem(group, active + 1);
220
+ } else if (event.key === (vertical ? "ArrowUp" : "ArrowLeft")) {
221
+ caret = focusItem(group, active === -1 ? -1 : active - 1);
222
+ } else if (event.key === "Home") {
223
+ caret = focusItem(group, 0);
224
+ } else if (event.key === "End") {
225
+ caret = focusItem(group, -1);
226
+ } else {
227
+ return;
228
+ }
229
+ event.preventDefault();
230
+ event.stopPropagation();
231
+ /* Arrow-to-select, and the reason it is a click rather than a write: `jx-action-button` emits
232
+ `change` only from inside its own click handler, so a group that wrote `checked` on a child
233
+ would move the selection with nothing announcing it — the host would never hear the key at
234
+ all. Clicking is the one path both a pointer and an arrow take. */
235
+ if (caret && state.selects === "single") {
236
+ caret.click();
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Collapse the group to one tab stop once it has rendered, and keep it collapsed to one.
242
+ *
243
+ * `onMount` is handed the reactive scope and nothing else, so the element announces itself with
244
+ * `jx-ready` — the kit's existing readiness event — and this reads the host off that event.
245
+ *
246
+ * @param {ActionGroupState} _state
247
+ * @param {Event} event
248
+ */
249
+ export function onGroupReady(_state: ActionGroupState, event: Event): void {
250
+ const group = groupOf(event);
251
+ if (group) {
252
+ syncRoving(group);
253
+ watchRoving(group);
254
+ }
255
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * `jx-color-area`'s behaviour sidecar: the pointer maths, the second axis's arrow keys, and the one
3
+ * place the family's event contract is written from.
4
+ *
5
+ * WHY THERE ARE TWO NATIVE RANGES INSIDE A SQUARE. A saturation/brightness field is a `slider` in
6
+ * two dimensions, and ARIA has no such role: the APG's answer, and the platform's, is TWO sliders.
7
+ * So the element is two real `<input type="range">`s — one per axis — and everything a slider owes
8
+ * a reader comes from them rather than from an authored imitation: `role="slider"`, the focus, the
9
+ * tab stops, `aria-valuemin`/`max`/`now` derived from the control's own `min`, `max` and `value`,
10
+ * the announcement when the value moves, form-control disabled semantics, and the along-axis arrow
11
+ * keys with their Home, End, PageUp and PageDown. They are transparent and `pointer-events: none`
12
+ * rather than `display: none`, because a control the platform has hidden is a control the
13
+ * accessibility tree does not have.
14
+ *
15
+ * WHAT IS LEFT FOR THIS FILE IS EXACTLY WHAT A SLIDER CANNOT KNOW.
16
+ *
17
+ * 1. **The other axis.** A range knows one dimension, so ArrowUp on the saturation control is the
18
+ * platform stepping SATURATION — which, to a reader looking at one square with one thumb in it,
19
+ * is the thumb moving sideways when they pressed up. The cross-axis keys are cancelled here and
20
+ * applied to the axis the reader meant. The along-axis keys are NOT touched: those are the
21
+ * platform's, they already clamp and snap, and two implementations of one key is two answers.
22
+ * 2. **Ten steps.** `Shift`+Arrow is the kit's coarse step, as it is on `jx-number-field`, and the
23
+ * platform has no such gesture on a range.
24
+ * 3. **The pointer.** A square has no native drag, so pointerdown, move and up are read against the
25
+ * track's own box. WCAG 2.5.7 is met by the ranges rather than by the drag: every value the
26
+ * pointer can reach is reachable with the arrow keys, and a single click with no movement sets
27
+ * the value outright, so no dragging motion is REQUIRED for any of it.
28
+ *
29
+ * THE ELEMENT IS THE SINGLE SOURCE OF EVENTS, and this is the one place in the kit where a native
30
+ * control's own `input` and `change` are stopped. `jx-number-field` lets them bubble because its
31
+ * control's value IS its value; here the two controls hold half a coordinate each, so a host that
32
+ * heard them would read `e.target.value` as `"64"` and have no way to know which axis it was. Every
33
+ * path — a key, a drag, a click — therefore dispatches `input` and `change` FROM THE HOST, whose
34
+ * own `hue`, `saturation` and `brightness` props are the whole answer. One `e.target`, one
35
+ * reading.
36
+ *
37
+ * @docs extending/ui-kit
38
+ */
39
+
40
+ import { snapToStep } from "../color.ts";
41
+
42
+ /** The tag this sidecar belongs to. */
43
+ const HOST = "jx-color-area";
44
+
45
+ /** The reactive scope a `jx-color-area` document hands its handlers. */
46
+ export interface ColorAreaState {
47
+ /** The hue the square is drawn under, 0-360. Read, never written here. */
48
+ hue: number;
49
+ /** The horizontal axis, 0-100. */
50
+ saturation: number;
51
+ /** The vertical axis, 0-100, measured upward from the bottom edge. */
52
+ brightness: number;
53
+ /** The grid both axes move on. */
54
+ step: number;
55
+ /** Whether the element refuses to move. */
56
+ disabled: boolean;
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ /** Which state key each axis writes. `x` is saturation and `y` is brightness. */
61
+ const AXIS_KEY = { x: "saturation", y: "brightness" } as const;
62
+
63
+ /** The axis a key belongs to when nothing cancels it. */
64
+ const KEY_AXIS: Readonly<Record<string, "x" | "y">> = {
65
+ ArrowDown: "y",
66
+ ArrowLeft: "x",
67
+ ArrowRight: "x",
68
+ ArrowUp: "y",
69
+ };
70
+
71
+ /** Which way each arrow moves its axis. Up and Right increase; the square's y is measured upward. */
72
+ const KEY_SIGN: Readonly<Record<string, number>> = {
73
+ ArrowDown: -1,
74
+ ArrowLeft: -1,
75
+ ArrowRight: 1,
76
+ ArrowUp: 1,
77
+ };
78
+
79
+ /** The hosts with a pointer down on them. Off to the side, because a drag is not element state. */
80
+ const dragging = new WeakSet<HTMLElement>();
81
+
82
+ /** The `jx-color-area` an event is being handled on. */
83
+ function hostOf(event: Event): HTMLElement | null {
84
+ const target = event.currentTarget;
85
+ return target instanceof HTMLElement && target.localName === HOST ? target : null;
86
+ }
87
+
88
+ /** The element's own gradient box, which is what a pointer coordinate is measured against. */
89
+ function trackOf(host: HTMLElement): HTMLElement | null {
90
+ return host.querySelector<HTMLElement>('[part="track"]');
91
+ }
92
+
93
+ /** The axis a node is, read off its own `part`; null for anything that is neither range. */
94
+ function axisOf(node: EventTarget | null): "x" | "y" | null {
95
+ if (!(node instanceof Element)) {
96
+ return null;
97
+ }
98
+ const part = node.getAttribute("part");
99
+ return part === "x" || part === "y" ? part : null;
100
+ }
101
+
102
+ /** The step, never zero: a zero step would snap every coordinate onto the minimum. */
103
+ function stepOf(state: ColorAreaState): number {
104
+ const step = Number(state.step);
105
+ return Number.isFinite(step) && step > 0 ? step : 1;
106
+ }
107
+
108
+ /**
109
+ * Say the value moved, from the HOST, in the platform's own names.
110
+ *
111
+ * @param host The element.
112
+ * @param names Which of `input` and `change` to dispatch, in order.
113
+ */
114
+ function announce(host: HTMLElement, ...names: string[]): void {
115
+ for (const name of names) {
116
+ host.dispatchEvent(new Event(name, { bubbles: true }));
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Write one axis, snapped to the grid and held inside 0-100.
122
+ *
123
+ * @param state The element's reactive scope.
124
+ * @param axis Which axis.
125
+ * @param value The value before snapping.
126
+ * @returns Whether the write moved anything, so a no-op key dispatches nothing.
127
+ */
128
+ function writeAxis(state: ColorAreaState, axis: "x" | "y", value: number): boolean {
129
+ const key = AXIS_KEY[axis];
130
+ const next = snapToStep(value, stepOf(state), 0, 100);
131
+ if (state[key] === next) {
132
+ return false;
133
+ }
134
+ state[key] = next;
135
+ return true;
136
+ }
137
+
138
+ /**
139
+ * The inner range's `input`: mirror the axis it holds, and re-say it from the host.
140
+ *
141
+ * The native event is stopped here. See the file's header: two controls holding half a coordinate
142
+ * each cannot both be `e.target` for one value.
143
+ *
144
+ * @param state The element's reactive scope.
145
+ * @param event The range's own input event.
146
+ */
147
+ export function onAreaAxisInput(state: ColorAreaState, event: Event): void {
148
+ const host = hostOf(event);
149
+ const axis = axisOf(event.target);
150
+ if (!host || !axis) {
151
+ return;
152
+ }
153
+ event.stopPropagation();
154
+ writeAxis(state, axis, Number((event.target as HTMLInputElement).value));
155
+ announce(host, "input");
156
+ }
157
+
158
+ /**
159
+ * The inner range's `change`: the same stop, and the commit re-said from the host.
160
+ *
161
+ * @param state The element's reactive scope.
162
+ * @param event The range's own change event.
163
+ */
164
+ export function onAreaAxisChange(state: ColorAreaState, event: Event): void {
165
+ const host = hostOf(event);
166
+ const axis = axisOf(event.target);
167
+ if (!host || !axis) {
168
+ return;
169
+ }
170
+ event.stopPropagation();
171
+ writeAxis(state, axis, Number((event.target as HTMLInputElement).value));
172
+ announce(host, "change");
173
+ }
174
+
175
+ /**
176
+ * The two keyboard jobs a range cannot do for itself: the OTHER axis, and ten steps.
177
+ *
178
+ * An along-axis arrow with no Shift returns untouched, so the platform's own step, clamp and
179
+ * announcement run exactly as they would on any range. Everything this does handle is cancelled, so
180
+ * the two never both act on one press.
181
+ *
182
+ * @param state The element's reactive scope.
183
+ * @param event The keydown, from either range.
184
+ */
185
+ export function onAreaKeydown(state: ColorAreaState, event: KeyboardEvent): void {
186
+ const host = hostOf(event);
187
+ const focused = axisOf(event.target);
188
+ if (!host || !focused || state.disabled === true) {
189
+ return;
190
+ }
191
+ const axis = KEY_AXIS[event.key];
192
+ if (!axis) {
193
+ return;
194
+ }
195
+ if (axis === focused && !event.shiftKey) {
196
+ return;
197
+ }
198
+ event.preventDefault();
199
+ event.stopPropagation();
200
+ const delta = stepOf(state) * (event.shiftKey ? 10 : 1) * (KEY_SIGN[event.key] ?? 0);
201
+ if (writeAxis(state, axis, Number(state[AXIS_KEY[axis]]) + delta)) {
202
+ /* Both names: a keystroke on a slider is a move AND a commit, which is what the platform's own
203
+ arrow does on the axis it owns. A key that changed nothing says nothing. */
204
+ announce(host, "input", "change");
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Read a pointer coordinate into both axes.
210
+ *
211
+ * @param state The element's reactive scope.
212
+ * @param host The element.
213
+ * @param event The pointer event.
214
+ * @returns Whether anything moved.
215
+ */
216
+ function fromPointer(state: ColorAreaState, host: HTMLElement, event: PointerEvent): boolean {
217
+ const track = trackOf(host);
218
+ if (!track) {
219
+ return false;
220
+ }
221
+ const box = track.getBoundingClientRect();
222
+ if (box.width <= 0 || box.height <= 0) {
223
+ /* An unlaid-out box would put every coordinate at the origin, which is a colour the reader did
224
+ not ask for. Saying nothing is the right answer to a question with no geometry in it. */
225
+ return false;
226
+ }
227
+ const x = writeAxis(state, "x", ((event.clientX - box.left) / box.width) * 100);
228
+ const y = writeAxis(state, "y", (1 - (event.clientY - box.top) / box.height) * 100);
229
+ return x || y;
230
+ }
231
+
232
+ /**
233
+ * A pointer went down on the square: take the value, take the capture, and put the caret on the
234
+ * horizontal range so the keyboard carries on from where the pointer stopped.
235
+ *
236
+ * @param state The element's reactive scope.
237
+ * @param event The pointer event.
238
+ */
239
+ export function onAreaPointerDown(state: ColorAreaState, event: PointerEvent): void {
240
+ const host = hostOf(event);
241
+ if (!host || state.disabled === true) {
242
+ return;
243
+ }
244
+ event.preventDefault();
245
+ dragging.add(host);
246
+ const track = trackOf(host);
247
+ if (track && typeof track.setPointerCapture === "function") {
248
+ /* Capture on the TRACK, so a drag that leaves the square keeps arriving here rather than
249
+ stopping wherever the pointer crossed the edge. */
250
+ try {
251
+ track.setPointerCapture(event.pointerId);
252
+ } catch {
253
+ /* A synthetic pointer has nothing to capture and Chrome throws; see split.ts. */
254
+ }
255
+ }
256
+ host.querySelector<HTMLElement>('[part="x"]')?.focus();
257
+ if (fromPointer(state, host, event)) {
258
+ announce(host, "input");
259
+ }
260
+ }
261
+
262
+ /**
263
+ * The drag itself. Nothing happens without a pointer down first, so a hover moves no colour.
264
+ *
265
+ * @param state The element's reactive scope.
266
+ * @param event The pointer event.
267
+ */
268
+ export function onAreaPointerMove(state: ColorAreaState, event: PointerEvent): void {
269
+ const host = hostOf(event);
270
+ if (!host || !dragging.has(host)) {
271
+ return;
272
+ }
273
+ if (fromPointer(state, host, event)) {
274
+ announce(host, "input");
275
+ }
276
+ }
277
+
278
+ /**
279
+ * The pointer came up, or the gesture was taken away: the value is committed once.
280
+ *
281
+ * `change` is dispatched even when the last move changed nothing, because the commit is about the
282
+ * gesture ending rather than about the final pixel — a click that lands exactly on the current
283
+ * value is still the reader choosing it. Nothing is read out of the scope: every value the gesture
284
+ * produced was written on the way here.
285
+ *
286
+ * @param _state The element's reactive scope, unread.
287
+ * @param event The pointer event.
288
+ */
289
+ export function onAreaPointerUp(_state: ColorAreaState, event: PointerEvent): void {
290
+ const host = hostOf(event);
291
+ if (!host || !dragging.delete(host)) {
292
+ return;
293
+ }
294
+ const track = trackOf(host);
295
+ if (
296
+ track &&
297
+ typeof track.releasePointerCapture === "function" &&
298
+ track.hasPointerCapture?.(event.pointerId)
299
+ ) {
300
+ track.releasePointerCapture(event.pointerId);
301
+ }
302
+ announce(host, "change");
303
+ }