@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
|
@@ -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
|
-
|
|
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 = () =>
|
|
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) =>
|
|
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) =>
|
|
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
|
|
558
|
-
//
|
|
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={
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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).
|
|
110
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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
|
);
|
package/src/components/text.tsx
CHANGED
|
@@ -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
|
|
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-
|
|
54
|
-
*
|
|
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%)",
|