@elabs-ai/components-ui 4.0.0 → 4.2.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-FCH4ZN6G.js +57 -0
- package/dist/chunk-FCH4ZN6G.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 +2597 -188
- package/dist/index.js +12855 -3578
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +21 -7
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +15 -4
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +56 -4
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +3 -1
- package/src/components/attribution-panel/attributions.generated.ts +83 -79
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +15 -5
- package/src/components/bento-grid/bento-grid.test.tsx +24 -3
- package/src/components/bento-grid/bento-grid.tsx +11 -1
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +10 -4
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +48 -7
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- 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 +214 -24
- package/src/components/checkbox/checkbox.tsx +2 -1
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +22 -15
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +88 -0
- package/src/components/command/command.tsx +38 -13
- 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-menu/context-menu.tsx +16 -7
- 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.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +13 -2
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +14 -6
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- 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 +342 -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 +50 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +90 -12
- package/src/components/file-upload/file-upload.tsx +164 -31
- package/src/components/form/form.tsx +8 -3
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +3 -3
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +8 -4
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +18 -9
- package/src/components/input-otp/input-otp.tsx +8 -2
- 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/label/label.tsx +1 -1
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- 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 +822 -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.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +66 -46
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/menubar/menubar.tsx +16 -6
- 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 +8 -4
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +67 -9
- package/src/components/nav-user/nav-user.tsx +5 -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 +204 -15
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- 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 +109 -0
- package/src/components/pagination/pagination.tsx +27 -19
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +3 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +32 -6
- 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 +53 -5
- 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-store.ts +506 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +1021 -0
- package/src/components/schema-form/schema-form.tsx +1381 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +14 -3
- package/src/components/select/select.tsx +13 -7
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- 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 +299 -0
- package/src/components/side-dock/side-dock.tsx +515 -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 +71 -2
- package/src/components/sidebar/sidebar.tsx +264 -38
- package/src/components/skeleton/skeleton.tsx +1 -0
- 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/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- 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 +110 -24
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +3 -1
- package/src/components/table/table.stories.tsx +64 -0
- package/src/components/table/table.test.tsx +149 -1
- package/src/components/table/table.tsx +71 -2
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +176 -72
- package/src/components/tag-input/tag-input.tsx +6 -3
- package/src/components/team-switcher/team-switcher.tsx +35 -6
- package/src/components/textarea/textarea.tsx +2 -2
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +140 -12
- 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 +10 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +91 -31
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- 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.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +29 -3
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +13 -5
- 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 +80 -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/cn.ts +9 -0
- package/src/lib/csv.ts +48 -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 +94 -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/merge-refs.ts +2 -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-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +34 -12
- 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
|
@@ -103,3 +103,166 @@ export const ToolbarSticky: Story = {
|
|
|
103
103
|
await expect(Math.abs(headerTopAfterScroll - headerTopStuck)).toBeLessThanOrEqual(1);
|
|
104
104
|
},
|
|
105
105
|
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `scroll="content"` makes `PageShell` itself the scroll port, for embedding
|
|
109
|
+
* inside a `SidebarInset` whose height is already bounded — the app chrome
|
|
110
|
+
* outside it (the top bar here) never moves.
|
|
111
|
+
*/
|
|
112
|
+
export const ScrollContent: Story = {
|
|
113
|
+
render: function ScrollContentStory() {
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex h-[420px] flex-col border border-border">
|
|
116
|
+
<div
|
|
117
|
+
data-testid="app-topbar"
|
|
118
|
+
className="shrink-0 border-b border-border bg-sidebar px-4 py-2 text-body"
|
|
119
|
+
>
|
|
120
|
+
App chrome outside PageShell
|
|
121
|
+
</div>
|
|
122
|
+
<PageShell scroll="content" data-testid="scroll-content-shell">
|
|
123
|
+
<div className="flex flex-col gap-3">
|
|
124
|
+
{Array.from({ length: 40 }, (_, i) => (
|
|
125
|
+
<div
|
|
126
|
+
key={i}
|
|
127
|
+
data-testid={i === 20 ? "marker-row" : undefined}
|
|
128
|
+
className="rounded-lg border bg-card p-4 text-body"
|
|
129
|
+
>
|
|
130
|
+
Row {i + 1}
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</PageShell>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
play: async ({ canvasElement }) => {
|
|
139
|
+
const shell = canvasElement.querySelector<HTMLElement>('[data-testid="scroll-content-shell"]');
|
|
140
|
+
const marker = canvasElement.querySelector<HTMLElement>('[data-testid="marker-row"]');
|
|
141
|
+
const topbar = canvasElement.querySelector<HTMLElement>('[data-testid="app-topbar"]');
|
|
142
|
+
|
|
143
|
+
await expect(shell).not.toBeNull();
|
|
144
|
+
await expect(marker).not.toBeNull();
|
|
145
|
+
await expect(topbar).not.toBeNull();
|
|
146
|
+
|
|
147
|
+
await expect(getComputedStyle(shell!).overflowY).toBe("auto");
|
|
148
|
+
|
|
149
|
+
// scroll="content" makes this element the scroll port, so it defaults to
|
|
150
|
+
// keyboard-operable (WCAG 2.1.1, axe `scrollable-region-focusable`). Read
|
|
151
|
+
// the ATTRIBUTE, not the `tabIndex` IDL getter — the getter reports a
|
|
152
|
+
// default for an element that carries no attribute at all.
|
|
153
|
+
await expect(shell!.getAttribute("tabindex")).toBe("0");
|
|
154
|
+
// And the rung is the inset one: `scroll="content"` is documented for use
|
|
155
|
+
// inside a `SidebarInset`, whose `overflow-hidden` clips both layers of the
|
|
156
|
+
// plain rung. `classList` is an exact token match, so this cannot be
|
|
157
|
+
// satisfied by `focus-ring-inset` being a superstring of `focus-ring`.
|
|
158
|
+
await expect(shell!.classList.contains("focus-ring-inset")).toBe(true);
|
|
159
|
+
await expect(shell!.classList.contains("focus-ring")).toBe(false);
|
|
160
|
+
|
|
161
|
+
const topbarTopBefore = topbar!.getBoundingClientRect().top;
|
|
162
|
+
const markerTopBefore = marker!.getBoundingClientRect().top;
|
|
163
|
+
|
|
164
|
+
shell!.scrollTop = 300;
|
|
165
|
+
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
expect(marker!.getBoundingClientRect().top).toBeLessThan(markerTopBefore);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// The chrome outside PageShell never moved — PageShell owns its own scroll.
|
|
171
|
+
await expect(topbar!.getBoundingClientRect().top).toBe(topbarTopBefore);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `scroll="fill"` fills its parent and scrolls nothing itself — a child (here
|
|
177
|
+
* an inner list) owns the overflow instead.
|
|
178
|
+
*/
|
|
179
|
+
export const ScrollFill: Story = {
|
|
180
|
+
render: function ScrollFillStory() {
|
|
181
|
+
return (
|
|
182
|
+
<div className="h-[320px] border border-border">
|
|
183
|
+
<PageShell
|
|
184
|
+
scroll="fill"
|
|
185
|
+
data-testid="scroll-fill-shell"
|
|
186
|
+
contentClassName="flex h-full min-h-0 flex-col"
|
|
187
|
+
>
|
|
188
|
+
<div
|
|
189
|
+
data-testid="inner-scroll"
|
|
190
|
+
// The inner list is the scroll port here (`scroll="fill"` means
|
|
191
|
+
// PageShell itself owns none of it), so THIS element needs the
|
|
192
|
+
// keyboard-operable fix (WCAG 2.1.1, axe
|
|
193
|
+
// `scrollable-region-focusable`) — same precedent as `DialogBody`.
|
|
194
|
+
tabIndex={0}
|
|
195
|
+
className="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border bg-card p-2 focus-ring"
|
|
196
|
+
>
|
|
197
|
+
{Array.from({ length: 40 }, (_, i) => (
|
|
198
|
+
<div
|
|
199
|
+
key={i}
|
|
200
|
+
data-testid={i === 20 ? "marker-row" : undefined}
|
|
201
|
+
className="rounded-md bg-surface-muted p-2 text-body"
|
|
202
|
+
>
|
|
203
|
+
Row {i + 1}
|
|
204
|
+
</div>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
</PageShell>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
play: async ({ canvasElement }) => {
|
|
212
|
+
const shell = canvasElement.querySelector<HTMLElement>('[data-testid="scroll-fill-shell"]');
|
|
213
|
+
const inner = canvasElement.querySelector<HTMLElement>('[data-testid="inner-scroll"]');
|
|
214
|
+
const marker = canvasElement.querySelector<HTMLElement>('[data-testid="marker-row"]');
|
|
215
|
+
|
|
216
|
+
await expect(shell).not.toBeNull();
|
|
217
|
+
await expect(inner).not.toBeNull();
|
|
218
|
+
await expect(marker).not.toBeNull();
|
|
219
|
+
|
|
220
|
+
// PageShell itself does not scroll — the inner list owns the overflow.
|
|
221
|
+
await expect(getComputedStyle(shell!).overflow).toBe("hidden");
|
|
222
|
+
await expect(getComputedStyle(inner!).overflowY).toBe("auto");
|
|
223
|
+
|
|
224
|
+
const markerTopBefore = marker!.getBoundingClientRect().top;
|
|
225
|
+
inner!.scrollTop = 300;
|
|
226
|
+
await waitFor(() => {
|
|
227
|
+
expect(marker!.getBoundingClientRect().top).toBeLessThan(markerTopBefore);
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `headerGutter` reserves a fixed-height header row so a page title lands at
|
|
234
|
+
* the same vertical coordinate on every route that turns it on.
|
|
235
|
+
*/
|
|
236
|
+
export const WithHeaderGutter: Story = {
|
|
237
|
+
render: () => (
|
|
238
|
+
<PageShell headerGutter header={<h1 className="text-title font-medium">Pipeline runs</h1>}>
|
|
239
|
+
<div className="rounded-xl border bg-card p-6 text-body text-muted-foreground">Content</div>
|
|
240
|
+
</PageShell>
|
|
241
|
+
),
|
|
242
|
+
play: async ({ canvasElement }) => {
|
|
243
|
+
const gutter = canvasElement.querySelector<HTMLElement>('[data-slot="page-shell-header"]');
|
|
244
|
+
await expect(gutter).not.toBeNull();
|
|
245
|
+
await expect(getComputedStyle(gutter!).minHeight).toBe("48px");
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The reserved row renders even on a route with no header at all — an empty
|
|
251
|
+
* band, not a missing one — which is what keeps titles aligned across routes
|
|
252
|
+
* that do and don't have one (ADR 0035).
|
|
253
|
+
*/
|
|
254
|
+
export const WithHeaderGutterNoHeader: Story = {
|
|
255
|
+
render: () => (
|
|
256
|
+
<PageShell headerGutter>
|
|
257
|
+
<div className="rounded-xl border bg-card p-6 text-body text-muted-foreground">
|
|
258
|
+
This route has no header, but the reserved row above still holds its space.
|
|
259
|
+
</div>
|
|
260
|
+
</PageShell>
|
|
261
|
+
),
|
|
262
|
+
play: async ({ canvasElement }) => {
|
|
263
|
+
const gutter = canvasElement.querySelector<HTMLElement>('[data-slot="page-shell-header"]');
|
|
264
|
+
await expect(gutter).not.toBeNull();
|
|
265
|
+
await expect(gutter!.textContent).toBe("");
|
|
266
|
+
await expect(getComputedStyle(gutter!).minHeight).toBe("48px");
|
|
267
|
+
},
|
|
268
|
+
};
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
import { render, screen } from "@testing-library/react";
|
|
3
4
|
import { PageShell } from "./page-shell";
|
|
4
5
|
|
|
6
|
+
/** True if any element in the tree carries a `min-h-*` utility class. */
|
|
7
|
+
function hasAnyMinHeightClass(root: HTMLElement): boolean {
|
|
8
|
+
const all = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
|
9
|
+
return all.some((el) => /(^|\s)min-h-\S/.test(el.className));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const HEADER_VARIANTS = [undefined, "default", "toolbar"] as const;
|
|
13
|
+
const WIDTHS = [undefined, "md", "lg", "xl", "full"] as const;
|
|
14
|
+
const HEADERS = [false, true] as const;
|
|
15
|
+
|
|
5
16
|
describe("PageShell", () => {
|
|
6
17
|
it("headerVariant omitted: renders the header inline with no sticky wrapper (AC2 — byte-identical default)", () => {
|
|
7
18
|
render(<PageShell header={<div data-testid="hdr">H</div>}>body</PageShell>);
|
|
@@ -54,4 +65,222 @@ describe("PageShell", () => {
|
|
|
54
65
|
|
|
55
66
|
expect(document.querySelector('[data-slot="page-shell-toolbar-header"]')).toBeNull();
|
|
56
67
|
});
|
|
68
|
+
|
|
69
|
+
describe("component-API baseline (forwardRef, spread, data-slot)", () => {
|
|
70
|
+
it("forwards a ref to the root element", () => {
|
|
71
|
+
const ref = createRef<HTMLDivElement>();
|
|
72
|
+
const { container } = render(<PageShell ref={ref}>body</PageShell>);
|
|
73
|
+
|
|
74
|
+
expect(ref.current).not.toBeNull();
|
|
75
|
+
expect(ref.current).toBe(container.firstElementChild);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("spreads extra DOM props (id, data-testid, aria-label) onto the root", () => {
|
|
79
|
+
render(
|
|
80
|
+
<PageShell id="my-page" data-testid="ps-root" aria-label="Runs">
|
|
81
|
+
body
|
|
82
|
+
</PageShell>,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const root = screen.getByTestId("ps-root");
|
|
86
|
+
expect(root.id).toBe("my-page");
|
|
87
|
+
expect(root.getAttribute("aria-label")).toBe("Runs");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('carries data-slot="page-shell" on the root and "page-shell-content" on the inner column', () => {
|
|
91
|
+
const { container } = render(<PageShell>body</PageShell>);
|
|
92
|
+
const root = container.firstElementChild as HTMLElement;
|
|
93
|
+
|
|
94
|
+
expect(root.getAttribute("data-slot")).toBe("page-shell");
|
|
95
|
+
expect(root.querySelector('[data-slot="page-shell-content"]')).not.toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('scroll="body" (default) — byte-identical to pre-R6, across every header/headerVariant/width combination', () => {
|
|
100
|
+
for (const headerVariant of HEADER_VARIANTS) {
|
|
101
|
+
for (const width of WIDTHS) {
|
|
102
|
+
for (const withHeader of HEADERS) {
|
|
103
|
+
const label = `header=${withHeader} headerVariant=${headerVariant ?? "(unset)"} width=${width ?? "(unset)"}`;
|
|
104
|
+
|
|
105
|
+
it(`root class is exactly the pre-R6 literal, no min-h-* anywhere, no gutter row — ${label}`, () => {
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<PageShell
|
|
108
|
+
header={withHeader ? <div data-testid="hdr">H</div> : undefined}
|
|
109
|
+
headerVariant={headerVariant}
|
|
110
|
+
width={width}
|
|
111
|
+
>
|
|
112
|
+
body
|
|
113
|
+
</PageShell>,
|
|
114
|
+
);
|
|
115
|
+
const root = container.firstElementChild as HTMLElement;
|
|
116
|
+
|
|
117
|
+
// (1) The additive default: root's own class string is unchanged, whatever
|
|
118
|
+
// the header/headerVariant/width combination — those axes never touch it.
|
|
119
|
+
expect(root.className).toBe("w-full px-4 py-6 sm:px-6 lg:px-8");
|
|
120
|
+
|
|
121
|
+
// (2) No min-h-* class anywhere in the tree — the gutter mechanism is
|
|
122
|
+
// entirely absent, not merely invisible.
|
|
123
|
+
expect(hasAnyMinHeightClass(root)).toBe(false);
|
|
124
|
+
|
|
125
|
+
// (3) No gutter row — `headerGutter: false` must not produce the empty
|
|
126
|
+
// reserved row ADR 0035 calls "the whole mechanism".
|
|
127
|
+
expect(root.querySelector('[data-slot="page-shell-header"]')).toBeNull();
|
|
128
|
+
|
|
129
|
+
// (4) The header itself renders exactly where it always did.
|
|
130
|
+
if (withHeader) {
|
|
131
|
+
const hdr = screen.getByTestId("hdr");
|
|
132
|
+
if (headerVariant === "toolbar") {
|
|
133
|
+
const toolbarWrapper = root.querySelector(
|
|
134
|
+
'[data-slot="page-shell-toolbar-header"]',
|
|
135
|
+
);
|
|
136
|
+
expect(toolbarWrapper).not.toBeNull();
|
|
137
|
+
expect(toolbarWrapper).toContainElement(hdr);
|
|
138
|
+
} else {
|
|
139
|
+
expect(root.querySelector('[data-slot="page-shell-toolbar-header"]')).toBeNull();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it('passing scroll="body" explicitly is identical to omitting it', () => {
|
|
148
|
+
const { container: withProp } = render(<PageShell scroll="body">body</PageShell>);
|
|
149
|
+
const { container: omitted } = render(<PageShell>body</PageShell>);
|
|
150
|
+
|
|
151
|
+
expect((withProp.firstElementChild as HTMLElement).className).toBe(
|
|
152
|
+
(omitted.firstElementChild as HTMLElement).className,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("scroll modes", () => {
|
|
158
|
+
it('scroll="content" makes the inner column the scroll container', () => {
|
|
159
|
+
const { container } = render(<PageShell scroll="content">body</PageShell>);
|
|
160
|
+
const root = container.firstElementChild as HTMLElement;
|
|
161
|
+
expect(root.className).toContain("min-h-0");
|
|
162
|
+
expect(root.className).toContain("flex-1");
|
|
163
|
+
expect(root.className).toContain("overflow-y-auto");
|
|
164
|
+
// The base padding/width classes are still present — additive, not a replacement.
|
|
165
|
+
expect(root.className).toContain("w-full");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('scroll="fill" fills its parent and does not scroll itself', () => {
|
|
169
|
+
const { container } = render(<PageShell scroll="fill">body</PageShell>);
|
|
170
|
+
const root = container.firstElementChild as HTMLElement;
|
|
171
|
+
expect(root.className).toContain("h-full");
|
|
172
|
+
expect(root.className).toContain("overflow-hidden");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("a caller className can still override a scroll-mode utility (className merges last)", () => {
|
|
176
|
+
const { container } = render(
|
|
177
|
+
<PageShell scroll="fill" className="overflow-visible">
|
|
178
|
+
body
|
|
179
|
+
</PageShell>,
|
|
180
|
+
);
|
|
181
|
+
const root = container.firstElementChild as HTMLElement;
|
|
182
|
+
expect(root.className).toContain("overflow-visible");
|
|
183
|
+
expect(root.className).not.toContain("overflow-hidden");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('scroll="content" defaults to keyboard-operable (WCAG 2.1.1 / axe scrollable-region-focusable)', () => {
|
|
187
|
+
const { container } = render(<PageShell scroll="content">body</PageShell>);
|
|
188
|
+
const root = container.firstElementChild as HTMLElement;
|
|
189
|
+
// `getAttribute`, never the `tabIndex` IDL getter: that getter answers a
|
|
190
|
+
// DEFAULT (-1 here, 0 for natively focusable elements) whether or not the
|
|
191
|
+
// attribute was ever written, so `expect(root.tabIndex).toBe(-1)` and the
|
|
192
|
+
// "gains no tab stop" cases below would pass on a component that emits
|
|
193
|
+
// nothing at all.
|
|
194
|
+
expect(root.getAttribute("tabindex")).toBe("0");
|
|
195
|
+
// `classList.contains` is an EXACT token match. `toContain("focus-ring")`
|
|
196
|
+
// is a substring test that `focus-ring-inset` satisfies, so it could not
|
|
197
|
+
// tell the two rungs apart — it passed before this fix and would pass
|
|
198
|
+
// after it, proving nothing either way.
|
|
199
|
+
expect(root.classList.contains("focus-ring-inset")).toBe(true);
|
|
200
|
+
// The plain rung must be ABSENT, not merely accompanied: both of its
|
|
201
|
+
// layers are drawn outside the element's box, and `scroll="content"`'s
|
|
202
|
+
// own doc sends callers to put this inside a `SidebarInset`, whose
|
|
203
|
+
// `overflow-hidden` clips exactly that.
|
|
204
|
+
expect(root.classList.contains("focus-ring")).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('scroll="content" lets a caller opt out of the default tab stop', () => {
|
|
208
|
+
const { container } = render(
|
|
209
|
+
<PageShell scroll="content" tabIndex={-1}>
|
|
210
|
+
body
|
|
211
|
+
</PageShell>,
|
|
212
|
+
);
|
|
213
|
+
const root = container.firstElementChild as HTMLElement;
|
|
214
|
+
expect(root.getAttribute("tabindex")).toBe("-1");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('scroll="fill" does not gain a default tab stop — the child scroll region owns it', () => {
|
|
218
|
+
const { container } = render(<PageShell scroll="fill">body</PageShell>);
|
|
219
|
+
const root = container.firstElementChild as HTMLElement;
|
|
220
|
+
expect(root.getAttribute("tabindex")).toBeNull();
|
|
221
|
+
expect(root.classList.contains("focus-ring")).toBe(false);
|
|
222
|
+
expect(root.classList.contains("focus-ring-inset")).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('scroll="body" (default) gains no tabIndex attribute — byte-identical', () => {
|
|
226
|
+
const { container } = render(<PageShell>body</PageShell>);
|
|
227
|
+
const root = container.firstElementChild as HTMLElement;
|
|
228
|
+
expect(root.getAttribute("tabindex")).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("headerGutter", () => {
|
|
233
|
+
it("reserves a fixed header row so titles land at one coordinate, when a header is present", () => {
|
|
234
|
+
render(
|
|
235
|
+
<PageShell headerGutter header={<h1>Runs</h1>}>
|
|
236
|
+
body
|
|
237
|
+
</PageShell>,
|
|
238
|
+
);
|
|
239
|
+
const gutter = screen.getByText("Runs").closest('[data-slot="page-shell-header"]');
|
|
240
|
+
expect(gutter).not.toBeNull();
|
|
241
|
+
expect((gutter as HTMLElement).className).toContain("min-h-(--page-shell-header-gutter)");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("renders the reserved row even when NO header is supplied — the empty row IS the mechanism (ADR 0035)", () => {
|
|
245
|
+
const { container } = render(<PageShell headerGutter>body</PageShell>);
|
|
246
|
+
const root = container.firstElementChild as HTMLElement;
|
|
247
|
+
|
|
248
|
+
const gutter = root.querySelector('[data-slot="page-shell-header"]');
|
|
249
|
+
expect(gutter).not.toBeNull();
|
|
250
|
+
expect((gutter as HTMLElement).className).toContain("min-h-(--page-shell-header-gutter)");
|
|
251
|
+
// It really is empty — no header content leaked in.
|
|
252
|
+
expect((gutter as HTMLElement).textContent).toBe("");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("declares the --page-shell-header-gutter default (--spacing(12)) on the root when on", () => {
|
|
256
|
+
const { container } = render(<PageShell headerGutter>body</PageShell>);
|
|
257
|
+
const root = container.firstElementChild as HTMLElement;
|
|
258
|
+
expect(root.className).toContain("[--page-shell-header-gutter:--spacing(12)]");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("is retunable per surface: a caller className overrides the default gutter height", () => {
|
|
262
|
+
const { container } = render(
|
|
263
|
+
<PageShell headerGutter className="[--page-shell-header-gutter:--spacing(16)]">
|
|
264
|
+
body
|
|
265
|
+
</PageShell>,
|
|
266
|
+
);
|
|
267
|
+
const root = container.firstElementChild as HTMLElement;
|
|
268
|
+
expect(root.className).toContain("[--page-shell-header-gutter:--spacing(16)]");
|
|
269
|
+
expect(root.className).not.toContain("--spacing(12)");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('composes with headerVariant="toolbar": the sticky bar nests inside the reserved row', () => {
|
|
273
|
+
render(
|
|
274
|
+
<PageShell headerGutter headerVariant="toolbar" header={<div data-testid="hdr">H</div>}>
|
|
275
|
+
body
|
|
276
|
+
</PageShell>,
|
|
277
|
+
);
|
|
278
|
+
const gutter = document.querySelector('[data-slot="page-shell-header"]');
|
|
279
|
+
const toolbar = document.querySelector('[data-slot="page-shell-toolbar-header"]');
|
|
280
|
+
expect(gutter).not.toBeNull();
|
|
281
|
+
expect(toolbar).not.toBeNull();
|
|
282
|
+
expect(gutter).toContainElement(toolbar as HTMLElement);
|
|
283
|
+
expect(toolbar).toContainElement(screen.getByTestId("hdr"));
|
|
284
|
+
});
|
|
285
|
+
});
|
|
57
286
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { forwardRef, type ComponentProps, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "../../lib/cn";
|
|
3
3
|
|
|
4
|
-
export interface PageShellProps {
|
|
4
|
+
export interface PageShellProps extends ComponentProps<"div"> {
|
|
5
5
|
children: ReactNode;
|
|
6
6
|
/** Optional header row (often a <SectionHeader />, or a <ViewToolbar> — see headerVariant). */
|
|
7
7
|
header?: ReactNode;
|
|
@@ -20,6 +20,37 @@ export interface PageShellProps {
|
|
|
20
20
|
headerVariant?: "default" | "toolbar";
|
|
21
21
|
/** Constrain content width. Defaults to "xl". */
|
|
22
22
|
width?: "md" | "lg" | "xl" | "full";
|
|
23
|
+
/**
|
|
24
|
+
* "body" (default) — the page scrolls with the document. Byte-identical to
|
|
25
|
+
* the pre-#R6 behaviour; existing callers are unaffected.
|
|
26
|
+
* "content" — this container is the scroll port. Use inside a `SidebarInset`
|
|
27
|
+
* whose height is already bounded, so the top bar stays put. Defaults its own
|
|
28
|
+
* `tabIndex` to `0` (a scroll container must be keyboard-operable — WCAG
|
|
29
|
+
* 2.1.1, axe `scrollable-region-focusable`), following the `DialogBody`
|
|
30
|
+
* precedent; pass `tabIndex={-1}` to opt out when the content already
|
|
31
|
+
* supplies its own focusable child.
|
|
32
|
+
* "fill" — fills its parent and scrolls nothing itself; a child (a table, a
|
|
33
|
+
* canvas) owns the scrolling — and owns that child's keyboard reachability
|
|
34
|
+
* too, the same way.
|
|
35
|
+
*/
|
|
36
|
+
scroll?: "body" | "content" | "fill";
|
|
37
|
+
/**
|
|
38
|
+
* Reserve a fixed header row so a page title lands at identical coordinates
|
|
39
|
+
* on every route, whether or not that route has a header. Default `false`.
|
|
40
|
+
*
|
|
41
|
+
* The row's height is a component-local CSS variable —
|
|
42
|
+
* `--page-shell-header-gutter`, declared on the root, default `--spacing(12)`
|
|
43
|
+
* (3rem / 48px at a 16px root — the height a one-line title already occupies
|
|
44
|
+
* inside the `headerVariant="toolbar"` bar's own `py-3`). It is NOT a theme
|
|
45
|
+
* token (`pnpm theme-parity:check` does not see it); retune it per surface the
|
|
46
|
+
* same way `--focus-ring-color` is retargeted elsewhere in this package:
|
|
47
|
+
* `<PageShell headerGutter className="[--page-shell-header-gutter:--spacing(16)]" />`.
|
|
48
|
+
*
|
|
49
|
+
* When `true` the header row renders **even if `header` is omitted** — that
|
|
50
|
+
* empty row is the whole mechanism, and it is what `headerGutter={false}`
|
|
51
|
+
* must never produce.
|
|
52
|
+
*/
|
|
53
|
+
headerGutter?: boolean;
|
|
23
54
|
className?: string;
|
|
24
55
|
contentClassName?: string;
|
|
25
56
|
}
|
|
@@ -31,32 +62,84 @@ const widthMap = {
|
|
|
31
62
|
full: "max-w-none",
|
|
32
63
|
} as const;
|
|
33
64
|
|
|
65
|
+
const scrollMap = {
|
|
66
|
+
body: "",
|
|
67
|
+
content: "min-h-0 flex-1 overflow-y-auto",
|
|
68
|
+
fill: "h-full min-h-0 overflow-hidden",
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
34
71
|
/** Page-level content container with consistent padding and max width. */
|
|
35
|
-
export function PageShell(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
export const PageShell = forwardRef<HTMLDivElement, PageShellProps>(function PageShell(
|
|
73
|
+
{
|
|
74
|
+
children,
|
|
75
|
+
header,
|
|
76
|
+
headerVariant = "default",
|
|
77
|
+
width = "xl",
|
|
78
|
+
scroll = "body",
|
|
79
|
+
headerGutter = false,
|
|
80
|
+
className,
|
|
81
|
+
contentClassName,
|
|
82
|
+
tabIndex,
|
|
83
|
+
...props
|
|
84
|
+
},
|
|
85
|
+
ref,
|
|
86
|
+
) {
|
|
87
|
+
// scroll="content" makes this element the scroll port, so it needs the same
|
|
88
|
+
// keyboard-operable fix `DialogBody`/`ExpandDialogPanes` ship: default the
|
|
89
|
+
// tab stop to 0, let a caller opt out with an explicit `tabIndex` (typically
|
|
90
|
+
// `-1`). "body"/"fill" never set a default, so their DOM gains no attribute.
|
|
91
|
+
const resolvedTabIndex = tabIndex ?? (scroll === "content" ? 0 : undefined);
|
|
92
|
+
|
|
93
|
+
const headerNode = header ? (
|
|
94
|
+
headerVariant === "toolbar" ? (
|
|
95
|
+
<div
|
|
96
|
+
data-slot="page-shell-toolbar-header"
|
|
97
|
+
className="sticky top-0 z-10 border-b border-border bg-background/95 py-3 backdrop-blur"
|
|
98
|
+
>
|
|
99
|
+
{header}
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
header
|
|
103
|
+
)
|
|
104
|
+
) : null;
|
|
105
|
+
|
|
43
106
|
return (
|
|
44
|
-
<div
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
107
|
+
<div
|
|
108
|
+
ref={ref}
|
|
109
|
+
data-slot="page-shell"
|
|
110
|
+
tabIndex={resolvedTabIndex}
|
|
111
|
+
className={cn(
|
|
112
|
+
"w-full px-4 py-6 sm:px-6 lg:px-8",
|
|
113
|
+
scrollMap[scroll],
|
|
114
|
+
// `focus-ring-inset`, NOT `focus-ring`: `scroll="content"`'s own doc
|
|
115
|
+
// above tells the caller to use it inside a `SidebarInset`, whose
|
|
116
|
+
// `overflow-hidden` clips both layers of the plain rung — they are
|
|
117
|
+
// drawn OUTSIDE the element's box. A consumer following that
|
|
118
|
+
// instruction would get a focusable scroll port with no visible focus
|
|
119
|
+
// indicator at all. Same reasoning, verbatim, as the app-shell blocks'
|
|
120
|
+
// own scroll ports.
|
|
121
|
+
scroll === "content" && "focus-ring-inset",
|
|
122
|
+
headerGutter && "[--page-shell-header-gutter:--spacing(12)]",
|
|
123
|
+
className,
|
|
124
|
+
)}
|
|
125
|
+
{...props}
|
|
126
|
+
>
|
|
127
|
+
<div
|
|
128
|
+
data-slot="page-shell-content"
|
|
129
|
+
className={cn("mx-auto w-full space-y-6", widthMap[width], contentClassName)}
|
|
130
|
+
>
|
|
131
|
+
{headerGutter ? (
|
|
132
|
+
<div
|
|
133
|
+
data-slot="page-shell-header"
|
|
134
|
+
className="flex min-h-(--page-shell-header-gutter) items-center"
|
|
135
|
+
>
|
|
136
|
+
{headerNode}
|
|
137
|
+
</div>
|
|
138
|
+
) : (
|
|
139
|
+
headerNode
|
|
140
|
+
)}
|
|
58
141
|
{children}
|
|
59
142
|
</div>
|
|
60
143
|
</div>
|
|
61
144
|
);
|
|
62
|
-
}
|
|
145
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { LocaleProvider } from "../locale-provider";
|
|
5
|
+
import {
|
|
6
|
+
PaginationEllipsis,
|
|
7
|
+
PaginationLink,
|
|
8
|
+
PaginationNext,
|
|
9
|
+
PaginationPrevious,
|
|
10
|
+
} from "./pagination";
|
|
11
|
+
|
|
12
|
+
describe("PaginationLink — forwardRef", () => {
|
|
13
|
+
it("forwards its ref to the rendered <a>", () => {
|
|
14
|
+
const ref = createRef<HTMLAnchorElement>();
|
|
15
|
+
render(
|
|
16
|
+
<PaginationLink ref={ref} href="#2">
|
|
17
|
+
2
|
|
18
|
+
</PaginationLink>,
|
|
19
|
+
);
|
|
20
|
+
expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
|
|
21
|
+
expect(ref.current).toBe(screen.getByText("2"));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pagination had no test file before #18 — its three microcopy sites
|
|
27
|
+
* (Previous/Next reuse the generic keys, the ellipsis's sr-only "More pages"
|
|
28
|
+
* is a new namespaced key) are locked here.
|
|
29
|
+
*/
|
|
30
|
+
describe("Pagination — microcopy (#18)", () => {
|
|
31
|
+
it("PaginationPrevious/PaginationNext keep their shipped English labels", () => {
|
|
32
|
+
render(
|
|
33
|
+
<>
|
|
34
|
+
<PaginationPrevious />
|
|
35
|
+
<PaginationNext />
|
|
36
|
+
</>,
|
|
37
|
+
);
|
|
38
|
+
expect(screen.getByText("Previous")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText("Next")).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("PaginationEllipsis keeps its shipped sr-only 'More pages' text", () => {
|
|
43
|
+
render(<PaginationEllipsis />);
|
|
44
|
+
expect(screen.getByText("More pages")).toHaveClass("sr-only");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("translates all three via a LocaleProvider (generic + namespaced keys)", () => {
|
|
48
|
+
render(
|
|
49
|
+
<LocaleProvider
|
|
50
|
+
locale="de-DE"
|
|
51
|
+
messages={{
|
|
52
|
+
previous: "Zurück",
|
|
53
|
+
next: "Weiter",
|
|
54
|
+
"ui.pagination.morePages": "Weitere Seiten",
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<PaginationPrevious />
|
|
58
|
+
<PaginationNext />
|
|
59
|
+
<PaginationEllipsis />
|
|
60
|
+
</LocaleProvider>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByText("Zurück")).toBeInTheDocument();
|
|
63
|
+
expect(screen.getByText("Weiter")).toBeInTheDocument();
|
|
64
|
+
expect(screen.getByText("Weitere Seiten")).toHaveClass("sr-only");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// #12/#53 review (P2): the `aria-label` overrides visible text content as
|
|
68
|
+
// the ACCESSIBLE NAME, so a non-English `LocaleProvider` that only
|
|
69
|
+
// translated the visible `previous`/`next` span left screen-reader users
|
|
70
|
+
// hearing hardcoded English regardless of locale.
|
|
71
|
+
describe("accessible name (aria-label) is localized, not just the visible text", () => {
|
|
72
|
+
it("defaults to the shipped English accessible names", () => {
|
|
73
|
+
render(
|
|
74
|
+
<>
|
|
75
|
+
<PaginationPrevious />
|
|
76
|
+
<PaginationNext />
|
|
77
|
+
</>,
|
|
78
|
+
);
|
|
79
|
+
expect(screen.getByLabelText("Go to previous page")).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("routes the aria-label through t() under a non-English LocaleProvider", () => {
|
|
84
|
+
render(
|
|
85
|
+
<LocaleProvider
|
|
86
|
+
locale="de-DE"
|
|
87
|
+
messages={{
|
|
88
|
+
previous: "Zurück",
|
|
89
|
+
next: "Weiter",
|
|
90
|
+
"ui.pagination.previous": "Zur vorherigen Seite",
|
|
91
|
+
"ui.pagination.next": "Zur nächsten Seite",
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<PaginationPrevious />
|
|
95
|
+
<PaginationNext />
|
|
96
|
+
</LocaleProvider>,
|
|
97
|
+
);
|
|
98
|
+
const prev = screen.getByLabelText("Zur vorherigen Seite");
|
|
99
|
+
const next = screen.getByLabelText("Zur nächsten Seite");
|
|
100
|
+
// The visible text is translated too — the accessible name isn't just
|
|
101
|
+
// masking untranslated visible content.
|
|
102
|
+
expect(prev).toHaveTextContent("Zurück");
|
|
103
|
+
expect(next).toHaveTextContent("Weiter");
|
|
104
|
+
// Neither link's accessible name is the stale hardcoded English string.
|
|
105
|
+
expect(screen.queryByLabelText("Go to previous page")).not.toBeInTheDocument();
|
|
106
|
+
expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|