@sincpro/web-ui 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,10 @@ function Facts({ fields, columns, layout, }) {
29
29
  /* The note's card goes INSIDE the value: a `<dl>` holds only its terms and values.
30
30
  With a note the value is a flex box, so the absolute card keeps the value's top
31
31
  as its static position while being read after it. */
32
- const value = (_jsxs("dd", { ...(noteId === undefined ? {} : { "aria-describedby": noteId }), className: `min-w-0 text-meta ${card === null ? "" : "flex"} ${empty ? "text-muted italic" : "text-ink"} ${field.mono === true ? "font-mono tabular-nums" : ""}`, children: [empty ? "—" : field.value, card] }));
32
+ const value = (_jsxs("dd", { ...(noteId === undefined ? {} : { "aria-describedby": noteId }),
33
+ /* `wrap-anywhere`: an email or a code has no space to break at, and in two
34
+ columns it ran over the fact beside it. */
35
+ className: `min-w-0 text-meta wrap-anywhere ${card === null ? "" : "flex"} ${empty ? "text-muted italic" : "text-ink"} ${field.mono === true ? "font-mono tabular-nums" : ""}`, children: [empty ? "—" : field.value, card] }));
33
36
  const name = (_jsxs("dt", { className: `flex items-center gap-1 text-micro text-muted ${horizontal ? "shrink-0" : "min-w-0"}`, children: [_jsx("span", { children: field.label }), field.hint === undefined ? null : _jsx(Hint, { children: field.hint }), field.note === undefined ? null : _jsx(NoteFlag, { note: field.note })] }));
34
37
  if (horizontal) {
35
38
  /* A header fact is a pair: the number sits next to its name. The
@@ -19,10 +19,14 @@ export declare function RecordCard({ title, subtitle, tags, meta, figure, media,
19
19
  tags?: ReactNode;
20
20
  meta?: ReactNode;
21
21
  figure?: ReactNode;
22
+ /** The picture of the record. A product's is a square (`Thumb`); a PERSON's is round, with
23
+ * the initials when there is no photo — `person`, like `RelationCard`'s. `alt` names the
24
+ * person when the title is not plain text. */
22
25
  media?: {
23
26
  src?: string;
24
27
  alt?: string;
25
28
  icon?: LucideIcon;
29
+ person?: true;
26
30
  };
27
31
  /** The card's last line, its controls: priority, kanban state, next activity, the owner's
28
32
  * avatar — each its own button, pressable without opening the card. */
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Thumb } from "../../primitives/cell/CellStack.js";
4
+ import { Avatar } from "../../primitives/value/Avatar.js";
4
5
  /**
5
6
  * One record as a card — the board presentation of a list.
6
7
  *
@@ -21,7 +22,9 @@ export function RecordCard({ title, subtitle, tags, meta, figure, media, footer,
21
22
  const titleText = _jsx("span", { className: "min-w-0 truncate", children: title });
22
23
  return (_jsxs("div", { className: `relative flex w-full min-w-0 flex-col gap-2 rounded-box border border-line bg-surface p-4 text-left ${onOpen === undefined
23
24
  ? ""
24
- : "transition-colors duration-(--duration-instant) focus-within:border-line-strong hover:border-line-strong"}`, children: [media === undefined ? null : (_jsx(Thumb, { size: "lg", ...(media.src === undefined ? {} : { src: media.src }), ...(media.alt === undefined ? {} : { alt: media.alt }), ...(media.icon === undefined ? {} : { icon: media.icon }) })), tags === undefined ? null : _jsx("div", { className: "flex flex-wrap gap-1.5", children: tags }), _jsxs("div", { className: "flex min-w-0 items-baseline justify-between gap-3", children: [onOpen === undefined ? (_jsx("p", { className: "flex min-w-0 text-lead font-medium text-ink", children: titleText })) : (_jsx("button", { type: "button", onClick: onOpen, className: "flex min-w-0 text-left text-lead font-medium text-ink outline-none after:absolute after:inset-0 after:rounded-box focus-visible:after:outline-2 focus-visible:after:outline-primary", children: titleText })), figure === undefined ? null : (_jsx("p", { className: "shrink-0 text-lead font-semibold tabular-nums text-ink", children: figure }))] }), subtitle === undefined ? null : (
25
+ : "transition-colors duration-(--duration-instant) focus-within:border-line-strong hover:border-line-strong"}`, children: [media === undefined ? null : media.person === true ? (
26
+ /* The name is written right below it: the photo's `alt` would say it twice. */
27
+ _jsx("span", { "aria-hidden": true, className: "flex shrink-0", children: _jsx(Avatar, { name: media.alt ?? (typeof title === "string" ? title : ""), size: "xl", ...(media.src === undefined ? {} : { src: media.src }) }) })) : (_jsx(Thumb, { size: "lg", ...(media.src === undefined ? {} : { src: media.src }), ...(media.alt === undefined ? {} : { alt: media.alt }), ...(media.icon === undefined ? {} : { icon: media.icon }) })), tags === undefined ? null : _jsx("div", { className: "flex flex-wrap gap-1.5", children: tags }), _jsxs("div", { className: "flex min-w-0 items-baseline justify-between gap-3", children: [onOpen === undefined ? (_jsx("p", { className: "flex min-w-0 text-lead font-medium text-ink", children: titleText })) : (_jsx("button", { type: "button", onClick: onOpen, className: "flex min-w-0 text-left text-lead font-medium text-ink outline-none after:absolute after:inset-0 after:rounded-box focus-visible:after:outline-2 focus-visible:after:outline-primary", children: titleText })), figure === undefined ? null : (_jsx("p", { className: "shrink-0 text-lead font-semibold tabular-nums text-ink", children: figure }))] }), subtitle === undefined ? null : (
25
28
  /* Above the stretched title, at its own width: the subtitle may be a link to ANOTHER
26
29
  record (the customer), which must open that one and not the card. */
27
30
  _jsx("p", { className: "relative z-10 -mt-1.5 w-fit max-w-full min-w-0 truncate text-meta text-muted", children: subtitle })), meta === undefined ? null : (_jsx("p", { className: "text-micro tabular-nums text-muted", children: meta })), footer === undefined ? null : (_jsx("div", { className: "relative z-10 flex min-w-0 items-center gap-1.5", children: footer }))] }));
@@ -7,9 +7,13 @@ export interface DirtyState {
7
7
  /** What gets saved, when it is not obvious: «guardar la factura», «guardar la
8
8
  * regla». */
9
9
  label?: string;
10
- /** Why it CANNOT be saved yet. Present = the button is off and the reason is
11
- * in its `title` — a button that is off with no reason is a wall. */
10
+ /** Why it CANNOT be saved yet. Present = the button is off and the reason is written
11
+ * where the «unsaved» mark was — a button that is off with no reason is a wall, and a
12
+ * reason in a `title` reaches neither the keyboard nor a finger. */
12
13
  blocked?: string;
14
+ /** How many fields changed: «2 cambios sin guardar» instead of a bare «sin guardar». The
15
+ * fields themselves are marked by their `Field changed`. */
16
+ changes?: number;
13
17
  }
14
18
  /** Save and discard, in the rail and in place of the action buttons.
15
19
  *
@@ -20,7 +24,17 @@ export interface DirtyState {
20
24
  * The «sin guardar» warning is text and not just a color: whoever is leaving
21
25
  * the screen has to be able to read what is going on, not deduce it from a
22
26
  * dot. */
