@jcsoftdev/ui 0.1.4 → 0.1.6

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 (67) hide show
  1. package/README.md +85 -2
  2. package/dist/atoms/button/button.d.ts +8 -4
  3. package/dist/atoms/button/button.js +16 -6
  4. package/dist/atoms/checkbox/checkbox.js +4 -1
  5. package/dist/atoms/chip/chip.d.ts +7 -3
  6. package/dist/atoms/chip/chip.js +22 -4
  7. package/dist/atoms/internal/status-icons.d.ts +4 -0
  8. package/dist/atoms/internal/status-icons.js +15 -0
  9. package/dist/atoms/radio-group/radio-group.js +1 -1
  10. package/dist/atoms/switch/switch.js +4 -1
  11. package/dist/charts/gauge-chart/gauge-chart.d.ts +1 -1
  12. package/dist/charts/gauge-chart/gauge-chart.js +6 -5
  13. package/dist/containers/banner/banner.d.ts +5 -3
  14. package/dist/containers/banner/banner.js +11 -5
  15. package/dist/containers/confirm-action/confirm-action.d.ts +17 -0
  16. package/dist/containers/confirm-action/confirm-action.js +71 -0
  17. package/dist/containers/confirm-action/index.d.ts +2 -0
  18. package/dist/containers/confirm-action/index.js +1 -0
  19. package/dist/containers/confirm-action/model.d.ts +14 -0
  20. package/dist/containers/confirm-action/model.js +12 -0
  21. package/dist/containers/index.d.ts +2 -0
  22. package/dist/containers/index.js +2 -0
  23. package/dist/containers/responsive-list/index.d.ts +1 -0
  24. package/dist/containers/responsive-list/index.js +1 -0
  25. package/dist/containers/responsive-list/responsive-list.d.ts +24 -0
  26. package/dist/containers/responsive-list/responsive-list.js +69 -0
  27. package/dist/forms/field/field.d.ts +5 -2
  28. package/dist/forms/field/field.js +11 -2
  29. package/dist/i18n/index.d.ts +2 -0
  30. package/dist/i18n/index.js +2 -0
  31. package/dist/i18n/strings.d.ts +42 -0
  32. package/dist/i18n/strings.js +31 -0
  33. package/dist/i18n/ui-strings-provider.d.ts +11 -0
  34. package/dist/i18n/ui-strings-provider.js +20 -0
  35. package/dist/overlays/dialog/dialog.js +5 -3
  36. package/dist/overlays/drawer/drawer.js +5 -3
  37. package/dist/states/error-state/error-state.d.ts +8 -3
  38. package/dist/states/error-state/error-state.js +19 -5
  39. package/dist/states/skeleton/skeleton.js +5 -2
  40. package/package.json +1 -1
  41. package/scripts/build-theme.mjs +11 -5
  42. package/src/atoms/button/button.tsx +27 -5
  43. package/src/atoms/checkbox/checkbox.tsx +4 -1
  44. package/src/atoms/chip/chip.tsx +28 -3
  45. package/src/atoms/internal/status-icons.tsx +17 -0
  46. package/src/atoms/radio-group/radio-group.tsx +1 -1
  47. package/src/atoms/switch/switch.tsx +4 -1
  48. package/src/charts/gauge-chart/gauge-chart.tsx +10 -6
  49. package/src/containers/banner/banner.tsx +18 -10
  50. package/src/containers/confirm-action/confirm-action.tsx +92 -0
  51. package/src/containers/confirm-action/index.ts +2 -0
  52. package/src/containers/confirm-action/model.ts +17 -0
  53. package/src/containers/index.ts +2 -0
  54. package/src/containers/responsive-list/index.ts +1 -0
  55. package/src/containers/responsive-list/responsive-list.tsx +127 -0
  56. package/src/forms/field/field.tsx +11 -3
  57. package/src/i18n/index.ts +2 -0
  58. package/src/i18n/strings.ts +44 -0
  59. package/src/i18n/ui-strings-provider.tsx +27 -0
  60. package/src/overlays/dialog/dialog.tsx +5 -3
  61. package/src/overlays/drawer/drawer.tsx +5 -3
  62. package/src/states/error-state/error-state.tsx +30 -7
  63. package/src/states/skeleton/skeleton.tsx +6 -2
  64. package/styles/Theme.kt +7 -0
  65. package/styles/theme.css +4 -0
  66. package/styles/tokens.css +24 -0
  67. package/tokens/default.json +34 -0
