@eifi1/ui-kit 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/components/amount-input.d.ts +8 -0
- package/dist/components/amount-input.js +10 -4
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/error-boundary.js +2 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/money-field.d.ts +92 -0
- package/dist/components/money-field.js +40 -0
- package/dist/components/money-field.js.map +1 -0
- package/dist/components/number-field.d.ts +12 -1
- package/dist/components/number-field.js +4 -0
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/page-header.js +29 -3
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/text-link.js +2 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +10 -1
- package/dist/feedback/feedback-dialog.js +13 -3
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/i18n/locales/de.js +1 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.js +1 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +1 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +1 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +1 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +1 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/document-navigation.d.ts +15 -0
- package/dist/lib/document-navigation.js +13 -0
- package/dist/lib/document-navigation.js.map +1 -0
- package/package.json +7 -4
- package/src/components/amount-input.tsx +18 -4
- package/src/components/error-boundary.tsx +2 -1
- package/src/components/money-field.tsx +75 -0
- package/src/components/number-field.tsx +14 -0
- package/src/components/page-header.tsx +38 -3
- package/src/components/text-link.tsx +2 -1
- package/src/feedback/feedback-dialog.tsx +20 -2
- package/src/i18n/locales/de.ts +1 -0
- package/src/i18n/locales/es.ts +1 -0
- package/src/i18n/locales/fr.ts +1 -0
- package/src/i18n/locales/hu.ts +1 -0
- package/src/i18n/locales/it.ts +1 -0
- package/src/i18n/locales/zh.ts +1 -0
- package/src/index.ts +1 -0
- package/src/lib/document-navigation.ts +17 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { AmountInput } from "./amount-input.js";
|
|
5
|
+
function parseSettled(text) {
|
|
6
|
+
const t = text.trim();
|
|
7
|
+
if (t === "") return null;
|
|
8
|
+
const n = Number(t);
|
|
9
|
+
return Number.isFinite(n) ? n : void 0;
|
|
10
|
+
}
|
|
11
|
+
function MoneyField({ value, onCommit, ref, ...props }) {
|
|
12
|
+
const external = value === null ? "" : String(value);
|
|
13
|
+
const [draft, setDraft] = useState(external);
|
|
14
|
+
const [seen, setSeen] = useState(value);
|
|
15
|
+
if (seen !== value) {
|
|
16
|
+
setSeen(value);
|
|
17
|
+
if (parseSettled(draft) !== value) setDraft(external);
|
|
18
|
+
}
|
|
19
|
+
return /* @__PURE__ */ jsx(
|
|
20
|
+
AmountInput,
|
|
21
|
+
{
|
|
22
|
+
...props,
|
|
23
|
+
ref,
|
|
24
|
+
value: draft,
|
|
25
|
+
onChange: setDraft,
|
|
26
|
+
onCommit: (text) => {
|
|
27
|
+
const next = parseSettled(text);
|
|
28
|
+
if (next === void 0) {
|
|
29
|
+
setDraft(external);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (next !== value) onCommit(next);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
MoneyField
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=money-field.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/money-field.tsx"],"sourcesContent":["import { useState, type ComponentProps, type Ref } from \"react\";\nimport { AmountInput } from \"./amount-input\";\n\ntype AmountInputProps = ComponentProps<typeof AmountInput>;\n\nexport interface MoneyFieldProps\n extends Omit<AmountInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"negative\" | \"onNegativeChange\" | \"ref\"> {\n /** The amount, or `null` for an empty field. */\n value: number | null;\n /**\n * The settled amount — on blur, on Enter, on the numpad's \"=\" and on a calculator\n * result; never a half-typed \"1200+\". Rounded to the currency's minor unit (or\n * `digits`) and clamped to `min` / `max`, like {@link AmountInput}. `null` when the\n * field was emptied. Not fired when the settled amount equals `value`, so an\n * Enter-then-blur saves once.\n */\n onCommit: (value: number | null) => void;\n ref?: Ref<HTMLInputElement>;\n}\n\n/** The number a settled text reads as: `null` for empty, `undefined` for no number\n * (a lone \"-\" or \".\"). */\nfunction parseSettled(text: string): number | null | undefined {\n const t = text.trim();\n if (t === \"\") return null;\n const n = Number(t);\n return Number.isFinite(n) ? n : undefined;\n}\n\n/**\n * {@link AmountInput} with a NUMBER in and a number out, for money edited outside a\n * form — a budget row, a table cell (kastlan 52: new-budget-page edited a CHF amount per\n * row with `NumberField` because AmountInput is string-valued and reports every\n * keystroke, \"1200+\" included, so a number-state row needed its own parse-and-settle\n * glue). This is that glue, the same one `RhfMoneyField` has: the text being typed\n * lives here, and only a settled figure reaches `onCommit`.\n *\n * The currency chip, the calculator, the numpad, the locale's decimal mark, the\n * rounding to the currency's minor unit and `min` / `max` are AmountInput's. A caller-\n * owned sign (`negative`) is not offered: the figure here carries its own minus.\n *\n * ```tsx\n * <MoneyField ariaLabel=\"Amount\" currency=\"CHF\" value={row.amount}\n * onCommit={(amount) => updateRow(row.id, { amount })} />\n * ```\n */\nexport function MoneyField({ value, onCommit, ref, ...props }: MoneyFieldProps) {\n const external = value === null ? \"\" : String(value);\n // The draft is re-read from `value` only when `value` moves away from what the draft\n // already says (a reset, another row's save), so a commit's own echo keeps the\n // typist's spelling (\"12.50\" stays \"12.50\" when 12.5 comes back).\n const [draft, setDraft] = useState(external);\n const [seen, setSeen] = useState(value);\n if (seen !== value) {\n setSeen(value);\n if (parseSettled(draft) !== value) setDraft(external);\n }\n return (\n <AmountInput\n {...props}\n ref={ref}\n value={draft}\n onChange={setDraft}\n onCommit={(text) => {\n const next = parseSettled(text);\n // No number (\"-\" alone): back to the stored amount rather than a guess.\n if (next === undefined) {\n setDraft(external);\n return;\n }\n if (next !== value) onCommit(next);\n }}\n />\n );\n}\n"],"mappings":";AA0DI;AA1DJ,SAAS,gBAA+C;AACxD,SAAS,mBAAmB;AAqB5B,SAAS,aAAa,MAAyC;AAC7D,QAAM,IAAI,KAAK,KAAK;AACpB,MAAI,MAAM,GAAI,QAAO;AACrB,QAAM,IAAI,OAAO,CAAC;AAClB,SAAO,OAAO,SAAS,CAAC,IAAI,IAAI;AAClC;AAmBO,SAAS,WAAW,EAAE,OAAO,UAAU,KAAK,GAAG,MAAM,GAAoB;AAC9E,QAAM,WAAW,UAAU,OAAO,KAAK,OAAO,KAAK;AAInD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,QAAQ;AAC3C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,MAAI,SAAS,OAAO;AAClB,YAAQ,KAAK;AACb,QAAI,aAAa,KAAK,MAAM,MAAO,UAAS,QAAQ;AAAA,EACtD;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ;AAAA,MACA,OAAO;AAAA,MACP,UAAU;AAAA,MACV,UAAU,CAAC,SAAS;AAClB,cAAM,OAAO,aAAa,IAAI;AAE9B,YAAI,SAAS,QAAW;AACtB,mBAAS,QAAQ;AACjB;AAAA,QACF;AACA,YAAI,SAAS,MAAO,UAAS,IAAI;AAAA,MACnC;AAAA;AAAA,EACF;AAEJ;","names":[]}
|
|
@@ -163,5 +163,16 @@ interface NumberFieldProps extends Omit<NumberInputProps, "value" | "onChange" |
|
|
|
163
163
|
* and a field that parses its own display back must not have that ambiguity.
|
|
164
164
|
*/
|
|
165
165
|
declare function NumberField({ value, onCommit, onValueChange, digits, min, max, nullable, unit, unitPlacement, locale: localeProp, label, invalid, error, id, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, ...rest }: NumberFieldProps): react.JSX.Element;
|
|
166
|
+
/**
|
|
167
|
+
* A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as
|
|
168
|
+
* defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a
|
|
169
|
+
* form-bound field, for the plain one (kastlan 51: a building's construction year in a
|
|
170
|
+
* wizard's local state, an entity id in the document upload spelled the two props out
|
|
171
|
+
* by hand).
|
|
172
|
+
*
|
|
173
|
+
* `??` rather than a spread over the defaults, so a wrapper forwarding its own optional
|
|
174
|
+
* `digits={props.digits}` still gets the preset.
|
|
175
|
+
*/
|
|
176
|
+
declare function IntegerField(props: NumberFieldProps): react.JSX.Element;
|
|
166
177
|
|
|
167
|
-
export { NumberField, type NumberFieldProps };
|
|
178
|
+
export { IntegerField, NumberField, type NumberFieldProps };
|
|
@@ -135,7 +135,11 @@ function NumberField({
|
|
|
135
135
|
/* @__PURE__ */ jsx("p", { id: errorId, className: FIELD_ERROR_CLASS, children: error })
|
|
136
136
|
] });
|
|
137
137
|
}
|
|
138
|
+
function IntegerField(props) {
|
|
139
|
+
return /* @__PURE__ */ jsx(NumberField, { ...props, digits: props.digits ?? 0, calculator: props.calculator ?? false });
|
|
140
|
+
}
|
|
138
141
|
export {
|
|
142
|
+
IntegerField,
|
|
139
143
|
NumberField
|
|
140
144
|
};
|
|
141
145
|
//# sourceMappingURL=number-field.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/number-field.tsx"],"sourcesContent":["import { 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 * Set on the `<input>`. Declared, with `id` (from NumberInput) and the two above, so\n * `Field`'s render-prop spread — `{(ids) => <NumberField {...ids} … />}`, kastlan's\n * wizard steps — is typed and reaches the input rather than being dropped: before\n * this, `aria-required` was not in the type and a required number was announced as\n * optional.\n */\n \"aria-required\"?: 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 \"aria-required\": ariaRequired,\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 // Straight onto NumberInput's <input>, which declares both now. They used to be set\n // on the DOM node from an effect, because NumberInput had no props for them.\n const required = ariaRequired === true || ariaRequired === \"true\";\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 aria-describedby={describedBy || undefined}\n aria-required={required || undefined}\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":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/number-field.tsx"],"sourcesContent":["import { 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 * Set on the `<input>`. Declared, with `id` (from NumberInput) and the two above, so\n * `Field`'s render-prop spread — `{(ids) => <NumberField {...ids} … />}`, kastlan's\n * wizard steps — is typed and reaches the input rather than being dropped: before\n * this, `aria-required` was not in the type and a required number was announced as\n * optional.\n */\n \"aria-required\"?: 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 \"aria-required\": ariaRequired,\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 // Straight onto NumberInput's <input>, which declares both now. They used to be set\n // on the DOM node from an effect, because NumberInput had no props for them.\n const required = ariaRequired === true || ariaRequired === \"true\";\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 aria-describedby={describedBy || undefined}\n aria-required={required || undefined}\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\n/**\n * A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as\n * defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a\n * form-bound field, for the plain one (kastlan 51: a building's construction year in a\n * wizard's local state, an entity id in the document upload spelled the two props out\n * by hand).\n *\n * `??` rather than a spread over the defaults, so a wrapper forwarding its own optional\n * `digits={props.digits}` still gets the preset.\n */\nexport function IntegerField(props: NumberFieldProps) {\n return <NumberField {...props} digits={props.digits ?? 0} calculator={props.calculator ?? false} />;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
4
|
import { cn } from "../lib/cn.js";
|
|
4
5
|
import { SECTION_LABEL_CLASS } from "./text.js";
|
|
5
6
|
const TITLE = {
|
|
@@ -35,6 +36,24 @@ function PageHeader({
|
|
|
35
36
|
const wrapsSecondary = hasSecondary && mobileLayout === "inline";
|
|
36
37
|
const align = actionsAlign != null ? ROW_ALIGN[mobileLayout][actionsAlign] : void 0;
|
|
37
38
|
const keepsWords = mobileLayout === "inline" && !truncateTitle;
|
|
39
|
+
const measuresWrap = keepsWords && hasSecondary && actions != null;
|
|
40
|
+
const titleRef = useRef(null);
|
|
41
|
+
const actionsRef = useRef(null);
|
|
42
|
+
const [actionsWrapped, setActionsWrapped] = useState(false);
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
const titleBox = titleRef.current;
|
|
45
|
+
const actionsBox = actionsRef.current;
|
|
46
|
+
if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === "undefined") return;
|
|
47
|
+
const observer = new ResizeObserver(() => {
|
|
48
|
+
const t = titleBox.getBoundingClientRect();
|
|
49
|
+
const a = actionsBox.getBoundingClientRect();
|
|
50
|
+
setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);
|
|
51
|
+
});
|
|
52
|
+
observer.observe(titleBox);
|
|
53
|
+
observer.observe(actionsBox);
|
|
54
|
+
if (titleBox.parentElement) observer.observe(titleBox.parentElement);
|
|
55
|
+
return () => observer.disconnect();
|
|
56
|
+
}, [measuresWrap]);
|
|
38
57
|
return /* @__PURE__ */ jsxs("div", { ...rest, className: cn("flex min-w-0 flex-col gap-2", className), children: [
|
|
39
58
|
breadcrumbs,
|
|
40
59
|
/* @__PURE__ */ jsxs(
|
|
@@ -47,7 +66,7 @@ function PageHeader({
|
|
|
47
66
|
align
|
|
48
67
|
),
|
|
49
68
|
children: [
|
|
50
|
-
/* @__PURE__ */ jsxs("div", { className: cn("flex-1", keepsWords ? "min-w-min" : "min-w-0"), children: [
|
|
69
|
+
/* @__PURE__ */ jsxs("div", { ref: titleRef, className: cn("flex-1", keepsWords ? "min-w-min" : "min-w-0"), children: [
|
|
51
70
|
eyebrow != null && /* @__PURE__ */ jsx("p", { className: cn(SECTION_LABEL_CLASS.xs, "mb-1"), children: eyebrow }),
|
|
52
71
|
/* @__PURE__ */ jsx(
|
|
53
72
|
Heading,
|
|
@@ -62,13 +81,20 @@ function PageHeader({
|
|
|
62
81
|
),
|
|
63
82
|
description != null && /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-[var(--text-muted)]", children: description })
|
|
64
83
|
] }),
|
|
65
|
-
actions != null && /* @__PURE__ */ jsx(
|
|
84
|
+
actions != null && /* @__PURE__ */ jsx(
|
|
85
|
+
"div",
|
|
86
|
+
{
|
|
87
|
+
ref: actionsRef,
|
|
88
|
+
className: cn("flex shrink-0 flex-wrap items-center gap-2", hasSecondary && "sm:order-2"),
|
|
89
|
+
children: actions
|
|
90
|
+
}
|
|
91
|
+
),
|
|
66
92
|
hasSecondary && /* @__PURE__ */ jsx(
|
|
67
93
|
"div",
|
|
68
94
|
{
|
|
69
95
|
className: cn(
|
|
70
96
|
"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0",
|
|
71
|
-
wrapsSecondary && "basis-full sm:basis-auto"
|
|
97
|
+
wrapsSecondary && (measuresWrap && actionsWrapped ? "sm:basis-auto" : "basis-full sm:basis-auto")
|
|
72
98
|
),
|
|
73
99
|
children: secondaryActions
|
|
74
100
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\n\n/**\n * `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a\n * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).\n *\n * `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at\n * EVERY width — budgets, rules, accounts, transactions and budget pages all write\n * exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to\n * `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's\n * titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is\n * one: the letters are taken in order, and there is none between `sm` and `md` — an\n * `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is\n * not.\n */\nexport type PageHeaderSize = \"sm\" | \"compact\" | \"md\";\n\nconst TITLE: Record<PageHeaderSize, string> = {\n sm: \"text-lg font-semibold\",\n compact: \"text-xl font-semibold\",\n md: \"text-xl font-semibold tracking-tight sm:text-2xl\",\n};\n\n/**\n * How the title block and the actions sit below the `sm` breakpoint. From `sm` up both\n * layouts are one row: title block at the start, actions at the end.\n */\nexport type PageHeaderMobileLayout = \"stacked\" | \"inline\";\n\nconst ROW: Record<PageHeaderMobileLayout, string> = {\n stacked: \"flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\",\n inline: \"flex-row items-center justify-between gap-2 sm:gap-4\",\n};\n\n/**\n * Where the actions sit on the title block's height, in the one row the header is from\n * `sm` up (and on a phone too when {@link PageHeaderMobileLayout} is `inline`, the only\n * layout that has a row there). Unset keeps each layout's own: `start` for `stacked`,\n * `center` for `inline`.\n */\nexport type PageHeaderActionsAlign = \"start\" | \"center\" | \"end\";\n\n/** Applied to the ROW, so the title block and the actions are aligned against each\n * other on one cross axis. 0.14.0 put `self-*` on the actions box alone, which moved\n * nothing when the actions were the taller item — they already filled the row, and\n * the title stayed at the top (keksdose H2, reports-page's two-line labelled\n * CurrencySelect). Per layout, because `stacked` is a column on a phone, where\n * `items-center` would centre everything HORIZONTALLY — so it aligns from `sm` up. */\nconst ROW_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {\n stacked: { start: \"sm:items-start\", center: \"sm:items-center\", end: \"sm:items-end\" },\n inline: { start: \"items-start\", center: \"items-center\", end: \"items-end\" },\n};\n\nexport interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n title: ReactNode;\n /** A sentence under the title. */\n description?: ReactNode;\n /** Buttons for the page — \"New\", \"Export\". At the title's end from `sm` up; under it,\n * wrapping, on a phone — unless {@link mobileLayout} is `inline`. */\n actions?: ReactNode;\n /** A `Breadcrumbs` trail, drawn above everything else. */\n breadcrumbs?: ReactNode;\n /** A small uppercase line above the title — the section or the kind of record\n * (\"Building\", \"Settings\"). The section-label type. */\n eyebrow?: ReactNode;\n /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a\n * header inside a page that already has its `h1` (a tab's own header). */\n as?: \"h1\" | \"h2\" | \"h3\";\n size?: PageHeaderSize;\n /**\n * `stacked` (default): on a phone the actions drop under the title and wrap, so a\n * row of three buttons never pushes the title off the screen.\n *\n * `inline`: the actions stay beside the title on a phone too, vertically centred on\n * it — keksdose's headers (transactions-page.tsx:515, `flex items-center\n * justify-between gap-2`), which carry one or two icon-sized controls and were tuned\n * for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the\n * row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is\n * for a header whose actions are known to fit beside a phone-width title (an icon\n * button, a month picker); a long title wraps to make room for them instead.\n */\n mobileLayout?: PageHeaderMobileLayout;\n /**\n * A second group of actions that gets its OWN full-width row under the header on a\n * phone, and joins the actions' row from `sm` up, between the title and\n * {@link actions}.\n *\n * keksdose's budget page (budget-page.tsx, feedback #60) is the case: title and month\n * navigation on row 1, the fold/unfold toggles on row 2 on a phone — \"two tidy rows\n * instead of the old ragged justify-between overflow\" — and one row, title | toggles |\n * month nav, on a wider screen. It builds that by hand with a wrapping flex row and\n * swapped order utilities; adopting `PageHeader` (keksdose G6b) must not lose it.\n * There: `actions` is the month navigation, this is the toggles.\n *\n * The DOM order is title, `actions`, `secondaryActions` — the phone's visual order,\n * and the one a screen reader and the Tab key follow at every width. From `sm` up the\n * two groups swap places visually only, which keeps the page's primary control (the\n * month) at the row's end, where keksdose had it, and first in reading order. The\n * breakpoint is `sm`, the one {@link mobileLayout} switches at, not keksdose's `md`:\n * one header, one breakpoint.\n */\n secondaryActions?: ReactNode;\n /**\n * The actions' vertical alignment against the title block — see\n * {@link PageHeaderActionsAlign}. Unset keeps the layout's own.\n *\n * keksdose's reports page (reports-page.tsx, keksdose G8) carries a LABELLED\n * `CurrencySelect` as its action: a field with its label on top is taller than the\n * title, and `stacked`'s default top alignment hangs it from the title's cap height\n * with its control well below the title's line. `center` sets it on the title's\n * middle, as that page's hand-written `items-center` row did. It aligns the whole row\n * (title block and actions against each other), since 0.14.1: 0.14.0 moved only the\n * actions box, which did nothing when the actions were the taller item (keksdose H2).\n */\n actionsAlign?: PageHeaderActionsAlign;\n /**\n * One line, cut with an ellipsis, instead of the default breaking of a long word onto\n * as many lines as it needs.\n *\n * keksdose's payees page (payees-page.tsx, live #263, keksdose G6a) wraps its title in\n * its own truncating span so a long name gives way to the `inline` header's actions\n * instead of pushing them. This is that span's behaviour on the heading itself; the\n * title block is already allowed to shrink below its content, which is what lets the\n * cut happen inside a flex row.\n *\n * The full title stays in the DOM as the heading's text, so a screen reader and the\n * document outline read all of it. There is deliberately no native `title` tooltip:\n * the kit does not use them (not on touch, not on keyboard focus, not styled).\n */\n truncateTitle?: boolean;\n}\n\n/**\n * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the\n * page's actions.\n *\n * kastlan's (shared/components/display/page-header.tsx) is the model — title and\n * description on one side, actions on the other, stacked on a phone — and this adds\n * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`\n * title (a status chip beside the name), and actions that WRAP when there are more\n * than fit a phone's width, instead of running off it. No outer margin: the page's\n * own rhythm (`space-y-*`, a grid gap) places it.\n */\nexport function PageHeader({\n title,\n description,\n actions,\n breadcrumbs,\n eyebrow,\n as = \"h1\",\n size = \"md\",\n mobileLayout = \"stacked\",\n secondaryActions,\n actionsAlign,\n truncateTitle = false,\n className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n const hasSecondary = secondaryActions != null;\n const wrapsSecondary = hasSecondary && mobileLayout === \"inline\";\n const align = actionsAlign != null ? ROW_ALIGN[mobileLayout][actionsAlign] : undefined;\n // keksdose H1: in `inline` the title block could shrink below its longest word, and\n // `break-words` then split \"Monatsbudget\" into \"Monatsbudg\" / \"et\" beside the actions.\n // Unless it truncates, the title keeps its longest word (min-content) and the ROW\n // wraps instead: actions that do not fit go to a line of their own, as the app's old\n // `flex-wrap` header did.\n const keepsWords = mobileLayout === \"inline\" && !truncateTitle;\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs}\n {/* With a second group, `inline` wraps so that group's full-width basis puts it\n on a row of its own on a phone (`stacked` is a column there already, and a\n basis would size a column item's HEIGHT); from `sm` up the row stops wrapping\n and the order utilities seat it between the title and the actions. Without\n one, nothing changes. */}\n <div\n className={cn(\n \"flex min-w-0\",\n ROW[mobileLayout],\n keepsWords ? \"flex-wrap\" : wrapsSecondary && \"flex-wrap sm:flex-nowrap\",\n align,\n )}\n >\n <div className={cn(\"flex-1\", keepsWords ? \"min-w-min\" : \"min-w-0\")}>\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading\n className={cn(\n truncateTitle ? \"truncate\" : \"break-words\",\n \"text-[var(--text-primary)]\",\n TITLE[size],\n )}\n >\n {title}\n </Heading>\n {description != null && <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>}\n </div>\n {actions != null && (\n <div className={cn(\"flex shrink-0 flex-wrap items-center gap-2\", hasSecondary && \"sm:order-2\")}>\n {actions}\n </div>\n )}\n {hasSecondary && (\n <div\n className={cn(\n \"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0\",\n wrapsSecondary && \"basis-full sm:basis-auto\",\n )}\n >\n {secondaryActions}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":";AA0LQ,SACsB,KADtB;AAzLR,SAAS,UAAU;AACnB,SAAS,2BAA2B;AAiBpC,MAAM,QAAwC;AAAA,EAC5C,IAAI;AAAA,EACJ,SAAS;AAAA,EACT,IAAI;AACN;AAQA,MAAM,MAA8C;AAAA,EAClD,SAAS;AAAA,EACT,QAAQ;AACV;AAgBA,MAAM,YAAoF;AAAA,EACxF,SAAS,EAAE,OAAO,kBAAkB,QAAQ,mBAAmB,KAAK,eAAe;AAAA,EACnF,QAAQ,EAAE,OAAO,eAAe,QAAQ,gBAAgB,KAAK,YAAY;AAC3E;AA4FO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,KAAK;AAAA,EACL,OAAO;AAAA,EACP,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA,GAAG;AACL,GAAoB;AAClB,QAAM,UAAU;AAChB,QAAM,eAAe,oBAAoB;AACzC,QAAM,iBAAiB,gBAAgB,iBAAiB;AACxD,QAAM,QAAQ,gBAAgB,OAAO,UAAU,YAAY,EAAE,YAAY,IAAI;AAM7E,QAAM,aAAa,iBAAiB,YAAY,CAAC;AACjD,SACE,qBAAC,SAAK,GAAG,MAAM,WAAW,GAAG,+BAA+B,SAAS,GAClE;AAAA;AAAA,IAMD;AAAA,MAAC;AAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA,IAAI,YAAY;AAAA,UAChB,aAAa,cAAc,kBAAkB;AAAA,UAC7C;AAAA,QACF;AAAA,QAEA;AAAA,+BAAC,SAAI,WAAW,GAAG,UAAU,aAAa,cAAc,SAAS,GAC9D;AAAA,uBAAW,QAAQ,oBAAC,OAAE,WAAW,GAAG,oBAAoB,IAAI,MAAM,GAAI,mBAAQ;AAAA,YAC/E;AAAA,cAAC;AAAA;AAAA,gBACC,WAAW;AAAA,kBACT,gBAAgB,aAAa;AAAA,kBAC7B;AAAA,kBACA,MAAM,IAAI;AAAA,gBACZ;AAAA,gBAEC;AAAA;AAAA,YACH;AAAA,YACC,eAAe,QAAQ,oBAAC,OAAE,WAAU,yCAAyC,uBAAY;AAAA,aAC5F;AAAA,UACC,WAAW,QACV,oBAAC,SAAI,WAAW,GAAG,8CAA8C,gBAAgB,YAAY,GAC1F,mBACH;AAAA,UAED,gBACC;AAAA,YAAC;AAAA;AAAA,cACC,WAAW;AAAA,gBACT;AAAA,gBACA,kBAAkB;AAAA,cACpB;AAAA,cAEC;AAAA;AAAA,UACH;AAAA;AAAA;AAAA,IAEJ;AAAA,KACF;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import { useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\n\n/**\n * `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a\n * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).\n *\n * `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at\n * EVERY width — budgets, rules, accounts, transactions and budget pages all write\n * exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to\n * `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's\n * titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is\n * one: the letters are taken in order, and there is none between `sm` and `md` — an\n * `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is\n * not.\n */\nexport type PageHeaderSize = \"sm\" | \"compact\" | \"md\";\n\nconst TITLE: Record<PageHeaderSize, string> = {\n sm: \"text-lg font-semibold\",\n compact: \"text-xl font-semibold\",\n md: \"text-xl font-semibold tracking-tight sm:text-2xl\",\n};\n\n/**\n * How the title block and the actions sit below the `sm` breakpoint. From `sm` up both\n * layouts are one row: title block at the start, actions at the end.\n */\nexport type PageHeaderMobileLayout = \"stacked\" | \"inline\";\n\nconst ROW: Record<PageHeaderMobileLayout, string> = {\n stacked: \"flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\",\n inline: \"flex-row items-center justify-between gap-2 sm:gap-4\",\n};\n\n/**\n * Where the actions sit on the title block's height, in the one row the header is from\n * `sm` up (and on a phone too when {@link PageHeaderMobileLayout} is `inline`, the only\n * layout that has a row there). Unset keeps each layout's own: `start` for `stacked`,\n * `center` for `inline`.\n */\nexport type PageHeaderActionsAlign = \"start\" | \"center\" | \"end\";\n\n/** Applied to the ROW, so the title block and the actions are aligned against each\n * other on one cross axis. 0.14.0 put `self-*` on the actions box alone, which moved\n * nothing when the actions were the taller item — they already filled the row, and\n * the title stayed at the top (keksdose H2, reports-page's two-line labelled\n * CurrencySelect). Per layout, because `stacked` is a column on a phone, where\n * `items-center` would centre everything HORIZONTALLY — so it aligns from `sm` up. */\nconst ROW_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {\n stacked: { start: \"sm:items-start\", center: \"sm:items-center\", end: \"sm:items-end\" },\n inline: { start: \"items-start\", center: \"items-center\", end: \"items-end\" },\n};\n\nexport interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n title: ReactNode;\n /** A sentence under the title. */\n description?: ReactNode;\n /** Buttons for the page — \"New\", \"Export\". At the title's end from `sm` up; under it,\n * wrapping, on a phone — unless {@link mobileLayout} is `inline`. */\n actions?: ReactNode;\n /** A `Breadcrumbs` trail, drawn above everything else. */\n breadcrumbs?: ReactNode;\n /** A small uppercase line above the title — the section or the kind of record\n * (\"Building\", \"Settings\"). The section-label type. */\n eyebrow?: ReactNode;\n /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a\n * header inside a page that already has its `h1` (a tab's own header). */\n as?: \"h1\" | \"h2\" | \"h3\";\n size?: PageHeaderSize;\n /**\n * `stacked` (default): on a phone the actions drop under the title and wrap, so a\n * row of three buttons never pushes the title off the screen.\n *\n * `inline`: the actions stay beside the title on a phone too, vertically centred on\n * it — keksdose's headers (transactions-page.tsx:515, `flex items-center\n * justify-between gap-2`), which carry one or two icon-sized controls and were tuned\n * for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the\n * row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is\n * for a header whose actions are known to fit beside a phone-width title (an icon\n * button, a month picker); a long title wraps to make room for them instead.\n */\n mobileLayout?: PageHeaderMobileLayout;\n /**\n * A second group of actions that gets its OWN full-width row under the header on a\n * phone, and joins the actions' row from `sm` up, between the title and\n * {@link actions}.\n *\n * keksdose's budget page (budget-page.tsx, feedback #60) is the case: title and month\n * navigation on row 1, the fold/unfold toggles on row 2 on a phone — \"two tidy rows\n * instead of the old ragged justify-between overflow\" — and one row, title | toggles |\n * month nav, on a wider screen. It builds that by hand with a wrapping flex row and\n * swapped order utilities; adopting `PageHeader` (keksdose G6b) must not lose it.\n * There: `actions` is the month navigation, this is the toggles.\n *\n * The DOM order is title, `actions`, `secondaryActions` — the phone's visual order,\n * and the one a screen reader and the Tab key follow at every width. From `sm` up the\n * two groups swap places visually only, which keeps the page's primary control (the\n * month) at the row's end, where keksdose had it, and first in reading order. The\n * breakpoint is `sm`, the one {@link mobileLayout} switches at, not keksdose's `md`:\n * one header, one breakpoint.\n */\n secondaryActions?: ReactNode;\n /**\n * The actions' vertical alignment against the title block — see\n * {@link PageHeaderActionsAlign}. Unset keeps the layout's own.\n *\n * keksdose's reports page (reports-page.tsx, keksdose G8) carries a LABELLED\n * `CurrencySelect` as its action: a field with its label on top is taller than the\n * title, and `stacked`'s default top alignment hangs it from the title's cap height\n * with its control well below the title's line. `center` sets it on the title's\n * middle, as that page's hand-written `items-center` row did. It aligns the whole row\n * (title block and actions against each other), since 0.14.1: 0.14.0 moved only the\n * actions box, which did nothing when the actions were the taller item (keksdose H2).\n */\n actionsAlign?: PageHeaderActionsAlign;\n /**\n * One line, cut with an ellipsis, instead of the default breaking of a long word onto\n * as many lines as it needs.\n *\n * keksdose's payees page (payees-page.tsx, live #263, keksdose G6a) wraps its title in\n * its own truncating span so a long name gives way to the `inline` header's actions\n * instead of pushing them. This is that span's behaviour on the heading itself; the\n * title block is already allowed to shrink below its content, which is what lets the\n * cut happen inside a flex row.\n *\n * The full title stays in the DOM as the heading's text, so a screen reader and the\n * document outline read all of it. There is deliberately no native `title` tooltip:\n * the kit does not use them (not on touch, not on keyboard focus, not styled).\n */\n truncateTitle?: boolean;\n}\n\n/**\n * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the\n * page's actions.\n *\n * kastlan's (shared/components/display/page-header.tsx) is the model — title and\n * description on one side, actions on the other, stacked on a phone — and this adds\n * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`\n * title (a status chip beside the name), and actions that WRAP when there are more\n * than fit a phone's width, instead of running off it. No outer margin: the page's\n * own rhythm (`space-y-*`, a grid gap) places it.\n */\nexport function PageHeader({\n title,\n description,\n actions,\n breadcrumbs,\n eyebrow,\n as = \"h1\",\n size = \"md\",\n mobileLayout = \"stacked\",\n secondaryActions,\n actionsAlign,\n truncateTitle = false,\n className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n const hasSecondary = secondaryActions != null;\n const wrapsSecondary = hasSecondary && mobileLayout === \"inline\";\n const align = actionsAlign != null ? ROW_ALIGN[mobileLayout][actionsAlign] : undefined;\n // keksdose H1: in `inline` the title block could shrink below its longest word, and\n // `break-words` then split \"Monatsbudget\" into \"Monatsbudg\" / \"et\" beside the actions.\n // Unless it truncates, the title keeps its longest word (min-content) and the ROW\n // wraps instead: actions that do not fit go to a line of their own, as the app's old\n // `flex-wrap` header did.\n const keepsWords = mobileLayout === \"inline\" && !truncateTitle;\n // keksdose, after H1: once the actions have wrapped below the title, the phone's\n // header was THREE rows — title / actions / secondary — where the app's old header\n // shared the second row between the month nav and the toggles (~48px of phone\n // height). So the secondary group's full-width basis (G6b: a row of its own) is\n // dropped exactly when the actions already sit below the title; then it joins their\n // row if it fits, and wraps under them if not. While the actions share the title's\n // row, nothing changes: the secondary group never climbs onto the title's row.\n //\n // CSS cannot see \"the previous item wrapped\", so it is measured — by a\n // ResizeObserver, whose first callback runs on `observe`. It is stable: the\n // secondary group comes after the actions, so its basis cannot move them. No\n // ResizeObserver (jsdom) or no layout keeps the basis, the 0.14.0 layout.\n const measuresWrap = keepsWords && hasSecondary && actions != null;\n const titleRef = useRef<HTMLDivElement>(null);\n const actionsRef = useRef<HTMLDivElement>(null);\n const [actionsWrapped, setActionsWrapped] = useState(false);\n useLayoutEffect(() => {\n const titleBox = titleRef.current;\n const actionsBox = actionsRef.current;\n if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n const t = titleBox.getBoundingClientRect();\n const a = actionsBox.getBoundingClientRect();\n // Unmeasured (display: none, no layout): keep the full-width basis.\n setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);\n });\n observer.observe(titleBox);\n observer.observe(actionsBox);\n if (titleBox.parentElement) observer.observe(titleBox.parentElement);\n return () => observer.disconnect();\n }, [measuresWrap]);\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs}\n {/* With a second group, `inline` wraps so that group's full-width basis puts it\n on a row of its own on a phone (`stacked` is a column there already, and a\n basis would size a column item's HEIGHT); from `sm` up the row stops wrapping\n and the order utilities seat it between the title and the actions. Without\n one, nothing changes. */}\n <div\n className={cn(\n \"flex min-w-0\",\n ROW[mobileLayout],\n keepsWords ? \"flex-wrap\" : wrapsSecondary && \"flex-wrap sm:flex-nowrap\",\n align,\n )}\n >\n <div ref={titleRef} className={cn(\"flex-1\", keepsWords ? \"min-w-min\" : \"min-w-0\")}>\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading\n className={cn(\n truncateTitle ? \"truncate\" : \"break-words\",\n \"text-[var(--text-primary)]\",\n TITLE[size],\n )}\n >\n {title}\n </Heading>\n {description != null && <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>}\n </div>\n {actions != null && (\n <div\n ref={actionsRef}\n className={cn(\"flex shrink-0 flex-wrap items-center gap-2\", hasSecondary && \"sm:order-2\")}\n >\n {actions}\n </div>\n )}\n {hasSecondary && (\n <div\n className={cn(\n \"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0\",\n wrapsSecondary && (measuresWrap && actionsWrapped ? \"sm:basis-auto\" : \"basis-full sm:basis-auto\"),\n )}\n >\n {secondaryActions}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -3,6 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { ExternalLink } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/cn.js";
|
|
5
5
|
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels.js";
|
|
6
|
+
import { documentNavigation } from "../lib/document-navigation.js";
|
|
6
7
|
function isExternalHref(href) {
|
|
7
8
|
return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href);
|
|
8
9
|
}
|
|
@@ -26,7 +27,7 @@ function replacingClick(onClick, href, replace, target) {
|
|
|
26
27
|
onClick?.(event);
|
|
27
28
|
if (!isPlainNavigationClick(event)) return;
|
|
28
29
|
event.preventDefault();
|
|
29
|
-
|
|
30
|
+
documentNavigation.replace(href);
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
function routerLinkNavigation(replace) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/text-link.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { ExternalLink } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent } from \"../i18n/kit-labels\";\n\n/* ── Choosing what draws a link ──────────────────────────────────────────── */\n\n/**\n * An `href` that leaves the app: a scheme (`https:`, `mailto:`, `tel:`) or a\n * protocol-relative `//host`. A router's link is for paths inside the app, so the\n * provider's `linkComponent` is never handed one of these — they stay a plain `<a>`.\n *\n * NOT external, and so handed to the router link: a path (`/x`, `x`, `?q=1`) and the\n * hash-router form `#/x` — that IS a route in a `HashRouter` app, which maps it to\n * `to=\"/x\"`. A plain in-page anchor (`#section`) is not external either, but it is not\n * a route: see {@link isInPageAnchor}.\n */\nexport function isExternalHref(href: string): boolean {\n return /^(?:[a-z][a-z\\d+.-]*:|\\/\\/)/i.test(href);\n}\n\n/**\n * An `href` that only scrolls the current page: `#section` (or a bare `#`). The\n * hash-router form `#/x` is a ROUTE, not an anchor, and is not one of these. A router\n * link must not get an anchor — under a `HashRouter` `#section` would navigate to the\n * path `/section` — so every kit link draws it as a plain `<a>`.\n */\nexport function isInPageAnchor(href: string): boolean {\n return href.startsWith(\"#\") && !href.startsWith(\"#/\");\n}\n\n/**\n * Whether the provider's `linkComponent` (or a kit component's router fallback) draws\n * `href`: an in-app path, `#/x` included — not an external `href`\n * ({@link isExternalHref}) and not an in-page `#anchor` ({@link isInPageAnchor}).\n */\nexport function isRoutableHref(href: string): boolean {\n return !isExternalHref(href) && !isInPageAnchor(href);\n}\n\n/**\n * What draws a kit link to `href`: the component's own `renderLink` when it was given\n * one (it always wins — the caller asked for it by name), else the\n * `<UiKitProvider linkComponent>` for a routable `href` ({@link isRoutableHref}), else\n * `undefined` — a plain `<a>`. The one rule every kit link follows: `https:` /\n * `mailto:` / `//host` and `#section` stay plain anchors; `/x`, `x` and a hash-router\n * `#/x` go to the router link. Every kit link props type is a subset of the anchor\n * attributes `KitLinkProps` describes, which is what makes the cast sound.\n * @internal Shared by the kit's link-bearing components; not part of the barrel.\n */\nexport function pickLinkRenderer<P>(\n own: ((props: P) => ReactElement) | undefined,\n kitLink: KitLinkComponent | undefined,\n href: string | undefined,\n): ((props: P) => ReactElement) | undefined {\n if (own) return own;\n if (!kitLink || href === undefined || !isRoutableHref(href)) return undefined;\n return kitLink as unknown as (props: P) => ReactElement;\n}\n\n/** The plain primary click a page navigates on — not a middle click, not ⌘ / Ctrl /\n * Shift / Alt (new tab, new window, download), and not one a handler already took. */\nfunction isPlainNavigationClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n !event.defaultPrevented &&\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey\n );\n}\n\n/**\n * The `onClick` of a PLAIN `<a>` whose kit link was asked to `replace` (keksdose F1).\n *\n * A router link replaces the history entry itself (it is handed `replace`, see\n * {@link routerLinkNavigation}); a plain `<a>` has no attribute that says so, and the\n * browser's own navigation always pushes. So a plain click is taken over — the caller's\n * `onClick` runs first and may still `preventDefault` — and becomes\n * `location.replace(href)`: the same document load, with Back skipping the one-shot URL.\n * That is the case of `replace` together with `reloadDocument`, or an app with no\n * `linkComponent`. A modified click (new tab) and a `target` other than `_self` are left\n * to the browser: they do not navigate THIS history. Without `replace`, `onClick` comes\n * back untouched.\n * @internal\n */\nexport function replacingClick(\n onClick: ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined,\n href: string,\n replace: boolean | undefined,\n target?: string,\n): ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined {\n if (!replace || (target !== undefined && target !== \"\" && target !== \"_self\")) return onClick;\n return (event) => {\n onClick?.(event);\n if (!isPlainNavigationClick(event)) return;\n event.preventDefault();\n window.location.replace(href);\n };\n}\n\n/**\n * What a ROUTER link is handed for `replace`: `{ replace: true }` when asked, else\n * nothing at all — so a provider link that spreads its props onto a DOM `<a>` never\n * receives an unknown attribute from a link that did not ask (see `KitLinkProps.replace`).\n * @internal\n */\nexport function routerLinkNavigation(replace: boolean | undefined): { replace?: true } {\n return replace ? { replace: true } : {};\n}\n\n/**\n * Draws `props` through `render` as a component of its own, so a router link's hooks\n * belong to it and not to the kit component calling it — for the kit components that\n * call their own `renderLink` inline and reach the provider's link through here.\n * @internal\n */\nexport function RenderedKitLink<P>({ render, props }: { render: (props: P) => ReactElement; props: P }) {\n return render(props);\n}\n\n/* ── TextLink ─────────────────────────────────────────────────────────────── */\n\n/**\n * `brand` (default) for a link in running text, `muted` for footer and meta links\n * that should not compete with the content, `danger` for a destructive one (\"Leave\n * group\"), `inherit` for a link that takes its surroundings' colour.\n *\n * `primary` and `secondary` are the body and the secondary text colour, UNDERLINED\n * (keksdose F3): a link that must read as part of the text around it — a name in a\n * list row, a reference in a card's meta line — where `brand` repaints it in the accent\n * and `muted` is a step too light to read as content. `secondary` darkens to the body\n * colour under the pointer, as `muted` does; `primary` has nowhere darker to go, so its\n * underline thickens instead.\n *\n * `warning` is the kit's `--warning` text colour, underlined (kastlan 46): a link inside\n * a `<Tone tone=\"warning\">` sentence or a warning banner, which `brand` would paint in a\n * second hue and `inherit` would leave indistinguishable from the sentence. Like\n * `primary`, it keeps its colour on hover and thickens the underline.\n *\n * The three colours that sit IN the text default to `underline=\"always\"`: with the\n * colour of their surroundings, the underline is the only thing that says \"link\"\n * (WCAG 1.4.1). An explicit `underline` still wins.\n */\nexport type TextLinkTone = \"brand\" | \"muted\" | \"danger\" | \"inherit\" | \"primary\" | \"secondary\" | \"warning\";\n\n/** `hover` (the default for `brand`, `muted`, `danger`, `inherit`): underlined under\n * the pointer — the idiom all three apps draw. `always` (the default for `primary`,\n * `secondary`, `warning`) for a link inside prose, where colour alone must not be the\n * only mark (WCAG 1.4.1). `none` where the context already says \"link\" (a footer nav). */\nexport type TextLinkUnderline = \"hover\" | \"always\" | \"none\";\n\n/** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */\nexport type TextLinkCurrent = \"page\" | \"step\" | \"location\" | \"date\" | \"time\" | \"true\";\n\n/** What {@link TextLinkProps.renderLink} (and the provider's `linkComponent`) is\n * handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface TextLinkRenderProps {\n href: string;\n /** The link's look — keep it, or the tone, underline and focus ring go. */\n className: string;\n children: ReactNode;\n ref?: Ref<HTMLAnchorElement>;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;\n \"aria-current\"?: TextLinkCurrent;\n id?: string;\n title?: string;\n /** Present, as `true`, only when the link was given `replace` — see\n * `KitLinkProps.replace`. Map it to your router's replace; never onto a DOM `<a>`. */\n replace?: boolean;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nexport interface TextLinkProps\n extends Omit<ComponentPropsWithoutRef<\"a\">, \"href\" | \"children\" | \"aria-current\" | \"onClick\" | \"onKeyDown\"> {\n href: string;\n children: ReactNode;\n /** Default `brand`. */\n tone?: TextLinkTone;\n /** Default `always` for `primary`, `secondary` and `warning`, else `hover`. */\n underline?: TextLinkUnderline;\n /**\n * Navigate by replacing the current history entry, so Back does not return to this\n * URL — keksdose F1, a one-shot URL (a confirmation landing, a `?done=1` hop). Handed\n * to the router link as `replace` (see `KitLinkProps.replace` for mapping it); on a\n * plain `<a>` (no `linkComponent`, or `reloadDocument`) a plain click becomes\n * `location.replace(href)`. Ignored on an `external` link, which opens a new tab.\n */\n replace?: boolean;\n /**\n * A plain `<a>` for an in-app `href`: the browser loads the whole document instead of\n * the router swapping the view — keksdose F1, a route served outside the SPA (a file\n * download, a server-rendered page, a logout that must drop every in-memory cache).\n * Wins over `renderLink` and the provider's `linkComponent`: it is the caller saying\n * \"not the router\" for this one link.\n */\n reloadDocument?: boolean;\n /**\n * The link leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`\n * (never the router's), an external-link mark after the text (mirrored in RTL), and\n * \"opens in a new tab\" (`common.opensInNewTab`) read after it — the mark says it\n * only to the eye. kastlan's BWO / BFS source links (rent-increase-calculator-page).\n */\n external?: boolean;\n /**\n * Keep the click (and the Enter that activates the link) from reaching an ancestor —\n * a link in a DataTable row with `onRowClick` goes to its own target instead of\n * running the row's action. kastlan's `CellLink` did this by hand.\n */\n stopPropagation?: boolean;\n /** Mark the link as the current one of a set — `true` is `aria-current=\"page\"`, the\n * legal footer's link to the page you are on. Drawn in semibold. */\n current?: boolean | TextLinkCurrent;\n /** A Lucide icon before the text, at the text's size — kastlan's \"← Back\" link. */\n icon?: LucideIcon;\n /** Your router's link for an in-app `href`. Default: the `<UiKitProvider\n * linkComponent>`, then a plain `<a>`. Ignored on an `external` link. */\n renderLink?: (props: TextLinkRenderProps) => ReactElement;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;\n ref?: Ref<HTMLAnchorElement>;\n}\n\nconst TONE: Record<TextLinkTone, string> = {\n brand: \"text-[var(--brand)] hover:text-[var(--brand-hover)]\",\n muted: \"text-[var(--text-muted)] hover:text-[var(--text-primary)]\",\n danger: \"text-[var(--danger)] hover:text-[var(--danger-hover)]\",\n inherit: \"\",\n primary: \"text-[var(--text-primary)] hover:decoration-2\",\n secondary: \"text-[var(--text-secondary)] hover:text-[var(--text-primary)]\",\n warning: \"text-[var(--warning)] hover:decoration-2\",\n};\n\n/** The tones whose colour is their surroundings' — underlined by default; see\n * {@link TextLinkTone}. */\nconst UNDERLINED_BY_DEFAULT = new Set<TextLinkTone>([\"primary\", \"secondary\", \"warning\"]);\n\nconst UNDERLINE: Record<TextLinkUnderline, string> = {\n hover: \"no-underline hover:underline\",\n always: \"underline\",\n none: \"no-underline\",\n};\n\n/**\n * The one inline link: a real `<a href>` (middle click, \"open in new tab\", link\n * semantics) through the app's router for an in-app path, in one of four tones.\n *\n * kastlan draws this link a dozen ways by hand — `EntityLink` and `CellLink` (text-primary,\n * hover underline, one stopping the row click), the legal pages' back link and footer,\n * the feedback page's URL links, the calculator's external sources with a hand-placed\n * ↗ and no word for a screen reader — and the lease wizard's unit step (lease-unit-step)\n * used a raw `<a>` to an in-app route, which reloads the whole app. Here the router\n * link comes from `<UiKitProvider linkComponent>`, set once.\n *\n * Inline, not `inline-flex`: a link inside a sentence must wrap with it. The icons sit\n * on the text's baseline at the text's size, so they scale with whatever type the link\n * is set in. The focus ring is an outline, which follows every line of a wrapped link.\n */\nexport function TextLink({\n href,\n children,\n tone = \"brand\",\n underline: underlineProp,\n external = false,\n replace,\n reloadDocument = false,\n stopPropagation = false,\n current,\n icon: Icon,\n renderLink,\n className,\n onClick,\n onKeyDown,\n ref,\n ...rest\n}: TextLinkProps) {\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const kitLink = useKitLink();\n const underline = underlineProp ?? (UNDERLINED_BY_DEFAULT.has(tone) ? \"always\" : \"hover\");\n const ariaCurrent: TextLinkCurrent | undefined =\n current === true ? \"page\" : current === false ? undefined : current;\n\n const look = cn(\n \"rounded-sm underline-offset-2 transition-colors\",\n \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]\",\n TONE[tone],\n UNDERLINE[underline],\n ariaCurrent && \"font-semibold\",\n className,\n );\n\n const body = (\n <>\n {Icon && <Icon aria-hidden className=\"me-1 inline size-[1em] shrink-0 align-[-0.125em]\" />}\n {children}\n {external && (\n <>\n <ExternalLink aria-hidden className=\"ms-1 inline size-[0.85em] align-[-0.1em] rtl:-scale-x-100\" />\n {\" \"}\n <span className=\"sr-only\">({common.opensInNewTab})</span>\n </>\n )}\n </>\n );\n\n const handleClick: TextLinkRenderProps[\"onClick\"] = stopPropagation\n ? (event) => {\n event.stopPropagation();\n onClick?.(event);\n }\n : onClick;\n const props: TextLinkRenderProps = {\n ...(rest as Partial<TextLinkRenderProps>),\n ref,\n href,\n className: look,\n \"aria-current\": ariaCurrent,\n onClick: handleClick,\n onKeyDown: stopPropagation\n ? (event) => {\n if (event.key === \"Enter\") event.stopPropagation();\n onKeyDown?.(event);\n }\n : onKeyDown,\n children: body,\n };\n\n const render = external || reloadDocument ? undefined : pickLinkRenderer(renderLink, kitLink, href);\n if (render) return <RenderedTextLink render={render} {...props} {...routerLinkNavigation(replace)} />;\n // A plain `<a>` never gets `replace` as an attribute; asked for, it takes the click.\n const plainClick = external ? handleClick : replacingClick(handleClick, href, replace, rest.target);\n const { children: content, ...anchor } = props;\n return (\n <a\n {...anchor}\n href={href}\n onClick={plainClick}\n {...(external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : null)}\n >\n {content}\n </a>\n );\n}\n\n/** Calls the link renderer as a component, so a router link's hooks are its own. */\nfunction RenderedTextLink({\n render,\n ...props\n}: TextLinkRenderProps & { render: (props: TextLinkRenderProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":";AA0Se,SAGP,UAHO,KAML,YANK;AAzSf,SAAS,oBAAoB;AAE7B,SAAS,UAAU;AACnB,SAAS,uBAAuB,cAAc,kBAAkB;AAezD,SAAS,eAAe,MAAuB;AACpD,SAAO,+BAA+B,KAAK,IAAI;AACjD;AAQO,SAAS,eAAe,MAAuB;AACpD,SAAO,KAAK,WAAW,GAAG,KAAK,CAAC,KAAK,WAAW,IAAI;AACtD;AAOO,SAAS,eAAe,MAAuB;AACpD,SAAO,CAAC,eAAe,IAAI,KAAK,CAAC,eAAe,IAAI;AACtD;AAYO,SAAS,iBACd,KACA,SACA,MAC0C;AAC1C,MAAI,IAAK,QAAO;AAChB,MAAI,CAAC,WAAW,SAAS,UAAa,CAAC,eAAe,IAAI,EAAG,QAAO;AACpE,SAAO;AACT;AAIA,SAAS,uBAAuB,OAA+C;AAC7E,SACE,CAAC,MAAM,oBACP,MAAM,WAAW,KACjB,CAAC,MAAM,WACP,CAAC,MAAM,WACP,CAAC,MAAM,YACP,CAAC,MAAM;AAEX;AAgBO,SAAS,eACd,SACA,MACA,SACA,QAC8D;AAC9D,MAAI,CAAC,WAAY,WAAW,UAAa,WAAW,MAAM,WAAW,QAAU,QAAO;AACtF,SAAO,CAAC,UAAU;AAChB,cAAU,KAAK;AACf,QAAI,CAAC,uBAAuB,KAAK,EAAG;AACpC,UAAM,eAAe;AACrB,WAAO,SAAS,QAAQ,IAAI;AAAA,EAC9B;AACF;AAQO,SAAS,qBAAqB,SAAkD;AACrF,SAAO,UAAU,EAAE,SAAS,KAAK,IAAI,CAAC;AACxC;AAQO,SAAS,gBAAmB,EAAE,QAAQ,MAAM,GAAqD;AACtG,SAAO,OAAO,KAAK;AACrB;AA0GA,MAAM,OAAqC;AAAA,EACzC,OAAO;AAAA,EACP,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AAAA,EACT,WAAW;AAAA,EACX,SAAS;AACX;AAIA,MAAM,wBAAwB,oBAAI,IAAkB,CAAC,WAAW,aAAa,SAAS,CAAC;AAEvF,MAAM,YAA+C;AAAA,EACnD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,MAAM;AACR;AAiBO,SAAS,SAAS;AAAA,EACvB;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,WAAW;AAAA,EACX,WAAW;AAAA,EACX;AAAA,EACA,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,QAAM,SAAS,aAAa,UAAU,qBAAqB;AAC3D,QAAM,UAAU,WAAW;AAC3B,QAAM,YAAY,kBAAkB,sBAAsB,IAAI,IAAI,IAAI,WAAW;AACjF,QAAM,cACJ,YAAY,OAAO,SAAS,YAAY,QAAQ,SAAY;AAE9D,QAAM,OAAO;AAAA,IACX;AAAA,IACA;AAAA,IACA,KAAK,IAAI;AAAA,IACT,UAAU,SAAS;AAAA,IACnB,eAAe;AAAA,IACf;AAAA,EACF;AAEA,QAAM,OACJ,iCACG;AAAA,YAAQ,oBAAC,QAAK,eAAW,MAAC,WAAU,oDAAmD;AAAA,IACvF;AAAA,IACA,YACC,iCACE;AAAA,0BAAC,gBAAa,eAAW,MAAC,WAAU,6DAA4D;AAAA,MAC/F;AAAA,MACD,qBAAC,UAAK,WAAU,WAAU;AAAA;AAAA,QAAE,OAAO;AAAA,QAAc;AAAA,SAAC;AAAA,OACpD;AAAA,KAEJ;AAGF,QAAM,cAA8C,kBAChD,CAAC,UAAU;AACT,UAAM,gBAAgB;AACtB,cAAU,KAAK;AAAA,EACjB,IACA;AACJ,QAAM,QAA6B;AAAA,IACjC,GAAI;AAAA,IACJ;AAAA,IACA;AAAA,IACA,WAAW;AAAA,IACX,gBAAgB;AAAA,IAChB,SAAS;AAAA,IACT,WAAW,kBACP,CAAC,UAAU;AACT,UAAI,MAAM,QAAQ,QAAS,OAAM,gBAAgB;AACjD,kBAAY,KAAK;AAAA,IACnB,IACA;AAAA,IACJ,UAAU;AAAA,EACZ;AAEA,QAAM,SAAS,YAAY,iBAAiB,SAAY,iBAAiB,YAAY,SAAS,IAAI;AAClG,MAAI,OAAQ,QAAO,oBAAC,oBAAiB,QAAiB,GAAG,OAAQ,GAAG,qBAAqB,OAAO,GAAG;AAEnG,QAAM,aAAa,WAAW,cAAc,eAAe,aAAa,MAAM,SAAS,KAAK,MAAM;AAClG,QAAM,EAAE,UAAU,SAAS,GAAG,OAAO,IAAI;AACzC,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ;AAAA,MACA,SAAS;AAAA,MACR,GAAI,WAAW,EAAE,QAAQ,UAAU,KAAK,sBAAsB,IAAI;AAAA,MAElE;AAAA;AAAA,EACH;AAEJ;AAGA,SAAS,iBAAiB;AAAA,EACxB;AAAA,EACA,GAAG;AACL,GAAmF;AACjF,SAAO,OAAO,KAAK;AACrB;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/text-link.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { ExternalLink } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent } from \"../i18n/kit-labels\";\nimport { documentNavigation } from \"../lib/document-navigation\";\n\n/* ── Choosing what draws a link ──────────────────────────────────────────── */\n\n/**\n * An `href` that leaves the app: a scheme (`https:`, `mailto:`, `tel:`) or a\n * protocol-relative `//host`. A router's link is for paths inside the app, so the\n * provider's `linkComponent` is never handed one of these — they stay a plain `<a>`.\n *\n * NOT external, and so handed to the router link: a path (`/x`, `x`, `?q=1`) and the\n * hash-router form `#/x` — that IS a route in a `HashRouter` app, which maps it to\n * `to=\"/x\"`. A plain in-page anchor (`#section`) is not external either, but it is not\n * a route: see {@link isInPageAnchor}.\n */\nexport function isExternalHref(href: string): boolean {\n return /^(?:[a-z][a-z\\d+.-]*:|\\/\\/)/i.test(href);\n}\n\n/**\n * An `href` that only scrolls the current page: `#section` (or a bare `#`). The\n * hash-router form `#/x` is a ROUTE, not an anchor, and is not one of these. A router\n * link must not get an anchor — under a `HashRouter` `#section` would navigate to the\n * path `/section` — so every kit link draws it as a plain `<a>`.\n */\nexport function isInPageAnchor(href: string): boolean {\n return href.startsWith(\"#\") && !href.startsWith(\"#/\");\n}\n\n/**\n * Whether the provider's `linkComponent` (or a kit component's router fallback) draws\n * `href`: an in-app path, `#/x` included — not an external `href`\n * ({@link isExternalHref}) and not an in-page `#anchor` ({@link isInPageAnchor}).\n */\nexport function isRoutableHref(href: string): boolean {\n return !isExternalHref(href) && !isInPageAnchor(href);\n}\n\n/**\n * What draws a kit link to `href`: the component's own `renderLink` when it was given\n * one (it always wins — the caller asked for it by name), else the\n * `<UiKitProvider linkComponent>` for a routable `href` ({@link isRoutableHref}), else\n * `undefined` — a plain `<a>`. The one rule every kit link follows: `https:` /\n * `mailto:` / `//host` and `#section` stay plain anchors; `/x`, `x` and a hash-router\n * `#/x` go to the router link. Every kit link props type is a subset of the anchor\n * attributes `KitLinkProps` describes, which is what makes the cast sound.\n * @internal Shared by the kit's link-bearing components; not part of the barrel.\n */\nexport function pickLinkRenderer<P>(\n own: ((props: P) => ReactElement) | undefined,\n kitLink: KitLinkComponent | undefined,\n href: string | undefined,\n): ((props: P) => ReactElement) | undefined {\n if (own) return own;\n if (!kitLink || href === undefined || !isRoutableHref(href)) return undefined;\n return kitLink as unknown as (props: P) => ReactElement;\n}\n\n/** The plain primary click a page navigates on — not a middle click, not ⌘ / Ctrl /\n * Shift / Alt (new tab, new window, download), and not one a handler already took. */\nfunction isPlainNavigationClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n !event.defaultPrevented &&\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey\n );\n}\n\n/**\n * The `onClick` of a PLAIN `<a>` whose kit link was asked to `replace` (keksdose F1).\n *\n * A router link replaces the history entry itself (it is handed `replace`, see\n * {@link routerLinkNavigation}); a plain `<a>` has no attribute that says so, and the\n * browser's own navigation always pushes. So a plain click is taken over — the caller's\n * `onClick` runs first and may still `preventDefault` — and becomes\n * `location.replace(href)`: the same document load, with Back skipping the one-shot URL.\n * That is the case of `replace` together with `reloadDocument`, or an app with no\n * `linkComponent`. A modified click (new tab) and a `target` other than `_self` are left\n * to the browser: they do not navigate THIS history. Without `replace`, `onClick` comes\n * back untouched.\n * @internal\n */\nexport function replacingClick(\n onClick: ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined,\n href: string,\n replace: boolean | undefined,\n target?: string,\n): ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined {\n if (!replace || (target !== undefined && target !== \"\" && target !== \"_self\")) return onClick;\n return (event) => {\n onClick?.(event);\n if (!isPlainNavigationClick(event)) return;\n event.preventDefault();\n documentNavigation.replace(href);\n };\n}\n\n/**\n * What a ROUTER link is handed for `replace`: `{ replace: true }` when asked, else\n * nothing at all — so a provider link that spreads its props onto a DOM `<a>` never\n * receives an unknown attribute from a link that did not ask (see `KitLinkProps.replace`).\n * @internal\n */\nexport function routerLinkNavigation(replace: boolean | undefined): { replace?: true } {\n return replace ? { replace: true } : {};\n}\n\n/**\n * Draws `props` through `render` as a component of its own, so a router link's hooks\n * belong to it and not to the kit component calling it — for the kit components that\n * call their own `renderLink` inline and reach the provider's link through here.\n * @internal\n */\nexport function RenderedKitLink<P>({ render, props }: { render: (props: P) => ReactElement; props: P }) {\n return render(props);\n}\n\n/* ── TextLink ─────────────────────────────────────────────────────────────── */\n\n/**\n * `brand` (default) for a link in running text, `muted` for footer and meta links\n * that should not compete with the content, `danger` for a destructive one (\"Leave\n * group\"), `inherit` for a link that takes its surroundings' colour.\n *\n * `primary` and `secondary` are the body and the secondary text colour, UNDERLINED\n * (keksdose F3): a link that must read as part of the text around it — a name in a\n * list row, a reference in a card's meta line — where `brand` repaints it in the accent\n * and `muted` is a step too light to read as content. `secondary` darkens to the body\n * colour under the pointer, as `muted` does; `primary` has nowhere darker to go, so its\n * underline thickens instead.\n *\n * `warning` is the kit's `--warning` text colour, underlined (kastlan 46): a link inside\n * a `<Tone tone=\"warning\">` sentence or a warning banner, which `brand` would paint in a\n * second hue and `inherit` would leave indistinguishable from the sentence. Like\n * `primary`, it keeps its colour on hover and thickens the underline.\n *\n * The three colours that sit IN the text default to `underline=\"always\"`: with the\n * colour of their surroundings, the underline is the only thing that says \"link\"\n * (WCAG 1.4.1). An explicit `underline` still wins.\n */\nexport type TextLinkTone = \"brand\" | \"muted\" | \"danger\" | \"inherit\" | \"primary\" | \"secondary\" | \"warning\";\n\n/** `hover` (the default for `brand`, `muted`, `danger`, `inherit`): underlined under\n * the pointer — the idiom all three apps draw. `always` (the default for `primary`,\n * `secondary`, `warning`) for a link inside prose, where colour alone must not be the\n * only mark (WCAG 1.4.1). `none` where the context already says \"link\" (a footer nav). */\nexport type TextLinkUnderline = \"hover\" | \"always\" | \"none\";\n\n/** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */\nexport type TextLinkCurrent = \"page\" | \"step\" | \"location\" | \"date\" | \"time\" | \"true\";\n\n/** What {@link TextLinkProps.renderLink} (and the provider's `linkComponent`) is\n * handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface TextLinkRenderProps {\n href: string;\n /** The link's look — keep it, or the tone, underline and focus ring go. */\n className: string;\n children: ReactNode;\n ref?: Ref<HTMLAnchorElement>;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;\n \"aria-current\"?: TextLinkCurrent;\n id?: string;\n title?: string;\n /** Present, as `true`, only when the link was given `replace` — see\n * `KitLinkProps.replace`. Map it to your router's replace; never onto a DOM `<a>`. */\n replace?: boolean;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nexport interface TextLinkProps\n extends Omit<ComponentPropsWithoutRef<\"a\">, \"href\" | \"children\" | \"aria-current\" | \"onClick\" | \"onKeyDown\"> {\n href: string;\n children: ReactNode;\n /** Default `brand`. */\n tone?: TextLinkTone;\n /** Default `always` for `primary`, `secondary` and `warning`, else `hover`. */\n underline?: TextLinkUnderline;\n /**\n * Navigate by replacing the current history entry, so Back does not return to this\n * URL — keksdose F1, a one-shot URL (a confirmation landing, a `?done=1` hop). Handed\n * to the router link as `replace` (see `KitLinkProps.replace` for mapping it); on a\n * plain `<a>` (no `linkComponent`, or `reloadDocument`) a plain click becomes\n * `location.replace(href)`. Ignored on an `external` link, which opens a new tab.\n */\n replace?: boolean;\n /**\n * A plain `<a>` for an in-app `href`: the browser loads the whole document instead of\n * the router swapping the view — keksdose F1, a route served outside the SPA (a file\n * download, a server-rendered page, a logout that must drop every in-memory cache).\n * Wins over `renderLink` and the provider's `linkComponent`: it is the caller saying\n * \"not the router\" for this one link.\n */\n reloadDocument?: boolean;\n /**\n * The link leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`\n * (never the router's), an external-link mark after the text (mirrored in RTL), and\n * \"opens in a new tab\" (`common.opensInNewTab`) read after it — the mark says it\n * only to the eye. kastlan's BWO / BFS source links (rent-increase-calculator-page).\n */\n external?: boolean;\n /**\n * Keep the click (and the Enter that activates the link) from reaching an ancestor —\n * a link in a DataTable row with `onRowClick` goes to its own target instead of\n * running the row's action. kastlan's `CellLink` did this by hand.\n */\n stopPropagation?: boolean;\n /** Mark the link as the current one of a set — `true` is `aria-current=\"page\"`, the\n * legal footer's link to the page you are on. Drawn in semibold. */\n current?: boolean | TextLinkCurrent;\n /** A Lucide icon before the text, at the text's size — kastlan's \"← Back\" link. */\n icon?: LucideIcon;\n /** Your router's link for an in-app `href`. Default: the `<UiKitProvider\n * linkComponent>`, then a plain `<a>`. Ignored on an `external` link. */\n renderLink?: (props: TextLinkRenderProps) => ReactElement;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;\n ref?: Ref<HTMLAnchorElement>;\n}\n\nconst TONE: Record<TextLinkTone, string> = {\n brand: \"text-[var(--brand)] hover:text-[var(--brand-hover)]\",\n muted: \"text-[var(--text-muted)] hover:text-[var(--text-primary)]\",\n danger: \"text-[var(--danger)] hover:text-[var(--danger-hover)]\",\n inherit: \"\",\n primary: \"text-[var(--text-primary)] hover:decoration-2\",\n secondary: \"text-[var(--text-secondary)] hover:text-[var(--text-primary)]\",\n warning: \"text-[var(--warning)] hover:decoration-2\",\n};\n\n/** The tones whose colour is their surroundings' — underlined by default; see\n * {@link TextLinkTone}. */\nconst UNDERLINED_BY_DEFAULT = new Set<TextLinkTone>([\"primary\", \"secondary\", \"warning\"]);\n\nconst UNDERLINE: Record<TextLinkUnderline, string> = {\n hover: \"no-underline hover:underline\",\n always: \"underline\",\n none: \"no-underline\",\n};\n\n/**\n * The one inline link: a real `<a href>` (middle click, \"open in new tab\", link\n * semantics) through the app's router for an in-app path, in one of four tones.\n *\n * kastlan draws this link a dozen ways by hand — `EntityLink` and `CellLink` (text-primary,\n * hover underline, one stopping the row click), the legal pages' back link and footer,\n * the feedback page's URL links, the calculator's external sources with a hand-placed\n * ↗ and no word for a screen reader — and the lease wizard's unit step (lease-unit-step)\n * used a raw `<a>` to an in-app route, which reloads the whole app. Here the router\n * link comes from `<UiKitProvider linkComponent>`, set once.\n *\n * Inline, not `inline-flex`: a link inside a sentence must wrap with it. The icons sit\n * on the text's baseline at the text's size, so they scale with whatever type the link\n * is set in. The focus ring is an outline, which follows every line of a wrapped link.\n */\nexport function TextLink({\n href,\n children,\n tone = \"brand\",\n underline: underlineProp,\n external = false,\n replace,\n reloadDocument = false,\n stopPropagation = false,\n current,\n icon: Icon,\n renderLink,\n className,\n onClick,\n onKeyDown,\n ref,\n ...rest\n}: TextLinkProps) {\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const kitLink = useKitLink();\n const underline = underlineProp ?? (UNDERLINED_BY_DEFAULT.has(tone) ? \"always\" : \"hover\");\n const ariaCurrent: TextLinkCurrent | undefined =\n current === true ? \"page\" : current === false ? undefined : current;\n\n const look = cn(\n \"rounded-sm underline-offset-2 transition-colors\",\n \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]\",\n TONE[tone],\n UNDERLINE[underline],\n ariaCurrent && \"font-semibold\",\n className,\n );\n\n const body = (\n <>\n {Icon && <Icon aria-hidden className=\"me-1 inline size-[1em] shrink-0 align-[-0.125em]\" />}\n {children}\n {external && (\n <>\n <ExternalLink aria-hidden className=\"ms-1 inline size-[0.85em] align-[-0.1em] rtl:-scale-x-100\" />\n {\" \"}\n <span className=\"sr-only\">({common.opensInNewTab})</span>\n </>\n )}\n </>\n );\n\n const handleClick: TextLinkRenderProps[\"onClick\"] = stopPropagation\n ? (event) => {\n event.stopPropagation();\n onClick?.(event);\n }\n : onClick;\n const props: TextLinkRenderProps = {\n ...(rest as Partial<TextLinkRenderProps>),\n ref,\n href,\n className: look,\n \"aria-current\": ariaCurrent,\n onClick: handleClick,\n onKeyDown: stopPropagation\n ? (event) => {\n if (event.key === \"Enter\") event.stopPropagation();\n onKeyDown?.(event);\n }\n : onKeyDown,\n children: body,\n };\n\n const render = external || reloadDocument ? undefined : pickLinkRenderer(renderLink, kitLink, href);\n if (render) return <RenderedTextLink render={render} {...props} {...routerLinkNavigation(replace)} />;\n // A plain `<a>` never gets `replace` as an attribute; asked for, it takes the click.\n const plainClick = external ? handleClick : replacingClick(handleClick, href, replace, rest.target);\n const { children: content, ...anchor } = props;\n return (\n <a\n {...anchor}\n href={href}\n onClick={plainClick}\n {...(external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : null)}\n >\n {content}\n </a>\n );\n}\n\n/** Calls the link renderer as a component, so a router link's hooks are its own. */\nfunction RenderedTextLink({\n render,\n ...props\n}: TextLinkRenderProps & { render: (props: TextLinkRenderProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -41,6 +41,9 @@ interface FeedbackDialogTextLabels {
|
|
|
41
41
|
category: string;
|
|
42
42
|
subject: string;
|
|
43
43
|
body: string;
|
|
44
|
+
/** The body's label when `requireBody={false}`: "What happened? (optional)".
|
|
45
|
+
* Optional, so a complete `UiKitLabels` typed before 0.14.2 still compiles. */
|
|
46
|
+
bodyOptional?: string;
|
|
44
47
|
/** The heading over the attachment buttons. */
|
|
45
48
|
attachment: string;
|
|
46
49
|
/** The line beside the buttons naming the shortcut. */
|
|
@@ -70,7 +73,7 @@ interface FeedbackSubmission {
|
|
|
70
73
|
* (Steering Design feedback #39). The clipboard is where a region snip already
|
|
71
74
|
* is on every platform.
|
|
72
75
|
*/
|
|
73
|
-
declare function FeedbackDialog({ open, onClose, categories, category, onCategoryChange, labels: labelsProp, onSubmit, submitting, contextSlot, attachmentAccept, maxAttachmentBytes, onAttachmentError, onCaptureScreenshot, }: {
|
|
76
|
+
declare function FeedbackDialog({ open, onClose, categories, category, onCategoryChange, labels: labelsProp, onSubmit, submitting, contextSlot, attachmentAccept, maxAttachmentBytes, onAttachmentError, onCaptureScreenshot, requireBody, }: {
|
|
74
77
|
open: boolean;
|
|
75
78
|
onClose: () => void;
|
|
76
79
|
categories: FeedbackCategoryOption[];
|
|
@@ -91,6 +94,12 @@ declare function FeedbackDialog({ open, onClose, categories, category, onCategor
|
|
|
91
94
|
* attachment"; the returned file is fed through the same validation + preview.
|
|
92
95
|
*/
|
|
93
96
|
onCaptureScreenshot?: () => Promise<File | null>;
|
|
97
|
+
/**
|
|
98
|
+
* `false`: a report with only a subject can be sent, and the body's label says it is
|
|
99
|
+
* optional (`bodyOptional`, unless `labels.body` is passed). Default `true`, the
|
|
100
|
+
* dialog's behaviour until 0.14.2 (keksdose K1: its backend takes title-only reports).
|
|
101
|
+
*/
|
|
102
|
+
requireBody?: boolean;
|
|
94
103
|
}): react.JSX.Element | null;
|
|
95
104
|
|
|
96
105
|
export { DEFAULT_FEEDBACK_DIALOG_LABELS, type FeedbackAttachmentLabels, type FeedbackCategoryOption, FeedbackDialog, type FeedbackDialogLabels, type FeedbackDialogTextLabels, type FeedbackSubmission };
|
|
@@ -15,6 +15,7 @@ const DEFAULT_FEEDBACK_DIALOG_LABELS = {
|
|
|
15
15
|
category: "Category",
|
|
16
16
|
subject: "Subject",
|
|
17
17
|
body: "What happened?",
|
|
18
|
+
bodyOptional: "What happened? (optional)",
|
|
18
19
|
attachment: "Screenshot",
|
|
19
20
|
submitHint: "Ctrl/\u2318 + Enter to send",
|
|
20
21
|
cancel: "Cancel",
|
|
@@ -33,7 +34,8 @@ function FeedbackDialog({
|
|
|
33
34
|
attachmentAccept = DEFAULT_ATTACHMENT_ACCEPT,
|
|
34
35
|
maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
35
36
|
onAttachmentError,
|
|
36
|
-
onCaptureScreenshot
|
|
37
|
+
onCaptureScreenshot,
|
|
38
|
+
requireBody = true
|
|
37
39
|
}) {
|
|
38
40
|
const [title, setTitle] = useState("");
|
|
39
41
|
const [body, setBody] = useState("");
|
|
@@ -50,14 +52,22 @@ function FeedbackDialog({
|
|
|
50
52
|
setAttachment(null);
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
|
-
const canSubmit = !!title && !!body && !submitting;
|
|
55
|
+
const canSubmit = !!title.trim() && (!requireBody || !!body.trim()) && !submitting;
|
|
54
56
|
const trySubmit = () => {
|
|
55
57
|
if (canSubmit) void onSubmit({ title, body, category, attachment });
|
|
56
58
|
};
|
|
57
59
|
if (!open) return null;
|
|
58
60
|
const categoryField = /* @__PURE__ */ jsx(Select, { label: labels.category, value: category, onChange: (e) => onCategoryChange(e.target.value), children: categories.map((c) => /* @__PURE__ */ jsx("option", { value: c.value, children: c.label }, c.value)) });
|
|
59
61
|
const subjectField = /* @__PURE__ */ jsx(Input, { label: labels.subject, value: title, onChange: (e) => setTitle(e.target.value), variant: "display" });
|
|
60
|
-
const bodyField = /* @__PURE__ */ jsx(
|
|
62
|
+
const bodyField = /* @__PURE__ */ jsx(
|
|
63
|
+
Textarea,
|
|
64
|
+
{
|
|
65
|
+
rows: 5,
|
|
66
|
+
label: requireBody ? labels.body : labelsProp?.body ?? labels.bodyOptional ?? labels.body,
|
|
67
|
+
value: body,
|
|
68
|
+
onChange: (e) => setBody(e.target.value)
|
|
69
|
+
}
|
|
70
|
+
);
|
|
61
71
|
const attachmentField = /* @__PURE__ */ jsx(
|
|
62
72
|
FeedbackAttachmentField,
|
|
63
73
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/feedback/feedback-dialog.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Button, Input, PHONE_QUERY, Select, Textarea } from \"../components/ui\";\nimport { Modal } from \"../components/modal\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport {\n DEFAULT_ATTACHMENT_ACCEPT,\n DEFAULT_MAX_ATTACHMENT_BYTES,\n FeedbackAttachmentField,\n} from \"./feedback-attachment\";\n\nexport interface FeedbackCategoryOption {\n value: string;\n label: string;\n}\n\nexport interface FeedbackAttachmentLabels {\n /** The heading over the field. Optional: a note editor puts the buttons\n * straight under its textarea, where a second heading is noise. */\n attachment?: string;\n attachmentAdd: string;\n /** Label for the \"capture screenshot\" button. Optional — falls back to the provider's\n * `feedbackAttachment.attachmentCapture`, then English. */\n attachmentCapture?: string;\n /** The line under the attachment buttons saying a screenshot can be pasted\n * straight in. Optional — falls back to the provider's `feedbackAttachment`, then English. */\n attachmentPaste?: string;\n attachmentRemove: string;\n}\n\nexport interface FeedbackDialogLabels extends FeedbackAttachmentLabels {\n title: string;\n category: string;\n subject: string;\n body: string;\n attachment: string;\n submitHint: string;\n cancel: string;\n save: string;\n}\n\n/**\n * The dialog's own strings — the `feedbackDialog` namespace of `<UiKitProvider\n * labels>` (0.12.0). The attachment keys are not repeated here: they are the\n * `feedbackAttachment` namespace's, which the field inside already reads.\n *\n * Before, every key was required at the call site, so each app restated ten strings\n * wherever it mounted the dialog (kastlan feedback-button.tsx built the object inline).\n */\nexport interface FeedbackDialogTextLabels {\n title: string;\n category: string;\n subject: string;\n body: string;\n /** The heading over the attachment buttons. */\n attachment: string;\n /** The line beside the buttons naming the shortcut. */\n submitHint: string;\n cancel: string;\n save: string;\n}\n\nexport const DEFAULT_FEEDBACK_DIALOG_LABELS: FeedbackDialogTextLabels = {\n title: \"Send feedback\",\n category: \"Category\",\n subject: \"Subject\",\n body: \"What happened?\",\n attachment: \"Screenshot\",\n submitHint: \"Ctrl/⌘ + Enter to send\",\n cancel: \"Cancel\",\n save: \"Send\",\n};\n\nexport interface FeedbackSubmission {\n title: string;\n body: string;\n category: string;\n attachment: File | null;\n}\n\n/**\n * The generic feedback form dialog: category + subject + body + an optional image\n * attachment, with Ctrl/Cmd+Enter to submit. Domain-free — the app supplies the\n * category options, labels and an `onSubmit` that talks to its own backend, plus\n * an optional `contextSlot` for app-specific context (user, current URL, …).\n *\n * **A screenshot can be pasted straight in.** Ctrl/Cmd+V anywhere in the dialog\n * takes an image off the clipboard and makes it the attachment, through the same\n * validation and the same preview as the file picker. It is the gesture the two\n * ways in did not cover: `onCaptureScreenshot` snapshots the *whole* app view,\n * and the file picker needs a file — so somebody who wanted to show one panel,\n * or one region of one, had to save a crop to disk first and then find it again\n * (Steering Design feedback #39). The clipboard is where a region snip already\n * is on every platform.\n */\nexport function FeedbackDialog({\n open,\n onClose,\n categories,\n category,\n onCategoryChange,\n labels: labelsProp,\n onSubmit,\n submitting = false,\n contextSlot,\n attachmentAccept = DEFAULT_ATTACHMENT_ACCEPT,\n maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onAttachmentError,\n onCaptureScreenshot,\n}: {\n open: boolean;\n onClose: () => void;\n categories: FeedbackCategoryOption[];\n category: string;\n onCategoryChange: (value: string) => void;\n /** Prop > `<UiKitProvider labels={{ feedbackDialog, feedbackAttachment }}>` >\n * English. Optional since 0.12.0; a whole `FeedbackDialogLabels` still fits. */\n labels?: Partial<FeedbackDialogLabels>;\n onSubmit: (data: FeedbackSubmission) => void | Promise<void>;\n submitting?: boolean;\n contextSlot?: ReactNode;\n attachmentAccept?: string[];\n maxAttachmentBytes?: number;\n onAttachmentError?: (kind: \"type\" | \"size\") => void;\n /**\n * Optional: capture a screenshot of the underlying app view and return it as a\n * File. When provided, a \"Capture screenshot\" button is shown next to \"Add\n * attachment\"; the returned file is fed through the same validation + preview.\n */\n onCaptureScreenshot?: () => Promise<File | null>;\n}) {\n const [title, setTitle] = useState(\"\");\n const [body, setBody] = useState(\"\");\n const [attachment, setAttachment] = useState<File | null>(null);\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n const labels = useKitLabels(\"feedbackDialog\", DEFAULT_FEEDBACK_DIALOG_LABELS, labelsProp);\n // The attachment field resolves its own keys from `feedbackAttachment`; it gets the\n // prop's (so a caller's `attachmentAdd` still wins) plus the resolved heading.\n const attachmentLabels = { ...labelsProp, attachment: labels.attachment };\n\n // Reset the form whenever the dialog is (re)opened — during render, on the\n // closed-to-open transition, so the old draft never paints for a frame.\n const [wasOpen, setWasOpen] = useState(open);\n if (open !== wasOpen) {\n setWasOpen(open);\n if (open) {\n setTitle(\"\");\n setBody(\"\");\n setAttachment(null);\n }\n }\n\n const canSubmit = !!title && !!body && !submitting;\n const trySubmit = () => {\n if (canSubmit) void onSubmit({ title, body, category, attachment });\n };\n\n if (!open) return null;\n\n const categoryField = (\n <Select label={labels.category} value={category} onChange={(e) => onCategoryChange(e.target.value)}>\n {categories.map((c) => (\n <option key={c.value} value={c.value}>\n {c.label}\n </option>\n ))}\n </Select>\n );\n // On a phone the subject leads as a heading rather than as the second of two\n // identical boxes under a select (Keksdose feedback #179). `variant=\"display\"`\n // is a no-op above the phone breakpoint, so the desktop dialog is unchanged.\n const subjectField = (\n <Input label={labels.subject} value={title} onChange={(e) => setTitle(e.target.value)} variant=\"display\" />\n );\n const bodyField = (\n <Textarea rows={5} label={labels.body} value={body} onChange={(e) => setBody(e.target.value)} />\n );\n const attachmentField = (\n <FeedbackAttachmentField\n value={attachment}\n onChange={setAttachment}\n labels={attachmentLabels}\n accept={attachmentAccept}\n maxBytes={maxAttachmentBytes}\n onError={onAttachmentError}\n onCaptureScreenshot={onCaptureScreenshot}\n // On `document`, not on the panel: the Modal focuses its own panel on open\n // and traps Tab inside it, so while this dialog is up every paste in the\n // page is meant for it — including the one made with nothing in\n // particular focused, which never reaches a React `onPaste` on a child.\n documentPaste={open}\n />\n );\n\n return (\n <Modal\n onClose={onClose}\n // The one dialog whose subject is the page behind it (Keksdose dev#460: \"Make\n // the feedback dialog draggable so I can see behind it if it blocks\n // something\") — you are describing what is under it while you type.\n draggable\n onKeyDown={(e) => {\n if ((e.ctrlKey || e.metaKey) && e.key === \"Enter\") {\n e.preventDefault();\n trySubmit();\n }\n }}\n >\n {/* Also the drag handle: pressing anywhere on the panel's own chrome moves it,\n and the heading is the strip a user reaches for. */}\n <h2 className=\"mb-3 text-lg font-semibold\">{labels.title}</h2>\n <div className=\"space-y-3\">\n {isMobile ? (\n // Phone shape, the same reasoning as the transaction editor's (#177):\n // the desktop order is the DATA MODEL's order — category, then subject,\n // then body — but nobody opens this dialog to pick a category. What they\n // came to do is say the thing, so that leads; the classification and the\n // evidence follow, a tier down.\n <>\n {subjectField}\n {bodyField}\n <div className=\"space-y-3 border-t border-[var(--border)] pt-3\">\n {categoryField}\n {attachmentField}\n {contextSlot}\n </div>\n </>\n ) : (\n <>\n {categoryField}\n {subjectField}\n {bodyField}\n {attachmentField}\n {contextSlot}\n </>\n )}\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"text-xs text-[var(--text-placeholder)]\">{labels.submitHint}</div>\n <div className=\"flex gap-2\">\n <Button variant=\"ghost\" onClick={onClose}>\n {labels.cancel}\n </Button>\n <Button onClick={trySubmit} disabled={!canSubmit}>\n {labels.save}\n </Button>\n </div>\n </div>\n </div>\n </Modal>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/feedback/feedback-dialog.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Button, Input, PHONE_QUERY, Select, Textarea } from \"../components/ui\";\nimport { Modal } from \"../components/modal\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport {\n DEFAULT_ATTACHMENT_ACCEPT,\n DEFAULT_MAX_ATTACHMENT_BYTES,\n FeedbackAttachmentField,\n} from \"./feedback-attachment\";\n\nexport interface FeedbackCategoryOption {\n value: string;\n label: string;\n}\n\nexport interface FeedbackAttachmentLabels {\n /** The heading over the field. Optional: a note editor puts the buttons\n * straight under its textarea, where a second heading is noise. */\n attachment?: string;\n attachmentAdd: string;\n /** Label for the \"capture screenshot\" button. Optional — falls back to the provider's\n * `feedbackAttachment.attachmentCapture`, then English. */\n attachmentCapture?: string;\n /** The line under the attachment buttons saying a screenshot can be pasted\n * straight in. Optional — falls back to the provider's `feedbackAttachment`, then English. */\n attachmentPaste?: string;\n attachmentRemove: string;\n}\n\nexport interface FeedbackDialogLabels extends FeedbackAttachmentLabels {\n title: string;\n category: string;\n subject: string;\n body: string;\n attachment: string;\n submitHint: string;\n cancel: string;\n save: string;\n}\n\n/**\n * The dialog's own strings — the `feedbackDialog` namespace of `<UiKitProvider\n * labels>` (0.12.0). The attachment keys are not repeated here: they are the\n * `feedbackAttachment` namespace's, which the field inside already reads.\n *\n * Before, every key was required at the call site, so each app restated ten strings\n * wherever it mounted the dialog (kastlan feedback-button.tsx built the object inline).\n */\nexport interface FeedbackDialogTextLabels {\n title: string;\n category: string;\n subject: string;\n body: string;\n /** The body's label when `requireBody={false}`: \"What happened? (optional)\".\n * Optional, so a complete `UiKitLabels` typed before 0.14.2 still compiles. */\n bodyOptional?: string;\n /** The heading over the attachment buttons. */\n attachment: string;\n /** The line beside the buttons naming the shortcut. */\n submitHint: string;\n cancel: string;\n save: string;\n}\n\nexport const DEFAULT_FEEDBACK_DIALOG_LABELS: FeedbackDialogTextLabels = {\n title: \"Send feedback\",\n category: \"Category\",\n subject: \"Subject\",\n body: \"What happened?\",\n bodyOptional: \"What happened? (optional)\",\n attachment: \"Screenshot\",\n submitHint: \"Ctrl/⌘ + Enter to send\",\n cancel: \"Cancel\",\n save: \"Send\",\n};\n\nexport interface FeedbackSubmission {\n title: string;\n body: string;\n category: string;\n attachment: File | null;\n}\n\n/**\n * The generic feedback form dialog: category + subject + body + an optional image\n * attachment, with Ctrl/Cmd+Enter to submit. Domain-free — the app supplies the\n * category options, labels and an `onSubmit` that talks to its own backend, plus\n * an optional `contextSlot` for app-specific context (user, current URL, …).\n *\n * **A screenshot can be pasted straight in.** Ctrl/Cmd+V anywhere in the dialog\n * takes an image off the clipboard and makes it the attachment, through the same\n * validation and the same preview as the file picker. It is the gesture the two\n * ways in did not cover: `onCaptureScreenshot` snapshots the *whole* app view,\n * and the file picker needs a file — so somebody who wanted to show one panel,\n * or one region of one, had to save a crop to disk first and then find it again\n * (Steering Design feedback #39). The clipboard is where a region snip already\n * is on every platform.\n */\nexport function FeedbackDialog({\n open,\n onClose,\n categories,\n category,\n onCategoryChange,\n labels: labelsProp,\n onSubmit,\n submitting = false,\n contextSlot,\n attachmentAccept = DEFAULT_ATTACHMENT_ACCEPT,\n maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onAttachmentError,\n onCaptureScreenshot,\n requireBody = true,\n}: {\n open: boolean;\n onClose: () => void;\n categories: FeedbackCategoryOption[];\n category: string;\n onCategoryChange: (value: string) => void;\n /** Prop > `<UiKitProvider labels={{ feedbackDialog, feedbackAttachment }}>` >\n * English. Optional since 0.12.0; a whole `FeedbackDialogLabels` still fits. */\n labels?: Partial<FeedbackDialogLabels>;\n onSubmit: (data: FeedbackSubmission) => void | Promise<void>;\n submitting?: boolean;\n contextSlot?: ReactNode;\n attachmentAccept?: string[];\n maxAttachmentBytes?: number;\n onAttachmentError?: (kind: \"type\" | \"size\") => void;\n /**\n * Optional: capture a screenshot of the underlying app view and return it as a\n * File. When provided, a \"Capture screenshot\" button is shown next to \"Add\n * attachment\"; the returned file is fed through the same validation + preview.\n */\n onCaptureScreenshot?: () => Promise<File | null>;\n /**\n * `false`: a report with only a subject can be sent, and the body's label says it is\n * optional (`bodyOptional`, unless `labels.body` is passed). Default `true`, the\n * dialog's behaviour until 0.14.2 (keksdose K1: its backend takes title-only reports).\n */\n requireBody?: boolean;\n}) {\n const [title, setTitle] = useState(\"\");\n const [body, setBody] = useState(\"\");\n const [attachment, setAttachment] = useState<File | null>(null);\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n const labels = useKitLabels(\"feedbackDialog\", DEFAULT_FEEDBACK_DIALOG_LABELS, labelsProp);\n // The attachment field resolves its own keys from `feedbackAttachment`; it gets the\n // prop's (so a caller's `attachmentAdd` still wins) plus the resolved heading.\n const attachmentLabels = { ...labelsProp, attachment: labels.attachment };\n\n // Reset the form whenever the dialog is (re)opened — during render, on the\n // closed-to-open transition, so the old draft never paints for a frame.\n const [wasOpen, setWasOpen] = useState(open);\n if (open !== wasOpen) {\n setWasOpen(open);\n if (open) {\n setTitle(\"\");\n setBody(\"\");\n setAttachment(null);\n }\n }\n\n // Trimmed: a subject or body of spaces is empty (keksdose's API answers a blank title\n // with a 422, and a blank report is no report).\n const canSubmit = !!title.trim() && (!requireBody || !!body.trim()) && !submitting;\n const trySubmit = () => {\n if (canSubmit) void onSubmit({ title, body, category, attachment });\n };\n\n if (!open) return null;\n\n const categoryField = (\n <Select label={labels.category} value={category} onChange={(e) => onCategoryChange(e.target.value)}>\n {categories.map((c) => (\n <option key={c.value} value={c.value}>\n {c.label}\n </option>\n ))}\n </Select>\n );\n // On a phone the subject leads as a heading rather than as the second of two\n // identical boxes under a select (Keksdose feedback #179). `variant=\"display\"`\n // is a no-op above the phone breakpoint, so the desktop dialog is unchanged.\n const subjectField = (\n <Input label={labels.subject} value={title} onChange={(e) => setTitle(e.target.value)} variant=\"display\" />\n );\n const bodyField = (\n <Textarea\n rows={5}\n label={requireBody ? labels.body : (labelsProp?.body ?? labels.bodyOptional ?? labels.body)}\n value={body}\n onChange={(e) => setBody(e.target.value)}\n />\n );\n const attachmentField = (\n <FeedbackAttachmentField\n value={attachment}\n onChange={setAttachment}\n labels={attachmentLabels}\n accept={attachmentAccept}\n maxBytes={maxAttachmentBytes}\n onError={onAttachmentError}\n onCaptureScreenshot={onCaptureScreenshot}\n // On `document`, not on the panel: the Modal focuses its own panel on open\n // and traps Tab inside it, so while this dialog is up every paste in the\n // page is meant for it — including the one made with nothing in\n // particular focused, which never reaches a React `onPaste` on a child.\n documentPaste={open}\n />\n );\n\n return (\n <Modal\n onClose={onClose}\n // The one dialog whose subject is the page behind it (Keksdose dev#460: \"Make\n // the feedback dialog draggable so I can see behind it if it blocks\n // something\") — you are describing what is under it while you type.\n draggable\n onKeyDown={(e) => {\n if ((e.ctrlKey || e.metaKey) && e.key === \"Enter\") {\n e.preventDefault();\n trySubmit();\n }\n }}\n >\n {/* Also the drag handle: pressing anywhere on the panel's own chrome moves it,\n and the heading is the strip a user reaches for. */}\n <h2 className=\"mb-3 text-lg font-semibold\">{labels.title}</h2>\n <div className=\"space-y-3\">\n {isMobile ? (\n // Phone shape, the same reasoning as the transaction editor's (#177):\n // the desktop order is the DATA MODEL's order — category, then subject,\n // then body — but nobody opens this dialog to pick a category. What they\n // came to do is say the thing, so that leads; the classification and the\n // evidence follow, a tier down.\n <>\n {subjectField}\n {bodyField}\n <div className=\"space-y-3 border-t border-[var(--border)] pt-3\">\n {categoryField}\n {attachmentField}\n {contextSlot}\n </div>\n </>\n ) : (\n <>\n {categoryField}\n {subjectField}\n {bodyField}\n {attachmentField}\n {contextSlot}\n </>\n )}\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"text-xs text-[var(--text-placeholder)]\">{labels.submitHint}</div>\n <div className=\"flex gap-2\">\n <Button variant=\"ghost\" onClick={onClose}>\n {labels.cancel}\n </Button>\n <Button onClick={trySubmit} disabled={!canSubmit}>\n {labels.save}\n </Button>\n </div>\n </div>\n </div>\n </Modal>\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/i18n/locales/de.js
CHANGED
|
@@ -15,6 +15,7 @@ function uiKitLabelsDe(numberLocale = "de-DE") {
|
|
|
15
15
|
category: "Kategorie",
|
|
16
16
|
subject: "Betreff",
|
|
17
17
|
body: "Was ist passiert?",
|
|
18
|
+
bodyOptional: "Was ist passiert? (optional)",
|
|
18
19
|
attachment: "Screenshot",
|
|
19
20
|
submitHint: "Strg/\u2318 + Enter zum Senden",
|
|
20
21
|
cancel: "Abbrechen",
|