Skip to content

Commit fd0f807

Browse files
refactor(MultiSelect): rename to MultiSelect, add aria attributes and keyboard navigation, requires an id now
1 parent 007f87f commit fd0f807

File tree

2 files changed

+101
-41
lines changed

2 files changed

+101
-41
lines changed
Lines changed: 98 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mdiCheck, mdiClose, mdiPlus } from "@mdi/js"
22
import { Key } from "@solid-primitives/keyed"
33
import type { Accessor } from "solid-js"
4-
import { mergeProps } from "solid-js"
4+
import { createEffect, createSignal, For, mergeProps, onMount } from "solid-js"
55
import { ct0, ct1 } from "~ui/i18n/ct0"
66
import { t4multiselect } from "~ui/input/select/t4multiselect"
77
import { buttonVariant } from "~ui/interactive/button/buttonCva"
@@ -11,14 +11,15 @@ import { CorvuPopover } from "~ui/interactive/popover/CorvuPopover"
1111
import { classArr } from "~ui/utils/classArr"
1212
import { classMerge } from "~ui/utils/classMerge"
1313
import type { SignalObject } from "~ui/utils/createSignalObject"
14+
import type { HasId } from "~ui/utils/HasId"
1415
import type { MayHaveChildren } from "~ui/utils/MayHaveChildren"
1516
import type { MayHaveClass } from "~ui/utils/MayHaveClass"
1617
import type { MayHaveInnerClass } from "~ui/utils/MayHaveInnerClass"
1718

