Skip to content

Commit 8e579d9

Browse files
authored
feat(modal): imperative store API + transition/collapse animation fixes (#135)
* chore: add changeset for modal imperative API + transition/collapse fixes * feat(modal): imperative store API + transition/collapse animation fixes Modal: - Replace the reducer-based context with a `ModalStore` external store (subscribe / show / hide / hideAll / register / remove). `show(id, props)` returns a promise resolved by `hide(result)`. - Add `Modal.Register`, `Modal.useModalActions`, `Modal.useModalSelf`, `Modal.store`, and a named `createModalStore` factory; legacy `Modal.useModal(id)` continues to work unchanged. - Re-export `createModalStore` from `@tiny-design/react`. - Add tests covering the per-id hook, registered components with awaitable results, declarative `<Modal.Register>` (incl. unmount), `hideAll`, `remove()` draining a pending resolver, and the `useModalSelf` outlet guard. - Refresh demos: Context (manual mount via `useModalActions`) and a new ContextRegister demo (registered + awaitable result). Document the Context API and a "Choosing a store" section in EN/ZH docs. Transition: - Move `onExited` side effect out of the `setState` updater (use a stateRef for race protection). Avoids "Cannot update X while rendering Y" warnings when `afterClose` dispatches across components. Collapse: - Always mount `<CollapseTransition>`; gate only the inner body content. The first time a panel expands from a closed start now plays the open animation instead of snapping to full height. CollapseTransition: - Keep `onHidden` in a ref so the animation effect depends only on `visible`. Inline `onHidden` callers no longer have unrelated parent re-renders interrupt the running animation. Docs: - Make markdown-tag's `slugifyLink` handle React node children so headings with inline `<code>` (e.g. `### \`ModalStore\``) don't crash.
1 parent 760188a commit 8e579d9

13 files changed

Lines changed: 800 additions & 92 deletions

File tree

.changeset/modal-imperative-api.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@tiny-design/react': minor
3+
---
4+
5+
feat(modal): add imperative/registered API on top of the existing context
6+
7+
- New exports: `Modal.Register`, `Modal.useModalActions`, `Modal.useModalSelf`, `Modal.store`, and a named `createModalStore` factory.
8+
- `show(id, props)` returns a promise that resolves with the value passed to `hide(result)`, so dialogs can be `await`ed.
9+
- `<Modal.Provider>` now backs an outlet that renders registered components; the legacy `Modal.useModal(id)` per-id hook continues to work unchanged.
10+
- New "Choosing a store" docs section warning that two providers sharing the singleton cause duplicate overlays — recommends `createModalStore()` for app-level providers.
11+
12+
fix(transition): stop firing `onExited` from inside a `setState` updater so it no longer triggers "Cannot update X while rendering Y" warnings when the callback dispatches across components.
13+
14+
fix(collapse-transition): keep `onHidden` in a ref so the animation effect depends only on `visible`. Inline `onHidden={() => …}` callers no longer cause unrelated parent re-renders to interrupt the running open/close animation.
15+
16+
fix(collapse): always mount `<CollapseTransition>` and gate only the body content. The first time a panel is opened from a closed start now plays the open animation instead of snapping to its full height.

apps/docs/src/components/markdown-tag/index.jsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import './md-tag.scss';
66
import { DemoBlock } from '../demo-block';
77
import { HighlightedCode } from '../highlighted-code';
88

9-
const slugifyLink = (name) => {
10-
if (name.includes(' ')) {
11-
return name.toLowerCase().split(' ').join('-');
12-
}
13-
return typeof name === 'string' ? name.toLowerCase() : name;
9+
const extractText = (node) => {
10+
if (node == null || typeof node === 'boolean') return '';
11+
if (typeof node === 'string' || typeof node === 'number') return String(node);
12+
if (Array.isArray(node)) return node.map(extractText).join('');
13+
if (React.isValidElement(node)) return extractText(node.props.children);
14+
return '';
1415
};
1516

17+
const slugifyLink = (children) =>
18+
extractText(children).toLowerCase().trim().split(/\s+/).filter(Boolean).join('-');
19+
1620
export const components = {
1721
wrapper: (props) => <div {...props} className="markdown" />,
1822
h1: (props) => <h1 {...props} className="markdown__heading-1" />,

packages/react/src/collapse-transition/collapse-transition.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ const CollapseTransition = ({
2020
const isFirstRender = useRef(true);
2121
const visible = open ?? isShow ?? false;
2222

23+
// Stash the latest onHidden so the animation effect can depend on `visible`
24+
// alone. Callers commonly pass an inline arrow (e.g. `() => setX(false)`),
25+
// and re-running the effect on every parent render restarts the animation.
26+
const onHiddenRef = useRef(onHidden);
27+
onHiddenRef.current = onHidden;
28+
2329
useEffect(() => {
2430
const node = ref.current;
2531
if (!node) return;
@@ -43,7 +49,7 @@ const CollapseTransition = ({
4349
node.style.height = '';
4450
} else {
4551
node.style.display = 'none';
46-
onHidden?.();
52+
onHiddenRef.current?.();
4753
}
4854
};
4955

@@ -76,7 +82,7 @@ const CollapseTransition = ({
7682
if (frameB) window.cancelAnimationFrame(frameB);
7783
node.removeEventListener('transitionend', handleTransitionEnd);
7884
};
79-
}, [visible, onHidden]);
85+
}, [visible]);
8086

8187
return (
8288
<div ref={ref} className={classNames('ty-collapse-transition', className)}>

packages/react/src/collapse/collapse-panel.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,12 @@ const CollapsePanel = ({
149149
{extraContent && <div className={`${prefixCls}-item__extra`}>{extraContent}</div>}
150150
</div>
151151

152-
{shouldRenderBody && (
153-
<CollapseTransition
154-
open={active}
155-
className={`${prefixCls}-item__body-wrapper`}
156-
onHidden={destroyOnHidden && !forceRender ? () => setBodyMounted(false) : undefined}
157-
>
152+
<CollapseTransition
153+
open={active}
154+
className={`${prefixCls}-item__body-wrapper`}
155+
onHidden={destroyOnHidden && !forceRender ? () => setBodyMounted(false) : undefined}
156+
>
157+
{shouldRenderBody ? (
158158
<div
159159
id={panelId}
160160
role="region"
@@ -163,8 +163,8 @@ const CollapsePanel = ({
163163
>
164164
{item.children}
165165
</div>
166-
</CollapseTransition>
167-
)}
166+
) : null}
167+
</CollapseTransition>
168168
</div>
169169
);
170170
};

packages/react/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export { default as Menu } from './menu';
4545
export { default as Message } from './message';
4646
export { default as NativeSelect } from './native-select';
4747
export { default as Row } from './row';
48-
export { default as Modal } from './modal';
48+
export { default as Modal, createModalStore } from './modal';
4949
export { default as Notification } from './notification';
5050
export { default as Overlay } from './overlay';
5151
export { default as Popover } from './popover';
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import React from 'react';
2+
import { act, fireEvent, render, screen } from '@testing-library/react';
3+
import Modal from '../index';
4+
import { createModalStore } from '../modal-context';
5+
6+
describe('Modal context — legacy useModal(id)', () => {
7+
function Toolbar() {
8+
const confirm = Modal.useModal('confirm');
9+
return <button onClick={confirm.show}>open</button>;
10+
}
11+
function ConfirmModal() {
12+
const { visible, close } = Modal.useModal('confirm');
13+
return (
14+
<Modal visible={visible} onClose={close} header="Confirm">
15+
confirm body
16+
</Modal>
17+
);
18+
}
19+
20+
it('shows and closes a modal via per-id hook', () => {
21+
render(
22+
<Modal.Provider store={createModalStore()}>
23+
<Toolbar />
24+
<ConfirmModal />
25+
</Modal.Provider>
26+
);
27+
28+
expect(screen.queryByText('confirm body')).not.toBeInTheDocument();
29+
fireEvent.click(screen.getByText('open'));
30+
expect(screen.getByText('confirm body')).toBeInTheDocument();
31+
});
32+
});
33+
34+
describe('Modal context — imperative API + outlet', () => {
35+
function ConfirmContent(props: { message: string }) {
36+
const { visible, hide, remove } = Modal.useModalSelf<{ message: string }, boolean>();
37+
return (
38+
<Modal
39+
visible={visible}
40+
header="Are you sure?"
41+
afterClose={remove}
42+
onConfirm={() => hide(true)}
43+
onCancel={() => hide(false)}>
44+
<span data-testid="message">{props.message}</span>
45+
</Modal>
46+
);
47+
}
48+
49+
it('renders registered components and resolves with hide(result)', async () => {
50+
const store = createModalStore();
51+
store.register('confirm', ConfirmContent);
52+
53+
let resolved: boolean | undefined;
54+
function Trigger() {
55+
const modal = Modal.useModalActions();
56+
return (
57+
<button
58+
onClick={async () => {
59+
resolved = await modal.show<boolean, { message: string }>('confirm', {
60+
message: 'delete this?',
61+
});
62+
}}>
63+
ask
64+
</button>
65+
);
66+
}
67+
68+
render(
69+
<Modal.Provider store={store}>
70+
<Trigger />
71+
</Modal.Provider>
72+
);
73+
74+
fireEvent.click(screen.getByText('ask'));
75+
expect(screen.getByTestId('message')).toHaveTextContent('delete this?');
76+
77+
await act(async () => {
78+
fireEvent.click(screen.getByText('OK'));
79+
});
80+
expect(resolved).toBe(true);
81+
});
82+
83+
it('declarative <Modal.Register> works the same as store.register', () => {
84+
const store = createModalStore();
85+
function Trigger() {
86+
const modal = Modal.useModalActions();
87+
return <button onClick={() => modal.show('confirm', { message: 'hi' })}>go</button>;
88+
}
89+
90+
render(
91+
<Modal.Provider store={store}>
92+
<Modal.Register id="confirm" component={ConfirmContent} />
93+
<Trigger />
94+
</Modal.Provider>
95+
);
96+
97+
expect(store.isRegistered('confirm')).toBe(true);
98+
fireEvent.click(screen.getByText('go'));
99+
expect(screen.getByTestId('message')).toHaveTextContent('hi');
100+
});
101+
102+
it('<Modal.Register> unregisters when unmounted', () => {
103+
const store = createModalStore();
104+
function Host({ mounted }: { mounted: boolean }) {
105+
return (
106+
<Modal.Provider store={store}>
107+
{mounted ? <Modal.Register id="confirm" component={ConfirmContent} /> : null}
108+
</Modal.Provider>
109+
);
110+
}
111+
112+
const { rerender } = render(<Host mounted />);
113+
expect(store.isRegistered('confirm')).toBe(true);
114+
115+
rerender(<Host mounted={false} />);
116+
expect(store.isRegistered('confirm')).toBe(false);
117+
});
118+
119+
it('hideAll resolves all open modals with undefined', async () => {
120+
const store = createModalStore();
121+
store.register('a', () => {
122+
const { visible, remove } = Modal.useModalSelf();
123+
return (
124+
<Modal visible={visible} afterClose={remove} header="a">
125+
a body
126+
</Modal>
127+
);
128+
});
129+
store.register('b', () => {
130+
const { visible, remove } = Modal.useModalSelf();
131+
return (
132+
<Modal visible={visible} afterClose={remove} header="b">
133+
b body
134+
</Modal>
135+
);
136+
});
137+
138+
let aResult: unknown = 'untouched';
139+
let bResult: unknown = 'untouched';
140+
render(<Modal.Provider store={store}>{null}</Modal.Provider>);
141+
142+
await act(async () => {
143+
store.show('a').then((v) => (aResult = v));
144+
store.show('b').then((v) => (bResult = v));
145+
});
146+
expect(screen.getByText('a body')).toBeInTheDocument();
147+
expect(screen.getByText('b body')).toBeInTheDocument();
148+
149+
await act(async () => {
150+
store.hideAll();
151+
});
152+
expect(aResult).toBeUndefined();
153+
expect(bResult).toBeUndefined();
154+
});
155+
156+
it('remove() drains a pending resolver instead of leaking the promise', async () => {
157+
const store = createModalStore();
158+
store.register('confirm', ConfirmContent);
159+
160+
let resolved: unknown = 'untouched';
161+
await act(async () => {
162+
store.show<boolean, { message: string }>('confirm', { message: 'x' }).then((v) => {
163+
resolved = v;
164+
});
165+
});
166+
167+
await act(async () => {
168+
store.remove('confirm');
169+
});
170+
// microtask flush
171+
await act(async () => {});
172+
173+
expect(resolved).toBeUndefined();
174+
});
175+
176+
it('useModalSelf throws outside an outlet', () => {
177+
function Bad() {
178+
Modal.useModalSelf();
179+
return null;
180+
}
181+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
182+
expect(() =>
183+
render(
184+
<Modal.Provider store={createModalStore()}>
185+
<Bad />
186+
</Modal.Provider>
187+
)
188+
).toThrow(/useModalSelf must be used inside/);
189+
spy.mockRestore();
190+
});
191+
});

packages/react/src/modal/demo/Context.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React from 'react';
2-
import { Modal, Button, Space } from '@tiny-design/react';
1+
import React, { useMemo } from 'react';
2+
import { Modal, Button, Space, createModalStore } from '@tiny-design/react';
33

44
function ConfirmModal() {
55
const { visible, close } = Modal.useModal('confirm');
@@ -20,21 +20,21 @@ function SettingsModal() {
2020
}
2121

2222
function Toolbar() {
23-
const confirm = Modal.useModal('confirm');
24-
const settings = Modal.useModal('settings');
23+
const { show } = Modal.useModalActions();
2524
return (
2625
<Space>
27-
<Button variant="solid" color="primary" onClick={confirm.show}>
26+
<Button variant="solid" color="primary" onClick={() => show('confirm')}>
2827
Open Confirm
2928
</Button>
30-
<Button onClick={settings.show}>Open Settings</Button>
29+
<Button onClick={() => show('settings')}>Open Settings</Button>
3130
</Space>
3231
);
3332
}
3433

3534
export default function ContextDemo() {
35+
const store = useMemo(() => createModalStore(), []);
3636
return (
37-
<Modal.Provider>
37+
<Modal.Provider store={store}>
3838
<Toolbar />
3939
<ConfirmModal />
4040
<SettingsModal />
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { useMemo, useState } from 'react';
2+
import { Modal, Button, Space, createModalStore } from '@tiny-design/react';
3+
4+
interface ConfirmDeleteProps {
5+
itemName: string;
6+
}
7+
8+
function ConfirmDelete({ itemName }: ConfirmDeleteProps) {
9+
const { visible, hide, remove } = Modal.useModalSelf<ConfirmDeleteProps, boolean>();
10+
return (
11+
<Modal
12+
header="Delete item"
13+
visible={visible}
14+
afterClose={remove}
15+
onConfirm={() => hide(true)}
16+
onCancel={() => hide(false)}
17+
confirmText="Delete"
18+
cancelText="Keep">
19+
<p>
20+
Are you sure you want to delete <strong>{itemName}</strong>? This action cannot be undone.
21+
</p>
22+
</Modal>
23+
);
24+
}
25+
26+
function Trigger() {
27+
const { show } = Modal.useModalActions();
28+
const [last, setLast] = useState<string>('');
29+
30+
return (
31+
<Space direction="vertical">
32+
<Button
33+
variant="solid"
34+
color="danger"
35+
onClick={async () => {
36+
const ok = await show<boolean, ConfirmDeleteProps>('confirm-delete', {
37+
itemName: 'Project Apollo',
38+
});
39+
setLast(ok ? 'deleted Project Apollo' : 'cancelled');
40+
}}>
41+
Delete Project Apollo
42+
</Button>
43+
{last ? <span>Last action: {last}</span> : null}
44+
</Space>
45+
);
46+
}
47+
48+
export default function ContextRegisterDemo() {
49+
const store = useMemo(() => createModalStore(), []);
50+
return (
51+
<Modal.Provider store={store}>
52+
<Modal.Register id="confirm-delete" component={ConfirmDelete} />
53+
<Trigger />
54+
</Modal.Provider>
55+
);
56+
}

0 commit comments

Comments
 (0)