From aebe5e9bec8abb720be332f53eebba4cca10daa2 Mon Sep 17 00:00:00 2001 From: Andrea Leardini <andre8244@gmail.com> Date: Thu, 8 Aug 2024 11:44:08 +0200 Subject: [PATCH] feat: add NeDropdownFilter component --- src/components/NeDropdownFilter.vue | 253 ++++++++++++++++++++++++++++ src/main.ts | 2 + stories/NeDropdownFilter.stories.ts | 175 +++++++++++++++++++ 3 files changed, 430 insertions(+) create mode 100644 src/components/NeDropdownFilter.vue create mode 100644 stories/NeDropdownFilter.stories.ts diff --git a/src/components/NeDropdownFilter.vue b/src/components/NeDropdownFilter.vue new file mode 100644 index 0000000..fb3b34d --- /dev/null +++ b/src/components/NeDropdownFilter.vue @@ -0,0 +1,253 @@ +<!-- + Copyright (C) 2024 Nethesis S.r.l. + SPDX-License-Identifier: GPL-3.0-or-later +--> + +<script setup lang="ts"> +import { ref, watch, computed, onMounted } from 'vue' +import { faChevronDown } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import { library } from '@fortawesome/fontawesome-svg-core' +import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue' +import { isEqual } from 'lodash-es' +import { v4 as uuidv4 } from 'uuid' +import NeBadge from './NeBadge.vue' +import NeLink from './NeLink.vue' + +export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' +export type FilterKind = 'radio' | 'checkbox' + +export type FilterOption = { + id: string + label: string + description?: string + disabled?: boolean +} + +const sizeStyle: { [index: string]: string } = { + xs: 'rounded px-2 py-1 text-xs', + sm: 'rounded px-2 py-1 text-sm', + md: 'rounded-md px-2.5 py-1.5 text-sm', + lg: 'rounded-md px-3 py-2 text-sm', + xl: 'rounded-md px-3.5 py-2.5 text-sm' +} + +export interface Props { + label: string + options: FilterOption[] + kind: FilterKind + clearFilterLabel: string + openMenuAriaLabel: string + showClearFilter?: boolean + showSelectionCount?: boolean + alignToRight?: boolean + size?: ButtonSize + disabled?: boolean + id?: string +} + +const props = withDefaults(defineProps<Props>(), { + showClearFilter: true, + showSelectionCount: true, + alignToRight: false, + size: 'md', + disabled: false, + id: '' +}) + +library.add(faChevronDown) + +const model = defineModel<string[]>() +const radioModel = ref('') +const checkboxModel = ref<string[]>([]) +const top = ref(0) +const left = ref(0) +const right = ref(0) +const buttonRef = ref() + +const componentId = computed(() => (props.id ? props.id : uuidv4())) + +const isSelectionCountShown = computed(() => { + return props.showSelectionCount && props.kind === 'checkbox' && checkboxModel.value.length > 0 +}) + +watch( + () => props.alignToRight, + () => { + calculatePosition() + } +) + +watch( + () => radioModel.value, + () => { + model.value = [radioModel.value] + } +) + +watch( + () => checkboxModel.value, + () => { + model.value = checkboxModel.value + } +) + +watch( + () => model.value, + () => { + updateInternalModel() + } +) + +onMounted(() => { + updateInternalModel() +}) + +function updateInternalModel() { + if (props.kind === 'radio') { + // update only if the value is different to avoid "Maximum recursive updates exceeded" error + if (model.value && radioModel.value !== model.value[0]) { + radioModel.value = model.value[0] + } + } else if (props.kind === 'checkbox') { + // update only if the value is different to avoid "Maximum recursive updates exceeded" error + if (model.value && !isEqual(checkboxModel.value, model.value)) { + checkboxModel.value = model.value + } + } +} + +function calculatePosition() { + top.value = buttonRef.value?.$el.getBoundingClientRect().bottom + window.scrollY + left.value = buttonRef.value?.$el.getBoundingClientRect().left - window.scrollX + right.value = + document.documentElement.clientWidth - + buttonRef.value?.$el.getBoundingClientRect().right - + window.scrollX +} +</script> + +<template> + <Menu as="div" class="relative inline-block text-left text-sm"> + <MenuButton ref="buttonRef" @click="calculatePosition()"> + <span class="sr-only">{{ openMenuAriaLabel }}</span> + <slot name="button"> + <!-- default button --> + <button + class="font-medium text-gray-700 shadow-sm ring-1 ring-gray-300 transition-colors duration-200 hover:bg-gray-200/70 hover:text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-100 dark:ring-gray-500 dark:hover:bg-gray-600/30 dark:hover:text-gray-50 dark:focus:ring-primary-300 dark:focus:ring-offset-primary-950" + :class="sizeStyle[props.size]" + :disabled="disabled" + type="button" + > + <div class="flex items-center justify-center"> + <slot v-if="$slots.label" name="label"></slot> + <span v-else>{{ label }}</span> + <NeBadge + v-if="isSelectionCountShown" + :text="checkboxModel.length.toString()" + size="xs" + class="ml-2" + /> + <font-awesome-icon + :icon="['fas', 'chevron-down']" + class="ml-2 h-3 w-3" + aria-hidden="true" + /> + </div> + </button> + </slot> + </MenuButton> + <Teleport to="body"> + <transition + enter-active-class="transition ease-out duration-100" + enter-from-class="transform opacity-0 scale-95" + enter-to-class="transform opacity-100 scale-100" + leave-active-class="transition ease-in duration-75" + leave-from-class="transform opacity-100 scale-100" + leave-to-class="transform opacity-0 scale-95" + > + <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" + > + <MenuItem v-if="showClearFilter && kind == 'checkbox'" as="div" class="py-2"> + <NeLink @click.stop="checkboxModel = []"> + {{ clearFilterLabel }} + </NeLink> + </MenuItem> + <MenuItem v-for="option in options" :key="option.id" as="div" :disabled="option.disabled"> + <!-- divider --> + <hr + v-if="option.id.includes('divider')" + class="my-1 border-gray-200 dark:border-gray-700" + /> + <!-- filter option --> + <div v-if="kind === 'radio'" class="flex items-center py-2" @click.stop> + <!-- radio button --> + <input + :id="`${componentId}-${option.id}`" + v-model="radioModel" + type="radio" + :name="componentId" + :value="option.id" + :aria-describedby="`${componentId}-${option.id}-description`" + class="peer border-gray-300 text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-950 dark:text-primary-500 checked:dark:bg-primary-500 dark:focus:ring-primary-300 focus:dark:ring-primary-200 focus:dark:ring-offset-gray-900" + :disabled="option.disabled || disabled" + /> + <label + :for="`${componentId}-${option.id}`" + :disabled="option.disabled" + class="ms-2 text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 dark:text-gray-50" + > + <div>{{ option.label }}</div> + <div + v-if="option.description" + :id="`${componentId}-${option.id}-description`" + class="text-gray-500 dark:text-gray-400" + > + {{ option.description }} + </div> + </label> + </div> + <div v-else-if="kind === 'checkbox'" class="flex items-center py-2" @click.stop> + <!-- checkbox --> + <div class="flex h-6 items-center"> + <input + :id="`${componentId}-${option.id}`" + v-model="checkboxModel" + type="checkbox" + :value="option.id" + :aria-describedby="`${componentId}-${option.id}-description`" + :disabled="option.disabled || disabled" + class="h-5 w-5 rounded border-gray-300 text-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:text-primary-500 dark:focus:ring-primary-300 dark:focus:ring-offset-primary-950 sm:h-4 sm:w-4" + /> + </div> + <div class="ml-3 text-sm leading-6"> + <!-- show label prop or default slot --> + <label + :class="[ + 'font-medium text-gray-700 dark:text-gray-50', + { 'cursor-not-allowed opacity-50': disabled } + ]" + :for="`${componentId}-${option.id}`" + > + <div>{{ option.label }}</div> + <div + v-if="option.description" + :id="`${componentId}-${option.id}-description`" + class="text-gray-500 dark:text-gray-400" + > + {{ option.description }} + </div> + </label> + </div> + </div> + </MenuItem> + </MenuItems> + </transition> + </Teleport> + </Menu> +</template> diff --git a/src/main.ts b/src/main.ts index c7f7a62..b8e4bf1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,7 @@ export { default as NeToastNotification } from '@/components/NeToastNotification export { default as NeModal } from '@/components/NeModal.vue' export { default as NeHeading } from '@/components/NeHeading.vue' export { default as NeListbox } from '@/components/NeListbox.vue' +export { default as NeDropdownFilter } from '@/components/NeDropdownFilter.vue' // types export export type { NeComboboxOption } from '@/components/NeCombobox.vue' @@ -43,6 +44,7 @@ export type { Tab } from '@/components/NeTabs.vue' export type { NeNotification } from '@/components/NeToastNotification.vue' export type { NeListboxOption } from '@/components/NeListbox.vue' export type { NeDropdownItem } from '@/components/NeDropdown.vue' +export type { FilterOption, ButtonSize, FilterKind } from '@/components/NeDropdownFilter.vue' // library functions export export { diff --git a/stories/NeDropdownFilter.stories.ts b/stories/NeDropdownFilter.stories.ts new file mode 100644 index 0000000..a63054d --- /dev/null +++ b/stories/NeDropdownFilter.stories.ts @@ -0,0 +1,175 @@ +// Copyright (C) 2024 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { Meta, StoryObj } from '@storybook/vue3' +import { NeDropdownFilter, NeButton } from '../src/main' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' + +const meta = { + title: 'NeDropdownFilter', + component: NeDropdownFilter, + tags: ['autodocs'], + argTypes: { + kind: { control: 'inline-radio', options: ['radio', 'checkbox'] }, + size: { control: 'inline-radio', options: ['xs', 'sm', 'md', 'lg', 'xl'] } + }, + args: { + label: 'Filter label', + options: [ + { + id: 'option1', + label: 'Option 1' + }, + { + id: 'option2', + label: 'Option 2' + }, + { + id: 'option3', + label: 'Option 3', + disabled: true + }, + { + id: 'option4', + label: 'Option 4' + } + ], + kind: 'checkbox', + clearFilterLabel: 'Clear filter', + openMenuAriaLabel: 'Open filter', + showClearFilter: true, + showSelectionCount: true, + alignToRight: false, + size: 'md', + disabled: false, + id: '' + } +} satisfies Meta<typeof NeDropdownFilter> + +export default meta +type Story = StoryObj<typeof meta> + +const template = '<NeDropdownFilter v-bind="args" />' + +export const CheckboxOptions: Story = { + render: (args) => ({ + components: { NeDropdownFilter }, + setup() { + return { args } + }, + template: template + }), + args: {} +} + +export const RadioOptions: Story = { + render: (args) => ({ + components: { NeDropdownFilter }, + setup() { + return { args } + }, + template: template + }), + args: { + kind: 'radio' + } +} + +export const OptionsWithDescription: Story = { + render: (args) => ({ + components: { NeDropdownFilter }, + setup() { + return { args } + }, + template: template + }), + args: { + options: [ + { + id: 'option1', + label: 'Option 1', + description: 'Description for option 1', + disabled: false + }, + { + id: 'option2', + label: 'Option 2', + description: 'Description for option 2', + disabled: false + }, + { + id: 'option3', + label: 'Option 3', + description: 'Description for option 3', + disabled: true + }, + { + id: 'option4', + label: 'Option 4', + description: 'Description for option 4', + disabled: false + } + ] + } +} + +export const NoClearFilter: Story = { + render: (args) => ({ + components: { NeDropdownFilter }, + setup() { + return { args } + }, + template: template + }), + args: { + kind: 'checkbox', + showClearFilter: false + } +} + +const alignToRightTemplate = `<div class="flex justify-end w-72"> + <NeDropdownFilter v-bind="args" /> +</div>` + +export const AlignToRight: Story = { + render: (args) => ({ + components: { NeDropdownFilter }, + setup() { + return { args } + }, + template: alignToRightTemplate + }), + args: { alignToRight: true } +} + +export const Disabled: Story = { + render: (args) => ({ + components: { NeDropdownFilter }, + setup() { + return { args } + }, + template: template + }), + args: { + disabled: true + } +} + +const withSlotTemplate = `<NeDropdownFilter v-bind="args"> + <template #button> + <span class="text-gray-700 dark:text-gray-100"> + Button slot + </span> + </template> +</NeDropdownFilter>` + +export const ButtonSlot: Story = { + render: (args) => ({ + components: { NeDropdownFilter, NeButton, FontAwesomeIcon }, + setup() { + return { args } + }, + template: withSlotTemplate + }), + args: {} +}