@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.
- package/dist/components/rel-accordion/rel-accordion.ts +13 -1
- package/dist/components/rel-alert/rel-alert.ts +1 -1
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
- package/dist/components/rel-badge/rel-badge.ts +6 -1
- package/dist/components/rel-button/rel-button.ts +18 -2
- package/dist/components/rel-calendar/rel-calendar.ts +86 -18
- package/dist/components/rel-carousel/rel-carousel.ts +45 -2
- package/dist/components/rel-checkbox/rel-checkbox.ts +4 -1
- package/dist/components/rel-chip/rel-chip.ts +59 -19
- package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
- package/dist/components/rel-date-picker/date-parse.ts +178 -0
- package/dist/components/rel-date-picker/rel-date-picker.ts +675 -93
- package/dist/components/rel-drawer/rel-drawer.ts +28 -61
- package/dist/components/rel-menu/rel-menu.ts +130 -15
- package/dist/components/rel-menubar/rel-menubar.ts +221 -63
- package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
- package/dist/components/rel-otp/rel-otp.ts +14 -4
- package/dist/components/rel-pagination/rel-pagination.ts +13 -0
- package/dist/components/rel-popover/rel-popover.ts +64 -5
- package/dist/components/rel-progress/rel-progress.ts +13 -4
- package/dist/components/rel-radio/README.md +6 -3
- package/dist/components/rel-radio/rel-radio.ts +236 -60
- package/dist/components/rel-range-picker/rel-range-picker.ts +581 -91
- package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
- package/dist/components/rel-segmented-button/rel-segmented-button.ts +55 -13
- package/dist/components/rel-select/rel-select.ts +203 -24
- package/dist/components/rel-sheet/rel-sheet.ts +184 -18
- package/dist/components/rel-sidebar/rel-sidebar.ts +50 -40
- package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
- package/dist/components/rel-slider/rel-slider.ts +6 -3
- package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
- package/dist/components/rel-stepper/rel-stepper.ts +47 -2
- package/dist/components/rel-switch/rel-switch.ts +3 -1
- package/dist/components/rel-tab/rel-tab.ts +32 -7
- package/dist/components/rel-textfield/rel-textfield.ts +263 -75
- package/dist/components/rel-time-picker/rel-time-picker.ts +749 -261
- package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
- package/dist/components/rel-time-picker/time-parse.ts +85 -0
- package/dist/components/rel-toast/rel-toast.ts +4 -2
- package/dist/components/rel-tooltip/README.md +13 -5
- package/dist/components/rel-tooltip/rel-tooltip.ts +33 -0
- package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
- package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
- package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar.ts +440 -440
- package/dist/components/rel-upload/rel-upload.ts +25 -0
- package/dist/index.d.mts +461 -23
- package/dist/index.d.ts +461 -23
- package/dist/index.js +3906 -807
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4143 -1055
- package/dist/index.mjs.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
23
|
-
* `"
|
|
24
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
/*
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
:
|
|
176
|
-
|
|
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,
|
|
197
|
-
|
|
198
|
-
.rel-radio-option.error-
|
|
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
|
|
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
|
-
|
|
456
|
+
${comfortable
|
|
457
|
+
? html`<span class="stack">${labelLine}${supporting}</span>`
|
|
458
|
+
: labelLine}
|
|
281
459
|
</div>
|
|
282
460
|
|
|
283
|
-
${
|
|
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
|
}
|