@lumiose/ui 0.1.1 → 0.1.3

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 (70) hide show
  1. package/README.md +2 -2
  2. package/dist/accordion/accordion.tsx +6 -4
  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 +5 -3
  9. package/dist/collapsible/panel.ts +3 -1
  10. package/dist/combobox/combobox.tsx +8 -6
  11. package/dist/combobox/context.ts +2 -1
  12. package/dist/context-menu/context-menu.tsx +6 -4
  13. package/dist/dialog/dialog.tsx +4 -2
  14. package/dist/drawer/drawer.tsx +2 -1
  15. package/dist/field/field.tsx +13 -11
  16. package/dist/form/reset.ts +4 -2
  17. package/dist/input/input.tsx +4 -1
  18. package/dist/menu/menu.tsx +13 -11
  19. package/dist/meter/meter.tsx +2 -1
  20. package/dist/navigation-menu/navigation-menu.tsx +6 -4
  21. package/dist/not-inside.ts +13 -0
  22. package/dist/number-field/number-field.tsx +6 -4
  23. package/dist/otp-field/otp-field.tsx +11 -9
  24. package/dist/popover/hover.ts +2 -1
  25. package/dist/popover/popover-hover.script.ts +27 -0
  26. package/dist/popover/popover.script.ts +19 -25
  27. package/dist/popover/popover.tsx +18 -9
  28. package/dist/preview-card/preview-card.tsx +4 -2
  29. package/dist/progress/progress.tsx +2 -1
  30. package/dist/radio-group/radio-group.tsx +5 -3
  31. package/dist/select/select.script.ts +26 -0
  32. package/dist/select/select.tsx +43 -11
  33. package/dist/slider/slider.tsx +6 -4
  34. package/dist/switch/switch.tsx +4 -3
  35. package/dist/tabs/tabs.tsx +5 -3
  36. package/dist/toast/toast.tsx +5 -3
  37. package/dist/toggle/toggle.tsx +6 -5
  38. package/dist/toggle-group/toggle-group.script.ts +12 -4
  39. package/dist/toggle-group/toggle-group.tsx +16 -4
  40. package/dist/toolbar/toolbar.tsx +2 -1
  41. package/dist/tooltip/tooltip.tsx +6 -2
  42. package/docs/README.md +16 -7
  43. package/docs/accordion.md +3 -2
  44. package/docs/autocomplete.md +2 -2
  45. package/docs/avatar.md +2 -2
  46. package/docs/checkbox-group.md +1 -1
  47. package/docs/checkbox.md +1 -1
  48. package/docs/collapsible.md +4 -3
  49. package/docs/combobox.md +4 -3
  50. package/docs/context-menu.md +3 -3
  51. package/docs/dialog.md +1 -1
  52. package/docs/drawer.md +3 -2
  53. package/docs/field.md +2 -2
  54. package/docs/installation.md +3 -3
  55. package/docs/llms-full.txt +114 -73
  56. package/docs/llms.txt +14 -14
  57. package/docs/menu.md +4 -3
  58. package/docs/menubar.md +3 -3
  59. package/docs/navigation-menu.md +4 -3
  60. package/docs/otp-field.md +2 -2
  61. package/docs/popover.md +7 -4
  62. package/docs/preview-card.md +4 -3
  63. package/docs/radio-group.md +1 -1
  64. package/docs/select.md +25 -10
  65. package/docs/tabs.md +1 -1
  66. package/docs/toast.md +2 -2
  67. package/docs/toggle-group.md +10 -4
  68. package/docs/toolbar.md +2 -2
  69. package/docs/tooltip.md +4 -3
  70. package/package.json +5 -5
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,8 @@ 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";
21
+ import { notInside } from "../not-inside.ts";
20
22
 
