/**
* 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, 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. */
let lenis = null;
if (typeof Lenis !== 'undefined') {
lenis = new Lenis({ autoRaf: true, anchors: 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]');
const chips = gsap.utils.toArray('[data-diet]');
const items = gsap.utils.toArray('[data-item]');
if (!bar || !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' });
});
});
})();
/* ============================================
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.kill();
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=null;"undefined"!=typeof Lenis&&(t=new Lenis({autoRaf:!0,anchors:!0}));const e=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(t){const e=t.conditions.isMotion,n=new Map,o=[];function r(t,e,o){t.addEventListener(e,o);const r=n.get(t)||{};r[e]=o,n.set(t,r)}const a=[];return function(){const t=document.querySelector("[data-filters]"),n=gsap.utils.toArray("[data-diet]"),s=gsap.utils.toArray("[data-item]");if(!t||!n.length||!s.length)return;const i=gsap.utils.toArray("[data-empty]");function c(){i.forEach(function(t){const e=t.closest("[data-panel]");if(!e)return;const n=Array.prototype.slice.call(e.querySelectorAll("[data-item]")),o=n.some(function(t){return!t.classList.contains("is-filtered")});t.hidden=o||0===n.length})}function l(){s.forEach(function(t){const n=function(t){const e=t.dataset.diet||"";return a.every(function(t){return-1!==e.split(/\s+/).indexOf(t)})}(t),o=t.classList.contains("is-filtered");n&&o?(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"})):n||o||(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()}n.forEach(function(t){r(t,"click",function(){const e=t.dataset.diet,n=a.indexOf(e),o=-1===n;o?a.push(e):a.splice(n,1),t.classList.toggle("is-on",o),t.setAttribute("aria-pressed",o?"true":"false"),l()})}),o.push(function(){a.length=0,s.forEach(function(t){t.classList.remove("is-filtered"),gsap.set(t,{clearProps:"opacity,transform"})}),n.forEach(function(t){t.classList.remove("is-on"),t.setAttribute("aria-pressed","false")}),i.forEach(function(t){t.hidden=!0})})}(),function(){const t=document.querySelector("[data-tabs]"),n=gsap.utils.toArray("[data-tab]"),a=gsap.utils.toArray("[data-panel]");if(!t||!n.length||!a.length)return;t.setAttribute("role","tablist");const s=[];if(n.forEach(function(t){const e=t.dataset.tab,n=a.filter(function(t){return t.dataset.panel===e})[0];n&&(t.setAttribute("role","tab"),t.id||(t.id="tab-"+e),n.setAttribute("role","tabpanel"),n.setAttribute("aria-labelledby",t.id),s.push({tab:t,panel:n}))}),!s.length)return;const i=document.documentElement,c=n.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(s.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 n=e===t;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false"),e.tabIndex=n?0:-1})}function p(t){i.classList.add("menu-all"),d(c),s.forEach(function(t){t.panel.classList.remove("is-active"),gsap.set(t.panel,{clearProps:"opacity,transform"})}),t&&f(s.map(function(t){return t.panel})),ScrollTrigger.refresh()}function g(t,e){t<0||t>=s.length||(i.classList.remove("menu-all"),d(s[t].tab),s.forEach(function(e,n){const o=n===t;e.panel.classList.toggle("is-active",o),o||gsap.set(e.panel,{clearProps:"opacity,transform"})}),e&&f([s[t].panel]),ScrollTrigger.refresh())}function h(t,e){if(c&&t===c)return void p(e);const n=s.map(function(t){return t.tab}).indexOf(t);n>=0&&g(n,e)}l.forEach(function(t,e){r(t,"click",function(e){e.preventDefault(),h(t,!0)}),r(t,"keydown",function(t){const n=t.key;let o=-1;if("ArrowRight"===n||"ArrowDown"===n)o=(e+1)%l.length;else if("ArrowLeft"===n||"ArrowUp"===n)o=(e-1+l.length)%l.length;else if("Home"===n)o=0;else{if("End"!==n)return;o=l.length-1}t.preventDefault(),h(l[o],!0),l[o].focus()})}),c?p(!1):g(0,!1),o.push(function(){u&&u.kill(),i.classList.remove("menu-all"),s.forEach(function(t){gsap.set(t.panel,{clearProps:"opacity,transform"})})})}(),function(){if(!e)return;const t=gsap.utils.toArray("[data-note]");if(!t.length)return;const n=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})}});o.push(function(){n.forEach(function(t){t.kill()}),gsap.set(t,{clearProps:"opacity,transform"})})}(),function(){n.forEach(function(t,e){Object.keys(t).forEach(function(n){e.removeEventListener(n,t[n])})}),n.clear(),o.forEach(function(t){t()}),o.length=0,ScrollTrigger.getAll().forEach(function(t){t.kill()})}})});window.gsapContext=e,window.addEventListener("beforeunload",function(){e&&e.kill(),t&&t.destroy()})});