@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.
Files changed (54) hide show
  1. package/README.md +5 -5
  2. package/dist/components/amount-input.d.ts +6 -0
  3. package/dist/components/amount-input.js +35 -5
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/error-boundary.d.ts +8 -1
  6. package/dist/components/error-boundary.js +18 -2
  7. package/dist/components/error-boundary.js.map +1 -1
  8. package/dist/components/numpad-sheet.d.ts +7 -3
  9. package/dist/components/numpad-sheet.js +4 -3
  10. package/dist/components/numpad-sheet.js.map +1 -1
  11. package/dist/components/page-header.d.ts +55 -2
  12. package/dist/components/page-header.js +34 -3
  13. package/dist/components/page-header.js.map +1 -1
  14. package/dist/components/text.js.map +1 -1
  15. package/dist/components/tooltip.d.ts +31 -0
  16. package/dist/components/tooltip.js +33 -0
  17. package/dist/components/tooltip.js.map +1 -1
  18. package/dist/feedback/feedback-thread.d.ts +87 -6
  19. package/dist/feedback/feedback-thread.js +134 -58
  20. package/dist/feedback/feedback-thread.js.map +1 -1
  21. package/dist/feedback.d.ts +1 -1
  22. package/dist/i18n/locales/de.js +3 -1
  23. package/dist/i18n/locales/de.js.map +1 -1
  24. package/dist/i18n/locales/es.js +2 -1
  25. package/dist/i18n/locales/es.js.map +1 -1
  26. package/dist/i18n/locales/fr.js +2 -1
  27. package/dist/i18n/locales/fr.js.map +1 -1
  28. package/dist/i18n/locales/hu.js +2 -1
  29. package/dist/i18n/locales/hu.js.map +1 -1
  30. package/dist/i18n/locales/it.js +2 -1
  31. package/dist/i18n/locales/it.js.map +1 -1
  32. package/dist/i18n/locales/zh.js +2 -1
  33. package/dist/i18n/locales/zh.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/lib/format.js +1 -1
  36. package/dist/lib/format.js.map +1 -1
  37. package/dist/rhf/fields.d.ts +3 -1
  38. package/dist/rhf/fields.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/amount-input.tsx +73 -7
  41. package/src/components/error-boundary.tsx +33 -3
  42. package/src/components/numpad-sheet.tsx +13 -4
  43. package/src/components/page-header.tsx +98 -3
  44. package/src/components/text.tsx +2 -1
  45. package/src/components/tooltip.tsx +109 -2
  46. package/src/feedback/feedback-thread.tsx +229 -37
  47. package/src/i18n/locales/de.ts +2 -0
  48. package/src/i18n/locales/es.ts +1 -0
  49. package/src/i18n/locales/fr.ts +1 -0
  50. package/src/i18n/locales/hu.ts +1 -0
  51. package/src/i18n/locales/it.ts +1 -0
  52. package/src/i18n/locales/zh.ts +1 -0
  53. package/src/lib/format.ts +3 -1
  54. package/src/rhf/fields.tsx +3 -1
