@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.
Files changed (64) 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 +5 -2
  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 +5 -2
  11. package/dist/containers/banner/banner.d.ts +5 -3
  12. package/dist/containers/banner/banner.js +11 -5
  13. package/dist/containers/confirm-action/confirm-action.d.ts +17 -0
  14. package/dist/containers/confirm-action/confirm-action.js +71 -0
  15. package/dist/containers/confirm-action/index.d.ts +2 -0
  16. package/dist/containers/confirm-action/index.js +1 -0
  17. package/dist/containers/confirm-action/model.d.ts +14 -0
  18. package/dist/containers/confirm-action/model.js +12 -0
  19. package/dist/containers/index.d.ts +2 -0
  20. package/dist/containers/index.js +2 -0
  21. package/dist/containers/responsive-list/index.d.ts +1 -0
  22. package/dist/containers/responsive-list/index.js +1 -0
  23. package/dist/containers/responsive-list/responsive-list.d.ts +24 -0
  24. package/dist/containers/responsive-list/responsive-list.js +69 -0
  25. package/dist/forms/field/field.d.ts +5 -2
  26. package/dist/forms/field/field.js +11 -2
  27. package/dist/i18n/index.d.ts +2 -0
  28. package/dist/i18n/index.js +2 -0
  29. package/dist/i18n/strings.d.ts +42 -0
  30. package/dist/i18n/strings.js +31 -0
  31. package/dist/i18n/ui-strings-provider.d.ts +11 -0
  32. package/dist/i18n/ui-strings-provider.js +20 -0
  33. package/dist/overlays/dialog/dialog.js +5 -3
  34. package/dist/overlays/drawer/drawer.js +5 -3
  35. package/dist/states/error-state/error-state.d.ts +8 -3
  36. package/dist/states/error-state/error-state.js +19 -5
  37. package/dist/states/skeleton/skeleton.js +5 -2
  38. package/package.json +1 -1
  39. package/scripts/build-theme.mjs +11 -5
  40. package/src/atoms/button/button.tsx +27 -5
  41. package/src/atoms/checkbox/checkbox.tsx +5 -2
  42. package/src/atoms/chip/chip.tsx +28 -3
  43. package/src/atoms/internal/status-icons.tsx +17 -0
  44. package/src/atoms/radio-group/radio-group.tsx +1 -1
  45. package/src/atoms/switch/switch.tsx +5 -2
  46. package/src/containers/banner/banner.tsx +18 -10
  47. package/src/containers/confirm-action/confirm-action.tsx +92 -0
  48. package/src/containers/confirm-action/index.ts +2 -0
  49. package/src/containers/confirm-action/model.ts +17 -0
  50. package/src/containers/index.ts +2 -0
  51. package/src/containers/responsive-list/index.ts +1 -0
  52. package/src/containers/responsive-list/responsive-list.tsx +127 -0
  53. package/src/forms/field/field.tsx +11 -3
  54. package/src/i18n/index.ts +2 -0
  55. package/src/i18n/strings.ts +44 -0
  56. package/src/i18n/ui-strings-provider.tsx +27 -0
  57. package/src/overlays/dialog/dialog.tsx +5 -3
  58. package/src/overlays/drawer/drawer.tsx +5 -3
  59. package/src/states/error-state/error-state.tsx +30 -7
  60. package/src/states/skeleton/skeleton.tsx +6 -2
  61. package/styles/Theme.kt +7 -0
  62. package/styles/theme.css +4 -0
  63. package/styles/tokens.css +24 -0
  64. package/tokens/default.json +34 -0
