@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
|
@@ -51,6 +51,15 @@ const meta = {
|
|
|
51
51
|
title: "Forms/TreeSelect",
|
|
52
52
|
component: TreeSelect,
|
|
53
53
|
tags: ["autodocs"],
|
|
54
|
+
// The trigger fills its container by default (no hard-fixed width) — cap
|
|
55
|
+
// the docs canvas to a realistic form-field width.
|
|
56
|
+
decorators: [
|
|
57
|
+
(Story) => (
|
|
58
|
+
<div className="w-72">
|
|
59
|
+
<Story />
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
],
|
|
54
63
|
argTypes: {
|
|
55
64
|
nodes: {
|
|
56
65
|
description: "Hierarchical tree data (`TreeNode[]`).",
|
|
@@ -37,4 +37,9 @@ describe("TreeSelect", () => {
|
|
|
37
37
|
render(<TreeSelect nodes={nodes} multiple value={["apple", "banana", "carrot"]} />);
|
|
38
38
|
expect(screen.getByText("+1")).toBeInTheDocument();
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
it("does not hard-fix the trigger width — fills its container by default", () => {
|
|
42
|
+
render(<TreeSelect nodes={nodes} />);
|
|
43
|
+
expect(screen.getByRole("combobox").className).not.toMatch(/\bw-64\b/);
|
|
44
|
+
});
|
|
40
45
|
});
|
|
@@ -7,6 +7,7 @@ import { cn } from "../../lib/cn";
|
|
|
7
7
|
import { Badge } from "../badge";
|
|
8
8
|
import { Button } from "../button";
|
|
9
9
|
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
|
10
|
+
import { useLocale } from "../locale-provider";
|
|
10
11
|
import { Tree, type TreeNode } from "../tree";
|
|
11
12
|
|
|
12
13
|
export interface TreeSelectProps<T = unknown> extends Omit<
|
|
@@ -47,18 +48,11 @@ function findLabel<T>(nodes: TreeNode<T>[], id: string): ReactNode | undefined {
|
|
|
47
48
|
* selection; composes `Tree` for all keyboard/SR behaviour.
|
|
48
49
|
*/
|
|
49
50
|
export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(function TreeSelect(
|
|
50
|
-
{
|
|
51
|
-
nodes,
|
|
52
|
-
value,
|
|
53
|
-
defaultValue,
|
|
54
|
-
onValueChange,
|
|
55
|
-
multiple = false,
|
|
56
|
-
placeholder = "Select…",
|
|
57
|
-
className,
|
|
58
|
-
...props
|
|
59
|
-
},
|
|
51
|
+
{ nodes, value, defaultValue, onValueChange, multiple = false, placeholder, className, ...props },
|
|
60
52
|
ref,
|
|
61
53
|
) {
|
|
54
|
+
const { t } = useLocale();
|
|
55
|
+
const resolvedPlaceholder = placeholder ?? t("ui.treeSelect.placeholder");
|
|
62
56
|
const [open, setOpen] = useState(false);
|
|
63
57
|
const treeId = useId();
|
|
64
58
|
const isControlled = value !== undefined;
|
|
@@ -88,12 +82,12 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
|
|
|
88
82
|
aria-haspopup="tree"
|
|
89
83
|
aria-expanded={open}
|
|
90
84
|
aria-controls={treeId}
|
|
91
|
-
className={cn("h-auto min-h-9 w-
|
|
85
|
+
className={cn("h-auto min-h-9 w-full justify-between font-normal", className)}
|
|
92
86
|
{...props}
|
|
93
87
|
>
|
|
94
88
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-start">
|
|
95
89
|
{labels.length === 0 ? (
|
|
96
|
-
<span className="text-muted-foreground">{
|
|
90
|
+
<span className="text-muted-foreground">{resolvedPlaceholder}</span>
|
|
97
91
|
) : multiple ? (
|
|
98
92
|
<>
|
|
99
93
|
{labels.slice(0, 2).map((l) => (
|
|
@@ -106,7 +100,10 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
|
|
|
106
100
|
</Badge>
|
|
107
101
|
))}
|
|
108
102
|
{labels.length > 2 ? (
|
|
109
|
-
<Badge
|
|
103
|
+
<Badge
|
|
104
|
+
variant="secondary"
|
|
105
|
+
aria-label={t("ui.treeSelect.moreSelected", { count: labels.length - 2 })}
|
|
106
|
+
>
|
|
110
107
|
+{labels.length - 2}
|
|
111
108
|
</Badge>
|
|
112
109
|
) : null}
|
|
@@ -118,7 +115,7 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
|
|
|
118
115
|
<ChevronsUpDown className="ms-2 size-4 shrink-0 opacity-50" aria-hidden="true" />
|
|
119
116
|
</Button>
|
|
120
117
|
</PopoverTrigger>
|
|
121
|
-
<PopoverContent className="w-64 p-1" align="start">
|
|
118
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] min-w-64 p-1" align="start">
|
|
122
119
|
<Tree
|
|
123
120
|
id={treeId}
|
|
124
121
|
nodes={nodes}
|
|
@@ -20,12 +20,13 @@ import { ProseLink } from "./prose";
|
|
|
20
20
|
afterEach(cleanup);
|
|
21
21
|
|
|
22
22
|
describe("ProseLink — #399 on-surface brand-accent rung", () => {
|
|
23
|
-
|
|
23
|
+
// `text-link` reads `--link`, which the reference themes alias to `--primary-text`.
|
|
24
|
+
it("renders the link ink, never the text-primary fill rung", () => {
|
|
24
25
|
render(<ProseLink href="https://example.com">docs</ProseLink>);
|
|
25
26
|
|
|
26
27
|
const link = screen.getByRole("link", { name: "docs" });
|
|
27
28
|
const classes = link.className.split(/\s+/);
|
|
28
|
-
expect(classes).toContain("text-
|
|
29
|
+
expect(classes).toContain("text-link");
|
|
29
30
|
expect(classes).not.toContain("text-primary");
|
|
30
31
|
});
|
|
31
32
|
|
|
@@ -116,10 +116,10 @@ export const ProseLink = forwardRef<HTMLAnchorElement, ProseLinkProps>(function
|
|
|
116
116
|
ref={ref}
|
|
117
117
|
href={href}
|
|
118
118
|
className={cn(
|
|
119
|
-
// #399 — `text-primary-text
|
|
120
|
-
// link is ordinary body text and owes WCAG 1.4.3 AA (4.5:1), which the
|
|
119
|
+
// #399 — `text-link` (defaults to `--primary-text`), not the `text-primary`
|
|
120
|
+
// FILL rung: a prose link is ordinary body text and owes WCAG 1.4.3 AA (4.5:1), which the
|
|
121
121
|
// fill only guarantees at the 1.4.11 mark bar (3:1).
|
|
122
|
-
"font-medium text-
|
|
122
|
+
"font-medium text-link underline underline-offset-4 hover:no-underline focus-ring",
|
|
123
123
|
className,
|
|
124
124
|
)}
|
|
125
125
|
{...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
3
|
import { expect } from "storybook/test";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
4
5
|
import { Heading, Text } from "./typography";
|
|
5
6
|
import {
|
|
6
7
|
ProseBlockquote,
|
|
@@ -242,3 +243,230 @@ export const Prose: Story = {
|
|
|
242
243
|
</div>
|
|
243
244
|
),
|
|
244
245
|
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The CJK FONT FALLBACK + LINE-BREAKING seam (#15). `--font-cjk-sans` is a
|
|
249
|
+
* dedicated tail in `--font-sans` (system faces only — never bundled; a CJK
|
|
250
|
+
* face is megabytes and every target platform already ships one) that
|
|
251
|
+
* `:lang(zh)` / `:lang(ko)` re-point per locale (`ja` is the root default);
|
|
252
|
+
* `:lang(ja)`/`:lang(zh)` also get `line-break: strict` and `:lang(ko)` gets
|
|
253
|
+
* `word-break: keep-all` (`themes.css` §§ "CJK PER-LOCALE FONT SEAM" / "CJK
|
|
254
|
+
* line-breaking"). Before this story, no story anywhere rendered CJK text, so
|
|
255
|
+
* none of this was exercised.
|
|
256
|
+
*
|
|
257
|
+
* The designed real-world shape is `<html lang="zh">` for a whole document,
|
|
258
|
+
* with `font-family: var(--font-sans)` declared ONCE at that same theme-root
|
|
259
|
+
* element. That matters for how this story has to be built: a custom
|
|
260
|
+
* property's `var()` references are substituted at the element that declares
|
|
261
|
+
* the property, and the RESULT — not the raw token stream — is what
|
|
262
|
+
* descendants inherit. So `--font-sans`'s `var(--font-cjk-sans)` is resolved
|
|
263
|
+
* once, at the theme root, using whatever `lang` is active THERE; a nested
|
|
264
|
+
* `:lang(zh)` further down only re-points `--font-cjk-sans` on that
|
|
265
|
+
* descendant — it does nothing for `--font-sans`, which the descendant never
|
|
266
|
+
* redeclares, only inherits pre-resolved. To show all three locales side by
|
|
267
|
+
* side under one shared theme root (whose own `lang` cannot be three things
|
|
268
|
+
* at once), each "with the seam" sample below redeclares the SAME expansion
|
|
269
|
+
* `--font-sans` itself uses (see `themes/light.css`/`themes/dark.css`)
|
|
270
|
+
* directly in its `style`, so the `var(--font-cjk-sans)` substitution happens
|
|
271
|
+
* fresh, at that element, against the `--font-cjk-sans` value its own `lang`
|
|
272
|
+
* cascades in via `:lang()`.
|
|
273
|
+
*/
|
|
274
|
+
type CjkSample = {
|
|
275
|
+
lang: "ja" | "zh" | "ko";
|
|
276
|
+
label: string;
|
|
277
|
+
rule: string;
|
|
278
|
+
text: string;
|
|
279
|
+
width?: string; // Optional custom width, defaults to w-56
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const CJK_SAMPLES: readonly CjkSample[] = [
|
|
283
|
+
{
|
|
284
|
+
lang: "ja",
|
|
285
|
+
label: "Japanese (ja)",
|
|
286
|
+
rule: "line-break: strict",
|
|
287
|
+
text:
|
|
288
|
+
"この文章は十分に長く、コンテナの幅を超えるとブラウザが自動的に折り返しを行います。" +
|
|
289
|
+
"折り返しの際に句読点や閉じ括弧(このように)が行頭に来ないようにするのが、" +
|
|
290
|
+
"line-break: strict の役割です。",
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
lang: "zh",
|
|
294
|
+
label: "Chinese (zh)",
|
|
295
|
+
rule: "line-break: strict",
|
|
296
|
+
text:
|
|
297
|
+
"这段文字足够长,当容器宽度不足时浏览器会自动换行。line-break: strict 的作用是避免" +
|
|
298
|
+
"标点符号(例如逗号、句号或右括号)出现在行首,让排版更符合中文书写习惯。",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
lang: "ko",
|
|
302
|
+
label: "Korean (ko)",
|
|
303
|
+
rule: "word-break: keep-all",
|
|
304
|
+
text:
|
|
305
|
+
"소프트웨어개발환경구축및정보통신기술표준화를위한국제협력과연구개발사업이진행되고있습니다. " +
|
|
306
|
+
"word-break: keep-all 속성은 긴 복합어 중간에서 줄이 바뀌지 않도록 보장하여 " +
|
|
307
|
+
"가독성을 지켜 줍니다.",
|
|
308
|
+
width: "w-36",
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
/** One locale panel: the seam ON (real `lang`) next to the seam forced OFF, for comparison. */
|
|
313
|
+
function CjkPanel({ sample }: { sample: CjkSample }) {
|
|
314
|
+
const { lang, label, rule, text, width = "w-56" } = sample;
|
|
315
|
+
const withSeamRef = useRef<HTMLParagraphElement>(null);
|
|
316
|
+
const [measured, setMeasured] = useState("");
|
|
317
|
+
useLayoutEffect(() => {
|
|
318
|
+
const el = withSeamRef.current;
|
|
319
|
+
if (!el) return;
|
|
320
|
+
const { lineBreak, wordBreak, fontFamily } = getComputedStyle(el);
|
|
321
|
+
setMeasured(`line-break: ${lineBreak} · word-break: ${wordBreak} · font-family: ${fontFamily}`);
|
|
322
|
+
}, []);
|
|
323
|
+
return (
|
|
324
|
+
<section aria-labelledby={`cjk-${lang}`} className="space-y-2">
|
|
325
|
+
<h3 id={`cjk-${lang}`} className="text-subtitle text-foreground">
|
|
326
|
+
{label} — <code className="text-code">{rule}</code>
|
|
327
|
+
</h3>
|
|
328
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
329
|
+
<figure className="m-0 space-y-1">
|
|
330
|
+
<figcaption className="text-meta text-muted-foreground">
|
|
331
|
+
Without the seam (comparison only — line-break/word-break forced to the CSS-initial
|
|
332
|
+
default)
|
|
333
|
+
</figcaption>
|
|
334
|
+
{/* Comparison-only override to show what the seam replaces; a real
|
|
335
|
+
consumer never does this. Font-family is left alone here — this
|
|
336
|
+
column isolates the LINE-BREAKING half of the seam. */}
|
|
337
|
+
<p
|
|
338
|
+
lang={lang}
|
|
339
|
+
style={{ fontFamily: "var(--font-sans)", lineBreak: "auto", wordBreak: "normal" }}
|
|
340
|
+
className={cn("m-0 rounded-md border border-border bg-card p-3 text-body", width)}
|
|
341
|
+
>
|
|
342
|
+
{text}
|
|
343
|
+
</p>
|
|
344
|
+
</figure>
|
|
345
|
+
<figure className="m-0 space-y-1">
|
|
346
|
+
<figcaption className="text-meta text-muted-foreground">
|
|
347
|
+
With the seam (the default result of{" "}
|
|
348
|
+
<code className="text-code">lang="{lang}"</code>)
|
|
349
|
+
</figcaption>
|
|
350
|
+
<p
|
|
351
|
+
ref={withSeamRef}
|
|
352
|
+
lang={lang}
|
|
353
|
+
data-testid={`cjk-with-seam-${lang}`}
|
|
354
|
+
// Redeclares --font-sans's own expansion (themes/light.css,
|
|
355
|
+
// themes/dark.css) directly, rather than using
|
|
356
|
+
// `var(--font-sans)`, because --font-sans is inherited from the
|
|
357
|
+
// shared theme root as an ALREADY-substituted string — see the
|
|
358
|
+
// comment above CjkSample. Writing the expansion here forces the
|
|
359
|
+
// `var(--font-cjk-sans)` substitution to happen at THIS element,
|
|
360
|
+
// against the value its own `lang` cascades in.
|
|
361
|
+
style={{
|
|
362
|
+
fontFamily:
|
|
363
|
+
'Inter, var(--font-cjk-sans), "Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
364
|
+
}}
|
|
365
|
+
className={cn("m-0 rounded-md border border-border bg-card p-3 text-body", width)}
|
|
366
|
+
>
|
|
367
|
+
{text}
|
|
368
|
+
</p>
|
|
369
|
+
</figure>
|
|
370
|
+
</div>
|
|
371
|
+
{/* The declared (not rendered) values — a real, un-bundled system CJK
|
|
372
|
+
face never looks identical across two machines, so THIS is what a
|
|
373
|
+
reviewer can actually verify: the correct stack is being asked for.
|
|
374
|
+
|
|
375
|
+
NOTE: document.fonts.check() is NOT used to verify font resolution
|
|
376
|
+
(issue #62). That API reports load status, not face existence — it
|
|
377
|
+
returns true even for nonexistent font names. The measured values
|
|
378
|
+
instead read the browser's own getComputedStyle output, which is
|
|
379
|
+
verifiable. */}
|
|
380
|
+
<p className="m-0 text-meta text-muted-foreground" data-testid={`cjk-measured-${lang}`}>
|
|
381
|
+
{measured || "measuring…"}
|
|
382
|
+
</p>
|
|
383
|
+
</section>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export const CJKFontAndLineBreaking: Story = {
|
|
388
|
+
name: "CJK font fallback + line-breaking (#15)",
|
|
389
|
+
parameters: {
|
|
390
|
+
docs: {
|
|
391
|
+
description: {
|
|
392
|
+
story:
|
|
393
|
+
"Japanese, Chinese and Korean samples, each wrapped in a narrow (14rem) column so " +
|
|
394
|
+
"the text wraps across multiple lines — the line-breaking rules need a real wrap " +
|
|
395
|
+
"point to have anything to do.\n\n" +
|
|
396
|
+
"**Verification by locale:**\n" +
|
|
397
|
+
"* **Japanese (`ja`) and Chinese (`zh`):** `line-break: strict` is verified via " +
|
|
398
|
+
"computed style only; no empirically-validated visual wrap difference has been found at " +
|
|
399
|
+
"this column width for any practical sample text in chromium or webkit, so the rendered " +
|
|
400
|
+
"layout is NOT asserted to differ. The rule is confirmed present via `getComputedStyle`.\n" +
|
|
401
|
+
"* **Korean (`ko`):** `word-break: keep-all` is verified both via computed style AND " +
|
|
402
|
+
"an actual rendered wrap-position difference assertion in the play function.\n\n" +
|
|
403
|
+
"The measured line under each pair reads the browser's OWN computed `line-break` / " +
|
|
404
|
+
"`word-break` / `font-family`, live. Rendered glyph shape depends on which CJK system " +
|
|
405
|
+
"fonts are installed on the machine viewing this story (none are bundled, by design " +
|
|
406
|
+
"— #15); the declared font STACK is what is verifiable everywhere.",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
render: () => (
|
|
411
|
+
<div className="max-w-3xl space-y-8">
|
|
412
|
+
{CJK_SAMPLES.map((s) => (
|
|
413
|
+
<CjkPanel key={s.lang} sample={s} />
|
|
414
|
+
))}
|
|
415
|
+
</div>
|
|
416
|
+
),
|
|
417
|
+
// Asserts the seam on the REAL rendered surface, mirroring the DensityScale
|
|
418
|
+
// play function above: read the browser's actual computed style rather than
|
|
419
|
+
// re-deriving the CSS by hand.
|
|
420
|
+
play: async ({ canvas }) => {
|
|
421
|
+
const ja = canvas.getByTestId("cjk-with-seam-ja");
|
|
422
|
+
const zh = canvas.getByTestId("cjk-with-seam-zh");
|
|
423
|
+
const ko = canvas.getByTestId("cjk-with-seam-ko");
|
|
424
|
+
|
|
425
|
+
// :lang(ja), :lang(zh) => line-break: strict. :lang(ko) is NOT in that
|
|
426
|
+
// rule, so it stays at the CSS-initial value ("auto").
|
|
427
|
+
expect(getComputedStyle(ja).lineBreak).toBe("strict");
|
|
428
|
+
expect(getComputedStyle(zh).lineBreak).toBe("strict");
|
|
429
|
+
expect(getComputedStyle(ko).lineBreak).toBe("auto");
|
|
430
|
+
|
|
431
|
+
// :lang(ko) => word-break: keep-all. ja/zh are NOT in that rule, so they
|
|
432
|
+
// stay at the CSS-initial value ("normal").
|
|
433
|
+
expect(getComputedStyle(ko).wordBreak).toBe("keep-all");
|
|
434
|
+
expect(getComputedStyle(ja).wordBreak).toBe("normal");
|
|
435
|
+
expect(getComputedStyle(zh).wordBreak).toBe("normal");
|
|
436
|
+
|
|
437
|
+
// --font-cjk-sans is re-pointed per :lang() — the DECLARED face list
|
|
438
|
+
// differs by locale, which is what the fallback seam actually ships.
|
|
439
|
+
expect(getComputedStyle(ja).fontFamily).toContain("Hiragino Sans");
|
|
440
|
+
expect(getComputedStyle(zh).fontFamily).toContain("PingFang SC");
|
|
441
|
+
expect(getComputedStyle(ko).fontFamily).toContain("Apple SD Gothic Neo");
|
|
442
|
+
|
|
443
|
+
// Korean's word-break: keep-all must produce a RENDERED wrap-position
|
|
444
|
+
// difference. Measure bounding height via getClientRects — if the seam
|
|
445
|
+
// (word-break: keep-all) doesn't change the wrap, the height stays the
|
|
446
|
+
// same. Find the corresponding "without the seam" panel by the section's
|
|
447
|
+
// sibling figure.
|
|
448
|
+
const koSection = canvas.getByTestId("cjk-with-seam-ko").closest("section");
|
|
449
|
+
if (!koSection) throw new Error("Korean section not found");
|
|
450
|
+
const koFigures = Array.from(koSection.querySelectorAll("figure"));
|
|
451
|
+
if (koFigures.length < 2) throw new Error("Korean figures not found");
|
|
452
|
+
|
|
453
|
+
const withSeamFigure = koFigures[1]; // "With the seam" is the second figure
|
|
454
|
+
const withoutSeamFigure = koFigures[0]; // "Without the seam" is the first figure
|
|
455
|
+
|
|
456
|
+
const withSeamP = withSeamFigure.querySelector("p");
|
|
457
|
+
const withoutSeamP = withoutSeamFigure.querySelector("p");
|
|
458
|
+
|
|
459
|
+
if (!withSeamP || !withoutSeamP) {
|
|
460
|
+
throw new Error("Korean paragraph elements not found");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Measure bounding height: the word-break property should change how the
|
|
464
|
+
// text wraps, resulting in a different total height.
|
|
465
|
+
const withSeamHeight = withSeamP.getBoundingClientRect().height;
|
|
466
|
+
const withoutSeamHeight = withoutSeamP.getBoundingClientRect().height;
|
|
467
|
+
|
|
468
|
+
// Assert they differ — word-break: keep-all should prevent word breaks
|
|
469
|
+
// that would otherwise occur, changing the layout.
|
|
470
|
+
expect(withSeamHeight).not.toBe(withoutSeamHeight);
|
|
471
|
+
},
|
|
472
|
+
};
|
|
@@ -11,7 +11,15 @@ const meta = {
|
|
|
11
11
|
title: "Layout/ViewToolbar",
|
|
12
12
|
component: ViewToolbar,
|
|
13
13
|
tags: ["autodocs"],
|
|
14
|
-
parameters: {
|
|
14
|
+
parameters: {
|
|
15
|
+
layout: "padded",
|
|
16
|
+
docs: {
|
|
17
|
+
description: {
|
|
18
|
+
component:
|
|
19
|
+
"The row above a list, table or board — filters and context on the left, actions on the right. Its controls are ordinary tab stops on purpose: a dense secondary control row that should cost ONE tab stop with arrow-key roving is `Layout/Toolbar`, the component that actually claims the `toolbar` ARIA role. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs) and [Docs / View Toolbar Contract](?path=/docs/docs-view-toolbar-contract--docs) (rule R10) for the filter-chip, result-count and control-height rules of this row.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
15
23
|
argTypes: {
|
|
16
24
|
info: {
|
|
17
25
|
description: "What this view is — shown in a tooltip on the ⓘ trigger.",
|
|
@@ -313,6 +321,38 @@ export const KeyboardRemoval: Story = {
|
|
|
313
321
|
},
|
|
314
322
|
};
|
|
315
323
|
|
|
324
|
+
/**
|
|
325
|
+
* `FilterChip`'s `trailing` slot (#284) — a short, non-shrinking secondary
|
|
326
|
+
* text segment (a count) that CSS truncation can never reach, unlike `label`.
|
|
327
|
+
* In a narrow container the label truncates with an ellipsis; the count
|
|
328
|
+
* stays fully visible in its own element.
|
|
329
|
+
*/
|
|
330
|
+
export const FilterChipWithTrailing: Story = {
|
|
331
|
+
render: () => (
|
|
332
|
+
<div className="w-[220px]">
|
|
333
|
+
<FilterChip
|
|
334
|
+
label="Status: Awaiting downstream reconciliation review"
|
|
335
|
+
trailing="excluded 1,204"
|
|
336
|
+
onRemove={() => undefined}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
),
|
|
340
|
+
play: async ({ canvas, canvasElement }) => {
|
|
341
|
+
const trailing = await canvas.findByText("excluded 1,204");
|
|
342
|
+
await expect(trailing).toBeInTheDocument();
|
|
343
|
+
|
|
344
|
+
const truncating = canvasElement.querySelector('[class*="truncate"]');
|
|
345
|
+
await expect(truncating).not.toBeNull();
|
|
346
|
+
await expect(truncating?.contains(trailing)).toBe(false);
|
|
347
|
+
|
|
348
|
+
await expect(
|
|
349
|
+
canvas.getByRole("button", {
|
|
350
|
+
name: "Remove filter: Status: Awaiting downstream reconciliation review · excluded 1,204",
|
|
351
|
+
}),
|
|
352
|
+
).toBeInTheDocument();
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
316
356
|
/** The same row on `dark`. */
|
|
317
357
|
export const DarkTheme: Story = {
|
|
318
358
|
globals: { theme: "dark" },
|
|
@@ -208,6 +208,39 @@ describe("FilterChip", () => {
|
|
|
208
208
|
expect(chip).not.toBeNull();
|
|
209
209
|
expect(ref.current).toBe(chip);
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
describe("trailing slot", () => {
|
|
213
|
+
it("renders trailing in its own element, separate from the truncating label span (#284)", () => {
|
|
214
|
+
const { container } = render(
|
|
215
|
+
<FilterChip label="Status: Failed" trailing="excluded 1,204" onRemove={() => undefined} />,
|
|
216
|
+
);
|
|
217
|
+
const label = container.querySelector("[data-slot='view-toolbar-filter-chip-label']");
|
|
218
|
+
const trailing = container.querySelector("[data-slot='view-toolbar-filter-chip-trailing']");
|
|
219
|
+
expect(label).not.toBeNull();
|
|
220
|
+
expect(trailing).not.toBeNull();
|
|
221
|
+
expect(label?.textContent).toBe("Status: Failed");
|
|
222
|
+
expect(trailing?.textContent).toBe("excluded 1,204");
|
|
223
|
+
expect(label?.className).toContain("truncate");
|
|
224
|
+
expect(trailing?.contains(label)).toBe(false);
|
|
225
|
+
expect(label?.contains(trailing as Node)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("composes label and trailing into the accessible name", () => {
|
|
229
|
+
render(
|
|
230
|
+
<FilterChip label="Status: Failed" trailing="excluded 1,204" onRemove={() => undefined} />,
|
|
231
|
+
);
|
|
232
|
+
expect(
|
|
233
|
+
screen.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
|
|
234
|
+
).toBeInTheDocument();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("renders no trailing element when trailing is omitted", () => {
|
|
238
|
+
const { container } = render(
|
|
239
|
+
<FilterChip label="Status: Failed" onRemove={() => undefined} />,
|
|
240
|
+
);
|
|
241
|
+
expect(container.querySelector("[data-slot='view-toolbar-filter-chip-trailing']")).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
211
244
|
});
|
|
212
245
|
|
|
213
246
|
describe("ResultCount", () => {
|
|
@@ -200,6 +200,22 @@ export interface FilterChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
|
|
200
200
|
* as a sentence fragment out of context (and in a screen reader).
|
|
201
201
|
*/
|
|
202
202
|
label: string;
|
|
203
|
+
/**
|
|
204
|
+
* Short, non-shrinking secondary text — a count, a unit (`"excluded 1,204"`).
|
|
205
|
+
* A second text slot rather than folding it into `label` (#284): `label` is
|
|
206
|
+
* the shrinkable, safe-to-truncate part; `trailing` is short and fixed-width
|
|
207
|
+
* and must never lose characters to the ellipsis. Same shape as
|
|
208
|
+
* `TurnStatus`'s and `TerminalWorking`'s `trailing` slot — a fixed-width
|
|
209
|
+
* companion beside a truncating label. Composed into the accessible name
|
|
210
|
+
* after `label` (`"Remove filter: <label> · <trailing>"`), so the byte-
|
|
211
|
+
* identical name from before this slot existed is unchanged.
|
|
212
|
+
*
|
|
213
|
+
* Trade-off: `trailing` is `shrink-0`, so an extremely narrow container now
|
|
214
|
+
* truncates `label` down to nothing before it touches `trailing`. That is
|
|
215
|
+
* the right default — the count is short and fixed, the label is the long,
|
|
216
|
+
* prose-shaped part — but it is a choice, not a free win.
|
|
217
|
+
*/
|
|
218
|
+
trailing?: string;
|
|
203
219
|
/** Remove this filter. The WHOLE chip is the remove control — see below. */
|
|
204
220
|
onRemove: () => void;
|
|
205
221
|
}
|
|
@@ -217,7 +233,7 @@ export interface FilterChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
|
|
217
233
|
* reach for `FacetFilter` (`@elabs-ai/components-data`), not a variant of this.
|
|
218
234
|
*/
|
|
219
235
|
export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(function FilterChip(
|
|
220
|
-
{ label, onRemove, className, onClick, ...props },
|
|
236
|
+
{ label, trailing, onRemove, className, onClick, ...props },
|
|
221
237
|
ref,
|
|
222
238
|
) {
|
|
223
239
|
const { t } = useLocale();
|
|
@@ -227,14 +243,16 @@ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(functio
|
|
|
227
243
|
ref={ref}
|
|
228
244
|
type="button"
|
|
229
245
|
data-slot="view-toolbar-filter-chip"
|
|
230
|
-
aria-label={t("ui.viewToolbar.removeFilter", {
|
|
246
|
+
aria-label={t("ui.viewToolbar.removeFilter", {
|
|
247
|
+
label: trailing ? `${label} · ${trailing}` : label,
|
|
248
|
+
})}
|
|
231
249
|
className={cn(
|
|
232
250
|
badgeVariants({ variant: "secondary" }),
|
|
233
251
|
"group min-h-6 max-w-full gap-1 ps-2.5 pe-1.5",
|
|
234
252
|
"transition-colors duration-fast ease-standard hover:bg-secondary/70",
|
|
235
253
|
// Badge's own `focus:` ring is dead weight on a <span> but would fire on
|
|
236
254
|
// every MOUSE click here; neutralise it and keep the focus-visible ring.
|
|
237
|
-
"focus:ring-0 focus-
|
|
255
|
+
"focus:ring-0 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
238
256
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
239
257
|
className,
|
|
240
258
|
)}
|
|
@@ -244,7 +262,22 @@ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(functio
|
|
|
244
262
|
}}
|
|
245
263
|
{...props}
|
|
246
264
|
>
|
|
247
|
-
<span className="truncate">
|
|
265
|
+
<span data-slot="view-toolbar-filter-chip-label" className="min-w-0 truncate">
|
|
266
|
+
{label}
|
|
267
|
+
</span>
|
|
268
|
+
{trailing ? (
|
|
269
|
+
<>
|
|
270
|
+
<span aria-hidden="true" className="shrink-0 text-muted-foreground">
|
|
271
|
+
·
|
|
272
|
+
</span>
|
|
273
|
+
<span
|
|
274
|
+
data-slot="view-toolbar-filter-chip-trailing"
|
|
275
|
+
className="shrink-0 whitespace-nowrap tabular-nums"
|
|
276
|
+
>
|
|
277
|
+
{trailing}
|
|
278
|
+
</span>
|
|
279
|
+
</>
|
|
280
|
+
) : null}
|
|
248
281
|
<X
|
|
249
282
|
aria-hidden="true"
|
|
250
283
|
className="size-3 shrink-0 opacity-60 transition-opacity duration-fast ease-standard group-hover:opacity-100"
|
|
@@ -58,6 +58,15 @@ const meta = {
|
|
|
58
58
|
},
|
|
59
59
|
},
|
|
60
60
|
},
|
|
61
|
+
// The trigger fills its container by default (no hard-fixed width) — cap
|
|
62
|
+
// the docs canvas to a realistic form-field width.
|
|
63
|
+
decorators: [
|
|
64
|
+
(Story) => (
|
|
65
|
+
<div className="w-72">
|
|
66
|
+
<Story />
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
],
|
|
61
70
|
argTypes: {
|
|
62
71
|
options: {
|
|
63
72
|
description:
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* - Search filtering while open
|
|
11
11
|
* - Keyboard navigation (ArrowDown/Up/Enter/Escape) driving aria-activedescendant
|
|
12
12
|
* - Virtualizer scroll behavior with 10,000 options
|
|
13
|
-
* -
|
|
13
|
+
* - Cross-theme visual correctness
|
|
14
14
|
*/
|
|
15
|
-
import { describe, expect, it } from "vitest";
|
|
16
|
-
import { render, screen } from "@testing-library/react";
|
|
15
|
+
import { describe, expect, it, vi } from "vitest";
|
|
16
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
17
17
|
import { VirtualSelect } from "./virtual-select";
|
|
18
18
|
import type { VirtualSelectOption } from "./virtual-select";
|
|
19
19
|
|
|
@@ -104,3 +104,29 @@ describe("VirtualSelect defaultValue (uncontrolled)", () => {
|
|
|
104
104
|
expect(screen.getByText("Gamma")).toBeInTheDocument();
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Deferred autofocus timer — cancelled on unmount (#reviewed)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe("VirtualSelect autofocus timer cleanup", () => {
|
|
113
|
+
it("cancels the pending search-input autofocus timeout when unmounted before it fires", () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
try {
|
|
116
|
+
const { unmount } = render(<VirtualSelect options={OPTIONS} />);
|
|
117
|
+
const trigger = screen.getByRole("combobox");
|
|
118
|
+
// Opening schedules a deferred `searchRef.current?.focus()`.
|
|
119
|
+
fireEvent.click(trigger);
|
|
120
|
+
const pendingBeforeUnmount = vi.getTimerCount();
|
|
121
|
+
expect(pendingBeforeUnmount).toBeGreaterThan(0);
|
|
122
|
+
|
|
123
|
+
unmount();
|
|
124
|
+
|
|
125
|
+
// The pending timer must be cancelled on unmount — left uncancelled, it
|
|
126
|
+
// stays scheduled and fires later against a torn-down `searchRef`.
|
|
127
|
+
expect(vi.getTimerCount()).toBeLessThan(pendingBeforeUnmount);
|
|
128
|
+
} finally {
|
|
129
|
+
vi.useRealTimers();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|