@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
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
4
|
import { CircleAlert } from "lucide-react";
|
|
3
5
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
6
|
import { cn } from "../../lib/cn";
|
|
7
|
+
import { ILLUSTRATION_ACCENT_VAR } from "../../illustrations/illustration-base";
|
|
8
|
+
import { useLocale } from "../locale-provider";
|
|
5
9
|
|
|
6
10
|
// ─── Variants ────────────────────────────────────────────────────────────────
|
|
7
11
|
|
|
@@ -14,11 +18,22 @@ export const statePanelVariants = cva(
|
|
|
14
18
|
variants: {
|
|
15
19
|
kind: {
|
|
16
20
|
empty: "border border-dashed bg-surface",
|
|
17
|
-
// error:
|
|
18
|
-
// (border-s-4
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
21
|
+
// error: the non-color, monochrome-survivable cue is the rail's WIDTH
|
|
22
|
+
// (border-s-4, 4px, vs the 1px hairline on the other three edges) —
|
|
23
|
+
// not its hue (per styling-and-tokens.md's border/border-strong
|
|
24
|
+
// decision test: "if I deleted this line, could a sighted user still
|
|
25
|
+
// tell the two regions apart?" here it's the thickness that answers
|
|
26
|
+
// yes). So the rail's COLOR should stay in the destructive family
|
|
27
|
+
// like the rest of the panel, not fall back to the neutral
|
|
28
|
+
// `border-strong` rung — a 4px grey rail beside three red hairlines
|
|
29
|
+
// read as a CSS-specificity bug, not a deliberate accent (#71).
|
|
30
|
+
// `border-s-destructive` is the FILL rung (styling-and-tokens.md
|
|
31
|
+
// "which status rung a graphical MARK reaches for") — guaranteed
|
|
32
|
+
// >=3:1 against --card/--background in every theme, so it still
|
|
33
|
+
// satisfies the WCAG 1.4.11 non-text-contrast guarantee the old
|
|
34
|
+
// `border-border-strong` comment claimed. The icon (:87-91) and the
|
|
35
|
+
// eyebrow (:148-152 below) are the third and fourth non-color cues.
|
|
36
|
+
error: "border border-destructive/30 bg-destructive/5 border-s-4 border-s-destructive",
|
|
22
37
|
loading: "",
|
|
23
38
|
},
|
|
24
39
|
},
|
|
@@ -41,11 +56,29 @@ export interface StatePanelProps extends VariantProps<typeof statePanelVariants>
|
|
|
41
56
|
/** Supporting description below the title. */
|
|
42
57
|
description?: ReactNode;
|
|
43
58
|
/**
|
|
44
|
-
* Icon
|
|
45
|
-
* For `"error"` defaults to a built-in warning triangle.
|
|
46
|
-
* For `"loading"` defaults to an animated spinner.
|
|
59
|
+
* Icon shown above the title, clamped to a uniform 40×40 so mismatched
|
|
60
|
+
* glyphs stay tidy. For `"error"` defaults to a built-in warning triangle.
|
|
61
|
+
* For `"loading"` defaults to an animated spinner. Ignored when
|
|
62
|
+
* `illustration` is also provided — see `illustration`.
|
|
47
63
|
*/
|
|
48
64
|
icon?: ReactNode;
|
|
65
|
+
/**
|
|
66
|
+
* A larger illustration (one of `@elabs-ai/components-ui`'s shipped
|
|
67
|
+
* `*Illustration` components, or a consumer's own `ReactNode`) shown above
|
|
68
|
+
* the title instead of `icon`. Unlike `icon`, it is **not** clamped to
|
|
69
|
+
* 40×40 — an illustration governs its own size (see each illustration's
|
|
70
|
+
* `size` prop, `rem`-based, ~64px–160px). Takes precedence over `icon`
|
|
71
|
+
* when both are given.
|
|
72
|
+
*/
|
|
73
|
+
illustration?: ReactNode;
|
|
74
|
+
/**
|
|
75
|
+
* Heading level for the panel's title (#328 convention). Set this to the
|
|
76
|
+
* level that correctly follows whatever heading precedes this panel in
|
|
77
|
+
* the document outline — e.g. `"h3"` inside a `ChartCard` whose own title
|
|
78
|
+
* is an `<h2>`.
|
|
79
|
+
* @default "h3"
|
|
80
|
+
*/
|
|
81
|
+
titleAs?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
49
82
|
/** Accessible label for the spinner in loading state. @default "Loading…" */
|
|
50
83
|
loadingLabel?: string;
|
|
51
84
|
/** Spinner size (loading kind only). @default "md" */
|
|
@@ -91,13 +124,17 @@ export function StatePanel({
|
|
|
91
124
|
title,
|
|
92
125
|
description,
|
|
93
126
|
icon,
|
|
94
|
-
|
|
127
|
+
illustration,
|
|
128
|
+
titleAs: Tag = "h3",
|
|
129
|
+
loadingLabel,
|
|
95
130
|
size = "md",
|
|
96
131
|
actions,
|
|
97
132
|
className,
|
|
98
133
|
}: StatePanelProps) {
|
|
134
|
+
const { t } = useLocale();
|
|
99
135
|
const isLoading = kind === "loading";
|
|
100
136
|
const isError = kind === "error";
|
|
137
|
+
const resolvedLoadingLabel = loadingLabel ?? t("loading");
|
|
101
138
|
|
|
102
139
|
// Role: loading → status + live region; error → alert; empty → (none needed).
|
|
103
140
|
const roleProps = isLoading
|
|
@@ -107,9 +144,9 @@ export function StatePanel({
|
|
|
107
144
|
: {};
|
|
108
145
|
|
|
109
146
|
// Default title / description fallbacks for error state (mirroring old ErrorState).
|
|
110
|
-
const resolvedTitle = title ?? (isError ? "
|
|
147
|
+
const resolvedTitle = title ?? (isError ? t("ui.statePanel.errorTitle") : undefined);
|
|
111
148
|
const resolvedDescription =
|
|
112
|
-
description ?? (isError ? "
|
|
149
|
+
description ?? (isError ? t("ui.statePanel.errorDescription") : undefined);
|
|
113
150
|
|
|
114
151
|
// Resolve displayed icon.
|
|
115
152
|
let resolvedIcon: ReactNode;
|
|
@@ -124,17 +161,48 @@ export function StatePanel({
|
|
|
124
161
|
return (
|
|
125
162
|
<div data-kind={kind} className={cn(statePanelVariants({ kind }), className)} {...roleProps}>
|
|
126
163
|
{/* Error eyebrow: "Error" label shown above the icon so the panel reads
|
|
127
|
-
as an error structurally even in monochrome themes.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
164
|
+
as an error structurally even in monochrome themes. This is running
|
|
165
|
+
TEXT, not a mark, so it takes the ink rung `text-destructive-text`
|
|
166
|
+
(>= 4.5:1, gated in themes-contrast.test.ts) — not the fill rung
|
|
167
|
+
`text-destructive`, whose contract is only the 3:1 mark bar (see
|
|
168
|
+
"Which status rung a graphical MARK reaches for", styling-and-tokens.md
|
|
169
|
+
#381). Combined with the thick left border from statePanelVariants it
|
|
170
|
+
gives two non-color structural cues. The icon below deliberately
|
|
171
|
+
KEEPS text-destructive: an icon is a mark, so the fill rung is
|
|
172
|
+
correct there — do not "tidy" it onto -text too. */}
|
|
131
173
|
{isError && (
|
|
132
|
-
<span className="text-
|
|
133
|
-
|
|
174
|
+
<span className="text-meta font-semibold uppercase tracking-widest text-destructive-text">
|
|
175
|
+
{t("ui.statePanel.errorEyebrow")}
|
|
134
176
|
</span>
|
|
135
177
|
)}
|
|
136
178
|
|
|
137
|
-
{
|
|
179
|
+
{illustration ? (
|
|
180
|
+
// Illustration wins over `icon` when both are given. No `[&_svg]:size-10`
|
|
181
|
+
// clamp here — an illustration governs its own (rem-based) size, unlike
|
|
182
|
+
// the fixed-size icon slot below. For `kind="error"` this also sets the
|
|
183
|
+
// `--illustration-accent` custom property every illustration's accent
|
|
184
|
+
// ink reads through, so ANY illustration (not just `ErrorIllustration`)
|
|
185
|
+
// retints its accent to `--destructive-text` instead of staying pinned
|
|
186
|
+
// to its default hue inside a red-tinted slot (#24 fix round 1, P0-2).
|
|
187
|
+
// TEXT rung, not the FILL token below: the wrapper's `text-destructive`
|
|
188
|
+
// class already puts the SUBJECT (silhouette) on the fill rung, so an
|
|
189
|
+
// accent override reading the same `--destructive` token collapsed
|
|
190
|
+
// onto it and disappeared (#48 finding 2) — `--destructive-text` is a
|
|
191
|
+
// deliberately distinct, deeper rung (matches the fallback every
|
|
192
|
+
// other illustration's own accent already uses: `--primary-text`,
|
|
193
|
+
// `--success-text`) so the accent stays visible against the retinted
|
|
194
|
+
// silhouette.
|
|
195
|
+
<div
|
|
196
|
+
className={cn(isError ? "text-destructive" : "text-muted-foreground")}
|
|
197
|
+
style={
|
|
198
|
+
isError
|
|
199
|
+
? ({ [ILLUSTRATION_ACCENT_VAR]: "var(--destructive-text)" } as CSSProperties)
|
|
200
|
+
: undefined
|
|
201
|
+
}
|
|
202
|
+
>
|
|
203
|
+
{illustration}
|
|
204
|
+
</div>
|
|
205
|
+
) : resolvedIcon ? (
|
|
138
206
|
// Error icon keeps text-destructive (not muted) so it stays visible even
|
|
139
207
|
// when rendered at low contrast. In monochrome themes the icon shape
|
|
140
208
|
// (CircleAlert) carries the cue; the color is supplemental.
|
|
@@ -146,19 +214,37 @@ export function StatePanel({
|
|
|
146
214
|
) : null}
|
|
147
215
|
|
|
148
216
|
{(resolvedTitle || resolvedDescription) && (
|
|
149
|
-
|
|
217
|
+
// A 112px+ illustration (default `size="7rem"`) needs a heavier title
|
|
218
|
+
// to group with, and more air between the artwork and the copy than
|
|
219
|
+
// between the title and its own description — otherwise the caption
|
|
220
|
+
// reads as an afterthought under the artwork instead of one grouped
|
|
221
|
+
// unit (#47). `mt-2` (on top of the root's `gap-3`) only fires on the
|
|
222
|
+
// illustration path; the plain `icon` path (a fixed 40x40 glyph) is
|
|
223
|
+
// untouched. The title/description gap stays the tight `space-y-1`
|
|
224
|
+
// either way — that's the "one visually distinct unit" the two lines
|
|
225
|
+
// read as.
|
|
226
|
+
<div className={cn("space-y-1", illustration && "mt-2")}>
|
|
150
227
|
{resolvedTitle && (
|
|
151
|
-
<
|
|
228
|
+
<Tag
|
|
229
|
+
className={cn(
|
|
230
|
+
"font-semibold text-foreground",
|
|
231
|
+
illustration ? "text-subtitle" : "text-body",
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
{resolvedTitle}
|
|
235
|
+
</Tag>
|
|
152
236
|
)}
|
|
153
237
|
{resolvedDescription && (
|
|
154
|
-
<p className="mx-auto max-w-sm text-
|
|
238
|
+
<p className="mx-auto max-w-sm text-body text-muted-foreground">
|
|
239
|
+
{resolvedDescription}
|
|
240
|
+
</p>
|
|
155
241
|
)}
|
|
156
242
|
</div>
|
|
157
243
|
)}
|
|
158
244
|
|
|
159
245
|
{/* Loading label (visually shown as supporting text; also the live-region content). */}
|
|
160
|
-
{isLoading &&
|
|
161
|
-
<span className="text-
|
|
246
|
+
{isLoading && resolvedLoadingLabel && (
|
|
247
|
+
<span className="text-body text-muted-foreground">{resolvedLoadingLabel}</span>
|
|
162
248
|
)}
|
|
163
249
|
|
|
164
250
|
{actions ? <div className="mt-1 flex items-center gap-2">{actions}</div> : null}
|
|
@@ -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: {
|
|
@@ -9,9 +9,10 @@ export const Switch = forwardRef<
|
|
|
9
9
|
return (
|
|
10
10
|
<SwitchPrimitive.Root
|
|
11
11
|
ref={ref}
|
|
12
|
+
data-slot="switch"
|
|
12
13
|
className={cn(
|
|
13
14
|
"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-
|
|
15
|
+
"focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
15
16
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
16
17
|
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
17
18
|
className,
|
|
@@ -19,6 +20,7 @@ export const Switch = forwardRef<
|
|
|
19
20
|
{...props}
|
|
20
21
|
>
|
|
21
22
|
<SwitchPrimitive.Thumb
|
|
23
|
+
data-slot="switch-thumb"
|
|
22
24
|
className={cn(
|
|
23
25
|
"pointer-events-none block size-4 rounded-full bg-background shadow-xs ring-0 transition-transform duration-fast ease-standard",
|
|
24
26
|
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
|
@@ -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,63 @@ 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
|
+
// A named, non-landmark stop: `group`, never `region` (a landmark per
|
|
108
|
+
// overflowing table collides under axe `landmark-unique`).
|
|
109
|
+
await expect(scrollRegion).toHaveAttribute("role", "group");
|
|
110
|
+
|
|
111
|
+
// The region is the only focusable element the story renders, so a
|
|
112
|
+
// single Tab from nothing focused reaches it — the real WCAG 2.1.1
|
|
113
|
+
// reproduction from the issue ("press Tab from the top").
|
|
114
|
+
await userEvent.tab();
|
|
115
|
+
await waitFor(() => expect(document.activeElement).toBe(scrollRegion));
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import { Table, TableBody, TableCell, TableRow } from "./table";
|
|
4
|
+
|
|
4
5
|
describe("Table", () => {
|
|
5
6
|
it("renders cells", () => {
|
|
6
7
|
render(
|
|
@@ -15,3 +16,150 @@ describe("Table", () => {
|
|
|
15
16
|
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
|
16
17
|
});
|
|
17
18
|
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* jsdom reports 0 for every layout metric, so overflow has to be simulated —
|
|
22
|
+
* same idiom as `packages/data/src/data-table/data-table.test.tsx`. Metrics
|
|
23
|
+
* are (re-)applied and the component's own `onScroll` handler is fired so the
|
|
24
|
+
* re-measurement runs through the real code path, not by poking at state.
|
|
25
|
+
*/
|
|
26
|
+
function setScrollMetrics(
|
|
27
|
+
el: HTMLElement,
|
|
28
|
+
{ scrollWidth, clientWidth, scrollHeight, clientHeight }: Record<string, number>,
|
|
29
|
+
) {
|
|
30
|
+
Object.defineProperty(el, "scrollWidth", { configurable: true, value: scrollWidth });
|
|
31
|
+
Object.defineProperty(el, "clientWidth", { configurable: true, value: clientWidth });
|
|
32
|
+
Object.defineProperty(el, "scrollHeight", { configurable: true, value: scrollHeight });
|
|
33
|
+
Object.defineProperty(el, "clientHeight", { configurable: true, value: clientHeight });
|
|
34
|
+
fireEvent.scroll(el);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scrollRegionOf(container: HTMLElement): HTMLElement {
|
|
38
|
+
const el = container.querySelector<HTMLElement>('[data-slot="table-scroll-region"]');
|
|
39
|
+
if (!el) throw new Error('no [data-slot="table-scroll-region"] in the rendered output');
|
|
40
|
+
return el;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("Table — #366 the scroll wrapper's tab stop exists only while it measurably overflows", () => {
|
|
44
|
+
it("adds NO tab stop and NO accessible name to a table that fits its container", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<Table>
|
|
47
|
+
<TableBody>
|
|
48
|
+
<TableRow>
|
|
49
|
+
<TableCell>Alpha</TableCell>
|
|
50
|
+
</TableRow>
|
|
51
|
+
</TableBody>
|
|
52
|
+
</Table>,
|
|
53
|
+
);
|
|
54
|
+
const scrollRegion = scrollRegionOf(container);
|
|
55
|
+
setScrollMetrics(scrollRegion, {
|
|
56
|
+
scrollWidth: 300,
|
|
57
|
+
clientWidth: 300,
|
|
58
|
+
scrollHeight: 100,
|
|
59
|
+
clientHeight: 100,
|
|
60
|
+
});
|
|
61
|
+
// A table that doesn't scroll must not gain a focus stop that does
|
|
62
|
+
// nothing, nor announce itself as "scrollable" — axe's
|
|
63
|
+
// `scrollable-region-focusable` only fires the other way round, so this
|
|
64
|
+
// is the locking assertion for it.
|
|
65
|
+
expect(scrollRegion).not.toHaveAttribute("tabindex");
|
|
66
|
+
expect(scrollRegion).not.toHaveAttribute("aria-label");
|
|
67
|
+
expect(screen.queryByLabelText("Table contents, scrollable")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("gains the tab stop + accessible name once the region measurably overflows vertically", () => {
|
|
71
|
+
const { container } = render(
|
|
72
|
+
<Table>
|
|
73
|
+
<TableBody>
|
|
74
|
+
<TableRow>
|
|
75
|
+
<TableCell>Alpha</TableCell>
|
|
76
|
+
</TableRow>
|
|
77
|
+
</TableBody>
|
|
78
|
+
</Table>,
|
|
79
|
+
);
|
|
80
|
+
const scrollRegion = scrollRegionOf(container);
|
|
81
|
+
setScrollMetrics(scrollRegion, {
|
|
82
|
+
scrollWidth: 300,
|
|
83
|
+
clientWidth: 300,
|
|
84
|
+
scrollHeight: 800,
|
|
85
|
+
clientHeight: 200,
|
|
86
|
+
});
|
|
87
|
+
expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
88
|
+
expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("gains the tab stop + accessible name once the region measurably overflows horizontally", () => {
|
|
92
|
+
const { container } = render(
|
|
93
|
+
<Table>
|
|
94
|
+
<TableBody>
|
|
95
|
+
<TableRow>
|
|
96
|
+
<TableCell>Alpha</TableCell>
|
|
97
|
+
</TableRow>
|
|
98
|
+
</TableBody>
|
|
99
|
+
</Table>,
|
|
100
|
+
);
|
|
101
|
+
const scrollRegion = scrollRegionOf(container);
|
|
102
|
+
setScrollMetrics(scrollRegion, {
|
|
103
|
+
scrollWidth: 900,
|
|
104
|
+
clientWidth: 300,
|
|
105
|
+
scrollHeight: 100,
|
|
106
|
+
clientHeight: 100,
|
|
107
|
+
});
|
|
108
|
+
expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
109
|
+
expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("drops the tab stop again when the overflow goes away (e.g. the container grows)", () => {
|
|
113
|
+
const { container } = render(
|
|
114
|
+
<Table>
|
|
115
|
+
<TableBody>
|
|
116
|
+
<TableRow>
|
|
117
|
+
<TableCell>Alpha</TableCell>
|
|
118
|
+
</TableRow>
|
|
119
|
+
</TableBody>
|
|
120
|
+
</Table>,
|
|
121
|
+
);
|
|
122
|
+
const scrollRegion = scrollRegionOf(container);
|
|
123
|
+
setScrollMetrics(scrollRegion, {
|
|
124
|
+
scrollWidth: 300,
|
|
125
|
+
clientWidth: 300,
|
|
126
|
+
scrollHeight: 800,
|
|
127
|
+
clientHeight: 200,
|
|
128
|
+
});
|
|
129
|
+
expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
130
|
+
setScrollMetrics(scrollRegion, {
|
|
131
|
+
scrollWidth: 300,
|
|
132
|
+
clientWidth: 300,
|
|
133
|
+
scrollHeight: 800,
|
|
134
|
+
clientHeight: 900,
|
|
135
|
+
});
|
|
136
|
+
expect(scrollRegion).not.toHaveAttribute("tabindex");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('adds role="group" once it overflows — a named, non-landmark stop', () => {
|
|
140
|
+
const { container } = render(
|
|
141
|
+
<Table>
|
|
142
|
+
<TableBody>
|
|
143
|
+
<TableRow>
|
|
144
|
+
<TableCell>Alpha</TableCell>
|
|
145
|
+
</TableRow>
|
|
146
|
+
</TableBody>
|
|
147
|
+
</Table>,
|
|
148
|
+
);
|
|
149
|
+
const scrollRegion = scrollRegionOf(container);
|
|
150
|
+
// `aria-label` on a plain `<div>` (role `generic`) is not guaranteed to
|
|
151
|
+
// produce an accessible name at all, so the overflowing stop needs a
|
|
152
|
+
// naming-capable role — `group`, never the `region` landmark, which would
|
|
153
|
+
// collide (axe `landmark-unique`) as soon as two tables overflow on a page.
|
|
154
|
+
expect(scrollRegion).not.toHaveAttribute("role");
|
|
155
|
+
setScrollMetrics(scrollRegion, {
|
|
156
|
+
scrollWidth: 300,
|
|
157
|
+
clientWidth: 300,
|
|
158
|
+
scrollHeight: 800,
|
|
159
|
+
clientHeight: 200,
|
|
160
|
+
});
|
|
161
|
+
expect(scrollRegion).toHaveAttribute("role", "group");
|
|
162
|
+
expect(screen.queryByRole("region")).toBeNull();
|
|
163
|
+
expect(screen.getByRole("group", { name: "Table contents, scrollable" })).toBe(scrollRegion);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -1,17 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
3
9
|
type HTMLAttributes,
|
|
4
10
|
type TdHTMLAttributes,
|
|
5
11
|
type ThHTMLAttributes,
|
|
6
12
|
} from "react";
|
|
7
13
|
import { cn } from "../../lib/cn";
|
|
14
|
+
import { useLocale } from "../locale-provider";
|
|
8
15
|
|
|
16
|
+
/**
|
|
17
|
+
* `Table`'s own scroll wrapper, gated on MEASURED overflow (#366).
|
|
18
|
+
*
|
|
19
|
+
* `className`/`ref`/`...props` all land on the `<table>` — unchanged, no new
|
|
20
|
+
* prop — so this wrapper is internal plumbing, not a new public surface. It
|
|
21
|
+
* follows `DataTable`'s non-virtualized branch verbatim
|
|
22
|
+
* (`packages/data/src/data-table/data-table.tsx:1658-1695`, #330), the
|
|
23
|
+
* closest precedent: a plain `overflow-auto` box with no pinned columns, no
|
|
24
|
+
* loading overlay and no edge fade to carry along, just the keyboard
|
|
25
|
+
* affordance itself.
|
|
26
|
+
*
|
|
27
|
+
* A table that FITS must stay a byte-identical no-op: no `tabIndex`, no
|
|
28
|
+
* `aria-label`, no `role`, no ring — an unconditional stop would announce
|
|
29
|
+
* "scrollable" on content that never scrolls. Only a table that measurably
|
|
30
|
+
* overflows gets the tab stop, its accessible name (WCAG 4.1.2) and the
|
|
31
|
+
* compound focus ring (`focus-ring-inset` — the wrapper's own edge is the
|
|
32
|
+
* scroll clip, so an outside ring would be cut, `.claude/rules/theming.md`).
|
|
33
|
+
*
|
|
34
|
+
* A naming-capable role is REQUIRED alongside that `aria-label`: `aria-label`
|
|
35
|
+
* on a plain `<div>` (role `generic`) is prohibited by ARIA 1.2 and not
|
|
36
|
+
* guaranteed to compute into a name, so `tabIndex={0}` alone would make an
|
|
37
|
+
* UNNAMED stop. That role is `group`, NOT `region`: `region` is a landmark,
|
|
38
|
+
* and every overflowing table on a page would add one more landmark with the
|
|
39
|
+
* same name (axe `landmark-unique`) over a real `<table>` that already has
|
|
40
|
+
* its own semantics. `group` names the stop without joining landmark
|
|
41
|
+
* navigation — the no-redundant-landmark call `DataTable` made first (#330).
|
|
42
|
+
*/
|
|
9
43
|
export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(function Table(
|
|
10
44
|
{ className, ...props },
|
|
11
45
|
ref,
|
|
12
46
|
) {
|
|
47
|
+
const { t } = useLocale();
|
|
48
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
const [scrollOverflows, setScrollOverflows] = useState(false);
|
|
50
|
+
|
|
51
|
+
const updateScrollAffordance = useCallback(() => {
|
|
52
|
+
const el = scrollRef.current;
|
|
53
|
+
if (!el) return;
|
|
54
|
+
// 1px tolerance absorbs sub-pixel layout rounding, which would otherwise
|
|
55
|
+
// report a permanent 0.5px overflow on a table that visually fits.
|
|
56
|
+
setScrollOverflows(
|
|
57
|
+
el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1,
|
|
58
|
+
);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const el = scrollRef.current;
|
|
63
|
+
if (!el) return;
|
|
64
|
+
updateScrollAffordance();
|
|
65
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
66
|
+
// Observe the CONTAINER (viewport changes) and the <table> inside it
|
|
67
|
+
// (content changes its intrinsic size without resizing the container).
|
|
68
|
+
const observer = new ResizeObserver(updateScrollAffordance);
|
|
69
|
+
observer.observe(el);
|
|
70
|
+
if (el.firstElementChild) observer.observe(el.firstElementChild);
|
|
71
|
+
return () => observer.disconnect();
|
|
72
|
+
}, [updateScrollAffordance]);
|
|
73
|
+
|
|
13
74
|
return (
|
|
14
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
ref={scrollRef}
|
|
77
|
+
data-slot="table-scroll-region"
|
|
78
|
+
tabIndex={scrollOverflows ? 0 : undefined}
|
|
79
|
+
role={scrollOverflows ? "group" : undefined}
|
|
80
|
+
aria-label={scrollOverflows ? t("ui.table.scrollRegion") : undefined}
|
|
81
|
+
onScroll={updateScrollAffordance}
|
|
82
|
+
className="relative w-full overflow-auto focus-ring-inset"
|
|
83
|
+
>
|
|
15
84
|
<table ref={ref} className={cn("w-full caption-bottom text-body", className)} {...props} />
|
|
16
85
|
</div>
|
|
17
86
|
);
|
|
@@ -79,7 +148,7 @@ export const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLT
|
|
|
79
148
|
<th
|
|
80
149
|
ref={ref}
|
|
81
150
|
className={cn(
|
|
82
|
-
"h-10 px-3 text-start align-middle font-
|
|
151
|
+
"h-10 px-3 text-start align-middle font-table-header text-muted-foreground",
|
|
83
152
|
className,
|
|
84
153
|
)}
|
|
85
154
|
{...props}
|
|
@@ -265,3 +265,51 @@ export const ProgrammaticActivation: Story = {
|
|
|
265
265
|
await expect(window.scrollY).toBe(0);
|
|
266
266
|
},
|
|
267
267
|
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `variant="underline"` — line tabs: a transparent row over a 1px rule, the
|
|
271
|
+
* active tab marked by a 2px `--primary` underline and foreground ink. Label
|
|
272
|
+
* weight follows the theme's `--control-weight`. Keyboard behaviour is Radix's,
|
|
273
|
+
* identical to the segmented default.
|
|
274
|
+
*/
|
|
275
|
+
export const Underline: Story = {
|
|
276
|
+
render: () => (
|
|
277
|
+
<Tabs defaultValue="overview" className="w-[28rem]">
|
|
278
|
+
<TabsList variant="underline">
|
|
279
|
+
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
280
|
+
<TabsTrigger value="activity">Activity</TabsTrigger>
|
|
281
|
+
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
282
|
+
</TabsList>
|
|
283
|
+
<TabsContent value="overview" className="text-body text-muted-foreground">
|
|
284
|
+
Overview panel.
|
|
285
|
+
</TabsContent>
|
|
286
|
+
<TabsContent value="activity" className="text-body text-muted-foreground">
|
|
287
|
+
Activity panel.
|
|
288
|
+
</TabsContent>
|
|
289
|
+
<TabsContent value="settings" className="text-body text-muted-foreground">
|
|
290
|
+
Settings panel.
|
|
291
|
+
</TabsContent>
|
|
292
|
+
</Tabs>
|
|
293
|
+
),
|
|
294
|
+
play: async ({ canvas, userEvent }) => {
|
|
295
|
+
const overview = canvas.getByRole("tab", { name: "Overview" });
|
|
296
|
+
const activity = canvas.getByRole("tab", { name: "Activity" });
|
|
297
|
+
|
|
298
|
+
// The underline is a 2px border on every trigger (transparent at rest), so
|
|
299
|
+
// activating a tab never changes its box.
|
|
300
|
+
await expect(getComputedStyle(activity).borderBottomWidth).toBe("2px");
|
|
301
|
+
await expect(getComputedStyle(overview).borderBottomColor).not.toBe(
|
|
302
|
+
getComputedStyle(activity).borderBottomColor,
|
|
303
|
+
);
|
|
304
|
+
const restingHeight = activity.getBoundingClientRect().height;
|
|
305
|
+
|
|
306
|
+
await userEvent.click(overview);
|
|
307
|
+
await userEvent.keyboard("{ArrowRight}");
|
|
308
|
+
await expect(activity).toHaveFocus();
|
|
309
|
+
await expect(activity).toHaveAttribute("aria-selected", "true");
|
|
310
|
+
await expect(activity.getBoundingClientRect().height).toBe(restingHeight);
|
|
311
|
+
// The panel mounts on switch and fades in (gated entrance) — wait for it to settle.
|
|
312
|
+
const panel = await canvas.findByText("Activity panel.");
|
|
313
|
+
await waitFor(() => expect(panel).toBeVisible());
|
|
314
|
+
},
|
|
315
|
+
};
|