@shohojdhara/atomix 0.4.0 → 0.4.1

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 (66) hide show
  1. package/dist/atomix.css +9231 -9337
  2. package/dist/atomix.css.map +1 -1
  3. package/dist/atomix.min.css +2 -2
  4. package/dist/atomix.min.css.map +1 -1
  5. package/dist/charts.js +4 -5
  6. package/dist/charts.js.map +1 -1
  7. package/dist/core.d.ts +87 -10
  8. package/dist/core.js +673 -480
  9. package/dist/core.js.map +1 -1
  10. package/dist/forms.d.ts +15 -3
  11. package/dist/forms.js +530 -97
  12. package/dist/forms.js.map +1 -1
  13. package/dist/heavy.js +5 -6
  14. package/dist/heavy.js.map +1 -1
  15. package/dist/index.d.ts +495 -254
  16. package/dist/index.esm.js +1269 -723
  17. package/dist/index.esm.js.map +1 -1
  18. package/dist/index.js +1273 -723
  19. package/dist/index.js.map +1 -1
  20. package/dist/index.min.js +1 -1
  21. package/dist/index.min.js.map +1 -1
  22. package/package.json +2 -2
  23. package/scripts/atomix-cli.js +10 -1
  24. package/scripts/cli/__tests__/utils.test.js +6 -2
  25. package/scripts/cli/migration-tools.js +2 -2
  26. package/scripts/cli/theme-bridge.js +7 -9
  27. package/scripts/cli/utils.js +2 -1
  28. package/src/components/Accordion/Accordion.stories.tsx +40 -0
  29. package/src/components/Accordion/Accordion.tsx +174 -56
  30. package/src/components/Accordion/AccordionCompound.test.tsx +70 -0
  31. package/src/components/Breadcrumb/Breadcrumb.tsx +156 -50
  32. package/src/components/Breadcrumb/BreadcrumbCompound.test.tsx +84 -0
  33. package/src/components/Callout/Callout.stories.tsx +166 -1011
  34. package/src/components/Callout/Callout.tsx +196 -84
  35. package/src/components/Callout/CalloutCompound.test.tsx +72 -0
  36. package/src/components/Dropdown/Dropdown.tsx +133 -20
  37. package/src/components/Dropdown/DropdownCompound.test.tsx +64 -0
  38. package/src/components/EdgePanel/EdgePanel.tsx +164 -112
  39. package/src/components/EdgePanel/EdgePanelCompound.test.tsx +53 -0
  40. package/src/components/Form/Select.stories.tsx +23 -0
  41. package/src/components/Form/Select.test.tsx +99 -0
  42. package/src/components/Form/Select.tsx +144 -93
  43. package/src/components/Form/SelectOption.tsx +88 -0
  44. package/src/components/Hero/Hero.stories.tsx +37 -0
  45. package/src/components/Hero/Hero.test.tsx +142 -0
  46. package/src/components/Hero/Hero.tsx +142 -3
  47. package/src/components/List/List.test.tsx +62 -0
  48. package/src/components/List/List.tsx +16 -5
  49. package/src/components/List/ListItem.tsx +20 -0
  50. package/src/components/Modal/Modal.stories.tsx +65 -1
  51. package/src/components/Modal/Modal.tsx +115 -35
  52. package/src/components/Modal/ModalCompound.test.tsx +94 -0
  53. package/src/components/Steps/Steps.tsx +124 -21
  54. package/src/components/Steps/StepsCompound.test.tsx +81 -0
  55. package/src/components/Tabs/Tabs.tsx +197 -44
  56. package/src/components/Tabs/TabsCompound.test.tsx +64 -0
  57. package/src/lib/composables/index.ts +0 -4
  58. package/src/lib/composables/useAtomixGlass.ts +0 -15
  59. package/src/lib/theme/devtools/CLI.ts +2 -10
  60. package/src/lib/types/components.ts +8 -2
  61. package/src/lib/utils/__tests__/componentUtils.test.ts +57 -2
  62. package/src/lib/utils/__tests__/themeNaming.test.ts +117 -0
  63. package/src/lib/utils/themeNaming.ts +1 -1
  64. package/src/styles/02-tools/_tools.breakpoints.scss +1 -1
  65. package/src/styles/02-tools/_tools.utility-api.scss +6 -6
  66. package/src/styles/99-utilities/_utilities.text.scss +0 -1