1819
/**
1920
* https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/src/Checkbox.tsx
2021
*/
21-
export interface Multiselect2Props extends MayHaveClass, MayHaveInnerClass, MayHaveChildren {
22+
export interface MultiSelectProps extends HasId, MayHaveClass, MayHaveInnerClass, MayHaveChildren {
2223
buttonProps: CorvuPopoverProps
2324
textNoEntries?: string
2425
textAddEntry?: string
@@ -30,18 +31,16 @@ export interface Multiselect2Props extends MayHaveClass, MayHaveInnerClass, MayH
3031
listOptionClass?: string
3132
}
3233

33-
export function Multiselect(p: Multiselect2Props) {
34+
export function MultiSelect(p: MultiSelectProps) {
3435
const buttonClass = classMerge(p.addEntryClass, p.buttonProps.class)
35-
const buttonProps = mergeProps(
36-
{
37-
icon: mdiPlus,
38-
children: p.textAddEntry ?? ct0(t4multiselect.Add_entry),
39-
},
40-
p.buttonProps,
41-
{ class: buttonClass },
42-
)
36+
const buttonProps = mergeProps(p.buttonProps, {
37+
icon: mdiPlus,
38+
children: p.textAddEntry ?? ct0(t4multiselect.Add_entry),
39+
class: buttonClass,
40+
})
4341
return (
4442
<div
43+
id={p.id}
4544
class={classArr(
4645
"group border border-input",
4746
"px-2 py-2 text-sm",
@@ -53,13 +52,15 @@ export function Multiselect(p: Multiselect2Props) {
5352
)}
5453
>
5554
<SelectedValues valueSignal={p.valueSignal} valueText={p.valueText} noItemsClass={p.noItemsClass} />
56-
<CorvuPopover {...buttonProps} innerClass={classArr(p.innerClass ?? "grid grid-cols-3 gap-x-2 gap-y-1")}>
55+
<CorvuPopover {...buttonProps}>
5756
<OptionList
57+
id={p.id}
5858
valueSignal={p.valueSignal}
5959
getOptions={p.getOptions}
6060
valueText={p.valueText}
6161
noItemsClass={p.noItemsClass}
6262
listOptionClass={p.listOptionClass}
63+
innerClass={p.innerClass}
6364
/>
6465
</CorvuPopover>
6566
</div>
@@ -74,7 +75,7 @@ interface SelectedValuesProps {
7475

7576
function SelectedValues(p: SelectedValuesProps) {
7677
return (
77-
<div class={"flex flex-wrap gap-1"}>
78+
<div class={"flex flex-wrap gap-1"} role="list">
7879
<Key each={p.valueSignal.get()} by={(item) => item} fallback={<NoItems class={p.noItemsClass} />}>
7980
{(item) => <SelectedValue option={item()} valueSignal={p.valueSignal} valueText={p.valueText} />}
8081
</Key>
@@ -94,20 +95,21 @@ function SelectedValue(p: SelectedValueProps) {
9495
const label = () => (p.valueText ? p.valueText(p.option) : p.option)
9596
return (
9697
<ButtonIcon
97-
variant={buttonVariant.outline}
98+
role="listitem"
99+
variant={buttonVariant.filled}
98100
iconRight={mdiClose}
99101
class={"text-sm px-2 py-1"}
100102
data-value={p.option}
101103
onMouseDown={(e) => optionRemove(p)}
102104
onClick={(e) => optionRemove(p)}
103-
title={ct1(t4multiselect.Remove_x, label())}
105+
title={ct1(t4multiselect.Remove_x, label()) || ""}
104106
>
105107
{label()}
106108
</ButtonIcon>
107109
)
108110
}
109111

110-
interface OptionListProps {
112+
interface OptionListProps extends HasId, MayHaveInnerClass {
111113
valueSignal: SignalObject<string[]>
112114
getOptions: Accessor<string[]>
113115
valueText?: (value: string) => string
@@ -116,45 +118,102 @@ interface OptionListProps {
116118
}
117119

118120
function OptionList(p: OptionListProps) {
121+
const getOptions = p.getOptions
122+
const options = getOptions()
123+
const [focusedIndex, setFocusedIndex] = createSignal(-1)
124+
125+
createEffect(() => {
126+
const idx = focusedIndex()
127+
if (idx >= 0) {
128+
setTimeout(() => {
129+
const el = document.getElementById(`${p.id}-option-${idx}`)
130+
el?.focus()
131+
}, 0)
132+
}
133+
})
134+
135+
onMount(() => {
136+
if (options.length > 0) {
137+
setFocusedIndex(0)
138+
}
139+
})
140+
141+
const handleKeyDown = (e: KeyboardEvent) => {
142+
const opts = getOptions()
143+
switch (e.key) {
144+
case "ArrowDown":
145+
e.preventDefault()
146+
setFocusedIndex((prev) => (prev + 1) % opts.length)
147+
break
148+
case "ArrowUp":
149+
e.preventDefault()
150+
setFocusedIndex((prev) => (prev - 1 + opts.length) % opts.length)
151+
break
152+
case "Enter":
153+
case " ":
154+
e.preventDefault()
155+
if (focusedIndex() >= 0) {
156+
const option = opts[focusedIndex()]!
157+
toggleOption({ option, valueSignal: p.valueSignal, valueText: p.valueText })
158+
}
159+
break
160+
}
161+
}
162+
163+
if (options.length === 0) {
164+
return <NoItems class={p.noItemsClass} />
165+
}
166+
119167
return (
120-
<>
121-
<Key each={p.getOptions()} by={(item) => item} fallback={<NoItems class={p.noItemsClass} />}>
122-
{(item) => (
168+
<div
169+
role="listbox"
170+
aria-multiselectable="true"
171+
onKeyDown={handleKeyDown}
172+
class={classArr(p.innerClass ?? "grid grid-cols-3 gap-x-2 gap-y-1")}
173+
>
174+
<For each={options}>
175+
{(option, index) => (
123176
<ListOption
124-
option={item()}
177+
id={p.id}
178+
option={option}
179+
index={index()}
180+
focusedIndex={focusedIndex()}
181+
setFocusedIndex={setFocusedIndex}
125182
valueSignal={p.valueSignal}
126183
valueText={p.valueText}
127184
listOptionClass={p.listOptionClass}
128185
/>
129186
)}
130-
</Key>
131-
</>
187+
</For>
188+
</div>
132189
)
133190
}
134191

135-
interface ListOptionProps extends MultiselectOptionState {
192+
interface ListOptionProps extends HasId, MultiselectOptionState {
193+
index: number
194+
focusedIndex: number
195+
setFocusedIndex: (v: number) => void
136196
listOptionClass?: string
137197
}
138198

139199
function ListOption(p: ListOptionProps) {
140200
const label = () => (p.valueText ? p.valueText(p.option) : p.option)
141201
return (
142-
<>
143-
<ButtonIcon
144-
type="button"
145-
role="checkbox"
146-
aria-checked={optionIsSelected(p)}
147-
data-state={optionIsSelected(p)}
148-
iconRight={optionIsSelected(p) ? mdiCheck : undefined}
149-
onClick={(e) => {
150-
toggleOption(p)
151-
}}
152-
variant={buttonVariant.ghost}
153-
class={classMerge("justify-start", p.listOptionClass)}
154-
>
155-
{label()}
156-
</ButtonIcon>
157-
</>
202+
<ButtonIcon
203+
id={`${p.id}-option-${p.index}`}
204+
tabIndex={p.focusedIndex === p.index ? 0 : -1}
205+
role="option"
206+
aria-selected={optionIsSelected(p)}
207+
iconRight={optionIsSelected(p) ? mdiCheck : undefined}
208+
onClick={() => {
209+
toggleOption(p)
210+
p.setFocusedIndex(p.index)
211+
}}
212+
variant={buttonVariant.ghost}
213+
class={classMerge("justify-start", p.focusedIndex === p.index ? "ring-2 ring-blue-500" : "", p.listOptionClass)}
214+
>
215+
{label()}
216+
</ButtonIcon>
158217
)
159218
}
160219

src/demos/input/DemoMultiSelect.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Multiselect } from "~ui/input/select/Multiselect"
1+
import { MultiSelect } from "~ui/input/select/MultiSelect"
22
import { PageWrapper } from "~ui/static/page/PageWrapper"
33
import { createSignalObject } from "~ui/utils/createSignalObject"
44
import { arrCreate } from "~utils/arr/arrCreate"
@@ -9,7 +9,8 @@ const multiValueSignal = createSignalObject<string[]>([])
99
export function DemoMultiSelect() {
1010
return (
1111
<PageWrapper>
12-
<Multiselect
12+
<MultiSelect
13+
id="DemoMultiSelect"
1314
valueSignal={multiValueSignal}
1415
getOptions={() => options100Strings}
1516
buttonProps={{}}

0 commit comments

Comments
 (0)