Skip to content

Commit

Permalink
feat(vue-menu): apply re-design, update tests and stories
Browse files Browse the repository at this point in the history
  • Loading branch information
devCrossNet committed Jul 14, 2021
1 parent 9cbe4a1 commit c1a8705
Show file tree
Hide file tree
Showing 9 changed files with 320 additions and 2 deletions.
2 changes: 1 addition & 1 deletion src/assets/design-system/docs/components/ComponentDocs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<vue-text look="description">{{ story }}</vue-text>
</vue-stack>

<vue-box padding="0">
<vue-box padding="0" :style="{ position: 'relative' }">
<slot />
</vue-box>
</vue-stack>
Expand Down
4 changes: 3 additions & 1 deletion src/assets/design-system/themes/light.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,17 @@

/* Borders */
--brand-primary-border-color: #{palette-color-level('neutral', 3)};
--brand-secondary-border-color: #{palette-color-level('neutral', 2)};
--brand-primary-border-color-inverse: #{palette-color-level('neutral', 6)};
--brand-secondary-border-color-inverse: #{palette-color-level('neutral', 5)};
--brand-border-radius-sm: #{$space-4};
--brand-border-radius-md: #{$space-8};
--brand-border-radius-lg: #{$space-16};

/* Box Shadows */
--brand-elevation-1: 0 -1px 2px rgba(19, 20, 22, 0.08), 0 3px 5px rgba(19, 20, 22, 0.12);
--brand-elevation-2: 0 -1px 3px rgba(19, 20, 22, 0.1), 0 4px 6px rgba(19, 20, 22, 0.2);
--brand-elevation-3: 0 -1px 3px rgba(19, 20, 22, 0.1), 0 4px 6px rgba(19, 20, 22, 0.2);
--brand-elevation-3: 0px -2px 4px rgba(0, 0, 0, 0.08), 0px 6px 10px rgba(19, 20, 22, 0.15);
--brand-shadow-outline: 0 0 0 #{$space-2} #{palette-color-level('neutral', 0)},
0 0 0 #{$space-4} #{palette-color-level('primary', 3)};

Expand Down
2 changes: 2 additions & 0 deletions src/assets/design-system/variables/_spacings.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ $space-unit: 0.8rem;
$space-2: $space-unit * 0.25; // 2px
$space-4: $space-unit * 0.5; // 4px
$space-8: $space-unit; // 8px
$space-10: $space-unit * 1.25; // 10px
$space-12: $space-unit * 1.5; // 12px
$space-16: $space-unit * 2; // 16px
$space-20: $space-unit * 2.5; // 20px
Expand All @@ -27,6 +28,7 @@ $spacings: (
'2': $space-2,
'4': $space-4,
'8': $space-8,
'10': $space-10,
'12': $space-12,
'16': $space-16,
'20': $space-20,
Expand Down
1 change: 1 addition & 0 deletions src/assets/design-system/variables/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@
@import 'components/atoms/star-rating';
@import 'components/molecules/back-to-top';
@import 'components/molecules/pop-over';
@import 'components/data-display/menu';
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);
82 changes: 82 additions & 0 deletions src/components/data-display/VueMenu/VueMenu.spec.ts
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',
},
],
]);
});
});
41 changes: 41 additions & 0 deletions src/components/data-display/VueMenu/VueMenu.stories.ts
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 },
},
},
);
169 changes: 169 additions & 0 deletions src/components/data-display/VueMenu/VueMenu.vue
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>
3 changes: 3 additions & 0 deletions src/interfaces/IItem.ts
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;
}

0 comments on commit c1a8705

Please sign in to comment.