@kenlo/sds-design-system 0.9.9 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +58 -7
  2. package/components/Avatar/Avatar.css +22 -0
  3. package/components/Button/Button.css +95 -8
  4. package/components/Card/Card.css +16 -6
  5. package/components/Card/card-variants.js +18 -0
  6. package/components/Card/card.marko +12 -2
  7. package/components/Checkbox/Checkbox.css +56 -0
  8. package/components/ColorPicker/ColorPicker.css +25 -2
  9. package/components/ColorPicker/color-picker.marko +25 -4
  10. package/components/DatePicker/DatePicker.css +22 -0
  11. package/components/Floating/focus-when-ready.js +59 -0
  12. package/components/Incrementer/Incrementer.css +22 -0
  13. package/components/Input/Input.css +25 -2
  14. package/components/Menu/Menu.css +21 -11
  15. package/components/Menu/menu-variants.js +12 -0
  16. package/components/Menu/menu.marko +11 -7
  17. package/components/Pill/Pill.css +46 -0
  18. package/components/Radio/Radio.css +75 -0
  19. package/components/Select/Select.css +21 -1
  20. package/components/Select/select-options.js +10 -1
  21. package/components/Select/select.marko +112 -5
  22. package/components/Table/Table.css +73 -2
  23. package/components/Table/table-column-picker.marko +161 -0
  24. package/components/Table/table-logic.js +56 -0
  25. package/components/Table/table.marko +50 -12
  26. package/components/TextEditor/TextEditor.css +2 -2
  27. package/components/Toggle/Toggle.css +40 -0
  28. package/components/Tooltip/Tooltip.css +2 -2
  29. package/components/Uploader/Uploader.css +23 -0
  30. package/foundations/custom-scheme.js +128 -10
  31. package/foundations/fonts/OFL.txt +114 -0
  32. package/foundations/fonts/fonts.json +152 -0
  33. package/foundations/fonts/manrope-cyrillic-ext.woff2 +0 -0
  34. package/foundations/fonts/manrope-cyrillic.woff2 +0 -0
  35. package/foundations/fonts/manrope-greek.woff2 +0 -0
  36. package/foundations/fonts/manrope-latin-ext.woff2 +0 -0
  37. package/foundations/fonts/manrope-latin.woff2 +0 -0
  38. package/foundations/fonts/manrope-vietnamese.woff2 +0 -0
  39. package/foundations/tokens.css +1095 -15
  40. package/icons/generated/fluent-chevron-down-filled.marko +3 -0
  41. package/icons/generated/fluent-chevron-up-filled.marko +3 -0
  42. package/icons/generated/fluent-column-triple-regular.marko +3 -0
  43. package/icons/manifest.json +3 -0
  44. package/marko.json +3 -0
  45. package/package.json +8 -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: 320px;
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: calc(7.5 * var(--sds-menu-item-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
+ }
@@ -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(--sds-menu-item-height);
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-width) solid var(--focus-stroke-color);
561
- outline-offset: 2px;
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-width) solid var(--focus-stroke-color);
586
- outline-offset: 2px;
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
- const field = panel.querySelector("input, textarea, [contenteditable='true']");
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)) {
@@ -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(--sds-menu-item-height, 40px);
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((o) => !o.disabled && !o.section)
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
- (panel.querySelector("input") ?? panel.querySelector("[role='listbox']"))?.focus();
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 && (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ")) {
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.state.open = true;
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
  >