cursedbelt 5.93.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 (95) 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/CommentThread.d.ts +9 -3
  8. package/dist/react/components/CommentThread.d.ts.map +1 -1
  9. package/dist/react/components/CommentThread.js +25 -11
  10. package/dist/react/components/CommentThread.js.map +1 -1
  11. package/dist/react/components/DatePicker.d.ts +9 -5
  12. package/dist/react/components/DatePicker.d.ts.map +1 -1
  13. package/dist/react/components/DatePicker.js +192 -27
  14. package/dist/react/components/DatePicker.js.map +1 -1
  15. package/dist/react/components/InlineEditableText.d.ts +12 -2
  16. package/dist/react/components/InlineEditableText.d.ts.map +1 -1
  17. package/dist/react/components/InlineEditableText.js +11 -8
  18. package/dist/react/components/InlineEditableText.js.map +1 -1
  19. package/dist/react/components/VoiceNote.d.ts +20 -0
  20. package/dist/react/components/VoiceNote.d.ts.map +1 -0
  21. package/dist/react/components/VoiceNote.js +54 -0
  22. package/dist/react/components/VoiceNote.js.map +1 -0
  23. package/dist/react/components/VoiceRecorder.d.ts +35 -0
  24. package/dist/react/components/VoiceRecorder.d.ts.map +1 -0
  25. package/dist/react/components/VoiceRecorder.js +155 -0
  26. package/dist/react/components/VoiceRecorder.js.map +1 -0
  27. package/dist/react/components/suggestionList.js +2 -2
  28. package/dist/react/components/suggestionList.js.map +1 -1
  29. package/dist/react/index.d.ts +2 -0
  30. package/dist/react/index.d.ts.map +1 -1
  31. package/dist/react/index.js +2 -0
  32. package/dist/react/index.js.map +1 -1
  33. package/dist/react/lib/dateText.d.ts +21 -0
  34. package/dist/react/lib/dateText.d.ts.map +1 -0
  35. package/dist/react/lib/dateText.js +94 -0
  36. package/dist/react/lib/dateText.js.map +1 -0
  37. package/dist/react/lib/floatingStyles.d.ts +12 -0
  38. package/dist/react/lib/floatingStyles.d.ts.map +1 -1
  39. package/dist/react/lib/floatingStyles.js +12 -0
  40. package/dist/react/lib/floatingStyles.js.map +1 -1
  41. package/dist/react/map-frame/MapFrame.d.ts +219 -0
  42. package/dist/react/map-frame/MapFrame.d.ts.map +1 -0
  43. package/dist/react/map-frame/MapFrame.js +726 -0
  44. package/dist/react/map-frame/MapFrame.js.map +1 -0
  45. package/dist/react/map-frame/geo.d.ts +210 -0
  46. package/dist/react/map-frame/geo.d.ts.map +1 -0
  47. package/dist/react/map-frame/geo.js +345 -0
  48. package/dist/react/map-frame/geo.js.map +1 -0
  49. package/dist/react/map-frame/index.d.ts +5 -0
  50. package/dist/react/map-frame/index.d.ts.map +1 -0
  51. package/dist/react/map-frame/index.js +3 -0
  52. package/dist/react/map-frame/index.js.map +1 -0
  53. package/dist/styles-areas/analytics.css +11 -10
  54. package/dist/styles-areas/canvas-hud.css +5 -5
  55. package/dist/styles-areas/data-table.css +13 -12
  56. package/dist/styles-areas/fields.css +12 -10
  57. package/dist/styles-areas/stats.css +12 -11
  58. package/dist/styles-areas/wizard.css +6 -6
  59. package/dist/styles-areas/workbench.css +17 -17
  60. package/dist/styles-utilities.css +15 -0
  61. package/package.json +13 -1
  62. package/scripts/guardrails-baseline.json +1 -1
  63. package/scripts/styleAreas.ts +1 -1
  64. package/src/react/components/AppShell.spec.tsx +53 -0
  65. package/src/react/components/AppShell.tsx +79 -23
  66. package/src/react/components/ComboboxField.tsx +2 -2
  67. package/src/react/components/CommentThread.spec.tsx +25 -0
  68. package/src/react/components/CommentThread.tsx +60 -10
  69. package/src/react/components/ControlHeightScale.spec.tsx +2 -2
  70. package/src/react/components/DatePicker.spec.tsx +186 -5
  71. package/src/react/components/DatePicker.tsx +416 -63
  72. package/src/react/components/InlineEditableText.spec.tsx +29 -0
  73. package/src/react/components/InlineEditableText.tsx +26 -10
  74. package/src/react/components/VoiceNote.tsx +92 -0
  75. package/src/react/components/VoiceRecorder.spec.tsx +116 -0
  76. package/src/react/components/VoiceRecorder.tsx +224 -0
  77. package/src/react/components/suggestionList.tsx +2 -2
  78. package/src/react/index.ts +9 -0
  79. package/src/react/lib/dateText.ts +94 -0
  80. package/src/react/lib/floatingStyles.spec.ts +36 -1
  81. package/src/react/lib/floatingStyles.ts +13 -0
  82. package/src/react/map-frame/MapFrame.spec.ts +168 -0
  83. package/src/react/map-frame/MapFrame.tsx +954 -0
  84. package/src/react/map-frame/geo.spec.ts +393 -0
  85. package/src/react/map-frame/geo.ts +421 -0
  86. package/src/react/map-frame/index.ts +34 -0
  87. package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -0
  88. package/src/styles-areas/analytics.css +11 -10
  89. package/src/styles-areas/canvas-hud.css +5 -5
  90. package/src/styles-areas/data-table.css +13 -12
  91. package/src/styles-areas/fields.css +12 -10
  92. package/src/styles-areas/stats.css +12 -11
  93. package/src/styles-areas/wizard.css +6 -6
  94. package/src/styles-areas/workbench.css +17 -17
  95. package/src/styles-utilities.css +15 -0
