@archoleat/next-accordion 1.0.1 → 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 +204 -29
  2. package/index.d.ts +21 -7
  3. package/index.js +1 -1
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -13,7 +13,9 @@
13
13
 
14
14
  - [Installation](#installation)
15
15
  - [Usage](#usage)
16
+ - [Styling](#styling)
16
17
  - [Props](#props)
18
+ - [Types](#types)
17
19
  - [Contributing](#contributing)
18
20
  - [License](#license)
19
21
 
@@ -60,56 +62,229 @@ const Example = () => <Accordion exclusive items={items} />;
60
62
  export { Example };
61
63
  ```
62
64
 
63
- ## 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).
64
69
 
65
- ### `Accordion`
70
+ ### Compound components
66
71
 
67
- - `items` (`AccordionItemData[]`, required): data rendered as a list
68
- of `AccordionItem`s. Each entry accepts `id` (`number`), `trigger`,
69
- `content`, plus every `AccordionItem` prop below (except `children`,
70
- which maps to `content`).
72
+ Instead of `items`, compose `AccordionItem`s directly as children.
73
+ `Accordion` still coordinates the `exclusive` behavior between them:
71
74
 
72
- - `className` (`string`): class applied to the wrapping `div`.
75
+ ```tsx
76
+ import { Accordion, AccordionItem } from '@archoleat/next-accordion';
73
77
 
74
- - `exclusive` (`boolean`, default `false`): when `true`, opening one
75
- 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
+ );
76
88
 
77
- ### `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.
78
95
 
79
- - `trigger` (`ReactNode`, required): content rendered inside
80
- the `summary` element.
96
+ ### Controlling the open item from outside
81
97
 
82
- - `children` (`ReactNode`, required): content rendered inside
83
- 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`:
84
102
 
85
- - `defaultOpen` (`boolean`, default `false`): initial expanded state
86
- (uncontrolled).
103
+ ```tsx
104
+ import { useState } from 'react';
87
105
 
88
- - `open` (`boolean`): expanded state (controlled). Pair
89
- with `onOpenChange`.
106
+ import type { AccordionOpenIdType } from '@archoleat/next-accordion';
107
+ import { Accordion } from '@archoleat/next-accordion';
90
108
 
91
- - `onOpenChange` (`(isOpen: boolean) => void`): called when the trigger
92
- 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);
93
116
 
94
- - `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
+ };
95
126
 
96
- - `duration` (`number`, default `300`): animation duration
97
- in milliseconds.
127
+ export { Example };
128
+ ```
98
129
 
99
- - `easing` (`string`, default `'ease-out'`): animation easing, passed
100
- 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.
101
133
 
102
- - `icon` (`ReactNode` or `(isOpen: boolean) => ReactNode`): rendered
103
- after `trigger`; the function form receives the open state.
134
+ ## Styling
104
135
 
105
- - `disableTriggerSelection` (`boolean`, default `false`): prevents text
106
- 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 -->
107
239
 
108
240
  All other native `details` attributes (`id`, `className`, and so on) are
109
241
  forwarded to the underlying `<details>` element. The animation is skipped
110
242
  in favor of an instant toggle when the user has `prefers-reduced-motion`
111
243
  enabled.
112
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
+
113
288
  ## Contributing
114
289
 
115
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.1",
4
+ "version": "1.1.0",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "email": "nikkeyl.me@gmail.com",