/**
* Sorrel, QR Table Menu Template
*
* A digital menu made to be opened from a QR code at the table. The
* signature is the sticky category bar: tapping Brunch, Small Plates,
* Sweets or Drinks crossfades to that section and staggers its dishes
* in, and the dietary chips (Veg / Vegan / GF) filter every section at
* once, fading the dishes that do not match out of the list.
*
* With JavaScript off the whole menu is one readable document: every
* section stacked, every dish shown, and the category bar degrades to
* plain in-page anchor links. The script adds a class that upgrades the
* bar into a tab set showing one section at a time; nothing is ever
* hidden behind a control that cannot run.
*
* Sections are independent. Every lookup is guarded, so a buyer can
* delete a whole category or a single dish and the rest still works.
*
* @plugins ScrollTrigger
* @techniques tabs, filter, click-toggle, scroll-reveal, stagger
*/
/* Registering the plugin FIRST, inside the guard, is load-bearing: the
has-js class gates the tab-panel layout, so a partial CDN failure
(gsap present, ScrollTrigger missing) must stop BEFORE the class is
added, leaving the full stacked menu rather than a page with every
section but one collapsed and no working tabs to reach them. */
if (typeof gsap !== 'undefined' && typeof ScrollTrigger !== 'undefined') {
gsap.registerPlugin(ScrollTrigger);
document.documentElement.classList.add('has-js');
} else {
// CDN blocked: GSAP never loaded, so undo the pre-paint hide from
// the head script and let the plain document show.
document.documentElement.classList.remove('has-js');
}
/* Runs immediately if the DOM is already parsed (a late or deferred
script, e.g. Cloudflare Rocket Loader) and waits for DOMContentLoaded
otherwise. A bare listener silently never fires under deferral. */
(function onReady(init) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(function initSorrel() {
/* OPTIONAL: Lenis smooth scroll. Remove this block and the CDN tag to drop it. */
/* Smooth scroll is opt-out: data-smooth="off" on <html>, or ?smooth=off in the
URL. Also off under prefers-reduced-motion, which Lenis does not do itself. */
let wantsSmooth = (new URLSearchParams(location.search).get('smooth')
|| document.documentElement.dataset.smooth) !== 'off'
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let lenis = null;
let lenisTick = null;
if (wantsSmooth && typeof Lenis !== 'undefined') {
/* Lenis and ScrollTrigger must share ONE clock. Left on its own rAF,
Lenis moves the page while ScrollTrigger is still reading the
previous frame. See docs/gsap-patterns.md. */
const hasST = typeof ScrollTrigger !== 'undefined';
lenis = new Lenis({ autoRaf: !hasST, anchors: true });
if (hasST) {
lenis.on('scroll', ScrollTrigger.update);
lenisTick = function (time) { lenis.raf(time * 1000); };
gsap.ticker.add(lenisTick);
gsap.ticker.lagSmoothing(0);
/* A refresh restores the native scroll position while Lenis is
still lerping toward its older target. */
ScrollTrigger.addEventListener('refresh', function () {
lenis.scrollTo(window.scrollY, { immediate: true, force: true });
});
}
}
const ctx = gsap.context(function gsapContextCallback() {
const mm = gsap.matchMedia();
mm.add({
isMotion: '(prefers-reduced-motion: no-preference)',
isReduced: '(prefers-reduced-motion: reduce)'
}, function matchMediaCallback(context) {
const isMotion = context.conditions.isMotion;
const handlers = new Map();
const resets = [];
function listen(el, type, fn) {
el.addEventListener(type, fn);
const entry = handlers.get(el) || {};
entry[type] = fn;
handlers.set(el, entry);
}
/* Filter state is shared: chips filter every section, and a
dish stays hidden while you switch categories, so it lives
out here rather than inside either module. */
const activeDiets = [];
/* ============================================
DIETARY FILTER CHIPS
Each dish carries its own diet tokens in data-diet
(a vegan dish is tagged veg too, since it qualifies).
A chip is a toggle; several active chips AND together,
so Veg + GF shows only dishes that are both.
A hidden dish gets .is-filtered, which sets display:none
in CSS, so with the chips absent (no JS) every dish shows.
============================================ */
(function initFilters() {
const bar = document.querySelector('[data-filters]');
if (!bar) return;
/* Scope the chips to the filter bar. The dishes also carry a
data-diet (that is what the chips test against), so a bare
'[data-diet]' selector would wire a chip toggle onto every
dish - and a tap on a dish would push its whole diet string
("veg gf") as an active filter that matches nothing, hiding
the entire menu. */
const chips = gsap.utils.toArray(bar.querySelectorAll('[data-diet]'));
const items = gsap.utils.toArray('[data-item]');
if (!chips.length || !items.length) return;
const empties = gsap.utils.toArray('[data-empty]');
function matches(item) {
const diet = item.dataset.diet || '';
return activeDiets.every(function (d) {
return diet.split(/\s+/).indexOf(d) !== -1;
});
}
/* A category whose every dish was filtered out shows a
short note in place of an empty list. */
function updateEmpties() {
empties.forEach(function (note) {
const panel = note.closest('[data-panel]');
if (!panel) return;
const rows = Array.prototype.slice.call(panel.querySelectorAll('[data-item]'));
const anyShown = rows.some(function (r) {
return !r.classList.contains('is-filtered');
});
note.hidden = anyShown || rows.length === 0;
});
}
function apply() {
items.forEach(function (item) {
const show = matches(item);
const hidden = item.classList.contains('is-filtered');
if (show && hidden) {
item.classList.remove('is-filtered');
if (isMotion) {
gsap.fromTo(item,
{ opacity: 0, y: 6 },
{ opacity: 1, y: 0, duration: 0.3, ease: 'power2.out' }
);
} else {
gsap.set(item, { clearProps: 'opacity,transform' });
}
} else if (!show && !hidden) {
if (isMotion) {
gsap.to(item, {
opacity: 0,
duration: 0.22,
ease: 'power1.out',
onComplete: function () {
item.classList.add('is-filtered');
gsap.set(item, { clearProps: 'opacity,transform' });
updateEmpties();
}
});
} else {
item.classList.add('is-filtered');
}
}
});
if (!isMotion) updateEmpties();
/* Sections change height as dishes leave, so anything
pinned below has moved. */
ScrollTrigger.refresh();
}
chips.forEach(function (chip) {
listen(chip, 'click', function () {
const diet = chip.dataset.diet;
const i = activeDiets.indexOf(diet);
const on = i === -1;
if (on) activeDiets.push(diet);
else activeDiets.splice(i, 1);
chip.classList.toggle('is-on', on);
chip.setAttribute('aria-pressed', on ? 'true' : 'false');
apply();
});
});
resets.push(function () {
activeDiets.length = 0;
items.forEach(function (item) {
item.classList.remove('is-filtered');
gsap.set(item, { clearProps: 'opacity,transform' });
});
chips.forEach(function (chip) {
chip.classList.remove('is-on');
chip.setAttribute('aria-pressed', 'false');
});
empties.forEach(function (note) { note.hidden = true; });
});
})();
/* ============================================
SIGNATURE: THE CATEGORY BAR
Anchor links in the markup, so with no JS they jump to
the stacked sections. Here they become a tab set: one
section shows at a time, and switching crossfades the
incoming section and staggers its (unfiltered) dishes in.
No CSS transform start state is set on the dishes, so the
fromTo below is the only thing touching their transform
and nothing survives between switches.
============================================ */
(function initTabs() {
const nav = document.querySelector('[data-tabs]');
const tabs = gsap.utils.toArray('[data-tab]');
const panels = gsap.utils.toArray('[data-panel]');
if (!nav || !tabs.length || !panels.length) return;
nav.setAttribute('role', 'tablist');
/* Pair each tab with its panel by name, not by index, so
deleting one category cannot shift the rest out of step. */
const pairs = [];
tabs.forEach(function (tab) {
const name = tab.dataset.tab;
const panel = panels.filter(function (p) {
return p.dataset.panel === name;
})[0];
if (!panel) return;
tab.setAttribute('role', 'tab');
if (!tab.id) tab.id = 'tab-' + name;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', tab.id);
pairs.push({ tab: tab, panel: panel });
});
if (!pairs.length) return;
const root = document.documentElement;
const allTab = tabs.filter(function (t) { return t.dataset.tab === 'all'; })[0];
if (allTab) {
allTab.setAttribute('role', 'tab');
if (!allTab.id) allTab.id = 'tab-all';
}
/* Keyboard order across the whole bar: All first, then categories. */
const order = (allTab ? [allTab] : []).concat(pairs.map(function (p) { return p.tab; }));
let build = null;
function play(panelList) {
if (build) build.kill();
if (!isMotion) return;
build = gsap.timeline();
build.fromTo(panelList,
{ opacity: 0 },
{ opacity: 1, duration: 0.28, ease: 'power1.out', stagger: panelList.length > 1 ? 0.05 : 0 }
);
/* Per-dish stagger only when a single section is shown; across
the whole menu it would be too much motion. */
if (panelList.length === 1) {
const rows = Array.prototype.slice.call(
panelList[0].querySelectorAll('[data-item]:not(.is-filtered)')
);
if (rows.length) {
build.fromTo(rows,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', stagger: 0.05, clearProps: 'transform' },
0.06
);
}
}
}
function markActive(activeTab) {
order.forEach(function (tab) {
const on = tab === activeTab;
tab.classList.toggle('is-active', on);
tab.setAttribute('aria-selected', on ? 'true' : 'false');
tab.tabIndex = on ? 0 : -1;
});
}
/* "All": every category stacked with its title as a divider. */
function showAll(animate) {
root.classList.add('menu-all');
markActive(allTab);
pairs.forEach(function (pair) {
pair.panel.classList.remove('is-active');
gsap.set(pair.panel, { clearProps: 'opacity,transform' });
});
if (animate) play(pairs.map(function (p) { return p.panel; }));
ScrollTrigger.refresh();
}
function activate(index, animate) {
if (index < 0 || index >= pairs.length) return;
root.classList.remove('menu-all');
markActive(pairs[index].tab);
pairs.forEach(function (pair, i) {
const on = i === index;
pair.panel.classList.toggle('is-active', on);
if (!on) gsap.set(pair.panel, { clearProps: 'opacity,transform' });
});
if (animate) play([pairs[index].panel]);
ScrollTrigger.refresh();
}
function select(tab, animate) {
if (allTab && tab === allTab) { showAll(animate); return; }
const idx = pairs.map(function (p) { return p.tab; }).indexOf(tab);
if (idx >= 0) activate(idx, animate);
}
order.forEach(function (tab, oi) {
listen(tab, 'click', function (event) {
/* The tab is an anchor; take over its jump so the page
does not leap to the section top on tap. */
event.preventDefault();
select(tab, true);
});
listen(tab, 'keydown', function (event) {
const key = event.key;
let ni = -1;
if (key === 'ArrowRight' || key === 'ArrowDown') ni = (oi + 1) % order.length;
else if (key === 'ArrowLeft' || key === 'ArrowUp') ni = (oi - 1 + order.length) % order.length;
else if (key === 'Home') ni = 0;
else if (key === 'End') ni = order.length - 1;
else return;
event.preventDefault();
select(order[ni], true);
order[ni].focus();
});
});
/* Default view: the whole menu ("All") if present, else the
first category, shown without the stagger so it opens composed. */
if (allTab) showAll(false); else activate(0, false);
resets.push(function () {
if (build) build.kill();
root.classList.remove('menu-all');
pairs.forEach(function (pair) {
gsap.set(pair.panel, { clearProps: 'opacity,transform' });
});
});
})();
/* ============================================
DISH DETAILS
Each dish carries a details block (calories + allergens)
that shows plainly with no JS. Here the dish name becomes a
disclosure button and the block collapses, opening on tap.
A single handler on the whole row toggles it, so a tap
anywhere on the dish opens it (the QR-menu gesture people
reach for). The button inside the name carries the keyboard
focus, the accessible name and aria-expanded; its Enter or
Space fires a click that bubbles to the same handler, so
there is one source of truth and no double toggle. The block
is pre-collapsed in CSS under .has-js, so nothing flashes.
============================================ */
(function initItemDetails() {
const items = gsap.utils.toArray('[data-item]');
if (!items.length) return;
items.forEach(function (item, i) {
const toggle = item.querySelector('[data-toggle]');
const more = item.querySelector('[data-more]');
if (!toggle || !more) return;
if (!more.id) more.id = 'item-more-' + i;
toggle.setAttribute('aria-controls', more.id);
toggle.setAttribute('aria-expanded', 'false');
/* Marks the dish as having a working disclosure, so the
chevron and pointer cursor only appear where a tap does
something. A dish whose details block was deleted skips
this block entirely and stays a plain, static name. */
item.classList.add('has-details');
/* Wrap the details in an inner element so .item__more can be
a padding-free clip container: height:0 then closes it
with no leftover gap. Guarded so a matchMedia re-run
(motion-preference change) does not nest a second wrap. */
if (!more.querySelector('.item__more-inner')) {
const inner = document.createElement('div');
inner.className = 'item__more-inner';
while (more.firstChild) inner.appendChild(more.firstChild);
more.appendChild(inner);
}
function setOpen(open) {
item.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
if (isMotion) {
gsap.to(more, {
height: open ? 'auto' : 0,
opacity: open ? 1 : 0,
duration: 0.32,
ease: 'power2.out',
/* Heights below change, so anything sticky or
pinned needs its positions recomputed. */
onComplete: function () { ScrollTrigger.refresh(); }
});
} else {
gsap.set(more, { height: open ? 'auto' : 0, opacity: open ? 1 : 0 });
ScrollTrigger.refresh();
}
}
listen(item, 'click', function () {
setOpen(!item.classList.contains('is-open'));
});
});
resets.push(function () {
items.forEach(function (item) {
const more = item.querySelector('[data-more]');
const toggle = item.querySelector('[data-toggle]');
item.classList.remove('is-open');
if (toggle) toggle.setAttribute('aria-expanded', 'false');
if (more) gsap.set(more, { clearProps: 'height,opacity' });
});
});
})();
/* ============================================
FOOTER REVEAL
The one scroll-reveal on the page, kept for the closing
notes. Guarded and once-only.
============================================ */
(function initFooterReveal() {
if (!isMotion) return;
const items = gsap.utils.toArray('[data-note]');
if (!items.length) return;
const st = ScrollTrigger.batch(items, {
start: 'top 94%',
once: true,
onEnter: function (batch) {
gsap.fromTo(batch,
{ opacity: 0, y: 16 },
{
opacity: 1,
y: 0,
duration: 0.6,
ease: 'power2.out',
stagger: 0.08,
overwrite: true
}
);
}
});
resets.push(function () {
st.forEach(function (t) { t.kill(); });
gsap.set(items, { clearProps: 'opacity,transform' });
});
})();
/* ============================================
CLEANUP
============================================ */
return function cleanup() {
handlers.forEach(function removeAll(entry, el) {
Object.keys(entry).forEach(function (type) {
el.removeEventListener(type, entry[type]);
});
});
handlers.clear();
resets.forEach(function (fn) { fn(); });
resets.length = 0;
ScrollTrigger.getAll().forEach(function (t) { t.kill(); });
};
});
});
window.gsapContext = ctx;
window.addEventListener('beforeunload', function () {
if (ctx) ctx.revert();
if (lenisTick) gsap.ticker.remove(lenisTick);
if (lenis) lenis.destroy();
});
});
"undefined"!=typeof gsap&&"undefined"!=typeof ScrollTrigger?(gsap.registerPlugin(ScrollTrigger),document.documentElement.classList.add("has-js")):document.documentElement.classList.remove("has-js"),function(t){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):t()}(function(){let t="off"!==(new URLSearchParams(location.search).get("smooth")||document.documentElement.dataset.smooth)&&!window.matchMedia("(prefers-reduced-motion: reduce)").matches,e=null,o=null;if(t&&"undefined"!=typeof Lenis){const t="undefined"!=typeof ScrollTrigger;e=new Lenis({autoRaf:!t,anchors:!0}),t&&(e.on("scroll",ScrollTrigger.update),o=function(t){e.raf(1e3*t)},gsap.ticker.add(o),gsap.ticker.lagSmoothing(0),ScrollTrigger.addEventListener("refresh",function(){e.scrollTo(window.scrollY,{immediate:!0,force:!0})}))}const n=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(t){const e=t.conditions.isMotion,o=new Map,n=[];function r(t,e,n){t.addEventListener(e,n);const r=o.get(t)||{};r[e]=n,o.set(t,r)}const a=[];return function(){const t=document.querySelector("[data-filters]");if(!t)return;const o=gsap.utils.toArray(t.querySelectorAll("[data-diet]")),i=gsap.utils.toArray("[data-item]");if(!o.length||!i.length)return;const s=gsap.utils.toArray("[data-empty]");function c(){s.forEach(function(t){const e=t.closest("[data-panel]");if(!e)return;const o=Array.prototype.slice.call(e.querySelectorAll("[data-item]")),n=o.some(function(t){return!t.classList.contains("is-filtered")});t.hidden=n||0===o.length})}function l(){i.forEach(function(t){const o=function(t){const e=t.dataset.diet||"";return a.every(function(t){return-1!==e.split(/\s+/).indexOf(t)})}(t),n=t.classList.contains("is-filtered");o&&n?(t.classList.remove("is-filtered"),e?gsap.fromTo(t,{opacity:0,y:6},{opacity:1,y:0,duration:.3,ease:"power2.out"}):gsap.set(t,{clearProps:"opacity,transform"})):o||n||(e?gsap.to(t,{opacity:0,duration:.22,ease:"power1.out",onComplete:function(){t.classList.add("is-filtered"),gsap.set(t,{clearProps:"opacity,transform"}),c()}}):t.classList.add("is-filtered"))}),e||c(),ScrollTrigger.refresh()}o.forEach(function(t){r(t,"click",function(){const e=t.dataset.diet,o=a.indexOf(e),n=-1===o;n?a.push(e):a.splice(o,1),t.classList.toggle("is-on",n),t.setAttribute("aria-pressed",n?"true":"false"),l()})}),n.push(function(){a.length=0,i.forEach(function(t){t.classList.remove("is-filtered"),gsap.set(t,{clearProps:"opacity,transform"})}),o.forEach(function(t){t.classList.remove("is-on"),t.setAttribute("aria-pressed","false")}),s.forEach(function(t){t.hidden=!0})})}(),function(){const t=document.querySelector("[data-tabs]"),o=gsap.utils.toArray("[data-tab]"),a=gsap.utils.toArray("[data-panel]");if(!t||!o.length||!a.length)return;t.setAttribute("role","tablist");const i=[];if(o.forEach(function(t){const e=t.dataset.tab,o=a.filter(function(t){return t.dataset.panel===e})[0];o&&(t.setAttribute("role","tab"),t.id||(t.id="tab-"+e),o.setAttribute("role","tabpanel"),o.setAttribute("aria-labelledby",t.id),i.push({tab:t,panel:o}))}),!i.length)return;const s=document.documentElement,c=o.filter(function(t){return"all"===t.dataset.tab})[0];c&&(c.setAttribute("role","tab"),c.id||(c.id="tab-all"));const l=(c?[c]:[]).concat(i.map(function(t){return t.tab}));let u=null;function f(t){if(u&&u.kill(),e&&(u=gsap.timeline(),u.fromTo(t,{opacity:0},{opacity:1,duration:.28,ease:"power1.out",stagger:t.length>1?.05:0}),1===t.length)){const e=Array.prototype.slice.call(t[0].querySelectorAll("[data-item]:not(.is-filtered)"));e.length&&u.fromTo(e,{opacity:0,y:10},{opacity:1,y:0,duration:.4,ease:"power2.out",stagger:.05,clearProps:"transform"},.06)}}function d(t){l.forEach(function(e){const o=e===t;e.classList.toggle("is-active",o),e.setAttribute("aria-selected",o?"true":"false"),e.tabIndex=o?0:-1})}function p(t){s.classList.add("menu-all"),d(c),i.forEach(function(t){t.panel.classList.remove("is-active"),gsap.set(t.panel,{clearProps:"opacity,transform"})}),t&&f(i.map(function(t){return t.panel})),ScrollTrigger.refresh()}function g(t,e){t<0||t>=i.length||(s.classList.remove("menu-all"),d(i[t].tab),i.forEach(function(e,o){const n=o===t;e.panel.classList.toggle("is-active",n),n||gsap.set(e.panel,{clearProps:"opacity,transform"})}),e&&f([i[t].panel]),ScrollTrigger.refresh())}function m(t,e){if(c&&t===c)return void p(e);const o=i.map(function(t){return t.tab}).indexOf(t);o>=0&&g(o,e)}l.forEach(function(t,e){r(t,"click",function(e){e.preventDefault(),m(t,!0)}),r(t,"keydown",function(t){const o=t.key;let n=-1;if("ArrowRight"===o||"ArrowDown"===o)n=(e+1)%l.length;else if("ArrowLeft"===o||"ArrowUp"===o)n=(e-1+l.length)%l.length;else if("Home"===o)n=0;else{if("End"!==o)return;n=l.length-1}t.preventDefault(),m(l[n],!0),l[n].focus()})}),c?p(!1):g(0,!1),n.push(function(){u&&u.kill(),s.classList.remove("menu-all"),i.forEach(function(t){gsap.set(t.panel,{clearProps:"opacity,transform"})})})}(),function(){const t=gsap.utils.toArray("[data-item]");t.length&&(t.forEach(function(t,o){const n=t.querySelector("[data-toggle]"),a=t.querySelector("[data-more]");if(n&&a){if(a.id||(a.id="item-more-"+o),n.setAttribute("aria-controls",a.id),n.setAttribute("aria-expanded","false"),t.classList.add("has-details"),!a.querySelector(".item__more-inner")){const t=document.createElement("div");for(t.className="item__more-inner";a.firstChild;)t.appendChild(a.firstChild);a.appendChild(t)}r(t,"click",function(){var o;o=!t.classList.contains("is-open"),t.classList.toggle("is-open",o),n.setAttribute("aria-expanded",o?"true":"false"),e?gsap.to(a,{height:o?"auto":0,opacity:o?1:0,duration:.32,ease:"power2.out",onComplete:function(){ScrollTrigger.refresh()}}):(gsap.set(a,{height:o?"auto":0,opacity:o?1:0}),ScrollTrigger.refresh())})}}),n.push(function(){t.forEach(function(t){const e=t.querySelector("[data-more]"),o=t.querySelector("[data-toggle]");t.classList.remove("is-open"),o&&o.setAttribute("aria-expanded","false"),e&&gsap.set(e,{clearProps:"height,opacity"})})}))}(),function(){if(!e)return;const t=gsap.utils.toArray("[data-note]");if(!t.length)return;const o=ScrollTrigger.batch(t,{start:"top 94%",once:!0,onEnter:function(t){gsap.fromTo(t,{opacity:0,y:16},{opacity:1,y:0,duration:.6,ease:"power2.out",stagger:.08,overwrite:!0})}});n.push(function(){o.forEach(function(t){t.kill()}),gsap.set(t,{clearProps:"opacity,transform"})})}(),function(){o.forEach(function(t,e){Object.keys(t).forEach(function(o){e.removeEventListener(o,t[o])})}),o.clear(),n.forEach(function(t){t()}),n.length=0,ScrollTrigger.getAll().forEach(function(t){t.kill()})}})});window.gsapContext=n,window.addEventListener("beforeunload",function(){n&&n.revert(),o&&gsap.ticker.remove(o),e&&e.destroy()})});