@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.
- package/dist/components/form/Sheet.js +4 -1
- package/dist/components/listing/RecordCard.d.ts +4 -0
- package/dist/components/listing/RecordCard.js +4 -1
- package/dist/components/record/DirtyActions.d.ts +20 -6
- package/dist/components/record/DirtyActions.js +55 -4
- package/dist/components/record/RecordHeader.d.ts +1 -1
- package/dist/components/record/RecordHeader.js +32 -13
- package/dist/components/record/RecordSkeleton.js +1 -1
- package/dist/components/search/SearchBar.js +12 -3
- package/dist/foundations/copy.d.ts +11 -0
- package/dist/foundations/copy.es.js +6 -0
- package/dist/foundations/copy.js +6 -0
- package/dist/primitives/button/Button.d.ts +12 -2
- package/dist/primitives/button/Button.js +12 -2
- package/dist/primitives/cell/CellStack.js +1 -1
- package/dist/primitives/field/Field.d.ts +8 -3
- package/dist/primitives/field/Field.js +7 -3
- package/dist/primitives/notice/Notice.js +3 -1
- package/dist/primitives/table/EditableTable.js +5 -1
- package/dist/tokens.css +42 -0
- package/dist/views/list/List.d.ts +8 -1
- package/dist/views/list/ListView.js +6 -2
- package/dist/views/list/types.d.ts +8 -0
- package/package.json +1 -1
|
@@ -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 }),
|
|
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 :
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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 ?
|
|
77
|
-
|
|
78
|
-
|
|
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,
|
|
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", {
|
|
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",
|
package/dist/foundations/copy.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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: "
|
|
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
|
-
/**
|
|
36
|
-
*
|
|
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 = (
|
|
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 (
|
|
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 },
|
|
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
|
|
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,
|
|
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
|
+
"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",
|