@eifi1/ui-kit 0.16.1 → 0.18.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 +34 -9
- package/dist/chart.d.ts +10 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +18 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/button-group.d.ts +10 -1
- package/dist/components/calculator.d.ts +10 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +10 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +93 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +10 -1
- package/dist/components/file-button.d.ts +10 -1
- package/dist/components/file-dropzone.d.ts +10 -1
- package/dist/components/form-actions.d.ts +10 -1
- package/dist/components/form-actions.js +35 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +30 -6
- package/dist/components/line-items.js +77 -33
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +13 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +10 -1
- package/dist/components/number-field.d.ts +11 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +10 -1
- package/dist/components/numpad-sheet.d.ts +10 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +25 -1
- package/dist/components/progress-bar.js +21 -4
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +10 -1
- package/dist/components/server-wake.d.ts +58 -0
- package/dist/components/server-wake.js +58 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +10 -1
- package/dist/components/share-card.d.ts +61 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- package/dist/components/signed-amount.d.ts +12 -3
- package/dist/components/signed-amount.js +6 -4
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +10 -1
- package/dist/components/time-input.d.ts +10 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +10 -1
- package/dist/components/ui.js +67 -30
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/user-avatar.d.ts +2 -0
- package/dist/components/write-lock.d.ts +68 -0
- package/dist/components/write-lock.js +33 -0
- package/dist/components/write-lock.js.map +1 -0
- package/dist/{data-table-CfVXtCeG.d.ts → data-table-3mi2R3so.d.ts} +59 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +10 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +10 -1
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +78 -0
- package/dist/i18n/{locales/de.js → german.js} +59 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +10 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +23 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/{de.d.ts → en.d.ts} +24 -10
- package/dist/i18n/locales/en.js +95 -0
- package/dist/i18n/locales/en.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +10 -1
- package/dist/i18n/locales/es.js +51 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +10 -1
- package/dist/i18n/locales/fr.js +51 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +10 -1
- package/dist/i18n/locales/hu.js +54 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +10 -1
- package/dist/i18n/locales/it.js +51 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +10 -1
- package/dist/i18n/locales/zh.js +50 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.js +33 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-CPbsjlXr.d.ts} +282 -10
- package/dist/lib/server-wake.d.ts +130 -0
- package/dist/lib/server-wake.js +121 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/rhf/fields.d.ts +10 -1
- package/dist/rhf/form.d.ts +10 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +10 -1
- package/dist/search/global-search.d.ts +24 -4
- package/dist/search/global-search.js +31 -3
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +10 -1
- package/dist/shell/top-bar-brand.d.ts +10 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +10 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard.d.ts +10 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/amount-input.tsx +8 -0
- package/src/components/chip.tsx +239 -6
- package/src/components/confirm-dialog.tsx +82 -7
- package/src/components/danger-confirm.tsx +118 -33
- package/src/components/data-table.tsx +185 -3
- package/src/components/form-actions.tsx +93 -4
- package/src/components/line-items.tsx +116 -40
- package/src/components/list.tsx +13 -1
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +40 -4
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +124 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/signed-amount.tsx +16 -5
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/ui.tsx +145 -34
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/{locales/de.ts → german.ts} +68 -13
- package/src/i18n/kit-labels.tsx +22 -2
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +116 -0
- package/src/i18n/locales/es.ts +51 -0
- package/src/i18n/locales/fr.ts +52 -0
- package/src/i18n/locales/hu.ts +55 -0
- package/src/i18n/locales/it.ts +51 -0
- package/src/i18n/locales/zh.ts +50 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +23 -1
- package/src/lib/server-wake.ts +270 -0
- package/src/rhf/line-items.tsx +2 -1
- package/src/search/global-search.tsx +59 -6
- package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
- package/dist/i18n/locales/de-CH-informal.js +0 -8
- package/dist/i18n/locales/de-CH-informal.js.map +0 -1
- package/dist/i18n/locales/de-informal.d.ts +0 -82
- package/dist/i18n/locales/de-informal.js +0 -37
- package/dist/i18n/locales/de-informal.js.map +0 -1
- package/dist/i18n/locales/de.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/line-items.tsx"],"sourcesContent":["import { useCallback, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, Key, ReactNode } from \"react\";\nimport { Plus, Trash2 } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, IconButton } from \"./ui\";\n\n/** The words {@link LineItems} renders on its own behalf. Row numbers are 1-based. */\nexport interface LineItemsLabels {\n /** The dashed add button. */\n add: string;\n /** The remove button's name for one row. */\n remove: (row: number) => string;\n /** Its name while it waits for the second press (`confirmRemove`). */\n confirmRemove: (row: number) => string;\n /** A row's group name, when the caller gives no `rowLabel`. */\n row: (row: number) => string;\n /** The name a cell's field should carry: the column, and which row. */\n cell: (column: string, row: number) => string;\n /** The totals row's label, and its group name. */\n totals: string;\n}\n\nexport const DEFAULT_LINE_ITEMS_LABELS: LineItemsLabels = {\n add: \"Add row\",\n remove: (row) => `Remove row ${row}`,\n confirmRemove: (row) => `Remove row ${row}? Press again to confirm`,\n row: (row) => `Row ${row}`,\n cell: (column, row) => `${column}, row ${row}`,\n totals: \"Total\",\n};\n\n/** What a column's `render` receives for one row. */\nexport interface LineItemCellContext<T> {\n item: T;\n /** 0-based. */\n index: number;\n /** The accessible name for the cell's field — \"Debit, row 2\". The header is\n * hidden from assistive tech (each row repeats it), so the field has to carry it:\n * `<Input aria-label={label} … />`. */\n label: string;\n /** The column's own name (\"Debit\") for the field's VISIBLE label — set only when the\n * cell's labels are `\"floating\"` (see {@link LineItemsProps.fieldLabels}), so one\n * render serves both modes: `<Input label={fieldLabel} aria-label={label} … />`.\n * Undefined there, the Input stays a bare compact control; set, it floats \"Debit\"\n * while `aria-label` still says which row. */\n fieldLabel?: string;\n}\n\n/** How a cell's field is labelled. `aria`: the field is a bare control named by\n * `aria-label`, under a header row (stacked: a small copy of the header over it).\n * `floating`: the field shows its own floating label ({@link LineItemCellContext.fieldLabel}),\n * so the list draws no header for it. */\nexport type LineItemsFieldLabels = \"aria\" | \"floating\";\n\n/** The colour of a {@link LineItemsSummary}'s value. */\nexport type LineItemsSummaryTone = \"neutral\" | \"success\" | \"danger\" | \"muted\";\n\n/**\n * A line under the lines that says where they stand — a split's remainder (\"Left to\n * assign CHF 0.00\", green at zero, red otherwise, muted while the total is not known\n * yet), with an optional action that settles it (\"Set amount to total\").\n */\nexport interface LineItemsSummary {\n label: ReactNode;\n value?: ReactNode;\n /** The value's colour. Default `neutral`. The label stays neutral: the colour is a\n * second signal, never the only one — the value and label carry the meaning. */\n tone?: LineItemsSummaryTone;\n /** A small button at the end of the line. Disabled along with the list's\n * `disabled`. */\n action?: { label: ReactNode; onClick: () => void; disabled?: boolean };\n}\n\nexport interface LineItemsColumn<T> {\n /** Stable id: the React key, and the key into `totals`. */\n key: string;\n /** The column header (and the small label over the cell once the rows stack). */\n header: ReactNode;\n /** The column's name for {@link LineItemCellContext.label}, when `header` is not a\n * string. */\n label?: string;\n /** The grid track once the rows are wide enough to be a table. Default\n * `minmax(0,1fr)`; `\"7rem\"` for an amount. */\n width?: string;\n /** `end` for figures: the header and the totals cell end-align with the field. */\n align?: \"start\" | \"end\";\n /** This column's labelling, over the list's {@link LineItemsProps.fieldLabels} — a\n * read-only \"Sum\" cell in a floating list keeps its header with `\"aria\"`. */\n fieldLabels?: LineItemsFieldLabels;\n /** With {@link LineItemsProps.narrowColumns} `2`: `2` gives the cell a whole row of\n * the stacked card (a description), where short fields pair up two to a row.\n * Default `1`. No effect on the wide grid. */\n narrowSpan?: 1 | 2;\n render: (ctx: LineItemCellContext<T>) => ReactNode;\n}\n\nexport interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n items: readonly T[];\n columns: readonly LineItemsColumn<T>[];\n /** The row's React key. Default: the item's `id` (react-hook-form's `useFieldArray`\n * `fields` carry one), else its index. */\n getKey?: (item: T, index: number) => Key;\n /** Append a row. Omitted: no add button. The first field of the new row is focused. */\n onAdd?: () => void;\n /** Remove the row at `index`. Omitted: no remove buttons. */\n onRemove?: (index: number) => void;\n /** Remove is disabled at or below this many rows (a journal entry needs two).\n * Default 0. */\n minItems?: number;\n /** Add is disabled at this many rows. */\n maxItems?: number;\n /**\n * Ask before a row goes:\n * - `true`: the remove button asks for a second press — it fills red and its name\n * becomes `lineItems.confirmRemove`; Escape, or leaving it, calls it off.\n * - a function: called with the row, and the row goes only if it answers `true`\n * (`(item, i) => confirm({ title: … , tone: \"danger\" })` with `useConfirm`).\n */\n confirmRemove?: boolean | ((item: T, index: number) => boolean | Promise<boolean>);\n /** The add button's text. Default `lineItems.add`. */\n addLabel?: ReactNode;\n /** A row's group name (\"Line 2\"). Default `lineItems.row`. */\n rowLabel?: (index: number, item: T) => string;\n /** A totals row under the lines, one entry per column `key`. The first column\n * without an entry carries {@link totalsLabel}. Omitted: no totals row. */\n totals?: Partial<Record<string, ReactNode>>;\n /** Default `lineItems.totals`, \"Total\". */\n totalsLabel?: ReactNode;\n /** Shown in place of the rows when there are none. */\n empty?: ReactNode;\n /** A message about the list as a whole (\"The entry does not balance\"). */\n error?: ReactNode;\n /** A line under the lines and totals — the remainder of a split, say. See\n * {@link LineItemsSummary}. */\n summary?: LineItemsSummary;\n /** How the cells' fields are labelled. Default `aria` (compact bare controls under a\n * header row); `floating` hands each cell its column's name as\n * {@link LineItemCellContext.fieldLabel} for a visible floating label, and drops the\n * header for those columns. A column's own `fieldLabels` wins. */\n fieldLabels?: LineItemsFieldLabels;\n /** Stacked (a narrow pane), how many fields share a row of a line's card: `2` pairs\n * them up (a column's `narrowSpan={2}` takes a whole row), once the pane is wide\n * enough for two fields (18rem); narrower still, one. Default `1`. */\n narrowColumns?: 1 | 2;\n /** Disables add and remove (the fields are the caller's to disable). */\n disabled?: boolean;\n labels?: Partial<LineItemsLabels>;\n}\n\nconst REMOVE_TRACK = \"2.25rem\";\nconst SUMMARY_TONE: Record<LineItemsSummaryTone, string> = {\n neutral: \"text-[var(--text-primary)]\",\n success: \"text-[var(--success)]\",\n danger: \"text-[var(--danger)]\",\n muted: \"text-[var(--text-muted)]\",\n};\nconst FOCUSABLE =\n \"input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])\";\n\nfunction defaultKey(item: unknown, index: number): Key {\n if (typeof item === \"object\" && item !== null && \"id\" in item) {\n const id = (item as { id: unknown }).id;\n if (typeof id === \"string\" || typeof id === \"number\") return id;\n }\n return index;\n}\n\nfunction hasContent(node: ReactNode): boolean {\n return node !== undefined && node !== null && node !== false && node !== \"\";\n}\n\ntype PendingFocus = { kind: \"added\" } | { kind: \"removed\"; index: number };\n\n/**\n * An editable list of lines — journal entry lines, lease components, invoice\n * positions, budget rows: column headers, a row of fields per item, a remove button\n * per row, a dashed \"add\" button under them, and an optional totals row.\n *\n * Presentation only: the rows are the caller's `items` and the fields are the\n * caller's, rendered per column. So it works over plain state —\n *\n * ```tsx\n * <LineItems items={lines} columns={cols} onAdd={() => setLines([...lines, blank()])}\n * onRemove={(i) => setLines(lines.filter((_, j) => j !== i))} minItems={2} />\n * ```\n *\n * — and over react-hook-form's `useFieldArray` alike (`items={fields}`, `onAdd={() =>\n * append(blank())}`, `onRemove={remove}`), for which `@eifi1/ui-kit/rhf` has\n * `RhfLineItems`.\n *\n * On a wide pane the rows are a grid under the headers; on a narrow one (a container\n * query, so a dialog stacks where it is cramped) each row stacks its fields, each\n * under a small copy of its header. The headers are `aria-hidden`: every row is a\n * named group and every field is named by `render`'s `label` (\"Debit, row 2\"), which\n * is what a screen reader needs — a header row it would read once, out of context.\n *\n * Keyboard: after Add, focus lands in the new row's first field; after a remove, on\n * the row that took its place (else the add button). Ctrl/⌘+Enter in any row adds a\n * row.\n */\nexport function LineItems<T>({\n items,\n columns,\n getKey = defaultKey,\n onAdd,\n onRemove,\n minItems = 0,\n maxItems,\n confirmRemove,\n addLabel,\n rowLabel,\n totals,\n totalsLabel,\n empty,\n error,\n summary,\n fieldLabels = \"aria\",\n narrowColumns = 1,\n disabled = false,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: LineItemsProps<T>) {\n const labels = useKitLabels(\"lineItems\", DEFAULT_LINE_ITEMS_LABELS, labelsProp);\n const rowRefs = useRef<Array<HTMLDivElement | null>>([]);\n const addRef = useRef<HTMLButtonElement>(null);\n const pendingFocus = useRef<PendingFocus | null>(null);\n const [confirmingAt, setConfirming] = useState<number | null>(null);\n // A confirm waiting on a row that has since gone is void.\n const confirming = confirmingAt !== null && confirmingAt < items.length ? confirmingAt : null;\n\n const tracks = [...columns.map((c) => c.width ?? \"minmax(0,1fr)\"), ...(onRemove ? [REMOVE_TRACK] : [])];\n const gridStyle = { \"--line-items-cols\": tracks.join(\" \") } as CSSProperties;\n const canAdd = Boolean(onAdd) && !disabled && (maxItems === undefined || items.length < maxItems);\n const canRemove = Boolean(onRemove) && !disabled && items.length > minItems;\n const hasError = hasContent(error);\n\n\n // Focus after the list has re-rendered with the new rows — a layout effect, so the\n // caret is where it belongs before paint.\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending) return;\n pendingFocus.current = null;\n if (pending.kind === \"added\") {\n const row = rowRefs.current[items.length - 1];\n row?.querySelector<HTMLElement>(FOCUSABLE)?.focus();\n return;\n }\n const row = rowRefs.current[Math.min(pending.index, items.length - 1)];\n const target =\n row?.querySelector<HTMLElement>(\"[data-line-items-remove]:not([disabled])\") ??\n row?.querySelector<HTMLElement>(FOCUSABLE) ??\n addRef.current;\n target?.focus();\n }, [items.length]);\n\n const add = useCallback(() => {\n if (!canAdd || !onAdd) return;\n pendingFocus.current = { kind: \"added\" };\n onAdd();\n }, [canAdd, onAdd]);\n\n const removeAt = async (index: number) => {\n if (!canRemove || !onRemove) return;\n if (confirmRemove === true && confirming !== index) {\n setConfirming(index);\n return;\n }\n if (typeof confirmRemove === \"function\") {\n const ok = await confirmRemove(items[index], index);\n if (!ok) return;\n }\n setConfirming(null);\n pendingFocus.current = { kind: \"removed\", index };\n onRemove(index);\n };\n\n const onRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key === \"Enter\" && (e.ctrlKey || e.metaKey) && canAdd) {\n e.preventDefault();\n add();\n }\n };\n\n const columnName = (column: LineItemsColumn<T>) =>\n column.label ?? (typeof column.header === \"string\" ? column.header : column.key);\n const floats = (column: LineItemsColumn<T>) => (column.fieldLabels ?? fieldLabels) === \"floating\";\n // A header row only for the columns that still need one: with every field floating\n // its own label, a row of headers would say each name twice.\n const showHeader = columns.some((c) => !floats(c));\n const pairs = narrowColumns === 2;\n\n const headerCell = (column: LineItemsColumn<T>) => (\n <span\n key={column.key}\n className={cn(\n \"min-w-0 truncate text-xs font-medium text-[var(--text-muted)]\",\n column.align === \"end\" && \"text-end\",\n )}\n >\n {floats(column) ? null : column.header}\n </span>\n );\n\n const showTotals = totals !== undefined;\n const labelColumn = showTotals ? columns.find((c) => !hasContent(totals[c.key]))?.key : undefined;\n\n return (\n <div\n data-slot=\"line-items\"\n {...rest}\n style={{ ...gridStyle, ...style }}\n className={cn(\"@container space-y-2\", className)}\n >\n {showHeader && (\n <div aria-hidden className=\"hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]\">\n {columns.map(headerCell)}\n {onRemove && <span />}\n </div>\n )}\n\n {items.length === 0 && hasContent(empty) && (\n <div className=\"rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-sm text-[var(--text-muted)]\">\n {empty}\n </div>\n )}\n\n {items.map((item, index) => {\n const row = index + 1;\n const isConfirming = confirming === index;\n return (\n // The row's Ctrl/⌘+Enter is a shortcut for the add button, which is itself\n // reachable by Tab; the group only listens for it bubbling from its fields.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n key={getKey(item, index)}\n ref={(el) => {\n rowRefs.current[index] = el;\n }}\n role=\"group\"\n aria-label={rowLabel ? rowLabel(index, item) : labels.row(row)}\n data-line-items-row=\"\"\n onKeyDown={onRowKeyDown}\n className={cn(\n \"grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start\",\n // Two short fields to a row of the card — from 18rem, where two fields\n // still fit a figure each; on anything narrower they stack after all.\n pairs && \"@2xs:grid-cols-2\",\n // Stacked, each row is a card of its own so the rows stay apart.\n \"@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3\",\n )}\n >\n {columns.map((column) => {\n const floating = floats(column);\n return (\n <div\n key={column.key}\n className={cn(\n \"min-w-0 space-y-1 @lg:space-y-0\",\n // The wide grid places every cell in its own track again.\n pairs && column.narrowSpan === 2 && \"@2xs:col-span-2 @lg:col-span-1\",\n )}\n >\n {!floating && (\n <span aria-hidden className=\"block text-xs font-medium text-[var(--text-muted)] @lg:hidden\">\n {column.header}\n </span>\n )}\n {column.render({\n item,\n index,\n label: labels.cell(columnName(column), row),\n fieldLabel: floating ? columnName(column) : undefined,\n })}\n </div>\n );\n })}\n {onRemove && (\n // Paired, the remove button keeps a row of its own at the card's end\n // rather than squeezing in beside the last field.\n <div className={cn(\"flex justify-end @lg:justify-center\", pairs && \"@2xs:col-span-2 @lg:col-span-1\")}>\n <IconButton\n type=\"button\"\n data-line-items-remove=\"\"\n variant={isConfirming ? \"danger\" : \"ghost\"}\n tone={isConfirming ? undefined : \"danger\"}\n label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}\n disabled={!canRemove}\n onClick={() => void removeAt(index)}\n onBlur={() => {\n if (isConfirming) setConfirming(null);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Escape\" && isConfirming) {\n e.stopPropagation();\n setConfirming(null);\n }\n }}\n >\n <Trash2 />\n </IconButton>\n </div>\n )}\n </div>\n );\n })}\n\n {showTotals && (\n <div\n role=\"group\"\n aria-label={typeof totalsLabel === \"string\" ? totalsLabel : labels.totals}\n data-line-items-totals=\"\"\n 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)]\"\n >\n {columns.map((column) => {\n const value = totals[column.key];\n if (column.key === labelColumn) {\n return (\n <span key={column.key} className=\"min-w-0\">\n {totalsLabel ?? labels.totals}\n </span>\n );\n }\n return (\n <span\n key={column.key}\n className={cn(\n \"min-w-0\",\n column.align === \"end\" && \"@lg:text-end\",\n // Stacked, a column with no total takes no line.\n !hasContent(value) && \"@max-lg:hidden\",\n )}\n >\n {hasContent(value) && (\n <span className=\"flex justify-between gap-2 @lg:block\">\n <span aria-hidden className=\"font-normal text-[var(--text-muted)] @lg:hidden\">\n {column.header}\n </span>\n {value}\n </span>\n )}\n </span>\n );\n })}\n {onRemove && <span className=\"@max-lg:hidden\" />}\n </div>\n )}\n\n {summary && (\n <div\n data-line-items-summary=\"\"\n className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-primary)]\"\n >\n <span className=\"min-w-0\">{summary.label}</span>\n {hasContent(summary.value) && (\n <span\n data-tone={summary.tone ?? \"neutral\"}\n className={cn(\"font-semibold tabular-nums\", SUMMARY_TONE[summary.tone ?? \"neutral\"])}\n >\n {summary.value}\n </span>\n )}\n {summary.action && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"ms-auto\"\n disabled={disabled || summary.action.disabled}\n onClick={summary.action.onClick}\n >\n {summary.action.label}\n </Button>\n )}\n </div>\n )}\n\n {hasError && (\n <p className=\"text-[11px] leading-tight text-[var(--danger)]\">\n {error}\n </p>\n )}\n\n {onAdd && (\n <Button\n ref={addRef}\n type=\"button\"\n variant=\"secondary\"\n className=\"w-full border-dashed\"\n disabled={!canAdd}\n onClick={add}\n >\n <Plus aria-hidden className=\"size-4\" />\n {addLabel ?? labels.add}\n </Button>\n )}\n </div>\n );\n}\n"],"mappings":";AAwSI,cAsBI,YAtBJ;AAxSJ,SAAS,aAAa,iBAAiB,QAAQ,gBAAgB;AAE/D,SAAS,MAAM,cAAc;AAC7B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,QAAQ,kBAAkB;AAkB5B,MAAM,4BAA6C;AAAA,EACxD,KAAK;AAAA,EACL,QAAQ,CAAC,QAAQ,cAAc,GAAG;AAAA,EAClC,eAAe,CAAC,QAAQ,cAAc,GAAG;AAAA,EACzC,KAAK,CAAC,QAAQ,OAAO,GAAG;AAAA,EACxB,MAAM,CAAC,QAAQ,QAAQ,GAAG,MAAM,SAAS,GAAG;AAAA,EAC5C,QAAQ;AACV;AAwHA,MAAM,eAAe;AACrB,MAAM,eAAqD;AAAA,EACzD,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AACT;AACA,MAAM,YACJ;AAEF,SAAS,WAAW,MAAe,OAAoB;AACrD,MAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,QAAQ,MAAM;AAC7D,UAAM,KAAM,KAAyB;AACrC,QAAI,OAAO,OAAO,YAAY,OAAO,OAAO,SAAU,QAAO;AAAA,EAC/D;AACA,SAAO;AACT;AAEA,SAAS,WAAW,MAA0B;AAC5C,SAAO,SAAS,UAAa,SAAS,QAAQ,SAAS,SAAS,SAAS;AAC3E;AA+BO,SAAS,UAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,gBAAgB;AAAA,EAChB,WAAW;AAAA,EACX,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,SAAS,aAAa,aAAa,2BAA2B,UAAU;AAC9E,QAAM,UAAU,OAAqC,CAAC,CAAC;AACvD,QAAM,SAAS,OAA0B,IAAI;AAC7C,QAAM,eAAe,OAA4B,IAAI;AACrD,QAAM,CAAC,cAAc,aAAa,IAAI,SAAwB,IAAI;AAElE,QAAM,aAAa,iBAAiB,QAAQ,eAAe,MAAM,SAAS,eAAe;AAEzF,QAAM,SAAS,CAAC,GAAG,QAAQ,IAAI,CAAC,MAAM,EAAE,SAAS,eAAe,GAAG,GAAI,WAAW,CAAC,YAAY,IAAI,CAAC,CAAE;AACtG,QAAM,YAAY,EAAE,qBAAqB,OAAO,KAAK,GAAG,EAAE;AAC1D,QAAM,SAAS,QAAQ,KAAK,KAAK,CAAC,aAAa,aAAa,UAAa,MAAM,SAAS;AACxF,QAAM,YAAY,QAAQ,QAAQ,KAAK,CAAC,YAAY,MAAM,SAAS;AACnE,QAAM,WAAW,WAAW,KAAK;AAKjC,kBAAgB,MAAM;AACpB,UAAM,UAAU,aAAa;AAC7B,QAAI,CAAC,QAAS;AACd,iBAAa,UAAU;AACvB,QAAI,QAAQ,SAAS,SAAS;AAC5B,YAAMA,OAAM,QAAQ,QAAQ,MAAM,SAAS,CAAC;AAC5C,MAAAA,MAAK,cAA2B,SAAS,GAAG,MAAM;AAClD;AAAA,IACF;AACA,UAAM,MAAM,QAAQ,QAAQ,KAAK,IAAI,QAAQ,OAAO,MAAM,SAAS,CAAC,CAAC;AACrE,UAAM,SACJ,KAAK,cAA2B,0CAA0C,KAC1E,KAAK,cAA2B,SAAS,KACzC,OAAO;AACT,YAAQ,MAAM;AAAA,EAChB,GAAG,CAAC,MAAM,MAAM,CAAC;AAEjB,QAAM,MAAM,YAAY,MAAM;AAC5B,QAAI,CAAC,UAAU,CAAC,MAAO;AACvB,iBAAa,UAAU,EAAE,MAAM,QAAQ;AACvC,UAAM;AAAA,EACR,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,QAAM,WAAW,OAAO,UAAkB;AACxC,QAAI,CAAC,aAAa,CAAC,SAAU;AAC7B,QAAI,kBAAkB,QAAQ,eAAe,OAAO;AAClD,oBAAc,KAAK;AACnB;AAAA,IACF;AACA,QAAI,OAAO,kBAAkB,YAAY;AACvC,YAAM,KAAK,MAAM,cAAc,MAAM,KAAK,GAAG,KAAK;AAClD,UAAI,CAAC,GAAI;AAAA,IACX;AACA,kBAAc,IAAI;AAClB,iBAAa,UAAU,EAAE,MAAM,WAAW,MAAM;AAChD,aAAS,KAAK;AAAA,EAChB;AAEA,QAAM,eAAe,CAAC,MAAqC;AACzD,QAAI,EAAE,QAAQ,YAAY,EAAE,WAAW,EAAE,YAAY,QAAQ;AAC3D,QAAE,eAAe;AACjB,UAAI;AAAA,IACN;AAAA,EACF;AAEA,QAAM,aAAa,CAAC,WAClB,OAAO,UAAU,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS,OAAO;AAC9E,QAAM,SAAS,CAAC,YAAgC,OAAO,eAAe,iBAAiB;AAGvF,QAAM,aAAa,QAAQ,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AACjD,QAAM,QAAQ,kBAAkB;AAEhC,QAAM,aAAa,CAAC,WAClB;AAAA,IAAC;AAAA;AAAA,MAEC,WAAW;AAAA,QACT;AAAA,QACA,OAAO,UAAU,SAAS;AAAA,MAC5B;AAAA,MAEC,iBAAO,MAAM,IAAI,OAAO,OAAO;AAAA;AAAA,IAN3B,OAAO;AAAA,EAOd;AAGF,QAAM,aAAa,WAAW;AAC9B,QAAM,cAAc,aAAa,QAAQ,KAAK,CAAC,MAAM,CAAC,WAAW,OAAO,EAAE,GAAG,CAAC,CAAC,GAAG,MAAM;AAExF,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACT,GAAG;AAAA,MACJ,OAAO,EAAE,GAAG,WAAW,GAAG,MAAM;AAAA,MAChC,WAAW,GAAG,wBAAwB,SAAS;AAAA,MAE9C;AAAA,sBACC,qBAAC,SAAI,eAAW,MAAC,WAAU,gEACxB;AAAA,kBAAQ,IAAI,UAAU;AAAA,UACtB,YAAY,oBAAC,UAAK;AAAA,WACrB;AAAA,QAGD,MAAM,WAAW,KAAK,WAAW,KAAK,KACrC,oBAAC,SAAI,WAAU,iHACZ,iBACH;AAAA,QAGD,MAAM,IAAI,CAAC,MAAM,UAAU;AAC1B,gBAAM,MAAM,QAAQ;AACpB,gBAAM,eAAe,eAAe;AACpC;AAAA;AAAA;AAAA;AAAA,YAIE;AAAA,cAAC;AAAA;AAAA,gBAEC,KAAK,CAAC,OAAO;AACX,0BAAQ,QAAQ,KAAK,IAAI;AAAA,gBAC3B;AAAA,gBACA,MAAK;AAAA,gBACL,cAAY,WAAW,SAAS,OAAO,IAAI,IAAI,OAAO,IAAI,GAAG;AAAA,gBAC7D,uBAAoB;AAAA,gBACpB,WAAW;AAAA,gBACX,WAAW;AAAA,kBACT;AAAA;AAAA;AAAA,kBAGA,SAAS;AAAA;AAAA,kBAET;AAAA,gBACF;AAAA,gBAEC;AAAA,0BAAQ,IAAI,CAAC,WAAW;AACvB,0BAAM,WAAW,OAAO,MAAM;AAC9B,2BACE;AAAA,sBAAC;AAAA;AAAA,wBAEC,WAAW;AAAA,0BACT;AAAA;AAAA,0BAEA,SAAS,OAAO,eAAe,KAAK;AAAA,wBACtC;AAAA,wBAEC;AAAA,2BAAC,YACA,oBAAC,UAAK,eAAW,MAAC,WAAU,iEACzB,iBAAO,QACV;AAAA,0BAED,OAAO,OAAO;AAAA,4BACb;AAAA,4BACA;AAAA,4BACA,OAAO,OAAO,KAAK,WAAW,MAAM,GAAG,GAAG;AAAA,4BAC1C,YAAY,WAAW,WAAW,MAAM,IAAI;AAAA,0BAC9C,CAAC;AAAA;AAAA;AAAA,sBAjBI,OAAO;AAAA,oBAkBd;AAAA,kBAEJ,CAAC;AAAA,kBACA;AAAA;AAAA,kBAGC,oBAAC,SAAI,WAAW,GAAG,uCAAuC,SAAS,gCAAgC,GACjG;AAAA,oBAAC;AAAA;AAAA,sBACC,MAAK;AAAA,sBACL,0BAAuB;AAAA,sBACvB,SAAS,eAAe,WAAW;AAAA,sBACnC,MAAM,eAAe,SAAY;AAAA,sBACjC,OAAO,eAAe,OAAO,cAAc,GAAG,IAAI,OAAO,OAAO,GAAG;AAAA,sBACnE,UAAU,CAAC;AAAA,sBACX,SAAS,MAAM,KAAK,SAAS,KAAK;AAAA,sBAClC,QAAQ,MAAM;AACZ,4BAAI,aAAc,eAAc,IAAI;AAAA,sBACtC;AAAA,sBACA,WAAW,CAAC,MAAM;AAChB,4BAAI,EAAE,QAAQ,YAAY,cAAc;AACtC,4BAAE,gBAAgB;AAClB,wCAAc,IAAI;AAAA,wBACpB;AAAA,sBACF;AAAA,sBAEA,8BAAC,UAAO;AAAA;AAAA,kBACV,GACF;AAAA;AAAA;AAAA,cAlEG,OAAO,MAAM,KAAK;AAAA,YAoEzB;AAAA;AAAA,QAEJ,CAAC;AAAA,QAEA,cACC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,cAAY,OAAO,gBAAgB,WAAW,cAAc,OAAO;AAAA,YACnE,0BAAuB;AAAA,YACvB,WAAU;AAAA,YAET;AAAA,sBAAQ,IAAI,CAAC,WAAW;AACvB,sBAAM,QAAQ,OAAO,OAAO,GAAG;AAC/B,oBAAI,OAAO,QAAQ,aAAa;AAC9B,yBACE,oBAAC,UAAsB,WAAU,WAC9B,yBAAe,OAAO,UADd,OAAO,GAElB;AAAA,gBAEJ;AACA,uBACE;AAAA,kBAAC;AAAA;AAAA,oBAEC,WAAW;AAAA,sBACT;AAAA,sBACA,OAAO,UAAU,SAAS;AAAA;AAAA,sBAE1B,CAAC,WAAW,KAAK,KAAK;AAAA,oBACxB;AAAA,oBAEC,qBAAW,KAAK,KACf,qBAAC,UAAK,WAAU,wCACd;AAAA,0CAAC,UAAK,eAAW,MAAC,WAAU,mDACzB,iBAAO,QACV;AAAA,sBACC;AAAA,uBACH;AAAA;AAAA,kBAdG,OAAO;AAAA,gBAgBd;AAAA,cAEJ,CAAC;AAAA,cACA,YAAY,oBAAC,UAAK,WAAU,kBAAiB;AAAA;AAAA;AAAA,QAChD;AAAA,QAGD,WACC;AAAA,UAAC;AAAA;AAAA,YACC,2BAAwB;AAAA,YACxB,WAAU;AAAA,YAEV;AAAA,kCAAC,UAAK,WAAU,WAAW,kBAAQ,OAAM;AAAA,cACxC,WAAW,QAAQ,KAAK,KACvB;AAAA,gBAAC;AAAA;AAAA,kBACC,aAAW,QAAQ,QAAQ;AAAA,kBAC3B,WAAW,GAAG,8BAA8B,aAAa,QAAQ,QAAQ,SAAS,CAAC;AAAA,kBAElF,kBAAQ;AAAA;AAAA,cACX;AAAA,cAED,QAAQ,UACP;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAQ;AAAA,kBACR,MAAK;AAAA,kBACL,WAAU;AAAA,kBACV,UAAU,YAAY,QAAQ,OAAO;AAAA,kBACrC,SAAS,QAAQ,OAAO;AAAA,kBAEvB,kBAAQ,OAAO;AAAA;AAAA,cAClB;AAAA;AAAA;AAAA,QAEJ;AAAA,QAGD,YACC,oBAAC,OAAE,WAAU,kDACV,iBACH;AAAA,QAGD,SACC;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,WAAU;AAAA,YACV,UAAU,CAAC;AAAA,YACX,SAAS;AAAA,YAET;AAAA,kCAAC,QAAK,eAAW,MAAC,WAAU,UAAS;AAAA,cACpC,YAAY,OAAO;AAAA;AAAA;AAAA,QACtB;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":["row"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/line-items.tsx"],"sourcesContent":["import { useCallback, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, Key, ReactNode } from \"react\";\nimport { Plus, Trash2 } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, IconButton } from \"./ui\";\n\n/** The words {@link LineItems} renders on its own behalf. Row numbers are 1-based. */\nexport interface LineItemsLabels {\n /** The dashed add button. */\n add: string;\n /** The remove button's name for one row. */\n remove: (row: number) => string;\n /** Its name while it waits for the second press (`confirmRemove`). */\n confirmRemove: (row: number) => string;\n /** A row's group name, when the caller gives no `rowLabel`. */\n row: (row: number) => string;\n /** The name a cell's field should carry: the column, and which row. */\n cell: (column: string, row: number) => string;\n /** The totals row's label, and its group name. */\n totals: string;\n}\n\nexport const DEFAULT_LINE_ITEMS_LABELS: LineItemsLabels = {\n add: \"Add row\",\n remove: (row) => `Remove row ${row}`,\n confirmRemove: (row) => `Remove row ${row}? Press again to confirm`,\n row: (row) => `Row ${row}`,\n cell: (column, row) => `${column}, row ${row}`,\n totals: \"Total\",\n};\n\n/** What a column's `render` receives for one row. */\nexport interface LineItemCellContext<T> {\n item: T;\n /** 0-based. */\n index: number;\n /** The accessible name for the cell's field — \"Debit, row 2\". The header is\n * hidden from assistive tech (each row repeats it), so the field has to carry it:\n * `<Input aria-label={label} … />`. */\n label: string;\n /** The column's own name (\"Debit\") for the field's VISIBLE label — set only when the\n * cell's labels are `\"floating\"` (see {@link LineItemsProps.fieldLabels}), so one\n * render serves both modes: `<Input label={fieldLabel} aria-label={label} … />`.\n * Undefined there, the Input stays a bare compact control; set, it floats \"Debit\"\n * while `aria-label` still says which row. */\n fieldLabel?: string;\n}\n\n/** How a cell's field is labelled. `aria`: the field is a bare control named by\n * `aria-label`, under a header row (stacked: a small copy of the header over it).\n * `floating`: the field shows its own floating label ({@link LineItemCellContext.fieldLabel}),\n * so the list draws no header for it. */\nexport type LineItemsFieldLabels = \"aria\" | \"floating\";\n\n/** The colour of a {@link LineItemsSummary}'s value — a verdict (`success` /\n * `danger`), quiet (`muted`), or the money pair (`income` / `expense`, the colours\n * `SignedAmount` draws a figure in) for a remainder that reads as the row it settles. */\nexport type LineItemsSummaryTone = \"neutral\" | \"success\" | \"danger\" | \"muted\" | \"income\" | \"expense\";\n\n/**\n * A line under the lines that says where they stand — a split's remainder (\"Left to\n * assign CHF 0.00\", green at zero, red otherwise, muted while the total is not known\n * yet), with an optional action that settles it (\"Set amount to total\").\n */\nexport interface LineItemsSummary {\n label: ReactNode;\n value?: ReactNode;\n /** The value's colour. Default `neutral`. The label stays neutral: the colour is a\n * second signal, never the only one — the value and label carry the meaning.\n * With no `value` the line is a state on its own (\"Waiting for the total\"), and\n * the tone colours the label instead — `muted` quiets the whole line. */\n tone?: LineItemsSummaryTone;\n /** Beside the value: a chip or badge that names the state (\"Balanced\" next to a\n * journal entry's zero difference). */\n status?: ReactNode;\n /** A small button at the end of the line. Disabled along with the list's\n * `disabled`. `variant`: `secondary` (default) is a boxed button; `link` a muted\n * text link, for an action that is an aside rather than the way to settle it. */\n action?: { label: ReactNode; onClick: () => void; disabled?: boolean; variant?: \"secondary\" | \"link\" };\n}\n\n/** Where a row's remove button sits. `row` (default): stacked, a row of the card to\n * itself (paired, the whole width); wide, its own track. `inline`: stacked, beside\n * the row's last field (\"category + amount\" over \"memo + delete\"); wide, unchanged. */\nexport type LineItemsRemovePlacement = \"row\" | \"inline\";\n\n/** How the remove button sits in the height of a wide row: at its top, centred, or at\n * its foot. */\nexport type LineItemsRemoveAlign = \"start\" | \"center\" | \"end\";\n\nexport interface LineItemsColumn<T> {\n /** Stable id: the React key, and the key into `totals`. */\n key: string;\n /** The column header (and the small label over the cell once the rows stack). */\n header: ReactNode;\n /** The column's name for {@link LineItemCellContext.label}, when `header` is not a\n * string. */\n label?: string;\n /** The grid track once the rows are wide enough to be a table. Default\n * `minmax(0,1fr)`; `\"7rem\"` for an amount. */\n width?: string;\n /** `end` for figures: the header and the totals cell end-align with the field. */\n align?: \"start\" | \"end\";\n /** This column's labelling, over the list's {@link LineItemsProps.fieldLabels} — a\n * read-only \"Sum\" cell in a floating list keeps its header with `\"aria\"`. */\n fieldLabels?: LineItemsFieldLabels;\n /** With {@link LineItemsProps.narrowColumns} `2`: `2` gives the cell a whole row of\n * the stacked card (a description), where short fields pair up two to a row.\n * Default `1`. No effect on the wide grid. */\n narrowSpan?: 1 | 2;\n render: (ctx: LineItemCellContext<T>) => ReactNode;\n}\n\nexport interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n items: readonly T[];\n columns: readonly LineItemsColumn<T>[];\n /** The row's React key. Default: the item's `id` (react-hook-form's `useFieldArray`\n * `fields` carry one), else its index. */\n getKey?: (item: T, index: number) => Key;\n /** Append a row. Omitted: no add button. The first field of the new row is focused. */\n onAdd?: () => void;\n /** Remove the row at `index`. Omitted: no remove buttons. */\n onRemove?: (index: number) => void;\n /** Remove is disabled at or below this many rows (a journal entry needs two).\n * Default 0. */\n minItems?: number;\n /** Add is disabled at this many rows. */\n maxItems?: number;\n /**\n * Ask before a row goes:\n * - `true`: the remove button asks for a second press — it fills red and its name\n * becomes `lineItems.confirmRemove`; Escape, or leaving it, calls it off.\n * - a function: called with the row, and the row goes only if it answers `true`\n * (`(item, i) => confirm({ title: … , tone: \"danger\" })` with `useConfirm`).\n */\n confirmRemove?: boolean | ((item: T, index: number) => boolean | Promise<boolean>);\n /** The add button's text. Default `lineItems.add`. */\n addLabel?: ReactNode;\n /** A row's group name (\"Line 2\"). Default `lineItems.row`. */\n rowLabel?: (index: number, item: T) => string;\n /** A totals row under the lines, one entry per column `key`. The first column\n * without an entry carries {@link totalsLabel}. Omitted: no totals row. */\n totals?: Partial<Record<string, ReactNode>>;\n /** Default `lineItems.totals`, \"Total\". */\n totalsLabel?: ReactNode;\n /** Shown in place of the rows when there are none. */\n empty?: ReactNode;\n /** A message about the list as a whole (\"The entry does not balance\"). */\n error?: ReactNode;\n /** A line under the lines and totals — the remainder of a split, say. See\n * {@link LineItemsSummary}. */\n summary?: LineItemsSummary;\n /** How the cells' fields are labelled. Default `aria` (compact bare controls under a\n * header row); `floating` hands each cell its column's name as\n * {@link LineItemCellContext.fieldLabel} for a visible floating label, and drops the\n * header for those columns. A column's own `fieldLabels` wins. */\n fieldLabels?: LineItemsFieldLabels;\n /** Stacked (a narrow pane), how many fields share a row of a line's card: `2` pairs\n * them up (a column's `narrowSpan={2}` takes a whole row), once the pane is wide\n * enough for two fields (18rem); narrower still, one. Default `1`. */\n narrowColumns?: 1 | 2;\n /** Stacked, where the remove button goes: a row of its own (`row`, default) or\n * beside the last field (`inline`). See {@link LineItemsRemovePlacement}. */\n removePlacement?: LineItemsRemovePlacement;\n /** On the wide grid, the remove button's place in the row's height. Default `start`\n * — or `center` when a column floats its label (`fieldLabels=\"floating\"`): a\n * floating field is taller than the button, and centred is level with its control.\n * A field that shows a message under itself grows the row; pin `start` then. */\n removeAlign?: LineItemsRemoveAlign;\n /** Disables add and remove (the fields are the caller's to disable). */\n disabled?: boolean;\n labels?: Partial<LineItemsLabels>;\n}\n\nconst REMOVE_TRACK = \"2.25rem\";\nconst SUMMARY_TONE: Record<LineItemsSummaryTone, string> = {\n neutral: \"text-[var(--text-primary)]\",\n success: \"text-[var(--success)]\",\n danger: \"text-[var(--danger)]\",\n muted: \"text-[var(--text-muted)]\",\n // SignedAmount's money palette (toneTextClass \"income\" / \"expense\").\n income: \"text-[var(--money-income)]\",\n expense: \"text-[var(--money-expense)]\",\n};\nconst REMOVE_ALIGN: Record<LineItemsRemoveAlign, string> = {\n start: \"@lg:self-start\",\n center: \"@lg:self-center\",\n end: \"@lg:self-end\",\n};\nconst FOCUSABLE =\n \"input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])\";\n\nfunction defaultKey(item: unknown, index: number): Key {\n if (typeof item === \"object\" && item !== null && \"id\" in item) {\n const id = (item as { id: unknown }).id;\n if (typeof id === \"string\" || typeof id === \"number\") return id;\n }\n return index;\n}\n\nfunction hasContent(node: ReactNode): boolean {\n return node !== undefined && node !== null && node !== false && node !== \"\";\n}\n\ntype PendingFocus = { kind: \"added\" } | { kind: \"removed\"; index: number };\n\n/**\n * An editable list of lines — journal entry lines, lease components, invoice\n * positions, budget rows: column headers, a row of fields per item, a remove button\n * per row, a dashed \"add\" button under them, and an optional totals row.\n *\n * Presentation only: the rows are the caller's `items` and the fields are the\n * caller's, rendered per column. So it works over plain state —\n *\n * ```tsx\n * <LineItems items={lines} columns={cols} onAdd={() => setLines([...lines, blank()])}\n * onRemove={(i) => setLines(lines.filter((_, j) => j !== i))} minItems={2} />\n * ```\n *\n * — and over react-hook-form's `useFieldArray` alike (`items={fields}`, `onAdd={() =>\n * append(blank())}`, `onRemove={remove}`), for which `@eifi1/ui-kit/rhf` has\n * `RhfLineItems`.\n *\n * On a wide pane the rows are a grid under the headers; on a narrow one (a container\n * query, so a dialog stacks where it is cramped) each row stacks its fields, each\n * under a small copy of its header. The headers are `aria-hidden`: every row is a\n * named group and every field is named by `render`'s `label` (\"Debit, row 2\"), which\n * is what a screen reader needs — a header row it would read once, out of context.\n *\n * Keyboard: after Add, focus lands in the new row's first field; after a remove, on\n * the row that took its place (else the add button). Ctrl/⌘+Enter in any row adds a\n * row.\n */\nexport function LineItems<T>({\n items,\n columns,\n getKey = defaultKey,\n onAdd,\n onRemove,\n minItems = 0,\n maxItems,\n confirmRemove,\n addLabel,\n rowLabel,\n totals,\n totalsLabel,\n empty,\n error,\n summary,\n fieldLabels = \"aria\",\n narrowColumns = 1,\n removePlacement = \"row\",\n removeAlign: removeAlignProp,\n disabled = false,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: LineItemsProps<T>) {\n const labels = useKitLabels(\"lineItems\", DEFAULT_LINE_ITEMS_LABELS, labelsProp);\n const rowRefs = useRef<Array<HTMLDivElement | null>>([]);\n const addRef = useRef<HTMLButtonElement>(null);\n const pendingFocus = useRef<PendingFocus | null>(null);\n const [confirmingAt, setConfirming] = useState<number | null>(null);\n // A confirm waiting on a row that has since gone is void.\n const confirming = confirmingAt !== null && confirmingAt < items.length ? confirmingAt : null;\n\n const tracks = [...columns.map((c) => c.width ?? \"minmax(0,1fr)\"), ...(onRemove ? [REMOVE_TRACK] : [])];\n const gridStyle = { \"--line-items-cols\": tracks.join(\" \") } as CSSProperties;\n const canAdd = Boolean(onAdd) && !disabled && (maxItems === undefined || items.length < maxItems);\n const canRemove = Boolean(onRemove) && !disabled && items.length > minItems;\n const hasError = hasContent(error);\n\n\n // Focus after the list has re-rendered with the new rows — a layout effect, so the\n // caret is where it belongs before paint.\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending) return;\n pendingFocus.current = null;\n if (pending.kind === \"added\") {\n const row = rowRefs.current[items.length - 1];\n row?.querySelector<HTMLElement>(FOCUSABLE)?.focus();\n return;\n }\n const row = rowRefs.current[Math.min(pending.index, items.length - 1)];\n const target =\n row?.querySelector<HTMLElement>(\"[data-line-items-remove]:not([disabled])\") ??\n row?.querySelector<HTMLElement>(FOCUSABLE) ??\n addRef.current;\n target?.focus();\n }, [items.length]);\n\n const add = useCallback(() => {\n if (!canAdd || !onAdd) return;\n pendingFocus.current = { kind: \"added\" };\n onAdd();\n }, [canAdd, onAdd]);\n\n const removeAt = async (index: number) => {\n if (!canRemove || !onRemove) return;\n if (confirmRemove === true && confirming !== index) {\n setConfirming(index);\n return;\n }\n if (typeof confirmRemove === \"function\") {\n const ok = await confirmRemove(items[index], index);\n if (!ok) return;\n }\n setConfirming(null);\n pendingFocus.current = { kind: \"removed\", index };\n onRemove(index);\n };\n\n const onRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key === \"Enter\" && (e.ctrlKey || e.metaKey) && canAdd) {\n e.preventDefault();\n add();\n }\n };\n\n const columnName = (column: LineItemsColumn<T>) =>\n column.label ?? (typeof column.header === \"string\" ? column.header : column.key);\n const floats = (column: LineItemsColumn<T>) => (column.fieldLabels ?? fieldLabels) === \"floating\";\n // A header row only for the columns that still need one: with every field floating\n // its own label, a row of headers would say each name twice.\n const showHeader = columns.some((c) => !floats(c));\n const pairs = narrowColumns === 2;\n const removeAlign = removeAlignProp ?? (columns.some(floats) ? \"center\" : \"start\");\n const inline = removePlacement === \"inline\" && Boolean(onRemove) && columns.length > 0;\n const lastColumn = columns[columns.length - 1];\n\n const headerCell = (column: LineItemsColumn<T>) => (\n <span\n key={column.key}\n className={cn(\n \"min-w-0 truncate text-xs font-medium text-[var(--text-muted)]\",\n column.align === \"end\" && \"text-end\",\n )}\n >\n {floats(column) ? null : column.header}\n </span>\n );\n\n const removeCell = (index: number, row: number, isConfirming: boolean) => (\n <div\n data-line-items-remove-cell=\"\"\n className={cn(\n \"flex justify-end @lg:justify-center\",\n REMOVE_ALIGN[removeAlign],\n // Paired, the remove button keeps a row of its own at the card's end rather\n // than squeezing in beside the last field — unless asked to go `inline`.\n inline ? \"shrink-0\" : pairs && \"@2xs:col-span-2 @lg:col-span-1\",\n )}\n >\n <IconButton\n type=\"button\"\n data-line-items-remove=\"\"\n variant={isConfirming ? \"danger\" : \"ghost\"}\n tone={isConfirming ? undefined : \"danger\"}\n label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}\n disabled={!canRemove}\n onClick={() => void removeAt(index)}\n onBlur={() => {\n if (isConfirming) setConfirming(null);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Escape\" && isConfirming) {\n e.stopPropagation();\n setConfirming(null);\n }\n }}\n >\n <Trash2 />\n </IconButton>\n </div>\n );\n\n const showTotals = totals !== undefined;\n const summaryTone = summary?.tone ?? \"neutral\";\n const summaryHasValue = hasContent(summary?.value);\n const labelColumn = showTotals ? columns.find((c) => !hasContent(totals[c.key]))?.key : undefined;\n\n return (\n <div\n data-slot=\"line-items\"\n {...rest}\n style={{ ...gridStyle, ...style }}\n className={cn(\"@container space-y-2\", className)}\n >\n {showHeader && (\n <div aria-hidden className=\"hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]\">\n {columns.map(headerCell)}\n {onRemove && <span />}\n </div>\n )}\n\n {items.length === 0 && hasContent(empty) && (\n <div className=\"rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-sm text-[var(--text-muted)]\">\n {empty}\n </div>\n )}\n\n {items.map((item, index) => {\n const row = index + 1;\n const isConfirming = confirming === index;\n return (\n // The row's Ctrl/⌘+Enter is a shortcut for the add button, which is itself\n // reachable by Tab; the group only listens for it bubbling from its fields.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n key={getKey(item, index)}\n ref={(el) => {\n rowRefs.current[index] = el;\n }}\n role=\"group\"\n aria-label={rowLabel ? rowLabel(index, item) : labels.row(row)}\n data-line-items-row=\"\"\n onKeyDown={onRowKeyDown}\n className={cn(\n \"grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start\",\n // Two short fields to a row of the card — from 18rem, where two fields\n // still fit a figure each; on anything narrower they stack after all.\n pairs && \"@2xs:grid-cols-2\",\n // Stacked, each row is a card of its own so the rows stay apart.\n \"@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3\",\n )}\n >\n {columns.map((column) => {\n const floating = floats(column);\n const isLast = inline && column === lastColumn;\n const span = pairs && column.narrowSpan === 2 && \"@2xs:col-span-2 @lg:col-span-1\";\n const cell = (\n <div\n key={column.key}\n className={cn(\n \"min-w-0 space-y-1 @lg:space-y-0\",\n // The wide grid places every cell in its own track again.\n isLast ? \"flex-1\" : span,\n )}\n >\n {!floating && (\n <span aria-hidden className=\"block text-xs font-medium text-[var(--text-muted)] @lg:hidden\">\n {column.header}\n </span>\n )}\n {column.render({\n item,\n index,\n label: labels.cell(columnName(column), row),\n fieldLabel: floating ? columnName(column) : undefined,\n })}\n </div>\n );\n if (!isLast) return cell;\n // Inline, the last field and the remove button share one cell of the\n // card; on the wide grid the wrapper dissolves (`contents`) and each is a\n // track of its own again. Level with the field's control: its foot under\n // a header copy, its middle when it floats its own label.\n return (\n <div\n key={column.key}\n data-line-items-inline=\"\"\n className={cn(\"flex min-w-0 gap-2 @lg:contents\", floating ? \"items-center\" : \"items-end\", span)}\n >\n {cell}\n {removeCell(index, row, isConfirming)}\n </div>\n );\n })}\n {onRemove && !inline && removeCell(index, row, isConfirming)}\n </div>\n );\n })}\n\n {showTotals && (\n <div\n role=\"group\"\n aria-label={typeof totalsLabel === \"string\" ? totalsLabel : labels.totals}\n data-line-items-totals=\"\"\n 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)]\"\n >\n {columns.map((column) => {\n const value = totals[column.key];\n if (column.key === labelColumn) {\n return (\n <span key={column.key} className=\"min-w-0\">\n {totalsLabel ?? labels.totals}\n </span>\n );\n }\n return (\n <span\n key={column.key}\n className={cn(\n \"min-w-0\",\n column.align === \"end\" && \"@lg:text-end\",\n // Stacked, a column with no total takes no line.\n !hasContent(value) && \"@max-lg:hidden\",\n )}\n >\n {hasContent(value) && (\n <span className=\"flex justify-between gap-2 @lg:block\">\n <span aria-hidden className=\"font-normal text-[var(--text-muted)] @lg:hidden\">\n {column.header}\n </span>\n {value}\n </span>\n )}\n </span>\n );\n })}\n {onRemove && <span className=\"@max-lg:hidden\" />}\n </div>\n )}\n\n {summary && (\n <div\n data-line-items-summary=\"\"\n className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-primary)]\"\n >\n {/* With no value the line is a state of its own, and the label takes the\n tone — the only place left to carry it. */}\n <span\n data-tone={summaryHasValue ? undefined : summaryTone}\n className={cn(\"min-w-0\", !summaryHasValue && SUMMARY_TONE[summaryTone])}\n >\n {summary.label}\n </span>\n {summaryHasValue && (\n <span data-tone={summaryTone} className={cn(\"font-semibold tabular-nums\", SUMMARY_TONE[summaryTone])}>\n {summary.value}\n </span>\n )}\n {hasContent(summary.status) && (\n <span data-line-items-summary-status=\"\" className=\"inline-flex\">\n {summary.status}\n </span>\n )}\n {summary.action && (\n <Button\n type=\"button\"\n variant={summary.action.variant === \"link\" ? \"link\" : \"secondary\"}\n tone={summary.action.variant === \"link\" ? \"muted\" : undefined}\n size=\"sm\"\n className=\"ms-auto\"\n disabled={disabled || summary.action.disabled}\n onClick={summary.action.onClick}\n >\n {summary.action.label}\n </Button>\n )}\n </div>\n )}\n\n {hasError && (\n <p className=\"text-[11px] leading-tight text-[var(--danger)]\">\n {error}\n </p>\n )}\n\n {onAdd && (\n <Button\n ref={addRef}\n type=\"button\"\n variant=\"secondary\"\n className=\"w-full border-dashed\"\n disabled={!canAdd}\n onClick={add}\n >\n <Plus aria-hidden className=\"size-4\" />\n {addLabel ?? labels.add}\n </Button>\n )}\n </div>\n );\n}\n"],"mappings":";AA8UI,cA0DI,YA1DJ;AA9UJ,SAAS,aAAa,iBAAiB,QAAQ,gBAAgB;AAE/D,SAAS,MAAM,cAAc;AAC7B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,QAAQ,kBAAkB;AAkB5B,MAAM,4BAA6C;AAAA,EACxD,KAAK;AAAA,EACL,QAAQ,CAAC,QAAQ,cAAc,GAAG;AAAA,EAClC,eAAe,CAAC,QAAQ,cAAc,GAAG;AAAA,EACzC,KAAK,CAAC,QAAQ,OAAO,GAAG;AAAA,EACxB,MAAM,CAAC,QAAQ,QAAQ,GAAG,MAAM,SAAS,GAAG;AAAA,EAC5C,QAAQ;AACV;AAiJA,MAAM,eAAe;AACrB,MAAM,eAAqD;AAAA,EACzD,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AAAA;AAAA,EAEP,QAAQ;AAAA,EACR,SAAS;AACX;AACA,MAAM,eAAqD;AAAA,EACzD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,KAAK;AACP;AACA,MAAM,YACJ;AAEF,SAAS,WAAW,MAAe,OAAoB;AACrD,MAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,QAAQ,MAAM;AAC7D,UAAM,KAAM,KAAyB;AACrC,QAAI,OAAO,OAAO,YAAY,OAAO,OAAO,SAAU,QAAO;AAAA,EAC/D;AACA,SAAO;AACT;AAEA,SAAS,WAAW,MAA0B;AAC5C,SAAO,SAAS,UAAa,SAAS,QAAQ,SAAS,SAAS,SAAS;AAC3E;AA+BO,SAAS,UAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,gBAAgB;AAAA,EAChB,kBAAkB;AAAA,EAClB,aAAa;AAAA,EACb,WAAW;AAAA,EACX,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,SAAS,aAAa,aAAa,2BAA2B,UAAU;AAC9E,QAAM,UAAU,OAAqC,CAAC,CAAC;AACvD,QAAM,SAAS,OAA0B,IAAI;AAC7C,QAAM,eAAe,OAA4B,IAAI;AACrD,QAAM,CAAC,cAAc,aAAa,IAAI,SAAwB,IAAI;AAElE,QAAM,aAAa,iBAAiB,QAAQ,eAAe,MAAM,SAAS,eAAe;AAEzF,QAAM,SAAS,CAAC,GAAG,QAAQ,IAAI,CAAC,MAAM,EAAE,SAAS,eAAe,GAAG,GAAI,WAAW,CAAC,YAAY,IAAI,CAAC,CAAE;AACtG,QAAM,YAAY,EAAE,qBAAqB,OAAO,KAAK,GAAG,EAAE;AAC1D,QAAM,SAAS,QAAQ,KAAK,KAAK,CAAC,aAAa,aAAa,UAAa,MAAM,SAAS;AACxF,QAAM,YAAY,QAAQ,QAAQ,KAAK,CAAC,YAAY,MAAM,SAAS;AACnE,QAAM,WAAW,WAAW,KAAK;AAKjC,kBAAgB,MAAM;AACpB,UAAM,UAAU,aAAa;AAC7B,QAAI,CAAC,QAAS;AACd,iBAAa,UAAU;AACvB,QAAI,QAAQ,SAAS,SAAS;AAC5B,YAAMA,OAAM,QAAQ,QAAQ,MAAM,SAAS,CAAC;AAC5C,MAAAA,MAAK,cAA2B,SAAS,GAAG,MAAM;AAClD;AAAA,IACF;AACA,UAAM,MAAM,QAAQ,QAAQ,KAAK,IAAI,QAAQ,OAAO,MAAM,SAAS,CAAC,CAAC;AACrE,UAAM,SACJ,KAAK,cAA2B,0CAA0C,KAC1E,KAAK,cAA2B,SAAS,KACzC,OAAO;AACT,YAAQ,MAAM;AAAA,EAChB,GAAG,CAAC,MAAM,MAAM,CAAC;AAEjB,QAAM,MAAM,YAAY,MAAM;AAC5B,QAAI,CAAC,UAAU,CAAC,MAAO;AACvB,iBAAa,UAAU,EAAE,MAAM,QAAQ;AACvC,UAAM;AAAA,EACR,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,QAAM,WAAW,OAAO,UAAkB;AACxC,QAAI,CAAC,aAAa,CAAC,SAAU;AAC7B,QAAI,kBAAkB,QAAQ,eAAe,OAAO;AAClD,oBAAc,KAAK;AACnB;AAAA,IACF;AACA,QAAI,OAAO,kBAAkB,YAAY;AACvC,YAAM,KAAK,MAAM,cAAc,MAAM,KAAK,GAAG,KAAK;AAClD,UAAI,CAAC,GAAI;AAAA,IACX;AACA,kBAAc,IAAI;AAClB,iBAAa,UAAU,EAAE,MAAM,WAAW,MAAM;AAChD,aAAS,KAAK;AAAA,EAChB;AAEA,QAAM,eAAe,CAAC,MAAqC;AACzD,QAAI,EAAE,QAAQ,YAAY,EAAE,WAAW,EAAE,YAAY,QAAQ;AAC3D,QAAE,eAAe;AACjB,UAAI;AAAA,IACN;AAAA,EACF;AAEA,QAAM,aAAa,CAAC,WAClB,OAAO,UAAU,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS,OAAO;AAC9E,QAAM,SAAS,CAAC,YAAgC,OAAO,eAAe,iBAAiB;AAGvF,QAAM,aAAa,QAAQ,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AACjD,QAAM,QAAQ,kBAAkB;AAChC,QAAM,cAAc,oBAAoB,QAAQ,KAAK,MAAM,IAAI,WAAW;AAC1E,QAAM,SAAS,oBAAoB,YAAY,QAAQ,QAAQ,KAAK,QAAQ,SAAS;AACrF,QAAM,aAAa,QAAQ,QAAQ,SAAS,CAAC;AAE7C,QAAM,aAAa,CAAC,WAClB;AAAA,IAAC;AAAA;AAAA,MAEC,WAAW;AAAA,QACT;AAAA,QACA,OAAO,UAAU,SAAS;AAAA,MAC5B;AAAA,MAEC,iBAAO,MAAM,IAAI,OAAO,OAAO;AAAA;AAAA,IAN3B,OAAO;AAAA,EAOd;AAGF,QAAM,aAAa,CAAC,OAAe,KAAa,iBAC9C;AAAA,IAAC;AAAA;AAAA,MACC,+BAA4B;AAAA,MAC5B,WAAW;AAAA,QACT;AAAA,QACA,aAAa,WAAW;AAAA;AAAA;AAAA,QAGxB,SAAS,aAAa,SAAS;AAAA,MACjC;AAAA,MAEA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,0BAAuB;AAAA,UACvB,SAAS,eAAe,WAAW;AAAA,UACnC,MAAM,eAAe,SAAY;AAAA,UACjC,OAAO,eAAe,OAAO,cAAc,GAAG,IAAI,OAAO,OAAO,GAAG;AAAA,UACnE,UAAU,CAAC;AAAA,UACX,SAAS,MAAM,KAAK,SAAS,KAAK;AAAA,UAClC,QAAQ,MAAM;AACZ,gBAAI,aAAc,eAAc,IAAI;AAAA,UACtC;AAAA,UACA,WAAW,CAAC,MAAM;AAChB,gBAAI,EAAE,QAAQ,YAAY,cAAc;AACtC,gBAAE,gBAAgB;AAClB,4BAAc,IAAI;AAAA,YACpB;AAAA,UACF;AAAA,UAEA,8BAAC,UAAO;AAAA;AAAA,MACV;AAAA;AAAA,EACF;AAGF,QAAM,aAAa,WAAW;AAC9B,QAAM,cAAc,SAAS,QAAQ;AACrC,QAAM,kBAAkB,WAAW,SAAS,KAAK;AACjD,QAAM,cAAc,aAAa,QAAQ,KAAK,CAAC,MAAM,CAAC,WAAW,OAAO,EAAE,GAAG,CAAC,CAAC,GAAG,MAAM;AAExF,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACT,GAAG;AAAA,MACJ,OAAO,EAAE,GAAG,WAAW,GAAG,MAAM;AAAA,MAChC,WAAW,GAAG,wBAAwB,SAAS;AAAA,MAE9C;AAAA,sBACC,qBAAC,SAAI,eAAW,MAAC,WAAU,gEACxB;AAAA,kBAAQ,IAAI,UAAU;AAAA,UACtB,YAAY,oBAAC,UAAK;AAAA,WACrB;AAAA,QAGD,MAAM,WAAW,KAAK,WAAW,KAAK,KACrC,oBAAC,SAAI,WAAU,iHACZ,iBACH;AAAA,QAGD,MAAM,IAAI,CAAC,MAAM,UAAU;AAC1B,gBAAM,MAAM,QAAQ;AACpB,gBAAM,eAAe,eAAe;AACpC;AAAA;AAAA;AAAA;AAAA,YAIE;AAAA,cAAC;AAAA;AAAA,gBAEC,KAAK,CAAC,OAAO;AACX,0BAAQ,QAAQ,KAAK,IAAI;AAAA,gBAC3B;AAAA,gBACA,MAAK;AAAA,gBACL,cAAY,WAAW,SAAS,OAAO,IAAI,IAAI,OAAO,IAAI,GAAG;AAAA,gBAC7D,uBAAoB;AAAA,gBACpB,WAAW;AAAA,gBACX,WAAW;AAAA,kBACT;AAAA;AAAA;AAAA,kBAGA,SAAS;AAAA;AAAA,kBAET;AAAA,gBACF;AAAA,gBAEC;AAAA,0BAAQ,IAAI,CAAC,WAAW;AACvB,0BAAM,WAAW,OAAO,MAAM;AAC9B,0BAAM,SAAS,UAAU,WAAW;AACpC,0BAAM,OAAO,SAAS,OAAO,eAAe,KAAK;AACjD,0BAAM,OACJ;AAAA,sBAAC;AAAA;AAAA,wBAEC,WAAW;AAAA,0BACT;AAAA;AAAA,0BAEA,SAAS,WAAW;AAAA,wBACtB;AAAA,wBAEC;AAAA,2BAAC,YACA,oBAAC,UAAK,eAAW,MAAC,WAAU,iEACzB,iBAAO,QACV;AAAA,0BAED,OAAO,OAAO;AAAA,4BACb;AAAA,4BACA;AAAA,4BACA,OAAO,OAAO,KAAK,WAAW,MAAM,GAAG,GAAG;AAAA,4BAC1C,YAAY,WAAW,WAAW,MAAM,IAAI;AAAA,0BAC9C,CAAC;AAAA;AAAA;AAAA,sBAjBI,OAAO;AAAA,oBAkBd;AAEF,wBAAI,CAAC,OAAQ,QAAO;AAKpB,2BACE;AAAA,sBAAC;AAAA;AAAA,wBAEC,0BAAuB;AAAA,wBACvB,WAAW,GAAG,mCAAmC,WAAW,iBAAiB,aAAa,IAAI;AAAA,wBAE7F;AAAA;AAAA,0BACA,WAAW,OAAO,KAAK,YAAY;AAAA;AAAA;AAAA,sBAL/B,OAAO;AAAA,oBAMd;AAAA,kBAEJ,CAAC;AAAA,kBACA,YAAY,CAAC,UAAU,WAAW,OAAO,KAAK,YAAY;AAAA;AAAA;AAAA,cA3DtD,OAAO,MAAM,KAAK;AAAA,YA4DzB;AAAA;AAAA,QAEJ,CAAC;AAAA,QAEA,cACC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,cAAY,OAAO,gBAAgB,WAAW,cAAc,OAAO;AAAA,YACnE,0BAAuB;AAAA,YACvB,WAAU;AAAA,YAET;AAAA,sBAAQ,IAAI,CAAC,WAAW;AACvB,sBAAM,QAAQ,OAAO,OAAO,GAAG;AAC/B,oBAAI,OAAO,QAAQ,aAAa;AAC9B,yBACE,oBAAC,UAAsB,WAAU,WAC9B,yBAAe,OAAO,UADd,OAAO,GAElB;AAAA,gBAEJ;AACA,uBACE;AAAA,kBAAC;AAAA;AAAA,oBAEC,WAAW;AAAA,sBACT;AAAA,sBACA,OAAO,UAAU,SAAS;AAAA;AAAA,sBAE1B,CAAC,WAAW,KAAK,KAAK;AAAA,oBACxB;AAAA,oBAEC,qBAAW,KAAK,KACf,qBAAC,UAAK,WAAU,wCACd;AAAA,0CAAC,UAAK,eAAW,MAAC,WAAU,mDACzB,iBAAO,QACV;AAAA,sBACC;AAAA,uBACH;AAAA;AAAA,kBAdG,OAAO;AAAA,gBAgBd;AAAA,cAEJ,CAAC;AAAA,cACA,YAAY,oBAAC,UAAK,WAAU,kBAAiB;AAAA;AAAA;AAAA,QAChD;AAAA,QAGD,WACC;AAAA,UAAC;AAAA;AAAA,YACC,2BAAwB;AAAA,YACxB,WAAU;AAAA,YAIV;AAAA;AAAA,gBAAC;AAAA;AAAA,kBACC,aAAW,kBAAkB,SAAY;AAAA,kBACzC,WAAW,GAAG,WAAW,CAAC,mBAAmB,aAAa,WAAW,CAAC;AAAA,kBAErE,kBAAQ;AAAA;AAAA,cACX;AAAA,cACC,mBACC,oBAAC,UAAK,aAAW,aAAa,WAAW,GAAG,8BAA8B,aAAa,WAAW,CAAC,GAChG,kBAAQ,OACX;AAAA,cAED,WAAW,QAAQ,MAAM,KACxB,oBAAC,UAAK,kCAA+B,IAAG,WAAU,eAC/C,kBAAQ,QACX;AAAA,cAED,QAAQ,UACP;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAS,QAAQ,OAAO,YAAY,SAAS,SAAS;AAAA,kBACtD,MAAM,QAAQ,OAAO,YAAY,SAAS,UAAU;AAAA,kBACpD,MAAK;AAAA,kBACL,WAAU;AAAA,kBACV,UAAU,YAAY,QAAQ,OAAO;AAAA,kBACrC,SAAS,QAAQ,OAAO;AAAA,kBAEvB,kBAAQ,OAAO;AAAA;AAAA,cAClB;AAAA;AAAA;AAAA,QAEJ;AAAA,QAGD,YACC,oBAAC,OAAE,WAAU,kDACV,iBACH;AAAA,QAGD,SACC;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,WAAU;AAAA,YACV,UAAU,CAAC;AAAA,YACX,SAAS;AAAA,YAET;AAAA,kCAAC,QAAK,eAAW,MAAC,WAAU,UAAS;AAAA,cACpC,YAAY,OAAO;AAAA;AAAA;AAAA,QACtB;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":["row"]}
|
|
@@ -99,7 +99,12 @@ interface ListItemBaseProps {
|
|
|
99
99
|
* never operated — no buttons, links or inputs (invalid HTML in a button, and folded
|
|
100
100
|
* into its name). Its text joins the row's accessible name, so give a bar that only
|
|
101
101
|
* repeats a figure already in the row `aria-hidden`. Rendered in a block `<span>`,
|
|
102
|
-
* full width of the
|
|
102
|
+
* full width of the TEXT COLUMN only — the column the title and subtitle are in, not
|
|
103
|
+
* the whole row: it starts after the {@link icon} / {@link leading} and stops where
|
|
104
|
+
* {@link trailing} begins, so a meter under a row with a trailing figure is as wide as
|
|
105
|
+
* the title, not run on under the figure (keksdose's budget rows). For a bar across
|
|
106
|
+
* the full row, put it in {@link children} (outside the target) or leave `trailing`
|
|
107
|
+
* off and show the figure in `meta`. Strictly a button holds phrasing content only; the
|
|
103
108
|
* kit's `ProgressBar` draws `<div>`s, which every browser lays out and reads fine
|
|
104
109
|
* there but an HTML validator flags.
|
|
105
110
|
*/
|
|
@@ -219,6 +224,13 @@ interface ListItemBaseProps {
|
|
|
219
224
|
* the list's child and keeps both. The `<li>` becomes a column and the row takes the
|
|
220
225
|
* full width, so an inline wrapper (the tooltip's `inline-flex` span) still stretches
|
|
221
226
|
* the row across the list.
|
|
227
|
+
*
|
|
228
|
+
* `row` is the row BOX (a `<div>`), not the target: a wrapper that forces a prop onto
|
|
229
|
+
* its child with `cloneElement` — a save guard that injects `disabled` while a form is
|
|
230
|
+
* dirty (keksdose's SaveGuard) — sets it on that div, where it does nothing, and the
|
|
231
|
+
* button inside stays live. Pass `disabled` (or `loading`) to the ListItem yourself,
|
|
232
|
+
* from the same state the guard reads, and let the wrapper only explain it:
|
|
233
|
+
* `<ListItem disabled={dirty} renderRow={(row) => <Tooltip label="Save first">{row}</Tooltip>} />`.
|
|
222
234
|
*/
|
|
223
235
|
renderRow?: (row: ReactElement) => ReactNode;
|
|
224
236
|
/** Reaches the main target — the element with the row's name and action. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/list.tsx"],"sourcesContent":["import { createContext, forwardRef, useContext, useId, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { ChevronDown, ExternalLink } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Spinner } from \"./ui\";\nimport { StatusDot } from \"./status-dot\";\nimport { Collapse } from \"./disclosure\";\nimport { toneTextClass } from \"./signed-amount\";\nimport type { TextTone } from \"./signed-amount\";\nimport { useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer } from \"./text-link\";\n\n/* ── Labels ───────────────────────────────────────────────────────────────── */\n\nexport interface ListLabels {\n /** Read after an unread row's title — the dot alone says nothing to a screen reader. */\n unread: string;\n /** Read after an external row's title: the row leaves the app in a new tab. */\n opensInNewTab: string;\n}\n\nexport const DEFAULT_LIST_LABELS: ListLabels = {\n unread: \"Unread\",\n opensInNewTab: \"opens in a new tab\",\n};\n\n/* ── List ─────────────────────────────────────────────────────────────────── */\n\n/**\n * `compact` 36px-ish rows for a dense panel (an inbox in a menu), `default` the ~44px\n * row every site draws with `py-2.5`, `comfortable` for a list that is the page.\n */\nexport type ListDensity = \"compact\" | \"default\" | \"comfortable\";\n/** `divider`: a rule between rows (kastlan's `divide-y` lists); `gap`: a small space\n * between rows (lenkbank's `space-y-2`, kastlan's `space-y-1`); `none`: flush. */\nexport type ListSeparator = \"divider\" | \"gap\" | \"none\";\n\nconst ListContext = createContext<{ density: ListDensity; separator: ListSeparator }>({\n density: \"default\",\n separator: \"gap\",\n});\n\nexport interface ListProps extends Omit<ComponentPropsWithoutRef<\"ul\">, \"role\"> {\n /** `ol` when the order means something (a ranked list, numbered segments). */\n as?: \"ul\" | \"ol\";\n /** Every row's density, unless a row says otherwise. Default `default`. */\n density?: ListDensity;\n /** Default `gap`. */\n separator?: ListSeparator;\n}\n\n/**\n * The list of {@link ListItem}s.\n *\n * A `<ul>` with an explicit `role=\"list\"`: Safari drops a list's semantics once its\n * bullets are styled away, and \"list, 7 items\" is exactly what a screen-reader user\n * wants to hear before the first row.\n */\nexport const List = forwardRef<HTMLUListElement, ListProps>(function List(\n { as = \"ul\", density = \"default\", separator = \"gap\", className, children, ...rest },\n ref,\n) {\n const Tag = as;\n return (\n <ListContext.Provider value={{ density, separator }}>\n <Tag\n {...rest}\n ref={ref as Ref<HTMLUListElement & HTMLOListElement>}\n role=\"list\"\n className={cn(\n \"m-0 flex list-none flex-col p-0\",\n separator === \"gap\" && (density === \"compact\" ? \"gap-0.5\" : \"gap-1\"),\n className,\n )}\n >\n {children}\n </Tag>\n </ListContext.Provider>\n );\n});\n\n/* ── ListItem ─────────────────────────────────────────────────────────────── */\n\n/** What {@link ListItemProps.renderLink} is handed. Spread it onto your router's link,\n * mapping `href` to what it calls it — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface ListItemLinkProps {\n href: string;\n /** The row's target look — keep it, or the row's padding and focus ring go. */\n className: string;\n children: ReactNode;\n ref?: Ref<HTMLAnchorElement>;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n \"aria-current\"?: \"true\";\n \"aria-busy\"?: true;\n id?: string;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nconst PAD: Record<\n ListDensity,\n { target: string; actions: string; leadingActions: string; gap: string; body: string }\n> = {\n compact: { target: \"gap-2 px-2 py-1.5\", actions: \"pe-1.5\", leadingActions: \"ps-1.5\", gap: \"gap-0.5\", body: \"px-2 pb-1.5\" },\n default: { target: \"gap-3 px-3 py-2.5\", actions: \"pe-2\", leadingActions: \"ps-2\", gap: \"gap-1\", body: \"px-3 pb-2.5\" },\n comfortable: { target: \"gap-3 px-4 py-3\", actions: \"pe-3\", leadingActions: \"ps-3\", gap: \"gap-1\", body: \"px-4 pb-3\" },\n};\n\n/** {@link ListItemBaseProps.trailingTone}: a {@link TextTone}, or `inherit` — no\n * colour of the slot's own, so an element's colour (or the row's) stands. */\nexport type ListItemTrailingTone = TextTone | \"inherit\";\n\nconst TARGET_RING =\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]\";\n\ninterface ListItemBaseProps {\n /** The row's name: one line, truncated — unless {@link titleLines} says otherwise. */\n title: ReactNode;\n /**\n * How many lines the title may take. `1` (default) truncates with an ellipsis, which\n * is right for a name the app chose — a unit number, a user, a file — where the\n * cut-off end is still recognisable and every row keeps one height.\n *\n * `2` clamps at two lines; `\"all\"` shows every line, breaking inside a long word\n * rather than overflowing. For rows whose only name is free text a person typed\n * (kastlan 44): a defect or a note titled by its description, where the first 30\n * characters on a phone say nothing and there is no other field to tell two rows\n * apart. Pair it with `align=\"start\"` so the icon stays by the first line.\n */\n titleLines?: 1 | 2 | \"all\";\n /** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */\n subtitle?: ReactNode;\n /** `2` for a line that is a body rather than a label (keksdose's notification text). */\n subtitleLines?: 1 | 2;\n /**\n * A small line ABOVE the title, in the caption type: the group a row belongs to, a\n * date heading, a category (\"Groceries · weekly\"). Part of the target and of its\n * name, read before the title. One line, truncated.\n */\n overline?: ReactNode;\n /** A third line in the caption type: a timestamp, \"Updated …\" (lenkbank's projects). */\n meta?: ReactNode;\n /**\n * Let {@link meta} wrap: it becomes a wrapping row (`flex-wrap`, a small gap)\n * instead of one truncated line — for a run of chips (keksdose's payee-backfill\n * tags), which otherwise had nowhere to go but the body, outside the target. Still\n * inside the target, so still no controls: chips that only show, not ones that\n * remove themselves.\n */\n metaWrap?: boolean;\n /**\n * Content INSIDE the main target, under the title, subtitle and meta: a progress or\n * budget bar, a sparkline, a strip of figures (keksdose's food-group bars). Unlike\n * {@link children}, which sits outside the target, a click on it is a click on the\n * row, and its hover fill covers it.\n *\n * Because it is inside the button or link it obeys the {@link trailing} rule: shown,\n * never operated — no buttons, links or inputs (invalid HTML in a button, and folded\n * into its name). Its text joins the row's accessible name, so give a bar that only\n * repeats a figure already in the row `aria-hidden`. Rendered in a block `<span>`,\n * full width of the text column. Strictly a button holds phrasing content only; the\n * kit's `ProgressBar` draws `<div>`s, which every browser lays out and reads fine\n * there but an HTML validator flags.\n */\n content?: ReactNode;\n /** A Lucide icon before the text, muted, at 16px. */\n icon?: LucideIcon;\n /** Anything else before the text — an avatar, a file-type badge, a drag handle. */\n leading?: ReactNode;\n /**\n * Inside the main target, at its end: a status chip, a file size, a count. It is part\n * of what the row's click does and part of its accessible name — so never a control.\n * Controls go in {@link actions}.\n */\n trailing?: ReactNode;\n /**\n * The colour of the {@link trailing} slot. Default `muted` — today's look, right for\n * a size or a count in plain text. `inherit` sets none, so an element's own colour\n * (an amount coloured by its sign) stands without a second override; any\n * {@link TextTone} colours a plain-text figure (`\"danger\"` for an overdue count).\n */\n trailingTone?: ListItemTrailingTone;\n /**\n * Controls BESIDE the main target, never inside it: copy, delete, \"Open\".\n *\n * A button inside a button (or a link) is invalid HTML, and a screen reader folds the\n * inner one into the outer one's name where it cannot be reached. lenkbank found this\n * three times (profile-bar.tsx:110, projects-page.tsx:61, segment-list.tsx:165) and\n * solved it the same way each time: the row's target and its actions are siblings in\n * one bordered box. That is this slot — and being siblings is also what stops a click\n * on \"Copy\" from selecting the row.\n */\n actions?: ReactNode;\n /**\n * Controls BEFORE the main target, never inside it: a selection checkbox, a drag\n * handle that is a button. The mirror of {@link actions} at the start edge — a\n * sibling of the target, so checking the box does not activate (or expand) the row\n * and the checkbox keeps its own name. Give the control an `aria-label` naming the\n * row (\"Select Groceries\"), since it is not inside the row's name. Anything that only\n * shows goes in {@link leading}, inside the target.\n */\n leadingActions?: ReactNode;\n /**\n * Rich content UNDER the title row and outside its target: photos, per-row buttons,\n * form fields — kastlan's handover room inspector (room-inspector.tsx), the handover\n * detail page and the wizard's defects step (wizard/defects-step.tsx), which draw\n * each room or defect as a `Card variant=\"inset\"` because a row could not hold a\n * photo strip with its own buttons.\n *\n * The same rule as {@link actions}, one row down: the title area stays ONE named\n * target (the button or link, with the title and subtitle as its name), and the body\n * is its sibling, never nested in it — a thumbnail's \"Remove\" inside the row's button\n * would be invalid HTML and unreachable by a screen reader. The body is a\n * `role=\"group\"` named by the row's title (`aria-labelledby`), so tabbing into the\n * third photo's button of the second room is heard as \"Kitchen, group\" first rather\n * than as a button floating free of any row. Inside the row's box, so a `selected`\n * border and a `divider` hold both; the hover fill stays on the target alone, since\n * the body is not what a click on the row does.\n */\n children?: ReactNode;\n /** Classes on the {@link children} body. */\n bodyClassName?: string;\n /**\n * The unread mark: a brand dot at the row's end, the title in semibold, and \"Unread\"\n * read after the title (keksdose notification-inbox.tsx:215). For another status,\n * pass your own mark in {@link status}.\n */\n unread?: boolean;\n /** A mark in the unread dot's place — a `StatusDot` of another tone, a count. */\n status?: ReactNode;\n /**\n * The row is the chosen one — a brand border on the raised surface (lenkbank's\n * profile bar and segment list). `aria-current=\"true\"` on the target: the row is the\n * current one of a set, which is what those lists mean, not a pressed toggle.\n */\n selected?: boolean;\n disabled?: boolean;\n /**\n * Work under way on the row — keksdose's attachment download (support-attachments\n * .tsx:91). A spinner takes the trailing edge, the target is `aria-busy`, and a\n * second click is ignored. The row keeps focus: `aria-disabled`, not `disabled`, so\n * a keyboard user is not dropped back to the top of the page mid-download.\n */\n loading?: boolean;\n /** Overrides the {@link List}'s density for this row. */\n density?: ListDensity;\n /** `start` for rows whose text runs to two or three lines, so the icon sits by the\n * title rather than the middle of the paragraph. Default `center`. */\n align?: \"center\" | \"start\";\n /** Classes on the visible row (the bordered box). */\n className?: string;\n /** Classes on the main target (the button, link or static block). */\n targetClassName?: string;\n /**\n * Extra attributes and handlers for the main target — e.g. HTML5 drag-and-drop\n * (`draggable`, `onDragStart`, `onDragEnd`): lenkbank's segment list drags the WHOLE\n * 44px row, and a grip inside the button cannot start a drag in Firefox. Spread first,\n * so the row's own role, name, state and click handling always win.\n */\n targetProps?: HTMLAttributes<HTMLElement>;\n /**\n * A visible border while unselected, instead of the transparent one. For rows laid out\n * as a wrapping strip (lenkbank's profile bar), where borderless rows read as loose\n * text. The selected row's brand border is unchanged.\n */\n bordered?: boolean;\n /** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */\n as?: \"li\" | \"div\";\n /**\n * Wraps the visible row (the bordered box: target, actions and body) in an element\n * of your own, INSIDE the `<li>` — a `<Tooltip>` explaining why the row is disabled,\n * an app's save guard: `renderRow={(row) => <Tooltip label=\"Locked\">{row}</Tooltip>}`.\n *\n * The wrapper otherwise had to go round the whole `ListItem`, which put a `<span>`\n * between the `<ul>` and its `<li>` (invalid; the list stops counting items), so\n * callers wrote `<li><Wrapper><ListItem as=\"div\" /></Wrapper></li>` — and lost the\n * divider and its spacing, which sit on the row's own `<li>`. Here the `<li>` stays\n * the list's child and keeps both. The `<li>` becomes a column and the row takes the\n * full width, so an inline wrapper (the tooltip's `inline-flex` span) still stretches\n * the row across the list.\n */\n renderRow?: (row: ReactElement) => ReactNode;\n /** Reaches the main target — the element with the row's name and action. */\n id?: string;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\n/**\n * The row is ONE thing to activate, so the types allow one: a button, an in-app link,\n * an external link, or nothing (a static row, like kastlan's passkey and user lists).\n */\nexport type ListItemProps = ListItemBaseProps &\n (\n | ({\n /** Makes the row a button — select, open, mark read. */\n onClick: (event: MouseEvent<HTMLButtonElement>) => void;\n href?: never;\n renderLink?: never;\n external?: never;\n onAuxClick?: never;\n } & NotExpandable)\n | ({\n /**\n * Makes the row a link. A real `<a href>`, so a middle click opens it in a\n * background tab (keksdose feedback #451) — which a button calling `navigate`\n * cannot do.\n */\n href: string;\n /** Your router's link for an in-app `href`. Default: the `<UiKitProvider\n * linkComponent>`, then `<a>`. The API `Chip`, `StatTile` and `MenuItem`\n * share. Ignored for an `external` row. */\n renderLink?: (props: ListItemLinkProps) => ReactElement;\n /**\n * The link leaves the app: a plain `<a target=\"_blank\" rel=\"noopener\n * noreferrer\">` with an external-link mark, and \"opens in a new tab\" read\n * after the title. keksdose's inbox rows whose URL is absolute.\n */\n external?: boolean;\n /** Runs on a click, before the navigation (mark the row read). */\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n /** Runs on a middle click, which fires no `click` — keksdose marks a\n * notification read here too, so a row opened into a background tab does not\n * stay unread. */\n onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n } & NotExpandable)\n | ({\n onClick?: never;\n href?: never;\n renderLink?: never;\n external?: never;\n onAuxClick?: never;\n } & NotExpandable)\n | {\n /**\n * Makes the row a disclosure: the target is a button with `aria-expanded` and a\n * turning chevron at its end, and this opens under the row, outside the target\n * (so it may hold controls), animated by {@link Collapse}. keksdose's collapsible\n * groups — a group row with a select-all checkbox before it\n * ({@link ListItemBaseProps.leadingActions}) and its members inside.\n *\n * The row's ONE action is the toggle, so it takes no `onClick` or `href`. The\n * content is a `role=\"group\"` named by the title, unmounted while shut (as a\n * `Disclosure`'s body is).\n */\n expandedContent: ReactNode;\n /** Controlled open state. Leave it out for the row to keep its own. */\n expanded?: boolean;\n /** The uncontrolled row's first state. Default `false`. */\n defaultExpanded?: boolean;\n /** Runs on every toggle with the new state. */\n onExpandedChange?: (expanded: boolean) => void;\n onClick?: never;\n href?: never;\n renderLink?: never;\n external?: never;\n onAuxClick?: never;\n }\n );\n\ninterface NotExpandable {\n expandedContent?: never;\n expanded?: never;\n defaultExpanded?: never;\n onExpandedChange?: never;\n}\n\n/**\n * One row of a {@link List}: leading icon or avatar, title and subtitle (both\n * truncating by default), a trailing slot, and — beside the row, not inside it — its actions.\n *\n * All three apps draw this row by hand, a dozen times: kastlan's units\n * (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity\n * feed (activity-feed.tsx:53), company users (platform-company-detail-page.tsx:140)\n * and passkeys (passkeys-card.tsx:75); keksdose's notification inbox and attachment\n * downloads; lenkbank's profiles, projects and segments. The copies differ in the\n * details that are easy to get wrong — which of them truncate, whether the actions\n * are nested in the button, whether a link row is a link at all.\n */\nexport const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(\n {\n title,\n titleLines = 1,\n overline,\n subtitle,\n subtitleLines = 1,\n meta,\n metaWrap = false,\n content,\n icon: Icon,\n leading,\n trailing,\n trailingTone = \"muted\",\n actions,\n leadingActions,\n unread = false,\n status,\n selected = false,\n disabled = false,\n loading = false,\n density: densityProp,\n align = \"center\",\n className,\n targetClassName,\n targetProps,\n bordered = false,\n as = \"li\",\n renderRow,\n children,\n bodyClassName,\n href,\n renderLink,\n external = false,\n onClick,\n onAuxClick,\n expandedContent,\n expanded: expandedProp,\n defaultExpanded = false,\n onExpandedChange,\n ...rest\n },\n ref,\n) {\n const list = useContext(ListContext);\n const labels = useKitLabels(\"list\", DEFAULT_LIST_LABELS);\n const kitLink = useKitLink();\n const [ownExpanded, setOwnExpanded] = useState(defaultExpanded);\n const density = densityProp ?? list.density;\n const pad = PAD[density];\n const expandable = expandedContent !== undefined;\n const expanded = expandable && (expandedProp ?? ownExpanded);\n const isLink = href !== undefined && !disabled;\n const isButton = !isLink && (onClick !== undefined || expandable) && href === undefined;\n const interactive = isLink || isButton;\n const inert = disabled || loading;\n const hasBody = children !== undefined && children !== null && children !== false;\n // Anything under the row's own line: the box turns into a column, and the target\n // takes its own corners and hover fill (see `children`).\n const stacked = hasBody || expandable;\n const titleId = useId();\n const expandedId = useId();\n\n const toggle = () => {\n const next = !expanded;\n if (expandedProp === undefined) setOwnExpanded(next);\n onExpandedChange?.(next);\n };\n\n const mark =\n status ??\n (unread ? <StatusDot tone=\"brand\" size=\"sm\" className={align === \"start\" ? \"mt-1.5\" : undefined} /> : null);\n\n const body = (\n <>\n {Icon && (\n <Icon\n className={cn(\"size-4 shrink-0 text-[var(--text-muted)]\", align === \"start\" && \"mt-0.5\")}\n aria-hidden\n />\n )}\n {leading}\n <span className=\"flex min-w-0 flex-1 flex-col\">\n {overline != null && (\n <span className=\"block truncate text-[11px] font-medium leading-snug text-[var(--text-muted)]\">\n {overline}\n </span>\n )}\n <span\n // Names the body's and the expanded content's group; harmless without one.\n id={stacked ? titleId : undefined}\n className={cn(\n \"block text-sm text-[var(--text-primary)]\",\n titleLines === 1 ? \"truncate\" : titleLines === 2 ? \"line-clamp-2 break-words\" : \"break-words\",\n unread ? \"font-semibold\" : \"font-medium\",\n )}\n >\n {title}\n {unread && <span className=\"sr-only\"> ({labels.unread})</span>}\n {external && href !== undefined && <span className=\"sr-only\"> ({labels.opensInNewTab})</span>}\n </span>\n {subtitle != null && (\n <span\n className={cn(\n \"block text-xs text-[var(--text-muted)]\",\n subtitleLines === 2 ? \"line-clamp-2\" : \"truncate\",\n )}\n >\n {subtitle}\n </span>\n )}\n {meta != null && (\n <span\n className={cn(\n \"mt-0.5 text-[11px] leading-snug text-[var(--text-muted)]\",\n metaWrap ? \"flex flex-wrap items-center gap-1\" : \"block truncate\",\n )}\n >\n {meta}\n </span>\n )}\n {content != null && <span className=\"mt-1.5 block min-w-0\">{content}</span>}\n </span>\n {trailing != null && (\n <span\n className={cn(\n \"flex shrink-0 items-center gap-2 text-xs\",\n trailingTone !== \"inherit\" && toneTextClass(trailingTone),\n )}\n >\n {trailing}\n </span>\n )}\n {loading ? (\n <Spinner label={null} className=\"size-3.5 shrink-0\" />\n ) : (\n external && href !== undefined && (\n <ExternalLink className=\"size-3.5 shrink-0 text-[var(--text-muted)] rtl:-scale-x-100\" aria-hidden />\n )\n )}\n {mark}\n {expandable && (\n // Turned, not swapped — the Disclosure card's convention: down while shut, up\n // while open. A rotation is its own mirror, so RTL needs nothing.\n <ChevronDown\n aria-hidden\n className={cn(\n \"size-4 shrink-0 text-[var(--text-muted)] transition-transform duration-200 ease-out motion-reduce:transition-none\",\n align === \"start\" && \"mt-0.5\",\n expanded && \"rotate-180\",\n )}\n />\n )}\n </>\n );\n\n const target = cn(\n \"flex min-w-0 flex-1 text-start\",\n align === \"start\" ? \"items-start\" : \"items-center\",\n pad.target,\n // When stacked the target is the top of the box, not all of it, so it takes its\n // own corners and its own hover fill (see `children`).\n stacked ? \"rounded-md\" : \"rounded-[inherit]\",\n stacked && interactive && !inert && !selected && \"transition-colors hover:bg-[var(--bg-hover)]\",\n interactive && TARGET_RING,\n interactive && !inert && \"cursor-pointer\",\n loading && \"cursor-progress\",\n targetClassName,\n );\n\n // `role` is the row's own (a button, a link, or nothing on a static block): a\n // targetProps role would silently turn the row into something else.\n const { role: _ignoredRole, ...targetExtras } = targetProps ?? {};\n\n let main: ReactNode;\n if (isLink) {\n const linkProps: ListItemLinkProps = {\n ...(targetExtras as Partial<ListItemLinkProps>),\n ...rest,\n ref: ref as Ref<HTMLAnchorElement>,\n href,\n className: target,\n \"aria-current\": selected ? \"true\" : undefined,\n \"aria-busy\": loading || undefined,\n onClick: loading\n ? (e) => e.preventDefault()\n : (onClick as ListItemLinkProps[\"onClick\"]),\n onAuxClick,\n children: body,\n };\n const { children: linkBody, ...anchorProps } = linkProps;\n const render = external ? undefined : pickLinkRenderer(renderLink, kitLink, href);\n main = render ? (\n <RenderedLink render={render} {...linkProps} />\n ) : (\n <a {...anchorProps} {...(external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : null)}>\n {linkBody}\n </a>\n );\n } else if (isButton) {\n main = (\n <button\n {...(targetExtras as HTMLAttributes<HTMLButtonElement>)}\n {...rest}\n ref={ref as Ref<HTMLButtonElement>}\n type=\"button\"\n // A disabled row is `disabled`: nothing to do there, and nothing to reach. A\n // loading row is only `aria-disabled` — see `loading`.\n disabled={disabled}\n aria-disabled={loading || undefined}\n aria-busy={loading || undefined}\n aria-current={selected ? \"true\" : undefined}\n aria-expanded={expandable ? expanded : undefined}\n aria-controls={expandable ? expandedId : undefined}\n onClick={\n loading\n ? undefined\n : expandable\n ? toggle\n : (onClick as (event: MouseEvent<HTMLButtonElement>) => void)\n }\n className={target}\n >\n {body}\n </button>\n );\n } else {\n main = (\n <div\n {...(targetExtras as HTMLAttributes<HTMLDivElement>)}\n {...rest}\n ref={ref as Ref<HTMLDivElement>}\n aria-disabled={disabled || undefined}\n aria-busy={loading || undefined}\n className={target}\n >\n {body}\n </div>\n );\n }\n\n const leadingActionsEl =\n leadingActions != null ? (\n <div className={cn(\"flex shrink-0 items-center\", pad.gap, pad.leadingActions)}>{leadingActions}</div>\n ) : null;\n const actionsEl =\n actions != null ? <div className={cn(\"flex shrink-0 items-center\", pad.gap, pad.actions)}>{actions}</div> : null;\n\n const row = (\n <div\n className={cn(\n \"flex min-w-0 rounded-md border transition-colors\",\n stacked ? \"flex-col\" : \"items-center\",\n // See `renderRow`: the wrapper may be inline, the row is not.\n renderRow && \"w-full\",\n selected\n ? \"border-[var(--brand)] bg-[var(--bg-surface-2)]\"\n : bordered\n ? \"border-[var(--border)]\"\n : \"border-transparent\",\n !stacked && interactive && !inert && !selected && \"hover:bg-[var(--bg-hover)]\",\n disabled && \"opacity-50\",\n className,\n )}\n >\n {stacked ? (\n <>\n <div className=\"flex min-w-0 items-center\">\n {leadingActionsEl}\n {main}\n {actionsEl}\n </div>\n {expandable && (\n <Collapse open={expanded}>\n <div id={expandedId} role=\"group\" aria-labelledby={titleId} className={cn(\"min-w-0\", pad.body)}>\n {expandedContent}\n </div>\n </Collapse>\n )}\n {hasBody && (\n <div role=\"group\" aria-labelledby={titleId} className={cn(\"min-w-0\", pad.body, bodyClassName)}>\n {children}\n </div>\n )}\n </>\n ) : (\n <>\n {leadingActionsEl}\n {main}\n {actionsEl}\n </>\n )}\n </div>\n );\n\n const Tag = as;\n return (\n <Tag\n className={cn(\n list.separator === \"divider\" && \"border-b border-[var(--border)] last:border-b-0\",\n list.separator === \"divider\" && as === \"li\" && \"py-0.5\",\n renderRow && \"flex flex-col\",\n )}\n >\n {renderRow ? renderRow(row) : row}\n </Tag>\n );\n});\n\nfunction RenderedLink({\n render,\n ...props\n}: ListItemLinkProps & { render: (props: ListItemLinkProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":"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","names":["List","ListItem"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/list.tsx"],"sourcesContent":["import { createContext, forwardRef, useContext, useId, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { ChevronDown, ExternalLink } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Spinner } from \"./ui\";\nimport { StatusDot } from \"./status-dot\";\nimport { Collapse } from \"./disclosure\";\nimport { toneTextClass } from \"./signed-amount\";\nimport type { TextTone } from \"./signed-amount\";\nimport { useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer } from \"./text-link\";\n\n/* ── Labels ───────────────────────────────────────────────────────────────── */\n\nexport interface ListLabels {\n /** Read after an unread row's title — the dot alone says nothing to a screen reader. */\n unread: string;\n /** Read after an external row's title: the row leaves the app in a new tab. */\n opensInNewTab: string;\n}\n\nexport const DEFAULT_LIST_LABELS: ListLabels = {\n unread: \"Unread\",\n opensInNewTab: \"opens in a new tab\",\n};\n\n/* ── List ─────────────────────────────────────────────────────────────────── */\n\n/**\n * `compact` 36px-ish rows for a dense panel (an inbox in a menu), `default` the ~44px\n * row every site draws with `py-2.5`, `comfortable` for a list that is the page.\n */\nexport type ListDensity = \"compact\" | \"default\" | \"comfortable\";\n/** `divider`: a rule between rows (kastlan's `divide-y` lists); `gap`: a small space\n * between rows (lenkbank's `space-y-2`, kastlan's `space-y-1`); `none`: flush. */\nexport type ListSeparator = \"divider\" | \"gap\" | \"none\";\n\nconst ListContext = createContext<{ density: ListDensity; separator: ListSeparator }>({\n density: \"default\",\n separator: \"gap\",\n});\n\nexport interface ListProps extends Omit<ComponentPropsWithoutRef<\"ul\">, \"role\"> {\n /** `ol` when the order means something (a ranked list, numbered segments). */\n as?: \"ul\" | \"ol\";\n /** Every row's density, unless a row says otherwise. Default `default`. */\n density?: ListDensity;\n /** Default `gap`. */\n separator?: ListSeparator;\n}\n\n/**\n * The list of {@link ListItem}s.\n *\n * A `<ul>` with an explicit `role=\"list\"`: Safari drops a list's semantics once its\n * bullets are styled away, and \"list, 7 items\" is exactly what a screen-reader user\n * wants to hear before the first row.\n */\nexport const List = forwardRef<HTMLUListElement, ListProps>(function List(\n { as = \"ul\", density = \"default\", separator = \"gap\", className, children, ...rest },\n ref,\n) {\n const Tag = as;\n return (\n <ListContext.Provider value={{ density, separator }}>\n <Tag\n {...rest}\n ref={ref as Ref<HTMLUListElement & HTMLOListElement>}\n role=\"list\"\n className={cn(\n \"m-0 flex list-none flex-col p-0\",\n separator === \"gap\" && (density === \"compact\" ? \"gap-0.5\" : \"gap-1\"),\n className,\n )}\n >\n {children}\n </Tag>\n </ListContext.Provider>\n );\n});\n\n/* ── ListItem ─────────────────────────────────────────────────────────────── */\n\n/** What {@link ListItemProps.renderLink} is handed. Spread it onto your router's link,\n * mapping `href` to what it calls it — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface ListItemLinkProps {\n href: string;\n /** The row's target look — keep it, or the row's padding and focus ring go. */\n className: string;\n children: ReactNode;\n ref?: Ref<HTMLAnchorElement>;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n \"aria-current\"?: \"true\";\n \"aria-busy\"?: true;\n id?: string;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nconst PAD: Record<\n ListDensity,\n { target: string; actions: string; leadingActions: string; gap: string; body: string }\n> = {\n compact: { target: \"gap-2 px-2 py-1.5\", actions: \"pe-1.5\", leadingActions: \"ps-1.5\", gap: \"gap-0.5\", body: \"px-2 pb-1.5\" },\n default: { target: \"gap-3 px-3 py-2.5\", actions: \"pe-2\", leadingActions: \"ps-2\", gap: \"gap-1\", body: \"px-3 pb-2.5\" },\n comfortable: { target: \"gap-3 px-4 py-3\", actions: \"pe-3\", leadingActions: \"ps-3\", gap: \"gap-1\", body: \"px-4 pb-3\" },\n};\n\n/** {@link ListItemBaseProps.trailingTone}: a {@link TextTone}, or `inherit` — no\n * colour of the slot's own, so an element's colour (or the row's) stands. */\nexport type ListItemTrailingTone = TextTone | \"inherit\";\n\nconst TARGET_RING =\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]\";\n\ninterface ListItemBaseProps {\n /** The row's name: one line, truncated — unless {@link titleLines} says otherwise. */\n title: ReactNode;\n /**\n * How many lines the title may take. `1` (default) truncates with an ellipsis, which\n * is right for a name the app chose — a unit number, a user, a file — where the\n * cut-off end is still recognisable and every row keeps one height.\n *\n * `2` clamps at two lines; `\"all\"` shows every line, breaking inside a long word\n * rather than overflowing. For rows whose only name is free text a person typed\n * (kastlan 44): a defect or a note titled by its description, where the first 30\n * characters on a phone say nothing and there is no other field to tell two rows\n * apart. Pair it with `align=\"start\"` so the icon stays by the first line.\n */\n titleLines?: 1 | 2 | \"all\";\n /** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */\n subtitle?: ReactNode;\n /** `2` for a line that is a body rather than a label (keksdose's notification text). */\n subtitleLines?: 1 | 2;\n /**\n * A small line ABOVE the title, in the caption type: the group a row belongs to, a\n * date heading, a category (\"Groceries · weekly\"). Part of the target and of its\n * name, read before the title. One line, truncated.\n */\n overline?: ReactNode;\n /** A third line in the caption type: a timestamp, \"Updated …\" (lenkbank's projects). */\n meta?: ReactNode;\n /**\n * Let {@link meta} wrap: it becomes a wrapping row (`flex-wrap`, a small gap)\n * instead of one truncated line — for a run of chips (keksdose's payee-backfill\n * tags), which otherwise had nowhere to go but the body, outside the target. Still\n * inside the target, so still no controls: chips that only show, not ones that\n * remove themselves.\n */\n metaWrap?: boolean;\n /**\n * Content INSIDE the main target, under the title, subtitle and meta: a progress or\n * budget bar, a sparkline, a strip of figures (keksdose's food-group bars). Unlike\n * {@link children}, which sits outside the target, a click on it is a click on the\n * row, and its hover fill covers it.\n *\n * Because it is inside the button or link it obeys the {@link trailing} rule: shown,\n * never operated — no buttons, links or inputs (invalid HTML in a button, and folded\n * into its name). Its text joins the row's accessible name, so give a bar that only\n * repeats a figure already in the row `aria-hidden`. Rendered in a block `<span>`,\n * full width of the TEXT COLUMN only — the column the title and subtitle are in, not\n * the whole row: it starts after the {@link icon} / {@link leading} and stops where\n * {@link trailing} begins, so a meter under a row with a trailing figure is as wide as\n * the title, not run on under the figure (keksdose's budget rows). For a bar across\n * the full row, put it in {@link children} (outside the target) or leave `trailing`\n * off and show the figure in `meta`. Strictly a button holds phrasing content only; the\n * kit's `ProgressBar` draws `<div>`s, which every browser lays out and reads fine\n * there but an HTML validator flags.\n */\n content?: ReactNode;\n /** A Lucide icon before the text, muted, at 16px. */\n icon?: LucideIcon;\n /** Anything else before the text — an avatar, a file-type badge, a drag handle. */\n leading?: ReactNode;\n /**\n * Inside the main target, at its end: a status chip, a file size, a count. It is part\n * of what the row's click does and part of its accessible name — so never a control.\n * Controls go in {@link actions}.\n */\n trailing?: ReactNode;\n /**\n * The colour of the {@link trailing} slot. Default `muted` — today's look, right for\n * a size or a count in plain text. `inherit` sets none, so an element's own colour\n * (an amount coloured by its sign) stands without a second override; any\n * {@link TextTone} colours a plain-text figure (`\"danger\"` for an overdue count).\n */\n trailingTone?: ListItemTrailingTone;\n /**\n * Controls BESIDE the main target, never inside it: copy, delete, \"Open\".\n *\n * A button inside a button (or a link) is invalid HTML, and a screen reader folds the\n * inner one into the outer one's name where it cannot be reached. lenkbank found this\n * three times (profile-bar.tsx:110, projects-page.tsx:61, segment-list.tsx:165) and\n * solved it the same way each time: the row's target and its actions are siblings in\n * one bordered box. That is this slot — and being siblings is also what stops a click\n * on \"Copy\" from selecting the row.\n */\n actions?: ReactNode;\n /**\n * Controls BEFORE the main target, never inside it: a selection checkbox, a drag\n * handle that is a button. The mirror of {@link actions} at the start edge — a\n * sibling of the target, so checking the box does not activate (or expand) the row\n * and the checkbox keeps its own name. Give the control an `aria-label` naming the\n * row (\"Select Groceries\"), since it is not inside the row's name. Anything that only\n * shows goes in {@link leading}, inside the target.\n */\n leadingActions?: ReactNode;\n /**\n * Rich content UNDER the title row and outside its target: photos, per-row buttons,\n * form fields — kastlan's handover room inspector (room-inspector.tsx), the handover\n * detail page and the wizard's defects step (wizard/defects-step.tsx), which draw\n * each room or defect as a `Card variant=\"inset\"` because a row could not hold a\n * photo strip with its own buttons.\n *\n * The same rule as {@link actions}, one row down: the title area stays ONE named\n * target (the button or link, with the title and subtitle as its name), and the body\n * is its sibling, never nested in it — a thumbnail's \"Remove\" inside the row's button\n * would be invalid HTML and unreachable by a screen reader. The body is a\n * `role=\"group\"` named by the row's title (`aria-labelledby`), so tabbing into the\n * third photo's button of the second room is heard as \"Kitchen, group\" first rather\n * than as a button floating free of any row. Inside the row's box, so a `selected`\n * border and a `divider` hold both; the hover fill stays on the target alone, since\n * the body is not what a click on the row does.\n */\n children?: ReactNode;\n /** Classes on the {@link children} body. */\n bodyClassName?: string;\n /**\n * The unread mark: a brand dot at the row's end, the title in semibold, and \"Unread\"\n * read after the title (keksdose notification-inbox.tsx:215). For another status,\n * pass your own mark in {@link status}.\n */\n unread?: boolean;\n /** A mark in the unread dot's place — a `StatusDot` of another tone, a count. */\n status?: ReactNode;\n /**\n * The row is the chosen one — a brand border on the raised surface (lenkbank's\n * profile bar and segment list). `aria-current=\"true\"` on the target: the row is the\n * current one of a set, which is what those lists mean, not a pressed toggle.\n */\n selected?: boolean;\n disabled?: boolean;\n /**\n * Work under way on the row — keksdose's attachment download (support-attachments\n * .tsx:91). A spinner takes the trailing edge, the target is `aria-busy`, and a\n * second click is ignored. The row keeps focus: `aria-disabled`, not `disabled`, so\n * a keyboard user is not dropped back to the top of the page mid-download.\n */\n loading?: boolean;\n /** Overrides the {@link List}'s density for this row. */\n density?: ListDensity;\n /** `start` for rows whose text runs to two or three lines, so the icon sits by the\n * title rather than the middle of the paragraph. Default `center`. */\n align?: \"center\" | \"start\";\n /** Classes on the visible row (the bordered box). */\n className?: string;\n /** Classes on the main target (the button, link or static block). */\n targetClassName?: string;\n /**\n * Extra attributes and handlers for the main target — e.g. HTML5 drag-and-drop\n * (`draggable`, `onDragStart`, `onDragEnd`): lenkbank's segment list drags the WHOLE\n * 44px row, and a grip inside the button cannot start a drag in Firefox. Spread first,\n * so the row's own role, name, state and click handling always win.\n */\n targetProps?: HTMLAttributes<HTMLElement>;\n /**\n * A visible border while unselected, instead of the transparent one. For rows laid out\n * as a wrapping strip (lenkbank's profile bar), where borderless rows read as loose\n * text. The selected row's brand border is unchanged.\n */\n bordered?: boolean;\n /** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */\n as?: \"li\" | \"div\";\n /**\n * Wraps the visible row (the bordered box: target, actions and body) in an element\n * of your own, INSIDE the `<li>` — a `<Tooltip>` explaining why the row is disabled,\n * an app's save guard: `renderRow={(row) => <Tooltip label=\"Locked\">{row}</Tooltip>}`.\n *\n * The wrapper otherwise had to go round the whole `ListItem`, which put a `<span>`\n * between the `<ul>` and its `<li>` (invalid; the list stops counting items), so\n * callers wrote `<li><Wrapper><ListItem as=\"div\" /></Wrapper></li>` — and lost the\n * divider and its spacing, which sit on the row's own `<li>`. Here the `<li>` stays\n * the list's child and keeps both. The `<li>` becomes a column and the row takes the\n * full width, so an inline wrapper (the tooltip's `inline-flex` span) still stretches\n * the row across the list.\n *\n * `row` is the row BOX (a `<div>`), not the target: a wrapper that forces a prop onto\n * its child with `cloneElement` — a save guard that injects `disabled` while a form is\n * dirty (keksdose's SaveGuard) — sets it on that div, where it does nothing, and the\n * button inside stays live. Pass `disabled` (or `loading`) to the ListItem yourself,\n * from the same state the guard reads, and let the wrapper only explain it:\n * `<ListItem disabled={dirty} renderRow={(row) => <Tooltip label=\"Save first\">{row}</Tooltip>} />`.\n */\n renderRow?: (row: ReactElement) => ReactNode;\n /** Reaches the main target — the element with the row's name and action. */\n id?: string;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\n/**\n * The row is ONE thing to activate, so the types allow one: a button, an in-app link,\n * an external link, or nothing (a static row, like kastlan's passkey and user lists).\n */\nexport type ListItemProps = ListItemBaseProps &\n (\n | ({\n /** Makes the row a button — select, open, mark read. */\n onClick: (event: MouseEvent<HTMLButtonElement>) => void;\n href?: never;\n renderLink?: never;\n external?: never;\n onAuxClick?: never;\n } & NotExpandable)\n | ({\n /**\n * Makes the row a link. A real `<a href>`, so a middle click opens it in a\n * background tab (keksdose feedback #451) — which a button calling `navigate`\n * cannot do.\n */\n href: string;\n /** Your router's link for an in-app `href`. Default: the `<UiKitProvider\n * linkComponent>`, then `<a>`. The API `Chip`, `StatTile` and `MenuItem`\n * share. Ignored for an `external` row. */\n renderLink?: (props: ListItemLinkProps) => ReactElement;\n /**\n * The link leaves the app: a plain `<a target=\"_blank\" rel=\"noopener\n * noreferrer\">` with an external-link mark, and \"opens in a new tab\" read\n * after the title. keksdose's inbox rows whose URL is absolute.\n */\n external?: boolean;\n /** Runs on a click, before the navigation (mark the row read). */\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n /** Runs on a middle click, which fires no `click` — keksdose marks a\n * notification read here too, so a row opened into a background tab does not\n * stay unread. */\n onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n } & NotExpandable)\n | ({\n onClick?: never;\n href?: never;\n renderLink?: never;\n external?: never;\n onAuxClick?: never;\n } & NotExpandable)\n | {\n /**\n * Makes the row a disclosure: the target is a button with `aria-expanded` and a\n * turning chevron at its end, and this opens under the row, outside the target\n * (so it may hold controls), animated by {@link Collapse}. keksdose's collapsible\n * groups — a group row with a select-all checkbox before it\n * ({@link ListItemBaseProps.leadingActions}) and its members inside.\n *\n * The row's ONE action is the toggle, so it takes no `onClick` or `href`. The\n * content is a `role=\"group\"` named by the title, unmounted while shut (as a\n * `Disclosure`'s body is).\n */\n expandedContent: ReactNode;\n /** Controlled open state. Leave it out for the row to keep its own. */\n expanded?: boolean;\n /** The uncontrolled row's first state. Default `false`. */\n defaultExpanded?: boolean;\n /** Runs on every toggle with the new state. */\n onExpandedChange?: (expanded: boolean) => void;\n onClick?: never;\n href?: never;\n renderLink?: never;\n external?: never;\n onAuxClick?: never;\n }\n );\n\ninterface NotExpandable {\n expandedContent?: never;\n expanded?: never;\n defaultExpanded?: never;\n onExpandedChange?: never;\n}\n\n/**\n * One row of a {@link List}: leading icon or avatar, title and subtitle (both\n * truncating by default), a trailing slot, and — beside the row, not inside it — its actions.\n *\n * All three apps draw this row by hand, a dozen times: kastlan's units\n * (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity\n * feed (activity-feed.tsx:53), company users (platform-company-detail-page.tsx:140)\n * and passkeys (passkeys-card.tsx:75); keksdose's notification inbox and attachment\n * downloads; lenkbank's profiles, projects and segments. The copies differ in the\n * details that are easy to get wrong — which of them truncate, whether the actions\n * are nested in the button, whether a link row is a link at all.\n */\nexport const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(\n {\n title,\n titleLines = 1,\n overline,\n subtitle,\n subtitleLines = 1,\n meta,\n metaWrap = false,\n content,\n icon: Icon,\n leading,\n trailing,\n trailingTone = \"muted\",\n actions,\n leadingActions,\n unread = false,\n status,\n selected = false,\n disabled = false,\n loading = false,\n density: densityProp,\n align = \"center\",\n className,\n targetClassName,\n targetProps,\n bordered = false,\n as = \"li\",\n renderRow,\n children,\n bodyClassName,\n href,\n renderLink,\n external = false,\n onClick,\n onAuxClick,\n expandedContent,\n expanded: expandedProp,\n defaultExpanded = false,\n onExpandedChange,\n ...rest\n },\n ref,\n) {\n const list = useContext(ListContext);\n const labels = useKitLabels(\"list\", DEFAULT_LIST_LABELS);\n const kitLink = useKitLink();\n const [ownExpanded, setOwnExpanded] = useState(defaultExpanded);\n const density = densityProp ?? list.density;\n const pad = PAD[density];\n const expandable = expandedContent !== undefined;\n const expanded = expandable && (expandedProp ?? ownExpanded);\n const isLink = href !== undefined && !disabled;\n const isButton = !isLink && (onClick !== undefined || expandable) && href === undefined;\n const interactive = isLink || isButton;\n const inert = disabled || loading;\n const hasBody = children !== undefined && children !== null && children !== false;\n // Anything under the row's own line: the box turns into a column, and the target\n // takes its own corners and hover fill (see `children`).\n const stacked = hasBody || expandable;\n const titleId = useId();\n const expandedId = useId();\n\n const toggle = () => {\n const next = !expanded;\n if (expandedProp === undefined) setOwnExpanded(next);\n onExpandedChange?.(next);\n };\n\n const mark =\n status ??\n (unread ? <StatusDot tone=\"brand\" size=\"sm\" className={align === \"start\" ? \"mt-1.5\" : undefined} /> : null);\n\n const body = (\n <>\n {Icon && (\n <Icon\n className={cn(\"size-4 shrink-0 text-[var(--text-muted)]\", align === \"start\" && \"mt-0.5\")}\n aria-hidden\n />\n )}\n {leading}\n <span className=\"flex min-w-0 flex-1 flex-col\">\n {overline != null && (\n <span className=\"block truncate text-[11px] font-medium leading-snug text-[var(--text-muted)]\">\n {overline}\n </span>\n )}\n <span\n // Names the body's and the expanded content's group; harmless without one.\n id={stacked ? titleId : undefined}\n className={cn(\n \"block text-sm text-[var(--text-primary)]\",\n titleLines === 1 ? \"truncate\" : titleLines === 2 ? \"line-clamp-2 break-words\" : \"break-words\",\n unread ? \"font-semibold\" : \"font-medium\",\n )}\n >\n {title}\n {unread && <span className=\"sr-only\"> ({labels.unread})</span>}\n {external && href !== undefined && <span className=\"sr-only\"> ({labels.opensInNewTab})</span>}\n </span>\n {subtitle != null && (\n <span\n className={cn(\n \"block text-xs text-[var(--text-muted)]\",\n subtitleLines === 2 ? \"line-clamp-2\" : \"truncate\",\n )}\n >\n {subtitle}\n </span>\n )}\n {meta != null && (\n <span\n className={cn(\n \"mt-0.5 text-[11px] leading-snug text-[var(--text-muted)]\",\n metaWrap ? \"flex flex-wrap items-center gap-1\" : \"block truncate\",\n )}\n >\n {meta}\n </span>\n )}\n {content != null && <span className=\"mt-1.5 block min-w-0\">{content}</span>}\n </span>\n {trailing != null && (\n <span\n className={cn(\n \"flex shrink-0 items-center gap-2 text-xs\",\n trailingTone !== \"inherit\" && toneTextClass(trailingTone),\n )}\n >\n {trailing}\n </span>\n )}\n {loading ? (\n <Spinner label={null} className=\"size-3.5 shrink-0\" />\n ) : (\n external && href !== undefined && (\n <ExternalLink className=\"size-3.5 shrink-0 text-[var(--text-muted)] rtl:-scale-x-100\" aria-hidden />\n )\n )}\n {mark}\n {expandable && (\n // Turned, not swapped — the Disclosure card's convention: down while shut, up\n // while open. A rotation is its own mirror, so RTL needs nothing.\n <ChevronDown\n aria-hidden\n className={cn(\n \"size-4 shrink-0 text-[var(--text-muted)] transition-transform duration-200 ease-out motion-reduce:transition-none\",\n align === \"start\" && \"mt-0.5\",\n expanded && \"rotate-180\",\n )}\n />\n )}\n </>\n );\n\n const target = cn(\n \"flex min-w-0 flex-1 text-start\",\n align === \"start\" ? \"items-start\" : \"items-center\",\n pad.target,\n // When stacked the target is the top of the box, not all of it, so it takes its\n // own corners and its own hover fill (see `children`).\n stacked ? \"rounded-md\" : \"rounded-[inherit]\",\n stacked && interactive && !inert && !selected && \"transition-colors hover:bg-[var(--bg-hover)]\",\n interactive && TARGET_RING,\n interactive && !inert && \"cursor-pointer\",\n loading && \"cursor-progress\",\n targetClassName,\n );\n\n // `role` is the row's own (a button, a link, or nothing on a static block): a\n // targetProps role would silently turn the row into something else.\n const { role: _ignoredRole, ...targetExtras } = targetProps ?? {};\n\n let main: ReactNode;\n if (isLink) {\n const linkProps: ListItemLinkProps = {\n ...(targetExtras as Partial<ListItemLinkProps>),\n ...rest,\n ref: ref as Ref<HTMLAnchorElement>,\n href,\n className: target,\n \"aria-current\": selected ? \"true\" : undefined,\n \"aria-busy\": loading || undefined,\n onClick: loading\n ? (e) => e.preventDefault()\n : (onClick as ListItemLinkProps[\"onClick\"]),\n onAuxClick,\n children: body,\n };\n const { children: linkBody, ...anchorProps } = linkProps;\n const render = external ? undefined : pickLinkRenderer(renderLink, kitLink, href);\n main = render ? (\n <RenderedLink render={render} {...linkProps} />\n ) : (\n <a {...anchorProps} {...(external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : null)}>\n {linkBody}\n </a>\n );\n } else if (isButton) {\n main = (\n <button\n {...(targetExtras as HTMLAttributes<HTMLButtonElement>)}\n {...rest}\n ref={ref as Ref<HTMLButtonElement>}\n type=\"button\"\n // A disabled row is `disabled`: nothing to do there, and nothing to reach. A\n // loading row is only `aria-disabled` — see `loading`.\n disabled={disabled}\n aria-disabled={loading || undefined}\n aria-busy={loading || undefined}\n aria-current={selected ? \"true\" : undefined}\n aria-expanded={expandable ? expanded : undefined}\n aria-controls={expandable ? expandedId : undefined}\n onClick={\n loading\n ? undefined\n : expandable\n ? toggle\n : (onClick as (event: MouseEvent<HTMLButtonElement>) => void)\n }\n className={target}\n >\n {body}\n </button>\n );\n } else {\n main = (\n <div\n {...(targetExtras as HTMLAttributes<HTMLDivElement>)}\n {...rest}\n ref={ref as Ref<HTMLDivElement>}\n aria-disabled={disabled || undefined}\n aria-busy={loading || undefined}\n className={target}\n >\n {body}\n </div>\n );\n }\n\n const leadingActionsEl =\n leadingActions != null ? (\n <div className={cn(\"flex shrink-0 items-center\", pad.gap, pad.leadingActions)}>{leadingActions}</div>\n ) : null;\n const actionsEl =\n actions != null ? <div className={cn(\"flex shrink-0 items-center\", pad.gap, pad.actions)}>{actions}</div> : null;\n\n const row = (\n <div\n className={cn(\n \"flex min-w-0 rounded-md border transition-colors\",\n stacked ? \"flex-col\" : \"items-center\",\n // See `renderRow`: the wrapper may be inline, the row is not.\n renderRow && \"w-full\",\n selected\n ? \"border-[var(--brand)] bg-[var(--bg-surface-2)]\"\n : bordered\n ? \"border-[var(--border)]\"\n : \"border-transparent\",\n !stacked && interactive && !inert && !selected && \"hover:bg-[var(--bg-hover)]\",\n disabled && \"opacity-50\",\n className,\n )}\n >\n {stacked ? (\n <>\n <div className=\"flex min-w-0 items-center\">\n {leadingActionsEl}\n {main}\n {actionsEl}\n </div>\n {expandable && (\n <Collapse open={expanded}>\n <div id={expandedId} role=\"group\" aria-labelledby={titleId} className={cn(\"min-w-0\", pad.body)}>\n {expandedContent}\n </div>\n </Collapse>\n )}\n {hasBody && (\n <div role=\"group\" aria-labelledby={titleId} className={cn(\"min-w-0\", pad.body, bodyClassName)}>\n {children}\n </div>\n )}\n </>\n ) : (\n <>\n {leadingActionsEl}\n {main}\n {actionsEl}\n </>\n )}\n </div>\n );\n\n const Tag = as;\n return (\n <Tag\n className={cn(\n list.separator === \"divider\" && \"border-b border-[var(--border)] last:border-b-0\",\n list.separator === \"divider\" && as === \"li\" && \"py-0.5\",\n renderRow && \"flex flex-col\",\n )}\n >\n {renderRow ? renderRow(row) : row}\n </Tag>\n );\n});\n\nfunction RenderedLink({\n render,\n ...props\n}: ListItemLinkProps & { render: (props: ListItemLinkProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":"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","names":["List","ListItem"]}
|
|
@@ -2,7 +2,7 @@ import * as react from 'react';
|
|
|
2
2
|
import { ComponentProps, Ref } from 'react';
|
|
3
3
|
import { AmountInput } from './amount-input.js';
|
|
4
4
|
import './calculator.js';
|
|
5
|
-
import '../kit-labels-
|
|
5
|
+
import '../kit-labels-CPbsjlXr.js';
|
|
6
6
|
import './data-table-labels.js';
|
|
7
7
|
import './mini-calendar.js';
|
|
8
8
|
import './calendar-heatmap.js';
|
|
@@ -53,6 +53,15 @@ import './authed-image.js';
|
|
|
53
53
|
import '../hooks/use-authed-src.js';
|
|
54
54
|
import './image-grid.js';
|
|
55
55
|
import './lightbox.js';
|
|
56
|
+
import './write-lock.js';
|
|
57
|
+
import './account-chips.js';
|
|
58
|
+
import '../lib/format.js';
|
|
59
|
+
import '../data-table-3mi2R3so.js';
|
|
60
|
+
import './data-table-sort.js';
|
|
61
|
+
import './swipeable-row.js';
|
|
62
|
+
import './reauth-dialog.js';
|
|
63
|
+
import './server-wake.js';
|
|
64
|
+
import '../lib/server-wake.js';
|
|
56
65
|
import './numpad-sheet.js';
|
|
57
66
|
|
|
58
67
|
type AmountInputProps = ComponentProps<typeof AmountInput>;
|
|
@@ -2,7 +2,7 @@ import * as react from 'react';
|
|
|
2
2
|
import { ComponentProps, ReactNode } from 'react';
|
|
3
3
|
import { NumberInput } from './number-input.js';
|
|
4
4
|
import './calculator.js';
|
|
5
|
-
import '../kit-labels-
|
|
5
|
+
import '../kit-labels-CPbsjlXr.js';
|
|
6
6
|
import './data-table-labels.js';
|
|
7
7
|
import './mini-calendar.js';
|
|
8
8
|
import './calendar-heatmap.js';
|
|
@@ -53,6 +53,15 @@ import './authed-image.js';
|
|
|
53
53
|
import '../hooks/use-authed-src.js';
|
|
54
54
|
import './image-grid.js';
|
|
55
55
|
import './lightbox.js';
|
|
56
|
+
import './write-lock.js';
|
|
57
|
+
import './account-chips.js';
|
|
58
|
+
import '../lib/format.js';
|
|
59
|
+
import '../data-table-3mi2R3so.js';
|
|
60
|
+
import './data-table-sort.js';
|
|
61
|
+
import './swipeable-row.js';
|
|
62
|
+
import './reauth-dialog.js';
|
|
63
|
+
import './server-wake.js';
|
|
64
|
+
import '../lib/server-wake.js';
|
|
56
65
|
import './numpad-sheet.js';
|
|
57
66
|
|
|
58
67
|
type NumberInputProps = ComponentProps<typeof NumberInput>;
|
|
@@ -161,7 +170,7 @@ interface NumberFieldProps extends Omit<NumberInputProps, "value" | "onChange" |
|
|
|
161
170
|
* digit grouping — "1.000" would read as a thousand in German and as one in English,
|
|
162
171
|
* and a field that parses its own display back must not have that ambiguity.
|
|
163
172
|
*/
|
|
164
|
-
declare function NumberField(
|
|
173
|
+
declare function NumberField(props: NumberFieldProps): react.JSX.Element;
|
|
165
174
|
/**
|
|
166
175
|
* A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as
|
|
167
176
|
* defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a
|
|
@@ -29,26 +29,27 @@ function roundTo(n, digits) {
|
|
|
29
29
|
return Number.isFinite(rounded) ? rounded : n;
|
|
30
30
|
}
|
|
31
31
|
const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
|
|
32
|
-
function NumberField({
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
32
|
+
function NumberField(props) {
|
|
33
|
+
const {
|
|
34
|
+
value,
|
|
35
|
+
onCommit,
|
|
36
|
+
onValueChange,
|
|
37
|
+
digits,
|
|
38
|
+
min,
|
|
39
|
+
max,
|
|
40
|
+
nullable = false,
|
|
41
|
+
unit,
|
|
42
|
+
unitPlacement = "suffix",
|
|
43
|
+
locale: localeProp,
|
|
44
|
+
label,
|
|
45
|
+
invalid,
|
|
46
|
+
error,
|
|
47
|
+
id,
|
|
48
|
+
"aria-describedby": ariaDescribedBy,
|
|
49
|
+
"aria-invalid": ariaInvalid,
|
|
50
|
+
"aria-required": ariaRequired,
|
|
51
|
+
...rest
|
|
52
|
+
} = props;
|
|
52
53
|
const locale = useKitLocale(localeProp);
|
|
53
54
|
const mark = decimalMark(locale);
|
|
54
55
|
const localize = (text) => mark === "," ? text.replace(/\./g, ",") : text;
|
|
@@ -129,10 +130,10 @@ function NumberField({
|
|
|
129
130
|
invalid: invalid || hasError || ariaInvalid === true || ariaInvalid === "true"
|
|
130
131
|
}
|
|
131
132
|
);
|
|
132
|
-
if (!hasError) return field;
|
|
133
|
+
if (!hasError && !("error" in props)) return field;
|
|
133
134
|
return /* @__PURE__ */ jsxs("div", { children: [
|
|
134
135
|
field,
|
|
135
|
-
/* @__PURE__ */ jsx("p", { id: errorId, className: FIELD_ERROR_CLASS, children: error })
|
|
136
|
+
hasError && /* @__PURE__ */ jsx("p", { id: errorId, className: FIELD_ERROR_CLASS, children: error })
|
|
136
137
|
] });
|
|
137
138
|
}
|
|
138
139
|
function IntegerField(props) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/number-field.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { NumberInput } from \"./number-input\";\nimport { evaluateExpression, formatResult } from \"../lib/calc\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\ntype NumberInputProps = ComponentProps<typeof NumberInput>;\n\nexport interface NumberFieldProps\n extends Omit<NumberInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"suffix\" | \"invalid\"> {\n /** The committed number. `null` is \"no value\" — shown as an empty field. */\n value: number | null;\n /**\n * The parsed number, on blur or Enter — never per keystroke — and only when it\n * differs from the last one committed. Already rounded to `digits` and clamped\n * into `[min, max]`. `null` only when `nullable` and the field was emptied.\n */\n onCommit: (value: number | null) => void;\n /**\n * LIVE MODE: the number the text reads as, on every keystroke (and every step\n * key) — for a calculator that recomputes as the user types, while `onCommit`\n * stays the one to save on.\n *\n * What it is NOT, until the commit: rounded to `digits` or clamped into\n * `[min, max]` — a half-typed \"1\" on the way to \"150\" must not read as the\n * minimum of 100. A calculation reads as its result (\"12+5\" → 17); an empty field\n * as `null` (whether or not `nullable`); a draft that is no number yet (\"-\", \"12+\")\n * fires nothing, so the last number stands. On blur/Enter it fires once more with\n * the committed value if that differs from the last one it reported — the rounded,\n * clamped or snapped-back number — so a live consumer always ends where `onCommit`\n * does.\n *\n * Feeding it back into `value` is safe: a `value` equal to the number this field\n * last reported does not re-render the text, so \"1,\" stays \"1,\" while the rest of\n * the form already shows 1. Not called when `value` changes from outside.\n */\n onValueChange?: (value: number | null) => void;\n /** Round to this many decimals on commit, and show at most this many (trailing\n * zeros are trimmed: `digits={2}` shows 12.5, not 12.50). Omit for no rounding. */\n digits?: number;\n /** Clamp the committed number into `[min, max]`. Applied after rounding. */\n min?: number;\n max?: number;\n /** An emptied field commits `null`. Without it an empty field is not an answer\n * and snaps back to the last value, like any other unparsable text. */\n nullable?: boolean;\n /** The unit — \"mm\", \"%\", \"km/h\". */\n unit?: ReactNode;\n /** Where `unit` goes: `\"suffix\"` (default) is NumberInput's read-out at the\n * field's end edge; `\"label\"` appends it to the label as \"Label (unit)\", for rows\n * of narrow fields where a suffix would eat the digits. */\n unitPlacement?: \"suffix\" | \"label\";\n /** BCP 47 tag for the decimal mark, else the `<UiKitProvider locale>`, else the\n * runtime's. */\n locale?: string;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`: shown under the field, attached via\n * `aria-describedby`, implies `invalid`. */\n error?: ReactNode;\n /** Merged with the error's id. Accepted so a form library's control slot (which\n * injects `id` / `aria-describedby` / `aria-invalid`) can wrap this directly. */\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n /**\n * Set on the `<input>`. Declared, with `id` (from NumberInput) and the two above, so\n * `Field`'s render-prop spread — `{(ids) => <NumberField {...ids} … />}`, kastlan's\n * wizard steps — is typed and reaches the input rather than being dropped: before\n * this, `aria-required` was not in the type and a required number was announced as\n * optional.\n */\n \"aria-required\"?: boolean | \"true\" | \"false\";\n}\n\n/** The locale's decimal mark, as far as this field can type it: \",\" or \".\". A mark\n * outside those two (Arabic's \"٫\") is not something {@link sanitizeLive} lets\n * through, so those locales keep the dot rather than get a field that eats keys. */\nfunction decimalMark(locale: string | undefined): \",\" | \".\" {\n try {\n const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\");\n return part?.value === \",\" ? \",\" : \".\";\n } catch {\n // An invalid tag throws a RangeError; a typo in a locale prop must not take the\n // form down with it.\n return \".\";\n }\n}\n\n/** What the draft reads as while it is being typed — see `onValueChange`. `undefined`\n * is \"not a number yet\", distinct from `null` (empty). */\nfunction parseLive(text: string): number | null | undefined {\n const t = text.trim().replace(/,/g, \".\");\n if (t === \"\") return null;\n if (/^-?\\d*\\.?\\d*$/.test(t)) {\n // \"-\", \".\" and \"-.\" pass the pattern and are no number yet.\n if (!/\\d/.test(t)) return undefined;\n const n = Number(t);\n return Number.isFinite(n) ? n : undefined;\n }\n return evaluateExpression(t) ?? undefined;\n}\n\n/** `toFixed` rounding rather than `Math.round(n * 10^d)`, which turns 1.005 into\n * 1.00 by way of 100.49999999999999 — the same choice the lenkbank field made. */\nfunction roundTo(n: number, digits: number | undefined): number {\n if (digits === undefined) return n;\n const d = Math.min(Math.max(Math.trunc(digits), 0), 100);\n const rounded = Number(n.toFixed(d));\n return Number.isFinite(rounded) ? rounded : n;\n}\n\n// The error line under a field — the same type as `ui.tsx`'s (module-private) one,\n// so a NumberField's message is indistinguishable from an Input's.\nconst FIELD_ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\n/**\n * A number field whose value IS a number: {@link NumberInput}'s text, calculator and\n * numpad, with the parse/commit/revert loop every consumer was writing on top of it.\n *\n * WHY THE DRAFT. Half-typed values — \"-\", \"1,\", \"12+\" — are not numbers, and\n * reformatting them mid-keystroke moves the caret and fights the typist; committing\n * per keystroke would also fire whatever a commit fires (a recalculation, a save) for\n * every meaningless intermediate. So the field keeps a draft STRING while it is being\n * typed and resolves it on blur/Enter:\n *\n * - a number (or a calculation, \"2700+50\") → rounded to `digits`, clamped into\n * `[min, max]`, committed, and the draft re-rendered from the result;\n * - empty → `null` when `nullable`, otherwise the same as the next case — `Number(\"\")`\n * is 0, and clearing a wheelbase and tabbing away must not set it to zero;\n * - anything else unparsable → the draft SNAPS BACK to the last committed value\n * rather than sending NaN on for a server to reject.\n *\n * The draft follows `value` when it changes from outside (a reset, a loaded record);\n * a change that came from here arrives back identical and is a no-op.\n *\n * LIVE MODE. A calculator that recomputes per keystroke used to have to pick: this\n * field (limits, rounding, but a number only on blur) or a bare NumberInput (every\n * keystroke, but a string and no limits). `onValueChange` is the per-keystroke\n * channel here, next to the commit rather than instead of it: live numbers for the\n * read-out, `onCommit` for the save. It lives on this field rather than as\n * `min`/`max`/`digits` on NumberInput because parsing, the locale's mark and the\n * limits are what this field already is — a second copy on the string field would be\n * a second, subtly different NumberField.\n *\n * STEPS. `step` (with `min`/`max` as bounds) turns on ArrowUp/ArrowDown and PageUp/\n * PageDown (×10) — see NumberInput's `step`. A step is a draft change like a\n * keystroke: `onValueChange` hears it at once, `onCommit` on blur/Enter. Pick a\n * `step` that `digits` can show (0.25 needs `digits={2}`), or the commit rounds it.\n *\n * LOCALE. The decimal mark is the locale's: a German user sees and types \"1,5\".\n * NumberInput normalises every keystroke to a dot (so its evaluator reads one\n * alphabet); this field maps the dot back before the draft is shown, which also\n * means the numpad's \".\" key types the mark the field is displaying. There is no\n * digit grouping — \"1.000\" would read as a thousand in German and as one in English,\n * and a field that parses its own display back must not have that ambiguity.\n */\nexport function NumberField({\n value,\n onCommit,\n onValueChange,\n digits,\n min,\n max,\n nullable = false,\n unit,\n unitPlacement = \"suffix\",\n locale: localeProp,\n label,\n invalid,\n error,\n id,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n ...rest\n}: NumberFieldProps) {\n const locale = useKitLocale(localeProp);\n const mark = decimalMark(locale);\n const localize = (text: string) => (mark === \",\" ? text.replace(/\\./g, \",\") : text);\n // `formatResult` for fixed notation: `String(1e-7)` is \"1e-7\", which the field\n // would read back as 17 (see calc.ts).\n const format = (n: number | null) =>\n n === null || !Number.isFinite(n) ? \"\" : localize(formatResult(roundTo(n, digits)));\n\n const [draft, setDraft] = useState(() => format(value));\n // Everything the displayed text depends on, not just `value`: switching the\n // provider's language or a field's `digits` has to re-render the draft too.\n const [seen, setSeen] = useState({ value, mark, digits });\n // The last number handed to `onCommit`, so Enter-then-blur (both commit) and a\n // parent that does not echo the value back cannot commit the same number twice.\n // State rather than a ref because the external-change branch below resets it\n // during render, and a ref may not be written there.\n const [committed, setCommitted] = useState(value);\n // The last number handed to `onValueChange` — state for the same reason.\n const [reported, setReported] = useState(value);\n\n // Adjust state during render rather than in an effect: React re-renders at once\n // with the new draft, before anything is painted, so there is no frame showing\n // the old text.\n if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {\n setSeen({ value, mark, digits });\n // Live mode echoing back what it was just told: the draft already says it, in\n // the typist's own spelling (\"1,\" or \"12+5\"), and reformatting would fight them.\n // Only a changed `value` qualifies — a new mark or `digits` still re-renders.\n const echo =\n onValueChange !== undefined && value === reported && seen.mark === mark && seen.digits === digits;\n if (!echo) {\n setDraft(format(value));\n setCommitted(value);\n setReported(value);\n }\n }\n\n const report = (next: number | null) => {\n if (next === reported) return;\n setReported(next);\n onValueChange?.(next);\n };\n\n const onText = (text: string) => {\n setDraft(localize(text));\n const live = parseLive(text);\n if (live !== undefined) report(live);\n };\n\n const commit = (text: string) => {\n const trimmed = text.trim();\n let next: number | null;\n const snapBack = () => {\n setDraft(format(committed));\n report(committed);\n };\n if (trimmed === \"\") {\n if (!nullable) return snapBack();\n next = null;\n } else {\n // `text` is NumberInput's committed form: an evaluated calculation, dot-decimal.\n // What survives its digits-only fallback can still be \"-\" or \".\" alone.\n const parsed = Number(trimmed.replace(/,/g, \".\"));\n if (!Number.isFinite(parsed)) return snapBack();\n next = roundTo(parsed, digits);\n if (min !== undefined) next = Math.max(min, next);\n if (max !== undefined) next = Math.min(max, next);\n }\n // Always re-render the draft from the result — \"007\" becomes \"7\", a clamped 500\n // shows the 100 it was clamped to — even when there is nothing new to commit.\n setDraft(format(next));\n report(next);\n if (next !== committed) {\n setCommitted(next);\n onCommit(next);\n }\n };\n\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const errorId = useId();\n // `null`/`false`/`\"\"` are what `touched && errors.x` evaluates to on the happy path.\n const hasError = error !== undefined && error !== null && error !== false && error !== \"\";\n const describedBy = [ariaDescribedBy, hasError ? errorId : undefined].filter(Boolean).join(\" \");\n\n // Straight onto NumberInput's <input>, which declares both now. They used to be set\n // on the DOM node from an effect, because NumberInput had no props for them.\n const required = ariaRequired === true || ariaRequired === \"true\";\n\n const labelWithUnit =\n unit !== undefined && unitPlacement === \"label\"\n ? label === undefined || label === \"\"\n ? unit\n : typeof label === \"string\" && typeof unit === \"string\"\n ? `${label} (${unit})`\n : (\n <>\n {label} ({unit})\n </>\n )\n : label;\n\n const field = (\n <NumberInput\n {...rest}\n id={fieldId}\n aria-describedby={describedBy || undefined}\n aria-required={required || undefined}\n label={labelWithUnit}\n value={draft}\n onChange={onText}\n onCommit={commit}\n min={min}\n max={max}\n suffix={unitPlacement === \"suffix\" ? unit : undefined}\n invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === \"true\"}\n />\n );\n // Same shape as Input's FieldGroup: no wrapper at all without a message, so adding\n // `error` support changed nothing about the DOM a flex row lays out.\n if (!hasError) return field;\n return (\n <div>\n {field}\n <p id={errorId} className={FIELD_ERROR_CLASS}>\n {error}\n </p>\n </div>\n );\n}\n\n/**\n * A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as\n * defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a\n * form-bound field, for the plain one (kastlan 51: a building's construction year in a\n * wizard's local state, an entity id in the document upload spelled the two props out\n * by hand).\n *\n * `??` rather than a spread over the defaults, so a wrapper forwarding its own optional\n * `digits={props.digits}` still gets the preset.\n */\nexport function IntegerField(props: NumberFieldProps) {\n return <NumberField {...props} digits={props.digits ?? 0} calculator={props.calculator ?? false} />;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/number-field.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { NumberInput } from \"./number-input\";\nimport { evaluateExpression, formatResult } from \"../lib/calc\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\ntype NumberInputProps = ComponentProps<typeof NumberInput>;\n\nexport interface NumberFieldProps\n extends Omit<NumberInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"suffix\" | \"invalid\"> {\n /** The committed number. `null` is \"no value\" — shown as an empty field. */\n value: number | null;\n /**\n * The parsed number, on blur or Enter — never per keystroke — and only when it\n * differs from the last one committed. Already rounded to `digits` and clamped\n * into `[min, max]`. `null` only when `nullable` and the field was emptied.\n */\n onCommit: (value: number | null) => void;\n /**\n * LIVE MODE: the number the text reads as, on every keystroke (and every step\n * key) — for a calculator that recomputes as the user types, while `onCommit`\n * stays the one to save on.\n *\n * What it is NOT, until the commit: rounded to `digits` or clamped into\n * `[min, max]` — a half-typed \"1\" on the way to \"150\" must not read as the\n * minimum of 100. A calculation reads as its result (\"12+5\" → 17); an empty field\n * as `null` (whether or not `nullable`); a draft that is no number yet (\"-\", \"12+\")\n * fires nothing, so the last number stands. On blur/Enter it fires once more with\n * the committed value if that differs from the last one it reported — the rounded,\n * clamped or snapped-back number — so a live consumer always ends where `onCommit`\n * does.\n *\n * Feeding it back into `value` is safe: a `value` equal to the number this field\n * last reported does not re-render the text, so \"1,\" stays \"1,\" while the rest of\n * the form already shows 1. Not called when `value` changes from outside.\n */\n onValueChange?: (value: number | null) => void;\n /** Round to this many decimals on commit, and show at most this many (trailing\n * zeros are trimmed: `digits={2}` shows 12.5, not 12.50). Omit for no rounding. */\n digits?: number;\n /** Clamp the committed number into `[min, max]`. Applied after rounding. */\n min?: number;\n max?: number;\n /** An emptied field commits `null`. Without it an empty field is not an answer\n * and snaps back to the last value, like any other unparsable text. */\n nullable?: boolean;\n /** The unit — \"mm\", \"%\", \"km/h\". */\n unit?: ReactNode;\n /** Where `unit` goes: `\"suffix\"` (default) is NumberInput's read-out at the\n * field's end edge; `\"label\"` appends it to the label as \"Label (unit)\", for rows\n * of narrow fields where a suffix would eat the digits. */\n unitPlacement?: \"suffix\" | \"label\";\n /** BCP 47 tag for the decimal mark, else the `<UiKitProvider locale>`, else the\n * runtime's. */\n locale?: string;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`: shown under the field, attached via\n * `aria-describedby`, implies `invalid`. */\n error?: ReactNode;\n /** Merged with the error's id. Accepted so a form library's control slot (which\n * injects `id` / `aria-describedby` / `aria-invalid`) can wrap this directly. */\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n /**\n * Set on the `<input>`. Declared, with `id` (from NumberInput) and the two above, so\n * `Field`'s render-prop spread — `{(ids) => <NumberField {...ids} … />}`, kastlan's\n * wizard steps — is typed and reaches the input rather than being dropped: before\n * this, `aria-required` was not in the type and a required number was announced as\n * optional.\n */\n \"aria-required\"?: boolean | \"true\" | \"false\";\n}\n\n/** The locale's decimal mark, as far as this field can type it: \",\" or \".\". A mark\n * outside those two (Arabic's \"٫\") is not something {@link sanitizeLive} lets\n * through, so those locales keep the dot rather than get a field that eats keys. */\nfunction decimalMark(locale: string | undefined): \",\" | \".\" {\n try {\n const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\");\n return part?.value === \",\" ? \",\" : \".\";\n } catch {\n // An invalid tag throws a RangeError; a typo in a locale prop must not take the\n // form down with it.\n return \".\";\n }\n}\n\n/** What the draft reads as while it is being typed — see `onValueChange`. `undefined`\n * is \"not a number yet\", distinct from `null` (empty). */\nfunction parseLive(text: string): number | null | undefined {\n const t = text.trim().replace(/,/g, \".\");\n if (t === \"\") return null;\n if (/^-?\\d*\\.?\\d*$/.test(t)) {\n // \"-\", \".\" and \"-.\" pass the pattern and are no number yet.\n if (!/\\d/.test(t)) return undefined;\n const n = Number(t);\n return Number.isFinite(n) ? n : undefined;\n }\n return evaluateExpression(t) ?? undefined;\n}\n\n/** `toFixed` rounding rather than `Math.round(n * 10^d)`, which turns 1.005 into\n * 1.00 by way of 100.49999999999999 — the same choice the lenkbank field made. */\nfunction roundTo(n: number, digits: number | undefined): number {\n if (digits === undefined) return n;\n const d = Math.min(Math.max(Math.trunc(digits), 0), 100);\n const rounded = Number(n.toFixed(d));\n return Number.isFinite(rounded) ? rounded : n;\n}\n\n// The error line under a field — the same type as `ui.tsx`'s (module-private) one,\n// so a NumberField's message is indistinguishable from an Input's.\nconst FIELD_ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\n/**\n * A number field whose value IS a number: {@link NumberInput}'s text, calculator and\n * numpad, with the parse/commit/revert loop every consumer was writing on top of it.\n *\n * WHY THE DRAFT. Half-typed values — \"-\", \"1,\", \"12+\" — are not numbers, and\n * reformatting them mid-keystroke moves the caret and fights the typist; committing\n * per keystroke would also fire whatever a commit fires (a recalculation, a save) for\n * every meaningless intermediate. So the field keeps a draft STRING while it is being\n * typed and resolves it on blur/Enter:\n *\n * - a number (or a calculation, \"2700+50\") → rounded to `digits`, clamped into\n * `[min, max]`, committed, and the draft re-rendered from the result;\n * - empty → `null` when `nullable`, otherwise the same as the next case — `Number(\"\")`\n * is 0, and clearing a wheelbase and tabbing away must not set it to zero;\n * - anything else unparsable → the draft SNAPS BACK to the last committed value\n * rather than sending NaN on for a server to reject.\n *\n * The draft follows `value` when it changes from outside (a reset, a loaded record);\n * a change that came from here arrives back identical and is a no-op.\n *\n * LIVE MODE. A calculator that recomputes per keystroke used to have to pick: this\n * field (limits, rounding, but a number only on blur) or a bare NumberInput (every\n * keystroke, but a string and no limits). `onValueChange` is the per-keystroke\n * channel here, next to the commit rather than instead of it: live numbers for the\n * read-out, `onCommit` for the save. It lives on this field rather than as\n * `min`/`max`/`digits` on NumberInput because parsing, the locale's mark and the\n * limits are what this field already is — a second copy on the string field would be\n * a second, subtly different NumberField.\n *\n * STEPS. `step` (with `min`/`max` as bounds) turns on ArrowUp/ArrowDown and PageUp/\n * PageDown (×10) — see NumberInput's `step`. A step is a draft change like a\n * keystroke: `onValueChange` hears it at once, `onCommit` on blur/Enter. Pick a\n * `step` that `digits` can show (0.25 needs `digits={2}`), or the commit rounds it.\n *\n * LOCALE. The decimal mark is the locale's: a German user sees and types \"1,5\".\n * NumberInput normalises every keystroke to a dot (so its evaluator reads one\n * alphabet); this field maps the dot back before the draft is shown, which also\n * means the numpad's \".\" key types the mark the field is displaying. There is no\n * digit grouping — \"1.000\" would read as a thousand in German and as one in English,\n * and a field that parses its own display back must not have that ambiguity.\n */\nexport function NumberField(props: NumberFieldProps) {\n const {\n value,\n onCommit,\n onValueChange,\n digits,\n min,\n max,\n nullable = false,\n unit,\n unitPlacement = \"suffix\",\n locale: localeProp,\n label,\n invalid,\n error,\n id,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n ...rest\n } = props;\n const locale = useKitLocale(localeProp);\n const mark = decimalMark(locale);\n const localize = (text: string) => (mark === \",\" ? text.replace(/\\./g, \",\") : text);\n // `formatResult` for fixed notation: `String(1e-7)` is \"1e-7\", which the field\n // would read back as 17 (see calc.ts).\n const format = (n: number | null) =>\n n === null || !Number.isFinite(n) ? \"\" : localize(formatResult(roundTo(n, digits)));\n\n const [draft, setDraft] = useState(() => format(value));\n // Everything the displayed text depends on, not just `value`: switching the\n // provider's language or a field's `digits` has to re-render the draft too.\n const [seen, setSeen] = useState({ value, mark, digits });\n // The last number handed to `onCommit`, so Enter-then-blur (both commit) and a\n // parent that does not echo the value back cannot commit the same number twice.\n // State rather than a ref because the external-change branch below resets it\n // during render, and a ref may not be written there.\n const [committed, setCommitted] = useState(value);\n // The last number handed to `onValueChange` — state for the same reason.\n const [reported, setReported] = useState(value);\n\n // Adjust state during render rather than in an effect: React re-renders at once\n // with the new draft, before anything is painted, so there is no frame showing\n // the old text.\n if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {\n setSeen({ value, mark, digits });\n // Live mode echoing back what it was just told: the draft already says it, in\n // the typist's own spelling (\"1,\" or \"12+5\"), and reformatting would fight them.\n // Only a changed `value` qualifies — a new mark or `digits` still re-renders.\n const echo =\n onValueChange !== undefined && value === reported && seen.mark === mark && seen.digits === digits;\n if (!echo) {\n setDraft(format(value));\n setCommitted(value);\n setReported(value);\n }\n }\n\n const report = (next: number | null) => {\n if (next === reported) return;\n setReported(next);\n onValueChange?.(next);\n };\n\n const onText = (text: string) => {\n setDraft(localize(text));\n const live = parseLive(text);\n if (live !== undefined) report(live);\n };\n\n const commit = (text: string) => {\n const trimmed = text.trim();\n let next: number | null;\n const snapBack = () => {\n setDraft(format(committed));\n report(committed);\n };\n if (trimmed === \"\") {\n if (!nullable) return snapBack();\n next = null;\n } else {\n // `text` is NumberInput's committed form: an evaluated calculation, dot-decimal.\n // What survives its digits-only fallback can still be \"-\" or \".\" alone.\n const parsed = Number(trimmed.replace(/,/g, \".\"));\n if (!Number.isFinite(parsed)) return snapBack();\n next = roundTo(parsed, digits);\n if (min !== undefined) next = Math.max(min, next);\n if (max !== undefined) next = Math.min(max, next);\n }\n // Always re-render the draft from the result — \"007\" becomes \"7\", a clamped 500\n // shows the 100 it was clamped to — even when there is nothing new to commit.\n setDraft(format(next));\n report(next);\n if (next !== committed) {\n setCommitted(next);\n onCommit(next);\n }\n };\n\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const errorId = useId();\n // `null`/`false`/`\"\"` are what `touched && errors.x` evaluates to on the happy path.\n const hasError = error !== undefined && error !== null && error !== false && error !== \"\";\n const describedBy = [ariaDescribedBy, hasError ? errorId : undefined].filter(Boolean).join(\" \");\n\n // Straight onto NumberInput's <input>, which declares both now. They used to be set\n // on the DOM node from an effect, because NumberInput had no props for them.\n const required = ariaRequired === true || ariaRequired === \"true\";\n\n const labelWithUnit =\n unit !== undefined && unitPlacement === \"label\"\n ? label === undefined || label === \"\"\n ? unit\n : typeof label === \"string\" && typeof unit === \"string\"\n ? `${label} (${unit})`\n : (\n <>\n {label} ({unit})\n </>\n )\n : label;\n\n const field = (\n <NumberInput\n {...rest}\n id={fieldId}\n aria-describedby={describedBy || undefined}\n aria-required={required || undefined}\n label={labelWithUnit}\n value={draft}\n onChange={onText}\n onCommit={commit}\n min={min}\n max={max}\n suffix={unitPlacement === \"suffix\" ? unit : undefined}\n invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === \"true\"}\n />\n );\n // Input's FieldGroup, restated: no wrapper for a field that never takes `error`, so\n // the prop changed nothing about the DOM a flex row lays out — and a STEADY one for a\n // field that does, message or not. Wrapping only while a message showed swapped the\n // element above the <input>, which remounted it: focus, caret and the half-typed\n // draft's keystrokes were lost the moment the message came or went.\n if (!hasError && !(\"error\" in props)) return field;\n return (\n <div>\n {field}\n {hasError && (\n <p id={errorId} className={FIELD_ERROR_CLASS}>\n {error}\n </p>\n )}\n </div>\n );\n}\n\n/**\n * A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as\n * defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a\n * form-bound field, for the plain one (kastlan 51: a building's construction year in a\n * wizard's local state, an entity id in the document upload spelled the two props out\n * by hand).\n *\n * `??` rather than a spread over the defaults, so a wrapper forwarding its own optional\n * `digits={props.digits}` still gets the preset.\n */\nexport function IntegerField(props: NumberFieldProps) {\n return <NumberField {...props} digits={props.digits ?? 0} calculator={props.calculator ?? false} />;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -2,7 +2,7 @@ import * as react from 'react';
|
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
3
|
import { CalculatorButtonLabels } from './calculator.js';
|
|
4
4
|
import { NumberPadSheetLabels } from './numpad-sheet.js';
|
|
5
|
-
import '../kit-labels-
|
|
5
|
+
import '../kit-labels-CPbsjlXr.js';
|
|
6
6
|
import './data-table-labels.js';
|
|
7
7
|
import './mini-calendar.js';
|
|
8
8
|
import './calendar-heatmap.js';
|
|
@@ -53,6 +53,15 @@ import './authed-image.js';
|
|
|
53
53
|
import '../hooks/use-authed-src.js';
|
|
54
54
|
import './image-grid.js';
|
|
55
55
|
import './lightbox.js';
|
|
56
|
+
import './write-lock.js';
|
|
57
|
+
import './account-chips.js';
|
|
58
|
+
import '../lib/format.js';
|
|
59
|
+
import '../data-table-3mi2R3so.js';
|
|
60
|
+
import './data-table-sort.js';
|
|
61
|
+
import './swipeable-row.js';
|
|
62
|
+
import './reauth-dialog.js';
|
|
63
|
+
import './server-wake.js';
|
|
64
|
+
import '../lib/server-wake.js';
|
|
56
65
|
|
|
57
66
|
interface NumberInputProps {
|
|
58
67
|
value: string;
|