@vitavision/ui 0.13.0 → 0.14.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 CHANGED
@@ -126,13 +126,13 @@ keep independent preferences.
126
126
  `DialogClose` · `Disclosure` · `DropdownMenu`, `MenuItem`, `MenuCheckboxItem`, `MenuLabel`,
127
127
  `MenuSeparator` (a non-modal command and toggle menu) · `Callout`, `Empty`, `ErrorBox`,
128
128
  `Skeleton`, `SkeletonRows` · `Progress` (a count out of a total, or indeterminate; `ProgressBar` is
129
- deprecated) · `Field` · `Input`, `NumberInput` (with an optional
129
+ deprecated) · `Breakdown`, `breakdownText` (counts by verdict as one thin stacked bar) · `Field` · `Input`, `NumberInput` (with an optional
130
130
  `unit` written in the field; number-valued with `onValueChange`), `Textarea` (optionally growing
131
131
  with its content between `minRows` and `maxRows`) · `HighlightedText`, `findSpans` (a string
132
132
  with its placeholders, tags and terms marked, never parsed as HTML) · `VectorInput`,
133
133
  `PoseInput` · `Kbd` · `Listbox` (single choice among options that render their own content) ·
134
134
  `PageHeader`, `Panel`, `ReadoutStrip`, `Section` · `Popover`, `PopoverClose` ·
135
- `SegmentedControl` · `Select` · `Slider` · `Table` (with `windowed` for thousands of rows) ·
135
+ `SegmentedControl` · `Select` · `Slider` · `Table` (with `windowed` for thousands of rows, and `selectionFollowsFocus` for keyboard selection) ·
136
136
  `Checkbox`, `Switch` · `ToggleChip` · `Toaster`, `toast()` (notifications from anywhere; mount one `<Toaster />` near the root) ·
137
137
  `InfoHint`, `Tooltip`, `TooltipProvider` ·
138
138
  `ThemeToggle` · plus `cn`, `focusRing`, `focusRingInset`, and `formatNumber` / `parseNumber`
package/dist/index.d.ts CHANGED
@@ -154,6 +154,69 @@ export declare function CountRun({ counts, className }: {
154
154
  className?: string | undefined;
155
155
  }): import("react").JSX.Element;
156
156
  //#endregion
157
+ //#region src/components/ui/Breakdown.d.ts
158
+ /**
159
+ * The colour of a `Breakdown` segment: a verdict (`normal`, `warn`, `defect`) for things the app
160
+ * has judged, or `neutral` for a category that is no verdict (skipped, not applicable).
161
+ */
162
+ type BreakdownTone = "normal" | "warn" | "defect" | "neutral";
163
+ /** One category of a `Breakdown`: how many, in which colour, and what a reader calls them. */
164
+ interface BreakdownSegment {
165
+ /** How many. Negative and non-finite values count as 0, and a segment of 0 is left out. */
166
+ value: number;
167
+ /** The colour, by meaning. */
168
+ tone: BreakdownTone;
169
+ /** What the count is, as it reads after the number: `"done"` gives "412 done". Unique among the segments. */
170
+ label: string;
171
+ }
172
+ /** Options of `breakdownText`, the same as the `Breakdown` props of the same names. */
173
+ interface BreakdownTextOptions {
174
+ /** The whole. Defaults to the sum of the segments; a total below the sum counts as the sum. */
175
+ total?: number | undefined;
176
+ /** What the rest of the total is, as it reads after the number: `"to do"` gives "131 to do". */
177
+ remainderLabel?: string | undefined;
178
+ }
179
+ /**
180
+ * The counts of a breakdown in words: `"412 done, 20 to check, 3 problems, 131 to do of 566"`.
181
+ *
182
+ * It is the default accessible name of `Breakdown`, and the text for a tooltip or a label printed
183
+ * beside the bar. Segments of 0 are left out; the rest of the total is named only with a
184
+ * `remainderLabel`. With nothing to count it reads `"0 of 566"`.
185
+ *
186
+ * @param segments - The categories, in order.
187
+ * @param options - The `total` and the `remainderLabel`.
188
+ * @returns The text.
189
+ */
190
+ export declare function breakdownText(segments: readonly BreakdownSegment[], options?: BreakdownTextOptions): string;
191
+ /** Props of `Breakdown`: the segments and their total, plus the `<div>` props. */
192
+ type BreakdownProps = Omit<ComponentProps<"div">, "children" | "role"> & {
193
+ /** The categories, drawn left to right in this order, each as wide as its share of the total. */
194
+ segments: readonly BreakdownSegment[];
195
+ /**
196
+ * The whole. Defaults to the sum of the segments; the rest is drawn as empty track. A total
197
+ * below the sum counts as the sum.
198
+ */
199
+ total?: number | undefined;
200
+ /** What the empty track counts, for the accessible name: `"to do"` gives "131 to do". */
201
+ remainderLabel?: string | undefined;
202
+ };
203
+ /**
204
+ * A thin bar of counts by category: 412 done, 20 to check and 3 problems out of 566, with the
205
+ * rest as empty track. Segments are drawn in the order given, each as wide as its share of the
206
+ * total (a non-zero count is never narrower than 2 px); segments of 0 are left out.
207
+ *
208
+ * Its colours are verdicts, so use it only for things the app has judged — never for a share of
209
+ * a continuous quantity or for series side by side (use `Progress` or a chart).
210
+ *
211
+ * A `role="img"` whose name lists every non-zero count, as `breakdownText` writes it: "412 done,
212
+ * 20 to check, 3 problems, 131 to do of 566". `aria-label` or `aria-labelledby` replaces it. The track
213
+ * matches `Progress` and follows the density in force. State is exposed as `data-state`
214
+ * (`"empty"` with nothing counted, `"partial"`, or `"complete"` when the segments fill the
215
+ * total), and each segment's tone as its `data-tone`. Width changes ease over 300 ms, and not at
216
+ * all with reduced motion.
217
+ */
218
+ export declare function Breakdown({ segments, total, remainderLabel, className, "aria-label": ariaLabel, ...rest }: BreakdownProps): import("react").JSX.Element;
219
+ //#endregion
157
220
  //#region src/components/ui/Button.d.ts
