@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
package/README.md CHANGED
@@ -22,7 +22,7 @@ Set these on `<html>` (or any element to restyle only its subtree); no rebuild i
22
22
  | `data-theme="light\|dark"` | Color scheme (omit to follow the OS). |
23
23
  | `data-accent="<palette-id>"` | Brand palette. |
24
24
  | `data-style="<preset>"` | Style preset (radii, borders, shadows, glass, ...). See https://ui.jcsoftdev.com/docs/theming/. |
25
- | `data-density="<level>"` | Control and spacing density. |
25
+ | `data-density="<level>"` | Control and spacing density: `compact`, `normal`, `comfortable`, `touch`. |
26
26
  | `data-radius="<level>"` | Global corner radius scale. |
27
27
 
28
28
  ### Subpath exports
@@ -30,6 +30,7 @@ Set these on `<html>` (or any element to restyle only its subtree); no rebuild i
30
30
  - Component groups: `@jcsoftdev/ui/atoms`, `forms`, `overlays`, `navigation`, `containers`, `states`, `shell`, `palette`, `calendar`, `charts`, `dashboards`, `utils`, each also per component (`@jcsoftdev/ui/atoms/button`).
31
31
  - `@jcsoftdev/ui/ai`: chat, prompt input, markdown and code block building blocks for AI interfaces.
32
32
  - `@jcsoftdev/ui/rich-text`: rich text editor with a sanitizer.
33
+ - `@jcsoftdev/ui/i18n`: `UiStringsProvider`, `useUiStrings` and the `en`/`es` string presets.
33
34
  - `@jcsoftdev/ui/xlsx`: dependency-free `.xlsx` writer.
34
35
  - `@jcsoftdev/ui/brand`: logo mark and logo forge utilities.
35
36
  - `@jcsoftdev/ui/styles/*` and `@jcsoftdev/ui/tokens/*`: CSS and token JSON.
@@ -97,6 +98,88 @@ A custom tokens file must define the same names as `tokens/default.json`; copy i
97
98
 
98
99
  Switching at runtime: set `data-theme="light|dark"` (remove it for "system") and `data-accent="<palette-id>"` on `<html>`.
99
100
 