23
- export declare function DirtyActions({ onSave, onDiscard, saving, label, blocked, }: DirtyState): import("react").JSX.Element;
24
- /** The one action a view leads with: the element written with `role="primary"` — the
25
- * same prop that paints it gold and that `make smoke` counts, so there is no second way to
26
- * say «this is the main one» that could disagree with the first. */
27
+ export declare function DirtyActions({ onSave, onDiscard, saving, label, blocked, changes, }: DirtyState): import("react").JSX.Element;
28
+ /**
29
+ * Whether the record has JUST been saved: `dirty` went from saving straight to gone.
30
+ *
31
+ * Context: saving ended with the rail silently swapping Save for the usual actions — the
32
+ * screen changed and said nothing, so each application wrote its own toast, or none. A
33
+ * failed save does not count: `saving` turns off and `dirty` stays.
34
+ */
35
+ export declare function useJustSaved(dirty: DirtyState | undefined): boolean;
36
+ /** «✓ Guardado», where Save was, for a moment. The live region is always there — a region
37
+ * inserted together with its words is not reliably announced. */
38
+ export declare function SavedMark({ saved }: {
39
+ saved: boolean;
40
+ }): import("react").JSX.Element;
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useUiCopy } from "../../foundations/copy.js";
3
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
4
  import { bySurface, useSurface } from "../../foundations/surface.js";
5
5
  import { Button } from "../../primitives/button/Button.js";
6
- import { Save, Undo2 } from "lucide-react";
6
+ import { Check, Save, Undo2 } from "lucide-react";
7
+ import { useEffect, useId, useRef, useState } from "react";
7
8
  /** Save and discard, in the rail and in place of the action buttons.
8
9
  *
9
10
  * Context: discard goes to the LEFT of save and with the quieter look. It is
@@ -13,10 +14,60 @@ import { Save, Undo2 } from "lucide-react";
13
14
  * The «sin guardar» warning is text and not just a color: whoever is leaving
14
15
  * the screen has to be able to read what is going on, not deduce it from a
15
16
  * dot. */
16
- export function DirtyActions({ onSave, onDiscard, saving = false, label, blocked, }) {
17
+ export function DirtyActions({ onSave, onDiscard, saving = false, label, blocked, changes, }) {
17
18
  const copy = useUiCopy();
19
+ const blockedId = useId();
18
20
  /* It follows the rail it sits on instead of assuming the dark one: on a light rail the
19
21
  chrome palette was white text on white. */
20
22
  const surface = useSurface();
21
- return (_jsxs(_Fragment, { children: [_jsx("span", { className: `inline-flex h-(--size-control-md) shrink-0 items-center gap-1.5 rounded-control px-2.5 text-meta font-medium ${bySurface(surface, "bg-warn/12 text-warn", "bg-chrome-warn/15 text-chrome-warn")}`, children: copy.record.unsaved }), _jsx(Button, { surface: surface, onClick: onDiscard, disabled: saving, icon: Undo2, children: copy.record.discard }), _jsx("span", { ...(blocked === undefined ? {} : { title: blocked }), children: _jsx(Button, { role: "primary", surface: surface, onClick: onSave, disabled: saving || blocked !== undefined, icon: Save, children: saving ? copy.record.saving : (label ?? copy.record.save) }) })] }));
23
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ...(blocked === undefined ? {} : { id: blockedId }), className: `inline-flex h-(--size-control-md) min-w-0 shrink items-center gap-1.5 truncate rounded-control px-2.5 text-meta font-medium ${bySurface(surface, "bg-warn/12 text-warn", "bg-chrome-warn/15 text-chrome-warn")}`, children: blocked ??
24
+ (changes === undefined || changes === 0
25
+ ? copy.record.unsaved
26
+ : fillCopy(changes === 1 ? copy.record.unsavedOne : copy.record.unsavedMany, {
27
+ count: changes.toLocaleString(copy.locale),
28
+ })) }), _jsx(Button, { surface: surface, onClick: onDiscard, disabled: saving, icon: Undo2, children: copy.record.discard }), _jsx(Button, { role: "primary", surface: surface, onClick: onSave, busy: saving, disabled: blocked !== undefined, icon: Save, ...(blocked === undefined ? {} : { "aria-describedby": blockedId }), children: saving ? copy.record.saving : (label ?? copy.record.save) })] }));
29
+ }
30
+ /** The one action a view leads with: the element written with `role="primary"` — the
31
+ * same prop that paints it gold and that `make smoke` counts, so there is no second way to
32
+ * say «this is the main one» that could disagree with the first. */
33
+ /** How long «Guardado» stays: long enough to be seen without looking for it, short enough
34
+ * not to stay as one more thing on the rail. */
35
+ const SAVED_FOR = 2400;
36
+ /**
37
+ * Whether the record has JUST been saved: `dirty` went from saving straight to gone.
38
+ *
39
+ * Context: saving ended with the rail silently swapping Save for the usual actions — the
40
+ * screen changed and said nothing, so each application wrote its own toast, or none. A
41
+ * failed save does not count: `saving` turns off and `dirty` stays.
42
+ */
43
+ export function useJustSaved(dirty) {
44
+ const wasSaving = useRef(false);
45
+ const [saved, setSaved] = useState(false);
46
+ const saving = dirty?.saving === true;
47
+ const present = dirty !== undefined;
48
+ useEffect(() => {
49
+ if (saving) {
50
+ wasSaving.current = true;
51
+ return;
52
+ }
53
+ if (present) {
54
+ wasSaving.current = false;
55
+ setSaved(false);
56
+ return;
57
+ }
58
+ if (!wasSaving.current)
59
+ return;
60
+ wasSaving.current = false;
61
+ setSaved(true);
62
+ const timer = setTimeout(() => setSaved(false), SAVED_FOR);
63
+ return () => clearTimeout(timer);
64
+ }, [saving, present]);
65
+ return saved;
66
+ }
67
+ /** «✓ Guardado», where Save was, for a moment. The live region is always there — a region
68
+ * inserted together with its words is not reliably announced. */
69
+ export function SavedMark({ saved }) {
70
+ const copy = useUiCopy();
71
+ const surface = useSurface();
72
+ return (_jsxs(_Fragment, { children: [_jsx("span", { role: "status", className: "sr-only", children: saved ? copy.record.saved : "" }), saved ? (_jsxs("span", { "aria-hidden": true, className: `inline-flex h-(--size-control-md) shrink-0 items-center gap-1.5 rounded-control px-2.5 text-meta font-medium transition-[opacity,translate] duration-(--duration-quick) ease-(--ease-out) starting:-translate-y-0.5 starting:opacity-0 ${bySurface(surface, "bg-good/12 text-good", "bg-chrome-good/15 text-chrome-good")}`, children: [_jsx(Check, { className: "size-(--size-icon-sm)", strokeWidth: 2.5 }), copy.record.saved] })) : null] }));
22
73
  }
@@ -2,7 +2,7 @@ import { type Crumb } from "./Breadcrumb.js";
2
2
  import { type DirtyState } from "./DirtyActions.js";
3
3
  import type { Surface, Tone } from "../../foundations/types.js";
4
4
  import type { LucideIcon } from "lucide-react";
5
- import type { ReactNode } from "react";
5
+ import { type ReactNode } from "react";
6
6
  export type { Surface };
