@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
@@ -0,0 +1,9 @@
1
+ let descriptions = 0;
2
+
3
+ // A choice's description (Radio, Checkbox, Switch) sits inside its label, so a press on it still
4
+ // chooses. It is hidden from the label and named as the input's description instead, so a screen
5
+ // reader says "Private, radio button, Only the people you add can open it" rather than one long name.
6
+ export function describeInput(input, description) {
7
+ description.id ||= `ui-description-${++descriptions}`;
8
+ input.setAttribute("aria-describedby", description.id);
9
+ }
@@ -220,31 +220,19 @@
220
220
  var(--ui-focus-halo);
221
221
  }
222
222
 
223
- /* Disabled drops the tone rather than the shape: every variant keeps its own structure, so a
224
- disabled outline is still an outline and a disabled ghost still has no edge. Intent goes — an
225
- unavailable action says "unavailable", not "unavailable, and destructive" — and so do the
226
- highlight and shadow, because nothing that cannot be pressed should look raised. */
223
+ /* Disabled keeps the shape and washes out the rest: every variant keeps its own structure, so a
224
+ disabled outline is still an outline and a disabled ghost still has no edge. One filter fades
225
+ fill, border, and text together (--ui-disabled-filter), and the theme decides which way is
226
+ toward the page. The highlight and shadow go, because nothing that cannot be pressed should
227
+ look raised. */
227
228
  :host:is(:disabled, [aria-disabled="true"]) {
228
- /* Derived from the tone itself, not from a separate disabled palette: faded toward the
229
- page, which desaturates it at the same time. Toward the page rather than toward white,
230
- so a dark theme dims where a light one lightens — the same rule, both schemes. */
231
- --_tone: color-mix(in oklab, var(--_hue) 58%, var(--ui-surface));
232
- --_tone-hover: var(--_tone);
233
- --_tone-active: var(--_tone);
234
- --_tone-text: color-mix(in oklab, var(--_hue-text) 72%, var(--ui-surface));
235
- --_on-tone: var(--_tone-text);
236
-
229
+ filter: var(--ui-button-disabled-filter, var(--ui-disabled-filter));
237
230
  opacity: var(--ui-button-disabled-opacity, 1);
238
231
  cursor: not-allowed;
239
232
  background-image: none;
240
233
  box-shadow: none;
241
234
  }
242
235
 
243
- /* A filled button keeps its fill, faded further so its text stays legible on it. */
244
- :is(:host-state([variant="solid"]), :host-state([variant="danger"])):is(:disabled, [aria-disabled="true"]) {
245
- --_tone: color-mix(in oklab, var(--_hue) 34%, var(--ui-surface));
246
- }
247
-
248
236
  /* Ghost has no hover to fall back on, so disabled is the one state it has to state outright:
249
237
  it takes a surface rather than reading as plain text. The surface is a wash of the faded tone,
250
238
  the way hover washes the live one, so it stays light in every tone. An opaque mix toward the
@@ -311,7 +299,6 @@
311
299
  :host-state([variant="link"]):is(:disabled, [aria-disabled="true"]) {
312
300
  background: none;
313
301
  border-color: transparent;
314
- color: var(--ui-button-disabled-text, var(--ui-disabled-text));
315
302
  }
316
303
 
317
304
  :host-state([variant="link"]):focus-visible {
@@ -28,7 +28,10 @@
28
28
  :name="name"
29
29
  :value="value"
30
30
  >
31
- <span class="label"><slot></slot></span>
31
+ <span class="text">
32
+ <span class="label"><slot></slot></span>
33
+ <span class="description" $ref="description" aria-hidden="true"><slot name="description"></slot></span>
34
+ </span>
32
35
  </label>
33
36
  <style>
34
37
  :host {
@@ -40,11 +43,29 @@
40
43
  cursor: pointer;
41
44
  }
42
45
 
46
+ /* The label, and under it an optional line saying what the choice means. */
47
+ .text {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: var(--ui-space-1);
51
+ min-inline-size: 0;
52
+ }
53
+
43
54
  .label {
44
55
  min-inline-size: 0;
45
56
  line-height: var(--ui-line-height-md);
46
57
  }
47
58
 