101
+ ## Touch density
102
+
103
+ `data-density="touch"` is the preset for phones operated with a thumb: controls and small buttons are
104
+ 44px tall, large buttons 52px, text inputs 48px, and padding follows `comfortable`. Components with
105
+ fixed hit areas (Checkbox, Switch, RadioGroup rows, an interactive Chip, the Dialog and Drawer close
106
+ buttons) grow to at least 44px under it and are unchanged under every other level.
107
+
108
+ ```html
109
+ <html data-density="touch"> <!-- or any subtree: <main data-density="touch"> -->
110
+ ```
111
+
112
+ Portaled content (Dialog, Drawer) leaves the subtree, so set the attribute on `<html>` or pass the
113
+ `container` prop to keep it inside. Custom token files can declare absolute sizes per level:
114
+ `{ "id": "touch", "density": 1.125, "values": { "control-height": "44px" } }`; the keys must be
115
+ tokens listed in `styles.density.scaled`.
116
+
117
+ ## Localized default strings
118
+
119
+ Default texts (Retry, Close, Loading, the optional marker, Cancel, Confirm, Processing…) come from
120
+ `UiStringsProvider`. `en` is the default and needs no provider; `es` is neutral Spanish (tú).
121
+ Explicit props such as `retryLabel` or `closeLabel` always win.
122
+
123
+ ```tsx
124
+ import { UiStringsProvider } from "@jcsoftdev/ui/i18n";
125
+
126
+ <UiStringsProvider locale="es" strings={{ retry: "Probar otra vez" }}>
127
+ <App />
128
+ </UiStringsProvider>;
129
+ ```
130
+
131
+ `uiStrings.en` and `uiStrings.es` export the presets; `useUiStrings()` reads the active set. A nested
132
+ provider inherits its parent and overrides only the keys it passes. Hard-coded Spanish labels that
133
+ older components already ship (NumberStepper, Carousel, AI set) are not routed through it yet.
134
+
135
+ ## Status, forms and states (additive props)
136
+
137
+ | Component | Added |
138
+ | --- | --- |
139
+ | `Button` | `loadingLabel` (shown instead of the children while `loading`, announced through a status region), `block` (full width), `size="lg"` (52px at normal density, scaled like the other controls). |
140
+ | `Field` | `optional` appends the localized "(optional)" marker to the label; `error` also accepts `null`. |
141
+ | `Banner` | `title`; a default glyph per tone when `icon` is not passed (`icon={null}` hides it, so status never relies on color alone); only `danger` is `role="alert"`, the rest are `role="status"`; the `info` tone uses the `info` tokens. |
142
+ | `Chip` | `icon`, `statusIcon` (prefixes `info`, `success`, `warning`, `danger` chips with their glyph), and an `info` tone. |
143
+ | `ErrorState` | `title` and `description` (`message` keeps working as the headline); an `Error` or any non-renderable value is never printed, a generic localized sentence is shown instead. |
144
+
145
+ ## ConfirmAction and ResponsiveList
146
+
147
+ ```tsx
148
+ import { ConfirmAction } from "@jcsoftdev/ui/containers/confirm-action";
149
+
150
+ <ConfirmAction
151
+ label="Cancel order"
152
+ consequence="The order of Marco Salazar is cancelled and S/ 45 is refunded."
153
+ onConfirm={async () => {
154
+ const res = await cancelOrder(id);
155
+ return res.ok ? undefined : { ok: false, error: "The payment is already settled." };
156
+ }}
157
+ />;
158
+ ```
159
+
160
+ `ConfirmAction` swaps its trigger for the consequence, a danger button and a Cancel button that takes
161
+ focus. While `onConfirm` runs the button shows the localized "Processing…"; a returned
162
+ `{ ok: false, error }` or a thrown error keeps the step open with an inline message.
163
+
164
+ ```tsx
165
+ import { ResponsiveList } from "@jcsoftdev/ui/containers/responsive-list";
166
+
167
+ <ResponsiveList
168
+ caption="Today's orders"
169
+ items={orders}
170
+ rowKey={(o) => o.id}
171
+ columns={[
172
+ { key: "customer", header: "Customer", cell: (o) => o.customer, primary: true },
173
+ { key: "total", header: "Total", cell: (o) => o.total, align: "end" },
174
+ ]}
175
+ renderActions={(o) => <Button size="sm">Open</Button>}
176
+ />;
177
+ ```
178
+
179
+ A table from `md` up (library `Table` styling) and a stack of cards below it; the `primary` column
180
+ (default: the first) titles each card and the other columns become label/value rows. Every row
181
+ carries `data-row-key`; `rowClassName` applies to both layouts.
182
+
100
183
  ## Install a single component (tree shaking)
101
184
 
102
185
  The package is unbundled ESM, `"sideEffects": ["**/*.css"]`, and every component has its own entry point:
@@ -128,6 +211,6 @@ The `source` export condition points at `src/*/index.ts` for consumers that want
128
211
 
129
212
  ## Conventions
130
213
 
131
- - English identifiers; every visible string and aria label is a prop (aria defaults in English).
214
+ - English identifiers; every visible string and aria label is a prop. Defaults are English and can be localized in one place with `UiStringsProvider`.
132
215
  - Relative imports use the `.js` extension; components import specific component entries, never group barrels.
133
216
  - No literal colors in components, no `dark:` variants; dark mode is a token concern.
@@ -1,11 +1,15 @@
1
- import { type ComponentPropsWithoutRef } from "react";
1
+ import { type ComponentPropsWithRef } from "react";
2
2
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
3
- export type ButtonSize = "sm" | "md";
4
- export interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
3
+ export type ButtonSize = "sm" | "md" | "lg";
4
+ export interface ButtonProps extends ComponentPropsWithRef<"button"> {
5
5
  variant?: ButtonVariant;
6
6
  size?: ButtonSize;
7
7
  loading?: boolean;
8
+ /** Replaces the children while `loading` and is announced to screen readers. */
9
+ loadingLabel?: string;
10
+ /** Stretch to the full width of the container. */
11
+ block?: boolean;
8
12
  /** Render the child element (via Radix Slot) instead of a `<button>`, e.g. a link. */
9
13
  asChild?: boolean;
10
14
  }
