@timmo001/groundwork-ui 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,155 @@
1
+ /**
2
+ * `gw-notification-center`: where notifications wait until they're dealt with.
3
+ * Docs: https://groundwork.timmo.dev/components/feedback/#notifications
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import "./gw-button.js";
9
+ import "./gw-empty-state.js";
10
+ import "./gw-notification.js";
11
+ import { animateHeight } from "./motion.js";
12
+ import { base } from "./styles.js";
13
+
14
+ // MDI's bell-check-outline
15
+ const emptyIcon =
16
+ "M11.5 21.97C10.7 21.85 10 21.22 10 20.38H11.5V21.97M21.5 13.5L20.08 12.09L16 16.17L13.92 14.09L12.5 15.5L16 19L21.5 13.5M6 17H11.08C11.03 17.33 11 17.66 11 18C11 18.34 11.03 18.67 11.08 19H3V18L5 16V10C5 6.9 7.03 4.17 10 3.29V3A2 2 0 0 1 12 1A2 2 0 0 1 14 3V3.29C16.97 4.17 19 6.9 19 10V11.18C18.37 11.07 17.7 11 17 11V10A5 5 0 0 0 12 5A5 5 0 0 0 7 10V17H6Z";
17
+
18
+ /**
19
+ * Where notifications wait until they're dealt with: a list of
20
+ * `gw-notification` cards with Clear all, and an empty state once nothing's
21
+ * left. Put it in a page, a panel or a popover from a bell button. Each
22
+ * card folds away when dismissed, and the list closes up smoothly. It fires
23
+ * `clear` when Clear all is pressed.
24
+ *
25
+ * @element gw-notification-center
26
+ * @summary A list of notifications with Clear all and an empty state.
27
+ *
28
+ * @attr {string} heading - The list's heading. Defaults to `Notifications`.
29
+ *
30
+ * @slot - The `gw-notification` cards.
31
+ *
32
+ * @fires clear - When Clear all is pressed.
33
+ */
34
+ export class GwNotificationCenter extends GwElement {
35
+ static override properties = {
36
+ heading: {},
37
+ } as const;
38
+
39
+ static override styles = [
40
+ base,
41
+ css`
42
+ :host {
43
+ display: block;
44
+ }
45
+
46
+ header {
47
+ display: flex;
48
+ gap: var(--gw-space-2);
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ margin-block-end: var(--gw-space-2);
52
+ }
53
+
54
+ h2 {
55
+ margin: 0;
56
+ font-size: var(--gw-font-size-m);
57
+ font-weight: var(--gw-font-weight-strong);
58
+ color: var(--gw-color-text-strong);
59
+ }
60
+
61
+ .list {
62
+ display: grid;
63
+ gap: var(--gw-space-2);
64
+ }
65
+
66
+ [hidden] {
67
+ display: none;
68
+ }
69
+
70
+ gw-empty-state::part(empty) {
71
+ padding-block: var(--gw-space-6);
72
+ }
73
+ `,
74
+ ];
75
+
76
+ declare heading: string;
77
+
78
+ readonly #heading = h("h2");
79
+
80
+ readonly #clear = h(
81
+ "gw-button",
82
+ { size: "small", variant: "neutral", appearance: "plain" },
83
+ "Clear all",
84
+ );
85
+
86
+ readonly #list = h("div", { class: "list", role: "list" }, h("slot"));
87
+
88
+ readonly #empty = h(
89
+ "gw-empty-state",
90
+ { icon: emptyIcon, heading: "You're all caught up" },
91
+ h("p", {}, "New notifications show up here."),
92
+ );
93
+
94
+ readonly #body = h("div", {}, this.#list, this.#empty);
95
+
96
+ #observer: MutationObserver | undefined;
97
+
98
+ constructor() {
99
+ super();
100
+ this.heading = "Notifications";
101
+ }
102
+
103
+ /** Dismisses every notification. */
104
+ clear() {
105
+ this.dispatchEvent(new Event("clear", { bubbles: true, composed: true }));
106
+
107
+ for (const item of this.querySelectorAll("gw-notification")) item.dismiss();
108
+ }
109
+
110
+ override connectedCallback() {
111
+ super.connectedCallback();
112
+ this.#observer = new MutationObserver(() => this.#refresh());
113
+ this.#observer.observe(this, { childList: true });
114
+ this.#refresh(false);
115
+ }
116
+
117
+ override disconnectedCallback() {
118
+ this.#observer?.disconnect();
119
+ super.disconnectedCallback();
120
+ }
121
+
122
+ #refresh(animate = true) {
123
+ const before = this.#body.offsetHeight;
124
+ const empty = this.querySelector("gw-notification") === null;
125
+
126
+ this.#list.hidden = empty;
127
+ this.#empty.hidden = !empty;
128
+ this.#clear.hidden = empty;
129
+
130
+ for (const item of this.querySelectorAll("gw-notification")) {
131
+ item.setAttribute("role", "listitem");
132
+ }
133
+
134
+ if (animate) animateHeight(this.#body, before);
135
+ }
136
+
137
+ protected override create(root: ShadowRoot) {
138
+ this.#clear.addEventListener("click", () => {
139
+ this.clear();
140
+ });
141
+ root.append(h("header", {}, this.#heading, this.#clear), this.#body);
142
+ }
143
+
144
+ protected override update() {
145
+ this.#heading.textContent = this.heading;
146
+ }
147
+ }
148
+
149
+ GwNotificationCenter.define("gw-notification-center");
150
+
151
+ declare global {
152
+ interface HTMLElementTagNameMap {
153
+ "gw-notification-center": GwNotificationCenter;
154
+ }
155
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `gw-notification`: one notification card, in the center or by its entry.
3
+ * Docs: https://groundwork.timmo.dev/components/feedback/#notifications
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import "./gw-button.js";
9
+ import { fadeEasing, motionDuration, reducedMotion } from "./motion.js";
10
+ import { base, createIcon, setIconPath, type Tone } from "./styles.js";
11
+
12
+ // MDI's close
13
+ const closeIcon =
14
+ "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z";
15
+
16
+ /**
17
+ * Something new the person needs to decide or know, kept in a
18
+ * `gw-notification-center` until it's dealt with. It has an icon, a heading,
19
+ * a line of text, when it happened and up to two actions. Its close button
20
+ * dismisses it: it fires `dismiss`, then folds away and removes itself,
21
+ * unless the event is cancelled.
22
+ *
23
+ * @element gw-notification
24
+ * @summary One notification card, dismissed with its close button.
25
+ *
26
+ * @attr {string} heading - The heading.
27
+ * @attr {string} icon - SVG path data for its icon.
28
+ * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The icon's color. Defaults to `neutral`.
29
+ * @attr {string} time - When it happened, as shown, such as `2 min ago`.
30
+ * @attr {boolean} unread - Marks it with a stripe until it's seen.
31
+ *
32
+ * @slot - A line of text.
33
+ * @slot actions - Up to two buttons.
34
+ *
35
+ * @csspart card - The card.
36
+ *
37
+ * @fires dismiss - When the close button is pressed. Cancel it to keep the card.
38
+ */
39
+ export class GwNotification extends GwElement {
40
+ static override properties = {
41
+ heading: {},
42
+ icon: {},
43
+ tone: { reflect: true },
44
+ time: {},
45
+ unread: { type: "boolean", reflect: true },
46
+ } as const;
47
+
48
+ static override styles = [
49
+ base,
50
+ css`
51
+ :host {
52
+ display: block;
53
+ }
54
+
55
+ .card {
56
+ display: grid;
57
+ grid-template-columns: auto 1fr auto;
58
+ gap: var(--gw-space-1) var(--gw-space-2);
59
+ align-items: start;
60
+ padding: var(--gw-space-3);
61
+ font-size: var(--gw-font-size-s);
62
+ color: var(--gw-color-text-primary);
63
+ background: var(--gw-color-surface-raised);
64
+ border: var(--gw-border-width) solid var(--gw-color-border-quiet);
65
+ border-radius: var(--gw-radius-control);
66
+ }
67
+
68
+ /* An inset stripe, so unread and read cards line up */
69
+ :host([unread]) .card {
70
+ box-shadow: inset calc(var(--gw-border-width) * 4) 0 0
71
+ var(--gw-tone, var(--gw-color-accent));
72
+ }
73
+
74
+ :host([unread]:dir(rtl)) .card {
75
+ box-shadow: inset calc(var(--gw-border-width) * -4) 0 0
76
+ var(--gw-tone, var(--gw-color-accent));
77
+ }
78
+
79
+ .card > .icon {
80
+ margin-block-start: 0.1em;
81
+ color: var(--gw-tone, var(--gw-color-text-secondary));
82
+ }
83
+
84
+ .card > .icon[hidden] {
85
+ display: none;
86
+ }
87
+
88
+ .body {
89
+ display: grid;
90
+ gap: var(--gw-space-1);
91
+ min-width: 0;
92
+ }
93
+
94
+ strong {
95
+ font-weight: var(--gw-font-weight-strong);
96
+ color: var(--gw-color-text-strong);
97
+ }
98
+
99
+ time {
100
+ font-size: var(--gw-font-size-xs);
101
+ color: var(--gw-color-text-secondary);
102
+ }
103
+
104
+ time:empty {
105
+ display: none;
106
+ }
107
+
108
+ ::slotted(p) {
109
+ margin: 0;
110
+ }
111
+
112
+ .actions {
113
+ display: flex;
114
+ flex-wrap: wrap;
115
+ gap: var(--gw-space-1);
116
+ margin-block-start: var(--gw-space-1);
117
+ }
118
+
119
+ .close {
120
+ margin: calc(var(--gw-space-1) * -1);
121
+ }
122
+ `,
123
+ ];
124
+
125
+ declare heading: string;
126
+ declare icon: string;
127
+ declare tone: Tone;
128
+ declare time: string;
129
+ declare unread: boolean;
130
+
131
+ readonly #icon = createIcon("");
132
+
133
+ readonly #heading = h("strong");
134
+
135
+ readonly #time = h("time");
136
+
137
+ readonly #close = h(
138
+ "gw-button",
139
+ {
140
+ class: "close",
141
+ variant: "neutral",
142
+ appearance: "plain",
143
+ size: "xs",
144
+ labels: "hide",
145
+ icon: closeIcon,
146
+ },
147
+ "Dismiss",
148
+ );
149
+
150
+ constructor() {
151
+ super();
152
+ this.heading = "";
153
+ this.icon = "";
154
+ this.tone = "neutral";
155
+ this.time = "";
156
+ this.unread = false;
157
+ }
158
+
159
+ /** Fires `dismiss`, then folds away and removes itself unless that's cancelled. */
160
+ dismiss() {
161
+ const event = new Event("dismiss", {
162
+ bubbles: true,
163
+ composed: true,
164
+ cancelable: true,
165
+ });
166
+
167
+ if (!this.dispatchEvent(event)) return;
168
+
169
+ const duration = reducedMotion()
170
+ ? 0
171
+ : motionDuration(this, "--gw-motion-normal", 250);
172
+
173
+ const leave = this.animate(
174
+ [
175
+ { opacity: 1, height: `${this.offsetHeight}px`, overflow: "clip" },
176
+ { opacity: 0, height: "0px", overflow: "clip" },
177
+ ],
178
+ { duration, easing: fadeEasing, fill: "forwards" },
179
+ );
180
+
181
+ leave.addEventListener("finish", () => this.remove());
182
+ leave.addEventListener("cancel", () => this.remove());
183
+ }
184
+
185
+ protected override create(root: ShadowRoot) {
186
+ this.#close.addEventListener("click", () => {
187
+ this.dismiss();
188
+ });
189
+ root.append(
190
+ h(
191
+ "article",
192
+ { class: "card", part: "card" },
193
+ this.#icon,
194
+ h(
195
+ "div",
196
+ { class: "body" },
197
+ this.#heading,
198
+ h("slot"),
199
+ this.#time,
200
+ h("div", { class: "actions" }, h("slot", { name: "actions" })),
201
+ ),
202
+ this.#close,
203
+ ),
204
+ );
205
+ }
206
+
207
+ protected override update() {
208
+ setIconPath(this.#icon, this.icon);
209
+ this.#icon.toggleAttribute("hidden", !this.icon);
210
+ this.#heading.textContent = this.heading;
211
+ this.#heading.hidden = !this.heading;
212
+ this.#time.textContent = this.time;
213
+ }
214
+ }
215
+
216
+ GwNotification.define("gw-notification");
217
+
218
+ declare global {
219
+ interface HTMLElementTagNameMap {
220
+ "gw-notification": GwNotification;
221
+ }
222
+ }