diff --git a/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.stories.js b/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.stories.js
new file mode 100644
index 00000000..459da39a
--- /dev/null
+++ b/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.stories.js
@@ -0,0 +1,147 @@
+import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'
+import UsaFooterCollapsibleMenu from './UsaFooterCollapsibleMenu.vue'
+
+const testItems = [
+ {
+ text: 'Test Item 1',
+ children: [
+ {
+ href: '/test-1/test-1-1',
+ text: 'Test Item 1.1',
+ },
+ {
+ href: '/test-1/test-1-2',
+ text: 'Test Item 1.2',
+ },
+ {
+ href: '/test-1/test-1-3',
+ text: 'Test Item 1.3',
+ },
+ ],
+ },
+ {
+ text: 'Test Item 2',
+ children: [
+ {
+ to: '/test-2/test-2-1',
+ text: 'Test Item 2.1',
+ },
+ {
+ to: '/test-2/test-2-2',
+ routerComponentName: 'nuxt-link',
+ text: 'Test Item 2.2',
+ },
+ {
+ href: '/test-2/test-2-3',
+ text: 'Test Item 2.3',
+ },
+ ],
+ },
+ {
+ id: 'test-3',
+ text: 'Test Item 3',
+ children: [
+ {
+ href: '/test-3/test-3-1',
+ text: 'Test Item 3.1',
+ },
+ {
+ href: '/test-3/test-3-2',
+ text: 'Test Item 3.2',
+ },
+ {
+ href: '/test-3/test-3-3',
+ text: 'Test Item 3.3',
+ },
+ ],
+ },
+]
+
+const defaultProps = {
+ items: UsaFooterCollapsibleMenu.props.items.default(),
+ headingTag: UsaFooterCollapsibleMenu.props.headingTag.default,
+ customClasses: UsaFooterCollapsibleMenu.props.customClasses.default(),
+}
+
+export default {
+ component: UsaFooterCollapsibleMenu,
+ title: 'Components/UsaFooterCollapsibleMenu',
+ argTypes: {
+ items: {
+ control: { type: 'object' },
+ },
+ headingTag: {
+ control: { type: 'text' },
+ },
+ customClasses: {
+ control: { type: 'object' },
+ },
+ },
+ args: {
+ items: defaultProps.items,
+ headingTag: defaultProps.headingTag,
+ customClasses: defaultProps.customClasses,
+ },
+ parameters: {
+ viewport: {
+ viewports: INITIAL_VIEWPORTS,
+ },
+ },
+ decorators: [
+ () => ({
+ template:
+ '',
+ }),
+ ],
+}
+
+const DefaultTemplate = (args, { argTypes }) => ({
+ components: { UsaFooterCollapsibleMenu },
+ props: Object.keys(argTypes),
+ setup() {
+ return { ...args }
+ },
+ template: ``,
+})
+
+export const DefaultFooterCollapsibleMenu = DefaultTemplate.bind({})
+DefaultFooterCollapsibleMenu.args = {
+ ...defaultProps,
+ items: testItems,
+}
+DefaultFooterCollapsibleMenu.storyName = 'Default'
+
+export const HeadingTagFooterCollapsibleMenu = DefaultTemplate.bind({})
+HeadingTagFooterCollapsibleMenu.args = {
+ ...defaultProps,
+ items: testItems,
+ headingTag: 'h3',
+}
+HeadingTagFooterCollapsibleMenu.storyName = 'Custom Heading Tag'
+
+export const MobileCollapsibleMenu = DefaultTemplate.bind({})
+MobileCollapsibleMenu.args = {
+ ...defaultProps,
+ items: testItems,
+}
+MobileCollapsibleMenu.parameters = {
+ viewport: {
+ defaultViewport: 'iphone6',
+ },
+}
+MobileCollapsibleMenu.storyName = 'Mobile Collapsible'
+
+export const CustomClassesFooterCollapsibleMenu = DefaultTemplate.bind({})
+CustomClassesFooterCollapsibleMenu.args = {
+ ...defaultProps,
+ items: testItems,
+ customClasses: {
+ gridRow: ['test-grid-row-class'],
+ gridCol: ['test-grid-col-class'],
+ },
+}
+CustomClassesFooterCollapsibleMenu.storyName = 'Custom CSS Classes'
diff --git a/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.test.js b/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.test.js
new file mode 100644
index 00000000..df29acee
--- /dev/null
+++ b/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.test.js
@@ -0,0 +1,216 @@
+import '@module/uswds/dist/css/uswds.min.css'
+import { mount } from '@cypress/vue'
+import UsaFooterCollapsibleMenu from './UsaFooterCollapsibleMenu.vue'
+
+describe('UsaFooterCollapsibleMenu', () => {
+ let testItems = []
+
+ beforeEach(() => {
+ testItems = [
+ {
+ text: 'Test Item 1',
+ children: [
+ {
+ href: '/test-1/test-1-1',
+ text: 'Test Item 1.1',
+ },
+ {
+ href: '/test-1/test-1-2',
+ text: 'Test Item 1.2',
+ },
+ {
+ href: '/test-1/test-1-3',
+ text: 'Test Item 1.3',
+ },
+ ],
+ },
+ {
+ text: 'Test Item 2',
+ children: [
+ {
+ to: '/test-2/test-2-1',
+ text: 'Test Item 2.1',
+ },
+ {
+ to: '/test-2/test-2-2',
+ routerComponentName: 'nuxt-link',
+ text: 'Test Item 2.2',
+ },
+ {
+ href: '/test-2/test-2-3',
+ text: 'Test Item 2.3',
+ },
+ ],
+ },
+ {
+ id: 'test-3',
+ text: 'Test Item 3',
+ children: [
+ {
+ href: '/test-3/test-3-1',
+ text: 'Test Item 3.1',
+ },
+ {
+ href: '/test-3/test-3-2',
+ text: 'Test Item 3.2',
+ },
+ {
+ href: '/test-3/test-3-3',
+ text: 'Test Item 3.3',
+ },
+ ],
+ },
+ ]
+ })
+
+ it('renders the component', () => {
+ mount(UsaFooterCollapsibleMenu, {
+ props: {
+ items: testItems,
+ },
+ })
+
+ cy.get('div.grid-row').should('have.class', 'grid-gap-4')
+ cy.get('.grid-row > div')
+ .should('have.class', 'mobile-lg:grid-col-6')
+ .and('have.class', 'desktop:grid-col-3')
+
+ cy.get('section')
+ .should('have.length', 3)
+ .and('have.class', 'usa-footer__primary-content')
+ .and('have.class', 'usa-footer__primary-content--collapsible')
+
+ cy.get('section > h4').should('have.length', 3)
+ cy.get('section > ul').should('have.length', 3)
+ cy.get('section li').should('have.length', 9)
+
+ cy.viewport(480, 600)
+
+ cy.get('section > h4').should('not.exist')
+ cy.get('section > button').should('have.length', 3)
+ cy.get('section > ul').should('not.have.visible')
+
+ cy.get('section:nth-of-type(1) button').click()
+ cy.get('section:nth-of-type(1) ul').should('be.visible')
+ cy.get('section:nth-of-type(2) ul').should('be.hidden')
+ cy.get('section:nth-of-type(3) ul').should('be.hidden')
+
+ cy.get('section:nth-of-type(3) button').click()
+ cy.get('section:nth-of-type(1) ul').should('be.hidden')
+ cy.get('section:nth-of-type(2) ul').should('be.hidden')
+ cy.get('section:nth-of-type(3) ul').should('be.visible')
+
+ cy.viewport('macbook-15')
+
+ cy.get('section > h4').should('have.length', 3)
+ cy.get('section > button').should('not.exist')
+ cy.get('section > ul').should('be.visible')
+
+ cy.viewport(480, 600)
+
+ cy.get('section > h4').should('not.exist')
+ cy.get('section > button').should('have.length', 3)
+ cy.get('section > ul').should('be.hidden')
+
+ cy.get('section:nth-of-type(2) button').click()
+ cy.get('section:nth-of-type(2) ul').should('be.visible')
+ })
+
+ it('starts as collapsible at small screens', () => {
+ cy.viewport(480, 600)
+
+ mount(UsaFooterCollapsibleMenu, {
+ props: {
+ items: testItems,
+ },
+ })
+
+ cy.get('section > h4').should('not.exist')
+ cy.get('section > button').should('have.length', 3)
+ cy.get('section > ul').should('be.hidden')
+
+ cy.get('section:nth-of-type(2) button').click()
+ cy.get('section:nth-of-type(2) ul').should('be.visible')
+ })
+
+ it('headings use custom `h3` size', () => {
+ mount(UsaFooterCollapsibleMenu, {
+ props: {
+ items: testItems,
+ headingTag: 'h3',
+ },
+ })
+
+ cy.get('section h3')
+ .should('have.length', 3)
+ .and('have.class', 'usa-footer__primary-link')
+ .and('not.have.class', 'usa-footer__primary-link--button')
+ })
+
+ it('uses custom grid prefix and separator and nav breakpoint', () => {
+ cy.viewport('macbook-15')
+
+ mount(UsaFooterCollapsibleMenu, {
+ props: {
+ items: testItems,
+ },
+ global: {
+ provide: {
+ 'vueUswds.footerNavBigBreakpoint': '850px',
+ 'vueUswds.prefixSeparator': '@',
+ 'vueUswds.gridNamespace': 'test-grid-namespace-',
+ },
+ },
+ })
+
+ cy.get('.test-grid-namespace-row').should(
+ 'have.class',
+ 'test-grid-namespace-gap-4'
+ )
+ cy.get('.test-grid-namespace-row > div')
+ .should('have.class', 'mobile-lg@test-grid-namespace-col-6')
+ .and('have.class', 'desktop@test-grid-namespace-col-3')
+
+ cy.get('section > h4').should('have.length', 3)
+ cy.get('section > button').should('not.exist')
+ cy.get('section > ul')
+ .should('not.have.css', 'display', 'none')
+ .and('be.visible')
+
+ cy.viewport(900, 1200)
+
+ cy.get('section > button').should('not.exist')
+ cy.get('section > h4').should('have.length', 3)
+ cy.get('section > ul')
+ .should('not.have.css', 'display', 'none')
+ .and('be.visible')
+
+ cy.viewport(750, 1200)
+
+ cy.get('section > button')
+ .should('have.length', 3)
+ .and('have.attr', 'aria-expanded', 'false')
+ cy.get('section > h4').should('not.exist')
+ cy.get('section > ul')
+ .should('have.css', 'display', 'none')
+ .and('be.hidden')
+ })
+
+ it('has custom grid CSS classes', () => {
+ mount(UsaFooterCollapsibleMenu, {
+ props: {
+ items: testItems,
+ customClasses: {
+ gridRow: ['test-grid-row-class'],
+ gridCol: ['test-grid-col-class'],
+ },
+ },
+ })
+
+ cy.get('div:first-of-type').should('have.class', 'test-grid-row-class')
+ cy.get('div:first-of-type > div').should(
+ 'have.class',
+ 'test-grid-col-class'
+ )
+ })
+})
diff --git a/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.vue b/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.vue
new file mode 100644
index 00000000..ed4bd868
--- /dev/null
+++ b/src/components/UsaFooterCollapsibleMenu/UsaFooterCollapsibleMenu.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
diff --git a/src/components/UsaFooterCollapsibleMenu/index.js b/src/components/UsaFooterCollapsibleMenu/index.js
new file mode 100644
index 00000000..fc081797
--- /dev/null
+++ b/src/components/UsaFooterCollapsibleMenu/index.js
@@ -0,0 +1,4 @@
+import UsaFooterCollapsibleMenu from './UsaFooterCollapsibleMenu.vue'
+
+export { UsaFooterCollapsibleMenu }
+export default UsaFooterCollapsibleMenu
diff --git a/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.stories.js b/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.stories.js
new file mode 100644
index 00000000..108a5741
--- /dev/null
+++ b/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.stories.js
@@ -0,0 +1,81 @@
+import UsaFooterCollapsibleMenuSection from './UsaFooterCollapsibleMenuSection.vue'
+
+const testItem = {
+ text: 'Test Item 1',
+ children: [
+ {
+ href: '/test-1/test-1-1',
+ text: 'Test Item 1.1',
+ },
+ {
+ href: '/test-1/test-1-2',
+ text: 'Test Item 1.2',
+ },
+ {
+ href: '/test-1/test-1-3',
+ text: 'Test Item 1.3',
+ },
+ ],
+}
+
+const defaultProps = {
+ item: UsaFooterCollapsibleMenuSection.props.item.default(),
+ headingTag: UsaFooterCollapsibleMenuSection.props.headingTag.default,
+}
+
+export default {
+ component: UsaFooterCollapsibleMenuSection,
+ title: 'Components/UsaFooterCollapsibleMenuSection',
+ argTypes: {
+ item: {
+ control: { type: 'object' },
+ },
+ headingTag: {
+ control: { type: 'text' },
+ },
+ },
+ args: {
+ item: defaultProps.item,
+ headingTag: defaultProps.headingTag,
+ },
+ decorators: [
+ () => ({
+ template:
+ '',
+ provide: {
+ footerMenuIsCollapsible: false,
+ menuSections: {},
+ registerMenuSection: () => {},
+ unregisterMenuSection: () => {},
+ toggleMenuSection: () => {},
+ },
+ }),
+ ],
+}
+
+const DefaultTemplate = (args, { argTypes }) => ({
+ components: { UsaFooterCollapsibleMenuSection },
+ props: Object.keys(argTypes),
+ setup() {
+ return { ...args }
+ },
+ template: ``,
+})
+
+export const DefaultFooterCollapsibleMenuItem = DefaultTemplate.bind({})
+DefaultFooterCollapsibleMenuItem.args = {
+ ...defaultProps,
+ item: testItem,
+}
+DefaultFooterCollapsibleMenuItem.storyName = 'Default'
+
+export const HeadingTagFooterCollapsibleMenuItem = DefaultTemplate.bind({})
+HeadingTagFooterCollapsibleMenuItem.args = {
+ ...defaultProps,
+ item: testItem,
+ headingTag: 'h3',
+}
+HeadingTagFooterCollapsibleMenuItem.storyName = 'Custom Heading Tag'
diff --git a/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.test.js b/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.test.js
new file mode 100644
index 00000000..3a6c3513
--- /dev/null
+++ b/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.test.js
@@ -0,0 +1,245 @@
+import '@module/uswds/dist/css/uswds.min.css'
+import { mount } from '@cypress/vue'
+import { reactive } from 'vue'
+import UsaFooterCollapsibleMenu from '@/components/UsaFooterCollapsibleMenu'
+import UsaFooterCollapsibleMenuSection from './UsaFooterCollapsibleMenuSection.vue'
+
+describe('UsaFooterCollapsibleMenuSection', () => {
+ let testItem
+
+ beforeEach(() => {
+ testItem = {
+ text: 'Test Item 1',
+ children: [
+ {
+ href: '/test-1/test-1-1',
+ text: 'Test Item 1.1',
+ },
+ {
+ href: '/test-1/test-1-2',
+ text: 'Test Item 1.2',
+ },
+ {
+ href: '/test-1/test-1-3',
+ text: 'Test Item 1.3',
+ },
+ ],
+ }
+ })
+
+ it('renders the component', () => {
+ const wrapperComponent = {
+ components: { UsaFooterCollapsibleMenu },
+ props: ['items'],
+ template: ``,
+ }
+
+ mount(wrapperComponent, {
+ props: {
+ items: [testItem],
+ },
+ })
+
+ cy.get('section.usa-footer__primary-content--collapsible').should(
+ 'have.class',
+ 'usa-footer__primary-content'
+ )
+
+ cy.get('section > h4')
+ .should('have.class', 'usa-footer__primary-link')
+ .and('not.have.class', 'usa-footer__primary-link--button')
+ .and('contain', 'Test Item 1')
+
+ cy.get('section > button').should('not.exist')
+
+ cy.get('section > ul')
+ .as('submenu')
+ .should('have.class', 'usa-list')
+ .and('have.class', 'usa-list--unstyled')
+ .and('be.visible')
+ .and('not.have.css', 'display', 'none')
+ .and('have.attr', 'id')
+
+ cy.get('section > ul > li')
+ .should('have.length', 3)
+ .and('have.class', 'usa-footer__secondary-link')
+
+ cy.get('section a').should('have.length', 3)
+
+ cy.get('ul > li:nth-of-type(1) a')
+ .should('have.attr', 'href', '/test-1/test-1-1')
+ .and('contain', 'Test Item 1.1')
+
+ cy.get('ul > li:nth-of-type(2) a')
+ .should('have.attr', 'href', '/test-1/test-1-2')
+ .and('contain', 'Test Item 1.2')
+
+ cy.get('ul > li:nth-of-type(3) a')
+ .should('have.attr', 'href', '/test-1/test-1-3')
+ .and('contain', 'Test Item 1.3')
+
+ cy.viewport('iphone-6')
+
+ cy.get('section > h4').should('not.exist')
+
+ cy.get('section > button')
+ .as('button')
+ .should('have.class', 'usa-footer__primary-link')
+ .and('have.class', 'usa-footer__primary-link--button')
+ .and('have.attr', 'aria-expanded', 'false')
+ .and('have.attr', 'type', 'button')
+ .and('have.attr', 'aria-controls')
+
+ cy.get('section > button').should('contain', 'Test Item 1')
+
+ cy.get('@submenu').and('have.css', 'display', 'none').and('not.be.visible')
+
+ cy.get('section > ul > li')
+ .should('have.length', 3)
+ .and('have.class', 'usa-footer__secondary-link')
+
+ cy.get('section a').should('have.length', 3)
+
+ cy.get('ul > li:nth-of-type(1) a')
+ .should('have.attr', 'href', '/test-1/test-1-1')
+ .and('contain', 'Test Item 1.1')
+
+ cy.get('ul > li:nth-of-type(2) a')
+ .should('have.attr', 'href', '/test-1/test-1-2')
+ .and('contain', 'Test Item 1.2')
+
+ cy.get('ul > li:nth-of-type(3) a')
+ .should('have.attr', 'href', '/test-1/test-1-3')
+ .and('contain', 'Test Item 1.3')
+
+ cy.get('@button').click().should('have.attr', 'aria-expanded', 'true')
+
+ cy.get('@submenu').and('not.have.css', 'display', 'none').and('be.visible')
+
+ cy.get('@button').click().should('have.attr', 'aria-expanded', 'false')
+ cy.get('@submenu').and('have.css', 'display', 'none').and('not.be.visible')
+
+ cy.viewport('macbook-15')
+
+ cy.get('section > h4')
+ .should('have.class', 'usa-footer__primary-link')
+ .and('not.have.class', 'usa-footer__primary-link--button')
+ .and('contain', 'Test Item 1')
+
+ cy.get('section > button').should('not.exist')
+
+ cy.get('section a').should('have.length', 3)
+ cy.get('@submenu').and('not.have.css', 'display', 'none').and('be.visible')
+ })
+
+ it('uses custom heading tag', () => {
+ mount(UsaFooterCollapsibleMenuSection, {
+ props: {
+ item: testItem,
+ headingTag: 'h3',
+ },
+ global: {
+ provide: {
+ menuSections: () => reactive({}),
+ registerMenuSection: () => {},
+ unregisterMenuSection: () => {},
+ toggleMenuSection: () => {},
+ footerMenuIsCollapsible: false,
+ },
+ },
+ })
+
+ cy.get('section > h3')
+ .should('have.class', 'usa-footer__primary-link')
+ .should('not.have.class', 'usa-footer__primary-link--button')
+ .and('contain', 'Test Item 1')
+ })
+
+ it('does not render submenu items if not in item prop', () => {
+ delete testItem.children
+
+ mount(UsaFooterCollapsibleMenuSection, {
+ props: {
+ item: testItem,
+ },
+ global: {
+ provide: {
+ menuSections: () => reactive({}),
+ registerMenuSection: () => {},
+ unregisterMenuSection: () => {},
+ toggleMenuSection: () => {},
+ footerMenuIsCollapsible: false,
+ },
+ },
+ })
+
+ cy.get('section > ul').should('not.exist')
+ })
+
+ it('un-registers section when unmounted', () => {
+ testItem.id = 'test-item-1'
+
+ mount(UsaFooterCollapsibleMenuSection, {
+ props: {
+ item: testItem,
+ },
+ global: {
+ provide: {
+ menuSections: () => reactive({}),
+ registerMenuSection: cy.stub().as('registerMenuSection'),
+ unregisterMenuSection: cy.stub().as('unregisterMenuSection'),
+ toggleMenuSection: () => {},
+ footerMenuIsCollapsible: false,
+ },
+ },
+ }).as('wrapper')
+
+ cy.get('@registerMenuSection').should('be.calledWith', 'test-item-1')
+
+ cy.get('section.usa-footer__primary-content--collapsible').should(
+ 'have.class',
+ 'usa-footer__primary-content'
+ )
+
+ cy.get('section > ul').should('have.id', 'test-item-1')
+
+ cy.get('@wrapper').invoke('unmount')
+
+ cy.get('@unregisterMenuSection').should('be.called')
+ })
+
+ it('uses correct `BaseLink` prop values', () => {
+ testItem.children[0].href = null
+ testItem.children[0].to = '/test-1/test-1-1'
+ testItem.children[2].href = null
+ testItem.children[2].to = '/test-1/test-1-3'
+ testItem.children[2].routerComponentName = 'nuxt-link'
+
+ mount(UsaFooterCollapsibleMenuSection, {
+ props: {
+ item: testItem,
+ },
+ global: {
+ provide: {
+ menuSections: () => reactive({}),
+ registerMenuSection: () => {},
+ unregisterMenuSection: () => {},
+ toggleMenuSection: () => {},
+ footerMenuIsCollapsible: false,
+ },
+ },
+ })
+
+ cy.get('ul > li:nth-of-type(1) a')
+ .should('have.attr', 'to', '/test-1/test-1-1')
+ .and('contain', 'Test Item 1.1')
+
+ cy.get('ul > li:nth-of-type(2) a')
+ .should('have.attr', 'href', '/test-1/test-1-2')
+ .and('contain', 'Test Item 1.2')
+
+ cy.get('ul > li:nth-of-type(3) nuxt-link')
+ .should('have.attr', 'to', '/test-1/test-1-3')
+ .and('contain', 'Test Item 1.3')
+ })
+})
diff --git a/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.vue b/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.vue
new file mode 100644
index 00000000..c6a44bc9
--- /dev/null
+++ b/src/components/UsaFooterCollapsibleMenuSection/UsaFooterCollapsibleMenuSection.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
diff --git a/src/components/UsaFooterCollapsibleMenuSection/index.js b/src/components/UsaFooterCollapsibleMenuSection/index.js
new file mode 100644
index 00000000..6b64fa6b
--- /dev/null
+++ b/src/components/UsaFooterCollapsibleMenuSection/index.js
@@ -0,0 +1,4 @@
+import UsaFooterCollapsibleMenuSection from './UsaFooterCollapsibleMenuSection.vue'
+
+export { UsaFooterCollapsibleMenuSection }
+export default UsaFooterCollapsibleMenuSection
diff --git a/src/components/UsaFooterMenu/UsaFooterMenu.stories.js b/src/components/UsaFooterMenu/UsaFooterMenu.stories.js
new file mode 100644
index 00000000..f9d6f6f5
--- /dev/null
+++ b/src/components/UsaFooterMenu/UsaFooterMenu.stories.js
@@ -0,0 +1,80 @@
+import UsaFooterMenu from './UsaFooterMenu.vue'
+
+const testItems = [
+ {
+ href: '/test-1',
+ text: 'Test Item 1',
+ },
+ {
+ to: '/test-2',
+ text: 'Test Item 2',
+ },
+ {
+ id: 'test-3',
+ href: '/test-3',
+ text: 'Test Item 3',
+ },
+ {
+ id: 'test-4',
+ href: '/test-4',
+ text: 'Test Item 4',
+ },
+]
+
+const defaultProps = {
+ items: UsaFooterMenu.props.items.default(),
+ customClasses: UsaFooterMenu.props.customClasses.default(),
+}
+
+export default {
+ component: UsaFooterMenu,
+ title: 'Components/UsaFooterMenu',
+ argTypes: {
+ items: {
+ control: { type: 'object' },
+ },
+ customClasses: {
+ control: { type: 'object' },
+ },
+ },
+ args: {
+ items: defaultProps.items,
+ customClasses: defaultProps.customClasses,
+ },
+ decorators: [
+ () => ({
+ template:
+ '',
+ }),
+ ],
+}
+
+const DefaultTemplate = (args, { argTypes }) => ({
+ components: { UsaFooterMenu },
+ props: Object.keys(argTypes),
+ setup() {
+ return { ...args }
+ },
+ template: ``,
+})
+
+export const DefaultFooterMenu = DefaultTemplate.bind({})
+DefaultFooterMenu.args = {
+ ...defaultProps,
+ items: testItems,
+}
+DefaultFooterMenu.storyName = 'Default'
+
+export const CustomClassesFooterMenu = DefaultTemplate.bind({})
+CustomClassesFooterMenu.args = {
+ ...defaultProps,
+ items: testItems,
+ customClasses: {
+ gridRow: ['test-grid-row-class'],
+ gridCol: ['test-grid-col-class'],
+ },
+}
+CustomClassesFooterMenu.storyName = 'Custom Classes'
diff --git a/src/components/UsaFooterMenu/UsaFooterMenu.test.js b/src/components/UsaFooterMenu/UsaFooterMenu.test.js
new file mode 100644
index 00000000..02b3422c
--- /dev/null
+++ b/src/components/UsaFooterMenu/UsaFooterMenu.test.js
@@ -0,0 +1,91 @@
+import '@module/uswds/dist/css/uswds.min.css'
+import { mount } from '@cypress/vue'
+import UsaFooterMenu from './UsaFooterMenu.vue'
+
+const testItems = [
+ {
+ href: '/test-1',
+ text: 'Test Item 1',
+ },
+ {
+ to: '/test-2',
+ text: 'Test Item 2',
+ routerComponentName: 'nuxt-link',
+ },
+ {
+ id: 'test-3',
+ to: '/test-3',
+ text: 'Test Item 3',
+ },
+]
+
+describe('UsaFooterMenu', () => {
+ it('renders the component', () => {
+ mount(UsaFooterMenu, {
+ props: {
+ items: testItems,
+ },
+ })
+
+ cy.get('ul').should('have.class', 'grid-row').and('have.class', 'grid-gap')
+
+ cy.get('li')
+ .should('have.length', 3)
+ .and('have.class', 'usa-footer__primary-content')
+ .and('have.class', 'mobile-lg:grid-col-6')
+ .and('have.class', 'desktop:grid-col-auto')
+
+ cy.get('li:nth-of-type(1) a')
+ .should('have.class', 'usa-footer__primary-link')
+ .and('have.attr', 'href', '/test-1')
+ .and('contain', 'Test Item 1')
+
+ cy.get('li:nth-of-type(2) nuxt-link')
+ .should('have.class', 'usa-footer__primary-link')
+ .and('have.attr', 'to', '/test-2')
+ .and('contain', 'Test Item 2')
+
+ cy.get('li:nth-of-type(3) a')
+ .should('have.class', 'usa-footer__primary-link')
+ .and('have.attr', 'to', '/test-3')
+ .and('contain', 'Test Item 3')
+ })
+
+ it('uses custom grid prefix and separator', () => {
+ mount(UsaFooterMenu, {
+ props: {
+ items: testItems,
+ },
+ global: {
+ provide: {
+ 'vueUswds.prefixSeparator': '@',
+ 'vueUswds.gridNamespace': 'test-grid-namespace-',
+ },
+ },
+ })
+
+ cy.get('ul')
+ .should('have.class', 'test-grid-namespace-row')
+ .and('have.class', 'test-grid-namespace-gap')
+
+ cy.get('li')
+ .and('have.class', 'mobile-lg@test-grid-namespace-col-6')
+ .and('have.class', 'desktop@test-grid-namespace-col-auto')
+ })
+
+ it('adds custom CSS classes', () => {
+ mount(UsaFooterMenu, {
+ props: {
+ items: testItems,
+ customClasses: {
+ gridRow: ['test-grid-row-class'],
+ gridCol: ['test-grid-col-class'],
+ },
+ },
+ })
+
+ cy.get('ul').should('have.class', 'test-grid-row-class')
+
+ cy.get('li').and('have.class', 'test-grid-col-class')
+ })
+})
diff --git a/src/components/UsaFooterMenu/UsaFooterMenu.vue b/src/components/UsaFooterMenu/UsaFooterMenu.vue
new file mode 100644
index 00000000..a77866e7
--- /dev/null
+++ b/src/components/UsaFooterMenu/UsaFooterMenu.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
diff --git a/src/components/UsaFooterMenu/index.js b/src/components/UsaFooterMenu/index.js
new file mode 100644
index 00000000..a6820cfc
--- /dev/null
+++ b/src/components/UsaFooterMenu/index.js
@@ -0,0 +1,4 @@
+import UsaFooterMenu from './UsaFooterMenu.vue'
+
+export { UsaFooterMenu }
+export default UsaFooterMenu
diff --git a/src/components/UsaFooterNav/UsaFooterNav.stories.js b/src/components/UsaFooterNav/UsaFooterNav.stories.js
new file mode 100644
index 00000000..99bdfcb6
--- /dev/null
+++ b/src/components/UsaFooterNav/UsaFooterNav.stories.js
@@ -0,0 +1,230 @@
+import UsaFooterNav from './UsaFooterNav.vue'
+
+const testItems = [
+ {
+ href: '/test-1',
+ text: 'Test Item 1',
+ },
+ {
+ to: '/test-2',
+ text: 'Test Item 2',
+ },
+ {
+ id: 'test-3',
+ href: '/test-3',
+ text: 'Test Item 3',
+ },
+ {
+ id: 'test-4',
+ href: '/test-4',
+ text: 'Test Item 4',
+ },
+]
+
+const testCollapsibleItems = [
+ {
+ text: 'Test Item 1',
+ children: [
+ {
+ href: '/test-1/test-1-1',
+ text: 'Test Item 1.1',
+ },
+ {
+ href: '/test-1/test-1-2',
+ text: 'Test Item 1.2',
+ },
+ {
+ href: '/test-1/test-1-3',
+ text: 'Test Item 1.3',
+ },
+ ],
+ },
+ {
+ text: 'Test Item 2',
+ children: [
+ {
+ href: '/test-2/test-2-1',
+ text: 'Test Item 2.1',
+ },
+ {
+ href: '/test-2/test-2-2',
+ text: 'Test Item 2.2',
+ },
+ {
+ href: '/test-2/test-2-3',
+ text: 'Test Item 2.3',
+ },
+ ],
+ },
+ {
+ id: 'test-3',
+ text: 'Test Item 3',
+ children: [
+ {
+ href: '/test-3/test-3-1',
+ text: 'Test Item 3.1',
+ },
+ {
+ href: '/test-3/test-3-2',
+ text: 'Test Item 3.2',
+ },
+ {
+ href: '/test-3/test-3-3',
+ text: 'Test Item 3.3',
+ },
+ ],
+ },
+]
+
+const defaultProps = {
+ ariaLabel: UsaFooterNav.props.ariaLabel.default,
+ items: UsaFooterNav.props.items.default(),
+ collapsibleHeadingTag: UsaFooterNav.props.collapsibleHeadingTag.default,
+ customClasses: UsaFooterNav.props.customClasses.default(),
+}
+
+export default {
+ component: UsaFooterNav,
+ title: 'Components/UsaFooterNav',
+ argTypes: {
+ ariaLabel: {
+ control: { type: 'text' },
+ },
+ items: {
+ control: { type: 'object' },
+ },
+ collapsibleHeadingTag: {
+ control: { type: 'text' },
+ },
+ customClasses: {
+ control: { type: 'object' },
+ },
+ defaultSlot: {
+ control: { type: 'text' },
+ },
+ },
+ args: {
+ ariaLabel: defaultProps.ariaLabel,
+ items: defaultProps.items,
+ collapsibleHeadingTag: defaultProps.collapsibleHeadingTag,
+ customClasses: defaultProps.customClasses,
+ defaultSlot: '',
+ },
+}
+
+const DefaultTemplate = (args, { argTypes }) => ({
+ components: { UsaFooterNav },
+ props: Object.keys(argTypes),
+ setup() {
+ return { ...args }
+ },
+ template: `
+ ${
+ args.defaultSlot
+ }
+ `,
+})
+
+export const DefaultFooterNav = DefaultTemplate.bind({})
+DefaultFooterNav.args = {
+ ...defaultProps,
+ items: testItems,
+}
+DefaultFooterNav.storyName = 'Default'
+
+export const BigFooterNav = DefaultTemplate.bind({})
+BigFooterNav.args = {
+ ...defaultProps,
+ items: testCollapsibleItems,
+}
+BigFooterNav.decorators = [
+ () => ({
+ template: '',
+ provide: {
+ footerVariant: 'big',
+ },
+ }),
+]
+
+export const MediumFooterNav = DefaultTemplate.bind({})
+MediumFooterNav.args = {
+ ...defaultProps,
+ items: testItems,
+}
+MediumFooterNav.decorators = [
+ () => ({
+ template: '',
+ }),
+]
+
+export const SlimFooterNav = DefaultTemplate.bind({})
+SlimFooterNav.args = {
+ ...defaultProps,
+ items: testItems,
+}
+SlimFooterNav.decorators = [
+ () => ({
+ template: '',
+ provide: {
+ footerVariant: 'slim',
+ },
+ }),
+]
+
+export const AriaLabelFooterNav = DefaultTemplate.bind({})
+AriaLabelFooterNav.args = {
+ ...defaultProps,
+ ariaLabel: 'Custom aria label',
+ items: testItems,
+}
+AriaLabelFooterNav.storyName = 'Custom Aria Label'
+
+export const CollapsibleHeadingTagFooterNav = DefaultTemplate.bind({})
+CollapsibleHeadingTagFooterNav.args = {
+ ...defaultProps,
+ collapsibleHeadingTag: 'h2',
+ items: testCollapsibleItems,
+}
+CollapsibleHeadingTagFooterNav.decorators = [
+ () => ({
+ template: '',
+ provide: {
+ footerVariant: 'big',
+ },
+ }),
+]
+CollapsibleHeadingTagFooterNav.storyName = 'Collapsible Heading Tag'
+
+export const DefaultScopedSlotFooterNav = DefaultTemplate.bind({})
+DefaultScopedSlotFooterNav.args = {
+ ...defaultProps,
+ items: testCollapsibleItems,
+ defaultSlot: `{{ items[0].text }}`,
+}
+DefaultScopedSlotFooterNav.decorators = [
+ () => ({
+ template: '',
+ }),
+]
+DefaultScopedSlotFooterNav.storyName = 'Default Scoped Slot'
+
+export const CustomClassesFooterNav = DefaultTemplate.bind({})
+CustomClassesFooterNav.args = {
+ ...defaultProps,
+ items: testItems,
+ customClasses: {
+ gridRow: ['test-grid-row-class'],
+ gridCol: ['test-grid-col-class'],
+ },
+}
+CustomClassesFooterNav.decorators = [
+ () => ({
+ template: '',
+ }),
+]
+CustomClassesFooterNav.storyName = 'Custom Classes'
diff --git a/src/components/UsaFooterNav/UsaFooterNav.test.js b/src/components/UsaFooterNav/UsaFooterNav.test.js
new file mode 100644
index 00000000..71261463
--- /dev/null
+++ b/src/components/UsaFooterNav/UsaFooterNav.test.js
@@ -0,0 +1,250 @@
+import '@module/uswds/dist/css/uswds.min.css'
+import { mount } from '@cypress/vue'
+import { h } from 'vue'
+import UsaFooterNav from './UsaFooterNav.vue'
+
+describe('UsaFooterNav', () => {
+ let testItems = []
+
+ beforeEach(() => {
+ testItems = [
+ {
+ href: '/item-1',
+ text: 'Item 1',
+ },
+ {
+ href: '/item-2',
+ text: 'Item 2',
+ },
+ ]
+ })
+
+ it('renders the component', () => {
+ mount(UsaFooterNav, {})
+
+ cy.get('nav')
+ .should('have.class', 'usa-footer__nav')
+ .and('have.attr', 'aria-label', 'Footer navigation')
+ .and('be.empty')
+ })
+
+ it('uses custom aria label and scoped default slot', () => {
+ mount(UsaFooterNav, {
+ props: {
+ ariaLabel: 'Test aria label',
+ items: testItems,
+ },
+ slots: {
+ default: ({ items }) => h('span', {}, `Test ${items[0].text}`),
+ },
+ })
+
+ cy.get('.usa-footer__nav').should(
+ 'have.attr',
+ 'aria-label',
+ 'Test aria label'
+ )
+ cy.get('.usa-footer__nav > span').should('have.contain', 'Test Item 1')
+ cy.get('h4').should('not.exist')
+ })
+
+ it('renders `medium` footer menu', () => {
+ mount(UsaFooterNav, {
+ props: {
+ items: testItems,
+ },
+ })
+
+ cy.get('.usa-footer__nav ul')
+ .should('have.class', 'grid-row')
+ .and('have.class', 'grid-gap')
+
+ cy.get('.usa-footer__nav li')
+ .should('have.length', 2)
+ .and('have.class', 'usa-footer__primary-content')
+ .and('have.class', 'mobile-lg:grid-col-6')
+ .and('have.class', 'desktop:grid-col-auto')
+
+ cy.get('.usa-footer__nav a')
+ .should('have.length', 2)
+ .and('have.class', 'usa-footer__primary-link')
+
+ cy.get('h4').should('not.exist')
+ })
+
+ it('renders `slim` footer menu', () => {
+ mount(UsaFooterNav, {
+ props: {
+ items: testItems,
+ },
+ global: {
+ provide: {
+ footerVariant: 'slim',
+ },
+ },
+ })
+
+ cy.get('.usa-footer__nav ul')
+ .should('have.class', 'grid-row')
+ .and('have.class', 'grid-gap')
+
+ cy.get('.usa-footer__nav li')
+ .should('have.length', 2)
+ .and('have.class', 'usa-footer__primary-content')
+ .and('have.class', 'mobile-lg:grid-col-6')
+ .and('have.class', 'desktop:grid-col-auto')
+
+ cy.get('.usa-footer__nav a')
+ .should('have.length', 2)
+ .and('have.class', 'usa-footer__primary-link')
+
+ cy.get('h4').should('not.exist')
+ })
+
+ it('renders `big` footer menu', () => {
+ mount(UsaFooterNav, {
+ props: {
+ items: testItems,
+ },
+ global: {
+ provide: {
+ footerVariant: 'big',
+ },
+ },
+ })
+
+ cy.get('.usa-footer__nav > div')
+ .should('have.class', 'grid-row')
+ .and('have.class', 'grid-gap-4')
+
+ cy.get('.usa-footer__nav > div > div')
+ .should('have.class', 'mobile-lg:grid-col-6')
+ .and('have.class', 'desktop:grid-col-3')
+
+ cy.get('.usa-footer__nav div section')
+ .should('have.length', 2)
+ .and('have.class', 'usa-footer__primary-content')
+ .and('have.class', 'usa-footer__primary-content--collapsible')
+
+ cy.get('h4').should('have.class', 'usa-footer__primary-link')
+ })
+
+ it('menu for invalid footer variant is not rendered', () => {
+ mount(UsaFooterNav, {
+ props: {
+ items: testItems,
+ },
+ global: {
+ provide: {
+ footerVariant: 'invalidfootervariant',
+ },
+ },
+ })
+
+ cy.get('.usa-footer__nav ul').should('not.exist')
+ })
+
+ it('`big` menu uses custom heading tag', () => {
+ mount(UsaFooterNav, {
+ props: {
+ collapsibleHeadingTag: 'h2',
+ items: testItems,
+ },
+ global: {
+ provide: {
+ footerVariant: 'big',
+ },
+ },
+ })
+
+ cy.get('h2').should('have.class', 'usa-footer__primary-link')
+ })
+
+ it('`medium/slim` menu uses custom grid classes', () => {
+ mount(UsaFooterNav, {
+ props: {
+ items: testItems,
+ customClasses: {
+ gridRow: ['test-grid-row-class'],
+ gridCol: ['test-grid-col-class'],
+ },
+ },
+ })
+
+ cy.get('.usa-footer__nav > ul').should('have.class', 'test-grid-row-class')
+
+ cy.get('.usa-footer__nav > ul > li').should(
+ 'have.class',
+ 'test-grid-col-class'
+ )
+ })
+
+ it('`big` menu uses custom grid classes', () => {
+ mount(UsaFooterNav, {
+ props: {
+ items: testItems,
+ customClasses: {
+ gridRow: ['test-grid-row-class'],
+ gridCol: ['test-grid-col-class'],
+ },
+ },
+ global: {
+ provide: {
+ footerVariant: 'big',
+ },
+ },
+ })
+
+ cy.get('.usa-footer__nav > div').should('have.class', 'test-grid-row-class')
+
+ cy.get('.usa-footer__nav > div > div').should(
+ 'have.class',
+ 'test-grid-col-class'
+ )
+ })
+
+ it('`medium/slim` menu uses custom grid prefix and separator', () => {
+ mount(UsaFooterNav, {
+ props: {
+ items: testItems,
+ },
+ global: {
+ provide: {
+ 'vueUswds.prefixSeparator': '@',
+ 'vueUswds.gridNamespace': 'test-grid-namespace-',
+ },
+ },
+ })
+
+ cy.get('.usa-footer__nav ul')
+ .should('have.class', 'test-grid-namespace-row')
+ .and('have.class', 'test-grid-namespace-gap')
+
+ cy.get('.usa-footer__nav li')
+ .and('have.class', 'mobile-lg@test-grid-namespace-col-6')
+ .and('have.class', 'desktop@test-grid-namespace-col-auto')
+ })
+
+ it('`big` menu uses custom grid prefix and separator', () => {
+ mount(UsaFooterNav, {
+ props: {
+ items: testItems,
+ },
+ global: {
+ provide: {
+ footerVariant: 'big',
+ 'vueUswds.prefixSeparator': '@',
+ 'vueUswds.gridNamespace': 'test-grid-namespace-',
+ },
+ },
+ })
+
+ cy.get('.usa-footer__nav > div')
+ .should('have.class', 'test-grid-namespace-row')
+ .and('have.class', 'test-grid-namespace-gap-4')
+
+ cy.get('.usa-footer__nav > div > div')
+ .should('have.class', 'mobile-lg@test-grid-namespace-col-6')
+ .and('have.class', 'desktop@test-grid-namespace-col-3')
+ })
+})
diff --git a/src/components/UsaFooterNav/UsaFooterNav.vue b/src/components/UsaFooterNav/UsaFooterNav.vue
new file mode 100644
index 00000000..6f560d4c
--- /dev/null
+++ b/src/components/UsaFooterNav/UsaFooterNav.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
diff --git a/src/components/UsaFooterNav/index.js b/src/components/UsaFooterNav/index.js
new file mode 100644
index 00000000..e2b7f514
--- /dev/null
+++ b/src/components/UsaFooterNav/index.js
@@ -0,0 +1,4 @@
+import UsaFooterNav from './UsaFooterNav.vue'
+
+export { UsaFooterNav }
+export default UsaFooterNav
diff --git a/src/components/UsaIcon/UsaIcon.stories.js b/src/components/UsaIcon/UsaIcon.stories.js
new file mode 100644
index 00000000..52111dc9
--- /dev/null
+++ b/src/components/UsaIcon/UsaIcon.stories.js
@@ -0,0 +1,83 @@
+import UsaIcon from './UsaIcon.vue'
+
+const defaultProps = {
+ // Name is required.
+ name: 'flag',
+ size: UsaIcon.props.size.default,
+ ariaHidden: UsaIcon.props.ariaHidden.default,
+ role: UsaIcon.props.role.default,
+ focusable: UsaIcon.props.focusable.default,
+}
+
+export default {
+ component: UsaIcon,
+ title: 'Components/UsaIcon',
+ argTypes: {
+ name: {
+ control: { type: 'text' },
+ },
+ size: {
+ options: ['', '3', '4', '5', '6', '7', '8', '9'],
+ control: {
+ type: 'select',
+ },
+ },
+ ariaHidden: {
+ control: { type: 'boolean' },
+ },
+ role: {
+ control: { type: 'text' },
+ },
+ focusable: {
+ control: { type: 'boolean' },
+ },
+ titleSlot: {
+ control: { type: 'text' },
+ },
+ },
+ args: {
+ name: defaultProps.name,
+ size: defaultProps.size,
+ ariaHidden: defaultProps.ariaHidden,
+ role: defaultProps.role,
+ focusable: defaultProps.focusable,
+ titleSlot: '',
+ },
+}
+
+const DefaultTemplate = (args, { argTypes }) => ({
+ components: { UsaIcon },
+ props: Object.keys(argTypes),
+ setup() {
+ return { ...args }
+ },
+ template: `
+ ${args.titleSlot}
+ `,
+})
+
+export const DefaultIcon = DefaultTemplate.bind({})
+DefaultIcon.args = {
+ ...defaultProps,
+}
+DefaultIcon.storyName = 'Default'
+
+export const CustomSizeIcon = DefaultTemplate.bind({})
+CustomSizeIcon.args = {
+ ...defaultProps,
+ size: '9',
+}
+CustomSizeIcon.storyName = 'Custom Size'
+
+export const TitleSlotIcon = DefaultTemplate.bind({})
+TitleSlotIcon.args = {
+ ...defaultProps,
+ titleSlot: 'Flag icon',
+}
+TitleSlotIcon.storyName = 'Title Slot'
diff --git a/src/components/UsaIcon/UsaIcon.test.js b/src/components/UsaIcon/UsaIcon.test.js
new file mode 100644
index 00000000..6afcaae6
--- /dev/null
+++ b/src/components/UsaIcon/UsaIcon.test.js
@@ -0,0 +1,79 @@
+import '@module/uswds/dist/css/uswds.min.css'
+import { mount } from '@cypress/vue'
+import { h } from 'vue'
+import UsaIcon from './UsaIcon.vue'
+
+describe('UsaIcon', () => {
+ it('renders the component', () => {
+ mount(UsaIcon, {
+ props: {
+ name: 'flag',
+ },
+ })
+
+ cy.get('svg.usa-icon')
+ .should('have.attr', 'aria-hidden', 'true')
+ .and('have.attr', 'role', 'img')
+ .and('have.attr', 'focusable', 'false')
+
+ cy.get('svg > use').should(
+ 'have.attr',
+ 'xlink:href',
+ '/assets/img/sprite.svg#flag'
+ )
+ })
+
+ it('attribute values match prop values', () => {
+ mount(UsaIcon, {
+ props: {
+ name: 'github',
+ ariaHidden: false,
+ role: 'presentation',
+ focusable: true,
+ size: 3,
+ },
+ global: {
+ provide: {
+ 'vueUswds.svgSpritePath': '/test.svg',
+ },
+ },
+ })
+
+ cy.get('.usa-icon')
+ .should('have.attr', 'aria-hidden', 'false')
+ .and('have.attr', 'role', 'presentation')
+ .and('have.attr', 'focusable', 'true')
+ .and('have.class', 'usa-icon--size-3')
+
+ cy.get('svg > use').should('have.attr', 'xlink:href', '/test.svg#github')
+ })
+
+ it('custom slot content is used', () => {
+ mount(UsaIcon, {
+ props: {
+ name: 'bug_report',
+ },
+ slots: {
+ title: () => h('title', {}, 'custom title slot'),
+ },
+ })
+
+ cy.get('svg title').should('contain', 'custom title slot')
+ })
+
+ it('warns in console about invalid `size` prop', () => {
+ cy.stub(window.console, 'warn').as('consoleWarn')
+
+ mount(UsaIcon, {
+ props: {
+ name: 'zoom_out_map',
+ size: 10,
+ },
+ })
+
+ cy.get('@consoleWarn').should(
+ 'be.calledWith',
+ `'10' is not a valid icon size`
+ )
+ })
+})
diff --git a/src/components/UsaIcon/UsaIcon.vue b/src/components/UsaIcon/UsaIcon.vue
new file mode 100644
index 00000000..420effb9
--- /dev/null
+++ b/src/components/UsaIcon/UsaIcon.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
diff --git a/src/components/UsaIcon/index.js b/src/components/UsaIcon/index.js
new file mode 100644
index 00000000..2d9d65f0
--- /dev/null
+++ b/src/components/UsaIcon/index.js
@@ -0,0 +1,4 @@
+import UsaIcon from './UsaIcon.vue'
+
+export { UsaIcon }
+export default UsaIcon
diff --git a/src/components/UsaIconList/UsaIconList.stories.js b/src/components/UsaIconList/UsaIconList.stories.js
new file mode 100644
index 00000000..e9832c1b
--- /dev/null
+++ b/src/components/UsaIconList/UsaIconList.stories.js
@@ -0,0 +1,68 @@
+import UsaIconList from './UsaIconList.vue'
+import UsaIconListItem from '@/components/UsaIconListItem'
+
+const defaultProps = {
+ color: UsaIconList.props.color.default,
+ size: UsaIconList.props.size.default,
+}
+
+export default {
+ component: UsaIconList,
+ title: 'Components/UsaIconList',
+ argTypes: {
+ color: {
+ control: { type: 'text' },
+ },
+ defaultSlot: {
+ control: { type: 'text' },
+ },
+ },
+ args: {
+ color: defaultProps.color,
+ size: defaultProps.size,
+ defaultSlot: `Icon list itemIcon list itemIcon list item`,
+ },
+}
+
+const DefaultTemplate = (args, { argTypes }) => ({
+ components: { UsaIconList, UsaIconListItem },
+ props: Object.keys(argTypes),
+ setup() {
+ return { ...args }
+ },
+ template: `${args.defaultSlot}`,
+})
+
+export const DefaultIconList = DefaultTemplate.bind({})
+DefaultIconList.args = {
+ ...defaultProps,
+}
+DefaultIconList.storyName = 'Default'
+
+export const ColorIconList = DefaultTemplate.bind({})
+ColorIconList.args = {
+ ...defaultProps,
+ color: 'success',
+}
+ColorIconList.storyName = 'Color'
+
+export const SingleSizeIconList = DefaultTemplate.bind({})
+SingleSizeIconList.args = {
+ ...defaultProps,
+ size: 'lg',
+}
+SingleSizeIconList.storyName = 'Single Size'
+
+export const ResponsiveSizesIconList = DefaultTemplate.bind({})
+ResponsiveSizesIconList.args = {
+ ...defaultProps,
+ size: {
+ mobile: 'lg',
+ tablet: 'xl',
+ desktop: '2xl',
+ },
+}
+ResponsiveSizesIconList.storyName = 'Responsive Sizes'
diff --git a/src/components/UsaIconList/UsaIconList.test.js b/src/components/UsaIconList/UsaIconList.test.js
new file mode 100644
index 00000000..d976cb19
--- /dev/null
+++ b/src/components/UsaIconList/UsaIconList.test.js
@@ -0,0 +1,86 @@
+import '@module/uswds/dist/css/uswds.min.css'
+import { mount } from '@cypress/vue'
+import { h } from 'vue'
+import UsaIconList from './UsaIconList.vue'
+
+describe('UsaIconList', () => {
+ it('renders the component', () => {
+ mount(UsaIconList, {
+ slots: {
+ default: () => h('li', {}, 'Test icon list item'),
+ },
+ })
+
+ cy.get('ul.usa-icon-list')
+ .should('have.attr', 'class')
+ .and('not.match', /usa-icon-list--/)
+ cy.get('.usa-icon-list > li').should('contain', 'Test icon list item')
+ })
+
+ it('applied color and size CSS classes from prop values', () => {
+ mount(UsaIconList, {
+ props: {
+ color: 'primary',
+ size: 'xl',
+ },
+ })
+
+ cy.get('.usa-icon-list')
+ .should('have.class', 'usa-icon-list--primary')
+ .and('have.class', 'usa-icon-list--size-xl')
+ })
+
+ it('formats RWD CSS size classes', () => {
+ mount(UsaIconList, {
+ props: {
+ size: {
+ mobile: 'sm',
+ tablet: 'lg',
+ desktop: '2xl',
+ },
+ },
+ })
+
+ cy.get('.usa-icon-list')
+ .should('have.class', 'mobile:usa-icon-list--size-sm')
+ .and('have.class', 'tablet:usa-icon-list--size-lg')
+ .and('have.class', 'desktop:usa-icon-list--size-2xl')
+ })
+
+ it('uses custom responsive prefix separator', () => {
+ mount(UsaIconList, {
+ props: {
+ size: {
+ mobile: 'sm',
+ tablet: 'lg',
+ desktop: '2xl',
+ },
+ },
+ global: {
+ provide: {
+ 'vueUswds.prefixSeparator': '-',
+ },
+ },
+ })
+
+ cy.get('.usa-icon-list')
+ .should('have.class', 'mobile-usa-icon-list--size-sm')
+ .and('have.class', 'tablet-usa-icon-list--size-lg')
+ .and('have.class', 'desktop-usa-icon-list--size-2xl')
+ })
+
+ it('warns in console about invalid `size` prop', () => {
+ cy.stub(window.console, 'warn').as('consoleWarn')
+
+ mount(UsaIconList, {
+ props: {
+ size: 'invalidsize',
+ },
+ })
+
+ cy.get('@consoleWarn').should(
+ 'be.calledWith',
+ `'invalidsize' is not a valid icon list size`
+ )
+ })
+})
diff --git a/src/components/UsaIconList/UsaIconList.vue b/src/components/UsaIconList/UsaIconList.vue
new file mode 100644
index 00000000..60ffc7d9
--- /dev/null
+++ b/src/components/UsaIconList/UsaIconList.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
diff --git a/src/components/UsaIconList/index.js b/src/components/UsaIconList/index.js
new file mode 100644
index 00000000..89f776e5
--- /dev/null
+++ b/src/components/UsaIconList/index.js
@@ -0,0 +1,4 @@
+import UsaIconList from './UsaIconList.vue'
+
+export { UsaIconList }
+export default UsaIconList
diff --git a/src/components/UsaIconListItem/UsaIconListItem.stories.js b/src/components/UsaIconListItem/UsaIconListItem.stories.js
new file mode 100644
index 00000000..ffd9a55d
--- /dev/null
+++ b/src/components/UsaIconListItem/UsaIconListItem.stories.js
@@ -0,0 +1,126 @@
+import UsaIconListItem from './UsaIconListItem.vue'
+
+const defaultProps = {
+ // Name is required.
+ icon: 'flag',
+ title: UsaIconListItem.props.title.default,
+ titleTag: UsaIconListItem.props.titleTag.default,
+ customClasses: UsaIconListItem.props.customClasses.default(),
+}
+
+export default {
+ component: UsaIconListItem,
+ title: 'Components/UsaIconListItem',
+ argTypes: {
+ icon: {
+ control: { type: 'text' },
+ },
+ title: {
+ control: { type: 'text' },
+ },
+ titleTag: {
+ options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+ control: {
+ type: 'select',
+ },
+ },
+ customClasses: {
+ control: { type: 'object' },
+ },
+ iconSlot: {
+ control: { type: 'text' },
+ },
+ titleSlot: {
+ control: { type: 'text' },
+ },
+ defaultSlot: {
+ control: { type: 'text' },
+ },
+ },
+ args: {
+ icon: defaultProps.icon,
+ title: defaultProps.title,
+ titleTag: defaultProps.titleTag,
+ customClasses: defaultProps.customClasses,
+ iconSlot: '',
+ titleSlot: '',
+ defaultSlot:
+ 'An icon list reinforces the meaning and visibility of individual list items with a leading icon.
',
+ },
+ decorators: [
+ () => ({
+ template: '',
+ }),
+ ],
+}
+
+const DefaultTemplate = (args, { argTypes }) => ({
+ components: { UsaIconListItem },
+ props: Object.keys(argTypes),
+ setup() {
+ return { ...args }
+ },
+ template: `
+ ${args.iconSlot}
+ ${args.titleSlot}
+ ${
+ args.defaultSlot
+ }
+ `,
+})
+
+export const DefaultIconListItem = DefaultTemplate.bind({})
+DefaultIconListItem.args = {
+ ...defaultProps,
+}
+DefaultIconListItem.storyName = 'Default'
+
+export const TitleIconListItem = DefaultTemplate.bind({})
+TitleIconListItem.args = {
+ ...defaultProps,
+ icon: 'bug_report',
+ title: 'Icon list item title',
+}
+TitleIconListItem.storyName = 'Item Title'
+
+export const TitleTagIconListItem = DefaultTemplate.bind({})
+TitleTagIconListItem.args = {
+ ...defaultProps,
+ icon: 'format_size',
+ title: 'Icon list item title',
+ titleTag: 'h4',
+}
+TitleTagIconListItem.storyName = 'Custom Title Tag'
+
+export const IconSlotIconListItem = DefaultTemplate.bind({})
+IconSlotIconListItem.args = {
+ ...defaultProps,
+ title: 'Icon list item title',
+ iconSlot: ``,
+}
+IconSlotIconListItem.storyName = 'Icon Slot'
+
+export const TitleSlotIconListItem = DefaultTemplate.bind({})
+TitleSlotIconListItem.args = {
+ ...defaultProps,
+ icon: 'directions',
+ titleSlot: 'Icon slot title',
+}
+TitleSlotIconListItem.storyName = 'Title Slot'
+
+export const CustomClassesIconListItem = DefaultTemplate.bind({})
+CustomClassesIconListItem.args = {
+ ...defaultProps,
+ icon: 'chat',
+ customClasses: {
+ icon: ['test-icon-class'],
+ content: ['test-content-class'],
+ title: ['test-title-class'],
+ },
+}
+CustomClassesIconListItem.storyName = 'Custom Classes'
diff --git a/src/components/UsaIconListItem/UsaIconListItem.test.js b/src/components/UsaIconListItem/UsaIconListItem.test.js
new file mode 100644
index 00000000..ac0184ec
--- /dev/null
+++ b/src/components/UsaIconListItem/UsaIconListItem.test.js
@@ -0,0 +1,81 @@
+import '@module/uswds/dist/css/uswds.min.css'
+import { mount } from '@cypress/vue'
+import UsaIconListItem from './UsaIconListItem.vue'
+import { h } from 'vue'
+
+describe('UsaIconListItem', () => {
+ it('renders the component', () => {
+ mount(UsaIconListItem, {
+ props: {
+ icon: 'flag',
+ },
+ slots: {
+ default: () => h('p', {}, 'Test item content'),
+ },
+ })
+
+ cy.get('li.usa-icon-list__item').should('exist')
+ cy.get('div.usa-icon-list__icon > svg.usa-icon').should('exist')
+ cy.get('h2.usa-icon-list__title').should('not.exist')
+ cy.get('div.usa-icon-list__content > p').should(
+ 'contain',
+ 'Test item content'
+ )
+ })
+
+ it('renders custom title tag and slot content', () => {
+ mount(UsaIconListItem, {
+ props: {
+ icon: 'flag',
+ title: 'TestTitleProp',
+ titleTag: 'h3',
+ },
+ slots: {
+ icon: () => h('span', { class: 'test-icon-slot' }, 'Test icon slot'),
+ title: () => 'Test item title slot',
+ default: () => 'Test item content',
+ },
+ })
+
+ cy.get('.usa-icon-list__icon > span.test-icon-slot').should(
+ 'contain',
+ 'Test icon slot'
+ )
+ cy.get('h3.usa-icon-list__title').should('contain', 'Test item title slot')
+ })
+
+ it('title heading element displays title prop text', () => {
+ mount(UsaIconListItem, {
+ props: {
+ icon: 'flag',
+ title: 'Test item title',
+ },
+ slots: {
+ default: () => 'Test item content',
+ },
+ })
+
+ cy.get('h2.usa-icon-list__title').should('contain', 'Test item title')
+ })
+
+ it('adds custom CSS classes', () => {
+ mount(UsaIconListItem, {
+ props: {
+ icon: 'flag',
+ title: 'Test item title',
+ customClasses: {
+ icon: ['test-icon-class'],
+ content: ['test-content-class'],
+ title: ['test-title-class'],
+ },
+ },
+ slots: {
+ default: () => 'Test item content',
+ },
+ })
+
+ cy.get('.usa-icon-list__icon').should('have.class', 'test-icon-class')
+ cy.get('.usa-icon-list__content').should('have.class', 'test-content-class')
+ cy.get('.usa-icon-list__title').should('have.class', 'test-title-class')
+ })
+})
diff --git a/src/components/UsaIconListItem/UsaIconListItem.vue b/src/components/UsaIconListItem/UsaIconListItem.vue
new file mode 100644
index 00000000..5279d032
--- /dev/null
+++ b/src/components/UsaIconListItem/UsaIconListItem.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
diff --git a/src/components/UsaIconListItem/index.js b/src/components/UsaIconListItem/index.js
new file mode 100644
index 00000000..7031b3f0
--- /dev/null
+++ b/src/components/UsaIconListItem/index.js
@@ -0,0 +1,4 @@
+import UsaIconListItem from './UsaIconListItem.vue'
+
+export { UsaIconListItem }
+export default UsaIconListItem
diff --git a/src/components/UsaLink/UsaLink.vue b/src/components/UsaLink/UsaLink.vue
index 8128ec64..55b5a7d7 100644
--- a/src/components/UsaLink/UsaLink.vue
+++ b/src/components/UsaLink/UsaLink.vue
@@ -22,7 +22,5 @@ const classes = computed(() => [
-
+
diff --git a/src/components/UsaNav/UsaNav.test.js b/src/components/UsaNav/UsaNav.test.js
index 81a7b53e..32041df7 100644
--- a/src/components/UsaNav/UsaNav.test.js
+++ b/src/components/UsaNav/UsaNav.test.js
@@ -365,6 +365,25 @@ describe('UsaNav', () => {
cy.get('@nav').should('not.have.class', 'is-visible')
})
+ it('mobile menu is closed when unmounted', () => {
+ cy.viewport('iphone-6')
+
+ mount(UsaNav, {
+ global: {
+ provide: {
+ closeMobileMenu: cy.stub().as('closeMobileMenu'),
+ isMobileMenuOpen: ref(true),
+ },
+ },
+ }).as('wrapper')
+
+ cy.get('.usa-nav').should('exist')
+
+ cy.get('@wrapper').invoke('unmount')
+
+ cy.get('@closeMobileMenu').should('be.called')
+ })
+
it('uses custom button slot', () => {
mount(UsaNav, {
props: {
diff --git a/src/components/UsaNavDropdown/UsaNavDropdown.test.js b/src/components/UsaNavDropdown/UsaNavDropdown.test.js
index 9c837a15..bb662df2 100644
--- a/src/components/UsaNavDropdown/UsaNavDropdown.test.js
+++ b/src/components/UsaNavDropdown/UsaNavDropdown.test.js
@@ -52,7 +52,7 @@ describe('UsaNavDropdown', () => {
cy.get('@registerDropdown').should(
'be.calledWithMatch',
- '__vuswds-id-global-usa-nav-dropdown',
+ 'vuswds-id-global-usa-nav-dropdown',
true
)
})
diff --git a/src/components/UsaNavPrimary/UsaNavPrimary.test.js b/src/components/UsaNavPrimary/UsaNavPrimary.test.js
index f1732e5a..f5b665e8 100644
--- a/src/components/UsaNavPrimary/UsaNavPrimary.test.js
+++ b/src/components/UsaNavPrimary/UsaNavPrimary.test.js
@@ -164,7 +164,7 @@ describe('UsaNavPrimary', () => {
cy.get('@dropdownButton1').should('have.attr', 'aria-expanded', 'false')
cy.get('@dropdownButton1')
.should('have.attr', 'aria-controls')
- .and('contain', '__vuswds-id-global-usa-nav-dropdown-')
+ .and('contain', 'vuswds-id-global-usa-nav-dropdown-')
cy.get('@dropdownButton1').find('> span').should('contain', 'Section 2')
@@ -174,7 +174,7 @@ describe('UsaNavPrimary', () => {
.as('submenu1')
.should('have.class', 'usa-nav__submenu')
.and('have.attr', 'id')
- .and('contain', '__vuswds-id-global-usa-nav-dropdown-')
+ .and('contain', 'vuswds-id-global-usa-nav-dropdown-')
cy.get('@submenu1').should('have.attr', 'hidden')
// Item 3-1
@@ -254,7 +254,7 @@ describe('UsaNavPrimary', () => {
cy.get('@dropdownButton2').should('have.attr', 'aria-expanded', 'false')
cy.get('@dropdownButton2')
.should('have.attr', 'aria-controls')
- .and('contain', '__vuswds-id-global-usa-nav-dropdown-')
+ .and('contain', 'vuswds-id-global-usa-nav-dropdown-')
cy.get('@dropdownButton2').find('> span').should('contain', 'Section 3')
@@ -264,7 +264,7 @@ describe('UsaNavPrimary', () => {
.as('submenu2')
.should('have.class', 'usa-nav__submenu')
.and('have.attr', 'id')
- .and('contain', '__vuswds-id-global-usa-nav-dropdown-')
+ .and('contain', 'vuswds-id-global-usa-nav-dropdown-')
cy.get('@submenu2').should('have.attr', 'hidden')
// Item 4-1
@@ -344,7 +344,7 @@ describe('UsaNavPrimary', () => {
cy.get('@dropdownButton1').should('have.attr', 'aria-expanded', 'false')
cy.get('@dropdownButton1')
.should('have.attr', 'aria-controls')
- .and('contain', '__vuswds-id-global-usa-nav-dropdown-')
+ .and('contain', 'vuswds-id-global-usa-nav-dropdown-')
cy.get('@dropdownButton1').find('> span').should('contain', 'Section 2')
@@ -355,7 +355,7 @@ describe('UsaNavPrimary', () => {
cy.get('@item3')
.find('> div.usa-nav__submenu')
.should('have.attr', 'id')
- .and('contain', '__vuswds-id-global-usa-nav-dropdown-')
+ .and('contain', 'vuswds-id-global-usa-nav-dropdown-')
cy.get('@item3')
.find('> div.usa-nav__submenu > div')
@@ -478,7 +478,7 @@ describe('UsaNavPrimary', () => {
cy.get('@dropdownButton2').should('have.attr', 'aria-expanded', 'false')
cy.get('@dropdownButton2')
.should('have.attr', 'aria-controls')
- .and('contain', '__vuswds-id-global-usa-nav-dropdown-')
+ .and('contain', 'vuswds-id-global-usa-nav-dropdown-')
cy.get('@dropdownButton2').find('> span').should('contain', 'Section 3')
@@ -489,7 +489,7 @@ describe('UsaNavPrimary', () => {
cy.get('@item4')
.find('> div.usa-nav__submenu')
.should('have.attr', 'id')
- .and('contain', '__vuswds-id-global-usa-nav-dropdown-')
+ .and('contain', 'vuswds-id-global-usa-nav-dropdown-')
cy.get('@item4')
.find('> div.usa-nav__submenu > div')
@@ -546,31 +546,31 @@ describe('UsaNavPrimary', () => {
cy.get('@dropdownButton').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu').should('be.hidden').and('have.attr', 'hidden')
- cy.get('@dropdownButton').click()
-
- cy.get('@dropdownButton').should('have.attr', 'aria-expanded', 'true')
+ cy.get('@dropdownButton')
+ .click()
+ .should('have.attr', 'aria-expanded', 'true')
- cy.get('@dropdownMenu').should('not.have.attr', 'hidden')
+ cy.get('@dropdownMenu').should('be.visible').and('not.have.attr', 'hidden')
cy.realPress('Escape')
cy.get('@dropdownButton').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu').should('be.hidden').and('have.attr', 'hidden')
- cy.get('@dropdownButton').click()
-
- cy.get('@dropdownButton').should('have.attr', 'aria-expanded', 'true')
+ cy.get('@dropdownButton')
+ .click()
+ .should('have.attr', 'aria-expanded', 'true')
- cy.get('@dropdownMenu').should('not.have.attr', 'hidden')
+ cy.get('@dropdownMenu').should('be.visible').and('not.have.attr', 'hidden')
cy.get('html').click('topLeft')
cy.get('@dropdownButton').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu').should('be.hidden').and('have.attr', 'hidden')
})
it('dropdowns are multiselectable on mobile screens', () => {
@@ -608,19 +608,19 @@ describe('UsaNavPrimary', () => {
)
cy.get('@dropdownButton1').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu1').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu1').should('be.hidden').and('have.attr', 'hidden')
cy.get('@dropdownButton2').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu2').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu2').should('be.hidden').and('have.attr', 'hidden')
// Click first dropdown.
cy.get('@dropdownButton1')
.click()
.should('have.attr', 'aria-expanded', 'true')
- cy.get('@dropdownMenu1').should('not.have.attr', 'hidden')
+ cy.get('@dropdownMenu1').should('be.visible').and('not.have.attr', 'hidden')
cy.get('@dropdownButton2').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu2').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu2').should('be.hidden').and('have.attr', 'hidden')
// Click second dropdown.
cy.get('@dropdownButton2')
@@ -628,48 +628,49 @@ describe('UsaNavPrimary', () => {
.should('have.attr', 'aria-expanded', 'true')
cy.get('@dropdownButton1').should('have.attr', 'aria-expanded', 'true')
- cy.get('@dropdownMenu1').should('not.have.attr', 'hidden')
- cy.get('@dropdownMenu2').should('not.have.attr', 'hidden')
+ cy.get('@dropdownMenu1').should('be.visible').and('not.have.attr', 'hidden')
+ cy.get('@dropdownMenu2').should('be.visible').and('not.have.attr', 'hidden')
// Click first dropdown again.
cy.get('@dropdownButton1')
.click()
.should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu1').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu1').should('be.hidden').and('have.attr', 'hidden')
cy.get('@dropdownButton2').should('have.attr', 'aria-expanded', 'true')
- cy.get('@dropdownMenu2').should('not.have.attr', 'hidden')
+ cy.get('@dropdownMenu2').should('be.visible').and('not.have.attr', 'hidden')
- // Click second dropdown again.
- cy.get('@dropdownButton2')
+ // Click first dropdown again.
+ cy.get('@dropdownButton1')
.click()
- .should('have.attr', 'aria-expanded', 'false')
+ .should('have.attr', 'aria-expanded', 'true')
+ cy.get('@dropdownMenu1').should('be.visible').and('not.have.attr', 'hidden')
+ cy.get('@dropdownButton2').should('have.attr', 'aria-expanded', 'true')
+ cy.get('@dropdownMenu2').should('be.visible').and('not.have.attr', 'hidden')
// Set for large screens.
cy.viewport('macbook-15')
+ // All dropdowns should now be closed.
cy.get('@dropdownButton1').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu1').should('have.attr', 'hidden')
-
+ cy.get('@dropdownMenu1').should('be.hidden').and('have.attr', 'hidden')
cy.get('@dropdownButton2').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu2').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu2').should('be.hidden').and('have.attr', 'hidden')
// Click first dropdown.
cy.get('@dropdownButton1')
.click()
.should('have.attr', 'aria-expanded', 'true')
- cy.get('@dropdownMenu1').should('not.have.attr', 'hidden')
-
+ cy.get('@dropdownMenu1').should('be.visible').and('not.have.attr', 'hidden')
cy.get('@dropdownButton2').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu2').should('have.attr', 'hidden')
+ cy.get('@dropdownMenu2').should('be.hidden').and('have.attr', 'hidden')
// Click second dropdown.
cy.get('@dropdownButton2')
.click()
.should('have.attr', 'aria-expanded', 'true')
-
+ cy.get('@dropdownMenu2').should('be.visible').and('not.have.attr', 'hidden')
cy.get('@dropdownButton1').should('have.attr', 'aria-expanded', 'false')
- cy.get('@dropdownMenu1').should('have.attr', 'hidden')
- cy.get('@dropdownMenu2').should('not.have.attr', 'hidden')
+ cy.get('@dropdownMenu1').should('be.hidden').and('have.attr', 'hidden')
})
it('clicking dropdown emits items in event', () => {
@@ -694,7 +695,7 @@ describe('UsaNavPrimary', () => {
.then(vm => {
expect(vm.emitted()).to.have.property('update:items')
const currentRangeEvent = vm.emitted('update:items')
- expect(currentRangeEvent).to.have.length(2)
+ expect(currentRangeEvent).to.have.length(1)
const dropdownIds = Object.keys(
currentRangeEvent[currentRangeEvent.length - 1][0]
@@ -712,7 +713,7 @@ describe('UsaNavPrimary', () => {
.then(vm => {
expect(vm.emitted()).to.have.property('update:items')
const currentRangeEvent = vm.emitted('update:items')
- expect(currentRangeEvent).to.have.length(3)
+ expect(currentRangeEvent).to.have.length(2)
const dropdownIds = Object.keys(
currentRangeEvent[currentRangeEvent.length - 1][0]
diff --git a/src/components/UsaNavPrimary/UsaNavPrimary.vue b/src/components/UsaNavPrimary/UsaNavPrimary.vue
index edd8079b..c7acbd85 100644
--- a/src/components/UsaNavPrimary/UsaNavPrimary.vue
+++ b/src/components/UsaNavPrimary/UsaNavPrimary.vue
@@ -41,6 +41,8 @@ watch(dropdownItems, () => {
emit('update:items', dropdownItems)
})
+watch(largeScreen, closeAllItems)
+
provide('registerDropdown', registerAccordionItem)
provide('unregisterDropdown', unregisterAccordionItem)
provide('toggleDropdown', toggleItem)
@@ -48,13 +50,9 @@ provide('closeDropdown', closeItem)
provide('closeAllDropdowns', closeAllItems)
provide('dropdownItems', dropdownItems)
-onKeyStroke('Escape', () => {
- closeAllItems()
-})
+onKeyStroke('Escape', closeAllItems)
-onClickOutside(nav, () => {
- closeAllItems()
-})
+onClickOutside(nav, closeAllItems)
diff --git a/src/components/UsaTooltip/UsaTooltip.stories.js b/src/components/UsaTooltip/UsaTooltip.stories.js
new file mode 100644
index 00000000..ff5a4c55
--- /dev/null
+++ b/src/components/UsaTooltip/UsaTooltip.stories.js
@@ -0,0 +1,165 @@
+import UsaTooltip from './UsaTooltip.vue'
+
+const defaultProps = {
+ label: UsaTooltip.props.label.default,
+ id: UsaTooltip.props.id.default,
+ wrapperTag: UsaTooltip.props.wrapperTag.default,
+ tag: UsaTooltip.props.tag.default,
+ position: UsaTooltip.props.position.default,
+ customClasses: UsaTooltip.props.customClasses.default(),
+}
+
+export default {
+ component: UsaTooltip,
+ title: 'Components/UsaTooltip',
+ argTypes: {
+ label: {
+ control: { type: 'text' },
+ },
+ id: {
+ control: { type: 'text' },
+ },
+ wrapperTag: {
+ control: { type: 'text' },
+ },
+ tag: {
+ control: { type: 'text' },
+ },
+ position: {
+ options: ['top', 'bottom', 'left', 'right'],
+ control: {
+ type: 'select',
+ },
+ },
+ customClasses: {
+ control: { type: 'object' },
+ },
+ defaultSlot: {
+ control: { type: 'text' },
+ },
+ labelSlot: {
+ control: { type: 'text' },
+ },
+ },
+ args: {
+ label: defaultProps.label,
+ id: defaultProps.id,
+ wrapperTag: defaultProps.wrapperTag,
+ tag: defaultProps.tag,
+ position: defaultProps.position,
+ customClasses: defaultProps.customClasses,
+ defaultSlot: '',
+ labelSlot: '',
+ },
+ decorators: [
+ () => ({
+ template:
+ '',
+ }),
+ ],
+}
+
+const DefaultTemplate = (args, { argTypes }) => ({
+ components: { UsaTooltip },
+ props: Object.keys(argTypes),
+ setup() {
+ return { ...args }
+ },
+ template: `
+ ${
+ args.defaultSlot
+ }
+ ${args.labelSlot}
+ `,
+})
+
+export const DefaultTooltip = DefaultTemplate.bind({})
+DefaultTooltip.args = {
+ ...defaultProps,
+ label: 'Test tooltip',
+ defaultSlot: 'Tooltip trigger element',
+}
+DefaultTooltip.storyName = 'Default'
+
+export const TopPositionTooltip = DefaultTemplate.bind({})
+TopPositionTooltip.args = {
+ ...defaultProps,
+ label: 'Test tooltip',
+ position: 'top',
+ defaultSlot: 'Top tooltip',
+}
+TopPositionTooltip.storyName = 'Top Position'
+
+export const BottomPositionTooltip = DefaultTemplate.bind({})
+BottomPositionTooltip.args = {
+ ...defaultProps,
+ label: 'Test tooltip',
+ position: 'bottom',
+ defaultSlot: 'Bottom tooltip',
+}
+BottomPositionTooltip.storyName = 'Bottom Position'
+
+export const LeftPositionTooltip = DefaultTemplate.bind({})
+LeftPositionTooltip.args = {
+ ...defaultProps,
+ label: 'Test tooltip',
+ position: 'left',
+ defaultSlot: 'Left tooltip',
+}
+LeftPositionTooltip.storyName = 'Left Position'
+
+export const RightPositionTooltip = DefaultTemplate.bind({})
+RightPositionTooltip.args = {
+ ...defaultProps,
+ label: 'Test tooltip',
+ position: 'right',
+ defaultSlot: 'Right tooltip',
+}
+RightPositionTooltip.storyName = 'Right Position'
+
+export const CustomIdTooltip = DefaultTemplate.bind({})
+CustomIdTooltip.args = {
+ ...defaultProps,
+ label: 'Test tooltip',
+ id: 'test-custom-id',
+ defaultSlot: 'Tooltip using custom ID',
+}
+CustomIdTooltip.storyName = 'Custom ID'
+
+export const CustomWrapperComponentTagsTooltip = DefaultTemplate.bind({})
+CustomWrapperComponentTagsTooltip.args = {
+ ...defaultProps,
+ label: 'Test tooltip',
+ wrapperTag: 'div',
+ tag: 'abbr',
+ defaultSlot: 'Custom tags tooltip',
+}
+CustomWrapperComponentTagsTooltip.storyName =
+ 'Custom Wrapper and Component tags'
+
+export const LabelSlotTooltip = DefaultTemplate.bind({})
+LabelSlotTooltip.args = {
+ ...defaultProps,
+ labelSlot: 'Label w/ HTML>',
+ defaultSlot: 'Tooltip using label slot',
+}
+LabelSlotTooltip.storyName = 'Label Slot'
+
+export const CustomClassesTooltip = DefaultTemplate.bind({})
+CustomClassesTooltip.args = {
+ ...defaultProps,
+ label: 'Test tooltip',
+ defaultSlot: 'Uses custom CSS classes',
+ customClasses: {
+ component: ['test-component-class'],
+ label: ['test-label-class'],
+ },
+}
+CustomClassesTooltip.storyName = 'Custom Classes'
diff --git a/src/components/UsaTooltip/UsaTooltip.test.js b/src/components/UsaTooltip/UsaTooltip.test.js
new file mode 100644
index 00000000..53e0bc26
--- /dev/null
+++ b/src/components/UsaTooltip/UsaTooltip.test.js
@@ -0,0 +1,164 @@
+import '@module/uswds/dist/css/uswds.min.css'
+import { mount } from '@cypress/vue'
+import UsaTooltip from './UsaTooltip.vue'
+
+describe('UsaTooltip', () => {
+ it('renders the component', () => {
+ mount(UsaTooltip, {
+ props: {
+ id: 'test-tooltip-id',
+ label: 'Test tooltip label',
+ customClasses: {},
+ },
+ slots: {
+ default: () => 'Test tooltip trigger',
+ },
+ })
+
+ cy.get('span.usa-tooltip').as('tooltip').should('exist')
+
+ cy.get('span.usa-tooltip__trigger')
+ .as('tooltipTrigger')
+ .should('have.attr', 'tabindex', '0')
+ .and('have.attr', 'aria-describedby', 'test-tooltip-id')
+ .and('contain', 'Test tooltip trigger')
+
+ cy.get('span.usa-tooltip__body')
+ .as('tooltipLabel')
+ .should('have.id', 'test-tooltip-id')
+ .and('not.have.class', 'is-set')
+ .and('not.have.class', 'is-visible')
+ .and('have.attr', 'role', 'tooltip')
+ .and('have.attr', 'aria-hidden', 'true')
+ .and('have.class', 'usa-tooltip__body--top')
+ .and('contain', 'Test tooltip label')
+ .and('be.hidden')
+
+ cy.get('@tooltipTrigger').trigger('mouseover')
+
+ cy.get('@tooltipLabel')
+ .should('be.visible')
+ .and('have.class', 'is-set')
+ .and('have.class', 'is-visible')
+ .and('have.class', 'usa-tooltip__body--bottom')
+ .and('have.attr', 'aria-hidden', 'false')
+ .and('have.attr', 'style')
+
+ cy.get('@tooltipTrigger').trigger('mouseout')
+
+ cy.get('@tooltipLabel')
+ .should('be.hidden')
+ .and('not.have.class', 'is-set')
+ .and('not.have.class', 'is-visible')
+ .and('have.attr', 'aria-hidden', 'true')
+
+ cy.get('@tooltipTrigger').focus()
+
+ cy.get('@tooltipLabel')
+ .should('be.visible')
+ .and('have.class', 'is-set')
+ .and('have.class', 'is-visible')
+ .and('have.attr', 'aria-hidden', 'false')
+
+ cy.get('@tooltipTrigger').blur()
+
+ cy.get('@tooltipLabel')
+ .should('be.hidden')
+ .and('not.have.class', 'is-set')
+ .and('not.have.class', 'is-visible')
+ .and('have.attr', 'aria-hidden', 'true')
+
+ cy.get('@tooltipTrigger').trigger('mouseover')
+
+ cy.get('@tooltipLabel')
+ .should('be.visible')
+ .and('have.class', 'is-set')
+ .and('have.class', 'is-visible')
+ .and('have.attr', 'aria-hidden', 'false')
+
+ cy.get('@tooltip').type('{esc}')
+
+ cy.get('@tooltipLabel')
+ .should('be.hidden')
+ .and('not.have.class', 'is-set')
+ .and('not.have.class', 'is-visible')
+ .and('have.attr', 'aria-hidden', 'true')
+ })
+
+ it('renders with custom tags, CSS classes, and label slot content', () => {
+ mount(UsaTooltip, {
+ props: {
+ wrapperTag: 'div',
+ tag: 'nuxt-link',
+ label: 'Test tooltip label',
+ customClasses: {
+ component: ['test-component-class'],
+ label: ['test-label-class'],
+ },
+ },
+ attrs: {
+ to: '/test-link',
+ class: 'test-tooltip-trigger-class',
+ },
+ slots: {
+ default: () => 'Test tooltip trigger',
+ label: () => 'Test label slot',
+ },
+ })
+
+ cy.get('div.usa-tooltip')
+ .and('have.class', 'test-component-class')
+ .should('not.have.class', 'test-tooltip-trigger-class')
+ .and('not.have.attr', 'to')
+
+ cy.get('nuxt-link.usa-tooltip__trigger')
+ .and('have.class', 'test-tooltip-trigger-class')
+ .and('have.attr', 'aria-describedby', 'vuswds-id-global-usa-tooltip-1')
+ .and('have.attr', 'to', '/test-link')
+ .and('contain', 'Test tooltip trigger')
+
+ cy.get('span.usa-tooltip__body')
+ .should('have.id', 'vuswds-id-global-usa-tooltip-1')
+ .and('have.class', 'test-label-class')
+ .and('contain', 'Test label slot')
+ })
+
+ it('sets correct CSS class for each position', () => {
+ const positions = ['top', 'bottom', 'left', 'right']
+
+ positions.forEach(position => {
+ mount(UsaTooltip, {
+ props: {
+ position: position,
+ label: 'Test tooltip label',
+ },
+ slots: {
+ default: () => 'Test tooltip trigger',
+ },
+ }).as('wrapper')
+
+ cy.get('.usa-tooltip__body').and(
+ 'have.class',
+ `usa-tooltip__body--${position}`
+ )
+
+ cy.get('@wrapper').invoke('unmount')
+ })
+ })
+
+ it('warns in console about invalid `position` prop value', () => {
+ cy.spy(window.console, 'warn').as('consoleWarn')
+
+ mount(UsaTooltip, {
+ props: {
+ position: 'notavalidposition',
+ label: 'Test tooltip label',
+ },
+ })
+
+ cy.get('@consoleWarn').should(
+ 'be.calledWith',
+ `'notavalidposition' is not a valid tooltip position`
+ )
+ })
+})
diff --git a/src/components/UsaTooltip/UsaTooltip.vue b/src/components/UsaTooltip/UsaTooltip.vue
new file mode 100644
index 00000000..d72faff9
--- /dev/null
+++ b/src/components/UsaTooltip/UsaTooltip.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+ {{ label }}
+
+
diff --git a/src/components/UsaTooltip/index.js b/src/components/UsaTooltip/index.js
new file mode 100644
index 00000000..d06faf72
--- /dev/null
+++ b/src/components/UsaTooltip/index.js
@@ -0,0 +1,4 @@
+import UsaTooltip from './UsaTooltip.vue'
+
+export { UsaTooltip }
+export default UsaTooltip
diff --git a/src/components/index.js b/src/components/index.js
index 0c4e9be9..88ad8a00 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -79,3 +79,11 @@ export { default as UsaTag } from './UsaTag'
export { default as UsaTextarea } from './UsaTextarea'
export { default as UsaTextInput } from './UsaTextInput'
export { default as UsaValidation } from './UsaValidation'
+export { default as UsaIcon } from './UsaIcon'
+export { default as UsaIconListItem } from './UsaIconListItem'
+export { default as UsaIconList } from './UsaIconList'
+export { default as UsaFooterNav } from './UsaFooterNav'
+export { default as UsaFooterMenu } from './UsaFooterMenu'
+export { default as UsaFooterCollapsibleMenu } from './UsaFooterCollapsibleMenu'
+export { default as UsaFooterCollapsibleMenuSection } from './UsaFooterCollapsibleMenuSection'
+export { default as UsaTooltip } from './UsaTooltip'
diff --git a/src/composables/useAccordion.js b/src/composables/useAccordion.js
index fcd489fd..9e2f287f 100644
--- a/src/composables/useAccordion.js
+++ b/src/composables/useAccordion.js
@@ -1,6 +1,6 @@
import { reactive } from 'vue'
-export default (_accordionItems, multiselectable) => {
+export default (_accordionItems, multiselectable = false) => {
const accordionItems = reactive(_accordionItems)
const registerAccordionItem = (id, isOpen) => {
@@ -23,6 +23,10 @@ export default (_accordionItems, multiselectable) => {
delete accordionItems[id]
}
+ const closeItem = id => {
+ accordionItems[id] = false
+ }
+
const openItem = id => {
accordionItems[id] = true
@@ -32,15 +36,11 @@ export default (_accordionItems, multiselectable) => {
for (const accordionId in accordionItems) {
if (accordionId !== id) {
- accordionItems[accordionId] = false
+ closeItem(accordionId)
}
}
}
- const closeItem = id => {
- accordionItems[id] = false
- }
-
const toggleItem = id => {
if (accordionItems[id]) {
closeItem(id)
@@ -50,13 +50,9 @@ export default (_accordionItems, multiselectable) => {
}
const closeAllItems = () => {
- const items = Object.keys(accordionItems)
-
- items.forEach(accordionId => {
- if (accordionItems[accordionId]) {
- accordionItems[accordionId] = false
- }
- })
+ for (const accordionId in accordionItems) {
+ closeItem(accordionId)
+ }
}
return {
diff --git a/src/composables/useToggle.js b/src/composables/useToggle.js
index 098e5f9b..777a84ee 100644
--- a/src/composables/useToggle.js
+++ b/src/composables/useToggle.js
@@ -23,14 +23,14 @@ export default (_id, idPrefix = '', defaultOpen = false, emit) => {
}
}
- watch(isOpen, () => {
+ watch(isOpen, newValue => {
if (emit) {
- emit('update:open', isOpen.value)
+ emit('update:open', newValue)
}
})
- watch(propValue, () => {
- if (propValue.value !== isOpen.value) {
+ watch(propValue, newValue => {
+ if (propValue.value !== newValue) {
toggleContent()
}
})
diff --git a/src/core.js b/src/core.js
index 41310d43..f2924b09 100644
--- a/src/core.js
+++ b/src/core.js
@@ -8,6 +8,7 @@ import {
SVG_SPRITE_PATH,
ROUTER_COMPONENT_NAME,
MOBILE_MENU_BREAKPOINT,
+ FOOTER_NAV_COLLAPSIBLE_BREAKPOINT,
} from '@/utils/constants.js'
export default {
@@ -22,6 +23,7 @@ export default {
svgSpritePath: SVG_SPRITE_PATH,
routerComponentName: ROUTER_COMPONENT_NAME,
mobileMenuBreakpoint: MOBILE_MENU_BREAKPOINT,
+ footerNavBigBreakpoint: FOOTER_NAV_COLLAPSIBLE_BREAKPOINT,
...customOptions,
version: version,
}
diff --git a/src/utils/constants.js b/src/utils/constants.js
index f3dabb8c..9b7752b0 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -5,3 +5,4 @@ export const IMAGE_PATH = '/assets/img'
export const SVG_SPRITE_PATH = '/assets/img/sprite.svg'
export const ROUTER_COMPONENT_NAME = null
export const MOBILE_MENU_BREAKPOINT = '64em'
+export const FOOTER_NAV_COLLAPSIBLE_BREAKPOINT = '30em'
diff --git a/src/utils/unique-id.js b/src/utils/unique-id.js
index cd337d9b..5a94c5ec 100644
--- a/src/utils/unique-id.js
+++ b/src/utils/unique-id.js
@@ -1,7 +1,7 @@
import { getCurrentInstance } from 'vue'
import { kebabCase } from '@/utils/common.js'
-const idPrefix = '__vuswds-id-'
+const idPrefix = 'vuswds-id-'
const idRegistry = {}
// Adapted from: