e536fdd2c0
Six tabs (Queue, On-call, Alerts, Team, Admin, Account) had already
outgrown the bottom bar once: 56b8191 let it auto-size its columns to
fit however many there were, but on a phone that only left each tab
55-65px wide. Squeezing further wasn't an option, so the bar is gone
on mobile and a hamburger button in the topbar opens a menu instead.
The menu reuses the sheet + menu-item pattern already used for the
snooze and assign-to actions in incident.js, rather than a new overlay
component. It lists the same sections the sidebar does, including
hiding Admin for non-admins, and the triggered-incident badge that
used to sit on the Queue tab icon now shows on the hamburger button.
Desktop (>=900px) is untouched: the sidebar is the same markup, CSS
alone hides it below 900px and shows it above, so nothing there
changed behaviourally.
Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
198 lines
6.5 KiB
JavaScript
198 lines
6.5 KiB
JavaScript
// DOM helpers, the bottom sheet, confirmation and toasts.
|
||
|
||
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
|
||
// attributes; boolean true sets an empty attribute, false/null skips it.
|
||
export function h(tag, attrs = {}, ...children) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v == null || v === false) continue;
|
||
if (k === 'class') el.className = v;
|
||
else if (k === 'text') el.textContent = v;
|
||
else if (k === 'dataset') Object.assign(el.dataset, v);
|
||
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
|
||
else if (k in el && typeof v !== 'string') el[k] = v;
|
||
else el.setAttribute(k, v === true ? '' : v);
|
||
}
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
function append(el, children) {
|
||
for (const c of children.flat(Infinity)) {
|
||
if (c == null || c === false) continue;
|
||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||
}
|
||
}
|
||
|
||
export function clear(el, ...children) {
|
||
el.replaceChildren();
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
|
||
const ICONS = {
|
||
back: ['M15 18l-6-6 6-6'],
|
||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
||
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
|
||
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
|
||
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
|
||
check: ['M5 12.5l4.5 4.5L19 7'],
|
||
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
||
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
||
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||
chevronLeft: ['M15 18l-6-6 6-6'],
|
||
chevronRight: ['M9 6l6 6-6 6'],
|
||
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
|
||
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
|
||
};
|
||
|
||
const SVG = 'http://www.w3.org/2000/svg';
|
||
export function icon(name, cls = 'icon') {
|
||
const svg = document.createElementNS(SVG, 'svg');
|
||
svg.setAttribute('viewBox', '0 0 24 24');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.setAttribute('class', cls);
|
||
for (const d of ICONS[name] || []) {
|
||
let node;
|
||
if (d.startsWith('circle:')) {
|
||
const [cx, cy, r] = d.slice(7).split(',');
|
||
node = document.createElementNS(SVG, 'circle');
|
||
node.setAttribute('cx', cx);
|
||
node.setAttribute('cy', cy);
|
||
node.setAttribute('r', r);
|
||
} else if (d.startsWith('rect:')) {
|
||
const [x, y, w, hgt, rx] = d.slice(5).split(',');
|
||
node = document.createElementNS(SVG, 'rect');
|
||
node.setAttribute('x', x);
|
||
node.setAttribute('y', y);
|
||
node.setAttribute('width', w);
|
||
node.setAttribute('height', hgt);
|
||
if (rx) node.setAttribute('rx', rx);
|
||
} else {
|
||
node = document.createElementNS(SVG, 'path');
|
||
node.setAttribute('d', d);
|
||
}
|
||
svg.append(node);
|
||
}
|
||
return svg;
|
||
}
|
||
|
||
// ---------- sheet ----------
|
||
|
||
const sheet = () => document.getElementById('sheet');
|
||
let sheetResolve = null;
|
||
|
||
// openSheet shows content in the bottom sheet (a centred dialog on desktop)
|
||
// and resolves with whatever closeSheet is given, or null when dismissed.
|
||
export function openSheet(build) {
|
||
const dlg = sheet();
|
||
if (dlg.open) closeSheet(null);
|
||
const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' }));
|
||
append(inner, [build()]);
|
||
clear(dlg, inner);
|
||
dlg.showModal();
|
||
const first = dlg.querySelector('[autofocus]');
|
||
if (first) first.focus();
|
||
return new Promise((resolve) => {
|
||
sheetResolve = resolve;
|
||
});
|
||
}
|
||
|
||
export function closeSheet(value = null) {
|
||
const dlg = sheet();
|
||
const resolve = sheetResolve;
|
||
sheetResolve = null;
|
||
if (dlg.open) dlg.close();
|
||
if (resolve) resolve(value);
|
||
}
|
||
|
||
export function sheetIsOpen() {
|
||
return sheet().open;
|
||
}
|
||
|
||
export function initSheet() {
|
||
const dlg = sheet();
|
||
// A tap on the backdrop lands on the dialog element itself.
|
||
dlg.addEventListener('click', (e) => {
|
||
if (e.target === dlg) closeSheet(null);
|
||
});
|
||
dlg.addEventListener('cancel', (e) => {
|
||
e.preventDefault();
|
||
closeSheet(null);
|
||
});
|
||
}
|
||
|
||
// confirm asks a yes/no question in the sheet.
|
||
export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) {
|
||
return openSheet(() => [
|
||
h('h2', { class: 'sheet-title', text: title }),
|
||
text && h('p', { class: 'sheet-text', text }),
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }),
|
||
h('button', {
|
||
class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`,
|
||
type: 'button',
|
||
autofocus: true,
|
||
onclick: () => closeSheet(true),
|
||
text: confirmLabel,
|
||
}),
|
||
),
|
||
]).then((v) => v === true);
|
||
}
|
||
|
||
// ---------- toast ----------
|
||
|
||
let toastTimer = 0;
|
||
export function toast(message, kind = '') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = message;
|
||
el.className = `toast ${kind}`;
|
||
el.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => {
|
||
el.hidden = true;
|
||
}, kind === 'error' ? 5000 : 2500);
|
||
}
|
||
|
||
// ---------- misc ----------
|
||
|
||
export function badge(text, cls = '') {
|
||
return h('span', { class: `badge ${cls}`, text });
|
||
}
|
||
|
||
export function labelChip(k, v) {
|
||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||
}
|
||
|
||
// One entry in a section's overview: a card that is a link, carrying the count
|
||
// only that section can state. Both the Admin tab and the Team tab open on one
|
||
// of these menus, and a menu item is a shape rather than a page's own idea.
|
||
export function menuCard(href, label, count, note) {
|
||
return h('a', { class: 'card overview-item', href },
|
||
h('div', { class: 'overview-head' },
|
||
h('strong', { text: label }),
|
||
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
||
h('p', { class: 'muted small', text: note }),
|
||
);
|
||
}
|
||
|
||
export function emptyState(title, text, iconName) {
|
||
return h('div', { class: 'empty' },
|
||
iconName && icon(iconName),
|
||
h('strong', { text: title }),
|
||
text && h('span', { text }),
|
||
);
|
||
}
|
||
|
||
export function spinner() {
|
||
return h('div', { class: 'empty' }, h('span', { class: 'spinner' }));
|
||
}
|