158
221
  /**
159
222
  * A button's look, by what the action is: `primary` for the one action a screen is for,
@@ -1198,11 +1261,12 @@ interface TableWindowing {
1198
1261
  * show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
1199
1262
  * whatever the order on screen.
1200
1263
  *
1201
- * Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End.
1264
+ * Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End;
1265
+ * with `selectionFollowsFocus` each of those moves also activates the row it lands on.
1202
1266
  * With `windowed` the table scrolls itself (vertically, under a sticky header), renders only the
1203
1267
  * rows in view, and uses a fixed layout: give columns a `width` where the content needs it.
1204
1268
  */
1205
- export declare function Table<Row>({ columns, rows, rowKey, empty, caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort, onSortChange, manualSort, windowed }: {
1269
+ export declare function Table<Row>({ columns, rows, rowKey, empty, caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort, onSortChange, manualSort, windowed, selectionFollowsFocus }: {
1206
1270
  /** The columns, in order. */
1207
1271
  columns: Column<Row>[];
1208
1272
  /** The rows, in their own order: the order shown while the table is not sorted. */
@@ -1251,6 +1315,15 @@ export declare function Table<Row>({ columns, rows, rowKey, empty, caption, clas
1251
1315
  * callbacks and keyboard navigation work as without it.
1252
1316
  */
1253
1317
  windowed?: TableWindowing | undefined;
1318
+ /**
1319
+ * Activate a row as soon as the keyboard moves to it: each arrow key, Page Up / Page Down,
1320
+ * Home or End that lands on another row also calls `onRowClick` for that row, with the key
1321
+ * press as the event — selection follows focus, as in a list where the selected item shows
1322
+ * its detail beside the list. Clicks, Enter and Space work as without it. Leave it off when
1323
+ * activating a row is costly or navigates away: then a reader can move through the rows and
1324
+ * choose one. Needs `onRowClick`.
1325
+ */
1326
+ selectionFollowsFocus?: boolean | undefined;
1254
1327
  }): import("react").JSX.Element;
1255
1328
  //#endregion
1256
1329
  //#region src/components/ui/Textarea.d.ts
@@ -1586,5 +1659,5 @@ export declare function Tabs<Id extends string>({ items, active, onSelect, label
1586
1659
  className?: string | undefined;
1587
1660
  }): import("react").JSX.Element;
1588
1661
  //#endregion
1589
- export type { BackLink, ButtonSize, ButtonVariant, CalloutTone, Column, Density, DropdownMenuProps, HighlightKind, HighlightRule, HighlightSpan, HighlightedTextProps, ListboxOption, ListboxOptionState, ListboxProps, MeasureTone, MenuCheckboxItemProps, MenuItemProps, NumberInputProps, PopoverProps, PoseInputProps, PoseValue, ProgressProps, Quaternion, ReadoutItem, RotationView, SelectOption, SortDirection, TabItem, TableSort, TableWindowing, TextareaProps, ThemeChoice, ToastOptions, ToastRecord, ToastStore, ToastTone, ToasterProps, Tone, Vec3, VectorInputProps };
1662
+ export type { BackLink, BreakdownProps, BreakdownSegment, BreakdownTextOptions, BreakdownTone, ButtonSize, ButtonVariant, CalloutTone, Column, Density, DropdownMenuProps, HighlightKind, HighlightRule, HighlightSpan, HighlightedTextProps, ListboxOption, ListboxOptionState, ListboxProps, MeasureTone, MenuCheckboxItemProps, MenuItemProps, NumberInputProps, PopoverProps, PoseInputProps, PoseValue, ProgressProps, Quaternion, ReadoutItem, RotationView, SelectOption, SortDirection, TabItem, TableSort, TableWindowing, TextareaProps, ThemeChoice, ToastOptions, ToastRecord, ToastStore, ToastTone, ToasterProps, Tone, Vec3, VectorInputProps };
1590
1663
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -246,7 +246,7 @@ function ThemeToggle({ storageKey = DEFAULT_THEME_STORAGE_KEY, className }) {
246
246
  }
247
247
  //#endregion
248
248
  //#region src/components/ui/Badge.tsx
249
- const TONE_CLASSES = {
249
+ const TONE_CLASSES$1 = {
250
250
  neutral: "bg-raised text-fg-muted ring-line",
251
251
  normal: "bg-normal/12 text-normal ring-normal/25",
252
252
  defect: "bg-defect/12 text-defect ring-defect/25",
@@ -271,7 +271,7 @@ const DOT_CLASSES = {
271
271
  function Badge({ tone = "neutral", className, children }) {
272
272
  return /* @__PURE__ */ jsx("span", {
273
273
  "data-tone": tone,
274
- className: cn("inline-flex items-center gap-1 rounded-control px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset", TONE_CLASSES[tone], className),
274
+ className: cn("inline-flex items-center gap-1 rounded-control px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset", TONE_CLASSES$1[tone], className),
275
275
  children
276
276
  });
277
277
  }
@@ -346,6 +346,93 @@ function DensityProvider({ value, children }) {
346
346
  });
347
347
  }
348
348
  //#endregion
349
+ //#region src/components/ui/Breakdown.tsx
350
+ /** Counts written the same on the server and in every browser: `2,556`, `0.5`. */
351
+ const count$1 = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
352
+ const TONE_CLASSES = {
353
+ normal: "bg-normal",
354
+ warn: "bg-warn",
355
+ defect: "bg-defect",
356
+ neutral: "bg-fg-subtle"
357
+ };
358
+ /** A count as drawn: 0 for anything that is not a finite, positive number. */
359
+ const amount = (value) => Number.isFinite(value) && value > 0 ? value : 0;
360
+ /** The segments that are drawn, the whole, and the rest of it. */
361
+ function measure(segments, total) {
362
+ const shown = segments.filter((segment) => amount(segment.value) > 0);
363
+ const sum = shown.reduce((acc, segment) => acc + amount(segment.value), 0);
364
+ const whole = total !== void 0 && Number.isFinite(total) && total > sum ? total : sum;
365
+ return {
366
+ shown,
367
+ sum,
368
+ whole,
369
+ rest: whole - sum
370
+ };
371
+ }
372
+ /**
373
+ * The counts of a breakdown in words: `"412 done, 20 to check, 3 problems, 131 to do of 566"`.
374
+ *
375
+ * It is the default accessible name of `Breakdown`, and the text for a tooltip or a label printed
376
+ * beside the bar. Segments of 0 are left out; the rest of the total is named only with a
377
+ * `remainderLabel`. With nothing to count it reads `"0 of 566"`.
378
+ *
379
+ * @param segments - The categories, in order.
380
+ * @param options - The `total` and the `remainderLabel`.
381
+ * @returns The text.
382
+ */
383
+ function breakdownText(segments, options = {}) {
384
+ const { shown, whole, rest } = measure(segments, options.total);
385
+ const parts = shown.map((segment) => `${count$1.format(amount(segment.value))} ${segment.label}`);
386
+ if (options.remainderLabel !== void 0 && rest > 0) parts.push(`${count$1.format(rest)} ${options.remainderLabel}`);
387
+ return `${parts.length > 0 ? parts.join(", ") : "0"} of ${count$1.format(whole)}`;
388
+ }
389
+ /**
390
+ * A thin bar of counts by category: 412 done, 20 to check and 3 problems out of 566, with the
391
+ * rest as empty track. Segments are drawn in the order given, each as wide as its share of the
392
+ * total (a non-zero count is never narrower than 2 px); segments of 0 are left out.
393
+ *
394
+ * Its colours are verdicts, so use it only for things the app has judged — never for a share of
395
+ * a continuous quantity or for series side by side (use `Progress` or a chart).
396
+ *
397
+ * A `role="img"` whose name lists every non-zero count, as `breakdownText` writes it: "412 done,
398
+ * 20 to check, 3 problems, 131 to do of 566". `aria-label` or `aria-labelledby` replaces it. The track
399
+ * matches `Progress` and follows the density in force. State is exposed as `data-state`
400
+ * (`"empty"` with nothing counted, `"partial"`, or `"complete"` when the segments fill the
401
+ * total), and each segment's tone as its `data-tone`. Width changes ease over 300 ms, and not at
402
+ * all with reduced motion.
403
+ */
404
+ function Breakdown({ segments, total, remainderLabel, className, "aria-label": ariaLabel, ...rest }) {
405
+ const density = useDensity();
406
+ const { shown, sum, rest: remainder } = measure(segments, total);
407
+ const name = ariaLabel ?? (rest["aria-labelledby"] ? void 0 : breakdownText(segments, {
408
+ total,
409
+ remainderLabel
410
+ }));
411
+ return /* @__PURE__ */ jsxs("div", {
412
+ role: "img",
413
+ "aria-label": name,
414
+ "data-state": sum === 0 ? "empty" : remainder > 0 ? "partial" : "complete",
415
+ ...rest,
416
+ className: cn("w-full overflow-hidden rounded-full bg-line", byDensity(density, "h-1.5", "h-1"), className),
417
+ style: {
418
+ display: "flex",
419
+ gap: 1,
420
+ ...rest.style
421
+ },
422
+ children: [shown.map((segment) => /* @__PURE__ */ jsx("div", {
423
+ "data-tone": segment.tone,
424
+ className: cn("h-full transition-[flex-grow] duration-300", TONE_CLASSES[segment.tone]),
425
+ style: {
426
+ flex: `${amount(segment.value)} 0 0`,
427
+ minWidth: 2
428
+ }
429
+ }, segment.label)), remainder > 0 && /* @__PURE__ */ jsx("div", {
430
+ className: "h-full transition-[flex-grow] duration-300",
431
+ style: { flex: `${remainder} 0 0` }
432
+ })]
433
+ });
434
+ }
435
+ //#endregion
349
436
  //#region src/components/ui/Button.tsx
350
437
  const VARIANT_CLASSES = {
351
438
  primary: "bg-signal text-signal-fg hover:bg-signal-strong",
@@ -2100,11 +2187,12 @@ const SORT_ICONS = {
2100
2187
  * show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
2101
2188
  * whatever the order on screen.
2102
2189
  *
2103
- * Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End.
2190
+ * Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End;
2191
+ * with `selectionFollowsFocus` each of those moves also activates the row it lands on.
2104
2192
  * With `windowed` the table scrolls itself (vertically, under a sticky header), renders only the
2105
2193
  * rows in view, and uses a fixed layout: give columns a `width` where the content needs it.
2106
2194
  */
2107
- function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort = null, onSortChange, manualSort = false, windowed }) {
2195
+ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort = null, onSortChange, manualSort = false, windowed, selectionFollowsFocus = false }) {
2108
2196
  const density = useDensity();
2109
2197
  const [ownSort, setOwnSort] = useState(defaultSort);
2110
2198
  const controlled = controlledSort !== void 0;
@@ -2143,10 +2231,12 @@ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, classN
2143
2231
  after: 0
2144
2232
  };
2145
2233
  const isWindowed = windowed !== void 0;
2234
+ const hasRows = rows.length > 0;
2146
2235
  useLayoutEffect(() => {
2147
2236
  const scroller = scrollRef.current;
2148
- if (!isWindowed || !scroller || typeof ResizeObserver === "undefined") return;
2237
+ if (!isWindowed || !hasRows || !scroller || typeof ResizeObserver === "undefined") return;
2149
2238
  const measure = () => {
2239
+ if (!scroller.isConnected) return;
2150
2240
  const next = {
2151
2241
  viewport: scroller.clientHeight,
2152
2242
  header: headRef.current?.offsetHeight ?? 0
@@ -2154,10 +2244,11 @@ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, classN
2154
2244
  setMeasured((last) => last?.viewport === next.viewport && last.header === next.header ? last : next);
2155
2245
  };
2156
2246
  measure();
2247
+ setScrollTop(scroller.scrollTop);
2157
2248
  const observer = new ResizeObserver(measure);
2158
2249
  observer.observe(scroller);
2159
2250
  return () => observer.disconnect();
2160
- }, [isWindowed]);
2251
+ }, [isWindowed, hasRows]);
2161
2252
  useLayoutEffect(() => {
2162
2253
  const target = pendingFocus.current;
2163
2254
  if (target === null) return;
@@ -2166,11 +2257,18 @@ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, classN
2166
2257
  pendingFocus.current = null;
2167
2258
  row.focus({ preventScroll: isWindowed });
2168
2259
  });
2169
- /** Moves focus from the row at `position` for a navigation key; `true` when the key was one. */
2170
- const moveFocus = (key, position) => {
2260
+ /**
2261
+ * Moves focus from the row at `position` for a navigation key, activating the new row when
2262
+ * selection follows focus; `true` when the key was one.
2263
+ */
2264
+ const moveFocus = (event, position) => {
2171
2265
  const page = windowed ? Math.floor((viewportHeight - headerHeight) / rowHeight) : 10;
2172
- const target = navigate(key, position, order.length, page);
2266
+ const target = navigate(event.key, position, order.length, page);
2173
2267
  if (target === null) return false;
2268
+ if (selectionFollowsFocus && onRowClick && target !== position) {
2269
+ const index = order[target];
2270
+ onRowClick(rows[index], index, event);
2271
+ }
2174
2272
  pendingFocus.current = target;
2175
2273
  const scroller = scrollRef.current;
2176
2274
  if (windowed && scroller) {
@@ -2183,9 +2281,9 @@ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, classN
2183
2281
  setScrollTop(scroller.scrollTop);
2184
2282
  }
2185
2283
  const row = bodyRef.current?.querySelector(`tr[data-position="${target}"]`);
2186
- if (row && !windowed) {
2284
+ if (row) {
2187
2285
  pendingFocus.current = null;
2188
- row.focus();
2286
+ row.focus({ preventScroll: windowed !== void 0 });
2189
2287
  }
2190
2288
  return true;
2191
2289
  };
@@ -2278,7 +2376,7 @@ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, classN
2278
2376
  if (event.key === "Enter" || event.key === " ") {
2279
2377
  event.preventDefault();
2280
2378
  onRowClick(row, index, event);
2281
- } else if (event.target === event.currentTarget && moveFocus(event.key, position)) event.preventDefault();
2379
+ } else if (event.target === event.currentTarget && moveFocus(event, position)) event.preventDefault();
2282
2380
  } : void 0,
2283
2381
  onPointerEnter: onRowHover ? () => onRowHover(row, index) : void 0,
2284
2382
  onPointerLeave: onRowHover ? () => onRowHover(null, null) : void 0,
@@ -2754,6 +2852,6 @@ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
2754
2852
  });
2755
2853
  }
