diff --git a/.storybook/preview.js b/.storybook/preview.js index 9888b23ab..16f8c38fc 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -4,11 +4,9 @@ import * as configs from '../src/config'; import '../src/assets/styles/main.scss'; -for (const configName in configs) { - const config = configs[configName]; - config.configure(); -} +configs.icons.configure(); +Vue.use(configs.constants); Vue.component('font-awesome-icon', FontAwesomeIcon); export const parameters = { diff --git a/.storybook/routeDecorator.js b/.storybook/routeDecorator.js new file mode 100644 index 000000000..059a9cc31 --- /dev/null +++ b/.storybook/routeDecorator.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; + +// abbreviated example of https://github.com/gvaldambrini/storybook-router/blob/master/packages/vue/vue.js + +export default (path = '/') => { + return (storyFn) => { + Vue.use(VueRouter); + const router = new VueRouter({ mode: 'history' }); + + router.replace(path); + + const WrappedComponent = storyFn(); + + return Vue.extend({ + router, + components: { WrappedComponent }, + template: '' + }); + } +} \ No newline at end of file diff --git a/.stylelintrc b/.stylelintrc index 40db42c66..500a7d3e4 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,3 +1,6 @@ { - "extends": "stylelint-config-standard" + "extends": "stylelint-config-standard", + "rules": { + "at-rule-no-unknown": null + } } diff --git a/package-lock.json b/package-lock.json index 7c7257d8c..2ad052226 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@fortawesome/pro-light-svg-icons": "^5.15.3", "@fortawesome/vue-fontawesome": "^2.0.2", "core-js": "^3.6.5", - "vue": "^2.6.11" + "vue": "^2.6.11", + "vue-router": "^3.5.1" }, "devDependencies": { "@babel/core": "^7.13.16", @@ -31438,6 +31439,11 @@ "integrity": "sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=", "dev": true }, + "node_modules/vue-router": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.1.tgz", + "integrity": "sha512-RRQNLT8Mzr8z7eL4p7BtKvRaTSGdCbTy2+Mm5HTJvLGYSSeG9gDzNasJPP/yOYKLy+/cLG/ftrqq5fvkFwBJEw==" + }, "node_modules/vue-style-loader": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz", @@ -58496,6 +58502,11 @@ } } }, + "vue-router": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.1.tgz", + "integrity": "sha512-RRQNLT8Mzr8z7eL4p7BtKvRaTSGdCbTy2+Mm5HTJvLGYSSeG9gDzNasJPP/yOYKLy+/cLG/ftrqq5fvkFwBJEw==" + }, "vue-style-loader": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz", diff --git a/package.json b/package.json index f413e3223..0ece67f26 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "@fortawesome/pro-light-svg-icons": "^5.15.3", "@fortawesome/vue-fontawesome": "^2.0.2", "core-js": "^3.6.5", - "vue": "^2.6.11" + "vue": "^2.6.11", + "vue-router": "^3.5.1" }, "devDependencies": { "@babel/core": "^7.13.16", diff --git a/src/assets/images/iconOverview.svg b/src/assets/images/iconOverview.svg new file mode 100644 index 000000000..0a975c0e9 --- /dev/null +++ b/src/assets/images/iconOverview.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/MainNavigation/MainNavigation.mdx b/src/components/MainNavigation/MainNavigation.mdx new file mode 100644 index 000000000..c7633959d --- /dev/null +++ b/src/components/MainNavigation/MainNavigation.mdx @@ -0,0 +1,92 @@ +import { + Preview, + Story, + ArgsTable, + PRIMARY_STORY, +} from "@storybook/addon-docs/blocks"; +import MainNavigation from "./MainNavigation.vue"; +import MainNavigationItem from "./MainNavigationItem.vue"; +import MainNavigationChildItem from "./MainNavigationChildItem.vue"; + +# MainNavigation + +The main navigation component is used for displaying the main, vertical navigation. + + + + + +## How to Use + +The main navigation renders a semantic `nav` element with a `ul`. It is made to work with `main-navigation-item` components. However, you can also write custom components for its slot as long as the root element is an `li` (for accessibility). + +The main navigation has a single prop, `collapsible` to control whether it has a click event on desktop that animates the navigation on click to slide in to a smaller width. This can give the user more screen real estate. `collapsible` defaults to true. If you do not want this animation, set this to false. + +When `collapsible` is true, the main navigation component passes information about it's expanded/collapsed state through slot props. This requires you passes the expanded prop to the child components in the template (see example below). + +Example of using this component in a template +```html + + + +``` + +# Main Navigation Item + +The main navigation item component is used for displaying a top level navigation item in the main, vertical navigation. + + + + + +## How to Use + +The main navigation item component can render as a traditional link (hooked up to your app's routing) or as a button that will show its child nav items on click. + +If you pass a `to` property, it will render as a link. Otherwise, it will render as a button. + +The `expanded` prop should come from the parent main navigation component and will control whether the item renders at full width on desktop or only renders wide enough to show its icon. + +Example of using this component in a template as a link. +```html + +``` + +If you provide children elements in the default slot, it will bind a click event to the button to hide/show the child nav items. If you do not want to allow the user to collapse this subnavigation (i.e. you want the child items always visible), set the `collapsible` prop on the main navigation item component to false. + +If you want the component to render with the child items collapsed by default, you can use the `subNavCollapsed` prop set to true. + +Example of using this component in a template as a button with child nav items. +```html + + + + +``` + +# MainNavigationChildItem + +The main navigation child item component is used for displaying a sub level navigation item in the main, vertical navigation. + + + + + +## How to Use + +The main navigation child item component renders as link (hooked up to your app's routing). + +Example of using this component in a template +```html + +``` + +## Props + + \ No newline at end of file diff --git a/src/components/MainNavigation/MainNavigation.stories.js b/src/components/MainNavigation/MainNavigation.stories.js new file mode 100644 index 000000000..823e12d19 --- /dev/null +++ b/src/components/MainNavigation/MainNavigation.stories.js @@ -0,0 +1,109 @@ +import routeDecorator from '../../../.storybook/routeDecorator'; + +import MainNavigation from './MainNavigation.vue'; +import MainNavigationItem from './MainNavigationItem.vue'; +import MainNavigationChildItem from './MainNavigationChildItem.vue'; +import mdx from './MainNavigation.mdx'; +import iconOverview from '../../assets/images/iconOverview.svg'; + +export default { + title: 'Components/Main Navigation', + component: MainNavigation, + subcomponents: { MainNavigationItem, MainNavigationChildItem }, + decorators: [ + () => ({ template: '
' }), + routeDecorator() + ], + parameters: { + docs: { + page: mdx + } + }, + argTypes: { + iconSrc: { + table: { + disable: true + }, + control: { + disable: true + } + } + } +}; + +const primaryTemplateStr = (args) => ` + + + + `; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { MainNavigation, MainNavigationChildItem, MainNavigationItem }, + template: primaryTemplateStr(args) +}); + +export const Primary = Template.bind({}); +Primary.args = { + iconSrc: iconOverview +}; +Primary.parameters = { + docs: { + source: { + code: primaryTemplateStr({ iconSrc: 'srcFilePath' }) + } + } +}; + +const itemTemplateStr = ''; +const ItemTemplate = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { MainNavigationItem }, + template: itemTemplateStr +}); +export const Item = ItemTemplate.bind({}); +Item.args = { + title: 'Overview', + iconSrc: iconOverview, + iconAltText: 'Overview icon', + to: '/overview', + expanded: true +}; +Item.parameters = { + docs: { + source: { + code: itemTemplateStr + } + } +}; + +const childTemplateStr = ''; +const ChildItemTemplate = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { MainNavigationChildItem }, + template: childTemplateStr +}); +export const ChildItem = ChildItemTemplate.bind({}); +ChildItem.args = { + title: 'Postcards', + to: '/postcards' +}; +ChildItem.parameters = { + docs: { + source: { + code: childTemplateStr + } + } +}; diff --git a/src/components/MainNavigation/MainNavigation.vue b/src/components/MainNavigation/MainNavigation.vue new file mode 100644 index 000000000..9a441d64f --- /dev/null +++ b/src/components/MainNavigation/MainNavigation.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/src/components/MainNavigation/MainNavigationChildItem.vue b/src/components/MainNavigation/MainNavigationChildItem.vue new file mode 100644 index 000000000..774423386 --- /dev/null +++ b/src/components/MainNavigation/MainNavigationChildItem.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/MainNavigation/MainNavigationItem.vue b/src/components/MainNavigation/MainNavigationItem.vue new file mode 100644 index 000000000..fa2c0f474 --- /dev/null +++ b/src/components/MainNavigation/MainNavigationItem.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/src/components/MainNavigation/__tests__/MainNavigation.spec.js b/src/components/MainNavigation/__tests__/MainNavigation.spec.js new file mode 100644 index 000000000..b7921da3a --- /dev/null +++ b/src/components/MainNavigation/__tests__/MainNavigation.spec.js @@ -0,0 +1,45 @@ +import '@testing-library/jest-dom'; + +import { render, fireEvent } from '@testing-library/vue'; +import MainNavigation from '../MainNavigation.vue'; + +const renderComponent = (options, configure = null) => render(MainNavigation, { ...options }, configure); + +describe('Main Navigation', () => { + + it('renders correctly', () => { + const { queryByRole } = renderComponent(); + + const nav = queryByRole('navigation'); + expect(nav).toBeInTheDocument(); + }); + + describe('when collapsible', () => { + + it('collapses when clicked', async () => { + const { getByRole } = renderComponent(); + + const list = getByRole('list'); + await fireEvent.click(list); + + expect(list).toHaveClass('collapsed'); + }); + + }); + + describe('when not collapsible', () => { + + it('does not collapse when clicked', async () => { + const props = { collapsible: false }; + const { getByRole } = renderComponent({ props }); + + const list = getByRole('list'); + await fireEvent.click(list); + + expect(list).not.toHaveClass('collapsed'); + }); + + }); + +}); + diff --git a/src/components/MainNavigation/__tests__/MainNavigationChildItem.spec.js b/src/components/MainNavigation/__tests__/MainNavigationChildItem.spec.js new file mode 100644 index 000000000..2f7b9065b --- /dev/null +++ b/src/components/MainNavigation/__tests__/MainNavigationChildItem.spec.js @@ -0,0 +1,44 @@ +import '@testing-library/jest-dom'; + +import { render } from '@testing-library/vue'; +import MainNavigationChildItem from '../MainNavigationChildItem.vue'; + +const initialProps = { + title: 'Overview', + to: '/overview' +}; +const routes = [ + { path: '/overview', component: { template: '
Overview
' } }, + { path: '/about', component: { template: '
About
' } }, + { path: '/', component: { template: '
Home
' } } +]; + +const renderComponent = (options, configure = null) => render(MainNavigationChildItem, { ...options, routes }, configure); + +describe('Main Navigation Child Item', () => { + + it('renders correctly', () => { + const props = initialProps; + const { queryByText, queryByRole } = renderComponent({ props }); + + let item = queryByText(props.title); + expect(item).toBeInTheDocument(); + + item = queryByRole('link'); + expect(item).toBeInTheDocument(); + }); + + it('adds the correct classes when the item is active', () => { + const props = initialProps; + + const configureWithInitialRoute = (_vue, _store, router) => { + router.push('/overview'); + }; + const { queryByTestId } = renderComponent({ props }, configureWithInitialRoute); + + const navItem = queryByTestId('nav-child-item'); + expect(navItem).toHaveClass('font-medium bg-white-300 rounded-l-full'); + }); + +}); + diff --git a/src/components/MainNavigation/__tests__/MainNavigationItem.spec.js b/src/components/MainNavigation/__tests__/MainNavigationItem.spec.js new file mode 100644 index 000000000..2077c0112 --- /dev/null +++ b/src/components/MainNavigation/__tests__/MainNavigationItem.spec.js @@ -0,0 +1,255 @@ +import '@testing-library/jest-dom'; +import Vue from 'vue'; + +import { render, fireEvent, waitFor } from '@testing-library/vue'; +import { constants } from '../../../config'; +import MainNavigationItem from '../MainNavigationItem.vue'; + +Vue.config.silent = true; // suppressing warnings due to known issue with dynamic components and native events + +const configureVue = (vue) => { + vue.use(constants); +}; + +const routes = []; + +const initialProps = { + title: 'Overview', + iconSrc: 'path/to/image.svg', + iconAltText: 'very descriptive alt text', + active: false, + subNavCollapsed: false +}; + +const renderComponent = (options, configure = configureVue) => render(MainNavigationItem, { ...options, routes }, configure); + +describe('Main Navigation Item', () => { + + it('renders correctly', () => { + const props = initialProps; + const { queryByText, queryByAltText } = renderComponent({ props }); + + const item = queryByText(props.title); + expect(item).toBeInTheDocument(); + + const image = queryByAltText(props.iconAltText); + expect(image).toHaveAttribute('src', props.iconSrc); + }); + + describe('with a to prop', () => { + + it('renders a link', () => { + const props = { + ...initialProps, + to: '/overview' + }; + const { queryByRole } = renderComponent({ props }); + + const link = queryByRole('link'); + expect(link).toBeInTheDocument(); + + const button = queryByRole('button'); + expect(button).not.toBeInTheDocument(); + }); + + }); + + describe('without a to prop', () => { + + it('renders a button', () => { + const props = initialProps; + const { queryByRole } = renderComponent({ props }); + + const link = queryByRole('link'); + expect(link).not.toBeInTheDocument(); + + const button = queryByRole('button'); + expect(button).toBeInTheDocument(); + }); + + describe('without chld nav items', () => { + + it('does not render a collapse/expand icon', () => { + const props = initialProps; + const { queryByAltText } = renderComponent({ props }); + + const image = queryByAltText(/Collapse|Expand/i); + expect(image).not.toBeInTheDocument(); + }); + + }); + + describe('with child nav items', () => { + + let slots; + let slotContent; + + beforeEach(() => { + slotContent = 'I\'m a child'; + slots = { default: [`
  • ${slotContent}
`] }; + }); + + it('renders the slot content', () => { + const props = initialProps; + const { queryByText } = renderComponent({ props, slots }); + + const slot = queryByText(slotContent); + expect(slot).toBeInTheDocument(); + }); + + describe('when collapsible', () => { + + it('renders a collapse/expand icon', () => { + const props = initialProps; + const { queryByAltText } = renderComponent({ props, slots }); + + const image = queryByAltText(/Collapse|Expand/i); + expect(image).toBeInTheDocument(); + }); + + describe('and expanded', () => { + + let props; + + beforeEach(() => { + props = { + ...initialProps, + subNavCollapsed: false + }; + }); + + it('renders a collapse icon', () => { + const { queryByAltText } = renderComponent({ props, slots }); + + const image = queryByAltText(/Collapse/i); + expect(image).toBeInTheDocument(); + }); + + it('collapses the sub navigation when clicked', async () => { + const { getByText, queryByText, queryByAltText } = renderComponent({ props, slots }); + + const button = getByText(props.title); + fireEvent.click(button); + + await waitFor(() => { + const slot = queryByText(slotContent); + expect(slot).not.toBeInTheDocument(); + }); + + await waitFor(() => { + const image = queryByAltText(/Expand/i); + expect(image).toBeInTheDocument(); + }); + }); + + }); + + describe('and collapsed', () => { + + let props; + + beforeEach(() => { + props = { + ...initialProps, + subNavCollapsed: true + }; + }); + + it('renders an expand icon', () => { + const { queryByAltText } = renderComponent({ props, slots }); + + const image = queryByAltText(/Expand/i); + expect(image).toBeInTheDocument(); + }); + + it('expands the sub navigation when clicked', async () => { + const { getByText, queryByText, queryByAltText } = renderComponent({ props, slots }); + + const button = getByText(props.title); + fireEvent.click(button); + + await waitFor(() => { + const slot = queryByText(slotContent); + expect(slot).toBeInTheDocument(); + }); + + await waitFor(() => { + const image = queryByAltText(/Collapse/i); + expect(image).toBeInTheDocument(); + }); + }); + + }); + + }); + + describe('when not collapsible', () => { + + let props; + + beforeEach(() => { + props = { + ...initialProps, + collapsible: false + }; + }); + + it('does not render a collapse/expand icon', () => { + const { queryByAltText } = renderComponent({ props, slots }); + + const image = queryByAltText(/Collapse|Expand/i); + expect(image).not.toBeInTheDocument(); + }); + + it('does not collapse the sub navigation when clicked', async () => { + const { getByText, queryByText } = renderComponent({ props, slots }); + + const button = getByText(props.title); + fireEvent.click(button); + + await waitFor(() => { + const slot = queryByText(slotContent); + expect(slot).toBeInTheDocument(); + }); + }); + + }); + + }); + + }); + + describe('when parent nav is expanded', () => { + + it('expands its content appropriately', () => { + const props = initialProps; + const { queryByTestId } = renderComponent({ props }); + + const collapsibleElement = queryByTestId('collapsibleElement'); + expect(collapsibleElement).toHaveClass('expanded'); + }); + + }); + + describe('when parent nav is collapsed', () => { + + let props; + + beforeEach(() => { + props = { + ...initialProps, + expanded: false + }; + }); + + it('collapses its content appropriately', () => { + const { queryByTestId } = renderComponent({ props }); + + const collapsibleElement = queryByTestId('collapsibleElement'); + expect(collapsibleElement).not.toHaveClass('expanded'); + }); + + }); + +}); + diff --git a/src/config/constants.js b/src/config/constants.js new file mode 100644 index 000000000..515fbfdd8 --- /dev/null +++ b/src/config/constants.js @@ -0,0 +1,11 @@ +const constants = { + lobAssetsUrl: 'https://s3-us-west-2.amazonaws.com/public.lob.com' +}; + +constants.install = function (Vue) { + Vue.prototype.$getConst = (key) => { + return constants[key]; + }; +}; + +export default constants; diff --git a/src/config/index.js b/src/config/index.js index 78d9efae7..17a7d2b3d 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -1 +1,2 @@ export { default as icons } from './icons'; +export { default as constants } from './constants'; diff --git a/src/main.js b/src/main.js index d5c87f319..b202ce6c5 100644 --- a/src/main.js +++ b/src/main.js @@ -1,15 +1,15 @@ +import VueRouter from 'vue-router'; import './assets/styles/main.scss'; import * as components from './components'; import * as configs from './config'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; -for (const configName in configs) { - const config = configs[configName]; - config.configure(); -} +configs.icons.configure(); const ComponentLibrary = { install (Vue) { + Vue.use(VueRouter); + Vue.use(configs.constants); Vue.component('font-awesome-icon', FontAwesomeIcon); // components