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.
Files changed (95) hide show
  1. package/README.md +2 -2
  2. package/index.cjs +28 -7
  3. package/package.json +1 -1
  4. package/source/beta-components/accordion/README.md +45 -0
  5. package/source/beta-components/accordion/__tests__/core.test.ts +58 -0
  6. package/source/beta-components/accordion/core/index.ts +2 -0
  7. package/source/beta-components/accordion/core/open.ts +56 -0
  8. package/source/beta-components/accordion/core/types.ts +17 -0
  9. package/source/beta-components/accordion/index.ts +8 -0
  10. package/source/beta-components/accordion/react/Accordion.tsx +133 -0
  11. package/source/beta-components/accordion/react/AccordionItem.tsx +120 -0
  12. package/source/beta-components/accordion/react/context.ts +20 -0
  13. package/source/beta-components/accordion/react/props.ts +13 -0
  14. package/source/beta-components/accordion/styles.css +208 -0
  15. package/source/beta-components/alert/README.md +39 -0
  16. package/source/beta-components/alert/__tests__/core.test.ts +17 -0
  17. package/source/beta-components/alert/core/index.ts +2 -0
  18. package/source/beta-components/alert/core/live.ts +25 -0
  19. package/source/beta-components/alert/core/types.ts +7 -0
  20. package/source/beta-components/alert/index.ts +5 -0
  21. package/source/beta-components/alert/react/Alert.tsx +113 -0
  22. package/source/beta-components/alert/react/icons.tsx +21 -0
  23. package/source/beta-components/alert/react/locale.ts +5 -0
  24. package/source/beta-components/alert/react/props.ts +11 -0
  25. package/source/beta-components/alert/styles.css +155 -0
  26. package/source/beta-components/avatar/__tests__/core.test.ts +88 -0
  27. package/source/beta-components/avatar/core/identity.ts +103 -0
  28. package/source/beta-components/avatar/core/index.ts +2 -0
  29. package/source/beta-components/avatar/core/types.ts +12 -0
  30. package/source/beta-components/avatar/index.ts +7 -0
  31. package/source/beta-components/avatar/react/Avatar.tsx +118 -0
  32. package/source/beta-components/avatar/react/AvatarGroup.tsx +74 -0
  33. package/source/beta-components/avatar/react/locale.ts +11 -0
  34. package/source/beta-components/avatar/react/props.ts +6 -0
  35. package/source/beta-components/avatar/styles.css +227 -0
  36. package/source/beta-components/badge/README.md +37 -0
  37. package/source/beta-components/badge/__tests__/core.test.ts +46 -0
  38. package/source/beta-components/badge/core/count.ts +37 -0
  39. package/source/beta-components/badge/core/index.ts +2 -0
  40. package/source/beta-components/badge/core/types.ts +11 -0
  41. package/source/beta-components/badge/index.ts +7 -0
  42. package/source/beta-components/badge/react/Badge.tsx +89 -0
  43. package/source/beta-components/badge/react/Tag.tsx +82 -0
  44. package/source/beta-components/badge/react/locale.ts +5 -0
  45. package/source/beta-components/badge/react/props.ts +6 -0
  46. package/source/beta-components/badge/styles.css +204 -0
  47. package/source/beta-components/number-input/README.md +53 -0
  48. package/source/beta-components/number-input/__tests__/core.test.ts +151 -0
  49. package/source/beta-components/number-input/core/format.ts +37 -0
  50. package/source/beta-components/number-input/core/index.ts +4 -0
  51. package/source/beta-components/number-input/core/parse.ts +118 -0
  52. package/source/beta-components/number-input/core/step.ts +73 -0
  53. package/source/beta-components/number-input/core/types.ts +32 -0
  54. package/source/beta-components/number-input/index.ts +5 -0
  55. package/source/beta-components/number-input/react/NumberInput.tsx +465 -0
  56. package/source/beta-components/number-input/react/icons.tsx +17 -0
  57. package/source/beta-components/number-input/react/locale.ts +7 -0
  58. package/source/beta-components/number-input/react/props.ts +11 -0
  59. package/source/beta-components/number-input/styles.css +231 -0
  60. package/source/beta-components/progress/README.md +38 -0
  61. package/source/beta-components/progress/__tests__/core.test.ts +56 -0
  62. package/source/beta-components/progress/core/index.ts +2 -0
  63. package/source/beta-components/progress/core/progress.ts +40 -0
  64. package/source/beta-components/progress/core/types.ts +23 -0
  65. package/source/beta-components/progress/index.ts +7 -0
  66. package/source/beta-components/progress/react/CircularProgress.tsx +106 -0
  67. package/source/beta-components/progress/react/Progress.tsx +113 -0
  68. package/source/beta-components/progress/react/locale.ts +7 -0
  69. package/source/beta-components/progress/react/props.ts +6 -0
  70. package/source/beta-components/progress/styles.css +202 -0
  71. package/source/beta-components/radio-group/README.md +58 -0
  72. package/source/beta-components/radio-group/__tests__/core.test.ts +64 -0
  73. package/source/beta-components/radio-group/core/group.ts +36 -0
  74. package/source/beta-components/radio-group/core/index.ts +2 -0
  75. package/source/beta-components/radio-group/core/types.ts +20 -0
  76. package/source/beta-components/radio-group/index.ts +9 -0
  77. package/source/beta-components/radio-group/react/Radio.tsx +144 -0
  78. package/source/beta-components/radio-group/react/RadioGroup.tsx +179 -0
  79. package/source/beta-components/radio-group/react/context.ts +18 -0
  80. package/source/beta-components/radio-group/react/locale.ts +5 -0
  81. package/source/beta-components/radio-group/react/props.ts +12 -0
  82. package/source/beta-components/radio-group/styles.css +270 -0
  83. package/source/beta-components/shared/__tests__/position.test.ts +143 -170
  84. package/source/beta-components/shared/core/position.ts +2 -15
  85. package/source/beta-components/shared/react/Popover.tsx +5 -18
  86. package/source/beta-components/switch/README.md +46 -0
  87. package/source/beta-components/switch/__tests__/core.test.ts +64 -0
  88. package/source/beta-components/switch/core/index.ts +2 -0
  89. package/source/beta-components/switch/core/toggle.ts +54 -0
  90. package/source/beta-components/switch/core/types.ts +9 -0
  91. package/source/beta-components/switch/index.ts +5 -0
  92. package/source/beta-components/switch/react/Switch.tsx +219 -0
  93. package/source/beta-components/switch/react/locale.ts +5 -0
  94. package/source/beta-components/switch/react/props.ts +12 -0
  95. package/source/beta-components/switch/styles.css +229 -0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # GBS Building Blocks 2.0 (v1.2.18)
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.2.18)
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(" Update the CLI, or re-run with --force to overwrite it anyway.");
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), { overwrite: true });
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((file) => !files.includes(file));
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 (fs.existsSync(target) && (force || manifest.files[file] === sha256(target))) {
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(getSourcePath(beta), folderFor(component, beta));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.18",
3
+ "version": "1.3.0",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -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,2 @@
1
+ export { normalizeOpen, toggleOpen, type ToggleOptions } from "./open";
2
+ export type * from "./types";
@@ -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";