@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,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
|
+
}
|
package/src/behaviors/menu.ts
CHANGED
|
@@ -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
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|