Skip to content

Commit

Permalink
feat(switch): add new package (#2625)
Browse files Browse the repository at this point in the history
* feat(switch): add package

* feat(tokens): add new icon size, adjust dark colors

* chore(box): add _checked_hover selector for switch

* chore(theme): add new icon size to theme shape

* feat(internal-docs): new tokens and components

* test(switch): add tests

* docs(checkbox, radio, chat-log): small improvements

* chore(switch): small type fixes

* docs(switch): add docs page

* chore(tokens): typo

* chore(switch): pr feedback

* fix: tidy up some markup and accessibility issues

* chore(switch): feedback from pr

* chore(switch): small fixes

* chore(switch): a11y violation in story

Co-authored-by: Si Taggart <me@simontaggart.com>
  • Loading branch information
nkrantz and SiTaggart committed Aug 23, 2022
1 parent 68d851d commit bd0a3fe
Show file tree
Hide file tree
Showing 41 changed files with 1,035 additions and 13 deletions.
6 changes: 6 additions & 0 deletions .changeset/eighty-cooks-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/label': minor
'@twilio-paste/core': minor
---

[Label] add the ability to use the label as a div HTML element
6 changes: 6 additions & 0 deletions .changeset/famous-dragons-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/switch': major
'@twilio-paste/core': minor
---

[Switch] add Switch package
6 changes: 6 additions & 0 deletions .changeset/khaki-dogs-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/core': patch
'@twilio-paste/theme': patch
---

[Theme] add new icon size (05) to theme shape
6 changes: 6 additions & 0 deletions .changeset/large-nails-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/core': patch
'@twilio-paste/box': patch
---

[Box] add a `_checked_hover` pseudo selector style prop for use in switch package
6 changes: 6 additions & 0 deletions .changeset/neat-rivers-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/core': minor
'@twilio-paste/design-tokens': minor
---

[Design tokens] add new icon size and line height tokens (05), adust dark theme background tokens
1 change: 1 addition & 0 deletions .codesandbox/ci.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"/packages/paste-core/layout/stack",
"/packages/paste-style-props",
"/packages/paste-libraries/styling",
"/packages/paste-core/components/switch",
"/packages/paste-core/components/table",
"/packages/paste-core/components/tabs",
"/packages/paste-core/primitives/tabs",
Expand Down
13 changes: 13 additions & 0 deletions internal-docs/engineering/core/adding-design-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Adding New Design Tokens

Tokens live in `packages/paste-design-tokens/tokens/global`. Dark theme tokens live in `packages/paste-design-tokens/tokens/dark/global`.

After adding a new token, bump the package and core up as a minor by running `yarn changeset`.

## Colors

When adding new color tokens, make sure the value points to an alias (e.g. `red-60`).

## Icon sizes

Make sure the new icon size points to a line height. You may have to add a new token to fit your needs. You'll also have to add the new line height to the alias file. You'll also need to add the new icon size to the `packages/paste-theme/src/generateThemeFromTokens` file and the `packages/paste-theme/src/types/GenericThemeShape` file. That will require a re-build and a snapshot update for the theme shape.
11 changes: 11 additions & 0 deletions internal-docs/engineering/core/component-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

Rules to abide by when designing UI components.

## Adding a new component

Run the `yarn create:package` script to create a new package. The initial changeset should be a major for the new package and a minor for core, and the new package should be released as version 1.x.x. Add the following dependencies to the package.json of your new package.

```
"@twilio-paste/box": "^x.x.x",
"@twilio-paste/design-tokens": "^x.x.x",
"@twilio-paste/style-props": "^x.x.x",
"@twilio-paste/theme": "^x.x.x",
```

## All

Parent components should not affect the style of child components (i.e.: .mycomponent > div)
Expand Down
2 changes: 2 additions & 0 deletions packages/paste-codemods/tools/.cache/mappings.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@
"Separator": "@twilio-paste/core/separator",
"SkeletonLoader": "@twilio-paste/core/skeleton-loader",
"Spinner": "@twilio-paste/core/spinner",
"Switch": "@twilio-paste/core/switch",
"SwitchContainer": "@twilio-paste/core/switch",
"TBody": "@twilio-paste/core/table",
"TBodyPropTypes": "@twilio-paste/core/table",
"TFoot": "@twilio-paste/core/table",
Expand Down
6 changes: 3 additions & 3 deletions packages/paste-core/components/label/src/Label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {RequiredDot} from './RequiredDot';

export type LabelVariants = 'default' | 'inverse';
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement>, Pick<BoxProps, 'element'> {
as?: 'label' | 'legend';
as?: 'label' | 'legend' | 'div';
children: NonNullable<React.ReactNode>;
disabled?: boolean;
htmlFor: string | undefined;
Expand Down Expand Up @@ -45,7 +45,7 @@ const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
let cursor = 'pointer';
if (disabled) {
cursor = 'not-allowed';
} else if (as === 'legend') {
} else if (as === 'legend' || as === 'div') {
cursor = 'default';
}

Expand Down Expand Up @@ -85,7 +85,7 @@ const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
Label.displayName = 'Label';

Label.propTypes = {
as: PropTypes.oneOf(['label', 'legend']),
as: PropTypes.oneOf(['label', 'legend', 'div']),
disabled: PropTypes.bool,
element: PropTypes.string,
htmlFor: PropTypes.string,
Expand Down
102 changes: 102 additions & 0 deletions packages/paste-core/components/switch/__tests__/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as React from 'react';
import {render} from '@testing-library/react';
import {CustomizationProvider} from '@twilio-paste/customization';
import {Default, Disabled, On, Required} from '../stories/index.stories';

describe('Switch', () => {
it('should render as role="switch"', () => {
const {getByRole} = render(<Default />);
expect(getByRole('switch')).not.toBeNull();
});
it('should render as disabled', () => {
const {getByRole} = render(<Disabled />);
expect(getByRole('switch').getAttribute('aria-disabled')).toBe('true');
});
it('should default to aria-checked="false"', () => {
const {getByRole} = render(<Default />);
expect(getByRole('switch').getAttribute('aria-checked')).toBe('false');
});
it('should add aria-checked when switch is "on"', () => {
const {getByRole} = render(<On />);
expect(getByRole('switch').getAttribute('aria-checked')).toBe('true');
});
it('should set aria-labelledby to the label id', () => {
const {getByRole, container} = render(<Default />);
const labelId = getByRole('switch').getAttribute('aria-labelledby');
expect(container.querySelector('[data-paste-element="SWITCH_CONTAINER_LABEL"]')?.getAttribute('id')).toEqual(
labelId
);
});
it('should set aria-describedby to the help text id when past', () => {
const {getByRole, container} = render(<Default />);
const describedbyId = getByRole('switch').getAttribute('aria-describedby');
expect(container.querySelector('[data-paste-element="SWITCH_CONTAINER_HELP_TEXT"]')?.getAttribute('id')).toEqual(
describedbyId
);
});
});

describe('Switch customization', () => {
it('should set an element data attribute on Switch', () => {
const {getByRole, container} = render(<Required />);
expect(getByRole('switch').dataset.pasteElement).toEqual('SWITCH');
expect(getByRole('switch').firstChild?.firstChild).toHaveAttribute('data-paste-element', 'SWITCH_KNOB');
expect(getByRole('switch').querySelector('[data-paste-element="SWITCH_ICON"]')).toBeInTheDocument();
expect(container.querySelector('[data-paste-element="SWITCH_CONTAINER_HELP_TEXT"]')).toBeInTheDocument();
expect(container.querySelector('[data-paste-element="SWITCH_CONTAINER_LABEL"]')).toBeInTheDocument();
});
it('should set custom element data attributes on Switch', () => {
const {getByRole, container} = render(<Required element="MY_SWITCH" />);
expect(getByRole('switch').dataset.pasteElement).toEqual('MY_SWITCH');
expect(getByRole('switch').firstChild?.firstChild).toHaveAttribute('data-paste-element', 'MY_SWITCH_KNOB');
expect(getByRole('switch').querySelector('[data-paste-element="MY_SWITCH_ICON"]')).toBeInTheDocument();
expect(container.querySelector('[data-paste-element="MY_SWITCH_HELP_TEXT"]')).toBeInTheDocument();
expect(container.querySelector('[data-paste-element="MY_SWITCH_LABEL"]')).toBeInTheDocument();
});
it('should add custom styling to Switch', () => {
const {getByRole, container} = render(
<CustomizationProvider
theme={TestTheme}
elements={{
SWITCH: {height: '30px', width: '52'},
SWITCH_KNOB: {height: '26px', width: '26px'},
SWITCH_CONTAINER_HELP_TEXT: {backgroundColor: 'colorBackgroundAvailable'},
SWITCH_CONTAINER_LABEL: {backgroundColor: 'colorBackgroundBrandStrong'},
}}
>
<Required />
</CustomizationProvider>
);
const theSwitch = getByRole('switch');
const switchKnob = getByRole('switch').firstChild?.firstChild;
const switchLabel = container.querySelector('[data-paste-element="SWITCH_CONTAINER_LABEL"]');
const switchHelpText = container.querySelector('[data-paste-element="SWITCH_CONTAINER_HELP_TEXT"]');
expect(theSwitch).toHaveStyleRule('height', '30px');
expect(switchKnob).toHaveStyleRule('height', '26px');
expect(switchLabel).toHaveStyleRule('background-color', 'rgb(3, 11, 93)');
expect(switchHelpText).toHaveStyleRule('background-color', 'rgb(20, 176, 83)');
});
it('should add custom styling to a custom named Switch', () => {
const {getByRole, container} = render(
<CustomizationProvider
theme={TestTheme}
elements={{
MY_SWITCH: {height: '30px', width: '52'},
MY_SWITCH_KNOB: {height: '26px', width: '26px'},
MY_SWITCH_HELP_TEXT: {backgroundColor: 'colorBackgroundAvailable'},
MY_SWITCH_LABEL: {backgroundColor: 'colorBackgroundBrandStrong'},
}}
>
<Required element="MY_SWITCH" />
</CustomizationProvider>
);
const toggle = getByRole('switch');
const toggleKnob = getByRole('switch').firstChild?.firstChild;
const toggleLabel = container.querySelector('[data-paste-element="MY_SWITCH_LABEL"]');
const toggleHelpText = container.querySelector('[data-paste-element="MY_SWITCH_HELP_TEXT"]');
expect(toggle).toHaveStyleRule('height', '30px');
expect(toggleKnob).toHaveStyleRule('height', '26px');
expect(toggleLabel).toHaveStyleRule('background-color', 'rgb(3, 11, 93)');
expect(toggleHelpText).toHaveStyleRule('background-color', 'rgb(20, 176, 83)');
});
});
3 changes: 3 additions & 0 deletions packages/paste-core/components/switch/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const {build} = require('../../../../tools/build/esbuild');

build(require('./package.json'));
57 changes: 57 additions & 0 deletions packages/paste-core/components/switch/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@twilio-paste/switch",
"version": "0.0.0",
"category": "interaction",
"status": "production",
"description": "A switch is an interactive binary control.",
"author": "Twilio Inc.",
"license": "MIT",
"main:dev": "src/index.tsx",
"main": "dist/index.js",
"module": "dist/index.es.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"build": "yarn clean && NODE_ENV=production node build.js && tsc",
"build:js": "NODE_ENV=development node build.js",
"build:props": "typedoc --tsconfig ./tsconfig.json --json ./dist/prop-types.json",
"clean": "rm -rf ./dist",
"tsc": "tsc"
},
"peerDependencies": {
"@twilio-paste/box": "^7.0.0",
"@twilio-paste/design-tokens": "^8.0.0",
"@twilio-paste/help-text": "^10.0.0",
"@twilio-paste/icons": "^9.0.0",
"@twilio-paste/label": "^10.0.0",
"@twilio-paste/media-object": "^7.0.0",
"@twilio-paste/style-props": "^6.0.0",
"@twilio-paste/text": "^7.0.0",
"@twilio-paste/theme": "^8.0.0",
"@twilio-paste/uid-library": "^0.2.5",
"prop-types": "^15.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@twilio-paste/box": "^7.0.0",
"@twilio-paste/design-tokens": "^8.0.0",
"@twilio-paste/help-text": "^10.0.0",
"@twilio-paste/icons": "^9.0.0",
"@twilio-paste/label": "^10.0.0",
"@twilio-paste/media-object": "^7.0.0",
"@twilio-paste/style-props": "^6.0.0",
"@twilio-paste/text": "^7.0.0",
"@twilio-paste/theme": "^8.0.0",
"@twilio-paste/uid-library": "^0.2.5",
"prop-types": "^15.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
105 changes: 105 additions & 0 deletions packages/paste-core/components/switch/src/Switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import type {SwitchProps} from './types';
import {SWITCH_HEIGHT, SWITCH_WIDTH} from './constants';
import {SwitchKnob} from './SwitchKnob';
import {Box, safelySpreadBoxProps} from '@twilio-paste/box';
import type {BoxStyleProps} from '@twilio-paste/box';

const styles: BoxStyleProps = {
backgroundColor: 'colorBackgroundStronger',
_checked_hover: {
backgroundColor: 'colorBackgroundPrimary',
color: 'colorTextLink',
},
_checked: {
backgroundColor: 'colorBackgroundPrimaryStronger',
color: 'colorTextLinkStronger',
},
_hover: {
backgroundColor: 'colorBackgroundStrongest',
cursor: 'pointer',
},
_disabled: {
backgroundColor: 'colorBackgroundStrong',
color: 'colorTextIcon',
cursor: 'not-allowed',
},
_focus: {boxShadow: 'shadowFocus'},
};

const Switch = React.forwardRef<HTMLDivElement, SwitchProps>(
({element = 'SWITCH', id, labelId, helpTextId, disabled = false, on = false, onClick, ...props}, ref) => {
const [switchIsOn, setSwitchIsOn] = React.useState(on);
const [isHovering, setIsHovering] = React.useState(false);

const handleClick = React.useCallback((): void => {
if (!disabled) {
setSwitchIsOn(!switchIsOn);
if (onClick) onClick();
}
}, [onClick, disabled, switchIsOn]);

const handleKeyDown = React.useCallback((event: React.KeyboardEvent): void => {
if (event.key === ' ' || event.key === 'Enter') setSwitchIsOn((prev) => !prev);
}, []);

return (
<Box
{...safelySpreadBoxProps(props)}
{...styles}
as="div"
role="switch"
aria-checked={switchIsOn}
aria-disabled={disabled}
aria-labelledby={labelId}
aria-describedby={helpTextId}
element={element}
id={id}
ref={ref}
tabIndex={0}
outline="none"
position="relative"
display="inline-block"
boxSizing="content-box"
height={SWITCH_HEIGHT}
width={SWITCH_WIDTH}
overflow="hidden"
padding="space10"
borderColor="colorBorder"
borderWidth="borderWidth10"
borderRadius="borderRadiusPill"
transition="background-color .2s ease-in-out, box-shadow .2s ease-in-out"
onClick={handleClick}
onKeyDown={handleKeyDown}
onMouseEnter={() => {
setIsHovering(true);
}}
onMouseLeave={() => {
setIsHovering(false);
}}
>
<SwitchKnob
element={element}
disabled={disabled}
switchIsOn={switchIsOn}
isHovering={isHovering}
height={SWITCH_HEIGHT}
/>
</Box>
);
}
);

Switch.displayName = 'Switch';

Switch.propTypes = {
disabled: PropTypes.bool,
element: PropTypes.string,
labelId: PropTypes.string,
id: PropTypes.string,
on: PropTypes.bool,
onClick: PropTypes.func,
};

export {Switch};
Loading

0 comments on commit bd0a3fe

Please sign in to comment.