@eifi1/ui-kit 0.5.1 → 0.6.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 (136) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +103 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/checkbox.d.ts +9 -0
  8. package/dist/components/checkbox.js +7 -2
  9. package/dist/components/checkbox.js.map +1 -1
  10. package/dist/components/chip.d.ts +3 -2
  11. package/dist/components/chip.js +14 -1
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/choice-card.d.ts +100 -0
  14. package/dist/components/choice-card.js +170 -0
  15. package/dist/components/choice-card.js.map +1 -0
  16. package/dist/components/combobox-core.d.ts +76 -6
  17. package/dist/components/combobox-core.js +119 -49
  18. package/dist/components/combobox-core.js.map +1 -1
  19. package/dist/components/combobox.d.ts +12 -2
  20. package/dist/components/combobox.js +42 -17
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/danger-confirm.d.ts +91 -0
  23. package/dist/components/danger-confirm.js +181 -0
  24. package/dist/components/danger-confirm.js.map +1 -0
  25. package/dist/components/dialog-frame.d.ts +84 -0
  26. package/dist/components/dialog-frame.js +86 -0
  27. package/dist/components/dialog-frame.js.map +1 -0
  28. package/dist/components/disclosure.d.ts +108 -0
  29. package/dist/components/disclosure.js +127 -0
  30. package/dist/components/disclosure.js.map +1 -0
  31. package/dist/components/entity-combobox.d.ts +17 -3
  32. package/dist/components/entity-combobox.js +25 -5
  33. package/dist/components/entity-combobox.js.map +1 -1
  34. package/dist/components/file-button.d.ts +161 -0
  35. package/dist/components/file-button.js +243 -0
  36. package/dist/components/file-button.js.map +1 -0
  37. package/dist/components/file-dropzone.d.ts +72 -23
  38. package/dist/components/file-dropzone.js +219 -94
  39. package/dist/components/file-dropzone.js.map +1 -1
  40. package/dist/components/icon-picker.d.ts +72 -0
  41. package/dist/components/icon-picker.js +104 -0
  42. package/dist/components/icon-picker.js.map +1 -0
  43. package/dist/components/mini-calendar.d.ts +3 -0
  44. package/dist/components/mini-calendar.js +4 -3
  45. package/dist/components/mini-calendar.js.map +1 -1
  46. package/dist/components/modal.d.ts +8 -1
  47. package/dist/components/modal.js +4 -2
  48. package/dist/components/modal.js.map +1 -1
  49. package/dist/components/multi-entity-combobox.d.ts +16 -3
  50. package/dist/components/multi-entity-combobox.js +25 -5
  51. package/dist/components/multi-entity-combobox.js.map +1 -1
  52. package/dist/components/number-field.d.ts +41 -1
  53. package/dist/components/number-field.js +42 -10
  54. package/dist/components/number-field.js.map +1 -1
  55. package/dist/components/number-input.d.ts +35 -2
  56. package/dist/components/number-input.js +35 -4
  57. package/dist/components/number-input.js.map +1 -1
  58. package/dist/components/numpad-sheet.d.ts +7 -0
  59. package/dist/components/search-field.d.ts +16 -0
  60. package/dist/components/search-field.js +29 -7
  61. package/dist/components/search-field.js.map +1 -1
  62. package/dist/components/signature-pad.d.ts +43 -1
  63. package/dist/components/signature-pad.js +74 -2
  64. package/dist/components/signature-pad.js.map +1 -1
  65. package/dist/components/swatch-picker.d.ts +69 -0
  66. package/dist/components/swatch-picker.js +75 -0
  67. package/dist/components/swatch-picker.js.map +1 -0
  68. package/dist/components/switch.d.ts +9 -0
  69. package/dist/components/switch.js +7 -2
  70. package/dist/components/switch.js.map +1 -1
  71. package/dist/components/tile-radio.d.ts +50 -0
  72. package/dist/components/tile-radio.js +140 -0
  73. package/dist/components/tile-radio.js.map +1 -0
  74. package/dist/components/toggle-group.d.ts +27 -5
  75. package/dist/components/toggle-group.js +22 -15
  76. package/dist/components/toggle-group.js.map +1 -1
  77. package/dist/components/ui.d.ts +150 -12
  78. package/dist/components/ui.js +196 -22
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/i18n/defaults.d.ts +7 -0
  81. package/dist/i18n/defaults.js +13 -2
  82. package/dist/i18n/defaults.js.map +1 -1
  83. package/dist/i18n/kit-labels.d.ts +38 -5
  84. package/dist/i18n/kit-labels.js +12 -4
  85. package/dist/i18n/kit-labels.js.map +1 -1
  86. package/dist/index.d.ts +16 -7
  87. package/dist/index.js +12 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/lib/table-text.d.ts +127 -0
  90. package/dist/lib/table-text.js +82 -0
  91. package/dist/lib/table-text.js.map +1 -0
  92. package/dist/rhf/form.d.ts +79 -0
  93. package/dist/rhf/form.js +143 -0
  94. package/dist/rhf/form.js.map +1 -0
  95. package/dist/rhf.d.ts +4 -0
  96. package/dist/rhf.js +3 -0
  97. package/dist/rhf.js.map +1 -0
  98. package/dist/shell/app-shell.js +3 -1
  99. package/dist/shell/app-shell.js.map +1 -1
  100. package/dist/table-text.d.ts +1 -0
  101. package/dist/table-text.js +3 -0
  102. package/dist/table-text.js.map +1 -0
  103. package/package.json +14 -1
  104. package/src/components/autocomplete.tsx +429 -0
  105. package/src/components/checkbox.tsx +16 -0
  106. package/src/components/chip.tsx +24 -2
  107. package/src/components/choice-card.tsx +305 -0
  108. package/src/components/combobox-core.tsx +228 -58
  109. package/src/components/combobox.tsx +58 -21
  110. package/src/components/danger-confirm.tsx +286 -0
  111. package/src/components/dialog-frame.tsx +179 -0
  112. package/src/components/disclosure.tsx +259 -0
  113. package/src/components/entity-combobox.tsx +41 -6
  114. package/src/components/file-button.tsx +458 -0
  115. package/src/components/file-dropzone.tsx +323 -117
  116. package/src/components/icon-picker.tsx +181 -0
  117. package/src/components/mini-calendar.tsx +7 -3
  118. package/src/components/modal.tsx +10 -2
  119. package/src/components/multi-entity-combobox.tsx +40 -6
  120. package/src/components/number-field.tsx +86 -10
  121. package/src/components/number-input.tsx +79 -2
  122. package/src/components/search-field.tsx +49 -6
  123. package/src/components/signature-pad.tsx +112 -0
  124. package/src/components/swatch-picker.tsx +141 -0
  125. package/src/components/switch.tsx +16 -0
  126. package/src/components/tile-radio.tsx +228 -0
  127. package/src/components/toggle-group.tsx +54 -18
  128. package/src/components/ui.tsx +400 -24
  129. package/src/i18n/defaults.ts +12 -1
  130. package/src/i18n/kit-labels.tsx +45 -5
  131. package/src/index.ts +19 -0
  132. package/src/lib/table-text.ts +265 -0
  133. package/src/rhf/form.tsx +300 -0
  134. package/src/rhf.ts +9 -0
  135. package/src/shell/app-shell.tsx +3 -1
  136. package/src/table-text.ts +8 -0
@@ -2,7 +2,7 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useState } from "react";
4
4
  import { NumberInput } from "./number-input.js";
5
- import { formatResult } from "../lib/calc.js";
5
+ import { evaluateExpression, formatResult } from "../lib/calc.js";
6
6
  import { useKitLocale } from "../i18n/kit-labels.js";
