@jcsoftdev/ui 0.1.5 → 0.1.7
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 +85 -2
- package/dist/atoms/button/button.d.ts +8 -4
- package/dist/atoms/button/button.js +16 -6
- package/dist/atoms/checkbox/checkbox.js +5 -2
- package/dist/atoms/chip/chip.d.ts +7 -3
- package/dist/atoms/chip/chip.js +22 -4
- package/dist/atoms/internal/status-icons.d.ts +4 -0
- package/dist/atoms/internal/status-icons.js +15 -0
- package/dist/atoms/radio-group/radio-group.js +1 -1
- package/dist/atoms/switch/switch.js +5 -2
- package/dist/containers/banner/banner.d.ts +5 -3
- package/dist/containers/banner/banner.js +11 -5
- package/dist/containers/confirm-action/confirm-action.d.ts +17 -0
- package/dist/containers/confirm-action/confirm-action.js +71 -0
- package/dist/containers/confirm-action/index.d.ts +2 -0
- package/dist/containers/confirm-action/index.js +1 -0
- package/dist/containers/confirm-action/model.d.ts +14 -0
- package/dist/containers/confirm-action/model.js +12 -0
- package/dist/containers/index.d.ts +2 -0
- package/dist/containers/index.js +2 -0
- package/dist/containers/responsive-list/index.d.ts +1 -0
- package/dist/containers/responsive-list/index.js +1 -0
- package/dist/containers/responsive-list/responsive-list.d.ts +24 -0
- package/dist/containers/responsive-list/responsive-list.js +69 -0
- package/dist/forms/field/field.d.ts +5 -2
- package/dist/forms/field/field.js +11 -2
- package/dist/i18n/index.d.ts +2 -0
- package/dist/i18n/index.js +2 -0
- package/dist/i18n/strings.d.ts +42 -0
- package/dist/i18n/strings.js +31 -0
- package/dist/i18n/ui-strings-provider.d.ts +11 -0
- package/dist/i18n/ui-strings-provider.js +20 -0
- package/dist/overlays/dialog/dialog.js +5 -3
- package/dist/overlays/drawer/drawer.js +5 -3
- package/dist/states/error-state/error-state.d.ts +8 -3
- package/dist/states/error-state/error-state.js +19 -5
- package/dist/states/skeleton/skeleton.js +5 -2
- package/package.json +1 -1
- package/scripts/build-theme.mjs +11 -5
- package/src/atoms/button/button.tsx +27 -5
- package/src/atoms/checkbox/checkbox.tsx +5 -2
- package/src/atoms/chip/chip.tsx +28 -3
- package/src/atoms/internal/status-icons.tsx +17 -0
- package/src/atoms/radio-group/radio-group.tsx +1 -1
- package/src/atoms/switch/switch.tsx +5 -2
- package/src/containers/banner/banner.tsx +18 -10
- package/src/containers/confirm-action/confirm-action.tsx +92 -0
- package/src/containers/confirm-action/index.ts +2 -0
- package/src/containers/confirm-action/model.ts +17 -0
- package/src/containers/index.ts +2 -0
- package/src/containers/responsive-list/index.ts +1 -0
- package/src/containers/responsive-list/responsive-list.tsx +127 -0
- package/src/forms/field/field.tsx +11 -3
- package/src/i18n/index.ts +2 -0
- package/src/i18n/strings.ts +44 -0
- package/src/i18n/ui-strings-provider.tsx +27 -0
- package/src/overlays/dialog/dialog.tsx +5 -3
- package/src/overlays/drawer/drawer.tsx +5 -3
- package/src/states/error-state/error-state.tsx +30 -7
- package/src/states/skeleton/skeleton.tsx +6 -2
- package/styles/Theme.kt +7 -0
- package/styles/theme.css +4 -0
- package/styles/tokens.css +24 -0
- package/tokens/default.json +34 -0
|
@@ -9,10 +9,13 @@ export interface SwitchProps extends ComponentPropsWithoutRef<typeof SwitchPrimi
|
|
|
9
9
|
export function Switch({ className, ...props }: SwitchProps) {
|
|
10
10
|
return (
|
|
11
11
|
<SwitchPrimitive.Root
|
|
12
|
-
className={cn(
|
|
12
|
+
className={cn(
|
|
13
|
+
"group inline-flex size-10 shrink-0 items-center justify-center in-data-[density=touch]:size-11",
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
13
16
|
{...props}
|
|
14
17
|
>
|
|
15
|
-
<span className="relative block h-5 w-[34px] rounded-pill bg-line-strong transition-colors data-[state=checked]:bg-accent">
|
|
18
|
+
<span className="relative block h-5 w-[34px] rounded-pill bg-line-strong transition-colors group-data-[state=checked]:bg-accent">
|
|
16
19
|
<SwitchPrimitive.Thumb className="absolute top-[3px] left-[3px] block size-3.5 rounded-pill bg-surface transition-transform data-[state=checked]:translate-x-3.5" />
|
|
17
20
|
</span>
|
|
18
21
|
</SwitchPrimitive.Root>
|
|
@@ -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-
|
|
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-
|
|
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
|
-
* `
|
|
22
|
-
* `
|
|
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: "
|
|
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
|
-
/**
|
|
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
|
-
{
|
|
62
|
+
{resolvedIcon ? (
|
|
58
63
|
<span aria-hidden="true" className={cn("mt-0.5 shrink-0", toneIconClasses[tone])}>
|
|
59
|
-
{
|
|
64
|
+
{resolvedIcon}
|
|
60
65
|
</span>
|
|
61
66
|
) : null}
|
|
62
|
-
<div className="min-w-0 flex-[1_1_14rem] break-words">
|
|
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,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
|
+
}
|
package/src/containers/index.ts
CHANGED
|
@@ -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
|
|
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}>
|
|
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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
28
|
+
correlationLabel,
|
|
21
29
|
onRetry,
|
|
22
|
-
retryLabel
|
|
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
|
-
{
|
|
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
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useUiStrings } from "../../i18n/index.js";
|
|
1
4
|
import { cn } from "../../utils/cn.js";
|
|
2
5
|
|
|
3
6
|
export type SkeletonVariant = "lines" | "table" | "card";
|
|
@@ -19,14 +22,15 @@ export function Skeleton({
|
|
|
19
22
|
variant = "lines",
|
|
20
23
|
lines = 3,
|
|
21
24
|
rows = 4,
|
|
22
|
-
label
|
|
25
|
+
label,
|
|
23
26
|
className,
|
|
24
27
|
}: SkeletonProps) {
|
|
28
|
+
const strings = useUiStrings();
|
|
25
29
|
return (
|
|
26
30
|
<div
|
|
27
31
|
role="status"
|
|
28
32
|
aria-busy="true"
|
|
29
|
-
aria-label={label}
|
|
33
|
+
aria-label={label ?? strings.loading}
|
|
30
34
|
className={cn("animate-pulse", className)}
|
|
31
35
|
>
|
|
32
36
|
{variant === "lines" ? (
|