@@ -134,4 +134,29 @@ describe('CommentThread', () => {
134
134
  fireEvent.click(replyBtn!);
135
135
  expect(onPost).toHaveBeenCalledWith('My reply', 'p1');
136
136
  });
137
+
138
+ test('a voice comment plays inline, and a voice-only one draws no empty text line', () => {
139
+ const { getByRole, container } = render(
140
+ <CommentThread comments={[makeComment({ body: '', audioUrl: 'https://media.test/c1.webm' })]} onPost={() => {}} />,
141
+ );
142
+ expect(getByRole('button', { name: 'Play voice comment by Alice' })).toBeTruthy();
143
+ expect(container.querySelector('audio')?.getAttribute('src')).toBe('https://media.test/c1.webm');
144
+ expect(container.querySelector('p.whitespace-pre-wrap')).toBeNull();
145
+ });
146
+
147
+ test('a deleted voice comment plays nothing', () => {
148
+ const { queryByRole, getByText } = render(
149
+ <CommentThread comments={[makeComment({ deleted: true, audioUrl: 'https://media.test/c1.webm' })]} onPost={() => {}} />,
150
+ );
151
+ expect(getByText('deleted')).toBeTruthy();
152
+ expect(queryByRole('button', { name: /Play voice comment/ })).toBeNull();
153
+ });
154
+
155
+ test('the record button appears only when the app takes voice comments', () => {
156
+ const without = render(<CommentThread comments={[]} onPost={() => {}} />);
157
+ expect(without.queryByRole('button', { name: 'Record a voice comment' })).toBeNull();
158
+ without.unmount();
159
+ const withVoice = render(<CommentThread comments={[]} onPost={() => {}} onPostVoice={() => {}} />);
160
+ expect(withVoice.getByRole('button', { name: 'Record a voice comment' })).toBeTruthy();
161
+ });
137
162
  });
@@ -1,4 +1,4 @@
1
- import { Edit2, Send, Trash2 } from 'lucide-react';
1
+ import { Edit2, Mic, Send, Trash2 } from 'lucide-react';
2
2
  import { useState } from 'react';
