@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,198 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { Engine } from '@dashforge/ui-core';
|
|
3
|
+
import type { AccessRequirement } from '@dashforge/rbac';
|
|
4
|
+
import type { AutocompleteVariants } from './autocomplete.variants.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default `{ value, label, disabled? }` option shape.
|
|
8
|
+
*
|
|
9
|
+
* The simplest shape and the one used by the component when no
|
|
10
|
+
* `getOptionValue` / `getOptionLabel` props are provided. For richer
|
|
11
|
+
* option shapes (e.g., `{ id, name, country, ... }`) make the component
|
|
12
|
+
* generic by passing `<TOption>` and providing the accessors:
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* type User = { id: string; name: string; admin?: boolean };
|
|
16
|
+
*
|
|
17
|
+
* <Autocomplete<User>
|
|
18
|
+
* name="owner"
|
|
19
|
+
* options={users}
|
|
20
|
+
* getOptionValue={(u) => u.id}
|
|
21
|
+
* getOptionLabel={(u) => u.name}
|
|
22
|
+
* getOptionDisabled={(u) => !u.admin}
|
|
23
|
+
* />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export interface AutocompleteOption {
|
|
27
|
+
/** Value persisted on the form. */
|
|
28
|
+
value: string;
|
|
29
|
+
/** Display label (string or any React node). */
|
|
30
|
+
label: ReactNode;
|
|
31
|
+
/** Per-option explicit disable. */
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AutocompleteSlotProps {
|
|
36
|
+
root?: { className?: string };
|
|
37
|
+
label?: { className?: string };
|
|
38
|
+
requiredMark?: { className?: string };
|
|
39
|
+
inputWrapper?: { className?: string };
|
|
40
|
+
input?: { className?: string };
|
|
41
|
+
trigger?: { className?: string };
|
|
42
|
+
clearButton?: { className?: string };
|
|
43
|
+
popover?: { className?: string };
|
|
44
|
+
listBox?: { className?: string };
|
|
45
|
+
listItem?: { className?: string };
|
|
46
|
+
emptyState?: { className?: string };
|
|
47
|
+
helperText?: { className?: string };
|
|
48
|
+
errorText?: { className?: string };
|
|
49
|
+
/** Wrapper around the chip list (multi-select only). */
|
|
50
|
+
chipsList?: { className?: string };
|
|
51
|
+
/** Individual chip displayed in multi-select. */
|
|
52
|
+
chip?: { className?: string };
|
|
53
|
+
/** `×` button on a chip. */
|
|
54
|
+
chipRemove?: { className?: string };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Union of the value shapes `<Autocomplete>` accepts.
|
|
59
|
+
*
|
|
60
|
+
* - **Single-select (default)**: `string | null`
|
|
61
|
+
* - **Multi-select** (`multiple` prop): `string[] | null`
|
|
62
|
+
*
|
|
63
|
+
* `null` means "no selection" in both modes — a multi-select with no
|
|
64
|
+
* picks emits an empty array on first commit, then keeps emitting arrays.
|
|
65
|
+
*/
|
|
66
|
+
export type AutocompleteValue = string | string[] | null;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Props for `<Autocomplete>`.
|
|
70
|
+
*
|
|
71
|
+
* Bridge-integrated single-select combobox with full ARIA combobox
|
|
72
|
+
* a11y, keyboard navigation, and Form Closure v1 error gating.
|
|
73
|
+
*
|
|
74
|
+
* Mirrors the MUI-side `@dashforge/ui/Autocomplete` at the bridge
|
|
75
|
+
* level — same `name` / `rules` / `visibleWhen` / `access` semantics,
|
|
76
|
+
* same StrictMode-safe unregister-on-unmount.
|
|
77
|
+
*
|
|
78
|
+
* @typeParam TOption Shape of an option. Defaults to
|
|
79
|
+
* `AutocompleteOption` (`{ value, label, disabled? }`).
|
|
80
|
+
* Pass `<TOption>` + `getOptionValue` / `getOptionLabel`
|
|
81
|
+
* for arbitrary domain types.
|
|
82
|
+
*/
|
|
83
|
+
export interface AutocompleteProps<TOption = AutocompleteOption>
|
|
84
|
+
extends AutocompleteVariants {
|
|
85
|
+
name: string;
|
|
86
|
+
rules?: unknown;
|
|
87
|
+
options: TOption[];
|
|
88
|
+
label?: ReactNode;
|
|
89
|
+
helperText?: ReactNode;
|
|
90
|
+
required?: boolean;
|
|
91
|
+
error?: boolean;
|
|
92
|
+
disabled?: boolean;
|
|
93
|
+
/** Placeholder shown when the input is empty. */
|
|
94
|
+
placeholder?: string;
|
|
95
|
+
/** Engine predicate — field not rendered when it returns `false`. */
|
|
96
|
+
visibleWhen?: (engine: Engine) => boolean;
|
|
97
|
+
/** RBAC access requirement. */
|
|
98
|
+
access?: AccessRequirement;
|
|
99
|
+
/** Root className shortcut. */
|
|
100
|
+
sx?: string;
|
|
101
|
+
/** Per-slot className overrides. */
|
|
102
|
+
slotProps?: AutocompleteSlotProps;
|
|
103
|
+
/** Controlled value (form mode reads from the bridge if omitted). */
|
|
104
|
+
value?: AutocompleteValue;
|
|
105
|
+
/** Default value for uncontrolled mode (no-op in form mode). */
|
|
106
|
+
defaultValue?: AutocompleteValue;
|
|
107
|
+
/** Fires when the user picks an option (after bridge update in form mode). */
|
|
108
|
+
onValueChange?: (value: AutocompleteValue) => void;
|
|
109
|
+
/** Fallback text when no option matches the typed filter. */
|
|
110
|
+
emptyMessage?: string;
|
|
111
|
+
|
|
112
|
+
// ───── F5-A-bis: multi-select ─────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Multi-select mode. When `true`:
|
|
116
|
+
*
|
|
117
|
+
* - `value` / `defaultValue` accept `string[]` (or `null`).
|
|
118
|
+
* - Picks are toggled rather than replaced; the popover stays open.
|
|
119
|
+
* - Selected items render as chips inside the input wrapper.
|
|
120
|
+
* - Backspace at the start of an empty input removes the last chip.
|
|
121
|
+
* - The clear button (`×`) clears the entire selection.
|
|
122
|
+
*/
|
|
123
|
+
multiple?: boolean;
|
|
124
|
+
|
|
125
|
+
// ───── F5-A-bis: free-solo ─────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Allow committing arbitrary strings (not just option values).
|
|
129
|
+
*
|
|
130
|
+
* When `true`:
|
|
131
|
+
* - **Enter** commits the typed text as the value when no option
|
|
132
|
+
* row is highlighted (or no rows match the filter).
|
|
133
|
+
* - **Blur** commits the typed text the same way, unless the text
|
|
134
|
+
* exactly matches an existing option's label (in which case the
|
|
135
|
+
* option's value snaps in).
|
|
136
|
+
* - In multi mode, each free-solo commit adds a new chip whose key
|
|
137
|
+
* and label are the typed string.
|
|
138
|
+
*
|
|
139
|
+
* Free-solo is independent of `multiple` — works in both modes.
|
|
140
|
+
*/
|
|
141
|
+
freeSolo?: boolean;
|
|
142
|
+
|
|
143
|
+
// ───── F5-A-bis: async runtime options ─────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Async option loader. Called (debounced by `loadDebounceMs`) every
|
|
147
|
+
* time the user types in the input. Results replace the static
|
|
148
|
+
* `options` prop while the user is actively filtering.
|
|
149
|
+
*
|
|
150
|
+
* Return a promise that resolves with the new option list. Reject /
|
|
151
|
+
* throw to surface a generic empty state.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```tsx
|
|
155
|
+
* <Autocomplete
|
|
156
|
+
* name="user"
|
|
157
|
+
* options={[]}
|
|
158
|
+
* loadOptions={async (q) => {
|
|
159
|
+
* const res = await fetch(`/api/users?q=${q}`);
|
|
160
|
+
* return res.ok ? res.json() : [];
|
|
161
|
+
* }}
|
|
162
|
+
* />
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
loadOptions?: (query: string) => Promise<TOption[]>;
|
|
166
|
+
/**
|
|
167
|
+
* Debounce window (ms) for `loadOptions`. The loader fires after the
|
|
168
|
+
* user stops typing for this long.
|
|
169
|
+
* @default 250
|
|
170
|
+
*/
|
|
171
|
+
loadDebounceMs?: number;
|
|
172
|
+
/** Text displayed in the popover while a fetch is in flight. */
|
|
173
|
+
loadingMessage?: ReactNode;
|
|
174
|
+
|
|
175
|
+
// ───── Generic option accessors (F5-A-bis) ─────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract the persistable value (string) from an option.
|
|
179
|
+
* @default `(o) => (o as AutocompleteOption).value`
|
|
180
|
+
*/
|
|
181
|
+
getOptionValue?: (option: TOption) => string;
|
|
182
|
+
/**
|
|
183
|
+
* Extract the display label from an option.
|
|
184
|
+
* @default `(o) => (o as AutocompleteOption).label`
|
|
185
|
+
*/
|
|
186
|
+
getOptionLabel?: (option: TOption) => ReactNode;
|
|
187
|
+
/**
|
|
188
|
+
* Per-option disabled flag.
|
|
189
|
+
* @default `(o) => Boolean((o as AutocompleteOption).disabled)`
|
|
190
|
+
*/
|
|
191
|
+
getOptionDisabled?: (option: TOption) => boolean;
|
|
192
|
+
/**
|
|
193
|
+
* Unique React key for an option. Defaults to `getOptionValue`.
|
|
194
|
+
* Override if your `value` strings can collide across distinct
|
|
195
|
+
* option records (rare).
|
|
196
|
+
*/
|
|
197
|
+
getOptionKey?: (option: TOption) => string;
|
|
198
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tailwind-variants recipe for `<Autocomplete>` with slots.
|
|
5
|
+
*
|
|
6
|
+
* Slots:
|
|
7
|
+
* - `root` — outer wrapper (label + combobox + helper/error)
|
|
8
|
+
* - `label` — text label above the input
|
|
9
|
+
* - `requiredMark` — red `*` for required fields
|
|
10
|
+
* - `inputWrapper` — surface holding the input + clear/trigger buttons
|
|
11
|
+
* - `input` — the typeable text input
|
|
12
|
+
* - `trigger` — caret / chevron button that opens the popover
|
|
13
|
+
* - `clearButton` — `×` button to clear the current selection
|
|
14
|
+
* - `popover` — floating panel containing the option list
|
|
15
|
+
* - `listBox` — list element wrapping the options
|
|
16
|
+
* - `listItem` — single option row
|
|
17
|
+
* - `emptyState` — fallback when no options match the filter
|
|
18
|
+
* - `helperText` — descriptive line below the input
|
|
19
|
+
* - `errorText` — semantic counterpart for error mode
|
|
20
|
+
* - `chipsList` — wrapper around selected chips (multi-select only)
|
|
21
|
+
* - `chip` — individual selected chip
|
|
22
|
+
* - `chipRemove` — `×` button on a chip
|
|
23
|
+
*/
|
|
24
|
+
export const autocompleteVariants = tv({
|
|
25
|
+
slots: {
|
|
26
|
+
root: 'flex',
|
|
27
|
+
label: 'block text-sm font-medium text-neutral-900 mb-1',
|
|
28
|
+
requiredMark: 'text-danger-500 ml-0.5',
|
|
29
|
+
inputWrapper: [
|
|
30
|
+
'relative flex items-center',
|
|
31
|
+
'rounded-md border bg-neutral-50',
|
|
32
|
+
'border-neutral-300',
|
|
33
|
+
'transition-colors',
|
|
34
|
+
'focus-within:border-primary-500 focus-within:ring-2 focus-within:ring-primary-500/30',
|
|
35
|
+
],
|
|
36
|
+
input: [
|
|
37
|
+
'block w-full bg-transparent outline-none',
|
|
38
|
+
'text-neutral-900 placeholder:text-neutral-400',
|
|
39
|
+
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
40
|
+
'read-only:cursor-default',
|
|
41
|
+
],
|
|
42
|
+
trigger: [
|
|
43
|
+
'flex items-center justify-center shrink-0 px-2',
|
|
44
|
+
'text-neutral-600 hover:text-neutral-900',
|
|
45
|
+
'disabled:cursor-not-allowed disabled:opacity-40',
|
|
46
|
+
'transition-colors',
|
|
47
|
+
],
|
|
48
|
+
clearButton: [
|
|
49
|
+
'flex items-center justify-center shrink-0 px-2',
|
|
50
|
+
'text-neutral-500 hover:text-neutral-900',
|
|
51
|
+
'transition-colors',
|
|
52
|
+
],
|
|
53
|
+
popover: [
|
|
54
|
+
'z-50 mt-1 max-h-64 overflow-auto',
|
|
55
|
+
'rounded-md border bg-neutral-50 border-neutral-300 shadow-lg',
|
|
56
|
+
'min-w-[var(--trigger-width)]',
|
|
57
|
+
],
|
|
58
|
+
listBox: 'outline-none py-1',
|
|
59
|
+
listItem: [
|
|
60
|
+
'cursor-pointer select-none px-3 py-2 text-sm text-neutral-900',
|
|
61
|
+
'outline-none',
|
|
62
|
+
'data-[focused]:bg-primary-50 data-[focused]:text-primary-900',
|
|
63
|
+
'data-[selected]:bg-primary-100 data-[selected]:text-primary-900 data-[selected]:font-medium',
|
|
64
|
+
'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
|
|
65
|
+
],
|
|
66
|
+
emptyState: 'px-3 py-2 text-sm text-neutral-500',
|
|
67
|
+
helperText: 'mt-1 text-sm text-neutral-600',
|
|
68
|
+
errorText: 'mt-1 text-sm text-danger-600',
|
|
69
|
+
chipsList: 'flex flex-wrap items-center gap-1 px-1 py-1',
|
|
70
|
+
chip: [
|
|
71
|
+
'inline-flex items-center gap-1 shrink-0',
|
|
72
|
+
'rounded-md bg-primary-100 text-primary-900',
|
|
73
|
+
'px-2 py-0.5 text-xs font-medium',
|
|
74
|
+
'border border-primary-200',
|
|
75
|
+
],
|
|
76
|
+
chipRemove: [
|
|
77
|
+
'inline-flex items-center justify-center',
|
|
78
|
+
'rounded-sm w-4 h-4 text-primary-700',
|
|
79
|
+
'hover:bg-primary-200 hover:text-primary-900',
|
|
80
|
+
'transition-colors',
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
variants: {
|
|
84
|
+
size: {
|
|
85
|
+
sm: {
|
|
86
|
+
inputWrapper: 'h-8',
|
|
87
|
+
input: 'text-sm px-2',
|
|
88
|
+
trigger: 'h-8 text-xs',
|
|
89
|
+
clearButton: 'h-8',
|
|
90
|
+
},
|
|
91
|
+
md: {
|
|
92
|
+
inputWrapper: 'h-10',
|
|
93
|
+
input: 'text-base px-3',
|
|
94
|
+
trigger: 'h-10 text-sm',
|
|
95
|
+
clearButton: 'h-10',
|
|
96
|
+
},
|
|
97
|
+
lg: {
|
|
98
|
+
inputWrapper: 'h-12',
|
|
99
|
+
input: 'text-lg px-4',
|
|
100
|
+
trigger: 'h-12 text-base',
|
|
101
|
+
clearButton: 'h-12',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
layout: {
|
|
105
|
+
stacked: { root: 'flex-col' },
|
|
106
|
+
inline: { root: 'flex-row items-start gap-3', label: 'mb-0 pt-2 whitespace-nowrap shrink-0' },
|
|
107
|
+
},
|
|
108
|
+
error: {
|
|
109
|
+
true: {
|
|
110
|
+
inputWrapper:
|
|
111
|
+
'border-danger-500 focus-within:border-danger-500 focus-within:ring-danger-500/30',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
fullWidth: {
|
|
115
|
+
true: {
|
|
116
|
+
root: 'w-full',
|
|
117
|
+
inputWrapper: 'w-full',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
disabled: {
|
|
121
|
+
true: {
|
|
122
|
+
inputWrapper: 'opacity-60 cursor-not-allowed bg-neutral-200',
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
defaultVariants: {
|
|
127
|
+
size: 'md',
|
|
128
|
+
layout: 'stacked',
|
|
129
|
+
error: false,
|
|
130
|
+
fullWidth: false,
|
|
131
|
+
disabled: false,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export type AutocompleteVariants = VariantProps<typeof autocompleteVariants>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Performance + re-render budget tests for `<Breadcrumbs>`.
|
|
4
|
+
*
|
|
5
|
+
* Layout components are typically near the top of the tree, so a
|
|
6
|
+
* regression here ripples through everything below. Guardrails:
|
|
7
|
+
*
|
|
8
|
+
* 1. Mount cost stays bounded even with very long trails.
|
|
9
|
+
* 2. Re-rendering the parent with the SAME items reference does not
|
|
10
|
+
* re-do the truncation memo work (verifies `useMemo` deps).
|
|
11
|
+
* 3. Truncation does not allocate per-render arrays that would defeat
|
|
12
|
+
* the React keyed-reuse heuristics.
|
|
13
|
+
*/
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
16
|
+
import { render, cleanup } from '@testing-library/react';
|
|
17
|
+
import { Breadcrumbs } from './Breadcrumbs.js';
|
|
18
|
+
import type { BreadcrumbItem } from './breadcrumbs.types.js';
|
|
19
|
+
|
|
20
|
+
void React;
|
|
21
|
+
afterEach(() => cleanup());
|
|
22
|
+
|
|
23
|
+
function makeTrail(n: number): BreadcrumbItem[] {
|
|
24
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
25
|
+
id: `c${i}`,
|
|
26
|
+
label: `Crumb ${i}`,
|
|
27
|
+
href: i === n - 1 ? undefined : `/c${i}`,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function withRenderCounter<P>(
|
|
32
|
+
Component: React.ComponentType<P>,
|
|
33
|
+
counterRef: { count: number }
|
|
34
|
+
): React.FC<P> {
|
|
35
|
+
return function Counted(props: P) {
|
|
36
|
+
counterRef.count++;
|
|
37
|
+
return <Component {...(props as P & object)} />;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('<Breadcrumbs> performance', () => {
|
|
42
|
+
it('mounts a 50-crumb trail under 50ms', () => {
|
|
43
|
+
const trail = makeTrail(50);
|
|
44
|
+
const t0 = performance.now();
|
|
45
|
+
render(<Breadcrumbs items={trail} />);
|
|
46
|
+
const t1 = performance.now();
|
|
47
|
+
expect(t1 - t0).toBeLessThan(50);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('mounts a 1000-crumb (collapsed) trail under 100ms', () => {
|
|
51
|
+
const trail = makeTrail(1000);
|
|
52
|
+
const t0 = performance.now();
|
|
53
|
+
render(<Breadcrumbs items={trail} maxItems={5} />);
|
|
54
|
+
const t1 = performance.now();
|
|
55
|
+
expect(t1 - t0).toBeLessThan(100);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('<Breadcrumbs> re-render budget', () => {
|
|
60
|
+
it('renders at most 2 times on mount', () => {
|
|
61
|
+
const counter = { count: 0 };
|
|
62
|
+
const Counted = withRenderCounter(Breadcrumbs, counter);
|
|
63
|
+
render(<Counted items={makeTrail(5)} />);
|
|
64
|
+
expect(counter.count).toBeLessThanOrEqual(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('re-renders exactly once per prop change to items', () => {
|
|
68
|
+
const counter = { count: 0 };
|
|
69
|
+
const Counted = withRenderCounter(Breadcrumbs, counter);
|
|
70
|
+
const a = makeTrail(3);
|
|
71
|
+
const b = makeTrail(4);
|
|
72
|
+
const c = makeTrail(5);
|
|
73
|
+
const { rerender } = render(<Counted items={a} />);
|
|
74
|
+
const baseline = counter.count;
|
|
75
|
+
rerender(<Counted items={b} />);
|
|
76
|
+
rerender(<Counted items={c} />);
|
|
77
|
+
// Two prop changes → up to two renders (plus tolerance).
|
|
78
|
+
expect(counter.count - baseline).toBeLessThanOrEqual(3);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does NOT re-render siblings when only Breadcrumbs items change', () => {
|
|
82
|
+
// Truncation memoization shouldn't leak through React.memo'd
|
|
83
|
+
// siblings, but at minimum it must not trigger sibling renders
|
|
84
|
+
// when the parent re-renders for an unrelated reason.
|
|
85
|
+
const sibCount = { count: 0 };
|
|
86
|
+
const Sibling = () => {
|
|
87
|
+
sibCount.count++;
|
|
88
|
+
return <div data-testid="sib" />;
|
|
89
|
+
};
|
|
90
|
+
const Tree = ({ items }: { items: BreadcrumbItem[] }) => (
|
|
91
|
+
<div>
|
|
92
|
+
<Breadcrumbs items={items} />
|
|
93
|
+
<Sibling />
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
const trail = makeTrail(5);
|
|
97
|
+
const { rerender } = render(<Tree items={trail} />);
|
|
98
|
+
const baseline = sibCount.count;
|
|
99
|
+
// Re-render the parent with the SAME items reference — Sibling
|
|
100
|
+
// should still re-render (parent re-rendered) but only ONCE per
|
|
101
|
+
// parent re-render, no extra renders triggered by Breadcrumbs.
|
|
102
|
+
rerender(<Tree items={trail} />);
|
|
103
|
+
rerender(<Tree items={trail} />);
|
|
104
|
+
expect(sibCount.count - baseline).toBeLessThanOrEqual(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('truncation logic is stable: same items + same maxItems => same DOM ids', () => {
|
|
108
|
+
// If `useMemo` is well-keyed, the rendered <li> elements (with
|
|
109
|
+
// stable keys from item.id) should be identical DOM nodes across
|
|
110
|
+
// unrelated re-renders. Detects an accidental "always new array"
|
|
111
|
+
// refactor that would invalidate keys.
|
|
112
|
+
const items = makeTrail(10);
|
|
113
|
+
const { rerender, container } = render(
|
|
114
|
+
<Breadcrumbs items={items} maxItems={4} />
|
|
115
|
+
);
|
|
116
|
+
const firstSnapshot = Array.from(container.querySelectorAll('li')).map(
|
|
117
|
+
(li) => li.outerHTML
|
|
118
|
+
);
|
|
119
|
+
rerender(<Breadcrumbs items={items} maxItems={4} />);
|
|
120
|
+
const secondSnapshot = Array.from(container.querySelectorAll('li')).map(
|
|
121
|
+
(li) => li.outerHTML
|
|
122
|
+
);
|
|
123
|
+
expect(secondSnapshot).toEqual(firstSnapshot);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
render,
|
|
6
|
+
screen,
|
|
7
|
+
fireEvent,
|
|
8
|
+
cleanup,
|
|
9
|
+
} from '@testing-library/react';
|
|
10
|
+
import { Breadcrumbs } from './Breadcrumbs.js';
|
|
11
|
+
import type { BreadcrumbItem } from './breadcrumbs.types.js';
|
|
12
|
+
|
|
13
|
+
// Keep the React import alive for the classic JSX transform — when this
|
|
14
|
+
// file is run in environments where the bundler tree-shakes
|
|
15
|
+
// `import * as React`, the JSX call sites would lose the global.
|
|
16
|
+
void React;
|
|
17
|
+
|
|
18
|
+
// Auto-cleanup is not reliably registered when test-library is loaded
|
|
19
|
+
// before vitest globals are wired up — register the unmount step
|
|
20
|
+
// explicitly so back-to-back `render()` calls don't accumulate trees.
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
cleanup();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const trail: BreadcrumbItem[] = [
|
|
26
|
+
{ id: 'home', label: 'Home', href: '/' },
|
|
27
|
+
{ id: 'settings', label: 'Settings', href: '/settings' },
|
|
28
|
+
{ id: 'profile', label: 'Profile' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
describe('<Breadcrumbs>', () => {
|
|
32
|
+
describe('basic rendering', () => {
|
|
33
|
+
it('renders a nav with role + accessible name', () => {
|
|
34
|
+
render(<Breadcrumbs items={trail} />);
|
|
35
|
+
const nav = screen.getByRole('navigation', { name: 'Breadcrumb' });
|
|
36
|
+
expect(nav).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders an <ol> with one <li> per item', () => {
|
|
40
|
+
const { container } = render(<Breadcrumbs items={trail} />);
|
|
41
|
+
expect(container.querySelectorAll('li')).toHaveLength(3);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns null for an empty items array', () => {
|
|
45
|
+
const { container } = render(<Breadcrumbs items={[]} />);
|
|
46
|
+
expect(container.firstChild).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('marks the last crumb as aria-current="page" by default', () => {
|
|
50
|
+
render(<Breadcrumbs items={trail} />);
|
|
51
|
+
const current = screen.getByText('Profile').closest('span');
|
|
52
|
+
expect(current?.getAttribute('aria-current')).toBe('page');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('honours an explicit `current: true` flag', () => {
|
|
56
|
+
const custom: BreadcrumbItem[] = [
|
|
57
|
+
{ id: 'a', label: 'A', href: '/a' },
|
|
58
|
+
{ id: 'b', label: 'B', current: true },
|
|
59
|
+
{ id: 'c', label: 'C', href: '/c' },
|
|
60
|
+
];
|
|
61
|
+
render(<Breadcrumbs items={custom} />);
|
|
62
|
+
const bElement = screen.getByText('B').closest('span');
|
|
63
|
+
expect(bElement?.getAttribute('aria-current')).toBe('page');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('separator', () => {
|
|
68
|
+
it('inserts the default `/` between crumbs', () => {
|
|
69
|
+
const { container } = render(<Breadcrumbs items={trail} />);
|
|
70
|
+
const separators = container.querySelectorAll('[aria-hidden="true"]');
|
|
71
|
+
// 2 separators between 3 crumbs.
|
|
72
|
+
expect(separators).toHaveLength(2);
|
|
73
|
+
expect(separators[0].textContent).toBe('/');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('accepts a custom separator (string)', () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<Breadcrumbs items={trail} separator=">" />
|
|
79
|
+
);
|
|
80
|
+
const separators = container.querySelectorAll('[aria-hidden="true"]');
|
|
81
|
+
expect(separators[0].textContent).toBe('>');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('accepts a custom separator (ReactNode)', () => {
|
|
85
|
+
render(
|
|
86
|
+
<Breadcrumbs
|
|
87
|
+
items={trail}
|
|
88
|
+
separator={<span data-testid="sep">»</span>}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
// Two separators ⇒ two custom nodes.
|
|
92
|
+
expect(screen.getAllByTestId('sep')).toHaveLength(2);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('navigation links', () => {
|
|
97
|
+
it('renders a non-current crumb with href as a real link', () => {
|
|
98
|
+
render(<Breadcrumbs items={trail} />);
|
|
99
|
+
const link = screen.getByRole('link', { name: 'Home' });
|
|
100
|
+
expect(link.getAttribute('href')).toBe('/');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('renders a crumb without href as a non-link span', () => {
|
|
104
|
+
const noHref: BreadcrumbItem[] = [
|
|
105
|
+
{ id: 'a', label: 'A' },
|
|
106
|
+
{ id: 'b', label: 'B' },
|
|
107
|
+
];
|
|
108
|
+
render(<Breadcrumbs items={noHref} />);
|
|
109
|
+
expect(screen.queryByRole('link')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('fires onClick on link crumbs', () => {
|
|
113
|
+
const onClick = vi.fn();
|
|
114
|
+
const itemsWithClick: BreadcrumbItem[] = [
|
|
115
|
+
{ id: 'a', label: 'A', href: '/a', onClick },
|
|
116
|
+
{ id: 'b', label: 'B' },
|
|
117
|
+
];
|
|
118
|
+
render(<Breadcrumbs items={itemsWithClick} />);
|
|
119
|
+
fireEvent.click(screen.getByText('A'));
|
|
120
|
+
expect(onClick).toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('renders a disabled crumb as a non-interactive span', () => {
|
|
124
|
+
const dis: BreadcrumbItem[] = [
|
|
125
|
+
{ id: 'a', label: 'A', href: '/a', disabled: true },
|
|
126
|
+
{ id: 'b', label: 'B' },
|
|
127
|
+
];
|
|
128
|
+
render(<Breadcrumbs items={dis} />);
|
|
129
|
+
// 'A' must not be a link (disabled overrides interactivity).
|
|
130
|
+
expect(screen.queryByRole('link', { name: 'A' })).toBeNull();
|
|
131
|
+
const span = screen.getByText('A').closest('span');
|
|
132
|
+
expect(span?.getAttribute('aria-disabled')).toBe('true');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('truncation', () => {
|
|
137
|
+
const long: BreadcrumbItem[] = Array.from({ length: 6 }, (_, i) => ({
|
|
138
|
+
id: `c${i}`,
|
|
139
|
+
label: `Crumb ${i}`,
|
|
140
|
+
href: i === 5 ? undefined : `/c${i}`,
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
it('collapses the middle when items > maxItems', () => {
|
|
144
|
+
const { container } = render(
|
|
145
|
+
<Breadcrumbs items={long} maxItems={3} />
|
|
146
|
+
);
|
|
147
|
+
// 1 (head) + 1 ellipsis + 1 (tail) = 3 <li>s.
|
|
148
|
+
expect(container.querySelectorAll('li')).toHaveLength(3);
|
|
149
|
+
expect(
|
|
150
|
+
container.querySelector('[aria-label="More breadcrumbs"]')
|
|
151
|
+
).toBeTruthy();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('respects itemsBeforeCollapse / itemsAfterCollapse', () => {
|
|
155
|
+
const { container } = render(
|
|
156
|
+
<Breadcrumbs
|
|
157
|
+
items={long}
|
|
158
|
+
maxItems={4}
|
|
159
|
+
itemsBeforeCollapse={2}
|
|
160
|
+
itemsAfterCollapse={2}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
// 2 head + 1 ellipsis + 2 tail = 5 <li>s.
|
|
164
|
+
// (maxItems is the visible-budget hint; once collapsed the
|
|
165
|
+
// before/after counts drive the final visible count.)
|
|
166
|
+
expect(container.querySelectorAll('li')).toHaveLength(5);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does NOT collapse when items.length <= maxItems', () => {
|
|
170
|
+
const { container } = render(
|
|
171
|
+
<Breadcrumbs items={trail} maxItems={10} />
|
|
172
|
+
);
|
|
173
|
+
expect(container.querySelectorAll('li')).toHaveLength(3);
|
|
174
|
+
expect(
|
|
175
|
+
container.querySelector('[aria-label="More breadcrumbs"]')
|
|
176
|
+
).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does NOT collapse when maxItems is 0 (default)', () => {
|
|
180
|
+
const { container } = render(<Breadcrumbs items={long} />);
|
|
181
|
+
expect(container.querySelectorAll('li')).toHaveLength(6);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('preserves the LAST crumb (current page) when collapsing', () => {
|
|
185
|
+
render(<Breadcrumbs items={long} maxItems={3} />);
|
|
186
|
+
expect(screen.getByText('Crumb 0')).toBeTruthy(); // first
|
|
187
|
+
expect(screen.getByText('Crumb 5')).toBeTruthy(); // last/current
|
|
188
|
+
expect(screen.queryByText('Crumb 2')).toBeNull(); // middle, collapsed
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('polymorphic link component', () => {
|
|
193
|
+
it('uses the provided linkComponent instead of <a>', () => {
|
|
194
|
+
const MyLink = vi.fn(
|
|
195
|
+
({ children, ...rest }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
|
196
|
+
<a data-testid="custom-link" {...rest}>
|
|
197
|
+
{children}
|
|
198
|
+
</a>
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
render(<Breadcrumbs items={trail} linkComponent={MyLink} />);
|
|
202
|
+
// Two interactive crumbs ⇒ MyLink called twice.
|
|
203
|
+
expect(MyLink).toHaveBeenCalledTimes(2);
|
|
204
|
+
expect(screen.getAllByTestId('custom-link')).toHaveLength(2);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('size variants', () => {
|
|
209
|
+
it('renders sm / md / lg without throwing', () => {
|
|
210
|
+
const sizes = ['sm', 'md', 'lg'] as const;
|
|
211
|
+
sizes.forEach((s) => {
|
|
212
|
+
const { unmount } = render(
|
|
213
|
+
<Breadcrumbs items={trail} size={s} />
|
|
214
|
+
);
|
|
215
|
+
expect(
|
|
216
|
+
screen.getByRole('navigation', { name: 'Breadcrumb' })
|
|
217
|
+
).toBeTruthy();
|
|
218
|
+
unmount();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('aria-label override', () => {
|
|
224
|
+
it('applies a custom aria-label to the nav landmark', () => {
|
|
225
|
+
render(<Breadcrumbs items={trail} ariaLabel="Sentiero corrente" />);
|
|
226
|
+
expect(
|
|
227
|
+
screen.getByRole('navigation', { name: 'Sentiero corrente' })
|
|
228
|
+
).toBeTruthy();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|