@eifi1/ui-kit 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/components/amount-input.d.ts +6 -0
- package/dist/components/amount-input.js +35 -5
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/error-boundary.d.ts +8 -1
- package/dist/components/error-boundary.js +18 -2
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +7 -3
- package/dist/components/numpad-sheet.js +4 -3
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-header.d.ts +55 -2
- package/dist/components/page-header.js +34 -3
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/text.js.map +1 -1
- package/dist/components/tooltip.d.ts +31 -0
- package/dist/components/tooltip.js +33 -0
- package/dist/components/tooltip.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +87 -6
- package/dist/feedback/feedback-thread.js +134 -58
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/i18n/locales/de.js +3 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.js +2 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +2 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +2 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +2 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +2 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/lib/format.js +1 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +3 -1
- package/dist/rhf/fields.js.map +1 -1
- package/package.json +1 -1
- package/src/components/amount-input.tsx +73 -7
- package/src/components/error-boundary.tsx +33 -3
- package/src/components/numpad-sheet.tsx +13 -4
- package/src/components/page-header.tsx +98 -3
- package/src/components/text.tsx +2 -1
- package/src/components/tooltip.tsx +109 -2
- package/src/feedback/feedback-thread.tsx +229 -37
- package/src/i18n/locales/de.ts +2 -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/lib/format.ts +3 -1
- package/src/rhf/fields.tsx +3 -1
|
@@ -85,15 +85,19 @@ interface NumberPadSheetLabels extends Partial<CalculatorLabels> {
|
|
|
85
85
|
/** Visible text on the primary key, not an `aria-label`. */
|
|
86
86
|
done?: string;
|
|
87
87
|
}
|
|
88
|
-
declare function NumberPadSheet({ value, onChange, onDone, label, labels, decimalMark, }: {
|
|
88
|
+
declare function NumberPadSheet({ value, onChange, onDone, label, labels, decimalMark, onResult, }: {
|
|
89
89
|
value: string;
|
|
90
90
|
/** Fired with the raw (sanitised) field text on every key — same contract as
|
|
91
91
|
* the host field's own `onChange`. */
|
|
92
92
|
onChange: (value: string) => void;
|
|
93
93
|
/** The glyph on the decimal key — "," where the host shows a comma (AmountInput
|
|
94
|
-
* in fr-CH, kastlan 40).
|
|
95
|
-
*
|
|
94
|
+
* in fr-CH, kastlan 40). With "," the key inserts a comma and the text is handed
|
|
95
|
+
* back unsanitized, for the host to read by its locale (keksdose G1). */
|
|
96
96
|
decimalMark?: "." | ",";
|
|
97
|
+
/** "=": the evaluated result, dot-decimal. Default `onChange`. A host that reads
|
|
98
|
+
* typed text by the locale's marks takes it apart from keystrokes, since a result
|
|
99
|
+
* like "1.234" is a decimal and never a grouped thousand. */
|
|
100
|
+
onResult?: (value: string) => void;
|
|
97
101
|
/** Fired on "Done": the host blurs the input, which commits (evaluates) and
|
|
98
102
|
* unmounts the sheet via its existing blur handler. */
|
|
99
103
|
onDone: () => void;
|
|
@@ -37,7 +37,8 @@ function NumberPadSheet({
|
|
|
37
37
|
onDone,
|
|
38
38
|
label,
|
|
39
39
|
labels,
|
|
40
|
-
decimalMark = "."
|
|
40
|
+
decimalMark = ".",
|
|
41
|
+
onResult
|
|
41
42
|
}) {
|
|
42
43
|
const fromProps = useMemo(() => {
|
|
43
44
|
if (!labels) return void 0;
|
|
@@ -48,11 +49,11 @@ function NumberPadSheet({
|
|
|
48
49
|
useEscapeKey(onDone);
|
|
49
50
|
const result = evaluateExpression(value);
|
|
50
51
|
const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : "";
|
|
51
|
-
const insert = (ch) => onChange(sanitizeLive(value + ch));
|
|
52
|
+
const insert = (ch) => onChange(decimalMark === "," ? value + (ch === "." ? "," : ch) : sanitizeLive(value + ch));
|
|
52
53
|
const backspace = () => onChange(value.slice(0, -1));
|
|
53
54
|
const clearAll = () => onChange("");
|
|
54
55
|
const equals = () => {
|
|
55
|
-
if (result !== null) onChange(formatResult(result));
|
|
56
|
+
if (result !== null) (onResult ?? onChange)(formatResult(result));
|
|
56
57
|
};
|
|
57
58
|
const sheet = /* @__PURE__ */ jsxs(
|
|
58
59
|
"div",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/numpad-sheet.tsx"],"sourcesContent":["import { createPortal } from \"react-dom\";\nimport { Delete } from \"lucide-react\";\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { evaluateExpression, formatResult, sanitizeLive } from \"../lib/calc\";\nimport {\n DEFAULT_CALCULATOR_LABELS,\n useKitLabels,\n type CalculatorLabels,\n} from \"../i18n/kit-labels\";\n\n/**\n * A full calculator keypad rendered as a bottom sheet — the mobile counterpart to\n * the desktop {@link CalculatorButton} popover, and the replacement for the old\n * under-the-field operator bar (`MathKeys`, feedback #334).\n *\n * A native app (YNAB) can swap the system keyboard for its own keypad; a PWA\n * cannot — but it CAN suppress the OS keyboard with `inputMode=\"none\"` on the\n * host field and paint its own keypad in the freed space. So the host input keeps\n * real focus/caret/selection + the existing value/commit contract, while this\n * sheet drives it: digits + operators + a live `= …` preview, evaluated through\n * the shared {@link evaluateExpression} engine. Desktop is untouched (it keeps the\n * native keyboard and the calculator popover); the host gates this to mobile.\n *\n * Buttons commit through the host's `onChange`/blur, so the sheet is stateless —\n * the field text is the single source of truth. `onPointerDown` preventDefault on\n * the root keeps the host input focused (caret stays, keyboard stays hidden) as\n * the user taps keys; the buttons' `onClick` still fires.\n */\n/** Names for the pad itself and its four non-digit keys. The first four are\n * screen-reader names; `done` is the one string here that is READ OFF THE SCREEN —\n * the brand-filled primary button — and it was a hardcoded English literal while\n * its three neighbours each took an override, so a German phone showed \"Done\"\n * among German labels with no prop able to change it. */\nexport interface NumberPadSheetLabels extends Partial<CalculatorLabels> {\n /** The pad's own name — the `calculator.panel` key under its older name here. */\n pad?: string;\n backspace?: string;\n clear?: string;\n equals?: string;\n /** Visible text on the primary key, not an `aria-label`. */\n done?: string;\n}\n\ntype PadKey =\n | { kind: \"ins\"; label: string; ins: string; accent?: boolean; name?: PadKeyName }\n | { kind: \"back\" };\n\n/** The {@link CalculatorLabels} key naming a non-digit key; digits are their glyph. */\ntype PadKeyName = \"plus\" | \"minus\" | \"times\" | \"divide\" | \"decimal\";\n\n// Insert chars that survive `sanitizeLive` (it keeps `× ÷` and ASCII `+ - * /`\n// but strips the unicode minus `−`, so minus inserts an ASCII \"-\" while showing\n// the nicer glyph). The evaluator normalises `× ÷` back to `* /`.\nconst PAD_KEYS: PadKey[] = [\n { kind: \"ins\", label: \"7\", ins: \"7\" },\n { kind: \"ins\", label: \"8\", ins: \"8\" },\n { kind: \"ins\", label: \"9\", ins: \"9\" },\n { kind: \"ins\", label: \"÷\", ins: \"÷\", accent: true, name: \"divide\" },\n { kind: \"ins\", label: \"4\", ins: \"4\" },\n { kind: \"ins\", label: \"5\", ins: \"5\" },\n { kind: \"ins\", label: \"6\", ins: \"6\" },\n { kind: \"ins\", label: \"×\", ins: \"×\", accent: true, name: \"times\" },\n { kind: \"ins\", label: \"1\", ins: \"1\" },\n { kind: \"ins\", label: \"2\", ins: \"2\" },\n { kind: \"ins\", label: \"3\", ins: \"3\" },\n { kind: \"ins\", label: \"−\", ins: \"-\", accent: true, name: \"minus\" },\n { kind: \"ins\", label: \".\", ins: \".\", name: \"decimal\" },\n { kind: \"ins\", label: \"0\", ins: \"0\" },\n { kind: \"back\" },\n { kind: \"ins\", label: \"+\", ins: \"+\", accent: true, name: \"plus\" },\n];\n\n/** Every key's shape — including the thing this pad used to delete without replacing.\n *\n * `focus:outline-none` alone is not a style choice, it is the removal of the only\n * signal a keyboard user has; on a 4×4 grid of identical tiles it leaves no way at all\n * to tell which key Enter is about to press (the audit's §a11y, and the same defect as\n * the dropdown search box).\n *\n * `focus-visible` and `ring-inset`, for the two reasons {@link ToggleGroup} already\n * writes down. This is a touch control first, and a plain `focus:` ring paints itself\n * on every TAP, because a tap focuses the button. And the keys sit 6px apart, which is\n * thinner than an outward ring plus the neighbour it would spill onto — inset keeps the\n * ring inside the key it describes.\n *\n * --brand rather than --border-strong: the operator keys are filled with --border, and\n * a ring in the colour of the thing it surrounds is not an indicator. */\nconst PAD_BTN =\n \"flex h-14 items-center justify-center rounded-lg text-lg font-medium transition-transform select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)] active:scale-[0.97]\";\nconst PAD_DIGIT = \"bg-[var(--bg-surface-2)] text-[var(--text-primary)] active:bg-[var(--border)]\";\nconst PAD_ACCENT = \"bg-[var(--border)] text-[var(--text-primary)] active:bg-[var(--bg-surface-2)]\";\n\nexport function NumberPadSheet({\n value,\n onChange,\n onDone,\n label,\n labels,\n decimalMark = \".\",\n}: {\n value: string;\n /** Fired with the raw (sanitised) field text on every key — same contract as\n * the host field's own `onChange`. */\n onChange: (value: string) => void;\n /** The glyph on the decimal key — \",\" where the host shows a comma (AmountInput\n * in fr-CH, kastlan 40). The key still inserts a dot; the host's text is dot-form\n * underneath and `sanitizeLive` reads either. */\n decimalMark?: \".\" | \",\";\n /** Fired on \"Done\": the host blurs the input, which commits (evaluates) and\n * unmounts the sheet via its existing blur handler. */\n onDone: () => void;\n /** Optional field label, echoed in the sheet header so the user still knows\n * which field they're editing when the sheet covers it. */\n label?: ReactNode;\n /** Names for the pad itself and its non-digit keys — screen-reader names, except\n * `done`, which is the visible text on the primary key. Merged over `calculator`\n * from `<UiKitProvider labels>`: this is the same keypad as `CalculatorButton`'s,\n * on a phone, and one translation covers both. Digits need no name — their glyph\n * IS the name; the operators do (see {@link CalculatorLabels.plus}). */\n labels?: NumberPadSheetLabels;\n}) {\n // `pad` is this sheet's older spelling of `panel`, so it is folded in as that.\n const fromProps = useMemo(() => {\n if (!labels) return undefined;\n const { pad, ...rest } = labels;\n return pad === undefined ? rest : { ...rest, panel: pad };\n }, [labels]);\n const text = useKitLabels(\"calculator\", DEFAULT_CALCULATOR_LABELS, fromProps);\n\n // NO `useBodyScrollLock`, and that is the point of the control (Keksdose live\n // #317: *\"Background not scrollable when the amount input calculator field is\n // open\"*).\n //\n // This sheet is a KEYBOARD, not a dialog. It is opened by focusing a field and\n // exists only because a PWA cannot swap the system keyboard for its own — so it\n // suppresses the OS keyboard with `inputMode=\"none\"` and paints itself in the\n // freed space. An OS keyboard does not freeze the page behind it; it takes the\n // bottom of the screen and leaves you free to scroll what is left, which is how\n // you reach the field you are typing into when the keys cover it.\n //\n // Locking here made the page unreachable at exactly the moment it matters most:\n // correcting a receipt total against the running line sum, where the figure you\n // are comparing against sits below the keypad. The other four holders of that\n // hook are all modal — `Modal`, `PickerSheet`, `FullBleedDialog`, the DataTable\n // row dialog — and they stay locked, because for them the page behind is not\n // part of the task.\n //\n // The pad still does not scroll ITSELF away under a stray drag: the root's\n // `onPointerDown` preventDefault (below) keeps the host input focused and eats\n // the gesture on the sheet, so a touch that starts on a key is not a page scroll.\n\n // ── NO focus trap either, and for a harder reason than the scroll lock ────────\n //\n // Wave 3 put `useFocusTrap` on every overlay in this package. This one is the\n // exception, and it is not an oversight to be tidied up later: a trap here would\n // DELETE THE COMPONENT IT WAS PROTECTING. Both hosts render the pad with\n // `showNumpad = isMobile && !disabled && focused`, where `focused` is the host\n // input's own focus state. Moving focus into the pad blurs that input, the host\n // sets `focused` false, and the pad unmounts — on mount, before a single key can be\n // pressed. The blur also COMMITS, so the trap would evaluate a half-typed\n // expression on the way out.\n //\n // That is the contract, not an accident of it. This sheet exists because a PWA\n // cannot swap the system keyboard for its own: the host keeps real focus, caret and\n // selection while `inputMode=\"none\"` suppresses the OS keys, and the root's\n // `onPointerDown` preventDefault below is there for the same reason. The pad is\n // `role=\"group\"`, not a dialog. A keyboard does not trap focus; it is what you type\n // WITH.\n //\n // What was genuinely missing is a way OUT that is not a tap. `useEscapeKey` and not\n // a handler on the sheet, because the sheet never has the focus a keydown would\n // bubble from — the host does, and the host is not this component. Escape does what\n // the Done key does rather than cancelling, because committing on blur is the\n // host's contract and a cancel would have to be the host's to offer.\n //\n // The limit, since it is better written down than discovered: inside a `Modal` the\n // dialog's own Escape handler sits on the focus path and stops the event before a\n // document listener sees it, so there the press closes the dialog — which it did\n // before this line existed too, and which takes the pad with it either way.\n useEscapeKey(onDone);\n\n const result = evaluateExpression(value);\n const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : \"\";\n\n const insert = (ch: string) => onChange(sanitizeLive(value + ch));\n const backspace = () => onChange(value.slice(0, -1));\n const clearAll = () => onChange(\"\");\n const equals = () => {\n if (result !== null) onChange(formatResult(result));\n };\n\n const sheet = (\n <div\n role=\"group\"\n aria-label={text.panel}\n // Keep the host input focused when tapping the pad: preventDefault on\n // pointerdown blocks the focus/blur, while the buttons' click still fires.\n onPointerDown={(e) => e.preventDefault()}\n className=\"fixed inset-x-0 bottom-0 z-50 border-t border-[var(--border)] bg-[var(--bg-surface)] px-2 pt-2 shadow-[0_-4px_24px_rgba(0,0,0,0.12)]\"\n style={{ paddingBottom: \"max(0.5rem, env(safe-area-inset-bottom))\" }}\n >\n {/* Header display: which field + the running expression and live result, so\n the value stays visible even when the sheet covers the field. */}\n <div className=\"flex items-end justify-between gap-3 px-2 pb-2\">\n {label != null && (\n <span className=\"truncate pb-1 text-xs font-medium text-[var(--money-neutral)]\">{label}</span>\n )}\n {/* At the logical end, opposite the label; the figures themselves stay\n left-to-right and right-aligned, like the desktop calculator's. */}\n <div className=\"ms-auto min-w-0\">\n <div dir=\"ltr\" className=\"truncate text-right font-mono text-lg leading-tight text-[var(--text-primary)]\">{value || \"0\"}</div>\n <div dir=\"ltr\" className=\"h-4 text-right font-mono text-xs text-[var(--money-neutral)]\">{preview}</div>\n </div>\n </div>\n\n <div className=\"grid grid-cols-4 gap-1.5\">\n {PAD_KEYS.map((key, i) =>\n key.kind === \"back\" ? (\n <button\n key=\"back\"\n type=\"button\"\n aria-label={text.backspace}\n onClick={backspace}\n className={cn(PAD_BTN, PAD_ACCENT)}\n >\n <Delete className=\"size-5\" />\n </button>\n ) : (\n <button\n key={`${key.ins}-${i}`}\n type=\"button\"\n aria-label={key.name ? text[key.name] : key.label}\n onClick={() => insert(key.ins)}\n className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}\n >\n {key.kind === \"ins\" && key.name === \"decimal\" ? decimalMark : key.label}\n </button>\n ),\n )}\n </div>\n\n <div className=\"mt-1.5 grid grid-cols-4 gap-1.5\">\n <button type=\"button\" aria-label={text.clear} onClick={clearAll} className={cn(PAD_BTN, PAD_ACCENT)}>\n C\n </button>\n <button type=\"button\" aria-label={text.equals} onClick={equals} className={cn(PAD_BTN, PAD_ACCENT)}>\n =\n </button>\n <button\n type=\"button\"\n onClick={onDone}\n // The one key whose own fill is --brand, so it rings in the colour that fill\n // was chosen to be legible against instead (tailwind-merge keeps the later\n // ring colour). A --brand ring here would be present in the DOM and invisible\n // on screen, which is the defect this change exists to fix.\n className={cn(\n PAD_BTN,\n \"col-span-2 bg-[var(--brand)] text-[var(--brand-contrast)] focus-visible:ring-[var(--brand-contrast)] active:bg-[var(--brand-hover)]\",\n )}\n >\n {text.done}\n </button>\n </div>\n </div>\n );\n\n return createPortal(sheet, document.body);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/numpad-sheet.tsx"],"sourcesContent":["import { createPortal } from \"react-dom\";\nimport { Delete } from \"lucide-react\";\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { evaluateExpression, formatResult, sanitizeLive } from \"../lib/calc\";\nimport {\n DEFAULT_CALCULATOR_LABELS,\n useKitLabels,\n type CalculatorLabels,\n} from \"../i18n/kit-labels\";\n\n/**\n * A full calculator keypad rendered as a bottom sheet — the mobile counterpart to\n * the desktop {@link CalculatorButton} popover, and the replacement for the old\n * under-the-field operator bar (`MathKeys`, feedback #334).\n *\n * A native app (YNAB) can swap the system keyboard for its own keypad; a PWA\n * cannot — but it CAN suppress the OS keyboard with `inputMode=\"none\"` on the\n * host field and paint its own keypad in the freed space. So the host input keeps\n * real focus/caret/selection + the existing value/commit contract, while this\n * sheet drives it: digits + operators + a live `= …` preview, evaluated through\n * the shared {@link evaluateExpression} engine. Desktop is untouched (it keeps the\n * native keyboard and the calculator popover); the host gates this to mobile.\n *\n * Buttons commit through the host's `onChange`/blur, so the sheet is stateless —\n * the field text is the single source of truth. `onPointerDown` preventDefault on\n * the root keeps the host input focused (caret stays, keyboard stays hidden) as\n * the user taps keys; the buttons' `onClick` still fires.\n */\n/** Names for the pad itself and its four non-digit keys. The first four are\n * screen-reader names; `done` is the one string here that is READ OFF THE SCREEN —\n * the brand-filled primary button — and it was a hardcoded English literal while\n * its three neighbours each took an override, so a German phone showed \"Done\"\n * among German labels with no prop able to change it. */\nexport interface NumberPadSheetLabels extends Partial<CalculatorLabels> {\n /** The pad's own name — the `calculator.panel` key under its older name here. */\n pad?: string;\n backspace?: string;\n clear?: string;\n equals?: string;\n /** Visible text on the primary key, not an `aria-label`. */\n done?: string;\n}\n\ntype PadKey =\n | { kind: \"ins\"; label: string; ins: string; accent?: boolean; name?: PadKeyName }\n | { kind: \"back\" };\n\n/** The {@link CalculatorLabels} key naming a non-digit key; digits are their glyph. */\ntype PadKeyName = \"plus\" | \"minus\" | \"times\" | \"divide\" | \"decimal\";\n\n// Insert chars that survive `sanitizeLive` (it keeps `× ÷` and ASCII `+ - * /`\n// but strips the unicode minus `−`, so minus inserts an ASCII \"-\" while showing\n// the nicer glyph). The evaluator normalises `× ÷` back to `* /`.\nconst PAD_KEYS: PadKey[] = [\n { kind: \"ins\", label: \"7\", ins: \"7\" },\n { kind: \"ins\", label: \"8\", ins: \"8\" },\n { kind: \"ins\", label: \"9\", ins: \"9\" },\n { kind: \"ins\", label: \"÷\", ins: \"÷\", accent: true, name: \"divide\" },\n { kind: \"ins\", label: \"4\", ins: \"4\" },\n { kind: \"ins\", label: \"5\", ins: \"5\" },\n { kind: \"ins\", label: \"6\", ins: \"6\" },\n { kind: \"ins\", label: \"×\", ins: \"×\", accent: true, name: \"times\" },\n { kind: \"ins\", label: \"1\", ins: \"1\" },\n { kind: \"ins\", label: \"2\", ins: \"2\" },\n { kind: \"ins\", label: \"3\", ins: \"3\" },\n { kind: \"ins\", label: \"−\", ins: \"-\", accent: true, name: \"minus\" },\n { kind: \"ins\", label: \".\", ins: \".\", name: \"decimal\" },\n { kind: \"ins\", label: \"0\", ins: \"0\" },\n { kind: \"back\" },\n { kind: \"ins\", label: \"+\", ins: \"+\", accent: true, name: \"plus\" },\n];\n\n/** Every key's shape — including the thing this pad used to delete without replacing.\n *\n * `focus:outline-none` alone is not a style choice, it is the removal of the only\n * signal a keyboard user has; on a 4×4 grid of identical tiles it leaves no way at all\n * to tell which key Enter is about to press (the audit's §a11y, and the same defect as\n * the dropdown search box).\n *\n * `focus-visible` and `ring-inset`, for the two reasons {@link ToggleGroup} already\n * writes down. This is a touch control first, and a plain `focus:` ring paints itself\n * on every TAP, because a tap focuses the button. And the keys sit 6px apart, which is\n * thinner than an outward ring plus the neighbour it would spill onto — inset keeps the\n * ring inside the key it describes.\n *\n * --brand rather than --border-strong: the operator keys are filled with --border, and\n * a ring in the colour of the thing it surrounds is not an indicator. */\nconst PAD_BTN =\n \"flex h-14 items-center justify-center rounded-lg text-lg font-medium transition-transform select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)] active:scale-[0.97]\";\nconst PAD_DIGIT = \"bg-[var(--bg-surface-2)] text-[var(--text-primary)] active:bg-[var(--border)]\";\nconst PAD_ACCENT = \"bg-[var(--border)] text-[var(--text-primary)] active:bg-[var(--bg-surface-2)]\";\n\nexport function NumberPadSheet({\n value,\n onChange,\n onDone,\n label,\n labels,\n decimalMark = \".\",\n onResult,\n}: {\n value: string;\n /** Fired with the raw (sanitised) field text on every key — same contract as\n * the host field's own `onChange`. */\n onChange: (value: string) => void;\n /** The glyph on the decimal key — \",\" where the host shows a comma (AmountInput\n * in fr-CH, kastlan 40). With \",\" the key inserts a comma and the text is handed\n * back unsanitized, for the host to read by its locale (keksdose G1). */\n decimalMark?: \".\" | \",\";\n /** \"=\": the evaluated result, dot-decimal. Default `onChange`. A host that reads\n * typed text by the locale's marks takes it apart from keystrokes, since a result\n * like \"1.234\" is a decimal and never a grouped thousand. */\n onResult?: (value: string) => void;\n /** Fired on \"Done\": the host blurs the input, which commits (evaluates) and\n * unmounts the sheet via its existing blur handler. */\n onDone: () => void;\n /** Optional field label, echoed in the sheet header so the user still knows\n * which field they're editing when the sheet covers it. */\n label?: ReactNode;\n /** Names for the pad itself and its non-digit keys — screen-reader names, except\n * `done`, which is the visible text on the primary key. Merged over `calculator`\n * from `<UiKitProvider labels>`: this is the same keypad as `CalculatorButton`'s,\n * on a phone, and one translation covers both. Digits need no name — their glyph\n * IS the name; the operators do (see {@link CalculatorLabels.plus}). */\n labels?: NumberPadSheetLabels;\n}) {\n // `pad` is this sheet's older spelling of `panel`, so it is folded in as that.\n const fromProps = useMemo(() => {\n if (!labels) return undefined;\n const { pad, ...rest } = labels;\n return pad === undefined ? rest : { ...rest, panel: pad };\n }, [labels]);\n const text = useKitLabels(\"calculator\", DEFAULT_CALCULATOR_LABELS, fromProps);\n\n // NO `useBodyScrollLock`, and that is the point of the control (Keksdose live\n // #317: *\"Background not scrollable when the amount input calculator field is\n // open\"*).\n //\n // This sheet is a KEYBOARD, not a dialog. It is opened by focusing a field and\n // exists only because a PWA cannot swap the system keyboard for its own — so it\n // suppresses the OS keyboard with `inputMode=\"none\"` and paints itself in the\n // freed space. An OS keyboard does not freeze the page behind it; it takes the\n // bottom of the screen and leaves you free to scroll what is left, which is how\n // you reach the field you are typing into when the keys cover it.\n //\n // Locking here made the page unreachable at exactly the moment it matters most:\n // correcting a receipt total against the running line sum, where the figure you\n // are comparing against sits below the keypad. The other four holders of that\n // hook are all modal — `Modal`, `PickerSheet`, `FullBleedDialog`, the DataTable\n // row dialog — and they stay locked, because for them the page behind is not\n // part of the task.\n //\n // The pad still does not scroll ITSELF away under a stray drag: the root's\n // `onPointerDown` preventDefault (below) keeps the host input focused and eats\n // the gesture on the sheet, so a touch that starts on a key is not a page scroll.\n\n // ── NO focus trap either, and for a harder reason than the scroll lock ────────\n //\n // Wave 3 put `useFocusTrap` on every overlay in this package. This one is the\n // exception, and it is not an oversight to be tidied up later: a trap here would\n // DELETE THE COMPONENT IT WAS PROTECTING. Both hosts render the pad with\n // `showNumpad = isMobile && !disabled && focused`, where `focused` is the host\n // input's own focus state. Moving focus into the pad blurs that input, the host\n // sets `focused` false, and the pad unmounts — on mount, before a single key can be\n // pressed. The blur also COMMITS, so the trap would evaluate a half-typed\n // expression on the way out.\n //\n // That is the contract, not an accident of it. This sheet exists because a PWA\n // cannot swap the system keyboard for its own: the host keeps real focus, caret and\n // selection while `inputMode=\"none\"` suppresses the OS keys, and the root's\n // `onPointerDown` preventDefault below is there for the same reason. The pad is\n // `role=\"group\"`, not a dialog. A keyboard does not trap focus; it is what you type\n // WITH.\n //\n // What was genuinely missing is a way OUT that is not a tap. `useEscapeKey` and not\n // a handler on the sheet, because the sheet never has the focus a keydown would\n // bubble from — the host does, and the host is not this component. Escape does what\n // the Done key does rather than cancelling, because committing on blur is the\n // host's contract and a cancel would have to be the host's to offer.\n //\n // The limit, since it is better written down than discovered: inside a `Modal` the\n // dialog's own Escape handler sits on the focus path and stops the event before a\n // document listener sees it, so there the press closes the dialog — which it did\n // before this line existed too, and which takes the pad with it either way.\n useEscapeKey(onDone);\n\n const result = evaluateExpression(value);\n const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : \"\";\n\n // With a \",\" mark the host keeps the typist's own text (AmountInput, keksdose G1):\n // the key inserts the comma it shows, and the host, not this pad, decides which mark\n // is the decimal one — sanitizing here would fold a grouping \".\" into a decimal.\n const insert = (ch: string) =>\n onChange(decimalMark === \",\" ? value + (ch === \".\" ? \",\" : ch) : sanitizeLive(value + ch));\n const backspace = () => onChange(value.slice(0, -1));\n const clearAll = () => onChange(\"\");\n const equals = () => {\n if (result !== null) (onResult ?? onChange)(formatResult(result));\n };\n\n const sheet = (\n <div\n role=\"group\"\n aria-label={text.panel}\n // Keep the host input focused when tapping the pad: preventDefault on\n // pointerdown blocks the focus/blur, while the buttons' click still fires.\n onPointerDown={(e) => e.preventDefault()}\n className=\"fixed inset-x-0 bottom-0 z-50 border-t border-[var(--border)] bg-[var(--bg-surface)] px-2 pt-2 shadow-[0_-4px_24px_rgba(0,0,0,0.12)]\"\n style={{ paddingBottom: \"max(0.5rem, env(safe-area-inset-bottom))\" }}\n >\n {/* Header display: which field + the running expression and live result, so\n the value stays visible even when the sheet covers the field. */}\n <div className=\"flex items-end justify-between gap-3 px-2 pb-2\">\n {label != null && (\n <span className=\"truncate pb-1 text-xs font-medium text-[var(--money-neutral)]\">{label}</span>\n )}\n {/* At the logical end, opposite the label; the figures themselves stay\n left-to-right and right-aligned, like the desktop calculator's. */}\n <div className=\"ms-auto min-w-0\">\n <div dir=\"ltr\" className=\"truncate text-right font-mono text-lg leading-tight text-[var(--text-primary)]\">{value || \"0\"}</div>\n <div dir=\"ltr\" className=\"h-4 text-right font-mono text-xs text-[var(--money-neutral)]\">{preview}</div>\n </div>\n </div>\n\n <div className=\"grid grid-cols-4 gap-1.5\">\n {PAD_KEYS.map((key, i) =>\n key.kind === \"back\" ? (\n <button\n key=\"back\"\n type=\"button\"\n aria-label={text.backspace}\n onClick={backspace}\n className={cn(PAD_BTN, PAD_ACCENT)}\n >\n <Delete className=\"size-5\" />\n </button>\n ) : (\n <button\n key={`${key.ins}-${i}`}\n type=\"button\"\n aria-label={key.name ? text[key.name] : key.label}\n onClick={() => insert(key.ins)}\n className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}\n >\n {key.kind === \"ins\" && key.name === \"decimal\" ? decimalMark : key.label}\n </button>\n ),\n )}\n </div>\n\n <div className=\"mt-1.5 grid grid-cols-4 gap-1.5\">\n <button type=\"button\" aria-label={text.clear} onClick={clearAll} className={cn(PAD_BTN, PAD_ACCENT)}>\n C\n </button>\n <button type=\"button\" aria-label={text.equals} onClick={equals} className={cn(PAD_BTN, PAD_ACCENT)}>\n =\n </button>\n <button\n type=\"button\"\n onClick={onDone}\n // The one key whose own fill is --brand, so it rings in the colour that fill\n // was chosen to be legible against instead (tailwind-merge keeps the later\n // ring colour). A --brand ring here would be present in the DOM and invisible\n // on screen, which is the defect this change exists to fix.\n className={cn(\n PAD_BTN,\n \"col-span-2 bg-[var(--brand)] text-[var(--brand-contrast)] focus-visible:ring-[var(--brand-contrast)] active:bg-[var(--brand-hover)]\",\n )}\n >\n {text.done}\n </button>\n </div>\n </div>\n );\n\n return createPortal(sheet, document.body);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -20,6 +20,13 @@ type PageHeaderSize = "sm" | "compact" | "md";
|
|
|
20
20
|
* layouts are one row: title block at the start, actions at the end.
|
|
21
21
|
*/
|
|
22
22
|
type PageHeaderMobileLayout = "stacked" | "inline";
|
|
23
|
+
/**
|
|
24
|
+
* Where the actions sit on the title block's height, in the one row the header is from
|
|
25
|
+
* `sm` up (and on a phone too when {@link PageHeaderMobileLayout} is `inline`, the only
|
|
26
|
+
* layout that has a row there). Unset keeps each layout's own: `start` for `stacked`,
|
|
27
|
+
* `center` for `inline`.
|
|
28
|
+
*/
|
|
29
|
+
type PageHeaderActionsAlign = "start" | "center" | "end";
|
|
23
30
|
interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
|
|
24
31
|
title: ReactNode;
|
|
25
32
|
/** A sentence under the title. */
|
|
@@ -49,6 +56,52 @@ interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title">
|
|
|
49
56
|
* button, a month picker); a long title wraps to make room for them instead.
|
|
50
57
|
*/
|
|
51
58
|
mobileLayout?: PageHeaderMobileLayout;
|
|
59
|
+
/**
|
|
60
|
+
* A second group of actions that gets its OWN full-width row under the header on a
|
|
61
|
+
* phone, and joins the actions' row from `sm` up, between the title and
|
|
62
|
+
* {@link actions}.
|
|
63
|
+
*
|
|
64
|
+
* keksdose's budget page (budget-page.tsx, feedback #60) is the case: title and month
|
|
65
|
+
* navigation on row 1, the fold/unfold toggles on row 2 on a phone — "two tidy rows
|
|
66
|
+
* instead of the old ragged justify-between overflow" — and one row, title | toggles |
|
|
67
|
+
* month nav, on a wider screen. It builds that by hand with a wrapping flex row and
|
|
68
|
+
* swapped order utilities; adopting `PageHeader` (keksdose G6b) must not lose it.
|
|
69
|
+
* There: `actions` is the month navigation, this is the toggles.
|
|
70
|
+
*
|
|
71
|
+
* The DOM order is title, `actions`, `secondaryActions` — the phone's visual order,
|
|
72
|
+
* and the one a screen reader and the Tab key follow at every width. From `sm` up the
|
|
73
|
+
* two groups swap places visually only, which keeps the page's primary control (the
|
|
74
|
+
* month) at the row's end, where keksdose had it, and first in reading order. The
|
|
75
|
+
* breakpoint is `sm`, the one {@link mobileLayout} switches at, not keksdose's `md`:
|
|
76
|
+
* one header, one breakpoint.
|
|
77
|
+
*/
|
|
78
|
+
secondaryActions?: ReactNode;
|
|
79
|
+
/**
|
|
80
|
+
* The actions' vertical alignment against the title block — see
|
|
81
|
+
* {@link PageHeaderActionsAlign}. Unset keeps the layout's own.
|
|
82
|
+
*
|
|
83
|
+
* keksdose's reports page (reports-page.tsx, keksdose G8) carries a LABELLED
|
|
84
|
+
* `CurrencySelect` as its action: a field with its label on top is taller than the
|
|
85
|
+
* title, and `stacked`'s default top alignment hangs it from the title's cap height
|
|
86
|
+
* with its control well below the title's line. `center` sets it on the title's
|
|
87
|
+
* middle, as that page's hand-written `items-center` row did.
|
|
88
|
+
*/
|
|
89
|
+
actionsAlign?: PageHeaderActionsAlign;
|
|
90
|
+
/**
|
|
91
|
+
* One line, cut with an ellipsis, instead of the default breaking of a long word onto
|
|
92
|
+
* as many lines as it needs.
|
|
93
|
+
*
|
|
94
|
+
* keksdose's payees page (payees-page.tsx, live #263, keksdose G6a) wraps its title in
|
|
95
|
+
* its own truncating span so a long name gives way to the `inline` header's actions
|
|
96
|
+
* instead of pushing them. This is that span's behaviour on the heading itself; the
|
|
97
|
+
* title block is already allowed to shrink below its content, which is what lets the
|
|
98
|
+
* cut happen inside a flex row.
|
|
99
|
+
*
|
|
100
|
+
* The full title stays in the DOM as the heading's text, so a screen reader and the
|
|
101
|
+
* document outline read all of it. There is deliberately no native `title` tooltip:
|
|
102
|
+
* the kit does not use them (not on touch, not on keyboard focus, not styled).
|
|
103
|
+
*/
|
|
104
|
+
truncateTitle?: boolean;
|
|
52
105
|
}
|
|
53
106
|
/**
|
|
54
107
|
* A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the
|
|
@@ -61,6 +114,6 @@ interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title">
|
|
|
61
114
|
* than fit a phone's width, instead of running off it. No outer margin: the page's
|
|
62
115
|
* own rhythm (`space-y-*`, a grid gap) places it.
|
|
63
116
|
*/
|
|
64
|
-
declare function PageHeader({ title, description, actions, breadcrumbs, eyebrow, as, size, mobileLayout, className, ...rest }: PageHeaderProps): react.JSX.Element;
|
|
117
|
+
declare function PageHeader({ title, description, actions, breadcrumbs, eyebrow, as, size, mobileLayout, secondaryActions, actionsAlign, truncateTitle, className, ...rest }: PageHeaderProps): react.JSX.Element;
|
|
65
118
|
|
|
66
|
-
export { PageHeader, type PageHeaderMobileLayout, type PageHeaderProps, type PageHeaderSize };
|
|
119
|
+
export { PageHeader, type PageHeaderActionsAlign, type PageHeaderMobileLayout, type PageHeaderProps, type PageHeaderSize };
|
|
@@ -11,6 +11,10 @@ const ROW = {
|
|
|
11
11
|
stacked: "flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4",
|
|
12
12
|
inline: "flex-row items-center justify-between gap-2 sm:gap-4"
|
|
13
13
|
};
|
|
14
|
+
const ACTIONS_ALIGN = {
|
|
15
|
+
stacked: { start: "sm:self-start", center: "sm:self-center", end: "sm:self-end" },
|
|
16
|
+
inline: { start: "self-start", center: "self-center", end: "self-end" }
|
|
17
|
+
};
|
|
14
18
|
function PageHeader({
|
|
15
19
|
title,
|
|
16
20
|
description,
|
|
@@ -20,19 +24,46 @@ function PageHeader({
|
|
|
20
24
|
as = "h1",
|
|
21
25
|
size = "md",
|
|
22
26
|
mobileLayout = "stacked",
|
|
27
|
+
secondaryActions,
|
|
28
|
+
actionsAlign,
|
|
29
|
+
truncateTitle = false,
|
|
23
30
|
className,
|
|
24
31
|
...rest
|
|
25
32
|
}) {
|
|
26
33
|
const Heading = as;
|
|
34
|
+
const hasSecondary = secondaryActions != null;
|
|
35
|
+
const wrapsSecondary = hasSecondary && mobileLayout === "inline";
|
|
36
|
+
const align = actionsAlign != null ? ACTIONS_ALIGN[mobileLayout][actionsAlign] : void 0;
|
|
27
37
|
return /* @__PURE__ */ jsxs("div", { ...rest, className: cn("flex min-w-0 flex-col gap-2", className), children: [
|
|
28
38
|
breadcrumbs,
|
|
29
|
-
/* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0", ROW[mobileLayout]), children: [
|
|
39
|
+
/* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0", ROW[mobileLayout], wrapsSecondary && "flex-wrap sm:flex-nowrap"), children: [
|
|
30
40
|
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
31
41
|
eyebrow != null && /* @__PURE__ */ jsx("p", { className: cn(SECTION_LABEL_CLASS.xs, "mb-1"), children: eyebrow }),
|
|
32
|
-
/* @__PURE__ */ jsx(
|
|
42
|
+
/* @__PURE__ */ jsx(
|
|
43
|
+
Heading,
|
|
44
|
+
{
|
|
45
|
+
className: cn(
|
|
46
|
+
truncateTitle ? "truncate" : "break-words",
|
|
47
|
+
"text-[var(--text-primary)]",
|
|
48
|
+
TITLE[size]
|
|
49
|
+
),
|
|
50
|
+
children: title
|
|
51
|
+
}
|
|
52
|
+
),
|
|
33
53
|
description != null && /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-[var(--text-muted)]", children: description })
|
|
34
54
|
] }),
|
|
35
|
-
actions != null && /* @__PURE__ */ jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })
|
|
55
|
+
actions != null && /* @__PURE__ */ jsx("div", { className: cn("flex shrink-0 flex-wrap items-center gap-2", hasSecondary && "sm:order-2", align), children: actions }),
|
|
56
|
+
hasSecondary && /* @__PURE__ */ jsx(
|
|
57
|
+
"div",
|
|
58
|
+
{
|
|
59
|
+
className: cn(
|
|
60
|
+
"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0",
|
|
61
|
+
wrapsSecondary && "basis-full sm:basis-auto",
|
|
62
|
+
align
|
|
63
|
+
),
|
|
64
|
+
children: secondaryActions
|
|
65
|
+
}
|
|
66
|
+
)
|
|
36
67
|
] })
|
|
37
68
|
] });
|
|
38
69
|
}
|
|
@@ -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\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\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 className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs}\n <div className={cn(\"flex min-w-0\", ROW[mobileLayout])}>\n <div className=\"min-w-0 flex-1\">\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading className={cn(\"break-words text-[var(--text-primary)]\", TITLE[size])}>{title}</Heading>\n {description != null && <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>}\n </div>\n {actions != null && <div className=\"flex shrink-0 flex-wrap items-center gap-2\">{actions}</div>}\n </div>\n </div>\n );\n}\n"],"mappings":";AA+FQ,SACsB,KADtB;AA9FR,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;AA4CO,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,GAAG;AACL,GAAoB;AAClB,QAAM,UAAU;AAChB,SACE,qBAAC,SAAK,GAAG,MAAM,WAAW,GAAG,+BAA+B,SAAS,GAClE;AAAA;AAAA,IACD,qBAAC,SAAI,WAAW,GAAG,gBAAgB,IAAI,YAAY,CAAC,GAClD;AAAA,2BAAC,SAAI,WAAU,kBACZ;AAAA,mBAAW,QAAQ,oBAAC,OAAE,WAAW,GAAG,oBAAoB,IAAI,MAAM,GAAI,mBAAQ;AAAA,QAC/E,oBAAC,WAAQ,WAAW,GAAG,0CAA0C,MAAM,IAAI,CAAC,GAAI,iBAAM;AAAA,QACrF,eAAe,QAAQ,oBAAC,OAAE,WAAU,yCAAyC,uBAAY;AAAA,SAC5F;AAAA,MACC,WAAW,QAAQ,oBAAC,SAAI,WAAU,8CAA8C,mBAAQ;AAAA,OAC3F;AAAA,KACF;AAEJ;","names":[]}
|
|
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/** Per layout, because `stacked` is a column on a phone — a `self-center` there would\n * centre the actions HORIZONTALLY — so it only aligns from `sm` up. */\nconst ACTIONS_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {\n stacked: { start: \"sm:self-start\", center: \"sm:self-center\", end: \"sm:self-end\" },\n inline: { start: \"self-start\", center: \"self-center\", end: \"self-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.\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 ? ACTIONS_ALIGN[mobileLayout][actionsAlign] : undefined;\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 className={cn(\"flex min-w-0\", ROW[mobileLayout], wrapsSecondary && \"flex-wrap sm:flex-nowrap\")}>\n <div className=\"min-w-0 flex-1\">\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\", align)}>\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 align,\n )}\n >\n {secondaryActions}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":";AAuKQ,SACsB,KADtB;AAtKR,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;AAYA,MAAM,gBAAwF;AAAA,EAC5F,SAAS,EAAE,OAAO,iBAAiB,QAAQ,kBAAkB,KAAK,cAAc;AAAA,EAChF,QAAQ,EAAE,OAAO,cAAc,QAAQ,eAAe,KAAK,WAAW;AACxE;AA0FO,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,cAAc,YAAY,EAAE,YAAY,IAAI;AACjF,SACE,qBAAC,SAAK,GAAG,MAAM,WAAW,GAAG,+BAA+B,SAAS,GAClE;AAAA;AAAA,IAMD,qBAAC,SAAI,WAAW,GAAG,gBAAgB,IAAI,YAAY,GAAG,kBAAkB,0BAA0B,GAChG;AAAA,2BAAC,SAAI,WAAU,kBACZ;AAAA,mBAAW,QAAQ,oBAAC,OAAE,WAAW,GAAG,oBAAoB,IAAI,MAAM,GAAI,mBAAQ;AAAA,QAC/E;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT,gBAAgB,aAAa;AAAA,cAC7B;AAAA,cACA,MAAM,IAAI;AAAA,YACZ;AAAA,YAEC;AAAA;AAAA,QACH;AAAA,QACC,eAAe,QAAQ,oBAAC,OAAE,WAAU,yCAAyC,uBAAY;AAAA,SAC5F;AAAA,MACC,WAAW,QACV,oBAAC,SAAI,WAAW,GAAG,8CAA8C,gBAAgB,cAAc,KAAK,GACjG,mBACH;AAAA,MAED,gBACC;AAAA,QAAC;AAAA;AAAA,UACC,WAAW;AAAA,YACT;AAAA,YACA,kBAAkB;AAAA,YAClB;AAAA,UACF;AAAA,UAEC;AAAA;AAAA,MACH;AAAA,OAEJ;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/text.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\n\n/* ── SectionLabel ─────────────────────────────────────────────────────────── */\n\n/**\n * `xs` is 10px — the size `LegendGroup`'s title and the top-bar menus' heading row draw;\n * `sm` (default) is 12px — the size `StatTile`'s label and lenkbank's section headings\n * (control-page.tsx:119/123/127) draw. Two sizes because the label sits either inside a\n * dense panel (a legend, a menu) or above a block of page content, and one size looked\n * wrong in the other place.\n *\n * `md` (0.11.0) is 11px, the rung between them: keksdose's budget-summary card labels\n * its figures (\"Assigned\", \"Activity\", \"Upcoming\", \"Available\" —\n * budget-summary-card:258/264/286/297) and its support thread its day divider and a message's meta line\n * (support-thread:153/223) at `text-[11px]` by hand — 10px read as a footnote under a\n * figure that size, 12px competed with it. Named `md` rather than slotted in order\n * because `sm` was already the default and renaming it would move every caller.\n *\n * `md` is also the heading over a CHART COLUMN (lenkbank P9): the small label centred\n * over each of a pair of facing charts, or over each column of a small-multiples grid.\n * There the label names a plot, not a section of the page, so `sm` (12px, the page's\n * section heading) out-ranks the card title above it, and `xs` (10px, a legend's\n * title) sinks below the axis ticks it sits beside. Rule of thumb: `xs` inside a\n * control (legend, menu), `md` over a figure or a plot, `sm` over a block of page\n * content.\n */\nexport type SectionLabelSize = \"xs\" | \"md\" | \"sm\";\n\n/**\n * The small, uppercase, tracked, muted label — as a class string, per size.\n *\n * Exported because `LegendGroup` and `StatTile` already draw it inline, and a class a\n * caller has to position itself (a `<th>`, a `<legend>`, a menu `<li>`) is better\n * reached as a string than wrapped in a component that only takes a `className`. The\n * uppercase is CSS, so a screen reader reads the text as written: write it in normal\n * case.\n */\nexport const SECTION_LABEL_CLASS: Record<SectionLabelSize, string> = {\n xs: \"text-[10px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\",\n md: \"text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\",\n sm: \"text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]\",\n};\n\n/**\n * `plain` (default): the label alone, as above.\n *\n * `band` (0.13, keksdose F5): the label on a full-width `surface-2` bar with a bottom\n * border — the day-group header of keksdose's mobile transaction list\n * (mobile-transaction-list.tsx:171), which separates one day's rows from the next.\n * Its type is kept as keksdose drew it — `font-medium` in the SECONDARY text colour,\n * not the plain label's semibold muted — so adopting the kit does not restyle a live\n * list: the bar already sets the label apart, and needs no extra weight. The padding (`px-4 py-2`) lines the text up with a\n * `ListItem density=\"comfortable\"` row below it; override it with `className` for a\n * list at another density. A sticky header is the caller's: add `sticky top-0`.\n */\nexport type SectionLabelVariant = \"plain\" | \"band\";\n\n/** The band's own type and box, per size — spelled out rather than merged over\n * {@link SECTION_LABEL_CLASS}, so the result does not hang on tailwind-merge telling\n * a font-size `text
|
|
1
|
+
{"version":3,"sources":["../../src/components/text.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\n\n/* ── SectionLabel ─────────────────────────────────────────────────────────── */\n\n/**\n * `xs` is 10px — the size `LegendGroup`'s title and the top-bar menus' heading row draw;\n * `sm` (default) is 12px — the size `StatTile`'s label and lenkbank's section headings\n * (control-page.tsx:119/123/127) draw. Two sizes because the label sits either inside a\n * dense panel (a legend, a menu) or above a block of page content, and one size looked\n * wrong in the other place.\n *\n * `md` (0.11.0) is 11px, the rung between them: keksdose's budget-summary card labels\n * its figures (\"Assigned\", \"Activity\", \"Upcoming\", \"Available\" —\n * budget-summary-card:258/264/286/297) and its support thread its day divider and a message's meta line\n * (support-thread:153/223) at `text-[11px]` by hand — 10px read as a footnote under a\n * figure that size, 12px competed with it. Named `md` rather than slotted in order\n * because `sm` was already the default and renaming it would move every caller.\n *\n * `md` is also the heading over a CHART COLUMN (lenkbank P9): the small label centred\n * over each of a pair of facing charts, or over each column of a small-multiples grid.\n * There the label names a plot, not a section of the page, so `sm` (12px, the page's\n * section heading) out-ranks the card title above it, and `xs` (10px, a legend's\n * title) sinks below the axis ticks it sits beside. Rule of thumb: `xs` inside a\n * control (legend, menu), `md` over a figure or a plot, `sm` over a block of page\n * content.\n */\nexport type SectionLabelSize = \"xs\" | \"md\" | \"sm\";\n\n/**\n * The small, uppercase, tracked, muted label — as a class string, per size.\n *\n * Exported because `LegendGroup` and `StatTile` already draw it inline, and a class a\n * caller has to position itself (a `<th>`, a `<legend>`, a menu `<li>`) is better\n * reached as a string than wrapped in a component that only takes a `className`. The\n * uppercase is CSS, so a screen reader reads the text as written: write it in normal\n * case.\n */\nexport const SECTION_LABEL_CLASS: Record<SectionLabelSize, string> = {\n xs: \"text-[10px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\",\n md: \"text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\",\n sm: \"text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]\",\n};\n\n/**\n * `plain` (default): the label alone, as above.\n *\n * `band` (0.13, keksdose F5): the label on a full-width `surface-2` bar with a bottom\n * border — the day-group header of keksdose's mobile transaction list\n * (mobile-transaction-list.tsx:171), which separates one day's rows from the next.\n * Its type is kept as keksdose drew it — `font-medium` in the SECONDARY text colour,\n * not the plain label's semibold muted — so adopting the kit does not restyle a live\n * list: the bar already sets the label apart, and needs no extra weight. The padding (`px-4 py-2`) lines the text up with a\n * `ListItem density=\"comfortable\"` row below it; override it with `className` for a\n * list at another density. A sticky header is the caller's: add `sticky top-0`.\n */\nexport type SectionLabelVariant = \"plain\" | \"band\";\n\n/** The band's own type and box, per size — spelled out rather than merged over\n * {@link SECTION_LABEL_CLASS}, so the result does not hang on tailwind-merge telling\n * a font-size arbitrary `text-` value from a colour one. (Not spelt out as class\n * names here: Tailwind scans comments, and a literal one broke the showcase CSS.) */\nconst SECTION_LABEL_BAND_CLASS: Record<SectionLabelSize, string> = {\n xs: \"text-[10px]\",\n md: \"text-[11px]\",\n sm: \"text-xs\",\n};\nconst BAND =\n \"block border-b border-[var(--border)] bg-[var(--bg-surface-2)] px-4 py-2 font-medium uppercase tracking-wide text-[var(--text-secondary)]\";\n\ntype SectionLabelElement = \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\" | \"p\" | \"span\" | \"div\" | \"legend\";\n\nexport interface SectionLabelProps extends ComponentPropsWithoutRef<\"h3\"> {\n /**\n * The element — which is the heading LEVEL, and so is the caller's to pick: the kit\n * cannot know where in the outline the label sits. Default `h3`, which is what\n * lenkbank's control page writes under its `h2` card title. Pass `span` or `p` for a\n * label that is not a heading at all (a legend group's title).\n */\n as?: SectionLabelElement;\n size?: SectionLabelSize;\n /** `band` draws the label on a `surface-2` bar with a bottom border — a list's group\n * header. See {@link SectionLabelVariant}. */\n variant?: SectionLabelVariant;\n children: ReactNode;\n}\n\n/**\n * A section's small uppercase label. lenkbank spells\n * `text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400`\n * on every `<h3>` of its control page and more; keksdose's budget switcher writes the\n * 10px version on a menu row. One component, the token colour, and the level chosen\n * where the outline is known.\n */\nexport function SectionLabel({\n as = \"h3\",\n size = \"sm\",\n variant = \"plain\",\n className,\n children,\n ...rest\n}: SectionLabelProps) {\n const Tag = as as ElementType;\n const type = variant === \"band\" ? cn(SECTION_LABEL_BAND_CLASS[size], BAND) : SECTION_LABEL_CLASS[size];\n return (\n <Tag {...rest} className={cn(type, className)}>\n {children}\n </Tag>\n );\n}\n\n/* ── Caption ──────────────────────────────────────────────────────────────── */\n\n/**\n * The caption type as a class string: 11px, snug, muted. lenkbank's `CAPTION`\n * (shared/ui/caption.ts:23) — written out 31 times before it was a constant — as a\n * token colour rather than `slate-500 dark:slate-400`, so it follows the palette.\n * Exported for the same reason lenkbank's is a string: every caller positions it\n * differently (`mt-1`, `self-end pb-2`, `border-t`), and what must be one thing is the\n * type, not the box.\n */\nexport const CAPTION_CLASS = \"text-[11px] leading-snug text-[var(--text-muted)]\";\n\nexport interface CaptionProps extends ComponentPropsWithoutRef<\"p\"> {\n /** Default `p`; `span` for a caption inside a line of other content. */\n as?: \"p\" | \"span\" | \"div\";\n children: ReactNode;\n}\n\n/**\n * The small grey sentence under a field, a table or a button — prose ABOUT the thing\n * above it. Not `FieldHint`, which hides behind a field label's \"?\" so a paragraph does\n * not make one field taller than its neighbour (lenkbank feedback #27).\n */\nexport function Caption({ as = \"p\", className, children, ...rest }: CaptionProps) {\n const Tag = as as ElementType;\n return (\n <Tag {...rest} className={cn(CAPTION_CLASS, className)}>\n {children}\n </Tag>\n );\n}\n"],"mappings":";AAyGI;AAxGJ,SAAS,UAAU;AAqCZ,MAAM,sBAAwD;AAAA,EACnE,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAoBA,MAAM,2BAA6D;AAAA,EACjE,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AACA,MAAM,OACJ;AA0BK,SAAS,aAAa;AAAA,EAC3B,KAAK;AAAA,EACL,OAAO;AAAA,EACP,UAAU;AAAA,EACV;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,MAAM;AACZ,QAAM,OAAO,YAAY,SAAS,GAAG,yBAAyB,IAAI,GAAG,IAAI,IAAI,oBAAoB,IAAI;AACrG,SACE,oBAAC,OAAK,GAAG,MAAM,WAAW,GAAG,MAAM,SAAS,GACzC,UACH;AAEJ;AAYO,MAAM,gBAAgB;AAatB,SAAS,QAAQ,EAAE,KAAK,KAAK,WAAW,UAAU,GAAG,KAAK,GAAiB;AAChF,QAAM,MAAM;AACZ,SACE,oBAAC,OAAK,GAAG,MAAM,WAAW,GAAG,eAAe,SAAS,GAClD,UACH;AAEJ;","names":[]}
|
|
@@ -159,6 +159,37 @@ interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
|
|
|
159
159
|
* existing app tests find without a hover (see above): flipping it would turn every
|
|
160
160
|
* `getByRole("tooltip")` written against 0.12 into a failure in all three apps at once.
|
|
161
161
|
* Reach for `lazy` wherever a `portal` was pinned only to keep a test's DOM clean.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ **The in-place bubble is clamped to the viewport when it opens (keksdose G7).** The
|
|
164
|
+
* portalled bubble has always measured itself and been pushed back onto the glass
|
|
165
|
+
* ({@link placeTooltip}); the in-place one never learnt its own size, so `side` was the
|
|
166
|
+
* whole of its placement and a `top` / `bottom` bubble sat centred on its trigger
|
|
167
|
+
* whatever that cost. The cost shows on a phone: long labels live at a row's START edge
|
|
168
|
+
* — a gcloud command in keksdose's jobs panel, a canned reply in a support thread — and
|
|
169
|
+
* a 20rem bubble centred on a trigger 16px from the edge hangs half its text off the
|
|
170
|
+
* screen. keksdose pinned `portal` on those sites for that alone, which gave up `lazy`'s
|
|
171
|
+
* point (the cheap, in-place bubble) to buy a placement.
|
|
172
|
+
*
|
|
173
|
+
* So now, when an in-place bubble goes up (default or `lazy`), a layout effect measures
|
|
174
|
+
* it and, if it crosses the viewport edge minus the same margin the portalled bubble
|
|
175
|
+
* keeps, slides it back along its CROSS axis only — sideways for `top` / `bottom`, up or
|
|
176
|
+
* down for the four side placements — with an inline `transform`, which composes with the
|
|
177
|
+
* placement classes' own `translate`. The main axis is left alone on purpose: sliding a
|
|
178
|
+
* `start` bubble along the main axis would slide it over its own trigger, and turning it
|
|
179
|
+
* round is a measured-placement decision this CSS-placed bubble does not make (pass
|
|
180
|
+
* `portal` for that). The width is already capped to the viewport (see
|
|
181
|
+
* `TOOLTIP_SURFACE`), so a bubble can always fit once slid, and a long label wraps
|
|
182
|
+
* instead of growing past the glass.
|
|
183
|
+
*
|
|
184
|
+
* Why on by default, with no prop? Because it is a no-op for every bubble that already
|
|
185
|
+
* fits — the shift is zero unless the bubble would overflow — so the only placements it
|
|
186
|
+
* changes are ones that were broken. The maths is in physical viewport pixels, so it
|
|
187
|
+
* needs no `dir`: an RTL row puts its long label at the RIGHT edge and gets slid left by
|
|
188
|
+
* the same code. Under jsdom there is no layout — every rect is zero-sized — and a
|
|
189
|
+
* zero-sized bubble is taken to be unmeasured, so tests see no transform at all. It is
|
|
190
|
+
* measured on open (and when the label or side changes while open), not on every scroll:
|
|
191
|
+
* an in-place bubble follows its trigger for free, and a page that scrolls sideways under
|
|
192
|
+
* an open tooltip is not a case worth a listener per tooltip.
|
|
162
193
|
*/
|
|
163
194
|
declare function Tooltip({ label, side, className, portal, lazy, redact, children, ...rest }: TooltipProps): react.JSX.Element;
|
|
164
195
|
interface TooltipSize {
|
|
@@ -88,6 +88,8 @@ function InPlaceTooltip({
|
|
|
88
88
|
const [dir, setDir] = useState("ltr");
|
|
89
89
|
const open = (hovered || focused) && !dismissed;
|
|
90
90
|
useEscapeKey(() => setDismissed(true), open);
|
|
91
|
+
const bubbleRef = useRef(null);
|
|
92
|
+
const shift = useViewportClamp(bubbleRef, open && !clipped, side, label);
|
|
91
93
|
useLayoutEffect(() => {
|
|
92
94
|
if (detect) setClipped(hasClippingAncestor(triggerRef.current));
|
|
93
95
|
}, [detect]);
|
|
@@ -128,9 +130,11 @@ function InPlaceTooltip({
|
|
|
128
130
|
open && /* @__PURE__ */ jsx(
|
|
129
131
|
"span",
|
|
130
132
|
{
|
|
133
|
+
ref: bubbleRef,
|
|
131
134
|
id,
|
|
132
135
|
role: "tooltip",
|
|
133
136
|
"data-private": redact ? "" : void 0,
|
|
137
|
+
style: shiftStyle(shift),
|
|
134
138
|
className: cn(TOOLTIP_SURFACE, "pointer-events-none absolute z-50 opacity-100", sidePositionClass[side]),
|
|
135
139
|
children: label
|
|
136
140
|
}
|
|
@@ -138,8 +142,10 @@ function InPlaceTooltip({
|
|
|
138
142
|
) : /* @__PURE__ */ jsx(
|
|
139
143
|
"span",
|
|
140
144
|
{
|
|
145
|
+
ref: bubbleRef,
|
|
141
146
|
id,
|
|
142
147
|
role: "tooltip",
|
|
148
|
+
style: shiftStyle(shift),
|
|
143
149
|
hidden: dismissed || void 0,
|
|
144
150
|
"data-private": redact ? "" : void 0,
|
|
145
151
|
className: cn(
|
|
@@ -167,6 +173,33 @@ function isEmptyLabel(label) {
|
|
|
167
173
|
}
|
|
168
174
|
const TOOLTIP_GAP = 4;
|
|
169
175
|
const TOOLTIP_MARGIN = 4;
|
|
176
|
+
const NO_SHIFT = { x: 0, y: 0 };
|
|
177
|
+
function slideInto(low, high, extent) {
|
|
178
|
+
if (low < TOOLTIP_MARGIN) return TOOLTIP_MARGIN - low;
|
|
179
|
+
if (high > extent - TOOLTIP_MARGIN) return Math.max(extent - TOOLTIP_MARGIN - high, TOOLTIP_MARGIN - low);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
function useViewportClamp(bubbleRef, active, side, label) {
|
|
183
|
+
const [shift, setShift] = useState(NO_SHIFT);
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
const el = bubbleRef.current;
|
|
186
|
+
if (!active || !el) {
|
|
187
|
+
setShift(NO_SHIFT);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const r = el.getBoundingClientRect();
|
|
191
|
+
if (r.width === 0 && r.height === 0) return;
|
|
192
|
+
const horizontal = side === "top" || side === "bottom";
|
|
193
|
+
setShift((previous) => {
|
|
194
|
+
const next = horizontal ? { x: slideInto(r.left - previous.x, r.right - previous.x, window.innerWidth), y: 0 } : { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, window.innerHeight) };
|
|
195
|
+
return next.x === previous.x && next.y === previous.y ? previous : next;
|
|
196
|
+
});
|
|
197
|
+
}, [bubbleRef, active, side, label]);
|
|
198
|
+
return shift;
|
|
199
|
+
}
|
|
200
|
+
function shiftStyle(shift) {
|
|
201
|
+
return shift.x === 0 && shift.y === 0 ? void 0 : { transform: `translate(${shift.x}px, ${shift.y}px)` };
|
|
202
|
+
}
|
|
170
203
|
const portalTransformBySide = {
|
|
171
204
|
right: "translate(0, -50%)",
|
|
172
205
|
left: "translate(-100%, -50%)",
|