@jxsuite/ui 0.5.0 → 0.7.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/icons/list.json CHANGED
@@ -24,6 +24,7 @@
24
24
  "arrows-out-line-horizontal": ["regular"],
25
25
  "arrows-out-line-vertical": ["regular"],
26
26
  "arrows-vertical": ["regular"],
27
+ "books": ["regular"],
27
28
  "brackets-curly": ["regular"],
28
29
  "browser": ["regular"],
29
30
  "caret-down": ["regular", "bold"],
@@ -79,6 +79,9 @@
79
79
  "arrows-vertical": {
80
80
  "regular": "M165.66,194.34a8,8,0,0,1,0,11.32l-32,32a8,8,0,0,1-11.32,0l-32-32a8,8,0,0,1,11.32-11.32L120,212.69V43.31L101.66,61.66A8,8,0,0,1,90.34,50.34l32-32a8,8,0,0,1,11.32,0l32,32a8,8,0,0,1-11.32,11.32L136,43.31V212.69l18.34-18.35A8,8,0,0,1,165.66,194.34Z"
81
81
  },
82
+ "books": {
83
+ "regular": "M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z"
84
+ },
82
85
  "brackets-curly": {
83
86
  "regular": "M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z"
84
87
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ui",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The Jx UI kit: interface elements authored as Jx documents, with their theme and icons",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,11 +58,12 @@
58
58
  "upgrade": "bun update --latest && bun install"
59
59
  },
60
60
  "dependencies": {
61
- "@jxsuite/runtime": "^4.0.3",
62
- "@jxsuite/schema": "^3.0.0"
61
+ "@jxsuite/runtime": "^5.0.0",
62
+ "@jxsuite/schema": "^4.0.0"
63
63
  },
64
64
  "devDependencies": {
65
- "@happy-dom/global-registrator": "^20.14.5",
65
+ "@happy-dom/global-registrator": "^21.0.3",
66
+ "@jxsuite/test-support": "^0.0.0",
66
67
  "@phosphor-icons/core": "^2.1.1"
67
68
  }
68
69
  }
@@ -24,7 +24,6 @@ const ITEM = "jx-action-button";
24
24
  export interface ActionGroupState {
25
25
  selects?: string;
26
26
  orientation?: string;
27
- [key: string]: unknown;
28
27
  }
29
28
 
30
29
  /** A child action button, with the property accessors its own document installs. */
@@ -37,7 +37,9 @@
37
37
  * @docs extending/ui-kit
38
38
  */
39
39
 
40
- import { snapToStep } from "../color.ts";
40
+ import { isFunction } from "@jxsuite/schema/guards";
41
+
42
+ import { ARROW_SIGN, snapToStep } from "../color.ts";
41
43
 
42
44
  /** The tag this sidecar belongs to. */
43
45
  const HOST = "jx-color-area";
@@ -54,27 +56,18 @@ export interface ColorAreaState {
54
56
  step: number;
55
57
  /** Whether the element refuses to move. */
56
58
  disabled: boolean;
57
- [key: string]: unknown;
58
59
  }
59
60
 
60
61
  /** Which state key each axis writes. `x` is saturation and `y` is brightness. */
61
62
  const AXIS_KEY = { x: "saturation", y: "brightness" } as const;
62
63
 
63
64
  /** 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
- };
65
+ const KEY_AXIS: ReadonlyMap<string, "x" | "y"> = new Map([
66
+ ["ArrowDown", "y"],
67
+ ["ArrowLeft", "x"],
68
+ ["ArrowRight", "x"],
69
+ ["ArrowUp", "y"],
70
+ ]);
78
71
 
79
72
  /** The hosts with a pointer down on them. Off to the side, because a drag is not element state. */
80
73
  const dragging = new WeakSet<HTMLElement>();
@@ -151,6 +144,8 @@ export function onAreaAxisInput(state: ColorAreaState, event: Event): void {
151
144
  return;
152
145
  }
