@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,22 +1,167 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useContext,
|
|
7
|
+
useId,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ComponentPropsWithoutRef,
|
|
12
|
+
type ElementRef,
|
|
13
|
+
type PointerEvent as ReactPointerEvent,
|
|
14
|
+
} from "react";
|
|
2
15
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
|
3
16
|
import { cva } from "class-variance-authority";
|
|
4
17
|
import { ChevronDown } from "lucide-react";
|
|
5
18
|
import { cn } from "../../lib/cn";
|
|
6
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Residual of #54 (issue #85, fix round 1 → round 2): a pointer move onto a
|
|
22
|
+
* trigger arms `@radix-ui/react-navigation-menu`'s root-level hover-intent
|
|
23
|
+
* open timer (`openTimerRef`, default `delayDuration` 200ms — confirmed
|
|
24
|
+
* unchanged through `@radix-ui/react-navigation-menu@1.2.22`, the current
|
|
25
|
+
* latest stable). Neither the click-open path (root `onItemSelect`) nor any
|
|
26
|
+
* dismiss path (Escape, outside-click, focus-out — all funnelled through one
|
|
27
|
+
* `ROOT_CONTENT_DISMISS` handler → `onItemDismiss`) clears that timer, so it
|
|
28
|
+
* fires later and silently reopens the panel even after an explicit
|
|
29
|
+
* dismissal.
|
|
30
|
+
*
|
|
31
|
+
* Round 1 ran Radix's own `onTriggerLeave` cancellation path (a synthetic
|
|
32
|
+
* `pointerout`) on every click. That clears the stray open timer, but
|
|
33
|
+
* `onTriggerLeave` does two things, not one — it ALSO unconditionally arms
|
|
34
|
+
* Radix's 150ms close timer (`startCloseTimer`/`closeTimerRef`), which
|
|
35
|
+
* nothing then cancels for a click-opened (or keyboard/touch-opened) menu,
|
|
36
|
+
* since it never gets a real pointerenter on its content. Every click-opened
|
|
37
|
+
* menu silently closed itself ~150ms later. No supported, finer-grained Radix
|
|
38
|
+
* API exists to clear only the open timer — the 6 context callbacks
|
|
39
|
+
* (`onTriggerEnter`/`onTriggerLeave`/`onContentEnter`/`onContentLeave`/
|
|
40
|
+
* `onItemSelect`/`onItemDismiss`) are the only reachable surface, and none of
|
|
41
|
+
* the internal refs (`openTimerRef`, `closeTimerRef`, `wasClickCloseRef`,
|
|
42
|
+
* `wasEscapeCloseRef`) are exported.
|
|
43
|
+
*
|
|
44
|
+
* Round 2 fix: stop reaching into Radix's internal timers via synthetic
|
|
45
|
+
* events entirely. `NavigationMenu` takes over `value`/`onValueChange` as a
|
|
46
|
+
* controlled proxy (transparent to a consumer's own controlled/uncontrolled
|
|
47
|
+
* usage) and rejects, ONCE, a reopen request for an item that was just
|
|
48
|
+
* dismissed (value → "") while a REAL mouse pointer was still resting on its
|
|
49
|
+
* trigger — exactly the #85 scenario (dismissed inside the hover-intent
|
|
50
|
+
* window, pointer never left). The guard is a one-shot, pointer-scoped
|
|
51
|
+
* suppression per item (`suppressReopenForRef` in `NavigationMenu`, armed by
|
|
52
|
+
* `NavigationMenuItemValueContext` + a small guard context), cleared the
|
|
53
|
+
* instant ANY of the following happens, so it can never block a genuine
|
|
54
|
+
* interaction: (a) the real pointer leaves that trigger (a fresh hover-intent
|
|
55
|
+
* cycle is legitimate — Radix's own `wasEscapeCloseRef`/`wasClickCloseRef`
|
|
56
|
+
* guards reset on the same real pointerenter), or (b) any click on that
|
|
57
|
+
* trigger (`noteExplicitActivation`, which runs synchronously before Radix's
|
|
58
|
+
* own `onItemSelect` in the same click dispatch, per
|
|
59
|
+
* `composeEventHandlers(props.onClick, () => context.onItemSelect(...))` in
|
|
60
|
+
* `@radix-ui/react-navigation-menu`'s Trigger). A pure keyboard sequence never
|
|
61
|
+
* touches this guard at all (no pointer event ever marks the trigger as
|
|
62
|
+
* "resting"), so it cannot regress keyboard or touch entry — see the 3
|
|
63
|
+
* scenarios locked in `navigation-menu.test.tsx` (pointer dismiss stays
|
|
64
|
+
* dismissed, click-open survives past the close-timer delay with no pointer
|
|
65
|
+
* movement, keyboard open/Escape/no-reopen).
|
|
66
|
+
*
|
|
67
|
+
* `NavigationMenuItem` is wrapped (was a bare re-export) purely so its
|
|
68
|
+
* resolved item value — the same value Radix's OWN auto-`useId()` fallback
|
|
69
|
+
* would otherwise compute internally and never expose — is available to
|
|
70
|
+
* `NavigationMenuTrigger` without inventing a new public prop or reaching
|
|
71
|
+
* into Radix's non-exported item context.
|
|
72
|
+
*
|
|
73
|
+
* Tracked upstream: https://github.com/radix-ui/primitives/issues (file and
|
|
74
|
+
* link the specific issue here once opened — non-blocking, parallel to this
|
|
75
|
+
* interim fix; confirmed still present in 1.2.22, the latest stable release
|
|
76
|
+
* as of this fix).
|
|
77
|
+
*/
|
|
78
|
+
type NavigationMenuGuard = {
|
|
79
|
+
notePointerPresence(itemValue: string, present: boolean): void;
|
|
80
|
+
noteExplicitActivation(itemValue: string): void;
|
|
81
|
+
/** One-shot: returns true (and clears the flag) iff this reopen should be swallowed. */
|
|
82
|
+
consumeSuppressionFlag(itemValue: string): boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const NavigationMenuGuardContext = createContext<NavigationMenuGuard | null>(null);
|
|
86
|
+
const NavigationMenuItemValueContext = createContext<string | undefined>(undefined);
|
|
87
|
+
|
|
7
88
|
export const NavigationMenu = forwardRef<
|
|
8
89
|
ElementRef<typeof NavigationMenuPrimitive.Root>,
|
|
9
90
|
ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
|
10
|
-
>(function NavigationMenu(
|
|
91
|
+
>(function NavigationMenu(
|
|
92
|
+
{ className, children, value: valueProp, defaultValue, onValueChange, ...props },
|
|
93
|
+
ref,
|
|
94
|
+
) {
|
|
95
|
+
const isControlled = valueProp !== undefined;
|
|
96
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
|
|
97
|
+
const currentValue = isControlled ? (valueProp as string) : internalValue;
|
|
98
|
+
const currentValueRef = useRef(currentValue);
|
|
99
|
+
currentValueRef.current = currentValue;
|
|
100
|
+
|
|
101
|
+
// itemValue currently under a resting, real (mouse) pointer — at most one
|
|
102
|
+
// at a time in practice, since a real pointer can only be inside one
|
|
103
|
+
// trigger's bounds.
|
|
104
|
+
const pointerPresentForRef = useRef<string | null>(null);
|
|
105
|
+
// One-shot: the itemValue whose next reopen request should be swallowed.
|
|
106
|
+
const suppressReopenForRef = useRef<string | null>(null);
|
|
107
|
+
|
|
108
|
+
const guard = useMemo<NavigationMenuGuard>(
|
|
109
|
+
() => ({
|
|
110
|
+
notePointerPresence(itemValue, present) {
|
|
111
|
+
if (present) {
|
|
112
|
+
pointerPresentForRef.current = itemValue;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (pointerPresentForRef.current === itemValue) pointerPresentForRef.current = null;
|
|
116
|
+
// A genuine leave ends the suspicious window this guard exists for —
|
|
117
|
+
// any future reopen must come from a fresh interaction (a new
|
|
118
|
+
// hover-intent cycle after re-entering, or a click), both legitimate.
|
|
119
|
+
if (suppressReopenForRef.current === itemValue) suppressReopenForRef.current = null;
|
|
120
|
+
},
|
|
121
|
+
noteExplicitActivation(itemValue) {
|
|
122
|
+
if (suppressReopenForRef.current === itemValue) suppressReopenForRef.current = null;
|
|
123
|
+
},
|
|
124
|
+
consumeSuppressionFlag(itemValue) {
|
|
125
|
+
if (suppressReopenForRef.current === itemValue) {
|
|
126
|
+
suppressReopenForRef.current = null;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
[],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const handleValueChange = (next: string) => {
|
|
136
|
+
const previous = currentValueRef.current;
|
|
137
|
+
if (next === "") {
|
|
138
|
+
if (previous !== "" && pointerPresentForRef.current === previous) {
|
|
139
|
+
// Dismissed while a real pointer still rests on its trigger, inside
|
|
140
|
+
// the hover-intent window — the #85 defect. Arm the one-shot guard
|
|
141
|
+
// against exactly this item's next reopen request.
|
|
142
|
+
suppressReopenForRef.current = previous;
|
|
143
|
+
}
|
|
144
|
+
} else if (guard.consumeSuppressionFlag(next)) {
|
|
145
|
+
return; // Swallow the stray reopen — stay on `previous` (closed).
|
|
146
|
+
}
|
|
147
|
+
if (!isControlled) setInternalValue(next);
|
|
148
|
+
onValueChange?.(next);
|
|
149
|
+
};
|
|
150
|
+
|
|
11
151
|
return (
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
152
|
+
<NavigationMenuGuardContext.Provider value={guard}>
|
|
153
|
+
<NavigationMenuPrimitive.Root
|
|
154
|
+
ref={ref}
|
|
155
|
+
data-slot="navigation-menu"
|
|
156
|
+
value={currentValue}
|
|
157
|
+
onValueChange={handleValueChange}
|
|
158
|
+
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
|
|
159
|
+
{...props}
|
|
160
|
+
>
|
|
161
|
+
{children}
|
|
162
|
+
<NavigationMenuViewport />
|
|
163
|
+
</NavigationMenuPrimitive.Root>
|
|
164
|
+
</NavigationMenuGuardContext.Provider>
|
|
20
165
|
);
|
|
21
166
|
});
|
|
22
167
|
|
|
@@ -27,26 +172,68 @@ export const NavigationMenuList = forwardRef<
|
|
|
27
172
|
return (
|
|
28
173
|
<NavigationMenuPrimitive.List
|
|
29
174
|
ref={ref}
|
|
175
|
+
data-slot="navigation-menu-list"
|
|
30
176
|
className={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
|
|
31
177
|
{...props}
|
|
32
178
|
/>
|
|
33
179
|
);
|
|
34
180
|
});
|
|
35
181
|
|
|
36
|
-
export const NavigationMenuItem =
|
|
182
|
+
export const NavigationMenuItem = forwardRef<
|
|
183
|
+
ElementRef<typeof NavigationMenuPrimitive.Item>,
|
|
184
|
+
ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Item>
|
|
185
|
+
>(function NavigationMenuItem({ value: valueProp, ...props }, ref) {
|
|
186
|
+
const autoValue = useId();
|
|
187
|
+
const value = valueProp ?? autoValue;
|
|
188
|
+
return (
|
|
189
|
+
<NavigationMenuItemValueContext.Provider value={value}>
|
|
190
|
+
<NavigationMenuPrimitive.Item
|
|
191
|
+
ref={ref}
|
|
192
|
+
data-slot="navigation-menu-item"
|
|
193
|
+
value={value}
|
|
194
|
+
{...props}
|
|
195
|
+
/>
|
|
196
|
+
</NavigationMenuItemValueContext.Provider>
|
|
197
|
+
);
|
|
198
|
+
});
|
|
37
199
|
|
|
38
200
|
export const navigationMenuTriggerStyle = cva(
|
|
39
|
-
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-
|
|
201
|
+
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-body font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent",
|
|
40
202
|
);
|
|
41
203
|
|
|
42
204
|
export const NavigationMenuTrigger = forwardRef<
|
|
43
205
|
ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
|
44
206
|
ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
|
45
|
-
>(function NavigationMenuTrigger(
|
|
207
|
+
>(function NavigationMenuTrigger(
|
|
208
|
+
{ className, children, onClick, onPointerEnter, onPointerLeave, ...props },
|
|
209
|
+
ref,
|
|
210
|
+
) {
|
|
211
|
+
const itemValue = useContext(NavigationMenuItemValueContext);
|
|
212
|
+
const guard = useContext(NavigationMenuGuardContext);
|
|
213
|
+
|
|
214
|
+
const notePointer = (event: ReactPointerEvent<HTMLButtonElement>, present: boolean) => {
|
|
215
|
+
if (itemValue !== undefined && guard && event.pointerType === "mouse") {
|
|
216
|
+
guard.notePointerPresence(itemValue, present);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
46
220
|
return (
|
|
47
221
|
<NavigationMenuPrimitive.Trigger
|
|
48
222
|
ref={ref}
|
|
223
|
+
data-slot="navigation-menu-trigger"
|
|
49
224
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
|
225
|
+
onPointerEnter={(event) => {
|
|
226
|
+
onPointerEnter?.(event);
|
|
227
|
+
notePointer(event, true);
|
|
228
|
+
}}
|
|
229
|
+
onPointerLeave={(event) => {
|
|
230
|
+
onPointerLeave?.(event);
|
|
231
|
+
notePointer(event, false);
|
|
232
|
+
}}
|
|
233
|
+
onClick={(event) => {
|
|
234
|
+
if (itemValue !== undefined) guard?.noteExplicitActivation(itemValue);
|
|
235
|
+
onClick?.(event);
|
|
236
|
+
}}
|
|
50
237
|
{...props}
|
|
51
238
|
>
|
|
52
239
|
{children}
|
|
@@ -65,8 +252,9 @@ export const NavigationMenuContent = forwardRef<
|
|
|
65
252
|
return (
|
|
66
253
|
<NavigationMenuPrimitive.Content
|
|
67
254
|
ref={ref}
|
|
255
|
+
data-slot="navigation-menu-content"
|
|
68
256
|
className={cn(
|
|
69
|
-
"
|
|
257
|
+
"start-0 top-0 w-full p-2 md:absolute md:w-auto",
|
|
70
258
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out",
|
|
71
259
|
"data-[motion=from-end]:slide-in-from-right-2 data-[motion=from-start]:slide-in-from-left-2 data-[motion=to-end]:slide-out-to-right-2 data-[motion=to-start]:slide-out-to-left-2",
|
|
72
260
|
"data-[motion^=from-]:[--tw-ease:var(--ease-entrance)]",
|
|
@@ -84,9 +272,10 @@ export const NavigationMenuViewport = forwardRef<
|
|
|
84
272
|
ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
|
85
273
|
>(function NavigationMenuViewport({ className, ...props }, ref) {
|
|
86
274
|
return (
|
|
87
|
-
<div className="absolute
|
|
275
|
+
<div className="absolute start-0 top-full flex justify-center">
|
|
88
276
|
<NavigationMenuPrimitive.Viewport
|
|
89
277
|
ref={ref}
|
|
278
|
+
data-slot="navigation-menu-viewport"
|
|
90
279
|
className={cn(
|
|
91
280
|
"relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
|
92
281
|
"origin-top transition-[width,height] duration-base ease-standard data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:zoom-in-90 data-[state=closed]:zoom-out-95",
|
|
@@ -76,13 +76,17 @@ type Story = StoryObj<typeof meta>;
|
|
|
76
76
|
|
|
77
77
|
export const Default: Story = {
|
|
78
78
|
args: { defaultValue: 5, min: 0, max: 10 },
|
|
79
|
-
// Clicks the Increase button and confirms the value increments.
|
|
79
|
+
// Clicks the Increase button and confirms the value increments. The input is a
|
|
80
|
+
// locale-formatted text spinbutton, so the displayed value is a string and the
|
|
81
|
+
// numeric value is announced via aria-valuenow.
|
|
80
82
|
play: async ({ canvas, userEvent }) => {
|
|
81
83
|
const input = canvas.getByRole("spinbutton");
|
|
82
|
-
await expect(input).toHaveValue(5);
|
|
84
|
+
await expect(input).toHaveValue("5");
|
|
85
|
+
await expect(input).toHaveAttribute("aria-valuenow", "5");
|
|
83
86
|
const increaseBtn = canvas.getByRole("button", { name: /increase/i });
|
|
84
87
|
await userEvent.click(increaseBtn);
|
|
85
|
-
await expect(input).toHaveValue(6);
|
|
88
|
+
await expect(input).toHaveValue("6");
|
|
89
|
+
await expect(input).toHaveAttribute("aria-valuenow", "6");
|
|
86
90
|
},
|
|
87
91
|
};
|
|
88
92
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
1
2
|
import { describe, expect, it, vi } from "vitest";
|
|
2
3
|
import { render, screen } from "@testing-library/react";
|
|
3
4
|
import userEvent from "@testing-library/user-event";
|
|
4
5
|
import { NumberInput } from "./number-input";
|
|
6
|
+
import { LocaleProvider } from "../locale-provider";
|
|
5
7
|
|
|
6
8
|
describe("NumberInput", () => {
|
|
7
9
|
it("renders a spinbutton input", () => {
|
|
@@ -93,4 +95,49 @@ describe("NumberInput", () => {
|
|
|
93
95
|
await userEvent.keyboard("{ArrowDown}");
|
|
94
96
|
expect(onValueChange).toHaveBeenLastCalledWith(5);
|
|
95
97
|
});
|
|
98
|
+
|
|
99
|
+
it("forwards the ref to the underlying <input>, not the wrapper", () => {
|
|
100
|
+
const ref = createRef<HTMLInputElement>();
|
|
101
|
+
render(<NumberInput aria-label="Qty" defaultValue={5} ref={ref} />);
|
|
102
|
+
expect(ref.current).toBeInstanceOf(HTMLInputElement);
|
|
103
|
+
expect(ref.current).toBe(screen.getByRole("spinbutton", { name: "Qty" }));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not hard-fix a width — the caller's className is not fighting a default", () => {
|
|
107
|
+
render(<NumberInput aria-label="Qty" defaultValue={5} className="w-24" />);
|
|
108
|
+
const group = screen.getByRole("group");
|
|
109
|
+
expect(group.className).not.toMatch(/\bw-36\b/);
|
|
110
|
+
expect(group.className).toMatch(/\bw-24\b/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("rounds fp-noisy step increments to the step's precision (0.1 + 0.1 + 0.1)", async () => {
|
|
114
|
+
const onValueChange = vi.fn();
|
|
115
|
+
render(
|
|
116
|
+
<NumberInput aria-label="Amount" defaultValue={0} step={0.1} onValueChange={onValueChange} />,
|
|
117
|
+
);
|
|
118
|
+
const increaseBtn = screen.getByRole("button", { name: "Increase" });
|
|
119
|
+
await userEvent.click(increaseBtn);
|
|
120
|
+
await userEvent.click(increaseBtn);
|
|
121
|
+
await userEvent.click(increaseBtn);
|
|
122
|
+
expect(onValueChange).toHaveBeenLastCalledWith(0.3);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("parses a locale-formatted number using the active locale's separators (de-DE: comma decimal)", async () => {
|
|
126
|
+
const onValueChange = vi.fn();
|
|
127
|
+
render(
|
|
128
|
+
<LocaleProvider locale="de-DE">
|
|
129
|
+
<NumberInput
|
|
130
|
+
aria-label="Betrag"
|
|
131
|
+
defaultValue={0}
|
|
132
|
+
step={0.1}
|
|
133
|
+
onValueChange={onValueChange}
|
|
134
|
+
/>
|
|
135
|
+
</LocaleProvider>,
|
|
136
|
+
);
|
|
137
|
+
const input = screen.getByRole("spinbutton", { name: "Betrag" });
|
|
138
|
+
await userEvent.clear(input);
|
|
139
|
+
await userEvent.type(input, "1.234,5");
|
|
140
|
+
await userEvent.tab();
|
|
141
|
+
expect(onValueChange).toHaveBeenLastCalledWith(1234.5);
|
|
142
|
+
});
|
|
96
143
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
// a2ui.exposed: yes
|
|
2
4
|
import { forwardRef, useCallback, useRef, useState, type HTMLAttributes } from "react";
|
|
3
5
|
import { Minus, Plus } from "lucide-react";
|
|
4
|
-
import { cn } from "../../lib/cn";
|
|
5
6
|
import { InputGroup, InputGroupButton, InputGroupInput } from "../input-group";
|
|
7
|
+
import { useLocale } from "../locale-provider";
|
|
6
8
|
|
|
7
9
|
export interface NumberInputProps extends Omit<
|
|
8
10
|
HTMLAttributes<HTMLDivElement>,
|
|
@@ -56,11 +58,37 @@ function clampValue(val: number, min?: number, max?: number): number {
|
|
|
56
58
|
return result;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
/** Number of decimal places implied by a step (e.g. 0.1 → 1, 1 → 0). */
|
|
62
|
+
function stepDecimals(step: number): number {
|
|
63
|
+
if (!isFinite(step) || step === 0) return 0;
|
|
64
|
+
const s = String(step);
|
|
65
|
+
const i = s.indexOf(".");
|
|
66
|
+
return i === -1 ? 0 : s.length - i - 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Round `value` to the decimal precision implied by `step`, avoiding fp noise (0.1+0.2). */
|
|
70
|
+
function roundToStep(value: number, step: number): number {
|
|
71
|
+
const decimals = stepDecimals(step);
|
|
72
|
+
if (decimals === 0) return Math.round(value);
|
|
73
|
+
const factor = 10 ** decimals;
|
|
74
|
+
return Math.round(value * factor) / factor;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The active locale's decimal and group separator characters (e.g. "," / "." in de-DE). */
|
|
78
|
+
function getLocaleSeparators(locale: string): { decimal: string; group: string } {
|
|
79
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
|
|
80
|
+
const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
|
|
81
|
+
const group = parts.find((p) => p.type === "group")?.value ?? ",";
|
|
82
|
+
return { decimal, group };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse a locale-formatted numeric string (group + decimal separators) into a number. */
|
|
86
|
+
function parseLocaleNumber(raw: string, locale: string): number {
|
|
87
|
+
const { decimal, group } = getLocaleSeparators(locale);
|
|
88
|
+
let normalized = raw.trim();
|
|
89
|
+
if (group) normalized = normalized.split(group).join("");
|
|
90
|
+
if (decimal !== ".") normalized = normalized.split(decimal).join(".");
|
|
91
|
+
return Number(normalized);
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
/**
|
|
@@ -69,7 +97,7 @@ function formatNum(value: number, formatOptions?: Intl.NumberFormatOptions): str
|
|
|
69
97
|
* Controlled via `value`/`onValueChange`; uncontrolled via `defaultValue`.
|
|
70
98
|
* Keyboard ↑/↓ = ±step; Shift+↑/↓ = ±step×10. Clamps on blur (not per keystroke).
|
|
71
99
|
*/
|
|
72
|
-
export const NumberInput = forwardRef<
|
|
100
|
+
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(function NumberInput(
|
|
73
101
|
{
|
|
74
102
|
value: valueProp,
|
|
75
103
|
defaultValue,
|
|
@@ -94,14 +122,20 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
|
|
|
94
122
|
},
|
|
95
123
|
ref,
|
|
96
124
|
) {
|
|
125
|
+
const { locale, formatNumber, t } = useLocale();
|
|
97
126
|
const isControlled = valueProp !== undefined;
|
|
98
127
|
|
|
128
|
+
const formatValue = useCallback(
|
|
129
|
+
(n: number) => formatNumber(n, formatOptions),
|
|
130
|
+
[formatNumber, formatOptions],
|
|
131
|
+
);
|
|
132
|
+
|
|
99
133
|
const [internalValue, setInternalValue] = useState<number | null>(
|
|
100
134
|
defaultValue !== undefined ? defaultValue : null,
|
|
101
135
|
);
|
|
102
136
|
const [inputText, setInputText] = useState<string>(() => {
|
|
103
137
|
const initial = isControlled ? valueProp : defaultValue;
|
|
104
|
-
return initial != null ?
|
|
138
|
+
return initial != null ? formatValue(initial) : "";
|
|
105
139
|
});
|
|
106
140
|
// Track whether the user is actively editing to avoid overwriting their input
|
|
107
141
|
const isEditing = useRef(false);
|
|
@@ -120,12 +154,12 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
|
|
|
120
154
|
(delta: number) => {
|
|
121
155
|
if (disabled || readOnly) return;
|
|
122
156
|
const base = currentValue ?? 0;
|
|
123
|
-
let next = base + delta;
|
|
157
|
+
let next = roundToStep(base + delta, step);
|
|
124
158
|
if (clamp) next = clampValue(next, min, max);
|
|
125
|
-
setInputText(
|
|
159
|
+
setInputText(formatValue(next));
|
|
126
160
|
commit(next);
|
|
127
161
|
},
|
|
128
|
-
[disabled, readOnly, currentValue, clamp, min, max,
|
|
162
|
+
[disabled, readOnly, currentValue, step, clamp, min, max, formatValue, commit],
|
|
129
163
|
);
|
|
130
164
|
|
|
131
165
|
const handleKeyDown = useCallback(
|
|
@@ -152,22 +186,23 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
|
|
|
152
186
|
commit(null);
|
|
153
187
|
return;
|
|
154
188
|
}
|
|
155
|
-
const parsed =
|
|
189
|
+
const parsed = parseLocaleNumber(raw, locale);
|
|
156
190
|
if (isNaN(parsed)) {
|
|
157
191
|
// Revert to last known good value
|
|
158
|
-
setInputText(currentValue != null ?
|
|
192
|
+
setInputText(currentValue != null ? formatValue(currentValue) : "");
|
|
159
193
|
return;
|
|
160
194
|
}
|
|
161
|
-
const
|
|
162
|
-
|
|
195
|
+
const rounded = roundToStep(parsed, step);
|
|
196
|
+
const clamped = clamp ? clampValue(rounded, min, max) : rounded;
|
|
197
|
+
setInputText(formatValue(clamped));
|
|
163
198
|
commit(clamped);
|
|
164
|
-
}, [inputText, currentValue, clamp, min, max,
|
|
199
|
+
}, [inputText, currentValue, clamp, min, max, step, locale, formatValue, commit]);
|
|
165
200
|
|
|
166
201
|
// Keep display text in sync with controlled value changes (only when not editing)
|
|
167
202
|
const prevControlledValue = useRef(valueProp);
|
|
168
203
|
if (isControlled && valueProp !== prevControlledValue.current && !isEditing.current) {
|
|
169
204
|
prevControlledValue.current = valueProp;
|
|
170
|
-
const nextText = valueProp != null ?
|
|
205
|
+
const nextText = valueProp != null ? formatValue(valueProp) : "";
|
|
171
206
|
if (nextText !== inputText) setInputText(nextText);
|
|
172
207
|
}
|
|
173
208
|
|
|
@@ -177,18 +212,24 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
|
|
|
177
212
|
disabled || readOnly || (max !== undefined && (currentValue ?? 0) >= max);
|
|
178
213
|
|
|
179
214
|
return (
|
|
180
|
-
<InputGroup
|
|
215
|
+
<InputGroup className={className} {...props}>
|
|
181
216
|
<InputGroupButton
|
|
182
|
-
aria-label="
|
|
217
|
+
aria-label={t("ui.numberInput.decrease")}
|
|
183
218
|
tabIndex={-1}
|
|
184
219
|
disabled={decrementDisabled}
|
|
185
220
|
onClick={() => applyStep(-step)}
|
|
186
|
-
className="rounded-
|
|
221
|
+
className="rounded-e-none"
|
|
187
222
|
>
|
|
188
223
|
<Minus aria-hidden="true" />
|
|
189
224
|
</InputGroupButton>
|
|
190
225
|
<InputGroupInput
|
|
191
|
-
|
|
226
|
+
ref={ref}
|
|
227
|
+
type="text"
|
|
228
|
+
inputMode="decimal"
|
|
229
|
+
role="spinbutton"
|
|
230
|
+
aria-valuenow={currentValue ?? undefined}
|
|
231
|
+
aria-valuemin={min}
|
|
232
|
+
aria-valuemax={max}
|
|
192
233
|
id={id}
|
|
193
234
|
name={name}
|
|
194
235
|
value={inputText}
|
|
@@ -200,17 +241,17 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
|
|
|
200
241
|
aria-describedby={ariaDescribedby}
|
|
201
242
|
aria-invalid={ariaInvalid}
|
|
202
243
|
aria-required={ariaRequired}
|
|
203
|
-
className="text-center
|
|
244
|
+
className="text-center"
|
|
204
245
|
onChange={handleChange}
|
|
205
246
|
onKeyDown={handleKeyDown}
|
|
206
247
|
onBlur={handleBlur}
|
|
207
248
|
/>
|
|
208
249
|
<InputGroupButton
|
|
209
|
-
aria-label="
|
|
250
|
+
aria-label={t("ui.numberInput.increase")}
|
|
210
251
|
tabIndex={-1}
|
|
211
252
|
disabled={incrementDisabled}
|
|
212
253
|
onClick={() => applyStep(step)}
|
|
213
|
-
className="rounded-
|
|
254
|
+
className="rounded-s-none"
|
|
214
255
|
>
|
|
215
256
|
<Plus aria-hidden="true" />
|
|
216
257
|
</InputGroupButton>
|