Spec for porting AddEventDialog UX patterns into TaskFormDialog: new header (title + description, no separator), full keyboard navigation (roving focus on pills, arrow nav, Enter to open popover, arrow nav inside list popovers and calendar), and date+time keyboard entry via extended DateField (withTime + flat props) and parseDate time suffix. Includes interactive HTML mockup demonstrating the keyboard flow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
11 KiB
Task Form Dialog — keyboard + header polish — Design
Date: 2026-05-14
Scope: adiuvAI renderer (src/renderer/components/tasks/TaskFormDialog.tsx) + supporting libs
Status: Approved by user (mockup at docs/mockups/2026-05-14-task-form-dialog-mockup.html), ready for implementation plan
Goal
Port three UX features shipped in the timeline batch-add AddEventDialog (docs/2026-05-08-task-ux-evolution-design.md § timeline batch) into TaskFormDialog:
- Header style —
DialogTitle+DialogDescription(no separator border), matchingAddEventDialog. - Full keyboard navigation — Tab/Shift-Tab between fields & pills, arrow keys within pills row, Enter to open focused pill, arrow keys inside list popovers + calendar, Esc to close popover.
- Date + time via keyboard — replace the Calendar + 2× hour/minute
Selecttriplet with a typeableDateFieldthat supports an optionalHH:MMsuffix and respectsFormatPrefs.dateFormat.
Non-goals
- Migrating
TaskFormDialogto a Sheet (deferred — seedocs/2026-05-08-task-ux-evolution-plan.md). - Touching
NewTaskDialog/EditTaskDialogwrappers (no behavior change). - Changes to other property popovers' rendering beyond keyboard handling.
- Inline project creation flow (
InlineProjectForm) — unchanged.
1. Header
Replace the current minimal header:
<DialogHeader className="px-5 py-3 border-b border-border/40">
<DialogTitle className="text-sm font-medium">{...}</DialogTitle>
</DialogHeader>
with the AddEventDialog style:
<DialogHeader>
<DialogTitle>{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}</DialogTitle>
<DialogDescription>
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
</DialogDescription>
</DialogHeader>
No border-bottom under the header — body flows directly under it. Keep the existing bg-card/92 backdrop-blur-xl overlay on DialogContent.
New i18n keys (all 5 languages): tasks.newTaskDescription, tasks.editTaskDescription.
2. Keyboard navigation
Pills row — roving focus + arrow movement
The property pills (Project · Priority · Status · Due · Assignees) become a roving-tabindex group:
- Only one pill at a time has
tabindex={0}; the rest havetabindex={-1}. Default focused pill = first (Project). Tab/Shift+Tabenters/exits the group as a single stop. Inside the group,Tabexits forward to the footer; on entry from the footer-side, focus restores to the last-focused pill.ArrowRight/ArrowDown→ next pill (clamped at end).ArrowLeft/ArrowUp→ previous pill (clamped at start).Home/End→ first / last pill.EnterorSpaceon focused pill → open its popover.
Implementation: a small hook useRovingFocus(ref, count) returning (index) => { tabIndex, onKeyDown, onFocus }. Pills consume it inside PropertyPill (kept presentational) via a wrapping <button>.
PropertyPill is already a <button>-ish trigger via <span>. To support real focus rings + key events, change the trigger element rendered by PopoverTrigger asChild from <span> to a <button type="button">. Visible focus ring matches --ring via focus-visible:ring-2 ring-ring/30.
List popovers — Project, Priority, Status, Assignees
shadcn Popover does not provide list semantics. Inside each popover content:
- Items render with
role="option"(ormenuitem) and rovingtabIndex(active item =0, rest-1). - When popover opens, focus moves to the currently-selected item (or first item).
ArrowDown/ArrowUpmove the active item;Home/Endjump to ends.Enter/Spaceselects the active item.- For single-select popovers (Project, Priority, Status) selection closes the popover and returns focus to the originating pill.
- For multi-select (Assignees) selection toggles; popover stays open.
Esccloses and returns focus to the pill. Tabinside a popover closes the popover (focus returns to pill, then the next Tab advances normally).
Implementation: a single shared hook useListboxKeys(items, opts) consumed by each popover content. Items are sourced from existing data (projectsList, knownAssignees, hard-coded priority/status arrays).
Calendar — keyboard
The shadcn Calendar already supports arrow-key day navigation and Enter to select (via react-day-picker). We need only to confirm that focus lands on the calendar grid when the Due popover opens. The new DateField (§3) replaces the current Popover+Calendar+Selects assembly and embeds the calendar.
Description — Enter
Keep existing behavior: Enter inserts a newline in the description textarea. The form-level ⌘/Ctrl+Enter submit handler already lives on the <form> element and continues to work; the footer's "⌘+Enter to create" hint is removed from the UI (the shortcut still works).
3. Date + time via keyboard
Strategy — extend existing DateField
Reuse src/renderer/components/ui/date-field.tsx (already typeable, format-aware via useFormatPrefs, with embedded Calendar). Add optional time support behind a new prop withTime?: boolean.
When withTime is on:
- The text input accepts either a bare date (
30/04/2026,Apr 30,+3d,tomorrow, …) or date-with-time suffix (30/04/2026 14:30). - The Popover content gains a small
Timerow under the Calendar — twoSelects (hour 00–23, minute in 5-min steps) identical to the current TaskFormDialog implementation. They edit the time portion of the committedDate. - Display value after commit:
<date in FormatPrefs.dateFormat> HH:MMwhen time component is non-midnight, otherwise just the date.
Parser extension (lib/parseDate.ts)
parseDate(input, prefs, keywords) adopts optional trailing time:
- Regex split:
RE_TIME = /\s+(\d{1,2}):(\d{2})\s*$/. - If matched, parse
HH/MM(0–23/0–59), strip the suffix, parse remaining string with the existing logic, then sethoursandminuteson the result. - If time match is invalid (e.g.
25:99), whole input is invalid.
Unit-test cases (existing tests if any get extended; otherwise small new file):
| Input | Format pref | Expected |
|---|---|---|
30/04/2026 14:30 |
dd/MM/yyyy |
2026-04-30 14:30 local |
04/30/2026 09:00 |
MM/dd/yyyy |
2026-04-30 09:00 |
2026-04-30 23:59 |
yyyy-MM-dd |
2026-04-30 23:59 |
tomorrow 08:15 |
any | next-day 08:15 |
30/04/2026 25:00 |
any | invalid |
30/04/2026 |
dd/MM | 2026-04-30 00:00 (date only, time unchanged) |
Caller change in TaskFormDialog
The whole Due Popover block (Calendar + hour/minute Selects + clear button) is replaced by:
<DateField
withTime
value={values.dueDate ? new Date(values.dueDate) : undefined}
onChange={(d) => setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }))}
placeholder={t('tasks.colDue')}
aria-label={t('tasks.colDue')}
/>
The pill itself remains for display when the field is collapsed. Two arrangements considered:
- (A) Pill opens a popover containing the
DateField— keeps visual parity with the other pills. TheDateFieldinside the popover is just anInput+ Calendar, no nested Popover. Recommended. - (B)
DateFieldreplaces the pill inline in the row — visually breaks the pill row.
Going with (A). To avoid a nested-popover (Popover inside PopoverContent), DateField gains a flat?: boolean prop. When flat is set, it renders:
- the typeable
Input, - the
Calendarinline (no internalPopoverwrapper), - the Time row (when
withTime).
The Due pill's PopoverContent renders <DateField withTime flat />. Outside the task dialog, existing callers (e.g. AddEventDialog) keep using the default (non-flat) DateField with its own popover trigger.
The Due popover content:
┌─ Due popover ───────────────────┐
│ [📅 30/04/2026 14:30 ] │ ← typeable Input (parses date + time)
│ Calendar grid (kbd nav) │
│ ── ── ── ── ── ── ── ── ── ── │
│ Time: [HH ⌄] : [MM ⌄] [Clear] │ ← shown only when withTime
└─────────────────────────────────┘
(The mockup illustrated standalone segments; that was a sketch — the real impl reuses DateField's single-input typeable parser, which is already keyboard-driven via parseDate.)
4. Files
Modified:
src/renderer/components/tasks/TaskFormDialog.tsx — new header; roving focus on pills row; replace Due popover with <DateField withTime />; drop the "⌘+Enter" hint
src/renderer/components/ui/date-field.tsx — new props withTime + flat; Time Selects; expanded onCommit/text-display logic
src/renderer/lib/parseDate.ts — accept optional trailing " HH:MM"
src/renderer/locales/{en,it,es,fr,de}/translation.json
— add tasks.newTaskDescription, tasks.editTaskDescription
New (small, kept local to features):
src/renderer/hooks/useRovingFocus.ts — generic roving-tabindex hook
src/renderer/hooks/useListboxKeys.ts — popover-list arrow/enter/esc handler
If a unit-test setup is later introduced for parseDate, add cases there. Not blocking.
5. Accessibility
- Pills row:
role="toolbar"witharia-label={t('tasks.properties')}; pills are<button>with descriptivearia-label(e.g.Project: Acme · Communications). - Listbox popovers: container
role="listbox", itemsrole="option",aria-selectedon the chosen one. Single-select popovers also setaria-activedescendanton the listbox when convenient; otherwise rely on.focus(). - Multi-select Assignees uses
aria-multiselectable="true". DateFieldkeeps existingaria-invalid+aria-describedbysemantics.
6. Out-of-scope follow-ups
- Project popover inline-create flow keyboard polish (currently a sub-form inside the popover — separate effort).
DateFieldnatural-language time keywords (e.g.tomorrow 9am) — onlyHH:MMaccepted.- Migrating
TaskFormDialogshell to a Sheet — already deferred.
7. Implementation order (suggested)
useRovingFocus+useListboxKeyshooks (no UI changes).parseDatetime-suffix support; refresh existing parseDate tests.DateFieldwithTimeprop + time Selects in Popover.TaskFormDialog:- Header swap (Title + Description, no border).
- Pills row wired to
useRovingFocus; pill trigger element switched to<button>. - Each list popover wired to
useListboxKeys. - Due popover content replaced by
<DateField withTime />. - Remove footer
⌘+Enterhint.
- i18n strings in all five languages.