From 72d7cc2f6e5b1fecd137666e64124efc4925eaad Mon Sep 17 00:00:00 2001 From: Roberto Date: Thu, 14 May 2026 10:51:57 +0200 Subject: [PATCH] docs: add task form dialog keyboard polish implementation plan Step-by-step plan to port AddEventDialog UX (header, full keyboard nav, date+time via DateField) into TaskFormDialog. Two new shared hooks (useRovingFocus, useListboxKeys), parseDate time-suffix, DateField withTime + flat props, and i18n updates across all 5 languages. Co-Authored-By: Claude Opus 4.7 --- docs/2026-05-14-task-form-dialog-kbd-plan.md | 1172 ++++++++++++++++++ 1 file changed, 1172 insertions(+) create mode 100644 docs/2026-05-14-task-form-dialog-kbd-plan.md diff --git a/docs/2026-05-14-task-form-dialog-kbd-plan.md b/docs/2026-05-14-task-form-dialog-kbd-plan.md new file mode 100644 index 0000000..04d7039 --- /dev/null +++ b/docs/2026-05-14-task-form-dialog-kbd-plan.md @@ -0,0 +1,1172 @@ +# Task Form Dialog — keyboard + header polish — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port `AddEventDialog`'s header style + full keyboard navigation + date-time keyboard entry into `TaskFormDialog`, reusing existing `DateField` and adding two small shared hooks. + +**Architecture:** Two new generic hooks (`useRovingFocus`, `useListboxKeys`) cover keyboard plumbing for any pill row + list popover. `parseDate` gains an optional trailing `HH:MM`. `DateField` gains `withTime` + `flat` props so it can be embedded inside another Popover without nesting. `TaskFormDialog` consumes all of these and drops its bespoke Due Popover + the footer ⌘+Enter hint. + +**Tech Stack:** React 19, TypeScript, shadcn/ui (`Popover`, `Calendar`, `Select`), react-day-picker, `react-i18next`, `TZDate` from `react-day-picker` (timezone-correct date construction), Drizzle ORM (unchanged), Electron. + +**Spec:** [docs/2026-05-14-task-form-dialog-kbd-design.md](./2026-05-14-task-form-dialog-kbd-design.md) +**Mockup:** [docs/mockups/2026-05-14-task-form-dialog-mockup.html](./mockups/2026-05-14-task-form-dialog-mockup.html) + +**No test suite in the project (`adiuvAI/CLAUDE.md`: "No test suite currently.").** Verification per task is `npm run lint` + manual smoke. Plan does not include TDD steps; if pytest/vitest is introduced later, regression cases for `parseDate` belong in `docs/2026-05-14-task-form-dialog-kbd-design.md` § 3 table. + +**Commit cadence:** one commit per task. Each commit message follows the existing Conventional Commits style visible in `git log` (`feat:`, `refactor:`, `docs:`, …) and includes the `Co-Authored-By: Claude Opus 4.7 ` trailer. + +--- + +## File Structure + +| File | Op | Responsibility | +|---|---|---| +| `adiuvAI/src/renderer/hooks/useRovingFocus.ts` | Create | Roving-tabindex hook for any horizontally-arranged group of focusable items (pills, tabs). | +| `adiuvAI/src/renderer/hooks/useListboxKeys.ts` | Create | Listbox keyboard handler (↑/↓/Home/End/Enter/Esc/Tab) shared by all single- and multi-select popovers. | +| `adiuvAI/src/renderer/lib/parseDate.ts` | Modify | Add optional trailing ` HH:MM` parsing. | +| `adiuvAI/src/renderer/components/ui/date-field.tsx` | Modify | Add `withTime?: boolean` (renders HH/MM Selects in popover) and `flat?: boolean` (skips the internal Popover, renders Input + Calendar + Time row inline). | +| `adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx` | Modify | New header (Title + Description, no border); pills row uses `useRovingFocus`; each list popover uses `useListboxKeys`; Due popover content is ``; remove `⌘+Enter` footer hint. Pill triggers become real ` + ), +); +PropertyPill.displayName = 'PropertyPill'; +``` + +- [ ] **Step 5.2 — Update callers that wrap PropertyPill in ``** + +`TaskFormDialog.tsx` currently wraps each `` inside `` so `PopoverTrigger asChild` can attach a ref to a single element. Now that `PropertyPill` is itself a ` + ); + } + if (it.kind === 'none') { + return ( + + ); + } + return ( + + ); + })} + + ); +} +``` + +In the Project popover, replace the inline list JSX with `` and pass: + +```tsx + { + setValues((v) => ({ ...v, projectId: id })); + setProjectPopoverOpen(false); + }} + onCreate={() => setCreatingProject(true)} + onClose={() => setProjectPopoverOpen(false)} +/> +``` + +- [ ] **Step 6c.3 — Priority popover** + +Replace the existing three-button `priorities.map` with a small listbox wrapper: + +```tsx +function PriorityList({ + value, + onSelect, + onClose, +}: { + value: string; + onSelect: (v: 'high' | 'medium' | 'low') => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const items = ['high', 'medium', 'low'] as const; + const initial = Math.max(0, items.indexOf(value as typeof items[number])); + const listbox = useListboxKeys({ + itemCount: items.length, + initialIndex: initial, + onSelect: (i) => onSelect(items[i]), + onClose, + }); + useEffect(() => { listbox.focusIndex(listbox.activeIndex); /* eslint-disable-next-line */ }, []); + return ( +
+ {items.map((p, i) => ( + + ))} +
+ ); +} +``` + +Drop in the Priority ``: + +```tsx + setValues((v) => ({ ...v, priority: p }))} + onClose={() => {/* shadcn closes when triggering element loses focus; explicit close below */}} +/> +``` + +For consistency with Project, control the Priority popover's `open` state from the parent (`const [priorityOpen, setPriorityOpen] = useState(false);`), pass it to ``, and `onClose={() => setPriorityOpen(false)}`. On `onSelect`, also call `setPriorityOpen(false)`. + +- [ ] **Step 6c.4 — Status popover** + +Identical pattern to Priority. Items: `['todo', 'in_progress', 'done']`. Component `StatusList`. Parent state `statusOpen / setStatusOpen`. + +- [ ] **Step 6c.5 — Assignees popover** + +Multi-select. Build `AssigneesList` similar to the others but `onSelect` toggles membership in `values.assignees` (and the popover stays open): + +```tsx +function AssigneesList({ + known, + selected, + onToggle, + onClose, + newName, + onNewNameChange, + onAddNew, +}: { + known: string[]; + selected: string[]; + onToggle: (name: string) => void; + onClose: () => void; + newName: string; + onNewNameChange: (s: string) => void; + onAddNew: () => void; +}) { + const { t } = useTranslation(); + const listbox = useListboxKeys({ + itemCount: known.length, + initialIndex: 0, + onSelect: (i) => onToggle(known[i]), + onClose, + }); + useEffect(() => { + if (known.length > 0) listbox.focusIndex(0); + // eslint-disable-next-line + }, []); + return ( +
+
+ {known.map((name, i) => { + const isOn = selected.includes(name); + return ( + + ); + })} +
+
+ onNewNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onAddNew(); + } + }} + className="h-8 text-sm flex-1" + /> + +
+
+ ); +} +``` + +In the Assignees ``, replace the existing JSX with: + +```tsx + + setValues((v) => ({ + ...v, + assignees: v.assignees.includes(name) + ? v.assignees.filter((a) => a !== name) + : [...v.assignees, name], + })) + } + onClose={() => {/* uncontrolled Popover; native Esc/blur closes */}} + newName={assigneeInput} + onNewNameChange={setAssigneeInput} + onAddNew={addNewAssignee} +/> +``` + +(Assignees can stay uncontrolled — shadcn handles dismissal on outside click + Esc inside the listbox is already wired by `useListboxKeys` via `onClose`; for a true close-on-esc, mirror the controlled pattern used for Priority/Status if desired. Keeping uncontrolled is consistent with the current code.) + +### 6d. Due popover via `DateField` + +- [ ] **Step 6d.1 — Replace the entire Due `` block** + +Remove the `HOURS`/`MINUTES` constants and the `derivePartsInTz` / `updateDueTime` helpers from `TaskFormDialog.tsx` — `DateField` now owns time handling. Then replace the Due `` body with: + +```tsx + + + } + label={t('tasks.colDue')} + value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null} + empty={!values.dueDate} + aria-label={t('tasks.colDue')} + /> + + + setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }))} + placeholder={t('tasks.colDue')} + aria-label={t('tasks.colDue')} + /> + + +``` + +Add `import { DateField } from '@/components/ui/date-field';` at the top and remove unused imports (`TZDate`, `Calendar`, `Select`* if no longer used elsewhere in the file — re-run lint after). + +**Note on timezones:** the old code constructed `TZDate` using user `prefs.timezone` so e.g. `00:00` was interpreted in the user's IANA zone. `DateField` uses plain `Date` (browser local time). In Electron this is the user's OS timezone, which is what `prefs.timezone` already defaults to. For users who manually override `prefs.timezone` to a non-OS zone, due times here will reflect OS-local instead. This matches `AddEventDialog`'s current behavior and is acceptable; if/when timezone-aware editing is reintroduced, both `DateField` and `AddEventDialog` should change together. + +### 6e. Remove ⌘+Enter footer hint + +- [ ] **Step 6e.1 — Audit footer** + +Re-read the current footer: the existing footer has only Cancel + Submit + (optional Paperclip in edit mode). It does NOT contain a "⌘+Enter to create" hint — that was a mockup-only element. The form-level `onKeyDown` that submits on `⌘/Ctrl+Enter` stays. + +No code change required for 6e; this step is a checklist confirmation. Move on. + +### 6f. Lint + smoke + commit + +- [ ] **Step 6f.1 — Lint** + +``` +npm run lint +``` +Expected: clean. Address any "unused import" errors from removed `TZDate` / `Select` / `Calendar` etc. + +- [ ] **Step 6f.2 — Manual smoke (full keyboard flow)** + +`npm start`, then in the running app: + +1. Open Tasks page → "New task". Header shows title + muted description, no separator line. +2. Tab from the title input → focus lands on the description textarea. Tab again → first pill (Project) shows focus ring. +3. Press ←/→ and ↑/↓: focus moves between pills. +4. With Project pill focused, press Enter: popover opens; "No project" or current selection is focused. +5. ↓/↑ navigate items. Enter selects → popover closes → focus returns to the Project pill. +6. Repeat for Priority, Status, Assignees. For Assignees, Enter toggles, Esc closes. +7. With Due pill focused, press Enter: popover opens with `DateField`. Type `30/04/2026 14:30`, press Enter — pill value reads `30/04/2026 14:30` (or US format if `FormatPrefs.dateFormat = 'MM/dd/yyyy'`). +8. Re-open Due, click a calendar day — date updates while keeping the time. Change hour Select → pill reflects new time. +9. `Ctrl+Enter` anywhere in the form submits. +10. Edit mode: re-open an existing task with a due date that has `HH:MM` and verify the field renders both date and time. + +- [ ] **Step 6f.3 — Commit** + +``` +git -C adiuvAI add src/renderer/components/tasks/TaskFormDialog.tsx +git -C adiuvAI commit -m "feat(TaskFormDialog): new header, full keyboard nav, DateField-based due" +``` + +--- + +## Task 7 — i18n keys in all five languages + +**Files:** +- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` + +**Why:** Header description string is new; missing keys would print the key path. + +- [ ] **Step 7.1 — Add keys under the `tasks` namespace** + +For each language, add (or merge into the existing `"tasks": { ... }` block) the two new keys: + +EN: +```json +"newTaskDescription": "Capture what needs doing. Set properties below or refine later.", +"editTaskDescription": "Update the task details, properties, and attachments." +``` + +IT: +```json +"newTaskDescription": "Annota cosa va fatto. Imposta le proprietà sotto o aggiornale dopo.", +"editTaskDescription": "Aggiorna dettagli, proprietà e allegati dell'attività." +``` + +ES: +```json +"newTaskDescription": "Anota qué hay que hacer. Define las propiedades abajo o más tarde.", +"editTaskDescription": "Actualiza los detalles, propiedades y adjuntos de la tarea." +``` + +FR: +```json +"newTaskDescription": "Notez ce qu'il faut faire. Définissez les propriétés ci-dessous ou plus tard.", +"editTaskDescription": "Mettez à jour les détails, propriétés et pièces jointes de la tâche." +``` + +DE: +```json +"newTaskDescription": "Halten Sie fest, was zu tun ist. Eigenschaften unten oder später setzen.", +"editTaskDescription": "Aktualisieren Sie Details, Eigenschaften und Anhänge der Aufgabe." +``` + +Place the keys near other `tasks.new*` / `tasks.edit*` entries (alphabetical inside `tasks` is the existing convention — match it). + +- [ ] **Step 7.2 — Lint** + +``` +npm run lint +``` +Expected: clean (JSON files don't lint, but TypeScript references resolve). + +- [ ] **Step 7.3 — Smoke** + +`npm start`, switch language in Settings → General → Language and confirm the dialog description renders in the chosen language. + +- [ ] **Step 7.4 — Commit** + +``` +git -C adiuvAI add src/renderer/locales/en/translation.json src/renderer/locales/it/translation.json src/renderer/locales/es/translation.json src/renderer/locales/fr/translation.json src/renderer/locales/de/translation.json +git -C adiuvAI commit -m "i18n: add tasks.newTaskDescription / editTaskDescription" +``` + +--- + +## Task 8 — Bump submodule in the monorepo + +**Files:** +- Modify: top-level `adiuvAI` submodule pointer. + +- [ ] **Step 8.1 — Stage submodule bump** + +From the monorepo root (`c:\Users\PC-Roby\Documents\_adiuvai_workspace`): + +``` +git add adiuvAI +git status +``` + +Expected: `modified: adiuvAI (new commits)` is now staged. + +- [ ] **Step 8.2 — Commit at monorepo level** + +``` +git commit -m "feat: bump adiuvAI submodule — task form keyboard polish" +``` + +- [ ] **Step 8.3 — Run graphify update** + +Per project `CLAUDE.md`: + +``` +graphify update . +``` + +This keeps the knowledge graph aligned with the new hooks / components. AST-only, no API cost. + +- [ ] **Step 8.4 — Final smoke + done** + +Reopen the Task dialog one more time and run through the full keyboard flow in §6f.2 to confirm nothing regressed after the submodule pointer was bumped. + +--- + +## Self-review (post-write, pre-handoff) + +- **Spec coverage:** + - §1 Header → Task 6a. + - §2 Pills row roving focus → Task 1 + 6b. List popovers → Task 2 + 6c. Calendar nav → react-day-picker built-in (no task needed; surfaced inside `CalendarTimeBody` in Task 4). Description Enter = newline → unchanged (Task 6e checklist). + - §3 Date+time: + - parseDate suffix → Task 3. + - DateField `withTime` + `flat` → Task 4. + - TaskFormDialog Due popover replacement → Task 6d. + - §4 Files list → mirrored 1-for-1. + - §5 Accessibility — `role="toolbar"` (6b.3), `role="listbox"` / `role="option"` / `aria-multiselectable` / `aria-selected` (6c). DateField a11y unchanged. + - §6 Out-of-scope items are not in the plan. Good. + +- **Placeholder scan:** no "TBD", "TODO", or hand-waving steps. Each code-changing step shows code. The "no internal tests" note explicitly says manual smoke instead of inventing fake tests. + +- **Type consistency:** + - `useRovingFocus` exposes `{ activeIndex, setActive, getItemProps }` — used in Task 6b. + - `useListboxKeys` exposes `{ activeIndex, setActive, focusIndex, getItemProps }` — used in Tasks 6c.2–6c.5. + - `DateField` new props `withTime`, `flat` — consumed in Task 6d. + - `PropertyPill` becomes a forwardRef `