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.
- 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/CommentThread.d.ts +9 -3
- package/dist/react/components/CommentThread.d.ts.map +1 -1
- package/dist/react/components/CommentThread.js +25 -11
- package/dist/react/components/CommentThread.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/VoiceNote.d.ts +20 -0
- package/dist/react/components/VoiceNote.d.ts.map +1 -0
- package/dist/react/components/VoiceNote.js +54 -0
- package/dist/react/components/VoiceNote.js.map +1 -0
- package/dist/react/components/VoiceRecorder.d.ts +35 -0
- package/dist/react/components/VoiceRecorder.d.ts.map +1 -0
- package/dist/react/components/VoiceRecorder.js +155 -0
- package/dist/react/components/VoiceRecorder.js.map +1 -0
- package/dist/react/components/suggestionList.js +2 -2
- package/dist/react/components/suggestionList.js.map +1 -1
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/index.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/react/map-frame/MapFrame.d.ts +219 -0
- package/dist/react/map-frame/MapFrame.d.ts.map +1 -0
- package/dist/react/map-frame/MapFrame.js +726 -0
- package/dist/react/map-frame/MapFrame.js.map +1 -0
- package/dist/react/map-frame/geo.d.ts +210 -0
- package/dist/react/map-frame/geo.d.ts.map +1 -0
- package/dist/react/map-frame/geo.js +345 -0
- package/dist/react/map-frame/geo.js.map +1 -0
- package/dist/react/map-frame/index.d.ts +5 -0
- package/dist/react/map-frame/index.d.ts.map +1 -0
- package/dist/react/map-frame/index.js +3 -0
- package/dist/react/map-frame/index.js.map +1 -0
- package/dist/styles-areas/analytics.css +11 -10
- package/dist/styles-areas/canvas-hud.css +5 -5
- 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-areas/workbench.css +17 -17
- package/dist/styles-utilities.css +15 -0
- package/package.json +13 -1
- package/scripts/guardrails-baseline.json +1 -1
- package/scripts/styleAreas.ts +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/CommentThread.spec.tsx +25 -0
- package/src/react/components/CommentThread.tsx +60 -10
- 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/VoiceNote.tsx +92 -0
- package/src/react/components/VoiceRecorder.spec.tsx +116 -0
- package/src/react/components/VoiceRecorder.tsx +224 -0
- package/src/react/components/suggestionList.tsx +2 -2
- package/src/react/index.ts +9 -0
- 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/react/map-frame/MapFrame.spec.ts +168 -0
- package/src/react/map-frame/MapFrame.tsx +954 -0
- package/src/react/map-frame/geo.spec.ts +393 -0
- package/src/react/map-frame/geo.ts +421 -0
- package/src/react/map-frame/index.ts +34 -0
- package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -0
- package/src/styles-areas/analytics.css +11 -10
- package/src/styles-areas/canvas-hud.css +5 -5
- 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-areas/workbench.css +17 -17
- 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
|
-
|
|
226
|
-
{comment.
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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),
|
|
329
|
-
* via {@link ReactionBar}
|
|
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
|
|
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
|
|
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
|
|