@sincpro/web-ui 1.6.0 → 1.7.1

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 (108) 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.js +55 -45
  14. package/dist/components/listing/SortMenu.d.ts +24 -20
  15. package/dist/components/listing/SortMenu.js +51 -91
  16. package/dist/components/listing/index.d.ts +2 -1
  17. package/dist/components/listing/index.js +2 -1
  18. package/dist/components/listing/modelCells.d.ts +7 -0
  19. package/dist/components/listing/modelCells.js +56 -0
  20. package/dist/components/listing/modelColumns.d.ts +9 -0
  21. package/dist/components/listing/modelColumns.js +59 -14
  22. package/dist/components/search/index.d.ts +1 -1
  23. package/dist/components/search/index.js +1 -1
  24. package/dist/components/search/model.d.ts +1 -0
  25. package/dist/components/search/model.js +7 -0
  26. package/dist/components/search/query.js +2 -3
  27. package/dist/foundations/copy.d.ts +131 -0
  28. package/dist/foundations/copy.es.js +86 -0
  29. package/dist/foundations/copy.js +86 -0
  30. package/dist/foundations/form.d.ts +25 -0
  31. package/dist/foundations/form.js +27 -0
  32. package/dist/foundations/index.d.ts +1 -1
  33. package/dist/foundations/index.js +1 -1
  34. package/dist/primitives/button/Button.js +3 -1
  35. package/dist/primitives/choice/ComboBox.js +2 -2
  36. package/dist/primitives/choice/SearchField.js +2 -5
  37. package/dist/primitives/choice/SegmentedControl.js +2 -2
  38. package/dist/primitives/choice/Slider.js +2 -2
  39. package/dist/primitives/date/DatePicker.js +4 -4
  40. package/dist/primitives/date/DateTime.js +3 -3
  41. package/dist/primitives/field/CardNumberInput.d.ts +41 -0
  42. package/dist/primitives/field/CardNumberInput.js +117 -0
  43. package/dist/primitives/field/CodeInput.d.ts +18 -0
  44. package/dist/primitives/field/CodeInput.js +37 -0
  45. package/dist/primitives/field/ColorInput.d.ts +21 -0
  46. package/dist/primitives/field/ColorInput.js +42 -0
  47. package/dist/primitives/field/ContactInputs.d.ts +18 -0
  48. package/dist/primitives/field/ContactInputs.js +70 -0
  49. package/dist/primitives/field/DurationInput.d.ts +20 -0
  50. package/dist/primitives/field/DurationInput.js +54 -0
  51. package/dist/primitives/field/Field.d.ts +26 -5
  52. package/dist/primitives/field/Field.js +19 -6
  53. package/dist/primitives/field/ImageInput.d.ts +51 -0
  54. package/dist/primitives/field/ImageInput.js +267 -0
  55. package/dist/primitives/field/PasswordInput.d.ts +29 -0
  56. package/dist/primitives/field/PasswordInput.js +44 -0
  57. package/dist/primitives/field/PatternInput.d.ts +23 -0
  58. package/dist/primitives/field/PatternInput.js +48 -0
  59. package/dist/primitives/field/RichTextEditor.d.ts +18 -0
  60. package/dist/primitives/field/RichTextEditor.js +224 -0
  61. package/dist/primitives/field/RichTextInput.d.ts +30 -0
  62. package/dist/primitives/field/RichTextInput.js +38 -0
  63. package/dist/primitives/field/SignatureInput.d.ts +34 -0
  64. package/dist/primitives/field/SignatureInput.js +215 -0
  65. package/dist/primitives/field/Suggest.js +2 -2
  66. package/dist/primitives/field/card.d.ts +33 -0
  67. package/dist/primitives/field/card.js +173 -0
  68. package/dist/primitives/field/clipboard.js +3 -2
  69. package/dist/primitives/field/contact.d.ts +17 -0
  70. package/dist/primitives/field/contact.js +65 -0
  71. package/dist/primitives/field/duration.d.ts +5 -0
  72. package/dist/primitives/field/duration.js +48 -0
  73. package/dist/primitives/field/index.d.ts +14 -0
  74. package/dist/primitives/field/index.js +20 -0
  75. package/dist/primitives/field/mask.d.ts +33 -0
  76. package/dist/primitives/field/mask.js +56 -0
  77. package/dist/primitives/field/media.d.ts +99 -0
  78. package/dist/primitives/field/media.js +136 -0
  79. package/dist/primitives/field/pattern.d.ts +10 -0
  80. package/dist/primitives/field/pattern.js +68 -0
  81. package/dist/primitives/menu/Menu.d.ts +16 -0
  82. package/dist/primitives/menu/Menu.js +26 -8
  83. package/dist/primitives/menu/index.d.ts +1 -1
  84. package/dist/primitives/menu/index.js +1 -1
  85. package/dist/primitives/picker/Picker.js +2 -2
  86. package/dist/primitives/picker/TagsField.js +2 -2
  87. package/dist/primitives/table/DataTable.d.ts +6 -4
  88. package/dist/primitives/table/DataTable.js +12 -8
  89. package/dist/primitives/table/EditableTable.js +102 -99
  90. package/dist/primitives/table/Table.d.ts +5 -8
  91. package/dist/primitives/table/Table.js +63 -15
  92. package/dist/primitives/table/index.d.ts +2 -1
  93. package/dist/primitives/table/index.js +2 -1
  94. package/dist/primitives/table/sorting.d.ts +42 -0
  95. package/dist/primitives/table/sorting.js +110 -0
  96. package/dist/primitives/value/RichText.d.ts +10 -0
  97. package/dist/primitives/value/RichText.js +130 -0
  98. package/dist/primitives/value/Value.d.ts +20 -2
  99. package/dist/primitives/value/Value.js +26 -7
  100. package/dist/primitives/value/index.d.ts +3 -1
  101. package/dist/primitives/value/index.js +4 -1
  102. package/dist/primitives/value/rounding.d.ts +9 -0
  103. package/dist/primitives/value/rounding.js +48 -0
  104. package/dist/tokens.css +103 -0
  105. package/dist/views/list/List.d.ts +5 -4
  106. package/dist/views/list/ListView.js +2 -2
  107. package/dist/views/list/types.d.ts +6 -6
  108. package/package.json +9 -1
