@sincpro/web-ui 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/components/attachments/Attachments.d.ts +56 -0
  2. package/dist/components/attachments/Attachments.js +93 -0
  3. package/dist/components/attachments/files.d.ts +28 -0
  4. package/dist/components/attachments/files.js +100 -0
  5. package/dist/components/attachments/index.d.ts +2 -0
  6. package/dist/components/attachments/index.js +3 -0
  7. package/dist/components/flow/StepFlow.d.ts +37 -0
  8. package/dist/components/flow/StepFlow.js +74 -0
  9. package/dist/components/flow/index.d.ts +1 -0
  10. package/dist/components/flow/index.js +2 -0
  11. package/dist/components/index.d.ts +2 -0
  12. package/dist/components/index.js +2 -0
  13. package/dist/components/lines/LineItems.js +55 -45
  14. package/dist/components/listing/SortMenu.d.ts +24 -20
  15. package/dist/components/listing/SortMenu.js +51 -91
  16. package/dist/components/listing/index.d.ts +2 -1
  17. package/dist/components/listing/index.js +2 -1
  18. package/dist/components/listing/modelCells.d.ts +7 -0
  19. package/dist/components/listing/modelCells.js +56 -0
  20. package/dist/components/listing/modelColumns.d.ts +9 -0
  21. package/dist/components/listing/modelColumns.js +59 -14
  22. package/dist/components/search/index.d.ts +1 -1
  23. package/dist/components/search/index.js +1 -1
  24. package/dist/components/search/model.d.ts +1 -0
  25. package/dist/components/search/model.js +7 -0
  26. package/dist/components/search/query.js +2 -3
  27. package/dist/foundations/copy.d.ts +131 -0
  28. package/dist/foundations/copy.es.js +86 -0
  29. package/dist/foundations/copy.js +86 -0
  30. package/dist/foundations/form.d.ts +25 -0
  31. package/dist/foundations/form.js +27 -0
  32. package/dist/foundations/index.d.ts +1 -1
  33. package/dist/foundations/index.js +1 -1
  34. package/dist/primitives/button/Button.js +3 -1
  35. package/dist/primitives/choice/ComboBox.js +2 -2
  36. package/dist/primitives/choice/SearchField.js +2 -5
  37. package/dist/primitives/choice/SegmentedControl.js +2 -2
  38. package/dist/primitives/choice/Slider.js +2 -2
  39. package/dist/primitives/date/DatePicker.js +4 -4
  40. package/dist/primitives/date/DateTime.js +3 -3
  41. package/dist/primitives/field/CardNumberInput.d.ts +41 -0
  42. package/dist/primitives/field/CardNumberInput.js +117 -0
  43. package/dist/primitives/field/CodeInput.d.ts +18 -0
  44. package/dist/primitives/field/CodeInput.js +37 -0
  45. package/dist/primitives/field/ColorInput.d.ts +21 -0
  46. package/dist/primitives/field/ColorInput.js +42 -0
  47. package/dist/primitives/field/ContactInputs.d.ts +18 -0
  48. package/dist/primitives/field/ContactInputs.js +70 -0
  49. package/dist/primitives/field/DurationInput.d.ts +20 -0
  50. package/dist/primitives/field/DurationInput.js +54 -0
  51. package/dist/primitives/field/Field.d.ts +26 -5
  52. package/dist/primitives/field/Field.js +19 -6
  53. package/dist/primitives/field/ImageInput.d.ts +51 -0
  54. package/dist/primitives/field/ImageInput.js +267 -0
  55. package/dist/primitives/field/PasswordInput.d.ts +29 -0
  56. package/dist/primitives/field/PasswordInput.js +44 -0
  57. package/dist/primitives/field/PatternInput.d.ts +23 -0
  58. package/dist/primitives/field/PatternInput.js +48 -0
  59. package/dist/primitives/field/RichTextEditor.d.ts +18 -0
  60. package/dist/primitives/field/RichTextEditor.js +224 -0
  61. package/dist/primitives/field/RichTextInput.d.ts +30 -0
  62. package/dist/primitives/field/RichTextInput.js +38 -0
  63. package/dist/primitives/field/SignatureInput.d.ts +34 -0
  64. package/dist/primitives/field/SignatureInput.js +215 -0
  65. package/dist/primitives/field/Suggest.js +2 -2
  66. package/dist/primitives/field/card.d.ts +33 -0
  67. package/dist/primitives/field/card.js +173 -0
  68. package/dist/primitives/field/clipboard.js +3 -2
  69. package/dist/primitives/field/contact.d.ts +17 -0
  70. package/dist/primitives/field/contact.js +65 -0
  71. package/dist/primitives/field/duration.d.ts +5 -0
  72. package/dist/primitives/field/duration.js +48 -0
  73. package/dist/primitives/field/index.d.ts +14 -0
  74. package/dist/primitives/field/index.js +20 -0
  75. package/dist/primitives/field/mask.d.ts +33 -0
  76. package/dist/primitives/field/mask.js +56 -0
  77. package/dist/primitives/field/media.d.ts +99 -0
  78. package/dist/primitives/field/media.js +136 -0
  79. package/dist/primitives/field/pattern.d.ts +10 -0
  80. package/dist/primitives/field/pattern.js +68 -0
  81. package/dist/primitives/menu/Menu.d.ts +16 -0
  82. package/dist/primitives/menu/Menu.js +26 -8
  83. package/dist/primitives/menu/index.d.ts +1 -1
  84. package/dist/primitives/menu/index.js +1 -1
  85. package/dist/primitives/picker/Picker.js +2 -2
  86. package/dist/primitives/picker/TagsField.js +2 -2
  87. package/dist/primitives/table/DataTable.d.ts +6 -4
  88. package/dist/primitives/table/DataTable.js +12 -8
  89. package/dist/primitives/table/EditableTable.js +102 -99
  90. package/dist/primitives/table/Table.d.ts +5 -8
  91. package/dist/primitives/table/Table.js +63 -15
  92. package/dist/primitives/table/index.d.ts +2 -1
  93. package/dist/primitives/table/index.js +2 -1
  94. package/dist/primitives/table/sorting.d.ts +42 -0
  95. package/dist/primitives/table/sorting.js +110 -0
  96. package/dist/primitives/value/RichText.d.ts +10 -0
  97. package/dist/primitives/value/RichText.js +130 -0
  98. package/dist/primitives/value/Value.d.ts +20 -2
  99. package/dist/primitives/value/Value.js +26 -7
  100. package/dist/primitives/value/index.d.ts +3 -1
  101. package/dist/primitives/value/index.js +4 -1
  102. package/dist/primitives/value/rounding.d.ts +9 -0
  103. package/dist/primitives/value/rounding.js +48 -0
  104. package/dist/tokens.css +103 -0
  105. package/dist/views/list/List.d.ts +5 -4
  106. package/dist/views/list/ListView.js +2 -2
  107. package/dist/views/list/types.d.ts +6 -6
  108. package/package.json +9 -1
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { DateFormatter, parseDateTime, parseTime, toCalendarDate, } from "@internationalized/date";
4
4
  import { AriaLocale } from "../../foundations/aria.js";
