Files
workspace/docs/2026-05-14-task-form-dialog-kbd-plan.md
Roberto 72d7cc2f6e 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 <noreply@anthropic.com>
2026-05-14 10:51:57 +02:00

1173 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <noreply@anthropic.com>` 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 `<DateField withTime flat />`; remove `⌘+Enter` footer hint. Pill triggers become real `<button>` elements (not `<span>`). |
| `adiuvAI/src/renderer/components/tasks/PropertyPill.tsx` | Modify | Change rendered element from `<span>` to `<button type="button">`; add focus-visible ring; accept `tabIndex` + standard button refs/handlers. |
| `adiuvAI/src/renderer/locales/en/translation.json` | Modify | Add `tasks.newTaskDescription`, `tasks.editTaskDescription`. |
| `adiuvAI/src/renderer/locales/it/translation.json` | Modify | Same keys, IT. |
| `adiuvAI/src/renderer/locales/es/translation.json` | Modify | Same keys, ES. |
| `adiuvAI/src/renderer/locales/fr/translation.json` | Modify | Same keys, FR. |
| `adiuvAI/src/renderer/locales/de/translation.json` | Modify | Same keys, DE. |
The `adiuvAI` directory is a git submodule. All commits below are inside that submodule; the outer monorepo gets a `bump adiuvAI submodule` commit at the end.
---
## Task 1 — Hook: `useRovingFocus`
**Files:**
- Create: `adiuvAI/src/renderer/hooks/useRovingFocus.ts`
**Why:** Pills row needs single tab stop + ←/→/↑/↓ navigation between items. Generic, reusable.
- [ ] **Step 1.1 — Create the hook file**
```ts
// adiuvAI/src/renderer/hooks/useRovingFocus.ts
import { useCallback, useRef, useState } from 'react';
export type RovingDirection = 'horizontal' | 'vertical' | 'both';
export interface UseRovingFocusOptions {
count: number;
direction?: RovingDirection;
initialIndex?: number;
loop?: boolean;
}
export interface RovingItemProps {
tabIndex: number;
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus: () => void;
ref: (el: HTMLElement | null) => void;
}
/**
* Roving tabindex helper. Group exposes a single tab stop; arrow keys
* move focus between items. Index state is internal — call `getItemProps(i)`
* for each item to wire it up.
*/
export function useRovingFocus({
count,
direction = 'both',
initialIndex = 0,
loop = false,
}: UseRovingFocusOptions) {
const [active, setActive] = useState<number>(initialIndex);
const refs = useRef<Array<HTMLElement | null>>([]);
const move = useCallback(
(next: number) => {
let target = next;
if (loop) {
target = ((next % count) + count) % count;
} else {
target = Math.max(0, Math.min(count - 1, next));
}
setActive(target);
const el = refs.current[target];
if (el) el.focus();
},
[count, loop],
);
const getItemProps = useCallback(
(index: number): RovingItemProps => ({
tabIndex: index === active ? 0 : -1,
ref: (el) => {
refs.current[index] = el;
},
onFocus: () => setActive(index),
onKeyDown: (e: React.KeyboardEvent) => {
const horizontal = direction === 'horizontal' || direction === 'both';
const vertical = direction === 'vertical' || direction === 'both';
if (horizontal && e.key === 'ArrowRight') {
e.preventDefault();
move(index + 1);
} else if (horizontal && e.key === 'ArrowLeft') {
e.preventDefault();
move(index - 1);
} else if (vertical && e.key === 'ArrowDown') {
e.preventDefault();
move(index + 1);
} else if (vertical && e.key === 'ArrowUp') {
e.preventDefault();
move(index - 1);
} else if (e.key === 'Home') {
e.preventDefault();
move(0);
} else if (e.key === 'End') {
e.preventDefault();
move(count - 1);
}
},
}),
[active, count, direction, move],
);
return { activeIndex: active, setActive, getItemProps };
}
```
- [ ] **Step 1.2 — Lint**
Run inside `adiuvAI/`:
```
npm run lint
```
Expected: no new warnings/errors.
- [ ] **Step 1.3 — Commit**
```
git -C adiuvAI add src/renderer/hooks/useRovingFocus.ts
git -C adiuvAI commit -m "feat: add useRovingFocus hook for roving-tabindex groups"
```
(Include the standard `Co-Authored-By` trailer via heredoc as in `CLAUDE.md`.)
---
## Task 2 — Hook: `useListboxKeys`
**Files:**
- Create: `adiuvAI/src/renderer/hooks/useListboxKeys.ts`
**Why:** Project / Priority / Status / Assignees popovers all need the same arrow-nav + Enter-to-select keyboard model. Multi-select for Assignees differs only in selection callback.
- [ ] **Step 2.1 — Create the hook**
```ts
// adiuvAI/src/renderer/hooks/useListboxKeys.ts
import { useCallback, useEffect, useRef, useState } from 'react';
export interface UseListboxKeysOptions {
itemCount: number;
initialIndex?: number;
onSelect: (index: number) => void;
onClose: () => void;
/**
* If true, focus does NOT move out of the listbox on Tab; the caller is
* expected to also call onClose so the parent can advance focus.
*/
closeOnTab?: boolean;
}
export interface ListboxItemProps {
tabIndex: number;
ref: (el: HTMLElement | null) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onMouseEnter: () => void;
'aria-selected': boolean;
}
/**
* Listbox keyboard model used inside popovers: ↑/↓ move active item,
* Home/End jump to ends, Enter/Space selects, Esc closes, Tab closes
* (so the parent's tab order resumes from the trigger).
*/
export function useListboxKeys({
itemCount,
initialIndex = 0,
onSelect,
onClose,
closeOnTab = true,
}: UseListboxKeysOptions) {
const [active, setActive] = useState<number>(initialIndex);
const refs = useRef<Array<HTMLElement | null>>([]);
// Reset active when the item set changes size.
useEffect(() => {
if (active >= itemCount) setActive(Math.max(0, itemCount - 1));
}, [itemCount, active]);
const focusIndex = useCallback((i: number) => {
setActive(i);
const el = refs.current[i];
if (el) el.focus();
}, []);
const getItemProps = useCallback(
(index: number): ListboxItemProps => ({
tabIndex: index === active ? 0 : -1,
ref: (el) => {
refs.current[index] = el;
},
'aria-selected': index === active,
onMouseEnter: () => setActive(index),
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
focusIndex(Math.min(index + 1, itemCount - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
focusIndex(Math.max(index - 1, 0));
} else if (e.key === 'Home') {
e.preventDefault();
focusIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
focusIndex(itemCount - 1);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onSelect(index);
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
} else if (e.key === 'Tab' && closeOnTab) {
// Let the Tab proceed; but close so popover unmounts.
onClose();
}
},
}),
[active, itemCount, focusIndex, onSelect, onClose, closeOnTab],
);
return { activeIndex: active, setActive, focusIndex, getItemProps };
}
```
- [ ] **Step 2.2 — Lint**
```
npm run lint
```
Expected: clean.
- [ ] **Step 2.3 — Commit**
```
git -C adiuvAI add src/renderer/hooks/useListboxKeys.ts
git -C adiuvAI commit -m "feat: add useListboxKeys hook for popover list keyboard"
```
---
## Task 3 — `parseDate`: accept optional `HH:MM` suffix
**Files:**
- Modify: `adiuvAI/src/renderer/lib/parseDate.ts`
**Why:** `DateField` will route both date and time through a single typeable input. Parser must accept e.g. `30/04/2026 14:30` and `tomorrow 08:15`.
Read the current file first so the diff is correct (it lives at lines 180+; the public entry is the `parseDate` function near the bottom — confirm before editing).
- [ ] **Step 3.1 — Add regex constant**
In `parseDate.ts`, add near the top with the other regex constants:
```ts
const RE_TIME_SUFFIX = /\s+(\d{1,2}):(\d{2})\s*$/;
```
- [ ] **Step 3.2 — Strip + apply time inside `parseDate`**
Locate the `export function parseDate(input, prefs, keywords): Date | null` (current entrypoint that delegates to `parseRel`, `parseKeyword`, `parseNumeric`). Replace its body so it first peels off the time suffix, parses the remaining string, then applies hours/minutes to the result:
```ts
export function parseDate(
input: ParseInput,
prefs: FormatPrefs,
keywords: DateKeywords,
base: Date = new Date(),
): Date | null {
if (!input) return null;
const raw = input.trim();
if (!raw) return null;
let datePart = raw;
let hours: number | null = null;
let minutes: number | null = null;
const tm = raw.match(RE_TIME_SUFFIX);
if (tm) {
const h = parseInt(tm[1], 10);
const m = parseInt(tm[2], 10);
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
hours = h;
minutes = m;
datePart = raw.slice(0, raw.length - tm[0].length).trim();
if (!datePart) return null;
}
// Existing resolution order — keep identical to the current implementation.
const fromRel = parseRel(datePart, base);
const fromKw = fromRel ?? parseKeyword(datePart, keywords, base);
const fromNum = fromKw ?? parseNumeric(datePart, prefs, base);
const result = fromNum;
if (!result) return null;
if (hours != null && minutes != null) {
result.setHours(hours, minutes, 0, 0);
}
return result;
}
```
If the current source uses different helper names (e.g. `parseRelative` instead of `parseRel`), keep the existing names — only the time-suffix scaffolding is new. The Date returned by the date-only branch already represents midnight local; the `setHours` call only fires when a time suffix is present, so existing callers (timeline `DateField` without `withTime`) keep their current behavior.
- [ ] **Step 3.3 — Lint**
```
npm run lint
```
Expected: clean.
- [ ] **Step 3.4 — Manual smoke** (no automated tests in repo)
Open `adiuvAI/src/renderer/lib/parseDate.ts` and re-read the function to confirm: ` 14:30` suffix without leading date → returns null (because `datePart` is empty after slicing). `25:00` → null. Bare date still works.
- [ ] **Step 3.5 — Commit**
```
git -C adiuvAI add src/renderer/lib/parseDate.ts
git -C adiuvAI commit -m "feat(parseDate): accept optional ' HH:MM' suffix"
```
---
## Task 4 — `DateField`: add `withTime` and `flat` props
**Files:**
- Modify: `adiuvAI/src/renderer/components/ui/date-field.tsx`
**Why:** Reuse the existing typeable DateField (which already drives via `parseDate` + `FormatPrefs`). Add (a) time Selects in the popover and (b) a `flat` mode that drops the internal Popover so the caller can embed it inside its own popover without nesting.
Re-read the file first — the full source is short (~165 lines).
- [ ] **Step 4.1 — Extend `DateFieldProps`**
```ts
export type DateFieldProps = {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
onCommit?: (d: Date) => void;
placeholder?: string;
minDate?: Date;
autoFocus?: boolean;
invalidMessage?: string;
className?: string;
'aria-label'?: string;
id?: string;
/** Show hour/minute selects under the calendar; parseDate accepts ` HH:MM` suffix. */
withTime?: boolean;
/** Render Input + Calendar (+ Time) inline without an internal Popover. */
flat?: boolean;
};
```
- [ ] **Step 4.2 — Update display text when value changes**
The current effect formats the date via `formatDate(value.getTime(), prefs)`. When `withTime` is on, append `HH:MM` if the time component is non-zero:
```ts
useEffect(() => {
if (!focused) {
setText(value ? formatValue(value, prefs, withTime) : '');
setInvalid(false);
}
}, [value, focused, prefs, withTime]);
```
Add a local helper inside the file (or import from `lib/date.ts` if you'd rather extend it — but keeping it local avoids touching unrelated callers):
```ts
function formatValue(d: Date, prefs: FormatPrefs, withTime: boolean): string {
const base = formatDate(d.getTime(), prefs);
if (!withTime) return base;
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
if (h === '00' && m === '00') return base;
return `${base} ${h}:${m}`;
}
```
(`FormatPrefs` is already imported as `useFormatPrefs`; add `import type { FormatPrefs } from '@/lib/date';` if needed.)
- [ ] **Step 4.3 — Add Time row inside the calendar popover**
The current `<PopoverContent>` body is just `<Calendar mode="single" ... />`. Wrap it so a Time row appears below when `withTime` is set. Same pattern as `TaskFormDialog`'s current Hour/Minute Selects (HOURS = 24, MINUTES = 5-minute steps).
Add at the top of the file:
```ts
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
```
Extract the calendar+time block into a small inline component used by both `flat` and popover modes:
```tsx
function CalendarTimeBody({
value,
onChange,
onCommit,
prefs,
withTime,
minDate,
onAfterPick,
}: {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
onCommit?: (d: Date) => void;
prefs: FormatPrefs;
withTime: boolean;
minDate?: Date;
onAfterPick: () => void;
}) {
const dueHour = value ? String(value.getHours()).padStart(2, '0') : '';
const dueMinute = value ? String(value.getMinutes()).padStart(2, '0') : '';
function applyTime(h: string, m: string) {
if (!value) return;
const next = new Date(value);
next.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
onChange(next);
onCommit?.(next);
}
return (
<>
<Calendar
mode="single"
selected={value}
onSelect={(d) => {
if (d) {
// Preserve existing time when re-picking a date.
const next = new Date(d);
if (value && withTime) {
next.setHours(value.getHours(), value.getMinutes(), 0, 0);
}
onChange(next);
onCommit?.(next);
}
onAfterPick();
}}
disabled={minDate ? { before: minDate } : undefined}
/>
{withTime && (
<div className="border-t px-3 py-2 flex items-center gap-1.5">
<Select value={dueHour} onValueChange={(h) => applyTime(h, dueMinute || '00')} disabled={!value}>
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="HH" /></SelectTrigger>
<SelectContent>
{HOURS.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm">:</span>
<Select value={dueMinute && MINUTES.includes(dueMinute) ? dueMinute : ''} onValueChange={(m) => applyTime(dueHour || '00', m)} disabled={!value}>
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="MM" /></SelectTrigger>
<SelectContent>
{MINUTES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
</>
);
}
```
- [ ] **Step 4.4 — Use `CalendarTimeBody` in both modes**
Inside the `DateField` JSX:
- When `flat`, render `<Input ... /> <div className="mt-2 rounded-md border"><CalendarTimeBody ... onAfterPick={() => inputRef.current?.focus()} /></div>`. **Do not** render the `Popover` wrapper or the trailing `CalendarIcon` button.
- When not `flat` (existing behavior), keep the `Popover` wrapper and replace the inner `<Calendar … />` with `<CalendarTimeBody ... onAfterPick={() => { setOpen(false); inputRef.current?.focus(); }} />`.
- [ ] **Step 4.5 — Lint**
```
npm run lint
```
Expected: clean.
- [ ] **Step 4.6 — Smoke**
Run dev (`npm start`), open Timeline → "Add event" → confirm DateField still works as before (no `withTime`, no `flat`). Spot-check: typing `tomorrow`, clicking calendar day. Visual must match prior behavior.
- [ ] **Step 4.7 — Commit**
```
git -C adiuvAI add src/renderer/components/ui/date-field.tsx
git -C adiuvAI commit -m "feat(date-field): add withTime + flat props"
```
---
## Task 5 — `PropertyPill`: render as real `<button>`
**Files:**
- Modify: `adiuvAI/src/renderer/components/tasks/PropertyPill.tsx`
**Why:** Roving focus + Enter-to-open requires a real focusable element. The current `<span>`-based pill can't receive keyboard focus reliably.
Read the file first; it is small. Replace the rendered element with a `<button type="button">` and forward refs/handlers:
- [ ] **Step 5.1 — Update component signature and rendering**
```tsx
// adiuvAI/src/renderer/components/tasks/PropertyPill.tsx
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
export interface PropertyPillProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
icon: React.ReactNode;
label: string;
value?: string | null;
empty?: boolean;
}
export const PropertyPill = forwardRef<HTMLButtonElement, PropertyPillProps>(
({ icon, label, value, empty, className, ...rest }, ref) => (
<button
ref={ref}
type="button"
data-empty={empty ? 'true' : undefined}
className={cn(
'inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs',
'border bg-card/70 text-foreground transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-ring',
empty
? 'border-dashed text-muted-foreground bg-transparent'
: 'border-border',
className,
)}
{...rest}
>
<span className="shrink-0">{icon}</span>
<span className="text-muted-foreground">{label}</span>
{value != null && !empty && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="font-medium">{value}</span>
</>
)}
</button>
),
);
PropertyPill.displayName = 'PropertyPill';
```
- [ ] **Step 5.2 — Update callers that wrap PropertyPill in `<span>`**
`TaskFormDialog.tsx` currently wraps each `<PropertyPill ... />` inside `<span>` so `PopoverTrigger asChild` can attach a ref to a single element. Now that `PropertyPill` is itself a `<button>` with a forwarded ref, the `<span>` wrapper must go (`PopoverTrigger asChild` will inject its props/ref directly into the pill button). This is part of Task 6; no separate caller update needed here.
- [ ] **Step 5.3 — Lint**
```
npm run lint
```
Likely yields a transient ESLint warning at Task 6 callsites until Step 6.* lands. If lint output is otherwise clean, proceed to commit.
- [ ] **Step 5.4 — Commit**
```
git -C adiuvAI add src/renderer/components/tasks/PropertyPill.tsx
git -C adiuvAI commit -m "refactor(PropertyPill): render as button with forwardRef and focus ring"
```
---
## Task 6 — `TaskFormDialog`: header + pills row roving focus + Due via DateField + listbox keys + drop ⌘+Enter hint
**Files:**
- Modify: `adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx`
This is the big task. Subdivided.
### 6a. Header swap
- [ ] **Step 6a.1 — Add `DialogDescription` import + new keys reference**
At the top of `TaskFormDialog.tsx`, in the existing `@/components/ui/dialog` import, add `DialogDescription`:
```ts
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
```
- [ ] **Step 6a.2 — Replace header markup**
Find:
```tsx
<DialogHeader className="px-5 py-3 border-b border-border/40">
<DialogTitle className="text-sm font-medium">
{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
</DialogTitle>
</DialogHeader>
```
Replace with:
```tsx
<DialogHeader>
<DialogTitle>
{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
</DialogTitle>
<DialogDescription>
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
</DialogDescription>
</DialogHeader>
```
(The `DialogHeader` and `DialogTitle` shadcn defaults already give the same spacing/typography used in `AddEventDialog`. No `border-b`.)
### 6b. Pills row roving focus
- [ ] **Step 6b.1 — Import hook**
```ts
import { useRovingFocus } from '@/hooks/useRovingFocus';
```
- [ ] **Step 6b.2 — Wire up at top of component body**
Just after the existing `useState` calls, add:
```ts
const PILL_COUNT = 5; // Project, Priority, Status, Due, Assignees
const pillsRoving = useRovingFocus({ count: PILL_COUNT, direction: 'both' });
```
- [ ] **Step 6b.3 — Attach to each pill**
Each `<PopoverTrigger asChild>` wraps a `<span>` wrapping `<PropertyPill ... />`. Remove the `<span>` wrappers and pass roving props directly to the pill. The pattern, repeated five times with `index = 0..4`:
```tsx
<PopoverTrigger asChild>
<PropertyPill
{...pillsRoving.getItemProps(0)}
icon={<Folder className="h-3 w-3" />}
label={t('tasks.project')}
value={selectedProject?.name ?? null}
empty={!selectedProject}
aria-label={t('tasks.project') + (selectedProject ? `: ${selectedProject.name}` : '')}
/>
</PopoverTrigger>
```
Repeat with `getItemProps(1)` (Priority), `getItemProps(2)` (Status), `getItemProps(3)` (Due), `getItemProps(4)` (Assignees). Update the surrounding `aria-label` per pill.
Add `role="toolbar" aria-label={t('tasks.properties')}` to the `<div className="flex flex-wrap gap-1.5" data-testid="property-pills">` wrapper that contains all pills.
### 6c. Each list popover uses `useListboxKeys`
- [ ] **Step 6c.1 — Import**
```ts
import { useListboxKeys } from '@/hooks/useListboxKeys';
```
- [ ] **Step 6c.2 — Project popover**
The Project popover today renders either `<InlineProjectForm ... />` (when `creatingProject`) or a list (`No project`, then `projectsList.map`). Wrap **only the list branch** in a listbox container that uses `useListboxKeys`:
```tsx
function ProjectList({
projects,
selectedId,
onSelect,
onCreate,
onClose,
}: {
projects: { id: string; name: string }[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onCreate: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Item layout: [0] "+ New project", [1] "No project", [2..N+1] projects
const items = [
{ kind: 'new' as const },
{ kind: 'none' as const },
...projects.map((p) => ({ kind: 'project' as const, id: p.id, name: p.name })),
];
const listbox = useListboxKeys({
itemCount: items.length,
initialIndex: selectedId
? items.findIndex((it) => it.kind === 'project' && it.id === selectedId)
: 1,
onSelect: (i) => {
const it = items[i];
if (it.kind === 'new') onCreate();
else if (it.kind === 'none') onSelect(null);
else onSelect(it.id);
},
onClose,
});
// Focus the active item once on mount.
useEffect(() => {
listbox.focusIndex(listbox.activeIndex);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="p-1" role="listbox" aria-label={t('tasks.project')}>
{items.map((it, i) => {
const itemProps = listbox.getItemProps(i);
const base =
'w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none flex items-center gap-1.5';
if (it.kind === 'new') {
return (
<button
key="new"
type="button"
{...itemProps}
role="option"
className={base + ' text-primary'}
onClick={() => onCreate()}
>
<Plus className="h-3.5 w-3.5" />
{t('projects.newProject')}
</button>
);
}
if (it.kind === 'none') {
return (
<button
key="none"
type="button"
{...itemProps}
role="option"
className={base}
onClick={() => onSelect(null)}
>
{t('tasks.noProject')}
</button>
);
}
return (
<button
key={it.id}
type="button"
{...itemProps}
role="option"
className={base}
onClick={() => onSelect(it.id)}
>
{it.name}
</button>
);
})}
</div>
);
}
```
In the Project popover, replace the inline list JSX with `<ProjectList ... />` and pass:
```tsx
<ProjectList
projects={projectsList}
selectedId={values.projectId}
onSelect={(id) => {
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 (
<div role="listbox" aria-label={t('tasks.priority')} className="p-1">
{items.map((p, i) => (
<button
key={p}
type="button"
{...listbox.getItemProps(i)}
role="option"
aria-selected={value === p}
onClick={() => onSelect(p)}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
>
{t(`tasks.${p}`)}
</button>
))}
</div>
);
}
```
Drop in the Priority `<PopoverContent>`:
```tsx
<PriorityList
value={values.priority}
onSelect={(p) => 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 `<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>`, 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 (
<div className="p-2" role="listbox" aria-multiselectable="true" aria-label={t('tasks.assignees')}>
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{known.map((name, i) => {
const isOn = selected.includes(name);
return (
<button
key={name}
type="button"
{...listbox.getItemProps(i)}
role="option"
aria-selected={isOn}
onClick={() => onToggle(name)}
className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
>
{isOn ? '✓ ' : ' '}{name}
</button>
);
})}
</div>
<div className="border-t mt-2 pt-2 flex gap-1.5">
<Input
placeholder={t('tasks.newAssigneeName', 'New name…')}
value={newName}
onChange={(e) => onNewNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onAddNew();
}
}}
className="h-8 text-sm flex-1"
/>
<Button type="button" size="sm" onClick={onAddNew} disabled={!newName.trim()}>
{t('common.add')}
</Button>
</div>
</div>
);
}
```
In the Assignees `<PopoverContent>`, replace the existing JSX with:
```tsx
<AssigneesList
known={knownAssignees}
selected={values.assignees}
onToggle={(name) =>
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 `<Popover>` block**
Remove the `HOURS`/`MINUTES` constants and the `derivePartsInTz` / `updateDueTime` helpers from `TaskFormDialog.tsx``DateField` now owns time handling. Then replace the Due `<Popover>` body with:
```tsx
<Popover>
<PopoverTrigger asChild>
<PropertyPill
{...pillsRoving.getItemProps(3)}
icon={<CalIcon className="h-3 w-3" />}
label={t('tasks.colDue')}
value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
empty={!values.dueDate}
aria-label={t('tasks.colDue')}
/>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" align="start">
<DateField
flat
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')}
/>
</PopoverContent>
</Popover>
```
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.26c.5.
- `DateField` new props `withTime`, `flat` — consumed in Task 6d.
- `PropertyPill` becomes a forwardRef `<button>` — consumed via `{...pillsRoving.getItemProps(i)}` in Task 6b.
- i18n keys `tasks.newTaskDescription`, `tasks.editTaskDescription` — defined Task 7, referenced Task 6a. Match.
No gaps detected.