-
Notifications
You must be signed in to change notification settings - Fork 393
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(vue-menu): apply re-design, update tests and stories
- Loading branch information
1 parent
9cbe4a1
commit c1a8705
Showing
9 changed files
with
320 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
18 changes: 18 additions & 0 deletions
18
src/assets/design-system/variables/components/data-display/_menu.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
$menu-z-index: 1000; | ||
$menu-bg: palette-color-level('neutral', 0); | ||
$menu-min-width: $space-120 + $space-72; | ||
$menu-padding: $space-8 0; | ||
$menu-shadow: var(--brand-elevation-3); | ||
$menu-border-radius: $space-8; | ||
$menu-border: 1px solid var(--brand-secondary-border-color); | ||
|
||
// item | ||
$menu-item-color: var(--brand-high-emphasis-text-color); | ||
$menu-item-padding: $space-10 $space-12; | ||
$menu-item-bg-active: var(--brand-primary-hover); | ||
$menu-item-color-active: var(--brand-high-emphasis-text-color-inverse); | ||
$menu-item-icon-size: $space-20; | ||
$menu-item-icon-size-gap: $space-8; | ||
|
||
// separator | ||
$menu-separator-border: 1px solid var(--brand-secondary-border-color); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
import { fireEvent, render, RenderResult } from '@testing-library/vue'; | ||
import VueMenu from './VueMenu.vue'; | ||
|
||
describe('VueMenu.vue', () => { | ||
let harness: RenderResult; | ||
|
||
beforeEach(() => { | ||
harness = render(VueMenu, { | ||
props: { | ||
items: [ | ||
{ label: 'Value 1', value: 'Value 1', description: 'Description 1', leadingIcon: 'hashtag' }, | ||
{ label: 'Value 2', value: 'Value 2', description: 'Description 2', trailingIcon: 'hashtag' }, | ||
{ label: '', value: 'separator' }, | ||
{ label: 'Value 3', value: 'Value 3', description: 'Description 3', leadingIcon: 'hashtag' }, | ||
{ label: 'Value 4', value: 'Value 4', description: 'Description 4', trailingIcon: 'hashtag' }, | ||
], | ||
}, | ||
}); | ||
}); | ||
|
||
test('renders component', () => { | ||
const { getByText } = harness; | ||
|
||
getByText('Value 1'); | ||
getByText('Value 2'); | ||
getByText('Value 3'); | ||
getByText('Value 4'); | ||
|
||
getByText('Description 1'); | ||
getByText('Description 2'); | ||
getByText('Description 3'); | ||
getByText('Description 4'); | ||
}); | ||
|
||
test('should select 1st item and emit click event via keyboard', async () => { | ||
const { getByTestId, emitted } = harness; | ||
const firstItem = getByTestId('Value 1-0'); | ||
|
||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowLeft', code: 'ArrowLeft' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowDown', code: 'ArrowDown' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowDown', code: 'ArrowDown' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowDown', code: 'ArrowDown' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowDown', code: 'ArrowDown' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowDown', code: 'ArrowDown' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'Enter', code: 'Enter' }); | ||
|
||
expect(emitted().click).toEqual([ | ||
[ | ||
{ | ||
description: 'Description 1', | ||
label: 'Value 1', | ||
leadingIcon: 'hashtag', | ||
value: 'Value 1', | ||
}, | ||
], | ||
]); | ||
}); | ||
|
||
test('should select last item and emit click event via keyboard', async () => { | ||
const { getByTestId, emitted } = harness; | ||
const firstItem = getByTestId('Value 1-0'); | ||
|
||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowRight', code: 'ArrowRight' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowUp', code: 'ArrowUp' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowUp', code: 'ArrowUp' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowUp', code: 'ArrowUp' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowUp', code: 'ArrowUp' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'ArrowUp', code: 'ArrowUp' }); | ||
await fireEvent.keyDown(firstItem.parentElement, { key: 'Enter', code: 'Enter' }); | ||
|
||
expect(emitted().click).toEqual([ | ||
[ | ||
{ | ||
description: 'Description 4', | ||
label: 'Value 4', | ||
trailingIcon: 'hashtag', | ||
value: 'Value 4', | ||
}, | ||
], | ||
]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { storiesOf } from '@storybook/vue'; | ||
import { action } from '@storybook/addon-actions'; | ||
import ComponentDocs from '@/assets/design-system/docs/components/ComponentDocs.vue'; | ||
import { IItem } from '@/interfaces/IItem'; | ||
import VueMenu from './VueMenu.vue'; | ||
|
||
const story = storiesOf('Data Display|Menu', module) as any; | ||
|
||
// TODO: add usage | ||
story.add( | ||
'Default', | ||
() => ({ | ||
components: { VueMenu, ComponentDocs }, | ||
data(): { items: Array<IItem> } { | ||
return { | ||
items: [ | ||
{ label: 'Value 1', value: 'Value 1', description: 'Description 1', leadingIcon: 'hashtag' }, | ||
{ label: 'Value 2', value: 'Value 2', description: 'Description 2', trailingIcon: 'hashtag' }, | ||
{ label: '', value: 'separator' }, | ||
{ label: 'Value 3', value: 'Value 3', description: 'Description 3', leadingIcon: 'hashtag' }, | ||
{ label: 'Value 4', value: 'Value 4', description: 'Description 4', trailingIcon: 'hashtag' }, | ||
], | ||
}; | ||
}, | ||
methods: { | ||
onClick: action('@click'), | ||
}, | ||
template: `<component-docs | ||
component-name="Menu" | ||
usage="TBD" | ||
story="Display menu with all interaction states. Please interact with the menu to see all states." | ||
> | ||
<vue-menu :items="items" @click="onClick" /> | ||
</component-docs>`, | ||
}), | ||
{ | ||
info: { | ||
components: { VueMenu }, | ||
}, | ||
}, | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
<template> | ||
<ul ref="menuRef" :class="$style.vueMenu" @keydown="onKeyDown"> | ||
<li | ||
v-for="(item, idx) in items" | ||
:key="`${item.value}-${idx}`" | ||
:data-testid="`${item.value}-${idx}`" | ||
:class="[selectedItemIndex === idx ? $style.active : '', item.value === 'separator' ? $style.separator : '']" | ||
:tabindex="item.value === 'separator' ? -1 : 0" | ||
@mouseenter="selectedItemIndex = idx" | ||
@mouseleave="selectedItemIndex = -1" | ||
@focus="selectedItemIndex = idx" | ||
@blur="selectedItemIndex = -1" | ||
@click.stop.prevent="onItemClick(item)" | ||
> | ||
<slot v-if="item.value !== 'separator'" name="option" :option="item"> | ||
<div v-if="item.leadingIcon" :class="$style.leading"> | ||
<component :is="`vue-icon-${item.leadingIcon}`" /> | ||
</div> | ||
|
||
<div :class="$style.value"> | ||
<vue-text :color="selectedItemIndex === idx ? 'text-high-inverse' : 'text-high'">{{ item.label }}</vue-text> | ||
<vue-text v-if="item.description" look="support" color="text-medium-inverse"> | ||
{{ item.description }} | ||
</vue-text> | ||
</div> | ||
|
||
<div v-if="item.trailingIcon" :class="$style.trailing"> | ||
<component :is="`vue-icon-${item.trailingIcon}`" /> | ||
</div> | ||
</slot> | ||
</li> | ||
</ul> | ||
</template> | ||
|
||
<script lang="ts"> | ||
import { computed, defineComponent, ref } from '@vue/composition-api'; | ||
import { IItem } from '@/interfaces/IItem'; | ||
import { getDomRef } from '@/composables/get-dom-ref'; | ||
import VueText from '@/components/typography/VueText/VueText.vue'; | ||
export default defineComponent({ | ||
name: 'VueMenu', | ||
components: { VueText }, | ||
props: { | ||
items: { type: [Array, Array as () => Array<IItem>], required: true }, | ||
}, | ||
setup(props, { emit }) { | ||
const menuRef = getDomRef(null); | ||
const selectedItemIndex = ref(-1); | ||
const maxItems = computed(() => props.items.length); | ||
const onItemClick = (item: IItem) => emit('click', item); | ||
const checkForPropagation = (e: KeyboardEvent) => { | ||
if (['Enter', 'Space', 'ArrowDown', 'ArrowUp'].includes(e.code)) { | ||
e.stopPropagation(); | ||
e.preventDefault(); | ||
} | ||
}; | ||
const handleSelection = (newIndex: number) => { | ||
if (newIndex === maxItems.value) { | ||
selectedItemIndex.value = 0; | ||
} else if (newIndex <= -1) { | ||
selectedItemIndex.value = maxItems.value - 1; | ||
} else { | ||
selectedItemIndex.value = newIndex; | ||
} | ||
}; | ||
const getNewIndex = (direction: string) => { | ||
let newIndex: number = direction === 'down' ? selectedItemIndex.value + 1 : selectedItemIndex.value - 1; | ||
if (props.items[newIndex] && props.items[newIndex].value === 'separator') { | ||
newIndex = direction === 'down' ? newIndex + 1 : newIndex - 1; | ||
} | ||
return newIndex; | ||
}; | ||
const onKeyDown = (e: KeyboardEvent) => { | ||
checkForPropagation(e); | ||
if (['Enter', 'Space'].includes(e.code) && selectedItemIndex.value > -1) { | ||
onItemClick(props.items[selectedItemIndex.value]); | ||
} else if (e.code === 'ArrowDown') { | ||
handleSelection(getNewIndex('down')); | ||
} else if (e.code === 'ArrowUp') { | ||
handleSelection(getNewIndex('up')); | ||
} | ||
}; | ||
/** | ||
* Only exposed for usage in parent components (e.g. dropdown) | ||
* doesn't need any testing | ||
*/ | ||
/* istanbul ignore next */ | ||
const focus = () => { | ||
menuRef.value.firstChild.focus(); | ||
}; | ||
return { | ||
menuRef, | ||
selectedItemIndex, | ||
onItemClick, | ||
onKeyDown, | ||
focus, | ||
}; | ||
}, | ||
}); | ||
</script> | ||
|
||
<style lang="scss" module> | ||
@import '~@/assets/design-system'; | ||
.vueMenu { | ||
position: absolute; | ||
display: inline-flex; | ||
flex-direction: column; | ||
background: $menu-bg; | ||
min-width: $menu-min-width; | ||
padding: $menu-padding; | ||
box-shadow: $menu-shadow; | ||
border-radius: $menu-border-radius; | ||
border: $menu-border; | ||
z-index: $menu-z-index; | ||
> li { | ||
position: relative; | ||
display: flex; | ||
align-items: center; | ||
padding: $menu-item-padding; | ||
color: $menu-item-color; | ||
cursor: pointer; | ||
outline: none; | ||
.leading, | ||
.trailing { | ||
height: $menu-item-icon-size; | ||
i { | ||
width: $menu-item-icon-size; | ||
height: $menu-item-icon-size; | ||
} | ||
} | ||
.leading { | ||
padding-right: $menu-item-icon-size-gap; | ||
} | ||
.trailing { | ||
padding-left: $menu-item-icon-size-gap; | ||
} | ||
.value { | ||
display: flex; | ||
flex-direction: column; | ||
flex: 1; | ||
} | ||
&.active { | ||
background: $menu-item-bg-active; | ||
color: $menu-item-color-active; | ||
} | ||
&.separator { | ||
padding: 0; | ||
height: 0; | ||
border-top: $menu-separator-border; | ||
} | ||
} | ||
} | ||
</style> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,7 @@ | ||
export interface IItem { | ||
label: string; | ||
value: any; | ||
description?: string; | ||
leadingIcon?: string; | ||
trailingIcon?: string; | ||
} |