@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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
|
|
2
|
+
/**
|
|
3
|
+
* Contract probe for WorkspacePicker (`packages/ui/src/components/workspace-picker/workspace-picker.tsx`), derived from its
|
|
4
|
+
* `Default` story (packages/ui/src/components/workspace-picker/workspace-picker.stories.tsx). See scripts/gen-contract-tests.mjs.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
7
|
+
import { render, cleanup } from "@testing-library/react";
|
|
8
|
+
import { createRef } from "react";
|
|
9
|
+
import * as stories from "../components/workspace-picker/workspace-picker.stories";
|
|
10
|
+
import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
|
|
14
|
+
// Every component here has its own prop/ref/element shape; a generated probe
|
|
15
|
+
// stays generic on purpose (loosely typed, not untyped — see
|
|
16
|
+
// scripts/gen-contract-tests.mjs) rather than re-deriving each one.
|
|
17
|
+
const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
|
|
18
|
+
const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
|
|
19
|
+
const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
|
|
21
|
+
const Component = meta.component as any;
|
|
22
|
+
const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
|
|
23
|
+
const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
|
|
24
|
+
|
|
25
|
+
/** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
|
|
26
|
+
function contractIt(assertion: string, name: string, fn: () => void) {
|
|
27
|
+
const key = `core-workspacepicker--default|jsdom|jsdom|${assertion}`;
|
|
28
|
+
const reason = KNOWN_FAILURES[key];
|
|
29
|
+
if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
|
|
30
|
+
return it(name, fn);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("WorkspacePicker contract", () => {
|
|
34
|
+
it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
|
|
35
|
+
const ref = createRef<Element>();
|
|
36
|
+
render(<Component {...args} ref={ref} />);
|
|
37
|
+
expect(ref.current).toBeInstanceOf(Element);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
contractIt("className", "merges a caller className onto the root", () => {
|
|
41
|
+
const { container } = render(<Component {...args} className="contract-probe" />);
|
|
42
|
+
expect(container.querySelector(".contract-probe")).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
contractIt("data-slot", 'exposes data-slot="workspace-picker" on its root', () => {
|
|
46
|
+
const { container } = render(<Component {...args} />);
|
|
47
|
+
expect(container.querySelector('[data-slot="workspace-picker"]')).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression guard for issue #26 — `react-hook-form` / `@hookform/resolvers`
|
|
8
|
+
* are used ONLY by the RHF-bound `Form` family
|
|
9
|
+
* (`packages/ui/src/components/form/form.tsx`); `FieldRow` and every other
|
|
10
|
+
* component in this package render from plain props and never import either.
|
|
11
|
+
*
|
|
12
|
+
* A consumer who never imports `Form` should not be forced to install (or
|
|
13
|
+
* have their bundler resolve) react-hook-form. Declaring both as
|
|
14
|
+
* `dependencies` defeats that — npm/pnpm install them unconditionally for
|
|
15
|
+
* every consumer of `@elabs-ai/components-ui`, regardless of whether `Form`
|
|
16
|
+
* is ever imported. This test locks the fix: both packages are declared as
|
|
17
|
+
* OPTIONAL peers, and neither reappears as a hard `dependency`.
|
|
18
|
+
*
|
|
19
|
+
* `zod` is used only inside `form.stories.tsx` (the `zodResolver` demo) and
|
|
20
|
+
* never in shipped runtime source — it belongs in `devDependencies`, never as
|
|
21
|
+
* a runtime `dependency` or a peer a consumer must install.
|
|
22
|
+
*/
|
|
23
|
+
const PACKAGE_JSON_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
24
|
+
|
|
25
|
+
function readPackageJson(): {
|
|
26
|
+
dependencies?: Record<string, string>;
|
|
27
|
+
devDependencies?: Record<string, string>;
|
|
28
|
+
peerDependencies?: Record<string, string>;
|
|
29
|
+
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
|
30
|
+
} {
|
|
31
|
+
return JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("packages/ui/package.json — react-hook-form is an optional peer, not a hard dependency", () => {
|
|
35
|
+
const pkg = readPackageJson();
|
|
36
|
+
|
|
37
|
+
it.each(["react-hook-form", "@hookform/resolvers"])(
|
|
38
|
+
"declares %s as an OPTIONAL peerDependency",
|
|
39
|
+
(name) => {
|
|
40
|
+
expect(pkg.dependencies?.[name]).toBeUndefined();
|
|
41
|
+
expect(pkg.peerDependencies?.[name]).toBeTruthy();
|
|
42
|
+
expect(pkg.peerDependenciesMeta?.[name]?.optional).toBe(true);
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it("still lists react-hook-form + @hookform/resolvers as devDependencies for the workspace's own build/test", () => {
|
|
47
|
+
// The package's own `Form` tests/stories (and `form.tsx` itself) need the
|
|
48
|
+
// real library to typecheck/run inside this workspace — peer alone does
|
|
49
|
+
// not guarantee resolution here, only for an external consumer.
|
|
50
|
+
expect(pkg.devDependencies?.["react-hook-form"]).toBeTruthy();
|
|
51
|
+
expect(pkg.devDependencies?.["@hookform/resolvers"]).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("declares zod as a devDependency only — never a runtime dependency or peer", () => {
|
|
55
|
+
expect(pkg.dependencies?.zod).toBeUndefined();
|
|
56
|
+
expect(pkg.peerDependencies?.zod).toBeUndefined();
|
|
57
|
+
expect(pkg.devDependencies?.zod).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* cells, a price row, and a per-plan CTA row. Reuses brand-ui's strongest
|
|
8
8
|
* assets — Table + Badge + Button — and stays token-only.
|
|
9
9
|
*
|
|
10
|
-
* Reads in
|
|
10
|
+
* Reads in every theme. Verify with globals=theme:<slug>.
|
|
11
11
|
*/
|
|
12
12
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
13
13
|
import { Check, Minus } from "lucide-react";
|
|
@@ -152,7 +152,7 @@ const meta = {
|
|
|
152
152
|
docs: {
|
|
153
153
|
description: {
|
|
154
154
|
component:
|
|
155
|
-
"The canonical plan/feature comparison matrix (brand-ui's Compare gap): a header with one featured column (badge + recessed wash), feature rows with check / dash / value cells, a price row, and a per-plan CTA row. Composes Table + Badge + Button; semantic tokens only; reads in
|
|
155
|
+
"The canonical plan/feature comparison matrix (brand-ui's Compare gap): a header with one featured column (badge + recessed wash), feature rows with check / dash / value cells, a price row, and a per-plan CTA row. Composes Table + Badge + Button; semantic tokens only; reads in every theme.",
|
|
156
156
|
},
|
|
157
157
|
},
|
|
158
158
|
},
|
|
@@ -9,7 +9,14 @@ export const AccordionItem = forwardRef<
|
|
|
9
9
|
ElementRef<typeof AccordionPrimitive.Item>,
|
|
10
10
|
ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
11
11
|
>(function AccordionItem({ className, ...props }, ref) {
|
|
12
|
-
return
|
|
12
|
+
return (
|
|
13
|
+
<AccordionPrimitive.Item
|
|
14
|
+
ref={ref}
|
|
15
|
+
data-slot="accordion-item"
|
|
16
|
+
className={cn("border-b", className)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
13
20
|
});
|
|
14
21
|
|
|
15
22
|
export const AccordionTrigger = forwardRef<
|
|
@@ -17,11 +24,12 @@ export const AccordionTrigger = forwardRef<
|
|
|
17
24
|
ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
18
25
|
>(function AccordionTrigger({ className, children, ...props }, ref) {
|
|
19
26
|
return (
|
|
20
|
-
<AccordionPrimitive.Header className="flex">
|
|
27
|
+
<AccordionPrimitive.Header className="flex" data-slot="accordion-header">
|
|
21
28
|
<AccordionPrimitive.Trigger
|
|
22
29
|
ref={ref}
|
|
30
|
+
data-slot="accordion-trigger"
|
|
23
31
|
className={cn(
|
|
24
|
-
"flex flex-1 items-center justify-between py-4 text-
|
|
32
|
+
"flex flex-1 items-center justify-between py-4 text-body font-medium transition-all hover:underline focus-ring [&[data-state=open]>svg]:rotate-180",
|
|
25
33
|
className,
|
|
26
34
|
)}
|
|
27
35
|
{...props}
|
|
@@ -40,7 +48,8 @@ export const AccordionContent = forwardRef<
|
|
|
40
48
|
return (
|
|
41
49
|
<AccordionPrimitive.Content
|
|
42
50
|
ref={ref}
|
|
43
|
-
|
|
51
|
+
data-slot="accordion-content"
|
|
52
|
+
className="overflow-hidden text-body data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
44
53
|
{...props}
|
|
45
54
|
>
|
|
46
55
|
<div className={cn("pb-4 pt-0 text-muted-foreground", className)}>{children}</div>
|
|
@@ -85,7 +85,7 @@ export function AdvancedGroup({
|
|
|
85
85
|
>
|
|
86
86
|
<CollapsibleTrigger
|
|
87
87
|
data-slot="advanced-group-trigger"
|
|
88
|
-
className="group -mx-2 flex items-center gap-2 rounded-md px-2 py-1.5 text-body font-medium hover:bg-accent hover:text-accent-foreground focus-
|
|
88
|
+
className="group -mx-2 flex items-center gap-2 rounded-md px-2 py-1.5 text-body font-medium hover:bg-accent hover:text-accent-foreground focus-ring"
|
|
89
89
|
>
|
|
90
90
|
<ChevronDown
|
|
91
91
|
aria-hidden="true"
|
|
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
|
|
5
5
|
export const alertVariants = cva(
|
|
6
|
-
"relative w-full rounded-lg border px-4 py-3 text-
|
|
6
|
+
"relative w-full rounded-lg border px-4 py-3 text-body [&>svg]:absolute [&>svg]:start-4 [&>svg]:top-4 [&>svg]:size-4 [&>svg]:text-current [&>svg~*]:ps-7",
|
|
7
7
|
{
|
|
8
8
|
variants: {
|
|
9
9
|
variant: {
|
|
@@ -11,8 +11,11 @@ export const alertVariants = cva(
|
|
|
11
11
|
info: "border-info/40 bg-info/10 text-foreground",
|
|
12
12
|
success: "border-success/40 bg-success/10 text-foreground",
|
|
13
13
|
warning: "border-warning/50 bg-warning/10 text-foreground",
|
|
14
|
+
// #124: the bare `text-destructive` set the ambient colour for the
|
|
15
|
+
// Title/Description running text — ink rung. `[&>svg]:text-destructive`
|
|
16
|
+
// (the icon mark) is unchanged.
|
|
14
17
|
destructive:
|
|
15
|
-
"border-destructive/40 bg-destructive/10 text-destructive [&>svg]:text-destructive",
|
|
18
|
+
"border-destructive/40 bg-destructive/10 text-destructive-text [&>svg]:text-destructive",
|
|
16
19
|
},
|
|
17
20
|
},
|
|
18
21
|
defaultVariants: { variant: "default" },
|
|
@@ -31,7 +34,13 @@ export const Alert = forwardRef<
|
|
|
31
34
|
ref,
|
|
32
35
|
) {
|
|
33
36
|
return (
|
|
34
|
-
<div
|
|
37
|
+
<div
|
|
38
|
+
ref={ref}
|
|
39
|
+
role={role}
|
|
40
|
+
data-slot="alert"
|
|
41
|
+
className={cn(alertVariants({ variant }), className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
35
44
|
);
|
|
36
45
|
});
|
|
37
46
|
|
|
@@ -53,6 +62,7 @@ export const AlertTitle = forwardRef<HTMLHeadingElement, AlertTitleProps>(functi
|
|
|
53
62
|
return (
|
|
54
63
|
<Tag
|
|
55
64
|
ref={ref}
|
|
65
|
+
data-slot="alert-title"
|
|
56
66
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
|
57
67
|
{...props}
|
|
58
68
|
/>
|
|
@@ -79,8 +89,9 @@ export const AlertDescription = forwardRef<HTMLParagraphElement, AlertDescriptio
|
|
|
79
89
|
return (
|
|
80
90
|
<div
|
|
81
91
|
ref={ref}
|
|
92
|
+
data-slot="alert-description"
|
|
82
93
|
className={cn(
|
|
83
|
-
"text-
|
|
94
|
+
"text-body [&_p]:leading-relaxed text-muted-foreground",
|
|
84
95
|
measure && "max-w-prose",
|
|
85
96
|
className,
|
|
86
97
|
)}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import {
|
|
4
|
+
AlertDialog,
|
|
5
|
+
AlertDialogContent,
|
|
6
|
+
AlertDialogDescription,
|
|
7
|
+
AlertDialogHeader,
|
|
8
|
+
AlertDialogTitle,
|
|
9
|
+
} from "./alert-dialog";
|
|
10
|
+
|
|
11
|
+
describe("AlertDialogContent overflow", () => {
|
|
12
|
+
it("caps height and scrolls a long body instead of overflowing the viewport", () => {
|
|
13
|
+
render(
|
|
14
|
+
<AlertDialog open>
|
|
15
|
+
<AlertDialogContent>
|
|
16
|
+
<AlertDialogHeader>
|
|
17
|
+
<AlertDialogTitle>Title</AlertDialogTitle>
|
|
18
|
+
<AlertDialogDescription>Description</AlertDialogDescription>
|
|
19
|
+
</AlertDialogHeader>
|
|
20
|
+
Body
|
|
21
|
+
</AlertDialogContent>
|
|
22
|
+
</AlertDialog>,
|
|
23
|
+
);
|
|
24
|
+
const content = screen.getByRole("alertdialog");
|
|
25
|
+
// Without a height ceiling + `overflow-y-auto`, a body longer than the
|
|
26
|
+
// viewport overflows past both edges — and the `-translate-y-1/2`
|
|
27
|
+
// centring pushes its top half off-screen, unreachable even by scrolling
|
|
28
|
+
// the page.
|
|
29
|
+
expect(content.className).toContain("max-h-[85dvh]");
|
|
30
|
+
expect(content.className).toContain("overflow-y-auto");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -19,8 +19,9 @@ const AlertDialogOverlay = forwardRef<
|
|
|
19
19
|
return (
|
|
20
20
|
<AlertDialogPrimitive.Overlay
|
|
21
21
|
ref={ref}
|
|
22
|
+
data-slot="alert-dialog-overlay"
|
|
22
23
|
className={cn(
|
|
23
|
-
"fixed inset-0 z-50 bg-
|
|
24
|
+
"fixed inset-0 z-50 bg-overlay backdrop-blur-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
24
25
|
className,
|
|
25
26
|
)}
|
|
26
27
|
{...props}
|
|
@@ -37,8 +38,13 @@ export const AlertDialogContent = forwardRef<
|
|
|
37
38
|
<AlertDialogOverlay />
|
|
38
39
|
<AlertDialogPrimitive.Content
|
|
39
40
|
ref={ref}
|
|
41
|
+
data-slot="alert-dialog-content"
|
|
40
42
|
className={cn(
|
|
41
|
-
|
|
43
|
+
// `max-h` + `overflow-y-auto`: without a height ceiling, a long body
|
|
44
|
+
// (a big diff, a long list of items to confirm) overflows past both
|
|
45
|
+
// viewport edges with its actions unreachable — the `-translate-y-1/2`
|
|
46
|
+
// centring only makes this worse by pushing the top half off-screen.
|
|
47
|
+
"fixed left-1/2 top-1/2 z-50 grid max-h-[85dvh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl bg-card p-6 text-card-foreground shadow-ring-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
42
48
|
className,
|
|
43
49
|
)}
|
|
44
50
|
{...props}
|
|
@@ -48,11 +54,18 @@ export const AlertDialogContent = forwardRef<
|
|
|
48
54
|
});
|
|
49
55
|
|
|
50
56
|
export function AlertDialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
51
|
-
return
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
data-slot="alert-dialog-header"
|
|
60
|
+
className={cn("flex flex-col gap-1.5 text-start", className)}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
52
64
|
}
|
|
53
65
|
export function AlertDialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
54
66
|
return (
|
|
55
67
|
<div
|
|
68
|
+
data-slot="alert-dialog-footer"
|
|
56
69
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
|
57
70
|
{...props}
|
|
58
71
|
/>
|
|
@@ -63,9 +76,11 @@ export const AlertDialogTitle = forwardRef<
|
|
|
63
76
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
|
64
77
|
>(function AlertDialogTitle({ className, ...props }, ref) {
|
|
65
78
|
return (
|
|
79
|
+
// `text-title`, matching `DialogTitle` (#286) — was `text-lg font-semibold`.
|
|
66
80
|
<AlertDialogPrimitive.Title
|
|
67
81
|
ref={ref}
|
|
68
|
-
|
|
82
|
+
data-slot="alert-dialog-title"
|
|
83
|
+
className={cn("text-title leading-none", className)}
|
|
69
84
|
{...props}
|
|
70
85
|
/>
|
|
71
86
|
);
|
|
@@ -77,7 +92,8 @@ export const AlertDialogDescription = forwardRef<
|
|
|
77
92
|
return (
|
|
78
93
|
<AlertDialogPrimitive.Description
|
|
79
94
|
ref={ref}
|
|
80
|
-
|
|
95
|
+
data-slot="alert-dialog-description"
|
|
96
|
+
className={cn("text-body text-muted-foreground", className)}
|
|
81
97
|
{...props}
|
|
82
98
|
/>
|
|
83
99
|
);
|
|
@@ -87,7 +103,12 @@ export const AlertDialogAction = forwardRef<
|
|
|
87
103
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
|
88
104
|
>(function AlertDialogAction({ className, ...props }, ref) {
|
|
89
105
|
return (
|
|
90
|
-
<AlertDialogPrimitive.Action
|
|
106
|
+
<AlertDialogPrimitive.Action
|
|
107
|
+
ref={ref}
|
|
108
|
+
data-slot="alert-dialog-action"
|
|
109
|
+
className={cn(buttonVariants(), className)}
|
|
110
|
+
{...props}
|
|
111
|
+
/>
|
|
91
112
|
);
|
|
92
113
|
});
|
|
93
114
|
export const AlertDialogCancel = forwardRef<
|
|
@@ -97,6 +118,7 @@ export const AlertDialogCancel = forwardRef<
|
|
|
97
118
|
return (
|
|
98
119
|
<AlertDialogPrimitive.Cancel
|
|
99
120
|
ref={ref}
|
|
121
|
+
data-slot="alert-dialog-cancel"
|
|
100
122
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
|
101
123
|
{...props}
|
|
102
124
|
/>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
2
3
|
import { AppShell } from "./app-shell";
|
|
3
4
|
import { TopNav } from "../top-nav";
|
|
4
5
|
import { PageShell } from "../page-shell";
|
|
@@ -6,7 +7,7 @@ import { SectionHeader } from "../section-header";
|
|
|
6
7
|
import { Button } from "../button";
|
|
7
8
|
|
|
8
9
|
const meta = {
|
|
9
|
-
title: "Layout/App Shell/
|
|
10
|
+
title: "Layout/App Shell/Minimal",
|
|
10
11
|
component: AppShell,
|
|
11
12
|
parameters: { layout: "fullscreen" },
|
|
12
13
|
} satisfies Meta<typeof AppShell>;
|
|
@@ -46,8 +47,10 @@ export const Default: Story = {
|
|
|
46
47
|
<PageShell
|
|
47
48
|
header={
|
|
48
49
|
<SectionHeader
|
|
50
|
+
// The page's own title, so it owns the document outline root.
|
|
51
|
+
as="h1"
|
|
49
52
|
title="Overview"
|
|
50
|
-
description="A
|
|
53
|
+
description="A minimal, unstyled two-region shell — the honest answer for a simple layout. For full sidebar behavior use the Sidebar primitive; for a fully wired enterprise console see Layout/App Shell/Flagship."
|
|
51
54
|
/>
|
|
52
55
|
}
|
|
53
56
|
>
|
|
@@ -58,4 +61,43 @@ export const Default: Story = {
|
|
|
58
61
|
</AppShell>
|
|
59
62
|
</div>
|
|
60
63
|
),
|
|
64
|
+
play: async ({ canvasElement }) => {
|
|
65
|
+
const canvas = within(canvasElement);
|
|
66
|
+
|
|
67
|
+
// The shell ships a skip link, so a keyboard user can pass the nav rail
|
|
68
|
+
// instead of tabbing through it on every route (WCAG 2.4.1). Resolve the
|
|
69
|
+
// TARGET FROM THE LINK, never from a hard-coded `#main-content`: querying
|
|
70
|
+
// the id directly stays green when the link and the target are deleted
|
|
71
|
+
// together.
|
|
72
|
+
const skip = canvas.getByRole("link", { name: "Skip to main content" });
|
|
73
|
+
const targetId = (skip.getAttribute("href") ?? "").replace(/^#/, "");
|
|
74
|
+
await expect(targetId).not.toBe("");
|
|
75
|
+
const target = canvasElement.querySelector(`#${CSS.escape(targetId)}`) as HTMLElement;
|
|
76
|
+
await expect(target).toBeVisible();
|
|
77
|
+
await expect(target.tagName).toBe("MAIN");
|
|
78
|
+
await expect(canvas.getByRole("main")).toBe(target);
|
|
79
|
+
|
|
80
|
+
// …and here <main> IS the scroll port (in the registry blocks the skip
|
|
81
|
+
// target and the port are separate elements), so it takes a REAL tab stop,
|
|
82
|
+
// not the `-1` a pure skip target would carry: a region that scrolls has to
|
|
83
|
+
// be keyboard-operable even when nothing inside it is focusable (WCAG
|
|
84
|
+
// 2.1.1, axe `scrollable-region-focusable`). Read as an ATTRIBUTE — the
|
|
85
|
+
// `tabIndex` IDL getter answers -1 for any non-focusable element whether or
|
|
86
|
+
// not anyone set it, so it cannot tell "-1 was set" from "nothing was set".
|
|
87
|
+
await expect(target).toHaveAttribute("tabindex", "0");
|
|
88
|
+
await expect(getComputedStyle(target).overflowY).toBe("auto");
|
|
89
|
+
// The INSET rung: the shell root above carries `overflow-hidden`, which
|
|
90
|
+
// clips both layers of the plain `focus-ring`. Asserting only that
|
|
91
|
+
// `focus-ring-inset` is present would still pass with both classes on the
|
|
92
|
+
// element — and then the clipped one is what paints.
|
|
93
|
+
await expect(target.classList.contains("focus-ring-inset")).toBe(true);
|
|
94
|
+
await expect(target.classList.contains("focus-ring")).toBe(false);
|
|
95
|
+
|
|
96
|
+
// The outline has a root. axe cannot catch its absence here:
|
|
97
|
+
// `page-has-heading-one` is a best-practice rule outside the wcag2a/wcag2aa
|
|
98
|
+
// tag set, and `heading-order` passes happily on levels that start at 2.
|
|
99
|
+
const h1s = canvasElement.querySelectorAll("h1");
|
|
100
|
+
await expect(h1s.length).toBe(1);
|
|
101
|
+
await expect(canvas.getByRole("heading", { level: 1 })).toHaveTextContent("Overview");
|
|
102
|
+
},
|
|
61
103
|
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import { AppShell } from "./app-shell";
|
|
6
|
+
import {
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarMenu,
|
|
10
|
+
SidebarMenuButton,
|
|
11
|
+
SidebarMenuItem,
|
|
12
|
+
SidebarProvider,
|
|
13
|
+
SidebarTrigger,
|
|
14
|
+
} from "../sidebar";
|
|
15
|
+
|
|
16
|
+
describe("AppShell", () => {
|
|
17
|
+
it("forwards a ref to the root element", () => {
|
|
18
|
+
const ref = createRef<HTMLDivElement>();
|
|
19
|
+
render(
|
|
20
|
+
<AppShell ref={ref} mainId="main">
|
|
21
|
+
Content
|
|
22
|
+
</AppShell>,
|
|
23
|
+
);
|
|
24
|
+
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
|
25
|
+
// The forwarded node IS the shell root — the flex/overflow box that
|
|
26
|
+
// establishes the fixed-height viewport, not some inner wrapper.
|
|
27
|
+
expect(ref.current?.className).toContain("h-dvh");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("AppShell — mobile navigation (#review 2.1: sidebar disappears below md)", () => {
|
|
32
|
+
const originalInnerWidth = window.innerWidth;
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
Object.defineProperty(window, "innerWidth", {
|
|
36
|
+
writable: true,
|
|
37
|
+
configurable: true,
|
|
38
|
+
value: originalInnerWidth,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `sidebar` slot is `hidden md:flex` with no fallback of its own (see
|
|
44
|
+
* `app-shell.tsx`'s module doc comment) — by design, because `<Sidebar>`
|
|
45
|
+
* already renders its own off-canvas `Sheet` below the mobile breakpoint,
|
|
46
|
+
* through a Radix portal that mounts into `document.body`, entirely
|
|
47
|
+
* unaffected by AppShell's `hidden md:flex` wrapper (a portal is not a
|
|
48
|
+
* descendant of that wrapper's DOM subtree). Composing `<AppShell>` with
|
|
49
|
+
* the real `<Sidebar>` primitive (not a bare custom `<nav>`) and placing a
|
|
50
|
+
* `<SidebarTrigger>` in `topNav` — both wrapped in ONE shared
|
|
51
|
+
* `<SidebarProvider>`, which is a plain React context and so is never
|
|
52
|
+
* blocked by AppShell's internal wrapper `<div>` — is therefore ALREADY a
|
|
53
|
+
* working mobile nav path with no AppShell code change: this test locks
|
|
54
|
+
* that composition rather than reworking AppShell into owning a second,
|
|
55
|
+
* competing `Sheet` (which would have to unmount `<Sidebar>` whenever ITS
|
|
56
|
+
* own drawer closes — see the doc comment for why that is a regression,
|
|
57
|
+
* not a fix). A consumer who instead passes a bare nav node still needs to
|
|
58
|
+
* bring its own responsive fallback, exactly as documented.
|
|
59
|
+
*/
|
|
60
|
+
it("reaches sidebar navigation through Sidebar's own Sheet fallback at a mobile width", async () => {
|
|
61
|
+
Object.defineProperty(window, "innerWidth", {
|
|
62
|
+
writable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
value: 500,
|
|
65
|
+
});
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
render(
|
|
68
|
+
<SidebarProvider>
|
|
69
|
+
<AppShell
|
|
70
|
+
mainId="main"
|
|
71
|
+
topNav={<SidebarTrigger />}
|
|
72
|
+
sidebar={
|
|
73
|
+
<Sidebar>
|
|
74
|
+
<SidebarContent>
|
|
75
|
+
<SidebarMenu>
|
|
76
|
+
<SidebarMenuItem>
|
|
77
|
+
<SidebarMenuButton>Dashboard</SidebarMenuButton>
|
|
78
|
+
</SidebarMenuItem>
|
|
79
|
+
</SidebarMenu>
|
|
80
|
+
</SidebarContent>
|
|
81
|
+
</Sidebar>
|
|
82
|
+
}
|
|
83
|
+
>
|
|
84
|
+
Main content
|
|
85
|
+
</AppShell>
|
|
86
|
+
</SidebarProvider>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Below `md`, the desktop sidebar branch renders nothing (it is inside
|
|
90
|
+
// AppShell's `hidden md:flex` slot AND `<Sidebar>` itself renders `null`
|
|
91
|
+
// desktop markup on mobile) — the nav item is reachable only once the
|
|
92
|
+
// trigger opens the portal-rendered Sheet.
|
|
93
|
+
expect(screen.queryByRole("button", { name: "Dashboard" })).not.toBeInTheDocument();
|
|
94
|
+
|
|
95
|
+
await user.click(screen.getByRole("button", { name: "Toggle Sidebar" }));
|
|
96
|
+
|
|
97
|
+
expect(await screen.findByRole("button", { name: "Dashboard" })).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { forwardRef, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "../../lib/cn";
|
|
3
|
+
import { SkipLink } from "../skip-link";
|
|
3
4
|
|
|
4
5
|
export interface AppShellProps {
|
|
5
6
|
/** Persistent left navigation (often a <Sidebar />). */
|
|
@@ -10,22 +11,73 @@ export interface AppShellProps {
|
|
|
10
11
|
className?: string;
|
|
11
12
|
/** Class for the scrolling main content region. */
|
|
12
13
|
mainClassName?: string;
|
|
14
|
+
/**
|
|
15
|
+
* The `<main>` region's id, and the skip link's target. Override it when a
|
|
16
|
+
* page mounts more than one shell — two elements sharing `main-content`
|
|
17
|
+
* would make the skip link ambiguous. @default "main-content"
|
|
18
|
+
*/
|
|
19
|
+
mainId?: string;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
/**
|
|
16
23
|
* App-first layout: fixed-height viewport with an optional sidebar, a top bar,
|
|
17
24
|
* and a scrollable main region. Pure layout — bring your own nav components.
|
|
25
|
+
*
|
|
26
|
+
* The `sidebar` slot is hidden below `md` with no fallback of its own: this
|
|
27
|
+
* shell does not know whether `sidebar` already manages its own responsive
|
|
28
|
+
* behavior. `<Sidebar>` (`../sidebar`) does — it renders as an offcanvas
|
|
29
|
+
* `Sheet` below the mobile breakpoint entirely on its own, through a Radix
|
|
30
|
+
* portal that is unaffected by this wrapper's `hidden md:flex` (a portal
|
|
31
|
+
* mounts into `document.body`, not into this DOM subtree) — pair it with a
|
|
32
|
+
* `<SidebarTrigger>` inside `topNav` for full mobile navigation. Wrapping an
|
|
33
|
+
* arbitrary `sidebar` node in a SECOND, shell-owned `Sheet` here would look
|
|
34
|
+
* like a fix but silently break that exact pattern: a Radix `Sheet`/`Dialog`
|
|
35
|
+
* only mounts its content while open, so `<Sidebar>` would be unmounted
|
|
36
|
+
* (not just hidden) whenever the shell's own drawer is closed — and with it,
|
|
37
|
+
* the very trigger a caller uses to open ITS mobile view. A bare nav node
|
|
38
|
+
* (no built-in responsive behavior of its own) has no such fallback; give it
|
|
39
|
+
* one explicitly, e.g. by rendering a `<Sheet>` alongside `topNav` in the
|
|
40
|
+
* consuming app.
|
|
41
|
+
*
|
|
42
|
+
* The `SidebarProvider` + `Sidebar` + `SidebarTrigger` composition above is
|
|
43
|
+
* regression-locked in `app-shell.test.tsx` at a mobile viewport width: no
|
|
44
|
+
* AppShell code change is needed to reach navigation below `md`, because
|
|
45
|
+
* `SidebarProvider` is a plain React context and is never blocked by this
|
|
46
|
+
* component's internal wrapper `<div>`.
|
|
18
47
|
*/
|
|
19
|
-
export
|
|
48
|
+
export const AppShell = forwardRef<HTMLDivElement, AppShellProps>(function AppShell(
|
|
49
|
+
{ sidebar, topNav, children, className, mainClassName, mainId = "main-content" },
|
|
50
|
+
ref,
|
|
51
|
+
) {
|
|
20
52
|
return (
|
|
21
53
|
<div
|
|
54
|
+
ref={ref}
|
|
22
55
|
className={cn("flex h-dvh w-full overflow-hidden bg-background text-foreground", className)}
|
|
23
56
|
>
|
|
57
|
+
{/* The first focusable element of the shell, so a keyboard user can pass
|
|
58
|
+
the nav rail instead of tabbing through it on every route (WCAG 2.4.1).
|
|
59
|
+
All four registry app-shell blocks ship one; the primitive they sit
|
|
60
|
+
beside must not be the exception, and it is the surface with the widest
|
|
61
|
+
reach — it is imported, not copy-owned. */}
|
|
62
|
+
<SkipLink targetId={mainId} />
|
|
24
63
|
{sidebar ? <div className="hidden md:flex">{sidebar}</div> : null}
|
|
25
64
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
26
65
|
{topNav}
|
|
27
|
-
|
|
66
|
+
{/* `<main>` IS the scroll port here (in the blocks the skip target and
|
|
67
|
+
the scroll port are separate elements), so it takes `tabIndex={0}`
|
|
68
|
+
rather than `{-1}`: a region that scrolls has to be keyboard-operable
|
|
69
|
+
even when the content inside it is not focusable (WCAG 2.1.1, axe
|
|
70
|
+
`scrollable-region-focusable`). `focus-ring-inset`, not `focus-ring`
|
|
71
|
+
— the root above carries `overflow-hidden`, which clips both layers
|
|
72
|
+
of the plain rung. */}
|
|
73
|
+
<main
|
|
74
|
+
id={mainId}
|
|
75
|
+
tabIndex={0}
|
|
76
|
+
className={cn("min-h-0 flex-1 overflow-y-auto focus-ring-inset", mainClassName)}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</main>
|
|
28
80
|
</div>
|
|
29
81
|
</div>
|
|
30
82
|
);
|
|
31
|
-
}
|
|
83
|
+
});
|
|
@@ -17,7 +17,15 @@ const meta = {
|
|
|
17
17
|
title: "Layout/AppSidebar",
|
|
18
18
|
component: AppSidebar,
|
|
19
19
|
tags: ["autodocs"],
|
|
20
|
-
parameters: {
|
|
20
|
+
parameters: {
|
|
21
|
+
layout: "fullscreen",
|
|
22
|
+
docs: {
|
|
23
|
+
description: {
|
|
24
|
+
component:
|
|
25
|
+
"The opinionated application sidebar — the raw primitive set it composes is `Layout/Sidebar`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `AppSidebar` wraps the `Sidebar` → `SidebarHeader` → `SidebarContent` → `SidebarFooter` skeleton behind typed `header` / `footer` slots with the navigation as `children`, and forwards every `Sidebar` prop. The registry’s sidebar blocks import this rather than rebuilding the skeleton.",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
21
29
|
decorators: [
|
|
22
30
|
(Story) => (
|
|
23
31
|
<SidebarProvider>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import { forwardRef, useId, useMemo, type HTMLAttributes } from "react";
|
|
2
4
|
import { cn } from "../../lib/cn";
|
|
3
5
|
import { Badge } from "../badge";
|
|
@@ -74,7 +76,7 @@ function AttributionItem({ item, requiredLabel }: { item: Attribution; requiredL
|
|
|
74
76
|
href={item.url}
|
|
75
77
|
target="_blank"
|
|
76
78
|
rel="noopener noreferrer"
|
|
77
|
-
className="text-body font-medium underline underline-offset-2 focus-
|
|
79
|
+
className="text-body font-medium underline underline-offset-2 focus-ring rounded-xs"
|
|
78
80
|
>
|
|
79
81
|
{item.name}
|
|
80
82
|
</a>
|