Skip to content

Commit

Permalink
feat: add options filtering to NeDropdownFilter (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
andre8244 authored Nov 6, 2024
1 parent 30aa720 commit bf3f4b2
Show file tree
Hide file tree
Showing 2 changed files with 139 additions and 5 deletions.
84 changes: 80 additions & 4 deletions src/components/NeDropdownFilter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { v4 as uuidv4 } from 'uuid'
import NeBadge from './NeBadge.vue'
import NeLink from './NeLink.vue'
import type { ButtonSize } from './NeButton.vue'
import NeTextInput from './NeTextInput.vue'
import { focusElement } from '@/main'

export type FilterKind = 'radio' | 'checkbox'

Expand All @@ -39,6 +41,12 @@ export interface Props {
openMenuAriaLabel: string
showClearFilter?: boolean
showSelectionCount?: boolean
noOptionsLabel: string
showOptionsFilter?: boolean
optionsFilterPlaceholder?: string
// limit the number of options displayed for performance
maxOptionsShown?: number
moreOptionsHiddenLabel: string
alignToRight?: boolean
size?: ButtonSize
disabled?: boolean
Expand All @@ -48,6 +56,9 @@ export interface Props {
const props = withDefaults(defineProps<Props>(), {
showClearFilter: true,
showSelectionCount: true,
showOptionsFilter: false,
optionsFilterPlaceholder: '',
maxOptionsShown: 25,
alignToRight: false,
size: 'md',
disabled: false,
Expand All @@ -61,13 +72,43 @@ const top = ref(0)
const left = ref(0)
const right = ref(0)
const buttonRef = ref()
const optionsFilter = ref('')
const optionsFilterRef = ref()

const componentId = computed(() => (props.id ? props.id : uuidv4()))

const isSelectionCountShown = computed(() => {
return props.showSelectionCount && props.kind == 'checkbox' && checkboxModel.value.length > 0
})

const optionsToDisplay = computed(() => {
return filteredOptions.value.slice(0, props.maxOptionsShown)
})

const moreOptionsHidden = computed(() => {
return filteredOptions.value.length > props.maxOptionsShown
})

const isShowingOptionsFilter = computed(() => {
return props.showOptionsFilter || props.options.length > props.maxOptionsShown
})

const filteredOptions = computed(() => {
if (!isShowingOptionsFilter.value) {
// return all options
return props.options
}

// show only options that match the options filter

const regex = /[^a-zA-Z0-9-]/g
const queryText = optionsFilter.value.replace(regex, '')

return props.options.filter((option) => {
return new RegExp(queryText, 'i').test(option.label?.replace(regex, ''))
})
})

watch(
() => props.alignToRight,
() => {
Expand Down Expand Up @@ -119,6 +160,12 @@ function calculatePosition() {
buttonRef.value?.$el.getBoundingClientRect().right -
window.scrollX
}

function maybeFocusOptionsFilter() {
if (isShowingOptionsFilter.value) {
focusElement(optionsFilterRef)
}
}
</script>

<template>
Expand Down Expand Up @@ -155,20 +202,39 @@ function calculatePosition() {
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
@after-enter="maybeFocusOptionsFilter"
>
<MenuItems
:style="[
{ top: top + 'px' },
alignToRight ? { right: right + 'px' } : { left: left + 'px' }
]"
class="absolute z-50 mt-2.5 max-h-[16.5rem] min-w-[10rem] overflow-y-auto rounded-md bg-white px-4 py-2 text-sm shadow-lg ring-1 ring-gray-900/5 focus:outline-none dark:bg-gray-950 dark:ring-gray-500/50"
class="absolute z-50 mt-2.5 max-h-[17.2rem] min-w-[10rem] overflow-y-auto rounded-md bg-white px-4 py-2 text-sm shadow-lg ring-1 ring-gray-900/5 focus:outline-none dark:bg-gray-950 dark:ring-gray-500/50"
>
<MenuItem v-if="showClearFilter && kind == 'checkbox'" as="div" class="py-2">
<div v-if="isShowingOptionsFilter" class="py-2">
<label class="sr-only" :for="`${componentId}-options-filter`">
{{ optionsFilterPlaceholder }}
</label>
<NeTextInput
:id="`${componentId}-options-filter`"
ref="optionsFilterRef"
v-model="optionsFilter"
:placeholder="optionsFilterPlaceholder"
is-search
@keydown.stop
/>
</div>
<div v-if="showClearFilter && kind == 'checkbox'" class="py-2">
<NeLink @click.stop="checkboxModel = []">
{{ clearFilterLabel }}
</NeLink>
</MenuItem>
<MenuItem v-for="option in options" :key="option.id" as="div" :disabled="option.disabled">
</div>
<MenuItem
v-for="option in optionsToDisplay"
:key="option.id"
as="div"
:disabled="option.disabled"
>
<!-- divider -->
<hr
v-if="option.id.includes('divider')"
Expand Down Expand Up @@ -235,6 +301,16 @@ function calculatePosition() {
</div>
</div>
</MenuItem>
<!-- showing a limited number of options for performance, but more options are available -->
<div v-if="moreOptionsHidden" class="cursor-default py-2 opacity-50">
{{ moreOptionsHiddenLabel }}
</div>
<!-- no option matching filter -->
<div v-if="!filteredOptions.length">
<div class="py-2 opacity-50">
{{ noOptionsLabel }}
</div>
</div>
</MenuItems>
</transition>
</Teleport>
Expand Down
60 changes: 59 additions & 1 deletion stories/NeDropdownFilter.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,26 @@ const meta = {
{
id: 'option4',
label: 'Option 4'
},
{
id: 'option5',
label: 'Option 5'
},
{
id: 'option6',
label: 'Option 6'
}
],
kind: 'checkbox',
clearFilterLabel: 'Clear filter',
clearFilterLabel: 'Clear selection',
openMenuAriaLabel: 'Open filter',
showClearFilter: true,
showSelectionCount: true,
noOptionsLabel: 'No options',
showOptionsFilter: false,
optionsFilterPlaceholder: 'Filter options',
maxOptionsShown: 25,
moreOptionsHiddenLabel: 'Continue typing to show more options',
alignToRight: false,
size: 'md',
disabled: false,
Expand Down Expand Up @@ -173,3 +186,48 @@ export const ButtonSlot: Story = {
}),
args: {}
}

export const NoOptions: Story = {
render: (args) => ({
components: { NeDropdownFilter },
setup() {
return { args }
},
template: template
}),
args: {
options: []
}
}

export const ShowOptionsFilter: Story = {
render: (args) => ({
components: { NeDropdownFilter },
setup() {
return { args }
},
template: template
}),
args: {
showOptionsFilter: true
}
}

const manyOptions: any = []

for (let i = 0; i < 150; i++) {
manyOptions.push({ id: i.toString(), label: `Option ${i}` })
}

export const ManyOptions: Story = {
render: (args) => ({
components: { NeDropdownFilter },
setup() {
return { args }
},
template: template
}),
args: {
options: manyOptions
}
}

0 comments on commit bf3f4b2

Please sign in to comment.