Skip to content

Commit

Permalink
pkp/pkp-lib#10033 Add new dropdown menu component (#363)
Browse files Browse the repository at this point in the history
* pkp/pkp-lib#10033 Add MoreOptions icon in Icon Gallery/components

* pkp/pkp-lib#10033 Add DropdownMenu component

* pkp/pkp-lib#10033 Change classes used for white bg and set min width

* pkp/pkp-lib#10033 Only use white bg for dropdown button if not using ellipsis menu

* pkp/pkp-lib#10033 Rename newly added component from DropdownMenu to DropdownActions

* pkp/pkp-lib#10033 Add click user event when rendering stories for DropdownActions component

* pkp/pkp-lib#10033 Add ariaLabel prop when using ellipsis menu

* pkp/pkp-lib#i10033 Implement code review suggestions

* pkp/pkp#10033 Update documentation and validations for the DropdownActions component

* pkp/pkp-lib#10033 Add validator for direction prop

* pkp/pkp-lib#10033 Add select options for Dropdown controls in Storybook

* pkp/pkp-lib#10033 Update documentation for the DropdownActions component

* pkp/pkp-lib#10033 Add Accessibility section in DropdownActions documentation
  • Loading branch information
blesildaramirez authored Jun 24, 2024
1 parent 50e3204 commit 67733de
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 0 deletions.
22 changes: 22 additions & 0 deletions src/components/DropdownActions/DropdownActions.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {Primary, Controls, Stories, Meta, Description} from '@storybook/blocks';
import DropdownActions from './DropdownActions.vue';

import * as DropdownActionsStories from './DropdownActions.stories.js';

<Meta of={DropdownActionsStories} />

# Dropdown Actions

## Usage

This component renders a dropdown menu that displays a list of actions. To use an ellipsis menu, set `displayAsEllipsis` to `true`. Otherwise, the `label` prop will be used as the dropdown button text.

## Accesibility

When the ellipsis menu is enabled by setting `displayAsEllipsis` to `true`, the `label` prop provides descriptive text for the "More Options" icon. In this case, the `ariaLabel` prop can be used to further specify the function of the button for screen readers.

For dropdowns with text, you can provide an `ariaLabel` prop to set the `aria-label` attribute on the dropdown button, ensuring that it is fully described for screen readers. This enhances accessibility without relying solely on the surrounding visual context.

<Primary />
<Controls />
<Stories />
125 changes: 125 additions & 0 deletions src/components/DropdownActions/DropdownActions.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import DropdownActions from './DropdownActions.vue';
import {within, userEvent} from '@storybook/test';

export default {
title: 'Components/DropdownActions',
component: DropdownActions,
render: (args) => ({
components: {DropdownActions},
setup() {
return {args};
},
template: '<DropdownActions v-bind="args" />',
}),
argTypes: {
direction: {
control: {type: 'select'},
options: ['left', 'right'],
},
},
decorators: [
() => ({
template:
'<div style="height: 300px; padding: 10px;" class="text-center"><story/></div>',
}),
],
};

const downloadActions = [
{
label: 'Author-Only Sections Displayed (PDF)',
name: 'authorPdf',
},
{
label: 'Author-Only Sections Displayed (XML)',
name: 'authorXml',
},
{
label: 'Editor Forms Shows All Review Sections (PDF)',
name: 'editorPdf',
},
{
label: 'Editor Forms Shows All Review Sections (XML)',
name: 'editorXml',
},
];

export const Default = {
args: {
actions: downloadActions,
label: 'Download Review Form',
ariaLabel: 'Click to download content in the available formats',
direction: 'left',
},
play: async ({canvasElement}) => {
// Assigns canvas to the component root element
const canvas = within(canvasElement);
const user = userEvent.setup();

await user.click(canvas.getByText('Download Review Form'));
},
};

export const EllipsisMenu = {
args: {
actions: [
{
label: 'View',
url: '#',
icon: 'View',
},
{
label: 'Email',
url: '#',
icon: 'Email',
},
{
label: 'Login As',
url: '#',
icon: 'LoginAs',
},
{
label: 'Remove User',
url: '#',
icon: 'Cancel',
isWarnable: true,
},
{
label: 'Disable User',
url: '#',
icon: 'DisableUser',
isWarnable: true,
},
{
label: 'Merge User',
url: '#',
icon: 'MergeUser',
},
],
label: 'User management options',
displayAsEllipsis: true,
direction: 'left',
},
play: async ({canvasElement}) => {
// Assigns canvas to the component root element
const canvas = within(canvasElement);
const user = userEvent.setup();

await user.click(canvas.getByText('User management options'));
},
};