@@ -158,6 +158,12 @@ interface AmountInputProps {
158
158
  * zero (12.345 → 12.35, -12.345 → -12.35) and happens when the figure settles: on
159
159
  * blur, on Enter, and on every result the calculator writes back. Keystrokes are left
160
160
  * alone, so "12.3" can still become "12.34".
161
+ *
162
+ * ⚠️ **A unit price or a rate needs `digits`.** The default is right for an amount of
163
+ * money and silently wrong for a price per unit: a fund's NAV of 123.4567 or fuel at
164
+ * 1.789 €/l is stored as 123.46 / 1.79, and no test fails (keksdose G2, a
165
+ * `Numeric(18,6)` holding price and a `Numeric(18,4)` unit price). Pass the column's
166
+ * scale: `digits={4}`, `digits={6}`.
161
167
  */
162
168
  digits?: number;
163
169
  /** The smallest amount the field settles on. A lower figure is raised to it when it
@@ -218,6 +224,36 @@ function decimalMark(locale: string | undefined): "," | "." {
218
224
  }
219
225
  }
220
226
 
227
+ /**
228
+ * Which typed mark is the decimal one (keksdose G1). {@link sanitizeLive} folds every
229
+ * "," into ".", so on its own it read the German "1.234,56" as 1.23456 — and the
230
+ * settle then made that 1.23, final. This decides per operand, from the typist's own
231
+ * text, before that fold:
232
+ *
233
+ * - both marks present: the one that is not the locale's is grouping, and goes;
234
+ * - in a "," locale, only "." present: grouping when it cannot be a decimal as a
235
+ * German reader writes it — twice ("1.234.567"), or in thousands shape ("1.234",
236
+ * "12.500") — otherwise a decimal ("1.5", typed by a dot-decimal habit);
237
+ * - in a "." locale, only "," present: grouping when it appears twice ("1,234,567");
238
+ * once it stays a decimal, because Swiss and German typists write "1,5" in a
239
+ * de-CH form (kastlan 40) and a unit price "1,789" is a decimal.
240
+ *
241
+ * Only a keystroke's text comes through here. A calculator result is dot-decimal
242
+ * already, and "1.234" from `100/81.03…` is never a thousand.
243
+ */
244
+ function normalizeTypedMarks(raw: string, mark: "," | "."): string {
245
+ const other = mark === "," ? "." : ",";
246
+ return raw.replace(/[0-9.,]+/g, (operand) => {
247
+ const hasMark = operand.includes(mark);
248
+ const count = operand.split(other).length - 1;
249
+ if (count === 0) return operand;
250
+ const grouping = hasMark
251
+ ? true
252
+ : count > 1 || (mark === "," && /^\d{1,3}(\.\d{3})+$/.test(operand));
253
+ return grouping ? operand.split(other).join("") : operand;
254
+ });
255
+ }
256
+
221
257
  // The consuming app's ONE money palette (`--money-expense` / `--money-income`),
222
258
  // not a bespoke rose/emerald pairing: a figure being typed has to wear the same
223
259
  // colour the same figure will wear once it is a row in the table behind the form
@@ -310,7 +346,23 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
310
346
  // follows `Intl` rather than a guess about a country.
311
347
  const locale = useKitLocale();
312
348
  const mark = decimalMark(locale);
313
- const display = mark === "," ? shown.replace(/\./g, ",") : shown;
349
+ // While the typist is typing, the field shows THEIR text ("1.234,5" stays exactly
350
+ // that), not the value re-spelt: re-spelling turned a grouping "." into a "," under
351
+ // their fingers, after which no rule could tell the two apart (keksdose G1). The
352
+ // draft is shown only while it still spells what the field holds; a value set from
353
+ // outside, a calculator result or a commit falls back to the derived spelling.
354
+ const [draft, setDraft] = useState<string | null>(null);
355
+ const derived = mark === "," ? shown.replace(/\./g, ",") : shown;
356
+ const draftAs = draft === null ? "" : sanitizeLive(normalizeTypedMarks(draft, mark));
357
+ const display =
358
+ draft === null
359
+ ? derived
360
+ : draftAs === shown
361
+ ? draft
362
+ : // The caller owns the sign: the typed "12" is shown as the "-12" it is.
363
+ signOwned && negative && `-${draftAs}` === shown
364
+ ? `-${draft}`
365
+ : derived;
314
366
  // Every route into the field — typing, the numpad sheet, the desktop
315
367
  // calculator, the blur/Enter commit — funnels through here, so the split is
316
368
  // written once and the four entry paths cannot drift.
@@ -345,7 +397,14 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
345
397
  }
346
398
  onChange(sign ? rest : text);
347
399
  };
348
- const commit = () => handleText(settle(commitExpression(shown)));
400
+ const commit = () => {
401
+ setDraft(null);
402
+ handleText(settle(commitExpression(shown)));
403
+ };
404
+ const typed = (raw: string) => {
405
+ setDraft(raw);
406
+ handleText(normalizeTypedMarks(raw, mark));
407
+ };
349
408
  const { open, setOpen, wrapperRef, panelRef, query, setQuery, inputRef } = useDropdownSearch();
350
409
  // The chip the currency list hangs off (Keksdose dev#548). It is portalled now, so
351
410
  // the panel needs a real trigger rect rather than a relative parent — see
@@ -404,7 +463,7 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
404
463
  // only the app knows the locale's decimal separator.
405
464
  placeholder={asDisplay ? (placeholder ?? "0") : label !== undefined ? " " : placeholder}
