@threadlabs/looma 0.12.1 → 0.12.3

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.
@@ -14,7 +14,8 @@
14
14
  :host {
15
15
  --_badge-surface: var(--ui-surface-subtle);
16
16
  --_badge-text: var(--ui-text-secondary);
17
- --_badge-border: color-mix(in srgb, var(--ui-text-secondary) 30%, transparent);
17
+ /* Every tone's edge is its fill, so it is unseen until forced colors draw it. */
18
+ --_badge-border: var(--ui-surface-subtle);
18
19
 
19
20
  display: inline-flex;
20
21
  align-items: center;
@@ -32,12 +33,12 @@
32
33
  white-space: nowrap;
33
34
  }
34
35
 
35
- /* Centre the glyphs, not the line box: a label without descenders otherwise reads high. */
36
36
  /* Centre the glyphs, not the line box: a label without descenders otherwise reads high.
37
- * Trimming applies to block containers, so the box is a block with centred content. */
37
+ * Trimming applies to block containers; an inline-block is one and, like the inline-flex it
38
+ * replaces, still sizes to its label rather than filling its container. */
38
39
  @supports (text-box-trim: trim-both) {
39
40
  :host {
40
- display: block;
41
+ display: inline-block;
41
42
  min-block-size: var(--ui-badge-min-block-size, 1.5rem);
42
43
  text-align: center;
43
44
  align-content: center;
@@ -23,8 +23,9 @@ export default function controller(host) {
23
23
  const wire = () => {
24
24
  const label = element.querySelector('[slot="label"], label');
25
25
  const input = element.querySelector("input, textarea, select");
26
- const help = element.querySelector('[slot="help"]');
27
- const error = element.querySelector('[slot="error"], [role="alert"]');
26
+ // A Vue named slot carries no slot attribute, so the help and error regions are read too.
27
+ const help = element.querySelector(':scope > .help > *, [slot="help"]');
28
+ const error = element.querySelector(':scope > .error > *, [slot="error"], [role="alert"]');
28
29
  if (input !== activeInput) {
29
30
  removeOwned(activeInput);
30
31
  activeInput = input;
@@ -15,11 +15,11 @@
15
15
  display: inline-block;
16
16
  inline-size: 100%;
17
17
  min-inline-size: 0;
18
- min-block-size: var(--ui-control-size-md);
18
+ min-block-size: var(--ui-input-min-block-size, var(--ui-control-size-md));
19
19
  margin: 0;
20
20
  padding: var(--ui-space-2) var(--ui-space-3);
21
21
  appearance: none;
22
- border: 1px solid var(--ui-input-border, var(--ui-control-border, var(--ui-border-strong)));
22
+ border: var(--ui-input-border-width, 1px) solid var(--ui-input-border, var(--ui-control-border, var(--ui-border-strong)));
23
23
  border-radius: var(--ui-input-radius, var(--ui-radius-md));
24
24
  background: var(--ui-input-surface, var(--ui-control-surface, var(--ui-surface-elevated)));
25
25
  color: var(--ui-text-primary);
@@ -74,6 +74,14 @@
74
74
  cursor: not-allowed;
75
75
  }
76
76
 
77
+ /* Forced colors drop box shadows, so the focus ring becomes an outline. */
78
+ @media (forced-colors: active) {
79
+ :host:focus-visible {
80
+ outline: var(--ui-input-focus-outline, 2px solid Highlight);
81
+ outline-offset: 1px;
82
+ }
83
+ }
84
+
77
85
  /* No text-box-trim here: trimming a native input's line box below its line height lets the text
78
86
  * scroll vertically inside the field. Native inputs already center their single line. */
79
87
 
@@ -1,4 +1,4 @@
1
- <template component="ui-input-group">
1
+ <template component="ui-input-group" controller="./ui-input-group.js">
2
2
  <defs>
3
3
  <prop name="disabled" type="boolean" default="false">Fades the group's framing to match a disabled input inside it. Disable the input itself too.</prop>
4
4
  </defs>
@@ -18,6 +18,9 @@
18
18
  thing the field is for ("Continue" after a site's address), so it reads as the field's action
19
19
  and not the form's. */
20
20
  :host {
21
+ /* The group's own border and height stand for the input's, so the group is a lone Input's height. */
22
+ --ui-input-border-width: 0;
23
+ --ui-input-min-block-size: 0;
21
24
  --ui-input-border: transparent;
22
25
  --ui-input-border-hover: transparent;
23
26
  --ui-input-focus-border: transparent;
@@ -25,6 +28,7 @@
25
28
  --ui-input-invalid-focus-shadow: none;
26
29
  --ui-input-shadow: none;
27
30
  --ui-input-focus-shadow: none;
31
+ --ui-input-focus-outline: none;
28
32
  --ui-input-radius: 0;
29
33
  --ui-input-surface: transparent;
30
34
 
@@ -34,21 +38,25 @@
34
38
  inline-size: 100%;
35
39
  min-inline-size: 0;
36
40
  min-block-size: var(--ui-control-size-md);
37
- border: 1px solid var(--ui-border-strong);
41
+ /* The frame reads the shared control tokens with a lone Input's fallbacks, so the two match. */
42
+ border: 1px solid var(--ui-control-border, var(--ui-border-strong));
38
43
  border-radius: var(--ui-radius-md);
39
- background: var(--ui-surface-elevated);
44
+ background: var(--ui-control-surface, var(--ui-surface-elevated));
40
45
  box-shadow: var(--ui-control-inset);
41
46
  overflow: hidden;
47
+ /* A press anywhere in the frame focuses the input. */
48
+ cursor: text;
42
49
  transition: border-color var(--ui-motion-fast) var(--ui-motion-ease), box-shadow var(--ui-motion-fast) var(--ui-motion-ease);
43
50
  }
44
51
 
45
- :host:hover:not(:focus-within) {
46
- border-color: var(--ui-text-secondary);
52
+ :host:hover:not(:has(input:focus, input:disabled)) {
53
+ border-color: var(--ui-control-border-hover, var(--ui-text-secondary));
47
54
  }
48
55
 
49
- /* The field's focus, not the action's: a focused button draws its own ring. */
50
- :host:has(.field :focus) {
51
- border-color: var(--ui-accent-solid);
56
+ /* The ring shows when the field's input would show its own: when it is focus-visible, which a
57
+ text input is for a pointer as well as a keyboard. A focused action button draws its own. */
58
+ :host:has(.field input:focus-visible) {
59
+ border-color: var(--ui-control-focus, var(--ui-accent-solid));
52
60
  box-shadow:
53
61
  var(--ui-control-inset),
54
62
  var(--ui-control-focus-halo-shadow);
@@ -58,9 +66,25 @@
58
66
  border-color: var(--ui-danger-solid);
59
67
  }
60
68
 
61
- :host-state([disabled]) {
62
- background: var(--ui-surface-muted);
69
+ :host:has(input:is([aria-invalid="true"], :user-invalid):focus-visible) {
70
+ box-shadow:
71
+ var(--ui-control-inset),
72
+ 0 0 0 3px color-mix(in srgb, var(--ui-danger-solid) 22%, transparent);
73
+ }
74
+
75
+ /* Forced colors drop box shadows, so the focus ring becomes an outline. */
76
+ @media (forced-colors: active) {
77
+ :host:has(input:focus-visible) {
78
+ outline: 2px solid Highlight;
79
+ outline-offset: 1px;
80
+ }
81
+ }
82
+
83
+ :host-state([disabled]),
84
+ :host:has(input:disabled) {
85
+ background: var(--ui-control-surface-disabled, var(--ui-surface-muted));
63
86
  box-shadow: none;
87
+ cursor: not-allowed;
64
88
  }
65
89
 
66
90
  .field {
@@ -80,11 +104,13 @@
80
104
  white-space: nowrap;
81
105
  }
82
106
 
107
+ /* Centred rather than padded above and below: the group is exactly a lone Input's height, so a
108
+ small button sits inside it with the difference split evenly, and the field keeps its height. */
83
109
  .action {
84
110
  display: flex;
85
111
  flex: none;
86
- align-items: stretch;
87
- padding: var(--ui-space-1);
112
+ align-items: center;
113
+ padding-inline: var(--ui-space-1);
88
114
  }
89
115
 
90
116
  .affix:empty,
@@ -0,0 +1,60 @@
1
+ let nextAffixId = 0;
2
+ // An affix holding a control keeps it: its clicks are its own, and it is not hidden or described.
3
+ const INTERACTIVE = "a[href], button, input, select, textarea, [tabindex], [contenteditable]";
4
+
5
+ function descriptionIds(input) {
6
+ return (input.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean);
7
+ }
8
+
9
+ // A text affix is hidden from assistive technology and linked to the input as its description
10
+ // instead, so the input's name stays its label and the affix is read once, with the input. The
11
+ // consumer's own aria-describedby (or a Form Field's) is kept. A press on an affix, or anywhere in
12
+ // the box outside the input, focuses the input without moving its caret or selecting the affix.
13
+ export default function controller(host) {
14
+ const element = host.element;
15
+ let owned = [];
16
+ let described = null;
17
+ const wire = () => {
18
+ const input = element.querySelector(".field input");
19
+ const affixes = Array.from(element.querySelectorAll(":scope > .affix")).filter((affix) => {
20
+ const text = affix.textContent.trim() !== "" && !affix.querySelector(INTERACTIVE);
21
+ if (text) affix.setAttribute("aria-hidden", "true");
22
+ else affix.removeAttribute("aria-hidden");
23
+ return text;
24
+ });
25
+ for (const affix of affixes) affix.id ||= `ui-input-group-affix-${(nextAffixId += 1)}`;
26
+ if (described && described !== input) {
27
+ const rest = descriptionIds(described).filter((id) => !owned.includes(id));
28
+ if (rest.length) described.setAttribute("aria-describedby", rest.join(" "));
29
+ else described.removeAttribute("aria-describedby");
30
+ }
31
+ described = input;
32
+ if (!input) return;
33
+ const external = descriptionIds(input).filter((id) => !owned.includes(id));
34
+ owned = affixes.map((affix) => affix.id);
35
+ const next = [...owned, ...external].join(" ");
36
+ if (next && input.getAttribute("aria-describedby") !== next) input.setAttribute("aria-describedby", next);
37
+ else if (!next && input.hasAttribute("aria-describedby")) input.removeAttribute("aria-describedby");
38
+ };
39
+ const pressedAround = (event) => !(event.target instanceof Element && event.target.closest(INTERACTIVE));
40
+ // Keep focus, and the caret, where they are, and start no text selection on the affix.
41
+ const onMousedown = (event) => {
42
+ if (pressedAround(event)) event.preventDefault();
43
+ };
44
+ // Click, not pointerdown: a tap's click is a user activation, so a touch keyboard opens.
45
+ const onClick = (event) => {
46
+ if (pressedAround(event) && !host.state.disabled) element.querySelector(".field input")?.focus();
47
+ };
48
+ element.addEventListener("mousedown", onMousedown);
49
+ element.addEventListener("click", onClick);
50
+ // Re-link when the input, an affix, or a description set elsewhere changes. Only aria-describedby
51
+ // is watched, and it is written only when it differs, so this settles.
52
+ const observer = new MutationObserver(() => queueMicrotask(wire));
53
+ observer.observe(element, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["aria-describedby"] });
54
+ wire();
55
+ return () => {
56
+ observer.disconnect();
57
+ element.removeEventListener("mousedown", onMousedown);
58
+ element.removeEventListener("click", onClick);
59
+ };
60
+ }