@popsure/dirty-swan 0.66.24 → 0.66.26
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/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +59 -5
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/lib/components/table/utils/useTableNavigation/useTableNavigation.d.ts +3 -1
- package/dist/cjs/lib/context.d.ts +13 -0
- package/dist/cjs/lib/context.test.d.ts +1 -0
- package/dist/cjs/lib/index.d.ts +2 -1
- package/dist/esm/components/modal/bottomModal/index.js +1 -0
- package/dist/esm/components/modal/bottomModal/index.js.map +1 -1
- package/dist/esm/components/modal/bottomOrRegularModal/index.js +1 -0
- package/dist/esm/components/modal/bottomOrRegularModal/index.js.map +1 -1
- package/dist/esm/components/modal/fullScreenModal/index.js +1 -0
- package/dist/esm/components/modal/fullScreenModal/index.js.map +1 -1
- package/dist/esm/components/modal/genericModal/index.js +2 -0
- package/dist/esm/components/modal/genericModal/index.js.map +1 -1
- package/dist/esm/components/modal/index.stories.js +1 -0
- package/dist/esm/components/modal/index.stories.js.map +1 -1
- package/dist/esm/components/modal/regularModal/index.js +1 -0
- package/dist/esm/components/modal/regularModal/index.js.map +1 -1
- package/dist/esm/components/table/Table.js +5 -3
- package/dist/esm/components/table/Table.js.map +1 -1
- package/dist/esm/components/table/Table.stories.js +2 -1
- package/dist/esm/components/table/Table.stories.js.map +1 -1
- package/dist/esm/components/table/Table.test.js +43 -3
- package/dist/esm/components/table/Table.test.js.map +1 -1
- package/dist/esm/components/table/utils/useTableNavigation/useTableNavigation.test.js +51 -1
- package/dist/esm/components/table/utils/useTableNavigation/useTableNavigation.test.js.map +1 -1
- package/dist/esm/context.js +39 -0
- package/dist/esm/context.js.map +1 -0
- package/dist/esm/context.stories.js +67 -0
- package/dist/esm/context.stories.js.map +1 -0
- package/dist/esm/context.test.js +58 -0
- package/dist/esm/context.test.js.map +1 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/lib/components/table/utils/useTableNavigation/useTableNavigation.d.ts +3 -1
- package/dist/esm/lib/context.d.ts +13 -0
- package/dist/esm/lib/context.test.d.ts +1 -0
- package/dist/esm/lib/index.d.ts +2 -1
- package/dist/esm/{useTableNavigation-CrxoXTVd.js → useTableNavigation-C-vb2BxI.js} +20 -5
- package/dist/esm/useTableNavigation-C-vb2BxI.js.map +1 -0
- package/package.json +1 -1
- package/src/index.tsx +3 -0
- package/src/lib/components/modal/genericModal/index.tsx +2 -0
- package/src/lib/components/table/Table.test.tsx +64 -0
- package/src/lib/components/table/Table.tsx +5 -5
- package/src/lib/components/table/utils/useTableNavigation/useTableNavigation.test.tsx +58 -0
- package/src/lib/components/table/utils/useTableNavigation/useTableNavigation.ts +25 -3
- package/src/lib/context.stories.tsx +136 -0
- package/src/lib/context.test.tsx +88 -0
- package/src/lib/context.tsx +60 -0
- package/src/lib/index.tsx +4 -0
- package/dist/esm/useTableNavigation-CrxoXTVd.js.map +0 -1
|
@@ -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;
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
1
2
|
import { render, screen } from '../../util/testUtils';
|
|
3
|
+
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
|
2
4
|
import { Table } from './Table';
|
|
3
5
|
import { TableSectionData } from './types';
|
|
4
6
|
|
|
7
|
+
jest.mock('../../hooks/useMediaQuery', () => ({
|
|
8
|
+
useMediaQuery: jest.fn(() => false),
|
|
9
|
+
}));
|
|
10
|
+
|
|
5
11
|
const tableData: TableSectionData[] = [
|
|
6
12
|
{
|
|
7
13
|
section: {
|
|
@@ -139,3 +145,61 @@ describe('Table', () => {
|
|
|
139
145
|
expect(screen.getByText('I am custom')).toBeInTheDocument();
|
|
140
146
|
});
|
|
141
147
|
});
|
|
148
|
+
|
|
149
|
+
describe('Table on mobile with a parent-controlled section', () => {
|
|
150
|
+
const planTable: TableSectionData[] = [
|
|
151
|
+
{
|
|
152
|
+
rows: [
|
|
153
|
+
[{ text: 'Choose your plan' }, { text: 'Basic' }, { text: 'Advanced' }],
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const ControlledTable = ({
|
|
159
|
+
onSelectionChanged,
|
|
160
|
+
}: {
|
|
161
|
+
onSelectionChanged: (index: number) => void;
|
|
162
|
+
}) => {
|
|
163
|
+
const [activeSection, setActiveSection] = useState(2);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<Table
|
|
167
|
+
title="Plans"
|
|
168
|
+
tableData={planTable}
|
|
169
|
+
activeSection={activeSection}
|
|
170
|
+
onSelectionChanged={(index) => {
|
|
171
|
+
onSelectionChanged(index);
|
|
172
|
+
setActiveSection(index);
|
|
173
|
+
}}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
beforeAll(() => {
|
|
179
|
+
HTMLElement.prototype.scroll = jest.fn();
|
|
180
|
+
(useMediaQuery as jest.Mock).mockReturnValue(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
afterAll(() => {
|
|
184
|
+
(useMediaQuery as jest.Mock).mockReturnValue(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('settles on the parent section instead of bouncing between columns', () => {
|
|
188
|
+
const reported: number[] = [];
|
|
189
|
+
|
|
190
|
+
render(
|
|
191
|
+
<ControlledTable
|
|
192
|
+
onSelectionChanged={(index) => {
|
|
193
|
+
reported.push(index);
|
|
194
|
+
if (reported.length > 5) {
|
|
195
|
+
throw new Error(`selection bounced: ${reported.join(',')}`);
|
|
196
|
+
}
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(reported).toEqual([2]);
|
|
202
|
+
expect(screen.getByTestId('previous-section-button')).toBeEnabled();
|
|
203
|
+
expect(screen.getByTestId('next-section-button')).toBeDisabled();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -94,13 +94,13 @@ const Table = ({
|
|
|
94
94
|
|
|
95
95
|
const useTabs = isMobile && mobileNavigationMode === 'tabs';
|
|
96
96
|
|
|
97
|
-
const { activeSection, navigateTable, setActiveSection } =
|
|
98
|
-
{
|
|
97
|
+
const { activeSection, navigateTable, setActiveSection, syncActiveSection } =
|
|
98
|
+
useTableNavigation({
|
|
99
99
|
enabled: isMobile,
|
|
100
100
|
containerRef,
|
|
101
|
+
initialSection: externalActiveSection ? externalActiveSection - 1 : 0,
|
|
101
102
|
onSelectionChanged,
|
|
102
|
-
}
|
|
103
|
-
);
|
|
103
|
+
});
|
|
104
104
|
|
|
105
105
|
const headerRow = tableData?.[0]?.rows?.[0];
|
|
106
106
|
const titleCell = {
|
|
@@ -138,7 +138,7 @@ const Table = ({
|
|
|
138
138
|
|
|
139
139
|
useEffect(() => {
|
|
140
140
|
if (externalActiveSection && externalActiveSection !== activeSection) {
|
|
141
|
-
|
|
141
|
+
syncActiveSection(externalActiveSection - 1);
|
|
142
142
|
}
|
|
143
143
|
}, [externalActiveSection]);
|
|
144
144
|
|
|
@@ -80,4 +80,62 @@ describe('useTableNavigation', () => {
|
|
|
80
80
|
});
|
|
81
81
|
expect(onSelectionChanged).toHaveBeenCalledWith(0);
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
it('starts on the section passed by the parent instead of the first one', () => {
|
|
85
|
+
const { result } = renderHook(() =>
|
|
86
|
+
useTableNavigation({
|
|
87
|
+
enabled: true,
|
|
88
|
+
containerRef,
|
|
89
|
+
onSelectionChanged,
|
|
90
|
+
initialSection: 1,
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(result.current.activeSection).toBe(2);
|
|
95
|
+
expect(containerRef.current?.scroll).toHaveBeenCalledWith({
|
|
96
|
+
top: 0,
|
|
97
|
+
left: 400,
|
|
98
|
+
behavior: 'smooth',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('does not report a section pushed in by the parent as a user selection', () => {
|
|
103
|
+
const { result } = renderHook(() =>
|
|
104
|
+
useTableNavigation({
|
|
105
|
+
enabled: true,
|
|
106
|
+
containerRef,
|
|
107
|
+
onSelectionChanged,
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
onSelectionChanged.mockClear();
|
|
111
|
+
|
|
112
|
+
act(() => {
|
|
113
|
+
result.current.syncActiveSection(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.current.activeSection).toBe(2);
|
|
117
|
+
expect(onSelectionChanged).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keeps reporting user navigation after a parent sync', () => {
|
|
121
|
+
const { result } = renderHook(() =>
|
|
122
|
+
useTableNavigation({
|
|
123
|
+
enabled: true,
|
|
124
|
+
containerRef,
|
|
125
|
+
onSelectionChanged,
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.syncActiveSection(1);
|
|
131
|
+
});
|
|
132
|
+
onSelectionChanged.mockClear();
|
|
133
|
+
|
|
134
|
+
act(() => {
|
|
135
|
+
result.current.navigateTable(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result.current.activeSection).toBe(1);
|
|
139
|
+
expect(onSelectionChanged).toHaveBeenCalledWith(1);
|
|
140
|
+
});
|
|
83
141
|
});
|
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
import debounce from "lodash.debounce";
|
|
2
|
-
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
interface UseTableNavigationReturn {
|
|
5
5
|
activeSection: number;
|
|
6
6
|
navigateTable: (increase?: boolean) => void;
|
|
7
7
|
setActiveSection: (section: number) => void;
|
|
8
|
+
syncActiveSection: (section: number) => void;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
interface UseTableNavigationProps {
|
|
11
12
|
containerRef: React.RefObject<HTMLElement>,
|
|
12
13
|
enabled?: boolean,
|
|
14
|
+
initialSection?: number,
|
|
13
15
|
onSelectionChanged?: (index: number) => void
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export const useTableNavigation = ({
|
|
17
19
|
enabled,
|
|
18
20
|
containerRef,
|
|
21
|
+
initialSection = 0,
|
|
19
22
|
onSelectionChanged
|
|
20
23
|
}: UseTableNavigationProps): UseTableNavigationReturn => {
|
|
21
|
-
const [activeSection, setActiveSection] = useState(
|
|
24
|
+
const [activeSection, setActiveSection] = useState(initialSection);
|
|
25
|
+
const activeSectionRef = useRef(initialSection);
|
|
26
|
+
const skipNextReportRef = useRef(false);
|
|
27
|
+
|
|
28
|
+
const syncActiveSection = useCallback((section: number) => {
|
|
29
|
+
if (section === activeSectionRef.current) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
skipNextReportRef.current = true;
|
|
34
|
+
setActiveSection(section);
|
|
35
|
+
}, []);
|
|
22
36
|
|
|
23
37
|
const handleScrollToSection = (increase?: boolean) => {
|
|
24
38
|
if (!enabled) {
|
|
@@ -55,11 +69,18 @@ export const useTableNavigation = ({
|
|
|
55
69
|
}, [enabled]);
|
|
56
70
|
|
|
57
71
|
useEffect(() => {
|
|
72
|
+
activeSectionRef.current = activeSection;
|
|
73
|
+
|
|
74
|
+
const skipReport = skipNextReportRef.current;
|
|
75
|
+
skipNextReportRef.current = false;
|
|
76
|
+
|
|
58
77
|
if (!enabled) {
|
|
59
78
|
return
|
|
60
79
|
}
|
|
61
80
|
|
|
62
|
-
|
|
81
|
+
if (!skipReport) {
|
|
82
|
+
onSelectionChanged?.(activeSection + 1);
|
|
83
|
+
}
|
|
63
84
|
|
|
64
85
|
if (containerRef.current) {
|
|
65
86
|
const containerWidth = containerRef.current.getBoundingClientRect().width;
|
|
@@ -77,5 +98,6 @@ export const useTableNavigation = ({
|
|
|
77
98
|
activeSection: activeSection + 1,
|
|
78
99
|
navigateTable: handleScrollToSection,
|
|
79
100
|
setActiveSection,
|
|
101
|
+
syncActiveSection,
|
|
80
102
|
}
|
|
81
103
|
}
|
|
@@ -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 {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"useTableNavigation-CrxoXTVd.js","sources":["../../../src/lib/components/table/utils/useTableNavigation/useTableNavigation.ts"],"sourcesContent":["import debounce from \"lodash.debounce\";\nimport { useCallback, useEffect, useState } from \"react\";\n\ninterface UseTableNavigationReturn {\n activeSection: number;\n navigateTable: (increase?: boolean) => void;\n setActiveSection: (section: number) => void;\n}\n\ninterface UseTableNavigationProps {\n containerRef: React.RefObject<HTMLElement>,\n enabled?: boolean,\n onSelectionChanged?: (index: number) => void\n}\n\nexport const useTableNavigation = ({\n enabled,\n containerRef,\n onSelectionChanged\n}: UseTableNavigationProps): UseTableNavigationReturn => {\n const [activeSection, setActiveSection] = useState(0);\n\n const handleScrollToSection = (increase?: boolean) => {\n if (!enabled) {\n return;\n }\n\n setActiveSection((prevSection) => \n prevSection + (increase ? 1 : -1)\n );\n };\n\n const handleTableScroll = useCallback(() => {\n if (!containerRef.current || !enabled) {\n return;\n }\n\n const containerWidth = containerRef.current.getBoundingClientRect().width;\n const scrollLeft = containerRef.current.scrollLeft * 1.1;\n const cellWidth = containerWidth / 2;\n\n setActiveSection(Math.floor(scrollLeft / cellWidth));\n }, [activeSection, containerRef, enabled]);\n\n const debouncedTableScroll = debounce(handleTableScroll, 150);\n\n useEffect(() => {\n const container = containerRef.current;\n\n container?.addEventListener('scroll', debouncedTableScroll, {\n passive: true,\n });\n\n return container?.removeEventListener('scroll', handleTableScroll);\n }, [enabled]);\n\n useEffect(() => {\n if (!enabled) {\n return\n }\n\n onSelectionChanged?.(activeSection + 1);\n\n if (containerRef.current) {\n const containerWidth = containerRef.current.getBoundingClientRect().width;\n const cellWidth = containerWidth / 2;\n\n containerRef.current.scroll({\n top: 0,\n left: cellWidth * activeSection,\n behavior: 'smooth',\n });\n }\n }, [enabled, activeSection]);\n\n return {\n activeSection: activeSection + 1,\n navigateTable: handleScrollToSection,\n setActiveSection,\n }\n}"],"names":[],"mappings":";;;AAeO,IAAM,kBAAkB,GAAG,UAAC,EAIT,EAAA;AAHxB,IAAA,IAAA,OAAO,aAAA,EACP,YAAY,GAAA,EAAA,CAAA,YAAA,EACZ,kBAAkB,GAAA,EAAA,CAAA,kBAAA;IAEZ,IAAA,EAAA,GAAoC,QAAQ,CAAC,CAAC,CAAC,EAA9C,aAAa,GAAA,EAAA,CAAA,CAAA,CAAA,EAAE,gBAAgB,GAAA,EAAA,CAAA,CAAA,CAAe;IAErD,IAAM,qBAAqB,GAAG,UAAC,QAAkB,EAAA;QAC/C,IAAI,CAAC,OAAO,EAAE;YACZ;QACF;QAEA,gBAAgB,CAAC,UAAC,WAAW,EAAA;AAC3B,YAAA,OAAA,WAAW,IAAI,QAAQ,GAAG,CAAC,GAAG,EAAE,CAAC;AAAjC,QAAA,CAAiC,CAClC;AACH,IAAA,CAAC;IAED,IAAM,iBAAiB,GAAG,WAAW,CAAC,YAAA;QACpC,IAAI,CAAC,YAAY,CAAC,OAAO,IAAI,CAAC,OAAO,EAAE;YACrC;QACF;QAEA,IAAM,cAAc,GAAG,YAAY,CAAC,OAAO,CAAC,qBAAqB,EAAE,CAAC,KAAK;QACzE,IAAM,UAAU,GAAG,YAAY,CAAC,OAAO,CAAC,UAAU,GAAG,GAAG;AACxD,QAAA,IAAM,SAAS,GAAG,cAAc,GAAG,CAAC;QAEpC,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;IACrD,CAAC,EAAE,CAAC,aAAa,EAAE,YAAY,EAAE,OAAO,CAAC,CAAC;IAE3C,IAAM,oBAAoB,GAAG,QAAQ,CAAC,iBAAiB,EAAE,GAAG,CAAC;AAE7D,IAAA,SAAS,CAAC,YAAA;AACR,QAAA,IAAM,SAAS,GAAG,YAAY,CAAC,OAAO;QAEtC,SAAS,KAAA,IAAA,IAAT,SAAS,KAAA,MAAA,GAAA,MAAA,GAAT,SAAS,CAAE,gBAAgB,CAAC,QAAQ,EAAE,oBAAoB,EAAE;AAC1D,YAAA,OAAO,EAAE,IAAI;AACd,SAAA,CAAC;AAEF,QAAA,OAAO,SAAS,KAAA,IAAA,IAAT,SAAS,KAAA,MAAA,GAAA,MAAA,GAAT,SAAS,CAAE,mBAAmB,CAAC,QAAQ,EAAE,iBAAiB,CAAC;AACpE,IAAA,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC;AAEb,IAAA,SAAS,CAAC,YAAA;QACR,IAAI,CAAC,OAAO,EAAE;YACZ;QACF;QAEA,kBAAkB,KAAA,IAAA,IAAlB,kBAAkB,KAAA,MAAA,GAAA,MAAA,GAAlB,kBAAkB,CAAG,aAAa,GAAG,CAAC,CAAC;AAEvC,QAAA,IAAI,YAAY,CAAC,OAAO,EAAE;YACxB,IAAM,cAAc,GAAG,YAAY,CAAC,OAAO,CAAC,qBAAqB,EAAE,CAAC,KAAK;AACzE,YAAA,IAAM,SAAS,GAAG,cAAc,GAAG,CAAC;AAEpC,YAAA,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC;AAC1B,gBAAA,GAAG,EAAE,CAAC;gBACN,IAAI,EAAE,SAAS,GAAG,aAAa;AAC/B,gBAAA,QAAQ,EAAE,QAAQ;AACnB,aAAA,CAAC;QACJ;AACF,IAAA,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC;IAE5B,OAAO;QACL,aAAa,EAAE,aAAa,GAAG,CAAC;AAChC,QAAA,aAAa,EAAE,qBAAqB;AACpC,QAAA,gBAAgB,EAAA,gBAAA;KACjB;AACH;;;;"}
|