diff --git a/.changeset/gorgeous-months-vanish.md b/.changeset/gorgeous-months-vanish.md new file mode 100644 index 000000000..3d8a57b5e --- /dev/null +++ b/.changeset/gorgeous-months-vanish.md @@ -0,0 +1,5 @@ +--- +'@ithaka/pharos': major +--- + +remove sidenav button, add consumer controls diff --git a/.storybook/initComponents.js b/.storybook/initComponents.js index 7a4e59708..483ae03b5 100644 --- a/.storybook/initComponents.js +++ b/.storybook/initComponents.js @@ -30,7 +30,6 @@ import { PharosSelect, PharosSheet, PharosSidenav, - PharosSidenavButton, PharosSidenavLink, PharosSidenavMenu, PharosSidenavSection, @@ -80,7 +79,6 @@ registerComponents('storybook', [ PharosSelect, PharosSheet, PharosSidenav, - PharosSidenavButton, PharosSidenavLink, PharosSidenavMenu, PharosSidenavSection, diff --git a/packages/pharos-site/initComponents.tsx b/packages/pharos-site/initComponents.tsx index 15fefdd22..a9d24310c 100644 --- a/packages/pharos-site/initComponents.tsx +++ b/packages/pharos-site/initComponents.tsx @@ -33,7 +33,6 @@ if (typeof window !== `undefined`) { pharos.PharosRadioGroup, pharos.PharosSelect, pharos.PharosSidenav, - pharos.PharosSidenavButton, pharos.PharosSidenavLink, pharos.PharosSidenavMenu, pharos.PharosSidenavSection, diff --git a/packages/pharos-site/src/components/layout.tsx b/packages/pharos-site/src/components/layout.tsx index d63599377..706de6692 100644 --- a/packages/pharos-site/src/components/layout.tsx +++ b/packages/pharos-site/src/components/layout.tsx @@ -37,12 +37,11 @@ const Layout: FC = ({ children, location, fill }) => { const [MainContent, setMainContent] = useState(null); useEffect(() => { - const { PharosSidenavButton, PharosLink, PharosLayout } = Pharos; + const { PharosLink, PharosLayout } = Pharos; const body = (
- Skip to main navigation diff --git a/packages/pharos/src/components/sidenav/PharosSidenav.react.stories.jsx b/packages/pharos/src/components/sidenav/PharosSidenav.react.stories.jsx index bc98584da..d5c7844c2 100644 --- a/packages/pharos/src/components/sidenav/PharosSidenav.react.stories.jsx +++ b/packages/pharos/src/components/sidenav/PharosSidenav.react.stories.jsx @@ -7,7 +7,6 @@ import { PharosSidenavMenu, PharosInputGroup, PharosButton, - PharosSidenavButton, PharosLink, } from '../../react-components'; import { configureDocsPage } from '@config/docsPageConfig'; @@ -28,7 +27,6 @@ export default { PharosSidenavMenu, PharosSidenavSection, PharosSidenavLink, - PharosSidenavButton, }, parameters: { docs: { page: configureDocsPage('sidenav') }, @@ -36,10 +34,17 @@ export default { }; export const Base = { - render: () => ( + render: (args) => ( - - + { + e.target.focus(); + }} + > + @@ -110,5 +115,6 @@ export const Base = { ), - parameters: { docs: { disable: true } }, + args: { open: false, hasCloseButton: false }, + parameters: {}, }; diff --git a/packages/pharos/src/components/sidenav/pharos-sidenav-button.scss b/packages/pharos/src/components/sidenav/pharos-sidenav-button.scss deleted file mode 100644 index 5f7d50c06..000000000 --- a/packages/pharos/src/components/sidenav/pharos-sidenav-button.scss +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: none; -} - -@media screen and (width <= 1055px) { - :host { - display: inline-flex; - } -} diff --git a/packages/pharos/src/components/sidenav/pharos-sidenav-button.test.ts b/packages/pharos/src/components/sidenav/pharos-sidenav-button.test.ts deleted file mode 100644 index 31c90f367..000000000 --- a/packages/pharos/src/components/sidenav/pharos-sidenav-button.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { fixture, expect } from '@open-wc/testing'; -import { html } from 'lit/static-html.js'; -import { setViewport } from '@web/test-runner-commands'; - -import type { PharosSidenavButton } from './pharos-sidenav-button'; - -describe('pharos-sidenav', () => { - let component: PharosSidenavButton; - - beforeEach(async () => { - await setViewport({ width: 1055, height: 768 }); - component = await fixture(html``); - }); - - it('is accessible', async () => { - await expect(component).to.be.accessible(); - }); - - it('is not visible when viewport is larger than 1055px', async () => { - await setViewport({ width: 1056, height: 768 }); - const display = window.getComputedStyle(component, null).getPropertyValue('display'); - expect(display).to.equal('none'); - }); - - it('is visible when viewport is below than 1056px', async () => { - const button = document.querySelector('test-pharos-sidenav-button'); - expect(button).to.not.be.null; - }); - - it('slides in the sidenav when clicked', async () => { - const sidenav = document.createElement('test-pharos-sidenav'); - document.body.appendChild(sidenav); - - component.click(); - expect(sidenav.slide).to.be.true; - }); - - it('focuses the sidenav after being clicked', async () => { - const sidenav = document.createElement('test-pharos-sidenav'); - document.body.appendChild(sidenav); - - component.click(); - await sidenav.updateComplete; - - const renderedSidenav = document.body.querySelector('test-pharos-sidenav'); - expect(document.activeElement === renderedSidenav).to.be.true; - }); -}); diff --git a/packages/pharos/src/components/sidenav/pharos-sidenav-button.ts b/packages/pharos/src/components/sidenav/pharos-sidenav-button.ts deleted file mode 100644 index 6dcab2e42..000000000 --- a/packages/pharos/src/components/sidenav/pharos-sidenav-button.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { CSSResultArray } from 'lit'; -import { sidenavButtonStyles } from './pharos-sidenav-button.css'; -import { PharosButton } from '../button/pharos-button'; -import type { PharosSidenav } from './pharos-sidenav'; - -import type { LinkTarget } from '../base/anchor-element'; -import type { ButtonType, IconName, ButtonVariant, PressedState } from '../button/pharos-button'; -export type { LinkTarget, ButtonType, IconName, ButtonVariant, PressedState }; - -/** - * Pharos sidenav button component. - * - * @tag pharos-sidenav-button - * - */ -export class PharosSidenavButton extends PharosButton { - constructor() { - super(); - this.icon = 'menu'; - this.variant = 'subtle'; - this.label = 'Open menu'; - } - - public static override get styles(): CSSResultArray { - return [super.styles, sidenavButtonStyles]; - } - - protected override firstUpdated(): void { - this.addEventListener('click', this._handleClickOpen); - } - - private async _handleClickOpen(): Promise { - const sidenav: PharosSidenav | null = document.querySelector( - '[data-pharos-component="PharosSidenav"]' - ); - if (sidenav) { - sidenav.slide = true; - await sidenav.updateComplete; - sidenav.focus(); - } - } -} diff --git a/packages/pharos/src/components/sidenav/pharos-sidenav.scss b/packages/pharos/src/components/sidenav/pharos-sidenav.scss index 9217a52e7..1a355c80e 100644 --- a/packages/pharos/src/components/sidenav/pharos-sidenav.scss +++ b/packages/pharos/src/components/sidenav/pharos-sidenav.scss @@ -6,13 +6,23 @@ #nav-element { position: fixed; top: 0; - left: 0; height: 100vh; overflow: scroll; background-color: var(--pharos-color-black); color: var(--pharos-color-text-white); - visibility: visible; - transition: visibility 0s linear 0s, left var(--pharos-transition-duration-long) ease-in-out; + left: -17rem; + visibility: hidden; + transition: visibility var(--pharos-transition-duration-long) linear + var(--pharos-transition-duration-long), + left var(--pharos-transition-duration-long) ease-in-out; +} + +:host([open]) { + #nav-element { + left: 0; + visibility: visible; + transition: visibility 0s linear 0s, left var(--pharos-transition-duration-long) ease-in-out; + } } .side-element__container { @@ -42,19 +52,7 @@ left: var(--pharos-spacing-one-quarter-x); } -@media screen and (width <= 1055px) { - :host { - width: initial; - } - - #nav-element { - left: -17rem; - visibility: hidden; - transition: visibility var(--pharos-transition-duration-long) linear - var(--pharos-transition-duration-long), - left var(--pharos-transition-duration-long) ease-in-out; - } - +:host([has-close-button]) { .side-element__button { visibility: visible; opacity: 1; @@ -62,11 +60,3 @@ opacity var(--pharos-transition-duration-long) linear var(--pharos-transition-duration-short); } } - -:host([slide]) { - #nav-element { - left: 0; - visibility: visible; - transition: visibility 0s linear 0s, left var(--pharos-transition-duration-long) ease-in-out; - } -} diff --git a/packages/pharos/src/components/sidenav/pharos-sidenav.test.ts b/packages/pharos/src/components/sidenav/pharos-sidenav.test.ts index d0880b436..5206896b7 100644 --- a/packages/pharos/src/components/sidenav/pharos-sidenav.test.ts +++ b/packages/pharos/src/components/sidenav/pharos-sidenav.test.ts @@ -1,4 +1,4 @@ -import { aTimeout, fixture, expect } from '@open-wc/testing'; +import { fixture, expect } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; import { setViewport } from '@web/test-runner-commands'; @@ -8,11 +8,69 @@ import type { PharosButton } from '../button/pharos-button'; describe('pharos-sidenav', () => { let component: PharosSidenav; + const getSimpleSidenav = () => { + return html` + + + + Search + + + + Menu item + + Menu item 1 + Menu item 2 + + Menu item + External link + External link + + + + Menu item 1 + Menu item 2 + Menu item 3 + + + Menu item 1 + Menu item 2 + + + + + Menu item 1 + Menu item 2 + Menu item 3 + Menu item 4 + + Menu item + + + `; + }; + beforeEach(async () => { await setViewport({ width: 1440, height: 900 }); component = await fixture( html` - + { await expect(component).to.be.accessible(); }); - it('slides out when the close button is clicked', async () => { - await setViewport({ width: 1055, height: 768 }); - component.slide = true; + it('has an attribute to open the modal', async () => { + component.open = true; + await component.updateComplete; + await expect(component.open).to.be.true; + }); + + it('has an attribute to close the modal', async () => { + component.open = true; + await component.updateComplete; + + component.open = false; + await component.updateComplete; + await expect(component.open).to.be.false; + }); + + it('does not render a close button when attribute hasCloseButton is false', async () => { + component.hasCloseButton = false; + component.open = true; + await component.updateComplete; + + const button = component.renderRoot.querySelector('.side-element__button') as PharosButton; + expect(button).to.be.null; + }); + + it('closes when the close button is clicked', async () => { + component.hasCloseButton = true; + component.open = true; await component.updateComplete; const button = component.renderRoot.querySelector('.side-element__button') as PharosButton; button?.click(); await component.updateComplete; - expect(component.slide).to.be.false; + expect(component.open).to.be.false; }); it('fires a custom event pharos-sidenav-close after closing', async () => { @@ -114,37 +196,51 @@ describe('pharos-sidenav', () => { expect(eventTriggered && detail === component).to.be.true; }); - it('delegates focus to the sidenav button after the close button is clicked', async () => { - const sidenavButton = document.createElement('test-pharos-sidenav-button'); - document.body.appendChild(sidenavButton); - await setViewport({ width: 1055, height: 768 }); - component.slide = true; + it('renders a skip link when attribute main-content-id is passed', async () => { + component.mainContentId = 'test'; await component.updateComplete; + const link = component.renderRoot.querySelector('#sidenav-skip-link'); + expect(link).not.to.be.null; + }); - const button = component.renderRoot.querySelector('.side-element__button') as PharosButton; - button?.click(); - await component.updateComplete; + it('opens when the element with matching attribute data-sidenav-id is clicked', async () => { + const trigger = document.createElement('button'); + trigger.setAttribute('id', 'trigger'); + trigger.setAttribute('data-sidenav-id', 'my-sidenav'); + document.body.appendChild(trigger); - const renderedButton = document.body.querySelector('test-pharos-sidenav-button'); - expect(document.activeElement === renderedButton).to.be.true; - }); + component = await fixture(getSimpleSidenav()); - it('resets its slide status when going back to a viewport above 1055px', async () => { - await setViewport({ width: 1055, height: 768 }); - component.slide = true; + trigger.click(); await component.updateComplete; - await aTimeout(1000); + expect(component.open).to.be.true; + }); + + it('delegates focus back to the element that opened it', async () => { + let activeElement = null; + const onFocusIn = (event: Event): void => { + activeElement = event.composedPath()[0]; + }; + document.addEventListener('focusin', onFocusIn); + + const trigger = document.createElement('button'); + const handleClick = (): void => { + const sidenav = document.querySelector('test-pharos-sidenav') as PharosSidenav; + sidenav.open = true; + }; + trigger.setAttribute('id', 'trigger'); + trigger.addEventListener('click', handleClick); + document.body.appendChild(trigger); - await setViewport({ width: 1056, height: 768 }); + const button = document.querySelector('#trigger') as HTMLButtonElement; + button.click(); + button.focus(); await component.updateComplete; - await aTimeout(1000); - expect(component.slide).to.be.false; - }); - it('renders a skip link when attribute main-content-id is passed', async () => { - component.mainContentId = 'test'; + component.open = false; await component.updateComplete; - const link = component.renderRoot.querySelector('#sidenav-skip-link'); - expect(link).not.to.be.null; + + expect(activeElement === button).to.be.true; + document.removeEventListener('focusin', onFocusIn); }); }); diff --git a/packages/pharos/src/components/sidenav/pharos-sidenav.ts b/packages/pharos/src/components/sidenav/pharos-sidenav.ts index 2d0c10ef2..95dfd0fa2 100644 --- a/packages/pharos/src/components/sidenav/pharos-sidenav.ts +++ b/packages/pharos/src/components/sidenav/pharos-sidenav.ts @@ -3,7 +3,6 @@ import { property } from 'lit/decorators.js'; import type { TemplateResult, CSSResultArray } from 'lit'; import { sidenavStyles } from './pharos-sidenav.css'; import { SideElement } from '../base/side-element'; -import type { PharosSidenavButton } from './pharos-sidenav-button'; import FocusMixin from '../../utils/mixins/focus'; import ScopedRegistryMixin from '../../utils/mixins/scoped-registry'; @@ -19,6 +18,7 @@ import { PharosLink } from '../link/pharos-link'; * @slot - Contains the sections of the sidenav (the default slot). * * @fires pharos-sidenav-close - Fires when the sidenav has closed + * @fires pharos-sidenav-open - Fires when the sidenav has opened * */ export class PharosSidenav extends ScopedRegistryMixin(FocusMixin(SideElement)) { @@ -28,11 +28,16 @@ export class PharosSidenav extends ScopedRegistryMixin(FocusMixin(SideElement)) }; /** - * Indicates that the sidenav should slide in. - * @attr slide + * Indicates whether or not the sidenav is open */ - @property({ type: Boolean, reflect: true }) - public slide = false; + @property({ type: Boolean, reflect: true, attribute: 'open' }) + public open = false; + + /** + * Indicates whether or not the close button displays in the sidenav. + */ + @property({ type: Boolean, reflect: true, attribute: 'has-close-button' }) + public hasCloseButton = false; /** * Indicates the skip to target @@ -41,20 +46,25 @@ export class PharosSidenav extends ScopedRegistryMixin(FocusMixin(SideElement)) @property({ type: String, reflect: true, attribute: 'main-content-id' }) public mainContentId?: string; - private _mediaQuery: MediaQueryList = window.matchMedia(`(max-width: 1055px)`); + private _triggers!: NodeListOf; constructor() { super(); - this._handleMediaChange = this._handleMediaChange.bind(this); + } + + protected override firstUpdated(): void { + this._addTriggerListeners(); } override connectedCallback(): void { super.connectedCallback && super.connectedCallback(); - this._mediaQuery.addEventListener('change', this._handleMediaChange); + this._addTriggerListeners(); } override disconnectedCallback(): void { - this._mediaQuery.removeEventListener('change', this._handleMediaChange); + this._triggers.forEach((trigger) => { + trigger.removeEventListener('click', this._openSidenav); + }); super.disconnectedCallback && super.disconnectedCallback(); } @@ -62,30 +72,50 @@ export class PharosSidenav extends ScopedRegistryMixin(FocusMixin(SideElement)) return [super.styles, sidenavStyles]; } - private _handleClickClose(event: Event): void { - event.preventDefault(); - event.stopPropagation(); - - this.slide = false; - const button: PharosSidenavButton | null = document.querySelector( - '[data-pharos-component="PharosSidenavButton"]' - ); - button?.focus(); - - const details = { - bubbles: true, - composed: true, - detail: this, - }; - this.dispatchEvent(new CustomEvent('pharos-sidenav-close', details)); + private _addTriggerListeners(): void { + const id = this.getAttribute('id'); + this._triggers = document.querySelectorAll(`[data-sidenav-id="${id}"]`); + this._triggers.forEach((trigger) => { + trigger.addEventListener('click', this._openSidenav); + }); } - private _handleMediaChange(e: MediaQueryListEvent): void { - if (!e.matches) { - this.slide = false; + private _closeSidenav(event: Event): void { + event.preventDefault(); + + if (this.open) { + const details = { + bubbles: true, + composed: true, + detail: this, + }; + if ( + this.dispatchEvent( + new CustomEvent('pharos-sidenav-close', { ...details, cancelable: true }) + ) + ) { + this.open = false; + } } } + private _openSidenav = (event: Event): void => { + event.preventDefault(); + if (!this.open) { + const details = { + bubbles: true, + composed: true, + detail: event.target, + }; + + if ( + this.dispatchEvent(new CustomEvent('pharos-sidenav-open', { ...details, cancelable: true })) + ) { + this.open = true; + } + } + }; + private _renderSkipToMain(): TemplateResult | typeof nothing { return this.mainContentId ? html` ` + : nothing; + } + protected override render(): TemplateResult { return html`