@lumiose/ui 0.1.1 → 0.1.2

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 (62) hide show
  1. package/README.md +2 -2
  2. package/dist/accordion/accordion.tsx +3 -2
  3. package/dist/autocomplete/autocomplete.tsx +6 -5
  4. package/dist/avatar/avatar.tsx +4 -3
  5. package/dist/batched.ts +11 -0
  6. package/dist/checkbox/checkbox.tsx +4 -3
  7. package/dist/checkbox-group/checkbox-group.tsx +3 -2
  8. package/dist/collapsible/collapsible.tsx +3 -2
  9. package/dist/combobox/combobox.tsx +6 -5
  10. package/dist/context-menu/context-menu.tsx +4 -3
  11. package/dist/dialog/dialog.tsx +2 -1
  12. package/dist/field/field.tsx +11 -10
  13. package/dist/form/reset.ts +4 -2
  14. package/dist/input/input.tsx +4 -1
  15. package/dist/menu/menu.tsx +9 -8
  16. package/dist/navigation-menu/navigation-menu.tsx +4 -3
  17. package/dist/number-field/number-field.tsx +4 -3
  18. package/dist/otp-field/otp-field.tsx +9 -8
  19. package/dist/popover/popover-hover.script.ts +27 -0
  20. package/dist/popover/popover.script.ts +19 -25
  21. package/dist/popover/popover.tsx +8 -3
  22. package/dist/preview-card/preview-card.tsx +2 -1
  23. package/dist/radio-group/radio-group.tsx +3 -2
  24. package/dist/select/select.script.ts +26 -0
  25. package/dist/select/select.tsx +36 -10
  26. package/dist/slider/slider.tsx +4 -3
  27. package/dist/switch/switch.tsx +4 -3
  28. package/dist/tabs/tabs.tsx +3 -2
  29. package/dist/toast/toast.tsx +3 -2
  30. package/dist/toggle/toggle.tsx +6 -5
  31. package/dist/toggle-group/toggle-group.script.ts +12 -4
  32. package/dist/toggle-group/toggle-group.tsx +16 -4
  33. package/dist/tooltip/tooltip.tsx +4 -1
  34. package/docs/README.md +9 -6
  35. package/docs/accordion.md +3 -2
  36. package/docs/autocomplete.md +2 -2
  37. package/docs/avatar.md +2 -2
  38. package/docs/checkbox-group.md +1 -1
  39. package/docs/checkbox.md +1 -1
  40. package/docs/collapsible.md +4 -3
  41. package/docs/combobox.md +4 -3
  42. package/docs/context-menu.md +3 -3
  43. package/docs/dialog.md +1 -1
  44. package/docs/drawer.md +3 -2
  45. package/docs/field.md +2 -2
  46. package/docs/installation.md +3 -3
  47. package/docs/llms-full.txt +107 -72
  48. package/docs/llms.txt +14 -14
  49. package/docs/menu.md +4 -3
  50. package/docs/menubar.md +3 -3
  51. package/docs/navigation-menu.md +4 -3
  52. package/docs/otp-field.md +2 -2
  53. package/docs/popover.md +7 -4
  54. package/docs/preview-card.md +4 -3
  55. package/docs/radio-group.md +1 -1
  56. package/docs/select.md +25 -10
  57. package/docs/tabs.md +1 -1
  58. package/docs/toast.md +2 -2
  59. package/docs/toggle-group.md +10 -4
  60. package/docs/toolbar.md +2 -2
  61. package/docs/tooltip.md +4 -3
  62. package/package.json +1 -1
package/README.md CHANGED
@@ -5,8 +5,8 @@ elements first (`<dialog>`, popover, invoker commands, CSS anchor positioning),
5
5
  elements (`lu-*`) only where the platform falls short, server-rendered without hydration. Parts