11
- export declare function Button({ variant, size, loading, disabled, asChild, className, children, ...props }: ButtonProps): import("react").JSX.Element;
15
+ export declare function Button({ variant, size, loading, loadingLabel, block, disabled, asChild, className, children, ...props }: ButtonProps): import("react").JSX.Element;
@@ -1,9 +1,9 @@
1
1
  "use client";
2
- import { jsx } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { Slot } from "@radix-ui/react-slot";
4
4
  import {
5
5
  Children,
6
- Fragment,
6
+ Fragment as Fragment2,
7
7
  isValidElement
8
8
  } from "react";
9
9
  import { cn } from "../../utils/cn.js";
@@ -15,12 +15,15 @@ const variantClasses = {
15
15
  };
16
16
  const sizeClasses = {
17
17
  sm: "h-control-sm px-3 text-[12.5px] [&_svg]:size-3.5",
18
- md: "h-control px-control-x text-[13px]"
18
+ md: "h-control px-control-x text-[13px]",
19
+ lg: "h-control-lg px-control-x text-[14px]"
19
20
  };
20
21
  function Button({
21
22
  variant = "secondary",
22
23
  size = "md",
23
24
  loading,
25
+ loadingLabel,
26
+ block,
24
27
  disabled,
25
28
  asChild,
26
29
  className,
@@ -28,8 +31,9 @@ function Button({
28
31
  ...props
29
32
  }) {
30
33
  const Comp = asChild ? Slot : "button";
31
- const content = asChild ? children : Children.map(children, labelRun);
32
- return /* @__PURE__ */ jsx(
34
+ const showLoadingLabel = loading && loadingLabel !== void 0 && !asChild;
35
+ const content = showLoadingLabel ? labelRun(loadingLabel) : asChild ? children : Children.map(children, labelRun);
36
+ const button = /* @__PURE__ */ jsx(
33
37
  Comp,
34
38
  {
35
39
  ...asChild ? {} : { type: "button" },
@@ -39,18 +43,24 @@ function Button({
39
43
  "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",
40
44
  variantClasses[variant],
41
45
  sizeClasses[size],
46
+ block && "flex w-full",
42
47
  className
43
48
  ),
44
49
  ...props,
45
50
  children: content
46
51
  }
47
52
  );
53
+ if (loadingLabel === void 0) return button;
54
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
55
+ button,
56
+ /* @__PURE__ */ jsx("span", { role: "status", className: "sr-only", children: loading ? loadingLabel : null })
57
+ ] });
48
58
  }
49
59
  function labelRun(child) {
50
60
  if (typeof child === "string" || typeof child === "number") {
51
61
  return /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: child });
52
62
  }
