@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,136 @@
|
|
|
1
|
+
// Preview Card's client script (decision-62, decision-63): it opens and closes every Preview Card
|
|
2
|
+
// on the page. A trigger is a link with `data-preview-card` naming its popup, a manual popover
|
|
3
|
+
// placed next to it by CSS anchor positioning (placement.css). Without the script the link is
|
|
4
|
+
// just a link: the card is an enhancement, as Base UI says its content should be.
|
|
5
|
+
//
|
|
6
|
+
// - Hover, with Popover's timing (../popover/hover.ts): a mouse or a pen resting on the link opens
|
|
7
|
+
// the card after `data-delay` (600 ms); it stays while the pointer is on the link or the card,
|
|
8
|
+
// and closes `data-close-delay` (300 ms) after it left both (WCAG 1.4.13: hoverable,
|
|
9
|
+
// persistent). Never on touch: a tap follows the link.
|
|
10
|
+
// - Keyboard focus (`:focus-visible`) on the link opens it after the same delay, without moving
|
|
11
|
+
// focus; it closes when focus leaves the link and the card. The open card follows its link in
|
|
12
|
+
// the focus order (the link is its invoker), so Tab reaches the card's links. A card that closes
|
|
13
|
+
// with focus inside (Escape) gives focus back to its link.
|
|
14
|
+
// - Dismissal by the shared layer model (../layer/layer.ts): Escape, without moving the pointer
|
|
15
|
+
// (1.4.13: dismissible), and a press outside.
|
|
16
|
+
// - Placement where CSS anchor positioning is missing (../placement/place.ts).
|
|
17
|
+
//
|
|
18
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
19
|
+
import { openLayer } from "../layer/layer.ts";
|
|
20
|
+
import { anchoring, place } from "../placement/place.ts";
|
|
21
|
+
import { hoverIntent } from "../popover/hover.ts";
|
|
22
|
+
|
|
23
|
+
const TRIGGER = "a[data-preview-card]";
|
|
24
|
+
|
|
25
|
+
/** Stops dismissing each open card (layer/layer.ts). */
|
|
26
|
+
const layers = new Map<HTMLElement, () => void>();
|
|
27
|
+
/** The cards that held focus as they started to close: focus goes back to their link. */
|
|
28
|
+
const holding = new WeakSet<HTMLElement>();
|
|
29
|
+
/** A link focus returns to: that focus does not open its card again. */
|
|
30
|
+
let returning: HTMLElement | undefined;
|
|
31
|
+
|
|
32
|
+
const popupOf = (trigger: HTMLElement) => {
|
|
33
|
+
const popup = document.getElementById(trigger.dataset.previewCard ?? "");
|
|
34
|
+
return popup?.hasAttribute("popover") ? popup : undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Whether focus is on `trigger` from the keyboard, or inside `popup`: it keeps the card open. */
|
|
38
|
+
const focusKeeps = (popup: HTMLElement, trigger: HTMLElement) =>
|
|
39
|
+
trigger.matches(":focus-visible") || popup.contains(document.activeElement);
|
|
40
|
+
|
|
41
|
+
function release(popup: HTMLElement) {
|
|
42
|
+
layers.get(popup)?.();
|
|
43
|
+
layers.delete(popup);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hover = hoverIntent({
|
|
47
|
+
triggerAt: (node) => node.closest?.<HTMLElement>(TRIGGER) ?? undefined,
|
|
48
|
+
popupOf,
|
|
49
|
+
show: (trigger, popup) => {
|
|
50
|
+
(popup.showPopover as (options?: { source?: HTMLElement }) => void)({ source: trigger });
|
|
51
|
+
if (!anchoring) place(popup, trigger);
|
|
52
|
+
},
|
|
53
|
+
hide: (popup, trigger) => {
|
|
54
|
+
if (!focusKeeps(popup, trigger)) popup.hidePopover();
|
|
55
|
+
},
|
|
56
|
+
delay: 600,
|
|
57
|
+
closeDelay: 300,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const on = <K extends keyof DocumentEventMap>(
|
|
61
|
+
type: K,
|
|
62
|
+
listener: (event: DocumentEventMap[K]) => void,
|
|
63
|
+
) => document.addEventListener(type, listener, true);
|
|
64
|
+
|
|
65
|
+
on("focusin", (event) => {
|
|
66
|
+
const target = event.target as Element;
|
|
67
|
+
const trigger = target.closest?.<HTMLElement>(TRIGGER);
|
|
68
|
+
if (trigger && trigger === returning) returning = undefined;
|
|
69
|
+
else if (trigger && target.matches(":focus-visible")) hover.open(trigger);
|
|
70
|
+
// Into an open card: it stays.
|
|
71
|
+
for (const [popup] of hover.hovered) if (popup.contains(target)) hover.stay(popup);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
on("focusout", (event) => {
|
|
75
|
+
const from = event.target as Node;
|
|
76
|
+
const to = event.relatedTarget as Node | null;
|
|
77
|
+
for (const [popup, trigger] of hover.hovered) {
|
|
78
|
+
const inside = (node: Node | null) =>
|
|
79
|
+
!!node && (popup.contains(node) || trigger.contains(node));
|
|
80
|
+
if (inside(from) && !inside(to)) {
|
|
81
|
+
// Focus left: close, unless the pointer is still on the link or the card.
|
|
82
|
+
if (trigger.matches(":hover") || popup.matches(":hover")) continue;
|
|
83
|
+
popup.hidePopover();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (from instanceof Element && from.closest?.(TRIGGER)) hover.cancel();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
on("beforetoggle", (event) => {
|
|
90
|
+
const popup = event.target;
|
|
91
|
+
if (!(popup instanceof HTMLElement) || !popup.matches("[popover][data-preview-card-popup]")) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
release(popup);
|
|
95
|
+
if (event.newState !== "open") {
|
|
96
|
+
if (popup.contains(document.activeElement)) holding.add(popup);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const trigger = document.querySelector<HTMLElement>(
|
|
100
|
+
`${TRIGGER}[data-preview-card="${CSS.escape(popup.id)}"]`,
|
|
101
|
+
);
|
|
102
|
+
layers.set(
|
|
103
|
+
popup,
|
|
104
|
+
openLayer({
|
|
105
|
+
popup,
|
|
106
|
+
contains: (node) => popup.contains(node) || !!trigger?.contains(node),
|
|
107
|
+
dismiss: () => popup.hidePopover(),
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
// Opened by the `open` prop: it is hover-opened from then on, so leaving it closes it.
|
|
111
|
+
if (trigger && !hover.hovered.has(popup)) hover.hovered.set(popup, trigger);
|
|
112
|
+
if (!anchoring && trigger) requestAnimationFrame(() => place(popup, trigger));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
on("toggle", (event) => {
|
|
116
|
+
const popup = event.target;
|
|
117
|
+
if (!(popup instanceof HTMLElement) || !popup.matches("[popover][data-preview-card-popup]")) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (event.newState === "closed") hover.forget(popup);
|
|
121
|
+
const trigger = document.querySelector<HTMLElement>(
|
|
122
|
+
`${TRIGGER}[data-preview-card="${CSS.escape(popup.id)}"]`,
|
|
123
|
+
);
|
|
124
|
+
trigger?.toggleAttribute("data-popup-open", event.newState === "open");
|
|
125
|
+
// Closed with focus inside (Escape, the `open` prop): the browser drops it to the page; back to
|
|
126
|
+
// the link instead (TASK-434). Focus that moved on elsewhere stays there.
|
|
127
|
+
if (event.newState === "closed" && holding.has(popup)) {
|
|
128
|
+
holding.delete(popup);
|
|
129
|
+
const active = document.activeElement;
|
|
130
|
+
if (trigger && (!active || active === document.body || popup.contains(active))) {
|
|
131
|
+
returning = trigger;
|
|
132
|
+
trigger.focus({ preventScroll: true });
|
|
133
|
+
returning = undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Preview Card (decision-62, TASK-381): a card that previews a link's destination on hover and on
|
|
2
|
+
// keyboard focus, after Base UI's PreviewCard. The trigger is the link itself; the popup is a
|
|
3
|
+
// manual popover placed next to it with CSS anchor positioning (Popover's placement.css), which
|
|
4
|
+
// preview-card.script.ts opens and closes with Popover's hover timing (../popover/hover.ts).
|
|
5
|
+
// Without JavaScript the link is just a link. The popup's `toggle` event is the single source of
|
|
6
|
+
// `onOpenChange`; the `open` prop drives it in the browser.
|
|
7
|
+
import { createContext, effect, ref, useContext, useId } from "lumiose";
|
|
8
|
+
import { anchorName, placement, withStyle, type PlacementProps } from "../placement/placement.ts";
|
|
9
|
+
import previewCardScript from "./preview-card.script.ts?client";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `PreviewCard.Root` renders no element: it holds the ids its parts share. `bind:open={signal}`
|
|
13
|
+
* stands in for `open` and `onOpenChange`.
|
|
14
|
+
*/
|
|
15
|
+
export interface PreviewCardRootProps {
|
|
16
|
+
/** Opens (`true`) or closes (`false`) the card when it changes, in the browser. */
|
|
17
|
+
open?: boolean;
|
|
18
|
+
/** Called after the card opens or closes, whatever opened or closed it. */
|
|
19
|
+
onOpenChange?: (open: boolean) => void;
|
|
20
|
+
/** The other parts: the `Trigger` and the `Popup`, anywhere inside. */
|
|
21
|
+
children?: JSX.Element;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The link: an `<a href>` whose hover and keyboard focus open the card. */
|
|
25
|
+
export type PreviewCardTriggerProps = JSX.IntrinsicElements["a"] & {
|
|
26
|
+
/** How long the pointer or focus rests on the link before the card opens, in ms. Default `600`. */
|
|
27
|
+
delay?: number;
|
|
28
|
+
/** How long the card stays after the pointer leaves the link and the card, in ms. Default `300`. */
|
|
29
|
+
closeDelay?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The card: a `<div popover="manual">` placed next to the link. */
|
|
33
|
+
export type PreviewCardPopupProps = JSX.IntrinsicElements["div"] & PlacementProps;
|
|
34
|
+
|
|
35
|
+
interface PreviewCardContextValue {
|
|
36
|
+
id: string;
|
|
37
|
+
popupId: string;
|
|
38
|
+
popup: JSX.Ref<HTMLDivElement>;
|
|
39
|
+
onToggle: (open: boolean) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PreviewCardContext = createContext<PreviewCardContextValue>();
|
|
43
|
+
|
|
44
|
+
function usePreviewCard(part: string): PreviewCardContextValue {
|
|
45
|
+
const context = useContext(PreviewCardContext);
|
|
46
|
+
if (!context) throw new Error(`PreviewCard.${part} must be inside <PreviewCard.Root>.`);
|
|
47
|
+
return context;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Root(props: PreviewCardRootProps) {
|
|
51
|
+
// Hover, focus, dismissal and fallback placement; the page loads it once (decision-63).
|
|
52
|
+
previewCardScript();
|
|
53
|
+
const id = useId();
|
|
54
|
+
const popup = ref<HTMLDivElement>();
|
|
55
|
+
// The `open` prop drives the popover; its `toggle` event reports back.
|
|
56
|
+
effect(() => {
|
|
57
|
+
const element = popup();
|
|
58
|
+
const wanted = props.open;
|
|
59
|
+
if (!element?.isConnected || wanted === undefined) return;
|
|
60
|
+
const shown = element.matches(":popover-open");
|
|
61
|
+
if (wanted && !shown) element.showPopover();
|
|
62
|
+
else if (!wanted && shown) element.hidePopover();
|
|
63
|
+
});
|
|
64
|
+
const context: PreviewCardContextValue = {
|
|
65
|
+
id,
|
|
66
|
+
popupId: `${id}-popup`,
|
|
67
|
+
popup,
|
|
68
|
+
onToggle: (next) => props.onOpenChange?.(next),
|
|
69
|
+
};
|
|
70
|
+
return <PreviewCardContext value={context}>{props.children}</PreviewCardContext>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Trigger({ children, delay, closeDelay, style, ...rest }: PreviewCardTriggerProps) {
|
|
74
|
+
const card = usePreviewCard("Trigger");
|
|
75
|
+
return (
|
|
76
|
+
<a
|
|
77
|
+
{...rest}
|
|
78
|
+
style={withStyle(style, { "anchor-name": anchorName(card.id) })}
|
|
79
|
+
data-preview-card={card.popupId}
|
|
80
|
+
data-delay={delay}
|
|
81
|
+
data-close-delay={closeDelay}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</a>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function Popup({
|
|
89
|
+
children,
|
|
90
|
+
ref: forwarded,
|
|
91
|
+
side,
|
|
92
|
+
align,
|
|
93
|
+
sideOffset,
|
|
94
|
+
alignOffset,
|
|
95
|
+
...rest
|
|
96
|
+
}: PreviewCardPopupProps) {
|
|
97
|
+
const card = usePreviewCard("Popup");
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
{...rest}
|
|
101
|
+
{...placement({ side, align, sideOffset, alignOffset, style: rest.style }, card.id, "bottom")}
|
|
102
|
+
ref={(element: HTMLDivElement) => {
|
|
103
|
+
card.popup(element);
|
|
104
|
+
forwarded?.(element);
|
|
105
|
+
}}
|
|
106
|
+
id={card.popupId}
|
|
107
|
+
popover="manual"
|
|
108
|
+
data-preview-card-popup=""
|
|
109
|
+
on:toggle={(event: ToggleEvent) => card.onToggle(event.newState === "open")}
|
|
110
|
+
>
|
|
111
|
+
{children}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A preview of a link's destination, shown while the pointer or keyboard focus rests on the link,
|
|
118
|
+
* after Base UI's PreviewCard. A visual enhancement: the link works without it (and without
|
|
119
|
+
* JavaScript), so the card should not hold anything the linked page lacks. Unstyled: style the
|
|
120
|
+
* parts through `class`, `:popover-open`, `[data-side]` and the link's `[data-popup-open]`.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* <PreviewCard.Root>
|
|
124
|
+
* <PreviewCard.Trigger href="/docs/signals">signals</PreviewCard.Trigger>
|
|
125
|
+
* <PreviewCard.Popup sideOffset={8}>Values that update what reads them.</PreviewCard.Popup>
|
|
126
|
+
* </PreviewCard.Root>
|
|
127
|
+
*/
|
|
128
|
+
export const PreviewCard = { Root, Trigger, Popup };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Progress (decision-62, TASK-373): a native `<progress>`, after Base UI's Progress. The element
|
|
2
|
+
// is the progress bar for assistive technology (`role="progressbar"`, its value and maximum, and
|
|
3
|
+
// `aria-valuetext` with the formatted value), indeterminate when it has no value (`:indeterminate`);
|
|
4
|
+
// the Root around it holds the label and the value text, and sets `--lu-value`. No script: a
|
|
5
|
+
// progress only changes when the component renders it again.
|
|
6
|
+
import { createContext, useContext, useId } from "lumiose";
|
|
7
|
+
import { withStyle } from "../placement/placement.ts";
|
|
8
|
+
import { formatValue, percent } from "./value.ts";
|
|
9
|
+
|
|
10
|
+
interface ProgressContextValue {
|
|
11
|
+
id: string;
|
|
12
|
+
value: () => number | null;
|
|
13
|
+
min: () => number;
|
|
14
|
+
max: () => number;
|
|
15
|
+
text: () => string | undefined;
|
|
16
|
+
/** What assistive technology reads: `getAriaValueText`'s, else the formatted value. */
|
|
17
|
+
valueText: () => string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ProgressContext = createContext<ProgressContextValue>();
|
|
21
|
+
|
|
22
|
+
/** `Progress.Root` renders a `<div>` around the parts, with `--lu-value` (0% to 100%). */
|
|
23
|
+
export type ProgressRootProps = JSX.HTMLProps<HTMLElement> & {
|
|
24
|
+
/** The progress so far, from `min` to `max`; `null` while it is unknown (indeterminate). */
|
|
25
|
+
value: number | null;
|
|
26
|
+
/** The value at the start. Default `0`. */
|
|
27
|
+
min?: number;
|
|
28
|
+
/** The value when done. Default `100`. */
|
|
29
|
+
max?: number;
|
|
30
|
+
/** How the value is written: `Intl.NumberFormat` options. Default: a percentage of the range. */
|
|
31
|
+
format?: Intl.NumberFormatOptions;
|
|
32
|
+
/** The locale of the formatted value. Default: the browser's (the server's in server HTML). */
|
|
33
|
+
locale?: Intl.LocalesArgument;
|
|
34
|
+
/** The text assistive technology reads for the value. Default: the formatted value. */
|
|
35
|
+
getAriaValueText?: (formattedValue: string, value: number | null) => string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The `<label>` of the progress bar. */
|
|
39
|
+
export type ProgressLabelProps = JSX.IntrinsicElements["label"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The progress bar: a native `<progress>`, styled with `appearance: none` and its
|
|
43
|
+
* `::-webkit-progress-value` and `::-moz-progress-bar` pseudo-elements, or `--lu-value`.
|
|
44
|
+
*/
|
|
45
|
+
export type ProgressTrackProps = Omit<JSX.IntrinsicElements["progress"], "value" | "max">;
|
|
46
|
+
|
|
47
|
+
/** A `<span>` with the formatted value. */
|
|
48
|
+
export type ProgressValueProps = Omit<JSX.IntrinsicElements["span"], "children"> & {
|
|
49
|
+
/** Renders the value your way, from the formatted value and the number. */
|
|
50
|
+
children?: (formattedValue: string, value: number | null) => JSX.Element;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function useProgress(part: string): ProgressContextValue {
|
|
54
|
+
const context = useContext(ProgressContext);
|
|
55
|
+
if (!context) throw new Error(`Progress.${part} must be inside <Progress.Root>.`);
|
|
56
|
+
return context;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Root({
|
|
60
|
+
value,
|
|
61
|
+
min,
|
|
62
|
+
max,
|
|
63
|
+
format,
|
|
64
|
+
locale,
|
|
65
|
+
getAriaValueText,
|
|
66
|
+
children,
|
|
67
|
+
style,
|
|
68
|
+
...rest
|
|
69
|
+
}: ProgressRootProps) {
|
|
70
|
+
const id = useId();
|
|
71
|
+
const low = () => min ?? 0;
|
|
72
|
+
const high = () => max ?? 100;
|
|
73
|
+
const text = () => {
|
|
74
|
+
const current = value;
|
|
75
|
+
if (current === null) return undefined;
|
|
76
|
+
return formatValue(current, low(), high(), format, locale);
|
|
77
|
+
};
|
|
78
|
+
const valueText = () => {
|
|
79
|
+
const formatted = text();
|
|
80
|
+
if (!getAriaValueText) return formatted;
|
|
81
|
+
return value === null ? undefined : getAriaValueText(formatted ?? "", value);
|
|
82
|
+
};
|
|
83
|
+
const context: ProgressContextValue = {
|
|
84
|
+
id,
|
|
85
|
+
value: () => value,
|
|
86
|
+
min: low,
|
|
87
|
+
max: high,
|
|
88
|
+
text,
|
|
89
|
+
valueText,
|
|
90
|
+
};
|
|
91
|
+
return (
|
|
92
|
+
<ProgressContext value={context}>
|
|
93
|
+
<div
|
|
94
|
+
{...rest}
|
|
95
|
+
style={withStyle(style, {
|
|
96
|
+
"--lu-value": value === null ? undefined : `${percent(value, low(), high())}%`,
|
|
97
|
+
})}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</div>
|
|
101
|
+
</ProgressContext>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function Label({ children, ...rest }: ProgressLabelProps) {
|
|
106
|
+
const progress = useProgress("Label");
|
|
107
|
+
return (
|
|
108
|
+
// Nothing points at the label: a caller's `id` names it (TASK-441 M39).
|
|
109
|
+
<label {...rest} id={rest.id ?? `${progress.id}-label`} for={`${progress.id}-track`}>
|
|
110
|
+
{children}
|
|
111
|
+
</label>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function Track(props: ProgressTrackProps) {
|
|
116
|
+
const progress = useProgress("Track");
|
|
117
|
+
// The native element counts from 0: the value's distance from `min`.
|
|
118
|
+
const position = () => {
|
|
119
|
+
const value = progress.value();
|
|
120
|
+
return value === null ? undefined : value - progress.min();
|
|
121
|
+
};
|
|
122
|
+
// Without a value (`undefined`), the runtime removes the attribute: the bar is indeterminate.
|
|
123
|
+
return (
|
|
124
|
+
<progress
|
|
125
|
+
{...props}
|
|
126
|
+
value={position()}
|
|
127
|
+
id={`${progress.id}-track`}
|
|
128
|
+
max={progress.max() - progress.min()}
|
|
129
|
+
aria-valuetext={progress.valueText()}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function Value({ children, ...rest }: ProgressValueProps) {
|
|
135
|
+
const progress = useProgress("Value");
|
|
136
|
+
return (
|
|
137
|
+
<span {...rest}>
|
|
138
|
+
{children ? children(progress.text() ?? "", progress.value()) : progress.text()}
|
|
139
|
+
</span>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A progress bar: a task's progress, or an indeterminate one while it is unknown. Native
|
|
145
|
+
* `<progress>`, with a label and the value as text. Unstyled: style it through `class`,
|
|
146
|
+
* `:indeterminate` and `--lu-value`.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* <Progress.Root value={uploaded()}>
|
|
150
|
+
* <Progress.Label>Uploading</Progress.Label>
|
|
151
|
+
* <Progress.Value />
|
|
152
|
+
* <Progress.Track />
|
|
153
|
+
* </Progress.Root>
|
|
154
|
+
*/
|
|
155
|
+
export const Progress = { Root, Label, Track, Value };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// What Progress, Meter and Slider show for a value: Base UI's formatting (a percentage of the range
|
|
2
|
+
// by default, or `Intl.NumberFormat` options), and the `--lu-value` custom property (the value's
|
|
3
|
+
// place in the range, as a percentage) that styles a bar or an indicator.
|
|
4
|
+
|
|
5
|
+
/** The value's place between `min` and `max`, from 0 to 100. */
|
|
6
|
+
export function percent(value: number, min: number, max: number): number {
|
|
7
|
+
if (max <= min) return 0;
|
|
8
|
+
return Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The value as text: a percentage of the range, or formatted with `format` when given. */
|
|
12
|
+
export function formatValue(
|
|
13
|
+
value: number,
|
|
14
|
+
min: number,
|
|
15
|
+
max: number,
|
|
16
|
+
format: Intl.NumberFormatOptions | undefined,
|
|
17
|
+
locale: Intl.LocalesArgument,
|
|
18
|
+
): string {
|
|
19
|
+
if (format) return new Intl.NumberFormat(locale, format).format(value);
|
|
20
|
+
return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 }).format(
|
|
21
|
+
percent(value, min, max) / 100,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// RadioGroup and Radio (decision-62, TASK-367): native radios in a `<fieldset role="radiogroup">`,
|
|
2
|
+
// after Base UI's RadioGroup and Radio. The platform gives the group one tab stop (the checked
|
|
3
|
+
// radio, or the first), arrow keys that move and select (skipping disabled radios, following the
|
|
4
|
+
// direction), `required`, submission and reset, in server HTML and without JavaScript; the legend
|
|
5
|
+
// names the group. checkbox.script.ts adds read-only.
|
|
6
|
+
import { createContext, effect, ref, signal, untrack, useContext, useId } from "lumiose";
|
|
7
|
+
import checkboxScript from "../checkbox/checkbox.script.ts?client";
|
|
8
|
+
import { describedBy, labelledBy, useField, useFormValues } from "../field/context.ts";
|
|
9
|
+
import { onFormReset } from "../form/reset.ts";
|
|
10
|
+
|
|
11
|
+
interface RadioGroupContextValue {
|
|
12
|
+
name: () => string;
|
|
13
|
+
value: () => string | undefined;
|
|
14
|
+
required: () => boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RadioGroupContext = createContext<RadioGroupContextValue>();
|
|
18
|
+
|
|
19
|
+
/** The fieldset props RadioGroup passes through, without the brand of intrinsic elements' props. */
|
|
20
|
+
type FieldsetProps = Omit<JSX.IntrinsicElements["fieldset"], symbol>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `RadioGroup` renders a `<fieldset role="radiogroup">`; name it with a `Fieldset.Legend` inside.
|
|
24
|
+
* `bind:value={signal}` stands in for `value` and `onValueChange`.
|
|
25
|
+
*/
|
|
26
|
+
export type RadioGroupProps = FieldsetProps & {
|
|
27
|
+
/** Checks the radio with this value when it changes. Pair it with `onValueChange`. */
|
|
28
|
+
value?: string;
|
|
29
|
+
/** The radio checked at first, when `value` is left out. */
|
|
30
|
+
defaultValue?: string;
|
|
31
|
+
/** Called with the value of the radio the user checks. */
|
|
32
|
+
onValueChange?: (value: string) => void;
|
|
33
|
+
/** The name the radios share and submit under. Default: a generated one. */
|
|
34
|
+
name?: string;
|
|
35
|
+
/** A radio must be checked to submit the form. */
|
|
36
|
+
required?: boolean;
|
|
37
|
+
/** The radios cannot be checked, but stay focusable and are submitted (`aria-readonly`). */
|
|
38
|
+
readOnly?: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** `Radio.Root` renders an `<input type="radio">` of its group. */
|
|
42
|
+
export type RadioRootProps = Omit<
|
|
43
|
+
JSX.IntrinsicElements["input"],
|
|
44
|
+
symbol | "type" | "checked" | "value" | "name" | "bind:checked" | "bind:value"
|
|
45
|
+
> & {
|
|
46
|
+
/** Required: the value the group takes, and submits, when this radio is checked. */
|
|
47
|
+
value: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A group of radios with one value. Native radios inside a fieldset: arrow keys, one tab stop,
|
|
52
|
+
* forms and reset are the browser's.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* <RadioGroup name="plan" defaultValue="pro">
|
|
56
|
+
* <Fieldset.Legend>Plan</Fieldset.Legend>
|
|
57
|
+
* <label><Radio.Root value="free" /> Free</label>
|
|
58
|
+
* <label><Radio.Root value="pro" /> Pro</label>
|
|
59
|
+
* </RadioGroup>
|
|
60
|
+
*/
|
|
61
|
+
export function RadioGroup({
|
|
62
|
+
value,
|
|
63
|
+
defaultValue,
|
|
64
|
+
onValueChange,
|
|
65
|
+
name,
|
|
66
|
+
required,
|
|
67
|
+
readOnly,
|
|
68
|
+
children,
|
|
69
|
+
...rest
|
|
70
|
+
}: RadioGroupProps) {
|
|
71
|
+
checkboxScript();
|
|
72
|
+
const field = useField();
|
|
73
|
+
const id = useId();
|
|
74
|
+
// A failed submission's values (a Form's `values`) replace the default (TASK-430).
|
|
75
|
+
const sent = useFormValues(
|
|
76
|
+
untrack(() => name ?? field?.name()),
|
|
77
|
+
untrack(() => rest.disabled),
|
|
78
|
+
);
|
|
79
|
+
const selected = signal<string | undefined>(
|
|
80
|
+
untrack(() => value ?? (sent ? sent[0] : defaultValue)),
|
|
81
|
+
);
|
|
82
|
+
// The `value` prop drives the radios when it changes.
|
|
83
|
+
effect(() => {
|
|
84
|
+
const next = value;
|
|
85
|
+
if (next !== undefined) selected.set(next);
|
|
86
|
+
});
|
|
87
|
+
const context: RadioGroupContextValue = {
|
|
88
|
+
name: () => name ?? field?.name() ?? id,
|
|
89
|
+
value: selected,
|
|
90
|
+
required: () => required === true,
|
|
91
|
+
};
|
|
92
|
+
// A form reset checks the default radio without a `change`: the group follows it, and
|
|
93
|
+
// `onValueChange` gets it ("" for none) (TASK-435).
|
|
94
|
+
const group = ref<HTMLFieldSetElement>();
|
|
95
|
+
onFormReset(
|
|
96
|
+
group,
|
|
97
|
+
() =>
|
|
98
|
+
[...group()!.querySelectorAll<HTMLInputElement>("input[type=radio]")].find(
|
|
99
|
+
(radio) => radio.checked && radio.closest("fieldset") === group(),
|
|
100
|
+
)?.value ?? "",
|
|
101
|
+
(next) => {
|
|
102
|
+
selected.set(next || undefined);
|
|
103
|
+
onValueChange?.(next);
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
return (
|
|
107
|
+
<RadioGroupContext value={context}>
|
|
108
|
+
<fieldset
|
|
109
|
+
{...rest}
|
|
110
|
+
ref={(element: HTMLFieldSetElement) => {
|
|
111
|
+
group(element);
|
|
112
|
+
rest.ref?.(element);
|
|
113
|
+
}}
|
|
114
|
+
disabled={rest.disabled || field?.disabled() || undefined}
|
|
115
|
+
// In a field the Label names the group: its `for` has no single radio to point at.
|
|
116
|
+
aria-labelledby={
|
|
117
|
+
rest["aria-labelledby"] ?? (rest["aria-label"] ? undefined : labelledBy(field))
|
|
118
|
+
}
|
|
119
|
+
aria-describedby={rest["aria-describedby"] ?? (field ? describedBy(field) : undefined)}
|
|
120
|
+
aria-invalid={rest["aria-invalid"] ?? (field?.invalid() ? "true" : undefined)}
|
|
121
|
+
role="radiogroup"
|
|
122
|
+
aria-required={required ? "true" : undefined}
|
|
123
|
+
aria-readonly={readOnly ? "true" : undefined}
|
|
124
|
+
on:change={(event: Event) => {
|
|
125
|
+
const radio = event.target as HTMLInputElement;
|
|
126
|
+
if (radio.type !== "radio" || !radio.checked) return;
|
|
127
|
+
selected.set(radio.value);
|
|
128
|
+
onValueChange?.(radio.value);
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{children}
|
|
132
|
+
</fieldset>
|
|
133
|
+
</RadioGroupContext>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Root({ value, ...rest }: RadioRootProps) {
|
|
138
|
+
const group = useContext(RadioGroupContext);
|
|
139
|
+
if (!group) throw new Error("Radio.Root must be inside <RadioGroup>.");
|
|
140
|
+
return (
|
|
141
|
+
<input
|
|
142
|
+
required={group.required() || undefined}
|
|
143
|
+
{...rest}
|
|
144
|
+
type="radio"
|
|
145
|
+
name={group.name()}
|
|
146
|
+
value={value}
|
|
147
|
+
checked={group.value() === value}
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A radio of a `RadioGroup`. Unstyled: style it through `class` and `:checked`, `:disabled` and
|
|
154
|
+
* `:focus-visible` (with `appearance: none` to draw your own).
|
|
155
|
+
*/
|
|
156
|
+
export const Radio = { Root };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The CSS Scroll Area needs to work, and nothing else (decision-62). The Viewport fills the Root
|
|
3
|
+
* and scrolls natively. Once the script runs (`:defined`), the native scrollbar is hidden and the
|
|
4
|
+
* custom scrollbars lie over the viewport's edges, their thumbs sized by `--lu-scroll-area-thumb-*`
|
|
5
|
+
* and leaving room for the corner; until then (or without JavaScript) the native scrollbars show
|
|
6
|
+
* and the custom ones stay hidden. It sits in the `lu.core` layer, so any app CSS wins.
|
|
7
|
+
*/
|
|
8
|
+
@layer lu.core, lu.theme;
|
|
9
|
+
|
|
10
|
+
@layer lu.core {
|
|
11
|
+
lu-scroll-area {
|
|
12
|
+
display: block;
|
|
13
|
+
position: relative;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
lu-scroll-area [data-viewport] {
|
|
17
|
+
box-sizing: border-box;
|
|
18
|
+
block-size: 100%;
|
|
19
|
+
max-block-size: inherit;
|
|
20
|
+
inline-size: 100%;
|
|
21
|
+
overflow: auto;
|
|
22
|
+
}
|
|
23
|
+
/* The Content grows with what it holds, so wide content scrolls sideways instead of spilling. */
|
|
24
|
+
lu-scroll-area [data-viewport] > * {
|
|
25
|
+
min-inline-size: fit-content;
|
|
26
|
+
}
|
|
27
|
+
lu-scroll-area:defined [data-viewport] {
|
|
28
|
+
scrollbar-width: none;
|
|
29
|
+
}
|
|
30
|
+
lu-scroll-area:defined [data-viewport]::-webkit-scrollbar {
|
|
31
|
+
display: none;
|
|
32
|
+
}
|
|
33
|
+
/* Important: a theme's display for the scrollbars must not show them without the script. */
|
|
34
|
+
lu-scroll-area:not(:defined) [data-scrollbar],
|
|
35
|
+
lu-scroll-area:not(:defined) [data-corner] {
|
|
36
|
+
display: none !important;
|
|
37
|
+
}
|
|
38
|
+
lu-scroll-area [data-scrollbar] {
|
|
39
|
+
display: flex;
|
|
40
|
+
position: absolute;
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
touch-action: none;
|
|
43
|
+
user-select: none;
|
|
44
|
+
}
|
|
45
|
+
lu-scroll-area [data-scrollbar][hidden],
|
|
46
|
+
lu-scroll-area [data-corner][hidden] {
|
|
47
|
+
display: none;
|
|
48
|
+
}
|
|
49
|
+
lu-scroll-area [data-scrollbar][data-orientation="vertical"] {
|
|
50
|
+
inset-block: 0 var(--lu-scroll-area-corner-height, 0px);
|
|
51
|
+
inset-inline-end: 0;
|
|
52
|
+
}
|
|
53
|
+
lu-scroll-area [data-scrollbar][data-orientation="horizontal"] {
|
|
54
|
+
inset-inline: 0 var(--lu-scroll-area-corner-width, 0px);
|
|
55
|
+
inset-block-end: 0;
|
|
56
|
+
}
|
|
57
|
+
lu-scroll-area [data-scrollbar][data-orientation="vertical"] [data-thumb] {
|
|
58
|
+
inline-size: 100%;
|
|
59
|
+
block-size: var(--lu-scroll-area-thumb-height);
|
|
60
|
+
}
|
|
61
|
+
lu-scroll-area [data-scrollbar][data-orientation="horizontal"] [data-thumb] {
|
|
62
|
+
block-size: 100%;
|
|
63
|
+
inline-size: var(--lu-scroll-area-thumb-width);
|
|
64
|
+
}
|
|
65
|
+
lu-scroll-area [data-corner] {
|
|
66
|
+
position: absolute;
|
|
67
|
+
inset-block-end: 0;
|
|
68
|
+
inset-inline-end: 0;
|
|
69
|
+
inline-size: var(--lu-scroll-area-corner-width);
|
|
70
|
+
block-size: var(--lu-scroll-area-corner-height);
|
|
71
|
+
}
|
|
72
|
+
}
|