@threadlabs/looma 0.11.5 → 0.12.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": "@threadlabs/looma",
3
- "version": "0.11.5",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -182,31 +182,19 @@
182
182
  var(--ui-focus-halo);
183
183
  }
184
184
 
185
- /* Disabled drops the tone rather than the shape: every variant keeps its own structure, so a
186
- disabled outline is still an outline and a disabled ghost still has no edge. Intent goes — an
187
- unavailable action says "unavailable", not "unavailable, and destructive" — and so do the
188
- highlight and shadow, because nothing that cannot be pressed should look raised. */
185
+ /* Disabled keeps the shape and washes out the rest: every variant keeps its own structure, so a
186
+ disabled outline is still an outline and a disabled ghost still has no edge. One filter fades
187
+ fill, border, and text together (--ui-disabled-filter), and the theme decides which way is
188
+ toward the page. The highlight and shadow go, because nothing that cannot be pressed should
189
+ look raised. */
189
190
  :scope:is(:disabled, [aria-disabled="true"]) {
190
- /* Derived from the tone itself, not from a separate disabled palette: faded toward the
191
- page, which desaturates it at the same time. Toward the page rather than toward white,
192
- so a dark theme dims where a light one lightens — the same rule, both schemes. */
193
- --_tone: color-mix(in oklab, var(--_hue) 58%, var(--ui-surface));
194
- --_tone-hover: var(--_tone);
195
- --_tone-active: var(--_tone);
196
- --_tone-text: color-mix(in oklab, var(--_hue-text) 72%, var(--ui-surface));
197
- --_on-tone: var(--_tone-text);
198
-
191
+ filter: var(--ui-button-disabled-filter, var(--ui-disabled-filter));
199
192
  opacity: var(--ui-button-disabled-opacity, 1);
200
193
  cursor: not-allowed;
201
194
  background-image: none;
202
195
  box-shadow: none;
203
196
  }
204
197
 
