@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,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;
@@ -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;