@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.
@@ -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
@@ -260,14 +248,15 @@
260
248
  }
261
249
  }
262
250
 
263
- /* Under touch, every button's press lands within the control minimum: an invisible hit area,
264
- centred on it, like IconButton's. A standalone link-style button (a "See all" beside a
265
- heading) is a target of its own, not text, so it gets one too. */
266
- html[data-ui-input-modality="touch"] :host {
251
+ /* Under touch, a link-style button's press lands within the control minimum: an invisible hit
252
+ area, centred on it, like IconButton's. A boxed button grows to the minimum itself (below);
253
+ a link-style one keeps its text size, so a standalone one ("See all" under a list) needs the
254
+ area. Only the link needs it: a hit area on every button would reach over its neighbours. */
255
+ html[data-ui-input-modality="touch"] :host-state([variant="link"]) {
267
256
  position: relative;
268
257
  }
269
258
 
270
- html[data-ui-input-modality="touch"] :host::after {
259
+ html[data-ui-input-modality="touch"] :host-state([variant="link"])::after {
271
260
  position: absolute;
272
261
  inset: 50% auto auto 50%;
273
262
  inline-size: max(100%, var(--ui-control-min-block-size));
@@ -310,7 +299,6 @@
310
299
  :host-state([variant="link"]):is(:disabled, [aria-disabled="true"]) {
311
300
  background: none;
312
301
  border-color: transparent;
313
- color: var(--ui-button-disabled-text, var(--ui-disabled-text));
314
302
  }
315
303
 
316
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 {
@@ -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);