@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javi Aguilar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lumiose/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Accessible, unstyled UI primitives for [Lumiose](https://github.com/itsjavi/lumiose): native
|
|
4
|
+
elements first (`<dialog>`, popover, invoker commands, CSS anchor positioning), light-DOM custom
|
|
5
|
+
elements (`lu-*`) only where the platform falls short, server-rendered without hydration. Parts
|
|
6
|
+
follow [Base UI](https://base-ui.com)'s anatomy; the approach is [Timeless](https://timeless.build)'s.
|
|
7
|
+
|
|
8
|
+
**In development**: not on npm yet. It keeps its own 0.x versions, apart from the version the other
|
|
9
|
+
Lumiose packages share.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { Dialog } from "@lumiose/ui";
|
|
13
|
+
import "@lumiose/ui/styles.css"; // only the CSS the components need to work (`lu.core` layer)
|
|
14
|
+
import "@lumiose/ui/themes/default.css"; // optional look: `--lu-` custom properties, light and dark
|
|
15
|
+
|
|
16
|
+
<Dialog.Root role="alertdialog">
|
|
17
|
+
<Dialog.Trigger class="lu-button">Delete project…</Dialog.Trigger>
|
|
18
|
+
<Dialog.Popup class="lu-dialog">
|
|
19
|
+
<Dialog.Title>Delete project?</Dialog.Title>
|
|
20
|
+
<Dialog.Description>This removes the project and its deployments.</Dialog.Description>
|
|
21
|
+
<footer>
|
|
22
|
+
<Dialog.Close class="lu-button" value="cancel">
|
|
23
|
+
Cancel
|
|
24
|
+
</Dialog.Close>
|
|
25
|
+
<Dialog.Close class="lu-button" data-variant="destructive" value="delete">
|
|
26
|
+
Delete
|
|
27
|
+
</Dialog.Close>
|
|
28
|
+
</footer>
|
|
29
|
+
</Dialog.Popup>
|
|
30
|
+
</Dialog.Root>;
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The package ships its `.tsx` and `.css` sources, which the app's Lumiose compiler compiles for the
|
|
34
|
+
server and the browser. Docs ship with it, for this version: `docs/` (`docs/llms.txt` indexes them;
|
|
35
|
+
in a project, `node_modules/@lumiose/ui/docs/`), and `lumiose docs ui` prints them.
|
|
36
|
+
|
|
37
|
+
- `pnpm --filter @lumiose/ui test`: unit tests (server rendering and the DOM, Vitest).
|
|
38
|
+
- `pnpm --filter @lumiose/ui size`: JavaScript and CSS per component.
|
|
39
|
+
- `pnpm --filter @lumiose-examples/ui-e2e test:e2e`: keyboard contracts, focus, axe and behaviour
|
|
40
|
+
without JavaScript, in Chromium; `test:e2e:browsers` runs them in Firefox and WebKit (CI's
|
|
41
|
+
ui-browsers workflow).
|
|
42
|
+
|
|
43
|
+
## Writing a component
|
|
44
|
+
|
|
45
|
+
The contract is decision-62 (native elements first, Base UI's anatomy, lowercase events, `bind:`,
|
|
46
|
+
the `lu.core` layer, WCAG 2.2 AA) and each docs page follows `docs/dialog.md` (decision-64).
|
|
47
|
+
|
|
48
|
+
- **A `lu-` element's getters have setters, or no prop shares their name.** On a custom element,
|
|
49
|
+
Lumiose sends a JSX prop to the element's property of that name when it has one (React 19's
|
|
50
|
+
rule; see the web components guide). A getter without a setter makes that assignment throw, and
|
|
51
|
+
the island's hydration fails. Read an attribute the component renders through a private getter
|
|
52
|
+
(`get #min()`) or another name. `src/elements.dom.test.ts` checks the attributes each component
|
|
53
|
+
writes on its `<lu-…>` tags against its element's read-only properties; props that pass through a
|
|
54
|
+
spread (`{...rest}`) are yours to check.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Accordion's items are native <details>: the browser shows and hides their panels. What they need
|
|
3
|
+
* on top: `<lu-accordion>`, a custom element, is inline until styled: it is a block, like the
|
|
4
|
+
* `<div>` it stands for; the heading inside a trigger stays on the same line as the <summary>'s
|
|
5
|
+
* marker; and a disabled trigger (aria-disabled) ignores the pointer, also before the client
|
|
6
|
+
* script loads.
|
|
7
|
+
*/
|
|
8
|
+
@layer lu.core, lu.theme;
|
|
9
|
+
|
|
10
|
+
@layer lu.core {
|
|
11
|
+
lu-accordion {
|
|
12
|
+
display: block;
|
|
13
|
+
}
|
|
14
|
+
lu-accordion > details > summary > :is(h1, h2, h3, h4, h5, h6) {
|
|
15
|
+
display: inline;
|
|
16
|
+
}
|
|
17
|
+
summary[aria-disabled="true"] {
|
|
18
|
+
pointer-events: none;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Accordion's client script (decision-62, decision-63): defines `<lu-accordion>`, the light-DOM
|
|
2
|
+
// element that Accordion.Root renders, and starts the panels' animation hooks and disabled triggers
|
|
3
|
+
// (../collapsible/panel.ts). The items are native `<details>` right inside it, so opening and
|
|
4
|
+
// closing, the exclusive group (`name`) and find-in-page need no script. The element adds the
|
|
5
|
+
// optional keys of the APG Accordion pattern, as Base UI and Zag have them (doc-9 §9):
|
|
6
|
+
//
|
|
7
|
+
// - Down and Up move focus to the next and the previous trigger (Right and Left when the root has
|
|
8
|
+
// `data-orientation="horizontal"`, swapped in right-to-left); Home and End to the first and the
|
|
9
|
+
// last. Focus loops unless the root has `data-loop-focus="false"`.
|
|
10
|
+
// - Disabled triggers (`aria-disabled="true"`) are skipped.
|
|
11
|
+
//
|
|
12
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
13
|
+
import { watchPanels } from "../collapsible/panel.ts";
|
|
14
|
+
|
|
15
|
+
watchPanels();
|
|
16
|
+
|
|
17
|
+
const enabled = (trigger: HTMLElement) => trigger.getAttribute("aria-disabled") !== "true";
|
|
18
|
+
|
|
19
|
+
export class LuAccordion extends HTMLElement {
|
|
20
|
+
/** The triggers of its own items, in order. */
|
|
21
|
+
get triggers(): HTMLElement[] {
|
|
22
|
+
return [...this.querySelectorAll<HTMLElement>(":scope > details > summary")];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connectedCallback() {
|
|
26
|
+
this.addEventListener("keydown", this.#onKeyDown);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnectedCallback() {
|
|
30
|
+
this.removeEventListener("keydown", this.#onKeyDown);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#onKeyDown = (event: KeyboardEvent) => {
|
|
34
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
35
|
+
const triggers = this.triggers;
|
|
36
|
+
const current = triggers.indexOf(event.target as HTMLElement);
|
|
37
|
+
if (current === -1) return;
|
|
38
|
+
const horizontal = this.dataset["orientation"] === "horizontal";
|
|
39
|
+
const rtl = getComputedStyle(this).direction === "rtl";
|
|
40
|
+
const forward = horizontal ? (rtl ? "ArrowLeft" : "ArrowRight") : "ArrowDown";
|
|
41
|
+
const backward = horizontal ? (rtl ? "ArrowRight" : "ArrowLeft") : "ArrowUp";
|
|
42
|
+
const loop = this.dataset["loopFocus"] !== "false";
|
|
43
|
+
const usable = triggers.filter(enabled);
|
|
44
|
+
let next: HTMLElement | undefined;
|
|
45
|
+
if (event.key === "Home") next = usable[0];
|
|
46
|
+
else if (event.key === "End") next = usable.at(-1);
|
|
47
|
+
else if (event.key === forward || event.key === backward) {
|
|
48
|
+
const step = event.key === forward ? 1 : -1;
|
|
49
|
+
for (let at = current + step; ; at += step) {
|
|
50
|
+
if (at < 0 || at >= triggers.length) {
|
|
51
|
+
if (!loop) break;
|
|
52
|
+
at = at < 0 ? triggers.length - 1 : 0;
|
|
53
|
+
}
|
|
54
|
+
if (at === current) break;
|
|
55
|
+
if (enabled(triggers[at]!)) {
|
|
56
|
+
next = triggers[at];
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else return;
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
next?.focus();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!customElements.get("lu-accordion")) customElements.define("lu-accordion", LuAccordion);
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Accordion (decision-62, TASK-370): Root renders `<lu-accordion>`, and each item is a native
|
|
2
|
+
// `<details>` with its `<summary>` as the trigger, so items open and close in server HTML with no
|
|
3
|
+
// JavaScript, find-in-page opens them, and one open item at a time is the browser's exclusive
|
|
4
|
+
// group (`name`). The element's script (accordion.script.ts) adds the arrow keys, Home and End of
|
|
5
|
+
// the APG Accordion pattern and Base UI's animation hooks. The items' `toggle` events are the
|
|
6
|
+
// single source of `onValueChange`. Ported from Timeless's approach (https://timeless.build).
|
|
7
|
+
import {
|
|
8
|
+
createContext,
|
|
9
|
+
effect,
|
|
10
|
+
onCleanup,
|
|
11
|
+
ref,
|
|
12
|
+
signal,
|
|
13
|
+
untrack,
|
|
14
|
+
useContext,
|
|
15
|
+
useId,
|
|
16
|
+
type Signal,
|
|
17
|
+
} from "lumiose";
|
|
18
|
+
import defineAccordion from "./accordion.script.ts?client";
|
|
19
|
+
import type { LuAccordion } from "./accordion.script.ts";
|
|
20
|
+
|
|
21
|
+
declare module "lumiose/jsx-runtime" {
|
|
22
|
+
namespace JSX {
|
|
23
|
+
interface IntrinsicElements {
|
|
24
|
+
/** Accordion.Root's element: it moves focus between its items' triggers. */
|
|
25
|
+
"lu-accordion": JSX.HTMLProps<LuAccordion>;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The element props Root passes to `<lu-accordion>` (`class`, `style`, `data-*`, `aria-*`,
|
|
32
|
+
* events), without the brand of intrinsic elements' props, so that `bind:value` applies to Root.
|
|
33
|
+
*/
|
|
34
|
+
type ElementProps = {
|
|
35
|
+
[
|
|
36
|
+
K in keyof JSX.HTMLProps<LuAccordion> as K extends string ? K : never
|
|
37
|
+
]: JSX.HTMLProps<LuAccordion>[K];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `Accordion.Root` renders `<lu-accordion>`, with the element props it is given.
|
|
42
|
+
* `bind:value={signal}` stands in for `value` and `onValueChange`.
|
|
43
|
+
*/
|
|
44
|
+
export interface AccordionRootProps extends Omit<ElementProps, "children"> {
|
|
45
|
+
/**
|
|
46
|
+
* The values of the open items: opens and closes them when it changes. Pair it with
|
|
47
|
+
* `onValueChange` to keep a signal in sync; leave both out and the items keep their own state.
|
|
48
|
+
*/
|
|
49
|
+
value?: string[];
|
|
50
|
+
/** The items open at first, when there is no `value`. Default: none. */
|
|
51
|
+
defaultValue?: string[];
|
|
52
|
+
/** Called after an item opens or closes, with the values of the open items. */
|
|
53
|
+
onValueChange?: (value: string[]) => void;
|
|
54
|
+
/** Several items may be open at once. Default `false`: opening one closes the other. */
|
|
55
|
+
multiple?: boolean;
|
|
56
|
+
/** Every item is disabled. */
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
/** The direction the arrow keys follow. Default `"vertical"`: Up and Down. */
|
|
59
|
+
orientation?: "vertical" | "horizontal";
|
|
60
|
+
/** The arrow keys go from the last trigger to the first and back. Default `true`. */
|
|
61
|
+
loopFocus?: boolean;
|
|
62
|
+
/** The `Accordion.Item`s, right inside it. */
|
|
63
|
+
children?: JSX.Element;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An item: a `<details>` right inside `Accordion.Root`, with its trigger and its panel. */
|
|
67
|
+
export interface AccordionItemProps extends Omit<
|
|
68
|
+
JSX.IntrinsicElements["details"],
|
|
69
|
+
"open" | "name" | "children"
|
|
70
|
+
> {
|
|
71
|
+
/** Identifies the item in the root's `value`. Default: an id of its own. */
|
|
72
|
+
value?: string;
|
|
73
|
+
/** Its trigger takes no focus and does not toggle the panel. */
|
|
74
|
+
disabled?: boolean;
|
|
75
|
+
/** Called after the item opens or closes. */
|
|
76
|
+
onOpenChange?: (open: boolean) => void;
|
|
77
|
+
/** The `Accordion.Header` with the `Accordion.Trigger`, then the `Accordion.Panel`. */
|
|
78
|
+
children?: JSX.Element;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The item's heading, an `<h3>`, around the trigger's label. HTML allows a heading in a
|
|
83
|
+
* `<summary>`, not a `<summary>` in a heading, so the `<h3>` is rendered inside the trigger. Set
|
|
84
|
+
* `aria-level` for another level.
|
|
85
|
+
*/
|
|
86
|
+
export type AccordionHeaderProps = JSX.IntrinsicElements["h3"];
|
|
87
|
+
/** The `<summary>` that opens and closes the item: put it inside `Accordion.Header`. */
|
|
88
|
+
export type AccordionTriggerProps = JSX.IntrinsicElements["summary"];
|
|
89
|
+
/** The `<div role="region">` that the trigger shows and hides, labelled by the trigger. */
|
|
90
|
+
export type AccordionPanelProps = JSX.IntrinsicElements["div"];
|
|
91
|
+
|
|
92
|
+
interface ItemRecord {
|
|
93
|
+
value: string;
|
|
94
|
+
details: () => HTMLDetailsElement | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface AccordionContextValue {
|
|
98
|
+
id: string;
|
|
99
|
+
multiple: boolean;
|
|
100
|
+
disabled: () => boolean;
|
|
101
|
+
/** Whether `value` starts open. */
|
|
102
|
+
initiallyOpen: (value: string) => boolean;
|
|
103
|
+
items: Signal<ItemRecord[]>;
|
|
104
|
+
/** An item's `toggle` event. */
|
|
105
|
+
onToggle: (value: string, open: boolean) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface ItemContextValue {
|
|
109
|
+
triggerId: string;
|
|
110
|
+
panelId: string;
|
|
111
|
+
disabled: () => boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const AccordionContext = createContext<AccordionContextValue>();
|
|
115
|
+
const ItemContext = createContext<ItemContextValue>();
|
|
116
|
+
/** The props of the `Accordion.Header` around a trigger. */
|
|
117
|
+
const HeaderContext = createContext<AccordionHeaderProps>();
|
|
118
|
+
|
|
119
|
+
function useAccordion(): AccordionContextValue {
|
|
120
|
+
const context = useContext(AccordionContext);
|
|
121
|
+
if (!context) throw new Error("Accordion parts must be inside <Accordion.Root>.");
|
|
122
|
+
return context;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function useItem(): ItemContextValue {
|
|
126
|
+
const context = useContext(ItemContext);
|
|
127
|
+
if (!context) throw new Error("Accordion parts must be inside an <Accordion.Item>.");
|
|
128
|
+
return context;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Root({
|
|
132
|
+
value,
|
|
133
|
+
defaultValue,
|
|
134
|
+
onValueChange,
|
|
135
|
+
multiple,
|
|
136
|
+
disabled,
|
|
137
|
+
orientation,
|
|
138
|
+
loopFocus,
|
|
139
|
+
children,
|
|
140
|
+
...rest
|
|
141
|
+
}: AccordionRootProps) {
|
|
142
|
+
// The arrow keys and the panels' animation hooks; the page loads it once (decision-63).
|
|
143
|
+
defineAccordion();
|
|
144
|
+
const id = useId();
|
|
145
|
+
const several = untrack(() => multiple === true);
|
|
146
|
+
const initial = untrack(() => value ?? defaultValue ?? []);
|
|
147
|
+
/** The open items' values, as the elements last reported or were set to. */
|
|
148
|
+
let open = several ? initial : initial.slice(0, 1);
|
|
149
|
+
const items = signal<ItemRecord[]>([]);
|
|
150
|
+
const context: AccordionContextValue = {
|
|
151
|
+
id,
|
|
152
|
+
multiple: several,
|
|
153
|
+
disabled: () => disabled === true,
|
|
154
|
+
initiallyOpen: (of) => open.includes(of),
|
|
155
|
+
items,
|
|
156
|
+
onToggle: (of, now) => {
|
|
157
|
+
const next = now
|
|
158
|
+
? [...open.filter((each) => each !== of && context.multiple), of]
|
|
159
|
+
: open.filter((each) => each !== of);
|
|
160
|
+
if (next.length === open.length && next.every((each) => open.includes(each))) return;
|
|
161
|
+
open = next;
|
|
162
|
+
onValueChange?.(next);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// The `value` prop opens and closes the items when it changes; their `toggle` events report
|
|
167
|
+
// back, so a signal written by `onValueChange` sees the same value again and nothing loops.
|
|
168
|
+
effect(() => {
|
|
169
|
+
const wanted = value;
|
|
170
|
+
if (wanted === undefined) return;
|
|
171
|
+
// The `toggle` events of the changes below then report nothing new.
|
|
172
|
+
open = several ? wanted : wanted.slice(0, 1);
|
|
173
|
+
for (const item of items()) {
|
|
174
|
+
const element = item.details();
|
|
175
|
+
const shown = open.includes(item.value);
|
|
176
|
+
if (element && element.open !== shown) element.open = shown;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<AccordionContext value={context}>
|
|
182
|
+
<lu-accordion
|
|
183
|
+
{...rest}
|
|
184
|
+
data-orientation={orientation === "horizontal" ? "horizontal" : undefined}
|
|
185
|
+
data-loop-focus={loopFocus === false ? "false" : undefined}
|
|
186
|
+
>
|
|
187
|
+
{children}
|
|
188
|
+
</lu-accordion>
|
|
189
|
+
</AccordionContext>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function Item({
|
|
194
|
+
value,
|
|
195
|
+
disabled,
|
|
196
|
+
onOpenChange,
|
|
197
|
+
children,
|
|
198
|
+
ref: forwarded,
|
|
199
|
+
...rest
|
|
200
|
+
}: AccordionItemProps) {
|
|
201
|
+
const accordion = useAccordion();
|
|
202
|
+
const id = useId();
|
|
203
|
+
// An item keeps the value it started with: it is its identity in the root's `value`.
|
|
204
|
+
const key = untrack(() => value) ?? id;
|
|
205
|
+
const details = ref<HTMLDetailsElement>();
|
|
206
|
+
const record: ItemRecord = { value: key, details };
|
|
207
|
+
accordion.items.update((list) => [...list, record]);
|
|
208
|
+
onCleanup(() => accordion.items.update((list) => list.filter((each) => each !== record)));
|
|
209
|
+
/** The state last reported: a `<details>` created open fires `toggle` too (TASK-441 M40). */
|
|
210
|
+
let shown = accordion.initiallyOpen(key);
|
|
211
|
+
const item: ItemContextValue = {
|
|
212
|
+
triggerId: `${id}-trigger`,
|
|
213
|
+
panelId: `${id}-panel`,
|
|
214
|
+
disabled: () => disabled === true || accordion.disabled(),
|
|
215
|
+
};
|
|
216
|
+
return (
|
|
217
|
+
<ItemContext value={item}>
|
|
218
|
+
<details
|
|
219
|
+
{...rest}
|
|
220
|
+
ref={(element: HTMLDetailsElement) => {
|
|
221
|
+
details(element);
|
|
222
|
+
forwarded?.(element);
|
|
223
|
+
}}
|
|
224
|
+
name={accordion.multiple ? undefined : accordion.id}
|
|
225
|
+
open={shown}
|
|
226
|
+
on:toggle={(event: ToggleEvent) => {
|
|
227
|
+
const now = event.newState === "open";
|
|
228
|
+
accordion.onToggle(key, now);
|
|
229
|
+
if (now === shown) return;
|
|
230
|
+
shown = now;
|
|
231
|
+
onOpenChange?.(now);
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{children}
|
|
235
|
+
</details>
|
|
236
|
+
</ItemContext>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function Header({ children, ...rest }: AccordionHeaderProps) {
|
|
241
|
+
return <HeaderContext value={rest}>{children}</HeaderContext>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function Trigger({ children, ...rest }: AccordionTriggerProps) {
|
|
245
|
+
const item = useItem();
|
|
246
|
+
const header = useContext(HeaderContext);
|
|
247
|
+
// A caller's `id` names it; the parts after it point at that (TASK-441 M39).
|
|
248
|
+
if (rest.id) item.triggerId = rest.id;
|
|
249
|
+
return (
|
|
250
|
+
<summary
|
|
251
|
+
{...rest}
|
|
252
|
+
id={item.triggerId}
|
|
253
|
+
aria-controls={item.panelId}
|
|
254
|
+
aria-disabled={item.disabled() ? "true" : undefined}
|
|
255
|
+
tabindex={item.disabled() ? -1 : rest.tabindex}
|
|
256
|
+
>
|
|
257
|
+
{header ? <h3 {...header}>{children}</h3> : children}
|
|
258
|
+
</summary>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function Panel({ children, ...rest }: AccordionPanelProps) {
|
|
263
|
+
const item = useItem();
|
|
264
|
+
return (
|
|
265
|
+
<div role="region" {...rest} id={item.panelId} aria-labelledby={item.triggerId}>
|
|
266
|
+
{children}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Accordion: a stack of items whose triggers show and hide their panels, after the APG Accordion
|
|
273
|
+
* pattern. Items are native `<details>` (one open at a time by default, through the browser's
|
|
274
|
+
* exclusive group), so they work in server HTML with no JavaScript; `<lu-accordion>`'s script adds
|
|
275
|
+
* the arrow keys. Unstyled: style the parts through `class`, `[open]` on the items, and the
|
|
276
|
+
* panels' `--lu-accordion-panel-height`, `[data-starting-style]` and `[data-ending-style]`.
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* <Accordion.Root defaultValue={["shipping"]}>
|
|
280
|
+
* <Accordion.Item value="shipping">
|
|
281
|
+
* <Accordion.Header>
|
|
282
|
+
* <Accordion.Trigger>Shipping</Accordion.Trigger>
|
|
283
|
+
* </Accordion.Header>
|
|
284
|
+
* <Accordion.Panel>Orders ship in two days.</Accordion.Panel>
|
|
285
|
+
* </Accordion.Item>
|
|
286
|
+
* </Accordion.Root>
|
|
287
|
+
*/
|
|
288
|
+
export const Accordion = { Root, Item, Header, Trigger, Panel };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Enter and exit animations of @lumiose/ui's parts (doc-9 §1): the starting style of an element's
|
|
2
|
+
// first frame, and the end of its exit, whatever a stylesheet animates. Neither waits for a frame:
|
|
3
|
+
// a page in the background renders none (`requestAnimationFrame` never runs), and a part must
|
|
4
|
+
// still leave. Shared by the components and their client scripts, so it must not import
|
|
5
|
+
// `lumiose`.
|
|
6
|
+
|
|
7
|
+
/** Forces a style update: transitions start from what is computed now. */
|
|
8
|
+
const flush = (element: Element) => getComputedStyle(element).getPropertyValue("opacity");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Applies `attribute` (`data-starting-style`) for the element's first style, then removes it, so
|
|
12
|
+
* a transition runs from the starting style to the element's own.
|
|
13
|
+
*/
|
|
14
|
+
export function startFrom(element: Element, attribute = "data-starting-style"): void {
|
|
15
|
+
element.setAttribute(attribute, "");
|
|
16
|
+
flush(element);
|
|
17
|
+
element.removeAttribute(attribute);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves once the element's animations and transitions end: after the longest one's remaining
|
|
22
|
+
* time, so a background page (whose animations do not advance) still gets there.
|
|
23
|
+
*/
|
|
24
|
+
export function afterAnimations(element: Element): Promise<void> {
|
|
25
|
+
flush(element);
|
|
26
|
+
let wait = 0;
|
|
27
|
+
for (const animation of element.getAnimations()) {
|
|
28
|
+
const timing = animation.effect?.getComputedTiming();
|
|
29
|
+
const end = Number(timing?.endTime ?? 0);
|
|
30
|
+
const current = Number(animation.currentTime ?? 0);
|
|
31
|
+
if (Number.isFinite(end)) wait = Math.max(wait, end - current);
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, wait));
|
|
34
|
+
}
|