Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add sort support #76

Merged
merged 6 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/NeDropdownFilter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
-->

<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { ref, watch, computed } from 'vue'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
Expand Down
6 changes: 1 addition & 5 deletions src/components/NeListbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,7 @@ onClickOutside(listboxRef, () => onClickOutsideListbox())
'absolute inset-y-0 right-0 flex items-center pr-4 text-primary-700 dark:text-primary-500'
]"
>
<FontAwesomeIcon
:icon="fasCheck"
class="h-4 w-4 shrink-0"
aria-hidden="true"
/>
<FontAwesomeIcon :icon="fasCheck" class="h-4 w-4 shrink-0" aria-hidden="true" />
</span>
</li>
</ListboxOption>
Expand Down
6 changes: 1 addition & 5 deletions src/components/NeModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,7 @@ function onSecondaryClick() {
@click="onClose"
>
<span class="sr-only">{{ closeAriaLabel }}</span>
<FontAwesomeIcon
:icon="['fas', 'xmark']"
class="h-5 w-5"
aria-hidden="true"
/>
<FontAwesomeIcon :icon="['fas', 'xmark']" class="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div class="sm:flex sm:items-start">
Expand Down
6 changes: 1 addition & 5 deletions src/components/NePaginator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,7 @@ function navigateToPage(page: number) {
@click="navigateToPage(currentPage + 1)"
>
<span class="sr-only">{{ nextLabel }}</span>
<FontAwesomeIcon
:icon="fasChevronRight"
class="h-3 w-3 shrink-0"
aria-hidden="true"
/>
<FontAwesomeIcon :icon="fasChevronRight" class="h-3 w-3 shrink-0" aria-hidden="true" />
</button>
</li>
</ul>
Expand Down
228 changes: 228 additions & 0 deletions src/components/NeSortDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<!--
Copyright (C) 2024 Nethesis S.r.l.
SPDX-License-Identifier: GPL-3.0-or-later
-->

<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { v4 as uuidv4 } from 'uuid'
import type { ButtonSize } from './NeButton.vue'

export type SortOption = {
id: string
label: string
}

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: SortOption[]
openMenuAriaLabel: string
sortByLabel: string
sortDirectionLabel: string
ascendingLabel: string
descendingLabel: string
alignToRight?: boolean
size?: ButtonSize
disabled?: boolean
id?: string
}

const props = withDefaults(defineProps<Props>(), {
alignToRight: false,
size: 'md',
disabled: false,
id: ''
})

const sortKey = defineModel<string>('sortKey')
const sortDescending = defineModel<boolean>('sortDescending')
const internalSortKey = ref('')
const internalDescendingValue = ref('asc')
const top = ref(0)
const left = ref(0)
const right = ref(0)
const buttonRef = ref()

const componentId = computed(() => (props.id ? props.id : uuidv4()))

watch(
() => props.alignToRight,
() => {
calculatePosition()
},
{ immediate: true }
)

watch(
() => sortDescending.value,
() => {
// update internal model when external model changes, converting boolean to string
internalDescendingValue.value = sortDescending.value ? 'desc' : 'asc'
},
{ immediate: true }
)

watch(
() => sortKey.value,
() => {
// update internal model when external model changes
if (sortKey.value !== internalSortKey.value) {
internalSortKey.value = sortKey.value || ''
}
},
{ immediate: true }
)

watch(
() => internalSortKey.value,
() => {
// update external model when internal model changes
sortKey.value = internalSortKey.value
},
{ immediate: true }
)

watch(
() => internalDescendingValue.value,
() => {
// update external model when internal model changes, converting string to boolean
sortDescending.value = internalDescendingValue.value === 'desc'
},
{ immediate: true }
)

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"
>
<span class="flex items-center justify-center">
<slot v-if="$slots.label" name="label"></slot>
<span v-else>{{ label }}</span>
<FontAwesomeIcon :icon="faChevronDown" class="ml-2 h-3 w-3" aria-hidden="true" />
</span>
</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-80 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"
>
<div class="text-sm font-medium leading-6 text-gray-500 dark:text-gray-400">
{{ sortByLabel }}
</div>
<MenuItem v-for="option in options" :key="option.id" as="div">
<!-- divider -->
<hr
v-if="option.id.includes('divider')"
class="my-1 border-gray-200 dark:border-gray-700"
/>
<div class="flex items-center py-2" @click.stop>
<input
:id="`${componentId}-${option.id}`"
v-model="internalSortKey"
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="disabled"
/>
<label
:for="`${componentId}-${option.id}`"
class="ms-2 flex flex-col text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 dark:text-gray-50"
>
<span>{{ option.label }}</span>
</label>
</div>
</MenuItem>
<div class="mt-4 text-sm font-medium leading-6 text-gray-500 dark:text-gray-400">
{{ sortDirectionLabel }}
</div>
<!-- ascending -->
<MenuItem as="div">
<div class="flex items-center py-2" @click.stop>
<input
:id="`${componentId}-asc`"
v-model="internalDescendingValue"
type="radio"
:name="`${componentId}-sortDirection`"
value="asc"
:aria-describedby="`${componentId}-asc-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="disabled"
/>
<label
:for="`${componentId}-asc`"
class="ms-2 flex flex-col text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 dark:text-gray-50"
>
<span>{{ ascendingLabel }}</span>
</label>
</div>
</MenuItem>
<!-- descending -->
<MenuItem as="div">
<div class="flex items-center py-2" @click.stop>
<input
:id="`${componentId}-desc`"
v-model="internalDescendingValue"
type="radio"
:name="`${componentId}-sortDirection`"
value="desc"
:aria-describedby="`${componentId}-desc-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="disabled"
/>
<label
:for="`${componentId}-desc`"
class="ms-2 flex flex-col text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 dark:text-gray-50"
>
<span>{{ descendingLabel }}</span>
</label>
</div>
</MenuItem>
</MenuItems>
</transition>
</Teleport>
</Menu>
</template>
25 changes: 22 additions & 3 deletions src/components/NeTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
SPDX-License-Identifier: GPL-3.0-or-later
-->
<script lang="ts" setup>
import { provide, ref, type PropType } from 'vue'
import { computed, provide, type PropType } from 'vue'
import NeTableSkeleton from './NeTableSkeleton.vue'

export type Breakpoint = 'sm' | 'md' | 'lg' | 'xl' | '2xl'
Expand All @@ -28,11 +28,30 @@ const props = defineProps({
skeletonColumns: {
type: Number,
default: 4
},
sortKey: {
type: String,
default: ''
},
sortDescending: {
type: Boolean,
default: false
}
})

// provide cardBreakpoint prop to children components
provide('cardBreakpoint', ref(props.cardBreakpoint))
// provide props to children components
provide(
'cardBreakpoint',
computed(() => props.cardBreakpoint)
)
provide(
'sortKey',
computed(() => props.sortKey)
)
provide(
'sortDescending',
computed(() => props.sortDescending)
)

const tableCardStyle: Record<Breakpoint, string> = {
sm: 'sm:table sm:divide-y sm:divide-gray-300 sm:dark:divide-gray-600',
Expand Down
67 changes: 65 additions & 2 deletions src/components/NeTableHeadCell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,71 @@
Copyright (C) 2024 Nethesis S.r.l.
SPDX-License-Identifier: GPL-3.0-or-later
-->
<script lang="ts" setup>
import { computed, inject, toValue } from 'vue'
import { faSort, faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'

const props = defineProps({
columnKey: {
type: String,
default: ''
},
sortable: {
type: Boolean,
default: false
}
})

const emit = defineEmits<{
sort: [{ key: string; descending: boolean }]
}>()

// inject from NeTable.vue
const sortKey = inject('sortKey', '')
const sortDescending = inject('sortDescending', false)

const ariaSort = computed(() => {
const sortKeyValue = toValue(sortKey)
const sortDescendingValue = toValue(sortDescending)

if (props.sortable && props.columnKey === sortKeyValue) {
return sortDescendingValue ? 'descending' : 'ascending'
}
return 'none'
})

const sort = () => {
const sortKeyValue = toValue(sortKey)
const sortDescendingValue = toValue(sortDescending)

if (props.columnKey === sortKeyValue) {
emit('sort', { key: props.columnKey, descending: !sortDescendingValue })
} else {
emit('sort', { key: props.columnKey, descending: false })
}
}
</script>
<template>
<th scope="col" class="px-6 py-3 font-medium">
<slot></slot>
<th scope="col" class="px-6 py-3 font-medium" :aria-sort="ariaSort">
<!-- sortable column header -->
<button v-if="sortable" class="group flex items-center gap-2" @click="sort">
<slot></slot>
<template v-if="props.columnKey === sortKey">
<FontAwesomeIcon
:icon="sortDescending ? faSortDown : faSortUp"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-gray-50"
aria-hidden="true"
/>
</template>
<FontAwesomeIcon
v-else
:icon="faSort"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-gray-50"
aria-hidden="true"
/>
</button>
<!-- non-sortable column header -->
<slot v-else></slot>
</th>
</template>
Loading