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>
853 lines
30 KiB
HTML
853 lines
30 KiB
HTML
<!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>
|