Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add options filtering to NeDropdownFilter #78

Merged
merged 1 commit into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}