@timmo001/groundwork-ui 0.6.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-ui",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Groundwork's framework-free web components, styled by the --gw-* tokens",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/fields.ts CHANGED
@@ -70,12 +70,37 @@ export const fieldStyles = css`
70
70
  fill: currentColor;
71
71
  }
72
72
 
73
+ /* Sizes run xs to xl through --gw-field-scale, around 1 for medium, and
74
+ the control's height follows the theme's density too */
75
+ :where(gw-input, gw-select)[size="xs"] {
76
+ --gw-field-scale: 0.6;
77
+ }
78
+
79
+ :where(gw-input, gw-select)[size="small"] {
80
+ --gw-field-scale: 0.8;
81
+ }
82
+
83
+ :where(gw-input, gw-select)[size="large"] {
84
+ --gw-field-scale: 1.2;
85
+ }
86
+
87
+ :where(gw-input, gw-select)[size="xl"] {
88
+ --gw-field-scale: 1.4;
89
+ }
90
+
73
91
  :where(gw-input, gw-select) :where(input, .gw-select-trigger) {
92
+ --gw-field-size: var(--gw-field-scale, 1);
93
+ --gw-icon-size: calc(
94
+ 1.25rem * var(--gw-field-size) * var(--gw-control-scale, 1)
95
+ );
96
+
74
97
  width: 100%;
75
- min-height: 2.5rem;
76
- padding: 0 var(--gw-space-3);
98
+ min-height: calc(
99
+ 2.5rem * var(--gw-field-size) * var(--gw-control-scale, 1)
100
+ );
101
+ padding: 0 calc(var(--gw-space-3) * var(--gw-field-size));
77
102
  font: inherit;
78
- font-size: var(--gw-font-size-s);
103
+ font-size: calc(var(--gw-font-size-s) * var(--gw-field-size));
79
104
  color: var(--gw-color-text-strong);
80
105
  background: var(--gw-color-surface-lower);
81
106
  border: var(--gw-border-width) solid var(--gw-color-border-normal);
@@ -87,9 +112,9 @@ export const fieldStyles = css`
87
112
  room around it that turns over while the list is open */
88
113
  gw-select .gw-select-trigger {
89
114
  display: flex;
90
- gap: var(--gw-space-2);
115
+ gap: calc(var(--gw-space-2) * var(--gw-field-size));
91
116
  align-items: center;
92
- padding-inline-end: var(--gw-space-3);
117
+ padding-inline-end: calc(var(--gw-space-3) * var(--gw-field-size));
93
118
  text-align: start;
94
119
  cursor: pointer;
95
120
  }
@@ -110,7 +135,8 @@ export const fieldStyles = css`
110
135
 
111
136
  gw-select[inline-label] .gw-select-trigger {
112
137
  width: auto;
113
- padding-inline: var(--gw-space-4) var(--gw-space-3);
138
+ padding-inline: calc(var(--gw-space-4) * var(--gw-field-size))
139
+ calc(var(--gw-space-3) * var(--gw-field-size));
114
140
  border-radius: var(--gw-radius-pill);
115
141
  }
116
142
 
package/src/gw-badge.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  import { GwElement, css, h, setAttribute } from "./element.js";
8
8
  import "./gw-skeleton.js";
9
9
  import { fadeIn } from "./motion.js";
10
- import { base, createIcon, setIconPath, type Tone } from "./styles.js";
10
+ import {
11
+ base,
12
+ controlSizes,
13
+ createIcon,
14
+ setIconPath,
15
+ type Tone,
16
+ } from "./styles.js";
11
17
 
12
18
  /**
13
19
  * A pill with an icon, a value and an optional small label above it. The
@@ -21,7 +27,7 @@ import { base, createIcon, setIconPath, type Tone } from "./styles.js";
21
27
  * @attr {string} icon - SVG path data for the icon.
22
28
  * @attr {string} label - A small label above the value.
23
29
  * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The icon's color. Defaults to `accent`.
24
- * @attr {"small"|"medium"} size - The size. Defaults to `medium`.
30
+ * @attr {"xs"|"small"|"medium"|"large"|"xl"} size - The size, around 36px tall at medium. Defaults to `medium`.
25
31
  * @attr {boolean} loading - Shows a skeleton for the value, and the icon if it has none.
26
32
  *
27
33
  * @slot - The value.
@@ -31,6 +37,8 @@ import { base, createIcon, setIconPath, type Tone } from "./styles.js";
31
37
  * @csspart value - The value.
32
38
  *
33
39
  * @cssprop --gw-tone - The icon's color, for a tone of your own. `tone` sets it.
40
+ * @cssprop --gw-badge-scale - Scales the badge, around 1 for medium.
41
+ * @cssprop --gw-control-scale - Scales its height and icon with the theme's density. The theme sets it.
34
42
  */
35
43
  export class GwBadge extends GwElement {
36
44
  static override properties = {
@@ -43,6 +51,7 @@ export class GwBadge extends GwElement {
43
51
 
44
52
  static override styles = [
45
53
  base,
54
+ controlSizes("badge"),
46
55
  css`
