@eifi1/ui-kit 0.14.1 → 0.15.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 +4 -3
- package/dist/components/alert-banner.js +5 -2
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +9 -1
- package/dist/components/amount-input.js +10 -4
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.js +9 -2
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/bulk-action-bar.js +6 -3
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/calendar-heatmap.js +5 -5
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/copy-button.d.ts +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-filter-popover.js +3 -3
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +12 -4
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/error-boundary.js +2 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/field.js +4 -1
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/image-grid.d.ts +8 -1
- package/dist/components/image-grid.js +16 -3
- package/dist/components/image-grid.js.map +1 -1
- package/dist/components/measured-grid.js +2 -2
- package/dist/components/measured-grid.js.map +1 -1
- package/dist/components/money-field.d.ts +92 -0
- package/dist/components/money-field.js +40 -0
- package/dist/components/money-field.js.map +1 -0
- package/dist/components/multi-select.js +3 -0
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +13 -2
- package/dist/components/number-field.js +4 -0
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +5 -2
- package/dist/components/number-input.js +11 -3
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/page-header.js +29 -3
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/series-chart.js +37 -8
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/stat-tile.js +3 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/text-link.js +2 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/treemap.d.ts +3 -2
- package/dist/components/treemap.js +14 -3
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +44 -10
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-dialog.d.ts +10 -1
- package/dist/feedback/feedback-dialog.js +13 -3
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-thread.js +31 -12
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/de.js +1 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/es.js +1 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/fr.js +1 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/hu.js +1 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/it.js +1 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/i18n/locales/zh.js +1 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
- package/dist/lib/document-navigation.d.ts +15 -0
- package/dist/lib/document-navigation.js +13 -0
- package/dist/lib/document-navigation.js.map +1 -0
- package/dist/lib/strip-fade.d.ts +31 -0
- package/dist/lib/strip-fade.js +54 -0
- package/dist/lib/strip-fade.js.map +1 -0
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf/form.js +1 -1
- package/dist/rhf/form.js.map +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +11 -2
- package/dist/shell/app-shell.js +20 -4
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/stepper-nav.js +14 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +7 -4
- package/src/components/alert-banner.tsx +10 -2
- package/src/components/amount-input.tsx +18 -4
- package/src/components/autocomplete.tsx +10 -1
- package/src/components/bulk-action-bar.tsx +12 -3
- package/src/components/calendar-heatmap.tsx +10 -5
- package/src/components/data-table-filter-popover.tsx +6 -3
- package/src/components/data-table.tsx +21 -7
- package/src/components/error-boundary.tsx +2 -1
- package/src/components/field.tsx +4 -1
- package/src/components/image-grid.tsx +30 -2
- package/src/components/measured-grid.tsx +3 -2
- package/src/components/money-field.tsx +75 -0
- package/src/components/multi-select.tsx +3 -0
- package/src/components/number-field.tsx +14 -0
- package/src/components/number-input.tsx +23 -4
- package/src/components/page-header.tsx +38 -3
- package/src/components/series-chart.tsx +56 -8
- package/src/components/stat-tile.tsx +8 -3
- package/src/components/text-link.tsx +2 -1
- package/src/components/treemap.tsx +30 -5
- package/src/components/ui.tsx +91 -11
- package/src/feedback/feedback-dialog.tsx +20 -2
- package/src/feedback/feedback-thread.tsx +36 -13
- package/src/i18n/locales/de.ts +1 -0
- package/src/i18n/locales/es.ts +1 -0
- package/src/i18n/locales/fr.ts +1 -0
- package/src/i18n/locales/hu.ts +1 -0
- package/src/i18n/locales/it.ts +1 -0
- package/src/i18n/locales/zh.ts +1 -0
- package/src/index.ts +1 -0
- package/src/lib/document-navigation.ts +17 -0
- package/src/lib/strip-fade.ts +82 -0
- package/src/rhf/form.tsx +3 -1
- package/src/shell/app-shell.tsx +34 -7
- package/src/wizard/stepper-nav.tsx +23 -2
|
@@ -251,8 +251,17 @@ function AutocompleteInner<V extends string | number = string>(
|
|
|
251
251
|
<span
|
|
252
252
|
aria-hidden
|
|
253
253
|
className={cn(
|
|
254
|
-
"pointer-events-none absolute
|
|
254
|
+
"pointer-events-none absolute flex text-[var(--text-muted)]",
|
|
255
255
|
small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
|
|
256
|
+
// Labelled, the icon sits on the VALUE's line — the one line box below
|
|
257
|
+
// FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
|
|
258
|
+
// the box. Centred on the box it rose into the top strip and sat on the
|
|
259
|
+
// label's first letters, which float at the same start inset. On the
|
|
260
|
+
// value line it is beside what it describes, and the label keeps the
|
|
261
|
+
// start edge every other label in the form's column shares.
|
|
262
|
+
hasLabel
|
|
263
|
+
? "top-[calc(1rem+1px)] h-5 items-center"
|
|
264
|
+
: "top-1/2 -translate-y-1/2",
|
|
256
265
|
)}
|
|
257
266
|
>
|
|
258
267
|
{icon}
|
|
@@ -277,7 +277,8 @@ export function BulkActionBar({
|
|
|
277
277
|
<X />
|
|
278
278
|
</IconButton>
|
|
279
279
|
) : (
|
|
280
|
-
|
|
280
|
+
// `ms-auto`: when the actions wrap, the way out still sits at the bar's end.
|
|
281
|
+
<Button type="button" variant="secondary" size="sm" onClick={onClear} className="ms-auto">
|
|
281
282
|
{labels.clear}
|
|
282
283
|
</Button>
|
|
283
284
|
);
|
|
@@ -296,7 +297,11 @@ export function BulkActionBar({
|
|
|
296
297
|
"sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--brand)_8%,var(--bg-surface))] backdrop-blur",
|
|
297
298
|
shown ? "animate-overlay" : "animate-overlay-out",
|
|
298
299
|
);
|
|
299
|
-
|
|
300
|
+
// `flex-wrap`: on a phone the count, three actions and a text "Clear selection" are
|
|
301
|
+
// wider than the screen. Without wrapping the count (the one shrinkable item)
|
|
302
|
+
// collapsed to a sliver and the rest ran out of the bar; wrapped, the actions that do
|
|
303
|
+
// not fit move to a second line and every label stays whole.
|
|
304
|
+
const rowClass = cn("flex flex-wrap items-center", floating ? "gap-1 px-2 py-1" : "gap-2 px-3 py-2");
|
|
300
305
|
const surfaceStyle = floating ? { ...FLOATING_STYLE, ...style } : style;
|
|
301
306
|
|
|
302
307
|
const toolbar = (
|
|
@@ -324,7 +329,11 @@ export function BulkActionBar({
|
|
|
324
329
|
className={hasPanel ? rowClass : cn(rowClass, surfaceClass, className)}
|
|
325
330
|
>
|
|
326
331
|
{floating && clear}
|
|
327
|
-
|
|
332
|
+
{/* `flex-auto`, not `flex-1`: a zero basis told the wrapping that the count needs
|
|
333
|
+
no room, so it was the item squeezed to nothing instead of a line breaking.
|
|
334
|
+
Its own width is its basis now; `truncate` is left for a label too long for a
|
|
335
|
+
line on its own. */}
|
|
336
|
+
<span className="min-w-0 flex-auto truncate text-sm font-medium text-[var(--text-primary)]">
|
|
328
337
|
{labels.selected(shownCount)}
|
|
329
338
|
</span>
|
|
330
339
|
{/* `contents`, so the caller's controls are flex items of the bar itself. */}
|
|
@@ -354,14 +354,19 @@ export function CalendarHeatmap({
|
|
|
354
354
|
|
|
355
355
|
const weekdayLabels = useMemo(() => {
|
|
356
356
|
const ref = new Date(2024, 0, 7 + weekStart); // 7 January 2024 was a Sunday
|
|
357
|
-
|
|
357
|
+
const week = Array.from({ length: 7 }, (_, i) => {
|
|
358
358
|
const d = new Date(ref);
|
|
359
359
|
d.setDate(ref.getDate() + i);
|
|
360
|
-
return
|
|
361
|
-
short: d.toLocaleDateString(locale, { weekday: "short" }),
|
|
362
|
-
long: d.toLocaleDateString(locale, { weekday: "long" }),
|
|
363
|
-
};
|
|
360
|
+
return d;
|
|
364
361
|
});
|
|
362
|
+
const name = (d: Date, weekday: "short" | "narrow" | "long") => d.toLocaleDateString(locale, { weekday });
|
|
363
|
+
// The label column is 2rem at 10px type, room for about four characters. Most
|
|
364
|
+
// locales' "short" is three or four ("Thu", "jeu.", "czw."), but some have no
|
|
365
|
+
// abbreviation at all — Arabic's short Thursday IS "الخميس", which overran the box
|
|
366
|
+
// and was cut. Such a locale takes the "narrow" form ("خ") for ALL seven, so the
|
|
367
|
+
// column never mixes two forms; the full name is on the aria-label either way.
|
|
368
|
+
const tooLong = week.some((d) => [...name(d, "short")].length > 4);
|
|
369
|
+
return week.map((d) => ({ short: name(d, tooLong ? "narrow" : "short"), long: name(d, "long") }));
|
|
365
370
|
}, [locale, weekStart]);
|
|
366
371
|
|
|
367
372
|
const lead = days.length ? weekdayIndex(days[0], weekStart) : 0;
|
|
@@ -163,9 +163,12 @@ export function FilterPopover<T>({
|
|
|
163
163
|
const set = (from: string, to: string) => onChange({ type: "date", from, to });
|
|
164
164
|
const presets = dateRangePresets();
|
|
165
165
|
const isActivePreset = (p: { from: string; to: string }) => v.from === p.from && v.to === p.to;
|
|
166
|
+
// Side by side, the 7rem preset column leaves a phone's calendar and its two
|
|
167
|
+
// native date inputs ~180px, which clips "mm/dd/yyyy" mid-placeholder. Below `sm`
|
|
168
|
+
// the presets wrap as a row above the calendar, which then gets the panel's width.
|
|
166
169
|
return (
|
|
167
|
-
<div className="flex gap-3">
|
|
168
|
-
<ul className="flex w-28 shrink-0 flex-col
|
|
170
|
+
<div className="flex flex-col gap-3 sm:flex-row">
|
|
171
|
+
<ul className="flex flex-wrap gap-0.5 text-xs sm:w-28 sm:shrink-0 sm:flex-col sm:flex-nowrap">
|
|
169
172
|
{presets.map((p) => {
|
|
170
173
|
const active = isActivePreset(p);
|
|
171
174
|
return (
|
|
@@ -174,7 +177,7 @@ export function FilterPopover<T>({
|
|
|
174
177
|
type="button"
|
|
175
178
|
onClick={() => set(p.from, p.to)}
|
|
176
179
|
className={cn(
|
|
177
|
-
"block
|
|
180
|
+
"block rounded px-2 py-1 text-start transition-colors sm:w-full",
|
|
178
181
|
active
|
|
179
182
|
? "bg-[var(--brand-bg)] text-[var(--brand-muted)]"
|
|
180
183
|
: "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
|
|
@@ -247,9 +247,10 @@ export interface DataTableProps<T> {
|
|
|
247
247
|
*/
|
|
248
248
|
urlSync?: TableUrlSync;
|
|
249
249
|
/**
|
|
250
|
-
* When true
|
|
251
|
-
*
|
|
252
|
-
*
|
|
250
|
+
* When true, the table fills its parent's height and scrolls INTERNALLY, with the
|
|
251
|
+
* header pinned — so the page itself doesn't add a second, outer scrollbar. On a
|
|
252
|
+
* phone the card list is the scroller instead, with the filter button pinned above
|
|
253
|
+
* it; an unbounded parent simply lets either layout grow to its content. The parent must give it a bounded height (e.g. a flex
|
|
253
254
|
* column inside the viewport-locked app shell). See feedback #207.
|
|
254
255
|
*/
|
|
255
256
|
fillHeight?: boolean;
|
|
@@ -1634,7 +1635,10 @@ export function DataTable<T>({
|
|
|
1634
1635
|
className={cn(
|
|
1635
1636
|
"group/table",
|
|
1636
1637
|
frame ? "overflow-clip" : "min-w-0",
|
|
1637
|
-
|
|
1638
|
+
// Both layouts, not just the table: the phone card list overflowing a bounded
|
|
1639
|
+
// pane drew itself over whatever followed it, and in a viewport-locked shell
|
|
1640
|
+
// the rows below the fold could not be reached at all.
|
|
1641
|
+
fillHeight && "flex flex-1 flex-col min-h-0",
|
|
1638
1642
|
className,
|
|
1639
1643
|
)}
|
|
1640
1644
|
>
|
|
@@ -1658,7 +1662,7 @@ export function DataTable<T>({
|
|
|
1658
1662
|
</div>
|
|
1659
1663
|
)}
|
|
1660
1664
|
{!isMdUp && (
|
|
1661
|
-
<div ref={mobileRootRef}>
|
|
1665
|
+
<div ref={mobileRootRef} className={cn(fillHeight && "flex min-h-0 flex-1 flex-col")}>
|
|
1662
1666
|
{/* Mobile filter access (feedback #299): the per-column filter popovers
|
|
1663
1667
|
live in the desktop header, which the card list doesn't render — so
|
|
1664
1668
|
expose the same filters through a bottom sheet here. */}
|
|
@@ -1679,6 +1683,11 @@ export function DataTable<T>({
|
|
|
1679
1683
|
<ul
|
|
1680
1684
|
className={cn(
|
|
1681
1685
|
"divide-y divide-[var(--border)]",
|
|
1686
|
+
// Under `fillHeight` the list is the scroller, as the table body is on a
|
|
1687
|
+
// wide screen: the filter button stays above it and a server pager below.
|
|
1688
|
+
// The endless-scroll sentinel still fires in here — an
|
|
1689
|
+
// IntersectionObserver on the viewport sees through the clipping ancestor.
|
|
1690
|
+
fillHeight && "min-h-0 flex-1 overflow-y-auto overscroll-contain",
|
|
1682
1691
|
// Same busy treatment as the desktop body; see `isLoading` below.
|
|
1683
1692
|
isLoading && mobileSlice.length > 0 && "opacity-60 transition-opacity",
|
|
1684
1693
|
)}
|
|
@@ -1711,7 +1720,8 @@ export function DataTable<T>({
|
|
|
1711
1720
|
ref={loadMoreRef}
|
|
1712
1721
|
className="px-4 py-4 text-center text-xs text-[var(--text-placeholder)]"
|
|
1713
1722
|
>
|
|
1714
|
-
{
|
|
1723
|
+
{/* Isolated for the reason spelled out on LoadingText. */}
|
|
1724
|
+
<bdi>{labels.loading}</bdi>
|
|
1715
1725
|
</li>
|
|
1716
1726
|
)}
|
|
1717
1727
|
</ul>
|
|
@@ -2287,10 +2297,14 @@ function PlainRoot(props: React.HTMLAttributes<HTMLDivElement>) {
|
|
|
2287
2297
|
|
|
2288
2298
|
/** The loading row's content: a spinner beside the word, the word for everyone. */
|
|
2289
2299
|
function LoadingText({ label }: { label: string }) {
|
|
2300
|
+
// `<bdi>`: the label takes its direction from its own letters, not the table's. The
|
|
2301
|
+
// English default inside a `dir="rtl"` table otherwise had its trailing "…" — a
|
|
2302
|
+
// neutral with no letter after it — resolved right-to-left and drawn in front of
|
|
2303
|
+
// the word: "…Loading". An Arabic or Hebrew label still reads right-to-left.
|
|
2290
2304
|
return (
|
|
2291
2305
|
<span className="inline-flex items-center gap-2">
|
|
2292
2306
|
<Spinner label={null} className="size-4" />
|
|
2293
|
-
{label}
|
|
2307
|
+
<bdi>{label}</bdi>
|
|
2294
2308
|
</span>
|
|
2295
2309
|
);
|
|
2296
2310
|
}
|
|
@@ -5,6 +5,7 @@ import { useKitLabels } from "../i18n/kit-labels";
|
|
|
5
5
|
import { Button, EmptyState } from "./ui";
|
|
6
6
|
import { CopyButton } from "./copy-button";
|
|
7
7
|
import { Disclosure } from "./disclosure";
|
|
8
|
+
import { documentNavigation } from "../lib/document-navigation";
|
|
8
9
|
|
|
9
10
|
/** The words of the default fallback. */
|
|
10
11
|
export interface ErrorBoundaryLabels {
|
|
@@ -277,7 +278,7 @@ function isOffline(): boolean {
|
|
|
277
278
|
|
|
278
279
|
function reloadPage(): void {
|
|
279
280
|
try {
|
|
280
|
-
|
|
281
|
+
documentNavigation.reload();
|
|
281
282
|
} catch {
|
|
282
283
|
/* nothing left to do from here */
|
|
283
284
|
}
|
package/src/components/field.tsx
CHANGED
|
@@ -150,7 +150,10 @@ export function Field({
|
|
|
150
150
|
<div
|
|
151
151
|
data-slot="field"
|
|
152
152
|
className={cn(
|
|
153
|
-
|
|
153
|
+
// `content-start`: in a grid row next to a taller item (one with a hint), a
|
|
154
|
+
// stretched Field would hand the extra height to its rows — the label grew,
|
|
155
|
+
// and the control sat lower than its neighbour's (showcase audit, #/forms).
|
|
156
|
+
"grid content-start gap-1.5",
|
|
154
157
|
// A hidden label is `position: absolute`; `relative` keeps its containing
|
|
155
158
|
// block here rather than the page's (see sr-only-containment.test).
|
|
156
159
|
labelVisibility !== "visible" && "relative",
|
|
@@ -70,6 +70,18 @@ const ASPECT_CLASS = {
|
|
|
70
70
|
auto: "aspect-[4/3]",
|
|
71
71
|
} as const;
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* A file name as stem and extension, so a tile can cut the stem and keep the
|
|
75
|
+
* extension whole: "floor-plan-level-2.p / df" (a break inside the extension) told the
|
|
76
|
+
* reader less than "floor-plan-le….pdf" does. A leading dot (".env") or an
|
|
77
|
+
* "extension" longer than five characters is not treated as one.
|
|
78
|
+
*/
|
|
79
|
+
export function splitFileName(name: string): [stem: string, ext: string] {
|
|
80
|
+
const dot = name.lastIndexOf(".");
|
|
81
|
+
if (dot <= 0 || name.length - dot > 6) return [name, ""];
|
|
82
|
+
return [name.slice(0, dot), name.slice(dot)];
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
/**
|
|
74
86
|
* A grid of thumbnails that open in a {@link Lightbox} — kastlan's room and defect
|
|
75
87
|
* photos (room-inspector, unit-rooms-tab) and its floor plans (unit-floor-plan), each
|
|
@@ -134,7 +146,7 @@ export function ImageGrid({
|
|
|
134
146
|
{kind === "file" ? (
|
|
135
147
|
<span className="flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-[var(--text-secondary)]">
|
|
136
148
|
<FileText aria-hidden className="size-6 shrink-0 text-[var(--text-muted)]" />
|
|
137
|
-
<
|
|
149
|
+
<FileNameLabel name={item.fileName ?? item.alt ?? ""} />
|
|
138
150
|
</span>
|
|
139
151
|
) : (
|
|
140
152
|
<AuthedImage
|
|
@@ -153,7 +165,12 @@ export function ImageGrid({
|
|
|
153
165
|
<div
|
|
154
166
|
role="group"
|
|
155
167
|
aria-label={labels.actions(name)}
|
|
156
|
-
|
|
168
|
+
// A backdrop behind the whole group, in the inverse token the overlay
|
|
169
|
+
// buttons use: a translucent disc alone took most of its colour from
|
|
170
|
+
// the picture, and on a pale photo or the light file tile the glyphs
|
|
171
|
+
// washed out. Two layers of the inverse keep them readable over any
|
|
172
|
+
// picture, in both themes.
|
|
173
|
+
className="absolute end-1 top-1 flex gap-1 rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_40%,transparent)] p-0.5 backdrop-blur-sm"
|
|
157
174
|
>
|
|
158
175
|
{actions}
|
|
159
176
|
</div>
|
|
@@ -179,3 +196,14 @@ export function ImageGrid({
|
|
|
179
196
|
</>
|
|
180
197
|
);
|
|
181
198
|
}
|
|
199
|
+
|
|
200
|
+
/** One line: the stem truncates, the extension never does. */
|
|
201
|
+
function FileNameLabel({ name }: { name: string }) {
|
|
202
|
+
const [stem, ext] = splitFileName(name);
|
|
203
|
+
return (
|
|
204
|
+
<span aria-hidden className="flex max-w-full min-w-0">
|
|
205
|
+
<span className="min-w-0 truncate">{stem}</span>
|
|
206
|
+
{ext && <span className="shrink-0">{ext}</span>}
|
|
207
|
+
</span>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -771,7 +771,8 @@ function CellGrid({
|
|
|
771
771
|
<Eraser aria-hidden className="size-3.5" /> {labels.clear}
|
|
772
772
|
</Button>
|
|
773
773
|
<span className="text-[11px] tabular-nums text-[var(--text-muted)]">
|
|
774
|
-
{
|
|
774
|
+
{/* bdi: an LTR label ("3 points") in an RTL grid would reorder to "points 3". */}
|
|
775
|
+
<bdi>{labels.points(filled.length)}</bdi>
|
|
775
776
|
</span>
|
|
776
777
|
{problems > 0 && (
|
|
777
778
|
<span className="text-[11px] text-[var(--danger)]">{labels.problems(problems)}</span>
|
|
@@ -845,7 +846,7 @@ function TextView({ columns, cells, onCells, disabled, mark, labels, labelledBy,
|
|
|
845
846
|
<p className="mt-1 px-1 text-[11px] text-[var(--text-muted)]">
|
|
846
847
|
{columns.map((column) => column.label).join(" · ")}
|
|
847
848
|
{" — "}
|
|
848
|
-
{labels.points(lines)}
|
|
849
|
+
<bdi>{labels.points(lines)}</bdi>
|
|
849
850
|
</p>
|
|
850
851
|
</div>
|
|
851
852
|
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useState, type ComponentProps, type Ref } from "react";
|
|
2
|
+
import { AmountInput } from "./amount-input";
|
|
3
|
+
|
|
4
|
+
type AmountInputProps = ComponentProps<typeof AmountInput>;
|
|
5
|
+
|
|
6
|
+
export interface MoneyFieldProps
|
|
7
|
+
extends Omit<AmountInputProps, "value" | "onChange" | "onCommit" | "negative" | "onNegativeChange" | "ref"> {
|
|
8
|
+
/** The amount, or `null` for an empty field. */
|
|
9
|
+
value: number | null;
|
|
10
|
+
/**
|
|
11
|
+
* The settled amount — on blur, on Enter, on the numpad's "=" and on a calculator
|
|
12
|
+
* result; never a half-typed "1200+". Rounded to the currency's minor unit (or
|
|
13
|
+
* `digits`) and clamped to `min` / `max`, like {@link AmountInput}. `null` when the
|
|
14
|
+
* field was emptied. Not fired when the settled amount equals `value`, so an
|
|
15
|
+
* Enter-then-blur saves once.
|
|
16
|
+
*/
|
|
17
|
+
onCommit: (value: number | null) => void;
|
|
18
|
+
ref?: Ref<HTMLInputElement>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The number a settled text reads as: `null` for empty, `undefined` for no number
|
|
22
|
+
* (a lone "-" or "."). */
|
|
23
|
+
function parseSettled(text: string): number | null | undefined {
|
|
24
|
+
const t = text.trim();
|
|
25
|
+
if (t === "") return null;
|
|
26
|
+
const n = Number(t);
|
|
27
|
+
return Number.isFinite(n) ? n : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* {@link AmountInput} with a NUMBER in and a number out, for money edited outside a
|
|
32
|
+
* form — a budget row, a table cell (kastlan 52: new-budget-page edited a CHF amount per
|
|
33
|
+
* row with `NumberField` because AmountInput is string-valued and reports every
|
|
34
|
+
* keystroke, "1200+" included, so a number-state row needed its own parse-and-settle
|
|
35
|
+
* glue). This is that glue, the same one `RhfMoneyField` has: the text being typed
|
|
36
|
+
* lives here, and only a settled figure reaches `onCommit`.
|
|
37
|
+
*
|
|
38
|
+
* The currency chip, the calculator, the numpad, the locale's decimal mark, the
|
|
39
|
+
* rounding to the currency's minor unit and `min` / `max` are AmountInput's. A caller-
|
|
40
|
+
* owned sign (`negative`) is not offered: the figure here carries its own minus.
|
|
41
|
+
*
|
|
42
|
+
* ```tsx
|
|
43
|
+
* <MoneyField ariaLabel="Amount" currency="CHF" value={row.amount}
|
|
44
|
+
* onCommit={(amount) => updateRow(row.id, { amount })} />
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function MoneyField({ value, onCommit, ref, ...props }: MoneyFieldProps) {
|
|
48
|
+
const external = value === null ? "" : String(value);
|
|
49
|
+
// The draft is re-read from `value` only when `value` moves away from what the draft
|
|
50
|
+
// already says (a reset, another row's save), so a commit's own echo keeps the
|
|
51
|
+
// typist's spelling ("12.50" stays "12.50" when 12.5 comes back).
|
|
52
|
+
const [draft, setDraft] = useState(external);
|
|
53
|
+
const [seen, setSeen] = useState(value);
|
|
54
|
+
if (seen !== value) {
|
|
55
|
+
setSeen(value);
|
|
56
|
+
if (parseSettled(draft) !== value) setDraft(external);
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<AmountInput
|
|
60
|
+
{...props}
|
|
61
|
+
ref={ref}
|
|
62
|
+
value={draft}
|
|
63
|
+
onChange={setDraft}
|
|
64
|
+
onCommit={(text) => {
|
|
65
|
+
const next = parseSettled(text);
|
|
66
|
+
// No number ("-" alone): back to the stored amount rather than a guess.
|
|
67
|
+
if (next === undefined) {
|
|
68
|
+
setDraft(external);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (next !== value) onCommit(next);
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -210,6 +210,9 @@ export function MultiSelect({
|
|
|
210
210
|
label !== undefined && FIELD_FLOATING_PAD,
|
|
211
211
|
// FIELD_TRIGGER's hover would still light a trigger nothing can open.
|
|
212
212
|
"disabled:hover:bg-[var(--bg-surface-2)]",
|
|
213
|
+
// Dimmed like EntityCombobox's and MultiEntityCombobox's disabled triggers;
|
|
214
|
+
// only the label was, so a disabled MultiSelect read as live (showcase audit).
|
|
215
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
213
216
|
field.isInvalid && FIELD_INVALID,
|
|
214
217
|
)}
|
|
215
218
|
>
|
|
@@ -304,3 +304,17 @@ export function NumberField({
|
|
|
304
304
|
</div>
|
|
305
305
|
);
|
|
306
306
|
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as
|
|
310
|
+
* defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a
|
|
311
|
+
* form-bound field, for the plain one (kastlan 51: a building's construction year in a
|
|
312
|
+
* wizard's local state, an entity id in the document upload spelled the two props out
|
|
313
|
+
* by hand).
|
|
314
|
+
*
|
|
315
|
+
* `??` rather than a spread over the defaults, so a wrapper forwarding its own optional
|
|
316
|
+
* `digits={props.digits}` still gets the preset.
|
|
317
|
+
*/
|
|
318
|
+
export function IntegerField(props: NumberFieldProps) {
|
|
319
|
+
return <NumberField {...props} digits={props.digits ?? 0} calculator={props.calculator ?? false} />;
|
|
320
|
+
}
|
|
@@ -53,7 +53,10 @@ interface NumberInputProps {
|
|
|
53
53
|
/** A {@link FieldHint} "?" on the label's own line, beside the label rather
|
|
54
54
|
* than at the far end of the strip — the far end is where the calculator
|
|
55
55
|
* lives, and a hint placed there landed on top of it (steering-design
|
|
56
|
-
* feedback #48).
|
|
56
|
+
* feedback #48). Plain TEXT (a string or a number) is a caption instead, UNDER
|
|
57
|
+
* the field and attached through `aria-describedby` after the caller's own —
|
|
58
|
+
* the same rule as {@link Select}'s `hint`: the label line has no room for a
|
|
59
|
+
* sentence, and one placed there ran over the label and into the value. */
|
|
57
60
|
hint?: ReactNode;
|
|
58
61
|
/** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
|
|
59
62
|
invalid?: boolean;
|
|
@@ -176,6 +179,10 @@ export function NumberInput({
|
|
|
176
179
|
}: NumberInputProps) {
|
|
177
180
|
const generatedId = useId();
|
|
178
181
|
const fieldId = id ?? generatedId;
|
|
182
|
+
const hintId = useId();
|
|
183
|
+
// Text is a caption under the field; a FieldHint rides the label line. See `hint`.
|
|
184
|
+
const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
|
|
185
|
+
const describedBy = textHint ? (ariaDescribedBy ? `${ariaDescribedBy} ${hintId}` : hintId) : ariaDescribedBy;
|
|
179
186
|
const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
|
|
180
187
|
const labelled = label !== undefined;
|
|
181
188
|
// On phones we suppress the OS keyboard (inputMode="none" below) for our own
|
|
@@ -215,13 +222,13 @@ export function NumberInput({
|
|
|
215
222
|
stepBy(count);
|
|
216
223
|
};
|
|
217
224
|
|
|
218
|
-
|
|
225
|
+
const field = (
|
|
219
226
|
<FloatingField
|
|
220
227
|
className={cn("w-full", className)}
|
|
221
228
|
htmlFor={fieldId}
|
|
222
229
|
label={label}
|
|
223
230
|
srOnlyLabel={asDisplay}
|
|
224
|
-
hint={hint}
|
|
231
|
+
hint={textHint ? undefined : hint}
|
|
225
232
|
>
|
|
226
233
|
<input
|
|
227
234
|
ref={inputRef}
|
|
@@ -248,7 +255,7 @@ export function NumberInput({
|
|
|
248
255
|
}}
|
|
249
256
|
onKeyDown={onKeyDown}
|
|
250
257
|
aria-invalid={invalid || undefined}
|
|
251
|
-
aria-describedby={
|
|
258
|
+
aria-describedby={describedBy}
|
|
252
259
|
aria-required={ariaRequired}
|
|
253
260
|
// The end padding sits last so it always wins over an inputClassName that
|
|
254
261
|
// sets its own px. Both trailing controls can be on at once, so the room
|
|
@@ -301,4 +308,16 @@ export function NumberInput({
|
|
|
301
308
|
)}
|
|
302
309
|
</FloatingField>
|
|
303
310
|
);
|
|
311
|
+
if (!textHint) return field;
|
|
312
|
+
// Outside the field's own `relative` box, as Select's caption is: the calculator
|
|
313
|
+
// and the unit are `inset-y-0` in it and would stretch down over a second line.
|
|
314
|
+
// `className` stays on the field, so adding a caption cannot change what it styles.
|
|
315
|
+
return (
|
|
316
|
+
<div>
|
|
317
|
+
{field}
|
|
318
|
+
<p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
|
|
319
|
+
{hint}
|
|
320
|
+
</p>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
304
323
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
1
2
|
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
|
|
2
3
|
import { cn } from "../lib/cn";
|
|
3
4
|
import { SECTION_LABEL_CLASS } from "./text";
|
|
@@ -168,6 +169,37 @@ export function PageHeader({
|
|
|
168
169
|
// wraps instead: actions that do not fit go to a line of their own, as the app's old
|
|
169
170
|
// `flex-wrap` header did.
|
|
170
171
|
const keepsWords = mobileLayout === "inline" && !truncateTitle;
|
|
172
|
+
// keksdose, after H1: once the actions have wrapped below the title, the phone's
|
|
173
|
+
// header was THREE rows — title / actions / secondary — where the app's old header
|
|
174
|
+
// shared the second row between the month nav and the toggles (~48px of phone
|
|
175
|
+
// height). So the secondary group's full-width basis (G6b: a row of its own) is
|
|
176
|
+
// dropped exactly when the actions already sit below the title; then it joins their
|
|
177
|
+
// row if it fits, and wraps under them if not. While the actions share the title's
|
|
178
|
+
// row, nothing changes: the secondary group never climbs onto the title's row.
|
|
179
|
+
//
|
|
180
|
+
// CSS cannot see "the previous item wrapped", so it is measured — by a
|
|
181
|
+
// ResizeObserver, whose first callback runs on `observe`. It is stable: the
|
|
182
|
+
// secondary group comes after the actions, so its basis cannot move them. No
|
|
183
|
+
// ResizeObserver (jsdom) or no layout keeps the basis, the 0.14.0 layout.
|
|
184
|
+
const measuresWrap = keepsWords && hasSecondary && actions != null;
|
|
185
|
+
const titleRef = useRef<HTMLDivElement>(null);
|
|
186
|
+
const actionsRef = useRef<HTMLDivElement>(null);
|
|
187
|
+
const [actionsWrapped, setActionsWrapped] = useState(false);
|
|
188
|
+
useLayoutEffect(() => {
|
|
189
|
+
const titleBox = titleRef.current;
|
|
190
|
+
const actionsBox = actionsRef.current;
|
|
191
|
+
if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === "undefined") return;
|
|
192
|
+
const observer = new ResizeObserver(() => {
|
|
193
|
+
const t = titleBox.getBoundingClientRect();
|
|
194
|
+
const a = actionsBox.getBoundingClientRect();
|
|
195
|
+
// Unmeasured (display: none, no layout): keep the full-width basis.
|
|
196
|
+
setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);
|
|
197
|
+
});
|
|
198
|
+
observer.observe(titleBox);
|
|
199
|
+
observer.observe(actionsBox);
|
|
200
|
+
if (titleBox.parentElement) observer.observe(titleBox.parentElement);
|
|
201
|
+
return () => observer.disconnect();
|
|
202
|
+
}, [measuresWrap]);
|
|
171
203
|
return (
|
|
172
204
|
<div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
|
|
173
205
|
{breadcrumbs}
|
|
@@ -184,7 +216,7 @@ export function PageHeader({
|
|
|
184
216
|
align,
|
|
185
217
|
)}
|
|
186
218
|
>
|
|
187
|
-
<div className={cn("flex-1", keepsWords ? "min-w-min" : "min-w-0")}>
|
|
219
|
+
<div ref={titleRef} className={cn("flex-1", keepsWords ? "min-w-min" : "min-w-0")}>
|
|
188
220
|
{eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
|
|
189
221
|
<Heading
|
|
190
222
|
className={cn(
|
|
@@ -198,7 +230,10 @@ export function PageHeader({
|
|
|
198
230
|
{description != null && <p className="mt-1 text-sm text-[var(--text-muted)]">{description}</p>}
|
|
199
231
|
</div>
|
|
200
232
|
{actions != null && (
|
|
201
|
-
<div
|
|
233
|
+
<div
|
|
234
|
+
ref={actionsRef}
|
|
235
|
+
className={cn("flex shrink-0 flex-wrap items-center gap-2", hasSecondary && "sm:order-2")}
|
|
236
|
+
>
|
|
202
237
|
{actions}
|
|
203
238
|
</div>
|
|
204
239
|
)}
|
|
@@ -206,7 +241,7 @@ export function PageHeader({
|
|
|
206
241
|
<div
|
|
207
242
|
className={cn(
|
|
208
243
|
"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0",
|
|
209
|
-
wrapsSecondary && "basis-full sm:basis-auto",
|
|
244
|
+
wrapsSecondary && (measuresWrap && actionsWrapped ? "sm:basis-auto" : "basis-full sm:basis-auto"),
|
|
210
245
|
)}
|
|
211
246
|
>
|
|
212
247
|
{secondaryActions}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// "category"`) or real dates (`x.type: "time"`) instead of a number the app had to
|
|
27
27
|
// invent. What each of those does to the zoom is decided in one place,
|
|
28
28
|
// `defaultZoomAxes` in `chart-zoom.tsx`.
|
|
29
|
-
import { useMemo, useRef, useState } from "react";
|
|
29
|
+
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
30
30
|
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
|
31
31
|
import {
|
|
32
32
|
Area,
|
|
@@ -884,6 +884,55 @@ function focusedBarShape(index: number) {
|
|
|
884
884
|
};
|
|
885
885
|
}
|
|
886
886
|
|
|
887
|
+
/** A marker label's size and weight — the one place both the text and its width
|
|
888
|
+
* estimate read them from. */
|
|
889
|
+
const MARKER_LABEL_SIZE = 11;
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* A marker's label, centred over its point but clamped inside the plot. recharts'
|
|
893
|
+
* `position: "top"` centres on the point no matter where it is, so a marker near the
|
|
894
|
+
* plot's end (the last sample, a projection's end point) had half its label cut off by
|
|
895
|
+
* the card — "over the c" on a phone. The width is measured once drawn; the first
|
|
896
|
+
* frame (and a DOM without layout) uses a 0.6em-per-character estimate, which errs wide.
|
|
897
|
+
*/
|
|
898
|
+
function MarkerLabel({
|
|
899
|
+
viewBox,
|
|
900
|
+
value,
|
|
901
|
+
}: {
|
|
902
|
+
viewBox?: { x?: number; y?: number; width?: number };
|
|
903
|
+
value?: ReactNode;
|
|
904
|
+
}) {
|
|
905
|
+
const plot = usePlotArea();
|
|
906
|
+
const ref = useRef<SVGTextElement>(null);
|
|
907
|
+
const [measured, setMeasured] = useState<number>();
|
|
908
|
+
const text = value == null ? "" : String(value);
|
|
909
|
+
useLayoutEffect(() => {
|
|
910
|
+
const el = ref.current;
|
|
911
|
+
if (!el || typeof el.getComputedTextLength !== "function") return;
|
|
912
|
+
const w = el.getComputedTextLength();
|
|
913
|
+
if (w > 0 && w !== measured) setMeasured(w);
|
|
914
|
+
}, [text, measured]);
|
|
915
|
+
if (!viewBox || viewBox.x == null || viewBox.y == null || text === "") return null;
|
|
916
|
+
const cx = viewBox.x + (viewBox.width ?? 0) / 2;
|
|
917
|
+
const half = (measured ?? text.length * MARKER_LABEL_SIZE * 0.6) / 2;
|
|
918
|
+
// The start clamp applied last: a label wider than the plot keeps its start on screen.
|
|
919
|
+
const x = plot ? Math.max(plot.x + half, Math.min(cx, plot.x + plot.width - half)) : cx;
|
|
920
|
+
return (
|
|
921
|
+
<text
|
|
922
|
+
ref={ref}
|
|
923
|
+
x={x}
|
|
924
|
+
y={viewBox.y - 5}
|
|
925
|
+
textAnchor="middle"
|
|
926
|
+
fontSize={MARKER_LABEL_SIZE}
|
|
927
|
+
fontWeight={600}
|
|
928
|
+
fill="var(--text-primary)"
|
|
929
|
+
className="recharts-label"
|
|
930
|
+
>
|
|
931
|
+
{text}
|
|
932
|
+
</text>
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
887
936
|
/**
|
|
888
937
|
* The keyboard's way onto a clickable chart (`onPointClick`): one focusable `<rect>` per
|
|
889
938
|
* bar, or per slot when there are no bars, laid over the plot INSIDE the recharts chart,
|
|
@@ -1350,6 +1399,11 @@ function SeriesPlot({
|
|
|
1350
1399
|
tickLine={false}
|
|
1351
1400
|
axisLine={false}
|
|
1352
1401
|
minTickGap={32}
|
|
1402
|
+
// Where the ticks crowd, drop every Nth rather than recharts' default
|
|
1403
|
+
// `preserveEnd`, which drops whichever single tick collides: a phone's whole-week
|
|
1404
|
+
// axis came out 2 4 6 8 _ 12, a doubled gap that reads as a missing week. Every
|
|
1405
|
+
// other tick, keeping the last, gives 4 8 12 — still an even ruler.
|
|
1406
|
+
interval="equidistantPreserveEnd"
|
|
1353
1407
|
tick={x.ticks === false ? false : undefined}
|
|
1354
1408
|
{...(tickAngle
|
|
1355
1409
|
? { angle: tickAngle, textAnchor: tickAngle < 0 ? "end" : "start" }
|
|
@@ -1563,13 +1617,7 @@ function SeriesPlot({
|
|
|
1563
1617
|
ifOverflow="discard"
|
|
1564
1618
|
label={
|
|
1565
1619
|
marker.label
|
|
1566
|
-
? {
|
|
1567
|
-
value: marker.label,
|
|
1568
|
-
position: "top",
|
|
1569
|
-
fontSize: 11,
|
|
1570
|
-
fontWeight: 600,
|
|
1571
|
-
fill: "var(--text-primary)",
|
|
1572
|
-
}
|
|
1620
|
+
? { value: marker.label, position: "top", content: <MarkerLabel /> }
|
|
1573
1621
|
: undefined
|
|
1574
1622
|
}
|
|
1575
1623
|
/>
|
|
@@ -516,12 +516,17 @@ function FigureRow({
|
|
|
516
516
|
}) {
|
|
517
517
|
const own = figure.tone ?? (tileTone === "neutral" ? undefined : tileTone);
|
|
518
518
|
const svTone = own === "none" ? undefined : own;
|
|
519
|
+
// The LABEL gives way, never the figure. This row used to wrap, so on a narrow tile a
|
|
520
|
+
// long label ("AUG (SIGNED)") dropped its amount onto a line of its own while the
|
|
521
|
+
// next row stayed on one — two layouts in one list, and a figure no longer beside
|
|
522
|
+
// the period it belongs to. Now the label breaks within itself and the amount stays
|
|
523
|
+
// on the label's first line, end-aligned like every other row.
|
|
519
524
|
return (
|
|
520
|
-
<div className="flex
|
|
521
|
-
<dt className="uppercase tracking-wide">{figure.label}</dt>
|
|
525
|
+
<div className="flex items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]">
|
|
526
|
+
<dt className="min-w-0 uppercase tracking-wide [overflow-wrap:anywhere]">{figure.label}</dt>
|
|
522
527
|
<dd
|
|
523
528
|
data-private={priv}
|
|
524
|
-
className={cn("ms-auto whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"])}
|
|
529
|
+
className={cn("ms-auto shrink-0 whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"])}
|
|
525
530
|
>
|
|
526
531
|
{show(figure.value)}
|
|
527
532
|
</dd>
|