@threadlabs/looma 0.25.0 → 0.25.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.
@@ -1,5 +1,9 @@
1
1
  <template component="ui-input-group" controller="./ui-input-group.js">
2
2
  <defs>
3
+ <prop name="multiline" type="boolean" default="false">Groups a Textarea with an action below its text inside the shared frame.</prop>
4
+ <prop name="actionPosition" type="keyword" values="below, top-end" default="below"
5
+ >In a multiline group, below keeps the action under the text. top-end places it inside the textarea at its upper end and reserves text padding for its
6
+ measured width.</prop>
3
7
  <prop name="prefixKind" type="keyword" values="text, icon" default="text"
4
8
  >Text affixes have their own framing. An icon prefix shares the field surface and uses one compact icon-to-value gap.</prop>
5
9
  <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>
@@ -46,6 +50,17 @@
46
50
  --_ui-default-input-focus-outline: none;
47
51
  --_ui-default-input-radius: 0;
48
52
  --_ui-default-input-surface: transparent;
53
+ --_ui-default-textarea-border-width: 0;
54
+ --_ui-default-textarea-border: transparent;
55
+ --_ui-default-textarea-border-hover: transparent;
56
+ --_ui-default-textarea-focus-border: transparent;
57
+ --_ui-default-textarea-invalid-border: transparent;
58
+ --_ui-default-textarea-invalid-focus-shadow: none;
59
+ --_ui-default-textarea-shadow: none;
60
+ --_ui-default-textarea-focus-shadow: none;
61
+ --_ui-default-textarea-focus-outline: none;
62
+ --_ui-default-textarea-radius: 0;
63
+ --_ui-default-textarea-surface: transparent;
49
64
 
50
65
  display: flex;
51
66
  align-items: stretch;
@@ -79,13 +94,13 @@
79
94
  }
80
95
  }
81
96
 