406
465
  value={display}
407
- onChange={(e) => handleText(e.target.value)}
466
+ onChange={(e) => typed(e.target.value)}
408
467
  onFocus={() => setFocused(true)}
409
468
  onBlur={() => {
410
469
  commit();
@@ -472,7 +531,10 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
472
531
  // The mark the field shows, as NumberInput hands its calculator; the
473
532
  // evaluator reads either, and its result comes back dot-decimal.
474
533
  value={display}
475
- onChange={(result, expression) => handleText(settle(result), expression)}
534
+ onChange={(result, expression) => {
535
+ setDraft(null);
536
+ handleText(settle(result), expression);
537
+ }}
476
538
  className="px-1.5"
477
539
  ariaLabel={labels?.calculatorTrigger}
478
540
  labels={labels?.calculator}
@@ -554,10 +616,14 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
554
616
  </div>
555
617
  {showNumpad && (
556
618
  <NumberPadSheet
557
- // Localised like the field it mirrors; its keys come back through
558
- // `sanitizeLive`, so the "." key and a "," both land as a dot.
619
+ // Localised like the field it mirrors. Keys are typing, read by the
620
+ // locale's marks like the keyboard's; "=" is a result, dot-decimal.
559
621
  value={display}
560
- onChange={handleText}
622
+ onChange={typed}
623
+ onResult={(result) => {
624
+ setDraft(null);
625
+ handleText(result);
626
+ }}
561
627
  onDone={() => innerRef.current?.blur()}
562
628
  label={label}
563
629
  labels={labels?.pad}
@@ -73,13 +73,29 @@ function read(get: () => unknown): string | undefined {
73
73
  }
74
74
  }
75
75
 
76
+ /** `JSON.stringify` that never throws (a cycle, a BigInt, a hostile getter) and never
77
+ * returns more than 500 characters. "" when there is nothing to say. */
78
+ function safeJson(value: unknown): string {
79
+ try {
80
+ const json = JSON.stringify(value);
81
+ if (!json || json === "{}") return "";
82
+ return json.length > 500 ? `${json.slice(0, 499)}…` : json;
83
+ } catch {
84
+ return "";
85
+ }
86
+ }
87
+
76
88
  /** {@link ErrorBoundaryDetails} of any thrown value, without ever throwing. */
77
89
  export function describeThrown(error: unknown): ErrorBoundaryDetails {
78
90
  if (typeof error === "object" && error !== null) {
79
91
  const e = error as { name?: unknown; message?: unknown; stack?: unknown };
92
+ const message = read(() => e.message) ?? "";
80
93
  return {
81
94
  name: read(() => e.name) ?? "Error",
82
- message: read(() => e.message) ?? "",
95
+ // `throw { code: 500 }` has no message, and an empty line leaves crash triage with
96
+ // nothing (keksdose G3a; its old reporter filed `{"code":500}`). Such a value is
97
+ // quoted as JSON instead, capped, and never at the cost of a throw.
98
+ message: message || (error instanceof Error ? "" : safeJson(error)),
83
99
  stack: read(() => e.stack),
84
100
  };
85
101
  }
@@ -135,6 +151,9 @@ export interface CrashReport {
135
151
  userAgent: string;
136
152
  /** The boundary's `appVersion` prop. */
137
153
  appVersion?: string;
154
+ /** The boundary's `placement` prop ("app", "page", "widget"): which boundary caught
155
+ * it, so triage can tell a whole-app crash from one page's (keksdose G3b). */
156
+ placement?: string;
138
157
  /** `navigator.onLine` at the moment of the crash; left out where there is none. */
139
158
  online?: boolean;
140
159
  /** {@link isChunkLoadError} of the thrown value. Such a crash is never handed to
@@ -161,6 +180,7 @@ export function formatCrashReport(report: CrashReport): string {
161
180
  `User agent: ${report.userAgent}`,
162
181
  ];
163
182
  if (report.appVersion) lines.push(`App version: ${report.appVersion}`);
183
+ if (report.placement) lines.push(`Placement: ${report.placement}`);
164
184
  if (report.online === false) lines.push("Online: no");
165
185
  for (const [key, value] of Object.entries(report.extras)) lines.push(`${key}: ${value}`);
166
186
  if (report.stack) lines.push("", "Stack:", report.stack.trim());
@@ -178,9 +198,14 @@ export function formatCrashReport(report: CrashReport): string {
178
198
  * times a second. The page and the time are left out on purpose: the same bug on two
179
199
  * routes is still one bug.
180
200
  */
181
- export function crashFingerprint(report: Pick<CrashReport, "name" | "message" | "stack">): string {
201
+ export function crashFingerprint(
202
+ report: Pick<CrashReport, "name" | "message" | "stack"> & Pick<Partial<CrashReport>, "placement">,
203
+ ): string {
182
204
  const frames = (report.stack ?? "").split("\n").slice(0, 4).join("|");
183
- return `${report.name}::${report.message}::${frames}`;
205
+ // The placement is part of it (keksdose G3b): the same error caught by the page
206
+ // boundary and, after Try again fails, by the app boundary is two facts for triage.
207
+ const where = report.placement ? `${report.placement}::` : "";
208
+ return `${where}${report.name}::${report.message}::${frames}`;
184
209
  }
185
210
 
186
211
  /**
@@ -305,6 +330,10 @@ export interface ErrorBoundaryProps {
305
330
  copyReport?: boolean;
306
331
  /** The app's version, printed in the report — the first question on every crash. */
307
332
  appVersion?: string;
333
+ /** Which boundary this is — "app" at the root, "page" in the layout — carried into
334
+ * the report (`placement`) and its fingerprint, so `onReport` needs no closure to say
335
+ * where the crash was caught (keksdose G3b). */
336
+ placement?: string;
308
337
  /** More `key: value` lines for the report, read when the boundary catches (a tenant
309
338
  * id, the feature flags). A throw from it is swallowed and the lines are left out. */
310
339
  reportExtras?: () => Record<string, string>;
@@ -411,6 +440,7 @@ function buildReport(details: ErrorBoundaryDetails, componentStack: string | und
411
440
  time: new Date().toISOString(),
412
441
  userAgent,
413
442
  appVersion: props.appVersion,
443
+ placement: props.placement,
414
444
  online,
415
445
  chunkLoad: isChunkLoadError(details),
416
446
  extras,
@@ -100,15 +100,20 @@ export function NumberPadSheet({
100
100
  label,
101
101
  labels,
102
102
  decimalMark = ".",
103
+ onResult,
103
104
  }: {
104
105
  value: string;
105
106
  /** Fired with the raw (sanitised) field text on every key — same contract as
106
107
  * the host field's own `onChange`. */
107
108
  onChange: (value: string) => void;
108
109
  /** The glyph on the decimal key — "," where the host shows a comma (AmountInput
109
- * in fr-CH, kastlan 40). The key still inserts a dot; the host's text is dot-form
110
- * underneath and `sanitizeLive` reads either. */
110
+ * in fr-CH, kastlan 40). With "," the key inserts a comma and the text is handed
111
+ * back unsanitized, for the host to read by its locale (keksdose G1). */
111
112
  decimalMark?: "." | ",";
113
+ /** "=": the evaluated result, dot-decimal. Default `onChange`. A host that reads
114
+ * typed text by the locale's marks takes it apart from keystrokes, since a result
115
+ * like "1.234" is a decimal and never a grouped thousand. */
116
+ onResult?: (value: string) => void;
112
117
  /** Fired on "Done": the host blurs the input, which commits (evaluates) and
113
118
  * unmounts the sheet via its existing blur handler. */
114
119
  onDone: () => void;
@@ -185,11 +190,15 @@ export function NumberPadSheet({
185
190
  const result = evaluateExpression(value);
186
191
  const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : "";
187
192
 
188
- const insert = (ch: string) => onChange(sanitizeLive(value + ch));
193
+ // With a "," mark the host keeps the typist's own text (AmountInput, keksdose G1):
194
+ // the key inserts the comma it shows, and the host, not this pad, decides which mark
195
+ // is the decimal one — sanitizing here would fold a grouping "." into a decimal.
196
+ const insert = (ch: string) =>
197
+ onChange(decimalMark === "," ? value + (ch === "." ? "," : ch) : sanitizeLive(value + ch));
189
198
  const backspace = () => onChange(value.slice(0, -1));
190
199
  const clearAll = () => onChange("");
191
200
  const equals = () => {
192
- if (result !== null) onChange(formatResult(result));
201
+ if (result !== null) (onResult ?? onChange)(formatResult(result));
193
202
  };
194
203
 
195
204
  const sheet = (
@@ -34,6 +34,21 @@ const ROW: Record<PageHeaderMobileLayout, string> = {
34
34
  inline: "flex-row items-center justify-between gap-2 sm:gap-4",
35
35
  };
36
36
 
37
+ /**
38
+ * Where the actions sit on the title block's height, in the one row the header is from
39
+ * `sm` up (and on a phone too when {@link PageHeaderMobileLayout} is `inline`, the only
40
+ * layout that has a row there). Unset keeps each layout's own: `start` for `stacked`,
41
+ * `center` for `inline`.
42
+ */
43
+ export type PageHeaderActionsAlign = "start" | "center" | "end";
44
+
45
+ /** Per layout, because `stacked` is a column on a phone — a `self-center` there would
46
+ * centre the actions HORIZONTALLY — so it only aligns from `sm` up. */
47
+ const ACTIONS_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {
48
+ stacked: { start: "sm:self-start", center: "sm:self-center", end: "sm:self-end" },
49
+ inline: { start: "self-start", center: "self-center", end: "self-end" },
50
+ };
51
+
37
52
  export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
38
53
  title: ReactNode;
39
54
  /** A sentence under the title. */
@@ -63,6 +78,52 @@ export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "
63
78
  * button, a month picker); a long title wraps to make room for them instead.
64
79
  */
65
80
  mobileLayout?: PageHeaderMobileLayout;
81
+ /**
82
+ * A second group of actions that gets its OWN full-width row under the header on a
83
+ * phone, and joins the actions' row from `sm` up, between the title and
84
+ * {@link actions}.
85
+ *
86
+ * keksdose's budget page (budget-page.tsx, feedback #60) is the case: title and month
87
+ * navigation on row 1, the fold/unfold toggles on row 2 on a phone — "two tidy rows
88
+ * instead of the old ragged justify-between overflow" — and one row, title | toggles |
89
+ * month nav, on a wider screen. It builds that by hand with a wrapping flex row and
90
+ * swapped order utilities; adopting `PageHeader` (keksdose G6b) must not lose it.
91
+ * There: `actions` is the month navigation, this is the toggles.
92
+ *
93
+ * The DOM order is title, `actions`, `secondaryActions` — the phone's visual order,
94
+ * and the one a screen reader and the Tab key follow at every width. From `sm` up the
95
+ * two groups swap places visually only, which keeps the page's primary control (the
96
+ * month) at the row's end, where keksdose had it, and first in reading order. The
97
+ * breakpoint is `sm`, the one {@link mobileLayout} switches at, not keksdose's `md`:
98
+ * one header, one breakpoint.
99
+ */
100
+ secondaryActions?: ReactNode;
101
+ /**
102
+ * The actions' vertical alignment against the title block — see
103
+ * {@link PageHeaderActionsAlign}. Unset keeps the layout's own.
104
+ *
105
+ * keksdose's reports page (reports-page.tsx, keksdose G8) carries a LABELLED
106
+ * `CurrencySelect` as its action: a field with its label on top is taller than the
107
+ * title, and `stacked`'s default top alignment hangs it from the title's cap height
108
+ * with its control well below the title's line. `center` sets it on the title's
109
+ * middle, as that page's hand-written `items-center` row did.
110
+ */
111
+ actionsAlign?: PageHeaderActionsAlign;
112
+ /**
113
+ * One line, cut with an ellipsis, instead of the default breaking of a long word onto
114
+ * as many lines as it needs.
115
+ *
116
+ * keksdose's payees page (payees-page.tsx, live #263, keksdose G6a) wraps its title in
117
+ * its own truncating span so a long name gives way to the `inline` header's actions
118
+ * instead of pushing them. This is that span's behaviour on the heading itself; the
119
+ * title block is already allowed to shrink below its content, which is what lets the
120
+ * cut happen inside a flex row.
121
+ *
122
+ * The full title stays in the DOM as the heading's text, so a screen reader and the
123
+ * document outline read all of it. There is deliberately no native `title` tooltip:
124
+ * the kit does not use them (not on touch, not on keyboard focus, not styled).
125
+ */
126
+ truncateTitle?: boolean;
66
127
  }
67
128
 
68
129
  /**
@@ -85,20 +146,54 @@ export function PageHeader({
85
146
  as = "h1",
86
147
  size = "md",
87
148
  mobileLayout = "stacked",
149
+ secondaryActions,
150
+ actionsAlign,
151
+ truncateTitle = false,
88
152
  className,
89
153
  ...rest
90
154
  }: PageHeaderProps) {
91
155
  const Heading = as as ElementType;
156
+ const hasSecondary = secondaryActions != null;
157
+ const wrapsSecondary = hasSecondary && mobileLayout === "inline";
158
+ const align = actionsAlign != null ? ACTIONS_ALIGN[mobileLayout][actionsAlign] : undefined;
92
159
  return (
93
160
  <div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
94
161
  {breadcrumbs}
95
- <div className={cn("flex min-w-0", ROW[mobileLayout])}>
162
+ {/* With a second group, `inline` wraps so that group's full-width basis puts it
163
+ on a row of its own on a phone (`stacked` is a column there already, and a
164
+ basis would size a column item's HEIGHT); from `sm` up the row stops wrapping
165
+ and the order utilities seat it between the title and the actions. Without
166
+ one, nothing changes. */}
167
+ <div className={cn("flex min-w-0", ROW[mobileLayout], wrapsSecondary && "flex-wrap sm:flex-nowrap")}>
96
168
  <div className="min-w-0 flex-1">
97
169
  {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
98
- <Heading className={cn("break-words text-[var(--text-primary)]", TITLE[size])}>{title}</Heading>
170
+ <Heading
171
+ className={cn(
172
+ truncateTitle ? "truncate" : "break-words",
173
+ "text-[var(--text-primary)]",
174
+ TITLE[size],
175
+ )}
176
+ >
177
+ {title}
178
+ </Heading>
99
179
  {description != null && <p className="mt-1 text-sm text-[var(--text-muted)]">{description}</p>}
100
180
  </div>
101
- {actions != null && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
181
+ {actions != null && (
182
+ <div className={cn("flex shrink-0 flex-wrap items-center gap-2", hasSecondary && "sm:order-2", align)}>
183
+ {actions}
184
+ </div>
185
+ )}
186
+ {hasSecondary && (
187
+ <div
188
+ className={cn(
189
+ "flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0",
190
+ wrapsSecondary && "basis-full sm:basis-auto",
191
+ align,
192
+ )}
193
+ >
194
+ {secondaryActions}
195
+ </div>
196
+ )}
102
197
  </div>
103
198
  </div>
104
199
  );
@@ -58,7 +58,8 @@ export type SectionLabelVariant = "plain" | "band";
58
58
 
59
59
  /** The band's own type and box, per size — spelled out rather than merged over
60
60
  * {@link SECTION_LABEL_CLASS}, so the result does not hang on tailwind-merge telling
61
- * a font-size `text-[…]` from a colour `text-[var(…)]`. */
61
+ * a font-size arbitrary `text-` value from a colour one. (Not spelt out as class
62
+ * names here: Tailwind scans comments, and a literal one broke the showcase CSS.) */
62
63
  const SECTION_LABEL_BAND_CLASS: Record<SectionLabelSize, string> = {
63
64
  xs: "text-[10px]",
64
65
  md: "text-[11px]",
@@ -50,8 +50,11 @@ function physicalSide(side: TooltipSide, dir: Direction): PhysicalSide {
50
50
  * the viewport as well, for narrow screens where 20rem is already most of it.
51
51
  * `side` is a preference rather than an instruction for the PORTALLED variant,
52
52
  * which measures the bubble and turns it round when it would not fit
53
- * (Steering Design feedback #126). The CSS-only one never learns its own size,
54
- * so there `side` is still the whole of the placement. */
53
+ * (Steering Design feedback #126). The CSS-placed one never turns round, so there
54
+ * `side` is still the whole of which side it is on — but since 0.14 it is slid back
55
+ * along the cross axis when it would cross the viewport edge (keksdose G7), which the
56
+ * viewport term in this cap is what makes possible: a bubble never wider than the glass
57
+ * minus the margins always fits once slid. */
55
58
  const TOOLTIP_SURFACE =
56
59
  "w-max max-w-[min(20rem,calc(100vw-1rem))] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-lg";
57
60
 
@@ -220,6 +223,37 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & { si
220
223
  * existing app tests find without a hover (see above): flipping it would turn every
221
224
  * `getByRole("tooltip")` written against 0.12 into a failure in all three apps at once.
222
225
  * Reach for `lazy` wherever a `portal` was pinned only to keep a test's DOM clean.
226
+ *
227
+ * ⚠️ **The in-place bubble is clamped to the viewport when it opens (keksdose G7).** The
228
+ * portalled bubble has always measured itself and been pushed back onto the glass
229
+ * ({@link placeTooltip}); the in-place one never learnt its own size, so `side` was the
230
+ * whole of its placement and a `top` / `bottom` bubble sat centred on its trigger
231
+ * whatever that cost. The cost shows on a phone: long labels live at a row's START edge
232
+ * — a gcloud command in keksdose's jobs panel, a canned reply in a support thread — and
233
+ * a 20rem bubble centred on a trigger 16px from the edge hangs half its text off the
234
+ * screen. keksdose pinned `portal` on those sites for that alone, which gave up `lazy`'s
235
+ * point (the cheap, in-place bubble) to buy a placement.
236
+ *
237
+ * So now, when an in-place bubble goes up (default or `lazy`), a layout effect measures
238
+ * it and, if it crosses the viewport edge minus the same margin the portalled bubble
239
+ * keeps, slides it back along its CROSS axis only — sideways for `top` / `bottom`, up or
240
+ * down for the four side placements — with an inline `transform`, which composes with the
241
+ * placement classes' own `translate`. The main axis is left alone on purpose: sliding a
242
+ * `start` bubble along the main axis would slide it over its own trigger, and turning it
243
+ * round is a measured-placement decision this CSS-placed bubble does not make (pass
244
+ * `portal` for that). The width is already capped to the viewport (see
245
+ * `TOOLTIP_SURFACE`), so a bubble can always fit once slid, and a long label wraps
246
+ * instead of growing past the glass.
247
+ *
248
+ * Why on by default, with no prop? Because it is a no-op for every bubble that already
249
+ * fits — the shift is zero unless the bubble would overflow — so the only placements it
250
+ * changes are ones that were broken. The maths is in physical viewport pixels, so it
251
+ * needs no `dir`: an RTL row puts its long label at the RIGHT edge and gets slid left by
252
+ * the same code. Under jsdom there is no layout — every rect is zero-sized — and a
253
+ * zero-sized bubble is taken to be unmeasured, so tests see no transform at all. It is
254
+ * measured on open (and when the label or side changes while open), not on every scroll:
255
+ * an in-place bubble follows its trigger for free, and a page that scrolls sideways under
256
+ * an open tooltip is not a case worth a listener per tooltip.
223
257
  */
224
258
  export function Tooltip({
225
259
  label,
@@ -300,6 +334,10 @@ function InPlaceTooltip({
300
334
  const [dir, setDir] = useState<Direction>("ltr");
301
335
  const open = (hovered || focused) && !dismissed;
302
336
  useEscapeKey(() => setDismissed(true), open);
337
+ // keksdose G7: slide an open in-place bubble back onto the screen. See "The in-place
338
+ // bubble is clamped" on {@link Tooltip}.
339
+ const bubbleRef = useRef<HTMLSpanElement | null>(null);
340
+ const shift = useViewportClamp(bubbleRef, open && !clipped, side, label);
303
341
  // At mount, before the first paint: the in-place bubble inside a scroller is the
304
342
  // phantom-scroll bug whether or not anyone opens it.
305
343
  useLayoutEffect(() => {
@@ -353,9 +391,11 @@ function InPlaceTooltip({
353
391
  // CSS that shows it) that can disagree for a frame after Escape.
354
392
  open && (
355
393
  <span
394
+ ref={bubbleRef}
356
395
  id={id}
357
396
  role="tooltip"
358
397
  data-private={redact ? "" : undefined}
398
+ style={shiftStyle(shift)}
359
399
  className={cn(TOOLTIP_SURFACE, "pointer-events-none absolute z-50 opacity-100", sidePositionClass[side])}
360
400
  >
361
401
  {label}
@@ -363,8 +403,10 @@ function InPlaceTooltip({
363
403
  )
364
404
  ) : (
365
405
  <span
406
+ ref={bubbleRef}
366
407
  id={id}
367
408
  role="tooltip"
409
+ style={shiftStyle(shift)}
368
410
  // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the
369
411
  // bubble out of the accessibility tree as well as off the screen, or a screen
370
412
  // reader still reads out the description of a bubble the user just closed.
@@ -413,6 +455,71 @@ const TOOLTIP_GAP = 4;
413
455
  * against the glass reads as clipped even when every character is on screen. */
414
456
  const TOOLTIP_MARGIN = 4;
415
457
 
458
+ /** A cross-axis slide, in viewport pixels, for an in-place bubble. */
459
+ interface Shift {
460
+ x: number;
461
+ y: number;
462
+ }
463
+
464
+ const NO_SHIFT: Shift = { x: 0, y: 0 };
465
+
466
+ /** How far to slide the span `[low, high]` so it sits inside `[TOOLTIP_MARGIN,
467
+ * extent - TOOLTIP_MARGIN]`. Zero when it already does. The start edge wins when the
468
+ * span is wider than the room — as in {@link clamp}, the start of a label is the half
469
+ * worth keeping (and the width cap means that only happens on a viewport narrower than
470
+ * twice the margin). */
471
+ function slideInto(low: number, high: number, extent: number): number {
472
+ if (low < TOOLTIP_MARGIN) return TOOLTIP_MARGIN - low;
473
+ if (high > extent - TOOLTIP_MARGIN) return Math.max(extent - TOOLTIP_MARGIN - high, TOOLTIP_MARGIN - low);
474
+ return 0;
475
+ }
476
+
477
+ /**
478
+ * The in-place bubble's viewport clamp (keksdose G7): while `active`, measure the bubble
479
+ * in a LAYOUT effect — before paint, so there is no frame with the label off the screen —
480
+ * and return the cross-axis slide that brings it inside the viewport minus
481
+ * `TOOLTIP_MARGIN`. `top` / `bottom` slide along x; `left` / `right` / `start` / `end`
482
+ * along y. {@link NO_SHIFT} while closed, so the next open measures the bubble where the
483
+ * CSS alone puts it.
484
+ *
485
+ * The rect it reads already includes the slide it applied last time (a label that
486
+ * changed while open), so that slide is taken back out before deciding the new one.
487
+ * A zero-sized rect means no layout (jsdom, `display: none`) and leaves the bubble put.
488
+ */
489
+ function useViewportClamp(
490
+ bubbleRef: RefObject<HTMLSpanElement | null>,
491
+ active: boolean,
492
+ side: TooltipSide,
493
+ label: ReactNode,
494
+ ): Shift {
495
+ const [shift, setShift] = useState<Shift>(NO_SHIFT);
496
+ useLayoutEffect(() => {
497
+ const el = bubbleRef.current;
498
+ if (!active || !el) {
499
+ setShift(NO_SHIFT);
500
+ return;
501
+ }
502
+ const r = el.getBoundingClientRect();
503
+ if (r.width === 0 && r.height === 0) return;
504
+ const horizontal = side === "top" || side === "bottom";
505
+ setShift((previous) => {
506
+ const next = horizontal
507
+ ? { x: slideInto(r.left - previous.x, r.right - previous.x, window.innerWidth), y: 0 }
508
+ : { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, window.innerHeight) };
509
+ return next.x === previous.x && next.y === previous.y ? previous : next;
510
+ });
511
+ }, [bubbleRef, active, side, label]);
512
+ return shift;
513
+ }
514
+
515
+ /** The inline style for a slide: none at all when there is nothing to slide, so a bubble
516
+ * that fits renders exactly as it did before G7. `transform` rather than `translate`
517
+ * because the placement classes own the `translate` property (Tailwind v4's
518
+ * `-translate-x-1/2`); the two compose instead of one replacing the other. */
519
+ function shiftStyle(shift: Shift) {
520
+ return shift.x === 0 && shift.y === 0 ? undefined : { transform: `translate(${shift.x}px, ${shift.y}px)` };
521
+ }
522
+
416
523
  const portalTransformBySide: Record<PhysicalSide, string> = {
417
524
  right: "translate(0, -50%)",
418
525
  left: "translate(-100%, -50%)",