@threadlabs/looma 0.11.6 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/components/shared/describe.js +9 -0
  2. package/components/ui-button/ui-button.html +6 -19
  3. package/components/ui-checkbox/ui-checkbox.html +22 -1
  4. package/components/ui-checkbox/ui-checkbox.js +2 -0
  5. package/components/ui-icon-button/ui-icon-button.html +4 -14
  6. package/components/ui-input-group/ui-input-group.html +15 -3
  7. package/components/ui-radio/ui-radio.html +22 -1
  8. package/components/ui-radio/ui-radio.js +2 -0
  9. package/components/ui-sidebar/ui-sidebar.js +3 -1
  10. package/components/ui-switch/ui-switch.html +22 -1
  11. package/components/ui-switch/ui-switch.js +2 -0
  12. package/dist/index.js +1 -1
  13. package/package.json +1 -1
  14. package/styles/ui-button.css +11 -25
  15. package/styles/ui-checkbox.css +21 -1
  16. package/styles/ui-icon-button.css +4 -13
  17. package/styles/ui-input-group.css +19 -4
  18. package/styles/ui-radio.css +21 -1
  19. package/styles/ui-switch.css +21 -1
  20. package/theme-dark.css +2 -0
  21. package/theme-high-contrast.css +3 -0
  22. package/tokens.css +3 -0
  23. package/vanilla/UiCheckbox.js +18 -3
  24. package/vanilla/UiInputGroup.js +11 -0
  25. package/vanilla/UiRadio.js +18 -3
  26. package/vanilla/UiSwitch.js +18 -3
  27. package/vue/UiButton.vue +6 -19
  28. package/vue/UiCheckbox.d.ts +3 -1
  29. package/vue/UiCheckbox.vue +24 -2
  30. package/vue/UiIconButton.vue +3 -13
  31. package/vue/UiInputGroup.d.ts +3 -1
  32. package/vue/UiInputGroup.vue +15 -3
  33. package/vue/UiRadio.d.ts +3 -1
  34. package/vue/UiRadio.vue +24 -2
  35. package/vue/UiSwitch.d.ts +3 -1
  36. package/vue/UiSwitch.vue +24 -2
  37. package/vue/chunks/UiButton.js +1 -1
  38. package/vue/chunks/UiCheckbox.js +14 -4
  39. package/vue/chunks/UiIconButton.js +1 -1
  40. package/vue/chunks/UiInputGroup.js +4 -2
  41. package/vue/chunks/UiRadio.js +14 -4
  42. package/vue/chunks/UiSwitch.js +14 -4
  43. package/vue/components.css +171 -133
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.11.6",
3
+ "version": "0.12.1",
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
@@ -273,7 +261,6 @@
273
261
  :scope[data-ui-button-state~="variant=link"]:is(:disabled, [aria-disabled="true"]) {
274
262
  background: none;
275
263
  border-color: transparent;
276
- color: var(--ui-button-disabled-text, var(--ui-disabled-text));
277
264
  }
278
265
 
279
266
  :scope[data-ui-button-state~="variant=link"]:focus-visible {
@@ -304,12 +291,11 @@
304
291
 
305
292
  /* A soft halo that transitions in with the other box-shadow changes, layered on the raised shadow. */
306
293
 
307
- /* Disabled drops the tone rather than the shape: every variant keeps its own structure, so a
308
- disabled outline is still an outline and a disabled ghost still has no edge. Intent goes — an
309
- unavailable action says "unavailable", not "unavailable, and destructive" — and so do the
310
- highlight and shadow, because nothing that cannot be pressed should look raised. */
311
-
312
- /* 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. */
313
299
 
314
300
  /* Ghost has no hover to fall back on, so disabled is the one state it has to state outright:
315
301
  it takes a surface rather than reading as plain text. The surface is a wash of the faded tone,
@@ -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));
@@ -5,7 +5,9 @@
5
5
  focus ring around the input and its affixes together; the input inside drops its own through
6
6
  its custom properties, so it is still the one ui-input, with its form behaviour intact. The
7
7
  affixes are plain text beside the input, so name the input with a label that says what the
8
- whole value is. */
8
+ whole value is. The action slot takes one button at the end, inside the border, for the one
9
+ thing the field is for ("Continue" after a site's address), so it reads as the field's action
10
+ and not the form's. */
9
11
  :scope {
10
12
  --ui-input-border: transparent;
11
13
  --ui-input-border-hover: transparent;
@@ -35,7 +37,8 @@
35
37
  border-color: var(--ui-text-secondary);
36
38
  }
37
39
 
38
- :scope:focus-within {
40
+ /* The field's focus, not the action's: a focused button draws its own ring. */
41
+ :scope:has(.field :focus) {
39
42
  border-color: var(--ui-accent-solid);
40
43
  box-shadow:
41
44
  var(--ui-control-inset),
@@ -68,7 +71,15 @@
68
71
  white-space: nowrap;
69
72
  }
70
73
 
71
- .affix:empty {
74
+ .action {
75
+ display: flex;
76
+ flex: none;
77
+ align-items: stretch;
78
+ padding: var(--ui-space-1);
79
+ }
80
+
81
+ .affix:empty,
82
+ .action:empty {
72
83
  display: none;
73
84
  }
74
85
 
@@ -83,7 +94,11 @@
83
94
  focus ring around the input and its affixes together; the input inside drops its own through
84
95
  its custom properties, so it is still the one ui-input, with its form behaviour intact. The
85
96
  affixes are plain text beside the input, so name the input with a label that says what the
86
- whole value is. */
97
+ whole value is. The action slot takes one button at the end, inside the border, for the one
98
+ thing the field is for ("Continue" after a site's address), so it reads as the field's action
99
+ and not the form's. */
100
+
101
+ /* The field's focus, not the action's: a focused button draws its own ring. */
87
102
 
88
103
  /* A component that sets its display still honours the hidden attribute on its root. */
89
104
  }
@@ -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,
@@ -45,6 +45,17 @@ export function createUiInputGroup(options = {}) {
45
45
  } else {
46
46
  }
47
47
  element.append(element2);
48
+ const element3 = document.createElement("span");
49
+ element3.setAttribute("class", "action");
50
+ if (slots["action"] ?? [].length > 0) {
51
+ for (const child of slots["action"] ?? []) {
52
+ const node = typeof child === "string" ? document.createTextNode(child) : child;
53
+ projected.push([node, "action"]);
54
+ element3.append(node);
55
+ }
56
+ } else {
57
+ }
58
+ element.append(element3);
48
59
  manageGeneratedProps(element, [
49
60
  { name: "disabled", attribute: "data-disabled", value: componentProps["disabled"], default: false, type: "boolean", required: false },
50
61
  ]);
@@ -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
@@ -354,7 +342,6 @@ html[data-ui-input-modality="touch"]
354
342
  [data-component~="ui-button"][data-ui-button-state~="variant=link"]:is(:disabled, [aria-disabled="true"]) {
355
343
  background: none;
356
344
  border-color: transparent;
357
- color: var(--ui-button-disabled-text, var(--ui-disabled-text));
358
345
  }
359
346
 
360
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;