@reliability-design/web 1.4.2 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog — @reliability-design/web
2
2
 
3
+ ## 1.4.3 (2026-07-30)
4
+
5
+ ### Added
6
+
7
+ - **New `rel-time-picker` component.** A form-field trigger button opening an Hour / Min / AM-PM column popup. Follows the range-picker draft model: column clicks stage a draft, **Apply** commits (emitting `rel-change`), Cancel/`Escape`/outside click discards, and a **Now** button snaps the draft to the current time. Supports `format` (`12h` | `24h` — the committed `value` is always 24-hour `"HH:mm"`), `minute-step` intervals, inclusive `min-time`/`max-time` bounds, the standard field API (`label`, `placeholder`, `helper-text`, `error`/`invalid` + `error-message`, `required`, `readonly`, `disabled`, `clearable`, `size`, `fullWidth`, hidden form input via `name`), `::part` hooks (base, label, trigger, value, icon, panel, option, footer, helper), and full keyboard/ARIA support. The Angular package ships a matching ControlValueAccessor wrapper for reactive and template-driven forms.
8
+ - **`label-style` prop (`default` | `floating` | `none`) on `rel-textfield`, `rel-select`, and `rel-autocomplete`.** `"none"` renders no visible label; supply `field-aria-label` or `field-aria-labelledby` to keep the control's accessible name. The old `floatingLabel` boolean remains as a deprecated alias for `label-style="floating"`.
9
+ - **`orientation` prop on `rel-radio` (`vertical` | `horizontal`).** Horizontal lays options out in a wrapping row; the group now renders `role="radiogroup"` with `aria-orientation`.
10
+ - **`size` prop on `rel-radio` (`small` | `medium`).** Small drops the circle from 20px to 16px and steps the label/supporting typography down a size for denser forms.
11
+
12
+ ## 1.4.2 (2026-07-29)
13
+
14
+ ### Added
15
+
16
+ - **New `rel-autocomplete` component.** A searchable single-select type-ahead built on `rel-textfield`, so every field feature (label, sizes, variants, floating label, icons, error display) carries over. Supports local items with `contains`/`starts-with`/`none` filtering or a remote `searchFn` (debounced, latest-query-wins with stale responses discarded, automatic loading row), per-item icons, bolded match highlighting, `clearable`, a configurable no-results row, full keyboard navigation, and combobox ARIA. Ships with an Angular ControlValueAccessor wrapper.
17
+ - Per-option leading icons in the `rel-select` menu — options accept an `icon` field (`rel-icon` name).
18
+ - `rel-select` multi-select chips no longer wrap to a second line — the visible chip count adapts to the field width, collapsing the rest into the "+N" chip; `max-visible-chips` is now an optional extra cap (default uncapped, was 3).
19
+ - `icon` prop on `rel-checkbox` — a leading `rel-icon` between the checkbox and label.
20
+ - `focus()` method on `rel-textfield` that forwards to the inner input (the host itself is not focusable).
21
+
22
+ ### Fixed
23
+
24
+ - Disabled `rel-button` styling — disabled buttons dim to 0.5 opacity, and only fill styles (primary, default, icon) flatten to the disabled fill; outline, ghost, link, and static keep their own shape.
25
+ - `rel-popover` rendering misaligned when anchored inside a centered `rel-modal`.
26
+ - Removed the native title-attribute tooltip on hover over a `rel-modal` title.
27
+ - Tab-focusing a below-fold `rel-checkbox`/`rel-radio` no longer scrolls the wrong container (which shoved the page past fixed shells and broke the layout).
28
+ - Closed `rel-drawer`/`rel-modal` panels are removed from the Tab order — keyboard focus no longer disappears into invisible close buttons or slotted content.
29
+
3
30
  ## 1.4.1 (2026-07-27)
4
31
 
5
32
  ### Fixed
@@ -59,7 +59,8 @@ Two data modes:
59
59
  | `loading` | `boolean` | `false` | Externally-controlled loading row (internal `searchFn` loading is automatic) |
60
60
  | `no-results-text` | `string` | `"No results found"` | Row shown when a query yields nothing |
61
61
  | `loading-text` | `string` | `"Loading…"` | Text next to the loading spinner |
62
- | `label`, `placeholder`, `size`, `variant`, `floating-label`, `prefix-icon`, `disabled`, `readonly`, `required`, `error`, `error-message`, `helper-text`, `name`, `full-width` | — | — | Passed through to the inner `rel-textfield` unchanged |
62
+ | `label`, `placeholder`, `size`, `variant`, `label-style`, `floating-label`, `field-aria-label`, `field-aria-labelledby`, `prefix-icon`, `disabled`, `readonly`, `required`, `error`, `error-message`, `helper-text`, `name`, `full-width` | — | — | Passed through to the inner `rel-textfield` unchanged |
63
+ | `label-style` | `"default" \| "floating" \| "none"` | — | Label presentation. `"none"` renders no label — pair it with `field-aria-label`. Unset, the deprecated `floating-label` boolean decides |
63
64
 