205
- /* A filled button keeps its fill, faded further so its text stays legible on it. */
206
- :is(:scope[data-ui-button-state~="variant=solid"], :scope[data-ui-button-state~="variant=danger"]):is(:disabled, [aria-disabled="true"]) {
207
- --_tone: color-mix(in oklab, var(--_hue) 34%, var(--ui-surface));
208
- }
209
-
210
198
  /* Ghost has no hover to fall back on, so disabled is the one state it has to state outright:
211
199
  it takes a surface rather than reading as plain text. The surface is a wash of the faded tone,
212
200
  the way hover washes the live one, so it stays light in every tone. An opaque mix toward the
@@ -222,14 +210,15 @@
222
210
  }
223
211
  }
224
212
 
225
- /* Under touch, every button's press lands within the control minimum: an invisible hit area,
226
- centred on it, like IconButton's. A standalone link-style button (a "See all" beside a
227
- heading) is a target of its own, not text, so it gets one too. */
228
- html[data-ui-input-modality="touch"] :scope {
213
+ /* Under touch, a link-style button's press lands within the control minimum: an invisible hit
214
+ area, centred on it, like IconButton's. A boxed button grows to the minimum itself (below);
215
+ a link-style one keeps its text size, so a standalone one ("See all" under a list) needs the
216
+ area. Only the link needs it: a hit area on every button would reach over its neighbours. */
217
+ html[data-ui-input-modality="touch"] :scope[data-ui-button-state~="variant=link"] {
229
218
  position: relative;
230
219
  }
231
220
 
232
- html[data-ui-input-modality="touch"] :scope::after {
221
+ html[data-ui-input-modality="touch"] :scope[data-ui-button-state~="variant=link"]::after {
233
222
  position: absolute;
234
223
  inset: 50% auto auto 50%;
235
224
  inline-size: max(100%, var(--ui-control-min-block-size));
@@ -272,7 +261,6 @@
272
261
  :scope[data-ui-button-state~="variant=link"]:is(:disabled, [aria-disabled="true"]) {
273
262
  background: none;
274
263
  border-color: transparent;
275
- color: var(--ui-button-disabled-text, var(--ui-disabled-text));
276
264
  }
277
265
 
278
266
  :scope[data-ui-button-state~="variant=link"]:focus-visible {
@@ -303,12 +291,11 @@
303
291
 
304
292
  /* A soft halo that transitions in with the other box-shadow changes, layered on the raised shadow. */
305
293
 
306
- /* Disabled drops the tone rather than the shape: every variant keeps its own structure, so a
307
- disabled outline is still an outline and a disabled ghost still has no edge. Intent goes — an
308
- unavailable action says "unavailable", not "unavailable, and destructive" — and so do the
309
- highlight and shadow, because nothing that cannot be pressed should look raised. */
310
-
311
- /* A filled button keeps its fill, faded further so its text stays legible on it. */
294
+ /* Disabled keeps the shape and washes out the rest: every variant keeps its own structure, so a
295
+ disabled outline is still an outline and a disabled ghost still has no edge. One filter fades
296
+ fill, border, and text together (--ui-disabled-filter), and the theme decides which way is
297
+ toward the page. The highlight and shadow go, because nothing that cannot be pressed should
298
+ look raised. */
312
299
 
313
300
  /* Ghost has no hover to fall back on, so disabled is the one state it has to state outright:
314
301
  it takes a surface rather than reading as plain text. The surface is a wash of the faded tone,
@@ -318,9 +305,10 @@
318
305
  @media (pointer: coarse) {
319
306
  }
320
307
 
321
- /* Under touch, every button's press lands within the control minimum: an invisible hit area,
322
- centred on it, like IconButton's. A standalone link-style button (a "See all" beside a
323
- heading) is a target of its own, not text, so it gets one too. */
308
+ /* Under touch, a link-style button's press lands within the control minimum: an invisible hit
309
+ area, centred on it, like IconButton's. A boxed button grows to the minimum itself (below);
310
+ a link-style one keeps its text size, so a standalone one ("See all" under a list) needs the
311
+ area. Only the link needs it: a hit area on every button would reach over its neighbours. */
324
312
 
325
313
  /* Link: an inline text action. No box, padding, or minimum height (inline targets are exempt under
326
314
  WCAG 2.5.8); the inherited font; an underline that strengthens on hover; a visible focus ring. */
@@ -9,11 +9,29 @@
9
9
  cursor: pointer;
10
10
  }
11
11
 
12
+ /* The label, and under it an optional line saying what the choice means. */
13
+ .text {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: var(--ui-space-1);
17
+ min-inline-size: 0;
18
+ }
19
+
12
20
  .label {
13
21
  min-inline-size: 0;
14
22
  line-height: var(--ui-line-height-md);
15
23
  }
16
24
 
25
+ .description {
26
+ color: var(--ui-text-secondary);
27
+ font-size: var(--ui-font-size-sm);
28
+ line-height: var(--ui-line-height);
29
+ }
30
+
31
+ .description:empty {
32
+ display: none;
33
+ }
34
+
17
35
  input {
18
36
  appearance: none;
19
37
  -webkit-appearance: none;
@@ -95,7 +113,9 @@
95
113
  }
96
114
  }
97
115
  @scope ([data-component~="ui-checkbox"]) to ([data-component]) {
98
- /* Centre the tick's box, then lift it: a rotated L's ink sits ~0.11rem below its box centre. */
116
+ /* The label, and under it an optional line saying what the choice means. */
117
+
118
+ /* Centre the tick's box, then lift it: a rotated L's ink sits ~0.11rem below its box centre. */
99
119
 
100
120
  /* A component that sets its display still honours the hidden attribute on its root. */
101
121
  }
@@ -161,22 +161,12 @@
161
161
  }
162
162
 
163
163
  /* Disabled colours fall back to each variant's own; ghost takes only the icon colour. */
164
+ /* Disabled washes the button out the way Button does: the same shape, one filter. */
164
165
  :scope:disabled {
166
+ filter: var(--ui-icon-button-disabled-filter, var(--ui-disabled-filter));
165
167
  opacity: var(--ui-icon-button-disabled-opacity, 1);
166
- color: var(--ui-icon-button-disabled-text, var(--ui-disabled-text));
167
168
  cursor: not-allowed;
168
- }
169
-
170
- :scope[data-ui-icon-button-state~="variant=outline"]:disabled {
171
- border-color: var(--ui-icon-button-disabled-border, var(--ui-border));
172
- background-color: var(--ui-icon-button-disabled-surface, var(--ui-disabled-surface));
173
- color: var(--ui-icon-button-disabled-text, var(--ui-disabled-text));
174
- }
175
-
176
- :scope[data-ui-icon-button-state~="variant=solid"]:disabled {
177
- border-color: var(--ui-icon-button-disabled-border, var(--ui-border));
178
- background-color: var(--ui-icon-button-disabled-surface, var(--ui-disabled-surface));
179
- color: var(--ui-icon-button-disabled-text, var(--ui-disabled-text));
169
+ box-shadow: none;
180
170
  }
181
171
 
182
172
  :scope {
@@ -212,6 +202,7 @@
212
202
  }
213
203
 
214
204
  /* Disabled colours fall back to each variant's own; ghost takes only the icon colour. */
205
+ /* Disabled washes the button out the way Button does: the same shape, one filter. */
215
206
 
216
207
  :where([data-slotted], [data-slotted] *):is(svg) {
217
208
  inline-size: var(--ui-icon-button-icon-size, var(--_icon-button-icon-default));
@@ -9,11 +9,29 @@
9
9
  cursor: pointer;
10
10
  }
11
11
 
12
+ /* The label, and under it an optional line saying what the choice means. */
13
+ .text {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: var(--ui-space-1);
17
+ min-inline-size: 0;
18
+ }
19
+
12
20
  .label {
13
21
  min-inline-size: 0;
14
22
  line-height: var(--ui-line-height-md);
15
23
  }
16
24
 
25
+ .description {
26
+ color: var(--ui-text-secondary);
27
+ font-size: var(--ui-font-size-sm);
28
+ line-height: var(--ui-line-height);
29
+ }
30
+
31
+ .description:empty {
32
+ display: none;
33
+ }
34
+
17
35
  input {
18
36
  flex: 0 0 auto;
19
37
  inline-size: var(--ui-radio-size, 1.125rem);
@@ -42,5 +60,7 @@
42
60
  }
43
61
  }