59
+ .description {
60
+ color: var(--ui-text-secondary);
61
+ font-size: var(--ui-font-size-sm);
62
+ line-height: var(--ui-line-height);
63
+ }
64
+
65
+ .description:empty {
66
+ display: none;
67
+ }
68
+
48
69
  input {
49
70
  appearance: none;
50
71
  -webkit-appearance: none;
@@ -1,4 +1,5 @@
1
1
  import { afterFormReset } from "../shared/form-reset.js";
2
+ import { describeInput } from "../shared/describe.js";
2
3
  import { trackTrigger } from "../shared/trigger.js";
3
4
 
4
5
  // `checked` sets the control initially and whenever it changes; the user's changes update the state.
@@ -6,6 +7,7 @@ import { trackTrigger } from "../shared/trigger.js";
6
7
  // the native default alone cannot hold it there.
7
8
  export default function controller(host) {
8
9
  const input = host.refs.input;
10
+ describeInput(input, host.refs.description);
9
11
  const [trigger, stopTracking] = trackTrigger(host);
10
12
  let external = host.state.checked;
11
13
  host.state.internalChecked = Boolean(external);
@@ -1,7 +1,7 @@
1
1
  <template component="ui-icon-button" controller="./ui-icon-button.js">
2
2
  <defs>
3
3
  <prop name="disabled" type="boolean" default="false"
4
- >Disables the button, so it cannot be pressed or focused, and greys the icon; outline and solid also take the disabled surface.</prop>
4
+ >Disables the button, so it cannot be pressed or focused, and washes it out: the same shape, with less colour and contrast (--ui-disabled-filter).</prop>
5
5
  <prop name="label" type="string">Accessible name for the icon.</prop>
6
6
  <prop name="size" type="sm | md | lg" default="md"
7
7
  >Button and icon size together: sm is 1.75rem with a small icon, md is 2rem, and lg is 2.5rem with a large icon. On touch, every size gets an invisible hit
@@ -177,22 +177,12 @@
177
177
  }
178
178
 
179
179
  /* Disabled colours fall back to each variant's own; ghost takes only the icon colour. */
180
+ /* Disabled washes the button out the way Button does: the same shape, one filter. */
180
181
  :host:disabled {
182
+ filter: var(--ui-icon-button-disabled-filter, var(--ui-disabled-filter));
181
183
  opacity: var(--ui-icon-button-disabled-opacity, 1);
182
- color: var(--ui-icon-button-disabled-text, var(--ui-disabled-text));
183
184
  cursor: not-allowed;
184
- }
185
-
186
- :host-state([variant="outline"]):disabled {
187
- border-color: var(--ui-icon-button-disabled-border, var(--ui-border));
188
- background-color: var(--ui-icon-button-disabled-surface, var(--ui-disabled-surface));
189
- color: var(--ui-icon-button-disabled-text, var(--ui-disabled-text));
190
- }
191
-
192
- :host-state([variant="solid"]):disabled {
193
- border-color: var(--ui-icon-button-disabled-border, var(--ui-border));
194
- background-color: var(--ui-icon-button-disabled-surface, var(--ui-disabled-surface));
195
- color: var(--ui-icon-button-disabled-text, var(--ui-disabled-text));
185
+ box-shadow: none;
196
186
  }
197
187
 
198
188
  :host {
@@ -6,6 +6,7 @@
6
6
  <span class="affix"><slot name="prefix"></slot></span>
7
7
  <span class="field"><slot></slot></span>
8
8
  <span class="affix"><slot name="suffix"></slot></span>
9
+ <span class="action"><slot name="action"></slot></span>
9
10
  </div>
10
11
  <style>
11
12
  /* An input with fixed text before or after it, such as a domain after a site's name
@@ -13,7 +14,9 @@
13
14
  focus ring around the input and its affixes together; the input inside drops its own through
14
15
  its custom properties, so it is still the one ui-input, with its form behaviour intact. The
15
16
  affixes are plain text beside the input, so name the input with a label that says what the
16
- whole value is. */
17
+ whole value is. The action slot takes one button at the end, inside the border, for the one
18
+ thing the field is for ("Continue" after a site's address), so it reads as the field's action
19
+ and not the form's. */
17
20
  :host {
18
21
  --ui-input-border: transparent;
19
22
  --ui-input-border-hover: transparent;
@@ -43,7 +46,8 @@
43
46
  border-color: var(--ui-text-secondary);
44
47
  }
45
48
 
46
- :host:focus-within {
49
+ /* The field's focus, not the action's: a focused button draws its own ring. */
50
+ :host:has(.field :focus) {
47
51
  border-color: var(--ui-accent-solid);
48
52
  box-shadow:
49
53
  var(--ui-control-inset),
@@ -76,7 +80,15 @@
76
80
  white-space: nowrap;
77
81
  }
78
82
 
79
- .affix:empty {
83
+ .action {
84
+ display: flex;
85
+ flex: none;
86
+ align-items: stretch;
87
+ padding: var(--ui-space-1);
88
+ }
89
+
90
+ .affix:empty,
91
+ .action:empty {
80
92
  display: none;
81
93
  }
82
94
 
@@ -17,7 +17,10 @@
17
17
  </defs>
18
18
  <label>
19
19
  <input type="radio" $ref="input" .checked="internalChecked" :disabled="disabled" :required="required" :value="value" :name="name">
20
- <span class="label"><slot></slot></span>
20
+ <span class="text">
21
+ <span class="label"><slot></slot></span>
22
+ <span class="description" $ref="description" aria-hidden="true"><slot name="description"></slot></span>
23
+ </span>
21
24
  </label>
22
25
  <style>
23
26
  :host {
@@ -29,11 +32,29 @@
29
32
  cursor: pointer;
30
33
  }
31
34
 
35
+ /* The label, and under it an optional line saying what the choice means. */
36
+ .text {
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: var(--ui-space-1);
40
+ min-inline-size: 0;
41
+ }
42
+
32
43
  .label {
33
44
  min-inline-size: 0;
34
45
  line-height: var(--ui-line-height-md);
35
46
  }
36
47
 
48
+ .description {
49
+ color: var(--ui-text-secondary);
50
+ font-size: var(--ui-font-size-sm);
51
+ line-height: var(--ui-line-height);
52
+ }
53
+
54
+ .description:empty {
55
+ display: none;
56
+ }
57
+
37
58
  input {
38
59
  flex: 0 0 auto;
39
60
  inline-size: var(--ui-radio-size, 1.125rem);
@@ -1,4 +1,5 @@
1
1
  import { afterFormReset } from "../shared/form-reset.js";
2
+ import { describeInput } from "../shared/describe.js";
2
3
  import { trackTrigger } from "../shared/trigger.js";
3
4
 
4
5
  // `checked` sets the control initially and whenever it changes; the user's changes update the state.
@@ -6,6 +7,7 @@ import { trackTrigger } from "../shared/trigger.js";
6
7
  // the native default alone cannot hold it there.
7
8
  export default function controller(host) {
8
9
  const input = host.refs.input;
10
+ describeInput(input, host.refs.description);
9
11
  const [trigger, stopTracking] = trackTrigger(host);
10
12
  let external = host.state.checked;
11
13
  host.state.internalChecked = Boolean(external);
@@ -130,8 +130,10 @@ export default function controller(host) {
130
130
  if (!invoker || !element.id || invoker.getAttribute("commandfor") !== element.id) return;
131
131
  toggle(triggerOf(invoker));
132
132
  };
133
+ // The sidebar's own toggle event shares the popover's event name and bubbles; hearing it here would
134
+ // report it again, forever. Only the drawer's own popover ToggleEvent, which has a newState, counts.
133
135
  const onPopoverToggle = (event) => {
134
- if (!host.state.drawer) return;
136
+ if (!host.state.drawer || event.target !== element || typeof event.newState !== "string") return;
135
137
  host.dispatch("toggle", { open: event.newState === "open", mode: "drawer", trigger: "programmatic" });
136
138
  };
137
139
 
@@ -14,7 +14,10 @@
14
14
  </defs>
15
15
  <label>
16
16
  <input type="checkbox" role="switch" $ref="input" .checked="internalChecked" :disabled="disabled" :required="required" :name="name" :value="value">
17
- <span class="label"><slot></slot></span>
17
+ <span class="text">
18
+ <span class="label"><slot></slot></span>
19
+ <span class="description" $ref="description" aria-hidden="true"><slot name="description"></slot></span>
20
+ </span>
18
21
  </label>
19
22
  <style>
20
23
  :host {
@@ -26,11 +29,29 @@
26
29
  cursor: pointer;
27
30
  }
28
31
 
32
+ /* The label, and under it an optional line saying what the choice means. */
33
+ .text {
34
+ display: flex;
35
+ flex-direction: column;
36
+ gap: var(--ui-space-1);
37
+ min-inline-size: 0;
38
+ }
39
+
29
40
  .label {
30
41
  min-inline-size: 0;
31
42
  line-height: var(--ui-line-height-md);
32
43
  }
33
44
 
45
+ .description {
46
+ color: var(--ui-text-secondary);
47
+ font-size: var(--ui-font-size-sm);
48
+ line-height: var(--ui-line-height);
49
+ }
50
+
51
+ .description:empty {
52
+ display: none;
53
+ }
54
+
34
55
  input {
35
56
  appearance: none;
36
57
  -webkit-appearance: none;
@@ -1,4 +1,5 @@
1
1
  import { afterFormReset } from "../shared/form-reset.js";
2
+ import { describeInput } from "../shared/describe.js";
2
3
  import { trackTrigger } from "../shared/trigger.js";
3
4
 
4
5
  // `checked` sets the control initially and whenever it changes; the user's changes update the state.
@@ -6,6 +7,7 @@ import { trackTrigger } from "../shared/trigger.js";
6
7
  // the native default alone cannot hold it there.
7
8
  export default function controller(host) {
8
9
  const input = host.refs.input;
10
+ describeInput(input, host.refs.description);
9
11
  const [trigger, stopTracking] = trackTrigger(host);
10
12
  let external = host.state.checked;
11
13
  host.state.internalChecked = Boolean(external);