64
65
  ## Events
65
66
 
@@ -83,6 +84,7 @@ Two data modes:
83
84
  - Combobox pattern: `role="combobox"` with `aria-expanded`, `aria-controls`, and `aria-activedescendant`; panel is `role="listbox"` with `role="option"` rows
84
85
  - Known limitation: the combobox role sits on a wrapper around the field rather than on the `<input>` itself — the input lives in `rel-textfield`'s nested shadow root, which `aria-activedescendant` cannot reference across. This is the standard pragmatic pattern for composed shadow components
85
86
  - Loading row uses `role="status"`; disabled/readonly never open the panel
87
+ - With `label-style="none"` no `<label>` renders, so `field-aria-label` (or `field-aria-labelledby`) must supply the accessible name — it also names the suggestion panel. A placeholder is not a valid naming mechanism; the inner field logs a console warning when both are missing
86
88
 
87
89
  ## Notes / Edge Cases
88
90
 
@@ -3,7 +3,12 @@ import { customElement, property, state, query } from "lit/decorators.js";
3
3
  import { repeat } from "lit/directives/repeat.js";
4
4
  import "@reliability-design/icons/rel-icon";
5
5
  import "../rel-textfield/rel-textfield.js";
6
- import type { RelTextfield, TextFieldSize, TextFieldVariant } from "../rel-textfield/rel-textfield.js";
6
+ import type {
7
+ RelTextfield,
8
+ TextFieldSize,
9
+ TextFieldVariant,
10
+ FieldLabelStyle,
11
+ } from "../rel-textfield/rel-textfield.js";
7
12
 
8
13
  /** An item is a plain string or any object read through displayField/valueField. */
9
14
  export type AutocompleteItem = string | Record<string, unknown>;