153
146
  event.stopPropagation();
147
+ /* SAFETY: axisOf answers only for a node whose part is `x` or `y`, and jx-color-area.json renders
148
+ exactly those two parts, each as a native `<input type="range">`. */
154
149
  writeAxis(state, axis, Number((event.target as HTMLInputElement).value));
155
150
  announce(host, "input");
156
151
  }
@@ -168,6 +163,8 @@ export function onAreaAxisChange(state: ColorAreaState, event: Event): void {
168
163
  return;
169
164
  }
170
165
  event.stopPropagation();
166
+ /* SAFETY: axisOf answers only for a node whose part is `x` or `y`, and jx-color-area.json renders
167
+ exactly those two parts, each as a native `<input type="range">`. */
171
168
  writeAxis(state, axis, Number((event.target as HTMLInputElement).value));
172
169
  announce(host, "change");
173
170
  }
@@ -188,7 +185,7 @@ export function onAreaKeydown(state: ColorAreaState, event: KeyboardEvent): void
188
185
  if (!host || !focused || state.disabled === true) {
189
186
  return;
190
187
  }
191
- const axis = KEY_AXIS[event.key];
188
+ const axis = KEY_AXIS.get(event.key);
192
189
  if (!axis) {
193
190
  return;
194
191
  }
@@ -197,7 +194,7 @@ export function onAreaKeydown(state: ColorAreaState, event: KeyboardEvent): void
197
194
  }
198
195
  event.preventDefault();
199
196
  event.stopPropagation();
