diff --git a/packages/storybook/stories/va-language-toggle.stories.jsx b/packages/storybook/stories/va-language-toggle.stories.jsx new file mode 100644 index 0000000000..c690df2d44 --- /dev/null +++ b/packages/storybook/stories/va-language-toggle.stories.jsx @@ -0,0 +1,68 @@ +import React, { Fragment } from 'react'; +import { getWebComponentDocs, StoryDocs, propStructure } from './wc-helpers'; +import { VaLanguageToggle } from '@department-of-veterans-affairs/web-components/react-bindings'; + +VaLanguageToggle.displayName = 'VaLanguageToggle'; + +const languageToggleDocs = getWebComponentDocs('va-language-toggle'); + +export default { + title: 'Components/Language Toggle', + id: 'components/va-language-toggle', + parameters: { + componentSubtitle: 'va-language-toggle web component', + docs: { + page: () => , + }, + }, +}; + +const url = new URL(window.parent.location.href); +url.searchParams.set('path', '/docs/components-va-language-toggle--docs'); + +const Template = ({}) => { + let lang = sessionStorage.getItem('va-language-toggle-lang') ?? 'en'; + function handleLanguageToggle(e) { + const { language } = e.detail; + sessionStorage.setItem('va-language-toggle-lang', language) + } + + return ( + + ); +}; + +const WithRouterLinksTemplate = ({}) => { + + function handleLanguageToggle(e) { + console.log(`the language has been toggled to ${e.detail.language}`); + } + + return ( + +
This example illustrates how to use the component with a router. When router-links is + set to true, clicking on a link will not navigate to a new page (i.e. event.preventDefault() is called). + By capturing the language-toggle event page content can be updated as needed to reflect the selected language. +
+
+ +
+ ) +} + +export const Default = Template.bind(null); +Default.argTypes = propStructure(languageToggleDocs); + +export const WithRouterLinks = WithRouterLinksTemplate.bind(null); diff --git a/packages/web-components/src/components.d.ts b/packages/web-components/src/components.d.ts index fb878abc41..97a5a80a6e 100644 --- a/packages/web-components/src/components.d.ts +++ b/packages/web-components/src/components.d.ts @@ -636,6 +636,33 @@ export namespace Components { */ "srtext"?: string; } + /** + * @componentName Language Toggle + * @maturityCategory caution + * @maturityLevel candidate + */ + interface VaLanguageToggle { + /** + * The English language href for the page. Required. + */ + "enHref": string; + /** + * The Spanish language href for the page. Optional. + */ + "esHref"?: string; + /** + * The ISO language code for the page. Default is 'en'. + */ + "language": string; + /** + * If true, specifies that the toggle is being used on a page with a router and clicking on a link will not result in page navigation. + */ + "routerLinks"?: boolean; + /** + * The Tagalog language href for the page. Optional. + */ + "tlHref"?: string; + } /** * @componentName Link * @maturityCategory caution @@ -698,6 +725,10 @@ export namespace Components { * Adds an aria-label attribute to the link element. */ "label"?: string; + /** + * The lang attribute for the anchor tag in the Default va-link. Also used for hreflang. + */ + "language"?: string; /** * The number of pages of the file. Only displayed if download is `true`. */ @@ -1825,6 +1856,10 @@ export interface VaFileInputMultipleCustomEvent extends CustomEvent { detail: T; target: HTMLVaFileInputMultipleElement; } +export interface VaLanguageToggleCustomEvent extends CustomEvent { + detail: T; + target: HTMLVaLanguageToggleElement; +} export interface VaLinkCustomEvent extends CustomEvent { detail: T; target: HTMLVaLinkElement; @@ -2330,6 +2365,29 @@ declare global { prototype: HTMLVaIconElement; new (): HTMLVaIconElement; }; + interface HTMLVaLanguageToggleElementEventMap { + "vaLanguageToggle": any; + "component-library-analytics": any; + } + /** + * @componentName Language Toggle + * @maturityCategory caution + * @maturityLevel candidate + */ + interface HTMLVaLanguageToggleElement extends Components.VaLanguageToggle, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLVaLanguageToggleElement, ev: VaLanguageToggleCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLVaLanguageToggleElement, ev: VaLanguageToggleCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLVaLanguageToggleElement: { + prototype: HTMLVaLanguageToggleElement; + new (): HTMLVaLanguageToggleElement; + }; interface HTMLVaLinkElementEventMap { "component-library-analytics": any; } @@ -2972,6 +3030,7 @@ declare global { "va-file-input-multiple": HTMLVaFileInputMultipleElement; "va-header-minimal": HTMLVaHeaderMinimalElement; "va-icon": HTMLVaIconElement; + "va-language-toggle": HTMLVaLanguageToggleElement; "va-link": HTMLVaLinkElement; "va-link-action": HTMLVaLinkActionElement; "va-loading-indicator": HTMLVaLoadingIndicatorElement; @@ -3730,6 +3789,41 @@ declare namespace LocalJSX { */ "srtext"?: string; } + /** + * @componentName Language Toggle + * @maturityCategory caution + * @maturityLevel candidate + */ + interface VaLanguageToggle { + /** + * The English language href for the page. Required. + */ + "enHref": string; + /** + * The Spanish language href for the page. Optional. + */ + "esHref"?: string; + /** + * The ISO language code for the page. Default is 'en'. + */ + "language"?: string; + /** + * The event used to track usage of the component. + */ + "onComponent-library-analytics"?: (event: VaLanguageToggleCustomEvent) => void; + /** + * Event fired when a link is clicked. Includes the selected language's ISO code. + */ + "onVaLanguageToggle"?: (event: VaLanguageToggleCustomEvent) => void; + /** + * If true, specifies that the toggle is being used on a page with a router and clicking on a link will not result in page navigation. + */ + "routerLinks"?: boolean; + /** + * The Tagalog language href for the page. Optional. + */ + "tlHref"?: string; + } /** * @componentName Link * @maturityCategory caution @@ -3793,8 +3887,9 @@ declare namespace LocalJSX { */ "label"?: string; /** - * The event used to track usage of the component. + * The lang attribute for the anchor tag in the Default va-link. Also used for hreflang. */ + "language"?: string; "onComponent-library-analytics"?: (event: VaLinkCustomEvent) => void; /** * The number of pages of the file. Only displayed if download is `true`. @@ -5031,6 +5126,7 @@ declare namespace LocalJSX { "va-file-input-multiple": VaFileInputMultiple; "va-header-minimal": VaHeaderMinimal; "va-icon": VaIcon; + "va-language-toggle": VaLanguageToggle; "va-link": VaLink; "va-link-action": VaLinkAction; "va-loading-indicator": VaLoadingIndicator; @@ -5206,6 +5302,12 @@ declare module "@stencil/core" { * @maturityLevel candidate */ "va-icon": LocalJSX.VaIcon & JSXBase.HTMLAttributes; + /** + * @componentName Language Toggle + * @maturityCategory caution + * @maturityLevel candidate + */ + "va-language-toggle": LocalJSX.VaLanguageToggle & JSXBase.HTMLAttributes; /** * @componentName Link * @maturityCategory caution diff --git a/packages/web-components/src/components/va-language-toggle/test/va-language-toggle.e2e.ts b/packages/web-components/src/components/va-language-toggle/test/va-language-toggle.e2e.ts new file mode 100644 index 0000000000..8807eb8777 --- /dev/null +++ b/packages/web-components/src/components/va-language-toggle/test/va-language-toggle.e2e.ts @@ -0,0 +1,70 @@ +import { newE2EPage } from "@stencil/core/testing"; +import { axeCheck } from "../../../testing/test-helpers"; + +describe('va-language-toggle', () => { + const enHref = "/resources/the-pact-act-and-your-va-benefits/"; + const esHref = "/resources/the-pact-act-and-your-va-benefits-esp/" + const tlHref = "/resources/the-pact-act-and-your-va-benefits-tl/" + + it('renders', async () => { + const page = await newE2EPage(); + await page.setContent(``); + const element = await page.find('va-language-toggle'); + expect(element).toHaveClass('hydrated'); + }); + + it('English is the default language', async () => { + const page = await newE2EPage(); + await page.setContent(``); + const anchor = await page.find('va-language-toggle >>> va-link'); + expect(anchor).toHaveClass('is-current-lang'); + }); + + it('only renders links for those languages with supplied hrefs', async () => { + const page = await newE2EPage(); + await page.setContent(``); + const anchors = await page.findAll('va-language-toggle >>> a'); + expect(anchors).toHaveLength(2); + }) + + it('if language prop is set the matching language is bolded', async () => { + const page = await newE2EPage(); + await page.setContent(``); + const [_, anchor] = await page.findAll('va-language-toggle >>> va-link'); + expect(anchor).toHaveClass('is-current-lang'); + }); + + it('if router-links is set, clicking an anchor tag does not result in page navigation', async () => { + const page = await newE2EPage(); + await page.setContent(``); + const [startUrl] = page.url().split('?'); + const [_, anchor] = await page.findAll('va-language-toggle >>> a'); + await anchor.click(); + const [endUrl] = page.url().split('?'); + expect(startUrl).toEqual(endUrl); + }); + + it('fires a language-toggle event when a link is clicked', async () => { + const page = await newE2EPage(); + await page.setContent(``); + const toggleSpy = await page.spyOnEvent('vaLanguageToggle'); + const [_, anchor] = await page.findAll('va-language-toggle >>> a'); + await anchor.click(); + expect(toggleSpy).toHaveReceivedEvent(); + }); + + it('fires a component-analytics event when a link is clicked', async () => { + const page = await newE2EPage(); + await page.setContent(``); + const toggleSpy = await page.spyOnEvent('component-library-analytics'); + const anchor = await page.find('va-language-toggle >>> a'); + await anchor.click(); + expect(toggleSpy).toHaveReceivedEvent(); + }); + + it('passes an aXe check', async () => { + const page = await newE2EPage(); + await page.setContent(``); + await axeCheck(page); + }); +}); diff --git a/packages/web-components/src/components/va-language-toggle/va-language-toggle.scss b/packages/web-components/src/components/va-language-toggle/va-language-toggle.scss new file mode 100644 index 0000000000..09d007d55a --- /dev/null +++ b/packages/web-components/src/components/va-language-toggle/va-language-toggle.scss @@ -0,0 +1,21 @@ +:host div { + display: inline-block; + + div.inner-div { + border-right: 1px solid var(--vads-color-base-light); + padding-right: 8px; + margin-right: 8px; + } + + va-link { + &::part(anchor) { + text-decoration: none; + } + &.is-current-lang { + font-weight: var(--font-weight-bold); + } + color: var(--vads-color-link); + padding-bottom: 2px; + border-bottom: 1px solid var(--vads-color-link); + } +} diff --git a/packages/web-components/src/components/va-language-toggle/va-language-toggle.tsx b/packages/web-components/src/components/va-language-toggle/va-language-toggle.tsx new file mode 100644 index 0000000000..cfb352023e --- /dev/null +++ b/packages/web-components/src/components/va-language-toggle/va-language-toggle.tsx @@ -0,0 +1,153 @@ +import { Component, Host, h, State, Event, EventEmitter, Prop } from '@stencil/core'; +import classNames from 'classnames'; + +export type LangUrl = { + label: string; + href: string; + lang: string; +}; + +/** + * @componentName Language Toggle + * @maturityCategory caution + * @maturityLevel candidate + */ + +@Component({ + tag: 'va-language-toggle', + styleUrl: 'va-language-toggle.scss', + shadow: true, +}) +export class VaLanguageToggle { + /** + * The ISO language code for the page. Default is 'en'. + */ + @Prop() language: string = 'en'; + + /** + * The English language href for the page. Required. + */ + @Prop() enHref!: string; + + /** + * The Spanish language href for the page. Optional. + */ + @Prop() esHref?: string; + + /** + * The Tagalog language href for the page. Optional. + */ + @Prop() tlHref?: string; + + /** + * A JSON array of objects with link data. + */ + @State() urls: LangUrl[]; + + /** + * If true, specifies that the toggle is being used on a page with a router and clicking on a link will not result in page navigation. + */ + @Prop() routerLinks?: boolean = false; + + /** + * Event fired when a link is clicked. Includes the selected language's ISO code. + */ + @Event() + vaLanguageToggle: EventEmitter; + + /** + * The event used to track usage of the component. + */ + @Event({ + bubbles: true, + composed: true, + eventName: 'component-library-analytics', + }) + componentLibraryAnalytics: EventEmitter; + + // get the current page's url with language as a query param. + // allows for marking "pages" as visited + getUrl(langCode: string): string { + const url = new URL(window.location.href); + url.searchParams.set('lang', langCode); + return url.href; + } + + // This method is fired whenever a link is clicked + handleToggle(e: Event, langCode: string): void { + // don't navigate from current page but set new language + if (this.routerLinks) { + e.preventDefault(); + // change browser url so that :visited styles apply to links + window.history.replaceState(null, null, this.getUrl(langCode)); + this.language = langCode; + } + + this.vaLanguageToggle.emit({ language: langCode }); + + const detail = { + componentName: 'va-language-toggle', + action: 'linkClick', + details: { + 'pipe-delimited-list-header': langCode + }, + }; + this.componentLibraryAnalytics.emit(detail); + } + + componentWillLoad() { + // always include English + const urls = [{ + label: "English", + lang: "en", + href: this.enHref + }]; + + if (this.esHref) { + urls.push({ + label: "EspaƱol", + lang: "es", + href: this.esHref + }); + } + + if (this.tlHref) { + urls.push({ + label: "Tagalog", + lang: "tl", + href: this.tlHref + }); + } + + this.urls = urls; + } + + render() { + const { language, urls } = this; + return ( + +
+ {urls.map(({href, lang, label}, i) => { + const anchorClass = classNames({ + 'is-current-lang': lang === language + }); + const divClass = classNames({ + 'inner-div': (i < urls.length - 1) + }) + return ( +
+ this.handleToggle(e, lang)} + text={label} + /> +
+ ) + })} +
+
+ ); + } +} diff --git a/packages/web-components/src/components/va-link/test/va-link.e2e.ts b/packages/web-components/src/components/va-link/test/va-link.e2e.ts index 9c85ec3888..3edca5e623 100644 --- a/packages/web-components/src/components/va-link/test/va-link.e2e.ts +++ b/packages/web-components/src/components/va-link/test/va-link.e2e.ts @@ -12,7 +12,7 @@ describe('va-link', () => { expect(element).toEqualHtml(` - + Find out if you qualify for this program and how to apply @@ -20,6 +20,17 @@ describe('va-link', () => { `); }); + it('adds a lang attribute if the language prop set on default va-link', async () => { + const page = await newE2EPage(); + await page.setContent( + '' + ); + + const element = await page.find('va-link >>> a'); + expect(element.getAttribute('lang')).toBe('en'); + expect(element.getAttribute('hrefLang')).toBe('en'); + }) + it('renders active link', async () => { const page = await newE2EPage(); await page.setContent( diff --git a/packages/web-components/src/components/va-link/va-link.tsx b/packages/web-components/src/components/va-link/va-link.tsx index da0ab724ee..b47d82b77b 100644 --- a/packages/web-components/src/components/va-link/va-link.tsx +++ b/packages/web-components/src/components/va-link/va-link.tsx @@ -106,6 +106,12 @@ export class VaLink { /** * The event used to track usage of the component. */ + + /** + * The lang attribute for the anchor tag in the Default va-link. Also used for hreflang. + */ + @Prop() language?: string; + @Event({ bubbles: true, composed: true, @@ -154,6 +160,7 @@ export class VaLink { external, iconName, iconSize, + language } = this; const linkClass = classNames({ @@ -293,6 +300,7 @@ export class VaLink { } // Default + const lang = language ? language : null; return ( {text}