@@ -76,7 +81,15 @@ export class RelAutocomplete extends LitElement {
76
81
  @property({ type: String }) placeholder: string = "";
77
82
  @property({ type: String }) size: TextFieldSize = "medium";
78
83
  @property({ type: String }) variant: TextFieldVariant = "default";
84
+ /** See `FieldLabelStyle`. Left `undefined` the deprecated `floatingLabel` decides. */
85
+ @property({ type: String, attribute: "label-style", reflect: true })
86
+ labelStyle?: FieldLabelStyle;
87
+ /** @deprecated Use `labelStyle="floating"`. Kept permanently as an alias. */
79
88
  @property({ type: Boolean, attribute: "floating-label" }) floatingLabel = false;
89
+ /** Accessible name for the input. Required when `labelStyle="none"`. */
90
+ @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
91
+ /** IDREF list of external element(s) naming the input. Wins over `fieldAriaLabel`. */
92
+ @property({ type: String, attribute: "field-aria-labelledby" }) fieldAriaLabelledby?: string;
80
93
  @property({ type: String, attribute: "prefix-icon" }) prefixIcon?: string;
81
94
  @property({ type: Boolean }) disabled = false;
82
95
  @property({ type: Boolean }) readonly = false;
@@ -105,6 +118,12 @@ export class RelAutocomplete extends LitElement {
105
118
  * doesn't overwrite the input text the way it does for external writes. */
106
119
  private internalValueWrite = false;
107
120
 
121
+ /** Name used for the suggestion panel — the visible label, or fieldAriaLabel
122
+ * once labelStyle="none" hides it. Mirrors rel-textfield's own resolution. */
123
+ private get accessibleName() {
124
+ return (this.labelStyle === "none" ? this.fieldAriaLabel : this.label) || "";
125
+ }
126
+
108
127
  static styles = css`
109
128
  :host {
110
129
  color: var(--rel-text-default);
@@ -540,7 +559,10 @@ export class RelAutocomplete extends LitElement {
540
559
  .value="${this.inputText}"
541
560
  .size="${this.size}"
542
561
  .variant="${this.variant}"
562
+ .labelStyle="${this.labelStyle}"
543
563
  .floatingLabel="${this.floatingLabel}"
564
+ .fieldAriaLabel="${this.fieldAriaLabel}"
565
+ .fieldAriaLabelledby="${this.fieldAriaLabelledby}"
544
566
  .prefixIcon="${this.prefixIcon}"
545
567
  .disabled="${this.disabled}"
546
568
  .readonly="${this.readonly}"
@@ -562,7 +584,7 @@ export class RelAutocomplete extends LitElement {
562
584
  part="panel list"
563
585
  class="ac-panel"
564
586
  role="listbox"
565
- aria-label="${this.label || "Suggestions"}"
587
+ aria-label="${this.accessibleName || "Suggestions"}"
566
588
  style="${this.panelStyle}"
567
589
  ?data-open="${this.open}"
568
590
  >
@@ -191,8 +191,8 @@ export class RelButton extends LitElement {
191
191
  pointer-events:none on the HOST would make it transparent to the
192
192
  pointer and suppress the cursor entirely. */
193
193
  cursor: not-allowed;
194
- /* The disabled tokens are full-contrast pairs (light: #0B0B0B on
195
- #C2C2C2, dark: #FFF on #404040), so on their own they render a
194
+ /* The disabled tokens are full-contrast pairs (light: near-black on
195
+ gray-300, dark: white on gray-700), so on their own they render a
196
196
  crisp button that reads as enabled. Dulling happens here instead —
197
197
  same opacity mechanism the rest of the library uses. */
198
198
  opacity: 0.5;
@@ -28,6 +28,17 @@ A radio button group component that allows users to select a single option from
28
28
  planRadio.selectedValue = 'basic';
29
29
  </script>
30
30
 
31
+ <!-- Horizontal row layout -->
32
+ <rel-radio id="sizeRadio" name="size-group" orientation="horizontal"></rel-radio>
33
+ <script>
34
+ const sizeRadio = document.getElementById('sizeRadio');
35
+ sizeRadio.options = [
36
+ { value: 's', label: 'Small' },
37
+ { value: 'm', label: 'Medium' },
38
+ { value: 'l', label: 'Large' }
39
+ ];
40
+ </script>
41
+
31
42
  <!-- Listen for changes -->
32
43
  <rel-radio id="preferenceRadio"></rel-radio>
33
44
  <script>
@@ -49,6 +60,8 @@ A radio button group component that allows users to select a single option from
49
60
  |------|------|---------|-------------|
50
61
  | `options` | `RadioOption[]` | `[]` | Array of radio options (see RadioOption interface below) |
51
62
  | `name` | `string` | `"radio-group"` | Name attribute for the radio group (for form grouping) |
63
+ | `orientation` | `"vertical" \| "horizontal"` | `"vertical"` | Layout axis: `"vertical"` stacks options in a column; `"horizontal"` lays them out in a wrapping row (reflected attribute) |
64
+ | `size` | `"small" \| "medium"` | `"medium"` | Control scale: `"medium"` = 20px circle with body-lg labels; `"small"` = 16px circle with body-md labels (reflected attribute) |
52
65
  | `selectedValue` | `string \| null` | `null` | Currently selected option value (reflected attribute) |
53
66
  | `disabled` | `boolean` | `false` | Disables the entire radio group |
54
67
 
@@ -82,7 +95,8 @@ None exposed.
82
95
  - `--rel-fonts-weight-body-medium` - Font weight for labels
83
96
 
84
97
  ### Spacing Tokens
85
- - `--rel-spacing-xs` - Gap between radio options
98
+ - `--rel-spacing-xs` - Gap between radio options (also the row gap when wrapping horizontally)
99
+ - `--rel-spacing-xl` - Column gap between options in `orientation="horizontal"`
86
100
  - `--rel-spacing-3xs` - Gap within each option (label and supporting text)
87
101
  - `--rel-spacing-2xs` - Gap between radio circle and label
88
102
 
@@ -94,6 +108,7 @@ None exposed.
94
108
  - `--rel-text-danger` - Error state border and dot color
95
109
 
96
110
  ## Accessibility
111
+ - The option list renders with `role="radiogroup"` and `aria-orientation` reflecting the `orientation` prop
97
112
  - Uses visually hidden native radio inputs for proper screen reader support
98
113
  - Custom visual radio circle with proper focus indicators
99
114
  - Focus visible state with outline offset for keyboard navigation
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css } from "lit";
2
2
  import { customElement, property } from "lit/decorators.js";
3
+ import type { Orientation } from "../rel-stepper/rel-stepper.js";
3
4
 
4
5
  export interface RadioOption {
5
6
  value: string;
@@ -14,6 +15,14 @@ export class RelRadio extends LitElement {
14
15
 
15
16
  @property({ type: String }) name = "radio-group";
16
17
 
18
+ /** Layout axis of the option list. `"vertical"` (default) stacks options in
19
+ * a column; `"horizontal"` lays them out in a wrapping row. */
20
+ @property({ type: String, reflect: true }) orientation: Orientation = "vertical";
21
+
22
+ /** Control scale. `"medium"` (default) = 20px circle with body-lg labels;
23
+ * `"small"` = 16px circle with body-md labels. */
24
+ @property({ type: String, reflect: true }) size: "small" | "medium" = "medium";
25
+
17
26
  @property({ type: String, reflect: true })
18
27
  selectedValue: string | null = null;
19
28
 
@@ -45,6 +54,15 @@ export class RelRadio extends LitElement {
45
54
  gap: var(--rel-spacing-xs);
46
55
  }
47
56
 
57
+ /* Horizontal orientation — options flow in a wrapping row. Wider column
58
+ gap than the vertical rhythm so adjacent option labels read as separate
59
+ choices; row gap keeps the vertical value for wrapped lines. */
60
+ :host([orientation="horizontal"]) .rel-radio-group {
61
+ flex-direction: row;
62
+ flex-wrap: wrap;
63
+ gap: var(--rel-spacing-xs) var(--rel-spacing-xl);
64
+ }
65
+
48
66
  .rel-radio-option {
49
67
  display: inline-flex;
50
68
  flex-direction: column;
@@ -147,6 +165,34 @@ export class RelRadio extends LitElement {
147
165
 
148
166
  }
149
167
 
168
+ /* Size variant (medium is the base scale above). Supporting-text indent =
169
+ circle width + the radio-main gap, so it stays aligned under the label. */
170
+ :host([size="small"]) .radio-custom {
171
+ width: 16px;
172
+ height: 16px;
173
+ }
174
+
175
+ :host([size="small"]) input:checked + .radio-custom::after {
176
+ inset: 3px;
177
+ }
178
+
179
+ :host([size="small"]) .radio-label {
180
+ font-family: var(--rel-body-md-regular-font-family);
181
+ font-weight: var(--rel-body-md-regular-font-weight);
182
+ font-size: var(--rel-body-md-regular-font-size);
183
+ line-height: var(--rel-body-md-regular-line-height);
184
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
185
+ }
186
+
187
+ :host([size="small"]) .radio-supporting {
188
+ margin-left: 24px;
189
+ font-family: var(--rel-body-xs-regular-font-family);
190
+ font-weight: var(--rel-body-xs-regular-font-weight);
191
+ font-size: var(--rel-body-xs-regular-font-size);
192
+ line-height: var(--rel-body-xs-regular-line-height);
193
+ letter-spacing: var(--rel-body-xs-regular-letter-spacing);
194
+ }
195
+
150
196
  /* Error state (Figma State=Error, both Is Checked values) */
151
197
  .rel-radio-option.error-unchecked .radio-custom,
152
198
  .rel-radio-option.error-checked .radio-custom {
@@ -243,7 +289,12 @@ export class RelRadio extends LitElement {
243
289
 
244
290
  render() {
245
291
  return html`
246
- <div part="base" class="rel-radio-group">
292
+ <div
293
+ part="base"
294
+ class="rel-radio-group"
295
+ role="radiogroup"
296
+ aria-orientation="${this.orientation}"
297
+ >
247
298
  ${this.options.map((o) => this.renderOption(o))}
248
299
  </div>
249
300
  ${this.error && this.errorMessage
@@ -34,6 +34,9 @@ A custom combobox-style select component built on the same textfield visual lang
34
34
  | Name | Type | Default | Description |
35
35
  |------|------|---------|-------------|
36
36
  | `label` | `string` | `""` | Label text displayed above the select |
37
+ | `label-style` | `"default" \| "floating" \| "none"` | `"default"` | Label presentation. `"default"` sits above the trigger, `"floating"` rests over the placeholder and animates onto the border line, `"none"` renders no label at all |
38
+ | `field-aria-label` | `string` | - | Accessible name for the trigger. Required with `label-style="none"` |
39
+ | `field-aria-labelledby` | `string` | - | IDREF list of external element(s) naming the trigger. Wins over `field-aria-label` |
37
40
  | `options` | `(string \| SelectOptionObject)[]` | `[]` | Options to display in the dropdown. Each entry is either a plain string or an object: `{ value, label?, supportingText?, icon? }`. `icon` is a `rel-icon` name rendered as a prefix (leading icon) on the menu item — before the label in single-select rows, and between the checkbox and label in multi-select rows |
38
41
  | `selected` | `string` | `""` | Currently selected option value |
39
42
  | `state` | `"default" \| "error" \| "disabled"` | `"default"` | Visual and interaction state of the select |
@@ -80,6 +83,9 @@ None exposed.
80
83
  ### Border Tokens
81
84
  - `--rel-border-radius-xs` - Rounded border radius (default `border="rounded"`)
82
85
 
86
+ ### Layout Tokens
87
+ - `--rel-field-label-reserve` - Height held for the absent label row under `label-style="none"`, so the trigger bottom-aligns with labelled siblings. Defaults to the label line-height plus the wrapper gap; set to `0` to collapse the row entirely in compact contexts (toolbars, table cells)
88
+
83
89
  ## Accessibility
84
90
  - Trigger input uses `role="combobox"` with `aria-haspopup="listbox"`, `aria-expanded`, and `aria-controls`
85
91
  - Dropdown panel uses `role="listbox"`; each option uses `role="option"` with `aria-selected`
@@ -4,11 +4,13 @@ import { repeat } from "lit/directives/repeat.js";
4
4
  import "@reliability-design/icons/rel-icon";
5
5
  import "../rel-chip/rel-chip.js";
6
6
  import "../rel-checkbox/rel-checkbox.js";
7
+ import type { FieldLabelStyle } from "../rel-textfield/rel-textfield.js";
7
8
 
8
9
  export type SelectState = "default" | "error" | "disabled";
9
10
  export type SelectSize = "small" | "medium" | "large";
10
11
  export type SelectBorder = "rounded" | "sharp";
11
- export type SelectLabelStyle = "default" | "floating";
12
+ /** Alias of the shared field type — kept so existing imports keep resolving. */
13
+ export type SelectLabelStyle = FieldLabelStyle;
12
14
 
13
15
  export interface SelectOptionObject {
14
16
  value: string;
@@ -44,7 +46,16 @@ export class RelSelect extends LitElement {
44
46
  // hyphens) — e.g. labelStyle would otherwise only respond to "labelstyle".
45
47
  // Every multi-word property below gets an explicit kebab-case `attribute`
46
48
  // so it works the way HTML usage actually looks.
47
- @property({ type: String, attribute: "label-style" }) labelStyle: SelectLabelStyle = "default";
49
+ @property({ type: String, attribute: "label-style", reflect: true })
50
+ labelStyle: SelectLabelStyle = "default";
51
+
52
+ /**
53
+ * Accessible name for the trigger. Required when `labelStyle="none"`, since no
54
+ * `<label>` renders and a placeholder is not a valid naming mechanism.
55
+ */
56
+ @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
57
+ /** IDREF list of external element(s) naming the trigger. Wins over `fieldAriaLabel`. */
58
+ @property({ type: String, attribute: "field-aria-labelledby" }) fieldAriaLabelledby?: string;
48
59
 
49
60
  @property({ type: Array }) options: SelectOption[] = [];
50
61
 
@@ -149,6 +160,22 @@ export class RelSelect extends LitElement {
149
160
 
150
161
  }
151
162
 
163
+ /* ── labelStyle="none" ──
164
+ No label renders. An empty spacer holds the label row's full height so the
165
+ trigger still bottom-aligns with labelled siblings in a form row.
166
+ The reserve covers label line-height + the wrapper gap, and the negative
167
+ margin cancels the flex gap that would otherwise sit below it — so
168
+ --rel-field-label-reserve: 0 collapses the row to genuinely zero
169
+ (toolbars, table cells), not just to the leftover gap. */
170
+ .label-reserve {
171
+ flex: none;
172
+ height: var(
173
+ --rel-field-label-reserve,
174
+ calc(var(--rel-body-sm-regular-line-height) + var(--rel-spacing-2xs))
175
+ );
176
+ margin-bottom: calc(-1 * var(--rel-spacing-2xs));
177
+ }
178
+
152
179
  /* Error only colors the border/focus ring and the supporting text below
153
180
  the field — label, floating label, placeholder, and icons stay normal. */
154
181
 
@@ -564,7 +591,25 @@ export class RelSelect extends LitElement {
564
591
  }
565
592
  }
566
593
 
594
+ private _warnedNoName = false;
595
+
567
596
  protected updated(changed: PropertyValues<this>) {
597
+ if (
598
+ !this._warnedNoName &&
599
+ this.labelStyle === "none" &&
600
+ !this.fieldAriaLabel &&
601
+ !this.fieldAriaLabelledby &&
602
+ !this.getAttribute("aria-label")
603
+ ) {
604
+ this._warnedNoName = true;
605
+ console.warn(
606
+ '<rel-select label-style="none"> has no accessible name. Set ' +
607
+ "`field-aria-label` or `field-aria-labelledby` — a placeholder is not a " +
608
+ "valid naming mechanism.",
609
+ this
610
+ );
611
+ }
612
+
568
613
  if (
569
614
  changed.has("selectedValues") ||
570
615
  changed.has("options") ||
@@ -922,7 +967,10 @@ export class RelSelect extends LitElement {
922
967
  const opts = this.normalizedOptions;
923
968
  const filtered = this.filteredOptions;
924
969
  const isDisabled = this.state === "disabled";
970
+ const labelHidden = this.labelStyle === "none";
925
971
  const showFloatingChrome = this.labelStyle === "floating" && !!this.label;
972
+ // With no visible label the ARIA name comes from fieldAriaLabel instead.
973
+ const accessibleName = (labelHidden ? this.fieldAriaLabel : this.label) || "";
926
974
 
927
975
  const wrapperClasses = [
928
976
  "select-wrapper",
@@ -945,9 +993,11 @@ export class RelSelect extends LitElement {
945
993
 
946
994
  return html`
947
995
  <div class="${wrapperClasses}">
948
- ${!showFloatingChrome && this.label
949
- ? html`<label part="label" class="select-label" for="${this.uniqueId}">${this.label}</label>`
950
- : ""}
996
+ ${labelHidden
997
+ ? html`<div class="label-reserve" aria-hidden="true"></div>`
998
+ : !showFloatingChrome && this.label
999
+ ? html`<label part="label" class="select-label" for="${this.uniqueId}">${this.label}</label>`
1000
+ : ""}
951
1001
 
952
1002
  <div class="select-container">
953
1003
  ${showFloatingChrome
@@ -966,7 +1016,8 @@ export class RelSelect extends LitElement {
966
1016
  aria-controls="${listboxId}"
967
1017
  aria-activedescendant="${activeOptionId}"
968
1018
  aria-invalid="${this.state === "error"}"
969
- aria-label="${this.label || this.placeholder}"
1019
+ aria-label="${this.fieldAriaLabelledby ? nothing : accessibleName || this.placeholder}"
1020
+ aria-labelledby="${this.fieldAriaLabelledby ?? nothing}"
970
1021
  ?disabled="${isDisabled}"
971
1022
  @click="${this.onTriggerClick}"
972
1023
  @keydown="${this.onTriggerKeyDown}"
@@ -1011,7 +1062,7 @@ export class RelSelect extends LitElement {
1011
1062
  part="panel list" class="select-listbox"
1012
1063
  role="listbox"
1013
1064
  aria-multiselectable="${this.multiple}"
1014
- aria-label="${this.label || "Options"}"
1065
+ aria-label="${accessibleName || "Options"}"
1015
1066
  style="${this.panelStyle}"
1016
1067
  ?data-open="${this.open}"
1017
1068
  >
@@ -18,6 +18,13 @@ A text input field component with support for labels, icons, validation states,
18
18
  supportingText="Must be at least 8 characters">
19
19
  </rel-textfield>
20
20
 
21
+ <!-- No label — an accessible name is required in its place -->
22
+ <rel-textfield
23
+ label-style="none"
24
+ placeholder="Search orders"
25
+ field-aria-label="Search orders">
26
+ </rel-textfield>
27
+
21
28
  <!-- Setting value via JavaScript -->
22
29
  <rel-textfield id="myField" label="Username"></rel-textfield>
23
30
  <script>
@@ -33,6 +40,10 @@ A text input field component with support for labels, icons, validation states,
33
40
  | Name | Type | Default | Description |
34
41
  |------|------|---------|-------------|
35
42
  | `label` | `string` | - | Label text displayed above the input |
43
+ | `label-style` | `"default" \| "floating" \| "none"` | - | Label presentation. `"none"` renders no label at all. Unset, the deprecated `floatingLabel` boolean decides |
44
+ | `floatingLabel` | `boolean` | `false` | **Deprecated** — use `label-style="floating"`. Kept permanently as an alias |
45
+ | `field-aria-label` | `string` | - | Accessible name for the input. Required with `label-style="none"` |
46
+ | `field-aria-labelledby` | `string` | - | IDREF list of external element(s) naming the input. Wins over `field-aria-label` |
36
47
  | `placeholder` | `string` | `""` | Placeholder text |
37
48
  | `type` | `string` | `"text"` | Input type (text, email, password, etc.) |
38
49
  | `value` | `string` | `""` | Current input value |
@@ -72,10 +83,15 @@ None exposed.
72
83
  - `--rel-bg-neutral-elevated-1`: Disabled background
73
84
  - `--rel-spacing-*`: Spacing tokens
74
85
  - `--rel-border-radius-sm`: Border radius
86
+ - `--rel-field-label-reserve`: Height held for the absent label row under `label-style="none"`, so the input bottom-aligns with labelled siblings. Defaults to the label line-height plus the wrapper gap; set to `0` to collapse the row entirely in compact contexts (toolbars, table cells)
75
87
 
76
88
  ## Accessibility
77
89
 
78
90
  - Associates `<label>` with input via `for`/`id`
91
+ - With `label-style="none"` no `<label>` renders, so `field-aria-label` (or
92
+ `field-aria-labelledby`) must supply the accessible name — a placeholder is
93
+ not a valid naming mechanism. The component logs a console warning when both
94
+ are missing.
79
95
  - Supports native HTML5 input attributes (`required`, `disabled`, `readonly`, `maxlength`)
80
96
  - Focus ring visible on keyboard navigation (`:focus-visible`)
81
97
  - Error state communicated visually via border color
@@ -1,4 +1,4 @@
1
- import { LitElement, html, css, nothing } from "lit";
1
+ import { LitElement, html, css, nothing, PropertyValues } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
4
 
@@ -6,6 +6,16 @@ export type TextFieldState = "default" | "error" | "disabled";
6
6
  export type TextFieldSize = "small" | "medium" | "large";
7
7
  export type TextFieldVariant = "default" | "underline" | "legend";
8
8
 
9
+ /**
10
+ * How a field presents its label. Shared by `<rel-textfield>`, `<rel-select>`
11
+ * and `<rel-autocomplete>` so the three stay in step.
12
+ * - `"default"` — static label above the control
13
+ * - `"floating"` — label rests over the placeholder, animates onto the border
14
+ * - `"none"` — no label renders; supply `fieldAriaLabel` /
15
+ * `fieldAriaLabelledby` to keep an accessible name
16
+ */
17
+ export type FieldLabelStyle = "default" | "floating" | "none";
18
+
9
19
  @customElement("rel-textfield")
10
20
  export class RelTextfield extends LitElement {
11
21
  @property({ type: String }) label?: string;
@@ -22,7 +32,23 @@ export class RelTextfield extends LitElement {
22
32
  @property({ type: String }) supportingText?: string;
23
33
  @property({ type: String }) state: TextFieldState = "default";
24
34
  @property({ type: String }) size: TextFieldSize = "medium";
35
+ /**
36
+ * Label presentation. Left `undefined` the deprecated `floatingLabel` boolean
37
+ * decides, so existing markup keeps working; set explicitly and it wins.
38
+ * Declares `attribute` because Lit would otherwise only listen for the
39
+ * all-lowercase `labelstyle`.
40
+ */
41
+ @property({ type: String, attribute: "label-style", reflect: true })
42
+ labelStyle?: FieldLabelStyle;
43
+ /** @deprecated Use `labelStyle="floating"`. Kept permanently as an alias. */
25
44
  @property({ type: Boolean, reflect: true }) floatingLabel = false;
45
+ /**
46
+ * Accessible name for the input. Required when `labelStyle="none"`, since no
47
+ * `<label>` renders and a placeholder is not a valid naming mechanism.
48
+ */
49
+ @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
50
+ /** IDREF list of external element(s) naming the input. Wins over `fieldAriaLabel`. */
51
+ @property({ type: String, attribute: "field-aria-labelledby" }) fieldAriaLabelledby?: string;
26
52
  @property({ type: String, reflect: true }) variant: TextFieldVariant = "default";
27
53
  @property({ type: Boolean }) disabled = false;
28
54
  @property({ type: Boolean }) required = false;
@@ -65,6 +91,17 @@ export class RelTextfield extends LitElement {
65
91
  return this.helperText ?? this.supportingText;
66
92
  }
67
93
 
94
+ /** labelStyle supersedes the deprecated floatingLabel boolean. */
95
+ private get resolvedLabelStyle(): FieldLabelStyle {
96
+ if (this.labelStyle) return this.labelStyle;
97
+ return this.floatingLabel ? "floating" : "default";
98
+ }
99
+
100
+ /** True when no visible label renders and the input needs an ARIA name. */
101
+ private get labelHidden() {
102
+ return this.resolvedLabelStyle === "none";
103
+ }
104
+
68
105
  static styles = css`
69
106
  :host {
70
107
  color: var(--rel-text-default);
@@ -101,6 +138,22 @@ export class RelTextfield extends LitElement {
101
138
  margin-left: 2px;
102
139
  }
103
140
 
141
+ /* ── labelStyle="none" ──
142
+ No label renders. An empty spacer holds the label row's full height so the
143
+ input box still bottom-aligns with labelled siblings in a form row.
144
+ The reserve covers label line-height + the wrapper gap, and the negative
145
+ margin cancels the flex gap that would otherwise sit below it — so
146
+ --rel-field-label-reserve: 0 collapses the row to genuinely zero
147
+ (toolbars, table cells), not just to the leftover gap. */
148
+ .label-reserve {
149
+ flex: none;
150
+ height: var(
151
+ --rel-field-label-reserve,
152
+ calc(var(--rel-body-sm-medium-line-height) + var(--rel-spacing-2xs))
153
+ );
154
+ margin-bottom: calc(-1 * var(--rel-spacing-2xs));
155
+ }
156
+
104
157
  /* Floating label variant */
105
158
  :host([floatinglabel]) .textfield-wrapper {
106
159
  gap: 0;
@@ -477,6 +530,12 @@ export class RelTextfield extends LitElement {
477
530
  box-sizing: border-box;
478
531
  }
479
532
 
533
+ /* labelStyle="none" + legend: nothing to seat in the notch, so drop the
534
+ legend entirely and let the top border run continuous. */
535
+ :host([label-style="none"][variant="legend"]) .field-legend {
536
+ display: none;
537
+ }
538
+
480
539
  /* Prefix icon (legend): vertically centered; input inset via has-left-icon */
481
540
  :host([variant="legend"]) .icon.left {
482
541
  top: 50%;
@@ -568,6 +627,29 @@ export class RelTextfield extends LitElement {
568
627
  if (rightIcon) this.rightIcon = rightIcon;
569
628
  }
570
629
 
630
+ willUpdate(changed: PropertyValues<this>) {
631
+ // Keep the deprecated boolean an accurate mirror of labelStyle. This is what
632
+ // lets every `:host([floatinglabel])` rule below stay untouched — the
633
+ // reflected attribute is still the single styling hook for the floating form.
634
+ if (changed.has("labelStyle") && this.labelStyle) {
635
+ this.floatingLabel = this.labelStyle === "floating";
636
+ }
637
+ }
638
+
639
+ private _warnedNoName = false;
640
+
641
+ updated() {
642
+ if (this._warnedNoName || !this.labelHidden) return;
643
+ if (this.fieldAriaLabel || this.fieldAriaLabelledby || this.getAttribute("aria-label")) return;
644
+ this._warnedNoName = true;
645
+ console.warn(
646
+ '<rel-textfield label-style="none"> has no accessible name. Set ' +
647
+ "`field-aria-label` or `field-aria-labelledby` — a placeholder is not a " +
648
+ "valid naming mechanism.",
649
+ this
650
+ );
651
+ }
652
+
571
653
  /** Focuses the inner input/textarea (the host itself is not focusable). */
572
654
  focus(options?: FocusOptions) {
573
655
  (this.renderRoot.querySelector("input, textarea") as HTMLElement | null)?.focus(options);
@@ -649,8 +731,11 @@ export class RelTextfield extends LitElement {
649
731
  .filter(Boolean)
650
732
  .join(" ");
651
733
 
734
+ const labelStyle = this.resolvedLabelStyle;
735
+ const labelHidden = labelStyle === "none";
736
+
652
737
  const labelTemplate = (extraClass = "") => {
653
- if (!this.label) return "";
738
+ if (!this.label || labelHidden) return "";
654
739
  return html`<label
655
740
  part="label"
656
741
  class="textfield-label${extraClass}"
@@ -658,7 +743,14 @@ export class RelTextfield extends LitElement {
658
743
  >${this.label}${this.required ? html`<span class="required-mark" aria-hidden="true">*</span>` : ""}</label>`;
659
744
  };
660
745
 
661
- const ariaLabelledby = this.variant === "legend" && this.label ? legendId : undefined;
746
+ // With no visible label the <label for> / <legend> naming path is gone, so
747
+ // fieldAriaLabelledby → fieldAriaLabel take over. An explicit labelledby
748
+ // always wins; aria-label only fills in when nothing else names the input.
749
+ const legendLabelledby =
750
+ this.variant === "legend" && this.label && !labelHidden ? legendId : undefined;
751
+ const ariaLabelledby = this.fieldAriaLabelledby ?? legendLabelledby;
752
+ const hasVisibleLabel = !labelHidden && !!this.label;
753
+ const ariaLabel = !hasVisibleLabel && !ariaLabelledby ? this.fieldAriaLabel : undefined;
662
754
 
663
755
  const inputTemplate = this.multiline
664
756
  ? html`<textarea part="input"
@@ -674,6 +766,7 @@ export class RelTextfield extends LitElement {
674
766
  ?required="${this.required}"
675
767
  aria-invalid="${isError}"
676
768
  aria-required="${this.required}"
769
+ aria-label="${ariaLabel ?? nothing}"
677
770
  aria-labelledby="${ariaLabelledby ?? nothing}"
678
771
  aria-describedby="${describedById ?? nothing}"
679
772
  @focus="${this.handleFocus}"
@@ -693,6 +786,7 @@ export class RelTextfield extends LitElement {
693
786
  ?required="${this.required}"
694
787
  aria-invalid="${isError}"
695
788
  aria-required="${this.required}"
789
+ aria-label="${ariaLabel ?? nothing}"
696
790
  aria-labelledby="${ariaLabelledby ?? nothing}"
697
791
  aria-describedby="${describedById ?? nothing}"
698
792
  @focus="${this.handleFocus}"
@@ -725,7 +819,7 @@ export class RelTextfield extends LitElement {
725
819
  // is always present (only its content is dynamic).
726
820
  return html`
727
821
  <div class="${wrapperClasses}">
728
- <fieldset part="control" class="${fieldsetClass}"><legend class="field-legend" id="${legendId}">${this.label ?? ""}${this.required ? html`<span class="required-mark" aria-hidden="true">*</span>` : ""}</legend>
822
+ <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>
729
823
  ${!this.multiline ? leadingIconTemplate : ""}
730
824
  ${inputTemplate}
731
825
  </fieldset>
@@ -736,10 +830,14 @@ export class RelTextfield extends LitElement {
736
830
 
737
831
  return html`
738
832
  <div class="${wrapperClasses}">
739
- ${!this.floatingLabel ? labelTemplate() : ""}
833
+ ${labelHidden
834
+ ? html`<div class="label-reserve" aria-hidden="true"></div>`
835
+ : labelStyle !== "floating"
836
+ ? labelTemplate()
837
+ : ""}
740
838
 
741
839
  <div part="control" class="${["textfield-container", this.isDisabled ? "disabled" : "", this.multiline ? "multiline" : ""].filter(Boolean).join(" ")}">
742
- ${this.floatingLabel
840
+ ${labelStyle === "floating"
743
841
  ? labelTemplate(leadingIcon && !this.multiline ? " has-left-icon" : "")
744
842
  : ""}
745
843
  ${!this.multiline ? leadingIconTemplate : ""}