@reliability-design/web 1.3.0 → 1.3.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.
@@ -1,397 +1,637 @@
1
- import { LitElement, html, css } from "lit";
2
- import { customElement, property, state } from "lit/decorators.js";
3
- import "../../styles/vars-v2.css";
4
- import "@reliability-design/icons/rel-icon";
5
-
6
- export type TextFieldState = "default" | "error" | "disabled";
7
- export type TextFieldSize = "small" | "medium" | "large";
8
- export type TextFieldVariant = "default" | "underline";
9
-
10
- @customElement("rel-textfield")
11
- export class RelTextfield extends LitElement {
12
- @property({ type: String }) label?: string;
13
- @property({ type: String }) placeholder: string = "";
14
- @property({ type: String }) type: string = "text";
15
- @property({ type: String }) value: string = "";
16
- @property({ type: String }) leftIcon?: string;
17
- @property({ type: String }) rightIcon?: string;
18
- @property({ type: String }) supportingText?: string;
19
- @property({ type: String }) state: TextFieldState = "default";
20
- @property({ type: String }) size: TextFieldSize = "medium";
21
- @property({ type: Boolean, reflect: true }) floatingLabel = false;
22
- @property({ type: String, reflect: true }) variant: TextFieldVariant = "default";
23
-
24
- @state() private internalState: "default" | "focused" = "default";
25
- private _wasError = false;
26
-
27
- private uniqueId = `textfield-${Math.random().toString(36).slice(2)}`;
28
-
29
- static styles = css`
30
- :host {
31
- display: block;
32
- font-family: var(--rel-fontFamilies-primary);
33
- }
34
-
35
- /* Wrapper */
36
- .textfield-wrapper {
37
- display: flex;
38
- flex-direction: column;
39
- gap: var(--rel-spacing-space-2xs);
40
- width: 100%;
41
- }
42
-
43
- /* Label */
44
- .textfield-label {
45
- font-size: var(--rel-fontSizes-sm);
46
- font-weight: var(--rel-fontWeights-medium);
47
- color: var(--rel-color-text-heading);
48
- }
49
-
50
- /* Floating label variant */
51
- :host([floatinglabel]) .textfield-wrapper {
52
- gap: 0;
53
- }
54
-
55
- :host([floatinglabel]) .textfield-container {
56
- overflow: visible;
57
- position: relative;
58
- padding-top: 0;
59
- }
60
-
61
- :host([floatinglabel]) .textfield-label {
62
- position: absolute;
63
- left: var(--rel-spacing-space-sm);
64
- top: 50%;
65
- transform: translateY(-50%);
66
- font-size: var(--rel-fontSizes-md);
67
- font-weight: var(--rel-fontWeights-regular);
68
- color: var(--rel-color-text-placeholder);
69
- pointer-events: none;
70
- transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease, transform 0.15s ease;
71
- background: var(--rel-color-bg-primary);
72
- padding: 0 2px;
73
- z-index: 1;
74
- line-height: 1;
75
- white-space: nowrap;
76
- }
77
-
78
- :host([floatinglabel]) .textfield-wrapper.focused .textfield-label,
79
- :host([floatinglabel]) .textfield-wrapper.has-value .textfield-label {
80
- top: 0;
81
- transform: translateY(-50%);
82
- font-size: var(--rel-fontSizes-xs);
83
- color: var(--rel-color-text-primary);
84
- }
85
-
86
- :host([floatinglabel]) .textfield-wrapper.error .textfield-label {
87
- color: var(--rel-error-text);
88
- }
89
-
90
- :host([floatinglabel]) .textfield-wrapper.focused:not(.error) .textfield-label {
91
- color: var(--rel-color-text-primary);
92
- }
93
-
94
- :host([floatinglabel]) .textfield-wrapper.focused.error .textfield-label,
95
- :host([floatinglabel]) .textfield-wrapper.has-value.error .textfield-label {
96
- top: 0;
97
- transform: translateY(-50%);
98
- font-size: var(--rel-fontSizes-xs);
99
- color: var(--rel-error-text);
100
- }
101
-
102
- :host([floatinglabel]) .textfield-input {
103
- padding-top: var(--rel-spacing-space-sm);
104
- padding-bottom: var(--rel-spacing-space-sm);
105
- }
106
-
107
- :host([floatinglabel]) .textfield-input.has-left-icon {
108
- padding-left: var(--rel-spacing-space-2xl);
109
- }
110
-
111
- :host([floatinglabel]) .textfield-label.has-left-icon {
112
- left: var(--rel-spacing-space-2xl);
113
- }
114
-
115
- :host([floatinglabel]) .textfield-wrapper.focused .textfield-label.has-left-icon,
116
- :host([floatinglabel]) .textfield-wrapper.has-value .textfield-label.has-left-icon {
117
- left: var(--rel-spacing-space-sm);
118
- }
119
-
120
- :host([floatinglabel]) .textfield-wrapper.disabled .textfield-label {
121
- background: var(--rel-color-bg-tertiary);
122
- }
123
-
124
- /* Container */
125
- .textfield-container {
126
- display: flex;
127
- align-items: center;
128
- position: relative;
129
- border: 1px solid var(--rel-color-border-default);
130
- border-radius: var(--rel-borders-radius-border-radius-100);
131
- background: var(--rel-color-bg-primary);
132
- overflow: hidden;
133
- transition: border 0.2s;
134
- }
135
-
136
- .textfield-container.disabled {
137
- background: var(--rel-color-bg-tertiary);
138
- cursor: not-allowed;
139
- }
140
-
141
- /* Input */
142
- .textfield-input {
143
- flex: 1;
144
- border: none;
145
- outline: none;
146
- font-size: var(--rel-fontSizes-md);
147
- padding: var(--rel-spacing-space-xs) var(--rel-spacing-space-sm);
148
- background: transparent;
149
- font-family: var(--rel-fontFamilies-primary);
150
- color: var(--rel-color-text-primary);
151
- }
152
-
153
- .textfield-input:disabled {
154
- cursor: not-allowed;
155
- color: var(--rel-color-text-disabled);
156
- }
157
-
158
- .icon {
159
- position: absolute;
160
- display: flex;
161
- pointer-events: none;
162
- color: var(--rel-color-text-placeholder);
163
- }
164
-
165
- .icon.left {
166
- left: 8px;
167
- }
168
-
169
- .icon.right {
170
- right: 8px;
171
- }
172
-
173
- .textfield-wrapper.small .textfield-input {
174
- padding: var(--rel-spacing-space-xs) var(--rel-spacing-space-sm);
175
- font-size: var(--rel-fontSizes-sm);
176
- }
177
-
178
- .textfield-wrapper.medium .textfield-input {
179
- padding: var(--rel-spacing-space-sm) var(--rel-spacing-space-md);
180
- font-size: var(--rel-fontSizes-md);
181
- }
182
-
183
- .textfield-wrapper.large .textfield-input {
184
- padding: var(--rel-spacing-space-md) var(--rel-spacing-space-md);
185
- font-size: var(--rel-fontSizes-lg);
186
- }
187
-
188
- /* Adjust padding when icons are present */
189
- .textfield-wrapper .textfield-input.has-left-icon {
190
- padding-left: var(--rel-spacing-space-2xl);
191
- }
192
-
193
- .textfield-wrapper .textfield-input.has-right-icon {
194
- padding-right: var(--rel-spacing-space-2xl);
195
- }
196
-
197
- /* States */
198
- .textfield-wrapper.focused .textfield-container {
199
- border-color: var(--rel-color-text-primary);
200
- }
201
-
202
- .textfield-input:focus-visible {
203
- outline: none;
204
- }
205
-
206
- .textfield-wrapper.focused .textfield-container,
207
- .textfield-container:focus-within {
208
- border-color: var(--rel-color-text-primary);
209
- box-shadow: 0 0 0 1px var(--rel-color-text-primary);
210
- }
211
-
212
- .textfield-wrapper.error .textfield-container {
213
- border-color: var(--rel-error-surface-default);
214
- box-shadow: none;
215
- }
216
-
217
- .textfield-wrapper.disabled .textfield-container {
218
- border-color: var(--rel-color-border-default);
219
- }
220
-
221
- /* Supporting text */
222
- .textfield-support {
223
- font-size: var(--rel-fontSizes-xs);
224
- color: var(--rel-color-text-placeholder);
225
- }
226
-
227
- .textfield-support.error {
228
- color: var(--rel-error-surface-default, #CC0000);
229
- }
230
-
231
- /* Placeholder */
232
- .textfield-input::placeholder {
233
- color: var(--rel-color-text-placeholder);
234
- }
235
-
236
- :host([floatinglabel]) .textfield-input::placeholder {
237
- opacity: 0;
238
- }
239
-
240
- :host([floatinglabel]) .textfield-wrapper.focused .textfield-input::placeholder {
241
- opacity: 1;
242
- }
243
-
244
- /* Suppress Chrome autofill blue background */
245
- .textfield-input:-webkit-autofill,
246
- .textfield-input:-webkit-autofill:hover,
247
- .textfield-input:-webkit-autofill:focus {
248
- -webkit-box-shadow: 0 0 0px 1000px var(--rel-color-bg-primary) inset;
249
- -webkit-text-fill-color: var(--rel-color-text-primary);
250
- }
251
-
252
- /* ── Underline variant ── */
253
- :host([variant="underline"]) .textfield-container {
254
- border: none;
255
- border-bottom: 2px solid var(--rel-color-border-default);
256
- border-radius: 0;
257
- background: var(--rel-color-bg-tertiary, #f5f5f5);
258
- box-shadow: none;
259
- overflow: visible;
260
- }
261
-
262
- :host([variant="underline"]) .textfield-wrapper.focused .textfield-container,
263
- :host([variant="underline"]) .textfield-container:focus-within {
264
- border-bottom: 2px solid var(--rel-color-text-primary);
265
- box-shadow: none;
266
- }
267
-
268
- :host([variant="underline"]) .textfield-wrapper.error .textfield-container {
269
- border-bottom: 2px solid var(--rel-error-surface-default, #CC0000);
270
- box-shadow: none;
271
- }
272
-
273
- :host([variant="underline"]) .textfield-wrapper.disabled .textfield-container {
274
- border-bottom: 2px solid var(--rel-color-border-default);
275
- background: var(--rel-color-bg-tertiary);
276
- }
277
-
278
- /* Remove border radius on input for underline variant */
279
- :host([variant="underline"]) .textfield-input {
280
- background: transparent;
281
- }
282
-
283
- /* Dark mode — underline variant uses same border colors as default */
284
- :host-context([data-theme="dark"]) :host([variant="underline"]) .textfield-container,
285
- :host-context(.dark) :host([variant="underline"]) .textfield-container {
286
- background: var(--rel-color-bg-tertiary);
287
- border-bottom-color: var(--rel-color-border-default);
288
- }
289
- `;
290
-
291
- connectedCallback() {
292
- super.connectedCallback();
293
- const leftIcon = this.getAttribute('lefticon') || this.getAttribute('leftIcon');
294
- if (leftIcon) this.leftIcon = leftIcon;
295
- const rightIcon = this.getAttribute('righticon') || this.getAttribute('rightIcon');
296
- if (rightIcon) this.rightIcon = rightIcon;
297
- }
298
-
299
- private handleFocus() {
300
- if (this.state !== "disabled") {
301
- this.internalState = "focused";
302
- }
303
- }
304
-
305
- private handleBlur() {
306
- if (this.internalState === "focused") {
307
- this.internalState = "default";
308
- }
309
- }
310
-
311
- private handleInput(e: Event) {
312
- const input = e.target as HTMLInputElement;
313
- this.value = input.value;
314
- if (this.state === "error" && this.value) {
315
- this._wasError = true;
316
- this.state = "default";
317
- } else if (this._wasError && !this.value) {
318
- this._wasError = false;
319
- this.state = "error";
320
- }
321
- this.dispatchEvent(
322
- new CustomEvent<string>("rel-change", {
323
- detail: this.value,
324
- bubbles: true,
325
- composed: true,
326
- })
327
- );
328
- }
329
-
330
- render() {
331
- const wrapperClasses = [
332
- "textfield-wrapper",
333
- this.size,
334
- this.state,
335
- this.internalState === "focused" ? "focused" : "",
336
- this.value ? "has-value" : "",
337
- ].filter(Boolean).join(" ");
338
-
339
- const inputClasses = [
340
- "textfield-input",
341
- this.leftIcon ? "has-left-icon" : "",
342
- this.rightIcon ? "has-right-icon" : "",
343
- ]
344
- .filter(Boolean)
345
- .join(" ");
346
-
347
- return html`
348
- <div class="${wrapperClasses}">
349
- ${!this.floatingLabel && this.label
350
- ? html`<label class="textfield-label" for="${this.uniqueId}">${this.label}</label>`
351
- : ""}
352
-
353
- <div
354
- class="textfield-container ${this.state === "disabled"
355
- ? "disabled"
356
- : ""}"
357
- >
358
- ${this.floatingLabel && this.label
359
- ? html`<label
360
- class="textfield-label${this.leftIcon ? ' has-left-icon' : ''}"
361
- for="${this.uniqueId}">${this.label}</label>`
362
- : ""}
363
- ${this.leftIcon
364
- ? html`<rel-icon name="${this.leftIcon}" size="sm" class="icon left"></rel-icon>`
365
- : ""}
366
-
367
- <input
368
- type="${this.type}"
369
- id="${this.uniqueId}"
370
- class="${inputClasses}"
371
- placeholder="${this.placeholder}"
372
- .value="${this.value}"
373
- ?disabled="${this.state === "disabled"}"
374
- aria-invalid="${this.state === "error"}"
375
- @focus="${this.handleFocus}"
376
- @blur="${this.handleBlur}"
377
- @input="${this.handleInput}"
378
- />
379
-
380
- ${this.rightIcon
381
- ? html`<rel-icon name="${this.rightIcon}" size="sm" class="icon right"></rel-icon>`
382
- : ""}
383
- </div>
384
-
385
- ${this.supportingText
386
- ? html`<div
387
- class="textfield-support ${this.state === "error"
388
- ? "error"
389
- : ""}"
390
- >
391
- ${this.supportingText}
392
- </div>`
393
- : ""}
394
- </div>
395
- `;
396
- }
397
- }
1
+ import { LitElement, html, css, nothing } from "lit";
2
+ import { customElement, property, state } from "lit/decorators.js";
3
+ import "../../styles/vars-v2.css";
4
+ import "@reliability-design/icons/rel-icon";
5
+
6
+ export type TextFieldState = "default" | "error" | "disabled";
7
+ export type TextFieldSize = "small" | "medium" | "large";
8
+ export type TextFieldVariant = "default" | "underline" | "legend";
9
+
10
+ @customElement("rel-textfield")
11
+ export class RelTextfield extends LitElement {
12
+ @property({ type: String }) label?: string;
13
+ @property({ type: String }) placeholder: string = "";
14
+ @property({ type: String }) type: string = "text";
15
+ @property({ type: String }) value: string = "";
16
+ @property({ type: String }) leftIcon?: string;
17
+ @property({ type: String }) rightIcon?: string;
18
+ @property({ type: String }) supportingText?: string;
19
+ @property({ type: String }) state: TextFieldState = "default";
20
+ @property({ type: String }) size: TextFieldSize = "medium";
21
+ @property({ type: Boolean, reflect: true }) floatingLabel = false;
22
+ @property({ type: String, reflect: true }) variant: TextFieldVariant = "default";
23
+ @property({ type: Boolean }) disabled = false;
24
+ @property({ type: Boolean }) required = false;
25
+ @property({ type: Boolean, reflect: true }) multiline = false;
26
+ @property({ type: Number }) rows = 3;
27
+
28
+ // ── Standardized input API (senior-team spec) ──────────────────────────────
29
+ // Prefer these over the deprecated aliases: prefixIcon (was leftIcon),
30
+ // helperText (was supportingText), error (was state="error").
31
+ @property({ type: String }) prefixIcon?: string;
32
+ @property({ type: String }) prefixIconAriaLabel?: string;
33
+ @property({ type: Boolean }) error = false;
34
+ @property({ type: String }) errorMessage?: string;
35
+ @property({ type: String }) helperText?: string;
36
+ @property({ type: Boolean }) readonly = false;
37
+ @property({ type: Number }) maxLength?: number;
38
+ @property({ type: String }) name?: string;
39
+ @property({ type: Boolean, reflect: true }) fullWidth = true;
40
+
41
+ @state() private internalState: "default" | "focused" = "default";
42
+ private _wasError = false;
43
+
44
+ private uniqueId = `textfield-${Math.random().toString(36).slice(2)}`;
45
+
46
+ private get isDisabled() {
47
+ return this.disabled || this.state === "disabled";
48
+ }
49
+
50
+ private get isError() {
51
+ return this.error || this.state === "error";
52
+ }
53
+
54
+ // prefixIcon supersedes the deprecated leftIcon
55
+ private get leadingIcon() {
56
+ return this.prefixIcon ?? this.leftIcon;
57
+ }
58
+
59
+ // helperText supersedes the deprecated supportingText
60
+ private get helper() {
61
+ return this.helperText ?? this.supportingText;
62
+ }
63
+
64
+ static styles = css`
65
+ :host {
66
+ display: block;
67
+ font-family: var(--rel-fontFamilies-primary);
68
+ }
69
+
70
+ /* Wrapper */
71
+ .textfield-wrapper {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: var(--rel-spacing-space-2xs);
75
+ width: 100%;
76
+ }
77
+
78
+ /* Label */
79
+ .textfield-label {
80
+ font-size: var(--rel-fontSizes-sm);
81
+ font-weight: var(--rel-fontWeights-medium);
82
+ color: var(--rel-color-text-heading);
83
+ }
84
+
85
+ .required-mark {
86
+ color: var(--rel-error-surface-default, #CC0000);
87
+ margin-left: 2px;
88
+ }
89
+
90
+ /* Floating label variant */
91
+ :host([floatinglabel]) .textfield-wrapper {
92
+ gap: 0;
93
+ }
94
+
95
+ :host([floatinglabel]) .textfield-container {
96
+ overflow: visible;
97
+ position: relative;
98
+ padding-top: 0;
99
+ }
100
+
101
+ :host([floatinglabel]) .textfield-label {
102
+ position: absolute;
103
+ left: var(--rel-spacing-space-sm);
104
+ top: 50%;
105
+ transform: translateY(-50%);
106
+ font-size: var(--rel-fontSizes-md);
107
+ font-weight: var(--rel-fontWeights-regular);
108
+ color: var(--rel-color-text-placeholder);
109
+ pointer-events: none;
110
+ transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease, transform 0.15s ease;
111
+ background: var(--rel-color-bg-primary);
112
+ padding: 0 2px;
113
+ z-index: 1;
114
+ line-height: 1;
115
+ white-space: nowrap;
116
+ }
117
+
118
+ /* Floating label in multiline mode anchors to top instead of vertical center */
119
+ :host([floatinglabel][multiline]) .textfield-label {
120
+ top: var(--rel-spacing-space-sm);
121
+ transform: none;
122
+ }
123
+
124
+ :host([floatinglabel]) .textfield-wrapper.focused .textfield-label,
125
+ :host([floatinglabel]) .textfield-wrapper.has-value .textfield-label {
126
+ top: 0;
127
+ transform: translateY(-50%);
128
+ font-size: var(--rel-fontSizes-xs);
129
+ color: var(--rel-color-text-primary);
130
+ }
131
+
132
+ :host([floatinglabel]) .textfield-wrapper.error .textfield-label {
133
+ color: var(--rel-error-text);
134
+ }
135
+
136
+ :host([floatinglabel]) .textfield-wrapper.focused:not(.error) .textfield-label {
137
+ color: var(--rel-color-text-primary);
138
+ }
139
+
140
+ :host([floatinglabel]) .textfield-wrapper.focused.error .textfield-label,
141
+ :host([floatinglabel]) .textfield-wrapper.has-value.error .textfield-label {
142
+ top: 0;
143
+ transform: translateY(-50%);
144
+ font-size: var(--rel-fontSizes-xs);
145
+ color: var(--rel-error-text);
146
+ }
147
+
148
+ :host([floatinglabel]) .textfield-input {
149
+ padding-top: var(--rel-spacing-space-sm);
150
+ padding-bottom: var(--rel-spacing-space-sm);
151
+ }
152
+
153
+ :host([floatinglabel]) .textfield-input.has-left-icon {
154
+ padding-left: var(--rel-spacing-space-2xl);
155
+ }
156
+
157
+ :host([floatinglabel]) .textfield-label.has-left-icon {
158
+ left: var(--rel-spacing-space-2xl);
159
+ }
160
+
161
+ :host([floatinglabel]) .textfield-wrapper.focused .textfield-label.has-left-icon,
162
+ :host([floatinglabel]) .textfield-wrapper.has-value .textfield-label.has-left-icon {
163
+ left: var(--rel-spacing-space-sm);
164
+ }
165
+
166
+ :host([floatinglabel]) .textfield-wrapper.disabled .textfield-label {
167
+ background: var(--rel-color-bg-tertiary);
168
+ }
169
+
170
+ /* Container */
171
+ .textfield-container {
172
+ display: flex;
173
+ align-items: center;
174
+ position: relative;
175
+ border: 1px solid var(--rel-color-border-default);
176
+ border-radius: var(--rel-borders-radius-border-radius-100);
177
+ background: var(--rel-color-bg-primary);
178
+ overflow: hidden;
179
+ transition: border 0.2s;
180
+ }
181
+
182
+ .textfield-container.disabled {
183
+ background: var(--rel-color-bg-tertiary);
184
+ cursor: not-allowed;
185
+ }
186
+
187
+ .textfield-container.multiline {
188
+ overflow: visible;
189
+ align-items: flex-start;
190
+ }
191
+
192
+ /* Input */
193
+ .textfield-input {
194
+ flex: 1;
195
+ border: none;
196
+ outline: none;
197
+ font-size: var(--rel-fontSizes-md);
198
+ padding: var(--rel-spacing-space-xs) var(--rel-spacing-space-sm);
199
+ background: transparent;
200
+ font-family: var(--rel-fontFamilies-primary);
201
+ color: var(--rel-color-text-primary);
202
+ }
203
+
204
+ .textfield-input:disabled {
205
+ cursor: not-allowed;
206
+ color: var(--rel-color-text-disabled);
207
+ }
208
+
209
+ /* Textarea */
210
+ textarea.textfield-input {
211
+ resize: vertical;
212
+ min-height: 80px;
213
+ width: 100%;
214
+ box-sizing: border-box;
215
+ }
216
+
217
+ textarea.textfield-input::-webkit-scrollbar {
218
+ width: 4px;
219
+ }
220
+ textarea.textfield-input::-webkit-scrollbar-track {
221
+ background: transparent;
222
+ }
223
+ textarea.textfield-input::-webkit-scrollbar-thumb {
224
+ background: var(--rel-color-text-placeholder);
225
+ border-radius: 2px;
226
+ }
227
+
228
+ .icon {
229
+ position: absolute;
230
+ display: flex;
231
+ pointer-events: none;
232
+ color: var(--rel-color-text-placeholder);
233
+ }
234
+
235
+ .icon.left {
236
+ left: 8px;
237
+ }
238
+
239
+ .icon.right {
240
+ right: 8px;
241
+ }
242
+
243
+ .textfield-wrapper.small .textfield-input {
244
+ padding: var(--rel-spacing-space-xs) var(--rel-spacing-space-sm);
245
+ font-size: var(--rel-fontSizes-sm);
246
+ }
247
+
248
+ .textfield-wrapper.medium .textfield-input {
249
+ padding: var(--rel-spacing-space-sm) var(--rel-spacing-space-md);
250
+ font-size: var(--rel-fontSizes-md);
251
+ }
252
+
253
+ .textfield-wrapper.large .textfield-input {
254
+ padding: var(--rel-spacing-space-md) var(--rel-spacing-space-md);
255
+ font-size: var(--rel-fontSizes-lg);
256
+ }
257
+
258
+ /* Adjust padding when icons are present */
259
+ .textfield-wrapper .textfield-input.has-left-icon {
260
+ padding-left: var(--rel-spacing-space-2xl);
261
+ }
262
+
263
+ .textfield-wrapper .textfield-input.has-right-icon {
264
+ padding-right: var(--rel-spacing-space-2xl);
265
+ }
266
+
267
+ /* States */
268
+ .textfield-wrapper.focused .textfield-container {
269
+ border-color: var(--rel-color-text-primary);
270
+ }
271
+
272
+ .textfield-input:focus-visible {
273
+ outline: none;
274
+ }
275
+
276
+ .textfield-wrapper.focused .textfield-container,
277
+ .textfield-container:focus-within {
278
+ border-color: var(--rel-color-text-primary);
279
+ box-shadow: 0 0 0 1px var(--rel-color-text-primary);
280
+ }
281
+
282
+ .textfield-wrapper.error .textfield-container,
283
+ .textfield-wrapper.error .textfield-container:focus-within {
284
+ border-color: var(--rel-error-surface-default);
285
+ box-shadow: none;
286
+ }
287
+
288
+ .textfield-wrapper.disabled .textfield-container {
289
+ border-color: var(--rel-color-border-default);
290
+ }
291
+
292
+ .textfield-wrapper.disabled .textfield-label {
293
+ color: var(--rel-color-text-disabled);
294
+ }
295
+
296
+ /* Supporting text */
297
+ .textfield-support {
298
+ font-size: var(--rel-fontSizes-xs);
299
+ color: var(--rel-color-text-placeholder);
300
+ }
301
+
302
+ .textfield-support.error {
303
+ color: var(--rel-error-surface-default, #CC0000);
304
+ }
305
+
306
+ /* Placeholder */
307
+ .textfield-input::placeholder {
308
+ color: var(--rel-color-text-placeholder);
309
+ }
310
+
311
+ :host([floatinglabel]) .textfield-input::placeholder {
312
+ opacity: 0;
313
+ }
314
+
315
+ :host([floatinglabel]) .textfield-wrapper.focused .textfield-input::placeholder {
316
+ opacity: 1;
317
+ }
318
+
319
+ /* Suppress Chrome autofill blue background */
320
+ .textfield-input:-webkit-autofill,
321
+ .textfield-input:-webkit-autofill:hover,
322
+ .textfield-input:-webkit-autofill:focus {
323
+ -webkit-box-shadow: 0 0 0px 1000px var(--rel-color-bg-primary) inset;
324
+ -webkit-text-fill-color: var(--rel-color-text-primary);
325
+ }
326
+
327
+ /* ── Underline variant ── */
328
+ :host([variant="underline"]) .textfield-container {
329
+ border: none;
330
+ border-bottom: 2px solid var(--rel-color-border-default);
331
+ border-radius: 0;
332
+ background: var(--rel-color-bg-tertiary, #f5f5f5);
333
+ box-shadow: none;
334
+ overflow: visible;
335
+ }
336
+
337
+ :host([variant="underline"]) .textfield-wrapper.focused .textfield-container,
338
+ :host([variant="underline"]) .textfield-container:focus-within {
339
+ border-bottom: 2px solid var(--rel-color-text-primary);
340
+ box-shadow: none;
341
+ }
342
+
343
+ :host([variant="underline"]) .textfield-wrapper.error .textfield-container {
344
+ border-bottom: 2px solid var(--rel-error-surface-default, #CC0000);
345
+ box-shadow: none;
346
+ }
347
+
348
+ :host([variant="underline"]) .textfield-wrapper.disabled .textfield-container {
349
+ border-bottom: 2px solid var(--rel-color-border-default);
350
+ background: var(--rel-color-bg-tertiary);
351
+ }
352
+
353
+ /* Remove border radius on input for underline variant */
354
+ :host([variant="underline"]) .textfield-input {
355
+ background: transparent;
356
+ }
357
+
358
+ /* Dark mode — underline variant uses same border colors as default */
359
+ :host-context([data-theme="dark"]) :host([variant="underline"]) .textfield-container,
360
+ :host-context(.dark) :host([variant="underline"]) .textfield-container {
361
+ background: var(--rel-color-bg-tertiary);
362
+ border-bottom-color: var(--rel-color-border-default);
363
+ }
364
+
365
+ /* ── Full-width control (fullWidth defaults true → block, fills parent) ── */
366
+ :host(:not([fullwidth])) {
367
+ display: inline-block;
368
+ width: auto;
369
+ }
370
+ :host(:not([fullwidth])) .textfield-wrapper {
371
+ width: auto;
372
+ }
373
+
374
+ /* ── Legend (notched-outline) variant ──
375
+ Native <fieldset>/<legend>: the legend notches the top border and stays
376
+ fixed in place across all states (no float/animation). */
377
+ :host([variant="legend"]) .field-fieldset {
378
+ position: relative;
379
+ margin: 0;
380
+ min-inline-size: 0;
381
+ padding: 0;
382
+ border: 1px solid var(--rel-color-border-default);
383
+ border-radius: var(--rel-borders-radius-border-radius-100);
384
+ background: var(--rel-color-bg-primary);
385
+ transition: border-color 0.2s;
386
+ }
387
+
388
+ :host([variant="legend"]) .field-legend {
389
+ margin-left: var(--rel-spacing-space-sm);
390
+ padding: 0 var(--rel-spacing-space-2xs);
391
+ font-size: var(--rel-fontSizes-sm);
392
+ font-weight: var(--rel-fontWeights-medium);
393
+ line-height: 1;
394
+ color: var(--rel-color-text-heading);
395
+ }
396
+
397
+ :host([variant="legend"]) .textfield-input {
398
+ width: 100%;
399
+ box-sizing: border-box;
400
+ }
401
+
402
+ /* Prefix icon (legend): vertically centered; input inset via has-left-icon */
403
+ :host([variant="legend"]) .icon.left {
404
+ top: 50%;
405
+ left: var(--rel-spacing-space-sm);
406
+ transform: translateY(-50%);
407
+ }
408
+ :host([variant="legend"]) .field-fieldset.multiline .icon.left {
409
+ top: var(--rel-spacing-space-sm);
410
+ transform: none;
411
+ }
412
+
413
+ /* Hover */
414
+ :host([variant="legend"]) .field-fieldset:hover {
415
+ border-color: var(--rel-color-text-placeholder);
416
+ }
417
+
418
+ /* Focus — border-color only. A box-shadow/outline ring hugs the full
419
+ rectangular border-box and would draw across the notch, appearing to
420
+ strike through the legend. The legend also recolors on focus (below). */
421
+ :host([variant="legend"]) .textfield-wrapper.focused .field-fieldset,
422
+ :host([variant="legend"]) .field-fieldset:focus-within {
423
+ border-color: var(--rel-color-border-focus);
424
+ box-shadow: none;
425
+ }
426
+ :host([variant="legend"]) .textfield-wrapper.focused .field-legend,
427
+ :host([variant="legend"]) .field-fieldset:focus-within .field-legend {
428
+ color: var(--rel-color-border-focus);
429
+ }
430
+
431
+ /* Error */
432
+ :host([variant="legend"]) .textfield-wrapper.error .field-fieldset {
433
+ border-color: var(--rel-error-surface-default);
434
+ box-shadow: none;
435
+ }
436
+ :host([variant="legend"]) .textfield-wrapper.error .field-legend {
437
+ color: var(--rel-error-surface-default);
438
+ }
439
+
440
+ /* Disabled */
441
+ :host([variant="legend"]) .textfield-wrapper.disabled .field-fieldset {
442
+ background: var(--rel-color-bg-tertiary);
443
+ border-color: var(--rel-color-border-default);
444
+ cursor: not-allowed;
445
+ }
446
+ :host([variant="legend"]) .textfield-wrapper.disabled .field-legend {
447
+ color: var(--rel-color-text-disabled);
448
+ }
449
+
450
+ /* Readonly */
451
+ :host([variant="legend"]) .textfield-wrapper.readonly .field-fieldset {
452
+ cursor: default;
453
+ }
454
+
455
+ /* Legend font tracks size */
456
+ :host([variant="legend"]) .textfield-wrapper.small .field-legend {
457
+ font-size: var(--rel-fontSizes-xs);
458
+ }
459
+ :host([variant="legend"]) .textfield-wrapper.large .field-legend {
460
+ font-size: var(--rel-fontSizes-md);
461
+ }
462
+ `;
463
+
464
+ connectedCallback() {
465
+ super.connectedCallback();
466
+ const leftIcon = this.getAttribute('lefticon') || this.getAttribute('leftIcon');
467
+ if (leftIcon) this.leftIcon = leftIcon;
468
+ const rightIcon = this.getAttribute('righticon') || this.getAttribute('rightIcon');
469
+ if (rightIcon) this.rightIcon = rightIcon;
470
+ }
471
+
472
+ private handleFocus() {
473
+ if (!this.isDisabled) {
474
+ this.internalState = "focused";
475
+ }
476
+ }
477
+
478
+ private handleBlur() {
479
+ if (this.internalState === "focused") {
480
+ this.internalState = "default";
481
+ }
482
+ }
483
+
484
+ private handleInput(e: Event) {
485
+ const input = e.target as HTMLInputElement;
486
+ this.value = input.value;
487
+ if (this.state === "error" && this.value) {
488
+ this._wasError = true;
489
+ this.state = "default";
490
+ } else if (this._wasError && !this.value) {
491
+ this._wasError = false;
492
+ this.state = "error";
493
+ }
494
+ this.dispatchEvent(
495
+ new CustomEvent<string>("rel-change", {
496
+ detail: this.value,
497
+ bubbles: true,
498
+ composed: true,
499
+ })
500
+ );
501
+ }
502
+
503
+ render() {
504
+ const isError = this.isError;
505
+ const stateClass = this.isDisabled ? "disabled" : isError ? "error" : "default";
506
+ const leadingIcon = this.leadingIcon;
507
+ const inputId = this.id || this.uniqueId;
508
+ const legendId = `${inputId}-legend`;
509
+
510
+ // errorMessage replaces the helper only when in error AND explicitly set;
511
+ // otherwise the (deprecated) supportingText/helperText shows as before.
512
+ const belowText = isError && this.errorMessage ? this.errorMessage : this.helper;
513
+ const describedById = belowText ? `${inputId}-desc` : undefined;
514
+
515
+ const wrapperClasses = [
516
+ "textfield-wrapper",
517
+ this.size,
518
+ stateClass,
519
+ this.readonly ? "readonly" : "",
520
+ this.internalState === "focused" ? "focused" : "",
521
+ this.value ? "has-value" : "",
522
+ ].filter(Boolean).join(" ");
523
+
524
+ const inputClasses = [
525
+ "textfield-input",
526
+ !this.multiline && leadingIcon ? "has-left-icon" : "",
527
+ !this.multiline && this.rightIcon && this.variant !== "legend" ? "has-right-icon" : "",
528
+ ]
529
+ .filter(Boolean)
530
+ .join(" ");
531
+
532
+ const labelTemplate = (extraClass = "") => {
533
+ if (!this.label) return "";
534
+ return html`<label
535
+ class="textfield-label${extraClass}"
536
+ for="${inputId}"
537
+ >${this.label}${this.required ? html`<span class="required-mark" aria-hidden="true">*</span>` : ""}</label>`;
538
+ };
539
+
540
+ const ariaLabelledby = this.variant === "legend" && this.label ? legendId : undefined;
541
+
542
+ const inputTemplate = this.multiline
543
+ ? html`<textarea
544
+ id="${inputId}"
545
+ class="${inputClasses}"
546
+ placeholder="${this.placeholder}"
547
+ .value="${this.value}"
548
+ rows="${this.rows}"
549
+ name="${this.name ?? nothing}"
550
+ maxlength="${this.maxLength ?? nothing}"
551
+ ?disabled="${this.isDisabled}"
552
+ ?readonly="${this.readonly}"
553
+ ?required="${this.required}"
554
+ aria-invalid="${isError}"
555
+ aria-required="${this.required}"
556
+ aria-labelledby="${ariaLabelledby ?? nothing}"
557
+ aria-describedby="${describedById ?? nothing}"
558
+ @focus="${this.handleFocus}"
559
+ @blur="${this.handleBlur}"
560
+ @input="${this.handleInput}"
561
+ ></textarea>`
562
+ : html`<input
563
+ type="${this.type}"
564
+ id="${inputId}"
565
+ class="${inputClasses}"
566
+ placeholder="${this.placeholder}"
567
+ .value="${this.value}"
568
+ name="${this.name ?? nothing}"
569
+ maxlength="${this.maxLength ?? nothing}"
570
+ ?disabled="${this.isDisabled}"
571
+ ?readonly="${this.readonly}"
572
+ ?required="${this.required}"
573
+ aria-invalid="${isError}"
574
+ aria-required="${this.required}"
575
+ aria-labelledby="${ariaLabelledby ?? nothing}"
576
+ aria-describedby="${describedById ?? nothing}"
577
+ @focus="${this.handleFocus}"
578
+ @blur="${this.handleBlur}"
579
+ @input="${this.handleInput}"
580
+ />`;
581
+
582
+ const leadingIconTemplate = leadingIcon
583
+ ? html`<rel-icon
584
+ name="${leadingIcon}"
585
+ size="sm"
586
+ class="icon left"
587
+ role="${this.prefixIconAriaLabel ? "img" : nothing}"
588
+ aria-label="${this.prefixIconAriaLabel ?? nothing}"
589
+ aria-hidden="${this.prefixIconAriaLabel ? nothing : "true"}"
590
+ ></rel-icon>`
591
+ : "";
592
+
593
+ const belowTemplate = belowText
594
+ ? html`<div class="textfield-support ${isError ? "error" : ""}" id="${describedById}">
595
+ ${belowText}
596
+ </div>`
597
+ : "";
598
+
599
+ if (this.variant === "legend") {
600
+ const fieldsetClass = ["field-fieldset", this.multiline ? "multiline" : ""].filter(Boolean).join(" ");
601
+ // The <legend> MUST be the fieldset's first child with no preceding
602
+ // whitespace/text or binding-comment, otherwise the browser won't cut the
603
+ // notch in the top border. So it's rendered flush against the tag open and
604
+ // is always present (only its content is dynamic).
605
+ return html`
606
+ <div class="${wrapperClasses}">
607
+ <fieldset class="${fieldsetClass}"><legend class="field-legend" id="${legendId}">${this.label ?? ""}${this.required ? html`<span class="required-mark" aria-hidden="true">*</span>` : ""}</legend>
608
+ ${!this.multiline ? leadingIconTemplate : ""}
609
+ ${inputTemplate}
610
+ </fieldset>
611
+ ${belowTemplate}
612
+ </div>
613
+ `;
614
+ }
615
+
616
+ return html`
617
+ <div class="${wrapperClasses}">
618
+ ${!this.floatingLabel ? labelTemplate() : ""}
619
+
620
+ <div class="${["textfield-container", this.isDisabled ? "disabled" : "", this.multiline ? "multiline" : ""].filter(Boolean).join(" ")}">
621
+ ${this.floatingLabel
622
+ ? labelTemplate(leadingIcon && !this.multiline ? " has-left-icon" : "")
623
+ : ""}
624
+ ${!this.multiline ? leadingIconTemplate : ""}
625
+
626
+ ${inputTemplate}
627
+
628
+ ${!this.multiline && this.rightIcon
629
+ ? html`<rel-icon name="${this.rightIcon}" size="sm" class="icon right"></rel-icon>`
630
+ : ""}
631
+ </div>
632
+
633
+ ${belowTemplate}
634
+ </div>
635
+ `;
636
+ }
637
+ }