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: {}
+}