fix: drawer modal close button + tab redesign
Two bugs and a redesign:
1) **X close button didn't close the modal**. The previous JS bound
close via event delegation on the modal root, but
panel.addEventListener('click', e => e.stopPropagation())
swallowed the X click before it ever bubbled up. Switched to
direct binding on every [data-drawer-close] element with an
idempotent guard so HTMX swaps that re-render the panel don't
double-bind.
2) **Stale legacy header in the server-rendered drawer body**. The
/chats/<id>/drawer endpoint renders its own <header
class="drawer-header"> with a duplicate <h2> and a broken
inline-onclick close (targets the OLD id="drawer"
semantics). Post-process: lift the bot name out of the legacy
header into the modal title, then remove the header.
3) **Tabs**. The drawer has 10 sections — too dense as a single
stack. Group into 4 tabs:
Scene : Scene + Activity
Cast : Guest + Group + Edges
Story : Events + Threads + Branches
Turns : Recent turns + Significance review
Implementation is client-side post-swap so the
/chats/<id>/drawer server response stays unchanged. Walks
.drawer-section blocks, buckets by their <h3>, builds a
<nav role="tablist"> and <section role="tabpanel">
tree, and toggles visibility on click. Empty buckets (e.g. no
Guest tab on a 1:1 chat) are hidden. Re-runs on every HTMX
afterSwap so in-drawer form submits keep the tabs.
CSS tabs match the editorial aesthetic: no pills, no fills — a
single muted-amber underline rule under the active tab, Newsreader
serif label, ink-faint inactive / ink-default active. Empty hover
state, focus ring uses the amber accent.
This commit is contained in:
+68
-4
@@ -285,17 +285,81 @@ body.drawer-modal-open { overflow: hidden; }
|
||||
/* Scoped overrides for the drawer-content the server renders into
|
||||
.drawer-panel-body. Keeps the existing class names working but
|
||||
re-tunes them for the warm-paper context. */
|
||||
/* Tabs nav — sits at the top of .drawer-content and lets the user
|
||||
pivot between Scene / Cast / Story / Turns groups. Underline-style
|
||||
active indicator (a single muted-amber rule) keeps the editorial
|
||||
feel — no pills, no boxes, no hover-fills. */
|
||||
.drawer-panel-body .drawer-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin: 0 -8px 14px; /* bleed the divider rule slightly past the body padding */
|
||||
padding: 0 8px 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.drawer-panel-body .drawer-tab {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 14px 12px;
|
||||
margin-bottom: -1px; /* sit on top of the parent's border-bottom */
|
||||
font-family: var(--serif);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-faint);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--duration) var(--ease),
|
||||
border-color var(--duration) var(--ease);
|
||||
border-radius: 0; /* strip the global button radius */
|
||||
}
|
||||
.drawer-panel-body .drawer-tab:hover {
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.drawer-panel-body .drawer-tab.is-active {
|
||||
color: var(--ink);
|
||||
border-bottom-color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
.drawer-panel-body .drawer-tab.is-active:hover {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.drawer-panel-body .drawer-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Panes — only one visible at a time. Uses [hidden] so the JS can
|
||||
toggle attribute-driven instead of class-driven. */
|
||||
.drawer-panel-body .drawer-tab-pane[hidden] { display: none; }
|
||||
|
||||
/* Sections inside a pane: drop the section-level rules since the
|
||||
tabs already segment the content. Keep the section h3 as a sub-
|
||||
heading inside its pane — useful when a tab groups multiple
|
||||
sections (e.g. Cast = Guest + Group + Edges). */
|
||||
.drawer-panel-body .drawer-section {
|
||||
padding: 14px 0;
|
||||
padding: 14px 0 18px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.drawer-panel-body .drawer-section:last-child { border-bottom: none; }
|
||||
.drawer-panel-body .drawer-tab-pane > .drawer-section:first-child { padding-top: 6px; }
|
||||
.drawer-panel-body .drawer-tab-pane > .drawer-section:last-child { border-bottom: none; padding-bottom: 4px; }
|
||||
/* When a pane has only one section, suppress the redundant h3 since
|
||||
the tab label is the same name. */
|
||||
.drawer-panel-body .drawer-tab-pane:has(> .drawer-section:only-child) > .drawer-section > h3 {
|
||||
display: none;
|
||||
}
|
||||
.drawer-panel-body .drawer-section h3 {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
+165
-13
@@ -62,14 +62,28 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Drawer modal — open/close + focus management. Re-fetches content
|
||||
// from the server on every open by dispatching a 'drawer-open' event
|
||||
// that the panel's hx-trigger picks up.
|
||||
// Drawer modal — open/close, focus management, and post-swap
|
||||
// tab-grouping. The server's /chats/<id>/drawer response is left
|
||||
// unchanged; this script post-processes the swapped HTML to:
|
||||
// 1. Pull the bot name from the legacy <header><h2> and use it as
|
||||
// the modal title.
|
||||
// 2. Remove the legacy header (it has its own onclick="hidden"
|
||||
// close that targets the OLD drawer semantics — broken now).
|
||||
// 3. Walk .drawer-section blocks and group them into 4 tabs by
|
||||
// their <h3> title:
|
||||
// Scene : Scene, Activity
|
||||
// Cast : Guest, Group, Edges
|
||||
// Story : Events, Threads, Branches
|
||||
// Turns : Recent turns, Significance review
|
||||
// A tab nav is rendered above the sections; clicking switches
|
||||
// which group is visible. Empty tabs (no matching sections) are
|
||||
// hidden.
|
||||
(function () {
|
||||
const modal = document.getElementById('drawer-modal');
|
||||
const toggle = document.querySelector('.drawer-toggle');
|
||||
if (!modal || !toggle) return;
|
||||
const closeButton = modal.querySelector('.drawer-panel-close');
|
||||
const titleEl = modal.querySelector('#drawer-modal-title');
|
||||
const body = modal.querySelector('.drawer-panel-body');
|
||||
const panel = modal.querySelector('.drawer-panel');
|
||||
|
||||
let lastFocus = null;
|
||||
@@ -87,7 +101,10 @@
|
||||
document.body.dispatchEvent(new CustomEvent('drawer-open'));
|
||||
// Focus the close button so Escape / Enter both work
|
||||
// immediately and screen readers announce the dialog.
|
||||
requestAnimationFrame(() => closeButton.focus());
|
||||
requestAnimationFrame(() => {
|
||||
const closeBtn = modal.querySelector('.drawer-panel-close');
|
||||
if (closeBtn) closeBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -107,14 +124,29 @@
|
||||
|
||||
toggle.addEventListener('click', open);
|
||||
|
||||
// Backdrop click closes; clicks INSIDE the panel must not bubble
|
||||
// up and trip the backdrop handler.
|
||||
modal.addEventListener('click', (e) => {
|
||||
const target = e.target;
|
||||
if (target instanceof HTMLElement && target.hasAttribute('data-drawer-close')) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
// Bind close DIRECTLY to every element flagged data-drawer-close.
|
||||
// Event delegation through .stopPropagation() previously swallowed
|
||||
// the close button's click (it sits inside .drawer-panel, which
|
||||
// stops propagation to keep backdrop clicks from leaking through
|
||||
// the panel itself). Direct binding sidesteps that and keeps the
|
||||
// panel-stops-propagation rule for everything else.
|
||||
function bindCloseTargets(root) {
|
||||
root.querySelectorAll('[data-drawer-close]').forEach((el) => {
|
||||
// Idempotent: only bind once per element.
|
||||
if (el.dataset.drawerCloseBound === '1') return;
|
||||
el.dataset.drawerCloseBound = '1';
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
});
|
||||
});
|
||||
}
|
||||
bindCloseTargets(modal);
|
||||
// Clicks inside the panel that AREN'T close targets must not
|
||||
// reach the backdrop click handler. (We don't have one currently
|
||||
// — backdrop close is via data-drawer-close on the backdrop div —
|
||||
// but stopPropagation here is defensive against future handlers.)
|
||||
panel.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// Escape closes only when the modal is open.
|
||||
@@ -124,6 +156,126 @@
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Tabs: group server-rendered .drawer-section blocks ----
|
||||
|
||||
const TAB_GROUPS = [
|
||||
{ id: 'scene', label: 'Scene', sections: ['Scene', 'Activity'] },
|
||||
{ id: 'cast', label: 'Cast', sections: ['Guest', 'Group', 'Edges'] },
|
||||
{ id: 'story', label: 'Story', sections: ['Events', 'Threads', 'Branches'] },
|
||||
{ id: 'turns', label: 'Turns', sections: ['Recent turns', 'Significance review'] },
|
||||
];
|
||||
|
||||
function tabIdForSection(h3Text) {
|
||||
const t = (h3Text || '').trim();
|
||||
for (const g of TAB_GROUPS) {
|
||||
if (g.sections.includes(t)) return g.id;
|
||||
}
|
||||
return 'scene'; // unknown sections fall into the first tab
|
||||
}
|
||||
|
||||
function buildTabs() {
|
||||
// Clean up the legacy server-rendered header inside the body
|
||||
// (duplicate close + duplicate title).
|
||||
const legacyHeader = body.querySelector(':scope > .drawer-content > .drawer-header');
|
||||
if (legacyHeader) {
|
||||
// Promote the bot name to the modal title before discarding.
|
||||
const h2 = legacyHeader.querySelector('h2');
|
||||
if (h2 && h2.textContent.trim()) {
|
||||
titleEl.textContent = h2.textContent.trim();
|
||||
}
|
||||
legacyHeader.remove();
|
||||
}
|
||||
|
||||
// The drawer-content wrapper holds all the sections. Group them.
|
||||
const content = body.querySelector('.drawer-content');
|
||||
if (!content) return;
|
||||
|
||||
const sections = Array.from(content.querySelectorAll(':scope > .drawer-section'));
|
||||
if (sections.length === 0) return;
|
||||
|
||||
// Bucket sections by tab id.
|
||||
const buckets = new Map(TAB_GROUPS.map((g) => [g.id, []]));
|
||||
for (const sec of sections) {
|
||||
const h3 = sec.querySelector(':scope > h3');
|
||||
const tabId = tabIdForSection(h3 ? h3.textContent : '');
|
||||
buckets.get(tabId).push(sec);
|
||||
}
|
||||
|
||||
// Build the tab nav. Skip empty buckets so the nav reflects
|
||||
// what the chat actually has (e.g. no Guest tab when 1:1).
|
||||
const nav = document.createElement('nav');
|
||||
nav.className = 'drawer-tabs';
|
||||
nav.setAttribute('role', 'tablist');
|
||||
|
||||
const panes = document.createElement('div');
|
||||
panes.className = 'drawer-tab-panes';
|
||||
|
||||
let firstActive = null;
|
||||
for (const group of TAB_GROUPS) {
|
||||
const items = buckets.get(group.id);
|
||||
if (!items.length) continue;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'drawer-tab';
|
||||
btn.setAttribute('role', 'tab');
|
||||
btn.id = `drawer-tab-${group.id}`;
|
||||
btn.dataset.tabTarget = group.id;
|
||||
btn.textContent = group.label;
|
||||
btn.setAttribute('aria-controls', `drawer-pane-${group.id}`);
|
||||
nav.appendChild(btn);
|
||||
|
||||
const pane = document.createElement('section');
|
||||
pane.className = 'drawer-tab-pane';
|
||||
pane.id = `drawer-pane-${group.id}`;
|
||||
pane.setAttribute('role', 'tabpanel');
|
||||
pane.setAttribute('aria-labelledby', `drawer-tab-${group.id}`);
|
||||
// Move the section nodes into the pane (preserves any HTMX
|
||||
// event listeners and the sections' interactive forms).
|
||||
for (const sec of items) pane.appendChild(sec);
|
||||
panes.appendChild(pane);
|
||||
|
||||
if (!firstActive) firstActive = group.id;
|
||||
}
|
||||
|
||||
// Replace the existing content with [nav][panes].
|
||||
content.innerHTML = '';
|
||||
content.appendChild(nav);
|
||||
content.appendChild(panes);
|
||||
|
||||
// Tab click handler.
|
||||
nav.addEventListener('click', (e) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const tabId = target.dataset.tabTarget;
|
||||
if (!tabId) return;
|
||||
activateTab(content, tabId);
|
||||
});
|
||||
|
||||
if (firstActive) activateTab(content, firstActive);
|
||||
}
|
||||
|
||||
function activateTab(content, tabId) {
|
||||
content.querySelectorAll('.drawer-tab').forEach((btn) => {
|
||||
const isActive = btn.dataset.tabTarget === tabId;
|
||||
btn.classList.toggle('is-active', isActive);
|
||||
btn.setAttribute('aria-selected', String(isActive));
|
||||
btn.setAttribute('tabindex', isActive ? '0' : '-1');
|
||||
});
|
||||
content.querySelectorAll('.drawer-tab-pane').forEach((pane) => {
|
||||
const isActive = pane.id === `drawer-pane-${tabId}`;
|
||||
pane.toggleAttribute('hidden', !isActive);
|
||||
});
|
||||
}
|
||||
|
||||
// Run after every HTMX swap into the panel body. Covers the
|
||||
// initial open AND any subsequent server-driven re-render
|
||||
// (e.g. an in-drawer form submit that returns refreshed HTML).
|
||||
body.addEventListener('htmx:afterSwap', () => {
|
||||
buildTabs();
|
||||
bindCloseTargets(modal);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user