5
5
  import { useUiCopy } from "../../foundations/copy.js";
6
- import { useFieldIds } from "../../foundations/form.js";
6
+ import { controlName, useFieldIds } from "../../foundations/form.js";
7
7
  import { useFormMode } from "../../foundations/form.js";
8
8
  import { Calendar } from "./Calendar.js";
9
9
  import { Segments } from "./DatePicker.js";
@@ -81,7 +81,7 @@ export function TimeField({ value, onChange, id: ownId, describedBy: ownDescribe
81
81
  ? ""
82
82
  : format.format(new Date(Date.UTC(1970, 0, 1, time.hour, time.minute))) }));
83
83
  }
84
- return (_jsx(AriaLocale, { children: _jsx(AriaTimeField, { id: id, "aria-labelledby": labelledBy, "aria-label": ariaLabel, "aria-describedby": describedBy, value: parseClock(value), onChange: (next) => onChange?.(next === null ? null : hhmm(next)), minValue: parseClock(min) ?? undefined, maxValue: parseClock(max) ?? undefined, hourCycle: hourCycle, isInvalid: invalid, isDisabled: disabled, className: "w-full", children: _jsx(FieldGroup, { children: _jsx(Segments, {}) }) }) }));
84
+ return (_jsx(AriaLocale, { children: _jsx(AriaTimeField, { id: id, "aria-labelledby": labelledBy, "aria-label": controlName(labelledBy, ariaLabel), "aria-describedby": describedBy, value: parseClock(value), onChange: (next) => onChange?.(next === null ? null : hhmm(next)), minValue: parseClock(min) ?? undefined, maxValue: parseClock(max) ?? undefined, hourCycle: hourCycle, isInvalid: invalid, isDisabled: disabled, className: "w-full", children: _jsx(FieldGroup, { children: _jsx(Segments, {}) }) }) }));
85
85
  }
86
86
  /** A day and its time — a delivery slot, an appointment: the day typed or picked on the
87
87
  * calendar, the time typed after it. The value is a local `"YYYY-MM-DDTHH:MM"`, or `null`. */
@@ -96,5 +96,5 @@ export function DateTimePicker({ value, onChange, id: ownId, describedBy: ownDes
96
96
  const stamp = parseStamp(value);
97
97
  return _jsx(ReadValue, { value: stamp === null ? "" : format.format(stamp.toDate("UTC")) });
98
98
  }
99
- return (_jsx(AriaLocale, { children: _jsxs(AriaDatePicker, { id: id, "aria-labelledby": labelledBy, "aria-label": ariaLabel, "aria-describedby": describedBy, value: parseStamp(value), onChange: (next) => onChange?.(next === null ? null : stampOf(next)), minValue: parseStamp(min) ?? undefined, maxValue: parseStamp(max) ?? undefined, granularity: "minute", hourCycle: hourCycle, isInvalid: invalid, isDisabled: disabled, className: "w-full", children: [_jsxs(FieldGroup, { children: [_jsx(Segments, {}), _jsx(FieldButton, { className: "mr-1 ml-auto", children: _jsx(CalendarDays, { "aria-hidden": true, className: "size-(--size-icon-md)", strokeWidth: 2 }) })] }), _jsx(Popover, { placement: "bottom end", children: _jsx(Dialog, { className: "p-3 outline-0", children: _jsx(Calendar, {}) }) })] }) }));
99
+ return (_jsx(AriaLocale, { children: _jsxs(AriaDatePicker, { id: id, "aria-labelledby": labelledBy, "aria-label": controlName(labelledBy, ariaLabel), "aria-describedby": describedBy, value: parseStamp(value), onChange: (next) => onChange?.(next === null ? null : stampOf(next)), minValue: parseStamp(min) ?? undefined, maxValue: parseStamp(max) ?? undefined, granularity: "minute", hourCycle: hourCycle, isInvalid: invalid, isDisabled: disabled, className: "w-full", children: [_jsxs(FieldGroup, { children: [_jsx(Segments, {}), _jsx(FieldButton, { className: "mr-1 ml-auto", children: _jsx(CalendarDays, { "aria-hidden": true, className: "size-(--size-icon-md)", strokeWidth: 2 }) })] }), _jsx(Popover, { placement: "bottom end", children: _jsx(Dialog, { className: "p-3 outline-0", children: _jsx(Calendar, {}) }) })] }) }));
100
100
  }
