@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,222 @@
|
|
|
1
|
+
// Tooltip's client script (decision-62, decision-63): what opens and closes a tooltip, for every
|
|
2
|
+
// Tooltip on the page. A trigger is an element whose `aria-describedby` names a
|
|
3
|
+
// `[popover][role=tooltip]`; the innermost one under the pointer wins. It follows Base UI and Zag
|
|
4
|
+
// (doc-9, section 4):
|
|
5
|
+
//
|
|
6
|
+
// - Opens after the trigger's `data-delay` (600 ms) of hover by a mouse or pen, never by touch, or
|
|
7
|
+
// at once on keyboard focus (`:focus-visible`). Within 400 ms of another tooltip, it opens at
|
|
8
|
+
// once. Only one tooltip is open at a time.
|
|
9
|
+
// - A press before the delay cancels it. Closes on Escape (only the tooltip: the keydown is
|
|
10
|
+
// cancelled, so a dialog around it stays open), on a press, on a click of its trigger (unless
|
|
11
|
+
// `data-close-on-click="false"`), on blur, and when an ancestor of the trigger scrolls.
|
|
12
|
+
// - Hoverable (WCAG 1.4.13): it stays open while the pointer moves onto it, unless the popup has
|
|
13
|
+
// `data-disable-hoverable-popup`, and closes `data-close-delay` (0 ms) after the pointer leaves.
|
|
14
|
+
// - Placement where CSS anchor positioning is missing (placement/place.ts).
|
|
15
|
+
//
|
|
16
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
17
|
+
import { anchoring, place } from "../placement/place.ts";
|
|
18
|
+
|
|
19
|
+
/** After a tooltip closes, how long the next one opens without delay (Base UI's Provider). */
|
|
20
|
+
const GROUP = 400;
|
|
21
|
+
/** How long a hoverable tooltip waits for the pointer to cross the gap from its trigger. */
|
|
22
|
+
const GRACE = 100;
|
|
23
|
+
|
|
24
|
+
let current: { trigger: HTMLElement; popup: HTMLElement } | undefined;
|
|
25
|
+
let pending: { trigger: HTMLElement; timer: ReturnType<typeof setTimeout> } | undefined;
|
|
26
|
+
let closing: ReturnType<typeof setTimeout> | undefined;
|
|
27
|
+
let closedAt = Number.NEGATIVE_INFINITY;
|
|
28
|
+
/** A trigger whose tooltip a click closed: it stays closed until the pointer or focus leaves. */
|
|
29
|
+
let suppressed: HTMLElement | undefined;
|
|
30
|
+
|
|
31
|
+
function on<K extends keyof DocumentEventMap>(
|
|
32
|
+
type: K,
|
|
33
|
+
listener: (event: DocumentEventMap[K]) => void,
|
|
34
|
+
options: AddEventListenerOptions = {},
|
|
35
|
+
) {
|
|
36
|
+
document.addEventListener(type, listener, { capture: true, ...options });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The tooltip that describes `trigger`, if any. */
|
|
40
|
+
function tipOf(trigger: Element): HTMLElement | undefined {
|
|
41
|
+
for (const id of (trigger.getAttribute("aria-describedby") ?? "").split(/\s+/)) {
|
|
42
|
+
const tip = id ? document.getElementById(id) : null;
|
|
43
|
+
if (tip?.matches("[popover][role=tooltip]")) return tip;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The innermost tooltip trigger at or around `node`. */
|
|
49
|
+
function triggerAt(node: EventTarget | null): HTMLElement | undefined {
|
|
50
|
+
let element = node instanceof Element ? node.closest("[aria-describedby]") : null;
|
|
51
|
+
while (element) {
|
|
52
|
+
if (element instanceof HTMLElement && tipOf(element)) return element;
|
|
53
|
+
element = element.parentElement?.closest("[aria-describedby]") ?? null;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ms = (trigger: HTMLElement, name: string, fallback: number) => {
|
|
59
|
+
const value = Number(trigger.getAttribute(name) ?? Number.NaN);
|
|
60
|
+
return Number.isFinite(value) ? value : fallback;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const hoverable = (popup: HTMLElement) => !popup.hasAttribute("data-disable-hoverable-popup");
|
|
64
|
+
|
|
65
|
+
function cancel() {
|
|
66
|
+
clearTimeout(pending?.timer);
|
|
67
|
+
clearTimeout(closing);
|
|
68
|
+
pending = undefined;
|
|
69
|
+
closing = undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function show(trigger: HTMLElement) {
|
|
73
|
+
cancel();
|
|
74
|
+
const popup = tipOf(trigger);
|
|
75
|
+
if (!popup) return;
|
|
76
|
+
if (current && current.popup !== popup) hide();
|
|
77
|
+
current = { trigger, popup };
|
|
78
|
+
if (!popup.matches(":popover-open")) {
|
|
79
|
+
// `source` makes the trigger the tooltip's invoker: a tooltip inside a popover keeps it open.
|
|
80
|
+
(popup.showPopover as (options?: { source?: HTMLElement }) => void)({ source: trigger });
|
|
81
|
+
}
|
|
82
|
+
if (!anchoring) place(popup, trigger);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hide() {
|
|
86
|
+
cancel();
|
|
87
|
+
const popup = current?.popup;
|
|
88
|
+
current = undefined;
|
|
89
|
+
closedAt = performance.now();
|
|
90
|
+
if (popup?.matches(":popover-open")) popup.hidePopover();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Opens `trigger`'s tooltip after its delay, or at once within the group window. */
|
|
94
|
+
function schedule(trigger: HTMLElement) {
|
|
95
|
+
if (trigger.hasAttribute("data-trigger-disabled") || trigger === suppressed) return;
|
|
96
|
+
if (current?.trigger === trigger) {
|
|
97
|
+
clearTimeout(closing);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (pending?.trigger === trigger) return;
|
|
101
|
+
clearTimeout(pending?.timer);
|
|
102
|
+
const warm = current !== undefined || performance.now() - closedAt < GROUP;
|
|
103
|
+
const delay = warm ? 0 : ms(trigger, "data-delay", 600);
|
|
104
|
+
if (delay === 0) show(trigger);
|
|
105
|
+
else pending = { trigger, timer: setTimeout(() => show(trigger), delay) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
on("pointerover", (event) => {
|
|
109
|
+
if (event.pointerType === "touch") return;
|
|
110
|
+
const target = event.target as Node;
|
|
111
|
+
if (current && hoverable(current.popup) && current.popup.contains(target)) {
|
|
112
|
+
clearTimeout(closing);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const trigger = triggerAt(target);
|
|
116
|
+
if (trigger) schedule(trigger);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
on("pointerout", (event) => {
|
|
120
|
+
if (event.pointerType === "touch") return;
|
|
121
|
+
const from = event.target as Node;
|
|
122
|
+
const to = event.relatedTarget as Node | null;
|
|
123
|
+
const left = (element: HTMLElement) => element.contains(from) && !element.contains(to);
|
|
124
|
+
if (pending && left(pending.trigger)) cancel();
|
|
125
|
+
if (suppressed && left(suppressed)) suppressed = undefined;
|
|
126
|
+
if (!current) return;
|
|
127
|
+
const { trigger, popup } = current;
|
|
128
|
+
const inside = (node: Node | null) =>
|
|
129
|
+
trigger.contains(node) || (hoverable(popup) && popup.contains(node));
|
|
130
|
+
if (inside(from) && !inside(to)) {
|
|
131
|
+
clearTimeout(closing);
|
|
132
|
+
const delay = ms(trigger, "data-close-delay", 0);
|
|
133
|
+
closing = setTimeout(hide, hoverable(popup) ? Math.max(delay, GRACE) : delay);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
on("pointerdown", (event) => {
|
|
138
|
+
const target = event.target as Node;
|
|
139
|
+
clearTimeout(pending?.timer);
|
|
140
|
+
pending = undefined;
|
|
141
|
+
if (!current || current.popup.contains(target)) return;
|
|
142
|
+
const { trigger } = current;
|
|
143
|
+
if (trigger.contains(target)) {
|
|
144
|
+
if (trigger.getAttribute("data-close-on-click") === "false") return;
|
|
145
|
+
suppressed = trigger;
|
|
146
|
+
}
|
|
147
|
+
hide();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// A click on the trigger, from the keyboard (Enter or Space) too.
|
|
151
|
+
on("click", (event) => {
|
|
152
|
+
if (!current || !current.trigger.contains(event.target as Node)) return;
|
|
153
|
+
if (current.trigger.getAttribute("data-close-on-click") === "false") {
|
|
154
|
+
// The browser light-dismisses a hint on a press outside it, its trigger included: undo it.
|
|
155
|
+
if (!current.popup.matches(":popover-open")) show(current.trigger);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
suppressed = current.trigger;
|
|
159
|
+
hide();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
on("focusin", (event) => {
|
|
163
|
+
const target = event.target as HTMLElement;
|
|
164
|
+
const trigger = triggerAt(target);
|
|
165
|
+
if (!trigger || trigger.hasAttribute("data-trigger-disabled")) return;
|
|
166
|
+
if (target.matches(":focus-visible")) show(trigger);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
on("focusout", (event) => {
|
|
170
|
+
const target = event.target as Node;
|
|
171
|
+
const to = event.relatedTarget as Node | null;
|
|
172
|
+
if (suppressed?.contains(target) && !suppressed.contains(to)) suppressed = undefined;
|
|
173
|
+
if (current?.trigger.contains(target) && !current.trigger.contains(to)) hide();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
on("keydown", (event) => {
|
|
177
|
+
if (event.key !== "Escape" || !current?.popup.matches(":popover-open")) return;
|
|
178
|
+
// Only the tooltip closes: a cancelled keydown is no close request for a dialog around it.
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
hide();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
on(
|
|
184
|
+
"scroll",
|
|
185
|
+
(event) => {
|
|
186
|
+
const scroller = event.target;
|
|
187
|
+
if (current && scroller instanceof Node && scroller.contains(current.trigger)) hide();
|
|
188
|
+
},
|
|
189
|
+
{ passive: true },
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Opened or closed another way: the `open` prop, or the browser's light dismiss of a hint.
|
|
193
|
+
on("toggle", (event) => {
|
|
194
|
+
const popup = event.target;
|
|
195
|
+
if (!(popup instanceof HTMLElement) || !popup.matches("[popover][role=tooltip]")) return;
|
|
196
|
+
if (event.newState === "closed") {
|
|
197
|
+
if (current?.popup === popup) {
|
|
198
|
+
current = undefined;
|
|
199
|
+
closedAt = performance.now();
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (current?.popup === popup) return;
|
|
204
|
+
const trigger = document.querySelector<HTMLElement>(
|
|
205
|
+
`[aria-describedby~="${CSS.escape(popup.id)}"]`,
|
|
206
|
+
);
|
|
207
|
+
if (!trigger) return;
|
|
208
|
+
if (current) hide();
|
|
209
|
+
current = { trigger, popup };
|
|
210
|
+
if (!anchoring) place(popup, trigger);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Opened by the `open` prop: placed in the frame it first paints in.
|
|
214
|
+
on("beforetoggle", (event) => {
|
|
215
|
+
const popup = event.target;
|
|
216
|
+
if (anchoring || event.newState !== "open" || !(popup instanceof HTMLElement)) return;
|
|
217
|
+
if (!popup.matches("[popover][role=tooltip]")) return;
|
|
218
|
+
requestAnimationFrame(() => {
|
|
219
|
+
const trigger = current?.popup === popup ? current.trigger : undefined;
|
|
220
|
+
if (trigger && popup.matches(":popover-open")) place(popup, trigger);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Tooltip (decision-62, TASK-356): a native `popover="hint"` (`manual` where the browser lacks
|
|
2
|
+
// hint popovers) with `role="tooltip"`, placed next to its trigger with CSS anchor positioning
|
|
3
|
+
// (placement.css). The trigger is described by it (`aria-describedby`), in server HTML too, so
|
|
4
|
+
// assistive technology reads the tip without hovering, as the APG tooltip pattern asks; the
|
|
5
|
+
// trigger still needs its own name. Hover and keyboard focus open it through the client script,
|
|
6
|
+
// which reads the trigger's delays from its attributes. Ported from Timeless's approach
|
|
7
|
+
// (https://timeless.build).
|
|
8
|
+
import { createContext, effect, ref, useContext, useId } from "lumiose";
|
|
9
|
+
import { anchorName, placement, withStyle, type PlacementProps } from "../placement/placement.ts";
|
|
10
|
+
import tooltipScript from "./tooltip.script.ts?client";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `Tooltip.Root` renders no element: it holds the ids its parts share. `bind:open={signal}`
|
|
14
|
+
* stands in for `open` and `onOpenChange`.
|
|
15
|
+
*/
|
|
16
|
+
export interface TooltipRootProps {
|
|
17
|
+
/**
|
|
18
|
+
* Opens (`true`) or closes (`false`) the tooltip when it changes, in the browser. Pair it with
|
|
19
|
+
* `onOpenChange` to keep a signal in sync; leave both out and hover and focus drive it.
|
|
20
|
+
*/
|
|
21
|
+
open?: boolean;
|
|
22
|
+
/** Called after the tooltip opens or closes, whatever opened or closed it. */
|
|
23
|
+
onOpenChange?: (open: boolean) => void;
|
|
24
|
+
/** Hover and focus do not open the tooltip. The `open` prop still does. */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The tooltip closes as soon as the pointer leaves the trigger, instead of staying open while
|
|
28
|
+
* the pointer moves onto it. Default `false`: WCAG 1.4.13 asks for hoverable content.
|
|
29
|
+
*/
|
|
30
|
+
disableHoverablePopup?: boolean;
|
|
31
|
+
/** The other parts: the `Tooltip.Trigger` and the `Tooltip.Popup`. */
|
|
32
|
+
children?: JSX.Element;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A `<button>` described by the tip (`aria-describedby`); its delays reach the client script as
|
|
37
|
+
* `data-` attributes.
|
|
38
|
+
*/
|
|
39
|
+
export type TooltipTriggerProps = JSX.IntrinsicElements["button"] & {
|
|
40
|
+
/** How long the pointer rests on the trigger before the tooltip opens, in ms. Default 600. */
|
|
41
|
+
delay?: number;
|
|
42
|
+
/** How long the tooltip stays after the pointer leaves, in ms. Default 0. */
|
|
43
|
+
closeDelay?: number;
|
|
44
|
+
/** Clicking the trigger closes the tooltip. Default `true`. */
|
|
45
|
+
closeOnClick?: boolean;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The tip: a `<span popover role="tooltip">`, placed next to the trigger. A `<span>`, so it may sit
|
|
49
|
+
* next to a trigger inside a paragraph.
|
|
50
|
+
*/
|
|
51
|
+
export type TooltipPopupProps = JSX.IntrinsicElements["span"] & PlacementProps;
|
|
52
|
+
|
|
53
|
+
interface TooltipContextValue {
|
|
54
|
+
id: string;
|
|
55
|
+
popupId: string;
|
|
56
|
+
popup: JSX.Ref<HTMLSpanElement>;
|
|
57
|
+
props: TooltipRootProps;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TooltipContext = createContext<TooltipContextValue>();
|
|
61
|
+
|
|
62
|
+
function useTooltip(): TooltipContextValue {
|
|
63
|
+
const context = useContext(TooltipContext);
|
|
64
|
+
if (!context) throw new Error("Tooltip parts must be inside <Tooltip.Root>.");
|
|
65
|
+
return context;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Root(props: TooltipRootProps) {
|
|
69
|
+
// Opens and closes tooltips on hover and focus, and places them without anchor positioning;
|
|
70
|
+
// the page loads it once (decision-63).
|
|
71
|
+
tooltipScript();
|
|
72
|
+
const id = useId();
|
|
73
|
+
const popup = ref<HTMLSpanElement>();
|
|
74
|
+
const context: TooltipContextValue = { id, popupId: `${id}-popup`, popup, props };
|
|
75
|
+
|
|
76
|
+
// The `open` prop drives the native state; the popup's `toggle` event reports back.
|
|
77
|
+
effect(() => {
|
|
78
|
+
const element = popup();
|
|
79
|
+
const wanted = props.open;
|
|
80
|
+
if (!element?.isConnected || wanted === undefined) return;
|
|
81
|
+
const shown = element.matches(":popover-open");
|
|
82
|
+
if (wanted && !shown) element.showPopover();
|
|
83
|
+
else if (!wanted && shown) element.hidePopover();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return <TooltipContext value={context}>{props.children}</TooltipContext>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Trigger({ children, delay, closeDelay, closeOnClick, ...rest }: TooltipTriggerProps) {
|
|
90
|
+
const tooltip = useTooltip();
|
|
91
|
+
const described = () =>
|
|
92
|
+
[tooltip.popupId, rest["aria-describedby"]].filter(Boolean).join(" ") || undefined;
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
{...rest}
|
|
97
|
+
aria-describedby={described()}
|
|
98
|
+
style={withStyle(rest.style, { "anchor-name": anchorName(tooltip.id) })}
|
|
99
|
+
data-delay={delay}
|
|
100
|
+
data-close-delay={closeDelay}
|
|
101
|
+
data-close-on-click={closeOnClick === false ? "false" : undefined}
|
|
102
|
+
data-trigger-disabled={tooltip.props.disabled ? "" : undefined}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</button>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function Popup({
|
|
110
|
+
children,
|
|
111
|
+
ref: forwarded,
|
|
112
|
+
side,
|
|
113
|
+
align,
|
|
114
|
+
sideOffset,
|
|
115
|
+
alignOffset,
|
|
116
|
+
...rest
|
|
117
|
+
}: TooltipPopupProps) {
|
|
118
|
+
const tooltip = useTooltip();
|
|
119
|
+
return (
|
|
120
|
+
<span
|
|
121
|
+
role="tooltip"
|
|
122
|
+
{...rest}
|
|
123
|
+
{...placement({ side, align, sideOffset, alignOffset, style: rest.style }, tooltip.id, "top")}
|
|
124
|
+
data-disable-hoverable-popup={tooltip.props.disableHoverablePopup ? "" : undefined}
|
|
125
|
+
ref={(element: HTMLSpanElement) => {
|
|
126
|
+
tooltip.popup(element);
|
|
127
|
+
forwarded?.(element);
|
|
128
|
+
}}
|
|
129
|
+
id={tooltip.popupId}
|
|
130
|
+
popover="hint"
|
|
131
|
+
on:toggle={(event: ToggleEvent) => tooltip.props.onOpenChange?.(event.newState === "open")}
|
|
132
|
+
>
|
|
133
|
+
{children}
|
|
134
|
+
</span>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A short tip next to its trigger, on hover or keyboard focus. The trigger is described by it, in
|
|
140
|
+
* server HTML too; give an icon-only trigger its own `aria-label`. Unstyled: style the parts
|
|
141
|
+
* through `class` and native state (`:popover-open`, `[data-side]`).
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* <Tooltip.Root>
|
|
145
|
+
* <Tooltip.Trigger aria-label="Bold">B</Tooltip.Trigger>
|
|
146
|
+
* <Tooltip.Popup sideOffset={6}>Bold (⌘B)</Tooltip.Popup>
|
|
147
|
+
* </Tooltip.Root>
|
|
148
|
+
*/
|
|
149
|
+
export const Tooltip = { Root, Trigger, Popup };
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# UI components
|
|
2
|
+
|
|
3
|
+
> `@lumiose/ui`: accessible, unstyled UI primitives built on native elements (`<dialog>`, popover, invoker commands, CSS anchor positioning), server-rendered without hydration. Style them with your own CSS or the optional default theme.
|
|
4
|
+
|
|
5
|
+
**Status:** in development. `@lumiose/ui` keeps its own 0.x versions, apart from the version all
|
|
6
|
+
other Lumiose packages share, and is not on npm yet: today it is used from this repository's
|
|
7
|
+
workspace. The goal for its 1.0 is parity with [Base UI](https://base-ui.com)'s components; the
|
|
8
|
+
table below lists them as they land.
|
|
9
|
+
|
|
10
|
+
These pages ship in the package, in `node_modules/@lumiose/ui/docs`, so they match the installed
|
|
11
|
+
version, and the Lumiose site renders them with a live demo of each example at
|
|
12
|
+
[itsjavi.com/lumiose/ui](https://itsjavi.com/lumiose/ui/). Start with [Installation](installation.md)
|
|
13
|
+
and [Theming](theming.md). For coding agents, `llms.txt` indexes them and `llms-full.txt` holds them all;
|
|
14
|
+
`lumiose docs ui` and `lumiose docs dialog` print them; the MCP server's `ui_components` tool lists
|
|
15
|
+
every component's parts, props, events and styling hooks; and `lumiose agents init` adds a UI
|
|
16
|
+
section to `AGENTS.md` and the `lumiose-ui` skill. Every component page has the same sections:
|
|
17
|
+
Anatomy, API, Example, Styling, Keyboard and focus, Without JavaScript, Browser support and Related.
|
|
18
|
+
|
|
19
|
+
## Approach
|
|
20
|
+
|
|
21
|
+
- **Native first.** A component renders the element the platform already has for the job: a
|
|
22
|
+
`<dialog>` opened by an invoker command, a popover, a `<button>`. Those work in server HTML
|
|
23
|
+
before any script loads, and keep the browser's own keyboard handling and focus management.
|
|
24
|
+
- **Custom elements only where the platform falls short.** Keyboard navigation in a menu or the
|
|
25
|
+
filtering of a combobox need script: those components render a light-DOM custom element with a
|
|
26
|
+
`lu-` tag (`lu-menu`, `lu-combobox`) that upgrades the server HTML in place. A page is meant to
|
|
27
|
+
load only the definitions of the elements it renders, and no island runtime for them (in
|
|
28
|
+
development, with those components).
|
|
29
|
+
- **Small scripts for native gaps.** Where a native element falls short of Base UI's behaviour, a
|
|
30
|
+
small script closes the gap, such as Dialog's right-click and touch handling. The component
|
|
31
|
+
still works without it; a page that renders the component loads it once, without the island
|
|
32
|
+
runtime.
|
|
33
|
+
- **Parts.** Each component is a set of parts named as in Base UI (`Dialog.Root`,
|
|
34
|
+
`Dialog.Trigger`, `Dialog.Popup`, …). Every part that renders an element takes that element's
|
|
35
|
+
props (`class`, `style`, `aria-*`, events) and passes them through.
|
|
36
|
+
- **Signals.** A component's state is a prop and a callback: `open` with `onOpenChange`, `checked`
|
|
37
|
+
with `onCheckedChange`, `value` with `onValueChange`. Pass a signal's value and write it back in
|
|
38
|
+
the callback to keep both in sync, or leave both out and the element keeps its own state. Custom
|
|
39
|
+
elements report changes with lowercase DOM events, like native ones: `openchange`,
|
|
40
|
+
`checkedchange` and `change`.
|
|
41
|
+
- **Keyboard contracts.** Every component follows its WAI-ARIA Authoring Practices pattern, and
|
|
42
|
+
its end-to-end tests check the keyboard paths.
|
|
43
|
+
- **WCAG 2.2 AA.** Every component meets it: the components own semantics, keyboard and
|
|
44
|
+
behaviour, and the default theme owns the visual criteria (contrast, non-text contrast, focus
|
|
45
|
+
visible, target size). Each component's end-to-end tests run axe with the WCAG A and AA rules,
|
|
46
|
+
closed and open, in light and dark, and check reflow at 320 CSS pixels and text spacing.
|
|
47
|
+
|
|
48
|
+
This is the approach of [Timeless](https://timeless.build), ported to Lumiose.
|
|
49
|
+
|
|
50
|
+
## Styling
|
|
51
|
+
|
|
52
|
+
`@lumiose/ui` is **unstyled**: its stylesheets hold only what a component needs to work (display,
|
|
53
|
+
positioning and anchoring, the top layer, the CSS variables a component sets), in the `lu.core`
|
|
54
|
+
cascade layer. A look is opt-in: the default theme, or your own CSS. [Installation](installation.md)
|
|
55
|
+
shows the imports, and [Theming](theming.md) the layers, the theme's classes and custom properties,
|
|
56
|
+
and how to style the parts yourself.
|
|
57
|
+
|
|
58
|
+
### The default theme
|
|
59
|
+
|
|
60
|
+
`@lumiose/ui/themes/default.css` is a ready look for demos, prototypes and getting started: neutral
|
|
61
|
+
surfaces, a blue primary, light and dark. Its classes (`lu-button`, `lu-dialog`, …) and its `--lu-`
|
|
62
|
+
custom properties, named as in shadcn/ui, are listed in [Theming](theming.md#the-default-theme).
|
|
63
|
+
|
|
64
|
+
## Components
|
|
65
|
+
|
|
66
|
+
| Component | Status |
|
|
67
|
+
| ------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
68
|
+
| [Dialog, alert dialog](dialog.md) | Available: native `<dialog>`, works without JavaScript |
|
|
69
|
+
| [Popover](popover.md) | Available: native popover, works without JavaScript |
|
|
70
|
+
| [Tooltip](tooltip.md) | Available: native hint popover, opened by its script |
|
|
71
|
+
| [Menu](menu.md) | Available: popover menu, opens without JavaScript |
|
|
72
|
+
| [Combobox](combobox.md) | Available: `<lu-combobox>`, a form control that filters its options |
|
|
73
|
+
| [Select](select.md) | Available: native `<select>`, customizable where supported, no script |
|
|
74
|
+
| [Tabs](tabs.md) | Available: server HTML with the full state, `<lu-tabs>` adds the keys |
|
|
75
|
+
| [Field](field.md) | Available: label, description, errors; native validation in the field |
|
|
76
|
+
| [Fieldset](fieldset.md) | Available: native `<fieldset>` and `<legend>`, no script |
|
|
77
|
+
| [Form](form.md) | Available: native `<form>`, a form action's errors in its fields |
|
|
78
|
+
| [Input](input.md) | Available: native `<input>`, no script |
|
|
79
|
+
| [Checkbox](checkbox.md) | Available: native checkbox, indeterminate and read-only |
|
|
80
|
+
| [Checkbox Group](checkbox-group.md) | Available: checkboxes with one value and a parent checkbox |
|
|
81
|
+
| [Radio](radio-group.md) | Available: native radios in a radiogroup fieldset |
|
|
82
|
+
| [Switch](switch.md) | Available: native checkbox with the switch role |
|
|
83
|
+
| [Collapsible](collapsible.md) | Available: native `<details>`, works without JavaScript |
|
|
84
|
+
| [Accordion](accordion.md) | Available: native `<details>` items, `<lu-accordion>` adds the keys |
|
|
85
|
+
| [Toast](toast.md) | Available: server-rendered or from a manager, `<lu-toast-viewport>` times and closes them |
|
|
86
|
+
| [Button](button.md) | Available: native `<button>`, focusable when disabled |
|
|
87
|
+
| [Toggle](toggle.md) | Available: `<button aria-pressed>`, pressed by the component in an island |
|
|
88
|
+
| [Toggle Group](toggle-group.md) | Available: `<lu-toggle-group>` presses its toggles and moves focus |
|
|
89
|
+
| [Progress](progress.md) | Available: native `<progress>`, indeterminate when unknown, no script |
|
|
90
|
+
| [Meter](meter.md) | Available: native `<meter>`, no script |
|
|
91
|
+
| [Slider](slider.md) | Available: native range inputs, `<lu-slider>` adds ranges and Page Up and Page Down |
|
|
92
|
+
| [Number Field](number-field.md) | Available: native number input, or a formatted spinbutton; press-and-hold buttons |
|
|
93
|
+
| [Autocomplete](autocomplete.md) | Available: free text with suggestions, on Combobox's `<lu-combobox>` |
|
|
94
|
+
| [OTP Field](otp-field.md) | Available: one native input under visual slots; autofill, paste, validation natively |
|
|
95
|
+
| [Preview Card](preview-card.md) | Available: a link whose hover or focus opens a card; the link works without JavaScript |
|
|
96
|
+
| [Toolbar](toolbar.md) | Available: `<lu-toolbar>`, one tab stop across buttons, links, inputs and toggles |
|
|
97
|
+
| [Scroll Area](scroll-area.md) | Available: native scrolling, custom scrollbars from `<lu-scroll-area>` |
|
|
98
|
+
| [Context Menu](context-menu.md) | Available: Menu at the pointer, opened by its script (right-click, long press, keys) |
|
|
99
|
+
| [Drawer](drawer.md) | Available: a Dialog against an edge, works without JavaScript; its script adds the swipe |
|
|
100
|
+
| [Separator](separator.md) | Available: native `<hr>`, horizontal or vertical, no script |
|
|
101
|
+
| [Avatar](avatar.md) | Available: an image over its fallback, `<lu-avatar>` follows its loading status |
|
|
102
|
+
| [Menubar](menubar.md) | Available: Menus in a `role="menubar"`, `<lu-menubar>` moves between them |
|
|
103
|
+
| [Navigation Menu](navigation-menu.md) | Available: a `<nav>` whose triggers open panels of links, works without JavaScript |
|
|
104
|
+
|
|
105
|
+
## Ids
|
|
106
|
+
|
|
107
|
+
The parts connect themselves by id (`for`, `aria-labelledby`, `aria-controls`, `popovertarget`,
|
|
108
|
+
`commandfor`), which they generate. A server renders a page once, in order: a part can take an
|
|
109
|
+
`id` of yours only where nothing before it in the page points at it, and the parts after it then
|
|
110
|
+
point at yours.
|
|
111
|
+
|
|
112
|
+
- **Your `id` names it**: the triggers of Accordion, Collapsible, Menu (and submenus) and Navigation
|
|
113
|
+
Menu; Tabs' tabs; Combobox's `Label` and `Item`s; the `Title` and `Description` of a Dialog,
|
|
114
|
+
Drawer or Popover (the popup renders them before its own attributes); a Toast's `Root`; the
|
|
115
|
+
`Label` of a Slider, Progress or Meter; and the elements that nothing points at (a Slider's
|
|
116
|
+
`Root`, a Tabs or Accordion `Root`, a Toolbar, a Tooltip's trigger).
|
|
117
|
+
- **It keeps its generated id**, because a part before it points at it: the popups of Dialog,
|
|
118
|
+
Drawer, Popover, Tooltip, Preview Card and Menu; Navigation Menu's `Content`; the panels of Tabs,
|
|
119
|
+
Accordion and Collapsible; Combobox's `Input`, `List` and `Popup`; a Slider's thumbs and the
|
|
120
|
+
tracks of Progress and Meter (their `Label` points at them); a Field's control (`Field.Label`).
|
|
121
|
+
Combobox's and Field's `Root` keep theirs too: their scripts find the parts by it. A Number
|
|
122
|
+
Field or an OTP Field takes the input's `id` on its `Root`.
|
|
123
|
+
|
|
124
|
+
To reach a popup or a panel from elsewhere (a `commandfor`, a `#hash` link), point at a part that
|
|
125
|
+
takes your `id`, such as a tab, or read the generated id from the rendered page.
|
|
126
|
+
|
|
127
|
+
## Right to left
|
|
128
|
+
|
|
129
|
+
There is no direction provider: every component reads the direction from the page, the `dir`
|
|
130
|
+
attribute on `<html>` or on any container around it, when it acts (TASK-385). Right to left:
|
|
131
|
+
|
|
132
|
+
- **Arrow keys** that move along a row swap: Tabs, Toggle Group, Toolbar, Menubar, Navigation
|
|
133
|
+
Menu, Accordion's and Menu's submenu keys, a Radio group (the browser's), Slider and Number
|
|
134
|
+
Field's scrub area (leftward goes up).
|
|
135
|
+
- **Placement** follows logical sides and alignment: `inline-start` and `inline-end` sides, and
|
|
136
|
+
`align="start"` or `"end"`, mirror; `left` and `right` sides stay physical, as in Base UI.
|
|
137
|
+
- **Sliders and scrollbars** run from the right: a Slider's track and Indicator, a Scroll Area's
|
|
138
|
+
horizontal thumb, a Progress or Meter bar (the indeterminate segment slides leftward).
|
|
139
|
+
- **Swipes** are physical: a Toast or a Drawer swiped right goes right in either direction.
|
|
140
|
+
- **The theme** uses logical properties (`inset-inline-*`, `margin-inline-*`, `border-inline-*`),
|
|
141
|
+
so it mirrors with no extra rule. A numeric OTP Field keeps its slots left to right, as digits
|
|
142
|
+
read in right-to-left scripts too.
|
|
143
|
+
|
|
144
|
+
The theme avoids `:dir()`, so it also mirrors in a project whose own `build.cssTarget` predates
|
|
145
|
+
`:dir()`: Lightning CSS, which minifies production CSS, then lowers it to a list of `:lang()`
|
|
146
|
+
selectors that misses a `dir` attribute. It uses `[dir="rtl"]` around the component instead, or
|
|
147
|
+
logical properties.
|
|
148
|
+
|
|
149
|
+
## Localisation
|
|
150
|
+
|
|
151
|
+
Labels and messages come from your markup, with four English defaults to override for another
|
|
152
|
+
language (TASK-456):
|
|
153
|
+
|
|
154
|
+
| Default | Where | Override |
|
|
155
|
+
| ---------------------- | --------------------------------------------------------- | --------------------------------- |
|
|
156
|
+
| "Notifications (F6)" | [Toast](toast.md)`.Viewport`, the landmark's name | `aria-label` on `Toast.Viewport` |
|
|
157
|
+
| "Remove" | [Combobox](combobox.md)`.Chips`, before each chip's label | `removeLabel` on `Combobox.Chips` |
|
|
158
|
+
| "Increase", "Decrease" | [Number Field](number-field.md)'s step buttons | `aria-label` on each button |
|
|
159
|
+
|
|
160
|
+
Validation messages are the browser's, in its language; a Field's `validate` and a form action's
|
|
161
|
+
errors are yours.
|
|
162
|
+
|
|
163
|
+
## Size
|
|
164
|
+
|
|
165
|
+
`@lumiose/ui` is opt-in, so its size is reported apart from the runtime's caps:
|
|
166
|
+
`pnpm --filter @lumiose/ui size` prints, per component, the JavaScript a server-rendered page loads
|
|
167
|
+
for its custom elements, the JavaScript an island that uses it loads, and its CSS (minified, gzip
|
|
168
|
+
and brotli).
|
|
169
|
+
|
|
170
|
+
## Related
|
|
171
|
+
|
|
172
|
+
- [Web components](https://itsjavi.com/lumiose/docs/guides/web-components/): custom elements in Lumiose pages and islands.
|
|
173
|
+
- [Islands](https://itsjavi.com/lumiose/docs/concepts/islands/): where components run in the browser.
|
|
174
|
+
- [JSX](https://itsjavi.com/lumiose/docs/concepts/jsx/): `class`, events and `on:`.
|