@lumiose/ui 0.1.0 → 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.
- package/README.md +2 -2
- package/dist/accordion/accordion.tsx +3 -2
- package/dist/autocomplete/autocomplete.tsx +6 -5
- package/dist/avatar/avatar.tsx +4 -3
- package/dist/batched.ts +11 -0
- package/dist/checkbox/checkbox.tsx +4 -3
- package/dist/checkbox-group/checkbox-group.tsx +3 -2
- package/dist/collapsible/collapsible.tsx +3 -2
- package/dist/combobox/combobox.tsx +6 -5
- package/dist/context-menu/context-menu.tsx +4 -3
- package/dist/dialog/dialog.tsx +2 -1
- package/dist/field/field.tsx +11 -10
- package/dist/form/reset.ts +4 -2
- package/dist/input/input.tsx +4 -1
- package/dist/menu/menu.tsx +9 -8
- package/dist/navigation-menu/navigation-menu.tsx +4 -3
- package/dist/number-field/number-field.tsx +4 -3
- package/dist/otp-field/otp-field.tsx +9 -8
- package/dist/popover/popover-hover.script.ts +27 -0
- package/dist/popover/popover.script.ts +19 -25
- package/dist/popover/popover.tsx +8 -3
- package/dist/preview-card/preview-card.tsx +2 -1
- package/dist/radio-group/radio-group.tsx +3 -2
- package/dist/select/select.script.ts +26 -0
- package/dist/select/select.tsx +36 -10
- package/dist/slider/slider.tsx +4 -3
- package/dist/switch/switch.tsx +4 -3
- package/dist/tabs/tabs.tsx +3 -2
- package/dist/toast/toast.tsx +3 -2
- package/dist/toggle/toggle.tsx +6 -5
- package/dist/toggle-group/toggle-group.script.ts +12 -4
- package/dist/toggle-group/toggle-group.tsx +16 -4
- package/dist/tooltip/tooltip.tsx +4 -1
- package/docs/README.md +9 -6
- package/docs/accordion.md +3 -2
- package/docs/autocomplete.md +2 -2
- package/docs/avatar.md +2 -2
- package/docs/checkbox-group.md +1 -1
- package/docs/checkbox.md +1 -1
- package/docs/collapsible.md +4 -3
- package/docs/combobox.md +4 -3
- package/docs/context-menu.md +3 -3
- package/docs/dialog.md +1 -1
- package/docs/drawer.md +3 -2
- package/docs/field.md +2 -2
- package/docs/installation.md +3 -3
- package/docs/llms-full.txt +107 -72
- package/docs/llms.txt +14 -14
- package/docs/menu.md +4 -3
- package/docs/menubar.md +3 -3
- package/docs/navigation-menu.md +4 -3
- package/docs/otp-field.md +2 -2
- package/docs/popover.md +7 -4
- package/docs/preview-card.md +4 -3
- package/docs/radio-group.md +1 -1
- package/docs/select.md +25 -10
- package/docs/tabs.md +1 -1
- package/docs/toast.md +2 -2
- package/docs/toggle-group.md +10 -4
- package/docs/toolbar.md +2 -2
- package/docs/tooltip.md +4 -3
- 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
|
|
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>
|
package/dist/avatar/avatar.tsx
CHANGED
|
@@ -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
|
}
|
package/dist/batched.ts
ADDED
|
@@ -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
|
package/dist/dialog/dialog.tsx
CHANGED
|
@@ -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>
|
package/dist/field/field.tsx
CHANGED
|
@@ -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>
|
package/dist/form/reset.ts
CHANGED
|
@@ -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(() => {
|
package/dist/input/input.tsx
CHANGED
|
@@ -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) =>
|
|
65
|
+
on:input={batched((event: Event) =>
|
|
66
|
+
onValueChange?.((event.currentTarget as HTMLInputElement).value),
|
|
67
|
+
)}
|
|
65
68
|
/>
|
|
66
69
|
);
|
|
67
70
|
}
|
package/dist/menu/menu.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
140
|
+
else if (hovered(popup)) {
|
|
139
141
|
event.preventDefault();
|
|
140
|
-
hover
|
|
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
|
|
163
|
+
// Open on hover: popover-hover.script.ts, which only hover triggers load, hands its timing here.
|
|
162
164
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
235
|
+
const wasHovered = hovered(popup);
|
|
242
236
|
const held = holding.has(popup);
|
|
243
237
|
holding.delete(popup);
|
|
244
|
-
hover
|
|
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;
|
package/dist/popover/popover.tsx
CHANGED
|
@@ -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
|
|
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>
|