Skip to content

Commit 59887c9

Browse files
committed
feat(select): allow to focus option with key
- Allow arrow `down` and `up` to focus select options
1 parent 5483fce commit 59887c9

File tree

4 files changed

+179
-37
lines changed

4 files changed

+179
-37
lines changed

packages/core/src/components/select/select.scss

+23-13
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* @prop --max-height: Change max-height for dropdown content (apply only when screen width is more than 640px)
1616
*/
1717

18-
@include join-item.item("details > summary");
18+
@include join-item.item(".dropdown > .dropdown-trigger");
1919

2020
// Select
2121
// ----------------------------------------------------------------
@@ -40,6 +40,18 @@ $minWidth: 12rem;
4040
position: relative;
4141
width: 100%;
4242

43+
&[open] {
44+
svg {
45+
transform: rotateX(180deg);
46+
}
47+
48+
.dropdown-content {
49+
pointer-events: all;
50+
opacity: 1;
51+
transform: rotateX(0);
52+
}
53+
}
54+
4355
&-trigger {
4456
display: flex;
4557
position: relative;
@@ -66,8 +78,12 @@ $minWidth: 12rem;
6678
}
6779

6880
&-content {
81+
pointer-events: none;
6982
position: absolute;
7083
z-index: 20;
84+
opacity: 0;
85+
86+
display: block;
7187

7288
min-height: 2rem;
7389
max-height: var(--max-height, 20rem);
@@ -82,9 +98,10 @@ $minWidth: 12rem;
8298
background-color: var(--background);
8399

84100
color: var(--color);
85-
animation-duration: 200ms;
86-
87-
animation-name: present;
101+
perspective: 200px;
102+
transform: rotateX(28deg);
103+
transform-style: preserve-3d;
104+
transition: 150ms ease-in 0s;
88105
}
89106

90107
&-backdrop {
@@ -103,7 +120,7 @@ $minWidth: 12rem;
103120
svg {
104121
min-width: 24px;
105122
margin-inline-start: auto;
106-
transition: transform 150ms ease 0ms;
123+
transition: transform 200ms ease 0ms;
107124
}
108125

109126
pop-list {
@@ -139,13 +156,6 @@ $minWidth: 12rem;
139156

140157
}
141158

142-
143-
:host(.select-expanded) {
144-
svg {
145-
transform: rotateX(180deg);
146-
}
147-
}
148-
149159
:host([bordered]) {
150160
--border-color: #{theme.use_color("base.content", 0.2)};
151161
}
@@ -161,7 +171,7 @@ $minWidth: 12rem;
161171
gap: 0.5rem;
162172
}
163173

164-
// Input Size
174+
// Select Size
165175
// ----------------------------------------------------------------
166176

