Skip to content

Commit

Permalink
Feature: Desktop-View - Added new components CommonSimpleTable and Co…
Browse files Browse the repository at this point in the history
…mmonActionMenu.

Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Benjamin Scharf <bs@zammad.com>
  • Loading branch information
4 people committed Apr 25, 2024
1 parent dff64d2 commit fa07a4c
Show file tree
Hide file tree
Showing 16 changed files with 766 additions and 48 deletions.
@@ -0,0 +1,100 @@
<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->

<script setup lang="ts">
import { computed, ref, toRefs } from 'vue'
import getUuid from '#shared/utils/getUuid.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import CommonPopover from '#desktop/components/CommonPopover/CommonPopover.vue'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonPopoverMenu from '#desktop/components/CommonPopover/CommonPopoverMenu.vue'
import { usePopover } from '#desktop/components/CommonPopover/usePopover.ts'
import type {
ButtonSize,
ButtonVariant,
} from '#desktop/components/CommonButton/types.ts'
import type {
MenuItem,
Orientation,
Placement,
} from '#desktop/components/CommonPopover/types.ts'
import { usePopoverMenu } from '#desktop/components/CommonPopover/usePopoverMenu.ts'
interface Props {
actions: MenuItem[]
entity?: ObjectLike
buttonSize?: ButtonSize
buttonVariant?: ButtonVariant
placement?: Placement
orientation?: Orientation
noSingleActionMode?: boolean
}
const props = withDefaults(defineProps<Props>(), {
buttonSize: 'large',
buttonVariant: 'neutral',
placement: 'start',
orientation: 'autoVertical',
})
const popoverMenu = ref<InstanceType<typeof CommonPopoverMenu>>()
const { popover, isOpen: popoverIsOpen, popoverTarget, toggle } = usePopover()
const { actions, entity } = toRefs(props)
const { filteredMenuItems, singleMenuItemPresent, singleMenuItem } =
usePopoverMenu(actions, entity, { provides: true })
const entityId = computed(() => props.entity?.id || getUuid())
const menuId = computed(() => `popover-${entityId.value}`)
const singleActionMode = computed(() => {
if (props.noSingleActionMode) return false
return singleMenuItemPresent.value
})
</script>

<template>
<div v-if="filteredMenuItems" class="inline-block">
<CommonButton
v-if="singleActionMode"
:size="buttonSize"
:variant="buttonVariant"
:aria-label="$t(singleMenuItem?.label)"
:icon="singleMenuItem?.icon"
@click="singleMenuItem?.onClick?.(entity as ObjectLike)"
/>
<CommonButton
v-else
:id="entity?.id || entityId"
ref="popoverTarget"
:aria-label="$t('Action menu button')"
aria-haspopup="true"
:aria-controls="menuId"
:class="{
'outline outline-1 outline-offset-1 outline-blue-800': popoverIsOpen,
}"
:size="buttonSize"
:variant="buttonVariant"
icon="three-dots-vertical"
@click="toggle"
/>
<CommonPopover
v-if="!singleActionMode"
:id="menuId"
ref="popover"
:placement="placement"
:orientation="orientation"
:owner="popoverTarget"
>
<CommonPopoverMenu
ref="popoverMenu"
:entity="entity"
:popover="popover"
/>
</CommonPopover>
</div>
</template>
@@ -0,0 +1,104 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import CommonActionMenu from '#desktop/components/CommonActionMenu/CommonActionMenu.vue'
import renderComponent from '#tests/support/components/renderComponent.ts'