7
7
  export declare function RecordHeader({ crumbs, pager, actions, status, kicker, icon: Icon, title, subtitle, tag, figure, railSurface, smart, aspects, dirty, activity, activityLayout, width, ribbon, blocking, indicator, children, }: {
8
8
  crumbs?: readonly Crumb[];
@@ -1,15 +1,31 @@
1
1
  "use client";
2
2
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Breadcrumb } from "./Breadcrumb.js";
4
- import { DirtyActions, } from "./DirtyActions.js";
4
+ import { DirtyActions, SavedMark, useJustSaved, } from "./DirtyActions.js";
5
5
  import { isPrimary, ThumbBar, thumbPlan } from "./ThumbBar.js";
6
6
  import { byDensity, useDensity } from "../../foundations/density.js";
7
7
  import { SurfaceProvider } from "../../foundations/surface.js";
8
8
  import { Line } from "../../primitives/block/Line.js";
9
9
  import { Tag } from "../../primitives/tag/Tag.js";
10
+ import { useEffect, useRef } from "react";
10
11
  export function RecordHeader({ crumbs, pager, actions, status, kicker, icon: Icon, title, subtitle, tag, figure, railSurface = "canvas", smart, aspects, dirty, activity, activityLayout = "auto", width = "reading", ribbon, blocking, indicator, children, }) {
11
12
  const d = useDensity();
12
- const hasRail = crumbs !== undefined ||
13
+ /* «Guardado» is said where Save was; a record whose rail exists only while it has changes
14
+ keeps it for that moment, or the answer would leave with the question. */
15
+ const saved = useJustSaved(dirty);
16
+ /* Save is gone the moment it has saved, and the focus that was on it fell to the page —
17
+ a keyboard was left at the top of the document. It comes back to the record's name:
18
+ where reading the record starts, and announced together with «Guardado». */
19
+ const nameRef = useRef(null);
20
+ useEffect(() => {
21
+ if (!saved)
22
+ return;
23
+ const lost = document.activeElement === null || document.activeElement === document.body;
24
+ if (lost)
25
+ nameRef.current?.focus({ preventScroll: true });
26
+ }, [saved]);
27
+ const hasRail = saved ||
28
+ crumbs !== undefined ||
13
29
  pager !== undefined ||
14
30
  actions !== undefined ||
15
31
  status !== undefined ||
@@ -40,12 +56,14 @@ export function RecordHeader({ crumbs, pager, actions, status, kicker, icon: Ico
40
56
  shadow. It exists on the PAGE, where the chrome rail is a real band
41
57
  (crumbs, pager, actions). A drawer is compact so `withRail` is already
42
58
  false. A dialog is comfortable but has no crumbs or actions — and it IS
43
- already a card. Status alone is not a page. */
44
- const paper = withRail &&
45
- (crumbs !== undefined ||
46
- pager !== undefined ||
47
- actions !== undefined ||
48
- dirty !== undefined);
59
+ already a card. Status alone is not a page.
60
+
61
+ And `dirty` does not decide it. Whether the record sits on paper is a fact of WHERE it
62
+ is mounted, and that does not change while someone types; `dirty` does, with the first
63
+ letter. Deciding by it swapped the whole tree under the paper at that letter — the field
64
+ being written was unmounted and lost the focus. Save and Discard still appear: in the
65
+ rail, which is a sibling and moves nothing. */
66
+ const paper = withRail && (crumbs !== undefined || pager !== undefined || actions !== undefined);
49
67
  /* ONE column for the rail and the page: the rail used to span the screen while the sheet
50
68
  stopped at 72rem, so on a wide desk the buttons sat 250 px left of the paper they act on
51
69
  and the record read as two things. */
@@ -58,7 +76,7 @@ export function RecordHeader({ crumbs, pager, actions, status, kicker, icon: Ico
58
76
  /* `display`, not `title`: the record's name is the entry point of the
59
77
  screen. At the title size (19 px over a 13 px body, 1.46:1) nothing on
60
78
  the sheet stood out and the eye had nowhere to start. */
61
- _jsx("h1", { className: "min-w-0 truncate text-display font-semibold text-ink", children: title })), tag === undefined ? null : _jsx(Tag, { tone: tag.tone, children: tag.label })] }), subtitle === undefined || !withIdentity ? null : (_jsx("p", { className: "mt-0.5 truncate text-lead text-ink", children: subtitle }))] })] }), figure === undefined || !withIdentity ? null : (_jsxs("div", { className: "ml-auto flex shrink-0 flex-col items-end", children: [_jsx("span", { className: "text-micro font-medium text-muted", children: figure.label }), _jsx("span", { className: "text-display font-semibold tabular-nums text-ink", children: figure.value })] }))] }), withRail || status === undefined ? null : (_jsx("div", { className: "flex min-w-0 flex-wrap items-center gap-1.5", children: status })), aspects === undefined ? null : (
79
+ _jsx("h1", { ref: nameRef, tabIndex: -1, className: "min-w-0 truncate text-display font-semibold text-ink outline-0", children: title })), tag === undefined ? null : _jsx(Tag, { tone: tag.tone, children: tag.label })] }), subtitle === undefined || !withIdentity ? null : (_jsx("p", { className: "mt-0.5 truncate text-lead text-ink", children: subtitle }))] })] }), figure === undefined || !withIdentity ? null : (_jsxs("div", { className: "ml-auto flex shrink-0 flex-col items-end", children: [_jsx("span", { className: "text-micro font-medium text-muted", children: figure.label }), _jsx("span", { className: "text-display font-semibold tabular-nums text-ink", children: figure.value })] }))] }), withRail || status === undefined ? null : (_jsx("div", { className: "flex min-w-0 flex-wrap items-center gap-1.5", children: status })), aspects === undefined ? null : (
62
80
  /* The line is the sheet's mouth. In comfortable it walks to the
63
81
  edges; the current tab sits on it and opens into the page below. */
64
82
  _jsx("div", { className: `border-b border-line-strong ${byDensity(d, "-mx-4 px-4", "")}`, children: aspects }))] }));
@@ -68,14 +86,15 @@ export function RecordHeader({ crumbs, pager, actions, status, kicker, icon: Ico
68
86
  it. The line wraps on a phone, so it is never pushed out of reach. */
69
87
  smart === undefined && indicator === undefined ? undefined : (_jsxs("div", { className: "flex min-w-0 flex-wrap items-center justify-center gap-2", children: [smart, indicator] })), end: pager }), actions === undefined &&
70
88
  status === undefined &&
