@elabs-ai/components-ui 4.0.0 → 4.1.0
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/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -19,6 +19,26 @@ describe("SplitPanel", () => {
|
|
|
19
19
|
expect(endPane.className).toMatch(/border-s/);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
// Issue #163 — WCAG 1.4.11: with both tones "plain" (the shipped default),
|
|
23
|
+
// the hairline is the ONLY cue between the two panes (no fill/elevation
|
|
24
|
+
// difference), so it must be the strong, ≥3:1 rung — `border-strong ≥ 3:1
|
|
25
|
+
// on --card/--background` is proven for every theme in
|
|
26
|
+
// `@elabs-ai/components-tokens`'s `themes-contrast.test.ts`; this test locks
|
|
27
|
+
// that `SplitPanel` actually reaches for that token in this configuration,
|
|
28
|
+
// not just the bare `border-s`/`border-t` class the previous assertion
|
|
29
|
+
// above already passed on the unfixed code.
|
|
30
|
+
it("plain/plain divider uses the strong (≥3:1) border rung, not the subtle default", () => {
|
|
31
|
+
const { getByText } = render(<SplitPanel start={<>List</>} end={<>Detail</>} />);
|
|
32
|
+
expect(getByText("Detail").className).toMatch(/border-border-strong/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("muted/card tones keep the subtle divider rung — the fill difference already carries the boundary", () => {
|
|
36
|
+
const { getByText } = render(
|
|
37
|
+
<SplitPanel startTone="muted" endTone="card" start={<>List</>} end={<>Detail</>} />,
|
|
38
|
+
);
|
|
39
|
+
expect(getByText("Detail").className).not.toMatch(/border-border-strong/);
|
|
40
|
+
});
|
|
41
|
+
|
|
22
42
|
it("applies the ground-offset tones: muted well + raised card", () => {
|
|
23
43
|
const { getByText } = render(
|
|
24
44
|
<SplitPanel
|
|
@@ -5,15 +5,20 @@ import { cn } from "../../lib/cn";
|
|
|
5
5
|
/**
|
|
6
6
|
* Per-pane surface tone — the ground-offset tiering axis (research 08 §H, #193).
|
|
7
7
|
* `plain` keeps the ambient ground (default; non-breaking). `card` RAISES the pane as
|
|
8
|
-
* a white `bg-card` + `shadow-sm` and is robust across
|
|
8
|
+
* a white `bg-card` + `shadow-sm` and is robust across every theme — a card is
|
|
9
9
|
* lighter than the page ground in BOTH light and dark, so it always reads as raised.
|
|
10
10
|
* `muted` (`bg-surface-muted`) reads as a recessed well ONLY in light themes; in dark
|
|
11
11
|
* themes `--surface-muted` is lighter than `--card`, so a `muted` pane reads as a faint
|
|
12
12
|
* raised tint, not a recess (dark-mode layering only goes UP — there is no surface below
|
|
13
13
|
* the page ground). For a guaranteed cross-theme raise/recess contrast, raise the focus
|
|
14
14
|
* pane with `card` and leave the other `plain`.
|
|
15
|
+
*
|
|
16
|
+
* Exported so the same tone system reaches a `Layout/Resizable` pane — a draggable
|
|
17
|
+
* `ResizablePanel` composes `splitPaneVariants({ tone })` for the identical ground-offset
|
|
18
|
+
* tiering `SplitPanel` gives its `startTone`/`endTone`, instead of a hand-rolled second
|
|
19
|
+
* copy of these three classes. See `Layout/Resizable`'s `Tiered` story.
|
|
15
20
|
*/
|
|
16
|
-
const
|
|
21
|
+
export const splitPaneVariants = cva("min-h-0 min-w-0 overflow-auto", {
|
|
17
22
|
variants: {
|
|
18
23
|
tone: {
|
|
19
24
|
plain: "",
|
|
@@ -24,7 +29,7 @@ const paneToneVariants = cva("min-h-0 min-w-0 overflow-auto", {
|
|
|
24
29
|
defaultVariants: { tone: "plain" },
|
|
25
30
|
});
|
|
26
31
|
|
|
27
|
-
export type SplitPanelTone = NonNullable<VariantProps<typeof
|
|
32
|
+
export type SplitPanelTone = NonNullable<VariantProps<typeof splitPaneVariants>["tone"]>;
|
|
28
33
|
|
|
29
34
|
export interface SplitPanelProps {
|
|
30
35
|
start: ReactNode;
|
|
@@ -49,8 +54,12 @@ export interface SplitPanelProps {
|
|
|
49
54
|
|
|
50
55
|
/**
|
|
51
56
|
* Two-pane layout for master/detail, editor/preview and inspector workflows.
|
|
52
|
-
* Static sizing by design (predictable in tests/SSR)
|
|
53
|
-
*
|
|
57
|
+
* Static sizing by design (predictable in tests/SSR) — `startSize` is a CSS grid
|
|
58
|
+
* track (`"320px"`, `"40%"`, `"1fr"`), not a percentage. For panes the user can
|
|
59
|
+
* drag to resize, reach for `Layout/Resizable`'s `ResizablePanelGroup`/
|
|
60
|
+
* `ResizablePanel`/`ResizableHandle` instead and apply `splitPaneVariants({ tone })`
|
|
61
|
+
* to a panel for the same ground-offset tiering. See
|
|
62
|
+
* [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).
|
|
54
63
|
*
|
|
55
64
|
* For a master/detail surface where the detail should read as a raised card on a
|
|
56
65
|
* recessed list, use `startTone="muted" endTone="card"` (ground-offset tiering).
|
|
@@ -80,11 +89,22 @@ export function SplitPanel({
|
|
|
80
89
|
)}
|
|
81
90
|
style={style}
|
|
82
91
|
>
|
|
83
|
-
<div className={cn(
|
|
92
|
+
<div className={cn(splitPaneVariants({ tone: startTone }), startClassName)}>{start}</div>
|
|
84
93
|
<div
|
|
85
94
|
className={cn(
|
|
86
|
-
|
|
87
|
-
divider &&
|
|
95
|
+
splitPaneVariants({ tone: endTone }),
|
|
96
|
+
divider &&
|
|
97
|
+
(isHorizontal ? "border-s" : "border-t") +
|
|
98
|
+
// #163 — with both panes "plain" there is no fill/elevation
|
|
99
|
+
// difference between them, so the hairline is the ONLY cue and
|
|
100
|
+
// must clear the 3:1 non-text bar (WCAG 1.4.11):
|
|
101
|
+
// `border-border-strong` (`border-strong ≥ 3:1 on --card/
|
|
102
|
+
// --background` is proven for every theme in
|
|
103
|
+
// @elabs-ai/components-tokens's themes-contrast.test.ts).
|
|
104
|
+
// With either tone "muted"/"card" the fill change already
|
|
105
|
+
// carries the boundary, so the subtle default rung stays —
|
|
106
|
+
// the redundant-cue exemption in styling-and-tokens.md.
|
|
107
|
+
(startTone === "plain" && endTone === "plain" ? " border-border-strong" : ""),
|
|
88
108
|
endClassName,
|
|
89
109
|
)}
|
|
90
110
|
>
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { Button } from "../button";
|
|
3
3
|
import { StatePanel } from "./state-panel";
|
|
4
|
+
import {
|
|
5
|
+
EmptyListIllustration,
|
|
6
|
+
NoAccessIllustration,
|
|
7
|
+
ErrorIllustration,
|
|
8
|
+
} from "../../illustrations";
|
|
4
9
|
|
|
5
10
|
const meta = {
|
|
6
11
|
title: "States/StatePanel",
|
|
@@ -78,3 +83,50 @@ export const EmptyNoIcon: Story = {
|
|
|
78
83
|
description: "Items will appear here once available.",
|
|
79
84
|
},
|
|
80
85
|
};
|
|
86
|
+
|
|
87
|
+
export const WithIllustration: Story = {
|
|
88
|
+
name: "Empty (with illustration, #24)",
|
|
89
|
+
args: {
|
|
90
|
+
kind: "empty",
|
|
91
|
+
title: "No projects yet",
|
|
92
|
+
description: "Create your first project to get started.",
|
|
93
|
+
illustration: <EmptyListIllustration />,
|
|
94
|
+
actions: <Button size="sm">New project</Button>,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const NoAccess: Story = {
|
|
99
|
+
name: "Empty (no-access illustration)",
|
|
100
|
+
args: {
|
|
101
|
+
// "No access" is a blocked/informational state, not a system failure —
|
|
102
|
+
// kind stays "empty" (no alarm-red "Error" eyebrow) rather than "error".
|
|
103
|
+
kind: "empty",
|
|
104
|
+
title: "You don’t have access",
|
|
105
|
+
description: "Ask a workspace admin to grant you permission.",
|
|
106
|
+
illustration: <NoAccessIllustration />,
|
|
107
|
+
actions: (
|
|
108
|
+
<Button size="sm" variant="outline">
|
|
109
|
+
Request access
|
|
110
|
+
</Button>
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const ErrorWithIllustration: Story = {
|
|
116
|
+
name: "Error (with illustration, #24 P0-2)",
|
|
117
|
+
args: {
|
|
118
|
+
// The one panel `kind` that re-tints the illustration slot (`text-destructive`)
|
|
119
|
+
// — pairing it with an illustration is what surfaces whether the accent
|
|
120
|
+
// follows the tint or clashes against it. See P0-2 in the fix-round review:
|
|
121
|
+
// no shipped story previously rendered an illustration inside `kind="error"`.
|
|
122
|
+
kind: "error",
|
|
123
|
+
title: "Failed to load data",
|
|
124
|
+
description: "The server returned an unexpected response.",
|
|
125
|
+
illustration: <ErrorIllustration />,
|
|
126
|
+
actions: (
|
|
127
|
+
<Button size="sm" variant="outline">
|
|
128
|
+
Try again
|
|
129
|
+
</Button>
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import { StatePanel } from "./state-panel";
|
|
4
|
+
import {
|
|
5
|
+
EmptyListIllustration,
|
|
6
|
+
ErrorIllustration,
|
|
7
|
+
ILLUSTRATION_ACCENT_VAR,
|
|
8
|
+
} from "../../illustrations";
|
|
4
9
|
|
|
5
10
|
describe("StatePanel", () => {
|
|
6
11
|
it("renders empty kind with title and description", () => {
|
|
@@ -24,6 +29,48 @@ describe("StatePanel", () => {
|
|
|
24
29
|
expect(alert).toHaveAttribute("data-kind", "error");
|
|
25
30
|
});
|
|
26
31
|
|
|
32
|
+
it("error eyebrow uses the destructive INK rung, not the fill rung (#40)", () => {
|
|
33
|
+
// The eyebrow is running TEXT on the panel's own `bg-destructive/5` wash, so
|
|
34
|
+
// it must reach for `text-destructive-text` (>= 4.5:1, the text-rung
|
|
35
|
+
// contract) — never the bare `text-destructive` fill rung, whose contract
|
|
36
|
+
// is only the 3:1 mark bar. See styling-and-tokens.md "Which status rung a
|
|
37
|
+
// graphical MARK reaches for" (#381).
|
|
38
|
+
render(<StatePanel kind="error" />);
|
|
39
|
+
const eyebrow = screen.getByText("Error");
|
|
40
|
+
expect(eyebrow.className).toContain("text-destructive-text");
|
|
41
|
+
expect(eyebrow.className).not.toMatch(/(?<!-)\btext-destructive\b(?!-)/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("error icon keeps the destructive FILL rung (a mark, not text)", () => {
|
|
45
|
+
// Inverse of the eyebrow lock above: an icon IS a mark, so it correctly
|
|
46
|
+
// stays on the fill rung — the fix must not "tidy" it onto -text too.
|
|
47
|
+
const { container } = render(<StatePanel kind="error" />);
|
|
48
|
+
const iconWrapper = container.querySelector("svg")?.parentElement;
|
|
49
|
+
expect(iconWrapper?.className ?? "").toMatch(/\btext-destructive\b/);
|
|
50
|
+
expect(iconWrapper?.className ?? "").not.toContain("text-destructive-text");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("error rail carries the width cue (border-s-4) AND a destructive-family colour, not the neutral border-strong grey (#71)", () => {
|
|
54
|
+
// The rail's WIDTH (4px vs 1px) is the monochrome-survivable structural
|
|
55
|
+
// cue (per styling-and-tokens.md's border/border-strong decision test);
|
|
56
|
+
// its HUE should match the rest of the destructive family, not fall back
|
|
57
|
+
// to the neutral `border-strong` rung — that reads as a CSS-specificity
|
|
58
|
+
// bug, a 4px grey rail beside three red hairlines, not a deliberate
|
|
59
|
+
// accent (#71).
|
|
60
|
+
const { container } = render(<StatePanel kind="error" />);
|
|
61
|
+
const root = container.firstChild as HTMLElement;
|
|
62
|
+
expect(root.className).toMatch(/\bborder-s-4\b/);
|
|
63
|
+
expect(root.className).toMatch(/\bborder-s-destructive\b/);
|
|
64
|
+
expect(root.className).not.toMatch(/\bborder-s-border-strong\b/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("empty kind's rail carries neither the error width nor its destructive colour", () => {
|
|
68
|
+
const { container } = render(<StatePanel kind="empty" />);
|
|
69
|
+
const root = container.firstChild as HTMLElement;
|
|
70
|
+
expect(root.className).not.toMatch(/\bborder-s-4\b/);
|
|
71
|
+
expect(root.className).not.toMatch(/\bborder-s-destructive\b/);
|
|
72
|
+
});
|
|
73
|
+
|
|
27
74
|
it("renders error kind with custom title", () => {
|
|
28
75
|
render(<StatePanel kind="error" title="Custom error" description="Custom description" />);
|
|
29
76
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
@@ -61,4 +108,82 @@ describe("StatePanel", () => {
|
|
|
61
108
|
);
|
|
62
109
|
expect(screen.getByTestId("custom-icon")).toBeInTheDocument();
|
|
63
110
|
});
|
|
111
|
+
|
|
112
|
+
it("renders an illustration without the size-10 icon clamp, decoratively (#24)", () => {
|
|
113
|
+
const { container } = render(
|
|
114
|
+
<StatePanel kind="empty" title="No items" illustration={<EmptyListIllustration />} />,
|
|
115
|
+
);
|
|
116
|
+
const svg = container.querySelector("svg");
|
|
117
|
+
expect(svg).not.toBeNull();
|
|
118
|
+
expect(svg).toHaveAttribute("aria-hidden", "true");
|
|
119
|
+
// The illustration's own wrapper must NOT carry the `[&_svg]:size-10`
|
|
120
|
+
// clamp the default icon slot uses — its rem sizing governs instead.
|
|
121
|
+
const wrapper = svg?.parentElement;
|
|
122
|
+
expect(wrapper?.className ?? "").not.toContain("size-10");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("illustration path renders the title at the subtitle role, distinct from the icon path's text-sm (#47)", () => {
|
|
126
|
+
// A 112px+ illustration needs a heavier title to group with — bumping the
|
|
127
|
+
// title to `text-subtitle` (styling-and-tokens.md's typography-scale
|
|
128
|
+
// roles) only on the illustration path, so the plain `icon` path (a
|
|
129
|
+
// fixed 40x40 glyph) stays byte-identical to before.
|
|
130
|
+
const { container: withIllustration } = render(
|
|
131
|
+
<StatePanel kind="empty" title="No items" illustration={<EmptyListIllustration />} />,
|
|
132
|
+
);
|
|
133
|
+
const titleWithIllustration = withIllustration.querySelector("h3");
|
|
134
|
+
expect(titleWithIllustration?.className ?? "").toContain("text-subtitle");
|
|
135
|
+
expect(titleWithIllustration?.className ?? "").not.toMatch(/\btext-sm\b/);
|
|
136
|
+
|
|
137
|
+
const { container: withIcon } = render(
|
|
138
|
+
<StatePanel kind="empty" title="No items" icon={<span data-testid="i" />} />,
|
|
139
|
+
);
|
|
140
|
+
const titleWithIcon = withIcon.querySelector("h3");
|
|
141
|
+
expect(titleWithIcon?.className ?? "").toMatch(/\btext-sm\b/);
|
|
142
|
+
expect(titleWithIcon?.className ?? "").not.toContain("text-subtitle");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("error-kind illustration retints the accent to the TEXT rung, distinct from the retinted subject's FILL rung (#48)", () => {
|
|
146
|
+
// StatePanel wraps the illustration slot in `text-destructive` (the FILL
|
|
147
|
+
// rung — a mark, per styling-and-tokens.md "which status rung a
|
|
148
|
+
// graphical MARK reaches for") for kind="error". If the ambient
|
|
149
|
+
// `--illustration-accent` override it sets ALSO resolved to
|
|
150
|
+
// `--destructive`, the accent would collapse onto the subject the moment
|
|
151
|
+
// both read the same token (#48 finding 2). It must resolve to the TEXT
|
|
152
|
+
// rung instead — matching the convention every other illustration's own
|
|
153
|
+
// default fallback already uses (`--primary-text`, `--success-text`).
|
|
154
|
+
const { container } = render(<StatePanel kind="error" illustration={<ErrorIllustration />} />);
|
|
155
|
+
const svg = container.querySelector("svg");
|
|
156
|
+
const wrapper = svg?.parentElement as HTMLElement;
|
|
157
|
+
expect(wrapper.className).toMatch(/\btext-destructive\b/);
|
|
158
|
+
expect(wrapper.style.getPropertyValue(ILLUSTRATION_ACCENT_VAR)).toBe("var(--destructive-text)");
|
|
159
|
+
// Sanity: ErrorIllustration's own fallback (used when no ambient override
|
|
160
|
+
// is set) also reaches for the TEXT rung, not the FILL token the subject
|
|
161
|
+
// above just read.
|
|
162
|
+
const accentRect = container.querySelector("rect");
|
|
163
|
+
expect(accentRect?.getAttribute("style") ?? "").toContain("--destructive-text");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("prefers illustration over icon when both are given", () => {
|
|
167
|
+
render(
|
|
168
|
+
<StatePanel
|
|
169
|
+
kind="empty"
|
|
170
|
+
title="Both given"
|
|
171
|
+
icon={<span data-testid="custom-icon">icon</span>}
|
|
172
|
+
illustration={<EmptyListIllustration />}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
expect(screen.queryByTestId("custom-icon")).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("titleAs renders the requested heading level (#385)", () => {
|
|
179
|
+
render(<StatePanel kind="empty" title="No data" titleAs="h2" />);
|
|
180
|
+
const heading = screen.getByRole("heading", { level: 2, name: "No data" });
|
|
181
|
+
expect(heading.tagName).toBe("H2");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("default titleAs is still h3 — unchanged output for every existing caller (#385)", () => {
|
|
185
|
+
render(<StatePanel kind="empty" title="No data" />);
|
|
186
|
+
const heading = screen.getByRole("heading", { level: 3, name: "No data" });
|
|
187
|
+
expect(heading.tagName).toBe("H3");
|
|
188
|
+
});
|
|
64
189
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import { CircleAlert } from "lucide-react";
|
|
3
3
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
|
+
import { ILLUSTRATION_ACCENT_VAR } from "../../illustrations/illustration-base";
|
|
5
6
|
|
|
6
7
|
// ─── Variants ────────────────────────────────────────────────────────────────
|
|
7
8
|
|
|
@@ -14,11 +15,22 @@ export const statePanelVariants = cva(
|
|
|
14
15
|
variants: {
|
|
15
16
|
kind: {
|
|
16
17
|
empty: "border border-dashed bg-surface",
|
|
17
|
-
// error:
|
|
18
|
-
// (border-s-4
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
18
|
+
// error: the non-color, monochrome-survivable cue is the rail's WIDTH
|
|
19
|
+
// (border-s-4, 4px, vs the 1px hairline on the other three edges) —
|
|
20
|
+
// not its hue (per styling-and-tokens.md's border/border-strong
|
|
21
|
+
// decision test: "if I deleted this line, could a sighted user still
|
|
22
|
+
// tell the two regions apart?" here it's the thickness that answers
|
|
23
|
+
// yes). So the rail's COLOR should stay in the destructive family
|
|
24
|
+
// like the rest of the panel, not fall back to the neutral
|
|
25
|
+
// `border-strong` rung — a 4px grey rail beside three red hairlines
|
|
26
|
+
// read as a CSS-specificity bug, not a deliberate accent (#71).
|
|
27
|
+
// `border-s-destructive` is the FILL rung (styling-and-tokens.md
|
|
28
|
+
// "which status rung a graphical MARK reaches for") — guaranteed
|
|
29
|
+
// >=3:1 against --card/--background in every theme, so it still
|
|
30
|
+
// satisfies the WCAG 1.4.11 non-text-contrast guarantee the old
|
|
31
|
+
// `border-border-strong` comment claimed. The icon (:87-91) and the
|
|
32
|
+
// eyebrow (:148-152 below) are the third and fourth non-color cues.
|
|
33
|
+
error: "border border-destructive/30 bg-destructive/5 border-s-4 border-s-destructive",
|
|
22
34
|
loading: "",
|
|
23
35
|
},
|
|
24
36
|
},
|
|
@@ -41,11 +53,29 @@ export interface StatePanelProps extends VariantProps<typeof statePanelVariants>
|
|
|
41
53
|
/** Supporting description below the title. */
|
|
42
54
|
description?: ReactNode;
|
|
43
55
|
/**
|
|
44
|
-
* Icon
|
|
45
|
-
* For `"error"` defaults to a built-in warning triangle.
|
|
46
|
-
* For `"loading"` defaults to an animated spinner.
|
|
56
|
+
* Icon shown above the title, clamped to a uniform 40×40 so mismatched
|
|
57
|
+
* glyphs stay tidy. For `"error"` defaults to a built-in warning triangle.
|
|
58
|
+
* For `"loading"` defaults to an animated spinner. Ignored when
|
|
59
|
+
* `illustration` is also provided — see `illustration`.
|
|
47
60
|
*/
|
|
48
61
|
icon?: ReactNode;
|
|
62
|
+
/**
|
|
63
|
+
* A larger illustration (one of `@elabs-ai/components-ui`'s shipped
|
|
64
|
+
* `*Illustration` components, or a consumer's own `ReactNode`) shown above
|
|
65
|
+
* the title instead of `icon`. Unlike `icon`, it is **not** clamped to
|
|
66
|
+
* 40×40 — an illustration governs its own size (see each illustration's
|
|
67
|
+
* `size` prop, `rem`-based, ~64px–160px). Takes precedence over `icon`
|
|
68
|
+
* when both are given.
|
|
69
|
+
*/
|
|
70
|
+
illustration?: ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* Heading level for the panel's title (#328 convention). Set this to the
|
|
73
|
+
* level that correctly follows whatever heading precedes this panel in
|
|
74
|
+
* the document outline — e.g. `"h3"` inside a `ChartCard` whose own title
|
|
75
|
+
* is an `<h2>`.
|
|
76
|
+
* @default "h3"
|
|
77
|
+
*/
|
|
78
|
+
titleAs?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
49
79
|
/** Accessible label for the spinner in loading state. @default "Loading…" */
|
|
50
80
|
loadingLabel?: string;
|
|
51
81
|
/** Spinner size (loading kind only). @default "md" */
|
|
@@ -91,6 +121,8 @@ export function StatePanel({
|
|
|
91
121
|
title,
|
|
92
122
|
description,
|
|
93
123
|
icon,
|
|
124
|
+
illustration,
|
|
125
|
+
titleAs: Tag = "h3",
|
|
94
126
|
loadingLabel = "Loading…",
|
|
95
127
|
size = "md",
|
|
96
128
|
actions,
|
|
@@ -124,17 +156,48 @@ export function StatePanel({
|
|
|
124
156
|
return (
|
|
125
157
|
<div data-kind={kind} className={cn(statePanelVariants({ kind }), className)} {...roleProps}>
|
|
126
158
|
{/* Error eyebrow: "Error" label shown above the icon so the panel reads
|
|
127
|
-
as an error structurally even in monochrome themes.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
159
|
+
as an error structurally even in monochrome themes. This is running
|
|
160
|
+
TEXT, not a mark, so it takes the ink rung `text-destructive-text`
|
|
161
|
+
(>= 4.5:1, gated in themes-contrast.test.ts) — not the fill rung
|
|
162
|
+
`text-destructive`, whose contract is only the 3:1 mark bar (see
|
|
163
|
+
"Which status rung a graphical MARK reaches for", styling-and-tokens.md
|
|
164
|
+
#381). Combined with the thick left border from statePanelVariants it
|
|
165
|
+
gives two non-color structural cues. The icon below deliberately
|
|
166
|
+
KEEPS text-destructive: an icon is a mark, so the fill rung is
|
|
167
|
+
correct there — do not "tidy" it onto -text too. */}
|
|
131
168
|
{isError && (
|
|
132
|
-
<span className="text-
|
|
169
|
+
<span className="text-meta font-semibold uppercase tracking-widest text-destructive-text">
|
|
133
170
|
Error
|
|
134
171
|
</span>
|
|
135
172
|
)}
|
|
136
173
|
|
|
137
|
-
{
|
|
174
|
+
{illustration ? (
|
|
175
|
+
// Illustration wins over `icon` when both are given. No `[&_svg]:size-10`
|
|
176
|
+
// clamp here — an illustration governs its own (rem-based) size, unlike
|
|
177
|
+
// the fixed-size icon slot below. For `kind="error"` this also sets the
|
|
178
|
+
// `--illustration-accent` custom property every illustration's accent
|
|
179
|
+
// ink reads through, so ANY illustration (not just `ErrorIllustration`)
|
|
180
|
+
// retints its accent to `--destructive-text` instead of staying pinned
|
|
181
|
+
// to its default hue inside a red-tinted slot (#24 fix round 1, P0-2).
|
|
182
|
+
// TEXT rung, not the FILL token below: the wrapper's `text-destructive`
|
|
183
|
+
// class already puts the SUBJECT (silhouette) on the fill rung, so an
|
|
184
|
+
// accent override reading the same `--destructive` token collapsed
|
|
185
|
+
// onto it and disappeared (#48 finding 2) — `--destructive-text` is a
|
|
186
|
+
// deliberately distinct, deeper rung (matches the fallback every
|
|
187
|
+
// other illustration's own accent already uses: `--primary-text`,
|
|
188
|
+
// `--success-text`) so the accent stays visible against the retinted
|
|
189
|
+
// silhouette.
|
|
190
|
+
<div
|
|
191
|
+
className={cn(isError ? "text-destructive" : "text-muted-foreground")}
|
|
192
|
+
style={
|
|
193
|
+
isError
|
|
194
|
+
? ({ [ILLUSTRATION_ACCENT_VAR]: "var(--destructive-text)" } as CSSProperties)
|
|
195
|
+
: undefined
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
{illustration}
|
|
199
|
+
</div>
|
|
200
|
+
) : resolvedIcon ? (
|
|
138
201
|
// Error icon keeps text-destructive (not muted) so it stays visible even
|
|
139
202
|
// when rendered at low contrast. In monochrome themes the icon shape
|
|
140
203
|
// (CircleAlert) carries the cue; the color is supplemental.
|
|
@@ -146,9 +209,25 @@ export function StatePanel({
|
|
|
146
209
|
) : null}
|
|
147
210
|
|
|
148
211
|
{(resolvedTitle || resolvedDescription) && (
|
|
149
|
-
|
|
212
|
+
// A 112px+ illustration (default `size="7rem"`) needs a heavier title
|
|
213
|
+
// to group with, and more air between the artwork and the copy than
|
|
214
|
+
// between the title and its own description — otherwise the caption
|
|
215
|
+
// reads as an afterthought under the artwork instead of one grouped
|
|
216
|
+
// unit (#47). `mt-2` (on top of the root's `gap-3`) only fires on the
|
|
217
|
+
// illustration path; the plain `icon` path (a fixed 40x40 glyph) is
|
|
218
|
+
// untouched. The title/description gap stays the tight `space-y-1`
|
|
219
|
+
// either way — that's the "one visually distinct unit" the two lines
|
|
220
|
+
// read as.
|
|
221
|
+
<div className={cn("space-y-1", illustration && "mt-2")}>
|
|
150
222
|
{resolvedTitle && (
|
|
151
|
-
<
|
|
223
|
+
<Tag
|
|
224
|
+
className={cn(
|
|
225
|
+
"font-semibold text-foreground",
|
|
226
|
+
illustration ? "text-subtitle" : "text-sm",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
{resolvedTitle}
|
|
230
|
+
</Tag>
|
|
152
231
|
)}
|
|
153
232
|
{resolvedDescription && (
|
|
154
233
|
<p className="mx-auto max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
|
|
@@ -6,6 +6,14 @@ const meta = {
|
|
|
6
6
|
title: "Core/StatusBadge",
|
|
7
7
|
component: StatusBadge,
|
|
8
8
|
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The closed seven-state execution-status vocabulary, each state carrying its own icon as well as its own tone so two states are still tellable apart in greyscale. A neutral label or count with no state semantics is `Core/Badge`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
9
17
|
args: { status: "complete" },
|
|
10
18
|
argTypes: {
|
|
11
19
|
status: {
|
|
@@ -11,7 +11,7 @@ export const Switch = forwardRef<
|
|
|
11
11
|
ref={ref}
|
|
12
12
|
className={cn(
|
|
13
13
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-fast ease-standard",
|
|
14
|
-
"focus-
|
|
14
|
+
"focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
15
15
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
16
16
|
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
17
17
|
className,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
2
3
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table";
|
|
3
4
|
const meta = {
|
|
4
5
|
title: "Data/Table",
|
|
@@ -8,6 +9,9 @@ const meta = {
|
|
|
8
9
|
docs: {
|
|
9
10
|
description: {
|
|
10
11
|
component:
|
|
12
|
+
"The STATIC table markup you lay out yourself; the interactive grid is " +
|
|
13
|
+
"`Data/DataTable` — see " +
|
|
14
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
11
15
|
"Low-level static table primitives (header/body/row/cell styling). For sorting, filtering, " +
|
|
12
16
|
"pagination, virtualization, row selection and column management, use the TanStack-powered " +
|
|
13
17
|
"DataTable (see Data/DataTable, @elabs-ai/components-data) instead.",
|
|
@@ -51,3 +55,61 @@ export const Default: Story = {
|
|
|
51
55
|
</Table>
|
|
52
56
|
),
|
|
53
57
|
};
|
|
58
|
+
|
|
59
|
+
// ─── Scrolling (#366) ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A fixed-height container with more rows than fit — `Table`'s own scroll
|
|
63
|
+
* wrapper is genuinely `overflow-auto` here, and that is exactly what no
|
|
64
|
+
* `Table` story ever exercised before #366: the wrapper's own keyboard
|
|
65
|
+
* affordance (WCAG 2.1.1 / axe `scrollable-region-focusable`) shipped
|
|
66
|
+
* unguarded on this package's own turf, only incidentally covered elsewhere.
|
|
67
|
+
* `Tab` reaches the region, and it carries the accessible name naming it a
|
|
68
|
+
* scrollable region — see `table.test.tsx` for the fitting-table negative.
|
|
69
|
+
*/
|
|
70
|
+
export const Scrolling: Story = {
|
|
71
|
+
render: () => (
|
|
72
|
+
// `Table`'s own scroll wrapper has no explicit height of its own — like any
|
|
73
|
+
// block box it grows to fit its content unless something makes it a flex
|
|
74
|
+
// item with a bounded main-axis size. A plain `<div style={{height:160}}>`
|
|
75
|
+
// ancestor does NOT do that (block children ignore a parent's height), so
|
|
76
|
+
// this outer div is a column flex container: its one child (the wrapper,
|
|
77
|
+
// which already carries `overflow-auto`) gets an automatic flex-basis
|
|
78
|
+
// minimum size of 0 per the flexbox spec's overflow carve-out, letting it
|
|
79
|
+
// shrink to the 160px bound instead of growing to its ~750px content.
|
|
80
|
+
<div style={{ height: 160, display: "flex", flexDirection: "column" }}>
|
|
81
|
+
<Table>
|
|
82
|
+
<TableHeader>
|
|
83
|
+
<TableRow>
|
|
84
|
+
<TableHead>Service</TableHead>
|
|
85
|
+
<TableHead>Status</TableHead>
|
|
86
|
+
</TableRow>
|
|
87
|
+
</TableHeader>
|
|
88
|
+
<TableBody>
|
|
89
|
+
{Array.from({ length: 20 }, (_, i) => (
|
|
90
|
+
<TableRow key={i}>
|
|
91
|
+
<TableCell>service-{i}</TableCell>
|
|
92
|
+
<TableCell>{i % 2 === 0 ? "healthy" : "degraded"}</TableCell>
|
|
93
|
+
</TableRow>
|
|
94
|
+
))}
|
|
95
|
+
</TableBody>
|
|
96
|
+
</Table>
|
|
97
|
+
</div>
|
|
98
|
+
),
|
|
99
|
+
play: async ({ canvasElement, userEvent }) => {
|
|
100
|
+
const scrollRegion = await waitFor(() => {
|
|
101
|
+
const el = canvasElement.querySelector<HTMLElement>('[data-slot="table-scroll-region"]');
|
|
102
|
+
if (!el || el.scrollHeight <= el.clientHeight) throw new Error("not overflowing yet");
|
|
103
|
+
return el;
|
|
104
|
+
});
|
|
105
|
+
await expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
106
|
+
await expect(scrollRegion).toHaveAccessibleName("Table contents, scrollable");
|
|
107
|
+
await expect(scrollRegion).not.toHaveAttribute("role");
|
|
108
|
+
|
|
109
|
+
// The region is the only focusable element the story renders, so a
|
|
110
|
+
// single Tab from nothing focused reaches it — the real WCAG 2.1.1
|
|
111
|
+
// reproduction from the issue ("press Tab from the top").
|
|
112
|
+
await userEvent.tab();
|
|
113
|
+
await waitFor(() => expect(document.activeElement).toBe(scrollRegion));
|
|
114
|
+
},
|
|
115
|
+
};
|