Skip to content

Commit

Permalink
fix(kselect, kmultiselect): filtered arrow navigation [KHCP-13956] (#…
Browse files Browse the repository at this point in the history
…2573)

* fix(kselect, kmultiselect): filtered arrow navigation [KHCP-13956]

* fix(kselect): refactor arrow key navigation implementation [KHCP-13956]

* fix(kselect): minor fix [KHCP-13956]

* fix(kmultiselect): refactor arrow key nav implementation [KHCP-13956]

* fix: minor fix

* fix(kmultiselect): address feedback [KHCP-13956]

* fix: address pr feedback

* fix: minor fix

* fix: address pr feedback
  • Loading branch information
portikM authored Jan 23, 2025
1 parent 26dcce3 commit 7ff96d2
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 215 deletions.
91 changes: 38 additions & 53 deletions src/components/KMultiselect/KMultiselect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
>
<div v-if="collapsedContext">
<KInput
ref="multiselectInputElement"
autocapitalize="off"
autocomplete="off"
class="multiselect-input"
Expand Down Expand Up @@ -177,43 +176,30 @@
@update:model-value="onQueryChange"
/>
</div>
<div aria-live="polite">
<KMultiselectItems
ref="kMultiselectItems"
:items="sortedItems"
@selected="handleItemSelect"
>
<template #content="{ item }">
<slot
class="multiselect-item"
:item="item"
name="item-template"
/>
</template>
</KMultiselectItems>
<KMultiselectItem
v-if="enableItemCreation && uniqueFilterStr && !$slots.empty"
key="multiselect-add-item"
class="multiselect-add-item"
data-testid="multiselect-add-item"
:item="{ label: `${filterString} (Add new value)`, value: 'add_item', disabled: !itemCreationValidator(filterString) }"
@selected="handleAddItem"
>
<template #content>
<div class="select-item-description">
{{ filterString }}
<span class="select-item-new-indicator">(Add new value)</span>
</div>
</template>
</KMultiselectItem>
<KMultiselectItem
v-if="!sortedItems.length && !$slots.empty && !enableItemCreation"
key="multiselect-empty-item"
class="multiselect-empty-item"
data-testid="multiselect-empty-item"
:item="{ label: 'No results', value: 'no_results', disabled: true }"
/>
</div>
<KMultiselectItems
ref="kMultiselectItems"
:filter-string="filterString"
:item-creation-enabled="enableItemCreation && uniqueFilterStr"
:item-creation-valid="itemCreationValidator(filterString)"
:items="sortedItems"
@add-item="handleAddItem"
@selected="handleItemSelect"
>
<template #content="{ item }">
<slot
class="multiselect-item"
:item="item"
name="item-template"
/>
</template>
</KMultiselectItems>
<KMultiselectItem
v-if="!sortedItems.length && !$slots.empty && !enableItemCreation"
key="multiselect-empty-item"
class="multiselect-empty-item"
data-testid="multiselect-empty-item"
:item="{ label: 'No results', value: 'no_results', disabled: true }"
/>
<div
v-if="$slots.empty && !loading && !sortedItems.length"
class="multiselect-empty"
Expand Down Expand Up @@ -285,7 +271,7 @@

