@reliability-design/web 1.4.4 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-alert/rel-alert.ts +1 -1
  3. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  4. package/dist/components/rel-badge/rel-badge.ts +6 -1
  5. package/dist/components/rel-button/rel-button.ts +18 -2
  6. package/dist/components/rel-calendar/rel-calendar.ts +86 -18
  7. package/dist/components/rel-carousel/rel-carousel.ts +45 -2
  8. package/dist/components/rel-checkbox/rel-checkbox.ts +4 -1
  9. package/dist/components/rel-chip/rel-chip.ts +59 -19
  10. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  11. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  12. package/dist/components/rel-date-picker/rel-date-picker.ts +675 -93
  13. package/dist/components/rel-drawer/rel-drawer.ts +28 -61
  14. package/dist/components/rel-menu/rel-menu.ts +130 -15
  15. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  16. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  17. package/dist/components/rel-otp/rel-otp.ts +14 -4
  18. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  19. package/dist/components/rel-popover/rel-popover.ts +64 -5
  20. package/dist/components/rel-progress/rel-progress.ts +13 -4
  21. package/dist/components/rel-radio/README.md +6 -3
  22. package/dist/components/rel-radio/rel-radio.ts +236 -60
  23. package/dist/components/rel-range-picker/rel-range-picker.ts +581 -91
  24. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  25. package/dist/components/rel-segmented-button/rel-segmented-button.ts +55 -13
  26. package/dist/components/rel-select/rel-select.ts +203 -24
  27. package/dist/components/rel-sheet/rel-sheet.ts +184 -18
  28. package/dist/components/rel-sidebar/rel-sidebar.ts +50 -40
  29. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  30. package/dist/components/rel-slider/rel-slider.ts +6 -3
  31. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  32. package/dist/components/rel-stepper/rel-stepper.ts +47 -2
  33. package/dist/components/rel-switch/rel-switch.ts +3 -1
  34. package/dist/components/rel-tab/rel-tab.ts +32 -7
  35. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  36. package/dist/components/rel-time-picker/rel-time-picker.ts +749 -261
  37. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  38. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  39. package/dist/components/rel-toast/rel-toast.ts +4 -2
  40. package/dist/components/rel-tooltip/README.md +13 -5
  41. package/dist/components/rel-tooltip/rel-tooltip.ts +33 -0
  42. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  43. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  44. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  45. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  46. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  47. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  48. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  49. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  50. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  51. package/dist/components/rel-upload/rel-upload.ts +25 -0
  52. package/dist/index.d.mts +461 -23
  53. package/dist/index.d.ts +461 -23
  54. package/dist/index.js +3906 -807
  55. package/dist/index.js.map +1 -1
  56. package/dist/index.mjs +4143 -1055
  57. package/dist/index.mjs.map +1 -1
  58. package/package.json +2 -2
@@ -1,9 +1,16 @@
1
1
  import { LitElement, html, css } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
+ import { getDeepActiveElement, getFocusableElements } from "../../utils/focus.js";
3
4
 
4
5
  type SideType = "top" | "right" | "bottom" | "left";
5
6
  type AlignType = "start" | "center" | "end";
6
7
 
8
+ /** Every currently-open popover on the page (module-level, shared by all
9
+ * instances). Used so opening one popover light-dismisses any other open
10
+ * popover it is not nested inside — regardless of whether it was opened by
11
+ * pointer, keyboard, or programmatically. */
12
+ const openPopovers = new Set<RelPopover>();
13
+
7
14
  @customElement("rel-popover")