3
3
  import { cn } from '../lib/cn.js';
4
4
  import { FIELD_CLASS } from '../lib/field.js';
@@ -9,6 +9,8 @@ import { Button } from './Button.js';
9
9
  import { IconButton } from './IconButton.js';
10
10
  import { ReactionBar, type ReactionGroup } from './ReactionBar.js';
11
11
  import { Skeleton } from './Skeleton.js';
12
+ import { VoiceNote } from './VoiceNote.js';
13
+ import { type VoiceRecording, VoiceRecorder } from './VoiceRecorder.js';
12
14
 
13
15
  // ── Types ─────────────────────────────────────────────────────────────────────
14
16
 
@@ -37,6 +39,8 @@ export interface CommentData {
37
39
  /** Whether the current user may delete this comment. */
38
40
  canDelete?: boolean;
39
41
  reactions?: CommentReactionSummary;
42
+ /** A voice comment's audio, played inline above its (optional) text. */
43
+ audioUrl?: string | null;
40
44
  }
41
45
 
42
46
  export interface CommentThreadProps {
@@ -48,6 +52,8 @@ export interface CommentThreadProps {
48
52
  onDelete?: (id: string) => void;
49
53
  /** Toggle a reaction on a comment. */
50
54
  onReact?: (commentId: string, emoji: string) => void;
55
+ /** Post a voice comment (parentId for a reply). When set, every composer grows a record button. */
56
+ onPostVoice?: (recording: VoiceRecording, parentId?: string) => void;
51
57
  className?: string;
52
58
  emptyMessage?: string;
53
59
  /** Placeholder for the root-level composer. */
@@ -81,16 +87,19 @@ function formatRelative(target: Date, from = new Date()): string {
81
87
 
82
88
  function Composer({
83
89
  onSubmit,
90
+ onVoice,
84
91
  placeholder,
85
92
  autoFocus,
86
93
  onCancel,
87
94
  }: {
88
95
  onSubmit: (body: string) => void;
96
+ onVoice?: (recording: VoiceRecording) => void;
89
97
  placeholder: string;
90
98
  autoFocus?: boolean;
91
99
  onCancel?: () => void;
92
100
  }) {
93
101
  const [body, setBody] = useState('');
102
+ const [recording, setRecording] = useState(false);
94
103
  const submit = () => {
95
104
  const trimmed = body.trim();
96
105
  if (!trimmed) return;
@@ -98,6 +107,18 @@ function Composer({
98
107
  setBody('');
99
108
  };
100
109
 
110
+ if (recording && onVoice) {
111
+ return (
112
+ <VoiceRecorder
113
+ onCancel={() => setRecording(false)}
114
+ onDone={(r) => {
115
+ setRecording(false);
116
+ onVoice(r);
117
+ }}
118
+ />
119
+ );
120
+ }
121
+
101
122
  return (
102
123
  <div className='flex items-end gap-2'>
103
124
  <AutoGrowTextarea
@@ -114,6 +135,11 @@ function Composer({
114
135
  // floor on a coarse pointer (DESIGN.md LAY-13).
115
136
  className={cn(FIELD_CLASS, 'min-h-9 flex-1', TAP_TARGET_HEIGHT)}
116
137
  />
138
+ {onVoice && (
139
+ <IconButton label='Record a voice comment' variant='secondary' onClick={() => setRecording(true)}>
140
+ <Mic />
141
+ </IconButton>
142
+ )}
117
143
  <Button
118
144
  variant='primary'
119
145
  size='sm'
@@ -159,6 +185,7 @@ function CommentItem({
159
185
  onEdit,
160
186
  onDelete,
161
187
  onReact,
188
+ onPostVoice,
162
189
  }: {
163
190
  comment: CommentData;
164
191
  replies: CommentData[];
@@ -166,6 +193,7 @@ function CommentItem({
166
193
  onEdit?: (id: string, body: string) => void;
167
194
  onDelete?: (id: string) => void;
168
195
  onReact?: (commentId: string, emoji: string) => void;
196
+ onPostVoice?: (recording: VoiceRecording, parentId?: string) => void;
169
197
  }) {
170
198
  const [replying, setReplying] = useState(false);
171
199
  const [editing, setEditing] = useState(false);
@@ -221,14 +249,21 @@ function CommentItem({
221
249
  Cancel
222
250
  </Button>
223
251
  </div>
252
+ ) : comment.deleted ? (
253
+ <p className='mt-0.5 text-sm italic text-muted-foreground'>deleted</p>
224
254
  ) : (
225
- <p className='mt-0.5 whitespace-pre-wrap break-words text-sm text-foreground'>
226
- {comment.deleted ? (
227
- <span className='italic text-muted-foreground'>deleted</span>
228
- ) : (
229
- comment.body
255
+ <>
256
+ {comment.audioUrl && (
257
+ <VoiceNote
258
+ src={comment.audioUrl}
259
+ label={`Play voice comment by ${comment.author.name || 'someone'}`}
260
+ className='mt-1 max-w-80'
261
+ />
262
+ )}
263
+ {comment.body && (
264
+ <p className='mt-0.5 whitespace-pre-wrap break-words text-sm text-foreground'>{comment.body}</p>
230
265
  )}
231
- </p>
266
+ </>
232
267
  )}
233
268
  </div>
234
269
 
@@ -294,6 +329,13 @@ function CommentItem({
294
329
  onPost(body, comment.id);
295
330
  setReplying(false);
296
331
  }}
332
+ onVoice={
333
+ onPostVoice &&
334
+ ((r) => {
335
+ onPostVoice(r, comment.id);
336
+ setReplying(false);
337
+ })
338
+ }
297
339
  />
298
340
  </div>
299
341
  )}
@@ -309,6 +351,7 @@ function CommentItem({
309
351
  onEdit={onEdit}
310
352
  onDelete={onDelete}
311
353
  onReact={onReact}
354
+ onPostVoice={onPostVoice}
312
355
  />
313
356
  ))}
314
357
  </div>
@@ -325,8 +368,9 @@ function CommentItem({
325
368
  * query/mutation hooks bundled — wire up your own TanStack Query mutations and pass
326
369
  * the results as props.
327
370
  *
328
- * Supports one level of replies, edit, delete (with confirm), and emoji reactions
329
- * via {@link ReactionBar}. Each interaction calls its corresponding handler; the
371
+ * Supports one level of replies, edit, delete (with confirm), emoji reactions
372
+ * via {@link ReactionBar}, and — with `onPostVoice` — voice comments recorded by
373
+ * {@link VoiceRecorder} and played inline by {@link VoiceNote}. Each interaction calls its corresponding handler; the
330
374
  * caller re-renders with fresh data.
331
375
  */
332
376
  export function CommentThread({
@@ -336,6 +380,7 @@ export function CommentThread({
336
380
  onEdit,
337
381
  onDelete,
338
382
  onReact,
383
+ onPostVoice,
339
384
  className,
340
385
  emptyMessage = 'No comments yet — start the conversation.',
341
386
  placeholder = 'Add a comment… (⌘/Ctrl+Enter to send)',
@@ -356,6 +401,7 @@ export function CommentThread({
356
401
  onEdit={onEdit}
357
402
  onDelete={onDelete}
358
403
  onReact={onReact}
404
+ onPostVoice={onPostVoice}
359
405
  />
360
406
  ))}
361
407
  {/* Loading drew nothing at all — only the composer — which read as an empty thread. */}
@@ -363,7 +409,11 @@ export function CommentThread({
363
409
  {!isLoading && topLevel.length === 0 && (
364
410
  <p className='text-xs text-muted-foreground'>{emptyMessage}</p>
365
411
  )}
366
- <Composer placeholder={placeholder} onSubmit={(body) => onPost(body)} />
412
+ <Composer
413
+ placeholder={placeholder}
414
+ onSubmit={(body) => onPost(body)}
415
+ onVoice={onPostVoice && ((r) => onPostVoice(r))}
416
+ />
367
417
  </div>
368
418
  );
369
419
  }
@@ -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