@jxsuite/ui 0.5.1 → 0.7.1

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.
@@ -45,6 +45,8 @@
45
45
  * @docs extending/ui-kit
46
46
  */
47
47
 
48
+ import { isFunction } from "@jxsuite/schema/guards";
49
+
48
50
  /** The reactive scope a popover-shaped document hands its handlers. */
49
51
  export interface PopoverState {
50
52
  open?: boolean;
@@ -56,7 +58,6 @@ export interface PopoverState {
56
58
  x?: number;
57
59
  y?: number;
58
60
  floor?: number;
59
- [key: string]: unknown;
60
61
  }
61
62
 
62
63
  /**
@@ -66,28 +67,23 @@ export interface PopoverState {
66
67
  * stub it either way.
67
68
  */
68
69
  export function supportsAnchorPositioning(): boolean {
69
- const css = (globalThis as { CSS?: { supports?: (property: string, value: string) => boolean } })
70
- .CSS;
71
- return typeof css?.supports === "function" && css.supports("position-area", "block-end");
72
- }
73
-
74
- /**
75
- * The popover API as this module calls it.
76
- *
77
- * Declared here rather than reached for through the DOM types because an engine may not have it at
78
- * all — the kit publishes to site authors, and every call site guards on `typeof`.
79
- */
80
- interface PopoverApi {
81
- showPopover?: (options?: { source?: HTMLElement }) => void;
82
- hidePopover?: () => void;
70
+ return (
71
+ typeof CSS !== "undefined" &&
72
+ isFunction(CSS.supports) &&
73
+ CSS.supports("position-area", "block-end")
74
+ );
83
75
  }
84
76
 
85
- /** An element that may be a popover: every method the behaviour calls is optional. */
86
- export type PopoverElement = HTMLElement;
77
+ /** A `jx-popover` element, with the `x` and `y` coordinates its document declares as props. */
78
+ export type PlacedPopover = HTMLElement & { x: number; y: number };
87
79
 
88
- /** The popover API of an element, whether or not this engine implements it. */
89
- function apiOf(host: HTMLElement): PopoverApi {
90
- return host as unknown as PopoverApi;
80
+ /** Run `callback` in the next frame, or now on an engine with no frames to wait for. */
81
+ function inFrame(callback: () => void): void {
82
+ if (isFunction(globalThis.requestAnimationFrame)) {
83
+ requestAnimationFrame(callback);
84
+ } else {
85
+ callback();
86
+ }
91
87
  }
92
88
 
93
89
  /**
@@ -126,13 +122,12 @@ export function anchorOf(host: HTMLElement): Element | null {
126
122
  * @param host The panel element.
127
123
  * @param anchor The element the panel was opened from, if any.
128
124
  */
129
- export function openAt(host: PopoverElement, anchor?: Element | null): void {
130
- const api = apiOf(host);
131
- if (typeof api.showPopover !== "function") {
125
+ export function openAt(host: HTMLElement, anchor?: Element | null): void {
126
+ if (!isFunction(host.showPopover)) {
132
127
  return;
133
128
  }
134
129
  if (!anchor) {
135
- api.showPopover();
130
+ host.showPopover();
136
131
  return;
137
132
  }
138
133
  /* BEFORE the call, because `beforetoggle` fires inside it and {@link onBeforeToggle} reads the
@@ -141,7 +136,7 @@ export function openAt(host: PopoverElement, anchor?: Element | null): void {
141
136
  open. */
142
137
  anchors.set(host, anchor);
143
138
  try {
144
- show(api, anchor);
139
+ show(host, anchor);
145
140
  } catch (error) {
146
141
  anchors.delete(host);
147
142
  throw error;
@@ -157,16 +152,16 @@ export function openAt(host: PopoverElement, anchor?: Element | null): void {
157
152
  * that one bare throws the same error a second time, out of `openAt` and into whatever bound the
158
153
  * handler. So it is re-raised from the FIRST call, unswallowed.
159
154
  */
160
- function show(api: PopoverApi, anchor: Element): void {
155
+ function show(host: HTMLElement, anchor: Element): void {
161
156
  try {
162
- /* `ShowPopoverOptions.source` is typed `HTMLElement`, but any element can be a
157
+ /* SAFETY: `ShowPopoverOptions.source` is typed `HTMLElement`, but any element can be a
163
158
  trigger and the platform only reads it as a focus origin. */
164
- api.showPopover?.({ source: anchor as HTMLElement });
159
+ host.showPopover({ source: anchor as HTMLElement });
165
160
  } catch (error) {
166
161
  if (!(error instanceof TypeError)) {
167
162
  throw error;
168
163
  }
169
- api.showPopover?.();
164
+ host.showPopover();
170
165
  }
171
166
  }
172
167
 
@@ -175,10 +170,9 @@ function show(api: PopoverApi, anchor: Element): void {
175
170
  *
176
171
  * @param host The panel element.
177
172
  */
178
- export function close(host: PopoverElement): void {
179
- const api = apiOf(host);
180
- if (typeof api.hidePopover === "function") {
181
- api.hidePopover();
173
+ export function close(host: HTMLElement): void {
174
+ if (isFunction(host.hidePopover)) {
175
+ host.hidePopover();
182
176
  }
183
177
  }
184
178
 
@@ -210,7 +204,7 @@ function invokerOf(host: HTMLElement): Element | null {
210
204
  * {@link openAt} recorded, then the invoker that names the panel.
211
205
  */
212
206
  function toggleSourceOf(host: HTMLElement, event: Event): Element | null {
213
- const { source } = event as Event & { source?: unknown };
207
+ const source = "source" in event ? event.source : null;
214
208
  if (source instanceof Element) {
215
209
  return source;
216
210
  }
@@ -250,16 +244,12 @@ export function measureAnchor(
250
244
  */
251
245
  function clearWhenStill(state: PopoverState, host: HTMLElement): void {
252
246
  const settle = (): void => {
253
- if (typeof host.getAnimations === "function" && host.getAnimations().length > 0) {
247
+ if (isFunction(host.getAnimations) && host.getAnimations().length > 0) {
254
248
  return;
255
249
  }
256
250
  state.settling = false;
257
251
  };
258
- if (typeof requestAnimationFrame === "function") {
259
- requestAnimationFrame(settle);
260
- } else {
261
- settle();
262
- }
252
+ inFrame(settle);
263
253
  }
264
254
 
265
255
  /**
@@ -278,7 +268,7 @@ export function onBeforeToggle(state: PopoverState, event: Event): void {
278
268
  if (!host) {
279
269
  return;
280
270
  }
281
- const opening = (event as { newState?: string }).newState === "open";
271
+ const opening = "newState" in event && event.newState === "open";
282
272
  const floor = Number(state.floor ?? 0);
283
273
  state.anchored = opening && floor <= 0 && toggleSourceOf(host, event) !== null;
284
274
  }
@@ -297,7 +287,7 @@ export function onToggle(state: PopoverState, event: Event): void {
297
287
  if (!host) {
298
288
  return;
299
289
  }
300
- const opening = (event as { newState?: string }).newState === "open";
290
+ const opening = "newState" in event && event.newState === "open";
301
291
  state.open = opening;
302
292
  state.settling = true;
303
293
  if (opening) {
@@ -391,9 +381,5 @@ export function clampIntoViewport(
391
381
  state.y = Math.max(margin, Math.round(floor - box.height - blockOffsetOf(panel)));
392
382
  }
393
383
  };
394
- if (typeof requestAnimationFrame === "function") {
395
- requestAnimationFrame(measure);
396
- } else {
397
- measure();
398
- }
384
+ inFrame(measure);
399
385
  }
@@ -38,12 +38,25 @@
38
38
  * @docs extending/ui-kit
39
39
  */
40
40
 
41
- /** A row as the element draws it. Only `value` and `label` are ever read here. */
42
- interface UnlistedRow {
41
+ /**
42
+ * A row as the element draws it: the value the control reports back, and the label the reader sees.
43
+ * Only `value` and `label` are ever read here.
44
+ */
45
+ export interface SelectRow {
43
46
  value: string;
44
47
  label: string;
45
48
  }
46
49
 
50
+ /** The reactive scope a `jx-select` document hands its sidecar. */
51
+ export interface SelectState {
52
+ /** The value the element holds; null when a host clears the reflected `value` attribute. */
53
+ value?: string | null;
54
+ /** The stand-in row for a value no listed row holds: empty, or exactly one row. */
55
+ unlisted?: SelectRow[];
56
+ /** This element's own id stem, minted at mount. */
57
+ uid?: string;
58
+ }
59
+
47
60
  /** How many stems have been minted, so no two instances share one. */
48
61
  let minted = 0;
49
62
 
@@ -89,17 +102,17 @@ function isListed(sel: HTMLSelectElement, want: string): boolean {
89
102
  * @param state The element's reactive scope.
90
103
  * @param sel The element's own `<select>`.
91
104
  */
92
- export function syncSelect(state: Record<string, unknown>, sel: HTMLSelectElement): void {
93
- const want = String(state["value"] ?? "");
94
- const rows = (state["unlisted"] as UnlistedRow[] | undefined) ?? [];
105
+ export function syncSelect(state: SelectState, sel: HTMLSelectElement): void {
106
+ const want = String(state.value ?? "");
107
+ const rows = state.unlisted ?? [];
95
108
  /* The empty string is a legal value — the inherit row — rather than absence, so it is never
96
109
  stood in for: a blank row labelled with a blank value would say nothing about what is held. */
97
110
  if (want !== "" && !isListed(sel, want)) {
98
111
  if (rows.length !== 1 || rows[0]?.value !== want) {
99
- state["unlisted"] = [{ label: want, value: want }];
112
+ state.unlisted = [{ label: want, value: want }];
100
113
  }
101
114
  } else if (rows.length > 0) {
102
- state["unlisted"] = [];
115
+ state.unlisted = [];
103
116
  }
104
117
  if ([...sel.options].some((option) => option.value === want)) {
105
118
  if (sel.value !== want || sel.selectedIndex < 0) {
@@ -120,9 +133,9 @@ export function syncSelect(state: Record<string, unknown>, sel: HTMLSelectElemen
120
133
  * @param state The element's reactive scope, whose `uid` and `unlisted` this writes.
121
134
  * @param host The `jx-select` element.
122
135
  */
123
- export function mountSelect(state: Record<string, unknown>, host: HTMLElement): void {
136
+ export function mountSelect(state: SelectState, host: HTMLElement): void {
124
137
  minted += 1;
125
- state["uid"] = `jx-select-${minted}`;
138
+ state.uid = `jx-select-${minted}`;
126
139
  const sel = controlOf(host);
127
140
  if (!sel) {
128
141
  return;
@@ -49,6 +49,9 @@
49
49
  * @docs extending/ui-kit
50
50
  */
51
51
 
52
+ import { isFunction, isNonNullObject } from "@jxsuite/schema/guards";
53
+ import type { JsonValue } from "@jxsuite/schema/types";
54
+
52
55
  /** The tag this sidecar belongs to. */
53
56
  const HOST = "jx-split";
54
57
 
@@ -79,7 +82,6 @@ export interface SplitState {
79
82
  dragging: boolean;
80
83
  /** The position the toggle returns to; negative until a collapse has recorded one. */
81
84
  restore: number;
82
- [key: string]: unknown;
83
85
  }
84
86
 
85
87
  /**
@@ -101,14 +103,16 @@ const NO_MODIFIERS: Readonly<SplitModifiers> = Object.freeze({
101
103
  shiftKey: false,
102
104
  });
103
105
 
104
- /** The four modifier flags off an event, each `true` only when it is exactly `true`. */
105
- function modifiersOf(event: object): SplitModifiers {
106
- const flags = event as Partial<Record<keyof SplitModifiers, unknown>>;
106
+ /**
107
+ * The four modifier flags off a pointer or key event, each `true` only when it is exactly `true`,
108
+ * because a synthetic event a test or a driver builds may carry none of them.
109
+ */
110
+ function modifiersOf(event: Partial<SplitModifiers>): SplitModifiers {
107
111
  return {
108
- altKey: flags.altKey === true,
109
- ctrlKey: flags.ctrlKey === true,
110
- metaKey: flags.metaKey === true,
111
- shiftKey: flags.shiftKey === true,
112
+ altKey: event.altKey === true,
113
+ ctrlKey: event.ctrlKey === true,
114
+ metaKey: event.metaKey === true,
115
+ shiftKey: event.shiftKey === true,
112
116
  };
113
117
  }
114
118
 
@@ -123,20 +127,34 @@ function modifiersOf(event: object): SplitModifiers {
123
127
  * @param event The `input` or `change`.
124
128
  */
125
129
  export function splitModifiersOf(event: Event): SplitModifiers {
126
- const { detail } = event as { detail?: unknown };
127
- return detail !== null && typeof detail === "object" ? modifiersOf(detail) : { ...NO_MODIFIERS };
130
+ const detail = "detail" in event ? event.detail : undefined;
131
+ if (!isNonNullObject(detail)) {
132
+ return { ...NO_MODIFIERS };
133
+ }
134
+ /* A detail is whatever its dispatcher put there, so each flag is probed by name rather than
135
+ trusted to be a boolean: only an exact `true` counts, as it does off an event. */
136
+ return {
137
+ altKey: "altKey" in detail && detail.altKey === true,
138
+ ctrlKey: "ctrlKey" in detail && detail.ctrlKey === true,
139
+ metaKey: "metaKey" in detail && detail.metaKey === true,
140
+ shiftKey: "shiftKey" in detail && detail.shiftKey === true,
141
+ };
142
+ }
143
+
144
+ /** The range the value may take, and the track length it was converted against. */
145
+ interface SplitBounds {
146
+ /** The track's length along the dragged axis, in pixels. */
147
+ length: number;
148
+ lo: number;
149
+ hi: number;
128
150
  }
129
151
 
130
- /** One live gesture: where it started, and the bounds it was measured against. */
131
- interface Drag {
152
+ /** One live gesture: where it started, and the bounds it was measured against, once. */
153
+ interface Drag extends SplitBounds {
132
154
  /** The pointer coordinate at `pointerdown`, along the dragged axis. */
133
155
  origin: number;
134
156
  /** The value at `pointerdown`. */
135
157
  start: number;
136
- /** The track's length along that axis, measured once. */
137
- length: number;
138
- lo: number;
139
- hi: number;
140
158
  }
141
159
 
142
160
  /**
@@ -167,13 +185,13 @@ function announce(host: HTMLElement, modifiers: SplitModifiers, ...names: string
167
185
  }
168
186
 
169
187
  /** A number, or the fallback when the scope holds something that is not one. */
170
- function finiteOr(value: unknown, fallback: number): number {
188
+ function finiteOr(value: JsonValue | undefined, fallback: number): number {
171
189
  const n = Number(value);
172
190
  return Number.isFinite(n) ? n : fallback;
173
191
  }
174
192
 
175
193
  /** A POSITIVE number, or the fallback. A step of zero is a key that does nothing. */
176
- function positiveOr(value: unknown, fallback: number): number {
194
+ function positiveOr(value: JsonValue | undefined, fallback: number): number {
177
195
  const n = Number(value);
178
196
  return Number.isFinite(n) && n > 0 ? n : fallback;
179
197
  }
@@ -262,10 +280,7 @@ export function trackOf(host: HTMLElement, vertical: boolean): number {
262
280
  * @param state The element's reactive scope.
263
281
  * @param host The element.
264
282
  */
265
- function boundsOf(
266
- state: SplitState,
267
- host: HTMLElement,
268
- ): { lo: number; hi: number; length: number } {
283
+ function boundsOf(state: SplitState, host: HTMLElement): SplitBounds {
269
284
  const length = trackOf(host, isVertical(state));
270
285
  const min = finiteOr(state.min, 0);
271
286
  const max = finiteOr(state.max, 1);
@@ -373,7 +388,7 @@ export function onSplitPointerDown(state: SplitState, event: PointerEvent): void
373
388
  origin: coordOf(isVertical(state), event),
374
389
  start: Number(state.value),
375
390
  });
376
- if (typeof host.setPointerCapture === "function") {
391
+ if (isFunction(host.setPointerCapture)) {
377
392
  /* Capture on the HOST, which is also the element the listener is on: a drag that runs off the
378
393
  end of the splitter keeps arriving here instead of stopping at whatever it crossed. */
379
394
  try {
@@ -436,8 +451,9 @@ export function onSplitPointerUp(state: SplitState, event: PointerEvent): void {
436
451
  }
437
452
  state.dragging = false;
438
453
  if (
439
- typeof host.releasePointerCapture === "function" &&
440
- host.hasPointerCapture?.(event.pointerId)
454
+ isFunction(host.releasePointerCapture) &&
455
+ isFunction(host.hasPointerCapture) &&
456
+ host.hasPointerCapture(event.pointerId)
441
457
  ) {
442
458
  host.releasePointerCapture(event.pointerId);
443
459
  }
@@ -38,7 +38,6 @@ export interface SwatchGroupState {
38
38
  value?: string;
39
39
  /** The group's accessible name. */
40
40
  label?: string;
41
- [key: string]: unknown;
42
41
  }
43
42
 
44
43
  /** A swatch element with the property accessors its own document installs. */
@@ -67,9 +66,7 @@ const watched = new WeakMap<HTMLElement, MutationObserver>();
67
66
  /** The group an event's `currentTarget` is. */
68
67
  function hostOf(event: Event): GroupElement | null {
69
68
  const target = event.currentTarget;
70
- return target instanceof HTMLElement && target.localName === GROUP
71
- ? (target as GroupElement)
72
- : null;
69
+ return target instanceof HTMLElement && target.localName === GROUP ? target : null;
73
70
  }
74
71
 
75
72
  /** Whether `group` is the nearest group around `node` — a key inside a nested group is its own. */
@@ -147,12 +144,12 @@ export function focusSwatch(group: HTMLElement, index: number): SwatchElement |
147
144
  *
148
145
  * @param group The `jx-swatch-group` element.
149
146
  */
150
- export function syncSwatches(group: HTMLElement): void {
147
+ export function syncSwatches(group: GroupElement): void {
151
148
  const swatches = swatchesOf(group);
152
149
  if (swatches.length === 0) {
153
150
  return;
154
151
  }
155
- const value = String((group as GroupElement).value ?? "");
152
+ const value = String(group.value ?? "");
156
153
  const enabled = swatches.filter((swatch) => !isDisabled(swatch));
157
154
  const chosen = enabled.find((swatch) => valueOf(swatch) === value);
158
155
  const caret = chosen ?? enabled[0] ?? null;
@@ -233,7 +230,7 @@ export function onSwatchGroupSelect(scope: SwatchGroupState, event: Event): void
233
230
  return;
234
231
  }
235
232
  event.stopPropagation();
236
- const value = String((event as CustomEvent<unknown>).detail ?? "");
233
+ const value = "detail" in event ? String(event.detail ?? "") : "";
237
234
  if (value === String(scope.value ?? "")) {
238
235
  return;
239
236
  }
@@ -33,7 +33,6 @@ export interface TabsState {
33
33
  label?: string;
34
34
  activation?: string;
35
35
  orientation?: string;
36
- [key: string]: unknown;
37
36
  }
38
37
 
39
38
  /** A tab element with the property accessors its document installs. */
@@ -87,9 +86,7 @@ function watchTabs(host: HTMLElement): void {
87
86
  /** The tablist an event's `currentTarget` is. */
88
87
  function hostOf(event: Event): TabsElement | null {
89
88
  const target = event.currentTarget;
90
- return target instanceof HTMLElement && target.localName === TABS
91
- ? (target as TabsElement)
92
- : null;
89
+ return target instanceof HTMLElement && target.localName === TABS ? target : null;
93
90
  }
94
91
 
95
92
  /** Whether `host` is the nearest tablist around `node` — a nested strip owns its own keys. */
@@ -146,12 +143,12 @@ export function focusTab(tabs: TabElement[], index: number): TabElement | null {
146
143
  *
147
144
  * @param host The `jx-tabs` element.
148
145
  */
149
- export function syncTabs(host: HTMLElement): void {
146
+ export function syncTabs(host: TabsElement): void {
150
147
  const tabs = tabsOf(host);
151
148
  if (tabs.length === 0) {
152
149
  return;
153
150
  }
154
- const selected = String((host as TabsElement).selected ?? "");
151
+ const selected = String(host.selected ?? "");
155
152
  const found = tabs.findIndex((tab) => valueOf(tab) === selected);
156
153
  const caret = found === -1 ? 0 : found;
157
154
  for (const [at, tab] of tabs.entries()) {
@@ -228,7 +225,7 @@ export function onTabsSelect(scope: TabsState, event: Event): void {
228
225
  return;
229
226
  }
230
227
  event.stopPropagation();
231
- const value = String((event as CustomEvent<unknown>).detail ?? "");
228
+ const value = "detail" in event ? String(event.detail ?? "") : "";
232
229
  if (value === String(scope.selected ?? "")) {
233
230
  return;
234
231
  }
@@ -12,6 +12,23 @@
12
12
  * @docs extending/ui-kit
13
13
  */
14
14
 
15
+ /** The reactive scope a `jx-textfield` document hands its sidecar. */
16
+ export interface TextfieldState {
17
+ /** The field's text. */
18
+ value?: string;
19
+ /** This element's own id stem, minted at mount. */
20
+ uid?: string;
21
+ }
22
+
23
+ /** The reactive scope a `jx-field` row hands its sidecar. */
24
+ export interface FieldRowState {
25
+ /** This row's own id stem, minted at mount. */
26
+ uid?: string;
27
+ }
28
+
29
+ /** A kit control a `jx-field` row names, through the properties its definition observes. */
30
+ type NameableControl = Element & { labelledby?: string; describedby?: string };
31
+
15
32
  /** The element's own native control. */
16
33
  function controlOf(host: HTMLElement): HTMLInputElement | HTMLTextAreaElement | null {
17
34
  return host.querySelector<HTMLInputElement | HTMLTextAreaElement>('[part="input"]');
@@ -63,12 +80,12 @@ function hostOf(event: Event): HTMLElement | null {
63
80
  * @param state The element's reactive scope, whose `value` this empties.
64
81
  * @param event The click on the clear button.
65
82
  */
66
- export function clearField(state: Record<string, unknown>, event: Event): void {
83
+ export function clearField(state: TextfieldState, event: Event): void {
67
84
  const host = hostOf(event);
68
85
  if (!host) {
69
86
  return;
70
87
  }
71
- state["value"] = "";
88
+ state.value = "";
72
89
  focusField(host);
73
90
  host.dispatchEvent(new Event("input", { bubbles: true }));
74
91
  host.dispatchEvent(new Event("change", { bubbles: true }));
@@ -85,19 +102,24 @@ let minted = 0;
85
102
  *
86
103
  * @param state The element's reactive scope, whose `uid` this writes.
87
104
  */
88
- export function mintFieldId(state: Record<string, unknown>): void {
105
+ export function mintFieldId(state: TextfieldState): void {
89
106
  minted += 1;
90
- state["uid"] = `jx-tf-${minted}`;
107
+ state.uid = `jx-tf-${minted}`;
91
108
  }
92
109
 
93
110
  let namedRows = 0;
94
111
 
95
112
  /** Whether an element's DEFINITION says it can be named — that it observes `labelledby`. */
96
113
  function observesNaming(el: Element): boolean {
97
- const def = customElements.get(el.localName) as
98
- | { observedAttributes?: readonly string[] }
99
- | undefined;
100
- return def?.observedAttributes?.includes("labelledby") === true;
114
+ const def = customElements.get(el.localName);
115
+ /* `observedAttributes` is a static member the platform reads off the constructor, which the DOM
116
+ types do not declare, so it is probed by name and shape. */
117
+ return (
118
+ def !== undefined &&
119
+ "observedAttributes" in def &&
120
+ Array.isArray(def.observedAttributes) &&
121
+ def.observedAttributes.includes("labelledby")
122
+ );
101
123
  }
102
124
 
103
125
  /**
@@ -141,24 +163,23 @@ function observesNaming(el: Element): boolean {
141
163
  * @param state The row's reactive scope, whose `uid` this writes.
142
164
  * @param host The `jx-field` element, whose slotted control this names.
143
165
  */
144
- export function nameFieldControl(state: Record<string, unknown>, host: HTMLElement): void {
166
+ export function nameFieldControl(state: FieldRowState, host: HTMLElement): void {
145
167
  namedRows += 1;
146
168
  const uid = `jx-field-${namedRows}`;
147
- state["uid"] = uid;
169
+ state.uid = uid;
148
170
 
149
171
  /* The row's OWN parts, found among its children rather than by a descendant query. Both spellings
150
172
  return the same node for a rendered row — the row's own parts precede everything slotted, so
151
173
  nothing in the suite can tell them apart — but a `[part]` lookup that may descend is one
152
174
  refactor away from reaching into a nested row's parts. */
153
175
  const own = (name: string) => [...host.children].find((el) => el.getAttribute("part") === name);
154
- const control = own("control")?.firstElementChild;
176
+ const control: NameableControl | null | undefined = own("control")?.firstElementChild;
155
177
  if (!control || !observesNaming(control)) {
156
178
  return;
157
179
  }
158
- const target = control as unknown as Record<string, unknown>;
159
- target["labelledby"] ||= `${uid}-label`;
180
+ control.labelledby ||= `${uid}-label`;
160
181
  const help = own("help");
161
182
  if (help && (help.firstElementChild !== null || help.textContent !== "")) {
162
- target["describedby"] ||= `${uid}-help`;
183
+ control.describedby ||= `${uid}-help`;
163
184
  }
164
185
  }
@@ -42,11 +42,13 @@ const FOCUSABLE =
42
42
 
43
43
  /** The reactive scope a `jx-toast-host` document hands its handlers. */
44
44
  export interface ToastHostState {
45
- /** The key that moves focus into the stack. Empty turns the gesture off. */
46
- hotkey: string;
45
+ /**
46
+ * The key that moves focus into the stack. Empty turns the gesture off, and so does null, which
47
+ * is what a host clearing the reflected `hotkey` attribute leaves.
48
+ */
49
+ hotkey: string | null;
47
50
  /** `polite`, `assertive` or `off`. */
48
51
  live: string;
49
- [key: string]: unknown;
50
52
  }
51
53
 
52
54
  /** Where focus was when the reader entered the stack, so it can be given back. */
@@ -208,13 +210,14 @@ export function onStackClose(_state: ToastHostState, event: Event): void {
208
210
  * @param host The element itself.
209
211
  */
210
212
  export function onHostMount(state: ToastHostState, host: HTMLElement): void {
211
- const root = host.getRootNode() as Document | ShadowRoot;
212
- const onKeydown = (event: Event): void => {
213
- const press = event as KeyboardEvent;
214
- if (press.altKey || press.ctrlKey || press.metaKey) {
213
+ /* The document, the shadow root, or — for a detached host — its topmost ancestor: every one of
214
+ them is a node a keydown from inside it reaches. */
215
+ const root = host.getRootNode();
216
+ const onKeydown = (press: Event): void => {
217
+ if (!(press instanceof KeyboardEvent) || press.altKey || press.ctrlKey || press.metaKey) {
215
218
  return;
216
219
  }
217
- const wanted = typeof state.hotkey === "string" ? state.hotkey : "";
220
+ const wanted = state.hotkey ?? "";
218
221
  if (wanted !== "" && press.key === wanted) {
219
222
  if (focusStack(host)) {
220
223
  press.preventDefault();