@threadlabs/looma 0.12.0 → 0.12.2

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.
@@ -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>
@@ -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,8 +14,13 @@
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 {
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;
18
24
  --ui-input-border: transparent;
19
25
  --ui-input-border-hover: transparent;
20
26
  --ui-input-focus-border: transparent;
@@ -22,6 +28,7 @@
22
28
  --ui-input-invalid-focus-shadow: none;
23
29
  --ui-input-shadow: none;
24
30
  --ui-input-focus-shadow: none;
31
+ --ui-input-focus-outline: none;
25
32
  --ui-input-radius: 0;
26
33
  --ui-input-surface: transparent;
27
34
 
@@ -31,20 +38,25 @@
31
38
  inline-size: 100%;
32
39
  min-inline-size: 0;
33
40
  min-block-size: var(--ui-control-size-md);
34
- 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));
35
43
  border-radius: var(--ui-radius-md);
36
- background: var(--ui-surface-elevated);
44
+ background: var(--ui-control-surface, var(--ui-surface-elevated));
37
45
  box-shadow: var(--ui-control-inset);
38
46
  overflow: hidden;
47
+ /* A press anywhere in the frame focuses the input. */
48
+ cursor: text;
39
49
  transition: border-color var(--ui-motion-fast) var(--ui-motion-ease), box-shadow var(--ui-motion-fast) var(--ui-motion-ease);
40
50
  }
41
51
 
42
- :host:hover:not(:focus-within) {
43
- 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));
44
54
  }
45
55
 
46
- :host:focus-within {
47
- 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));
48
60
  box-shadow:
49
61
  var(--ui-control-inset),
50
62
  var(--ui-control-focus-halo-shadow);
@@ -54,9 +66,25 @@
54
66
  border-color: var(--ui-danger-solid);
55
67
  }
56
68
 
57
- :host-state([disabled]) {
58
- 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));
59
86
  box-shadow: none;
87
+ cursor: not-allowed;
60
88
  }
61
89
 
62
90
  .field {
@@ -76,7 +104,17 @@
76
104
  white-space: nowrap;
77
105
  }
78
106
 
79
- .affix:empty {
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. */
109
+ .action {
110
+ display: flex;
111
+ flex: none;
112
+ align-items: center;
113
+ padding-inline: var(--ui-space-1);
114
+ }
115
+
116
+ .affix:empty,
117
+ .action:empty {
80
118
  display: none;
81
119
  }
82
120
 
@@ -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
+ }