@@ -0,0 +1,41 @@
1
+ import { type CardBrand } from "./card.js";
2
+ import { type ControlProps } from "./Field.js";
3
+ export declare function CardNumberInput({ id: ownId, value, onChange, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled, mode, brand: givenBrand, masked, "aria-label": ariaLabel, }: ControlProps & {
4
+ /** The digits, without spaces. Whatever else comes in (a pasted «4242-4242…») is dropped. */
5
+ value: string;
6
+ onChange?: (digits: string) => void;
7
+ placeholder?: string;
8
+ /** The brand when the digits cannot say it — a stored card known only by its last four. */
9
+ brand?: CardBrand;
10
+ /** In read mode, only the last four. On by default. */
11
+ masked?: boolean;
12
+ }): import("react").JSX.Element;
13
+ /**
14
+ * The expiry of a card, «MM/AA». The value is the four digits («1228»); the slash is drawn.
15
+ *
16
+ * Context: the month that can only be one digit gets its zero while typing — «4» is April,
17
+ * so «04/» — which is what a card's face says and what saves a keystroke. The caret is kept
18
+ * by digit, like `CardNumberInput`. Whether it already EXPIRED is the form's to say, with
19
+ * `isExpiryValid`: good through the last day of its month.
20
+ */
21
+ export declare function CardExpiryInput({ mode, value, onChange, placeholder, ...props }: ControlProps & {
22
+ /** The four digits, MMAA. */
23
+ value: string;
24
+ onChange?: (digits: string) => void;
25
+ placeholder?: string;
26
+ }): import("react").JSX.Element;
27
+ /**
28
+ * The security code on the back of a card (on the front of an Amex): three digits, four for
29
+ * Amex — `brand` decides, so it can be the one `CardNumberInput` recognised.
30
+ *
31
+ * Read mode never shows it: a fixed row of dots, as long as the brand's code, and the copy
32
+ * button finds nothing to copy. It is asked once and not stored — a form that keeps it is
33
+ * outside what a card network allows.
34
+ */
35
+ export declare function CardCodeInput({ mode, value, onChange, brand, placeholder, ...props }: ControlProps & {
36
+ value: string;
37
+ onChange?: (digits: string) => void;
38
+ /** The card's brand, for its length. */
39
+ brand?: CardBrand | null;
40
+ placeholder?: string;
41
+ }): import("react").JSX.Element;
@@ -0,0 +1,117 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useFieldIds, useFormMode } from "../../foundations/form.js";
4
+ import { FieldGroup } from "./aria.js";
5
+ import { cardBrand, cardBrandName, cardCodeLength, cardDigits, cardMaxDigits, formatCardNumber, formatExpiry, maskCardNumber, } from "./card.js";
6
+ import { controlClasses, nameOf, ReadValue, } from "./Field.js";
7
+ import { maskedChange, useMaskCaret } from "./mask.js";
8
+ import { CreditCard } from "lucide-react";
9
+ import { Input, TextField as AriaTextField } from "react-aria-components";
10
+ /**
11
+ * A payment card number: typed in the brand's groups, the brand named as it is recognised.
12
+ *
13
+ * Context: the value is the DIGITS (`card.ts`); the spaces are drawn. Formatting while
14
+ * typing has one trap — inserting a space moves the text under the caret, and a caret that
15
+ * jumps to the end on every fourth digit makes correcting the middle of a number impossible.
16
+ * So the caret is kept by DIGIT, not by character: after each change it goes back after the
17
+ * same number of digits it was after. Deleting a space deletes the digit before it, which is
18
+ * what the person meant.
19
+ *
20
+ * What it does not decide is whether the number is WRONG: `isValidCardNumber` (Luhn, length
21
+ * by brand) is exported for the form to say it in the field's `error`, when the person tries
22
+ * — not on every keystroke, while the number is still half typed.
23
+ *
24
+ * Read mode shows the brand and the last four («Visa •••• 4242»): a stored card is shown that
25
+ * way everywhere. `masked={false}` shows it whole, for the one screen that has to. An
26
+ * application that only keeps the last four passes those and the `brand`.
27
+ */
28
+ const isDigit = (char) => /\d/.test(char);
29
+ export function CardNumberInput({ id: ownId, value, onChange, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, brand: givenBrand, masked = true, "aria-label": ariaLabel, }) {
30
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
31
+ id: ownId,
32
+ describedBy: ownDescribedBy,
33
+ invalid: ownInvalid,
34
+ });
35
+ const digits = cardDigits(value);
36
+ const brand = givenBrand ?? cardBrand(digits);
37
+ const formatted = formatCardNumber(digits, brand);
38
+ const { input, keep } = useMaskCaret(formatted, isDigit);
39
+ if (useFormMode(mode) === "read") {
40
+ const shown = masked ? maskCardNumber(digits) : formatted;
41
+ return (_jsx(ReadValue, { mono: true, value: digits === "" ? null : brand === null ? shown : `${cardBrandName(brand)} ${shown}` }));
42
+ }
43
+ const change = (raw) => {
44
+ const { next, caret } = maskedChange({
45
+ raw,
46
+ at: input.current?.selectionStart ?? raw.length,
47
+ current: digits,
48
+ drawn: formatted,
49
+ clean: cardDigits,
50
+ max: cardMaxDigits(cardDigits(raw), givenBrand),
51
+ });
52
+ keep(caret);
53
+ onChange?.(next);
54
+ };
55
+ return (_jsx(AriaTextField, { id: id, "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel, placeholder), value: formatted, onChange: change, inputMode: "numeric", autoComplete: "cc-number", isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: _jsxs(FieldGroup, { isInvalid: invalid, isDisabled: disabled, children: [_jsx(CreditCard, { "aria-hidden": true, className: "ml-2.5 size-(--size-icon-sm) shrink-0 text-muted", strokeWidth: 2 }), _jsx(Input, { ref: input, "aria-describedby": describedBy, placeholder: placeholder, spellCheck: false, className: "h-full min-w-0 flex-1 bg-transparent px-2 font-mono text-meta text-ink tabular-nums outline-0 placeholder:text-muted disabled:cursor-not-allowed" }), brand === null ? null : (
56
+ /* The brand as it is recognised: a wrong first digit shows up here, before the
57
+ sixteenth. Read with the number, as part of what the field holds. */
58
+ _jsx("span", { className: "mr-2.5 shrink-0 text-micro font-medium whitespace-nowrap text-muted", children: cardBrandName(brand) }))] }) }));
59
+ }
60
+ /** The box of a short card datum: numeric keyboard, its autofill, digits in tabular mono. */
61
+ function CardDatumBox({ id: ownId, value, onChange, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, autoComplete, inputRef, }) {
62
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
63
+ id: ownId,
64
+ describedBy: ownDescribedBy,
65
+ invalid: ownInvalid,
66
+ });
67
+ return (_jsx(AriaTextField, { id: id, "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel, placeholder), value: value, onChange: onChange, inputMode: "numeric", autoComplete: autoComplete, isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: _jsx(Input, { ...(inputRef === undefined ? {} : { ref: inputRef }), "aria-describedby": describedBy, placeholder: placeholder, spellCheck: false, className: `${controlClasses(invalid)} font-mono tabular-nums` }) }));
68
+ }
69
+ /**
70
+ * The expiry of a card, «MM/AA». The value is the four digits («1228»); the slash is drawn.
71
+ *
72
+ * Context: the month that can only be one digit gets its zero while typing — «4» is April,
73
+ * so «04/» — which is what a card's face says and what saves a keystroke. The caret is kept
74
+ * by digit, like `CardNumberInput`. Whether it already EXPIRED is the form's to say, with
75
+ * `isExpiryValid`: good through the last day of its month.
76
+ */
77
+ export function CardExpiryInput({ mode, value, onChange, placeholder = "MM/AA", ...props }) {
78
+ const digits = cardDigits(value).slice(0, 4);
79
+ const drawn = formatExpiry(digits);
80
+ const { input, keep } = useMaskCaret(drawn, isDigit);
81
+ if (useFormMode(mode) === "read") {
82
+ return _jsx(ReadValue, { mono: true, value: digits === "" ? null : drawn });
83
+ }
84
+ const change = (raw) => {
85
+ let { next, caret } = maskedChange({
86
+ raw,
87
+ at: input.current?.selectionStart ?? raw.length,
88
+ current: digits,
89
+ drawn,
90
+ clean: cardDigits,
91
+ max: 4,
92
+ });
93
+ /* Only while TYPING: deleting the zero of «04» must not put it back. */
94
+ if (raw.length > drawn.length && /^[2-9]/.test(next)) {
95
+ next = `0${next}`.slice(0, 4);
96
+ caret = Math.min(caret + 1, next.length);
97
+ }
98
+ keep(caret);
99
+ onChange?.(next);
100
+ };
101
+ return (_jsx(CardDatumBox, { ...props, value: drawn, onChange: change, placeholder: placeholder, autoComplete: "cc-exp", inputRef: input }));
102
+ }
103
+ /**
104
+ * The security code on the back of a card (on the front of an Amex): three digits, four for
105
+ * Amex — `brand` decides, so it can be the one `CardNumberInput` recognised.
106
+ *
107
+ * Read mode never shows it: a fixed row of dots, as long as the brand's code, and the copy
108
+ * button finds nothing to copy. It is asked once and not stored — a form that keeps it is
109
+ * outside what a card network allows.
110
+ */
111
+ export function CardCodeInput({ mode, value, onChange, brand, placeholder, ...props }) {
112
+ const length = cardCodeLength(brand);
113
+ if (useFormMode(mode) === "read") {
114
+ return (_jsx(ReadValue, { value: value === "" ? null : (_jsx("span", { "aria-hidden": true, "data-copy-skip": "", className: "tracking-widest", children: "•".repeat(length) })) }));
115
+ }
116
+ return (_jsx(CardDatumBox, { ...props, value: cardDigits(value).slice(0, length), onChange: (raw) => onChange?.(cardDigits(raw).slice(0, length)), placeholder: placeholder ?? "•".repeat(length), autoComplete: "cc-csc" }));
117
+ }
@@ -0,0 +1,18 @@
1
+ import { type ControlProps } from "./Field.js";
2
+ /**
3
+ * A verification code of N characters — the one an SMS or an email sends — drawn as N boxes.
4
+ *
5
+ * Context: the boxes are only drawn. Underneath is ONE input, transparent over them: six
6
+ * inputs that pass the focus from one to the next break pasting the whole code, the
7
+ * device's «fill from SMS» (`autoComplete="one-time-code"`), and a screen reader, which
8
+ * hears six fields with no name. One input does all three, and the boxes show where the next
9
+ * character goes.
10
+ */
11
+ export declare function CodeInput({ id: ownId, value, onChange, length, alphanumeric, describedBy: ownDescribedBy, invalid: ownInvalid, disabled, mode, "aria-label": ariaLabel, }: ControlProps & {
12
+ value: string;
13
+ onChange?: (code: string) => void;
14
+ /** How many characters. */
15
+ length?: number;
16
+ /** Letters too, in capitals. Digits only by default. */
17
+ alphanumeric?: boolean;
18
+ }): import("react").JSX.Element;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useFieldIds, useFormMode } from "../../foundations/form.js";
4
+ import { nameOf, ReadValue } from "./Field.js";
5
+ import { useState } from "react";
6
+ import { Input, TextField as AriaTextField } from "react-aria-components";
7
+ /**
8
+ * A verification code of N characters — the one an SMS or an email sends — drawn as N boxes.
9
+ *
10
+ * Context: the boxes are only drawn. Underneath is ONE input, transparent over them: six
11
+ * inputs that pass the focus from one to the next break pasting the whole code, the
12
+ * device's «fill from SMS» (`autoComplete="one-time-code"`), and a screen reader, which
13
+ * hears six fields with no name. One input does all three, and the boxes show where the next
14
+ * character goes.
15
+ */
16
+ export function CodeInput({ id: ownId, value, onChange, length = 6, alphanumeric = false, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, "aria-label": ariaLabel, }) {
17
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
18
+ id: ownId,
19
+ describedBy: ownDescribedBy,
20
+ invalid: ownInvalid,
21
+ });
22
+ const [focused, setFocused] = useState(false);
23
+ const clean = (text) => (alphanumeric
24
+ ? text.replace(/[^a-z\d]/gi, "").toUpperCase()
25
+ : text.replace(/\D/g, "")).slice(0, length);
26
+ const code = clean(value);
27
+ if (useFormMode(mode) === "read") {
28
+ return _jsx(ReadValue, { mono: true, value: code === "" ? null : code });
29
+ }
30
+ /* The box the next character goes into; the last one once the code is whole. */
31
+ const current = Math.min(code.length, length - 1);
32
+ return (_jsxs(AriaTextField, { id: id, "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel), value: code, onChange: (next) => onChange?.(clean(next)), inputMode: alphanumeric ? "text" : "numeric", autoComplete: "one-time-code", isInvalid: invalid, isDisabled: disabled, className: "relative inline-flex", children: [_jsx("span", { "aria-hidden": true, className: "flex gap-1.5", children: Array.from({ length }, (_, index) => (_jsx("span", { className: `flex size-(--size-control-md) items-center justify-center rounded-control border bg-surface font-mono text-lead text-ink transition-colors duration-(--duration-instant) ${invalid
33
+ ? "border-danger"
34
+ : focused && index === current
35
+ ? "border-primary outline-2 outline-offset-1 outline-primary"
36
+ : "border-line-strong"} ${disabled ? "bg-ground text-muted" : ""}`, children: code[index] ?? "" }, index))) }), _jsx(Input, { "aria-describedby": describedBy, spellCheck: false, onFocus: () => setFocused(true), onBlur: () => setFocused(false), className: "absolute inset-0 size-full cursor-text bg-transparent text-transparent caret-transparent outline-0 selection:bg-transparent disabled:cursor-not-allowed" })] }));
37
+ }
@@ -0,0 +1,21 @@
1
+ import { type ControlProps } from "./Field.js";
2
+ /**
3
+ * A color: the label of a tag, the color of a stage, a brand color — as a hex «#1F6FEB».
4
+ *
5
+ * Context: it is React Aria's colour set. `ColorField` takes the hex typed or pasted (with or
6
+ * without `#`) and reads it back normalised; `ColorSwatch` draws it with the colour's NAME
7
+ * as its accessible name («azul oscuro vívido»), so a screen reader hears a colour and not
8
+ * six hex digits; `ColorSwatchPicker` is the row of `swatches` the application offers — its
9
+ * own palette, never one baked in here — moved through with the arrow keys.
10
+ *
11
+ * The colours are DATA (what the record holds), so they are drawn from the value and not
12
+ * from the tokens; everything around them is the system's.
13
+ */
14
+ export declare function ColorInput({ id: ownId, value, onChange, swatches, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled, mode, "aria-label": ariaLabel, }: ControlProps & {
15
+ /** «#RRGGBB»; `""` is no colour. */
16
+ value: string;
17
+ onChange?: (hex: string) => void;
18
+ /** The application's palette, offered above the field. */
19
+ swatches?: readonly string[];
20
+ placeholder?: string;
21
+ }): import("react").JSX.Element;
@@ -0,0 +1,42 @@
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 { FieldGroup } from "./aria.js";
6
+ import { nameOf, ReadValue } from "./Field.js";
7
+ import { ColorField, ColorSwatch, ColorSwatchPicker, ColorSwatchPickerItem, Input, parseColor, } from "react-aria-components";
8
+ /** A hex the color parser takes, or `null`. */
9
+ function colorOf(value) {
10
+ try {
11
+ return value.trim() === "" ? null : parseColor(value);
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ const SWATCH = "size-(--size-icon-md) shrink-0 rounded-mark border border-line-strong";
18
+ /**
19
+ * A color: the label of a tag, the color of a stage, a brand color — as a hex «#1F6FEB».
20
+ *
21
+ * Context: it is React Aria's colour set. `ColorField` takes the hex typed or pasted (with or
22
+ * without `#`) and reads it back normalised; `ColorSwatch` draws it with the colour's NAME
23
+ * as its accessible name («azul oscuro vívido»), so a screen reader hears a colour and not
24
+ * six hex digits; `ColorSwatchPicker` is the row of `swatches` the application offers — its
25
+ * own palette, never one baked in here — moved through with the arrow keys.
26
+ *
27
+ * The colours are DATA (what the record holds), so they are drawn from the value and not
28
+ * from the tokens; everything around them is the system's.
29
+ */
30
+ export function ColorInput({ id: ownId, value, onChange, swatches, placeholder = "#RRGGBB", describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, "aria-label": ariaLabel, }) {
31
+ const copy = useUiCopy();
32
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
33
+ id: ownId,
34
+ describedBy: ownDescribedBy,
35
+ invalid: ownInvalid,
36
+ });
37
+ const color = colorOf(value);
38
+ if (useFormMode(mode) === "read") {
39
+ return (_jsx(ReadValue, { mono: true, value: color === null ? null : (_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(ColorSwatch, { color: color, className: SWATCH }), color.toString("hex")] })) }));
40
+ }
41
+ return (_jsxs("div", { className: "flex w-full min-w-0 flex-col gap-1.5", children: [swatches === undefined || swatches.length === 0 ? null : (_jsx(ColorSwatchPicker, { "aria-label": copy.field.palette, ...(color === null ? {} : { value: color }), onChange: (next) => onChange?.(next.toString("hex")), className: "flex flex-wrap gap-1", children: swatches.map((one) => (_jsx(ColorSwatchPickerItem, { color: one, isDisabled: disabled, className: "rounded-mark p-0.5 outline-0 data-focus-visible:outline-2 data-focus-visible:outline-primary data-selected:ring-2 data-selected:ring-primary", children: _jsx(ColorSwatch, { className: SWATCH }) }, one))) })), _jsx(ColorField, { id: id, "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel, placeholder), value: color, onChange: (next) => onChange?.(next === null ? "" : next.toString("hex")), isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: _jsxs(FieldGroup, { isInvalid: invalid, isDisabled: disabled, children: [color === null ? (_jsx("span", { "aria-hidden": true, className: `ml-2 ${SWATCH} bg-ground` })) : (_jsx(ColorSwatch, { color: color, className: `ml-2 ${SWATCH}` })), _jsx(Input, { "aria-describedby": describedBy, placeholder: placeholder, spellCheck: false, className: "h-full min-w-0 flex-1 bg-transparent px-2 font-mono text-meta text-ink uppercase outline-0 placeholder:text-muted disabled:cursor-not-allowed" })] }) })] }));
42
+ }
@@ -0,0 +1,18 @@
1
+ import { type ControlProps } from "./Field.js";
2
+ type AddressProps = ControlProps & {
3
+ value: string;
4
+ onChange?: (next: string) => void;
5
+ placeholder?: string;
6
+ };
7
+ /** An email: the email keyboard and autofill; in read mode, a `mailto:` link. */
8
+ export declare function EmailInput({ mode, ...props }: AddressProps): import("react").JSX.Element;
9
+ /** A phone as people write it — «+591 7 123-4567», kept as typed. The phone keyboard and
10
+ * autofill; in read mode a `tel:` link, and with `whatsapp` a second link to write there. */
11
+ export declare function PhoneInput({ mode, whatsapp, ...props }: AddressProps & {
12
+ /** Also offer WhatsApp in read mode — the number needs its country code. */
13
+ whatsapp?: boolean;
14
+ }): import("react").JSX.Element;
15
+ /** A website: the URL keyboard; in read mode a link that opens in a new tab, read without
16
+ * its `https://`. A bare «sincpro.com.bo» is kept as typed and linked with `https://`. */
17
+ export declare function UrlInput({ mode, ...props }: AddressProps): import("react").JSX.Element;
18
+ export {};
@@ -0,0 +1,70 @@
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 { FieldGroup } from "./aria.js";
6
+ import { emailHref, telHref, urlHref, urlLabel, whatsappHref, } from "./contact.js";
7
+ import { nameOf, ReadValue } from "./Field.js";
8
+ import { Globe, Mail, MessageCircle, Phone } from "lucide-react";
9
+ import { Input, TextField as AriaTextField } from "react-aria-components";
10
+ /* ==================================================================== *
11
+ * EMAIL, PHONE, WEBSITE — a text that is an ADDRESS.
12
+ *
13
+ * Context: written into a `TextInput` they were plain text twice over. In
14
+ * edit mode the phone's keyboard was the letters one, the browser offered no
15
+ * autofill and underlined «agutierrez@» as a misspelling; in read mode an
16
+ * email was a string you had to select and paste into the mail client.
17
+ *
18
+ * So each one asks for its keyboard (`inputMode`), its autofill
19
+ * (`autoComplete`) and its `type`, draws its icon inside the box so the field
20
+ * says what it holds before anything is typed, and in read mode is a LINK —
21
+ * `mailto:`, `tel:` (and WhatsApp when asked), the website in a new tab. The
22
+ * links are built by `contact.ts`, which never turns «javascript:…» into one.
23
+ *
24
+ * Whether the value is WRONG is the form's to say, on trying: `isEmail` and
25
+ * `isUrl` are exported for that, like `isValidCardNumber`.
26
+ * ==================================================================== */
27
+ const LINK = "min-w-0 truncate rounded-mark text-primary underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary";
28
+ /** The shared box: React Aria's `TextField`, its keyboard and autofill, the icon in front. */
29
+ function AddressInput({ id: ownId, value, onChange, placeholder, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, icon: Icon, type, inputMode, autoComplete, }) {
30
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
31
+ id: ownId,
32
+ describedBy: ownDescribedBy,
33
+ invalid: ownInvalid,
34
+ });
35
+ return (_jsx(AriaTextField, { id: id, "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel, placeholder), value: value, onChange: (next) => onChange?.(next), type: type, inputMode: inputMode, autoComplete: autoComplete, isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: _jsxs(FieldGroup, { isInvalid: invalid, isDisabled: disabled, children: [_jsx(Icon, { "aria-hidden": true, className: "ml-2.5 size-(--size-icon-sm) shrink-0 text-muted", strokeWidth: 2 }), _jsx(Input, { "aria-describedby": describedBy, placeholder: placeholder, spellCheck: false, className: `h-full min-w-0 flex-1 bg-transparent px-2 text-meta text-ink outline-0 placeholder:text-muted disabled:cursor-not-allowed ${type === "tel" ? "tabular-nums" : ""}` })] }) }));
36
+ }
37
+ /** The read mode: a link when the value makes one, the plain text when it does not. */
38
+ function ReadLink({ value, href, label, external = false, after, }) {
39
+ const copy = useUiCopy();
40
+ if (value.trim() === "")
41
+ return _jsx(ReadValue, { value: null });
42
+ if (href === null)
43
+ return _jsx(ReadValue, { value: value });
44
+ return (_jsx(ReadValue, { value: _jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [_jsxs("a", { href: href, className: LINK, ...(external ? { target: "_blank", rel: "noopener noreferrer" } : {}), children: [label ?? value, external ? _jsxs("span", { className: "sr-only", children: [" (", copy.field.newTab, ")"] }) : null] }), after] }) }));
45
+ }
46
+ /** An email: the email keyboard and autofill; in read mode, a `mailto:` link. */
47
+ export function EmailInput({ mode, ...props }) {
48
+ if (useFormMode(mode) === "read") {
49
+ return _jsx(ReadLink, { value: props.value, href: emailHref(props.value) });
50
+ }
51
+ return (_jsx(AddressInput, { ...props, icon: Mail, type: "email", inputMode: "email", autoComplete: "email" }));
52
+ }
53
+ /** A phone as people write it — «+591 7 123-4567», kept as typed. The phone keyboard and
54
+ * autofill; in read mode a `tel:` link, and with `whatsapp` a second link to write there. */
55
+ export function PhoneInput({ mode, whatsapp = false, ...props }) {
56
+ const copy = useUiCopy();
57
+ if (useFormMode(mode) === "read") {
58
+ const chat = whatsapp ? whatsappHref(props.value) : null;
59
+ return (_jsx(ReadLink, { value: props.value, href: telHref(props.value), after: chat === null ? null : (_jsx("a", { href: chat, target: "_blank", rel: "noopener noreferrer", "aria-label": `${copy.person.whatsapp} (${copy.field.newTab})`, title: copy.person.whatsapp, "data-copy-skip": "", className: "inline-flex shrink-0 items-center rounded-mark p-0.5 text-primary hover:bg-primary/8 focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(MessageCircle, { "aria-hidden": true, className: "size-(--size-icon-sm)", strokeWidth: 2 }) })) }));
60
+ }
61
+ return (_jsx(AddressInput, { ...props, icon: Phone, type: "tel", inputMode: "tel", autoComplete: "tel" }));
62
+ }
63
+ /** A website: the URL keyboard; in read mode a link that opens in a new tab, read without
64
+ * its `https://`. A bare «sincpro.com.bo» is kept as typed and linked with `https://`. */
65
+ export function UrlInput({ mode, ...props }) {
66
+ if (useFormMode(mode) === "read") {
67
+ return (_jsx(ReadLink, { value: props.value, href: urlHref(props.value), label: urlLabel(props.value), external: true }));
68
+ }
69
+ return (_jsx(AddressInput, { ...props, icon: Globe, type: "url", inputMode: "url", autoComplete: "url" }));
70
+ }
@@ -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
- type ControlProps = {
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;
@@ -92,9 +94,15 @@ type ControlProps = {
92
94
  /** Forces the mode. By default it inherits the form's: in read mode it draws the value. */
93
95
  mode?: FormMode;
94
96
  /** The name of the control when it is NOT inside a `Field` — a cell of an editable table,
95
- * the box of a message. Inside a field, the field's label names it. */
97
+ * the box of a message. Inside a field the field's label names it and this is IGNORED
98
+ * (`controlName`): with both, the control was read twice. */
96
99
  "aria-label"?: string;
97
100
  };
101
+ /** The accessible name of a control. Inside a `Field` the field's label names it; outside,
102
+ * the consumer's `aria-label`, and as a last resort the placeholder — which is what a
103
+ * browser reads for a bare `<input placeholder>` anyway, so a control outside a form is
104
+ * never nameless. */
105
+ export declare function nameOf(labelledBy: string | undefined, ariaLabel?: string, placeholder?: string): string | undefined;
98
106
  /**
99
107
  * One line of text: a business name, a reference, a NIT.
100
108
  *
@@ -129,7 +137,7 @@ export declare function TextInput({ id: ownId, value, onChange, placeholder, des
129
137
  * number the field would have accepted. The value stays a plain `number`, and `""` is the
130
138
  * empty field — never `NaN`, which is how React Aria says it inside.
131
139
  */
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 & {
140
+ 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
141
  value: number | "";
134
142
  onChange?: (next: number | "") => void;
135
143
  min?: number;
@@ -138,6 +146,20 @@ export declare function NumberInput({ id: ownId, value, onChange, min, max, step
138
146
  /** The unit, attached to the field: "días", "u.", "kg". A number without a unit in a
139
147
  * form is half a question. For money, prefer `format` with a currency. */
140
148
  unit?: string;
149
+ /** The unit (or the currency) is CHOSEN, in the same box: «12 · kg ▾», «500 · USD ▾». The
150
+ * number does not convert — what a kilo is in tonnes is the application's — it only says
151
+ * which one was meant. Takes the place of `unit`. */
152
+ units?: {
153
+ options: readonly {
154
+ id: string;
155
+ label: string;
156
+ }[];
157
+ value: string;
158
+ onChange: (id: string) => void;
159
+ };
160
+ /** The VALUE is rounded when it is committed — to 0,05 for cash, to the unit's precision —
161
+ * so what the form submits is what is drawn. See `roundTo`. */
162
+ rounding?: Rounding;
141
163
  /** How the number is written: `{ style: "currency", currency: "BOB" }`,
142
164
  * `{ style: "percent" }`, `{ minimumFractionDigits: 2 }`. In the copy's locale. */
143
165
  format?: Intl.NumberFormatOptions;
@@ -180,4 +202,3 @@ export declare function Select({ id: ownId, value, options, onChange, describedB
180
202
  onChange?: (next: string) => void;
181
203
  placeholder?: string;
182
204
  }): import("react").JSX.Element;
183
- export {};
@@ -4,7 +4,7 @@ import { AriaLocale } from "../../foundations/aria.js";
4
4
  import { useUiCopy } from "../../foundations/copy.js";
5
5
  import { byDensity, useDensity } from "../../foundations/density.js";
6
6
  import { FieldIdsContext, useFieldIds, } from "../../foundations/form.js";
7
- import { FormModeProvider, useFormMode, } from "../../foundations/form.js";
7
+ import { controlName, FormModeProvider, useFormMode, } from "../../foundations/form.js";
8
8
  import { focusRing, tv } from "../../foundations/variants.js";
9
9
  import { FieldGroup, listOptionStyles } from "./aria.js";
10
10
  import { FieldCopy, useFieldCopy } from "./clipboard.js";
@@ -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,8 +138,8 @@ 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
- return labelledBy === undefined ? (ariaLabel ?? placeholder) : ariaLabel;
141
+ export function nameOf(labelledBy, ariaLabel, placeholder) {
142
+ return controlName(labelledBy, ariaLabel, placeholder);
142
143
  }
143
144
  /**
144
145
  * One line of text: a business name, a reference, a NIT.
@@ -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), unit === undefined ? "" : ` ${unit}`] })) }));
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) ? "" : next), 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 }) })] })] }), unit === undefined ? null : (_jsx("span", { className: "shrink-0 text-meta whitespace-nowrap text-muted", children: unit }))] }) }));
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.