@reliability-design/web 1.4.4 → 1.4.6

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.
Files changed (58) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-alert/rel-alert.ts +1 -1
  3. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  4. package/dist/components/rel-badge/rel-badge.ts +6 -1
  5. package/dist/components/rel-button/rel-button.ts +18 -2
  6. package/dist/components/rel-calendar/rel-calendar.ts +86 -18
  7. package/dist/components/rel-carousel/rel-carousel.ts +45 -2
  8. package/dist/components/rel-checkbox/rel-checkbox.ts +4 -1
  9. package/dist/components/rel-chip/rel-chip.ts +59 -19
  10. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  11. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  12. package/dist/components/rel-date-picker/rel-date-picker.ts +675 -93
  13. package/dist/components/rel-drawer/rel-drawer.ts +28 -61
  14. package/dist/components/rel-menu/rel-menu.ts +130 -15
  15. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  16. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  17. package/dist/components/rel-otp/rel-otp.ts +14 -4
  18. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  19. package/dist/components/rel-popover/rel-popover.ts +64 -5
  20. package/dist/components/rel-progress/rel-progress.ts +13 -4
  21. package/dist/components/rel-radio/README.md +6 -3
  22. package/dist/components/rel-radio/rel-radio.ts +236 -60
  23. package/dist/components/rel-range-picker/rel-range-picker.ts +581 -91
  24. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  25. package/dist/components/rel-segmented-button/rel-segmented-button.ts +55 -13
  26. package/dist/components/rel-select/rel-select.ts +203 -24
  27. package/dist/components/rel-sheet/rel-sheet.ts +184 -18
  28. package/dist/components/rel-sidebar/rel-sidebar.ts +50 -40
  29. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  30. package/dist/components/rel-slider/rel-slider.ts +6 -3
  31. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  32. package/dist/components/rel-stepper/rel-stepper.ts +47 -2
  33. package/dist/components/rel-switch/rel-switch.ts +3 -1
  34. package/dist/components/rel-tab/rel-tab.ts +32 -7
  35. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  36. package/dist/components/rel-time-picker/rel-time-picker.ts +749 -261
  37. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  38. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  39. package/dist/components/rel-toast/rel-toast.ts +4 -2
  40. package/dist/components/rel-tooltip/README.md +13 -5
  41. package/dist/components/rel-tooltip/rel-tooltip.ts +33 -0
  42. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  43. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  44. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  45. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  46. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  47. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  48. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  49. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  50. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  51. package/dist/components/rel-upload/rel-upload.ts +25 -0
  52. package/dist/index.d.mts +461 -23
  53. package/dist/index.d.ts +461 -23
  54. package/dist/index.js +3906 -807
  55. package/dist/index.js.map +1 -1
  56. package/dist/index.mjs +4143 -1055
  57. package/dist/index.mjs.map +1 -1
  58. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { LitElement, html, css } from "lit";
1
+ import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property } from "lit/decorators.js";
3
3
 
4
4
  export type StepStatus = "done" | "progress" | "disabled";
