@kenlo/sds-design-system 0.9.9 → 1.0.1
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/README.md +49 -7
- package/components/Avatar/Avatar.css +22 -0
- package/components/Button/Button.css +95 -8
- package/components/Card/Card.css +16 -6
- package/components/Card/card-variants.js +18 -0
- package/components/Card/card.marko +12 -2
- package/components/Checkbox/Checkbox.css +56 -0
- package/components/ColorPicker/ColorPicker.css +25 -2
- package/components/ColorPicker/color-picker.marko +25 -4
- package/components/DatePicker/DatePicker.css +22 -0
- package/components/Floating/focus-when-ready.js +59 -0
- package/components/Incrementer/Incrementer.css +22 -0
- package/components/Input/Input.css +25 -2
- package/components/Menu/Menu.css +21 -11
- package/components/Menu/menu-variants.js +12 -0
- package/components/Menu/menu.marko +11 -7
- package/components/Pill/Pill.css +46 -0
- package/components/Radio/Radio.css +75 -0
- package/components/Select/Select.css +21 -1
- package/components/Select/select-options.js +10 -1
- package/components/Select/select.marko +112 -5
- package/components/Table/Table.css +73 -2
- package/components/Table/table-column-picker.marko +161 -0
- package/components/Table/table-logic.js +56 -0
- package/components/Table/table.marko +50 -12
- package/components/TextEditor/TextEditor.css +2 -2
- package/components/Toggle/Toggle.css +40 -0
- package/components/Tooltip/Tooltip.css +2 -2
- package/components/Uploader/Uploader.css +23 -0
- package/foundations/custom-scheme.js +128 -10
- package/foundations/fonts/OFL.txt +114 -0
- package/foundations/fonts/fonts.json +152 -0
- package/foundations/fonts/manrope-cyrillic-ext.woff2 +0 -0
- package/foundations/fonts/manrope-cyrillic.woff2 +0 -0
- package/foundations/fonts/manrope-greek.woff2 +0 -0
- package/foundations/fonts/manrope-latin-ext.woff2 +0 -0
- package/foundations/fonts/manrope-latin.woff2 +0 -0
- package/foundations/fonts/manrope-vietnamese.woff2 +0 -0
- package/foundations/tokens.css +1094 -13
- package/icons/generated/fluent-chevron-down-filled.marko +3 -0
- package/icons/generated/fluent-chevron-up-filled.marko +3 -0
- package/icons/generated/fluent-column-triple-regular.marko +3 -0
- package/icons/manifest.json +3 -0
- package/marko.json +3 -0
- package/package.json +4 -3
|
@@ -443,7 +443,7 @@
|
|
|
443
443
|
border-radius: var(--radius-md);
|
|
444
444
|
box-shadow: var(--shadow-drop-md);
|
|
445
445
|
min-width: 220px;
|
|
446
|
-
max-height:
|
|
446
|
+
max-height: var(--menu-max-height);
|
|
447
447
|
overflow-y: auto;
|
|
448
448
|
padding: var(--spacing-1);
|
|
449
449
|
}
|
|
@@ -524,7 +524,7 @@
|
|
|
524
524
|
in the common case, and the cut-off row is what says there is more. The cap is on the
|
|
525
525
|
body rather than the panel so the search box above it stays put while the rows move. */
|
|
526
526
|
.sds-input__country .sds-menu__panel-body {
|
|
527
|
-
max-height:
|
|
527
|
+
max-height: var(--menu-max-height);
|
|
528
528
|
}
|
|
529
529
|
/* Only a minimum width — the padding is the menu header's, and so is the full width. */
|
|
530
530
|
.sds-input__country-search {
|
|
@@ -545,3 +545,26 @@
|
|
|
545
545
|
.sds-input--disabled .sds-input__country-ddi {
|
|
546
546
|
color: var(--color-neutral-40);
|
|
547
547
|
}
|
|
548
|
+
|
|
549
|
+
/* ── The site's gradient, on the stroke ────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
`border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
|
|
552
|
+
the gradient's first stop, flat. These rules paint it properly instead: the gradient on
|
|
553
|
+
`border-box`, the surface colour on `padding-box`, over a transparent border. Only the
|
|
554
|
+
border's own 2px show the gradient, and `border-radius` still works, which is what
|
|
555
|
+
`border-image` would cost.
|
|
556
|
+
|
|
557
|
+
`[data-primary-gradient]` is written by `applyCustomGradient` and removed by
|
|
558
|
+
`applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
|
|
559
|
+
code that fills the slot says so. KDLS wrote the same rules under
|
|
560
|
+
`[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
|
|
561
|
+
the brand and these went with them. Keyed on the marker they serve any site's gradient.
|
|
562
|
+
─────────────────────────────────────────────────────────────────────────── */
|
|
563
|
+
|
|
564
|
+
[data-primary-gradient] .sds-input:hover:not(:focus-within):not(.sds-input--disabled),
|
|
565
|
+
[data-primary-gradient] .sds-input:focus-within:not(.sds-input--feedback-success):not(.sds-input--feedback-alert):not(.sds-input--feedback-error) {
|
|
566
|
+
background:
|
|
567
|
+
linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
|
|
568
|
+
var(--color-primary-50) border-box;
|
|
569
|
+
border-color: transparent;
|
|
570
|
+
}
|
package/components/Menu/Menu.css
CHANGED
|
@@ -12,10 +12,6 @@
|
|
|
12
12
|
position: relative;
|
|
13
13
|
display: inline-flex;
|
|
14
14
|
flex-direction: column;
|
|
15
|
-
/* The row height, named rather than written twice. A caller that wants a panel "seven
|
|
16
|
-
rows tall" can only say so in terms of this — and a pixel count copied out of here
|
|
17
|
-
would go stale the moment the row changed. */
|
|
18
|
-
--sds-menu-item-height: 40px;
|
|
19
15
|
}
|
|
20
16
|
/* ── Built-in trigger button ── */
|
|
21
17
|
.sds-menu__btn {
|
|
@@ -86,6 +82,7 @@
|
|
|
86
82
|
text-align: left;
|
|
87
83
|
}
|
|
88
84
|
.sds-menu__panel--inline .sds-menu__panel-body {
|
|
85
|
+
max-height: none;
|
|
89
86
|
overflow-y: hidden;
|
|
90
87
|
}
|
|
91
88
|
.sds-nav--vertical .sds-menu__panel--inline .sds-menu__item {
|
|
@@ -164,7 +161,6 @@
|
|
|
164
161
|
z-index: var(--z-floating);
|
|
165
162
|
min-width: 200px;
|
|
166
163
|
max-width: 380px;
|
|
167
|
-
max-height: 480px;
|
|
168
164
|
overflow: hidden;
|
|
169
165
|
display: flex;
|
|
170
166
|
flex-direction: column;
|
|
@@ -208,7 +204,21 @@
|
|
|
208
204
|
.sds-menu__panel-body {
|
|
209
205
|
flex: 1;
|
|
210
206
|
min-height: 0;
|
|
207
|
+
max-height: var(--menu-max-height);
|
|
211
208
|
overflow-y: auto;
|
|
209
|
+
/* The box that scrolls is also the box that CONTAINS.
|
|
210
|
+
|
|
211
|
+
A custom row may hold a control, and every toggle, switch and radio in this system is
|
|
212
|
+
a visually hidden `position: absolute` input. With this box static, that input's
|
|
213
|
+
containing block is the panel instead — so it sits outside the box that scrolls, and
|
|
214
|
+
the panel, which is `overflow: hidden`, becomes scrollable. It then scrolls the moment
|
|
215
|
+
the control takes focus, with no scrollbar to show it, no cause a reader can see, and
|
|
216
|
+
nothing to drag it back: the first rows end up clipped above the top edge and an equal
|
|
217
|
+
band of blank white sits below the last one.
|
|
218
|
+
|
|
219
|
+
Measured with the portal's colour menu: panel scrollHeight 502 against a clientHeight
|
|
220
|
+
of 300. With this line, 300. */
|
|
221
|
+
position: relative;
|
|
212
222
|
padding: max(var(--spacing-1), calc(var(--radius-md) * 0.3)) 0;
|
|
213
223
|
}
|
|
214
224
|
.sds-menu__panel-footer {
|
|
@@ -221,13 +231,13 @@
|
|
|
221
231
|
z-index: var(--z-floating);
|
|
222
232
|
}
|
|
223
233
|
.sds-menu__panel-bare .sds-menu__panel-body {
|
|
234
|
+
max-height: none;
|
|
224
235
|
overflow: visible;
|
|
225
236
|
padding: 0;
|
|
226
237
|
}
|
|
227
238
|
/* ── SubMenu panel — one layer above main panel ── */
|
|
228
239
|
.sds-menu__submenu-panel {
|
|
229
240
|
z-index: calc(var(--z-floating) + 1);
|
|
230
|
-
max-height: 480px;
|
|
231
241
|
}
|
|
232
242
|
/* ── Search (fixed header — does not scroll with list) ── */
|
|
233
243
|
.sds-menu__search {
|
|
@@ -286,7 +296,7 @@ a.sds-menu__item {
|
|
|
286
296
|
align-items: center;
|
|
287
297
|
gap: var(--spacing-3);
|
|
288
298
|
padding: var(--spacing-2) var(--spacing-4);
|
|
289
|
-
min-height: var(--
|
|
299
|
+
min-height: var(--menu-item-height);
|
|
290
300
|
cursor: pointer;
|
|
291
301
|
color: var(--color-neutral-80);
|
|
292
302
|
font-family: var(--font-family-base);
|
|
@@ -557,8 +567,8 @@ a.sds-menu__item {
|
|
|
557
567
|
}
|
|
558
568
|
|
|
559
569
|
.sds-menu__trigger-custom:focus-visible {
|
|
560
|
-
outline: var(--focus-ring-
|
|
561
|
-
outline-offset:
|
|
570
|
+
outline: var(--focus-ring-outline);
|
|
571
|
+
outline-offset: var(--focus-ring-outline-offset);
|
|
562
572
|
border-radius: var(--radius-xs);
|
|
563
573
|
}
|
|
564
574
|
|
|
@@ -582,8 +592,8 @@ a.sds-menu__item {
|
|
|
582
592
|
}
|
|
583
593
|
|
|
584
594
|
.sds-menu__avatar-btn:focus-visible {
|
|
585
|
-
outline: var(--focus-ring-
|
|
586
|
-
outline-offset:
|
|
595
|
+
outline: var(--focus-ring-outline);
|
|
596
|
+
outline-offset: var(--focus-ring-outline-offset);
|
|
587
597
|
}
|
|
588
598
|
|
|
589
599
|
/* ── A row that opens the rows under it ───────────────────────────────────────
|
|
@@ -87,3 +87,15 @@ export function flattenRows(rows, expanded = {}) {
|
|
|
87
87
|
/** Rows a keyboard can land on — a divider or a section header is not one of them. */
|
|
88
88
|
export const isSelectable = (row) =>
|
|
89
89
|
row.type !== 'divider' && row.type !== 'section' && row.type !== 'custom' && !row.disabled;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What counts as the panel's search box.
|
|
93
|
+
*
|
|
94
|
+
* Narrower than "an input", because `<@item custom>` lets a caller put a real control in
|
|
95
|
+
* the panel and a radio or a checkbox is an input too. It has to be a field the reader
|
|
96
|
+
* types into, or the menu hands the caret to something nobody asked to type in — and
|
|
97
|
+
* focusing it scrolls the panel, which is `overflow: hidden` and shows no scrollbar to say
|
|
98
|
+
* so. Measured once, in the portal's colour menu: 202.5px of silent scroll, five rows
|
|
99
|
+
* clipped above the top edge and a blank band below the last one.
|
|
100
|
+
*/
|
|
101
|
+
export const SEARCH_FIELD = "input[type='text'], input[type='search'], input:not([type]), textarea, [contenteditable='true']";
|
|
@@ -34,7 +34,8 @@ after hydration is a menu that jumps. The three placements KDLS offers are all b
|
|
|
34
34
|
trigger, so CSS does them — and when Popover lands with real collision detection, this
|
|
35
35
|
grows into it. -->
|
|
36
36
|
|
|
37
|
-
import { assertPlacement, assertTrigger, assertIntent, normaliseItems, flattenRows, isSelectable
|
|
37
|
+
import { assertPlacement, assertTrigger, assertIntent, normaliseItems, flattenRows, isSelectable,
|
|
38
|
+
SEARCH_FIELD }
|
|
38
39
|
from "./menu-variants.js";
|
|
39
40
|
|
|
40
41
|
import chevronIcon from "../../icons/generated/fluent-chevron-down-regular.marko";
|
|
@@ -140,14 +141,17 @@ class {
|
|
|
140
141
|
const panel = this.getEl("panel");
|
|
141
142
|
if (!panel) return;
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
// A field that takes TEXT, not any input. A custom row may hold a control — a toggle,
|
|
145
|
+
// a checkbox — and a radio is an `input`, so asking for one found the reader's control
|
|
146
|
+
// and focused it believing it was the search box.
|
|
147
|
+
const field = panel.querySelector(SEARCH_FIELD);
|
|
144
148
|
if (field) {
|
|
145
|
-
field.focus();
|
|
149
|
+
field.focus({ preventScroll: true });
|
|
146
150
|
return;
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
this.state.activeIndex = this.rows.findIndex((row) => isSelectable(row));
|
|
150
|
-
panel.querySelector("[role='listbox']")?.focus();
|
|
154
|
+
panel.querySelector("[role='listbox']")?.focus({ preventScroll: true });
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
toggle() {
|
|
@@ -195,19 +199,19 @@ class {
|
|
|
195
199
|
if (event.key === "ArrowDown") {
|
|
196
200
|
event.preventDefault();
|
|
197
201
|
this.move(1);
|
|
198
|
-
if (typing) this.getEl("panel")?.querySelector("[role='listbox']")?.focus();
|
|
202
|
+
if (typing) this.getEl("panel")?.querySelector("[role='listbox']")?.focus({ preventScroll: true });
|
|
199
203
|
return;
|
|
200
204
|
}
|
|
201
205
|
if (event.key === "ArrowUp") {
|
|
202
206
|
event.preventDefault();
|
|
203
207
|
this.move(-1);
|
|
204
|
-
if (typing) this.getEl("panel")?.querySelector("[role='listbox']")?.focus();
|
|
208
|
+
if (typing) this.getEl("panel")?.querySelector("[role='listbox']")?.focus({ preventScroll: true });
|
|
205
209
|
return;
|
|
206
210
|
}
|
|
207
211
|
if (event.key === "Escape") {
|
|
208
212
|
event.preventDefault();
|
|
209
213
|
this.close();
|
|
210
|
-
this.getEl("trigger")?.focus();
|
|
214
|
+
this.getEl("trigger")?.focus({ preventScroll: true });
|
|
211
215
|
return;
|
|
212
216
|
}
|
|
213
217
|
if (event.key === "Enter" || (event.key === " " && !typing)) {
|
package/components/Pill/Pill.css
CHANGED
|
@@ -191,3 +191,49 @@
|
|
|
191
191
|
[data-theme="dark"] .sds-pill--off:active:not(:disabled):not(.sds-pill--loading) {
|
|
192
192
|
background-color: var(--color-neutral-30);
|
|
193
193
|
}
|
|
194
|
+
|
|
195
|
+
/* ── The site's gradient, on the stroke ────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
`border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
|
|
198
|
+
the gradient's first stop, flat. These rules paint it properly instead: the gradient on
|
|
199
|
+
`border-box`, the surface colour on `padding-box`, over a transparent border. Only the
|
|
200
|
+
border's own 2px show the gradient, and `border-radius` still works, which is what
|
|
201
|
+
`border-image` would cost.
|
|
202
|
+
|
|
203
|
+
`[data-primary-gradient]` is written by `applyCustomGradient` and removed by
|
|
204
|
+
`applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
|
|
205
|
+
code that fills the slot says so. KDLS wrote the same rules under
|
|
206
|
+
`[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
|
|
207
|
+
the brand and these went with them. Keyed on the marker they serve any site's gradient.
|
|
208
|
+
─────────────────────────────────────────────────────────────────────────── */
|
|
209
|
+
|
|
210
|
+
[data-primary-gradient] .sds-pill--off:hover:not(:disabled):not(.sds-pill--loading) {
|
|
211
|
+
background:
|
|
212
|
+
linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
|
|
213
|
+
var(--color-primary-50) border-box;
|
|
214
|
+
border-color: transparent;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ── And when the gradient FILLS this, it has no border to fight ──────────────
|
|
218
|
+
|
|
219
|
+
Filled from the slot and ringed with the `-solid` companion: the same colour while the
|
|
220
|
+
slot is solid, and a flat hoop of the first stop once it is a gradient. `border: none`
|
|
221
|
+
because under a gradient the fill IS the whole control.
|
|
222
|
+
|
|
223
|
+
Touched states brighten or darken rather than stepping to tone 60 or 70 — stepping a
|
|
224
|
+
gradient's tone moves every stop at once and reads as a different colour, not as the
|
|
225
|
+
same control being pressed. Same answer as the checked checkbox. ─────────── */
|
|
226
|
+
[data-primary-gradient] .sds-pill--on {
|
|
227
|
+
background: var(--color-primary-50);
|
|
228
|
+
border: none;
|
|
229
|
+
}
|
|
230
|
+
[data-primary-gradient] .sds-pill--on:hover:not(:disabled):not(.sds-pill--loading) {
|
|
231
|
+
background: var(--color-primary-50);
|
|
232
|
+
border: none;
|
|
233
|
+
filter: brightness(1.1);
|
|
234
|
+
}
|
|
235
|
+
[data-primary-gradient] .sds-pill--on:active:not(:disabled):not(.sds-pill--loading) {
|
|
236
|
+
background: var(--color-primary-50);
|
|
237
|
+
border: none;
|
|
238
|
+
filter: brightness(0.85);
|
|
239
|
+
}
|
|
@@ -228,3 +228,78 @@
|
|
|
228
228
|
transform: scale(0);
|
|
229
229
|
transition: transform 120ms ease;
|
|
230
230
|
}
|
|
231
|
+
|
|
232
|
+
/* ── The site's gradient, on the stroke ────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
`border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
|
|
235
|
+
the gradient's first stop, flat. These rules paint it properly instead: the gradient on
|
|
236
|
+
`border-box`, the surface colour on `padding-box`, over a transparent border. Only the
|
|
237
|
+
border's own 2px show the gradient, and `border-radius` still works, which is what
|
|
238
|
+
`border-image` would cost.
|
|
239
|
+
|
|
240
|
+
`[data-primary-gradient]` is written by `applyCustomGradient` and removed by
|
|
241
|
+
`applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
|
|
242
|
+
code that fills the slot says so. KDLS wrote the same rules under
|
|
243
|
+
`[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
|
|
244
|
+
the brand and these went with them. Keyed on the marker they serve any site's gradient.
|
|
245
|
+
─────────────────────────────────────────────────────────────────────────── */
|
|
246
|
+
|
|
247
|
+
[data-primary-gradient] .sds-radio:hover:not(.sds-radio--disabled):not(.sds-radio--selected) .sds-radio__circle,
|
|
248
|
+
[data-primary-gradient] .sds-radio:active:not(.sds-radio--disabled):not(.sds-radio--selected) .sds-radio__circle {
|
|
249
|
+
background:
|
|
250
|
+
linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
|
|
251
|
+
var(--color-primary-50) border-box;
|
|
252
|
+
border-color: transparent;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* ── And when the gradient FILLS this, it has no border to fight ──────────────
|
|
256
|
+
|
|
257
|
+
Filled from the slot and ringed with the `-solid` companion: the same colour while the
|
|
258
|
+
slot is solid, and a flat hoop of the first stop once it is a gradient. `border: none`
|
|
259
|
+
because under a gradient the fill IS the whole control.
|
|
260
|
+
|
|
261
|
+
Touched states brighten or darken rather than stepping to tone 60 or 70 — stepping a
|
|
262
|
+
gradient's tone moves every stop at once and reads as a different colour, not as the
|
|
263
|
+
same control being pressed. Same answer as the checked checkbox. ─────────── */
|
|
264
|
+
/* The dot is drawn as a hole rather than as a mark: the circle carries the gradient and
|
|
265
|
+
`::after` punches the surface colour out of the middle. Painting the dot instead would
|
|
266
|
+
need a second gradient that lines up with the first, which it never quite does. */
|
|
267
|
+
/* Both spellings of "selected", because this component has two: `--selected` when the
|
|
268
|
+
caller controls it, and the native `:checked` when it controls itself. The base rules
|
|
269
|
+
below cover both, and so must these — a radio ticked by a click carries only the second,
|
|
270
|
+
which is how the first version of this rule looked right in a test and did nothing on
|
|
271
|
+
the page. */
|
|
272
|
+
[data-primary-gradient] .sds-radio--selected .sds-radio__circle,
|
|
273
|
+
[data-primary-gradient] .sds-radio__input:checked ~ .sds-radio__circle {
|
|
274
|
+
position: relative;
|
|
275
|
+
background: var(--color-primary-50);
|
|
276
|
+
border: none;
|
|
277
|
+
box-shadow: none;
|
|
278
|
+
}
|
|
279
|
+
[data-primary-gradient] .sds-radio--selected .sds-radio__circle::after,
|
|
280
|
+
[data-primary-gradient] .sds-radio__input:checked ~ .sds-radio__circle::after {
|
|
281
|
+
content: '';
|
|
282
|
+
position: absolute;
|
|
283
|
+
border-radius: 50%;
|
|
284
|
+
background: var(--component-bg-color);
|
|
285
|
+
inset: 5px;
|
|
286
|
+
}
|
|
287
|
+
[data-primary-gradient] .sds-radio--md.sds-radio--selected .sds-radio__circle::after,
|
|
288
|
+
[data-primary-gradient] .sds-radio--md .sds-radio__input:checked ~ .sds-radio__circle::after {
|
|
289
|
+
inset: 7px;
|
|
290
|
+
}
|
|
291
|
+
[data-primary-gradient] .sds-radio--selected:hover:not(.sds-radio--disabled) .sds-radio__circle,
|
|
292
|
+
[data-primary-gradient] .sds-radio--selected:active:not(.sds-radio--disabled) .sds-radio__circle,
|
|
293
|
+
[data-primary-gradient] .sds-radio:hover:not(.sds-radio--disabled) .sds-radio__input:checked ~ .sds-radio__circle,
|
|
294
|
+
[data-primary-gradient] .sds-radio:active:not(.sds-radio--disabled) .sds-radio__input:checked ~ .sds-radio__circle {
|
|
295
|
+
background: var(--color-primary-50);
|
|
296
|
+
border: none;
|
|
297
|
+
box-shadow: none;
|
|
298
|
+
filter: brightness(1.1);
|
|
299
|
+
}
|
|
300
|
+
/* The ring sits outside now — the fill is the whole circle, so an inset one would be
|
|
301
|
+
hidden under it. */
|
|
302
|
+
[data-primary-gradient] .sds-radio--selected:focus-visible .sds-radio__circle,
|
|
303
|
+
[data-primary-gradient] .sds-radio__input:focus-visible:checked ~ .sds-radio__circle {
|
|
304
|
+
box-shadow: var(--focus-ring);
|
|
305
|
+
}
|
|
@@ -209,6 +209,10 @@
|
|
|
209
209
|
list-style: none;
|
|
210
210
|
flex: 1;
|
|
211
211
|
min-height: 0;
|
|
212
|
+
/* The panel carries no height of its own, so without this the list grew by one row per
|
|
213
|
+
option until it ran past the bottom of the window — and the rows past the fold are
|
|
214
|
+
rows nobody scrolls to, because nothing said there were any. */
|
|
215
|
+
max-height: var(--menu-max-height);
|
|
212
216
|
overflow-y: auto;
|
|
213
217
|
}
|
|
214
218
|
/* The list takes focus so the keyboard has somewhere to land, and shows nothing for it.
|
|
@@ -261,7 +265,7 @@
|
|
|
261
265
|
/* 40px, like every other row in a menu or a dropdown — the padding plus the line box
|
|
262
266
|
came to 38, so this row was two pixels shorter than the same row in an sds-menu and
|
|
263
267
|
than the navButton it sits under. Rafael, measuring the theme bar. */
|
|
264
|
-
min-height: var(--
|
|
268
|
+
min-height: var(--menu-item-height);
|
|
265
269
|
border-radius: 11px; /* soft, the install default — schemes at the end */
|
|
266
270
|
font-family: var(--font-family-base);
|
|
267
271
|
font-size: var(--font-size-body-md);
|
|
@@ -287,6 +291,22 @@
|
|
|
287
291
|
color: var(--color-neutral-10-on);
|
|
288
292
|
box-shadow: var(--focus-ring-inset);
|
|
289
293
|
}
|
|
294
|
+
/* `:focus`, not `:focus-visible`, and on the LIST rather than on the row.
|
|
295
|
+
|
|
296
|
+
The arrow keys move a highlight, not the caret: focus stays on the list, which is what
|
|
297
|
+
hears the next arrow, and `aria-activedescendant` names the row for a screen reader.
|
|
298
|
+
That means the row the reader is on never matches `:focus-visible` — the browser only
|
|
299
|
+
marks focus it moved itself — so without this rule the arrows walked an invisible
|
|
300
|
+
highlight. The list is the only thing that can say "the keyboard is in here", and
|
|
301
|
+
`[data-active]` is the only thing that can say which row.
|
|
302
|
+
|
|
303
|
+
Identical in shape to Menu's `.sds-menu__list:focus .sds-menu__item[data-active]`, which
|
|
304
|
+
met the same problem first and wrote the same answer. */
|
|
305
|
+
.sds-select__list:focus .sds-select__option[data-active="true"] {
|
|
306
|
+
background: var(--color-neutral-10);
|
|
307
|
+
color: var(--color-neutral-10-on);
|
|
308
|
+
box-shadow: var(--focus-ring-inset);
|
|
309
|
+
}
|
|
290
310
|
.sds-select__option--selected {
|
|
291
311
|
background: var(--color-neutral-10);
|
|
292
312
|
color: var(--color-neutral-10-on);
|
|
@@ -101,9 +101,18 @@ export function displayLabel(options, value, { placeholder = '', separator = ',
|
|
|
101
101
|
return labels.join(separator);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A row the keyboard can land on — a section header is a label, not a choice, and a
|
|
106
|
+
* disabled option is a dead end the reader would have to guess their way out of.
|
|
107
|
+
*
|
|
108
|
+
* Written once and read by both the arrow keys and `selectableValues`, so "selectable"
|
|
109
|
+
* cannot come to mean two things. Menu states the same rule as `isSelectable`.
|
|
110
|
+
*/
|
|
111
|
+
export const isOptionSelectable = (option) => !option.section && !option.disabled;
|
|
112
|
+
|
|
104
113
|
/** Every value that can be chosen — a disabled option is not one of them. */
|
|
105
114
|
export const selectableValues = (options) => options
|
|
106
|
-
.filter(
|
|
115
|
+
.filter(isOptionSelectable)
|
|
107
116
|
.map((o) => o.value);
|
|
108
117
|
|
|
109
118
|
/**
|
|
@@ -25,9 +25,10 @@ strings only, which fails on exactly the words a Brazilian visitor types without
|
|
|
25
25
|
|
|
26
26
|
import { normaliseOptions, filterOptions, toArray, toggleValue, isSelected,
|
|
27
27
|
displayLabel, pillLabel, hiddenChoiceCount, widestLabel, selectableValues, selectAllState,
|
|
28
|
-
assertTrigger, assertWidth }
|
|
28
|
+
isOptionSelectable, assertTrigger, assertWidth }
|
|
29
29
|
from "./select-options.js";
|
|
30
30
|
import { assertFeedback as assertInputFeedback } from "../Input/input-types.js";
|
|
31
|
+
import { focusWhenReady } from "../Floating/focus-when-ready.js";
|
|
31
32
|
import chevronIcon from "../../icons/generated/fluent-chevron-down-regular.marko";
|
|
32
33
|
import clearIcon from "../../icons/generated/fluent-dismiss-regular.marko";
|
|
33
34
|
|
|
@@ -36,6 +37,10 @@ class {
|
|
|
36
37
|
this.state = {
|
|
37
38
|
open: false,
|
|
38
39
|
query: "",
|
|
40
|
+
// Where the arrow keys are, as an index into `rows`. -1 is "nowhere", which is what a
|
|
41
|
+
// panel opened with the mouse starts at: nothing is highlighted until the reader
|
|
42
|
+
// moves, so Enter can never commit a row nobody has looked at.
|
|
43
|
+
activeIndex: -1,
|
|
39
44
|
value: input.value ?? input.defaultValue ?? (input.multiple ? [] : undefined),
|
|
40
45
|
};
|
|
41
46
|
}
|
|
@@ -44,10 +49,22 @@ class {
|
|
|
44
49
|
setOpen(next) {
|
|
45
50
|
if (this.input.disabled) return;
|
|
46
51
|
this.state.open = next;
|
|
52
|
+
this.state.activeIndex = -1;
|
|
47
53
|
if (!next) this.state.query = "";
|
|
48
54
|
if (next) this.wantsFocus = true;
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The list's size as it stands, before anything has changed it.
|
|
59
|
+
*
|
|
60
|
+
* Without this the FIRST update looks like the list changed size — it was `undefined`
|
|
61
|
+
* and is now a number — so the highlight was cleared on the very update that opening the
|
|
62
|
+
* panel causes, and an arrow on a closed Select opened it onto nothing.
|
|
63
|
+
*/
|
|
64
|
+
onMount() {
|
|
65
|
+
this.lastSize = this.rows.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
/**
|
|
52
69
|
* An opened list puts the caret where the next keystroke belongs: the search box when
|
|
53
70
|
* there is one, the list itself otherwise, so the arrow keys work without a click.
|
|
@@ -56,13 +73,35 @@ class {
|
|
|
56
73
|
* state changes.
|
|
57
74
|
*/
|
|
58
75
|
onUpdate() {
|
|
76
|
+
// A filtered list is a different list, and the highlight was an index into the old one
|
|
77
|
+
// — after typing it would point at whatever happened to land in that position. Menu hit
|
|
78
|
+
// exactly this and says so in its own source.
|
|
79
|
+
//
|
|
80
|
+
// It drops to "nowhere" rather than to the first match, which is where this Select
|
|
81
|
+
// diverges from Menu: nothing here is ever highlighted until the reader asks for it, so
|
|
82
|
+
// their next Enter is always about a row they have seen. One more ArrowDown reaches the
|
|
83
|
+
// first match, and that keystroke is the reader saying they looked.
|
|
84
|
+
const size = this.rows.length;
|
|
85
|
+
if (size !== this.lastSize) {
|
|
86
|
+
this.lastSize = size;
|
|
87
|
+
this.state.activeIndex = -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
59
90
|
if (!this.wantsFocus || !this.state.open) return;
|
|
60
91
|
this.wantsFocus = false;
|
|
61
92
|
|
|
62
93
|
const panel = this.getEl("panel");
|
|
63
94
|
if (!panel) return;
|
|
64
95
|
|
|
65
|
-
|
|
96
|
+
// Asking once is not enough: sds-floating keeps the panel `visibility: hidden` until it
|
|
97
|
+
// has measured and placed it, and focus() on a hidden element does nothing and says
|
|
98
|
+
// nothing. The rule, and the measurement behind it, are in focus-when-ready.js.
|
|
99
|
+
focusWhenReady(() => {
|
|
100
|
+
if (!this.state.open) return null;
|
|
101
|
+
const current = this.getEl("panel");
|
|
102
|
+
return current
|
|
103
|
+
&& (current.querySelector("input") ?? current.querySelector("[role='listbox']"));
|
|
104
|
+
});
|
|
66
105
|
}
|
|
67
106
|
|
|
68
107
|
get currentValue() {
|
|
@@ -79,6 +118,40 @@ class {
|
|
|
79
118
|
return this.input.filterOption === false ? all : filterOptions(all, this.state.query);
|
|
80
119
|
}
|
|
81
120
|
|
|
121
|
+
/**
|
|
122
|
+
* What the arrow keys walk, and what `activeIndex` indexes into.
|
|
123
|
+
*
|
|
124
|
+
* The same list the template renders, `loading` included: while the spinner is up there
|
|
125
|
+
* are no rows on screen, so an index would point into a list nobody has seen yet.
|
|
126
|
+
*/
|
|
127
|
+
get rows() {
|
|
128
|
+
return this.input.loading ? [] : this.visibleOptions;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Moves the highlight, skipping section headers and disabled options, wrapping at both
|
|
133
|
+
* ends. Menu's `move`, deliberately identical — two lists in the same product answering
|
|
134
|
+
* the same key differently is the kind of difference nobody decides on purpose.
|
|
135
|
+
*/
|
|
136
|
+
move(step) {
|
|
137
|
+
const selectable = this.rows
|
|
138
|
+
.map((row, index) => (isOptionSelectable(row) ? index : -1))
|
|
139
|
+
.filter((index) => index !== -1);
|
|
140
|
+
if (!selectable.length) return;
|
|
141
|
+
|
|
142
|
+
const current = selectable.indexOf(this.state.activeIndex);
|
|
143
|
+
const next = current === -1
|
|
144
|
+
? (step > 0 ? 0 : selectable.length - 1)
|
|
145
|
+
: (current + step + selectable.length) % selectable.length;
|
|
146
|
+
|
|
147
|
+
this.state.activeIndex = selectable[next];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The row the arrows are on, or null when they are nowhere. */
|
|
151
|
+
get activeOption() {
|
|
152
|
+
return this.rows[this.state.activeIndex] ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
82
155
|
togglePanel() {
|
|
83
156
|
this.setOpen(!this.state.open);
|
|
84
157
|
}
|
|
@@ -86,6 +159,7 @@ class {
|
|
|
86
159
|
closePanel() {
|
|
87
160
|
this.state.open = false;
|
|
88
161
|
this.state.query = "";
|
|
162
|
+
this.state.activeIndex = -1;
|
|
89
163
|
}
|
|
90
164
|
|
|
91
165
|
commit(next) {
|
|
@@ -156,9 +230,33 @@ class {
|
|
|
156
230
|
this.getEl("trigger")?.focus();
|
|
157
231
|
return;
|
|
158
232
|
}
|
|
159
|
-
if (!this.state.open
|
|
233
|
+
if (!this.state.open) {
|
|
234
|
+
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
this.setOpen(true);
|
|
237
|
+
// Opening and then asking for one more arrow to reach the first row is a keystroke
|
|
238
|
+
// that says nothing. Menu's trigger does the same.
|
|
239
|
+
this.move(1);
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Inside a search box Space is a space, and an arrow means "leave the box and go to the
|
|
245
|
+
// rows" rather than moving a highlight nobody is looking at. The same split Menu makes.
|
|
246
|
+
const typing = event.target.matches?.("input, textarea");
|
|
247
|
+
|
|
248
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
this.move(event.key === "ArrowDown" ? 1 : -1);
|
|
251
|
+
if (typing) this.getEl("panel")?.querySelector("[role='listbox']")?.focus();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (event.key === "Enter" || (event.key === " " && !typing)) {
|
|
256
|
+
const option = this.activeOption;
|
|
257
|
+
if (!option) return;
|
|
160
258
|
event.preventDefault();
|
|
161
|
-
this.
|
|
259
|
+
this.choose(option, event);
|
|
162
260
|
}
|
|
163
261
|
}
|
|
164
262
|
}
|
|
@@ -379,6 +477,10 @@ $ {
|
|
|
379
477
|
// Focusable, not tabbable: an opened list with no search box takes the caret so
|
|
380
478
|
// the arrow keys work without a click, and the tab order is unchanged.
|
|
381
479
|
tabindex="-1"
|
|
480
|
+
// The list holds focus and NAMES the option the arrows are on. Moving real focus
|
|
481
|
+
// onto the option instead would take it off the list, and the list is what hears
|
|
482
|
+
// the next arrow — and a screen reader would hear the list and never the row.
|
|
483
|
+
aria-activedescendant=(state.activeIndex >= 0 ? `${listboxId}-option-${state.activeIndex}` : null)
|
|
382
484
|
>
|
|
383
485
|
<if(loading)>
|
|
384
486
|
<li class="sds-select__loading"><sds-spinner size="sm" label="Carregando opções"/></li>
|
|
@@ -387,7 +489,7 @@ $ {
|
|
|
387
489
|
<li class="sds-select__empty">${emptyMessage}</li>
|
|
388
490
|
</else-if>
|
|
389
491
|
|
|
390
|
-
<for|option| of=(loading ? [] : visible)>
|
|
492
|
+
<for|option, index| of=(loading ? [] : visible)>
|
|
391
493
|
<if(option.section)>
|
|
392
494
|
<li class="sds-select__section-header" role="presentation">${option.label}</li>
|
|
393
495
|
</if>
|
|
@@ -395,9 +497,14 @@ $ {
|
|
|
395
497
|
<li role="none">
|
|
396
498
|
<button
|
|
397
499
|
type="button"
|
|
500
|
+
id=`${listboxId}-option-${index}`
|
|
398
501
|
class=`sds-select__option ${isSelected(value, option.value) ? "sds-select__option--selected" : ""}`.trim()
|
|
399
502
|
role="option"
|
|
400
503
|
aria-selected=(isSelected(value, option.value) ? "true" : "false")
|
|
504
|
+
// What the stylesheet rings. It cannot be `:focus-visible` here: focus
|
|
505
|
+
// stays on the list, and a highlight the component moved is not something
|
|
506
|
+
// the browser marks as visible focus.
|
|
507
|
+
data-active=(index === state.activeIndex ? "true" : null)
|
|
401
508
|
disabled=(option.disabled || null)
|
|
402
509
|
on-click("choose", option)
|
|
403
510
|
>
|