2756
2854
  //#endregion
2757
- export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, DropdownMenu, Empty, ErrorBox, Field, HighlightedText, InfoHint, Input, Kbd, Listbox, MenuCheckboxItem, MenuItem, MenuLabel, MenuSeparator, NumberInput, PageHeader, Panel, Popover, PopoverClose, PoseInput, Progress, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, Toaster, ToggleChip, Tooltip, TooltipProvider, VectorInput, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, findSpans, focusRing, focusRingInset, formatNumber, initTheme, inputClasses, parseNumber, progressText, readThemeChoice, resolveTheme, setThemeChoice, sortRows, toast, toneColor, useControlHeight, useDensity };
2855
+ export { Badge, Breakdown, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, DropdownMenu, Empty, ErrorBox, Field, HighlightedText, InfoHint, Input, Kbd, Listbox, MenuCheckboxItem, MenuItem, MenuLabel, MenuSeparator, NumberInput, PageHeader, Panel, Popover, PopoverClose, PoseInput, Progress, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, Toaster, ToggleChip, Tooltip, TooltipProvider, VectorInput, breakdownText, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, findSpans, focusRing, focusRingInset, formatNumber, initTheme, inputClasses, parseNumber, progressText, readThemeChoice, resolveTheme, setThemeChoice, sortRows, toast, toneColor, useControlHeight, useDensity };
2758
2856
 
2759
2857
  //# sourceMappingURL=index.js.map