Compare commits
10 Commits
c68e23b713
...
feat/proje
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a8acd08c0 | ||
|
|
82a7a8dc27 | ||
|
|
5a90dbc832 | ||
|
|
f72aaa8424 | ||
|
|
fa09ed2156 | ||
|
|
1341fb3144 | ||
|
|
3705316a25 | ||
|
|
72d7cc2f6e | ||
|
|
e1d15b3edd | ||
|
|
faea5f0448 |
@@ -1,15 +1,6 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__langfuse__createTextPrompt",
|
||||
"mcp__langfuse-docs__searchLangfuseDocs",
|
||||
"Bash(python -m ruff check . --fix)",
|
||||
"Bash(ruff check *)",
|
||||
"Bash(powershell -Command \"cd 'c:\\\\\\\\_temp\\\\\\\\_adiuvai_workspace\\\\\\\\api'; .venv\\\\\\\\Scripts\\\\\\\\pytest.exe tests/test_memory_relations.py -v 2>&1 | Out-File -FilePath 'C:\\\\\\\\Users\\\\\\\\musso\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\pytest_phase3.txt' -Encoding UTF8; Get-Content 'C:\\\\\\\\Users\\\\\\\\musso\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\pytest_phase3.txt'\")",
|
||||
"mcp__postgres__execute_sql",
|
||||
"mcp__langfuse__listPrompts",
|
||||
"mcp__langfuse__getPrompt"
|
||||
]
|
||||
"allow": []
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"caveman@caveman": true
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Skill(shadcn)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(node -e \"const d = require\\('date-fns'\\); console.log\\(typeof d.eachDayOfInterval, typeof d.eachWeekOfInterval, typeof d.eachMonthOfInterval, typeof d.startOfMonth\\)\")",
|
||||
"Bash(export LANGFUSE_PUBLIC_KEY=pk-lf-0e62a9eb-0978-4e2e-b3ad-bb36194701b8)",
|
||||
"Bash(export LANGFUSE_SECRET_KEY=sk-lf-286c165f-1c84-4a36-b0b0-cfe5b680897d)",
|
||||
"Bash(export LANGFUSE_HOST=https://langfuse.muticolturano.com)",
|
||||
"Bash(npx langfuse-cli *)",
|
||||
"mcp__langfuse-docs__getLangfuseDocsPage",
|
||||
"Bash(python -c ' *)",
|
||||
"WebFetch(domain:ui.shadcn.com)"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"langfuse-docs",
|
||||
"langfuse",
|
||||
"postgres"
|
||||
]
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,4 +12,5 @@ tmp/
|
||||
.superpowers/
|
||||
graphify-out/cache/
|
||||
graphify-out/manifest.json
|
||||
graphify-out/cost.json
|
||||
graphify-out/cost.json
|
||||
.claude/settings.local.json
|
||||
|
||||
2
adiuvAI
2
adiuvAI
Submodule adiuvAI updated: b0c415f90f...81fe6d29e2
2
api
2
api
Submodule api updated: 956fa88853...cc0e258e8c
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.
|
||||
1172
docs/2026-05-14-task-form-dialog-kbd-plan.md
Normal file
1172
docs/2026-05-14-task-form-dialog-kbd-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||
1579
docs/superpowers/plans/2026-05-13-timeline-batch-add.md
Normal file
1579
docs/superpowers/plans/2026-05-13-timeline-batch-add.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
41409
graphify-out/graph.json
41409
graphify-out/graph.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user