Skip to content

Commit

Permalink
feat: Apps-3035 create filters drop down component (#664)
Browse files Browse the repository at this point in the history
* feat: filtersdropdown scaffolding

* feat: filters component and stories

* chore: comment in progress work

* chore: add types

* feat: styles

* chore: cleanup, add async data test

* chore: lint

* feat: add emits and story

* feat: add emits and focus styles

---------

Co-authored-by: Jess Divers <[email protected]>
  • Loading branch information
farosFreed and Jess Divers authored Dec 13, 2024
1 parent 90fdeab commit 8dbad4a
Show file tree
Hide file tree
Showing 8 changed files with 415 additions and 7 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"sass": "^1.79.5",
"storybook": "^7.4.1",
"typescript": "^5.6.3",
"ucla-library-design-tokens": "^5.27.1",
"ucla-library-design-tokens": "^5.28.0",
"video.js": "^8.5.2",
"vite": "^5.4.8",
"vite-svg-loader": "^5.1.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

114 changes: 114 additions & 0 deletions src/lib-components/FiltersDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import SvgGlyphX from 'ucla-library-design-tokens/assets/svgs/icon-ftva-xtag.svg'
import SvgFilterIcon from 'ucla-library-design-tokens/assets/svgs/icon-ftva-filter.svg'
import BlockTag from './BlockTag.vue'
import ButtonLink from './ButtonLink.vue'
import MobileDrawer from './MobileDrawer.vue'
import { useTheme } from '@/composables/useTheme'
// PROPS
interface FilterGroupsTypes {
name: string
searchField: string
options: string[]
}
const { filterGroups } = defineProps({
filterGroups: {
type: Array as PropType<FilterGroupsTypes[]>,
default: () => [],
}
})
const emit = defineEmits(['update-display'])
// V-MODEL DATA
interface SelectedFiltersTypes {
[key: string]: string[]
}
const selectedFilters = defineModel('selectedFilters', { type: Object as PropType<SelectedFiltersTypes>, required: true, default: {} })
// FUNCTIONS
// calc # for UI '# selected' display
const numOfSelectedFilters = computed(() => {
let count = 0
// for each key in selectedFilters
for (const key in selectedFilters.value as any) {
// add the length of the array of selectedFilters[key]
count += selectedFilters.value[key].length
}
return count
})
// check if option is selected so we can display 'x' SVG
function isSelected(searchField: string, option: string) {
// check if selectedFilter object has any keys, fail gracefully if it doesn't
if (!Object.keys(selectedFilters.value).length)
return null
return selectedFilters.value[searchField].includes(option)
}
// Clear Button Click / clear all selected filters
function clearFilters() {
for (const group of filterGroups)
selectedFilters.value[group.searchField] = []
}
// Done Button Click / emit selected filters to parent
function onDoneClick() {
emit('update-display', selectedFilters.value)
}
// THEME
const theme = useTheme()
const parsedClasses = computed(() => {
return ['filters-dropdown', theme?.value || '']
})
</script>

<template>
<div :class="parsedClasses">
<MobileDrawer>
<template #buttonLabel>
<div class="filter-summary">
Filters ({{ numOfSelectedFilters }} selected )
</div>
<span class="icon-svg">
<SvgFilterIcon aria-hidden="true" />
</span>
</template>
<template #dropdownItems="{ removeOverlay }">
<div class="dropdown-filter">
<div v-for="group in filterGroups" :key="group.name" class="filter-group">
<h3>{{ group.name }}</h3>
<div class="pills">
<!-- <label> must wrap <input> for accessbility fuctionality -->
<label v-for="option in group.options" :key="option" class="pill-label">
<!-- Hidden checkbox for managing selection & screen-reader user interaction -->
<input
:id="option" v-model="selectedFilters[group.searchField]" type="checkbox" class="pill-checkbox" :name="option"
:value="option"
>
<!-- BlockTag component for display -->
<BlockTag :label="option" :is-secondary="true">
<!-- 'x' SVG only shows when selected -->
<template v-if="isSelected(group.searchField, option)">
<SvgGlyphX class="close-icon" />
</template>
</BlockTag>
</label>
</div>
</div>
<div class="action-row">
<ButtonLink class="action-row-button select-button" label="Done" icon-name="none" @click="onDoneClick(); removeOverlay();" />
<ButtonLink
class="action-row-button clear-button" label="Clear" icon-name="icon-close"
@click="clearFilters"
/>
</div>
</div>
</template>
</MobileDrawer>
</div>
</template>

<style lang="scss" scoped>
@import "@/styles/default/_filters-dropdown.scss";
@import "@/styles/ftva/_filters-dropdown.scss";
</style>
2 changes: 1 addition & 1 deletion src/lib-components/MobileDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ onMounted(() => {

<template>
<div :class="parsedClasses">
<div v-on-click-outside="closeDropdownOnClickOutside">
<div v-on-click-outside="closeDropdownOnClickOutside" class="dropdown-wrapper">
<div class="dropdown-overlay" :class="isDropdownExpandedClass" />
<button
class="mobile-button"
Expand Down
10 changes: 10 additions & 0 deletions src/stories/FiltersDropdown.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
describe('FiltersDropdown', () => {
it('Default', () => {
cy.visit(
'/iframe.html?id=filters-dropdown--default&args=&viewMode=story'
)
cy.get('.filters-dropdown').should('exist')

cy.percySnapshot('FiltersDropdown: Default')
})
})
116 changes: 116 additions & 0 deletions src/stories/FiltersDropdown.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { computed, onMounted, ref } from 'vue'
import FiltersDropdown from '@/lib-components/FiltersDropdown.vue'

export default {
title: 'Filters Dropdown',
component: FiltersDropdown,
}

// MOCK DATA
const mockFilterGroups = [
{
name: 'Event Type',
searchField: 'ftvaEventTypeFilters.title.keyword',
options: ['Film', 'Theater', 'Lecture'],
},
{
name: 'Screen Format',
searchField: 'ftvaScreeningFormatFilters.title.keyword',
options: ['Online', 'In-Person'],
},
]
// note that because this component uses v-model by default,
// parent component needs to use a ref for selectedFilters
const mockEmptySelectedFilters = ref({ 'ftvaEventTypeFilters.title.keyword': [], 'ftvaScreeningFormatFilters.title.keyword': [] })
const mockSelectedFilters = ref({
'ftvaEventTypeFilters.title.keyword': ['Film', 'Theater'],
'ftvaScreeningFormatFilters.title.keyword': ['Online'],
})

export function Default() {
return {
components: { FiltersDropdown },
data() {
return { mockFilterGroups, mockSelectedFilters: mockEmptySelectedFilters }
},
template: '<div style="width:400px"><span>Selected filters display:{{ mockSelectedFilters }}</span><filters-dropdown v-model:selectedFilters="mockSelectedFilters" :filterGroups="mockFilterGroups" /></div>',
}
}

// uses async data
export function InitialSelectedFilters() {
return {
components: { FiltersDropdown },
setup() {
const selectedFilters = ref({})
// mock getting selected filters from a route or other async source
const fetchFilters = async () => {
// Mocking an async fetch call
const response = await new Promise((resolve) => {
setTimeout(() => {
resolve(mockSelectedFilters.value)
}, 1000)
})
selectedFilters.value = response
}

fetchFilters()

return { selectedFilters }
},
data() {
return { mockFilterGroups }
},
template: '<span>Selected filters display:{{ selectedFilters }}</span><filters-dropdown v-model:selectedFilters="selectedFilters" :filterGroups="mockFilterGroups" />',
}
}

// FTVA Theme
export function FTVA() {
return {
components: { FiltersDropdown },
data() {
return { mockFilterGroups, mockSelectedFilters }
},
provide() {
return {
theme: computed(() => 'ftva'),
}
},
template: '<span>Selected filters display:{{ mockSelectedFilters }}</span><filters-dropdown v-model:selectedFilters="mockSelectedFilters" :filterGroups="mockFilterGroups" />',
}
}

// FTVA Theme W Selected Filters updating on 'done' click only
// This is the current planned implmentation on the FTVA site
const mockSelectedFiltersEmitted = ref({
'ftvaEventTypeFilters.title.keyword': ['Film'],
'ftvaScreeningFormatFilters.title.keyword': ['Online'],
})
export function FTVAFiltersUpdateDoneClick() {
return {
components: { FiltersDropdown },
setup() {
const selectedFiltersDisplay = ref({})

const updateFiltersDisplay = () => {
// assignment is done with spread operator so that a copy is made
selectedFiltersDisplay.value = { ...mockSelectedFiltersEmitted.value }
}
onMounted(() => {
// trigger function once onMount to update display with initial selected filters
updateFiltersDisplay()
})
return { selectedFiltersDisplay, updateFiltersDisplay }
},
data() {
return { mockFilterGroups, mockSelectedFiltersEmitted }
},
provide() {
return {
theme: computed(() => 'ftva'),
}
},
template: '<span>Selected filters display:{{ selectedFiltersDisplay }}</span><filters-dropdown v-model:selectedFilters="mockSelectedFiltersEmitted" @update-display="updateFiltersDisplay" :filterGroups="mockFilterGroups" />',
}
}
Loading

1 comment on commit 8dbad4a

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.