@@ -1,85 +1,206 @@
1
- import React from 'react';
1
+ import React, { memo, forwardRef } from 'react';
2
2
  import { CalloutProps } from '../../lib/types/components';
3
3
  import { useCallout } from '../../lib/composables/useCallout';
4
4
  import { Icon } from '../Icon/Icon';
5
5
  import { AtomixGlass } from '../AtomixGlass/AtomixGlass';
6
6
 
7
+ // Subcomponents
8
+ export const CalloutIcon = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
9
+ ({ children, className = '', ...props }, ref) => (
10
+ <div ref={ref} className={`c-callout__icon ${className}`.trim()} {...props}>
11
+ {children}
12
+ </div>
13
+ )
14
+ );
15
+ CalloutIcon.displayName = 'CalloutIcon';
16
+
17
+ export const CalloutMessage = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
18
+ ({ children, className = '', ...props }, ref) => (
19
+ <div ref={ref} className={`c-callout__message ${className}`.trim()} {...props}>
20
+ {children}
21
+ </div>
22
+ )
23
+ );
24
+ CalloutMessage.displayName = 'CalloutMessage';
25
+
26
+ export const CalloutTitle = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
27
+ ({ children, className = '', ...props }, ref) => (
28
+ <div ref={ref} className={`c-callout__title ${className}`.trim()} {...props}>
29
+ {children}
30
+ </div>
31
+ )
32
+ );
33
+ CalloutTitle.displayName = 'CalloutTitle';
34
+
35
+ export const CalloutText = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
36
+ ({ children, className = '', ...props }, ref) => (
37
+ <div ref={ref} className={`c-callout__text ${className}`.trim()} {...props}>
38
+ {children}
39
+ </div>
40
+ )
41
+ );
42
+ CalloutText.displayName = 'CalloutText';
43
+
44
+ export const CalloutActions = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
45
+ ({ children, className = '', ...props }, ref) => (
46
+ <div ref={ref} className={`c-callout__actions ${className}`.trim()} {...props}>
47
+ {children}
48
+ </div>
49
+ )
50
+ );
51
+ CalloutActions.displayName = 'CalloutActions';
52
+
53
+ export interface CalloutCloseButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
54
+ export const CalloutCloseButton = forwardRef<HTMLButtonElement, CalloutCloseButtonProps>(
55
+ ({ onClick, className = '', ...props }, ref) => (
56
+ <button
57
+ ref={ref}
58
+ className={`c-callout__close-btn ${className}`.trim()}
59
+ onClick={onClick}
60
+ aria-label="Close"
61
+ {...props}
62
+ >
63
+ <Icon name="X" size="md" />
64
+ </button>
65
+ )
66
+ );
67
+ CalloutCloseButton.displayName = 'CalloutCloseButton';
68
+
69
+ // Wrapper for content (icon + message)
70
+ export const CalloutContent = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
71
+ ({ children, className = '', ...props }, ref) => (
72
+ <div ref={ref} className={`c-callout__content ${className}`.trim()} {...props}>
73
+ {children}
74
+ </div>
75
+ )
76
+ );
77
+ CalloutContent.displayName = 'CalloutContent';
78
+
7
79
  /**
8
80
  * Callout component for displaying important messages, notifications, or alerts
9
81
  */
