cursedbelt 5.46.2 → 5.47.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/dist/react/charts/IntervalTimeline.d.ts.map +1 -1
- package/dist/react/charts/IntervalTimeline.js +2 -2
- package/dist/react/charts/IntervalTimeline.js.map +1 -1
- package/dist/react/chat/DateMarker.d.ts +4 -0
- package/dist/react/chat/DateMarker.d.ts.map +1 -1
- package/dist/react/chat/DateMarker.js +15 -2
- package/dist/react/chat/DateMarker.js.map +1 -1
- package/dist/react/components/Button.d.ts.map +1 -1
- package/dist/react/components/Button.js +5 -0
- package/dist/react/components/Button.js.map +1 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +1 -1
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
- package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
- package/dist/react/components/DataTable/SortHeader.js +22 -5
- package/dist/react/components/DataTable/SortHeader.js.map +1 -1
- package/dist/react/components/DataTable/types.d.ts +10 -0
- package/dist/react/components/DataTable/types.d.ts.map +1 -1
- package/dist/react/components/DataTable/types.js.map +1 -1
- package/dist/react/components/DropdownMenu.js +10 -4
- package/dist/react/components/DropdownMenu.js.map +1 -1
- package/dist/react/components/Field.d.ts +9 -1
- package/dist/react/components/Field.d.ts.map +1 -1
- package/dist/react/components/Field.js +3 -3
- package/dist/react/components/Field.js.map +1 -1
- package/dist/react/components/SecretInput.d.ts +4 -2
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +2 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/auth/AuthCard.d.ts.map +1 -1
- package/dist/react/components/auth/AuthCard.js +5 -1
- package/dist/react/components/auth/AuthCard.js.map +1 -1
- package/dist/react/components/auth/PasswordSetFields.d.ts.map +1 -1
- package/dist/react/components/auth/PasswordSetFields.js +2 -2
- package/dist/react/components/auth/PasswordSetFields.js.map +1 -1
- package/dist/react/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
- package/dist/react/components/auth/PasswordStrengthMeter.js +3 -1
- package/dist/react/components/auth/PasswordStrengthMeter.js.map +1 -1
- package/dist/react/components/auth/RegisterForm.js +1 -1
- package/dist/react/components/auth/RegisterForm.js.map +1 -1
- package/dist/react/components/auth/SignupForm.d.ts.map +1 -1
- package/dist/react/components/auth/SignupForm.js +4 -2
- package/dist/react/components/auth/SignupForm.js.map +1 -1
- package/dist/react/components/auth/TotpEntry.d.ts.map +1 -1
- package/dist/react/components/auth/TotpEntry.js +18 -1
- package/dist/react/components/auth/TotpEntry.js.map +1 -1
- package/dist/react/components/auth/fields.d.ts +3 -1
- package/dist/react/components/auth/fields.d.ts.map +1 -1
- package/dist/react/components/auth/fields.js +6 -2
- package/dist/react/components/auth/fields.js.map +1 -1
- package/dist/react/components/calendar/DayAgenda.d.ts.map +1 -1
- package/dist/react/components/calendar/DayAgenda.js +4 -2
- package/dist/react/components/calendar/DayAgenda.js.map +1 -1
- package/dist/react/components/calendar/EventChip.d.ts +5 -1
- package/dist/react/components/calendar/EventChip.d.ts.map +1 -1
- package/dist/react/components/calendar/EventChip.js +10 -3
- package/dist/react/components/calendar/EventChip.js.map +1 -1
- package/dist/react/components/calendar/MonthGrid.js +1 -1
- package/dist/react/components/calendar/MonthGrid.js.map +1 -1
- package/dist/react/components/calendar/WeekView.d.ts.map +1 -1
- package/dist/react/components/calendar/WeekView.js +2 -2
- package/dist/react/components/calendar/WeekView.js.map +1 -1
- package/dist/react/components/calendar/colorUtils.d.ts.map +1 -1
- package/dist/react/components/calendar/colorUtils.js +13 -11
- package/dist/react/components/calendar/colorUtils.js.map +1 -1
- package/dist/react/components/fieldErrorText.d.ts +12 -0
- package/dist/react/components/fieldErrorText.d.ts.map +1 -0
- package/dist/react/components/fieldErrorText.js +12 -0
- package/dist/react/components/fieldErrorText.js.map +1 -0
- package/dist/react/deep-link/resume.d.ts.map +1 -1
- package/dist/react/deep-link/resume.js +9 -2
- package/dist/react/deep-link/resume.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +3 -0
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +12 -1
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.d.ts +3 -1
- package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.js +51 -15
- package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
- package/dist/react/file-tree/folderView.d.ts +25 -4
- package/dist/react/file-tree/folderView.d.ts.map +1 -1
- package/dist/react/file-tree/folderView.js +49 -9
- package/dist/react/file-tree/folderView.js.map +1 -1
- package/dist/react/file-tree/index.d.ts +2 -1
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +2 -1
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/file-tree/useFolderView.d.ts +34 -4
- package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
- package/dist/react/file-tree/useFolderView.js +43 -11
- package/dist/react/file-tree/useFolderView.js.map +1 -1
- package/dist/styles-areas/analytics.css +26 -25
- package/dist/styles-areas/calendar.css +4 -4
- package/dist/styles-areas/data-table.css +26 -25
- package/dist/styles-utilities.css +24 -9
- package/package.json +5 -1
- package/scripts/demoCoverage.spec.ts +33 -0
- package/scripts/demoCoverage.ts +124 -0
- package/src/react/charts/IntervalTimeline.tsx +6 -4
- package/src/react/chat/DateMarker.tsx +22 -6
- package/src/react/chat/MessageThread.spec.tsx +25 -1
- package/src/react/components/Button.spec.tsx +11 -0
- package/src/react/components/Button.tsx +5 -0
- package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
- package/src/react/components/DataTable/DataTable.tsx +1 -0
- package/src/react/components/DataTable/SortHeader.tsx +45 -5
- package/src/react/components/DataTable/types.ts +10 -0
- package/src/react/components/DropdownMenu.spec.tsx +26 -0
- package/src/react/components/DropdownMenu.tsx +10 -3
- package/src/react/components/Field.spec.tsx +24 -0
- package/src/react/components/Field.tsx +15 -5
- package/src/react/components/SecretInput.tsx +5 -1
- package/src/react/components/auth/AuthCard.spec.tsx +8 -0
- package/src/react/components/auth/AuthCard.tsx +4 -1
- package/src/react/components/auth/PasswordSetFields.tsx +7 -11
- package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
- package/src/react/components/auth/RegisterForm.tsx +6 -6
- package/src/react/components/auth/SignupForm.spec.tsx +17 -0
- package/src/react/components/auth/SignupForm.tsx +11 -10
- package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
- package/src/react/components/auth/TotpEntry.tsx +18 -1
- package/src/react/components/auth/fields.tsx +8 -1
- package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
- package/src/react/components/calendar/DayAgenda.tsx +5 -2
- package/src/react/components/calendar/EventChip.spec.tsx +12 -8
- package/src/react/components/calendar/EventChip.tsx +11 -6
- package/src/react/components/calendar/MonthGrid.tsx +1 -1
- package/src/react/components/calendar/WeekView.tsx +5 -3
- package/src/react/components/calendar/colorUtils.ts +13 -11
- package/src/react/components/fieldErrorText.tsx +17 -0
- package/src/react/deep-link/resume.spec.tsx +16 -0
- package/src/react/deep-link/resume.ts +9 -2
- package/src/react/file-tree/FileTree.tsx +14 -1
- package/src/react/file-tree/FolderViewMenus.tsx +57 -17
- package/src/react/file-tree/folderView.spec.ts +63 -1
- package/src/react/file-tree/folderView.ts +63 -12
- package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
- package/src/react/file-tree/index.ts +15 -1
- package/src/react/file-tree/useFolderView.ts +73 -12
- package/src/styles-areas/analytics.css +26 -25
- package/src/styles-areas/calendar.css +4 -4
- package/src/styles-areas/data-table.css +26 -25
- package/src/styles-utilities.css +24 -9
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun run demo:coverage [--prune]` — every exported component has a demo, or it does not ship.
|
|
3
|
+
*
|
|
4
|
+
* ## The trap this exists for
|
|
5
|
+
*
|
|
6
|
+
* Forge task 2466 (2026-10-02) set out to review every exported component against the demo, "the
|
|
7
|
+
* visual contract", and its first step was the list. Generated from the export map rather than
|
|
8
|
+
* remembered, it said **242 of 383** exported components appeared in no demo page at all —
|
|
9
|
+
* `Tabs`, `TopBar`, `Menu`, the whole auth kit, chat, calendar — so no review, matrix or design
|
|
10
|
+
* audit had ever drawn them, and nothing stopped the 384th arriving the same way.
|
|
11
|
+
*
|
|
12
|
+
* ## What counts
|
|
13
|
+
*
|
|
14
|
+
* A COMPONENT is a capitalised value export of a `./react*` subpath (`package.json` `exports`,
|
|
15
|
+
* through its `source` file) whose type is a React component: an exotic component (`memo`,
|
|
16
|
+
* `forwardRef`, `lazy`) or a function returning an element. A component is DEMOED when a file under
|
|
17
|
+
* `demo/` (not `demo/dist/`) names it outside its imports and comments — a demo that imports a
|
|
18
|
+
* component and never renders it has not shown it.
|
|
19
|
+
*
|
|
20
|
+
* `demoCoverage.baseline` lists the ones that had no demo when this was written. It is a CEILING:
|
|
21
|
+
* a component missing from the demo and from the baseline fails; a baseline entry that now HAS a
|
|
22
|
+
* demo (or is no longer exported) fails too, so the list only shrinks — `--prune` removes those,
|
|
23
|
+
* and refuses to add anything.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import * as ts from "typescript";
|
|
28
|
+
|
|
29
|
+
const ROOT = join(import.meta.dir, "..");
|
|
30
|
+
export const BASELINE = join(ROOT, "demoCoverage.baseline");
|
|
31
|
+
|
|
32
|
+
/** Every component name → the subpaths that export it. */
|
|
33
|
+
export function exportedComponents(root: string = ROOT): Map<string, string[]> {
|
|
34
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { exports: Record<string, unknown> };
|
|
35
|
+
const entries: [string, string][] = [];
|
|
36
|
+
for (const [subpath, target] of Object.entries(pkg.exports)) {
|
|
37
|
+
const source = typeof target === "object" && target !== null ? (target as { source?: unknown }).source : undefined;
|
|
38
|
+
if (subpath.startsWith("./react") && typeof source === "string") entries.push([subpath, join(root, source)]);
|
|
39
|
+
}
|
|
40
|
+
const program = ts.createProgram(
|
|
41
|
+
entries.map(([, file]) => file),
|
|
42
|
+
{
|
|
43
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
44
|
+
module: ts.ModuleKind.ESNext,
|
|
45
|
+
target: ts.ScriptTarget.ES2022,
|
|
46
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
47
|
+
allowImportingTsExtensions: true,
|
|
48
|
+
noEmit: true,
|
|
49
|
+
skipLibCheck: true,
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
const checker = program.getTypeChecker();
|
|
53
|
+
const found = new Map<string, string[]>();
|
|
54
|
+
for (const [subpath, file] of entries) {
|
|
55
|
+
const source = program.getSourceFile(file);
|
|
56
|
+
const module = source && checker.getSymbolAtLocation(source);
|
|
57
|
+
if (!module) continue;
|
|
58
|
+
for (const exported of checker.getExportsOfModule(module)) {
|
|
59
|
+
const name = exported.getName();
|
|
60
|
+
if (!/^[A-Z][a-z0-9]/.test(name)) continue;
|
|
61
|
+
const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
|
|
62
|
+
const declaration = symbol.valueDeclaration;
|
|
63
|
+
if (!(symbol.flags & ts.SymbolFlags.Value) || !declaration) continue;
|
|
64
|
+
const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
|
|
65
|
+
const component =
|
|
66
|
+
/ExoticComponent|FC<|FunctionComponent/.test(checker.typeToString(type)) ||
|
|
67
|
+
type.getCallSignatures().some((s) => /Element|ReactNode|ReactPortal/.test(checker.typeToString(s.getReturnType())));
|
|
68
|
+
if (!component) continue;
|
|
69
|
+
found.set(name, [...(found.get(name) ?? []), subpath]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every demo source with its imports and comments taken out, as one text. */
|
|
76
|
+
export function demoText(root: string = ROOT): string {
|
|
77
|
+
const files = [...new Bun.Glob("demo/**/*.{ts,tsx}").scanSync(root)].filter((f) => !f.startsWith("demo/dist/"));
|
|
78
|
+
return files
|
|
79
|
+
.map((f) =>
|
|
80
|
+
readFileSync(join(root, f), "utf8")
|
|
81
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
82
|
+
.replace(/(^|[^:])\/\/.*$/gm, "$1")
|
|
83
|
+
.replace(/^\s*import[\s\S]*?from\s+["'][^"']+["'];?/gm, ""),
|
|
84
|
+
)
|
|
85
|
+
.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The components no demo names, sorted. */
|
|
89
|
+
export function undemoed(components: ReadonlyMap<string, string[]>, text: string): string[] {
|
|
90
|
+
return [...components.keys()].filter((name) => !new RegExp(`\\b${name}\\b`).test(text)).sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readBaseline(file: string = BASELINE): string[] {
|
|
94
|
+
return readFileSync(file, "utf8")
|
|
95
|
+
.split("\n")
|
|
96
|
+
.map((l) => l.trim())
|
|
97
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What the check says: the new gaps (a fault), and the entries that have outlived their gap (also a fault). */
|
|
101
|
+
export function compare(missing: readonly string[], baseline: readonly string[]): { added: string[]; stale: string[] } {
|
|
102
|
+
const now = new Set(missing);
|
|
103
|
+
const allowed = new Set(baseline);
|
|
104
|
+
return { added: missing.filter((n) => !allowed.has(n)), stale: baseline.filter((n) => !now.has(n)) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (import.meta.main) {
|
|
108
|
+
const components = exportedComponents();
|
|
109
|
+
const missing = undemoed(components, demoText());
|
|
110
|
+
const baseline = readBaseline();
|
|
111
|
+
const { added, stale } = compare(missing, baseline);
|
|
112
|
+
const prune = process.argv.includes("--prune");
|
|
113
|
+
if (prune && stale.length > 0) {
|
|
114
|
+
const text = readFileSync(BASELINE, "utf8");
|
|
115
|
+
const gone = new Set(stale);
|
|
116
|
+
writeFileSync(BASELINE, text.split("\n").filter((l) => !gone.has(l.trim())).join("\n"));
|
|
117
|
+
console.log(`pruned ${stale.length}: ${stale.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
for (const name of added) console.error(`✗ ${name} (${components.get(name)?.join(", ")}) has no demo — add it to a demo/sections page before it ships.`);
|
|
120
|
+
if (!prune) for (const name of stale) console.error(`✗ ${name} is in demoCoverage.baseline but no longer needs to be — \`bun run demo:coverage --prune\`.`);
|
|
121
|
+
const faults = added.length + (prune ? 0 : stale.length);
|
|
122
|
+
console.log(`${components.size} exported components · ${components.size - missing.length} demoed · ${missing.length} without a demo (baseline ${baseline.length - (prune ? stale.length : 0)})`);
|
|
123
|
+
process.exit(faults > 0 ? 1 : 0);
|
|
124
|
+
}
|
|
@@ -243,22 +243,24 @@ export function IntervalTimeline({
|
|
|
243
243
|
</div>
|
|
244
244
|
)}
|
|
245
245
|
|
|
246
|
-
|
|
246
|
+
{/* The two ends of the axis never wrap ("02:30 / PM" at 390px), and the overflow note takes
|
|
247
|
+
its own full-width row instead of squeezing between them (forge task 2591). */}
|
|
248
|
+
<div className='flex flex-wrap justify-between gap-x-3 text-caption text-muted-foreground tabular-nums'>
|
|
247
249
|
{/* Bands and markers are drawn `aria-hidden` and carry no `title` — a native
|
|
248
250
|
tooltip does not exist on touch and cannot be themed (DESIGN.md §4). They
|
|
249
251
|
are NAMED here instead, in text every reader gets. */}
|
|
250
|
-
<span>
|
|
252
|
+
<span className='whitespace-nowrap'>
|
|
251
253
|
{formatTime(view.from)}
|
|
252
254
|
{bands.length > 0 && ' · shaded = paused'}
|
|
253
255
|
{markers.filter((m) => m.label).length > 0 &&
|
|
254
256
|
` · ${markers.filter((m) => m.label).map((m) => m.label).join(', ')} marked`}
|
|
255
257
|
</span>
|
|
258
|
+
<span className='whitespace-nowrap'>{formatTime(view.to)}</span>
|
|
256
259
|
{lanes.overflow > 0 && (
|
|
257
|
-
<span className='text-warning'>
|
|
260
|
+
<span className='w-full text-warning'>
|
|
258
261
|
+{lanes.overflow} more not drawn — the chart stacks {maxLanes} lanes
|
|
259
262
|
</span>
|
|
260
263
|
)}
|
|
261
|
-
<span>{formatTime(view.to)}</span>
|
|
262
264
|
</div>
|
|
263
265
|
</div>
|
|
264
266
|
);
|
|
@@ -17,6 +17,16 @@ function formatMarkerDate(date: Date, now = new Date()): string {
|
|
|
17
17
|
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The LOCAL calendar day as `YYYY-MM-DD` — the same day the label names. `toISOString()` is the
|
|
22
|
+
* UTC day, so a message at 19:00 in Chicago was labelled "Wednesday, July 1" and stamped
|
|
23
|
+
* `2026-07-02` (forge task 2591).
|
|
24
|
+
*/
|
|
25
|
+
function localDayKey(date: Date): string {
|
|
26
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
27
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
21
31
|
|
|
22
32
|
export interface DateMarkerProps {
|
|
@@ -27,23 +37,29 @@ export interface DateMarkerProps {
|
|
|
27
37
|
/**
|
|
28
38
|
* Centered date-separator row inserted between messages from different calendar
|
|
29
39
|
* days. Renders "Today", "Yesterday", or a full date ("Monday, July 1").
|
|
40
|
+
*
|
|
41
|
+
* A `separator` with the date as its name, not `aria-hidden`: in a transcript the day change is
|
|
42
|
+
* the only place a screen reader learns WHEN the messages below were sent (Slack and iMessage
|
|
43
|
+
* both announce it).
|
|
30
44
|
*/
|
|
31
45
|
export function DateMarker({ date, className }: DateMarkerProps) {
|
|
32
46
|
const d = date instanceof Date ? date : new Date(date);
|
|
33
47
|
const label = formatMarkerDate(d);
|
|
34
|
-
const
|
|
48
|
+
const day = localDayKey(d);
|
|
35
49
|
|
|
36
50
|
return (
|
|
51
|
+
// biome-ignore lint/a11y/useSemanticElements: an <hr> cannot hold the date it announces; a named separator role on the row is the labelled-divider pattern
|
|
37
52
|
<div
|
|
38
|
-
data-chat-date={
|
|
53
|
+
data-chat-date={day}
|
|
54
|
+
role='separator'
|
|
55
|
+
aria-label={label}
|
|
39
56
|
className={cn('flex items-center gap-3 px-4 py-2', className)}
|
|
40
|
-
aria-hidden='true'
|
|
41
57
|
>
|
|
42
|
-
<span className='h-px flex-1 bg-border' />
|
|
43
|
-
<time dateTime={
|
|
58
|
+
<span aria-hidden className='h-px flex-1 bg-border' />
|
|
59
|
+
<time dateTime={day} className='text-xs font-medium text-muted-foreground'>
|
|
44
60
|
{label}
|
|
45
61
|
</time>
|
|
46
|
-
<span className='h-px flex-1 bg-border' />
|
|
62
|
+
<span aria-hidden className='h-px flex-1 bg-border' />
|
|
47
63
|
</div>
|
|
48
64
|
);
|
|
49
65
|
}
|
|
@@ -122,9 +122,33 @@ describe('DateMarker', () => {
|
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
test('has data-chat-date attribute', () => {
|
|
125
|
-
const { container } = render(<DateMarker date={new Date(
|
|
125
|
+
const { container } = render(<DateMarker date={new Date(2026, 6, 2, 12)} />);
|
|
126
126
|
expect(container.querySelector('[data-chat-date="2026-07-02"]')).toBeTruthy();
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
test('stamps the LOCAL day the label names, at either end of it — not the UTC day', () => {
|
|
130
|
+
// `bun test` runs in UTC, where the local and UTC days agree and the bug cannot show — so the
|
|
131
|
+
// zone is set here: west of UTC the late message crosses into tomorrow's UTC day, east of it
|
|
132
|
+
// the early one falls back into yesterday's.
|
|
133
|
+
const zone = process.env.TZ;
|
|
134
|
+
try {
|
|
135
|
+
for (const [tz, hour] of [['America/Chicago', 23], ['Asia/Tokyo', 0]] as const) {
|
|
136
|
+
process.env.TZ = tz;
|
|
137
|
+
const { container, unmount } = render(<DateMarker date={new Date(2026, 6, 2, hour, 30)} />);
|
|
138
|
+
const marker = container.querySelector('[data-chat-date]');
|
|
139
|
+
expect(marker?.getAttribute('data-chat-date')).toBe('2026-07-02');
|
|
140
|
+
expect(marker?.querySelector('time')?.getAttribute('dateTime')).toBe('2026-07-02');
|
|
141
|
+
unmount();
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
process.env.TZ = zone;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('is a named separator a screen reader announces, not hidden from it', () => {
|
|
149
|
+
render(<DateMarker date={new Date()} />);
|
|
150
|
+
expect(screen.getByRole('separator', { name: 'Today' })).toBeTruthy();
|
|
151
|
+
});
|
|
128
152
|
});
|
|
129
153
|
|
|
130
154
|
// ── StatusMarker ──────────────────────────────────────────────────────────────
|
|
@@ -34,6 +34,17 @@ describe('Button', () => {
|
|
|
34
34
|
expect(cls).toContain('h-[var(--control-h-md)]'); // md, the shared control-height token
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
test('a link is unpadded text at every size, so it lines up with the text around it (task 2466)', () => {
|
|
38
|
+
for (const size of [undefined, 'sm', 'md', 'lg'] as const) {
|
|
39
|
+
const { getByRole, unmount } = render(<Button tooltip='L' variant='link' size={size}>L</Button>);
|
|
40
|
+
const cls = getByRole('button').className.split(/\s+/);
|
|
41
|
+
expect(cls).toContain('px-0');
|
|
42
|
+
expect(cls).toContain('h-auto');
|
|
43
|
+
expect(cls.some((c) => /^px-[1-9]/.test(c) || c.startsWith('h-[var(--control-h'))).toBe(false);
|
|
44
|
+
unmount();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
37
48
|
test('primary uses the saturated solid accent fill; destructive is quiet at rest', () => {
|
|
38
49
|
const { getByRole, rerender } = render(<Button tooltip='P' variant='primary'>P</Button>);
|
|
39
50
|
expect(getByRole('button').className).toContain('bg-accent-solid');
|
|
@@ -119,6 +119,11 @@ export const buttonVariants = cva(
|
|
|
119
119
|
'icon-lg': 'h-[var(--control-h-lg)] w-[var(--control-h-lg)] p-0 pointer-coarse:min-w-11',
|
|
120
120
|
},
|
|
121
121
|
},
|
|
122
|
+
// 🔴 A link is a TEXT link at every size (forge task 2466). `link`'s own `px-0 h-auto` came
|
|
123
|
+
// before the size's `px-3`/`px-4` and fixed height, so the size won the merge and every link
|
|
124
|
+
// button — sized or on the `md` default — drew a padded box: TotpEntry's "Use a recovery code
|
|
125
|
+
// instead" sat 12px in from the column every other row starts on.
|
|
126
|
+
compoundVariants: [{ variant: 'link', size: ['sm', 'md', 'lg'], class: 'h-auto px-0 py-0' }],
|
|
122
127
|
defaultVariants: { variant: 'secondary', size: 'md' },
|
|
123
128
|
},
|
|
124
129
|
);
|
|
@@ -568,3 +568,46 @@ describe('hidePagination shows every row', () => {
|
|
|
568
568
|
expect(bodyRows(container).length).toBe(50);
|
|
569
569
|
});
|
|
570
570
|
});
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* 🔴 Forge task 2577: apps/music's Length header read "LE…" at 390px. `headerIcon` paints a glyph
|
|
574
|
+
* instead of the label below a TABLE width, and the label stays the header's accessible name.
|
|
575
|
+
*/
|
|
576
|
+
describe('ColDef.headerIcon', () => {
|
|
577
|
+
const CLOCK = <svg data-testid='clock' />;
|
|
578
|
+
const WITH_ICON: ColDef<Person>[] = [
|
|
579
|
+
{ key: 'name', label: 'Name', type: 'text', sortable: true },
|
|
580
|
+
{ key: 'age', label: 'Length', type: 'number', sortable: true, headerIcon: CLOCK },
|
|
581
|
+
{ key: 'nick', label: 'Plain', type: 'text', headerIcon: CLOCK },
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
test('the glyph replaces the label below the table step; the label stays for assistive tech', () => {
|
|
585
|
+
const { container } = render(<DataTable columns={WITH_ICON} data={PEOPLE} />);
|
|
586
|
+
const heads = [...container.querySelectorAll('thead th')];
|
|
587
|
+
const length = heads.find((th) => th.textContent?.includes('Length')) as HTMLElement;
|
|
588
|
+
const label = length.querySelector('[data-cb-header-label]') as HTMLElement;
|
|
589
|
+
const icon = length.querySelector('[data-cb-header-icon]') as HTMLElement;
|
|
590
|
+
expect(label.className).toContain('@max-2xl:sr-only');
|
|
591
|
+
expect(icon.className).toContain('hidden @max-2xl:inline-flex');
|
|
592
|
+
expect(icon.getAttribute('aria-hidden')).not.toBeNull();
|
|
593
|
+
expect(icon.querySelector('[data-testid="clock"]')).not.toBeNull();
|
|
594
|
+
// A sortable header still sorts by its label's name, and stays a 44px target without it.
|
|
595
|
+
expect(length.querySelector('button')?.textContent).toContain('Length');
|
|
596
|
+
expect(length.querySelector('button')?.className).toContain('@max-2xl:min-w-11');
|
|
597
|
+
|
|
598
|
+
const plain = heads.find((th) => th.textContent?.includes('Plain')) as HTMLElement;
|
|
599
|
+
// A plain (unsortable) header swaps the same way.
|
|
600
|
+
expect(plain.querySelector('[data-cb-header-label]')?.className).toContain('@max-2xl:sr-only');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('a column without one renders exactly as before', () => {
|
|
604
|
+
const { container } = render(<DataTable columns={COLUMNS} data={PEOPLE} />);
|
|
605
|
+
expect(container.querySelector('[data-cb-header-icon]')).toBeNull();
|
|
606
|
+
expect(container.querySelector('[data-cb-header-label]')).toBeNull();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test('the utility build carries every class the swap uses', async () => {
|
|
610
|
+
const css = await Bun.file(new URL('../../../styles-utilities.css', import.meta.url)).text();
|
|
611
|
+
for (const cls of ['sr-only', 'inline-flex', 'min-w-11', 'justify-center']) expect(css).toContain(`.\\@max-2xl\\:${cls}`);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
@@ -1171,6 +1171,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1171
1171
|
<SortHeader
|
|
1172
1172
|
column={column}
|
|
1173
1173
|
label={col.label}
|
|
1174
|
+
icon={col.headerIcon}
|
|
1174
1175
|
sortable={col.sortable}
|
|
1175
1176
|
sortIndex={stack.length > 1 ? stack.findIndex((s) => s.id === col.key) : -1}
|
|
1176
1177
|
onSort={() => toggleSortKey(col.key)}
|
|
@@ -23,9 +23,36 @@ const withLabelTip = (label: string, children: ReactNode) => (
|
|
|
23
23
|
</Tooltip>
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* `ColDef.headerIcon`'s swap below the table's 42rem step (`@max-2xl`; the table is the
|
|
28
|
+
* `@container`). The label goes `sr-only`, not `hidden`, so the header keeps its name for
|
|
29
|
+
* assistive tech at every width. One step only: each step costs every consuming app its CSS.
|
|
30
|
+
*/
|
|
31
|
+
const COMPACT_LABEL = '@max-2xl:sr-only';
|
|
32
|
+
const COMPACT_ICON = 'hidden @max-2xl:inline-flex';
|
|
33
|
+
/** A glyph and an arrow are ~30px; the label was what made the button wide enough to tap. */
|
|
34
|
+
const COMPACT_BUTTON = ' @max-2xl:min-w-11 @max-2xl:justify-center';
|
|
35
|
+
|
|
36
|
+
/** The label, and — with an icon — the glyph that replaces it on a narrow table. */
|
|
37
|
+
function HeaderLabel({ label, icon }: { label: string; icon?: ReactNode }) {
|
|
38
|
+
if (icon == null) return <span className='min-w-0 truncate'>{label}</span>;
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<span data-cb-header-label className={`min-w-0 truncate ${COMPACT_LABEL}`}>
|
|
42
|
+
{label}
|
|
43
|
+
</span>
|
|
44
|
+
<span data-cb-header-icon aria-hidden className={`shrink-0 items-center [&>svg]:size-3.5 ${COMPACT_ICON}`}>
|
|
45
|
+
{icon}
|
|
46
|
+
</span>
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
export interface SortHeaderProps<TRow> {
|
|
27
52
|
column: Column<TRow, unknown>;
|
|
28
53
|
label: string;
|
|
54
|
+
/** `ColDef.headerIcon` — drawn instead of the label when the table is narrower than 42rem. */
|
|
55
|
+
icon?: ReactNode;
|
|
29
56
|
sortable?: boolean;
|
|
30
57
|
/**
|
|
31
58
|
* This column's position in the sort STACK, or `-1` when it is not in one (or
|
|
@@ -58,6 +85,7 @@ export interface SortHeaderProps<TRow> {
|
|
|
58
85
|
export function SortHeader<TRow>({
|
|
59
86
|
column,
|
|
60
87
|
label,
|
|
88
|
+
icon,
|
|
61
89
|
sortable,
|
|
62
90
|
sortIndex = -1,
|
|
63
91
|
onSort,
|
|
@@ -69,8 +97,20 @@ export function SortHeader<TRow>({
|
|
|
69
97
|
// that overflows a `<th>` paints on top of the NEXT header rather than
|
|
70
98
|
// clipping, which reads as a rendering fault ("Sask"/"ACTEMPY" in orch's
|
|
71
99
|
// fleet table, 2026-08-11). `title` keeps the full label one hover away.
|
|
72
|
-
if (!sortable)
|
|
100
|
+
if (!sortable)
|
|
101
|
+
return withLabelTip(
|
|
102
|
+
label,
|
|
103
|
+
icon == null ? (
|
|
104
|
+
<span className='block truncate'>{label}</span>
|
|
105
|
+
) : (
|
|
106
|
+
<span className='flex min-w-0 items-center'>
|
|
107
|
+
<HeaderLabel label={label} icon={icon} />
|
|
108
|
+
</span>
|
|
109
|
+
),
|
|
110
|
+
);
|
|
73
111
|
const sorted = column.getIsSorted();
|
|
112
|
+
// The design law's LAY-13 floor is 44px, and music's audit caught the bare glyph at 390 (task 2577).
|
|
113
|
+
const compact = icon == null ? '' : COMPACT_BUTTON;
|
|
74
114
|
if (disabledReason)
|
|
75
115
|
// Visible and explained, never hidden — and `aria-disabled` rather than `disabled`, because
|
|
76
116
|
// a disabled button fires no pointer events and the reason could never be hovered.
|
|
@@ -81,9 +121,9 @@ export function SortHeader<TRow>({
|
|
|
81
121
|
type='button'
|
|
82
122
|
aria-disabled
|
|
83
123
|
aria-label={`${label} — ${disabledReason}`}
|
|
84
|
-
className={`-mx-1 -my-2.5 inline-flex max-w-full cursor-not-allowed items-center gap-1 rounded px-1 py-2.5 uppercase focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}`}
|
|
124
|
+
className={`-mx-1 -my-2.5 inline-flex max-w-full cursor-not-allowed items-center gap-1 rounded px-1 py-2.5 uppercase focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}${compact}`}
|
|
85
125
|
>
|
|
86
|
-
<
|
|
126
|
+
<HeaderLabel label={label} icon={icon} />
|
|
87
127
|
</button>,
|
|
88
128
|
);
|
|
89
129
|
return withLabelTip(
|
|
@@ -104,11 +144,11 @@ export function SortHeader<TRow>({
|
|
|
104
144
|
// computed styles on orch's Fleet tab, 2026-08-12: `th` uppercase,
|
|
105
145
|
// `button` none. Letter-spacing does inherit (0.55px on both), so only
|
|
106
146
|
// the case needs restating.
|
|
107
|
-
className={`-mx-1 -my-2.5 inline-flex max-w-full items-center gap-1 rounded px-1 py-2.5 uppercase transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}`}
|
|
147
|
+
className={`-mx-1 -my-2.5 inline-flex max-w-full items-center gap-1 rounded px-1 py-2.5 uppercase transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}${compact}`}
|
|
108
148
|
>
|
|
109
149
|
{/* min-w-0 so the label is what gives way in a narrow track — the sort
|
|
110
150
|
arrow is the affordance and must survive at any width. */}
|
|
111
|
-
<
|
|
151
|
+
<HeaderLabel label={label} icon={icon} />
|
|
112
152
|
{sorted === 'asc' ? (
|
|
113
153
|
<ArrowUp className='size-3 shrink-0' aria-hidden />
|
|
114
154
|
) : sorted === 'desc' ? (
|
|
@@ -90,6 +90,16 @@ export interface ColDef<TRow = Record<string, unknown>> {
|
|
|
90
90
|
* Applies to the grid; the card tiers already choose their own fields.
|
|
91
91
|
*/
|
|
92
92
|
hideBelow?: 'sm' | 'md' | 'lg';
|
|
93
|
+
/**
|
|
94
|
+
* A glyph the header shows INSTEAD of its label when the table (its container) is narrower
|
|
95
|
+
* than 42rem — {@link hideBelow}'s `'sm'` step — Spotify's clock over the duration column. `label` stays the
|
|
96
|
+
* accessible name, the hover tip and the Columns-menu name; only the painted text gives way.
|
|
97
|
+
*
|
|
98
|
+
* 🔴 Why (forge task 2577): apps/music's Length column read "LE…" at 390px. "LENGTH" and its
|
|
99
|
+
* sort arrow need ~90px, and on a phone those pixels belong to the title, which is already
|
|
100
|
+
* truncating real titles. A truncated word is a header nobody can read; a glyph is one.
|
|
101
|
+
*/
|
|
102
|
+
headerIcon?: ReactNode;
|
|
93
103
|
/**
|
|
94
104
|
* Drop order when the table is too NARROW for its declared tracks — HIGHER numbers go first.
|
|
95
105
|
* Columns without one never drop. The table measures its own width and collapses the
|
|
@@ -91,4 +91,30 @@ describe('DropdownMenu — a list of verbs in groups, destructive last (OVL-7)',
|
|
|
91
91
|
const items = Array.from(open.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent);
|
|
92
92
|
expect(items.at(-1)).toBe('Delete');
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
test('once one row can be checked, every row keeps its column — icons line up', () => {
|
|
96
|
+
// The player's ⋯ (forge task 2568): Keep offline and Tag are toggles, Share and Delete are
|
|
97
|
+
// not, and the toggles' check column pushed their icons 24px right of the others'.
|
|
98
|
+
render(
|
|
99
|
+
<DropdownMenu
|
|
100
|
+
items={[
|
|
101
|
+
{ key: 'share', label: 'Share', icon: <span>s</span> },
|
|
102
|
+
{ key: 'keep', label: 'Keep offline', icon: <span>k</span>, checked: false },
|
|
103
|
+
{ key: 'del', label: 'Delete', icon: <span>d</span>, destructive: true },
|
|
104
|
+
]}
|
|
105
|
+
trigger={<B>actions</B>}
|
|
106
|
+
/>,
|
|
107
|
+
);
|
|
108
|
+
fireEvent.pointerDown(screen.getByText('actions'), { button: 0, ctrlKey: false });
|
|
109
|
+
const rows = Array.from((menu() as HTMLElement).querySelectorAll('[role^="menuitem"]')) as HTMLElement[];
|
|
110
|
+
expect(rows.map((row) => row.getAttribute('role'))).toEqual(['menuitem', 'menuitemcheckbox', 'menuitem']);
|
|
111
|
+
for (const row of rows) expect(row.className.split(/\s+/)).toContain('pl-8');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a menu with no row to check keeps no empty check column', () => {
|
|
115
|
+
render(<DropdownMenu items={[{ key: 'share', label: 'Share' }]} trigger={<B>actions</B>} />);
|
|
116
|
+
fireEvent.pointerDown(screen.getByText('actions'), { button: 0, ctrlKey: false });
|
|
117
|
+
const row = (menu() as HTMLElement).querySelector('[role="menuitem"]') as HTMLElement;
|
|
118
|
+
expect(row.className.split(/\s+/)).not.toContain('pl-8');
|
|
119
|
+
});
|
|
94
120
|
});
|
|
@@ -155,11 +155,17 @@ export function DropdownMenu({
|
|
|
155
155
|
|
|
156
156
|
function MenuSections({ items }: { items: readonly DropdownMenuItem[] }) {
|
|
157
157
|
const sections = useMemo(() => arrangeMenuItems(items), [items]);
|
|
158
|
+
/*
|
|
159
|
+
* One row that can be checked gives every row of this menu the check column, as a native menu
|
|
160
|
+
* does: otherwise the toggles' icons sit 24px right of their neighbours' (the player's ⋯, forge
|
|
161
|
+
* task 2568). A submenu is its own menu and decides for itself.
|
|
162
|
+
*/
|
|
163
|
+
const inset = items.some((item) => !item.divider && item.checked !== undefined);
|
|
158
164
|
return sections.map((section, index) => (
|
|
159
165
|
<Fragment key={section[0]?.key ?? index}>
|
|
160
166
|
{index > 0 && <RDrop.Separator className='my-1 h-px bg-border' />}
|
|
161
167
|
{section.map((item) => (
|
|
162
|
-
<DropdownItem key={item.key} item={item} />
|
|
168
|
+
<DropdownItem key={item.key} item={item} inset={inset} />
|
|
163
169
|
))}
|
|
164
170
|
</Fragment>
|
|
165
171
|
));
|
|
@@ -195,7 +201,7 @@ function ItemTrailing({ item }: { item: DropdownMenuAction }) {
|
|
|
195
201
|
);
|
|
196
202
|
}
|
|
197
203
|
|
|
198
|
-
function DropdownItem({ item }: { item: DropdownMenuItem }) {
|
|
204
|
+
function DropdownItem({ item, inset }: { item: DropdownMenuItem; inset: boolean }) {
|
|
199
205
|
const container = useFloatingContainer();
|
|
200
206
|
if (item.divider) {
|
|
201
207
|
return <RDrop.Separator className='my-1 h-px bg-border' />;
|
|
@@ -204,7 +210,7 @@ function DropdownItem({ item }: { item: DropdownMenuItem }) {
|
|
|
204
210
|
if (item.children?.length) {
|
|
205
211
|
return (
|
|
206
212
|
<RDrop.Sub>
|
|
207
|
-
<RDrop.SubTrigger className={cn(FLOATING_ITEM_BASE, 'justify-between')}>
|
|
213
|
+
<RDrop.SubTrigger className={cn(FLOATING_ITEM_BASE, 'justify-between', inset && 'pl-8')}>
|
|
208
214
|
<span className='flex items-center gap-2'>
|
|
209
215
|
{item.icon && (
|
|
210
216
|
<span className='inline-flex size-4 items-center justify-center' aria-hidden>
|
|
@@ -262,6 +268,7 @@ function DropdownItem({ item }: { item: DropdownMenuItem }) {
|
|
|
262
268
|
title={item.title ?? item.disabledReason}
|
|
263
269
|
className={cn(
|
|
264
270
|
FLOATING_ITEM_BASE,
|
|
271
|
+
inset && 'pl-8',
|
|
265
272
|
item.destructive && 'text-destructive focus:bg-destructive/10 focus:text-destructive',
|
|
266
273
|
)}
|
|
267
274
|
>
|
|
@@ -200,6 +200,30 @@ describe('Field', () => {
|
|
|
200
200
|
expect(container.querySelector('div')?.childElementCount).toBe(before as number);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test('a `status` (the strength meter) holds the reserved row directly under the control, and gives it to an error (task 2466)', () => {
|
|
204
|
+
const { container } = render(
|
|
205
|
+
<Harness>
|
|
206
|
+
<Field name='email' label='Email' reserveErrorSpace status={<span>Strong</span>}>
|
|
207
|
+
<input
|
|
208
|
+
// guardrails-ignore no-raw-inputs: test fixture, not product UI
|
|
209
|
+
/>
|
|
210
|
+
</Field>
|
|
211
|
+
</Harness>,
|
|
212
|
+
);
|
|
213
|
+
const slot = () => container.querySelector('[data-cb-field-status]');
|
|
214
|
+
// In the reserved row's place — not under an empty one.
|
|
215
|
+
expect(container.querySelector('[data-cc-field-error-reserved]')).toBeNull();
|
|
216
|
+
expect(slot()?.textContent).toBe('Strong');
|
|
217
|
+
const prev = slot()?.previousElementSibling;
|
|
218
|
+
expect(prev?.tagName === 'INPUT' || prev?.querySelector('input') != null).toBe(true);
|
|
219
|
+
const before = container.querySelector('div')?.childElementCount;
|
|
220
|
+
|
|
221
|
+
setError('email', 'Required');
|
|
222
|
+
expect(slot()).toBeNull();
|
|
223
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toContain('Required');
|
|
224
|
+
expect(container.querySelector('div')?.childElementCount).toBe(before as number);
|
|
225
|
+
});
|
|
226
|
+
|
|
203
227
|
test('is OFF by default — turning it on globally is a density change to every form', () => {
|
|
204
228
|
const { container } = render(
|
|
205
229
|
<Harness>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { AlertCircle } from 'lucide-react';
|
|
2
1
|
import { cloneElement, isValidElement, type ReactElement, type ReactNode, useId } from 'react';
|
|
3
2
|
import { get, useFormContext } from 'react-hook-form';
|
|
4
3
|
import { cn } from '../lib/cn.js';
|
|
4
|
+
import { FieldErrorText } from './fieldErrorText.js';
|
|
5
5
|
import {
|
|
6
6
|
DESCRIPTION_FOLD_CHARS,
|
|
7
7
|
FIELD_WIDTH_CLASS,
|
|
@@ -64,6 +64,14 @@ interface FieldBaseProps {
|
|
|
64
64
|
* reachability gate caught exactly that: "Create an account" did nothing.
|
|
65
65
|
*/
|
|
66
66
|
reserveErrorSpace?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Live feedback on the VALUE — a password-strength meter, a character count — in the error's
|
|
69
|
+
* slot under the control: shown while there is no error, giving the slot to the error while
|
|
70
|
+
* one shows (FRM-10), and never folded into the ⓘ the way help is. With `reserveErrorSpace`
|
|
71
|
+
* it holds the reserved line itself, so it must fit one line of `text-sm`. (Forge task 2466:
|
|
72
|
+
* the meter used to sit BELOW the reserved empty line, 34px from the field it measures.)
|
|
73
|
+
*/
|
|
74
|
+
status?: ReactNode;
|
|
67
75
|
/**
|
|
68
76
|
* How wide the CONTROL may grow — a cap, never a fixed width (see {@link FieldWidth}). The
|
|
69
77
|
* label, description and error keep the row's full width.
|
|
@@ -140,6 +148,7 @@ export function Field({
|
|
|
140
148
|
children,
|
|
141
149
|
className,
|
|
142
150
|
reserveErrorSpace = false,
|
|
151
|
+
status,
|
|
143
152
|
width,
|
|
144
153
|
descriptionOnNarrow = 'auto',
|
|
145
154
|
layout = 'stacked',
|
|
@@ -293,10 +302,11 @@ export function Field({
|
|
|
293
302
|
) : null}
|
|
294
303
|
|
|
295
304
|
{error ? (
|
|
296
|
-
<
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
305
|
+
<FieldErrorText id={errId}>{error}</FieldErrorText>
|
|
306
|
+
) : status != null ? (
|
|
307
|
+
<div className='min-h-[1lh] text-sm' data-cb-field-status=''>
|
|
308
|
+
{status}
|
|
309
|
+
</div>
|
|
300
310
|
) : reserveErrorSpace && (description == null || foldsAlways) ? (
|
|
301
311
|
// `aria-hidden` + no `role`: this is LAYOUT, and a screen reader must not
|
|
302
312
|
// meet an empty alert region. The height is one line of the same `text-sm`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CSSProperties, type KeyboardEvent, type ReactNode, useState } from 'react';
|
|
1
|
+
import { type CSSProperties, type FocusEvent, type KeyboardEvent, type ReactNode, useState } from 'react';
|
|
2
2
|
import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
|
|
3
3
|
import { CONTROL_HEIGHT_CLASS, type ControlSize, declareFieldWidth } from '../lib/field.js';
|
|
4
4
|
import { resolveClass, resolveStyle, type StyleableProps } from '../styling.js';
|
|
@@ -123,6 +123,8 @@ export interface SecretInputProps extends StyleableProps<SecretInputSlot> {
|
|
|
123
123
|
disabled?: boolean;
|
|
124
124
|
/** Fires on Enter in THIS field — the login/rotation forms submit from their last field. */
|
|
125
125
|
onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
|
126
|
+
/** Fires when the INPUT loses focus — a form library's touched/validate-on-blur hook. */
|
|
127
|
+
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
|
|
126
128
|
className?: string;
|
|
127
129
|
style?: CSSProperties;
|
|
128
130
|
/** Container height on the shared control-height scale (`--control-h-*`, task
|
|
@@ -156,6 +158,7 @@ export const SecretInput = ({
|
|
|
156
158
|
autoFocus = false,
|
|
157
159
|
disabled,
|
|
158
160
|
onEnter,
|
|
161
|
+
onBlur,
|
|
159
162
|
className,
|
|
160
163
|
style,
|
|
161
164
|
classNames,
|
|
@@ -206,6 +209,7 @@ export const SecretInput = ({
|
|
|
206
209
|
value={value}
|
|
207
210
|
readOnly={readOnly}
|
|
208
211
|
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
|
|
212
|
+
onBlur={onBlur}
|
|
209
213
|
onKeyDown={onEnter ? (e) => {
|
|
210
214
|
if (e.key === 'Enter') onEnter(e);
|
|
211
215
|
} : undefined}
|
|
@@ -23,6 +23,14 @@ describe('AuthCard', () => {
|
|
|
23
23
|
expect(getByText('Footer')).not.toBeNull();
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test('embedded, it paints no page background of its own — the host’s surface shows through (task 2466)', () => {
|
|
27
|
+
const page = render(<AuthCard>x</AuthCard>);
|
|
28
|
+
expect((page.container.firstElementChild as HTMLElement).className).toContain('bg-background');
|
|
29
|
+
page.unmount();
|
|
30
|
+
const embedded = render(<AuthCard fullScreen={false}>x</AuthCard>);
|
|
31
|
+
expect((embedded.container.firstElementChild as HTMLElement).className).not.toContain('bg-background');
|
|
32
|
+
});
|
|
33
|
+
|
|
26
34
|
test('omits the header block entirely when no title/subtitle/logo', () => {
|
|
27
35
|
const { container } = render(
|
|
28
36
|
<AuthCard>
|