@sincpro/web-ui 1.5.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 (101) 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/lines/LineItems.d.ts +14 -3
  14. package/dist/components/lines/LineItems.js +6 -4
  15. package/dist/components/listing/SortMenu.d.ts +24 -20
  16. package/dist/components/listing/SortMenu.js +51 -91
  17. package/dist/components/listing/index.d.ts +2 -1
  18. package/dist/components/listing/index.js +2 -1
  19. package/dist/components/listing/modelCells.d.ts +7 -0
  20. package/dist/components/listing/modelCells.js +56 -0
  21. package/dist/components/listing/modelColumns.d.ts +9 -0
  22. package/dist/components/listing/modelColumns.js +59 -14
  23. package/dist/components/search/index.d.ts +1 -1
  24. package/dist/components/search/index.js +1 -1
  25. package/dist/components/search/model.d.ts +1 -0
  26. package/dist/components/search/model.js +7 -0
  27. package/dist/components/search/query.js +2 -3
  28. package/dist/foundations/copy.d.ts +131 -0
  29. package/dist/foundations/copy.es.js +86 -0
  30. package/dist/foundations/copy.js +86 -0
  31. package/dist/foundations/form.d.ts +18 -1
  32. package/dist/foundations/form.js +6 -3
  33. package/dist/primitives/button/Button.js +3 -1
  34. package/dist/primitives/field/CardNumberInput.d.ts +41 -0
  35. package/dist/primitives/field/CardNumberInput.js +117 -0
  36. package/dist/primitives/field/CodeInput.d.ts +18 -0
  37. package/dist/primitives/field/CodeInput.js +37 -0
  38. package/dist/primitives/field/ColorInput.d.ts +21 -0
  39. package/dist/primitives/field/ColorInput.js +42 -0
  40. package/dist/primitives/field/ContactInputs.d.ts +18 -0
  41. package/dist/primitives/field/ContactInputs.js +70 -0
  42. package/dist/primitives/field/DurationInput.d.ts +20 -0
  43. package/dist/primitives/field/DurationInput.js +54 -0
  44. package/dist/primitives/field/Field.d.ts +24 -4
  45. package/dist/primitives/field/Field.js +17 -4
  46. package/dist/primitives/field/ImageInput.d.ts +51 -0
  47. package/dist/primitives/field/ImageInput.js +267 -0
  48. package/dist/primitives/field/PasswordInput.d.ts +29 -0
  49. package/dist/primitives/field/PasswordInput.js +44 -0
  50. package/dist/primitives/field/PatternInput.d.ts +23 -0
  51. package/dist/primitives/field/PatternInput.js +48 -0
  52. package/dist/primitives/field/RichTextEditor.d.ts +18 -0
  53. package/dist/primitives/field/RichTextEditor.js +224 -0
  54. package/dist/primitives/field/RichTextInput.d.ts +30 -0
  55. package/dist/primitives/field/RichTextInput.js +38 -0
  56. package/dist/primitives/field/SignatureInput.d.ts +34 -0
  57. package/dist/primitives/field/SignatureInput.js +215 -0
  58. package/dist/primitives/field/card.d.ts +33 -0
  59. package/dist/primitives/field/card.js +173 -0
  60. package/dist/primitives/field/clipboard.js +3 -2
  61. package/dist/primitives/field/contact.d.ts +17 -0
  62. package/dist/primitives/field/contact.js +65 -0
  63. package/dist/primitives/field/duration.d.ts +5 -0
  64. package/dist/primitives/field/duration.js +48 -0
  65. package/dist/primitives/field/index.d.ts +14 -0
  66. package/dist/primitives/field/index.js +20 -0
  67. package/dist/primitives/field/mask.d.ts +33 -0
  68. package/dist/primitives/field/mask.js +56 -0
  69. package/dist/primitives/field/media.d.ts +99 -0
  70. package/dist/primitives/field/media.js +136 -0
  71. package/dist/primitives/field/pattern.d.ts +10 -0
  72. package/dist/primitives/field/pattern.js +68 -0
  73. package/dist/primitives/menu/Menu.d.ts +16 -0
  74. package/dist/primitives/menu/Menu.js +26 -8
  75. package/dist/primitives/menu/index.d.ts +1 -1
  76. package/dist/primitives/menu/index.js +1 -1
  77. package/dist/primitives/table/DataTable.d.ts +5 -3
  78. package/dist/primitives/table/DataTable.js +2 -4
  79. package/dist/primitives/table/EditableTable.d.ts +49 -7
  80. package/dist/primitives/table/EditableTable.js +321 -82
  81. package/dist/primitives/table/Table.d.ts +5 -8
  82. package/dist/primitives/table/Table.js +63 -15
  83. package/dist/primitives/table/index.d.ts +3 -2
  84. package/dist/primitives/table/index.js +2 -1
  85. package/dist/primitives/table/rowWindow.d.ts +3 -0
  86. package/dist/primitives/table/rowWindow.js +4 -0
  87. package/dist/primitives/table/sorting.d.ts +42 -0
  88. package/dist/primitives/table/sorting.js +110 -0
  89. package/dist/primitives/value/RichText.d.ts +10 -0
  90. package/dist/primitives/value/RichText.js +130 -0
  91. package/dist/primitives/value/Value.d.ts +20 -2
  92. package/dist/primitives/value/Value.js +26 -7
  93. package/dist/primitives/value/index.d.ts +3 -1
  94. package/dist/primitives/value/index.js +4 -1
  95. package/dist/primitives/value/rounding.d.ts +9 -0
  96. package/dist/primitives/value/rounding.js +48 -0
  97. package/dist/tokens.css +103 -0
  98. package/dist/views/list/List.d.ts +5 -4
  99. package/dist/views/list/ListView.js +2 -2
  100. package/dist/views/list/types.d.ts +6 -6
  101. package/package.json +9 -1
