@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 +21 -0
- package/README.md +171 -2
- package/_index.scss +2 -0
- package/_layout.scss +26 -0
- package/_selectors.scss +269 -0
- package/disclosure.js +92 -0
- package/examples/README.md +11 -0
- package/examples/disclosure.md +90 -0
- package/examples/inline-snippets.md +112 -0
- package/examples/relocate.md +52 -0
- package/examples/sidebar-left.md +57 -0
- package/examples/subnav.md +71 -0
- package/global.js +106 -0
- package/package.json +62 -4
- package/patterns/_audio-player-bottom.scss +24 -0
- package/patterns/_column-list.scss +77 -0
- package/patterns/_disclosure.scss +65 -0
- package/patterns/_index.scss +7 -0
- package/patterns/_relocate-flash-guard.scss +54 -0
- package/patterns/_sidebar.scss +77 -0
- package/ready.js +94 -0
- package/relocate.js +38 -0
- package/selectors.js +121 -0
- package/subnav.js +51 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Dropdowns and accordions
|
|
2
|
+
|
|
3
|
+
`disclosure()` adds a button that opens and closes content. Use it with
|
|
4
|
+
the SCSS below; JavaScript alone won't hide anything. Set colors, spacing,
|
|
5
|
+
and borders in your theme.
|
|
6
|
+
|
|
7
|
+
## Dropdown menu
|
|
8
|
+
|
|
9
|
+
This makes `#sub-nav-menu` a dropdown below 768px. The menu must contain a
|
|
10
|
+
`ul`. Put the JavaScript in your theme:
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { onGroveRender } from '@5mts/canopy/ready.js';
|
|
14
|
+
import { disclosure } from '@5mts/canopy/disclosure.js';
|
|
15
|
+
|
|
16
|
+
onGroveRender(() => {
|
|
17
|
+
const nav = document.querySelector('#sub-nav-menu');
|
|
18
|
+
if (nav) disclosure(nav, { label: 'Menu', toggleClass: 'my-toggle', panel: 'ul' });
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Add this to your SCSS:
|
|
23
|
+
|
|
24
|
+
```scss
|
|
25
|
+
@use 'pkg:@5mts/canopy/patterns' as *;
|
|
26
|
+
|
|
27
|
+
.my-toggle { display: none; }
|
|
28
|
+
|
|
29
|
+
#sub-nav-menu {
|
|
30
|
+
@media (max-width: 767px) {
|
|
31
|
+
@include disclosure-dropdown('my-toggle') {
|
|
32
|
+
.my-toggle { @include disclosure-glyph; }
|
|
33
|
+
ul {
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
background: white;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Change `767px` to choose when the dropdown appears. Remove the media query
|
|
43
|
+
to use a dropdown at every width. Keep `toggleClass` and the mixin's first
|
|
44
|
+
argument identical. Repeated calls update the label without adding another button.
|
|
45
|
+
|
|
46
|
+
To close the menu on an outside click or Escape, pass
|
|
47
|
+
`closeOnOutsideClick: true` and `closeOnEscape: true` to `disclosure()`.
|
|
48
|
+
|
|
49
|
+
## Collapsible transcript
|
|
50
|
+
|
|
51
|
+
This example finds a container whose first child is an `h2` containing only
|
|
52
|
+
"Transcript". In the CMS, start each transcript with that heading.
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { onGroveElement } from '@5mts/canopy/ready.js';
|
|
56
|
+
import { disclosure } from '@5mts/canopy/disclosure.js';
|
|
57
|
+
|
|
58
|
+
onGroveElement('h2:first-child', (heading) => {
|
|
59
|
+
if (heading.textContent.trim().toLowerCase() !== 'transcript') return;
|
|
60
|
+
|
|
61
|
+
const wrapper = heading.parentElement;
|
|
62
|
+
disclosure(wrapper, { label: 'Transcript', toggleClass: 'transcript-toggle' });
|
|
63
|
+
wrapper.classList.add('collapsible-transcript');
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```scss
|
|
68
|
+
@use 'pkg:@5mts/canopy/patterns' as *;
|
|
69
|
+
|
|
70
|
+
.collapsible-transcript {
|
|
71
|
+
@include disclosure-accordion('transcript-toggle', $preserve: 'h2');
|
|
72
|
+
.transcript-toggle { @include disclosure-glyph; }
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The heading stays visible when closed. Omit `$preserve: 'h2'` to hide it
|
|
77
|
+
with the transcript. The container should hold only the transcript: its
|
|
78
|
+
other direct children will also collapse.
|
|
79
|
+
|
|
80
|
+
## SCSS options
|
|
81
|
+
|
|
82
|
+
- `disclosure-dropdown($toggle-class, $panel: 'ul', $panel-display: flex)`
|
|
83
|
+
opens the panel as a full-width overlay. If you change `$panel`, change
|
|
84
|
+
the JavaScript `panel` option too.
|
|
85
|
+
- `disclosure-accordion($toggle-class, $preserve: ())` hides direct children
|
|
86
|
+
when closed, except the button and any selectors in `$preserve`.
|
|
87
|
+
- `disclosure-glyph($closed: '▼', $open: '▲')` adds an open/closed indicator
|
|
88
|
+
to the button.
|
|
89
|
+
|
|
90
|
+
Browsers without CSS `:has()` support leave the content expanded.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Run JavaScript pasted into the CMS
|
|
2
|
+
|
|
3
|
+
Grove can change pages without reloading the browser. Canopy runs your code
|
|
4
|
+
when the elements it needs appear, including after those page changes.
|
|
5
|
+
|
|
6
|
+
Your site must load `global.js` once. If it doesn't yet, follow
|
|
7
|
+
[site setup](#site-setup).
|
|
8
|
+
|
|
9
|
+
## Code for one page
|
|
10
|
+
|
|
11
|
+
Paste this into a page's HTML field. It adds a working "Back to top" button:
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<button type="button" class="back-to-top">Back to top</button>
|
|
15
|
+
<script type="text/canopy">
|
|
16
|
+
canopy.on('groveElement', '.back-to-top', button => {
|
|
17
|
+
button.addEventListener('click', () => window.scrollTo({ top: 0 }));
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Keep `type="text/canopy"`. Canopy runs the script when it appears and stops
|
|
23
|
+
its registered callbacks when it leaves the page. Returning to the page
|
|
24
|
+
runs a newly inserted script again.
|
|
25
|
+
|
|
26
|
+
`groveElement` runs once per matching element. Use it for adding buttons,
|
|
27
|
+
changing text, or attaching click handlers.
|
|
28
|
+
|
|
29
|
+
## Code for every page
|
|
30
|
+
|
|
31
|
+
Use a normal `<script>` in a site-wide header or footer slot:
|
|
32
|
+
|
|
33
|
+
```html
|
|
34
|
+
<script>
|
|
35
|
+
canopy.on('groveElement', '.back-to-top', button => {
|
|
36
|
+
button.addEventListener('click', () => window.scrollTo({ top: 0 }));
|
|
37
|
+
});
|
|
38
|
+
</script>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This handles each `.back-to-top` button as it appears across page changes.
|
|
42
|
+
Include the button HTML wherever you want it. The setup below must come
|
|
43
|
+
before site-wide snippets.
|
|
44
|
+
|
|
45
|
+
## Site setup
|
|
46
|
+
|
|
47
|
+
Add this once in the site-wide header, before other canopy scripts. Replace
|
|
48
|
+
`VERSION` with the published version you want to use.
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<script>
|
|
52
|
+
window.canopy = window.canopy || {
|
|
53
|
+
on: (...args) => (window.canopyActions = window.canopyActions || []).push(args),
|
|
54
|
+
};
|
|
55
|
+
</script>
|
|
56
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@5mts/canopy@VERSION/global.js"></script>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The first script holds calls until canopy loads. Use a fixed version so
|
|
60
|
+
updates don't change your site unexpectedly.
|
|
61
|
+
|
|
62
|
+
If your theme already includes `import '@5mts/canopy/global.js'`, keep the
|
|
63
|
+
first script and omit the CDN script. Load `global.js` only once.
|
|
64
|
+
|
|
65
|
+
Sites whose Content Security Policy blocks `eval` cannot run `text/canopy`
|
|
66
|
+
scripts. Use a permitted normal script with `{ scope: 'page' }` instead.
|
|
67
|
+
Without `global.js`, `text/canopy` scripts do nothing.
|
|
68
|
+
|
|
69
|
+
## Choose when code runs
|
|
70
|
+
|
|
71
|
+
| Action | Runs |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `groveElement` | Once per element matching your selector |
|
|
74
|
+
| `groveRender` | When ready, then after content or navigation changes |
|
|
75
|
+
| `groveNavigate` | On load and whenever the path or query changes |
|
|
76
|
+
|
|
77
|
+
`groveNavigate` ignores hash changes; returning to an earlier URL runs it
|
|
78
|
+
again. The new page's elements may not exist yet, so use `groveElement`
|
|
79
|
+
when working with them.
|
|
80
|
+
|
|
81
|
+
With `groveRender`, check before changing text or adding/removing elements.
|
|
82
|
+
Those changes trigger another run; repeating them can freeze the page:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
canopy.on('groveRender', () => {
|
|
86
|
+
const heading = document.querySelector('#section-title');
|
|
87
|
+
if (heading && heading.textContent !== 'Local news') {
|
|
88
|
+
heading.textContent = 'Local news';
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Limit a normal script to its current page
|
|
94
|
+
|
|
95
|
+
Pass `{ scope: 'page' }` last:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
canopy.on('groveElement', '.back-to-top', button => {
|
|
99
|
+
button.addEventListener('click', () => window.scrollTo({ top: 0 }));
|
|
100
|
+
}, { scope: 'page' });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
This checks the path and query where the call was made. The callback stays
|
|
104
|
+
registered and can run again when you return. In a normal script, calls
|
|
105
|
+
apply site-wide unless you add this option. Calls from tag managers, the
|
|
106
|
+
console, or other callbacks follow the same rule.
|
|
107
|
+
|
|
108
|
+
Once canopy has loaded, `canopy.on()` returns a function you can call to
|
|
109
|
+
stop that registration. Calls made before it loads can't return this function.
|
|
110
|
+
|
|
111
|
+
For debugging, look for `[canopy]` errors in the browser console. Check
|
|
112
|
+
action names first: unknown names throw an error.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Move content
|
|
2
|
+
|
|
3
|
+
`relocate()` moves matching elements when the page loads and after Grove
|
|
4
|
+
updates it. Register each move once in your theme's JavaScript.
|
|
5
|
+
|
|
6
|
+
## Move the podcast subscribe links
|
|
7
|
+
|
|
8
|
+
This places the links inside the first `.my-sidebar-slot` element:
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { grove } from '@5mts/canopy/selectors.js';
|
|
12
|
+
import { relocate } from '@5mts/canopy/relocate.js';
|
|
13
|
+
|
|
14
|
+
relocate({ source: grove('podcast-subscribe'), dest: '.my-sidebar-slot' });
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use any CSS selector for `source` and `dest`. All matching sources move;
|
|
18
|
+
if the destination is missing, they stay where they are.
|
|
19
|
+
|
|
20
|
+
`position` controls placement:
|
|
21
|
+
|
|
22
|
+
| Value | Placement |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `beforeend` (default) | Inside, after existing content |
|
|
25
|
+
| `afterbegin` | Inside, before existing content |
|
|
26
|
+
| `beforebegin` | Before the destination |
|
|
27
|
+
| `afterend` | After the destination |
|
|
28
|
+
|
|
29
|
+
## Prevent a flash before the move
|
|
30
|
+
|
|
31
|
+
Optional SCSS hides the source until it moves:
|
|
32
|
+
|
|
33
|
+
```scss
|
|
34
|
+
@use 'pkg:@5mts/canopy' as *;
|
|
35
|
+
@use 'pkg:@5mts/canopy/patterns' as *;
|
|
36
|
+
|
|
37
|
+
@include grove-selector('podcast-subscribe') {
|
|
38
|
+
@include relocate-flash-guard($dest: '.my-sidebar-slot');
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use the same destination selector in both examples. The source stays visible
|
|
43
|
+
on pages without a destination. If JavaScript fails, it reappears after
|
|
44
|
+
five seconds.
|
|
45
|
+
|
|
46
|
+
Options: `$fallback-delay: 5s`, `$duration: 0.4s`, and `$shift: -0.5rem`.
|
|
47
|
+
These control the fallback reveal's delay, duration, and vertical movement.
|
|
48
|
+
Reduced-motion settings remove the animation, but keep the delayed reveal.
|
|
49
|
+
|
|
50
|
+
Moved elements get `data-canopy-relocated`; destinations get the `canopy-relocated`
|
|
51
|
+
class. The SCSS uses the attribute to reveal each element after it moves,
|
|
52
|
+
including new content loaded during navigation.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Put the sidebar on the left
|
|
2
|
+
|
|
3
|
+
`sidebar-left` moves Grove's sidebar to the left on wide screens and fixes
|
|
4
|
+
its margins. You choose which pages it affects and how much space separates
|
|
5
|
+
the columns.
|
|
6
|
+
|
|
7
|
+
## Pages with a sidebar menu
|
|
8
|
+
|
|
9
|
+
This puts the sidebar on the left from 768px up, and above the content on
|
|
10
|
+
smaller screens:
|
|
11
|
+
|
|
12
|
+
```scss
|
|
13
|
+
@use 'pkg:@5mts/canopy/patterns' as *;
|
|
14
|
+
|
|
15
|
+
html:has(#sub-nav-menu) {
|
|
16
|
+
@include sidebar-left($gap: 2rem, $stacked: sidebar-first);
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Replace `html:has(#sub-nav-menu)` to select different pages. Set column
|
|
21
|
+
widths in your theme.
|
|
22
|
+
|
|
23
|
+
## Show and episode pages
|
|
24
|
+
|
|
25
|
+
These layouts use different widths for side-by-side columns:
|
|
26
|
+
|
|
27
|
+
```scss
|
|
28
|
+
@use 'pkg:@5mts/canopy/patterns' as *;
|
|
29
|
+
|
|
30
|
+
@include sidebar-left($container: 'show-landing-container', $gap: 2rem);
|
|
31
|
+
@include sidebar-left($container: 'episode-container', $gap: 2rem);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| `$container` | Sidebar moves left at |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `'page-container'` (default) | 768px |
|
|
37
|
+
| `'show-landing-container'` | 768px |
|
|
38
|
+
| `'episode-container'` | 1024px |
|
|
39
|
+
|
|
40
|
+
For article, blog post, and live blog layouts, pass `$breakpoint: 1024px`
|
|
41
|
+
with `'page-container'` to match Grove. `'show-container'` combines layouts
|
|
42
|
+
with different widths, so it requires an explicit `$breakpoint`.
|
|
43
|
+
|
|
44
|
+
## Options
|
|
45
|
+
|
|
46
|
+
- `$gap` sets the sidebar's right margin at every width. Omit it to leave
|
|
47
|
+
that margin unchanged.
|
|
48
|
+
- `$breakpoint` overrides the width in the table.
|
|
49
|
+
- `$stacked: sidebar-first` puts the sidebar above the content below that
|
|
50
|
+
width. `$stacked: sidebar-last` puts it below. Omit `$stacked` to keep
|
|
51
|
+
Grove's normal stacking.
|
|
52
|
+
|
|
53
|
+
If your `$breakpoint` is wider than Grove's, set `$stacked` too. This keeps
|
|
54
|
+
the columns stacked until your chosen width.
|
|
55
|
+
|
|
56
|
+
For a sidebar menu with current-page styling and a mobile dropdown, see
|
|
57
|
+
[section navigation](subnav.md).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Section navigation
|
|
2
|
+
|
|
3
|
+
`subnav()` labels a menu for screen readers and marks the current page's
|
|
4
|
+
link. Use it with `disclosure()` for a dropdown on small screens.
|
|
5
|
+
|
|
6
|
+
## JavaScript
|
|
7
|
+
|
|
8
|
+
This expects a menu container with `id="sub-nav-menu"` and a `ul` of links.
|
|
9
|
+
Add it to your theme's JavaScript:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { onGroveRender } from '@5mts/canopy/ready.js';
|
|
13
|
+
import { subnav } from '@5mts/canopy/subnav.js';
|
|
14
|
+
import { disclosure } from '@5mts/canopy/disclosure.js';
|
|
15
|
+
|
|
16
|
+
onGroveRender(() => {
|
|
17
|
+
const nav = document.querySelector('#sub-nav-menu');
|
|
18
|
+
if (!nav) return;
|
|
19
|
+
|
|
20
|
+
const current = subnav(nav, { label: 'Section', currentClass: 'current-link' });
|
|
21
|
+
|
|
22
|
+
disclosure(nav, {
|
|
23
|
+
label: current?.textContent.trim() || 'Section',
|
|
24
|
+
panel: 'ul',
|
|
25
|
+
toggleClass: 'section-toggle',
|
|
26
|
+
closeOnOutsideClick: true,
|
|
27
|
+
closeOnEscape: true,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The button shows the current page's link text, or "Section" if no link
|
|
33
|
+
matches. Remove the `disclosure()` call and import for an expanded menu.
|
|
34
|
+
|
|
35
|
+
## SCSS
|
|
36
|
+
|
|
37
|
+
This bolds the current link and turns the menu into a dropdown below 768px:
|
|
38
|
+
|
|
39
|
+
```scss
|
|
40
|
+
@use 'pkg:@5mts/canopy/patterns' as *;
|
|
41
|
+
|
|
42
|
+
.section-toggle { display: none; }
|
|
43
|
+
|
|
44
|
+
#sub-nav-menu {
|
|
45
|
+
a.current-link { font-weight: bold; }
|
|
46
|
+
|
|
47
|
+
@media (max-width: 767px) {
|
|
48
|
+
@include disclosure-dropdown('section-toggle') {
|
|
49
|
+
.section-toggle { @include disclosure-glyph; }
|
|
50
|
+
ul {
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
background: white;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Change the width, colors, and spacing for your site. More options:
|
|
60
|
+
[dropdowns and accordions](disclosure.md).
|
|
61
|
+
|
|
62
|
+
## Current-page and accessibility details
|
|
63
|
+
|
|
64
|
+
- Matching links get `aria-current="page"` and `currentClass` (default:
|
|
65
|
+
`canopy-current`). Links must be on the same site. Matching ignores query
|
|
66
|
+
strings, hashes, and trailing slashes.
|
|
67
|
+
- `subnav()` returns the first matching link, or `null`.
|
|
68
|
+
- It adds a navigation role and label where needed, and preserves an
|
|
69
|
+
existing `aria-label`. Pass a container around the list, such as a `nav`
|
|
70
|
+
or `div`; a bare `ul` or `ol` won't get a navigation role or label.
|
|
71
|
+
- Lists get `role="list"` so Safari recognizes them when bullets are hidden.
|
package/global.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Enable <script type="text/canopy"> snippets and window.canopy.on().
|
|
2
|
+
//
|
|
3
|
+
// Load once in your theme:
|
|
4
|
+
// import '@5mts/canopy/global.js'
|
|
5
|
+
// Or use a module script:
|
|
6
|
+
// <script type="module" src=".../@5mts/canopy/global.js"></script>
|
|
7
|
+
//
|
|
8
|
+
// Page snippets run when their script element appears. Inside a snippet,
|
|
9
|
+
// canopy.on() callbacks stop when Grove removes the script from the page.
|
|
10
|
+
// Each snippet has its own variables and can appear before this module loads.
|
|
11
|
+
//
|
|
12
|
+
// window.canopy.on('groveRender', callback) runs on every page. Its action
|
|
13
|
+
// names match ready.js functions without the initial "on": groveRender,
|
|
14
|
+
// groveElement, groveNavigate. Add {scope: "page"} as the last argument to
|
|
15
|
+
// limit callbacks to the current path and query, including return visits.
|
|
16
|
+
// Calls made before this module loads need the setup in examples/inline-snippets.md.
|
|
17
|
+
|
|
18
|
+
import { onGroveRender, onGroveElement, onGroveNavigate } from "./ready.js";
|
|
19
|
+
|
|
20
|
+
const api = { onGroveRender, onGroveElement, onGroveNavigate };
|
|
21
|
+
|
|
22
|
+
function resolve(action) {
|
|
23
|
+
const method = api["on" + action[0].toUpperCase() + action.slice(1)];
|
|
24
|
+
if (!method) throw new Error(`[canopy] unknown action: '${action}'`);
|
|
25
|
+
return method;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Read and remove the optional final {scope} argument.
|
|
29
|
+
function takeScope(args, fallback) {
|
|
30
|
+
let scope = fallback;
|
|
31
|
+
if (args.length && typeof args[args.length - 1] === "object") {
|
|
32
|
+
scope = args.pop().scope ?? fallback;
|
|
33
|
+
}
|
|
34
|
+
if (scope !== "page" && scope !== "site") {
|
|
35
|
+
throw new Error(`[canopy] unknown scope: '${scope}' (use "page" or "site")`);
|
|
36
|
+
}
|
|
37
|
+
return scope;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Return the function that stops this callback. Invalid actions throw an error.
|
|
41
|
+
function on(action, ...args) {
|
|
42
|
+
if (takeScope(args, "site") === "page") {
|
|
43
|
+
const here = location.pathname + location.search;
|
|
44
|
+
args = args.map(a => typeof a !== "function" ? a : (...xs) => {
|
|
45
|
+
if (location.pathname + location.search === here) return a(...xs);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return resolve(action)(...args);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Log errors and continue with the remaining queued calls.
|
|
52
|
+
function run(entry) {
|
|
53
|
+
try { on(...entry); }
|
|
54
|
+
catch (err) { console.error("[canopy] dropped queued action:", err); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Page snippets
|
|
58
|
+
|
|
59
|
+
const seen = new WeakSet();
|
|
60
|
+
const records = [];
|
|
61
|
+
|
|
62
|
+
function execute(el) {
|
|
63
|
+
const record = { el, offs: [], dead: false };
|
|
64
|
+
records.push(record);
|
|
65
|
+
const facade = {
|
|
66
|
+
on: (action, ...args) => {
|
|
67
|
+
if (record.dead) return () => {}; // don't add callbacks after leaving the page
|
|
68
|
+
if (takeScope(args, "page") === "site") return on(action, ...args, { scope: "site" });
|
|
69
|
+
const off = resolve(action)(...args);
|
|
70
|
+
record.offs.push(off);
|
|
71
|
+
return off;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
// Give each snippet its own variables and page-scoped canopy.on().
|
|
76
|
+
// If the site's Content Security Policy blocks eval, use a plain script
|
|
77
|
+
// with {scope: "page"} instead of text/canopy.
|
|
78
|
+
new Function("canopy", el.textContent)(facade);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error("[canopy] page snippet failed:", err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Run first so removed snippets stop before their callbacks can run again.
|
|
85
|
+
onGroveRender(() => {
|
|
86
|
+
for (const el of document.querySelectorAll('script[type="text/canopy"]')) {
|
|
87
|
+
if (seen.has(el)) continue;
|
|
88
|
+
seen.add(el);
|
|
89
|
+
execute(el);
|
|
90
|
+
}
|
|
91
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
92
|
+
if (!records[i].el.isConnected) {
|
|
93
|
+
records[i].dead = true;
|
|
94
|
+
records[i].offs.forEach(off => off());
|
|
95
|
+
records.splice(i, 1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Keep existing references to window.canopy working.
|
|
101
|
+
window.canopy = Object.assign(window.canopy ?? {}, { on });
|
|
102
|
+
|
|
103
|
+
// Run queued calls now and new calls as they arrive.
|
|
104
|
+
const queued = Array.isArray(window.canopyActions) ? window.canopyActions : [];
|
|
105
|
+
window.canopyActions = { push: (...entries) => entries.forEach(run) };
|
|
106
|
+
queued.forEach(run);
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@5mts/canopy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A lightweight toolkit for customizing the styling and presentation of Grove sites",
|
|
5
|
+
"author": "Five Mountains",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/5mts/canopy.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/5mts/canopy#readme",
|
|
11
|
+
"bugs": "https://github.com/5mts/canopy/issues",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sass": "_index.scss",
|
|
14
|
+
"main": "_index.scss",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"sass": "./_index.scss"
|
|
18
|
+
},
|
|
19
|
+
"./patterns": {
|
|
20
|
+
"sass": "./patterns/_index.scss"
|
|
21
|
+
},
|
|
22
|
+
"./patterns.scss": {
|
|
23
|
+
"sass": "./patterns/_index.scss"
|
|
24
|
+
},
|
|
25
|
+
"./selectors.js": "./selectors.js",
|
|
26
|
+
"./ready.js": "./ready.js",
|
|
27
|
+
"./global.js": "./global.js",
|
|
28
|
+
"./relocate.js": "./relocate.js",
|
|
29
|
+
"./disclosure.js": "./disclosure.js",
|
|
30
|
+
"./subnav.js": "./subnav.js",
|
|
31
|
+
"./_selectors.scss": "./_selectors.scss",
|
|
32
|
+
"./_layout.scss": "./_layout.scss"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"_index.scss",
|
|
36
|
+
"_selectors.scss",
|
|
37
|
+
"_layout.scss",
|
|
38
|
+
"patterns/",
|
|
39
|
+
"examples/",
|
|
40
|
+
"selectors.js",
|
|
41
|
+
"ready.js",
|
|
42
|
+
"global.js",
|
|
43
|
+
"relocate.js",
|
|
44
|
+
"disclosure.js",
|
|
45
|
+
"subnav.js",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build:selectors": "node scripts/generate-selectors-js.mjs",
|
|
51
|
+
"prepublishOnly": "npm run build:selectors"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"keywords": [
|
|
55
|
+
"grove",
|
|
56
|
+
"sass",
|
|
57
|
+
"scss",
|
|
58
|
+
"adapter",
|
|
59
|
+
"theme"
|
|
60
|
+
],
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Fix Grove's audio player at the bottom of the screen. Set $offset for its
|
|
2
|
+
// bottom position and $reserve-footer for space below the footer content.
|
|
3
|
+
// Footer padding applies at $reserve-at and wider (768px by default).
|
|
4
|
+
|
|
5
|
+
@use '../selectors' as *;
|
|
6
|
+
@use '../layout' as *;
|
|
7
|
+
|
|
8
|
+
@mixin audio-player-bottom($offset: null, $reserve-footer: null, $reserve-at: grove-bp('md')) {
|
|
9
|
+
@include grove-selector('audio-player') {
|
|
10
|
+
position: fixed;
|
|
11
|
+
top: auto;
|
|
12
|
+
@if $offset != null {
|
|
13
|
+
bottom: $offset;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@if $reserve-footer != null {
|
|
18
|
+
@media (min-width: $reserve-at) {
|
|
19
|
+
.Page-footer {
|
|
20
|
+
padding-bottom: $reserve-footer;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Arrange a promo list in two or three columns. Use two-column-list() or
|
|
2
|
+
// three-column-list(). Both use two columns from $two-col-at (768px);
|
|
3
|
+
// three-column-list() adds a third from $three-col-at (1240px).
|
|
4
|
+
// Hides bylines, puts audio labels first, and removes image margins.
|
|
5
|
+
|
|
6
|
+
@use '../selectors' as *;
|
|
7
|
+
@use '../layout' as *;
|
|
8
|
+
|
|
9
|
+
@mixin column-list(
|
|
10
|
+
$columns: 3,
|
|
11
|
+
$gap: 1.5rem,
|
|
12
|
+
$tile-padding: 20px 10px,
|
|
13
|
+
$row-margin: 0 -10px,
|
|
14
|
+
$two-col-at: grove-bp('md'),
|
|
15
|
+
$three-col-at: grove-bp('lg')
|
|
16
|
+
) {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: row;
|
|
19
|
+
flex-wrap: wrap;
|
|
20
|
+
margin: $row-margin;
|
|
21
|
+
|
|
22
|
+
@include grove-selector('promo') {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
margin: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Tile spacing.
|
|
29
|
+
& > * {
|
|
30
|
+
padding: $tile-padding;
|
|
31
|
+
margin: 0;
|
|
32
|
+
border-bottom: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@include grove-selector('promo-content') {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
gap: $gap;
|
|
39
|
+
|
|
40
|
+
@include grove-selector('byline') {
|
|
41
|
+
display: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@include grove-selector('promo-audio-label') {
|
|
45
|
+
order: 1;
|
|
46
|
+
}
|
|
47
|
+
@include grove-selector(('promo-title', 'promo-content', 'description')) {
|
|
48
|
+
order: 2;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Two columns.
|
|
53
|
+
@media (min-width: $two-col-at) {
|
|
54
|
+
& > * {
|
|
55
|
+
flex-basis: 50%;
|
|
56
|
+
max-width: 50%;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Three columns, if requested.
|
|
61
|
+
@if $columns >= 3 {
|
|
62
|
+
@media (min-width: $three-col-at) {
|
|
63
|
+
& > * {
|
|
64
|
+
flex-basis: 33%;
|
|
65
|
+
max-width: 33%;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@include grove-selector('promo-media') {
|
|
71
|
+
margin: 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Both shortcuts accept column-list's named options, such as $gap.
|
|
76
|
+
@mixin two-column-list($args...) { @include column-list(2, $args...); }
|
|
77
|
+
@mixin three-column-list($args...) { @include column-list(3, $args...); }
|