From 7b396dea1528f101f707365d837cbbbb1446c229 Mon Sep 17 00:00:00 2001 From: Patrick Cate Date: Tue, 21 Dec 2021 01:32:33 -0500 Subject: [PATCH] feat: implement `UsaBanner` and `UsaBannerContent` components ISSUES CLOSED: #8, #10 --- src/components/UsaBanner/UsaBanner.stories.js | 157 ++++++++++ src/components/UsaBanner/UsaBanner.test.js | 274 ++++++++++++++++++ src/components/UsaBanner/UsaBanner.vue | 109 +++++++ src/components/UsaBanner/index.js | 4 + .../UsaBannerContent.stories.js | 52 ++++ .../UsaBannerContent/UsaBannerContent.test.js | 69 +++++ .../UsaBannerContent/UsaBannerContent.vue | 82 ++++++ src/components/UsaBannerContent/index.js | 4 + src/composables/useToggle.js | 45 +++ 9 files changed, 796 insertions(+) create mode 100644 src/components/UsaBanner/UsaBanner.stories.js create mode 100644 src/components/UsaBanner/UsaBanner.test.js create mode 100644 src/components/UsaBanner/UsaBanner.vue create mode 100644 src/components/UsaBanner/index.js create mode 100644 src/components/UsaBannerContent/UsaBannerContent.stories.js create mode 100644 src/components/UsaBannerContent/UsaBannerContent.test.js create mode 100644 src/components/UsaBannerContent/UsaBannerContent.vue create mode 100644 src/components/UsaBannerContent/index.js create mode 100644 src/composables/useToggle.js diff --git a/src/components/UsaBanner/UsaBanner.stories.js b/src/components/UsaBanner/UsaBanner.stories.js new file mode 100644 index 00000000..c73f7a0c --- /dev/null +++ b/src/components/UsaBanner/UsaBanner.stories.js @@ -0,0 +1,157 @@ +import UsaBanner from './UsaBanner.vue' + +const defaultProps = { + open: false, + id: '', + ariaLabel: 'Official government website', + headerText: 'An official website of the United States government', + actionText: "Here's how you know", + customClasses: { + accordion: [], + bannerHeader: [], + bannerInner: [], + button: [], + bannerContent: [], + }, +} + +export default { + component: UsaBanner, + title: 'Components/UsaBanner', + argTypes: { + open: { + control: { type: 'boolean' }, + }, + id: { + control: { type: 'text' }, + }, + ariaLabel: { + control: { type: 'text' }, + }, + headerText: { + control: { type: 'text' }, + }, + actionText: { + control: { type: 'text' }, + }, + customClasses: { + control: { type: 'object' }, + }, + flagSlot: { + control: { type: 'text' }, + }, + buttonSlot: { + control: { type: 'text' }, + }, + defaultSlot: { + control: { type: 'text' }, + }, + }, + args: { + open: defaultProps.open, + id: defaultProps.id, + ariaLabel: defaultProps.ariaLabel, + headerText: defaultProps.headerText, + actionText: defaultProps.actionText, + customClasses: defaultProps.customClasses, + flagSlot: '', + buttonSlot: '', + defaultSlot: '', + }, +} + +const DefaultTemplate = (args, { argTypes }) => ({ + components: { UsaBanner }, + props: Object.keys(argTypes), + setup() { + return { ...args } + }, + template: ` + + + + `, +}) + +export const DefaultBanner = DefaultTemplate.bind({}) +DefaultBanner.args = { + ...defaultProps, +} +DefaultBanner.storyName = 'Default' + +export const DefaultOpenBanner = DefaultTemplate.bind({}) +DefaultOpenBanner.args = { + ...defaultProps, + open: true, +} +DefaultOpenBanner.storyName = 'Open by Default' + +export const CustomIdBanner = DefaultTemplate.bind({}) +CustomIdBanner.args = { + ...defaultProps, + id: 'custom-id', +} +CustomIdBanner.storyName = 'Custom ID' + +export const FlagSlotBanner = DefaultTemplate.bind({}) +FlagSlotBanner.args = { + ...defaultProps, + flagSlot: 'Custom Flag Icon', +} +FlagSlotBanner.storyName = 'Flag Slot' + +export const ButtonSlotBanner = DefaultTemplate.bind({}) +ButtonSlotBanner.args = { + ...defaultProps, + buttonSlot: 'Custom Button Text', +} +ButtonSlotBanner.storyName = 'Button Slot' + +export const DefaultSlotBanner = DefaultTemplate.bind({}) +DefaultSlotBanner.args = { + ...defaultProps, + open: true, + defaultSlot: 'Custom Banner Content', +} +DefaultSlotBanner.storyName = 'Default Slot' + +export const CustomClassesBanner = DefaultTemplate.bind({}) +CustomClassesBanner.args = { + ...defaultProps, + customClasses: { + accordion: ['custom-accordion-class'], + bannerHeader: ['custom-banner-header-class'], + bannerInner: ['custom-banner-inner-class'], + button: ['custom-button-class'], + bannerContent: ['custom-banner-content-class'], + }, +} +CustomClassesBanner.storyName = 'Custom Classes' + +export const AriaLabelBanner = DefaultTemplate.bind({}) +AriaLabelBanner.args = { + ...defaultProps, + ariaLabel: 'Custom aria label', +} +AriaLabelBanner.storyName = 'Custom Aria Label' + +export const HeaderTextBanner = DefaultTemplate.bind({}) +HeaderTextBanner.args = { + ...defaultProps, + headerText: 'Custom header text', +} +HeaderTextBanner.storyName = 'Custom Header Text' + +export const ActionTextlBanner = DefaultTemplate.bind({}) +ActionTextlBanner.args = { + ...defaultProps, + actionText: 'Custom action text', +} +ActionTextlBanner.storyName = 'Custom Action Text' diff --git a/src/components/UsaBanner/UsaBanner.test.js b/src/components/UsaBanner/UsaBanner.test.js new file mode 100644 index 00000000..3dc20dd7 --- /dev/null +++ b/src/components/UsaBanner/UsaBanner.test.js @@ -0,0 +1,274 @@ +import '@module/uswds/dist/css/uswds.min.css' +import { mount } from '@cypress/vue' +import { h } from 'vue' +import UsaBanner from './UsaBanner.vue' + +describe('UsaBanner', () => { + it('renders the component', () => { + mount(UsaBanner, {}) + + cy.get('section.usa-banner') + .should('have.attr', 'aria-label') + .and('contain', 'Official government website') + cy.get('.usa-accordion').should('exist') + cy.get('header.usa-banner__header').should('exist') + cy.get('.usa-banner__inner').should('exist') + + cy.get('img.usa-banner__header-flag') + .should('have.attr', 'alt') + .and('contain', 'U.S. flag') + cy.get('img.usa-banner__header-flag') + .should('have.attr', 'src') + .and('contain', '/assets/img/us_flag_small.png') + + cy.get('.usa-banner__header-text').should( + 'contain', + 'An official website of the United States government' + ) + + // Check that the default grid classes exist. + cy.get('.grid-col-auto').should('exist') + cy.get('.grid-col-fill').should('exist') + cy.get(`.tablet\\:grid-col-auto`).should('exist') + + cy.get('p.usa-banner__header-action') + .should('have.attr', 'aria-hidden') + .and('contain', 'true') + cy.get('p.usa-banner__header-action').should( + 'contain', + "Here's how you know" + ) + cy.get('button.usa-banner__button') + .should('have.class', 'usa-accordion__button') + .and('have.attr', 'aria-expanded') + .and('contain', 'false') + + cy.get('button.usa-banner__button') + .should('have.attr', 'aria-controls') + .and('not.be.empty') + + cy.get('span.usa-banner__button-text').should( + 'contain', + "Here's how you know" + ) + + cy.get('.usa-banner__content') + .should('have.class', 'usa-accordion__content') + .and('have.attr', 'id') + + cy.get('.usa-banner__content') + .and('not.be.visible') + .and('have.attr', 'hidden') + }) + + it('uses custom prop text', () => { + mount(UsaBanner, { + props: { + open: true, + ariaLabel: 'Test arial label', + headerText: 'Test header text', + actionText: 'Text action text', + }, + }) + + cy.get('.usa-banner') + .should('have.attr', 'aria-label') + .and('contain', 'Test arial label') + + cy.get('.usa-banner__header').should( + 'have.class', + 'usa-banner__header--expanded' + ) + + cy.get('.usa-banner__header-text').should('contain', 'Test header text') + + cy.get('.usa-banner__header-action').should('contain', 'Text action text') + cy.get('.usa-banner__button-text').should('contain', 'Text action text') + + cy.get('.usa-banner__content').should('not.have.attr', 'hidden') + cy.get('.usa-banner__content').and('be.visible') + }) + + it('uses custom CSS classes', () => { + mount(UsaBanner, { + props: { + customClasses: { + accordion: ['test-banner-class'], + bannerHeader: ['test-banner-header-class'], + bannerInner: ['test-banner-inner-class'], + button: ['test-button-class'], + bannerContent: ['test-banner-content-class'], + }, + }, + }) + + cy.get('.test-banner-class').should('exist') + cy.get('.test-banner-header-class').should('exist') + cy.get('.test-banner-inner-class').should('exist') + cy.get('.test-button-class').should('exist') + cy.get('.test-banner-content-class').should('exist') + }) + + it('click button toggle banner open/close', () => { + mount(UsaBanner, { + props: { + id: 'test-id', + }, + }) + + // Should be closed. + cy.get('.usa-banner__header').should( + 'not.have.class', + 'usa-banner__header--expanded' + ) + + cy.get('.usa-banner__button') + .as('button') + .should('have.attr', 'aria-expanded') + .and('contain', 'false') + + cy.get('@button') + .should('have.attr', 'aria-controls') + .and('contain', 'test-id') + + cy.get('.usa-banner__content') + .should('have.id', 'test-id') + .and('have.attr', 'hidden') + + cy.get('.usa-banner__content').should('not.be.visible') + + // Toggle open. + cy.get('@button').click() + + cy.get('.usa-banner__header').should( + 'have.class', + 'usa-banner__header--expanded' + ) + + // Should now be open. + cy.get('@button') + .should('have.attr', 'aria-expanded') + .and('contain', 'true') + + cy.get('@button') + .should('have.attr', 'aria-controls') + .and('contain', 'test-id') + + cy.get('.usa-banner__content') + .should('have.id', 'test-id') + .and('not.have.attr', 'hidden') + + cy.get('.usa-banner__content').should('be.visible') + + // Toggle close. + cy.get('@button').click() + + cy.get('.usa-banner__header').should( + 'not.have.class', + 'usa-banner__header--expanded' + ) + + cy.get('.usa-banner__button') + .as('button') + .should('have.attr', 'aria-expanded') + .and('contain', 'false') + + cy.get('@button') + .should('have.attr', 'aria-controls') + .and('contain', 'test-id') + + cy.get('.usa-banner__content') + .should('have.id', 'test-id') + .and('have.attr', 'hidden') + + cy.get('.usa-banner__content').should('not.be.visible') + }) + + it('v-model binds to open prop and emits update event', () => { + const wrapper = mount(UsaBanner, { + props: { + open: false, + 'onUpdate:open': async open => { + await wrapper.vue().then(vm => { + vm.setProps({ open: open }) + }) + }, + }, + }).as('wrapper') + + cy.get('.usa-banner__button').as('button').click() + + cy.get('@wrapper') + .vue() + .then(vm => { + expect(vm.emitted()).to.have.property('update:open') + const currentOpenEvent = vm.emitted('update:open') + expect(currentOpenEvent).to.have.length(1) + expect(currentOpenEvent[currentOpenEvent.length - 1]).to.contain(true) + }) + + cy.get('.usa-banner__button').as('button').click() + + cy.get('@wrapper') + .vue() + .then(vm => { + expect(vm.emitted()).to.have.property('update:open') + const currentOpenEvent = vm.emitted('update:open') + expect(currentOpenEvent).to.have.length(2) + expect(currentOpenEvent[currentOpenEvent.length - 1]).to.contain(false) + }) + }) + + it('uses custom slot content', () => { + mount(UsaBanner, { + props: { + open: true, + actionText: 'Scoped slot button action text', + }, + slots: { + flag: () => 'Test flag slot content', + button: props => + h( + 'span', + { + 'v-slot:button': 'props', + }, + `${props.isOpen ? 'open' : 'closed'} - ${ + props.actionText + } - Test button slot content` + ), + default: () => 'Test default slot content', + }, + }) + + cy.get('.usa-banner__inner > div:first-child').should( + 'contain', + 'Test flag slot content' + ) + cy.get('.usa-accordion__button').should( + 'contain', + 'open - Scoped slot button action text - Test button slot content' + ) + cy.get('.usa-banner__content').should( + 'contain', + 'Test default slot content' + ) + }) + + it('uses injected prop values', () => { + mount(UsaBanner, { + props: {}, + global: { + provide: { + 'vueUswds.svgSpritePath': '/test.svg', + 'vueUswds.prefixSeparator': '@', + 'vueUswds.gridNamespace': 'test-grid-namespace-', + }, + }, + }) + + cy.get('.test-grid-namespace-col-auto').should('exist') + cy.get('.test-grid-namespace-col-fill').should('exist') + cy.get(`.tablet\\@test-grid-namespace-col-auto`).should('exist') + }) +}) diff --git a/src/components/UsaBanner/UsaBanner.vue b/src/components/UsaBanner/UsaBanner.vue new file mode 100644 index 00000000..986db8ed --- /dev/null +++ b/src/components/UsaBanner/UsaBanner.vue @@ -0,0 +1,109 @@ + + + diff --git a/src/components/UsaBanner/index.js b/src/components/UsaBanner/index.js new file mode 100644 index 00000000..8b9f01bd --- /dev/null +++ b/src/components/UsaBanner/index.js @@ -0,0 +1,4 @@ +import UsaBanner from './UsaBanner.vue' + +export { UsaBanner } +export default UsaBanner diff --git a/src/components/UsaBannerContent/UsaBannerContent.stories.js b/src/components/UsaBannerContent/UsaBannerContent.stories.js new file mode 100644 index 00000000..83f19cfd --- /dev/null +++ b/src/components/UsaBannerContent/UsaBannerContent.stories.js @@ -0,0 +1,52 @@ +import UsaBannerContent from './UsaBannerContent.vue' + +const defaultProps = {} + +export default { + component: UsaBannerContent, + title: 'Components/UsaBannerContent', + argTypes: { + tldIconSlot: { + control: { type: 'text' }, + }, + tldDescriptionSlot: { + control: { type: 'text' }, + }, + httpsIconSlot: { + control: { type: 'text' }, + }, + httpsDescriptionSlot: { + control: { type: 'text' }, + }, + }, +} + +const DefaultTemplate = (args, { argTypes }) => ({ + components: { UsaBannerContent }, + props: Object.keys(argTypes), + setup() { + return { ...args } + }, + template: ` + + + + + `, +}) + +export const DefaultBannerContent = DefaultTemplate.bind({}) +DefaultBannerContent.args = { + ...defaultProps, +} +DefaultBannerContent.storyName = 'Default' + +export const CustomSlotBannerContent = DefaultTemplate.bind({}) +CustomSlotBannerContent.args = { + ...defaultProps, + tldIconSlot: 'Custom TLD Icon', + tldDescriptionSlot: 'Custom TLD Desciption', + httpsIconSlot: 'Custom HTTPS Icon', + httpsDescriptionSlot: 'Custom HTTPS Desciption', +} +CustomSlotBannerContent.storyName = 'Custom Slot Content' diff --git a/src/components/UsaBannerContent/UsaBannerContent.test.js b/src/components/UsaBannerContent/UsaBannerContent.test.js new file mode 100644 index 00000000..371f91fe --- /dev/null +++ b/src/components/UsaBannerContent/UsaBannerContent.test.js @@ -0,0 +1,69 @@ +import '@module/uswds/dist/css/uswds.min.css' +import { mount } from '@cypress/vue' +import UsaBannerContent from './UsaBannerContent.vue' + +describe('UsaBannerContent', () => { + it('renders the component', () => { + mount(UsaBannerContent, {}) + + cy.get('[data-v-app] > div').should('exist') + cy.get('.usa-banner__guidance').should('have.length', 2) + cy.get('.usa-media-block__body').should('have.length', 2) + + cy.get('img.usa-banner__icon[src$="icon-dot-gov.svg"]') + .as('tldIcon') + .should('have.class', 'usa-media-block__img') + .and('have.attr', 'role') + .and('contain', 'img') + + cy.get('@tldIcon').should('have.attr', 'alt').and('be.empty') + cy.get('@tldIcon').should('have.attr', 'aria-hidden').and('contain', 'true') + + cy.get('img.usa-banner__icon[src$="icon-https.svg"]') + .as('httpsIcon') + .should('have.class', 'usa-media-block__img') + .and('have.attr', 'role') + .and('contain', 'img') + + cy.get('@httpsIcon').should('have.attr', 'alt').and('be.empty') + cy.get('@httpsIcon') + .should('have.attr', 'aria-hidden') + .and('contain', 'true') + + cy.get('svg.usa-banner__lock-image') + .should('have.attr', 'focusable') + .and('contain', 'false') + }) + + it('uses custom slot content', () => { + mount(UsaBannerContent, { + slots: { + tldIcon: () => 'test tld icon', + tldDescription: () => 'test tld description', + httpsIcon: () => 'test https icon', + httpsDescription: () => 'test https description', + }, + }) + + cy.get('.usa-banner__guidance').should('contain', 'test tld icon') + cy.get('.usa-media-block__body').should('contain', 'test tld description') + cy.get('.usa-banner__guidance').should('contain', 'test https icon') + cy.get('.usa-media-block__body').should('contain', 'test https description') + }) + + it('uses injected prop values', () => { + mount(UsaBannerContent, { + global: { + provide: { + 'vueUswds.svgSpritePath': '/test.svg', + 'vueUswds.prefixSeparator': '@', + 'vueUswds.gridNamespace': 'test-grid-namespace-', + }, + }, + }) + + cy.get('.test-grid-namespace-row').should('exist') + cy.get('.test-grid-namespace-gap-lg').should('exist') + cy.get(`.tablet\\@test-grid-namespace-col-6`).should('have.length', 2) + }) +}) diff --git a/src/components/UsaBannerContent/UsaBannerContent.vue b/src/components/UsaBannerContent/UsaBannerContent.vue new file mode 100644 index 00000000..a56d3518 --- /dev/null +++ b/src/components/UsaBannerContent/UsaBannerContent.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/components/UsaBannerContent/index.js b/src/components/UsaBannerContent/index.js new file mode 100644 index 00000000..2e7a2884 --- /dev/null +++ b/src/components/UsaBannerContent/index.js @@ -0,0 +1,4 @@ +import UsaBannerContent from './UsaBannerContent.vue' + +export { UsaBannerContent } +export default UsaBannerContent diff --git a/src/composables/useToggle.js b/src/composables/useToggle.js new file mode 100644 index 00000000..098e5f9b --- /dev/null +++ b/src/composables/useToggle.js @@ -0,0 +1,45 @@ +import { ref, computed, readonly, watch } from 'vue' +import { nextId } from '@/utils/unique-id.js' + +export default (_id, idPrefix = '', defaultOpen = false, emit) => { + const propValue = ref(defaultOpen) + const isOpen = ref(propValue.value) + + const toggleId = computed(() => _id || nextId(idPrefix)) + + const closeContent = () => { + isOpen.value = false + } + + const openContent = () => { + isOpen.value = true + } + + const toggleContent = () => { + if (isOpen.value) { + closeContent() + } else { + openContent() + } + } + + watch(isOpen, () => { + if (emit) { + emit('update:open', isOpen.value) + } + }) + + watch(propValue, () => { + if (propValue.value !== isOpen.value) { + toggleContent() + } + }) + + return { + isOpen: readonly(isOpen), + toggleId: readonly(toggleId), + closeContent, + openContent, + toggleContent, + } +}