cursedbelt 5.94.0 → 5.95.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/components/AppShell.d.ts +12 -1
- package/dist/react/components/AppShell.d.ts.map +1 -1
- package/dist/react/components/AppShell.js +24 -5
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/ComboboxField.js +2 -2
- package/dist/react/components/ComboboxField.js.map +1 -1
- package/dist/react/components/DatePicker.d.ts +9 -5
- package/dist/react/components/DatePicker.d.ts.map +1 -1
- package/dist/react/components/DatePicker.js +192 -27
- package/dist/react/components/DatePicker.js.map +1 -1
- package/dist/react/components/InlineEditableText.d.ts +12 -2
- package/dist/react/components/InlineEditableText.d.ts.map +1 -1
- package/dist/react/components/InlineEditableText.js +11 -8
- package/dist/react/components/InlineEditableText.js.map +1 -1
- package/dist/react/components/suggestionList.js +2 -2
- package/dist/react/components/suggestionList.js.map +1 -1
- package/dist/react/lib/dateText.d.ts +21 -0
- package/dist/react/lib/dateText.d.ts.map +1 -0
- package/dist/react/lib/dateText.js +94 -0
- package/dist/react/lib/dateText.js.map +1 -0
- package/dist/react/lib/floatingStyles.d.ts +12 -0
- package/dist/react/lib/floatingStyles.d.ts.map +1 -1
- package/dist/react/lib/floatingStyles.js +12 -0
- package/dist/react/lib/floatingStyles.js.map +1 -1
- package/dist/styles-areas/analytics.css +11 -10
- package/dist/styles-areas/data-table.css +13 -12
- package/dist/styles-areas/fields.css +12 -10
- package/dist/styles-areas/stats.css +12 -11
- package/dist/styles-areas/wizard.css +6 -6
- package/dist/styles-utilities.css +15 -0
- package/package.json +1 -1
- package/src/react/components/AppShell.spec.tsx +53 -0
- package/src/react/components/AppShell.tsx +79 -23
- package/src/react/components/ComboboxField.tsx +2 -2
- package/src/react/components/ControlHeightScale.spec.tsx +2 -2
- package/src/react/components/DatePicker.spec.tsx +186 -5
- package/src/react/components/DatePicker.tsx +416 -63
- package/src/react/components/InlineEditableText.spec.tsx +29 -0
- package/src/react/components/InlineEditableText.tsx +26 -10
- package/src/react/components/suggestionList.tsx +2 -2
- package/src/react/lib/dateText.ts +94 -0
- package/src/react/lib/floatingStyles.spec.ts +36 -1
- package/src/react/lib/floatingStyles.ts +13 -0
- package/src/styles-areas/analytics.css +11 -10
- package/src/styles-areas/data-table.css +13 -12
- package/src/styles-areas/fields.css +12 -10
- package/src/styles-areas/stats.css +12 -11
- package/src/styles-areas/wizard.css +6 -6
- package/src/styles-utilities.css +15 -0
|
@@ -18,8 +18,10 @@ import { SideNav } from '../nav/SideNav.js';
|
|
|
18
18
|
import { TopBarPlaces } from '../nav/TopBar.js';
|
|
19
19
|
import { CommandSourceProvider, useCommandSources } from '../palette/commandSources.js';
|
|
20
20
|
import { useCommandPalette } from '../palette/useCommandPalette.js';
|
|
21
|
+
import { IconSidebar } from '../icons.js';
|
|
21
22
|
import { AmbientBackdrop, type AmbientBackdropProps } from './AmbientBackdrop.js';
|
|
22
23
|
import { BannerLane, type ShellBanner } from './BannerLane.js';
|
|
24
|
+
import { IconButton } from './IconButton.js';
|
|
23
25
|
import { lazyPart } from './DataTable/lazyPart.js';
|
|
24
26
|
import { ScrollArea, type ScrollAreaSpace } from './ScrollArea.js';
|
|
25
27
|
|
|
@@ -79,6 +81,18 @@ export interface AppShellProps {
|
|
|
79
81
|
* the section's NEXT state. */
|
|
80
82
|
onNavSectionToggle?: (section: string, collapsed: boolean) => void;
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The 240px sidebar folded to the 64px icon rail at every width — the reader's choice, to see
|
|
86
|
+
* the page wider (a photo archive's grid, a canvas): Linear's `⌘\` and Notion's `«` fold theirs
|
|
87
|
+
* the same way. Controlled: the app remembers it (a setting, `localStorage`) and passes it back.
|
|
88
|
+
* With `onNavCollapsedChange` the rail head carries the fold button (`IconSidebar`, a tooltip
|
|
89
|
+
* either way); without it the sidebar is fixed open, as before. The rail keeps its places, the
|
|
90
|
+
* account and the ⌘K trigger folded (NAV-2, NAV-15); a phone is never folded — it has no sidebar.
|
|
91
|
+
*/
|
|
92
|
+
navCollapsed?: boolean;
|
|
93
|
+
/** The fold button was pressed; `collapsed` is the sidebar's NEXT state. */
|
|
94
|
+
onNavCollapsedChange?: (collapsed: boolean) => void;
|
|
95
|
+
|
|
82
96
|
/** Dynamic per-place badge (unread counts), keyed by domain id. A badge may carry its own tone (`{ value, tone }`) — see {@link NavBadge}. */
|
|
83
97
|
navBadges?: Record<string, NavBadge>;
|
|
84
98
|
/** How those counts READ — `alert` (default, red) for "this needs you", `neutral` or
|
|
@@ -233,7 +247,7 @@ const UNSECTIONED_MAX_PLACES = 7;
|
|
|
233
247
|
* <header h-[--header-h]> ← left: brand · center: places or ⌘K field · right: account
|
|
234
248
|
* <BannerLane /> ← NAV-10, only when there is a banner
|
|
235
249
|
* <div flex min-h-0 grow> ← sidebar + content row
|
|
236
|
-
* <aside md:flex lg:hidden w-16 /> ← 6+ places: the icon rail
|
|
250
|
+
* <aside md:flex lg:hidden w-16 /> ← 6+ places: the icon rail (and at lg, folded by `navCollapsed`)
|
|
237
251
|
* <aside lg:flex w-60 /> ← 6+ places: the sectioned SideNav
|
|
238
252
|
* <main><ScrollArea>{children}</ScrollArea></main> ← THE ONLY page scroll container
|
|
239
253
|
* </div>
|
|
@@ -266,6 +280,8 @@ function Shell({
|
|
|
266
280
|
onNavPrefsChange,
|
|
267
281
|
navCollapsedSections,
|
|
268
282
|
onNavSectionToggle,
|
|
283
|
+
navCollapsed = false,
|
|
284
|
+
onNavCollapsedChange,
|
|
269
285
|
navBadges,
|
|
270
286
|
navBadgeTone,
|
|
271
287
|
navHeader,
|
|
@@ -354,8 +370,31 @@ function Shell({
|
|
|
354
370
|
* rail (md–lg) gets the account and the square ⌘K trigger; `onCapture` stays on the full sidebar
|
|
355
371
|
* and the phone's PlaceBar, as a worded primary button cannot fit a 64px rail.
|
|
356
372
|
*/
|
|
357
|
-
|
|
358
|
-
|
|
373
|
+
// The fold button (`navCollapsed`): only where the app takes the choice back — a shell that cannot
|
|
374
|
+
// remember it does not offer it. On the icon rail it stacks under the account. On the full sidebar it
|
|
375
|
+
// sits at the leading edge of the ⌘K row, NOT on the brand line: the account launcher there is a
|
|
376
|
+
// worded control (cursedauth's "Curt Phillips ▾", 175px in a 240px rail), and a third control on
|
|
377
|
+
// that line left the brand no width at all (photos, WebKit, 2026-10-25). A shell with no ⌘K row
|
|
378
|
+
// keeps it on the brand line, where there is room.
|
|
379
|
+
const foldButton = (collapsed: boolean) =>
|
|
380
|
+
onNavCollapsedChange ? (
|
|
381
|
+
<IconButton
|
|
382
|
+
data-cb-rail-fold=''
|
|
383
|
+
aria-pressed={collapsed}
|
|
384
|
+
label={collapsed ? 'Show the sidebar' : 'Hide the sidebar'}
|
|
385
|
+
tooltip={collapsed ? 'Show the sidebar with its names' : 'Fold the sidebar to icons, for a wider page'}
|
|
386
|
+
size='sm'
|
|
387
|
+
variant='ghost'
|
|
388
|
+
className='mt-0.5'
|
|
389
|
+
onClick={() => onNavCollapsedChange(!collapsed)}
|
|
390
|
+
>
|
|
391
|
+
<IconSidebar />
|
|
392
|
+
</IconButton>
|
|
393
|
+
) : null;
|
|
394
|
+
const hasCommandRow = (collapsed: boolean) => Boolean(openCommand || (!collapsed && (onCapture || commandBarExtra)));
|
|
395
|
+
const railCommand = (collapsed: boolean) => {
|
|
396
|
+
if (!hasCommandRow(collapsed)) return null;
|
|
397
|
+
const bar = (
|
|
359
398
|
<CommandBar
|
|
360
399
|
className={collapsed ? 'flex-col' : 'w-full min-w-0 flex-wrap'}
|
|
361
400
|
compact={collapsed}
|
|
@@ -366,22 +405,36 @@ function Shell({
|
|
|
366
405
|
commandHint={commandHint}
|
|
367
406
|
extra={collapsed ? undefined : commandBarExtra}
|
|
368
407
|
/>
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
408
|
+
);
|
|
409
|
+
const fold = collapsed ? null : foldButton(false);
|
|
410
|
+
return fold ? (
|
|
411
|
+
<div data-cb-rail-command-row='' className='flex w-full min-w-0 items-start gap-1'>
|
|
412
|
+
{fold}
|
|
413
|
+
<div className='min-w-0 flex-1'>{bar}</div>
|
|
414
|
+
</div>
|
|
415
|
+
) : (
|
|
416
|
+
bar
|
|
417
|
+
);
|
|
418
|
+
};
|
|
419
|
+
const railHead = (collapsed: boolean) => {
|
|
420
|
+
const foldOnBrandLine = collapsed || !hasCommandRow(collapsed);
|
|
421
|
+
return (
|
|
422
|
+
<div data-cb-rail-head='' className={cn('flex flex-col gap-3', collapsed && 'items-center')}>
|
|
423
|
+
{(navHeader && !collapsed) || account || (foldOnBrandLine && onNavCollapsedChange) ? (
|
|
424
|
+
<div className={cn('flex min-w-0 items-center gap-2', collapsed && 'flex-col')}>
|
|
425
|
+
{navHeader && !collapsed ? <div className='flex min-w-0 flex-1 items-center overflow-hidden'>{navHeader}</div> : null}
|
|
426
|
+
{account ? (
|
|
427
|
+
<div data-cb-account='' className='shrink-0'>
|
|
428
|
+
{account}
|
|
429
|
+
</div>
|
|
430
|
+
) : null}
|
|
431
|
+
{foldOnBrandLine ? foldButton(collapsed) : null}
|
|
432
|
+
</div>
|
|
433
|
+
) : null}
|
|
434
|
+
{railCommand(collapsed)}
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
};
|
|
385
438
|
|
|
386
439
|
const sideNavProps = {
|
|
387
440
|
model: nav as NavigationModel,
|
|
@@ -483,15 +536,17 @@ function Shell({
|
|
|
483
536
|
<div className='relative flex min-h-0 grow overflow-hidden'>
|
|
484
537
|
{/* 6+ places: the labeled, sectioned sidebar at lg (240px), the icon rail at md–lg
|
|
485
538
|
(64px — a 240px rail on an ~820px screen squeezes the workspace, owner tablet
|
|
486
|
-
teardown 2026-07-15 §4)
|
|
487
|
-
prop, not a media query, and the
|
|
539
|
+
teardown 2026-07-15 §4) — and at lg too while the reader has folded it (`navCollapsed`).
|
|
540
|
+
Two CSS-switched renders: SideNav's `collapsed` is a prop, not a media query, and the
|
|
541
|
+
rail is cheap to render twice. */}
|
|
488
542
|
{pattern === 'side' && (
|
|
489
543
|
<>
|
|
490
544
|
<aside
|
|
491
545
|
data-cb-nav-rail=''
|
|
492
546
|
data-cb-nav-collapsed=''
|
|
493
547
|
className={cn(
|
|
494
|
-
'hidden w-[var(--rail-w-collapsed)] border-r border-border md:flex md:shrink-0 md:flex-col
|
|
548
|
+
'hidden w-[var(--rail-w-collapsed)] border-r border-border md:flex md:shrink-0 md:flex-col',
|
|
549
|
+
!navCollapsed && 'lg:hidden',
|
|
495
550
|
chrome,
|
|
496
551
|
)}
|
|
497
552
|
>
|
|
@@ -500,7 +555,8 @@ function Shell({
|
|
|
500
555
|
<aside
|
|
501
556
|
data-cb-nav-rail=''
|
|
502
557
|
className={cn(
|
|
503
|
-
'hidden w-[var(--rail-w)] border-r border-border
|
|
558
|
+
'hidden w-[var(--rail-w)] border-r border-border',
|
|
559
|
+
!navCollapsed && 'lg:flex lg:shrink-0 lg:flex-col',
|
|
504
560
|
chrome,
|
|
505
561
|
)}
|
|
506
562
|
>
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
choiceFieldWidth,
|
|
12
12
|
declareFieldWidth,
|
|
13
13
|
} from '../lib/field.js';
|
|
14
|
-
import { FLOATING_COLLISION_PADDING,
|
|
14
|
+
import { BASE_UI_POSITIONER, FLOATING_COLLISION_PADDING,
|
|
15
15
|
FLOATING_CONTENT_BASE, FLOATING_ITEM_BASE } from '../lib/floatingStyles.js';
|
|
16
16
|
import { TAP_TARGET, TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
|
|
17
17
|
|
|
@@ -336,7 +336,7 @@ export function ComboboxField(props: ComboboxFieldProps) {
|
|
|
336
336
|
<Combobox.Positioner
|
|
337
337
|
sideOffset={4}
|
|
338
338
|
collisionPadding={FLOATING_COLLISION_PADDING}
|
|
339
|
-
className=
|
|
339
|
+
className={BASE_UI_POSITIONER}
|
|
340
340
|
>
|
|
341
341
|
<Combobox.Popup
|
|
342
342
|
className={cn(
|
|
@@ -72,9 +72,9 @@ describe('control-height-tokens: cross-component parity', () => {
|
|
|
72
72
|
expect(getByRole('button').className).toContain(TOKEN.lg);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
test('DatePicker
|
|
75
|
+
test('DatePicker field honors `size`', () => {
|
|
76
76
|
const { getByRole } = render(<DatePicker size='sm' placeholder='Pick' onChange={() => {}} />);
|
|
77
|
-
expect(getByRole('
|
|
77
|
+
expect(getByRole('textbox').className).toContain(TOKEN.sm);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
test('ColorField container honors `size`', () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
-
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
3
|
import { DatePicker } from './DatePicker.js';
|
|
4
4
|
import { DateRangePicker } from './DateRangePicker.js';
|
|
5
5
|
import { DateTimeField } from './DateTimeField.js';
|
|
@@ -54,13 +54,194 @@ describe('DatePicker', () => {
|
|
|
54
54
|
expect(btn).toHaveProperty('disabled', true);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test('aria-invalid and aria-describedby are forwarded to the
|
|
57
|
+
test('aria-invalid and aria-describedby are forwarded to the text field', () => {
|
|
58
58
|
const { getByRole } = render(
|
|
59
59
|
<DatePicker aria-invalid={true} aria-describedby='err' onChange={() => {}} />,
|
|
60
60
|
);
|
|
61
|
-
const
|
|
62
|
-
expect(
|
|
63
|
-
expect(
|
|
61
|
+
const field = getByRole('textbox');
|
|
62
|
+
expect(field.getAttribute('aria-invalid')).toBe('true');
|
|
63
|
+
expect(field.getAttribute('aria-describedby')).toBe('err');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** Type `text` into the field and leave it (blur commits). */
|
|
68
|
+
function typeAndBlur(field: HTMLElement, text: string) {
|
|
69
|
+
fireEvent.change(field, { target: { value: text } });
|
|
70
|
+
fireEvent.blur(field);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('DatePicker typing', () => {
|
|
74
|
+
const forms: Array<[string, string]> = [
|
|
75
|
+
['2015-12-05', '2015-12-05'],
|
|
76
|
+
['2015-12-5', '2015-12-05'],
|
|
77
|
+
['12/5/2015', '2015-12-05'],
|
|
78
|
+
['12/05/2015', '2015-12-05'],
|
|
79
|
+
['12-5-2015', '2015-12-05'],
|
|
80
|
+
['12.5.2015', '2015-12-05'],
|
|
81
|
+
['12/05/15', '2015-12-05'],
|
|
82
|
+
['12/05/99', '1999-12-05'],
|
|
83
|
+
['Dec 5 2015', '2015-12-05'],
|
|
84
|
+
['Dec 5, 2015', '2015-12-05'],
|
|
85
|
+
['December 5, 2015', '2015-12-05'],
|
|
86
|
+
['5 Dec 2015', '2015-12-05'],
|
|
87
|
+
['Dec 5th, 2015', '2015-12-05'],
|
|
88
|
+
['2015 Dec 5', '2015-12-05'],
|
|
89
|
+
[' dec 5 2015 ', '2015-12-05'],
|
|
90
|
+
['2/29/2024', '2024-02-29'],
|
|
91
|
+
];
|
|
92
|
+
for (const [typed, iso] of forms) {
|
|
93
|
+
test(`"${typed}" normalizes to ${iso}`, () => {
|
|
94
|
+
const onChange = mock((_v: string | undefined) => {});
|
|
95
|
+
const { getByRole } = render(<DatePicker onChange={onChange} />);
|
|
96
|
+
typeAndBlur(getByRole('textbox'), typed);
|
|
97
|
+
expect(onChange.mock.calls).toEqual([[iso]]);
|
|
98
|
+
expect(getByRole('textbox').getAttribute('aria-invalid')).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test('Enter commits like blur', () => {
|
|
103
|
+
const onChange = mock((_v: string | undefined) => {});
|
|
104
|
+
const { getByRole } = render(<DatePicker onChange={onChange} />);
|
|
105
|
+
const field = getByRole('textbox');
|
|
106
|
+
fireEvent.change(field, { target: { value: '12/5/2015' } });
|
|
107
|
+
fireEvent.keyDown(field, { key: 'Enter' });
|
|
108
|
+
expect(onChange.mock.calls).toEqual([['2015-12-05']]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a committed value shows in the display format', () => {
|
|
112
|
+
const { getByRole, rerender } = render(<DatePicker value='2015-12-05' onChange={() => {}} />);
|
|
113
|
+
expect((getByRole('textbox') as HTMLInputElement).value).toContain('2015');
|
|
114
|
+
rerender(<DatePicker value='2024-03-15' onChange={() => {}} />);
|
|
115
|
+
expect((getByRole('textbox') as HTMLInputElement).value).toContain('2024');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const garbage = ['hello', '13/45/2015', '2/30/2015', '12/5', 'Dec 2015', '12/5/215', 'Foo 5 2015', '0000-00-00'];
|
|
119
|
+
for (const text of garbage) {
|
|
120
|
+
test(`garbage "${text}" never reaches onChange and shows a reason`, () => {
|
|
121
|
+
const onChange = mock((_v: string | undefined) => {});
|
|
122
|
+
const { getByRole } = render(<DatePicker value='2020-01-01' onChange={onChange} />);
|
|
123
|
+
const field = getByRole('textbox');
|
|
124
|
+
typeAndBlur(field, text);
|
|
125
|
+
expect(onChange.mock.calls).toEqual([]);
|
|
126
|
+
expect(field.getAttribute('aria-invalid')).toBe('true');
|
|
127
|
+
expect(getByRole('alert').textContent?.length).toBeGreaterThan(0);
|
|
128
|
+
expect(field.getAttribute('aria-describedby')).toBe(getByRole('alert').id);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test('Escape on invalid text restores the last good value', () => {
|
|
133
|
+
const onChange = mock((_v: string | undefined) => {});
|
|
134
|
+
const { getByRole, queryByRole } = render(<DatePicker value='2020-01-01' onChange={onChange} />);
|
|
135
|
+
const field = getByRole('textbox') as HTMLInputElement;
|
|
136
|
+
const before = field.value;
|
|
137
|
+
typeAndBlur(field, 'nonsense');
|
|
138
|
+
fireEvent.keyDown(field, { key: 'Escape' });
|
|
139
|
+
expect(field.value).toBe(before);
|
|
140
|
+
expect(field.getAttribute('aria-invalid')).toBeNull();
|
|
141
|
+
expect(queryByRole('alert')).toBeNull();
|
|
142
|
+
expect(onChange.mock.calls).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a date before min or after max is refused, and says which bound', () => {
|
|
146
|
+
const onChange = mock((_v: string | undefined) => {});
|
|
147
|
+
const { getByRole } = render(
|
|
148
|
+
<DatePicker min='2010-01-01' max='2020-12-31' onChange={onChange} />,
|
|
149
|
+
);
|
|
150
|
+
const field = getByRole('textbox');
|
|
151
|
+
typeAndBlur(field, '12/31/2009');
|
|
152
|
+
expect(getByRole('alert').textContent).toContain('on or after');
|
|
153
|
+
typeAndBlur(field, '1/1/2021');
|
|
154
|
+
expect(getByRole('alert').textContent).toContain('on or before');
|
|
155
|
+
expect(onChange.mock.calls).toEqual([]);
|
|
156
|
+
typeAndBlur(field, '1/1/2020');
|
|
157
|
+
expect(onChange.mock.calls).toEqual([['2020-01-01']]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('clearing the text clears the value', () => {
|
|
161
|
+
const onChange = mock((_v: string | undefined) => {});
|
|
162
|
+
const { getByRole } = render(<DatePicker value='2020-01-01' onChange={onChange} />);
|
|
163
|
+
typeAndBlur(getByRole('textbox'), '');
|
|
164
|
+
expect(onChange.mock.calls).toEqual([[undefined]]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the calendar opens on a typed date', () => {
|
|
168
|
+
const { getByRole, container } = render(<DatePicker value='2024-03-01' onChange={() => {}} />);
|
|
169
|
+
typeAndBlur(getByRole('textbox'), 'Jul 4 1985');
|
|
170
|
+
fireEvent.click(container.querySelector('button[aria-haspopup="dialog"]') as HTMLElement);
|
|
171
|
+
expect(screen.getByRole('button', { name: /July 1985, choose month and year/ })).not.toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('DatePicker calendar navigation', () => {
|
|
176
|
+
function open(value = '2024-03-15', props: Partial<React.ComponentProps<typeof DatePicker>> = {}) {
|
|
177
|
+
const utils = render(<DatePicker value={value} onChange={() => {}} {...props} />);
|
|
178
|
+
fireEvent.click(utils.container.querySelector('button[aria-haspopup="dialog"]') as HTMLElement);
|
|
179
|
+
return utils;
|
|
180
|
+
}
|
|
181
|
+
const caption = (re: RegExp) => screen.getByRole('button', { name: re });
|
|
182
|
+
|
|
183
|
+
test('Next/Previous year buttons move a year', () => {
|
|
184
|
+
open();
|
|
185
|
+
fireEvent.click(screen.getByRole('button', { name: 'Previous year' }));
|
|
186
|
+
expect(caption(/March 2023/)).not.toBeNull();
|
|
187
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next year' }));
|
|
188
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next year' }));
|
|
189
|
+
expect(caption(/March 2025/)).not.toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('month buttons still move a month', () => {
|
|
193
|
+
open();
|
|
194
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
|
|
195
|
+
expect(caption(/April 2024/)).not.toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a year step clamps to the min month, and is disabled once there', () => {
|
|
199
|
+
open('2024-03-15', { min: '2024-01-10', max: '2024-06-20' });
|
|
200
|
+
fireEvent.click(screen.getByRole('button', { name: 'Previous year' }));
|
|
201
|
+
expect(caption(/January 2024/)).not.toBeNull();
|
|
202
|
+
expect((screen.getByRole('button', { name: 'Previous year' }) as HTMLButtonElement).disabled).toBe(true);
|
|
203
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next year' }));
|
|
204
|
+
expect(caption(/June 2024/)).not.toBeNull();
|
|
205
|
+
expect((screen.getByRole('button', { name: 'Next year' }) as HTMLButtonElement).disabled).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('year buttons are disabled when min and max pin the month', () => {
|
|
209
|
+
open('2024-03-15', { min: '2024-03-01', max: '2024-03-31' });
|
|
210
|
+
expect((screen.getByRole('button', { name: 'Previous year' }) as HTMLButtonElement).disabled).toBe(true);
|
|
211
|
+
expect((screen.getByRole('button', { name: 'Next year' }) as HTMLButtonElement).disabled).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('Shift+PageUp moves a year, PageUp a month (and the Shift forms go forward)', () => {
|
|
215
|
+
open();
|
|
216
|
+
const press = (name: RegExp, key: string, shiftKey = false) => {
|
|
217
|
+
const day = screen.getByRole('button', { name });
|
|
218
|
+
act(() => day.focus());
|
|
219
|
+
fireEvent.keyDown(day, { key, shiftKey });
|
|
220
|
+
};
|
|
221
|
+
press(/March 15th, 2024/, 'PageUp', true);
|
|
222
|
+
expect(caption(/March 2023/)).not.toBeNull();
|
|
223
|
+
press(/March 15th, 2023/, 'PageUp');
|
|
224
|
+
expect(caption(/February 2023/)).not.toBeNull();
|
|
225
|
+
press(/February 15th, 2023/, 'PageDown', true);
|
|
226
|
+
expect(caption(/February 2024/)).not.toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('the caption opens a year grid, then months: 2024 to 1985 in two picks', () => {
|
|
230
|
+
open();
|
|
231
|
+
fireEvent.click(caption(/March 2024, choose month and year/));
|
|
232
|
+
fireEvent.click(screen.getByRole('button', { name: '1985' }));
|
|
233
|
+
fireEvent.click(screen.getByRole('button', { name: /July 1985/ }));
|
|
234
|
+
expect(caption(/July 1985/)).not.toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('the month chooser disables months outside min/max', () => {
|
|
238
|
+
open('2024-03-15', { min: '2023-05-01', max: '2024-08-31' });
|
|
239
|
+
fireEvent.click(caption(/March 2024, choose month and year/));
|
|
240
|
+
expect(screen.queryByRole('button', { name: '2022' })).toBeNull();
|
|
241
|
+
fireEvent.click(screen.getByRole('button', { name: '2023' }));
|
|
242
|
+
const month = (re: RegExp) => screen.getByRole('button', { name: re }) as HTMLButtonElement;
|
|
243
|
+
expect(month(/April 2023/).disabled).toBe(true);
|
|
244
|
+
expect(month(/May 2023/).disabled).toBe(false);
|
|
64
245
|
});
|
|
65
246
|
});
|
|
66
247
|
|