@@ -41,7 +41,7 @@ export interface GaugeChartProps {
41
41
  variant?: "semi" | "three-quarter";
42
42
  /** Filled arc (`progress`) or a pointer over the bands (`needle`). */
43
43
  mode?: "progress" | "needle";
44
- /** Colored bands. With `progress` they sit behind the arc and the arc takes the color of the band it is in. */
44
+ /** Colored bands. With `progress` the track stays neutral, the arc takes the color of the band it is in, and a band shows only while hovered or focused. */
45
45
  ranges?: readonly GaugeRange[];
46
46
  height?: number | "fill";
47
47
  /** Width / height ratio; sizes the drawing from the container width when `height` is not "fill". */
@@ -406,7 +406,7 @@ function GaugePlot({
406
406
  </defs>
407
407
  ) : null}
408
408
 
409
- {bands.length === 0 ? (
409
+ {bands.length === 0 || !needle ? (
410
410
  <path
411
411
  d={arcLine(cx, cy, mid, a0 + capAngle, a1 - capAngle)}
412
412
  fill="none"
@@ -414,7 +414,8 @@ function GaugePlot({
414
414
  strokeLinecap={roundedCaps ? "round" : "butt"}
415
415
  style={{ stroke: "var(--chart-grid)" }}
416
416
  />
417
- ) : (
417
+ ) : null}
418
+ {bands.length > 0 ? (
418
419
  <g mask={roundedCaps ? `url(#${uid}-track)` : undefined}>
419
420
  {bandPaths.map((path, index) => {
420
421
  const item = bands[index];
@@ -426,15 +427,18 @@ function GaugePlot({
426
427
  d={path}
427
428
  width={band}
428
429
  color={rangeColor(item)}
429
- opacity={needle ? 0.92 : 0.26}
430
- dimmed={focused != null && focused !== index}
430
+ // In progress mode the bands behind the arc would show as a
431
+ // patchwork of tints next to it, so they stay invisible hit areas
432
+ // and only appear while explored.
433
+ opacity={needle ? 0.92 : focused === index ? 0.32 : 0}
434
+ dimmed={needle && focused != null && focused !== index}
431
435
  onEnter={(event) => nav.hover(index, event)}
432
436
  onLeave={nav.leave}
433
437
  />
434
438
  );
435
439
  })}
436
440
  </g>
437
- )}
441
+ ) : null}
438
442
 
439
443
  {!needle && fraction > 0 ? (
440
444
  <path
@@ -1,36 +1,39 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { statusIcon } from "../../atoms/internal/status-icons.js";
2
3
  import { cn } from "../../utils/cn.js";
3
4
 
4
5
  export type BannerTone = "info" | "success" | "warning" | "danger";
5
6
 
6
7
  const toneClasses: Record<BannerTone, string> = {
7
- info: "bg-accent-soft",
8
+ info: "bg-info-soft",
8
9
  success: "bg-success-soft",
9
10
  warning: "bg-warning-soft",
10
11
  danger: "bg-danger-soft",
11
12
  };
12
13
 
13
14
  const toneIconClasses: Record<BannerTone, string> = {
14
- info: "text-accent-ink",
15
+ info: "text-info",
15
16
  success: "text-success",
16
17
  warning: "text-warning",
17
18
  danger: "text-danger",
18
19
  };
19
20
 
20
21
  /**
21
- * `info`/`success` are non-urgent notices (`role=status`, polite live region).
22
- * `warning`/`danger` need attention (`role=alert`, assertive live region).
22
+ * Only `danger` interrupts (`role=alert`, assertive live region); every other tone is a polite
23
+ * `role=status` notice.
23
24
  */
24
25
  const toneRole: Record<BannerTone, "status" | "alert"> = {
25
26
  info: "status",
26
27
  success: "status",
27
- warning: "alert",
28
+ warning: "status",
28
29
  danger: "alert",
29
30
  };
30
31
 
31
- export interface BannerProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
32
+ export interface BannerProps extends Omit<ComponentPropsWithoutRef<"div">, "role" | "title"> {
32
33
  tone?: BannerTone;
33
- /** Decorative icon on the left; marked `aria-hidden`. */
34
+ /** Bold first line above the content. */
35
+ title?: ReactNode;
36
+ /** Decorative icon on the left; marked `aria-hidden`. Defaults to a glyph for the tone; `null` hides it. */
34
37
  icon?: ReactNode;
35
38
  /** Actions on the right, e.g. a retry button. */
36
39
  action?: ReactNode;
@@ -38,12 +41,14 @@ export interface BannerProps extends Omit<ComponentPropsWithoutRef<"div">, "role
38
41
 
39
42
  export function Banner({
40
43
  tone = "info",
44
+ title,
41
45
  icon,
42
46
  action,
43
47
  className,
44
48
  children,
45
49
  ...props
46
50
  }: BannerProps) {
51
+ const resolvedIcon = icon === undefined ? statusIcon(tone) : icon;
47
52
  return (
48
53
  <div
49
54
  role={toneRole[tone]}
@@ -54,12 +59,15 @@ export function Banner({
54
59
  )}
55
60
  {...props}
56
61
  >
57
- {icon ? (
62
+ {resolvedIcon ? (
58
63
  <span aria-hidden="true" className={cn("mt-0.5 shrink-0", toneIconClasses[tone])}>
59
- {icon}
64
+ {resolvedIcon}
60
65
  </span>
61
66
  ) : null}
62
- <div className="min-w-0 flex-[1_1_14rem] break-words">{children}</div>
67
+ <div className="min-w-0 flex-[1_1_14rem] break-words">
68
+ {title ? <p className="font-semibold">{title}</p> : null}
69
+ {children}
70
+ </div>
63
71
  {action ? (
64
72
  <div className="ml-auto flex shrink-0 flex-wrap items-center gap-1.5">{action}</div>
65
73
  ) : null}
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ import { Button, type ButtonVariant } from "../../atoms/button/button.js";
5
+ import { useUiStrings } from "../../i18n/index.js";
6
+ import { cn } from "../../utils/cn.js";
7
+ import { Banner } from "../banner/banner.js";
8
+ import { type ConfirmResult, runConfirm } from "./model.js";
9
+
10
+ export interface ConfirmActionProps {
11
+ /** Trigger label, a verb in the user's words ("Cancel order"). */
12
+ label: string;
13
+ /** What will happen, said plainly, shown before the user commits. */
14
+ consequence: string;
15
+ /** Label of the destructive button. Defaults to the localized "Confirm". */
16
+ confirmLabel?: string;
17
+ cancelLabel?: string;
18
+ /** Runs on confirm. Return `{ ok: false, error }` or throw to keep the step open with a message. */
19
+ onConfirm: () => Promise<ConfirmResult> | Promise<void>;
20
+ triggerVariant?: ButtonVariant;
21
+ className?: string;
22
+ }
23
+
24
+ /** Inline two-step confirmation for a destructive action; no modal, no lost context. */
25
+ export function ConfirmAction({
26
+ label,
27
+ consequence,
28
+ confirmLabel,
29
+ cancelLabel,
30
+ onConfirm,
31
+ triggerVariant = "secondary",
32
+ className,
33
+ }: ConfirmActionProps) {
34
+ const strings = useUiStrings();
35
+ const consequenceId = useId();
36
+ const triggerRef = useRef<HTMLButtonElement>(null);
37
+ const wasConfirming = useRef(false);
38
+ const [confirming, setConfirming] = useState(false);
39
+ const [busy, setBusy] = useState(false);
40
+ const [failure, setFailure] = useState<string | null>(null);
41
+
42
+ useEffect(() => {
43
+ if (wasConfirming.current && !confirming) triggerRef.current?.focus();
44
+ wasConfirming.current = confirming;
45
+ }, [confirming]);
46
+
47
+ async function confirm(): Promise<void> {
48
+ setBusy(true);
49
+ setFailure(null);
50
+ const outcome = await runConfirm(onConfirm, strings.actionFailed);
51
+ setBusy(false);
52
+ if (outcome.status === "failed") setFailure(outcome.message);
53
+ else setConfirming(false);
54
+ }
55
+
56
+ if (!confirming) {
57
+ return (
58
+ <div className={cn("flex flex-col gap-2", className)}>
59
+ {failure === null ? null : <Banner tone="danger">{failure}</Banner>}
60
+ <Button ref={triggerRef} variant={triggerVariant} onClick={() => setConfirming(true)}>
61
+ {label}
62
+ </Button>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <fieldset
69
+ aria-describedby={consequenceId}
70
+ className={cn("m-0 flex min-w-0 flex-col gap-3 border-0 p-0", className)}
71
+ >
72
+ <legend className="sr-only">{label}</legend>
73
+ <p id={consequenceId} className="text-body text-ink">
74
+ {consequence}
75
+ </p>
76
+ {failure === null ? null : <Banner tone="danger">{failure}</Banner>}
77
+ <div className="flex flex-wrap gap-2">
78
+ <Button
79
+ variant="danger"
80
+ loading={busy}
81
+ loadingLabel={strings.processing}
82
+ onClick={() => void confirm()}
83
+ >
84
+ {confirmLabel ?? strings.confirm}
85
+ </Button>
86
+ <Button variant="secondary" disabled={busy} autoFocus onClick={() => setConfirming(false)}>
87
+ {cancelLabel ?? strings.cancel}
88
+ </Button>
89
+ </div>
90
+ </fieldset>
91
+ );
92
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./confirm-action.js";
2
+ export type { ConfirmResult } from "./model.js";
@@ -0,0 +1,17 @@
1
+ export type ConfirmResult = { ok: true } | { ok: false; error: string };
2
+
3
+ export type ConfirmOutcome = { status: "done" } | { status: "failed"; message: string };
4
+
5
+ /** Runs the confirmed action and folds a returned failure or a thrown error into one outcome. */
6
+ export async function runConfirm(
7
+ onConfirm: () => Promise<ConfirmResult> | Promise<void>,
8
+ fallbackMessage: string,
9
+ ): Promise<ConfirmOutcome> {
10
+ try {
11
+ const result = (await onConfirm()) as ConfirmResult | undefined;
12
+ if (result !== undefined && !result.ok) return { status: "failed", message: result.error };
13
+ return { status: "done" };
14
+ } catch {
15
+ return { status: "failed", message: fallbackMessage };
16
+ }
17
+ }
@@ -5,6 +5,7 @@ export * from "./carousel/index.js";
5
5
  export * from "./cell/index.js";
6
6
  export * from "./cols/index.js";
7
7
  export * from "./comment/index.js";
8
+ export * from "./confirm-action/index.js";
8
9
  export * from "./data-grid/index.js";
9
10
  export * from "./description-list/index.js";
10
11
  export * from "./facts/index.js";
@@ -18,6 +19,7 @@ export * from "./kpi/index.js";
18
19
  export * from "./kpi-group/index.js";
19
20
  export * from "./page-header/index.js";
20
21
  export * from "./resizable/index.js";
22
+ export * from "./responsive-list/index.js";
21
23
  export * from "./scrolly/index.js";
22
24
  export * from "./settings-layout/index.js";
23
25
  export * from "./settings-row/index.js";
@@ -0,0 +1 @@
1
+ export * from "./responsive-list.js";
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { useUiStrings } from "../../i18n/index.js";
5
+ import { cn } from "../../utils/cn.js";
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeaderCell,
12
+ TableRow,
13
+ } from "../table/table.js";
14
+
15
+ export interface ResponsiveListColumn<T> {
16
+ key: string;
17
+ header: string;
18
+ cell: (item: T) => ReactNode;
19
+ /** Title of the stacked card. Defaults to the first column. */
20
+ primary?: boolean;
21
+ align?: "start" | "end";
22
+ }
23
+
24
+ export interface ResponsiveListProps<T> {
25
+ /** Accessible name of the table and of the card list. */
26
+ caption: string;
27
+ items: readonly T[];
28
+ columns: readonly ResponsiveListColumn<T>[];
29
+ rowKey: (item: T) => string;
30
+ /** Per-row actions: a last table column, and a footer row on each card. */
31
+ renderActions?: (item: T) => ReactNode;
32
+ /** Header of the actions column. Defaults to the localized "Actions". */
33
+ actionsHeader?: string;
34
+ rowClassName?: (item: T) => string | undefined;
35
+ className?: string;
36
+ }
37
+
38
+ /** A table from `md` up and a stack of cards below it, from one column definition. */
39
+ export function ResponsiveList<T>({
40
+ caption,
41
+ items,
42
+ columns,
43
+ rowKey,
44
+ renderActions,
45
+ actionsHeader,
46
+ rowClassName,
47
+ className,
48
+ }: ResponsiveListProps<T>) {
49
+ const strings = useUiStrings();
50
+ const primary = columns.find((column) => column.primary) ?? columns[0];
51
+ const details = columns.filter((column) => column !== primary);
52
+
53
+ return (
54
+ <div className={cn("min-w-0", className)}>
55
+ <div className="hidden rounded-card border-card shadow-card surface-card md:block">
56
+ <Table>
57
+ <caption className="sr-only">{caption}</caption>
58
+ <TableHead>
59
+ <TableRow className="hover:bg-transparent">
60
+ {columns.map((column) => (
61
+ <TableHeaderCell key={column.key} scope="col" alignRight={column.align === "end"}>
62
+ {column.header}
63
+ </TableHeaderCell>
64
+ ))}
65
+ {renderActions ? (
66
+ <TableHeaderCell scope="col" alignRight>
67
+ {actionsHeader ?? strings.actions}
68
+ </TableHeaderCell>
69
+ ) : null}
70
+ </TableRow>
71
+ </TableHead>
72
+ <TableBody>
73
+ {items.map((item) => (
74
+ <TableRow
75
+ key={rowKey(item)}
76
+ data-row-key={rowKey(item)}
77
+ className={rowClassName?.(item)}
78
+ >
79
+ {columns.map((column) => (
80
+ <TableCell key={column.key} alignRight={column.align === "end"}>
81
+ {column.cell(item)}
82
+ </TableCell>
83
+ ))}
84
+ {renderActions ? (
85
+ <TableCell alignRight>
86
+ <div className="flex flex-wrap justify-end gap-2">{renderActions(item)}</div>
87
+ </TableCell>
88
+ ) : null}
89
+ </TableRow>
90
+ ))}
91
+ </TableBody>
92
+ </Table>
93
+ </div>
94
+ <ul aria-label={caption} className="m-0 flex list-none flex-col gap-density p-0 md:hidden">
95
+ {items.map((item) => (
96
+ <li
97
+ key={rowKey(item)}
98
+ data-row-key={rowKey(item)}
99
+ className={cn(
100
+ "flex min-w-0 flex-col gap-3 rounded-card border-card p-card shadow-card surface-card",
101
+ rowClassName?.(item),
102
+ )}
103
+ >
104
+ <div className="min-w-0 font-semibold text-[15px] text-ink [overflow-wrap:anywhere]">
105
+ {primary?.cell(item)}
106
+ </div>
107
+ {details.length > 0 ? (
108
+ <dl className="m-0 flex flex-col gap-2">
109
+ {details.map((column) => (
110
+ <div key={column.key} className="flex items-baseline justify-between gap-4">
111
+ <dt className="shrink-0 text-ink-3 text-meta">{column.header}</dt>
112
+ <dd className="m-0 min-w-0 text-right text-body text-ink [overflow-wrap:anywhere]">
113
+ {column.cell(item)}
114
+ </dd>
115
+ </div>
116
+ ))}
117
+ </dl>
118
+ ) : null}
119
+ {renderActions ? (
120
+ <div className="flex flex-wrap gap-2">{renderActions(item)}</div>
121
+ ) : null}
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ </div>
126
+ );
127
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { cloneElement, isValidElement, type ReactElement, useId } from "react";
4
+ import { useUiStrings } from "../../i18n/index.js";
4
5
  import { cn } from "../../utils/cn.js";
5
6
  import { Label } from "../label/index.js";
6
7
 
@@ -13,13 +14,17 @@ interface ControlProps {
13
14
  export interface FieldProps {
14
15
  label: string;
15
16
  hint?: string;
16
- error?: string;
17
+ /** Message under the control; `null` and `undefined` mean no error. */
18
+ error?: string | null;
19
+ /** Marks the field as optional by appending the localized marker to the label. */
20
+ optional?: boolean;
17
21
  id?: string;
18
22
  children: ReactElement<ControlProps>;
19
23
  className?: string;
20
24
  }
21
25
 
22
- export function Field({ label, hint, error, id, children, className }: FieldProps) {
26
+ export function Field({ label, hint, error, optional, id, children, className }: FieldProps) {
27
+ const strings = useUiStrings();
23
28
  const generatedId = useId();
24
29
  const controlId = id ?? generatedId;
25
30
  const hintId = hint ? `${controlId}-hint` : undefined;
@@ -36,7 +41,10 @@ export function Field({ label, hint, error, id, children, className }: FieldProp
36
41
 
37
42
  return (
38
43
  <div className={cn("flex min-w-0 flex-col gap-1.25", className)}>
39
- <Label htmlFor={controlId}>{label}</Label>
44
+ <Label htmlFor={controlId}>
45
+ {label}
46
+ {optional ? <span className="font-normal text-ink-3"> ({strings.optional})</span> : null}
47
+ </Label>
40
48
  {control}
41
49
  {hint ? (
42
50
  <p id={hintId} className="text-meta text-ink-3">
@@ -0,0 +1,2 @@
1
+ export * from "./strings.js";
2
+ export * from "./ui-strings-provider.js";
@@ -0,0 +1,44 @@
1
+ export interface UiStrings {
2
+ retry: string;
3
+ close: string;
4
+ loading: string;
5
+ optional: string;
6
+ cancel: string;
7
+ confirm: string;
8
+ processing: string;
9
+ correlationPrefix: string;
10
+ actionFailed: string;
11
+ somethingWrong: string;
12
+ actions: string;
13
+ }
14
+
15
+ export type UiLocale = "en" | "es";
16
+
17
+ export const uiStrings = {
18
+ en: {
19
+ retry: "Retry",
20
+ close: "Close",
21
+ loading: "Loading",
22
+ optional: "optional",
23
+ cancel: "Cancel",
24
+ confirm: "Confirm",
25
+ processing: "Processing…",
26
+ correlationPrefix: "ID:",
27
+ actionFailed: "The action could not be completed. Try again.",
28
+ somethingWrong: "Something went wrong. Try again.",
29
+ actions: "Actions",
30
+ },
31
+ es: {
32
+ retry: "Reintentar",
33
+ close: "Cerrar",
34
+ loading: "Cargando",
35
+ optional: "opcional",
36
+ cancel: "Cancelar",
37
+ confirm: "Confirmar",
38
+ processing: "Procesando…",
39
+ correlationPrefix: "ID:",
40
+ actionFailed: "No se pudo completar la acción. Inténtalo de nuevo.",
41
+ somethingWrong: "Algo salió mal. Inténtalo de nuevo.",
42
+ actions: "Acciones",
43
+ },
44
+ } as const satisfies Record<UiLocale, UiStrings>;
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { createContext, type ReactNode, useContext, useMemo } from "react";
4
+ import { type UiLocale, type UiStrings, uiStrings } from "./strings.js";
5
+
6
+ const UiStringsContext = createContext<UiStrings>(uiStrings.en);
7
+
8
+ export interface UiStringsProviderProps {
9
+ /** Preset to start from. Without it the provider keeps the strings of its parent (default `en`). */
10
+ locale?: UiLocale;
11
+ /** Overrides applied on top of the preset. */
12
+ strings?: Partial<UiStrings>;
13
+ children: ReactNode;
14
+ }
15
+
16
+ export function UiStringsProvider({ locale, strings, children }: UiStringsProviderProps) {
17
+ const parent = useContext(UiStringsContext);
18
+ const value = useMemo<UiStrings>(
19
+ () => ({ ...(locale ? uiStrings[locale] : parent), ...strings }),
20
+ [locale, strings, parent],
21
+ );
22
+ return <UiStringsContext.Provider value={value}>{children}</UiStringsContext.Provider>;
23
+ }
24
+
25
+ export function useUiStrings(): UiStrings {
26
+ return useContext(UiStringsContext);
27
+ }
@@ -3,6 +3,7 @@
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
4
  import { X } from "lucide-react";
5
5
  import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
6
+ import { useUiStrings } from "../../i18n/index.js";
6
7
  import { cn } from "../../utils/cn.js";
7
8
  import {
8
9
  PreviousFocusProvider,
@@ -56,7 +57,7 @@ export function DialogContent({
56
57
  className,
57
58
  width = "md",
58
59
  blocking = false,
59
- closeLabel = "Close",
60
+ closeLabel,
60
61
  container,
61
62
  children,
62
63
  onEscapeKeyDown,
@@ -66,6 +67,7 @@ export function DialogContent({
66
67
  ...props
67
68
  }: DialogContentProps) {
68
69
  const previousFocusRef = usePreviousFocus();
70
+ const strings = useUiStrings();
69
71
  return (
70
72
  <DialogPrimitive.Portal container={container}>
71
73
  <DialogPrimitive.Overlay className="fixed inset-0 z-70 bg-scrim" />
@@ -96,8 +98,8 @@ export function DialogContent({
96
98
  {children}
97
99
  {!blocking && (
98
100
  <DialogPrimitive.Close
99
- aria-label={closeLabel}
100
- className="absolute top-3 right-3 rounded-event p-1 text-ink-3 hover:text-ink"
101
+ aria-label={closeLabel ?? strings.close}
102
+ className="absolute top-3 right-3 rounded-event p-1 text-ink-3 hover:text-ink in-data-[density=touch]:top-0.5 in-data-[density=touch]:right-0.5 in-data-[density=touch]:p-3.5"
101
103
  >
102
104
  <X aria-hidden="true" className="size-4" />
103
105
  </DialogPrimitive.Close>
@@ -3,6 +3,7 @@
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
4
  import { X } from "lucide-react";
5
5
  import type { ComponentPropsWithoutRef } from "react";
6
+ import { useUiStrings } from "../../i18n/index.js";
6
7
  import { cn } from "../../utils/cn.js";
7
8
  import {
8
9
  PreviousFocusProvider,
@@ -44,7 +45,7 @@ export interface DrawerContentProps
44
45
  export function DrawerContent({
45
46
  className,
46
47
  blocking = false,
47
- closeLabel = "Close",
48
+ closeLabel,
48
49
  children,
49
50
  onEscapeKeyDown,
50
51
  onPointerDownOutside,
@@ -53,6 +54,7 @@ export function DrawerContent({
53
54
  ...props
54
55
  }: DrawerContentProps) {
55
56
  const previousFocusRef = usePreviousFocus();
57
+ const strings = useUiStrings();
56
58
  return (
57
59
  <DialogPrimitive.Portal>
58
60
  <DialogPrimitive.Overlay className="fixed inset-0 z-70 bg-scrim" />
@@ -82,8 +84,8 @@ export function DrawerContent({
82
84
  {children}
83
85
  {!blocking && (
84
86
  <DialogPrimitive.Close
85
- aria-label={closeLabel}
86
- className="absolute top-3 right-3 rounded-event p-1 text-ink-3 hover:text-ink"
87
+ aria-label={closeLabel ?? strings.close}
88
+ className="absolute top-3 right-3 rounded-event p-1 text-ink-3 hover:text-ink in-data-[density=touch]:top-0.5 in-data-[density=touch]:right-0.5 in-data-[density=touch]:p-3.5"
87
89
  >
88
90
  <X aria-hidden="true" className="size-4" />
89
91
  </DialogPrimitive.Close>
@@ -1,10 +1,16 @@
1
1
  "use client";
2
2
 
3
- import type { ReactNode } from "react";
3
+ import { isValidElement, type ReactNode } from "react";
4
+ import { useUiStrings } from "../../i18n/index.js";
4
5
  import { cn } from "../../utils/cn.js";
5
6
 
6
7
  export interface ErrorStateProps {
7
- message: ReactNode;
8
+ /** Short headline. Wins over `message` as the headline when both are given. */
9
+ title?: ReactNode;
10
+ /** Explanation under the headline, in plain language. */
11
+ description?: ReactNode;
12
+ /** Headline when `title` is absent (kept for source compatibility). */
13
+ message?: ReactNode;
8
14
  /** Correlation code for support; never a raw exception. */
9
15
  correlationId?: ReactNode;
10
16
  /** Prefix shown before the correlation id. */
@@ -15,13 +21,19 @@ export interface ErrorStateProps {
15
21
  }
16
22
 
17
23
  export function ErrorState({
24
+ title,
25
+ description,
18
26
  message,
19
27
  correlationId,
20
- correlationLabel = "ID:",
28
+ correlationLabel,
21
29
  onRetry,
22
- retryLabel = "Retry",
30
+ retryLabel,
23
31
  className,
24
32
  }: ErrorStateProps) {
33
+ const strings = useUiStrings();
34
+ const headline = displayable(title) ?? displayable(message) ?? strings.somethingWrong;
35
+ const detail =
36
+ displayable(description) ?? (title === undefined ? undefined : displayable(message));
25
37
  return (
26
38
  <div
27
39
  role="alert"
@@ -31,11 +43,14 @@ export function ErrorState({
31
43
  )}
32
44
  >
33
45
  <p className="max-w-full font-semibold text-[14px] text-ink [overflow-wrap:anywhere]">
34
- {message}
46
+ {headline}
35
47
  </p>
48
+ {detail ? (
49
+ <p className="max-w-[42ch] text-[13px] text-ink-3 [overflow-wrap:anywhere]">{detail}</p>
50
+ ) : null}
36
51
  {correlationId ? (
37
52
  <p className="max-w-full text-[11.5px] text-ink-3 tabular-nums [overflow-wrap:anywhere]">
38
- {correlationLabel} {correlationId}
53
+ {correlationLabel ?? strings.correlationPrefix} {correlationId}
39
54
  </p>
40
55
  ) : null}
41
56
  {onRetry ? (
@@ -44,9 +59,17 @@ export function ErrorState({
44
59
  onClick={onRetry}
45
60
  className="mt-2 inline-flex h-control-sm cursor-pointer items-center rounded-input border-control bg-surface-control px-3 font-semibold text-[13px] text-ink hover:bg-surface-2"
46
61
  >
47
- {retryLabel}
62
+ {retryLabel ?? strings.retry}
48
63
  </button>
49
64
  ) : null}
50
65
  </div>
51
66
  );
52
67
  }
68
+
69
+ /** Strings, numbers and elements render; an Error or any other object is never shown raw. */
70
+ function displayable(value: ReactNode): ReactNode {
71
+ if (value === null || value === undefined || typeof value === "boolean") return undefined;
72
+ if (typeof value === "string" || typeof value === "number") return value;
73
+ if (isValidElement(value) || Array.isArray(value)) return value;
74
+ return undefined;
75
+ }