From fc902f2511d584fc0126a58302f401be3faef9ad Mon Sep 17 00:00:00 2001 From: Mike Iden Date: Thu, 31 Oct 2024 11:53:22 -0400 Subject: [PATCH] Switch: New component contribution (#825) * feat(switch): add toggle switch * feat(switch): clean up tests and storybook stories * feat(switch): update color palette, fix stories * feat(switch): update spacing and cursors * feat(switch): update colors to be accessible * feat(switch): add change event to storybook and docs * Update packages/pharos-site/static/guidelines/switch.docs.tsx Co-authored-by: Brent Swisher * feat(switch): sizing and styling * docs(switch): add to site navigation * docs(switch): register new component in pharos-site * feat(switch): re-add font weight * feat(switch): fix transition timing * feat(switch): use inset border increase and update outline offset --------- Co-authored-by: Brent Swisher Co-authored-by: Brent Swisher --- .changeset/strange-planes-tap.md | 6 + .storybook/initComponents.js | 2 + .tool-versions | 2 +- packages/pharos-site/initComponents.tsx | 1 + .../pharos-site/src/components/Sidenav.tsx | 1 + .../src/pages/components/switch/index.tsx | 1 + .../static/guidelines/switch.docs.tsx | 172 ++++++++++++ .../switch/PharosSwitch.react.stories.jsx | 41 +++ .../src/components/switch/pharos-switch.scss | 123 +++++++++ .../components/switch/pharos-switch.test.ts | 247 ++++++++++++++++++ .../src/components/switch/pharos-switch.ts | 109 ++++++++ .../switch/pharos-switch.wc.stories.jsx | 32 +++ packages/pharos/src/index.ts | 1 + packages/pharos/src/test/initComponents.ts | 2 + packages/pharos/tokens/color/base.json | 3 +- 15 files changed, 741 insertions(+), 2 deletions(-) create mode 100644 .changeset/strange-planes-tap.md create mode 100644 packages/pharos-site/src/pages/components/switch/index.tsx create mode 100644 packages/pharos-site/static/guidelines/switch.docs.tsx create mode 100644 packages/pharos/src/components/switch/PharosSwitch.react.stories.jsx create mode 100644 packages/pharos/src/components/switch/pharos-switch.scss create mode 100644 packages/pharos/src/components/switch/pharos-switch.test.ts create mode 100644 packages/pharos/src/components/switch/pharos-switch.ts create mode 100644 packages/pharos/src/components/switch/pharos-switch.wc.stories.jsx diff --git a/.changeset/strange-planes-tap.md b/.changeset/strange-planes-tap.md new file mode 100644 index 000000000..c95e2c81e --- /dev/null +++ b/.changeset/strange-planes-tap.md @@ -0,0 +1,6 @@ +--- +'@ithaka/pharos-site': minor +'@ithaka/pharos': minor +--- + +Add switch component diff --git a/.storybook/initComponents.js b/.storybook/initComponents.js index cfdba2bb1..c4da9ab23 100644 --- a/.storybook/initComponents.js +++ b/.storybook/initComponents.js @@ -34,6 +34,7 @@ import { PharosSidenavLink, PharosSidenavMenu, PharosSidenavSection, + PharosSwitch, PharosTabs, PharosTab, PharosTable, @@ -85,6 +86,7 @@ registerComponents('storybook', [ PharosSidenavLink, PharosSidenavMenu, PharosSidenavSection, + PharosSwitch, PharosTabs, PharosTab, PharosTable, diff --git a/.tool-versions b/.tool-versions index d7568adf6..f31e6b02b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 20.11.1 +nodejs 20.18.0 diff --git a/packages/pharos-site/initComponents.tsx b/packages/pharos-site/initComponents.tsx index b805a8b1a..b6e9d0da1 100644 --- a/packages/pharos-site/initComponents.tsx +++ b/packages/pharos-site/initComponents.tsx @@ -37,6 +37,7 @@ if (typeof window !== `undefined`) { pharos.PharosSidenavLink, pharos.PharosSidenavMenu, pharos.PharosSidenavSection, + pharos.PharosSwitch, pharos.PharosTabs, pharos.PharosTab, pharos.PharosTable, diff --git a/packages/pharos-site/src/components/Sidenav.tsx b/packages/pharos-site/src/components/Sidenav.tsx index 0746d410c..d03fef603 100644 --- a/packages/pharos-site/src/components/Sidenav.tsx +++ b/packages/pharos-site/src/components/Sidenav.tsx @@ -147,6 +147,7 @@ const Sidenav: FC = ({ isOpen, showCloseButton }) => { 'Radio button', 'Select', 'Sidenav', + 'Switch', 'Tabs', 'Toast', 'Tooltip', diff --git a/packages/pharos-site/src/pages/components/switch/index.tsx b/packages/pharos-site/src/pages/components/switch/index.tsx new file mode 100644 index 000000000..7134e0086 --- /dev/null +++ b/packages/pharos-site/src/pages/components/switch/index.tsx @@ -0,0 +1 @@ +export { default as default } from '@guidelines/switch.docs'; diff --git a/packages/pharos-site/static/guidelines/switch.docs.tsx b/packages/pharos-site/static/guidelines/switch.docs.tsx new file mode 100644 index 000000000..7c03421e7 --- /dev/null +++ b/packages/pharos-site/static/guidelines/switch.docs.tsx @@ -0,0 +1,172 @@ +import PageSection from '@components/statics/PageSection.tsx'; +import BestPractices from '@components/statics/BestPractices.tsx'; +import { PharosSwitch, PharosHeading, PharosLink } from '@ithaka/pharos/lib/react-components'; +import Canvas from '../../src/components/Canvas'; +import { FC } from 'react'; + +const SwitchPage: FC = () => { + return ( + <> + + + I am a switch + + + + +

Labels for switches are placed to the left of the switch.

+
+ +

+ Switches in JSTOR are often used when enabling or disabling features on the site. Their + larger size and clear on/off state make them easy to use and understand. +

+
+
{' '} + + +
  • + Use switches when users need to choose "yes" or "no" per option, with no + indeterminate state +
  • +
  • + Switches should work independently from each other, unless there are switches to + control groups +
  • +
  • Switches should always include a label
  • +
  • Use switches when choices are not mutually exclusive
  • +
  • The list of choices should be in a logical order
  • +
  • Value of the switch should be persisted when the control is changed
  • + + } + Dont={ +
      +
    • Don't use switches when there are no choices or a choice is non-binary
    • +
    • + Don't use where only one choice in a group is allowed. Consider using the radio + button component instead +
    • +
    • + Don't make switches affect other switches unless there is a clear hierarchy of + controls (aka master switch and sub-switches) +
    • +
    + } + /> +
    {' '} + + +
      +
    • + Labels are descriptive and succinct. They should provide further clarity for the user. +
    • +
    • Labels should not end in punctuation.
    • +
    • Use Sentence case for labels.
    • +
    • + Avoid using negative language as it can be counterintuitive. For example, "I agree to + the terms" instead of "I don't agree to the terms." +
    • +
    • + Long labels may wrap to a second line, but consider rewording the label if it gets too + long. +
    • +
    • Labels should not be truncated.
    • +
    +
    +
    {' '} + + +

    Indicates that the switch is interactable.

    + + + Switch label + + +
    + +

    Indicates that the switch should not be interactable.

    + + + Switch label + + +
    + +

    Indicates that the switch is selected and will submit a checked value of true.

    + + + Switch label + + +
    + +

    + Indicates that the switch is not selected and will submit an unchecked value of false. +

    + + + Switch label + + +
    +
    + + +
      +
    • + + 4.1.2 Name, Role, Value A + +
    • +
    +
    + + Switches help users to understand the relationship between selections in a form element or + filter. A switch grouping also will typically allow users to select more than one item in + the grouping. + + +
      +
    • The switch has the role "switch".
    • +
    • + When the switch is selected, the ARIA state is set to aria-checked="true"{' '} + and when it is deselected aria-checked="false". +
    • +
    +
    + + +
      +
    • Reads: "item name, state (checked or unchecked), switch, group"
    • +
    +
    + +
      +
    • + The Space key can be used to select and deselect each switch when it has + focus. +
    • +
    • + Users can navigate between switch inputs by pressing Tab or{' '} + Shift-Tab. +
    • +
    • Switches identified as `disabled` attribute are ignored in the tab order.
    • +
    +
    +
    +
    + + ); +}; +export default SwitchPage; diff --git a/packages/pharos/src/components/switch/PharosSwitch.react.stories.jsx b/packages/pharos/src/components/switch/PharosSwitch.react.stories.jsx new file mode 100644 index 000000000..38b1fd781 --- /dev/null +++ b/packages/pharos/src/components/switch/PharosSwitch.react.stories.jsx @@ -0,0 +1,41 @@ +import { PharosSwitch } from '../../react-components/switch/pharos-switch'; +import { configureDocsPage } from '@config/docsPageConfig'; +import { PharosContext } from '../../utils/PharosContext'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Forms/Switch', + component: PharosSwitch, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + page: configureDocsPage('switch'), + }, + options: { selectedPanel: 'addon-controls' }, + }, +}; + +export const Base = { + render: ({ disabled, checked }) => ( + action('Change')(e.target.checked)} + > + Toggle Switch + + ), + args: { + disabled: false, + checked: false, + }, + parameters: { + options: { selectedPanel: 'addon-actions' }, + }, +}; diff --git a/packages/pharos/src/components/switch/pharos-switch.scss b/packages/pharos/src/components/switch/pharos-switch.scss new file mode 100644 index 000000000..f4efd51bd --- /dev/null +++ b/packages/pharos/src/components/switch/pharos-switch.scss @@ -0,0 +1,123 @@ +@use '../../utils/scss/mixins'; + +.input-wrapper { + @include mixins.option-wrapper; + + align-items: center; + column-gap: var(--pharos-spacing-1-x); +} + +.switch__control { + height: 32px; + width: 70px; + display: flex; + align-items: center; + box-sizing: border-box; + cursor: pointer; + border: 1px solid; + border-color: var(--pharos-color-marble-gray-80); + border-radius: 2rem; + background-color: var(--pharos-color-marble-gray-94); + transition: + background-color var(--pharos-transition-duration-default) ease-out, + border-color var(--pharos-transition-duration-default) ease-out, + box-shadow var(--pharos-transition-duration-default) ease-out; + position: relative; + + &:hover { + border-color: var(--pharos-color-marble-gray-30); + box-shadow: inset 0 0 0 1px var(--pharos-color-marble-gray-30); + } + + &::before { + position: absolute; + content: ''; + height: 1rem; + width: 1rem; + background-color: var(--pharos-color-marble-gray-30); + left: 9px; + transition: var(--pharos-transition-duration-default) ease-out; + border-radius: 50%; + } + + &::after { + font-weight: var(--pharos-font-weight-bold); + color: var(--pharos-color-marble-gray-30); + content: 'OFF'; + position: absolute; + right: 9px; + transition: color var(--pharos-transition-duration-default) ease-out; + font-size: var(--pharos-font-size-small); + } + + .switch__input:checked + .input-wrapper & { + background-color: var(--pharos-color-green-97); + border-color: var(--pharos-color-green-base); + + &:hover { + box-shadow: inset 0 0 0 1px var(--pharos-color-green-base); + } + + &::before { + background-color: var(--pharos-color-green-base); + left: calc(100% - 9px - 1rem); + } + + &::after { + color: var(--pharos-color-green-base); + content: 'ON'; + left: 9px; + right: unset; + } + } +} + +.switch__label { + cursor: pointer; +} + +#switch-element { + @include mixins.option-input; +} + +#switch-element:focus-visible { + + .input-wrapper .switch__control { + outline: 2px solid var(--pharos-color-focus); + outline-offset: 2px; + border-color: var(--pharos-color-marble-gray-30); + box-shadow: inset 0 0 0 1px var(--pharos-color-marble-gray-30); + } +} + +#switch-element:disabled { + + .input-wrapper .switch__label { + cursor: default; + } + + + .input-wrapper .switch__control { + cursor: default; + background-color: var(--pharos-color-marble-gray-base); + border-color: var(--pharos-color-marble-gray-94); + + &:hover { + box-shadow: none; + } + + &::before { + background-color: var(--pharos-color-marble-gray-50); + } + + &::after { + color: var(--pharos-color-marble-gray-50); + } + } +} + +#switch-element:focus-visible:checked { + + .input-wrapper .switch__control { + outline: 2px solid var(--pharos-color-focus); + outline-offset: 2px; + border-color: var(--pharos-color-green-base); + box-shadow: inset 0 0 0 1px var(--pharos-color-green-base); + } +} diff --git a/packages/pharos/src/components/switch/pharos-switch.test.ts b/packages/pharos/src/components/switch/pharos-switch.test.ts new file mode 100644 index 000000000..424dd8ee9 --- /dev/null +++ b/packages/pharos/src/components/switch/pharos-switch.test.ts @@ -0,0 +1,247 @@ +import { fixture, expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import type { PharosSwitch } from './pharos-switch'; +import createFormData from '../../utils/createFormData'; + +describe('pharos-switch', () => { + let component: PharosSwitch; + + beforeEach(async () => { + component = await fixture( + html`test switch` + ); + }); + + it('is accessible', async () => { + await expect(component).to.be.accessible(); + }); + + it('is accessible when focused', async () => { + component.dispatchEvent(new Event('focusin')); + await component.updateComplete; + await expect(component).to.be.accessible(); + }); + + it('is accessible when disabled', async () => { + component = await fixture( + html`test switch` + ); + await expect(component).to.be.accessible(); + }); + + it('has an attribute to set check value', async () => { + component = await fixture(html` + test switch + `); + await expect(component.checked).to.equal(true); + }); + + it('fires a change event', async () => { + let eventSource = null as Element | null; + const onChange = (event: Event): void => { + eventSource = event.composedPath()[0] as Element; + }; + component = await fixture(html` + test switch + `); + + component['_switch'].click(); + await component.updateComplete; + + expect((eventSource as Element).isSameNode(component)).to.be.true; + }); + + it('is able to receive focus', async () => { + let activeElement = null; + const onFocusIn = (event: Event): void => { + activeElement = event.composedPath()[0]; + }; + document.addEventListener('focusin', onFocusIn); + + component['_switch'].focus(); + await component.updateComplete; + expect(activeElement === component['_switch']).to.be.true; + document.removeEventListener('focusin', onFocusIn); + }); + + it('is not able to receive focus when disabled', async () => { + let activeElement = null; + const onFocusIn = (event: Event): void => { + activeElement = event.composedPath()[0]; + }; + document.addEventListener('focusin', onFocusIn); + + component = await fixture( + html`test switch` + ); + + component['_switch'].focus(); + await component.updateComplete; + expect(activeElement === component['_switch']).to.be.false; + expect(document.activeElement === component).to.be.false; + document.removeEventListener('focusin', onFocusIn); + }); + + it('updates the form value', async () => { + const parentNode = document.createElement('form'); + parentNode.setAttribute('name', 'my-form'); + component = await fixture( + html` + + test switch + + `, + { parentNode } + ); + + const form = document.querySelector('form'); + const formdata = createFormData(form as HTMLFormElement); + + expect(formdata.get('my-switch')).to.equal('test'); + }); + + it('updates the form value to "on" when no value is passed', async () => { + const parentNode = document.createElement('form'); + parentNode.setAttribute('name', 'my-form'); + component = await fixture( + html` + + test switch + + `, + { parentNode } + ); + + const form = document.querySelector('form'); + const formdata = createFormData(form as HTMLFormElement); + + expect(formdata.get('my-switch')).to.equal('on'); + }); + + it('does not update the form value when disabled', async () => { + const parentNode = document.createElement('form'); + parentNode.setAttribute('name', 'my-form'); + component = await fixture( + html` + + test switch + + `, + { parentNode } + ); + + const form = document.querySelector('form'); + const formdata = createFormData(form as HTMLFormElement); + + expect(formdata.get('my-switch')).to.be.null; + }); + + it('can be clicked when the label is hidden', async () => { + component = await fixture(html` + + test switch + + `); + + const switchElement = component.renderRoot.querySelector('.switch__control') as HTMLSpanElement; + switchElement.dispatchEvent(new Event('click')); + await component.updateComplete; + + expect(component.checked).to.be.true; + }); + + it('can be clicked when no label is present', async () => { + component = await fixture(html` `); + + const switchElement = component.renderRoot.querySelector('.switch__control') as HTMLSpanElement; + switchElement.dispatchEvent(new Event('click')); + await component.updateComplete; + + expect(component.checked).to.be.true; + }); + + it('is able to delegate focus', async () => { + let activeElement = null; + const onFocusIn = (event: Event): void => { + activeElement = event.composedPath()[0]; + }; + document.addEventListener('focusin', onFocusIn); + + component.focus(); + + expect(activeElement === component['_switch']).to.be.true; + document.removeEventListener('focusin', onFocusIn); + }); + + it('allows links in the label to be clicked', async () => { + component = await fixture(html` + test switch with link + `); + const link = component.renderRoot.querySelector('a'); + link?.click(); + await component.updateComplete; + + await expect(component.checked).to.be.false; + }); + + it('fires a single click event when label is clicked', async () => { + let count = 0; + const onClick = (): void => { + count++; + }; + component = await fixture(html` + test switch + `); + + const label = component.renderRoot.querySelector('label') as HTMLLabelElement; + label?.click(); + await component.updateComplete; + expect(count).to.equal(1); + }); + + it('fires a single click event but does not update if event prevented', async () => { + const onClick = (event: Event): void => { + event.preventDefault(); + }; + component = await fixture(html` + test switch + `); + + const label = component.renderRoot.querySelector('label') as HTMLLabelElement; + label?.click(); + await component.updateComplete; + await expect(component.checked).to.be.false; + }); + + it('resets checked when the form is reset', async () => { + const parentNode = document.createElement('form'); + parentNode.setAttribute('name', 'my-form'); + component = await fixture( + html` + + test switch + + `, + { parentNode } + ); + + component.checked = false; + await component.updateComplete; + + const form = document.querySelector('form'); + form?.dispatchEvent(new Event('reset')); + await component.updateComplete; + + const formdata = createFormData(form as HTMLFormElement); + expect(formdata.get('my-switch')).to.equal('test'); + }); +}); diff --git a/packages/pharos/src/components/switch/pharos-switch.ts b/packages/pharos/src/components/switch/pharos-switch.ts new file mode 100644 index 000000000..c8769b0f2 --- /dev/null +++ b/packages/pharos/src/components/switch/pharos-switch.ts @@ -0,0 +1,109 @@ +import type { CSSResultArray, TemplateResult } from 'lit'; +import { html } from 'lit'; +import { switchStyles } from './pharos-switch.css'; +import FormMixin from '../../utils/mixins/form'; +import { FormElement } from '../base/form-element'; +import { property, query } from 'lit/decorators.js'; + +/** + * Pharos switch component. + * + * @tag pharos-switch + * + * @fires change - Fires when the value has changed + */ +export class PharosSwitch extends FormMixin(FormElement) { + /** + * Indicates if checkbox is checked. + * @attr checked + */ + @property({ type: Boolean, reflect: true }) + public checked = false; + + /** + * Indicates the value for the input. + * @attr value + */ + @property({ type: String, reflect: true }) + public value = ''; + + @query('#switch-element') + private _switch!: HTMLInputElement; + + public static override get styles(): CSSResultArray { + return [switchStyles]; + } + + protected override firstUpdated(): void { + this._switch.defaultChecked = this.checked; + } + + private _handleClick(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this._switch.click(); + } + + public onChange(): void { + /** + * The native checkbox state has already changed, so invert it + */ + const originalCheckedState = !this._switch.checked; + + this.checked = this._switch.checked; + + const notCancelled = this.dispatchEvent( + new CustomEvent('change', { + bubbles: true, + cancelable: true, + composed: true, + detail: { + target: this._switch, // pass the native checkbox in the event + }, + }) + ); + + /** + * if the event was prevented + * indeterminate and checked states return to their previous values + */ + if (!notCancelled) { + this.checked = originalCheckedState; + } + } + + _handleFormdata(event: CustomEvent): void { + const { formData } = event; + if (!this.disabled && this.checked) { + formData.append(this.name, this.value || 'on'); + } + } + + _handleFormReset(): void { + this.checked = this._switch.defaultChecked; + } + + protected override render(): TemplateResult { + return html` +
    + +
    + + +
    +
    + `; + } +} diff --git a/packages/pharos/src/components/switch/pharos-switch.wc.stories.jsx b/packages/pharos/src/components/switch/pharos-switch.wc.stories.jsx new file mode 100644 index 000000000..9465a5e7a --- /dev/null +++ b/packages/pharos/src/components/switch/pharos-switch.wc.stories.jsx @@ -0,0 +1,32 @@ +import { html } from 'lit'; +import { action } from '@storybook/addon-actions'; + +import { configureDocsPage } from '@config/docsPageConfig'; + +export default { + title: 'Forms/Switch', + component: 'pharos-switch', + parameters: { + docs: { + page: configureDocsPage('switch'), + }, + options: { selectedPanel: 'addon-controls' }, + }, +}; + +export const Base = { + render: ({ disabled, checked }) => + html` Toggle Switch`, + args: { + disabled: false, + checked: false, + }, + parameters: { + options: { selectedPanel: 'addon-actions' }, + }, +}; diff --git a/packages/pharos/src/index.ts b/packages/pharos/src/index.ts index 142eee794..80ff33408 100644 --- a/packages/pharos/src/index.ts +++ b/packages/pharos/src/index.ts @@ -45,3 +45,4 @@ export { PharosToggleButtonGroup } from './components/toggle-button-group/pharos export { PharosCoachMark } from './components/coach-mark/pharos-coach-mark'; export { PharosPopover } from './components/popover/pharos-popover'; export { PharosSheet } from './components/sheet/pharos-sheet'; +export { PharosSwitch } from './components/switch/pharos-switch'; diff --git a/packages/pharos/src/test/initComponents.ts b/packages/pharos/src/test/initComponents.ts index 890593a5d..d85a5096e 100644 --- a/packages/pharos/src/test/initComponents.ts +++ b/packages/pharos/src/test/initComponents.ts @@ -34,6 +34,7 @@ import { PharosSidenavLink, PharosSidenavMenu, PharosSidenavSection, + PharosSwitch, PharosTabs, PharosTab, PharosTable, @@ -85,6 +86,7 @@ registerComponents('test', [ PharosSidenavLink, PharosSidenavMenu, PharosSidenavSection, + PharosSwitch, PharosTabs, PharosTab, PharosTable, diff --git a/packages/pharos/tokens/color/base.json b/packages/pharos/tokens/color/base.json index a9aed1553..be4b31d84 100644 --- a/packages/pharos/tokens/color/base.json +++ b/packages/pharos/tokens/color/base.json @@ -39,7 +39,8 @@ }, "Green": { "base": { "value": "hsl(137.6, 54.8%, 32.9%)", "group": "", "palette": "feedback", "order": "1"}, - "93": { "value": "hsl(135, 22.2%, 92.9%)", "group": "", "palette": "feedback", "order": "1" } + "93": { "value": "hsl(135, 22.2%, 92.9%)", "group": "", "palette": "feedback", "order": "1" }, + "97": { "value": "hsl(140, 60%, 97.0%)", "group": "", "palette": "feedback", "order": "1" } }, "interactive": { "primary": {