44
62
  @scope ([data-component~="ui-radio"]) to ([data-component]) {
45
- /* A component that sets its display still honours the hidden attribute on its root. */
63
+ /* The label, and under it an optional line saying what the choice means. */
64
+
65
+ /* A component that sets its display still honours the hidden attribute on its root. */
46
66
  }
@@ -9,11 +9,29 @@
9
9
  cursor: pointer;
10
10
  }
11
11
 
12
+ /* The label, and under it an optional line saying what the choice means. */
13
+ .text {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: var(--ui-space-1);
17
+ min-inline-size: 0;
18
+ }
19
+
12
20
  .label {
13
21
  min-inline-size: 0;
14
22
  line-height: var(--ui-line-height-md);
15
23
  }
16
24
 
25
+ .description {
26
+ color: var(--ui-text-secondary);
27
+ font-size: var(--ui-font-size-sm);
28
+ line-height: var(--ui-line-height);
29
+ }
30
+
31
+ .description:empty {
32
+ display: none;
33
+ }
34
+
17
35
  input {
18
36
  appearance: none;
19
37
  -webkit-appearance: none;
@@ -81,7 +99,9 @@
81
99
  }
82
100
  }
83
101
  @scope ([data-component~="ui-switch"]) to ([data-component]) {
84
- @media (prefers-reduced-motion: reduce) {
102
+ /* The label, and under it an optional line saying what the choice means. */
103
+
104
+ @media (prefers-reduced-motion: reduce) {
85
105
  }
86
106
 
87
107
  /* A component that sets its display still honours the hidden attribute on its root. */
package/theme-dark.css CHANGED
@@ -23,6 +23,7 @@
23
23
  --ui-shadow-lg: 0 10px 28px rgb(0 0 0 / 0.55), 0 4px 10px rgb(0 0 0 / 0.35);
24
24
  --ui-control-inset: inset 0 1px 2px rgb(0 0 0 / 0.35);
25
25
  --ui-raised: 0 1px 2px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.06);
26
+ --ui-disabled-filter: saturate(0.2) contrast(0.75) brightness(0.8);
26
27
  --ui-raised-highlight: linear-gradient(rgb(255 255 255 / 0.04), rgb(255 255 255 / 0));
27
28
  --ui-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a0a09c' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
28
29
  }
@@ -51,6 +52,7 @@
51
52
  --ui-shadow-lg: 0 10px 28px rgb(0 0 0 / 0.55), 0 4px 10px rgb(0 0 0 / 0.35);
52
53
  --ui-control-inset: inset 0 1px 2px rgb(0 0 0 / 0.35);
53
54
  --ui-raised: 0 1px 2px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.06);
55
+ --ui-disabled-filter: saturate(0.2) contrast(0.75) brightness(0.8);
54
56
  --ui-raised-highlight: linear-gradient(rgb(255 255 255 / 0.04), rgb(255 255 255 / 0));
55
57
  --ui-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a0a09c' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
56
58
  }
@@ -24,6 +24,7 @@
24
24
  --ui-text-secondary: #111111;
25
25
  --ui-text-muted: #222222;
26
26
  --ui-disabled-text: #333333;
27
+ --ui-disabled-filter: saturate(0);
27
28
  --ui-focus-ring: #0000ff;
28
29
  }
29
30
  }
@@ -52,6 +53,8 @@
52
53
  --ui-text-secondary: #111111;
53
54
  --ui-text-muted: #222222;
54
55
  --ui-disabled-text: #333333;
56
+ /* Disabled loses its colour, never its contrast. */
57
+ --ui-disabled-filter: saturate(0);
55
58
  --ui-focus-ring: #0000ff;
56
59
  }
57
60
  }
package/tokens.css CHANGED
@@ -141,6 +141,9 @@
141
141
  full opacity, so a disabled control reads as unavailable rather than as a faded variant. */
142
142
  --ui-disabled-surface: var(--ui-surface-sunken);
143
143
  --ui-disabled-text: var(--ui-text-muted);
144
+ /* A disabled button keeps its own look, washed out: less colour, less contrast, toward the page.
145
+ A filter rather than a second palette, so every variant and tone fades the same way. */
146
+ --ui-disabled-filter: saturate(0.2) contrast(0.75) brightness(1.25);
144
147
 
145
148
  --ui-focus-ring: var(--ui-accent);
146
149
 
@@ -3,7 +3,7 @@ import { manageComponentLifecycle } from "@nextwebwg/html-next/runtime";
3
3
  import * as controller from "../components/ui-checkbox/ui-checkbox.js";
4
4
  import "../styles/ui-checkbox.css";
5
5
 
