@lumiose/ui 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Autocomplete (decision-62, TASK-377): a text input with suggestions, after Base UI's Autocomplete
|
|
2
|
+
// and the APG combobox with list autocomplete. The text is the value: the input has the name and
|
|
3
|
+
// submits what is typed, and choosing a suggestion only fills it in. It is a Combobox in free mode:
|
|
4
|
+
// Root renders the same `<lu-combobox>` with `free` (combobox.script.ts), and the other parts are
|
|
5
|
+
// Combobox's, reading this Root's context. The `value` prop drives the input's text when it
|
|
6
|
+
// changes; the input's `input` events report back through `onValueChange`.
|
|
7
|
+
import { effect, ref, signal, untrack, useId } from "lumiose";
|
|
8
|
+
import {
|
|
9
|
+
Combobox,
|
|
10
|
+
type ComboboxClearProps,
|
|
11
|
+
type ComboboxEmptyProps,
|
|
12
|
+
type ComboboxGroupLabelProps,
|
|
13
|
+
type ComboboxGroupProps,
|
|
14
|
+
type ComboboxInputProps,
|
|
15
|
+
type ComboboxItemProps,
|
|
16
|
+
type ComboboxLabelProps,
|
|
17
|
+
type ComboboxListProps,
|
|
18
|
+
type ComboboxPopupProps,
|
|
19
|
+
type ComboboxStatusProps,
|
|
20
|
+
type ComboboxTriggerProps,
|
|
21
|
+
} from "../combobox/combobox.tsx";
|
|
22
|
+
import type { ComboboxElement, ComboboxOpenChangeDetail } from "../combobox/combobox.script.ts";
|
|
23
|
+
import comboboxScript from "../combobox/combobox.script.ts?client";
|
|
24
|
+
import { ComboboxContext, type ComboboxContextValue } from "../combobox/context.ts";
|
|
25
|
+
import { controlId, useField, useFormValues } from "../field/context.ts";
|
|
26
|
+
import { onFormReset } from "../form/reset.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How the suggestions complete the text, as in Base UI: `"list"` filters them by the text,
|
|
30
|
+
* `"both"` also shows the highlighted one in the input, `"inline"` only does that (no filtering),
|
|
31
|
+
* `"none"` does neither (the page filters them, as with results from a server).
|
|
32
|
+
*/
|
|
33
|
+
export type AutocompleteMode = "list" | "both" | "inline" | "none";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The element props Root passes to `<lu-combobox>`, without the brand of intrinsic elements' props,
|
|
37
|
+
* so that `bind:value` and `bind:open` apply to Root.
|
|
38
|
+
*/
|
|
39
|
+
type ElementProps = {
|
|
40
|
+
[
|
|
41
|
+
K in keyof JSX.HTMLProps<ComboboxElement> as K extends string ? K : never
|
|
42
|
+
]: JSX.HTMLProps<ComboboxElement>[K];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `Autocomplete.Root` renders `<lu-combobox free>` around the other parts. `bind:value={signal}`
|
|
47
|
+
* stands in for `value` and `onValueChange`, `bind:open` for `open` and `onOpenChange`.
|
|
48
|
+
*/
|
|
49
|
+
export interface AutocompleteRootProps extends Omit<
|
|
50
|
+
ElementProps,
|
|
51
|
+
"children" | "onChange" | "onInput"
|
|
52
|
+
> {
|
|
53
|
+
/** Puts this text in the input when it changes. Pair it with `onValueChange`. */
|
|
54
|
+
value?: string;
|
|
55
|
+
/** The text at first, when `value` is left out; a form reset goes back to it. */
|
|
56
|
+
defaultValue?: string;
|
|
57
|
+
/** Called as the text changes: typing, choosing a suggestion, clearing. */
|
|
58
|
+
onValueChange?: (value: string) => void;
|
|
59
|
+
/** Opens (`true`) or closes (`false`) the suggestions when it changes, in the browser. */
|
|
60
|
+
open?: boolean;
|
|
61
|
+
/** Called after the suggestions open or close, whatever opened or closed them. */
|
|
62
|
+
onOpenChange?: (open: boolean) => void;
|
|
63
|
+
/** How the suggestions complete the text. Default `"list"`. */
|
|
64
|
+
mode?: AutocompleteMode;
|
|
65
|
+
/**
|
|
66
|
+
* How `"list"` and `"both"` filter the suggestions by their text: `"contains"` (default) or
|
|
67
|
+
* `"startsWith"`, ignoring case and accents.
|
|
68
|
+
*/
|
|
69
|
+
filter?: "contains" | "startsWith";
|
|
70
|
+
/** A click on the input opens the suggestions too; otherwise typing and Arrow Down do. */
|
|
71
|
+
openOnInputClick?: boolean;
|
|
72
|
+
/** The first suggestion is highlighted as you type, so Enter chooses it. */
|
|
73
|
+
autoHighlight?: boolean;
|
|
74
|
+
/** A suggestion the pointer highlighted stays highlighted when the pointer leaves the list. */
|
|
75
|
+
keepHighlight?: boolean;
|
|
76
|
+
/** A click on a suggestion submits the form, as a search box does. */
|
|
77
|
+
submitOnItemClick?: boolean;
|
|
78
|
+
/** Shows at most this many suggestions; a narrower text shows the others. */
|
|
79
|
+
limit?: number;
|
|
80
|
+
/** The name the text is submitted under (the input's). */
|
|
81
|
+
name?: string;
|
|
82
|
+
/** The form needs a text. */
|
|
83
|
+
required?: boolean;
|
|
84
|
+
/** The autocomplete cannot be used. */
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
/** The other parts: `Label`, `Input`, the buttons, and the `List` or `Popup`. */
|
|
87
|
+
children?: JSX.Element;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The `<label>` of the input. */
|
|
91
|
+
export type AutocompleteLabelProps = ComboboxLabelProps;
|
|
92
|
+
/** The `<input role="combobox">`: it has the name and submits the text. */
|
|
93
|
+
export type AutocompleteInputProps = ComboboxInputProps;
|
|
94
|
+
/** A `<button>` that opens and closes the suggestions, out of the tab order. */
|
|
95
|
+
export type AutocompleteTriggerProps = ComboboxTriggerProps;
|
|
96
|
+
/** A `<button>` that empties the text, out of the tab order and hidden while it is empty. */
|
|
97
|
+
export type AutocompleteClearProps = ComboboxClearProps;
|
|
98
|
+
/** A `<div popover>` around the `List`, for an empty state or a status beside the suggestions. */
|
|
99
|
+
export type AutocompletePopupProps = ComboboxPopupProps;
|
|
100
|
+
/** The `<div role="listbox">` of suggestions: the popup itself, unless it is inside a `Popup`. */
|
|
101
|
+
export type AutocompleteListProps = ComboboxListProps;
|
|
102
|
+
/** A suggestion, a `<div role="option">`: its `value` is the text it puts in the input. */
|
|
103
|
+
export type AutocompleteItemProps = ComboboxItemProps;
|
|
104
|
+
/** A `<div role="group">` of suggestions, named by its `GroupLabel`. */
|
|
105
|
+
export type AutocompleteGroupProps = ComboboxGroupProps;
|
|
106
|
+
/** A `<div role="presentation">` that names its group. */
|
|
107
|
+
export type AutocompleteGroupLabelProps = ComboboxGroupLabelProps;
|
|
108
|
+
/** A `<div role="status">` shown when no suggestion matches. */
|
|
109
|
+
export type AutocompleteEmptyProps = ComboboxEmptyProps;
|
|
110
|
+
/** A `<div role="status">` for a loading or result-count message. */
|
|
111
|
+
export type AutocompleteStatusProps = ComboboxStatusProps;
|
|
112
|
+
|
|
113
|
+
function Root({
|
|
114
|
+
value,
|
|
115
|
+
defaultValue,
|
|
116
|
+
onValueChange,
|
|
117
|
+
open,
|
|
118
|
+
onOpenChange,
|
|
119
|
+
mode,
|
|
120
|
+
filter,
|
|
121
|
+
openOnInputClick,
|
|
122
|
+
autoHighlight,
|
|
123
|
+
keepHighlight,
|
|
124
|
+
submitOnItemClick,
|
|
125
|
+
limit,
|
|
126
|
+
name,
|
|
127
|
+
required,
|
|
128
|
+
disabled,
|
|
129
|
+
children,
|
|
130
|
+
...rest
|
|
131
|
+
}: AutocompleteRootProps) {
|
|
132
|
+
// The behaviour, on the server HTML and in islands; the page loads it once (decision-63).
|
|
133
|
+
comboboxScript();
|
|
134
|
+
const id = useId();
|
|
135
|
+
const field = useField();
|
|
136
|
+
const host = ref<ComboboxElement>();
|
|
137
|
+
const completion = untrack(() => mode ?? "list");
|
|
138
|
+
const sent = useFormValues(
|
|
139
|
+
untrack(() => name ?? field?.name()),
|
|
140
|
+
untrack(() => disabled),
|
|
141
|
+
);
|
|
142
|
+
const context: ComboboxContextValue = {
|
|
143
|
+
id,
|
|
144
|
+
inputId: field ? controlId(field) : `${id}-input`,
|
|
145
|
+
selected: () => [],
|
|
146
|
+
multiple: () => false,
|
|
147
|
+
disabled: () => disabled === true || field?.disabled() === true,
|
|
148
|
+
labels: signal(0),
|
|
149
|
+
popup: false,
|
|
150
|
+
text: {
|
|
151
|
+
name: () => name ?? field?.name(),
|
|
152
|
+
required: () => required,
|
|
153
|
+
// A failed submission's text (a Form's `values`) replaces the default (TASK-430).
|
|
154
|
+
initial: untrack(() => value ?? sent?.[0] ?? defaultValue),
|
|
155
|
+
autocomplete: completion,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// The props drive the input and the popup when they change; the events report back.
|
|
160
|
+
effect(() => {
|
|
161
|
+
const next = value;
|
|
162
|
+
const input = host()?.input;
|
|
163
|
+
if (next !== undefined && input && input.value !== next) input.value = next;
|
|
164
|
+
});
|
|
165
|
+
effect(() => {
|
|
166
|
+
const element = host();
|
|
167
|
+
const wanted = open;
|
|
168
|
+
if (element?.isConnected && wanted !== undefined) element.open = wanted;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// A form reset puts the input's text back without an `input` event: it goes to
|
|
172
|
+
// `onValueChange` (TASK-435).
|
|
173
|
+
if (onValueChange) {
|
|
174
|
+
onFormReset(
|
|
175
|
+
() => host()?.input ?? undefined,
|
|
176
|
+
() => host()?.input?.value ?? "",
|
|
177
|
+
onValueChange,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<ComboboxContext value={context}>
|
|
183
|
+
<lu-combobox
|
|
184
|
+
{...rest}
|
|
185
|
+
ref={host}
|
|
186
|
+
id={id}
|
|
187
|
+
free
|
|
188
|
+
inline={completion === "both" || completion === "inline"}
|
|
189
|
+
filter={completion === "inline" || completion === "none" ? "none" : filter}
|
|
190
|
+
open-on-input-click={openOnInputClick}
|
|
191
|
+
auto-highlight={autoHighlight}
|
|
192
|
+
keep-highlight={keepHighlight}
|
|
193
|
+
submit-on-item-click={submitOnItemClick}
|
|
194
|
+
limit={limit}
|
|
195
|
+
disabled={context.disabled()}
|
|
196
|
+
on:input={(event: Event) => {
|
|
197
|
+
const input = host()?.input;
|
|
198
|
+
if (input && event.target === input) onValueChange?.(input.value);
|
|
199
|
+
}}
|
|
200
|
+
on:openchange={(event: CustomEvent<ComboboxOpenChangeDetail>) =>
|
|
201
|
+
onOpenChange?.(event.detail.open)
|
|
202
|
+
}
|
|
203
|
+
>
|
|
204
|
+
{children}
|
|
205
|
+
</lu-combobox>
|
|
206
|
+
</ComboboxContext>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A text input with suggestions, for text that may be anything (a search, a tag, an address):
|
|
212
|
+
* the APG combobox with list autocomplete, after Base UI's Autocomplete. Unlike a Combobox, the
|
|
213
|
+
* text is the value and no suggestion has to be chosen; the input submits it. `<lu-combobox>`'s
|
|
214
|
+
* script runs it, on server-rendered pages too. Unstyled: style the parts through `class` and
|
|
215
|
+
* `[aria-expanded]`, `:popover-open`, `[aria-disabled]`, `[data-highlighted]`.
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* <Autocomplete.Root name="search" bind:value={query}>
|
|
219
|
+
* <Autocomplete.Label>Search</Autocomplete.Label>
|
|
220
|
+
* <Autocomplete.Input />
|
|
221
|
+
* <Autocomplete.List>
|
|
222
|
+
* <Autocomplete.Item value="lumiose">lumiose</Autocomplete.Item>
|
|
223
|
+
* </Autocomplete.List>
|
|
224
|
+
* </Autocomplete.Root>
|
|
225
|
+
*/
|
|
226
|
+
export const Autocomplete = {
|
|
227
|
+
Root,
|
|
228
|
+
Label: Combobox.Label,
|
|
229
|
+
Input: Combobox.Input,
|
|
230
|
+
Trigger: Combobox.Trigger,
|
|
231
|
+
Clear: Combobox.Clear,
|
|
232
|
+
Popup: Combobox.Popup,
|
|
233
|
+
List: Combobox.List,
|
|
234
|
+
Item: Combobox.Item,
|
|
235
|
+
Group: Combobox.Group,
|
|
236
|
+
GroupLabel: Combobox.GroupLabel,
|
|
237
|
+
Empty: Combobox.Empty,
|
|
238
|
+
Status: Combobox.Status,
|
|
239
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Avatar: `<lu-avatar>` stacks its image over its fallback in one grid cell, so showing one never
|
|
3
|
+
* moves the other. Where scripting is enabled the image stays hidden while it loads (server HTML
|
|
4
|
+
* marks it `data-loading`) and after it fails, and the fallback hides once it has loaded; without
|
|
5
|
+
* JavaScript an avatar with an image shows the image alone. `visibility`, not `display`, so a
|
|
6
|
+
* `loading="lazy"` image still loads. The fallback's `delay` is a zero-length animation that keeps
|
|
7
|
+
* it hidden that long.
|
|
8
|
+
*/
|
|
9
|
+
@layer lu.core, lu.theme;
|
|
10
|
+
|
|
11
|
+
@layer lu.core {
|
|
12
|
+
lu-avatar {
|
|
13
|
+
display: inline-grid;
|
|
14
|
+
/* One cell, never wider or taller than the root: a broken or loading image's alt text would
|
|
15
|
+
widen it otherwise. */
|
|
16
|
+
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
|
|
17
|
+
}
|
|
18
|
+
lu-avatar > * {
|
|
19
|
+
grid-area: 1 / 1;
|
|
20
|
+
}
|
|
21
|
+
lu-avatar > img {
|
|
22
|
+
z-index: 1;
|
|
23
|
+
}
|
|
24
|
+
lu-avatar > img[data-error] {
|
|
25
|
+
visibility: hidden;
|
|
26
|
+
}
|
|
27
|
+
@media (scripting: enabled) {
|
|
28
|
+
lu-avatar > img[data-loading] {
|
|
29
|
+
visibility: hidden;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
lu-avatar[data-loaded] > [data-fallback] {
|
|
33
|
+
visibility: hidden;
|
|
34
|
+
}
|
|
35
|
+
/*
|
|
36
|
+
* Without JavaScript nothing tells a failed image from a loaded one: the image is the avatar,
|
|
37
|
+
* and the fallback behind it stays out of sight and out of the accessibility tree, which would
|
|
38
|
+
* name the avatar twice (TASK-445 L8).
|
|
39
|
+
*/
|
|
40
|
+
@media (scripting: none) {
|
|
41
|
+
lu-avatar:has(> img) > [data-fallback] {
|
|
42
|
+
visibility: hidden;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
lu-avatar > [data-fallback] {
|
|
46
|
+
animation: lu-avatar-delay 0s var(--lu-avatar-delay, 0s) backwards;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@keyframes lu-avatar-delay {
|
|
51
|
+
from {
|
|
52
|
+
visibility: hidden;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Avatar's client script (decision-62, decision-63). `<lu-avatar>` follows the loading status of
|
|
2
|
+
// its image (its `<img>` child): `data-loading` while it loads and `data-error` when it failed or
|
|
3
|
+
// has no source, Base UI's attributes, on the image, which stays hidden meanwhile (avatar.css);
|
|
4
|
+
// `data-loaded` on the root once it has loaded, which hides the fallback. Each change dispatches
|
|
5
|
+
// `loadingstatuschange` on the image. A new `src` or `srcset`, or a new image, starts over.
|
|
6
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
7
|
+
|
|
8
|
+
/** An avatar image's loading status. */
|
|
9
|
+
export type AvatarLoadingStatus = "loading" | "loaded" | "error";
|
|
10
|
+
|
|
11
|
+
/** The `detail` of `loadingstatuschange`. */
|
|
12
|
+
export interface AvatarLoadingStatusChangeDetail {
|
|
13
|
+
status: AvatarLoadingStatus;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class AvatarElement extends HTMLElement {
|
|
17
|
+
#observer: MutationObserver | undefined;
|
|
18
|
+
#image: HTMLImageElement | null = null;
|
|
19
|
+
#status: AvatarLoadingStatus | undefined;
|
|
20
|
+
|
|
21
|
+
/** The loading status of the avatar's image; `undefined` while it has none. */
|
|
22
|
+
get status(): AvatarLoadingStatus | undefined {
|
|
23
|
+
return this.#status;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
connectedCallback(): void {
|
|
27
|
+
// `load` and `error` do not bubble: they reach the root in the capture phase.
|
|
28
|
+
this.addEventListener("load", this.#settle, true);
|
|
29
|
+
this.addEventListener("error", this.#settle, true);
|
|
30
|
+
this.#observer = new MutationObserver(this.#check);
|
|
31
|
+
this.#observer.observe(this, {
|
|
32
|
+
childList: true,
|
|
33
|
+
subtree: true,
|
|
34
|
+
attributeFilter: ["src", "srcset"],
|
|
35
|
+
});
|
|
36
|
+
this.#check();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
disconnectedCallback(): void {
|
|
40
|
+
this.removeEventListener("load", this.#settle, true);
|
|
41
|
+
this.removeEventListener("error", this.#settle, true);
|
|
42
|
+
this.#observer?.disconnect();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#settle = (event: Event): void => {
|
|
46
|
+
const image = this.#current();
|
|
47
|
+
if (image !== null && event.target === image) {
|
|
48
|
+
this.#set(image, event.type === "load" ? "loaded" : "error");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
#check = (): void => {
|
|
53
|
+
const image = this.#current();
|
|
54
|
+
if (image === null) {
|
|
55
|
+
this.#image = null;
|
|
56
|
+
this.#status = undefined;
|
|
57
|
+
this.removeAttribute("data-loaded");
|
|
58
|
+
} else if (!image.getAttribute("src") && !image.getAttribute("srcset")) {
|
|
59
|
+
this.#set(image, "error");
|
|
60
|
+
} else if (!image.complete) {
|
|
61
|
+
this.#set(image, "loading");
|
|
62
|
+
} else if (image.naturalWidth > 0) {
|
|
63
|
+
this.#set(image, "loaded");
|
|
64
|
+
} else {
|
|
65
|
+
// Complete without a size: a broken image, or an SVG without intrinsic dimensions.
|
|
66
|
+
const source = image.currentSrc;
|
|
67
|
+
const done = (status: AvatarLoadingStatus) => () => {
|
|
68
|
+
if (image.currentSrc === source && this.#current() === image) this.#set(image, status);
|
|
69
|
+
};
|
|
70
|
+
this.#set(image, "loading");
|
|
71
|
+
image.decode().then(done("loaded"), done("error"));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
#current(): HTMLImageElement | null {
|
|
76
|
+
return this.querySelector<HTMLImageElement>(":scope > img");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#set(image: HTMLImageElement, status: AvatarLoadingStatus): void {
|
|
80
|
+
image.toggleAttribute("data-loading", status === "loading");
|
|
81
|
+
image.toggleAttribute("data-error", status === "error");
|
|
82
|
+
this.toggleAttribute("data-loaded", status === "loaded");
|
|
83
|
+
if (image === this.#image && status === this.#status) return;
|
|
84
|
+
this.#image = image;
|
|
85
|
+
this.#status = status;
|
|
86
|
+
image.dispatchEvent(
|
|
87
|
+
new CustomEvent<AvatarLoadingStatusChangeDetail>("loadingstatuschange", {
|
|
88
|
+
detail: { status },
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!customElements.get("lu-avatar")) {
|
|
95
|
+
customElements.define("lu-avatar", AvatarElement);
|
|
96
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Avatar (decision-62, TASK-374): an image with a fallback, after Base UI's Avatar with its
|
|
2
|
+
// `keepMounted` image. Root renders `<lu-avatar>` (./avatar.script.ts), which stacks the image over
|
|
3
|
+
// the fallback in one grid cell, so neither moves the other. Image renders a native `<img>` in the
|
|
4
|
+
// server HTML, loading at once (or lazily), hidden while it loads and after it fails; Fallback
|
|
5
|
+
// renders a `<span>` (initials, an icon), shown until the image has loaded, after an optional
|
|
6
|
+
// delay. Without JavaScript the image covers the fallback once it loads.
|
|
7
|
+
import { onMount, ref } from "lumiose";
|
|
8
|
+
import { withStyle } from "../placement/placement.ts";
|
|
9
|
+
import avatarScript from "./avatar.script.ts?client";
|
|
10
|
+
import type {
|
|
11
|
+
AvatarElement,
|
|
12
|
+
AvatarLoadingStatus,
|
|
13
|
+
AvatarLoadingStatusChangeDetail,
|
|
14
|
+
} from "./avatar.script.ts";
|
|
15
|
+
|
|
16
|
+
export type { AvatarLoadingStatus } from "./avatar.script.ts";
|
|
17
|
+
|
|
18
|
+
declare module "lumiose/jsx-runtime" {
|
|
19
|
+
namespace JSX {
|
|
20
|
+
interface IntrinsicElements {
|
|
21
|
+
/** Avatar.Root's element: it follows its image's loading status. */
|
|
22
|
+
"lu-avatar": JSX.HTMLProps<AvatarElement>;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `Avatar.Root` renders `<lu-avatar>`, an inline box around the image and the fallback. */
|
|
28
|
+
export type AvatarRootProps = JSX.IntrinsicElements["lu-avatar"];
|
|
29
|
+
|
|
30
|
+
/** `Avatar.Image` renders a native `<img>`, with every native prop. */
|
|
31
|
+
export type AvatarImageProps = JSX.IntrinsicElements["img"] & {
|
|
32
|
+
/**
|
|
33
|
+
* Called when the image's loading status changes: `"loading"`, `"loaded"`, or `"error"` (it
|
|
34
|
+
* failed, or has no `src`). In an island it also reports the status the image had when the island
|
|
35
|
+
* hydrated.
|
|
36
|
+
*/
|
|
37
|
+
onLoadingStatusChange?: (status: AvatarLoadingStatus) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** `Avatar.Fallback` renders a `<span>`, shown until the image has loaded. */
|
|
41
|
+
export type AvatarFallbackProps = JSX.IntrinsicElements["span"] & {
|
|
42
|
+
/**
|
|
43
|
+
* How long to wait before showing the fallback, in milliseconds: an image that loads sooner never
|
|
44
|
+
* shows it. Default `0`.
|
|
45
|
+
*/
|
|
46
|
+
delay?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An inline box for the image and the fallback, stacked in one cell. Give it a size (the default
|
|
51
|
+
* theme's `lu-avatar` does), so the image loading or failing never changes the layout.
|
|
52
|
+
*/
|
|
53
|
+
function Root({ children, ...rest }: AvatarRootProps) {
|
|
54
|
+
// `<lu-avatar>` follows the image's loading status; the page loads it once (decision-63).
|
|
55
|
+
avatarScript();
|
|
56
|
+
return <lu-avatar {...rest}>{children}</lu-avatar>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The picture: a native `<img>`, with `alt` (the person's name, or `""` when the name is next to
|
|
61
|
+
* it). Hidden while it loads and after it fails, as `[data-loading]` and `[data-error]`.
|
|
62
|
+
*/
|
|
63
|
+
function Image({ onLoadingStatusChange, ...rest }: AvatarImageProps) {
|
|
64
|
+
const image = ref<HTMLImageElement>();
|
|
65
|
+
let reported: AvatarLoadingStatus | undefined;
|
|
66
|
+
const report = (status: AvatarLoadingStatus | undefined) => {
|
|
67
|
+
if (status === undefined || status === reported) return;
|
|
68
|
+
reported = status;
|
|
69
|
+
onLoadingStatusChange?.(status);
|
|
70
|
+
};
|
|
71
|
+
// A hydrated island hears the status the image already had.
|
|
72
|
+
onMount(() => report((image()?.parentElement as AvatarElement | null)?.status));
|
|
73
|
+
return (
|
|
74
|
+
<img
|
|
75
|
+
{...rest}
|
|
76
|
+
ref={image}
|
|
77
|
+
data-loading
|
|
78
|
+
on:loadingstatuschange={(event: CustomEvent<AvatarLoadingStatusChangeDetail>) =>
|
|
79
|
+
report(event.detail.status)
|
|
80
|
+
}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What shows until the image has loaded: initials or an icon, in a `<span data-fallback>`. */
|
|
86
|
+
function Fallback({ delay, style, children, ...rest }: AvatarFallbackProps) {
|
|
87
|
+
return (
|
|
88
|
+
<span
|
|
89
|
+
{...rest}
|
|
90
|
+
data-fallback
|
|
91
|
+
style={
|
|
92
|
+
delay ? withStyle(style, { "--lu-avatar-delay": `${delay}ms` } as JSX.CSSProperties) : style
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
{children}
|
|
96
|
+
</span>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An image with a fallback, after Base UI's Avatar: Root, Image and Fallback. A 0.5 kB script
|
|
102
|
+
* follows the image's loading status.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* <Avatar.Root class="lu-avatar">
|
|
106
|
+
* <Avatar.Image src="/people/ada.jpg" alt="Ada Lovelace" />
|
|
107
|
+
* <Avatar.Fallback delay={600}>AL</Avatar.Fallback>
|
|
108
|
+
* </Avatar.Root>
|
|
109
|
+
*/
|
|
110
|
+
export const Avatar = { Root, Image, Fallback };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Button's client script (decision-62, decision-63). A Button disabled with `focusableWhenDisabled`
|
|
2
|
+
// stays a focusable `<button aria-disabled="true">` (a loading state keeps its place in the tab
|
|
3
|
+
// order and its focus), so the platform would still run its click, submit its form or reset it.
|
|
4
|
+
// Where this script runs, a click on such a button, from the pointer, Enter or Space, is cancelled
|
|
5
|
+
// before any handler sees it. A press (`pointerdown`, `mousedown`) or Enter or Space (`keydown`,
|
|
6
|
+
// `keyup`) reaches none of its handlers either, nor its ancestors' bubbling ones, as in Base UI
|
|
7
|
+
// (TASK-450 L23); listeners that capture (a layer's outside press) still see them, the press still
|
|
8
|
+
// focuses it, and the other keys (Tab, a toolbar's arrows) go on. Buttons with a role (menu items,
|
|
9
|
+
// tabs) are their widgets' to handle.
|
|
10
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
11
|
+
|
|
12
|
+
const DISABLED = "button[aria-disabled='true']:not([role])";
|
|
13
|
+
|
|
14
|
+
/** Cancels a click on an `aria-disabled` button, before Lumiose's handlers and the form's action. */
|
|
15
|
+
export function cancelDisabledClick(event: Event): void {
|
|
16
|
+
const button = (event.target as Element).closest?.(DISABLED);
|
|
17
|
+
if (!button) return;
|
|
18
|
+
event.preventDefault();
|
|
19
|
+
event.stopImmediatePropagation();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Keeps a press, Enter or Space on an `aria-disabled` button from its handlers. */
|
|
23
|
+
export function stopDisabledInteraction(event: Event): void {
|
|
24
|
+
if (!(event.target as Element).closest?.(DISABLED)) return;
|
|
25
|
+
if (event instanceof KeyboardEvent) {
|
|
26
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
}
|
|
29
|
+
event.stopPropagation();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
document.addEventListener("click", cancelDisabledClick, true);
|
|
33
|
+
for (const type of ["pointerdown", "mousedown", "keydown", "keyup"]) {
|
|
34
|
+
document.addEventListener(type, stopDisabledInteraction, true);
|
|
35
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Button (decision-62, TASK-364): a native `<button>`, after Base UI's Button. `type` defaults to
|
|
2
|
+
// `"button"`, so a Button in a form submits only when asked to. Disabled it is natively disabled
|
|
3
|
+
// (out of the tab order); with `focusableWhenDisabled` it stays focusable as
|
|
4
|
+
// `aria-disabled="true"` and button.script.ts cancels its clicks.
|
|
5
|
+
import buttonScript from "./button.script.ts?client";
|
|
6
|
+
|
|
7
|
+
/** A native `<button>`, with every native prop (`type`, `name`, `value`, `form`, `command`, …). */
|
|
8
|
+
export type ButtonProps = JSX.IntrinsicElements["button"] & {
|
|
9
|
+
/**
|
|
10
|
+
* Keeps the button focusable while `disabled`: it becomes `aria-disabled="true"` instead of
|
|
11
|
+
* natively disabled, so focus stays on it (a loading state, say) and screen readers still find
|
|
12
|
+
* it; its clicks are ignored.
|
|
13
|
+
*/
|
|
14
|
+
focusableWhenDisabled?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A native button. Unstyled: style it through `class` and `:disabled`, `[aria-disabled="true"]`,
|
|
19
|
+
* `:focus-visible`, `:hover`.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <Button type="submit" disabled={saving()} focusableWhenDisabled>
|
|
23
|
+
* {saving() ? "Saving…" : "Save"}
|
|
24
|
+
* </Button>
|
|
25
|
+
*/
|
|
26
|
+
export function Button({ children, disabled, focusableWhenDisabled, ...rest }: ButtonProps) {
|
|
27
|
+
// Cancels the clicks of `aria-disabled` buttons; the page loads it once (decision-63).
|
|
28
|
+
buttonScript();
|
|
29
|
+
const soft = () => disabled === true && focusableWhenDisabled === true;
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
{...rest}
|
|
34
|
+
disabled={disabled === true && !soft()}
|
|
35
|
+
aria-disabled={soft() ? "true" : rest["aria-disabled"]}
|
|
36
|
+
>
|
|
37
|
+
{children}
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
}
|