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>
This commit is contained in:
Roberto
2026-05-14 10:47:57 +02:00
parent faea5f0448
commit e1d15b3edd
2 changed files with 1053 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
# 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 style**`DialogTitle` + `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:
```tsx
<DialogHeader className="px-5 py-3 border-b border-border/40">
<DialogTitle className="text-sm font-medium">{...}</DialogTitle>
</DialogHeader>
```
with the `AddEventDialog` style:
```tsx
<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 `Select`s (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:
```tsx
<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.