@reliability-design/web 1.4.8 → 1.4.9
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 +61 -0
- package/dist/components/rel-alert/README.md +1 -1
- package/dist/components/rel-autocomplete/README.md +2 -1
- package/dist/components/rel-carousel/README.md +1 -0
- package/dist/components/rel-checkbox/README.md +2 -1
- package/dist/components/rel-date-picker/README.md +7 -1
- package/dist/components/rel-radio/README.md +1 -1
- package/dist/components/rel-range-picker/README.md +7 -1
- package/dist/components/rel-segmented-button/README.md +5 -4
- package/dist/components/rel-select/README.md +6 -3
- package/dist/components/rel-stepper/README.md +12 -3
- package/dist/components/rel-tab/README.md +1 -0
- package/dist/components/rel-textfield/README.md +2 -2
- package/dist/components/rel-time-picker/README.md +6 -0
- package/dist/index.d.mts +131 -18
- package/dist/index.d.ts +131 -18
- package/dist/index.js +1086 -239
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1071 -224
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -105,11 +105,23 @@ RelAccordion.styles = css`
|
|
|
105
105
|
pointer-events: none;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/* No CSS border property here, deliberately. Figma's Plain stroke is
|
|
109
|
+
strokeAlign=INSIDE, which OVERLAYS the content: verified against the
|
|
110
|
+
live file, the Head frame is 539 wide inside a 539 component in every
|
|
111
|
+
one of the 24 variants, Plain and Shadow alike. A CSS border insets
|
|
112
|
+
its children instead, which pushed the header in by 1px on all four
|
|
113
|
+
sides — so the hover wash stopped 1px short of the card edge and a
|
|
114
|
+
ring of untinted --rel-bg-neutral survived around it. That ring is
|
|
115
|
+
what read as "a border appears on hover", and it appeared on EVERY
|
|
116
|
+
variant because the border lived on this shared base; Shadow merely
|
|
117
|
+
set border-color: transparent, which still reserves the 1px.
|
|
118
|
+
An outline with a negative offset reproduces strokeAlign=INSIDE
|
|
119
|
+
exactly: no layout inset, painted on top, follows border-radius, and
|
|
120
|
+
not clipped by this element's own overflow: hidden. */
|
|
108
121
|
.accordion {
|
|
109
122
|
background: var(--rel-bg-neutral);
|
|
110
123
|
box-sizing: border-box;
|
|
111
124
|
border-radius: var(--_acc-radius, var(--rel-border-radius-xs));
|
|
112
|
-
border: 1px solid var(--rel-border-subtle);
|
|
113
125
|
overflow: hidden;
|
|
114
126
|
box-shadow: var(--rel-shadows-sm);
|
|
115
127
|
}
|
|
@@ -118,8 +130,9 @@ RelAccordion.styles = css`
|
|
|
118
130
|
--_acc-radius: var(--rel-border-radius-xs);
|
|
119
131
|
}
|
|
120
132
|
|
|
133
|
+
/* Live Figma: Style=Shadow carries NO stroke at all (strokes: []). */
|
|
121
134
|
:host([appearance="shadow"]) .accordion {
|
|
122
|
-
|
|
135
|
+
outline: none;
|
|
123
136
|
box-shadow: var(--rel-shadows-sm);
|
|
124
137
|
}
|
|
125
138
|
|
|
@@ -127,11 +140,32 @@ RelAccordion.styles = css`
|
|
|
127
140
|
--_acc-radius: 0px;
|
|
128
141
|
}
|
|
129
142
|
|
|
143
|
+
/* Live Figma: Style=Plain is strokeWeight 1, strokeAlign INSIDE. */
|
|
130
144
|
:host([appearance="plain"]) .accordion {
|
|
131
|
-
|
|
145
|
+
outline: 1px solid var(--rel-border-subtle);
|
|
146
|
+
outline-offset: -1px;
|
|
132
147
|
box-shadow: none;
|
|
133
148
|
}
|
|
134
149
|
|
|
150
|
+
/* box-shadow is NOT painted in forced-colors mode (Windows High Contrast)
|
|
151
|
+
but outline is — the same UA behaviour styles/focus-ring.ts documents,
|
|
152
|
+
and the reason its outer ring is an outline rather than a third shadow
|
|
153
|
+
layer. It matters more here than there: appearance="shadow" has no
|
|
154
|
+
stroke in Figma, so once the shadow is dropped the card has NO boundary
|
|
155
|
+
at all and the header, body and page all read as one surface.
|
|
156
|
+
This used to work by accident — while the base rule declared a 1px solid
|
|
157
|
+
transparent border, the UA forced that transparent border to a visible
|
|
158
|
+
colour. Removing the border to fix the hover inset took the accident
|
|
159
|
+
away with it, so the edge is restored deliberately.
|
|
160
|
+
CanvasText rather than a token: in forced-colors the user's palette is
|
|
161
|
+
the whole point. Plain already has an outline and simply re-asserts it. */
|
|
162
|
+
@media (forced-colors: active) {
|
|
163
|
+
.accordion {
|
|
164
|
+
outline: 1px solid CanvasText;
|
|
165
|
+
outline-offset: -1px;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
/* ── Header ───────────────────────────────────────────────── */
|
|
136
170
|
|
|
137
171
|
.header {
|
|
@@ -552,8 +586,12 @@ RelAlert.styles = css2`
|
|
|
552
586
|
gap: var(--rel-spacing-lg);
|
|
553
587
|
padding: var(--rel-spacing-md);
|
|
554
588
|
border-radius: var(--rel-border-radius-sm);
|
|
555
|
-
min-width: 360px;
|
|
556
589
|
box-sizing: border-box;
|
|
590
|
+
/* No min-width. Figma's Alert is a fixed 400 with minWidth UNSET on all
|
|
591
|
+
twelve variants (verified on the live file), so a 360px floor was
|
|
592
|
+
code-invented. It stopped the alert fitting any column narrower than
|
|
593
|
+
360px and is what forced the docs Variants grid to minmax(360px, 1fr).
|
|
594
|
+
A consumer who wants a floor can set one on the host. */
|
|
557
595
|
}
|
|
558
596
|
|
|
559
597
|
/* Figma "Frame 10": the leading icon and the content column, 12px apart.
|
|
@@ -566,10 +604,17 @@ RelAlert.styles = css2`
|
|
|
566
604
|
min-width: 0;
|
|
567
605
|
}
|
|
568
606
|
|
|
607
|
+
/* Figma "Content": layoutGrow 1, layoutSizingHorizontal FILL — it takes the
|
|
608
|
+
width left over beside the leading icon. Without flex it hugged its text,
|
|
609
|
+
so a short message left the column narrow and a long one wrapped earlier
|
|
610
|
+
than it needed to. min-width: 0 is the companion every flex child needs
|
|
611
|
+
before it is allowed to shrink below its content. */
|
|
569
612
|
.rel-content {
|
|
570
613
|
display: flex;
|
|
571
614
|
flex-direction: column;
|
|
572
615
|
gap: var(--rel-spacing-3xs);
|
|
616
|
+
flex: 1;
|
|
617
|
+
min-width: 0;
|
|
573
618
|
}
|
|
574
619
|
|
|
575
620
|
.rel-message1 {
|
|
@@ -653,9 +698,16 @@ RelAlert.styles = css2`
|
|
|
653
698
|
/* COLORS — semantic status tokens carry both light and dark modes.
|
|
654
699
|
Base classes are the SUBTLE style (Figma Style=Subtle): tone light fill,
|
|
655
700
|
no border, tone-colored title. */
|
|
656
|
-
.
|
|
657
|
-
|
|
658
|
-
|
|
701
|
+
/* No border here. Only Style=Outline has a stroke in Figma — Solid and
|
|
702
|
+
Subtle report strokes: NONE on the live file — and Outline's is
|
|
703
|
+
strokeAlign: INSIDE, which OVERLAYS the content rather than insetting it.
|
|
704
|
+
A CSS border does inset, so declaring a transparent one on the shared
|
|
705
|
+
base made every variant's content box 1px tighter than Figma and
|
|
706
|
+
reserved a pixel that only one appearance ever paints in.
|
|
707
|
+
The outline block below draws the real thing with outline-offset: -1px,
|
|
708
|
+
which reproduces inside-aligned stroke exactly: no layout inset, painted
|
|
709
|
+
on top, and it follows border-radius. Same fix as rel-accordion, which
|
|
710
|
+
had the identical bug. */
|
|
659
711
|
|
|
660
712
|
.alert-neutral {
|
|
661
713
|
background: var(--rel-bg-neutral);
|
|
@@ -752,26 +804,45 @@ RelAlert.styles = css2`
|
|
|
752
804
|
/* Outline style (Figma Style=Outline): no fill, tone border, tone title */
|
|
753
805
|
:host([appearance="outline"]) .rel-alert {
|
|
754
806
|
background: transparent;
|
|
807
|
+
outline: 1px solid transparent;
|
|
808
|
+
outline-offset: -1px;
|
|
755
809
|
}
|
|
756
810
|
|
|
757
811
|
:host([appearance="outline"]) .alert-neutral {
|
|
758
|
-
|
|
812
|
+
outline-color: var(--rel-border-default);
|
|
759
813
|
}
|
|
760
814
|
|
|
761
815
|
:host([appearance="outline"]) .alert-info {
|
|
762
|
-
|
|
816
|
+
outline-color: var(--rel-border-info);
|
|
763
817
|
}
|
|
764
818
|
|
|
765
819
|
:host([appearance="outline"]) .alert-success {
|
|
766
|
-
|
|
820
|
+
outline-color: var(--rel-border-success);
|
|
767
821
|
}
|
|
768
822
|
|
|
769
823
|
:host([appearance="outline"]) .alert-warning {
|
|
770
|
-
|
|
824
|
+
outline-color: var(--rel-border-warning);
|
|
771
825
|
}
|
|
772
826
|
|
|
773
827
|
:host([appearance="outline"]) .alert-destructive {
|
|
774
|
-
|
|
828
|
+
outline-color: var(--rel-border-danger);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/* Every appearance keeps a boundary in forced-colors mode (Windows High
|
|
832
|
+
Contrast), where the UA overrides fills and a Solid or Subtle alert would
|
|
833
|
+
otherwise be indistinguishable from the page. This used to work by
|
|
834
|
+
accident: while the shared base declared a 1px transparent border, the UA
|
|
835
|
+
forced it to a visible colour. Removing that border to fix the content
|
|
836
|
+
inset took the accident with it, so the edge is restored deliberately —
|
|
837
|
+
outline rather than border because outline is painted in that mode and
|
|
838
|
+
because it does not re-introduce the inset. CanvasText, not a token: in
|
|
839
|
+
forced-colors the user's palette is the point. Outline appearance simply
|
|
840
|
+
re-asserts what it already has. Same treatment as rel-accordion. */
|
|
841
|
+
@media (forced-colors: active) {
|
|
842
|
+
.rel-alert {
|
|
843
|
+
outline: 1px solid CanvasText;
|
|
844
|
+
outline-offset: -1px;
|
|
845
|
+
}
|
|
775
846
|
}
|
|
776
847
|
|
|
777
848
|
/* Close-icon colour on Subtle and Outline: the tone colour, matching the
|
|
@@ -1127,16 +1198,28 @@ var RelTextfield = class extends LitElement3 {
|
|
|
1127
1198
|
</div>`;
|
|
1128
1199
|
const belowTemplate = html3`<div part="helper" class="${["textfield-support", isError ? "error" : "", belowText ? "" : "empty"].filter(Boolean).join(" ")}" id="${describedById}" aria-live="polite">${this.helperIcon && belowText ? html3`<rel-icon part="helper-icon" name="${this.helperIcon}" aria-hidden="true"></rel-icon>` : ""}${belowText ?? ""}</div>`;
|
|
1129
1200
|
if (this.variant === "legend") {
|
|
1130
|
-
const
|
|
1201
|
+
const legendContainerClass = [
|
|
1202
|
+
"textfield-container",
|
|
1203
|
+
this.isDisabled ? "disabled" : "",
|
|
1204
|
+
this.multiline ? "multiline" : "",
|
|
1205
|
+
prefixClear ? "prefix-clear" : ""
|
|
1206
|
+
].filter(Boolean).join(" ");
|
|
1131
1207
|
return html3`
|
|
1132
1208
|
<div class="${wrapperClasses}">
|
|
1133
|
-
<
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1209
|
+
<div part="control" class="${legendContainerClass}" @mousedown="${this.handleControlMousedown}">
|
|
1210
|
+
<!-- aria-hidden on the FIELDSET, and deliberately not countered on
|
|
1211
|
+
the legend: aria-hidden="false" cannot un-hide a descendant of
|
|
1212
|
+
a hidden element, so writing it would only mislead. Hiding is
|
|
1213
|
+
correct here — the fieldset now wraps nothing but the legend,
|
|
1214
|
+
so leaving it exposed would announce an empty group before the
|
|
1215
|
+
field. The name still works: accname step 2B includes hidden
|
|
1216
|
+
nodes when they are reached through aria-labelledby, which is
|
|
1217
|
+
how the input gets its name from this legend's id. -->
|
|
1218
|
+
<fieldset class="legend-outline" aria-hidden="true"><legend class="field-legend" id="${legendId}"><span class="legend-text">${labelHidden ? "" : this.label ?? ""}${this.required && !labelHidden ? html3`<span class="required-mark" aria-hidden="true">*</span>` : ""}</span></legend></fieldset>
|
|
1219
|
+
${prefixTemplate}
|
|
1220
|
+
<div class="textfield-field">${inputTemplate}</div>
|
|
1221
|
+
${suffixTemplate}
|
|
1222
|
+
</div>
|
|
1140
1223
|
${belowTemplate}
|
|
1141
1224
|
</div>
|
|
1142
1225
|
`;
|
|
@@ -1249,7 +1332,23 @@ RelTextfield.styles = css3`
|
|
|
1249
1332
|
|
|
1250
1333
|
:host([floatinglabel]) .textfield-label {
|
|
1251
1334
|
position: absolute;
|
|
1252
|
-
|
|
1335
|
+
/* ZERO, and the Figma numbers are why. Live spec, TextField Size=Medium
|
|
1336
|
+
Label Type=Floating: the "Inner Content" frame sits at dx=0 with
|
|
1337
|
+
padding-left 12, and the "Floating Label" frame sits at dx=12 with its
|
|
1338
|
+
own padding-left 4 — so the label BOX aligns with the content edge and
|
|
1339
|
+
its TEXT lands 4px in, that 4px being the notch padding rather than an
|
|
1340
|
+
indent.
|
|
1341
|
+
.textfield-field already begins at the control's border plus inline
|
|
1342
|
+
padding, i.e. at the content edge, so left: 0 puts the box exactly
|
|
1343
|
+
where Figma puts it. The old --_rel-tf-pad-x applied that inset a
|
|
1344
|
+
SECOND time and pushed the text to 29px from the control edge; briefly
|
|
1345
|
+
correcting it to -4px then over-shot the other way to 13px and left
|
|
1346
|
+
almost no border between the corner and the notch. Figma's own value is
|
|
1347
|
+
16px from the control edge; we land on 17px, the extra 1px being our
|
|
1348
|
+
border, which Figma's inside-aligned stroke absorbs into its padding.
|
|
1349
|
+
With a prefix affix present .textfield-field starts after it, so the
|
|
1350
|
+
label follows the icon. */
|
|
1351
|
+
left: 0;
|
|
1253
1352
|
top: 50%;
|
|
1254
1353
|
transform: translateY(-50%);
|
|
1255
1354
|
color: var(--rel-text-placeholder);
|
|
@@ -1280,19 +1379,59 @@ RelTextfield.styles = css3`
|
|
|
1280
1379
|
but a ring and a notch cannot coexist on one box — see the legend
|
|
1281
1380
|
variant's own note below — and Recipe B requires the ring. The surface
|
|
1282
1381
|
colour is therefore a documented seam: --rel-textfield-surface. */
|
|
1382
|
+
/* TWO OVERLAPPING strips, not one exact one. The reference: the label is
|
|
1383
|
+
top: 0 against .textfield-field, whose top edge is the container's
|
|
1384
|
+
CONTENT-box top, so in the label's own coordinates 50% is one
|
|
1385
|
+
border-width below the border-box top. Measured from there the ring
|
|
1386
|
+
occupies [50% - notch, 50% - border] and the border [50% - border, 50%].
|
|
1387
|
+
A single strip cut to exactly that band leaked at some zoom levels and
|
|
1388
|
+
not others (v1.4.9 "slash" report): the border and box-shadow are
|
|
1389
|
+
pixel-snapped by Blink, while a strip inside a translateY(-50%) label
|
|
1390
|
+
rasterises at a fractional offset, so at 105%, 115%, 135% zoom, and at
|
|
1391
|
+
Windows 125% scaling with NO zoom, their edges disagreed by under a
|
|
1392
|
+
device pixel and a hairline of ring or border showed through the text.
|
|
1393
|
+
No offset tuning can fix that; it is right at one scale factor only.
|
|
1394
|
+
So each strip overshoots into paint of ITS OWN colour, and every edge
|
|
1395
|
+
lands either on same-colour paint or inside the other strip's opaque
|
|
1396
|
+
interior:
|
|
1397
|
+
::before surface colour, the whole band + 2px above + 2px below
|
|
1398
|
+
::after the control's FILL, the border + 3px below, painted on top
|
|
1399
|
+
The strips must OVERLAP, not abut. Abutting them at the ring/border
|
|
1400
|
+
junction leaked a constant quarter of the ring colour: two anti-aliased
|
|
1401
|
+
edges on one device row cover a*(1-a) of what is underneath, 0.25 at
|
|
1402
|
+
a = 0.5. Measured as exactly 61/244 before the overlap, 0 after.
|
|
1403
|
+
The overshoot is 2px, not 1: the strip snaps to the grid in the label's
|
|
1404
|
+
own (fractionally offset) space and the ring in the container's, so
|
|
1405
|
+
the two can disagree by up to a full device pixel, and a 1px overshoot
|
|
1406
|
+
still left a 34/255 residue on the ring's outermost row at 105% zoom.
|
|
1407
|
+
Every term derives from --_rel-tf-notch-h / --_rel-tf-border-w, so a
|
|
1408
|
+
ring change carries through. The ::after fill is the container's own
|
|
1409
|
+
background, not the surface seam, so the overshoot into the interior
|
|
1410
|
+
is invisible. Gate: qa/paint-geometry.mjs SLASH pass, zoom x DSF. */
|
|
1283
1411
|
:host([floatinglabel]) .textfield-wrapper.focused .textfield-label::before,
|
|
1284
1412
|
:host([floatinglabel]) .textfield-wrapper.has-value .textfield-label::before {
|
|
1285
1413
|
content: "";
|
|
1286
1414
|
position: absolute;
|
|
1287
1415
|
left: 0;
|
|
1288
1416
|
right: 0;
|
|
1289
|
-
top: 50
|
|
1290
|
-
height: var(--_rel-tf-notch-h, 1px);
|
|
1291
|
-
transform: translateY(-50%);
|
|
1417
|
+
top: calc(50% - var(--_rel-tf-notch-h, 1px) - 2px);
|
|
1418
|
+
height: calc(var(--_rel-tf-notch-h, 1px) + 4px);
|
|
1292
1419
|
background: var(--rel-textfield-surface, var(--rel-bg-neutral));
|
|
1293
1420
|
z-index: -1;
|
|
1294
1421
|
}
|
|
1295
1422
|
|
|
1423
|
+
:host([floatinglabel]) .textfield-wrapper.focused .textfield-label::after,
|
|
1424
|
+
:host([floatinglabel]) .textfield-wrapper.has-value .textfield-label::after {
|
|
1425
|
+
content: "";
|
|
1426
|
+
position: absolute;
|
|
1427
|
+
left: 0;
|
|
1428
|
+
right: 0;
|
|
1429
|
+
top: calc(50% - var(--_rel-tf-border-w, 1px));
|
|
1430
|
+
height: calc(var(--_rel-tf-border-w, 1px) + 3px);
|
|
1431
|
+
background: var(--rel-bg-neutral);
|
|
1432
|
+
z-index: -1;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1296
1435
|
/* Figma keeps the docked label on --rel-text-label and ramps it per size:
|
|
1297
1436
|
body/md/regular (L), body/sm/regular (M), body/xs/regular (S). */
|
|
1298
1437
|
:host([floatinglabel]) .textfield-wrapper.focused .textfield-label,
|
|
@@ -1366,6 +1505,9 @@ RelTextfield.styles = css3`
|
|
|
1366
1505
|
:host([floatinglabel]) .textfield-wrapper.disabled .textfield-label::before {
|
|
1367
1506
|
background: var(--rel-textfield-surface, var(--rel-bg-neutral-elevated-2));
|
|
1368
1507
|
}
|
|
1508
|
+
:host([floatinglabel]) .textfield-wrapper.disabled .textfield-label::after {
|
|
1509
|
+
background: var(--rel-bg-neutral-elevated-2);
|
|
1510
|
+
}
|
|
1369
1511
|
|
|
1370
1512
|
/* Container */
|
|
1371
1513
|
.textfield-container {
|
|
@@ -1778,11 +1920,17 @@ RelTextfield.styles = css3`
|
|
|
1778
1920
|
color: var(--rel-text-placeholder);
|
|
1779
1921
|
}
|
|
1780
1922
|
|
|
1781
|
-
|
|
1923
|
+
/* Hiding the placeholder only makes sense where a floating label is
|
|
1924
|
+
standing in its place. The legend variant has no floating label — its
|
|
1925
|
+
label lives in the notch at every state, and the legend branch renders
|
|
1926
|
+
no .textfield-label at all — so applying this there left an empty field
|
|
1927
|
+
at rest: no placeholder AND no label. Excluding legend restores the
|
|
1928
|
+
placeholder for the one variant that has nothing to replace it. */
|
|
1929
|
+
:host([floatinglabel]:not([variant="legend"])) .textfield-input::placeholder {
|
|
1782
1930
|
opacity: 0;
|
|
1783
1931
|
}
|
|
1784
1932
|
|
|
1785
|
-
:host([floatinglabel]) .textfield-wrapper.focused .textfield-input::placeholder {
|
|
1933
|
+
:host([floatinglabel]:not([variant="legend"])) .textfield-wrapper.focused .textfield-input::placeholder {
|
|
1786
1934
|
opacity: 1;
|
|
1787
1935
|
}
|
|
1788
1936
|
|
|
@@ -1850,56 +1998,134 @@ RelTextfield.styles = css3`
|
|
|
1850
1998
|
/* ── Legend (notched-outline) variant ──
|
|
1851
1999
|
Native <fieldset>/<legend>: the legend notches the top border and stays
|
|
1852
2000
|
fixed in place across all states (no float/animation). */
|
|
1853
|
-
|
|
1854
|
-
|
|
2001
|
+
/* The container keeps a TRANSPARENT border rather than dropping it. The
|
|
2002
|
+
border is 1px of layout that the default variant also has, and the
|
|
2003
|
+
inline padding is measured from inside it — remove it and a legend
|
|
2004
|
+
field's text sits 1px left of its default-variant neighbour in the same
|
|
2005
|
+
form. The visible stroke is painted by .legend-outline on top, exactly
|
|
2006
|
+
over this transparent one, so the two never double up (including in
|
|
2007
|
+
forced-colors, where they coincide rather than stack). */
|
|
2008
|
+
:host([variant="legend"]) .textfield-container {
|
|
2009
|
+
border-color: transparent;
|
|
2010
|
+
background: transparent;
|
|
2011
|
+
/* The legend protrudes above the top edge; overflow: hidden from the
|
|
2012
|
+
base rule would slice its top half off. */
|
|
2013
|
+
overflow: visible;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/* The decorative outline layer. inset: 0 makes its border box identical to
|
|
2017
|
+
the container's, so the painted outline IS --_rel-tf-h — 32 / 40 / 48,
|
|
2018
|
+
with no legend-half subtracted, which is the whole bug this replaced.
|
|
2019
|
+
pointer-events: none keeps clicks going to the control beneath, and the
|
|
2020
|
+
content is lifted above it rather than this being pushed to z-index: -1,
|
|
2021
|
+
because .textfield-container is position: relative with z-index: auto
|
|
2022
|
+
and therefore does NOT create a stacking context — a negative z-index
|
|
2023
|
+
here could slip behind an ancestor's background. */
|
|
2024
|
+
:host([variant="legend"]) .legend-outline {
|
|
2025
|
+
position: absolute;
|
|
2026
|
+
/* NOT inset: 0. An absolutely positioned box's insets resolve against
|
|
2027
|
+
its containing block's PADDING box, and the container keeps a 1px
|
|
2028
|
+
transparent border (see above), so inset: 0 put this layer's border
|
|
2029
|
+
box 1px inside the container's on every side — a painted outline 2px
|
|
2030
|
+
short of the ladder at every size, measured 30/38/46 against 32/40/48.
|
|
2031
|
+
Pulling out by the border width lands the two border boxes exactly on
|
|
2032
|
+
each other, which is what makes the painted height the ladder value. */
|
|
2033
|
+
inset: calc(-1 * var(--_rel-tf-border-w, 1px));
|
|
2034
|
+
/* The legend has a small height (see .field-legend), and Blink then
|
|
2035
|
+
paints the fieldset's top border (legend-height - border) / 2 lower
|
|
2036
|
+
than the layer's top edge. Pull the top edge up by that outset so the
|
|
2037
|
+
painted line stays exactly on the container's top edge and the
|
|
2038
|
+
painted height stays on the 32 / 40 / 48 ladder. Only the top moves;
|
|
2039
|
+
the bottom stays put. */
|
|
2040
|
+
--_rel-tf-legend-h: 3px;
|
|
2041
|
+
--_rel-tf-legend-outset: calc(
|
|
2042
|
+
(var(--_rel-tf-legend-h) - var(--_rel-tf-border-w, 1px)) / 2
|
|
2043
|
+
);
|
|
2044
|
+
top: calc(-1 * var(--_rel-tf-border-w, 1px) - var(--_rel-tf-legend-outset));
|
|
1855
2045
|
margin: 0;
|
|
1856
2046
|
min-inline-size: 0;
|
|
1857
|
-
|
|
2047
|
+
/* Aligns the legend TEXT with the input text. The legend carries its own
|
|
2048
|
+
--rel-spacing-2xs of side padding to cut the notch a little wider than
|
|
2049
|
+
the glyphs, so the layer insets by that much less than the control's
|
|
2050
|
+
own inline padding. Derived from --_rel-tf-pad-x rather than restated,
|
|
2051
|
+
which is what makes the small size line up: the old rule hard-coded
|
|
2052
|
+
--rel-spacing-sm and could not see the size ramp at all, because
|
|
2053
|
+
--_rel-tf-pad-x is declared on .textfield-container and the legend
|
|
2054
|
+
variant never rendered one. */
|
|
2055
|
+
padding-inline: calc(
|
|
2056
|
+
var(--_rel-tf-pad-x, var(--rel-spacing-sm)) - var(--rel-spacing-2xs)
|
|
2057
|
+
);
|
|
2058
|
+
padding-block: 0;
|
|
1858
2059
|
border: 1px solid var(--rel-border-default);
|
|
1859
2060
|
border-radius: var(--rel-border-radius-xs);
|
|
1860
2061
|
background: var(--rel-bg-neutral);
|
|
2062
|
+
pointer-events: none;
|
|
1861
2063
|
transition: border-color 0.2s;
|
|
1862
2064
|
}
|
|
1863
2065
|
|
|
2066
|
+
/* Lift the real content above the decorative layer. */
|
|
2067
|
+
:host([variant="legend"]) .textfield-field,
|
|
2068
|
+
:host([variant="legend"]) .textfield-affix {
|
|
2069
|
+
position: relative;
|
|
2070
|
+
z-index: 1;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/* The legend is taken OUT OF FLOW — height: 0 with visible overflow.
|
|
2074
|
+
Blink draws a fieldset's top border through the legend's VERTICAL
|
|
2075
|
+
MIDDLE, so an in-flow legend pushes the painted line down by half its
|
|
2076
|
+
own line-height: measured 23 / 30 / 37 against the 32 / 40 / 48 ladder,
|
|
2077
|
+
which is exactly half of the 18 / 20 / 22 legend line-heights. Collapsing
|
|
2078
|
+
the legend's height puts its middle on the layer's top edge, so the
|
|
2079
|
+
border is drawn there and the painted outline is the full ladder value.
|
|
2080
|
+
The notch still cuts, because the gap spans the legend's WIDTH and that
|
|
2081
|
+
is unaffected. The text is then lifted back onto the line by
|
|
2082
|
+
.legend-text, which needs its own element: translateY(-50%) on a
|
|
2083
|
+
zero-height box resolves to zero, so the transform has to live on a
|
|
2084
|
+
child that still has height. */
|
|
2085
|
+
/* NOT height: 0 any more, and the reason is Blink's cutout rule. The gap
|
|
2086
|
+
it cuts in the top border for the legend is max(legend height, border
|
|
2087
|
+
width) tall, measured from the layer's top edge. A zero-height legend
|
|
2088
|
+
therefore gets a cutout EXACTLY the border's height — and at any
|
|
2089
|
+
fractional scale factor (Windows 125%: DSF 1.25) the pixel-snapped
|
|
2090
|
+
border lands partly outside that rect, so it painted straight through
|
|
2091
|
+
the legend text at half strength (measured 122/244 on every legend cell
|
|
2092
|
+
at DSF 1.25, clean at DSF 1). A 3px legend makes the cutout 3px tall
|
|
2093
|
+
with a pixel of margin either side of the border. The price is Blink's
|
|
2094
|
+
other rule — the border is drawn through the legend's vertical middle,
|
|
2095
|
+
(3 - 1) / 2 = 1px down — which .legend-outline cancels by starting 1px
|
|
2096
|
+
higher, so the painted outline is still the ladder value. The text is
|
|
2097
|
+
lifted onto that line by .legend-text below. */
|
|
1864
2098
|
:host([variant="legend"]) .field-legend {
|
|
1865
|
-
|
|
2099
|
+
display: block;
|
|
2100
|
+
height: var(--_rel-tf-legend-h, 3px);
|
|
2101
|
+
overflow: visible;
|
|
2102
|
+
margin: 0;
|
|
1866
2103
|
padding: 0 var(--rel-spacing-2xs);
|
|
1867
2104
|
color: var(--rel-text-label);
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2105
|
+
/* REGULAR, not medium. Figma's floated label is 400 at every size — the
|
|
2106
|
+
same finding already recorded at rel-select.ts. This rule used to be
|
|
2107
|
+
the only one in the ramp at 500, so the DEFAULT size rendered heavier
|
|
2108
|
+
than both small and large, which set their own regular weights below. */
|
|
2109
|
+
font-family: var(--rel-body-sm-regular-font-family);
|
|
2110
|
+
font-weight: var(--rel-body-sm-regular-font-weight);
|
|
2111
|
+
font-size: var(--rel-body-sm-regular-font-size);
|
|
2112
|
+
line-height: var(--rel-body-sm-regular-line-height);
|
|
2113
|
+
letter-spacing: var(--rel-body-sm-regular-letter-spacing);
|
|
1873
2114
|
}
|
|
1874
2115
|
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
2116
|
+
/* Carries the legend's text back onto the border line the zero-height
|
|
2117
|
+
legend now sits on. inline-block so it has a height of its own for the
|
|
2118
|
+
percentage to resolve against. */
|
|
2119
|
+
:host([variant="legend"]) .legend-text {
|
|
2120
|
+
display: inline-block;
|
|
2121
|
+
/* Centre the text on the painted line, which sits at the legend's
|
|
2122
|
+
vertical middle (half the legend's height below its top). */
|
|
2123
|
+
transform: translateY(calc(-50% + var(--_rel-tf-legend-h, 3px) / 2));
|
|
1878
2124
|
}
|
|
1879
2125
|
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
the visible outline at exactly --_rel-tf-h; the row fills the remaining
|
|
1883
|
-
content area and centres the input. Multiline keeps auto height. */
|
|
1884
|
-
:host([variant="legend"]) .field-fieldset:not(.multiline) {
|
|
2126
|
+
:host([variant="legend"]) .textfield-input {
|
|
2127
|
+
width: 100%;
|
|
1885
2128
|
box-sizing: border-box;
|
|
1886
|
-
height: var(--_rel-tf-h, 40px);
|
|
1887
|
-
/* Flex column so the row fills the content box below the legend (a
|
|
1888
|
-
percentage height on the row doesn't resolve inside a fieldset). The
|
|
1889
|
-
<legend> is never a flex item, so the notch is unaffected. */
|
|
1890
|
-
display: flex;
|
|
1891
|
-
flex-direction: column;
|
|
1892
|
-
}
|
|
1893
|
-
:host([variant="legend"]) .field-fieldset:not(.multiline) .field-row {
|
|
1894
|
-
flex: 1;
|
|
1895
|
-
min-height: 0;
|
|
1896
|
-
}
|
|
1897
|
-
:host([variant="legend"]) .field-fieldset:not(.multiline) .textfield-input {
|
|
1898
|
-
padding-top: 0;
|
|
1899
|
-
padding-bottom: 0;
|
|
1900
|
-
/* Fill the row so the whole outline is clickable (see the default
|
|
1901
|
-
variant's note above). */
|
|
1902
|
-
align-self: stretch;
|
|
1903
2129
|
}
|
|
1904
2130
|
|
|
1905
2131
|
/* labelStyle="none" + legend: nothing to seat in the notch, so drop the
|
|
@@ -1907,58 +2133,73 @@ RelTextfield.styles = css3`
|
|
|
1907
2133
|
:host([label-style="none"][variant="legend"]) .field-legend {
|
|
1908
2134
|
display: none;
|
|
1909
2135
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
:host([variant="legend"]) .field-row {
|
|
1915
|
-
display: flex;
|
|
1916
|
-
align-items: center;
|
|
1917
|
-
}
|
|
1918
|
-
:host([variant="legend"]) .textfield-affix.prefix.fill {
|
|
1919
|
-
border-radius: 0;
|
|
2136
|
+
/* No legend rendered means no outset either, so the layer goes back to
|
|
2137
|
+
sitting exactly on the container's border box. */
|
|
2138
|
+
:host([label-style="none"][variant="legend"]) .legend-outline {
|
|
2139
|
+
top: calc(-1 * var(--_rel-tf-border-w, 1px));
|
|
1920
2140
|
}
|
|
1921
2141
|
|
|
1922
|
-
/*
|
|
1923
|
-
|
|
1924
|
-
|
|
2142
|
+
/* A fill prefix used to be squared off because the fieldset's content box
|
|
2143
|
+
started below the legend's midline, so it could not reach the top
|
|
2144
|
+
border. The content now rides the ordinary container row, whose top IS
|
|
2145
|
+
the border, so the fill prefix can keep the control's own radius. */
|
|
2146
|
+
|
|
2147
|
+
/* Every state below paints on the OUTLINE LAYER, not the container: the
|
|
2148
|
+
container's own border is transparent by design. The container must also
|
|
2149
|
+
be told to drop the shared focus ring — see the note on focus. */
|
|
2150
|
+
:host([variant="legend"]) .textfield-wrapper:not(.focused):not(.error):not(.disabled)
|
|
2151
|
+
.textfield-container:hover .legend-outline {
|
|
1925
2152
|
border-color: var(--rel-text-placeholder);
|
|
1926
2153
|
}
|
|
1927
2154
|
|
|
1928
|
-
/* Focus — border-color only. A box-shadow
|
|
1929
|
-
rectangular border
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2155
|
+
/* Focus — border-color only, no ring. A box-shadow or outline ring hugs
|
|
2156
|
+
the full rectangular border box and would draw straight across the
|
|
2157
|
+
notch, reading as a line struck through the legend. That is the same
|
|
2158
|
+
collision the floating label has on the other variants, except here
|
|
2159
|
+
there is no mask strip to hide it, so the ring is dropped outright and
|
|
2160
|
+
the legend recolours instead. */
|
|
2161
|
+
:host([variant="legend"]) .textfield-wrapper.focused .textfield-container,
|
|
2162
|
+
:host([variant="legend"]) .textfield-container:focus-within {
|
|
1934
2163
|
box-shadow: none;
|
|
2164
|
+
border-color: transparent;
|
|
2165
|
+
}
|
|
2166
|
+
:host([variant="legend"]) .textfield-wrapper.focused .legend-outline,
|
|
2167
|
+
:host([variant="legend"]) .textfield-container:focus-within .legend-outline {
|
|
2168
|
+
border-color: var(--rel-border-strong);
|
|
1935
2169
|
}
|
|
1936
2170
|
:host([variant="legend"]) .textfield-wrapper.focused .field-legend,
|
|
1937
|
-
:host([variant="legend"]) .
|
|
2171
|
+
:host([variant="legend"]) .textfield-container:focus-within .field-legend {
|
|
1938
2172
|
color: var(--rel-border-strong);
|
|
1939
2173
|
}
|
|
1940
2174
|
|
|
1941
2175
|
/* Error */
|
|
1942
|
-
:host([variant="legend"]) .textfield-wrapper.error .
|
|
1943
|
-
border-color: var(--rel-border-danger);
|
|
2176
|
+
:host([variant="legend"]) .textfield-wrapper.error .textfield-container {
|
|
1944
2177
|
box-shadow: none;
|
|
2178
|
+
border-color: transparent;
|
|
2179
|
+
}
|
|
2180
|
+
:host([variant="legend"]) .textfield-wrapper.error .legend-outline {
|
|
2181
|
+
border-color: var(--rel-border-danger);
|
|
1945
2182
|
}
|
|
1946
2183
|
:host([variant="legend"]) .textfield-wrapper.error .field-legend {
|
|
1947
2184
|
color: var(--rel-text-danger);
|
|
1948
2185
|
}
|
|
1949
2186
|
|
|
1950
|
-
/* Disabled
|
|
1951
|
-
|
|
2187
|
+
/* Disabled — the fill moves to the layer with the stroke, since the
|
|
2188
|
+
container is transparent and would otherwise paint the dulled surface
|
|
2189
|
+
underneath the layer's own background. */
|
|
2190
|
+
:host([variant="legend"]) .textfield-wrapper.disabled .textfield-container {
|
|
2191
|
+
background: transparent;
|
|
2192
|
+
}
|
|
2193
|
+
:host([variant="legend"]) .textfield-wrapper.disabled .legend-outline {
|
|
1952
2194
|
background: var(--rel-bg-neutral-elevated-2);
|
|
1953
2195
|
border-color: var(--rel-border-accent);
|
|
1954
|
-
cursor: not-allowed;
|
|
1955
2196
|
}
|
|
1956
2197
|
:host([variant="legend"]) .textfield-wrapper.disabled .field-legend {
|
|
1957
2198
|
color: var(--rel-text-disabled);
|
|
1958
2199
|
}
|
|
1959
2200
|
|
|
1960
2201
|
/* Readonly */
|
|
1961
|
-
:host([variant="legend"]) .textfield-wrapper.readonly .
|
|
2202
|
+
:host([variant="legend"]) .textfield-wrapper.readonly .textfield-container {
|
|
1962
2203
|
cursor: default;
|
|
1963
2204
|
}
|
|
1964
2205
|
|
|
@@ -2177,20 +2418,28 @@ var RelCheckbox = class extends LitElement4 {
|
|
|
2177
2418
|
.checked=${this.status === "checked"}
|
|
2178
2419
|
.indeterminate=${this.status === "indeterminate"}
|
|
2179
2420
|
aria-checked="${this.status === "indeterminate" ? "mixed" : nothing3}"
|
|
2421
|
+
aria-label="${this.fieldAriaLabel ?? nothing3}"
|
|
2180
2422
|
@click=${this.toggle}
|
|
2181
2423
|
@blur=${() => this._onTouched()}
|
|
2182
2424
|
?disabled=${this.state === "disabled"}
|
|
2183
2425
|
/>
|
|
2184
2426
|
|
|
2427
|
+
<!-- Both marks are Figma's own paths, read from the live Published DS
|
|
2428
|
+
file (set 8418:1987) on 2026-09-25 and left in their authored
|
|
2429
|
+
16-unit box. Figma knocks these out of the filled square; we paint
|
|
2430
|
+
them in the inverse tone over it instead, which is the same
|
|
2431
|
+
picture and keeps the existing per-state fill rules working.
|
|
2432
|
+
Do not re-scale the coordinates — the svg is sized to the drawn
|
|
2433
|
+
square so one unit here is one Figma unit. -->
|
|
2185
2434
|
<span class="box">
|
|
2186
|
-
<svg class="check-icon" viewBox="0 0
|
|
2435
|
+
<svg class="check-icon" viewBox="0 0 16 16" fill="var(--rel-text-inverse)">
|
|
2187
2436
|
<path
|
|
2188
|
-
d="M6.
|
|
2437
|
+
d="M6.22222 12.4444L1.77778 8L3.03111 6.74667L6.22222 9.92889L12.9689 3.18222L14.2222 4.44444L6.22222 12.4444Z"
|
|
2189
2438
|
/>
|
|
2190
2439
|
</svg>
|
|
2191
2440
|
|
|
2192
|
-
<svg class="indeterminate-icon" viewBox="0 0 16
|
|
2193
|
-
<rect width="
|
|
2441
|
+
<svg class="indeterminate-icon" viewBox="0 0 16 16" fill="var(--rel-text-inverse)">
|
|
2442
|
+
<rect x="3.55556" y="7.11111" width="8.88889" height="1.77778" />
|
|
2194
2443
|
</svg>
|
|
2195
2444
|
</span>
|
|
2196
2445
|
|
|
@@ -2291,11 +2540,25 @@ RelCheckbox.styles = css4`
|
|
|
2291
2540
|
The inset is expressed as a ratio rather than four fractional pixel
|
|
2292
2541
|
values, so it stays exact at any device pixel ratio and follows the
|
|
2293
2542
|
frame if the ramp ever changes.
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2543
|
+
|
|
2544
|
+
The control was UNFLATTENED in the 2026-09-23 Published DS revision, so
|
|
2545
|
+
the stroke and the marks are no longer guesses. Verified against the live
|
|
2546
|
+
file (set 8418:1987, node paths read 2026-09-25): the artwork box is 16
|
|
2547
|
+
units wide and EVERY value in it is 16/9 = 1.7778 units —
|
|
2548
|
+
|
|
2549
|
+
corner radius 1.7778 (outer rect: C0.8 0 0 0.8 0 1.77778)
|
|
2550
|
+
border thickness 1.7778 (outer 0…16 hollowed to 1.7778…14.2222)
|
|
2551
|
+
indeterminate dash height 1.7778 (y 7.11111 → 8.88889)
|
|
2552
|
+
indeterminate dash width 8.8889 (x 3.55556 → 12.4444, centred on 8)
|
|
2553
|
+
|
|
2554
|
+
Because the artwork box IS the drawn square (both are 0.8 × the frame),
|
|
2555
|
+
one sixteenth of the square is one unit, so a ninth of the square is the
|
|
2556
|
+
single number that governs radius, border and dash alike. That is why
|
|
2557
|
+
they are derived below rather than tabulated per size: four hand-written
|
|
2558
|
+
radii are what let large and extra-large silently share medium's 4px.
|
|
2559
|
+
Figma leaves the INNER corner square, which border-radius reproduces
|
|
2560
|
+
for free — an outer radius equal to the border width leaves no inner
|
|
2561
|
+
curve. */
|
|
2299
2562
|
.box {
|
|
2300
2563
|
width: var(--_rel-cb-size);
|
|
2301
2564
|
height: var(--_rel-cb-size);
|
|
@@ -2306,6 +2569,12 @@ RelCheckbox.styles = css4`
|
|
|
2306
2569
|
flex-shrink: 0;
|
|
2307
2570
|
}
|
|
2308
2571
|
|
|
2572
|
+
/* ONE box-shadow declaration, and every state contributes by setting
|
|
2573
|
+
--_rel-cb-border-color instead of restating the shadow. Restating it per
|
|
2574
|
+
state is the trap rel-stepper's connector hit and styles/focus-ring.ts
|
|
2575
|
+
documents at length: box-shadow is a single property, so two rules that
|
|
2576
|
+
both declare it do not compose — specificity picks a winner and the loser
|
|
2577
|
+
vanishes silently. */
|
|
2309
2578
|
.box::before {
|
|
2310
2579
|
content: "";
|
|
2311
2580
|
position: absolute;
|
|
@@ -2313,11 +2582,30 @@ RelCheckbox.styles = css4`
|
|
|
2313
2582
|
inset: 10%;
|
|
2314
2583
|
box-sizing: border-box;
|
|
2315
2584
|
border-radius: var(--_rel-cb-radius);
|
|
2316
|
-
|
|
2585
|
+
/* Resting wall colour; the state rules below override just this. */
|
|
2586
|
+
--_rel-cb-border-color: var(--rel-text-default);
|
|
2587
|
+
box-shadow: inset 0 0 0 var(--_rel-cb-border) var(--_rel-cb-border-color);
|
|
2317
2588
|
background: transparent;
|
|
2318
2589
|
transition: all 0.2s;
|
|
2319
2590
|
}
|
|
2320
2591
|
|
|
2592
|
+
/* box-shadow is NOT painted in forced-colors mode (Windows High Contrast) —
|
|
2593
|
+
the same UA behaviour that styles/focus-ring.ts keeps its outer ring on an
|
|
2594
|
+
outline for. For a focus ring that costs a refinement; here it would erase
|
|
2595
|
+
the ENTIRE affordance, because the wall is all there is to see on an
|
|
2596
|
+
unchecked box. So forced-colors gets a real border back.
|
|
2597
|
+
|
|
2598
|
+
Safe to add rather than swap: the 10% inset pins all four edges, so the
|
|
2599
|
+
drawn square's outer edge is 80% of the frame whatever the wall is made
|
|
2600
|
+
of, and the shadow it sits beside is not painted here anyway. Bound to
|
|
2601
|
+
CanvasText because the forced palette, not the token, is what the user
|
|
2602
|
+
asked for. */
|
|
2603
|
+
@media (forced-colors: active) {
|
|
2604
|
+
.box::before {
|
|
2605
|
+
border: 1px solid CanvasText;
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2321
2609
|
/* Figma puts the focus ring on the WHOLE component at 1.5px OUTSIDE, not
|
|
2322
2610
|
around the control alone. Matches rel-radio, which already does this. */
|
|
2323
2611
|
.wrapper:has(input:focus-visible) {
|
|
@@ -2329,9 +2617,16 @@ RelCheckbox.styles = css4`
|
|
|
2329
2617
|
}
|
|
2330
2618
|
|
|
2331
2619
|
svg {
|
|
2332
|
-
/*
|
|
2333
|
-
|
|
2334
|
-
|
|
2620
|
+
/* Both marks are authored in Figma's 16-unit artwork box, and that box is
|
|
2621
|
+
the drawn square — 80% of the frame, the same as ::before's inset. So
|
|
2622
|
+
the svg covers the square exactly and every coordinate inside it is a
|
|
2623
|
+
Figma unit; position and size fall out of the path instead of being
|
|
2624
|
+
re-derived here. This was 55%/55% against an 8:1 viewBox, which (with
|
|
2625
|
+
preserveAspectRatio defaulting to meet) rendered the dash at 55%/8 =
|
|
2626
|
+
6.875% of the frame — 1.6× thinner than Figma's 11.11%, and fractional
|
|
2627
|
+
at every size, which is what made it look blurred. */
|
|
2628
|
+
width: 80%;
|
|
2629
|
+
height: 80%;
|
|
2335
2630
|
position: relative;
|
|
2336
2631
|
display: none;
|
|
2337
2632
|
}
|
|
@@ -2347,7 +2642,7 @@ RelCheckbox.styles = css4`
|
|
|
2347
2642
|
tokens for this same glyph. */
|
|
2348
2643
|
.checked .box::before {
|
|
2349
2644
|
background: var(--rel-text-default);
|
|
2350
|
-
border-color: var(--rel-text-default);
|
|
2645
|
+
--_rel-cb-border-color: var(--rel-text-default);
|
|
2351
2646
|
}
|
|
2352
2647
|
|
|
2353
2648
|
.checked .check-icon {
|
|
@@ -2369,13 +2664,13 @@ RelCheckbox.styles = css4`
|
|
|
2369
2664
|
glyph with --rel-text-danger, a text token, never a border-* one; the two
|
|
2370
2665
|
are the same value in light mode but diverge in dark (red-300 vs -400). */
|
|
2371
2666
|
.error .box::before {
|
|
2372
|
-
border-color: var(--rel-text-danger);
|
|
2667
|
+
--_rel-cb-border-color: var(--rel-text-danger);
|
|
2373
2668
|
}
|
|
2374
2669
|
|
|
2375
2670
|
/* Error checked — error fill, transparent checkmark */
|
|
2376
2671
|
.error.checked .box::before {
|
|
2377
2672
|
background: var(--rel-action-danger-fill);
|
|
2378
|
-
border-color: var(--rel-action-danger-fill);
|
|
2673
|
+
--_rel-cb-border-color: var(--rel-action-danger-fill);
|
|
2379
2674
|
}
|
|
2380
2675
|
|
|
2381
2676
|
/* Figma State=Error tints the SUPPORTING text danger; the label stays
|
|
@@ -2398,7 +2693,7 @@ RelCheckbox.styles = css4`
|
|
|
2398
2693
|
(gray-500 vs gray-700) and identical to --rel-text-caption, so a hovered
|
|
2399
2694
|
label was indistinguishable from supporting text. */
|
|
2400
2695
|
.default .row:hover .box::before {
|
|
2401
|
-
border-color: var(--rel-text-label);
|
|
2696
|
+
--_rel-cb-border-color: var(--rel-text-label);
|
|
2402
2697
|
}
|
|
2403
2698
|
|
|
2404
2699
|
.default .row:hover .label {
|
|
@@ -2407,7 +2702,7 @@ RelCheckbox.styles = css4`
|
|
|
2407
2702
|
|
|
2408
2703
|
.default.checked .row:hover .box::before {
|
|
2409
2704
|
background: var(--rel-text-label);
|
|
2410
|
-
border-color: var(--rel-text-label);
|
|
2705
|
+
--_rel-cb-border-color: var(--rel-text-label);
|
|
2411
2706
|
}
|
|
2412
2707
|
|
|
2413
2708
|
.default.indeterminate .row:hover .indeterminate-icon {
|
|
@@ -2463,19 +2758,80 @@ RelCheckbox.styles = css4`
|
|
|
2463
2758
|
role and compact indent token. The drawn square is 80% of the frame
|
|
2464
2759
|
(see .box::before above), so these numbers are footprints, not squares:
|
|
2465
2760
|
small 16 body-sm / body-xs indent xl (24)
|
|
2466
|
-
medium 18 body-md / body-
|
|
2467
|
-
large 20 body-lg / body-
|
|
2468
|
-
extra-large 24 body-xl / body-
|
|
2469
|
-
|
|
2761
|
+
medium 18 body-md / body-sm indent xl (24)
|
|
2762
|
+
large 20 body-lg / body-md indent 2xl (28)
|
|
2763
|
+
extra-large 24 body-xl / body-lg indent 3xl (32)
|
|
2764
|
+
Verified against the live Published DS file 2026-09-25: .Check Control is
|
|
2765
|
+
16/18/20/24 across all 15 variants per size. This ramp is correct — do
|
|
2766
|
+
not "fix" it because the three lower steps look alike; Figma's own steps
|
|
2767
|
+
are 2px apart and the difference it leans on is the type role.
|
|
2768
|
+
Supporting text (2026-09-26 Trello ticket, "visual adjustment"): ONE step
|
|
2769
|
+
below the label on the body scale at every size — 12/10, 14/12, 16/14,
|
|
2770
|
+
20/16. It used to be two steps below at medium and above. The live file
|
|
2771
|
+
(lastModified 2026-09-23) carries the new pair at Small, Medium and
|
|
2772
|
+
Extra-Large; its Large variants still say body-sm (12) for supporting,
|
|
2773
|
+
which would give Medium and Large the same supporting size and breaks
|
|
2774
|
+
the ticket's own rule, so Large follows the ticket (body-md) and the
|
|
2775
|
+
Figma omission is flagged to the designer rather than copied.
|
|
2776
|
+
|
|
2777
|
+
Only the FRAME and the type roles vary per size. Radius and border are
|
|
2778
|
+
derived from the frame (a ninth of the drawn square, see .box::before),
|
|
2779
|
+
so they cannot fall out of step the way four literals did: large and
|
|
2780
|
+
extra-large previously declared no radius at all and inherited medium's
|
|
2781
|
+
4px, leaving a 24px box and an 18px box with the same corner. */
|
|
2782
|
+
/* DO NOT ROUND THESE. The expression lands on fractional pixels at two of
|
|
2783
|
+
the four sizes and that is deliberate:
|
|
2784
|
+
|
|
2785
|
+
small 16px frame → 12.8px square → 1.4222px (2.84 device px @2x)
|
|
2786
|
+
medium 18px frame → 14.4px square → 1.6px (3.2)
|
|
2787
|
+
large 20px frame → 16px square → 1.7778px (3.56)
|
|
2788
|
+
extra-large 24px frame → 19.2px square → 2.1333px (4.27)
|
|
2789
|
+
|
|
2790
|
+
The fractions are downstream of the FRAME ramp, which is Figma's and
|
|
2791
|
+
already shipped, so rounding the derived value would break the fixed
|
|
2792
|
+
relationship between the radius and the square it sits on — and that
|
|
2793
|
+
relationship is the only thing making the four sizes read as one control
|
|
2794
|
+
rather than four drawings.
|
|
2795
|
+
|
|
2796
|
+
The blur this replaced was not caused by fractionality. It was a 1.6x
|
|
2797
|
+
ERROR: the dash rendered 1.10px where Figma wanted 1.78px, because an 8:1
|
|
2798
|
+
viewBox was being letterboxed into a square box. Fixing the ratio fixes
|
|
2799
|
+
the symptom; rounding on top would be a second, unrelated invention —
|
|
2800
|
+
and inventing a tidier-looking number than the derived one is exactly
|
|
2801
|
+
what produced the rel-radio Small regression that 1.4.9 reverts.
|
|
2802
|
+
|
|
2803
|
+
── WHY THE WALL IS AN INSET SHADOW AND NOT A BORDER ──
|
|
2804
|
+
Because border-width is the one length here that the engine refuses to
|
|
2805
|
+
keep. Blink snaps it to whole CSS pixels at used-value time: declared as
|
|
2806
|
+
a border, the derived value computed to 1 / 1 / 1 / 2px for the four
|
|
2807
|
+
sizes, and read the same at device pixel ratios 1, 2 AND 3, while the
|
|
2808
|
+
radius on the very same element read 1.42222px throughout. One property
|
|
2809
|
+
keeping its fraction is the control group for the other losing it, so
|
|
2810
|
+
that is snapping and not a getComputedStyle artefact (measured
|
|
2811
|
+
2026-09-25).
|
|
2812
|
+
|
|
2813
|
+
Snapped, the faithful value painted WORSE than the flat 2px guess it
|
|
2814
|
+
replaced — sum of absolute painted error 1.933px against 1.333px:
|
|
2815
|
+
|
|
2816
|
+
size Figma as border error flat 2px error
|
|
2817
|
+
s 1.4222 1px 0.422 2px 0.578
|
|
2818
|
+
m 1.6 1px 0.600 2px 0.400
|
|
2819
|
+
l 1.7778 1px 0.778 2px 0.222
|
|
2820
|
+
xl 2.1333 2px 0.133 2px 0.133
|
|
2821
|
+
|
|
2822
|
+
box-shadow spread is NOT snapped — it antialiases sub-pixel — so an inset
|
|
2823
|
+
shadow paints the derived value exactly at every size and the error goes
|
|
2824
|
+
to ~0, with no CSS round() and so no browser-floor question. */
|
|
2470
2825
|
:host {
|
|
2471
2826
|
--_rel-cb-size: 18px;
|
|
2472
|
-
|
|
2827
|
+
/* drawn square = frame × 0.8; every Figma value in it = square ÷ 9 */
|
|
2828
|
+
--_rel-cb-radius: calc(var(--_rel-cb-size) * 0.8 / 9);
|
|
2829
|
+
--_rel-cb-border: calc(var(--_rel-cb-size) * 0.8 / 9);
|
|
2473
2830
|
--_rel-cb-indent: var(--rel-spacing-xl);
|
|
2474
2831
|
}
|
|
2475
2832
|
|
|
2476
2833
|
:host([size="small"]) {
|
|
2477
2834
|
--_rel-cb-size: 16px;
|
|
2478
|
-
--_rel-cb-radius: 3px;
|
|
2479
2835
|
--_rel-cb-indent: var(--rel-spacing-xl);
|
|
2480
2836
|
}
|
|
2481
2837
|
|
|
@@ -2499,11 +2855,11 @@ RelCheckbox.styles = css4`
|
|
|
2499
2855
|
}
|
|
2500
2856
|
|
|
2501
2857
|
.text {
|
|
2502
|
-
font-family: var(--rel-body-
|
|
2503
|
-
font-weight: var(--rel-body-
|
|
2504
|
-
font-size: var(--rel-body-
|
|
2505
|
-
line-height: var(--rel-body-
|
|
2506
|
-
letter-spacing: var(--rel-body-
|
|
2858
|
+
font-family: var(--rel-body-sm-regular-font-family);
|
|
2859
|
+
font-weight: var(--rel-body-sm-regular-font-weight);
|
|
2860
|
+
font-size: var(--rel-body-sm-regular-font-size);
|
|
2861
|
+
line-height: var(--rel-body-sm-regular-line-height);
|
|
2862
|
+
letter-spacing: var(--rel-body-sm-regular-letter-spacing);
|
|
2507
2863
|
}
|
|
2508
2864
|
|
|
2509
2865
|
:host([size="small"]) .label {
|
|
@@ -2514,6 +2870,14 @@ RelCheckbox.styles = css4`
|
|
|
2514
2870
|
letter-spacing: var(--rel-body-sm-regular-letter-spacing);
|
|
2515
2871
|
}
|
|
2516
2872
|
|
|
2873
|
+
:host([size="small"]) .text {
|
|
2874
|
+
font-family: var(--rel-body-xs-regular-font-family);
|
|
2875
|
+
font-weight: var(--rel-body-xs-regular-font-weight);
|
|
2876
|
+
font-size: var(--rel-body-xs-regular-font-size);
|
|
2877
|
+
line-height: var(--rel-body-xs-regular-line-height);
|
|
2878
|
+
letter-spacing: var(--rel-body-xs-regular-letter-spacing);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2517
2881
|
:host([size="large"]) .label {
|
|
2518
2882
|
font-family: var(--rel-body-lg-regular-font-family);
|
|
2519
2883
|
font-weight: var(--rel-body-lg-regular-font-weight);
|
|
@@ -2523,11 +2887,11 @@ RelCheckbox.styles = css4`
|
|
|
2523
2887
|
}
|
|
2524
2888
|
|
|
2525
2889
|
:host([size="large"]) .text {
|
|
2526
|
-
font-family: var(--rel-body-
|
|
2527
|
-
font-weight: var(--rel-body-
|
|
2528
|
-
font-size: var(--rel-body-
|
|
2529
|
-
line-height: var(--rel-body-
|
|
2530
|
-
letter-spacing: var(--rel-body-
|
|
2890
|
+
font-family: var(--rel-body-md-regular-font-family);
|
|
2891
|
+
font-weight: var(--rel-body-md-regular-font-weight);
|
|
2892
|
+
font-size: var(--rel-body-md-regular-font-size);
|
|
2893
|
+
line-height: var(--rel-body-md-regular-line-height);
|
|
2894
|
+
letter-spacing: var(--rel-body-md-regular-letter-spacing);
|
|
2531
2895
|
}
|
|
2532
2896
|
|
|
2533
2897
|
:host([size="extra-large"]) .label {
|
|
@@ -2539,11 +2903,11 @@ RelCheckbox.styles = css4`
|
|
|
2539
2903
|
}
|
|
2540
2904
|
|
|
2541
2905
|
:host([size="extra-large"]) .text {
|
|
2542
|
-
font-family: var(--rel-body-
|
|
2543
|
-
font-weight: var(--rel-body-
|
|
2544
|
-
font-size: var(--rel-body-
|
|
2545
|
-
line-height: var(--rel-body-
|
|
2546
|
-
letter-spacing: var(--rel-body-
|
|
2906
|
+
font-family: var(--rel-body-lg-regular-font-family);
|
|
2907
|
+
font-weight: var(--rel-body-lg-regular-font-weight);
|
|
2908
|
+
font-size: var(--rel-body-lg-regular-font-size);
|
|
2909
|
+
line-height: var(--rel-body-lg-regular-line-height);
|
|
2910
|
+
letter-spacing: var(--rel-body-lg-regular-letter-spacing);
|
|
2547
2911
|
}
|
|
2548
2912
|
`;
|
|
2549
2913
|
__decorateClass([
|
|
@@ -2573,6 +2937,9 @@ __decorateClass([
|
|
|
2573
2937
|
__decorateClass([
|
|
2574
2938
|
property4({ type: String, reflect: true })
|
|
2575
2939
|
], RelCheckbox.prototype, "alignment", 2);
|
|
2940
|
+
__decorateClass([
|
|
2941
|
+
property4({ type: String, attribute: "field-aria-label" })
|
|
2942
|
+
], RelCheckbox.prototype, "fieldAriaLabel", 2);
|
|
2576
2943
|
RelCheckbox = __decorateClass([
|
|
2577
2944
|
customElement4("rel-checkbox")
|
|
2578
2945
|
], RelCheckbox);
|
|
@@ -3658,6 +4025,14 @@ var RelAutocomplete = class extends LitElement7 {
|
|
|
3658
4025
|
get optionCheckboxSize() {
|
|
3659
4026
|
return this.size;
|
|
3660
4027
|
}
|
|
4028
|
+
/** Native tooltip carrying the row's full text. Both lines truncate to one
|
|
4029
|
+
* line with an ellipsis, so a long supporting line is otherwise unrecoverable
|
|
4030
|
+
* by sight; hovering the row reveals it. Only rows that carry a second line
|
|
4031
|
+
* get one, so plain lists do not sprout tooltips. */
|
|
4032
|
+
rowTitle(item) {
|
|
4033
|
+
return item.supportingText ? `${item.label}
|
|
4034
|
+
${item.supportingText}` : nothing4;
|
|
4035
|
+
}
|
|
3661
4036
|
get selectAllStatus() {
|
|
3662
4037
|
const filtered = this.filteredItems.filter((i) => !i.disabled);
|
|
3663
4038
|
if (filtered.length === 0) return "unchecked";
|
|
@@ -3935,6 +4310,7 @@ var RelAutocomplete = class extends LitElement7 {
|
|
|
3935
4310
|
role="option"
|
|
3936
4311
|
aria-selected="${this.selectedValues.includes(item.value)}"
|
|
3937
4312
|
aria-disabled="${item.disabled ? "true" : nothing4}"
|
|
4313
|
+
title="${this.rowTitle(item)}"
|
|
3938
4314
|
@mousedown="${(e) => e.preventDefault()}"
|
|
3939
4315
|
@click="${() => this.toggleMultiOption(item)}"
|
|
3940
4316
|
@pointerenter="${() => {
|
|
@@ -3958,6 +4334,7 @@ var RelAutocomplete = class extends LitElement7 {
|
|
|
3958
4334
|
role="option"
|
|
3959
4335
|
aria-selected="${item.value === this.value}"
|
|
3960
4336
|
aria-disabled="${item.disabled ? "true" : nothing4}"
|
|
4337
|
+
title="${this.rowTitle(item)}"
|
|
3961
4338
|
@mousedown="${(e) => e.preventDefault()}"
|
|
3962
4339
|
@click="${() => this.selectItem(item)}"
|
|
3963
4340
|
@pointerenter="${() => {
|
|
@@ -4000,7 +4377,11 @@ RelAutocomplete.styles = css8`
|
|
|
4000
4377
|
surface with a 4px radius and shadow/md, and no border. ── */
|
|
4001
4378
|
.ac-panel {
|
|
4002
4379
|
position: fixed;
|
|
4003
|
-
|
|
4380
|
+
/* Only consulted on the no-Popover-API fallback path: once the panel is
|
|
4381
|
+
promoted to the top layer, z-index is inert and stacking is by
|
|
4382
|
+
promotion order. Shared with the pickers so the whole overlay family
|
|
4383
|
+
is overridable from one place. */
|
|
4384
|
+
z-index: var(--rel-overlay-fallback-z, 1001);
|
|
4004
4385
|
display: none;
|
|
4005
4386
|
/* Reset native [popover] UA styles (the panel is promoted to the
|
|
4006
4387
|
top layer so transformed ancestors — e.g. a centered rel-modal
|
|
@@ -4086,13 +4467,32 @@ RelAutocomplete.styles = css8`
|
|
|
4086
4467
|
}
|
|
4087
4468
|
|
|
4088
4469
|
/* Hover and the keyboard highlight share Figma's Hover wash; Active —
|
|
4089
|
-
the committed selection — reuses it and bumps the label to medium.
|
|
4090
|
-
|
|
4470
|
+
the committed selection — reuses it and bumps the label to medium.
|
|
4471
|
+
|
|
4472
|
+
NOTE there is deliberately no :hover in this rule. The pointerenter
|
|
4473
|
+
handler already moves highlightedIndex onto the hovered row, so hover
|
|
4474
|
+
is expressed through .highlighted and a :hover selector here would be
|
|
4475
|
+
redundant — except in one case, where it was a real defect: arrowing
|
|
4476
|
+
the list moves .highlighted but CANNOT clear a CSS :hover, so the row
|
|
4477
|
+
the mouse happens to rest on stayed lit alongside the keyboard cursor
|
|
4478
|
+
and the committed selection. Three rows painted the same wash for
|
|
4479
|
+
three different meanings, and the user could not tell which one Enter
|
|
4480
|
+
would commit. Measured before the fix: rows 0/2/4 all rgb(243,243,243)
|
|
4481
|
+
with row 2 carrying :hover and .highlighted=false. */
|
|
4091
4482
|
.ac-option.highlighted,
|
|
4092
4483
|
.ac-option[aria-selected="true"] {
|
|
4093
4484
|
background: var(--rel-autocomplete-option-hover, var(--rel-action-overlay-hover));
|
|
4094
4485
|
}
|
|
4095
4486
|
|
|
4487
|
+
/* The Select-all row is the one row with no pointerenter handler (it is
|
|
4488
|
+
not in the options list, so it has no index to highlight), so it keeps
|
|
4489
|
+
a plain :hover. rel-select instead carries a selectAllHighlighted
|
|
4490
|
+
state for this; autocomplete has no such state and does not need one
|
|
4491
|
+
for a row that cannot be reached by the arrow keys. */
|
|
4492
|
+
.ac-select-all:hover {
|
|
4493
|
+
background: var(--rel-autocomplete-option-hover, var(--rel-action-overlay-hover));
|
|
4494
|
+
}
|
|
4495
|
+
|
|
4096
4496
|
.ac-option:active {
|
|
4097
4497
|
background: var(--rel-autocomplete-option-pressed, var(--rel-action-overlay-pressed));
|
|
4098
4498
|
}
|
|
@@ -4190,6 +4590,28 @@ RelAutocomplete.styles = css8`
|
|
|
4190
4590
|
letter-spacing: var(--rel-body-sm-regular-letter-spacing);
|
|
4191
4591
|
}
|
|
4192
4592
|
|
|
4593
|
+
/* Multi-select rows delegate to a nested rel-checkbox, which wraps its
|
|
4594
|
+
text by default, so the SAME item rendered as one truncated line in
|
|
4595
|
+
single mode and as three wrapped lines — or a sideways-overflowing
|
|
4596
|
+
identifier — under multiple (measured 2026-09-26: row 58px vs 120px,
|
|
4597
|
+
panel scrollWidth 326 in a 320 panel). The checkbox's label and helper
|
|
4598
|
+
are exposed as parts, so the row can reach them from here and apply the
|
|
4599
|
+
same one-line ellipsis; the host itself needs min-width: 0 or the flex
|
|
4600
|
+
row lets it grow past the panel instead of shrinking its text. */
|
|
4601
|
+
.ac-option > rel-checkbox {
|
|
4602
|
+
flex: 1;
|
|
4603
|
+
min-width: 0;
|
|
4604
|
+
}
|
|
4605
|
+
|
|
4606
|
+
.ac-option > rel-checkbox::part(label),
|
|
4607
|
+
.ac-option > rel-checkbox::part(helper) {
|
|
4608
|
+
display: block;
|
|
4609
|
+
min-width: 0;
|
|
4610
|
+
overflow: hidden;
|
|
4611
|
+
text-overflow: ellipsis;
|
|
4612
|
+
white-space: nowrap;
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4193
4615
|
:host([size="large"]) .ac-option-supporting {
|
|
4194
4616
|
font-family: var(--rel-body-md-regular-font-family);
|
|
4195
4617
|
font-weight: var(--rel-body-md-regular-font-weight);
|
|
@@ -6554,9 +6976,23 @@ RelCarousel.styles = css14`
|
|
|
6554
6976
|
height: 100%;
|
|
6555
6977
|
}
|
|
6556
6978
|
|
|
6979
|
+
/* The height override stretches a content-height slide to fill the fixed
|
|
6980
|
+
viewport, so a caption that used to hug its text was left anchored to
|
|
6981
|
+
the top of a much taller box — measured 84px of content top-aligned in
|
|
6982
|
+
180px, i.e. ~96px of dead space beneath it. Figma centres it: every
|
|
6983
|
+
vertical variant has the Content slot on justify=CENTER align=CENTER.
|
|
6984
|
+
Centring needs a formatting context the slide does not otherwise have,
|
|
6985
|
+
hence the flex display. It is behind a hook because forcing flex onto a
|
|
6986
|
+
consumer's slotted element can reflow that element's own children —
|
|
6987
|
+
set --rel-carousel-slide-display: block to opt out. Scoped to vertical
|
|
6988
|
+
only: horizontal slides are content-height, so there is nothing to
|
|
6989
|
+
centre and no reason to touch their box model. */
|
|
6557
6990
|
:host([orientation="vertical"]) ::slotted(*) {
|
|
6558
6991
|
width: 100% !important;
|
|
6559
6992
|
height: var(--rel-carousel-slide-height, 100%) !important;
|
|
6993
|
+
display: var(--rel-carousel-slide-display, flex);
|
|
6994
|
+
flex-direction: column;
|
|
6995
|
+
justify-content: center;
|
|
6560
6996
|
}
|
|
6561
6997
|
|
|
6562
6998
|
.track.dragging {
|
|
@@ -6676,6 +7112,50 @@ RelCarousel.styles = css14`
|
|
|
6676
7112
|
transform: none;
|
|
6677
7113
|
}
|
|
6678
7114
|
|
|
7115
|
+
/* Vertical orientation: the arrows ride the axis the carousel actually
|
|
7116
|
+
moves on. Without this they kept the horizontal placement — measured
|
|
7117
|
+
prev at x=32 y=98 and next at x=264 y=98, i.e. mid-left and mid-right,
|
|
7118
|
+
identical to the horizontal case — so they flanked the caption from
|
|
7119
|
+
both sides while the motion was up and down. Figma nests both arrow
|
|
7120
|
+
buttons in a layout=col Stack for the vertical variants. */
|
|
7121
|
+
:host([orientation="vertical"]) .nav-button {
|
|
7122
|
+
top: auto;
|
|
7123
|
+
left: 50%;
|
|
7124
|
+
right: auto;
|
|
7125
|
+
transform: translateX(-50%);
|
|
7126
|
+
}
|
|
7127
|
+
|
|
7128
|
+
:host([orientation="vertical"]) .nav-button.prev {
|
|
7129
|
+
top: var(--rel-spacing-xs);
|
|
7130
|
+
left: 50%;
|
|
7131
|
+
}
|
|
7132
|
+
|
|
7133
|
+
:host([orientation="vertical"]) .nav-button.next {
|
|
7134
|
+
bottom: var(--rel-spacing-xs);
|
|
7135
|
+
right: auto;
|
|
7136
|
+
}
|
|
7137
|
+
|
|
7138
|
+
/* chevron-left becomes up, chevron-right becomes down. Applied to the
|
|
7139
|
+
bottom-arrows row too: those buttons still page the carousel along the
|
|
7140
|
+
vertical axis, so a left/right glyph would misdescribe them wherever
|
|
7141
|
+
they sit. */
|
|
7142
|
+
:host([orientation="vertical"]) .nav-button svg {
|
|
7143
|
+
transform: rotate(90deg);
|
|
7144
|
+
}
|
|
7145
|
+
|
|
7146
|
+
/* Type=Bottom Arrows keeps its arrows inline in the indicators row even
|
|
7147
|
+
when the carousel is vertical. This rule has to restate the reset
|
|
7148
|
+
because the vertical block above outranks the plain .dots .nav-button
|
|
7149
|
+
selector (0,3,0 against 0,2,0). */
|
|
7150
|
+
:host([orientation="vertical"]) .dots .nav-button {
|
|
7151
|
+
position: static;
|
|
7152
|
+
transform: none;
|
|
7153
|
+
top: auto;
|
|
7154
|
+
bottom: auto;
|
|
7155
|
+
left: auto;
|
|
7156
|
+
right: auto;
|
|
7157
|
+
}
|
|
7158
|
+
|
|
6679
7159
|
.nav-button svg {
|
|
6680
7160
|
width: 16px;
|
|
6681
7161
|
height: 16px;
|
|
@@ -6910,6 +7390,7 @@ RelCardMedia = __decorateClass([
|
|
|
6910
7390
|
import { LitElement as LitElement15, html as html15, css as css16, nothing as nothing8 } from "lit";
|
|
6911
7391
|
import { customElement as customElement15, property as property15, state as state6, query as query3 } from "lit/decorators.js";
|
|
6912
7392
|
import { live } from "lit/directives/live.js";
|
|
7393
|
+
import "@reliability-design/icons/rel-icon";
|
|
6913
7394
|
|
|
6914
7395
|
// src/components/rel-date-picker/date-format.ts
|
|
6915
7396
|
function formatDatePattern(date, pattern, locale = "en-US") {
|
|
@@ -7270,6 +7751,34 @@ var RelDatePicker = class extends LitElement15 {
|
|
|
7270
7751
|
this.onReposition = () => {
|
|
7271
7752
|
if (this.open) this.updatePopoverPosition();
|
|
7272
7753
|
};
|
|
7754
|
+
/* Outside scroll CLOSES the panel; it used to reposition.
|
|
7755
|
+
*
|
|
7756
|
+
* Repositioning is what produced the reported "panel floats over the site
|
|
7757
|
+
* header" bug: the panel is in the top layer, so it paints above all normal
|
|
7758
|
+
* content by definition, and tracking a trigger that scrolls under a sticky
|
|
7759
|
+
* nav leaves a calendar anchored to something nobody can see. No z-index can
|
|
7760
|
+
* fix that — the top layer is not in the page's stacking order at all.
|
|
7761
|
+
*
|
|
7762
|
+
* rel-select reached the same conclusion for its own listbox and its comment
|
|
7763
|
+
* records the other half of the reason: re-deriving fixed coordinates on
|
|
7764
|
+
* every scroll event is fragile and was making the panel jump to the top of
|
|
7765
|
+
* the viewport. This is that pattern, so the family now behaves alike.
|
|
7766
|
+
*
|
|
7767
|
+
* Scrolling the panel's OWN content must not close it. The test is
|
|
7768
|
+
* CONTAINMENT, not equality with one known scroller: the panel is itself
|
|
7769
|
+
* `overflow-y: auto` today, but the same guard has to hold for any scroller
|
|
7770
|
+
* nested inside it (rel-time-picker's per-column lists are exactly that
|
|
7771
|
+
* case), and an equality test would silently close the panel the first time
|
|
7772
|
+
* one is added.
|
|
7773
|
+
*
|
|
7774
|
+
* resize still repositions — a resize keeps the trigger on screen, so there
|
|
7775
|
+
* is nothing to run away from. */
|
|
7776
|
+
this.onPageScroll = (e) => {
|
|
7777
|
+
if (!this.open) return;
|
|
7778
|
+
const panel = this.popoverElement;
|
|
7779
|
+
if (panel && e.composedPath().includes(panel)) return;
|
|
7780
|
+
this.closeCalendarPopover(false);
|
|
7781
|
+
};
|
|
7273
7782
|
this.updatePopoverPosition = () => {
|
|
7274
7783
|
const panel = this.popoverElement;
|
|
7275
7784
|
const anchor = this.triggerButton;
|
|
@@ -7395,7 +7904,7 @@ var RelDatePicker = class extends LitElement15 {
|
|
|
7395
7904
|
document.removeEventListener("click", this.handleClickOutside);
|
|
7396
7905
|
document.removeEventListener("keydown", this.handleEscapeKey);
|
|
7397
7906
|
window.removeEventListener("resize", this.onReposition);
|
|
7398
|
-
window.removeEventListener("scroll", this.
|
|
7907
|
+
window.removeEventListener("scroll", this.onPageScroll, true);
|
|
7399
7908
|
this.hideTopLayer();
|
|
7400
7909
|
}
|
|
7401
7910
|
updated(changedProperties) {
|
|
@@ -7409,7 +7918,7 @@ var RelDatePicker = class extends LitElement15 {
|
|
|
7409
7918
|
if (changedProperties.has("open")) {
|
|
7410
7919
|
if (!this.open) {
|
|
7411
7920
|
window.removeEventListener("resize", this.onReposition);
|
|
7412
|
-
window.removeEventListener("scroll", this.
|
|
7921
|
+
window.removeEventListener("scroll", this.onPageScroll, true);
|
|
7413
7922
|
this.hideTopLayer();
|
|
7414
7923
|
this.focusPanelOnOpen = true;
|
|
7415
7924
|
this.panelView = "days";
|
|
@@ -7417,7 +7926,7 @@ var RelDatePicker = class extends LitElement15 {
|
|
|
7417
7926
|
}
|
|
7418
7927
|
this.showTopLayer();
|
|
7419
7928
|
window.addEventListener("resize", this.onReposition);
|
|
7420
|
-
window.addEventListener("scroll", this.
|
|
7929
|
+
window.addEventListener("scroll", this.onPageScroll, true);
|
|
7421
7930
|
const focusPanel = this.focusPanelOnOpen;
|
|
7422
7931
|
this.focusPanelOnOpen = true;
|
|
7423
7932
|
if (focusPanel) {
|
|
@@ -7929,6 +8438,28 @@ var RelDatePicker = class extends LitElement15 {
|
|
|
7929
8438
|
* Index of the single grid cell carrying tabindex="0". The old expression
|
|
7930
8439
|
* (`isSelected || (index === 0 && !value)`) left the grid with ZERO tab
|
|
7931
8440
|
* stops once the user arrowed around without a selection.
|
|
8441
|
+
*
|
|
8442
|
+
* KNOWN GAP (deferred from 1.4.9, needs a ticket covering BOTH calendars):
|
|
8443
|
+
* `focusedDate` is read here as the first candidate but is never assigned
|
|
8444
|
+
* anywhere in this file, so it is permanently null and the tab stop tracks
|
|
8445
|
+
* the SELECTION rather than focus. Tab out of the grid and back and you
|
|
8446
|
+
* return to the selected day, not the day you left.
|
|
8447
|
+
*
|
|
8448
|
+
* Wiring it from each cell's `@focus` was tried and is NOT sufficient, which
|
|
8449
|
+
* is why this is still open. Measured 2026-09-25 by driving real keys: from
|
|
8450
|
+
* Sun 27 Sep, one ArrowDown lands focus on Sun 25 Oct instead of Sun 4 Oct,
|
|
8451
|
+
* because handleDayKeydown moves by grid INDEX and then lets the view flip
|
|
8452
|
+
* month, so index+7 addresses a different date in the new grid. lit reuses
|
|
8453
|
+
* the same button elements across that re-render, so the focused element
|
|
8454
|
+
* never changes and NO focus event fires — the handler cannot observe the
|
|
8455
|
+
* move it is supposed to record.
|
|
8456
|
+
*
|
|
8457
|
+
* The real fix is to navigate by DATE and set the rove explicitly before
|
|
8458
|
+
* focusing, the way rel-range-picker.moveGridFocusTo already does (verified
|
|
8459
|
+
* correct in the same run: Sep 27 → Oct 4 → Oct 11, tab stop and focus
|
|
8460
|
+
* agreeing at every step). That is a rewrite of this component's arrow-key
|
|
8461
|
+
* model, not a contained fix, and it must land on both calendars together or
|
|
8462
|
+
* they diverge again.
|
|
7932
8463
|
*/
|
|
7933
8464
|
getRovingIndex(days) {
|
|
7934
8465
|
const candidates = [
|
|
@@ -8143,11 +8674,11 @@ var RelDatePicker = class extends LitElement15 {
|
|
|
8143
8674
|
?disabled=${this.disabled}
|
|
8144
8675
|
@click=${this.handleIconClick}
|
|
8145
8676
|
>
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
|
|
8677
|
+
<!-- Figma pins this suffix to node 5137:1877, which resolves to
|
|
8678
|
+
COMPONENT_SET 5137:1876 "Calendar Month" — verified against
|
|
8679
|
+
the live Published DS file 2026-09-25. This was a hand-drawn
|
|
8680
|
+
inline path that matched no icon in the set. -->
|
|
8681
|
+
<rel-icon name="calendar-month"></rel-icon>
|
|
8151
8682
|
</button>
|
|
8152
8683
|
</span>
|
|
8153
8684
|
</div>
|
|
@@ -8506,18 +9037,28 @@ RelDatePicker.styles = css16`
|
|
|
8506
9037
|
}
|
|
8507
9038
|
|
|
8508
9039
|
/* Figma's suffix icon is 18px at medium; rel-textfield ramps 16 / 18 / 20
|
|
8509
|
-
across the size scale, so the trigger follows the same ramp.
|
|
8510
|
-
|
|
9040
|
+
across the size scale, so the trigger follows the same ramp.
|
|
9041
|
+
Both selectors are needed and neither is redundant: the CLEAR (×) button
|
|
9042
|
+
is still a raw inline <svg>, while the calendar suffix is now a
|
|
9043
|
+
<rel-icon> whose own svg lives in a shadow root that .icon-button svg
|
|
9044
|
+
cannot reach. Sizing the rel-icon ELEMENT works because a declaration
|
|
9045
|
+
from the outer tree beats the :host([size]) rules inside it — which is
|
|
9046
|
+
also why the element carries no size attribute: one source for the
|
|
9047
|
+
ramp, not two that have to agree. */
|
|
9048
|
+
.icon-button svg,
|
|
9049
|
+
.icon-button rel-icon {
|
|
8511
9050
|
width: 18px;
|
|
8512
9051
|
height: 18px;
|
|
8513
9052
|
}
|
|
8514
9053
|
|
|
8515
|
-
.date-picker-wrapper.small .icon-button svg
|
|
9054
|
+
.date-picker-wrapper.small .icon-button svg,
|
|
9055
|
+
.date-picker-wrapper.small .icon-button rel-icon {
|
|
8516
9056
|
width: 16px;
|
|
8517
9057
|
height: 16px;
|
|
8518
9058
|
}
|
|
8519
9059
|
|
|
8520
|
-
.date-picker-wrapper.large .icon-button svg
|
|
9060
|
+
.date-picker-wrapper.large .icon-button svg,
|
|
9061
|
+
.date-picker-wrapper.large .icon-button rel-icon {
|
|
8521
9062
|
width: 20px;
|
|
8522
9063
|
height: 20px;
|
|
8523
9064
|
}
|
|
@@ -8541,7 +9082,14 @@ RelDatePicker.styles = css16`
|
|
|
8541
9082
|
position: absolute;
|
|
8542
9083
|
top: calc(100% + 8px);
|
|
8543
9084
|
left: 0;
|
|
8544
|
-
|
|
9085
|
+
/* Fallback stacking ONLY: while the panel is in the top layer this is
|
|
9086
|
+
inert, because top-layer elements are not in the page stacking order at
|
|
9087
|
+
all and order of promotion decides. It applies solely where the Popover
|
|
9088
|
+
API is unavailable. One overridable hook across every overlay, so a
|
|
9089
|
+
consumer with its own stacking scheme sets it once; the default matches
|
|
9090
|
+
rel-select / rel-autocomplete / rel-popover / rel-menubar. This was
|
|
9091
|
+
2147483647, which cannot be layered against anything. */
|
|
9092
|
+
z-index: var(--rel-overlay-fallback-z, 1001);
|
|
8545
9093
|
background: var(--rel-bg-neutral);
|
|
8546
9094
|
border-radius: var(--rel-border-radius-sm);
|
|
8547
9095
|
/* Figma ".Date Picker Calendar" is a shadowed card with no border.
|
|
@@ -9205,6 +9753,21 @@ var RelRangePicker = class extends LitElement16 {
|
|
|
9205
9753
|
this.onReposition = () => {
|
|
9206
9754
|
if (this.open && this.mode === "popover") this.updatePopoverPosition();
|
|
9207
9755
|
};
|
|
9756
|
+
/* Outside scroll CLOSES the panel; it used to reposition. Same change as
|
|
9757
|
+
* rel-date-picker and rel-time-picker — see the long note in
|
|
9758
|
+
* rel-date-picker.onPageScroll for why tracking the trigger is what put the
|
|
9759
|
+
* panel over the sticky site header, and why z-index cannot fix it.
|
|
9760
|
+
*
|
|
9761
|
+
* ONLY in popover mode. `popup` mode centres the shell in the viewport behind
|
|
9762
|
+
* a backdrop and never anchors to the trigger, so there is no trigger to
|
|
9763
|
+
* scroll away from and closing on scroll would just be a dialog that vanishes
|
|
9764
|
+
* when the page moves. Guarded exactly as onReposition above is. */
|
|
9765
|
+
this.onPageScroll = (e) => {
|
|
9766
|
+
if (!this.open || this.mode !== "popover") return;
|
|
9767
|
+
const panel = this.popoverEl;
|
|
9768
|
+
if (panel && e.composedPath().includes(panel)) return;
|
|
9769
|
+
this.cancelPopover();
|
|
9770
|
+
};
|
|
9208
9771
|
this.updatePopoverPosition = () => {
|
|
9209
9772
|
const panel = this.popoverEl;
|
|
9210
9773
|
if (!panel || !this.open || this.mode !== "popover") return;
|
|
@@ -9417,7 +9980,7 @@ var RelRangePicker = class extends LitElement16 {
|
|
|
9417
9980
|
document.removeEventListener("click", this.handleClickOutside);
|
|
9418
9981
|
document.removeEventListener("keydown", this.handleGlobalKeydown);
|
|
9419
9982
|
window.removeEventListener("resize", this.onReposition);
|
|
9420
|
-
window.removeEventListener("scroll", this.
|
|
9983
|
+
window.removeEventListener("scroll", this.onPageScroll, true);
|
|
9421
9984
|
this.hideTopLayer();
|
|
9422
9985
|
}
|
|
9423
9986
|
updated(changed) {
|
|
@@ -9431,10 +9994,10 @@ var RelRangePicker = class extends LitElement16 {
|
|
|
9431
9994
|
if (this.open) {
|
|
9432
9995
|
this.showTopLayer();
|
|
9433
9996
|
window.addEventListener("resize", this.onReposition);
|
|
9434
|
-
window.addEventListener("scroll", this.
|
|
9997
|
+
window.addEventListener("scroll", this.onPageScroll, true);
|
|
9435
9998
|
} else {
|
|
9436
9999
|
window.removeEventListener("resize", this.onReposition);
|
|
9437
|
-
window.removeEventListener("scroll", this.
|
|
10000
|
+
window.removeEventListener("scroll", this.onPageScroll, true);
|
|
9438
10001
|
this.hideTopLayer();
|
|
9439
10002
|
}
|
|
9440
10003
|
}
|
|
@@ -10048,6 +10611,21 @@ var RelRangePicker = class extends LitElement16 {
|
|
|
10048
10611
|
* The cell that carries tabindex="0". Prefers the last roved date, then the
|
|
10049
10612
|
* draft start, then today, then the first selectable day — always exactly
|
|
10050
10613
|
* one Tab stop, even after arrowing away with nothing selected.
|
|
10614
|
+
*
|
|
10615
|
+
* `focusedDate` is written by moveGridFocusTo, which sets it from the DATE
|
|
10616
|
+
* before moving focus rather than reading it back off a focus event. That
|
|
10617
|
+
* ordering is what makes the arrow keys correct across a month boundary —
|
|
10618
|
+
* verified 2026-09-25 by driving real keys: Sep 27 → Oct 4 → Oct 11 with the
|
|
10619
|
+
* tab stop and focus agreeing at every step, including the view change.
|
|
10620
|
+
* rel-date-picker moves by grid INDEX instead and lands on the wrong date
|
|
10621
|
+
* there; see the note on its getRovingIndex. Do not "simplify" this to a
|
|
10622
|
+
* focus listener — lit reuses the same cell elements across the re-render, so
|
|
10623
|
+
* the focused element does not change and no focus event fires.
|
|
10624
|
+
*
|
|
10625
|
+
* Still open for BOTH calendars: focus arriving from a click or from Tab does
|
|
10626
|
+
* not update the rove, only the arrow keys do. Deferred from 1.4.9 as a
|
|
10627
|
+
* shared ticket, since fixing one calendar alone reintroduces the divergence
|
|
10628
|
+
* the shared period-page module exists to prevent.
|
|
10051
10629
|
*/
|
|
10052
10630
|
getRovingIso(days) {
|
|
10053
10631
|
const inView = days.filter((d) => d.isCurrentMonth);
|
|
@@ -10103,7 +10681,14 @@ var RelRangePicker = class extends LitElement16 {
|
|
|
10103
10681
|
aria-label=${triggerLabel || this.placeholder}
|
|
10104
10682
|
@click=${() => this.toggleOpen()}
|
|
10105
10683
|
>
|
|
10106
|
-
|
|
10684
|
+
<!-- Deliberately the SAME icon as rel-date-picker, at the user's
|
|
10685
|
+
request, so the two triggers read as one family. Figma does
|
|
10686
|
+
NOT specify a trigger icon here: the range-picker spec lines
|
|
10687
|
+
only carry node 5137:2842, which resolves to "Plus" and
|
|
10688
|
+
appears in 440 places across the component specs — it is the
|
|
10689
|
+
un-overridden INSTANCE_SWAP default, not a choice. So this is
|
|
10690
|
+
ours to pick, and it should move with the date picker. -->
|
|
10691
|
+
<rel-icon name="calendar-month"></rel-icon>
|
|
10107
10692
|
</rel-button>
|
|
10108
10693
|
` : html16`
|
|
10109
10694
|
<div
|
|
@@ -10158,9 +10743,7 @@ var RelRangePicker = class extends LitElement16 {
|
|
|
10158
10743
|
?disabled=${this.disabled}
|
|
10159
10744
|
@click=${this.handleIconClick}
|
|
10160
10745
|
>
|
|
10161
|
-
<
|
|
10162
|
-
<path d="M4 2h1V1h2v1h2V1h2v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 3H4v7h8V5z"/>
|
|
10163
|
-
</svg>
|
|
10746
|
+
<rel-icon name="calendar-month"></rel-icon>
|
|
10164
10747
|
</button>
|
|
10165
10748
|
</span>
|
|
10166
10749
|
</div>
|
|
@@ -10531,9 +11114,18 @@ RelRangePicker.styles = css17`
|
|
|
10531
11114
|
color: var(--rel-text-default);
|
|
10532
11115
|
}
|
|
10533
11116
|
|
|
10534
|
-
.
|
|
11117
|
+
/* Both selectors, deliberately. The calendar trigger is a <rel-icon>,
|
|
11118
|
+
whose own svg lives in a shadow root that a plain .icon-button svg
|
|
11119
|
+
selector cannot reach — so sizing only the raw svg would leave it at its
|
|
11120
|
+
component default. The clear (x) button is still a hand-drawn inline
|
|
11121
|
+
svg, so the raw selector has to stay as well. Sizing the rel-icon
|
|
11122
|
+
ELEMENT works because a declaration from the outer tree beats the
|
|
11123
|
+
:host([size]) rules inside it. Same fix as rel-date-picker. */
|
|
11124
|
+
.icon-button svg,
|
|
11125
|
+
.icon-button rel-icon {
|
|
10535
11126
|
width: 16px;
|
|
10536
11127
|
height: 16px;
|
|
11128
|
+
--rel-icon-size: 16px;
|
|
10537
11129
|
}
|
|
10538
11130
|
|
|
10539
11131
|
/* ── Popover shell ── */
|
|
@@ -10561,7 +11153,14 @@ RelRangePicker.styles = css17`
|
|
|
10561
11153
|
position: absolute;
|
|
10562
11154
|
top: calc(100% + 8px);
|
|
10563
11155
|
left: 0;
|
|
10564
|
-
|
|
11156
|
+
/* Fallback stacking ONLY: while the panel is in the top layer this is
|
|
11157
|
+
inert, because top-layer elements are not in the page stacking order at
|
|
11158
|
+
all and order of promotion decides. It applies solely where the Popover
|
|
11159
|
+
API is unavailable. One overridable hook across every overlay, so a
|
|
11160
|
+
consumer with its own stacking scheme sets it once; the default matches
|
|
11161
|
+
rel-select / rel-autocomplete / rel-popover / rel-menubar. This was
|
|
11162
|
+
2147483647, which cannot be layered against anything. */
|
|
11163
|
+
z-index: var(--rel-overlay-fallback-z, 1001);
|
|
10565
11164
|
border-radius: var(--rel-border-radius-sm, 8px);
|
|
10566
11165
|
background: var(--rel-bg-neutral);
|
|
10567
11166
|
/* Figma effectStyle=shadow/xl — the token now exists. */
|
|
@@ -10595,7 +11194,8 @@ RelRangePicker.styles = css17`
|
|
|
10595
11194
|
width: auto;
|
|
10596
11195
|
height: auto;
|
|
10597
11196
|
border: none;
|
|
10598
|
-
|
|
11197
|
+
/* One below the shell, so the backdrop never covers its own panel. */
|
|
11198
|
+
z-index: calc(var(--rel-overlay-fallback-z, 1001) - 1);
|
|
10599
11199
|
background: rgba(0,0,0,0.4);
|
|
10600
11200
|
}
|
|
10601
11201
|
|
|
@@ -12315,18 +12915,27 @@ var RelRadio = class extends LitElement23 {
|
|
|
12315
12915
|
};
|
|
12316
12916
|
this._onTouched = () => {
|
|
12317
12917
|
};
|
|
12918
|
+
/** Whether the author wrote `density` themselves, as opposed to inheriting
|
|
12919
|
+
* the default. Read once at connect: Lit reflects during update, which is
|
|
12920
|
+
* after this runs, so the attribute is present here only if authored. */
|
|
12921
|
+
this.densityExplicit = false;
|
|
12318
12922
|
}
|
|
12319
12923
|
willUpdate(changed) {
|
|
12320
12924
|
if (changed.has("size")) {
|
|
12321
12925
|
const canonical = SIZE_ALIASES5[this.size];
|
|
12322
12926
|
if (canonical) this.size = canonical;
|
|
12323
12927
|
}
|
|
12324
|
-
|
|
12928
|
+
const densityChosen = this.densityExplicit || changed.get("density") !== void 0;
|
|
12929
|
+
if ((changed.has("alignment") || changed.has("density")) && this.alignment === "trailing" && this.density === "compact" && densityChosen) {
|
|
12325
12930
|
console.warn(
|
|
12326
12931
|
'[rel-radio] density="compact" has no trailing layout; rendering comfortable density.'
|
|
12327
12932
|
);
|
|
12328
12933
|
}
|
|
12329
12934
|
}
|
|
12935
|
+
connectedCallback() {
|
|
12936
|
+
super.connectedCallback();
|
|
12937
|
+
this.densityExplicit = this.hasAttribute("density");
|
|
12938
|
+
}
|
|
12330
12939
|
/** Trailing has no compact layout in the spec — trailing wins. */
|
|
12331
12940
|
get effectiveDensity() {
|
|
12332
12941
|
return this.alignment === "trailing" ? "comfortable" : this.density;
|
|
@@ -12594,13 +13203,22 @@ RelRadio.styles = css24`
|
|
|
12594
13203
|
letter-spacing: var(--rel-body-lg-regular-letter-spacing);
|
|
12595
13204
|
}
|
|
12596
13205
|
|
|
13206
|
+
/* Supporting text sits ONE step below the label on the body scale at every
|
|
13207
|
+
size — 14/12 small, 16/14 medium, 20/16 large (2026-09-26 Trello ticket,
|
|
13208
|
+
"visual adjustment", confirmed by the designer as applying to every
|
|
13209
|
+
label + supporting pair, not just the checkbox). It used to be two steps
|
|
13210
|
+
below. The Figma ticket covered the Checkbox set only; extending the
|
|
13211
|
+
rule to Radio was proposed from the code side and approved by the
|
|
13212
|
+
designer on 2026-09-26, so the Radio Button set (lastModified
|
|
13213
|
+
2026-09-23) still shows the old two-step pairs (body-xs / body-sm /
|
|
13214
|
+
body-md) and will be updated to match the code, not the other way. */
|
|
12597
13215
|
.radio-supporting {
|
|
12598
13216
|
color: var(--rel-text-caption);
|
|
12599
|
-
font-family: var(--rel-body-
|
|
12600
|
-
font-weight: var(--rel-body-
|
|
12601
|
-
font-size: var(--rel-body-
|
|
12602
|
-
line-height: var(--rel-body-
|
|
12603
|
-
letter-spacing: var(--rel-body-
|
|
13217
|
+
font-family: var(--rel-body-md-regular-font-family);
|
|
13218
|
+
font-weight: var(--rel-body-md-regular-font-weight);
|
|
13219
|
+
font-size: var(--rel-body-md-regular-font-size);
|
|
13220
|
+
line-height: var(--rel-body-md-regular-line-height);
|
|
13221
|
+
letter-spacing: var(--rel-body-md-regular-letter-spacing);
|
|
12604
13222
|
}
|
|
12605
13223
|
|
|
12606
13224
|
/* Compact density — supporting text sits under the whole row, indented
|
|
@@ -12660,11 +13278,11 @@ RelRadio.styles = css24`
|
|
|
12660
13278
|
}
|
|
12661
13279
|
|
|
12662
13280
|
:host([size="large"]) .radio-supporting {
|
|
12663
|
-
font-family: var(--rel-body-
|
|
12664
|
-
font-weight: var(--rel-body-
|
|
12665
|
-
font-size: var(--rel-body-
|
|
12666
|
-
line-height: var(--rel-body-
|
|
12667
|
-
letter-spacing: var(--rel-body-
|
|
13281
|
+
font-family: var(--rel-body-lg-regular-font-family);
|
|
13282
|
+
font-weight: var(--rel-body-lg-regular-font-weight);
|
|
13283
|
+
font-size: var(--rel-body-lg-regular-font-size);
|
|
13284
|
+
line-height: var(--rel-body-lg-regular-line-height);
|
|
13285
|
+
letter-spacing: var(--rel-body-lg-regular-letter-spacing);
|
|
12668
13286
|
}
|
|
12669
13287
|
|
|
12670
13288
|
:host([size="large"]) .radio-supporting.indent {
|
|
@@ -12681,39 +13299,29 @@ RelRadio.styles = css24`
|
|
|
12681
13299
|
letter-spacing: var(--rel-body-sm-semibold-letter-spacing);
|
|
12682
13300
|
}
|
|
12683
13301
|
|
|
12684
|
-
/*
|
|
12685
|
-
|
|
12686
|
-
the RING is inset 2px from the BOX on each side (box - 4), and the DOT
|
|
12687
|
-
is half the ring. M is 20/16/8, L is 24/20/10, so S is 16/12/6 — all
|
|
12688
|
-
integers, so the 2px ring stays crisp at 1x and 2x. Deriving from the
|
|
12689
|
-
stated ring/box = 0.8 instead would have given 12.8 and a 6.4 dot,
|
|
12690
|
-
which is where a 2px border on a fractional circle goes soft. */
|
|
12691
|
-
:host([size="small"]) .radio-custom {
|
|
12692
|
-
width: 16px;
|
|
12693
|
-
height: 16px;
|
|
12694
|
-
}
|
|
13302
|
+
/* SMALL DELIBERATELY DECLARES NO CONTROL — it shares medium's 20px box,
|
|
13303
|
+
16px ring and 8px dot, and only the TYPE below steps down.
|
|
12695
13304
|
|
|
12696
|
-
|
|
12697
|
-
|
|
12698
|
-
|
|
12699
|
-
|
|
13305
|
+
v1.4.8 ticket 11b gave Small its own 16/12/6 control, derived from the
|
|
13306
|
+
premise "the ramp is 16 / 20 / 24". That premise was never read off
|
|
13307
|
+
Figma, and it is wrong. Verified against the LIVE Published DS file on
|
|
13308
|
+
2026-09-25 (set "Radio Button" 8408:395, 45 variants): .Radio Control
|
|
13309
|
+
measures 20×20 in all 15 Small variants, 20×20 in all 15 Medium, and
|
|
13310
|
+
24×24 in all 15 Large. Figma discriminates Small from Medium by type
|
|
13311
|
+
alone. memory/project_web_components.md recorded exactly this on
|
|
13312
|
+
2026-08-21 — "S and M share the 20px control, only typography steps
|
|
13313
|
+
down" — before 1.4.8 overrode it.
|
|
12700
13314
|
|
|
12701
|
-
|
|
12702
|
-
|
|
12703
|
-
|
|
12704
|
-
|
|
13315
|
+
So the correct Small is the ABSENCE of a rule. Adding one back means
|
|
13316
|
+
re-deriving it from Figma, not from the invariant the other two sizes
|
|
13317
|
+
happen to satisfy: that invariant (ring = box − 4, dot = ring ÷ 2) is a
|
|
13318
|
+
coincidence of rounding, since Figma's ring is proportional at 0.8 and
|
|
13319
|
+
our Large ring is a deliberate 19.2 → 20. */
|
|
12705
13320
|
|
|
12706
|
-
/* The compact indent is box + the 8px row gap,
|
|
12707
|
-
|
|
13321
|
+
/* The compact indent is box + the 8px row gap, so Small takes medium's
|
|
13322
|
+
2xl (28) along with medium's 20px box. */
|
|
12708
13323
|
:host([size="small"]) .radio-supporting.indent {
|
|
12709
|
-
padding-left: var(--rel-spacing-
|
|
12710
|
-
}
|
|
12711
|
-
|
|
12712
|
-
/* The option avatar matches the control box at every size (20 at M, 24 at
|
|
12713
|
-
L), so it follows Small down to 16. */
|
|
12714
|
-
:host([size="small"]) .radio-avatar {
|
|
12715
|
-
width: 16px;
|
|
12716
|
-
height: 16px;
|
|
13324
|
+
padding-left: var(--rel-spacing-2xl);
|
|
12717
13325
|
}
|
|
12718
13326
|
|
|
12719
13327
|
:host([size="small"]) .radio-label {
|
|
@@ -12725,11 +13333,11 @@ RelRadio.styles = css24`
|
|
|
12725
13333
|
}
|
|
12726
13334
|
|
|
12727
13335
|
:host([size="small"]) .radio-supporting {
|
|
12728
|
-
font-family: var(--rel-body-
|
|
12729
|
-
font-weight: var(--rel-body-
|
|
12730
|
-
font-size: var(--rel-body-
|
|
12731
|
-
line-height: var(--rel-body-
|
|
12732
|
-
letter-spacing: var(--rel-body-
|
|
13336
|
+
font-family: var(--rel-body-sm-regular-font-family);
|
|
13337
|
+
font-weight: var(--rel-body-sm-regular-font-weight);
|
|
13338
|
+
font-size: var(--rel-body-sm-regular-font-size);
|
|
13339
|
+
line-height: var(--rel-body-sm-regular-line-height);
|
|
13340
|
+
letter-spacing: var(--rel-body-sm-regular-letter-spacing);
|
|
12733
13341
|
}
|
|
12734
13342
|
|
|
12735
13343
|
/* Error state (Figma State=Error) — ring, dot, and supporting text go
|
|
@@ -13344,12 +13952,23 @@ RelMenu._css = `
|
|
|
13344
13952
|
flex: 1 1 auto;
|
|
13345
13953
|
}
|
|
13346
13954
|
|
|
13347
|
-
/* Submenu affordance \u2014 same rel-icon chevron the menubar uses.
|
|
13955
|
+
/* Submenu affordance \u2014 same rel-icon chevron the menubar uses.
|
|
13956
|
+
|
|
13957
|
+
No opacity here: verified against the live Figma file, "Chevron Right"
|
|
13958
|
+
is opacity 1 on both the instance and its vector, on every State and
|
|
13959
|
+
Size, bound to the same colour variable as the row label beside it.
|
|
13960
|
+
The 0.6 that used to sit here dulled it to a mid grey.
|
|
13961
|
+
|
|
13962
|
+
The colour is declared rather than inherited on purpose. rel-menu
|
|
13963
|
+
renders to the LIGHT DOM (createRenderRoot returns this), so these
|
|
13964
|
+
selectors are unscoped globals and an inherited colour would travel
|
|
13965
|
+
through the consumer's page cascade \u2014 an app setting a colour on any
|
|
13966
|
+
ancestor would have been repainting this chevron. */
|
|
13348
13967
|
.submenu-chevron {
|
|
13349
13968
|
margin-left: auto;
|
|
13350
13969
|
display: inline-flex;
|
|
13351
13970
|
align-items: center;
|
|
13352
|
-
|
|
13971
|
+
color: var(--rel-text-default);
|
|
13353
13972
|
pointer-events: none;
|
|
13354
13973
|
}
|
|
13355
13974
|
|
|
@@ -16700,12 +17319,12 @@ var RelSegmentedButton = class extends LitElement35 {
|
|
|
16700
17319
|
setActive(option) {
|
|
16701
17320
|
if (!option.disabled) {
|
|
16702
17321
|
this.active = option.value;
|
|
17322
|
+
const detail = { value: option.value };
|
|
16703
17323
|
this.dispatchEvent(
|
|
16704
|
-
new CustomEvent("
|
|
16705
|
-
|
|
16706
|
-
|
|
16707
|
-
|
|
16708
|
-
})
|
|
17324
|
+
new CustomEvent("rel-change", { detail, bubbles: true, composed: true })
|
|
17325
|
+
);
|
|
17326
|
+
this.dispatchEvent(
|
|
17327
|
+
new CustomEvent("changed", { detail, bubbles: true, composed: true })
|
|
16709
17328
|
);
|
|
16710
17329
|
}
|
|
16711
17330
|
}
|
|
@@ -17156,6 +17775,14 @@ var RelSelect = class extends LitElement36 {
|
|
|
17156
17775
|
get optionCheckboxSize() {
|
|
17157
17776
|
return this.size;
|
|
17158
17777
|
}
|
|
17778
|
+
/** Native tooltip carrying the row's full text. Both lines truncate to one
|
|
17779
|
+
* line with an ellipsis, so a long supporting line is otherwise unrecoverable
|
|
17780
|
+
* by sight; hovering the row reveals it. Only rows that carry a second line
|
|
17781
|
+
* get one, so plain lists do not sprout tooltips. */
|
|
17782
|
+
rowTitle(option) {
|
|
17783
|
+
return option.supportingText ? `${option.label}
|
|
17784
|
+
${option.supportingText}` : nothing18;
|
|
17785
|
+
}
|
|
17159
17786
|
get selectAllStatus() {
|
|
17160
17787
|
const filtered = this.filteredOptions;
|
|
17161
17788
|
if (filtered.length === 0) return "unchecked";
|
|
@@ -17174,13 +17801,27 @@ var RelSelect = class extends LitElement36 {
|
|
|
17174
17801
|
window.removeEventListener("resize", this.updatePosition);
|
|
17175
17802
|
window.removeEventListener("scroll", this.onPageScroll, true);
|
|
17176
17803
|
}
|
|
17804
|
+
/**
|
|
17805
|
+
* BOTH events fire, deliberately — do not delete either.
|
|
17806
|
+
*
|
|
17807
|
+
* This repo is split down the middle on the convention, not inconsistent in
|
|
17808
|
+
* one place: rel-select and rel-segmented-button emit `changed`, while
|
|
17809
|
+
* rel-switch, rel-checkbox and rel-radio emit `rel-change`. Renaming
|
|
17810
|
+
* `changed` would break every existing rel-select consumer, so 1.4.9 adds
|
|
17811
|
+
* the prefixed name alongside it rather than moving anyone. New code should
|
|
17812
|
+
* listen for `rel-change`; `changed` stays for as long as 1.x does.
|
|
17813
|
+
*
|
|
17814
|
+
* One `detail` object is shared by both dispatches so the two events cannot
|
|
17815
|
+
* drift apart — a listener on one sees exactly what a listener on the other
|
|
17816
|
+
* sees.
|
|
17817
|
+
*/
|
|
17177
17818
|
emitChange() {
|
|
17819
|
+
const detail = { value: this.multiple ? this.selectedValues : this.selected };
|
|
17178
17820
|
this.dispatchEvent(
|
|
17179
|
-
new CustomEvent("changed", {
|
|
17180
|
-
|
|
17181
|
-
|
|
17182
|
-
|
|
17183
|
-
})
|
|
17821
|
+
new CustomEvent("changed", { detail, bubbles: true, composed: true })
|
|
17822
|
+
);
|
|
17823
|
+
this.dispatchEvent(
|
|
17824
|
+
new CustomEvent("rel-change", { detail, bubbles: true, composed: true })
|
|
17184
17825
|
);
|
|
17185
17826
|
}
|
|
17186
17827
|
openListbox() {
|
|
@@ -17624,6 +18265,7 @@ var RelSelect = class extends LitElement36 {
|
|
|
17624
18265
|
role="option"
|
|
17625
18266
|
aria-selected="${this.selectedValues.includes(option.value)}"
|
|
17626
18267
|
aria-label="${option.label}"
|
|
18268
|
+
title="${this.rowTitle(option)}"
|
|
17627
18269
|
@pointerenter="${() => {
|
|
17628
18270
|
this.highlightedIndex = index;
|
|
17629
18271
|
this.selectAllHighlighted = false;
|
|
@@ -17655,6 +18297,7 @@ var RelSelect = class extends LitElement36 {
|
|
|
17655
18297
|
part="option" class="select-option ${index === this.highlightedIndex ? "highlighted" : ""}"
|
|
17656
18298
|
role="option"
|
|
17657
18299
|
aria-selected="${option.value === this.selected}"
|
|
18300
|
+
title="${this.rowTitle(option)}"
|
|
17658
18301
|
@mousedown="${(e) => e.preventDefault()}"
|
|
17659
18302
|
@click="${() => this.selectSingleOption(option)}"
|
|
17660
18303
|
@pointerenter="${() => this.highlightedIndex = index}"
|
|
@@ -17975,12 +18618,40 @@ RelSelect.styles = css34`
|
|
|
17975
18618
|
--_rel-sel-icon: 16px;
|
|
17976
18619
|
}
|
|
17977
18620
|
|
|
18621
|
+
/* Border geometry, in one place, so the mask strip below is DERIVED from
|
|
18622
|
+
the paint it has to cover rather than restating it. Declared on the
|
|
18623
|
+
wrapper and not on .select-trigger because .floating-label is the
|
|
18624
|
+
trigger's SIBLING, not its child, and would not inherit from it. */
|
|
18625
|
+
.select-wrapper {
|
|
18626
|
+
--_rel-sel-border-w: 1px;
|
|
18627
|
+
--_rel-sel-ring-w: 0px;
|
|
18628
|
+
--_rel-sel-notch-h: calc(var(--_rel-sel-border-w) + var(--_rel-sel-ring-w));
|
|
18629
|
+
}
|
|
18630
|
+
|
|
18631
|
+
.select-wrapper.focused {
|
|
18632
|
+
--_rel-sel-ring-w: 2px;
|
|
18633
|
+
}
|
|
18634
|
+
|
|
17978
18635
|
.floating-label {
|
|
17979
18636
|
position: absolute;
|
|
18637
|
+
/* Matches Figma directly and needs no arithmetic: the live spec puts the
|
|
18638
|
+
"Floating Label" frame at dx=12 from the Input frame's edge, and this
|
|
18639
|
+
element's containing block (.select-container) IS the trigger's outer
|
|
18640
|
+
edge, so --_rel-sel-pad lands it exactly there. rel-textfield needs a
|
|
18641
|
+
different value (0) for the same result, because ITS containing block
|
|
18642
|
+
already starts inside the border and padding. Do not copy one into the
|
|
18643
|
+
other — I briefly "corrected" this one to subtract the notch padding
|
|
18644
|
+
and moved a label that was already right. */
|
|
17980
18645
|
left: var(--_rel-sel-pad, var(--rel-spacing-sm));
|
|
17981
18646
|
top: 50%;
|
|
17982
18647
|
transform: translateY(-50%);
|
|
17983
|
-
background
|
|
18648
|
+
/* NO background here. It used to paint --rel-select-bg across the
|
|
18649
|
+
label's whole ~20px line box to hide a 1px border, which erased about
|
|
18650
|
+
10px of the trigger's interior and 10px of whatever sat above it.
|
|
18651
|
+
Figma does not do that: the "Floating Label" frame is a strip whose
|
|
18652
|
+
HEIGHT EQUALS THE BORDER IT MASKS — size=45x1 at rest and 45x2 when
|
|
18653
|
+
focused (rel-select.md). rel-textfield already moved off the chip for
|
|
18654
|
+
exactly this reason; the strip is now on ::before below. */
|
|
17984
18655
|
/* Figma pads the floating-label chip by --rel-spacing-2xs; this was a
|
|
17985
18656
|
raw 4px that happened to match but was not on the scale. */
|
|
17986
18657
|
padding: 0 var(--rel-spacing-2xs);
|
|
@@ -18026,6 +18697,8 @@ RelSelect.styles = css34`
|
|
|
18026
18697
|
.select-wrapper.has-value .floating-label,
|
|
18027
18698
|
.select-wrapper.open .floating-label {
|
|
18028
18699
|
top: 0;
|
|
18700
|
+
/* Same inset as the resting rule above, so the label does not shift
|
|
18701
|
+
horizontally as it floats. */
|
|
18029
18702
|
left: var(--_rel-sel-pad, var(--rel-spacing-sm));
|
|
18030
18703
|
/* Figma's floated label is REGULAR 400, not medium 500. */
|
|
18031
18704
|
font-family: var(--rel-body-sm-regular-font-family);
|
|
@@ -18054,10 +18727,66 @@ RelSelect.styles = css34`
|
|
|
18054
18727
|
letter-spacing: var(--rel-body-xs-regular-letter-spacing);
|
|
18055
18728
|
}
|
|
18056
18729
|
|
|
18057
|
-
/*
|
|
18058
|
-
|
|
18059
|
-
|
|
18060
|
-
|
|
18730
|
+
/* The mask: TWO OVERLAPPING strips, same idea as rel-textfield, different
|
|
18731
|
+
reference — and the reference is the whole reason this cannot be copied
|
|
18732
|
+
across. In rel-textfield the label is positioned inside the control, so
|
|
18733
|
+
its 50% is one border-width BELOW the border-box top. Here
|
|
18734
|
+
.floating-label is a SIBLING of .select-trigger inside
|
|
18735
|
+
.select-container, so its 50% IS the trigger's border-box top. Measured
|
|
18736
|
+
from there the focus outline occupies [50% - ringW, 50%] (outlines draw
|
|
18737
|
+
outside the border box) and the border [50%, 50% + borderW].
|
|
18738
|
+
A single strip cut to exactly that band leaked at some zoom levels and
|
|
18739
|
+
not others (v1.4.9 "slash" report): border and outline are
|
|
18740
|
+
pixel-snapped by Blink, while a strip inside a translateY(-50%) label
|
|
18741
|
+
rasterises at a fractional offset, so their edges disagreed by under a
|
|
18742
|
+
device pixel and a hairline showed through the text. So each strip
|
|
18743
|
+
overshoots into paint of ITS OWN colour, and the two overlap so no two
|
|
18744
|
+
anti-aliased edges share a row (abutting edges leak a*(1-a) of the band
|
|
18745
|
+
— measured as exactly a quarter of the ring colour on rel-textfield):
|
|
18746
|
+
::before surface colour, ring + border + 2px above + 2px below
|
|
18747
|
+
::after the trigger's FILL, border + 3px below, painted on top
|
|
18748
|
+
2px of overshoot, not 1: the strip and the band snap to the device grid
|
|
18749
|
+
in different coordinate spaces and can disagree by a full pixel. */
|
|
18750
|
+
.select-wrapper.focused .floating-label::before,
|
|
18751
|
+
.select-wrapper.has-value .floating-label::before,
|
|
18752
|
+
.select-wrapper.open .floating-label::before {
|
|
18753
|
+
content: "";
|
|
18754
|
+
position: absolute;
|
|
18755
|
+
left: 0;
|
|
18756
|
+
right: 0;
|
|
18757
|
+
top: calc(50% - var(--_rel-sel-ring-w, 0px) - 2px);
|
|
18758
|
+
height: calc(var(--_rel-sel-notch-h, 1px) + 4px);
|
|
18759
|
+
background: var(--rel-select-surface, var(--rel-select-bg, var(--rel-bg-neutral)));
|
|
18760
|
+
z-index: -1;
|
|
18761
|
+
}
|
|
18762
|
+
|
|
18763
|
+
.select-wrapper.focused .floating-label::after,
|
|
18764
|
+
.select-wrapper.has-value .floating-label::after,
|
|
18765
|
+
.select-wrapper.open .floating-label::after {
|
|
18766
|
+
content: "";
|
|
18767
|
+
position: absolute;
|
|
18768
|
+
left: 0;
|
|
18769
|
+
right: 0;
|
|
18770
|
+
top: 50%;
|
|
18771
|
+
height: calc(var(--_rel-sel-border-w, 1px) + 3px);
|
|
18772
|
+
background: var(--rel-select-bg, var(--rel-bg-neutral));
|
|
18773
|
+
z-index: -1;
|
|
18774
|
+
}
|
|
18775
|
+
|
|
18776
|
+
/* Figma swaps the strip's fill on the disabled variant
|
|
18777
|
+
(--rel-bg-neutral-elevated-2), matching the disabled trigger fill at
|
|
18778
|
+
.select-wrapper.disabled .select-trigger. rel-select had no equivalent of
|
|
18779
|
+
rel-textfield's disabled swap at all, so the old chip painted
|
|
18780
|
+
--rel-bg-neutral over a trigger filled --rel-bg-neutral-elevated-2: a
|
|
18781
|
+
visibly lighter block sitting on the field. */
|
|
18782
|
+
.select-wrapper.disabled .floating-label::before {
|
|
18783
|
+
background: var(
|
|
18784
|
+
--rel-select-surface-disabled,
|
|
18785
|
+
var(--rel-select-bg-disabled, var(--rel-bg-neutral-elevated-2))
|
|
18786
|
+
);
|
|
18787
|
+
}
|
|
18788
|
+
.select-wrapper.disabled .floating-label::after {
|
|
18789
|
+
background: var(--rel-select-bg-disabled, var(--rel-bg-neutral-elevated-2));
|
|
18061
18790
|
}
|
|
18062
18791
|
|
|
18063
18792
|
/* The real placeholder hides while the float-label rests in its spot,
|
|
@@ -18113,7 +18842,12 @@ RelSelect.styles = css34`
|
|
|
18113
18842
|
/* ── Dropdown listbox ── */
|
|
18114
18843
|
.select-listbox {
|
|
18115
18844
|
position: fixed;
|
|
18116
|
-
|
|
18845
|
+
/* Only consulted on the no-Popover-API fallback path: once the listbox
|
|
18846
|
+
is promoted to the top layer, z-index is inert and stacking is by
|
|
18847
|
+
promotion order. Shared with the pickers so the whole overlay family
|
|
18848
|
+
is overridable from one place instead of disagreeing (they were on
|
|
18849
|
+
2147483647, which beat consumer app chrome unconditionally). */
|
|
18850
|
+
z-index: var(--rel-overlay-fallback-z, 1001);
|
|
18117
18851
|
display: none;
|
|
18118
18852
|
/* Reset native [popover] UA styles (the listbox is promoted to the
|
|
18119
18853
|
top layer so transformed ancestors — e.g. a centered rel-modal
|
|
@@ -18338,6 +19072,26 @@ RelSelect.styles = css34`
|
|
|
18338
19072
|
white-space: nowrap;
|
|
18339
19073
|
}
|
|
18340
19074
|
|
|
19075
|
+
/* Multi-select rows delegate to a nested rel-checkbox, which wraps its
|
|
19076
|
+
text by default, so the same option truncated to one line in single
|
|
19077
|
+
mode and wrapped to three (or overflowed the panel sideways on an
|
|
19078
|
+
unbroken identifier) under multiple. The checkbox's label and helper
|
|
19079
|
+
are parts, so the row reaches them from here and applies the same
|
|
19080
|
+
one-line ellipsis. Same fix as rel-autocomplete, same date. */
|
|
19081
|
+
.checkbox-option > rel-checkbox {
|
|
19082
|
+
flex: 1;
|
|
19083
|
+
min-width: 0;
|
|
19084
|
+
}
|
|
19085
|
+
|
|
19086
|
+
.checkbox-option > rel-checkbox::part(label),
|
|
19087
|
+
.checkbox-option > rel-checkbox::part(helper) {
|
|
19088
|
+
display: block;
|
|
19089
|
+
min-width: 0;
|
|
19090
|
+
overflow: hidden;
|
|
19091
|
+
text-overflow: ellipsis;
|
|
19092
|
+
white-space: nowrap;
|
|
19093
|
+
}
|
|
19094
|
+
|
|
18341
19095
|
/* One line, truncated like the label. supportingText used to render only in
|
|
18342
19096
|
multi-select rows (through rel-checkbox); single-select rows drop it on
|
|
18343
19097
|
the floor, which made the same option object render differently
|
|
@@ -20580,6 +21334,7 @@ var RelStepper = class extends LitElement41 {
|
|
|
20580
21334
|
this.orientation = "horizontal";
|
|
20581
21335
|
this.size = "large";
|
|
20582
21336
|
this.type = "default";
|
|
21337
|
+
this.connector = "solid";
|
|
20583
21338
|
}
|
|
20584
21339
|
willUpdate(changed) {
|
|
20585
21340
|
if (changed.has("size") && this.size === "small") {
|
|
@@ -20590,6 +21345,14 @@ var RelStepper = class extends LitElement41 {
|
|
|
20590
21345
|
"Figma has no Size axis on the Stepper: the connector geometry is fixed, so size no longer changes anything. Retune the connector with the CSS hook instead."
|
|
20591
21346
|
);
|
|
20592
21347
|
}
|
|
21348
|
+
if (changed.has("textAlign") && this.textAlign !== "below" && !this.labelPlacement) {
|
|
21349
|
+
warnDeprecated(
|
|
21350
|
+
"rel-stepper",
|
|
21351
|
+
"textAlign",
|
|
21352
|
+
"label-placement",
|
|
21353
|
+
'Use label-placement="inverted" for textalign="below" and label-placement="centered" for textalign="right".'
|
|
21354
|
+
);
|
|
21355
|
+
}
|
|
20593
21356
|
}
|
|
20594
21357
|
isDone(step) {
|
|
20595
21358
|
return step.status === "done";
|
|
@@ -20972,9 +21735,15 @@ RelStepper.styles = css39`
|
|
|
20972
21735
|
/********************************************
|
|
20973
21736
|
* Connector Lines
|
|
20974
21737
|
********************************************/
|
|
21738
|
+
/* The line colour is held in a custom property rather than written straight
|
|
21739
|
+
to background, because the dashed variant below needs the SAME colour
|
|
21740
|
+
inside a gradient. With the colour in background the state rules and the
|
|
21741
|
+
dash pattern would each have to win the same declaration, and whichever
|
|
21742
|
+
came last would erase the other. */
|
|
20975
21743
|
.connector {
|
|
20976
21744
|
position: absolute;
|
|
20977
|
-
|
|
21745
|
+
--_rel-stepper-line-color: var(--rel-bg-neutral-elevated-2);
|
|
21746
|
+
background: var(--_rel-stepper-line-color);
|
|
20978
21747
|
z-index: 1;
|
|
20979
21748
|
}
|
|
20980
21749
|
|
|
@@ -20986,7 +21755,6 @@ RelStepper.styles = css39`
|
|
|
20986
21755
|
transform: translateY(-50%);
|
|
20987
21756
|
height: 1px;
|
|
20988
21757
|
position: absolute;
|
|
20989
|
-
background: var(--rel-bg-neutral-elevated-2);
|
|
20990
21758
|
}
|
|
20991
21759
|
|
|
20992
21760
|
.rel-stepper.horizontal .rel-step:last-child .connector {
|
|
@@ -21021,6 +21789,37 @@ RelStepper.styles = css39`
|
|
|
21021
21789
|
display: none;
|
|
21022
21790
|
}
|
|
21023
21791
|
|
|
21792
|
+
/* ── connector="dot" (Figma ".Line Types" Type=Dot, set 6437:428) ──
|
|
21793
|
+
Verified against the LIVE Published DS file 2026-09-25: both Dot variants
|
|
21794
|
+
carry RECTANGLE "Line" 1×20 with strokeWeight=1 and strokeDashes=[2,2],
|
|
21795
|
+
i.e. a 1px line, 2px on, 2px off. (The Default variants carry no dashes;
|
|
21796
|
+
the COMPONENT_SET node's own [10,5] is the set frame's border, not a
|
|
21797
|
+
variant value.)
|
|
21798
|
+
|
|
21799
|
+
A repeating gradient rather than a dashed border-style, because the dashed
|
|
21800
|
+
border style is engine-defined — no browser lets you state a 2/2 period,
|
|
21801
|
+
and each one picks its own dash length from the border width. The
|
|
21802
|
+
gradient is exact and stays exact at any device pixel ratio.
|
|
21803
|
+
|
|
21804
|
+
The line thickness still comes from the height/width declared above, so
|
|
21805
|
+
this only replaces the paint. Colour comes through the custom property so
|
|
21806
|
+
done / progress steps dash in their own tone. */
|
|
21807
|
+
:host([connector="dot"]) .rel-stepper.horizontal .connector {
|
|
21808
|
+
background: repeating-linear-gradient(
|
|
21809
|
+
to right,
|
|
21810
|
+
var(--_rel-stepper-line-color) 0 2px,
|
|
21811
|
+
transparent 2px 4px
|
|
21812
|
+
);
|
|
21813
|
+
}
|
|
21814
|
+
|
|
21815
|
+
:host([connector="dot"]) .rel-stepper.vertical .connector {
|
|
21816
|
+
background: repeating-linear-gradient(
|
|
21817
|
+
to bottom,
|
|
21818
|
+
var(--_rel-stepper-line-color) 0 2px,
|
|
21819
|
+
transparent 2px 4px
|
|
21820
|
+
);
|
|
21821
|
+
}
|
|
21822
|
+
|
|
21024
21823
|
/********************************************
|
|
21025
21824
|
* Step States
|
|
21026
21825
|
********************************************/
|
|
@@ -21046,11 +21845,11 @@ RelStepper.styles = css39`
|
|
|
21046
21845
|
}
|
|
21047
21846
|
|
|
21048
21847
|
.rel-step.done .connector {
|
|
21049
|
-
|
|
21848
|
+
--_rel-stepper-line-color: var(--rel-border-strong);
|
|
21050
21849
|
}
|
|
21051
21850
|
|
|
21052
21851
|
.rel-step.progress .connector {
|
|
21053
|
-
|
|
21852
|
+
--_rel-stepper-line-color: var(--rel-border-strong);
|
|
21054
21853
|
}
|
|
21055
21854
|
|
|
21056
21855
|
/* Figma "Content": body/md/medium, text default; Inactive at 40% */
|
|
@@ -21136,6 +21935,9 @@ __decorateClass([
|
|
|
21136
21935
|
__decorateClass([
|
|
21137
21936
|
property40({ type: String, reflect: true })
|
|
21138
21937
|
], RelStepper.prototype, "type", 2);
|
|
21938
|
+
__decorateClass([
|
|
21939
|
+
property40({ type: String, reflect: true })
|
|
21940
|
+
], RelStepper.prototype, "connector", 2);
|
|
21139
21941
|
__decorateClass([
|
|
21140
21942
|
property40({ type: String, reflect: true, attribute: "label-placement" })
|
|
21141
21943
|
], RelStepper.prototype, "labelPlacement", 2);
|
|
@@ -21690,6 +22492,13 @@ var RelTab = class extends LitElement43 {
|
|
|
21690
22492
|
composed: true
|
|
21691
22493
|
})
|
|
21692
22494
|
);
|
|
22495
|
+
this.dispatchEvent(
|
|
22496
|
+
new CustomEvent("rel-change", {
|
|
22497
|
+
detail: index,
|
|
22498
|
+
bubbles: true,
|
|
22499
|
+
composed: true
|
|
22500
|
+
})
|
|
22501
|
+
);
|
|
21693
22502
|
}
|
|
21694
22503
|
onKeyDown(event, index) {
|
|
21695
22504
|
if (event.key === "Enter" || event.key === " ") {
|
|
@@ -22071,6 +22880,23 @@ var RelTimePicker = class extends LitElement44 {
|
|
|
22071
22880
|
this.onReposition = () => {
|
|
22072
22881
|
if (this.open) this.updatePopoverPosition();
|
|
22073
22882
|
};
|
|
22883
|
+
/* Outside scroll CLOSES the panel; it used to reposition. Same change as
|
|
22884
|
+
* rel-date-picker and rel-range-picker, and the pattern rel-select has used
|
|
22885
|
+
* all along — see the long note in rel-date-picker.onPageScroll for why
|
|
22886
|
+
* repositioning is what produced the "panel floats over the sticky header"
|
|
22887
|
+
* report, and why no z-index can fix it.
|
|
22888
|
+
*
|
|
22889
|
+
* This component is the reason the exemption tests CONTAINMENT rather than
|
|
22890
|
+
* equality with the panel: the hour / minute / period columns are
|
|
22891
|
+
* `overflow-y: auto` in their own right (`.column-list`), so a scroll that
|
|
22892
|
+
* must NOT close the panel very often has one of them as its target, not the
|
|
22893
|
+
* panel. Discards the draft, like every other outside dismissal. */
|
|
22894
|
+
this.onPageScroll = (e) => {
|
|
22895
|
+
if (!this.open) return;
|
|
22896
|
+
const panel = this.popoverElement;
|
|
22897
|
+
if (panel && e.composedPath().includes(panel)) return;
|
|
22898
|
+
this.cancelPopover(false);
|
|
22899
|
+
};
|
|
22074
22900
|
this.updatePopoverPosition = () => {
|
|
22075
22901
|
const panel = this.popoverElement;
|
|
22076
22902
|
const anchor = this.triggerButton;
|
|
@@ -22130,7 +22956,7 @@ var RelTimePicker = class extends LitElement44 {
|
|
|
22130
22956
|
document.removeEventListener("click", this.handleClickOutside);
|
|
22131
22957
|
document.removeEventListener("keydown", this.handleEscapeKey);
|
|
22132
22958
|
window.removeEventListener("resize", this.onReposition);
|
|
22133
|
-
window.removeEventListener("scroll", this.
|
|
22959
|
+
window.removeEventListener("scroll", this.onPageScroll, true);
|
|
22134
22960
|
this.hideTopLayer();
|
|
22135
22961
|
}
|
|
22136
22962
|
// `open` is a public reflected property (consumers may set it directly,
|
|
@@ -22140,7 +22966,7 @@ var RelTimePicker = class extends LitElement44 {
|
|
|
22140
22966
|
if (changedProperties.has("open")) {
|
|
22141
22967
|
if (!this.open) {
|
|
22142
22968
|
window.removeEventListener("resize", this.onReposition);
|
|
22143
|
-
window.removeEventListener("scroll", this.
|
|
22969
|
+
window.removeEventListener("scroll", this.onPageScroll, true);
|
|
22144
22970
|
this.hideTopLayer();
|
|
22145
22971
|
this.focusPanelOnOpen = true;
|
|
22146
22972
|
return;
|
|
@@ -22148,7 +22974,7 @@ var RelTimePicker = class extends LitElement44 {
|
|
|
22148
22974
|
this.resetDraftFromValue();
|
|
22149
22975
|
this.showTopLayer();
|
|
22150
22976
|
window.addEventListener("resize", this.onReposition);
|
|
22151
|
-
window.addEventListener("scroll", this.
|
|
22977
|
+
window.addEventListener("scroll", this.onPageScroll, true);
|
|
22152
22978
|
const focusPanel = this.focusPanelOnOpen;
|
|
22153
22979
|
this.focusPanelOnOpen = true;
|
|
22154
22980
|
setTimeout(() => {
|
|
@@ -23125,7 +23951,14 @@ RelTimePicker.styles = css42`
|
|
|
23125
23951
|
position: absolute;
|
|
23126
23952
|
top: calc(100% + 8px);
|
|
23127
23953
|
left: 0;
|
|
23128
|
-
|
|
23954
|
+
/* Fallback stacking ONLY: while the panel is in the top layer this is
|
|
23955
|
+
inert, because top-layer elements are not in the page stacking order at
|
|
23956
|
+
all and order of promotion decides. It applies solely where the Popover
|
|
23957
|
+
API is unavailable. One overridable hook across every overlay, so a
|
|
23958
|
+
consumer with its own stacking scheme sets it once; the default matches
|
|
23959
|
+
rel-select / rel-autocomplete / rel-popover / rel-menubar. This was
|
|
23960
|
+
2147483647, which cannot be layered against anything. */
|
|
23961
|
+
z-index: var(--rel-overlay-fallback-z, 1001);
|
|
23129
23962
|
background: var(--rel-bg-surface);
|
|
23130
23963
|
border: 1px solid var(--rel-border-subtle);
|
|
23131
23964
|
border-radius: var(--rel-border-radius-sm);
|
|
@@ -24680,7 +25513,11 @@ var RelUpload = class extends LitElement47 {
|
|
|
24680
25513
|
}
|
|
24681
25514
|
renderRow(f) {
|
|
24682
25515
|
return html47`
|
|
24683
|
-
|
|
25516
|
+
<!-- role="listitem" is required, not decorative: the container at
|
|
25517
|
+
.rows carries role="list", and a list whose children are plain divs
|
|
25518
|
+
fails axe's aria-required-children. It was failing on the uploading,
|
|
25519
|
+
failure, disabled and delayed stories alike. -->
|
|
25520
|
+
<div class="row ${f.status}" role="listitem">
|
|
24684
25521
|
<rel-icon name="document" size="md"></rel-icon>
|
|
24685
25522
|
|
|
24686
25523
|
<div class="row-content">
|
|
@@ -24785,8 +25622,19 @@ RelUpload.styles = css45`
|
|
|
24785
25622
|
font-family: var(--rel-fonts-font-family);
|
|
24786
25623
|
}
|
|
24787
25624
|
|
|
25625
|
+
/* not-allowed must live on the HOST, which keeps pointer-events: auto.
|
|
25626
|
+
It used to sit on .dropzone, where it was unreachable: .dropzone is
|
|
25627
|
+
inside .body, .body is pointer-events: none when disabled, and an
|
|
25628
|
+
element that is not a hit target never has its cursor consulted —
|
|
25629
|
+
the cursor resolves from the nearest ancestor that IS one. Measured on
|
|
25630
|
+
a disabled upload before the fix: .dropzone computed not-allowed
|
|
25631
|
+
while the user saw the default arrow. getComputedStyle agreed with the
|
|
25632
|
+
CSS and disagreed with the screen, so anyone re-verifying this must
|
|
25633
|
+
HOVER it rather than read the computed value.
|
|
25634
|
+
rel-button hit the same trap and documents the same resolution. */
|
|
24788
25635
|
:host([disabled]) {
|
|
24789
25636
|
opacity: 0.6;
|
|
25637
|
+
cursor: not-allowed;
|
|
24790
25638
|
}
|
|
24791
25639
|
|
|
24792
25640
|
:host([disabled]) .body {
|
|
@@ -24799,7 +25647,6 @@ RelUpload.styles = css45`
|
|
|
24799
25647
|
instead of a faded live one. */
|
|
24800
25648
|
:host([disabled]) .dropzone {
|
|
24801
25649
|
background: var(--rel-bg-neutral-elevated-2);
|
|
24802
|
-
cursor: not-allowed;
|
|
24803
25650
|
}
|
|
24804
25651
|
|
|
24805
25652
|
.body {
|