Put the other menus in the top bar
The bar hid them behind a switcher and a sheet. It now names every menu that exists, which is what was asked for; it scrolls sideways when the categories outgrow the width, and the current one is the only filled item. Without the script it is simply a row of links, so nothing is lost when JavaScript is off.
This commit is contained in:
1 parent
c31b73d553
commit
c5c0b7d005
6 files changed
+56
-241
No files matched your search
@@ -11,3 +11,5 @@ compose.yaml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
# design prototype, never committed to main and never shipped
|
||||
public/prototype
|
||||
@@ -4,3 +4,5 @@ node_modules/
|
||||
.dev/
|
||||
*.log
|
||||
site.env
|
||||
# design prototype, never committed to main and never shipped
|
||||
/public/prototype/
|
||||
@@ -118,51 +118,4 @@
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- The menu switcher ----------
|
||||
The bar names the menu you are reading; tapping it lists the rest. The
|
||||
switcher is a link to the landing page, so it still works without this
|
||||
file — the script just opens the sheet instead of navigating. */
|
||||
var switcher = document.querySelector('[data-switcher]');
|
||||
var sheet = document.querySelector('[data-sheet]');
|
||||
var sheetClose = document.querySelector('[data-sheet-close]');
|
||||
|
||||
function closeSheet() {
|
||||
if (!sheet) return;
|
||||
sheet.hidden = true;
|
||||
if (switcher) {
|
||||
switcher.setAttribute('aria-expanded', 'false');
|
||||
switcher.focus({ preventScroll: true });
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function openSheet() {
|
||||
if (!sheet) return;
|
||||
sheet.hidden = false;
|
||||
if (switcher) switcher.setAttribute('aria-expanded', 'true');
|
||||
document.body.style.overflow = 'hidden';
|
||||
var target = sheet.querySelector('.sheet__item') || sheetClose;
|
||||
if (target) target.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
if (switcher && sheet) {
|
||||
switcher.addEventListener('click', function (event) {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (sheet.hidden) openSheet();
|
||||
else closeSheet();
|
||||
});
|
||||
|
||||
sheet.addEventListener('click', function (event) {
|
||||
if (event.target === sheet) closeSheet();
|
||||
});
|
||||
|
||||
if (sheetClose) sheetClose.addEventListener('click', closeSheet);
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && !sheet.hidden) closeSheet();
|
||||
});
|
||||
}
|
||||
})();
|
||||
+26
-128
@@ -212,33 +212,34 @@ a {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switcher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
.bar__nav {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.15rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.bar__nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bar__link {
|
||||
padding: 0.35rem 0.6rem;
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.98rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar__link:hover {
|
||||
color: var(--ink);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.bar__link.is-current {
|
||||
color: var(--steel);
|
||||
background: var(--accent);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.switcher:hover {
|
||||
border-color: var(--brass-edge);
|
||||
}
|
||||
|
||||
.switcher__caret {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-right: 1.5px solid var(--copper);
|
||||
border-bottom: 1.5px solid var(--copper);
|
||||
transform: rotate(45deg) translate(-0.1rem, -0.1rem);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.switcher[aria-expanded='true'] .switcher__caret {
|
||||
transform: rotate(-135deg) translate(-0.05rem, -0.05rem);
|
||||
}
|
||||
|
||||
.pour {
|
||||
@@ -257,109 +258,6 @@ a {
|
||||
background: var(--copper);
|
||||
}
|
||||
|
||||
/* ---------- The switcher sheet ---------- */
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
background: color-mix(in srgb, #05090a 74%, transparent);
|
||||
}
|
||||
|
||||
.sheet[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sheet__inner {
|
||||
width: 100%;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.1rem 1rem calc(1.2rem + env(safe-area-inset-bottom));
|
||||
background: var(--rail);
|
||||
border-top: 2px solid var(--copper);
|
||||
animation: sheet-rise 0.24s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes sheet-rise {
|
||||
from {
|
||||
transform: translateY(1.6rem);
|
||||
opacity: 0.5;
|
||||
}
|
||||
to {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sheet__title {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sheet__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sheet__item {
|
||||
display: grid;
|
||||
grid-template-columns: 2.4rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.5rem 0.4rem;
|
||||
border-bottom: 1px solid var(--hairline-soft);
|
||||
}
|
||||
|
||||
.sheet__item.is-current {
|
||||
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
||||
}
|
||||
|
||||
.sheet__peek {
|
||||
width: 2.4rem;
|
||||
height: 3rem;
|
||||
overflow: hidden;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.sheet__peek img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
}
|
||||
|
||||
.sheet__name {
|
||||
font-size: 1.12rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sheet__state {
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sheet__item.is-current .sheet__state {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sheet__close {
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: 1px solid var(--brass-edge);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- The pages themselves ---------- */
|
||||
|
||||
.section-heading {
|
||||
|
||||
+17
-56
@@ -64,62 +64,32 @@ export function setDateLabel(manifest: SetManifest): string {
|
||||
return 'not published';
|
||||
}
|
||||
|
||||
interface SheetEntry {
|
||||
section: SectionConfig;
|
||||
current: boolean;
|
||||
cover: string | null;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar names the menu you are reading and opens a sheet with the rest. A row
|
||||
* of tabs was unreadable at six categories and would not survive more.
|
||||
* The switcher is a link to the landing page, so it still works without the
|
||||
* script; the script opens the sheet instead of navigating.
|
||||
* The bar names every menu that exists, so the other pages are one tap away
|
||||
* rather than behind a control. It scrolls sideways when the categories outgrow
|
||||
* the width, which is why these are plain links and not tabs or pills.
|
||||
*/
|
||||
function renderBar(config: SiteConfig, currentLabel: string): Html {
|
||||
function renderBar(config: SiteConfig, currentSlug: string, menu: readonly MenuLink[]): Html {
|
||||
return html`<header class="bar">
|
||||
<a class="bar__brand" href="/" aria-label="${config.brand.name} — all menus">
|
||||
<img src="${config.brand.logo ?? '/assets/favicon.svg'}" alt="" width="24" height="24" />
|
||||
</a>
|
||||
<a class="switcher" href="/" data-switcher aria-expanded="false" aria-controls="menu-sheet">
|
||||
<span class="switcher__label">${currentLabel}</span>
|
||||
<span class="switcher__caret" aria-hidden="true"></span>
|
||||
</a>
|
||||
<nav class="bar__nav" aria-label="Menus">
|
||||
${join(
|
||||
menu.map(
|
||||
(entry) => html`<a
|
||||
class="bar__link${entry.section.slug === currentSlug ? ' is-current' : ''}"
|
||||
href="/${entry.section.slug}/"
|
||||
${entry.section.slug === currentSlug ? html`aria-current="page"` : ''}
|
||||
>${entry.section.shortLabel}</a
|
||||
>`,
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function renderSheet(config: SiteConfig, entries: readonly SheetEntry[]): Html {
|
||||
return html`<section class="sheet" id="menu-sheet" data-sheet hidden aria-label="Menus">
|
||||
<div class="sheet__inner">
|
||||
<p class="sheet__title">${config.brand.name}</p>
|
||||
<ul class="sheet__list">
|
||||
${join(
|
||||
entries.map(
|
||||
(entry) => html`<li>
|
||||
<a
|
||||
class="sheet__item${entry.current ? ' is-current' : ''}"
|
||||
href="/${entry.section.slug}/"
|
||||
${entry.current ? html`aria-current="page"` : ''}
|
||||
>
|
||||
<span class="sheet__peek" aria-hidden="true">
|
||||
${entry.cover === null
|
||||
? ''
|
||||
: html`<img src="${entry.cover}" alt="" loading="lazy" decoding="async" />`}
|
||||
</span>
|
||||
<span class="sheet__name">${entry.section.label}</span>
|
||||
<span class="sheet__state">${entry.current ? 'Showing' : pageCountLabel(entry.pages)}</span>
|
||||
</a>
|
||||
</li>`,
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
<button class="sheet__close" type="button" data-sheet-close>Close</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderPages(section: SectionConfig, pages: readonly PageRecord[]): Html {
|
||||
const total = pages.length;
|
||||
return html`<main class="pages" data-pages>
|
||||
@@ -251,16 +221,7 @@ export function renderSectionPage(input: SectionInput): Html {
|
||||
const currentHref = `/${section.slug}/`;
|
||||
const total = set.manifest.pages.length;
|
||||
|
||||
const body = html`${renderBar(config, section.label)}
|
||||
${renderSheet(
|
||||
config,
|
||||
input.menu.map((entry) => ({
|
||||
section: entry.section,
|
||||
current: entry.section.slug === section.slug,
|
||||
cover: entry.cover,
|
||||
pages: entry.pages,
|
||||
})),
|
||||
)}
|
||||
const body = html`${renderBar(config, section.slug, input.menu)}
|
||||
${input.preview
|
||||
? html`<p class="notice notice--admin">
|
||||
Preview of a Draft — customers cannot see this yet.
|
||||
|
||||
+9
-10
@@ -130,9 +130,8 @@ describe('a Section page', () => {
|
||||
assert.doesNotMatch(body, /Older menus/);
|
||||
assert.doesNotMatch(body, /This is an older menu/);
|
||||
assert.match(body, /src="\/assets\/app\.js"/);
|
||||
assert.match(body, /class="switcher"/, 'the bar names the menu and opens the switcher');
|
||||
assert.match(body, /<span class="switcher__label">Bar<\/span>/);
|
||||
assert.match(body, /id="menu-sheet"/, 'the sheet exists for the script to open');
|
||||
assert.match(body, /class="bar__nav"/, 'the bar links the other menus');
|
||||
assert.match(body, /class="bar__link is-current"[^>]*aria-current="page"/s, 'the current one is marked');
|
||||
});
|
||||
|
||||
it('serves both the trailing-slash and bare URLs', async () => {
|
||||
@@ -147,8 +146,8 @@ describe('a Section page', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the menu switcher', () => {
|
||||
it('lists every Section that has a menu, in category order, marking the one being read', async () => {
|
||||
describe('the bar', () => {
|
||||
it('links every Section that has a menu, in category order, marking the one being read', async () => {
|
||||
await seedPublishedBar();
|
||||
const store = app.menu.store;
|
||||
const food = await store.create('food', { label: 'Food Menu' });
|
||||
@@ -156,18 +155,18 @@ describe('the menu switcher', () => {
|
||||
await store.publish(food.id);
|
||||
|
||||
const page = await app.inject({ method: 'GET', url: '/bar/' });
|
||||
const listed = [...page.body.matchAll(/class="sheet__item[^"]*"\s+href="\/([a-z-]+)\//g)].map(
|
||||
const linked = [...page.body.matchAll(/class="bar__link[^"]*"\s+href="\/([a-z-]+)\//g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
assert.deepEqual(listed, ['food', 'bar']);
|
||||
assert.equal((page.body.match(/sheet__item is-current/g) ?? []).length, 1);
|
||||
assert.match(page.body, />Showing</);
|
||||
assert.deepEqual(linked, ['food', 'bar']);
|
||||
assert.equal((page.body.match(/bar__link is-current/g) ?? []).length, 1);
|
||||
assert.match(page.body, /class="bar__link is-current"[^>]*href="\/bar\/"/s);
|
||||
});
|
||||
|
||||
it('leaves a category with no menu out until it has one', async () => {
|
||||
await seedPublishedBar();
|
||||
const page = await app.inject({ method: 'GET', url: '/bar/' });
|
||||
assert.doesNotMatch(page.body, /sheet__name">Wine/);
|
||||
assert.doesNotMatch(page.body, /bar__link[^>]*>Wine</);
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/wine/' })).statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user