@5mts/canopy 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ // Show/hide styles for disclosure.js. Add colors, borders, and spacing in
2
+ // your theme. Wrap an include in a media query to limit it to certain widths,
3
+ // and hide the toggle outside those widths: .my-toggle { display: none; }.
4
+ // Keep the toggle hidden by default for disclosure-dropdown; it shows the
5
+ // toggle only in browsers with :has(). Both mixins leave content visible
6
+ // in browsers without :has().
7
+
8
+ // Add an open/closed arrow to the button. Empty alt text keeps screen readers
9
+ // from reading the arrow where supported; the first declaration is a fallback.
10
+ @mixin disclosure-glyph($closed: '▼', $open: '▲') {
11
+ &::after {
12
+ content: $closed;
13
+ content: $closed / '';
14
+ }
15
+
16
+ &[aria-expanded='true']::after {
17
+ content: $open;
18
+ content: $open / '';
19
+ }
20
+ }
21
+
22
+ // Include on the container passed to disclosure(). Hides its children while
23
+ // closed, except the toggle and $preserve matches. For example, $preserve: 'h2'
24
+ // keeps headings available to screen readers. Style the toggle in your theme.
25
+ @mixin disclosure-accordion($toggle-class, $preserve: ()) {
26
+ $spared: ':not(.#{$toggle-class})';
27
+ @each $selector in $preserve {
28
+ $spared: $spared + ':not(#{$selector})';
29
+ }
30
+
31
+ &:has(.#{$toggle-class}:not([aria-expanded='true'])) > #{$spared} {
32
+ display: none;
33
+ }
34
+ }
35
+
36
+ // Include on the container passed to disclosure(). Shows $panel as a
37
+ // full-width overlay below the toggle when open. The panel can be nested.
38
+ // Put dropdown colors, backgrounds, and shadows in the include's content block
39
+ // so they apply only in browsers that support the dropdown.
40
+ @mixin disclosure-dropdown($toggle-class, $panel: 'ul', $panel-display: flex) {
41
+ position: relative; // Position the panel relative to this container.
42
+
43
+ @supports selector(:has(*)) {
44
+ // Full-width toggle: label left, arrow right.
45
+ .#{$toggle-class} {
46
+ display: flex;
47
+ justify-content: space-between;
48
+ width: 100%;
49
+ text-align: left;
50
+ cursor: pointer;
51
+ }
52
+
53
+ #{$panel} {
54
+ display: none;
55
+ position: absolute;
56
+ width: 100%;
57
+ }
58
+
59
+ &:has(.#{$toggle-class}[aria-expanded='true']) #{$panel} {
60
+ display: $panel-display;
61
+ }
62
+
63
+ @content;
64
+ }
65
+ }
@@ -0,0 +1,7 @@
1
+ // Import these mixins separately. Each adds CSS only when you @include it.
2
+ // @use 'pkg:@5mts/canopy/patterns' as *;
3
+ @forward 'column-list';
4
+ @forward 'audio-player-bottom';
5
+ @forward 'disclosure';
6
+ @forward 'relocate-flash-guard';
7
+ @forward 'sidebar';
@@ -0,0 +1,54 @@
1
+ // Hide an element until relocate.js moves it and adds data-canopy-relocated.
2
+ // Include inside the element's rule:
3
+ //
4
+ // @include grove-selector('podcast-subscribe') {
5
+ // @include relocate-flash-guard($dest: '.my-sidebar-slot');
6
+ // }
7
+ //
8
+ // With $dest, hide only on pages containing that destination. New elements
9
+ // loaded during navigation stay hidden until moved. If JS doesn't run,
10
+ // reveal the content after $fallback-delay (5s). Reduced motion skips the
11
+ // transition but still restores the content.
12
+
13
+ $-flash-guard-keyframes-emitted: false;
14
+
15
+ @mixin -flash-guard-hidden($duration, $delay, $shift) {
16
+ height: 0;
17
+ opacity: 0;
18
+ overflow: hidden;
19
+ transform: translateY($shift);
20
+ // Allow the height to animate from 0 to auto.
21
+ interpolate-size: allow-keywords;
22
+ animation: canopy-relocate-reveal $duration $delay forwards;
23
+
24
+ @media (prefers-reduced-motion: reduce) {
25
+ transform: none;
26
+ animation-duration: 0s;
27
+ }
28
+ }
29
+
30
+ @mixin relocate-flash-guard($dest: null, $fallback-delay: 5s, $duration: 0.4s, $shift: -0.5rem) {
31
+ // Add the shared animation once per compilation.
32
+ @if not $-flash-guard-keyframes-emitted {
33
+ $-flash-guard-keyframes-emitted: true !global;
34
+ @at-root {
35
+ @keyframes canopy-relocate-reveal {
36
+ to {
37
+ height: auto;
38
+ opacity: 1;
39
+ transform: translateY(0);
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ @if $dest {
46
+ body:has(#{$dest}) &:not([data-canopy-relocated]) {
47
+ @include -flash-guard-hidden($duration, $fallback-delay, $shift);
48
+ }
49
+ } @else {
50
+ &:not([data-canopy-relocated]) {
51
+ @include -flash-guard-hidden($duration, $fallback-delay, $shift);
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,77 @@
1
+ // Move the sidebar to the left and adjust its margins. By default, use
2
+ // Grove's breakpoint for the chosen container. Nest the include to choose
3
+ // which pages to change:
4
+ //
5
+ // html:has(#sub-nav-menu) {
6
+ // @include sidebar-left($gap: 2rem, $stacked: sidebar-first);
7
+ // }
8
+
9
+ @use 'sass:map';
10
+ @use '../selectors' as *;
11
+ @use '../layout' as *;
12
+
13
+ // Widths where Grove puts the sidebar beside the main content.
14
+ // For page-container, md fits OneOff/Author pages. Pass $breakpoint:
15
+ // grove-bp('md-lg') for BlogPost/LiveBlog/ArtP pages.
16
+ // show-container spans different widths, so it needs an explicit $breakpoint.
17
+ $-sidebar-native-bp: (
18
+ 'page-container': grove-bp('md'),
19
+ 'show-landing-container': grove-bp('md'),
20
+ 'episode-container': grove-bp('md-lg'),
21
+ );
22
+
23
+ // $container: selector key for the flex parent of main content and aside.
24
+ // Use page-container, show-landing-container, or episode-container.
25
+ // $gap: space to the right of the sidebar, at all widths.
26
+ // $breakpoint: put the sidebar on the left at this width and above.
27
+ // Defaults to the container's width listed above.
28
+ // $stacked: below that width, sidebar-first puts the aside above the content;
29
+ // sidebar-last uses document order (normally aside below). Default null
30
+ // keeps Grove's layout. Set this if a higher breakpoint should keep content
31
+ // stacked past Grove's usual breakpoint.
32
+ @mixin sidebar-left($container: 'page-container', $gap: null, $breakpoint: null, $stacked: null) {
33
+ @if $stacked != null and $stacked != sidebar-first and $stacked != sidebar-last {
34
+ @error "sidebar-left: $stacked must be sidebar-first or sidebar-last (or null to leave Grove's stacking alone).";
35
+ }
36
+
37
+ $flip-at: $breakpoint;
38
+ @if $flip-at == null {
39
+ $flip-at: map.get($-sidebar-native-bp, $container);
40
+ @if $flip-at == null {
41
+ @error "sidebar-left: no known Grove flip point for '#{$container}' — it may span families with different breakpoints (show-container does). Use a family key (show-landing-container, episode-container) or pass $breakpoint.";
42
+ }
43
+ }
44
+
45
+ @include grove-selector($container) {
46
+ @media (min-width: $flip-at) {
47
+ flex-direction: row-reverse;
48
+ justify-content: space-between;
49
+ }
50
+
51
+ @if $stacked != null {
52
+ @media (max-width: #{$flip-at - 1px}) {
53
+ display: flex;
54
+ @if $stacked == sidebar-first {
55
+ flex-direction: column-reverse;
56
+ } @else {
57
+ flex-direction: column;
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ // Remove right-sidebar margins and apply the requested gap.
64
+ @include grove-selector('sidebar') {
65
+ margin-left: 0;
66
+
67
+ @if $gap != null {
68
+ margin-right: $gap;
69
+ }
70
+
71
+ margin-top: 0;
72
+ }
73
+
74
+ @include grove-selector('sidebar-content') {
75
+ margin-top: 0;
76
+ }
77
+ }
package/ready.js ADDED
@@ -0,0 +1,94 @@
1
+ // Run code when Grove content loads or changes, including after navigation.
2
+ // DOMContentLoaded alone misses content Grove adds later.
3
+ // Keep callbacks quick and safe to repeat: check before adding or changing things.
4
+
5
+ const callbacks = new Set();
6
+ let started = false;
7
+ let scheduled = false;
8
+
9
+ // Run callback when the page is ready, content changes, or the URL changes.
10
+ // Returns a function that stops the callback.
11
+ export function onGroveRender(callback) {
12
+ callbacks.add(callback);
13
+ start();
14
+ schedule();
15
+ return () => callbacks.delete(callback);
16
+ }
17
+
18
+ // Run callback(el) once per matching element, including elements added later.
19
+ export function onGroveElement(selector, callback) {
20
+ const seen = new WeakSet();
21
+ return onGroveRender(() => {
22
+ for (const el of document.querySelectorAll(selector)) {
23
+ if (seen.has(el)) continue;
24
+ seen.add(el);
25
+ callback(el);
26
+ }
27
+ });
28
+ }
29
+
30
+ // Run callback(url) on load and when the path or query changes, including Back.
31
+ // Ignores hash changes. Content may still be loading; use onGroveElement to
32
+ // work with page elements. Returns a function that stops the callback.
33
+ export function onGroveNavigate(callback) {
34
+ // Remember only the last URL so returning to a page runs the callback again.
35
+ let last;
36
+ return onGroveRender(() => {
37
+ const url = location.pathname + location.search;
38
+ if (url === last) return;
39
+ last = url;
40
+ callback(url);
41
+ });
42
+ }
43
+
44
+ // Start watching on first use, not on import.
45
+ function start() {
46
+ if (started) return;
47
+ started = true;
48
+ if (document.readyState === "loading") {
49
+ document.addEventListener("DOMContentLoaded", () => {
50
+ observeBody();
51
+ schedule();
52
+ }, { once: true });
53
+ } else {
54
+ observeBody();
55
+ }
56
+ // Also handle Back/Forward and pages restored from the browser's cache.
57
+ window.addEventListener("pageshow", e => { if (e.persisted) schedule(); });
58
+ window.addEventListener("popstate", schedule);
59
+ // These methods fire no event. Watch them in case the URL changes after content.
60
+ for (const method of ["pushState", "replaceState"]) {
61
+ const orig = history[method];
62
+ history[method] = function (...args) {
63
+ const result = orig.apply(this, args);
64
+ schedule();
65
+ return result;
66
+ };
67
+ }
68
+ }
69
+
70
+ function observeBody() {
71
+ new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
72
+ }
73
+
74
+ // Group changes so callbacks run once per batch.
75
+ function schedule() {
76
+ if (scheduled) return;
77
+ scheduled = true;
78
+ queueMicrotask(() => {
79
+ scheduled = false;
80
+ runAll();
81
+ });
82
+ }
83
+
84
+ function runAll() {
85
+ if (document.readyState === "loading") return; // wait until the DOM is ready
86
+ for (const callback of [...callbacks]) {
87
+ if (!callbacks.has(callback)) continue; // stopped by an earlier callback
88
+ try {
89
+ callback();
90
+ } catch (err) {
91
+ console.error("[canopy] onGroveRender callback failed:", err);
92
+ }
93
+ }
94
+ }
package/relocate.js ADDED
@@ -0,0 +1,38 @@
1
+ // Move elements selected by `source` to `dest` (inside it by default).
2
+ // Runs on load, content changes, and navigation, including Back/Forward.
3
+ //
4
+ // Both values are CSS selectors; use grove('key') for a mapped Grove selector.
5
+ //
6
+ // SCSS uses these names; keep them in sync:
7
+ // data-canopy-relocated marks moved elements so they move only once.
8
+ // canopy-relocated marks a destination that received elements.
9
+ // CSS can hide elements until data-canopy-relocated is set to prevent a flash.
10
+
11
+ import { onGroveRender } from "./ready.js";
12
+
13
+ const rules = [];
14
+ let registered = false;
15
+
16
+ // rule = { source, dest, position = 'beforeend' }
17
+ export function relocate(rule) {
18
+ rules.push(rule);
19
+ if (!registered) {
20
+ registered = true; // start watching only when a rule is added
21
+ onGroveRender(applyAll);
22
+ }
23
+ }
24
+
25
+ function applyAll() {
26
+ for (const { source, dest, position = "beforeend" } of rules) {
27
+ const destEl = document.querySelector(dest);
28
+ if (!destEl) continue;
29
+ let moved = false;
30
+ for (const node of document.querySelectorAll(source)) {
31
+ if (node.dataset.canopyRelocated) continue;
32
+ node.dataset.canopyRelocated = "true";
33
+ destEl.insertAdjacentElement(position, node);
34
+ moved = true;
35
+ }
36
+ if (moved) destEl.classList.add("canopy-relocated");
37
+ }
38
+ }
package/selectors.js ADDED
@@ -0,0 +1,121 @@
1
+ // GENERATED — do not edit. Source: _selectors.scss
2
+ // Regenerate via: npm run build:selectors
3
+
4
+ export const groveSelectors = Object.freeze({
5
+ "container-columns": ".FourColumnContainer, .OneColumnContainer, .ThreeColumnContainer, .TwoColumnContainer3070, .TwoColumnContainer5050, .TwoColumnContainer7030",
6
+ "page-is-show": ".PodcastPage, .RadioShowPage",
7
+ "page-is-episode": ".PCEP, .RSEP",
8
+ "page-is-archive": ".SectionPage, .TagPage",
9
+ "promo": "ps-promo:not(:where(ps-tabs,.EplB) ps-promo)",
10
+ "promo-media": "[class^=\"Promo\"][class$=\"-media\"]",
11
+ "promo-content": "[class^=\"Promo\"][class$=\"-content\"]",
12
+ "audio-player": ".PH-persistent-player",
13
+ "promo-audio-label": "[class^=\"Promo\"][class$=\"-audio-label\"]",
14
+ "button": "button, button.Button, button.Link",
15
+ "episode-list": ".EplB, .EplA",
16
+ "episode-list-wrapper": ".EplB-items, .EplA-items",
17
+ "episode-list-header": ".EplB-header, .EplA-header",
18
+ "episode-list-title": ".EplB-header-title, .EplA-header-title",
19
+ "episode-list-item": ".EplB-items-item, .EplA-items-item",
20
+ "promo-title": "[class^=\"Promo\"][class$=\"-title\"]",
21
+ "heading": "[class$=\"-header\"], [class$=\"-heading\"]",
22
+ "heading-title": "[class$=\"-header-title\"]",
23
+ "sidebar": "main > aside, [class$=\"-wrapper\"] > aside",
24
+ "sidebar-content": "aside > [class$=\"-aside-content\"]",
25
+ "main-content": "[class$=\"-mainContent\"]",
26
+ "main-headline": "h1[class$=\"-headline\"]",
27
+ "byline": "[class$=\"-byline\"]",
28
+ "content-meta": "[class$=\"-contentInfo\"]",
29
+ "page-lead": "main [class$=\"-lead\"]",
30
+ "breadcrumbs": "[class$=\"-breadcrumbs-wrapper\"]",
31
+ "people-byline": ".PromoPeople-content",
32
+ "article-body": "article [class$=\"-articleBody\"]",
33
+ "article-subhead": "article h2[class$=\"-subheadline\"]",
34
+ "post-article-tags": "[class$=\"-tags\"]",
35
+ "post-article-bio": "[class$=\"-bottomByline\"]",
36
+ "page-description": "[class$=\"-pageDescription-content\"]",
37
+ "series-banner": ".SeriesBanner",
38
+ "page-container": "[class$=\"Page-wrapper\"]",
39
+ "page-header": "[class$=\"Page-head\"]",
40
+ "page-head-text": "[class$=\"Page-head-text\"]",
41
+ "main-without-aside": "main:not(:has(~ aside, aside), aside ~ main)",
42
+ "main-with-aside": "main:has(~ aside, aside), aside ~ main",
43
+ "metadata-relocatable-links": "[class$=\"-contentInfo\"] a",
44
+ "content-image": "figure .Image, .PromoXS-media .Image",
45
+ "caption": ".Figure-caption, .CarouselSlide-infoDescription, .Figure-credit, .CarouselSlide-infoAttribution, [class$=\"-credit-container\"] [class$=\"-credit\"], [class$=\"-credit-container\"] [class$=\"-divider\"], [class$=\"-credit-container\"] [class$=\"-source\"]",
46
+ "title-text": "[class$=\"-title\"]",
47
+ "description": "[class$=\"-description\"]",
48
+ "news-story": ".ArtP:not(.aside)",
49
+ "podcast-container": ".PodcastPage-main, .PCEP-content .PCEP-wrapper",
50
+ "podcast-aside": ".PodcastPage-aside, .PCEP-aside",
51
+ "podcast-cover-and-title": ".PodcastPage-top",
52
+ "podcast-byline": ".PodcastPage-byline",
53
+ "podcast-description": ".PodcastPage-byline + *",
54
+ "podcast-subscribe": ".PodcastActionBar",
55
+ "podcast-subscribe-label": ".PodcastLink-provider",
56
+ "podcast-social-bar": ".PodcastPage-social",
57
+ "podcast-icon-headings": ".PodcastActionBar-heading, .PodcastPage-social .SocialBar-heading",
58
+ "podcast-hostname": ".PodcastPage-hostName",
59
+ "episode-series-header": "[class$=\"EP-parentInfo\"]",
60
+ "podcast-header": ".PodcastPage-info",
61
+ "radio-container": ".RadioShowPage-main, .RSEP-content .RSEP-wrapper",
62
+ "radio-aside": ".RadioShowPage-aside, .RSEP-aside",
63
+ "radio-cover-and-title": ".RadioShowPage-top",
64
+ "radio-byline": ".RadioShowPage-byline",
65
+ "radio-description": ".RadioShowPage-byline + *",
66
+ "radio-social-bar": ".RadioShowPage-social",
67
+ "radio-icon-headings": ".RadioShowPage-social .SocialBar-heading",
68
+ "radio-hostname": ".RadioShowPage-hostName",
69
+ "radio-header": ".RadioShowPage-info",
70
+ "show-landing-container": ".PodcastPage-main, .RadioShowPage-main",
71
+ "episode-container": ".PCEP-content .PCEP-wrapper, .RSEP-content .RSEP-wrapper",
72
+ "show-main": "[class$=\"Page-wrapper\"], [class$=\"EP-main\"]",
73
+ "sidebar-headings": "aside h2, aside h3, aside h4",
74
+ "episode-meta": ".PCEP-contentInfo, .RSEP-contentInfo",
75
+ "episode-player": ".PCEP-audioPlayer, .RSEP-audioPlayer",
76
+ "show-schedule-info": ".RadioShowPage-mediaSchedule, .PodcastPage-podcastSchedule",
77
+ "news-tile": "ps-promo[data-content-type=\"news-story\"]",
78
+ "eyebrow": "[class$=\"-category\"]:has(> a), [class$=\"-breadcrumbs\"]:has(a)",
79
+ "author-info": "[class$=\"-authorName\"], [class$=\"-contributors\"], [class$=\"-hostName\"]",
80
+ "author-position": "[class$=\"-authorTitle\"]",
81
+ "timestamp": "[class$=\"-timestamp\"], [class$=\"-timestamp\"] > *",
82
+ "tab": "ps-tabs .Tabs-tabs-tab",
83
+ "tab-active": "ps-tabs [role=\"tab\"][data-active=\"true\"], ps-tabs .Tabs-tabs-tab[data-active=\"true\"]",
84
+ "social-action": ".ActionLink[data-social-service]",
85
+ "nav-bar": ".PH-nav-bar",
86
+ "nav-item": ".NavI",
87
+ "nav-item-list": ".NavI-items",
88
+ "nav-item-text": ".NavI-text",
89
+ "dropdown-nav-items": ".DropdownNavigation-items, .NavI .NavLink",
90
+ "stream-pill-highlighted": ".StreamPill:hover, [playing] .StreamPill",
91
+ });
92
+
93
+ export const groveContentTypes = Object.freeze(["ArtP","RSEP"]);
94
+ export const groveNewsStoryType = "ArtP";
95
+ export const groveHeadSuffixes = Object.freeze(["-breadcrumbs-wrapper","-headline","-contentInfo","-audioPlayer","-parentInfo"]);
96
+
97
+ export function grove(key) {
98
+ const value = groveSelectors[key];
99
+ if (value == null) {
100
+ throw new Error(
101
+ `No Grove selector found for key: '${key}'. ` +
102
+ `Check $grove-selectors-base — or write the raw selector if the class is plainly readable.`
103
+ );
104
+ }
105
+ return value;
106
+ }
107
+
108
+ export function groveAll(keys, root = document) {
109
+ const list = Array.isArray(keys) ? keys : [keys];
110
+ return Array.from(root.querySelectorAll(list.map(grove).join(', ')));
111
+ }
112
+
113
+ export function groveOne(key, root = document) {
114
+ return root.querySelector(grove(key));
115
+ }
116
+
117
+ export function groveVariants(prefix, suffix, letters) {
118
+ return letters.map(L => `.${prefix}${L}${suffix}`);
119
+ }
120
+
121
+ export const isNewsStory = el => el?.classList?.contains(groveNewsStoryType);
package/subnav.js ADDED
@@ -0,0 +1,51 @@
1
+ // Label a menu for screen readers and mark the current page's link with
2
+ // aria-current="page" and the canopy-current class (or your chosen currentClass).
3
+ // Returns the matching link, or null. Use it to label a menu button:
4
+ //
5
+ // const current = subnav(container, { label: 'Section' });
6
+ // disclosure(container, { label: current?.textContent.trim() || 'Section', panel: 'ul' });
7
+ //
8
+ // Call from onGroveRender to update the menu after navigation.
9
+ // Links match by origin and path; query strings and hashes are ignored.
10
+
11
+ // Ignore trailing slashes when comparing paths.
12
+ function normalizePath(pathname) {
13
+ return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
14
+ }
15
+
16
+ export function subnav(container, options = {}) {
17
+ const {
18
+ label = "Menu", // menu name for screen readers
19
+ currentClass = "canopy-current",
20
+ } = options;
21
+
22
+ const isList = container.matches("ul, ol"); // preserve the list's role
23
+
24
+ if (!isList) {
25
+ if (container.tagName !== "NAV") container.setAttribute("role", "navigation");
26
+ if (!container.hasAttribute("aria-label")) {
27
+ container.setAttribute("aria-label", label);
28
+ }
29
+ }
30
+
31
+ // list-style: none drops the list role in Safari; restore it.
32
+ const lists = isList ? [container] : container.querySelectorAll("ul, ol");
33
+ for (const list of lists) list.setAttribute("role", "list");
34
+
35
+ const currentPath = normalizePath(location.pathname);
36
+ let currentLink = null;
37
+
38
+ for (const link of container.querySelectorAll("a[href]")) {
39
+ // An external site's /about is not this site's /about.
40
+ const isCurrent = link.origin === location.origin
41
+ && normalizePath(link.pathname) === currentPath;
42
+ link.classList.toggle(currentClass, isCurrent);
43
+ if (isCurrent) {
44
+ link.setAttribute("aria-current", "page");
45
+ if (!currentLink) currentLink = link; // first match wins
46
+ } else {
47
+ link.removeAttribute("aria-current");
48
+ }
49
+ }
50
+ return currentLink;
51
+ }