@lumiose/ui 0.0.0-stage → 0.1.1
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,432 @@
|
|
|
1
|
+
// Toast (decision-62, TASK-371): Viewport renders `<lu-toast-viewport>`, a light-DOM element whose
|
|
2
|
+
// script (toast.script.ts) gives every toast inside it Base UI's behaviour: the top layer, timers
|
|
3
|
+
// that pause on hover and focus, the limit, swipe to dismiss, F6, and announcements. What renders
|
|
4
|
+
// the toasts is up to the page:
|
|
5
|
+
//
|
|
6
|
+
// - Server HTML: a page renders `Toast.Root` with a plain object (`toast={{ title: "Saved" }}`),
|
|
7
|
+
// for a form action's result. It shows without JavaScript; the script then times and closes it.
|
|
8
|
+
// - An island: a toast manager (`Toast.createToastManager()`, shared through a module) adds,
|
|
9
|
+
// updates and closes toasts, and the island maps `manager.toasts()` to `Toast.Root`s. The
|
|
10
|
+
// manager keeps each toast's object stable and its fields reactive, so an update changes the
|
|
11
|
+
// toast in place; a close runs its exit animation, then the manager removes it.
|
|
12
|
+
//
|
|
13
|
+
// Ported from Timeless's approach (https://timeless.build).
|
|
14
|
+
import { createContext, effect, ref, signal, untrack, useContext, useId } from "lumiose";
|
|
15
|
+
import { afterAnimations } from "../animation/animation.ts";
|
|
16
|
+
import toastScript from "./toast.script.ts?client";
|
|
17
|
+
import { CLOSE_COMMAND } from "./command.ts";
|
|
18
|
+
import type { LuToastViewport } from "./toast.script.ts";
|
|
19
|
+
|
|
20
|
+
declare module "lumiose/jsx-runtime" {
|
|
21
|
+
namespace JSX {
|
|
22
|
+
interface IntrinsicElements {
|
|
23
|
+
/** Toast.Viewport's element: the toasts' region, in the top layer. */
|
|
24
|
+
"lu-toast-viewport": JSX.HTMLProps<LuToastViewport>;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a toast shows and how it behaves: the options of `add` and `update`. */
|
|
30
|
+
export interface ToastOptions {
|
|
31
|
+
/** An id of your own, to update or close the toast later. Default: one the manager makes. */
|
|
32
|
+
id?: string;
|
|
33
|
+
/** Its title, shown by `Toast.Title`. */
|
|
34
|
+
title?: string;
|
|
35
|
+
/** Its description, shown by `Toast.Description`. */
|
|
36
|
+
description?: string;
|
|
37
|
+
/** A type of your own (`"success"`, `"error"`, `"loading"`): its `data-type`, for styling. */
|
|
38
|
+
type?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Milliseconds before it closes on its own; `0` keeps it until closed. Default: the provider's
|
|
41
|
+
* `timeout`, 5000. Keep toasts with an action or with something to read twice open (`0`).
|
|
42
|
+
*/
|
|
43
|
+
timeout?: number;
|
|
44
|
+
/**
|
|
45
|
+
* `"high"` announces it at once (`role="alert"`), for errors that need attention; default
|
|
46
|
+
* `"low"`, announced when the reader is idle (`role="status"`).
|
|
47
|
+
*/
|
|
48
|
+
priority?: "low" | "high";
|
|
49
|
+
/**
|
|
50
|
+
* The props of its `Toast.Action` button: `children` for the label, `onClick`, `class`,
|
|
51
|
+
* `disabled` and `aria-*`. Other keys are dropped: the options may be a payload as it arrived.
|
|
52
|
+
*/
|
|
53
|
+
actionProps?: JSX.IntrinsicElements["button"];
|
|
54
|
+
/** Called when it starts closing. */
|
|
55
|
+
onClose?: () => void;
|
|
56
|
+
/** Called once it is removed, after its exit animation. */
|
|
57
|
+
onRemove?: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A toast, as a manager lists it: its options, with an id, read reactively. */
|
|
61
|
+
export interface ToastObject extends ToastOptions {
|
|
62
|
+
id: string;
|
|
63
|
+
/** `"ending"` while it closes: its exit animation runs, then the manager removes it. */
|
|
64
|
+
transitionStatus?: "ending";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A promise toast's options in each state: options, a title, or a function of the result. */
|
|
68
|
+
export interface ToastPromiseOptions<T> {
|
|
69
|
+
/** While the promise runs: the toast stays (`timeout: 0`) with `type: "loading"`. */
|
|
70
|
+
loading: ToastOptions | string;
|
|
71
|
+
/** Once it resolves, with `type: "success"` and the default timeout. */
|
|
72
|
+
success: ToastOptions | string | ((value: T) => ToastOptions | string);
|
|
73
|
+
/** Once it rejects, with `type: "error"` and the default timeout. */
|
|
74
|
+
error: ToastOptions | string | ((error: unknown) => ToastOptions | string);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Adds, updates and closes toasts: share one through a module between islands. */
|
|
78
|
+
export interface ToastManager {
|
|
79
|
+
/** The open toasts, newest first: map them to `Toast.Root`s inside `Toast.Viewport`. */
|
|
80
|
+
toasts(): readonly ToastObject[];
|
|
81
|
+
/** Adds a toast and returns its id. With the id of an open toast, it updates that one in place. */
|
|
82
|
+
add(options: ToastOptions): string;
|
|
83
|
+
/** Changes an open toast in place. */
|
|
84
|
+
update(id: string, options: Partial<ToastOptions>): void;
|
|
85
|
+
/** Closes a toast, or every toast without an id. */
|
|
86
|
+
close(id?: string): void;
|
|
87
|
+
/**
|
|
88
|
+
* Shows a loading toast while `promise` runs (it stays), then turns it into a success or an
|
|
89
|
+
* error toast. Returns `promise`.
|
|
90
|
+
*/
|
|
91
|
+
promise<T>(promise: Promise<T>, options: ToastPromiseOptions<T>): Promise<T>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `Toast.Provider` holds the defaults of its toasts and a manager for `Toast.useToastManager`. */
|
|
95
|
+
export interface ToastProviderProps {
|
|
96
|
+
/** The manager its islands share. Default: a new one. */
|
|
97
|
+
toastManager?: ToastManager;
|
|
98
|
+
/** Milliseconds before a toast closes on its own. Default `5000`; `0` keeps toasts open. */
|
|
99
|
+
timeout?: number;
|
|
100
|
+
/** How many toasts show at once; older ones wait, hidden. Default `3`. */
|
|
101
|
+
limit?: number;
|
|
102
|
+
/** The `Toast.Viewport` and whatever adds toasts: the provider renders no element. */
|
|
103
|
+
children?: JSX.Element;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The toasts' region: `<lu-toast-viewport role="region">`, in the top layer. Name it with
|
|
108
|
+
* `aria-label` (default "Notifications (F6)").
|
|
109
|
+
*/
|
|
110
|
+
export type ToastViewportProps = JSX.HTMLProps<LuToastViewport>;
|
|
111
|
+
|
|
112
|
+
/** A toast: a `<div role="status">`, or `role="alert"` for a high-priority one. */
|
|
113
|
+
export type ToastRootProps = JSX.IntrinsicElements["div"] & {
|
|
114
|
+
/** The toast: one from a manager, or a plain object in server HTML. */
|
|
115
|
+
toast: ToastObject | (ToastOptions & { id?: string });
|
|
116
|
+
/**
|
|
117
|
+
* The directions a swipe closes it in. Default `["down", "right"]`; `[]` turns swiping off.
|
|
118
|
+
*/
|
|
119
|
+
swipeDirection?: ("up" | "down" | "left" | "right")[];
|
|
120
|
+
};
|
|
121
|
+
/** A `<div>` around the toast's text and buttons. */
|
|
122
|
+
export type ToastContentProps = JSX.IntrinsicElements["div"];
|
|
123
|
+
/** The toast's `<h2>`: its children, or else the toast's `title`. */
|
|
124
|
+
export type ToastTitleProps = JSX.IntrinsicElements["h2"];
|
|
125
|
+
/** The toast's `<p>`: its children, or else the toast's `description`. */
|
|
126
|
+
export type ToastDescriptionProps = JSX.IntrinsicElements["p"];
|
|
127
|
+
/** A `<button>` for the toast's action, with the toast's `actionProps`; nothing without them. */
|
|
128
|
+
export type ToastActionProps = JSX.IntrinsicElements["button"];
|
|
129
|
+
/** A `<button>` that closes the toast. Name it, with text or `aria-label`. */
|
|
130
|
+
export type ToastCloseProps = JSX.IntrinsicElements["button"];
|
|
131
|
+
|
|
132
|
+
const DEFAULT_TIMEOUT = 5000;
|
|
133
|
+
const DEFAULT_LIMIT = 3;
|
|
134
|
+
|
|
135
|
+
interface ManagedToast {
|
|
136
|
+
record: ToastObject;
|
|
137
|
+
set: (options: Partial<ToastObject>) => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The manager of each managed toast, and its removal. */
|
|
141
|
+
const owners = new WeakMap<object, { remove: (id: string) => void; close: (id: string) => void }>();
|
|
142
|
+
|
|
143
|
+
const FIELDS = [
|
|
144
|
+
"title",
|
|
145
|
+
"description",
|
|
146
|
+
"type",
|
|
147
|
+
"timeout",
|
|
148
|
+
"priority",
|
|
149
|
+
"actionProps",
|
|
150
|
+
"onClose",
|
|
151
|
+
"onRemove",
|
|
152
|
+
"transitionStatus",
|
|
153
|
+
] as const;
|
|
154
|
+
|
|
155
|
+
/** A toast object whose fields read a signal, so the parts that show them update in place. */
|
|
156
|
+
function managed(id: string, options: ToastOptions): ManagedToast {
|
|
157
|
+
const state = signal<Partial<ToastObject>>({ ...options });
|
|
158
|
+
const record = { id } as ToastObject;
|
|
159
|
+
for (const field of FIELDS) {
|
|
160
|
+
Object.defineProperty(record, field, { enumerable: true, get: () => state()[field] });
|
|
161
|
+
}
|
|
162
|
+
return { record, set: (next) => state.set({ ...state(), ...next }) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const asOptions = (value: ToastOptions | string): ToastOptions =>
|
|
166
|
+
typeof value === "string" ? { title: value } : value;
|
|
167
|
+
|
|
168
|
+
let managers = 0;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Makes a toast manager (`Toast.createToastManager()`, as in Base UI). Create one in a module and
|
|
172
|
+
* import it where toasts are added and shown.
|
|
173
|
+
*/
|
|
174
|
+
function createToastManager(): ToastManager {
|
|
175
|
+
const prefix = `toast-${(managers += 1)}-`;
|
|
176
|
+
let count = 0;
|
|
177
|
+
const list = signal<ManagedToast[]>([]);
|
|
178
|
+
const find = (id: string) => list().find((each) => each.record.id === id);
|
|
179
|
+
const remove = (id: string) => {
|
|
180
|
+
const toast = find(id);
|
|
181
|
+
if (!toast) return;
|
|
182
|
+
list.set(list().filter((each) => each !== toast));
|
|
183
|
+
toast.record.onRemove?.();
|
|
184
|
+
};
|
|
185
|
+
const close = (id: string) => {
|
|
186
|
+
const toast = find(id);
|
|
187
|
+
if (!toast || toast.record.transitionStatus === "ending") return;
|
|
188
|
+
toast.set({ transitionStatus: "ending" });
|
|
189
|
+
toast.record.onClose?.();
|
|
190
|
+
};
|
|
191
|
+
const owner = { remove, close };
|
|
192
|
+
|
|
193
|
+
const manager: ToastManager = {
|
|
194
|
+
toasts: () => list().map((each) => each.record),
|
|
195
|
+
add(options) {
|
|
196
|
+
const id = options.id ?? `${prefix}${(count += 1)}`;
|
|
197
|
+
// An open toast with that id changes in place, as `update` would (TASK-442 L4).
|
|
198
|
+
const open = find(id);
|
|
199
|
+
if (open && open.record.transitionStatus !== "ending") {
|
|
200
|
+
open.set(options);
|
|
201
|
+
return id;
|
|
202
|
+
}
|
|
203
|
+
const toast = managed(id, options);
|
|
204
|
+
owners.set(toast.record, owner);
|
|
205
|
+
list.set([toast, ...list().filter((each) => each.record.id !== id)]);
|
|
206
|
+
return id;
|
|
207
|
+
},
|
|
208
|
+
update(id, options) {
|
|
209
|
+
find(id)?.set(options);
|
|
210
|
+
},
|
|
211
|
+
close(id) {
|
|
212
|
+
if (id !== undefined) close(id);
|
|
213
|
+
else for (const each of list()) close(each.record.id);
|
|
214
|
+
},
|
|
215
|
+
async promise(promise, options) {
|
|
216
|
+
const id = manager.add({ timeout: 0, type: "loading", ...asOptions(options.loading) });
|
|
217
|
+
try {
|
|
218
|
+
const value = await promise;
|
|
219
|
+
const success = options.success;
|
|
220
|
+
manager.update(id, {
|
|
221
|
+
type: "success",
|
|
222
|
+
timeout: undefined,
|
|
223
|
+
...asOptions(typeof success === "function" ? success(value) : success),
|
|
224
|
+
});
|
|
225
|
+
return value;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const failure = options.error;
|
|
228
|
+
manager.update(id, {
|
|
229
|
+
type: "error",
|
|
230
|
+
timeout: undefined,
|
|
231
|
+
...asOptions(typeof failure === "function" ? failure(error) : failure),
|
|
232
|
+
});
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
return manager;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface ToastContextValue {
|
|
241
|
+
manager: ToastManager;
|
|
242
|
+
timeout: () => number;
|
|
243
|
+
limit: () => number;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface ToastItemContextValue {
|
|
247
|
+
toast: ToastObject | (ToastOptions & { id?: string });
|
|
248
|
+
/** The toast element's id, for its close buttons. */
|
|
249
|
+
elementId: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const ToastContext = createContext<ToastContextValue>();
|
|
253
|
+
const ToastItemContext = createContext<ToastItemContextValue>();
|
|
254
|
+
|
|
255
|
+
function useToastItem(): ToastItemContextValue {
|
|
256
|
+
const context = useContext(ToastItemContext);
|
|
257
|
+
if (!context) throw new Error("Toast parts must be inside <Toast.Root>.");
|
|
258
|
+
return context;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The manager of the closest `Toast.Provider`. */
|
|
262
|
+
function useToastManager(): ToastManager {
|
|
263
|
+
const context = useContext(ToastContext);
|
|
264
|
+
if (!context) throw new Error("Toast.useToastManager() must be called inside <Toast.Provider>.");
|
|
265
|
+
return context.manager;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function Provider(props: ToastProviderProps) {
|
|
269
|
+
// The provider keeps the manager it started with.
|
|
270
|
+
const manager = untrack(() => props.toastManager ?? createToastManager());
|
|
271
|
+
const context: ToastContextValue = {
|
|
272
|
+
manager,
|
|
273
|
+
timeout: () => props.timeout ?? DEFAULT_TIMEOUT,
|
|
274
|
+
limit: () => props.limit ?? DEFAULT_LIMIT,
|
|
275
|
+
};
|
|
276
|
+
return <ToastContext value={context}>{props.children}</ToastContext>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function Viewport({ children, ...rest }: ToastViewportProps) {
|
|
280
|
+
// Timers, the limit, swipes, F6 and the top layer; the page loads it once (decision-63).
|
|
281
|
+
toastScript();
|
|
282
|
+
const provider = useContext(ToastContext);
|
|
283
|
+
return (
|
|
284
|
+
<lu-toast-viewport
|
|
285
|
+
role="region"
|
|
286
|
+
aria-label="Notifications (F6)"
|
|
287
|
+
tabindex={-1}
|
|
288
|
+
{...rest}
|
|
289
|
+
data-limit={String(provider?.limit() ?? DEFAULT_LIMIT)}
|
|
290
|
+
>
|
|
291
|
+
{children}
|
|
292
|
+
</lu-toast-viewport>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function Root({ toast, swipeDirection, children, ref: forwarded, ...rest }: ToastRootProps) {
|
|
297
|
+
toastScript();
|
|
298
|
+
const provider = useContext(ToastContext);
|
|
299
|
+
// Its Close points at it: a caller's `id` names it (TASK-441 M39).
|
|
300
|
+
const generated = useId();
|
|
301
|
+
const elementId = untrack(() => rest.id) ?? generated;
|
|
302
|
+
const element = ref<HTMLDivElement>();
|
|
303
|
+
const owner = owners.get(toast);
|
|
304
|
+
const ending = () => (toast as ToastObject).transitionStatus === "ending";
|
|
305
|
+
|
|
306
|
+
// A managed toast that closes: once its exit animation is over, its manager removes it.
|
|
307
|
+
effect(() => {
|
|
308
|
+
const node = element();
|
|
309
|
+
if (!owner || !node || !ending()) return;
|
|
310
|
+
// After the update that sets `data-ending-style`, which starts the exit.
|
|
311
|
+
queueMicrotask(() => void afterAnimations(node).then(() => owner.remove(toast.id!)));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<ToastItemContext value={{ toast, elementId }}>
|
|
316
|
+
<div
|
|
317
|
+
role={toast.priority === "high" ? "alert" : "status"}
|
|
318
|
+
aria-atomic="true"
|
|
319
|
+
{...rest}
|
|
320
|
+
ref={(node: HTMLDivElement) => {
|
|
321
|
+
element(node);
|
|
322
|
+
forwarded?.(node);
|
|
323
|
+
}}
|
|
324
|
+
id={elementId}
|
|
325
|
+
data-type={toast.type}
|
|
326
|
+
data-timeout={String(toast.timeout ?? provider?.timeout() ?? DEFAULT_TIMEOUT)}
|
|
327
|
+
data-swipe-directions={(swipeDirection ?? ["down", "right"]).join(" ") || undefined}
|
|
328
|
+
data-ending-style={ending() ? "" : undefined}
|
|
329
|
+
on:close={(event: Event) => {
|
|
330
|
+
// The script closes it: a managed toast leaves through its manager.
|
|
331
|
+
if (!owner) return;
|
|
332
|
+
event.preventDefault();
|
|
333
|
+
owner.close(toast.id!);
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
{children}
|
|
337
|
+
</div>
|
|
338
|
+
</ToastItemContext>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function Content({ children, ...rest }: ToastContentProps) {
|
|
343
|
+
return <div {...rest}>{children}</div>;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function Title({ children, ...rest }: ToastTitleProps) {
|
|
347
|
+
const item = useToastItem();
|
|
348
|
+
const content = () => children ?? item.toast.title;
|
|
349
|
+
return <>{content() ? <h2 {...rest}>{content()}</h2> : null}</>;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function Description({ children, ...rest }: ToastDescriptionProps) {
|
|
353
|
+
const item = useToastItem();
|
|
354
|
+
const content = () => children ?? item.toast.description;
|
|
355
|
+
return <>{content() ? <p {...rest}>{content()}</p> : null}</>;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The `actionProps` keys the action button takes. A toast's options may be a notification or
|
|
360
|
+
* realtime payload passed as it arrived, so a key such as `type`, `formaction` or `style` from it
|
|
361
|
+
* must not reach the button (TASK-470). Props written on `Toast.Action` are the page's own.
|
|
362
|
+
*/
|
|
363
|
+
const ACTION_KEY = /^(?:children|onClick|class|disabled|aria-[a-z]+)$/;
|
|
364
|
+
|
|
365
|
+
function allowed(props: ToastActionProps | undefined): ToastActionProps | undefined {
|
|
366
|
+
if (!props) return undefined;
|
|
367
|
+
const out: Record<string, unknown> = {};
|
|
368
|
+
for (const key of Object.keys(props)) {
|
|
369
|
+
if (ACTION_KEY.test(key)) out[key] = props[key as keyof ToastActionProps];
|
|
370
|
+
}
|
|
371
|
+
return out as ToastActionProps;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function Action({ children, ...rest }: ToastActionProps) {
|
|
375
|
+
const item = useToastItem();
|
|
376
|
+
const props = () => allowed(item.toast.actionProps);
|
|
377
|
+
return (
|
|
378
|
+
<>
|
|
379
|
+
{props() || children ? (
|
|
380
|
+
<button type="button" {...props()} {...rest}>
|
|
381
|
+
{children ?? props()?.children}
|
|
382
|
+
</button>
|
|
383
|
+
) : null}
|
|
384
|
+
</>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function Close({ children, ...rest }: ToastCloseProps) {
|
|
389
|
+
const item = useToastItem();
|
|
390
|
+
return (
|
|
391
|
+
<button type="button" {...rest} command={CLOSE_COMMAND} commandfor={item.elementId}>
|
|
392
|
+
{children}
|
|
393
|
+
</button>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Toasts: short notices in a region of their own, after Base UI's toast. Server HTML can render
|
|
399
|
+
* them (a form action's result); islands add them through a manager. `<lu-toast-viewport>`'s
|
|
400
|
+
* script times them, pauses on hover and focus, limits how many show, closes them on a swipe,
|
|
401
|
+
* Escape or their close button, and focuses the region on F6. Unstyled: style the parts through
|
|
402
|
+
* `class`, `[data-type]`, `[data-starting-style]`, `[data-ending-style]`, `[data-swiping]` and the
|
|
403
|
+
* `--lu-toast-swipe-movement-x`/`-y` and `--lu-toast-index` variables.
|
|
404
|
+
*
|
|
405
|
+
* @example
|
|
406
|
+
* const toasts = Toast.createToastManager();
|
|
407
|
+
* toasts.add({ title: "Saved", description: "Your changes are live." });
|
|
408
|
+
*
|
|
409
|
+
* <Toast.Provider toastManager={toasts}>
|
|
410
|
+
* <Toast.Viewport>
|
|
411
|
+
* {toasts.toasts().map((toast) => (
|
|
412
|
+
* <Toast.Root toast={toast}>
|
|
413
|
+
* <Toast.Title />
|
|
414
|
+
* <Toast.Description />
|
|
415
|
+
* <Toast.Close aria-label="Close">×</Toast.Close>
|
|
416
|
+
* </Toast.Root>
|
|
417
|
+
* ))}
|
|
418
|
+
* </Toast.Viewport>
|
|
419
|
+
* </Toast.Provider>
|
|
420
|
+
*/
|
|
421
|
+
export const Toast = {
|
|
422
|
+
Provider,
|
|
423
|
+
Viewport,
|
|
424
|
+
Root,
|
|
425
|
+
Content,
|
|
426
|
+
Title,
|
|
427
|
+
Description,
|
|
428
|
+
Action,
|
|
429
|
+
Close,
|
|
430
|
+
createToastManager,
|
|
431
|
+
useToastManager,
|
|
432
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The context a ToggleGroup gives its toggles: the pressed values and the toggles rendered so far,
|
|
2
|
+
// for the group's one tab stop in the server HTML.
|
|
3
|
+
import { createContext, type Signal } from "lumiose";
|
|
4
|
+
|
|
5
|
+
export interface ToggleRecord {
|
|
6
|
+
value: string | undefined;
|
|
7
|
+
disabled: () => boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ToggleGroupContextValue {
|
|
11
|
+
values: () => readonly string[];
|
|
12
|
+
items: Signal<ToggleRecord[]>;
|
|
13
|
+
disabled: () => boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ToggleGroupContext = createContext<ToggleGroupContextValue>();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Toggle (decision-62, TASK-369): a native `<button aria-pressed>`, pressed or not, after Base UI's
|
|
2
|
+
// Toggle. Its label stays the same; its state is `aria-pressed`. Inside a ToggleGroup, the group's
|
|
3
|
+
// element presses it (on server pages too) and it is one of the group's roving items; on its own,
|
|
4
|
+
// the component presses it, in an island.
|
|
5
|
+
import { effect, onCleanup, signal, untrack, useContext } from "lumiose";
|
|
6
|
+
import type { ToggleChangeDetail } from "../toggle-group/toggle-group.script.ts";
|
|
7
|
+
import { ToolbarContext } from "../toolbar/context.ts";
|
|
8
|
+
import { ToggleGroupContext, type ToggleRecord } from "./context.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The button props Toggle passes through, without the brand of intrinsic elements' props, so that
|
|
12
|
+
* `bind:pressed` applies to it.
|
|
13
|
+
*/
|
|
14
|
+
type ButtonProps = Omit<JSX.IntrinsicElements["button"], symbol | "value" | "type">;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A `<button type="button" aria-pressed>`. `bind:pressed={signal}` stands in for `pressed` and
|
|
18
|
+
* `onPressedChange`.
|
|
19
|
+
*/
|
|
20
|
+
export type ToggleProps = ButtonProps & {
|
|
21
|
+
/** Presses (`true`) or releases (`false`) it when it changes. Pair it with `onPressedChange`. */
|
|
22
|
+
pressed?: boolean;
|
|
23
|
+
/** Whether it starts pressed when `pressed` is left out. */
|
|
24
|
+
defaultPressed?: boolean;
|
|
25
|
+
/** Called after the user presses or releases it. */
|
|
26
|
+
onPressedChange?: (pressed: boolean) => void;
|
|
27
|
+
/** Its identity in a ToggleGroup's `value` (also the button's `value`). */
|
|
28
|
+
value?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A two-state button: bold, mute, pin. Unstyled: style it through `class`, `[aria-pressed="true"]`,
|
|
33
|
+
* `:disabled` and `:focus-visible`.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* <Toggle aria-label="Bold" bind:pressed={bold}>B</Toggle>
|
|
37
|
+
*/
|
|
38
|
+
export function Toggle({
|
|
39
|
+
pressed,
|
|
40
|
+
defaultPressed,
|
|
41
|
+
onPressedChange,
|
|
42
|
+
value,
|
|
43
|
+
children,
|
|
44
|
+
...rest
|
|
45
|
+
}: ToggleProps) {
|
|
46
|
+
const group = useContext(ToggleGroupContext);
|
|
47
|
+
// Inside a Toolbar, the toolbar moves the tab stop (TASK-383) and its `disabled` reaches the
|
|
48
|
+
// toggle, which stays focusable (TASK-437 M13).
|
|
49
|
+
const toolbar = useContext(ToolbarContext);
|
|
50
|
+
// Standalone: the component holds the state; in a group, the group's element and value do.
|
|
51
|
+
const state = signal(untrack(() => pressed ?? defaultPressed ?? false));
|
|
52
|
+
effect(() => {
|
|
53
|
+
if (pressed !== undefined) state.set(pressed);
|
|
54
|
+
});
|
|
55
|
+
const record: ToggleRecord = { value, disabled: () => rest.disabled === true };
|
|
56
|
+
if (group) {
|
|
57
|
+
group.items.update((list) => [...list, record]);
|
|
58
|
+
onCleanup(() => group.items.update((list) => list.filter((each) => each !== record)));
|
|
59
|
+
}
|
|
60
|
+
const on = () => (group ? value !== undefined && group.values().includes(value) : state());
|
|
61
|
+
/**
|
|
62
|
+
* The group's tab stop in the server HTML: its first pressed toggle, or its first enabled one
|
|
63
|
+
* when none is pressed. The element keeps it from then on. In a toolbar every item is a tab stop
|
|
64
|
+
* until the toolbar's script makes them one.
|
|
65
|
+
*/
|
|
66
|
+
const stop = untrack(() => {
|
|
67
|
+
if (toolbar || !group) return undefined;
|
|
68
|
+
const usable = group.items().filter((each) => !each.disabled() && !group.disabled());
|
|
69
|
+
const values = group.values();
|
|
70
|
+
const first =
|
|
71
|
+
values.length > 0
|
|
72
|
+
? usable.find((each) => each.value !== undefined && values.includes(each.value))
|
|
73
|
+
: usable[0];
|
|
74
|
+
return first === record ? 0 : -1;
|
|
75
|
+
});
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
tabindex={stop}
|
|
80
|
+
{...rest}
|
|
81
|
+
disabled={rest.disabled || group?.disabled() || undefined}
|
|
82
|
+
aria-disabled={toolbar?.disabled() ? "true" : rest["aria-disabled"]}
|
|
83
|
+
value={value}
|
|
84
|
+
aria-pressed={on() ? "true" : "false"}
|
|
85
|
+
on:click={(event: MouseEvent) => {
|
|
86
|
+
// In a group, the group's element presses it and reports `pressedchange`.
|
|
87
|
+
if (group || event.defaultPrevented) return;
|
|
88
|
+
const next = !state();
|
|
89
|
+
state.set(next);
|
|
90
|
+
onPressedChange?.(next);
|
|
91
|
+
}}
|
|
92
|
+
on:pressedchange={(event: CustomEvent<ToggleChangeDetail>) =>
|
|
93
|
+
onPressedChange?.(event.detail.pressed)
|
|
94
|
+
}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</button>
|
|
98
|
+
);
|
|
99
|
+
}
|