feat(v4): Add filter input components (#10493)

This PR adds three components along with stories

1. MultiSelect - This is used for filter values, allowing multiple values and folding of values where there are too many items
2. SingleSelect - This is used for filter values, allows selecting and toggling a single item
3. FilterSelect - This is used for operators and others, it allows icons and labels as well as toggling them using props. The v-model for this binds just the final value unlike the previous two components with bind the entire object.

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Shivam Mishra
2024-11-26 01:22:28 +05:30
committed by GitHub
parent e9ba4200b2
commit c23cd094f9
11 changed files with 489 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { provideDropdownContext } from './provider.js';
const emit = defineEmits(['close']);
@@ -20,9 +21,9 @@ provideDropdownContext({
</script>
<template>
<div class="relative z-20 space-y-2">
<div class="relative space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" v-on-clickaway="closeMenu" class="absolute">
<div v-if="isOpen" v-on-click-outside="closeMenu" class="absolute">
<slot />
</div>
</div>

View File

@@ -0,0 +1,66 @@
<script setup>
import { ref } from 'vue';
import FilterSelect from './FilterSelect.vue';
const options = [
{ value: 'EQUAL_TO', label: 'Equal To', icon: 'i-ph-equals-bold' },
{
value: 'NOT_EQUAL_TO',
label: 'Not Equal To',
icon: 'i-ph-not-equals-bold',
},
{ value: 'IS_PRESENT', label: 'Is Present', icon: 'i-ph-member-of-bold' },
{
value: 'IS_NOT_PRESENT',
label: 'Is Not Present',
icon: 'i-ph-not-member-of-bold',
},
{ value: 'CONTAINS', label: 'Contains', icon: 'i-ph-superset-of-bold' },
{
value: 'DOES_NOT_CONTAIN',
label: 'Does Not Contain',
icon: 'i-ph-not-superset-of-bold',
},
{
value: 'IS_GREATER_THAN',
label: 'Is Greater Than',
icon: 'i-ph-greater-than-bold',
},
{ value: 'IS_LESS_THAN', label: 'Is Less Than', icon: 'i-ph-less-than-bold' },
{
value: 'DAYS_BEFORE',
label: 'Days Before',
icon: 'i-ph-calendar-minus-bold',
},
{
value: 'STARTS_WITH',
label: 'Starts With',
icon: 'i-ph-caret-line-right-bold',
},
];
const selected = ref(options[0].value);
</script>
<template>
<Story
title="Components/Filters/Filter Select"
:layout="{ type: 'grid', width: '250px' }"
>
<Variant title="With Icon & Label">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" :options="options" />
</div>
</Variant>
<Variant title="Without Icon">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" hide-icon :options="options" />
</div>
</Variant>
<Variant title="Without Label">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" hide-label :options="options" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed } from 'vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
import Button from 'next/button/Button.vue';
// [{label, icon, value}]
const props = defineProps({
options: {
type: Array,
required: true,
},
hideLabel: {
type: Boolean,
default: false,
},
hideIcon: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'faded',
},
});
const selected = defineModel({
type: [String, Number],
required: true,
});
const selectedOption = computed(() => {
return props.options.find(o => o.value === selected.value) || {};
});
const iconToRender = computed(() => {
if (props.hideIcon) return null;
return selectedOption.value.icon || 'i-lucide-chevron-down';
});
const updateSelected = newValue => {
selected.value = newValue;
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<slot name="trigger" :toggle="toggle">
<Button
sm
slate
:variant
:icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true"
:label="hideLabel ? null : selectedOption.label"
@click="toggle"
/>
</slot>
</template>
<DropdownBody class="top-0 min-w-48 z-[999]">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { ref } from 'vue';
import MultiSelect from './MultiSelect.vue';
const options = [
{ name: 'Open', id: 'open' },
{ name: 'Closed', id: 'closed' },
{ name: 'Pending', id: 'pending' },
{ name: 'Resolved', id: 'resolved' },
{ name: 'Spam', id: 'spam' },
{ name: 'All', id: 'all' },
];
const selected = ref([]);
</script>
<template>
<Story
title="Components/Filters/Multiselect Input"
:layout="{ type: 'grid', width: '600px' }"
>
<div class="min-h-[400px]">
<MultiSelect v-model="selected" :options="options" />
</div>
</Story>
</template>

View File

@@ -0,0 +1,146 @@
<script setup>
import { defineModel, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips } = defineProps({
options: {
type: Array,
required: true,
},
maxChips: {
type: Number,
default: 3,
},
});
const { t } = useI18n();
const selected = defineModel({
type: [Array, String],
required: true,
});
const hasItems = computed(() => {
if (!selected.value) return false;
if (!Array.isArray(selected.value)) return false;
if (selected.value.length === 0) return false;
return true;
});
const selectedIds = computed(() => {
if (!hasItems.value) return [];
return selected.value.map(value => value.id);
});
const selectedItems = computed(() => {
// Options has additional properties, so we need to use them directly
if (!hasItems.value) return [];
return options.filter(option => selectedIds.value.includes(option.id));
});
const selectedVisibleItems = computed(() => {
if (!hasItems.value) return [];
// avoid showing "+1 more" coz it takes up space anway, might as well show it
if (selectedItems.value.length === maxChips + 1) return selectedItems.value;
// if we have more than maxChips then show only maxChips
return selectedItems.value.slice(0, maxChips);
});
const remainingItems = computed(() => {
if (!hasItems.value) return [];
if (selectedItems.value.length === maxChips + 1) return [];
return selectedItems.value.slice(maxChips);
});
const remainingTooltip = computed(() => {
if (!hasItems.value) return '';
return remainingItems.value.map(item => item.name).join(', ');
});
const toggleOption = option => {
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
const optionToToggle = {
id: option.id,
name: option.name,
};
const idToToggle = optionToToggle.id;
if (!hasItems.value) {
selected.value = [optionToToggle];
return;
}
if (selectedIds.value.includes(idToToggle)) {
selected.value = selected.value.filter(value => value.id !== idToToggle);
} else {
selected.value = [...selected.value, optionToToggle];
}
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<button
v-if="hasItems"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
>
<div
v-for="item in selectedVisibleItems"
:key="item.name"
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<span class="truncate">{{ item.name }}</span>
</div>
<div
v-if="remainingItems.length > 0"
v-tooltip.top="remainingTooltip"
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
>
<span class="truncate">{{
t('COMBOBOX.MORE', { count: remainingItems.length })
}}</span>
</div>
<div class="flex items-center border-none px-3 gap-2">
<Icon icon="i-lucide-plus" />
</div>
</button>
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-[999]">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
:key="option.id"
:icon="option.icon"
preserve-open
@click="toggleOption(option)"
>
<template #label>
{{ option.name }}
<Icon
v-if="selectedIds.includes(option.id)"
icon="i-lucide-check"
class="bg-n-blue-text pointer-events-none"
/>
</template>
</DropdownItem>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { ref } from 'vue';
import SingleSelect from './SingleSelect.vue';
const options = [
{ name: 'Open', id: 'open' },
{ name: 'Closed', id: 'closed' },
{ name: 'Pending', id: 'pending' },
{ name: 'Resolved', id: 'resolved' },
{ name: 'Spam', id: 'spam' },
{ name: 'All', id: 'all' },
];
const selected = ref(options[0]);
</script>
<template>
<Story
title="Components/Filters/Single Select Input"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="With Search">
<div class="min-h-[400px]">
<SingleSelect v-model="selected" :options="options" />
</div>
</Variant>
<Variant title="Without Search">
<div class="min-h-[400px]">
<SingleSelect v-model="selected" disable-search :options="options" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,124 @@
<script setup>
import { defineModel, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({
options: {
type: Array,
required: true,
},
disableSearch: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const selected = defineModel({
type: Object,
required: true,
});
const searchTerm = ref('');
const searchResults = computed(() => {
if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']);
});
const selectedItem = computed(() => {
if (!options) return null;
if (!selected.value) return null;
// there are cases where the selected value is an array
const optionToSearch = Array.isArray(selected.value)
? selected.value[0]
: selected.value;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
});
const toggleSelected = option => {
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
const optionToToggle = {
id: option.id,
name: option.name,
};
if (selected.value && selected.value.id === optionToToggle.id) {
selected.value = null;
} else {
selected.value = optionToToggle;
}
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<Button
v-if="selectedItem"
sm
slate
faded
:icon="selectedItem.icon"
:label="selectedItem.name"
@click="toggle"
/>
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-56 z-[999]">
<div v-if="!disableSearch" class="relative">
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchTerm"
autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="max-h-80 overflow-scroll">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
:key="option.id"
:icon="option.icon"
@click="toggleSelected(option)"
>
<template #label>
{{ option.name }}
<Icon
v-if="selectedItem && selectedItem.id === option.id"
icon="i-lucide-check"
class="bg-n-blue-text pointer-events-none"
/>
</template>
</DropdownItem>
</template>
<template v-else-if="searchTerm">
<DropdownItem disabled>
{{ t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm: searchTerm }) }}
</DropdownItem>
</template>
<template v-else>
<DropdownItem disabled>
{{ t('COMBOBOX.EMPTY_STATE') }}
</DropdownItem>
</template>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>

View File

@@ -5,8 +5,10 @@
},
"COMBOBOX": {
"PLACEHOLDER": "Select an option...",
"EMPTY_SEARCH_RESULTS": "No items found for the search term `{searchTerm}`",
"EMPTY_STATE": "No results found.",
"SEARCH_PLACEHOLDER": "Search..."
"SEARCH_PLACEHOLDER": "Search...",
"MORE": "+{count} more"
},
"DROPDOWN_MENU": {
"SEARCH_PLACEHOLDER": "Search...",

View File

@@ -105,6 +105,7 @@
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.3",
"@iconify-json/lucide": "^1.2.11",
"@iconify-json/ph": "^1.2.1",
"@iconify-json/ri": "^1.2.3",
"@size-limit/file": "^8.2.4",
"@vitest/coverage-v8": "2.0.1",

10
pnpm-lock.yaml generated
View File

@@ -233,6 +233,9 @@ importers:
'@iconify-json/lucide':
specifier: ^1.2.11
version: 1.2.11
'@iconify-json/ph':
specifier: ^1.2.1
version: 1.2.1
'@iconify-json/ri':
specifier: ^1.2.3
version: 1.2.3
@@ -878,6 +881,9 @@ packages:
'@iconify-json/lucide@1.2.11':
resolution: {integrity: sha512-dqpbV7+g1qqxtZOHCZKwdKhtYYqEUjFhYiOg/+PcADbjtapoL+bwa1Brn12gAHq5r2K7Mf29xRHOTmZ3UHHOrw==}
'@iconify-json/ph@1.2.1':
resolution: {integrity: sha512-x0DNfwWrS18dbsBYOq3XGiZnGz4CgRyC+YSl/TZvMQiKhIUl1woWqUbMYqqfMNUBzjyk7ulvaRovpRsIlqIf8g==}
'@iconify-json/ri@1.2.3':
resolution: {integrity: sha512-UVKofd5xkSevGd5K01pvO4NWsu+2C9spu+GxnMZUYymUiaWmpCAxtd22MFSpm6MGf0MP4GCwhDCo1Q8L8oZ9wg==}
@@ -5708,6 +5714,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ph@1.2.1':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ri@1.2.3':
dependencies:
'@iconify/types': 2.0.0

View File

@@ -126,7 +126,7 @@ const tailwindConfig = {
},
},
},
...getIconCollections(['lucide', 'logos', 'ri']),
...getIconCollections(['lucide', 'logos', 'ri', 'ph']),
},
}),
],