@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.
- package/LICENSE +201 -0
- package/README.md +32 -2
- package/package.json +39 -4
- package/src/dismiss.ts +165 -0
- package/src/element.ts +375 -0
- package/src/field-info.ts +56 -0
- package/src/fields.ts +346 -0
- package/src/gw-alert.ts +117 -0
- package/src/gw-badge.ts +171 -0
- package/src/gw-banner.ts +120 -0
- package/src/gw-button.ts +438 -0
- package/src/gw-card.ts +187 -0
- package/src/gw-checkbox.ts +113 -0
- package/src/gw-chip.ts +122 -0
- package/src/gw-command-menu.ts +283 -0
- package/src/gw-dialog.ts +246 -0
- package/src/gw-dot.ts +89 -0
- package/src/gw-empty-state.ts +107 -0
- package/src/gw-expansion.ts +224 -0
- package/src/gw-facts.ts +141 -0
- package/src/gw-fade-in.ts +108 -0
- package/src/gw-gauge.ts +328 -0
- package/src/gw-input.ts +127 -0
- package/src/gw-menu-button.ts +386 -0
- package/src/gw-meter.ts +194 -0
- package/src/gw-notification-center.ts +155 -0
- package/src/gw-notification.ts +222 -0
- package/src/gw-option-list.ts +616 -0
- package/src/gw-page-header.ts +125 -0
- package/src/gw-popover.ts +382 -0
- package/src/gw-section-title.ts +71 -0
- package/src/gw-segmented.ts +301 -0
- package/src/gw-select.ts +361 -0
- package/src/gw-setting-row.ts +168 -0
- package/src/gw-skeleton.ts +178 -0
- package/src/gw-spinner.ts +84 -0
- package/src/gw-toast.ts +167 -0
- package/src/index.ts +76 -0
- package/src/layout.ts +182 -0
- package/src/motion.ts +105 -0
- package/src/styles.ts +122 -0
package/src/gw-card.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gw-card`: a surface for one group of content.
|
|
3
|
+
* Docs: https://groundwork.timmo.dev/components/display/#card
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { GwElement, css, h, setAttribute } from "./element.js";
|
|
8
|
+
import "./gw-skeleton.js";
|
|
9
|
+
import { animateHeight, fadeIn } from "./motion.js";
|
|
10
|
+
import { base } from "./styles.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A surface for one group of content, with an optional heading and actions.
|
|
14
|
+
* With `href` the whole card is a link. While `loading`, lines of skeleton
|
|
15
|
+
* stand in for the content, and for the heading until it's known. The card
|
|
16
|
+
* glides to its new height and the content fades in once it's loaded.
|
|
17
|
+
*
|
|
18
|
+
* @element gw-card
|
|
19
|
+
* @summary A surface for one group of content, or a link to more.
|
|
20
|
+
*
|
|
21
|
+
* @attr {string} heading - The card's heading.
|
|
22
|
+
* @attr {string} href - Makes the whole card a link to this address.
|
|
23
|
+
* @attr {boolean} flush - Drops the padding, for content that runs to the edges.
|
|
24
|
+
* @attr {boolean} loading - Shows skeleton lines for the content and any unknown heading.
|
|
25
|
+
* @attr {number} lines - How many skeleton lines while loading. Defaults to 3.
|
|
26
|
+
*
|
|
27
|
+
* @slot - The content.
|
|
28
|
+
* @slot actions - Buttons beside the heading.
|
|
29
|
+
*
|
|
30
|
+
* @csspart card - The card's surface.
|
|
31
|
+
*/
|
|
32
|
+
export class GwCard extends GwElement {
|
|
33
|
+
static override properties = {
|
|
34
|
+
heading: {},
|
|
35
|
+
href: {},
|
|
36
|
+
flush: { type: "boolean", reflect: true },
|
|
37
|
+
loading: { type: "boolean", reflect: true },
|
|
38
|
+
lines: { type: "number" },
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
static override styles = [
|
|
42
|
+
base,
|
|
43
|
+
css`
|
|
44
|
+
:host {
|
|
45
|
+
display: block;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.card {
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-direction: column;
|
|
51
|
+
gap: var(--gw-space-3);
|
|
52
|
+
height: 100%;
|
|
53
|
+
padding: var(--gw-space-4);
|
|
54
|
+
color: inherit;
|
|
55
|
+
text-decoration: none;
|
|
56
|
+
background: var(--gw-color-surface-raised);
|
|
57
|
+
border: var(--gw-border-width) solid var(--gw-color-border-normal);
|
|
58
|
+
border-radius: var(--gw-radius-panel);
|
|
59
|
+
box-shadow: var(--gw-shadow-s);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
:host([flush]) .card {
|
|
63
|
+
padding: 0;
|
|
64
|
+
overflow: hidden;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
a.card {
|
|
68
|
+
transition: border-color var(--gw-motion-fast) var(--gw-motion-easing);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
a.card:hover {
|
|
72
|
+
border-color: var(--gw-color-border-accent);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
header {
|
|
76
|
+
display: flex;
|
|
77
|
+
gap: var(--gw-space-2);
|
|
78
|
+
align-items: center;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
h2 {
|
|
82
|
+
flex: 1;
|
|
83
|
+
margin: 0;
|
|
84
|
+
font-size: var(--gw-font-size-m);
|
|
85
|
+
font-weight: var(--gw-font-weight-strong);
|
|
86
|
+
color: var(--gw-color-text-strong);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.body:not([hidden]) {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
gap: inherit;
|
|
93
|
+
}
|
|
94
|
+
`,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
declare heading: string;
|
|
98
|
+
declare href: string;
|
|
99
|
+
declare flush: boolean;
|
|
100
|
+
declare loading: boolean;
|
|
101
|
+
/** How many lines of skeleton stand in for the content while loading. */
|
|
102
|
+
declare lines: number;
|
|
103
|
+
|
|
104
|
+
readonly #title = h("span");
|
|
105
|
+
|
|
106
|
+
readonly #titleSkeleton = h("gw-skeleton-text");
|
|
107
|
+
|
|
108
|
+
readonly #header = h(
|
|
109
|
+
"header",
|
|
110
|
+
{},
|
|
111
|
+
h("h2", {}, this.#title, this.#titleSkeleton),
|
|
112
|
+
h("slot", { name: "actions" }),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
readonly #skeleton = h("gw-skeleton-lines");
|
|
116
|
+
|
|
117
|
+
readonly #body = h("div", { class: "body" }, h("slot"));
|
|
118
|
+
|
|
119
|
+
#card: HTMLElement = h("section", { class: "card", part: "card" });
|
|
120
|
+
|
|
121
|
+
#wasLoading = false;
|
|
122
|
+
|
|
123
|
+
#rendered = false;
|
|
124
|
+
|
|
125
|
+
constructor() {
|
|
126
|
+
super();
|
|
127
|
+
this.heading = "";
|
|
128
|
+
this.href = "";
|
|
129
|
+
this.flush = false;
|
|
130
|
+
this.loading = false;
|
|
131
|
+
this.lines = 3;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
protected override create(root: ShadowRoot) {
|
|
135
|
+
this.#card.append(this.#header, this.#skeleton, this.#body);
|
|
136
|
+
root.append(this.#card);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
protected override update(changed: ReadonlySet<string>) {
|
|
140
|
+
const animate =
|
|
141
|
+
changed.has("loading") &&
|
|
142
|
+
this.#rendered &&
|
|
143
|
+
this.#wasLoading !== this.loading;
|
|
144
|
+
|
|
145
|
+
const before = animate ? this.offsetHeight : 0;
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
changed.has("href") &&
|
|
149
|
+
(this.#card.tagName === "A") !== Boolean(this.href)
|
|
150
|
+
) {
|
|
151
|
+
const next = this.href
|
|
152
|
+
? h("a", { class: "card", part: "card" })
|
|
153
|
+
: h("section", { class: "card", part: "card" });
|
|
154
|
+
|
|
155
|
+
next.append(...this.#card.childNodes);
|
|
156
|
+
this.#card.replaceWith(next);
|
|
157
|
+
this.#card = next;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (this.#card instanceof HTMLAnchorElement) this.#card.href = this.href;
|
|
161
|
+
|
|
162
|
+
setAttribute(this.#card, "aria-busy", this.loading ? "true" : null);
|
|
163
|
+
this.#title.textContent = this.heading;
|
|
164
|
+
this.#titleSkeleton.hidden = this.heading !== "" || !this.loading;
|
|
165
|
+
this.#header.hidden = !this.heading && !this.loading;
|
|
166
|
+
this.#skeleton.lines = this.lines;
|
|
167
|
+
this.#skeleton.hidden = !this.loading;
|
|
168
|
+
this.#body.hidden = this.loading;
|
|
169
|
+
|
|
170
|
+
if (animate) {
|
|
171
|
+
animateHeight(this, before);
|
|
172
|
+
|
|
173
|
+
if (!this.loading) fadeIn(this.#body);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.#wasLoading = this.loading;
|
|
177
|
+
this.#rendered = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
GwCard.define("gw-card");
|
|
182
|
+
|
|
183
|
+
declare global {
|
|
184
|
+
interface HTMLElementTagNameMap {
|
|
185
|
+
"gw-card": GwCard;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gw-checkbox` and `gw-switch`: labeled native checkboxes in light DOM.
|
|
3
|
+
* Docs: https://groundwork.timmo.dev/components/inputs/#checkbox-and-switch
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { h, setAttribute } from "./element.js";
|
|
8
|
+
import { LightElement, fieldId } from "./fields.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A labeled native checkbox. `gw-switch` draws the same control as a switch.
|
|
12
|
+
*
|
|
13
|
+
* @element gw-checkbox
|
|
14
|
+
* @summary A labeled checkbox in light DOM, so forms see it.
|
|
15
|
+
*
|
|
16
|
+
* @attr {string} label - The label.
|
|
17
|
+
* @attr {string} name - The form field name.
|
|
18
|
+
* @attr {string} value - The value submitted when checked. Defaults to `on`.
|
|
19
|
+
* @attr {string} hint - One line of help under it.
|
|
20
|
+
* @attr {boolean} checked - Whether it's checked.
|
|
21
|
+
* @attr {boolean} disabled - Disables it.
|
|
22
|
+
* @attr {boolean} label-hidden - Hides the label but keeps it for assistive technology.
|
|
23
|
+
*/
|
|
24
|
+
export class GwCheckbox extends LightElement {
|
|
25
|
+
static override properties = {
|
|
26
|
+
label: {},
|
|
27
|
+
name: {},
|
|
28
|
+
value: {},
|
|
29
|
+
hint: {},
|
|
30
|
+
checked: { type: "boolean" },
|
|
31
|
+
disabled: { type: "boolean" },
|
|
32
|
+
labelHidden: { type: "boolean", attribute: "label-hidden" },
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
declare label: string;
|
|
36
|
+
/** Keeps the label for assistive technology but hides it, such as in a setting row. */
|
|
37
|
+
declare labelHidden: boolean;
|
|
38
|
+
declare name: string;
|
|
39
|
+
declare value: string;
|
|
40
|
+
declare hint: string;
|
|
41
|
+
declare checked: boolean;
|
|
42
|
+
declare disabled: boolean;
|
|
43
|
+
|
|
44
|
+
readonly #id = fieldId();
|
|
45
|
+
|
|
46
|
+
readonly #input = h("input", { type: "checkbox" });
|
|
47
|
+
|
|
48
|
+
readonly #text = h("span");
|
|
49
|
+
|
|
50
|
+
readonly #hint = h("p", { class: "gw-hint", id: `${this.#id}-hint` });
|
|
51
|
+
|
|
52
|
+
/** `switch` for `gw-switch`, so assistive technology announces it as one. */
|
|
53
|
+
protected get controlRole(): string | undefined {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
constructor() {
|
|
58
|
+
super();
|
|
59
|
+
this.label = "";
|
|
60
|
+
this.name = "";
|
|
61
|
+
this.value = "on";
|
|
62
|
+
this.hint = "";
|
|
63
|
+
this.checked = false;
|
|
64
|
+
this.disabled = false;
|
|
65
|
+
this.labelHidden = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
protected override create(root: HTMLElement) {
|
|
69
|
+
setAttribute(this.#input, "role", this.controlRole);
|
|
70
|
+
this.#input.addEventListener("change", () => {
|
|
71
|
+
this.checked = this.#input.checked;
|
|
72
|
+
});
|
|
73
|
+
root.append(h("label", {}, this.#input, this.#text), this.#hint);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
protected override update() {
|
|
77
|
+
const input = this.#input;
|
|
78
|
+
|
|
79
|
+
setAttribute(input, "name", this.name || null);
|
|
80
|
+
input.value = this.value;
|
|
81
|
+
setAttribute(input, "aria-describedby", this.hint ? this.#hint.id : null);
|
|
82
|
+
input.disabled = this.disabled;
|
|
83
|
+
input.checked = this.checked;
|
|
84
|
+
this.#text.textContent = this.label;
|
|
85
|
+
this.#text.className = this.labelHidden ? "gw-visually-hidden" : "";
|
|
86
|
+
this.#hint.textContent = this.hint;
|
|
87
|
+
this.#hint.hidden = !this.hint;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A checkbox drawn as a switch, announced as a switch. It takes the same
|
|
93
|
+
* attributes as `gw-checkbox`.
|
|
94
|
+
*
|
|
95
|
+
* @element gw-switch
|
|
96
|
+
* @summary A labeled switch for a setting that applies at once.
|
|
97
|
+
*/
|
|
98
|
+
export class GwSwitch extends GwCheckbox {
|
|
99
|
+
protected override get controlRole() {
|
|
100
|
+
return "switch";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
GwCheckbox.define("gw-checkbox");
|
|
105
|
+
|
|
106
|
+
GwSwitch.define("gw-switch");
|
|
107
|
+
|
|
108
|
+
declare global {
|
|
109
|
+
interface HTMLElementTagNameMap {
|
|
110
|
+
"gw-checkbox": GwCheckbox;
|
|
111
|
+
"gw-switch": GwSwitch;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/gw-chip.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gw-chip`: a short status or category, tinted by its tone.
|
|
3
|
+
* Docs: https://groundwork.timmo.dev/components/display/#chip-and-dot
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { GwElement, css, h, setAttribute } from "./element.js";
|
|
8
|
+
import "./gw-skeleton.js";
|
|
9
|
+
import { fadeIn } from "./motion.js";
|
|
10
|
+
import { base, createIcon, setIconPath, type Tone } from "./styles.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A short status or category, tinted by its `tone`. While `loading`, a
|
|
14
|
+
* skeleton stands in for its text, and the text fades in once it's loaded.
|
|
15
|
+
*
|
|
16
|
+
* @element gw-chip
|
|
17
|
+
* @summary A short status or category, tinted by its tone.
|
|
18
|
+
*
|
|
19
|
+
* @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The tint. Defaults to `neutral`.
|
|
20
|
+
* @attr {string} icon - SVG path data for an icon before the text.
|
|
21
|
+
* @attr {boolean} loading - Shows a skeleton for the text.
|
|
22
|
+
*
|
|
23
|
+
* @slot - The text.
|
|
24
|
+
* @slot icon - An icon before the text, in place of `icon`.
|
|
25
|
+
*
|
|
26
|
+
* @csspart chip - The chip.
|
|
27
|
+
*/
|
|
28
|
+
export class GwChip extends GwElement {
|
|
29
|
+
static override properties = {
|
|
30
|
+
tone: { reflect: true },
|
|
31
|
+
icon: {},
|
|
32
|
+
loading: { type: "boolean", reflect: true },
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
static override styles = [
|
|
36
|
+
base,
|
|
37
|
+
css`
|
|
38
|
+
:host {
|
|
39
|
+
display: inline-flex;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* 32px, with 12px padding */
|
|
43
|
+
.chip {
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
gap: var(--gw-space-2);
|
|
46
|
+
align-items: center;
|
|
47
|
+
box-sizing: border-box;
|
|
48
|
+
height: 2rem;
|
|
49
|
+
padding: 0 var(--gw-space-3);
|
|
50
|
+
font-size: var(--gw-font-size-xs);
|
|
51
|
+
font-weight: var(--gw-font-weight-strong);
|
|
52
|
+
color: var(--gw-color-text-strong);
|
|
53
|
+
background: var(--gw-tone-fill, var(--gw-color-fill-neutral-resting));
|
|
54
|
+
border: var(--gw-border-width) solid
|
|
55
|
+
var(--gw-tone-border, var(--gw-color-border-quiet));
|
|
56
|
+
border-radius: var(--gw-radius-chip);
|
|
57
|
+
--gw-icon-size: 1rem;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.content {
|
|
61
|
+
display: inline-flex;
|
|
62
|
+
gap: inherit;
|
|
63
|
+
align-items: center;
|
|
64
|
+
}
|
|
65
|
+
`,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
declare tone: Tone;
|
|
69
|
+
/** SVG path data for an icon before the text, instead of the `icon` slot. */
|
|
70
|
+
declare icon: string;
|
|
71
|
+
declare loading: boolean;
|
|
72
|
+
|
|
73
|
+
readonly #icon = createIcon("");
|
|
74
|
+
|
|
75
|
+
readonly #chip = h("span", { class: "chip", part: "chip" });
|
|
76
|
+
|
|
77
|
+
readonly #skeleton = h("gw-skeleton-text", {
|
|
78
|
+
style: "--gw-skeleton-width: 3.5rem",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
readonly #content = h(
|
|
82
|
+
"span",
|
|
83
|
+
{ class: "content" },
|
|
84
|
+
h("slot", { name: "icon" }, this.#icon),
|
|
85
|
+
h("slot"),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
constructor() {
|
|
89
|
+
super();
|
|
90
|
+
this.tone = "neutral";
|
|
91
|
+
this.icon = "";
|
|
92
|
+
this.loading = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
protected override create(root: ShadowRoot) {
|
|
96
|
+
this.#chip.append(this.#skeleton, this.#content);
|
|
97
|
+
root.append(this.#chip);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
protected override update(changed: ReadonlySet<string>) {
|
|
101
|
+
const wasLoading = this.#content.hidden;
|
|
102
|
+
|
|
103
|
+
setIconPath(this.#icon, this.icon);
|
|
104
|
+
this.#icon.toggleAttribute("hidden", !this.icon);
|
|
105
|
+
|
|
106
|
+
setAttribute(this.#chip, "aria-busy", this.loading ? "true" : null);
|
|
107
|
+
this.#skeleton.hidden = !this.loading;
|
|
108
|
+
this.#content.hidden = this.loading;
|
|
109
|
+
|
|
110
|
+
if (changed.has("loading") && wasLoading && !this.loading) {
|
|
111
|
+
fadeIn(this.#content);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
GwChip.define("gw-chip");
|
|
117
|
+
|
|
118
|
+
declare global {
|
|
119
|
+
interface HTMLElementTagNameMap {
|
|
120
|
+
"gw-chip": GwChip;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gw-command-menu`: a searchable list of commands on Cmd+K or Ctrl+K.
|
|
3
|
+
* Docs: https://groundwork.timmo.dev/components/overlays/#command-menu
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { GwElement, css, h } from "./element.js";
|
|
8
|
+
import "./gw-option-list.js";
|
|
9
|
+
import type { OptionItem } from "./gw-option-list.js";
|
|
10
|
+
import { base } from "./styles.js";
|
|
11
|
+
|
|
12
|
+
/** Below this width the menu is a bottom sheet. */
|
|
13
|
+
const sheetBelow = 600;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A command menu on Cmd+K or Ctrl+K: a searchable list of commands in a
|
|
17
|
+
* modal near the top of the page, or a bottom sheet on phones. Picking one
|
|
18
|
+
* closes the menu and fires `pick` with its value. Esc or a click outside
|
|
19
|
+
* closes it. It won't open over another dialog, so only one overlay shows at
|
|
20
|
+
* a time. Set `shortcut="none"` to open it only with `show()`.
|
|
21
|
+
*
|
|
22
|
+
* @element gw-command-menu
|
|
23
|
+
* @summary A searchable list of commands on Cmd+K or Ctrl+K.
|
|
24
|
+
*
|
|
25
|
+
* @attr {OptionItem[]} options - The commands, as JSON.
|
|
26
|
+
* @attr {string} label - The accessible name. Defaults to `Commands`.
|
|
27
|
+
* @attr {string} placeholder - The search placeholder. Defaults to `Search commands`.
|
|
28
|
+
* @attr {string} shortcut - The key that opens it with Cmd or Ctrl, such as `mod+k`, or `none`. Defaults to `mod+k`.
|
|
29
|
+
* @attr {boolean} open - Whether it shows.
|
|
30
|
+
*
|
|
31
|
+
* @fires pick - When a command is picked, with its `value` in `detail`.
|
|
32
|
+
*/
|
|
33
|
+
export class GwCommandMenu extends GwElement {
|
|
34
|
+
static override properties = {
|
|
35
|
+
options: { type: "json" },
|
|
36
|
+
label: {},
|
|
37
|
+
placeholder: {},
|
|
38
|
+
shortcut: {},
|
|
39
|
+
open: { type: "boolean", reflect: true },
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
static override styles = [
|
|
43
|
+
base,
|
|
44
|
+
css`
|
|
45
|
+
dialog {
|
|
46
|
+
top: 12vh;
|
|
47
|
+
width: min(36rem, calc(100vw - 2 * var(--gw-space-3)));
|
|
48
|
+
max-height: 70vh;
|
|
49
|
+
padding: 0;
|
|
50
|
+
margin-block: 0;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
color: var(--gw-color-text-primary);
|
|
53
|
+
background: var(--gw-color-surface-overlay);
|
|
54
|
+
border: max(var(--gw-border-width), 1px) solid
|
|
55
|
+
var(--gw-color-border-normal);
|
|
56
|
+
border-radius: var(--gw-radius-panel);
|
|
57
|
+
box-shadow: var(--gw-shadow-l);
|
|
58
|
+
--gw-option-list-height: min(24rem, 56vh);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
dialog::backdrop {
|
|
62
|
+
background: rgb(0 0 0 / 50%);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
dialog,
|
|
66
|
+
dialog::backdrop {
|
|
67
|
+
opacity: 0;
|
|
68
|
+
transition:
|
|
69
|
+
opacity var(--gw-motion-normal) var(--gw-motion-easing),
|
|
70
|
+
scale var(--gw-motion-normal) var(--gw-motion-easing),
|
|
71
|
+
translate var(--gw-motion-normal) var(--gw-motion-easing),
|
|
72
|
+
display var(--gw-motion-normal) allow-discrete,
|
|
73
|
+
overlay var(--gw-motion-normal) allow-discrete;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
dialog {
|
|
77
|
+
scale: 0.96;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
dialog[open],
|
|
81
|
+
dialog[open]::backdrop {
|
|
82
|
+
opacity: 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
dialog[open] {
|
|
86
|
+
scale: 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@starting-style {
|
|
90
|
+
dialog[open],
|
|
91
|
+
dialog[open]::backdrop {
|
|
92
|
+
opacity: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
dialog[open] {
|
|
96
|
+
scale: 0.96;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
dialog[data-placement="sheet"] {
|
|
101
|
+
inset: auto 0 0;
|
|
102
|
+
width: 100%;
|
|
103
|
+
max-width: none;
|
|
104
|
+
padding-block-end: env(safe-area-inset-bottom);
|
|
105
|
+
border-end-start-radius: 0;
|
|
106
|
+
border-end-end-radius: 0;
|
|
107
|
+
scale: 1;
|
|
108
|
+
translate: 0 100%;
|
|
109
|
+
--gw-option-list-height: 60vh;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dialog[data-placement="sheet"][open] {
|
|
113
|
+
opacity: 1;
|
|
114
|
+
translate: 0 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@starting-style {
|
|
118
|
+
dialog[data-placement="sheet"][open] {
|
|
119
|
+
translate: 0 100%;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
footer {
|
|
124
|
+
display: flex;
|
|
125
|
+
gap: var(--gw-space-3);
|
|
126
|
+
padding: var(--gw-space-2) var(--gw-space-3);
|
|
127
|
+
font-size: var(--gw-font-size-xs);
|
|
128
|
+
color: var(--gw-color-text-secondary);
|
|
129
|
+
border-block-start: var(--gw-border-width) solid
|
|
130
|
+
var(--gw-color-border-quiet);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
kbd {
|
|
134
|
+
padding: 0 var(--gw-space-1);
|
|
135
|
+
font: inherit;
|
|
136
|
+
border: 1px solid var(--gw-color-border-normal);
|
|
137
|
+
border-radius: var(--gw-radius-control);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
dialog[data-placement="sheet"] footer {
|
|
141
|
+
display: none;
|
|
142
|
+
}
|
|
143
|
+
`,
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
declare options: readonly OptionItem[];
|
|
147
|
+
declare label: string;
|
|
148
|
+
declare placeholder: string;
|
|
149
|
+
/** `mod+k` (the default) opens it on Cmd+K or Ctrl+K; `none` turns that off. */
|
|
150
|
+
declare shortcut: string;
|
|
151
|
+
declare open: boolean;
|
|
152
|
+
|
|
153
|
+
readonly #list = h("gw-option-list", { search: true });
|
|
154
|
+
|
|
155
|
+
readonly #dialog = h(
|
|
156
|
+
"dialog",
|
|
157
|
+
{},
|
|
158
|
+
this.#list,
|
|
159
|
+
h(
|
|
160
|
+
"footer",
|
|
161
|
+
{ "aria-hidden": "true" },
|
|
162
|
+
h("span", {}, h("kbd", {}, "↑"), " ", h("kbd", {}, "↓"), " to move"),
|
|
163
|
+
h("span", {}, h("kbd", {}, "Enter"), " to pick"),
|
|
164
|
+
h("span", {}, h("kbd", {}, "Esc"), " to close"),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
constructor() {
|
|
169
|
+
super();
|
|
170
|
+
this.options = [];
|
|
171
|
+
this.label = "Commands";
|
|
172
|
+
this.placeholder = "Search commands";
|
|
173
|
+
this.shortcut = "mod+k";
|
|
174
|
+
this.open = false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
show() {
|
|
178
|
+
this.open = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
close() {
|
|
182
|
+
this.open = false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
readonly #onShortcut = (event: KeyboardEvent) => {
|
|
186
|
+
if (
|
|
187
|
+
this.shortcut === "none" ||
|
|
188
|
+
event.defaultPrevented ||
|
|
189
|
+
event.key.toLowerCase() !== "k" ||
|
|
190
|
+
!(event.metaKey || event.ctrlKey) ||
|
|
191
|
+
event.altKey ||
|
|
192
|
+
event.shiftKey
|
|
193
|
+
) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
|
|
199
|
+
if (this.open) {
|
|
200
|
+
this.close();
|
|
201
|
+
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// One overlay at a time: another open dialog keeps the page
|
|
206
|
+
if (
|
|
207
|
+
document.querySelector(
|
|
208
|
+
"dialog[open], gw-dialog[open], gw-command-menu[open]",
|
|
209
|
+
)
|
|
210
|
+
) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.show();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
override connectedCallback() {
|
|
218
|
+
super.connectedCallback();
|
|
219
|
+
document.addEventListener("keydown", this.#onShortcut);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
override disconnectedCallback() {
|
|
223
|
+
document.removeEventListener("keydown", this.#onShortcut);
|
|
224
|
+
super.disconnectedCallback();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
protected override create(root: ShadowRoot) {
|
|
228
|
+
this.#dialog.addEventListener("close", () => {
|
|
229
|
+
this.open = false;
|
|
230
|
+
});
|
|
231
|
+
this.#dialog.addEventListener("click", (event) => {
|
|
232
|
+
if (event.target === this.#dialog) this.close();
|
|
233
|
+
});
|
|
234
|
+
this.#list.addEventListener("pick", (event) => {
|
|
235
|
+
event.stopPropagation();
|
|
236
|
+
|
|
237
|
+
const value =
|
|
238
|
+
event instanceof CustomEvent &&
|
|
239
|
+
event.detail !== null &&
|
|
240
|
+
"value" in event.detail
|
|
241
|
+
? String(event.detail.value)
|
|
242
|
+
: "";
|
|
243
|
+
|
|
244
|
+
this.close();
|
|
245
|
+
this.dispatchEvent(
|
|
246
|
+
new CustomEvent("pick", {
|
|
247
|
+
detail: { value },
|
|
248
|
+
bubbles: true,
|
|
249
|
+
composed: true,
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
root.append(this.#dialog);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
protected override update() {
|
|
257
|
+
const list = this.#list;
|
|
258
|
+
const dialog = this.#dialog;
|
|
259
|
+
|
|
260
|
+
list.options = Array.isArray(this.options) ? this.options : [];
|
|
261
|
+
list.label = this.label;
|
|
262
|
+
list.placeholder = this.placeholder;
|
|
263
|
+
dialog.setAttribute("aria-label", this.label);
|
|
264
|
+
|
|
265
|
+
if (this.open && !dialog.open) {
|
|
266
|
+
dialog.dataset.placement =
|
|
267
|
+
window.innerWidth < sheetBelow ? "sheet" : "center";
|
|
268
|
+
dialog.showModal();
|
|
269
|
+
list.reset();
|
|
270
|
+
document.dispatchEvent(new Event("gw-dialog-open"));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!this.open && dialog.open) dialog.close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
GwCommandMenu.define("gw-command-menu");
|
|
278
|
+
|
|
279
|
+
declare global {
|
|
280
|
+
interface HTMLElementTagNameMap {
|
|
281
|
+
"gw-command-menu": GwCommandMenu;
|
|
282
|
+
}
|
|
283
|
+
}
|