@@ -0,0 +1,69 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useUiStrings } from "../../i18n/index.js";
4
+ import { cn } from "../../utils/cn.js";
5
+ import {
6
+ Table,
7
+ TableBody,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeaderCell,
11
+ TableRow
12
+ } from "../table/table.js";
13
+ function ResponsiveList({
14
+ caption,
15
+ items,
16
+ columns,
17
+ rowKey,
18
+ renderActions,
19
+ actionsHeader,
20
+ rowClassName,
21
+ className
22
+ }) {
23
+ const strings = useUiStrings();
24
+ const primary = columns.find((column) => column.primary) ?? columns[0];
25
+ const details = columns.filter((column) => column !== primary);
26
+ return /* @__PURE__ */ jsxs("div", { className: cn("min-w-0", className), children: [
27
+ /* @__PURE__ */ jsx("div", { className: "hidden rounded-card border-card shadow-card surface-card md:block", children: /* @__PURE__ */ jsxs(Table, { children: [
28
+ /* @__PURE__ */ jsx("caption", { className: "sr-only", children: caption }),
29
+ /* @__PURE__ */ jsx(TableHead, { children: /* @__PURE__ */ jsxs(TableRow, { className: "hover:bg-transparent", children: [
30
+ columns.map((column) => /* @__PURE__ */ jsx(TableHeaderCell, { scope: "col", alignRight: column.align === "end", children: column.header }, column.key)),
31
+ renderActions ? /* @__PURE__ */ jsx(TableHeaderCell, { scope: "col", alignRight: true, children: actionsHeader ?? strings.actions }) : null
32
+ ] }) }),
33
+ /* @__PURE__ */ jsx(TableBody, { children: items.map((item) => /* @__PURE__ */ jsxs(
34
+ TableRow,
35
+ {
36
+ "data-row-key": rowKey(item),
37
+ className: rowClassName?.(item),
38
+ children: [
39
+ columns.map((column) => /* @__PURE__ */ jsx(TableCell, { alignRight: column.align === "end", children: column.cell(item) }, column.key)),
40
+ renderActions ? /* @__PURE__ */ jsx(TableCell, { alignRight: true, children: /* @__PURE__ */ jsx("div", { className: "flex flex-wrap justify-end gap-2", children: renderActions(item) }) }) : null
41
+ ]
42
+ },
43
+ rowKey(item)
44
+ )) })
45
+ ] }) }),
46
+ /* @__PURE__ */ jsx("ul", { "aria-label": caption, className: "m-0 flex list-none flex-col gap-density p-0 md:hidden", children: items.map((item) => /* @__PURE__ */ jsxs(
47
+ "li",
48
+ {
49
+ "data-row-key": rowKey(item),
50
+ className: cn(
51
+ "flex min-w-0 flex-col gap-3 rounded-card border-card p-card shadow-card surface-card",
52
+ rowClassName?.(item)
53
+ ),
54
+ children: [
55
+ /* @__PURE__ */ jsx("div", { className: "min-w-0 font-semibold text-[15px] text-ink [overflow-wrap:anywhere]", children: primary?.cell(item) }),
56
+ details.length > 0 ? /* @__PURE__ */ jsx("dl", { className: "m-0 flex flex-col gap-2", children: details.map((column) => /* @__PURE__ */ jsxs("div", { className: "flex items-baseline justify-between gap-4", children: [
57
+ /* @__PURE__ */ jsx("dt", { className: "shrink-0 text-ink-3 text-meta", children: column.header }),
58
+ /* @__PURE__ */ jsx("dd", { className: "m-0 min-w-0 text-right text-body text-ink [overflow-wrap:anywhere]", children: column.cell(item) })
59
+ ] }, column.key)) }) : null,
60
+ renderActions ? /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: renderActions(item) }) : null
61
+ ]
62
+ },
63
+ rowKey(item)
64
+ )) })
65
+ ] });
66
+ }
67
+ export {
68
+ ResponsiveList
69
+ };
@@ -7,10 +7,13 @@ interface ControlProps {
7
7
  export interface FieldProps {
8
8
  label: string;
9
9
  hint?: string;
10
- error?: string;
10
+ /** Message under the control; `null` and `undefined` mean no error. */
11
+ error?: string | null;
12
+ /** Marks the field as optional by appending the localized marker to the label. */
13
+ optional?: boolean;
11
14
  id?: string;
12
15
  children: ReactElement<ControlProps>;
13
16
  className?: string;
14
17
  }
15
- export declare function Field({ label, hint, error, id, children, className }: FieldProps): import("react").JSX.Element;
18
+ export declare function Field({ label, hint, error, optional, id, children, className }: FieldProps): import("react").JSX.Element;
16
19
  export {};
@@ -1,9 +1,11 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { cloneElement, isValidElement, 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
- function Field({ label, hint, error, id, children, className }) {
7
+ function Field({ label, hint, error, optional, id, children, className }) {
8
+ const strings = useUiStrings();
7
9
  const generatedId = useId();
8
10
  const controlId = id ?? generatedId;
9
11
  const hintId = hint ? `${controlId}-hint` : void 0;
@@ -15,7 +17,14 @@ function Field({ label, hint, error, id, children, className }) {
15
17
  "aria-invalid": error ? true : void 0
16
18
  }) : children;
17
19
  return /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 flex-col gap-1.25", className), children: [
18
- /* @__PURE__ */ jsx(Label, { htmlFor: controlId, children: label }),
20
+ /* @__PURE__ */ jsxs(Label, { htmlFor: controlId, children: [
21
+ label,
22
+ optional ? /* @__PURE__ */ jsxs("span", { className: "font-normal text-ink-3", children: [
23
+ " (",
24
+ strings.optional,
25
+ ")"
26
+ ] }) : null
27
+ ] }),
19
28
  control,
20
29
  hint ? /* @__PURE__ */ jsx("p", { id: hintId, className: "text-meta text-ink-3", children: hint }) : null,
21
30
  error ? /* @__PURE__ */ jsx("p", { id: errorId, className: "font-semibold text-danger text-meta", role: "alert", children: error }) : null
@@ -0,0 +1,2 @@
1
+ export * from "./strings.js";
2
+ export * from "./ui-strings-provider.js";
@@ -0,0 +1,2 @@
1
+ export * from "./strings.js";
2
+ export * from "./ui-strings-provider.js";
@@ -0,0 +1,42 @@
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
+ export type UiLocale = "en" | "es";
15
+ export declare const uiStrings: {
16
+ readonly en: {
17
+ readonly retry: "Retry";
18
+ readonly close: "Close";
19
+ readonly loading: "Loading";
20
+ readonly optional: "optional";
21
+ readonly cancel: "Cancel";
22
+ readonly confirm: "Confirm";
23
+ readonly processing: "Processing…";
24
+ readonly correlationPrefix: "ID:";
25
+ readonly actionFailed: "The action could not be completed. Try again.";
26
+ readonly somethingWrong: "Something went wrong. Try again.";
27
+ readonly actions: "Actions";
28
+ };
29
+ readonly es: {
30
+ readonly retry: "Reintentar";
31
+ readonly close: "Cerrar";
32
+ readonly loading: "Cargando";
33
+ readonly optional: "opcional";
34
+ readonly cancel: "Cancelar";
35
+ readonly confirm: "Confirmar";
36
+ readonly processing: "Procesando…";
37
+ readonly correlationPrefix: "ID:";
38
+ readonly actionFailed: "No se pudo completar la acción. Inténtalo de nuevo.";
39
+ readonly somethingWrong: "Algo salió mal. Inténtalo de nuevo.";
40
+ readonly actions: "Acciones";
41
+ };
42
+ };
@@ -0,0 +1,31 @@
1
+ const uiStrings = {
2
+ en: {
3
+ retry: "Retry",
4
+ close: "Close",
5
+ loading: "Loading",
6
+ optional: "optional",
7
+ cancel: "Cancel",
8
+ confirm: "Confirm",
9
+ processing: "Processing\u2026",
10
+ correlationPrefix: "ID:",
11
+ actionFailed: "The action could not be completed. Try again.",
12
+ somethingWrong: "Something went wrong. Try again.",
13
+ actions: "Actions"
14
+ },
15
+ es: {
16
+ retry: "Reintentar",
17
+ close: "Cerrar",
18
+ loading: "Cargando",
19
+ optional: "opcional",
20
+ cancel: "Cancelar",
21
+ confirm: "Confirmar",
22
+ processing: "Procesando\u2026",
23
+ correlationPrefix: "ID:",
24
+ actionFailed: "No se pudo completar la acci\xF3n. Int\xE9ntalo de nuevo.",
25
+ somethingWrong: "Algo sali\xF3 mal. Int\xE9ntalo de nuevo.",
26
+ actions: "Acciones"
27
+ }
28
+ };
29
+ export {
30
+ uiStrings
31
+ };
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from "react";
2
+ import { type UiLocale, type UiStrings } from "./strings.js";
3
+ export interface UiStringsProviderProps {
4
+ /** Preset to start from. Without it the provider keeps the strings of its parent (default `en`). */
5
+ locale?: UiLocale;
6
+ /** Overrides applied on top of the preset. */
7
+ strings?: Partial<UiStrings>;
8
+ children: ReactNode;
9
+ }
10
+ export declare function UiStringsProvider({ locale, strings, children }: UiStringsProviderProps): import("react").JSX.Element;
11
+ export declare function useUiStrings(): UiStrings;
@@ -0,0 +1,20 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useMemo } from "react";
4
+ import { uiStrings } from "./strings.js";
5
+ const UiStringsContext = createContext(uiStrings.en);
6
+ function UiStringsProvider({ locale, strings, children }) {
7
+ const parent = useContext(UiStringsContext);
8
+ const value = useMemo(
9
+ () => ({ ...locale ? uiStrings[locale] : parent, ...strings }),
10
+ [locale, strings, parent]
11
+ );
12
+ return /* @__PURE__ */ jsx(UiStringsContext.Provider, { value, children });
13
+ }
14
+ function useUiStrings() {
15
+ return useContext(UiStringsContext);
16
+ }
17
+ export {
18
+ UiStringsProvider,
19
+ useUiStrings
20
+ };
@@ -2,6 +2,7 @@
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
4
  import { X } from "lucide-react";
5
+ import { useUiStrings } from "../../i18n/index.js";
5
6
  import { cn } from "../../utils/cn.js";
6
7
  import {
7
8
  PreviousFocusProvider,
@@ -27,7 +28,7 @@ function DialogContent({
27
28
  className,
28
29
  width = "md",
29
30
  blocking = false,
30
- closeLabel = "Close",
31
+ closeLabel,
31
32
  container,
32
33
  children,
33
34
  onEscapeKeyDown,
@@ -37,6 +38,7 @@ function DialogContent({
37
38
  ...props
38
39
  }) {
39
40
  const previousFocusRef = usePreviousFocus();
41
+ const strings = useUiStrings();
40
42
  return /* @__PURE__ */ jsxs(DialogPrimitive.Portal, { container, children: [
41
43
  /* @__PURE__ */ jsx(DialogPrimitive.Overlay, { className: "fixed inset-0 z-70 bg-scrim" }),
42
44
  /* @__PURE__ */ jsxs(
@@ -69,8 +71,8 @@ function DialogContent({
69
71
  !blocking && /* @__PURE__ */ jsx(
70
72
  DialogPrimitive.Close,
71
73
  {
72
- "aria-label": closeLabel,
73
- className: "absolute top-3 right-3 rounded-event p-1 text-ink-3 hover:text-ink",
74
+ "aria-label": closeLabel ?? strings.close,
75
+ 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",
74
76
  children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-4" })
75
77
  }
76
78
  )
@@ -2,6 +2,7 @@
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
4
  import { X } from "lucide-react";
5
+ import { useUiStrings } from "../../i18n/index.js";
5
6
  import { cn } from "../../utils/cn.js";
6
7
  import {
7
8
  PreviousFocusProvider,
@@ -23,7 +24,7 @@ const DrawerTitle = DialogPrimitive.Title;
23
24
  function DrawerContent({
24
25
  className,
25
26
  blocking = false,
26
- closeLabel = "Close",
27
+ closeLabel,
27
28
  children,
28
29
  onEscapeKeyDown,
29
30
  onPointerDownOutside,
@@ -32,6 +33,7 @@ function DrawerContent({
32
33
  ...props
33
34
  }) {
34
35
  const previousFocusRef = usePreviousFocus();
36
+ const strings = useUiStrings();
35
37
  return /* @__PURE__ */ jsxs(DialogPrimitive.Portal, { children: [
36
38
  /* @__PURE__ */ jsx(DialogPrimitive.Overlay, { className: "fixed inset-0 z-70 bg-scrim" }),
37
39
  /* @__PURE__ */ jsxs(
@@ -63,8 +65,8 @@ function DrawerContent({
63
65
  !blocking && /* @__PURE__ */ jsx(
64
66
  DialogPrimitive.Close,
65
67
  {
66
- "aria-label": closeLabel,
67
- className: "absolute top-3 right-3 rounded-event p-1 text-ink-3 hover:text-ink",
68
+ "aria-label": closeLabel ?? strings.close,
69
+ 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",
68
70
  children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-4" })
69
71
  }
70
72
  )
@@ -1,6 +1,11 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  export interface ErrorStateProps {
3
- message: ReactNode;
3
+ /** Short headline. Wins over `message` as the headline when both are given. */
4
+ title?: ReactNode;
5
+ /** Explanation under the headline, in plain language. */
6
+ description?: ReactNode;
7
+ /** Headline when `title` is absent (kept for source compatibility). */
8
+ message?: ReactNode;
4
9
  /** Correlation code for support; never a raw exception. */
5
10
  correlationId?: ReactNode;
6
11
  /** Prefix shown before the correlation id. */
@@ -9,4 +14,4 @@ export interface ErrorStateProps {
9
14
  retryLabel?: string;
10
15
  className?: string;
11
16
  }
12
- export declare function ErrorState({ message, correlationId, correlationLabel, onRetry, retryLabel, className, }: ErrorStateProps): import("react").JSX.Element;
17
+ export declare function ErrorState({ title, description, message, correlationId, correlationLabel, onRetry, retryLabel, className, }: ErrorStateProps): import("react").JSX.Element;
@@ -1,14 +1,21 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { isValidElement } from "react";
4
+ import { useUiStrings } from "../../i18n/index.js";
3
5
  import { cn } from "../../utils/cn.js";
4
6
  function ErrorState({
7
+ title,
8
+ description,
5
9
  message,
6
10
  correlationId,
7
- correlationLabel = "ID:",
11
+ correlationLabel,
8
12
  onRetry,
9
- retryLabel = "Retry",
13
+ retryLabel,
10
14
  className
11
15
  }) {
16
+ const strings = useUiStrings();
17
+ const headline = displayable(title) ?? displayable(message) ?? strings.somethingWrong;
18
+ const detail = displayable(description) ?? (title === void 0 ? void 0 : displayable(message));
12
19
  return /* @__PURE__ */ jsxs(
13
20
  "div",
14
21
  {
@@ -18,9 +25,10 @@ function ErrorState({
18
25
  className
19
26
  ),
20
27
  children: [
21
- /* @__PURE__ */ jsx("p", { className: "max-w-full font-semibold text-[14px] text-ink [overflow-wrap:anywhere]", children: message }),
28
+ /* @__PURE__ */ jsx("p", { className: "max-w-full font-semibold text-[14px] text-ink [overflow-wrap:anywhere]", children: headline }),
29
+ detail ? /* @__PURE__ */ jsx("p", { className: "max-w-[42ch] text-[13px] text-ink-3 [overflow-wrap:anywhere]", children: detail }) : null,
22
30
  correlationId ? /* @__PURE__ */ jsxs("p", { className: "max-w-full text-[11.5px] text-ink-3 tabular-nums [overflow-wrap:anywhere]", children: [
23
- correlationLabel,
31
+ correlationLabel ?? strings.correlationPrefix,
24
32
  " ",
25
33
  correlationId
26
34
  ] }) : null,
@@ -30,13 +38,19 @@ function ErrorState({
30
38
  type: "button",
31
39
  onClick: onRetry,
32
40
  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",
33
- children: retryLabel
41
+ children: retryLabel ?? strings.retry
34
42
  }
35
43
  ) : null
36
44
  ]
37
45
  }
38
46
  );
39
47
  }
48
+ function displayable(value) {
49
+ if (value === null || value === void 0 || typeof value === "boolean") return void 0;
50
+ if (typeof value === "string" || typeof value === "number") return value;
51
+ if (isValidElement(value) || Array.isArray(value)) return value;
52
+ return void 0;
53
+ }
40
54
  export {
41
55
  ErrorState
42
56
  };
@@ -1,19 +1,22 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useUiStrings } from "../../i18n/index.js";
2
4
  import { cn } from "../../utils/cn.js";
3
5
  const PLACEHOLDER_IDS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"];
4
6
  function Skeleton({
5
7
  variant = "lines",
6
8
  lines = 3,
7
9
  rows = 4,
8
- label = "Loading",
10
+ label,
9
11
  className
10
12
  }) {
13
+ const strings = useUiStrings();
11
14
  return /* @__PURE__ */ jsxs(
12
15
  "div",
13
16
  {
14
17
  role: "status",
15
18
  "aria-busy": "true",
16
- "aria-label": label,
19
+ "aria-label": label ?? strings.loading,
17
20
  className: cn("animate-pulse", className),
18
21
  children: [
19
22
  variant === "lines" ? /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: PLACEHOLDER_IDS.slice(0, lines).map((id, index) => /* @__PURE__ */ jsx(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcsoftdev/ui",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Token-driven React component library: React 19, Radix, Tailwind v4.",
5
5
  "author": "jcsoftdev",
6
6
  "license": "MIT",
@@ -116,6 +116,13 @@ export function stylesOf(tokens) {
116
116
  throw new Error(`styles: density token "${name}" is themed`);
117
117
  }
118
118
 
119
+ for (const level of density.levels) {
120
+ for (const name of Object.keys(level.values ?? {})) {
121
+ if (!densityGoverned.has(name) || name === "density")
122
+ throw new Error(`styles: density level "${level.id}" sets "${name}", which is not scaled`);
123
+ }
124
+ }
125
+
119
126
  const radiusLevels = styles.radius?.levels ?? [];
120
127
  const radiusGoverned = new Set(radiusLevels.flatMap((l) => Object.keys(l.values ?? {})));
121
128
  for (const name of radiusGoverned) known(name, "radius levels");
@@ -170,11 +177,10 @@ function buildStyleBlocks(tokens) {
170
177
  }
171
178
 
172
179
  for (const level of s.density.levels) {
173
- const entries = s.densityGoverned.map((name) =>
174
- name === "density"
175
- ? [name, String(level.density)]
176
- : [name, `calc(${s.byName.get(name).value} * var(--density))`],
177
- );
180
+ const entries = s.densityGoverned.map((name) => {
181
+ if (name === "density") return [name, String(level.density)];
182
+ return [name, level.values?.[name] ?? `calc(${s.byName.get(name).value} * var(--density))`];
183
+ });
178
184
  blocks.push(...variantBlocks("data-density", level.id, entries, themes));
179
185
  }
180
186
 
@@ -3,7 +3,7 @@
3
3
  import { Slot } from "@radix-ui/react-slot";
4
4
  import {
5
5
  Children,
6
- type ComponentPropsWithoutRef,
6
+ type ComponentPropsWithRef,
7
7
  Fragment,
8
8
  isValidElement,
9
9
  type ReactNode,
@@ -11,7 +11,7 @@ import {
11
11
  import { cn } from "../../utils/cn.js";
12
12
 
13
13
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
14
- export type ButtonSize = "sm" | "md";
14
+ export type ButtonSize = "sm" | "md" | "lg";
15
15
 
16
16
  const variantClasses: Record<ButtonVariant, string> = {
17
17
  primary: "bg-accent text-on-accent font-bold hover:bg-accent-ink",
@@ -23,12 +23,17 @@ const variantClasses: Record<ButtonVariant, string> = {
23
23
  const sizeClasses: Record<ButtonSize, string> = {
24
24
  sm: "h-control-sm px-3 text-[12.5px] [&_svg]:size-3.5",
25
25
  md: "h-control px-control-x text-[13px]",
26
+ lg: "h-control-lg px-control-x text-[14px]",
26
27
  };
27
28
 
28
- export interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
29
+ export interface ButtonProps extends ComponentPropsWithRef<"button"> {
29
30
  variant?: ButtonVariant;
30
31
  size?: ButtonSize;
31
32
  loading?: boolean;
33
+ /** Replaces the children while `loading` and is announced to screen readers. */
34
+ loadingLabel?: string;
35
+ /** Stretch to the full width of the container. */
36
+ block?: boolean;
32
37
  /** Render the child element (via Radix Slot) instead of a `<button>`, e.g. a link. */
33
38
  asChild?: boolean;
34
39
  }
@@ -37,6 +42,8 @@ export function Button({
37
42
  variant = "secondary",
38
43
  size = "md",
39
44
  loading,
45
+ loadingLabel,
46
+ block,
40
47
  disabled,
41
48
  asChild,
42
49
  className,
@@ -46,8 +53,13 @@ export function Button({
46
53
  const Comp = asChild ? Slot : "button";
47
54
  // Text runs get their own truncating span so a long label ends in "…" inside a narrow
48
55
  // container instead of overflowing; icons keep their size. Slot needs its single child as is.
49
- const content = asChild ? children : Children.map(children, labelRun);
50
- return (
56
+ const showLoadingLabel = loading && loadingLabel !== undefined && !asChild;
57
+ const content = showLoadingLabel
58
+ ? labelRun(loadingLabel)
59
+ : asChild
60
+ ? children
61
+ : Children.map(children, labelRun);
62
+ const button = (
51
63
  <Comp
52
64
  {...(asChild ? {} : { type: "button" })}
53
65
  aria-busy={loading ? "true" : undefined}
@@ -56,6 +68,7 @@ export function Button({
56
68
  "inline-flex min-w-0 max-w-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-button pressable focus-visible:focus-ring font-[650] no-underline disabled:cursor-not-allowed disabled:opacity-45 [&_svg]:shrink-0",
57
69
  variantClasses[variant],
58
70
  sizeClasses[size],
71
+ block && "flex w-full",
59
72
  className,
60
73
  )}
61
74
  {...props}
@@ -63,6 +76,15 @@ export function Button({
63
76
  {content}
64
77
  </Comp>
65
78
  );
79
+ if (loadingLabel === undefined) return button;
80
+ return (
81
+ <>
82
+ {button}
83
+ <span role="status" className="sr-only">
84
+ {loading ? loadingLabel : null}
85
+ </span>
86
+ </>
87
+ );
66
88
  }
67
89
 
68
90
  function labelRun(child: ReactNode): ReactNode {
@@ -10,10 +10,13 @@ export interface CheckboxProps extends ComponentPropsWithoutRef<typeof CheckboxP
10
10
  export function Checkbox({ className, ...props }: CheckboxProps) {
11
11
  return (
12
12
  <CheckboxPrimitive.Root
13
- className={cn("inline-flex size-10 shrink-0 items-center justify-center", className)}
13
+ className={cn(
14
+ "group inline-flex size-10 shrink-0 items-center justify-center in-data-[density=touch]:size-11",
15
+ className,
16
+ )}
14
17
  {...props}
15
18
  >
16
- <span className="grid size-[18px] place-items-center rounded-pill border-[1.5px] border-line-strong data-[state=checked]:border-success data-[state=checked]:bg-success">
19
+ <span className="grid size-[18px] place-items-center rounded-pill border-[1.5px] border-line-strong group-data-[state=checked]:border-success group-data-[state=checked]:bg-success group-data-[state=indeterminate]:border-success group-data-[state=indeterminate]:bg-success">
17
20
  <CheckboxPrimitive.Indicator>
18
21
  <Check aria-hidden="true" className="size-[11px] stroke-[3] text-on-accent" />
19
22
  </CheckboxPrimitive.Indicator>
@@ -1,5 +1,6 @@
1
- import type { ComponentPropsWithoutRef } from "react";
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
2
  import { cn } from "../../utils/cn.js";
3
+ import { type StatusTone, statusIcon } from "../internal/status-icons.js";
3
4
 
4
5
  export type ChipTone =
5
6
  | "neutral"
@@ -7,6 +8,7 @@ export type ChipTone =
7
8
  | "warning"
8
9
  | "success"
9
10
  | "accent"
11
+ | "info"
10
12
  | "solid"
11
13
  | "outline";
12
14
 
@@ -16,6 +18,7 @@ const toneClasses: Record<ChipTone, string> = {
16
18
  warning: "bg-warning-soft text-warning",
17
19
  success: "bg-success-soft text-success",
18
20
  accent: "bg-accent-soft text-accent-ink",
21
+ info: "bg-info-soft text-info",
19
22
  solid: "bg-accent text-on-accent",
20
23
  outline: "bg-transparent text-ink-2 shadow-[inset_0_0_0_1px_var(--line-strong)]",
21
24
  };
@@ -24,18 +27,40 @@ export interface ChipProps extends ComponentPropsWithoutRef<"span"> {
24
27
  tone?: ChipTone;
25
28
  /** Use the smaller chip radius instead of a full pill. */
26
29
  square?: boolean;
30
+ /** Leading decorative icon; wins over `statusIcon`. */
31
+ icon?: ReactNode;
32
+ /** Prefix `info`, `success`, `warning` and `danger` chips with their status glyph. */
33
+ statusIcon?: boolean;
27
34
  }
28
35
 
29
- export function Chip({ tone = "neutral", square, className, ...props }: ChipProps) {
36
+ const statusTones = new Set<ChipTone>(["info", "success", "warning", "danger"]);
37
+
38
+ export function Chip({
39
+ tone = "neutral",
40
+ square,
41
+ icon,
42
+ statusIcon: withStatusIcon,
43
+ className,
44
+ children,
45
+ ...props
46
+ }: ChipProps) {
47
+ const leading =
48
+ icon ??
49
+ (withStatusIcon && statusTones.has(tone) ? statusIcon(tone as StatusTone, "size-3") : null);
50
+ const interactive = props.onClick !== undefined || props.role === "button";
30
51
  return (
31
52
  <span
32
53
  className={cn(
33
54
  "inline-flex h-6 max-w-full items-center gap-1.5 overflow-hidden whitespace-nowrap px-2.5 font-[650] text-[12px] leading-none [&_svg]:size-3",
34
55
  square ? "rounded-chip" : "rounded-badge",
35
56
  toneClasses[tone],
57
+ interactive && "in-data-[density=touch]:min-h-11",
36
58
  className,
37
59
  )}
38
60
  {...props}
39
- />
61
+ >
62
+ {leading}
63
+ {children}
64
+ </span>
40
65
  );
41
66
  }
@@ -0,0 +1,17 @@
1
+ import { CircleAlert, CircleCheck, Info, TriangleAlert } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type StatusTone = "info" | "success" | "warning" | "danger";
5
+
6
+ const icons = {
7
+ info: Info,
8
+ success: CircleCheck,
9
+ warning: TriangleAlert,
10
+ danger: CircleAlert,
11
+ } as const;
12
+
13
+ /** Decorative glyph that pairs a status tone with a shape, so status never relies on color alone. */
14
+ export function statusIcon(tone: StatusTone, className = "size-4"): ReactNode {
15
+ const Icon = icons[tone];
16
+ return <Icon aria-hidden="true" className={className} />;
17
+ }
@@ -31,7 +31,7 @@ export function RadioGroup({ options, className, ...props }: RadioGroupProps) {
31
31
  <label
32
32
  key={option.value}
33
33
  htmlFor={id}
34
- className="flex cursor-pointer items-start gap-2 rounded-control border-divider p-2.5 text-body has-[[data-state=checked]]:border-accent has-[[data-state=checked]]:shadow-[inset_0_0_0_1px_var(--accent)]"
34
+ className="flex cursor-pointer items-start gap-2 rounded-control border-divider p-2.5 text-body in-data-[density=touch]:min-h-11 has-[[data-state=checked]]:border-accent has-[[data-state=checked]]:shadow-[inset_0_0_0_1px_var(--accent)]"
35
35
  >
36
36
  <RadioGroupPrimitive.Item
37
37
  id={id}