71
- dirty === undefined ? null : (
89
+ dirty === undefined &&
90
+ !saved ? null : (
72
91
  /* Row 2 — WHAT I CAN DO and WHERE IT IS AT: actions stuck to the
73
92
  left edge, the history and the state stuck to the right one. No
74
93
  `flex-wrap`: wrapping split the row in two and the state ended up
75
94
  below the actions, which is another hierarchy. */
76
- _jsxs("div", { className: "flex min-w-0 items-start gap-x-4", children: [dirty !== undefined ? (_jsx("div", { className: "flex shrink-0 items-center gap-1.5 @max-handheld/record:hidden", children: _jsx(DirtyActions, { ...dirty }) })) : actions === undefined ? null : (_jsx("div", { className: "flex shrink-0 items-center gap-1.5", children: primaries.length === 0
77
- ? actions
78
- : railActions.map((node) => isPrimary(node) ? (_jsx("span", { "data-thumb-moves": "", className: "contents @max-handheld/record:hidden", children: node }, node.key)) : (node)) })), status === undefined ? null : (_jsx("div", { className: "ml-auto flex min-w-0 flex-col items-end gap-1.5", children: status }))] }))] }) }) }) })) : null, paper ? (
95
+ _jsxs("div", { className: "flex min-w-0 items-start gap-x-4", children: [dirty !== undefined ? (_jsx("div", { className: "flex shrink-0 items-center gap-1.5 @max-handheld/record:hidden", children: _jsx(DirtyActions, { ...dirty }) })) : actions === undefined ? (_jsx(SavedMark, { saved: saved })) : (_jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [primaries.length === 0
96
+ ? actions
97
+ : railActions.map((node) => isPrimary(node) ? (_jsx("span", { "data-thumb-moves": "", className: "contents @max-handheld/record:hidden", children: node }, node.key)) : (node)), _jsx(SavedMark, { saved: saved })] })), status === undefined ? null : (_jsx("div", { className: "ml-auto flex min-w-0 flex-col items-end gap-1.5", children: status }))] }))] }) }) }) })) : null, paper ? (
79
98
  /* ==================================================================== *
80
99
  * THE PAGE — [sheet | Actividad], on the ground.
81
100
  *
@@ -17,5 +17,5 @@ const SOLAPAS = ["w-14", "w-16", "w-20"];
17
17
  export function RecordSkeleton({ label }) {
18
18
  const d = useDensity();
19
19
  const page = d === "comfortable";
20
- return (_jsxs("article", { "aria-busy": "true", className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "sr-only", children: label }), page ? (_jsxs("div", { "aria-hidden": true, className: "flex items-center gap-3 bg-chrome px-3.5 py-2.5", children: [_jsx("span", { className: "h-3 w-24 rounded-control bg-chrome-ink/15" }), _jsx("span", { className: "h-3 w-40 rounded-control bg-chrome-ink/10" }), _jsx("span", { className: "ml-auto h-(--size-control-sm) w-28 rounded-control bg-chrome-ink/15" })] })) : null, _jsx("div", { "aria-hidden": true, className: page ? "border border-line bg-surface" : "bg-surface", children: _jsxs("div", { className: `flex flex-col ${byDensity(d, "gap-3 px-4 pt-3.5 pb-4", "gap-2 py-2")}`, children: [_jsx("span", { className: "h-5 w-72 max-w-full rounded-control bg-ink/8" }), _jsx("div", { className: "flex flex-wrap gap-x-6 gap-y-2", children: HECHOS.map((width) => (_jsxs("span", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "h-2.5 w-10 rounded-control bg-ink/5" }), _jsx("span", { className: `h-3.5 rounded-control bg-ink/8 ${width}` })] }, width))) }), _jsx("div", { className: "flex gap-4 border-b border-line pb-2", children: SOLAPAS.map((width, i) => (_jsx("span", { className: `h-3 rounded-control ${i === 0 ? "bg-ink/10" : "bg-ink/5"} ${width}` }, width))) }), _jsx("span", { className: "h-24 rounded-box bg-ink/5" })] }) })] }));
20
+ return (_jsxs("article", { "aria-busy": "true", className: "flex min-w-0 animate-reveal-late flex-col", children: [_jsx("span", { className: "sr-only", children: label }), page ? (_jsxs("div", { "aria-hidden": true, className: "flex items-center gap-3 bg-chrome px-3.5 py-2.5", children: [_jsx("span", { className: "h-3 w-24 rounded-control bg-chrome-ink/15" }), _jsx("span", { className: "h-3 w-40 rounded-control bg-chrome-ink/10" }), _jsx("span", { className: "ml-auto h-(--size-control-sm) w-28 rounded-control bg-chrome-ink/15" })] })) : null, _jsx("div", { "aria-hidden": true, className: page ? "border border-line bg-surface" : "bg-surface", children: _jsxs("div", { className: `flex flex-col ${byDensity(d, "gap-3 px-4 pt-3.5 pb-4", "gap-2 py-2")}`, children: [_jsx("span", { className: "h-5 w-72 max-w-full rounded-control bg-ink/8" }), _jsx("div", { className: "flex flex-wrap gap-x-6 gap-y-2", children: HECHOS.map((width) => (_jsxs("span", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "h-2.5 w-10 rounded-control bg-ink/5" }), _jsx("span", { className: `h-3.5 rounded-control bg-ink/8 ${width}` })] }, width))) }), _jsx("div", { className: "flex gap-4 border-b border-line pb-2", children: SOLAPAS.map((width, i) => (_jsx("span", { className: `h-3 rounded-control ${i === 0 ? "bg-ink/10" : "bg-ink/5"} ${width}` }, width))) }), _jsx("span", { className: "h-24 rounded-box bg-ink/5" })] }) })] }));
21
21
  }
@@ -74,6 +74,11 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
74
74
  const input = useRef(null);
75
75
  const box = useRef(null);
76
76
  const id = useId();
77
+ /* The suggestions are the input's popup (the ARIA combobox pattern): the input keeps the
78
+ focus and says which list it controls and which option is the active one, so a screen
79
+ reader announces «Nombre del cliente · contiene Globex» as the option it is. */
80
+ const listId = useId();
81
+ const optionId = (at) => `${listId}-${at}`;
77
82
  /**
78
83
  * Los campos por los que se puede preguntar.
79
84
  *
@@ -290,7 +295,9 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
290
295
  removeLabel: copy.search.dropOp,
291
296
  },
292
297
  ]),
293
- ] }), _jsx("input", { ref: input, "aria-label": copy.choice.search, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
298
+ ] }), _jsx("input", { ref: input, "aria-label": copy.choice.search, role: "combobox", "aria-autocomplete": "list", "aria-expanded": open, ...(open ? { "aria-controls": listId } : {}), ...(open && !asTags && suggestions[active] !== undefined
299
+ ? { "aria-activedescendant": optionId(active) }
300
+ : {}), value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
294
301
  /* Retroceso con el campo vacío saca la ÚLTIMA instancia: el gesto
295
302
  que ya existe en cualquier campo de fichas y el único que se
296
303
  descubre sin que nadie lo explique. La última y no la última de
@@ -392,7 +399,7 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
392
399
  /* UNA LISTA, no una tira. Se está ELIGIENDO: el ojo baja por una
393
400
  columna y el teclado también. Doce atributos doblados en tres
394
401
  renglones son doce cosas que barrer en zigzag para encontrar una. */
395
- _jsx("div", { className: "max-h-72 overflow-y-auto p-1", children: _jsx(Tags, { stack: true, surface: "canvas", label: step.field !== undefined ? copy.search.askOp : copy.search.askAttribute, items: suggestions.map((one, at) => ({
402
+ _jsx("div", { id: listId, className: "max-h-72 overflow-y-auto p-1", children: _jsx(Tags, { stack: true, surface: "canvas", label: step.field !== undefined ? copy.search.askOp : copy.search.askAttribute, items: suggestions.map((one, at) => ({
396
403
  id: one.id,
397
404
  label: one.label,
398
405
  ...(one.kind === "op"
@@ -402,7 +409,9 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
402
409
  : {}),
403
410
  active: at === active,
404
411
  onSelect: () => pick(one),
405
- })) }) })) : (_jsx("div", { className: "p-1", children: suggestions.map((one, at) => (_jsxs("button", { type: "button", onClick: () => pick(one), onMouseEnter: () => setActive(at), className: `flex w-full items-baseline gap-1.5 rounded-control px-2 py-1.5 text-left text-meta transition-colors duration-(--duration-instant) ${at === active ? "bg-primary/8" : "hover:bg-ink/4"}`, children: [_jsx("span", { className: "shrink-0 font-medium text-ink", children: one.label }), one.kind === "condition" || one.kind === "literal" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "shrink-0 text-muted", children: ":" }), _jsx("span", { className: "min-w-0 truncate font-mono text-micro text-ink", children: one.hint })] })) : one.kind === "field" && one.hint !== undefined ? (_jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro text-muted", children: one.hint })) : null] }, one.id))) }))] })) : null, panel && (columns.length > 0 || onOpenBuilder !== undefined) ? (_jsxs("div", { id: id, className: "absolute top-full right-0 z-30 mt-1 flex w-max max-w-full flex-wrap overflow-hidden rounded-panel border border-line-strong bg-surface shadow-float", children: [columns.map((c, i) => (_jsxs("div", { className: `flex min-w-48 flex-1 flex-col ${i === 0 ? "" : "border-t border-line sm:border-t-0 sm:border-l"}`, children: [_jsx("div", { className: "border-b border-line px-2.5 py-1.5 text-micro font-medium text-muted", children: c.title }), _jsx("div", { className: "flex flex-col gap-0.5 p-1", children: c.content ??
412
+ })) }) })) : (_jsx("div", { id: listId, role: "listbox", "aria-label": copy.search.suggestions, className: "p-1", children: suggestions.map((one, at) => (_jsxs("div", { id: optionId(at), role: "option", "aria-selected": at === active,
413
+ /* The input keeps the focus: pressing an option must not take it. */
414
+ onMouseDown: (event) => event.preventDefault(), onClick: () => pick(one), onMouseEnter: () => setActive(at), className: `flex w-full cursor-pointer items-baseline gap-1.5 rounded-control px-2 py-1.5 text-left text-meta transition-colors duration-(--duration-instant) ${at === active ? "bg-primary/8" : "hover:bg-ink/4"}`, children: [_jsx("span", { className: "shrink-0 font-medium text-ink", children: one.label }), one.kind === "condition" || one.kind === "literal" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "shrink-0 text-muted", children: ":" }), _jsx("span", { className: "min-w-0 truncate font-mono text-micro text-ink", children: one.hint })] })) : one.kind === "field" && one.hint !== undefined ? (_jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro text-muted", children: one.hint })) : null] }, one.id))) }))] })) : null, panel && (columns.length > 0 || onOpenBuilder !== undefined) ? (_jsxs("div", { id: id, className: "absolute top-full right-0 z-30 mt-1 flex w-max max-w-full flex-wrap overflow-hidden rounded-panel border border-line-strong bg-surface shadow-float", children: [columns.map((c, i) => (_jsxs("div", { className: `flex min-w-48 flex-1 flex-col ${i === 0 ? "" : "border-t border-line sm:border-t-0 sm:border-l"}`, children: [_jsx("div", { className: "border-b border-line px-2.5 py-1.5 text-micro font-medium text-muted", children: c.title }), _jsx("div", { className: "flex flex-col gap-0.5 p-1", children: c.content ??
406
415
  c.groups?.map((g, gi) => (_jsx(Group, { group: g, first: gi === 0, onSelect: c.onSelect }, g.label ?? gi))) })] }, c.title))), onOpenBuilder === undefined ? null : (_jsx("div", { className: "w-full border-t border-line p-1", children: _jsxs("button", { type: "button", onClick: () => {
407
416
  onOpenBuilder();
408
417
  setPanel(false);
@@ -70,8 +70,13 @@ export type UiCopy = {
70
70
  faces: string;
71
71
  /** The mark of a record with changes that are not saved yet. */
72
72
  unsaved: string;
73
+ /** The same mark when the record says how many fields changed. */
74
+ unsavedOne: string;
75
+ unsavedMany: string;
73
76
  save: string;
74
77
  saving: string;
78
+ /** Said, briefly, once what was saved is saved. */
79
+ saved: string;
75
80
  discard: string;
76
81
  /** Read aloud next to a stage that failed: the red fill alone says nothing to a
77
82
  * screen reader. */
@@ -115,6 +120,8 @@ export type UiCopy = {
115
120
  unset: string;
116
121
  /** A list of checks read with nothing chosen. */
117
122
  none: string;
123
+ /** Next to the label of a field whose value changed and is not saved yet. */
124
+ changed: string;
118
125
  };
119
126
  /** The choice controls: combo boxes, selects, suggestion lists. */
120
127
  choice: {
@@ -127,6 +134,8 @@ export type UiCopy = {
127
134
  };
128
135
  search: {
129
136
  tools: string;
137
+ /** The name of the list of what the bar offers for what was typed. */
138
+ suggestions: string;
130
139
  /** The name of the panel button once something is set: it shows the count, and what
131
140
  * the button SHOWS has to be inside its name (WCAG 2.5.3) — `{label}` is `tools`. */
132
141
  toolsSetOne: string;
@@ -259,6 +268,8 @@ export type UiCopy = {
259
268
  * record itself. */
260
269
  peek: string;
261
270
  open: string;
271
+ /** Asked again while the rows stay: the name of the discreet bar over them. */
272
+ refreshing: string;
262
273
  };
263
274
  /** La historia de un registro: eventos y mensajes. */
264
275
  /** The panel of everything around a record: its notices, its conversation and its
@@ -72,8 +72,11 @@ export const SPANISH_COPY = {
72
72
  status: "Estado",
73
73
  faces: "Caras",
74
74
  unsaved: "sin guardar",
75
+ unsavedOne: "1 cambio sin guardar",
76
+ unsavedMany: "{count} cambios sin guardar",
75
77
  save: "Guardar",
76
78
  saving: "Guardando…",
79
+ saved: "Guardado",
77
80
  discard: "Descartar",
78
81
  failed: "falló",
79
82
  notices: "Avisos sobre este registro",
@@ -106,6 +109,7 @@ export const SPANISH_COPY = {
106
109
  help: "Ayuda",
107
110
  unset: "sin definir",
108
111
  none: "Ninguna",
112
+ changed: "modificado",
109
113
  },
110
114
  choice: {
111
115
  noResults: "Sin resultados",
@@ -127,6 +131,7 @@ export const SPANISH_COPY = {
127
131
  },
128
132
  search: {
129
133
  tools: "Filtrar, agrupar y favoritos",
134
+ suggestions: "Sugerencias",
130
135
  toolsSetOne: "{label}, {count} puesto",
131
136
  toolsSetMany: "{label}, {count} puestos",
132
137
  editChip: "Clic para editar",
@@ -209,6 +214,7 @@ export const SPANISH_COPY = {
209
214
  count: "{count} registros",
210
215
  peek: "Vista previa",
211
216
  open: "Abrir",
217
+ refreshing: "Actualizando la lista…",
212
218
  },
213
219
  activity: {
214
220
  bySystem: "el sistema",
@@ -48,8 +48,11 @@ export const ENGLISH_COPY = {
48
48
  status: "Status",
49
49
  faces: "Record faces",
50
50
  unsaved: "unsaved",
51
+ unsavedOne: "1 change unsaved",
52
+ unsavedMany: "{count} changes unsaved",
51
53
  save: "Save",
52
54
  saving: "Saving…",
55
+ saved: "Saved",
53
56
  discard: "Discard",
54
57
  failed: "failed",
55
58
  notices: "Notices about this record",
@@ -82,6 +85,7 @@ export const ENGLISH_COPY = {
82
85
  help: "Help",
83
86
  unset: "not set",
84
87
  none: "None",
88
+ changed: "changed",
85
89
  },
86
90
  choice: {
87
91
  noResults: "No results",
@@ -89,6 +93,7 @@ export const ENGLISH_COPY = {
89
93
  },
90
94
  search: {
91
95
  tools: "Filter, group and favorites",
96
+ suggestions: "Suggestions",
92
97
  toolsSetOne: "{label}, {count} applied",
93
98
  toolsSetMany: "{label}, {count} applied",
94
99
  editChip: "Click to edit",
@@ -157,6 +162,7 @@ export const ENGLISH_COPY = {
157
162
  count: "{count} records",
158
163
  peek: "Quick look",
159
164
  open: "Open",
165
+ refreshing: "Updating the list…",
160
166
  },
161
167
  activity: {
162
168
  bySystem: "the system",
@@ -1,5 +1,5 @@
1
1
  import type { Surface } from "../../foundations/types.js";
2
- import type { LucideIcon } from "lucide-react";
2
+ import { type LucideIcon } from "lucide-react";
3
3
  import type { ComponentPropsWithRef, ReactNode } from "react";
4
4
  export type ButtonRole = "primary" | "secondary" | "ghost" | "danger";
5
5
  export type ButtonSize = "sm" | "md" | "lg";
@@ -53,8 +53,18 @@ export type ButtonProps = NativeProps<"button", "type" | "disabled" | "title" |
53
53
  expanded?: boolean;
54
54
  /** What it opens, written to `aria-haspopup`. `menu` when absent. */
55
55
  haspopup?: ButtonPopup;
56
+ /**
57
+ * What it started is running — saving, publishing, registering a payment.
58
+ *
59
+ * Context: the buttons said it by turning `disabled`, and a disabled button drops the focus
60
+ * to the page: whoever pressed Save with the keyboard was left nowhere, and a press while
61
+ * it ran was silently lost or — worse — sent twice. Busy, the button KEEPS the focus and
62
+ * its width (the label stays), says so (`aria-busy`, a turning mark where its icon was) and
63
+ * ignores presses until it is done.
64
+ */
65
+ busy?: boolean;
56
66
  };
57
- export declare function Button({ children, role, size, surface: surfaceProp, icon: Icon, disabled, title, type, expanded, haspopup, ref, ...native }: ButtonProps): import("react").JSX.Element;
67
+ export declare function Button({ children, role, size, surface: surfaceProp, icon: Icon, disabled, title, type, expanded, haspopup, busy, ref, onClick, ...native }: ButtonProps): import("react").JSX.Element;
58
68
  export type ButtonLinkProps = NativeProps<"a", "href" | "download" | "title"> & {
59
69
  children: ReactNode;
60
70
  href: string;
@@ -60,6 +60,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
60
60
  * ==================================================================== */
61
61
  import { useSurface } from "../../foundations/surface.js";
62
62
  import { Tooltip, TooltipTrigger, useInTooltipTrigger, } from "../overlay/Tooltip.js";
63
+ import { LoaderCircle } from "lucide-react";
63
64
  /** The height comes from `--size-control-*`; the padding and the type follow.
64
65
  *
65
66
  * sm a row, the header of a block
@@ -146,10 +147,19 @@ function popupAttrs(expanded, haspopup) {
146
147
  ...(expanded === undefined ? {} : { "aria-expanded": expanded }),
147
148
  };
148
149
  }
149
- export function Button({ children, role = "secondary", size = "md", surface: surfaceProp, icon: Icon, disabled = false, title, type = "button", expanded, haspopup, ref, ...native }) {
150
+ export function Button({ children, role = "secondary", size = "md", surface: surfaceProp, icon: Icon, disabled = false, title, type = "button", expanded, haspopup, busy = false, ref, onClick, ...native }) {
150
151
  const surface = useSurface(surfaceProp);
151
152
  const ic = ICON[size];
152
- return (_jsxs("button", { ...native, ref: ref, type: type, title: title, disabled: disabled, ...popupAttrs(expanded, haspopup), "data-emphasis": role, className: buttonClasses(role, size, surface), children: [Icon === undefined ? null : (_jsx(Icon, { className: `${ic.size} shrink-0`, strokeWidth: ic.stroke, "aria-hidden": true })), children] }));
153
+ const Mark = busy ? LoaderCircle : Icon;
154
+ return (_jsxs("button", { ...native, ref: ref, type: type, title: title, disabled: disabled, ...(busy ? { "aria-busy": true, "aria-disabled": true } : {}), onClick: (event) => {
155
+ if (busy) {
156
+ event.preventDefault();
157
+ return;
158
+ }
159
+ onClick?.(event);
160
+ }, ...popupAttrs(expanded, haspopup), "data-emphasis": role, className: busy
161
+ ? `${buttonClasses(role, size, surface)} cursor-progress`
162
+ : buttonClasses(role, size, surface), children: [Mark === undefined ? null : (_jsx(Mark, { className: `${ic.size} shrink-0 ${busy ? "motion-safe:animate-spin" : ""}`, strokeWidth: ic.stroke, "aria-hidden": true })), children] }));
153
163
  }
154
164
  /**
155
165
  * The same button, but it is a real link.
@@ -32,5 +32,5 @@ export function Thumb({ src, alt, icon: Icon, size = "md", }) {
32
32
  export function CellStack({ primary, secondary, media, avatar, wrap = false, }) {
33
33
  return (_jsxs("span", { className: "flex min-w-0 items-center gap-2.5", children: [avatar !== undefined ? (
34
34
  /* The name is written beside it: the photo's `alt` would say it twice. */
35
- _jsx("span", { "aria-hidden": true, className: "flex shrink-0", children: _jsx(Avatar, { name: avatar.name, size: "lg", ...(avatar.src === undefined ? {} : { src: avatar.src }) }) })) : media === undefined ? null : (_jsx(Thumb, { size: "md", ...(media.src === undefined ? {} : { src: media.src }), ...(media.alt === undefined ? {} : { alt: media.alt }), ...(media.icon === undefined ? {} : { icon: media.icon }) })), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate text-meta font-medium text-ink", ...(typeof primary === "string" ? { title: primary } : {}), children: primary }), secondary === undefined ? null : (_jsx("span", { className: `text-micro text-muted ${wrap ? "line-clamp-2" : "truncate"}`, ...(typeof secondary === "string" ? { title: secondary } : {}), children: secondary }))] })] }));
35
+ _jsx("span", { "aria-hidden": true, className: "flex shrink-0", children: _jsx(Avatar, { name: avatar.name, size: "lg", ...(avatar.src === undefined ? {} : { src: avatar.src }) }) })) : media === undefined ? null : (_jsx(Thumb, { size: "md", ...(media.src === undefined ? {} : { src: media.src }), ...(media.alt === undefined ? {} : { alt: media.alt }), ...(media.icon === undefined ? {} : { icon: media.icon }) })), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "line-clamp-1 text-meta font-medium break-all text-ink", ...(typeof primary === "string" ? { title: primary } : {}), children: primary }), secondary === undefined ? null : (_jsx("span", { className: `text-micro text-muted ${wrap ? "line-clamp-2" : "line-clamp-1 break-all"}`, ...(typeof secondary === "string" ? { title: secondary } : {}), children: secondary }))] })] }));
36
36
  }
