11import { mdiCheck , mdiClose , mdiPlus } from "@mdi/js"
22import { Key } from "@solid-primitives/keyed"
33import type { Accessor } from "solid-js"
4- import { mergeProps } from "solid-js"
4+ import { createEffect , createSignal , For , mergeProps , onMount } from "solid-js"
55import { ct0 , ct1 } from "~ui/i18n/ct0"
66import { t4multiselect } from "~ui/input/select/t4multiselect"
77import { buttonVariant } from "~ui/interactive/button/buttonCva"
@@ -11,14 +11,15 @@ import { CorvuPopover } from "~ui/interactive/popover/CorvuPopover"
1111import { classArr } from "~ui/utils/classArr"
1212import { classMerge } from "~ui/utils/classMerge"
1313import type { SignalObject } from "~ui/utils/createSignalObject"
14+ import type { HasId } from "~ui/utils/HasId"
1415import type { MayHaveChildren } from "~ui/utils/MayHaveChildren"
1516import type { MayHaveClass } from "~ui/utils/MayHaveClass"
1617import 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
7576function 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
118120function 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
139199function 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
0 commit comments