Skip to content

Commit

Permalink
feat(disclosure): create component package
Browse files Browse the repository at this point in the history
  • Loading branch information
SiTaggart committed Jul 1, 2020
1 parent 3299ad6 commit 6911efb
Show file tree
Hide file tree
Showing 11 changed files with 511 additions and 0 deletions.
4 changes: 4 additions & 0 deletions packages/paste-core/components/disclosure/CHANGELOG.md
@@ -0,0 +1,4 @@
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
@@ -0,0 +1,60 @@
import * as React from 'react';
import {axe} from 'jest-axe';
import {render, screen, fireEvent} from '@testing-library/react';
import {Disclosure, DisclosureContent, DisclosureHeading, DisclosureHeadingProps, DisclosureProps} from '../src';

export const MockDisclosure: React.FC<{
visible?: DisclosureProps['visible'];
disabled?: DisclosureHeadingProps['disabled'];
focusable?: DisclosureHeadingProps['focusable'];
}> = ({visible, disabled, focusable}) => {
return (
<Disclosure baseId="disclosure" visible={visible}>
<DisclosureHeading as="h1" variant="heading10" disabled={disabled} focusable={focusable}>
Clickable heading
</DisclosureHeading>
<DisclosureContent data-testid="disclosure">Disclosure content</DisclosureContent>
</Disclosure>
);
};