@@ -2,7 +2,7 @@ import { type FieldIds } from "../../foundations/form.js";
2
2
  import { type FormMode } from "../../foundations/form.js";
3
3
  import { type FieldNote } from "./note.js";
4
4
  import { type ReactNode } from "react";
5
- export declare function Field({ label, hint, error, required, children, htmlFor, layout, action, mode, note, copy: copyAs, }: {
5
+ export declare function Field({ label, hint, error, required, children, htmlFor, layout, action, mode, note, changed, copy: copyAs, }: {
6
6
  label: string;
7
7
  /** What is expected. A `?` on the label that opens it on press. */
8
8
  hint?: string;
@@ -32,8 +32,13 @@ export declare function Field({ label, hint, error, required, children, htmlFor,
32
32
  * Aplicar». A card in the record's margin when there is one, a `Flag` by the label when
33
33
  * there is not; its sentence describes the control (`aria-describedby`). See `note.tsx`. */
34
34
  note?: FieldNote;
35
- /** The copy button by the label (see `copy.tsx`): on by default, copying what the control
36
- * shows. A string copies that instead; `false` removes the button from this field. */
35
+ /** Its value changed and is not saved yet. A dot by the label says WHICH fields a
36
+ * «2 cambios sin guardar» is about — in a form of twenty, the reason to look before
37
+ * saving — and it is read as part of the field's description. */
38
+ changed?: boolean;
39
+ /** The copy button by the label (see `clipboard.tsx`): on by default, copying what the
40
+ * control shows. A string copies that instead; `false` removes the button from this
41
+ * field. */
37
42
  copy?: string | false;
38
43
  }): import("react").JSX.Element;
39
44
  /**
@@ -40,7 +40,7 @@ import { Button as AriaButton, Input, ListBox, ListBoxItem, NumberField as AriaN
40
40
  * control ate a line on every field. The error still goes below, close to
41
41
  * the cursor, and it says what to put.
42
42
  * ==================================================================== */
43
- export function Field({ label, hint, error, required = false, children, htmlFor, layout, action, mode, note, copy: copyAs, }) {
43
+ export function Field({ label, hint, error, required = false, children, htmlFor, layout, action, mode, note, changed = false, copy: copyAs, }) {
44
44
  const d = useDensity();
45
45
  const copy = useUiCopy();
46
46
  const copies = useFieldCopy() && copyAs !== false;
@@ -52,9 +52,13 @@ export function Field({ label, hint, error, required = false, children, htmlFor,
52
52
  const hintId = `${id}-hint`;
53
53
  const errorId = `${id}-error`;
54
54
  const noteId = `${id}-note`;
55
+ const changedId = `${id}-changed`;
55
56
  /* The error first: what has to be corrected is heard before what is worth knowing. */
56
- const describedBy = describedByOf(hint === undefined ? null : hintId, error === undefined ? null : errorId, note === undefined ? null : noteId);
57
- const labelNode = (_jsxs("span", { className: `flex min-w-0 items-center gap-1 text-micro font-medium text-muted ${inline ? "pt-2" : ""}`, children: [_jsxs("label", { id: `${id}-label`, htmlFor: id, className: inline ? "min-w-0 truncate" : "", children: [label, required ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "text-ink", "aria-hidden": true, children: [" ", "*"] }), _jsxs("span", { className: "sr-only", children: [", ", copy.field.required] })] })) : null] }), hint === undefined ? null : _jsx(Hint, { id: hintId, children: hint }), note === undefined ? null : _jsx(NoteFlag, { note: note }), copies ? (_jsx(FieldCopy, { label: label, scope: shown, ...(typeof copyAs === "string" ? { value: copyAs } : {}) })) : null] }));
57
+ const describedBy = describedByOf(hint === undefined ? null : hintId, error === undefined ? null : errorId, note === undefined ? null : noteId, changed ? changedId : null);
58
+ const labelNode = (
59
+ /* The label follows its control's focus: in a form of twenty fields, the eye finds the
60
+ row being written by its name as much as by the ring. */
61
+ _jsxs("span", { className: `flex min-w-0 items-center gap-1 text-micro font-medium text-muted transition-colors duration-(--duration-quick) group-focus-within/field:text-primary ${inline ? "pt-2" : ""}`, children: [_jsxs("label", { id: `${id}-label`, htmlFor: id, className: inline ? "min-w-0 truncate" : "", children: [label, required ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "text-ink", "aria-hidden": true, children: [" ", "*"] }), _jsxs("span", { className: "sr-only", children: [", ", copy.field.required] })] })) : null] }), hint === undefined ? null : _jsx(Hint, { id: hintId, children: hint }), note === undefined ? null : _jsx(NoteFlag, { note: note }), changed ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, title: copy.field.changed, className: "size-1.5 shrink-0 rounded-pill bg-accent transition-transform duration-(--duration-quick) ease-(--ease-out) starting:scale-0" }), _jsx("span", { id: changedId, className: "sr-only", children: copy.field.changed })] })) : null, copies ? (_jsx(FieldCopy, { label: label, scope: shown, ...(typeof copyAs === "string" ? { value: copyAs } : {}) })) : null] }));
58
62
  /* Last in the field so it is read last; absolutely placed, it still sits at the field's
59
63
  top — the static position of an absolute child of a flex or grid box is its start. */