6
- const definition = {...{"contract":{"tag":"ui-checkbox","props":{"checked":{"type":"boolean","required":false,"target":{"attribute":"checked"},"default":false},"disabled":{"type":"boolean","required":false,"target":{"attribute":"disabled"},"default":false},"indeterminate":{"type":"boolean","required":false,"target":{"property":"indeterminate"},"default":false},"name":{"type":"string","required":false,"target":{"attribute":"name"},"default":""},"required":{"type":"boolean","required":false,"target":{"attribute":"required"},"default":false},"value":{"type":"string","required":false,"target":{"attribute":"value"},"default":"on"}}},"template":{"kind":"element","name":"label","attributes":[],"children":[{"kind":"element","name":"input","attributes":[{"kind":"literal","name":"type","value":"checkbox"},{"kind":"property","key":"checked","name":"checked","expression":"internalChecked","expressionPlan":{"source":"internalChecked","ast":{"kind":"id","name":"internalChecked"},"dependencies":["internalChecked"]}},{"kind":"property","key":"indeterminate","name":"indeterminate","expression":"indeterminate","expressionPlan":{"source":"indeterminate","ast":{"kind":"id","name":"indeterminate"},"dependencies":["indeterminate"]}},{"kind":"attribute","name":"disabled","expression":"disabled","expressionPlan":{"source":"disabled","ast":{"kind":"id","name":"disabled"},"dependencies":["disabled"]}},{"kind":"attribute","name":"required","expression":"required","expressionPlan":{"source":"required","ast":{"kind":"id","name":"required"},"dependencies":["required"]}},{"kind":"attribute","name":"name","expression":"name","expressionPlan":{"source":"name","ast":{"kind":"id","name":"name"},"dependencies":["name"]}},{"kind":"attribute","name":"value","expression":"value","expressionPlan":{"source":"value","ast":{"kind":"id","name":"value"},"dependencies":["value"]}}],"children":[],"ref":"input"},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"label"}],"children":[{"kind":"slot"}]}]},"declarations":[{"kind":"state","name":"internalChecked","expression":{"source":"false","ast":{"kind":"literal","value":false},"dependencies":[]}},{"kind":"event","name":"change","type":"object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })","bubbles":true,"composed":true,"cancelable":false}],"root":{"kind":"native","element":"label","choices":["label"]}},source:{file:import.meta.url},css:""};
6
+ const definition = {...{"contract":{"tag":"ui-checkbox","props":{"checked":{"type":"boolean","required":false,"target":{"attribute":"checked"},"default":false},"disabled":{"type":"boolean","required":false,"target":{"attribute":"disabled"},"default":false},"indeterminate":{"type":"boolean","required":false,"target":{"property":"indeterminate"},"default":false},"name":{"type":"string","required":false,"target":{"attribute":"name"},"default":""},"required":{"type":"boolean","required":false,"target":{"attribute":"required"},"default":false},"value":{"type":"string","required":false,"target":{"attribute":"value"},"default":"on"}}},"template":{"kind":"element","name":"label","attributes":[],"children":[{"kind":"element","name":"input","attributes":[{"kind":"literal","name":"type","value":"checkbox"},{"kind":"property","key":"checked","name":"checked","expression":"internalChecked","expressionPlan":{"source":"internalChecked","ast":{"kind":"id","name":"internalChecked"},"dependencies":["internalChecked"]}},{"kind":"property","key":"indeterminate","name":"indeterminate","expression":"indeterminate","expressionPlan":{"source":"indeterminate","ast":{"kind":"id","name":"indeterminate"},"dependencies":["indeterminate"]}},{"kind":"attribute","name":"disabled","expression":"disabled","expressionPlan":{"source":"disabled","ast":{"kind":"id","name":"disabled"},"dependencies":["disabled"]}},{"kind":"attribute","name":"required","expression":"required","expressionPlan":{"source":"required","ast":{"kind":"id","name":"required"},"dependencies":["required"]}},{"kind":"attribute","name":"name","expression":"name","expressionPlan":{"source":"name","ast":{"kind":"id","name":"name"},"dependencies":["name"]}},{"kind":"attribute","name":"value","expression":"value","expressionPlan":{"source":"value","ast":{"kind":"id","name":"value"},"dependencies":["value"]}}],"children":[],"ref":"input"},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"text"}],"children":[{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"label"}],"children":[{"kind":"slot"}]},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"description"},{"kind":"literal","name":"aria-hidden","value":"true"}],"children":[{"kind":"slot","fallback":[],"name":"description"}],"ref":"description"}]}]},"declarations":[{"kind":"state","name":"internalChecked","expression":{"source":"false","ast":{"kind":"literal","value":false},"dependencies":[]}},{"kind":"event","name":"change","type":"object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })","bubbles":true,"composed":true,"cancelable":false}],"root":{"kind":"native","element":"label","choices":["label"]}},source:{file:import.meta.url},css:""};
7
7
 