@@ -79,6 +79,17 @@ export class RelStepper extends LitElement {
79
79
  .rel-step {
80
80
  display: flex;
81
81
  position: relative;
82
+ cursor: pointer;
83
+ }
84
+
85
+ .rel-step.disabled {
86
+ cursor: not-allowed;
87
+ }
88
+
89
+ .rel-step:focus-visible {
90
+ outline: 2px solid var(--rel-border-focus);
91
+ outline-offset: 2px;
92
+ border-radius: var(--rel-border-radius-xs);
82
93
  }
83
94
 
84
95
  .rel-stepper.horizontal.text-below .rel-step {
@@ -402,6 +413,33 @@ export class RelStepper extends LitElement {
402
413
  return classes.join(" ");
403
414
  }
404
415
 
416
+ private onStepKeyDown(e: KeyboardEvent, step: Step, index: number) {
417
+ if (e.key === "Enter" || e.key === " ") {
418
+ e.preventDefault();
419
+ this.onStepClick(step, index);
420
+ return;
421
+ }
422
+
423
+ const prevKey = e.key === "ArrowLeft" || e.key === "ArrowUp";
424
+ const nextKey = e.key === "ArrowRight" || e.key === "ArrowDown";
425
+ if (!prevKey && !nextKey && e.key !== "Home" && e.key !== "End") return;
426
+
427
+ const steps = Array.from(
428
+ this.renderRoot.querySelectorAll<HTMLElement>(".rel-step:not(.disabled)")
429
+ );
430
+ if (steps.length === 0) return;
431
+
432
+ e.preventDefault();
433
+ const current = steps.indexOf(e.currentTarget as HTMLElement);
434
+ let target: number;
435
+ if (e.key === "Home") target = 0;
436
+ else if (e.key === "End") target = steps.length - 1;
437
+ else if (prevKey) target = (current - 1 + steps.length) % steps.length;
438
+ else target = (current + 1) % steps.length;
439
+
440
+ steps[target]?.focus();
441
+ }
442
+
405
443
  private onStepClick(step: Step, index: number) {
406
444
  if (step.status === "disabled") return;
407
445
 
@@ -429,6 +467,7 @@ export class RelStepper extends LitElement {
429
467
  viewBox="0 0 18 14"
430
468
  fill="none"
431
469
  class="check-icon"
470
+ aria-hidden="true"
432
471
  >
433
472
  <path
434
473
  d="M6.44479 14L0.5 7.36383L1.9862 5.70478L6.44479 10.6819L16.0138 0L17.5 1.65904L6.44479 14Z"
@@ -460,12 +499,18 @@ export class RelStepper extends LitElement {
460
499
  const isTextRight = placement !== "inverted";
461
500
 
462
501
  return html`
463
- <div class="${stepperClasses}">
502
+ <div class="${stepperClasses}" role="group" aria-label="Progress steps">
464
503
  ${this.steps.map(
465
504
  (step, idx) => html`
466
505
  <div
467
506
  class="${this.getStepClasses(step)}"
507
+ role="button"
508
+ tabindex="${this.isDisabled(step) ? -1 : 0}"
509
+ aria-label="${step.title}, step ${idx + 1} of ${this.steps.length}${this.isDone(step) ? ", completed" : ""}"
510
+ aria-disabled="${this.isDisabled(step) ? "true" : nothing}"
511
+ aria-current="${this.isProgress(step) ? "step" : nothing}"
468
512
  @click="${() => this.onStepClick(step, idx)}"
513
+ @keydown="${(e: KeyboardEvent) => this.onStepKeyDown(e, step, idx)}"
469
514
  >
470
515
  ${isVertical && isTextRight
471
516
  ? html`
@@ -1,4 +1,4 @@
1
- import { LitElement, html, css } from "lit";
1
+ import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
3
 
4
4
  @customElement("rel-switch")
@@ -6,6 +6,7 @@ export class RelSwitch extends LitElement {
6
6
  @property({ type: Boolean }) checked = false;
7
7
  @property({ type: Boolean }) disabled = false;
8
8
  @property({ type: String }) value = "";
9
+ @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
9
10
 
10
11
  private _onChange: (value: boolean) => void = () => {};
11
12
  private _onTouched: () => void = () => {};
@@ -182,6 +183,7 @@ export class RelSwitch extends LitElement {
182
183
  type="checkbox"
183
184
  role="switch"
184
185
  aria-checked="${this.checked}"
186
+ aria-label="${this.fieldAriaLabel || nothing}"
185
187
  ?disabled=${this.disabled}
186
188
  .checked=${this.checked}
187
189
  @change=${(e: Event) => this.toggle(e)}
@@ -33,6 +33,9 @@ export class RelTab extends LitElement {
33
33
  size: "sm" | "md" | "lg" = "md";
34
34
  @state() private _tabItems: RelTabItem[] = [];
35
35
 
36
+ /** Stable id prefix linking tabs to the shared tabpanel. */
37
+ private tabsId = `rel-tab-${Math.random().toString(36).slice(2)}`;
38
+
36
39
  static styles = css`
37
40
  :host {
38
41
  color: var(--rel-text-default);
@@ -57,7 +60,7 @@ export class RelTab extends LitElement {
57
60
  position: relative;
58
61
  padding: var(--rel-spacing-sm) var(--rel-spacing-md);
59
62
  cursor: pointer;
60
- color: var(--rel-text-body);
63
+ color: var(--rel-text-body);
61
64
  transition: color 0.2s ease;
62
65
  display: flex;
63
66
  align-items: center;
@@ -194,14 +197,28 @@ export class RelTab extends LitElement {
194
197
  next = (next + dir + tabs.length) % tabs.length;
195
198
  if (!tabs[next]?.disabled) break;
196
199
  }
197
- this.selectTab(next);
198
- this.updateComplete.then(() => {
199
- const tabEls = this.shadowRoot?.querySelectorAll("[role=tab]");
200
- (tabEls?.[next] as HTMLElement)?.focus();
201
- });
200
+ this.selectAndFocusTab(next);
201
+ return;
202
+ }
203
+
204
+ if (event.key === "Home" || event.key === "End") {
205
+ event.preventDefault();
206
+ const tabs = this._effectiveTabs;
207
+ const order = [...tabs.keys()];
208
+ if (event.key === "End") order.reverse();
209
+ const next = order.find((i) => !tabs[i]?.disabled);
210
+ if (next !== undefined) this.selectAndFocusTab(next);
202
211
  }
203
212
  }
204
213
 
214
+ private selectAndFocusTab(index: number) {
215
+ this.selectTab(index);
216
+ this.updateComplete.then(() => {
217
+ const tabEls = this.shadowRoot?.querySelectorAll("[role=tab]");
218
+ (tabEls?.[index] as HTMLElement)?.focus();
219
+ });
220
+ }
221
+
205
222
  render() {
206
223
  const tabs = this._effectiveTabs;
207
224
  return html`
@@ -213,9 +230,11 @@ export class RelTab extends LitElement {
213
230
  part="item" class="tab ${i === this.activeIndex
214
231
  ? "active"
215
232
  : ""} ${tab.disabled ? "disabled" : ""}"
233
+ id="${this.tabsId}-tab-${i}"
216
234
  role="tab"
217
235
  aria-selected="${i === this.activeIndex}"
218
236
  aria-disabled="${tab.disabled ?? false}"
237
+ aria-controls="${this.tabsId}-panel"
219
238
  tabindex="${tab.disabled ? -1 : i === this.activeIndex ? 0 : -1}"
220
239
  @click="${() => this.selectTab(i)}"
221
240
  @keydown="${(e: KeyboardEvent) => this.onKeyDown(e, i)}"
@@ -231,7 +250,13 @@ export class RelTab extends LitElement {
231
250
  `
232
251
  )}
233
252
  </div>
234
- <div class="tab-content">
253
+ <div
254
+ class="tab-content"
255
+ id="${this.tabsId}-panel"
256
+ role="tabpanel"
257
+ aria-labelledby="${this.tabsId}-tab-${this.activeIndex}"
258
+ tabindex="0"
259
+ >
235
260
  <slot></slot>
236
261
  </div>
237
262
  `;
@@ -5,6 +5,13 @@ import "@reliability-design/icons/rel-icon";
5
5
  export type TextFieldState = "default" | "error" | "disabled";
6
6
  export type TextFieldSize = "small" | "medium" | "large";
7
7
  export type TextFieldVariant = "default" | "underline" | "legend";
8
+ /**
9
+ * How the prefix block is drawn (Figma `.Preffix` → Style):
10
+ * - `"clear"` — bare icon/text, no separator (the legacy `leftIcon` look)
11
+ * - `"divider"` — hairline between the prefix and the input
12
+ * - `"fill"` — tinted block flush with the control's left edge
13
+ */
14
+ export type TextFieldPrefixStyle = "clear" | "divider" | "fill";
8
15
 
9
16
  /**
10
17
  * How a field presents its label. Shared by `<rel-textfield>`, `<rel-select>`
@@ -29,6 +36,18 @@ export class RelTextfield extends LitElement {
29
36
  @property({ type: Boolean, attribute: "clickable-right-icon" }) clickableRightIcon = false;
30
37
  /** Accessible label for the trailing icon when it is clickable. */
31
38
  @property({ type: String, attribute: "right-icon-aria-label" }) rightIconAriaLabel?: string;
39
+ // ── Prefix / suffix labels (Figma .Preffix / .Suffix) ───────────────────────
40
+ /** Short text rendered before the input, e.g. `"http://"` or `"$"`. Sits in
41
+ * the same prefix block as `prefixIcon` and is styled by `prefixStyle`. */
42
+ @property({ type: String, attribute: "prefix-text" }) prefixText?: string;
43
+ /** Visual treatment of the prefix block — applies to the prefix icon, text
44
+ * and slotted `prefix` content alike. See `TextFieldPrefixStyle`. */
45
+ @property({ type: String, attribute: "prefix-style" }) prefixStyle: TextFieldPrefixStyle = "clear";
46
+ /** Short text rendered after the input, e.g. `"kg"` or `".00"`. Shares the
47
+ * suffix block with `rightIcon` and slotted `suffix` content. */
48
+ @property({ type: String, attribute: "suffix-text" }) suffixText?: string;
49
+ /** Draws a hairline between the input and the suffix block. */
50
+ @property({ type: Boolean, attribute: "suffix-divider" }) suffixDivider = false;
32
51
  @property({ type: String }) supportingText?: string;
33
52
  @property({ type: String }) state: TextFieldState = "default";
34
53
  @property({ type: String }) size: TextFieldSize = "medium";
@@ -69,7 +88,10 @@ export class RelTextfield extends LitElement {
69
88
  @property({ type: Boolean, reflect: true }) fullWidth = true;
70
89
 
71
90
  @state() private internalState: "default" | "focused" = "default";
91
+ /** Whether the `prefix` / `suffix` named slots currently have content. */
92
+ @state() private slotted = { prefix: false, suffix: false };
72
93
  private _wasError = false;
94
+ private _prefixObserver?: ResizeObserver;
73
95
 
74
96
  private uniqueId = `textfield-${Math.random().toString(36).slice(2)}`;
75
97
 
@@ -227,17 +249,16 @@ export class RelTextfield extends LitElement {
227
249
  padding-bottom: var(--rel-spacing-sm);
228
250
  }
229
251
 
230
- :host([floatinglabel]) .textfield-input.has-left-icon {
231
- padding-left: var(--rel-spacing-2xl);
232
- }
233
-
234
- :host([floatinglabel]) .textfield-label.has-left-icon {
235
- left: var(--rel-spacing-2xl);
236
- }
237
-
238
- :host([floatinglabel]) .textfield-wrapper.focused .textfield-label.has-left-icon,
239
- :host([floatinglabel]) .textfield-wrapper.has-value .textfield-label.has-left-icon {
240
- left: var(--rel-spacing-sm);
252
+ /* The floating label lives inside .textfield-field (the input's own flex
253
+ column), so at rest it naturally sits after any prefix block. Once
254
+ floated, a "clear" prefix (bare icon — the legacy leftIcon look) lets the
255
+ label slide back over the control's left edge, matching Figma's
256
+ "Floating Label" frame at x=12; --_rel-tf-prefix-w is the measured
257
+ prefix width (ResizeObserver). Divider/fill prefixes are solid blocks,
258
+ so the floated label stays in the field column, after them. */
259
+ :host([floatinglabel]) .textfield-wrapper.focused .prefix-clear .textfield-label,
260
+ :host([floatinglabel]) .textfield-wrapper.has-value .prefix-clear .textfield-label {
261
+ left: calc(var(--rel-spacing-sm) - var(--_rel-tf-prefix-w, 0px));
241
262
  }
242
263
 
243
264
  :host([floatinglabel]) .textfield-wrapper.disabled .textfield-label {
@@ -266,9 +287,22 @@ export class RelTextfield extends LitElement {
266
287
  align-items: flex-start;
267
288
  }
268
289
 
290
+ /* The input's own column: prefix | field | suffix. Positioned so the
291
+ floating label anchors to the input area, not the whole control. */
292
+ .textfield-field {
293
+ position: relative;
294
+ display: flex;
295
+ align-items: center;
296
+ align-self: stretch;
297
+ flex: 1;
298
+ min-width: 0;
299
+ }
300
+
269
301
  /* Input */
270
302
  .textfield-input {
271
303
  flex: 1;
304
+ min-width: 0;
305
+ width: 100%;
272
306
  border: none;
273
307
  outline: none;
274
308
  padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
@@ -305,24 +339,113 @@ export class RelTextfield extends LitElement {
305
339
  border-radius: 2px;
306
340
  }
307
341
 
308
- .icon {
309
- position: absolute;
342
+ /* ── Prefix / suffix blocks (Figma .Preffix / .Suffix) ──
343
+ Flex children of the control, stretched to its height. Each holds an
344
+ icon and/or a short text label plus a named slot for custom content
345
+ (a unit dropdown, an action button…). Colour follows Figma: text-default
346
+ for both icon and text, like rel-select's trigger icons. */
347
+ .textfield-affix {
310
348
  display: flex;
311
- pointer-events: none;
312
- color: var(--rel-text-placeholder);
349
+ align-items: center;
350
+ align-self: stretch;
351
+ flex: none;
352
+ gap: var(--rel-spacing-xs);
353
+ box-sizing: border-box;
354
+ color: var(--rel-text-default);
355
+ white-space: nowrap;
356
+ transition: border-color 0.2s, background 0.2s;
357
+ }
358
+
359
+ .textfield-affix.hidden {
360
+ display: none;
361
+ }
362
+
363
+ .textfield-affix rel-icon {
364
+ --rel-icon-size: 18px;
365
+ flex: none;
366
+ }
367
+ .textfield-wrapper.small .textfield-affix rel-icon {
368
+ --rel-icon-size: 16px;
369
+ }
370
+ .textfield-wrapper.large .textfield-affix rel-icon {
371
+ --rel-icon-size: 20px;
372
+ }
373
+
374
+ .affix-text {
375
+ font-family: var(--rel-body-md-medium-font-family);
376
+ font-weight: var(--rel-body-md-medium-font-weight);
377
+ font-size: var(--rel-body-md-medium-font-size);
378
+ line-height: var(--rel-body-md-medium-line-height);
379
+ letter-spacing: var(--rel-body-md-medium-letter-spacing);
380
+ }
381
+ .textfield-wrapper.small .affix-text {
382
+ font-family: var(--rel-body-sm-medium-font-family);
383
+ font-weight: var(--rel-body-sm-medium-font-weight);
384
+ font-size: var(--rel-body-sm-medium-font-size);
385
+ line-height: var(--rel-body-sm-medium-line-height);
386
+ letter-spacing: var(--rel-body-sm-medium-letter-spacing);
387
+ }
388
+ .textfield-wrapper.large .affix-text {
389
+ font-family: var(--rel-body-lg-medium-font-family);
390
+ font-weight: var(--rel-body-lg-medium-font-weight);
391
+ font-size: var(--rel-body-lg-medium-font-size);
392
+ line-height: var(--rel-body-lg-medium-line-height);
393
+ letter-spacing: var(--rel-body-lg-medium-letter-spacing);
394
+ }
395
+
396
+ /* Prefix — Style=Clear: bare; icon-only keeps the input's own left padding
397
+ as the gap, text pads both sides. */
398
+ .textfield-affix.prefix {
399
+ padding-inline: var(--rel-spacing-sm) 0;
400
+ }
401
+ .textfield-affix.prefix.has-text {
402
+ padding-inline: var(--rel-spacing-sm);
403
+ }
404
+ /* Style=Divider: hairline on the right */
405
+ .textfield-affix.prefix.divider {
406
+ padding-inline: var(--rel-spacing-sm);
407
+ border-right: 1px solid var(--rel-border-default);
408
+ }
409
+ /* Style=Fill: tinted block flush with the control's edge, sm gap after */
410
+ .textfield-affix.prefix.fill {
411
+ padding-inline: var(--rel-spacing-sm);
412
+ margin-right: var(--rel-spacing-sm);
413
+ background: var(--rel-bg-neutral-elevated-2);
414
+ border-radius: calc(var(--rel-border-radius-xs) - 1px) 0 0
415
+ calc(var(--rel-border-radius-xs) - 1px);
416
+ }
417
+
418
+ /* Suffix — Divider=False: pl xs / pr sm; Divider=True: hairline on the left */
419
+ .textfield-affix.suffix {
420
+ padding-inline: var(--rel-spacing-xs) var(--rel-spacing-sm);
421
+ }
422
+ .textfield-affix.suffix.divider {
423
+ padding-inline: var(--rel-spacing-sm);
424
+ border-left: 1px solid var(--rel-border-default);
313
425
  }
314
426
 
315
- .icon.left {
316
- left: 8px;
427
+ /* Size=S tightens every inline pad to xs */
428
+ .textfield-wrapper.small .textfield-affix.prefix {
429
+ padding-inline: var(--rel-spacing-xs) 0;
430
+ }
431
+ .textfield-wrapper.small .textfield-affix.prefix.has-text,
432
+ .textfield-wrapper.small .textfield-affix.prefix.divider,
433
+ .textfield-wrapper.small .textfield-affix.prefix.fill,
434
+ .textfield-wrapper.small .textfield-affix.suffix,
435
+ .textfield-wrapper.small .textfield-affix.suffix.divider {
436
+ padding-inline: var(--rel-spacing-xs);
317
437
  }
318
438
 
319
- .icon.right {
320
- right: 8px;
439
+ .textfield-wrapper.disabled .textfield-affix {
440
+ color: var(--rel-text-disabled);
441
+ }
442
+
443
+ .icon {
444
+ display: flex;
321
445
  }
322
446
 
323
447
  /* Interactive trailing icon (e.g. password show/hide) */
324
448
  .icon.right.clickable {
325
- pointer-events: auto;
326
449
  cursor: pointer;
327
450
  border-radius: var(--rel-border-radius-xs, 4px);
328
451
  }
@@ -365,15 +488,6 @@ export class RelTextfield extends LitElement {
365
488
  letter-spacing: var(--rel-body-lg-regular-letter-spacing);
366
489
  }
367
490
 
368
- /* Adjust padding when icons are present */
369
- .textfield-wrapper .textfield-input.has-left-icon {
370
- padding-left: var(--rel-spacing-2xl);
371
- }
372
-
373
- .textfield-wrapper .textfield-input.has-right-icon {
374
- padding-right: var(--rel-spacing-2xl);
375
- }
376
-
377
491
  /* States */
378
492
  /* Hover (Figma State=Hover): border-strong at 1px — no focus ring */
379
493
  .textfield-wrapper:not(.focused):not(.error):not(.disabled) .textfield-container:hover {
@@ -428,6 +542,10 @@ export class RelTextfield extends LitElement {
428
542
  color: var(--rel-text-danger);
429
543
  }
430
544
 
545
+ .textfield-support.empty {
546
+ margin-top: calc(-1 * var(--rel-spacing-2xs));
547
+ }
548
+
431
549
  /* Placeholder */
432
550
  .textfield-input::placeholder {
433
551
  color: var(--rel-text-placeholder);
@@ -536,15 +654,15 @@ export class RelTextfield extends LitElement {
536
654
  display: none;
537
655
  }
538
656
 
539
- /* Prefix icon (legend): vertically centered; input inset via has-left-icon */
540
- :host([variant="legend"]) .icon.left {
541
- top: 50%;
542
- left: var(--rel-spacing-sm);
543
- transform: translateY(-50%);
657
+ /* Legend: prefix | field | suffix sit in a flex row under the legend. The
658
+ fieldset's content box starts below the legend's midline, so a fill
659
+ prefix can't reach the top border here — square it off instead. */
660
+ :host([variant="legend"]) .field-row {
661
+ display: flex;
662
+ align-items: center;
544
663
  }
545
- :host([variant="legend"]) .field-fieldset.multiline .icon.left {
546
- top: var(--rel-spacing-sm);
547
- transform: none;
664
+ :host([variant="legend"]) .textfield-affix.prefix.fill {
665
+ border-radius: 0;
548
666
  }
549
667
 
550
668
  /* Hover */
@@ -625,6 +743,48 @@ export class RelTextfield extends LitElement {
625
743
  if (leftIcon) this.leftIcon = leftIcon;
626
744
  const rightIcon = this.getAttribute('righticon') || this.getAttribute('rightIcon');
627
745
  if (rightIcon) this.rightIcon = rightIcon;
746
+ // Seed slot presence from static markup; slotchange keeps it current after.
747
+ this.slotted = {
748
+ prefix: !!this.querySelector(':scope > [slot="prefix"]'),
749
+ suffix: !!this.querySelector(':scope > [slot="suffix"]'),
750
+ };
751
+ }
752
+
753
+ disconnectedCallback() {
754
+ super.disconnectedCallback();
755
+ this._prefixObserver?.disconnect();
756
+ }
757
+
758
+ private handleSlotChange(e: Event) {
759
+ const slot = e.target as HTMLSlotElement;
760
+ const key = slot.name as "prefix" | "suffix";
761
+ const has = slot
762
+ .assignedNodes({ flatten: true })
763
+ .some((n) => n.nodeType === Node.ELEMENT_NODE || (n.textContent ?? "").trim() !== "");
764
+ if (this.slotted[key] !== has) this.slotted = { ...this.slotted, [key]: has };
765
+ }
766
+
767
+ /** Clicking the (non-interactive) prefix/suffix area focuses the input, as a
768
+ * label would. Clickable icons and slotted controls keep their own clicks. */
769
+ private handleAffixClick(e: Event) {
770
+ const target = e.target as HTMLElement;
771
+ if (target.closest(".clickable") || target.assignedSlot || target.closest("[slot]")) return;
772
+ this.focus();
773
+ }
774
+
775
+ /** Publishes the prefix block's width as --_rel-tf-prefix-w on its parent so
776
+ * the floated label can slide back over a clear-style prefix (see CSS). */
777
+ private observePrefix() {
778
+ if (typeof ResizeObserver === "undefined") return;
779
+ const prefix = this.renderRoot.querySelector<HTMLElement>(".textfield-affix.prefix");
780
+ this._prefixObserver ??= new ResizeObserver((entries) => {
781
+ for (const entry of entries) {
782
+ const el = entry.target as HTMLElement;
783
+ el.parentElement?.style.setProperty("--_rel-tf-prefix-w", `${el.offsetWidth}px`);
784
+ }
785
+ });
786
+ this._prefixObserver.disconnect();
787
+ if (prefix) this._prefixObserver.observe(prefix);
628
788
  }
629
789
 
630
790
  willUpdate(changed: PropertyValues<this>) {
@@ -639,6 +799,7 @@ export class RelTextfield extends LitElement {
639
799
  private _warnedNoName = false;
640
800
 
641
801
  updated() {
802
+ this.observePrefix();
642
803
  if (this._warnedNoName || !this.labelHidden) return;
643
804
  if (this.fieldAriaLabel || this.fieldAriaLabelledby || this.getAttribute("aria-label")) return;
644
805
  this._warnedNoName = true;
@@ -712,7 +873,7 @@ export class RelTextfield extends LitElement {
712
873
  // errorMessage replaces the helper only when in error AND explicitly set;
713
874
  // otherwise the (deprecated) supportingText/helperText shows as before.
714
875
  const belowText = isError && this.errorMessage ? this.errorMessage : this.helper;
715
- const describedById = belowText ? `${inputId}-desc` : undefined;
876
+ const describedById = `${inputId}-desc`;
716
877
 
717
878
  const wrapperClasses = [
718
879
  "textfield-wrapper",
@@ -723,13 +884,14 @@ export class RelTextfield extends LitElement {
723
884
  this.value ? "has-value" : "",
724
885
  ].filter(Boolean).join(" ");
725
886
 
726
- const inputClasses = [
727
- "textfield-input",
728
- !this.multiline && leadingIcon ? "has-left-icon" : "",
729
- !this.multiline && this.rightIcon && this.variant !== "legend" ? "has-right-icon" : "",
730
- ]
731
- .filter(Boolean)
732
- .join(" ");
887
+ const inputClasses = "textfield-input";
888
+
889
+ // Prefix / suffix blocks. Hidden (not removed) in multiline mode so the
890
+ // named slots stay mounted and slotchange keeps tracking content.
891
+ const affixesShown = !this.multiline;
892
+ const hasPrefix = affixesShown && !!(leadingIcon || this.prefixText || this.slotted.prefix);
893
+ const hasSuffix = affixesShown && !!(this.rightIcon || this.suffixText || this.slotted.suffix);
894
+ const prefixClear = hasPrefix && this.prefixStyle === "clear";
733
895
 
734
896
  const labelStyle = this.resolvedLabelStyle;
735
897
  const labelHidden = labelStyle === "none";
@@ -768,7 +930,7 @@ export class RelTextfield extends LitElement {
768
930
  aria-required="${this.required}"
769
931
  aria-label="${ariaLabel ?? nothing}"
770
932
  aria-labelledby="${ariaLabelledby ?? nothing}"
771
- aria-describedby="${describedById ?? nothing}"
933
+ aria-describedby="${describedById}"
772
934
  @focus="${this.handleFocus}"
773
935
  @blur="${this.handleBlur}"
774
936
  @input="${this.handleInput}"
@@ -788,7 +950,7 @@ export class RelTextfield extends LitElement {
788
950
  aria-required="${this.required}"
789
951
  aria-label="${ariaLabel ?? nothing}"
790
952
  aria-labelledby="${ariaLabelledby ?? nothing}"
791
- aria-describedby="${describedById ?? nothing}"
953
+ aria-describedby="${describedById}"
792
954
  @focus="${this.handleFocus}"
793
955
  @blur="${this.handleBlur}"
794
956
  @input="${this.handleInput}"
@@ -797,7 +959,6 @@ export class RelTextfield extends LitElement {
797
959
  const leadingIconTemplate = leadingIcon
798
960
  ? html`<rel-icon
799
961
  name="${leadingIcon}"
800
- size="sm"
801
962
  class="icon left"
802
963
  role="${this.prefixIconAriaLabel ? "img" : nothing}"
803
964
  aria-label="${this.prefixIconAriaLabel ?? nothing}"
@@ -805,12 +966,50 @@ export class RelTextfield extends LitElement {
805
966
  ></rel-icon>`
806
967
  : "";
807
968
 
808
- const belowTemplate = belowText
809
- ? html`<div part="helper" class="textfield-support ${isError ? "error" : ""}" id="${describedById}">
810
- ${belowText}
811
- </div>`
969
+ const trailingIconTemplate = this.rightIcon
970
+ ? html`<rel-icon
971
+ part="right-icon"
972
+ name="${this.rightIcon}"
973
+ class="icon right ${this.clickableRightIcon ? "clickable" : ""}"
974
+ role="${this.clickableRightIcon ? "button" : nothing}"
975
+ tabindex="${this.clickableRightIcon ? "0" : nothing}"
976
+ aria-label="${this.clickableRightIcon ? (this.rightIconAriaLabel ?? nothing) : nothing}"
977
+ aria-hidden="${this.clickableRightIcon ? nothing : "true"}"
978
+ @click="${(e: Event) => this.handleRightIconClick(e)}"
979
+ @keydown="${(e: KeyboardEvent) => this.handleRightIconKeydown(e)}"
980
+ ></rel-icon>`
812
981
  : "";
813
982
 
983
+ // Order inside each block: slotted content sits outermost (a unit dropdown
984
+ // at the far left, an action button at the far right), then icon, then text
985
+ // nearest the input.
986
+ const prefixClasses = [
987
+ "textfield-affix",
988
+ "prefix",
989
+ this.prefixStyle,
990
+ this.prefixText || this.slotted.prefix ? "has-text" : "",
991
+ hasPrefix ? "" : "hidden",
992
+ ].filter(Boolean).join(" ");
993
+ const prefixTemplate = html`<div part="prefix" class="${prefixClasses}" @click="${this.handleAffixClick}">
994
+ <slot name="prefix" @slotchange="${this.handleSlotChange}"></slot>
995
+ ${hasPrefix ? leadingIconTemplate : ""}
996
+ ${hasPrefix && this.prefixText ? html`<span class="affix-text">${this.prefixText}</span>` : ""}
997
+ </div>`;
998
+
999
+ const suffixClasses = [
1000
+ "textfield-affix",
1001
+ "suffix",
1002
+ this.suffixDivider ? "divider" : "",
1003
+ hasSuffix ? "" : "hidden",
1004
+ ].filter(Boolean).join(" ");
1005
+ const suffixTemplate = html`<div part="suffix" class="${suffixClasses}" @click="${this.handleAffixClick}">
1006
+ ${hasSuffix && this.suffixText ? html`<span class="affix-text">${this.suffixText}</span>` : ""}
1007
+ ${hasSuffix ? trailingIconTemplate : ""}
1008
+ <slot name="suffix" @slotchange="${this.handleSlotChange}"></slot>
1009
+ </div>`;
1010
+
1011
+ const belowTemplate = html`<div part="helper" class="${["textfield-support", isError ? "error" : "", belowText ? "" : "empty"].filter(Boolean).join(" ")}" id="${describedById}" aria-live="polite">${belowText ?? ""}</div>`;
1012
+
814
1013
  if (this.variant === "legend") {
815
1014
  const fieldsetClass = ["field-fieldset", this.multiline ? "multiline" : ""].filter(Boolean).join(" ");
816
1015
  // The <legend> MUST be the fieldset's first child with no preceding
@@ -820,8 +1019,11 @@ export class RelTextfield extends LitElement {
820
1019
  return html`
821
1020
  <div class="${wrapperClasses}">
822
1021
  <fieldset part="control" class="${fieldsetClass}"><legend class="field-legend" id="${legendId}">${labelHidden ? "" : this.label ?? ""}${this.required && !labelHidden ? html`<span class="required-mark" aria-hidden="true">*</span>` : ""}</legend>
823
- ${!this.multiline ? leadingIconTemplate : ""}
824
- ${inputTemplate}
1022
+ <div class="field-row">
1023
+ ${prefixTemplate}
1024
+ <div class="textfield-field">${inputTemplate}</div>
1025
+ ${suffixTemplate}
1026
+ </div>
825
1027
  </fieldset>
826
1028
  ${belowTemplate}
827
1029
  </div>
@@ -836,27 +1038,13 @@ export class RelTextfield extends LitElement {
836
1038
  ? labelTemplate()
837
1039
  : ""}
838
1040
 
839
- <div part="control" class="${["textfield-container", this.isDisabled ? "disabled" : "", this.multiline ? "multiline" : ""].filter(Boolean).join(" ")}">
840
- ${labelStyle === "floating"
841
- ? labelTemplate(leadingIcon && !this.multiline ? " has-left-icon" : "")
842
- : ""}
843
- ${!this.multiline ? leadingIconTemplate : ""}
844
-
845
- ${inputTemplate}
846
-
847
- ${!this.multiline && this.rightIcon
848
- ? html`<rel-icon
849
- part="right-icon"
850
- name="${this.rightIcon}"
851
- size="${this.clickableRightIcon ? "md" : "sm"}"
852
- class="icon right ${this.clickableRightIcon ? "clickable" : ""}"
853
- role="${this.clickableRightIcon ? "button" : nothing}"
854
- tabindex="${this.clickableRightIcon ? "0" : nothing}"
855
- aria-label="${this.clickableRightIcon ? (this.rightIconAriaLabel ?? nothing) : nothing}"
856
- @click="${(e: Event) => this.handleRightIconClick(e)}"
857
- @keydown="${(e: KeyboardEvent) => this.handleRightIconKeydown(e)}"
858
- ></rel-icon>`
859
- : ""}
1041
+ <div part="control" class="${["textfield-container", this.isDisabled ? "disabled" : "", this.multiline ? "multiline" : "", prefixClear ? "prefix-clear" : ""].filter(Boolean).join(" ")}">
1042
+ ${prefixTemplate}
1043
+ <div class="textfield-field">
1044
+ ${labelStyle === "floating" ? labelTemplate() : ""}
1045
+ ${inputTemplate}
1046
+ </div>
1047
+ ${suffixTemplate}
860
1048
  </div>
861
1049
 
862
1050
  ${belowTemplate}