@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,85 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tailwind-variants recipe for `<Textarea>` with 7 slots.
|
|
5
|
+
*
|
|
6
|
+
* Sibling of `<TextField>` but renders a multi-line `<textarea>` element
|
|
7
|
+
* instead of a single-line `<input>`. Shares the same slot taxonomy +
|
|
8
|
+
* variant axes so consumers can swap the two without rewriting overrides.
|
|
9
|
+
*/
|
|
10
|
+
export const textareaVariants = tv({
|
|
11
|
+
slots: {
|
|
12
|
+
root: 'flex',
|
|
13
|
+
label: 'block text-sm font-medium text-neutral-900 mb-1',
|
|
14
|
+
requiredMark: 'text-danger-500 ml-0.5',
|
|
15
|
+
inputWrapper: [
|
|
16
|
+
'relative flex',
|
|
17
|
+
// bg-neutral-50 inverts via @dashforge/tw-tokens — dark-mode aware.
|
|
18
|
+
'rounded-md border bg-neutral-50',
|
|
19
|
+
'border-neutral-300',
|
|
20
|
+
'transition-colors',
|
|
21
|
+
'focus-within:border-primary-500 focus-within:ring-2 focus-within:ring-primary-500/30',
|
|
22
|
+
],
|
|
23
|
+
input: [
|
|
24
|
+
'block w-full bg-transparent outline-none resize-y',
|
|
25
|
+
'text-neutral-900 placeholder:text-neutral-400',
|
|
26
|
+
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
27
|
+
'read-only:cursor-default',
|
|
28
|
+
],
|
|
29
|
+
helperText: 'mt-1 text-sm text-neutral-600',
|
|
30
|
+
errorText: 'mt-1 text-sm text-danger-600',
|
|
31
|
+
},
|
|
32
|
+
variants: {
|
|
33
|
+
size: {
|
|
34
|
+
sm: {
|
|
35
|
+
inputWrapper: 'px-2 py-1.5',
|
|
36
|
+
input: 'text-sm',
|
|
37
|
+
},
|
|
38
|
+
md: {
|
|
39
|
+
inputWrapper: 'px-3 py-2',
|
|
40
|
+
input: 'text-base',
|
|
41
|
+
},
|
|
42
|
+
lg: {
|
|
43
|
+
inputWrapper: 'px-4 py-3',
|
|
44
|
+
input: 'text-lg',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
layout: {
|
|
48
|
+
stacked: { root: 'flex-col' },
|
|
49
|
+
inline: { root: 'flex-row items-start gap-3', label: 'mb-0 pt-2 whitespace-nowrap shrink-0' },
|
|
50
|
+
},
|
|
51
|
+
error: {
|
|
52
|
+
true: {
|
|
53
|
+
inputWrapper:
|
|
54
|
+
'border-danger-500 focus-within:border-danger-500 focus-within:ring-danger-500/30',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
fullWidth: {
|
|
58
|
+
true: {
|
|
59
|
+
root: 'w-full',
|
|
60
|
+
inputWrapper: 'w-full',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
disabled: {
|
|
64
|
+
true: {
|
|
65
|
+
inputWrapper: 'opacity-60 cursor-not-allowed bg-neutral-200',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
resize: {
|
|
69
|
+
none: { input: 'resize-none' },
|
|
70
|
+
vertical: { input: 'resize-y' },
|
|
71
|
+
horizontal: { input: 'resize-x' },
|
|
72
|
+
both: { input: 'resize' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
defaultVariants: {
|
|
76
|
+
size: 'md',
|
|
77
|
+
layout: 'stacked',
|
|
78
|
+
error: false,
|
|
79
|
+
fullWidth: false,
|
|
80
|
+
disabled: false,
|
|
81
|
+
resize: 'vertical',
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export type TextareaVariants = VariantProps<typeof textareaVariants>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
4
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
5
|
+
import { TopBar } from './TopBar.js';
|
|
6
|
+
|
|
7
|
+
void React;
|
|
8
|
+
afterEach(() => cleanup());
|
|
9
|
+
|
|
10
|
+
describe('<TopBar>', () => {
|
|
11
|
+
it('renders as a banner landmark by default (<header>)', () => {
|
|
12
|
+
render(<TopBar end={<span>x</span>} />);
|
|
13
|
+
expect(screen.getByRole('banner')).toBeTruthy();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('renders as a plain <div> when asDiv is true', () => {
|
|
17
|
+
const { container } = render(<TopBar end={<span>x</span>} asDiv />);
|
|
18
|
+
expect(container.querySelector('header')).toBeNull();
|
|
19
|
+
expect(container.querySelector('div')).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders the start slot', () => {
|
|
23
|
+
render(<TopBar start={<button>menu</button>} />);
|
|
24
|
+
expect(screen.getByRole('button', { name: 'menu' })).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders the center slot', () => {
|
|
28
|
+
render(<TopBar center={<span data-testid="c">page</span>} />);
|
|
29
|
+
expect(screen.getByTestId('c')).toBeTruthy();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders the end slot', () => {
|
|
33
|
+
render(<TopBar end={<button>user</button>} />);
|
|
34
|
+
expect(screen.getByRole('button', { name: 'user' })).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders children as the center slot when `center` is not provided', () => {
|
|
38
|
+
render(
|
|
39
|
+
<TopBar>
|
|
40
|
+
<span data-testid="child">title</span>
|
|
41
|
+
</TopBar>
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByTestId('child')).toBeTruthy();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('prefers explicit `center` over children', () => {
|
|
47
|
+
render(
|
|
48
|
+
<TopBar center={<span data-testid="explicit-center">c</span>}>
|
|
49
|
+
<span data-testid="child">should-not-render</span>
|
|
50
|
+
</TopBar>
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByTestId('explicit-center')).toBeTruthy();
|
|
53
|
+
expect(screen.queryByTestId('child')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not render start / end wrappers when those props are absent', () => {
|
|
57
|
+
const { container } = render(<TopBar center={<span>c</span>} />);
|
|
58
|
+
// Inner divs: only the center div is rendered when start/end are
|
|
59
|
+
// both undefined (we render `<div>{center ?? children}</div>` always).
|
|
60
|
+
expect(container.querySelectorAll('header > div')).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('applies the sx className to the root', () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<TopBar center={<span>c</span>} sx="custom-class" />
|
|
66
|
+
);
|
|
67
|
+
expect(container.querySelector('header.custom-class')).toBeTruthy();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('renders height variants without throwing', () => {
|
|
71
|
+
(['sm', 'md', 'lg'] as const).forEach((h) => {
|
|
72
|
+
const { unmount } = render(<TopBar center={<span>c</span>} height={h} />);
|
|
73
|
+
expect(screen.getByRole('banner')).toBeTruthy();
|
|
74
|
+
unmount();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('<TopBar> performance', () => {
|
|
80
|
+
it('mounts in under 20ms', () => {
|
|
81
|
+
const t0 = performance.now();
|
|
82
|
+
render(
|
|
83
|
+
<TopBar
|
|
84
|
+
start={<button>start</button>}
|
|
85
|
+
center={<span>center</span>}
|
|
86
|
+
end={<button>end</button>}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
const t1 = performance.now();
|
|
90
|
+
expect(t1 - t0).toBeLessThan(20);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('renders at most 2 times on mount', () => {
|
|
94
|
+
let count = 0;
|
|
95
|
+
const Counted = (props: React.ComponentProps<typeof TopBar>) => {
|
|
96
|
+
count++;
|
|
97
|
+
return <TopBar {...props} />;
|
|
98
|
+
};
|
|
99
|
+
render(
|
|
100
|
+
<Counted
|
|
101
|
+
start={<button>s</button>}
|
|
102
|
+
center={<span>c</span>}
|
|
103
|
+
end={<button>e</button>}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
expect(count).toBeLessThanOrEqual(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('does NOT re-render an unrelated sibling on prop change', () => {
|
|
110
|
+
const sibCount = { count: 0 };
|
|
111
|
+
const Sibling = () => {
|
|
112
|
+
sibCount.count++;
|
|
113
|
+
return <div data-testid="sib" />;
|
|
114
|
+
};
|
|
115
|
+
const Tree = ({ label }: { label: string }) => (
|
|
116
|
+
<div>
|
|
117
|
+
<TopBar center={<span>{label}</span>} />
|
|
118
|
+
<Sibling />
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
const { rerender } = render(<Tree label="A" />);
|
|
122
|
+
const before = sibCount.count;
|
|
123
|
+
rerender(<Tree label="B" />);
|
|
124
|
+
rerender(<Tree label="C" />);
|
|
125
|
+
// Each parent rerender is expected to also re-render the sibling.
|
|
126
|
+
// 2 parent rerenders ⇒ ≤ 2 extra sibling renders (+1 tolerance).
|
|
127
|
+
expect(sibCount.count - before).toBeLessThanOrEqual(3);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { cn } from '../../utils/cn.js';
|
|
2
|
+
import { topBarVariants } from './topBar.variants.js';
|
|
3
|
+
import type { TopBarProps } from './topBar.types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dashforge TW TopBar — sticky header with 3 named slots.
|
|
7
|
+
*
|
|
8
|
+
* Pure composition. Three regions arranged horizontally:
|
|
9
|
+
*
|
|
10
|
+
* ┌──────────────────────────────────────────────────────────┐
|
|
11
|
+
* │ start │ center │ end │
|
|
12
|
+
* └──────────────────────────────────────────────────────────┘
|
|
13
|
+
*
|
|
14
|
+
* - `start` and `end` shrink to content.
|
|
15
|
+
* - `center` grows to fill remaining space.
|
|
16
|
+
* - If you pass `children` instead of `center`, those children render
|
|
17
|
+
* in the center region — handy for terse JSX:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <TopBar start={<Logo />} end={<UserMenu />}>
|
|
21
|
+
* <Breadcrumbs items={trail} />
|
|
22
|
+
* </TopBar>
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Default renders as `<header role="banner">` (implicit from
|
|
26
|
+
* `<header>` as a top-level landmark). Pass `asDiv` for cases where
|
|
27
|
+
* you don't want the banner role (e.g., nested inside another header).
|
|
28
|
+
*
|
|
29
|
+
* **A11y**:
|
|
30
|
+
* - `<header>` is a banner landmark — only one per page; if you put
|
|
31
|
+
* multiple TopBars on a page, use `asDiv` on the secondary ones.
|
|
32
|
+
* - Slots are agnostic about ARIA — wrap your slot content with
|
|
33
|
+
* appropriate roles (`<nav>`, `<search>`, etc.) where needed.
|
|
34
|
+
*/
|
|
35
|
+
export function TopBar(props: TopBarProps) {
|
|
36
|
+
const {
|
|
37
|
+
start,
|
|
38
|
+
center,
|
|
39
|
+
end,
|
|
40
|
+
children,
|
|
41
|
+
height,
|
|
42
|
+
sticky,
|
|
43
|
+
asDiv,
|
|
44
|
+
sx,
|
|
45
|
+
slotProps,
|
|
46
|
+
} = props;
|
|
47
|
+
|
|
48
|
+
const v = topBarVariants({ height, sticky });
|
|
49
|
+
const Tag = (asDiv ? 'div' : 'header') as 'header';
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Tag className={cn(v.root(), sx, slotProps?.root?.className)}>
|
|
53
|
+
{(start != null) && (
|
|
54
|
+
<div className={cn(v.start(), slotProps?.start?.className)}>
|
|
55
|
+
{start}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
<div className={cn(v.center(), slotProps?.center?.className)}>
|
|
59
|
+
{center ?? children}
|
|
60
|
+
</div>
|
|
61
|
+
{(end != null) && (
|
|
62
|
+
<div className={cn(v.end(), slotProps?.end?.className)}>{end}</div>
|
|
63
|
+
)}
|
|
64
|
+
</Tag>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { TopBarVariants } from './topBar.variants.js';
|
|
3
|
+
|
|
4
|
+
export interface TopBarSlotProps {
|
|
5
|
+
root?: { className?: string };
|
|
6
|
+
start?: { className?: string };
|
|
7
|
+
center?: { className?: string };
|
|
8
|
+
end?: { className?: string };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props for `<TopBar>`.
|
|
13
|
+
*
|
|
14
|
+
* Pure composition container with 3 named slots — `start`, `center`,
|
|
15
|
+
* `end` — that fill a sticky horizontal bar.
|
|
16
|
+
*
|
|
17
|
+
* Typical patterns:
|
|
18
|
+
*
|
|
19
|
+
* - **start**: mobile menu toggle, brand/logo
|
|
20
|
+
* - **center**: Breadcrumbs, page title, global search
|
|
21
|
+
* - **end**: notifications icon, user menu, theme toggle
|
|
22
|
+
*
|
|
23
|
+
* The component is intentionally minimal: it just lays out the three
|
|
24
|
+
* regions. All semantics (e.g., `<header role="banner">`) come for
|
|
25
|
+
* free from rendering as an HTML `<header>` element.
|
|
26
|
+
*/
|
|
27
|
+
export interface TopBarProps extends TopBarVariants {
|
|
28
|
+
/** Left-aligned slot (mobile menu, brand). */
|
|
29
|
+
start?: ReactNode;
|
|
30
|
+
/** Center slot (breadcrumbs, title). Grows to fill remaining space. */
|
|
31
|
+
center?: ReactNode;
|
|
32
|
+
/** Right-aligned slot (actions, user menu). */
|
|
33
|
+
end?: ReactNode;
|
|
34
|
+
/** Convenience: render the bar as a plain `<div>` (no banner role). */
|
|
35
|
+
asDiv?: boolean;
|
|
36
|
+
/** Root className shortcut. */
|
|
37
|
+
sx?: string;
|
|
38
|
+
/** Per-slot className overrides. */
|
|
39
|
+
slotProps?: TopBarSlotProps;
|
|
40
|
+
/** Children render between `start` and `end` when no `center` is set. */
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tailwind-variants recipe for `<TopBar>`.
|
|
5
|
+
*
|
|
6
|
+
* Slots:
|
|
7
|
+
* - `root` — outer `<header>` (or `<div>` with `asDiv`)
|
|
8
|
+
* - `start` — left region (shrink-to-content)
|
|
9
|
+
* - `center` — middle region (grows to fill)
|
|
10
|
+
* - `end` — right region (shrink-to-content)
|
|
11
|
+
*/
|
|
12
|
+
export const topBarVariants = tv({
|
|
13
|
+
slots: {
|
|
14
|
+
root: [
|
|
15
|
+
'flex items-center gap-3 w-full px-4 shrink-0',
|
|
16
|
+
'bg-neutral-50 border-b border-neutral-200',
|
|
17
|
+
],
|
|
18
|
+
start: 'flex items-center gap-2 min-w-0 shrink-0',
|
|
19
|
+
center: 'flex items-center gap-2 min-w-0 flex-1',
|
|
20
|
+
end: 'flex items-center gap-2 shrink-0',
|
|
21
|
+
},
|
|
22
|
+
variants: {
|
|
23
|
+
height: {
|
|
24
|
+
sm: { root: 'h-12 text-sm' },
|
|
25
|
+
md: { root: 'h-14' },
|
|
26
|
+
lg: { root: 'h-16' },
|
|
27
|
+
},
|
|
28
|
+
sticky: {
|
|
29
|
+
true: { root: 'sticky top-0 z-30' },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: {
|
|
33
|
+
height: 'md',
|
|
34
|
+
sticky: true,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type TopBarVariants = VariantProps<typeof topBarVariants>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { DashFormBridge } from '@dashforge/ui-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolved validation state for a bridge-managed field.
|
|
6
|
+
*
|
|
7
|
+
* Returned by `resolveValidationState`, this is consumed by all form
|
|
8
|
+
* components that surface error + helper text (TextField, Checkbox,
|
|
9
|
+
* Switch, …) to derive their final render state.
|
|
10
|
+
*/
|
|
11
|
+
export interface ValidationState {
|
|
12
|
+
error: boolean;
|
|
13
|
+
helperText: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute `error` + `helperText` for a bridge-managed field.
|
|
18
|
+
*
|
|
19
|
+
* **Renderer-agnostic** — copy of the MUI-side
|
|
20
|
+
* `textField.validation.ts`. Promoted to a `_shared/` directory inside
|
|
21
|
+
* `@dashforge/tw` so all form components (TextField + Checkbox +
|
|
22
|
+
* Switch) reuse the same precedence logic without depending on a
|
|
23
|
+
* specific component folder.
|
|
24
|
+
*
|
|
25
|
+
* Precedence rules (matches MUI side byte-for-byte):
|
|
26
|
+
*
|
|
27
|
+
* 1. **Explicit props win.** If the user passed `error` / `helperText`
|
|
28
|
+
* explicitly, those values are used as-is.
|
|
29
|
+
* 2. **Auto values are gated by interaction.** The bridge's error is
|
|
30
|
+
* surfaced only when the field is `touched` OR the form has been
|
|
31
|
+
* submitted at least once (`submitCount > 0`). This prevents the
|
|
32
|
+
* "error spam while typing" anti-pattern.
|
|
33
|
+
* 3. **`error === false` explicitly clears the bridge's error message
|
|
34
|
+
* text.** Useful for forcing a "valid" visual state regardless of
|
|
35
|
+
* bridge state.
|
|
36
|
+
*
|
|
37
|
+
* @param name field name registered with the bridge
|
|
38
|
+
* @param bridge active `DashFormBridge` (guaranteed non-null at
|
|
39
|
+
* call sites by the surrounding `if (bridge)`)
|
|
40
|
+
* @param explicitError `error` prop from the consumer
|
|
41
|
+
* @param explicitHelperText `helperText` prop from the consumer
|
|
42
|
+
*/
|
|
43
|
+
export function resolveValidationState(
|
|
44
|
+
name: string,
|
|
45
|
+
bridge: DashFormBridge,
|
|
46
|
+
explicitError: boolean | undefined,
|
|
47
|
+
explicitHelperText: ReactNode | undefined
|
|
48
|
+
): ValidationState {
|
|
49
|
+
const autoErr = bridge.getError(name) ?? null;
|
|
50
|
+
const autoTouched = bridge.isTouched(name) ?? false;
|
|
51
|
+
const submitCount = bridge.submitCount ?? 0;
|
|
52
|
+
|
|
53
|
+
const allowAutoError = autoTouched || submitCount > 0;
|
|
54
|
+
|
|
55
|
+
const error = explicitError ?? (Boolean(autoErr) && allowAutoError);
|
|
56
|
+
const helperText =
|
|
57
|
+
explicitHelperText ?? (allowAutoError ? autoErr?.message : undefined);
|
|
58
|
+
|
|
59
|
+
return { error, helperText };
|
|
60
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { renderHook } from '@testing-library/react';
|
|
4
|
+
import { RbacProvider } from '@dashforge/rbac';
|
|
5
|
+
import type {
|
|
6
|
+
RbacPolicy,
|
|
7
|
+
Subject,
|
|
8
|
+
AccessRequirement,
|
|
9
|
+
} from '@dashforge/rbac';
|
|
10
|
+
import { useAccessState } from './useAccessState.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TW-side copy of the MUI useAccessState unit test, kept independent
|
|
14
|
+
* per the isolation contract. The hook contract is identical so the
|
|
15
|
+
* MUI test cases double as the source of truth for behaviour parity.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const FULL_ACCESS_POLICY: RbacPolicy = {
|
|
19
|
+
roles: [
|
|
20
|
+
{
|
|
21
|
+
name: 'admin',
|
|
22
|
+
permissions: [{ resource: '*', action: '*', effect: 'allow' }],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const READ_ONLY_POLICY: RbacPolicy = {
|
|
28
|
+
roles: [
|
|
29
|
+
{
|
|
30
|
+
name: 'viewer',
|
|
31
|
+
permissions: [
|
|
32
|
+
{ resource: 'document', action: 'read', effect: 'allow' },
|
|
33
|
+
{ resource: 'document', action: 'update', effect: 'deny' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const adminSubject: Subject = { id: 'admin-user', roles: ['admin'] };
|
|
40
|
+
const viewerSubject: Subject = { id: 'viewer-user', roles: ['viewer'] };
|
|
41
|
+
|
|
42
|
+
function wrapWithRbac(policy: RbacPolicy, subject: Subject) {
|
|
43
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
44
|
+
return (
|
|
45
|
+
<RbacProvider policy={policy} subject={subject}>
|
|
46
|
+
{children}
|
|
47
|
+
</RbacProvider>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('useAccessState (tw)', () => {
|
|
53
|
+
describe('no access requirement', () => {
|
|
54
|
+
it('returns default full-access state when access is undefined', () => {
|
|
55
|
+
const { result } = renderHook(() => useAccessState(undefined));
|
|
56
|
+
expect(result.current).toEqual({
|
|
57
|
+
visible: true,
|
|
58
|
+
disabled: false,
|
|
59
|
+
readonly: false,
|
|
60
|
+
granted: true,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns stable identity across re-renders', () => {
|
|
65
|
+
const { result, rerender } = renderHook(() => useAccessState(undefined));
|
|
66
|
+
const first = result.current;
|
|
67
|
+
rerender();
|
|
68
|
+
expect(result.current).toBe(first);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('access requirement WITHOUT RbacProvider (fail-safe)', () => {
|
|
73
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
76
|
+
});
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
warnSpy.mockRestore();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns DEFAULT_ACCESS_STATE (fails open) when no provider mounted', () => {
|
|
82
|
+
const access: AccessRequirement = { resource: 'doc', action: 'read' };
|
|
83
|
+
const { result } = renderHook(() => useAccessState(access));
|
|
84
|
+
expect(result.current.visible).toBe(true);
|
|
85
|
+
expect(result.current.granted).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('logs a dev-mode warning naming the missing requirement', () => {
|
|
89
|
+
const access: AccessRequirement = { resource: 'doc', action: 'update' };
|
|
90
|
+
renderHook(() => useAccessState(access));
|
|
91
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
92
|
+
const msg = String(warnSpy.mock.calls[0]?.[0] ?? '');
|
|
93
|
+
expect(msg).toContain('No RbacProvider');
|
|
94
|
+
expect(msg).toContain('doc');
|
|
95
|
+
expect(msg).toContain('update');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('access requirement WITH RbacProvider', () => {
|
|
100
|
+
it('grants when policy allows', () => {
|
|
101
|
+
const access: AccessRequirement = { resource: 'document', action: 'read' };
|
|
102
|
+
const { result } = renderHook(() => useAccessState(access), {
|
|
103
|
+
wrapper: wrapWithRbac(FULL_ACCESS_POLICY, adminSubject),
|
|
104
|
+
});
|
|
105
|
+
expect(result.current.granted).toBe(true);
|
|
106
|
+
expect(result.current.visible).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('denies (hide) when policy forbids and onUnauthorized=hide', () => {
|
|
110
|
+
const access: AccessRequirement = {
|
|
111
|
+
resource: 'document',
|
|
112
|
+
action: 'update',
|
|
113
|
+
onUnauthorized: 'hide',
|
|
114
|
+
};
|
|
115
|
+
const { result } = renderHook(() => useAccessState(access), {
|
|
116
|
+
wrapper: wrapWithRbac(READ_ONLY_POLICY, viewerSubject),
|
|
117
|
+
});
|
|
118
|
+
expect(result.current.granted).toBe(false);
|
|
119
|
+
expect(result.current.visible).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('denies (disable) when policy forbids and onUnauthorized=disable', () => {
|
|
123
|
+
const access: AccessRequirement = {
|
|
124
|
+
resource: 'document',
|
|
125
|
+
action: 'update',
|
|
126
|
+
onUnauthorized: 'disable',
|
|
127
|
+
};
|
|
128
|
+
const { result } = renderHook(() => useAccessState(access), {
|
|
129
|
+
wrapper: wrapWithRbac(READ_ONLY_POLICY, viewerSubject),
|
|
130
|
+
});
|
|
131
|
+
expect(result.current.granted).toBe(false);
|
|
132
|
+
expect(result.current.visible).toBe(true);
|
|
133
|
+
expect(result.current.disabled).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('denies (readonly) when policy forbids and onUnauthorized=readonly', () => {
|
|
137
|
+
const access: AccessRequirement = {
|
|
138
|
+
resource: 'document',
|
|
139
|
+
action: 'update',
|
|
140
|
+
onUnauthorized: 'readonly',
|
|
141
|
+
};
|
|
142
|
+
const { result } = renderHook(() => useAccessState(access), {
|
|
143
|
+
wrapper: wrapWithRbac(READ_ONLY_POLICY, viewerSubject),
|
|
144
|
+
});
|
|
145
|
+
expect(result.current.granted).toBe(false);
|
|
146
|
+
expect(result.current.readonly).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('memoization', () => {
|
|
151
|
+
it('returns identical reference when access reference stays the same', () => {
|
|
152
|
+
const access: AccessRequirement = { resource: 'doc', action: 'read' };
|
|
153
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
154
|
+
const { result, rerender } = renderHook(() => useAccessState(access));
|
|
155
|
+
const first = result.current;
|
|
156
|
+
rerender();
|
|
157
|
+
expect(result.current).toBe(first);
|
|
158
|
+
warnSpy.mockRestore();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useRbacOptional, resolveAccessState } from '@dashforge/rbac';
|
|
3
|
+
import type { AccessRequirement, AccessState } from '@dashforge/rbac';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default full-access state. Returned when no access requirement is
|
|
7
|
+
* supplied **or** when no `RbacProvider` is mounted (fail-safe default).
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_ACCESS_STATE: AccessState = {
|
|
12
|
+
visible: true,
|
|
13
|
+
disabled: false,
|
|
14
|
+
readonly: false,
|
|
15
|
+
granted: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hook to resolve RBAC access state for a UI component.
|
|
20
|
+
*
|
|
21
|
+
* **TW-side copy of the MUI implementation** in `@dashforge/ui/src/hooks/useAccessState.ts`.
|
|
22
|
+
* Per the architecture plan v2 (2026-05-15) the two ecosystems share
|
|
23
|
+
* only the bridge layer (`@dashforge/forms` + `@dashforge/ui-core` +
|
|
24
|
+
* `@dashforge/rbac`); UI-side hooks are duplicated intentionally so
|
|
25
|
+
* the MUI and TW ecosystems remain fully isolated.
|
|
26
|
+
*
|
|
27
|
+
* The behaviour is byte-equivalent to the MUI side:
|
|
28
|
+
*
|
|
29
|
+
* - If `access` is undefined → returns `DEFAULT_ACCESS_STATE`.
|
|
30
|
+
* - If `access` is supplied but no `RbacProvider` is mounted → returns
|
|
31
|
+
* `DEFAULT_ACCESS_STATE` and emits a dev-only `console.warn` so the
|
|
32
|
+
* developer notices the missing provider. Failing **open** is the
|
|
33
|
+
* safer default (RBAC silently denying everything would be a hidden
|
|
34
|
+
* regression in development).
|
|
35
|
+
* - Otherwise → evaluates the requirement against the active
|
|
36
|
+
* `rbac.engine` + `rbac.subject` via `resolveAccessState`.
|
|
37
|
+
*
|
|
38
|
+
* The memo dependency list is `[access, rbac]` — a stable RBAC
|
|
39
|
+
* context + identical access requirement re-uses the same result
|
|
40
|
+
* object so downstream `useMemo` / `React.memo` comparisons stay
|
|
41
|
+
* cheap.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* function MyComponent({ access }: { access?: AccessRequirement }) {
|
|
46
|
+
* const accessState = useAccessState(access);
|
|
47
|
+
* if (!accessState.visible) return null;
|
|
48
|
+
* return <input disabled={accessState.disabled} readOnly={accessState.readonly} />;
|
|
49
|
+
* }
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function useAccessState(
|
|
53
|
+
access: AccessRequirement | undefined
|
|
54
|
+
): AccessState {
|
|
55
|
+
// Hooks are called unconditionally to comply with the rules of hooks.
|
|
56
|
+
// `useRbacOptional` returns `null` when no provider is mounted
|
|
57
|
+
// (it does NOT throw — that's intentional, see its doc).
|
|
58
|
+
const rbac = useRbacOptional();
|
|
59
|
+
|
|
60
|
+
return useMemo(() => {
|
|
61
|
+
if (!access) {
|
|
62
|
+
return DEFAULT_ACCESS_STATE;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!rbac) {
|
|
66
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
67
|
+
|
|
68
|
+
console.warn(
|
|
69
|
+
'[useAccessState] No RbacProvider found but access requirement was provided. ' +
|
|
70
|
+
'Defaulting to full access. Wrap your component tree with <RbacProvider> to enable RBAC. ' +
|
|
71
|
+
`Resource: ${access.resource}, Action: ${access.action}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return DEFAULT_ACCESS_STATE;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return resolveAccessState(access, (request) =>
|
|
78
|
+
rbac.engine.can(rbac.subject, request)
|
|
79
|
+
);
|
|
80
|
+
}, [access, rbac]);
|
|
81
|
+
}
|