6
6
  follow [Base UI](https://base-ui.com)'s anatomy; the approach is [Timeless](https://timeless.build)'s.
7
7
 
8
- **In development**: not on npm yet. It keeps its own 0.x versions, apart from the version the other
9
- Lumiose packages share.
8
+ **In development**, on npm: `pnpm add @lumiose/ui` in a Lumiose app (`lumiose` is its peer
9
+ dependency). It keeps its own 0.x versions, apart from the version the other Lumiose packages share.
10
10
 
11
11
  ```tsx
12
12
  import { Dialog } from "@lumiose/ui";
@@ -17,6 +17,7 @@ import {
17
17
  } from "lumiose";
18
18
  import defineAccordion from "./accordion.script.ts?client";
19
19
  import type { LuAccordion } from "./accordion.script.ts";
20
+ import { batched } from "../batched.ts";
20
21
 
21
22
  declare module "lumiose/jsx-runtime" {
22
23
  namespace JSX {
@@ -223,13 +224,13 @@ function Item({
223
224
  }}
224
225
  name={accordion.multiple ? undefined : accordion.id}
225
226
  open={shown}
226
- on:toggle={(event: ToggleEvent) => {
227
+ on:toggle={batched((event: ToggleEvent) => {
227
228
  const now = event.newState === "open";
228
229
  accordion.onToggle(key, now);
229
230
  if (now === shown) return;
230
231
  shown = now;
231
232
  onOpenChange?.(now);
232
- }}
233
+ })}
233
234
  >
234
235
  {children}
235
236
  </details>
@@ -24,6 +24,7 @@ import comboboxScript from "../combobox/combobox.script.ts?client";
24
24
  import { ComboboxContext, type ComboboxContextValue } from "../combobox/context.ts";
25
25
  import { controlId, useField, useFormValues } from "../field/context.ts";
26
26
  import { onFormReset } from "../form/reset.ts";
27
+ import { batched } from "../batched.ts";
27
28
 
28
29
  /**
29
30
  * How the suggestions complete the text, as in Base UI: `"list"` filters them by the text,
@@ -193,13 +194,13 @@ function Root({
193
194
  submit-on-item-click={submitOnItemClick}
194
195
  limit={limit}
195
196
  disabled={context.disabled()}
196
- on:input={(event: Event) => {
197
+ on:input={batched((event: Event) => {
197
198
  const input = host()?.input;
198
199
  if (input && event.target === input) onValueChange?.(input.value);
199
- }}
200
- on:openchange={(event: CustomEvent<ComboboxOpenChangeDetail>) =>
201
- onOpenChange?.(event.detail.open)
202
- }
200
+ })}
201
+ on:openchange={batched((event: CustomEvent<ComboboxOpenChangeDetail>) =>
202
+ onOpenChange?.(event.detail.open),
203
+ )}
203
204
  >
204
205
  {children}
205
206
  </lu-combobox>
@@ -12,6 +12,7 @@ import type {
12
12
  AvatarLoadingStatus,
13
13
  AvatarLoadingStatusChangeDetail,
14
14
  } from "./avatar.script.ts";
15
+ import { batched } from "../batched.ts";
15
16
 
16
17
  export type { AvatarLoadingStatus } from "./avatar.script.ts";
17
18
 
@@ -75,9 +76,9 @@ function Image({ onLoadingStatusChange, ...rest }: AvatarImageProps) {
75
76
  {...rest}
76
77
  ref={image}
77
78
  data-loading
78
- on:loadingstatuschange={(event: CustomEvent<AvatarLoadingStatusChangeDetail>) =>
79
- report(event.detail.status)
80
- }
79
+ on:loadingstatuschange={batched((event: CustomEvent<AvatarLoadingStatusChangeDetail>) =>
80
+ report(event.detail.status),
81
+ )}
81
82
  />
82
83
  );
83
84
  }
@@ -0,0 +1,11 @@
1
+ // The components' native listeners (`on:`) run inside `batch()`, as Lumiose's delegated event
2
+ // handlers do (TASK-540): the signal writes of a listener and of the callbacks it calls
3
+ // (`onValueChange`, `onOpenChange`, …) render together, before the event's dispatch returns, so
4
+ // the DOM is current right after it instead of a microtask later.
5
+ import { batch } from "lumiose";
6
+
7
+ /** `listener`, run inside `batch()`. */
8
+ export const batched =
9
+ <E>(listener: (event: E) => void) =>
10
+ (event: E): void =>
11
+ batch(() => listener(event));
@@ -9,6 +9,7 @@ import { fieldProps, useField, useFormValues } from "../field/context.ts";
9
9
  import checkboxScript from "./checkbox.script.ts?client";
10
10
  import { CheckboxGroupContext, optionId, type CheckboxGroupContextValue } from "./context.ts";
11
11
  import { afterFormReset, onFormReset } from "../form/reset.ts";
12
+ import { batched } from "../batched.ts";
12
13
 
13
14
  /**
14
15
  * The native checkbox props Root passes through (`name`, `required`, `disabled`, `form`, `id`,
@@ -137,9 +138,9 @@ function Root({
137
138
  data-parent={parent ? "" : undefined}
138
139
  aria-readonly={readOnly ? "true" : undefined}
139
140
  data-unchecked-value={uncheckedValue}
140
- on:change={(event: Event) =>
141
- onCheckedChange?.((event.currentTarget as HTMLInputElement).checked)
142
- }
141
+ on:change={batched((event: Event) =>
142
+ onCheckedChange?.((event.currentTarget as HTMLInputElement).checked),
143
+ )}
143
144
  />
144
145
  );
145
146
  }
@@ -7,6 +7,7 @@ import checkboxScript from "../checkbox/checkbox.script.ts?client";
7
7
  import { CheckboxGroupContext } from "../checkbox/context.ts";
8
8
  import { describedBy, labelledBy, useField, useFormValues } from "../field/context.ts";
9
9
  import { onFormReset } from "../form/reset.ts";
10
+ import { batched } from "../batched.ts";
10
11
 
11
12
  /** The fieldset props CheckboxGroup passes through, without the brand of intrinsic elements' props. */
12
13
  type FieldsetProps = Omit<JSX.IntrinsicElements["fieldset"], symbol>;
@@ -98,11 +99,11 @@ export function CheckboxGroup({
98
99
  }
99
100
  aria-describedby={rest["aria-describedby"] ?? (field ? describedBy(field) : undefined)}
100
101
  aria-invalid={rest["aria-invalid"] ?? (field?.invalid() ? "true" : undefined)}
101
- on:change={(event: Event) => {
102
+ on:change={batched((event: Event) => {
102
103
  if (!(event.target as Element).matches("input[type=checkbox]")) return;
103
104
  // checkbox.script.ts ran first, for a parent's toggle.
104
105
  report(checked(event.currentTarget as HTMLFieldSetElement));
105
- }}
106
+ })}
106
107
  >
107
108
  {children}
108
109
  </fieldset>
@@ -6,6 +6,7 @@
6
6
  // trigger. Ported from Timeless's approach (https://timeless.build).
7
7
  import { createContext, effect, ref, untrack, useContext, useId } from "lumiose";
8
8
  import collapsibleScript from "./collapsible.script.ts?client";
9
+ import { batched } from "../batched.ts";
9
10
 
10
11
  /**
11
12
  * The `<details>` props Root passes on (`class`, `style`, `name`, `data-*`, `aria-*`, events),
@@ -96,12 +97,12 @@ function Root({
96
97
  forwarded?.(element);
97
98
  }}
98
99
  open={shown}
99
- on:toggle={(event: ToggleEvent) => {
100
+ on:toggle={batched((event: ToggleEvent) => {
100
101
  const now = event.newState === "open";
101
102
  if (now === shown) return;
102
103
  shown = now;
103
104
  onOpenChange?.(now);
104
- }}
105
+ })}
105
106
  >
106
107
  {children}
107
108
  </details>
@@ -27,6 +27,7 @@ import { controlId, describedBy, labelledBy, useField, useFormValues } from "../
27
27
  import { onFormReset } from "../form/reset.ts";
28
28
  import { anchorName, placement, withStyle, type PlacementProps } from "../placement/placement.ts";
29
29
  import { ComboboxContext, register, useCombobox, type ComboboxContextValue } from "./context.ts";
30
+ import { batched } from "../batched.ts";
30
31
 
31
32
  declare module "lumiose/jsx-runtime" {
32
33
  namespace JSX {
@@ -266,14 +267,14 @@ function Root<Multiple extends boolean | undefined = false>({
266
267
  disabled={context.disabled()}
267
268
  multiple={multiple}
268
269
  filter={filter}
269
- on:change={() => {
270
+ on:change={batched(() => {
270
271
  const next = host()!.value;
271
272
  selected.set(list(next));
272
273
  onValueChange?.(next as ComboboxValue<Multiple>);
273
- }}
274
- on:openchange={(event: CustomEvent<ComboboxOpenChangeDetail>) =>
275
- onOpenChange?.(event.detail.open)
276
- }
274
+ })}
275
+ on:openchange={batched((event: CustomEvent<ComboboxOpenChangeDetail>) =>
276
+ onOpenChange?.(event.detail.open),
277
+ )}
277
278
  >
278
279
  {children}
279
280
  </lu-combobox>
@@ -19,6 +19,7 @@ import type { MenuElement, MenuOpenChangeDetail } from "../menu/menu.script.ts";
19
19
  import { anchorName } from "../placement/placement.ts";
20
20
  import contextMenuScript from "./context-menu.script.ts?client";
21
21
  import type { ContextMenuElement } from "./context-menu.script.ts";
22
+ import { batched } from "../batched.ts";
22
23
 
23
24
  declare module "lumiose/jsx-runtime" {
24
25
  namespace JSX {
@@ -126,9 +127,9 @@ function Popup(props: ContextMenuPopupProps) {
126
127
  return (
127
128
  <lu-menu
128
129
  ref={context.host}
129
- on:openchange={(event: CustomEvent<MenuOpenChangeDetail>) =>
130
- context.onOpenChange(event.detail.open)
131
- }
130
+ on:openchange={batched((event: CustomEvent<MenuOpenChangeDetail>) =>
131
+ context.onOpenChange(event.detail.open),
132
+ )}
132
133
  >
133
134
  {/* The point the popup is anchored to: the script moves it to the pointer. */}
134
135
  <span
@@ -16,6 +16,7 @@ import {
16
16
  type Signal,
17
17
  } from "lumiose";
18
18
  import dialogScript from "./dialog.script.ts?client";
19
+ import { batched } from "../batched.ts";
19
20
 
20
21
  /**
21
22
  * `Dialog.Root` renders no element: it holds the dialog's state and the ids its parts share.
@@ -148,7 +149,7 @@ function Popup({ children, ref: forwarded, ...rest }: DialogPopupProps) {
148
149
  forwarded?.(element);
149
150
  }}
150
151
  id={dialog.popupId}
151
- on:toggle={(event: ToggleEvent) => dialog.onToggle(event.newState === "open")}
152
+ on:toggle={batched((event: ToggleEvent) => dialog.onToggle(event.newState === "open"))}
152
153
  >
153
154
  {isServer ? content : children}
154
155
  </dialog>
@@ -31,6 +31,7 @@ import {
31
31
  import fieldScript from "./field.script.ts?client";
32
32
  import { listenToForm, reportValidity } from "../form/native.ts";
33
33
  import { Input, type InputProps } from "../input/input.tsx";
34
+ import { batched } from "../batched.ts";
34
35
 
35
36
  declare module "lumiose/jsx-runtime" {
36
37
  namespace JSX {
@@ -288,7 +289,7 @@ function Root({
288
289
  * (cleared while it waits): its check runs now, and a synchronous one that fails stops the
289
290
  * submission, which the form then reports. An async check still waits for the server.
290
291
  */
291
- const onSubmit = (event: Event) => {
292
+ const onSubmit = batched((event: Event) => {
292
293
  if (!due) return;
293
294
  clearTimeout(timer);
294
295
  due();
@@ -297,7 +298,7 @@ function Root({
297
298
  event.preventDefault();
298
299
  event.stopImmediatePropagation();
299
300
  reportValidity(event.currentTarget as HTMLFormElement);
300
- };
301
+ });
301
302
  effect(() => {
302
303
  if (!validate) return;
303
304
  const element = document.getElementById(controlId(field));
@@ -314,13 +315,13 @@ function Root({
314
315
  }
315
316
  });
316
317
  // A submit attempt checks every control (`invalid` does not bubble); a reset clears the checks.
317
- const onInvalid = (event: Event) => snapshot(event.target);
318
- const onReset = () => {
318
+ const onInvalid = batched((event: Event) => snapshot(event.target));
319
+ const onReset = batched(() => {
319
320
  checked.set(undefined);
320
321
  checking = undefined;
321
322
  // The controls take their default values after the event.
322
323
  setTimeout(() => run(document.getElementById(controlId(field)), 0, true));
323
- };
324
+ });
324
325
  onMount(() => {
325
326
  const element = host();
326
327
  if (!element) return;
@@ -349,15 +350,15 @@ function Root({
349
350
  }}
350
351
  id={id}
351
352
  validation-mode={validationMode ?? form?.validationMode()}
352
- on:input={(event: Event) => {
353
+ on:input={batched((event: Event) => {
353
354
  run(event.target, validationDebounceTime);
354
355
  snapshot(event.target);
355
- }}
356
- on:change={(event: Event) => {
356
+ })}
357
+ on:change={batched((event: Event) => {
357
358
  run(event.target);
358
359
  snapshot(event.target);
359
- }}
360
- on:focusout={(event: Event) => snapshot(event.target)}
360
+ })}
361
+ on:focusout={batched((event: Event) => snapshot(event.target))}
361
362
  >
362
363
  {children}
363
364
  </lu-field>
@@ -4,7 +4,7 @@
4
4
  // value when its form resets and again a task later, once the controls (and the elements'
5
5
  // scripts, which follow the reset the same way) hold their defaults, and reports the new value
6
6
  // when it differs.
7
- import { onCleanup, onMount } from "lumiose";
7
+ import { batch, onCleanup, onMount } from "lumiose";
8
8
  import { listenToForm } from "./native.ts";
9
9
 
10
10
  const same = (a: unknown, b: unknown) =>
@@ -20,10 +20,12 @@ function listen(element: () => Element | undefined, atReset: () => () => void):
20
20
  (host as { form?: HTMLFormElement | null } | undefined)?.form ?? host?.closest("form");
21
21
  if (!form) return;
22
22
  let timer: ReturnType<typeof setTimeout> | undefined;
23
+ // In `batch()`, as the components' listeners (../batched.ts): the callbacks' writes render
24
+ // together.
23
25
  const listener = () => {
24
26
  const later = atReset();
25
27
  clearTimeout(timer);
26
- timer = setTimeout(later);
28
+ timer = setTimeout(() => batch(later));
27
29
  };
28
30
  const stop = listenToForm(form, "reset", listener);
29
31
  onCleanup(() => {
@@ -4,6 +4,7 @@
4
4
  import { ref, untrack } from "lumiose";
5
5
  import { fieldProps, useField, useFormValues } from "../field/context.ts";
6
6
  import { onFormReset } from "../form/reset.ts";
7
+ import { batched } from "../batched.ts";
7
8
 
8
9
  /**
9
10
  * An element's props without the brand of intrinsic elements' props, so that the component's
@@ -61,7 +62,9 @@ export function Input({ value, defaultValue, onValueChange, ...rest }: InputProp
61
62
  rest.ref?.(element);
62
63
  }}
63
64
  value={value !== undefined ? value : untrack(() => sent?.[0] ?? defaultValue)}
64
- on:input={(event: Event) => onValueChange?.((event.currentTarget as HTMLInputElement).value)}
65
+ on:input={batched((event: Event) =>
66
+ onValueChange?.((event.currentTarget as HTMLInputElement).value),
67
+ )}
65
68
  />
66
69
  );
67
70
  }
@@ -21,6 +21,7 @@ import type { MenuCheckedChangeDetail, MenuElement, MenuOpenChangeDetail } from
21
21
  import menuScript from "./menu.script.ts?client";
22
22
  import { anchorName, placement, withStyle, type Align, type Side } from "../placement/placement.ts";
23
23
  import { OUTSIDE_TOOLBAR, ToolbarContext } from "../toolbar/context.ts";
24
+ import { batched } from "../batched.ts";
24
25
 
25
26
  declare module "lumiose/jsx-runtime" {
26
27
  namespace JSX {
@@ -244,9 +245,9 @@ function menuRoot(props: MenuRootProps, submenu: boolean) {
244
245
  <lu-menu
245
246
  ref={host}
246
247
  role={context.menubar ? "none" : undefined}
247
- on:openchange={(event: CustomEvent<MenuOpenChangeDetail>) =>
248
- props.onOpenChange?.(event.detail.open)
249
- }
248
+ on:openchange={batched((event: CustomEvent<MenuOpenChangeDetail>) =>
249
+ props.onOpenChange?.(event.detail.open),
250
+ )}
250
251
  >
251
252
  {props.children}
252
253
  </lu-menu>
@@ -377,9 +378,9 @@ function CheckboxItem({
377
378
  {...rest}
378
379
  role="menuitemcheckbox"
379
380
  aria-checked={checked ?? defaultChecked ?? false}
380
- on:checkedchange={(event: CustomEvent<MenuCheckedChangeDetail>) =>
381
- onCheckedChange?.(event.detail.checked)
382
- }
381
+ on:checkedchange={batched((event: CustomEvent<MenuCheckedChangeDetail>) =>
382
+ onCheckedChange?.(event.detail.checked),
383
+ )}
383
384
  >
384
385
  {children}
385
386
  </button>
@@ -442,12 +443,12 @@ function RadioGroup({
442
443
  <GroupElement
443
444
  {...rest}
444
445
  group={group}
445
- on:checkedchange={(event: CustomEvent<MenuCheckedChangeDetail>) => {
446
+ on:checkedchange={batched((event: CustomEvent<MenuCheckedChangeDetail>) => {
446
447
  const item = event.target as HTMLButtonElement;
447
448
  if (item.getAttribute("role") === "menuitemradio" && event.detail.checked) {
448
449
  onValueChange?.(item.value);
449
450
  }
450
- }}
451
+ })}
451
452
  >
452
453
  {children}
453
454
  </GroupElement>
@@ -15,6 +15,7 @@ import type {
15
15
  NavigationMenuElement,
16
16
  NavigationMenuValueChangeDetail,
17
17
  } from "./navigation-menu.script.ts";
18
+ import { batched } from "../batched.ts";
18
19
 
19
20
  declare module "lumiose/jsx-runtime" {
20
21
  namespace JSX {
@@ -150,9 +151,9 @@ function Root({
150
151
  data-delay={delay}
151
152
  data-close-delay={closeDelay}
152
153
  data-orientation={vertical() ? "vertical" : "horizontal"}
153
- on:valuechange={(event: CustomEvent<NavigationMenuValueChangeDetail>) =>
154
- onValueChange?.(event.detail.value)
155
- }
154
+ on:valuechange={batched((event: CustomEvent<NavigationMenuValueChangeDetail>) =>
155
+ onValueChange?.(event.detail.value),
156
+ )}
156
157
  >
157
158
  {/* A nested menu is a `<div>`, inside its parent's landmark. */}
158
159
  <Dynamic component={nested ? "div" : "nav"} {...attributes()}>
@@ -24,6 +24,7 @@ import { onFormReset } from "../form/reset.ts";
24
24
  import { formatNumber, formatter } from "./format.ts";
25
25
  import type { NumberFieldElement } from "./number-field.script.ts";
26
26
  import numberFieldScript from "./number-field.script.ts?client";
27
+ import { batched } from "../batched.ts";
27
28
 
28
29
  declare module "lumiose/jsx-runtime" {
29
30
  namespace JSX {
@@ -260,11 +261,11 @@ function Root({
260
261
  locale={locale}
261
262
  allow-wheel-scrub={allowWheelScrub}
262
263
  allow-out-of-range={allowOutOfRange}
263
- on:input={report}
264
- on:change={() => {
264
+ on:input={batched(report)}
265
+ on:change={batched(() => {
265
266
  report();
266
267
  onValueCommitted?.(element()?.value ?? null);
267
- }}
268
+ })}
268
269
  >
269
270
  <input
270
271
  type="hidden"
@@ -12,6 +12,7 @@ import { controlId, describedBy, useField } from "../field/context.ts";
12
12
  import { onFormReset } from "../form/reset.ts";
13
13
  import type { OTPFieldElement, OTPFieldValueDetail } from "./otp-field.script.ts";
14
14
  import otpFieldScript from "./otp-field.script.ts?client";
15
+ import { batched } from "../batched.ts";
15
16
 
16
17
  declare module "lumiose/jsx-runtime" {
17
18
  namespace JSX {
@@ -234,19 +235,19 @@ function Root({
234
235
  validation-type={type()}
235
236
  mask={mask}
236
237
  auto-submit={autoSubmit}
237
- on:input={(event: Event) => {
238
+ on:input={batched((event: Event) => {
238
239
  const input = element()?.input;
239
240
  // A composition reports once committed (the element fires `input` then, TASK-432).
240
241
  if (input && event.target === input && !(event as InputEvent).isComposing) {
241
242
  onValueChange?.(input.value);
242
243
  }
243
- }}
244
- on:valuecomplete={(event: CustomEvent<OTPFieldValueDetail>) =>
245
- onValueComplete?.(event.detail.value)
246
- }
247
- on:valueinvalid={(event: CustomEvent<OTPFieldValueDetail>) =>
248
- onValueInvalid?.(event.detail.value)
249
- }
244
+ })}
245
+ on:valuecomplete={batched((event: CustomEvent<OTPFieldValueDetail>) =>
246
+ onValueComplete?.(event.detail.value),
247
+ )}
248
+ on:valueinvalid={batched((event: CustomEvent<OTPFieldValueDetail>) =>
249
+ onValueInvalid?.(event.detail.value),
250
+ )}
250
251
  >
251
252
  {children}
252
253
  </lu-otp-field>
@@ -0,0 +1,27 @@
1
+ // Popover's hover script (TASK-394, TASK-541): only a Popover.Trigger with `openOnHover` loads it,
2
+ // so a page without one goes without the hover timing (hover.ts). A trigger with
3
+ // `data-open-on-hover` opens its popover after `data-delay` of hover by a mouse or a pen, and the
4
+ // popover closes `data-close-delay` after the pointer left both (popover.script.ts, which this
5
+ // script hands the timing to, follows what it opened: focus, a click on the trigger).
6
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
7
+ import { hoverIntent } from "./hover.ts";
8
+ import { isPopover, useHover } from "./popover.script.ts";
9
+
10
+ useHover(
11
+ hoverIntent({
12
+ triggerAt: (node) =>
13
+ node.closest?.<HTMLElement>("button[data-open-on-hover][popovertarget]") ?? undefined,
14
+ popupOf: (trigger) => {
15
+ const popup = document.getElementById(trigger.getAttribute("popovertarget")!);
16
+ return isPopover(popup) ? popup : undefined;
17
+ },
18
+ show: (trigger, popup) => {
19
+ // A hover-opened popover leaves focus where it is (popover.script.ts's `toggle`).
20
+ popup.popover = "manual";
21
+ (popup.showPopover as (options?: { source?: HTMLElement }) => void)({ source: trigger });
22
+ },
23
+ hide: (popup) => popup.hidePopover(),
24
+ delay: 300,
25
+ closeDelay: 0,
26
+ }),
27
+ );
@@ -21,6 +21,8 @@
21
21
  // focus. It stays open while the pointer is on the trigger or the popup (WCAG 1.4.13: hoverable)
22
22
  // and closes `data-close-delay` (0 ms, at least a 100 ms grace to cross the gap) after it left
23
23
  // both. A click on the trigger keeps it open until it is dismissed (an outside press, Escape).
24
+ // The hover timing is popover-hover.script.ts's, which only a trigger with `openOnHover` loads
25
+ // (TASK-541); this script follows the popovers it opened.
24
26
  // - A modal popover is a modal `<dialog data-side>` opened by `command="show-modal"`: placement,
25
27
  // focus and `aria-expanded` as above; Dialog's script and the native dialog do the rest.
26
28
  // - `data-trap-focus` (`modal="trap-focus"`, TASK-410): Tab and Shift+Tab go around the open
@@ -34,7 +36,7 @@
34
36
  //
35
37
  // It must not import `lumiose`: server-rendered pages load it without the runtime.
36
38
  import { openLayer } from "../layer/layer.ts";
37
- import { hoverIntent } from "./hover.ts";
39
+ import type { HoverIntent } from "./hover.ts";
38
40
  import { anchoring, place } from "../placement/place.ts";
39
41
 
40
42
  /** The last press, until a key is pressed: what an opening came from. */
@@ -75,7 +77,7 @@ function tabStops(popup: HTMLElement): HTMLElement[] {
75
77
  });
76
78
  }
77
79
 
78
- const isPopover = (node: unknown): node is HTMLElement =>
80
+ export const isPopover = (node: unknown): node is HTMLElement =>
79
81
  node instanceof HTMLElement &&
80
82
  node.matches("[popover][data-side][role=dialog], dialog[data-side]");
81
83
 
@@ -135,9 +137,9 @@ on("click", (event) => {
135
137
  const popup = trigger && document.getElementById(trigger.getAttribute("popovertarget")!);
136
138
  if (!isPopover(popup) || trigger!.matches("[popovertargetaction=hide]")) return;
137
139
  if (!popup.matches(":popover-open")) popup.popover = "manual";
138
- else if (hovered.has(popup)) {
140
+ else if (hovered(popup)) {
139
141
  event.preventDefault();
140
- hover.forget(popup);
142
+ hover!.forget(popup);
141
143
  }
142
144
  });
143
145
 
@@ -158,25 +160,17 @@ on("focusout", (event) => {
158
160
  }
159
161
  });
160
162
 
161
- // Open on hover (hover.ts) -------------------------------------------------------------------
163
+ // Open on hover: popover-hover.script.ts, which only hover triggers load, hands its timing here.
162
164
 
163
- const hover = hoverIntent({
164
- triggerAt: (node) =>
165
- node.closest?.<HTMLElement>("button[data-open-on-hover][popovertarget]") ?? undefined,
166
- popupOf: (trigger) => {
167
- const popup = document.getElementById(trigger.getAttribute("popovertarget")!);
168
- return isPopover(popup) ? popup : undefined;
169
- },
170
- show: (trigger, popup) => {
171
- // A hover-opened popover leaves focus where it is (see `toggle`).
172
- popup.popover = "manual";
173
- (popup.showPopover as (options?: { source?: HTMLElement }) => void)({ source: trigger });
174
- },
175
- hide: (popup) => popup.hidePopover(),
176
- delay: 300,
177
- closeDelay: 0,
178
- });
179
- const hovered = hover.hovered;
165
+ let hover: HoverIntent | undefined;
166
+
167
+ /** Takes the hover timing of popover-hover.script.ts. */
168
+ export function useHover(intent: HoverIntent): void {
169
+ hover = intent;
170
+ }
171
+
172
+ /** Whether a hover opened `popup`, and no click on its trigger has kept it open since. */
173
+ const hovered = (popup: HTMLElement) => hover?.hovered.has(popup) === true;
180
174
 
181
175
  /** Stops dismissing `popup`: as it closes, so the next close request goes to the layer below. */
182
176
  function release(popup: HTMLElement) {
@@ -226,7 +220,7 @@ on("toggle", (event) => {
226
220
 
227
221
  if (open) {
228
222
  // A hover opens it without taking focus from where it is.
229
- if (hovered.has(popup) || popup.contains(document.activeElement)) return;
223
+ if (hovered(popup) || popup.contains(document.activeElement)) return;
230
224
  const touch = press?.pointerType === "touch" && event.timeStamp - press.timeStamp < 1000;
231
225
  const first = touch ? undefined : tabStops(popup)[0];
232
226
  if (first) first.focus();
@@ -238,10 +232,10 @@ on("toggle", (event) => {
238
232
  }
239
233
 
240
234
  placed.delete(popup);
241
- const wasHovered = hovered.has(popup);
235
+ const wasHovered = hovered(popup);
242
236
  const held = holding.has(popup);
243
237
  holding.delete(popup);
244
- hover.forget(popup);
238
+ hover?.forget(popup);
245
239
  // A hover opened it and focus was elsewhere: closing it moves no focus.
246
240
  if (wasHovered && !held) return;
247
241
  const active = document.activeElement;
@@ -6,7 +6,8 @@
6
6
  // `onOpenChange`. Ported from Timeless's approach (https://timeless.build).
7
7
  //
8
8
  // TASK-394: a trigger may open the popover on hover (`openOnHover`, after `delay`, closing
9
- // `closeDelay` after the pointer leaves both), which popover.script.ts runs. A `modal` popover is a
9
+ // `closeDelay` after the pointer leaves both), which popover-hover.script.ts times (only hover
10
+ // triggers load it, TASK-541) and popover.script.ts follows. A `modal` popover is a
10
11
  // modal `<dialog>` instead, opened by the trigger's `command="show-modal"`: the page behind it is
11
12
  // inert and does not scroll, focus stays inside, and a press outside or Escape closes it.
12
13
  import {
@@ -24,7 +25,9 @@ import {
24
25
  import dialogScript from "../dialog/dialog.script.ts?client";
25
26
  import { anchorName, placement, withStyle, type PlacementProps } from "../placement/placement.ts";
26
27
  import popoverScript from "./popover.script.ts?client";
28
+ import popoverHoverScript from "./popover-hover.script.ts?client";
27
29
  import { OUTSIDE_TOOLBAR, ToolbarContext } from "../toolbar/context.ts";
30
+ import { batched } from "../batched.ts";
28
31
 
29
32
  /**
30
33
  * `Popover.Root` renders no element: it holds the ids its parts share. `bind:open={signal}`
@@ -176,6 +179,8 @@ function Trigger({
176
179
  openOnHover && !popover.modal && !off
177
180
  ? { "data-open-on-hover": "", "data-delay": delay, "data-close-delay": closeDelay }
178
181
  : {};
182
+ // Only a hover trigger loads the hover timing on a server page (TASK-541).
183
+ if (untrack(() => openOnHover)) popoverHoverScript();
179
184
  return (
180
185
  <button
181
186
  type="button"
@@ -228,7 +233,7 @@ function Popup({
228
233
  ref={attach}
229
234
  id={popover.popupId}
230
235
  closedby="any"
231
- on:toggle={toggle}
236
+ on:toggle={batched(toggle)}
232
237
  >
233
238
  {isServer ? content : inside()}
234
239
  </dialog>
@@ -247,7 +252,7 @@ function Popup({
247
252
  id={popover.popupId}
248
253
  popover="auto"
249
254
  data-trap-focus={popover.trapFocus ? "" : undefined}
250
- on:toggle={toggle}
255
+ on:toggle={batched(toggle)}
251
256
  >
252
257
  {isServer ? content : inside()}
253
258
  </div>