@reliability-design/web 1.4.1 → 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.
@@ -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,34 @@ 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
+
653
+ /** Focuses the inner input/textarea (the host itself is not focusable). */
654
+ focus(options?: FocusOptions) {
655
+ (this.renderRoot.querySelector("input, textarea") as HTMLElement | null)?.focus(options);
656
+ }
657
+
571
658
  private handleFocus() {
572
659
  if (!this.isDisabled) {
573
660
  this.internalState = "focused";
@@ -644,8 +731,11 @@ export class RelTextfield extends LitElement {
644
731
  .filter(Boolean)
645
732
  .join(" ");
646
733
 
734
+ const labelStyle = this.resolvedLabelStyle;
735
+ const labelHidden = labelStyle === "none";
736
+
647
737
  const labelTemplate = (extraClass = "") => {
648
- if (!this.label) return "";
738
+ if (!this.label || labelHidden) return "";
649
739
  return html`<label
650
740
  part="label"
651
741
  class="textfield-label${extraClass}"
@@ -653,7 +743,14 @@ export class RelTextfield extends LitElement {
653
743
  >${this.label}${this.required ? html`<span class="required-mark" aria-hidden="true">*</span>` : ""}</label>`;
654
744
  };
655
745
 
656
- 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;
657
754
 
658
755
  const inputTemplate = this.multiline
659
756
  ? html`<textarea part="input"
@@ -669,6 +766,7 @@ export class RelTextfield extends LitElement {
669
766
  ?required="${this.required}"
670
767
  aria-invalid="${isError}"
671
768
  aria-required="${this.required}"
769
+ aria-label="${ariaLabel ?? nothing}"
672
770
  aria-labelledby="${ariaLabelledby ?? nothing}"
673
771
  aria-describedby="${describedById ?? nothing}"
674
772
  @focus="${this.handleFocus}"
@@ -688,6 +786,7 @@ export class RelTextfield extends LitElement {
688
786
  ?required="${this.required}"
689
787
  aria-invalid="${isError}"
690
788
  aria-required="${this.required}"
789
+ aria-label="${ariaLabel ?? nothing}"
691
790
  aria-labelledby="${ariaLabelledby ?? nothing}"
692
791
  aria-describedby="${describedById ?? nothing}"
693
792
  @focus="${this.handleFocus}"
@@ -720,7 +819,7 @@ export class RelTextfield extends LitElement {
720
819
  // is always present (only its content is dynamic).
721
820
  return html`
722
821
  <div class="${wrapperClasses}">
723
- <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>
724
823
  ${!this.multiline ? leadingIconTemplate : ""}
725
824
  ${inputTemplate}
726
825
  </fieldset>
@@ -731,10 +830,14 @@ export class RelTextfield extends LitElement {
731
830
 
732
831
  return html`
733
832
  <div class="${wrapperClasses}">
734
- ${!this.floatingLabel ? labelTemplate() : ""}
833
+ ${labelHidden
834
+ ? html`<div class="label-reserve" aria-hidden="true"></div>`
835
+ : labelStyle !== "floating"
836
+ ? labelTemplate()
837
+ : ""}
735
838
 
736
839
  <div part="control" class="${["textfield-container", this.isDisabled ? "disabled" : "", this.multiline ? "multiline" : ""].filter(Boolean).join(" ")}">
737
- ${this.floatingLabel
840
+ ${labelStyle === "floating"
738
841
  ? labelTemplate(leadingIcon && !this.multiline ? " has-left-icon" : "")
739
842
  : ""}
740
843
  ${!this.multiline ? leadingIconTemplate : ""}
@@ -0,0 +1,199 @@
1
+ # rel-time-picker
2
+
3
+ A fully accessible time picker component built with Lit for the Reliability Design System. Features a trigger button with an hour/minute (and AM/PM) column popover. Selections are held in draft state until Apply is clicked, mirroring the rel-range-picker interaction model.
4
+
5
+ ## Features
6
+
7
+ - Trigger button with time-column popover (Hour / Min / AM-PM)
8
+ - 12-hour and 24-hour display formats
9
+ - Configurable minute intervals (1, 5, 10, 15, 30, …)
10
+ - Draft state with Now / Cancel / Apply footer
11
+ - Min/max time constraints
12
+ - Optional clear button
13
+ - Label, placeholder, helper text and error states
14
+ - Standardized input API (mirrors rel-textfield / rel-date-picker)
15
+ - Keyboard navigation and full ARIA support
16
+ - Token-driven styling with `::part` hooks
17
+
18
+ ## Value format
19
+
20
+ The committed `value` is **always 24-hour `"HH:mm"`** (e.g. `"09:30"`, `"17:05"`) regardless of the display `format`, so consumers get a consistent machine-readable string.
21
+
22
+ ## Usage
23
+
24
+ ### Basic
25
+
26
+ ```html
27
+ <rel-time-picker></rel-time-picker>
28
+ ```
29
+
30
+ ### With Value
31
+
32
+ ```html
33
+ <rel-time-picker value="09:30"></rel-time-picker>
34
+ ```
35
+
36
+ ### With Label
37
+
38
+ ```html
39
+ <rel-time-picker label="Start time" placeholder="Select time"></rel-time-picker>
40
+ ```
41
+
42
+ ### 24-hour format
43
+
44
+ ```html
45
+ <rel-time-picker format="24h" value="17:05"></rel-time-picker>
46
+ ```
47
+
48
+ ### Minute intervals
49
+
50
+ ```html
51
+ <rel-time-picker minute-step="15"></rel-time-picker>
52
+ <rel-time-picker minute-step="30"></rel-time-picker>
53
+ ```
54
+
55
+ ### With Min/Max
56
+
57
+ ```html
58
+ <rel-time-picker
59
+ min-time="09:00"
60
+ max-time="17:30"
61
+ placeholder="Business hours only"
62
+ ></rel-time-picker>
63
+ ```
64
+
65
+ ### Clearable
66
+
67
+ ```html
68
+ <rel-time-picker value="09:30" clearable></rel-time-picker>
69
+ ```
70
+
71
+ ### States
72
+
73
+ ```html
74
+ <rel-time-picker value="09:30" disabled></rel-time-picker>
75
+ <rel-time-picker value="09:30" readonly></rel-time-picker>
76
+ <rel-time-picker error error-message="Select a valid time"></rel-time-picker>
77
+ <rel-time-picker helper-text="Local timezone"></rel-time-picker>
78
+ <rel-time-picker label="Start time" required></rel-time-picker>
79
+ ```
80
+
81
+ ### Sizes / layout
82
+
83
+ ```html
84
+ <rel-time-picker size="small"></rel-time-picker>
85
+ <rel-time-picker size="large"></rel-time-picker>
86
+ <rel-time-picker fullWidth></rel-time-picker>
87
+ ```
88
+
89
+ ### Form usage
90
+
91
+ ```html
92
+ <form>
93
+ <rel-time-picker name="startTime" label="Start time" required></rel-time-picker>
94
+ </form>
95
+ ```
96
+
97
+ The value is mirrored onto a hidden `<input name="...">` so native form submission works.
98
+
99
+ ### Programmatic Control (JavaScript)
100
+
101
+ ```javascript
102
+ const picker = document.querySelector('rel-time-picker');
103
+
104
+ // Set value (always 24h HH:mm)
105
+ picker.value = '14:30';
106
+
107
+ // Open programmatically
108
+ picker.open = true;
109
+
110
+ // Listen to changes (fired on Apply, and on clear with value: null)
111
+ picker.addEventListener('rel-change', (e) => {
112
+ console.log('Selected time:', e.detail.value); // "14:30" | null
113
+ });
114
+
115
+ picker.addEventListener('rel-open-change', (e) => {
116
+ console.log('Open:', e.detail.open);
117
+ });
118
+ ```
119
+
120
+ ## Interaction model
121
+
122
+ - Clicking a column entry updates the **draft** shown in the readout row; nothing is committed yet.
123
+ - **Apply** commits the draft, emits `rel-change` and closes.
124
+ - **Cancel**, `Escape` or an outside click discards the draft (same as rel-range-picker).
125
+ - **Now** sets the draft to the current time, snapped to `minute-step` and clamped into `min-time`/`max-time`.
126
+ - Picking an hour with no minute selected defaults the minute to `00`.
127
+
128
+ ## Keyboard
129
+
130
+ | Key | Context | Action |
131
+ | --- | --- | --- |
132
+ | `Enter` / `Space` / `ArrowDown` | Trigger | Open the popover |
133
+ | `ArrowUp` / `ArrowDown` | Column | Move and select the previous/next entry |
134
+ | `ArrowLeft` / `ArrowRight` | Column | Jump between Hour / Min / AM-PM columns |
135
+ | `Home` / `End` | Column | First / last entry |
136
+ | `Enter` | Column | Select the entry and Apply |
137
+ | `Escape` | Anywhere | Discard draft and close |
138
+
139
+ ## Properties
140
+
141
+ | Property | Attribute | Type | Default | Description |
142
+ | --- | --- | --- | --- | --- |
143
+ | `value` | `value` | `string \| null` | `null` | Committed time, 24h `"HH:mm"` |
144
+ | `placeholder` | `placeholder` | `string` | `"Select time"` | Trigger text when empty |
145
+ | `label` | `label` | `string \| null` | `null` | Field label |
146
+ | `format` | `format` | `"12h" \| "24h"` | `"12h"` | Display format (value stays `HH:mm`) |
147
+ | `minuteStep` | `minute-step` | `number` | `1` | Minute column granularity |
148
+ | `minTime` | `min-time` | `string \| null` | `null` | Earliest selectable time (inclusive) |
149
+ | `maxTime` | `max-time` | `string \| null` | `null` | Latest selectable time (inclusive) |
150
+ | `disabled` | `disabled` | `boolean` | `false` | Disables all interaction |
151
+ | `readonly` | `readonly` | `boolean` | `false` | Value shown, popover cannot open |
152
+ | `required` | `required` | `boolean` | `false` | Renders `*` and sets `aria-required` |
153
+ | `clearable` | `clearable` | `boolean` | `false` | Clear button when a value is set |
154
+ | `open` | `open` | `boolean` | `false` | Controls / reflects popover state |
155
+ | `error` | `error` | `boolean` | `false` | Error styling |
156
+ | `invalid` | `invalid` | `boolean` | `false` | Alias for `error` |
157
+ | `errorMessage` | `error-message` | `string` | — | Shown below when error/invalid |
158
+ | `helperText` | `helper-text` | `string` | — | Assistive text below the field |
159
+ | `name` | `name` | `string` | — | Form field name (hidden input) |
160
+ | `size` | `size` | `"small" \| "medium" \| "large"` | `"medium"` | Trigger size |
161
+ | `fullWidth` | `fullWidth` | `boolean` | `false` | Fill the parent width |
162
+ | `variant` | `variant` | `"outlined"` | `"outlined"` | Visual style |
163
+
164
+ ## Events
165
+
166
+ | Event | Detail | Description |
167
+ | --- | --- | --- |
168
+ | `rel-change` | `{ value: string \| null }` | Fired on Apply and on clear |
169
+ | `rel-open-change` | `{ open: boolean }` | Popover opened/closed |
170
+ | `rel-focus` | — | Trigger received focus |
171
+ | `rel-blur` | — | Trigger lost focus |
172
+
173
+ ## CSS Parts
174
+
175
+ | Part | Element |
176
+ | --- | --- |
177
+ | `base` | Outer wrapper |
178
+ | `label` | Field label |
179
+ | `trigger` | Trigger button |
180
+ | `value` | Trigger value/placeholder text |
181
+ | `icon` | Trailing clock icon |
182
+ | `panel` | Popover panel |
183
+ | `option` | Hour/minute/period entries |
184
+ | `footer` | Now / Cancel / Apply row |
185
+ | `helper` | Helper / error text |
186
+
187
+ ```css
188
+ /* Example: widen the popover and restyle options */
189
+ rel-time-picker::part(panel) {
190
+ box-shadow: var(--rel-shadows-shadow-400);
191
+ }
192
+ rel-time-picker::part(option) {
193
+ border-radius: var(--rel-border-radius-xs);
194
+ }
195
+ ```
196
+
197
+ ## Design tokens
198
+
199
+ Styling is fully token-driven: surfaces (`--rel-bg-surface`, `--rel-bg-surface-elevated`), borders (`--rel-border-default`, `--rel-border-strong`, `--rel-border-focus`, `--rel-border-danger`, `--rel-border-subtle`), text (`--rel-text-default`, `--rel-text-label`, `--rel-text-placeholder`, `--rel-text-danger`, `--rel-text-disabled`), selection (`--rel-action-brand-fill`, `--rel-action-brand-label-icons-primary`), spacing, radius, shadows and the `--rel-body-*` typography roles.