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
@@ -1,8 +1,18 @@
1
1
  import { DayPicker, type Matcher } from '@daypicker/react';
2
- import { Calendar } from 'lucide-react';
3
- import { useState } from 'react';
2
+ import {
3
+ Calendar,
4
+ ChevronDown,
5
+ ChevronLeft,
6
+ ChevronRight,
7
+ ChevronsLeft,
8
+ ChevronsRight,
9
+ } from 'lucide-react';
10
+ import { type ReactNode, useEffect, useId, useState } from 'react';
4
11
  import { useMediaQuery } from '../hooks/useMediaQuery.js';
5
12
  import { cn } from '../lib/cn.js';
13
+ import { RAW_BUTTON } from '../lib/cn.js';
14
+ import { TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
15
+ import { parseDateText } from '../lib/dateText.js';
6
16
  import {
7
17
  DAY_PICKER_CLASS_NAMES,
8
18
  DayPickerChevron,
@@ -11,10 +21,13 @@ import {
11
21
  localDateToIso,
12
22
  ThemedDayButton,
13
23
  } from '../lib/daypicker.js';
14
- import { type ControlSize, declareFieldWidth, fieldControlClass } from '../lib/field.js';
24
+ import { type ControlSize, declareFieldWidth } from '../lib/field.js';
15
25
  import { Drawer } from './Drawer.js';
26
+ import { Button } from './Button.js';
27
+ import { IconButton } from './IconButton.js';
28
+ import { Input } from './Input.js';
16
29
  import { Popover } from './Popover.js';
17
- import { RAW_BUTTON } from '../lib/cn.js';
30
+ import { ScrollArea } from './ScrollArea.js';
18
31
 
19
32
  export interface DatePickerProps {
20
33
  /** ISO date string 'YYYY-MM-DD'. Empty string or undefined = no selection. */
@@ -36,14 +49,256 @@ export interface DatePickerProps {
36
49
  size?: ControlSize;
37
50
  }
38
51
 
52
+ type CalendarView = 'days' | 'years' | 'months';
53
+
54
+ const monthStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
55
+ const addMonths = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth() + n, 1);
56
+
57
+ /** Keep a month inside the [min, max] months, if given. */
58
+ function clampMonth(d: Date, min?: Date, max?: Date): Date {
59
+ const first = monthStart(d);
60
+ if (min && first < monthStart(min)) return monthStart(min);
61
+ if (max && first > monthStart(max)) return monthStart(max);
62
+ return first;
63
+ }
64
+
65
+ const sameMonth = (a: Date, b: Date) =>
66
+ a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
67
+
68
+ const CHOOSER_BUTTON =
69
+ `flex h-[var(--control-h-sm)] ${TAP_TARGET_HEIGHT} w-full items-center justify-center rounded-md text-sm transition-colors hover:bg-accent/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-30`;
70
+ const CHOOSER_SELECTED = 'bg-accent-solid text-white hover:bg-accent-solid-hover';
71
+
72
+ /** One cell of the year / month chooser grids. */
73
+ function ChooserCell({
74
+ current,
75
+ disabled,
76
+ tall,
77
+ label,
78
+ onPick,
79
+ children,
80
+ }: {
81
+ current: boolean;
82
+ disabled?: boolean;
83
+ tall?: boolean;
84
+ label?: string;
85
+ onPick: () => void;
86
+ children: ReactNode;
87
+ }) {
88
+ return (
89
+ <button
90
+ // guardrails-ignore no-raw-action-button: task 3764 — a cell in the year/month chooser grid, a
91
+ // presentational selection like ThemedDayButton (STANDARDS.md §4a carve-out).
92
+ type='button'
93
+ disabled={disabled}
94
+ aria-current={current ? 'date' : undefined}
95
+ aria-label={label}
96
+ ref={(el) => {
97
+ // Bring the viewed year into view in the scrolling year grid.
98
+ if (el && current) el.scrollIntoView?.({ block: 'center' });
99
+ }}
100
+ className={cn(
101
+ RAW_BUTTON,
102
+ CHOOSER_BUTTON,
103
+ tall && 'h-[var(--control-h-lg)]',
104
+ current && CHOOSER_SELECTED,
105
+ )}
106
+ onClick={onPick}
107
+ >
108
+ {children}
109
+ </button>
110
+ );
111
+ }
112
+
113
+ /**
114
+ * The calendar body: month + year steppers, a caption that opens a year/month chooser, and
115
+ * the day grid. PageUp/PageDown (month) and Shift+PageUp/PageDown (year) are DayPicker's own,
116
+ * per the WAI-ARIA date-picker dialog pattern; the month is controlled here so the steppers,
117
+ * the chooser and the keys all move the same view.
118
+ */
119
+ function DatePickerCalendar({
120
+ selected,
121
+ month,
122
+ onMonthChange,
123
+ onSelect,
124
+ minDate,
125
+ maxDate,
126
+ }: {
127
+ selected: Date | undefined;
128
+ month: Date;
129
+ onMonthChange: (month: Date) => void;
130
+ onSelect: (date: Date | undefined) => void;
131
+ minDate: Date | undefined;
132
+ maxDate: Date | undefined;
133
+ }) {
134
+ const [view, setView] = useState<CalendarView>('days');
135
+ const [chosenYear, setChosenYear] = useState(month.getFullYear());
136
+
137
+ const disabledMatchers: Matcher[] = [];
138
+ if (minDate) disabledMatchers.push({ before: minDate });
139
+ if (maxDate) disabledMatchers.push({ after: maxDate });
140
+
141
+ const step = (months: number) => onMonthChange(clampMonth(addMonths(month, months), minDate, maxDate));
142
+ const stepDisabled = (months: number) =>
143
+ sameMonth(clampMonth(addMonths(month, months), minDate, maxDate), month);
144
+
145
+ if (view === 'years') {
146
+ const thisYear = new Date().getFullYear();
147
+ const first = minDate ? minDate.getFullYear() : Math.min(1900, month.getFullYear());
148
+ const last = maxDate ? maxDate.getFullYear() : Math.max(thisYear + 30, month.getFullYear());
149
+ const years = Array.from({ length: last - first + 1 }, (_, i) => first + i);
150
+ return (
151
+ <div>
152
+ <div className={`flex h-[var(--control-h-lg)] ${TAP_TARGET_HEIGHT} items-center gap-1 pb-1`}>
153
+ <IconButton label='Back to calendar' size='sm' onClick={() => setView('days')}>
154
+ <ChevronLeft />
155
+ </IconButton>
156
+ <span className='flex-1 text-center text-sm font-semibold'>Choose a year</span>
157
+ <span className='w-[var(--control-h-sm)]' aria-hidden />
158
+ </div>
159
+ <ScrollArea className='max-h-60' space='none' safeArea={false}>
160
+ <fieldset aria-label='Year' className='m-0 grid min-w-0 grid-cols-4 gap-1 border-0 p-0'>
161
+ {years.map((year) => {
162
+ const isCurrent = year === month.getFullYear();
163
+ return (
164
+ <ChooserCell
165
+ key={year}
166
+ current={isCurrent}
167
+ onPick={() => {
168
+ setChosenYear(year);
169
+ setView('months');
170
+ }}
171
+ >
172
+ {year}
173
+ </ChooserCell>
174
+ );
175
+ })}
176
+ </fieldset>
177
+ </ScrollArea>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ if (view === 'months') {
183
+ return (
184
+ <div>
185
+ <div className={`flex h-[var(--control-h-lg)] ${TAP_TARGET_HEIGHT} items-center gap-1 pb-1`}>
186
+ <IconButton label='Choose another year' size='sm' onClick={() => setView('years')}>
187
+ <ChevronLeft />
188
+ </IconButton>
189
+ <span className='flex-1 text-center text-sm font-semibold'>{chosenYear}</span>
190
+ <span className='w-[var(--control-h-sm)]' aria-hidden />
191
+ </div>
192
+ <fieldset aria-label='Month' className='m-0 grid min-w-0 grid-cols-3 gap-1 border-0 p-0'>
193
+ {Array.from({ length: 12 }, (_, i) => {
194
+ const start = new Date(chosenYear, i, 1);
195
+ const outOfRange =
196
+ (minDate !== undefined && start < monthStart(minDate)) ||
197
+ (maxDate !== undefined && start > monthStart(maxDate));
198
+ const isCurrent = sameMonth(start, month);
199
+ return (
200
+ <ChooserCell
201
+ key={start.getTime()}
202
+ current={isCurrent}
203
+ disabled={outOfRange}
204
+ tall
205
+ label={start.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })}
206
+ onPick={() => {
207
+ onMonthChange(start);
208
+ setView('days');
209
+ }}
210
+ >
211
+ {start.toLocaleDateString(undefined, { month: 'short' })}
212
+ </ChooserCell>
213
+ );
214
+ })}
215
+ </fieldset>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ return (
221
+ <div>
222
+ <div className='flex items-center gap-0.5 pb-1'>
223
+ <IconButton
224
+ label='Previous year'
225
+ size='sm'
226
+ disabled={stepDisabled(-12)}
227
+ onClick={() => step(-12)}
228
+ >
229
+ <ChevronsLeft />
230
+ </IconButton>
231
+ <IconButton
232
+ label='Previous month'
233
+ size='sm'
234
+ disabled={stepDisabled(-1)}
235
+ onClick={() => step(-1)}
236
+ >
237
+ <ChevronLeft />
238
+ </IconButton>
239
+ <Button
240
+ variant='ghost'
241
+ size='sm'
242
+ tooltip='Choose month and year'
243
+ aria-haspopup='dialog'
244
+ aria-label={`${month.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })}, choose month and year`}
245
+ className={`min-w-0 flex-1 gap-1 font-semibold ${TAP_TARGET_HEIGHT}`}
246
+ onClick={() => {
247
+ setChosenYear(month.getFullYear());
248
+ setView('years');
249
+ }}
250
+ >
251
+ <span className='truncate'>
252
+ {month.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })}
253
+ </span>
254
+ <ChevronDown className='h-3.5 w-3.5 shrink-0 opacity-60' />
255
+ </Button>
256
+ <IconButton
257
+ label='Next month'
258
+ size='sm'
259
+ disabled={stepDisabled(1)}
260
+ onClick={() => step(1)}
261
+ >
262
+ <ChevronRight />
263
+ </IconButton>
264
+ <IconButton
265
+ label='Next year'
266
+ size='sm'
267
+ disabled={stepDisabled(12)}
268
+ onClick={() => step(12)}
269
+ >
270
+ <ChevronsRight />
271
+ </IconButton>
272
+ </div>
273
+ <DayPicker
274
+ mode='single'
275
+ selected={selected}
276
+ onSelect={onSelect}
277
+ month={month}
278
+ onMonthChange={(next) => onMonthChange(clampMonth(next, minDate, maxDate))}
279
+ hideNavigation
280
+ classNames={{ ...DAY_PICKER_CLASS_NAMES, month_caption: 'hidden' }}
281
+ components={{ DayButton: ThemedDayButton, Chevron: DayPickerChevron as never }}
282
+ disabled={disabledMatchers.length > 0 ? disabledMatchers : undefined}
283
+ startMonth={minDate}
284
+ endMonth={maxDate}
285
+ />
286
+ </div>
287
+ );
288
+ }
289
+
39
290
  /**
40
- * Token-themed date picker on `@daypicker/react`. Replaces the bare
41
- * `<input type="date">` for the `FieldInput` `date` type — presents a calendar
42
- * popover on desktop and a bottom sheet on mobile (&lt;640 px), with full
43
- * keyboard navigation and screen-reader labels.
291
+ * Token-themed date picker on `@daypicker/react`. The field is a text input a person can
292
+ * TYPE a date into (`2015-12-05`, `12/5/2015`, `Dec 5 2015`, `December 5, 2015`, `5 Dec
293
+ * 2015`; a two-digit year reads as the nearest sensible century), normalized to ISO on
294
+ * blur or Enter — and a calendar button beside it opens a popover on desktop or a bottom
295
+ * sheet on mobile (&lt;640 px). In the calendar the month AND year step with buttons, the
296
+ * caption opens a year/month chooser, and PageUp/PageDown (Shift for a year) move the view.
44
297
  *
45
298
  * Value is an ISO date string (`'YYYY-MM-DD'`), matching what a native date
46
- * input produces, so it's drop-in compatible with existing RHF form schemas.
299
+ * input produces, so it's drop-in compatible with existing RHF form schemas. Typed text that
300
+ * does not parse, or falls outside `min`/`max`, never reaches `onChange`: the field turns
301
+ * invalid with a short reason, and Escape restores the last good value.
47
302
  */
48
303
  export function DatePicker({
49
304
  value,
@@ -62,13 +317,60 @@ export function DatePicker({
62
317
  }: DatePickerProps) {
63
318
  const [open, setOpen] = useState(false);
64
319
  const isMobile = useMediaQuery('(max-width: 639px)');
320
+ const [anchor, setAnchor] = useState<HTMLDivElement | null>(null);
321
+ const errorId = useId();
65
322
 
66
323
  const selectedDate = value ? isoToLocalDate(value) : undefined;
67
- const defaultMonth = selectedDate ?? new Date();
324
+ const minDate = min ? isoToLocalDate(min) : undefined;
325
+ const maxDate = max ? isoToLocalDate(max) : undefined;
68
326
 
69
- const disabledMatchers: Matcher[] = [];
70
- if (min) disabledMatchers.push({ before: isoToLocalDate(min) });
71
- if (max) disabledMatchers.push({ after: isoToLocalDate(max) });
327
+ // The text being typed, or null while the field just shows the value.
328
+ const [draft, setDraft] = useState<string | null>(null);
329
+ const [error, setError] = useState<string | null>(null);
330
+ const [month, setMonth] = useState(() => clampMonth(selectedDate ?? new Date(), minDate, maxDate));
331
+
332
+ // A new value from outside (or a pick in the calendar) replaces whatever was being typed
333
+ // and moves the calendar's view to it. min/max only bound the clamp: the view moves when the
334
+ // value does, so they stay out of the dependency list.
335
+ // biome-ignore lint/correctness/useExhaustiveDependencies: see above
336
+ useEffect(() => {
337
+ setDraft(null);
338
+ setError(null);
339
+ if (value) setMonth(clampMonth(isoToLocalDate(value), minDate, maxDate));
340
+ }, [value]);
341
+
342
+ const display = selectedDate ? formatDateDisplay(selectedDate) : '';
343
+ const inputText = draft ?? display;
344
+
345
+ const refuse = (reason: string) => {
346
+ setError(reason);
347
+ return false;
348
+ };
349
+
350
+ /** Parse the draft; on success call onChange once and return to the display form. */
351
+ const commitDraft = (): boolean => {
352
+ if (draft === null) return true;
353
+ if (draft.trim() === '') {
354
+ setDraft(null);
355
+ setError(null);
356
+ if (value) onChange?.(undefined);
357
+ return true;
358
+ }
359
+ const parsed = parseDateText(draft);
360
+ if (!parsed.ok) return refuse(parsed.reason);
361
+ const date = isoToLocalDate(parsed.iso);
362
+ if (minDate && date < minDate) {
363
+ return refuse(`Pick a date on or after ${formatDateDisplay(minDate)}`);
364
+ }
365
+ if (maxDate && date > maxDate) {
366
+ return refuse(`Pick a date on or before ${formatDateDisplay(maxDate)}`);
367
+ }
368
+ setDraft(null);
369
+ setError(null);
370
+ setMonth(clampMonth(date, minDate, maxDate));
371
+ if (parsed.iso !== value) onChange?.(parsed.iso);
372
+ return true;
373
+ };
72
374
 
73
375
  const handleSelect = (date: Date | undefined) => {
74
376
  onChange?.(date ? localDateToIso(date) : undefined);
@@ -78,76 +380,127 @@ export function DatePicker({
78
380
 
79
381
  const handleOpenChange = (next: boolean) => {
80
382
  setOpen(next);
81
- if (!next) onBlur?.();
383
+ if (next) {
384
+ // Open on the typed date when the text is valid, else on the value.
385
+ setMonth(clampMonth(selectedDate ?? new Date(), minDate, maxDate));
386
+ } else onBlur?.();
82
387
  };
83
388
 
84
- const triggerLabel = selectedDate ? formatDateDisplay(selectedDate) : placeholder;
389
+ const openCalendar = () => {
390
+ if (disabled) return;
391
+ if (draft !== null) {
392
+ const parsed = parseDateText(draft);
393
+ if (parsed.ok) setMonth(clampMonth(isoToLocalDate(parsed.iso), minDate, maxDate));
394
+ }
395
+ setOpen(true);
396
+ };
85
397
 
86
- const trigger = (
87
- <button
88
- {...rest}
89
- // guardrails-ignore no-raw-action-button: presentational popover/drawer trigger for
90
- // the calendar (opens a picker, doesn't mutate) — styled as a field via
91
- // fieldControlClass and needs to be a plain <button> to work as both the Popover
92
- // and Drawer trigger.
93
- type='button'
94
- id={id}
95
- name={name}
96
- disabled={disabled}
97
- aria-invalid={ariaInvalid}
98
- aria-describedby={ariaDescribedBy}
99
- aria-haspopup='dialog'
100
- aria-expanded={open}
101
- aria-label={selectedDate ? triggerLabel : placeholder}
102
- onClick={() => setOpen(true)}
103
- className={cn(RAW_BUTTON,
104
- fieldControlClass(size),
105
- 'flex cursor-pointer items-center justify-between text-left',
106
- !selectedDate && 'text-field-placeholder',
107
- disabled && 'cursor-not-allowed',
108
- )}
109
- >
110
- <span>{triggerLabel}</span>
111
- <Calendar className='h-4 w-4 shrink-0 opacity-50' />
112
- </button>
398
+ const invalid = ariaInvalid || error !== null ? true : undefined;
399
+ const describedBy = [ariaDescribedBy, error ? errorId : undefined].filter(Boolean).join(' ');
400
+
401
+ const field = (
402
+ <div ref={setAnchor} className='relative w-full'>
403
+ <Input
404
+ {...rest}
405
+ type='text'
406
+ maxLength={64}
407
+ size={size}
408
+ id={id}
409
+ name={name}
410
+ disabled={disabled}
411
+ autoComplete='off'
412
+ spellCheck={false}
413
+ value={inputText}
414
+ placeholder={placeholder}
415
+ aria-invalid={invalid}
416
+ aria-describedby={describedBy || undefined}
417
+ onFocus={(e) => e.currentTarget.select()}
418
+ onChange={(e) => {
419
+ setDraft(e.target.value);
420
+ setError(null);
421
+ }}
422
+ onKeyDown={(e) => {
423
+ if (e.key === 'Enter') {
424
+ e.preventDefault();
425
+ commitDraft();
426
+ } else if (e.key === 'Escape' && (draft !== null || error !== null)) {
427
+ e.preventDefault();
428
+ e.stopPropagation();
429
+ setDraft(null);
430
+ setError(null);
431
+ } else if (e.key === 'ArrowDown' && e.altKey) {
432
+ e.preventDefault();
433
+ openCalendar();
434
+ }
435
+ }}
436
+ onBlur={(e) => {
437
+ commitDraft();
438
+ // Focus moving to the calendar button (or into its panel) is not the field being left.
439
+ const next = e.relatedTarget as Node | null;
440
+ if (!open && !(next && anchor?.contains(next))) onBlur?.();
441
+ }}
442
+ className='pr-11 pointer-coarse:pr-12'
443
+ />
444
+ <IconButton
445
+ size={size}
446
+ label={selectedDate ? `Change date, ${display}` : placeholder}
447
+ disabled={disabled}
448
+ aria-haspopup='dialog'
449
+ aria-expanded={open}
450
+ onClick={() => (open ? handleOpenChange(false) : openCalendar())}
451
+ className='absolute right-0 top-0 text-muted-foreground hover:text-foreground'
452
+ >
453
+ <Calendar className='opacity-70' />
454
+ </IconButton>
455
+ </div>
113
456
  );
114
457
 
458
+ const message = error ? (
459
+ <p id={errorId} role='alert' className='mt-1 text-xs text-field-error'>
460
+ {error}
461
+ </p>
462
+ ) : null;
463
+
115
464
  const calendar = (
116
- <DayPicker
117
- mode='single'
465
+ <DatePickerCalendar
118
466
  selected={selectedDate}
467
+ month={month}
468
+ onMonthChange={setMonth}
119
469
  onSelect={handleSelect}
120
- defaultMonth={defaultMonth}
121
- classNames={DAY_PICKER_CLASS_NAMES}
122
- components={{ DayButton: ThemedDayButton, Chevron: DayPickerChevron as never }}
123
- disabled={disabledMatchers.length > 0 ? disabledMatchers : undefined}
124
- startMonth={min ? isoToLocalDate(min) : undefined}
125
- endMonth={max ? isoToLocalDate(max) : undefined}
470
+ minDate={minDate}
471
+ maxDate={maxDate}
126
472
  />
127
473
  );
128
474
 
129
475
  if (isMobile) {
130
476
  return (
131
- <>
132
- {trigger}
477
+ <div className='w-full'>
478
+ {field}
479
+ {message}
133
480
  <Drawer open={open} onClose={() => handleOpenChange(false)} side='bottom' size='md'>
134
481
  <div className='flex justify-center py-2'>{calendar}</div>
135
482
  </Drawer>
136
- </>
483
+ </div>
137
484
  );
138
485
  }
139
486
 
140
487
  return (
141
- <Popover
142
- trigger={trigger}
143
- open={open}
144
- onOpenChange={handleOpenChange}
145
- align='start'
146
- arrow={false}
147
- width={280}
148
- >
149
- {calendar}
150
- </Popover>
488
+ <div className='w-full'>
489
+ {field}
490
+ {message}
491
+ <Popover
492
+ anchor={anchor}
493
+ open={open}
494
+ onOpenChange={handleOpenChange}
495
+ align='start'
496
+ arrow={false}
497
+ // 🔴 Wide enough for the longest caption beside its four steppers: at 296 "September 2026"
498
+ // truncated to "September 20…" (photos' lightbox, forge task 3764). 320 still fits a 390px phone.
499
+ width={320}
500
+ >
501
+ {calendar}
502
+ </Popover>
503
+ </div>
151
504
  );
152
505
  }
153
506
 
@@ -46,6 +46,35 @@ describe('InlineEditableText', () => {
46
46
  expect(getByText('Hello')).toBeDefined();
47
47
  });
48
48
 
49
+ test("selects the whole value by default, so typing replaces a title", () => {
50
+ const { getByRole } = render(<InlineEditableText value='Hello' onChange={() => {}} />);
51
+ fireEvent.click(getByRole('button'));
52
+ const input = getByRole('textbox') as HTMLInputElement;
53
+ expect([input.selectionStart, input.selectionEnd]).toEqual([0, 5]);
54
+ });
55
+
56
+ test("caret='end' puts the caret after the text and selects nothing, so a keystroke cannot replace paragraphs", () => {
57
+ const notes = 'First paragraph.\n\nSecond paragraph.';
58
+ const { getByRole } = render(
59
+ <InlineEditableText multiline caret='end' value={notes} onChange={() => {}} />,
60
+ );
61
+ fireEvent.click(getByRole('button'));
62
+ const area = getByRole('textbox') as HTMLTextAreaElement;
63
+ expect([area.selectionStart, area.selectionEnd]).toEqual([notes.length, notes.length]);
64
+ });
65
+
66
+ test('maxLength caps the editor (default 10,000)', () => {
67
+ const editorOf = (maxLength?: number) => {
68
+ const view = render(<InlineEditableText multiline maxLength={maxLength} value='x' onChange={() => {}} />);
69
+ fireEvent.click(view.getByRole('button'));
70
+ const cap = (view.getByRole('textbox') as HTMLTextAreaElement).maxLength;
71
+ cleanup();
72
+ return cap;
73
+ };
74
+ expect(editorOf()).toBe(10_000);
75
+ expect(editorOf(5000)).toBe(5000);
76
+ });
77
+
49
78
  test('read-only mode renders plain text and never an editor', () => {
50
79
  const { queryByRole, getByText } = render(
51
80
  <InlineEditableText value='Locked' canEdit={false} onChange={() => {}} />,
@@ -37,6 +37,16 @@ export interface InlineEditableTextProps {
37
37
  * edit needs a separate, explicit affordance.
38
38
  */
39
39
  trigger?: 'text' | 'icon';
40
+ /**
41
+ * Where the caret lands when editing starts (default `'select-all'`):
42
+ * - `'select-all'` — the whole value is selected, so typing replaces it (a title, a name).
43
+ * - `'end'` — the caret goes after the last character and nothing is selected; for
44
+ * paragraphs someone ADDS to (notes, a description), where one stray keystroke
45
+ * would otherwise replace everything they wrote.
46
+ */
47
+ caret?: 'select-all' | 'end';
48
+ /** The editor's character ceiling (default 10,000) — the server's limit, so the editor stops where a save would be refused. */
49
+ maxLength?: number;
40
50
  ariaLabel?: string;
41
51
  }
42
52
 
@@ -46,7 +56,7 @@ export interface InlineEditableTextProps {
46
56
  * primitive, used anywhere a title/heading should be edited in place. Fully
47
57
  * token-driven (accent border + ring on the editor); the editor focuses and
48
58
  * selects its text on mount so typing replaces (and the cursor is never stranded
49
- * at position 0).
59
+ * at position 0) — or, with `caret='end'`, puts the caret after the text.
50
60
  */
51
61
  export function InlineEditableText({
52
62
  value,
@@ -58,6 +68,8 @@ export function InlineEditableText({
58
68
  inputClassName,
59
69
  trigger = 'text',
60
70
  wrap = true,
71
+ caret = 'select-all',
72
+ maxLength = 10_000,
61
73
  ariaLabel,
62
74
  ...rest
63
75
  }: InlineEditableTextProps) {
@@ -81,15 +93,19 @@ export function InlineEditableText({
81
93
  setEditing(false);
82
94
  };
83
95
 
84
- // Focus + select-all as soon as the editor mounts (it only renders while editing).
85
- // MUST be identity-stable: an inline ref callback gets detached/re-attached on every
96
+ // Focus + select-all (or caret-at-end) as soon as the editor mounts (it only renders while
97
+ // editing). MUST be identity-stable: an inline ref callback gets detached/re-attached on every
86
98
  // render, so each keystroke's setDraft re-render would re-run el.select() and the next
87
99
  // character would replace everything typed so far (one-character-survives bug).
88
- const focusOnMount = useCallback((el: HTMLInputElement | HTMLTextAreaElement | null) => {
89
- if (!el) return;
90
- el.focus();
91
- el.select();
92
- }, []);
100
+ const focusOnMount = useCallback(
101
+ (el: HTMLInputElement | HTMLTextAreaElement | null) => {
102
+ if (!el) return;
103
+ el.focus();
104
+ if (caret === 'end') el.setSelectionRange(el.value.length, el.value.length);
105
+ else el.select();
106
+ },
107
+ [caret],
108
+ );
93
109
 
94
110
  if (editing) {
95
111
  const editorClass = cn(
@@ -104,7 +120,7 @@ export function InlineEditableText({
104
120
  ref={focusOnMount}
105
121
  aria-label={ariaLabel}
106
122
  className={cn(editorClass, 'resize-none')}
107
- maxLength={10_000}
123
+ maxLength={maxLength}
108
124
  value={draft}
109
125
  onChange={(e) => setDraft(e.target.value)}
110
126
  onBlur={commit}
@@ -125,7 +141,7 @@ export function InlineEditableText({
125
141
  ref={focusOnMount}
126
142
  aria-label={ariaLabel}
127
143
  className={editorClass}
128
- maxLength={10_000}
144
+ maxLength={maxLength}
129
145
  value={draft}
130
146
  onChange={(e) => setDraft(e.target.value)}
131
147
  onBlur={commit}
@@ -3,7 +3,7 @@
3
3
  import { Autocomplete } from '@base-ui/react/autocomplete';
4
4
  import type { ReactNode } from 'react';
5
5
  import { cn } from '../lib/cn.js';
6
- import { FLOATING_COLLISION_PADDING,
6
+ import { BASE_UI_POSITIONER, FLOATING_COLLISION_PADDING,
7
7
  FLOATING_CONTENT_BASE, FLOATING_ITEM_BASE } from '../lib/floatingStyles.js';
8
8
 
9
9
  /**
@@ -32,7 +32,7 @@ export function SuggestionList<S>({
32
32
  <Autocomplete.Positioner
33
33
  sideOffset={4}
34
34
  collisionPadding={FLOATING_COLLISION_PADDING}
35
- className='z-[var(--z-popover)]'
35
+ className={BASE_UI_POSITIONER}
36
36
  >
37
37
  <Autocomplete.Popup
38
38
  className={cn(