@eifi1/ui-kit 0.15.4 → 0.16.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.
Files changed (160) hide show
  1. package/README.md +20 -15
  2. package/dist/chart.d.ts +3 -4
  3. package/dist/components/alert-banner.js +1 -1
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +13 -4
  6. package/dist/components/amount-input.js +166 -157
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/authed-image.d.ts +14 -3
  9. package/dist/components/authed-image.js +32 -23
  10. package/dist/components/authed-image.js.map +1 -1
  11. package/dist/components/button-group.d.ts +3 -4
  12. package/dist/components/calculator.d.ts +3 -4
  13. package/dist/components/checkbox.js +1 -1
  14. package/dist/components/checkbox.js.map +1 -1
  15. package/dist/components/choice-card.js +1 -1
  16. package/dist/components/choice-card.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +3 -4
  18. package/dist/components/error-boundary.js +1 -1
  19. package/dist/components/error-boundary.js.map +1 -1
  20. package/dist/components/facing-pair.d.ts +3 -4
  21. package/dist/components/file-button.d.ts +3 -4
  22. package/dist/components/file-button.js +64 -31
  23. package/dist/components/file-button.js.map +1 -1
  24. package/dist/components/file-dropzone.d.ts +3 -4
  25. package/dist/components/file-dropzone.js +1 -1
  26. package/dist/components/file-dropzone.js.map +1 -1
  27. package/dist/components/form-actions.d.ts +4 -5
  28. package/dist/components/form-actions.js +18 -6
  29. package/dist/components/form-actions.js.map +1 -1
  30. package/dist/components/line-items.d.ts +53 -2
  31. package/dist/components/line-items.js +73 -7
  32. package/dist/components/line-items.js.map +1 -1
  33. package/dist/components/list.d.ts +99 -5
  34. package/dist/components/list.js +103 -38
  35. package/dist/components/list.js.map +1 -1
  36. package/dist/components/loading-state.d.ts +19 -3
  37. package/dist/components/loading-state.js +14 -3
  38. package/dist/components/loading-state.js.map +1 -1
  39. package/dist/components/money-field.d.ts +3 -4
  40. package/dist/components/number-field.d.ts +3 -4
  41. package/dist/components/number-input.d.ts +3 -4
  42. package/dist/components/number-input.js +4 -2
  43. package/dist/components/number-input.js.map +1 -1
  44. package/dist/components/numpad-sheet.d.ts +3 -4
  45. package/dist/components/passkeys-setting.d.ts +30 -10
  46. package/dist/components/passkeys-setting.js +19 -11
  47. package/dist/components/passkeys-setting.js.map +1 -1
  48. package/dist/components/progress-bar.d.ts +12 -3
  49. package/dist/components/progress-bar.js +21 -11
  50. package/dist/components/progress-bar.js.map +1 -1
  51. package/dist/components/series-chart.d.ts +3 -4
  52. package/dist/components/settings-fields.d.ts +3 -4
  53. package/dist/components/signed-amount.d.ts +28 -4
  54. package/dist/components/signed-amount.js +17 -8
  55. package/dist/components/signed-amount.js.map +1 -1
  56. package/dist/components/stat-tile.d.ts +31 -2
  57. package/dist/components/stat-tile.js +37 -6
  58. package/dist/components/stat-tile.js.map +1 -1
  59. package/dist/components/status-dot.d.ts +4 -1
  60. package/dist/components/status-dot.js +20 -1
  61. package/dist/components/status-dot.js.map +1 -1
  62. package/dist/components/swatch-picker.d.ts +3 -1
  63. package/dist/components/swatch-picker.js.map +1 -1
  64. package/dist/components/table.d.ts +18 -13
  65. package/dist/components/table.js +20 -5
  66. package/dist/components/table.js.map +1 -1
  67. package/dist/components/text-link.d.ts +3 -4
  68. package/dist/components/time-input.d.ts +3 -4
  69. package/dist/components/toggle-legend.d.ts +12 -1
  70. package/dist/components/toggle-legend.js +6 -2
  71. package/dist/components/toggle-legend.js.map +1 -1
  72. package/dist/components/ui.d.ts +3 -4
  73. package/dist/components/ui.js +156 -52
  74. package/dist/components/ui.js.map +1 -1
  75. package/dist/feedback/feedback-attachment.d.ts +2 -82
  76. package/dist/feedback/feedback-attachment.js +285 -55
  77. package/dist/feedback/feedback-attachment.js.map +1 -1
  78. package/dist/feedback/feedback-dialog.d.ts +2 -105
  79. package/dist/feedback/feedback-dialog.js +57 -25
  80. package/dist/feedback/feedback-dialog.js.map +1 -1
  81. package/dist/feedback/feedback-inbox.d.ts +1 -1
  82. package/dist/feedback/feedback-thread.d.ts +2 -2
  83. package/dist/feedback-attachment-WFiXJ8We.d.ts +302 -0
  84. package/dist/feedback.d.ts +1 -2
  85. package/dist/hooks/use-file-drop.d.ts +3 -4
  86. package/dist/i18n/defaults.d.ts +3 -4
  87. package/dist/i18n/kit-labels.d.ts +2 -3
  88. package/dist/i18n/locales/de-CH-informal.d.ts +3 -4
  89. package/dist/i18n/locales/de-CH.d.ts +3 -4
  90. package/dist/i18n/locales/de-informal.d.ts +3 -4
  91. package/dist/i18n/locales/de.d.ts +3 -4
  92. package/dist/i18n/locales/de.js +5 -1
  93. package/dist/i18n/locales/de.js.map +1 -1
  94. package/dist/i18n/locales/es.d.ts +3 -4
  95. package/dist/i18n/locales/es.js +5 -1
  96. package/dist/i18n/locales/es.js.map +1 -1
  97. package/dist/i18n/locales/fr.d.ts +3 -4
  98. package/dist/i18n/locales/fr.js +5 -1
  99. package/dist/i18n/locales/fr.js.map +1 -1
  100. package/dist/i18n/locales/hu.d.ts +3 -4
  101. package/dist/i18n/locales/hu.js +5 -1
  102. package/dist/i18n/locales/hu.js.map +1 -1
  103. package/dist/i18n/locales/it.d.ts +3 -4
  104. package/dist/i18n/locales/it.js +5 -1
  105. package/dist/i18n/locales/it.js.map +1 -1
  106. package/dist/i18n/locales/zh.d.ts +3 -4
  107. package/dist/i18n/locales/zh.js +5 -1
  108. package/dist/i18n/locales/zh.js.map +1 -1
  109. package/dist/index.d.ts +10 -11
  110. package/dist/{kit-labels-CnnBd-2N.d.ts → kit-labels-DE--LmCk.d.ts} +154 -14
  111. package/dist/lib/format.d.ts +18 -3
  112. package/dist/lib/format.js +2 -1
  113. package/dist/lib/format.js.map +1 -1
  114. package/dist/rhf/fields.d.ts +3 -4
  115. package/dist/rhf/form.d.ts +3 -4
  116. package/dist/rhf/line-items.js.map +1 -1
  117. package/dist/rhf.d.ts +3 -4
  118. package/dist/shell/app-shell.d.ts +3 -4
  119. package/dist/shell/top-bar-brand.d.ts +3 -4
  120. package/dist/shell.d.ts +3 -4
  121. package/dist/theme/palette-derive.d.ts +3 -0
  122. package/dist/theme/palette-derive.js +7 -0
  123. package/dist/theme/palette-derive.js.map +1 -1
  124. package/dist/wizard/stepper-nav.d.ts +3 -4
  125. package/dist/wizard.d.ts +3 -4
  126. package/package.json +4 -2
  127. package/src/components/alert-banner.tsx +1 -1
  128. package/src/components/amount-input.tsx +47 -16
  129. package/src/components/authed-image.tsx +20 -2
  130. package/src/components/checkbox.tsx +1 -1
  131. package/src/components/choice-card.tsx +1 -1
  132. package/src/components/error-boundary.tsx +1 -1
  133. package/src/components/file-button.tsx +77 -27
  134. package/src/components/file-dropzone.tsx +1 -1
  135. package/src/components/form-actions.tsx +86 -8
  136. package/src/components/line-items.tsx +131 -14
  137. package/src/components/list.tsx +240 -50
  138. package/src/components/loading-state.tsx +35 -4
  139. package/src/components/number-input.tsx +18 -2
  140. package/src/components/passkeys-setting.tsx +45 -18
  141. package/src/components/progress-bar.tsx +46 -21
  142. package/src/components/signed-amount.tsx +47 -8
  143. package/src/components/stat-tile.tsx +76 -6
  144. package/src/components/status-dot.tsx +31 -0
  145. package/src/components/swatch-picker.tsx +3 -1
  146. package/src/components/table.tsx +42 -15
  147. package/src/components/toggle-legend.tsx +21 -4
  148. package/src/components/ui.tsx +280 -70
  149. package/src/feedback/feedback-attachment.tsx +472 -96
  150. package/src/feedback/feedback-dialog.tsx +162 -50
  151. package/src/i18n/locales/de.ts +4 -0
  152. package/src/i18n/locales/es.ts +4 -0
  153. package/src/i18n/locales/fr.ts +4 -0
  154. package/src/i18n/locales/hu.ts +4 -0
  155. package/src/i18n/locales/it.ts +4 -0
  156. package/src/i18n/locales/zh.ts +4 -0
  157. package/src/lib/format.ts +24 -3
  158. package/src/rhf/line-items.tsx +6 -0
  159. package/src/theme/palette-derive.ts +10 -0
  160. package/tokens.css +9 -1
