@dashforge/tw 0.1.0-beta
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/.swcrc +29 -0
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dist/components/AppShell/AppShell.d.ts +32 -0
- package/dist/components/AppShell/AppShell.d.ts.map +1 -0
- package/dist/components/AppShell/appShell.types.d.ts +62 -0
- package/dist/components/AppShell/appShell.types.d.ts.map +1 -0
- package/dist/components/AppShell/appShell.variants.d.ts +65 -0
- package/dist/components/AppShell/appShell.variants.d.ts.map +1 -0
- package/dist/components/Autocomplete/Autocomplete.d.ts +32 -0
- package/dist/components/Autocomplete/Autocomplete.d.ts.map +1 -0
- package/dist/components/Autocomplete/autocomplete.types.d.ts +214 -0
- package/dist/components/Autocomplete/autocomplete.types.d.ts.map +1 -0
- package/dist/components/Autocomplete/autocomplete.variants.d.ts +214 -0
- package/dist/components/Autocomplete/autocomplete.variants.d.ts.map +1 -0
- package/dist/components/Breadcrumbs/Breadcrumbs.d.ts +23 -0
- package/dist/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -0
- package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts +98 -0
- package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts.map +1 -0
- package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts +85 -0
- package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -0
- package/dist/components/Button/Button.d.ts +44 -0
- package/dist/components/Button/Button.d.ts.map +1 -0
- package/dist/components/Button/button.types.d.ts +66 -0
- package/dist/components/Button/button.types.d.ts.map +1 -0
- package/dist/components/Button/button.variants.d.ts +104 -0
- package/dist/components/Button/button.variants.d.ts.map +1 -0
- package/dist/components/Checkbox/Checkbox.d.ts +31 -0
- package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/components/Checkbox/checkbox.types.d.ts +86 -0
- package/dist/components/Checkbox/checkbox.types.d.ts.map +1 -0
- package/dist/components/Checkbox/checkbox.variants.d.ts +109 -0
- package/dist/components/Checkbox/checkbox.variants.d.ts.map +1 -0
- package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +38 -0
- package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -0
- package/dist/components/ConfirmDialog/confirmDialog.types.d.ts +80 -0
- package/dist/components/ConfirmDialog/confirmDialog.types.d.ts.map +1 -0
- package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts +90 -0
- package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts.map +1 -0
- package/dist/components/DateTimePicker/DateTimePicker.d.ts +49 -0
- package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +1 -0
- package/dist/components/DateTimePicker/dateTimePicker.types.d.ts +95 -0
- package/dist/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -0
- package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts +165 -0
- package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -0
- package/dist/components/LeftNav/LeftNav.d.ts +34 -0
- package/dist/components/LeftNav/LeftNav.d.ts.map +1 -0
- package/dist/components/LeftNav/leftNav.types.d.ts +136 -0
- package/dist/components/LeftNav/leftNav.types.d.ts.map +1 -0
- package/dist/components/LeftNav/leftNav.variants.d.ts +143 -0
- package/dist/components/LeftNav/leftNav.variants.d.ts.map +1 -0
- package/dist/components/NumberField/NumberField.d.ts +18 -0
- package/dist/components/NumberField/NumberField.d.ts.map +1 -0
- package/dist/components/NumberField/numberField.types.d.ts +79 -0
- package/dist/components/NumberField/numberField.types.d.ts.map +1 -0
- package/dist/components/NumberField/numberField.variants.d.ts +169 -0
- package/dist/components/NumberField/numberField.variants.d.ts.map +1 -0
- package/dist/components/OTPField/OTPField.d.ts +25 -0
- package/dist/components/OTPField/OTPField.d.ts.map +1 -0
- package/dist/components/OTPField/otpField.types.d.ts +74 -0
- package/dist/components/OTPField/otpField.types.d.ts.map +1 -0
- package/dist/components/OTPField/otpField.variants.d.ts +105 -0
- package/dist/components/OTPField/otpField.variants.d.ts.map +1 -0
- package/dist/components/RadioGroup/RadioGroup.d.ts +35 -0
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -0
- package/dist/components/RadioGroup/radioGroup.types.d.ts +103 -0
- package/dist/components/RadioGroup/radioGroup.types.d.ts.map +1 -0
- package/dist/components/RadioGroup/radioGroup.variants.d.ts +166 -0
- package/dist/components/RadioGroup/radioGroup.variants.d.ts.map +1 -0
- package/dist/components/Snackbar/Snackbar.d.ts +37 -0
- package/dist/components/Snackbar/Snackbar.d.ts.map +1 -0
- package/dist/components/Snackbar/snackbar.types.d.ts +86 -0
- package/dist/components/Snackbar/snackbar.types.d.ts.map +1 -0
- package/dist/components/Snackbar/snackbar.variants.d.ts +153 -0
- package/dist/components/Snackbar/snackbar.variants.d.ts.map +1 -0
- package/dist/components/Switch/Switch.d.ts +15 -0
- package/dist/components/Switch/Switch.d.ts.map +1 -0
- package/dist/components/Switch/switch.types.d.ts +47 -0
- package/dist/components/Switch/switch.types.d.ts.map +1 -0
- package/dist/components/Switch/switch.variants.d.ts +105 -0
- package/dist/components/Switch/switch.variants.d.ts.map +1 -0
- package/dist/components/TextField/TextField.d.ts +25 -0
- package/dist/components/TextField/TextField.d.ts.map +1 -0
- package/dist/components/TextField/textField.types.d.ts +72 -0
- package/dist/components/TextField/textField.types.d.ts.map +1 -0
- package/dist/components/TextField/textField.variants.d.ts +160 -0
- package/dist/components/TextField/textField.variants.d.ts.map +1 -0
- package/dist/components/Textarea/Textarea.d.ts +16 -0
- package/dist/components/Textarea/Textarea.d.ts.map +1 -0
- package/dist/components/Textarea/textarea.types.d.ts +63 -0
- package/dist/components/Textarea/textarea.types.d.ts.map +1 -0
- package/dist/components/Textarea/textarea.variants.d.ts +197 -0
- package/dist/components/Textarea/textarea.variants.d.ts.map +1 -0
- package/dist/components/TopBar/TopBar.d.ts +33 -0
- package/dist/components/TopBar/TopBar.d.ts.map +1 -0
- package/dist/components/TopBar/topBar.types.d.ts +49 -0
- package/dist/components/TopBar/topBar.types.d.ts.map +1 -0
- package/dist/components/TopBar/topBar.variants.d.ts +79 -0
- package/dist/components/TopBar/topBar.variants.d.ts.map +1 -0
- package/dist/components/_shared/resolveValidationState.d.ts +42 -0
- package/dist/components/_shared/resolveValidationState.d.ts.map +1 -0
- package/dist/hooks/useAccessState.d.ts +37 -0
- package/dist/hooks/useAccessState.d.ts.map +1 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +4958 -0
- package/dist/src/components/AppShell/AppShell.d.ts +32 -0
- package/dist/src/components/AppShell/AppShell.d.ts.map +1 -0
- package/dist/src/components/AppShell/appShell.types.d.ts +62 -0
- package/dist/src/components/AppShell/appShell.types.d.ts.map +1 -0
- package/dist/src/components/AppShell/appShell.variants.d.ts +65 -0
- package/dist/src/components/AppShell/appShell.variants.d.ts.map +1 -0
- package/dist/src/components/Autocomplete/Autocomplete.d.ts +32 -0
- package/dist/src/components/Autocomplete/Autocomplete.d.ts.map +1 -0
- package/dist/src/components/Autocomplete/autocomplete.types.d.ts +214 -0
- package/dist/src/components/Autocomplete/autocomplete.types.d.ts.map +1 -0
- package/dist/src/components/Autocomplete/autocomplete.variants.d.ts +214 -0
- package/dist/src/components/Autocomplete/autocomplete.variants.d.ts.map +1 -0
- package/dist/src/components/Breadcrumbs/Breadcrumbs.d.ts +23 -0
- package/dist/src/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -0
- package/dist/src/components/Breadcrumbs/breadcrumbs.types.d.ts +98 -0
- package/dist/src/components/Breadcrumbs/breadcrumbs.types.d.ts.map +1 -0
- package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts +85 -0
- package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -0
- package/dist/src/components/Button/Button.d.ts +44 -0
- package/dist/src/components/Button/Button.d.ts.map +1 -0
- package/dist/src/components/Button/button.types.d.ts +66 -0
- package/dist/src/components/Button/button.types.d.ts.map +1 -0
- package/dist/src/components/Button/button.variants.d.ts +104 -0
- package/dist/src/components/Button/button.variants.d.ts.map +1 -0
- package/dist/src/components/Checkbox/Checkbox.d.ts +31 -0
- package/dist/src/components/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/src/components/Checkbox/checkbox.types.d.ts +86 -0
- package/dist/src/components/Checkbox/checkbox.types.d.ts.map +1 -0
- package/dist/src/components/Checkbox/checkbox.variants.d.ts +109 -0
- package/dist/src/components/Checkbox/checkbox.variants.d.ts.map +1 -0
- package/dist/src/components/ConfirmDialog/ConfirmDialog.d.ts +38 -0
- package/dist/src/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -0
- package/dist/src/components/ConfirmDialog/confirmDialog.types.d.ts +80 -0
- package/dist/src/components/ConfirmDialog/confirmDialog.types.d.ts.map +1 -0
- package/dist/src/components/ConfirmDialog/confirmDialog.variants.d.ts +90 -0
- package/dist/src/components/ConfirmDialog/confirmDialog.variants.d.ts.map +1 -0
- package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +49 -0
- package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -0
- package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +95 -0
- package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -0
- package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +165 -0
- package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -0
- package/dist/src/components/LeftNav/LeftNav.d.ts +34 -0
- package/dist/src/components/LeftNav/LeftNav.d.ts.map +1 -0
- package/dist/src/components/LeftNav/leftNav.types.d.ts +136 -0
- package/dist/src/components/LeftNav/leftNav.types.d.ts.map +1 -0
- package/dist/src/components/LeftNav/leftNav.variants.d.ts +143 -0
- package/dist/src/components/LeftNav/leftNav.variants.d.ts.map +1 -0
- package/dist/src/components/NumberField/NumberField.d.ts +18 -0
- package/dist/src/components/NumberField/NumberField.d.ts.map +1 -0
- package/dist/src/components/NumberField/numberField.types.d.ts +79 -0
- package/dist/src/components/NumberField/numberField.types.d.ts.map +1 -0
- package/dist/src/components/NumberField/numberField.variants.d.ts +169 -0
- package/dist/src/components/NumberField/numberField.variants.d.ts.map +1 -0
- package/dist/src/components/OTPField/OTPField.d.ts +25 -0
- package/dist/src/components/OTPField/OTPField.d.ts.map +1 -0
- package/dist/src/components/OTPField/otpField.types.d.ts +74 -0
- package/dist/src/components/OTPField/otpField.types.d.ts.map +1 -0
- package/dist/src/components/OTPField/otpField.variants.d.ts +105 -0
- package/dist/src/components/OTPField/otpField.variants.d.ts.map +1 -0
- package/dist/src/components/RadioGroup/RadioGroup.d.ts +35 -0
- package/dist/src/components/RadioGroup/RadioGroup.d.ts.map +1 -0
- package/dist/src/components/RadioGroup/radioGroup.types.d.ts +103 -0
- package/dist/src/components/RadioGroup/radioGroup.types.d.ts.map +1 -0
- package/dist/src/components/RadioGroup/radioGroup.variants.d.ts +166 -0
- package/dist/src/components/RadioGroup/radioGroup.variants.d.ts.map +1 -0
- package/dist/src/components/Snackbar/Snackbar.d.ts +37 -0
- package/dist/src/components/Snackbar/Snackbar.d.ts.map +1 -0
- package/dist/src/components/Snackbar/snackbar.types.d.ts +86 -0
- package/dist/src/components/Snackbar/snackbar.types.d.ts.map +1 -0
- package/dist/src/components/Snackbar/snackbar.variants.d.ts +153 -0
- package/dist/src/components/Snackbar/snackbar.variants.d.ts.map +1 -0
- package/dist/src/components/Switch/Switch.d.ts +15 -0
- package/dist/src/components/Switch/Switch.d.ts.map +1 -0
- package/dist/src/components/Switch/switch.types.d.ts +47 -0
- package/dist/src/components/Switch/switch.types.d.ts.map +1 -0
- package/dist/src/components/Switch/switch.variants.d.ts +105 -0
- package/dist/src/components/Switch/switch.variants.d.ts.map +1 -0
- package/dist/src/components/TextField/TextField.d.ts +25 -0
- package/dist/src/components/TextField/TextField.d.ts.map +1 -0
- package/dist/src/components/TextField/textField.types.d.ts +72 -0
- package/dist/src/components/TextField/textField.types.d.ts.map +1 -0
- package/dist/src/components/TextField/textField.variants.d.ts +160 -0
- package/dist/src/components/TextField/textField.variants.d.ts.map +1 -0
- package/dist/src/components/Textarea/Textarea.d.ts +16 -0
- package/dist/src/components/Textarea/Textarea.d.ts.map +1 -0
- package/dist/src/components/Textarea/textarea.types.d.ts +63 -0
- package/dist/src/components/Textarea/textarea.types.d.ts.map +1 -0
- package/dist/src/components/Textarea/textarea.variants.d.ts +197 -0
- package/dist/src/components/Textarea/textarea.variants.d.ts.map +1 -0
- package/dist/src/components/TopBar/TopBar.d.ts +33 -0
- package/dist/src/components/TopBar/TopBar.d.ts.map +1 -0
- package/dist/src/components/TopBar/topBar.types.d.ts +49 -0
- package/dist/src/components/TopBar/topBar.types.d.ts.map +1 -0
- package/dist/src/components/TopBar/topBar.variants.d.ts +79 -0
- package/dist/src/components/TopBar/topBar.variants.d.ts.map +1 -0
- package/dist/src/components/_shared/resolveValidationState.d.ts +42 -0
- package/dist/src/components/_shared/resolveValidationState.d.ts.map +1 -0
- package/dist/src/hooks/useAccessState.d.ts +37 -0
- package/dist/src/hooks/useAccessState.d.ts.map +1 -0
- package/dist/src/index.d.ts +82 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/utils/cn.d.ts +26 -0
- package/dist/src/utils/cn.d.ts.map +1 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/utils/cn.d.ts +26 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/eslint.config.mjs +47 -0
- package/package.json +37 -0
- package/project.json +15 -0
- package/rollup.config.cjs +16 -0
- package/src/components/AppShell/AppShell.test.tsx +223 -0
- package/src/components/AppShell/AppShell.tsx +114 -0
- package/src/components/AppShell/appShell.types.ts +49 -0
- package/src/components/AppShell/appShell.variants.ts +48 -0
- package/src/components/Autocomplete/Autocomplete.perf.test.tsx +225 -0
- package/src/components/Autocomplete/Autocomplete.test.tsx +556 -0
- package/src/components/Autocomplete/Autocomplete.tsx +996 -0
- package/src/components/Autocomplete/autocomplete.types.ts +198 -0
- package/src/components/Autocomplete/autocomplete.variants.ts +135 -0
- package/src/components/Breadcrumbs/Breadcrumbs.perf.test.tsx +125 -0
- package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +231 -0
- package/src/components/Breadcrumbs/Breadcrumbs.tsx +203 -0
- package/src/components/Breadcrumbs/breadcrumbs.types.ts +87 -0
- package/src/components/Breadcrumbs/breadcrumbs.variants.ts +49 -0
- package/src/components/Button/Button.test.tsx +183 -0
- package/src/components/Button/Button.tsx +139 -0
- package/src/components/Button/button.types.ts +74 -0
- package/src/components/Button/button.variants.ts +159 -0
- package/src/components/Checkbox/Checkbox.test.tsx +146 -0
- package/src/components/Checkbox/Checkbox.tsx +228 -0
- package/src/components/Checkbox/checkbox.types.ts +88 -0
- package/src/components/Checkbox/checkbox.variants.ts +68 -0
- package/src/components/ConfirmDialog/ConfirmDialog.perf.test.tsx +172 -0
- package/src/components/ConfirmDialog/ConfirmDialog.test.tsx +311 -0
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +252 -0
- package/src/components/ConfirmDialog/confirmDialog.types.ts +70 -0
- package/src/components/ConfirmDialog/confirmDialog.variants.ts +76 -0
- package/src/components/DateTimePicker/DateTimePicker.perf.test.tsx +153 -0
- package/src/components/DateTimePicker/DateTimePicker.test.tsx +204 -0
- package/src/components/DateTimePicker/DateTimePicker.tsx +319 -0
- package/src/components/DateTimePicker/dateTimePicker.types.ts +83 -0
- package/src/components/DateTimePicker/dateTimePicker.variants.ts +84 -0
- package/src/components/LeftNav/LeftNav.perf.test.tsx +172 -0
- package/src/components/LeftNav/LeftNav.test.tsx +302 -0
- package/src/components/LeftNav/LeftNav.tsx +330 -0
- package/src/components/LeftNav/leftNav.types.ts +120 -0
- package/src/components/LeftNav/leftNav.variants.ts +96 -0
- package/src/components/NumberField/NumberField.test.tsx +83 -0
- package/src/components/NumberField/NumberField.tsx +293 -0
- package/src/components/NumberField/numberField.types.ts +72 -0
- package/src/components/NumberField/numberField.variants.ts +94 -0
- package/src/components/OTPField/OTPField.test.tsx +121 -0
- package/src/components/OTPField/OTPField.tsx +250 -0
- package/src/components/OTPField/otpField.types.ts +58 -0
- package/src/components/OTPField/otpField.variants.ts +72 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +140 -0
- package/src/components/RadioGroup/RadioGroup.tsx +261 -0
- package/src/components/RadioGroup/radioGroup.types.ts +85 -0
- package/src/components/RadioGroup/radioGroup.variants.ts +87 -0
- package/src/components/Snackbar/Snackbar.perf.test.tsx +144 -0
- package/src/components/Snackbar/Snackbar.test.tsx +327 -0
- package/src/components/Snackbar/Snackbar.tsx +298 -0
- package/src/components/Snackbar/snackbar.types.ts +83 -0
- package/src/components/Snackbar/snackbar.variants.ts +72 -0
- package/src/components/Switch/Switch.test.tsx +131 -0
- package/src/components/Switch/Switch.tsx +158 -0
- package/src/components/Switch/switch.types.ts +36 -0
- package/src/components/Switch/switch.variants.ts +65 -0
- package/src/components/TextField/TextField.test.tsx +194 -0
- package/src/components/TextField/TextField.tsx +216 -0
- package/src/components/TextField/textField.types.ts +70 -0
- package/src/components/TextField/textField.variants.ts +87 -0
- package/src/components/Textarea/Textarea.test.tsx +75 -0
- package/src/components/Textarea/Textarea.tsx +190 -0
- package/src/components/Textarea/textarea.types.ts +60 -0
- package/src/components/Textarea/textarea.variants.ts +85 -0
- package/src/components/TopBar/TopBar.test.tsx +129 -0
- package/src/components/TopBar/TopBar.tsx +66 -0
- package/src/components/TopBar/topBar.types.ts +42 -0
- package/src/components/TopBar/topBar.variants.ts +38 -0
- package/src/components/_shared/resolveValidationState.ts +60 -0
- package/src/hooks/useAccessState.spec.tsx +161 -0
- package/src/hooks/useAccessState.ts +81 -0
- package/src/index.ts +191 -0
- package/src/utils/cn.spec.ts +16 -0
- package/src/utils/cn.ts +29 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +55 -0
- package/tsconfig.spec.json +34 -0
- package/vite.config.ts +25 -0
- package/vitest.config.mts +22 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { useCallback, useContext, useEffect, useId, useRef } from 'react';
|
|
2
|
+
import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
|
|
3
|
+
import type { DashFormBridge, FieldRegistration } from '@dashforge/ui-core';
|
|
4
|
+
import { useDashFieldMeta } from '@dashforge/forms';
|
|
5
|
+
import { cn } from '../../utils/cn.js';
|
|
6
|
+
import { useAccessState } from '../../hooks/useAccessState.js';
|
|
7
|
+
import { resolveValidationState } from '../_shared/resolveValidationState.js';
|
|
8
|
+
import { numberFieldVariants } from './numberField.variants.js';
|
|
9
|
+
import type { NumberFieldProps } from './numberField.types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format a numeric bridge value for the input UI.
|
|
13
|
+
*
|
|
14
|
+
* Strict rules (mirror the MUI side):
|
|
15
|
+
* - `number` → `String(n)` (no thousands separators, no locale)
|
|
16
|
+
* - `null` / `undefined` → empty string (so the input shows blank)
|
|
17
|
+
* - non-finite numbers (NaN, ±Infinity) are coerced to `""` and never written
|
|
18
|
+
* back to the bridge
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
function formatForDisplay(v: unknown): string {
|
|
23
|
+
if (v == null) return '';
|
|
24
|
+
if (typeof v === 'number') {
|
|
25
|
+
return Number.isFinite(v) ? String(v) : '';
|
|
26
|
+
}
|
|
27
|
+
if (typeof v === 'string') return v;
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Coerce a string from the input back to the bridge storage type.
|
|
33
|
+
*
|
|
34
|
+
* - `""` → `null` (cleared)
|
|
35
|
+
* - parseable finite number → that number
|
|
36
|
+
* - anything else → `undefined` (caller should NOT write to the bridge)
|
|
37
|
+
*
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
function parseFromInput(str: string): number | null | undefined {
|
|
41
|
+
if (str === '') return null;
|
|
42
|
+
// Allow the user to type the leading sign or a trailing decimal point without
|
|
43
|
+
// committing a partial number to the bridge — those parse to NaN here.
|
|
44
|
+
const n = Number(str);
|
|
45
|
+
return Number.isFinite(n) ? n : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Dashforge TW NumberField — bridge-integrated numeric input.
|
|
50
|
+
*
|
|
51
|
+
* Mirrors `@dashforge/ui/NumberField` semantics: storage type is
|
|
52
|
+
* `number | null`, NaN is never written, `Number.isFinite` is the guard.
|
|
53
|
+
* Renders a native `<input type="number">` with an OPTIONAL +/− stepper
|
|
54
|
+
* (opt-in via `showStepper`). The native browser spinner is suppressed
|
|
55
|
+
* in CSS so visual consistency is preserved cross-browser.
|
|
56
|
+
*
|
|
57
|
+
* **A11y**:
|
|
58
|
+
* - `aria-invalid` from resolved validation state
|
|
59
|
+
* - `aria-describedby` links the input to the helper/error text
|
|
60
|
+
* - `min` / `max` / `step` forwarded to the native input (which exposes
|
|
61
|
+
* `aria-valuemin` / `aria-valuemax` automatically)
|
|
62
|
+
*/
|
|
63
|
+
export function NumberField(props: NumberFieldProps) {
|
|
64
|
+
const {
|
|
65
|
+
name,
|
|
66
|
+
rules,
|
|
67
|
+
visibleWhen,
|
|
68
|
+
layout = 'stacked',
|
|
69
|
+
size,
|
|
70
|
+
label,
|
|
71
|
+
helperText,
|
|
72
|
+
required,
|
|
73
|
+
error,
|
|
74
|
+
disabled,
|
|
75
|
+
fullWidth,
|
|
76
|
+
access,
|
|
77
|
+
sx,
|
|
78
|
+
slotProps,
|
|
79
|
+
placeholder,
|
|
80
|
+
min,
|
|
81
|
+
max,
|
|
82
|
+
step = 1,
|
|
83
|
+
showStepper = false,
|
|
84
|
+
onChange: userOnChange,
|
|
85
|
+
onBlur: userOnBlur,
|
|
86
|
+
value: userValue,
|
|
87
|
+
defaultValue,
|
|
88
|
+
...rest
|
|
89
|
+
} = props;
|
|
90
|
+
|
|
91
|
+
// ───── Hooks (unconditional) ─────
|
|
92
|
+
const bridge = useContext(DashFormContext) as DashFormBridge | null;
|
|
93
|
+
const isVisible = useEngineVisibility(bridge?.engine, visibleWhen);
|
|
94
|
+
// Per-field subscription side-effect. Value read happens via
|
|
95
|
+
// `bridge.getValue(name)` (mirrors MUI side — avoids first-render
|
|
96
|
+
// undefined window from `useDashFieldMeta.value`).
|
|
97
|
+
useDashFieldMeta(name);
|
|
98
|
+
const accessState = useAccessState(access);
|
|
99
|
+
|
|
100
|
+
const inputId = useId();
|
|
101
|
+
const helperId = `${inputId}-help`;
|
|
102
|
+
|
|
103
|
+
// StrictMode-safe unregister-on-unmount
|
|
104
|
+
const unregisterRef = useRef({ bridge, name });
|
|
105
|
+
unregisterRef.current = { bridge, name };
|
|
106
|
+
const isMountedRef = useRef(false);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
isMountedRef.current = true;
|
|
110
|
+
return () => {
|
|
111
|
+
isMountedRef.current = false;
|
|
112
|
+
const { bridge: cap, name: capName } = unregisterRef.current;
|
|
113
|
+
queueMicrotask(() => {
|
|
114
|
+
if (!isMountedRef.current) cap?.unregister?.(capName);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
// ───── Derived (every hook MUST be called above the early returns) ─────
|
|
120
|
+
const effectiveDisabled = Boolean(disabled) || accessState.disabled;
|
|
121
|
+
const effectiveReadOnly = accessState.readonly;
|
|
122
|
+
|
|
123
|
+
const isFormMode = Boolean(bridge?.register);
|
|
124
|
+
|
|
125
|
+
let resolvedError = error;
|
|
126
|
+
let resolvedHelperText: typeof helperText = helperText;
|
|
127
|
+
let registration: FieldRegistration | null = null;
|
|
128
|
+
let resolvedDisplayValue: string;
|
|
129
|
+
|
|
130
|
+
if (isFormMode && bridge) {
|
|
131
|
+
registration = bridge.register(name, rules);
|
|
132
|
+
const validation = resolveValidationState(name, bridge, error, helperText);
|
|
133
|
+
resolvedError = validation.error;
|
|
134
|
+
resolvedHelperText = validation.helperText;
|
|
135
|
+
resolvedDisplayValue =
|
|
136
|
+
userValue !== undefined
|
|
137
|
+
? formatForDisplay(userValue)
|
|
138
|
+
: formatForDisplay(bridge.getValue(name));
|
|
139
|
+
} else {
|
|
140
|
+
resolvedDisplayValue =
|
|
141
|
+
userValue !== undefined
|
|
142
|
+
? formatForDisplay(userValue)
|
|
143
|
+
: formatForDisplay(defaultValue);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const writeToBridge = (parsed: number | null | undefined) => {
|
|
147
|
+
if (!isFormMode || !bridge) return;
|
|
148
|
+
if (parsed === undefined) return; // partial/invalid — don't pollute the bridge
|
|
149
|
+
bridge.setValue?.(name, parsed);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
153
|
+
const parsed = parseFromInput(e.target.value);
|
|
154
|
+
writeToBridge(parsed);
|
|
155
|
+
if (isFormMode && registration?.onChange) {
|
|
156
|
+
// Forward to RHF — pass through the original event so RHF's
|
|
157
|
+
// internal logic still sees the raw string change.
|
|
158
|
+
void registration.onChange(e);
|
|
159
|
+
}
|
|
160
|
+
userOnChange?.(e);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
164
|
+
if (isFormMode && registration?.onBlur) {
|
|
165
|
+
registration.onBlur(e);
|
|
166
|
+
}
|
|
167
|
+
userOnBlur?.(e);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const stepBy = (delta: number) => {
|
|
171
|
+
const current = isFormMode && bridge
|
|
172
|
+
? (bridge.getValue(name) as number | null | undefined)
|
|
173
|
+
: parseFromInput(resolvedDisplayValue);
|
|
174
|
+
const base = typeof current === 'number' && Number.isFinite(current) ? current : 0;
|
|
175
|
+
let next = base + delta;
|
|
176
|
+
if (typeof min === 'number') next = Math.max(min, next);
|
|
177
|
+
if (typeof max === 'number') next = Math.min(max, next);
|
|
178
|
+
writeToBridge(next);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const canIncrement =
|
|
182
|
+
!effectiveDisabled && (typeof max !== 'number' || (parseFromInput(resolvedDisplayValue) ?? -Infinity) < max);
|
|
183
|
+
const canDecrement =
|
|
184
|
+
!effectiveDisabled && (typeof min !== 'number' || (parseFromInput(resolvedDisplayValue) ?? Infinity) > min);
|
|
185
|
+
|
|
186
|
+
const registrationRefFn = registration?.ref;
|
|
187
|
+
const inputRef = useCallback(
|
|
188
|
+
(instance: HTMLInputElement | null) => {
|
|
189
|
+
if (typeof registrationRefFn === 'function') {
|
|
190
|
+
registrationRefFn(instance);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
[registrationRefFn]
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// ───── Render-time guards (after all hooks) ─────
|
|
197
|
+
if (!isVisible) return null;
|
|
198
|
+
if (!accessState.visible) return null;
|
|
199
|
+
|
|
200
|
+
const v = numberFieldVariants({
|
|
201
|
+
size,
|
|
202
|
+
layout,
|
|
203
|
+
error: resolvedError,
|
|
204
|
+
fullWidth,
|
|
205
|
+
disabled: effectiveDisabled,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div className={cn(v.root(), sx, slotProps?.root?.className)}>
|
|
210
|
+
{label && (
|
|
211
|
+
<label
|
|
212
|
+
htmlFor={inputId}
|
|
213
|
+
className={cn(v.label(), slotProps?.label?.className)}
|
|
214
|
+
>
|
|
215
|
+
{label}
|
|
216
|
+
{required && (
|
|
217
|
+
<span
|
|
218
|
+
aria-hidden="true"
|
|
219
|
+
className={cn(v.requiredMark(), slotProps?.requiredMark?.className)}
|
|
220
|
+
>
|
|
221
|
+
*
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
224
|
+
</label>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
<div className={cn(v.inputWrapper(), slotProps?.inputWrapper?.className)}>
|
|
228
|
+
<input
|
|
229
|
+
{...rest}
|
|
230
|
+
id={inputId}
|
|
231
|
+
name={name}
|
|
232
|
+
type="number"
|
|
233
|
+
placeholder={placeholder}
|
|
234
|
+
value={resolvedDisplayValue}
|
|
235
|
+
min={min}
|
|
236
|
+
max={max}
|
|
237
|
+
step={step}
|
|
238
|
+
disabled={effectiveDisabled}
|
|
239
|
+
readOnly={effectiveReadOnly}
|
|
240
|
+
required={required}
|
|
241
|
+
aria-invalid={resolvedError || undefined}
|
|
242
|
+
aria-describedby={resolvedHelperText ? helperId : undefined}
|
|
243
|
+
onChange={handleChange}
|
|
244
|
+
onBlur={handleBlur}
|
|
245
|
+
ref={inputRef}
|
|
246
|
+
className={cn(v.input(), slotProps?.input?.className)}
|
|
247
|
+
/>
|
|
248
|
+
|
|
249
|
+
{showStepper && (
|
|
250
|
+
<div
|
|
251
|
+
className={cn(v.stepper(), slotProps?.stepper?.className)}
|
|
252
|
+
aria-hidden="true"
|
|
253
|
+
>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
tabIndex={-1}
|
|
257
|
+
onClick={() => stepBy(step)}
|
|
258
|
+
disabled={!canIncrement}
|
|
259
|
+
className={cn(v.stepperButton(), slotProps?.stepperButton?.className)}
|
|
260
|
+
aria-label="Increment"
|
|
261
|
+
>
|
|
262
|
+
▲
|
|
263
|
+
</button>
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
tabIndex={-1}
|
|
267
|
+
onClick={() => stepBy(-step)}
|
|
268
|
+
disabled={!canDecrement}
|
|
269
|
+
className={cn(v.stepperButton(), slotProps?.stepperButton?.className)}
|
|
270
|
+
aria-label="Decrement"
|
|
271
|
+
>
|
|
272
|
+
▼
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{resolvedHelperText && (
|
|
279
|
+
<p
|
|
280
|
+
id={helperId}
|
|
281
|
+
className={cn(
|
|
282
|
+
resolvedError ? v.errorText() : v.helperText(),
|
|
283
|
+
resolvedError
|
|
284
|
+
? slotProps?.errorText?.className
|
|
285
|
+
: slotProps?.helperText?.className
|
|
286
|
+
)}
|
|
287
|
+
>
|
|
288
|
+
{resolvedHelperText}
|
|
289
|
+
</p>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangeEventHandler,
|
|
3
|
+
FocusEventHandler,
|
|
4
|
+
InputHTMLAttributes,
|
|
5
|
+
ReactNode,
|
|
6
|
+
} from 'react';
|
|
7
|
+
import type { Engine } from '@dashforge/ui-core';
|
|
8
|
+
import type { AccessRequirement } from '@dashforge/rbac';
|
|
9
|
+
import type { NumberFieldVariants } from './numberField.variants.js';
|
|
10
|
+
|
|
11
|
+
export interface NumberFieldSlotProps {
|
|
12
|
+
root?: { className?: string };
|
|
13
|
+
label?: { className?: string };
|
|
14
|
+
requiredMark?: { className?: string };
|
|
15
|
+
inputWrapper?: { className?: string };
|
|
16
|
+
input?: { className?: string };
|
|
17
|
+
stepper?: { className?: string };
|
|
18
|
+
stepperButton?: { className?: string };
|
|
19
|
+
helperText?: { className?: string };
|
|
20
|
+
errorText?: { className?: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Props for `<NumberField>`.
|
|
25
|
+
*
|
|
26
|
+
* Value contract (mirrors `@dashforge/ui/NumberField`):
|
|
27
|
+
* - Bridge storage type: `number | null` (NEVER `NaN`).
|
|
28
|
+
* - UI display: `number → String(number)`, `null/undefined → ""`.
|
|
29
|
+
* - On user input:
|
|
30
|
+
* - empty string → `setValue(name, null)`
|
|
31
|
+
* - parseable number → `setValue(name, parsed)`
|
|
32
|
+
* - non-numeric input → no bridge write (UI string is kept as-is for UX,
|
|
33
|
+
* but the bridge stays at the last valid value)
|
|
34
|
+
* - Parsing uses `Number(...)` + `Number.isFinite(...)`. No locale parsing.
|
|
35
|
+
*/
|
|
36
|
+
export interface NumberFieldProps
|
|
37
|
+
extends Omit<
|
|
38
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
39
|
+
'name' | 'type' | 'size' | 'onChange' | 'onBlur' | 'value' | 'defaultValue'
|
|
40
|
+
>,
|
|
41
|
+
NumberFieldVariants {
|
|
42
|
+
name: string;
|
|
43
|
+
rules?: unknown;
|
|
44
|
+
label?: ReactNode;
|
|
45
|
+
helperText?: ReactNode;
|
|
46
|
+
required?: boolean;
|
|
47
|
+
error?: boolean;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
/** Engine predicate — field not rendered when it returns `false`. */
|
|
50
|
+
visibleWhen?: (engine: Engine) => boolean;
|
|
51
|
+
/** RBAC access requirement (combines with explicit `disabled`). */
|
|
52
|
+
access?: AccessRequirement;
|
|
53
|
+
/** Root className shortcut (`cn`'d with the variant root class). */
|
|
54
|
+
sx?: string;
|
|
55
|
+
/** Per-slot className overrides. */
|
|
56
|
+
slotProps?: NumberFieldSlotProps;
|
|
57
|
+
/** Controlled value (form mode reads from the bridge if omitted). */
|
|
58
|
+
value?: number | string | null;
|
|
59
|
+
/** Default value (uncontrolled, standalone mode only). */
|
|
60
|
+
defaultValue?: number | string | null;
|
|
61
|
+
/** Min allowed value (passed to the input + clamps stepper). */
|
|
62
|
+
min?: number;
|
|
63
|
+
/** Max allowed value (passed to the input + clamps stepper). */
|
|
64
|
+
max?: number;
|
|
65
|
+
/** Stepper step size. Default 1. */
|
|
66
|
+
step?: number;
|
|
67
|
+
/** Show inline +/− stepper buttons inside the input wrapper. Default false. */
|
|
68
|
+
showStepper?: boolean;
|
|
69
|
+
/** Change handler — receives the native event; consume value via bridge. */
|
|
70
|
+
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
71
|
+
onBlur?: FocusEventHandler<HTMLInputElement>;
|
|
72
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tailwind-variants recipe for `<NumberField>`.
|
|
5
|
+
*
|
|
6
|
+
* Same 7-slot taxonomy as `<TextField>` for visual/override parity, plus
|
|
7
|
+
* a dedicated `stepper` slot for the optional +/- increment buttons.
|
|
8
|
+
*/
|
|
9
|
+
export const numberFieldVariants = tv({
|
|
10
|
+
slots: {
|
|
11
|
+
root: 'flex',
|
|
12
|
+
label: 'block text-sm font-medium text-neutral-900 mb-1',
|
|
13
|
+
requiredMark: 'text-danger-500 ml-0.5',
|
|
14
|
+
inputWrapper: [
|
|
15
|
+
'relative flex items-center',
|
|
16
|
+
// bg-neutral-50 inverts via @dashforge/tw-tokens — dark-mode aware.
|
|
17
|
+
'rounded-md border bg-neutral-50',
|
|
18
|
+
'border-neutral-300',
|
|
19
|
+
'transition-colors',
|
|
20
|
+
'focus-within:border-primary-500 focus-within:ring-2 focus-within:ring-primary-500/30',
|
|
21
|
+
],
|
|
22
|
+
input: [
|
|
23
|
+
'block w-full bg-transparent outline-none',
|
|
24
|
+
'text-neutral-900 placeholder:text-neutral-400',
|
|
25
|
+
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
26
|
+
'read-only:cursor-default',
|
|
27
|
+
// Suppress the native browser spinner — we ship our own optional stepper
|
|
28
|
+
'[-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
|
29
|
+
// Right-align numeric values by convention
|
|
30
|
+
'text-right',
|
|
31
|
+
],
|
|
32
|
+
stepper: [
|
|
33
|
+
'flex flex-col border-l border-neutral-300',
|
|
34
|
+
'select-none',
|
|
35
|
+
],
|
|
36
|
+
stepperButton: [
|
|
37
|
+
'flex items-center justify-center px-2 cursor-pointer',
|
|
38
|
+
'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100',
|
|
39
|
+
'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent',
|
|
40
|
+
'transition-colors',
|
|
41
|
+
],
|
|
42
|
+
helperText: 'mt-1 text-sm text-neutral-600',
|
|
43
|
+
errorText: 'mt-1 text-sm text-danger-600',
|
|
44
|
+
},
|
|
45
|
+
variants: {
|
|
46
|
+
size: {
|
|
47
|
+
sm: {
|
|
48
|
+
inputWrapper: 'h-8',
|
|
49
|
+
input: 'text-sm px-2',
|
|
50
|
+
stepperButton: 'h-4 text-xs',
|
|
51
|
+
},
|
|
52
|
+
md: {
|
|
53
|
+
inputWrapper: 'h-10',
|
|
54
|
+
input: 'text-base px-3',
|
|
55
|
+
stepperButton: 'h-5 text-sm',
|
|
56
|
+
},
|
|
57
|
+
lg: {
|
|
58
|
+
inputWrapper: 'h-12',
|
|
59
|
+
input: 'text-lg px-4',
|
|
60
|
+
stepperButton: 'h-6 text-base',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
layout: {
|
|
64
|
+
stacked: { root: 'flex-col' },
|
|
65
|
+
inline: { root: 'flex-row items-start gap-3', label: 'mb-0 pt-2 whitespace-nowrap shrink-0' },
|
|
66
|
+
},
|
|
67
|
+
error: {
|
|
68
|
+
true: {
|
|
69
|
+
inputWrapper:
|
|
70
|
+
'border-danger-500 focus-within:border-danger-500 focus-within:ring-danger-500/30',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
fullWidth: {
|
|
74
|
+
true: {
|
|
75
|
+
root: 'w-full',
|
|
76
|
+
inputWrapper: 'w-full',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
disabled: {
|
|
80
|
+
true: {
|
|
81
|
+
inputWrapper: 'opacity-60 cursor-not-allowed bg-neutral-200',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
defaultVariants: {
|
|
86
|
+
size: 'md',
|
|
87
|
+
layout: 'stacked',
|
|
88
|
+
error: false,
|
|
89
|
+
fullWidth: false,
|
|
90
|
+
disabled: false,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export type NumberFieldVariants = VariantProps<typeof numberFieldVariants>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { DashFormProvider } from '@dashforge/forms';
|
|
5
|
+
import { OTPField } from './OTPField.js';
|
|
6
|
+
|
|
7
|
+
describe('<OTPField>', () => {
|
|
8
|
+
describe('standalone (no DashForm)', () => {
|
|
9
|
+
it('renders the configured number of slots (default 6)', () => {
|
|
10
|
+
const { container } = render(<OTPField name="otp" label="Code" />);
|
|
11
|
+
// Slots are `<div data-active>` cells inside the slots row — the
|
|
12
|
+
// hidden input is the 7th flex child but is the only role=textbox.
|
|
13
|
+
const slots = container.querySelectorAll('[aria-hidden="true"][class*="rounded-md"]');
|
|
14
|
+
expect(slots).toHaveLength(6);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders the requested number of slots (length=4)', () => {
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<OTPField name="otp" label="Code" length={4} />
|
|
20
|
+
);
|
|
21
|
+
const slots = container.querySelectorAll('[aria-hidden="true"][class*="rounded-md"]');
|
|
22
|
+
expect(slots).toHaveLength(4);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders a `*` for required fields', () => {
|
|
26
|
+
render(<OTPField name="otp" label="Code" required />);
|
|
27
|
+
expect(screen.getByText('*')).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders helperText when provided', () => {
|
|
31
|
+
render(
|
|
32
|
+
<OTPField name="otp" label="Code" helperText="Check your phone" />
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getByText('Check your phone')).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('displays a controlled value across its slots', () => {
|
|
38
|
+
const { container } = render(
|
|
39
|
+
<OTPField name="otp" label="Code" value="1234" length={4} onChange={() => undefined} />
|
|
40
|
+
);
|
|
41
|
+
const charSpans = container.querySelectorAll('[class*="font-mono"] span');
|
|
42
|
+
// First 4 slots are filled with 1234
|
|
43
|
+
expect(charSpans[0]?.textContent).toBe('1');
|
|
44
|
+
expect(charSpans[1]?.textContent).toBe('2');
|
|
45
|
+
expect(charSpans[2]?.textContent).toBe('3');
|
|
46
|
+
expect(charSpans[3]?.textContent).toBe('4');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('numeric mode sanitises non-digit input', () => {
|
|
50
|
+
const onChange = vi.fn();
|
|
51
|
+
render(
|
|
52
|
+
<OTPField name="otp" label="Code" mode="numeric" length={4} onChange={onChange} />
|
|
53
|
+
);
|
|
54
|
+
// The hidden input absorbs keystrokes
|
|
55
|
+
const input = screen.getByLabelText('Code') as HTMLInputElement;
|
|
56
|
+
fireEvent.change(input, { target: { value: '12ab34' } });
|
|
57
|
+
// Only digits survive (and capped at length=4)
|
|
58
|
+
expect(onChange).toHaveBeenCalledWith('1234');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('alphanumeric mode allows letters + digits', () => {
|
|
62
|
+
const onChange = vi.fn();
|
|
63
|
+
render(
|
|
64
|
+
<OTPField
|
|
65
|
+
name="otp"
|
|
66
|
+
label="Code"
|
|
67
|
+
mode="alphanumeric"
|
|
68
|
+
length={6}
|
|
69
|
+
onChange={onChange}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
const input = screen.getByLabelText('Code') as HTMLInputElement;
|
|
73
|
+
fireEvent.change(input, { target: { value: 'a1B2-c3' } });
|
|
74
|
+
expect(onChange).toHaveBeenCalledWith('a1B2c3');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('fires onComplete when all slots are filled', () => {
|
|
78
|
+
const onComplete = vi.fn();
|
|
79
|
+
render(
|
|
80
|
+
<OTPField
|
|
81
|
+
name="otp"
|
|
82
|
+
label="Code"
|
|
83
|
+
length={4}
|
|
84
|
+
onComplete={onComplete}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
const input = screen.getByLabelText('Code') as HTMLInputElement;
|
|
88
|
+
fireEvent.change(input, { target: { value: '9999' } });
|
|
89
|
+
expect(onComplete).toHaveBeenCalledWith('9999');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does NOT fire onComplete on partial input', () => {
|
|
93
|
+
const onComplete = vi.fn();
|
|
94
|
+
render(
|
|
95
|
+
<OTPField name="otp" label="Code" length={6} onComplete={onComplete} />
|
|
96
|
+
);
|
|
97
|
+
const input = screen.getByLabelText('Code') as HTMLInputElement;
|
|
98
|
+
fireEvent.change(input, { target: { value: '123' } });
|
|
99
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('respects `disabled` on the underlying input', () => {
|
|
103
|
+
render(<OTPField name="otp" label="Code" disabled />);
|
|
104
|
+
const input = screen.getByLabelText('Code') as HTMLInputElement;
|
|
105
|
+
expect(input.disabled).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('inside DashFormProvider', () => {
|
|
110
|
+
it('mounts cleanly inside a bridge-providing tree', () => {
|
|
111
|
+
render(
|
|
112
|
+
<DashFormProvider defaultValues={{ otp: '' }}>
|
|
113
|
+
<OTPField name="otp" label="Code" length={4} />
|
|
114
|
+
</DashFormProvider>
|
|
115
|
+
);
|
|
116
|
+
// Mount-only assertion. Initial value reflection and round-trip
|
|
117
|
+
// are timing-sensitive in vitest; verified end-to-end in dash.
|
|
118
|
+
expect(screen.getByLabelText('Code')).toBeInstanceOf(HTMLInputElement);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|