@allons-y/webc-patterns 0.0.0-stage → 0.1.0-beta.1

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.
Files changed (49) hide show
  1. package/LICENSE +385 -0
  2. package/README.md +536 -2
  3. package/components/norn-accordion.webc +124 -0
  4. package/components/norn-breadcrumbs.webc +71 -0
  5. package/components/norn-callout.webc +97 -0
  6. package/components/norn-card-grid.webc +60 -0
  7. package/components/norn-card.webc +143 -0
  8. package/components/norn-code-tabs.webc +270 -0
  9. package/components/norn-copy-button.webc +220 -0
  10. package/components/norn-fact-list.webc +52 -0
  11. package/components/norn-footer.webc +122 -0
  12. package/components/norn-head.webc +59 -0
  13. package/components/norn-header.webc +238 -0
  14. package/components/norn-heading-anchor.webc +71 -0
  15. package/components/norn-hero.webc +81 -0
  16. package/components/norn-icon.webc +140 -0
  17. package/components/norn-link-button.webc +96 -0
  18. package/components/norn-notice.webc +94 -0
  19. package/components/norn-pager.webc +67 -0
  20. package/components/norn-rich-text.webc +105 -0
  21. package/components/norn-search.webc +303 -0
  22. package/components/norn-segmented-control.webc +143 -0
  23. package/components/norn-side-nav.webc +149 -0
  24. package/components/norn-sidebar-layout.webc +185 -0
  25. package/components/norn-simple-table.webc +120 -0
  26. package/components/norn-skip-link.webc +41 -0
  27. package/components/norn-theme-toggle.webc +127 -0
  28. package/components/norn-toc.webc +260 -0
  29. package/css/base.css +75 -0
  30. package/css/print.css +8 -0
  31. package/css/reset.css +45 -0
  32. package/fallback/norn-code-block.webc +13 -0
  33. package/index.js +177 -0
  34. package/layouts/norn-base.webc +137 -0
  35. package/layouts/norn-docs-home.webc +109 -0
  36. package/layouts/norn-docs.webc +77 -0
  37. package/layouts/norn-landing.webc +73 -0
  38. package/layouts/norn-workspace.webc +25 -0
  39. package/layouts/norn-workspaces-home.webc +44 -0
  40. package/lib/icons.js +166 -0
  41. package/lib/npm.js +49 -0
  42. package/lib/optional.js +25 -0
  43. package/lib/site.js +87 -0
  44. package/lib/workspaces.js +71 -0
  45. package/markdown.js +278 -0
  46. package/package.json +71 -5
  47. package/plugin.js +279 -0
  48. package/project.js +15 -0
  49. package/schema/project.schema.json +112 -0
