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