@archoleat/next-accordion 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +206 -29
  2. package/index.d.ts +21 -7
  3. package/index.js +1 -1
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Next Accordion
2
2
 
3
+ ![NPM Version](https://img.shields.io/npm/v/%40archoleat%2Fnext-accordion)
4
+ ![NPM Downloads](https://img.shields.io/npm/dm/%40archoleat%2Fnext-accordion)
3
5
  ![Specs](https://img.shields.io/github/actions/workflow/status/archoleat/next-accordion/spec.yaml?label=Specs)
4
6
  ![Commitlint](https://img.shields.io/github/actions/workflow/status/archoleat/next-accordion/commitlint.yaml?label=Commitlint)
5
7
  ![Editorconfig](https://img.shields.io/github/actions/workflow/status/archoleat/next-accordion/editorconfig.yaml?label=Editorconfig)
@@ -11,7 +13,9 @@
11
13
 
12
14
  - [Installation](#installation)
13
15
  - [Usage](#usage)
16
+ - [Styling](#styling)
14
17
  - [Props](#props)
18
+ - [Types](#types)
15
19
  - [Contributing](#contributing)
16
20
  - [License](#license)
17
21
 
@@ -58,56 +62,229 @@ const Example = () => <Accordion exclusive items={items} />;
58
62
  export { Example };
59
63
  ```
60
64
 
61
- ## Props
65
+ `Accordion` renders each item as an `<li>` inside a wrapping `<ul>` (with
66
+ `className` applied to that `<ul>`), since a set of panels is semantically
67
+ a list. Browsers apply default list styling (markers, indent) to `<ul>`,
68
+ so reset it yourself — see [Styling](#styling).
62
69
 
63
- ### `Accordion`
70
+ ### Compound components
64
71
 
65
- - `items` (`AccordionItemData[]`, required): data rendered as a list
66
- of `AccordionItem`s. Each entry accepts `id` (`number`), `trigger`,
67
- `content`, plus every `AccordionItem` prop below (except `children`,
68
- which maps to `content`).
72
+ Instead of `items`, compose `AccordionItem`s directly as children.
73
+ `Accordion` still coordinates the `exclusive` behavior between them:
69
74
 
70
- - `className` (`string`): class applied to the wrapping `div`.
75
+ ```tsx
76
+ import { Accordion, AccordionItem } from '@archoleat/next-accordion';
71
77
 
72
- - `exclusive` (`boolean`, default `false`): when `true`, opening one
73
- item closes the others.
78
+ const Example = () => (
79
+ <Accordion exclusive>
80
+ <AccordionItem trigger="First">
81
+ <p>First content</p>
82
+ </AccordionItem>
83
+ <AccordionItem trigger="Second">
84
+ <p>Second content</p>
85
+ </AccordionItem>
86
+ </Accordion>
87
+ );
74
88
 
75
- ### `AccordionItem`
89
+ export { Example };
90
+ ```
91
+
92
+ Give each `AccordionItem` a `key` when the list can be reordered or
93
+ filtered; otherwise `Accordion` falls back to positional indexes to tell
94
+ items apart. `Accordion` accepts either `items` or `children`, not both.
76
95
 
77
- - `trigger` (`ReactNode`, required): content rendered inside
78
- the `summary` element.
96
+ ### Controlling the open item from outside
79
97
 
80
- - `children` (`ReactNode`, required): content rendered inside
81
- the accordion panel.
98
+ In `exclusive` mode, which item is open lives inside `Accordion` by
99
+ default. Pass `openId` (paired with `onOpenIdChange`) to read or drive it
100
+ from outside — the same controlled/uncontrolled split `AccordionItem`
101
+ uses for `open`/`onOpenChange`:
82
102
 
83
- - `defaultOpen` (`boolean`, default `false`): initial expanded state
84
- (uncontrolled).
103
+ ```tsx
104
+ import { useState } from 'react';
85
105
 
86
- - `open` (`boolean`): expanded state (controlled). Pair
87
- with `onOpenChange`.
106
+ import type { AccordionOpenIdType } from '@archoleat/next-accordion';
107
+ import { Accordion } from '@archoleat/next-accordion';
88
108
 
89
- - `onOpenChange` (`(isOpen: boolean) => void`): called when the trigger
90
- is clicked.
109
+ const items = [
110
+ { id: 1, trigger: 'First', content: <p>First content</p> },
111
+ { id: 2, trigger: 'Second', content: <p>Second content</p> },
112
+ ];
113
+
114
+ const Example = () => {
115
+ const [openId, setOpenId] = useState<AccordionOpenIdType>(1);
91
116
 
92
- - `disabled` (`boolean`, default `false`): ignores clicks on the trigger.
117
+ return (
118
+ <Accordion
119
+ exclusive
120
+ items={items}
121
+ openId={openId}
122
+ onOpenIdChange={setOpenId}
123
+ />
124
+ );
125
+ };
93
126
 
94
- - `duration` (`number`, default `300`): animation duration
95
- in milliseconds.
127
+ export { Example };
128
+ ```
96
129
 
97
- - `easing` (`string`, default `'ease-out'`): animation easing, passed
98
- to the Web Animations API.
130
+ Uncontrolled usage works the same way `defaultOpen` does on `AccordionItem`:
131
+ pass `defaultOpenId` for the initial open item and read subsequent changes
132
+ through `onOpenIdChange`, without taking over the state yourself.
99
133
 
100
- - `icon` (`ReactNode` or `(isOpen: boolean) => ReactNode`): rendered
101
- after `trigger`; the function form receives the open state.
134
+ ## Styling
102
135
 
103
- - `disableTriggerSelection` (`boolean`, default `false`): prevents text
104
- selection on the trigger (e.g. on double-click).
136
+ The library ships no CSS of its own beyond what's needed for the open/close
137
+ animation, so every visual aspect is yours to style. The rendered markup
138
+ gives you the following hooks:
139
+
140
+ - `Accordion`'s `className` targets the wrapping `<ul>`.
141
+
142
+ - `AccordionItem`'s `className` (a forwarded native `details` attribute)
143
+ targets that item's `<details>`.
144
+
145
+ - `AccordionItem`'s `triggerClassName` and `contentClassName` target
146
+ the `<summary>` and the panel wrapper `<div>` directly — no need to
147
+ reach for descendant selectors.
148
+
149
+ - `trigger` is a `ReactNode`, so wrap it in your own elements to style
150
+ the label.
151
+
152
+ - `<details open>` and a disabled item's `data-disabled` are both plain
153
+ attributes, so Tailwind's `open:` and `data-[disabled]:` variants apply
154
+ directly — no need for the `icon` function-of-`isOpen` form just to
155
+ rotate a chevron.
156
+
157
+ The example below uses [`cn`](https://ui.shadcn.com/docs/installation/manual#add-a-cn-helper),
158
+ the common `clsx` + `tailwind-merge` helper, to combine a shared base
159
+ class with any per-item override:
160
+
161
+ ```tsx
162
+ import { Accordion } from '@archoleat/next-accordion';
163
+
164
+ import { cn } from './lib/cn';
165
+
166
+ const ChevronIcon = () => (
167
+ <svg
168
+ aria-hidden
169
+ className="size-4 shrink-0 transition-transform duration-300 group-open:rotate-180"
170
+ height="16"
171
+ viewBox="0 0 16 16"
172
+ width="16"
173
+ >
174
+ <path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="2" />
175
+ </svg>
176
+ );
177
+
178
+ const items = [
179
+ { id: 1, trigger: 'First', content: <p>First content</p> },
180
+ { id: 2, trigger: 'Second', content: <p>Second content</p> },
181
+ ];
182
+
183
+ const Example = () => (
184
+ <Accordion
185
+ className="m-0 flex list-none flex-col gap-2 p-0"
186
+ exclusive
187
+ items={items.map((item) => ({
188
+ ...item,
189
+ className: 'overflow-hidden rounded-lg border border-slate-200',
190
+ contentClassName: 'p-4 pt-0',
191
+ duration: 250,
192
+ easing: 'ease-in-out',
193
+ icon: <ChevronIcon />,
194
+ triggerClassName: cn(
195
+ 'flex cursor-pointer items-center justify-between p-4',
196
+ 'aria-disabled:pointer-events-none aria-disabled:opacity-50',
197
+ ),
198
+ }))}
199
+ />
200
+ );
201
+
202
+ export { Example };
203
+ ```
204
+
205
+ ## Props
206
+
207
+ <!-- lint disable maximum-line-length -->
208
+
209
+ ### `Accordion`
210
+
211
+ | Prop | Type | Default | Description |
212
+ | ----------- | ------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
213
+ | `items` | `AccordionItemDataType[]` | — | Data rendered as a list of `AccordionItem`s. Each entry accepts `id` (`number`), `trigger`, `content`, plus every `AccordionItem` prop below (except `children`, which maps to `content`). Mutually exclusive with `children`. |
214
+ | `children` | `ReactNode` | — | `AccordionItem` elements composed directly (compound component pattern). Mutually exclusive with `items`. |
215
+ | `className` | `string` | — | Class applied to the wrapping `ul`. Each item is rendered inside its own `li`. |
216
+ | `exclusive` | `boolean` | `false` | When `true`, opening one item closes the others. |
217
+ | `openId` | `AccordionOpenIdType` | — | Id of the currently open item (controlled). Only meaningful when `exclusive` is `true`. Pair with `onOpenIdChange`. |
218
+ | `defaultOpenId` | `AccordionOpenIdType` | `null` | Initial open item id (uncontrolled). Only meaningful when `exclusive` is `true`. |
219
+ | `onOpenIdChange` | `(openId: AccordionOpenIdType) => void` | — | Called when the open item changes in `exclusive` mode. |
220
+
221
+ ### `AccordionItem`
222
+
223
+ | Prop | Type | Default | Description |
224
+ | ------------------------- | ---------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
225
+ | `trigger` | `ReactNode` | — | Content rendered inside the `summary` element. **Required.** |
226
+ | `children` | `ReactNode` | — | Content rendered inside the accordion panel. **Required.** |
227
+ | `defaultOpen` | `boolean` | `false` | Initial expanded state (uncontrolled). |
228
+ | `open` | `boolean` | — | Expanded state (controlled). Pair with `onOpenChange`. |
229
+ | `onOpenChange` | `(isOpen: boolean) => void` | — | Called when the trigger is clicked. |
230
+ | `disabled` | `boolean` | `false` | Ignores clicks on the trigger. |
231
+ | `duration` | `number` | `300` | Animation duration in milliseconds. |
232
+ | `easing` | `string` | `'ease-in-out'` | Animation easing, passed to the Web Animations API. |
233
+ | `icon` | `ReactNode \| (isOpen: boolean) => ReactNode` | — | Rendered after `trigger`; the function form receives the open state. |
234
+ | `disableTriggerSelection` | `boolean` | `false` | Prevents text selection on the trigger (e.g. on double-click). |
235
+ | `triggerClassName` | `string` | — | Class applied to the `summary` element. |
236
+ | `contentClassName` | `string` | — | Class applied to the panel wrapper `div`. |
237
+
238
+ <!-- lint enable maximum-line-length -->
105
239
 
106
240
  All other native `details` attributes (`id`, `className`, and so on) are
107
241
  forwarded to the underlying `<details>` element. The animation is skipped
108
242
  in favor of an instant toggle when the user has `prefers-reduced-motion`
109
243
  enabled.
110
244
 
245
+ ## Types
246
+
247
+ `@archoleat/next-accordion` exports the prop types for both components,
248
+ plus the shape used by `items` and by the open-item id, so you can type
249
+ your own code around them without redeclaring the definitions.
250
+
251
+ `AccordionOpenIdType` (`number | string | null`) is the type of `openId`,
252
+ `defaultOpenId`, and the argument passed to `onOpenIdChange` — see
253
+ [Controlling the open item from outside](#controlling-the-open-item-from-outside).
254
+
255
+ Type an `items` array built outside JSX with `AccordionItemDataType`:
256
+
257
+ ```tsx
258
+ import type { AccordionItemDataType } from '@archoleat/next-accordion';
259
+
260
+ const items: AccordionItemDataType[] = [
261
+ { id: 1, trigger: 'First', content: <p>First content</p> },
262
+ { id: 2, trigger: 'Second', content: <p>Second content</p> },
263
+ ];
264
+
265
+ export { items };
266
+ ```
267
+
268
+ Type a wrapper component with `AccordionProps` / `AccordionItemProps`:
269
+
270
+ ```tsx
271
+ import { Accordion, AccordionItem } from '@archoleat/next-accordion';
272
+ import type {
273
+ AccordionItemProps,
274
+ AccordionProps,
275
+ } from '@archoleat/next-accordion';
276
+
277
+ const FaqAccordion = (props: AccordionProps) => (
278
+ <Accordion className="faq" {...props} />
279
+ );
280
+
281
+ const FaqItem = (props: AccordionItemProps) => (
282
+ <AccordionItem duration={300} {...props} />
283
+ );
284
+
285
+ export { FaqAccordion, FaqItem };
286
+ ```
287
+
111
288
  ## Contributing
112
289
 
113
290
  Please read [**CONTRIBUTING**](https://github.com/archoleat/.github/blob/main/CONTRIBUTING.md)
package/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as react from 'react';
2
2
  import { DetailsHTMLAttributes, ReactNode } from 'react';
3
3
 
4
- type AccordionItemProps = Omit<DetailsHTMLAttributes<HTMLDetailsElement>, 'children' | 'onToggle' | 'open'> & {
4
+ type Props$1 = Omit<DetailsHTMLAttributes<HTMLDetailsElement>, 'children' | 'onToggle' | 'open'> & {
5
5
  children: ReactNode;
6
+ contentClassName?: string;
6
7
  defaultOpen?: boolean;
7
8
  disabled?: boolean;
8
9
  disableTriggerSelection?: boolean;
@@ -12,21 +13,34 @@ type AccordionItemProps = Omit<DetailsHTMLAttributes<HTMLDetailsElement>, 'child
12
13
  onOpenChange?: (isOpen: boolean) => void;
13
14
  open?: boolean;
14
15
  trigger: ReactNode;
16
+ triggerClassName?: string;
15
17
  };
16
18
 
17
- type AccordionItemData = Omit<AccordionItemProps, 'children' | 'content' | 'id'> & {
19
+ type AccordionItemDataType = Omit<Props$1, 'children' | 'content' | 'id'> & {
18
20
  content: ReactNode;
19
21
  id: number;
20
22
  };
21
- type AccordionProps = {
23
+ type OpenIdType = number | string | null;
24
+ type SharedProps = {
22
25
  className?: string;
26
+ defaultOpenId?: OpenIdType;
23
27
  exclusive?: boolean;
24
- items: AccordionItemData[];
28
+ onOpenIdChange?: (openId: OpenIdType) => void;
29
+ openId?: OpenIdType;
25
30
  };
31
+ type ItemsProps = SharedProps & {
32
+ children?: never;
33
+ items: AccordionItemDataType[];
34
+ };
35
+ type ChildrenProps = SharedProps & {
36
+ children: ReactNode;
37
+ items?: never;
38
+ };
39
+ type Props = ChildrenProps | ItemsProps;
26
40
 
27
- declare const Accordion: (props: AccordionProps) => react.JSX.Element;
41
+ declare const Accordion: (props: Props) => react.JSX.Element;
28
42
 
29
- declare const AccordionItem: (props: AccordionItemProps) => react.JSX.Element;
43
+ declare const AccordionItem: (props: Props$1) => react.JSX.Element;
30
44
 
31
45
  export { Accordion, AccordionItem };
32
- export type { AccordionItemData, AccordionItemProps, AccordionProps };
46
+ export type { AccordionItemDataType, Props$1 as AccordionItemProps, OpenIdType as AccordionOpenIdType, Props as AccordionProps };
package/index.js CHANGED
@@ -1 +1 @@
1
- import{jsxs as F,jsx as x}from"react/jsx-runtime";import{useState as T,useRef as l,useLayoutEffect as P}from"react";const U=()=>typeof window<"u"&&typeof window.matchMedia=="function"&&window.matchMedia("(prefers-reduced-motion: reduce)").matches,$=300,D="ease-out",b=m=>{const{children:g,defaultOpen:f=!1,disabled:o=!1,disableTriggerSelection:u=!1,duration:i=$,easing:c=D,icon:r,onOpenChange:d,open:a,trigger:H,...I}=m,[p,L]=T(f),h=l(null),R=l(null),v=l(null),t=l(null),N=l(!0),O=a!==void 0,n=O?a:p,M=e=>{if(e.preventDefault(),h.current&&(h.current.open=n),o)return;const s=!n;O||L(s),d?.(s)};return P(()=>{const e=h.current,s=R.current,E=v.current;if(!e||!s||!E)return;if(N.current){N.current=!1,e.open=n;return}if(e.open===n)return;if(t.current?.cancel(),U()||i<=0){e.open=n;return}e.style.overflow="hidden";const S=()=>{e.open=n,e.style.height="",e.style.overflow="",t.current?.cancel(),t.current=null};let y;if(n)e.style.height=`${e.offsetHeight}px`,e.open=!0,y=window.requestAnimationFrame(()=>{const w=`${e.offsetHeight}px`,A=`${s.offsetHeight+E.offsetHeight}px`;t.current=e.animate({height:[w,A]},{duration:i,easing:c,fill:"forwards"}),t.current.addEventListener("finish",S)});else{const w=`${e.offsetHeight}px`,A=`${s.offsetHeight}px`;t.current=e.animate({height:[w,A]},{duration:i,easing:c,fill:"forwards"}),t.current.addEventListener("finish",S)}return()=>{y!==void 0&&window.cancelAnimationFrame(y),t.current?.cancel()}},[n,i,c]),F("details",{...I,"data-disabled":o||void 0,ref:h,children:[F("summary",{"aria-disabled":o,onClick:M,ref:R,style:{cursor:o?void 0:"pointer",listStyle:"none",userSelect:u?"none":void 0,WebkitUserSelect:u?"none":void 0},children:[H,typeof r=="function"?r(n):r]}),x("div",{ref:v,style:{display:"flow-root"},children:g})]})},_=m=>{const{className:g,exclusive:f=!1,items:o}=m,[u,i]=T(null);return x("div",{className:g,children:o.map(({content:c,id:r,onOpenChange:d,...a})=>x(b,{...a,...!f&&d?{onOpenChange:d}:{},...f?{onOpenChange:p=>{i(p?r:null),d?.(p)},open:u===r}:{},children:c},r))})};export{_ as Accordion,b as AccordionItem};
1
+ import{jsxs as U,jsx as l}from"react/jsx-runtime";import{useState as b,useRef as g,useLayoutEffect as $,Children as k,isValidElement as L,cloneElement as D}from"react";const _=300,j="ease-in-out",q=()=>typeof window<"u"&&typeof window.matchMedia=="function"&&window.matchMedia("(prefers-reduced-motion: reduce)").matches,E=o=>{const{children:y,contentClassName:A,defaultOpen:m=!1,disabled:d=!1,disableTriggerSelection:w=!1,duration:a=_,easing:u=j,icon:f,onOpenChange:I,open:n,trigger:s,triggerClassName:t,...N}=o,[h,R]=b(m),c=g(null),x=g(null),v=g(null),i=g(null),P=g(!0),S=n!==void 0,r=S?n:h,M=e=>{if(e.preventDefault(),c.current&&(c.current.open=r),d)return;const p=!r;S||R(p),I?.(p)};return $(()=>{const e=c.current,p=x.current,F=v.current;if(!e||!p||!F)return;if(P.current){P.current=!1,e.open=r;return}if(e.open===r)return;if(i.current?.cancel(),q()||a<=0){e.open=r;return}e.style.overflow="hidden";const T=()=>{e.open=r,e.style.height="",e.style.overflow="",i.current?.cancel(),i.current=null};let O;if(r)e.style.height=`${e.offsetHeight}px`,e.open=!0,O=window.requestAnimationFrame(()=>{const C=`${e.offsetHeight}px`,H=`${p.offsetHeight+F.offsetHeight}px`;i.current=e.animate({height:[C,H]},{duration:a,easing:u,fill:"forwards"}),i.current.addEventListener("finish",T)});else{const C=`${e.offsetHeight}px`,H=`${p.offsetHeight}px`;i.current=e.animate({height:[C,H]},{duration:a,easing:u,fill:"forwards"}),i.current.addEventListener("finish",T)}return()=>{O!==void 0&&window.cancelAnimationFrame(O),i.current?.cancel()}},[r,a,u]),U("details",{...N,"data-disabled":d||void 0,ref:c,children:[U("summary",{"aria-disabled":d,className:t,onClick:M,ref:x,style:{cursor:d?void 0:"pointer",listStyle:"none",userSelect:w?"none":void 0,WebkitUserSelect:w?"none":void 0},children:[s,typeof f=="function"?f(r):f]}),l("div",{className:A,ref:v,style:{display:"flow-root"},children:y})]})},G=o=>{const{className:y,defaultOpenId:A=null,exclusive:m=!1,onOpenIdChange:d}=o,[w,a]=b(A),u=o.openId!==void 0,f=u?o.openId:w,I=n=>{u||a(n),d?.(n)};return o.items?l("ul",{className:y,children:o.items.map(({content:n,id:s,onOpenChange:t,...N})=>l("li",{children:l(E,{...N,...!m&&t?{onOpenChange:t}:{},...m?{onOpenChange:c=>{I(c?s:null),t?.(c)},open:f===s}:{},children:n})},s))}):l("ul",{className:y,children:k.map(o.children,(n,s)=>{const t=L(n)?n.key??s:s;return!L(n)||n.type!==E?l("li",{children:n},t):l("li",{children:D(n,m?{onOpenChange:h=>{I(h?t:null),n.props.onOpenChange?.(h)},open:f===t}:{})},t)})})};export{G as Accordion,E as AccordionItem};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@archoleat/next-accordion",
3
3
  "description": "Animated React accordion using details and summary",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "email": "nikkeyl.me@gmail.com",