@@ -0,0 +1,270 @@
1
+ <!---
2
+ /*
3
+ * SPDX-License-Identifier: MPL-2.0
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+ --->
9
+ <span hidden>
10
+ <span data-string="label" :id="'norn-code-tabs-' + uid"><slot name="label"><span webc:nokeep @text="nornComponentStrings?.('norn-code-tabs', 'label', $data.page) || 'Code samples'"></span></slot></span>
11
+ <span data-string="sample"><slot name="sample"><span webc:nokeep @text="nornComponentStrings?.('norn-code-tabs', 'sample', $data.page) || 'Sample {n}'"></span></slot></span>
12
+ </span>
13
+ <div :data-group="group || undefined"><slot></slot></div>
14
+ <style>
15
+ @layer norn.components {
16
+ norn-code-tabs {
17
+ display: block;
18
+
19
+ & > div {
20
+ display: grid;
21
+ gap: var(--norn-space-s, 0.75rem);
22
+ }
23
+
24
+ & > div > [role="tablist"] {
25
+ display: flex;
26
+ flex-wrap: wrap;
27
+ gap: var(--norn-space-xs, 0.5rem);
28
+ border-block-end: 1px solid var(--norn-ui-border-color, light-dark(#d3d8e2, #2d323e));
29
+ }
30
+
31
+ & > div > [role="tablist"] > [role="tab"] {
32
+ --_state: 0%;
33
+ box-sizing: border-box;
34
+ min-block-size: var(--norn-ui-control-height, 2.25rem);
35
+ margin-block-end: -1px;
36
+ padding: 0 var(--norn-space-s, 0.75rem);
37
+ border: 0;
38
+ border-block-end: 2px solid transparent;
39
+ border-start-start-radius: var(--norn-ui-radius-s, 4px);
40
+ border-start-end-radius: var(--norn-ui-radius-s, 4px);
41
+ background-color: color-mix(in oklab, currentcolor var(--_state), transparent);
42
+ color: var(--norn-text-muted, light-dark(#4b5160, #a4abba));
43
+ font: inherit;
44
+ font-size: var(--norn-font-size-s, 0.875rem);
45
+ overflow-wrap: anywhere;
46
+ cursor: pointer;
47
+
48
+ &:hover {
49
+ --_state: var(--norn-ui-state-hover, 8%);
50
+ color: var(--norn-text-base, light-dark(#16181d, #eceef3));
51
+ }
52
+
53
+ &:active {
54
+ --_state: var(--norn-ui-state-press, 12%);
55
+ }
56
+
57
+ /* The ring sits inside the tab, clear of the underline. */
58
+ &:focus-visible {
59
+ outline-offset: calc(-1 * var(--norn-ui-focus-offset, 2px));
60
+ }
61
+
62
+ &[aria-selected="true"] {
63
+ border-block-end-color: var(--norn-text-accent, light-dark(oklch(from color-mix(in oklch, var(--norn-ui-accent, #2346d4) 82%, black) min(l, 0.5) c h), oklch(from color-mix(in oklch, var(--norn-ui-accent, #2346d4) 55%, white) max(l, 0.72) c h)));
64
+ color: var(--norn-text-accent, light-dark(oklch(from color-mix(in oklch, var(--norn-ui-accent, #2346d4) 82%, black) min(l, 0.5) c h), oklch(from color-mix(in oklch, var(--norn-ui-accent, #2346d4) 55%, white) max(l, 0.72) c h)));
65
+ font-weight: 600;
66
+ }
67
+ }
68
+
69
+ & > div > [role="tabpanel"][hidden] {
70
+ display: none;
71
+ }
72
+
73
+ /* The tab names the sample, so the caption would repeat it. */
74
+ & > div > [role="tabpanel"] figcaption {
75
+ display: none;
76
+ }
77
+
78
+ & > div > [role="tabpanel"] figure:has(> figcaption) {
79
+ row-gap: 0;
80
+ }
81
+
82
+ @media (pointer: coarse) {
83
+ & > div > [role="tablist"] > [role="tab"] {
84
+ min-block-size: 2.75rem;
85
+ }
86
+ }
87
+
88
+ @media (prefers-reduced-motion: no-preference) {
89
+ & > div > [role="tablist"] > [role="tab"] {
90
+ transition: background-color var(--norn-ui-motion-duration, 150ms) var(--norn-ui-motion-easing, cubic-bezier(0.2, 0, 0, 1));
91
+ }
92
+ }
93
+
94
+ /* A clear border would be drawn in the text color, so only the selected tab has one. */
95
+ @media (forced-colors: active) {
96
+ & > div > [role="tablist"] > [role="tab"] {
97
+ border-block-end-width: 0;
98
+ padding-block-end: 2px;
99
+ }
100
+
101
+ & > div > [role="tablist"] > [role="tab"][aria-selected="true"] {
102
+ border-block-end-width: 2px;
103
+ border-block-end-color: Highlight;
104
+ padding-block-end: 0;
105
+ }
106
+ }
107
+
108
+ @media print {
109
+ & > div > [role="tablist"] {
110
+ display: none;
111
+ }
112
+
113
+ & > div > [role="tabpanel"][hidden] {
114
+ display: block;
115
+ }
116
+ }
117
+ }
118
+ }
119
+ </style>
120
+ <script>
121
+ {
122
+ const STORE = "norn-code-tabs:";
123
+ let sets = 0;
124
+
125
+ const remembered = (group) => {
126
+ try {
127
+ return group ? localStorage.getItem(STORE + group) : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ };
132
+
133
+ const remember = (group, name) => {
134
+ try {
135
+ if (group) localStorage.setItem(STORE + group, name);
136
+ } catch {
137
+ // Not remembered: the choice holds for this page only.
138
+ }
139
+ };
140
+
141
+ /**
142
+ * A sample's tab name, read from its markup, so it never waits on the code block's script:
143
+ * its `data-tab`, its caption, the text of its `label` slot when the author filled it, or its language.
144
+ */
145
+ const nameOf = (panel) => {
146
+ const label = panel.querySelector(":scope > figure > [data-name]");
147
+ return (
148
+ panel.dataset.tab ||
149
+ panel.querySelector("figcaption")?.textContent.trim() ||
150
+ (label && !label.querySelector("[data-default]") ? label.textContent.trim() : "") ||
151
+ panel.querySelector("pre[data-language]")?.dataset.language ||
152
+ ""
153
+ );
154
+ };
155
+
156
+ /** The names with a repeat made distinct by a number: `sh`, `sh 2`, `sh 3`. A tab name picks a tab, so none can be shared. */
157
+ const distinct = (names) => {
158
+ const used = new Set(names);
159
+ const seen = new Set();
160
+ return names.map((name) => {
161
+ if (!seen.has(name)) {
162
+ seen.add(name);
163
+ return name;
164
+ }
165
+ let count = 2;
166
+ while (used.has(`${name} ${count}`)) count++;
167
+ used.add(`${name} ${count}`);
168
+ return `${name} ${count}`;
169
+ });
170
+ };
171
+
172
+ /** Tabs over the code samples in the slot. The stacked samples are the whole component without a script. */
173
+ class CodeTabs extends HTMLElement {
174
+ #sync = (event) => {
175
+ if (event.detail.group === this.#group && event.detail.source !== this) this.#select(event.detail.name, false);
176
+ };
177
+
178
+ get #box() {
179
+ return this.querySelector(":scope > div");
180
+ }
181
+
182
+ get #group() {
183
+ return this.#box?.dataset.group;
184
+ }
185
+
186
+ connectedCallback() {
187
+ document.addEventListener("codetabschange", this.#sync);
188
+ const box = this.#box;
189
+ if (!box || box.querySelector(':scope > [role="tablist"]')) return;
190
+ const panels = [...box.querySelectorAll(":scope > norn-code-block")];
191
+ if (panels.length < 2) return;
192
+ const id = `norn-code-tabs-${++sets}`;
193
+ const strings = this.querySelector(":scope > [hidden]");
194
+ const name = strings?.querySelector('[data-string="label"]');
195
+ const sample = strings?.querySelector('[data-string="sample"]')?.textContent.trim() || "{n}";
196
+ const list = document.createElement("div");
197
+ list.setAttribute("role", "tablist");
198
+ if (name?.id) list.setAttribute("aria-labelledby", name.id);
199
+ const names = distinct(panels.map((panel, index) => nameOf(panel) || sample.replaceAll("{n}", String(index + 1))));
200
+ panels.forEach((panel, index) => {
201
+ const tab = document.createElement("button");
202
+ tab.type = "button";
203
+ tab.id = `${id}-tab-${index}`;
204
+ tab.setAttribute("role", "tab");
205
+ tab.setAttribute("aria-controls", (panel.id ||= `${id}-panel-${index}`));
206
+ tab.textContent = names[index];
207
+ panel.setAttribute("role", "tabpanel");
208
+ panel.setAttribute("aria-labelledby", tab.id);
209
+ list.append(tab);
210
+ });
211
+ box.prepend(list);
212
+ list.addEventListener("click", (event) => {
213
+ const tab = event.target.closest('[role="tab"]');
214
+ if (tab) this.#show(this.#tabs().indexOf(tab), true);
215
+ });
216
+ list.addEventListener("keydown", (event) => this.#key(event));
217
+ const tabs = this.#tabs();
218
+ const saved = remembered(this.#group);
219
+ this.#select(saved, false);
220
+ if (!tabs.some((tab) => tab.getAttribute("aria-selected") === "true")) this.#show(0, false);
221
+ }
222
+
223
+ disconnectedCallback() {
224
+ document.removeEventListener("codetabschange", this.#sync);
225
+ }
226
+
227
+ #tabs() {
228
+ return [...this.querySelectorAll('[role="tab"]')];
229
+ }
230
+
231
+ /** The tab with this name, if any. Names are distinct within a set, so it is the only one. */
232
+ #select(name, chosen) {
233
+ const index = this.#tabs().findIndex((tab) => tab.textContent === name);
234
+ if (index !== -1) this.#show(index, chosen);
235
+ }
236
+
237
+ /** Show the tab at this index, and the panel at the same index. A person's choice is remembered and shared with the group; a sync is not passed on. */
238
+ #show(index, chosen) {
239
+ const tabs = this.#tabs();
240
+ const panels = [...this.querySelectorAll(':scope > div > [role="tabpanel"]')];
241
+ tabs.forEach((tab, position) => {
242
+ const selected = position === index;
243
+ tab.setAttribute("aria-selected", String(selected));
244
+ tab.tabIndex = selected ? 0 : -1;
245
+ if (panels[position]) panels[position].hidden = !selected;
246
+ });
247
+ if (!chosen) return;
248
+ const name = tabs[index].textContent;
249
+ remember(this.#group, name);
250
+ if (this.#group) document.dispatchEvent(new CustomEvent("codetabschange", { detail: { group: this.#group, name, source: this } }));
251
+ }
252
+
253
+ /** Arrow keys follow the reading direction; Home and End go to the ends. Moving focus selects the tab. */
254
+ #key(event) {
255
+ const tabs = this.#tabs();
256
+ const index = tabs.indexOf(document.activeElement);
257
+ if (index === -1) return;
258
+ const forward = getComputedStyle(this).direction === "rtl" ? "ArrowLeft" : "ArrowRight";
259
+ const backward = forward === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
260
+ const next = { [forward]: (index + 1) % tabs.length, [backward]: (index - 1 + tabs.length) % tabs.length, Home: 0, End: tabs.length - 1 }[event.key];
261
+ if (next === undefined) return;
262
+ event.preventDefault();
263
+ tabs[next].focus();
264
+ this.#show(next, true);
265
+ }
266
+ }
267
+
268
+ if (!customElements.get("norn-code-tabs")) customElements.define("norn-code-tabs", CodeTabs);
269
+ }
270
+ </script>
@@ -0,0 +1,220 @@
1
+ <!---
2
+ /*
3
+ * SPDX-License-Identifier: MPL-2.0
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+ --->
9
+ <span webc:if="!(typeof strings === 'string' && strings)" hidden>
10
+ <span data-string="label" :id="'norn-copy-button-' + uid"><slot name="label"></slot></span>
11
+ <span data-string="copy"><slot name="copy"><span webc:nokeep @text="nornComponentStrings?.('norn-copy-button', 'copy', $data.page) || 'Copy'"></span></slot></span>
12
+ <span data-string="copied"><slot name="copied"><span webc:nokeep @text="nornComponentStrings?.('norn-copy-button', 'copied', $data.page) || 'Copied'"></span></slot></span>
13
+ <span data-string="failed"><slot name="failed"><span webc:nokeep @text="nornComponentStrings?.('norn-copy-button', 'failed', $data.page) || 'Copy failed'"></span></slot></span>
14
+ </span>
15
+ <button
16
+ type="button"
17
+ hidden
18
+ :data-value="typeof value === 'string' ? value : undefined"
19
+ :data-source="typeof source === 'string' ? source : undefined"
20
+ :data-strings="typeof strings === 'string' && strings ? strings : undefined"
21
+ :data-compact="compact === true || compact === '' || compact === 'true' ? '' : undefined"
22
+ :data-surface="['base', 'raised', 'code'].includes(surface) ? surface : undefined"
23
+ ><span data-icons><norn-icon @name="copy"></norn-icon><norn-icon @name="check"></norn-icon></span><span data-label></span></button>
24
+ <span role="status"></span>
25
+ <style>
26
+ @layer norn.components {
27
+ norn-copy-button {
28
+ display: inline-flex;
29
+ max-inline-size: 100%;
30
+
31
+ & button {
32
+ --_state: 0%;
33
+ box-sizing: border-box;
34
+ display: inline-flex;
35
+ align-items: center;
36
+ min-block-size: var(--norn-ui-control-height, 2.25rem);
37
+ max-inline-size: 100%;
38
+ padding: 0 var(--norn-space-xs, 0.5rem);
39
+ border: 0;
40
+ border-radius: var(--norn-ui-border-radius, 6px);
41
+ background-color: color-mix(in oklab, currentcolor var(--_state), var(--_bg, transparent));
42
+ color: var(--norn-text-muted, light-dark(#4b5160, #a4abba));
43
+ font: inherit;
44
+ font-size: var(--norn-font-size-s, 0.875rem);
45
+ overflow-wrap: anywhere;
46
+ text-align: start;
47
+ cursor: pointer;
48
+
49
+ &[hidden] {
50
+ display: none;
51
+ }
52
+
53
+ &[data-surface="base"] {
54
+ --_bg: var(--norn-surface-base, light-dark(#ffffff, #101216));
55
+ }
56
+
57
+ &[data-surface="raised"] {
58
+ --_bg: var(--norn-surface-raised, light-dark(hsl(40, 14%, 96%), #242932));
59
+ }
60
+
61
+ &[data-surface="code"] {
62
+ --_bg: var(--norn-surface-code, light-dark(hsl(40, 14%, 94%), #2c313b));
63
+ }
64
+
65
+ &:hover {
66
+ --_state: var(--norn-ui-state-hover, 8%);
67
+ }
68
+
69
+ &:active {
70
+ --_state: var(--norn-ui-state-press, 12%);
71
+ }
72
+
73
+ &[data-state="copied"] {
74
+ color: var(--norn-status-success, light-dark(#0b6b3a, #7ed6a1));
75
+ }
76
+
77
+ &[data-state="failed"] {
78
+ color: var(--norn-status-danger, light-dark(#b42318, #ff8a80));
79
+ }
80
+ }
81
+
82
+ /* The copy icon and the check share a cell, and so do the three texts, so the button is as wide for one as for another. */
83
+ & [data-icons],
84
+ & [data-label] {
85
+ display: inline-grid;
86
+ }
87
+
88
+ & [data-icons] > norn-icon,
89
+ & [data-label] > span {
90
+ grid-area: 1 / 1;
91
+ }
92
+
93
+ & [data-label] {
94
+ margin-inline-start: var(--norn-space-2xs, 0.25rem);
95
+ }
96
+
97
+ & button:not([data-state="copied"]) [data-icons] > norn-icon:last-child,
98
+ & button[data-state="copied"] [data-icons] > norn-icon:first-child,
99
+ & [data-label] > span:not([data-active]) {
100
+ visibility: hidden;
101
+ }
102
+
103
+ /* A compact button is only its icon until the pointer or focus is on it, or it has a result to show. */
104
+ & button[data-compact]:not(:hover, :focus-visible, [data-state]) [data-label] {
105
+ inline-size: 0;
106
+ margin: 0;
107
+ overflow: hidden;
108
+ white-space: nowrap;
109
+ }
110
+
111
+ & [role="status"] {
112
+ position: absolute;
113
+ inline-size: 1px;
114
+ block-size: 1px;
115
+ overflow: hidden;
116
+ clip-path: inset(50%);
117
+ white-space: nowrap;
118
+ }
119
+
120
+ @media (pointer: coarse) {
121
+ & button {
122
+ min-block-size: 2.75rem;
123
+ }
124
+ }
125
+
126
+ @media (prefers-reduced-motion: no-preference) {
127
+ & button {
128
+ transition: background-color var(--norn-ui-motion-duration, 150ms) var(--norn-ui-motion-easing, cubic-bezier(0.2, 0, 0, 1));
129
+ }
130
+ }
131
+
132
+ @media print {
133
+ display: none;
134
+ }
135
+ }
136
+ }
137
+ </style>
138
+ <script>
139
+ {
140
+ const CONFIRMATION_MS = 2000;
141
+
142
+ /** Copies its value, or its target's text, and says how that went, in the texts the page holds. */
143
+ class CopyButton extends HTMLElement {
144
+ #wired = false;
145
+ #timer;
146
+ #name = "";
147
+
148
+ connectedCallback() {
149
+ const button = this.querySelector("button");
150
+ if (!button || typeof navigator.clipboard?.writeText !== "function") return;
151
+ const name = this.#string(button, "label");
152
+ this.#name = name?.id && name.textContent.trim() ? name.id : "";
153
+ if (this.#name) button.setAttribute("aria-labelledby", this.#name);
154
+ this.#build(button);
155
+ button.hidden = false;
156
+ if (this.#wired) return;
157
+ this.#wired = true;
158
+ button.addEventListener("click", () => this.#copy(button));
159
+ }
160
+
161
+ disconnectedCallback() {
162
+ clearTimeout(this.#timer);
163
+ }
164
+
165
+ /** The element holding one of the texts: in this component, or in the one `data-strings` names. */
166
+ #string(button, key) {
167
+ const holder = button.dataset.strings ? document.getElementById(button.dataset.strings) : this.querySelector(":scope > span[hidden]");
168
+ return holder?.querySelector(`[data-string="${key}"]`);
169
+ }
170
+
171
+ #text(button, key) {
172
+ return this.#string(button, key)?.textContent.trim() ?? "";
173
+ }
174
+
175
+ /** The three texts in one cell, so the button is as wide for any of them. Safe to run again. */
176
+ #build(button) {
177
+ button.querySelector("[data-label]").replaceChildren(
178
+ ...["copy", "copied", "failed"].map((key) => {
179
+ const text = document.createElement("span");
180
+ text.dataset.text = key;
181
+ text.textContent = this.#text(button, key);
182
+ return text;
183
+ }),
184
+ );
185
+ this.#show(button, "copy");
186
+ }
187
+
188
+ /** Show one text, and mark the button with the result so the icon and color follow. */
189
+ #show(button, key) {
190
+ for (const text of button.querySelectorAll("[data-label] > span")) text.toggleAttribute("data-active", text.dataset.text === key);
191
+ if (key === "copy") delete button.dataset.state;
192
+ else button.dataset.state = key;
193
+ }
194
+
195
+ async #copy(button) {
196
+ const status = this.querySelector('[role="status"]');
197
+ const { value, source } = button.dataset;
198
+ let ok = true;
199
+ try {
200
+ await navigator.clipboard.writeText(value ?? document.getElementById(source)?.textContent ?? "");
201
+ } catch {
202
+ ok = false;
203
+ }
204
+ const result = ok ? "copied" : "failed";
205
+ this.#show(button, result);
206
+ status.textContent = this.#text(button, result);
207
+ // The visible text is the name while it shows the result, so the name always holds the words on the button.
208
+ button.removeAttribute("aria-labelledby");
209
+ clearTimeout(this.#timer);
210
+ this.#timer = setTimeout(() => {
211
+ this.#show(button, "copy");
212
+ status.textContent = "";
213
+ if (this.#name) button.setAttribute("aria-labelledby", this.#name);
214
+ }, CONFIRMATION_MS);
215
+ }
216
+ }
217
+
218
+ if (!customElements.get("norn-copy-button")) customElements.define("norn-copy-button", CopyButton);
219
+ }
220
+ </script>
@@ -0,0 +1,52 @@
1
+ <!---
2
+ /*
3
+ * SPDX-License-Identifier: MPL-2.0
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+ --->
9
+ <ul role="list" :data-gap="['s', 'm', 'l'].includes(gap) ? gap : undefined"><slot></slot></ul>
10
+ <style>
11
+ @layer norn.components {
12
+ norn-fact-list {
13
+ display: block;
14
+
15
+ & ul {
16
+ display: flex;
17
+ flex-wrap: wrap;
18
+ gap: var(--norn-space-xs, 0.5rem) var(--norn-space-m, 1.25rem);
19
+ margin: 0;
20
+ padding: 0;
21
+ list-style: none;
22
+ color: var(--norn-text-muted, light-dark(#4b5160, #a4abba));
23
+ font-size: var(--norn-font-size-s, 0.875rem);
24
+
25
+ &[data-gap="s"] {
26
+ row-gap: var(--norn-space-s, 0.75rem);
27
+ }
28
+
29
+ &[data-gap="m"] {
30
+ row-gap: var(--norn-space-m, 1.25rem);
31
+ }
32
+
33
+ &[data-gap="l"] {
34
+ row-gap: var(--norn-space-l, 2rem);
35
+ }
36
+ }
37
+
38
+ & li {
39
+ display: inline-flex;
40
+ align-items: center;
41
+ gap: var(--norn-space-2xs, 0.25rem);
42
+ max-inline-size: 100%;
43
+ line-height: 1.5;
44
+ overflow-wrap: anywhere;
45
+ }
46
+
47
+ & norn-icon {
48
+ color: var(--norn-text-accent, light-dark(oklch(from color-mix(in oklch, var(--norn-ui-accent, #2346d4) 82%, black) min(l, 0.5) c h), oklch(from color-mix(in oklch, var(--norn-ui-accent, #2346d4) 55%, white) max(l, 0.72) c h)));
49
+ }
50
+ }
51
+ }
52
+ </style>
@@ -0,0 +1,122 @@
1
+ <!---
2
+ /*
3
+ * SPDX-License-Identifier: MPL-2.0
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+ --->
9
+ <footer :style="[typeof width === 'string' && width ? '--_width: ' + width : '', typeof leadWidth === 'string' && leadWidth ? '--_lead: ' + leadWidth : ''].filter(Boolean).join('; ')">
10
+ <div><slot></slot></div>
11
+ <div><slot name="colophon"></slot></div>
12
+ </footer>
13
+ <style>
14
+ @layer norn.components {
15
+ norn-footer {
16
+ display: block;
17
+ container-type: inline-size;
18
+
19
+ & > footer {
20
+ display: grid;
21
+ gap: var(--norn-space-l, 2rem);
22
+ padding: var(--norn-space-l, 2rem) var(--norn-space-m, 1.25rem);
23
+ border-block-start: 1px solid var(--norn-ui-border-color, light-dark(#d3d8e2, #2d323e));
24
+ color: var(--norn-text-muted, light-dark(#4b5160, #a4abba));
25
+ font-size: var(--norn-font-size-s, 0.875rem);
26
+ }
27
+
28
+ & > footer > div {
29
+ inline-size: min(100%, var(--_width, var(--norn-layout-width, 64rem)));
30
+ margin-inline: auto;
31
+
32
+ /* A slot nobody filled leaves its wrapper empty: take it out of the grid. */
33
+ &:not(:has(*)) {
34
+ display: none;
35
+ }
36
+
37
+ &:first-child {
38
+ display: grid;
39
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
40
+ align-items: start;
41
+ gap: var(--norn-space-l, 2rem);
42
+ }
43
+
44
+ &:first-child > :is(div, nav) {
45
+ display: grid;
46
+ align-content: start;
47
+ gap: var(--norn-space-s, 0.75rem);
48
+ }
49
+
50
+ /* A nav of several link groups: one landmark, a column per group. */
51
+ &:first-child > nav:has(> div) {
52
+ display: flex;
53
+ flex-wrap: wrap;
54
+ gap: var(--norn-space-l, 2rem);
55
+ }
56
+
57
+ &:first-child > nav > div {
58
+ display: grid;
59
+ align-content: start;
60
+ gap: var(--norn-space-s, 0.75rem);
61
+ }
62
+ }
63
+
64
+ @container (min-width: 60rem) {
65
+ & > footer > div:first-child {
66
+ grid-template-columns: minmax(0, var(--_lead, 25rem));
67
+ grid-auto-columns: minmax(min-content, max-content);
68
+ grid-auto-flow: column;
69
+ justify-content: space-between;
70
+ }
71
+ }
72
+
73
+ & :where(p) {
74
+ margin-block: 0;
75
+ }
76
+
77
+ & :where(strong) {
78
+ color: var(--norn-text-base, light-dark(#16181d, #eceef3));
79
+ }
80
+
81
+ & :where(small) {
82
+ font-size: var(--norn-font-size-s, 0.875rem);
83
+ }
84
+
85
+ & > footer > div:first-child > :is(div, nav) > ul,
86
+ & > footer > div:first-child > nav > div > ul {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: var(--norn-space-xs, 0.5rem);
90
+ margin: 0;
91
+ padding: 0;
92
+ list-style: none;
93
+ }
94
+
95
+ & :where(a) {
96
+ color: inherit;
97
+ }
98
+
99
+ /* A link in the navigation is quiet until the pointer or focus is on it. A link in a sentence keeps its underline. */
100
+ & :where(nav a) {
101
+ text-decoration: none;
102
+ }
103
+
104
+ & :where(a:is(:hover, :focus-visible)) {
105
+ color: var(--norn-text-accent, light-dark(oklch(from color-mix(in oklch, var(--norn-ui-accent, #2346d4) 82%, black) min(l, 0.5) c h), oklch(from color-mix(in oklch, var(--norn-ui-accent, #2346d4) 55%, white) max(l, 0.72) c h)));
106
+ text-decoration: underline;
107
+ }
108
+
109
+ & :where(a:has(norn-icon)) {
110
+ display: inline-flex;
111
+ align-items: center;
112
+ gap: var(--norn-space-2xs, 0.25rem);
113
+ }
114
+
115
+ @media print {
116
+ & > footer {
117
+ gap: var(--norn-space-s, 0.75rem);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ </style>