7
7
  function decimalMark(locale) {
8
8
  try {
@@ -12,6 +12,16 @@ function decimalMark(locale) {
12
12
  return ".";
13
13
  }
14
14
  }
15
+ function parseLive(text) {
16
+ const t = text.trim().replace(/,/g, ".");
17
+ if (t === "") return null;
18
+ if (/^-?\d*\.?\d*$/.test(t)) {
19
+ if (!/\d/.test(t)) return void 0;
20
+ const n = Number(t);
21
+ return Number.isFinite(n) ? n : void 0;
22
+ }
23
+ return evaluateExpression(t) ?? void 0;
24
+ }
15
25
  function roundTo(n, digits) {
16
26
  if (digits === void 0) return n;
17
27
  const d = Math.min(Math.max(Math.trunc(digits), 0), 100);
@@ -22,6 +32,7 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
22
32
  function NumberField({
23
33
  value,
24
34
  onCommit,
35
+ onValueChange,
25
36
  digits,
26
37
  min,
27
38
  max,
@@ -42,28 +53,47 @@ function NumberField({
42
53
  const localize = (text) => mark === "," ? text.replace(/\./g, ",") : text;
43
54
  const format = (n) => n === null || !Number.isFinite(n) ? "" : localize(formatResult(roundTo(n, digits)));
44
55
  const [draft, setDraft] = useState(() => format(value));
45
- const shownFor = `${value}|${mark}|${digits}`;
46
- const [seen, setSeen] = useState(shownFor);
56
+ const [seen, setSeen] = useState({ value, mark, digits });
47
57
  const [committed, setCommitted] = useState(value);
48
- if (shownFor !== seen) {
49
- setSeen(shownFor);
50
- setDraft(format(value));
51
- setCommitted(value);
58
+ const [reported, setReported] = useState(value);
59
+ if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {
60
+ setSeen({ value, mark, digits });
61
+ const echo = onValueChange !== void 0 && value === reported && seen.mark === mark && seen.digits === digits;
62
+ if (!echo) {
63
+ setDraft(format(value));
64
+ setCommitted(value);
65
+ setReported(value);
66
+ }
52
67
  }
68
+ const report = (next) => {
69
+ if (next === reported) return;
70
+ setReported(next);
71
+ onValueChange?.(next);
72
+ };
73
+ const onText = (text) => {
74
+ setDraft(localize(text));
75
+ const live = parseLive(text);
76
+ if (live !== void 0) report(live);
77
+ };
53
78
  const commit = (text) => {
54
79
  const trimmed = text.trim();
55
80
  let next;
81
+ const snapBack = () => {
82
+ setDraft(format(committed));
83
+ report(committed);
84
+ };
56
85
  if (trimmed === "") {
57
- if (!nullable) return setDraft(format(committed));
86
+ if (!nullable) return snapBack();
58
87
  next = null;
59
88
  } else {
60
89
  const parsed = Number(trimmed.replace(/,/g, "."));
61
- if (!Number.isFinite(parsed)) return setDraft(format(committed));
90
+ if (!Number.isFinite(parsed)) return snapBack();
62
91
  next = roundTo(parsed, digits);
63
92
  if (min !== void 0) next = Math.max(min, next);
64
93
  if (max !== void 0) next = Math.min(max, next);
65
94
  }
66
95
  setDraft(format(next));
96
+ report(next);
67
97
  if (next !== committed) {
68
98
  setCommitted(next);
69
99
  onCommit(next);
@@ -93,8 +123,10 @@ function NumberField({
93
123
  id: fieldId,
94
124
  label: labelWithUnit,
95
125
  value: draft,
96
- onChange: (text) => setDraft(localize(text)),
126
+ onChange: onText,
97
127
  onCommit: commit,
128
+ min,
129
+ max,
98
130
  suffix: unitPlacement === "suffix" ? unit : void 0,
99
131
  invalid: invalid || hasError || ariaInvalid === true || ariaInvalid === "true"
100
132
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/number-field.tsx"],"sourcesContent":["import { useEffect, useId, useState } from \"react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { NumberInput } from \"./number-input\";\nimport { formatResult } from \"../lib/calc\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\ntype NumberInputProps = ComponentProps<typeof NumberInput>;\n\nexport interface NumberFieldProps\n extends Omit<NumberInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"suffix\" | \"invalid\"> {\n /** The committed number. `null` is \"no value\" — shown as an empty field. */\n value: number | null;\n /**\n * The parsed number, on blur or Enter — never per keystroke — and only when it\n * differs from the last one committed. Already rounded to `digits` and clamped\n * into `[min, max]`. `null` only when `nullable` and the field was emptied.\n */\n onCommit: (value: number | null) => void;\n /** Round to this many decimals on commit, and show at most this many (trailing\n * zeros are trimmed: `digits={2}` shows 12.5, not 12.50). Omit for no rounding. */\n digits?: number;\n /** Clamp the committed number into `[min, max]`. Applied after rounding. */\n min?: number;\n max?: number;\n /** An emptied field commits `null`. Without it an empty field is not an answer\n * and snaps back to the last value, like any other unparsable text. */\n nullable?: boolean;\n /** The unit — \"mm\", \"%\", \"km/h\". */\n unit?: ReactNode;\n /** Where `unit` goes: `\"suffix\"` (default) is NumberInput's read-out at the\n * field's end edge; `\"label\"` appends it to the label as \"Label (unit)\", for rows\n * of narrow fields where a suffix would eat the digits. */\n unitPlacement?: \"suffix\" | \"label\";\n /** BCP 47 tag for the decimal mark, else the `<UiKitProvider locale>`, else the\n * runtime's. */\n locale?: string;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`: shown under the field, attached via\n * `aria-describedby`, implies `invalid`. */\n error?: ReactNode;\n /** Merged with the error's id. Accepted so a form library's control slot (which\n * injects `id` / `aria-describedby` / `aria-invalid`) can wrap this directly. */\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n}\n\n/** The locale's decimal mark, as far as this field can type it: \",\" or \".\". A mark\n * outside those two (Arabic's \"٫\") is not something {@link sanitizeLive} lets\n * through, so those locales keep the dot rather than get a field that eats keys. */\nfunction decimalMark(locale: string | undefined): \",\" | \".\" {\n try {\n const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\");\n return part?.value === \",\" ? \",\" : \".\";\n } catch {\n // An invalid tag throws a RangeError; a typo in a locale prop must not take the\n // form down with it.\n return \".\";\n }\n}\n\n/** `toFixed` rounding rather than `Math.round(n * 10^d)`, which turns 1.005 into\n * 1.00 by way of 100.49999999999999 — the same choice the lenkbank field made. */\nfunction roundTo(n: number, digits: number | undefined): number {\n if (digits === undefined) return n;\n const d = Math.min(Math.max(Math.trunc(digits), 0), 100);\n const rounded = Number(n.toFixed(d));\n return Number.isFinite(rounded) ? rounded : n;\n}\n\n// The error line under a field — the same type as `ui.tsx`'s (module-private) one,\n// so a NumberField's message is indistinguishable from an Input's.\nconst FIELD_ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\n/**\n * A number field whose value IS a number: {@link NumberInput}'s text, calculator and\n * numpad, with the parse/commit/revert loop every consumer was writing on top of it.\n *\n * WHY THE DRAFT. Half-typed values — \"-\", \"1,\", \"12+\" — are not numbers, and\n * reformatting them mid-keystroke moves the caret and fights the typist; committing\n * per keystroke would also fire whatever a commit fires (a recalculation, a save) for\n * every meaningless intermediate. So the field keeps a draft STRING while it is being\n * typed and resolves it on blur/Enter:\n *\n * - a number (or a calculation, \"2700+50\") → rounded to `digits`, clamped into\n * `[min, max]`, committed, and the draft re-rendered from the result;\n * - empty → `null` when `nullable`, otherwise the same as the next case — `Number(\"\")`\n * is 0, and clearing a wheelbase and tabbing away must not set it to zero;\n * - anything else unparsable → the draft SNAPS BACK to the last committed value\n * rather than sending NaN on for a server to reject.\n *\n * The draft follows `value` when it changes from outside (a reset, a loaded record);\n * a change that came from here arrives back identical and is a no-op.\n *\n * LOCALE. The decimal mark is the locale's: a German user sees and types \"1,5\".\n * NumberInput normalises every keystroke to a dot (so its evaluator reads one\n * alphabet); this field maps the dot back before the draft is shown, which also\n * means the numpad's \".\" key types the mark the field is displaying. There is no\n * digit grouping — \"1.000\" would read as a thousand in German and as one in English,\n * and a field that parses its own display back must not have that ambiguity.\n */\nexport function NumberField({\n value,\n onCommit,\n digits,\n min,\n max,\n nullable = false,\n unit,\n unitPlacement = \"suffix\",\n locale: localeProp,\n label,\n invalid,\n error,\n id,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n ...rest\n}: NumberFieldProps) {\n const locale = useKitLocale(localeProp);\n const mark = decimalMark(locale);\n const localize = (text: string) => (mark === \",\" ? text.replace(/\\./g, \",\") : text);\n // `formatResult` for fixed notation: `String(1e-7)` is \"1e-7\", which the field\n // would read back as 17 (see calc.ts).\n const format = (n: number | null) =>\n n === null || !Number.isFinite(n) ? \"\" : localize(formatResult(roundTo(n, digits)));\n\n const [draft, setDraft] = useState(() => format(value));\n // Everything the displayed text depends on, not just `value`: switching the\n // provider's language or a field's `digits` has to re-render the draft too.\n const shownFor = `${value}|${mark}|${digits}`;\n const [seen, setSeen] = useState(shownFor);\n // The last number handed to `onCommit`, so Enter-then-blur (both commit) and a\n // parent that does not echo the value back cannot commit the same number twice.\n // State rather than a ref because the external-change branch below resets it\n // during render, and a ref may not be written there.\n const [committed, setCommitted] = useState(value);\n\n // Adjust state during render rather than in an effect: React re-renders at once\n // with the new draft, before anything is painted, so there is no frame showing\n // the old text.\n if (shownFor !== seen) {\n setSeen(shownFor);\n setDraft(format(value));\n setCommitted(value);\n }\n\n const commit = (text: string) => {\n const trimmed = text.trim();\n let next: number | null;\n if (trimmed === \"\") {\n if (!nullable) return setDraft(format(committed));\n next = null;\n } else {\n // `text` is NumberInput's committed form: an evaluated calculation, dot-decimal.\n // What survives its digits-only fallback can still be \"-\" or \".\" alone.\n const parsed = Number(trimmed.replace(/,/g, \".\"));\n if (!Number.isFinite(parsed)) return setDraft(format(committed));\n next = roundTo(parsed, digits);\n if (min !== undefined) next = Math.max(min, next);\n if (max !== undefined) next = Math.min(max, next);\n }\n // Always re-render the draft from the result — \"007\" becomes \"7\", a clamped 500\n // shows the 100 it was clamped to — even when there is nothing new to commit.\n setDraft(format(next));\n if (next !== committed) {\n setCommitted(next);\n onCommit(next);\n }\n };\n\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const errorId = useId();\n // `null`/`false`/`\"\"` are what `touched && errors.x` evaluates to on the happy path.\n const hasError = error !== undefined && error !== null && error !== false && error !== \"\";\n const describedBy = [ariaDescribedBy, hasError ? errorId : undefined].filter(Boolean).join(\" \");\n\n // NumberInput takes no `aria-describedby` of its own, so the reference is set on\n // its <input> directly, found by the id this field hands it. React never renders\n // that attribute there, so nothing reconciles it away again.\n useEffect(() => {\n const input = document.getElementById(fieldId);\n if (!input) return;\n if (describedBy) input.setAttribute(\"aria-describedby\", describedBy);\n else input.removeAttribute(\"aria-describedby\");\n }, [fieldId, describedBy]);\n\n const labelWithUnit =\n unit !== undefined && unitPlacement === \"label\"\n ? label === undefined || label === \"\"\n ? unit\n : typeof label === \"string\" && typeof unit === \"string\"\n ? `${label} (${unit})`\n : (\n <>\n {label} ({unit})\n </>\n )\n : label;\n\n const field = (\n <NumberInput\n {...rest}\n id={fieldId}\n label={labelWithUnit}\n value={draft}\n onChange={(text) => setDraft(localize(text))}\n onCommit={commit}\n suffix={unitPlacement === \"suffix\" ? unit : undefined}\n invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === \"true\"}\n />\n );\n // Same shape as Input's FieldGroup: no wrapper at all without a message, so adding\n // `error` support changed nothing about the DOM a flex row lays out.\n if (!hasError) return field;\n return (\n <div>\n {field}\n <p id={errorId} className={FIELD_ERROR_CLASS}>\n {error}\n </p>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/number-field.tsx"],"sourcesContent":["import { useEffect, useId, useState } from \"react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { NumberInput } from \"./number-input\";\nimport { evaluateExpression, formatResult } from \"../lib/calc\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\ntype NumberInputProps = ComponentProps<typeof NumberInput>;\n\nexport interface NumberFieldProps\n extends Omit<NumberInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"suffix\" | \"invalid\"> {\n /** The committed number. `null` is \"no value\" — shown as an empty field. */\n value: number | null;\n /**\n * The parsed number, on blur or Enter — never per keystroke — and only when it\n * differs from the last one committed. Already rounded to `digits` and clamped\n * into `[min, max]`. `null` only when `nullable` and the field was emptied.\n */\n onCommit: (value: number | null) => void;\n /**\n * LIVE MODE: the number the text reads as, on every keystroke (and every step\n * key) — for a calculator that recomputes as the user types, while `onCommit`\n * stays the one to save on.\n *\n * What it is NOT, until the commit: rounded to `digits` or clamped into\n * `[min, max]` — a half-typed \"1\" on the way to \"150\" must not read as the\n * minimum of 100. A calculation reads as its result (\"12+5\" → 17); an empty field\n * as `null` (whether or not `nullable`); a draft that is no number yet (\"-\", \"12+\")\n * fires nothing, so the last number stands. On blur/Enter it fires once more with\n * the committed value if that differs from the last one it reported — the rounded,\n * clamped or snapped-back number — so a live consumer always ends where `onCommit`\n * does.\n *\n * Feeding it back into `value` is safe: a `value` equal to the number this field\n * last reported does not re-render the text, so \"1,\" stays \"1,\" while the rest of\n * the form already shows 1. Not called when `value` changes from outside.\n */\n onValueChange?: (value: number | null) => void;\n /** Round to this many decimals on commit, and show at most this many (trailing\n * zeros are trimmed: `digits={2}` shows 12.5, not 12.50). Omit for no rounding. */\n digits?: number;\n /** Clamp the committed number into `[min, max]`. Applied after rounding. */\n min?: number;\n max?: number;\n /** An emptied field commits `null`. Without it an empty field is not an answer\n * and snaps back to the last value, like any other unparsable text. */\n nullable?: boolean;\n /** The unit — \"mm\", \"%\", \"km/h\". */\n unit?: ReactNode;\n /** Where `unit` goes: `\"suffix\"` (default) is NumberInput's read-out at the\n * field's end edge; `\"label\"` appends it to the label as \"Label (unit)\", for rows\n * of narrow fields where a suffix would eat the digits. */\n unitPlacement?: \"suffix\" | \"label\";\n /** BCP 47 tag for the decimal mark, else the `<UiKitProvider locale>`, else the\n * runtime's. */\n locale?: string;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`: shown under the field, attached via\n * `aria-describedby`, implies `invalid`. */\n error?: ReactNode;\n /** Merged with the error's id. Accepted so a form library's control slot (which\n * injects `id` / `aria-describedby` / `aria-invalid`) can wrap this directly. */\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n}\n\n/** The locale's decimal mark, as far as this field can type it: \",\" or \".\". A mark\n * outside those two (Arabic's \"٫\") is not something {@link sanitizeLive} lets\n * through, so those locales keep the dot rather than get a field that eats keys. */\nfunction decimalMark(locale: string | undefined): \",\" | \".\" {\n try {\n const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\");\n return part?.value === \",\" ? \",\" : \".\";\n } catch {\n // An invalid tag throws a RangeError; a typo in a locale prop must not take the\n // form down with it.\n return \".\";\n }\n}\n\n/** What the draft reads as while it is being typed — see `onValueChange`. `undefined`\n * is \"not a number yet\", distinct from `null` (empty). */\nfunction parseLive(text: string): number | null | undefined {\n const t = text.trim().replace(/,/g, \".\");\n if (t === \"\") return null;\n if (/^-?\\d*\\.?\\d*$/.test(t)) {\n // \"-\", \".\" and \"-.\" pass the pattern and are no number yet.\n if (!/\\d/.test(t)) return undefined;\n const n = Number(t);\n return Number.isFinite(n) ? n : undefined;\n }\n return evaluateExpression(t) ?? undefined;\n}\n\n/** `toFixed` rounding rather than `Math.round(n * 10^d)`, which turns 1.005 into\n * 1.00 by way of 100.49999999999999 — the same choice the lenkbank field made. */\nfunction roundTo(n: number, digits: number | undefined): number {\n if (digits === undefined) return n;\n const d = Math.min(Math.max(Math.trunc(digits), 0), 100);\n const rounded = Number(n.toFixed(d));\n return Number.isFinite(rounded) ? rounded : n;\n}\n\n// The error line under a field — the same type as `ui.tsx`'s (module-private) one,\n// so a NumberField's message is indistinguishable from an Input's.\nconst FIELD_ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\n/**\n * A number field whose value IS a number: {@link NumberInput}'s text, calculator and\n * numpad, with the parse/commit/revert loop every consumer was writing on top of it.\n *\n * WHY THE DRAFT. Half-typed values — \"-\", \"1,\", \"12+\" — are not numbers, and\n * reformatting them mid-keystroke moves the caret and fights the typist; committing\n * per keystroke would also fire whatever a commit fires (a recalculation, a save) for\n * every meaningless intermediate. So the field keeps a draft STRING while it is being\n * typed and resolves it on blur/Enter:\n *\n * - a number (or a calculation, \"2700+50\") → rounded to `digits`, clamped into\n * `[min, max]`, committed, and the draft re-rendered from the result;\n * - empty → `null` when `nullable`, otherwise the same as the next case — `Number(\"\")`\n * is 0, and clearing a wheelbase and tabbing away must not set it to zero;\n * - anything else unparsable → the draft SNAPS BACK to the last committed value\n * rather than sending NaN on for a server to reject.\n *\n * The draft follows `value` when it changes from outside (a reset, a loaded record);\n * a change that came from here arrives back identical and is a no-op.\n *\n * LIVE MODE. A calculator that recomputes per keystroke used to have to pick: this\n * field (limits, rounding, but a number only on blur) or a bare NumberInput (every\n * keystroke, but a string and no limits). `onValueChange` is the per-keystroke\n * channel here, next to the commit rather than instead of it: live numbers for the\n * read-out, `onCommit` for the save. It lives on this field rather than as\n * `min`/`max`/`digits` on NumberInput because parsing, the locale's mark and the\n * limits are what this field already is — a second copy on the string field would be\n * a second, subtly different NumberField.\n *\n * STEPS. `step` (with `min`/`max` as bounds) turns on ArrowUp/ArrowDown and PageUp/\n * PageDown (×10) — see NumberInput's `step`. A step is a draft change like a\n * keystroke: `onValueChange` hears it at once, `onCommit` on blur/Enter. Pick a\n * `step` that `digits` can show (0.25 needs `digits={2}`), or the commit rounds it.\n *\n * LOCALE. The decimal mark is the locale's: a German user sees and types \"1,5\".\n * NumberInput normalises every keystroke to a dot (so its evaluator reads one\n * alphabet); this field maps the dot back before the draft is shown, which also\n * means the numpad's \".\" key types the mark the field is displaying. There is no\n * digit grouping — \"1.000\" would read as a thousand in German and as one in English,\n * and a field that parses its own display back must not have that ambiguity.\n */\nexport function NumberField({\n value,\n onCommit,\n onValueChange,\n digits,\n min,\n max,\n nullable = false,\n unit,\n unitPlacement = \"suffix\",\n locale: localeProp,\n label,\n invalid,\n error,\n id,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n ...rest\n}: NumberFieldProps) {\n const locale = useKitLocale(localeProp);\n const mark = decimalMark(locale);\n const localize = (text: string) => (mark === \",\" ? text.replace(/\\./g, \",\") : text);\n // `formatResult` for fixed notation: `String(1e-7)` is \"1e-7\", which the field\n // would read back as 17 (see calc.ts).\n const format = (n: number | null) =>\n n === null || !Number.isFinite(n) ? \"\" : localize(formatResult(roundTo(n, digits)));\n\n const [draft, setDraft] = useState(() => format(value));\n // Everything the displayed text depends on, not just `value`: switching the\n // provider's language or a field's `digits` has to re-render the draft too.\n const [seen, setSeen] = useState({ value, mark, digits });\n // The last number handed to `onCommit`, so Enter-then-blur (both commit) and a\n // parent that does not echo the value back cannot commit the same number twice.\n // State rather than a ref because the external-change branch below resets it\n // during render, and a ref may not be written there.\n const [committed, setCommitted] = useState(value);\n // The last number handed to `onValueChange` — state for the same reason.\n const [reported, setReported] = useState(value);\n\n // Adjust state during render rather than in an effect: React re-renders at once\n // with the new draft, before anything is painted, so there is no frame showing\n // the old text.\n if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {\n setSeen({ value, mark, digits });\n // Live mode echoing back what it was just told: the draft already says it, in\n // the typist's own spelling (\"1,\" or \"12+5\"), and reformatting would fight them.\n // Only a changed `value` qualifies — a new mark or `digits` still re-renders.\n const echo =\n onValueChange !== undefined && value === reported && seen.mark === mark && seen.digits === digits;\n if (!echo) {\n setDraft(format(value));\n setCommitted(value);\n setReported(value);\n }\n }\n\n const report = (next: number | null) => {\n if (next === reported) return;\n setReported(next);\n onValueChange?.(next);\n };\n\n const onText = (text: string) => {\n setDraft(localize(text));\n const live = parseLive(text);\n if (live !== undefined) report(live);\n };\n\n const commit = (text: string) => {\n const trimmed = text.trim();\n let next: number | null;\n const snapBack = () => {\n setDraft(format(committed));\n report(committed);\n };\n if (trimmed === \"\") {\n if (!nullable) return snapBack();\n next = null;\n } else {\n // `text` is NumberInput's committed form: an evaluated calculation, dot-decimal.\n // What survives its digits-only fallback can still be \"-\" or \".\" alone.\n const parsed = Number(trimmed.replace(/,/g, \".\"));\n if (!Number.isFinite(parsed)) return snapBack();\n next = roundTo(parsed, digits);\n if (min !== undefined) next = Math.max(min, next);\n if (max !== undefined) next = Math.min(max, next);\n }\n // Always re-render the draft from the result — \"007\" becomes \"7\", a clamped 500\n // shows the 100 it was clamped to — even when there is nothing new to commit.\n setDraft(format(next));\n report(next);\n if (next !== committed) {\n setCommitted(next);\n onCommit(next);\n }\n };\n\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const errorId = useId();\n // `null`/`false`/`\"\"` are what `touched && errors.x` evaluates to on the happy path.\n const hasError = error !== undefined && error !== null && error !== false && error !== \"\";\n const describedBy = [ariaDescribedBy, hasError ? errorId : undefined].filter(Boolean).join(\" \");\n\n // NumberInput takes no `aria-describedby` of its own, so the reference is set on\n // its <input> directly, found by the id this field hands it. React never renders\n // that attribute there, so nothing reconciles it away again.\n useEffect(() => {\n const input = document.getElementById(fieldId);\n if (!input) return;\n if (describedBy) input.setAttribute(\"aria-describedby\", describedBy);\n else input.removeAttribute(\"aria-describedby\");\n }, [fieldId, describedBy]);\n\n const labelWithUnit =\n unit !== undefined && unitPlacement === \"label\"\n ? label === undefined || label === \"\"\n ? unit\n : typeof label === \"string\" && typeof unit === \"string\"\n ? `${label} (${unit})`\n : (\n <>\n {label} ({unit})\n </>\n )\n : label;\n\n const field = (\n <NumberInput\n {...rest}\n id={fieldId}\n label={labelWithUnit}\n value={draft}\n onChange={onText}\n onCommit={commit}\n min={min}\n max={max}\n suffix={unitPlacement === \"suffix\" ? unit : undefined}\n invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === \"true\"}\n />\n );\n // Same shape as Input's FieldGroup: no wrapper at all without a message, so adding\n // `error` support changed nothing about the DOM a flex row lays out.\n if (!hasError) return field;\n return (\n <div>\n {field}\n <p id={errorId} className={FIELD_ERROR_CLASS}>\n {error}\n </p>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -20,6 +20,13 @@ import './sparkline.js';
20
20
  import './stat-tile.js';
21
21
  import './signature-pad.js';
22
22
  import './password-strength.js';
23
+ import './danger-confirm.js';
24
+ import './swatch-picker.js';
25
+ import './tile-radio.js';
26
+ import './icon-picker.js';
27
+ import './dialog-frame.js';
28
+ import './modal.js';
29
+ import './file-button.js';
23
30
 
24
31
  interface NumberInputProps {
25
32
  value: string;
@@ -87,7 +94,33 @@ interface NumberInputProps {
87
94
  * every value reads as part of the number.
88
95
  */
89
96
  suffix?: ReactNode;
97
+ /**
98
+ * Turns on the step keys: ArrowUp / ArrowDown add or subtract `step`, PageUp /
99
+ * PageDown ten of them. The result lands on the grid `min + k × step` (or `0 + k ×
100
+ * step` without a `min`), so 3.1 with `step={0.25}` goes up to 3.25, not 3.35 —
101
+ * what a native number input does. Decimal-exact: `0.1 + 0.2` steps to "0.3".
102
+ *
103
+ * A step changes the text through `onChange` like a keystroke would; `onCommit`
104
+ * still fires on blur/Enter. Without `step` the arrow keys move the caret as before.
105
+ */
106
+ step?: number;
107
+ /** Bounds for the step keys only — a step never leaves `[min, max]`. This field's
108
+ * value is a STRING, so typed text is not clamped: {@link NumberField} is the
109
+ * numeric field that clamps what is typed as well. */
110
+ min?: number;
111
+ max?: number;
90
112
  }
113
+ /**
114
+ * `current` moved by `count` steps (negative = down), on the grid `origin + k × step`
115
+ * and clamped into `[min, max]`. An off-grid value moves to the NEXT grid point in
116
+ * that direction first, so one press is never more than one step.
117
+ *
118
+ * The grid index is found by division and the result rebuilt as `origin + k × step`,
119
+ * then cut to the decimals `step` and `origin` are written with — which is where
120
+ * binary noise goes: `0.1 × 3` is 0.30000000000000004, and `toFixed(1)` of it is
121
+ * "0.3". Summing steps would accumulate that noise press by press instead.
122
+ */
123
+ declare function stepNumber(current: number | null, count: number, step: number, min?: number, max?: number): number;
91
124
  /**
92
125
  * A numeric text field with a built-in calculator: type a calculation straight
93
126
  * in (e.g. "12+5", evaluated on blur/Enter) or click the trailing calculator
@@ -95,6 +128,6 @@ interface NumberInputProps {
95
128
  * {@link AmountInput} — the currency-free counterpart for budgets, goals,
96
129
  * invoice totals and split lines.
97
130
  */
98
- declare function NumberInput({ value, onChange, onCommit, label, ariaLabel, labels, placeholder, disabled, autoFocus, className, inputClassName, id, calculator, variant, hint, invalid, suffix, }: NumberInputProps): react.JSX.Element;
131
+ declare function NumberInput({ value, onChange, onCommit, label, ariaLabel, labels, placeholder, disabled, autoFocus, className, inputClassName, id, calculator, variant, hint, invalid, suffix, step, min, max, }: NumberInputProps): react.JSX.Element;
99
132
 
100
- export { NumberInput };
133
+ export { NumberInput, stepNumber };
@@ -5,8 +5,24 @@ import { CalculatorButton } from "./calculator.js";
5
5
  import { NumberPadSheet } from "./numpad-sheet.js";
6
6
  import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from "./ui.js";
7
7
  import { cn } from "../lib/cn.js";
8
- import { commitExpression, sanitizeLive } from "../lib/calc.js";
8
+ import { commitExpression, formatResult, sanitizeLive } from "../lib/calc.js";
9
9
  import { useMediaQuery } from "../hooks/use-media-query.js";
10
+ function decimalsOf(n) {
11
+ const text = formatResult(n);
12
+ const dot = text.indexOf(".");
13
+ return dot === -1 ? 0 : text.length - dot - 1;
14
+ }
15
+ function stepNumber(current, count, step, min, max) {
16
+ const clamp = (n) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));
17
+ if (current === null || !Number.isFinite(current)) return clamp(0);
18
+ const origin = min !== void 0 && Number.isFinite(min) ? min : 0;
19
+ const raw = (current - origin) / step;
20
+ const nearest = Math.round(raw);
21
+ const onGrid = Math.abs(raw - nearest) < 1e-9;
22
+ const k = onGrid ? nearest + count : count > 0 ? Math.ceil(raw) + count - 1 : Math.floor(raw) + count + 1;
23
+ const places = Math.min(Math.max(decimalsOf(step), decimalsOf(origin)), 20);
24
+ return clamp(Number((origin + k * step).toFixed(places)));
25
+ }
10
26
  function NumberInput({
11
27
  value,
12
28
  onChange,
@@ -24,7 +40,10 @@ function NumberInput({
24
40
  variant = "field",
25
41
  hint,
26
42
  invalid,
27
- suffix
43
+ suffix,
44
+ step,
45
+ min,
46
+ max
28
47
  }) {
29
48
  const generatedId = useId();
30
49
  const fieldId = id ?? generatedId;
@@ -40,8 +59,19 @@ function NumberInput({
40
59
  if (next !== value) onChange(next);
41
60
  onCommit?.(next);
42
61
  };
62
+ const stepBy = (count) => {
63
+ const resolved = commitExpression(value).trim();
64
+ const n = resolved === "" ? NaN : Number(resolved);
65
+ const next = formatResult(stepNumber(Number.isFinite(n) ? n : null, count, step, min, max));
66
+ if (next !== value) onChange(next);
67
+ };
43
68
  const onKeyDown = (e) => {
44
- if (e.key === "Enter") commit();
69
+ if (e.key === "Enter") return commit();
70
+ if (!step || !(step > 0) || !Number.isFinite(step) || e.altKey || e.ctrlKey || e.metaKey) return;
71
+ const count = e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : e.key === "PageUp" ? 10 : e.key === "PageDown" ? -10 : 0;
72
+ if (count === 0) return;
73
+ e.preventDefault();
74
+ stepBy(count);
45
75
  };
46
76
  return /* @__PURE__ */ jsxs(
47
77
  FloatingField,
@@ -119,6 +149,7 @@ function NumberInput({
119
149
  );
120
150
  }
121
151
  export {
122
- NumberInput
152
+ NumberInput,
153
+ stepNumber
123
154
  };
124
155
  //# sourceMappingURL=number-input.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/number-input.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { CalculatorButton, type CalculatorButtonLabels } from \"./calculator\";\nimport { NumberPadSheet, type NumberPadSheetLabels } from \"./numpad-sheet\";\nimport { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { commitExpression, sanitizeLive } from \"../lib/calc\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\n\ninterface NumberInputProps {\n value: string;\n /** Fired on every keystroke with the raw text (comma → dot, operators kept so\n * a typed expression survives). */\n onChange: (value: string) => void;\n /** Fired on blur/Enter with the *evaluated* value — use this for save-on-blur\n * fields so the save always sees the resolved number, never \"10+5\". */\n onCommit?: (value: string) => void;\n label?: ReactNode;\n ariaLabel?: string;\n /** Names for the calculator this field renders — its trigger, and the controls\n * inside the popover — and for the numpad sheet it opens on a phone. Overrides\n * for THIS field only: both resolve the `calculator` namespace of\n * `<UiKitProvider labels>` themselves, then fall back to English.\n *\n * `pad` is not optional plumbing: this field renders a {@link NumberPadSheet}\n * exactly as {@link AmountInput} does, but had no way to pass it anything, so\n * the SAME keypad announced itself in German when a money field opened it and in\n * English when a goal target did. */\n labels?: {\n calculatorTrigger?: string;\n calculator?: CalculatorButtonLabels;\n pad?: NumberPadSheetLabels;\n };\n placeholder?: string;\n disabled?: boolean;\n autoFocus?: boolean;\n /** Wrapper class. */\n className?: string;\n /** Overrides the input's own styling (merged after the field base). */\n inputClassName?: string;\n id?: string;\n /** Show the calculator-popover trigger (default true). Set false for fields in\n * an ephemeral close-on-blur editor, where clicking the icon would blur away\n * and tear the editor down before the popover could open — inline typing\n * (e.g. \"200+50\" → Enter) still evaluates there. */\n calculator?: boolean;\n /** {@link FIELD_DISPLAY} — on a phone, the figure at display size with the field\n * chrome dropped. The currency-free half of the same treatment `AmountInput`\n * carries, for a dialog whose whole point is the one number (a budget goal's\n * target); not for a field among many, and never for the compact inline\n * editors this control also serves. */\n variant?: \"field\" | \"display\";\n /** A {@link FieldHint} \"?\" on the label's own line, beside the label rather\n * than at the far end of the strip — the far end is where the calculator\n * lives, and a hint placed there landed on top of it (steering-design\n * feedback #48). */\n hint?: ReactNode;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /**\n * A static unit shown at the field's right edge — \"%\", \"kg\", \"km/h\".\n *\n * The read-out half of {@link AmountInput}'s currency chip, and it exists for the\n * same reason that one does: a unit belongs to the FIELD, not to the text, so\n * typing it is a way to get it into the value. Keksdose had a percentage spelled\n * three different ways across three screens — `<Input type=\"number\" step=\"0.01\">`\n * for a loan rate, `step=\"0.1\"` for a tax rate, `inputMode=\"decimal\"` for a VAT\n * rate — and none of the three showed a \"%\" anywhere near the box, so what the\n * digits meant was a question the label alone had to answer.\n *\n * `aria-hidden`, like the currency read-out: it is a property of the field that\n * the label already names (\"Interest rate (%)\"), and announcing it again after\n * every value reads as part of the number.\n */\n suffix?: ReactNode;\n}\n\n/**\n * A numeric text field with a built-in calculator: type a calculation straight\n * in (e.g. \"12+5\", evaluated on blur/Enter) or click the trailing calculator\n * icon for a keypad. String `value`/`onChange` contract, mirroring\n * {@link AmountInput} — the currency-free counterpart for budgets, goals,\n * invoice totals and split lines.\n */\nexport function NumberInput({\n value,\n onChange,\n onCommit,\n label,\n ariaLabel,\n labels,\n placeholder,\n disabled,\n autoFocus,\n className,\n inputClassName,\n id,\n calculator = true,\n variant = \"field\",\n hint,\n invalid,\n suffix,\n}: NumberInputProps) {\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const labelled = label !== undefined;\n // On phones we suppress the OS keyboard (inputMode=\"none\" below) for our own\n // calculator numpad, so the desktop popover trigger is hidden (feedback #334).\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n const asDisplay = isMobile && variant === \"display\";\n const showCalc = calculator && !disabled && !isMobile;\n const [focused, setFocused] = useState(false);\n // On mobile, focusing opens the numpad bottom sheet instead of the native\n // keyboard (#334); the ref lets its \"Done\" blur → commit + close.\n const showNumpad = isMobile && !disabled && focused;\n const inputRef = useRef<HTMLInputElement>(null);\n\n const commit = () => {\n const next = commitExpression(value);\n if (next !== value) onChange(next);\n onCommit?.(next);\n };\n\n const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") commit();\n };\n\n return (\n <FloatingField\n className={cn(\"w-full\", className)}\n htmlFor={fieldId}\n label={label}\n srOnlyLabel={asDisplay}\n hint={hint}\n >\n <input\n ref={inputRef}\n id={fieldId}\n aria-label={ariaLabel}\n type=\"text\"\n // Mobile: suppress the OS keyboard so the numpad sheet owns entry (field\n // keeps focus/caret); desktop keeps the native decimal keypad.\n inputMode={isMobile ? \"none\" : \"decimal\"}\n autoComplete=\"off\"\n autoFocus={autoFocus}\n disabled={disabled}\n // Display mode has no floating label to feed the blank-placeholder trick,\n // and an empty borderless figure shows nothing — so it keeps whatever\n // placeholder the caller gave, falling back to a zero to aim at.\n placeholder={asDisplay ? (placeholder ?? \"0\") : labelled ? \" \" : placeholder}\n value={value}\n onChange={(e) => onChange(sanitizeLive(e.target.value))}\n onFocus={() => setFocused(true)}\n onBlur={() => {\n commit();\n setFocused(false);\n }}\n onKeyDown={onKeyDown}\n aria-invalid={invalid || undefined}\n // The right-padding sits last so it always wins over an inputClassName that\n // sets its own px. Both trailing controls can be on at once, so the room\n // they need is reserved together rather than by whichever happens to render:\n // the calculator is ~36px and a short unit ~24px, and a field that reserved\n // only one of them would let the digits run under the other.\n className={cn(\n asDisplay\n ? cn(FIELD_DISPLAY, \"text-4xl font-semibold leading-tight tracking-tight tabular-nums\")\n : labelled\n ? FLOATING_INPUT_CLASS\n : FIELD_BASE,\n inputClassName,\n showCalc && suffix !== undefined ? \"pr-16\" : showCalc ? \"pr-9\" : suffix !== undefined ? \"pr-8\" : undefined,\n invalid && FIELD_INVALID,\n )}\n />\n {(showCalc || suffix !== undefined) && (\n // One flex track for both, so the unit and the calculator sit side by side\n // instead of stacking on the same corner — AmountInput's arrangement, which\n // has carried a chip and a calculator together since #430.\n <div className=\"absolute inset-y-0 right-0 flex items-center\">\n {showCalc && (\n <CalculatorButton\n value={value}\n onChange={onChange}\n className=\"self-stretch px-2.5\"\n ariaLabel={labels?.calculatorTrigger}\n labels={labels?.calculator}\n />\n )}\n {suffix !== undefined && (\n <span\n aria-hidden\n className=\"pointer-events-none pr-3 text-xs font-medium text-[var(--text-muted)]\"\n >\n {suffix}\n </span>\n )}\n </div>\n )}\n {showNumpad && (\n <NumberPadSheet\n value={value}\n onChange={onChange}\n onDone={() => inputRef.current?.blur()}\n label={label}\n labels={labels?.pad}\n />\n )}\n </FloatingField>\n );\n}\n"],"mappings":";AAuIM,cA4CE,YA5CF;AAvIN,SAAS,OAAO,QAAQ,gBAAgB;AAExC,SAAS,wBAAqD;AAC9D,SAAS,sBAAiD;AAC1D,SAAS,YAAY,eAAe,eAAe,sBAAsB,eAAe,mBAAmB;AAC3G,SAAS,UAAU;AACnB,SAAS,kBAAkB,oBAAoB;AAC/C,SAAS,qBAAqB;AA6EvB,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb,UAAU;AAAA,EACV;AAAA,EACA;AAAA,EACA;AACF,GAAqB;AACnB,QAAM,cAAc,MAAM;AAC1B,QAAM,UAAU,MAAM;AACtB,QAAM,WAAW,UAAU;AAG3B,QAAM,WAAW,cAAc,aAAa,KAAK;AACjD,QAAM,YAAY,YAAY,YAAY;AAC1C,QAAM,WAAW,cAAc,CAAC,YAAY,CAAC;AAC7C,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAG5C,QAAM,aAAa,YAAY,CAAC,YAAY;AAC5C,QAAM,WAAW,OAAyB,IAAI;AAE9C,QAAM,SAAS,MAAM;AACnB,UAAM,OAAO,iBAAiB,KAAK;AACnC,QAAI,SAAS,MAAO,UAAS,IAAI;AACjC,eAAW,IAAI;AAAA,EACjB;AAEA,QAAM,YAAY,CAAC,MAAuC;AACxD,QAAI,EAAE,QAAQ,QAAS,QAAO;AAAA,EAChC;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,UAAU,SAAS;AAAA,MACjC,SAAS;AAAA,MACT;AAAA,MACA,aAAa;AAAA,MACb;AAAA,MAEA;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,IAAI;AAAA,YACJ,cAAY;AAAA,YACZ,MAAK;AAAA,YAGL,WAAW,WAAW,SAAS;AAAA,YAC/B,cAAa;AAAA,YACb;AAAA,YACA;AAAA,YAIA,aAAa,YAAa,eAAe,MAAO,WAAW,MAAM;AAAA,YACjE;AAAA,YACA,UAAU,CAAC,MAAM,SAAS,aAAa,EAAE,OAAO,KAAK,CAAC;AAAA,YACtD,SAAS,MAAM,WAAW,IAAI;AAAA,YAC9B,QAAQ,MAAM;AACZ,qBAAO;AACP,yBAAW,KAAK;AAAA,YAClB;AAAA,YACA;AAAA,YACA,gBAAc,WAAW;AAAA,YAMzB,WAAW;AAAA,cACT,YACI,GAAG,eAAe,kEAAkE,IACpF,WACE,uBACA;AAAA,cACN;AAAA,cACA,YAAY,WAAW,SAAY,UAAU,WAAW,SAAS,WAAW,SAAY,SAAS;AAAA,cACjG,WAAW;AAAA,YACb;AAAA;AAAA,QACF;AAAA,SACE,YAAY,WAAW;AAAA;AAAA;AAAA,QAIvB,qBAAC,SAAI,WAAU,gDACZ;AAAA,sBACC;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,WAAU;AAAA,cACV,WAAW,QAAQ;AAAA,cACnB,QAAQ,QAAQ;AAAA;AAAA,UAClB;AAAA,UAED,WAAW,UACV;AAAA,YAAC;AAAA;AAAA,cACC,eAAW;AAAA,cACX,WAAU;AAAA,cAET;AAAA;AAAA,UACH;AAAA,WAEJ;AAAA,QAED,cACC;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA;AAAA,YACA,QAAQ,MAAM,SAAS,SAAS,KAAK;AAAA,YACrC;AAAA,YACA,QAAQ,QAAQ;AAAA;AAAA,QAClB;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/number-input.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { CalculatorButton, type CalculatorButtonLabels } from \"./calculator\";\nimport { NumberPadSheet, type NumberPadSheetLabels } from \"./numpad-sheet\";\nimport { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { commitExpression, formatResult, sanitizeLive } from \"../lib/calc\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\n\ninterface NumberInputProps {\n value: string;\n /** Fired on every keystroke with the raw text (comma → dot, operators kept so\n * a typed expression survives). */\n onChange: (value: string) => void;\n /** Fired on blur/Enter with the *evaluated* value — use this for save-on-blur\n * fields so the save always sees the resolved number, never \"10+5\". */\n onCommit?: (value: string) => void;\n label?: ReactNode;\n ariaLabel?: string;\n /** Names for the calculator this field renders — its trigger, and the controls\n * inside the popover — and for the numpad sheet it opens on a phone. Overrides\n * for THIS field only: both resolve the `calculator` namespace of\n * `<UiKitProvider labels>` themselves, then fall back to English.\n *\n * `pad` is not optional plumbing: this field renders a {@link NumberPadSheet}\n * exactly as {@link AmountInput} does, but had no way to pass it anything, so\n * the SAME keypad announced itself in German when a money field opened it and in\n * English when a goal target did. */\n labels?: {\n calculatorTrigger?: string;\n calculator?: CalculatorButtonLabels;\n pad?: NumberPadSheetLabels;\n };\n placeholder?: string;\n disabled?: boolean;\n autoFocus?: boolean;\n /** Wrapper class. */\n className?: string;\n /** Overrides the input's own styling (merged after the field base). */\n inputClassName?: string;\n id?: string;\n /** Show the calculator-popover trigger (default true). Set false for fields in\n * an ephemeral close-on-blur editor, where clicking the icon would blur away\n * and tear the editor down before the popover could open — inline typing\n * (e.g. \"200+50\" → Enter) still evaluates there. */\n calculator?: boolean;\n /** {@link FIELD_DISPLAY} — on a phone, the figure at display size with the field\n * chrome dropped. The currency-free half of the same treatment `AmountInput`\n * carries, for a dialog whose whole point is the one number (a budget goal's\n * target); not for a field among many, and never for the compact inline\n * editors this control also serves. */\n variant?: \"field\" | \"display\";\n /** A {@link FieldHint} \"?\" on the label's own line, beside the label rather\n * than at the far end of the strip — the far end is where the calculator\n * lives, and a hint placed there landed on top of it (steering-design\n * feedback #48). */\n hint?: ReactNode;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /**\n * A static unit shown at the field's right edge — \"%\", \"kg\", \"km/h\".\n *\n * The read-out half of {@link AmountInput}'s currency chip, and it exists for the\n * same reason that one does: a unit belongs to the FIELD, not to the text, so\n * typing it is a way to get it into the value. Keksdose had a percentage spelled\n * three different ways across three screens — `<Input type=\"number\" step=\"0.01\">`\n * for a loan rate, `step=\"0.1\"` for a tax rate, `inputMode=\"decimal\"` for a VAT\n * rate — and none of the three showed a \"%\" anywhere near the box, so what the\n * digits meant was a question the label alone had to answer.\n *\n * `aria-hidden`, like the currency read-out: it is a property of the field that\n * the label already names (\"Interest rate (%)\"), and announcing it again after\n * every value reads as part of the number.\n */\n suffix?: ReactNode;\n /**\n * Turns on the step keys: ArrowUp / ArrowDown add or subtract `step`, PageUp /\n * PageDown ten of them. The result lands on the grid `min + k × step` (or `0 + k ×\n * step` without a `min`), so 3.1 with `step={0.25}` goes up to 3.25, not 3.35 —\n * what a native number input does. Decimal-exact: `0.1 + 0.2` steps to \"0.3\".\n *\n * A step changes the text through `onChange` like a keystroke would; `onCommit`\n * still fires on blur/Enter. Without `step` the arrow keys move the caret as before.\n */\n step?: number;\n /** Bounds for the step keys only — a step never leaves `[min, max]`. This field's\n * value is a STRING, so typed text is not clamped: {@link NumberField} is the\n * numeric field that clamps what is typed as well. */\n min?: number;\n max?: number;\n}\n\n/** Decimal places of `n` as written (\"0.25\" → 2, \"1e-7\" → 7), for decimal-exact\n * stepping. */\nfunction decimalsOf(n: number): number {\n const text = formatResult(n);\n const dot = text.indexOf(\".\");\n return dot === -1 ? 0 : text.length - dot - 1;\n}\n\n/**\n * `current` moved by `count` steps (negative = down), on the grid `origin + k × step`\n * and clamped into `[min, max]`. An off-grid value moves to the NEXT grid point in\n * that direction first, so one press is never more than one step.\n *\n * The grid index is found by division and the result rebuilt as `origin + k × step`,\n * then cut to the decimals `step` and `origin` are written with — which is where\n * binary noise goes: `0.1 × 3` is 0.30000000000000004, and `toFixed(1)` of it is\n * \"0.3\". Summing steps would accumulate that noise press by press instead.\n */\nexport function stepNumber(\n current: number | null,\n count: number,\n step: number,\n min?: number,\n max?: number,\n): number {\n const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));\n // Nothing to step from: land on the value nearest zero the bounds allow, rather\n // than on ±step — a room count with `min={1}` goes to 1, a rate to 0.\n if (current === null || !Number.isFinite(current)) return clamp(0);\n const origin = min !== undefined && Number.isFinite(min) ? min : 0;\n const raw = (current - origin) / step;\n const nearest = Math.round(raw);\n // \"On the grid\" within float tolerance: (0.3 - 0) / 0.1 is 2.9999999999999996.\n const onGrid = Math.abs(raw - nearest) < 1e-9;\n const k = onGrid\n ? nearest + count\n : count > 0\n ? Math.ceil(raw) + count - 1\n : Math.floor(raw) + count + 1;\n const places = Math.min(Math.max(decimalsOf(step), decimalsOf(origin)), 20);\n return clamp(Number((origin + k * step).toFixed(places)));\n}\n\n/**\n * A numeric text field with a built-in calculator: type a calculation straight\n * in (e.g. \"12+5\", evaluated on blur/Enter) or click the trailing calculator\n * icon for a keypad. String `value`/`onChange` contract, mirroring\n * {@link AmountInput} — the currency-free counterpart for budgets, goals,\n * invoice totals and split lines.\n */\nexport function NumberInput({\n value,\n onChange,\n onCommit,\n label,\n ariaLabel,\n labels,\n placeholder,\n disabled,\n autoFocus,\n className,\n inputClassName,\n id,\n calculator = true,\n variant = \"field\",\n hint,\n invalid,\n suffix,\n step,\n min,\n max,\n}: NumberInputProps) {\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const labelled = label !== undefined;\n // On phones we suppress the OS keyboard (inputMode=\"none\" below) for our own\n // calculator numpad, so the desktop popover trigger is hidden (feedback #334).\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n const asDisplay = isMobile && variant === \"display\";\n const showCalc = calculator && !disabled && !isMobile;\n const [focused, setFocused] = useState(false);\n // On mobile, focusing opens the numpad bottom sheet instead of the native\n // keyboard (#334); the ref lets its \"Done\" blur → commit + close.\n const showNumpad = isMobile && !disabled && focused;\n const inputRef = useRef<HTMLInputElement>(null);\n\n const commit = () => {\n const next = commitExpression(value);\n if (next !== value) onChange(next);\n onCommit?.(next);\n };\n\n const stepBy = (count: number) => {\n // The step starts from the text as a commit would read it, so \"12+5\" steps from 17.\n const resolved = commitExpression(value).trim();\n const n = resolved === \"\" ? NaN : Number(resolved);\n const next = formatResult(stepNumber(Number.isFinite(n) ? n : null, count, step as number, min, max));\n if (next !== value) onChange(next);\n };\n\n const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") return commit();\n // A zero, negative or non-finite step is no step: the keys stay the caret's.\n if (!step || !(step > 0) || !Number.isFinite(step) || e.altKey || e.ctrlKey || e.metaKey) return;\n const count =\n e.key === \"ArrowUp\" ? 1 : e.key === \"ArrowDown\" ? -1 : e.key === \"PageUp\" ? 10 : e.key === \"PageDown\" ? -10 : 0;\n if (count === 0) return;\n // Otherwise ArrowUp/Down also jump the caret to the start/end of the text.\n e.preventDefault();\n stepBy(count);\n };\n\n return (\n <FloatingField\n className={cn(\"w-full\", className)}\n htmlFor={fieldId}\n label={label}\n srOnlyLabel={asDisplay}\n hint={hint}\n >\n <input\n ref={inputRef}\n id={fieldId}\n aria-label={ariaLabel}\n type=\"text\"\n // Mobile: suppress the OS keyboard so the numpad sheet owns entry (field\n // keeps focus/caret); desktop keeps the native decimal keypad.\n inputMode={isMobile ? \"none\" : \"decimal\"}\n autoComplete=\"off\"\n autoFocus={autoFocus}\n disabled={disabled}\n // Display mode has no floating label to feed the blank-placeholder trick,\n // and an empty borderless figure shows nothing — so it keeps whatever\n // placeholder the caller gave, falling back to a zero to aim at.\n placeholder={asDisplay ? (placeholder ?? \"0\") : labelled ? \" \" : placeholder}\n value={value}\n onChange={(e) => onChange(sanitizeLive(e.target.value))}\n onFocus={() => setFocused(true)}\n onBlur={() => {\n commit();\n setFocused(false);\n }}\n onKeyDown={onKeyDown}\n aria-invalid={invalid || undefined}\n // The right-padding sits last so it always wins over an inputClassName that\n // sets its own px. Both trailing controls can be on at once, so the room\n // they need is reserved together rather than by whichever happens to render:\n // the calculator is ~36px and a short unit ~24px, and a field that reserved\n // only one of them would let the digits run under the other.\n className={cn(\n asDisplay\n ? cn(FIELD_DISPLAY, \"text-4xl font-semibold leading-tight tracking-tight tabular-nums\")\n : labelled\n ? FLOATING_INPUT_CLASS\n : FIELD_BASE,\n inputClassName,\n showCalc && suffix !== undefined ? \"pr-16\" : showCalc ? \"pr-9\" : suffix !== undefined ? \"pr-8\" : undefined,\n invalid && FIELD_INVALID,\n )}\n />\n {(showCalc || suffix !== undefined) && (\n // One flex track for both, so the unit and the calculator sit side by side\n // instead of stacking on the same corner — AmountInput's arrangement, which\n // has carried a chip and a calculator together since #430.\n <div className=\"absolute inset-y-0 right-0 flex items-center\">\n {showCalc && (\n <CalculatorButton\n value={value}\n onChange={onChange}\n className=\"self-stretch px-2.5\"\n ariaLabel={labels?.calculatorTrigger}\n labels={labels?.calculator}\n />\n )}\n {suffix !== undefined && (\n <span\n aria-hidden\n className=\"pointer-events-none pr-3 text-xs font-medium text-[var(--text-muted)]\"\n >\n {suffix}\n </span>\n )}\n </div>\n )}\n {showNumpad && (\n <NumberPadSheet\n value={value}\n onChange={onChange}\n onDone={() => inputRef.current?.blur()}\n label={label}\n labels={labels?.pad}\n />\n )}\n </FloatingField>\n );\n}\n"],"mappings":"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","names":[]}
@@ -18,6 +18,13 @@ import './sparkline.js';
18
18
  import './stat-tile.js';
19
19
  import './signature-pad.js';
20
20
  import './password-strength.js';
21
+ import './danger-confirm.js';
22
+ import './swatch-picker.js';
23
+ import './tile-radio.js';
24
+ import './icon-picker.js';
25
+ import './dialog-frame.js';
26
+ import './modal.js';
27
+ import './file-button.js';
21
28
 
22
29
  /**
23
30
  * A full calculator keypad rendered as a bottom sheet — the mobile counterpart to
@@ -20,6 +20,22 @@ interface SearchFieldOwnProps extends Omit<InputHTMLAttributes<HTMLInputElement>
20
20
  * decision, not a default: a filter you can type into and not untype is the
21
21
  * complaint that produced the button on three of these four screens. */
22
22
  clearLabel?: string;
23
+ /**
24
+ * `"field"` (default): the bordered filter box that sits on a page.
25
+ *
26
+ * `"inline"`: no box at all — no border, no fill, no padding of its own — and no
27
+ * type size of its own either, so it takes the size of the header it sits in.
28
+ * For a search that IS the header: a command-palette sheet, a picker's top line
29
+ * (Keksdose's transaction search, which lost its `text-base` headline to a
30
+ * bordered `text-sm` box when it moved onto this component). The container is
31
+ * what frames it, so give the container the focus cue if it needs one
32
+ * (`focus-within:`); the caret is the field's own.
33
+ */
34
+ variant?: "field" | "inline";
35
+ /** Classes for the `<input>` itself. `className` styles the wrapper — the box the
36
+ * icon and the clear button are positioned against — so it could set a width
37
+ * and nothing else. See {@link Input}'s `inputClassName`. */
38
+ inputClassName?: string;
23
39
  }
24
40
  /**
25
41
  * One of the two spellings is REQUIRED, and the union is how that survives the
@@ -4,15 +4,33 @@ import { forwardRef, useRef } from "react";
4
4
  import { Search, X } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
6
  import { FIELD_BASE } from "./ui.js";
7
- const SearchField = forwardRef(function SearchField2({ value, onChange, label, clearLabel, className, placeholder, "aria-label": ariaLabel, ...rest }, ref) {
7
+ const SEARCH_INLINE = "block w-full min-w-0 border-0 bg-transparent py-1 text-[var(--text-primary)] shadow-none placeholder:text-[var(--text-placeholder)] focus:outline-none focus:ring-0";
8
+ const SearchField = forwardRef(function SearchField2({
9
+ value,
10
+ onChange,
11
+ label,
12
+ clearLabel,
13
+ className,
14
+ inputClassName,
15
+ placeholder,
16
+ variant = "field",
17
+ "aria-label": ariaLabel,
18
+ ...rest
19
+ }, ref) {
8
20
  const innerRef = useRef(null);
9
21
  const name = ariaLabel ?? label;
22
+ const inline = variant === "inline";
10
23
  return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
11
24
  /* @__PURE__ */ jsx(
12
25
  Search,
13
26
  {
14
27
  "aria-hidden": true,
15
- className: "pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]"
28
+ className: cn(
29
+ "pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
30
+ // Logical sides throughout, so a right-to-left page gets the icon at the
31
+ // start of the line and the clear button at its end.
32
+ inline ? "start-0" : "start-3"
33
+ )
16
34
  }
17
35
  ),
18
36
  /* @__PURE__ */ jsx(
@@ -35,12 +53,13 @@ const SearchField = forwardRef(function SearchField2({ value, onChange, label, c
35
53
  spellCheck: false,
36
54
  ...rest,
37
55
  className: cn(
38
- FIELD_BASE,
39
- "pl-9",
56
+ inline ? SEARCH_INLINE : FIELD_BASE,
57
+ inline ? "ps-7" : "ps-9",
40
58
  // Room for our own "×" only when there is one; a field that can't be
41
59
  // cleared has no reason to reserve the space.
42
- clearLabel === void 0 ? "pr-3" : "pr-9",
43
- "[&::-webkit-search-cancel-button]:appearance-none"
60
+ clearLabel === void 0 ? inline ? "pe-0" : "pe-3" : inline ? "pe-8" : "pe-9",
61
+ "[&::-webkit-search-cancel-button]:appearance-none",
62
+ inputClassName
44
63
  )
45
64
  }
46
65
  ),
@@ -53,7 +72,10 @@ const SearchField = forwardRef(function SearchField2({ value, onChange, label, c
53
72
  innerRef.current?.focus();
54
73
  },
55
74
  "aria-label": clearLabel,
56
- className: "absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]",
75
+ className: cn(
76
+ "absolute top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]",
77
+ inline ? "end-0" : "end-2"
78
+ ),
57
79
  children: /* @__PURE__ */ jsx(X, { className: "size-4" })
58
80
  }
59
81
  )
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/search-field.tsx"],"sourcesContent":["import { forwardRef, useRef } from \"react\";\nimport type { InputHTMLAttributes } from \"react\";\nimport { Search, X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FIELD_BASE } from \"./ui\";\n\ninterface SearchFieldOwnProps\n extends Omit<\n InputHTMLAttributes<HTMLInputElement>,\n \"value\" | \"onChange\" | \"type\" | \"aria-label\"\n > {\n value: string;\n /** The typed text. Emitted as a plain string, not an event: every caller of the\n * four this replaced wanted the string, and one of them held it in a URL param\n * rather than in state. */\n onChange: (value: string) => void;\n /**\n * The field's accessible name.\n *\n * @deprecated Pass `aria-label` instead — the DOM spelling this kit standardised on\n * when it found three names for one idea (`ariaLabel`, `aria-label`, and this).\n * Unchanged and still supported; it names the field only when `aria-label` is\n * absent, so a consumer can migrate one call site at a time.\n */\n label?: string;\n /** Names the clear button. Omit it and no clear button is rendered — which is a\n * decision, not a default: a filter you can type into and not untype is the\n * complaint that produced the button on three of these four screens. */\n clearLabel?: string;\n}\n\n/**\n * One of the two spellings is REQUIRED, and the union is how that survives the\n * deprecation. Making `label` merely optional would have been the easy change and the\n * wrong one: the whole reason this component exists is that two of the four boxes it\n * replaced were named by nothing but their placeholder — which disappears the moment\n * someone types, i.e. exactly when a screen-reader user is working in the field. A\n * caller now has a choice of spelling; it still has no option to pass neither.\n */\nexport type SearchFieldProps = SearchFieldOwnProps &\n ({ \"aria-label\": string; label?: string } | { label: string; \"aria-label\"?: string });\n\n/**\n * The page-level filter box: a search icon, a `type=\"search\"` input, and a clear\n * \"×\" that appears once there is something to clear.\n *\n * ## Why it is a component\n *\n * Keksdose had four of these and no two agreed. Two were character-identical\n * twenty-line copies (the settings catalogue filter and the tour list filter) —\n * the same absolute icon at `left-3`, the same `pl-9 pr-9`, the same\n * `[&::-webkit-search-cancel-button]:appearance-none`, the same conditional clear\n * button at `right-2` — differing only in which state setter they called. The\n * third (the admin support queue) was the same idea at `left-2`/`size-3.5`/`pl-7`\n * with no clear button and no `type=\"search\"`, so it had neither the `searchbox`\n * role nor a way to empty itself. The fourth (the bank picker) was a bare `<Input\n * placeholder=\"Search\">`: no icon, no clear, and no accessible name at all once\n * the placeholder went away.\n *\n * None of that is a design decision anyone made; it is four people solving the\n * same problem on four days. A shared component is how the fourth screen gets the\n * clear button the first one earned.\n *\n * ## The two X's\n *\n * `type=\"search\"` is what gives the field its `searchbox` role — and, in Chrome, a\n * NATIVE clear cross that paints right next to ours, so a non-empty box shows two\n * X's an inch apart. Ours is the one that survives: it carries an accessible name\n * and matches the app's other icon buttons, and it returns focus to the input so\n * the next query can be typed straight away.\n */\nexport const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(\n { value, onChange, label, clearLabel, className, placeholder, \"aria-label\": ariaLabel, ...rest },\n ref,\n) {\n const innerRef = useRef<HTMLInputElement>(null);\n // Whichever spelling the caller used. It is the accessible name AND the placeholder\n // fallback, so a box named only by `aria-label` still says what it is before anyone\n // has typed in it — the same deal `label` has always had.\n const name = ariaLabel ?? label;\n return (\n <div className={cn(\"relative\", className)}>\n <Search\n aria-hidden\n className=\"pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]\"\n />\n <input\n ref={(node) => {\n innerRef.current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) ref.current = node;\n }}\n type=\"search\"\n value={value}\n onChange={(e) => onChange(e.target.value)}\n aria-label={name}\n placeholder={placeholder ?? name}\n // Keksdose live #333: *\"Entering the search here — is that set as password?\n // … I got prompted for remembering my name or mail address\"*, typed into the\n // settings filter on a phone.\n //\n // A `type=\"search\"` input with no `name` and no `autocomplete` is a field\n // Chrome's autofill classifies by GUESSWORK, from the labels around it and\n // the other fields on the page — and a settings page has an email and a\n // password on it. Naming the field and declaring the intent is what takes it\n // out of that guess: `autocomplete=\"off\"` says it is not part of any profile,\n // and a `name` of \"search\" is the strongest hint the heuristics take.\n //\n // The three that follow are not about autofill but about the same keyboard:\n // a filter box that capitalises the first letter and autocorrects \"2fa\" to\n // \"2FA\" is fighting the person typing into it. Declared BEFORE `{...rest}` so\n // a caller with a reason can still override any of them.\n name=\"search\"\n autoComplete=\"off\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n spellCheck={false}\n {...rest}\n className={cn(\n FIELD_BASE,\n \"pl-9\",\n // Room for our own \"×\" only when there is one; a field that can't be\n // cleared has no reason to reserve the space.\n clearLabel === undefined ? \"pr-3\" : \"pr-9\",\n \"[&::-webkit-search-cancel-button]:appearance-none\",\n )}\n />\n {clearLabel !== undefined && value !== \"\" && (\n <button\n type=\"button\"\n onClick={() => {\n onChange(\"\");\n // Clearing is the start of the next query far more often than it is the\n // end of this one, so the caret stays where it can be typed into.\n innerRef.current?.focus();\n }}\n aria-label={clearLabel}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </button>\n )}\n </div>\n );\n});\nSearchField.displayName = \"SearchField\";\n"],"mappings":";AAiFI,SACE,KADF;AAjFJ,SAAS,YAAY,cAAc;AAEnC,SAAS,QAAQ,SAAS;AAC1B,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAmEpB,MAAM,cAAc,WAA+C,SAASA,aACjF,EAAE,OAAO,UAAU,OAAO,YAAY,WAAW,aAAa,cAAc,WAAW,GAAG,KAAK,GAC/F,KACA;AACA,QAAM,WAAW,OAAyB,IAAI;AAI9C,QAAM,OAAO,aAAa;AAC1B,SACE,qBAAC,SAAI,WAAW,GAAG,YAAY,SAAS,GACtC;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,WAAU;AAAA;AAAA,IACZ;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,CAAC,SAAS;AACb,mBAAS,UAAU;AACnB,cAAI,OAAO,QAAQ,WAAY,KAAI,IAAI;AAAA,mBAC9B,IAAK,KAAI,UAAU;AAAA,QAC9B;AAAA,QACA,MAAK;AAAA,QACL;AAAA,QACA,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAK;AAAA,QACxC,cAAY;AAAA,QACZ,aAAa,eAAe;AAAA,QAgB5B,MAAK;AAAA,QACL,cAAa;AAAA,QACb,gBAAe;AAAA,QACf,aAAY;AAAA,QACZ,YAAY;AAAA,QACX,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA;AAAA;AAAA;AAAA,UAGA,eAAe,SAAY,SAAS;AAAA,UACpC;AAAA,QACF;AAAA;AAAA,IACF;AAAA,IACC,eAAe,UAAa,UAAU,MACrC;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAS,MAAM;AACb,mBAAS,EAAE;AAGX,mBAAS,SAAS,MAAM;AAAA,QAC1B;AAAA,QACA,cAAY;AAAA,QACZ,WAAU;AAAA,QAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,IACxB;AAAA,KAEJ;AAEJ,CAAC;AACD,YAAY,cAAc;","names":["SearchField"]}
1
+ {"version":3,"sources":["../../src/components/search-field.tsx"],"sourcesContent":["import { forwardRef, useRef } from \"react\";\nimport type { InputHTMLAttributes } from \"react\";\nimport { Search, X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FIELD_BASE } from \"./ui\";\n\ninterface SearchFieldOwnProps\n extends Omit<\n InputHTMLAttributes<HTMLInputElement>,\n \"value\" | \"onChange\" | \"type\" | \"aria-label\"\n > {\n value: string;\n /** The typed text. Emitted as a plain string, not an event: every caller of the\n * four this replaced wanted the string, and one of them held it in a URL param\n * rather than in state. */\n onChange: (value: string) => void;\n /**\n * The field's accessible name.\n *\n * @deprecated Pass `aria-label` instead — the DOM spelling this kit standardised on\n * when it found three names for one idea (`ariaLabel`, `aria-label`, and this).\n * Unchanged and still supported; it names the field only when `aria-label` is\n * absent, so a consumer can migrate one call site at a time.\n */\n label?: string;\n /** Names the clear button. Omit it and no clear button is rendered — which is a\n * decision, not a default: a filter you can type into and not untype is the\n * complaint that produced the button on three of these four screens. */\n clearLabel?: string;\n /**\n * `\"field\"` (default): the bordered filter box that sits on a page.\n *\n * `\"inline\"`: no box at all — no border, no fill, no padding of its own — and no\n * type size of its own either, so it takes the size of the header it sits in.\n * For a search that IS the header: a command-palette sheet, a picker's top line\n * (Keksdose's transaction search, which lost its `text-base` headline to a\n * bordered `text-sm` box when it moved onto this component). The container is\n * what frames it, so give the container the focus cue if it needs one\n * (`focus-within:`); the caret is the field's own.\n */\n variant?: \"field\" | \"inline\";\n /** Classes for the `<input>` itself. `className` styles the wrapper — the box the\n * icon and the clear button are positioned against — so it could set a width\n * and nothing else. See {@link Input}'s `inputClassName`. */\n inputClassName?: string;\n}\n\n// The inline look: FIELD_DISPLAY's idea (the chrome gone, the value the thing\n// itself) without even its baseline, because the header it sits in already draws\n// the line. No `text-*` size, on purpose — the input inherits the caller's.\nconst SEARCH_INLINE =\n \"block w-full min-w-0 border-0 bg-transparent py-1 text-[var(--text-primary)] shadow-none placeholder:text-[var(--text-placeholder)] focus:outline-none focus:ring-0\";\n\n/**\n * One of the two spellings is REQUIRED, and the union is how that survives the\n * deprecation. Making `label` merely optional would have been the easy change and the\n * wrong one: the whole reason this component exists is that two of the four boxes it\n * replaced were named by nothing but their placeholder — which disappears the moment\n * someone types, i.e. exactly when a screen-reader user is working in the field. A\n * caller now has a choice of spelling; it still has no option to pass neither.\n */\nexport type SearchFieldProps = SearchFieldOwnProps &\n ({ \"aria-label\": string; label?: string } | { label: string; \"aria-label\"?: string });\n\n/**\n * The page-level filter box: a search icon, a `type=\"search\"` input, and a clear\n * \"×\" that appears once there is something to clear.\n *\n * ## Why it is a component\n *\n * Keksdose had four of these and no two agreed. Two were character-identical\n * twenty-line copies (the settings catalogue filter and the tour list filter) —\n * the same absolute icon at `left-3`, the same `pl-9 pr-9`, the same\n * `[&::-webkit-search-cancel-button]:appearance-none`, the same conditional clear\n * button at `right-2` — differing only in which state setter they called. The\n * third (the admin support queue) was the same idea at `left-2`/`size-3.5`/`pl-7`\n * with no clear button and no `type=\"search\"`, so it had neither the `searchbox`\n * role nor a way to empty itself. The fourth (the bank picker) was a bare `<Input\n * placeholder=\"Search\">`: no icon, no clear, and no accessible name at all once\n * the placeholder went away.\n *\n * None of that is a design decision anyone made; it is four people solving the\n * same problem on four days. A shared component is how the fourth screen gets the\n * clear button the first one earned.\n *\n * ## The two X's\n *\n * `type=\"search\"` is what gives the field its `searchbox` role — and, in Chrome, a\n * NATIVE clear cross that paints right next to ours, so a non-empty box shows two\n * X's an inch apart. Ours is the one that survives: it carries an accessible name\n * and matches the app's other icon buttons, and it returns focus to the input so\n * the next query can be typed straight away.\n */\nexport const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(\n {\n value,\n onChange,\n label,\n clearLabel,\n className,\n inputClassName,\n placeholder,\n variant = \"field\",\n \"aria-label\": ariaLabel,\n ...rest\n },\n ref,\n) {\n const innerRef = useRef<HTMLInputElement>(null);\n // Whichever spelling the caller used. It is the accessible name AND the placeholder\n // fallback, so a box named only by `aria-label` still says what it is before anyone\n // has typed in it — the same deal `label` has always had.\n const name = ariaLabel ?? label;\n const inline = variant === \"inline\";\n return (\n <div className={cn(\"relative\", className)}>\n <Search\n aria-hidden\n className={cn(\n \"pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]\",\n // Logical sides throughout, so a right-to-left page gets the icon at the\n // start of the line and the clear button at its end.\n inline ? \"start-0\" : \"start-3\",\n )}\n />\n <input\n ref={(node) => {\n innerRef.current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) ref.current = node;\n }}\n type=\"search\"\n value={value}\n onChange={(e) => onChange(e.target.value)}\n aria-label={name}\n placeholder={placeholder ?? name}\n // Keksdose live #333: *\"Entering the search here — is that set as password?\n // … I got prompted for remembering my name or mail address\"*, typed into the\n // settings filter on a phone.\n //\n // A `type=\"search\"` input with no `name` and no `autocomplete` is a field\n // Chrome's autofill classifies by GUESSWORK, from the labels around it and\n // the other fields on the page — and a settings page has an email and a\n // password on it. Naming the field and declaring the intent is what takes it\n // out of that guess: `autocomplete=\"off\"` says it is not part of any profile,\n // and a `name` of \"search\" is the strongest hint the heuristics take.\n //\n // The three that follow are not about autofill but about the same keyboard:\n // a filter box that capitalises the first letter and autocorrects \"2fa\" to\n // \"2FA\" is fighting the person typing into it. Declared BEFORE `{...rest}` so\n // a caller with a reason can still override any of them.\n name=\"search\"\n autoComplete=\"off\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n spellCheck={false}\n {...rest}\n className={cn(\n inline ? SEARCH_INLINE : FIELD_BASE,\n inline ? \"ps-7\" : \"ps-9\",\n // Room for our own \"×\" only when there is one; a field that can't be\n // cleared has no reason to reserve the space.\n clearLabel === undefined ? (inline ? \"pe-0\" : \"pe-3\") : inline ? \"pe-8\" : \"pe-9\",\n \"[&::-webkit-search-cancel-button]:appearance-none\",\n inputClassName,\n )}\n />\n {clearLabel !== undefined && value !== \"\" && (\n <button\n type=\"button\"\n onClick={() => {\n onChange(\"\");\n // Clearing is the start of the next query far more often than it is the\n // end of this one, so the caret stays where it can be typed into.\n innerRef.current?.focus();\n }}\n aria-label={clearLabel}\n className={cn(\n \"absolute top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]\",\n inline ? \"end-0\" : \"end-2\",\n )}\n >\n <X className=\"size-4\" />\n </button>\n )}\n </div>\n );\n});\nSearchField.displayName = \"SearchField\";\n"],"mappings":";AAmHI,SACE,KADF;AAnHJ,SAAS,YAAY,cAAc;AAEnC,SAAS,QAAQ,SAAS;AAC1B,SAAS,UAAU;AACnB,SAAS,kBAAkB;AA8C3B,MAAM,gBACJ;AA0CK,MAAM,cAAc,WAA+C,SAASA,aACjF;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,cAAc;AAAA,EACd,GAAG;AACL,GACA,KACA;AACA,QAAM,WAAW,OAAyB,IAAI;AAI9C,QAAM,OAAO,aAAa;AAC1B,QAAM,SAAS,YAAY;AAC3B,SACE,qBAAC,SAAI,WAAW,GAAG,YAAY,SAAS,GACtC;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,WAAW;AAAA,UACT;AAAA;AAAA;AAAA,UAGA,SAAS,YAAY;AAAA,QACvB;AAAA;AAAA,IACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,CAAC,SAAS;AACb,mBAAS,UAAU;AACnB,cAAI,OAAO,QAAQ,WAAY,KAAI,IAAI;AAAA,mBAC9B,IAAK,KAAI,UAAU;AAAA,QAC9B;AAAA,QACA,MAAK;AAAA,QACL;AAAA,QACA,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAK;AAAA,QACxC,cAAY;AAAA,QACZ,aAAa,eAAe;AAAA,QAgB5B,MAAK;AAAA,QACL,cAAa;AAAA,QACb,gBAAe;AAAA,QACf,aAAY;AAAA,QACZ,YAAY;AAAA,QACX,GAAG;AAAA,QACJ,WAAW;AAAA,UACT,SAAS,gBAAgB;AAAA,UACzB,SAAS,SAAS;AAAA;AAAA;AAAA,UAGlB,eAAe,SAAa,SAAS,SAAS,SAAU,SAAS,SAAS;AAAA,UAC1E;AAAA,UACA;AAAA,QACF;AAAA;AAAA,IACF;AAAA,IACC,eAAe,UAAa,UAAU,MACrC;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAS,MAAM;AACb,mBAAS,EAAE;AAGX,mBAAS,SAAS,MAAM;AAAA,QAC1B;AAAA,QACA,cAAY;AAAA,QACZ,WAAW;AAAA,UACT;AAAA,UACA,SAAS,UAAU;AAAA,QACrB;AAAA,QAEA,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,IACxB;AAAA,KAEJ;AAEJ,CAAC;AACD,YAAY,cAAc;","names":["SearchField"]}
@@ -64,6 +64,12 @@ interface SignaturePadLabels {
64
64
  /** Live-region messages after the matching button. */
65
65
  cleared: string;
66
66
  undone: string;
67
+ /** {@link SignatureView}: what it says when there is no signature to show. */
68
+ viewEmpty: string;
69
+ /** {@link SignatureView}: the saved PNG's alternative text. */
70
+ viewDrawn: string;
71
+ /** {@link SignatureView}: the accessible name of a typed-name signature. */
72
+ viewTyped: (name: string) => string;
67
73
  }
68
74
  declare const DEFAULT_SIGNATURE_PAD_LABELS: SignaturePadLabels;
69
75
  /** How the value handed back was made. */
@@ -115,5 +121,41 @@ interface SignaturePadProps extends Omit<ComponentPropsWithoutRef<"div">, "onCha
115
121
  labels?: Partial<SignaturePadLabels>;
116
122
  }
117
123
  declare const SignaturePad: react.ForwardRefExoticComponent<SignaturePadProps & react.RefAttributes<SignaturePadHandle>>;
124
+ interface SignatureViewProps extends ComponentPropsWithoutRef<"figure"> {
125
+ /** The saved PNG — what `SignaturePad` handed to `onChange`/`onSave`, as stored. */
126
+ value?: string | null;
127
+ /**
128
+ * A signature given as a typed name (`detail.typedName`), for a host that stores the
129
+ * text rather than its PNG. Rendered in the same script face the pad draws it in.
130
+ * Ignored while `value` is set.
131
+ */
132
+ typedName?: string | null;
133
+ /** Visible caption above the signature; defaults to `labels.label`. `null` hides it
134
+ * (the image keeps its alternative text) — for a signature block whose own heading
135
+ * already says whose signature it is. */
136
+ label?: ReactNode;
137
+ /**
138
+ * The exported PNG is dark ink on a transparent ground (`exportInk`), which is
139
+ * invisible on the dark theme's surface. By default the image is colour-inverted
140
+ * under `.dark`, so the ink reads light; `false` keeps the pixels as stored — for a
141
+ * PNG exported with its own `exportBackground`, which inverting would turn black.
142
+ */
143
+ adaptInk?: boolean;
144
+ /** Classes for the frame — its height, chiefly (default `h-40`, the pad's). */
145
+ frameClassName?: string;
146
+ /** User-facing strings; see {@link SignaturePadLabels} (`view*`). */
147
+ labels?: Partial<SignaturePadLabels>;
148
+ }
149
+ /**
150
+ * A saved signature, shown — the read side of {@link SignaturePad}: the same frame and
151
+ * signing line, none of the drawing chrome (no canvas, buttons, instructions or live
152
+ * region). A separate component rather than a `readOnly` pad because there is nothing
153
+ * of the pad to reuse: the pad's scene is STROKES, and a stored signature is a PNG
154
+ * (or a name) that cannot be turned back into them.
155
+ *
156
+ * Kastlan's handover protocol, reopened after signing, is the case: two signatures
157
+ * that must be visible, and must not look editable.
158
+ */
159
+ declare function SignatureView({ value, typedName, label, adaptInk, frameClassName, labels: labelsProp, className, ...rest }: SignatureViewProps): react.JSX.Element;
118
160
 
119
- export { DEFAULT_SIGNATURE_PAD_LABELS, type SignatureDetail, SignaturePad, type SignaturePadHandle, type SignaturePadLabels, type SignaturePadProps };
161
+ export { DEFAULT_SIGNATURE_PAD_LABELS, type SignatureDetail, SignaturePad, type SignaturePadHandle, type SignaturePadLabels, type SignaturePadProps, SignatureView, type SignatureViewProps };