@sincpro/web-ui 1.6.0 → 1.7.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.
Files changed (93) hide show
  1. package/dist/components/attachments/Attachments.d.ts +56 -0
  2. package/dist/components/attachments/Attachments.js +93 -0
  3. package/dist/components/attachments/files.d.ts +28 -0
  4. package/dist/components/attachments/files.js +100 -0
  5. package/dist/components/attachments/index.d.ts +2 -0
  6. package/dist/components/attachments/index.js +3 -0
  7. package/dist/components/flow/StepFlow.d.ts +37 -0
  8. package/dist/components/flow/StepFlow.js +74 -0
  9. package/dist/components/flow/index.d.ts +1 -0
  10. package/dist/components/flow/index.js +2 -0
  11. package/dist/components/index.d.ts +2 -0
  12. package/dist/components/index.js +2 -0
  13. package/dist/components/listing/SortMenu.d.ts +24 -20
  14. package/dist/components/listing/SortMenu.js +51 -91
  15. package/dist/components/listing/index.d.ts +2 -1
  16. package/dist/components/listing/index.js +2 -1
  17. package/dist/components/listing/modelCells.d.ts +7 -0
  18. package/dist/components/listing/modelCells.js +56 -0
  19. package/dist/components/listing/modelColumns.d.ts +9 -0
  20. package/dist/components/listing/modelColumns.js +59 -14
  21. package/dist/components/search/index.d.ts +1 -1
  22. package/dist/components/search/index.js +1 -1
  23. package/dist/components/search/model.d.ts +1 -0
  24. package/dist/components/search/model.js +7 -0
  25. package/dist/components/search/query.js +2 -3
  26. package/dist/foundations/copy.d.ts +131 -0
  27. package/dist/foundations/copy.es.js +86 -0
  28. package/dist/foundations/copy.js +86 -0
  29. package/dist/primitives/button/Button.js +3 -1
  30. package/dist/primitives/field/CardNumberInput.d.ts +41 -0
  31. package/dist/primitives/field/CardNumberInput.js +117 -0
  32. package/dist/primitives/field/CodeInput.d.ts +18 -0
  33. package/dist/primitives/field/CodeInput.js +37 -0
  34. package/dist/primitives/field/ColorInput.d.ts +21 -0
  35. package/dist/primitives/field/ColorInput.js +42 -0
  36. package/dist/primitives/field/ContactInputs.d.ts +18 -0
  37. package/dist/primitives/field/ContactInputs.js +70 -0
  38. package/dist/primitives/field/DurationInput.d.ts +20 -0
  39. package/dist/primitives/field/DurationInput.js +54 -0
  40. package/dist/primitives/field/Field.d.ts +24 -4
  41. package/dist/primitives/field/Field.js +17 -4
  42. package/dist/primitives/field/ImageInput.d.ts +51 -0
  43. package/dist/primitives/field/ImageInput.js +267 -0
  44. package/dist/primitives/field/PasswordInput.d.ts +29 -0
  45. package/dist/primitives/field/PasswordInput.js +44 -0
  46. package/dist/primitives/field/PatternInput.d.ts +23 -0
  47. package/dist/primitives/field/PatternInput.js +48 -0
  48. package/dist/primitives/field/RichTextEditor.d.ts +18 -0
  49. package/dist/primitives/field/RichTextEditor.js +224 -0
  50. package/dist/primitives/field/RichTextInput.d.ts +30 -0
  51. package/dist/primitives/field/RichTextInput.js +38 -0
  52. package/dist/primitives/field/SignatureInput.d.ts +34 -0
  53. package/dist/primitives/field/SignatureInput.js +215 -0
  54. package/dist/primitives/field/card.d.ts +33 -0
  55. package/dist/primitives/field/card.js +173 -0
  56. package/dist/primitives/field/clipboard.js +3 -2
  57. package/dist/primitives/field/contact.d.ts +17 -0
  58. package/dist/primitives/field/contact.js +65 -0
  59. package/dist/primitives/field/duration.d.ts +5 -0
  60. package/dist/primitives/field/duration.js +48 -0
  61. package/dist/primitives/field/index.d.ts +14 -0
  62. package/dist/primitives/field/index.js +20 -0
  63. package/dist/primitives/field/mask.d.ts +33 -0
  64. package/dist/primitives/field/mask.js +56 -0
  65. package/dist/primitives/field/media.d.ts +99 -0
  66. package/dist/primitives/field/media.js +136 -0
  67. package/dist/primitives/field/pattern.d.ts +10 -0
  68. package/dist/primitives/field/pattern.js +68 -0
  69. package/dist/primitives/menu/Menu.d.ts +16 -0
  70. package/dist/primitives/menu/Menu.js +26 -8
  71. package/dist/primitives/menu/index.d.ts +1 -1
  72. package/dist/primitives/menu/index.js +1 -1
  73. package/dist/primitives/table/DataTable.d.ts +5 -3
  74. package/dist/primitives/table/DataTable.js +2 -4
  75. package/dist/primitives/table/Table.d.ts +5 -8
  76. package/dist/primitives/table/Table.js +63 -15
  77. package/dist/primitives/table/index.d.ts +2 -1
  78. package/dist/primitives/table/index.js +2 -1
  79. package/dist/primitives/table/sorting.d.ts +42 -0
  80. package/dist/primitives/table/sorting.js +110 -0
  81. package/dist/primitives/value/RichText.d.ts +10 -0
  82. package/dist/primitives/value/RichText.js +130 -0
  83. package/dist/primitives/value/Value.d.ts +20 -2
  84. package/dist/primitives/value/Value.js +26 -7
  85. package/dist/primitives/value/index.d.ts +3 -1
  86. package/dist/primitives/value/index.js +4 -1
  87. package/dist/primitives/value/rounding.d.ts +9 -0
  88. package/dist/primitives/value/rounding.js +48 -0
  89. package/dist/tokens.css +103 -0
  90. package/dist/views/list/List.d.ts +5 -4
  91. package/dist/views/list/ListView.js +2 -2
  92. package/dist/views/list/types.d.ts +6 -6
  93. package/package.json +9 -1
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The arithmetic behind the two media controls — the crop of `ImageInput` and the strokes of
3
+ * `SignatureInput` — kept apart from the canvas so it can be checked without a browser.
4
+ *
5
+ * Context: a crop that is off by half a pixel, or a pan that lets the frame slide past the
6
+ * edge of the photo, is invisible in a story and obvious on the avatar a hundred screens
7
+ * later. Here the rules are plain functions over sizes and points; the components only
8
+ * read the pointer and the keys, ask these where things go, and paint.
9
+ */
10
+ /** Past four times the photo is mostly blur: the limit is about what is worth keeping. */
11
+ export const CROP_MAX_ZOOM = 4;
12
+ export const CROP_CENTERED = { zoom: 1, x: 0.5, y: 0.5 };
13
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
14
+ /** The width of the frame, in image pixels, at zoom 1: the largest box of `aspect` that fits
15
+ * inside the image. «Cover», never «contain» — a crop never adds empty bands. */
16
+ function coverWidth(image, aspect) {
17
+ return Math.min(image.width, image.height * aspect);
18
+ }
19
+ /** The crop made valid: zoom within `[1, CROP_MAX_ZOOM]`, and the centre held far enough
20
+ * from the edges that the frame never shows past the image. */
21
+ export function clampCrop(image, aspect, crop) {
22
+ const zoom = clamp(Number.isFinite(crop.zoom) ? crop.zoom : 1, 1, CROP_MAX_ZOOM);
23
+ const width = coverWidth(image, aspect) / zoom;
24
+ const height = width / aspect;
25
+ const halfX = width / 2 / image.width;
26
+ const halfY = height / 2 / image.height;
27
+ return {
28
+ zoom,
29
+ x: clamp(crop.x, halfX, 1 - halfX),
30
+ y: clamp(crop.y, halfY, 1 - halfY),
31
+ };
32
+ }
33
+ /** The part of the image the frame shows, in image pixels — what `drawImage` reads from. */
34
+ export function cropRegion(image, aspect, crop) {
35
+ const valid = clampCrop(image, aspect, crop);
36
+ const width = coverWidth(image, aspect) / valid.zoom;
37
+ const height = width / aspect;
38
+ return {
39
+ x: valid.x * image.width - width / 2,
40
+ y: valid.y * image.height - height / 2,
41
+ width,
42
+ height,
43
+ };
44
+ }
45
+ /**
46
+ * Moves the IMAGE under the frame by a fraction of the frame — the way a drag does: dragging
47
+ * right by a quarter of the frame slides the photo right by a quarter, so the frame now shows
48
+ * more of its left side. The arrow keys move it the same way, so the hand and the keyboard
49
+ * never disagree about which way is which.
50
+ */
51
+ export function panCrop(image, aspect, crop, dx, dy) {
52
+ const region = cropRegion(image, aspect, crop);
53
+ return clampCrop(image, aspect, {
54
+ ...crop,
55
+ x: crop.x - (dx * region.width) / image.width,
56
+ y: crop.y - (dy * region.height) / image.height,
57
+ });
58
+ }
59
+ /** Zooms by `factor` about the frame's centre (the point the person is looking at). */
60
+ export function zoomCrop(image, aspect, crop, factor) {
61
+ return clampCrop(image, aspect, { ...crop, zoom: crop.zoom * factor });
62
+ }
63
+ /** The size the cropped image is written at: the region's own pixels, never upscaled (a
64
+ * bigger file of the same blur), and at most `maxSide` on its longer side. */
65
+ export function outputSize(region, maxSide) {
66
+ const scale = Math.min(1, maxSide / Math.max(region.width, region.height));
67
+ return {
68
+ width: Math.max(1, Math.round(region.width * scale)),
69
+ height: Math.max(1, Math.round(region.height * scale)),
70
+ };
71
+ }
72
+ /** Whether a file is an image the browser can draw. A dropped file is not filtered by
73
+ * `accept`, so the type is checked here — and by its extension when the system did not
74
+ * say its type, which happens for a file dragged from some file managers. */
75
+ export function isImageFile(file) {
76
+ if (file.type !== "")
77
+ return file.type.startsWith("image/");
78
+ return /\.(?:png|jpe?g|gif|webp|avif|bmp|svg)$/i.test(file.name);
79
+ }
80
+ /** The type a crop is written in: JPEG and WebP stay what they were (a photo as PNG is five
81
+ * times heavier for nothing); everything else becomes PNG, which keeps transparency. */
82
+ export function croppedType(type) {
83
+ return type === "image/jpeg" || type === "image/webp" ? type : "image/png";
84
+ }
85
+ /** The file's name with the extension of the type it was written in: «logo.gif» cropped to
86
+ * a PNG is «logo.png», so the server does not trust a name that lies about its content. */
87
+ export function croppedName(name, type) {
88
+ const extension = type === "image/jpeg" ? "jpg" : type === "image/webp" ? "webp" : "png";
89
+ const base = name.replace(/\.[^./]+$/, "");
90
+ return `${base === "" ? "imagen" : base}.${extension}`;
91
+ }
92
+ const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
93
+ /**
94
+ * The path of one stroke, smoothed: each sampled point becomes the CONTROL of a quadratic
95
+ * curve that runs between the midpoints around it.
96
+ *
97
+ * Context: joining the pointer's samples with straight lines draws a polygon — at the speed
98
+ * a signature is written the samples are far apart, and every corner shows. Curving through
99
+ * the midpoints passes near every sample without a corner at any of them, and costs nothing:
100
+ * no pressure, no velocity, no library. A single sample is a tap — a dot, the one over an i.
101
+ */
102
+ export function strokeSteps(points) {
103
+ const first = points[0];
104
+ if (first === undefined)
105
+ return [];
106
+ if (points.length === 1)
107
+ return [{ kind: "dot", at: first }];
108
+ const steps = [{ kind: "move", to: first }];
109
+ for (let i = 1; i < points.length - 1; i += 1) {
110
+ steps.push({
111
+ kind: "quad",
112
+ control: points[i],
113
+ to: midpoint(points[i], points[i + 1]),
114
+ });
115
+ }
116
+ steps.push({ kind: "line", to: points[points.length - 1] });
117
+ return steps;
118
+ }
119
+ /** The font size a typed name is written at so it fits `maxWidth`: `size` when it fits, and
120
+ * smaller in proportion when it does not — a long name is still one line, never cut. */
121
+ export function fitFontSize(widthAtSize, size, maxWidth, min = 12) {
122
+ if (widthAtSize <= maxWidth || widthAtSize <= 0)
123
+ return size;
124
+ return Math.max(min, Math.floor((size * maxWidth) / widthAtSize));
125
+ }
126
+ /** Keeps a pointer's moves on the element that started a drag, even when it leaves it — a
127
+ * stroke that runs off the pad is still one stroke. A pointer the browser no longer tracks
128
+ * (a synthetic one, or one already lifted) cannot be captured, and the drag works without. */
129
+ export function capturePointer(event) {
130
+ try {
131
+ event.currentTarget.setPointerCapture(event.pointerId);
132
+ }
133
+ catch {
134
+ /* Not capturable: the moves still arrive while the pointer stays over the element. */
135
+ }
136
+ }
@@ -0,0 +1,10 @@
1
+ /** What of a text fills the pattern's slots, in order — a character a slot does not take is
2
+ * dropped, letters go to capitals. «1020304-5» on «###-#######» is «10203045». */
3
+ export declare function patternValue(text: string, pattern: string): string;
4
+ /** The value drawn on the pattern, up to its last character: a separator is drawn only once
5
+ * something comes after it, so deleting never leaves a dangling «-». */
6
+ export declare function formatPattern(value: string, pattern: string): string;
7
+ /** Every slot is filled. */
8
+ export declare function isPatternComplete(value: string, pattern: string): boolean;
9
+ /** How many characters the pattern takes. */
10
+ export declare function patternLength(pattern: string): number;
@@ -0,0 +1,68 @@
1
+ /* ==================================================================== *
2
+ * A PATTERN — where each character of an identifier goes.
3
+ *
4
+ * # a digit
5
+ * A a letter, written in capitals
6
+ * * a letter or a digit
7
+ *
8
+ * Anything else is a separator drawn between them: «###-#######» for a
9
+ * reference, «AA## #### #### #### #### ####» for an IBAN. Separators are never
10
+ * letters or digits — those always mean a slot.
11
+ *
12
+ * The value is only what fills the slots («BO12345…»); the separators are
13
+ * drawn. Pure: `PatternInput` types with it and a form checks with it.
14
+ * ==================================================================== */
15
+ const SLOT = /[#A*]/;
16
+ function fits(slot, char) {
17
+ if (slot === "#")
18
+ return /\d/.test(char);
19
+ if (slot === "A")
20
+ return /[a-z]/i.test(char);
21
+ return /[a-z\d]/i.test(char);
22
+ }
23
+ /** The slots of a pattern, in order. */
24
+ function slotsOf(pattern) {
25
+ return [...pattern].filter((char) => SLOT.test(char));
26
+ }
27
+ /** What of a text fills the pattern's slots, in order — a character a slot does not take is
28
+ * dropped, letters go to capitals. «1020304-5» on «###-#######» is «10203045». */
29
+ export function patternValue(text, pattern) {
30
+ const slots = slotsOf(pattern);
31
+ let value = "";
32
+ for (const char of text) {
33
+ const slot = slots[value.length];
34
+ if (slot === undefined)
35
+ break;
36
+ if (fits(slot, char))
37
+ value += char.toUpperCase();
38
+ }
39
+ return value;
40
+ }
41
+ /** The value drawn on the pattern, up to its last character: a separator is drawn only once
42
+ * something comes after it, so deleting never leaves a dangling «-». */
43
+ export function formatPattern(value, pattern) {
44
+ let out = "";
45
+ let pending = "";
46
+ let at = 0;
47
+ for (const char of pattern) {
48
+ if (at >= value.length)
49
+ break;
50
+ if (SLOT.test(char)) {
51
+ out += pending + (value[at] ?? "");
52
+ pending = "";
53
+ at++;
54
+ }
55
+ else {
56
+ pending += char;
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /** Every slot is filled. */
62
+ export function isPatternComplete(value, pattern) {
63
+ return patternValue(value, pattern).length === slotsOf(pattern).length;
64
+ }
65
+ /** How many characters the pattern takes. */
66
+ export function patternLength(pattern) {
67
+ return slotsOf(pattern).length;
68
+ }
@@ -65,6 +65,22 @@ declare const WIDTH: {
65
65
  /** Every option of the tree by id, children included: a submenu is a separate React Aria
66
66
  * menu with its own `onAction`, and both have to reach the same handlers. */
67
67
  export declare function indexOptions(options: readonly MenuOption[], into?: Map<string, MenuOption>): Map<string, MenuOption>;
68
+ /**
69
+ * The look of a list control's trigger — «Filtros ▾», «Columnas ▾», «Ordenar ▾» — without
70
+ * the menu.
71
+ *
72
+ * Context: a control of the bar that opens a PANEL and not a list of options (the sort
73
+ * criteria) still has to look like its neighbours; copying these classes is how the third
74
+ * trigger ends up one pixel off. Same reason `buttonClasses` exists.
75
+ */
76
+ export declare function menuTriggerClasses({ variant, surface, active, open, focusVisible, iconOnly, }: {
77
+ variant?: "button" | "text";
78
+ surface?: "canvas" | "chrome";
79
+ active?: boolean;
80
+ open?: boolean;
81
+ focusVisible?: boolean;
82
+ iconOnly?: boolean;
83
+ }): string;
68
84
  export declare function Menu({ label, groups, onSelect, surface, align, icon: Trigger,
69
85
  /** The menu as a control of a list ("filters ▾") carries the chevron; the
70
86
  * actions `⋯` does not, because the icon already says there is more. */
@@ -26,24 +26,35 @@ export function indexOptions(options, into = new Map()) {
26
26
  }
27
27
  return into;
28
28
  }
29
- export function Menu({ label, groups, onSelect, surface = "canvas", align = "start", icon: Trigger,
30
- /** The menu as a control of a list ("filters ▾") carries the chevron; the
31
- * actions `⋯` does not, because the icon already says there is more. */
32
- chevron = true, active = false, iconOnly = false, variant = "button", searchPlaceholder, emptyText, width = "md", role, size = "md", title, closeOnSelect = true, }) {
33
- const copy = useUiCopy();
34
- const { contains } = useFilter({ sensitivity: "base" });
29
+ /**
30
+ * The look of a list control's trigger — «Filtros ▾», «Columnas ▾», «Ordenar ▾» — without
31
+ * the menu.
32
+ *
33
+ * Context: a control of the bar that opens a PANEL and not a list of options (the sort
34
+ * criteria) still has to look like its neighbours; copying these classes is how the third
35
+ * trigger ends up one pixel off. Same reason `buttonClasses` exists.
36
+ */
37
+ export function menuTriggerClasses({ variant = "button", surface = "canvas", active = false, open = false, focusVisible = false, iconOnly = false, }) {
35
38
  /* `text` is the menu of a BAR: no frame and no background, just the highlight
36
39
  on hover. Five bordered menus on top of an application are five buttons
37
40
  competing with the ones in the view, and the bar stops reading as an index.
38
41
  `button` is the menu of a CONTROL —"filters ▾", "group ▾"—, and there the
39
42
  frame is what says it can be pressed. */
40
- const triggerClass = (open) => variant === "text"
43
+ const tone = variant === "text"
41
44
  ? surface === "chrome"
42
45
  ? `border-transparent text-chrome-muted hover:bg-chrome-ink/12 hover:text-chrome-ink ${active || open ? "bg-chrome-ink/12 text-chrome-ink" : ""}`
43
46
  : `border-transparent text-muted hover:bg-ink/6 hover:text-ink ${active || open ? "bg-ink/6 text-ink" : ""}`
44
47
  : surface === "chrome"
45
48
  ? `border-chrome-muted/35 bg-chrome-raised text-chrome-ink hover:border-chrome-accent/45 ${active ? "border-chrome-accent/60" : ""}`
46
49
  : `border-line-strong bg-surface hover:border-primary/45 hover:bg-primary/5 ${active ? "border-primary/60 text-primary" : "text-ink"}`;
50
+ return `inline-flex h-(--size-control-sm) shrink-0 cursor-default items-center gap-1.5 border text-micro font-medium whitespace-nowrap outline-0 transition-colors duration-(--duration-instant) ${focusVisible ? "outline-2 outline-offset-2 outline-primary" : ""} ${iconOnly ? "w-(--size-control-sm) justify-center rounded-pill" : "rounded-control px-2.5"} ${tone}`;
51
+ }
52
+ export function Menu({ label, groups, onSelect, surface = "canvas", align = "start", icon: Trigger,
53
+ /** The menu as a control of a list ("filters ▾") carries the chevron; the
54
+ * actions `⋯` does not, because the icon already says there is more. */
55
+ chevron = true, active = false, iconOnly = false, variant = "button", searchPlaceholder, emptyText, width = "md", role, size = "md", title, closeOnSelect = true, }) {
56
+ const copy = useUiCopy();
57
+ const { contains } = useFilter({ sensitivity: "base" });
47
58
  const byId = indexOptions(groups.flatMap((g) => g.options));
48
59
  const menu = (_jsx(MenuList, { label: label, groups: groups, width: width, closeOnSelect: closeOnSelect, onAction: (key) => {
49
60
  const option = byId.get(String(key));
@@ -51,7 +62,14 @@ chevron = true, active = false, iconOnly = false, variant = "button", searchPlac
51
62
  onSelect?.(String(key));
52
63
  }, emptyText: emptyText ?? copy.menu.empty }));
53
64
  return (_jsxs(MenuTrigger, { children: [_jsx(AriaButton, { "aria-label": iconOnly ? label : undefined, ...(role === undefined ? {} : { "data-emphasis": role }), className: ({ isPressed, isFocusVisible }) => role === undefined
54
- ? `inline-flex h-(--size-control-sm) shrink-0 cursor-default items-center gap-1.5 border text-micro font-medium whitespace-nowrap outline-0 transition-colors duration-(--duration-instant) ${isFocusVisible ? "outline-2 outline-offset-2 outline-primary" : ""} ${iconOnly ? "w-(--size-control-sm) justify-center rounded-pill" : "rounded-control px-2.5"} ${triggerClass(isPressed)}`
65
+ ? menuTriggerClasses({
66
+ variant,
67
+ surface,
68
+ active,
69
+ open: isPressed,
70
+ focusVisible: isFocusVisible,
71
+ iconOnly,
72
+ })
55
73
  : `${buttonClasses(role, size, surface)} gap-1.5`, children: _jsxs("span", { className: "contents", title: title ?? (iconOnly ? label : undefined), children: [Trigger === undefined ? (iconOnly ? (_jsx(ChevronDown, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true })) : null) : (_jsx(Trigger, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true })), iconOnly ? null : label, chevron && !iconOnly ? (_jsx(ChevronDown, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true })) : null] }) }), _jsx(Popover, { placement: align === "end" ? "bottom end" : "bottom start", className: WIDTH[width], children: searchPlaceholder === undefined ? (menu) : (
56
74
  /* The search box stays at the top: in a menu of twenty-five entries,
57
75
  scrolling loses exactly the control that explains why only three are
@@ -1 +1 @@
1
- export { Menu, type MenuGroup, type MenuOption } from "./Menu.js";
1
+ export { Menu, type MenuGroup, type MenuOption, menuTriggerClasses, } from "./Menu.js";
@@ -1,2 +1,2 @@
1
1
  /* A data-driven dropdown: the actions `⋯` and "+ Create". */
2
- export { Menu } from "./Menu.js";
2
+ export { Menu, menuTriggerClasses, } from "./Menu.js";
@@ -1,7 +1,7 @@
1
1
  import { type Pile } from "../../foundations/grouping.js";
2
2
  import type { Tone } from "../../foundations/types.js";
3
3
  import { type RowAction } from "./RowActions.js";
4
- import { type SortState } from "./Table.js";
4
+ import type { SortState } from "./sorting.js";
5
5
  import { type ReactNode } from "react";
6
6
  /** How a column is aggregated when its rows are pulled together into a group.
7
7
  *
@@ -153,8 +153,10 @@ export declare function DataTable<T extends object>({ data, columns, getRowId, s
153
153
  data: readonly T[];
154
154
  columns: readonly DataColumn<T>[];
155
155
  getRowId: (row: T) => string;
156
- sort?: SortState | null;
157
- onSortChange?: (next: SortState | null) => void;
156
+ /** The order, outermost first: the second criterion decides only between rows the first
157
+ * leaves equal. Empty or absent is «as they came». */
158
+ sort?: readonly SortState[];
159
+ onSortChange?: (next: SortState[]) => void;
158
160
  /**
159
161
  * Los NIVELES por los que se parte, de afuera hacia adentro. Vacío o ausente
160
162
  * es «sin agrupar».
@@ -8,7 +8,7 @@ import { EditRow, firstControl } from "./EditableTable.js";
8
8
  import { PileFootLine } from "./PileFootLine.js";
9
9
  import { RowActions } from "./RowActions.js";
10
10
  import { useRowWindow } from "./rowWindow.js";
11
- import { SelectBox, Table, Td, Th, THead, Tr, } from "./Table.js";
11
+ import { SelectBox, Table, Td, Th, THead, Tr } from "./Table.js";
12
12
  import { aggregationFns, columnGroupingFeature, createColumnHelper, createExpandedRowModel, createGroupedRowModel, createSortedRowModel, rowAggregationFeature, rowExpandingFeature, rowSortingFeature, sortFns, tableFeatures, useTable, } from "@tanstack/react-table";
13
13
  import { ChevronRight, Plus } from "lucide-react";
14
14
  import { useEffect, useMemo, useRef, useState } from "react";
@@ -247,9 +247,7 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
247
247
  columns: defs,
248
248
  getRowId: (row) => getRowId(row),
249
249
  state: {
250
- sorting: sort === null || sort === undefined
251
- ? []
252
- : [{ id: sort.key, desc: sort.direction === "desc" }],
250
+ sorting: (sort ?? []).map((one) => ({ id: one.key, desc: one.direction === "desc" })),
253
251
  /* Counted elsewhere, the tree comes made: grouping the page again would undo it. */
254
252
  grouping: piles === undefined ? (levels ?? []).map((level) => level.id) : [],
255
253
  },
@@ -1,11 +1,7 @@
1
1
  import type { Tone } from "../../foundations/types.js";
2
2
  import type { WindowedRow } from "./rowWindow.js";
3
+ import { type SortState } from "./sorting.js";
3
4
  import { type ReactNode } from "react";
4
- export type SortDirection = "asc" | "desc";
5
- export interface SortState {
6
- key: string;
7
- direction: SortDirection;
8
- }
9
5
  export declare function Table({ children,
10
6
  /** With a height, the table scrolls on its own and the header sticks.
11
7
  * Without a height, the page scrolls and the header goes away. */
@@ -29,14 +25,15 @@ export declare function THead({ children, rowIndex, }: {
29
25
  }): import("react").JSX.Element;
30
26
  /** A column. If it gets a `sortKey`, it can be sorted.
31
27
  *
32
- * The icon is ALWAYS there, dimmed when the column is not the sorted one: an
28
+ * The icon is ALWAYS there, dimmed when the column is not sorted: an
33
29
  * indicator that only shows up once you sort does not say which columns are
34
30
  * sortable, and then you have to try them one by one. */
35
31
  export declare function Th({ children, sortKey, sort, onSort, numeric, align, width, }: {
36
32
  children: ReactNode;
37
33
  sortKey?: string;
38
- sort?: SortState | null;
39
- onSort?: (next: SortState | null) => void;
34
+ /** The order of the set, outermost first. Empty is «as they came». */
35
+ sort?: readonly SortState[];
36
+ onSort?: (next: SortState[]) => void;
40
37
  /** Numbers align to the right: that is what lets you compare orders of
41
38
  * magnitude without reading the figures. */
42
39
  numeric?: boolean;
@@ -1,8 +1,46 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
3
4
  import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
5
+ import { pressSort, sortPosition, } from "./sorting.js";
4
6
  import { ArrowDown, ArrowUp, ChevronRight, ChevronsUpDown } from "lucide-react";
5
7
  import { createContext, useContext, useId } from "react";
8
+ /* ==================================================================== *
9
+ * TABLE — the way this product shows N records.
10
+ *
11
+ * Dense, but readable, and a single split is what makes that possible:
12
+ *
13
+ * the header in PROPORTIONAL is a word
14
+ * a number in TABULAR FIGURES is a value that lines up with the one below
15
+ *
16
+ * That is what makes a table readable at a glance: the eye tells the frame
17
+ * apart from the content without reading either of them. Everything in mono
18
+ * —which is where this product came from— turned the header into just another
19
+ * row of data, and the whole report into a terminal. Mono is now for codes
20
+ * only, and only when a cell asks for it.
21
+ *
22
+ * ── Three sort states, not two ──────────────────────────────────────
23
+ *
24
+ * Ascending, descending and **unsorted**. The third one is not a luxury: the
25
+ * natural order of a delivery queue is the order they arrived in, and a table that
26
+ * only toggles asc/desc has no way back to it. Losing the original order and
27
+ * not being able to get it back is the number one complaint about any
28
+ * sortable table.
29
+ *
30
+ * ── And more than one column ────────────────────────────────────────
31
+ *
32
+ * Shift+click adds the column as a TIE-BREAKER instead of replacing the order:
33
+ * «by date, then by customer». Each sorted header then carries its place (1,
34
+ * 2, 3) next to the arrow, because two arrows alone do not say which one
35
+ * decides. The model is in `sorting.ts`.
36
+ *
37
+ * ── The header stays pinned ─────────────────────────────────────────
38
+ *
39
+ * It is the difference between a long table you can read and one where, by
40
+ * row forty, nobody knows which column they are looking at any more. It needs
41
+ * the box to have a height: without `height` there is no scroll of its own
42
+ * and nothing to stick to.
43
+ * ==================================================================== */
6
44
  export function Table({ children,
7
45
  /** With a height, the table scrolls on its own and the header sticks.
8
46
  * Without a height, the page scrolls and the header goes away. */
@@ -28,25 +66,35 @@ export function THead({ children, rowIndex, }) {
28
66
  }
29
67
  /** A column. If it gets a `sortKey`, it can be sorted.
30
68
  *
31
- * The icon is ALWAYS there, dimmed when the column is not the sorted one: an
69
+ * The icon is ALWAYS there, dimmed when the column is not sorted: an
32
70
  * indicator that only shows up once you sort does not say which columns are
33
71
  * sortable, and then you have to try them one by one. */
34
72
  export function Th({ children, sortKey, sort, onSort, numeric = false, align, width, }) {
35
- const active = sortKey !== undefined && sort?.key === sortKey;
36
- const dir = active ? sort.direction : undefined;
37
- function nextSort() {
38
- if (sortKey === undefined || onSort === undefined)
39
- return;
40
- // asc → desc → unsorted. The third click puts the table back the way it was.
41
- if (!active)
42
- onSort({ key: sortKey, direction: "asc" });
43
- else if (dir === "asc")
44
- onSort({ key: sortKey, direction: "desc" });
45
- else
46
- onSort(null);
47
- }
73
+ const copy = useUiCopy();
74
+ const at = sortKey === undefined ? null : sortPosition(sort, sortKey);
75
+ const dir = at?.direction;
76
+ /* The place is drawn only when there is more than one criterion: «1» alone says nothing
77
+ the arrow did not. */
78
+ const ranked = at !== null && (sort?.length ?? 0) > 1;
79
+ const right = (align ?? (numeric ? "right" : "left")) === "right";
48
80
  const Icon = dir === "asc" ? ArrowUp : dir === "desc" ? ArrowDown : ChevronsUpDown;
49
- return (_jsx("th", { scope: "col", style: width === undefined ? undefined : { width }, "aria-sort": active ? (dir === "asc" ? "ascending" : "descending") : "none", className: `border-b border-line-strong px-3 py-2 ${HEADER_TEXT} ${(align ?? (numeric ? "right" : "left")) === "right" ? "text-right" : "text-left"}`, children: sortKey === undefined ? (children) : (_jsxs("button", { type: "button", onClick: nextSort, className: `inline-flex items-center gap-1 rounded-control transition-colors duration-(--duration-instant) hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${(align ?? (numeric ? "right" : "left")) === "right" ? "flex-row-reverse" : ""} ${active ? "text-ink" : ""}`, children: [children, _jsx(Icon, { className: `size-(--size-icon-sm) shrink-0 ${active ? "text-primary" : "text-line-strong"}`, strokeWidth: 2, "aria-hidden": true })] })) }));
81
+ return (_jsx("th", { scope: "col", style: width === undefined ? undefined : { width }, "aria-sort": sortKey === undefined
82
+ ? undefined
83
+ : at === null
84
+ ? "none"
85
+ : at.index > 0
86
+ ? undefined
87
+ : dir === "asc"
88
+ ? "ascending"
89
+ : "descending", className: `border-b border-line-strong px-3 py-2 ${HEADER_TEXT} ${right ? "text-right" : "text-left"}`, children: sortKey === undefined ? (children) : (_jsxs("button", { type: "button", title: copy.sorting.addHint,
90
+ /* Shift+click selects the header's text in some browsers before the click lands. */
91
+ onMouseDown: (event) => {
92
+ if (event.shiftKey)
93
+ event.preventDefault();
94
+ }, onClick: (event) => onSort?.(pressSort(sort ?? [], sortKey, event.shiftKey)), className: `inline-flex items-center gap-1 rounded-control transition-colors duration-(--duration-instant) select-none hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${right ? "flex-row-reverse" : ""} ${at === null ? "" : "text-ink"}`, children: [children, _jsxs("span", { className: "inline-flex shrink-0 items-center gap-px", children: [_jsx(Icon, { className: `size-(--size-icon-sm) shrink-0 ${at === null ? "text-line-strong" : "text-primary"}`, strokeWidth: 2, "aria-hidden": true }), ranked ? (_jsx("span", { "aria-hidden": true, className: "text-micro text-primary tabular-nums", children: at.index + 1 })) : null] }), ranked ? (_jsx("span", { className: "sr-only", children: fillCopy(copy.sorting.place, {
95
+ place: String(at.index + 1),
96
+ direction: dir === "asc" ? copy.sorting.asc : copy.sorting.desc,
97
+ }) })) : null] })) }));
50
98
  }
51
99
  /** The wash of a row lives on the cells, not on the `<tr>`. A background on the
52
100
  * row paints edge to edge; on the cells the ends can round and the table's
@@ -5,4 +5,5 @@ export { PivotTable, type PivotTableProps, } from "./PivotTable.js";
5
5
  export { type ReportColumn, type ReportRow, type ReportRowKind, ReportTable, } from "./ReportTable.js";
6
6
  export { type RowAction, RowActions } from "./RowActions.js";
7
7
  export { type SheetColumn, SheetTable } from "./SheetTable.js";
8
- export { CELL_TONE, type SortDirection, type SortState, Table, Td, Th, THead, Tr, } from "./Table.js";
8
+ export { compareBy, flipSort, moveSort, pressSort, type SortDirection, sortPosition, type SortState, } from "./sorting.js";
9
+ export { CELL_TONE, Table, Td, Th, THead, Tr } from "./Table.js";
@@ -7,4 +7,5 @@ export { PivotTable, } from "./PivotTable.js";
7
7
  export { ReportTable, } from "./ReportTable.js";
8
8
  export { RowActions } from "./RowActions.js";
9
9
  export { SheetTable } from "./SheetTable.js";
10
- export { CELL_TONE, Table, Td, Th, THead, Tr, } from "./Table.js";
10
+ export { compareBy, flipSort, moveSort, pressSort, sortPosition, } from "./sorting.js";
11
+ export { CELL_TONE, Table, Td, Th, THead, Tr } from "./Table.js";
@@ -0,0 +1,42 @@
1
+ export type SortDirection = "asc" | "desc";
2
+ /** One criterion: a field and its direction. */
3
+ export interface SortState {
4
+ key: string;
5
+ direction: SortDirection;
6
+ }
7
+ /** Where a column stands in the order: its place (0 = the primary) and direction. */
8
+ export declare function sortPosition(sort: readonly SortState[] | undefined, key: string): {
9
+ index: number;
10
+ direction: SortDirection;
11
+ } | null;
12
+ /**
13
+ * What a press on a column header does to the order.
14
+ *
15
+ * Plain press — the column ALONE:
16
+ * - the only criterion: asc → desc → nothing. The third press gives the
17
+ * natural order back (see `Table.tsx`).
18
+ * - with others around it: the column alone, flipped if it was there, and
19
+ * never «nothing» — a press that also wipes the tie-breakers someone built
20
+ * in the panel would be a surprise, not a shortcut.
21
+ *
22
+ * `additive` (Shift) — one more tie-breaker, the others untouched:
23
+ * - absent: appended, ascending.
24
+ * - ascending: flipped in its place.
25
+ * - descending: taken out.
26
+ */
27
+ export declare function pressSort(sort: readonly SortState[], key: string, additive: boolean): SortState[];
28
+ /** The criterion at `index` with its direction flipped. */
29
+ export declare function flipSort(sort: readonly SortState[], index: number): SortState[];
30
+ /** The criteria with the ones in `keys` moved to sit before `target` (or after it). The
31
+ * shape React Aria's drag-and-drop hands over: a set of dragged keys and a drop target. */
32
+ export declare function moveSort(sort: readonly SortState[], keys: ReadonlySet<string>, target: string, position: "before" | "after"): SortState[];
33
+ /**
34
+ * Compares two rows by every criterion, in order: the second one speaks only when the first
35
+ * one ties. `read` gives each row's value for a field; `undefined` there means «this field
36
+ * cannot be sorted here» and the criterion is skipped.
37
+ *
38
+ * Missing values go LAST whatever the direction. A blank is not «the smallest»: it is the
39
+ * absence of an answer, and putting it first when the order is flipped fills the first
40
+ * screen with the rows that say the least.
41
+ */
42
+ export declare function compareBy<T>(sort: readonly SortState[], read: (key: string) => ((row: T) => string | number | null | undefined) | undefined): (a: T, b: T) => number;