@exotic-holidays/ui 0.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 +46 -0
- package/package.json +42 -0
- package/src/base-components/Accordion.tsx +561 -0
- package/src/base-components/Badge.tsx +191 -0
- package/src/base-components/Button.tsx +331 -0
- package/src/base-components/ButtonGroup.tsx +149 -0
- package/src/base-components/Card.tsx +250 -0
- package/src/base-components/Checkbox.tsx +49 -0
- package/src/base-components/ChipInput.tsx +208 -0
- package/src/base-components/CommonButton.tsx +33 -0
- package/src/base-components/DataTable.tsx +82 -0
- package/src/base-components/Divider.tsx +82 -0
- package/src/base-components/Dropdown.tsx +85 -0
- package/src/base-components/EmptyState.tsx +18 -0
- package/src/base-components/FilterPopover.tsx +50 -0
- package/src/base-components/Input.tsx +60 -0
- package/src/base-components/Modal.tsx +107 -0
- package/src/base-components/OtpVerificationModal.tsx +251 -0
- package/src/base-components/Pagination.tsx +51 -0
- package/src/base-components/PhoneInput.tsx +142 -0
- package/src/base-components/PopConfirm.tsx +350 -0
- package/src/base-components/SearchPopover.tsx +70 -0
- package/src/base-components/SearchableSelect.tsx +734 -0
- package/src/base-components/Select.tsx +49 -0
- package/src/base-components/Table.tsx +78 -0
- package/src/base-components/Textarea.tsx +45 -0
- package/src/base-components/ThemeProvider.tsx +92 -0
- package/src/base-components/Toaster.tsx +198 -0
- package/src/base-components/index.ts +32 -0
- package/src/components/DashboardLayout.tsx +326 -0
- package/src/components/ListPage.tsx +140 -0
- package/src/components/QuickAccess.tsx +118 -0
- package/src/components/UserMenu.tsx +138 -0
- package/src/helpers/bem.ts +13 -0
- package/src/helpers/cn.ts +9 -0
- package/src/index.ts +16 -0
- package/src/theme.css +285 -0
- package/tsconfig.json +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @exotic-holidays/ui
|
|
2
|
+
|
|
3
|
+
EHI Travel Platform design system (**AceUI**). Shared base components, theme tokens, and helpers
|
|
4
|
+
consumed by every portal app (Hotelier, Agent, Back Office).
|
|
5
|
+
|
|
6
|
+
## Install (within the monorepo)
|
|
7
|
+
|
|
8
|
+
Already linked via npm workspaces. In a portal app's `package.json`:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
"dependencies": { "@exotic-holidays/ui": "*" }
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
And in the app's `next.config.ts`:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
const nextConfig = { transpilePackages: ["@exotic-holidays/ui"] };
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
// app/globals.css
|
|
24
|
+
@import "tailwindcss";
|
|
25
|
+
@import "@exotic-holidays/ui/theme.css";
|
|
26
|
+
|
|
27
|
+
// any component / page
|
|
28
|
+
import { Button, Input, Card, Table, ListPage } from "@exotic-holidays/ui";
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What's inside
|
|
32
|
+
|
|
33
|
+
| Path | Contents |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `src/theme.css` | AceUI `@theme` tokens + `.dark` scope (teal primary, semantic surface/foreground/border ramps) |
|
|
36
|
+
| `src/base-components/` | Button, Input, Select, Card, Modal, Badge, Table (+TLoader), Pagination, PopConfirm, EmptyState, SearchableSelect, Checkbox, ChipInput, Dropdown, Divider, ButtonGroup, Accordion, Toaster, ThemeProvider |
|
|
37
|
+
| `src/components/ListPage.tsx` | Standardized index/list page scaffold (header + search + filter button + table slot + pagination) |
|
|
38
|
+
| `src/helpers/` | `cn` (clsx + tailwind-merge), `bem` |
|
|
39
|
+
|
|
40
|
+
## Rules
|
|
41
|
+
|
|
42
|
+
- **Never use raw Tailwind palette colours** (`bg-slate-800`, `bg-[#0c1220]`) — only semantic tokens
|
|
43
|
+
(`surface-*`, `foreground-*`, `border-*`, `primary`/`success`/`warning`/`danger`).
|
|
44
|
+
- **Invertible accents** (work in light + dark): tints `bg-{c}-50 / text-{c}-700 / border-{c}-200`;
|
|
45
|
+
solids `bg-{c}-500 / text-{c}-foreground`.
|
|
46
|
+
- Components are client components (`"use client"`); apps must list `@exotic-holidays/ui` in `transpilePackages`.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@exotic-holidays/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "EHI Travel Platform design system (AceUI) — base components, theme tokens, helpers.",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"types": "src/index.ts",
|
|
10
|
+
"sideEffects": [
|
|
11
|
+
"*.css"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./theme.css": "./src/theme.css",
|
|
16
|
+
"./base-components": "./src/base-components/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"lint": "eslint src",
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"class-variance-authority": "^0.7.1",
|
|
24
|
+
"clsx": "^2.1.1",
|
|
25
|
+
"framer-motion": "^12.40.0",
|
|
26
|
+
"libphonenumber-js": "^1.13.7",
|
|
27
|
+
"lucide-react": "^1.17.0",
|
|
28
|
+
"react-international-phone": "^4.8.0",
|
|
29
|
+
"tailwind-merge": "^3.6.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^19",
|
|
33
|
+
"react-dom": "^19"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/react": "^19",
|
|
37
|
+
"@types/react-dom": "^19",
|
|
38
|
+
"react": "^19",
|
|
39
|
+
"react-dom": "^19",
|
|
40
|
+
"typescript": "^5"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useState,
|
|
7
|
+
useCallback,
|
|
8
|
+
useId,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useEffect,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import { cva } from 'class-variance-authority';
|
|
15
|
+
import { block, element, modifier } from '../helpers/bem';
|
|
16
|
+
import { cn } from '../helpers/cn';
|
|
17
|
+
|
|
18
|
+
/* ============================================
|
|
19
|
+
Types
|
|
20
|
+
============================================ */
|
|
21
|
+
|
|
22
|
+
export type AccordionVariant = 'light' | 'bordered' | 'splitted';
|
|
23
|
+
export type AccordionSelectionMode = 'single' | 'multiple';
|
|
24
|
+
export type AccordionRenderStrategy = 'default' | 'lazy';
|
|
25
|
+
export type AccordionHeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
26
|
+
|
|
27
|
+
export type AccordionIndicatorRender = (params: {
|
|
28
|
+
isOpen: boolean;
|
|
29
|
+
isDisabled: boolean;
|
|
30
|
+
defaultIndicator: ReactNode;
|
|
31
|
+
}) => ReactNode;
|
|
32
|
+
|
|
33
|
+
export interface AccordionProps {
|
|
34
|
+
/** Child AccordionItem components */
|
|
35
|
+
children?: ReactNode;
|
|
36
|
+
/** Visual style variant */
|
|
37
|
+
variant?: AccordionVariant;
|
|
38
|
+
/** Single: one item open at a time. Multiple: many items can be open */
|
|
39
|
+
selectionMode?: AccordionSelectionMode;
|
|
40
|
+
/** Keys of items expanded by default (uncontrolled) */
|
|
41
|
+
defaultExpandedKeys?: (string | number)[];
|
|
42
|
+
/** Keys of items that are expanded (controlled) */
|
|
43
|
+
expandedKeys?: (string | number)[];
|
|
44
|
+
/** Keys of items that are disabled */
|
|
45
|
+
disabledKeys?: (string | number)[];
|
|
46
|
+
/** When to render item content: default (all) or lazy (on expand) */
|
|
47
|
+
renderStrategy?: AccordionRenderStrategy;
|
|
48
|
+
/** Disable all accordion items */
|
|
49
|
+
isDisabled?: boolean;
|
|
50
|
+
/** Called when expansion state changes */
|
|
51
|
+
onExpandedChange?: (keys: (string | number)[]) => void;
|
|
52
|
+
/** Additional CSS classes */
|
|
53
|
+
className?: string;
|
|
54
|
+
/** BEM class name prefix (default 'aceui'). Usually set via AceUIProvider when using @aceuidev/core. */
|
|
55
|
+
prefix?: string;
|
|
56
|
+
/** Accessible label for the accordion region */
|
|
57
|
+
ariaLabel?: string;
|
|
58
|
+
/** Test ID for testing */
|
|
59
|
+
testId?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AccordionItemProps {
|
|
63
|
+
/** Unique identifier for this item (used for expansion state) */
|
|
64
|
+
itemKey: string | number;
|
|
65
|
+
/** Main heading text */
|
|
66
|
+
title: ReactNode;
|
|
67
|
+
/** Optional secondary text below title */
|
|
68
|
+
subtitle?: ReactNode;
|
|
69
|
+
/** Expandable body content */
|
|
70
|
+
children: ReactNode;
|
|
71
|
+
/** Custom content before title (e.g. avatar, icon) */
|
|
72
|
+
startContent?: ReactNode;
|
|
73
|
+
/** Custom expand/collapse indicator: ReactNode or function (isOpen, isDisabled, defaultIndicator) => ReactNode */
|
|
74
|
+
indicator?: ReactNode | AccordionIndicatorRender;
|
|
75
|
+
/** Disable this item */
|
|
76
|
+
isDisabled?: boolean;
|
|
77
|
+
/** Additional CSS classes */
|
|
78
|
+
className?: string;
|
|
79
|
+
/** BEM class name prefix (default from Accordion context). Override per item if needed. */
|
|
80
|
+
prefix?: string;
|
|
81
|
+
/** Accessible label override */
|
|
82
|
+
ariaLabel?: string;
|
|
83
|
+
/** Semantic heading level for title */
|
|
84
|
+
headingLevel?: AccordionHeadingLevel;
|
|
85
|
+
/** Test ID for testing */
|
|
86
|
+
testId?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ============================================
|
|
90
|
+
Context
|
|
91
|
+
============================================ */
|
|
92
|
+
|
|
93
|
+
interface AccordionContextValue {
|
|
94
|
+
expandedKeys: Set<string | number>;
|
|
95
|
+
renderedKeys: Set<string | number>;
|
|
96
|
+
toggle: (key: string | number) => void;
|
|
97
|
+
isExpanded: (key: string | number) => boolean;
|
|
98
|
+
isDisabled: (key: string | number) => boolean;
|
|
99
|
+
variant: AccordionVariant;
|
|
100
|
+
selectionMode: AccordionSelectionMode;
|
|
101
|
+
renderStrategy: AccordionRenderStrategy;
|
|
102
|
+
accordionId: string;
|
|
103
|
+
focusedKey: string | number | null;
|
|
104
|
+
setFocusedKey: (key: string | number | null) => void;
|
|
105
|
+
registerItem: (key: string | number) => void;
|
|
106
|
+
itemKeys: (string | number)[];
|
|
107
|
+
prefix: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const AccordionContext = createContext<AccordionContextValue | null>(null);
|
|
111
|
+
|
|
112
|
+
function useAccordionContext(): AccordionContextValue {
|
|
113
|
+
const ctx = useContext(AccordionContext);
|
|
114
|
+
if (!ctx) {
|
|
115
|
+
throw new Error('AccordionItem must be used within Accordion');
|
|
116
|
+
}
|
|
117
|
+
return ctx;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ============================================
|
|
121
|
+
Chevron Icon
|
|
122
|
+
============================================ */
|
|
123
|
+
|
|
124
|
+
const ChevronRightIcon = () => (
|
|
125
|
+
<svg
|
|
126
|
+
width="20"
|
|
127
|
+
height="20"
|
|
128
|
+
viewBox="0 0 24 24"
|
|
129
|
+
fill="none"
|
|
130
|
+
stroke="currentColor"
|
|
131
|
+
strokeWidth="2"
|
|
132
|
+
strokeLinecap="round"
|
|
133
|
+
strokeLinejoin="round"
|
|
134
|
+
aria-hidden
|
|
135
|
+
>
|
|
136
|
+
<path d="M9 18l6-6-6-6" />
|
|
137
|
+
</svg>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/* ============================================
|
|
141
|
+
Accordion Variants (CVA)
|
|
142
|
+
============================================ */
|
|
143
|
+
|
|
144
|
+
export const accordionVariants = cva(
|
|
145
|
+
'flex flex-col gap-0 w-full min-w-0 font-[inherit]',
|
|
146
|
+
{
|
|
147
|
+
variants: {
|
|
148
|
+
variant: {
|
|
149
|
+
light: 'bg-transparent',
|
|
150
|
+
bordered:
|
|
151
|
+
'bg-transparent border-2 border-border-default rounded-[var(--aceui-accordion-border-radius)] overflow-hidden px-[12px]',
|
|
152
|
+
splitted: 'bg-transparent gap-[8px]',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
defaultVariants: {
|
|
156
|
+
variant: 'light',
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
/* ============================================
|
|
162
|
+
Accordion
|
|
163
|
+
============================================ */
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Accordion component for expandable/collapsible content sections.
|
|
167
|
+
* Supports single/multiple expansion, variants, keyboard navigation, and lazy rendering.
|
|
168
|
+
*/
|
|
169
|
+
export const Accordion = ({
|
|
170
|
+
children,
|
|
171
|
+
variant = 'light',
|
|
172
|
+
selectionMode = 'single',
|
|
173
|
+
defaultExpandedKeys = [],
|
|
174
|
+
expandedKeys: controlledExpandedKeys,
|
|
175
|
+
disabledKeys = [],
|
|
176
|
+
renderStrategy = 'default',
|
|
177
|
+
isDisabled = false,
|
|
178
|
+
onExpandedChange,
|
|
179
|
+
className = '',
|
|
180
|
+
prefix = 'aceui',
|
|
181
|
+
ariaLabel,
|
|
182
|
+
testId,
|
|
183
|
+
}: AccordionProps) => {
|
|
184
|
+
const accordionId = useId();
|
|
185
|
+
const itemKeysRef = useRef<(string | number)[]>([]);
|
|
186
|
+
const [itemKeys, setItemKeys] = useState<(string | number)[]>([]);
|
|
187
|
+
|
|
188
|
+
const isControlled = controlledExpandedKeys !== undefined;
|
|
189
|
+
|
|
190
|
+
const [internalExpandedKeys, setInternalExpandedKeys] = useState<Set<string | number>>(() => {
|
|
191
|
+
const keys = defaultExpandedKeys;
|
|
192
|
+
const set = new Set(keys);
|
|
193
|
+
if (selectionMode === 'single' && keys.length > 1) {
|
|
194
|
+
return new Set([keys[0]]);
|
|
195
|
+
}
|
|
196
|
+
return set;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const expandedKeys = isControlled
|
|
200
|
+
? new Set(controlledExpandedKeys)
|
|
201
|
+
: internalExpandedKeys;
|
|
202
|
+
|
|
203
|
+
const [focusedKey, setFocusedKey] = useState<string | number | null>(null);
|
|
204
|
+
|
|
205
|
+
const [renderedKeys, setRenderedKeys] = useState<Set<string | number>>(() => {
|
|
206
|
+
if (renderStrategy === 'lazy') {
|
|
207
|
+
const keys = controlledExpandedKeys !== undefined ? (controlledExpandedKeys ?? []) : defaultExpandedKeys;
|
|
208
|
+
const arr = selectionMode === 'single' && keys.length > 1 ? [keys[0]] : keys;
|
|
209
|
+
return new Set(arr);
|
|
210
|
+
}
|
|
211
|
+
return new Set();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (renderStrategy !== 'lazy') return;
|
|
216
|
+
const keys = isControlled && controlledExpandedKeys ? controlledExpandedKeys : Array.from(internalExpandedKeys);
|
|
217
|
+
setRenderedKeys((prev) => {
|
|
218
|
+
const hasNew = keys.some((k) => !prev.has(k));
|
|
219
|
+
if (!hasNew) return prev;
|
|
220
|
+
const next = new Set(prev);
|
|
221
|
+
keys.forEach((k) => next.add(k));
|
|
222
|
+
return next;
|
|
223
|
+
});
|
|
224
|
+
}, [renderStrategy, isControlled, controlledExpandedKeys, internalExpandedKeys]);
|
|
225
|
+
|
|
226
|
+
const registerItem = useCallback((key: string | number) => {
|
|
227
|
+
itemKeysRef.current = Array.from(new Set([...itemKeysRef.current, key]));
|
|
228
|
+
setItemKeys([...itemKeysRef.current]);
|
|
229
|
+
}, []);
|
|
230
|
+
|
|
231
|
+
const toggle = useCallback(
|
|
232
|
+
(key: string | number) => {
|
|
233
|
+
if (isDisabled) return;
|
|
234
|
+
const disabledSet = new Set(disabledKeys);
|
|
235
|
+
if (disabledSet.has(key)) return;
|
|
236
|
+
|
|
237
|
+
const nextKeys = new Set(expandedKeys);
|
|
238
|
+
if (nextKeys.has(key)) {
|
|
239
|
+
nextKeys.delete(key);
|
|
240
|
+
} else {
|
|
241
|
+
if (selectionMode === 'single') {
|
|
242
|
+
nextKeys.clear();
|
|
243
|
+
}
|
|
244
|
+
nextKeys.add(key);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!isControlled) {
|
|
248
|
+
setInternalExpandedKeys(nextKeys);
|
|
249
|
+
}
|
|
250
|
+
onExpandedChange?.(Array.from(nextKeys));
|
|
251
|
+
},
|
|
252
|
+
[isDisabled, disabledKeys, expandedKeys, selectionMode, isControlled, onExpandedChange]
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const isExpanded = useCallback(
|
|
256
|
+
(key: string | number) => expandedKeys.has(key),
|
|
257
|
+
[expandedKeys]
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const isDisabledCheck = useCallback(
|
|
261
|
+
(key: string | number) => isDisabled || disabledKeys.includes(key),
|
|
262
|
+
[isDisabled, disabledKeys]
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const contextValue: AccordionContextValue = useMemo(
|
|
266
|
+
() => ({
|
|
267
|
+
expandedKeys,
|
|
268
|
+
renderedKeys,
|
|
269
|
+
toggle,
|
|
270
|
+
isExpanded,
|
|
271
|
+
isDisabled: isDisabledCheck,
|
|
272
|
+
variant,
|
|
273
|
+
selectionMode,
|
|
274
|
+
renderStrategy,
|
|
275
|
+
accordionId,
|
|
276
|
+
focusedKey,
|
|
277
|
+
setFocusedKey,
|
|
278
|
+
registerItem,
|
|
279
|
+
itemKeys,
|
|
280
|
+
prefix,
|
|
281
|
+
}),
|
|
282
|
+
[
|
|
283
|
+
expandedKeys,
|
|
284
|
+
renderedKeys,
|
|
285
|
+
toggle,
|
|
286
|
+
isExpanded,
|
|
287
|
+
isDisabledCheck,
|
|
288
|
+
variant,
|
|
289
|
+
selectionMode,
|
|
290
|
+
renderStrategy,
|
|
291
|
+
accordionId,
|
|
292
|
+
focusedKey,
|
|
293
|
+
registerItem,
|
|
294
|
+
itemKeys,
|
|
295
|
+
prefix,
|
|
296
|
+
]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const blockClass = block(prefix, 'accordion');
|
|
300
|
+
const accordionBemClasses = [
|
|
301
|
+
blockClass,
|
|
302
|
+
modifier(prefix, 'accordion', variant),
|
|
303
|
+
isDisabled && modifier(prefix, 'accordion', 'disabled'),
|
|
304
|
+
].filter(Boolean) as string[];
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<AccordionContext.Provider value={contextValue}>
|
|
308
|
+
<div
|
|
309
|
+
className={cn(accordionBemClasses, accordionVariants({ variant }), className)}
|
|
310
|
+
role="region"
|
|
311
|
+
aria-label={ariaLabel}
|
|
312
|
+
data-testid={testId}
|
|
313
|
+
>
|
|
314
|
+
{children}
|
|
315
|
+
</div>
|
|
316
|
+
</AccordionContext.Provider>
|
|
317
|
+
);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
Accordion.displayName = 'Accordion';
|
|
321
|
+
|
|
322
|
+
/* ============================================
|
|
323
|
+
AccordionItem
|
|
324
|
+
============================================ */
|
|
325
|
+
|
|
326
|
+
function getItemVariantClasses(variant: AccordionVariant): string {
|
|
327
|
+
switch (variant) {
|
|
328
|
+
case 'light':
|
|
329
|
+
case 'bordered':
|
|
330
|
+
return 'border-b border-border-default last:border-b-0';
|
|
331
|
+
case 'splitted':
|
|
332
|
+
return 'bg-surface-1 border border-border-subtle rounded-[16px] overflow-hidden shadow-none';
|
|
333
|
+
default:
|
|
334
|
+
return '';
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function getHeaderVariantClasses(variant: AccordionVariant): string {
|
|
339
|
+
if (variant === 'splitted') {
|
|
340
|
+
return 'px-[20px] pr-[24px] rounded-[16px]';
|
|
341
|
+
}
|
|
342
|
+
return '';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function getContentInnerVariantClasses(variant: AccordionVariant): string {
|
|
346
|
+
if (variant === 'splitted') {
|
|
347
|
+
return 'px-[20px]';
|
|
348
|
+
}
|
|
349
|
+
return '';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export const AccordionItem = ({
|
|
353
|
+
itemKey,
|
|
354
|
+
title,
|
|
355
|
+
subtitle,
|
|
356
|
+
children,
|
|
357
|
+
startContent,
|
|
358
|
+
indicator,
|
|
359
|
+
isDisabled: itemDisabled = false,
|
|
360
|
+
className = '',
|
|
361
|
+
prefix: prefixProp,
|
|
362
|
+
ariaLabel,
|
|
363
|
+
headingLevel = 'h3',
|
|
364
|
+
testId,
|
|
365
|
+
}: AccordionItemProps) => {
|
|
366
|
+
const ctx = useAccordionContext();
|
|
367
|
+
const {
|
|
368
|
+
toggle,
|
|
369
|
+
isExpanded,
|
|
370
|
+
isDisabled,
|
|
371
|
+
accordionId,
|
|
372
|
+
focusedKey,
|
|
373
|
+
setFocusedKey,
|
|
374
|
+
registerItem,
|
|
375
|
+
itemKeys,
|
|
376
|
+
renderStrategy,
|
|
377
|
+
renderedKeys,
|
|
378
|
+
variant,
|
|
379
|
+
prefix: contextPrefix,
|
|
380
|
+
} = ctx;
|
|
381
|
+
|
|
382
|
+
const prefix = prefixProp ?? contextPrefix;
|
|
383
|
+
|
|
384
|
+
const expanded = isExpanded(itemKey);
|
|
385
|
+
const disabled = isDisabled(itemKey) || itemDisabled;
|
|
386
|
+
|
|
387
|
+
const headerId = `${accordionId}-header-${itemKey}`;
|
|
388
|
+
const contentId = `${accordionId}-content-${itemKey}`;
|
|
389
|
+
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
registerItem(itemKey);
|
|
392
|
+
}, [itemKey, registerItem]);
|
|
393
|
+
|
|
394
|
+
const index = itemKeys.indexOf(itemKey);
|
|
395
|
+
const isFocused = focusedKey === itemKey;
|
|
396
|
+
const tabIndex = isFocused || (focusedKey === null && index === 0) ? 0 : -1;
|
|
397
|
+
|
|
398
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
399
|
+
if (disabled) return;
|
|
400
|
+
|
|
401
|
+
switch (e.key) {
|
|
402
|
+
case 'Enter':
|
|
403
|
+
case ' ':
|
|
404
|
+
e.preventDefault();
|
|
405
|
+
toggle(itemKey);
|
|
406
|
+
break;
|
|
407
|
+
case 'ArrowDown':
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
if (index < itemKeys.length - 1) {
|
|
410
|
+
setFocusedKey(itemKeys[index + 1]);
|
|
411
|
+
document.getElementById(`${accordionId}-header-${itemKeys[index + 1]}`)?.focus();
|
|
412
|
+
}
|
|
413
|
+
break;
|
|
414
|
+
case 'ArrowUp':
|
|
415
|
+
e.preventDefault();
|
|
416
|
+
if (index > 0) {
|
|
417
|
+
setFocusedKey(itemKeys[index - 1]);
|
|
418
|
+
document.getElementById(`${accordionId}-header-${itemKeys[index - 1]}`)?.focus();
|
|
419
|
+
}
|
|
420
|
+
break;
|
|
421
|
+
case 'Home':
|
|
422
|
+
e.preventDefault();
|
|
423
|
+
if (itemKeys.length > 0) {
|
|
424
|
+
setFocusedKey(itemKeys[0]);
|
|
425
|
+
document.getElementById(`${accordionId}-header-${itemKeys[0]}`)?.focus();
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
case 'End':
|
|
429
|
+
e.preventDefault();
|
|
430
|
+
if (itemKeys.length > 0) {
|
|
431
|
+
const lastKey = itemKeys[itemKeys.length - 1];
|
|
432
|
+
setFocusedKey(lastKey);
|
|
433
|
+
document.getElementById(`${accordionId}-header-${lastKey}`)?.focus();
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const handleClick = () => {
|
|
440
|
+
if (!disabled) toggle(itemKey);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const handleFocus = () => setFocusedKey(itemKey);
|
|
444
|
+
const handleBlur = () => setFocusedKey(null);
|
|
445
|
+
|
|
446
|
+
const shouldRenderContent =
|
|
447
|
+
renderStrategy === 'default' || (renderStrategy === 'lazy' && renderedKeys.has(itemKey));
|
|
448
|
+
|
|
449
|
+
const HeadingTag = headingLevel;
|
|
450
|
+
|
|
451
|
+
const defaultIndicator = <ChevronRightIcon />;
|
|
452
|
+
const resolvedIndicator =
|
|
453
|
+
typeof indicator === 'function'
|
|
454
|
+
? indicator({ isOpen: expanded, isDisabled: disabled, defaultIndicator })
|
|
455
|
+
: (indicator ?? defaultIndicator);
|
|
456
|
+
|
|
457
|
+
const itemBlockClass = block(prefix, 'accordion-item');
|
|
458
|
+
const itemBemClasses = [
|
|
459
|
+
itemBlockClass,
|
|
460
|
+
expanded && modifier(prefix, 'accordion-item', 'expanded'),
|
|
461
|
+
disabled && modifier(prefix, 'accordion-item', 'disabled'),
|
|
462
|
+
].filter(Boolean) as string[];
|
|
463
|
+
|
|
464
|
+
const itemClasses = cn(
|
|
465
|
+
itemBemClasses,
|
|
466
|
+
'flex flex-col w-full',
|
|
467
|
+
disabled && 'opacity-[0.5] pointer-events-none',
|
|
468
|
+
getItemVariantClasses(variant),
|
|
469
|
+
className
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const headerElementClass = element(prefix, 'accordion-item', 'header');
|
|
473
|
+
const headerClasses = cn(
|
|
474
|
+
headerElementClass,
|
|
475
|
+
'flex items-center gap-[8px] w-full py-[12px] border-none bg-transparent font-[inherit] text-[1rem] leading-[1.5rem] text-text-default text-left cursor-pointer outline-none transition-colors duration-200',
|
|
476
|
+
'focus-visible:outline-2 focus-visible:outline-[var(--aceui-color-focus)] focus-visible:outline-offset-2',
|
|
477
|
+
disabled && 'cursor-not-allowed',
|
|
478
|
+
getHeaderVariantClasses(variant)
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const contentElementClass = element(prefix, 'accordion-item', 'content');
|
|
482
|
+
const contentInnerClasses = cn(
|
|
483
|
+
'overflow-hidden min-h-0',
|
|
484
|
+
getContentInnerVariantClasses(variant)
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
const contentTransition = '0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
|
488
|
+
const contentWrapperStyle: React.CSSProperties = {
|
|
489
|
+
display: 'grid',
|
|
490
|
+
gridTemplateRows: expanded ? '1fr' : '0fr',
|
|
491
|
+
transition: `grid-template-rows ${contentTransition}`,
|
|
492
|
+
};
|
|
493
|
+
const contentInnerStyle: React.CSSProperties = expanded
|
|
494
|
+
? {
|
|
495
|
+
visibility: 'visible',
|
|
496
|
+
paddingBlock: '12px',
|
|
497
|
+
transition: `visibility 0s linear 0s, padding-block ${contentTransition}`,
|
|
498
|
+
}
|
|
499
|
+
: {
|
|
500
|
+
visibility: 'hidden',
|
|
501
|
+
paddingBlock: 0,
|
|
502
|
+
transition: `visibility 0s linear 0.3s, padding-block ${contentTransition}`,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<div className={itemClasses} data-testid={testId}>
|
|
507
|
+
<div
|
|
508
|
+
id={headerId}
|
|
509
|
+
className={headerClasses}
|
|
510
|
+
role="button"
|
|
511
|
+
tabIndex={tabIndex}
|
|
512
|
+
aria-expanded={expanded}
|
|
513
|
+
aria-controls={contentId}
|
|
514
|
+
aria-disabled={disabled ? 'true' : undefined}
|
|
515
|
+
aria-label={ariaLabel}
|
|
516
|
+
onClick={handleClick}
|
|
517
|
+
onKeyDown={handleKeyDown}
|
|
518
|
+
onFocus={handleFocus}
|
|
519
|
+
onBlur={handleBlur}
|
|
520
|
+
>
|
|
521
|
+
{startContent && (
|
|
522
|
+
<div className="shrink-0 flex items-center">{startContent}</div>
|
|
523
|
+
)}
|
|
524
|
+
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
|
525
|
+
<HeadingTag className="!m-0 !text-[inherit] !font-medium">{title}</HeadingTag>
|
|
526
|
+
{subtitle && (
|
|
527
|
+
<span className="text-[0.875rem] text-text-muted leading-[1.25rem]">
|
|
528
|
+
{subtitle}
|
|
529
|
+
</span>
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
<div
|
|
533
|
+
className={cn(
|
|
534
|
+
element(prefix, 'accordion-item', 'indicator'),
|
|
535
|
+
'shrink-0 flex items-center justify-center transition-transform duration-200',
|
|
536
|
+
expanded && 'rotate-90'
|
|
537
|
+
)}
|
|
538
|
+
aria-hidden
|
|
539
|
+
>
|
|
540
|
+
{resolvedIndicator}
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
|
|
544
|
+
{shouldRenderContent && (
|
|
545
|
+
<div
|
|
546
|
+
id={contentId}
|
|
547
|
+
className={cn(contentElementClass, 'overflow-hidden')}
|
|
548
|
+
style={contentWrapperStyle}
|
|
549
|
+
role="region"
|
|
550
|
+
aria-labelledby={headerId}
|
|
551
|
+
>
|
|
552
|
+
<div className={contentInnerClasses} style={contentInnerStyle}>
|
|
553
|
+
{children}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
)}
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
AccordionItem.displayName = 'AccordionItem';
|