8
15
  export class RelPopover extends LitElement {
9
16
  /* =======================
@@ -86,13 +93,15 @@ export class RelPopover extends LitElement {
86
93
  super.connectedCallback();
87
94
  this.internalOpen = this.isControlled() ? !!this.open : this.defaultOpen;
88
95
 
89
- // ONE interaction entry point — capture phase
96
+ // ONE interaction entry point per input method — capture phase
90
97
  this.addEventListener("pointerdown", this.onHostPointerDown, true);
98
+ this.addEventListener("keydown", this.onHostKeyDown, true);
91
99
  }
92
100
 
93
101
  disconnectedCallback() {
94
102
  super.disconnectedCallback();
95
103
  this.removeEventListener("pointerdown", this.onHostPointerDown, true);
104
+ this.removeEventListener("keydown", this.onHostKeyDown, true);
96
105
  this.removeGlobalListeners();
97
106
  this.hideNativePopover();
98
107
  }
@@ -159,13 +168,28 @@ export class RelPopover extends LitElement {
159
168
  this.setOpen(!this.activeOpen);
160
169
  };
161
170
 
171
+ private onHostKeyDown = (e: KeyboardEvent) => {
172
+ if (this.disabled) return;
173
+ if (e.key !== "Enter" && e.key !== " ") return;
174
+ if (!this.triggerEl || !e.composedPath().includes(this.triggerEl)) return;
175
+
176
+ // Keyboard twin of onHostPointerDown. preventDefault also suppresses the
177
+ // native synthetic click of a <button> trigger, which would toggle twice.
178
+ e.preventDefault();
179
+ e.stopPropagation();
180
+
181
+ this.setOpen(!this.activeOpen);
182
+ };
183
+
162
184
  /* =======================
163
185
  * OPEN / CLOSE
164
186
  * ======================= */
165
187
 
166
188
  private onOpen() {
167
- this.returnFocusEl =
168
- (document.activeElement as HTMLElement) ?? this.triggerEl;
189
+ this.returnFocusEl = getDeepActiveElement() ?? this.triggerEl;
190
+
191
+ this.closeOtherPopovers();
192
+ openPopovers.add(this);
169
193
 
170
194
  this.addGlobalListeners();
171
195
  this.showNativePopover();
@@ -177,11 +201,29 @@ export class RelPopover extends LitElement {
177
201
  }
178
202
 
179
203
  private onClose() {
204
+ openPopovers.delete(this);
180
205
  this.removeGlobalListeners();
181
206
  this.hideNativePopover();
182
207
  this.restoreFocus();
183
208
  }
184
209
 
210
+ /**
211
+ * Light-dismiss every other open popover that isn't an ancestor of this
212
+ * one (nested popovers keep their parent open). This is what the outside-
213
+ * pointerdown listener would do for a click on our trigger — except the
214
+ * trigger handler stops that pointerdown in the capture phase, and a
215
+ * keyboard (Enter/Space) or programmatic open never produces one at all.
216
+ * Honors each popover's own `closeOnOutsideClick` setting.
217
+ */
218
+ private closeOtherPopovers() {
219
+ for (const other of openPopovers) {
220
+ if (other === this) continue;
221
+ if (!other.closeOnOutsideClick) continue;
222
+ if (other.contains(this)) continue;
223
+ other.setOpen(false);
224
+ }
225
+ }
226
+
185
227
  /* =======================
186
228
  * TOP LAYER (native Popover API)
187
229
  *
@@ -219,7 +261,11 @@ export class RelPopover extends LitElement {
219
261
 
220
262
  private addGlobalListeners() {
221
263
  if (this.closeOnOutsideClick) {
222
- document.addEventListener("pointerdown", this.onOutsidePointerDown);
264
+ // Capture phase: another popover's trigger handler (onHostPointerDown)
265
+ // calls stopPropagation() in its own capture listener, which would
266
+ // otherwise swallow the event before a bubble-phase document listener
267
+ // ever saw it — leaving this popover open alongside the new one.
268
+ document.addEventListener("pointerdown", this.onOutsidePointerDown, true);
223
269
  }
224
270
 
225
271
  if (this.closeOnEscape || this.trapFocus) {
@@ -231,7 +277,7 @@ export class RelPopover extends LitElement {
231
277
  }
232
278
 
233
279
  private removeGlobalListeners() {
234
- document.removeEventListener("pointerdown", this.onOutsidePointerDown);
280
+ document.removeEventListener("pointerdown", this.onOutsidePointerDown, true);
235
281
  document.removeEventListener("keydown", this.onKeyDown);
236
282
  window.removeEventListener("resize", this.updatePosition);
237
283
  window.removeEventListener("scroll", this.updatePosition, true);
@@ -386,6 +432,19 @@ export class RelPopover extends LitElement {
386
432
  this.triggerEl.setAttribute("aria-haspopup", "dialog");
387
433
  this.triggerEl.setAttribute("aria-expanded", String(this.activeOpen));
388
434
  this.triggerEl.setAttribute("aria-controls", this.uniqueId);
435
+
436
+ // A bare <div>/<span> trigger must be Tab-reachable. Skip anything that
437
+ // is (or contains) natively focusable so buttons don't get a second stop.
438
+ if (
439
+ this.triggerEl.tabIndex < 0 &&
440
+ !this.triggerEl.hasAttribute("tabindex") &&
441
+ getFocusableElements(this.triggerEl).length === 0
442
+ ) {
443
+ this.triggerEl.setAttribute("tabindex", "0");
444
+ if (!this.triggerEl.hasAttribute("role")) {
445
+ this.triggerEl.setAttribute("role", "button");
446
+ }
447
+ }
389
448
  }
390
449
 
391
450
  /* =======================
@@ -15,6 +15,9 @@ export class RelProgress extends LitElement {
15
15
  @property({ type: Number })
16
16
  value = 0;
17
17
 
18
+ @property({ type: String })
19
+ label = "";
20
+
18
21
  private radius = 15.9155;
19
22
  private circumference = 2 * Math.PI * this.radius;
20
23
 
@@ -155,9 +158,14 @@ export class RelProgress extends LitElement {
155
158
  return Math.min(100, Math.max(0, this.value));
156
159
  }
157
160
 
161
+ private get accessibleName() {
162
+ if (this.label) return this.label;
163
+ return this.mode.endsWith("indeterminate") ? "Loading" : "Progress";
164
+ }
165
+
158
166
  private renderLinearDeterminate() {
159
167
  return html`
160
- <div part="track" class="linear-track" role="progressbar"
168
+ <div part="track" class="linear-track" role="progressbar" aria-label="${this.accessibleName}"
161
169
  aria-valuenow="${this.safeValue}" aria-valuemin="0" aria-valuemax="100">
162
170
  <div part="indicator" class="linear-bar determinate" style="width: ${this.safeValue}%"></div>
163
171
  </div>
@@ -166,7 +174,7 @@ export class RelProgress extends LitElement {
166
174
 
167
175
  private renderLinearIndeterminate() {
168
176
  return html`
169
- <div part="track" class="linear-track" role="progressbar">
177
+ <div part="track" class="linear-track" role="progressbar" aria-label="${this.accessibleName}">
170
178
  <div part="indicator" class="linear-bar indeterminate"></div>
171
179
  </div>
172
180
  `;
@@ -177,7 +185,8 @@ export class RelProgress extends LitElement {
177
185
  this.circumference - (this.safeValue / 100) * this.circumference;
178
186
 
179
187
  return html`
180
- <div class="circular">
188
+ <div class="circular" role="progressbar" aria-label="${this.accessibleName}"
189
+ aria-valuenow="${this.safeValue}" aria-valuemin="0" aria-valuemax="100">
181
190
  <svg viewBox="0 0 36 36" class="circular-svg">
182
191
  <circle class="circular-bg" cx="18" cy="18" r="${this.radius}" />
183
192
  <circle
@@ -195,7 +204,7 @@ export class RelProgress extends LitElement {
195
204
 
196
205
  private renderCircularIndeterminate() {
197
206
  return html`
198
- <div class="circular">
207
+ <div class="circular" role="progressbar" aria-label="${this.accessibleName}">
199
208
  <svg viewBox="0 0 50 50" class="spinner">
200
209
  <circle
201
210
  class="spinner-path"
@@ -61,9 +61,11 @@ A radio button group component that allows users to select a single option from
61
61
  | `options` | `RadioOption[]` | `[]` | Array of radio options (see RadioOption interface below) |
62
62
  | `name` | `string` | `"radio-group"` | Name attribute for the radio group (for form grouping) |
63
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) |
64
+ | `size` | `"small" \| "medium" \| "large"` | `"medium"` | Control scale (Figma Size L/M/S): `"medium"` = 20px control with body-lg labels; `"large"` = 24px control with body-xl labels; `"small"` = 20px control with body-md labels — S and M share the control size, only typography steps down (reflected attribute) |
65
+ | `density` | `"compact" \| "comfortable"` | `"compact"` | Supporting-text layout (Figma Density): `"compact"` puts the supporting text on its own line under the row, indented past the control; `"comfortable"` stacks label + supporting text beside the control (reflected attribute) |
66
+ | `alignment` | `"leading" \| "trailing"` | `"leading"` | Control position (Figma Layout): `"trailing"` pushes the control to the far edge of the row and always renders comfortable density — the spec has no compact trailing layout (reflected attribute) |
65
67
  | `selectedValue` | `string \| null` | `null` | Currently selected option value (reflected attribute) |
66
- | `disabled` | `boolean` | `false` | Disables the entire radio group |
68
+ | `disabled` | `boolean` | `false` | Disables the entire radio group (renders at 40% opacity) |
67
69
 
68
70
  ### RadioOption Interface
69
71
  ```typescript
@@ -72,7 +74,8 @@ interface RadioOption {
72
74
  label: string; // Display label
73
75
  supportingText?: string; // Optional helper text below the label
74
76
  disabled?: boolean; // Disable individual option
75
- error?: boolean; // Show error state for individual option
77
+ icon?: string; // rel-icon name rendered before the label
78
+ avatar?: string; // 1–2 initials rendered as a circular chip before the label (ignored when icon is set)
76
79
  }
77
80
  ```
78
81
 
@@ -1,5 +1,6 @@
1
- import { LitElement, html, css } from "lit";
1
+ import { LitElement, html, css, nothing, type PropertyValues } from "lit";
2
2
  import { customElement, property } from "lit/decorators.js";
3
+ import "@reliability-design/icons/rel-icon";
3
4
  import type { Orientation } from "../rel-stepper/rel-stepper.js";
4
5
 
5
6
  export interface RadioOption {
@@ -7,8 +8,17 @@ export interface RadioOption {
7
8
  label: string;
8
9
  supportingText?: string;
9
10
  disabled?: boolean;
11
+ /** rel-icon name rendered before the label (Figma Has Icon/Avatar, Type=Icon). */
12
+ icon?: string;
13
+ /** 1–2 initials rendered as a circular chip before the label (Type=Avatar).
14
+ * Ignored when `icon` is also set. */
15
+ avatar?: string;
10
16
  }
11
17
 
18
+ type RadioSize = "small" | "medium" | "large";
19
+ type RadioDensity = "compact" | "comfortable";
20
+ type RadioAlignment = "leading" | "trailing";
21
+
12
22
  @customElement("rel-radio")
13
23
  export class RelRadio extends LitElement {
14
24
  @property({ type: Array }) options: RadioOption[] = [];
@@ -19,9 +29,23 @@ export class RelRadio extends LitElement {
19
29
  * a column; `"horizontal"` lays them out in a wrapping row. */
20
30
  @property({ type: String, reflect: true }) orientation: Orientation = "vertical";
21
31
 
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";
32
+ /** Control scale (Figma Size). `"medium"` (default, Figma M) = 20px control
33
+ * with body-lg labels; `"large"` (Figma L) = 24px control with body-xl
34
+ * labels; `"small"` (Figma S) = 20px control with body-md labels — S and M
35
+ * share the control size in the spec, only typography steps down. */
36
+ @property({ type: String, reflect: true }) size: RadioSize = "medium";
37
+
38
+ /** Supporting-text layout (Figma Density). `"compact"` (default) puts the
39
+ * supporting text on its own line under the row, indented past the control;
40
+ * `"comfortable"` stacks label + supporting text beside the control. */
41
+ @property({ type: String, reflect: true }) density: RadioDensity = "compact";
42
+
43
+ /** Control position (Figma Layout). `"leading"` (default) renders the
44
+ * control before the label; `"trailing"` pushes it to the far edge of the
45
+ * row. Trailing has no compact layout in the spec, so it always renders
46
+ * with comfortable density. Meant for vertical orientation — trailing
47
+ * options stretch to the group's full width. */
48
+ @property({ type: String, reflect: true }) alignment: RadioAlignment = "leading";
25
49
 
26
50
  @property({ type: String, reflect: true })
27
51
  selectedValue: string | null = null;
@@ -33,6 +57,9 @@ export class RelRadio extends LitElement {
33
57
 
34
58
  @property({ type: String }) errorMessage = "";
35
59
 
60
+ /** Accessible name for the radiogroup (it renders no visible group label). */
61
+ @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
62
+
36
63
  @property({ type: String }) value = "";
37
64
 
38
65
  checked = false;
@@ -63,6 +90,12 @@ export class RelRadio extends LitElement {
63
90
  gap: var(--rel-spacing-xs) var(--rel-spacing-xl);
64
91
  }
65
92
 
93
+ /* Trailing alignment — options stretch so the control can sit on the far
94
+ edge of the group's width. */
95
+ :host([alignment="trailing"]) .rel-radio-group {
96
+ align-items: stretch;
97
+ }
98
+
66
99
  .rel-radio-option {
67
100
  display: inline-flex;
68
101
  flex-direction: column;
@@ -71,23 +104,11 @@ export class RelRadio extends LitElement {
71
104
  }
72
105
 
73
106
  .rel-radio-option.disabled {
74
- opacity: 0.6;
75
- color: var(--rel-text-disabled);
107
+ /* Figma State=Disabled: whole option at 40% opacity, colors unchanged. */
108
+ opacity: 0.4;
76
109
  cursor: not-allowed !important;
77
110
  }
78
111
 
79
- .rel-radio-option:not(.disabled):hover .radio-custom {
80
- border-color: var(--rel-text-body);
81
- }
82
-
83
- .rel-radio-option:not(.disabled):hover .radio-label {
84
- color: var(--rel-text-body);
85
- }
86
-
87
- .rel-radio-option:not(.disabled):hover .radio-supporting {
88
- color: var(--rel-text-caption);
89
- }
90
-
91
112
  .rel-radio-option:focus-within:has(input:focus-visible) {
92
113
  outline: 1.5px solid var(--rel-border-focus);
93
114
  outline-offset: 1px;
@@ -104,7 +125,17 @@ export class RelRadio extends LitElement {
104
125
  position: relative;
105
126
  display: flex;
106
127
  align-items: center;
107
- gap: var(--rel-spacing-2xs);
128
+ /* Compact row rhythm (control ↔ label). Comfortable widens to 12px. */
129
+ gap: var(--rel-spacing-xs);
130
+ }
131
+
132
+ .rel-radio-option.comfortable .radio-main {
133
+ gap: var(--rel-spacing-sm);
134
+ }
135
+
136
+ :host([alignment="trailing"]) .radio-main {
137
+ flex-direction: row-reverse;
138
+ justify-content: space-between;
108
139
  }
109
140
 
110
141
  input {
@@ -117,63 +148,163 @@ export class RelRadio extends LitElement {
117
148
  pointer-events: none;
118
149
  }
119
150
 
151
+ /* Control box (Figma .Radio Control) — the drawn ring is inset inside a
152
+ larger hit/spacing box: 16px ring in a 20px box (M/S), 20px ring in a
153
+ 24px box (L). Indent math below uses the BOX size. */
120
154
  .radio-custom {
121
155
  width: 20px;
122
156
  height: 20px;
123
- border: 2px solid var(--rel-text-label);
124
- border-radius: 50%;
157
+ display: inline-flex;
158
+ align-items: center;
159
+ justify-content: center;
125
160
  position: relative;
126
161
  flex-shrink: 0;
127
162
  }
128
163
 
129
- input:checked + .radio-custom::after {
164
+ .radio-custom::before {
165
+ content: "";
166
+ box-sizing: border-box;
167
+ width: 16px;
168
+ height: 16px;
169
+ border: 2px solid var(--rel-text-default);
170
+ border-radius: 50%;
171
+ }
172
+
173
+ .radio-custom::after {
130
174
  content: "";
131
175
  position: absolute;
132
- inset: 4px;
133
- background: var(--rel-text-label);
176
+ width: 8px;
177
+ height: 8px;
134
178
  border-radius: 50%;
179
+ background: var(--rel-text-default);
180
+ display: none;
135
181
  }
136
182
 
137
- .radio-label {
183
+ input:checked + .radio-custom::after {
184
+ display: block;
185
+ }
186
+
187
+ /* Comfortable density — label and supporting text stack beside the control. */
188
+ .stack {
189
+ display: flex;
190
+ flex-direction: column;
191
+ min-width: 0;
192
+ }
193
+
194
+ .label-line {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: var(--rel-spacing-xs);
198
+ min-width: 0;
199
+ }
200
+
201
+ /* Optional label prefix — icon tracks the label's color in every state
202
+ (rel-icon inherits currentColor). */
203
+ .radio-prefix {
204
+ flex-shrink: 0;
138
205
  color: var(--rel-text-default);
206
+ }
139
207
 
140
- font-family: var(--rel-body-lg-regular-font-family);
208
+ .radio-avatar {
209
+ flex-shrink: 0;
210
+ width: 20px;
211
+ height: 20px;
212
+ border-radius: 50%;
213
+ display: inline-flex;
214
+ align-items: center;
215
+ justify-content: center;
216
+ background: var(--rel-bg-neutral-inverse-2);
217
+ color: var(--rel-text-inverse);
218
+ font-family: var(--rel-body-xs-semibold-font-family);
219
+ font-weight: var(--rel-body-xs-semibold-font-weight);
220
+ font-size: var(--rel-body-xs-semibold-font-size);
221
+ line-height: 1;
222
+ letter-spacing: var(--rel-body-xs-semibold-letter-spacing);
223
+ }
141
224
 
225
+ .radio-label {
226
+ color: var(--rel-text-default);
227
+ font-family: var(--rel-body-lg-regular-font-family);
142
228
  font-weight: var(--rel-body-lg-regular-font-weight);
143
-
144
229
  font-size: var(--rel-body-lg-regular-font-size);
145
-
146
230
  line-height: var(--rel-body-lg-regular-line-height);
147
-
148
231
  letter-spacing: var(--rel-body-lg-regular-letter-spacing);
149
-
150
232
  }
151
233
 
152
234
  .radio-supporting {
153
- margin-left: 28px;
154
235
  color: var(--rel-text-caption);
155
-
156
236
  font-family: var(--rel-body-sm-regular-font-family);
157
-
158
237
  font-weight: var(--rel-body-sm-regular-font-weight);
159
-
160
238
  font-size: var(--rel-body-sm-regular-font-size);
161
-
162
239
  line-height: var(--rel-body-sm-regular-line-height);
163
-
164
240
  letter-spacing: var(--rel-body-sm-regular-letter-spacing);
241
+ }
165
242
 
243
+ /* Compact density — supporting text sits under the whole row, indented
244
+ past the control box (box width + row gap). */
245
+ .radio-supporting.indent {
246
+ padding-left: 28px;
166
247
  }
167
248
 
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;
249
+ /* Hover (Figma State=Hover) — ring, dot, label, and prefix icon shift
250
+ text-default → text-body; supporting text stays caption. Scoped away
251
+ from disabled and error so those keep their own colors. */
252
+ .rel-radio-option:not(.disabled):not(.error-unchecked):not(.error-checked):hover .radio-custom::before {
253
+ border-color: var(--rel-text-body);
173
254
  }
174
255
 
175
- :host([size="small"]) input:checked + .radio-custom::after {
176
- inset: 3px;
256
+ .rel-radio-option:not(.disabled):not(.error-unchecked):not(.error-checked):hover .radio-custom::after {
257
+ background: var(--rel-text-body);
258
+ }
259
+
260
+ .rel-radio-option:not(.disabled):not(.error-unchecked):not(.error-checked):hover .radio-label,
261
+ .rel-radio-option:not(.disabled):not(.error-unchecked):not(.error-checked):hover .radio-prefix {
262
+ color: var(--rel-text-body);
263
+ }
264
+
265
+ /* Size variants (medium is the base scale above). */
266
+ :host([size="large"]) .radio-custom {
267
+ width: 24px;
268
+ height: 24px;
269
+ }
270
+
271
+ :host([size="large"]) .radio-custom::before {
272
+ width: 20px;
273
+ height: 20px;
274
+ }
275
+
276
+ :host([size="large"]) .radio-custom::after {
277
+ width: 10px;
278
+ height: 10px;
279
+ }
280
+
281
+ :host([size="large"]) .radio-label {
282
+ font-family: var(--rel-body-xl-regular-font-family);
283
+ font-weight: var(--rel-body-xl-regular-font-weight);
284
+ font-size: var(--rel-body-xl-regular-font-size);
285
+ line-height: var(--rel-body-xl-regular-line-height);
286
+ letter-spacing: var(--rel-body-xl-regular-letter-spacing);
287
+ }
288
+
289
+ :host([size="large"]) .radio-supporting {
290
+ font-family: var(--rel-body-md-regular-font-family);
291
+ font-weight: var(--rel-body-md-regular-font-weight);
292
+ font-size: var(--rel-body-md-regular-font-size);
293
+ line-height: var(--rel-body-md-regular-line-height);
294
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
295
+ }
296
+
297
+ :host([size="large"]) .radio-supporting.indent {
298
+ padding-left: 32px;
299
+ }
300
+
301
+ :host([size="large"]) .radio-avatar {
302
+ width: 24px;
303
+ height: 24px;
304
+ font-family: var(--rel-body-sm-semibold-font-family);
305
+ font-weight: var(--rel-body-sm-semibold-font-weight);
306
+ font-size: var(--rel-body-sm-semibold-font-size);
307
+ letter-spacing: var(--rel-body-sm-semibold-letter-spacing);
177
308
  }
178
309
 
179
310
  :host([size="small"]) .radio-label {
@@ -185,7 +316,6 @@ export class RelRadio extends LitElement {
185
316
  }
186
317
 
187
318
  :host([size="small"]) .radio-supporting {
188
- margin-left: 24px;
189
319
  font-family: var(--rel-body-xs-regular-font-family);
190
320
  font-weight: var(--rel-body-xs-regular-font-weight);
191
321
  font-size: var(--rel-body-xs-regular-font-size);
@@ -193,21 +323,17 @@ export class RelRadio extends LitElement {
193
323
  letter-spacing: var(--rel-body-xs-regular-letter-spacing);
194
324
  }
195
325
 
196
- /* Error state (Figma State=Error, both Is Checked values) */
197
- .rel-radio-option.error-unchecked .radio-custom,
198
- .rel-radio-option.error-checked .radio-custom {
326
+ /* Error state (Figma State=Error) — ring, dot, and supporting text go
327
+ danger; the label (and prefix) keep their default color. */
328
+ .rel-radio-option.error-unchecked .radio-custom::before,
329
+ .rel-radio-option.error-checked .radio-custom::before {
199
330
  border-color: var(--rel-border-danger);
200
331
  }
201
332
 
202
- .rel-radio-option.error-checked input:checked + .radio-custom::after {
333
+ .rel-radio-option.error-checked .radio-custom::after {
203
334
  background: var(--rel-text-danger);
204
335
  }
205
336
 
206
- .rel-radio-option.error-unchecked .radio-label,
207
- .rel-radio-option.error-checked .radio-label {
208
- color: var(--rel-text-danger);
209
- }
210
-
211
337
  .rel-radio-option.error-unchecked .radio-supporting,
212
338
  .rel-radio-option.error-checked .radio-supporting {
213
339
  color: var(--rel-text-danger);
@@ -229,8 +355,29 @@ export class RelRadio extends LitElement {
229
355
  letter-spacing: var(--rel-body-sm-regular-letter-spacing);
230
356
 
231
357
  }
358
+
359
+ .error-message.empty {
360
+ margin-top: 0;
361
+ }
232
362
  `;
233
363
 
364
+ protected willUpdate(changed: PropertyValues<this>) {
365
+ if (
366
+ (changed.has("alignment") || changed.has("density")) &&
367
+ this.alignment === "trailing" &&
368
+ this.density === "compact"
369
+ ) {
370
+ console.warn(
371
+ '[rel-radio] density="compact" has no trailing layout; rendering comfortable density.'
372
+ );
373
+ }
374
+ }
375
+
376
+ /** Trailing has no compact layout in the spec — trailing wins. */
377
+ private get effectiveDensity(): RadioDensity {
378
+ return this.alignment === "trailing" ? "comfortable" : this.density;
379
+ }
380
+
234
381
  private onChange(value: string) {
235
382
  if (this.disabled) return;
236
383
 
@@ -253,11 +400,27 @@ export class RelRadio extends LitElement {
253
400
  return this.selectedValue !== null && this.selectedValue !== "";
254
401
  }
255
402
 
403
+ private renderPrefix(option: RadioOption) {
404
+ if (option.icon) {
405
+ return html`<rel-icon
406
+ name="${option.icon}"
407
+ size="${this.size === "large" ? "lg" : "md"}"
408
+ class="radio-prefix"
409
+ ></rel-icon>`;
410
+ }
411
+ if (option.avatar) {
412
+ return html`<span class="radio-avatar" aria-hidden="true">${option.avatar}</span>`;
413
+ }
414
+ return null;
415
+ }
416
+
256
417
  private renderOption(option: RadioOption) {
257
418
  const isChecked = this.selectedValue === option.value;
258
419
  const isDisabled = this.disabled || option.disabled;
420
+ const comfortable = this.effectiveDensity === "comfortable";
259
421
  const classes = [
260
422
  "rel-radio-option",
423
+ comfortable ? "comfortable" : "compact",
261
424
  isDisabled ? "disabled" : "",
262
425
  this.error && !this.hasSelection ? "error-unchecked" : "",
263
426
  this.error && isChecked ? "error-checked" : "",
@@ -265,6 +428,19 @@ export class RelRadio extends LitElement {
265
428
  .filter(Boolean)
266
429
  .join(" ");
267
430
 
431
+ const labelLine = html`
432
+ <span class="label-line">
433
+ ${this.renderPrefix(option)}
434
+ <span part="label" class="radio-label">${option.label}</span>
435
+ </span>
436
+ `;
437
+
438
+ const supporting = option.supportingText
439
+ ? html`<div class="radio-supporting ${comfortable ? "" : "indent"}">
440
+ ${option.supportingText}
441
+ </div>`
442
+ : null;
443
+
268
444
  return html`
269
445
  <label class="${classes}">
270
446
  <div class="radio-main">
@@ -277,12 +453,12 @@ export class RelRadio extends LitElement {
277
453
  @blur="${() => this._onTouched()}"
278
454
  />
279
455
  <span class="radio-custom"></span>
280
- <span part="label" class="radio-label">${option.label}</span>
456
+ ${comfortable
457
+ ? html`<span class="stack">${labelLine}${supporting}</span>`
458
+ : labelLine}
281
459
  </div>
282
460
 
283
- ${option.supportingText
284
- ? html`<div class="radio-supporting">${option.supportingText}</div>`
285
- : ""}
461
+ ${comfortable ? null : supporting}
286
462
  </label>
287
463
  `;
288
464
  }
@@ -294,12 +470,12 @@ export class RelRadio extends LitElement {
294
470
  class="rel-radio-group"
295
471
  role="radiogroup"
296
472
  aria-orientation="${this.orientation}"
473
+ aria-label="${this.fieldAriaLabel ?? nothing}"
474
+ aria-describedby="radio-error"
297
475
  >
298
476
  ${this.options.map((o) => this.renderOption(o))}
299
477
  </div>
300
- ${this.error && this.errorMessage
301
- ? html`<div class="error-message">${this.errorMessage}</div>`
302
- : ""}
478
+ <div class="${this.error && this.errorMessage ? "error-message" : "error-message empty"}" id="radio-error" aria-live="polite">${this.error && this.errorMessage ? this.errorMessage : ""}</div>
303
479
  `;
304
480
  }
305
481
  }