8
8
  export function createUiCheckbox(options = {}) {
9
9
  const { attributes = {}, children = [], slots = {}, as, ...componentProps } = options;
@@ -32,15 +32,30 @@ export function createUiCheckbox(options = {}) {
32
32
  else element0.setAttribute("value", String(value4));
33
33
  element.append(element0);
34
34
  const element1 = document.createElement("span");
35
- element1.setAttribute("class", "label");
35
+ element1.setAttribute("class", "text");
36
+ const element2 = document.createElement("span");
37
+ element2.setAttribute("class", "label");
36
38
  if (children.length > 0) {
37
39
  for (const child of children) {
38
40
  const node = typeof child === "string" ? document.createTextNode(child) : child;
39
41
  projected.push([node, ""]);
40
- element1.append(node);
42
+ element2.append(node);
41
43
  }
42
44
  } else {
43
45
  }
46
+ element1.append(element2);
47
+ const element3 = document.createElement("span");
48
+ element3.setAttribute("class", "description");
49
+ element3.setAttribute("aria-hidden", "true");
50
+ if (slots["description"] ?? [].length > 0) {
51
+ for (const child of slots["description"] ?? []) {
52
+ const node = typeof child === "string" ? document.createTextNode(child) : child;
53
+ projected.push([node, "description"]);
54
+ element3.append(node);
55
+ }
56
+ } else {
57
+ }
58
+ element1.append(element3);
44
59
  element.append(element1);
45
60
  manageComponentLifecycle(element, definition, { props: componentProps, projected,
46
61
  controller,
@@ -3,7 +3,7 @@ import { manageComponentLifecycle } from "@nextwebwg/html-next/runtime";
3
3
  import * as controller from "../components/ui-radio/ui-radio.js";
4
4
  import "../styles/ui-radio.css";
5
5
 
6
- const definition = {...{"contract":{"tag":"ui-radio","props":{"checked":{"type":"boolean","required":false,"target":{"attribute":"checked"},"default":false},"disabled":{"type":"boolean","required":false,"target":{"attribute":"disabled"},"default":false},"name":{"type":"string","required":false,"target":{"attribute":"name"},"default":""},"required":{"type":"boolean","required":false,"target":{"attribute":"required"},"default":false},"value":{"type":"string","required":false,"target":{"attribute":"value"},"default":"on"}}},"template":{"kind":"element","name":"label","attributes":[],"children":[{"kind":"element","name":"input","attributes":[{"kind":"literal","name":"type","value":"radio"},{"kind":"property","key":"checked","name":"checked","expression":"internalChecked","expressionPlan":{"source":"internalChecked","ast":{"kind":"id","name":"internalChecked"},"dependencies":["internalChecked"]}},{"kind":"attribute","name":"disabled","expression":"disabled","expressionPlan":{"source":"disabled","ast":{"kind":"id","name":"disabled"},"dependencies":["disabled"]}},{"kind":"attribute","name":"required","expression":"required","expressionPlan":{"source":"required","ast":{"kind":"id","name":"required"},"dependencies":["required"]}},{"kind":"attribute","name":"value","expression":"value","expressionPlan":{"source":"value","ast":{"kind":"id","name":"value"},"dependencies":["value"]}},{"kind":"attribute","name":"name","expression":"name","expressionPlan":{"source":"name","ast":{"kind":"id","name":"name"},"dependencies":["name"]}}],"children":[],"ref":"input"},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"label"}],"children":[{"kind":"slot"}]}]},"declarations":[{"kind":"state","name":"internalChecked","expression":{"source":"false","ast":{"kind":"literal","value":false},"dependencies":[]}},{"kind":"event","name":"change","type":"object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })","bubbles":true,"composed":true,"cancelable":false}],"root":{"kind":"native","element":"label","choices":["label"]}},source:{file:import.meta.url},css:""};
6
+ const definition = {...{"contract":{"tag":"ui-radio","props":{"checked":{"type":"boolean","required":false,"target":{"attribute":"checked"},"default":false},"disabled":{"type":"boolean","required":false,"target":{"attribute":"disabled"},"default":false},"name":{"type":"string","required":false,"target":{"attribute":"name"},"default":""},"required":{"type":"boolean","required":false,"target":{"attribute":"required"},"default":false},"value":{"type":"string","required":false,"target":{"attribute":"value"},"default":"on"}}},"template":{"kind":"element","name":"label","attributes":[],"children":[{"kind":"element","name":"input","attributes":[{"kind":"literal","name":"type","value":"radio"},{"kind":"property","key":"checked","name":"checked","expression":"internalChecked","expressionPlan":{"source":"internalChecked","ast":{"kind":"id","name":"internalChecked"},"dependencies":["internalChecked"]}},{"kind":"attribute","name":"disabled","expression":"disabled","expressionPlan":{"source":"disabled","ast":{"kind":"id","name":"disabled"},"dependencies":["disabled"]}},{"kind":"attribute","name":"required","expression":"required","expressionPlan":{"source":"required","ast":{"kind":"id","name":"required"},"dependencies":["required"]}},{"kind":"attribute","name":"value","expression":"value","expressionPlan":{"source":"value","ast":{"kind":"id","name":"value"},"dependencies":["value"]}},{"kind":"attribute","name":"name","expression":"name","expressionPlan":{"source":"name","ast":{"kind":"id","name":"name"},"dependencies":["name"]}}],"children":[],"ref":"input"},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"text"}],"children":[{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"label"}],"children":[{"kind":"slot"}]},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"description"},{"kind":"literal","name":"aria-hidden","value":"true"}],"children":[{"kind":"slot","fallback":[],"name":"description"}],"ref":"description"}]}]},"declarations":[{"kind":"state","name":"internalChecked","expression":{"source":"false","ast":{"kind":"literal","value":false},"dependencies":[]}},{"kind":"event","name":"change","type":"object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })","bubbles":true,"composed":true,"cancelable":false}],"root":{"kind":"native","element":"label","choices":["label"]}},source:{file:import.meta.url},css:""};
7
7
 