@@ -1,8 +1,9 @@
1
1
  import { isValidElement } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import type { LucideIcon } from "lucide-react";
3
4
  import { cn } from "../lib/cn";
4
5
  import { useKitLabels } from "../i18n/kit-labels";
5
- import { Button, Spinner, type ButtonVariant } from "./ui";
6
+ import { Button, Spinner, type ButtonProps, type ButtonVariant } from "./ui";
6
7
 
7
8
  /** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
8
9
  export interface FormActionsLabels {
@@ -28,6 +29,25 @@ export type FormActionsAlign = "start" | "center" | "end" | "between";
28
29
  */
29
30
  export type FormActionsPlacement = "inline" | "sticky" | "dialog";
30
31
 
32
+ /**
33
+ * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.
34
+ * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto
35
+ * the phone's bottom nav by `--app-nav-h`.
36
+ * - `container`: the bottom of whatever scroll container it is in — a dialog body, a
37
+ * side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.
38
+ */
39
+ export type FormActionsStickyWithin = "viewport" | "container";
40
+
41
+ /**
42
+ * Extra attributes for the save button — an `id`, `data-*` hooks for tests or
43
+ * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,
44
+ * disabled, variant, form, children, the busy state) are not among them.
45
+ */
46
+ export type FormActionsSubmitProps = Omit<
47
+ ButtonProps,
48
+ "type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy"
49
+ > & { [key: `data-${string}`]: string | number | boolean | undefined };
50
+
31
51
  /** The start-side action of {@link FormActionsProps.destructive}, as data. */