describe('Disclosure', () => {
it('should render a disclosure button with aria attributes', () => {
render(<MockDisclosure />);
const renderedDisclosureButton = screen.getByRole('button');
expect(renderedDisclosureButton.getAttribute('aria-expanded')).toEqual('false');
expect(renderedDisclosureButton.getAttribute('aria-controls')).toEqual('disclosure');
expect(renderedDisclosureButton.getAttribute('tabindex')).toEqual('0');
expect(screen.getByTestId('disclosure').id).toEqual('disclosure');
});
it('should render a disclosure open', () => {
render(<MockDisclosure visible />);
const renderedDisclosureButton = screen.getByRole('button');
expect(renderedDisclosureButton.getAttribute('aria-expanded')).toEqual('true');
});
it('should update attributes when clicked', () => {
render(<MockDisclosure />);
const renderedDisclosureButton = screen.getByRole('button');
fireEvent.click(renderedDisclosureButton);
expect(renderedDisclosureButton.getAttribute('aria-expanded')).toEqual('true');
});
it('should render a disabled disclosure', () => {
render(<MockDisclosure disabled />);
const renderedDisclosureButton = screen.getByRole('button');
expect(renderedDisclosureButton.getAttribute('aria-disabled')).toEqual('true');
expect(renderedDisclosureButton.getAttribute('tabindex')).toBeNull();
});
it('should render a disabled but focusable disclosure', () => {
render(<MockDisclosure disabled focusable />);
const renderedDisclosureButton = screen.getByRole('button');
expect(renderedDisclosureButton.getAttribute('aria-disabled')).toEqual('true');
expect(renderedDisclosureButton.getAttribute('tabindex')).toEqual('0');
});
describe('accessibility', () => {
it('should have no accessibility violations', async () => {
const {container} = render(<MockDisclosure />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
});
59 changes: 59 additions & 0 deletions packages/paste-core/components/disclosure/package.json
@@ -0,0 +1,59 @@
{
"name": "@twilio-paste/disclosure",
"version": "0.0.0",
"category": "interaction",
"status": "alpha",
"description": "The Disclosure is used to create accessible, hierarchical and collapsible structure to your pages.",
"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 && yarn compile",
"build:dev": "yarn clean && yarn compile:dev",
"build:props": "typedoc --tsconfig ./tsconfig.json --json ./dist/prop-types.json",
"clean": "rm -rf ./dist && rm -rf tsconfig.build.tsbuildinfo && rm -rf .rpt2_cache",
"compile": "rollup -c --environment NODE_ENV:production",
"compile:dev": "rollup -c --environment NODE_ENV:development",
"prepublishOnly": "yarn build",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"@twilio-paste/box": "^2.5.4",
"@twilio-paste/card": "^1.3.41",
"@twilio-paste/design-tokens": "^5.2.2",
"@twilio-paste/disclosure-primitive": "^0.1.6",
"@twilio-paste/heading": "^2.0.14",
"@twilio-paste/icons": "^2.2.9",
"@twilio-paste/style-props": "^1.2.5",
"@twilio-paste/styling-library": "^0.1.0",
"@twilio-paste/text": "^2.1.15",
"@twilio-paste/theme": "^3.2.5",
"@twilio-paste/types": "^3.0.9",
"prop-types": "^15.7.2",
"react": "^16.8.6",
"react-dom": "^16.8.6"
},
"devDependencies": {
"@twilio-paste/box": "^2.5.4",
"@twilio-paste/card": "^1.3.41",
"@twilio-paste/design-tokens": "^5.2.2",
"@twilio-paste/disclosure-primitive": "^0.1.6",
"@twilio-paste/heading": "^2.0.14",
"@twilio-paste/icons": "^2.2.9",
"@twilio-paste/style-props": "^1.2.5",
"@twilio-paste/styling-library": "^0.1.0",
"@twilio-paste/text": "^2.1.15",
"@twilio-paste/theme": "^3.2.5",
"@twilio-paste/types": "^3.0.9"
}
}
34 changes: 34 additions & 0 deletions packages/paste-core/components/disclosure/rollup.config.js
@@ -0,0 +1,34 @@
import typescript from 'rollup-plugin-typescript2';
import babel from 'rollup-plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import {terser} from 'rollup-plugin-terser';
import pkg from './package.json';

export default {
input: pkg['main:dev'],
output: [
{
file: pkg.main,
format: 'cjs',
},
{
file: pkg.module,
format: 'esm',
},
],
external: [...Object.keys(pkg.peerDependencies || {})],
plugins: [
resolve(),
commonjs(),
typescript({
clean: true,
typescript: require('typescript'),
tsconfig: './tsconfig.build.json',
}),
babel({
exclude: 'node_modules/**',
}),
process.env.NODE_ENV === 'production' ? terser() : null,
],
};
47 changes: 47 additions & 0 deletions packages/paste-core/components/disclosure/src/Disclosure.tsx
@@ -0,0 +1,47 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import {
useDisclosurePrimitiveState,
DisclosurePrimitiveInitialState,
DisclosurePrimitveStateReturn,
} from '@twilio-paste/disclosure-primitive';
import {Card} from '@twilio-paste/card';

export type Variants = 'contained' | 'default';

export interface DisclosureContextProps {
disclosure: DisclosurePrimitveStateReturn;
variant: Variants;
}

export const DisclosureContext = React.createContext<DisclosureContextProps>({} as any);

export interface DisclosureProps extends DisclosurePrimitiveInitialState {
children: NonNullable<React.ReactNode>;
variant?: Variants;
}
const Disclosure: React.FC<DisclosureProps> = ({children, variant = 'default', ...props}) => {
const disclosure = useDisclosurePrimitiveState({...props});
const disclosureContext = {
disclosure,
variant,
};

if (variant === 'contained') {
return (
<DisclosureContext.Provider value={disclosureContext}>
<Card padding="space0">{children}</Card>
</DisclosureContext.Provider>
);
}
return <DisclosureContext.Provider value={disclosureContext}>{children}</DisclosureContext.Provider>;
};
Disclosure.displayName = 'Disclosure';

if (process.env.NODE_ENV === 'development') {
Disclosure.propTypes = {
children: PropTypes.node.isRequired,
variant: PropTypes.oneOf(['default', 'contained']),
};
}
export {Disclosure};
@@ -0,0 +1,39 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import {DisclosurePrimitiveContent, DisclosurePrimitiveContentProps} from '@twilio-paste/disclosure-primitive';
import {Box} from '@twilio-paste/box';
import {DisclosureContext, Variants} from './Disclosure';

const getVariantStyles = (variant: Variants): {} => {
switch (variant) {
case 'contained':
return {
borderTopStyle: 'solid',
borderTopWidth: 'borderWidth20',
borderTopColor: 'colorBorderLight',
padding: 'space50',
};
default:
return {};
}
};

export interface DisclosureContentProps extends DisclosurePrimitiveContentProps {
children: NonNullable<React.ReactNode>;
}
const DisclosureContent: React.FC<DisclosureContentProps> = ({children, ...props}) => {
const {disclosure, variant} = React.useContext(DisclosureContext);
return (
<DisclosurePrimitiveContent {...disclosure} {...props} as={Box} {...getVariantStyles(variant)}>
{children}
</DisclosurePrimitiveContent>
);
};
DisclosureContent.displayName = 'DisclosureContent';

if (process.env.NODE_ENV === 'development') {
DisclosureContent.propTypes = {
children: PropTypes.node.isRequired,
};
}
export {DisclosureContent};
123 changes: 123 additions & 0 deletions packages/paste-core/components/disclosure/src/DisclosureHeading.tsx
@@ -0,0 +1,123 @@
import * as React from 'react';
import {Heading, HeadingProps, HeadingPropTypes} from '@twilio-paste/heading';
import {Box} from '@twilio-paste/box';
import {ChevronDownIcon, ChevronDownIconProps} from '@twilio-paste/icons/esm/ChevronDownIcon';
import {DisclosurePrimitive, DisclosurePrimitiveProps} from '@twilio-paste/disclosure-primitive';
import {DisclosureContext, Variants} from './Disclosure';

export interface DisclosureHeadingProps extends Omit<DisclosurePrimitiveProps, 'baseId' | 'toggle'> {
children: NonNullable<React.ReactNode>;
as: HeadingProps['as'];
marginBottom?: HeadingProps['marginBottom'];
variant: HeadingProps['variant'];
}

interface StyledDisclosureHeadingProps extends Omit<DisclosureHeadingProps, 'as'> {
renderAs: HeadingProps['as'];
customDisabled?: boolean;
customFocusable?: boolean;
disclosureVariant: Variants;
}

const getIconSize = (variant: HeadingProps['variant']): ChevronDownIconProps['size'] => {
switch (variant) {
case 'heading10':
return 'sizeIcon90';
case 'heading20':
return 'sizeIcon70';
case 'heading30':
return 'sizeIcon60';
case 'heading40':
return 'sizeIcon40';
case 'heading50':
return 'sizeIcon30';
case 'heading60':
default:
return 'sizeIcon20';
}
};

const getVariantStyles = (variant: Variants): {} => {
switch (variant) {
case 'contained':
return {
paddingBottom: 'space50',
paddingLeft: 'space40',
paddingRight: 'space40',
paddingTop: 'space50',
};
default:
return {};
}
};

const StyledDisclosureHeading = React.forwardRef<HTMLDivElement, StyledDisclosureHeadingProps>(
({children, marginBottom, renderAs, disclosureVariant, customDisabled, customFocusable, variant, ...props}, ref) => {
return (
<Heading
as={renderAs}
marginBottom={disclosureVariant === 'contained' ? 'space0' : marginBottom}
variant={variant}
>
<Box
as="div"
alignItems="center"
borderRadius="borderRadius20"
cursor="pointer"
display="flex"
outline="none"
position="relative"
ref={ref}
role="button"
_hover={{
textDecoration: 'underline',
}}
_focus={{
boxShadow: 'shadowFocus',
}}
_disabled={{
color: 'colorTextWeak',
cursor: 'not-allowed',
}}
{...getVariantStyles(disclosureVariant)}
{...props}
>
<Box
as="span"
display="flex"
transform={props['aria-expanded'] ? 'rotate(0deg)' : 'rotate(-90deg)'}
transition="all 200ms linear"
>
<ChevronDownIcon decorative size={getIconSize(variant)} />
</Box>
<Box flexGrow={1}>{children}</Box>
</Box>
</Heading>
);
}
);

const DisclosureHeading: React.FC<DisclosureHeadingProps> = ({children, as, disabled, focusable, ...props}) => {
const {disclosure, variant} = React.useContext(DisclosureContext);
return (
<DisclosurePrimitive
{...disclosure}
{...props}
renderAs={as}
as={StyledDisclosureHeading}
disclosureVariant={variant}
disabled={disabled}
customDisabled={disabled}
focusable={focusable}
customFocusable={focusable}
>
{children}
</DisclosurePrimitive>
);
};
DisclosureHeading.displayName = 'DisclosureHeading';

if (process.env.NODE_ENV === 'development') {
DisclosureHeading.propTypes = HeadingPropTypes;
}
export {DisclosureHeading};
3 changes: 3 additions & 0 deletions packages/paste-core/components/disclosure/src/index.tsx
@@ -0,0 +1,3 @@
export * from './Disclosure';
export * from './DisclosureHeading';
export * from './DisclosureContent';

0 comments on commit 6911efb

Please sign in to comment.