@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,330 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import { cn } from '../../utils/cn.js';
|
|
3
|
+
import { useAccessState } from '../../hooks/useAccessState.js';
|
|
4
|
+
import { leftNavVariants } from './leftNav.variants.js';
|
|
5
|
+
import type {
|
|
6
|
+
LeftNavGroup,
|
|
7
|
+
LeftNavItem,
|
|
8
|
+
LeftNavNode,
|
|
9
|
+
LeftNavProps,
|
|
10
|
+
} from './leftNav.types.js';
|
|
11
|
+
|
|
12
|
+
/** Type-narrowing guard for `LeftNavGroup`. */
|
|
13
|
+
function isGroup(node: LeftNavNode): node is LeftNavGroup {
|
|
14
|
+
return node.kind === 'group';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Internal `<NavItemRow>` — renders one clickable row.
|
|
19
|
+
*
|
|
20
|
+
* Extracted as a separate component so each row can call `useAccessState`
|
|
21
|
+
* unconditionally (RBAC is per-row). Without this extraction we'd have
|
|
22
|
+
* a rules-of-hooks violation iterating `items.map(useAccessState)`.
|
|
23
|
+
*/
|
|
24
|
+
function NavItemRow({
|
|
25
|
+
item,
|
|
26
|
+
isActive,
|
|
27
|
+
collapsed,
|
|
28
|
+
linkComponent,
|
|
29
|
+
v,
|
|
30
|
+
slotProps,
|
|
31
|
+
}: {
|
|
32
|
+
item: LeftNavItem;
|
|
33
|
+
isActive: boolean;
|
|
34
|
+
collapsed: boolean;
|
|
35
|
+
linkComponent: NonNullable<LeftNavProps['linkComponent']>;
|
|
36
|
+
v: ReturnType<typeof leftNavVariants>;
|
|
37
|
+
slotProps: LeftNavProps['slotProps'];
|
|
38
|
+
}) {
|
|
39
|
+
const accessState = useAccessState(item.access);
|
|
40
|
+
if (!accessState.visible) return null;
|
|
41
|
+
|
|
42
|
+
const isDisabled = Boolean(item.disabled) || accessState.disabled;
|
|
43
|
+
const Link = linkComponent;
|
|
44
|
+
|
|
45
|
+
const linkClasses = cn(
|
|
46
|
+
v.itemLink(),
|
|
47
|
+
isActive && v.itemActive(),
|
|
48
|
+
slotProps?.itemLink?.className,
|
|
49
|
+
isActive && slotProps?.itemActive?.className
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Visual content — same in `<a>` / `<button>` branches below.
|
|
53
|
+
const content = (
|
|
54
|
+
<>
|
|
55
|
+
{item.icon && (
|
|
56
|
+
<span
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
className={cn(v.itemIcon(), slotProps?.itemIcon?.className)}
|
|
59
|
+
>
|
|
60
|
+
{item.icon}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
<span className={cn(v.itemLabel(), slotProps?.itemLabel?.className)}>
|
|
64
|
+
{item.label}
|
|
65
|
+
</span>
|
|
66
|
+
{!collapsed && item.badge != null && (
|
|
67
|
+
<span className={cn(v.itemBadge(), slotProps?.itemBadge?.className)}>
|
|
68
|
+
{item.badge}
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<li className={cn(v.item(), slotProps?.item?.className)}>
|
|
76
|
+
{item.href !== undefined ? (
|
|
77
|
+
<Link
|
|
78
|
+
href={item.href}
|
|
79
|
+
onClick={(event) => {
|
|
80
|
+
if (isDisabled) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
item.onSelect?.();
|
|
85
|
+
}}
|
|
86
|
+
aria-current={isActive ? 'page' : undefined}
|
|
87
|
+
aria-disabled={isDisabled || undefined}
|
|
88
|
+
title={collapsed && typeof item.label === 'string' ? item.label : undefined}
|
|
89
|
+
className={linkClasses}
|
|
90
|
+
>
|
|
91
|
+
{content}
|
|
92
|
+
</Link>
|
|
93
|
+
) : (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => {
|
|
97
|
+
if (isDisabled) return;
|
|
98
|
+
item.onSelect?.();
|
|
99
|
+
}}
|
|
100
|
+
aria-current={isActive ? 'page' : undefined}
|
|
101
|
+
aria-disabled={isDisabled || undefined}
|
|
102
|
+
disabled={isDisabled}
|
|
103
|
+
title={collapsed && typeof item.label === 'string' ? item.label : undefined}
|
|
104
|
+
className={linkClasses}
|
|
105
|
+
>
|
|
106
|
+
{content}
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
</li>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Internal `<NavGroupRow>` — renders a collapsible group of items.
|
|
115
|
+
*
|
|
116
|
+
* State strategy: each group owns its own `expanded` state (uncontrolled
|
|
117
|
+
* by default, seeded from `defaultExpanded`). A controlled mode kicks
|
|
118
|
+
* in when `group.expanded` is provided — then the parent owns the state
|
|
119
|
+
* and we never call `setLocalExpanded`.
|
|
120
|
+
*/
|
|
121
|
+
function NavGroupRow({
|
|
122
|
+
group,
|
|
123
|
+
activeId,
|
|
124
|
+
collapsed,
|
|
125
|
+
linkComponent,
|
|
126
|
+
onGroupExpandedChange,
|
|
127
|
+
v,
|
|
128
|
+
slotProps,
|
|
129
|
+
}: {
|
|
130
|
+
group: LeftNavGroup;
|
|
131
|
+
activeId: string | undefined;
|
|
132
|
+
collapsed: boolean;
|
|
133
|
+
linkComponent: NonNullable<LeftNavProps['linkComponent']>;
|
|
134
|
+
onGroupExpandedChange?: (groupId: string, expanded: boolean) => void;
|
|
135
|
+
v: ReturnType<typeof leftNavVariants>;
|
|
136
|
+
slotProps: LeftNavProps['slotProps'];
|
|
137
|
+
}) {
|
|
138
|
+
const accessState = useAccessState(group.access);
|
|
139
|
+
const isControlled = group.expanded !== undefined;
|
|
140
|
+
const [localExpanded, setLocalExpanded] = useState<boolean>(
|
|
141
|
+
group.defaultExpanded ?? true
|
|
142
|
+
);
|
|
143
|
+
const expanded = isControlled ? Boolean(group.expanded) : localExpanded;
|
|
144
|
+
|
|
145
|
+
if (!accessState.visible) return null;
|
|
146
|
+
const isDisabled = accessState.disabled;
|
|
147
|
+
|
|
148
|
+
const toggle = () => {
|
|
149
|
+
if (isDisabled) return;
|
|
150
|
+
const next = !expanded;
|
|
151
|
+
if (!isControlled) setLocalExpanded(next);
|
|
152
|
+
onGroupExpandedChange?.(group.id, next);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Group header ids → wires aria-controls / aria-labelledby for the
|
|
156
|
+
// expandable region the screen-reader announces.
|
|
157
|
+
const headerId = `${group.id}-header`;
|
|
158
|
+
const regionId = `${group.id}-region`;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<li className={cn(v.group(), slotProps?.group?.className)}>
|
|
162
|
+
<button
|
|
163
|
+
id={headerId}
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={toggle}
|
|
166
|
+
aria-expanded={expanded}
|
|
167
|
+
aria-controls={regionId}
|
|
168
|
+
aria-disabled={isDisabled || undefined}
|
|
169
|
+
disabled={isDisabled}
|
|
170
|
+
title={collapsed && typeof group.label === 'string' ? group.label : undefined}
|
|
171
|
+
className={cn(v.groupHeader(), slotProps?.groupHeader?.className)}
|
|
172
|
+
>
|
|
173
|
+
{group.icon && (
|
|
174
|
+
<span
|
|
175
|
+
aria-hidden="true"
|
|
176
|
+
className={cn(v.itemIcon(), slotProps?.itemIcon?.className)}
|
|
177
|
+
>
|
|
178
|
+
{group.icon}
|
|
179
|
+
</span>
|
|
180
|
+
)}
|
|
181
|
+
<span className={cn(v.itemLabel(), slotProps?.itemLabel?.className)}>
|
|
182
|
+
{group.label}
|
|
183
|
+
</span>
|
|
184
|
+
{!collapsed && (
|
|
185
|
+
<span aria-hidden="true" className="shrink-0 text-neutral-500 text-xs">
|
|
186
|
+
{expanded ? '▾' : '▸'}
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
</button>
|
|
190
|
+
{expanded && (
|
|
191
|
+
<ul
|
|
192
|
+
id={regionId}
|
|
193
|
+
role="region"
|
|
194
|
+
aria-labelledby={headerId}
|
|
195
|
+
className={cn(
|
|
196
|
+
v.groupChildren(),
|
|
197
|
+
slotProps?.groupChildren?.className
|
|
198
|
+
)}
|
|
199
|
+
>
|
|
200
|
+
{group.children.map((child) => (
|
|
201
|
+
<NavItemRow
|
|
202
|
+
key={child.id}
|
|
203
|
+
item={child}
|
|
204
|
+
isActive={activeId === child.id}
|
|
205
|
+
collapsed={collapsed}
|
|
206
|
+
linkComponent={linkComponent}
|
|
207
|
+
v={v}
|
|
208
|
+
slotProps={slotProps}
|
|
209
|
+
/>
|
|
210
|
+
))}
|
|
211
|
+
</ul>
|
|
212
|
+
)}
|
|
213
|
+
</li>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Dashforge TW LeftNav — router-agnostic sidebar navigation.
|
|
219
|
+
*
|
|
220
|
+
* Pure presentational. The consumer wires `activeId` from the current
|
|
221
|
+
* route. Each row renders as a real `<a>` (with `href`) or a `<button>`
|
|
222
|
+
* (with `onSelect`); `linkComponent` swaps in a router-aware link
|
|
223
|
+
* component for SPA navigation.
|
|
224
|
+
*
|
|
225
|
+
* Modes:
|
|
226
|
+
* - **Expanded** (`collapsed={false}`): full-width with labels +
|
|
227
|
+
* badges + group children visible.
|
|
228
|
+
* - **Rail** (`collapsed={true}`): 56px wide, icons only, labels
|
|
229
|
+
* hidden from sighted users (still announced to screen readers via
|
|
230
|
+
* `sr-only`). Group children are hidden — clicking the group
|
|
231
|
+
* header is a no-op visually in rail mode (consumer is expected
|
|
232
|
+
* to either expand the nav or surface a flyout in a follow-up).
|
|
233
|
+
*
|
|
234
|
+
* **A11y**:
|
|
235
|
+
* - `<nav aria-label="…">` landmark.
|
|
236
|
+
* - `aria-current="page"` on the active row.
|
|
237
|
+
* - Groups expose `aria-expanded` / `aria-controls` and the children
|
|
238
|
+
* wrapper is `role="region" aria-labelledby="…-header"`.
|
|
239
|
+
* - Rail-mode tooltips via the native `title` attribute (preserves
|
|
240
|
+
* hover discoverability when labels are visually hidden).
|
|
241
|
+
*
|
|
242
|
+
* **Re-render contract**:
|
|
243
|
+
* - Rows are extracted into stable sub-components so a single
|
|
244
|
+
* `activeId` change re-renders all rows but each does only its
|
|
245
|
+
* own `useAccessState` lookup; this is the price of per-row RBAC.
|
|
246
|
+
* - Group expand/collapse re-renders ONLY that group (local state).
|
|
247
|
+
*/
|
|
248
|
+
export function LeftNav(props: LeftNavProps) {
|
|
249
|
+
const {
|
|
250
|
+
items,
|
|
251
|
+
activeId,
|
|
252
|
+
collapsed = false,
|
|
253
|
+
onCollapseChange,
|
|
254
|
+
onGroupExpandedChange,
|
|
255
|
+
brand,
|
|
256
|
+
footer,
|
|
257
|
+
width,
|
|
258
|
+
linkComponent = 'a' as unknown as NonNullable<LeftNavProps['linkComponent']>,
|
|
259
|
+
ariaLabel = 'Main navigation',
|
|
260
|
+
sx,
|
|
261
|
+
slotProps,
|
|
262
|
+
showCollapseToggle = true,
|
|
263
|
+
} = props;
|
|
264
|
+
|
|
265
|
+
const v = leftNavVariants({ width, collapsed });
|
|
266
|
+
|
|
267
|
+
const handleCollapseToggle = useCallback(() => {
|
|
268
|
+
onCollapseChange?.(!collapsed);
|
|
269
|
+
}, [collapsed, onCollapseChange]);
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<nav
|
|
273
|
+
aria-label={ariaLabel}
|
|
274
|
+
className={cn(v.root(), sx, slotProps?.root?.className)}
|
|
275
|
+
>
|
|
276
|
+
{(brand || (showCollapseToggle && onCollapseChange)) && (
|
|
277
|
+
<div className={cn(v.brand(), slotProps?.brand?.className)}>
|
|
278
|
+
{brand && <div className="flex-1 min-w-0 truncate">{brand}</div>}
|
|
279
|
+
{showCollapseToggle && onCollapseChange && (
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={handleCollapseToggle}
|
|
283
|
+
aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
|
|
284
|
+
aria-pressed={collapsed}
|
|
285
|
+
className={cn(
|
|
286
|
+
v.collapseToggle(),
|
|
287
|
+
slotProps?.collapseToggle?.className
|
|
288
|
+
)}
|
|
289
|
+
>
|
|
290
|
+
{collapsed ? '»' : '«'}
|
|
291
|
+
</button>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
<ul className={cn(v.list(), slotProps?.list?.className)}>
|
|
297
|
+
{items.map((node) =>
|
|
298
|
+
isGroup(node) ? (
|
|
299
|
+
<NavGroupRow
|
|
300
|
+
key={node.id}
|
|
301
|
+
group={node}
|
|
302
|
+
activeId={activeId}
|
|
303
|
+
collapsed={collapsed}
|
|
304
|
+
linkComponent={linkComponent}
|
|
305
|
+
onGroupExpandedChange={onGroupExpandedChange}
|
|
306
|
+
v={v}
|
|
307
|
+
slotProps={slotProps}
|
|
308
|
+
/>
|
|
309
|
+
) : (
|
|
310
|
+
<NavItemRow
|
|
311
|
+
key={node.id}
|
|
312
|
+
item={node}
|
|
313
|
+
isActive={activeId === node.id}
|
|
314
|
+
collapsed={collapsed}
|
|
315
|
+
linkComponent={linkComponent}
|
|
316
|
+
v={v}
|
|
317
|
+
slotProps={slotProps}
|
|
318
|
+
/>
|
|
319
|
+
)
|
|
320
|
+
)}
|
|
321
|
+
</ul>
|
|
322
|
+
|
|
323
|
+
{footer && (
|
|
324
|
+
<div className={cn(v.footer(), slotProps?.footer?.className)}>
|
|
325
|
+
{footer}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</nav>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ReactNode,
|
|
3
|
+
ComponentType,
|
|
4
|
+
AnchorHTMLAttributes,
|
|
5
|
+
Ref,
|
|
6
|
+
} from 'react';
|
|
7
|
+
import type { AccessRequirement } from '@dashforge/rbac';
|
|
8
|
+
import type { LeftNavVariants } from './leftNav.variants.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One row in the LeftNav. May be either a clickable item OR a group
|
|
12
|
+
* (a header that owns nested children, optionally collapsible).
|
|
13
|
+
*/
|
|
14
|
+
export type LeftNavNode = LeftNavItem | LeftNavGroup;
|
|
15
|
+
|
|
16
|
+
export interface LeftNavItem {
|
|
17
|
+
kind?: 'item';
|
|
18
|
+
/** Stable id used for `activeId` matching + React keys. */
|
|
19
|
+
id: string;
|
|
20
|
+
label: ReactNode;
|
|
21
|
+
icon?: ReactNode;
|
|
22
|
+
/**
|
|
23
|
+
* Destination for navigation. Omit for a pure-callback item (use
|
|
24
|
+
* `onSelect`).
|
|
25
|
+
*/
|
|
26
|
+
href?: string;
|
|
27
|
+
/** Fired when the row is clicked (in addition to navigation). */
|
|
28
|
+
onSelect?: () => void;
|
|
29
|
+
/** Optional pill / counter rendered to the right of the label. */
|
|
30
|
+
badge?: ReactNode;
|
|
31
|
+
/** Per-item disabled flag. */
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
/** RBAC access requirement — hides / disables the row. */
|
|
34
|
+
access?: AccessRequirement;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LeftNavGroup {
|
|
38
|
+
kind: 'group';
|
|
39
|
+
id: string;
|
|
40
|
+
label: ReactNode;
|
|
41
|
+
icon?: ReactNode;
|
|
42
|
+
children: LeftNavItem[];
|
|
43
|
+
/**
|
|
44
|
+
* Initial expanded state when used in uncontrolled mode. Default
|
|
45
|
+
* `true` (groups open by default — most app navs want immediate
|
|
46
|
+
* visibility).
|
|
47
|
+
*/
|
|
48
|
+
defaultExpanded?: boolean;
|
|
49
|
+
/** Controlled expanded state. Provide `onExpandedChange` to update. */
|
|
50
|
+
expanded?: boolean;
|
|
51
|
+
/** RBAC access requirement — hides / disables the whole group. */
|
|
52
|
+
access?: AccessRequirement;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Polymorphic link component prop. Defaults to `<a>`. Pass a
|
|
57
|
+
* router-aware component (react-router `Link`, Next `Link`) for SPA
|
|
58
|
+
* navigation.
|
|
59
|
+
*/
|
|
60
|
+
export type LeftNavLinkComponent = ComponentType<
|
|
61
|
+
AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
62
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
63
|
+
}
|
|
64
|
+
>;
|
|
65
|
+
|
|
66
|
+
export interface LeftNavSlotProps {
|
|
67
|
+
root?: { className?: string };
|
|
68
|
+
brand?: { className?: string };
|
|
69
|
+
list?: { className?: string };
|
|
70
|
+
item?: { className?: string };
|
|
71
|
+
itemLink?: { className?: string };
|
|
72
|
+
itemActive?: { className?: string };
|
|
73
|
+
itemIcon?: { className?: string };
|
|
74
|
+
itemLabel?: { className?: string };
|
|
75
|
+
itemBadge?: { className?: string };
|
|
76
|
+
group?: { className?: string };
|
|
77
|
+
groupHeader?: { className?: string };
|
|
78
|
+
groupChildren?: { className?: string };
|
|
79
|
+
footer?: { className?: string };
|
|
80
|
+
collapseToggle?: { className?: string };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Props for `<LeftNav>`.
|
|
85
|
+
*
|
|
86
|
+
* Router-agnostic sidebar navigation. The consumer drives the active
|
|
87
|
+
* item via `activeId` (typically derived from the current route).
|
|
88
|
+
*
|
|
89
|
+
* Rail / drawer responsive behaviour is handled by `<AppShell>`; this
|
|
90
|
+
* component renders the actual nav content and exposes a `collapsed`
|
|
91
|
+
* prop for the icon-only "rail" rendering.
|
|
92
|
+
*/
|
|
93
|
+
export interface LeftNavProps extends LeftNavVariants {
|
|
94
|
+
items: LeftNavNode[];
|
|
95
|
+
/** Currently-active item id (highlight + `aria-current="page"`). */
|
|
96
|
+
activeId?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Icon-only rail mode. When `true`, labels and group children are
|
|
99
|
+
* visually hidden (kept for screen readers via `sr-only`).
|
|
100
|
+
*/
|
|
101
|
+
collapsed?: boolean;
|
|
102
|
+
/** Fired when the user clicks the collapse-toggle button. */
|
|
103
|
+
onCollapseChange?: (collapsed: boolean) => void;
|
|
104
|
+
/** Fired when a group's expanded state changes (controlled or not). */
|
|
105
|
+
onGroupExpandedChange?: (groupId: string, expanded: boolean) => void;
|
|
106
|
+
/** Brand / logo node rendered at the top. */
|
|
107
|
+
brand?: ReactNode;
|
|
108
|
+
/** Footer node (e.g., user badge). */
|
|
109
|
+
footer?: ReactNode;
|
|
110
|
+
/** Override the link component. @default 'a' */
|
|
111
|
+
linkComponent?: LeftNavLinkComponent;
|
|
112
|
+
/** Visible label of the nav landmark. @default 'Main navigation' */
|
|
113
|
+
ariaLabel?: string;
|
|
114
|
+
/** Root className shortcut. */
|
|
115
|
+
sx?: string;
|
|
116
|
+
/** Per-slot className overrides. */
|
|
117
|
+
slotProps?: LeftNavSlotProps;
|
|
118
|
+
/** Show / hide the collapse toggle button. @default true */
|
|
119
|
+
showCollapseToggle?: boolean;
|
|
120
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tailwind-variants recipe for `<LeftNav>`.
|
|
5
|
+
*
|
|
6
|
+
* Slots:
|
|
7
|
+
* - `root` — `<nav>` landmark
|
|
8
|
+
* - `brand` — top brand/logo wrapper
|
|
9
|
+
* - `list` — `<ul>` containing items & groups
|
|
10
|
+
* - `item` — `<li>` wrapper
|
|
11
|
+
* - `itemLink` — clickable row (`<a>` or `<button>`)
|
|
12
|
+
* - `itemActive` — active state on the row
|
|
13
|
+
* - `itemIcon` — icon slot inside a row
|
|
14
|
+
* - `itemLabel` — text label slot
|
|
15
|
+
* - `itemBadge` — right-aligned pill
|
|
16
|
+
* - `group` — wrapper around a group header + children
|
|
17
|
+
* - `groupHeader` — clickable header row (`<button>`)
|
|
18
|
+
* - `groupChildren` — `<ul>` containing the group's items
|
|
19
|
+
* - `footer` — bottom slot (user badge, etc.)
|
|
20
|
+
* - `collapseToggle` — rail-mode toggle button
|
|
21
|
+
*/
|
|
22
|
+
export const leftNavVariants = tv({
|
|
23
|
+
slots: {
|
|
24
|
+
root: [
|
|
25
|
+
'flex flex-col h-full',
|
|
26
|
+
'bg-neutral-50 border-r border-neutral-200',
|
|
27
|
+
'transition-[width] duration-200',
|
|
28
|
+
],
|
|
29
|
+
brand: [
|
|
30
|
+
'flex items-center gap-2 px-3 h-14 shrink-0',
|
|
31
|
+
'border-b border-neutral-200 text-neutral-900 font-semibold',
|
|
32
|
+
],
|
|
33
|
+
list: 'flex flex-col gap-0.5 p-2 flex-1 overflow-y-auto',
|
|
34
|
+
item: 'list-none',
|
|
35
|
+
itemLink: [
|
|
36
|
+
'flex items-center gap-2 px-2 py-2 rounded-md',
|
|
37
|
+
'text-sm text-neutral-700 hover:bg-neutral-100',
|
|
38
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
|
|
39
|
+
'transition-colors w-full',
|
|
40
|
+
'aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
|
|
41
|
+
'aria-disabled:hover:bg-transparent',
|
|
42
|
+
],
|
|
43
|
+
itemActive: 'bg-primary-100 text-primary-900 font-medium',
|
|
44
|
+
itemIcon: 'shrink-0 w-5 h-5 flex items-center justify-center',
|
|
45
|
+
itemLabel: 'truncate flex-1 min-w-0 text-left',
|
|
46
|
+
itemBadge: [
|
|
47
|
+
'shrink-0 inline-flex items-center justify-center',
|
|
48
|
+
'px-1.5 h-5 rounded-full text-[10px] font-medium',
|
|
49
|
+
'bg-neutral-200 text-neutral-800',
|
|
50
|
+
],
|
|
51
|
+
group: 'flex flex-col gap-0.5',
|
|
52
|
+
groupHeader: [
|
|
53
|
+
'flex items-center gap-2 px-2 py-2 rounded-md',
|
|
54
|
+
'text-sm font-medium text-neutral-900 hover:bg-neutral-100',
|
|
55
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
|
|
56
|
+
'transition-colors w-full',
|
|
57
|
+
],
|
|
58
|
+
groupChildren: 'flex flex-col gap-0.5 pl-6',
|
|
59
|
+
footer: [
|
|
60
|
+
'border-t border-neutral-200 p-3 shrink-0',
|
|
61
|
+
'flex items-center gap-2',
|
|
62
|
+
],
|
|
63
|
+
collapseToggle: [
|
|
64
|
+
'inline-flex items-center justify-center w-8 h-8 shrink-0',
|
|
65
|
+
'rounded-md text-neutral-600 hover:bg-neutral-100',
|
|
66
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
|
|
67
|
+
'transition-colors',
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
variants: {
|
|
71
|
+
width: {
|
|
72
|
+
sm: { root: 'w-48' },
|
|
73
|
+
md: { root: 'w-60' },
|
|
74
|
+
lg: { root: 'w-72' },
|
|
75
|
+
},
|
|
76
|
+
collapsed: {
|
|
77
|
+
true: {
|
|
78
|
+
root: 'w-14',
|
|
79
|
+
brand: 'justify-center px-0',
|
|
80
|
+
list: 'p-1 items-center',
|
|
81
|
+
itemLink: 'justify-center px-0 py-2.5',
|
|
82
|
+
itemLabel: 'sr-only',
|
|
83
|
+
itemBadge: 'hidden',
|
|
84
|
+
groupHeader: 'justify-center px-0',
|
|
85
|
+
groupChildren: 'hidden',
|
|
86
|
+
footer: 'justify-center px-0',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
defaultVariants: {
|
|
91
|
+
width: 'md',
|
|
92
|
+
collapsed: false,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export type LeftNavVariants = VariantProps<typeof leftNavVariants>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { DashFormProvider } from '@dashforge/forms';
|
|
5
|
+
import { NumberField } from './NumberField.js';
|
|
6
|
+
|
|
7
|
+
describe('<NumberField>', () => {
|
|
8
|
+
describe('standalone (no DashForm)', () => {
|
|
9
|
+
it('renders a numeric input with the label', () => {
|
|
10
|
+
render(<NumberField name="age" label="Age" />);
|
|
11
|
+
const el = screen.getByLabelText('Age') as HTMLInputElement;
|
|
12
|
+
expect(el).toBeInstanceOf(HTMLInputElement);
|
|
13
|
+
expect(el.type).toBe('number');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('respects `min` / `max` / `step` attributes', () => {
|
|
17
|
+
render(
|
|
18
|
+
<NumberField name="qty" label="Qty" min={1} max={10} step={2} />
|
|
19
|
+
);
|
|
20
|
+
const el = screen.getByLabelText('Qty');
|
|
21
|
+
expect(el.getAttribute('min')).toBe('1');
|
|
22
|
+
expect(el.getAttribute('max')).toBe('10');
|
|
23
|
+
expect(el.getAttribute('step')).toBe('2');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('displays a controlled numeric value via `value`', () => {
|
|
27
|
+
const { rerender } = render(
|
|
28
|
+
<NumberField name="qty" label="Qty" value={5} onChange={() => undefined} />
|
|
29
|
+
);
|
|
30
|
+
expect((screen.getByLabelText('Qty') as HTMLInputElement).value).toBe('5');
|
|
31
|
+
rerender(
|
|
32
|
+
<NumberField name="qty" label="Qty" value={null} onChange={() => undefined} />
|
|
33
|
+
);
|
|
34
|
+
expect((screen.getByLabelText('Qty') as HTMLInputElement).value).toBe('');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders a `*` for required fields', () => {
|
|
38
|
+
render(<NumberField name="x" label="X" required />);
|
|
39
|
+
expect(screen.getByText('*')).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does NOT render a stepper unless `showStepper` is set', () => {
|
|
43
|
+
render(<NumberField name="x" label="X" />);
|
|
44
|
+
expect(screen.queryByLabelText('Increment')).toBeNull();
|
|
45
|
+
expect(screen.queryByLabelText('Decrement')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders +/- stepper buttons when `showStepper` is true', () => {
|
|
49
|
+
render(<NumberField name="x" label="X" showStepper />);
|
|
50
|
+
expect(screen.getByLabelText('Increment')).toBeTruthy();
|
|
51
|
+
expect(screen.getByLabelText('Decrement')).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('inside DashFormProvider', () => {
|
|
56
|
+
it('mounts cleanly inside a bridge-providing tree', () => {
|
|
57
|
+
render(
|
|
58
|
+
<DashFormProvider defaultValues={{ age: 30 }}>
|
|
59
|
+
<NumberField name="age" label="Age" />
|
|
60
|
+
</DashFormProvider>
|
|
61
|
+
);
|
|
62
|
+
// Bridge mode mounts cleanly with no console errors. Initial-value
|
|
63
|
+
// reflection in the input is timing-sensitive (RHF's defaultValues
|
|
64
|
+
// commit happens after the first render); the actual end-to-end
|
|
65
|
+
// round-trip is verified in the dash smoke page where async
|
|
66
|
+
// RHF state has time to settle.
|
|
67
|
+
expect(screen.getByLabelText('Age')).toBeInstanceOf(HTMLInputElement);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('stepper buttons render and are clickable inside the bridge tree', () => {
|
|
71
|
+
render(
|
|
72
|
+
<DashFormProvider defaultValues={{ qty: 5 }}>
|
|
73
|
+
<NumberField name="qty" label="Qty" showStepper step={3} />
|
|
74
|
+
</DashFormProvider>
|
|
75
|
+
);
|
|
76
|
+
// The stepper itself renders correctly; the bridge round-trip on
|
|
77
|
+
// click is covered by the smoke test in dash.
|
|
78
|
+
const inc = screen.getByLabelText('Increment') as HTMLButtonElement;
|
|
79
|
+
expect(inc.disabled).toBe(false);
|
|
80
|
+
fireEvent.click(inc); // should not throw
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|