@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.
- package/README.md +204 -29
- package/index.d.ts +21 -7
- package/index.js +1 -1
- 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
|
-
|
|
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
|
-
###
|
|
70
|
+
### Compound components
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
+
```tsx
|
|
76
|
+
import { Accordion, AccordionItem } from '@archoleat/next-accordion';
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
the `summary` element.
|
|
96
|
+
### Controlling the open item from outside
|
|
81
97
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
103
|
+
```tsx
|
|
104
|
+
import { useState } from 'react';
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
import type { AccordionOpenIdType } from '@archoleat/next-accordion';
|
|
107
|
+
import { Accordion } from '@archoleat/next-accordion';
|
|
90
108
|
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
117
|
+
return (
|
|
118
|
+
<Accordion
|
|
119
|
+
exclusive
|
|
120
|
+
items={items}
|
|
121
|
+
openId={openId}
|
|
122
|
+
onOpenIdChange={setOpenId}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
95
126
|
|
|
96
|
-
|
|
97
|
-
|
|
127
|
+
export { Example };
|
|
128
|
+
```
|
|
98
129
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
after `trigger`; the function form receives the open state.
|
|
134
|
+
## Styling
|
|
104
135
|
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
19
|
+
type AccordionItemDataType = Omit<Props$1, 'children' | 'content' | 'id'> & {
|
|
18
20
|
content: ReactNode;
|
|
19
21
|
id: number;
|
|
20
22
|
};
|
|
21
|
-
type
|
|
23
|
+
type OpenIdType = number | string | null;
|
|
24
|
+
type SharedProps = {
|
|
22
25
|
className?: string;
|
|
26
|
+
defaultOpenId?: OpenIdType;
|
|
23
27
|
exclusive?: boolean;
|
|
24
|
-
|
|
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:
|
|
41
|
+
declare const Accordion: (props: Props) => react.JSX.Element;
|
|
28
42
|
|
|
29
|
-
declare const AccordionItem: (props:
|
|
43
|
+
declare const AccordionItem: (props: Props$1) => react.JSX.Element;
|
|
30
44
|
|
|
31
45
|
export { Accordion, AccordionItem };
|
|
32
|
-
export type {
|
|
46
|
+
export type { AccordionItemDataType, Props$1 as AccordionItemProps, OpenIdType as AccordionOpenIdType, Props as AccordionProps };
|
package/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{jsxs as
|
|
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};
|