diff --git a/app/javascript/packages/components/block-link.jsx b/app/javascript/packages/components/block-link.jsx deleted file mode 100644 index ce27360d70e..00000000000 --- a/app/javascript/packages/components/block-link.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useI18n } from '@18f/identity-react-i18n'; - -/** @typedef {import('react').ReactNode} ReactNode */ - -/** - * @typedef BlockLinkProps - * - * @prop {string} url Link destination. - * @prop {boolean=} isNewTab Whether link should open in a new tab. Defaults to false. Use best - * judgment to reserve new tabs to when absolutely necessary, such as when form data may otherwise - * be lost. - * @prop {ReactNode} children Child elements. - */ - -/** - * @param {BlockLinkProps} props - */ -function BlockLink({ url, children, isNewTab = false }) { - const { t } = useI18n(); - - const classes = ['usa-link', 'block-link', isNewTab && 'usa-link--external'] - .filter(Boolean) - .join(' '); - - let newTabProps; - if (isNewTab) { - newTabProps = { target: '_blank', rel: 'noreferrer' }; - } - - return ( - - {children} - {isNewTab && {t('links.new_window')}} - - - ); -} - -export default BlockLink; diff --git a/app/javascript/packages/components/block-link.spec.jsx b/app/javascript/packages/components/block-link.spec.jsx deleted file mode 100644 index b37dcb0617a..00000000000 --- a/app/javascript/packages/components/block-link.spec.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import { render } from '@testing-library/react'; -import BlockLink from './block-link'; - -describe('BlockLink', () => { - const linkText = 'link text'; - const url = '/example'; - - it('renders a link', () => { - const { getByRole } = render({linkText}); - - const link = getByRole('link'); - - expect(link.hasAttribute('target')).to.be.false(); - expect(link.hasAttribute('rel')).to.be.false(); - expect(link.textContent).to.equal(linkText); - }); - - it('renders a link in a new tab', () => { - const { getByRole } = render( - - {linkText} - , - ); - - const link = getByRole('link'); - - expect(link.getAttribute('target')).to.equal('_blank'); - expect(link.getAttribute('rel')).to.equal('noreferrer'); - expect(link.textContent).to.equal(`${linkText} links.new_window`); - }); -}); diff --git a/app/javascript/packages/components/block-link.spec.tsx b/app/javascript/packages/components/block-link.spec.tsx new file mode 100644 index 00000000000..671deb81a6d --- /dev/null +++ b/app/javascript/packages/components/block-link.spec.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react'; +import BlockLink from './block-link'; + +describe('BlockLink', () => { + it('renders a link with expected class name and arrow content', () => { + const { getByRole } = render(); + + const link = getByRole('link'); + + expect(link.classList.contains('block-link')).to.be.true(); + expect(link.querySelector('.block-link__arrow')).to.exist(); + }); + + context('with custom css class', () => { + it('renders a link with expected class names', () => { + const { getByRole } = render(); + + const link = getByRole('link'); + + expect(link.classList.contains('block-link')).to.be.true(); + expect(link.classList.contains('my-custom-class')).to.be.true(); + }); + }); +}); diff --git a/app/javascript/packages/components/block-link.tsx b/app/javascript/packages/components/block-link.tsx new file mode 100644 index 00000000000..c2bcd979f77 --- /dev/null +++ b/app/javascript/packages/components/block-link.tsx @@ -0,0 +1,32 @@ +import Link, { LinkProps } from './link'; + +interface BlockLinkProps extends LinkProps { + /** + * Link destination. + */ + href: string; +} + +function BlockLink({ href, children, className, ...linkProps }: BlockLinkProps) { + const classes = ['block-link', className].filter(Boolean).join(' '); + + return ( + + {children} + + + ); +} + +export default BlockLink; diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index f8d88ccd04e..601ac6a8840 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -5,6 +5,7 @@ export { default as ButtonTo } from './button-to'; export { default as BlockLink } from './block-link'; export { default as Icon } from './icon'; export { default as FullScreen } from './full-screen'; +export { default as Link } from './link'; export { default as PageHeading } from './page-heading'; export { default as SpinnerDots } from './spinner-dots'; export { default as TextInput } from './text-input'; diff --git a/app/javascript/packages/components/link.spec.tsx b/app/javascript/packages/components/link.spec.tsx new file mode 100644 index 00000000000..424422f7e00 --- /dev/null +++ b/app/javascript/packages/components/link.spec.tsx @@ -0,0 +1,102 @@ +import { render } from '@testing-library/react'; +import Link, { isExternalURL } from './link'; + +describe('isExternalURL', () => { + it('returns true if host name is different', () => { + const result = isExternalURL('http://example.com', 'http://example.test'); + + expect(result).to.be.true(); + }); + + it('returns false if host name is same', () => { + const result = isExternalURL('http://example.com', 'http://example.com'); + + expect(result).to.be.false(); + }); + + it('returns false if candidate url cannot be parsed', () => { + const result = isExternalURL('/', 'http://example.com'); + + expect(result).to.be.false(); + }); + + it('returns false if current url cannot be parsed', () => { + const result = isExternalURL('http://example.com', ''); + + expect(result).to.be.false(); + }); +}); + +describe('Link', () => { + it('renders link', () => { + const { getByRole } = render(Example); + + const link = getByRole('link', { name: 'Example' }) as HTMLAnchorElement; + + expect(link.getAttribute('href')).to.equal('/'); + expect(link.target).to.equal(''); + expect([...link.classList.values()]).to.have.all.members(['usa-link']); + }); + + context('with custom css class', () => { + it('renders link with class', () => { + const { getByRole } = render(); + + const link = getByRole('link') as HTMLAnchorElement; + + expect(link.classList.contains('my-custom-class')).to.be.true(); + }); + }); + + context('with isExternal prop', () => { + it('renders link which includes external link styles', () => { + const { getByRole } = render(); + + const link = getByRole('link') as HTMLAnchorElement; + + expect([...link.classList.values()]).to.have.all.members(['usa-link', 'usa-link--external']); + }); + + it('renders link which opens in new tab', () => { + const { getByRole } = render(); + + const link = getByRole('link') as HTMLAnchorElement; + + expect(link.target).to.equal('_blank'); + expect(link.rel).to.equal('noreferrer'); + }); + + context('with explicitly-false isNewTab prop', () => { + it('renders link which does not open in new tab', () => { + const { getByRole } = render(); + + const link = getByRole('link') as HTMLAnchorElement; + + expect(link.target).to.equal(''); + }); + }); + }); + + context('with isNewTab prop', () => { + it('renders link which opens in new tab', () => { + const { getByRole } = render(); + + const link = getByRole('link') as HTMLAnchorElement; + + expect(link.target).to.equal('_blank'); + expect(link.rel).to.equal('noreferrer'); + }); + + it('includes additional text hint for assistive technology', () => { + const { getByRole } = render( + + Example + , + ); + + const link = getByRole('link', { name: 'Example links.new_window' }) as HTMLAnchorElement; + + expect(link).to.exist(); + }); + }); +}); diff --git a/app/javascript/packages/components/link.tsx b/app/javascript/packages/components/link.tsx new file mode 100644 index 00000000000..5b876bf34ae --- /dev/null +++ b/app/javascript/packages/components/link.tsx @@ -0,0 +1,63 @@ +import type { ReactNode, AnchorHTMLAttributes } from 'react'; +import { t } from '@18f/identity-i18n'; + +export interface LinkProps { + /** + * Link destination. + */ + href: string; + + /** + * Whether link destination is an external resource. + */ + isExternal?: boolean; + + /** + * Whether link should open in a new tab. + */ + isNewTab?: boolean; + + /** + * Additional class names to apply. + */ + className?: string; + + /** + * Link text. + */ + children?: ReactNode; +} + +export function isExternalURL(url, currentURL = window.location.href) { + try { + return new URL(url).hostname !== new URL(currentURL).hostname; + } catch { + return false; + } +} + +function Link({ + href, + isExternal = isExternalURL(href), + isNewTab = isExternal, + className, + children, +}: LinkProps) { + const classes = ['usa-link', className, isExternal && 'usa-link--external'] + .filter(Boolean) + .join(' '); + + let newTabProps: AnchorHTMLAttributes | undefined; + if (isNewTab) { + newTabProps = { target: '_blank', rel: 'noreferrer' }; + } + + return ( + + {children} + {isNewTab && {t('links.new_window')}} + + ); +} + +export default Link; diff --git a/app/javascript/packages/components/troubleshooting-options.jsx b/app/javascript/packages/components/troubleshooting-options.jsx index 0700f8a5e23..9da86835496 100644 --- a/app/javascript/packages/components/troubleshooting-options.jsx +++ b/app/javascript/packages/components/troubleshooting-options.jsx @@ -43,7 +43,7 @@ function TroubleshootingOptions({ headingTag = 'h2', heading, options, isNewFeat
    {options.map(({ url, text, isExternal }) => (
  • - + {text}
  • diff --git a/app/javascript/packages/components/troubleshooting-options.spec.jsx b/app/javascript/packages/components/troubleshooting-options.spec.jsx index 84e08866277..7605ab0fb44 100644 --- a/app/javascript/packages/components/troubleshooting-options.spec.jsx +++ b/app/javascript/packages/components/troubleshooting-options.spec.jsx @@ -33,8 +33,8 @@ describe('TroubleshootingOptions', () => { Option 1, url: 'https://example.com/1', isExternal: true }, - { text: 'Option 2', url: 'https://example.com/2' }, + { text: <>Option 1, url: `/1`, isExternal: true }, + { text: 'Option 2', url: `/2` }, ]} />, ); @@ -43,10 +43,10 @@ describe('TroubleshootingOptions', () => { expect(links).to.have.lengthOf(2); expect(links[0].textContent).to.equal('Option 1 links.new_window'); - expect(links[0].href).to.equal('https://example.com/1'); + expect(links[0].getAttribute('href')).to.equal(`/1`); expect(links[0].target).to.equal('_blank'); expect(links[1].textContent).to.equal('Option 2'); - expect(links[1].href).to.equal('https://example.com/2'); + expect(links[1].getAttribute('href')).to.equal(`/2`); expect(links[1].target).to.be.empty(); }); diff --git a/app/javascript/packages/password-toggle/password-toggle.spec.tsx b/app/javascript/packages/password-toggle/password-toggle.spec.tsx index 9dc744c1684..79c9a3eb896 100644 --- a/app/javascript/packages/password-toggle/password-toggle.spec.tsx +++ b/app/javascript/packages/password-toggle/password-toggle.spec.tsx @@ -34,6 +34,12 @@ describe('PasswordToggle', () => { expect(container.querySelector('.password-toggle--toggle-bottom')).to.exist(); }); + it('applies custom class to wrapper element', () => { + const { container } = render(); + + expect(container.querySelector('lg-password-toggle.my-custom-class')).to.exist(); + }); + it('passes additional props to underlying text input', () => { const type = 'password'; const { getByLabelText } = render(); diff --git a/app/javascript/packages/password-toggle/password-toggle.tsx b/app/javascript/packages/password-toggle/password-toggle.tsx index 60db2595a56..f0fa55b49a7 100644 --- a/app/javascript/packages/password-toggle/password-toggle.tsx +++ b/app/javascript/packages/password-toggle/password-toggle.tsx @@ -32,6 +32,11 @@ type PasswordToggleProps = Partial & { * Placement of toggle relative to the input. */ togglePosition?: TogglePosition; + + /** + * Additional classes to apply to wrapper. + */ + className?: string; }; function PasswordToggle( @@ -39,6 +44,7 @@ function PasswordToggle( label = t('components.password_toggle.label'), toggleLabel = t('components.password_toggle.toggle_label'), togglePosition = 'top', + className, ...textInputProps }: PasswordToggleProps, ref: ForwardedRef, @@ -47,8 +53,13 @@ function PasswordToggle( const inputId = `password-toggle-input-${instanceId}`; const toggleId = `password-toggle-${instanceId}`; - const classes = - togglePosition === 'top' ? 'password-toggle--toggle-top' : 'password-toggle--toggle-bottom'; + const classes = [ + className, + togglePosition === 'top' && 'password-toggle--toggle-top', + togglePosition === 'bottom' && 'password-toggle--toggle-bottom', + ] + .filter(Boolean) + .join(' '); return ( diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx index 62b4bbbeef2..edb6e67e908 100644 --- a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx +++ b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx @@ -5,7 +5,7 @@ import { FormStepsButton, useHistoryParam, FormStepsContext } from '@18f/identit import { PasswordToggle } from '@18f/identity-password-toggle'; import { FlowContext } from '@18f/identity-verify-flow'; import { formatHTML } from '@18f/identity-react-i18n'; -import { PageHeading, Accordion, Alert, Button } from '@18f/identity-components'; +import { PageHeading, Accordion, Alert, Button, Link } from '@18f/identity-components'; import { getConfigValue } from '@18f/identity-config'; import type { ChangeEvent } from 'react'; import type { FormStepComponentProps } from '@18f/identity-form-steps'; @@ -35,6 +35,8 @@ function PasswordConfirmStep({ errors, registerField, onChange, value }: Passwor return ; } + const appName = getConfigValue('appName'); + return ( <> {errors.map(({ error }) => ( @@ -42,18 +44,21 @@ function PasswordConfirmStep({ errors, registerField, onChange, value }: Passwor {error.message} ))} - - {t('idv.titles.session.review', { app_name: getConfigValue('appName') })} - -
    - ) => { - onChange({ password: event.target.value }); - }} - /> -
    + {t('idv.titles.session.review', { app_name: appName })} +

    {t('idv.messages.sessions.review_message', { app_name: appName })}

    +

    + + {t('idv.messages.sessions.read_more_encrypt', { app_name: appName })} + +

    + ) => { + onChange({ password: event.target.value }); + }} + className="margin-top-6" + />
    {formatHTML( t('idv.forgot_password.link_html', { diff --git a/spec/javascripts/packages/document-capture/components/warning-spec.jsx b/spec/javascripts/packages/document-capture/components/warning-spec.jsx index 6726a4bcab9..07d9c1aae87 100644 --- a/spec/javascripts/packages/document-capture/components/warning-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/warning-spec.jsx @@ -20,7 +20,7 @@ describe('document-capture/components/warning', () => { troubleshootingOptions={ } location="example" @@ -46,6 +46,6 @@ describe('document-capture/components/warning', () => { }); expect(getByText('Something went wrong')).to.exist(); expect(getByRole('heading', { name: 'Having trouble?' })).to.exist(); - expect(getByRole('link', { name: 'Get help' }).href).to.equal('https://example.com/'); + expect(getByRole('link', { name: 'Get help' }).getAttribute('href')).to.equal('/'); }); });