82
- :host:hover:not(:has(input:focus, input:disabled, input:read-only, [aria-invalid="true"], :user-invalid)) {
97
+ :host:hover:not(:has(:is(input, textarea):focus, :is(input, textarea):disabled, :is(input, textarea):read-only, [aria-invalid="true"], :user-invalid)) {
83
98
  border-color: var(--ui-control-border-hover, var(--ui-text-secondary));
84
99
  }
85
100
 
86
101
  /* The ring shows when the field's input would show its own: when it is focus-visible, which a
87
102
  text input is for a pointer as well as a keyboard. A focused action button draws its own. */
88
- :host:has(.field input:focus-visible) {
103
+ :host:has(.field :is(input, textarea):focus-visible) {
89
104
  border-color: var(--ui-control-focus, var(--ui-accent-solid));
90
105
  box-shadow:
91
106
  var(--ui-control-inset),
@@ -96,7 +111,7 @@
96
111
  border-color: var(--ui-input-group-invalid-border, var(--ui-field-danger, var(--ui-danger-solid)));
97
112
  }
98
113
 
99
- :host:has(input:is([aria-invalid="true"], :user-invalid):focus-visible) {
114
+ :host:has(:is(input, textarea):is([aria-invalid="true"], :user-invalid):focus-visible) {
100
115
  box-shadow: var(
101
116
  --ui-input-group-invalid-focus-shadow,
102
117
  var(--ui-control-inset),
@@ -110,14 +125,14 @@
110
125
 
111
126
  /* Forced colors drop box shadows, so the focus ring becomes an outline. */
112
127
  @media (forced-colors: active) {
113
- :host:has(input:focus-visible) {
128
+ :host:has(:is(input, textarea):focus-visible) {
114
129
  outline: var(--ui-focus-width) solid Highlight;
115
130
  outline-offset: var(--ui-border-width);
116
131
  }
117
132
  }
118
133
 
119
134
  :host([disabled]),
120
- :host:has(input:disabled) {
135
+ :host:has(:is(input, textarea):disabled) {
121
136
  background: var(--ui-control-surface-disabled, var(--ui-surface-muted));
122
137
  box-shadow: none;
123
138
  cursor: not-allowed;
@@ -172,6 +187,41 @@
172
187
  padding-inline: var(--ui-space-1);
173
188
  }
174
189
 
190
+ /* Multiline text owns the first row; its action remains inside the same frame below it. */
191
+ :host([multiline]) {
192
+ display: grid;
193
+ grid-template-columns: auto minmax(0, 1fr) auto;
194
+ }
195
+
196
+ :host([multiline]) .field {
197
+ grid-column: 2;
198
+ }
199
+
200
+ :host([multiline]) .action {
201
+ grid-column: 1 / -1;
202
+ justify-content: end;
203
+ padding: var(--ui-space-2);
204
+ padding-block-start: 0;
205
+ }
206
+
207
+ /* The same textarea keeps its full resizeable box. Its text reserves the action's live width,
208
+ so a translated label or pending indicator cannot cover what the person types. */
209
+ :host([multiline][actionPosition="top-end"]) {
210
+ --_ui-default-textarea-padding-inline-end: calc(var(--_ui-input-group-action-inline-size, 7rem) + var(--ui-space-3));
211
+ }
212
+
213
+ :host([multiline][actionPosition="top-end"]) .field {
214
+ grid-row: 1;
215
+ }
216
+
217
+ :host([multiline][actionPosition="top-end"]) .action {
218
+ grid-column: 2;
219
+ grid-row: 1;
220
+ align-self: start;
221
+ justify-self: end;
222
+ padding: var(--ui-space-2);
223
+ }
224
+
175
225
  .affix:empty,
176
226
  .action:empty {
177
227
  display: none;
@@ -20,8 +20,23 @@ function connect(host) {
20
20
  let owned = [];
21
21
  let described = null;
22
22
  const warned = new Set();
23
+ let action = null;
24
+ const measureAction = () => {
25
+ const width = action?.getBoundingClientRect().width ?? 0;
26
+ element.style.setProperty("--_ui-input-group-action-inline-size", `${width}px`);
27
+ };
28
+ // Observe the actual slot surface rather than estimating a label's width. This also catches
29
+ // pending indicators, font loading, touch target sizing and translated labels.
30
+ const actionObserver = new ResizeObserver(measureAction);
23
31
  const wire = () => {
24
- const input = element.querySelector(".field input");
32
+ const nextAction = element.querySelector(":scope > .action");
33
+ if (action !== nextAction) {
34
+ if (action) actionObserver.unobserve(action);
35
+ action = nextAction;
36
+ if (action) actionObserver.observe(action);
37
+ }
38
+ measureAction();
39
+ const input = element.querySelector(".field :is(input, textarea)");
25
40
  const affixes = Array.from(element.querySelectorAll(":scope > .affix")).filter((affix) => {
26
41
  const text = affix.textContent.trim() !== "" && !affix.querySelector(INTERACTIVE);
27
42
  if (text) affix.setAttribute("aria-hidden", "true");
@@ -50,7 +65,7 @@ function connect(host) {
50
65
  };
51
66
  // Click, not pointerdown: a tap's click is a user activation, so a touch keyboard opens.
52
67
  const onClick = (event) => {
53
- if (pressedAround(event) && !host.props.disabled.value) element.querySelector(".field input")?.focus();
68
+ if (pressedAround(event) && !host.props.disabled.value) element.querySelector(".field :is(input, textarea)")?.focus();
54
69
  };
55
70
  element.addEventListener("mousedown", onMousedown);
56
71
  element.addEventListener("click", onClick);
@@ -61,6 +76,8 @@ function connect(host) {
61
76
  wire();
62
77
  return () => {
63
78
  observer.disconnect();
79
+ actionObserver.disconnect();
80
+ element.style.removeProperty("--_ui-input-group-action-inline-size");
64
81
  element.removeEventListener("mousedown", onMousedown);
65
82
  element.removeEventListener("click", onClick);
66
83
  };
@@ -26,6 +26,30 @@
26
26
  ></textarea>
27
27
  <style>
28
28
  /* This component's hooks style the element they are set on, and never a nested one. */
29
+ @property --ui-textarea-border-width {
30
+ syntax: "*";
31
+ inherits: false;
32
+ }
33
+ @property --ui-textarea-border {
34
+ syntax: "*";
35
+ inherits: false;
36
+ }
37
+ @property --ui-textarea-border-hover {
38
+ syntax: "*";
39
+ inherits: false;
40
+ }
41
+ @property --ui-textarea-focus-border {
42
+ syntax: "*";
43
+ inherits: false;
44
+ }
45
+ @property --ui-textarea-focus-outline {
46
+ syntax: "*";
47
+ inherits: false;
48
+ }
49
+ @property --ui-textarea-surface {
50
+ syntax: "*";
51
+ inherits: false;
52
+ }
29
53
  @property --ui-textarea-focus-shadow {
30
54
  syntax: "*";
31
55
  inherits: false;
@@ -46,6 +70,10 @@
46
70
  syntax: "*";
47
71
  inherits: false;
48
72
  }
73
+ @property --ui-textarea-padding-inline-end {
74
+ syntax: "*";
75
+ inherits: false;
76
+ }
49
77
 
50
78
  :host {
51
79
  box-sizing: border-box;
@@ -56,15 +84,17 @@
56
84
  margin: 0;
57
85
  --_padding-block: var(--ui-space-2);
58
86
  padding: var(--ui-space-2) var(--ui-space-3);
87
+ padding-inline-end: var(--ui-textarea-padding-inline-end, var(--_ui-default-textarea-padding-inline-end, var(--ui-space-3)));
59
88
  appearance: none;
60
89
  resize: vertical;
61
- border: var(--ui-border-width) solid var(--ui-control-border, var(--ui-border-strong));
62
- border-radius: var(--ui-textarea-radius, var(--ui-field-radius, var(--ui-radius-md)));
63
- background: var(--ui-control-surface, var(--ui-surface-elevated));
90
+ border: var(--ui-textarea-border-width, var(--_ui-default-textarea-border-width, var(--ui-border-width))) solid
91
+ var(--ui-textarea-border, var(--_ui-default-textarea-border, var(--ui-control-border, var(--ui-border-strong))));
92
+ border-radius: var(--ui-textarea-radius, var(--_ui-default-textarea-radius, var(--ui-field-radius, var(--ui-radius-md))));
93
+ background: var(--ui-textarea-surface, var(--_ui-default-textarea-surface, var(--ui-control-surface, var(--ui-surface-elevated))));
64
94
  color: var(--ui-text-primary);
65
95
  font: inherit;
66
96
  line-height: var(--ui-line-height-md);
67
- box-shadow: var(--ui-textarea-shadow, var(--ui-control-inset));
97
+ box-shadow: var(--ui-textarea-shadow, var(--_ui-default-textarea-shadow, var(--ui-control-inset)));
68
98
  transition:
69
99
  border-color var(--ui-motion-fast) var(--ui-motion-ease),
70
100
  box-shadow var(--ui-motion-fast) var(--ui-motion-ease),
@@ -83,29 +113,32 @@
83
113
  }
84
114
 
85
115
  :host:hover:not(:focus):not(:disabled):not(:read-only):not([aria-invalid="true"]):not(:user-invalid) {
86
- border-color: var(--ui-control-border-hover, var(--ui-text-secondary));
116
+ border-color: var(--ui-textarea-border-hover, var(--_ui-default-textarea-border-hover, var(--ui-control-border-hover, var(--ui-text-secondary))));
87
117
  }
88
118
 
89
119
  :host:focus-visible {
90
- border-color: var(--ui-control-focus, var(--ui-accent-solid));
91
- outline: none;
92
- box-shadow: var(--ui-textarea-focus-shadow, var(--ui-control-inset), var(--ui-control-focus-halo-shadow));
120
+ border-color: var(--ui-textarea-focus-border, var(--_ui-default-textarea-focus-border, var(--ui-control-focus, var(--ui-accent-solid))));
121
+ outline: var(--ui-textarea-focus-outline, var(--_ui-default-textarea-focus-outline, none));
122
+ box-shadow: var(--ui-textarea-focus-shadow, var(--_ui-default-textarea-focus-shadow, var(--ui-control-inset), var(--ui-control-focus-halo-shadow)));
93
123
  }
94
124
 
95
125
  /* The app's invalid flag, or the browser's own verdict once the user has left an invalid field. */
96
126
  :host[aria-invalid="true"],
97
127
  :host:user-invalid {
98
- border-color: var(--ui-textarea-invalid-border, var(--ui-field-danger, var(--ui-danger-solid)));
128
+ border-color: var(--ui-textarea-invalid-border, var(--_ui-default-textarea-invalid-border, var(--ui-field-danger, var(--ui-danger-solid))));
99
129
  }
100
130
 
101
131
  :host:is([aria-invalid="true"], :user-invalid):focus-visible {
102
132
  box-shadow: var(
103
133
  --ui-textarea-invalid-focus-shadow,
104
- var(--ui-control-inset),
105
- 0 0 0 calc(var(--ui-focus-width) * 1.5) color-mix(
106
- in srgb,
107
- var(--ui-textarea-invalid-border, var(--ui-field-danger, var(--ui-danger-solid))) 22%,
108
- transparent
134
+ var(
135
+ --_ui-default-textarea-invalid-focus-shadow,
136
+ var(--ui-control-inset),
137
+ 0 0 0 calc(var(--ui-focus-width) * 1.5) color-mix(
138
+ in srgb,
139
+ var(--ui-textarea-invalid-border, var(--_ui-default-textarea-invalid-border, var(--ui-field-danger, var(--ui-danger-solid)))) 22%,
140
+ transparent
141
+ )
109
142
  )
110
143
  );
111
144
  }
@@ -120,12 +153,14 @@
120
153
  :host([size="sm"]) {
121
154
  --_padding-block: var(--ui-space-1);
122
155
  padding: var(--ui-space-1) var(--ui-space-2);
156
+ padding-inline-end: var(--ui-textarea-padding-inline-end, var(--_ui-default-textarea-padding-inline-end, var(--ui-space-2)));
123
157
  font-size: var(--ui-font-size-sm);
124
158
  }
125
159
 
126
160
  :host([size="lg"]) {
127
161
  --_padding-block: var(--ui-space-3);
128
162
  padding: var(--ui-space-3) var(--ui-space-4);
163
+ padding-inline-end: var(--ui-textarea-padding-inline-end, var(--_ui-default-textarea-padding-inline-end, var(--ui-space-4)));
129
164
  font-size: var(--ui-font-size-md);
130
165
  }
131
166