<script lang="ts">
import type { Ref, PropType } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onUnmounted, useAttrs, useSlots, useId } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onUnmounted, useAttrs, useSlots, useId, useTemplateRef } from 'vue'
import useUtilities from '@/composables/useUtilities'
import KBadge from '@/components/KBadge/KBadge.vue'
import KInput from '@/components/KInput/KInput.vue'
Expand Down Expand Up @@ -466,7 +452,7 @@ const emit = defineEmits<{
(e: 'item-removed', value: MultiselectItem): void
}>()
const kMultiselectItems = ref<InstanceType<typeof KMultiselectItems> | null>(null)
const multiselectItemsRef = useTemplateRef('kMultiselectItems')
const isRequired = computed((): boolean => attrs.required !== undefined && String(attrs.required) !== 'false')
const strippedLabel = computed((): string => stripRequiredLabel(props.label, isRequired.value))
Expand Down Expand Up @@ -499,10 +485,9 @@ const defaultId = useId()
const multiselectWrapperId = computed((): string => attrs.id ? String(attrs.id) : defaultId) // unique id for the KLabel `for` attribute
const multiselectKey = useId()
const multiselectElement = ref<HTMLDivElement | null>(null)
const multiselectInputElement = ref<InstanceType<typeof KInput> | null>(null)
const multiselectDropdownInputElement = ref<InstanceType<typeof KInput> | null>(null)
const multiselectSelectionsStagingElement = ref<HTMLDivElement>()
const multiselectElementRef = useTemplateRef('multiselectElement')
const multiselectDropdownInputElementRef = useTemplateRef('multiselectDropdownInputElement')
const multiselectSelectionsStagingElementRef = useTemplateRef('multiselectSelectionsStagingElement')
// filter and selection
const selectionsMaxHeight = computed((): number => {
Expand Down Expand Up @@ -657,7 +642,7 @@ const handleToggle = async (open: boolean, isToggled: Ref<boolean>, toggle: () =
await nextTick() // wait for the dropdown to open
const input = multiselectDropdownInputElement.value?.$el?.querySelector('input') as HTMLInputElement
const input = multiselectDropdownInputElementRef.value?.$el?.querySelector('input') as HTMLInputElement
input?.focus({ preventScroll: true })
}
} else {
Expand All @@ -673,7 +658,7 @@ const handleToggle = async (open: boolean, isToggled: Ref<boolean>, toggle: () =
const stageSelections = () => {
// set timeout required to push the calculation to the end of the update lifecycle event queue
setTimeout(() => {
const elem = multiselectSelectionsStagingElement.value
const elem = multiselectSelectionsStagingElementRef.value
if (props.collapsedContext) {
// if it's collapsed don't do calculations, because we don't display badges
Expand Down Expand Up @@ -909,7 +894,7 @@ const triggerFocus = (evt: any, isToggled: Ref<boolean>):void => {
}
if ((evt.code === 'ArrowDown' || evt.code === 'ArrowUp')) {
kMultiselectItems.value?.setFocus()
multiselectItemsRef.value?.setFocus()
}
}
Expand All @@ -919,7 +904,7 @@ const onTriggerKeypress = () => {
const onDropdownInputKeyup = (event: any) => {
if ((event.code === 'ArrowDown' || event.code === 'ArrowUp')) {
kMultiselectItems.value?.setFocus()
multiselectItemsRef.value?.setFocus()
}
}
Expand All @@ -940,7 +925,7 @@ const triggerInitialFocus = (): void => {
watch(stagingKey, () => {
// set timeout required to push the calculation to the end of the update lifecycle event queue
setTimeout(() => {
const elem = multiselectSelectionsStagingElement.value
const elem = multiselectSelectionsStagingElementRef.value
if (props.collapsedContext) {
// if collapsed, don't do all the calculations because we are not displaying badges
Expand Down Expand Up @@ -1062,7 +1047,7 @@ const numericWidth = ref<number>(300)
const setNumericWidth = async (): Promise<void> => {
numericWidth.value = 300
await nextTick()
numericWidth.value = multiselectElement.value?.clientWidth || 300
numericWidth.value = multiselectElementRef.value?.clientWidth || 300
stageSelections()
}
Expand All @@ -1072,12 +1057,12 @@ onMounted(() => {
useEventListener('resize', setNumericWidth) // automatically removes listener on unmount so no need to clean up
resizeObserver.value = ResizeObserverHelper.create(setNumericWidth)
resizeObserver.value.observe(multiselectElement.value as HTMLDivElement)
resizeObserver.value.observe(multiselectElementRef.value as HTMLDivElement)
})
onUnmounted(() => {
if (resizeObserver.value && multiselectElement.value) {
resizeObserver.value.unobserve(multiselectElement.value)
if (resizeObserver.value && multiselectElementRef.value) {
resizeObserver.value.unobserve(multiselectElementRef.value)
}
})
</script>
Expand Down
140 changes: 81 additions & 59 deletions src/components/KMultiselect/KMultiselectItems.vue
Original file line number Diff line number Diff line change
@@ -1,36 +1,14 @@
<template>
<KMultiselectItem
v-for="item, idx in nonGroupedItems"
:key="`${item.key ? item.key : idx}-item`"
ref="kMultiselectItem"
:item="item"
@arrow-down="() => shiftFocus(item.key, 'down')"
@arrow-up="() => shiftFocus(item.key, 'up')"
@selected="handleItemSelect"
>
<template #content>
<slot
:item="item"
name="content"
/>
</template>
</KMultiselectItem>

<div
v-for="group in groups"
:key="`${group}-group`"
class="multiselect-group"
ref="itemsContainer"
aria-live="polite"
class="multiselect-items-container"
>
<span class="multiselect-group-title">
{{ group }}
</span>
<KMultiselectItem
v-for="(item, idx) in getGroupItems(group)"
:key="`${item.key ? item.key : group + '-' + idx + '-item'}`"
ref="kMultiselectItem"
v-for="item, idx in nonGroupedItems"
:key="`${item.key ? item.key : idx}-item`"
:item="item"
@arrow-down="() => shiftFocus(item.key, 'down')"
@arrow-up="() => shiftFocus(item.key, 'up')"
@keydown="onKeyPress"
@selected="handleItemSelect"
>
<template #content>
Expand All @@ -40,12 +18,53 @@
/>
</template>
</KMultiselectItem>

<div
v-for="group in groups"
:key="`${group}-group`"
class="multiselect-group"
>
<span class="multiselect-group-title">
{{ group }}
</span>
<KMultiselectItem
v-for="(item, idx) in getGroupItems(group)"
:key="`${item.key ? item.key : group + '-' + idx + '-item'}`"
:item="item"
@keydown="onKeyPress"
@selected="handleItemSelect"
>
<template #content>
<slot
:item="item"
name="content"
/>
</template>
</KMultiselectItem>
</div>

<KMultiselectItem
v-if="itemCreationEnabled"
key="multiselect-add-item"
class="multiselect-add-item"
data-testid="multiselect-add-item"
:item="{ label: `${filterString} (Add new value)`, value: 'add_item', disabled: !itemCreationValid }"
@keydown="onKeyPress"
@selected="$emit('add-item')"
>
<template #content>
<div class="select-item-description">
{{ filterString }}
<span class="select-item-new-indicator">(Add new value)</span>
</div>
</template>
</KMultiselectItem>
</div>
</template>

<script setup lang="ts">
import type { PropType } from 'vue'
import { computed, ref } from 'vue'
import { computed, useTemplateRef } from 'vue'
import KMultiselectItem from '@/components/KMultiselect/KMultiselectItem.vue'
import type { MultiselectItem } from '@/types'
Expand All @@ -56,11 +75,24 @@ const props = defineProps({
// Items must have a label & value
validator: (items: MultiselectItem[]) => !items.length || (items.every(i => i.label !== undefined && i.value !== undefined)),
},
itemCreationEnabled: {
type: Boolean,
default: false,
},
filterString: {
type: String,
default: '',
},
itemCreationValid: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['selected'])
const kMultiselectItem = ref<InstanceType<typeof KMultiselectItem>[] | null>(null)
const emit = defineEmits<{
(e: 'selected', item: MultiselectItem): void
(e: 'add-item'): void
}>()
const handleItemSelect = (item: MultiselectItem) => emit('selected', item)
Expand All @@ -69,41 +101,31 @@ const groups = computed((): string[] => [...new Set((props.items?.filter(item =>
const getGroupItems = (group: string) => props.items?.filter(item => item.group === group)
const setFocus = (index: number = 0) => {
if (kMultiselectItem.value) {
if (!props.items[index].disabled) {
kMultiselectItem.value[index]?.$el?.querySelector('button').focus()
} else {
setFocus(index + 1)
}
}
const itemsContainerRef = useTemplateRef('itemsContainer')
const setItemFocus = (): void => {
const firstSelectableItem = itemsContainerRef.value?.querySelector<HTMLButtonElement>('.multiselect-item button:not(:disabled)')
firstSelectableItem?.focus()
}
const shiftFocus = (key: MultiselectItem['key'], direction: 'down' | 'up') => {
const index = props.items.findIndex(item => item.key === key)
const onKeyPress = ({ target, key } : KeyboardEvent) => {
if (key === 'ArrowDown' || key === 'ArrowUp') {
// all selectable items
const selectableItems = itemsContainerRef.value?.querySelectorAll<HTMLButtonElement>('.multiselect-item button:not(:disabled)')
if (index === -1) {
return // Exit if the item is not found
}
if (selectableItems?.length) {
// find the current element index in the array
const currentElementIndex = [...selectableItems].indexOf(target as HTMLButtonElement)
// move to the next or previous element
const nextElementIndex = key === 'ArrowDown' ? currentElementIndex + 1 : currentElementIndex - 1
const nextElement = selectableItems[nextElementIndex]
// determine step for navigation
const step = direction === 'down' ? 1 : -1
const isValidIndex = direction === 'down'
? index + step < props.items.length
: index + step >= 0
if (isValidIndex) {
const nextIndex = index + step
if (props.items[nextIndex].disabled) {
// find the next valid index if the current one is disabled
shiftFocus(props.items[nextIndex].key!, direction)
} else {
// focus the button
kMultiselectItem.value?.[nextIndex]?.$el?.querySelector('button')?.focus()
nextElement?.focus()
}
}
}
defineExpose({ setFocus })
defineExpose({ setFocus: setItemFocus })
</script>

<style lang="scss" scoped>
Expand Down
Loading

0 comments on commit 7ff96d2

Please sign in to comment.