@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
@@ -12,6 +12,8 @@ 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";
16
+ import { notInside } from "../not-inside.ts";
15
17
 
16
18
  declare module "lumiose/jsx-runtime" {
17
19
  namespace JSX {
@@ -144,7 +146,7 @@ const OTPFieldContext = createContext<OTPFieldContextValue>();
144
146
 
145
147
  function useOTPField(part: string): OTPFieldContextValue {
146
148
  const context = useContext(OTPFieldContext);
147
- if (!context) throw new Error(`OTPField.${part} must be inside <OTPField.Root>.`);
149
+ if (!context) throw notInside(`OTPField.${part}`, "<OTPField.Root>");
148
150
  return context;
149
151
  }
150
152
 
@@ -234,19 +236,19 @@ function Root({
234
236
  validation-type={type()}
235
237
  mask={mask}
236
238
  auto-submit={autoSubmit}
237
- on:input={(event: Event) => {
239
+ on:input={batched((event: Event) => {
238
240
  const input = element()?.input;
239
241
  // A composition reports once committed (the element fires `input` then, TASK-432).
240
242
  if (input && event.target === input && !(event as InputEvent).isComposing) {
241
243
  onValueChange?.(input.value);
242
244
  }
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
- }
245
+ })}
246
+ on:valuecomplete={batched((event: CustomEvent<OTPFieldValueDetail>) =>
247
+ onValueComplete?.(event.detail.value),
248
+ )}
249
+ on:valueinvalid={batched((event: CustomEvent<OTPFieldValueDetail>) =>
250
+ onValueInvalid?.(event.detail.value),
251
+ )}
250
252
  >
251
253
  {children}
252
254
  </lu-otp-field>
@@ -157,7 +157,8 @@ export function hoverIntent(source: HoverSource): HoverIntent {
157
157
  const { popup, x, y } = corridor;
158
158
  // Arrived (the popup's `pointerover` cancelled the close), or closed already.
159
159
  if (!closing.has(popup)) corridor = undefined;
160
- else if (toward(popup.getBoundingClientRect(), x, y, event.clientX, event.clientY)) close(popup);
160
+ else if (toward(popup.getBoundingClientRect(), x, y, event.clientX, event.clientY))
161
+ close(popup);
161
162
  else corridor = undefined;
162
163
  });
163
164
 
@@ -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,10 @@ 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";
31
+ import { notInside } from "../not-inside.ts";
28
32
 
29
33
  /**
30
34
  * `Popover.Root` renders no element: it holds the ids its parts share. `bind:open={signal}`
@@ -94,7 +98,7 @@ const PopoverContext = createContext<PopoverContextValue>();
94
98
 
95
99
  function usePopover(): PopoverContextValue {
96
100
  const context = useContext(PopoverContext);
97
- if (!context) throw new Error("Popover parts must be inside <Popover.Root>.");
101
+ if (!context) throw notInside("Popover parts", "<Popover.Root>");
98
102
  return context;
99
103
  }
100
104
 
@@ -171,11 +175,16 @@ function Trigger({
171
175
  ? { command: "show-modal", commandfor: popover.popupId, "aria-expanded": "false" as const }
172
176
  : { popovertarget: popover.popupId };
173
177
  // A function, so hover opening and its delays follow the props: the script reads them from
174
- // the trigger at each hover (TASK-477).
175
- const hover = () =>
176
- openOnHover && !popover.modal && !off
177
- ? { "data-open-on-hover": "", "data-delay": delay, "data-close-delay": closeDelay }
178
- : {};
178
+ // the trigger at each hover (TASK-477). Only a hover trigger loads the hover timing (TASK-541),
179
+ // on a server page or in an island, once `openOnHover` is on: the call is conditional, so an
180
+ // island loads the script when it is made (TASK-551).
181
+ const hover = () => {
182
+ if (openOnHover && !popover.modal && !off) {
183
+ popoverHoverScript();
184
+ return { "data-open-on-hover": "", "data-delay": delay, "data-close-delay": closeDelay };
185
+ }
186
+ return {};
187
+ };
179
188
  return (
180
189
  <button
181
190
  type="button"
@@ -228,7 +237,7 @@ function Popup({
228
237
  ref={attach}
229
238
  id={popover.popupId}
230
239
  closedby="any"
231
- on:toggle={toggle}
240
+ on:toggle={batched(toggle)}
232
241
  >
233
242
  {isServer ? content : inside()}
234
243
  </dialog>
@@ -247,7 +256,7 @@ function Popup({
247
256
  id={popover.popupId}
248
257
  popover="auto"
249
258
  data-trap-focus={popover.trapFocus ? "" : undefined}
250
- on:toggle={toggle}
259
+ on:toggle={batched(toggle)}
251
260
  >
252
261
  {isServer ? content : inside()}
253
262
  </div>
@@ -7,6 +7,8 @@
7
7
  import { createContext, effect, ref, useContext, useId } from "lumiose";
8
8
  import { anchorName, placement, withStyle, type PlacementProps } from "../placement/placement.ts";
9
9
  import previewCardScript from "./preview-card.script.ts?client";
10
+ import { batched } from "../batched.ts";
11
+ import { notInside } from "../not-inside.ts";
10
12
 
11
13
  /**
12
14
  * `PreviewCard.Root` renders no element: it holds the ids its parts share. `bind:open={signal}`
@@ -43,7 +45,7 @@ const PreviewCardContext = createContext<PreviewCardContextValue>();
43
45
 
44
46
  function usePreviewCard(part: string): PreviewCardContextValue {
45
47
  const context = useContext(PreviewCardContext);
46
- if (!context) throw new Error(`PreviewCard.${part} must be inside <PreviewCard.Root>.`);
48
+ if (!context) throw notInside(`PreviewCard.${part}`, "<PreviewCard.Root>");
47
49
  return context;
48
50
  }
49
51
 
@@ -106,7 +108,7 @@ function Popup({
106
108
  id={card.popupId}
107
109
  popover="manual"
108
110
  data-preview-card-popup=""
109
- on:toggle={(event: ToggleEvent) => card.onToggle(event.newState === "open")}
111
+ on:toggle={batched((event: ToggleEvent) => card.onToggle(event.newState === "open"))}
110
112
  >
111
113
  {children}
112
114
  </div>
@@ -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 "./value.ts";
9
+ import { notInside } from "../not-inside.ts";
9
10
 
10
11
  interface ProgressContextValue {
11
12
  id: string;
@@ -52,7 +53,7 @@ export type ProgressValueProps = Omit<JSX.IntrinsicElements["span"], "children">
52
53
 
53
54
  function useProgress(part: string): ProgressContextValue {
54
55
  const context = useContext(ProgressContext);
55
- if (!context) throw new Error(`Progress.${part} must be inside <Progress.Root>.`);
56
+ if (!context) throw notInside(`Progress.${part}`, "<Progress.Root>");
56
57
  return context;
57
58
  }
58
59
 
@@ -7,6 +7,8 @@ import { createContext, effect, ref, signal, untrack, useContext, useId } from "
7
7
  import checkboxScript from "../checkbox/checkbox.script.ts?client";
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";
11
+ import { notInside } from "../not-inside.ts";
10
12
 
11
13
  interface RadioGroupContextValue {
12
14
  name: () => string;
@@ -121,12 +123,12 @@ export function RadioGroup({
121
123
  role="radiogroup"
122
124
  aria-required={required ? "true" : undefined}
123
125
  aria-readonly={readOnly ? "true" : undefined}
124
- on:change={(event: Event) => {
126
+ on:change={batched((event: Event) => {
125
127
  const radio = event.target as HTMLInputElement;
126
128
  if (radio.type !== "radio" || !radio.checked) return;
127
129
  selected.set(radio.value);
128
130
  onValueChange?.(radio.value);
129
- }}
131
+ })}
130
132
  >
131
133
  {children}
132
134
  </fieldset>
@@ -136,7 +138,7 @@ export function RadioGroup({
136
138
 
137
139
  function Root({ value, ...rest }: RadioRootProps) {
138
140
  const group = useContext(RadioGroupContext);
139
- if (!group) throw new Error("Radio.Root must be inside <RadioGroup>.");
141
+ if (!group) throw notInside("Radio.Root", "<RadioGroup>");
140
142
  return (
141
143
  <input
142
144
  required={group.required() || undefined}
@@ -0,0 +1,26 @@
1
+ // Select's client script (decision-63, TASK-537), loaded by a Select whose items have a `label`.
2
+ // A customizable select's picker (`appearance: base-select`) shows an option's `label` attribute
3
+ // instead of its content, through the browser's own style, which no CSS overrides. When such a
4
+ // select is pressed or takes focus, before its picker opens, this script removes the attribute from
5
+ // the options that Select.Item rendered with a label: the picker then shows their content,
6
+ // typeahead matches the label's hidden copy first in it, and their `aria-label` keeps their name.
7
+ // The classic select keeps the attribute, which it shows and typeahead matches; so does server HTML
8
+ // before the script runs, where the customizable picker shows the label.
9
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
10
+
11
+ /** The hidden copy of a label that Select.Item puts first in its option. */
12
+ const LABELLED = "option[label] > [data-label]:first-child";
13
+
14
+ /** Shows the content of the labelled items of the customizable select at or around `target`. */
15
+ export function showContent(target: EventTarget | null): void {
16
+ const select = (target as Element | null)?.closest?.("select");
17
+ if (!select || getComputedStyle(select).appearance !== "base-select") return;
18
+ for (const copy of select.querySelectorAll(LABELLED))
19
+ copy.parentElement!.removeAttribute("label");
20
+ }
21
+
22
+ for (const type of ["pointerdown", "focusin"]) {
23
+ document.addEventListener(type, (event) => showContent(event.target), true);
24
+ }
25
+ // A select focused before the script ran (`autofocus`).
26
+ showContent(document.activeElement);
@@ -3,14 +3,23 @@
3
3
  // already does everything Base UI's Select does around the value: the combobox role, the keyboard
4
4
  // and typeahead (on the closed control too), the top-layer picker anchored to the control, forms
5
5
  // (`name`, `required`, autofill, reset, a disabled fieldset) and the mobile pickers. So Select
6
- // ships no script: it works in server HTML, with no JavaScript, and in islands, where `value` drives
7
- // the control when it changes and its `change` event reports back through `onValueChange`. Where
8
- // customizable select is supported, `Select.Trigger` and `Select.Value` style the button and show
9
- // the selected option's content, and options may hold more than text. TASK-358 records why it is
10
- // native rather than the listbox pattern.
6
+ // works in server HTML, with no JavaScript, and in islands, where `value` drives the control when
7
+ // it changes and its `change` event reports back through `onValueChange`. Where customizable select
8
+ // is supported, `Select.Trigger` and `Select.Value` style the button and show the selected option's
9
+ // content, and options may hold more than text. TASK-358 records why it is native rather than the
10
+ // listbox pattern.
11
+ //
12
+ // An item's `label` (TASK-537) names it: the option's `label` attribute, which the classic select
13
+ // shows and typeahead matches, a hidden copy first in its content, which typeahead matches once
14
+ // the attribute is gone, and its `aria-label`. A customizable select's picker shows an option's
15
+ // `label` attribute instead of its content (the browser's style, which no CSS overrides), so
16
+ // select.script.ts removes the attribute from a customizable select's items as it takes focus.
11
17
  import { createContext, effect, ref, untrack, useContext } from "lumiose";
18
+ import selectScript from "./select.script.ts?client";
12
19
  import { fieldProps, useField, useFormValues } from "../field/context.ts";
13
20
  import { onFormReset } from "../form/reset.ts";
21
+ import { batched } from "../batched.ts";
22
+ import { notInside } from "../not-inside.ts";
14
23
 
15
24
  declare module "lumiose/jsx-runtime" {
16
25
  namespace JSX {
@@ -67,6 +76,11 @@ export type SelectValueProps = Omit<JSX.IntrinsicElements["selectedcontent"], "c
67
76
  export type SelectItemProps = Omit<JSX.IntrinsicElements["option"], "value" | "selected"> & {
68
77
  /** The value the select takes, and submits, when the option is selected. */
69
78
  value: string;
79
+ /**
80
+ * The option's name, for typeahead, assistive technology and the classic select, when its
81
+ * content starts with an icon or holds more than text. The picker shows the content.
82
+ */
83
+ label?: string;
70
84
  /** The option cannot be selected. */
71
85
  disabled?: boolean;
72
86
  };
@@ -112,9 +126,9 @@ function Root({ value, defaultValue, onValueChange, children, ...rest }: SelectR
112
126
  select(element);
113
127
  rest.ref?.(element);
114
128
  }}
115
- on:change={(event: Event) =>
116
- onValueChange?.((event.currentTarget as HTMLSelectElement).value)
117
- }
129
+ on:change={batched((event: Event) =>
130
+ onValueChange?.((event.currentTarget as HTMLSelectElement).value),
131
+ )}
118
132
  >
119
133
  {children}
120
134
  </select>
@@ -134,11 +148,29 @@ function Value(props: SelectValueProps) {
134
148
  return <selectedcontent {...props} />;
135
149
  }
136
150
 
137
- function Item({ children, value, ...rest }: SelectItemProps) {
151
+ function Item({ children, value, label, ...rest }: SelectItemProps) {
138
152
  const select = useContext(SelectContext);
139
- if (!select) throw new Error("Select parts must be inside <Select.Root>.");
153
+ if (!select) throw notInside("Select parts", "<Select.Root>");
154
+ // Only items with a label need the script. Read where the attribute is, so a label that appears
155
+ // after an island's first render still loads it (TASK-552; the call is conditional, so an island
156
+ // loads the script when it is made, TASK-551).
157
+ const labelled = () => {
158
+ if (label !== undefined) selectScript();
159
+ return label;
160
+ };
140
161
  return (
141
- <option {...rest} value={value} selected={select.initial === value || undefined}>
162
+ <option
163
+ aria-label={label}
164
+ {...rest}
165
+ label={labelled()}
166
+ value={value}
167
+ selected={select.initial === value || undefined}
168
+ >
169
+ {label === undefined ? null : (
170
+ <span hidden data-label="">
171
+ {label}
172
+ </span>
173
+ )}
142
174
  {children}
143
175
  </option>
144
176
  );
@@ -13,6 +13,8 @@ import { percent } from "../progress/value.ts";
13
13
  import { formatValues } from "./format.ts";
14
14
  import type { SliderElement } from "./slider.script.ts";
15
15
  import sliderScript from "./slider.script.ts?client";
16
+ import { batched } from "../batched.ts";
17
+ import { notInside } from "../not-inside.ts";
16
18
 
17
19
  declare module "lumiose/jsx-runtime" {
18
20
  namespace JSX {
@@ -155,7 +157,7 @@ const SliderContext = createContext<SliderContextValue>();
155
157
 
156
158
  function useSlider(part: string): SliderContextValue {
157
159
  const context = useContext(SliderContext);
158
- if (!context) throw new Error(`Slider.${part} must be inside <Slider.Root>.`);
160
+ if (!context) throw notInside(`Slider.${part}`, "<Slider.Root>");
159
161
  return context;
160
162
  }
161
163
 
@@ -259,12 +261,12 @@ function Root<V extends SliderValue = number>({
259
261
  format={format ? JSON.stringify(format) : undefined}
260
262
  locale={locale}
261
263
  style={withStyle(style, untrack(range))}
262
- on:input={() => {
264
+ on:input={batched(() => {
263
265
  const next = current();
264
266
  values.set(asList(next, low()));
265
267
  onValueChange?.(next);
266
- }}
267
- on:change={() => onValueCommitted?.(current())}
268
+ })}
269
+ on:change={batched(() => onValueCommitted?.(current()))}
268
270
  >
269
271
  {children}
270
272
  </lu-slider>
@@ -7,6 +7,7 @@ import { ref, untrack } from "lumiose";
7
7
  import checkboxScript from "../checkbox/checkbox.script.ts?client";
8
8
  import { fieldProps, useField, useFormValues } from "../field/context.ts";
9
9
  import { onFormReset } from "../form/reset.ts";
10
+ import { batched } from "../batched.ts";
10
11
 
11
12
  /**
12
13
  * The native checkbox props Root passes through, without the brand of intrinsic elements' props,
@@ -73,9 +74,9 @@ function Root({
73
74
  checked={checked ?? initial}
74
75
  aria-readonly={readOnly ? "true" : undefined}
75
76
  data-unchecked-value={uncheckedValue}
76
- on:change={(event: Event) =>
77
- onCheckedChange?.((event.currentTarget as HTMLInputElement).checked)
78
- }
77
+ on:change={batched((event: Event) =>
78
+ onCheckedChange?.((event.currentTarget as HTMLInputElement).checked),
79
+ )}
79
80
  />
80
81
  );
81
82
  }
@@ -19,6 +19,8 @@ import {
19
19
  import { withStyle } from "../placement/placement.ts";
20
20
  import defineTabs from "./tabs.script.ts?client";
21
21
  import type { LuTabs } from "./tabs.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 {
@@ -113,7 +115,7 @@ const TabsContext = createContext<TabsContextValue>();
113
115
 
114
116
  function useTabs(): TabsContextValue {
115
117
  const context = useContext(TabsContext);
116
- if (!context) throw new Error("Tabs parts must be inside <Tabs.Root>.");
118
+ if (!context) throw notInside("Tabs parts", "<Tabs.Root>");
117
119
  return context;
118
120
  }
119
121
 
@@ -166,12 +168,12 @@ function Root({
166
168
  ref={element}
167
169
  // The anchor the selected tab carries for a Tabs.Indicator (tabs.css).
168
170
  style={withStyle(rest.style, { "--lu-tabs-anchor": `--lu-tabs-${id}` })}
169
- on:valuechange={() => {
171
+ on:valuechange={batched(() => {
170
172
  const next = element()?.value;
171
173
  if (next == null) return;
172
174
  selected.set(next);
173
175
  onValueChange?.(next);
174
- }}
176
+ })}
175
177
  >
176
178
  {children}
177
179
  </lu-tabs>
@@ -16,6 +16,8 @@ import { afterAnimations } from "../animation/animation.ts";
16
16
  import toastScript from "./toast.script.ts?client";
17
17
  import { CLOSE_COMMAND } from "./command.ts";
18
18
  import type { LuToastViewport } from "./toast.script.ts";
19
+ import { batched } from "../batched.ts";
20
+ import { notInside } from "../not-inside.ts";
19
21
 
20
22
  declare module "lumiose/jsx-runtime" {
21
23
  namespace JSX {
@@ -254,7 +256,7 @@ const ToastItemContext = createContext<ToastItemContextValue>();
254
256
 
255
257
  function useToastItem(): ToastItemContextValue {
256
258
  const context = useContext(ToastItemContext);
257
- if (!context) throw new Error("Toast parts must be inside <Toast.Root>.");
259
+ if (!context) throw notInside("Toast parts", "<Toast.Root>");
258
260
  return context;
259
261
  }
260
262
 
@@ -326,12 +328,12 @@ function Root({ toast, swipeDirection, children, ref: forwarded, ...rest }: Toas
326
328
  data-timeout={String(toast.timeout ?? provider?.timeout() ?? DEFAULT_TIMEOUT)}
327
329
  data-swipe-directions={(swipeDirection ?? ["down", "right"]).join(" ") || undefined}
328
330
  data-ending-style={ending() ? "" : undefined}
329
- on:close={(event: Event) => {
331
+ on:close={batched((event: Event) => {
330
332
  // The script closes it: a managed toast leaves through its manager.
331
333
  if (!owner) return;
332
334
  event.preventDefault();
333
335
  owner.close(toast.id!);
334
- }}
336
+ })}
335
337
  >
336
338
  {children}
337
339
  </div>
@@ -6,6 +6,7 @@ import { effect, onCleanup, signal, untrack, useContext } from "lumiose";
6
6
  import type { ToggleChangeDetail } from "../toggle-group/toggle-group.script.ts";
7
7
  import { ToolbarContext } from "../toolbar/context.ts";
8
8
  import { ToggleGroupContext, type ToggleRecord } from "./context.ts";
9
+ import { batched } from "../batched.ts";
9
10
 
10
11
  /**
11
12
  * The button props Toggle passes through, without the brand of intrinsic elements' props, so that
@@ -82,16 +83,16 @@ export function Toggle({
82
83
  aria-disabled={toolbar?.disabled() ? "true" : rest["aria-disabled"]}
83
84
  value={value}
84
85
  aria-pressed={on() ? "true" : "false"}
85
- on:click={(event: MouseEvent) => {
86
+ on:click={batched((event: MouseEvent) => {
86
87
  // In a group, the group's element presses it and reports `pressedchange`.
87
88
  if (group || event.defaultPrevented) return;
88
89
  const next = !state();
89
90
  state.set(next);
90
91
  onPressedChange?.(next);
91
- }}
92
- on:pressedchange={(event: CustomEvent<ToggleChangeDetail>) =>
93
- onPressedChange?.(event.detail.pressed)
94
- }
92
+ })}
93
+ on:pressedchange={batched((event: CustomEvent<ToggleChangeDetail>) =>
94
+ onPressedChange?.(event.detail.pressed),
95
+ )}
95
96
  >
96
97
  {children}
97
98
  </button>
@@ -51,16 +51,24 @@ export class ToggleGroupElement extends HTMLElement {
51
51
  );
52
52
  }
53
53
 
54
- /** The pressed toggles' values. Setting it presses those toggles, without events. */
54
+ /**
55
+ * The pressed toggles' values. Setting it presses those toggles, without events; a value equal
56
+ * to the current one changes nothing and measures nothing (TASK-539).
57
+ */
55
58
  get value(): string[] {
56
59
  return this.items.filter(pressed).map((item) => item.value);
57
60
  }
58
61
 
59
62
  set value(next: readonly string[] | null | undefined) {
60
63
  const values = next ?? [];
61
- for (const item of this.items)
62
- item.setAttribute("aria-pressed", String(values.includes(item.value)));
63
- this.#rove();
64
+ let changed = false;
65
+ for (const item of this.items) {
66
+ const on = String(values.includes(item.value));
67
+ if (item.getAttribute("aria-pressed") === on) continue;
68
+ item.setAttribute("aria-pressed", on);
69
+ changed = true;
70
+ }
71
+ if (changed) this.#rove();
64
72
  }
65
73
 
66
74
  /** Whether several toggles can be pressed; reflects the `multiple` attribute. */