const fn = vi.fn()
describe('CommonActionMenu', () => {
let view: ReturnType<typeof renderComponent>

const actions = [
{
key: 'delete-foo',
label: 'Delete Foo',
icon: 'trash3',
onClick: ({ id }: { id: string }) => {
fn(id)
},
},
{
key: 'change-foo',
label: 'Change Foo',
icon: 'person-gear',
onClick: ({ id }: { id: string }) => {
fn(id)
},
},
]

beforeEach(() => {
view = renderComponent(CommonActionMenu, {
props: {
entity: {
id: 'foo-test-action',
},
actions,
},
})
})

it('shows action menu button by default', () => {
expect(view.getByIconName('three-dots-vertical')).toBeInTheDocument()
})

it('calls onClick handler when action is clicked', async () => {
await view.events.click(view.getByIconName('three-dots-vertical'))

expect(view.getByIconName('trash3')).toBeInTheDocument()
expect(view.getByIconName('person-gear')).toBeInTheDocument()

await view.events.click(view.getByText('Change Foo'))

expect(fn).toHaveBeenCalledWith('foo-test-action')
})

it('finds corresponding a11y controls', async () => {
const id = view
.getByLabelText('Action menu button')
.getAttribute('aria-controls')

await view.events.click(view.getByIconName('three-dots-vertical'))

const popover = document.getElementById(id as string)

expect(popover?.getAttribute('id')).toEqual(id)
})

describe('single action mode', () => {
beforeEach(async () => {
await view.rerender({
actions: [actions[0]],
})
})

it('adds aria label on single action button', () => {
expect(view.getByLabelText('Delete Foo')).toBeInTheDocument()
})

it('supports single action mode', () => {
expect(
view.queryByIconName('three-dots-vertical'),
).not.toBeInTheDocument()

expect(view.getByIconName('trash3')).toBeInTheDocument()
})

it('calls onClick handler when action is clicked', async () => {
await view.events.click(view.getByIconName('trash3'))

expect(fn).toHaveBeenCalledWith('foo-test-action')
})

it('renders single action if prop is set', async () => {
await view.rerender({
noSingleActionMode: true,
})

expect(view.queryByIconName('trash3')).not.toBeInTheDocument()

await view.events.click(view.getByIconName('three-dots-vertical'))

expect(view.getByIconName('trash3')).toBeInTheDocument()
})
})
})
@@ -1,7 +1,13 @@
<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->