47
56
  :host {
48
57
  display: inline-flex;
@@ -50,17 +59,21 @@ export class GwBadge extends GwElement {
50
59
  vertical-align: middle;
51
60
  }
52
61
 
53
- /* 36px tall, 12px padding, 8px between the 18px icon and the text */
62
+ /* 36px tall at medium, 12px padding, 8px between the 18px icon and
63
+ the text */
54
64
  .badge {
55
- --gw-icon-size: 1.125rem;
65
+ --gw-size: var(--gw-badge-scale, 1);
66
+ --gw-scale: calc(var(--gw-size) * var(--gw-control-scale, 1));
67
+ --gw-icon-size: calc(1.125rem * var(--gw-scale));
56
68
 
57
69
  display: inline-flex;
58
- gap: var(--gw-space-2);
70
+ gap: calc(var(--gw-space-2) * var(--gw-size));
59
71
  align-items: center;
60
72
  box-sizing: border-box;
61
73
  max-width: 100%;
62
- height: 2.25rem;
63
- padding: 0 var(--gw-space-4) 0 var(--gw-space-3);
74
+ height: calc(2.25rem * var(--gw-scale));
75
+ padding: 0 calc(var(--gw-space-4) * var(--gw-size)) 0
76
+ calc(var(--gw-space-3) * var(--gw-size));
64
77
  vertical-align: middle;
65
78
  color: var(--gw-color-text-strong);
66
79
  background: var(--gw-color-surface-raised);
@@ -68,14 +81,6 @@ export class GwBadge extends GwElement {
68
81
  border-radius: var(--gw-radius-pill);
69
82
  }
70
83
 
71
- :host([size="small"]) .badge {
72
- --gw-icon-size: 1rem;
73
-
74
- gap: var(--gw-space-1);
75
- height: 1.75rem;
76
- padding: 0 var(--gw-space-3) 0 var(--gw-space-2);
77
- }
78
-
79
84
  .icon {
80
85
  color: var(--gw-tone, var(--gw-color-accent));
81
86
  }
@@ -87,13 +92,19 @@ export class GwBadge extends GwElement {
87
92
  }
88
93
 
89
94
  .label {
90
- font-size: var(--gw-font-size-2xs);
95
+ font-size: max(
96
+ var(--gw-font-size-2xs),
97
+ calc(var(--gw-font-size-2xs) * var(--gw-size))
98
+ );
91
99
  line-height: 1.1;
92
100
  color: var(--gw-color-text-secondary);
93
101
  }
94
102
 
95
103
  .content {
96
- font-size: var(--gw-font-size-xs);
104
+ font-size: max(
105
+ var(--gw-font-size-2xs),
106
+ calc(var(--gw-font-size-xs) * var(--gw-size))
107
+ );
97
108
  font-weight: var(--gw-font-weight-strong);
98
109
  line-height: 1.2;
99
110
  white-space: nowrap;
@@ -108,7 +119,7 @@ export class GwBadge extends GwElement {
108
119
  declare icon: string;
109
120
  declare label: string;
110
121
  declare tone: Tone;
111
- declare size: "small" | "medium";
122
+ declare size: "xs" | "small" | "medium" | "large" | "xl";
112
123
  declare loading: boolean;
113
124
 
114
125
  readonly #badge = h("span", { class: "badge", part: "badge" });
package/src/gw-button.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * @module
6
6
  */
7
7
  import { GwElement, css, h, setAttribute } from "./element.js";
8
- import { base, createIcon, setIconPath } from "./styles.js";
8
+ import { base, controlSizes, createIcon, setIconPath } from "./styles.js";
9
9
 
10
10
  /**
11
11
  * A native button styled through `--gw-*` variables, so it follows the
@@ -38,6 +38,7 @@ import { base, createIcon, setIconPath } from "./styles.js";
38
38
  * @attr {boolean} disabled - Disables the button.
39
39
  * @attr {boolean} loading - Shows a spinning ring in the icon's place and ignores clicks.
40
40
  * @attr {string} icon - SVG path data for an icon before the text.
41
+ * @attr {string} swatch - A CSS color, drawn as a small round dot in the icon's place, such as a tag's own color.
41
42
  * @attr {"show"|"hide"|"auto"} labels - Whether the text shows beside an icon: always, never, or not on narrow screens. Defaults to `show`.
42
43
  *
43
44
  * @slot - The text, or an `<a>` holding it, in place of `href`.
@@ -46,6 +47,7 @@ import { base, createIcon, setIconPath } from "./styles.js";
46
47
  * @csspart button - The native button or link, or the pill around a slotted link.
47
48
  *
48
49
  * @cssprop --gw-button-scale - Scales every dimension, around 1 for medium.
50
+ * @cssprop --gw-control-scale - Scales its height and icon with the theme's density. The theme sets it.
49
51
  * @cssprop --gw-button-tone - The color, set by `variant`.
50
52
  * @cssprop --gw-button-ink - The text color, when it differs from the tone.
51
53
  */
@@ -60,6 +62,7 @@ export class GwButton extends GwElement {
60
62
  disabled: { type: "boolean", reflect: true },
61
63
  loading: { type: "boolean", reflect: true },
62
64
  icon: {},
65
+ swatch: {},
63
66
  labels: { reflect: true },
64
67
  } as const;
65
68
 
@@ -71,6 +74,7 @@ export class GwButton extends GwElement {
71
74
 
72
75
  static override styles = [
73
76
  base,
77
+ controlSizes("button"),
74
78
  css`
75
79
  :host {
76
80
  --gw-button-tone: var(--gw-color-accent);
@@ -78,25 +82,6 @@ export class GwButton extends GwElement {
78
82
  display: inline-block;
79
83
  }
80
84
 
81
- /* Everything scales from --gw-button-scale, around a 40px medium
82
- button: the presets give 24, 32, 48 and 56px, and an app can set any
83
- value for a size between or beyond them */
84
- :host([size="xs"]) {
85
- --gw-button-scale: 0.6;
86
- }
87
-
88
- :host([size="small"]) {
89
- --gw-button-scale: 0.8;
90
- }
91
-
92
- :host([size="large"]) {
93
- --gw-button-scale: 1.2;
94
- }
95
-
96
- :host([size="xl"]) {
97
- --gw-button-scale: 1.4;
98
- }
99
-
100
85
  :host([variant="neutral"]) {
101
86
  --gw-button-tone: var(--gw-color-text-secondary);
102
87
  --gw-button-ink: var(--gw-color-text-strong);
@@ -119,18 +104,19 @@ export class GwButton extends GwElement {
119
104
  }
120
105
 
121
106
  .button {
122
- --gw-scale: var(--gw-button-scale, 1);
107
+ --gw-size: var(--gw-button-scale, 1);
108
+ --gw-scale: calc(var(--gw-size) * var(--gw-control-scale, 1));
123
109
  --gw-icon-size: calc(1.125rem * var(--gw-scale));
124
110
 
125
111
  position: relative;
126
112
  display: inline-flex;
127
- gap: calc(var(--gw-space-2) * var(--gw-scale));
113
+ gap: calc(var(--gw-space-2) * var(--gw-size));
128
114
  align-items: center;
129
115
  justify-content: center;
130
116
  min-height: calc(2.5rem * var(--gw-scale));
131
- padding: 0 calc(var(--gw-space-4) * var(--gw-scale));
117
+ padding: 0 calc(var(--gw-space-4) * var(--gw-size));
132
118
  font: inherit;
133
- font-size: calc(var(--gw-font-size-s) * var(--gw-scale));
119
+ font-size: calc(var(--gw-font-size-s) * var(--gw-size));
134
120
  font-weight: var(--gw-font-weight-strong);
135
121
  line-height: 1;
136
122
  color: var(--gw-button-ink, var(--gw-button-tone));
@@ -258,10 +244,22 @@ export class GwButton extends GwElement {
258
244
  place-items: center;
259
245
  }
260
246
 
261
- .lead > * {
247
+ /* The icon slot is display: contents, so its slotted icon is a grid
248
+ item of its own and needs the shared cell too */
249
+ .lead > *,
250
+ ::slotted([slot="icon"]) {
262
251
  grid-area: 1 / 1;
263
252
  }
264
253
 
254
+ /* A color dot in the icon's place */
255
+ .swatch {
256
+ width: calc(var(--gw-icon-size) * 0.6);
257
+ height: calc(var(--gw-icon-size) * 0.6);
258
+ border: 1px solid
259
+ color-mix(in oklab, var(--gw-color-text-primary) 20%, transparent);
260
+ border-radius: 50%;
261
+ }
262
+
265
263
  .lead > *,
266
264
  .text,
267
265
  .ring {
@@ -357,6 +355,8 @@ export class GwButton extends GwElement {
357
355
  declare loading: boolean;
358
356
  /** SVG path data for an icon before the text. */
359
357
  declare icon: string;
358
+ /** A CSS color, drawn as a small round dot in the icon's place. */
359
+ declare swatch: string;
360
360
  /** Whether the text shows beside an icon: always, never, or not on narrow screens. */
361
361
  declare labels: "show" | "hide" | "auto";
362
362
 
@@ -364,10 +364,13 @@ export class GwButton extends GwElement {
364
364
 
365
365
  readonly #iconSlot = h("slot", { name: "icon" });
366
366
 
367
+ readonly #swatch = h("span", { class: "swatch", "aria-hidden": "true" });
368
+
367
369
  readonly #lead = h(
368
370
  "span",
369
371
  { class: "lead" },
370
372
  this.#icon,
373
+ this.#swatch,
371
374
  this.#iconSlot,
372
375
  h("span", { class: "ring" }),
373
376
  );
@@ -392,6 +395,7 @@ export class GwButton extends GwElement {
392
395
  constructor() {
393
396
  super();
394
397
  this.icon = "";
398
+ this.swatch = "";
395
399
  this.labels = "show";
396
400
  this.variant = "brand";
397
401
  this.appearance = "accent";
@@ -472,7 +476,9 @@ export class GwButton extends GwElement {
472
476
  const control = this.#control;
473
477
 
474
478
  const hasIcon =
475
- this.icon !== "" || this.querySelector("[slot='icon']") !== null;
479
+ this.icon !== "" ||
480
+ this.swatch !== "" ||
481
+ this.querySelector("[slot='icon']") !== null;
476
482
 
477
483
  control.classList.toggle("has-icon", hasIcon);
478
484
  control.classList.toggle(
@@ -482,6 +488,8 @@ export class GwButton extends GwElement {
482
488
  );
483
489
  setIconPath(this.#icon, this.icon);
484
490
  this.#icon.style.display = this.icon ? "" : "none";
491
+ this.#swatch.style.background = this.swatch;
492
+ this.#swatch.hidden = this.swatch === "" || this.icon !== "";
485
493
  setAttribute(
486
494
  control,
487
495
  "title",
package/src/gw-card.ts CHANGED
@@ -15,6 +15,12 @@ import { base } from "./styles.js";
15
15
  * stand in for the content, and for the heading until it's known. The card
16
16
  * glides to its new height and the content fades in once it's loaded.
17
17
  *
18
+ * For a link the page's HTML has to carry, such as one with a `target` or
19
+ * `rel`, put an `<a>` in the `heading` slot instead of `href`. A click
20
+ * anywhere on the card that isn't on another control follows it, the card
21
+ * shows the link's hover and focus, and the link stays a real one in the
22
+ * page, so the rest of the card's content stays plain text.
23
+ *
18
24
  * @element gw-card
19
25
  * @summary A surface for one group of content, or a link to more.
20
26
  *
@@ -25,6 +31,7 @@ import { base } from "./styles.js";
25
31
  * @attr {number} lines - How many skeleton lines while loading. Defaults to 3.
26
32
  *
27
33
  * @slot - The content.
34
+ * @slot heading - The heading, in place of `heading`. An `<a>` here makes the whole card follow it.
28
35
  * @slot actions - Buttons beside the heading.
29
36
  *
30
37
  * @csspart card - The card's surface.
@@ -71,10 +78,32 @@ export class GwCard extends GwElement {
71
78
  transition: border-color var(--gw-motion-fast) var(--gw-motion-easing);
72
79
  }
73
80
 
74
- a.card:hover {
81
+ a.card:hover,
82
+ .card.linked:hover {
75
83
  border-color: var(--gw-color-border-accent);
76
84
  }
77
85
 
86
+ /* A card following a link in its heading slot */
87
+ .card.linked {
88
+ cursor: pointer;
89
+ transition: border-color var(--gw-motion-fast) var(--gw-motion-easing);
90
+ }
91
+
92
+ .card.focused {
93
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
94
+ outline-offset: var(--gw-focus-offset);
95
+ }
96
+
97
+ ::slotted([slot="heading"]) {
98
+ font: inherit;
99
+ color: inherit;
100
+ }
101
+
102
+ ::slotted(a[slot="heading"]) {
103
+ text-decoration: none;
104
+ outline: none;
105
+ }
106
+
78
107
  header {
79
108
  display: flex;
80
109
  gap: var(--gw-space-2);
@@ -108,10 +137,18 @@ export class GwCard extends GwElement {
108
137
 
109
138
  readonly #titleSkeleton = h("gw-skeleton-text");
110
139
 
140
+ readonly #headingSlot = h("slot", { name: "heading" });
141
+
111
142
  readonly #header = h(
112
143
  "header",
113
144
  { part: "header" },
114
- h("h2", { part: "heading" }, this.#title, this.#titleSkeleton),
145
+ h(
146
+ "h2",
147
+ { part: "heading" },
148
+ this.#title,
149
+ this.#headingSlot,
150
+ this.#titleSkeleton,
151
+ ),
115
152
  h("slot", { name: "actions" }),
116
153
  );
117
154
 
@@ -132,6 +169,57 @@ export class GwCard extends GwElement {
132
169
  this.flush = false;
133
170
  this.loading = false;
134
171
  this.lines = 3;
172
+
173
+ // A click on the card, but not on another control in it, follows the
174
+ // link in the heading slot
175
+ this.addEventListener("click", (event) => {
176
+ const link = this.#link();
177
+
178
+ if (link === null || this.loading) return;
179
+
180
+ const path = event.composedPath();
181
+ const own = path.slice(0, path.indexOf(this));
182
+
183
+ if (
184
+ own.some(
185
+ (node) =>
186
+ node instanceof HTMLElement &&
187
+ node.matches(
188
+ "a, button, input, select, textarea, label, [tabindex], gw-button",
189
+ ),
190
+ )
191
+ ) {
192
+ return;
193
+ }
194
+
195
+ // Selecting text in the card shouldn't open the link
196
+ if (getSelection()?.toString()) return;
197
+
198
+ link.click();
199
+ });
200
+
201
+ const focus = () => {
202
+ this.#card.classList.toggle(
203
+ "focused",
204
+ this.#link()?.matches(":focus-visible") === true,
205
+ );
206
+ };
207
+
208
+ this.addEventListener("focusin", focus);
209
+ this.addEventListener("focusout", focus);
210
+
211
+ this.#headingSlot.addEventListener("slotchange", () => {
212
+ this.requestUpdate("heading");
213
+ });
214
+ }
215
+
216
+ /** The `<a>` in the heading slot, or holding it, when there is one. */
217
+ #link(): HTMLAnchorElement | null {
218
+ const heading = this.querySelector(":scope > [slot='heading']");
219
+
220
+ if (heading instanceof HTMLAnchorElement) return heading;
221
+
222
+ return heading?.querySelector("a") ?? null;
135
223
  }
136
224
 
137
225
  protected override create(root: ShadowRoot) {
@@ -163,9 +251,14 @@ export class GwCard extends GwElement {
163
251
  if (this.#card instanceof HTMLAnchorElement) this.#card.href = this.href;
164
252
 
165
253
  setAttribute(this.#card, "aria-busy", this.loading ? "true" : null);
254
+
255
+ const slotted = this.querySelector(":scope > [slot='heading']") !== null;
256
+
257
+ this.#card.classList.toggle("linked", this.#link() !== null && !this.href);
166
258
  this.#title.textContent = this.heading;
167
- this.#titleSkeleton.hidden = this.heading !== "" || !this.loading;
168
- this.#header.hidden = !this.heading && !this.loading;
259
+ this.#titleSkeleton.hidden =
260
+ this.heading !== "" || slotted || !this.loading;
261
+ this.#header.hidden = !this.heading && !slotted && !this.loading;
169
262
  this.#skeleton.lines = this.lines;
170
263
  this.#skeleton.hidden = !this.loading;
171
264
  this.#body.hidden = this.loading;
package/src/gw-chip.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  import { GwElement, css, h, setAttribute } from "./element.js";
8
8
  import "./gw-skeleton.js";
9
9
  import { fadeIn } from "./motion.js";
10
- import { base, createIcon, setIconPath, type Tone } from "./styles.js";
10
+ import {
11
+ base,
12
+ controlSizes,
13
+ createIcon,
14
+ setIconPath,
15
+ type Tone,
16
+ } from "./styles.js";
11
17
 
12
18
  /**
13
19
  * A short status or category, tinted by its `tone`. While `loading`, a
@@ -18,7 +24,9 @@ import { base, createIcon, setIconPath, type Tone } from "./styles.js";
18
24
  *
19
25
  * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The tint. Defaults to `neutral`.
20
26
  * @attr {string} icon - SVG path data for an icon before the text.
27
+ * @attr {string} swatch - A CSS color, drawn as a small round dot in the icon's place, such as a tag's own color.
21
28
  * @attr {boolean} loading - Shows a skeleton for the text.
29
+ * @attr {"xs"|"small"|"medium"|"large"|"xl"} size - The size, around 32px tall at medium. Defaults to `medium`.
22
30
  *
23
31
  * @slot - The text.
24
32
  * @slot icon - An icon before the text, in place of `icon`.
@@ -27,37 +35,48 @@ import { base, createIcon, setIconPath, type Tone } from "./styles.js";
27
35
  *
28
36
  * @cssprop --gw-tone-fill - The tint, for a tone of your own. `tone` sets it.
29
37
  * @cssprop --gw-tone-border - The border. `tone` sets it.
38
+ * @cssprop --gw-chip-scale - Scales the chip, around 1 for medium.
39
+ * @cssprop --gw-control-scale - Scales its height and icon with the theme's density. The theme sets it.
30
40
  */
31
41
  export class GwChip extends GwElement {
32
42
  static override properties = {
33
43
  tone: { reflect: true },
34
44
  icon: {},
45
+ swatch: {},
35
46
  loading: { type: "boolean", reflect: true },
47
+ size: { reflect: true },
36
48
  } as const;
37
49
 
38
50
  static override styles = [
39
51
  base,
52
+ controlSizes("chip"),
40
53
  css`
41
54
  :host {
42
55
  display: inline-flex;
43
56
  }
44
57
 
45
- /* 32px, with 12px padding */
58
+ /* 32px at medium, with 12px padding */
46
59
  .chip {
60
+ --gw-size: var(--gw-chip-scale, 1);
61
+ --gw-scale: calc(var(--gw-size) * var(--gw-control-scale, 1));
62
+ --gw-icon-size: calc(1rem * var(--gw-scale));
63
+
47
64
  display: inline-flex;
48
- gap: var(--gw-space-2);
65
+ gap: calc(var(--gw-space-2) * var(--gw-size));
49
66
  align-items: center;
50
67
  box-sizing: border-box;
51
- height: 2rem;
52
- padding: 0 var(--gw-space-3);
53
- font-size: var(--gw-font-size-xs);
68
+ height: calc(2rem * var(--gw-scale));
69
+ padding: 0 calc(var(--gw-space-3) * var(--gw-size));
70
+ font-size: max(
71
+ var(--gw-font-size-2xs),
72
+ calc(var(--gw-font-size-xs) * var(--gw-size))
73
+ );
54
74
  font-weight: var(--gw-font-weight-strong);
55
75
  color: var(--gw-color-text-strong);
56
76
  background: var(--gw-tone-fill, var(--gw-color-fill-neutral-resting));
57
77
  border: var(--gw-border-width) solid
58
78
  var(--gw-tone-border, var(--gw-color-border-quiet));
59
79
  border-radius: var(--gw-radius-chip);
60
- --gw-icon-size: 1rem;
61
80
  }
62
81
 
63
82
  .content {
@@ -65,16 +84,31 @@ export class GwChip extends GwElement {
65
84
  gap: inherit;
66
85
  align-items: center;
67
86
  }
87
+
88
+ /* A color dot in the icon's place */
89
+ .swatch {
90
+ flex: none;
91
+ width: calc(var(--gw-icon-size) * 0.6);
92
+ height: calc(var(--gw-icon-size) * 0.6);
93
+ border: 1px solid
94
+ color-mix(in oklab, var(--gw-color-text-primary) 20%, transparent);
95
+ border-radius: 50%;
96
+ }
68
97
  `,
69
98
  ];
70
99
 
71
100
  declare tone: Tone;
72
101
  /** SVG path data for an icon before the text, instead of the `icon` slot. */
73
102
  declare icon: string;
103
+ /** A CSS color, drawn as a small round dot in the icon's place. */
104
+ declare swatch: string;
74
105
  declare loading: boolean;
106
+ declare size: "xs" | "small" | "medium" | "large" | "xl";
75
107
 
76
108
  readonly #icon = createIcon("");
77
109
 
110
+ readonly #swatch = h("span", { class: "swatch", "aria-hidden": "true" });
111
+
78
112
  readonly #chip = h("span", { class: "chip", part: "chip" });
79
113
 
80
114
  readonly #skeleton = h("gw-skeleton-text", {
@@ -84,7 +118,7 @@ export class GwChip extends GwElement {
84
118
  readonly #content = h(
85
119
  "span",
86
120
  { class: "content" },
87
- h("slot", { name: "icon" }, this.#icon),
121
+ h("slot", { name: "icon" }, this.#icon, this.#swatch),
88
122
  h("slot"),
89
123
  );
90
124
 
@@ -92,7 +126,9 @@ export class GwChip extends GwElement {
92
126
  super();
93
127
  this.tone = "neutral";
94
128
  this.icon = "";
129
+ this.swatch = "";
95
130
  this.loading = false;
131
+ this.size = "medium";
96
132
  }
97
133
 
98
134
  protected override create(root: ShadowRoot) {
@@ -105,6 +141,8 @@ export class GwChip extends GwElement {
105
141
 
106
142
  setIconPath(this.#icon, this.icon);
107
143
  this.#icon.toggleAttribute("hidden", !this.icon);
144
+ this.#swatch.style.background = this.swatch;
145
+ this.#swatch.hidden = this.swatch === "" || this.icon !== "";
108
146
 
109
147
  setAttribute(this.#chip, "aria-busy", this.loading ? "true" : null);
110
148
  this.#skeleton.hidden = !this.loading;
package/src/gw-input.ts CHANGED
@@ -25,6 +25,10 @@ import { LightElement, fieldId } from "./fields.js";
25
25
  * @attr {string} error - An error under the field, which marks it invalid.
26
26
  * @attr {boolean} required - Requires a value.
27
27
  * @attr {boolean} disabled - Disables the field.
28
+ * @attr {"xs"|"small"|"medium"|"large"|"xl"} size - The field's size, 40px tall at medium. Defaults to `medium`.
29
+ *
30
+ * @cssprop --gw-field-scale - Scales the field, around 1 for medium.
31
+ * @cssprop --gw-control-scale - Scales its height with the theme's density. The theme sets it.
28
32
  */
29
33
  export class GwInput extends LightElement {
30
34
  static override properties = {
@@ -39,6 +43,7 @@ export class GwInput extends LightElement {
39
43
  error: {},
40
44
  required: { type: "boolean" },
41
45
  disabled: { type: "boolean" },
46
+ size: { reflect: true },
42
47
  } as const;
43
48
 
44
49
  declare label: string;
@@ -53,6 +58,7 @@ export class GwInput extends LightElement {
53
58
  declare error: string;
54
59
  declare required: boolean;
55
60
  declare disabled: boolean;
61
+ declare size: "xs" | "small" | "medium" | "large" | "xl";
56
62
 
57
63
  readonly #id = fieldId();
58
64
 
@@ -79,6 +85,7 @@ export class GwInput extends LightElement {
79
85
  this.error = "";
80
86
  this.required = false;
81
87
  this.disabled = false;
88
+ this.size = "medium";
82
89
  }
83
90
 
84
91
  protected override create(root: HTMLElement) {