32
52
  export interface FormActionsDestructive {
33
53
  label: ReactNode;
@@ -65,10 +85,21 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
65
85
  cancelLabel?: ReactNode;
66
86
  /** The save button's text while `pending` ("Saving…"). Default: `submitLabel`. */
67
87
  pendingLabel?: ReactNode;
88
+ /**
89
+ * A Lucide icon before the save button's text. While `pending` the spinner takes
90
+ * ITS place rather than being added in front of it — an icon written into
91
+ * `submitLabel` itself stays, so the button would show the spinner AND the glyph,
92
+ * two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of
93
+ * its text only to drop the check mark).
94
+ */
95
+ submitIcon?: LucideIcon;
96
+ /** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a
97
+ * `className`. See {@link FormActionsSubmitProps}. */
98
+ submitProps?: FormActionsSubmitProps;
68
99
  /**
69
100
  * The save is running: a spinner in the save button, `aria-busy` on it, and the
70
101
  * button disabled, so a second click cannot send the form twice. Cancel stays
71
- * usable.
102
+ * usable. The spinner goes in front of the text, or in place of {@link submitIcon}.
72
103
  */
73
104
  pending?: boolean;
74
105
  /** Disable save for a reason of the form's own (an unbalanced entry). */
@@ -85,10 +116,39 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
85
116
  * is `between`-aligned unless `align` says otherwise.
86
117
  */
87
118
  destructive?: ReactNode | FormActionsDestructive;
119
+ /**
120
+ * Something neutral at the START of the row — a caption ("Last saved 2 min ago",
121
+ * "Drag the corners to crop"), a "View activity" link. After {@link destructive}
122
+ * when both are given; like it, makes the row `between`-aligned unless `align` says
123
+ * otherwise. `children` are for further ACTIONS, beside Cancel.
124
+ */
125
+ start?: ReactNode;
88
126
  /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */
89
127
  align?: FormActionsAlign;
90
128
  /** See {@link FormActionsPlacement}. Default `inline`. */
91
129
  placement?: FormActionsPlacement;
130
+ /**
131
+ * With `placement="sticky"`: what the row sticks to. Default `viewport`.
132
+ *
133
+ * CSS sticky always sticks to the NEAREST scroll container, so the default row
134
+ * already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,
135
+ * which AppShell publishes on the document: inside a pane or a portalled dialog
136
+ * body on a phone it then floats a nav's height above that container's bottom,
137
+ * with the content scrolling through the gap. `container` drops that offset.
138
+ *
139
+ * What neither value can fix, because it is the layout, not the row:
140
+ * - sticky moves only within its PARENT box. Inside a DataTable expansion row the
141
+ * parent is the expansion cell, so the row pins to the table's own scroller
142
+ * (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans
143
+ * that scroller's bottom edge — not to the page.
144
+ * - any `overflow` other than `visible`/`clip` between the row and the scroller
145
+ * you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the
146
+ * scroll container; if THAT box does not scroll, the row never sticks.
147
+ * - the offset is resolved against the scroller's content box, so a scroller with
148
+ * bottom padding leaves that strip uncovered; give the row a negative `bottom`
149
+ * through `style` and the same amount back as padding.
150
+ */
151
+ stickyWithin?: FormActionsStickyWithin;
92
152
  /** The `id` of the `<form>` the save button submits, when the row is rendered
93
153
  * outside it (a dialog's footer slot). */
94
154
  form?: string;
@@ -121,13 +181,17 @@ export function FormActions({
121
181
  submitLabel,
122
182
  cancelLabel,
123
183
  pendingLabel,
184
+ submitIcon: SubmitIcon,
185
+ submitProps,
124
186
  pending = false,
125
187
  submitDisabled = false,
126
188
  submitDisabledReason,
127
189
  submitVariant = "brand",
128
190
  destructive,
191
+ start: startSlot,
129
192
  align,
130
193
  placement = "inline",
194
+ stickyWithin = "viewport",
131
195
  form,
132
196
  children,
133
197
  className,
@@ -136,8 +200,9 @@ export function FormActions({
136
200
  }: FormActionsProps) {
137
201
  const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
138
202
  const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
139
- const justify = align ?? (hasDestructive ? "between" : "end");
140
- const start = !hasDestructive ? null : isDestructiveData(destructive) ? (
203
+ const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;
204
+ const justify = align ?? (hasDestructive || hasStart ? "between" : "end");
205
+ const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (
141
206
  <Button
142
207
  type="button"
143
208
  variant="ghost"
@@ -157,6 +222,7 @@ export function FormActions({
157
222
  <div
158
223
  data-slot="form-actions"
159
224
  data-placement={placement}
225
+ data-sticky-within={placement === "sticky" ? stickyWithin : undefined}
160
226
  {...rest}
161
227
  style={
162
228
  placement === "sticky"
@@ -165,15 +231,22 @@ export function FormActions({
165
231
  // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`
166
232
  // pinned the row under AppShell's nav, which covers the page's last ~56px.
167
233
  // `--app-nav-h` is the nav's measured height (0px from md up, and outside an
168
- // AppShell); the 1px overlaps the two top borders into one line.
169
- bottom: "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
234
+ // AppShell); the 1px overlaps the two top borders into one line. In a
235
+ // container the nav is not underneath: `--app-nav-h` is the document's,
236
+ // and would hold the row that far above the container's own edge.
237
+ bottom: stickyWithin === "container" ? 0 : "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
170
238
  ...style,
171
239
  }
172
240
  : style
173
241
  }
174
242
  className={cn("flex flex-wrap items-center gap-2", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}
175
243
  >
176
- {start && <div className="me-auto flex items-center gap-2">{start}</div>}
244
+ {(hasDestructive || hasStart) && (
245
+ <div className="me-auto flex min-w-0 flex-wrap items-center gap-2">
246
+ {destructiveNode}
247
+ {hasStart && startSlot}
248
+ </div>
249
+ )}
177
250
  <div className="flex flex-wrap items-center gap-2">
178
251
  {children}
179
252
  {onCancel && (
@@ -182,6 +255,7 @@ export function FormActions({
182
255
  </Button>
183
256
  )}
184
257
  <Button
258
+ {...submitProps}
185
259
  type={onSubmit ? "button" : "submit"}
186
260
  form={form}
187
261
  onClick={onSubmit}
@@ -190,7 +264,11 @@ export function FormActions({
190
264
  disabledReason={pending ? undefined : submitDisabledReason}
191
265
  aria-busy={pending || undefined}
192
266
  >
193
- {pending && <Spinner label={null} className="size-4" />}
267
+ {pending ? (
268
+ <Spinner label={null} className="size-4" />
269
+ ) : (
270
+ SubmitIcon && <SubmitIcon aria-hidden className="size-4 shrink-0" />
271
+ )}
194
272
  {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
195
273
  </Button>
196
274
  </div>
@@ -39,6 +39,37 @@ export interface LineItemCellContext<T> {
39
39
  * hidden from assistive tech (each row repeats it), so the field has to carry it:
40
40
  * `<Input aria-label={label} … />`. */
41
41
  label: string;
42
+ /** The column's own name ("Debit") for the field's VISIBLE label — set only when the
43
+ * cell's labels are `"floating"` (see {@link LineItemsProps.fieldLabels}), so one
44
+ * render serves both modes: `<Input label={fieldLabel} aria-label={label} … />`.
45
+ * Undefined there, the Input stays a bare compact control; set, it floats "Debit"
46
+ * while `aria-label` still says which row. */
47
+ fieldLabel?: string;
48
+ }
49
+
50
+ /** How a cell's field is labelled. `aria`: the field is a bare control named by
51
+ * `aria-label`, under a header row (stacked: a small copy of the header over it).
52
+ * `floating`: the field shows its own floating label ({@link LineItemCellContext.fieldLabel}),
53
+ * so the list draws no header for it. */
54
+ export type LineItemsFieldLabels = "aria" | "floating";
55
+
56
+ /** The colour of a {@link LineItemsSummary}'s value. */
57
+ export type LineItemsSummaryTone = "neutral" | "success" | "danger" | "muted";
58
+
59
+ /**
60
+ * A line under the lines that says where they stand — a split's remainder ("Left to
61
+ * assign CHF 0.00", green at zero, red otherwise, muted while the total is not known
62
+ * yet), with an optional action that settles it ("Set amount to total").
63
+ */
64
+ export interface LineItemsSummary {
65
+ label: ReactNode;
66
+ value?: ReactNode;
67
+ /** The value's colour. Default `neutral`. The label stays neutral: the colour is a
68
+ * second signal, never the only one — the value and label carry the meaning. */
69
+ tone?: LineItemsSummaryTone;
70
+ /** A small button at the end of the line. Disabled along with the list's
71
+ * `disabled`. */
72
+ action?: { label: ReactNode; onClick: () => void; disabled?: boolean };
42
73
  }
43
74
 
44
75
  export interface LineItemsColumn<T> {
@@ -54,6 +85,13 @@ export interface LineItemsColumn<T> {
54
85
  width?: string;
55
86
  /** `end` for figures: the header and the totals cell end-align with the field. */
56
87
  align?: "start" | "end";
88
+ /** This column's labelling, over the list's {@link LineItemsProps.fieldLabels} — a
89
+ * read-only "Sum" cell in a floating list keeps its header with `"aria"`. */
90
+ fieldLabels?: LineItemsFieldLabels;
91
+ /** With {@link LineItemsProps.narrowColumns} `2`: `2` gives the cell a whole row of
92
+ * the stacked card (a description), where short fields pair up two to a row.
93
+ * Default `1`. No effect on the wide grid. */
94
+ narrowSpan?: 1 | 2;
57
95
  render: (ctx: LineItemCellContext<T>) => ReactNode;
58
96
  }
59
97
 
@@ -93,12 +131,30 @@ export interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<"div">,
93
131
  empty?: ReactNode;
94
132
  /** A message about the list as a whole ("The entry does not balance"). */
95
133
  error?: ReactNode;
134
+ /** A line under the lines and totals — the remainder of a split, say. See
135
+ * {@link LineItemsSummary}. */
136
+ summary?: LineItemsSummary;
137
+ /** How the cells' fields are labelled. Default `aria` (compact bare controls under a
138
+ * header row); `floating` hands each cell its column's name as
139
+ * {@link LineItemCellContext.fieldLabel} for a visible floating label, and drops the
140
+ * header for those columns. A column's own `fieldLabels` wins. */
141
+ fieldLabels?: LineItemsFieldLabels;
142
+ /** Stacked (a narrow pane), how many fields share a row of a line's card: `2` pairs
143
+ * them up (a column's `narrowSpan={2}` takes a whole row), once the pane is wide
144
+ * enough for two fields (18rem); narrower still, one. Default `1`. */
145
+ narrowColumns?: 1 | 2;
96
146
  /** Disables add and remove (the fields are the caller's to disable). */
97
147
  disabled?: boolean;
98
148
  labels?: Partial<LineItemsLabels>;
99
149
  }
100
150
 
101
151
  const REMOVE_TRACK = "2.25rem";
152
+ const SUMMARY_TONE: Record<LineItemsSummaryTone, string> = {
153
+ neutral: "text-[var(--text-primary)]",
154
+ success: "text-[var(--success)]",
155
+ danger: "text-[var(--danger)]",
156
+ muted: "text-[var(--text-muted)]",
157
+ };
102
158
  const FOCUSABLE =
103
159
  "input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])";
104
160
 
@@ -158,6 +214,9 @@ export function LineItems<T>({
158
214
  totalsLabel,
159
215
  empty,
160
216
  error,
217
+ summary,
218
+ fieldLabels = "aria",
219
+ narrowColumns = 1,
161
220
  disabled = false,
162
221
  labels: labelsProp,
163
222
  className,
@@ -228,6 +287,11 @@ export function LineItems<T>({
228
287
 
229
288
  const columnName = (column: LineItemsColumn<T>) =>
230
289
  column.label ?? (typeof column.header === "string" ? column.header : column.key);
290
+ const floats = (column: LineItemsColumn<T>) => (column.fieldLabels ?? fieldLabels) === "floating";
291
+ // A header row only for the columns that still need one: with every field floating
292
+ // its own label, a row of headers would say each name twice.
293
+ const showHeader = columns.some((c) => !floats(c));
294
+ const pairs = narrowColumns === 2;
231
295
 
232
296
  const headerCell = (column: LineItemsColumn<T>) => (
233
297
  <span
@@ -237,7 +301,7 @@ export function LineItems<T>({
237
301
  column.align === "end" && "text-end",
238
302
  )}
239
303
  >
240
- {column.header}
304
+ {floats(column) ? null : column.header}
241
305
  </span>
242
306
  );
243
307
 
@@ -251,10 +315,12 @@ export function LineItems<T>({
251
315
  style={{ ...gridStyle, ...style }}
252
316
  className={cn("@container space-y-2", className)}
253
317
  >
254
- <div aria-hidden className="hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]">
255
- {columns.map(headerCell)}
256
- {onRemove && <span />}
257
- </div>
318
+ {showHeader && (
319
+ <div aria-hidden className="hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]">
320
+ {columns.map(headerCell)}
321
+ {onRemove && <span />}
322
+ </div>
323
+ )}
258
324
 
259
325
  {items.length === 0 && hasContent(empty) && (
260
326
  <div className="rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-sm text-[var(--text-muted)]">
@@ -280,20 +346,42 @@ export function LineItems<T>({
280
346
  onKeyDown={onRowKeyDown}
281
347
  className={cn(
282
348
  "grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start",
349
+ // Two short fields to a row of the card — from 18rem, where two fields
350
+ // still fit a figure each; on anything narrower they stack after all.
351
+ pairs && "@2xs:grid-cols-2",
283
352
  // Stacked, each row is a card of its own so the rows stay apart.
284
353
  "@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3",
285
354
  )}
286
355
  >
287
- {columns.map((column) => (
288
- <div key={column.key} className="min-w-0 space-y-1 @lg:space-y-0">
289
- <span aria-hidden className="block text-xs font-medium text-[var(--text-muted)] @lg:hidden">
290
- {column.header}
291
- </span>
292
- {column.render({ item, index, label: labels.cell(columnName(column), row) })}
293
- </div>
294
- ))}
356
+ {columns.map((column) => {
357
+ const floating = floats(column);
358
+ return (
359
+ <div
360
+ key={column.key}
361
+ className={cn(
362
+ "min-w-0 space-y-1 @lg:space-y-0",
363
+ // The wide grid places every cell in its own track again.
364
+ pairs && column.narrowSpan === 2 && "@2xs:col-span-2 @lg:col-span-1",
365
+ )}
366
+ >
367
+ {!floating && (
368
+ <span aria-hidden className="block text-xs font-medium text-[var(--text-muted)] @lg:hidden">
369
+ {column.header}
370
+ </span>
371
+ )}
372
+ {column.render({
373
+ item,
374
+ index,
375
+ label: labels.cell(columnName(column), row),
376
+ fieldLabel: floating ? columnName(column) : undefined,
377
+ })}
378
+ </div>
379
+ );
380
+ })}
295
381
  {onRemove && (
296
- <div className="flex justify-end @lg:justify-center">
382
+ // Paired, the remove button keeps a row of its own at the card's end
383
+ // rather than squeezing in beside the last field.
384
+ <div className={cn("flex justify-end @lg:justify-center", pairs && "@2xs:col-span-2 @lg:col-span-1")}>
297
385
  <IconButton
298
386
  type="button"
299
387
  data-line-items-remove=""
@@ -361,6 +449,35 @@ export function LineItems<T>({
361
449
  </div>
362
450
  )}
363
451
 
452
+ {summary && (
453
+ <div
454
+ data-line-items-summary=""
455
+ className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-primary)]"
456
+ >
457
+ <span className="min-w-0">{summary.label}</span>
458
+ {hasContent(summary.value) && (
459
+ <span
460
+ data-tone={summary.tone ?? "neutral"}
461
+ className={cn("font-semibold tabular-nums", SUMMARY_TONE[summary.tone ?? "neutral"])}
462
+ >
463
+ {summary.value}
464
+ </span>
465
+ )}
466
+ {summary.action && (
467
+ <Button
468
+ type="button"
469
+ variant="secondary"
470
+ size="sm"
471
+ className="ms-auto"
472
+ disabled={disabled || summary.action.disabled}
473
+ onClick={summary.action.onClick}
474
+ >
475
+ {summary.action.label}
476
+ </Button>
477
+ )}
478
+ </div>
479
+ )}
480
+
364
481
  {hasError && (
365
482
  <p className="text-[11px] leading-tight text-[var(--danger)]">
366
483
  {error}