<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref } from 'vue'
import {
type ComponentPublicInstance,
computed,
nextTick,
onUnmounted,
ref,
} from 'vue'
import { onClickOutside, type UseElementBoundingReturn } from '@vueuse/core'
import { onKeyUp, useElementBounding, useWindowSize } from '@vueuse/core'
Expand All @@ -19,7 +25,7 @@ import type {
} from './types'
export interface Props {
owner: HTMLElement | undefined
owner: HTMLElement | ComponentPublicInstance | undefined
orientation?: Orientation
placement?: Placement
hideArrow?: boolean
Expand Down Expand Up @@ -209,7 +215,9 @@ const updateOwnerAriaExpandedState = () => {
const element = props.owner
if (!element) return
element.ariaExpanded = showPopover.value ? 'true' : 'false'
if ('ariaExpanded' in element) {
element.ariaExpanded = showPopover.value ? 'true' : 'false'
}
}
const closePopover = (isInteractive = false) => {
Expand All @@ -219,7 +227,10 @@ const closePopover = (isInteractive = false) => {
emit('close')
nextTick(() => {
if (!isInteractive) props.owner?.focus()
if (!isInteractive && props.owner) {
// eslint-disable-next-line no-unused-expressions
'$el' in props.owner ? props.owner.$el?.focus?.() : props.owner?.focus?.()
}
updateOwnerAriaExpandedState()
testFlags.set('common-select.closed')
})
Expand Down Expand Up @@ -293,7 +304,7 @@ const duration = VITE_TEST_MODE ? undefined : { enter: 300, leave: 200 }
role="region"
class="popover fixed z-50 flex min-h-9 rounded-xl border border-neutral-100 bg-white antialiased dark:border-gray-900 dark:bg-gray-500"
:style="popoverStyle"
:aria-labelledby="owner?.id"
:aria-labelledby="owner && '$el' in owner ? owner.$el?.id : owner?.id"
>
<div class="overflow-y-auto">
<slot />
Expand Down
@@ -1,56 +1,50 @@
<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->

<script setup lang="ts">
import { computed, useSlots } from 'vue'
import { computed, toRefs, useSlots } from 'vue'
import { useSessionStore } from '#shared/stores/session.ts'
import type { CommonPopoverInstance, MenuItem } from './types'
import type { ObjectLike } from '#shared/types/utils.ts'
import { usePopoverMenu } from '#desktop/components/CommonPopover/usePopoverMenu.ts'
import type { CommonPopoverInstance, MenuItem, Variant } from './types'
import CommonPopoverMenuItem from './CommonPopoverMenuItem.vue'
export interface Props {
popover: CommonPopoverInstance | undefined
headerLabel?: string
items?: MenuItem[]
entity?: ObjectLike
}
const props = defineProps<Props>()
const session = useSessionStore()
const availableItems = computed(() => {
if (!props.items || !props.items.length) return null
return props.items.filter((item) => {
if (item.permission) {
return session.hasPermission(item.permission)
}
const { items, entity } = toRefs(props)
if (item.show) {
return item.show()
}
return true
})
})
const { filteredMenuItems } = usePopoverMenu(items, entity)
const slots = useSlots()
const showHeaderLabel = computed(() => {
if (!availableItems.value && !slots.default) return false
if (!filteredMenuItems.value && !slots.default) return false
return slots.header || props.headerLabel
})
const onClickItem = (event: MouseEvent, item: MenuItem) => {
if (item.onClick) {
item.onClick(event)
item.onClick(props.entity)
}
if (!item.noCloseOnClick) {
props.popover?.closePopover()
}
}
const getHoverFocusStyles = (variant?: Variant) => {
if (variant === 'danger') {
return 'focus-within:bg-red-50 hover:bg-red-50 hover:focus-within:bg-red-50 dark:focus-within:bg-red-900 dark:hover:bg-red-900 dark:hover:focus-within:bg-red-900'
}
return 'focus-within:bg-blue-800 focus-within:text-white hover:bg-blue-600 hover:focus-within:bg-blue-800 dark:hover:bg-blue-900 dark:hover:focus-within:bg-blue-800'
}
</script>

<template>
Expand All @@ -61,33 +55,35 @@ const onClickItem = (event: MouseEvent, item: MenuItem) => {
aria-level="2"
class="p-2 leading-3"
>
<slot name="header"
><CommonLabel
size="small"
class="text-stone-200 dark:text-neutral-500"
>{{ i18n.t(headerLabel) }}</CommonLabel
></slot
>
<slot name="header">
<CommonLabel size="small" class="text-stone-200 dark:text-neutral-500"
>{{ i18n.t(headerLabel) }}
</CommonLabel>
</slot>
</div>

<template v-if="availableItems || $slots.default">
<template v-if="filteredMenuItems || $slots.default">
<slot>
<ul role="menu" v-bind="$attrs" class="flex w-full flex-col">
<template v-for="item in availableItems" :key="item.key">
<template v-for="item in filteredMenuItems" :key="item.key">
<li
role="menuitem"
class="group flex items-center justify-between last:rounded-b-[10px] focus-within:bg-blue-800 focus-within:text-white hover:bg-blue-600 hover:focus-within:bg-blue-800 dark:hover:bg-blue-900 dark:hover:focus-within:bg-blue-800"
:class="{
'first:rounded-t-[10px]': !showHeaderLabel,
'border-t border-neutral-100 dark:border-gray-900':
item.separatorTop,
}"
class="group flex items-center justify-between last:rounded-b-[10px]"
:class="[
{
'first:rounded-t-[10px]': !showHeaderLabel,
'border-t border-neutral-100 dark:border-gray-900':
item.separatorTop,
},
getHoverFocusStyles(item.variant),
]"
>
<slot :name="`item-${item.key}`" v-bind="item">
<component
:is="item.component || CommonPopoverMenuItem"
class="flex grow p-2.5"
:label="item.label"
:variant="item.variant"
:link="item.link"
:icon="item.icon"
:label-placeholder="item.labelPlaceholder"
Expand Down

0 comments on commit fa07a4c

Please sign in to comment.