10
- export const Callout: React.FC<CalloutProps> = ({
11
- title,
12
- children,
13
- icon,
14
- variant = 'primary',
15
- onClose,
16
- actions,
17
- compact = false,
18
- isToast = false,
19
- glass,
20
- className,
21
- style,
22
- ...props
23
- }) => {
24
- const { generateCalloutClass, handleClose } = useCallout({
25
- variant,
26
- compact,
27
- isToast,
82
+ type CalloutComponent = React.FC<CalloutProps> & {
83
+ Icon: typeof CalloutIcon;
84
+ Message: typeof CalloutMessage;
85
+ Title: typeof CalloutTitle;
86
+ Text: typeof CalloutText;
87
+ Actions: typeof CalloutActions;
88
+ CloseButton: typeof CalloutCloseButton;
89
+ Content: typeof CalloutContent;
90
+ };
91
+
92
+ export const Callout: CalloutComponent = memo(
93
+ ({
94
+ title,
95
+ children,
96
+ icon,
97
+ variant = 'primary',
98
+ onClose,
99
+ actions,
100
+ compact = false,
101
+ isToast = false,
28
102
  glass,
29
103
  className,
30
104
  style,
31
- });
105
+ ...props
106
+ }: CalloutProps) => {
107
+ const { generateCalloutClass, handleClose } = useCallout({
108
+ variant,
109
+ compact,
110
+ isToast,
111
+ glass,
112
+ className,
113
+ style,
114
+ });
32
115
 
33
- // Determine appropriate ARIA attributes based on variant
34
- const getAriaAttributes = () => {
35
- const baseAttributes: Record<string, string> = {
36
- role: 'region',
37
- };
116
+ // Determine appropriate ARIA attributes based on variant
117
+ const getAriaAttributes = () => {
118
+ const baseAttributes: Record<string, string> = {
119
+ role: 'region',
120
+ };
38
121
 
39
- // For toast notifications or alerts, use appropriate role and live region
40
- if (isToast) {
41
- baseAttributes.role = 'alert';
42
- baseAttributes['aria-live'] = 'polite';
43
- } else if (['warning', 'error'].includes(variant)) {
44
- baseAttributes.role = 'alert';
45
- baseAttributes['aria-live'] = 'assertive';
46
- } else if (['info', 'success'].includes(variant)) {
47
- baseAttributes.role = 'status';
48
- baseAttributes['aria-live'] = 'polite';
49
- }
122
+ // For toast notifications or alerts, use appropriate role and live region
123
+ if (isToast) {
124
+ baseAttributes.role = 'alert';
125
+ baseAttributes['aria-live'] = 'polite';
126
+ } else if (['warning', 'error'].includes(variant)) {
127
+ baseAttributes.role = 'alert';
128
+ baseAttributes['aria-live'] = 'assertive';
129
+ } else if (['info', 'success'].includes(variant)) {
130
+ baseAttributes.role = 'status';
131
+ baseAttributes['aria-live'] = 'polite';
132
+ }
133
+
134
+ return baseAttributes;
135
+ };
50
136
 
51
- return baseAttributes;
52
- };
137
+ // Check for compound usage
138
+ const hasCompoundComponents = React.Children.toArray(children).some((child) =>
139
+ React.isValidElement(child) &&
140
+ [
141
+ 'CalloutIcon',
142
+ 'CalloutMessage',
143
+ 'CalloutTitle',
144
+ 'CalloutText',
145
+ 'CalloutActions',
146
+ 'CalloutContent',
147
+ ].includes((child.type as any).displayName)
148
+ );
53
149
 
54
- const calloutContent = (
55
- <>
56
- <div className="c-callout__content">
57
- {icon && <div className="c-callout__icon">{icon}</div>}
58
- <div className="c-callout__message">
59
- {title && <div className="c-callout__title">{title}</div>}
60
- {children && <div className="c-callout__text">{children}</div>}
150
+ const calloutContent = hasCompoundComponents ? (
151
+ children
152
+ ) : (
153
+ <>
154
+ <div className="c-callout__content">
155
+ {icon && <div className="c-callout__icon">{icon}</div>}
156
+ <div className="c-callout__message">
157
+ {title && <div className="c-callout__title">{title}</div>}
158
+ {children && <div className="c-callout__text">{children}</div>}
159
+ </div>
61
160
  </div>
62
- </div>
63
161
 
64
- {actions && <div className="c-callout__actions">{actions}</div>}
65
-
66
- {onClose && (
67
- <button className="c-callout__close-btn" onClick={handleClose(onClose)} aria-label="Close">
68
- <Icon name="X" size="md" />
69
- </button>
70
- )}
71
- </>
72
- );
73
-
74
- if (glass) {
75
- // Default glass settings for callouts
76
- const defaultGlassProps = {
77
- displacementScale: 30,
78
- cornerRadius: 8,
79
- elasticity: 0,
80
- };
162
+ {actions && <div className="c-callout__actions">{actions}</div>}
163
+
164
+ {onClose && (
165
+ <button
166
+ className="c-callout__close-btn"
167
+ onClick={handleClose(onClose)}
168
+ aria-label="Close"
169
+ >
170
+ <Icon name="X" size="md" />
171
+ </button>
172
+ )}
173
+ </>
174
+ );
175
+
176
+ if (glass) {
177
+ // Default glass settings for callouts
178
+ const defaultGlassProps = {
179
+ displacementScale: 30,
180
+ cornerRadius: 8,
181
+ elasticity: 0,
182
+ };
183
+
184
+ const glassProps = glass === true ? defaultGlassProps : { ...defaultGlassProps, ...glass };
81
185
 
82
- const glassProps = glass === true ? defaultGlassProps : { ...defaultGlassProps, ...glass };
186
+ return (
187
+ <div
188
+ className={generateCalloutClass({ variant, compact, isToast, glass, className })}
189
+ {...getAriaAttributes()}
190
+ {...props}
191
+ style={style}
192
+ >
193
+ <AtomixGlass {...glassProps}>
194
+ <div
195
+ className="c-callout__glass-content"
196
+ style={{ borderRadius: glassProps.cornerRadius }}
197
+ >
198
+ {calloutContent}
199
+ </div>
200
+ </AtomixGlass>
201
+ </div>
202
+ );
203
+ }
83
204
 
84
205
  return (
85
206
  <div
@@ -88,32 +209,23 @@ export const Callout: React.FC<CalloutProps> = ({
88
209
  {...props}
89
210
  style={style}
90
211
  >
91
- <AtomixGlass {...glassProps}>
92
- <div
93
- className="c-callout__glass-content"
94
- style={{ borderRadius: glassProps.cornerRadius }}
95
- >
96
- {calloutContent}
97
- </div>
98
- </AtomixGlass>
212
+ {calloutContent}
99
213
  </div>
100
214
  );
101
215
  }
102
-
103
- return (
104
- <div
105
- className={generateCalloutClass({ variant, compact, isToast, glass, className })}
106
- {...getAriaAttributes()}
107
- {...props}
108
- style={style}
109
- >
110
- {calloutContent}
111
- </div>
112
- );
113
- };
216
+ ) as unknown as CalloutComponent;
114
217
 
115
218
  Callout.displayName = 'Callout';
116
219
 
220
+ // Attach subcomponents
221
+ Callout.Icon = CalloutIcon;
222
+ Callout.Message = CalloutMessage;
223
+ Callout.Title = CalloutTitle;
224
+ Callout.Text = CalloutText;
225
+ Callout.Actions = CalloutActions;
226
+ Callout.CloseButton = CalloutCloseButton;
227
+ Callout.Content = CalloutContent;
228
+
117
229
  export type { CalloutProps };
118
230
 
119
231
  export default Callout;
@@ -0,0 +1,72 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { Callout } from './Callout';
4
+ import React from 'react';
5
+
6
+ describe('Callout Component', () => {
7
+ it('renders correctly with legacy props', () => {
8
+ render(
9
+ <Callout title="Legacy Title" icon={<span>Icon</span>}>
10
+ Legacy Content
11
+ </Callout>
12
+ );
13
+
14
+ expect(screen.getByText('Legacy Title')).toBeInTheDocument();
15
+ expect(screen.getByText('Legacy Content')).toBeInTheDocument();
16
+ expect(screen.getByText('Icon')).toBeInTheDocument();
17
+ });
18
+
19
+ it('renders correctly with compound components', () => {
20
+ render(
21
+ <Callout>
22
+ <Callout.Content>
23
+ <Callout.Icon>
24
+ <span>Compound Icon</span>
25
+ </Callout.Icon>
26
+ <Callout.Message>
27
+ <Callout.Title>Compound Title</Callout.Title>
28
+ <Callout.Text>Compound Text</Callout.Text>
29
+ </Callout.Message>
30
+ </Callout.Content>
31
+ <Callout.Actions>
32
+ <button>Action</button>
33
+ </Callout.Actions>
34
+ <Callout.CloseButton onClick={() => {}} />
35
+ </Callout>
36
+ );
37
+
38
+ expect(screen.getByText('Compound Icon')).toBeInTheDocument();
39
+ expect(screen.getByText('Compound Title')).toBeInTheDocument();
40
+ expect(screen.getByText('Compound Text')).toBeInTheDocument();
41
+ expect(screen.getByText('Action')).toBeInTheDocument();
42
+ expect(screen.getByLabelText('Close')).toBeInTheDocument();
43
+ });
44
+
45
+ it('prioritizes compound components over legacy props', () => {
46
+ render(
47
+ <Callout title="Legacy Title">
48
+ <Callout.Content>
49
+ <Callout.Message>
50
+ <Callout.Text>Compound Text</Callout.Text>
51
+ </Callout.Message>
52
+ </Callout.Content>
53
+ </Callout>
54
+ );
55
+
56
+ expect(screen.getByText('Compound Text')).toBeInTheDocument();
57
+ expect(screen.queryByText('Legacy Title')).not.toBeInTheDocument();
58
+ });
59
+
60
+ it('renders close button when used as compound', () => {
61
+ const onClose = vi.fn();
62
+ render(
63
+ <Callout>
64
+ <Callout.CloseButton onClick={onClose} />
65
+ </Callout>
66
+ );
67
+
68
+ const button = screen.getByLabelText('Close');
69
+ fireEvent.click(button);
70
+ expect(onClose).toHaveBeenCalled();
71
+ });
72
+ });
@@ -6,6 +6,8 @@ import React, {
6
6
  useContext,
7
7
  useEffect,
8
8
  memo,
9
+ forwardRef,
10
+ ReactNode,
9
11
  } from 'react';
10
12
  import { DROPDOWN } from '../../lib/constants/components';
11
13
  import { AtomixGlass } from '../AtomixGlass/AtomixGlass';
@@ -14,6 +16,7 @@ import type {
14
16
  DropdownItemProps,
15
17
  DropdownDividerProps,
16
18
  DropdownHeaderProps,
19
+ AtomixGlassProps,
17
20
  } from '../../lib/types/components';
18
21
 
19
22
  // Context type definition
@@ -32,6 +35,54 @@ const DropdownContext = createContext<DropdownContextType>({
32
35
  trigger: 'click',
33
36
  });
34
37
 
38
+ // Compound Components
39
+
40
+ export const DropdownMenu = forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(
41
+ ({ children, className = '', ...props }, ref) => {
42
+ const { glass } = useContext(DropdownStyleContext); // We need to access glass prop here?
43
+ // Wait, the original code wrapped <ul> in Context Provider.
44
+ // And applied glass wrapper around <ul>.
45
+ // If we use Compound Component, DropdownMenu should be the list.
46
+
47
+ return (
48
+ <ul
49
+ ref={ref}
50
+ className={`c-dropdown__menu ${glass ? 'c-dropdown__menu--glass' : ''} ${className}`.trim()}
51
+ {...props}
52
+ >
53
+ {children}
54
+ </ul>
55
+ );
56
+ }
57
+ );
58
+ DropdownMenu.displayName = 'DropdownMenu';
59
+
60
+ export const DropdownTrigger = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
61
+ ({ children, className = '', onClick, onKeyDown, ...props }, ref) => {
62
+ // We need to inject the trigger logic here.
63
+ // But triggers are usually handled by the parent Dropdown in the original code.
64
+ // The original code wraps children in `c-dropdown__toggle` div.
65
+
66
+ // Ideally, DropdownTrigger allows user to customize the trigger element.
67
+ // For backward compat, Dropdown wraps `children` (legacy) in `c-dropdown__toggle`.
68
+
69
+ // If we use <Dropdown.Trigger><Button/></Dropdown.Trigger>, we want the Button to be the trigger.
70
+
71
+ return (
72
+ <div
73
+ ref={ref}
74
+ className={`c-dropdown__toggle ${className}`.trim()}
75
+ onClick={onClick}
76
+ onKeyDown={onKeyDown}
77
+ {...props}
78
+ >
79
+ {children}
80
+ </div>
81
+ );
82
+ }
83
+ );
84
+ DropdownTrigger.displayName = 'DropdownTrigger';
85
+
35
86
  /**
36
87
  * DropdownItem component for menu items
37
88
  */
@@ -139,10 +190,21 @@ export const DropdownHeader: React.FC<DropdownHeaderProps> = memo(
139
190
  }
140
191
  );
141
192
 
193
+ // Helper context to pass glass prop to DropdownMenu
194
+ const DropdownStyleContext = createContext<{ glass?: AtomixGlassProps | boolean }>({});
195
+
142
196
  /**
143
197
  * Dropdown component for creating dropdown menus
144
198
  */
145
- export const Dropdown: React.FC<DropdownProps> = memo(
199
+ type DropdownComponent = React.FC<DropdownProps> & {
200
+ Trigger: typeof DropdownTrigger;
201
+ Menu: typeof DropdownMenu;
202
+ Item: typeof DropdownItem;
203
+ Divider: typeof DropdownDivider;
204
+ Header: typeof DropdownHeader;
205
+ };
206
+
207
+ export const Dropdown: DropdownComponent = memo(
146
208
  ({
147
209
  children,
148
210
  menu,
@@ -160,7 +222,7 @@ export const Dropdown: React.FC<DropdownProps> = memo(
160
222
  style,
161
223
  glass,
162
224
  ...props
163
- }) => {
225
+ }: DropdownProps) => {
164
226
  // Set up controlled vs uncontrolled state
165
227
  const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState(false);
166
228
  const isControlled = controlledIsOpen !== undefined;
@@ -328,22 +390,46 @@ export const Dropdown: React.FC<DropdownProps> = memo(
328
390
  menuStyleProps.minWidth = typeof minWidth === 'number' ? `${minWidth}px` : minWidth;
329
391
  }
330
392
 
331
- const menuContent = (
332
- <div className="c-dropdown__menu-inner" style={menuStyleProps}>
333
- <DropdownContext.Provider value={{ isOpen, close, id: dropdownId, trigger }}>
334
- <ul className={`c-dropdown__menu ${glass ? 'c-dropdown__menu--glass' : ''}`}>{menu}</ul>
335
- </DropdownContext.Provider>
336
- </div>
393
+ // Determine content structure
394
+ // Legacy: menu prop + children as trigger
395
+ // Compound: children contains Trigger and Menu
396
+
397
+ const hasCompoundComponents = React.Children.toArray(children).some((child) =>
398
+ React.isValidElement(child) &&
399
+ ['DropdownTrigger', 'DropdownMenu'].includes((child.type as any).displayName)
337
400
  );
338
401
 
339
- return (
340
- <div
341
- ref={dropdownRef}
342
- className={dropdownClasses}
343
- style={style}
344
- onMouseEnter={trigger === 'hover' ? handleHoverOpen : undefined}
345
- {...props}
346
- >
402
+ let triggerContent: ReactNode;
403
+ let menuContentNode: ReactNode;
404
+
405
+ if (hasCompoundComponents) {
406
+ // Find Trigger and Menu in children
407
+ React.Children.forEach(children, (child) => {
408
+ if (React.isValidElement(child)) {
409
+ if ((child.type as any).displayName === 'DropdownTrigger') {
410
+ triggerContent = React.cloneElement(child, {
411
+ ref: toggleRef,
412
+ onClick: (e: React.MouseEvent) => {
413
+ handleToggleClick(e);
414
+ (child.props as any).onClick?.(e);
415
+ },
416
+ onKeyDown: (e: React.KeyboardEvent) => {
417
+ handleToggleKeyDown(e);
418
+ (child.props as any).onKeyDown?.(e);
419
+ },
420
+ 'aria-haspopup': 'menu',
421
+ 'aria-expanded': isOpen,
422
+ 'aria-controls': dropdownId,
423
+ tabIndex: 0,
424
+ } as any);
425
+ } else if ((child.type as any).displayName === 'DropdownMenu') {
426
+ menuContentNode = child;
427
+ }
428
+ }
429
+ });
430
+ } else {
431
+ // Legacy mode
432
+ triggerContent = (
347
433
  <div
348
434
  ref={toggleRef}
349
435
  className="c-dropdown__toggle"
@@ -356,6 +442,31 @@ export const Dropdown: React.FC<DropdownProps> = memo(
356
442
  >
357
443
  {children}
358
444
  </div>
445
+ );
446
+ menuContentNode = (
447
+ <ul className={`c-dropdown__menu ${glass ? 'c-dropdown__menu--glass' : ''}`}>{menu}</ul>
448
+ );
449
+ }
450
+
451
+ const menuContent = (
452
+ <div className="c-dropdown__menu-inner" style={menuStyleProps}>
453
+ <DropdownStyleContext.Provider value={{ glass }}>
454
+ <DropdownContext.Provider value={{ isOpen, close, id: dropdownId, trigger }}>
455
+ {menuContentNode}
456
+ </DropdownContext.Provider>
457
+ </DropdownStyleContext.Provider>
458
+ </div>
459
+ );
460
+
461
+ return (
462
+ <div
463
+ ref={dropdownRef}
464
+ className={dropdownClasses}
465
+ style={style}
466
+ onMouseEnter={trigger === 'hover' ? handleHoverOpen : undefined}
467
+ {...props}
468
+ >
469
+ {triggerContent}
359
470
 
360
471
  <div
361
472
  ref={menuRef}
@@ -384,13 +495,15 @@ export const Dropdown: React.FC<DropdownProps> = memo(
384
495
  </div>
385
496
  );
386
497
  }
387
- );
498
+ ) as unknown as DropdownComponent;
388
499
 
389
500
  export type { DropdownProps, DropdownItemProps, DropdownDividerProps, DropdownHeaderProps };
390
501
 
391
502
  Dropdown.displayName = 'Dropdown';
392
- DropdownItem.displayName = 'DropdownItem';
393
- DropdownDivider.displayName = 'DropdownDivider';
394
- DropdownHeader.displayName = 'DropdownHeader';
503
+ Dropdown.Trigger = DropdownTrigger;
504
+ Dropdown.Menu = DropdownMenu;
505
+ Dropdown.Item = DropdownItem;
506
+ Dropdown.Divider = DropdownDivider;
507
+ Dropdown.Header = DropdownHeader;
395
508
 
396
509
  export default Dropdown;
@@ -0,0 +1,64 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { Dropdown } from './Dropdown';
4
+ import React from 'react';
5
+
6
+ describe('Dropdown Component', () => {
7
+ it('renders correctly with legacy props', () => {
8
+ // In legacy mode, `menu` prop content is rendered inside the dropdown wrapper.
9
+ // The dropdown wrapper uses CSS visibility/opacity/display to hide the menu when not open.
10
+ // `toBeVisible` checks if the element is visible to the user.
11
+ // However, if the menu is just visually hidden via CSS classes (e.g. opacity: 0),
12
+ // jest-dom might consider it visible if display is not none and visibility is not hidden.
13
+ // Let's check if the wrapper has `is-open` class.
14
+
15
+ const { container } = render(
16
+ <Dropdown menu={<Dropdown.Item>Item 1</Dropdown.Item>}>
17
+ <button>Trigger</button>
18
+ </Dropdown>
19
+ );
20
+
21
+ expect(screen.getByText('Trigger')).toBeInTheDocument();
22
+
23
+ // Check if the menu wrapper exists but does not have 'is-open' class
24
+ const menuWrapper = container.querySelector('.c-dropdown__menu-wrapper');
25
+ expect(menuWrapper).not.toHaveClass('is-open');
26
+ });
27
+
28
+ it('renders correctly with compound components', () => {
29
+ render(
30
+ <Dropdown>
31
+ <Dropdown.Trigger>
32
+ <button>Compound Trigger</button>
33
+ </Dropdown.Trigger>
34
+ <Dropdown.Menu>
35
+ <Dropdown.Item>Item 1</Dropdown.Item>
36
+ </Dropdown.Menu>
37
+ </Dropdown>
38
+ );
39
+
40
+ expect(screen.getByText('Compound Trigger')).toBeInTheDocument();
41
+ });
42
+
43
+ it('toggles menu in compound mode', () => {
44
+ const { container } = render(
45
+ <Dropdown>
46
+ <Dropdown.Trigger>
47
+ <button>Trigger</button>
48
+ </Dropdown.Trigger>
49
+ <Dropdown.Menu>
50
+ <Dropdown.Item>Item 1</Dropdown.Item>
51
+ </Dropdown.Menu>
52
+ </Dropdown>
53
+ );
54
+
55
+ fireEvent.click(screen.getByText('Trigger'));
56
+
57
+ // Check if open class is applied or aria-expanded
58
+ const trigger = screen.getByText('Trigger').closest('.c-dropdown__toggle');
59
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
60
+
61
+ const menuWrapper = container.querySelector('.c-dropdown__menu-wrapper');
62
+ expect(menuWrapper).toHaveClass('is-open');
63
+ });
64
+ });