@carissa-io/components-accordion 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/README.md CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
1
+ # @carissa-io/components-accordion
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Accordion: `Accordion` and `AccordionItem`, following the WAI-ARIA accordion pattern. A React component set, SCSS-themed,
4
+ with no other dependency (the chevron is drawn in CSS).
5
+
6
+ ```bash
7
+ npm i @carissa-io/components-accordion
8
+ ```
9
+
10
+ ## Use
11
+
12
+ ```jsx
13
+ import { Accordion, AccordionItem } from '@carissa-io/components-accordion'
14
+
15
+ <Accordion type="single" default_value="shipping" on_change={(value) => track(value)}>
16
+ <AccordionItem value="shipping" title={t('shipping')}>...</AccordionItem>
17
+ <AccordionItem value="returns" title={t('returns')} title_level={2}>...</AccordionItem>
18
+ <AccordionItem value="warranty" title={t('warranty')} disabled>...</AccordionItem>
19
+ </Accordion>
20
+ ```
21
+
22
+ Uncontrolled with `default_value` or controlled with `value` and `on_change(value)`. `value` is a string in `single` mode
23
+ (an empty string means none open) and an array in `multiple` mode; `on_change` receives the same shape.
24
+
25
+ | Part | Prop | |
26
+ |---|---|---|
27
+ | `Accordion` | `type` | `single` (default, one open item) or `multiple`; anything else falls back to `single` |
28
+ | | `value` | the open item (`single`) or items (`multiple`), controlled |
29
+ | | `default_value` | the same, when uncontrolled |
30
+ | | `on_change` | `on_change(value)` when the user toggles an item (also when controlled) |
31
+ | | `collapsible` | default `true`; in `single` mode, `false` keeps one item open once opened (`multiple` always closes) |
32
+ | `AccordionItem` | `value` | identifies the item in the accordion's value |
33
+ | | `title` | the content of the header button |
34
+ | | `title_level` | the document level of the heading around the button, `1` to `6`, default `3` |
35
+ | | `disabled` | cannot be toggled, skipped by the keyboard |
36
+
37
+ Children of `AccordionItem` are its region. They stay mounted while it is closed (the region is `hidden`), so their state
38
+ survives. `className` is appended and unknown props go to the root `div` of each part.
39
+
40
+ ## Accessibility
41
+
42
+ Each item is a heading (`h<title_level>`) holding a `button` with `aria-expanded` and `aria-controls`, and a `div role="region"`
43
+ with `aria-labelledby` pointing to the button, `hidden` when closed. The ids come from `useId` plus the value.
44
+
45
+ | Key | Action |
46
+ |---|---|
47
+ | Enter / Space | toggle the focused header (the native button) |
48
+ | Arrow Down / Up | focus the next / previous enabled header, wrapping at the ends |
49
+ | Home / End | focus the first / last enabled header |
50
+
51
+ ## Style
52
+
53
+ ```scss
54
+ @use '@carissa-io/components-accordion/scss' with (
55
+ $c-accordion-radius: 0.5rem,
56
+ $c-accordion-header-background-hover: #f1f3f5
57
+ );
58
+ ```
59
+
60
+ Classes: `c-accordion`, `c-accordion__item` (`--open`, `--disabled`), `c-accordion__heading`, `c-accordion__trigger`,
61
+ `c-accordion__title`, `c-accordion__region` and `c-accordion__content`. The chevron is `c-accordion__trigger::after`, rotated by
62
+ `aria-expanded`; there is no icon dependency. Every variable is `!default` (see [scss/_variables.scss](scss/_variables.scss));
63
+ the defaults are neutral, so the component works without a theme. Transitions are disabled with `prefers-reduced-motion`.
64
+
65
+ | Variable | Default |
66
+ |---|---|
67
+ | `$c-accordion-border-width`, `$c-accordion-border-color`, `$c-accordion-radius` | `1px`, `rgb(0 0 0 / 0.15)`, `0` |
68
+ | `$c-accordion-header-padding-x`, `$c-accordion-header-padding-y` | `1rem`, `0.75rem` |
69
+ | `$c-accordion-header-font-size`, `$c-accordion-header-font-weight` | `1rem`, `500` |
70
+ | `$c-accordion-header-color`, `$c-accordion-header-background` | `inherit`, `transparent` |
71
+ | `$c-accordion-header-background-hover` | `rgb(0 0 0 / 0.04)` |
72
+ | `$c-accordion-header-color-open`, `$c-accordion-header-background-open` | `inherit`, `transparent` |
73
+ | `$c-accordion-chevron-size`, `$c-accordion-chevron-thickness`, `$c-accordion-chevron-color` | `0.5rem`, `2px`, `currentColor` |
74
+ | `$c-accordion-chevron-rotation-open` | `180deg` |
75
+ | `$c-accordion-content-padding-x`, `$c-accordion-content-padding-y` | `1rem`, `0.75rem` |
76
+ | `$c-accordion-focus-outline`, `$c-accordion-focus-outline-offset` | `2px solid currentColor`, `-2px` |
77
+ | `$c-accordion-disabled-opacity` | `0.5` |
78
+ | `$c-accordion-transition-duration` | `0.15s` |
package/dist/index.js ADDED
@@ -0,0 +1,93 @@
1
+ // src/Accordion.jsx
2
+ import { createContext, useContext, useId, useState } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ var TYPES = ["single", "multiple"];
5
+ var LEVELS = [1, 2, 3, 4, 5, 6];
6
+ var AccordionContext = createContext(null);
7
+ var join = (...parts) => parts.filter(Boolean).join(" ");
8
+ var slug = (value) => String(value).replace(/\s+/g, "_");
9
+ function to_list(type, value) {
10
+ if (type === "multiple") return Array.isArray(value) ? value : value === void 0 || value === null || value === "" ? [] : [value];
11
+ if (Array.isArray(value)) return value.length ? [value[0]] : [];
12
+ return value === void 0 || value === null || value === "" ? [] : [value];
13
+ }
14
+ function Accordion({
15
+ type = "single",
16
+ value,
17
+ default_value,
18
+ on_change,
19
+ collapsible = true,
20
+ className = "",
21
+ onKeyDown,
22
+ children,
23
+ ...rest
24
+ }) {
25
+ const base_id = useId();
26
+ const safe_type = TYPES.includes(type) ? type : "single";
27
+ const [inner, setInner] = useState(default_value);
28
+ const controlled = value !== void 0;
29
+ const open = to_list(safe_type, controlled ? value : inner);
30
+ const toggle = (item) => {
31
+ const is_open = open.includes(item);
32
+ let next;
33
+ if (safe_type === "multiple") {
34
+ next = is_open ? open.filter((entry) => entry !== item) : [...open, item];
35
+ } else if (is_open) {
36
+ if (!collapsible) return;
37
+ next = "";
38
+ } else {
39
+ next = item;
40
+ }
41
+ if (!controlled) setInner(next);
42
+ if (on_change) on_change(next);
43
+ };
44
+ const handle_key_down = (event) => {
45
+ if (onKeyDown) onKeyDown(event);
46
+ if (event.defaultPrevented) return;
47
+ const origin = event.target.closest?.(".c-accordion__trigger");
48
+ if (!origin || origin.closest(".c-accordion") !== event.currentTarget) return;
49
+ const triggers = Array.from(event.currentTarget.querySelectorAll(".c-accordion__trigger")).filter(
50
+ (trigger) => !trigger.disabled && trigger.closest(".c-accordion") === event.currentTarget
51
+ );
52
+ const index = triggers.indexOf(origin);
53
+ if (index === -1) return;
54
+ let target;
55
+ if (event.key === "ArrowDown") target = triggers[(index + 1) % triggers.length];
56
+ else if (event.key === "ArrowUp") target = triggers[(index - 1 + triggers.length) % triggers.length];
57
+ else if (event.key === "Home") target = triggers[0];
58
+ else if (event.key === "End") target = triggers[triggers.length - 1];
59
+ else return;
60
+ event.preventDefault();
61
+ target.focus();
62
+ };
63
+ return /* @__PURE__ */ jsx(AccordionContext.Provider, { value: { base_id, open, toggle }, children: /* @__PURE__ */ jsx("div", { ...rest, className: join("c-accordion", className), onKeyDown: handle_key_down, children }) });
64
+ }
65
+ function AccordionItem({ value, title, title_level = 3, disabled = false, className = "", children, ...rest }) {
66
+ const context = useContext(AccordionContext);
67
+ if (!context) throw new Error("AccordionItem must be rendered inside Accordion");
68
+ const { base_id, open, toggle } = context;
69
+ const is_open = open.includes(value);
70
+ const Heading = `h${LEVELS.includes(Number(title_level)) ? Number(title_level) : 3}`;
71
+ const trigger_id = `${base_id}-trigger-${slug(value)}`;
72
+ const region_id = `${base_id}-region-${slug(value)}`;
73
+ return /* @__PURE__ */ jsxs("div", { ...rest, className: join("c-accordion__item", is_open && "c-accordion__item--open", disabled && "c-accordion__item--disabled", className), children: [
74
+ /* @__PURE__ */ jsx(Heading, { className: "c-accordion__heading", children: /* @__PURE__ */ jsx(
75
+ "button",
76
+ {
77
+ type: "button",
78
+ id: trigger_id,
79
+ className: "c-accordion__trigger",
80
+ "aria-expanded": is_open,
81
+ "aria-controls": region_id,
82
+ disabled,
83
+ onClick: () => toggle(value),
84
+ children: /* @__PURE__ */ jsx("span", { className: "c-accordion__title", children: title })
85
+ }
86
+ ) }),
87
+ /* @__PURE__ */ jsx("div", { id: region_id, role: "region", "aria-labelledby": trigger_id, hidden: !is_open, className: "c-accordion__region", children: /* @__PURE__ */ jsx("div", { className: "c-accordion__content", children }) })
88
+ ] });
89
+ }
90
+ export {
91
+ Accordion,
92
+ AccordionItem
93
+ };
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@carissa-io/components-accordion",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Carissa UI: accessible accordion of collapsible sections as React components with SCSS variables",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "files": [
8
+ "dist",
9
+ "scss",
10
+ "THIRD-PARTY-NOTICES"
11
+ ],
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./scss": "./scss/index.scss",
15
+ "./scss/*": "./scss/*"
16
+ },
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "esbuild src/index.js --bundle --packages=external --format=esm --jsx=automatic --outfile=dist/index.js",
20
+ "test": "vitest run",
21
+ "lint": "eslint .",
22
+ "prepublishOnly": "node -e \"if (!process.env.GITHUB_ACTIONS) { console.error('Refusing to publish outside GitHub Actions CI: commit and push to main instead.'); process.exit(1); }\" && npm run build"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/carissa-io/components-accordion.git"
27
+ },
28
+ "license": "UNLICENSED",
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=18"
34
+ },
35
+ "devDependencies": {
36
+ "@eslint/js": "^10.0.1",
37
+ "@testing-library/jest-dom": "^7.0.1",
38
+ "@testing-library/react": "^16.3.3",
39
+ "@testing-library/user-event": "^14.6.7",
40
+ "@vitejs/plugin-react": "^6.1.1",
41
+ "esbuild": "^0.28.0",
42
+ "eslint": "^10.12.0",
43
+ "eslint-plugin-react-hooks": "^7.1.1",
44
+ "globals": "^17.13.0",
45
+ "jsdom": "^29.1.1",
46
+ "react": "^19.3.0",
47
+ "react-dom": "^19.3.0",
48
+ "sass-embedded": "^1.105.1",
49
+ "vite": "^8.3.2",
50
+ "vitest": "^4.1.11"
51
+ },
52
+ "publishConfig": {
53
+ "registry": "https://registry.npmjs.org",
54
+ "access": "public"
55
+ }
56
+ }
@@ -0,0 +1,99 @@
1
+ @use 'variables' as *;
2
+
3
+ .c-accordion {
4
+ box-sizing: border-box;
5
+ border: $c-accordion-border-width solid $c-accordion-border-color;
6
+ border-radius: $c-accordion-radius;
7
+ }
8
+
9
+ .c-accordion__item {
10
+ &:not(:first-child) {
11
+ border-top: $c-accordion-border-width solid $c-accordion-border-color;
12
+ }
13
+
14
+ &:first-child > .c-accordion__heading .c-accordion__trigger {
15
+ border-top-left-radius: $c-accordion-radius;
16
+ border-top-right-radius: $c-accordion-radius;
17
+ }
18
+
19
+ &:last-child:not(.c-accordion__item--open) > .c-accordion__heading .c-accordion__trigger {
20
+ border-bottom-right-radius: $c-accordion-radius;
21
+ border-bottom-left-radius: $c-accordion-radius;
22
+ }
23
+ }
24
+
25
+ .c-accordion__heading {
26
+ margin: 0;
27
+ font: inherit;
28
+ }
29
+
30
+ .c-accordion__trigger {
31
+ box-sizing: border-box;
32
+ display: flex;
33
+ gap: 1rem;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ width: 100%;
37
+ padding: $c-accordion-header-padding-y $c-accordion-header-padding-x;
38
+ font: inherit;
39
+ font-size: $c-accordion-header-font-size;
40
+ font-weight: $c-accordion-header-font-weight;
41
+ line-height: 1.5;
42
+ color: $c-accordion-header-color;
43
+ text-align: start;
44
+ cursor: pointer;
45
+ background: $c-accordion-header-background;
46
+ border: 0;
47
+ transition: background-color $c-accordion-transition-duration ease-out;
48
+
49
+ // The chevron: a rotated corner of a box, so no icon dependency. It follows aria-expanded.
50
+ &::after {
51
+ flex: none;
52
+ box-sizing: border-box;
53
+ width: $c-accordion-chevron-size;
54
+ height: $c-accordion-chevron-size;
55
+ content: '';
56
+ border-right: $c-accordion-chevron-thickness solid $c-accordion-chevron-color;
57
+ border-bottom: $c-accordion-chevron-thickness solid $c-accordion-chevron-color;
58
+ transform: translateY(-25%) rotate(45deg);
59
+ transition: transform $c-accordion-transition-duration ease-out;
60
+ }
61
+
62
+ &[aria-expanded='true'] {
63
+ color: $c-accordion-header-color-open;
64
+ background: $c-accordion-header-background-open;
65
+
66
+ &::after {
67
+ transform: translateY(25%) rotate(45deg + $c-accordion-chevron-rotation-open);
68
+ }
69
+ }
70
+
71
+ &:hover:not(:disabled) {
72
+ background: $c-accordion-header-background-hover;
73
+ }
74
+
75
+ &:focus-visible {
76
+ outline: $c-accordion-focus-outline;
77
+ outline-offset: $c-accordion-focus-outline-offset;
78
+ }
79
+
80
+ &:disabled {
81
+ cursor: not-allowed;
82
+ opacity: $c-accordion-disabled-opacity;
83
+ }
84
+ }
85
+
86
+ .c-accordion__region[hidden] {
87
+ display: none;
88
+ }
89
+
90
+ .c-accordion__content {
91
+ padding: $c-accordion-content-padding-y $c-accordion-content-padding-x;
92
+ }
93
+
94
+ @media (prefers-reduced-motion: reduce) {
95
+ .c-accordion__trigger,
96
+ .c-accordion__trigger::after {
97
+ transition: none;
98
+ }
99
+ }
@@ -0,0 +1,39 @@
1
+ // Every value is `!default`: set it before the first @use / through `with (...)` and it wins.
2
+ //
3
+ // @use '@carissa-io/components-accordion/scss' with ($c-accordion-header-background-hover: #f1f3f5, $c-accordion-radius: 0.5rem);
4
+ //
5
+ // The defaults are neutral (bordered sections with a CSS chevron), so the component works without a theme.
6
+
7
+ // Box
8
+ $c-accordion-border-width: 1px !default;
9
+ $c-accordion-border-color: rgb(0 0 0 / 0.15) !default;
10
+ $c-accordion-radius: 0 !default;
11
+
12
+ // Header
13
+ $c-accordion-header-padding-x: 1rem !default;
14
+ $c-accordion-header-padding-y: 0.75rem !default;
15
+ $c-accordion-header-font-size: 1rem !default;
16
+ $c-accordion-header-font-weight: 500 !default;
17
+ $c-accordion-header-color: inherit !default;
18
+ $c-accordion-header-background: transparent !default;
19
+ $c-accordion-header-background-hover: rgb(0 0 0 / 0.04) !default;
20
+ $c-accordion-header-color-open: inherit !default;
21
+ $c-accordion-header-background-open: transparent !default;
22
+
23
+ // Chevron (drawn with CSS, rotated while open)
24
+ $c-accordion-chevron-size: 0.5rem !default;
25
+ $c-accordion-chevron-thickness: 2px !default;
26
+ $c-accordion-chevron-color: currentColor !default;
27
+ $c-accordion-chevron-rotation-open: 180deg !default;
28
+
29
+ // Content
30
+ $c-accordion-content-padding-x: 1rem !default;
31
+ $c-accordion-content-padding-y: 0.75rem !default;
32
+
33
+ // Focus and disabled
34
+ $c-accordion-focus-outline: 2px solid currentColor !default;
35
+ $c-accordion-focus-outline-offset: -2px !default;
36
+ $c-accordion-disabled-opacity: 0.5 !default;
37
+
38
+ // Motion
39
+ $c-accordion-transition-duration: 0.15s !default;
@@ -0,0 +1,2 @@
1
+ @forward 'variables';
2
+ @use 'accordion';