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:
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