@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,118 @@
1
+ /**
2
+ * `jx-listbox`'s sidecar: the two things a document body cannot say about a list whose keyboard
3
+ * belongs to somebody else.
4
+ *
5
+ * **The listbox is the single writer of every row's `selected`**, exactly as `jx-tabs` is of every
6
+ * tab's, and for the same reason: the flag has to be moved on a foreign element and specs/ui.md §2
7
+ * principle 5 forbids a sidecar writing an ATTRIBUTE on one. So the element observes a PROPERTY,
8
+ * this module writes the property, and the option's own document is what turns it into
9
+ * `aria-selected`. A consumer therefore moves the highlight by writing ONE string — the listbox's
10
+ * `active`, which is the id its owning field already points `aria-activedescendant` at — instead of
11
+ * re-deciding the question once per row.
12
+ *
13
+ * That is the whole of the duplication being closed. Studio's three palettes each carried
14
+ * `aria-selected="${$map.index === state.activeIndex ? 'true' : 'false'}"` and a `data-selected`
15
+ * beside it, in three files, with nothing keeping the three in agreement; the id reference is one
16
+ * answer that reaches both the field and the row, which is what a light DOM is for (§2 principle
17
+ * 2).
18
+ *
19
+ * **The active row is scrolled back into view**, which is the half no consumer could do at all: it
20
+ * is measurement and a method call on another element, both of which belong here. `block:
21
+ * "nearest"` is deliberate — it does nothing when the row is already visible, so a pointer moving
22
+ * over a list never scrolls it under the pointer.
23
+ *
24
+ * What this module does NOT do is take focus, and that is the element's whole contract rather than
25
+ * an omission. A `jx-listbox` has no `tabindex` and never calls `focus()`: one of its two owners is
26
+ * a field that must keep the caret (a palette), and the other is NOTHING AT ALL — Studio's slash
27
+ * menu filters for a caret sitting in the canvas, often in another realm, and every character typed
28
+ * after the `/` has to keep landing there. A menu owns DOM focus by contract, which is exactly why
29
+ * that surface is a listbox and not a menu.
30
+ *
31
+ * @docs extending/ui-kit
32
+ */
33
+
34
+ /** A row element with the property accessors its own document installs. */
35
+ type OptionElement = HTMLElement & { selected?: boolean; disabled?: boolean; value?: string };
36
+
37
+ /** The reactive scope a `jx-listbox` document hands its handlers. */
38
+ export interface ListboxState {
39
+ /** The id of the row the caret is on; the empty string when none is. */
40
+ active?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ const ROW = "jx-option";
45
+ const LIST = "jx-listbox";
46
+
47
+ /**
48
+ * The rows of one listbox, in document order, excluding the rows of any listbox nested inside it.
49
+ *
50
+ * Rows sit inside the panel's emulated `<slot>` and inside whatever `role="group"` wrappers a
51
+ * consumer added for its headings, so this walks descendants and keeps the ones whose nearest
52
+ * listbox is this one — the same shape `rowsOf` has in the menu behaviour.
53
+ *
54
+ * @param host The `jx-listbox` element.
55
+ * @returns Its own rows.
56
+ */
57
+ export function optionsOf(host: HTMLElement): OptionElement[] {
58
+ return [...host.querySelectorAll<OptionElement>(ROW)].filter((row) => row.closest(LIST) === host);
59
+ }
60
+
61
+ /**
62
+ * Bring every row's `selected` into agreement with the active id, and scroll the newly active one
63
+ * back into view.
64
+ *
65
+ * @param host The `jx-listbox` element.
66
+ * @param active The id of the row the caret is on; `""` for none.
67
+ * @returns The row that is now active, or null when nothing is.
68
+ */
69
+ export function syncListbox(host: HTMLElement, active: string): OptionElement | null {
70
+ let landed: OptionElement | null = null;
71
+ let moved = false;
72
+ for (const row of optionsOf(host)) {
73
+ const wanted = active !== "" && row.id === active;
74
+ if (wanted) {
75
+ landed = row;
76
+ /* Only a row that was NOT already selected is scrolled to. A rows-changed mutation that
77
+ leaves the same row active must not yank the list under a reader's pointer. */
78
+ moved = row.selected !== true;
79
+ }
80
+ if (row.selected !== wanted) {
81
+ row.selected = wanted;
82
+ }
83
+ }
84
+ if (landed && moved && typeof landed.scrollIntoView === "function") {
85
+ landed.scrollIntoView({ block: "nearest" });
86
+ }
87
+ return landed;
88
+ }
89
+
90
+ /**
91
+ * Keep the rows in step with `active` for as long as the element lives.
92
+ *
93
+ * Two things move underneath this and neither produces the other's mutation record, so both are
94
+ * watched. The ROWS change when a filter re-runs, which is a `childList` record — and the row
95
+ * carrying the active id is then a different element, so its `selected` has to be re-asserted or
96
+ * the highlight simply disappears. The ACTIVE ID changes when a key moves the caret, which mutates
97
+ * no row at all; the document is what makes that write audible, by binding `data-active` to the
98
+ * same state, exactly as `jx-select`'s sidecar reads a host write of `value` through `data-value`.
99
+ *
100
+ * There is deliberately no `onUnmount`, for the reason `select.ts` sets out at length: the runtime
101
+ * initialises an element once, so a sidecar that disconnected on removal could never install
102
+ * another, and an ordinary re-parent would leave the element rendering, reactive and deaf.
103
+ *
104
+ * @param state The element's reactive scope.
105
+ * @param host The `jx-listbox` element.
106
+ */
107
+ export function mountListbox(state: ListboxState, host: HTMLElement): void {
108
+ syncListbox(host, String(state.active ?? ""));
109
+ const observer = new MutationObserver(() => {
110
+ syncListbox(host, String(state.active ?? ""));
111
+ });
112
+ observer.observe(host, {
113
+ attributeFilter: ["data-active"],
114
+ attributes: true,
115
+ childList: true,
116
+ subtree: true,
117
+ });
118
+ }
@@ -11,8 +11,23 @@
11
11
  * A submenu is a child `jx-menu` slotted into a row (`slot="submenu"`), so it is a DOM descendant
12
12
  * of its parent panel and the platform treats the two as one popover hierarchy: opening the child
13
13
  * does not close the parent, and a click outside closes both.
14
+ *
15
+ * **Placement is the platform's for a menu that hangs from something, and the clamp's for a menu at
16
+ * a pointer.** A menu shown from a source has that source as its implicit anchor, exactly as
17
+ * `jx-popover` does (see `popover.ts`), but a menu's usual source is the element a CONTEXT menu was
18
+ * opened over — what gets the keyboard back, not where the panel hangs — so anchoring is opted into
19
+ * by `placement`, and a submenu opts in on its own with `inline-end span-block-end`, which is the
20
+ * "beside its row, flipped to the other side when it would overflow" the clamp used to compute by
21
+ * hand. {@link onMenuBeforeToggle} decides it before layout; a stack whose root has a floor is
22
+ * never anchored, because a floor is a limit the platform's fallbacks cannot express.
23
+ *
24
+ * @docs extending/ui-kit
14
25
  */
15
26
 
27
+ import { anchorOf, clampIntoViewport, openAt, supportsAnchorPositioning } from "./popover.ts";
28
+
29
+ export { clampIntoViewport } from "./popover.ts";
30
+
16
31
  const ROW = "jx-menu-item";
17
32
  const MENU = "jx-menu";
18
33
 
@@ -22,9 +37,18 @@ export interface MenuState {
22
37
  x?: number;
23
38
  y?: number;
24
39
  floor?: number;
40
+ /** A `position-area` value, or empty for placement by `x` and `y`. */
41
+ placement?: string;
42
+ /** The area the anchored rule reads: `placement`, else the submenu default, else empty. */
43
+ area?: string;
44
+ /** Whether the platform is placing this panel against the element it was shown from. */
45
+ anchored?: boolean;
25
46
  [key: string]: unknown;
26
47
  }
27
48
 
49
+ /** Where a submenu sits when its document says nothing: beside its row, extending downward. */
50
+ export const SUBMENU_PLACEMENT = "inline-end span-block-end";
51
+
28
52
  /** A row element with the property accessors its document installs. */
29
53
  type RowElement = HTMLElement & { expanded?: boolean; disabled?: boolean };
30
54
  /** A menu element with the property accessors its document installs. */
@@ -95,10 +119,22 @@ function isShowing(menu: MenuElement): boolean {
95
119
  return menu.open === true;
96
120
  }
97
121
 
98
- /** Show a menu panel through the platform, if it is not already showing. */
99
- export function showMenu(menu: MenuElement): void {
100
- if (!isShowing(menu) && typeof menu.showPopover === "function") {
101
- menu.showPopover();
122
+ /**
123
+ * Show a menu panel through the platform, if it is not already showing.
124
+ *
125
+ * `source` is the element the panel was opened from, and it is not decoration: HTML relocates a
126
+ * popover's sequential focus position and restores focus relative to its INVOKER, which is
127
+ * established either by a `popovertarget` button or by `showPopover({ source })`. A bare
128
+ * `showPopover()` — which is what this did — leaves the panel with no invoker at all, so closing a
129
+ * submenu returned the caret to wherever the document happened to leave it, and a mousedown on the
130
+ * row a submenu hangs off light-dismissed the submenu as an outside click.
131
+ *
132
+ * @param menu The panel to show.
133
+ * @param source The element it is being opened from, if any.
134
+ */
135
+ export function showMenu(menu: MenuElement, source?: Element | null): void {
136
+ if (!isShowing(menu)) {
137
+ openAt(menu, source ?? null);
102
138
  }
103
139
  }
104
140
 
@@ -143,7 +179,7 @@ export function openSubmenu(
143
179
  sub.x = Math.round(box.right) - 2;
144
180
  sub.y = Math.round(box.top) - 4;
145
181
  row.expanded = true;
146
- showMenu(sub);
182
+ showMenu(sub, row);
147
183
  if (options.focus) {
148
184
  focusRow(rowsOf(sub), 0);
149
185
  }
@@ -269,9 +305,34 @@ export function onMenuKeydown(_state: MenuState, event: KeyboardEvent): void {
269
305
  }
270
306
 
271
307
  /**
272
- * Mirror the platform's toggle into state; on show, clamp into the viewport once laid out and move
273
- * the caret to the first row; on hide, close whatever submenus were open and clear the parent row's
274
- * expanded state.
308
+ * The platform's `beforetoggle`: where `area` and `anchored` are decided, before the panel is laid
309
+ * out, so the first frame is already the anchored one and nothing jumps.
310
+ *
311
+ * `area` is `placement` when the document set one, else the submenu default for a menu inside a
312
+ * row, else empty; `anchored` is an opening with an area, a source the platform or `openAt` named,
313
+ * and no floor on the stack's root. A closing `beforetoggle` clears `anchored`, so the coordinate
314
+ * rule is back in force for whatever shows the panel next.
315
+ *
316
+ * @param {MenuState} state
317
+ * @param {Event} event The platform's ToggleEvent
318
+ */
319
+ export function onMenuBeforeToggle(state: MenuState, event: Event): void {
320
+ const menu = menuOf(event);
321
+ if (!menu) {
322
+ return;
323
+ }
324
+ const opening = (event as { newState?: string }).newState === "open";
325
+ const area = String(state.placement ?? "") || (parentRowOf(menu) ? SUBMENU_PLACEMENT : "");
326
+ state.area = area;
327
+ const { source } = event as Event & { source?: unknown };
328
+ const from = source instanceof Element ? source : anchorOf(menu);
329
+ state.anchored = opening && area !== "" && from !== null && !hasFloor(menu);
330
+ }
331
+
332
+ /**
333
+ * Mirror the platform's toggle into state; on show, clamp into the viewport once laid out — unless
334
+ * the platform is placing the panel by anchor — and move the caret to the first row; on hide, close
335
+ * whatever submenus were open and clear the parent row's expanded state.
275
336
  *
276
337
  * @param {MenuState} state
277
338
  * @param {Event} event The platform's ToggleEvent
@@ -291,57 +352,28 @@ export function onMenuToggle(state: MenuState, event: Event): void {
291
352
  }
292
353
  return;
293
354
  }
294
- clampIntoViewport(state, menu);
355
+ if (!(state.anchored === true && supportsAnchorPositioning())) {
356
+ clampIntoViewport(state, menu, {
357
+ flipAgainst: parent?.closest<HTMLElement>(MENU) ?? null,
358
+ floor: floorOf(menu),
359
+ });
360
+ }
295
361
  if (!menu.contains(document.activeElement)) {
296
362
  focusRow(rowsOf(menu), 0);
297
363
  }
298
364
  }
299
365
 
366
+ /** Whether the stack's root declares a floor at all. */
367
+ function hasFloor(menu: HTMLElement): boolean {
368
+ return Number((rootMenuOf(menu) as MenuElement).floor ?? 0) > 0;
369
+ }
370
+
300
371
  /** The lowest edge a stack may reach: the root menu's `floor`, else the viewport's bottom. */
301
372
  function floorOf(menu: HTMLElement): number {
302
373
  const floor = Number((rootMenuOf(menu) as MenuElement).floor ?? 0);
303
374
  return floor > 0 ? floor : window.innerHeight;
304
375
  }
305
376
 
306
- /**
307
- * Keep a shown panel inside its area. Measured after a frame, because an unlaid-out panel is zero
308
- * wide and would never appear to overflow anything; a zero-size box (a test DOM) is left where it
309
- * is.
310
- *
311
- * A submenu that would leave the viewport on the right FLIPS to its parent menu's left rather than
312
- * sliding over the parent; a root menu slides. Both are floored at the stack's `floor` — the root
313
- * menu's, so one floor holds for every level.
314
- *
315
- * @param {MenuState} state
316
- * @param {HTMLElement} menu
317
- */
318
- export function clampIntoViewport(state: MenuState, menu: HTMLElement): void {
319
- const measure = (): void => {
320
- const box = menu.getBoundingClientRect();
321
- if (box.width === 0 && box.height === 0) {
322
- return;
323
- }
324
- const margin = 4;
325
- const bottom = floorOf(menu);
326
- if (box.right > window.innerWidth - margin) {
327
- const parent = parentRowOf(menu)?.closest<HTMLElement>(MENU) ?? null;
328
- const flipped = parent ? Math.round(parent.getBoundingClientRect().left) - box.width + 2 : -1;
329
- state.x =
330
- flipped >= margin
331
- ? flipped
332
- : Math.max(margin, Math.round(window.innerWidth - box.width - margin));
333
- }
334
- if (box.bottom > bottom) {
335
- state.y = Math.max(margin, Math.round(bottom - box.height));
336
- }
337
- };
338
- if (typeof requestAnimationFrame === "function") {
339
- requestAnimationFrame(measure);
340
- } else {
341
- measure();
342
- }
343
- }
344
-
345
377
  /**
346
378
  * Put the caret somewhere after the rows changed under it: when focus is no longer inside `menu`,
347
379
  * the first row of the deepest open submenu takes it — and a submenu left with no rows at all is
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `jx-number-field`'s behaviour sidecar: the stepping a document cannot express, and the mirror
3
+ * that follows a value the platform wrote without telling anyone.
4
+ *
5
+ * Everything here goes through the element's OWN inner `<input type="number">` and its own value
6
+ * API. `HTMLInputElement.stepUp(n)` / `stepDown(n)` take a multiplier, clamp to `min` and `max`,
7
+ * and snap to the step grid measured from `min` — hand-rolled `value ± step` diverges the moment
8
+ * the value is off that grid (min 0, step 0.3, value 0.5: `stepUp(1)` is 0.6 and `value + step` is
9
+ * 0.8). They are unreachable from a document body, because the expression evaluator's `calleeOwner`
10
+ * has no `event#/` branch and a `call` on `event#/target/stepUp` would invoke with an undefined
11
+ * receiver. That is why this module exists, and it is the amendment to specs/ui.md §2 principle 5:
12
+ * a native form control's own value-stepping API, on the element's own inner control.
13
+ *
14
+ * WHO WROTE THE VALUE DECIDES WHO SAYS SO.
15
+ *
16
+ * When the READER wrote it, the platform has already said so and nothing here touches that: the
17
+ * native `input` and `change` bubble out of `[part="input"]` unstopped and un-re-sent, so a host's
18
+ * `e.target` is the live control and `e.target.valueAsNumber` (NaN when empty), `.validity`,
19
+ * `.checkValidity()` and `.form` all read. `jx-textfield`'s sidecar leaves the same pair alone, so
20
+ * one ported `(e.target as HTMLInputElement)` cast is right for both kit fields.
21
+ *
22
+ * When the ELEMENT wrote it — the two stepper buttons, and Shift+Arrow — the platform fires nothing
23
+ * at all for a programmatic value write, so the same two NAMES are dispatched from the HOST, whose
24
+ * own `value` is that same string. No new names, and no `detail`: an event is how a host is told
25
+ * that the value moved, not where the element keeps its state.
26
+ *
27
+ * SO `badInput` RIDES THE ELEMENT, NOT AN EVENT. `<input type="number">` runs a value sanitization
28
+ * algorithm and discards what it cannot parse, so a half-typed "1e" reads as an empty value with
29
+ * `validity.badInput` true. A host that deletes a key when the field goes empty must tell that from
30
+ * a clear, and it asks the element: `host.badInput`, written here before the event bubbles past,
31
+ * and mirrored to `data-bad-input` on the host for a selector. From a handler holding only the
32
+ * event that is `(event.target as Element).closest("jx-number-field")` — which works on both paths,
33
+ * because the control is inside the host and the host is itself.
34
+ *
35
+ * @docs extending/ui-kit
36
+ */
37
+
38
+ /** The reactive scope a `jx-number-field` document hands its handlers. */
39
+ export interface NumberFieldState {
40
+ /** The field's text, as a string — empty is empty, never 0. */
41
+ value: string;
42
+ /** Whether the control is holding something it cannot parse as a number. */
43
+ badInput: boolean;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ const HOST = "jx-number-field";
48
+
49
+ /** The `jx-number-field` an event belongs to. */
50
+ function hostOf(event: Event): HTMLElement | null {
51
+ const target = event.currentTarget;
52
+ return target instanceof Element ? target.closest<HTMLElement>(HOST) : null;
53
+ }
54
+
55
+ /** The element's own native control. */
56
+ function controlOf(host: HTMLElement): HTMLInputElement | null {
57
+ return host.querySelector<HTMLInputElement>('input[part="input"]');
58
+ }
59
+
60
+ /**
61
+ * Read the control back into state.
62
+ *
63
+ * `badInput` is the third case between empty and zero, and this is where a host reads it from: the
64
+ * element, synchronously, before any event this write precedes has finished bubbling.
65
+ */
66
+ function mirror(state: NumberFieldState, control: HTMLInputElement): void {
67
+ state.value = control.value;
68
+ state.badInput = control.validity?.badInput === true;
69
+ }
70
+
71
+ /**
72
+ * Say that a value the ELEMENT wrote moved and was committed, in the platform's own two names, from
73
+ * the host — the platform fires neither for a programmatic write.
74
+ */
75
+ function announce(host: HTMLElement): void {
76
+ host.dispatchEvent(new Event("input", { bubbles: true }));
77
+ host.dispatchEvent(new Event("change", { bubbles: true }));
78
+ }
79
+
80
+ /**
81
+ * Step the control by `n` steps — negative steps down — through the control's own API, then mirror
82
+ * and announce.
83
+ *
84
+ * @param state The element's reactive scope.
85
+ * @param event The event whose `currentTarget` sits inside the field.
86
+ * @param n How many steps, signed.
87
+ */
88
+ export function stepBy(state: NumberFieldState, event: Event, n: number): void {
89
+ const host = hostOf(event);
90
+ const control = host ? controlOf(host) : null;
91
+ if (!host || !control) {
92
+ return;
93
+ }
94
+ try {
95
+ if (n < 0) {
96
+ control.stepDown(-n);
97
+ } else {
98
+ control.stepUp(n);
99
+ }
100
+ } catch {
101
+ /* A control whose value cannot be converted to a number throws InvalidStateError: there is
102
+ nothing to step from, and the reader's half-typed text is not ours to replace. */
103
+ return;
104
+ }
105
+ mirror(state, control);
106
+ announce(host);
107
+ }
108
+
109
+ /**
110
+ * The inner control's `keydown`: Shift+ArrowUp / Shift+ArrowDown move ten steps.
111
+ *
112
+ * A plain Arrow is left alone — the platform already steps by one, clamps and snaps. The shifted
113
+ * pair is cancelled so the platform's own single step does not run on top of the ten.
114
+ *
115
+ * @param state The element's reactive scope.
116
+ * @param event The keyboard event.
117
+ */
118
+ export function onNumberKeydown(state: NumberFieldState, event: Event): void {
119
+ const key = event as KeyboardEvent;
120
+ if (!key.shiftKey) {
121
+ return;
122
+ }
123
+ if (key.key === "ArrowUp") {
124
+ key.preventDefault();
125
+ stepBy(state, event, 10);
126
+ } else if (key.key === "ArrowDown") {
127
+ key.preventDefault();
128
+ stepBy(state, event, -10);
129
+ }
130
+ }
131
+
132
+ /** The step-up button: one step, the same way an ArrowUp does it. */
133
+ export function onStepUp(state: NumberFieldState, event: Event): void {
134
+ stepBy(state, event, 1);
135
+ }
136
+
137
+ /** The step-down button: one step down. */
138
+ export function onStepDown(state: NumberFieldState, event: Event): void {
139
+ stepBy(state, event, -1);
140
+ }
141
+
142
+ /**
143
+ * The inner control's `change`: the reader committed an edit.
144
+ *
145
+ * This only reads the control back into state. The native event is neither stopped nor re-sent — it
146
+ * goes on bubbling from `[part="input"]`, which is the whole reason a host can still reach
147
+ * `e.target.valueAsNumber` and `e.target.validity`. The mirror lives here as well as on `input`
148
+ * because a `change` can arrive without one: a host that writes the control's value itself gets no
149
+ * `input` from the platform either.
150
+ *
151
+ * @param state The element's reactive scope.
152
+ * @param event The native change event.
153
+ */
154
+ export function onNumberChange(state: NumberFieldState, event: Event): void {
155
+ const host = hostOf(event);
156
+ const control = host ? controlOf(host) : null;
157
+ if (!host || !control) {
158
+ return;
159
+ }
160
+ mirror(state, control);
161
+ }