@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.
- package/dist/components/attachments/Attachments.d.ts +56 -0
- package/dist/components/attachments/Attachments.js +93 -0
- package/dist/components/attachments/files.d.ts +28 -0
- package/dist/components/attachments/files.js +100 -0
- package/dist/components/attachments/index.d.ts +2 -0
- package/dist/components/attachments/index.js +3 -0
- package/dist/components/flow/StepFlow.d.ts +37 -0
- package/dist/components/flow/StepFlow.js +74 -0
- package/dist/components/flow/index.d.ts +1 -0
- package/dist/components/flow/index.js +2 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/components/lines/LineItems.js +55 -45
- package/dist/components/listing/SortMenu.d.ts +24 -20
- package/dist/components/listing/SortMenu.js +51 -91
- package/dist/components/listing/index.d.ts +2 -1
- package/dist/components/listing/index.js +2 -1
- package/dist/components/listing/modelCells.d.ts +7 -0
- package/dist/components/listing/modelCells.js +56 -0
- package/dist/components/listing/modelColumns.d.ts +9 -0
- package/dist/components/listing/modelColumns.js +59 -14
- package/dist/components/search/index.d.ts +1 -1
- package/dist/components/search/index.js +1 -1
- package/dist/components/search/model.d.ts +1 -0
- package/dist/components/search/model.js +7 -0
- package/dist/components/search/query.js +2 -3
- package/dist/foundations/copy.d.ts +131 -0
- package/dist/foundations/copy.es.js +86 -0
- package/dist/foundations/copy.js +86 -0
- package/dist/foundations/form.d.ts +25 -0
- package/dist/foundations/form.js +27 -0
- package/dist/foundations/index.d.ts +1 -1
- package/dist/foundations/index.js +1 -1
- package/dist/primitives/button/Button.js +3 -1
- package/dist/primitives/choice/ComboBox.js +2 -2
- package/dist/primitives/choice/SearchField.js +2 -5
- package/dist/primitives/choice/SegmentedControl.js +2 -2
- package/dist/primitives/choice/Slider.js +2 -2
- package/dist/primitives/date/DatePicker.js +4 -4
- package/dist/primitives/date/DateTime.js +3 -3
- package/dist/primitives/field/CardNumberInput.d.ts +41 -0
- package/dist/primitives/field/CardNumberInput.js +117 -0
- package/dist/primitives/field/CodeInput.d.ts +18 -0
- package/dist/primitives/field/CodeInput.js +37 -0
- package/dist/primitives/field/ColorInput.d.ts +21 -0
- package/dist/primitives/field/ColorInput.js +42 -0
- package/dist/primitives/field/ContactInputs.d.ts +18 -0
- package/dist/primitives/field/ContactInputs.js +70 -0
- package/dist/primitives/field/DurationInput.d.ts +20 -0
- package/dist/primitives/field/DurationInput.js +54 -0
- package/dist/primitives/field/Field.d.ts +26 -5
- package/dist/primitives/field/Field.js +19 -6
- package/dist/primitives/field/ImageInput.d.ts +51 -0
- package/dist/primitives/field/ImageInput.js +267 -0
- package/dist/primitives/field/PasswordInput.d.ts +29 -0
- package/dist/primitives/field/PasswordInput.js +44 -0
- package/dist/primitives/field/PatternInput.d.ts +23 -0
- package/dist/primitives/field/PatternInput.js +48 -0
- package/dist/primitives/field/RichTextEditor.d.ts +18 -0
- package/dist/primitives/field/RichTextEditor.js +224 -0
- package/dist/primitives/field/RichTextInput.d.ts +30 -0
- package/dist/primitives/field/RichTextInput.js +38 -0
- package/dist/primitives/field/SignatureInput.d.ts +34 -0
- package/dist/primitives/field/SignatureInput.js +215 -0
- package/dist/primitives/field/Suggest.js +2 -2
- package/dist/primitives/field/card.d.ts +33 -0
- package/dist/primitives/field/card.js +173 -0
- package/dist/primitives/field/clipboard.js +3 -2
- package/dist/primitives/field/contact.d.ts +17 -0
- package/dist/primitives/field/contact.js +65 -0
- package/dist/primitives/field/duration.d.ts +5 -0
- package/dist/primitives/field/duration.js +48 -0
- package/dist/primitives/field/index.d.ts +14 -0
- package/dist/primitives/field/index.js +20 -0
- package/dist/primitives/field/mask.d.ts +33 -0
- package/dist/primitives/field/mask.js +56 -0
- package/dist/primitives/field/media.d.ts +99 -0
- package/dist/primitives/field/media.js +136 -0
- package/dist/primitives/field/pattern.d.ts +10 -0
- package/dist/primitives/field/pattern.js +68 -0
- package/dist/primitives/menu/Menu.d.ts +16 -0
- package/dist/primitives/menu/Menu.js +26 -8
- package/dist/primitives/menu/index.d.ts +1 -1
- package/dist/primitives/menu/index.js +1 -1
- package/dist/primitives/picker/Picker.js +2 -2
- package/dist/primitives/picker/TagsField.js +2 -2
- package/dist/primitives/table/DataTable.d.ts +6 -4
- package/dist/primitives/table/DataTable.js +12 -8
- package/dist/primitives/table/EditableTable.js +102 -99
- package/dist/primitives/table/Table.d.ts +5 -8
- package/dist/primitives/table/Table.js +63 -15
- package/dist/primitives/table/index.d.ts +2 -1
- package/dist/primitives/table/index.js +2 -1
- package/dist/primitives/table/sorting.d.ts +42 -0
- package/dist/primitives/table/sorting.js +110 -0
- package/dist/primitives/value/RichText.d.ts +10 -0
- package/dist/primitives/value/RichText.js +130 -0
- package/dist/primitives/value/Value.d.ts +20 -2
- package/dist/primitives/value/Value.js +26 -7
- package/dist/primitives/value/index.d.ts +3 -1
- package/dist/primitives/value/index.js +4 -1
- package/dist/primitives/value/rounding.d.ts +9 -0
- package/dist/primitives/value/rounding.js +48 -0
- package/dist/tokens.css +103 -0
- package/dist/views/list/List.d.ts +5 -4
- package/dist/views/list/ListView.js +2 -2
- package/dist/views/list/types.d.ts +6 -6
- package/package.json +9 -1
|
@@ -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
|
+
}
|
|
@@ -4,7 +4,7 @@ import { AriaLocale } from "../../foundations/aria.js";
|
|
|
4
4
|
import { useFieldIds } from "../../foundations/form.js";
|
|
5
5
|
import { useFormMode } from "../../foundations/form.js";
|
|
6
6
|
import { FieldGroup, listOptionStyles } from "./aria.js";
|
|
7
|
-
import { ReadValue } from "./Field.js";
|
|
7
|
+
import { nameOf, ReadValue } from "./Field.js";
|
|
8
8
|
import { Popover } from "../overlay/Popover.js";
|
|
9
9
|
import { ComboBox as AriaComboBox, Input, ListBox, ListBoxItem, useFilter, } from "react-aria-components";
|
|
10
10
|
const MAX = 8;
|
|
@@ -34,7 +34,7 @@ function EditableSuggest({ id: ownId, value, onChange, suggestions, placeholder,
|
|
|
34
34
|
.filter((one) => needle === "" || contains(one, needle))
|
|
35
35
|
.slice(0, max)
|
|
36
36
|
.map((one) => ({ id: one }));
|
|
37
|
-
return (_jsxs(AriaComboBox, { id: id, "aria-labelledby": labelledBy, "aria-label": labelledBy
|
|
37
|
+
return (_jsxs(AriaComboBox, { id: id, "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel, placeholder),
|
|
38
38
|
/* An open field: what is typed is the value, a suggestion only fills it faster. */
|
|
39
39
|
allowsCustomValue: true, inputValue: value, onInputChange: (next) => onChange?.(next), items: shown,
|
|
40
40
|
/* It proposes as soon as the field is entered, like the hand-written one did: the
|
|
@@ -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;
|