@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.
Files changed (38) hide show
  1. package/README.md +46 -0
  2. package/package.json +42 -0
  3. package/src/base-components/Accordion.tsx +561 -0
  4. package/src/base-components/Badge.tsx +191 -0
  5. package/src/base-components/Button.tsx +331 -0
  6. package/src/base-components/ButtonGroup.tsx +149 -0
  7. package/src/base-components/Card.tsx +250 -0
  8. package/src/base-components/Checkbox.tsx +49 -0
  9. package/src/base-components/ChipInput.tsx +208 -0
  10. package/src/base-components/CommonButton.tsx +33 -0
  11. package/src/base-components/DataTable.tsx +82 -0
  12. package/src/base-components/Divider.tsx +82 -0
  13. package/src/base-components/Dropdown.tsx +85 -0
  14. package/src/base-components/EmptyState.tsx +18 -0
  15. package/src/base-components/FilterPopover.tsx +50 -0
  16. package/src/base-components/Input.tsx +60 -0
  17. package/src/base-components/Modal.tsx +107 -0
  18. package/src/base-components/OtpVerificationModal.tsx +251 -0
  19. package/src/base-components/Pagination.tsx +51 -0
  20. package/src/base-components/PhoneInput.tsx +142 -0
  21. package/src/base-components/PopConfirm.tsx +350 -0
  22. package/src/base-components/SearchPopover.tsx +70 -0
  23. package/src/base-components/SearchableSelect.tsx +734 -0
  24. package/src/base-components/Select.tsx +49 -0
  25. package/src/base-components/Table.tsx +78 -0
  26. package/src/base-components/Textarea.tsx +45 -0
  27. package/src/base-components/ThemeProvider.tsx +92 -0
  28. package/src/base-components/Toaster.tsx +198 -0
  29. package/src/base-components/index.ts +32 -0
  30. package/src/components/DashboardLayout.tsx +326 -0
  31. package/src/components/ListPage.tsx +140 -0
  32. package/src/components/QuickAccess.tsx +118 -0
  33. package/src/components/UserMenu.tsx +138 -0
  34. package/src/helpers/bem.ts +13 -0
  35. package/src/helpers/cn.ts +9 -0
  36. package/src/index.ts +16 -0
  37. package/src/theme.css +285 -0
  38. 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';