Skip to content

Commit bf3f4b2

Browse files
authored
feat: add options filtering to NeDropdownFilter (#78)
1 parent 30aa720 commit bf3f4b2

File tree

2 files changed

+139
-5
lines changed

2 files changed

+139
-5
lines changed

src/components/NeDropdownFilter.vue

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { v4 as uuidv4 } from 'uuid'
1313
import NeBadge from './NeBadge.vue'
1414
import NeLink from './NeLink.vue'
1515
import type { ButtonSize } from './NeButton.vue'
16+
import NeTextInput from './NeTextInput.vue'
17+
import { focusElement } from '@/main'
1618
1719
export type FilterKind = 'radio' | 'checkbox'
1820
@@ -39,6 +41,12 @@ export interface Props {
3941
openMenuAriaLabel: string
4042
showClearFilter?: boolean
4143
showSelectionCount?: boolean
44+
noOptionsLabel: string
45+
showOptionsFilter?: boolean
46+
optionsFilterPlaceholder?: string
47+
// limit the number of options displayed for performance
48+
maxOptionsShown?: number
49+
moreOptionsHiddenLabel: string
4250
alignToRight?: boolean
4351
size?: ButtonSize
4452
disabled?: boolean
@@ -48,6 +56,9 @@ export interface Props {
4856
const props = withDefaults(defineProps<Props>(), {
4957
showClearFilter: true,
5058
showSelectionCount: true,
59+
showOptionsFilter: false,
60+
optionsFilterPlaceholder: '',
61+
maxOptionsShown: 25,
5162
alignToRight: false,
5263
size: 'md',
5364
disabled: false,
@@ -61,13 +72,43 @@ const top = ref(0)
6172
const left = ref(0)
6273
const right = ref(0)
6374
const buttonRef = ref()
75+
const optionsFilter = ref('')
76+
const optionsFilterRef = ref()
6477
6578
const componentId = computed(() => (props.id ? props.id : uuidv4()))
6679
6780
const isSelectionCountShown = computed(() => {
6881
return props.showSelectionCount && props.kind == 'checkbox' && checkboxModel.value.length > 0
6982
})
7083
84+
const optionsToDisplay = computed(() => {
85+
return filteredOptions.value.slice(0, props.maxOptionsShown)
86+
})
87+
88+
const moreOptionsHidden = computed(() => {
89+
return filteredOptions.value.length > props.maxOptionsShown
90+
})
91+
92+
const isShowingOptionsFilter = computed(() => {
93+
return props.showOptionsFilter || props.options.length > props.maxOptionsShown
94+
})
95+
96+
const filteredOptions = computed(() => {
97+
if (!isShowingOptionsFilter.value) {
98+
// return all options
99+
return props.options
100+
}
101+
102+
// show only options that match the options filter
103+
104+
const regex = /[^a-zA-Z0-9-]/g
105+
const queryText = optionsFilter.value.replace(regex, '')
106+
107+
return props.options.filter((option) => {
108+
return new RegExp(queryText, 'i').test(option.label?.replace(regex, ''))
109+
})
110+
})
111+
71112
watch(
72113
() => props.alignToRight,
73114
() => {
@@ -119,6 +160,12 @@ function calculatePosition() {
119160
buttonRef.value?.$el.getBoundingClientRect().right -
120161
window.scrollX
121162
}
163+
164+
function maybeFocusOptionsFilter() {
165+
if (isShowingOptionsFilter.value) {
166+
focusElement(optionsFilterRef)
167+
}
168+
}
122169
</script>
123170

124171
<template>
@@ -155,20 +202,39 @@ function calculatePosition() {
155202
leave-active-class="transition ease-in duration-75"
156203
leave-from-class="transform opacity-100 scale-100"
157204
leave-to-class="transform opacity-0 scale-95"
205+
@after-enter="maybeFocusOptionsFilter"
158206
>
159207
<MenuItems
160208
:style="[
161209
{ top: top + 'px' },
162210
alignToRight ? { right: right + 'px' } : { left: left + 'px' }
163211
]"
164-
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"
212+
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"
165213
>
166-
<MenuItem v-if="showClearFilter && kind == 'checkbox'" as="div" class="py-2">
214+
<div v-if="isShowingOptionsFilter" class="py-2">
215+
<label class="sr-only" :for="`${componentId}-options-filter`">
216+
{{ optionsFilterPlaceholder }}
217+
</label>
218+
<NeTextInput
219+
:id="`${componentId}-options-filter`"
220+
ref="optionsFilterRef"
221+
v-model="optionsFilter"
222+
:placeholder="optionsFilterPlaceholder"
223+
is-search
224+
@keydown.stop
225+
/>
226+
</div>
227+
<div v-if="showClearFilter && kind == 'checkbox'" class="py-2">
167228
<NeLink @click.stop="checkboxModel = []">
168229
{{ clearFilterLabel }}
169230
</NeLink>
170-
</MenuItem>
171-
<MenuItem v-for="option in options" :key="option.id" as="div" :disabled="option.disabled">
231+
</div>
232+
<MenuItem
233+
v-for="option in optionsToDisplay"
234+
:key="option.id"
235+
as="div"
236+
:disabled="option.disabled"
237+
>
172238
<!-- divider -->
173239
<hr
174240
v-if="option.id.includes('divider')"
@@ -235,6 +301,16 @@ function calculatePosition() {
235301
</div>
236302
</div>
237303
</MenuItem>
304+
<!-- showing a limited number of options for performance, but more options are available -->
305+
<div v-if="moreOptionsHidden" class="cursor-default py-2 opacity-50">
306+
{{ moreOptionsHiddenLabel }}
307+
</div>
308+
<!-- no option matching filter -->
309+
<div v-if="!filteredOptions.length">
310+
<div class="py-2 opacity-50">
311+
{{ noOptionsLabel }}
312+
</div>
313+
</div>
238314
</MenuItems>
239315
</transition>
240316
</Teleport>

stories/NeDropdownFilter.stories.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,26 @@ const meta = {
3232
{
3333
id: 'option4',
3434
label: 'Option 4'
35+
},
36+
{
37+
id: 'option5',
38+
label: 'Option 5'
39+
},
40+
{
41+
id: 'option6',
42+
label: 'Option 6'
3543
}
3644
],
3745
kind: 'checkbox',
38-
clearFilterLabel: 'Clear filter',
46+
clearFilterLabel: 'Clear selection',
3947
openMenuAriaLabel: 'Open filter',
4048
showClearFilter: true,
4149
showSelectionCount: true,
50+
noOptionsLabel: 'No options',
51+
showOptionsFilter: false,
52+
optionsFilterPlaceholder: 'Filter options',
53+
maxOptionsShown: 25,
54+
moreOptionsHiddenLabel: 'Continue typing to show more options',
4255
alignToRight: false,
4356
size: 'md',
4457
disabled: false,
@@ -173,3 +186,48 @@ export const ButtonSlot: Story = {
173186
}),
174187
args: {}
175188
}
189+
190+
export const NoOptions: Story = {
191+
render: (args) => ({
192+
components: { NeDropdownFilter },
193+
setup() {
194+
return { args }
195+
},
196+
template: template
197+
}),
198+
args: {
199+
options: []
200+
}
201+
}
202+
203+
export const ShowOptionsFilter: Story = {
204+
render: (args) => ({
205+
components: { NeDropdownFilter },
206+
setup() {
207+
return { args }
208+
},
209+
template: template
210+
}),
211+
args: {
212+
showOptionsFilter: true
213+
}
214+
}
215+
216+
const manyOptions: any = []
217+
218+
for (let i = 0; i < 150; i++) {
219+
manyOptions.push({ id: i.toString(), label: `Option ${i}` })
220+
}
221+
222+
export const ManyOptions: Story = {
223+
render: (args) => ({
224+
components: { NeDropdownFilter },
225+
setup() {
226+
return { args }
227+
},
228+
template: template
229+
}),
230+
args: {
231+
options: manyOptions
232+
}
233+
}

0 commit comments

Comments
 (0)