60
64
  const noteCard = note === undefined ? null : _jsx(NoteCard, { note: note, id: noteId, owner: label });
@@ -27,7 +27,9 @@ import { AlertTriangle, PackageX } from "lucide-react";
27
27
  const SKELETON_NAME = ["w-48", "w-36", "w-56", "w-40", "w-44", "w-32"];
28
28
  export function Skeleton({ rows = 6, label, }) {
29
29
  const d = useDensity();
30
- return (_jsxs("div", { "aria-busy": "true", className: "flex flex-col", children: [_jsx("span", { className: "sr-only", children: label }), Array.from({ length: rows }, (_, i) => (_jsxs("div", { "aria-hidden": true, className: `flex items-center ${byDensity(d, "gap-3 px-2 py-2", "gap-2 px-1.5 py-1")}`, children: [_jsx("span", { className: `h-3.5 rounded-control bg-ink/8 ${SKELETON_NAME[i % SKELETON_NAME.length]}` }), _jsx("span", { className: "ml-auto h-3 w-14 rounded-control bg-ink/5" })] }, i)))] }));
30
+ return (
31
+ /* It appears late (`animate-reveal-late`): a read faster than 200ms never shows it. */
32
+ _jsxs("div", { "aria-busy": "true", className: "flex animate-reveal-late flex-col", children: [_jsx("span", { className: "sr-only", children: label }), Array.from({ length: rows }, (_, i) => (_jsxs("div", { "aria-hidden": true, className: `flex items-center ${byDensity(d, "gap-3 px-2 py-2", "gap-2 px-1.5 py-1")}`, children: [_jsx("span", { className: `h-3.5 rounded-control bg-ink/8 ${SKELETON_NAME[i % SKELETON_NAME.length]}` }), _jsx("span", { className: "ml-auto h-3 w-14 rounded-control bg-ink/5" })] }, i)))] }));
31
33
  }
