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.
Files changed (49) hide show
  1. package/dist/react/components/AppShell.d.ts +12 -1
  2. package/dist/react/components/AppShell.d.ts.map +1 -1
  3. package/dist/react/components/AppShell.js +24 -5
  4. package/dist/react/components/AppShell.js.map +1 -1
  5. package/dist/react/components/ComboboxField.js +2 -2
  6. package/dist/react/components/ComboboxField.js.map +1 -1
  7. package/dist/react/components/DatePicker.d.ts +9 -5
  8. package/dist/react/components/DatePicker.d.ts.map +1 -1
  9. package/dist/react/components/DatePicker.js +192 -27
  10. package/dist/react/components/DatePicker.js.map +1 -1
  11. package/dist/react/components/InlineEditableText.d.ts +12 -2
  12. package/dist/react/components/InlineEditableText.d.ts.map +1 -1
  13. package/dist/react/components/InlineEditableText.js +11 -8
  14. package/dist/react/components/InlineEditableText.js.map +1 -1
  15. package/dist/react/components/suggestionList.js +2 -2
  16. package/dist/react/components/suggestionList.js.map +1 -1
  17. package/dist/react/lib/dateText.d.ts +21 -0
  18. package/dist/react/lib/dateText.d.ts.map +1 -0
  19. package/dist/react/lib/dateText.js +94 -0
  20. package/dist/react/lib/dateText.js.map +1 -0
  21. package/dist/react/lib/floatingStyles.d.ts +12 -0
  22. package/dist/react/lib/floatingStyles.d.ts.map +1 -1
  23. package/dist/react/lib/floatingStyles.js +12 -0
  24. package/dist/react/lib/floatingStyles.js.map +1 -1
  25. package/dist/styles-areas/analytics.css +11 -10
  26. package/dist/styles-areas/data-table.css +13 -12
  27. package/dist/styles-areas/fields.css +12 -10
  28. package/dist/styles-areas/stats.css +12 -11
  29. package/dist/styles-areas/wizard.css +6 -6
  30. package/dist/styles-utilities.css +15 -0
  31. package/package.json +1 -1
  32. package/src/react/components/AppShell.spec.tsx +53 -0
  33. package/src/react/components/AppShell.tsx +79 -23
  34. package/src/react/components/ComboboxField.tsx +2 -2
  35. package/src/react/components/ControlHeightScale.spec.tsx +2 -2
  36. package/src/react/components/DatePicker.spec.tsx +186 -5
  37. package/src/react/components/DatePicker.tsx +416 -63
  38. package/src/react/components/InlineEditableText.spec.tsx +29 -0
  39. package/src/react/components/InlineEditableText.tsx +26 -10
  40. package/src/react/components/suggestionList.tsx +2 -2
  41. package/src/react/lib/dateText.ts +94 -0
  42. package/src/react/lib/floatingStyles.spec.ts +36 -1
  43. package/src/react/lib/floatingStyles.ts +13 -0
  44. package/src/styles-areas/analytics.css +11 -10
  45. package/src/styles-areas/data-table.css +13 -12
  46. package/src/styles-areas/fields.css +12 -10
  47. package/src/styles-areas/stats.css +12 -11
  48. package/src/styles-areas/wizard.css +6 -6
  49. 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
- const railCommand = (collapsed: boolean) =>
358
- openCommand || (!collapsed && (onCapture || commandBarExtra)) ? (
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
- ) : null;
370
- const railHead = (collapsed: boolean) => (
371
- <div data-cb-rail-head='' className={cn('flex flex-col gap-3', collapsed && 'items-center')}>
372
- {(navHeader && !collapsed) || account ? (
373
- <div className={cn('flex min-w-0 items-center gap-2', collapsed && 'flex-col')}>
374
- {navHeader && !collapsed ? <div className='flex min-w-0 flex-1 items-center overflow-hidden'>{navHeader}</div> : null}
375
- {account ? (
376
- <div data-cb-account='' className='shrink-0'>
377
- {account}
378
- </div>
379
- ) : null}
380
- </div>
381
- ) : null}
382
- {railCommand(collapsed)}
383
- </div>
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). Two CSS-switched renders: SideNav's `collapsed` is a
487
- prop, not a media query, and the rail is cheap to render twice. */}
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 lg:hidden',
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 lg:flex lg:shrink-0 lg:flex-col',
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='z-[var(--z-popover)]'
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 trigger honors `size`', () => {
75
+ test('DatePicker field honors `size`', () => {
76
76
  const { getByRole } = render(<DatePicker size='sm' placeholder='Pick' onChange={() => {}} />);
77
- expect(getByRole('button').className).toContain(TOKEN.sm);
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 trigger', () => {
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 btn = getByRole('button');
62
- expect(btn.getAttribute('aria-invalid')).toBe('true');
63
- expect(btn.getAttribute('aria-describedby')).toBe('err');
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