diff --git a/packages/ui/stories/toast.stories.tsx b/packages/ui/stories/toast.stories.tsx index 58ebeaa7f2..90916e5b4d 100644 --- a/packages/ui/stories/toast.stories.tsx +++ b/packages/ui/stories/toast.stories.tsx @@ -111,3 +111,130 @@ export const ConfirmQueued: Story = { await expect(opener).toHaveFocus(); }, }; + +// The diagnostics affordance the provider wires onto every error toast — and +// the branch where opening it also fails, so the provider raises its own +// failure toast instead of swallowing the rejection. +const DIAGNOSTICS_ERROR_ACTION = { + label: '查看详情', + failureTitle: '无法打开诊断', + failureDescription: '日志服务未响应,请稍后重试。', + onClick: async () => { + throw new Error('diagnostics unavailable'); + }, +}; + +// Real trigger: any failed operation surfaced through `toast.error` (a failed +// save, an offline sync, an unauthorised request — this surface has one error +// affordance, so they all share its shape). A bare `error` variant never +// exercises the action button or its own failure path. +export const ErrorWithDiagnostics: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + render: () => { + const toast = useToast(); + useEffect(() => { + toast.toast({ + title: '保存失败', + description: '网络中断,更改未同步。', + variant: 'error', + duration: 0, + }); + }, [toast]); + return
; + }, + play: async () => { + const page = within(document.body); + // The error toast carries the provider's diagnostics action… + const diagnostics = await page.findByRole('button', { name: '查看详情' }); + await userEvent.click(diagnostics); + // …and when that action itself fails, the provider surfaces its own + // failure toast rather than dropping the rejection on the floor. + await page.findByText('无法打开诊断'); + }, +}; + +function DestructiveConfirmExample() { + const toast = useToast(); + return ( +
+
+ ); +} + +// Real trigger: an irreversible action (delete a project / task). The confirm +// button wears the destructive variant and initial focus rests on cancel, so +// the dangerous path is never the default. +export const DestructiveConfirm: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const page = within(document.body); + await userEvent.click(canvas.getByRole('button', { name: '删除项目' })); + const dialog = await page.findByRole('alertdialog', { name: '删除项目?' }); + await expect(within(dialog).getByRole('button', { name: '删除' })).toBeInTheDocument(); + // The least-destructive choice holds initial focus. + await expect(within(dialog).getByRole('button', { name: '取消' })).toHaveFocus(); + }, +}; + +const LONG_TOAST_TITLE = + '同步到远程工作区时发生了一个需要你注意的、标题本身就相当冗长的问题'; +const LONG_TOAST_DESCRIPTION = + '远程主机在多次重试后仍未确认写入:连接在握手阶段被重置,随后的退避重试也接连超时。' + + '本地更改已安全保留,你可以稍后重新同步;这段说明刻意写得很长,用来检验 toast 卡片在多行文本下是否仍然清晰,而不会把操作区挤出可视范围。'; + +// Very long title and body: the card must wrap legibly rather than clip text +// or shove the layout off-screen. +export const LongContent: Story = { + render: () => { + const toast = useToast(); + useEffect(() => { + toast.toast({ + title: LONG_TOAST_TITLE, + description: LONG_TOAST_DESCRIPTION, + variant: 'warning', + duration: 0, + }); + }, [toast]); + return
; + }, +}; + +// Many notifications at once — a burst of background results stacking up. +// Exercises the stack's density and whatever cap or scroll the layer applies, +// which a single seeded toast never shows. +export const ManyStacked: Story = { + render: () => { + const toast = useToast(); + useEffect(() => { + for (let i = 1; i <= 8; i += 1) { + toast.toast({ + title: `后台任务 ${i} 已完成`, + description: `第 ${i} 条通知的说明文字。`, + variant: VARIANTS[i % VARIANTS.length]!, + duration: 0, + }); + } + }, [toast]); + return
; + }, +};