21
23
  declare module "lumiose/jsx-runtime" {
22
24
  namespace JSX {
@@ -118,13 +120,13 @@ const HeaderContext = createContext<AccordionHeaderProps>();
118
120
 
119
121
  function useAccordion(): AccordionContextValue {
120
122
  const context = useContext(AccordionContext);
121
- if (!context) throw new Error("Accordion parts must be inside <Accordion.Root>.");
123
+ if (!context) throw notInside("Accordion parts", "<Accordion.Root>");
122
124
  return context;
123
125
  }
124
126
 
125
127
  function useItem(): ItemContextValue {
126
128
  const context = useContext(ItemContext);
127
- if (!context) throw new Error("Accordion parts must be inside an <Accordion.Item>.");
129
+ if (!context) throw notInside("Accordion parts", "an <Accordion.Item>");
128
130
  return context;
129
131
  }
130
132
 
@@ -223,13 +225,13 @@ function Item({
223
225
  }}
224
226
  name={accordion.multiple ? undefined : accordion.id}
225
227
  open={shown}
226
- on:toggle={(event: ToggleEvent) => {
228
+ on:toggle={batched((event: ToggleEvent) => {
227
229
  const now = event.newState === "open";
228
230
  accordion.onToggle(key, now);
229
231
  if (now === shown) return;
230
232
  shown = now;
231
233
  onOpenChange?.(now);
232
- }}
234
+ })}
233
235
  >
234
236
  {children}
235
237
  </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,8 @@
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";
10
+ import { notInside } from "../not-inside.ts";
9
11
 
10
12
  /**
11
13
  * The `<details>` props Root passes on (`class`, `style`, `name`, `data-*`, `aria-*`, events),
@@ -53,7 +55,7 @@ const CollapsibleContext = createContext<CollapsibleContextValue>();
53
55
 
54
56
  function useCollapsible(): CollapsibleContextValue {
55
57
  const context = useContext(CollapsibleContext);
56
- if (!context) throw new Error("Collapsible parts must be inside <Collapsible.Root>.");
58
+ if (!context) throw notInside("Collapsible parts", "<Collapsible.Root>");
57
59
  return context;
58
60
  }
59
61
 
@@ -96,12 +98,12 @@ function Root({
96
98
  forwarded?.(element);
97
99
  }}
98
100
  open={shown}
99
- on:toggle={(event: ToggleEvent) => {
101
+ on:toggle={batched((event: ToggleEvent) => {
100
102
  const now = event.newState === "open";
101
103
  if (now === shown) return;
102
104
  shown = now;
103
105
  onOpenChange?.(now);
104
- }}
106
+ })}
105
107
  >
106
108
  {children}
107
109
  </details>
@@ -34,7 +34,9 @@ function panelOf(details: HTMLDetailsElement): HTMLElement | undefined {
34
34
 
35
35
  /** The variables' prefix: an accordion item is a `<details>` right inside `<lu-accordion>`. */
36
36
  const prefix = (details: HTMLDetailsElement) =>
37
- details.parentElement?.localName === "lu-accordion" ? "--lu-accordion-panel" : "--lu-collapsible-panel";
37
+ details.parentElement?.localName === "lu-accordion"
38
+ ? "--lu-accordion-panel"
39
+ : "--lu-collapsible-panel";
38
40
 
39
41
  function measure(panel: HTMLElement, name: string) {
40
42
  panel.style.setProperty(`${name}-height`, `${panel.scrollHeight}px`);
@@ -27,6 +27,8 @@ 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";
31
+ import { notInside } from "../not-inside.ts";
30
32
 
31
33
  declare module "lumiose/jsx-runtime" {
32
34
  namespace JSX {
@@ -266,14 +268,14 @@ function Root<Multiple extends boolean | undefined = false>({
266
268
  disabled={context.disabled()}
267
269
  multiple={multiple}
268
270
  filter={filter}
269
- on:change={() => {
271
+ on:change={batched(() => {
270
272
  const next = host()!.value;
271
273
  selected.set(list(next));
272
274
  onValueChange?.(next as ComboboxValue<Multiple>);
273
- }}
274
- on:openchange={(event: CustomEvent<ComboboxOpenChangeDetail>) =>
275
- onOpenChange?.(event.detail.open)
276
- }
275
+ })}
276
+ on:openchange={batched((event: CustomEvent<ComboboxOpenChangeDetail>) =>
277
+ onOpenChange?.(event.detail.open),
278
+ )}
277
279
  >
278
280
  {children}
279
281
  </lu-combobox>
@@ -488,7 +490,7 @@ function GroupElement({
488
490
 
489
491
  function GroupLabel({ children, ...rest }: ComboboxGroupLabelProps) {
490
492
  const group = useContext(GroupContext);
491
- if (!group) throw new Error("Combobox.GroupLabel must be inside <Combobox.Group>.");
493
+ if (!group) throw notInside("Combobox.GroupLabel", "<Combobox.Group>");
492
494
  register(group.labels);
493
495
  return (
494
496
  <div role="presentation" {...rest} id={group.labelId}>
@@ -1,6 +1,7 @@
1
1
  // The context the Combobox parts share, with Autocomplete's Root (TASK-377): the same parts work
2
2
  // under either Root, which says how the input behaves.
3
3
  import { createContext, onCleanup, useContext, type Signal } from "lumiose";
4
+ import { notInside } from "../not-inside.ts";
4
5
 
5
6
  export interface ComboboxContextValue {
6
7
  /** The element's id; the parts' ids and the anchor name derive from it. */
@@ -35,7 +36,7 @@ export const ComboboxContext = createContext<ComboboxContextValue>();
35
36
 
36
37
  export function useCombobox(): ComboboxContextValue {
37
38
  const context = useContext(ComboboxContext);
38
- if (!context) throw new Error("Combobox parts must be inside <Combobox.Root>.");
39
+ if (!context) throw notInside("Combobox parts", "<Combobox.Root>");
39
40
  return context;
40
41
  }
41
42
 
@@ -19,6 +19,8 @@ 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";
23
+ import { notInside } from "../not-inside.ts";
22
24
 
23
25
  declare module "lumiose/jsx-runtime" {
24
26
  namespace JSX {
@@ -71,7 +73,7 @@ const ContextMenuContext = createContext<ContextMenuContextValue>();
71
73
 
72
74
  function useContextMenu(): ContextMenuContextValue {
73
75
  const context = useContext(ContextMenuContext);
74
- if (!context) throw new Error("ContextMenu parts must be inside <ContextMenu.Root>.");
76
+ if (!context) throw notInside("ContextMenu parts", "<ContextMenu.Root>");
75
77
  return context;
76
78
  }
77
79
 
@@ -126,9 +128,9 @@ function Popup(props: ContextMenuPopupProps) {
126
128
  return (
127
129
  <lu-menu
128
130
  ref={context.host}
129
- on:openchange={(event: CustomEvent<MenuOpenChangeDetail>) =>
130
- context.onOpenChange(event.detail.open)
131
- }
131
+ on:openchange={batched((event: CustomEvent<MenuOpenChangeDetail>) =>
132
+ context.onOpenChange(event.detail.open),
133
+ )}
132
134
  >
133
135
  {/* The point the popup is anchored to: the script moves it to the pointer. */}
134
136
  <span
@@ -16,6 +16,8 @@ import {
16
16
  type Signal,
17
17
  } from "lumiose";
18
18
  import dialogScript from "./dialog.script.ts?client";
19
+ import { batched } from "../batched.ts";
20
+ import { notInside } from "../not-inside.ts";
19
21
 
20
22
  /**
21
23
  * `Dialog.Root` renders no element: it holds the dialog's state and the ids its parts share.
@@ -68,7 +70,7 @@ const DialogContext = createContext<DialogContextValue>();
68
70
 
69
71
  function useDialog(): DialogContextValue {
70
72
  const context = useContext(DialogContext);
71
- if (!context) throw new Error("Dialog parts must be inside <Dialog.Root>.");
73
+ if (!context) throw notInside("Dialog parts", "<Dialog.Root>");
72
74
  return context;
73
75
  }
74
76
 
@@ -148,7 +150,7 @@ function Popup({ children, ref: forwarded, ...rest }: DialogPopupProps) {
148
150
  forwarded?.(element);
149
151
  }}
150
152
  id={dialog.popupId}
151
- on:toggle={(event: ToggleEvent) => dialog.onToggle(event.newState === "open")}
153
+ on:toggle={batched((event: ToggleEvent) => dialog.onToggle(event.newState === "open"))}
152
154
  >
153
155
  {isServer ? content : children}
154
156
  </dialog>
@@ -13,6 +13,7 @@ import {
13
13
  type DialogTriggerProps,
14
14
  } from "../dialog/dialog.tsx";
15
15
  import drawerScript from "./drawer.script.ts?client";
16
+ import { notInside } from "../not-inside.ts";
16
17
 
17
18
  /** The direction a swipe closes the drawer in, and the edge of the window it sits against. */
18
19
  export type DrawerSwipeDirection = "up" | "down" | "left" | "right";
@@ -68,7 +69,7 @@ function Root(props: DrawerRootProps) {
68
69
 
69
70
  function Popup({ children, ...rest }: DrawerPopupProps) {
70
71
  const drawer = useContext(DrawerContext);
71
- if (!drawer) throw new Error("Drawer parts must be inside <Drawer.Root>.");
72
+ if (!drawer) throw notInside("Drawer parts", "<Drawer.Root>");
72
73
  return (
73
74
  <Dialog.Popup {...rest} data-swipe-direction={drawer.direction()}>
74
75
  {children}
@@ -31,6 +31,8 @@ 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";
35
+ import { notInside } from "../not-inside.ts";
34
36
 
35
37
  declare module "lumiose/jsx-runtime" {
36
38
  namespace JSX {
@@ -288,7 +290,7 @@ function Root({
288
290
  * (cleared while it waits): its check runs now, and a synchronous one that fails stops the
289
291
  * submission, which the form then reports. An async check still waits for the server.
290
292
  */
291
- const onSubmit = (event: Event) => {
293
+ const onSubmit = batched((event: Event) => {
292
294
  if (!due) return;
293
295
  clearTimeout(timer);
294
296
  due();
@@ -297,7 +299,7 @@ function Root({
297
299
  event.preventDefault();
298
300
  event.stopImmediatePropagation();
299
301
  reportValidity(event.currentTarget as HTMLFormElement);
300
- };
302
+ });
301
303
  effect(() => {
302
304
  if (!validate) return;
303
305
  const element = document.getElementById(controlId(field));
@@ -314,13 +316,13 @@ function Root({
314
316
  }
315
317
  });
316
318
  // 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 = () => {
319
+ const onInvalid = batched((event: Event) => snapshot(event.target));
320
+ const onReset = batched(() => {
319
321
  checked.set(undefined);
320
322
  checking = undefined;
321
323
  // The controls take their default values after the event.
322
324
  setTimeout(() => run(document.getElementById(controlId(field)), 0, true));
323
- };
325
+ });
324
326
  onMount(() => {
325
327
  const element = host();
326
328
  if (!element) return;
@@ -349,15 +351,15 @@ function Root({
349
351
  }}
350
352
  id={id}
351
353
  validation-mode={validationMode ?? form?.validationMode()}
352
- on:input={(event: Event) => {
354
+ on:input={batched((event: Event) => {
353
355
  run(event.target, validationDebounceTime);
354
356
  snapshot(event.target);
355
- }}
356
- on:change={(event: Event) => {
357
+ })}
358
+ on:change={batched((event: Event) => {
357
359
  run(event.target);
358
360
  snapshot(event.target);
359
- }}
360
- on:focusout={(event: Event) => snapshot(event.target)}
361
+ })}
362
+ on:focusout={batched((event: Event) => snapshot(event.target))}
361
363
  >
362
364
  {children}
363
365
  </lu-field>
@@ -372,7 +374,7 @@ function Validity({ children }: FieldValidityProps) {
372
374
 
373
375
  function useFieldPart(part: string): FieldContextValue {
374
376
  const field = useContext(FieldContext);
375
- if (!field) throw new Error(`Field.${part} must be inside <Field.Root>.`);
377
+ if (!field) throw notInside(`Field.${part}`, "<Field.Root>");
376
378
  return field;
377
379
  }
378
380
 
@@ -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,8 @@ 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";
25
+ import { notInside } from "../not-inside.ts";
24
26
 
25
27
  declare module "lumiose/jsx-runtime" {
26
28
  namespace JSX {
@@ -189,7 +191,7 @@ const RadioContext = createContext<{ value: () => string | undefined }>();
189
191
 
190
192
  function useMenu(): MenuContextValue {
191
193
  const context = useContext(MenuContext);
192
- if (!context) throw new Error("Menu parts must be inside <Menu.Root>.");
194
+ if (!context) throw notInside("Menu parts", "<Menu.Root>");
193
195
  return context;
194
196
  }
195
197
 
@@ -244,9 +246,9 @@ function menuRoot(props: MenuRootProps, submenu: boolean) {
244
246
  <lu-menu
245
247
  ref={host}
246
248
  role={context.menubar ? "none" : undefined}
247
- on:openchange={(event: CustomEvent<MenuOpenChangeDetail>) =>
248
- props.onOpenChange?.(event.detail.open)
249
- }
249
+ on:openchange={batched((event: CustomEvent<MenuOpenChangeDetail>) =>
250
+ props.onOpenChange?.(event.detail.open),
251
+ )}
250
252
  >
251
253
  {props.children}
252
254
  </lu-menu>
@@ -377,9 +379,9 @@ function CheckboxItem({
377
379
  {...rest}
378
380
  role="menuitemcheckbox"
379
381
  aria-checked={checked ?? defaultChecked ?? false}
380
- on:checkedchange={(event: CustomEvent<MenuCheckedChangeDetail>) =>
381
- onCheckedChange?.(event.detail.checked)
382
- }
382
+ on:checkedchange={batched((event: CustomEvent<MenuCheckedChangeDetail>) =>
383
+ onCheckedChange?.(event.detail.checked),
384
+ )}
383
385
  >
384
386
  {children}
385
387
  </button>
@@ -416,7 +418,7 @@ function Group(props: MenuGroupProps) {
416
418
 
417
419
  function GroupLabel({ children, ...rest }: MenuGroupLabelProps) {
418
420
  const group = useContext(GroupContext);
419
- if (!group) throw new Error("Menu.GroupLabel must be inside <Menu.Group> or <Menu.RadioGroup>.");
421
+ if (!group) throw notInside("Menu.GroupLabel", "<Menu.Group> or <Menu.RadioGroup>");
420
422
  register(group.labels);
421
423
  return (
422
424
  <div role="presentation" {...rest} id={group.labelId}>
@@ -442,12 +444,12 @@ function RadioGroup({
442
444
  <GroupElement
443
445
  {...rest}
444
446
  group={group}
445
- on:checkedchange={(event: CustomEvent<MenuCheckedChangeDetail>) => {
447
+ on:checkedchange={batched((event: CustomEvent<MenuCheckedChangeDetail>) => {
446
448
  const item = event.target as HTMLButtonElement;
447
449
  if (item.getAttribute("role") === "menuitemradio" && event.detail.checked) {
448
450
  onValueChange?.(item.value);
449
451
  }
450
- }}
452
+ })}
451
453
  >
452
454
  {children}
453
455
  </GroupElement>
@@ -466,7 +468,7 @@ function RadioItem({
466
468
  }: MenuRadioItemProps) {
467
469
  useMenu();
468
470
  const radio = useContext(RadioContext);
469
- if (!radio) throw new Error("Menu.RadioItem must be inside <Menu.RadioGroup>.");
471
+ if (!radio) throw notInside("Menu.RadioItem", "<Menu.RadioGroup>");
470
472
  return (
471
473
  <button
472
474
  type="button"
@@ -6,6 +6,7 @@
6
6
  import { createContext, useContext, useId } from "lumiose";
7
7
  import { withStyle } from "../placement/placement.ts";
8
8
  import { formatValue, percent } from "../progress/value.ts";
9
+ import { notInside } from "../not-inside.ts";
9
10
 
10
11
  interface MeterContextValue {
11
12
  id: string;
@@ -52,7 +53,7 @@ export type MeterValueProps = Omit<JSX.IntrinsicElements["span"], "children"> &
52
53
 
53
54
  function useMeter(part: string): MeterContextValue {
54
55
  const context = useContext(MeterContext);
55
- if (!context) throw new Error(`Meter.${part} must be inside <Meter.Root>.`);
56
+ if (!context) throw notInside(`Meter.${part}`, "<Meter.Root>");
56
57
  return context;
57
58
  }
58
59
 
@@ -15,6 +15,8 @@ import type {
15
15
  NavigationMenuElement,
16
16
  NavigationMenuValueChangeDetail,
17
17
  } from "./navigation-menu.script.ts";
18
+ import { batched } from "../batched.ts";
19
+ import { notInside } from "../not-inside.ts";
18
20
 
19
21
  declare module "lumiose/jsx-runtime" {
20
22
  namespace JSX {
@@ -99,7 +101,7 @@ const ItemContext = createContext<ItemContextValue>();
99
101
 
100
102
  function useItem(part: string): ItemContextValue {
101
103
  const item = useContext(ItemContext);
102
- if (!item) throw new Error(`NavigationMenu.${part} must be inside <NavigationMenu.Item>.`);
104
+ if (!item) throw notInside(`NavigationMenu.${part}`, "<NavigationMenu.Item>");
103
105
  return item;
104
106
  }
105
107
 
@@ -150,9 +152,9 @@ function Root({
150
152
  data-delay={delay}
151
153
  data-close-delay={closeDelay}
152
154
  data-orientation={vertical() ? "vertical" : "horizontal"}
153
- on:valuechange={(event: CustomEvent<NavigationMenuValueChangeDetail>) =>
154
- onValueChange?.(event.detail.value)
155
- }
155
+ on:valuechange={batched((event: CustomEvent<NavigationMenuValueChangeDetail>) =>
156
+ onValueChange?.(event.detail.value),
157
+ )}
156
158
  >
157
159
  {/* A nested menu is a `<div>`, inside its parent's landmark. */}
158
160
  <Dynamic component={nested ? "div" : "nav"} {...attributes()}>
@@ -0,0 +1,13 @@
1
+ // The error a compound component's part throws when the Root (or another part) it needs is not
2
+ // above it. The case that is hard to see is an island in between (TASK-550): a part passed to an
3
+ // island as a slot renders in the page, where the Root the island renders around the slot is not.
4
+
5
+ /** `subject must be inside container.`, with the island hint. */
6
+ export function notInside(subject: string, container: string): Error {
7
+ return new Error(
8
+ `${subject} must be inside ${container}. If this part looks inside, check for an island in between: ` +
9
+ "a part passed to an island as a slot (children or a JSX prop) renders in the page, not inside " +
10
+ "the components the island renders around it. Pass the data as props and render the parts " +
11
+ "inside the island.",
12
+ );
13
+ }
@@ -24,6 +24,8 @@ 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";
28
+ import { notInside } from "../not-inside.ts";
27
29
 
28
30
  declare module "lumiose/jsx-runtime" {
29
31
  namespace JSX {
@@ -164,7 +166,7 @@ const NumberFieldContext = createContext<NumberFieldContextValue>();
164
166
 
165
167
  function useNumberField(part: string): NumberFieldContextValue {
166
168
  const context = useContext(NumberFieldContext);
167
- if (!context) throw new Error(`NumberField.${part} must be inside <NumberField.Root>.`);
169
+ if (!context) throw notInside(`NumberField.${part}`, "<NumberField.Root>");
168
170
  return context;
169
171
  }
170
172
 
@@ -260,11 +262,11 @@ function Root({
260
262
  locale={locale}
261
263
  allow-wheel-scrub={allowWheelScrub}
262
264
  allow-out-of-range={allowOutOfRange}
263
- on:input={report}
264
- on:change={() => {
265
+ on:input={batched(report)}
266
+ on:change={batched(() => {
265
267
  report();
266
268
  onValueCommitted?.(element()?.value ?? null);
267
- }}
269
+ })}
268
270
  >
269
271
  <input
270
272
  type="hidden"