export const RightAlignedMenu = {
args: {
actions: downloadActions,
label: 'Right Aligned Menu',
direction: 'right',
},
play: async ({canvasElement}) => {
// Assigns canvas to the component root element
const canvas = within(canvasElement);
const user = userEvent.setup();

await user.click(canvas.getByText('Right Aligned Menu'));
},
};
106 changes: 106 additions & 0 deletions src/components/DropdownActions/DropdownActions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<template>
<div class="relative inline-block items-start justify-between">
<Menu as="div">
<div>
<MenuButton
class="hover:bg-gray-50 inline-flex w-full justify-center gap-x-1.5 rounded px-3 py-2"
:class="
displayAsEllipsis
? 'text-3xl-normal'
: 'border border-light bg-secondary text-lg-normal'
"
:aria-label="ariaLabel || null"
>
<span :class="displayAsEllipsis ? 'sr-only' : ''">{{ label }}</span>
<Icon
class="-mr-1 h-5 w-5 text-primary"
:icon="displayAsEllipsis ? 'MoreOptions' : 'Dropdown'"
aria-hidden="true"
/>
</MenuButton>
</div>

<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
class="absolute z-10 w-max border border-light bg-secondary shadow focus:outline-none"
:class="
direction === 'right'
? 'ltr:left-0 ltr:origin-top-left rtl:right-0 rtl:origin-top-left'
: 'ltr:right-0 ltr:origin-top-right rtl:left-0 rtl:origin-top-right'
"
>
<MenuItem v-for="(action, i) in actions" :key="i" v-slot="{active}">
<div class="min-w-[96px]">
<PkpButton
v-if="isValidAction(action)"
:element="action.url ? 'a' : 'button'"
:href="action.url"
:icon="action.icon"
:is-active="active"
:is-warnable="action.isWarnable"
:class="i !== actions.length - 1 ? 'border-b' : ''"
size-variant="fullWidth"
class="border-light"
@click="action.name"
>
{{ action.label }}
</PkpButton>
</div>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
</template>

<script setup>
import {Menu, MenuButton, MenuItem, MenuItems} from '@headlessui/vue';
defineProps({
/** An array of action objects. Each object should contain `label` (string), `url` (string) to navigate to if the action involves a link, or `name` (string) to perform the action when clicked, an optional `icon` (string) and `isWarnable` (boolean) if the button needs the "warning" button styling from `<Button>` component. */
actions: {
type: Array,
required: true,
validator: (actions) => {
return actions.every((action) => {
const hasLabel =
typeof action.label === 'string' && action.label.trim() !== '';
const hasAction = action.url || action.name;
return hasLabel && hasAction;
});
},
},
/** The text label for the button. This is required. If `displayAsEllipsis` is `true`, the label will be used for accessibility. */
label: {
type: String,
required: true,
},
/** If `true`, the button will display an ellipsis (`...`) */
displayAsEllipsis: {
type: Boolean,
default: false,
},
/** The accessible label for the button, used by screen readers. This is optional. */
ariaLabel: {
type: String,
default: '',
},
/** This specifies where the dropdown appears relative to the element, such as "left" or "right." */
direction: {
type: String,
default: 'left',
validator: (direction) => ['left', 'right'].includes(direction),
},
});
const isValidAction = (action) => {
return action?.label && (action?.url || action?.name);
};
</script>
1 change: 1 addition & 0 deletions src/components/Icon/Icon.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const iconGallery = {
'Italics',
'LoginAs',
'MergeUser',
'MoreOptions',
'MySubmissions',
'NavDoi',
'Notifications',
Expand Down
2 changes: 2 additions & 0 deletions src/components/Icon/Icon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import Issues from './icons/Issues.vue';
import Italics from './icons/Italics.vue';
import LoginAs from './icons/LoginAs.vue';
import MergeUser from './icons/MergeUser.vue';
import MoreOptions from './icons/MoreOptions.vue';
import MySubmissions from './icons/MySubmissions.vue';
import NavDoi from './icons/NavDoi.vue';
import Notifications from './icons/Notifications.vue';
Expand Down Expand Up @@ -126,6 +127,7 @@ const svgIcons = {
Italics,
LoginAs,
MergeUser,
MoreOptions,
MySubmissions,
NavDoi,
Notifications,
Expand Down
8 changes: 8 additions & 0 deletions src/components/Icon/icons/MoreOptions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<template>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.5 10.5C3.675 10.5 3 11.175 3 12C3 12.825 3.675 13.5 4.5 13.5C5.325 13.5 6 12.825 6 12C6 11.175 5.325 10.5 4.5 10.5ZM19.5 10.5C18.675 10.5 18 11.175 18 12C18 12.825 18.675 13.5 19.5 13.5C20.325 13.5 21 12.825 21 12C21 11.175 20.325 10.5 19.5 10.5ZM12 10.5C11.175 10.5 10.5 11.175 10.5 12C10.5 12.825 11.175 13.5 12 13.5C12.825 13.5 13.5 12.825 13.5 12C13.5 11.175 12.825 10.5 12 10.5Z"
fill="currentColor"
/>
</svg>
</template>

0 comments on commit 67733de

Please sign in to comment.