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