@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.
- package/dist/atomix.css +9231 -9337
- package/dist/atomix.css.map +1 -1
- package/dist/atomix.min.css +2 -2
- package/dist/atomix.min.css.map +1 -1
- package/dist/charts.js +4 -5
- package/dist/charts.js.map +1 -1
- package/dist/core.d.ts +87 -10
- package/dist/core.js +673 -480
- package/dist/core.js.map +1 -1
- package/dist/forms.d.ts +15 -3
- package/dist/forms.js +530 -97
- package/dist/forms.js.map +1 -1
- package/dist/heavy.js +5 -6
- package/dist/heavy.js.map +1 -1
- package/dist/index.d.ts +495 -254
- package/dist/index.esm.js +1269 -723
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1273 -723
- package/dist/index.js.map +1 -1
- package/dist/index.min.js +1 -1
- package/dist/index.min.js.map +1 -1
- package/package.json +2 -2
- package/scripts/atomix-cli.js +10 -1
- package/scripts/cli/__tests__/utils.test.js +6 -2
- package/scripts/cli/migration-tools.js +2 -2
- package/scripts/cli/theme-bridge.js +7 -9
- package/scripts/cli/utils.js +2 -1
- package/src/components/Accordion/Accordion.stories.tsx +40 -0
- package/src/components/Accordion/Accordion.tsx +174 -56
- package/src/components/Accordion/AccordionCompound.test.tsx +70 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +156 -50
- package/src/components/Breadcrumb/BreadcrumbCompound.test.tsx +84 -0
- package/src/components/Callout/Callout.stories.tsx +166 -1011
- package/src/components/Callout/Callout.tsx +196 -84
- package/src/components/Callout/CalloutCompound.test.tsx +72 -0
- package/src/components/Dropdown/Dropdown.tsx +133 -20
- package/src/components/Dropdown/DropdownCompound.test.tsx +64 -0
- package/src/components/EdgePanel/EdgePanel.tsx +164 -112
- package/src/components/EdgePanel/EdgePanelCompound.test.tsx +53 -0
- package/src/components/Form/Select.stories.tsx +23 -0
- package/src/components/Form/Select.test.tsx +99 -0
- package/src/components/Form/Select.tsx +144 -93
- package/src/components/Form/SelectOption.tsx +88 -0
- package/src/components/Hero/Hero.stories.tsx +37 -0
- package/src/components/Hero/Hero.test.tsx +142 -0
- package/src/components/Hero/Hero.tsx +142 -3
- package/src/components/List/List.test.tsx +62 -0
- package/src/components/List/List.tsx +16 -5
- package/src/components/List/ListItem.tsx +20 -0
- package/src/components/Modal/Modal.stories.tsx +65 -1
- package/src/components/Modal/Modal.tsx +115 -35
- package/src/components/Modal/ModalCompound.test.tsx +94 -0
- package/src/components/Steps/Steps.tsx +124 -21
- package/src/components/Steps/StepsCompound.test.tsx +81 -0
- package/src/components/Tabs/Tabs.tsx +197 -44
- package/src/components/Tabs/TabsCompound.test.tsx +64 -0
- package/src/lib/composables/index.ts +0 -4
- package/src/lib/composables/useAtomixGlass.ts +0 -15
- package/src/lib/theme/devtools/CLI.ts +2 -10
- package/src/lib/types/components.ts +8 -2
- package/src/lib/utils/__tests__/componentUtils.test.ts +57 -2
- package/src/lib/utils/__tests__/themeNaming.test.ts +117 -0
- package/src/lib/utils/themeNaming.ts +1 -1
- package/src/styles/02-tools/_tools.breakpoints.scss +1 -1
- package/src/styles/02-tools/_tools.utility-api.scss +6 -6
- 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
variant,
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<div className="c-
|
|
59
|
-
{
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
{
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
});
|