32
34
  /** What is shown when there is nothing to show: a sentence, and the gesture
33
35
  * that resolves it if there is one. Never a drawing. */
@@ -602,7 +602,11 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
602
602
  return null;
603
603
  return (_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-meta", children: [_jsx("dt", { className: "text-muted", children: c.header }), _jsx("dd", { className: "font-semibold text-ink tabular-nums", children: total })] }, c.id));
604
604
  }) }))] }));
605
- return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, tabIndex: box.tabIndex, className: `min-w-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "w-0 border-b border-line-strong" }) : null, columns.map((c) => c.header === "" ? (_jsx("td", { className: "w-0 border-b border-line-strong" }, c.id)) : (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width }, className: `border-b border-line-strong ${HEADER_TEXT} ${cell} ${c.numeric === true ? "text-right" : "text-left"}`, children: c.header }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "w-0 border-b border-line-strong" }))] }) }), _jsx("tbody", { ...(reorderable
605
+ return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, tabIndex: box.tabIndex, className: `min-w-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "w-0 border-b border-line-strong" }) : null, columns.map((c) => c.header === "" ? (_jsx("td", { className: "w-0 border-b border-line-strong" }, c.id)) : (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width },
606
+ /* A column of figures takes its figures' width (`w-0` in an auto
607
+ table is «as narrow as its content») and the text takes the rest:
608
+ the product name was cut while «Cantidad» had room for ten digits. */
609
+ className: `border-b border-line-strong ${HEADER_TEXT} ${cell} ${c.numeric === true ? "text-right" : "text-left"} ${c.numeric === true && c.width === undefined ? "w-0" : ""}`, children: c.header }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "w-0 border-b border-line-strong" }))] }) }), _jsx("tbody", { ...(reorderable
606
610
  ? {
607
611
  onDragOver: overRow,
608
612
  onDrop: (event) => {
package/dist/tokens.css CHANGED
@@ -630,6 +630,28 @@
630
630
  the corner of the eye. The bar uses it behind `motion-safe:`: with reduced
631
631
  motion it stands still. */
632
632
  --animate-progress-sweep: progress-sweep 1.6s var(--ease-in-out) infinite;
633
+
634
+ /* What appears only if waiting takes long enough to be seen: a skeleton. A read that
635
+ answers in 150ms showed a skeleton for one frame — a flash that says «something broke»
636
+ more than «loading». It waits 200ms invisible, then fades in. */
637
+ --animate-reveal-late: reveal var(--duration-quick) var(--ease-out) 200ms both;
638
+ }
639
+
640
+ @keyframes reveal {
641
+ from {
642
+ opacity: 0;
643
+ }
644
+ }
645
+
646
+ /* The focus ring ARRIVES: it closes in from a little farther out while it takes its colour,
647
+ in 160ms. A ring that pops is found; one that moves is found before the eye looks for it.
648
+ Only on the controls a person moves through — a dialog or a popover that takes the focus to
649
+ be announced keeps its own entrance — and only with motion allowed. */
650
+ @keyframes focus-in {
651
+ from {
652
+ outline-offset: 5px;
653
+ outline-color: transparent;
654
+ }
633
655
  }
634
656
 
635
657
  /* The segment is two fifths of its track: from just off the left edge (-100 % of
@@ -924,6 +946,26 @@
924
946
  font-variant-numeric: tabular-nums;
925
947
  }
926
948
 
949
+ @media (prefers-reduced-motion: no-preference) {
950
+ :where(
951
+ button,
952
+ a[href],
953
+ input,
954
+ textarea,
955
+ select,
956
+ summary,
957
+ [role="tab"],
958
+ [role="option"],
959
+ [role="gridcell"],
960
+ [role="row"],
961
+ [role="checkbox"],
962
+ [role="radio"],
963
+ [role="switch"]
964
+ ):focus-visible {
965
+ animation: focus-in var(--duration-quick) var(--ease-out);
966
+ }
967
+ }
968
+
927
969
  /* Thin and warm scrollbars. With six panels open, six grey system bars are
928
970
  six stripes that say nothing. */
929
971
  * {
@@ -120,9 +120,16 @@ export type ListProps<T extends object> = {
120
120
  * actions — a list to read, like the lines of a record in view mode. One switch, so whoever
121
121
  * passes the handlers generically does not have to strip them one by one. */
122
122
  readOnly?: boolean;
123
- /** Los tres estados, en un solo lugar. */
123
+ /** Los estados del conjunto, en un solo lugar.
124
+ *
125
+ * `loading` es la PRIMERA lectura: no hay filas todavía, y se dibuja el esqueleto.
126
+ * `refreshing` es volver a preguntar con filas en la mano —se tipeó en la búsqueda, se
127
+ * cambió de página—: las filas se quedan y una barra discreta dice que se está
128
+ * preguntando. Cambiarlas por un esqueleto en cada pregunta hacía parpadear la tabla, y un
129
+ * clic podía caer sobre una fila que ya no estaba. */
124
130
  state?: {
125
131
  loading?: boolean;
132
+ refreshing?: boolean;
126
133
  error?: string | null;
127
134
  };
128
135
  /** Vacía POR UN FILTRO es otra situación: no falta contenido, sobra un
@@ -22,6 +22,7 @@ import { Button } from "../../primitives/button/Button.js";
22
22
  import { Empty, Failed, Skeleton } from "../../primitives/notice/Notice.js";
23
23
  import { PivotTable } from "../../primitives/table/PivotTable.js";
24
24
  import { ReportTable } from "../../primitives/table/ReportTable.js";
25
+ import { ProgressBar } from "../../primitives/value/ProgressBar.js";
25
26
  import { groupedColumnIds, WIDE_PRESENTS } from "./columns.js";
26
27
  import { groupLevelsOf, reportColumnsOf, reportRowsOf, sheetFromColumns, sheetRowsOf, } from "./derive.js";
27
28
  import { SheetPresentation, TablePresentation, } from "./presentations.js";
@@ -79,6 +80,7 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
79
80
  const density = useDensity();
80
81
  const copy = useUiCopy();
81
82
  const loading = state?.loading === true;
83
+ const refreshing = state?.refreshing === true && !loading;
82
84
  const error = state?.error ?? null;
83
85
  const columnSpecs = useMemo(() => specsFromColumns(columns ?? [], false), [columns]);
84
86
  const columnControl = useColumnControl(columnSpecs, {
@@ -331,7 +333,7 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
331
333
  /* The list is its own container (`@container`: the presentations adapt to the width
332
334
  the LIST has, not the window) and fills the space it is given. Before, every page
333
335
  wrapped it in a `div` to get both. */
334
- _jsxs("div", { ref: frame, className: "@container flex min-w-0 flex-1 flex-col bg-surface", children: [_jsxs(Listing, { bar: bar,
336
+ _jsxs("div", { ref: frame, "aria-busy": refreshing || loading, className: "@container flex min-w-0 flex-1 flex-col bg-surface", children: [_jsxs(Listing, { bar: bar,
335
337
  /* La barra se queda arriba: con 187 filas, scrollear para ver la última
336
338
  dejaba sin buscador, sin paginador y sin el conmutador.
337
339
  Embedded it does NOT: the record has its own sticky parts (its rail, its tabs),
@@ -344,7 +346,9 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
344
346
  metrics: _jsx("div", { className: "px-3.5", children: metrics }),
345
347
  }), children: [loading ? _jsx(Skeleton, { label: copy.failed.loading }) : null, isEmpty ? (filtered ? (_jsx(Empty, { sentence: empty?.whenFiltered?.sentence ?? copy.list.emptyFiltered, ...(empty?.whenFiltered?.action === undefined
346
348
  ? {}
347
- : { action: empty.whenFiltered.action }) })) : (_jsx(Empty, { sentence: empty?.sentence ?? copy.list.empty, ...(empty?.action === undefined ? {} : { action: empty.action }) }))) : null, loading || isEmpty ? null : body] }), error === null ? null : (_jsx("div", { className: "px-3 pt-(--spacing-block)", children: _jsx(Failed, { heading: copy.failed.list, sentence: error }) })), children] }));
349
+ : { action: empty.whenFiltered.action }) })) : (_jsx(Empty, { sentence: empty?.sentence ?? copy.list.empty, ...(empty?.action === undefined ? {} : { action: empty.action }) }))) : null, _jsxs("div", { className: "relative min-w-0", children: [refreshing ? (
350
+ /* Over the top edge of the rows, not above them: the rows do not move. */
351
+ _jsx("div", { className: "pointer-events-none absolute inset-x-0 top-0 z-20", children: _jsx(ProgressBar, { label: copy.list.refreshing, width: "w-full" }) })) : null, loading || isEmpty ? null : body] })] }), error === null ? null : (_jsx("div", { className: "px-3 pt-(--spacing-block)", children: _jsx(Failed, { heading: copy.failed.list, sentence: error }) })), children] }));
348
352
  }
349
353
  /** What a list offers when it fails and can be retried. */
350
354
  export function RetryAction({ onRetry }) {
@@ -359,8 +359,16 @@ export type ListViewProps<T extends object> = {
359
359
  /** The three states, in a single place. Every view used to re-assemble them
360
360
  * with three nested ternaries, and that is why «empty because of a filter»
361
361
  * and «really empty» got mixed up in half the screens. */
362
+ /** Los estados del conjunto, en un solo lugar.
363
+ *
364
+ * `loading` es la PRIMERA lectura: no hay filas todavía, y se dibuja el esqueleto.
365
+ * `refreshing` es volver a preguntar con filas en la mano —se tipeó en la búsqueda, se
366
+ * cambió de página—: las filas se quedan y una barra discreta dice que se está
367
+ * preguntando. Cambiarlas por un esqueleto en cada pregunta hacía parpadear la tabla, y un
368
+ * clic podía caer sobre una fila que ya no estaba. */
362
369
  state?: {
363
370
  loading?: boolean;
371
+ refreshing?: boolean;
364
372
  error?: string | null;
365
373
  };
366
374
  filtered?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sincpro/web-ui",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "An enterprise web design system for business software — ERPs, back offices, management tools — in React and Tailwind: tokens, primitives, components and the two views every management screen is made of.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",