@@ -0,0 +1,51 @@
1
+ import { type ControlProps } from "./Field.js";
2
+ /** What `onChange` hands over: the FILE, for the upload — a multipart part with its name and
3
+ * type, the thing a server stores — and its `url` (a data URL), to show it right away. */
4
+ export type ImageChange = {
5
+ file: File;
6
+ url: string;
7
+ };
8
+ /**
9
+ * An image the record holds — a product photo, a logo, the face on a contact: chosen,
10
+ * dropped or pasted, previewed, replaced and removed, and cropped to a shape when the field
11
+ * has one.
12
+ *
13
+ * Context: `Thumb` and `Avatar` showed an image and nothing could change it; this is the
14
+ * control that does. The way in is `DropZone`'s — React Aria's `DropZone` and `FileTrigger`,
15
+ * so a drop, a paste (⌘/Ctrl+V of a copied image) and the keyboard all reach it — but what
16
+ * it holds is the image itself, drawn where the file's name would be.
17
+ *
18
+ * **The value is a string; the change carries the file.** `value` is whatever an `<img>` can
19
+ * draw — the URL the server keeps, or the data URL of a pick not yet saved — so the record
20
+ * passes what it has and the control never asks which. `onChange` hands over `{ file, url }`:
21
+ * the `File` for the upload (with its real name and type), and the `url` to put back into
22
+ * `value` at once, so the new image shows before the server answers. A data URL and not an
23
+ * object URL on purpose: it needs no `revoke`, it does not die with the page, and the form
24
+ * can hold it in state without anyone owning its lifetime. `null` is «removed».
25
+ *
26
+ * **The crop is the field's, not the person's.** With `aspect` (1 for an avatar, 16/9 for a
27
+ * banner) a picked image opens in a frame of that shape before it is taken: drag it or move
28
+ * it with the arrows, zoom with the two buttons or with + and −, Enter applies and Escape
29
+ * leaves it as it was. It is drawn on a canvas, so the file that comes out is ALREADY the
30
+ * crop — the server receives the avatar, not a photo plus four numbers to apply later — and
31
+ * at most `maxSide` pixels on its longer side. Without `aspect` the image is taken as it is.
32
+ *
33
+ * In read mode it draws the image with its `alt`; with no image, the «not set» mark.
34
+ */
35
+ export declare function ImageInput({ id: ownId, value, onChange, alt, aspect: ownAspect, shape, accept, maxSide, describedBy: ownDescribedBy, invalid: ownInvalid, disabled, mode, "aria-label": ariaLabel, }: ControlProps & {
36
+ /** What an `<img>` draws: a stored URL or a data URL. `null` or `""` is no image. */
37
+ value: string | null;
38
+ onChange?: (next: ImageChange | null) => void;
39
+ /** What the image shows, said to whoever cannot see it: «Foto de Ana Gutiérrez». `""` when
40
+ * the name beside it already says everything. */
41
+ alt: string;
42
+ /** Width over height of the crop. Present, a picked image is cropped to it before it is
43
+ * taken. A `circle` crops to 1 by default. */
44
+ aspect?: number;
45
+ /** `circle` for a face: the frame and the preview are round (the file stays square). */
46
+ shape?: "rect" | "circle";
47
+ /** The file dialog's filter. A drop is checked as an image whatever this says. */
48
+ accept?: string;
49
+ /** The longest side the cropped file is written at, in pixels. */
50
+ maxSide?: number;
51
+ }): import("react").JSX.Element;
@@ -0,0 +1,267 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useUiCopy } from "../../foundations/copy.js";
4
+ import { useFieldIds, useFormMode } from "../../foundations/form.js";
5
+ import { cn } from "../../foundations/variants.js";
6
+ import { Button, buttonClasses, IconButton } from "../button/Button.js";
7
+ import { nameOf, ReadValue } from "./Field.js";
8
+ import { capturePointer, clampCrop, CROP_CENTERED, CROP_MAX_ZOOM, croppedName, croppedType, cropRegion, isImageFile, outputSize, panCrop, zoomCrop, } from "./media.js";
9
+ import { AlertCircle, ImagePlus, ZoomIn, ZoomOut } from "lucide-react";
10
+ import { useEffect, useId, useRef, useState, } from "react";
11
+ import { Button as AriaButton, DropZone as AriaDropZone, FileTrigger, } from "react-aria-components";
12
+ function readDataUrl(file) {
13
+ return new Promise((resolve, reject) => {
14
+ const reader = new FileReader();
15
+ reader.onload = () => resolve(String(reader.result));
16
+ reader.onerror = () => reject(reader.error ?? new Error("unreadable"));
17
+ reader.readAsDataURL(file);
18
+ });
19
+ }
20
+ function loadImage(url) {
21
+ return new Promise((resolve, reject) => {
22
+ const image = new Image();
23
+ image.onload = () => resolve(image);
24
+ image.onerror = () => reject(new Error("not an image"));
25
+ image.src = url;
26
+ });
27
+ }
28
+ function canvasBlob(canvas, type) {
29
+ return new Promise((resolve, reject) => {
30
+ canvas.toBlob((blob) => (blob === null ? reject(new Error("no blob")) : resolve(blob)), type, 0.92);
31
+ });
32
+ }
33
+ /** How far one press of an arrow moves the image: a twentieth of the frame, a fifth with
34
+ * Shift — fine enough to centre a face, coarse enough not to take forty presses. */
35
+ const STEP = 0.05;
36
+ const BIG_STEP = 0.2;
37
+ const ZOOM_STEP = 1.2;
38
+ const FRAME = {
39
+ rect: "w-40 rounded-box",
40
+ circle: "w-24 rounded-pill",
41
+ };
42
+ /**
43
+ * An image the record holds — a product photo, a logo, the face on a contact: chosen,
44
+ * dropped or pasted, previewed, replaced and removed, and cropped to a shape when the field
45
+ * has one.
46
+ *
47
+ * Context: `Thumb` and `Avatar` showed an image and nothing could change it; this is the
48
+ * control that does. The way in is `DropZone`'s — React Aria's `DropZone` and `FileTrigger`,
49
+ * so a drop, a paste (⌘/Ctrl+V of a copied image) and the keyboard all reach it — but what
50
+ * it holds is the image itself, drawn where the file's name would be.
51
+ *
52
+ * **The value is a string; the change carries the file.** `value` is whatever an `<img>` can
53
+ * draw — the URL the server keeps, or the data URL of a pick not yet saved — so the record
54
+ * passes what it has and the control never asks which. `onChange` hands over `{ file, url }`:
55
+ * the `File` for the upload (with its real name and type), and the `url` to put back into
56
+ * `value` at once, so the new image shows before the server answers. A data URL and not an
57
+ * object URL on purpose: it needs no `revoke`, it does not die with the page, and the form
58
+ * can hold it in state without anyone owning its lifetime. `null` is «removed».
59
+ *
60
+ * **The crop is the field's, not the person's.** With `aspect` (1 for an avatar, 16/9 for a
61
+ * banner) a picked image opens in a frame of that shape before it is taken: drag it or move
62
+ * it with the arrows, zoom with the two buttons or with + and −, Enter applies and Escape
63
+ * leaves it as it was. It is drawn on a canvas, so the file that comes out is ALREADY the
64
+ * crop — the server receives the avatar, not a photo plus four numbers to apply later — and
65
+ * at most `maxSide` pixels on its longer side. Without `aspect` the image is taken as it is.
66
+ *
67
+ * In read mode it draws the image with its `alt`; with no image, the «not set» mark.
68
+ */
69
+ export function ImageInput({ id: ownId, value, onChange, alt, aspect: ownAspect, shape = "rect", accept = "image/*", maxSide = 1024, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, "aria-label": ariaLabel, }) {
70
+ const copy = useUiCopy();
71
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
72
+ id: ownId,
73
+ describedBy: ownDescribedBy,
74
+ invalid: ownInvalid,
75
+ });
76
+ const readMode = useFormMode(mode) === "read";
77
+ const [editing, setEditing] = useState(null);
78
+ const [failed, setFailed] = useState(false);
79
+ const [refocus, setRefocus] = useState(false);
80
+ const chooser = useRef(null);
81
+ const own = useId();
82
+ const errorId = `${own}-error`;
83
+ const aspect = ownAspect ?? (shape === "circle" ? 1 : undefined);
84
+ const has = value !== null && value !== "";
85
+ /* The editor and the remove button take their buttons with them when they close: the
86
+ focus goes back to the chooser, not to the top of the page. */
87
+ useEffect(() => {
88
+ if (!refocus)
89
+ return;
90
+ chooser.current?.focus();
91
+ setRefocus(false);
92
+ }, [refocus]);
93
+ if (readMode) {
94
+ return has ? (_jsx("span", { className: cn("block overflow-clip bg-ground", FRAME[shape]), children: _jsx("img", { src: value, alt: alt, style: { aspectRatio: aspect ?? 4 / 3 }, className: cn("block size-full", aspect === undefined ? "object-contain" : "object-cover") }) })) : (_jsx(ReadValue, { value: null }));
95
+ }
96
+ async function take(file) {
97
+ if (disabled)
98
+ return;
99
+ if (!isImageFile(file)) {
100
+ setFailed(true);
101
+ return;
102
+ }
103
+ try {
104
+ const url = await readDataUrl(file);
105
+ if (aspect === undefined) {
106
+ setFailed(false);
107
+ onChange?.({ file, url });
108
+ return;
109
+ }
110
+ const image = await loadImage(url);
111
+ const size = { width: image.naturalWidth, height: image.naturalHeight };
112
+ setFailed(false);
113
+ setEditing({ file, image, size, crop: clampCrop(size, aspect, CROP_CENTERED) });
114
+ }
115
+ catch {
116
+ setFailed(true);
117
+ }
118
+ }
119
+ async function onDrop(items) {
120
+ for (const item of items) {
121
+ if (item.kind === "file") {
122
+ await take(await item.getFile());
123
+ return;
124
+ }
125
+ }
126
+ }
127
+ const types = accept
128
+ .split(",")
129
+ .map((t) => t.trim())
130
+ .filter((t) => t !== "");
131
+ const describedAll = [describedBy, failed ? errorId : null].filter(Boolean).join(" ") || undefined;
132
+ const error = failed ? (_jsxs("p", { id: errorId, role: "alert", className: "flex items-start gap-1.5 text-micro text-danger", children: [_jsx(AlertCircle, { "aria-hidden": true, className: "mt-px size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "max-w-prose", children: copy.media.notImage })] })) : null;
133
+ if (editing !== null && aspect !== undefined) {
134
+ return (_jsx("div", { id: id, role: "group", "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel), "aria-describedby": describedBy, className: "flex w-full min-w-0 flex-col gap-2", children: _jsx(CropEditor, { editing: editing, aspect: aspect, shape: shape, onCrop: (crop) => setEditing({ ...editing, crop }), onCancel: () => {
135
+ setEditing(null);
136
+ setRefocus(true);
137
+ }, onApply: async () => {
138
+ const { file, image, size, crop } = editing;
139
+ const region = cropRegion(size, aspect, crop);
140
+ const out = outputSize(region, maxSide);
141
+ const canvas = document.createElement("canvas");
142
+ canvas.width = out.width;
143
+ canvas.height = out.height;
144
+ canvas
145
+ .getContext("2d")
146
+ ?.drawImage(image, region.x, region.y, region.width, region.height, 0, 0, out.width, out.height);
147
+ const type = croppedType(file.type);
148
+ const url = canvas.toDataURL(type, 0.92);
149
+ const blob = await canvasBlob(canvas, type);
150
+ onChange?.({
151
+ file: new File([blob], croppedName(file.name, type), { type }),
152
+ url,
153
+ });
154
+ setEditing(null);
155
+ setRefocus(true);
156
+ } }) }));
157
+ }
158
+ return (_jsxs("div", { id: id, role: "group", "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel), "aria-describedby": describedAll, className: "flex w-full min-w-0 flex-col gap-2", children: [_jsxs(AriaDropZone, { "aria-label": copy.media.dropImage, isDisabled: disabled, getDropOperation: () => (disabled ? "cancel" : "copy"), onDrop: (event) => void onDrop(event.items), className: ({ isDropTarget, isFocusVisible }) => cn("flex min-w-0 items-center gap-4 rounded-box border p-3 transition-colors duration-(--duration-instant) outline-primary outline-offset-2", isDropTarget
159
+ ? "border-primary bg-primary/8 forced-colors:border-[Highlight]"
160
+ : invalid || failed
161
+ ? "border-danger bg-surface"
162
+ : "border-dashed border-line-strong bg-surface", isFocusVisible ? "outline-2" : "outline-0"), children: [_jsx("span", { className: cn("flex shrink-0 items-center justify-center overflow-clip border border-line bg-ground text-muted", FRAME[shape]), style: { aspectRatio: aspect ?? 4 / 3 }, children: has ? (_jsx("img", { src: value, alt: alt, className: cn("block size-full", aspect === undefined ? "object-contain" : "object-cover") })) : (_jsx(ImagePlus, { "aria-hidden": true, className: "size-(--size-icon-lg)", strokeWidth: 1.5 })) }), _jsxs("div", { className: "flex min-w-0 flex-col items-start gap-2", children: [has ? null : _jsx("p", { className: "text-meta text-muted", children: copy.media.dropImage }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(FileTrigger, { acceptedFileTypes: types, onSelect: (files) => {
163
+ const file = files?.[0];
164
+ if (file !== undefined)
165
+ void take(file);
166
+ }, children: _jsx(AriaButton, { ref: chooser, isDisabled: disabled, "data-emphasis": "secondary", className: buttonClasses("secondary", "sm"), children: has ? copy.media.changeImage : copy.media.chooseImage }) }), has ? (_jsx(Button, { role: "ghost", size: "sm", disabled: disabled, onClick: () => {
167
+ setFailed(false);
168
+ onChange?.(null);
169
+ setRefocus(true);
170
+ }, children: copy.media.removeImage })) : null] })] })] }), error] }));
171
+ }
172
+ /**
173
+ * The frame a picked image is cropped in: the image on a canvas, moved by the pointer and by
174
+ * the keys, and the two zoom buttons.
175
+ *
176
+ * Context: one focusable surface (`role="application"`, so a screen reader hands the arrows
177
+ * to it instead of reading by line), named and described by the instruction that is also
178
+ * written under it — the keys are not a secret for whoever cannot see the frame, nor for
179
+ * whoever can. The zoom is said as a figure in a polite live region.
180
+ */
181
+ function CropEditor({ editing, aspect, shape, onCrop, onCancel, onApply, }) {
182
+ const copy = useUiCopy();
183
+ const canvas = useRef(null);
184
+ const drag = useRef(null);
185
+ const helpId = `${useId()}-help`;
186
+ const { image, size, crop } = editing;
187
+ /* The frame is the person's first stop: the image just appeared, and the keys move it. */
188
+ useEffect(() => {
189
+ canvas.current?.focus();
190
+ }, []);
191
+ useEffect(() => {
192
+ const el = canvas.current;
193
+ if (el === null)
194
+ return;
195
+ const paint = () => {
196
+ const dpr = window.devicePixelRatio || 1;
197
+ const width = Math.max(1, Math.round(el.clientWidth * dpr));
198
+ const height = Math.max(1, Math.round((el.clientWidth / aspect) * dpr));
199
+ if (el.width !== width)
200
+ el.width = width;
201
+ if (el.height !== height)
202
+ el.height = height;
203
+ const ctx = el.getContext("2d");
204
+ if (ctx === null)
205
+ return;
206
+ const region = cropRegion(size, aspect, crop);
207
+ ctx.clearRect(0, 0, width, height);
208
+ ctx.drawImage(image, region.x, region.y, region.width, region.height, 0, 0, width, height);
209
+ if (shape === "circle") {
210
+ /* What the round preview will cut off, veiled in the surface's colour — read from
211
+ the token at paint time, so the veil follows the theme. */
212
+ const veil = getComputedStyle(el).getPropertyValue("--color-surface").trim();
213
+ ctx.save();
214
+ ctx.globalAlpha = 0.6;
215
+ ctx.fillStyle = veil === "" ? getComputedStyle(el).backgroundColor : veil;
216
+ ctx.beginPath();
217
+ ctx.rect(0, 0, width, height);
218
+ ctx.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, Math.PI * 2);
219
+ ctx.fill("evenodd");
220
+ ctx.restore();
221
+ }
222
+ };
223
+ paint();
224
+ const observer = new ResizeObserver(paint);
225
+ observer.observe(el);
226
+ return () => observer.disconnect();
227
+ }, [image, size, aspect, crop, shape]);
228
+ const pan = (dx, dy) => onCrop(panCrop(size, aspect, crop, dx, dy));
229
+ const zoom = (factor) => onCrop(zoomCrop(size, aspect, crop, factor));
230
+ function onKeyDown(event) {
231
+ const step = event.shiftKey ? BIG_STEP : STEP;
232
+ const keys = {
233
+ ArrowLeft: () => pan(-step, 0),
234
+ ArrowRight: () => pan(step, 0),
235
+ ArrowUp: () => pan(0, -step),
236
+ ArrowDown: () => pan(0, step),
237
+ "+": () => zoom(ZOOM_STEP),
238
+ "=": () => zoom(ZOOM_STEP),
239
+ "-": () => zoom(1 / ZOOM_STEP),
240
+ _: () => zoom(1 / ZOOM_STEP),
241
+ "0": () => onCrop(clampCrop(size, aspect, CROP_CENTERED)),
242
+ Enter: () => void onApply(),
243
+ Escape: onCancel,
244
+ };
245
+ const run = keys[event.key];
246
+ if (run === undefined)
247
+ return;
248
+ event.preventDefault();
249
+ run();
250
+ }
251
+ function onPointerDown(event) {
252
+ capturePointer(event);
253
+ drag.current = { x: event.clientX, y: event.clientY };
254
+ }
255
+ function onPointerMove(event) {
256
+ const from = drag.current;
257
+ if (from === null)
258
+ return;
259
+ const box = event.currentTarget.getBoundingClientRect();
260
+ drag.current = { x: event.clientX, y: event.clientY };
261
+ pan((event.clientX - from.x) / box.width, (event.clientY - from.y) / box.height);
262
+ }
263
+ const endDrag = () => {
264
+ drag.current = null;
265
+ };
266
+ return (_jsxs(_Fragment, { children: [_jsx("canvas", { ref: canvas, role: "application", tabIndex: 0, "aria-label": copy.media.crop, "aria-describedby": helpId, onKeyDown: onKeyDown, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, style: { aspectRatio: aspect }, className: cn("block w-full max-w-xs cursor-grab touch-none border border-line-strong bg-ground outline-primary outline-offset-2 focus-visible:outline-2 active:cursor-grabbing", shape === "circle" ? "rounded-box" : "rounded-control") }), _jsx("p", { id: helpId, className: "max-w-xs text-micro text-muted", children: copy.media.cropHelp }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(IconButton, { icon: ZoomOut, label: copy.media.zoomOut, disabled: crop.zoom <= 1, onClick: () => zoom(1 / ZOOM_STEP) }), _jsxs("span", { "aria-live": "polite", className: "min-w-10 text-center text-micro text-muted tabular-nums", children: [Math.round(crop.zoom * 100), " %"] }), _jsx(IconButton, { icon: ZoomIn, label: copy.media.zoomIn, disabled: crop.zoom >= CROP_MAX_ZOOM, onClick: () => zoom(ZOOM_STEP) }), _jsxs("span", { className: "ml-auto flex items-center gap-2", children: [_jsx(Button, { role: "ghost", size: "sm", onClick: onCancel, children: copy.media.cancel }), _jsx(Button, { size: "sm", onClick: () => void onApply(), children: copy.media.applyCrop })] })] })] }));
267
+ }
@@ -0,0 +1,29 @@
1
+ import { type ControlProps } from "./Field.js";
2
+ /**
3
+ * A password: hidden while it is typed, shown on purpose with the eye.
4
+ *
5
+ * Context: it is `TextInput`'s wiring — React Aria's `TextField`, named and described by
6
+ * its `Field` through `useFieldIds` — with three things a password owes and a text does not:
7
+ *
8
+ * - **The eye.** Typing blind is where a long password goes wrong; the button shows it as
9
+ * text and hides it again, and says which of the two it will do. `revealable={false}`
10
+ * removes it, for a screen where it must not be shown.
11
+ * - **Caps Lock.** The most common reason a right password is refused. While it is on, a
12
+ * line under the control says so — in the warn tone, with an icon and the words.
13
+ * - **Read mode never shows it.** It draws a fixed row of dots, the same for every length
14
+ * (a length is a hint), and says «password set» to a screen reader. The field's copy
15
+ * button copies what is SEEN: the dots are not a value, and a hidden input is skipped
16
+ * (`clipboard.tsx`); revealed, it copies like any text.
17
+ *
18
+ * `autoComplete` tells a password manager what this is: `current-password` fills a login,
19
+ * `new-password` offers to generate one.
20
+ */
21
+ export declare function PasswordInput({ id: ownId, value, onChange, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled, mode, autoComplete, revealable, "aria-label": ariaLabel, }: ControlProps & {
22
+ value: string;
23
+ onChange?: (next: string) => void;
24
+ placeholder?: string;
25
+ /** `current-password` for a login, `new-password` when choosing one. */
26
+ autoComplete?: "current-password" | "new-password" | "off";
27
+ /** The eye that shows it. On by default; `false` where it must never be shown. */
28
+ revealable?: boolean;
29
+ }): import("react").JSX.Element;
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useUiCopy } from "../../foundations/copy.js";
4
+ import { useFieldIds, useFormMode } from "../../foundations/form.js";
5
+ import { FieldButton, FieldGroup } from "./aria.js";
6
+ import { nameOf, ReadValue } from "./Field.js";
7
+ import { Eye, EyeOff, TriangleAlert } from "lucide-react";
8
+ import { useId, useState } from "react";
9
+ import { Input, TextField as AriaTextField } from "react-aria-components";
10
+ /**
11
+ * A password: hidden while it is typed, shown on purpose with the eye.
12
+ *
13
+ * Context: it is `TextInput`'s wiring — React Aria's `TextField`, named and described by
14
+ * its `Field` through `useFieldIds` — with three things a password owes and a text does not:
15
+ *
16
+ * - **The eye.** Typing blind is where a long password goes wrong; the button shows it as
17
+ * text and hides it again, and says which of the two it will do. `revealable={false}`
18
+ * removes it, for a screen where it must not be shown.
19
+ * - **Caps Lock.** The most common reason a right password is refused. While it is on, a
20
+ * line under the control says so — in the warn tone, with an icon and the words.
21
+ * - **Read mode never shows it.** It draws a fixed row of dots, the same for every length
22
+ * (a length is a hint), and says «password set» to a screen reader. The field's copy
23
+ * button copies what is SEEN: the dots are not a value, and a hidden input is skipped
24
+ * (`clipboard.tsx`); revealed, it copies like any text.
25
+ *
26
+ * `autoComplete` tells a password manager what this is: `current-password` fills a login,
27
+ * `new-password` offers to generate one.
28
+ */
29
+ export function PasswordInput({ id: ownId, value, onChange, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, autoComplete = "current-password", revealable = true, "aria-label": ariaLabel, }) {
30
+ const copy = useUiCopy();
31
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
32
+ id: ownId,
33
+ describedBy: ownDescribedBy,
34
+ invalid: ownInvalid,
35
+ });
36
+ const [shown, setShown] = useState(false);
37
+ const [capsLock, setCapsLock] = useState(false);
38
+ const capsId = `${useId()}-caps`;
39
+ if (useFormMode(mode) === "read") {
40
+ return (_jsx(ReadValue, { value: value === "" ? null : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, "data-copy-skip": "", className: "tracking-widest", children: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx("span", { className: "sr-only", children: copy.field.secretSet })] })) }));
41
+ }
42
+ const readCaps = (event) => setCapsLock(event.getModifierState("CapsLock"));
43
+ return (_jsxs("div", { className: "flex w-full min-w-0 flex-col gap-1", children: [_jsx(AriaTextField, { id: id, "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel, placeholder), value: value, onChange: (next) => onChange?.(next), type: shown ? "text" : "password", autoComplete: autoComplete, isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: _jsxs(FieldGroup, { isInvalid: invalid, isDisabled: disabled, children: [_jsx(Input, { "aria-describedby": [describedBy, capsLock ? capsId : null].filter(Boolean).join(" ") || undefined, placeholder: placeholder, spellCheck: false, onKeyDown: readCaps, onKeyUp: readCaps, onBlur: () => setCapsLock(false), className: "h-full min-w-0 flex-1 bg-transparent px-2.5 text-meta text-ink outline-0 placeholder:text-muted disabled:cursor-not-allowed" }), revealable ? (_jsx(FieldButton, { "aria-label": shown ? copy.field.hideSecret : copy.field.showSecret, isDisabled: disabled, onPress: () => setShown((now) => !now), className: "mr-1", children: shown ? (_jsx(EyeOff, { "aria-hidden": true, className: "size-(--size-icon-sm)", strokeWidth: 2 })) : (_jsx(Eye, { "aria-hidden": true, className: "size-(--size-icon-sm)", strokeWidth: 2 })) })) : null] }) }), capsLock ? (_jsxs("p", { id: capsId, className: "flex items-center gap-1.5 text-micro text-warn", children: [_jsx(TriangleAlert, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), copy.field.capsLock] })) : null] }));
44
+ }
@@ -0,0 +1,23 @@
1
+ import { type ControlProps } from "./Field.js";
2
+ /**
3
+ * An identifier with a fixed shape — a NIT, a CI, a bank account, an IBAN — typed on its
4
+ * `pattern` (`#` digit, `A` letter, `*` either; see `pattern.ts`).
5
+ *
6
+ * Context: written into a `TextInput`, «1020304-5», «10203045» and «1020304 5» were three
7
+ * values for the same company, and the search found one of them. Here the value is only the
8
+ * characters (`"10203045"`), the separators are drawn as they are typed, a character the
9
+ * slot does not take is not let in, and the caret stays where it was while the separators
10
+ * appear (`mask.ts`). Pasting «BO12 3456…» with any spacing lands in the slots.
11
+ *
12
+ * Read mode draws it on the pattern, in mono — a value written by a machine and compared
13
+ * with the one next to it. Whether it is COMPLETE is the form's to say: `isPatternComplete`.
14
+ */
15
+ export declare function PatternInput({ id: ownId, value, onChange, pattern, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled, mode, "aria-label": ariaLabel, }: ControlProps & {
16
+ /** The characters only, without separators. */
17
+ value: string;
18
+ onChange?: (value: string) => void;
19
+ /** «###-#######», «AA## #### #### ####». */
20
+ pattern: string;
21
+ /** Without one, the pattern itself with `0` for a digit: «000-0000000». */
22
+ placeholder?: string;
23
+ }): import("react").JSX.Element;
@@ -0,0 +1,48 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useFieldIds, useFormMode } from "../../foundations/form.js";
4
+ import { controlClasses, nameOf, ReadValue, } from "./Field.js";
5
+ import { maskedChange, useMaskCaret } from "./mask.js";
6
+ import { formatPattern, patternLength, patternValue, } from "./pattern.js";
7
+ import { Input, TextField as AriaTextField } from "react-aria-components";
8
+ const isSlotChar = (char) => /[a-z\d]/i.test(char);
9
+ /**
10
+ * An identifier with a fixed shape — a NIT, a CI, a bank account, an IBAN — typed on its
11
+ * `pattern` (`#` digit, `A` letter, `*` either; see `pattern.ts`).
12
+ *
13
+ * Context: written into a `TextInput`, «1020304-5», «10203045» and «1020304 5» were three
14
+ * values for the same company, and the search found one of them. Here the value is only the
15
+ * characters (`"10203045"`), the separators are drawn as they are typed, a character the
16
+ * slot does not take is not let in, and the caret stays where it was while the separators
17
+ * appear (`mask.ts`). Pasting «BO12 3456…» with any spacing lands in the slots.
18
+ *
19
+ * Read mode draws it on the pattern, in mono — a value written by a machine and compared
20
+ * with the one next to it. Whether it is COMPLETE is the form's to say: `isPatternComplete`.
21
+ */
22
+ export function PatternInput({ id: ownId, value, onChange, pattern, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, "aria-label": ariaLabel, }) {
23
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
24
+ id: ownId,
25
+ describedBy: ownDescribedBy,
26
+ invalid: ownInvalid,
27
+ });
28
+ const clean = patternValue(value, pattern);
29
+ const drawn = formatPattern(clean, pattern);
30
+ const { input, keep } = useMaskCaret(drawn, isSlotChar);
31
+ if (useFormMode(mode) === "read") {
32
+ return _jsx(ReadValue, { mono: true, value: clean === "" ? null : drawn });
33
+ }
34
+ const change = (raw) => {
35
+ const { next, caret } = maskedChange({
36
+ raw,
37
+ at: input.current?.selectionStart ?? raw.length,
38
+ current: clean,
39
+ drawn,
40
+ clean: (text) => patternValue(text, pattern),
41
+ max: patternLength(pattern),
42
+ });
43
+ keep(caret);
44
+ onChange?.(next);
45
+ };
46
+ const shown = placeholder ?? pattern.replace(/#/g, "0").replace(/\*/g, "X");
47
+ return (_jsx(AriaTextField, { id: id, "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel, shown), value: drawn, onChange: change, inputMode: /[A*]/.test(pattern) ? "text" : "numeric", isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: _jsx(Input, { ref: input, "aria-describedby": describedBy, placeholder: shown, spellCheck: false, autoCapitalize: "characters", className: `${controlClasses(invalid)} font-mono tabular-nums` }) }));
48
+ }
@@ -0,0 +1,18 @@
1
+ export type MentionItem = {
2
+ id: string;
3
+ label: string;
4
+ };
5
+ export type RichTextEditorProps = {
6
+ id: string | undefined;
7
+ labelledBy: string | undefined;
8
+ describedBy: string | undefined;
9
+ invalid: boolean;
10
+ ariaLabel: string | undefined;
11
+ value: string;
12
+ onChange: ((html: string) => void) | undefined;
13
+ placeholder: string | undefined;
14
+ disabled: boolean;
15
+ toolbar: "full" | "basic" | "none";
16
+ mentions: ((query: string) => readonly MentionItem[] | Promise<readonly MentionItem[]>) | undefined;
17
+ };
18
+ export declare function RichTextEditor({ id, labelledBy, describedBy, invalid, ariaLabel, value, onChange, placeholder, disabled, toolbar, mentions, }: RichTextEditorProps): import("react").JSX.Element;