gbs-add-block 1.2.18 → 1.3.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/README.md +2 -2
- package/index.cjs +28 -7
- package/package.json +1 -1
- package/source/beta-components/accordion/README.md +45 -0
- package/source/beta-components/accordion/__tests__/core.test.ts +58 -0
- package/source/beta-components/accordion/core/index.ts +2 -0
- package/source/beta-components/accordion/core/open.ts +56 -0
- package/source/beta-components/accordion/core/types.ts +17 -0
- package/source/beta-components/accordion/index.ts +8 -0
- package/source/beta-components/accordion/react/Accordion.tsx +133 -0
- package/source/beta-components/accordion/react/AccordionItem.tsx +120 -0
- package/source/beta-components/accordion/react/context.ts +20 -0
- package/source/beta-components/accordion/react/props.ts +13 -0
- package/source/beta-components/accordion/styles.css +208 -0
- package/source/beta-components/alert/README.md +39 -0
- package/source/beta-components/alert/__tests__/core.test.ts +17 -0
- package/source/beta-components/alert/core/index.ts +2 -0
- package/source/beta-components/alert/core/live.ts +25 -0
- package/source/beta-components/alert/core/types.ts +7 -0
- package/source/beta-components/alert/index.ts +5 -0
- package/source/beta-components/alert/react/Alert.tsx +113 -0
- package/source/beta-components/alert/react/icons.tsx +21 -0
- package/source/beta-components/alert/react/locale.ts +5 -0
- package/source/beta-components/alert/react/props.ts +11 -0
- package/source/beta-components/alert/styles.css +155 -0
- package/source/beta-components/avatar/__tests__/core.test.ts +88 -0
- package/source/beta-components/avatar/core/identity.ts +103 -0
- package/source/beta-components/avatar/core/index.ts +2 -0
- package/source/beta-components/avatar/core/types.ts +12 -0
- package/source/beta-components/avatar/index.ts +7 -0
- package/source/beta-components/avatar/react/Avatar.tsx +118 -0
- package/source/beta-components/avatar/react/AvatarGroup.tsx +74 -0
- package/source/beta-components/avatar/react/locale.ts +11 -0
- package/source/beta-components/avatar/react/props.ts +6 -0
- package/source/beta-components/avatar/styles.css +227 -0
- package/source/beta-components/badge/README.md +37 -0
- package/source/beta-components/badge/__tests__/core.test.ts +46 -0
- package/source/beta-components/badge/core/count.ts +37 -0
- package/source/beta-components/badge/core/index.ts +2 -0
- package/source/beta-components/badge/core/types.ts +11 -0
- package/source/beta-components/badge/index.ts +7 -0
- package/source/beta-components/badge/react/Badge.tsx +89 -0
- package/source/beta-components/badge/react/Tag.tsx +82 -0
- package/source/beta-components/badge/react/locale.ts +5 -0
- package/source/beta-components/badge/react/props.ts +6 -0
- package/source/beta-components/badge/styles.css +204 -0
- package/source/beta-components/number-input/README.md +53 -0
- package/source/beta-components/number-input/__tests__/core.test.ts +151 -0
- package/source/beta-components/number-input/core/format.ts +37 -0
- package/source/beta-components/number-input/core/index.ts +4 -0
- package/source/beta-components/number-input/core/parse.ts +118 -0
- package/source/beta-components/number-input/core/step.ts +73 -0
- package/source/beta-components/number-input/core/types.ts +32 -0
- package/source/beta-components/number-input/index.ts +5 -0
- package/source/beta-components/number-input/react/NumberInput.tsx +465 -0
- package/source/beta-components/number-input/react/icons.tsx +17 -0
- package/source/beta-components/number-input/react/locale.ts +7 -0
- package/source/beta-components/number-input/react/props.ts +11 -0
- package/source/beta-components/number-input/styles.css +231 -0
- package/source/beta-components/progress/README.md +38 -0
- package/source/beta-components/progress/__tests__/core.test.ts +56 -0
- package/source/beta-components/progress/core/index.ts +2 -0
- package/source/beta-components/progress/core/progress.ts +40 -0
- package/source/beta-components/progress/core/types.ts +23 -0
- package/source/beta-components/progress/index.ts +7 -0
- package/source/beta-components/progress/react/CircularProgress.tsx +106 -0
- package/source/beta-components/progress/react/Progress.tsx +113 -0
- package/source/beta-components/progress/react/locale.ts +7 -0
- package/source/beta-components/progress/react/props.ts +6 -0
- package/source/beta-components/progress/styles.css +202 -0
- package/source/beta-components/radio-group/README.md +58 -0
- package/source/beta-components/radio-group/__tests__/core.test.ts +64 -0
- package/source/beta-components/radio-group/core/group.ts +36 -0
- package/source/beta-components/radio-group/core/index.ts +2 -0
- package/source/beta-components/radio-group/core/types.ts +20 -0
- package/source/beta-components/radio-group/index.ts +9 -0
- package/source/beta-components/radio-group/react/Radio.tsx +144 -0
- package/source/beta-components/radio-group/react/RadioGroup.tsx +179 -0
- package/source/beta-components/radio-group/react/context.ts +18 -0
- package/source/beta-components/radio-group/react/locale.ts +5 -0
- package/source/beta-components/radio-group/react/props.ts +12 -0
- package/source/beta-components/radio-group/styles.css +270 -0
- package/source/beta-components/shared/__tests__/position.test.ts +143 -170
- package/source/beta-components/shared/core/position.ts +2 -15
- package/source/beta-components/shared/react/Popover.tsx +5 -18
- package/source/beta-components/switch/README.md +46 -0
- package/source/beta-components/switch/__tests__/core.test.ts +64 -0
- package/source/beta-components/switch/core/index.ts +2 -0
- package/source/beta-components/switch/core/toggle.ts +54 -0
- package/source/beta-components/switch/core/types.ts +9 -0
- package/source/beta-components/switch/index.ts +5 -0
- package/source/beta-components/switch/react/Switch.tsx +219 -0
- package/source/beta-components/switch/react/locale.ts +5 -0
- package/source/beta-components/switch/react/props.ts +12 -0
- package/source/beta-components/switch/styles.css +229 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# GBS Building Blocks 2.0 (v1.
|
|
1
|
+
# GBS Building Blocks 2.0 (v1.3.0)
|
|
2
2
|
|
|
3
3
|
Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@ For detailed documentation on usage and props, Please visit: [Building Block Doc
|
|
|
10
10
|
|
|
11
11
|
New Beta Components are avilable for testing and feedback, Please visit: [Building Block Documentation v2.0.0 beta](https://gramprokit.vercel.app/2.0.0-beta)
|
|
12
12
|
|
|
13
|
-
## What's New 🎉 (Ver 1.
|
|
13
|
+
## What's New 🎉 (Ver 1.3.0)
|
|
14
14
|
|
|
15
15
|
- Update Candidate for next major change 2.0.0
|
|
16
16
|
|
package/index.cjs
CHANGED
|
@@ -56,12 +56,22 @@ const CONFIG = {
|
|
|
56
56
|
"Popover",
|
|
57
57
|
"Card",
|
|
58
58
|
"Skeleton",
|
|
59
|
+
"NumberInput",
|
|
60
|
+
"RadioGroup",
|
|
61
|
+
"Switch",
|
|
62
|
+
"Accordion",
|
|
63
|
+
"Alert",
|
|
64
|
+
"Avatar",
|
|
65
|
+
"Badge",
|
|
66
|
+
"Progress",
|
|
59
67
|
],
|
|
60
68
|
// Beta folder names that are not simply the lowercased component name.
|
|
61
69
|
betaFolders: {
|
|
62
70
|
DataGrid: "data-grid",
|
|
63
71
|
DatePicker: "date-picker",
|
|
64
72
|
FileUploader: "file-uploader",
|
|
73
|
+
NumberInput: "number-input",
|
|
74
|
+
RadioGroup: "radio-group",
|
|
65
75
|
},
|
|
66
76
|
// Define component dependencies
|
|
67
77
|
dependencies: {
|
|
@@ -89,8 +99,7 @@ const MANIFEST_FILE = ".install-manifest.json";
|
|
|
89
99
|
* component-lib.
|
|
90
100
|
*/
|
|
91
101
|
const TESTS_DIR = "__tests__";
|
|
92
|
-
const isTestPath = (filePath) =>
|
|
93
|
-
filePath.split(/[\\/]/).includes(TESTS_DIR);
|
|
102
|
+
const isTestPath = (filePath) => filePath.split(/[\\/]/).includes(TESTS_DIR);
|
|
94
103
|
|
|
95
104
|
const normalizeComponent = (component, availableComponents) =>
|
|
96
105
|
availableComponents.find(
|
|
@@ -199,7 +208,9 @@ const installShared = async (destPath, { force = false } = {}) => {
|
|
|
199
208
|
console.error(
|
|
200
209
|
" Installing it would downgrade the folder and break the components already here.",
|
|
201
210
|
);
|
|
202
|
-
console.error(
|
|
211
|
+
console.error(
|
|
212
|
+
" Update the CLI, or re-run with --force to overwrite it anyway.",
|
|
213
|
+
);
|
|
203
214
|
process.exit(1);
|
|
204
215
|
}
|
|
205
216
|
|
|
@@ -230,16 +241,23 @@ const installShared = async (destPath, { force = false } = {}) => {
|
|
|
230
241
|
|
|
231
242
|
const files = listFiles(src);
|
|
232
243
|
for (const file of files) {
|
|
233
|
-
await fs.copy(path.join(src, file), path.join(dest, file), {
|
|
244
|
+
await fs.copy(path.join(src, file), path.join(dest, file), {
|
|
245
|
+
overwrite: true,
|
|
246
|
+
});
|
|
234
247
|
}
|
|
235
248
|
|
|
236
249
|
// Remove what an older version left behind, but only files we installed and
|
|
237
250
|
// the user has not since changed.
|
|
238
251
|
if (manifest) {
|
|
239
|
-
const removed = Object.keys(manifest.files).filter(
|
|
252
|
+
const removed = Object.keys(manifest.files).filter(
|
|
253
|
+
(file) => !files.includes(file),
|
|
254
|
+
);
|
|
240
255
|
for (const file of removed) {
|
|
241
256
|
const target = path.join(dest, file);
|
|
242
|
-
if (
|
|
257
|
+
if (
|
|
258
|
+
fs.existsSync(target) &&
|
|
259
|
+
(force || manifest.files[file] === sha256(target))
|
|
260
|
+
) {
|
|
243
261
|
await fs.remove(target);
|
|
244
262
|
}
|
|
245
263
|
}
|
|
@@ -285,7 +303,10 @@ const checkComponentExists = (component, destPath, beta = false) => {
|
|
|
285
303
|
|
|
286
304
|
const copyComponent = async (component, destPath, beta = false) => {
|
|
287
305
|
try {
|
|
288
|
-
const componentSrc = path.join(
|
|
306
|
+
const componentSrc = path.join(
|
|
307
|
+
getSourcePath(beta),
|
|
308
|
+
folderFor(component, beta),
|
|
309
|
+
);
|
|
289
310
|
const componentDest = path.join(destPath, folderFor(component, beta));
|
|
290
311
|
|
|
291
312
|
if (!fs.existsSync(componentSrc)) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Accordion
|
|
2
|
+
|
|
3
|
+
Sections that open and close, built on native `<details>` and `<summary>`, for
|
|
4
|
+
React 19 and styled to match the rest of the library. No runtime dependencies
|
|
5
|
+
besides React.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { Accordion, AccordionItem } from "@/components/accordion";
|
|
9
|
+
import "@/components/accordion/styles.css";
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
<Accordion
|
|
14
|
+
defaultValue={["shipping"]}
|
|
15
|
+
items={[
|
|
16
|
+
{ value: "shipping", title: "Shipping", content: <p>Two to five days.</p> },
|
|
17
|
+
{ value: "returns", title: "Returns", content: <p>Thirty days, no questions.</p> },
|
|
18
|
+
]}
|
|
19
|
+
/>
|
|
20
|
+
|
|
21
|
+
<Accordion multiple variant="contained">
|
|
22
|
+
<AccordionItem value="one" title="Details" meta={<Badge count={3} />}>
|
|
23
|
+
…
|
|
24
|
+
</AccordionItem>
|
|
25
|
+
</Accordion>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Accordion:** `value` / `defaultValue` / `onValueChange` (string arrays),
|
|
29
|
+
`items` or `<AccordionItem>` children, `multiple`, `collapsible`, `variant`
|
|
30
|
+
(`separated` `contained` `plain`), `size`, `iconPosition`, `classNames`, `ref`.
|
|
31
|
+
|
|
32
|
+
**AccordionItem:** `value`, `title`, `description`, `children`, `meta`, `icon`,
|
|
33
|
+
`disabled`, `classNames` (`root` `header` `title` `description` `icon`
|
|
34
|
+
`content` `body`), `ref` (the `<details>`).
|
|
35
|
+
|
|
36
|
+
The browser owns the disclosure: the header is focusable, Enter and Space work,
|
|
37
|
+
the panel is out of the accessibility tree while closed, and Ctrl+F finds text
|
|
38
|
+
inside a closed panel where the browser supports it. What the component owns is
|
|
39
|
+
the group — which panel is open, and whether the last one may be closed —
|
|
40
|
+
because `<details name>` alone cannot express that and gives no value to store.
|
|
41
|
+
|
|
42
|
+
Opening animates in browsers that have `interpolate-size` and
|
|
43
|
+
`::details-content`, and simply shows and hides everywhere else.
|
|
44
|
+
|
|
45
|
+
`toggleOpen` and `normalizeOpen` are exported from the framework-free `core`.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeOpen, toggleOpen } from "../core/open";
|
|
3
|
+
|
|
4
|
+
describe("toggleOpen", () => {
|
|
5
|
+
describe("one at a time", () => {
|
|
6
|
+
it("opens a panel and closes whatever was open", () => {
|
|
7
|
+
expect(toggleOpen(["a"], "b")).toEqual(["b"]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("closes the open panel when pressed again", () => {
|
|
11
|
+
expect(toggleOpen(["a"], "a")).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("keeps it open when the accordion cannot be empty", () => {
|
|
15
|
+
// Panels that are the whole page: closing the last leaves a dead end.
|
|
16
|
+
expect(toggleOpen(["a"], "a", { collapsible: false })).toEqual(["a"]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("several at a time", () => {
|
|
21
|
+
it("adds and removes without touching the others", () => {
|
|
22
|
+
expect(toggleOpen(["a"], "b", { multiple: true })).toEqual(["a", "b"]);
|
|
23
|
+
expect(toggleOpen(["a", "b"], "a", { multiple: true })).toEqual(["b"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("keeps the order panels were opened in", () => {
|
|
27
|
+
expect(toggleOpen(["b"], "a", { multiple: true })).toEqual(["b", "a"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("refuses to close the last one when the accordion cannot be empty", () => {
|
|
31
|
+
expect(toggleOpen(["a"], "a", { multiple: true, collapsible: false })).toEqual(["a"]);
|
|
32
|
+
// Any other panel is still free to close, because one stays open.
|
|
33
|
+
expect(toggleOpen(["a", "b"], "a", { multiple: true, collapsible: false })).toEqual(["b"]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("normalizeOpen", () => {
|
|
39
|
+
const values = ["a", "b", "c"];
|
|
40
|
+
|
|
41
|
+
it("drops values the accordion does not have", () => {
|
|
42
|
+
expect(normalizeOpen(["a", "z"], values, { multiple: true })).toEqual(["a"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps only the first for a single accordion", () => {
|
|
46
|
+
// Otherwise it starts showing two panels and can never show two again.
|
|
47
|
+
expect(normalizeOpen(["a", "b"], values)).toEqual(["a"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("opens the first panel when the accordion cannot be empty", () => {
|
|
51
|
+
expect(normalizeOpen([], values, { collapsible: false })).toEqual(["a"]);
|
|
52
|
+
expect(normalizeOpen([], values)).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("has nothing to open when there are no panels", () => {
|
|
56
|
+
expect(normalizeOpen([], [], { collapsible: false })).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which panels are open after a header is pressed.
|
|
3
|
+
*
|
|
4
|
+
* Three rules, and every accordion gets at least one of them wrong at some
|
|
5
|
+
* point, which is why they are here rather than spread through the component:
|
|
6
|
+
*
|
|
7
|
+
* `multiple` decides whether opening one closes the others. `collapsible`
|
|
8
|
+
* decides whether the open one can be closed by pressing it again — a
|
|
9
|
+
* non-collapsible accordion always has exactly one panel open, which is what
|
|
10
|
+
* you want when the panels are the whole content of the page and an empty page
|
|
11
|
+
* would be a dead end.
|
|
12
|
+
*
|
|
13
|
+
* Order is kept as panels were opened, so a caller storing the value can tell
|
|
14
|
+
* which one was opened last.
|
|
15
|
+
*/
|
|
16
|
+
export interface ToggleOptions {
|
|
17
|
+
multiple?: boolean;
|
|
18
|
+
collapsible?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toggleOpen(
|
|
22
|
+
open: readonly string[],
|
|
23
|
+
value: string,
|
|
24
|
+
{ multiple = false, collapsible = true }: ToggleOptions = {},
|
|
25
|
+
): string[] {
|
|
26
|
+
const isOpen = open.includes(value);
|
|
27
|
+
|
|
28
|
+
if (!multiple) {
|
|
29
|
+
if (!isOpen) return [value];
|
|
30
|
+
return collapsible ? [] : [value];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!isOpen) return [...open, value];
|
|
34
|
+
// The last panel of a non-collapsible accordion stays put; closing any other
|
|
35
|
+
// one is fine, because something is still open afterwards.
|
|
36
|
+
if (!collapsible && open.length === 1) return [...open];
|
|
37
|
+
return open.filter((item) => item !== value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The starting value, trimmed to what the settings allow.
|
|
42
|
+
*
|
|
43
|
+
* A single accordion given three open panels, or a non-collapsible one given
|
|
44
|
+
* none, would otherwise start in a state it can never return to once the user
|
|
45
|
+
* touches it.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeOpen(
|
|
48
|
+
open: readonly string[],
|
|
49
|
+
values: readonly string[],
|
|
50
|
+
{ multiple = false, collapsible = true }: ToggleOptions = {},
|
|
51
|
+
): string[] {
|
|
52
|
+
const known = open.filter((value) => values.includes(value));
|
|
53
|
+
const trimmed = multiple ? known : known.slice(0, 1);
|
|
54
|
+
if (trimmed.length === 0 && !collapsible && values.length > 0) return [values[0]];
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type AccordionVariant = "separated" | "contained" | "plain";
|
|
4
|
+
|
|
5
|
+
export type AccordionSize = "sm" | "md" | "lg";
|
|
6
|
+
|
|
7
|
+
/** Which side of the header the chevron sits on. */
|
|
8
|
+
export type AccordionIconPosition = "start" | "end";
|
|
9
|
+
|
|
10
|
+
export interface AccordionItemData {
|
|
11
|
+
value: string;
|
|
12
|
+
title: ReactNode;
|
|
13
|
+
/** A second line under the title. */
|
|
14
|
+
description?: ReactNode;
|
|
15
|
+
content: ReactNode;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Accordion } from "./react/Accordion";
|
|
2
|
+
export type { AccordionProps } from "./react/Accordion";
|
|
3
|
+
export { AccordionItem } from "./react/AccordionItem";
|
|
4
|
+
export type { AccordionItemProps } from "./react/AccordionItem";
|
|
5
|
+
export { AccordionContext, useAccordionContext } from "./react/context";
|
|
6
|
+
export type { AccordionContextValue } from "./react/context";
|
|
7
|
+
export type { AccordionItemSlot, AccordionSlot } from "./react/props";
|
|
8
|
+
export * from "./core";
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useMemo,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type Ref,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useControllableState } from "../../shared/react/useControllableState";
|
|
11
|
+
import { normalizeOpen, toggleOpen } from "../core/open";
|
|
12
|
+
import type {
|
|
13
|
+
AccordionIconPosition,
|
|
14
|
+
AccordionItemData,
|
|
15
|
+
AccordionSize,
|
|
16
|
+
AccordionVariant,
|
|
17
|
+
} from "../core/types";
|
|
18
|
+
import { AccordionItem } from "./AccordionItem";
|
|
19
|
+
import { AccordionContext, type AccordionContextValue } from "./context";
|
|
20
|
+
import { cx, type AccordionSlot } from "./props";
|
|
21
|
+
|
|
22
|
+
export interface AccordionProps {
|
|
23
|
+
/** Open panels, by value (controlled). */
|
|
24
|
+
value?: string[];
|
|
25
|
+
defaultValue?: string[];
|
|
26
|
+
onValueChange?(value: string[]): void;
|
|
27
|
+
/** Render these panels, or pass `<AccordionItem>` children. */
|
|
28
|
+
items?: readonly AccordionItemData[];
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
/** Allow more than one panel open at a time. Default false. */
|
|
31
|
+
multiple?: boolean;
|
|
32
|
+
/** Allow the open panel to be closed again. Default true. */
|
|
33
|
+
collapsible?: boolean;
|
|
34
|
+
/** Default `separated`. */
|
|
35
|
+
variant?: AccordionVariant;
|
|
36
|
+
/** Default `md`. */
|
|
37
|
+
size?: AccordionSize;
|
|
38
|
+
/** Which side the chevron sits on. Default `end`. */
|
|
39
|
+
iconPosition?: AccordionIconPosition;
|
|
40
|
+
className?: string;
|
|
41
|
+
classNames?: Partial<Record<AccordionSlot, string>>;
|
|
42
|
+
style?: CSSProperties;
|
|
43
|
+
ref?: Ref<HTMLDivElement>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sections that open one at a time, built on `<details>` and `<summary>`.
|
|
48
|
+
*
|
|
49
|
+
* The browser owns the disclosure: the header is focusable, Enter and Space
|
|
50
|
+
* work, the panel is hidden from search and from the accessibility tree while
|
|
51
|
+
* closed, and Ctrl+F still finds text inside a closed panel in browsers that
|
|
52
|
+
* support it. None of that is implemented here.
|
|
53
|
+
*
|
|
54
|
+
* What is implemented is the accordion *around* the disclosures — which one is
|
|
55
|
+
* open, and whether closing the last is allowed — because `<details name>`
|
|
56
|
+
* alone cannot express a non-collapsible group, and gives no value to store.
|
|
57
|
+
*/
|
|
58
|
+
export function Accordion(props: AccordionProps) {
|
|
59
|
+
const {
|
|
60
|
+
ref,
|
|
61
|
+
value,
|
|
62
|
+
defaultValue,
|
|
63
|
+
onValueChange,
|
|
64
|
+
items,
|
|
65
|
+
children,
|
|
66
|
+
multiple = false,
|
|
67
|
+
collapsible = true,
|
|
68
|
+
variant = "separated",
|
|
69
|
+
size = "md",
|
|
70
|
+
iconPosition = "end",
|
|
71
|
+
className,
|
|
72
|
+
classNames,
|
|
73
|
+
style,
|
|
74
|
+
} = props;
|
|
75
|
+
|
|
76
|
+
const [open, setOpen] = useControllableState<string[]>(value, defaultValue ?? []);
|
|
77
|
+
const settings = useMemo(() => ({ multiple, collapsible }), [multiple, collapsible]);
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
* With `items`, the panels are known and the starting value can be trimmed
|
|
81
|
+
* against them. With children they are not — and asking each item to
|
|
82
|
+
* register itself would mean either reading a ref while rendering or a
|
|
83
|
+
* second render pass on mount, for the sake of dropping a value that already
|
|
84
|
+
* matches nothing and shows nothing.
|
|
85
|
+
*
|
|
86
|
+
* The one thing that needs the list is opening a panel in a non-collapsible
|
|
87
|
+
* accordion that starts with none open, so give that case a `defaultValue`.
|
|
88
|
+
*/
|
|
89
|
+
const effective = useMemo(
|
|
90
|
+
() => normalizeOpen(open, items ? items.map((item) => item.value) : open, settings),
|
|
91
|
+
[open, items, settings],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const toggle = useCallback(
|
|
95
|
+
(item: string) => {
|
|
96
|
+
const next = toggleOpen(effective, item, settings);
|
|
97
|
+
setOpen(next);
|
|
98
|
+
onValueChange?.(next);
|
|
99
|
+
},
|
|
100
|
+
[effective, settings, setOpen, onValueChange],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const context = useMemo<AccordionContextValue>(
|
|
104
|
+
() => ({ open: effective, toggle, size, variant, iconPosition }),
|
|
105
|
+
[effective, toggle, size, variant, iconPosition],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
ref={ref}
|
|
111
|
+
className={cx("ac-root", classNames?.root, className)}
|
|
112
|
+
style={style}
|
|
113
|
+
data-variant={variant}
|
|
114
|
+
data-size={size}
|
|
115
|
+
>
|
|
116
|
+
<AccordionContext value={context}>
|
|
117
|
+
{items
|
|
118
|
+
? items.map((item) => (
|
|
119
|
+
<AccordionItem
|
|
120
|
+
key={item.value}
|
|
121
|
+
value={item.value}
|
|
122
|
+
title={item.title}
|
|
123
|
+
description={item.description}
|
|
124
|
+
disabled={item.disabled}
|
|
125
|
+
>
|
|
126
|
+
{item.content}
|
|
127
|
+
</AccordionItem>
|
|
128
|
+
))
|
|
129
|
+
: children}
|
|
130
|
+
</AccordionContext>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useId,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type MouseEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type Ref,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { ChevronDownIcon } from "../../shared/react/icons";
|
|
11
|
+
import { useAccordionContext } from "./context";
|
|
12
|
+
import { cx, type AccordionItemSlot } from "./props";
|
|
13
|
+
|
|
14
|
+
export interface AccordionItemProps {
|
|
15
|
+
/** Identifies the panel in the accordion's value. */
|
|
16
|
+
value: string;
|
|
17
|
+
title: ReactNode;
|
|
18
|
+
/** A second line under the title. */
|
|
19
|
+
description?: ReactNode;
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
/** Something at the end of the header: a badge, a count. */
|
|
22
|
+
meta?: ReactNode;
|
|
23
|
+
/** Replaces the chevron. */
|
|
24
|
+
icon?: ReactNode;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
classNames?: Partial<Record<AccordionItemSlot, string>>;
|
|
28
|
+
style?: CSSProperties;
|
|
29
|
+
ref?: Ref<HTMLDetailsElement>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One section. A `<details>` whose open state the accordion owns, so the group
|
|
34
|
+
* can close the others and refuse to close the last one.
|
|
35
|
+
*/
|
|
36
|
+
export function AccordionItem(props: AccordionItemProps) {
|
|
37
|
+
const {
|
|
38
|
+
ref,
|
|
39
|
+
value,
|
|
40
|
+
title,
|
|
41
|
+
description,
|
|
42
|
+
children,
|
|
43
|
+
meta,
|
|
44
|
+
icon,
|
|
45
|
+
disabled = false,
|
|
46
|
+
className,
|
|
47
|
+
classNames,
|
|
48
|
+
style,
|
|
49
|
+
} = props;
|
|
50
|
+
|
|
51
|
+
const context = useAccordionContext("AccordionItem");
|
|
52
|
+
const id = useId();
|
|
53
|
+
const open = context.open.includes(value);
|
|
54
|
+
|
|
55
|
+
const onClick = (event: MouseEvent<HTMLElement>) => {
|
|
56
|
+
// The accordion decides; letting the browser toggle as well would fight it.
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
if (!disabled) context.toggle(value);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<details
|
|
63
|
+
ref={ref}
|
|
64
|
+
className={cx("ac-item", classNames?.root, className)}
|
|
65
|
+
style={style}
|
|
66
|
+
open={open}
|
|
67
|
+
data-disabled={disabled || undefined}
|
|
68
|
+
>
|
|
69
|
+
<summary
|
|
70
|
+
id={`${id}-header`}
|
|
71
|
+
className={cx("ac-header", classNames?.header)}
|
|
72
|
+
/*
|
|
73
|
+
* An explicit button role, because a bare <summary> is exposed as a
|
|
74
|
+
* group in Chromium and `aria-expanded` on it is ignored — so the one
|
|
75
|
+
* thing a screen reader most needs to know, whether the section is
|
|
76
|
+
* open, never gets announced. The role changes nothing about the
|
|
77
|
+
* keyboard: Enter and Space still come from <details> itself.
|
|
78
|
+
*/
|
|
79
|
+
role="button"
|
|
80
|
+
aria-expanded={open}
|
|
81
|
+
aria-controls={`${id}-content`}
|
|
82
|
+
// A disabled section is marked rather than removed from the tab order:
|
|
83
|
+
// the heading of something you cannot open is still worth reading, and
|
|
84
|
+
// dropping it loses it silently.
|
|
85
|
+
aria-disabled={disabled || undefined}
|
|
86
|
+
onClick={onClick}
|
|
87
|
+
>
|
|
88
|
+
{context.iconPosition === "start" && (
|
|
89
|
+
<span className={cx("ac-icon", classNames?.icon)} aria-hidden="true">
|
|
90
|
+
{icon ?? <ChevronDownIcon />}
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
<span className="ac-heading">
|
|
95
|
+
<span className={cx("ac-title", classNames?.title)}>{title}</span>
|
|
96
|
+
{description && (
|
|
97
|
+
<span className={cx("ac-description", classNames?.description)}>{description}</span>
|
|
98
|
+
)}
|
|
99
|
+
</span>
|
|
100
|
+
|
|
101
|
+
{meta && <span className="ac-meta">{meta}</span>}
|
|
102
|
+
|
|
103
|
+
{context.iconPosition === "end" && (
|
|
104
|
+
<span className={cx("ac-icon", classNames?.icon)} aria-hidden="true">
|
|
105
|
+
{icon ?? <ChevronDownIcon />}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</summary>
|
|
109
|
+
|
|
110
|
+
<div
|
|
111
|
+
id={`${id}-content`}
|
|
112
|
+
className={cx("ac-content", classNames?.content)}
|
|
113
|
+
role="region"
|
|
114
|
+
aria-labelledby={`${id}-header`}
|
|
115
|
+
>
|
|
116
|
+
<div className={cx("ac-body", classNames?.body)}>{children}</div>
|
|
117
|
+
</div>
|
|
118
|
+
</details>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, use } from "react";
|
|
4
|
+
import type { AccordionIconPosition, AccordionSize, AccordionVariant } from "../core/types";
|
|
5
|
+
|
|
6
|
+
export interface AccordionContextValue {
|
|
7
|
+
open: readonly string[];
|
|
8
|
+
toggle(value: string): void;
|
|
9
|
+
size: AccordionSize;
|
|
10
|
+
variant: AccordionVariant;
|
|
11
|
+
iconPosition: AccordionIconPosition;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const AccordionContext = createContext<AccordionContextValue | null>(null);
|
|
15
|
+
|
|
16
|
+
export function useAccordionContext(component: string): AccordionContextValue {
|
|
17
|
+
const context = use(AccordionContext);
|
|
18
|
+
if (!context) throw new Error(`<${component}> must be used inside an <Accordion>.`);
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type AccordionSlot = "root";
|
|
2
|
+
|
|
3
|
+
export type AccordionItemSlot =
|
|
4
|
+
| "root"
|
|
5
|
+
| "header"
|
|
6
|
+
| "title"
|
|
7
|
+
| "description"
|
|
8
|
+
| "icon"
|
|
9
|
+
| "content"
|
|
10
|
+
| "body";
|
|
11
|
+
|
|
12
|
+
// Shared with every other component, so class handling cannot drift.
|
|
13
|
+
export { cx } from "../../shared/core/cx";
|