cursedbelt 5.46.2 → 5.48.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/react/media/DenseGalleryShell.d.ts +8 -1
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +33 -8
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +12 -1
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +30 -2
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/controlBar.d.ts +3 -2
- package/dist/react/media/controlBar.d.ts.map +1 -1
- package/dist/react/media/controlBar.js +16 -10
- package/dist/react/media/controlBar.js.map +1 -1
- package/dist/react/media/denseGallery.css +56 -18
- package/dist/react/media/videoPlayer.css +2 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +8 -4
- package/dist/react/media-gallery/MediaGallery.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/react/media/DenseGalleryShell.spec.tsx +9 -7
- package/src/react/media/DenseGalleryShell.tsx +53 -11
- package/src/react/media/VideoPlayer.tsx +78 -2
- package/src/react/media/controlBar.tsx +17 -10
- package/src/react/media/denseGallery.css +56 -18
- package/src/react/media/videoPlayer.css +2 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +4 -4
- package/src/react/media-gallery/MediaGallery.tsx +12 -0
- 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
|
@@ -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>
|
|
@@ -57,7 +57,10 @@ export function AuthCard({
|
|
|
57
57
|
<div
|
|
58
58
|
className={cn(
|
|
59
59
|
'flex w-full flex-col items-center justify-center gap-6 p-4',
|
|
60
|
-
|
|
60
|
+
// The page colour belongs to the full-screen page only. Embedded, the card sits on its
|
|
61
|
+
// host's surface — painting `bg-background` there drew a slab around it, a box on a box
|
|
62
|
+
// inside the host's panel (forge task 2466).
|
|
63
|
+
aurora ? 'dark cb-aurora cb-auth-aurora' : fullScreen && 'bg-background',
|
|
61
64
|
fullScreen && 'min-h-dvh',
|
|
62
65
|
className,
|
|
63
66
|
)}
|
|
@@ -36,17 +36,13 @@ export function PasswordSetFields({
|
|
|
36
36
|
|
|
37
37
|
return (
|
|
38
38
|
<>
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{showStrength ? (
|
|
47
|
-
<PasswordStrengthMeter value={password ?? ''} minLength={minPasswordLength} />
|
|
48
|
-
) : null}
|
|
49
|
-
</div>
|
|
39
|
+
<AuthPasswordField
|
|
40
|
+
name={passwordName}
|
|
41
|
+
label={passwordLabel}
|
|
42
|
+
autoComplete='new-password'
|
|
43
|
+
autoFocus={autoFocus}
|
|
44
|
+
status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
|
|
45
|
+
/>
|
|
50
46
|
{showConfirm ? (
|
|
51
47
|
<AuthPasswordField name={confirmName} label={confirmLabel} autoComplete='new-password' />
|
|
52
48
|
) : null}
|
|
@@ -40,9 +40,11 @@ export function PasswordStrengthMeter({
|
|
|
40
40
|
if (!value) return null;
|
|
41
41
|
const tone = SEGMENT_TONE[level as Exclude<PasswordStrengthLevel, 'empty'>];
|
|
42
42
|
|
|
43
|
+
// ONE line (forge task 2466): it sits in a field's error slot (`Field status`), which is one
|
|
44
|
+
// line of `text-sm` tall, so the bars and the word share a row rather than stacking.
|
|
43
45
|
return (
|
|
44
|
-
<div className={cn('flex
|
|
45
|
-
<div className='flex gap-1' aria-hidden>
|
|
46
|
+
<div className={cn('flex min-h-[1lh] items-center gap-3 text-sm', className)}>
|
|
47
|
+
<div className='flex flex-1 gap-1' aria-hidden>
|
|
46
48
|
{[0, 1, 2, 3].map((i) => (
|
|
47
49
|
<span
|
|
48
50
|
key={i}
|
|
@@ -54,7 +56,7 @@ export function PasswordStrengthMeter({
|
|
|
54
56
|
))}
|
|
55
57
|
</div>
|
|
56
58
|
<p
|
|
57
|
-
className='text-xs text-muted-foreground'
|
|
59
|
+
className='m-0 shrink-0 text-xs text-muted-foreground'
|
|
58
60
|
role='status'
|
|
59
61
|
aria-label={`Password strength: ${LABEL[level]}`}
|
|
60
62
|
>
|
|
@@ -82,12 +82,12 @@ export function RegisterForm({
|
|
|
82
82
|
>
|
|
83
83
|
{children}
|
|
84
84
|
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
<AuthPasswordField
|
|
86
|
+
name='password'
|
|
87
|
+
label='Password'
|
|
88
|
+
autoComplete='new-password'
|
|
89
|
+
status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
|
|
90
|
+
/>
|
|
91
91
|
|
|
92
92
|
<AuthPasswordField
|
|
93
93
|
name='confirmPassword'
|
|
@@ -52,6 +52,23 @@ describe('SignupForm', () => {
|
|
|
52
52
|
expect(queryByText(/Password strength/)).not.toBeNull();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test('a password validates on BLUR, like every other field — not only on submit (task 2466)', async () => {
|
|
56
|
+
const { container, findByText } = render(<SignupForm onSubmit={async () => {}} />);
|
|
57
|
+
const password = container.querySelector('#cb-f-password') as HTMLInputElement;
|
|
58
|
+
fireEvent.change(password, { target: { value: 'short' } });
|
|
59
|
+
fireEvent.blur(password);
|
|
60
|
+
await findByText('Use at least 8 characters');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the terms error is a field error like every other — icon, and wired to the checkbox (task 2466)", async () => {
|
|
64
|
+
const { getByRole, findByText } = render(<SignupForm onSubmit={async () => {}} terms='I agree to the terms' />);
|
|
65
|
+
fireEvent.click(getByRole('button', { name: 'Create account' }));
|
|
66
|
+
const error = (await findByText('Please accept to continue')).closest('p') as HTMLElement;
|
|
67
|
+
expect(error.hasAttribute('data-cb-field-error')).toBe(true);
|
|
68
|
+
expect(error.querySelector('svg')).not.toBeNull();
|
|
69
|
+
expect(getByRole('checkbox').getAttribute('aria-describedby')).toBe(error.id);
|
|
70
|
+
});
|
|
71
|
+
|
|
55
72
|
test('requires the terms checkbox when terms are given', async () => {
|
|
56
73
|
const onSubmit = mock(async () => {});
|
|
57
74
|
const { container, getByRole, findByText } = render(
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { type ReactNode, useMemo } from 'react';
|
|
1
|
+
import { type ReactNode, useId, useMemo } from 'react';
|
|
2
2
|
import { Controller, useForm } from 'react-hook-form';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { makeFormConfig } from '../../lib/form.js';
|
|
5
5
|
import { Checkbox } from '../Checkbox.js';
|
|
6
|
+
import { FieldErrorText } from '../fieldErrorText.js';
|
|
6
7
|
import { AuthForm } from './AuthForm.js';
|
|
7
8
|
import { AuthPasswordField, AuthTextField } from './fields.js';
|
|
8
9
|
import { PasswordStrengthMeter } from './PasswordStrengthMeter.js';
|
|
@@ -93,6 +94,7 @@ export function SignupForm({
|
|
|
93
94
|
);
|
|
94
95
|
|
|
95
96
|
const password = form.watch('password');
|
|
97
|
+
const termsErrorId = useId();
|
|
96
98
|
|
|
97
99
|
return (
|
|
98
100
|
<AuthForm
|
|
@@ -115,12 +117,12 @@ export function SignupForm({
|
|
|
115
117
|
|
|
116
118
|
{children}
|
|
117
119
|
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
<AuthPasswordField
|
|
121
|
+
name='password'
|
|
122
|
+
label='Password'
|
|
123
|
+
autoComplete='new-password'
|
|
124
|
+
status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
|
|
125
|
+
/>
|
|
124
126
|
|
|
125
127
|
{showConfirm ? (
|
|
126
128
|
<AuthPasswordField
|
|
@@ -140,12 +142,11 @@ export function SignupForm({
|
|
|
140
142
|
checked={field.value}
|
|
141
143
|
onCheckedChange={(v) => field.onChange(v === true)}
|
|
142
144
|
aria-invalid={form.formState.errors.acceptTerms ? true : undefined}
|
|
145
|
+
aria-describedby={form.formState.errors.acceptTerms ? termsErrorId : undefined}
|
|
143
146
|
label={terms}
|
|
144
147
|
/>
|
|
145
148
|
{form.formState.errors.acceptTerms ? (
|
|
146
|
-
<
|
|
147
|
-
{form.formState.errors.acceptTerms.message as string}
|
|
148
|
-
</p>
|
|
149
|
+
<FieldErrorText id={termsErrorId}>{form.formState.errors.acceptTerms.message as string}</FieldErrorText>
|
|
149
150
|
) : null}
|
|
150
151
|
</div>
|
|
151
152
|
)}
|
|
@@ -32,6 +32,33 @@ describe('TotpEntry', () => {
|
|
|
32
32
|
expect(onSubmit).toHaveBeenCalledWith({ code: 'ABCDE-FGHIJ', recovery: true });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
test('a refused code is cleared and the caret goes back to the first box (task 2466)', async () => {
|
|
36
|
+
const onSubmit = mock(async () => {
|
|
37
|
+
throw { message: 'That code has expired.' };
|
|
38
|
+
});
|
|
39
|
+
const { container, getByRole, findByText } = render(<TotpEntry onSubmit={onSubmit} autoSubmit={false} />);
|
|
40
|
+
const boxes = Array.from(container.querySelectorAll('input')) as HTMLInputElement[];
|
|
41
|
+
for (let i = 0; i < 6; i++) fireEvent.change(boxes[i], { target: { value: String(i + 1) } });
|
|
42
|
+
fireEvent.click(getByRole('button', { name: 'Verify' }));
|
|
43
|
+
await findByText('That code has expired.');
|
|
44
|
+
const after = Array.from(container.querySelectorAll('input')) as HTMLInputElement[];
|
|
45
|
+
await waitFor(() => expect(after.map((b) => b.value).join('')).toBe(''));
|
|
46
|
+
expect(document.activeElement).toBe(after[0]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a refused RECOVERY code stays, for the user to correct', async () => {
|
|
50
|
+
const onSubmit = mock(async () => {
|
|
51
|
+
throw { message: 'That recovery code was already used.' };
|
|
52
|
+
});
|
|
53
|
+
const { container, getByRole, findByText } = render(<TotpEntry onSubmit={onSubmit} autoSubmit={false} />);
|
|
54
|
+
fireEvent.click(getByRole('button', { name: 'Use a recovery code instead' }));
|
|
55
|
+
const input = container.querySelector('#cb-f-code') as HTMLInputElement;
|
|
56
|
+
fireEvent.change(input, { target: { value: 'ABCDE-FGHIJ' } });
|
|
57
|
+
fireEvent.click(getByRole('button', { name: 'Verify' }));
|
|
58
|
+
await findByText('That recovery code was already used.');
|
|
59
|
+
expect((container.querySelector('#cb-f-code') as HTMLInputElement).value).toBe('ABCDE-FGHIJ');
|
|
60
|
+
});
|
|
61
|
+
|
|
35
62
|
test('omits the recovery toggle when allowRecoveryCode is false', () => {
|
|
36
63
|
const { queryByRole } = render(
|
|
37
64
|
<TotpEntry onSubmit={async () => {}} allowRecoveryCode={false} autoSubmit={false} />,
|
|
@@ -72,11 +72,26 @@ export function TotpEntry({
|
|
|
72
72
|
form.clearErrors();
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
// A refused authenticator code is dead — it rotates in seconds — so clear it and put the caret
|
|
76
|
+
// back on the first box, the way GitHub and Stripe do (forge task 2466). A recovery code is
|
|
77
|
+
// long and typed by hand, so it stays for the user to correct.
|
|
78
|
+
const submit: AuthSubmitHandler<TotpValues> = async (values) => {
|
|
79
|
+
try {
|
|
80
|
+
return await onSubmit(values);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (!values.recovery) {
|
|
83
|
+
form.setValue('code', '');
|
|
84
|
+
formRef.current?.querySelector<HTMLInputElement>('input')?.focus();
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
75
90
|
return (
|
|
76
91
|
<AuthForm
|
|
77
92
|
form={form}
|
|
78
93
|
formRef={formRef}
|
|
79
|
-
onSubmit={
|
|
94
|
+
onSubmit={submit}
|
|
80
95
|
onSuccess={onSuccess}
|
|
81
96
|
mapError={mapError}
|
|
82
97
|
submitLabel={submitLabel}
|
|
@@ -128,6 +143,8 @@ export function TotpEntry({
|
|
|
128
143
|
value={field.value}
|
|
129
144
|
onChange={field.onChange}
|
|
130
145
|
length={length}
|
|
146
|
+
// Spread across the column, so the boxes end where every other row does.
|
|
147
|
+
className='w-full justify-between'
|
|
131
148
|
onComplete={autoSubmit ? () => formRef.current?.requestSubmit() : undefined}
|
|
132
149
|
aria-label='Authentication code'
|
|
133
150
|
aria-invalid={aria['aria-invalid']}
|
|
@@ -68,6 +68,8 @@ export interface AuthPasswordFieldProps {
|
|
|
68
68
|
description?: ReactNode;
|
|
69
69
|
required?: boolean;
|
|
70
70
|
autoFocus?: boolean;
|
|
71
|
+
/** Live feedback under the field — the strength meter — in its error slot ({@link Field}'s `status`). */
|
|
72
|
+
status?: ReactNode;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/**
|
|
@@ -83,10 +85,11 @@ export function AuthPasswordField({
|
|
|
83
85
|
description,
|
|
84
86
|
required,
|
|
85
87
|
autoFocus,
|
|
88
|
+
status,
|
|
86
89
|
}: AuthPasswordFieldProps) {
|
|
87
90
|
const { control } = useFormContext();
|
|
88
91
|
return (
|
|
89
|
-
<Field name={name} label={label} description={description} required={required} reserveErrorSpace>
|
|
92
|
+
<Field name={name} label={label} description={description} required={required} reserveErrorSpace status={status}>
|
|
90
93
|
{(aria) => (
|
|
91
94
|
<Controller
|
|
92
95
|
name={name}
|
|
@@ -97,6 +100,10 @@ export function AuthPasswordField({
|
|
|
97
100
|
name={field.name}
|
|
98
101
|
value={field.value ?? ''}
|
|
99
102
|
onChange={field.onChange}
|
|
103
|
+
// 🔴 Without this a password never validated on blur, only on submit — the forms
|
|
104
|
+
// are `mode: 'onBlur'`, and RHF only learns of a blur through `field.onBlur`
|
|
105
|
+
// (forge task 2466: "Use at least 8 characters" never showed until submit).
|
|
106
|
+
onBlur={field.onBlur}
|
|
100
107
|
label={labelText(label, name)}
|
|
101
108
|
placeholder={placeholder}
|
|
102
109
|
autoComplete={autoComplete}
|
|
@@ -24,6 +24,15 @@ describe('DayAgenda', () => {
|
|
|
24
24
|
expect(screen.getByText('Nothing planned')).toBeTruthy();
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
test('an empty day points at Add only when there is an Add to press', () => {
|
|
28
|
+
const { unmount } = render(<DayAgenda dayDate={DAY} events={[]} onSelectOccurrence={() => {}} />);
|
|
29
|
+
expect(screen.getByText('No events on this day.')).toBeTruthy();
|
|
30
|
+
expect(screen.queryByText(/Add/)).toBeNull();
|
|
31
|
+
unmount();
|
|
32
|
+
render(<DayAgenda dayDate={DAY} events={[]} onSelectOccurrence={() => {}} onAdd={() => {}} />);
|
|
33
|
+
expect(screen.getByText('Use Add to create an event on this day.')).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
27
36
|
test('renders an event title', () => {
|
|
28
37
|
const events = [makeEvent({ id: 'e1', title: 'Morning run' })];
|
|
29
38
|
render(<DayAgenda dayDate={DAY} events={events} onSelectOccurrence={() => {}} />);
|
|
@@ -89,7 +89,8 @@ export function DayAgenda({
|
|
|
89
89
|
<EmptyState
|
|
90
90
|
icon={<CalendarIcon className='h-6 w-6' />}
|
|
91
91
|
title='Nothing planned'
|
|
92
|
-
|
|
92
|
+
// Only point at a button that is there: a read-only agenda has no Add (forge task 2591).
|
|
93
|
+
description={onAdd ? 'Use Add to create an event on this day.' : 'No events on this day.'}
|
|
93
94
|
/>
|
|
94
95
|
) : (
|
|
95
96
|
<ul className='flex flex-col gap-1'>
|
|
@@ -140,7 +141,9 @@ function AgendaItem({ occurrence, color, calendarName, onSelect }: AgendaItemPro
|
|
|
140
141
|
style={dotStyle}
|
|
141
142
|
aria-hidden='true'
|
|
142
143
|
/>
|
|
143
|
-
|
|
144
|
+
{/* Wide enough for "12:00 PM" on one line — at w-16 it wrapped and pushed its title
|
|
145
|
+
out of line with every other row's (forge task 2591). */}
|
|
146
|
+
<span className='w-20 shrink-0 whitespace-nowrap font-semibold tabular-nums text-muted-foreground'>
|
|
144
147
|
{occurrence.allDay ? 'All day' : fmtTime(occurrence.startAt)}
|
|
145
148
|
</span>
|
|
146
149
|
<span
|