@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,385 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, Key, ReactNode } from "react";
|
|
3
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
import { Button, IconButton } from "./ui";
|
|
7
|
+
|
|
8
|
+
/** The words {@link LineItems} renders on its own behalf. Row numbers are 1-based. */
|
|
9
|
+
export interface LineItemsLabels {
|
|
10
|
+
/** The dashed add button. */
|
|
11
|
+
add: string;
|
|
12
|
+
/** The remove button's name for one row. */
|
|
13
|
+
remove: (row: number) => string;
|
|
14
|
+
/** Its name while it waits for the second press (`confirmRemove`). */
|
|
15
|
+
confirmRemove: (row: number) => string;
|
|
16
|
+
/** A row's group name, when the caller gives no `rowLabel`. */
|
|
17
|
+
row: (row: number) => string;
|
|
18
|
+
/** The name a cell's field should carry: the column, and which row. */
|
|
19
|
+
cell: (column: string, row: number) => string;
|
|
20
|
+
/** The totals row's label, and its group name. */
|
|
21
|
+
totals: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_LINE_ITEMS_LABELS: LineItemsLabels = {
|
|
25
|
+
add: "Add row",
|
|
26
|
+
remove: (row) => `Remove row ${row}`,
|
|
27
|
+
confirmRemove: (row) => `Remove row ${row}? Press again to confirm`,
|
|
28
|
+
row: (row) => `Row ${row}`,
|
|
29
|
+
cell: (column, row) => `${column}, row ${row}`,
|
|
30
|
+
totals: "Total",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** What a column's `render` receives for one row. */
|
|
34
|
+
export interface LineItemCellContext<T> {
|
|
35
|
+
item: T;
|
|
36
|
+
/** 0-based. */
|
|
37
|
+
index: number;
|
|
38
|
+
/** The accessible name for the cell's field — "Debit, row 2". The header is
|
|
39
|
+
* hidden from assistive tech (each row repeats it), so the field has to carry it:
|
|
40
|
+
* `<Input aria-label={label} … />`. */
|
|
41
|
+
label: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LineItemsColumn<T> {
|
|
45
|
+
/** Stable id: the React key, and the key into `totals`. */
|
|
46
|
+
key: string;
|
|
47
|
+
/** The column header (and the small label over the cell once the rows stack). */
|
|
48
|
+
header: ReactNode;
|
|
49
|
+
/** The column's name for {@link LineItemCellContext.label}, when `header` is not a
|
|
50
|
+
* string. */
|
|
51
|
+
label?: string;
|
|
52
|
+
/** The grid track once the rows are wide enough to be a table. Default
|
|
53
|
+
* `minmax(0,1fr)`; `"7rem"` for an amount. */
|
|
54
|
+
width?: string;
|
|
55
|
+
/** `end` for figures: the header and the totals cell end-align with the field. */
|
|
56
|
+
align?: "start" | "end";
|
|
57
|
+
render: (ctx: LineItemCellContext<T>) => ReactNode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
61
|
+
items: readonly T[];
|
|
62
|
+
columns: readonly LineItemsColumn<T>[];
|
|
63
|
+
/** The row's React key. Default: the item's `id` (react-hook-form's `useFieldArray`
|
|
64
|
+
* `fields` carry one), else its index. */
|
|
65
|
+
getKey?: (item: T, index: number) => Key;
|
|
66
|
+
/** Append a row. Omitted: no add button. The first field of the new row is focused. */
|
|
67
|
+
onAdd?: () => void;
|
|
68
|
+
/** Remove the row at `index`. Omitted: no remove buttons. */
|
|
69
|
+
onRemove?: (index: number) => void;
|
|
70
|
+
/** Remove is disabled at or below this many rows (a journal entry needs two).
|
|
71
|
+
* Default 0. */
|
|
72
|
+
minItems?: number;
|
|
73
|
+
/** Add is disabled at this many rows. */
|
|
74
|
+
maxItems?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Ask before a row goes:
|
|
77
|
+
* - `true`: the remove button asks for a second press — it fills red and its name
|
|
78
|
+
* becomes `lineItems.confirmRemove`; Escape, or leaving it, calls it off.
|
|
79
|
+
* - a function: called with the row, and the row goes only if it answers `true`
|
|
80
|
+
* (`(item, i) => confirm({ title: … , tone: "danger" })` with `useConfirm`).
|
|
81
|
+
*/
|
|
82
|
+
confirmRemove?: boolean | ((item: T, index: number) => boolean | Promise<boolean>);
|
|
83
|
+
/** The add button's text. Default `lineItems.add`. */
|
|
84
|
+
addLabel?: ReactNode;
|
|
85
|
+
/** A row's group name ("Line 2"). Default `lineItems.row`. */
|
|
86
|
+
rowLabel?: (index: number, item: T) => string;
|
|
87
|
+
/** A totals row under the lines, one entry per column `key`. The first column
|
|
88
|
+
* without an entry carries {@link totalsLabel}. Omitted: no totals row. */
|
|
89
|
+
totals?: Partial<Record<string, ReactNode>>;
|
|
90
|
+
/** Default `lineItems.totals`, "Total". */
|
|
91
|
+
totalsLabel?: ReactNode;
|
|
92
|
+
/** Shown in place of the rows when there are none. */
|
|
93
|
+
empty?: ReactNode;
|
|
94
|
+
/** A message about the list as a whole ("The entry does not balance"). */
|
|
95
|
+
error?: ReactNode;
|
|
96
|
+
/** Disables add and remove (the fields are the caller's to disable). */
|
|
97
|
+
disabled?: boolean;
|
|
98
|
+
labels?: Partial<LineItemsLabels>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const REMOVE_TRACK = "2.25rem";
|
|
102
|
+
const FOCUSABLE =
|
|
103
|
+
"input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])";
|
|
104
|
+
|
|
105
|
+
function defaultKey(item: unknown, index: number): Key {
|
|
106
|
+
if (typeof item === "object" && item !== null && "id" in item) {
|
|
107
|
+
const id = (item as { id: unknown }).id;
|
|
108
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
109
|
+
}
|
|
110
|
+
return index;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasContent(node: ReactNode): boolean {
|
|
114
|
+
return node !== undefined && node !== null && node !== false && node !== "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type PendingFocus = { kind: "added" } | { kind: "removed"; index: number };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* An editable list of lines — journal entry lines, lease components, invoice
|
|
121
|
+
* positions, budget rows: column headers, a row of fields per item, a remove button
|
|
122
|
+
* per row, a dashed "add" button under them, and an optional totals row.
|
|
123
|
+
*
|
|
124
|
+
* Presentation only: the rows are the caller's `items` and the fields are the
|
|
125
|
+
* caller's, rendered per column. So it works over plain state —
|
|
126
|
+
*
|
|
127
|
+
* ```tsx
|
|
128
|
+
* <LineItems items={lines} columns={cols} onAdd={() => setLines([...lines, blank()])}
|
|
129
|
+
* onRemove={(i) => setLines(lines.filter((_, j) => j !== i))} minItems={2} />
|
|
130
|
+
* ```
|
|
131
|
+
*
|
|
132
|
+
* — and over react-hook-form's `useFieldArray` alike (`items={fields}`, `onAdd={() =>
|
|
133
|
+
* append(blank())}`, `onRemove={remove}`), for which `@eifi1/ui-kit/rhf` has
|
|
134
|
+
* `RhfLineItems`.
|
|
135
|
+
*
|
|
136
|
+
* On a wide pane the rows are a grid under the headers; on a narrow one (a container
|
|
137
|
+
* query, so a dialog stacks where it is cramped) each row stacks its fields, each
|
|
138
|
+
* under a small copy of its header. The headers are `aria-hidden`: every row is a
|
|
139
|
+
* named group and every field is named by `render`'s `label` ("Debit, row 2"), which
|
|
140
|
+
* is what a screen reader needs — a header row it would read once, out of context.
|
|
141
|
+
*
|
|
142
|
+
* Keyboard: after Add, focus lands in the new row's first field; after a remove, on
|
|
143
|
+
* the row that took its place (else the add button). Ctrl/⌘+Enter in any row adds a
|
|
144
|
+
* row.
|
|
145
|
+
*/
|
|
146
|
+
export function LineItems<T>({
|
|
147
|
+
items,
|
|
148
|
+
columns,
|
|
149
|
+
getKey = defaultKey,
|
|
150
|
+
onAdd,
|
|
151
|
+
onRemove,
|
|
152
|
+
minItems = 0,
|
|
153
|
+
maxItems,
|
|
154
|
+
confirmRemove,
|
|
155
|
+
addLabel,
|
|
156
|
+
rowLabel,
|
|
157
|
+
totals,
|
|
158
|
+
totalsLabel,
|
|
159
|
+
empty,
|
|
160
|
+
error,
|
|
161
|
+
disabled = false,
|
|
162
|
+
labels: labelsProp,
|
|
163
|
+
className,
|
|
164
|
+
style,
|
|
165
|
+
...rest
|
|
166
|
+
}: LineItemsProps<T>) {
|
|
167
|
+
const labels = useKitLabels("lineItems", DEFAULT_LINE_ITEMS_LABELS, labelsProp);
|
|
168
|
+
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
169
|
+
const addRef = useRef<HTMLButtonElement>(null);
|
|
170
|
+
const pendingFocus = useRef<PendingFocus | null>(null);
|
|
171
|
+
const [confirmingAt, setConfirming] = useState<number | null>(null);
|
|
172
|
+
// A confirm waiting on a row that has since gone is void.
|
|
173
|
+
const confirming = confirmingAt !== null && confirmingAt < items.length ? confirmingAt : null;
|
|
174
|
+
|
|
175
|
+
const tracks = [...columns.map((c) => c.width ?? "minmax(0,1fr)"), ...(onRemove ? [REMOVE_TRACK] : [])];
|
|
176
|
+
const gridStyle = { "--line-items-cols": tracks.join(" ") } as CSSProperties;
|
|
177
|
+
const canAdd = Boolean(onAdd) && !disabled && (maxItems === undefined || items.length < maxItems);
|
|
178
|
+
const canRemove = Boolean(onRemove) && !disabled && items.length > minItems;
|
|
179
|
+
const hasError = hasContent(error);
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
// Focus after the list has re-rendered with the new rows — a layout effect, so the
|
|
183
|
+
// caret is where it belongs before paint.
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
const pending = pendingFocus.current;
|
|
186
|
+
if (!pending) return;
|
|
187
|
+
pendingFocus.current = null;
|
|
188
|
+
if (pending.kind === "added") {
|
|
189
|
+
const row = rowRefs.current[items.length - 1];
|
|
190
|
+
row?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const row = rowRefs.current[Math.min(pending.index, items.length - 1)];
|
|
194
|
+
const target =
|
|
195
|
+
row?.querySelector<HTMLElement>("[data-line-items-remove]:not([disabled])") ??
|
|
196
|
+
row?.querySelector<HTMLElement>(FOCUSABLE) ??
|
|
197
|
+
addRef.current;
|
|
198
|
+
target?.focus();
|
|
199
|
+
}, [items.length]);
|
|
200
|
+
|
|
201
|
+
const add = useCallback(() => {
|
|
202
|
+
if (!canAdd || !onAdd) return;
|
|
203
|
+
pendingFocus.current = { kind: "added" };
|
|
204
|
+
onAdd();
|
|
205
|
+
}, [canAdd, onAdd]);
|
|
206
|
+
|
|
207
|
+
const removeAt = async (index: number) => {
|
|
208
|
+
if (!canRemove || !onRemove) return;
|
|
209
|
+
if (confirmRemove === true && confirming !== index) {
|
|
210
|
+
setConfirming(index);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (typeof confirmRemove === "function") {
|
|
214
|
+
const ok = await confirmRemove(items[index], index);
|
|
215
|
+
if (!ok) return;
|
|
216
|
+
}
|
|
217
|
+
setConfirming(null);
|
|
218
|
+
pendingFocus.current = { kind: "removed", index };
|
|
219
|
+
onRemove(index);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const onRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
223
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && canAdd) {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
add();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const columnName = (column: LineItemsColumn<T>) =>
|
|
230
|
+
column.label ?? (typeof column.header === "string" ? column.header : column.key);
|
|
231
|
+
|
|
232
|
+
const headerCell = (column: LineItemsColumn<T>) => (
|
|
233
|
+
<span
|
|
234
|
+
key={column.key}
|
|
235
|
+
className={cn(
|
|
236
|
+
"min-w-0 truncate text-xs font-medium text-[var(--text-muted)]",
|
|
237
|
+
column.align === "end" && "text-end",
|
|
238
|
+
)}
|
|
239
|
+
>
|
|
240
|
+
{column.header}
|
|
241
|
+
</span>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const showTotals = totals !== undefined;
|
|
245
|
+
const labelColumn = showTotals ? columns.find((c) => !hasContent(totals[c.key]))?.key : undefined;
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div
|
|
249
|
+
data-slot="line-items"
|
|
250
|
+
{...rest}
|
|
251
|
+
style={{ ...gridStyle, ...style }}
|
|
252
|
+
className={cn("@container space-y-2", className)}
|
|
253
|
+
>
|
|
254
|
+
<div aria-hidden className="hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]">
|
|
255
|
+
{columns.map(headerCell)}
|
|
256
|
+
{onRemove && <span />}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{items.length === 0 && hasContent(empty) && (
|
|
260
|
+
<div className="rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-sm text-[var(--text-muted)]">
|
|
261
|
+
{empty}
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
{items.map((item, index) => {
|
|
266
|
+
const row = index + 1;
|
|
267
|
+
const isConfirming = confirming === index;
|
|
268
|
+
return (
|
|
269
|
+
// The row's Ctrl/⌘+Enter is a shortcut for the add button, which is itself
|
|
270
|
+
// reachable by Tab; the group only listens for it bubbling from its fields.
|
|
271
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
272
|
+
<div
|
|
273
|
+
key={getKey(item, index)}
|
|
274
|
+
ref={(el) => {
|
|
275
|
+
rowRefs.current[index] = el;
|
|
276
|
+
}}
|
|
277
|
+
role="group"
|
|
278
|
+
aria-label={rowLabel ? rowLabel(index, item) : labels.row(row)}
|
|
279
|
+
data-line-items-row=""
|
|
280
|
+
onKeyDown={onRowKeyDown}
|
|
281
|
+
className={cn(
|
|
282
|
+
"grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start",
|
|
283
|
+
// Stacked, each row is a card of its own so the rows stay apart.
|
|
284
|
+
"@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3",
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
{columns.map((column) => (
|
|
288
|
+
<div key={column.key} className="min-w-0 space-y-1 @lg:space-y-0">
|
|
289
|
+
<span aria-hidden className="block text-xs font-medium text-[var(--text-muted)] @lg:hidden">
|
|
290
|
+
{column.header}
|
|
291
|
+
</span>
|
|
292
|
+
{column.render({ item, index, label: labels.cell(columnName(column), row) })}
|
|
293
|
+
</div>
|
|
294
|
+
))}
|
|
295
|
+
{onRemove && (
|
|
296
|
+
<div className="flex justify-end @lg:justify-center">
|
|
297
|
+
<IconButton
|
|
298
|
+
type="button"
|
|
299
|
+
data-line-items-remove=""
|
|
300
|
+
variant={isConfirming ? "danger" : "ghost"}
|
|
301
|
+
tone={isConfirming ? undefined : "danger"}
|
|
302
|
+
label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}
|
|
303
|
+
disabled={!canRemove}
|
|
304
|
+
onClick={() => void removeAt(index)}
|
|
305
|
+
onBlur={() => {
|
|
306
|
+
if (isConfirming) setConfirming(null);
|
|
307
|
+
}}
|
|
308
|
+
onKeyDown={(e) => {
|
|
309
|
+
if (e.key === "Escape" && isConfirming) {
|
|
310
|
+
e.stopPropagation();
|
|
311
|
+
setConfirming(null);
|
|
312
|
+
}
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
<Trash2 />
|
|
316
|
+
</IconButton>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
})}
|
|
322
|
+
|
|
323
|
+
{showTotals && (
|
|
324
|
+
<div
|
|
325
|
+
role="group"
|
|
326
|
+
aria-label={typeof totalsLabel === "string" ? totalsLabel : labels.totals}
|
|
327
|
+
data-line-items-totals=""
|
|
328
|
+
className="grid grid-cols-1 gap-2 border-t border-[var(--border)] pt-2 text-sm font-semibold tabular-nums text-[var(--text-primary)] @lg:grid-cols-[var(--line-items-cols)]"
|
|
329
|
+
>
|
|
330
|
+
{columns.map((column) => {
|
|
331
|
+
const value = totals[column.key];
|
|
332
|
+
if (column.key === labelColumn) {
|
|
333
|
+
return (
|
|
334
|
+
<span key={column.key} className="min-w-0">
|
|
335
|
+
{totalsLabel ?? labels.totals}
|
|
336
|
+
</span>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return (
|
|
340
|
+
<span
|
|
341
|
+
key={column.key}
|
|
342
|
+
className={cn(
|
|
343
|
+
"min-w-0",
|
|
344
|
+
column.align === "end" && "@lg:text-end",
|
|
345
|
+
// Stacked, a column with no total takes no line.
|
|
346
|
+
!hasContent(value) && "@max-lg:hidden",
|
|
347
|
+
)}
|
|
348
|
+
>
|
|
349
|
+
{hasContent(value) && (
|
|
350
|
+
<span className="flex justify-between gap-2 @lg:block">
|
|
351
|
+
<span aria-hidden className="font-normal text-[var(--text-muted)] @lg:hidden">
|
|
352
|
+
{column.header}
|
|
353
|
+
</span>
|
|
354
|
+
{value}
|
|
355
|
+
</span>
|
|
356
|
+
)}
|
|
357
|
+
</span>
|
|
358
|
+
);
|
|
359
|
+
})}
|
|
360
|
+
{onRemove && <span className="@max-lg:hidden" />}
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
{hasError && (
|
|
365
|
+
<p className="text-[11px] leading-tight text-[var(--danger)]">
|
|
366
|
+
{error}
|
|
367
|
+
</p>
|
|
368
|
+
)}
|
|
369
|
+
|
|
370
|
+
{onAdd && (
|
|
371
|
+
<Button
|
|
372
|
+
ref={addRef}
|
|
373
|
+
type="button"
|
|
374
|
+
variant="secondary"
|
|
375
|
+
className="w-full border-dashed"
|
|
376
|
+
disabled={!canAdd}
|
|
377
|
+
onClick={add}
|
|
378
|
+
>
|
|
379
|
+
<Plus aria-hidden className="size-4" />
|
|
380
|
+
{addLabel ?? labels.add}
|
|
381
|
+
</Button>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
package/src/components/list.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { createContext, forwardRef, useContext } from "react";
|
|
1
|
+
import { createContext, forwardRef, useContext, useId } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
3
|
import { ExternalLink } from "lucide-react";
|
|
4
4
|
import type { LucideIcon } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
import { Spinner } from "./ui";
|
|
7
7
|
import { StatusDot } from "./status-dot";
|
|
8
|
-
import { useKitLabels } from "../i18n/kit-labels";
|
|
8
|
+
import { useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
9
|
+
import { pickLinkRenderer } from "./text-link";
|
|
9
10
|
|
|
10
11
|
/* ── Labels ───────────────────────────────────────────────────────────────── */
|
|
11
12
|
|
|
@@ -95,18 +96,30 @@ export interface ListItemLinkProps {
|
|
|
95
96
|
[key: `data-${string}`]: unknown;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
const PAD: Record<ListDensity, { target: string; actions: string; gap: string }> = {
|
|
99
|
-
compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", gap: "gap-0.5" },
|
|
100
|
-
default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", gap: "gap-1" },
|
|
101
|
-
comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", gap: "gap-1" },
|
|
99
|
+
const PAD: Record<ListDensity, { target: string; actions: string; gap: string; body: string }> = {
|
|
100
|
+
compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", gap: "gap-0.5", body: "px-2 pb-1.5" },
|
|
101
|
+
default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", gap: "gap-1", body: "px-3 pb-2.5" },
|
|
102
|
+
comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", gap: "gap-1", body: "px-4 pb-3" },
|
|
102
103
|
};
|
|
103
104
|
|
|
104
105
|
const TARGET_RING =
|
|
105
106
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
|
|
106
107
|
|
|
107
108
|
interface ListItemBaseProps {
|
|
108
|
-
/** The row's name: one line, truncated. */
|
|
109
|
+
/** The row's name: one line, truncated — unless {@link titleLines} says otherwise. */
|
|
109
110
|
title: ReactNode;
|
|
111
|
+
/**
|
|
112
|
+
* How many lines the title may take. `1` (default) truncates with an ellipsis, which
|
|
113
|
+
* is right for a name the app chose — a unit number, a user, a file — where the
|
|
114
|
+
* cut-off end is still recognisable and every row keeps one height.
|
|
115
|
+
*
|
|
116
|
+
* `2` clamps at two lines; `"all"` shows every line, breaking inside a long word
|
|
117
|
+
* rather than overflowing. For rows whose only name is free text a person typed
|
|
118
|
+
* (kastlan 44): a defect or a note titled by its description, where the first 30
|
|
119
|
+
* characters on a phone say nothing and there is no other field to tell two rows
|
|
120
|
+
* apart. Pair it with `align="start"` so the icon stays by the first line.
|
|
121
|
+
*/
|
|
122
|
+
titleLines?: 1 | 2 | "all";
|
|
110
123
|
/** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */
|
|
111
124
|
subtitle?: ReactNode;
|
|
112
125
|
/** `2` for a line that is a body rather than a label (keksdose's notification text). */
|
|
@@ -134,6 +147,26 @@ interface ListItemBaseProps {
|
|
|
134
147
|
* on "Copy" from selecting the row.
|
|
135
148
|
*/
|
|
136
149
|
actions?: ReactNode;
|
|
150
|
+
/**
|
|
151
|
+
* Rich content UNDER the title row and outside its target: photos, per-row buttons,
|
|
152
|
+
* form fields — kastlan's handover room inspector (room-inspector.tsx), the handover
|
|
153
|
+
* detail page and the wizard's defects step (wizard/defects-step.tsx), which draw
|
|
154
|
+
* each room or defect as a `Card variant="inset"` because a row could not hold a
|
|
155
|
+
* photo strip with its own buttons.
|
|
156
|
+
*
|
|
157
|
+
* The same rule as {@link actions}, one row down: the title area stays ONE named
|
|
158
|
+
* target (the button or link, with the title and subtitle as its name), and the body
|
|
159
|
+
* is its sibling, never nested in it — a thumbnail's "Remove" inside the row's button
|
|
160
|
+
* would be invalid HTML and unreachable by a screen reader. The body is a
|
|
161
|
+
* `role="group"` named by the row's title (`aria-labelledby`), so tabbing into the
|
|
162
|
+
* third photo's button of the second room is heard as "Kitchen, group" first rather
|
|
163
|
+
* than as a button floating free of any row. Inside the row's box, so a `selected`
|
|
164
|
+
* border and a `divider` hold both; the hover fill stays on the target alone, since
|
|
165
|
+
* the body is not what a click on the row does.
|
|
166
|
+
*/
|
|
167
|
+
children?: ReactNode;
|
|
168
|
+
/** Classes on the {@link children} body. */
|
|
169
|
+
bodyClassName?: string;
|
|
137
170
|
/**
|
|
138
171
|
* The unread mark: a brand dot at the row's end, the title in semibold, and "Unread"
|
|
139
172
|
* read after the title (keksdose notification-inbox.tsx:215). For another status,
|
|
@@ -207,8 +240,9 @@ export type ListItemProps = ListItemBaseProps &
|
|
|
207
240
|
* cannot do.
|
|
208
241
|
*/
|
|
209
242
|
href: string;
|
|
210
|
-
/** Your router's link for an in-app `href`. Default `<
|
|
211
|
-
*
|
|
243
|
+
/** Your router's link for an in-app `href`. Default: the `<UiKitProvider
|
|
244
|
+
* linkComponent>`, then `<a>`. The API `Chip`, `StatTile` and `MenuItem`
|
|
245
|
+
* share. Ignored for an `external` row. */
|
|
212
246
|
renderLink?: (props: ListItemLinkProps) => ReactElement;
|
|
213
247
|
/**
|
|
214
248
|
* The link leaves the app: a plain `<a target="_blank" rel="noopener
|
|
@@ -234,7 +268,7 @@ export type ListItemProps = ListItemBaseProps &
|
|
|
234
268
|
|
|
235
269
|
/**
|
|
236
270
|
* One row of a {@link List}: leading icon or avatar, title and subtitle (both
|
|
237
|
-
* truncating), a trailing slot, and — beside the row, not inside it — its actions.
|
|
271
|
+
* truncating by default), a trailing slot, and — beside the row, not inside it — its actions.
|
|
238
272
|
*
|
|
239
273
|
* All three apps draw this row by hand, a dozen times: kastlan's units
|
|
240
274
|
* (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity
|
|
@@ -247,6 +281,7 @@ export type ListItemProps = ListItemBaseProps &
|
|
|
247
281
|
export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
|
|
248
282
|
{
|
|
249
283
|
title,
|
|
284
|
+
titleLines = 1,
|
|
250
285
|
subtitle,
|
|
251
286
|
subtitleLines = 1,
|
|
252
287
|
meta,
|
|
@@ -266,6 +301,8 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
266
301
|
targetProps,
|
|
267
302
|
bordered = false,
|
|
268
303
|
as = "li",
|
|
304
|
+
children,
|
|
305
|
+
bodyClassName,
|
|
269
306
|
href,
|
|
270
307
|
renderLink,
|
|
271
308
|
external = false,
|
|
@@ -277,12 +314,15 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
277
314
|
) {
|
|
278
315
|
const list = useContext(ListContext);
|
|
279
316
|
const labels = useKitLabels("list", DEFAULT_LIST_LABELS);
|
|
317
|
+
const kitLink = useKitLink();
|
|
280
318
|
const density = densityProp ?? list.density;
|
|
281
319
|
const pad = PAD[density];
|
|
282
320
|
const isLink = href !== undefined && !disabled;
|
|
283
321
|
const isButton = !isLink && onClick !== undefined && href === undefined;
|
|
284
322
|
const interactive = isLink || isButton;
|
|
285
323
|
const inert = disabled || loading;
|
|
324
|
+
const hasBody = children !== undefined && children !== null && children !== false;
|
|
325
|
+
const titleId = useId();
|
|
286
326
|
|
|
287
327
|
const mark =
|
|
288
328
|
status ??
|
|
@@ -299,8 +339,11 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
299
339
|
{leading}
|
|
300
340
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
301
341
|
<span
|
|
342
|
+
// Names the body's group; harmless without one.
|
|
343
|
+
id={hasBody ? titleId : undefined}
|
|
302
344
|
className={cn(
|
|
303
|
-
"block
|
|
345
|
+
"block text-sm text-[var(--text-primary)]",
|
|
346
|
+
titleLines === 1 ? "truncate" : titleLines === 2 ? "line-clamp-2 break-words" : "break-words",
|
|
304
347
|
unread ? "font-semibold" : "font-medium",
|
|
305
348
|
)}
|
|
306
349
|
>
|
|
@@ -336,7 +379,10 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
336
379
|
"flex min-w-0 flex-1 text-start",
|
|
337
380
|
align === "start" ? "items-start" : "items-center",
|
|
338
381
|
pad.target,
|
|
339
|
-
|
|
382
|
+
// With a body the target is the top of the box, not all of it, so it takes its
|
|
383
|
+
// own corners and its own hover fill (see `children`).
|
|
384
|
+
hasBody ? "rounded-md" : "rounded-[inherit]",
|
|
385
|
+
hasBody && interactive && !inert && !selected && "transition-colors hover:bg-[var(--bg-hover)]",
|
|
340
386
|
interactive && TARGET_RING,
|
|
341
387
|
interactive && !inert && "cursor-pointer",
|
|
342
388
|
loading && "cursor-progress",
|
|
@@ -364,8 +410,9 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
364
410
|
children: body,
|
|
365
411
|
};
|
|
366
412
|
const { children: linkBody, ...anchorProps } = linkProps;
|
|
367
|
-
|
|
368
|
-
|
|
413
|
+
const render = external ? undefined : pickLinkRenderer(renderLink, kitLink, href);
|
|
414
|
+
main = render ? (
|
|
415
|
+
<RenderedLink render={render} {...linkProps} />
|
|
369
416
|
) : (
|
|
370
417
|
<a {...anchorProps} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}>
|
|
371
418
|
{linkBody}
|
|
@@ -405,6 +452,9 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
405
452
|
);
|
|
406
453
|
}
|
|
407
454
|
|
|
455
|
+
const actionsEl =
|
|
456
|
+
actions != null ? <div className={cn("flex shrink-0 items-center", pad.gap, pad.actions)}>{actions}</div> : null;
|
|
457
|
+
|
|
408
458
|
const Tag = as;
|
|
409
459
|
return (
|
|
410
460
|
<Tag
|
|
@@ -415,20 +465,33 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
415
465
|
>
|
|
416
466
|
<div
|
|
417
467
|
className={cn(
|
|
418
|
-
"flex min-w-0
|
|
468
|
+
"flex min-w-0 rounded-md border transition-colors",
|
|
469
|
+
hasBody ? "flex-col" : "items-center",
|
|
419
470
|
selected
|
|
420
471
|
? "border-[var(--brand)] bg-[var(--bg-surface-2)]"
|
|
421
472
|
: bordered
|
|
422
473
|
? "border-[var(--border)]"
|
|
423
474
|
: "border-transparent",
|
|
424
|
-
interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
|
|
475
|
+
!hasBody && interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
|
|
425
476
|
disabled && "opacity-50",
|
|
426
477
|
className,
|
|
427
478
|
)}
|
|
428
479
|
>
|
|
429
|
-
{
|
|
430
|
-
|
|
431
|
-
|
|
480
|
+
{hasBody ? (
|
|
481
|
+
<>
|
|
482
|
+
<div className="flex min-w-0 items-center">
|
|
483
|
+
{main}
|
|
484
|
+
{actionsEl}
|
|
485
|
+
</div>
|
|
486
|
+
<div role="group" aria-labelledby={titleId} className={cn("min-w-0", pad.body, bodyClassName)}>
|
|
487
|
+
{children}
|
|
488
|
+
</div>
|
|
489
|
+
</>
|
|
490
|
+
) : (
|
|
491
|
+
<>
|
|
492
|
+
{main}
|
|
493
|
+
{actionsEl}
|
|
494
|
+
</>
|
|
432
495
|
)}
|
|
433
496
|
</div>
|
|
434
497
|
</Tag>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
4
|
+
import { Spinner } from "./ui";
|
|
5
|
+
|
|
6
|
+
export type LoadingStateSize = "sm" | "md" | "lg";
|
|
7
|
+
|
|
8
|
+
export interface LoadingStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
|
|
9
|
+
/** The visible words under the spinner, and what the status region announces.
|
|
10
|
+
* Default: `common.loading` ("Loading…"). */
|
|
11
|
+
label?: ReactNode;
|
|
12
|
+
/** `sm` for a panel or a table body, `md` (default) for a section, `lg` for a page. */
|
|
13
|
+
size?: LoadingStateSize;
|
|
14
|
+
/** Spinner beside the text on one line, rather than above it. */
|
|
15
|
+
inline?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SPINNER: Record<LoadingStateSize, string> = { sm: "h-4 w-4", md: "h-8 w-8", lg: "h-10 w-10" };
|
|
19
|
+
const TEXT: Record<LoadingStateSize, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
|
|
20
|
+
const PAD: Record<LoadingStateSize, string> = { sm: "py-4", md: "py-12", lg: "py-20" };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A spinner and the words "Loading…", centred where the content will be.
|
|
24
|
+
*
|
|
25
|
+
* kastlan's `LoadingState` (feedback/loading-state.tsx:14), which got the reading
|
|
26
|
+
* right the second time: the WRAPPER is the `role="status"` region and the words are
|
|
27
|
+
* visible text inside it, so the spinner is decorative (`label={null}`) — otherwise
|
|
28
|
+
* the reader hears "Loading… Loading…", one from each.
|
|
29
|
+
*/
|
|
30
|
+
export function LoadingState({ label, size = "md", inline = false, className, ...rest }: LoadingStateProps) {
|
|
31
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
{...rest}
|
|
35
|
+
role="status"
|
|
36
|
+
className={cn(
|
|
37
|
+
"flex items-center justify-center text-center text-[var(--text-muted)]",
|
|
38
|
+
inline ? "gap-2" : "flex-col gap-2",
|
|
39
|
+
PAD[size],
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<Spinner label={null} className={SPINNER[size]} />
|
|
44
|
+
<span className={TEXT[size]}>{label ?? common.loading}</span>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -3,6 +3,8 @@ import type { MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
|
3
3
|
import { Check } from "lucide-react";
|
|
4
4
|
import type { LucideIcon } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
+
import { useKitLink } from "../i18n/kit-labels";
|
|
7
|
+
import { pickLinkRenderer } from "./text-link";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* `TOPBAR_MENU_ITEM_CLASS`'s row, spelled out rather than imported: a component does
|
|
@@ -102,7 +104,8 @@ export type MenuItemProps = MenuItemBaseProps &
|
|
|
102
104
|
/** Makes the row a link — keksdose's "Manage budgets" and account-menu rows. */
|
|
103
105
|
href: string;
|
|
104
106
|
/** Renders the link — pass your router's `<Link>`, since a plain `<a>` reloads
|
|
105
|
-
* a single-page app. Default `<
|
|
107
|
+
* a single-page app. Default: the `<UiKitProvider linkComponent>`, then `<a>`.
|
|
108
|
+
* The same API as `Chip` and `StatTile`. */
|
|
106
109
|
renderLink?: (props: MenuItemLinkProps) => ReactElement;
|
|
107
110
|
/** Runs on the way (close the menu). */
|
|
108
111
|
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
@@ -148,6 +151,7 @@ export const MenuItem = forwardRef<HTMLElement, MenuItemProps>(function MenuItem
|
|
|
148
151
|
},
|
|
149
152
|
ref,
|
|
150
153
|
) {
|
|
154
|
+
const kitLink = useKitLink();
|
|
151
155
|
const on = href !== undefined ? !!current : !!checked;
|
|
152
156
|
const look = cn(
|
|
153
157
|
MENU_ITEM_ROW,
|
|
@@ -193,7 +197,8 @@ export const MenuItem = forwardRef<HTMLElement, MenuItemProps>(function MenuItem
|
|
|
193
197
|
"aria-current": current ? "page" : undefined,
|
|
194
198
|
children: body,
|
|
195
199
|
};
|
|
196
|
-
|
|
200
|
+
const render = pickLinkRenderer(renderLink, kitLink, href);
|
|
201
|
+
if (render) return <RenderedLink render={render} {...linkProps} />;
|
|
197
202
|
const { children: linkBody, ...anchorProps } = linkProps;
|
|
198
203
|
return <a {...anchorProps}>{linkBody}</a>;
|
|
199
204
|
}
|