8
8
  export function createUiRadio(options = {}) {
9
9
  const { attributes = {}, children = [], slots = {}, as, ...componentProps } = options;
@@ -30,15 +30,30 @@ export function createUiRadio(options = {}) {
30
30
  else element0.setAttribute("name", String(value3));
31
31
  element.append(element0);
32
32
  const element1 = document.createElement("span");
33
- element1.setAttribute("class", "label");
33
+ element1.setAttribute("class", "text");
34
+ const element2 = document.createElement("span");
35
+ element2.setAttribute("class", "label");
34
36
  if (children.length > 0) {
35
37
  for (const child of children) {
36
38
  const node = typeof child === "string" ? document.createTextNode(child) : child;
37
39
  projected.push([node, ""]);
38
- element1.append(node);
40
+ element2.append(node);
39
41
  }
40
42
  } else {
41
43
  }
44
+ element1.append(element2);
45
+ const element3 = document.createElement("span");
46
+ element3.setAttribute("class", "description");
47
+ element3.setAttribute("aria-hidden", "true");
48
+ if (slots["description"] ?? [].length > 0) {
49
+ for (const child of slots["description"] ?? []) {
50
+ const node = typeof child === "string" ? document.createTextNode(child) : child;
51
+ projected.push([node, "description"]);
52
+ element3.append(node);
53
+ }
54
+ } else {
55
+ }
56
+ element1.append(element3);
42
57
  element.append(element1);
43
58
  manageComponentLifecycle(element, definition, { props: componentProps, projected,
44
59
  controller,
@@ -3,7 +3,7 @@ import { manageComponentLifecycle } from "@nextwebwg/html-next/runtime";
3
3
  import * as controller from "../components/ui-switch/ui-switch.js";
4
4
  import "../styles/ui-switch.css";
5
5
 
6
- const definition = {...{"contract":{"tag":"ui-switch","props":{"checked":{"type":"boolean","required":false,"target":{"attribute":"checked"},"default":false},"disabled":{"type":"boolean","required":false,"target":{"attribute":"disabled"},"default":false},"name":{"type":"string","required":false,"target":{"attribute":"name"},"default":""},"required":{"type":"boolean","required":false,"target":{"attribute":"required"},"default":false},"value":{"type":"string","required":false,"target":{"attribute":"value"},"default":"on"}}},"template":{"kind":"element","name":"label","attributes":[],"children":[{"kind":"element","name":"input","attributes":[{"kind":"literal","name":"type","value":"checkbox"},{"kind":"literal","name":"role","value":"switch"},{"kind":"property","key":"checked","name":"checked","expression":"internalChecked","expressionPlan":{"source":"internalChecked","ast":{"kind":"id","name":"internalChecked"},"dependencies":["internalChecked"]}},{"kind":"attribute","name":"disabled","expression":"disabled","expressionPlan":{"source":"disabled","ast":{"kind":"id","name":"disabled"},"dependencies":["disabled"]}},{"kind":"attribute","name":"required","expression":"required","expressionPlan":{"source":"required","ast":{"kind":"id","name":"required"},"dependencies":["required"]}},{"kind":"attribute","name":"name","expression":"name","expressionPlan":{"source":"name","ast":{"kind":"id","name":"name"},"dependencies":["name"]}},{"kind":"attribute","name":"value","expression":"value","expressionPlan":{"source":"value","ast":{"kind":"id","name":"value"},"dependencies":["value"]}}],"children":[],"ref":"input"},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"label"}],"children":[{"kind":"slot"}]}]},"declarations":[{"kind":"state","name":"internalChecked","expression":{"source":"false","ast":{"kind":"literal","value":false},"dependencies":[]}},{"kind":"event","name":"change","type":"object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })","bubbles":true,"composed":true,"cancelable":false}],"root":{"kind":"native","element":"label","choices":["label"]}},source:{file:import.meta.url},css:""};
6
+ const definition = {...{"contract":{"tag":"ui-switch","props":{"checked":{"type":"boolean","required":false,"target":{"attribute":"checked"},"default":false},"disabled":{"type":"boolean","required":false,"target":{"attribute":"disabled"},"default":false},"name":{"type":"string","required":false,"target":{"attribute":"name"},"default":""},"required":{"type":"boolean","required":false,"target":{"attribute":"required"},"default":false},"value":{"type":"string","required":false,"target":{"attribute":"value"},"default":"on"}}},"template":{"kind":"element","name":"label","attributes":[],"children":[{"kind":"element","name":"input","attributes":[{"kind":"literal","name":"type","value":"checkbox"},{"kind":"literal","name":"role","value":"switch"},{"kind":"property","key":"checked","name":"checked","expression":"internalChecked","expressionPlan":{"source":"internalChecked","ast":{"kind":"id","name":"internalChecked"},"dependencies":["internalChecked"]}},{"kind":"attribute","name":"disabled","expression":"disabled","expressionPlan":{"source":"disabled","ast":{"kind":"id","name":"disabled"},"dependencies":["disabled"]}},{"kind":"attribute","name":"required","expression":"required","expressionPlan":{"source":"required","ast":{"kind":"id","name":"required"},"dependencies":["required"]}},{"kind":"attribute","name":"name","expression":"name","expressionPlan":{"source":"name","ast":{"kind":"id","name":"name"},"dependencies":["name"]}},{"kind":"attribute","name":"value","expression":"value","expressionPlan":{"source":"value","ast":{"kind":"id","name":"value"},"dependencies":["value"]}}],"children":[],"ref":"input"},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"text"}],"children":[{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"label"}],"children":[{"kind":"slot"}]},{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"description"},{"kind":"literal","name":"aria-hidden","value":"true"}],"children":[{"kind":"slot","fallback":[],"name":"description"}],"ref":"description"}]}]},"declarations":[{"kind":"state","name":"internalChecked","expression":{"source":"false","ast":{"kind":"literal","value":false},"dependencies":[]}},{"kind":"event","name":"change","type":"object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })","bubbles":true,"composed":true,"cancelable":false}],"root":{"kind":"native","element":"label","choices":["label"]}},source:{file:import.meta.url},css:""};
7
7
 
