@eifi1/ui-kit 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -37
- package/dist/chart.d.ts +3 -1
- package/dist/chart.js +2 -0
- package/dist/chart.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +105 -0
- package/dist/components/account-settings-labels.js +81 -0
- package/dist/components/account-settings-labels.js.map +1 -0
- package/dist/components/account-settings.d.ts +36 -52
- package/dist/components/account-settings.js +44 -8
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +26 -1
- package/dist/components/alert-banner.js +10 -7
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +26 -4
- package/dist/components/amount-input.js +5 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +68 -0
- package/dist/components/authed-image.js +117 -0
- package/dist/components/authed-image.js.map +1 -0
- package/dist/components/breadcrumbs.d.ts +2 -1
- package/dist/components/breadcrumbs.js +5 -2
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/bulk-action-bar.d.ts +51 -5
- package/dist/components/bulk-action-bar.js +88 -41
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +107 -3
- package/dist/components/button-group.js +60 -5
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +17 -4
- package/dist/components/calendar-heatmap.d.ts +162 -0
- package/dist/components/calendar-heatmap.js +336 -0
- package/dist/components/calendar-heatmap.js.map +1 -0
- package/dist/components/chip.d.ts +2 -1
- package/dist/components/chip.js +6 -3
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +66 -2
- package/dist/components/choice-card.js +108 -1
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.js +2 -0
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +2 -2
- package/dist/components/combobox.js +31 -9
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +52 -70
- package/dist/components/copy-button.js +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +63 -0
- package/dist/components/data-table-cells.js +64 -0
- package/dist/components/data-table-cells.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +31 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +13 -0
- package/dist/components/data-table-labels.js +8 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +104 -12
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +3 -1
- package/dist/components/date-picker.js +15 -2
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +23 -3
- package/dist/components/description-list.js +23 -3
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +1 -0
- package/dist/components/entity-combobox.d.ts +1 -1
- package/dist/components/entity-combobox.js +18 -3
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +92 -0
- package/dist/components/error-boundary.js +111 -0
- package/dist/components/error-boundary.js.map +1 -0
- package/dist/components/field.d.ts +69 -0
- package/dist/components/field.js +75 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/file-button.d.ts +51 -208
- package/dist/components/file-dropzone.d.ts +50 -2
- package/dist/components/floating-panel.d.ts +156 -4
- package/dist/components/floating-panel.js +193 -29
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +53 -0
- package/dist/components/form-actions.js +98 -0
- package/dist/components/form-actions.js.map +1 -0
- package/dist/components/image-grid.d.ts +67 -0
- package/dist/components/image-grid.js +112 -0
- package/dist/components/image-grid.js.map +1 -0
- package/dist/components/lightbox.d.ts +104 -0
- package/dist/components/lightbox.js +229 -0
- package/dist/components/lightbox.js.map +1 -0
- package/dist/components/line-items.d.ts +114 -0
- package/dist/components/line-items.js +238 -0
- package/dist/components/line-items.js.map +1 -0
- package/dist/components/list.d.ts +37 -3
- package/dist/components/list.js +39 -14
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +24 -0
- package/dist/components/loading-state.js +32 -0
- package/dist/components/loading-state.js.map +1 -0
- package/dist/components/menu-item.d.ts +13 -2
- package/dist/components/menu-item.js +11 -2
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +82 -5
- package/dist/components/mini-calendar.js +212 -64
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +20 -1
- package/dist/components/modal.js +26 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +21 -1
- package/dist/components/month-picker.js +139 -26
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +1 -1
- package/dist/components/multi-entity-combobox.js +19 -3
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/nav-pills.d.ts +67 -0
- package/dist/components/nav-pills.js +69 -0
- package/dist/components/nav-pills.js.map +1 -0
- package/dist/components/number-field.d.ts +26 -5
- package/dist/components/number-field.js +5 -7
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +27 -5
- package/dist/components/number-input.js +7 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +17 -4
- package/dist/components/passkeys-setting.d.ts +67 -0
- package/dist/components/passkeys-setting.js +214 -0
- package/dist/components/passkeys-setting.js.map +1 -0
- package/dist/components/picker-sheet.js +2 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart-labels.d.ts +20 -0
- package/dist/components/pie-chart-labels.js +12 -0
- package/dist/components/pie-chart-labels.js.map +1 -0
- package/dist/components/pie-chart.d.ts +101 -0
- package/dist/components/pie-chart.js +283 -0
- package/dist/components/pie-chart.js.map +1 -0
- package/dist/components/progress-bar.d.ts +73 -3
- package/dist/components/progress-bar.js +110 -8
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.d.ts +34 -0
- package/dist/components/qr-code.js +57 -0
- package/dist/components/qr-code.js.map +1 -0
- package/dist/components/search-field.d.ts +15 -3
- package/dist/components/search-field.js +5 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart.d.ts +16 -0
- package/dist/components/series-chart.js +4 -1
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +50 -1
- package/dist/components/signed-amount.d.ts +121 -0
- package/dist/components/signed-amount.js +152 -0
- package/dist/components/signed-amount.js.map +1 -0
- package/dist/components/skeleton.d.ts +10 -3
- package/dist/components/skeleton.js +12 -2
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/stat-tile.d.ts +2 -1
- package/dist/components/stat-tile.js +5 -2
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/status-dot.d.ts +6 -2
- package/dist/components/status-dot.js +6 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/table.d.ts +77 -11
- package/dist/components/table.js +85 -17
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +176 -0
- package/dist/components/text-link.js +109 -0
- package/dist/components/text-link.js.map +1 -0
- package/dist/components/text.d.ts +8 -1
- package/dist/components/text.js +1 -0
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +50 -1
- package/dist/components/toggle-group.d.ts +28 -0
- package/dist/components/toggle-group.js +58 -9
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +36 -3
- package/dist/components/toggle-legend.js +49 -0
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +11 -3
- package/dist/components/tooltip.js +2 -0
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +52 -729
- package/dist/components/ui.js +296 -27
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +3 -52
- package/dist/components/use-table-state.js +128 -30
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +25 -3
- package/dist/components/user-avatar.js +32 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
- package/dist/data-table.d.ts +2 -1
- package/dist/data-table.js +5 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +26 -3
- package/dist/feedback/feedback-dialog.js +16 -2
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +133 -0
- package/dist/feedback/feedback-thread.js +237 -0
- package/dist/feedback/feedback-thread.js.map +1 -0
- package/dist/feedback.d.ts +2 -1
- package/dist/feedback.js +1 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-authed-src.d.ts +71 -0
- package/dist/hooks/use-authed-src.js +63 -0
- package/dist/hooks/use-authed-src.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +50 -2
- package/dist/hooks/use-hotkey.d.ts +59 -0
- package/dist/hooks/use-hotkey.js +105 -0
- package/dist/hooks/use-hotkey.js.map +1 -0
- package/dist/hooks/use-search-param-state.d.ts +83 -0
- package/dist/hooks/use-search-param-state.js +94 -0
- package/dist/hooks/use-search-param-state.js.map +1 -0
- package/dist/i18n/defaults.d.ts +17 -4
- package/dist/i18n/defaults.js +33 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +47 -310
- package/dist/i18n/kit-labels.js +20 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +17 -4
- package/dist/i18n/locales/de-CH.d.ts +17 -4
- package/dist/i18n/locales/de-informal.d.ts +17 -4
- package/dist/i18n/locales/de.d.ts +17 -4
- package/dist/i18n/locales/de.js +165 -4
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +17 -4
- package/dist/i18n/locales/es.js +162 -4
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +17 -4
- package/dist/i18n/locales/fr.js +162 -4
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +17 -4
- package/dist/i18n/locales/hu.js +162 -4
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +17 -4
- package/dist/i18n/locales/it.js +162 -4
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +17 -4
- package/dist/i18n/locales/zh.js +162 -4
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +45 -21
- package/dist/index.js +43 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
- package/dist/lib/clipping.d.ts +21 -5
- package/dist/lib/clipping.js +3 -0
- package/dist/lib/clipping.js.map +1 -1
- package/dist/lib/dates.d.ts +17 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/format.d.ts +144 -0
- package/dist/lib/format.js +149 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/qr-encode.d.ts +44 -0
- package/dist/lib/qr-encode.js +338 -0
- package/dist/lib/qr-encode.js.map +1 -0
- package/dist/rhf/fields.d.ts +241 -0
- package/dist/rhf/fields.js +601 -0
- package/dist/rhf/fields.js.map +1 -0
- package/dist/rhf/form.d.ts +50 -1
- package/dist/rhf/line-items.d.ts +31 -0
- package/dist/rhf/line-items.js +59 -0
- package/dist/rhf/line-items.js.map +1 -0
- package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
- package/dist/rhf/use-rhf-wizard-step.js +38 -0
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
- package/dist/rhf.d.ts +63 -1
- package/dist/rhf.js +3 -0
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +99 -2
- package/dist/shell/app-shell.js +58 -3
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.d.ts +74 -0
- package/dist/shell/auth-layout.js +97 -0
- package/dist/shell/auth-layout.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +81 -0
- package/dist/shell/top-bar-brand.js +47 -0
- package/dist/shell/top-bar-brand.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +84 -3
- package/dist/shell/topbar-action-menu.js +115 -27
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +58 -2
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +49 -1
- package/dist/wizard/wizard-context.d.ts +30 -1
- package/dist/wizard/wizard-context.js +23 -2
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +20 -5
- package/dist/wizard/wizard-step.js +16 -3
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +51 -3
- package/package.json +1 -1
- package/src/chart.ts +4 -0
- package/src/components/account-settings-labels.ts +190 -0
- package/src/components/account-settings.tsx +110 -63
- package/src/components/alert-banner.tsx +36 -7
- package/src/components/amount-input.tsx +13 -1
- package/src/components/authed-image.tsx +190 -0
- package/src/components/breadcrumbs.tsx +8 -2
- package/src/components/bulk-action-bar.tsx +163 -57
- package/src/components/button-group.tsx +129 -4
- package/src/components/calendar-heatmap.tsx +599 -0
- package/src/components/chip.tsx +8 -4
- package/src/components/choice-card.tsx +208 -1
- package/src/components/combobox-core.tsx +5 -0
- package/src/components/combobox.tsx +31 -9
- package/src/components/copy-button.tsx +3 -2
- package/src/components/data-table-cells.tsx +119 -0
- package/src/components/data-table-filters.ts +77 -0
- package/src/components/data-table-labels.ts +23 -0
- package/src/components/data-table.tsx +231 -13
- package/src/components/date-picker.tsx +19 -3
- package/src/components/description-list.tsx +52 -3
- package/src/components/entity-combobox.tsx +18 -3
- package/src/components/error-boundary.tsx +216 -0
- package/src/components/field.tsx +188 -0
- package/src/components/floating-panel.tsx +380 -16
- package/src/components/form-actions.tsx +191 -0
- package/src/components/image-grid.tsx +181 -0
- package/src/components/lightbox.tsx +374 -0
- package/src/components/line-items.tsx +385 -0
- package/src/components/list.tsx +93 -18
- package/src/components/loading-state.tsx +47 -0
- package/src/components/menu-item.tsx +29 -3
- package/src/components/mini-calendar.tsx +317 -57
- package/src/components/modal.tsx +61 -3
- package/src/components/month-picker.tsx +181 -26
- package/src/components/multi-entity-combobox.tsx +19 -3
- package/src/components/nav-pills.tsx +152 -0
- package/src/components/number-field.tsx +15 -10
- package/src/components/number-input.tsx +16 -1
- package/src/components/passkeys-setting.tsx +331 -0
- package/src/components/picker-sheet.tsx +7 -1
- package/src/components/pie-chart-labels.ts +32 -0
- package/src/components/pie-chart.tsx +479 -0
- package/src/components/progress-bar.tsx +241 -13
- package/src/components/qr-code.tsx +83 -0
- package/src/components/search-field.tsx +20 -4
- package/src/components/series-chart.tsx +21 -0
- package/src/components/signed-amount.tsx +286 -0
- package/src/components/skeleton.tsx +21 -3
- package/src/components/stat-tile.tsx +8 -2
- package/src/components/status-dot.tsx +10 -2
- package/src/components/table.tsx +186 -23
- package/src/components/text-link.tsx +248 -0
- package/src/components/text.tsx +9 -1
- package/src/components/toggle-group.tsx +106 -13
- package/src/components/toggle-legend.tsx +87 -2
- package/src/components/tooltip.tsx +17 -3
- package/src/components/ui.tsx +572 -44
- package/src/components/use-table-state.ts +265 -33
- package/src/components/user-avatar.tsx +57 -3
- package/src/data-table.ts +10 -0
- package/src/feedback/feedback-dialog.tsx +42 -3
- package/src/feedback/feedback-thread.tsx +434 -0
- package/src/feedback.ts +1 -0
- package/src/hooks/use-authed-src.ts +161 -0
- package/src/hooks/use-hotkey.ts +172 -0
- package/src/hooks/use-search-param-state.ts +187 -0
- package/src/i18n/defaults.ts +32 -0
- package/src/i18n/kit-labels.tsx +104 -8
- package/src/i18n/locales/de.ts +162 -0
- package/src/i18n/locales/es.ts +159 -0
- package/src/i18n/locales/fr.ts +162 -0
- package/src/i18n/locales/hu.ts +160 -0
- package/src/i18n/locales/it.ts +160 -0
- package/src/i18n/locales/zh.ts +159 -0
- package/src/index.ts +64 -0
- package/src/lib/clipping.ts +22 -4
- package/src/lib/dates.ts +37 -0
- package/src/lib/format.ts +325 -0
- package/src/lib/qr-encode.ts +420 -0
- package/src/rhf/fields.tsx +1054 -0
- package/src/rhf/line-items.tsx +130 -0
- package/src/rhf/use-rhf-wizard-step.ts +113 -0
- package/src/rhf.ts +8 -0
- package/src/shell/app-shell.tsx +147 -2
- package/src/shell/auth-layout.tsx +190 -0
- package/src/shell/top-bar-brand.tsx +76 -0
- package/src/shell/topbar-action-menu.tsx +260 -49
- package/src/shell.ts +2 -0
- package/src/wizard/wizard-context.tsx +50 -1
- package/src/wizard/wizard-step.tsx +43 -6
- package/tokens.css +12 -0
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound fields: one line per field of a react-hook-form form. `@eifi1/ui-kit/rhf`.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <Form {...form}>
|
|
6
|
+
* <RhfTextField name="name" label="Name" hint="As on the lease" required
|
|
7
|
+
* rules={{ required: "Enter a name" }} />
|
|
8
|
+
* <RhfMoneyField name="rent" label="Net rent" currency="CHF" />
|
|
9
|
+
* <RhfDateField name="start" label="Start" />
|
|
10
|
+
* <RhfSelect name="type" label="Type" options={TYPES} />
|
|
11
|
+
* <RhfCombobox name="tenantId" label="Tenant" options={tenants} clearable />
|
|
12
|
+
* <RhfCheckbox name="isDefault" label="Default account" />
|
|
13
|
+
* </Form>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Each folds the `FormField → FormItem → FormLabel → FormControl → field →
|
|
17
|
+
* FormDescription → FormMessage` scaffold (see ./form) into one element, the way
|
|
18
|
+
* kastlan's shared/components/form/fields.tsx does for its forms — with what that file
|
|
19
|
+
* left out:
|
|
20
|
+
*
|
|
21
|
+
* - **The red border.** Every field gets `invalid` from the field state, so an errored
|
|
22
|
+
* field PAINTS as well as announces. kastlan's number, money, date and entity fields
|
|
23
|
+
* passed only `aria-invalid` through `FormControl`, or nothing, and `Input`, `Select`
|
|
24
|
+
* and `Textarea` paint only from the prop (see the note in ./form).
|
|
25
|
+
* - **Focus on error.** `field.ref` reaches the focusable element — the element itself
|
|
26
|
+
* where the kit field forwards a ref, a focus handle on it where it does not (the
|
|
27
|
+
* number field, the pickers) — so `handleSubmit` and `trigger(…, { shouldFocus })`
|
|
28
|
+
* land the caret on the first field in error.
|
|
29
|
+
* - **`control` is optional**: under `<Form {...form}>` the fields find the form
|
|
30
|
+
* through react-hook-form's context. Pass it for the field's value types
|
|
31
|
+
* (`control={form.control}` checks `name` against the form's values).
|
|
32
|
+
*
|
|
33
|
+
* The common props: `name`, `control`, `label` (a {@link FormLabel} above the field;
|
|
34
|
+
* omitted, none — then give the field an `aria-label`), `hint` (standing advice under
|
|
35
|
+
* the field, attached to it with `aria-describedby`), `required` (the label's mark,
|
|
36
|
+
* and `aria-required` where the control takes it — validation is `rules`' or the
|
|
37
|
+
* resolver's), `rules`, `disabled`, and `className` for the item's box.
|
|
38
|
+
*
|
|
39
|
+
* {@link RhfField} is the shell they are all built on, for a control the kit does not
|
|
40
|
+
* ship (an address autocomplete): it takes a `render` and wires the rest.
|
|
41
|
+
*/
|
|
42
|
+
import { useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
|
|
43
|
+
import type {
|
|
44
|
+
ControllerFieldState,
|
|
45
|
+
ControllerProps,
|
|
46
|
+
ControllerRenderProps,
|
|
47
|
+
FieldPath,
|
|
48
|
+
FieldValues,
|
|
49
|
+
} from "react-hook-form";
|
|
50
|
+
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField } from "./form";
|
|
51
|
+
import { Input, Select, Textarea, type InputProps, type SelectProps, type TextareaProps } from "../components/ui";
|
|
52
|
+
import { NumberField, type NumberFieldProps } from "../components/number-field";
|
|
53
|
+
import { AmountInput } from "../components/amount-input";
|
|
54
|
+
import { DatePicker, type DatePickerProps } from "../components/date-picker";
|
|
55
|
+
import { Checkbox, type CheckboxProps } from "../components/checkbox";
|
|
56
|
+
import { EntityCombobox, type EntityComboboxProps } from "../components/entity-combobox";
|
|
57
|
+
import { Combobox, type ComboboxProps } from "../components/combobox";
|
|
58
|
+
import type { ComboClearValue } from "../components/combobox-core";
|
|
59
|
+
import { cn } from "../lib/cn";
|
|
60
|
+
|
|
61
|
+
// ── the shell ────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** What every bound field takes. */
|
|
64
|
+
export interface RhfFieldBaseProps<
|
|
65
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
66
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
67
|
+
TTransformed = TFieldValues,
|
|
68
|
+
> {
|
|
69
|
+
name: TName;
|
|
70
|
+
/** `form.control`. Optional under `<Form {...form}>`; pass it to type-check `name`. */
|
|
71
|
+
control?: ControllerProps<TFieldValues, TName, TTransformed>["control"];
|
|
72
|
+
/** react-hook-form's `rules` (`required`, `min`, `validate` …). */
|
|
73
|
+
rules?: ControllerProps<TFieldValues, TName, TTransformed>["rules"];
|
|
74
|
+
/** A {@link FormLabel} above the field. Omitted: no label — name the control with
|
|
75
|
+
* an `aria-label` instead. */
|
|
76
|
+
label?: ReactNode;
|
|
77
|
+
/** Standing advice under the field ("As printed on the card"), attached to the
|
|
78
|
+
* control with `aria-describedby`. */
|
|
79
|
+
hint?: ReactNode;
|
|
80
|
+
/** Draws the label's required mark and sets `aria-required` where the control
|
|
81
|
+
* takes it. Whether the field IS required is `rules`' or the resolver's to say. */
|
|
82
|
+
required?: boolean;
|
|
83
|
+
/** Disables the field — through react-hook-form, so its value is left out of the
|
|
84
|
+
* submitted values as a disabled input's would be. */
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
/** Classes for the item's box (a `grid gap-1`). */
|
|
87
|
+
className?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What {@link RhfField}'s `render` receives. */
|
|
91
|
+
export interface RhfFieldRenderContext<
|
|
92
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
93
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
94
|
+
> {
|
|
95
|
+
field: ControllerRenderProps<TFieldValues, TName>;
|
|
96
|
+
fieldState: ControllerFieldState;
|
|
97
|
+
/** The field has an error: pass it to the control's `invalid` so it paints. */
|
|
98
|
+
invalid: boolean;
|
|
99
|
+
/** The error's message, if any. */
|
|
100
|
+
error: string | undefined;
|
|
101
|
+
/** The control's id, which the label points at. `FormControl` sets it on the
|
|
102
|
+
* rendered element already; this is for a control that routes it elsewhere. */
|
|
103
|
+
id: string;
|
|
104
|
+
/** The label's id, for a control that is named with `aria-labelledby`. */
|
|
105
|
+
labelId: string;
|
|
106
|
+
/** The ids of the rendered hint and message, if any. */
|
|
107
|
+
describedBy: string | undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface RhfFieldProps<
|
|
111
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
112
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
113
|
+
TTransformed = TFieldValues,
|
|
114
|
+
> extends RhfFieldBaseProps<TFieldValues, TName, TTransformed> {
|
|
115
|
+
/** The control. Wrapped in {@link FormControl} (its `id`, `aria-describedby` and
|
|
116
|
+
* `aria-invalid`) unless `asControl` is false. */
|
|
117
|
+
render: (ctx: RhfFieldRenderContext<TFieldValues, TName>) => ReactElement;
|
|
118
|
+
/** `false`: render the control as is, for one that wires its own ids from the
|
|
119
|
+
* context. Default `true`. */
|
|
120
|
+
asControl?: boolean;
|
|
121
|
+
/** `false`: no {@link FormMessage}, for a control that renders its own error (its
|
|
122
|
+
* `error` prop). Default `true`. */
|
|
123
|
+
message?: boolean;
|
|
124
|
+
/** `false`: no {@link FormLabel}, for a control that carries its label itself (a
|
|
125
|
+
* checkbox). Default `true`. */
|
|
126
|
+
showLabel?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hasContent(node: ReactNode): boolean {
|
|
130
|
+
return node !== undefined && node !== null && node !== false && node !== "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The label, with an id the control can be named by. Inside the item, so it can read
|
|
134
|
+
* the item's id. */
|
|
135
|
+
function RhfLabel({ required, children }: { required?: boolean; children: ReactNode }) {
|
|
136
|
+
const { id } = useFormField();
|
|
137
|
+
return (
|
|
138
|
+
<FormLabel id={`${id}-label`} required={required}>
|
|
139
|
+
{children}
|
|
140
|
+
</FormLabel>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ControlSlot<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>>({
|
|
145
|
+
field,
|
|
146
|
+
fieldState,
|
|
147
|
+
render,
|
|
148
|
+
asControl,
|
|
149
|
+
}: {
|
|
150
|
+
field: ControllerRenderProps<TFieldValues, TName>;
|
|
151
|
+
fieldState: ControllerFieldState;
|
|
152
|
+
render: RhfFieldProps<TFieldValues, TName>["render"];
|
|
153
|
+
asControl: boolean;
|
|
154
|
+
}) {
|
|
155
|
+
const { id, formItemId, describedBy } = useFormField();
|
|
156
|
+
const element = render({
|
|
157
|
+
field,
|
|
158
|
+
fieldState,
|
|
159
|
+
invalid: fieldState.invalid,
|
|
160
|
+
error: fieldState.error?.message,
|
|
161
|
+
id: formItemId,
|
|
162
|
+
labelId: `${id}-label`,
|
|
163
|
+
describedBy,
|
|
164
|
+
});
|
|
165
|
+
return asControl ? <FormControl>{element}</FormControl> : element;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The shell every bound field is built on: label, control, hint and message around
|
|
170
|
+
* a `render` of your own.
|
|
171
|
+
*
|
|
172
|
+
* ```tsx
|
|
173
|
+
* <RhfField name="address" label="Address" render={({ field, invalid }) => (
|
|
174
|
+
* <AddressAutocomplete value={field.value ?? ""} onChange={field.onChange} invalid={invalid} />
|
|
175
|
+
* )} />
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
export function RhfField<
|
|
179
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
180
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
181
|
+
TTransformed = TFieldValues,
|
|
182
|
+
>({
|
|
183
|
+
name,
|
|
184
|
+
control,
|
|
185
|
+
rules,
|
|
186
|
+
label,
|
|
187
|
+
hint,
|
|
188
|
+
required,
|
|
189
|
+
disabled,
|
|
190
|
+
className,
|
|
191
|
+
render,
|
|
192
|
+
asControl = true,
|
|
193
|
+
message = true,
|
|
194
|
+
showLabel = true,
|
|
195
|
+
}: RhfFieldProps<TFieldValues, TName, TTransformed>) {
|
|
196
|
+
return (
|
|
197
|
+
<FormField
|
|
198
|
+
control={control}
|
|
199
|
+
name={name}
|
|
200
|
+
rules={rules}
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
render={({ field, fieldState }) => (
|
|
203
|
+
<FormItem className={className}>
|
|
204
|
+
{showLabel && hasContent(label) && <RhfLabel required={required}>{label}</RhfLabel>}
|
|
205
|
+
<ControlSlot field={field} fieldState={fieldState} render={render} asControl={asControl} />
|
|
206
|
+
{hasContent(hint) && <FormDescription>{hint}</FormDescription>}
|
|
207
|
+
{message && <FormMessage />}
|
|
208
|
+
</FormItem>
|
|
209
|
+
)}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Hands react-hook-form a focus handle for a control that forwards no ref: its
|
|
216
|
+
* `shouldFocusError` calls `focus()` on whatever `field.ref` was given. `find` runs at
|
|
217
|
+
* focus time, so it sees the element as it is then.
|
|
218
|
+
*/
|
|
219
|
+
function useFocusHandle(ref: ControllerRenderProps["ref"], find: () => HTMLElement | null | undefined) {
|
|
220
|
+
const findRef = useRef(find);
|
|
221
|
+
useLayoutEffect(() => {
|
|
222
|
+
findRef.current = find;
|
|
223
|
+
});
|
|
224
|
+
useLayoutEffect(() => {
|
|
225
|
+
ref({ focus: () => findRef.current()?.focus() });
|
|
226
|
+
}, [ref]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── text ─────────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
type OwnInputProps = Omit<
|
|
232
|
+
InputProps,
|
|
233
|
+
"name" | "value" | "defaultValue" | "onChange" | "onBlur" | "ref" | "label" | "error" | "invalid" | "className" | "disabled" | "required"
|
|
234
|
+
>;
|
|
235
|
+
|
|
236
|
+
export type RhfTextFieldProps<
|
|
237
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
238
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
239
|
+
TTransformed = TFieldValues,
|
|
240
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> & OwnInputProps;
|
|
241
|
+
|
|
242
|
+
/** A text {@link Input} — `type` for email, tel, password, url. Stores the string. */
|
|
243
|
+
export function RhfTextField<
|
|
244
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
245
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
246
|
+
TTransformed = TFieldValues,
|
|
247
|
+
>({
|
|
248
|
+
name,
|
|
249
|
+
control,
|
|
250
|
+
rules,
|
|
251
|
+
label,
|
|
252
|
+
hint,
|
|
253
|
+
required,
|
|
254
|
+
disabled,
|
|
255
|
+
className,
|
|
256
|
+
...inputProps
|
|
257
|
+
}: RhfTextFieldProps<TFieldValues, TName, TTransformed>) {
|
|
258
|
+
return (
|
|
259
|
+
<RhfField
|
|
260
|
+
{...{ name, control, rules, label, hint, required, disabled, className }}
|
|
261
|
+
render={({ field, invalid }) => (
|
|
262
|
+
<Input
|
|
263
|
+
aria-required={required || undefined}
|
|
264
|
+
{...inputProps}
|
|
265
|
+
name={field.name}
|
|
266
|
+
ref={field.ref}
|
|
267
|
+
value={(field.value ?? "") as string}
|
|
268
|
+
onChange={field.onChange}
|
|
269
|
+
onBlur={field.onBlur}
|
|
270
|
+
disabled={field.disabled}
|
|
271
|
+
invalid={invalid}
|
|
272
|
+
/>
|
|
273
|
+
)}
|
|
274
|
+
/>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type OwnTextareaProps = Omit<
|
|
279
|
+
TextareaProps,
|
|
280
|
+
"name" | "value" | "defaultValue" | "onChange" | "onBlur" | "ref" | "label" | "error" | "invalid" | "className" | "disabled" | "required"
|
|
281
|
+
> & {
|
|
282
|
+
/** Classes for the `<textarea>`. */
|
|
283
|
+
inputClassName?: string;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export type RhfTextareaProps<
|
|
287
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
288
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
289
|
+
TTransformed = TFieldValues,
|
|
290
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> & OwnTextareaProps;
|
|
291
|
+
|
|
292
|
+
/** A multi-line {@link Textarea}; takes `rows`. Stores the string. */
|
|
293
|
+
export function RhfTextarea<
|
|
294
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
295
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
296
|
+
TTransformed = TFieldValues,
|
|
297
|
+
>({
|
|
298
|
+
name,
|
|
299
|
+
control,
|
|
300
|
+
rules,
|
|
301
|
+
label,
|
|
302
|
+
hint,
|
|
303
|
+
required,
|
|
304
|
+
disabled,
|
|
305
|
+
className,
|
|
306
|
+
inputClassName,
|
|
307
|
+
...areaProps
|
|
308
|
+
}: RhfTextareaProps<TFieldValues, TName, TTransformed>) {
|
|
309
|
+
return (
|
|
310
|
+
<RhfField
|
|
311
|
+
{...{ name, control, rules, label, hint, required, disabled, className }}
|
|
312
|
+
render={({ field, invalid }) => (
|
|
313
|
+
<Textarea
|
|
314
|
+
aria-required={required || undefined}
|
|
315
|
+
{...areaProps}
|
|
316
|
+
className={inputClassName}
|
|
317
|
+
name={field.name}
|
|
318
|
+
ref={field.ref}
|
|
319
|
+
value={(field.value ?? "") as string}
|
|
320
|
+
onChange={field.onChange}
|
|
321
|
+
onBlur={field.onBlur}
|
|
322
|
+
disabled={field.disabled}
|
|
323
|
+
invalid={invalid}
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
/>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── numbers ──────────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/** A form value → the number field's `number | null`. Defaults hold `""`, `null`, a
|
|
333
|
+
* number, or a numeric string. */
|
|
334
|
+
function toNumber(value: unknown): number | null {
|
|
335
|
+
if (value === null || value === undefined || value === "") return null;
|
|
336
|
+
const n = typeof value === "number" ? value : Number(String(value).replace(",", "."));
|
|
337
|
+
return Number.isFinite(n) ? n : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export type RhfNumberFieldProps<
|
|
341
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
342
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
343
|
+
TTransformed = TFieldValues,
|
|
344
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
|
|
345
|
+
Pick<
|
|
346
|
+
NumberFieldProps,
|
|
347
|
+
| "digits"
|
|
348
|
+
| "min"
|
|
349
|
+
| "max"
|
|
350
|
+
| "step"
|
|
351
|
+
| "calculator"
|
|
352
|
+
| "unit"
|
|
353
|
+
| "unitPlacement"
|
|
354
|
+
| "placeholder"
|
|
355
|
+
| "inputClassName"
|
|
356
|
+
| "locale"
|
|
357
|
+
| "ariaLabel"
|
|
358
|
+
| "autoFocus"
|
|
359
|
+
> & {
|
|
360
|
+
/** What an emptied field stores. Default `null`; `""` for a schema written
|
|
361
|
+
* against a native number input (`z.coerce.number()` over `""`). */
|
|
362
|
+
emptyValue?: null | "";
|
|
363
|
+
/** `false`: an emptied field snaps back to its last number instead of storing
|
|
364
|
+
* `emptyValue`. Default `true`. */
|
|
365
|
+
nullable?: boolean;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The kit's {@link NumberField}: a parsed, rounded (`digits`) and clamped (`min` /
|
|
370
|
+
* `max`) number, committed on blur or Enter in the locale's decimal mark. Stores a
|
|
371
|
+
* number, or `emptyValue` when emptied.
|
|
372
|
+
*/
|
|
373
|
+
export function RhfNumberField<
|
|
374
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
375
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
376
|
+
TTransformed = TFieldValues,
|
|
377
|
+
>({
|
|
378
|
+
name,
|
|
379
|
+
control,
|
|
380
|
+
rules,
|
|
381
|
+
label,
|
|
382
|
+
hint,
|
|
383
|
+
required,
|
|
384
|
+
disabled,
|
|
385
|
+
className,
|
|
386
|
+
emptyValue = null,
|
|
387
|
+
nullable = true,
|
|
388
|
+
...numberProps
|
|
389
|
+
}: RhfNumberFieldProps<TFieldValues, TName, TTransformed>) {
|
|
390
|
+
return (
|
|
391
|
+
<RhfField
|
|
392
|
+
{...{ name, control, rules, label, hint, required, disabled, className }}
|
|
393
|
+
render={({ field, invalid, id }) => (
|
|
394
|
+
// FormControl clones this element with `id` / `aria-describedby` /
|
|
395
|
+
// `aria-invalid`, which NumberControl hands on to the field.
|
|
396
|
+
<NumberControl
|
|
397
|
+
field={field as unknown as ControllerRenderProps}
|
|
398
|
+
invalid={invalid}
|
|
399
|
+
required={required}
|
|
400
|
+
id={id}
|
|
401
|
+
emptyValue={emptyValue}
|
|
402
|
+
nullable={nullable}
|
|
403
|
+
numberProps={numberProps}
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
/>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function NumberControl({
|
|
411
|
+
field,
|
|
412
|
+
invalid,
|
|
413
|
+
required,
|
|
414
|
+
id,
|
|
415
|
+
emptyValue,
|
|
416
|
+
nullable,
|
|
417
|
+
numberProps,
|
|
418
|
+
...aria
|
|
419
|
+
}: {
|
|
420
|
+
field: ControllerRenderProps;
|
|
421
|
+
invalid: boolean;
|
|
422
|
+
required?: boolean;
|
|
423
|
+
id: string;
|
|
424
|
+
emptyValue: null | "";
|
|
425
|
+
nullable: boolean;
|
|
426
|
+
numberProps: Omit<RhfNumberFieldProps, keyof RhfFieldBaseProps | "emptyValue" | "nullable">;
|
|
427
|
+
"aria-describedby"?: string;
|
|
428
|
+
"aria-invalid"?: NumberFieldProps["aria-invalid"];
|
|
429
|
+
}) {
|
|
430
|
+
// NumberField forwards no ref; it routes `id` to its <input>, where the handle
|
|
431
|
+
// finds it.
|
|
432
|
+
useFocusHandle(field.ref, () => document.getElementById(id));
|
|
433
|
+
return (
|
|
434
|
+
<NumberField
|
|
435
|
+
{...numberProps}
|
|
436
|
+
aria-describedby={aria["aria-describedby"]}
|
|
437
|
+
aria-invalid={aria["aria-invalid"]}
|
|
438
|
+
aria-required={required || undefined}
|
|
439
|
+
id={id}
|
|
440
|
+
value={toNumber(field.value)}
|
|
441
|
+
onCommit={(n) => {
|
|
442
|
+
field.onChange(n ?? emptyValue);
|
|
443
|
+
field.onBlur();
|
|
444
|
+
}}
|
|
445
|
+
nullable={nullable}
|
|
446
|
+
disabled={field.disabled}
|
|
447
|
+
invalid={invalid}
|
|
448
|
+
/>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── money ────────────────────────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
export type RhfMoneyFieldProps<
|
|
455
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
456
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
457
|
+
TTransformed = TFieldValues,
|
|
458
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> & {
|
|
459
|
+
placeholder?: string;
|
|
460
|
+
/** The currency chip's code ("CHF"). */
|
|
461
|
+
currency?: string;
|
|
462
|
+
/** Makes the chip a currency picker. Bind it to another field with
|
|
463
|
+
* `useController`, or to state. */
|
|
464
|
+
onCurrencyChange?: (code: string) => void;
|
|
465
|
+
/** Colours the figure for the direction of money. */
|
|
466
|
+
tone?: "neutral" | "outflow" | "inflow";
|
|
467
|
+
align?: "start" | "center";
|
|
468
|
+
ariaLabel?: string;
|
|
469
|
+
autoFocus?: boolean;
|
|
470
|
+
/**
|
|
471
|
+
* What the form stores: `"number"` (default) — `12.5`, or `emptyValue` while the
|
|
472
|
+
* field is empty — or `"string"`, the field's own text ("12.50") for a schema that
|
|
473
|
+
* parses it itself. A half-typed sum ("12+") leaves the stored number as it was
|
|
474
|
+
* until it resolves.
|
|
475
|
+
*/
|
|
476
|
+
valueAs?: "number" | "string";
|
|
477
|
+
/** What an emptied field stores when `valueAs` is `"number"`. Default `null`. */
|
|
478
|
+
emptyValue?: null | "";
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/** The amount a text reads as, or `undefined` for a draft that is not one yet. */
|
|
482
|
+
function parseAmount(text: string): number | null | undefined {
|
|
483
|
+
const trimmed = text.trim();
|
|
484
|
+
if (trimmed === "") return null;
|
|
485
|
+
if (!/^-?\d*[.,]?\d*$/.test(trimmed)) return undefined;
|
|
486
|
+
const n = Number(trimmed.replace(",", "."));
|
|
487
|
+
return Number.isFinite(n) ? n : undefined;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function MoneyControl({
|
|
491
|
+
field,
|
|
492
|
+
invalid,
|
|
493
|
+
required,
|
|
494
|
+
id,
|
|
495
|
+
describedBy,
|
|
496
|
+
props,
|
|
497
|
+
}: {
|
|
498
|
+
field: ControllerRenderProps;
|
|
499
|
+
invalid: boolean;
|
|
500
|
+
required?: boolean;
|
|
501
|
+
id: string;
|
|
502
|
+
describedBy: string | undefined;
|
|
503
|
+
props: Omit<RhfMoneyFieldProps, keyof RhfFieldBaseProps>;
|
|
504
|
+
}) {
|
|
505
|
+
const { valueAs = "number", emptyValue = null, ariaLabel, ...amountProps } = props;
|
|
506
|
+
const value: unknown = field.value;
|
|
507
|
+
const external = value === null || value === undefined ? "" : String(value);
|
|
508
|
+
// The text being typed. A stored NUMBER cannot hold "12." or "12+5", so the draft
|
|
509
|
+
// lives here and is re-read from the form only when the form's value moves away
|
|
510
|
+
// from what the draft already says (a reset, a setValue).
|
|
511
|
+
const [draft, setDraft] = useState(external);
|
|
512
|
+
const [seen, setSeen] = useState<unknown>(value);
|
|
513
|
+
if (seen !== value) {
|
|
514
|
+
setSeen(value);
|
|
515
|
+
const current = valueAs === "string" ? draft : parseAmount(draft);
|
|
516
|
+
const same = valueAs === "string" ? current === external : current === toNumber(value);
|
|
517
|
+
if (!same) setDraft(external);
|
|
518
|
+
}
|
|
519
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
520
|
+
// AmountInput reports no blur, and react-hook-form needs one to mark the field
|
|
521
|
+
// touched: listen on its <input>.
|
|
522
|
+
const onBlur = field.onBlur;
|
|
523
|
+
useLayoutEffect(() => {
|
|
524
|
+
const el = input.current;
|
|
525
|
+
if (!el) return;
|
|
526
|
+
el.addEventListener("blur", onBlur);
|
|
527
|
+
return () => el.removeEventListener("blur", onBlur);
|
|
528
|
+
}, [onBlur]);
|
|
529
|
+
return (
|
|
530
|
+
<div>
|
|
531
|
+
<AmountInput
|
|
532
|
+
{...amountProps}
|
|
533
|
+
ariaLabel={ariaLabel}
|
|
534
|
+
id={id}
|
|
535
|
+
aria-describedby={describedBy}
|
|
536
|
+
aria-required={required || undefined}
|
|
537
|
+
ref={(el) => {
|
|
538
|
+
input.current = el;
|
|
539
|
+
field.ref(el);
|
|
540
|
+
}}
|
|
541
|
+
value={draft}
|
|
542
|
+
disabled={field.disabled}
|
|
543
|
+
invalid={invalid}
|
|
544
|
+
onChange={(text) => {
|
|
545
|
+
setDraft(text);
|
|
546
|
+
if (valueAs === "string") {
|
|
547
|
+
field.onChange(text);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const parsed = parseAmount(text);
|
|
551
|
+
if (parsed === undefined) return;
|
|
552
|
+
field.onChange(parsed ?? emptyValue);
|
|
553
|
+
}}
|
|
554
|
+
/>
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The kit's {@link AmountInput}: a money figure with its currency chip and the
|
|
561
|
+
* calculator ("1200+80" resolves on blur or Enter). Stores a number by default — see
|
|
562
|
+
* `valueAs`.
|
|
563
|
+
*/
|
|
564
|
+
export function RhfMoneyField<
|
|
565
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
566
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
567
|
+
TTransformed = TFieldValues,
|
|
568
|
+
>({
|
|
569
|
+
name,
|
|
570
|
+
control,
|
|
571
|
+
rules,
|
|
572
|
+
label,
|
|
573
|
+
hint,
|
|
574
|
+
required,
|
|
575
|
+
disabled,
|
|
576
|
+
className,
|
|
577
|
+
...amountProps
|
|
578
|
+
}: RhfMoneyFieldProps<TFieldValues, TName, TTransformed>) {
|
|
579
|
+
return (
|
|
580
|
+
<RhfField
|
|
581
|
+
{...{ name, control, rules, label, hint, required, disabled, className }}
|
|
582
|
+
asControl={false}
|
|
583
|
+
render={({ field, invalid, id, describedBy }) => (
|
|
584
|
+
<MoneyControl
|
|
585
|
+
field={field as unknown as ControllerRenderProps}
|
|
586
|
+
invalid={invalid}
|
|
587
|
+
required={required}
|
|
588
|
+
id={id}
|
|
589
|
+
describedBy={describedBy}
|
|
590
|
+
props={amountProps}
|
|
591
|
+
/>
|
|
592
|
+
)}
|
|
593
|
+
/>
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── date ─────────────────────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
export type RhfDateFieldProps<
|
|
600
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
601
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
602
|
+
TTransformed = TFieldValues,
|
|
603
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
|
|
604
|
+
Omit<DatePickerProps, "value" | "onChange" | "label" | "invalid" | "disabled" | "className" | "id"> & {
|
|
605
|
+
/** Classes for the picker. Default `w-full`. */
|
|
606
|
+
inputClassName?: string;
|
|
607
|
+
/** What a cleared date stores. Default `""`, the empty ISO string; `null` for a
|
|
608
|
+
* nullable schema. */
|
|
609
|
+
emptyValue?: "" | null;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
function DateControl({
|
|
613
|
+
field,
|
|
614
|
+
invalid,
|
|
615
|
+
required,
|
|
616
|
+
id,
|
|
617
|
+
emptyValue,
|
|
618
|
+
inputClassName,
|
|
619
|
+
pickerProps,
|
|
620
|
+
...aria
|
|
621
|
+
}: {
|
|
622
|
+
field: ControllerRenderProps;
|
|
623
|
+
invalid: boolean;
|
|
624
|
+
required?: boolean;
|
|
625
|
+
id: string;
|
|
626
|
+
emptyValue: "" | null;
|
|
627
|
+
inputClassName?: string;
|
|
628
|
+
pickerProps: Omit<DatePickerProps, "value" | "onChange" | "label" | "invalid" | "disabled" | "className" | "id">;
|
|
629
|
+
"aria-describedby"?: string;
|
|
630
|
+
"aria-invalid"?: DatePickerProps["aria-invalid"];
|
|
631
|
+
}) {
|
|
632
|
+
useFocusHandle(field.ref, () => document.getElementById(id));
|
|
633
|
+
const value: unknown = field.value;
|
|
634
|
+
return (
|
|
635
|
+
<DatePicker
|
|
636
|
+
{...pickerProps}
|
|
637
|
+
{...aria}
|
|
638
|
+
aria-required={required || undefined}
|
|
639
|
+
id={id}
|
|
640
|
+
value={typeof value === "string" ? value : ""}
|
|
641
|
+
onChange={(iso) => {
|
|
642
|
+
field.onChange(iso === "" ? emptyValue : iso);
|
|
643
|
+
field.onBlur();
|
|
644
|
+
}}
|
|
645
|
+
disabled={field.disabled}
|
|
646
|
+
invalid={invalid}
|
|
647
|
+
className={inputClassName ?? "w-full"}
|
|
648
|
+
/>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** The kit's {@link DatePicker}, holding an ISO `"YYYY-MM-DD"` string. */
|
|
653
|
+
export function RhfDateField<
|
|
654
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
655
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
656
|
+
TTransformed = TFieldValues,
|
|
657
|
+
>({
|
|
658
|
+
name,
|
|
659
|
+
control,
|
|
660
|
+
rules,
|
|
661
|
+
label,
|
|
662
|
+
hint,
|
|
663
|
+
required,
|
|
664
|
+
disabled,
|
|
665
|
+
className,
|
|
666
|
+
inputClassName,
|
|
667
|
+
emptyValue = "",
|
|
668
|
+
...pickerProps
|
|
669
|
+
}: RhfDateFieldProps<TFieldValues, TName, TTransformed>) {
|
|
670
|
+
return (
|
|
671
|
+
<RhfField
|
|
672
|
+
{...{ name, control, rules, label, hint, required, disabled, className }}
|
|
673
|
+
render={({ field, invalid, id }) => (
|
|
674
|
+
// FormControl clones this element with `aria-describedby` / `aria-invalid`,
|
|
675
|
+
// which DateControl hands on to the picker's trigger.
|
|
676
|
+
<DateControl
|
|
677
|
+
field={field as unknown as ControllerRenderProps}
|
|
678
|
+
invalid={invalid}
|
|
679
|
+
required={required}
|
|
680
|
+
id={id}
|
|
681
|
+
emptyValue={emptyValue}
|
|
682
|
+
inputClassName={inputClassName}
|
|
683
|
+
pickerProps={pickerProps}
|
|
684
|
+
/>
|
|
685
|
+
)}
|
|
686
|
+
/>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ── select ───────────────────────────────────────────────────────────────────
|
|
691
|
+
|
|
692
|
+
export interface RhfSelectOption {
|
|
693
|
+
value: string | number;
|
|
694
|
+
label: ReactNode;
|
|
695
|
+
disabled?: boolean;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export type RhfSelectProps<
|
|
699
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
700
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
701
|
+
TTransformed = TFieldValues,
|
|
702
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
|
|
703
|
+
Pick<SelectProps, "size" | "autoFocus" | "aria-label"> & {
|
|
704
|
+
/** The options, as data. Or pass `<option>` children. */
|
|
705
|
+
options?: readonly RhfSelectOption[];
|
|
706
|
+
children?: ReactNode;
|
|
707
|
+
/** A first, empty option ("Choose…"), selected while the value is empty. */
|
|
708
|
+
placeholder?: string;
|
|
709
|
+
/** Store `Number(value)` rather than the option's string. An empty choice still
|
|
710
|
+
* stores `""`. */
|
|
711
|
+
valueAsNumber?: boolean;
|
|
712
|
+
/** Classes for the select. Default `w-full`. */
|
|
713
|
+
selectClassName?: string;
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
/** A native {@link Select}, from `options` or `<option>` children. */
|
|
717
|
+
export function RhfSelect<
|
|
718
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
719
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
720
|
+
TTransformed = TFieldValues,
|
|
721
|
+
>({
|
|
722
|
+
name,
|
|
723
|
+
control,
|
|
724
|
+
rules,
|
|
725
|
+
label,
|
|
726
|
+
hint,
|
|
727
|
+
required,
|
|
728
|
+
disabled,
|
|
729
|
+
className,
|
|
730
|
+
options,
|
|
731
|
+
children,
|
|
732
|
+
placeholder,
|
|
733
|
+
valueAsNumber,
|
|
734
|
+
selectClassName,
|
|
735
|
+
...selectProps
|
|
736
|
+
}: RhfSelectProps<TFieldValues, TName, TTransformed>) {
|
|
737
|
+
return (
|
|
738
|
+
<RhfField
|
|
739
|
+
{...{ name, control, rules, label, hint, required, disabled, className }}
|
|
740
|
+
render={({ field, invalid }) => (
|
|
741
|
+
<Select
|
|
742
|
+
aria-required={required || undefined}
|
|
743
|
+
{...selectProps}
|
|
744
|
+
className={cn("w-full", selectClassName)}
|
|
745
|
+
name={field.name}
|
|
746
|
+
ref={field.ref}
|
|
747
|
+
value={(field.value ?? "") as string | number}
|
|
748
|
+
onChange={(e) => {
|
|
749
|
+
const raw = e.target.value;
|
|
750
|
+
field.onChange(valueAsNumber && raw !== "" ? Number(raw) : raw);
|
|
751
|
+
}}
|
|
752
|
+
onBlur={field.onBlur}
|
|
753
|
+
disabled={field.disabled}
|
|
754
|
+
invalid={invalid}
|
|
755
|
+
>
|
|
756
|
+
{placeholder !== undefined && <option value="">{placeholder}</option>}
|
|
757
|
+
{options
|
|
758
|
+
? options.map((o) => (
|
|
759
|
+
<option key={String(o.value)} value={o.value} disabled={o.disabled}>
|
|
760
|
+
{o.label}
|
|
761
|
+
</option>
|
|
762
|
+
))
|
|
763
|
+
: children}
|
|
764
|
+
</Select>
|
|
765
|
+
)}
|
|
766
|
+
/>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ── checkbox ─────────────────────────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
export type RhfCheckboxProps<
|
|
773
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
774
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
775
|
+
TTransformed = TFieldValues,
|
|
776
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
|
|
777
|
+
Pick<CheckboxProps, "inputClassName" | "autoFocus" | "aria-label">;
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The kit's {@link Checkbox}, label beside the box and `hint` under it (the
|
|
781
|
+
* checkbox's own `description`). Stores a boolean. `required` is the native one here
|
|
782
|
+
* — see Checkbox — so a `<form>` refuses to submit it unticked.
|
|
783
|
+
*/
|
|
784
|
+
export function RhfCheckbox<
|
|
785
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
786
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
787
|
+
TTransformed = TFieldValues,
|
|
788
|
+
>({
|
|
789
|
+
name,
|
|
790
|
+
control,
|
|
791
|
+
rules,
|
|
792
|
+
label,
|
|
793
|
+
hint,
|
|
794
|
+
required,
|
|
795
|
+
disabled,
|
|
796
|
+
className,
|
|
797
|
+
...boxProps
|
|
798
|
+
}: RhfCheckboxProps<TFieldValues, TName, TTransformed>) {
|
|
799
|
+
return (
|
|
800
|
+
<RhfField
|
|
801
|
+
{...{ name, control, rules, required, disabled, className }}
|
|
802
|
+
showLabel={false}
|
|
803
|
+
render={({ field, invalid }) => (
|
|
804
|
+
<Checkbox
|
|
805
|
+
{...boxProps}
|
|
806
|
+
ref={field.ref}
|
|
807
|
+
name={field.name}
|
|
808
|
+
checked={Boolean(field.value)}
|
|
809
|
+
onCheckedChange={field.onChange}
|
|
810
|
+
onBlur={field.onBlur}
|
|
811
|
+
disabled={field.disabled}
|
|
812
|
+
required={required}
|
|
813
|
+
invalid={invalid}
|
|
814
|
+
label={label}
|
|
815
|
+
description={hint}
|
|
816
|
+
/>
|
|
817
|
+
)}
|
|
818
|
+
/>
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── comboboxes ───────────────────────────────────────────────────────────────
|
|
823
|
+
|
|
824
|
+
export type RhfComboboxProps<
|
|
825
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
826
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
827
|
+
V extends string | number = string | number,
|
|
828
|
+
TTransformed = TFieldValues,
|
|
829
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
|
|
830
|
+
Pick<
|
|
831
|
+
EntityComboboxProps<V>,
|
|
832
|
+
| "options"
|
|
833
|
+
| "loadOptions"
|
|
834
|
+
| "loading"
|
|
835
|
+
| "placeholder"
|
|
836
|
+
| "searchPlaceholder"
|
|
837
|
+
| "emptyLabel"
|
|
838
|
+
| "clearable"
|
|
839
|
+
| "clearLabel"
|
|
840
|
+
| "onCreate"
|
|
841
|
+
| "createLabel"
|
|
842
|
+
| "filter"
|
|
843
|
+
| "minChars"
|
|
844
|
+
| "debounceMs"
|
|
845
|
+
> & {
|
|
846
|
+
/** What a clear stores — the picker's `clearValue`. Default `null`; `""` for a
|
|
847
|
+
* schema that spells "no choice" as an empty string. */
|
|
848
|
+
clearValue?: ComboClearValue;
|
|
849
|
+
/** Classes for the picker. */
|
|
850
|
+
comboClassName?: string;
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
function EntityControl<V extends string | number>({
|
|
854
|
+
field,
|
|
855
|
+
invalid,
|
|
856
|
+
required,
|
|
857
|
+
error,
|
|
858
|
+
id,
|
|
859
|
+
describedBy,
|
|
860
|
+
clearValue,
|
|
861
|
+
comboClassName,
|
|
862
|
+
comboProps,
|
|
863
|
+
}: {
|
|
864
|
+
field: ControllerRenderProps;
|
|
865
|
+
invalid: boolean;
|
|
866
|
+
required?: boolean;
|
|
867
|
+
error: string | undefined;
|
|
868
|
+
id: string;
|
|
869
|
+
describedBy: string | undefined;
|
|
870
|
+
clearValue: ComboClearValue;
|
|
871
|
+
comboClassName?: string;
|
|
872
|
+
comboProps: Omit<RhfComboboxProps<FieldValues, string, V>, keyof RhfFieldBaseProps | "clearValue" | "comboClassName">;
|
|
873
|
+
}) {
|
|
874
|
+
const box = useRef<HTMLDivElement>(null);
|
|
875
|
+
// EntityCombobox puts `id` and the ARIA below on its trigger, so the label's
|
|
876
|
+
// `htmlFor` names it, the hint describes it and the focus handle finds it by id.
|
|
877
|
+
useFocusHandle(field.ref, () => document.getElementById(id));
|
|
878
|
+
// EntityCombobox reports no blur: focus leaving the picker marks the field touched.
|
|
879
|
+
const onBlur = field.onBlur;
|
|
880
|
+
useLayoutEffect(() => {
|
|
881
|
+
const el = box.current;
|
|
882
|
+
if (!el) return;
|
|
883
|
+
el.addEventListener("focusout", onBlur);
|
|
884
|
+
return () => el.removeEventListener("focusout", onBlur);
|
|
885
|
+
}, [onBlur]);
|
|
886
|
+
const value: unknown = field.value;
|
|
887
|
+
return (
|
|
888
|
+
<div ref={box}>
|
|
889
|
+
<EntityCombobox<V, ComboClearValue>
|
|
890
|
+
{...comboProps}
|
|
891
|
+
className={comboClassName}
|
|
892
|
+
id={id}
|
|
893
|
+
aria-describedby={describedBy}
|
|
894
|
+
aria-required={required || undefined}
|
|
895
|
+
value={(value ?? null) as V | ComboClearValue | null}
|
|
896
|
+
clearValue={clearValue}
|
|
897
|
+
onChange={(v) => field.onChange(v)}
|
|
898
|
+
disabled={field.disabled}
|
|
899
|
+
invalid={invalid}
|
|
900
|
+
// Its own message, which it merges into the trigger's aria-describedby after
|
|
901
|
+
// the hint.
|
|
902
|
+
error={error}
|
|
903
|
+
/>
|
|
904
|
+
</div>
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* The kit's {@link EntityCombobox}: an id-keyed pick from `options` or `loadOptions`.
|
|
910
|
+
* Stores the option's `value`, or `clearValue` on a clear (only offered with
|
|
911
|
+
* `clearable`).
|
|
912
|
+
*/
|
|
913
|
+
export function RhfCombobox<
|
|
914
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
915
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
916
|
+
V extends string | number = string | number,
|
|
917
|
+
TTransformed = TFieldValues,
|
|
918
|
+
>({
|
|
919
|
+
name,
|
|
920
|
+
control,
|
|
921
|
+
rules,
|
|
922
|
+
label,
|
|
923
|
+
hint,
|
|
924
|
+
required,
|
|
925
|
+
disabled,
|
|
926
|
+
className,
|
|
927
|
+
clearValue = null,
|
|
928
|
+
comboClassName,
|
|
929
|
+
...comboProps
|
|
930
|
+
}: RhfComboboxProps<TFieldValues, TName, V, TTransformed>) {
|
|
931
|
+
return (
|
|
932
|
+
<RhfField
|
|
933
|
+
{...{ name, control, rules, label, hint, required, disabled, className }}
|
|
934
|
+
asControl={false}
|
|
935
|
+
message={false}
|
|
936
|
+
render={({ field, invalid, error, id, describedBy }) => (
|
|
937
|
+
<EntityControl<V>
|
|
938
|
+
field={field as unknown as ControllerRenderProps}
|
|
939
|
+
invalid={invalid}
|
|
940
|
+
required={required}
|
|
941
|
+
error={error}
|
|
942
|
+
id={id}
|
|
943
|
+
describedBy={describedBy}
|
|
944
|
+
clearValue={clearValue}
|
|
945
|
+
comboClassName={comboClassName}
|
|
946
|
+
comboProps={comboProps}
|
|
947
|
+
/>
|
|
948
|
+
)}
|
|
949
|
+
/>
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
export type RhfTextComboboxProps<
|
|
954
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
955
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
956
|
+
TTransformed = TFieldValues,
|
|
957
|
+
> = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
|
|
958
|
+
Pick<
|
|
959
|
+
ComboboxProps,
|
|
960
|
+
| "options"
|
|
961
|
+
| "placeholder"
|
|
962
|
+
| "groupBy"
|
|
963
|
+
| "maxSuggestions"
|
|
964
|
+
| "searchPlaceholder"
|
|
965
|
+
| "createLabel"
|
|
966
|
+
| "optionAdornment"
|
|
967
|
+
| "autoFocus"
|
|
968
|
+
| "aria-label"
|
|
969
|
+
> & {
|
|
970
|
+
/** Classes for the combobox. */
|
|
971
|
+
comboClassName?: string;
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* The kit's free-text {@link Combobox}: type anything, or pick one of `options`.
|
|
976
|
+
* Stores the string.
|
|
977
|
+
*/
|
|
978
|
+
export function RhfTextCombobox<
|
|
979
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
980
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
981
|
+
TTransformed = TFieldValues,
|
|
982
|
+
>({
|
|
983
|
+
name,
|
|
984
|
+
control,
|
|
985
|
+
rules,
|
|
986
|
+
label,
|
|
987
|
+
hint,
|
|
988
|
+
required,
|
|
989
|
+
disabled,
|
|
990
|
+
className,
|
|
991
|
+
comboClassName,
|
|
992
|
+
...comboProps
|
|
993
|
+
}: RhfTextComboboxProps<TFieldValues, TName, TTransformed>) {
|
|
994
|
+
return (
|
|
995
|
+
<RhfField
|
|
996
|
+
{...{ name, control, rules, label, hint, required, disabled, className }}
|
|
997
|
+
asControl={false}
|
|
998
|
+
message={false}
|
|
999
|
+
render={({ field, invalid, error, id, describedBy }) => (
|
|
1000
|
+
<TextComboControl
|
|
1001
|
+
field={field as unknown as ControllerRenderProps}
|
|
1002
|
+
invalid={invalid}
|
|
1003
|
+
required={required}
|
|
1004
|
+
error={error}
|
|
1005
|
+
id={id}
|
|
1006
|
+
describedBy={describedBy}
|
|
1007
|
+
className={comboClassName}
|
|
1008
|
+
comboProps={comboProps}
|
|
1009
|
+
/>
|
|
1010
|
+
)}
|
|
1011
|
+
/>
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function TextComboControl({
|
|
1016
|
+
field,
|
|
1017
|
+
invalid,
|
|
1018
|
+
required,
|
|
1019
|
+
error,
|
|
1020
|
+
id,
|
|
1021
|
+
describedBy,
|
|
1022
|
+
className,
|
|
1023
|
+
comboProps,
|
|
1024
|
+
}: {
|
|
1025
|
+
field: ControllerRenderProps;
|
|
1026
|
+
invalid: boolean;
|
|
1027
|
+
required?: boolean;
|
|
1028
|
+
error: string | undefined;
|
|
1029
|
+
id: string;
|
|
1030
|
+
describedBy: string | undefined;
|
|
1031
|
+
className?: string;
|
|
1032
|
+
comboProps: Omit<RhfTextComboboxProps, keyof RhfFieldBaseProps | "comboClassName">;
|
|
1033
|
+
}) {
|
|
1034
|
+
// The combobox routes `id` and its ARIA to its <input>, so the label's `htmlFor`
|
|
1035
|
+
// names it, the hint describes it and the focus handle finds it; the error is its
|
|
1036
|
+
// own `error` prop, which it merges in after the hint.
|
|
1037
|
+
useFocusHandle(field.ref, () => document.getElementById(id));
|
|
1038
|
+
const value: unknown = field.value;
|
|
1039
|
+
return (
|
|
1040
|
+
<Combobox
|
|
1041
|
+
{...comboProps}
|
|
1042
|
+
id={id}
|
|
1043
|
+
aria-describedby={describedBy}
|
|
1044
|
+
aria-required={required || undefined}
|
|
1045
|
+
className={className}
|
|
1046
|
+
value={typeof value === "string" ? value : value == null ? "" : String(value)}
|
|
1047
|
+
onChange={field.onChange}
|
|
1048
|
+
onBlur={field.onBlur}
|
|
1049
|
+
disabled={field.disabled}
|
|
1050
|
+
invalid={invalid}
|
|
1051
|
+
error={error}
|
|
1052
|
+
/>
|
|
1053
|
+
);
|
|
1054
|
+
}
|