@popsure/dirty-swan 0.66.23 → 0.66.25

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 (47) hide show
  1. package/dist/cjs/index.d.ts +1 -1
  2. package/dist/cjs/index.js +42 -5
  3. package/dist/cjs/index.js.map +1 -1
  4. package/dist/cjs/lib/context.d.ts +13 -0
  5. package/dist/cjs/lib/context.test.d.ts +1 -0
  6. package/dist/cjs/lib/index.d.ts +2 -1
  7. package/dist/esm/components/modal/bottomModal/index.js +1 -0
  8. package/dist/esm/components/modal/bottomModal/index.js.map +1 -1
  9. package/dist/esm/components/modal/bottomOrRegularModal/index.js +1 -0
  10. package/dist/esm/components/modal/bottomOrRegularModal/index.js.map +1 -1
  11. package/dist/esm/components/modal/fullScreenModal/index.js +1 -0
  12. package/dist/esm/components/modal/fullScreenModal/index.js.map +1 -1
  13. package/dist/esm/components/modal/genericModal/index.js +2 -0
  14. package/dist/esm/components/modal/genericModal/index.js.map +1 -1
  15. package/dist/esm/components/modal/index.stories.js +1 -0
  16. package/dist/esm/components/modal/index.stories.js.map +1 -1
  17. package/dist/esm/components/modal/regularModal/index.js +1 -0
  18. package/dist/esm/components/modal/regularModal/index.js.map +1 -1
  19. package/dist/esm/components/segmentedControl/index.js +4 -5
  20. package/dist/esm/components/segmentedControl/index.js.map +1 -1
  21. package/dist/esm/components/table/Table.js +1 -0
  22. package/dist/esm/components/table/Table.js.map +1 -1
  23. package/dist/esm/components/table/Table.stories.js +1 -0
  24. package/dist/esm/components/table/Table.stories.js.map +1 -1
  25. package/dist/esm/components/table/Table.test.js +1 -0
  26. package/dist/esm/components/table/Table.test.js.map +1 -1
  27. package/dist/esm/context.js +39 -0
  28. package/dist/esm/context.js.map +1 -0
  29. package/dist/esm/context.stories.js +67 -0
  30. package/dist/esm/context.stories.js.map +1 -0
  31. package/dist/esm/context.test.js +58 -0
  32. package/dist/esm/context.test.js.map +1 -0
  33. package/dist/esm/index.d.ts +1 -1
  34. package/dist/esm/index.js +1 -0
  35. package/dist/esm/index.js.map +1 -1
  36. package/dist/esm/lib/context.d.ts +13 -0
  37. package/dist/esm/lib/context.test.d.ts +1 -0
  38. package/dist/esm/lib/index.d.ts +2 -1
  39. package/package.json +1 -1
  40. package/src/index.tsx +3 -0
  41. package/src/lib/components/modal/genericModal/index.tsx +2 -0
  42. package/src/lib/components/segmentedControl/index.tsx +5 -11
  43. package/src/lib/components/segmentedControl/style.module.scss +7 -3
  44. package/src/lib/context.stories.tsx +136 -0
  45. package/src/lib/context.test.tsx +88 -0
  46. package/src/lib/context.tsx +60 -0
  47. package/src/lib/index.tsx +4 -0
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../../../src/lib/hooks/useFocusWithin.ts"],"sourcesContent":["import { useEffect } from 'react';\n\nexport const useFocusWithin = (\n ref: HTMLElement | null,\n callback: (isFocusWithin: boolean) => void\n) => {\n useEffect(() => {\n const handleOnFocusIn = () => {\n if (!ref) {\n return;\n }\n\n const hasFocus = ref?.contains(document.activeElement);\n\n callback(Boolean(hasFocus));\n };\n\n document.addEventListener('focusin', handleOnFocusIn);\n\n return () => document?.removeEventListener('focusin', handleOnFocusIn);\n }, [callback, ref]);\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAEO,IAAM,cAAc,GAAG,UAC5B,GAAuB,EACvB,QAA0C,EAAA;AAE1C,IAAA,SAAS,CAAC,YAAA;AACR,QAAA,IAAM,eAAe,GAAG,YAAA;YACtB,IAAI,CAAC,GAAG,EAAE;gBACR;YACF;AAEA,YAAA,IAAM,QAAQ,GAAG,GAAG,KAAA,IAAA,IAAH,GAAG,KAAA,MAAA,GAAA,MAAA,GAAH,GAAG,CAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC;AAEtD,YAAA,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;AAC7B,QAAA,CAAC;AAED,QAAA,QAAQ,CAAC,gBAAgB,CAAC,SAAS,EAAE,eAAe,CAAC;AAErD,QAAA,OAAO,cAAM,OAAA,QAAQ,KAAA,IAAA,IAAR,QAAQ,uBAAR,QAAQ,CAAE,mBAAmB,CAAC,SAAS,EAAE,eAAe,CAAC,CAAA,CAAzD,CAAyD;AACxE,IAAA,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;AACrB;;;;"}
1
+ {"version":3,"file":"index.js","sources":["../../../src/lib/hooks/useFocusWithin.ts"],"sourcesContent":["import { useEffect } from 'react';\n\nexport const useFocusWithin = (\n ref: HTMLElement | null,\n callback: (isFocusWithin: boolean) => void\n) => {\n useEffect(() => {\n const handleOnFocusIn = () => {\n if (!ref) {\n return;\n }\n\n const hasFocus = ref?.contains(document.activeElement);\n\n callback(Boolean(hasFocus));\n };\n\n document.addEventListener('focusin', handleOnFocusIn);\n\n return () => document?.removeEventListener('focusin', handleOnFocusIn);\n }, [callback, ref]);\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAEO,IAAM,cAAc,GAAG,UAC5B,GAAuB,EACvB,QAA0C,EAAA;AAE1C,IAAA,SAAS,CAAC,YAAA;AACR,QAAA,IAAM,eAAe,GAAG,YAAA;YACtB,IAAI,CAAC,GAAG,EAAE;gBACR;YACF;AAEA,YAAA,IAAM,QAAQ,GAAG,GAAG,KAAA,IAAA,IAAH,GAAG,KAAA,MAAA,GAAA,MAAA,GAAH,GAAG,CAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC;AAEtD,YAAA,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;AAC7B,QAAA,CAAC;AAED,QAAA,QAAQ,CAAC,gBAAgB,CAAC,SAAS,EAAE,eAAe,CAAC;AAErD,QAAA,OAAO,cAAM,OAAA,QAAQ,KAAA,IAAA,IAAR,QAAQ,uBAAR,QAAQ,CAAE,mBAAmB,CAAC,SAAS,EAAE,eAAe,CAAC,CAAA,CAAzD,CAAyD;AACxE,IAAA,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;AACrB;;;;"}
@@ -0,0 +1,13 @@
1
+ import { ReactNode } from 'react';
2
+ interface DirtySwanContextValue {
3
+ onModalOpen: () => void;
4
+ onModalClose: () => void;
5
+ }
6
+ interface DirtySwanProviderProps {
7
+ children: ReactNode;
8
+ onModalChange?: (isModalOpen: boolean) => void;
9
+ }
10
+ export declare const DirtySwanProvider: ({ children, onModalChange, }: DirtySwanProviderProps) => JSX.Element;
11
+ export declare const useDirtySwan: () => DirtySwanContextValue | null;
12
+ export declare const useModalTrack: (isVisible: boolean) => void;
13
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -41,7 +41,8 @@ import { LogoInvertedColor } from './components/logo/LogoInvertedColor';
41
41
  import { LogoInverted } from './components/logo/LogoInverted';
42
42
  import { LogoPositiveColor } from './components/logo/LogoPositiveColor';
43
43
  import { LogoPositiveBlack } from './components/logo/LogoPositiveBlack';
44
+ import { DirtySwanProvider, useDirtySwan, useModalTrack } from './context';
44
45
  export * from './components/icon';
45
- export { Accordion, DateSelector, SignaturePad, AutocompleteAddress, Input, MultiDropzone, DownloadButton, IbanInput, BottomModal, RegularModal, BottomOrRegularModal, FullScreenModal, InfoCard, Card, CardButton, Button, CurrencyInput, AutoSuggestMultiSelect, Chip, AutoSuggestInput, ComparisonTable, TableRating, TableTrueFalse, TableRowHeader, TableButton, TableInfoButton, SearchableDropdown, SegmentedControl, Checkbox, Radio, Link, InformationBox, Badge, images, illustrations, Spinner, Table, Tabs, Toggle, Toaster, toast, useEscapeKey, useFocusWithin, useOnClickOutside, useMediaQuery, LogoColor, LogoWhite, LogoBlack, LogoOrangeBox, LogoInvertedColor, LogoInverted, LogoPositiveColor, LogoPositiveBlack, };
46
+ export { Accordion, DateSelector, SignaturePad, AutocompleteAddress, Input, MultiDropzone, DownloadButton, IbanInput, BottomModal, RegularModal, BottomOrRegularModal, FullScreenModal, InfoCard, Card, CardButton, Button, CurrencyInput, AutoSuggestMultiSelect, Chip, AutoSuggestInput, ComparisonTable, TableRating, TableTrueFalse, TableRowHeader, TableButton, TableInfoButton, SearchableDropdown, SegmentedControl, Checkbox, Radio, Link, InformationBox, Badge, images, illustrations, Spinner, Table, Tabs, Toggle, Toaster, toast, useEscapeKey, useFocusWithin, useOnClickOutside, useMediaQuery, LogoColor, LogoWhite, LogoBlack, LogoOrangeBox, LogoInvertedColor, LogoInverted, LogoPositiveColor, LogoPositiveBlack, DirtySwanProvider, useDirtySwan, useModalTrack, };
46
47
  export type { AccordionProps, SearchableDropdownProps, SearchableDropdownOption, BadgeProps, IllustrationKeys, InformationBoxProps, InputProps, FileType, MultiDropzoneProps, TableHeader, UploadedFile, UploadStatus, CardProps, IconWrapperProps, TabItem, TabsProps, TableData, TableProps, };
47
48
  export type { DownloadStatus } from './models/download';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popsure/dirty-swan",
3
- "version": "0.66.23",
3
+ "version": "0.66.25",
4
4
  "author": "Vincent Audoire <vincent@getpopsure.com>",
5
5
  "license": "MIT",
6
6
  "private": false,
package/src/index.tsx CHANGED
@@ -54,6 +54,9 @@ export {
54
54
  LogoPositiveColor,
55
55
  LogoPositiveBlack,
56
56
  SearchableDropdown,
57
+ DirtySwanProvider,
58
+ useDirtySwan,
59
+ useModalTrack,
57
60
  } from './lib';
58
61
 
59
62
  export * from './lib/components/icon';
@@ -1,5 +1,6 @@
1
1
  import { Props } from '..';
2
2
  import useOnClose, { OnCloseReturn } from '../hooks/useOnClose';
3
+ import { useModalTrack } from '../../../context';
3
4
 
4
5
  import styles from './style.module.scss';
5
6
  import classNamesUtil from 'classnames';
@@ -156,6 +157,7 @@ const InnerModal = ({
156
157
  export const GenericModal = (props: GenericModalProps) => {
157
158
  const { isOpen, onClose, dismissible = true } = props;
158
159
  const { isVisible, ...rest } = useOnClose(onClose, isOpen, dismissible);
160
+ useModalTrack(isVisible);
159
161
 
160
162
  if (!isVisible) {
161
163
  return null;
@@ -45,6 +45,8 @@ const SegmentedControl = ({
45
45
  style={{ height: `${height}px` }}
46
46
  >
47
47
  {values.map((value: TitleWithSubtitle | string, index: number) => {
48
+ const selectedColor = selectedIndex === index ? 'tc-white' : 'tc-neutral-900';
49
+
48
50
  return (
49
51
  <div
50
52
  role="button"
@@ -62,27 +64,19 @@ const SegmentedControl = ({
62
64
  >
63
65
  {typeof value === 'string' ? (
64
66
  <div
65
- className={`p-h4 ta-center ${
66
- selectedIndex === index ? '' : 'tc-purple-600'
67
- }`}
67
+ className={`p-h4 ta-center ${selectedColor} ${styles['text-color']}`}
68
68
  >
69
69
  {value}
70
70
  </div>
71
71
  ) : (
72
72
  <>
73
73
  <div
74
- className={`p-h4 ta-center ${
75
- selectedIndex === index ? '' : 'tc-purple-600'
76
- }`}
74
+ className={`p-h4 ta-center ${selectedColor} ${styles['text-color']}`}
77
75
  >
78
76
  {value.title}
79
77
  </div>
80
78
  <div
81
- className={`p-p--small ta-center ${
82
- selectedIndex === index
83
- ? 'tc-neutral-600'
84
- : 'tc-purple-600'
85
- }`}
79
+ className={`p-p--small ta-center ${selectedColor} ${styles['text-color']}`}
86
80
  >
87
81
  {value.subtitle}
88
82
  </div>
@@ -2,7 +2,7 @@
2
2
  position: relative;
3
3
  display: inline-block;
4
4
 
5
- background-color: var(--ds-purple-100);
5
+ background-color: var(--ds-orange-50);
6
6
  border-radius: 8px;
7
7
 
8
8
  padding: 0 8px;
@@ -22,7 +22,7 @@
22
22
  padding-right: 16px;
23
23
 
24
24
  &:focus {
25
- outline-color: var(--ds-purple-600);
25
+ outline-color: var(--ds-primary-900);
26
26
  outline-offset: 2px;
27
27
  }
28
28
  }
@@ -30,7 +30,7 @@
30
30
  .select-chip-background {
31
31
  position: absolute;
32
32
 
33
- background-color: white;
33
+ background-color: var(--ds-neutral-900);
34
34
  border-radius: 8px;
35
35
 
36
36
  top: 8px;
@@ -39,3 +39,7 @@
39
39
 
40
40
  transition: width 0.3s, left 0.3s;
41
41
  }
42
+
43
+ .text-color {
44
+ transition: color 0.15s ease-in;
45
+ }
@@ -0,0 +1,136 @@
1
+ import { useState } from 'react';
2
+ import { DirtySwanProvider } from './context';
3
+ import { BottomOrRegularModal } from './components/modal';
4
+ import { Button } from './components/button';
5
+
6
+ const story = {
7
+ title: 'JSX/DirtySwanProvider',
8
+ component: DirtySwanProvider,
9
+ argTypes: {
10
+ onModalChange: {
11
+ description:
12
+ 'Callback fired when the modal open state changes. Receives `true` when the first modal opens and `false` when the last modal closes.',
13
+ action: true,
14
+ table: {
15
+ category: 'Callbacks',
16
+ },
17
+ },
18
+ },
19
+ parameters: {
20
+ docs: {
21
+ description: {
22
+ component:
23
+ 'An optional context provider that notifies consumers when any modal is open or closed. It tracks nested modals using a counter — `onModalChange(true)` fires when the first modal opens, and `onModalChange(false)` only fires when the last one closes.',
24
+ },
25
+ },
26
+ },
27
+ };
28
+
29
+ export const SingleModal = {
30
+ render: ({ onModalChange }: { onModalChange: (isOpen: boolean) => void }) => {
31
+ const [isOpen, setIsOpen] = useState(false);
32
+ const [isModalOpen, setIsModalOpen] = useState(false);
33
+
34
+ const handleModalChange = (open: boolean) => {
35
+ setIsModalOpen(open);
36
+ onModalChange(open);
37
+ };
38
+
39
+ return (
40
+ <DirtySwanProvider onModalChange={handleModalChange}>
41
+ <div className="d-flex fd-column gap16">
42
+ <div>
43
+ Modal state: <strong>{isModalOpen ? 'open' : 'closed'}</strong>
44
+ </div>
45
+ <Button className="wmn2" onClick={() => setIsOpen(true)}>
46
+ Open modal
47
+ </Button>
48
+ </div>
49
+
50
+ <BottomOrRegularModal
51
+ title="Single modal"
52
+ isOpen={isOpen}
53
+ onClose={() => setIsOpen(false)}
54
+ >
55
+ <div style={{ padding: '0 24px 24px 24px' }}>
56
+ Opening this modal triggered onModalChange(true). Closing it will
57
+ trigger onModalChange(false).
58
+ <Button className="mt24 wmn3" onClick={() => setIsOpen(false)}>
59
+ Close
60
+ </Button>
61
+ </div>
62
+ </BottomOrRegularModal>
63
+ </DirtySwanProvider>
64
+ );
65
+ },
66
+ name: 'Single modal',
67
+ };
68
+
69
+ export const NestedModals = {
70
+ render: ({ onModalChange }: { onModalChange: (isOpen: boolean) => void }) => {
71
+ const [isFirstOpen, setIsFirstOpen] = useState(false);
72
+ const [isSecondOpen, setIsSecondOpen] = useState(false);
73
+ const [isModalOpen, setIsModalOpen] = useState(false);
74
+
75
+ const handleModalChange = (open: boolean) => {
76
+ setIsModalOpen(open);
77
+ onModalChange(open);
78
+ };
79
+
80
+ return (
81
+ <DirtySwanProvider onModalChange={handleModalChange}>
82
+ <div className="d-flex fd-column gap16">
83
+ <div>
84
+ Modal state: <strong>{isModalOpen ? 'open' : 'closed'}</strong>
85
+ </div>
86
+ <Button className="wmn2" onClick={() => setIsFirstOpen(true)}>
87
+ Open first modal
88
+ </Button>
89
+ </div>
90
+
91
+ <BottomOrRegularModal
92
+ title="First modal"
93
+ isOpen={isFirstOpen}
94
+ onClose={() => setIsFirstOpen(false)}
95
+ >
96
+ <div style={{ padding: '0 24px 24px 24px' }}>
97
+ This is the first modal. Open a second one to see that
98
+ onModalChange(false) only fires when both are closed.
99
+ <div className="d-flex fd-row gap8 mt24">
100
+ <Button className="wmn3" onClick={() => setIsSecondOpen(true)}>
101
+ Open second modal
102
+ </Button>
103
+ <Button
104
+ variant="textBlack"
105
+ className="wmn3"
106
+ onClick={() => setIsFirstOpen(false)}
107
+ >
108
+ Close
109
+ </Button>
110
+ </div>
111
+ </div>
112
+ </BottomOrRegularModal>
113
+
114
+ <BottomOrRegularModal
115
+ title="Second modal"
116
+ isOpen={isSecondOpen}
117
+ onClose={() => setIsSecondOpen(false)}
118
+ >
119
+ <div style={{ padding: '0 24px 24px 24px' }}>
120
+ This is the second (nested) modal. Closing this one will not trigger
121
+ onModalChange(false) because the first modal is still open.
122
+ <Button
123
+ className="mt24 wmn3"
124
+ onClick={() => setIsSecondOpen(false)}
125
+ >
126
+ Close
127
+ </Button>
128
+ </div>
129
+ </BottomOrRegularModal>
130
+ </DirtySwanProvider>
131
+ );
132
+ },
133
+ name: 'Nested modals',
134
+ };
135
+
136
+ export default story;
@@ -0,0 +1,88 @@
1
+ import { render, screen, fireEvent } from './util/testUtils';
2
+ import { DirtySwanProvider, useDirtySwan } from './context';
3
+
4
+ const TestConsumer = () => {
5
+ const ctx = useDirtySwan();
6
+
7
+ return (
8
+ <div>
9
+ <button onClick={() => ctx?.onModalOpen()}>open</button>
10
+ <button onClick={() => ctx?.onModalClose()}>close</button>
11
+ </div>
12
+ );
13
+ };
14
+
15
+ describe('DirtySwanProvider', () => {
16
+ it('calls onModalChange(true) when first modal opens', () => {
17
+ const onModalChange = jest.fn();
18
+ render(
19
+ <DirtySwanProvider onModalChange={onModalChange}>
20
+ <TestConsumer />
21
+ </DirtySwanProvider>
22
+ );
23
+
24
+ fireEvent.click(screen.getByText('open'));
25
+
26
+ expect(onModalChange).toHaveBeenCalledWith(true);
27
+ });
28
+
29
+ it('calls onModalChange(false) when last modal closes', () => {
30
+ const onModalChange = jest.fn();
31
+ render(
32
+ <DirtySwanProvider onModalChange={onModalChange}>
33
+ <TestConsumer />
34
+ </DirtySwanProvider>
35
+ );
36
+
37
+ fireEvent.click(screen.getByText('open'));
38
+ fireEvent.click(screen.getByText('close'));
39
+
40
+ expect(onModalChange).toHaveBeenLastCalledWith(false);
41
+ });
42
+
43
+ it('does not call onModalChange(false) when nested modals are still open', () => {
44
+ const onModalChange = jest.fn();
45
+ render(
46
+ <DirtySwanProvider onModalChange={onModalChange}>
47
+ <TestConsumer />
48
+ </DirtySwanProvider>
49
+ );
50
+
51
+ fireEvent.click(screen.getByText('open'));
52
+ fireEvent.click(screen.getByText('open'));
53
+ fireEvent.click(screen.getByText('close'));
54
+
55
+ expect(onModalChange).toHaveBeenCalledTimes(1);
56
+ expect(onModalChange).toHaveBeenCalledWith(true);
57
+ });
58
+
59
+ it('calls onModalChange(false) when all nested modals close', () => {
60
+ const onModalChange = jest.fn();
61
+ render(
62
+ <DirtySwanProvider onModalChange={onModalChange}>
63
+ <TestConsumer />
64
+ </DirtySwanProvider>
65
+ );
66
+
67
+ fireEvent.click(screen.getByText('open'));
68
+ fireEvent.click(screen.getByText('open'));
69
+ fireEvent.click(screen.getByText('close'));
70
+ fireEvent.click(screen.getByText('close'));
71
+
72
+ expect(onModalChange).toHaveBeenCalledTimes(2);
73
+ expect(onModalChange).toHaveBeenLastCalledWith(false);
74
+ });
75
+
76
+ it('returns null when used outside provider', () => {
77
+ let contextValue: ReturnType<typeof useDirtySwan> = undefined as any;
78
+
79
+ const Spy = () => {
80
+ contextValue = useDirtySwan();
81
+ return null;
82
+ };
83
+
84
+ render(<Spy />);
85
+
86
+ expect(contextValue).toBeNull();
87
+ });
88
+ });
@@ -0,0 +1,60 @@
1
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
2
+
3
+ interface DirtySwanContextValue {
4
+ onModalOpen: () => void;
5
+ onModalClose: () => void;
6
+ }
7
+
8
+ const DirtySwanContext = createContext<DirtySwanContextValue | null>(null);
9
+
10
+ interface DirtySwanProviderProps {
11
+ children: ReactNode;
12
+ onModalChange?: (isModalOpen: boolean) => void;
13
+ }
14
+
15
+ export const DirtySwanProvider = ({
16
+ children,
17
+ onModalChange,
18
+ }: DirtySwanProviderProps) => {
19
+ const countRef = useRef(0);
20
+ const onModalChangeRef = useRef(onModalChange);
21
+ onModalChangeRef.current = onModalChange;
22
+
23
+ const onModalOpen = useCallback(() => {
24
+ countRef.current += 1;
25
+ if (countRef.current === 1) {
26
+ onModalChangeRef.current?.(true);
27
+ }
28
+ }, []);
29
+
30
+ const onModalClose = useCallback(() => {
31
+ countRef.current -= 1;
32
+ if (countRef.current === 0) {
33
+ onModalChangeRef.current?.(false);
34
+ }
35
+ }, []);
36
+
37
+ const value = useMemo(
38
+ () => ({ onModalOpen, onModalClose }),
39
+ [onModalOpen, onModalClose]
40
+ );
41
+
42
+ return (
43
+ <DirtySwanContext.Provider value={value}>
44
+ {children}
45
+ </DirtySwanContext.Provider>
46
+ );
47
+ };
48
+
49
+ export const useDirtySwan = () => useContext(DirtySwanContext);
50
+
51
+ export const useModalTrack = (isVisible: boolean) => {
52
+ const dirtySwan = useDirtySwan();
53
+
54
+ useEffect(() => {
55
+ if (!isVisible) return;
56
+
57
+ dirtySwan?.onModalOpen();
58
+ return () => dirtySwan?.onModalClose();
59
+ }, [isVisible, dirtySwan]);
60
+ };
package/src/lib/index.tsx CHANGED
@@ -67,6 +67,7 @@ import { LogoInvertedColor } from './components/logo/LogoInvertedColor';
67
67
  import { LogoInverted } from './components/logo/LogoInverted';
68
68
  import { LogoPositiveColor } from './components/logo/LogoPositiveColor';
69
69
  import { LogoPositiveBlack } from './components/logo/LogoPositiveBlack';
70
+ import { DirtySwanProvider, useDirtySwan, useModalTrack } from './context';
70
71
 
71
72
  export * from './components/icon';
72
73
 
@@ -124,6 +125,9 @@ export {
124
125
  LogoInverted,
125
126
  LogoPositiveColor,
126
127
  LogoPositiveBlack,
128
+ DirtySwanProvider,
129
+ useDirtySwan,
130
+ useModalTrack,
127
131
  };
128
132
 
129
133
  export type {