@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,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;
@@ -0,0 +1,224 @@
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 { Button, IconButton } from "../button/Button.js";
5
+ import { emailHref, telHref, urlHref } from "./contact.js";
6
+ import { PopoverTrigger } from "../overlay/PopoverTrigger.js";
7
+ import { Toolbar, ToolbarSeparator } from "../toolbar/Toolbar.js";
8
+ import Mention from "@tiptap/extension-mention";
9
+ import { TableKit } from "@tiptap/extension-table";
10
+ import { Placeholder } from "@tiptap/extensions";
11
+ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
12
+ import StarterKit from "@tiptap/starter-kit";
13
+ import { Bold, Heading2, Heading3, Italic, Link as LinkIcon, List, ListOrdered, Redo2, RemoveFormatting, Strikethrough, Table, TextQuote, Underline, Undo2, } from "lucide-react";
14
+ import { useEffect, useId, useRef, useState } from "react";
15
+ /** A link address as people write it: a website, an email, a phone — or nothing valid. */
16
+ function linkHref(text) {
17
+ const clean = text.trim();
18
+ if (clean === "")
19
+ return null;
20
+ if (clean.includes("@") && !clean.includes("/"))
21
+ return emailHref(clean.replace(/^mailto:/i, ""));
22
+ if (/^(?:tel:)?\+?[\d\s().-]{6,}$/i.test(clean))
23
+ return telHref(clean);
24
+ return urlHref(clean);
25
+ }
26
+ function LinkForm({ editor, close }) {
27
+ const copy = useUiCopy().richText;
28
+ const current = editor.getAttributes("link")["href"] ?? "";
29
+ const [text, setText] = useState(current);
30
+ const [tried, setTried] = useState(false);
31
+ const inputId = useId();
32
+ const href = linkHref(text);
33
+ const apply = () => {
34
+ setTried(true);
35
+ if (href === null)
36
+ return;
37
+ editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
38
+ close();
39
+ };
40
+ return (_jsxs("form", { className: "flex w-72 max-w-full flex-col gap-2", onSubmit: (event) => {
41
+ event.preventDefault();
42
+ apply();
43
+ }, children: [_jsx("label", { htmlFor: inputId, className: "text-micro font-medium text-muted", children: copy.linkUrl }), _jsx("input", { id: inputId, value: text, autoFocus: true, inputMode: "url", spellCheck: false, "aria-invalid": tried && href === null, ...(tried && href === null ? { "aria-describedby": `${inputId}-error` } : {}), onChange: (event) => setText(event.target.value), className: "h-(--size-control-md) w-full min-w-0 rounded-control border border-line-strong bg-surface px-2.5 text-meta text-ink outline-0 focus-visible:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" }), tried && href === null ? (_jsx("p", { id: `${inputId}-error`, role: "alert", className: "text-micro text-danger", children: copy.badLink })) : null, _jsxs("div", { className: "flex items-center justify-end gap-2", children: [current === "" ? null : (_jsx(Button, { role: "ghost", size: "sm", onClick: () => {
44
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
45
+ close();
46
+ }, children: copy.removeLink })), _jsx(Button, { role: "secondary", size: "sm", type: "submit", children: copy.applyLink })] })] }));
47
+ }
48
+ function Bar({ editor, full }) {
49
+ const copy = useUiCopy().richText;
50
+ const on = useEditorState({
51
+ editor,
52
+ selector: ({ editor: e }) => ({
53
+ bold: e.isActive("bold"),
54
+ italic: e.isActive("italic"),
55
+ underline: e.isActive("underline"),
56
+ strike: e.isActive("strike"),
57
+ h2: e.isActive("heading", { level: 2 }),
58
+ h3: e.isActive("heading", { level: 3 }),
59
+ bullet: e.isActive("bulletList"),
60
+ ordered: e.isActive("orderedList"),
61
+ quote: e.isActive("blockquote"),
62
+ link: e.isActive("link"),
63
+ undo: e.can().undo(),
64
+ redo: e.can().redo(),
65
+ }),
66
+ });
67
+ const chain = () => editor.chain().focus();
68
+ const toggle = (label, icon, pressed, run) => (_jsx(IconButton, { icon: icon, label: label, "aria-pressed": pressed, disabled: !editor.isEditable,
69
+ /* Keeps the selection: a press that blurred the editor would format nothing. */
70
+ onMouseDown: (event) => event.preventDefault(), onClick: run }, label));
71
+ return (_jsx("div", { className: "border-b border-line px-1 py-1", children: _jsxs(Toolbar, { label: copy.toolbar, children: [toggle(copy.bold, Bold, on.bold, () => chain().toggleBold().run()), toggle(copy.italic, Italic, on.italic, () => chain().toggleItalic().run()), full
72
+ ? toggle(copy.underline, Underline, on.underline, () => chain().toggleUnderline().run())
73
+ : null, full
74
+ ? toggle(copy.strike, Strikethrough, on.strike, () => chain().toggleStrike().run())
75
+ : null, _jsx(ToolbarSeparator, {}), full
76
+ ? toggle(copy.heading, Heading2, on.h2, () => chain().toggleHeading({ level: 2 }).run())
77
+ : null, full
78
+ ? toggle(copy.subheading, Heading3, on.h3, () => chain().toggleHeading({ level: 3 }).run())
79
+ : null, toggle(copy.bulletList, List, on.bullet, () => chain().toggleBulletList().run()), toggle(copy.orderedList, ListOrdered, on.ordered, () => chain().toggleOrderedList().run()), full
80
+ ? toggle(copy.quote, TextQuote, on.quote, () => chain().toggleBlockquote().run())
81
+ : null, _jsx(ToolbarSeparator, {}), _jsx(PopoverTrigger, { title: copy.link, trigger: _jsx(IconButton, { icon: LinkIcon, label: copy.link, "aria-pressed": on.link, disabled: !editor.isEditable }), children: (close) => _jsx(LinkForm, { editor: editor, close: close }) }), full ? (_jsx(IconButton, { icon: Table, label: copy.table, disabled: !editor.isEditable, onMouseDown: (event) => event.preventDefault(), onClick: () => chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() })) : null, full ? (_jsxs(_Fragment, { children: [_jsx(ToolbarSeparator, {}), _jsx(IconButton, { icon: Undo2, label: copy.undo, disabled: !on.undo, onMouseDown: (event) => event.preventDefault(), onClick: () => chain().undo().run() }), _jsx(IconButton, { icon: Redo2, label: copy.redo, disabled: !on.redo, onMouseDown: (event) => event.preventDefault(), onClick: () => chain().redo().run() }), _jsx(IconButton, { icon: RemoveFormatting, label: copy.clear, disabled: !editor.isEditable, onMouseDown: (event) => event.preventDefault(), onClick: () => chain().unsetAllMarks().clearNodes().run() })] })) : null] }) }));
82
+ }
83
+ /** The list that opens on «@»: fixed under the caret, moved through from the editor. */
84
+ function MentionList({ asking, active, listId, }) {
85
+ const copy = useUiCopy().richText;
86
+ const rect = asking.rect;
87
+ if (rect === null)
88
+ return null;
89
+ return (_jsx("ul", { id: listId, role: "listbox", "aria-label": copy.mentions, className: "fixed z-50 flex w-64 flex-col rounded-panel border border-line-strong bg-surface p-1 shadow-float", style: { left: rect.left, top: rect.bottom + 4 }, children: asking.items.length === 0 ? (_jsx("li", { role: "option", "aria-selected": false, "aria-disabled": true, className: "px-2 py-1 text-meta text-muted", children: copy.noMatches })) : (asking.items.map((item, index) => (_jsx("li", { id: `${listId}-${index}`, role: "option", "aria-selected": index === active, onMouseDown: (event) => {
90
+ event.preventDefault();
91
+ asking.pick(item);
92
+ }, className: `cursor-default rounded-control px-2 py-1 text-meta text-ink ${index === active ? "bg-primary/8" : ""}`, children: item.label }, item.id)))) }));
93
+ }
94
+ export function RichTextEditor({ id, labelledBy, describedBy, invalid, ariaLabel, value, onChange, placeholder, disabled, toolbar, mentions, }) {
95
+ const changed = useRef(onChange);
96
+ changed.current = onChange;
97
+ const searching = useRef(mentions);
98
+ searching.current = mentions;
99
+ const [asking, setAsking] = useState(null);
100
+ const [active, setActive] = useState(0);
101
+ const activeRef = useRef(0);
102
+ activeRef.current = active;
103
+ const askingRef = useRef(null);
104
+ askingRef.current = asking;
105
+ const listId = useId();
106
+ const editor = useEditor({
107
+ immediatelyRender: false,
108
+ editable: !disabled,
109
+ content: value,
110
+ extensions: [
111
+ StarterKit.configure({
112
+ heading: { levels: [2, 3] },
113
+ link: {
114
+ openOnClick: false,
115
+ autolink: true,
116
+ HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
117
+ },
118
+ }),
119
+ TableKit.configure({ table: { resizable: false } }),
120
+ ...(placeholder === undefined ? [] : [Placeholder.configure({ placeholder })]),
121
+ ...(mentions === undefined
122
+ ? []
123
+ : [
124
+ Mention.configure({
125
+ HTMLAttributes: { "data-type": "mention" },
126
+ renderText: ({ node }) => `@${String(node.attrs["label"] ?? node.attrs["id"])}`,
127
+ suggestion: {
128
+ char: "@",
129
+ items: async ({ query }) => [...((await searching.current?.(query)) ?? [])],
130
+ render: () => {
131
+ const show = (props) => {
132
+ setActive(0);
133
+ setAsking({
134
+ items: props.items,
135
+ rect: props.clientRect?.() ?? null,
136
+ pick: (item) => props.command({ id: item.id, label: item.label }),
137
+ });
138
+ };
139
+ return {
140
+ onStart: show,
141
+ onUpdate: show,
142
+ onExit: () => setAsking(null),
143
+ onKeyDown: ({ event }) => {
144
+ const now = askingRef.current;
145
+ if (now === null)
146
+ return false;
147
+ const count = now.items.length;
148
+ if (event.key === "Escape") {
149
+ setAsking(null);
150
+ return true;
151
+ }
152
+ if (count === 0)
153
+ return false;
154
+ if (event.key === "ArrowDown") {
155
+ setActive((activeRef.current + 1) % count);
156
+ return true;
157
+ }
158
+ if (event.key === "ArrowUp") {
159
+ setActive((activeRef.current - 1 + count) % count);
160
+ return true;
161
+ }
162
+ if (event.key === "Enter" || event.key === "Tab") {
163
+ const item = now.items[activeRef.current];
164
+ if (item !== undefined)
165
+ now.pick(item);
166
+ return true;
167
+ }
168
+ return false;
169
+ },
170
+ };
171
+ },
172
+ },
173
+ }),
174
+ ]),
175
+ ],
176
+ editorProps: {
177
+ attributes: {
178
+ class: "rich-text min-h-24 max-h-96 overflow-y-auto px-2.5 py-2 outline-0",
179
+ role: "textbox",
180
+ "aria-multiline": "true",
181
+ },
182
+ },
183
+ onUpdate: ({ editor: e }) => changed.current?.(e.isEmpty ? "" : e.getHTML()),
184
+ });
185
+ /* The field's wiring on the editable element itself — React Aria-style ids from `Field`,
186
+ kept in step when they change (an error appears, the label is renamed). */
187
+ useEffect(() => {
188
+ const dom = editor?.view.dom;
189
+ if (dom === undefined)
190
+ return;
191
+ const set = (name, next) => next === undefined ? dom.removeAttribute(name) : dom.setAttribute(name, next);
192
+ set("id", id);
193
+ set("aria-labelledby", labelledBy);
194
+ set("aria-label", labelledBy === undefined ? (ariaLabel ?? placeholder) : undefined);
195
+ set("aria-describedby", describedBy);
196
+ set("aria-invalid", invalid ? "true" : undefined);
197
+ set("aria-controls", asking === null ? undefined : listId);
198
+ set("aria-activedescendant", asking === null || asking.items.length === 0 ? undefined : `${listId}-${active}`);
199
+ }, [
200
+ editor,
201
+ id,
202
+ labelledBy,
203
+ describedBy,
204
+ invalid,
205
+ ariaLabel,
206
+ placeholder,
207
+ asking,
208
+ active,
209
+ listId,
210
+ ]);
211
+ useEffect(() => editor?.setEditable(!disabled), [editor, disabled]);
212
+ /* A value written from outside (a reset, a suggestion applied) replaces the content — but
213
+ never while the person is typing, which would move their caret. */
214
+ useEffect(() => {
215
+ if (editor === null || editor.isFocused)
216
+ return;
217
+ const current = editor.isEmpty ? "" : editor.getHTML();
218
+ if (current !== value)
219
+ editor.commands.setContent(value === "" ? "" : value, { emitUpdate: false });
220
+ }, [editor, value]);
221
+ return (_jsxs("div", { className: `w-full min-w-0 overflow-clip rounded-control border bg-surface transition-colors duration-(--duration-instant) focus-within:outline-2 focus-within:outline-offset-2 ${invalid
222
+ ? "border-danger focus-within:outline-danger"
223
+ : "border-line-strong focus-within:border-primary focus-within:outline-primary"} ${disabled ? "bg-ground text-muted" : ""}`, children: [editor === null || toolbar === "none" ? null : (_jsx(Bar, { editor: editor, full: toolbar === "full" })), _jsx(EditorContent, { editor: editor }), asking === null ? null : (_jsx(MentionList, { asking: asking, active: active, listId: listId }))] }));
224
+ }
@@ -0,0 +1,30 @@
1
+ import type { ControlProps } from "./Field.js";
2
+ import type { MentionItem } from "./RichTextEditor.js";
3
+ /**
4
+ * Formatted text — a description, the notes of an order, the body of an email: an Odoo `html`
5
+ * field. The value is HTML in and HTML out.
6
+ *
7
+ * Context: Tiptap (ProseMirror) underneath because it is headless — the toolbar is the
8
+ * system's own `Toolbar` of `IconButton`s, so the editor looks like every other control —
9
+ * and because it reads and writes HTML, which is what the engine stores. The editor is a
10
+ * separate chunk loaded only on editing; until it arrives, a box of the editor's size holds
11
+ * the place so the form does not jump.
12
+ *
13
+ * Read mode never mounts an editor: it draws `RichTextValue`, the same HTML CLEANED of
14
+ * anything that runs (DOMPurify), in the same `rich-text` style the editor uses — so what is
15
+ * written is what is read, line for line.
16
+ *
17
+ * `toolbar` picks how much to offer: `full` (headings, quote, table, undo), `basic` (bold,
18
+ * italic, lists, link — a comment), `none` (only the keyboard shortcuts). `mentions` turns on
19
+ * «@»: the application answers who matches what was typed.
20
+ */
21
+ export declare function RichTextInput({ id: ownId, value, onChange, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled, mode, toolbar, mentions, "aria-label": ariaLabel, }: ControlProps & {
22
+ /** HTML. `""` is empty. */
23
+ value: string;
24
+ onChange?: (html: string) => void;
25
+ placeholder?: string;
26
+ toolbar?: "full" | "basic" | "none";
27
+ /** Who can be mentioned with «@», for what was typed after it. */
28
+ mentions?: (query: string) => readonly MentionItem[] | Promise<readonly MentionItem[]>;
29
+ }): import("react").JSX.Element;
30
+ export type { MentionItem };
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useFieldIds, useFormMode } from "../../foundations/form.js";
4
+ import { RichTextValue } from "../value/RichText.js";
5
+ import { lazy, Suspense } from "react";
6
+ /* The editor (Tiptap + ProseMirror) is its own chunk: fetched the first time a rich-text
7
+ field is EDITED, never by a record that is only read. */
8
+ const RichTextEditor = lazy(() => import("./RichTextEditor.js").then((module) => ({
9
+ default: module.RichTextEditor,
10
+ })));
11
+ /**
12
+ * Formatted text — a description, the notes of an order, the body of an email: an Odoo `html`
13
+ * field. The value is HTML in and HTML out.
14
+ *
15
+ * Context: Tiptap (ProseMirror) underneath because it is headless — the toolbar is the
16
+ * system's own `Toolbar` of `IconButton`s, so the editor looks like every other control —
17
+ * and because it reads and writes HTML, which is what the engine stores. The editor is a
18
+ * separate chunk loaded only on editing; until it arrives, a box of the editor's size holds
19
+ * the place so the form does not jump.
20
+ *
21
+ * Read mode never mounts an editor: it draws `RichTextValue`, the same HTML CLEANED of
22
+ * anything that runs (DOMPurify), in the same `rich-text` style the editor uses — so what is
23
+ * written is what is read, line for line.
24
+ *
25
+ * `toolbar` picks how much to offer: `full` (headings, quote, table, undo), `basic` (bold,
26
+ * italic, lists, link — a comment), `none` (only the keyboard shortcuts). `mentions` turns on
27
+ * «@»: the application answers who matches what was typed.
28
+ */
29
+ export function RichTextInput({ id: ownId, value, onChange, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, toolbar = "full", mentions, "aria-label": ariaLabel, }) {
30
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
31
+ id: ownId,
32
+ describedBy: ownDescribedBy,
33
+ invalid: ownInvalid,
34
+ });
35
+ if (useFormMode(mode) === "read")
36
+ return _jsx(RichTextValue, { value: value });
37
+ return (_jsx(Suspense, { fallback: _jsx("div", { "aria-busy": "true", className: "h-32 w-full min-w-0 animate-reveal-late rounded-control border border-line-strong bg-ground" }), children: _jsx(RichTextEditor, { id: id, labelledBy: labelledBy, describedBy: describedBy, invalid: invalid, ariaLabel: ariaLabel, value: value, onChange: onChange, placeholder: placeholder, disabled: disabled, toolbar: toolbar, mentions: mentions }) }));
38
+ }
@@ -0,0 +1,34 @@
1
+ import { type ControlProps } from "./Field.js";
2
+ /**
3
+ * A signature: drawn with the mouse, a pen or a finger — or typed, for whoever cannot draw.
4
+ *
5
+ * Context: the pad is a canvas that follows the pointer (`touch-none`, so a finger draws
6
+ * instead of scrolling the page) and smooths each stroke through the midpoints of its
7
+ * samples (`strokeSteps`) — no pressure, no library, and no corners. When a stroke ends the
8
+ * pad is exported as a PNG data URL and handed to `onChange`; «Limpiar» empties it and
9
+ * hands `null`.
10
+ *
11
+ * **A canvas alone shuts people out.** Whoever works with a keyboard or a screen reader, or
12
+ * whose hand does not hold a pointer steady, cannot draw. «Escribir mi nombre» turns the pad
13
+ * into a text box: the name typed (starting from `name`, when the form knows who signs) is
14
+ * written into the same PNG in the system's font, in italic, on the same line — the value
15
+ * has the same shape whichever way it was made, and the form does not have to know.
16
+ *
17
+ * **The pad is paper.** It is a forced-light subtree (`data-theme="light"`): the pen reads
18
+ * `--color-ink` there, so a signature made in the dark theme is still dark ink on a
19
+ * transparent PNG — the thing a printed contract or a PDF expects — and read mode lays it on
20
+ * the same paper, so it is legible on either theme.
21
+ *
22
+ * A value that did not come from this pad (a signature already saved) is shown as it is,
23
+ * not drawn over: «Limpiar» starts a new one. In read mode it draws the image.
24
+ */
25
+ export declare function SignatureInput({ id: ownId, value, onChange, name, alt, describedBy: ownDescribedBy, invalid: ownInvalid, disabled, mode, "aria-label": ariaLabel, }: ControlProps & {
26
+ /** The signature as an image: a PNG data URL from this pad, or the URL of a saved one.
27
+ * `null` or `""` is unsigned. */
28
+ value: string | null;
29
+ onChange?: (next: string | null) => void;
30
+ /** Who signs, to start the typed signature with. */
31
+ name?: string;
32
+ /** What the image is called to a screen reader. «Firma» by default. */
33
+ alt?: string;
34
+ }): import("react").JSX.Element;
@@ -0,0 +1,215 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } 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 } from "../button/Button.js";
7
+ import { controlClasses, nameOf, ReadValue, } from "./Field.js";
8
+ import { capturePointer, fitFontSize, strokeSteps, } from "./media.js";
9
+ import { Eraser, PenLine, Type } from "lucide-react";
10
+ import { useEffect, useId, useRef, useState } from "react";
11
+ /** The pen, in CSS pixels: the weight of a ballpoint at the size the pad is drawn. */
12
+ const PEN = 2.25;
13
+ /** Where the line sits, from the top of the pad — the same fraction the drawn line uses
14
+ * (`bottom-1/4`), so a typed name rests on the line the person sees. */
15
+ const BASELINE = 0.75;
16
+ /** The gap between the pad's edge and the start of a typed name — `inset-x-4`. */
17
+ const INSET = 16;
18
+ /** The pen's colour: the INK token of the paper the canvas is on, read when it paints. The
19
+ * pad is a light subtree (see below), so this is the dark ink in either theme. */
20
+ function inkOf(el) {
21
+ const style = getComputedStyle(el);
22
+ return style.getPropertyValue("--color-ink").trim() || style.color;
23
+ }
24
+ function paint(canvas, strokes) {
25
+ const ctx = canvas.getContext("2d");
26
+ if (ctx === null)
27
+ return;
28
+ const dpr = canvas.clientWidth === 0 ? 1 : canvas.width / canvas.clientWidth;
29
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
30
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
31
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
32
+ const ink = inkOf(canvas);
33
+ ctx.strokeStyle = ink;
34
+ ctx.fillStyle = ink;
35
+ ctx.lineWidth = PEN;
36
+ ctx.lineCap = "round";
37
+ ctx.lineJoin = "round";
38
+ for (const stroke of strokes) {
39
+ ctx.beginPath();
40
+ for (const step of strokeSteps(stroke)) {
41
+ if (step.kind === "dot") {
42
+ ctx.arc(step.at.x, step.at.y, PEN / 2, 0, Math.PI * 2);
43
+ ctx.fill();
44
+ }
45
+ else if (step.kind === "move")
46
+ ctx.moveTo(step.to.x, step.to.y);
47
+ else if (step.kind === "quad") {
48
+ ctx.quadraticCurveTo(step.control.x, step.control.y, step.to.x, step.to.y);
49
+ }
50
+ else
51
+ ctx.lineTo(step.to.x, step.to.y);
52
+ }
53
+ ctx.stroke();
54
+ }
55
+ }
56
+ /** A typed name, written as a signature: the system's font in italic, on the line, as big as
57
+ * the pad allows and smaller when the name is long. */
58
+ function typedSignature(text, pad) {
59
+ const dpr = window.devicePixelRatio || 1;
60
+ const width = pad.clientWidth || 480;
61
+ const height = pad.clientHeight || 128;
62
+ const canvas = document.createElement("canvas");
63
+ canvas.width = Math.round(width * dpr);
64
+ canvas.height = Math.round(height * dpr);
65
+ const ctx = canvas.getContext("2d");
66
+ if (ctx === null)
67
+ return "";
68
+ ctx.scale(dpr, dpr);
69
+ const family = getComputedStyle(pad).fontFamily;
70
+ const font = (size) => `italic 400 ${size}px ${family}`;
71
+ const base = Math.round(height * 0.42);
72
+ ctx.font = font(base);
73
+ const size = fitFontSize(ctx.measureText(text).width, base, width - INSET * 2);
74
+ ctx.font = font(size);
75
+ ctx.fillStyle = inkOf(pad);
76
+ ctx.textBaseline = "alphabetic";
77
+ ctx.fillText(text, INSET, height * BASELINE - 2);
78
+ return canvas.toDataURL("image/png");
79
+ }
80
+ /**
81
+ * A signature: drawn with the mouse, a pen or a finger — or typed, for whoever cannot draw.
82
+ *
83
+ * Context: the pad is a canvas that follows the pointer (`touch-none`, so a finger draws
84
+ * instead of scrolling the page) and smooths each stroke through the midpoints of its
85
+ * samples (`strokeSteps`) — no pressure, no library, and no corners. When a stroke ends the
86
+ * pad is exported as a PNG data URL and handed to `onChange`; «Limpiar» empties it and
87
+ * hands `null`.
88
+ *
89
+ * **A canvas alone shuts people out.** Whoever works with a keyboard or a screen reader, or
90
+ * whose hand does not hold a pointer steady, cannot draw. «Escribir mi nombre» turns the pad
91
+ * into a text box: the name typed (starting from `name`, when the form knows who signs) is
92
+ * written into the same PNG in the system's font, in italic, on the same line — the value
93
+ * has the same shape whichever way it was made, and the form does not have to know.
94
+ *
95
+ * **The pad is paper.** It is a forced-light subtree (`data-theme="light"`): the pen reads
96
+ * `--color-ink` there, so a signature made in the dark theme is still dark ink on a
97
+ * transparent PNG — the thing a printed contract or a PDF expects — and read mode lays it on
98
+ * the same paper, so it is legible on either theme.
99
+ *
100
+ * A value that did not come from this pad (a signature already saved) is shown as it is,
101
+ * not drawn over: «Limpiar» starts a new one. In read mode it draws the image.
102
+ */
103
+ export function SignatureInput({ id: ownId, value, onChange, name = "", alt, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, "aria-label": ariaLabel, }) {
104
+ const copy = useUiCopy();
105
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
106
+ id: ownId,
107
+ describedBy: ownDescribedBy,
108
+ invalid: ownInvalid,
109
+ });
110
+ const readMode = useFormMode(mode) === "read";
111
+ const [typing, setTyping] = useState(false);
112
+ const [typed, setTyped] = useState(name);
113
+ const pad = useRef(null);
114
+ const canvas = useRef(null);
115
+ const strokes = useRef([]);
116
+ const drawing = useRef(false);
117
+ /* The last value this pad handed out: a `value` equal to it is the pad's own work, and the
118
+ canvas keeps drawing it; anything else came from outside and is shown as an image. */
119
+ const emitted = useRef(null);
120
+ const own = useId();
121
+ const helpId = `${own}-help`;
122
+ const has = value !== null && value !== "";
123
+ const stored = has && value !== emitted.current;
124
+ const label = alt ?? copy.media.signature;
125
+ /* The canvas is as many device pixels as it is drawn at, or the line blurs on a dense
126
+ screen; a resize repaints the strokes, which are kept in CSS pixels. */
127
+ useEffect(() => {
128
+ const el = canvas.current;
129
+ if (el === null)
130
+ return;
131
+ const fit = () => {
132
+ const dpr = window.devicePixelRatio || 1;
133
+ el.width = Math.max(1, Math.round(el.clientWidth * dpr));
134
+ el.height = Math.max(1, Math.round(el.clientHeight * dpr));
135
+ paint(el, strokes.current);
136
+ };
137
+ fit();
138
+ const observer = new ResizeObserver(fit);
139
+ observer.observe(el);
140
+ return () => observer.disconnect();
141
+ }, [typing, stored, readMode]);
142
+ if (readMode) {
143
+ return has ? (_jsx("span", { "data-theme": "light", className: "inline-flex rounded-control border border-line bg-surface p-1", children: _jsx("img", { src: value, alt: label, className: "h-16 w-auto" }) })) : (_jsx(ReadValue, { value: null }));
144
+ }
145
+ const emit = (next) => {
146
+ emitted.current = next;
147
+ onChange?.(next);
148
+ };
149
+ const pointOf = (event) => {
150
+ const box = event.currentTarget.getBoundingClientRect();
151
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
152
+ };
153
+ function onPointerDown(event) {
154
+ if (disabled || (event.pointerType === "mouse" && event.button !== 0))
155
+ return;
156
+ capturePointer(event);
157
+ drawing.current = true;
158
+ strokes.current.push([pointOf(event)]);
159
+ paint(event.currentTarget, strokes.current);
160
+ }
161
+ function onPointerMove(event) {
162
+ if (!drawing.current)
163
+ return;
164
+ const stroke = strokes.current[strokes.current.length - 1];
165
+ if (stroke === undefined)
166
+ return;
167
+ /* The samples the browser merged into this event: a fast stroke between two frames is
168
+ several points, and drawing only the last one cuts its curves into chords. */
169
+ const native = event.nativeEvent;
170
+ const samples = typeof native.getCoalescedEvents === "function" ? native.getCoalescedEvents() : [];
171
+ const box = event.currentTarget.getBoundingClientRect();
172
+ if (samples.length > 0) {
173
+ for (const one of samples)
174
+ stroke.push({ x: one.clientX - box.left, y: one.clientY - box.top });
175
+ }
176
+ else
177
+ stroke.push(pointOf(event));
178
+ paint(event.currentTarget, strokes.current);
179
+ }
180
+ function onPointerUp(event) {
181
+ if (!drawing.current)
182
+ return;
183
+ drawing.current = false;
184
+ emit(event.currentTarget.toDataURL("image/png"));
185
+ }
186
+ function clear() {
187
+ strokes.current = [];
188
+ if (canvas.current !== null)
189
+ paint(canvas.current, []);
190
+ if (typing)
191
+ setTyped("");
192
+ emit(null);
193
+ }
194
+ function writeTyped(text) {
195
+ setTyped(text);
196
+ emit(text.trim() === "" || pad.current === null ? null : typedSignature(text, pad.current));
197
+ }
198
+ /* One signature, either way: switching starts the other one from scratch, and a typed name
199
+ that is already there is written at once. */
200
+ function switchMode() {
201
+ strokes.current = [];
202
+ if (typing) {
203
+ setTyping(false);
204
+ emit(null);
205
+ }
206
+ else {
207
+ setTyping(true);
208
+ writeTyped(typed);
209
+ }
210
+ }
211
+ const paper = cn("relative h-32 w-full overflow-clip rounded-control border bg-surface font-sans text-ink", invalid ? "border-danger" : "border-line-strong", disabled ? "bg-ground" : "");
212
+ return (_jsxs("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: [_jsxs("div", { ref: pad, "data-theme": "light", className: paper, children: [stored || (typing && has) ? (_jsx("img", { src: value ?? "", alt: label, className: "size-full object-contain object-left" })) : typing ? null : (_jsx("canvas", { ref: canvas, role: "img", "aria-label": copy.media.signaturePad, "aria-describedby": helpId, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, className: cn("absolute inset-0 size-full touch-none", disabled ? "cursor-not-allowed" : "cursor-crosshair") })), _jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-4 bottom-1/4 border-t border-line-strong" })] }), typing ? (_jsxs("label", { className: "flex flex-col gap-1 text-micro font-medium text-muted", children: [copy.media.typedName, _jsx("input", {
213
+ /* The person asked for this box by pressing the button: the focus goes there. */
214
+ autoFocus: true, value: typed, disabled: disabled, autoComplete: "name", onChange: (event) => writeTyped(event.target.value), className: `${controlClasses(invalid)} font-normal` })] })) : (_jsx("p", { id: helpId, className: "text-micro text-muted", children: copy.media.signatureHelp })), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { size: "sm", icon: typing ? PenLine : Type, disabled: disabled, onClick: switchMode, children: typing ? copy.media.drawInstead : copy.media.typeName }), _jsx(Button, { role: "ghost", size: "sm", icon: Eraser, disabled: disabled, onClick: clear, children: copy.media.clear })] })] }));
215
+ }
@@ -0,0 +1,33 @@
1
+ export type CardBrand = "visa" | "mastercard" | "amex" | "discover" | "diners" | "jcb" | "unionpay" | "maestro";
2
+ /** The longest a card number gets, of any brand. */
3
+ export declare const CARD_MAX_DIGITS = 19;
4
+ /** Only the digits of what was typed or pasted: «4242 4242-4242» → «424242424242». */
5
+ export declare function cardDigits(text: string): string;
6
+ /** The brand the first digits say, or `null` while they do not say one yet. */
7
+ export declare function cardBrand(digits: string): CardBrand | null;
8
+ /** How the brand is written: «American Express». */
9
+ export declare function cardBrandName(brand: CardBrand): string;
10
+ /** The most digits a number of this brand takes — where typing stops. */
11
+ export declare function cardMaxDigits(digits: string, brand?: CardBrand | null): number;
12
+ /** The digits in the brand's groups: «378282246310005» → «3782 822463 10005». */
13
+ export declare function formatCardNumber(digits: string, brand?: CardBrand | null): string;
14
+ /** The Luhn check digit holds: a mistyped digit or two swapped neighbours fail it. */
15
+ export declare function luhnValid(digits: string): boolean;
16
+ /** A whole number: a known brand, one of its lengths, and the check digit holds. */
17
+ export declare function isValidCardNumber(digits: string): boolean;
18
+ /** Only the last four, the way a card is shown once it is stored: «•••• 4242». */
19
+ export declare function maskCardNumber(digits: string): string;
20
+ /** The digits of an expiry, a leading zero put in for a month that can only be one digit:
21
+ * «4» is April, so «04»; «1» may still be October. At most four: MMAA. */
22
+ export declare function expiryDigits(text: string): string;
23
+ /** How the expiry reads: «1228» → «12/28». */
24
+ export declare function formatExpiry(digits: string): string;
25
+ /** The month and the full year of a whole expiry, or `null` while it is not one. */
26
+ export declare function expiryParts(digits: string): {
27
+ month: number;
28
+ year: number;
29
+ } | null;
30
+ /** A real month that has not ended yet: a card is good through the last day of its month. */
31
+ export declare function isExpiryValid(digits: string, now?: Date): boolean;
32
+ /** How many digits the security code has: four on the front of an Amex, three elsewhere. */
33
+ export declare function cardCodeLength(brand: CardBrand | null | undefined): 3 | 4;