Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/modal-imperative-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@tiny-design/react': minor
---

feat(modal): add imperative/registered API on top of the existing context

- New exports: `Modal.Register`, `Modal.useModalActions`, `Modal.useModalSelf`, `Modal.store`, and a named `createModalStore` factory.
- `show(id, props)` returns a promise that resolves with the value passed to `hide(result)`, so dialogs can be `await`ed.
- `<Modal.Provider>` now backs an outlet that renders registered components; the legacy `Modal.useModal(id)` per-id hook continues to work unchanged.
- New "Choosing a store" docs section warning that two providers sharing the singleton cause duplicate overlays — recommends `createModalStore()` for app-level providers.

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.

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.

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.
14 changes: 9 additions & 5 deletions apps/docs/src/components/markdown-tag/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import './md-tag.scss';
import { DemoBlock } from '../demo-block';
import { HighlightedCode } from '../highlighted-code';

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

const slugifyLink = (children) =>
extractText(children).toLowerCase().trim().split(/\s+/).filter(Boolean).join('-');

export const components = {
wrapper: (props) => <div {...props} className="markdown" />,
h1: (props) => <h1 {...props} className="markdown__heading-1" />,
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/collapse-transition/collapse-transition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const CollapseTransition = ({
const isFirstRender = useRef(true);
const visible = open ?? isShow ?? false;

// Stash the latest onHidden so the animation effect can depend on `visible`
// alone. Callers commonly pass an inline arrow (e.g. `() => setX(false)`),
// and re-running the effect on every parent render restarts the animation.
const onHiddenRef = useRef(onHidden);
onHiddenRef.current = onHidden;

useEffect(() => {
const node = ref.current;
if (!node) return;
Expand All @@ -43,7 +49,7 @@ const CollapseTransition = ({
node.style.height = '';
} else {
node.style.display = 'none';
onHidden?.();
onHiddenRef.current?.();
}
};

Expand Down Expand Up @@ -76,7 +82,7 @@ const CollapseTransition = ({
if (frameB) window.cancelAnimationFrame(frameB);
node.removeEventListener('transitionend', handleTransitionEnd);
};
}, [visible, onHidden]);
}, [visible]);

return (
<div ref={ref} className={classNames('ty-collapse-transition', className)}>
Expand Down
16 changes: 8 additions & 8 deletions packages/react/src/collapse/collapse-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,12 @@ const CollapsePanel = ({
{extraContent && <div className={`${prefixCls}-item__extra`}>{extraContent}</div>}
</div>

{shouldRenderBody && (
<CollapseTransition
open={active}
className={`${prefixCls}-item__body-wrapper`}
onHidden={destroyOnHidden && !forceRender ? () => setBodyMounted(false) : undefined}
>
<CollapseTransition
open={active}
className={`${prefixCls}-item__body-wrapper`}
onHidden={destroyOnHidden && !forceRender ? () => setBodyMounted(false) : undefined}
>
{shouldRenderBody ? (
<div
id={panelId}
role="region"
Expand All @@ -163,8 +163,8 @@ const CollapsePanel = ({
>
{item.children}
</div>
</CollapseTransition>
)}
) : null}
</CollapseTransition>
</div>
);
};
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export { default as Menu } from './menu';
export { default as Message } from './message';
export { default as NativeSelect } from './native-select';
export { default as Row } from './row';
export { default as Modal } from './modal';
export { default as Modal, createModalStore } from './modal';
export { default as Notification } from './notification';
export { default as Overlay } from './overlay';
export { default as Popover } from './popover';
Expand Down
191 changes: 191 additions & 0 deletions packages/react/src/modal/__tests__/modal-context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import Modal from '../index';
import { createModalStore } from '../modal-context';

describe('Modal context — legacy useModal(id)', () => {
function Toolbar() {
const confirm = Modal.useModal('confirm');
return <button onClick={confirm.show}>open</button>;
}
function ConfirmModal() {
const { visible, close } = Modal.useModal('confirm');
return (
<Modal visible={visible} onClose={close} header="Confirm">
confirm body
</Modal>
);
}

it('shows and closes a modal via per-id hook', () => {
render(
<Modal.Provider store={createModalStore()}>
<Toolbar />
<ConfirmModal />
</Modal.Provider>
);

expect(screen.queryByText('confirm body')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('open'));
expect(screen.getByText('confirm body')).toBeInTheDocument();
});
});

describe('Modal context — imperative API + outlet', () => {
function ConfirmContent(props: { message: string }) {
const { visible, hide, remove } = Modal.useModalSelf<{ message: string }, boolean>();
return (
<Modal
visible={visible}
header="Are you sure?"
afterClose={remove}
onConfirm={() => hide(true)}
onCancel={() => hide(false)}>
<span data-testid="message">{props.message}</span>
</Modal>
);
}

it('renders registered components and resolves with hide(result)', async () => {
const store = createModalStore();
store.register('confirm', ConfirmContent);

let resolved: boolean | undefined;
function Trigger() {
const modal = Modal.useModalActions();
return (
<button
onClick={async () => {
resolved = await modal.show<boolean, { message: string }>('confirm', {
message: 'delete this?',
});
}}>
ask
</button>
);
}

render(
<Modal.Provider store={store}>
<Trigger />
</Modal.Provider>
);

fireEvent.click(screen.getByText('ask'));
expect(screen.getByTestId('message')).toHaveTextContent('delete this?');

await act(async () => {
fireEvent.click(screen.getByText('OK'));
});
expect(resolved).toBe(true);
});

it('declarative <Modal.Register> works the same as store.register', () => {
const store = createModalStore();
function Trigger() {
const modal = Modal.useModalActions();
return <button onClick={() => modal.show('confirm', { message: 'hi' })}>go</button>;
}

render(
<Modal.Provider store={store}>
<Modal.Register id="confirm" component={ConfirmContent} />
<Trigger />
</Modal.Provider>
);

expect(store.isRegistered('confirm')).toBe(true);
fireEvent.click(screen.getByText('go'));
expect(screen.getByTestId('message')).toHaveTextContent('hi');
});

it('<Modal.Register> unregisters when unmounted', () => {
const store = createModalStore();
function Host({ mounted }: { mounted: boolean }) {
return (
<Modal.Provider store={store}>
{mounted ? <Modal.Register id="confirm" component={ConfirmContent} /> : null}
</Modal.Provider>
);
}

const { rerender } = render(<Host mounted />);
expect(store.isRegistered('confirm')).toBe(true);

rerender(<Host mounted={false} />);
expect(store.isRegistered('confirm')).toBe(false);
});

it('hideAll resolves all open modals with undefined', async () => {
const store = createModalStore();
store.register('a', () => {
const { visible, remove } = Modal.useModalSelf();
return (
<Modal visible={visible} afterClose={remove} header="a">
a body
</Modal>
);
});
store.register('b', () => {
const { visible, remove } = Modal.useModalSelf();
return (
<Modal visible={visible} afterClose={remove} header="b">
b body
</Modal>
);
});

let aResult: unknown = 'untouched';
let bResult: unknown = 'untouched';
render(<Modal.Provider store={store}>{null}</Modal.Provider>);

await act(async () => {
store.show('a').then((v) => (aResult = v));
store.show('b').then((v) => (bResult = v));
});
expect(screen.getByText('a body')).toBeInTheDocument();
expect(screen.getByText('b body')).toBeInTheDocument();

await act(async () => {
store.hideAll();
});
expect(aResult).toBeUndefined();
expect(bResult).toBeUndefined();
});

it('remove() drains a pending resolver instead of leaking the promise', async () => {
const store = createModalStore();
store.register('confirm', ConfirmContent);

let resolved: unknown = 'untouched';
await act(async () => {
store.show<boolean, { message: string }>('confirm', { message: 'x' }).then((v) => {
resolved = v;
});
});

await act(async () => {
store.remove('confirm');
});
// microtask flush
await act(async () => {});

expect(resolved).toBeUndefined();
});

it('useModalSelf throws outside an outlet', () => {
function Bad() {
Modal.useModalSelf();
return null;
}
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() =>
render(
<Modal.Provider store={createModalStore()}>
<Bad />
</Modal.Provider>
)
).toThrow(/useModalSelf must be used inside/);
spy.mockRestore();
});
});
14 changes: 7 additions & 7 deletions packages/react/src/modal/demo/Context.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Modal, Button, Space } from '@tiny-design/react';
import React, { useMemo } from 'react';
import { Modal, Button, Space, createModalStore } from '@tiny-design/react';

function ConfirmModal() {
const { visible, close } = Modal.useModal('confirm');
Expand All @@ -20,21 +20,21 @@ function SettingsModal() {
}

function Toolbar() {
const confirm = Modal.useModal('confirm');
const settings = Modal.useModal('settings');
const { show } = Modal.useModalActions();
return (
<Space>
<Button variant="solid" color="primary" onClick={confirm.show}>
<Button variant="solid" color="primary" onClick={() => show('confirm')}>
Open Confirm
</Button>
<Button onClick={settings.show}>Open Settings</Button>
<Button onClick={() => show('settings')}>Open Settings</Button>
</Space>
);
}

export default function ContextDemo() {
const store = useMemo(() => createModalStore(), []);
return (
<Modal.Provider>
<Modal.Provider store={store}>
<Toolbar />
<ConfirmModal />
<SettingsModal />
Expand Down
56 changes: 56 additions & 0 deletions packages/react/src/modal/demo/ContextRegister.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { useMemo, useState } from 'react';
import { Modal, Button, Space, createModalStore } from '@tiny-design/react';

interface ConfirmDeleteProps {
itemName: string;
}

function ConfirmDelete({ itemName }: ConfirmDeleteProps) {
const { visible, hide, remove } = Modal.useModalSelf<ConfirmDeleteProps, boolean>();
return (
<Modal
header="Delete item"
visible={visible}
afterClose={remove}
onConfirm={() => hide(true)}
onCancel={() => hide(false)}
confirmText="Delete"
cancelText="Keep">
<p>
Are you sure you want to delete <strong>{itemName}</strong>? This action cannot be undone.
</p>
</Modal>
);
}

function Trigger() {
const { show } = Modal.useModalActions();
const [last, setLast] = useState<string>('');

return (
<Space direction="vertical">
<Button
variant="solid"
color="danger"
onClick={async () => {
const ok = await show<boolean, ConfirmDeleteProps>('confirm-delete', {
itemName: 'Project Apollo',
});
setLast(ok ? 'deleted Project Apollo' : 'cancelled');
}}>
Delete Project Apollo
</Button>
{last ? <span>Last action: {last}</span> : null}
</Space>
);
}

export default function ContextRegisterDemo() {
const store = useMemo(() => createModalStore(), []);
return (
<Modal.Provider store={store}>
<Modal.Register id="confirm-delete" component={ConfirmDelete} />
<Trigger />
</Modal.Provider>
);
}
Loading
Loading