Files
workspace/docs/2026-05-14-task-form-dialog-kbd-design.md
Roberto e1d15b3edd docs: add task form dialog keyboard polish design
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>
2026-05-14 10:47:57 +02:00

11 KiB
Raw Blame History

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:

  1. Header styleDialogTitle + DialogDescription (no separator border), matching AddEventDialog.
  2. 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.
  3. Date + time via keyboard — replace the Calendar + 2× hour/minute Select triplet with a typeable DateField that supports an optional HH:MM suffix and respects FormatPrefs.dateFormat.

Non-goals

  • Migrating TaskFormDialog to a Sheet (deferred — see docs/2026-05-08-task-ux-evolution-plan.md).
  • Touching NewTaskDialog / EditTaskDialog wrappers (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 have tabindex={-1}. Default focused pill = first (Project).
  • Tab / Shift+Tab enters/exits the group as a single stop. Inside the group, Tab exits 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.
  • Enter or Space on 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" (or menuitem) and roving tabIndex (active item = 0, rest -1).
  • When popover opens, focus moves to the currently-selected item (or first item).
  • ArrowDown / ArrowUp move the active item; Home/End jump to ends.
  • Enter / Space selects 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. Esc closes and returns focus to the pill.
  • Tab inside 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 Time row under the Calendar — two Selects (hour 0023, minute in 5-min steps) identical to the current TaskFormDialog implementation. They edit the time portion of the committed Date.
  • Display value after commit: <date in FormatPrefs.dateFormat> HH:MM when 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 (023 / 059), strip the suffix, parse remaining string with the existing logic, then set hours and minutes on 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. The DateField inside the popover is just an Input + Calendar, no nested Popover. Recommended.
  • (B) DateField replaces 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 Calendar inline (no internal Popover wrapper),
  • 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" with aria-label={t('tasks.properties')}; pills are <button> with descriptive aria-label (e.g. Project: Acme · Communications).
  • Listbox popovers: container role="listbox", items role="option", aria-selected on the chosen one. Single-select popovers also set aria-activedescendant on the listbox when convenient; otherwise rely on .focus().
  • Multi-select Assignees uses aria-multiselectable="true".
  • DateField keeps existing aria-invalid + aria-describedby semantics.

6. Out-of-scope follow-ups

  • Project popover inline-create flow keyboard polish (currently a sub-form inside the popover — separate effort).
  • DateField natural-language time keywords (e.g. tomorrow 9am) — only HH:MM accepted.
  • Migrating TaskFormDialog shell to a Sheet — already deferred.

7. Implementation order (suggested)

  1. useRovingFocus + useListboxKeys hooks (no UI changes).
  2. parseDate time-suffix support; refresh existing parseDate tests.
  3. DateField withTime prop + time Selects in Popover.
  4. 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 ⌘+Enter hint.
  5. i18n strings in all five languages.