@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,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
- export const Hero: React.FC<HeroProps> = ({
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 const List: React.FC<ListProps> = memo(
6
- ({ children, variant = 'default', className = '', style, ...props }) => {
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
- export const Modal: React.FC<ModalProps> = memo(
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
- {(title || closeButton) && (
150
- <div className="c-modal__header">
151
- <div className="c-modal__header-content">
152
- {title && <h3 className="c-modal__title">{title}</h3>}
153
- {subtitle && <p className="c-modal__sub">{subtitle}</p>}
154
- </div>
155
- {closeButton && (
156
- <button
157
- type="button"
158
- className="c-modal__close c-btn js-modal-close"
159
- onClick={close}
160
- aria-label="Close modal"
161
- >
162
- <svg
163
- width="20"
164
- height="20"
165
- viewBox="0 0 20 20"
166
- fill="none"
167
- xmlns="http://www.w3.org/2000/svg"
168
- >
169
- <path
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
- </div>
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
- Modal.displayName = 'Modal';
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
+ });