@eifi1/ui-kit 0.11.0 → 0.13.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 +59 -34
- 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/amount-input.d.ts +40 -4
- package/dist/components/amount-input.js +50 -12
- 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 +10 -2
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +51 -2
- package/dist/components/button-group.js +5 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +15 -4
- package/dist/components/calendar-heatmap.d.ts +41 -3
- package/dist/components/calendar-heatmap.js +48 -7
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/chart.js +14 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +2 -1
- package/dist/components/chip.js +26 -18
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +35 -3
- package/dist/components/choice-card.js +83 -25
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.d.ts +3 -1
- package/dist/components/combobox-core.js +18 -5
- 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 +51 -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-filter-popover.js +8 -1
- package/dist/components/data-table-filter-popover.js.map +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 +125 -27
- 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 +40 -20
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +281 -0
- package/dist/components/error-boundary.js +302 -0
- package/dist/components/error-boundary.js.map +1 -0
- package/dist/components/field-sync.js +90 -83
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/field.d.ts +30 -4
- package/dist/components/field.js +40 -17
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +50 -208
- package/dist/components/file-dropzone.d.ts +49 -2
- package/dist/components/floating-panel.d.ts +13 -4
- package/dist/components/floating-panel.js +7 -3
- 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/full-bleed-dialog.js +4 -0
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/hover-menu.js +64 -56
- package/dist/components/hover-menu.js.map +1 -1
- 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 -4
- package/dist/components/list.js +35 -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 +2 -1
- package/dist/components/menu-item.js +5 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +13 -1
- package/dist/components/mini-calendar.js +46 -2
- 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 +42 -20
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/nav-pills.d.ts +2 -1
- package/dist/components/nav-pills.js +5 -1
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/number-field.d.ts +24 -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 +25 -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 +20 -5
- package/dist/components/numpad-sheet.js +3 -2
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-header.d.ts +35 -6
- package/dist/components/page-header.js +7 -1
- package/dist/components/page-header.js.map +1 -1
- 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 +5 -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/popover.js +3 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress-bar.d.ts +44 -3
- package/dist/components/progress-bar.js +58 -22
- 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/settings-fields.d.ts +49 -1
- package/dist/components/signed-amount.d.ts +122 -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 +6 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swipeable-row.js +4 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table.d.ts +39 -5
- package/dist/components/table.js +45 -11
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +238 -0
- package/dist/components/text-link.js +144 -0
- package/dist/components/text-link.js.map +1 -0
- package/dist/components/text.d.ts +26 -2
- package/dist/components/text.js +16 -2
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +49 -1
- package/dist/components/toggle-group.d.ts +35 -0
- package/dist/components/toggle-group.js +43 -14
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +53 -3
- package/dist/components/toggle-legend.js +54 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +51 -1
- package/dist/components/tooltip.js +59 -35
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +51 -779
- package/dist/components/ui.js +364 -34
- 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/{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-attachment.js +4 -6
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +26 -3
- package/dist/feedback/feedback-dialog.js +21 -5
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js +86 -75
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +173 -0
- package/dist/feedback/feedback-thread.js +264 -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 +49 -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 +15 -4
- package/dist/i18n/defaults.js +31 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +46 -312
- 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 +15 -4
- package/dist/i18n/locales/de-CH.d.ts +15 -4
- package/dist/i18n/locales/de-informal.d.ts +15 -4
- package/dist/i18n/locales/de.d.ts +15 -4
- package/dist/i18n/locales/de.js +164 -3
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +15 -4
- package/dist/i18n/locales/es.js +161 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +15 -4
- package/dist/i18n/locales/fr.js +161 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +15 -4
- package/dist/i18n/locales/hu.js +161 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +15 -4
- package/dist/i18n/locales/it.js +161 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +15 -4
- package/dist/i18n/locales/zh.js +161 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +39 -19
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-Mz8zZHIF.d.ts +1744 -0
- package/dist/lib/format.d.ts +162 -0
- package/dist/lib/format.js +151 -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 +283 -0
- package/dist/rhf/fields.js +631 -0
- package/dist/rhf/fields.js.map +1 -0
- package/dist/rhf/form.d.ts +49 -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.d.ts +61 -1
- package/dist/rhf.js +2 -0
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.js +20 -14
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +99 -2
- package/dist/shell/app-shell.js +120 -57
- 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 +21 -4
- package/dist/shell/topbar-action-menu.js +18 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +56 -1
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour.js +10 -4
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +48 -1
- package/dist/wizard/use-wizard.js +4 -2
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-context.d.ts +24 -1
- package/dist/wizard/wizard-context.js +19 -2
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +30 -5
- package/dist/wizard/wizard-step.js +34 -3
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +50 -3
- package/package.json +6 -6
- 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/amount-input.tsx +109 -10
- package/src/components/authed-image.tsx +190 -0
- package/src/components/breadcrumbs.tsx +8 -2
- package/src/components/bulk-action-bar.tsx +16 -3
- package/src/components/button-group.tsx +7 -2
- package/src/components/calendar-heatmap.tsx +105 -10
- package/src/components/chart.tsx +19 -8
- package/src/components/chip.tsx +14 -4
- package/src/components/choice-card.tsx +136 -25
- package/src/components/combobox-core.tsx +33 -9
- package/src/components/combobox.tsx +33 -9
- package/src/components/copy-button.tsx +3 -2
- package/src/components/data-table-cells.tsx +119 -0
- package/src/components/data-table-filter-popover.tsx +13 -5
- package/src/components/data-table-filters.ts +77 -0
- package/src/components/data-table-labels.ts +23 -0
- package/src/components/data-table.tsx +230 -13
- package/src/components/date-picker.tsx +19 -3
- package/src/components/description-list.tsx +52 -3
- package/src/components/entity-combobox.tsx +25 -7
- package/src/components/error-boundary.tsx +640 -0
- package/src/components/field-sync.tsx +48 -40
- package/src/components/field.tsx +56 -5
- package/src/components/floating-panel.tsx +20 -7
- package/src/components/form-actions.tsx +191 -0
- package/src/components/full-bleed-dialog.tsx +4 -0
- package/src/components/hover-menu.tsx +26 -17
- 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 +82 -19
- package/src/components/loading-state.tsx +47 -0
- package/src/components/menu-item.tsx +7 -2
- package/src/components/mini-calendar.tsx +72 -3
- package/src/components/modal.tsx +65 -3
- package/src/components/month-picker.tsx +181 -26
- package/src/components/multi-entity-combobox.tsx +27 -7
- package/src/components/nav-pills.tsx +8 -1
- package/src/components/number-field.tsx +15 -10
- package/src/components/number-input.tsx +17 -1
- package/src/components/numpad-sheet.tsx +6 -1
- package/src/components/page-header.tsx +42 -5
- package/src/components/passkeys-setting.tsx +331 -0
- package/src/components/picker-sheet.tsx +10 -1
- package/src/components/pie-chart-labels.ts +32 -0
- package/src/components/pie-chart.tsx +479 -0
- package/src/components/popover.tsx +3 -0
- package/src/components/progress-bar.tsx +129 -19
- package/src/components/qr-code.tsx +83 -0
- package/src/components/search-field.tsx +20 -4
- package/src/components/signed-amount.tsx +295 -0
- package/src/components/skeleton.tsx +21 -3
- package/src/components/stat-tile.tsx +11 -3
- package/src/components/swipeable-row.tsx +7 -7
- package/src/components/table.tsx +102 -13
- package/src/components/text-link.tsx +356 -0
- package/src/components/text.tsx +46 -2
- package/src/components/toggle-group.tsx +93 -20
- package/src/components/toggle-legend.tsx +108 -2
- package/src/components/tooltip.tsx +79 -8
- package/src/components/ui.tsx +707 -49
- package/src/components/use-table-state.ts +265 -33
- package/src/data-table.ts +10 -0
- package/src/feedback/feedback-attachment.tsx +10 -7
- package/src/feedback/feedback-dialog.tsx +49 -7
- package/src/feedback/feedback-inbox.tsx +7 -0
- package/src/feedback/feedback-thread.tsx +510 -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 +30 -0
- package/src/i18n/kit-labels.tsx +118 -8
- package/src/i18n/locales/de.ts +161 -0
- package/src/i18n/locales/es.ts +158 -0
- package/src/i18n/locales/fr.ts +161 -0
- package/src/i18n/locales/hu.ts +159 -0
- package/src/i18n/locales/it.ts +159 -0
- package/src/i18n/locales/zh.ts +158 -0
- package/src/index.ts +59 -0
- package/src/lib/format.ts +348 -0
- package/src/lib/qr-encode.ts +420 -0
- package/src/rhf/fields.tsx +1135 -0
- package/src/rhf/line-items.tsx +130 -0
- package/src/rhf.ts +6 -0
- package/src/search/command-palette.tsx +29 -19
- package/src/shell/app-shell.tsx +153 -3
- package/src/shell/auth-layout.tsx +190 -0
- package/src/shell/top-bar-brand.tsx +76 -0
- package/src/shell/topbar-action-menu.tsx +32 -4
- package/src/shell.ts +2 -0
- package/src/tour/tour.tsx +16 -5
- package/src/wizard/use-wizard.ts +8 -4
- package/src/wizard/wizard-context.tsx +41 -1
- package/src/wizard/wizard-step.tsx +75 -6
- package/tokens.css +12 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link LineItems} over react-hook-form's `useFieldArray`. `@eifi1/ui-kit/rhf`.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <RhfLineItems
|
|
6
|
+
* name="components"
|
|
7
|
+
* newItem={() => ({ type: "net_rent", amount: null, validFrom: "" })}
|
|
8
|
+
* minItems={1}
|
|
9
|
+
* columns={[
|
|
10
|
+
* { key: "type", header: "Type", render: ({ name, label }) => (
|
|
11
|
+
* <RhfSelect name={`${name}.type`} options={TYPES} aria-label={label} />
|
|
12
|
+
* ) },
|
|
13
|
+
* { key: "amount", header: "Amount", width: "8rem", align: "end", render: ({ name, label }) => (
|
|
14
|
+
* <RhfMoneyField name={`${name}.amount`} ariaLabel={label} />
|
|
15
|
+
* ) },
|
|
16
|
+
* ]}
|
|
17
|
+
* totals={{ amount: formatMoney(total) }}
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The rows are the array's `fields` (keyed by their `id`, as react-hook-form asks),
|
|
22
|
+
* Add appends `newItem()`, remove is `remove(index)`, and an error on the array
|
|
23
|
+
* itself — the `rules` here, or a resolver's `lines: "At least two lines"` — is the
|
|
24
|
+
* list's `error`. Each cell's `render` gets `name`, the row's path
|
|
25
|
+
* (`"components.2"`), to bind its field to.
|
|
26
|
+
*/
|
|
27
|
+
import type { ReactNode } from "react";
|
|
28
|
+
import {
|
|
29
|
+
useFieldArray,
|
|
30
|
+
useFormState,
|
|
31
|
+
type FieldArray,
|
|
32
|
+
type FieldArrayPath,
|
|
33
|
+
type FieldArrayWithId,
|
|
34
|
+
type FieldValues,
|
|
35
|
+
type UseFieldArrayProps,
|
|
36
|
+
} from "react-hook-form";
|
|
37
|
+
import {
|
|
38
|
+
LineItems,
|
|
39
|
+
type LineItemCellContext,
|
|
40
|
+
type LineItemsColumn,
|
|
41
|
+
type LineItemsProps,
|
|
42
|
+
} from "../components/line-items";
|
|
43
|
+
|
|
44
|
+
/** A cell's context, with the row's path in the form. */
|
|
45
|
+
export interface RhfLineItemCellContext<TItem, TArrayName extends string>
|
|
46
|
+
extends LineItemCellContext<TItem> {
|
|
47
|
+
/** The row's path: `${name}.${index}` — bind the cell's field to
|
|
48
|
+
* `` `${name}.amount` ``. */
|
|
49
|
+
name: `${TArrayName}.${number}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RhfLineItemsColumn<TItem, TArrayName extends string>
|
|
53
|
+
extends Omit<LineItemsColumn<TItem>, "render"> {
|
|
54
|
+
render: (ctx: RhfLineItemCellContext<TItem, TArrayName>) => ReactNode;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RhfLineItemsProps<
|
|
58
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
59
|
+
TArrayName extends FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>,
|
|
60
|
+
TTransformed = TFieldValues,
|
|
61
|
+
> extends Omit<
|
|
62
|
+
LineItemsProps<FieldArrayWithId<TFieldValues, TArrayName, "id">>,
|
|
63
|
+
"items" | "columns" | "onAdd" | "onRemove" | "getKey"
|
|
64
|
+
> {
|
|
65
|
+
name: TArrayName;
|
|
66
|
+
/** `form.control`. Optional under `<Form {...form}>`. */
|
|
67
|
+
control?: UseFieldArrayProps<TFieldValues, TArrayName, "id", TTransformed>["control"];
|
|
68
|
+
/** `useFieldArray`'s `rules` (`minLength`, `validate` …); its message is the list's
|
|
69
|
+
* error. */
|
|
70
|
+
rules?: UseFieldArrayProps<TFieldValues, TArrayName, "id", TTransformed>["rules"];
|
|
71
|
+
columns: readonly RhfLineItemsColumn<FieldArrayWithId<TFieldValues, TArrayName, "id">, TArrayName>[];
|
|
72
|
+
/** A new row's values. Omitted: no add button. */
|
|
73
|
+
newItem?: () => FieldArray<TFieldValues, TArrayName>;
|
|
74
|
+
/** `false`: no remove buttons. Default `true`. */
|
|
75
|
+
removable?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The message of an error at `path` in react-hook-form's errors tree: the array's
|
|
79
|
+
* own (`root`, from `rules`) or one a resolver put on the array. */
|
|
80
|
+
function arrayError(errors: unknown, path: string): string | undefined {
|
|
81
|
+
let node: unknown = errors;
|
|
82
|
+
for (const key of path.split(".")) {
|
|
83
|
+
if (node === null || typeof node !== "object") return undefined;
|
|
84
|
+
node = (node as Record<string, unknown>)[key];
|
|
85
|
+
}
|
|
86
|
+
if (node === null || typeof node !== "object") return undefined;
|
|
87
|
+
const { root, message } = node as { root?: { message?: unknown }; message?: unknown };
|
|
88
|
+
const text = root?.message ?? message;
|
|
89
|
+
return typeof text === "string" && text !== "" ? text : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** {@link LineItems} bound to a field array. See the module note. */
|
|
93
|
+
export function RhfLineItems<
|
|
94
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
95
|
+
TArrayName extends FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>,
|
|
96
|
+
TTransformed = TFieldValues,
|
|
97
|
+
>({
|
|
98
|
+
name,
|
|
99
|
+
control,
|
|
100
|
+
rules,
|
|
101
|
+
columns,
|
|
102
|
+
newItem,
|
|
103
|
+
removable = true,
|
|
104
|
+
error,
|
|
105
|
+
...rest
|
|
106
|
+
}: RhfLineItemsProps<TFieldValues, TArrayName, TTransformed>) {
|
|
107
|
+
const { fields, append, remove } = useFieldArray<TFieldValues, TArrayName, "id", TTransformed>({
|
|
108
|
+
control,
|
|
109
|
+
name,
|
|
110
|
+
rules,
|
|
111
|
+
});
|
|
112
|
+
const { errors } = useFormState({ control, name: name as never });
|
|
113
|
+
const bound: LineItemsColumn<FieldArrayWithId<TFieldValues, TArrayName, "id">>[] = columns.map(
|
|
114
|
+
({ render, ...column }) => ({
|
|
115
|
+
...column,
|
|
116
|
+
render: (ctx) => render({ ...ctx, name: `${name}.${ctx.index}` as `${TArrayName}.${number}` }),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
return (
|
|
120
|
+
<LineItems
|
|
121
|
+
{...rest}
|
|
122
|
+
items={fields}
|
|
123
|
+
columns={bound}
|
|
124
|
+
getKey={(field) => field.id}
|
|
125
|
+
onAdd={newItem ? () => append(newItem()) : undefined}
|
|
126
|
+
onRemove={removable ? (index) => remove(index) : undefined}
|
|
127
|
+
error={error ?? arrayError(errors, name)}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
package/src/rhf.ts
CHANGED
|
@@ -9,3 +9,9 @@
|
|
|
9
9
|
export * from "./rhf/form";
|
|
10
10
|
// The react-hook-form bridge to the wizard's Next gate (useWizard lives in /wizard).
|
|
11
11
|
export * from "./rhf/use-rhf-wizard-step";
|
|
12
|
+
// Bound fields — one line per field (RhfTextField, RhfNumberField, RhfIntegerField, RhfMoneyField,
|
|
13
|
+
// RhfDateField, RhfTextarea, RhfSelect, RhfCheckbox, RhfCombobox, RhfTextCombobox)
|
|
14
|
+
// and RhfField, the shell they are built on.
|
|
15
|
+
export * from "./rhf/fields";
|
|
16
|
+
// LineItems over useFieldArray.
|
|
17
|
+
export * from "./rhf/line-items";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
4
|
import { CornerDownLeft, Search, X } from "lucide-react";
|
|
@@ -54,9 +54,12 @@ function groupItems(items: CommandItem[]) {
|
|
|
54
54
|
}
|
|
55
55
|
byGroup.get(item.group)!.push(item);
|
|
56
56
|
}
|
|
57
|
+
const flat = order.flatMap((g) => byGroup.get(g)!).filter((item) => item.kind !== "status");
|
|
57
58
|
return {
|
|
58
59
|
groups: order.map((g) => ({ group: g, items: byGroup.get(g)! })),
|
|
59
|
-
flat
|
|
60
|
+
flat,
|
|
61
|
+
/** Each selectable item's position in `flat` — its `data-index` and keyboard slot. */
|
|
62
|
+
flatIndex: new Map(flat.map((item, index) => [item, index])),
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -279,8 +282,9 @@ export function CommandPalette({
|
|
|
279
282
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
280
283
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
281
284
|
const listRef = useRef<HTMLUListElement>(null);
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
// The latest `search`, callable from the debounced effect without being one of its
|
|
286
|
+
// dependencies — `revision` is what re-runs it (see the prop's docstring).
|
|
287
|
+
const runSearch = useEffectEvent((q: string) => search(q));
|
|
284
288
|
const reqId = useRef(0);
|
|
285
289
|
/** What the provider's last answer was: a promise (`false`), an array (`true`), or
|
|
286
290
|
* not known yet. Only a provider known to be async shows "Searching…" while the
|
|
@@ -305,17 +309,24 @@ export function CommandPalette({
|
|
|
305
309
|
useBodyScrollLock(open);
|
|
306
310
|
|
|
307
311
|
// Reset when opened — the palette's OWN text only; a controlled query belongs to
|
|
308
|
-
// its owner (see the prop).
|
|
312
|
+
// its owner (see the prop). Adjusted during render on the closed-to-open transition
|
|
313
|
+
// (starting from "closed", so a palette mounted open resets too). `controlled` is
|
|
314
|
+
// read at the open, not watched: a caller switching modes while the palette is open
|
|
315
|
+
// is not a reason to wipe what is typed.
|
|
316
|
+
const [wasOpen, setWasOpen] = useState(false);
|
|
317
|
+
if (open !== wasOpen) {
|
|
318
|
+
setWasOpen(open);
|
|
319
|
+
if (open) {
|
|
320
|
+
if (!controlled) setOwnQuery("");
|
|
321
|
+
// An uncommitted draft does not outlive the open it was typed in.
|
|
322
|
+
setDraft(controlled ? query : "");
|
|
323
|
+
setActive(0);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// The ref half of that reset: results from a previous open do not carry a
|
|
327
|
+
// highlight into this one.
|
|
309
328
|
useEffect(() => {
|
|
310
|
-
if (
|
|
311
|
-
if (!controlled) setOwnQuery("");
|
|
312
|
-
// An uncommitted draft does not outlive the open it was typed in.
|
|
313
|
-
setDraft(controlled ? query : "");
|
|
314
|
-
setActive(0);
|
|
315
|
-
shownQuery.current = null;
|
|
316
|
-
// `controlled` is read at the open, not watched: a caller switching modes while
|
|
317
|
-
// the palette is open is not a reason to wipe what is typed.
|
|
318
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
329
|
+
if (open) shownQuery.current = null;
|
|
319
330
|
}, [open]);
|
|
320
331
|
|
|
321
332
|
// Debounced, race-safe search.
|
|
@@ -325,7 +336,7 @@ export function CommandPalette({
|
|
|
325
336
|
if (syncRef.current === false) setLoading(true);
|
|
326
337
|
const run = async () => {
|
|
327
338
|
try {
|
|
328
|
-
const answer =
|
|
339
|
+
const answer = runSearch(query);
|
|
329
340
|
syncRef.current = !isThenable(answer);
|
|
330
341
|
if (!syncRef.current) setLoading(true);
|
|
331
342
|
const r = await answer;
|
|
@@ -359,7 +370,7 @@ export function CommandPalette({
|
|
|
359
370
|
}, [query, open, revision]);
|
|
360
371
|
|
|
361
372
|
// Group results, preserving first-seen group order; keep a flat list for nav.
|
|
362
|
-
const { groups, flat } = useMemo(() => groupItems(results), [results]);
|
|
373
|
+
const { groups, flat, flatIndex } = useMemo(() => groupItems(results), [results]);
|
|
363
374
|
useEffect(() => {
|
|
364
375
|
activeIdRef.current = flat[active]?.id;
|
|
365
376
|
});
|
|
@@ -404,8 +415,8 @@ export function CommandPalette({
|
|
|
404
415
|
if (!open) return null;
|
|
405
416
|
|
|
406
417
|
const comfortable = density === "comfortable";
|
|
407
|
-
let flatIndex = -1;
|
|
408
418
|
return createPortal(
|
|
419
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer-only backdrop dismiss; Escape is the keyboard path
|
|
409
420
|
<div
|
|
410
421
|
className={cn(
|
|
411
422
|
"fixed inset-0 z-[70] flex items-start justify-center bg-black/40",
|
|
@@ -546,8 +557,7 @@ export function CommandPalette({
|
|
|
546
557
|
</li>
|
|
547
558
|
);
|
|
548
559
|
}
|
|
549
|
-
flatIndex
|
|
550
|
-
const idx = flatIndex;
|
|
560
|
+
const idx = flatIndex.get(item) ?? -1;
|
|
551
561
|
const isActive = idx === active;
|
|
552
562
|
const redact = item.redact ?? redactLabels;
|
|
553
563
|
// Written once and worn by either tag below, so the anchor and the
|
package/src/shell/app-shell.tsx
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
|
-
import { NavLink, matchPath, useLocation } from "react-router";
|
|
4
|
+
import { Link, NavLink, matchPath, useLocation } from "react-router";
|
|
5
5
|
import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
6
6
|
import type { LucideIcon } from "lucide-react";
|
|
7
7
|
import { cn } from "../lib/cn";
|
|
8
8
|
import { dirOf, type Direction } from "../lib/direction";
|
|
9
9
|
import { readStored, writeStored } from "../lib/safe-storage";
|
|
10
10
|
import { Tooltip } from "../components/tooltip";
|
|
11
|
+
import { pickLinkRenderer } from "../components/text-link";
|
|
11
12
|
import { useAnchoredRect } from "../hooks/use-anchored-rect";
|
|
12
13
|
import { useEscapeKey } from "../hooks/use-dismiss";
|
|
13
14
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
14
|
-
import { DEFAULT_APP_SHELL_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
15
|
+
import { DEFAULT_APP_SHELL_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
16
|
+
import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
|
|
15
17
|
|
|
16
18
|
export interface AppShellSubItem {
|
|
17
19
|
to: string;
|
|
@@ -48,6 +50,44 @@ export interface AppShellNavItem {
|
|
|
48
50
|
items?: AppShellSubItem[];
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* One entry of the sidebar's bottom area ({@link AppShellProps.sidebarFooterItems}) —
|
|
55
|
+
* the version link and the legal links kastlan hand-built in `sidebarFooter`.
|
|
56
|
+
*
|
|
57
|
+
* - `link` (default): one link. Expanded it shows `icon` + `text` (default `label`),
|
|
58
|
+
* with `hint` as a tooltip if given; collapsed it is the icon alone, named and
|
|
59
|
+
* tooltipped by `label`.
|
|
60
|
+
* - `links`: a small group — the legal links. Expanded, a `<nav>` named `label` holding
|
|
61
|
+
* the links as a wrapping row of small text; collapsed, ONE icon link to `to` (default
|
|
62
|
+
* the first link's), named and tooltipped by `label`, since a column of icons for
|
|
63
|
+
* "Imprint", "Privacy" and "Terms" would say nothing an icon can.
|
|
64
|
+
*/
|
|
65
|
+
export type AppShellSidebarFooterItem =
|
|
66
|
+
| {
|
|
67
|
+
kind?: "link";
|
|
68
|
+
key: string;
|
|
69
|
+
to: string;
|
|
70
|
+
icon: LucideIcon;
|
|
71
|
+
/** The link's name, and the tooltip when collapsed ("What's new · v1.4.0"). */
|
|
72
|
+
label: string;
|
|
73
|
+
/** Visible text when expanded ("Kastlan v1.4.0"). Default `label`. */
|
|
74
|
+
text?: ReactNode;
|
|
75
|
+
/** A tooltip on the expanded link ("What's new"). */
|
|
76
|
+
hint?: string;
|
|
77
|
+
/** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`. */
|
|
78
|
+
external?: boolean;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
kind: "links";
|
|
82
|
+
key: string;
|
|
83
|
+
icon: LucideIcon;
|
|
84
|
+
/** The group's name — its `<nav>` label, and the collapsed icon's name ("Legal"). */
|
|
85
|
+
label: string;
|
|
86
|
+
/** The collapsed icon's target. Default the first link's `to`. */
|
|
87
|
+
to?: string;
|
|
88
|
+
links: { to: string; label: string; external?: boolean }[];
|
|
89
|
+
};
|
|
90
|
+
|
|
51
91
|
/**
|
|
52
92
|
* Exported and `<div>`-shaped. This is the outermost element of every consuming app, so
|
|
53
93
|
* there is nothing above it to hang an id, a landmark label or a `data-tour` anchor on —
|
|
@@ -66,6 +106,13 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
66
106
|
/** Extra sidebar content above the collapse toggle (e.g. a version link).
|
|
67
107
|
* Receives the collapsed state so it can render compact vs. full. */
|
|
68
108
|
sidebarFooter?: (collapsed: boolean) => ReactNode;
|
|
109
|
+
/** The sidebar's bottom area as data — a version link, the legal links — drawn full
|
|
110
|
+
* when expanded and as icon + tooltip when collapsed. Rendered above
|
|
111
|
+
* `sidebarFooter`, which stays for anything the data cannot describe. */
|
|
112
|
+
sidebarFooterItems?: AppShellSidebarFooterItem[];
|
|
113
|
+
/** The router link for `sidebarFooterItems`. Default: the provider's
|
|
114
|
+
* `linkComponent`, else react-router's `Link`. */
|
|
115
|
+
renderLink?: KitLinkComponent;
|
|
69
116
|
/** localStorage key for the persisted collapse state. */
|
|
70
117
|
collapseStorageKey?: string;
|
|
71
118
|
/** Default: `appShell.collapse` from the {@link UiKitProvider}, else English. */
|
|
@@ -201,6 +248,8 @@ export function AppShell({
|
|
|
201
248
|
children,
|
|
202
249
|
footer,
|
|
203
250
|
sidebarFooter,
|
|
251
|
+
sidebarFooterItems,
|
|
252
|
+
renderLink,
|
|
204
253
|
collapseStorageKey: collapseStorageKeyProp,
|
|
205
254
|
collapseLabel,
|
|
206
255
|
expandLabel,
|
|
@@ -296,6 +345,9 @@ export function AppShell({
|
|
|
296
345
|
))}
|
|
297
346
|
</div>
|
|
298
347
|
</nav>
|
|
348
|
+
{!!sidebarFooterItems?.length && (
|
|
349
|
+
<SidebarFooterItems items={sidebarFooterItems} collapsed={collapsed} renderLink={renderLink} />
|
|
350
|
+
)}
|
|
299
351
|
{sidebarFooter?.(collapsed)}
|
|
300
352
|
<div className="border-t border-[var(--border)] p-2">
|
|
301
353
|
{collapsed ? (
|
|
@@ -397,6 +449,99 @@ export function AppShell({
|
|
|
397
449
|
);
|
|
398
450
|
}
|
|
399
451
|
|
|
452
|
+
const routerLink: KitLinkComponent = ({ href, ...p }) => <Link to={href} {...p} />;
|
|
453
|
+
const plainLink: KitLinkComponent = ({ children, ...p }) => <a {...p}>{children}</a>;
|
|
454
|
+
|
|
455
|
+
const FOOTER_ICON_LINK =
|
|
456
|
+
"flex min-h-8 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]";
|
|
457
|
+
|
|
458
|
+
/** Calls the link renderer as a component, so a router link's hooks are its own. */
|
|
459
|
+
function RenderedLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
|
|
460
|
+
return render(props);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The sidebar's bottom area, from {@link AppShellSidebarFooterItem}s. */
|
|
464
|
+
function SidebarFooterItems({
|
|
465
|
+
items,
|
|
466
|
+
collapsed,
|
|
467
|
+
renderLink,
|
|
468
|
+
}: {
|
|
469
|
+
items: AppShellSidebarFooterItem[];
|
|
470
|
+
collapsed: boolean;
|
|
471
|
+
renderLink?: KitLinkComponent;
|
|
472
|
+
}) {
|
|
473
|
+
const kitLink = useKitLink();
|
|
474
|
+
const linkTo = (to: string, external: boolean | undefined, { children, ...props }: Omit<KitLinkProps, "href">) => {
|
|
475
|
+
// The kit's one link rule (`pickLinkRenderer`): an external `to` or an in-page
|
|
476
|
+
// `#anchor` is a plain `<a>` even without `external`; only `external` opens a tab.
|
|
477
|
+
const InApp = pickLinkRenderer(renderLink, kitLink ?? routerLink, to) ?? plainLink;
|
|
478
|
+
return external ? (
|
|
479
|
+
<a href={to} target="_blank" rel="noopener noreferrer" {...props}>
|
|
480
|
+
{children}
|
|
481
|
+
</a>
|
|
482
|
+
) : (
|
|
483
|
+
<RenderedLink render={InApp} href={to} {...props}>
|
|
484
|
+
{children}
|
|
485
|
+
</RenderedLink>
|
|
486
|
+
);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<div data-slot="sidebar-footer-items" className={cn("px-2 py-1", collapsed ? "space-y-1" : "space-y-1.5")}>
|
|
491
|
+
{items.map((item) => {
|
|
492
|
+
if (collapsed) {
|
|
493
|
+
const to = item.kind === "links" ? (item.to ?? item.links[0]?.to) : item.to;
|
|
494
|
+
if (!to) return null;
|
|
495
|
+
const external = item.kind === "links" ? item.links.find((l) => l.to === to)?.external : item.external;
|
|
496
|
+
return (
|
|
497
|
+
<Tooltip key={item.key} label={item.label} side="end" portal className="block">
|
|
498
|
+
{linkTo(to, external, {
|
|
499
|
+
"aria-label": item.label,
|
|
500
|
+
className: FOOTER_ICON_LINK,
|
|
501
|
+
children: <item.icon className="size-4" aria-hidden />,
|
|
502
|
+
})}
|
|
503
|
+
</Tooltip>
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (item.kind === "links") {
|
|
507
|
+
return (
|
|
508
|
+
<nav
|
|
509
|
+
key={item.key}
|
|
510
|
+
aria-label={item.label}
|
|
511
|
+
className="flex flex-wrap justify-center gap-x-3 gap-y-0.5 text-[11px] text-[var(--text-muted)]"
|
|
512
|
+
>
|
|
513
|
+
{item.links.map((l) => (
|
|
514
|
+
<span key={l.to}>
|
|
515
|
+
{linkTo(l.to, l.external, {
|
|
516
|
+
className: "rounded-sm hover:text-[var(--text-secondary)] hover:underline",
|
|
517
|
+
children: l.label,
|
|
518
|
+
})}
|
|
519
|
+
</span>
|
|
520
|
+
))}
|
|
521
|
+
</nav>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const link = linkTo(item.to, item.external, {
|
|
525
|
+
className: cn(FOOTER_ICON_LINK, "gap-1.5 text-xs font-medium"),
|
|
526
|
+
children: (
|
|
527
|
+
<>
|
|
528
|
+
<item.icon className="size-3.5 shrink-0" aria-hidden />
|
|
529
|
+
<span className="min-w-0 truncate">{item.text ?? item.label}</span>
|
|
530
|
+
</>
|
|
531
|
+
),
|
|
532
|
+
});
|
|
533
|
+
return item.hint ? (
|
|
534
|
+
<Tooltip key={item.key} label={item.hint} side="top" portal className="block">
|
|
535
|
+
{link}
|
|
536
|
+
</Tooltip>
|
|
537
|
+
) : (
|
|
538
|
+
<div key={item.key}>{link}</div>
|
|
539
|
+
);
|
|
540
|
+
})}
|
|
541
|
+
</div>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
400
545
|
/** One cell of the phone's group bar. Marked while the group's own page OR any of its
|
|
401
546
|
* pages is current — NavLink alone only knows its own `to`, which left the bar with
|
|
402
547
|
* nothing marked on every sub-page (the same defect the sidebar had). */
|
|
@@ -620,7 +765,6 @@ function SidebarGroup({
|
|
|
620
765
|
// group UNFOLDS into view on load rather than appearing already open.
|
|
621
766
|
const here = subActive || onOwnPage;
|
|
622
767
|
useEffect(() => {
|
|
623
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- follows the route, see above
|
|
624
768
|
if (here) setOpenGroup(item.to);
|
|
625
769
|
}, [here, item.to, setOpenGroup]);
|
|
626
770
|
const expanded = inline && open;
|
|
@@ -786,6 +930,9 @@ function SidebarFlyout({ item, children }: { item: AppShellNavItem; children: Re
|
|
|
786
930
|
);
|
|
787
931
|
|
|
788
932
|
return (
|
|
933
|
+
// Not a control: hover and focus-within on the nav link inside. Focus (the
|
|
934
|
+
// keyboard path) opens it exactly as hover does, Escape closes it.
|
|
935
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- hover/focus-within wrapper around a real link
|
|
789
936
|
<div
|
|
790
937
|
ref={wrapperRef}
|
|
791
938
|
className="relative"
|
|
@@ -798,6 +945,9 @@ function SidebarFlyout({ item, children }: { item: AppShellNavItem; children: Re
|
|
|
798
945
|
{open &&
|
|
799
946
|
pos &&
|
|
800
947
|
createPortal(
|
|
948
|
+
// Keeps the flyout open while the pointer crosses into it; keyboard users
|
|
949
|
+
// reach it by focus, which the wrapper above already handles.
|
|
950
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer-only hover bridge
|
|
801
951
|
<div
|
|
802
952
|
dir={dir}
|
|
803
953
|
style={{ position: "fixed", ...pos }}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { Card } from "../components/ui";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `extends ComponentPropsWithoutRef<"div">` because this is the page's outermost element
|
|
7
|
+
* — the one that owns `min-h-dvh` and the safe-area padding — so there is nothing a
|
|
8
|
+
* consumer could wrap it in to add an id or a `data-*` hook without breaking that.
|
|
9
|
+
*/
|
|
10
|
+
export interface AuthLayoutProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
|
|
11
|
+
/** The brand mark (an SVG), drawn in a brand-coloured tile above the title. */
|
|
12
|
+
logo?: ReactNode;
|
|
13
|
+
/** `false` draws `logo` as it is, without the tile — for a full-colour logo image. */
|
|
14
|
+
logoTile?: boolean;
|
|
15
|
+
/** The page's `<h1>` (see `headingAs`): the product name on sign-in, "Verify email",
|
|
16
|
+
* "Imprint". */
|
|
17
|
+
title?: ReactNode;
|
|
18
|
+
/** The muted line under the title. */
|
|
19
|
+
description?: ReactNode;
|
|
20
|
+
/** The card's content — the form, the status, the legal text. */
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
/**
|
|
23
|
+
* Above the header, outside the card — the legal pages' "← Back to sign in" link, or
|
|
24
|
+
* a notice banner. Start-aligned in both widths.
|
|
25
|
+
*/
|
|
26
|
+
back?: ReactNode;
|
|
27
|
+
/** The top-end corner: a `LanguageMenu`. */
|
|
28
|
+
languageMenu?: ReactNode;
|
|
29
|
+
/** The top-end corner, before the language menu: a `ThemeToggle`. */
|
|
30
|
+
themeToggle?: ReactNode;
|
|
31
|
+
/** Below the card, centred — the legal links. */
|
|
32
|
+
footer?: ReactNode;
|
|
33
|
+
/** Wraps the footer in a `<nav>` with this name ("Legal"). Without it, a plain block. */
|
|
34
|
+
footerLabel?: string;
|
|
35
|
+
/**
|
|
36
|
+
* - `"narrow"` (default) — a `max-w-sm` card centred on the page, header centred:
|
|
37
|
+
* sign-in, register, verify email, two-factor.
|
|
38
|
+
* - `"wide"` — a `max-w-3xl` column from the top of the page, header start-aligned:
|
|
39
|
+
* running text (imprint, privacy, terms), which reads badly centred.
|
|
40
|
+
*/
|
|
41
|
+
width?: "narrow" | "wide";
|
|
42
|
+
/** `false` lays the header and content on the page itself, without the card's
|
|
43
|
+
* surface and border — the legal pages. Default true. */
|
|
44
|
+
card?: boolean;
|
|
45
|
+
/** Extra classes for the card (or, with `card={false}`, the content column). */
|
|
46
|
+
cardClassName?: string;
|
|
47
|
+
/**
|
|
48
|
+
* `false` draws the content column and the footer as plain `<div>`s instead of
|
|
49
|
+
* `<main>` and `<footer>` — for an AuthLayout embedded in a page that already has its
|
|
50
|
+
* landmarks (a preview, a docs page, a dialog), where a second `<main>` would nest
|
|
51
|
+
* one inside the other. Default `true`: on its own route it IS the page.
|
|
52
|
+
*/
|
|
53
|
+
landmark?: boolean;
|
|
54
|
+
/** The title's element. Default `h1` — the page's heading; an embedded layout takes
|
|
55
|
+
* the level of where it sits (`h2`, `h3`). */
|
|
56
|
+
headingAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Keeps the page clear of a notch, the rounded corners and the home indicator on a
|
|
60
|
+
* phone in `viewport-fit=cover`; 1rem where the platform reports no inset. Physical
|
|
61
|
+
* left/right, because that is what `env()` reports. */
|
|
62
|
+
const SAFE_AREA: CSSProperties = {
|
|
63
|
+
paddingTop: "max(1rem, env(safe-area-inset-top, 0px))",
|
|
64
|
+
paddingBottom: "max(1rem, env(safe-area-inset-bottom, 0px))",
|
|
65
|
+
paddingLeft: "max(1rem, env(safe-area-inset-left, 0px))",
|
|
66
|
+
paddingRight: "max(1rem, env(safe-area-inset-right, 0px))",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const FOOTER_CLASS =
|
|
70
|
+
"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-[var(--text-muted)] [&_a:hover]:text-[var(--text-primary)] [&_a:hover]:underline [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-[var(--brand)]";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The public page frame outside the app shell: sign-in, registration, email
|
|
74
|
+
* verification, two-factor — and, `width="wide"`, the legal pages.
|
|
75
|
+
*
|
|
76
|
+
* kastlan's login, register, verify-email and two-factor pages each repeat the same
|
|
77
|
+
* `min-h-screen` centring, the absolutely placed language switcher, the brand tile, the
|
|
78
|
+
* centred title + subtitle and an absolutely placed legal footer. Absolutely placed,
|
|
79
|
+
* the footer and the switcher sat ON the card on a short phone screen (a tall register
|
|
80
|
+
* form, or the software keyboard up); here they are rows of a column that grows with
|
|
81
|
+
* its content, so the page scrolls instead of overlapping.
|
|
82
|
+
*
|
|
83
|
+
* The corner controls, the `<main>` and the footer are in reading order: a screen
|
|
84
|
+
* reader meets the language switch first, which is where someone who cannot read the
|
|
85
|
+
* page's language needs it.
|
|
86
|
+
*/
|
|
87
|
+
export function AuthLayout({
|
|
88
|
+
logo,
|
|
89
|
+
logoTile = true,
|
|
90
|
+
title,
|
|
91
|
+
description,
|
|
92
|
+
children,
|
|
93
|
+
back,
|
|
94
|
+
languageMenu,
|
|
95
|
+
themeToggle,
|
|
96
|
+
footer,
|
|
97
|
+
footerLabel,
|
|
98
|
+
width = "narrow",
|
|
99
|
+
card = true,
|
|
100
|
+
cardClassName,
|
|
101
|
+
landmark = true,
|
|
102
|
+
headingAs: Heading = "h1",
|
|
103
|
+
className,
|
|
104
|
+
style,
|
|
105
|
+
...rest
|
|
106
|
+
}: AuthLayoutProps) {
|
|
107
|
+
const Main = landmark ? "main" : "div";
|
|
108
|
+
const Footer = landmark ? "footer" : "div";
|
|
109
|
+
const wide = width === "wide";
|
|
110
|
+
const hasCorner = languageMenu != null || themeToggle != null;
|
|
111
|
+
const hasHeader = logo != null || title != null || description != null;
|
|
112
|
+
|
|
113
|
+
const header = hasHeader && (
|
|
114
|
+
<div
|
|
115
|
+
data-slot="auth-layout-header"
|
|
116
|
+
className={cn("flex flex-col gap-1.5", wide ? "items-start text-start" : "items-center text-center")}
|
|
117
|
+
>
|
|
118
|
+
{logo != null &&
|
|
119
|
+
(logoTile ? (
|
|
120
|
+
<div className="mb-2 flex size-12 items-center justify-center rounded-lg bg-[var(--brand)] text-[var(--brand-contrast)] [&_svg]:size-6">
|
|
121
|
+
{logo}
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<div className="mb-2">{logo}</div>
|
|
125
|
+
))}
|
|
126
|
+
{title != null && (
|
|
127
|
+
<Heading
|
|
128
|
+
className={cn(
|
|
129
|
+
"font-semibold leading-tight text-[var(--text-primary)]",
|
|
130
|
+
wide ? "text-2xl md:text-3xl" : "text-2xl",
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
{title}
|
|
134
|
+
</Heading>
|
|
135
|
+
)}
|
|
136
|
+
{description != null && <p className="text-sm text-[var(--text-muted)]">{description}</p>}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const body = (
|
|
141
|
+
<>
|
|
142
|
+
{header}
|
|
143
|
+
<div className={cn(hasHeader && (wide ? "mt-6" : "mt-5"))}>{children}</div>
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
{...rest}
|
|
150
|
+
data-auth-layout={width}
|
|
151
|
+
style={{ ...SAFE_AREA, ...style }}
|
|
152
|
+
className={cn("flex min-h-dvh flex-col bg-[var(--bg-page)] text-[var(--text-primary)]", className)}
|
|
153
|
+
>
|
|
154
|
+
{hasCorner && (
|
|
155
|
+
<div data-slot="auth-layout-corner" className="flex items-center justify-end gap-1">
|
|
156
|
+
{themeToggle}
|
|
157
|
+
{languageMenu}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
<Main
|
|
161
|
+
data-slot="auth-layout-main"
|
|
162
|
+
className={cn(
|
|
163
|
+
// `relative` contains any `sr-only` child (see AppShell's <main>).
|
|
164
|
+
"relative flex w-full flex-1 flex-col",
|
|
165
|
+
wide ? "py-6 md:py-10" : "items-center justify-center py-8",
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
<div className={cn("mx-auto w-full", wide ? "max-w-3xl" : "max-w-sm")}>
|
|
169
|
+
{back != null && <div className="mb-4">{back}</div>}
|
|
170
|
+
{card ? (
|
|
171
|
+
<Card className={cn(wide ? "p-6 md:p-8" : "p-6", cardClassName)}>{body}</Card>
|
|
172
|
+
) : (
|
|
173
|
+
<div className={cardClassName}>{body}</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</Main>
|
|
177
|
+
{footer != null && footer !== false && (
|
|
178
|
+
<Footer data-slot="auth-layout-footer" className="pt-4">
|
|
179
|
+
{footerLabel ? (
|
|
180
|
+
<nav aria-label={footerLabel} className={FOOTER_CLASS}>
|
|
181
|
+
{footer}
|
|
182
|
+
</nav>
|
|
183
|
+
) : (
|
|
184
|
+
<div className={FOOTER_CLASS}>{footer}</div>
|
|
185
|
+
)}
|
|
186
|
+
</Footer>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|