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.
|
||||||
852
docs/mockups/2026-05-14-task-form-dialog-mockup.html
Normal file
852
docs/mockups/2026-05-14-task-form-dialog-mockup.html
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Task Form Dialog — keyboard-driven mockup</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4edf3;
|
||||||
|
--canvas: #ebe4ea;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-soft: #fbf7fa;
|
||||||
|
--border: #c8c3cd;
|
||||||
|
--border-soft: #d8d4dc;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #6e6a73;
|
||||||
|
--primary: #fbc881;
|
||||||
|
--primary-fg: #4a3210;
|
||||||
|
--accent: #e9e5ee;
|
||||||
|
--ring: #8a8ea9;
|
||||||
|
--danger: #c4423a;
|
||||||
|
--green: #5a8a55;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0c0c0c;
|
||||||
|
--canvas: #161616;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--card-soft: #202020;
|
||||||
|
--border: #323232;
|
||||||
|
--border-soft: #2a2a2a;
|
||||||
|
--text: #f5f5f5;
|
||||||
|
--muted: #9a9a9a;
|
||||||
|
--primary: #fbc881;
|
||||||
|
--primary-fg: #4a3210;
|
||||||
|
--accent: #2a2a2a;
|
||||||
|
--ring: #8a8ea9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Geist", system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.page-hint {
|
||||||
|
position: fixed; left: 16px; top: 16px;
|
||||||
|
background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 10px 12px;
|
||||||
|
font-size: 12px; max-width: 280px; line-height: 1.5;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.page-hint strong { color: var(--text); }
|
||||||
|
.page-hint kbd {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--accent); border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 4px; padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog */
|
||||||
|
.overlay {
|
||||||
|
width: 580px; max-width: 100%;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 50px -10px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.4) inset;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.overlay { background: rgba(26,26,26,0.92); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header — AddEventDialog style: title + description, no separator */
|
||||||
|
.dlg-header {
|
||||||
|
padding: 18px 22px 8px;
|
||||||
|
}
|
||||||
|
.dlg-title {
|
||||||
|
font-size: 16px; font-weight: 600; margin: 0;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.dlg-desc {
|
||||||
|
margin: 4px 0 0; font-size: 13px; color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.dlg-body { padding: 18px 22px 12px; }
|
||||||
|
.title-input {
|
||||||
|
width: 100%;
|
||||||
|
border: none; outline: none; background: transparent;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
font-size: 22px; font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.title-input::placeholder { color: var(--muted); opacity: 0.7; }
|
||||||
|
.desc-input {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
border: none; outline: none; background: transparent;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.desc-input::placeholder { color: var(--muted); opacity: 0.7; }
|
||||||
|
|
||||||
|
/* Properties section */
|
||||||
|
.props-label {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--muted); margin: 14px 0 8px;
|
||||||
|
}
|
||||||
|
.pills { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
|
||||||
|
/* Pill */
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 28px; padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--card-soft);
|
||||||
|
font-size: 12px; color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms, border-color 120ms, box-shadow 120ms;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pill[data-empty="true"] {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.pill:focus-visible,
|
||||||
|
.pill[data-focused="true"] {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ring);
|
||||||
|
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
|
||||||
|
}
|
||||||
|
.pill .pill-label { color: var(--muted); }
|
||||||
|
.pill .pill-value { font-weight: 500; }
|
||||||
|
.pill .pill-sep { color: var(--muted); opacity: 0.5; }
|
||||||
|
.pill-icon { font-size: 11px; line-height: 1; }
|
||||||
|
.pill .pi-up { color: #c4423a; }
|
||||||
|
.pill .pi-mid { color: #b97a14; }
|
||||||
|
.pill .pi-down { color: var(--muted); }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.dlg-footer {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 22px;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
background: rgba(0,0,0,0.015);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.dlg-footer { background: rgba(255,255,255,0.02); }
|
||||||
|
}
|
||||||
|
.kbd-hint { font-size: 11px; color: var(--muted); }
|
||||||
|
.kbd-hint kbd {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--accent); border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 4px; padding: 1px 5px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
height: 30px; padding: 0 14px; border-radius: 8px;
|
||||||
|
font: inherit; font-size: 13px; font-weight: 500;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent; color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
|
||||||
|
border-color: var(--ring);
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.footer-actions { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
/* Popover */
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 220px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 12px 32px -8px rgba(0,0,0,0.2);
|
||||||
|
padding: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.popover[data-open="true"] { display: block; }
|
||||||
|
.pop-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.pop-item:hover,
|
||||||
|
.pop-item:focus,
|
||||||
|
.pop-item[data-active="true"] { background: var(--accent); }
|
||||||
|
.pop-item:focus { box-shadow: inset 0 0 0 1px var(--ring); }
|
||||||
|
.pop-item .check { width: 14px; color: var(--muted); }
|
||||||
|
.pop-item[data-selected="true"] .check::before { content: "✓"; color: var(--text); }
|
||||||
|
|
||||||
|
/* DateField segments */
|
||||||
|
.datefield {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--card-soft);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.datefield:focus-within {
|
||||||
|
border-color: var(--ring);
|
||||||
|
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
|
||||||
|
}
|
||||||
|
.segment {
|
||||||
|
min-width: 1.8ch; text-align: center; padding: 2px 1px;
|
||||||
|
border-radius: 3px; outline: none; cursor: text;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.segment[data-placeholder="true"] { color: var(--muted); opacity: 0.6; }
|
||||||
|
.segment:focus { background: var(--accent); }
|
||||||
|
.seg-sep { color: var(--muted); padding: 0 1px; user-select: none; }
|
||||||
|
|
||||||
|
.date-pop {
|
||||||
|
padding: 12px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
.date-pop .field-label {
|
||||||
|
font-size: 11px; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.date-pop .cal {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.cal-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
font-size: 12px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.cal-head .month { font-weight: 600; }
|
||||||
|
.cal-head button {
|
||||||
|
border: 1px solid var(--border); background: transparent;
|
||||||
|
border-radius: 6px; width: 22px; height: 22px;
|
||||||
|
color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px; font-size: 11px;
|
||||||
|
}
|
||||||
|
.cal-dow {
|
||||||
|
color: var(--muted); text-align: center;
|
||||||
|
padding: 4px 0; font-weight: 500;
|
||||||
|
}
|
||||||
|
.cal-day {
|
||||||
|
text-align: center; padding: 5px 0;
|
||||||
|
border-radius: 5px; cursor: pointer; outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.cal-day:focus,
|
||||||
|
.cal-day[data-active="true"] { background: var(--accent); }
|
||||||
|
.cal-day[data-selected="true"] {
|
||||||
|
background: var(--primary); color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
.cal-day[data-other-month="true"] { color: var(--muted); opacity: 0.4; }
|
||||||
|
|
||||||
|
.pop-anchor { position: relative; display: inline-flex; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-hint">
|
||||||
|
<strong>Keyboard demo</strong><br>
|
||||||
|
<kbd>Tab</kbd>/<kbd>Shift+Tab</kbd> cycles fields + pills.<br>
|
||||||
|
<kbd>Enter</kbd> opens focused pill.<br>
|
||||||
|
<kbd>↑</kbd>/<kbd>↓</kbd> inside popovers and calendar.<br>
|
||||||
|
<kbd>Esc</kbd> closes popover.<br>
|
||||||
|
Due pill: type date directly (segment edit).
|
||||||
|
<hr style="border:none; border-top:1px solid var(--border-soft); margin:8px 0;">
|
||||||
|
<label style="font-size:11px;">FormatPrefs.dateFormat:
|
||||||
|
<select id="fmt-pref" style="margin-top:4px; width:100%; padding:4px; font: inherit; font-size:11px;">
|
||||||
|
<option value="dd/MM/yyyy">dd/MM/yyyy</option>
|
||||||
|
<option value="MM/dd/yyyy">MM/dd/yyyy</option>
|
||||||
|
<option value="yyyy-MM-dd">yyyy-MM-dd</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overlay" role="dialog" aria-modal="true" aria-labelledby="dlg-title">
|
||||||
|
<header class="dlg-header">
|
||||||
|
<h2 id="dlg-title" class="dlg-title">New task</h2>
|
||||||
|
<p class="dlg-desc">Capture what needs doing. Set properties below or skip and refine later.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dlg-body">
|
||||||
|
<input class="title-input" id="f-title" placeholder="What needs to be done?" autofocus />
|
||||||
|
<textarea class="desc-input" id="f-desc" rows="3" placeholder="Add a description…"></textarea>
|
||||||
|
|
||||||
|
<div class="props-label">Properties</div>
|
||||||
|
<div class="pills" id="pills">
|
||||||
|
|
||||||
|
<!-- Project pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="project" data-empty="true" tabindex="0">
|
||||||
|
<span class="pill-icon">📁</span>
|
||||||
|
<span class="pill-label">Project</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover" data-popover="project" role="listbox">
|
||||||
|
<div class="pop-item" data-active="true" data-value="">
|
||||||
|
<span class="check"></span>No project
|
||||||
|
</div>
|
||||||
|
<div class="pop-item" data-value="acme-comm">
|
||||||
|
<span class="check"></span>Acme · Communications
|
||||||
|
</div>
|
||||||
|
<div class="pop-item" data-value="testing-bot">
|
||||||
|
<span class="check"></span>Testing · AI ChatBot
|
||||||
|
</div>
|
||||||
|
<div class="pop-item" data-value="adiuvai-app">
|
||||||
|
<span class="check"></span>AdiuvAI · App
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Priority pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="priority" tabindex="0">
|
||||||
|
<span class="pill-icon pi-mid">→</span>
|
||||||
|
<span class="pill-label">Priority</span>
|
||||||
|
<span class="pill-sep">·</span>
|
||||||
|
<span class="pill-value">Medium</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover" data-popover="priority" role="listbox" style="min-width:160px;">
|
||||||
|
<div class="pop-item" data-value="high"><span class="check"></span>High</div>
|
||||||
|
<div class="pop-item" data-active="true" data-selected="true" data-value="medium"><span class="check"></span>Medium</div>
|
||||||
|
<div class="pop-item" data-value="low"><span class="check"></span>Low</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Status pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="status" tabindex="0">
|
||||||
|
<span class="pill-icon">○</span>
|
||||||
|
<span class="pill-label">Status</span>
|
||||||
|
<span class="pill-sep">·</span>
|
||||||
|
<span class="pill-value">To do</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover" data-popover="status" role="listbox" style="min-width:170px;">
|
||||||
|
<div class="pop-item" data-active="true" data-selected="true" data-value="todo"><span class="check"></span>To do</div>
|
||||||
|
<div class="pop-item" data-value="in_progress"><span class="check"></span>In progress</div>
|
||||||
|
<div class="pop-item" data-value="done"><span class="check"></span>Done</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Due pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="due" data-empty="true" tabindex="0">
|
||||||
|
<span class="pill-icon">📅</span>
|
||||||
|
<span class="pill-label">Due</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover date-pop" data-popover="due" role="dialog" style="min-width:300px;">
|
||||||
|
<div class="field-label">Date</div>
|
||||||
|
<div class="datefield" id="datefield" tabindex="-1"><!-- segments injected by JS --></div>
|
||||||
|
<div class="cal" id="calendar">
|
||||||
|
<div class="cal-head">
|
||||||
|
<button type="button" data-nav="-1">‹</button>
|
||||||
|
<span class="month" id="cal-month">May 2026</span>
|
||||||
|
<button type="button" data-nav="1">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="cal-grid" id="cal-grid"><!-- filled by JS --></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Assignees pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="assignees" data-empty="true" tabindex="0">
|
||||||
|
<span class="pill-icon">+</span>
|
||||||
|
<span class="pill-label">Add assignees</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover" data-popover="assignees" role="listbox">
|
||||||
|
<div class="pop-item" data-active="true" data-value="alex"><span class="check"></span>Alex Morgan</div>
|
||||||
|
<div class="pop-item" data-value="priya"><span class="check"></span>Priya Shah</div>
|
||||||
|
<div class="pop-item" data-value="yo"><span class="check"></span>You</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dlg-footer">
|
||||||
|
<div></div>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button type="button" class="btn">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary">Create task</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ---------- popover open/close + arrow nav ---------- */
|
||||||
|
const pills = document.querySelectorAll('.pill');
|
||||||
|
const popovers = document.querySelectorAll('.popover');
|
||||||
|
|
||||||
|
function closeAllPopovers() {
|
||||||
|
popovers.forEach((p) => p.setAttribute('data-open', 'false'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pillArr = Array.from(pills);
|
||||||
|
pills.forEach((pill) => {
|
||||||
|
pill.addEventListener('click', (e) => openPopoverFor(pill));
|
||||||
|
pill.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openPopoverFor(pill);
|
||||||
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = pillArr.indexOf(pill);
|
||||||
|
const next = pillArr[Math.min(idx + 1, pillArr.length - 1)];
|
||||||
|
next && next.focus();
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = pillArr.indexOf(pill);
|
||||||
|
const prev = pillArr[Math.max(idx - 1, 0)];
|
||||||
|
prev && prev.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPopoverFor(pill) {
|
||||||
|
const which = pill.dataset.pill;
|
||||||
|
const pop = document.querySelector(`.popover[data-popover="${which}"]`);
|
||||||
|
if (!pop) return;
|
||||||
|
closeAllPopovers();
|
||||||
|
pop.setAttribute('data-open', 'true');
|
||||||
|
if (which === 'due') {
|
||||||
|
// focus first date segment
|
||||||
|
const firstSeg = pop.querySelector('.segment');
|
||||||
|
if (firstSeg) firstSeg.focus();
|
||||||
|
} else {
|
||||||
|
const items = pop.querySelectorAll('.pop-item');
|
||||||
|
items.forEach((i) => i.setAttribute('tabindex', '-1'));
|
||||||
|
const active = pop.querySelector('.pop-item[data-active="true"]') || items[0];
|
||||||
|
if (active) {
|
||||||
|
active.setAttribute('tabindex', '0');
|
||||||
|
active.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const open = document.querySelector('.popover[data-open="true"]');
|
||||||
|
if (open) {
|
||||||
|
e.preventDefault();
|
||||||
|
closePopover(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- list popover keyboard ---------- */
|
||||||
|
popovers.forEach((pop) => {
|
||||||
|
if (pop.dataset.popover === 'due') return;
|
||||||
|
const items = Array.from(pop.querySelectorAll('.pop-item'));
|
||||||
|
items.forEach((it) => {
|
||||||
|
it.setAttribute('tabindex', '-1');
|
||||||
|
it.addEventListener('click', () => selectPopItem(pop, it));
|
||||||
|
it.addEventListener('keydown', (e) => onPopItemKey(e, pop, items, it));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function onPopItemKey(e, pop, items, item) {
|
||||||
|
const idx = items.indexOf(item);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
moveFocus(items, Math.min(idx + 1, items.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
moveFocus(items, Math.max(idx - 1, 0));
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault(); moveFocus(items, 0);
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault(); moveFocus(items, items.length - 1);
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectPopItem(pop, item);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closePopover(pop);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
closePopover(pop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function moveFocus(items, target) {
|
||||||
|
items.forEach((i) => i.setAttribute('tabindex', '-1'));
|
||||||
|
const el = items[target];
|
||||||
|
el.setAttribute('tabindex', '0');
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
function closePopover(pop) {
|
||||||
|
pop.setAttribute('data-open', 'false');
|
||||||
|
const pill = document.querySelector(`.pill[data-pill="${pop.dataset.popover}"]`);
|
||||||
|
pill && pill.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPopItem(pop, item) {
|
||||||
|
const which = pop.dataset.popover;
|
||||||
|
if (which === 'assignees') {
|
||||||
|
item.toggleAttribute('data-selected');
|
||||||
|
} else {
|
||||||
|
pop.querySelectorAll('.pop-item').forEach((i) => i.removeAttribute('data-selected'));
|
||||||
|
item.setAttribute('data-selected', 'true');
|
||||||
|
}
|
||||||
|
updatePillFrom(pop);
|
||||||
|
if (which !== 'assignees') closePopover(pop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePillFrom(pop) {
|
||||||
|
const which = pop.dataset.popover;
|
||||||
|
const pill = document.querySelector(`.pill[data-pill="${which}"]`);
|
||||||
|
if (!pill) return;
|
||||||
|
if (which === 'assignees') {
|
||||||
|
const sel = Array.from(pop.querySelectorAll('.pop-item[data-selected="true"]'));
|
||||||
|
if (sel.length === 0) {
|
||||||
|
pill.setAttribute('data-empty', 'true');
|
||||||
|
pill.innerHTML = '<span class="pill-icon">+</span><span class="pill-label">Add assignees</span>';
|
||||||
|
} else {
|
||||||
|
pill.removeAttribute('data-empty');
|
||||||
|
const names = sel.map((s) => s.textContent.trim());
|
||||||
|
pill.innerHTML = `<span class="pill-icon">👤</span><span class="pill-label">Assignees</span><span class="pill-sep">·</span><span class="pill-value">${names.join(', ')}</span>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cur = pop.querySelector('.pop-item[data-selected="true"]');
|
||||||
|
if (which === 'project') {
|
||||||
|
if (!cur || cur.dataset.value === '') {
|
||||||
|
pill.setAttribute('data-empty', 'true');
|
||||||
|
pill.innerHTML = '<span class="pill-icon">📁</span><span class="pill-label">Project</span>';
|
||||||
|
} else {
|
||||||
|
pill.removeAttribute('data-empty');
|
||||||
|
pill.innerHTML = `<span class="pill-icon">📁</span><span class="pill-label">Project</span><span class="pill-sep">·</span><span class="pill-value">${cur.textContent.trim()}</span>`;
|
||||||
|
}
|
||||||
|
} else if (which === 'priority') {
|
||||||
|
const v = cur.dataset.value;
|
||||||
|
const icon = v === 'high' ? '<span class="pill-icon pi-up">↑</span>'
|
||||||
|
: v === 'low' ? '<span class="pill-icon pi-down">↓</span>'
|
||||||
|
: '<span class="pill-icon pi-mid">→</span>';
|
||||||
|
const label = v[0].toUpperCase() + v.slice(1);
|
||||||
|
pill.innerHTML = `${icon}<span class="pill-label">Priority</span><span class="pill-sep">·</span><span class="pill-value">${label}</span>`;
|
||||||
|
} else if (which === 'status') {
|
||||||
|
const v = cur.dataset.value;
|
||||||
|
const icon = v === 'done' ? '✓' : v === 'in_progress' ? '◐' : '○';
|
||||||
|
const label = v === 'in_progress' ? 'In progress' : v === 'todo' ? 'To do' : 'Done';
|
||||||
|
pill.innerHTML = `<span class="pill-icon">${icon}</span><span class="pill-label">Status</span><span class="pill-sep">·</span><span class="pill-value">${label}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- DateField — format-aware segments ---------- */
|
||||||
|
const SEG_DEFS = {
|
||||||
|
day: { len: 2, min: 1, max: 31, ph: 'DD' },
|
||||||
|
month: { len: 2, min: 1, max: 12, ph: 'MM' },
|
||||||
|
year: { len: 4, min: 1900, max: 2100, ph: 'YYYY' },
|
||||||
|
hour: { len: 2, min: 0, max: 23, ph: 'HH' },
|
||||||
|
minute: { len: 2, min: 0, max: 59, ph: 'MM' },
|
||||||
|
};
|
||||||
|
const FMT_LAYOUT = {
|
||||||
|
'dd/MM/yyyy': [['day','/'],['month','/'],['year',null]],
|
||||||
|
'MM/dd/yyyy': [['month','/'],['day','/'],['year',null]],
|
||||||
|
'yyyy-MM-dd': [['year','-'],['month','-'],['day',null]],
|
||||||
|
};
|
||||||
|
let currentFmt = 'dd/MM/yyyy';
|
||||||
|
|
||||||
|
function renderDateField() {
|
||||||
|
const df = document.getElementById('datefield');
|
||||||
|
const cur = readDateField();
|
||||||
|
df.innerHTML = '';
|
||||||
|
const layout = FMT_LAYOUT[currentFmt].concat([null, ['hour',':'], ['minute', null]]);
|
||||||
|
layout.forEach((entry) => {
|
||||||
|
if (entry === null) {
|
||||||
|
const sp = document.createElement('span');
|
||||||
|
sp.className = 'seg-sep'; sp.innerHTML = ' ';
|
||||||
|
df.appendChild(sp); return;
|
||||||
|
}
|
||||||
|
const [key, sep] = entry;
|
||||||
|
const def = SEG_DEFS[key];
|
||||||
|
const seg = document.createElement('span');
|
||||||
|
seg.className = 'segment';
|
||||||
|
seg.contentEditable = 'true';
|
||||||
|
seg.dataset.seg = key;
|
||||||
|
seg.dataset.len = def.len; seg.dataset.min = def.min; seg.dataset.max = def.max;
|
||||||
|
const v = cur[key];
|
||||||
|
if (v == null) {
|
||||||
|
seg.dataset.placeholder = 'true';
|
||||||
|
seg.textContent = def.ph;
|
||||||
|
} else {
|
||||||
|
seg.dataset.placeholder = 'false';
|
||||||
|
seg.textContent = String(v).padStart(def.len, '0');
|
||||||
|
}
|
||||||
|
df.appendChild(seg);
|
||||||
|
if (sep) {
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.className = 'seg-sep'; s.textContent = sep;
|
||||||
|
df.appendChild(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bindDateSegments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDateSegments() {
|
||||||
|
const dfSegments = Array.from(document.querySelectorAll('.segment'));
|
||||||
|
dfSegments.forEach((seg, idx) => {
|
||||||
|
seg.addEventListener('focus', () => {
|
||||||
|
if (seg.dataset.placeholder === 'true') {
|
||||||
|
seg.textContent = '';
|
||||||
|
}
|
||||||
|
// select all
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(seg);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
});
|
||||||
|
seg.addEventListener('blur', () => {
|
||||||
|
const len = parseInt(seg.dataset.len, 10);
|
||||||
|
const min = parseInt(seg.dataset.min, 10);
|
||||||
|
const max = parseInt(seg.dataset.max, 10);
|
||||||
|
let v = seg.textContent.replace(/\D/g, '');
|
||||||
|
if (!v) {
|
||||||
|
seg.dataset.placeholder = 'true';
|
||||||
|
seg.textContent = seg.dataset.seg.toUpperCase().slice(0,len).padEnd(len, seg.dataset.seg[0].toUpperCase());
|
||||||
|
// reset to nice placeholder
|
||||||
|
const ph = { day:'DD', month:'MM', year:'YYYY', hour:'HH', minute:'MM' }[seg.dataset.seg];
|
||||||
|
seg.textContent = ph;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = parseInt(v, 10);
|
||||||
|
if (n < min) n = min;
|
||||||
|
if (n > max) n = max;
|
||||||
|
seg.dataset.placeholder = 'false';
|
||||||
|
seg.textContent = String(n).padStart(len, '0');
|
||||||
|
refreshSelectedDay();
|
||||||
|
});
|
||||||
|
seg.addEventListener('keydown', (e) => {
|
||||||
|
const len = parseInt(seg.dataset.len, 10);
|
||||||
|
if (e.key === 'ArrowRight' || (e.key === '/' || e.key === ':') ) {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = dfSegments[idx + 1];
|
||||||
|
if (next) next.focus();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = dfSegments[idx - 1];
|
||||||
|
if (prev) prev.focus();
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const min = parseInt(seg.dataset.min, 10);
|
||||||
|
const max = parseInt(seg.dataset.max, 10);
|
||||||
|
const cur = parseInt(seg.textContent.replace(/\D/g,''), 10);
|
||||||
|
const base = isNaN(cur) ? min : cur;
|
||||||
|
let n = base + (e.key === 'ArrowUp' ? 1 : -1);
|
||||||
|
if (n < min) n = max;
|
||||||
|
if (n > max) n = min;
|
||||||
|
seg.dataset.placeholder = 'false';
|
||||||
|
seg.textContent = String(n).padStart(len, '0');
|
||||||
|
refreshSelectedDay();
|
||||||
|
} else if (/^\d$/.test(e.key)) {
|
||||||
|
const cur = seg.textContent.replace(/\D/g,'');
|
||||||
|
if (cur.length >= len) {
|
||||||
|
e.preventDefault();
|
||||||
|
seg.textContent = e.key;
|
||||||
|
// place caret at end
|
||||||
|
}
|
||||||
|
// when reaching len, advance to next segment after this char
|
||||||
|
setTimeout(() => {
|
||||||
|
if ((seg.textContent || '').replace(/\D/g,'').length >= len) {
|
||||||
|
const next = dfSegments[idx + 1];
|
||||||
|
if (next) next.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
} else if (e.key === 'Backspace' && seg.textContent === '') {
|
||||||
|
const prev = dfSegments[idx - 1];
|
||||||
|
if (prev) { e.preventDefault(); prev.focus(); }
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
seg.blur();
|
||||||
|
const pop = document.querySelector('.popover[data-popover="due"]');
|
||||||
|
closePopover(pop);
|
||||||
|
updateDuePill();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
const pop = document.querySelector('.popover[data-popover="due"]');
|
||||||
|
closePopover(pop);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readDateFieldFromDOM() {
|
||||||
|
return readDateField();
|
||||||
|
}
|
||||||
|
renderDateField();
|
||||||
|
document.getElementById('fmt-pref').addEventListener('change', (e) => {
|
||||||
|
currentFmt = e.target.value;
|
||||||
|
renderDateField();
|
||||||
|
updateDuePill();
|
||||||
|
});
|
||||||
|
|
||||||
|
function readDateField() {
|
||||||
|
const get = (k) => {
|
||||||
|
const s = document.querySelector(`.segment[data-seg="${k}"]`);
|
||||||
|
if (!s || s.dataset.placeholder === 'true') return null;
|
||||||
|
const v = s.textContent.replace(/\D/g,'');
|
||||||
|
return v ? parseInt(v, 10) : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
day: get('day'), month: get('month'), year: get('year'),
|
||||||
|
hour: get('hour'), minute: get('minute'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateValue(d) {
|
||||||
|
const day = String(d.day).padStart(2,'0');
|
||||||
|
const month = String(d.month).padStart(2,'0');
|
||||||
|
const year = String(d.year);
|
||||||
|
switch (currentFmt) {
|
||||||
|
case 'MM/dd/yyyy': return `${month}/${day}/${year}`;
|
||||||
|
case 'yyyy-MM-dd': return `${year}-${month}-${day}`;
|
||||||
|
default: return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateDuePill() {
|
||||||
|
const d = readDateField();
|
||||||
|
const pill = document.querySelector('.pill[data-pill="due"]');
|
||||||
|
if (d.day && d.month && d.year) {
|
||||||
|
pill.removeAttribute('data-empty');
|
||||||
|
const time = d.hour != null && d.minute != null
|
||||||
|
? ` ${String(d.hour).padStart(2,'0')}:${String(d.minute).padStart(2,'0')}` : '';
|
||||||
|
pill.innerHTML = `<span class="pill-icon">📅</span><span class="pill-label">Due</span><span class="pill-sep">·</span><span class="pill-value">${formatDateValue(d)}${time}</span>`;
|
||||||
|
} else {
|
||||||
|
pill.setAttribute('data-empty', 'true');
|
||||||
|
pill.innerHTML = '<span class="pill-icon">📅</span><span class="pill-label">Due</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Mini calendar ---------- */
|
||||||
|
let calYear = 2026, calMonth = 5; // May 2026
|
||||||
|
function renderCalendar() {
|
||||||
|
const grid = document.getElementById('cal-grid');
|
||||||
|
document.getElementById('cal-month').textContent =
|
||||||
|
new Date(calYear, calMonth - 1, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' });
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const dows = ['Mo','Tu','We','Th','Fr','Sa','Su'];
|
||||||
|
dows.forEach((d) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-dow'; el.textContent = d;
|
||||||
|
grid.appendChild(el);
|
||||||
|
});
|
||||||
|
const first = new Date(calYear, calMonth - 1, 1);
|
||||||
|
const offset = (first.getDay() + 6) % 7; // Mon-first
|
||||||
|
const daysInMonth = new Date(calYear, calMonth, 0).getDate();
|
||||||
|
const daysPrev = new Date(calYear, calMonth - 1, 0).getDate();
|
||||||
|
for (let i = offset - 1; i >= 0; i--) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-day';
|
||||||
|
el.dataset.otherMonth = 'true';
|
||||||
|
el.textContent = daysPrev - i;
|
||||||
|
grid.appendChild(el);
|
||||||
|
}
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-day';
|
||||||
|
el.tabIndex = 0;
|
||||||
|
el.textContent = d;
|
||||||
|
el.dataset.day = d;
|
||||||
|
el.addEventListener('click', () => pickCalDay(d));
|
||||||
|
el.addEventListener('keydown', (e) => onCalKey(e, d));
|
||||||
|
grid.appendChild(el);
|
||||||
|
}
|
||||||
|
refreshSelectedDay();
|
||||||
|
}
|
||||||
|
function refreshSelectedDay() {
|
||||||
|
const d = readDateField();
|
||||||
|
const days = document.querySelectorAll('.cal-day[data-day]');
|
||||||
|
days.forEach((el) => el.removeAttribute('data-selected'));
|
||||||
|
if (d.day && d.month === calMonth && d.year === calYear) {
|
||||||
|
const tgt = document.querySelector(`.cal-day[data-day="${d.day}"]`);
|
||||||
|
if (tgt) tgt.setAttribute('data-selected', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pickCalDay(d) {
|
||||||
|
const segDay = document.querySelector('.segment[data-seg="day"]');
|
||||||
|
const segMonth = document.querySelector('.segment[data-seg="month"]');
|
||||||
|
const segYear = document.querySelector('.segment[data-seg="year"]');
|
||||||
|
segDay.dataset.placeholder = 'false'; segDay.textContent = String(d).padStart(2,'0');
|
||||||
|
segMonth.dataset.placeholder = 'false'; segMonth.textContent = String(calMonth).padStart(2,'0');
|
||||||
|
segYear.dataset.placeholder = 'false'; segYear.textContent = String(calYear);
|
||||||
|
refreshSelectedDay();
|
||||||
|
updateDuePill();
|
||||||
|
}
|
||||||
|
function onCalKey(e, d) {
|
||||||
|
const grid = document.getElementById('cal-grid');
|
||||||
|
const days = Array.from(grid.querySelectorAll('.cal-day[data-day]'));
|
||||||
|
const idx = days.findIndex((el) => parseInt(el.dataset.day,10) === d);
|
||||||
|
let target = null;
|
||||||
|
if (e.key === 'ArrowRight') target = days[idx + 1];
|
||||||
|
else if (e.key === 'ArrowLeft') target = days[idx - 1];
|
||||||
|
else if (e.key === 'ArrowDown') target = days[idx + 7];
|
||||||
|
else if (e.key === 'ArrowUp') target = days[idx - 7];
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); pickCalDay(d); return; }
|
||||||
|
if (target) { e.preventDefault(); target.focus(); }
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-nav]').forEach((b) => {
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
const dir = parseInt(b.dataset.nav, 10);
|
||||||
|
calMonth += dir;
|
||||||
|
if (calMonth < 1) { calMonth = 12; calYear--; }
|
||||||
|
if (calMonth > 12) { calMonth = 1; calYear++; }
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
renderCalendar();
|
||||||
|
|
||||||
|
/* ---------- click outside closes popovers ---------- */
|
||||||
|
document.addEventListener('mousedown', (e) => {
|
||||||
|
const inPop = e.target.closest('.popover');
|
||||||
|
const inPill = e.target.closest('.pill');
|
||||||
|
if (!inPop && !inPill) closeAllPopovers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user