@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 +27 -0
- package/dist/components/rel-autocomplete/README.md +3 -1
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +24 -2
- package/dist/components/rel-button/rel-button.ts +2 -2
- package/dist/components/rel-radio/README.md +16 -1
- package/dist/components/rel-radio/rel-radio.ts +52 -1
- package/dist/components/rel-select/README.md +6 -0
- package/dist/components/rel-select/rel-select.ts +58 -7
- package/dist/components/rel-textfield/README.md +16 -0
- package/dist/components/rel-textfield/rel-textfield.ts +104 -6
- package/dist/components/rel-time-picker/README.md +199 -0
- package/dist/components/rel-time-picker/rel-time-picker.ts +994 -0
- package/dist/index.d.mts +187 -38
- package/dist/index.d.ts +187 -38
- package/dist/index.js +1195 -89
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1195 -90
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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 {
|
|
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.
|
|
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:
|
|
195
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
${
|
|
949
|
-
? html`<
|
|
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.
|
|
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="${
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
${
|
|
840
|
+
${labelStyle === "floating"
|
|
743
841
|
? labelTemplate(leadingIcon && !this.multiline ? " has-left-icon" : "")
|
|
744
842
|
: ""}
|
|
745
843
|
${!this.multiline ? leadingIconTemplate : ""}
|