@@ -0,0 +1,33 @@
1
+ import { type RefObject } from "react";
2
+ export type MaskedChange = {
3
+ next: string;
4
+ caret: number;
5
+ };
6
+ /**
7
+ * What a keystroke did to a masked value.
8
+ *
9
+ * raw the input's text right after the keystroke
10
+ * at the caret in `raw`
11
+ * current the significant characters before it
12
+ * drawn how `current` was drawn
13
+ * clean the significant characters of any text
14
+ * max the most of them the value takes
15
+ */
16
+ export declare function maskedChange({ raw, at, current, drawn, clean, max, }: {
17
+ raw: string;
18
+ at: number;
19
+ current: string;
20
+ drawn: string;
21
+ clean: (text: string) => string;
22
+ max: number;
23
+ }): MaskedChange;
24
+ /** Where, in the drawn text, the caret goes to sit after `count` significant characters. */
25
+ export declare function caretAfter(drawn: string, count: number, significant: (char: string) => boolean): number;
26
+ /**
27
+ * The input's ref, and `keep(count)` to call in `onChange`: once the new text is drawn, the
28
+ * caret goes back after `count` significant characters.
29
+ */
30
+ export declare function useMaskCaret(drawn: string, significant: (char: string) => boolean): {
31
+ input: RefObject<HTMLInputElement | null>;
32
+ keep: (count: number) => void;
33
+ };
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { useLayoutEffect, useRef } from "react";
3
+ /**
4
+ * What a keystroke did to a masked value.
5
+ *
6
+ * raw the input's text right after the keystroke
7
+ * at the caret in `raw`
8
+ * current the significant characters before it
9
+ * drawn how `current` was drawn
10
+ * clean the significant characters of any text
11
+ * max the most of them the value takes
12
+ */
13
+ export function maskedChange({ raw, at, current, drawn, clean, max, }) {
14
+ let next = clean(raw);
15
+ let before = clean(raw.slice(0, at)).length;
16
+ if (next === current && raw.length < drawn.length && before > 0) {
17
+ next = next.slice(0, before - 1) + next.slice(before);
18
+ before -= 1;
19
+ }
20
+ next = next.slice(0, max);
21
+ return { next, caret: Math.min(before, next.length) };
22
+ }
23
+ /** Where, in the drawn text, the caret goes to sit after `count` significant characters. */
24
+ export function caretAfter(drawn, count, significant) {
25
+ let seen = 0;
26
+ let at = 0;
27
+ while (at < drawn.length && seen < count) {
28
+ if (significant(drawn[at] ?? ""))
29
+ seen++;
30
+ at++;
31
+ }
32
+ return at;
33
+ }
34
+ /**
35
+ * The input's ref, and `keep(count)` to call in `onChange`: once the new text is drawn, the
36
+ * caret goes back after `count` significant characters.
37
+ */
38
+ export function useMaskCaret(drawn, significant) {
39
+ const input = useRef(null);
40
+ const pending = useRef(null);
41
+ useLayoutEffect(() => {
42
+ const element = input.current;
43
+ const want = pending.current;
44
+ if (element === null || want === null)
45
+ return;
46
+ pending.current = null;
47
+ const at = caretAfter(drawn, want, significant);
48
+ element.setSelectionRange(at, at);
49
+ }, [drawn, significant]);
50
+ return {
51
+ input,
52
+ keep: (count) => {
53
+ pending.current = count;
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,99 @@
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
+ export type Size = {
11
+ width: number;
12
+ height: number;
13
+ };
14
+ export type Rect = {
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ };
20
+ export type Point = {
21
+ x: number;
22
+ y: number;
23
+ };
24
+ /** Where the frame sits on the image: how much it is zoomed in (1 = the image just covers
25
+ * the frame) and the CENTRE of the frame, as a fraction of the image's width and height.
26
+ * Fractions and not pixels so the same crop holds whatever size the image is drawn at. */
27
+ export type Crop = {
28
+ zoom: number;
29
+ x: number;
30
+ y: number;
31
+ };
32
+ /** Past four times the photo is mostly blur: the limit is about what is worth keeping. */
33
+ export declare const CROP_MAX_ZOOM = 4;
34
+ export declare const CROP_CENTERED: Crop;
35
+ /** The crop made valid: zoom within `[1, CROP_MAX_ZOOM]`, and the centre held far enough
36
+ * from the edges that the frame never shows past the image. */
37
+ export declare function clampCrop(image: Size, aspect: number, crop: Crop): Crop;
38
+ /** The part of the image the frame shows, in image pixels — what `drawImage` reads from. */
39
+ export declare function cropRegion(image: Size, aspect: number, crop: Crop): Rect;
40
+ /**
41
+ * Moves the IMAGE under the frame by a fraction of the frame — the way a drag does: dragging
42
+ * right by a quarter of the frame slides the photo right by a quarter, so the frame now shows
43
+ * more of its left side. The arrow keys move it the same way, so the hand and the keyboard
44
+ * never disagree about which way is which.
45
+ */
46
+ export declare function panCrop(image: Size, aspect: number, crop: Crop, dx: number, dy: number): Crop;
47
+ /** Zooms by `factor` about the frame's centre (the point the person is looking at). */
48
+ export declare function zoomCrop(image: Size, aspect: number, crop: Crop, factor: number): Crop;
49
+ /** The size the cropped image is written at: the region's own pixels, never upscaled (a
50
+ * bigger file of the same blur), and at most `maxSide` on its longer side. */
51
+ export declare function outputSize(region: Size, maxSide: number): Size;
52
+ /** Whether a file is an image the browser can draw. A dropped file is not filtered by
53
+ * `accept`, so the type is checked here — and by its extension when the system did not
54
+ * say its type, which happens for a file dragged from some file managers. */
55
+ export declare function isImageFile(file: {
56
+ type: string;
57
+ name: string;
58
+ }): boolean;
59
+ /** The type a crop is written in: JPEG and WebP stay what they were (a photo as PNG is five
60
+ * times heavier for nothing); everything else becomes PNG, which keeps transparency. */
61
+ export declare function croppedType(type: string): "image/png" | "image/jpeg" | "image/webp";
62
+ /** The file's name with the extension of the type it was written in: «logo.gif» cropped to
63
+ * a PNG is «logo.png», so the server does not trust a name that lies about its content. */
64
+ export declare function croppedName(name: string, type: string): string;
65
+ /** One instruction of a stroke's path, in the canvas's terms. */
66
+ export type PathStep = {
67
+ kind: "dot";
68
+ at: Point;
69
+ } | {
70
+ kind: "move";
71
+ to: Point;
72
+ } | {
73
+ kind: "quad";
74
+ control: Point;
75
+ to: Point;
76
+ } | {
77
+ kind: "line";
78
+ to: Point;
79
+ };
80
+ /**
81
+ * The path of one stroke, smoothed: each sampled point becomes the CONTROL of a quadratic
82
+ * curve that runs between the midpoints around it.
83
+ *
84
+ * Context: joining the pointer's samples with straight lines draws a polygon — at the speed
85
+ * a signature is written the samples are far apart, and every corner shows. Curving through
86
+ * the midpoints passes near every sample without a corner at any of them, and costs nothing:
87
+ * no pressure, no velocity, no library. A single sample is a tap — a dot, the one over an i.
88
+ */
89
+ export declare function strokeSteps(points: readonly Point[]): PathStep[];
90
+ /** The font size a typed name is written at so it fits `maxWidth`: `size` when it fits, and
91
+ * smaller in proportion when it does not — a long name is still one line, never cut. */
92
+ export declare function fitFontSize(widthAtSize: number, size: number, maxWidth: number, min?: number): number;
93
+ /** Keeps a pointer's moves on the element that started a drag, even when it leaves it — a
94
+ * stroke that runs off the pad is still one stroke. A pointer the browser no longer tracks
95
+ * (a synthetic one, or one already lifted) cannot be captured, and the drag works without. */
96
+ export declare function capturePointer(event: {
97
+ currentTarget: Element;
98
+ pointerId: number;
99
+ }): void;
@@ -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,5 +1,6 @@
1
1
  import { type Grouping } from "../../foundations/grouping.js";
2
2
  import { type FlagProps } from "../notice/Flag.js";
3
+ import { type WindowedRow } from "./rowWindow.js";
3
4
  import { type ReactNode } from "react";
4
5
  /** Where a column goes when the row is drawn as a stacked card (a narrow box).
5
6
  * `trail` sits at the top right, beside the title — a state, the row's buttons. */
@@ -46,6 +47,37 @@ export type EditBands<T> = {
46
47
  * column does not add up. The lines are the ones under it until the next section. */
47
48
  total?: (column: EditColumn<T>, lines: readonly T[]) => ReactNode | null;
48
49
  };
50
+ /** A warning drawn as a `Flag`: on a row (`flag`) or on one cell (`EditCellState.flag`). */
51
+ export type EditFlag = Omit<FlagProps, "children"> & {
52
+ detail?: ReactNode;
53
+ owner?: string;
54
+ };
55
+ /**
56
+ * What ONE cell of one row is right now — said by whoever knows the record: the states the
57
+ * backend sends per line and field. Every key left out keeps the column as declared.
58
+ *
59
+ * Context: a column declares what it is for every row (`edit`), but an ERP line decides
60
+ * per row — a delivered line keeps its quantity, a service line has no lot, a discount is
61
+ * required only past a threshold. The table does not know why; it is told, cell by cell.
62
+ */
63
+ export type EditCellState = {
64
+ /** Not written in on this row: the cell reads while the row is edited. */
65
+ readonly?: boolean;
66
+ /** Has to have a value: marked while the row is edited, and said to its control. */
67
+ required?: boolean;
68
+ /** Not there on this row (Odoo's per-row `invisible`): the cell is drawn empty. */
69
+ hidden?: boolean;
70
+ /** What is wrong with this value, said as what to put — under the value, with an icon,
71
+ * and the control is `aria-invalid` and described by it. `null` when nothing is. */
72
+ error?: string | null;
73
+ /** A warning about THIS value — «precio bajo el costo» on the price, not on the line. */
74
+ flag?: EditFlag | null;
75
+ };
76
+ /** What a whole row is right now. A row that should not be seen is not passed at all. */
77
+ export type EditRowState = {
78
+ /** Read, not written: walked with the arrows, never opened to edit. */
79
+ readonly?: boolean;
80
+ };
49
81
  /** One more way to add, beside «add a line»: a section, a note. */
50
82
  export type EditAdd = {
51
83
  label: string;
@@ -97,7 +129,7 @@ export type RowKey = "ArrowDown" | "ArrowUp" | "Home" | "End";
97
129
  * of a new record in `DataTable`: one keyboard model, three shapes. Exported for that
98
130
  * family sibling only; it is not in the barrel.
99
131
  */
100
- export declare function EditRow({ as, rowId, rowRef, editable, editing, tabbable, className, onStart, onCommit, onCancel, onMove, onFocusRow, onTabOut, children, }: {
132
+ export declare function EditRow({ as, rowId, rowRef, editable, editing, tabbable, className, onStart, onCommit, onCancel, onMove, onFocusRow, onTabOut, windowed, children, }: {
101
133
  as: "tr" | "li";
102
134
  rowId: string;
103
135
  rowRef: (node: HTMLElement | null) => void;
@@ -113,6 +145,9 @@ export declare function EditRow({ as, rowId, rowRef, editable, editing, tabbable
113
145
  /** `Tab` left the row by one of its edges. `true` = the table took it (opened another
114
146
  * row, added one); `false` = the browser moves the focus as usual. */
115
147
  onTabOut: (direction: "next" | "prev") => boolean;
148
+ /** Past a few hundred rows the table draws only the ones in view (`rowWindow`): this row's
149
+ * measure, its index, and the focus that keeps it drawn. */
150
+ windowed?: WindowedRow | undefined;
116
151
  children: ReactNode;
117
152
  }): import("react").JSX.Element;
118
153
  /**
@@ -135,15 +170,19 @@ totals, empty, grouping,
135
170
  /** El total de una columna DENTRO de un montón, ya formateado. Lo calcula quien
136
171
  * compone, por el mismo motivo que `totals`: sumar significa cosas distintas
137
172
  * según el dominio, y un porcentaje se promedia, no se suma. */
138
- groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, onReorder, }: {
173
+ groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, onReorder, cellState, rowState, }: {
139
174
  rows: readonly T[];
140
175
  columns: readonly EditColumn<T>[];
176
+ /** The row's key on this screen, stable for as long as the row is drawn — a local key for
177
+ * a row the backend has not made yet, kept after it is saved. A key that changes is a new
178
+ * row to React: the row being written is unmounted and loses the focus. */
141
179
  getRowId: (row: T) => string;
142
180
  onChange?: (id: string, next: T) => void;
143
181
  onRemove?: (id: string) => void;
144
182
  /** «Add a line». Return the new row's id —or a promise of it, when the backend makes
145
183
  * the record— and the table opens that row with the focus on its first control.
146
- * Return nothing and the row just appears, closed. */
184
+ * Return nothing and the row just appears, closed. The id is the row's KEY (`getRowId`):
185
+ * give a line the backend has not made a local one, and keep it after the save. */
147
186
  onAdd?: () => string | void | Promise<string | void>;
148
187
  addLabel?: string;
149
188
  totals?: readonly (ReactNode | null)[];
@@ -184,10 +223,13 @@ groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, onReorder, }:
184
223
  * `null` for the rows with nothing to say, which should be most of them. `owner` is what
185
224
  * the record's activity says it belongs to («Línea 3 · Impresora…»); without it, the
186
225
  * row's first value. */
187
- flag?: (row: T) => (Omit<FlagProps, "children"> & {
188
- detail?: ReactNode;
189
- owner?: string;
190
- }) | null;
226
+ flag?: (row: T) => EditFlag | null;
227
+ /** What each cell is on each row — readonly, required, hidden, its error, its warning —
228
+ * as the backend says it per line. `null` keeps the column as declared. See
229
+ * `EditCellState`. */
230
+ cellState?: (row: T, columnId: string) => EditCellState | null;
231
+ /** What a whole row is: `readonly` is read, not opened. See `EditRowState`. */
232
+ rowState?: (row: T) => EditRowState | null;
191
233
  /** The rows have an order of their own — the lines of an order, Odoo's `sequence` — and it
192
234
  * is changed by dragging the grip at the start of a row, or with Alt + ↑ / ↓ on it. `before`
193
235
  * is the row it lands in front of; `null`, the end. Off while grouped (the piles decide the