@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Five Mountains
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,172 @@
1
- # Temporary Holding Version
1
+ # Canopy: A Grove styling & design toolkit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A library to assist in custom designs for sites using the NPR Grove CMS.
4
+
5
+ **This project is not affiliated with NPR, Grove, Brightspot, or other vendors.** This is a completely independent project of [Five Mountains](https://5mts.com), a private, independent consultancy supporting public media and local news. While we will try to keep this library updated as a community resource, this project is provided purely "as is" and we can't guarantee it will work or continue working for your needs.
6
+
7
+ We encourage questions, issues, and submissions — and if you need support with Grove or other digital projects, we take a limited number of paid projects each year (which help support work on this and other community projects).
8
+
9
+ ## Quick start
10
+
11
+ Download the starter into a new theme directory, install its dependencies, and start the local development server:
12
+
13
+ ```bash
14
+ npx degit 5mts/canopy/starter my-grove-theme
15
+ cd my-grove-theme
16
+ npm install
17
+ npm run dev
18
+ ```
19
+
20
+ The starter pins the audio player to the bottom of the screen and gives the home page a cream background. It rebuilds your CSS when you save changes.
21
+
22
+ To preview it on your site, add this stylesheet link to the page `<head>` using your browser's local overrides or our [Local Script Testing](https://chromewebstore.google.com/detail/local-script-testing/jmgdaokiiafjphaomnmecgeldnpddebe) extension for Chromium browsers:
23
+
24
+ ```html
25
+ <link rel="stylesheet" href="http://localhost:3000/dist/theme.css">
26
+ ```
27
+
28
+ See the [starter instructions](https://github.com/5mts/canopy/tree/main/starter#readme) for customization and production builds.
29
+
30
+
31
+ ## Install in a theme
32
+
33
+ ```bash
34
+ npm install @5mts/canopy
35
+ ```
36
+
37
+ Requires Dart Sass 1.78+ with `--pkg-importer=node` to use the `pkg:` imports below. Put Sass examples in your theme's `.scss` files and JavaScript imports in its `.js` files.
38
+
39
+ ## Style Grove elements
40
+
41
+ Canopy gives short names to complex Grove selectors.
42
+
43
+ ```scss
44
+ @use 'pkg:@5mts/canopy' as *;
45
+
46
+ @include grove-selector('promo-title') {
47
+ color: teal;
48
+ }
49
+ ```
50
+
51
+ This styles Grove promo titles. Find available names in the [selector map](_selectors.scss). For a simple class such as `.SocialBar`, use ordinary CSS.
52
+
53
+ To limit a rule to part of the page, nest it:
54
+
55
+ ```scss
56
+ aside {
57
+ @include grove-selector('promo-title') {
58
+ font-size: 1rem;
59
+ }
60
+ }
61
+ ```
62
+
63
+ ### Style pages by program, section, or tag
64
+
65
+ ```scss
66
+ #{grove-page-is('program', 'Morning Show')} {
67
+ @include grove-selector('nav-bar') {
68
+ background: teal;
69
+ }
70
+ }
71
+ ```
72
+
73
+ Use `program`, `category` (Grove Section), `keywords` (Grove Tags), `pageType`, or `series`. Values must match the page's metadata. Pass a list to match any listed value: `grove-page-is('program', ('Morning Show', 'Weekend Talk'))`.
74
+
75
+ ### Change a layout
76
+
77
+ ```scss
78
+ @use 'pkg:@5mts/canopy/patterns' as *;
79
+
80
+ .ListE-items {
81
+ @include three-column-list;
82
+ }
83
+
84
+ @include audio-player-bottom($offset: 0, $reserve-footer: 50px);
85
+ ```
86
+
87
+ The list uses two columns from 768px and three from 1240px. It also hides bylines and adjusts tile spacing. Use `two-column-list` to stop at two columns.
88
+
89
+ The player stays at the bottom of the screen. Set `$reserve-footer` to its height to keep footer content clear; that padding applies from 768px by default.
90
+
91
+ Imports alone add no CSS. Each `@include` adds the styles you request.
92
+
93
+ | Task | Example |
94
+ |---|---|
95
+ | Put the sidebar on the left | [Sidebar](examples/sidebar-left.md) |
96
+ | Add a dropdown or accordion | [Show and hide content](examples/disclosure.md) |
97
+ | Move an element | [Relocation](examples/relocate.md) |
98
+ | Mark the current section link or add a section menu | [Section navigation](examples/subnav.md) |
99
+
100
+ ## Run JavaScript when Grove adds content
101
+
102
+ Grove can load page content after your script runs and replace it during navigation. Use `onGroveElement` to handle each matching element when it appears:
103
+
104
+ ```js
105
+ import { onGroveElement } from '@5mts/canopy/ready.js';
106
+
107
+ onGroveElement('.EplB-header-title', el => {
108
+ el.textContent = 'Latest episodes';
109
+ });
110
+ ```
111
+
112
+ This runs once per element, including new elements added during navigation.
113
+
114
+ Two other helpers are available from `ready.js`:
115
+
116
+ - `onGroveRender(callback)` runs when the page is ready, content is added or removed, and navigation occurs. **Check before changing text or adding elements**: repeated changes can trigger an endless loop.
117
+ - `onGroveNavigate(callback)` passes the current path and query, such as `/news?page=2`, at load and when they change. Returning to a URL runs it again; `#hash` changes do not. Content may still be loading, so use `onGroveElement` for element changes.
118
+
119
+ Each helper returns a function you can call to stop its callback. Importing `ready.js` alone does not start watching the page.
120
+
121
+ ### Find Grove elements
122
+
123
+ ```js
124
+ import { grove, groveAll, groveOne } from '@5mts/canopy/selectors.js';
125
+
126
+ grove('promo-title'); // CSS selector
127
+ groveOne('promo-title'); // First matching element, or null
128
+ groveAll('promo-title'); // Array of matching elements
129
+ ```
130
+
131
+ Use these inside a ready callback when content may not have loaded yet. The JavaScript selector list is in [selectors.js](selectors.js); Sass-only groups are not included.
132
+
133
+ ### Move an element
134
+
135
+ ```js
136
+ import { grove } from '@5mts/canopy/selectors.js';
137
+ import { relocate } from '@5mts/canopy/relocate.js';
138
+
139
+ relocate({ source: grove('podcast-subscribe'), dest: '#subscribe-slot' });
140
+ ```
141
+
142
+ Add an element with `id="subscribe-slot"` where you want the links. This moves all matching elements into it and handles new content after navigation. See [relocation](examples/relocate.md) for other positions and how to hide elements until they move.
143
+
144
+ ### Add menus and show/hide buttons
145
+
146
+ - [`disclosure()`](examples/disclosure.md) adds a button that opens and closes content. Pair it with the Sass example to control visibility.
147
+ - [`subnav()`](examples/subnav.md) labels section navigation for screen readers and marks the current page's link. The example also shows how to make it a dropdown.
148
+ - [`global.js`](examples/inline-snippets.md) lets scripts pasted into the CMS use the ready helpers through `canopy.on()`.
149
+
150
+ ## Maintain the library
151
+
152
+ Edit selectors in `_selectors.scss`, then regenerate `selectors.js`:
153
+
154
+ ```bash
155
+ npm run build:selectors
156
+ ```
157
+
158
+ Do not edit `selectors.js` directly. Publishing runs this build automatically.
159
+
160
+ In Sass, a selector group can reference other keys: `show-container: ('podcast-container', 'radio-container')`. These groups follow changes to their members and are not included in the generated JavaScript. Keep site-specific groups in your theme.
161
+
162
+ Version policy:
163
+
164
+ - **Patch:** docs and internal changes; selectors unchanged.
165
+ - **Minor:** new selectors, patterns, or JavaScript features that preserve existing behavior.
166
+ - **Major:** removed or renamed keys, or selector changes that may change theme styles.
167
+
168
+ A dependency range such as `^0.3.0` allows `0.3.x` updates. Review release changes before updating beyond that range.
169
+
170
+ ## License
171
+
172
+ MIT
package/_index.scss ADDED
@@ -0,0 +1,2 @@
1
+ @forward 'selectors';
2
+ @forward 'layout';
package/_layout.scss ADDED
@@ -0,0 +1,26 @@
1
+ // Grove breakpoints. Use these widths to match Grove's layout changes.
2
+ // CSS variables don't work in media queries; use grove-bp() instead.
3
+ //
4
+ // Usage:
5
+ // @use 'pkg:@5mts/canopy' as *;
6
+ //
7
+ // @media (min-width: grove-bp('lg')) { .my-heading { font-size: 2rem; } }
8
+
9
+ @use 'sass:map';
10
+
11
+ // Keep site-specific breakpoints in your theme.
12
+ $grove-breakpoints-base: (
13
+ md: 768px,
14
+ md-lg: 1024px,
15
+ lg: 1240px,
16
+ ) !default;
17
+
18
+ $grove-breakpoints: $grove-breakpoints-base;
19
+
20
+ @function grove-bp($key) {
21
+ $value: map.get($grove-breakpoints, $key);
22
+ @if $value == null {
23
+ @error "No Grove breakpoint found for key: '#{$key}'. Check $grove-breakpoints-base — theme-specific breakpoints belong in your own tokens.";
24
+ }
25
+ @return $value;
26
+ }
@@ -0,0 +1,269 @@
1
+ // Grove selectors by name. Use grove-selector() to style a component:
2
+ // @use 'pkg:@5mts/canopy' as *;
3
+ //
4
+ // @include grove-selector('promo') {
5
+ // background: white;
6
+ // }
7
+ //
8
+ // Nest the include to limit it to certain pages:
9
+ // html.ArtP {
10
+ // @include grove-selector('main-content') { padding: 1rem; }
11
+ // }
12
+ //
13
+ // Write simple selectors such as .SocialBar or .Image directly in your CSS.
14
+ // Keep site-specific selector groups in your theme.
15
+
16
+ @use 'sass:map';
17
+ @use 'sass:list';
18
+ @use 'sass:meta';
19
+ @use 'sass:string';
20
+
21
+ // Update this map when Grove changes its class names.
22
+ $grove-selectors-base: (
23
+ // Layout containers
24
+
25
+ container-columns: '.FourColumnContainer, .OneColumnContainer, .ThreeColumnContainer, .TwoColumnContainer3070, .TwoColumnContainer5050, .TwoColumnContainer7030',
26
+
27
+ // Page types
28
+ page-is-show: '.PodcastPage, .RadioShowPage',
29
+ page-is-episode: '.PCEP, .RSEP',
30
+ page-is-archive: '.SectionPage, .TagPage',
31
+
32
+ // Content components
33
+ // Skip promos inside tabs and EplB episode lists.
34
+ promo: 'ps-promo:not(:where(ps-tabs,.EplB) ps-promo)',
35
+ promo-media: '[class^="Promo"][class$="-media"]',
36
+ promo-content: '[class^="Promo"][class$="-content"]',
37
+ audio-player: '.PH-persistent-player',
38
+ promo-audio-label: '[class^="Promo"][class$="-audio-label"]',
39
+ button: 'button, button.Button, button.Link',
40
+
41
+ episode-list: '.EplB, .EplA',
42
+ episode-list-wrapper: '.EplB-items, .EplA-items',
43
+ episode-list-header: '.EplB-header, .EplA-header',
44
+ episode-list-title: '.EplB-header-title, .EplA-header-title',
45
+ episode-list-item: '.EplB-items-item, .EplA-items-item',
46
+
47
+ // Typography
48
+ promo-title: '[class^="Promo"][class$="-title"]',
49
+ // Grove uses both -header and -heading for component headings.
50
+ heading: '[class$="-header"], [class$="-heading"]',
51
+ heading-title: '[class$="-header-title"]',
52
+
53
+ // Page regions
54
+ sidebar: 'main > aside, [class$="-wrapper"] > aside',
55
+ sidebar-content: 'aside > [class$="-aside-content"]',
56
+ main-content: '[class$="-mainContent"]',
57
+ main-headline: 'h1[class$="-headline"]',
58
+ byline: '[class$="-byline"]',
59
+ // Byline and timestamp below an article headline.
60
+ content-meta: '[class$="-contentInfo"]',
61
+
62
+ page-lead: 'main [class$="-lead"]',
63
+ breadcrumbs: '[class$="-breadcrumbs-wrapper"]',
64
+ // Byline in a people promo.
65
+ people-byline: '.PromoPeople-content',
66
+ article-body: 'article [class$="-articleBody"]',
67
+ article-subhead: 'article h2[class$="-subheadline"]',
68
+ post-article-tags: '[class$="-tags"]',
69
+ post-article-bio: '[class$="-bottomByline"]',
70
+ page-description: '[class$="-pageDescription-content"]',
71
+ series-banner: '.SeriesBanner',
72
+
73
+ page-container: '[class$="Page-wrapper"]',
74
+ page-header: '[class$="Page-head"]',
75
+ page-head-text: '[class$="Page-head-text"]',
76
+
77
+
78
+ // Match whether main contains an aside or has an aside sibling.
79
+ main-without-aside: 'main:not(:has(~ aside, aside), aside ~ main)',
80
+ main-with-aside: 'main:has(~ aside, aside), aside ~ main',
81
+
82
+ // Links below the headline that JS can move to the sidebar.
83
+ // Check which links this matches before moving them.
84
+ metadata-relocatable-links: '[class$="-contentInfo"] a',
85
+
86
+ content-image: 'figure .Image, .PromoXS-media .Image',
87
+ caption: '.Figure-caption, .CarouselSlide-infoDescription, .Figure-credit, .CarouselSlide-infoAttribution, [class$="-credit-container"] [class$="-credit"], [class$="-credit-container"] [class$="-divider"], [class$="-credit-container"] [class$="-source"]',
88
+ title-text: '[class$="-title"]',
89
+ description: '[class$="-description"]',
90
+
91
+ // Content types
92
+ news-story: '.ArtP:not(.aside)',
93
+
94
+ // Podcast
95
+ podcast-container: '.PodcastPage-main, .PCEP-content .PCEP-wrapper',
96
+ podcast-aside: '.PodcastPage-aside, .PCEP-aside',
97
+ podcast-cover-and-title: '.PodcastPage-top',
98
+ podcast-byline: '.PodcastPage-byline',
99
+ podcast-description: '.PodcastPage-byline + *',
100
+ podcast-subscribe: '.PodcastActionBar',
101
+ // Subscribe link label.
102
+ podcast-subscribe-label: '.PodcastLink-provider',
103
+ podcast-social-bar: '.PodcastPage-social',
104
+ podcast-icon-headings: '.PodcastActionBar-heading, .PodcastPage-social .SocialBar-heading',
105
+ podcast-hostname: '.PodcastPage-hostName',
106
+ episode-series-header: '[class$="EP-parentInfo"]',
107
+ podcast-header: '.PodcastPage-info',
108
+
109
+ // Radio show
110
+ radio-container: '.RadioShowPage-main, .RSEP-content .RSEP-wrapper',
111
+ radio-aside: '.RadioShowPage-aside, .RSEP-aside',
112
+ radio-cover-and-title: '.RadioShowPage-top',
113
+ radio-byline: '.RadioShowPage-byline',
114
+ radio-description: '.RadioShowPage-byline + *',
115
+ radio-social-bar: '.RadioShowPage-social',
116
+ // Radio pages have social headings but no podcast subscribe bar.
117
+ radio-icon-headings: '.RadioShowPage-social .SocialBar-heading',
118
+ radio-hostname: '.RadioShowPage-hostName',
119
+ radio-header: '.RadioShowPage-info',
120
+
121
+ // Shared podcast/radio selectors. Lists combine the named keys above.
122
+ // Subscribe selectors are podcast-only.
123
+ show-container: ('podcast-container', 'radio-container'),
124
+ // Show pages put the sidebar beside content at md; episode pages at md-lg.
125
+ show-landing-container: '.PodcastPage-main, .RadioShowPage-main',
126
+ episode-container: '.PCEP-content .PCEP-wrapper, .RSEP-content .RSEP-wrapper',
127
+ show-aside: ('podcast-aside', 'radio-aside'),
128
+ show-main: '[class$="Page-wrapper"], [class$="EP-main"]',
129
+ show-cover-and-title: ('podcast-cover-and-title', 'radio-cover-and-title', 'episode-series-header'),
130
+ show-byline: ('podcast-byline', 'radio-byline'),
131
+ show-description: ('podcast-description', 'radio-description'),
132
+ show-social-bar: ('podcast-social-bar', 'radio-social-bar'),
133
+ show-icon-headings: ('podcast-icon-headings', 'radio-icon-headings'),
134
+ sidebar-headings: 'aside h2, aside h3, aside h4',
135
+
136
+ show-hostname: ('podcast-hostname', 'radio-hostname'),
137
+ show-header: ('podcast-header', 'radio-header'),
138
+ episode-meta: '.PCEP-contentInfo, .RSEP-contentInfo',
139
+ episode-player: '.PCEP-audioPlayer, .RSEP-audioPlayer',
140
+ show-schedule-info: '.RadioShowPage-mediaSchedule, .PodcastPage-podcastSchedule',
141
+
142
+ // News tile
143
+ news-tile: 'ps-promo[data-content-type="news-story"]',
144
+
145
+ // Category links above titles. :has() skips elements without a link.
146
+ eyebrow: '[class$="-category"]:has(> a), [class$="-breadcrumbs"]:has(a)',
147
+ author-info: '[class$="-authorName"], [class$="-contributors"], [class$="-hostName"]',
148
+ author-position: '[class$="-authorTitle"]',
149
+ timestamp: '[class$="-timestamp"], [class$="-timestamp"] > *',
150
+
151
+ // The selected tab has data-active="true".
152
+ tab: 'ps-tabs .Tabs-tabs-tab',
153
+ tab-active: 'ps-tabs [role="tab"][data-active="true"], ps-tabs .Tabs-tabs-tab[data-active="true"]',
154
+
155
+ // Social links in the action bar.
156
+ social-action: '.ActionLink[data-social-service]',
157
+
158
+ // Navigation
159
+ nav-bar: '.PH-nav-bar',
160
+ nav-item: '.NavI',
161
+ nav-item-list: '.NavI-items',
162
+ nav-item-text: '.NavI-text',
163
+ dropdown-nav-items: '.DropdownNavigation-items, .NavI .NavLink',
164
+
165
+ // Streaming / players
166
+ stream-pill-highlighted: '.StreamPill:hover, [playing] .StreamPill',
167
+ );
168
+
169
+ $grove-selectors: $grove-selectors-base;
170
+
171
+ // Return the selector for a key, such as grove('promo'). Unknown keys stop
172
+ // compilation. A list in the map combines other keys; these combined entries
173
+ // are available in Sass only, not selectors.js.
174
+ @function grove($key, $seen: ()) {
175
+ @if list.index($seen, $key) {
176
+ @error "Circular Grove selector reference: #{list.append($seen, $key, comma)}";
177
+ }
178
+ $value: map.get($grove-selectors, $key);
179
+ @if $value == null {
180
+ @error "No Grove selector found for key: '#{$key}'. Check $grove-selectors-base — or write the raw selector if the class is plainly readable.";
181
+ }
182
+ @if meta.type-of($value) == 'list' {
183
+ $result: ();
184
+ @each $ref in $value {
185
+ $resolved: grove($ref, list.append($seen, $key));
186
+ // Skip duplicate selectors.
187
+ @if not list.index($result, $resolved) {
188
+ $result: list.append($result, $resolved, comma);
189
+ }
190
+ }
191
+ @return $result;
192
+ }
193
+ @return $value;
194
+ }
195
+
196
+ // Style one key or a list of keys. Nest the include to limit where it applies.
197
+ // @include grove-selector('promo') { margin: 0; }
198
+ // @include grove-selector(('news-tile', 'tab')) { color: navy; }
199
+ @mixin grove-selector($keys) {
200
+ $selector-list: ();
201
+ @each $key in $keys {
202
+ $selector-list: list.append($selector-list, grove($key), comma);
203
+ }
204
+ #{$selector-list} {
205
+ @content;
206
+ }
207
+ }
208
+
209
+ // Build selectors for component variants. Match the case of Grove's classes.
210
+ // #{grove-variants('Promo', '-title', A B)} { margin-top: 0; }
211
+ // Produces .PromoA-title, .PromoB-title { margin-top: 0; }
212
+ @function grove-variants($prefix, $suffix, $letters) {
213
+ $result: ();
214
+ @each $letter in $letters {
215
+ $class: '.' + $prefix + $letter + $suffix;
216
+ $result: list.append($result, $class, comma);
217
+ }
218
+ @return $result;
219
+ }
220
+
221
+ // Match a value in <meta name="brightspot-dataLayer">. Keys include program,
222
+ // category (Grove Section), keywords (Grove Tags), pageType, and series.
223
+ @function grove-page-match($key, $value) {
224
+ @return "meta[name='brightspot-dataLayer'][content*='\"#{$key}\" : \"#{$value}\"']";
225
+ }
226
+
227
+ // Limit styles to pages with a matching value. A list matches any listed value.
228
+ // #{grove-page-is('program', 'Morning Show')} { .my-heading { color: navy; } }
229
+ @function grove-page-is($key, $values) {
230
+ $matches: ();
231
+ @each $value in $values {
232
+ $matches: list.append($matches, grove-page-match($key, $value), comma);
233
+ }
234
+ @return "html:has(#{$matches})";
235
+ }
236
+
237
+
238
+ // Content types and header elements
239
+ $grove-content-types: 'ArtP', 'RSEP' !default;
240
+ $grove-news-story-type: 'ArtP';
241
+ $grove-head-suffixes: '-breadcrumbs-wrapper', '-headline', '-contentInfo', '-audioPlayer', '-parentInfo' !default;
242
+
243
+ // List variants covered by list-header and list-header-title.
244
+ // Add letters here if Grove adds variants.
245
+ $grove-list-prefix: 'List' !default;
246
+ $grove-list-letters: A B C D E F G H !default;
247
+
248
+ // Include EplA episode lists in those header selectors. EplB is excluded.
249
+ $grove-epl-prefix: 'Epl' !default;
250
+ $grove-epl-letters: A !default;
251
+
252
+ // Add selectors built from the variants above. These are Sass-only keys.
253
+ // Store selectors as quoted strings: grove() treats lists as key references.
254
+ $_list-header: list.join(
255
+ grove-variants($grove-list-prefix, '-header', $grove-list-letters),
256
+ grove-variants($grove-epl-prefix, '-header', $grove-epl-letters),
257
+ comma
258
+ );
259
+ $_list-header-title: list.join(
260
+ grove-variants($grove-list-prefix, '-header-title', $grove-list-letters),
261
+ grove-variants($grove-epl-prefix, '-header-title', $grove-epl-letters),
262
+ comma
263
+ );
264
+ $grove-selectors: map.merge($grove-selectors, (
265
+ list-header: '#{$_list-header}',
266
+ list-header-title: '#{$_list-header-title}',
267
+
268
+ news-story-head-meta: "#{grove-variants($grove-news-story-type, '', $grove-head-suffixes)}, #{grove('series-banner')}",
269
+ ));
package/disclosure.js ADDED
@@ -0,0 +1,92 @@
1
+ // Add a button that switches aria-expanded between "true" and "false".
2
+ // Use CSS to show or hide content based on that attribute.
3
+ // Safe to call from onGroveRender; repeat calls only update the button text.
4
+ //
5
+ // disclosure(container, {
6
+ // label: 'Menu',
7
+ // panel: 'ul',
8
+ // });
9
+ //
10
+ // panel can be a selector within the container or an element. If a selector
11
+ // finds nothing, return null. Otherwise return the button.
12
+ // Optional: toggleClass sets its class (default: canopy-disclosure-toggle).
13
+ // Set closeOnOutsideClick or closeOnEscape to true when creating the button
14
+ // to enable either. Both default to false. Escape also focuses the button.
15
+
16
+ let uid = 0;
17
+
18
+ // Share one outside-click listener; drop removed containers on the next click.
19
+ const watched = new Set();
20
+ let listenerBound = false;
21
+
22
+ function handleOutsideClick(event) {
23
+ for (const entry of watched) {
24
+ if (!entry.container.isConnected) { watched.delete(entry); continue; }
25
+ if (
26
+ entry.button.getAttribute("aria-expanded") === "true" &&
27
+ !entry.container.contains(event.target)
28
+ ) {
29
+ entry.button.setAttribute("aria-expanded", "false");
30
+ }
31
+ }
32
+ }
33
+
34
+ export function disclosure(container, options = {}) {
35
+ const {
36
+ label,
37
+ toggleClass = "canopy-disclosure-toggle",
38
+ panel,
39
+ closeOnOutsideClick = false,
40
+ closeOnEscape = false,
41
+ } = options;
42
+
43
+ // Reuse the button if this container already has one.
44
+ let button = container.querySelector(`.${toggleClass}`);
45
+ if (!button) {
46
+ let panelEl = null;
47
+ if (panel instanceof Element) {
48
+ panelEl = panel;
49
+ } else if (typeof panel === "string") {
50
+ panelEl = container.querySelector(panel);
51
+ if (!panelEl) return null; // no panel, no toggle
52
+ }
53
+
54
+ button = document.createElement("button");
55
+ button.type = "button";
56
+ button.className = toggleClass;
57
+ button.setAttribute("aria-expanded", "false");
58
+ if (panelEl) {
59
+ if (!panelEl.id) panelEl.id = `canopy-disclosure-${++uid}`;
60
+ button.setAttribute("aria-controls", panelEl.id);
61
+ }
62
+ button.addEventListener("click", () => {
63
+ const open = button.getAttribute("aria-expanded") === "true";
64
+ button.setAttribute("aria-expanded", String(!open));
65
+ });
66
+ container.prepend(button);
67
+
68
+ if (closeOnOutsideClick) {
69
+ watched.add({ container, button });
70
+ if (!listenerBound) {
71
+ listenerBound = true;
72
+ document.addEventListener("click", handleOutsideClick);
73
+ }
74
+ }
75
+
76
+ if (closeOnEscape) {
77
+ // Keep the listener on the container so it leaves with the page content.
78
+ container.addEventListener("keydown", (event) => {
79
+ if (event.key === "Escape" && button.getAttribute("aria-expanded") === "true") {
80
+ button.setAttribute("aria-expanded", "false");
81
+ button.focus();
82
+ }
83
+ });
84
+ }
85
+ }
86
+
87
+ // Only write changed text; repeated writes would keep triggering onGroveRender.
88
+ if (typeof label === "string" && button.textContent !== label) {
89
+ button.textContent = label;
90
+ }
91
+ return button;
92
+ }
@@ -0,0 +1,11 @@
1
+ # canopy examples
2
+
3
+ Copy an example, then change its selectors, labels, and styles for your site.
4
+ SCSS and imported JavaScript go in your theme. For code pasted into the CMS,
5
+ start with inline snippets.
6
+
7
+ - [Inline snippets](inline-snippets.md) — run JavaScript pasted into the CMS.
8
+ - [Dropdowns and accordions](disclosure.md) — add a button to show or hide content.
9
+ - [Section navigation](subnav.md) — mark the current page and add a mobile menu.
10
+ - [Move content](relocate.md) — move elements when a page loads or changes.
11
+ - [Left sidebar](sidebar-left.md) — place the sidebar on the left or above the page.