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:
201
docs/2026-05-14-task-form-dialog-kbd-design.md
Normal file
201
docs/2026-05-14-task-form-dialog-kbd-design.md
Normal 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 00–23, 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` (`0–23` / `0–59`), 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.
|
||||
Reference in New Issue
Block a user