53
- if (isValidElement(child) && child.type === Fragment) {
63
+ if (isValidElement(child) && child.type === Fragment2) {
54
64
  return Children.map(child.props.children, labelRun);
55
65
  }
56
66
  return child;
@@ -7,7 +7,10 @@ function Checkbox({ className, ...props }) {
7
7
  return /* @__PURE__ */ jsx(
8
8
  CheckboxPrimitive.Root,
9
9
  {
10
- className: cn("inline-flex size-10 shrink-0 items-center justify-center", className),
10
+ className: cn(
11
+ "inline-flex size-10 shrink-0 items-center justify-center in-data-[density=touch]:size-11",
12
+ className
13
+ ),
11
14
  ...props,
12
15
  children: /* @__PURE__ */ jsx("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", children: /* @__PURE__ */ jsx(CheckboxPrimitive.Indicator, { children: /* @__PURE__ */ jsx(Check, { "aria-hidden": "true", className: "size-[11px] stroke-[3] text-on-accent" }) }) })
13
16
  }
@@ -1,8 +1,12 @@
1
- import type { ComponentPropsWithoutRef } from "react";
2
- export type ChipTone = "neutral" | "danger" | "warning" | "success" | "accent" | "solid" | "outline";
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ export type ChipTone = "neutral" | "danger" | "warning" | "success" | "accent" | "info" | "solid" | "outline";
3
3
  export interface ChipProps extends ComponentPropsWithoutRef<"span"> {
4
4
  tone?: ChipTone;
5
5
  /** Use the smaller chip radius instead of a full pill. */
6
6
  square?: boolean;
7
+ /** Leading decorative icon; wins over `statusIcon`. */
8
+ icon?: ReactNode;
9
+ /** Prefix `info`, `success`, `warning` and `danger` chips with their status glyph. */
10
+ statusIcon?: boolean;
7
11
  }
8
- export declare function Chip({ tone, square, className, ...props }: ChipProps): import("react").JSX.Element;
12
+ export declare function Chip({ tone, square, icon, statusIcon: withStatusIcon, className, children, ...props }: ChipProps): import("react").JSX.Element;
@@ -1,25 +1,43 @@
1
- import { jsx } from "react/jsx-runtime";
1
+ import { jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "../../utils/cn.js";
3
+ import { statusIcon } from "../internal/status-icons.js";
3
4
  const toneClasses = {
4
5
  neutral: "bg-surface-2 text-ink-2",
5
6
  danger: "bg-danger-soft text-danger",
6
7
  warning: "bg-warning-soft text-warning",
7
8
  success: "bg-success-soft text-success",
8
9
  accent: "bg-accent-soft text-accent-ink",
10
+ info: "bg-info-soft text-info",
9
11
  solid: "bg-accent text-on-accent",
10
12
  outline: "bg-transparent text-ink-2 shadow-[inset_0_0_0_1px_var(--line-strong)]"
11
13
  };
12
- function Chip({ tone = "neutral", square, className, ...props }) {
13
- return /* @__PURE__ */ jsx(
14
+ const statusTones = /* @__PURE__ */ new Set(["info", "success", "warning", "danger"]);
15
+ function Chip({
16
+ tone = "neutral",
17
+ square,
18
+ icon,
19
+ statusIcon: withStatusIcon,
20
+ className,
21
+ children,
22
+ ...props
23
+ }) {
24
+ const leading = icon ?? (withStatusIcon && statusTones.has(tone) ? statusIcon(tone, "size-3") : null);
25
+ const interactive = props.onClick !== void 0 || props.role === "button";
26
+ return /* @__PURE__ */ jsxs(
14
27
  "span",
15
28
  {
16
29
  className: cn(
17
30
  "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",
18
31
  square ? "rounded-chip" : "rounded-badge",
19
32
  toneClasses[tone],
33
+ interactive && "in-data-[density=touch]:min-h-11",
20
34
  className
21
35
  ),
22
- ...props
36
+ ...props,
37
+ children: [
38
+ leading,
39
+ children
40
+ ]
23
41
  }
24
42
  );
25
43
  }
@@ -0,0 +1,4 @@
1
+ import type { ReactNode } from "react";
2
+ export type StatusTone = "info" | "success" | "warning" | "danger";
3
+ /** Decorative glyph that pairs a status tone with a shape, so status never relies on color alone. */
4
+ export declare function statusIcon(tone: StatusTone, className?: string): ReactNode;
@@ -0,0 +1,15 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { CircleAlert, CircleCheck, Info, TriangleAlert } from "lucide-react";
3
+ const icons = {
4
+ info: Info,
5
+ success: CircleCheck,
6
+ warning: TriangleAlert,
7
+ danger: CircleAlert
8
+ };
9
+ function statusIcon(tone, className = "size-4") {
10
+ const Icon = icons[tone];
11
+ return /* @__PURE__ */ jsx(Icon, { "aria-hidden": "true", className });
12
+ }
13
+ export {
14
+ statusIcon
15
+ };
@@ -19,7 +19,7 @@ function RadioGroup({ options, className, ...props }) {
19
19
  "label",
20
20
  {
21
21
  htmlFor: id,
22
- 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)]",
22
+ 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)]",
23
23
  children: [
24
24
  /* @__PURE__ */ jsx(
25
25
  RadioGroupPrimitive.Item,
@@ -6,7 +6,10 @@ function Switch({ className, ...props }) {
6
6
  return /* @__PURE__ */ jsx(
7
7
  SwitchPrimitive.Root,
8
8
  {
9
- className: cn("inline-flex size-10 shrink-0 items-center justify-center", className),
9
+ className: cn(
10
+ "inline-flex size-10 shrink-0 items-center justify-center in-data-[density=touch]:size-11",
11
+ className
12
+ ),
10
13
  ...props,
11
14
  children: /* @__PURE__ */ jsx("span", { className: "relative block h-5 w-[34px] rounded-pill bg-line-strong transition-colors data-[state=checked]:bg-accent", children: /* @__PURE__ */ jsx(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" }) })
12
15
  }
@@ -22,7 +22,7 @@ export interface GaugeChartProps {
22
22
  variant?: "semi" | "three-quarter";
23
23
  /** Filled arc (`progress`) or a pointer over the bands (`needle`). */
24
24
  mode?: "progress" | "needle";
25
- /** Colored bands. With `progress` they sit behind the arc and the arc takes the color of the band it is in. */
25
+ /** 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. */
26
26
  ranges?: readonly GaugeRange[];
27
27
  height?: number | "fill";
28
28
  /** Width / height ratio; sizes the drawing from the container width when `height` is not "fill". */
@@ -282,7 +282,7 @@ function GaugePlot({
282
282
  )
283
283
  }
284
284
  ) }) : null,
285
- bands.length === 0 ? /* @__PURE__ */ jsx(
285
+ bands.length === 0 || !needle ? /* @__PURE__ */ jsx(
286
286
  "path",
287
287
  {
288
288
  d: arcLine(cx, cy, mid, a0 + capAngle, a1 - capAngle),
@@ -291,7 +291,8 @@ function GaugePlot({
291
291
  strokeLinecap: roundedCaps ? "round" : "butt",
292
292
  style: { stroke: "var(--chart-grid)" }
293
293
  }
294
- ) : /* @__PURE__ */ jsx("g", { mask: roundedCaps ? `url(#${uid}-track)` : void 0, children: bandPaths.map((path, index) => {
294
+ ) : null,
295
+ bands.length > 0 ? /* @__PURE__ */ jsx("g", { mask: roundedCaps ? `url(#${uid}-track)` : void 0, children: bandPaths.map((path, index) => {
295
296
  const item = bands[index];
296
297
  if (!item || !path) return null;
297
298
  return /* @__PURE__ */ jsx(
@@ -300,14 +301,14 @@ function GaugePlot({
300
301
  d: path,
301
302
  width: band,
302
303
  color: rangeColor(item),
303
- opacity: needle ? 0.92 : 0.26,
304
- dimmed: focused != null && focused !== index,
304
+ opacity: needle ? 0.92 : focused === index ? 0.32 : 0,
305
+ dimmed: needle && focused != null && focused !== index,
305
306
  onEnter: (event) => nav.hover(index, event),
306
307
  onLeave: nav.leave
307
308
  },
308
309
  index
309
310
  );
310
- }) }),
311
+ }) }) : null,
311
312
  !needle && fraction > 0 ? /* @__PURE__ */ jsx(
312
313
  "path",
313
314
  {
@@ -1,10 +1,12 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
2
  export type BannerTone = "info" | "success" | "warning" | "danger";
3
- export interface BannerProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
3
+ export interface BannerProps extends Omit<ComponentPropsWithoutRef<"div">, "role" | "title"> {
4
4
  tone?: BannerTone;
5
- /** Decorative icon on the left; marked `aria-hidden`. */
5
+ /** Bold first line above the content. */
6
+ title?: ReactNode;
7
+ /** Decorative icon on the left; marked `aria-hidden`. Defaults to a glyph for the tone; `null` hides it. */
6
8
  icon?: ReactNode;
7
9
  /** Actions on the right, e.g. a retry button. */
8
10
  action?: ReactNode;
9
11
  }
10
- export declare function Banner({ tone, icon, action, className, children, ...props }: BannerProps): import("react").JSX.Element;
12
+ export declare function Banner({ tone, title, icon, action, className, children, ...props }: BannerProps): import("react").JSX.Element;
@@ -1,13 +1,14 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { statusIcon } from "../../atoms/internal/status-icons.js";
2
3
  import { cn } from "../../utils/cn.js";
3
4
  const toneClasses = {
4
- info: "bg-accent-soft",
5
+ info: "bg-info-soft",
5
6
  success: "bg-success-soft",
6
7
  warning: "bg-warning-soft",
7
8
  danger: "bg-danger-soft"
8
9
  };
9
10
  const toneIconClasses = {
10
- info: "text-accent-ink",
11
+ info: "text-info",
11
12
  success: "text-success",
12
13
  warning: "text-warning",
13
14
  danger: "text-danger"
@@ -15,17 +16,19 @@ const toneIconClasses = {
15
16
  const toneRole = {
16
17
  info: "status",
17
18
  success: "status",
18
- warning: "alert",
19
+ warning: "status",
19
20
  danger: "alert"
20
21
  };
21
22
  function Banner({
22
23
  tone = "info",
24
+ title,
23
25
  icon,
24
26
  action,
25
27
  className,
26
28
  children,
27
29
  ...props
28
30
  }) {
31
+ const resolvedIcon = icon === void 0 ? statusIcon(tone) : icon;
29
32
  return /* @__PURE__ */ jsxs(
30
33
  "div",
31
34
  {
@@ -37,8 +40,11 @@ function Banner({
37
40
  ),
38
41
  ...props,
39
42
  children: [
40
- icon ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: cn("mt-0.5 shrink-0", toneIconClasses[tone]), children: icon }) : null,
41
- /* @__PURE__ */ jsx("div", { className: "min-w-0 flex-[1_1_14rem] break-words", children }),
43
+ resolvedIcon ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: cn("mt-0.5 shrink-0", toneIconClasses[tone]), children: resolvedIcon }) : null,
44
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-[1_1_14rem] break-words", children: [
45
+ title ? /* @__PURE__ */ jsx("p", { className: "font-semibold", children: title }) : null,
46
+ children
47
+ ] }),
42
48
  action ? /* @__PURE__ */ jsx("div", { className: "ml-auto flex shrink-0 flex-wrap items-center gap-1.5", children: action }) : null
43
49
  ]
44
50
  }
@@ -0,0 +1,17 @@
1
+ import { type ButtonVariant } from "../../atoms/button/button.js";
2
+ import { type ConfirmResult } from "./model.js";
3
+ export interface ConfirmActionProps {
4
+ /** Trigger label, a verb in the user's words ("Cancel order"). */
5
+ label: string;
6
+ /** What will happen, said plainly, shown before the user commits. */
7
+ consequence: string;
8
+ /** Label of the destructive button. Defaults to the localized "Confirm". */
9
+ confirmLabel?: string;
10
+ cancelLabel?: string;
11
+ /** Runs on confirm. Return `{ ok: false, error }` or throw to keep the step open with a message. */
12
+ onConfirm: () => Promise<ConfirmResult> | Promise<void>;
13
+ triggerVariant?: ButtonVariant;
14
+ className?: string;
15
+ }
16
+ /** Inline two-step confirmation for a destructive action; no modal, no lost context. */
17
+ export declare function ConfirmAction({ label, consequence, confirmLabel, cancelLabel, onConfirm, triggerVariant, className, }: ConfirmActionProps): import("react").JSX.Element;
@@ -0,0 +1,71 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ import { Button } 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 { runConfirm } from "./model.js";
9
+ function ConfirmAction({
10
+ label,
11
+ consequence,
12
+ confirmLabel,
13
+ cancelLabel,
14
+ onConfirm,
15
+ triggerVariant = "secondary",
16
+ className
17
+ }) {
18
+ const strings = useUiStrings();
19
+ const consequenceId = useId();
20
+ const triggerRef = useRef(null);
21
+ const wasConfirming = useRef(false);
22
+ const [confirming, setConfirming] = useState(false);
23
+ const [busy, setBusy] = useState(false);
24
+ const [failure, setFailure] = useState(null);
25
+ useEffect(() => {
26
+ if (wasConfirming.current && !confirming) triggerRef.current?.focus();
27
+ wasConfirming.current = confirming;
28
+ }, [confirming]);
29
+ async function confirm() {
30
+ setBusy(true);
31
+ setFailure(null);
32
+ const outcome = await runConfirm(onConfirm, strings.actionFailed);
33
+ setBusy(false);
34
+ if (outcome.status === "failed") setFailure(outcome.message);
35
+ else setConfirming(false);
36
+ }
37
+ if (!confirming) {
38
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2", className), children: [
39
+ failure === null ? null : /* @__PURE__ */ jsx(Banner, { tone: "danger", children: failure }),
40
+ /* @__PURE__ */ jsx(Button, { ref: triggerRef, variant: triggerVariant, onClick: () => setConfirming(true), children: label })
41
+ ] });
42
+ }
43
+ return /* @__PURE__ */ jsxs(
44
+ "fieldset",
45
+ {
46
+ "aria-describedby": consequenceId,
47
+ className: cn("m-0 flex min-w-0 flex-col gap-3 border-0 p-0", className),
48
+ children: [
49
+ /* @__PURE__ */ jsx("legend", { className: "sr-only", children: label }),
50
+ /* @__PURE__ */ jsx("p", { id: consequenceId, className: "text-body text-ink", children: consequence }),
51
+ failure === null ? null : /* @__PURE__ */ jsx(Banner, { tone: "danger", children: failure }),
52
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
53
+ /* @__PURE__ */ jsx(
54
+ Button,
55
+ {
56
+ variant: "danger",
57
+ loading: busy,
58
+ loadingLabel: strings.processing,
59
+ onClick: () => void confirm(),
60
+ children: confirmLabel ?? strings.confirm
61
+ }
62
+ ),
63
+ /* @__PURE__ */ jsx(Button, { variant: "secondary", disabled: busy, autoFocus: true, onClick: () => setConfirming(false), children: cancelLabel ?? strings.cancel })
64
+ ] })
65
+ ]
66
+ }
67
+ );
68
+ }
69
+ export {
70
+ ConfirmAction
71
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./confirm-action.js";
2
+ export type { ConfirmResult } from "./model.js";
@@ -0,0 +1 @@
1
+ export * from "./confirm-action.js";
@@ -0,0 +1,14 @@
1
+ export type ConfirmResult = {
2
+ ok: true;
3
+ } | {
4
+ ok: false;
5
+ error: string;
6
+ };
7
+ export type ConfirmOutcome = {
8
+ status: "done";
9
+ } | {
10
+ status: "failed";
11
+ message: string;
12
+ };
13
+ /** Runs the confirmed action and folds a returned failure or a thrown error into one outcome. */
14
+ export declare function runConfirm(onConfirm: () => Promise<ConfirmResult> | Promise<void>, fallbackMessage: string): Promise<ConfirmOutcome>;
@@ -0,0 +1,12 @@
1
+ async function runConfirm(onConfirm, fallbackMessage) {
2
+ try {
3
+ const result = await onConfirm();
4
+ if (result !== void 0 && !result.ok) return { status: "failed", message: result.error };
5
+ return { status: "done" };
6
+ } catch {
7
+ return { status: "failed", message: fallbackMessage };
8
+ }
9
+ }
10
+ export {
11
+ runConfirm
12
+ };
@@ -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";
@@ -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 @@
1
+ export * from "./responsive-list.js";
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from "react";
2
+ export interface ResponsiveListColumn<T> {
3
+ key: string;
4
+ header: string;
5
+ cell: (item: T) => ReactNode;
6
+ /** Title of the stacked card. Defaults to the first column. */
7
+ primary?: boolean;
8
+ align?: "start" | "end";
9
+ }
10
+ export interface ResponsiveListProps<T> {
11
+ /** Accessible name of the table and of the card list. */
12
+ caption: string;
13
+ items: readonly T[];
14
+ columns: readonly ResponsiveListColumn<T>[];
15
+ rowKey: (item: T) => string;
16
+ /** Per-row actions: a last table column, and a footer row on each card. */
17
+ renderActions?: (item: T) => ReactNode;
18
+ /** Header of the actions column. Defaults to the localized "Actions". */
19
+ actionsHeader?: string;
20
+ rowClassName?: (item: T) => string | undefined;
21
+ className?: string;
22
+ }
23
+ /** A table from `md` up and a stack of cards below it, from one column definition. */
24
+ export declare function ResponsiveList<T>({ caption, items, columns, rowKey, renderActions, actionsHeader, rowClassName, className, }: ResponsiveListProps<T>): import("react").JSX.Element;