@elabs-ai/components-ui 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/chunk-FCH4ZN6G.js +57 -0
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2597 -188
- package/dist/index.js +12855 -3578
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +21 -7
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +15 -4
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +56 -4
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +3 -1
- package/src/components/attribution-panel/attributions.generated.ts +83 -79
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +15 -5
- package/src/components/bento-grid/bento-grid.test.tsx +24 -3
- package/src/components/bento-grid/bento-grid.tsx +11 -1
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +10 -4
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +48 -7
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +214 -24
- package/src/components/checkbox/checkbox.tsx +2 -1
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +22 -15
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +88 -0
- package/src/components/command/command.tsx +38 -13
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +13 -2
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +14 -6
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +342 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +50 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +90 -12
- package/src/components/file-upload/file-upload.tsx +164 -31
- package/src/components/form/form.tsx +8 -3
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +3 -3
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +8 -4
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +18 -9
- package/src/components/input-otp/input-otp.tsx +8 -2
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/label/label.tsx +1 -1
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +822 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +66 -46
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +8 -4
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +67 -9
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +204 -15
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +109 -0
- package/src/components/pagination/pagination.tsx +27 -19
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +3 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +32 -6
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +53 -5
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +1021 -0
- package/src/components/schema-form/schema-form.tsx +1381 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +14 -3
- package/src/components/select/select.tsx +13 -7
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +299 -0
- package/src/components/side-dock/side-dock.tsx +515 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +71 -2
- package/src/components/sidebar/sidebar.tsx +264 -38
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +110 -24
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +3 -1
- package/src/components/table/table.stories.tsx +64 -0
- package/src/components/table/table.test.tsx +149 -1
- package/src/components/table/table.tsx +71 -2
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +176 -72
- package/src/components/tag-input/tag-input.tsx +6 -3
- package/src/components/team-switcher/team-switcher.tsx +35 -6
- package/src/components/textarea/textarea.tsx +2 -2
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +140 -12
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +10 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +91 -31
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +29 -3
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +13 -5
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +80 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +94 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +34 -12
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
cloneElement,
|
|
3
5
|
forwardRef,
|
|
@@ -8,16 +10,43 @@ import {
|
|
|
8
10
|
} from "react";
|
|
9
11
|
import { Slot } from "@radix-ui/react-slot";
|
|
10
12
|
import { cn } from "../../lib/cn";
|
|
13
|
+
import { hasRenderableContent } from "../../lib/has-renderable-content";
|
|
11
14
|
import { Label } from "../label";
|
|
12
15
|
|
|
13
16
|
export interface FieldRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
14
17
|
/** Field label — rendered as a real `<label>`, click-to-focus associated with the control. */
|
|
15
18
|
label: ReactNode;
|
|
16
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Optional help/description text, always visible (not gated by an error).
|
|
21
|
+
* Renders nothing — and contributes no id to `aria-describedby` — when
|
|
22
|
+
* `description` is falsy (`false`/`0`/`""`/`null`/`undefined`), an empty
|
|
23
|
+
* array, or an array containing only falsy values (`{list.map(...)}` on an
|
|
24
|
+
* empty list; `[a && "x", b && "y"]` with both false).
|
|
25
|
+
*
|
|
26
|
+
* **Known limit:** a value that is itself a COMPONENT that renders nothing
|
|
27
|
+
* (returns `null`/an empty fragment) is not knowable before render, so it
|
|
28
|
+
* still produces an empty paragraph that `aria-describedby` points at.
|
|
29
|
+
* `FieldDescription` and the wider React ecosystem share this limit — pass
|
|
30
|
+
* a falsy value instead of a component that may render nothing, e.g.
|
|
31
|
+
* `description={hasHint ? hintText : undefined}`, never
|
|
32
|
+
* `description={<MaybeRendersNull />}`.
|
|
33
|
+
*/
|
|
17
34
|
description?: ReactNode;
|
|
18
35
|
/**
|
|
19
36
|
* Validation error text. When present: sets `aria-invalid` on the control,
|
|
20
37
|
* is appended to `aria-describedby`, and is announced via `role="alert"`.
|
|
38
|
+
* Renders nothing — and leaves `aria-invalid="false"` — when `error` is
|
|
39
|
+
* falsy (`false`/`0`/`""`/`null`/`undefined`), an empty array, or an array
|
|
40
|
+
* containing only falsy values (`{errors.map(...)}` on an empty list — the
|
|
41
|
+
* idiomatic "no errors" shape).
|
|
42
|
+
*
|
|
43
|
+
* **Known limit:** a value that is itself a COMPONENT that renders nothing
|
|
44
|
+
* (returns `null`/an empty fragment) is not knowable before render, so it
|
|
45
|
+
* still produces an empty `role="alert"` element that `aria-describedby`
|
|
46
|
+
* points at. `FieldError` and the wider React ecosystem share this limit —
|
|
47
|
+
* pass a falsy value instead of a component that may render nothing, e.g.
|
|
48
|
+
* `error={hasError ? message : undefined}`, never
|
|
49
|
+
* `error={<MaybeRendersNull />}`.
|
|
21
50
|
*/
|
|
22
51
|
error?: ReactNode;
|
|
23
52
|
/**
|
|
@@ -62,11 +91,19 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
|
|
|
62
91
|
const childProps = control.props;
|
|
63
92
|
const controlId = childProps.id ?? `${id}-control`;
|
|
64
93
|
|
|
94
|
+
// Same predicate `FieldDescription`/`FieldError` use (`../field/field.tsx`)
|
|
95
|
+
// — bare truthiness (`description ? … : null`) treats an empty/all-falsy
|
|
96
|
+
// ARRAY as content, since every array is truthy in JS. `errors.map(...)`
|
|
97
|
+
// on an empty list is the idiomatic "no errors" shape, so that gap is a
|
|
98
|
+
// real defect here, not a hypothetical (#93).
|
|
99
|
+
const hasDescription = hasRenderableContent(description);
|
|
100
|
+
const hasError = hasRenderableContent(error);
|
|
101
|
+
|
|
65
102
|
const describedBy =
|
|
66
103
|
[
|
|
67
104
|
childProps["aria-describedby"] ?? null,
|
|
68
|
-
|
|
69
|
-
|
|
105
|
+
hasDescription ? descriptionId : null,
|
|
106
|
+
hasError ? errorId : null,
|
|
70
107
|
]
|
|
71
108
|
.filter(Boolean)
|
|
72
109
|
.join(" ") || undefined;
|
|
@@ -86,12 +123,19 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
|
|
|
86
123
|
<Slot
|
|
87
124
|
id={controlId}
|
|
88
125
|
aria-describedby={describedBy}
|
|
89
|
-
|
|
126
|
+
// `FieldRoot.invalid` (`../field/field.tsx`) is intentionally
|
|
127
|
+
// INDEPENDENT of whether a `FieldError` with content is mounted — a
|
|
128
|
+
// caller can mark the control invalid before its message settles.
|
|
129
|
+
// `FieldRow` has no such prop: `error` is its only invalidity
|
|
130
|
+
// signal, so "no error content" must mean "not invalid" — deriving
|
|
131
|
+
// this from raw `!!error` marked `error={[]}` invalid with nothing
|
|
132
|
+
// to explain why (#93).
|
|
133
|
+
aria-invalid={hasError}
|
|
90
134
|
data-slot="field-row-control"
|
|
91
135
|
>
|
|
92
136
|
{cloneElement(control, { id: controlId, "aria-describedby": describedBy })}
|
|
93
137
|
</Slot>
|
|
94
|
-
{
|
|
138
|
+
{hasDescription ? (
|
|
95
139
|
<p
|
|
96
140
|
id={descriptionId}
|
|
97
141
|
data-slot="field-row-description"
|
|
@@ -100,7 +144,7 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
|
|
|
100
144
|
{description}
|
|
101
145
|
</p>
|
|
102
146
|
) : null}
|
|
103
|
-
{
|
|
147
|
+
{hasError ? (
|
|
104
148
|
<p
|
|
105
149
|
id={errorId}
|
|
106
150
|
role="alert"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
4
|
import {
|
|
4
5
|
FileUpload,
|
|
5
6
|
FileUploadDropzone,
|
|
@@ -106,6 +107,21 @@ export const Default: Story = {
|
|
|
106
107
|
</FileUpload>
|
|
107
108
|
</div>
|
|
108
109
|
),
|
|
110
|
+
play: async ({ canvasElement }) => {
|
|
111
|
+
const canvas = within(canvasElement);
|
|
112
|
+
// #322 — the dropzone (`focus-ring-within`) is the sole compound focus
|
|
113
|
+
// indicator for this composite control; the inner "Browse files" button
|
|
114
|
+
// must delegate its own indicator to it, never paint a second one.
|
|
115
|
+
const browseButton = canvas.getByRole("button", { name: "Browse files" });
|
|
116
|
+
await userEvent.tab();
|
|
117
|
+
await expect(browseButton).toHaveFocus();
|
|
118
|
+
const dropzone = browseButton.closest('[data-slot="file-upload-dropzone"]');
|
|
119
|
+
await expect(dropzone).not.toBeNull();
|
|
120
|
+
const dropzoneStyle = getComputedStyle(dropzone as HTMLElement);
|
|
121
|
+
const buttonStyle = getComputedStyle(browseButton);
|
|
122
|
+
await expect(dropzoneStyle.outlineStyle).toBe("solid");
|
|
123
|
+
await expect(buttonStyle.outlineStyle).not.toBe("solid");
|
|
124
|
+
},
|
|
109
125
|
};
|
|
110
126
|
|
|
111
127
|
export const Uncontrolled: Story = {
|
|
@@ -15,21 +15,28 @@ function makeUploadFile(name = "test.txt", size = 1024): UploadFile {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
describe("FileUpload", () => {
|
|
18
|
-
it("
|
|
18
|
+
it("default dropzone exposes exactly one control: the Browse files button", () => {
|
|
19
19
|
render(
|
|
20
20
|
<FileUpload>
|
|
21
21
|
<FileUploadDropzone />
|
|
22
22
|
</FileUpload>,
|
|
23
23
|
);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
expect(
|
|
29
|
-
|
|
30
|
-
expect(
|
|
31
|
-
expect(
|
|
32
|
-
|
|
24
|
+
const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
|
|
25
|
+
// The zone is a plain drop surface — no role, no tab stop — so the inner
|
|
26
|
+
// button is never nested inside another interactive element (axe
|
|
27
|
+
// `nested-interactive` / `aria-allowed-role`).
|
|
28
|
+
expect(dropzone.tagName.toLowerCase()).toBe("div");
|
|
29
|
+
expect(dropzone).not.toHaveAttribute("role");
|
|
30
|
+
expect(dropzone).not.toHaveAttribute("tabIndex");
|
|
31
|
+
expect(screen.getAllByRole("button")).toHaveLength(1);
|
|
32
|
+
|
|
33
|
+
const input = document.querySelector("input[type='file']") as HTMLInputElement;
|
|
34
|
+
const clickSpy = vi.spyOn(input, "click");
|
|
35
|
+
fireEvent.click(screen.getByRole("button", { name: "Browse files" }));
|
|
36
|
+
// One activation opens the picker once (the zone's click must not re-fire it).
|
|
37
|
+
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
38
|
+
fireEvent.click(dropzone);
|
|
39
|
+
expect(clickSpy).toHaveBeenCalledTimes(2);
|
|
33
40
|
});
|
|
34
41
|
|
|
35
42
|
it("hidden file input has sr-only class (visually hidden but real)", () => {
|
|
@@ -88,7 +95,11 @@ describe("FileUpload", () => {
|
|
|
88
95
|
expect(onFilesChange.mock.calls[0]![0]).toHaveLength(0);
|
|
89
96
|
});
|
|
90
97
|
|
|
91
|
-
it("FileUploadList
|
|
98
|
+
it("FileUploadList announces via aria-live while keeping its native list role", () => {
|
|
99
|
+
// NOT role="status" — that would override the <ul>'s implicit `list`
|
|
100
|
+
// role and strip `listitem` from every <FileUploadItem> inside it
|
|
101
|
+
// (an axe `listitem` violation). `aria-live="polite"` alone still
|
|
102
|
+
// announces additions/removals to assistive tech.
|
|
92
103
|
const f = makeUploadFile();
|
|
93
104
|
render(
|
|
94
105
|
<FileUpload files={[f]} onFilesChange={() => {}}>
|
|
@@ -97,8 +108,9 @@ describe("FileUpload", () => {
|
|
|
97
108
|
</FileUploadList>
|
|
98
109
|
</FileUpload>,
|
|
99
110
|
);
|
|
100
|
-
const list = screen.getByRole("
|
|
111
|
+
const list = screen.getByRole("list");
|
|
101
112
|
expect(list).toHaveAttribute("aria-live", "polite");
|
|
113
|
+
expect(list).not.toHaveAttribute("role");
|
|
102
114
|
});
|
|
103
115
|
|
|
104
116
|
it("FileUploadList returns null when no files and no children", () => {
|
|
@@ -151,6 +163,72 @@ describe("FileUpload", () => {
|
|
|
151
163
|
expect(onFilesChange).toHaveBeenCalledWith([]);
|
|
152
164
|
});
|
|
153
165
|
|
|
166
|
+
it("drag-and-drop rejects files that don't match `accept` and reports why", () => {
|
|
167
|
+
const onFilesChange = vi.fn();
|
|
168
|
+
const onFilesRejected = vi.fn();
|
|
169
|
+
render(
|
|
170
|
+
<FileUpload
|
|
171
|
+
files={[]}
|
|
172
|
+
onFilesChange={onFilesChange}
|
|
173
|
+
onFilesRejected={onFilesRejected}
|
|
174
|
+
accept=".png"
|
|
175
|
+
>
|
|
176
|
+
<FileUploadDropzone />
|
|
177
|
+
</FileUpload>,
|
|
178
|
+
);
|
|
179
|
+
const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
|
|
180
|
+
const wrongType = new File(["x"], "notes.txt", { type: "text/plain" });
|
|
181
|
+
fireEvent.drop(dropzone, { dataTransfer: { files: [wrongType] } });
|
|
182
|
+
|
|
183
|
+
// A drop bypasses the native <input accept> filter entirely — without
|
|
184
|
+
// this, a dragged-in .txt would be added even though the picker would
|
|
185
|
+
// never have offered it.
|
|
186
|
+
expect(onFilesChange).toHaveBeenCalledWith([]);
|
|
187
|
+
expect(onFilesRejected).toHaveBeenCalledWith([{ file: wrongType, reason: "accept" }]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("drag-and-drop of several files when multiple=false keeps only the first, rejects the rest", () => {
|
|
191
|
+
const onFilesChange = vi.fn();
|
|
192
|
+
const onFilesRejected = vi.fn();
|
|
193
|
+
render(
|
|
194
|
+
<FileUpload files={[]} onFilesChange={onFilesChange} onFilesRejected={onFilesRejected}>
|
|
195
|
+
<FileUploadDropzone />
|
|
196
|
+
</FileUpload>,
|
|
197
|
+
);
|
|
198
|
+
const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
|
|
199
|
+
const first = new File(["a"], "a.txt", { type: "text/plain" });
|
|
200
|
+
const second = new File(["b"], "b.txt", { type: "text/plain" });
|
|
201
|
+
fireEvent.drop(dropzone, { dataTransfer: { files: [first, second] } });
|
|
202
|
+
|
|
203
|
+
// `multiple` is only enforced by the browser for the picker dialog, never
|
|
204
|
+
// for a raw DataTransfer drop.
|
|
205
|
+
expect(onFilesChange.mock.calls[0]![0]).toHaveLength(1);
|
|
206
|
+
expect(onFilesChange.mock.calls[0]![0][0].file.name).toBe("a.txt");
|
|
207
|
+
expect(onFilesRejected).toHaveBeenCalledWith([{ file: second, reason: "multiple" }]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("a custom-children dropzone still opens the picker on Enter/Space (keyboard reachable)", () => {
|
|
211
|
+
render(
|
|
212
|
+
<FileUpload>
|
|
213
|
+
<FileUploadDropzone>Drop it here</FileUploadDropzone>
|
|
214
|
+
</FileUpload>,
|
|
215
|
+
);
|
|
216
|
+
// With custom `children` there is no fallback "Browse files" button —
|
|
217
|
+
// the zone itself must be the one control: a tab stop answering Enter/Space.
|
|
218
|
+
const dropzone = screen.getByRole("button");
|
|
219
|
+
expect(dropzone).toHaveTextContent("Drop it here");
|
|
220
|
+
expect(dropzone).toHaveAttribute("data-slot", "file-upload-dropzone");
|
|
221
|
+
expect(dropzone).toHaveAttribute("tabIndex", "0");
|
|
222
|
+
|
|
223
|
+
const input = document.querySelector("input[type='file']") as HTMLInputElement;
|
|
224
|
+
const clickSpy = vi.spyOn(input, "click");
|
|
225
|
+
fireEvent.keyDown(dropzone, { key: "Enter" });
|
|
226
|
+
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
227
|
+
|
|
228
|
+
fireEvent.keyDown(dropzone, { key: " " });
|
|
229
|
+
expect(clickSpy).toHaveBeenCalledTimes(2);
|
|
230
|
+
});
|
|
231
|
+
|
|
154
232
|
it("disabled prop disables the file input and remove buttons", () => {
|
|
155
233
|
const f = makeUploadFile();
|
|
156
234
|
render(
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { X, Upload, FileIcon, CheckCircle2, AlertCircle } from "lucide-react";
|
|
19
19
|
import { cn } from "../../lib/cn";
|
|
20
20
|
import { Button } from "../button";
|
|
21
|
+
import { useLocale } from "../locale-provider";
|
|
21
22
|
import { Progress } from "../progress";
|
|
22
23
|
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
@@ -39,6 +40,42 @@ export interface UploadFile {
|
|
|
39
40
|
errorMessage?: string;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Why `addFiles` (picker OR drag-and-drop) declined a file. */
|
|
44
|
+
export type FileRejectionReason = "accept" | "maxSize" | "maxFiles" | "multiple";
|
|
45
|
+
|
|
46
|
+
export interface FileRejection {
|
|
47
|
+
file: File;
|
|
48
|
+
reason: FileRejectionReason;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Match a file against an `<input accept>`-style pattern list (comma-separated
|
|
53
|
+
* extensions like `.png`, exact MIME types like `image/png`, or wildcards like
|
|
54
|
+
* `image/*`). Drag-and-drop delivers files straight from `DataTransfer` — the
|
|
55
|
+
* browser only enforces `accept` for the native picker dialog, never for a
|
|
56
|
+
* drop — so this has to be re-applied by hand for the drop path to actually
|
|
57
|
+
* "respect `accept`".
|
|
58
|
+
*/
|
|
59
|
+
function fileMatchesAccept(file: File, accept: string | undefined): boolean {
|
|
60
|
+
if (!accept) return true;
|
|
61
|
+
const patterns = accept
|
|
62
|
+
.split(",")
|
|
63
|
+
.map((p) => p.trim().toLowerCase())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
if (patterns.length === 0) return true;
|
|
66
|
+
const name = file.name.toLowerCase();
|
|
67
|
+
const type = file.type.toLowerCase();
|
|
68
|
+
return patterns.some((pattern) => {
|
|
69
|
+
if (pattern.startsWith(".")) return name.endsWith(pattern);
|
|
70
|
+
if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
|
|
71
|
+
return type === pattern;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeUploadFileId(file: File): string {
|
|
76
|
+
return `${file.name}-${Date.now()}-${Math.random()}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
42
79
|
// ---------------------------------------------------------------------------
|
|
43
80
|
// Context
|
|
44
81
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +95,22 @@ interface FileUploadContextValue {
|
|
|
58
95
|
inputId: string;
|
|
59
96
|
}
|
|
60
97
|
|
|
98
|
+
/** Read `accept`/`multiple`/`maxSize`/`maxFiles` off a rejection for a default message. */
|
|
99
|
+
export function describeFileRejection(rejection: FileRejection): string {
|
|
100
|
+
switch (rejection.reason) {
|
|
101
|
+
case "accept":
|
|
102
|
+
return `${rejection.file.name} is not an accepted file type.`;
|
|
103
|
+
case "maxSize":
|
|
104
|
+
return `${rejection.file.name} is too large.`;
|
|
105
|
+
case "maxFiles":
|
|
106
|
+
return `${rejection.file.name} was not added — the file limit was reached.`;
|
|
107
|
+
case "multiple":
|
|
108
|
+
return `${rejection.file.name} was not added — only one file is allowed.`;
|
|
109
|
+
default:
|
|
110
|
+
return `${rejection.file.name} was not added.`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
61
114
|
const FileUploadContext = createContext<FileUploadContextValue | null>(null);
|
|
62
115
|
|
|
63
116
|
function useFileUpload(): FileUploadContextValue {
|
|
@@ -75,6 +128,12 @@ export interface FileUploadProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
75
128
|
files?: UploadFile[];
|
|
76
129
|
/** Called when files are added or removed. */
|
|
77
130
|
onFilesChange?: (files: UploadFile[]) => void;
|
|
131
|
+
/**
|
|
132
|
+
* Called with the files a picker selection OR a drag-and-drop declined —
|
|
133
|
+
* wrong type (`accept`), over `maxSize`, over `maxFiles`, or extra files
|
|
134
|
+
* dropped when `multiple` is false — each with its {@link FileRejectionReason}.
|
|
135
|
+
*/
|
|
136
|
+
onFilesRejected?: (rejections: FileRejection[]) => void;
|
|
78
137
|
/** Accepted MIME types / extensions (forwarded to <input accept>). */
|
|
79
138
|
accept?: string;
|
|
80
139
|
/** Allow multiple files at once. */
|
|
@@ -102,6 +161,7 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
|
|
|
102
161
|
{
|
|
103
162
|
files: filesProp,
|
|
104
163
|
onFilesChange,
|
|
164
|
+
onFilesRejected,
|
|
105
165
|
accept,
|
|
106
166
|
multiple = false,
|
|
107
167
|
maxSize,
|
|
@@ -134,19 +194,45 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
|
|
|
134
194
|
|
|
135
195
|
const addFiles = useCallback(
|
|
136
196
|
(incoming: File[]) => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
197
|
+
const rejections: FileRejection[] = [];
|
|
198
|
+
|
|
199
|
+
// A drop can deliver more files than the native picker ever would —
|
|
200
|
+
// `multiple` is only enforced by the browser for the `<input>` dialog,
|
|
201
|
+
// never for `DataTransfer`, so re-apply it here for the drop path too.
|
|
202
|
+
let candidates = incoming;
|
|
203
|
+
if (!multiple && candidates.length > 1) {
|
|
204
|
+
rejections.push(
|
|
205
|
+
...candidates.slice(1).map((file) => ({ file, reason: "multiple" as const })),
|
|
206
|
+
);
|
|
207
|
+
candidates = candidates.slice(0, 1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const toAdd: File[] = [];
|
|
211
|
+
for (const f of candidates) {
|
|
212
|
+
if (!fileMatchesAccept(f, accept)) {
|
|
213
|
+
rejections.push({ file: f, reason: "accept" });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (maxSize && f.size > maxSize) {
|
|
217
|
+
rejections.push({ file: f, reason: "maxSize" });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
toAdd.push(f);
|
|
221
|
+
}
|
|
141
222
|
|
|
142
223
|
const next = [...files];
|
|
143
224
|
for (const f of toAdd) {
|
|
144
|
-
if (maxFiles && next.length >= maxFiles)
|
|
145
|
-
|
|
225
|
+
if (maxFiles && next.length >= maxFiles) {
|
|
226
|
+
rejections.push({ file: f, reason: "maxFiles" });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
next.push({ id: makeUploadFileId(f), file: f });
|
|
146
230
|
}
|
|
231
|
+
|
|
147
232
|
commit(next);
|
|
233
|
+
if (rejections.length > 0) onFilesRejected?.(rejections);
|
|
148
234
|
},
|
|
149
|
-
[files, maxSize, maxFiles, commit],
|
|
235
|
+
[files, accept, multiple, maxSize, maxFiles, commit, onFilesRejected],
|
|
150
236
|
);
|
|
151
237
|
|
|
152
238
|
const removeFile = useCallback(
|
|
@@ -256,30 +342,61 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
|
|
|
256
342
|
// FileUploadDropzone
|
|
257
343
|
// ---------------------------------------------------------------------------
|
|
258
344
|
|
|
259
|
-
export interface FileUploadDropzoneProps extends HTMLAttributes<
|
|
260
|
-
/** Label for the
|
|
345
|
+
export interface FileUploadDropzoneProps extends HTMLAttributes<HTMLDivElement> {
|
|
346
|
+
/** Label for the default "Browse files" button. */
|
|
261
347
|
browseLabel?: string;
|
|
262
348
|
}
|
|
263
349
|
|
|
264
350
|
/**
|
|
265
351
|
* FileUploadDropzone — the drag-and-drop target.
|
|
266
352
|
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
353
|
+
* A plain `<div>` drop surface with exactly ONE keyboard-reachable control:
|
|
354
|
+
*
|
|
355
|
+
* - Default content: a real `<button>` ("Browse files") is the tab stop; the
|
|
356
|
+
* zone itself carries no role and no tab stop, so nothing interactive is
|
|
357
|
+
* nested inside another interactive element.
|
|
358
|
+
* - Custom `children`: there is no inner button, so the zone itself becomes
|
|
359
|
+
* the control (`role="button"`, a tab stop, Enter/Space). Custom children
|
|
360
|
+
* must then be non-interactive content.
|
|
361
|
+
*
|
|
362
|
+
* A pointer click anywhere on the zone also opens the picker — a mouse
|
|
363
|
+
* convenience on top of the keyboard path above, never the only path.
|
|
270
364
|
*/
|
|
271
|
-
export const FileUploadDropzone = forwardRef<
|
|
365
|
+
export const FileUploadDropzone = forwardRef<HTMLDivElement, FileUploadDropzoneProps>(
|
|
272
366
|
function FileUploadDropzone(
|
|
273
|
-
{ className, children, browseLabel
|
|
367
|
+
{ className, children, browseLabel, onClick, onKeyDown, ...props },
|
|
274
368
|
ref,
|
|
275
369
|
) {
|
|
276
|
-
const { isDragging, disabled,
|
|
370
|
+
const { isDragging, disabled, openPicker } = useFileUpload();
|
|
371
|
+
const { t } = useLocale();
|
|
372
|
+
const resolvedBrowseLabel = browseLabel ?? t("ui.fileUpload.browseFiles");
|
|
373
|
+
// With custom children there is no inner button, so the zone owns the
|
|
374
|
+
// one keyboard path itself.
|
|
375
|
+
const isSelfControl = children != null;
|
|
277
376
|
|
|
278
377
|
return (
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
378
|
+
<div
|
|
379
|
+
ref={ref}
|
|
380
|
+
role={isSelfControl ? "button" : undefined}
|
|
381
|
+
tabIndex={isSelfControl && !disabled ? 0 : undefined}
|
|
382
|
+
// Also on the default (role-less) zone: it marks the dimmed copy as
|
|
383
|
+
// inactive UI, which WCAG 1.4.3 exempts from contrast — the same
|
|
384
|
+
// exemption a `<label>` of a disabled control gets.
|
|
385
|
+
aria-disabled={disabled || undefined}
|
|
386
|
+
onClick={(e) => {
|
|
387
|
+
onClick?.(e);
|
|
388
|
+
if (disabled || e.defaultPrevented) return;
|
|
389
|
+
openPicker();
|
|
390
|
+
}}
|
|
391
|
+
onKeyDown={(e) => {
|
|
392
|
+
onKeyDown?.(e);
|
|
393
|
+
if (!isSelfControl || disabled || e.defaultPrevented) return;
|
|
394
|
+
if (e.target !== e.currentTarget) return;
|
|
395
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
396
|
+
e.preventDefault();
|
|
397
|
+
openPicker();
|
|
398
|
+
}
|
|
399
|
+
}}
|
|
283
400
|
data-slot="file-upload-dropzone"
|
|
284
401
|
data-dragging={isDragging || undefined}
|
|
285
402
|
data-disabled={disabled || undefined}
|
|
@@ -289,7 +406,7 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
|
|
|
289
406
|
"hover:border-ring hover:bg-accent/50",
|
|
290
407
|
"data-[dragging]:border-primary data-[dragging]:bg-primary/5",
|
|
291
408
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
292
|
-
"focus-
|
|
409
|
+
"focus-ring-within focus-within:ring-offset-2 focus-within:ring-offset-background",
|
|
293
410
|
className,
|
|
294
411
|
)}
|
|
295
412
|
{...props}
|
|
@@ -300,29 +417,37 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
|
|
|
300
417
|
<Upload className="size-5 text-muted-foreground" aria-hidden="true" />
|
|
301
418
|
</div>
|
|
302
419
|
<div className="flex flex-col items-center gap-1 text-center">
|
|
303
|
-
<p className="text-
|
|
304
|
-
|
|
420
|
+
<p className="text-body font-medium text-foreground">
|
|
421
|
+
{t("ui.fileUpload.dragDropHere")}
|
|
422
|
+
</p>
|
|
423
|
+
<p className="text-meta text-muted-foreground">
|
|
305
424
|
or{" "}
|
|
306
425
|
<button
|
|
307
426
|
type="button"
|
|
308
427
|
disabled={disabled}
|
|
309
428
|
onClick={(e) => {
|
|
310
|
-
|
|
429
|
+
// The zone's own click handler opens the picker; stop the
|
|
430
|
+
// bubble so one activation opens it exactly once.
|
|
431
|
+
e.stopPropagation();
|
|
311
432
|
openPicker();
|
|
312
433
|
}}
|
|
313
434
|
className={cn(
|
|
314
435
|
// #399 — inline text trigger inside a sentence → `-text` rung.
|
|
315
|
-
"text-primary-text underline-offset-2 hover:underline focus-
|
|
436
|
+
"text-primary-text underline-offset-2 hover:underline focus-ring rounded-sm",
|
|
437
|
+
// #322 — the dropzone's `focus-ring-within` (above) is the
|
|
438
|
+
// one compound indicator for this composite control; this
|
|
439
|
+
// button delegates rather than painting a second one.
|
|
440
|
+
"focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
316
441
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
317
442
|
)}
|
|
318
443
|
>
|
|
319
|
-
{
|
|
444
|
+
{resolvedBrowseLabel}
|
|
320
445
|
</button>
|
|
321
446
|
</p>
|
|
322
447
|
</div>
|
|
323
448
|
</>
|
|
324
449
|
)}
|
|
325
|
-
</
|
|
450
|
+
</div>
|
|
326
451
|
);
|
|
327
452
|
},
|
|
328
453
|
);
|
|
@@ -340,14 +465,19 @@ export type FileUploadListProps = HTMLAttributes<HTMLUListElement>;
|
|
|
340
465
|
export const FileUploadList = forwardRef<HTMLUListElement, FileUploadListProps>(
|
|
341
466
|
function FileUploadList({ className, children, ...props }, ref) {
|
|
342
467
|
const { files } = useFileUpload();
|
|
468
|
+
const { t } = useLocale();
|
|
343
469
|
if (files.length === 0 && !children) return null;
|
|
344
470
|
|
|
345
471
|
return (
|
|
472
|
+
// `aria-live="polite"` alone is enough to announce additions/removals —
|
|
473
|
+
// it does NOT require `role="status"`. Adding that role here used to
|
|
474
|
+
// override the `<ul>`'s implicit `list` role, which strips the
|
|
475
|
+
// `listitem` role from every `<FileUploadItem>` (`<li>`) inside it and
|
|
476
|
+
// fails axe's `listitem` rule the moment the list is non-empty.
|
|
346
477
|
<ul
|
|
347
478
|
ref={ref}
|
|
348
|
-
role="status"
|
|
349
479
|
aria-live="polite"
|
|
350
|
-
aria-label="
|
|
480
|
+
aria-label={t("ui.fileUpload.selectedFiles")}
|
|
351
481
|
data-slot="file-upload-list"
|
|
352
482
|
className={cn("flex flex-col gap-2", className)}
|
|
353
483
|
{...props}
|
|
@@ -412,10 +542,10 @@ export const FileUploadItem = forwardRef<HTMLLIElement, FileUploadItemProps>(
|
|
|
412
542
|
>
|
|
413
543
|
<div className="flex items-center gap-2">
|
|
414
544
|
{statusIcon[resolvedStatus]}
|
|
415
|
-
<span className="flex-1 min-w-0 truncate text-
|
|
545
|
+
<span className="flex-1 min-w-0 truncate text-body text-foreground">
|
|
416
546
|
{uploadFile.file.name}
|
|
417
547
|
</span>
|
|
418
|
-
<span className="shrink-0 text-
|
|
548
|
+
<span className="shrink-0 text-meta tabular-nums text-muted-foreground">
|
|
419
549
|
{formatFileSize(uploadFile.file.size)}
|
|
420
550
|
</span>
|
|
421
551
|
<Button
|
|
@@ -434,7 +564,10 @@ export const FileUploadItem = forwardRef<HTMLLIElement, FileUploadItemProps>(
|
|
|
434
564
|
<Progress value={resolvedProgress} className="h-1" />
|
|
435
565
|
)}
|
|
436
566
|
{isError && resolvedError && (
|
|
437
|
-
|
|
567
|
+
// #124: this is running text (the rejection reason), so it takes the
|
|
568
|
+
// >=4.5:1 ink rung `text-destructive-text` — not the 3:1 mark rung the
|
|
569
|
+
// `statusIcon.error` glyph above correctly keeps.
|
|
570
|
+
<p role="alert" className="text-meta text-destructive-text">
|
|
438
571
|
{resolvedError}
|
|
439
572
|
</p>
|
|
440
573
|
)}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
createContext,
|
|
3
5
|
forwardRef,
|
|
@@ -80,7 +82,8 @@ export const FormLabel = forwardRef<
|
|
|
80
82
|
htmlFor={formItemId}
|
|
81
83
|
className={cn(
|
|
82
84
|
"transition-colors duration-fast ease-standard",
|
|
83
|
-
error
|
|
85
|
+
// #124: the label's own error-state text, not a mark — ink rung.
|
|
86
|
+
error && "text-destructive-text",
|
|
84
87
|
className,
|
|
85
88
|
)}
|
|
86
89
|
{...props}
|
|
@@ -113,7 +116,7 @@ export const FormDescription = forwardRef<
|
|
|
113
116
|
<p
|
|
114
117
|
ref={ref}
|
|
115
118
|
id={formDescriptionId}
|
|
116
|
-
className={cn("text-
|
|
119
|
+
className={cn("text-body text-muted-foreground", className)}
|
|
117
120
|
{...props}
|
|
118
121
|
/>
|
|
119
122
|
);
|
|
@@ -129,7 +132,9 @@ export const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLP
|
|
|
129
132
|
ref={ref}
|
|
130
133
|
id={formMessageId}
|
|
131
134
|
className={cn(
|
|
132
|
-
|
|
135
|
+
// #124: the error message body is running text — ink rung, not the
|
|
136
|
+
// 3:1 mark rung `text-destructive` guarantees.
|
|
137
|
+
"text-body font-medium text-destructive-text animate-in fade-in slide-in-from-top-1 ease-entrance",
|
|
133
138
|
className,
|
|
134
139
|
)}
|
|
135
140
|
{...props}
|
|
@@ -59,7 +59,7 @@ describe("IconButton", () => {
|
|
|
59
59
|
);
|
|
60
60
|
const wrapper = document.querySelector('[data-slot="icon-button"]')!;
|
|
61
61
|
expect(wrapper).toHaveAttribute("tabindex", "0");
|
|
62
|
-
expect(wrapper.className).toContain("focus-
|
|
62
|
+
expect(wrapper.className).toContain("focus-ring");
|
|
63
63
|
await userEvent.tab();
|
|
64
64
|
expect(wrapper).toHaveFocus();
|
|
65
65
|
});
|
|
@@ -97,12 +97,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
97
97
|
data-slot="icon-button"
|
|
98
98
|
tabIndex={disabled ? 0 : undefined}
|
|
99
99
|
className={cn(
|
|
100
|
-
"inline-flex rounded-
|
|
100
|
+
"inline-flex rounded-control",
|
|
101
101
|
// While disabled this span IS the focus target (the button is out
|
|
102
102
|
// of the tab order), so it must carry the system's focus ring —
|
|
103
103
|
// without this it falls back to the UA default outline, which is
|
|
104
|
-
// a hardcoded browser blue identical in
|
|
105
|
-
"focus-
|
|
104
|
+
// a hardcoded browser blue identical in every theme.
|
|
105
|
+
"focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
106
106
|
disabled && "cursor-not-allowed",
|
|
107
107
|
)}
|
|
108
108
|
>
|