8
8
  export function createUiSwitch(options = {}) {
9
9
  const { attributes = {}, children = [], slots = {}, as, ...componentProps } = options;
@@ -31,15 +31,30 @@ export function createUiSwitch(options = {}) {
31
31
  else element0.setAttribute("value", String(value3));
32
32
  element.append(element0);
33
33
  const element1 = document.createElement("span");
34
- element1.setAttribute("class", "label");
34
+ element1.setAttribute("class", "text");
35
+ const element2 = document.createElement("span");
36
+ element2.setAttribute("class", "label");
35
37
  if (children.length > 0) {
36
38
  for (const child of children) {
37
39
  const node = typeof child === "string" ? document.createTextNode(child) : child;
38
40
  projected.push([node, ""]);
39
- element1.append(node);
41
+ element2.append(node);
40
42
  }
41
43
  } else {
42
44
  }
45
+ element1.append(element2);
46
+ const element3 = document.createElement("span");
47
+ element3.setAttribute("class", "description");
48
+ element3.setAttribute("aria-hidden", "true");
49
+ if (slots["description"] ?? [].length > 0) {
50
+ for (const child of slots["description"] ?? []) {
51
+ const node = typeof child === "string" ? document.createTextNode(child) : child;
52
+ projected.push([node, "description"]);
53
+ element3.append(node);
54
+ }
55
+ } else {
56
+ }
57
+ element1.append(element3);
43
58
  element.append(element1);
44
59
  manageComponentLifecycle(element, definition, { props: componentProps, projected,
45
60
  controller,
package/vue/UiButton.vue CHANGED
@@ -259,31 +259,19 @@ function attribute(value: unknown): any {
259
259
  var(--ui-focus-halo);
260
260
  }
261
261
 
262
- /* Disabled drops the tone rather than the shape: every variant keeps its own structure, so a
263
- disabled outline is still an outline and a disabled ghost still has no edge. Intent goes — an
264
- unavailable action says "unavailable", not "unavailable, and destructive" — and so do the
265
- highlight and shadow, because nothing that cannot be pressed should look raised. */
262
+ /* Disabled keeps the shape and washes out the rest: every variant keeps its own structure, so a
263
+ disabled outline is still an outline and a disabled ghost still has no edge. One filter fades
264
+ fill, border, and text together (--ui-disabled-filter), and the theme decides which way is
265
+ toward the page. The highlight and shadow go, because nothing that cannot be pressed should
266
+ look raised. */
266
267
  [data-component~="ui-button"]:is(:disabled, [aria-disabled="true"]) {
267
- /* Derived from the tone itself, not from a separate disabled palette: faded toward the
268
- page, which desaturates it at the same time. Toward the page rather than toward white,
269
- so a dark theme dims where a light one lightens — the same rule, both schemes. */
270
- --_tone: color-mix(in oklab, var(--_hue) 58%, var(--ui-surface));
271
- --_tone-hover: var(--_tone);
272
- --_tone-active: var(--_tone);
273
- --_tone-text: color-mix(in oklab, var(--_hue-text) 72%, var(--ui-surface));
274
- --_on-tone: var(--_tone-text);
275
-
268
+ filter: var(--ui-button-disabled-filter, var(--ui-disabled-filter));
276
269
  opacity: var(--ui-button-disabled-opacity, 1);
277
270
  cursor: not-allowed;
278
271
  background-image: none;
279
272
  box-shadow: none;
280
273
  }
281
274
 
282
- /* A filled button keeps its fill, faded further so its text stays legible on it. */
283
- :is([data-component~="ui-button"][data-ui-button-state~="variant=solid"], [data-component~="ui-button"][data-ui-button-state~="variant=danger"]):is(:disabled, [aria-disabled="true"]) {
284
- --_tone: color-mix(in oklab, var(--_hue) 34%, var(--ui-surface));
285
- }
286
-
287
275
  /* Ghost has no hover to fall back on, so disabled is the one state it has to state outright:
288
276
  it takes a surface rather than reading as plain text. The surface is a wash of the faded tone,
289
277
  the way hover washes the live one, so it stays light in every tone. An opaque mix toward the
@@ -299,14 +287,17 @@ function attribute(value: unknown): any {
299
287
  }
300
288
  }
301
289
 
302
- /* Under touch, every button's press lands within the control minimum: an invisible hit area,
303
- centred on it, like IconButton's. A standalone link-style button (a "See all" beside a
304
- heading) is a target of its own, not text, so it gets one too. */
305
- html[data-ui-input-modality="touch"] [data-component~="ui-button"] {
290
+ /* Under touch, a link-style button's press lands within the control minimum: an invisible hit
291
+ area, centred on it, like IconButton's. A boxed button grows to the minimum itself (below);
292
+ a link-style one keeps its text size, so a standalone one ("See all" under a list) needs the
293
+ area. Only the link needs it: a hit area on every button would reach over its neighbours. */
294
+ html[data-ui-input-modality="touch"]
295
+ [data-component~="ui-button"][data-ui-button-state~="variant=link"] {
306
296
  position: relative;
307
297
  }
308
298
 
309
- html[data-ui-input-modality="touch"] [data-component~="ui-button"]::after {
299
+ html[data-ui-input-modality="touch"]
300
+ [data-component~="ui-button"][data-ui-button-state~="variant=link"]::after {
310
301
  position: absolute;
311
302
  inset: 50% auto auto 50%;
312
303
  inline-size: max(100%, var(--ui-control-min-block-size));
@@ -351,7 +342,6 @@ html[data-ui-input-modality="touch"] [data-component~="ui-button"]::after {
351
342
  [data-component~="ui-button"][data-ui-button-state~="variant=link"]:is(:disabled, [aria-disabled="true"]) {
352
343
  background: none;
353
344
  border-color: transparent;
354
- color: var(--ui-button-disabled-text, var(--ui-disabled-text));
355
345
  }
356
346
 
357
347
  [data-component~="ui-button"][data-ui-button-state~="variant=link"]:focus-visible {
@@ -6,9 +6,11 @@ type __VLS_Props = {
6
6
  required?: boolean;
7
7
  value?: string;
8
8
  };
9
- declare var __VLS_1: {};
9
+ declare var __VLS_1: {}, __VLS_3: {};
10
10
  type __VLS_Slots = {} & {
11
11
  default?: (props: typeof __VLS_1) => any;
12
+ } & {
13
+ description?: (props: typeof __VLS_3) => any;
12
14
  };
13
15
  declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
14
16
  change: (detail: {
@@ -38,6 +38,7 @@ const emit = defineEmits<{
38
38
 
39
39
  const root = ref<HTMLElement | null>(null)
40
40
  const inputElement = useTemplateRef<HTMLElement>('input')
41
+ const descriptionElement = useTemplateRef<HTMLElement>('description')
41
42
  const internalChecked = ref(false)
42
43
 
43
44
  /** The values the styles' :host-state() rules test. */
@@ -66,7 +67,7 @@ useComponentHost(controllerModule.default, {
66
67
  root,
67
68
  dispatch,
68
69
  props,
69
- refs: { input: inputElement },
70
+ refs: { input: inputElement, description: descriptionElement },
70
71
  state: { internalChecked },
71
72
  })
72
73
  </script>
@@ -87,7 +88,10 @@ useComponentHost(controllerModule.default, {
87
88
  :value="value"
88
89
  ref="input"
89
90
  >
90
- <span class="label"><slot /></span></label>
91
+ <span class="text"><span class="label"><slot /></span>
92
+ <span class="description" aria-hidden="true" ref="description"><slot
93
+ name="description"
94
+ /></span></span></label>
91
95
  </template>
92
96
 
93
97
  <style scoped>
@@ -100,11 +104,29 @@ useComponentHost(controllerModule.default, {
100
104
  cursor: pointer;
101
105
  }
102
106
 
107
+ /* The label, and under it an optional line saying what the choice means. */
108
+ .text {
109
+ display: flex;
110
+ flex-direction: column;
111
+ gap: var(--ui-space-1);
112
+ min-inline-size: 0;
113
+ }
114
+
103
115
  .label {
104
116
  min-inline-size: 0;
105
117
  line-height: var(--ui-line-height-md);
106
118
  }
107
119
 
120
+ .description {
121
+ color: var(--ui-text-secondary);
122
+ font-size: var(--ui-font-size-sm);
123
+ line-height: var(--ui-line-height);
124
+ }
125
+
126
+ .description:empty {
127
+ display: none;
128
+ }
129
+
108
130
  input {
109
131
  appearance: none;
110
132
  -webkit-appearance: none;