@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,10 +1,139 @@
|
|
|
1
|
-
import React, { CSSProperties, useEffect } from 'react';
|
|
2
|
-
import { HeroProps, HeroAlignment } from '../../lib/types/components';
|
|
1
|
+
import React, { CSSProperties, useEffect, ReactNode } from 'react';
|
|
2
|
+
import { HeroProps, HeroAlignment, AtomixGlassProps } from '../../lib/types/components';
|
|
3
3
|
import { useHero } from '../../lib/composables/useHero';
|
|
4
4
|
import { HERO } from '../../lib/constants/components';
|
|
5
5
|
import { AtomixGlass } from '../AtomixGlass/AtomixGlass';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Subcomponents
|
|
8
|
+
export interface HeroTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
|
9
|
+
level?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const HeroTitle = ({ children, className, level = 'h1', ...props }: HeroTitleProps) => {
|
|
13
|
+
const Tag = level as any;
|
|
14
|
+
return (
|
|
15
|
+
<Tag className={`${HERO.SELECTORS.TITLE.replace('.', '')} ${className || ''}`.trim()} {...props}>
|
|
16
|
+
{children}
|
|
17
|
+
</Tag>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const HeroSubtitle = ({ children, className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => {
|
|
22
|
+
return (
|
|
23
|
+
<p className={`${HERO.SELECTORS.SUBTITLE.replace('.', '')} ${className || ''}`.trim()} {...props}>
|
|
24
|
+
{children}
|
|
25
|
+
</p>
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const HeroText = ({ children, className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => {
|
|
30
|
+
return (
|
|
31
|
+
<p className={`${HERO.SELECTORS.TEXT.replace('.', '')} ${className || ''}`.trim()} {...props}>
|
|
32
|
+
{children}
|
|
33
|
+
</p>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const HeroActions = ({ children, className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
|
|
38
|
+
return (
|
|
39
|
+
<div className={`${HERO.SELECTORS.ACTIONS.replace('.', '')} ${className || ''}`.trim()} {...props}>
|
|
40
|
+
{children}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface HeroContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
46
|
+
glass?: AtomixGlassProps | boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const HeroContent = ({ children, className, style, glass, ...props }: HeroContentProps) => {
|
|
50
|
+
const contentClass = `${HERO.SELECTORS.CONTENT.replace('.', '')} ${className || ''}`.trim();
|
|
51
|
+
|
|
52
|
+
if (glass) {
|
|
53
|
+
const glassProps = typeof glass === 'boolean' ? {
|
|
54
|
+
displacementScale: 60,
|
|
55
|
+
blurAmount: 3,
|
|
56
|
+
saturation: 180,
|
|
57
|
+
aberrationIntensity: 0,
|
|
58
|
+
cornerRadius: 8,
|
|
59
|
+
overLight: false,
|
|
60
|
+
mode: 'standard' as const,
|
|
61
|
+
} : glass;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className={contentClass} style={style} {...props}>
|
|
65
|
+
<AtomixGlass {...glassProps}>
|
|
66
|
+
<div className="u-p-4">
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
</AtomixGlass>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className={contentClass} style={style} {...props}>
|
|
76
|
+
{children}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface HeroImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|
82
|
+
wrapperClassName?: string;
|
|
83
|
+
wrapperStyle?: React.CSSProperties;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const HeroImage = ({
|
|
87
|
+
src,
|
|
88
|
+
alt = '',
|
|
89
|
+
className,
|
|
90
|
+
wrapperClassName,
|
|
91
|
+
wrapperStyle,
|
|
92
|
+
...props
|
|
93
|
+
}: HeroImageProps) => {
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
className={`${HERO.SELECTORS.IMAGE_WRAPPER.replace('.', '')} ${wrapperClassName || ''}`.trim()}
|
|
97
|
+
style={wrapperStyle}
|
|
98
|
+
>
|
|
99
|
+
<img
|
|
100
|
+
src={src}
|
|
101
|
+
alt={alt}
|
|
102
|
+
className={`${HERO.SELECTORS.IMAGE.replace('.', '')} ${className || ''}`.trim()}
|
|
103
|
+
{...props}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const HeroBackground = ({ className, style, src, children, ...props }: React.HTMLAttributes<HTMLDivElement> & { src?: string }) => {
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
className={`${HERO.SELECTORS.BG.replace('.', '')} ${className || ''}`.trim()}
|
|
113
|
+
style={style}
|
|
114
|
+
{...props}
|
|
115
|
+
>
|
|
116
|
+
{src && (
|
|
117
|
+
<img
|
|
118
|
+
src={src}
|
|
119
|
+
alt="Background"
|
|
120
|
+
className={HERO.SELECTORS.BG_IMAGE.replace('.', '')}
|
|
121
|
+
/>
|
|
122
|
+
)}
|
|
123
|
+
{children}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const Hero: React.FC<HeroProps> & {
|
|
129
|
+
Title: typeof HeroTitle;
|
|
130
|
+
Subtitle: typeof HeroSubtitle;
|
|
131
|
+
Text: typeof HeroText;
|
|
132
|
+
Actions: typeof HeroActions;
|
|
133
|
+
Content: typeof HeroContent;
|
|
134
|
+
Image: typeof HeroImage;
|
|
135
|
+
Background: typeof HeroBackground;
|
|
136
|
+
} = ({
|
|
8
137
|
title,
|
|
9
138
|
subtitle,
|
|
10
139
|
text,
|
|
@@ -38,6 +167,7 @@ export const Hero: React.FC<HeroProps> = ({
|
|
|
38
167
|
headingLevel = 'h1',
|
|
39
168
|
reverseOnMobile = false,
|
|
40
169
|
parts,
|
|
170
|
+
backgroundElement,
|
|
41
171
|
...rest
|
|
42
172
|
}: HeroProps) => {
|
|
43
173
|
// Define dynamic heading tag
|
|
@@ -421,6 +551,7 @@ export const Hero: React.FC<HeroProps> = ({
|
|
|
421
551
|
data-parallax-intensity={parallax ? parallaxIntensity : undefined}
|
|
422
552
|
{...rest}
|
|
423
553
|
>
|
|
554
|
+
{backgroundElement}
|
|
424
555
|
{renderBackground()}
|
|
425
556
|
<div
|
|
426
557
|
className={`${HERO.SELECTORS.CONTAINER.replace('.', '')} o-container ${parts?.container?.className || ''}`.trim()}
|
|
@@ -451,6 +582,14 @@ export const Hero: React.FC<HeroProps> = ({
|
|
|
451
582
|
);
|
|
452
583
|
};
|
|
453
584
|
|
|
585
|
+
Hero.Title = HeroTitle;
|
|
586
|
+
Hero.Subtitle = HeroSubtitle;
|
|
587
|
+
Hero.Text = HeroText;
|
|
588
|
+
Hero.Actions = HeroActions;
|
|
589
|
+
Hero.Content = HeroContent;
|
|
590
|
+
Hero.Image = HeroImage;
|
|
591
|
+
Hero.Background = HeroBackground;
|
|
592
|
+
|
|
454
593
|
export type { HeroProps };
|
|
455
594
|
|
|
456
595
|
Hero.displayName = 'Hero';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { List } from './List';
|
|
5
|
+
|
|
6
|
+
describe('List Component', () => {
|
|
7
|
+
it('renders legacy items wrapped in li', () => {
|
|
8
|
+
render(
|
|
9
|
+
<List>
|
|
10
|
+
<span>Item 1</span>
|
|
11
|
+
<span>Item 2</span>
|
|
12
|
+
</List>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const listItems = screen.getAllByRole('listitem');
|
|
16
|
+
expect(listItems).toHaveLength(2);
|
|
17
|
+
expect(listItems[0]).toHaveTextContent('Item 1');
|
|
18
|
+
expect(listItems[0]).toHaveClass('c-list__item');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders List.Item components directly', () => {
|
|
22
|
+
render(
|
|
23
|
+
<List>
|
|
24
|
+
<List.Item>Item 1</List.Item>
|
|
25
|
+
<List.Item className="custom-class">Item 2</List.Item>
|
|
26
|
+
</List>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const listItems = screen.getAllByRole('listitem');
|
|
30
|
+
expect(listItems).toHaveLength(2);
|
|
31
|
+
expect(listItems[0]).toHaveTextContent('Item 1');
|
|
32
|
+
expect(listItems[0]).toHaveClass('c-list__item');
|
|
33
|
+
expect(listItems[1]).toHaveTextContent('Item 2');
|
|
34
|
+
expect(listItems[1]).toHaveClass('c-list__item');
|
|
35
|
+
expect(listItems[1]).toHaveClass('custom-class');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders mixed content correctly', () => {
|
|
39
|
+
render(
|
|
40
|
+
<List>
|
|
41
|
+
<List.Item>Compound Item</List.Item>
|
|
42
|
+
<span>Legacy Item</span>
|
|
43
|
+
</List>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const listItems = screen.getAllByRole('listitem');
|
|
47
|
+
expect(listItems).toHaveLength(2);
|
|
48
|
+
expect(listItems[0]).toHaveTextContent('Compound Item');
|
|
49
|
+
expect(listItems[1]).toHaveTextContent('Legacy Item');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders ordered list when variant is number', () => {
|
|
53
|
+
render(
|
|
54
|
+
<List variant="number">
|
|
55
|
+
<List.Item>Item 1</List.Item>
|
|
56
|
+
</List>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const list = screen.getByRole('list');
|
|
60
|
+
expect(list.tagName).toBe('OL');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import React, { memo } from 'react';
|
|
2
2
|
import { ListProps } from '../../lib/types/components';
|
|
3
3
|
import { LIST } from '../../lib/constants/components';
|
|
4
|
+
import { ListItem } from './ListItem';
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
|
|
6
|
+
export type { ListProps };
|
|
7
|
+
|
|
8
|
+
export type ListComponent = React.FC<ListProps> & {
|
|
9
|
+
Item: typeof ListItem;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const List: ListComponent = memo(
|
|
13
|
+
({ children, variant = 'default', className = '', style, ...props }: ListProps) => {
|
|
7
14
|
// Generate CSS classes
|
|
8
15
|
const listClasses = [LIST.BASE_CLASS, variant !== 'default' && `c-list--${variant}`, className]
|
|
9
16
|
.filter(Boolean)
|
|
@@ -16,6 +23,11 @@ export const List: React.FC<ListProps> = memo(
|
|
|
16
23
|
<ListElement className={listClasses} style={style} {...props}>
|
|
17
24
|
{React.Children.map(children, child => {
|
|
18
25
|
if (React.isValidElement(child)) {
|
|
26
|
+
// Check if child is a ListItem
|
|
27
|
+
if (child.type === ListItem) {
|
|
28
|
+
return child;
|
|
29
|
+
}
|
|
30
|
+
// Legacy behavior: wrap in li
|
|
19
31
|
return <li className="c-list__item">{child}</li>;
|
|
20
32
|
}
|
|
21
33
|
return <li className="c-list__item">{child}</li>;
|
|
@@ -23,10 +35,9 @@ export const List: React.FC<ListProps> = memo(
|
|
|
23
35
|
</ListElement>
|
|
24
36
|
);
|
|
25
37
|
}
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
export type { ListProps };
|
|
38
|
+
) as unknown as ListComponent;
|
|
29
39
|
|
|
30
40
|
List.displayName = 'List';
|
|
41
|
+
List.Item = ListItem;
|
|
31
42
|
|
|
32
43
|
export default List;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React, { forwardRef } from 'react';
|
|
2
|
+
import { LIST } from '../../lib/constants/components';
|
|
3
|
+
|
|
4
|
+
export interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
|
|
5
|
+
children?: React.ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
|
|
9
|
+
({ children, className = '', ...props }, ref) => {
|
|
10
|
+
return (
|
|
11
|
+
<li ref={ref} className={`${LIST.ITEM_CLASS} ${className}`.trim()} {...props}>
|
|
12
|
+
{children}
|
|
13
|
+
</li>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
ListItem.displayName = 'ListItem';
|
|
19
|
+
|
|
20
|
+
export default ListItem;
|
|
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
|
|
|
2
2
|
import { fn } from '@storybook/test';
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import type { AtomixGlassProps } from '../../lib/types/components';
|
|
5
|
-
import Modal from './Modal';
|
|
5
|
+
import { Modal } from './Modal';
|
|
6
6
|
|
|
7
7
|
// Helper type for glass props in stories (without children requirement)
|
|
8
8
|
type GlassProps = boolean | Omit<AtomixGlassProps, 'children'>;
|
|
@@ -31,6 +31,7 @@ Modal displays content in a focused overlay dialog. It provides a way to present
|
|
|
31
31
|
- Header and footer sections
|
|
32
32
|
- Accessible design
|
|
33
33
|
- Responsive behavior
|
|
34
|
+
- **Compound Component Pattern** (new)
|
|
34
35
|
|
|
35
36
|
## Accessibility
|
|
36
37
|
|
|
@@ -53,6 +54,20 @@ Modal displays content in a focused overlay dialog. It provides a way to present
|
|
|
53
54
|
</Modal>
|
|
54
55
|
\`\`\`
|
|
55
56
|
|
|
57
|
+
### Compound Component Usage
|
|
58
|
+
|
|
59
|
+
\`\`\`tsx
|
|
60
|
+
<Modal isOpen={isOpen} onOpenChange={setIsOpen}>
|
|
61
|
+
<Modal.Header closeButton title="Custom Header" />
|
|
62
|
+
<Modal.Body>
|
|
63
|
+
<p>Flexible body content</p>
|
|
64
|
+
</Modal.Body>
|
|
65
|
+
<Modal.Footer>
|
|
66
|
+
<button>Action</button>
|
|
67
|
+
</Modal.Footer>
|
|
68
|
+
</Modal>
|
|
69
|
+
\`\`\`
|
|
70
|
+
|
|
56
71
|
### With Glass Effect
|
|
57
72
|
|
|
58
73
|
\`\`\`tsx
|
|
@@ -284,6 +299,55 @@ export const WithGlassEffect: Story = {
|
|
|
284
299
|
},
|
|
285
300
|
};
|
|
286
301
|
|
|
302
|
+
export const CompoundUsage: Story = {
|
|
303
|
+
render: args => {
|
|
304
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<>
|
|
308
|
+
<div
|
|
309
|
+
className="c-btn c-btn--primary"
|
|
310
|
+
onClick={() => setIsOpen(true)}
|
|
311
|
+
style={{ cursor: 'pointer', padding: '8px 16px', display: 'inline-block' }}
|
|
312
|
+
>
|
|
313
|
+
Open Compound Modal
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<Modal
|
|
317
|
+
{...args}
|
|
318
|
+
isOpen={isOpen}
|
|
319
|
+
onOpenChange={setIsOpen}
|
|
320
|
+
>
|
|
321
|
+
<Modal.Header
|
|
322
|
+
title="Compound Component Pattern"
|
|
323
|
+
subtitle="Fully customizable header"
|
|
324
|
+
closeButton
|
|
325
|
+
/>
|
|
326
|
+
<Modal.Body>
|
|
327
|
+
<p>
|
|
328
|
+
This modal uses the Compound Component pattern (Modal.Header, Modal.Body, Modal.Footer).
|
|
329
|
+
This allows for greater flexibility in content arrangement.
|
|
330
|
+
</p>
|
|
331
|
+
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f5f5f5', borderRadius: '4px' }}>
|
|
332
|
+
Custom content structure inside Body
|
|
333
|
+
</div>
|
|
334
|
+
</Modal.Body>
|
|
335
|
+
<Modal.Footer>
|
|
336
|
+
<button className="c-btn c-btn--outline-secondary" onClick={() => setIsOpen(false)}>Custom Footer Button</button>
|
|
337
|
+
</Modal.Footer>
|
|
338
|
+
</Modal>
|
|
339
|
+
</>
|
|
340
|
+
);
|
|
341
|
+
},
|
|
342
|
+
parameters: {
|
|
343
|
+
docs: {
|
|
344
|
+
description: {
|
|
345
|
+
story: 'Demonstrates the Compound Component usage pattern.',
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
287
351
|
/**
|
|
288
352
|
* Small size modal variant.
|
|
289
353
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState, useCallback, memo } from 'react';
|
|
1
|
+
import React, { useEffect, useRef, useState, useCallback, memo, forwardRef, ReactNode } from 'react';
|
|
2
2
|
import { ModalProps } from '../../lib/types/components';
|
|
3
3
|
import { MODAL } from '../../lib/constants/components';
|
|
4
4
|
import { AtomixGlass } from '../AtomixGlass/AtomixGlass';
|
|
@@ -73,10 +73,83 @@ function useModal({
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Modal Subcomponents
|
|
77
|
+
|
|
78
|
+
export interface ModalHeaderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
79
|
+
title?: ReactNode;
|
|
80
|
+
subtitle?: ReactNode;
|
|
81
|
+
closeButton?: boolean;
|
|
82
|
+
onClose?: () => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(
|
|
86
|
+
({ title, subtitle, closeButton, onClose, children, className = '', ...props }, ref) => {
|
|
87
|
+
return (
|
|
88
|
+
<div ref={ref} className={`c-modal__header ${className}`.trim()} {...props}>
|
|
89
|
+
<div className="c-modal__header-content">
|
|
90
|
+
{title && <h3 className="c-modal__title">{title}</h3>}
|
|
91
|
+
{subtitle && <p className="c-modal__sub">{subtitle}</p>}
|
|
92
|
+
{children}
|
|
93
|
+
</div>
|
|
94
|
+
{closeButton && (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
className="c-modal__close c-btn js-modal-close"
|
|
98
|
+
onClick={onClose}
|
|
99
|
+
aria-label="Close modal"
|
|
100
|
+
>
|
|
101
|
+
<svg
|
|
102
|
+
width="20"
|
|
103
|
+
height="20"
|
|
104
|
+
viewBox="0 0 20 20"
|
|
105
|
+
fill="none"
|
|
106
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
107
|
+
>
|
|
108
|
+
<path
|
|
109
|
+
d="M16.0672 15.1828C16.1253 15.2409 16.1713 15.3098 16.2028 15.3857C16.2342 15.4615 16.2504 15.5429 16.2504 15.625C16.2504 15.7071 16.2342 15.7884 16.2028 15.8643C16.1713 15.9402 16.1253 16.0091 16.0672 16.0672C16.0091 16.1252 15.9402 16.1713 15.8643 16.2027C15.7885 16.2342 15.7071 16.2503 15.625 16.2503C15.5429 16.2503 15.4616 16.2342 15.3857 16.2027C15.3098 16.1713 15.2409 16.1252 15.1828 16.0672L10 10.8836L4.8172 16.0672C4.69992 16.1844 4.54086 16.2503 4.37501 16.2503C4.20916 16.2503 4.0501 16.1844 3.93282 16.0672C3.81555 15.9499 3.74966 15.7908 3.74966 15.625C3.74966 15.4591 3.81555 15.3001 3.93282 15.1828L9.11642 9.99998L3.93282 4.81717C3.81555 4.69989 3.74966 4.54083 3.74966 4.37498C3.74966 4.20913 3.81555 4.05007 3.93282 3.93279C4.0501 3.81552 4.20916 3.74963 4.37501 3.74963C4.54086 3.74963 4.69992 3.81552 4.8172 3.93279L10 9.11639L15.1828 3.93279C15.3001 3.81552 15.4592 3.74963 15.625 3.74963C15.7909 3.74963 15.9499 3.81552 16.0672 3.93279C16.1845 4.05007 16.2504 4.20913 16.2504 4.37498C16.2504 4.54083 16.1845 4.69989 16.0672 4.81717L10.8836 9.99998L16.0672 15.1828Z"
|
|
110
|
+
fill="#141414"
|
|
111
|
+
/>
|
|
112
|
+
</svg>
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
ModalHeader.displayName = 'ModalHeader';
|
|
120
|
+
|
|
121
|
+
export const ModalBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
122
|
+
({ children, className = '', ...props }, ref) => {
|
|
123
|
+
return (
|
|
124
|
+
<div ref={ref} className={`c-modal__body ${className}`.trim()} {...props}>
|
|
125
|
+
{children}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
ModalBody.displayName = 'ModalBody';
|
|
131
|
+
|
|
132
|
+
export const ModalFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
133
|
+
({ children, className = '', ...props }, ref) => {
|
|
134
|
+
return (
|
|
135
|
+
<div ref={ref} className={`c-modal__footer ${className}`.trim()} {...props}>
|
|
136
|
+
{children}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
ModalFooter.displayName = 'ModalFooter';
|
|
142
|
+
|
|
76
143
|
/**
|
|
77
144
|
* Modal component for displaying overlay content
|
|
78
145
|
*/
|
|
79
|
-
|
|
146
|
+
type ModalComponent = React.FC<ModalProps> & {
|
|
147
|
+
Header: typeof ModalHeader;
|
|
148
|
+
Body: typeof ModalBody;
|
|
149
|
+
Footer: typeof ModalFooter;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const ModalImpl = memo(
|
|
80
153
|
({
|
|
81
154
|
children,
|
|
82
155
|
isOpen = false,
|
|
@@ -94,7 +167,7 @@ export const Modal: React.FC<ModalProps> = memo(
|
|
|
94
167
|
footer,
|
|
95
168
|
glass,
|
|
96
169
|
...props
|
|
97
|
-
}) => {
|
|
170
|
+
}: ModalProps) => {
|
|
98
171
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
99
172
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
100
173
|
const backdropRef = useRef<HTMLDivElement>(null);
|
|
@@ -144,41 +217,40 @@ export const Modal: React.FC<ModalProps> = memo(
|
|
|
144
217
|
.filter(Boolean)
|
|
145
218
|
.join(' ');
|
|
146
219
|
|
|
220
|
+
// Check for compound components usage
|
|
221
|
+
const hasCompoundComponents = React.Children.toArray(children).some((child) =>
|
|
222
|
+
React.isValidElement(child) &&
|
|
223
|
+
['ModalHeader', 'ModalBody', 'ModalFooter'].includes((child.type as any).displayName)
|
|
224
|
+
);
|
|
225
|
+
|
|
147
226
|
const modalContent = (
|
|
148
227
|
<div className="c-modal__content">
|
|
149
|
-
{
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
d="M16.0672 15.1828C16.1253 15.2409 16.1713 15.3098 16.2028 15.3857C16.2342 15.4615 16.2504 15.5429 16.2504 15.625C16.2504 15.7071 16.2342 15.7884 16.2028 15.8643C16.1713 15.9402 16.1253 16.0091 16.0672 16.0672C16.0091 16.1252 15.9402 16.1713 15.8643 16.2027C15.7885 16.2342 15.7071 16.2503 15.625 16.2503C15.5429 16.2503 15.4616 16.2342 15.3857 16.2027C15.3098 16.1713 15.2409 16.1252 15.1828 16.0672L10 10.8836L4.8172 16.0672C4.69992 16.1844 4.54086 16.2503 4.37501 16.2503C4.20916 16.2503 4.0501 16.1844 3.93282 16.0672C3.81555 15.9499 3.74966 15.7908 3.74966 15.625C3.74966 15.4591 3.81555 15.3001 3.93282 15.1828L9.11642 9.99998L3.93282 4.81717C3.81555 4.69989 3.74966 4.54083 3.74966 4.37498C3.74966 4.20913 3.81555 4.05007 3.93282 3.93279C4.0501 3.81552 4.20916 3.74963 4.37501 3.74963C4.54086 3.74963 4.69992 3.81552 4.8172 3.93279L10 9.11639L15.1828 3.93279C15.3001 3.81552 15.4592 3.74963 15.625 3.74963C15.7909 3.74963 15.9499 3.81552 16.0672 3.93279C16.1845 4.05007 16.2504 4.20913 16.2504 4.37498C16.2504 4.54083 16.1845 4.69989 16.0672 4.81717L10.8836 9.99998L16.0672 15.1828Z"
|
|
171
|
-
fill="#141414"
|
|
172
|
-
/>
|
|
173
|
-
</svg>
|
|
174
|
-
</button>
|
|
228
|
+
{hasCompoundComponents ? (
|
|
229
|
+
React.Children.map(children, child => {
|
|
230
|
+
if (
|
|
231
|
+
React.isValidElement(child) &&
|
|
232
|
+
(child.type as any).displayName === 'ModalHeader'
|
|
233
|
+
) {
|
|
234
|
+
return React.cloneElement(child, {
|
|
235
|
+
onClose: (child.props as any).onClose || close,
|
|
236
|
+
} as any);
|
|
237
|
+
}
|
|
238
|
+
return child;
|
|
239
|
+
})
|
|
240
|
+
) : (
|
|
241
|
+
<>
|
|
242
|
+
{(title || closeButton) && (
|
|
243
|
+
<ModalHeader
|
|
244
|
+
title={title}
|
|
245
|
+
subtitle={subtitle}
|
|
246
|
+
closeButton={closeButton}
|
|
247
|
+
onClose={close}
|
|
248
|
+
/>
|
|
175
249
|
)}
|
|
176
|
-
|
|
250
|
+
<ModalBody>{children}</ModalBody>
|
|
251
|
+
{footer && <ModalFooter>{footer}</ModalFooter>}
|
|
252
|
+
</>
|
|
177
253
|
)}
|
|
178
|
-
|
|
179
|
-
<div className="c-modal__body">{children}</div>
|
|
180
|
-
|
|
181
|
-
{footer && <div className="c-modal__footer">{footer}</div>}
|
|
182
254
|
</div>
|
|
183
255
|
);
|
|
184
256
|
|
|
@@ -218,7 +290,15 @@ export const Modal: React.FC<ModalProps> = memo(
|
|
|
218
290
|
}
|
|
219
291
|
);
|
|
220
292
|
|
|
221
|
-
|
|
293
|
+
ModalImpl.displayName = 'Modal';
|
|
294
|
+
|
|
295
|
+
// Attach subcomponents
|
|
296
|
+
const ModalWithSubcomponents = ModalImpl as unknown as ModalComponent;
|
|
297
|
+
ModalWithSubcomponents.Header = ModalHeader;
|
|
298
|
+
ModalWithSubcomponents.Body = ModalBody;
|
|
299
|
+
ModalWithSubcomponents.Footer = ModalFooter;
|
|
300
|
+
|
|
301
|
+
export const Modal = ModalWithSubcomponents;
|
|
222
302
|
|
|
223
303
|
export type { ModalProps };
|
|
224
304
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { Modal } from './Modal';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
describe('Modal Component', () => {
|
|
7
|
+
it('renders correctly with legacy props', () => {
|
|
8
|
+
render(
|
|
9
|
+
<Modal isOpen={true} title="Legacy Title" footer="Legacy Footer">
|
|
10
|
+
Legacy Content
|
|
11
|
+
</Modal>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
expect(screen.getByText('Legacy Title')).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText('Legacy Content')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('Legacy Footer')).toBeInTheDocument();
|
|
17
|
+
|
|
18
|
+
// Check structure classes
|
|
19
|
+
expect(document.querySelector('.c-modal__header')).toBeInTheDocument();
|
|
20
|
+
expect(document.querySelector('.c-modal__body')).toBeInTheDocument();
|
|
21
|
+
expect(document.querySelector('.c-modal__footer')).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders correctly with compound components', () => {
|
|
25
|
+
render(
|
|
26
|
+
<Modal isOpen={true}>
|
|
27
|
+
<Modal.Header title="Compound Header" />
|
|
28
|
+
<Modal.Body>Compound Body</Modal.Body>
|
|
29
|
+
<Modal.Footer>Compound Footer</Modal.Footer>
|
|
30
|
+
</Modal>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(screen.getByText('Compound Header')).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByText('Compound Body')).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByText('Compound Footer')).toBeInTheDocument();
|
|
36
|
+
|
|
37
|
+
// Verify no double wrapping
|
|
38
|
+
// If double wrapping occurred, we might see nested .c-modal__body or similar issues,
|
|
39
|
+
// or the header inside the body if logic failed.
|
|
40
|
+
|
|
41
|
+
const header = document.querySelector('.c-modal__header');
|
|
42
|
+
const body = document.querySelector('.c-modal__body');
|
|
43
|
+
const footer = document.querySelector('.c-modal__footer');
|
|
44
|
+
|
|
45
|
+
// Header should be a direct child of .c-modal__content (or close to it)
|
|
46
|
+
expect(header?.parentElement).toHaveClass('c-modal__content');
|
|
47
|
+
expect(body?.parentElement).toHaveClass('c-modal__content');
|
|
48
|
+
expect(footer?.parentElement).toHaveClass('c-modal__content');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('injects onClose into Modal.Header when used in compound mode', () => {
|
|
52
|
+
const onClose = vi.fn();
|
|
53
|
+
render(
|
|
54
|
+
<Modal isOpen={true} onClose={onClose}>
|
|
55
|
+
<Modal.Header closeButton data-testid="header" />
|
|
56
|
+
<Modal.Body>Content</Modal.Body>
|
|
57
|
+
</Modal>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const closeBtn = screen.getByLabelText('Close modal');
|
|
61
|
+
fireEvent.click(closeBtn);
|
|
62
|
+
expect(onClose).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('allows custom onClose in Modal.Header', () => {
|
|
66
|
+
const modalOnClose = vi.fn();
|
|
67
|
+
const headerOnClose = vi.fn();
|
|
68
|
+
|
|
69
|
+
render(
|
|
70
|
+
<Modal isOpen={true} onClose={modalOnClose}>
|
|
71
|
+
<Modal.Header closeButton onClose={headerOnClose} />
|
|
72
|
+
<Modal.Body>Content</Modal.Body>
|
|
73
|
+
</Modal>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const closeBtn = screen.getByLabelText('Close modal');
|
|
77
|
+
fireEvent.click(closeBtn);
|
|
78
|
+
|
|
79
|
+
expect(headerOnClose).toHaveBeenCalled();
|
|
80
|
+
expect(modalOnClose).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('prioritizes compound components over legacy props', () => {
|
|
84
|
+
render(
|
|
85
|
+
<Modal isOpen={true} title="Legacy Title">
|
|
86
|
+
<Modal.Header title="Compound Header" />
|
|
87
|
+
<Modal.Body>Compound Body</Modal.Body>
|
|
88
|
+
</Modal>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByText('Compound Header')).toBeInTheDocument();
|
|
92
|
+
expect(screen.queryByText('Legacy Title')).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|