167177
:host([size="xs"]) .dropdown-trigger {

packages/core/src/components/select/select.tsx

+59-23
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@ import {
55
Element,
66
Event,
77
type EventEmitter,
8-
Fragment,
98
Host,
109
Method,
1110
Prop,
1211
State,
1312
Watch,
1413
h,
1514
} from '@stencil/core';
16-
import { ENTER, ESC, SPACE } from 'key-definitions';
15+
import { ARROW_DOWN, ARROW_UP, ENTER, ESC, SPACE } from 'key-definitions';
1716
import type { FormAssociatedInterface, Size } from 'src/interface';
1817
import { componentConfig, config } from '#config';
1918
import { ClickOutside } from '#utils/click-outside';
@@ -50,6 +49,8 @@ export class Select implements ComponentInterface, FormAssociatedInterface {
5049
@Element() host!: HTMLElement;
5150
@AttachInternals() internals: ElementInternals;
5251

52+
@State() options: HTMLPopSelectOptionElement[] = [];
53+
5354
@State() open = false;
5455

5556
@State() errorText: string;
@@ -382,6 +383,11 @@ export class Select implements ComponentInterface, FormAssociatedInterface {
382383
};
383384
};
384385

386+
private onSlotChange = () => {
387+
const options = this.host.querySelectorAll('pop-select-option');
388+
this.options = [...Array.from(options)];
389+
};
390+
385391
private get values(): any[] {
386392
const { value } = this;
387393
if (value == null) return [];
@@ -415,10 +421,6 @@ export class Select implements ComponentInterface, FormAssociatedInterface {
415421
return '';
416422
}
417423

418-
private get options() {
419-
return Array.from(this.host.querySelectorAll('pop-select-option'));
420-
}
421-
422424
private getAriaLabel(text: string): string {
423425
const { placeholder } = this;
424426
const displayValue = text;
@@ -484,19 +486,36 @@ export class Select implements ComponentInterface, FormAssociatedInterface {
484486
}}
485487
value={this.value}
486488
>
487-
{this.options.map(option => (
488-
<pop-item>
489-
<pop-radio
490-
checked={isOptionSelected(this.value, getOptionValue(option), this.compare)}
491-
color={color === 'ghost' ? undefined : color}
492-
disabled={option.disabled}
493-
size={size}
494-
value={getOptionValue(option)}
495-
>
496-
{option.textContent}
497-
</pop-radio>
498-
</pop-item>
499-
))}
489+
<pop-list>
490+
{this.options.map((option, idx) => (
491+
<pop-item>
492+
<pop-radio
493+
checked={isOptionSelected(this.value, getOptionValue(option), this.compare)}
494+
color={color === 'ghost' ? undefined : color}
495+
disabled={option.disabled}
496+
onKeyUp={async ev => {
497+
if (ev.key !== ARROW_DOWN.key && ev.key !== ARROW_UP.key) {
498+
return;
499+
}
500+
ev.preventDefault();
501+
const radios = this.dropdownRef?.querySelectorAll('pop-radio') ?? [];
502+
if (radios.length === 0) {
503+
return;
504+
}
505+
506+
const previous = idx === 0 ? this.options.length - 1 : idx - 1;
507+
const next = idx === this.options.length - 1 ? 0 : idx + 1;
508+
const index = ev.key === ARROW_UP.key ? previous : next;
509+
return Array.from(radios).at(index).setFocus();
510+
}}
511+
size={size}
512+
value={getOptionValue(option)}
513+
>
514+
{option.textContent}
515+
</pop-radio>
516+
</pop-item>
517+
))}
518+
</pop-list>
500519
</pop-radio-group>
501520
);
502521
}
@@ -505,13 +524,28 @@ export class Select implements ComponentInterface, FormAssociatedInterface {
505524
const { color, size } = this;
506525

507526
return (
508-
<Fragment>
509-
{this.options.map(option => (
527+
<pop-list>
528+
{this.options.map((option, idx) => (
510529
<pop-item>
511530
<pop-checkbox
512531
checked={isOptionSelected(this.value, getOptionValue(option), this.compare)}
513532
color={color === 'ghost' ? undefined : color}
514533
disabled={option.disabled ?? this.disabled}
534+
onKeyUp={async ev => {
535+
if (ev.key !== ARROW_DOWN.key && ev.key !== ARROW_UP.key) {
536+
return;
537+
}
538+
ev.preventDefault();
539+
const checkboxes = this.dropdownRef?.querySelectorAll('pop-checkbox') ?? [];
540+
if (checkboxes.length === 0) {
541+
return;
542+
}
543+
544+
const previous = idx === 0 ? this.options.length - 1 : idx - 1;
545+
const next = idx === this.options.length - 1 ? 0 : idx + 1;
546+
const index = ev.key === ARROW_UP.key ? previous : next;
547+
return Array.from(checkboxes).at(index).setFocus();
548+
}}
515549
onPopChange={async () => {
516550
this.errorText = this.errorTextValue;
517551
if (this.errorText) {
@@ -530,7 +564,7 @@ export class Select implements ComponentInterface, FormAssociatedInterface {
530564
</pop-checkbox>
531565
</pop-item>
532566
))}
533-
</Fragment>
567+
</pop-list>
534568
);
535569
}
536570

@@ -594,7 +628,7 @@ export class Select implements ComponentInterface, FormAssociatedInterface {
594628
class="dropdown-content"
595629
part="content"
596630
>
597-
<pop-list>{this.multiple ? this.renderCheckboxOptions() : this.renderRadioOptions()}</pop-list>
631+
{this.multiple ? this.renderCheckboxOptions() : this.renderRadioOptions()}
598632
</div>
599633
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Element not focusable, handle by summary keyboard event */}
600634
<div
@@ -614,6 +648,8 @@ export class Select implements ComponentInterface, FormAssociatedInterface {
614648
</Show>
615649
</div>
616650
</Show>
651+
652+
<slot onSlotchange={this.onSlotChange} />
617653
</Host>
618654
);
619655
}

packages/core/src/components/select/tests/form/index.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<head>
55
<meta charset="UTF-8">
66
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7-
<title>Select | Poppy-ui</title>
7+
<title>Select Form | Poppy-ui</title>
88
<link rel="stylesheet" href="/dist/poppy/poppy.css">
99
<script type="module" src="/dist/poppy/poppy.esm.js"></script>
1010
<script nomodule src="/dist/poppy/poppy.js"></script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>Select Join | Poppy-ui</title>
8+
<link rel="stylesheet" href="/dist/poppy/poppy.css">
9+
<script type="module" src="/dist/poppy/poppy.esm.js"></script>
10+
<script nomodule src="/dist/poppy/poppy.js"></script>
11+
<style>
12+
main {
13+
width: 100vw;
14+
height: 100dvh;
15+
display: flex;
16+
flex-direction: column;
17+
gap: 1rem;
18+
padding: 1rem;
19+
20+
background-color: var(--base-300);
21+
}
22+
23+
section {
24+
display: flex;
25+
flex-direction: column;
26+
justify-content: center;
27+
gap: .35rem;
28+
}
29+
30+
div {
31+
display: flex;
32+
gap: .5rem;
33+
}
34+
</style>
35+
</head>
36+
37+
<body>
38+
<main>
39+
<section>
40+
<h2>Select - basic</h2>
41+
<div>
42+
<pop-select placeholder="Select a value">
43+
<span slot="label">Label</span>
44+
<pop-select-option value="1">Option 1</pop-select-option>
45+
<pop-select-option value="2">Option 2</pop-select-option>
46+
<pop-select-option value="3">Option 3</pop-select-option>
47+
<pop-select-option value="4">Option 4</pop-select-option>
48+
</pop-select>
49+
<pop-select placeholder="Select a value">
50+
<span slot="label">Label</span>
51+
<pop-select-option value="1">Option 1</pop-select-option>
52+
<pop-select-option value="2">Option 2</pop-select-option>
53+
<pop-select-option value="3">Option 3</pop-select-option>
54+
<pop-select-option value="4">Option 4</pop-select-option>
55+
</pop-select>
56+
<pop-select placeholder="Select a value">
57+
<span slot="label">Label</span>
58+
<pop-select-option value="1">Option 1</pop-select-option>
59+
<pop-select-option value="2">Option 2</pop-select-option>
60+
<pop-select-option value="3">Option 3</pop-select-option>
61+
<pop-select-option value="4">Option 4</pop-select-option>
62+
</pop-select>
63+
</div>
64+
</section>
65+
<section>
66+
<h2>Select - joined</h2>
67+
<div>
68+
<pop-join>
69+
<pop-select placeholder="Select a value">
70+
<span slot="label">Label</span>
71+
<pop-select-option value="1">Option 1</pop-select-option>
72+
<pop-select-option value="2">Option 2</pop-select-option>
73+
<pop-select-option value="3">Option 3</pop-select-option>
74+
<pop-select-option value="4">Option 4</pop-select-option>
75+
</pop-select>
76+
<pop-select placeholder="Select a value">
77+
<span slot="label">Label</span>
78+
<pop-select-option value="1">Option 1</pop-select-option>
79+
<pop-select-option value="2">Option 2</pop-select-option>
80+
<pop-select-option value="3">Option 3</pop-select-option>
81+
<pop-select-option value="4">Option 4</pop-select-option>
82+
</pop-select>
83+
<pop-select placeholder="Select a value">
84+
<span slot="label">Label</span>
85+
<pop-select-option value="1">Option 1</pop-select-option>
86+
<pop-select-option value="2">Option 2</pop-select-option>
87+
<pop-select-option value="3">Option 3</pop-select-option>
88+
<pop-select-option value="4">Option 4</pop-select-option>
89+
</pop-select>
90+
</pop-join>
91+
</div>
92+
</section>
93+
</main>
94+
</body>
95+
96+
</html>

0 commit comments

Comments
 (0)