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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -126,7 +126,7 @@ const tailwindConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...getIconCollections(['lucide', 'logos', 'ri']),
|
||||
...getIconCollections(['lucide', 'logos', 'ri', 'ph']),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user