200
- const delta = stepOf(state) * (event.shiftKey ? 10 : 1) * (KEY_SIGN[event.key] ?? 0);
197
+ const delta = stepOf(state) * (event.shiftKey ? 10 : 1) * (ARROW_SIGN.get(event.key) ?? 0);
201
198
  if (writeAxis(state, axis, Number(state[AXIS_KEY[axis]]) + delta)) {
202
199
  /* Both names: a keystroke on a slider is a move AND a commit, which is what the platform's own
203
200
  arrow does on the axis it owns. A key that changed nothing says nothing. */
@@ -244,7 +241,7 @@ export function onAreaPointerDown(state: ColorAreaState, event: PointerEvent): v
244
241
  event.preventDefault();
245
242
  dragging.add(host);
246
243
  const track = trackOf(host);
247
- if (track && typeof track.setPointerCapture === "function") {
244
+ if (track && isFunction(track.setPointerCapture)) {
248
245
  /* Capture on the TRACK, so a drag that leaves the square keeps arriving here rather than
249
246
  stopping wherever the pointer crossed the edge. */
250
247
  try {
@@ -294,8 +291,9 @@ export function onAreaPointerUp(_state: ColorAreaState, event: PointerEvent): vo
294
291
  const track = trackOf(host);
295
292
  if (
296
293
  track &&
297
- typeof track.releasePointerCapture === "function" &&
298
- track.hasPointerCapture?.(event.pointerId)
294
+ isFunction(track.releasePointerCapture) &&
295
+ isFunction(track.hasPointerCapture) &&
296
+ track.hasPointerCapture(event.pointerId)
299
297
  ) {
300
298
  track.releasePointerCapture(event.pointerId);
301
299
  }
@@ -33,6 +33,9 @@
33
33
  * @docs extending/ui-kit
34
34
  */
35
35
 
36
+ import { isFunction } from "@jxsuite/schema/guards";
37
+ import type { JsonValue } from "@jxsuite/schema/types";
38
+
36
39
  import {
37
40
  formatHex,
38
41
  formatOklch,
@@ -44,19 +47,24 @@ import {
44
47
  } from "../color.ts";
45
48
  import type { Rgb } from "../color.ts";
46
49
  import { close, openAt } from "./popover.ts";
50
+ import type { PlacedPopover } from "./popover.ts";
47
51
 
48
52
  /** The tag this sidecar belongs to. */
49
53
  const HOST = "jx-color-field";
50
54
 
51
55
  /** The reactive scope a `jx-color-field` document hands its handlers. */
52
56
  export interface ColorFieldState {
53
- /** The colour, spelled as `format` says. */
54
- value: string;
57
+ /**
58
+ * The colour, spelled as `format` says. Null when a host clears the reflected `value` attribute,
59
+ * which is a write this module reads but never makes.
60
+ */
61
+ value: string | null;
55
62
  /**
56
63
  * The colour behind `value` when `value` is a reference this page cannot resolve; empty when the
57
- * value stands for itself. Drawn by the chip and opened on by the picker, never written.
64
+ * value stands for itself. Drawn by the chip and opened on by the picker, never written; null
65
+ * when a host clears the reflected attribute.
58
66
  */
59
- resolved: string;
67
+ resolved: string | null;
60
68
  /** `hex` or `oklch`: which spelling {@link compose} writes. */
61
69
  format: string;
62
70
  /** Whether the alpha channel is offered at all. */
@@ -83,14 +91,26 @@ export interface ColorFieldState {
83
91
  uid: string;
84
92
  /** Whether the element refuses to move. */
85
93
  disabled: boolean;
86
- [key: string]: unknown;
87
94
  }
88
95
 
89
- /** The system's screen-colour picker, where the engine has one. */
96
+ /**
97
+ * The system's screen-colour picker, where the engine has one. Not in the TypeScript DOM library
98
+ * yet, so its result is typed as loosely as an engine's own implementation could hand it back.
99
+ */
90
100
  interface EyeDropperApi {
91
- open: () => Promise<{ sRGBHex: string }>;
101
+ open: () => Promise<{ sRGBHex?: string } | undefined>;
92
102
  }
93
103
 
104
+ /**
105
+ * A node the document has named a channel, with the properties the composed elements install: the
106
+ * area's two axes, and the `value` a slider or a slotted palette carries.
107
+ */
108
+ type ChannelElement = HTMLElement & {
109
+ saturation?: number;
110
+ brightness?: number;
111
+ value?: number | string;
112
+ };
113
+
94
114
  /** How many stems have been minted, so no two instances share one. */
95
115
  let minted = 0;
96
116
 
@@ -120,9 +140,9 @@ function ownerOf(event: Event): HTMLElement | null {
120
140
  return target instanceof Element ? target.closest<HTMLElement>(HOST) : null;
121
141
  }
122
142
 
123
- /** The picker panel. */
124
- function panelOf(host: HTMLElement): HTMLElement | null {
125
- return host.querySelector<HTMLElement>('[part="picker"]');
143
+ /** The picker panel: the `jx-popover` jx-color-field.json renders as its `picker` part. */
144
+ function panelOf(host: HTMLElement): PlacedPopover | null {
145
+ return host.querySelector<PlacedPopover>('[part="picker"]');
126
146
  }
127
147
 
128
148
  /** The colour the channels currently describe. */
@@ -242,11 +262,20 @@ const CHANNEL = "[data-jx-channel]";
242
262
  * channel by name is what makes the answer the same for a control the element owns, a control one
243
263
  * of its children owns, and a palette a consumer slotted in.
244
264
  */
245
- function sourceOf(event: Event): (HTMLElement & Record<string, unknown>) | null {
265
+ function sourceOf(event: Event): ChannelElement | null {
246
266
  const { target } = event;
247
- return target instanceof Element
248
- ? target.closest<HTMLElement & Record<string, unknown>>(CHANNEL)
249
- : null;
267
+ return target instanceof Element ? target.closest<ChannelElement>(CHANNEL) : null;
268
+ }
269
+
270
+ /**
271
+ * The `value` an event's target carries, as text; empty when it carries none.
272
+ *
273
+ * The target is a native `<input>` for the text and system channels, but for a palette it is
274
+ * whatever a consumer slotted in, which may expose no `value` at all.
275
+ */
276
+ function targetValue(event: Event): string {
277
+ const { target } = event;
278
+ return target !== null && "value" in target ? String(target.value ?? "") : "";
250
279
  }
251
280
 
252
281
  /**
@@ -272,7 +301,7 @@ function route(state: ColorFieldState, event: Event, name: "input" | "change"):
272
301
  }
273
302
  event.stopPropagation();
274
303
  if (channel === "text") {
275
- const typed = String((event.target as HTMLInputElement).value ?? "");
304
+ const typed = targetValue(event);
276
305
  state.text = typed;
277
306
  const parsed = parseColor(typed);
278
307
  if (name === "input") {
@@ -295,18 +324,18 @@ function route(state: ColorFieldState, event: Event, name: "input" | "change"):
295
324
  whatever announced them, and both are opaque by construction — a colour well cannot express
296
325
  an alpha and a palette entry need not — so the alpha the reader already chose is carried
297
326
  across rather than reset to full by a control that has nothing to say about it. */
298
- const parsed = parseColor(String((event.target as HTMLInputElement).value ?? ""));
327
+ const parsed = parseColor(targetValue(event));
299
328
  if (!parsed) {
300
329
  return;
301
330
  }
302
331
  applyRgb(state, { ...parsed, a: state.alpha === true ? Number(state.opacity) / 100 : 1 });
303
332
  } else if (channel === "area") {
304
- state.saturation = Number(source["saturation"]);
305
- state.brightness = Number(source["brightness"]);
333
+ state.saturation = Number(source.saturation);
334
+ state.brightness = Number(source.brightness);
306
335
  } else if (channel === "hue") {
307
- state.hue = Number(source["value"]);
336
+ state.hue = Number(source.value);
308
337
  } else if (channel === "opacity") {
309
- state.opacity = Number(source["value"]);
338
+ state.opacity = Number(source.value);
310
339
  } else {
311
340
  return;
312
341
  }
@@ -373,8 +402,8 @@ export function onOpenPicker(state: ColorFieldState, event: Event): void {
373
402
  return;
374
403
  }
375
404
  const box = host.getBoundingClientRect();
376
- (panel as HTMLElement & { x: number; y: number }).x = Math.round(box.left);
377
- (panel as HTMLElement & { x: number; y: number }).y = Math.round(box.bottom);
405
+ panel.x = Math.round(box.left);
406
+ panel.y = Math.round(box.bottom);
378
407
  openAt(panel, event.currentTarget instanceof Element ? event.currentTarget : host);
379
408
  }
380
409
 
@@ -392,10 +421,16 @@ export function onOpenPicker(state: ColorFieldState, event: Event): void {
392
421
  * @param offered The element's own `eyedropper` prop.
393
422
  * @returns Whether to draw the button.
394
423
  */
395
- export function eyeDropperAvailable(offered: unknown): boolean {
396
- return (
397
- offered === true && typeof (globalThis as { EyeDropper?: unknown }).EyeDropper === "function"
398
- );
424
+ export function eyeDropperAvailable(offered: JsonValue | undefined): boolean {
425
+ return offered === true && isFunction(eyeDropperOf());
426
+ }
427
+
428
+ /** The engine's `EyeDropper` constructor, or undefined where it ships none. */
429
+ function eyeDropperOf(): (new () => EyeDropperApi) | undefined {
430
+ /* SAFETY: `EyeDropper` is the WICG screen-picker constructor, absent from lib.dom; where an engine
431
+ ships it, `new EyeDropper().open()` resolves the picked colour, and where it does not the
432
+ property is undefined, which every caller guards. */
433
+ return (globalThis as { EyeDropper?: new () => EyeDropperApi }).EyeDropper;
399
434
  }
400
435
 
401
436
  /**
@@ -410,7 +445,7 @@ export function eyeDropperAvailable(offered: unknown): boolean {
410
445
  */
411
446
  export function onEyeDropper(state: ColorFieldState, event: Event): Promise<void> {
412
447
  const host = ownerOf(event);
413
- const Api = (globalThis as { EyeDropper?: new () => EyeDropperApi }).EyeDropper;
448
+ const Api = eyeDropperOf();
414
449
  if (!host || !Api || state.disabled === true) {
415
450
  return Promise.resolve();
416
451
  }
@@ -459,7 +494,7 @@ export function onFieldMount(state: ColorFieldState, event: Event): void {
459
494
  const panel = panelOf(host);
460
495
  if (panel) {
461
496
  panel.addEventListener("toggle", (toggle: Event) => {
462
- state.expanded = (toggle as Event & { newState?: string }).newState === "open";
497
+ state.expanded = "newState" in toggle && toggle.newState === "open";
463
498
  });
464
499
  }
465
500
  }
@@ -24,7 +24,7 @@
24
24
  * @docs extending/ui-kit
25
25
  */
26
26
 
27
- import { snapToStep } from "../color.ts";
27
+ import { ARROW_SIGN, snapToStep } from "../color.ts";
28
28
 
29
29
  /** The tag this sidecar belongs to. */
30
30
  const HOST = "jx-color-slider";
@@ -41,16 +41,14 @@ export interface ColorSliderState {
41
41
  step: number;
42
42
  /** Whether the element refuses to move. */
43
43
  disabled: boolean;
44
- [key: string]: unknown;
45
44
  }
46
45
 
47
- /** Which way each arrow moves the value. */
48
- const KEY_SIGN: Readonly<Record<string, number>> = {
49
- ArrowDown: -1,
50
- ArrowLeft: -1,
51
- ArrowRight: 1,
52
- ArrowUp: 1,
53
- };
46
+ /** The track's resolved range and grid, every member a finite number. */
47
+ interface SliderBounds {
48
+ min: number;
49
+ max: number;
50
+ step: number;
51
+ }
54
52
 
55
53
  /** The `jx-color-slider` an event is being handled on. */
56
54
  function hostOf(event: Event): HTMLElement | null {
@@ -79,7 +77,7 @@ function announce(host: HTMLElement, ...names: string[]): void {
79
77
  * is what the inner range's own `max` attribute is bound to, so the platform's clamp and this one
80
78
  * cannot drift apart.
81
79
  */
82
- function boundsOf(state: ColorSliderState): { min: number; max: number; step: number } {
80
+ function boundsOf(state: ColorSliderState): SliderBounds {
83
81
  const min = Number.isFinite(Number(state.min)) ? Number(state.min) : 0;
84
82
  const top = Number(state.maxValue);
85
83
  const step = Number(state.step);
@@ -104,6 +102,8 @@ export function onSliderInput(state: ColorSliderState, event: Event): void {
104
102
  return;
105
103
  }
106
104
  event.stopPropagation();
105
+ /* SAFETY: the element's only form control is its `input[part="input"]` range, so an `input` event
106
+ reaching the host from anything other than the host itself was fired by that range. */
107
107
  state.value = Number((event.target as HTMLInputElement).value);
108
108
  announce(host, "input");
109
109
  }
@@ -120,6 +120,8 @@ export function onSliderChange(state: ColorSliderState, event: Event): void {
120
120
  return;
121
121
  }
122
122
  event.stopPropagation();
123
+ /* SAFETY: the element's only form control is its `input[part="input"]` range, so a `change` event
124
+ reaching the host from anything other than the host itself was fired by that range. */
123
125
  state.value = Number((event.target as HTMLInputElement).value);
124
126
  announce(host, "change");
125
127
  }
@@ -132,7 +134,7 @@ export function onSliderChange(state: ColorSliderState, event: Event): void {
132
134
  */
133
135
  export function onSliderKeydown(state: ColorSliderState, event: KeyboardEvent): void {
134
136
  const host = hostOf(event);
135
- const sign = KEY_SIGN[event.key];
137
+ const sign = ARROW_SIGN.get(event.key);
136
138
  if (!host || !sign || !event.shiftKey || state.disabled === true) {
137
139
  return;
138
140
  }
@@ -49,6 +49,7 @@
49
49
  */
50
50
 
51
51
  import { close, openAt } from "./popover.ts";
52
+ import type { PlacedPopover } from "./popover.ts";
52
53
 
53
54
  /** A row as the element draws it. */
54
55
  export interface ComboboxRow {
@@ -84,7 +85,6 @@ export interface ComboboxState {
84
85
  disabled?: boolean;
85
86
  readonly?: boolean;
86
87
  uid?: string;
87
- [key: string]: unknown;
88
88
  }
89
89
 
90
90
  const HOST = "jx-combobox";
@@ -103,9 +103,9 @@ export function inputOf(host: HTMLElement): HTMLInputElement | null {
103
103
  return host.querySelector<HTMLInputElement>('input[part="input"]');
104
104
  }
105
105
 
106
- /** The element's own panel. */
107
- function panelOf(host: HTMLElement): HTMLElement | null {
108
- return host.querySelector<HTMLElement>('[part="popup"]');
106
+ /** The element's own panel: the `jx-popover` jx-combobox.json renders as its `popup` part. */
107
+ function panelOf(host: HTMLElement): PlacedPopover | null {
108
+ return host.querySelector<PlacedPopover>('[part="popup"]');
109
109
  }
110
110
 
111
111
  /** The rows the element was handed, whatever it was handed. */
@@ -146,9 +146,8 @@ export function openList(host: HTMLElement): void {
146
146
  return;
147
147
  }
148
148
  const box = host.getBoundingClientRect();
149
- const placed = panel as HTMLElement & { x: number; y: number };
150
- placed.x = Math.round(box.left);
151
- placed.y = Math.round(box.bottom);
149
+ panel.x = Math.round(box.left);
150
+ panel.y = Math.round(box.bottom);
152
151
  openAt(panel, host);
153
152
  }
154
153
 
@@ -295,7 +294,7 @@ export function onComboboxInput(state: ComboboxState, event: Event): void {
295
294
  * @param event The platform's ToggleEvent, from the panel.
296
295
  */
297
296
  export function onComboboxToggle(state: ComboboxState, event: Event): void {
298
- const opening = (event as { newState?: string }).newState === "open";
297
+ const opening = "newState" in event && event.newState === "open";
299
298
  state.open = opening;
300
299
  if (!opening) {
301
300
  state.activeIndex = -1;
@@ -332,7 +331,7 @@ export function onComboboxButton(state: ComboboxState, event: Event): void {
332
331
  */
333
332
  export function onComboboxPick(state: ComboboxState, event: Event): void {
334
333
  const host = comboboxOf(event);
335
- const picked = String((event as CustomEvent<unknown>).detail ?? "");
334
+ const picked = "detail" in event ? String(event.detail ?? "") : "";
336
335
  const row = rowFor(rowsOf(state), picked);
337
336
  if (!host || !row) {
338
337
  return;
@@ -79,7 +79,7 @@ export function close(host: HTMLElement, returnValue?: string): void {
79
79
 
80
80
  /** The platform's `toggle` on the inner dialog: the one source of truth for `open`. */
81
81
  export function onToggle(state: DialogState, event: Event): void {
82
- state.open = (event as { newState?: string }).newState === "open";
82
+ state.open = "newState" in event && event.newState === "open";
83
83
  }
84
84
 
85
85
  /**
@@ -106,7 +106,7 @@ export function onNativeClose(state: DialogState, event: Event): void {
106
106
  */
107
107
  export function onCommand(_state: DialogState, event: Event): void {
108
108
  const host = hostOf(event);
109
- const command = (event as { command?: string }).command ?? "";
109
+ const command = "command" in event ? event.command : undefined;
110
110
  if (!host) {
111
111
  return;
112
112
  }
@@ -31,6 +31,8 @@
31
31
  * @docs extending/ui-kit
32
32
  */
33
33
 
34
+ import { isFunction } from "@jxsuite/schema/guards";
35
+
34
36
  /** A row element with the property accessors its own document installs. */
35
37
  type OptionElement = HTMLElement & { selected?: boolean; disabled?: boolean; value?: string };
36
38
 
@@ -38,7 +40,6 @@ type OptionElement = HTMLElement & { selected?: boolean; disabled?: boolean; val
38
40
  export interface ListboxState {
39
41
  /** The id of the row the caret is on; the empty string when none is. */
40
42
  active?: string;
41
- [key: string]: unknown;
42
43
  }
43
44
 
44
45
  const ROW = "jx-option";
@@ -81,7 +82,7 @@ export function syncListbox(host: HTMLElement, active: string): OptionElement |
81
82
  row.selected = wanted;
82
83
  }
83
84
  }
84
- if (landed && moved && typeof landed.scrollIntoView === "function") {
85
+ if (landed && moved && isFunction(landed.scrollIntoView)) {
85
86
  landed.scrollIntoView({ block: "nearest" });
86
87
  }
87
88
  return landed;
@@ -125,7 +126,7 @@ export function mountListbox(state: ListboxState, host: HTMLElement): void {
125
126
  });
126
127
  };
127
128
  const observer = new MutationObserver(() => {
128
- if (typeof requestAnimationFrame === "function") {
129
+ if (isFunction(globalThis.requestAnimationFrame)) {
129
130
  scheduleSync();
130
131
  } else {
131
132
  syncListbox(host, String(state.active ?? ""));
@@ -24,6 +24,8 @@
24
24
  * @docs extending/ui-kit
25
25
  */
26
26
 
27
+ import { isFunction } from "@jxsuite/schema/guards";
28
+
27
29
  import { anchorOf, clampIntoViewport, openAt, supportsAnchorPositioning } from "./popover.ts";
28
30
 
29
31
  export { clampIntoViewport } from "./popover.ts";
@@ -43,7 +45,6 @@ export interface MenuState {
43
45
  area?: string;
44
46
  /** Whether the platform is placing this panel against the element it was shown from. */
45
47
  anchored?: boolean;
46
- [key: string]: unknown;
47
48
  }
48
49
 
49
50
  /** Where a submenu sits when its document says nothing: beside its row, extending downward. */
@@ -57,9 +58,7 @@ type MenuElement = HTMLElement & { open?: boolean; x?: number; y?: number; floor
57
58
  /** The menu an event's `currentTarget` is. */
58
59
  function menuOf(event: Event): MenuElement | null {
59
60
  const target = event.currentTarget;
60
- return target instanceof HTMLElement && target.localName === MENU
61
- ? (target as MenuElement)
62
- : null;
61
+ return target instanceof HTMLElement && target.localName === MENU ? target : null;
63
62
  }
64
63
 
65
64
  /**
@@ -99,11 +98,11 @@ export function submenuOf(row: HTMLElement): MenuElement | null {
99
98
 
100
99
  /** The row a submenu is slotted into, if it is a submenu. */
101
100
  export function parentRowOf(menu: HTMLElement): RowElement | null {
102
- return (menu.parentElement?.closest(ROW) as RowElement | null) ?? null;
101
+ return menu.parentElement?.closest<RowElement>(ROW) ?? null;
103
102
  }
104
103
 
105
104
  /** The outermost menu of a stack. */
106
- export function rootMenuOf(menu: HTMLElement): HTMLElement {
105
+ export function rootMenuOf(menu: HTMLElement): MenuElement {
107
106
  let root = menu;
108
107
  for (;;) {
109
108
  const row = parentRowOf(root);
@@ -140,7 +139,7 @@ export function showMenu(menu: MenuElement, source?: Element | null): void {
140
139
 
141
140
  /** Hide a menu panel through the platform, if it is showing. */
142
141
  export function hideMenu(menu: MenuElement): void {
143
- if (isShowing(menu) && typeof menu.hidePopover === "function") {
142
+ if (isShowing(menu) && isFunction(menu.hidePopover)) {
144
143
  menu.hidePopover();
145
144
  }
146
145
  }
@@ -201,7 +200,7 @@ function closeSubmenus(menu: HTMLElement, except: RowElement | null = null): voi
201
200
 
202
201
  /** A row's label text: its label part, else all of its text. */
203
202
  function rowLabel(row: HTMLElement): string {
204
- return row.querySelector('[part="label"]')?.textContent ?? row.textContent ?? "";
203
+ return row.querySelector('[part="label"]')?.textContent ?? row.textContent;
205
204
  }
206
205
 
207
206
  /** The next row after `active` whose label starts with `char`, wrapping; -1 when none does. */
@@ -285,7 +284,7 @@ export function onMenuKeydown(_state: MenuState, event: KeyboardEvent): void {
285
284
  break;
286
285
  }
287
286
  case "Tab": {
288
- hideMenu(rootMenuOf(menu) as MenuElement);
287
+ hideMenu(rootMenuOf(menu));
289
288
  break;
290
289
  }
291
290
  default: {
@@ -321,10 +320,10 @@ export function onMenuBeforeToggle(state: MenuState, event: Event): void {
321
320
  if (!menu) {
322
321
  return;
323
322
  }
324
- const opening = (event as { newState?: string }).newState === "open";
323
+ const opening = "newState" in event && event.newState === "open";
325
324
  const area = String(state.placement ?? "") || (parentRowOf(menu) ? SUBMENU_PLACEMENT : "");
326
325
  state.area = area;
327
- const { source } = event as Event & { source?: unknown };
326
+ const source = "source" in event ? event.source : null;
328
327
  const from = source instanceof Element ? source : anchorOf(menu);
329
328
  state.anchored = opening && area !== "" && from !== null && !hasFloor(menu);
330
329
  }
@@ -342,7 +341,7 @@ export function onMenuToggle(state: MenuState, event: Event): void {
342
341
  if (!menu) {
343
342
  return;
344
343
  }
345
- const opening = (event as { newState?: string }).newState === "open";
344
+ const opening = "newState" in event && event.newState === "open";
346
345
  state.open = opening;
347
346
  const parent = parentRowOf(menu);
348
347
  if (!opening) {
@@ -365,12 +364,12 @@ export function onMenuToggle(state: MenuState, event: Event): void {
365
364
 
366
365
  /** Whether the stack's root declares a floor at all. */
367
366
  function hasFloor(menu: HTMLElement): boolean {
368
- return Number((rootMenuOf(menu) as MenuElement).floor ?? 0) > 0;
367
+ return Number(rootMenuOf(menu).floor ?? 0) > 0;
369
368
  }
370
369
 
371
370
  /** The lowest edge a stack may reach: the root menu's `floor`, else the viewport's bottom. */
372
371
  function floorOf(menu: HTMLElement): number {
373
- const floor = Number((rootMenuOf(menu) as MenuElement).floor ?? 0);
372
+ const floor = Number(rootMenuOf(menu).floor ?? 0);
374
373
  return floor > 0 ? floor : window.innerHeight;
375
374
  }
376
375
 
@@ -385,7 +384,7 @@ export function ensureCaret(menu: HTMLElement): void {
385
384
  if (menu.contains(document.activeElement)) {
386
385
  return;
387
386
  }
388
- let deepest = menu as MenuElement;
387
+ let deepest: MenuElement = menu;
389
388
  for (;;) {
390
389
  const open = rowsOf(deepest, { includeDisabled: true })
391
390
  .map((row) => submenuOf(row))