Crayons: Buttons vs CTAs (#15311)

* init

* buttons

* buttons, ctas, icons

* ctas fix

* links vs ctas

* alias

* fixes

* add tests for Buttons component

* add tooltip to button story controls

* add link and CTA tests

* add prop types

* docs

* designs

* docs

* focus visible moved to global place

* Updated some component snapshot tests.

* Mocked react-inlinesvg module because of https://github.com/gilbarbara/react-inlinesvg/issues/145\#issuecomment-623453339

* tests

* Copy updates

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>

* missed spots

* Apply suggestions from code review

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>

* danger accent colors

* node

* colors

* adding code comment

* better prop name: style -> variant

* button new

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
Co-authored-by: Nick Taylor <nick@dev.to>
This commit is contained in:
ludwiczakpawel 2021-11-30 20:40:32 +01:00 committed by GitHub
parent ad8506ee8c
commit 245e70d4a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1221 additions and 11 deletions

View file

@ -412,3 +412,106 @@ label[class*='crayons-btn']:focus-within // label for input[type="file"] made to
}
}
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
// .c-btn //////////////////////////////////////////////////
////////////////////////////////////////////////////////////
.c-btn {
position: relative;
display: inline-block;
padding: var(--su-2) var(--su-4);
border-radius: var(--radius);
font: inherit;
text-align: center;
cursor: pointer;
transition: all var(--transition-props);
border: none;
overflow-wrap: normal;
outline: 0;
background-color: var(--bg);
color: var(--color);
&:hover:enabled {
background-color: var(--bg-hover);
color: var(--color-hover);
z-index: var(--z-elevate);
}
// Both focus declarations below are the same but we unfortunately can't
// combine them because Safari doesn't handle it properly.
// First declaration is for all browsers that support :focus-visible
// which is basically almost all of them.
// Second declaration is for browsers that do not support :focus-visible
// which basically is only Safari right now :shrug:
&:focus-visible {
background-color: var(--bg-hover);
color: var(--color-hover);
z-index: var(--z-elevate);
box-shadow: var(--focus-ring);
}
.js-focus-visible &.focus-visible:focus {
background-color: var(--bg-hover);
color: var(--color-hover);
z-index: var(--z-elevate);
box-shadow: var(--focus-ring);
}
&[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
// This makes sure icons inside buttons don't cause problems with click handlers
// See: https://css-tricks.com/slightly-careful-sub-elements-clickable-things/
& > * {
pointer-events: none;
+ input {
pointer-events: revert;
}
}
}
.c-btn {
--bg: var(--btn-bg);
--bg-hover: var(--btn-bg-hover);
--color: var(--btn-color);
--color-hover: var(--btn-color-hover);
&.c-btn--destructive {
--bg: var(--btn-destructive-bg);
--bg-hover: var(--btn-destructive-bg-hover);
--color: var(--btn-destructive-color);
--color-hover: var(--btn-destructive-color-hover);
}
}
.c-btn--primary {
--bg: var(--btn-primary-bg);
--bg-hover: var(--btn-primary-bg-hover);
--color: var(--btn-primary-color);
--color-hover: var(--btn-primary-color-hover);
font-weight: var(--fw-medium);
&.c-btn--destructive {
--bg: var(--btn-primary-destructive-bg);
--bg-hover: var(--btn-primary-destructive-bg-hover);
--color: var(--btn-primary-destructive-color);
--color-hover: var(--btn-primary-destructive-color-hover);
}
}
.c-btn--icon-alone {
padding: var(--su-2);
}
.c-btn--icon-left {
.c-btn__icon {
margin-right: var(--su-2);
margin-left: calc(var(--su-1) * -1);
}
}

View file

@ -0,0 +1,40 @@
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
// .c-cta //////////////////////////////////////////////////
////////////////////////////////////////////////////////////
.c-link {
&.c-cta {
--border-width: 1px;
padding: calc(var(--su-2) - var(--border-width))
calc(var(--su-4) - var(--border-width));
border: var(--border-width) solid var(--border);
text-decoration: none;
background-color: var(--bg);
color: var(--color);
text-align: center;
display: inline-block;
}
&.c-cta {
--bg: transparent;
--bg-hover: var(--base-20);
--color: var(--base-80);
--color-hover: var(--base-100);
--border: var(--base-60);
--border-hover: var(--base-80);
}
&.c-cta--branded {
--bg: transparent;
--bg-hover: var(--accent-brand-a10);
--color: var(--accent-brand);
--color-hover: var(--accent-brand-darker);
--border: var(--accent-brand);
--border-hover: var(--accent-brand-darker);
font-weight: var(--fw-medium);
}
}

View file

@ -0,0 +1,93 @@
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
// .c-link /////////////////////////////////////////////////
////////////////////////////////////////////////////////////
.c-link {
position: relative;
display: inline-block;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
transition: all var(--transition-props);
outline: 0;
text-decoration: none;
color: var(--color);
&--block {
display: flex;
padding: var(--su-2) var(--su-4);
overflow-wrap: normal;
background-color: var(--bg);
&:hover {
background-color: var(--bg-hover);
}
}
&:hover {
color: var(--color-hover);
z-index: var(--z-elevate);
text-decoration: underline;
}
// Both focus declarations below are the same but we unfortunately can't
// combine them because Safari doesn't recognize it properly.
// First declaration is for all browsers that support :focus-visible
// which is basically almost all of them
// Second declaration is for browsers that do not support :focus-visible
// which basically is only Safari right now.
&:focus-visible {
color: var(--color-hover);
z-index: var(--z-elevate);
box-shadow: var(--focus-ring);
text-decoration: underline;
}
.js-focus-visible &.focus-visible:focus {
color: var(--color-hover);
z-index: var(--z-elevate);
box-shadow: var(--focus-ring);
text-decoration: underline;
}
&--block {
&:focus-visible {
background-color: var(--bg-hover);
}
.js-focus-visible &.focus-visible:focus {
background-color: var(--bg-hover);
}
}
}
.c-link {
--bg: transparent;
--bg-hover: var(--base-20);
--color: var(--base-80);
--color-hover: var(--accent-brand-darker);
}
.c-link--branded {
--bg: transparent;
--bg-hover: var(--accent-brand-a10);
--color: var(--accent-brand);
--color-hover: var(--accent-brand-darker);
}
.c-link--icon-alone {
padding: var(--su-2);
// If we ever want to display <Link /> only with icon it should probably
// no longer be `block` (or `flex`) element, i.e. "Notifications" icon in
// the header. Hence reverting to `inline-block`.
display: inline-block;
}
.c-link--icon-left {
.c-link__icon {
margin-right: var(--su-2);
margin-left: calc(var(--su-1) * -1);
}
}

View file

@ -69,9 +69,9 @@
--accent-warning-a10: #{rgba(#ffcf4c, 0.1)};
// Danger
--accent-danger: #dc1818;
--accent-danger-darker: #c20a0a;
--accent-danger-lighter: #ec5050;
--accent-danger: #d31616;
--accent-danger-darker: #b90909;
--accent-danger-lighter: #ec4646;
--accent-danger-a10: #{rgba(#dc1818, 0.1)};
////////////////////////////////////////////////////////////////////////////////////
@ -241,3 +241,37 @@
--reaction-comment-color: var(--accent-warning);
--reaction-comment-bg: var(--accent-warning-a10);
}
:root {
// Separating some variables here as these are the results
// of broader Crayons refactoring (see #15311) and I didn't
// want to mix everything together.
////////////////////////////////////////////////
// Buttons /////////////////////////////////////
////////////////////////////////////////////////
// Buttons: Default
--btn-bg: transparent;
--btn-bg-hover: var(--base-20);
--btn-color: var(--base-80);
--btn-color-hover: var(--base-100);
// Buttons: Default Destructive
--btn-destructive-bg: transparent;
--btn-destructive-bg-hover: var(--accent-danger-a10);
--btn-destructive-color: var(--accent-danger);
--btn-destructive-color-hover: var(--accent-danger-darker);
// Buttons: Primary
--btn-primary-bg: var(--accent-brand);
--btn-primary-bg-hover: var(--accent-brand-darker);
--btn-primary-color: var(--base-inverted);
--btn-primary-color-hover: var(--base-inverted);
// Buttons: Primary Destructive
--btn-primary-destructive-bg: var(--accent-danger);
--btn-primary-destructive-bg-hover: var(--accent-danger-darker);
--btn-primary-destructive-color: var(--base-inverted);
--btn-primary-destructive-color-hover: var(--base-inverted);
}

View file

@ -52,6 +52,13 @@
--transition-time: 100ms;
--transition-props: var(--transition-func) var(--transition-time);
////////////////////////////////////////////////////////////////////////////////////
// Focus ring //////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////
--focus-ring: 0 0 0 2px var(--base-inverted),
0 0 0 4px var(--button-primary-bg-hover); // TODO: replace with appropriate variable from _colors.scss
////////////////////////////////////////////////////////////////////////////////////
// Typography. /////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////

View file

@ -14,6 +14,7 @@
@import 'components/buttons';
@import 'components/cards';
@import 'components/comments';
@import 'components/ctas';
@import 'components/dropdowns';
@import 'components/editor-toolbar';
@import 'components/forms';
@ -21,6 +22,7 @@
@import 'components/header';
@import 'components/headers';
@import 'components/indicators';
@import 'components/links';
@import 'components/loaders';
@import 'components/media-cards';
@import 'components/modals';

View file

@ -65,10 +65,10 @@
--accent-warning-a10: #{rgba(#ffcf4c, 0.1)};
// Danger
--accent-danger: #dc1818;
--accent-danger-darker: #c20a0a;
--accent-danger-lighter: #ec5050;
--accent-danger-a10: #{rgba(#dc1818, 0.1)};
--accent-danger: #ed3434;
--accent-danger-darker: #e40a0a;
--accent-danger-lighter: #f15555;
--accent-danger-a10: #{rgba(#ed3434, 0.1)};
// Main colors
--body-bg: var(--base-0);
@ -230,3 +230,19 @@
--reaction-save-color: var(--accent-brand);
--reaction-save-bg: var(--accent-brand-a10);
}
:root {
// Separating some variables here as these are the results
// of broader Crayons refactoring (see #15311) and I didn't
// want to mix everything together.
////////////////////////////////////////////////
// Buttons /////////////////////////////////////
////////////////////////////////////////////////
// Buttons: Primary
--btn-primary-bg-hover: var(--accent-brand-lighter);
// Buttons: Primary Destructive
--btn-primary-destructive-bg-hover: var(--accent-danger-lighter);
}

View file

@ -11,6 +11,7 @@ module.exports = {
// https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#correct-globs-in-mainjs
stories: ['../**/__stories__/*.stories.@(mdx|jsx)'],
addons: [
'@storybook/addon-controls',
'@storybook/addon-knobs',
'@storybook/addon-actions',
'@storybook/addon-links',
@ -81,4 +82,8 @@ module.exports = {
return config;
},
babel: async (options) => ({
...options,
plugins: [...options.plugins, 'inline-react-svg'],
}),
};

View file

@ -92,6 +92,7 @@ Foo.parameters = {
};
export const parameters = {
controls: { expanded: true },
options: {
storySort: {
order: [

View file

@ -6,4 +6,19 @@ body {
#root {
padding: 2rem;
}
.sbdocs-ul,
.sbdocs-ol {
list-style: revert;
margin: revert;
padding: revert;
}
code {
all: revert;
}
.docblock-source {
margin: revert;
}
}

View file

@ -17,7 +17,9 @@ export function initializeActionsPanel(user, path) {
`;
function toggleModActionsMenu() {
document.getElementById('mod-actions-menu-btn-area').classList.remove('hidden');
document
.getElementById('mod-actions-menu-btn-area')
.classList.remove('hidden');
document
.getElementsByClassName('mod-actions-menu')[0]
.classList.toggle('showing');
@ -41,8 +43,8 @@ export function initializeActionsPanel(user, path) {
// eslint-disable-next-line no-restricted-globals
if (!isModerationPage()) {
// don't show mod button in mod center page
document.getElementById('mod-actions-menu-btn-area')
.innerHTML = modActionsMenuIconHTML
document.getElementById('mod-actions-menu-btn-area').innerHTML =
modActionsMenuIconHTML;
document
.getElementsByClassName('mod-actions-menu-btn')[0]
.addEventListener('click', toggleModActionsMenu);

View file

@ -0,0 +1,84 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { useState } from 'preact/hooks';
import classNames from 'classnames/bind';
import { defaultChildrenPropTypes } from '../../common-prop-types/default-children-prop-types';
import { Icon } from '@crayons';
export const ButtonNew = (props) => {
const {
children,
primary,
icon,
rounded,
destructive,
type = 'button',
className,
tooltip,
onKeyUp,
...otherProps
} = props;
const [suppressTooltip, setSuppressTooltip] = useState(false);
const handleKeyUp = (event) => {
onKeyUp?.(event);
if (!tooltip) {
return;
}
setSuppressTooltip(event.key === 'Escape');
};
const classes = classNames('c-btn', {
'c-btn--primary': primary,
'c-btn--destructive': destructive,
'c-btn--icon-left': icon && children,
'c-btn--icon-alone': icon && !children,
'crayons-tooltip__activator': tooltip,
'radius-full': rounded,
[className]: className,
});
return (
<button
type={type}
className={classes}
onKeyUp={handleKeyUp}
{...otherProps}
>
{icon && (
<Icon
aria-hidden="true"
focusable="false"
src={icon}
className={classNames('c-btn__icon')}
/>
)}
{children}
{tooltip ? (
<span
data-testid="tooltip"
className={classNames('crayons-tooltip__content', {
'crayons-tooltip__suppressed': suppressTooltip,
})}
>
{tooltip}
</span>
) : null}
</button>
);
};
ButtonNew.displayName = 'ButtonNew';
ButtonNew.propTypes = {
children: defaultChildrenPropTypes,
primary: PropTypes.bool,
rounded: PropTypes.bool,
destructive: PropTypes.bool,
type: PropTypes.oneOf(['button', 'submit']),
className: PropTypes.string,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
onKeyUp: PropTypes.func,
icon: PropTypes.ReactNode,
};

View file

@ -0,0 +1,63 @@
# Buttons
Buttons are clickable elements that are used to trigger actions.
They communicate calls to action to the user and allow users to interact with pages in a variety of ways.
Button labels express what action will occur when the user interacts with it.
It is therefore very important that the different variants are implemented consistently across product,
so that they message the correct actions.
## CTAs vs Links vs Buttons
The most important of these rules is that the `<CTA />` (or `<Link />`) and `<Button />` elements are assigned to appropriate respective tasks.
That is: `<CTA />` or `<Link />` should be used where navigation is elicited, and `<Button />` where a non-navigational action is being invoked.
For example, a 'Submit' control on 'Sign in' view (form) would suit a `<Button />` because its job is to submit the form.
However, 'Register' call-to-action—taking the user to a different form, in a different page or screen—would benefit from `<CTA />` or `<Link />` semantics.
Do not use `<Button />s` as navigational elements. Instead, use Links (or CTAs) when the desired action is to take the user to a new page.
## Variants
### Primary
Primary button has special look: high contrast (against rest of the view/component) and it's filled with accent color.
Ideally there should be only one Primary button per entire component, or sometimes even per entire view.
Primary buttons should be used for the principal call to action on the page.
### Default
Default variant is less prominent, and therefore can be used for less prominent actions.
Default buttons are visually light (greyish) and you can have as many of them as you want (within common sense :D).
They can (but don't have to) be used in conjuction with a Primary button. In that case, default variant should be used for any remaining actions.
## Configuration
### Destructive actions
For actions that could have destructive effects on the users data (for example, *delete* or *remove*) we have a special style.
This style can be applied by using `destructive` prop and it will work for both: primary and default variants. Take care when using the default variant to ensure that the red text has sufficient color contrast against the background where you are using it.
### Icons
Icon can be placed in front of a label to both clarify an action and call attention to a button.
By passing an object in `icon` prop, there will be automatically included `<Icon />` component.
By default, the icon itself won't be customizable. Icons within buttons are hidden from assistive technology, so make sure your button has descriptive enough text, or is otherwise well-labelled (e.g. with `aria-label`).
#### Icon-only
There are certain instances where an icon will suffice in place of a text label, but use these cautiously.
Icon-only buttons should always use clear and obvious icon, ideally accompanied by a tooltip or title. Make sure your button is adequately labelled for assistive technologies, either by the tooltip or by an `aria-label`.
##### Fully rounded
In most cases, whenever we display icon-only buttons (standalone, not in a *buttons group*) we tend to make button fully rounded - use `rounded` prop for that.
Other than that please avoid customizing border-radius on buttons.
### Tooltip
By passing a string or node in `tooltip` prop, button will get a custom tooltip (visually different than a classic `title`) on `:hover` and `:focus`. The tooltip makes up part of the "accessible name" used by assistive technologies like screen readers. If you _do not_ wish the tooltip to be part of the accessible name, consider wrapping it in a `<span aria-hidden="true">`. Tooltips can be temporarily dismissed on `Escape` key press.
## Design
<iframe style="border: 1px solid rgba(0, 0, 0, 0.1);" width="100%" height="450" src="https://www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com%2Ffile%2FgHpwugeu9jFGW4d9u3ReuR%2FCrayons%3Fnode-id%3D1%253A29" allowfullscreen></iframe>

View file

@ -0,0 +1,76 @@
import { h } from 'preact';
import ButtonsDoc from './Buttons.mdx';
import { ButtonNew as Button } from '@crayons';
import CogIcon from '@images/cog.svg';
export default {
component: Button,
title: 'BETA/Buttons',
parameters: {
docs: {
page: ButtonsDoc,
},
},
argTypes: {
primary: {
description:
'This prop defines whether or not your button will have *primary* style (in practice: high contrast, filled with accent color). Keep in mind ideally there should be only one primary button per entire component, or sometimes even per entire view.',
table: {
defaultValue: { summary: false },
},
},
rounded: {
description:
'By enabling this setting you can turn all corners of the button to be fully rounded. We usually use it **only** for buttons containing **icon without label**.',
table: {
defaultValue: { summary: false },
},
},
destructive: {
description:
'For various destructive actions we can have special styling for button which will add red-ish coloring.',
table: {
defaultValue: { summary: false },
},
},
tooltip: {
description:
"If defined, button will have a custom tooltip on `:hover` and `:focus`. The tooltip content will form part of the button's accessible name unless passed as a `<span>` with `aria-hidden='true'`",
control: {
type: 'text',
},
table: {
defaultValue: { summary: undefined },
},
},
icon: {
control: false,
description:
'Even though the generated JSX code in this component will have an empty function as prop value (`<Button icon={() => {}}>`), the actual value should be an icon object imported from `@images`. Read more about icons in their dedicated Storybook page.',
table: {
defaultValue: { summary: 'CogIcon' },
},
},
},
};
export const Default = (args) => <Button {...args} />;
Default.args = {
primary: false,
destructive: false,
children: 'Button label',
tooltip: undefined,
rounded: false,
};
export const Primary = (args) => <Button {...args} />;
Primary.args = {
...Default.args,
primary: true,
};
export const WithIcon = (args) => <Button {...args} />;
WithIcon.args = {
...Default.args,
icon: CogIcon,
};

View file

@ -0,0 +1,110 @@
import { h } from 'preact';
import { render, fireEvent } from '@testing-library/preact';
import { axe } from 'jest-axe';
import { ButtonNew as Button } from '@crayons';
import CogIcon from '@images/cog.svg';
import '@testing-library/jest-dom';
describe('<Button />', () => {
it('has no accessibility errors in default variant', async () => {
const { container } = render(<Button>Hello world!</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no accessibility errors when props provided', async () => {
const { container } = render(
<Button primary rounded destructive icon={CogIcon} tooltip="tooltip">
Hello world!
</Button>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders a default button', () => {
const { container } = render(<Button>Hello world!</Button>);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders a primary button', () => {
const { container } = render(<Button primary>Hello world!</Button>);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders with an icon and text', () => {
const { container } = render(<Button icon={CogIcon}>Hello world!</Button>);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders with an icon only', () => {
const { container } = render(<Button icon={CogIcon} />);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders a destructive button', () => {
const { container } = render(<Button destructive>Hello world!</Button>);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders a rounded button', () => {
const { container } = render(<Button rounded>Hello world!</Button>);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders with a tooltip', () => {
const { container } = render(
<Button tooltip="tooltip text">Hello world!</Button>,
);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders with additional classnames', () => {
const { container } = render(
<Button className="one two three">Hello world!</Button>,
);
expect(container.innerHTML).toMatchSnapshot();
});
it('should render a button as a specific button type (HTML type attribute) when buttonType is set.', () => {
const { container } = render(<Button type="submit">Hello world!</Button>);
expect(container.innerHTML).toMatchSnapshot();
});
it('should attach additional passed props to the button element', () => {
const mockClickHandler = jest.fn();
const { getByRole } = render(
<Button onClick={mockClickHandler}>Hello world!</Button>,
);
const button = getByRole('button', { name: 'Hello world!' });
button.click();
expect(mockClickHandler).toHaveBeenCalledTimes(1);
});
it('should suppress any tooltip and execute any onKeyUp prop when Escape is pressed', async () => {
const mockKeyUpHandler = jest.fn();
const { getByRole, getByTestId } = render(
<Button tooltip="test tooltip" onKeyUp={mockKeyUpHandler}>
Hello world!
</Button>,
);
const button = getByRole('button', { name: 'Hello world! test tooltip' });
button.focus();
expect(getByTestId('tooltip')).not.toHaveClass(
'crayons-tooltip__suppressed',
);
fireEvent.keyUp(button, { key: 'Escape' });
expect(getByTestId('tooltip')).toHaveClass('crayons-tooltip__suppressed');
expect(mockKeyUpHandler).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Button /> renders a default button 1`] = `"<button type=\\"button\\" class=\\"c-btn\\">Hello world!</button>"`;
exports[`<Button /> renders a destructive button 1`] = `"<button type=\\"button\\" class=\\"c-btn c-btn--destructive\\">Hello world!</button>"`;
exports[`<Button /> renders a primary button 1`] = `"<button type=\\"button\\" class=\\"c-btn c-btn--primary\\">Hello world!</button>"`;
exports[`<Button /> renders a rounded button 1`] = `"<button type=\\"button\\" class=\\"c-btn radius-full\\">Hello world!</button>"`;
exports[`<Button /> renders with a tooltip 1`] = `"<button type=\\"button\\" class=\\"c-btn crayons-tooltip__activator\\">Hello world!<span data-testid=\\"tooltip\\" class=\\"crayons-tooltip__content\\">tooltip text</span></button>"`;
exports[`<Button /> renders with additional classnames 1`] = `"<button type=\\"button\\" class=\\"c-btn one two three\\">Hello world!</button>"`;
exports[`<Button /> renders with an icon and text 1`] = `"<button type=\\"button\\" class=\\"c-btn c-btn--icon-left\\"><svg class=\\"crayons-icon c-btn__icon\\" aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg>Hello world!</button>"`;
exports[`<Button /> renders with an icon only 1`] = `"<button type=\\"button\\" class=\\"c-btn c-btn--icon-alone\\"><svg class=\\"crayons-icon c-btn__icon\\" aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg></button>"`;
exports[`<Button /> should render a button as a specific button type (HTML type attribute) when buttonType is set. 1`] = `"<button type=\\"submit\\" class=\\"c-btn\\">Hello world!</button>"`;

View file

@ -0,0 +1 @@
export * from './Button';

View file

@ -0,0 +1,22 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import classNames from 'classnames/bind';
import { Link } from '@crayons';
export const CTA = (props) => {
const { variant = 'default', className, ...otherProps } = props;
const classes = classNames('c-cta', {
[`c-cta--${variant}`]: variant,
[className]: className,
});
return <Link block className={classes} {...otherProps} />;
};
CTA.displayName = 'CTA';
CTA.propTypes = {
variant: PropTypes.oneOf(['default', 'branded']),
className: PropTypes.string,
};

View file

@ -0,0 +1,40 @@
# CTAs (Call To Actions)
Call To Action is an extension for `<Link block />` component therefore it inherits Link's props and configuration.
CTA is meant to be more prominent and visually heavy so use these cautiously.
## CTAs vs Links vs Buttons
The most important of these rules is that the `<CTA />` (or `<Link />`) and `<Button />` elements are assigned to appropriate respective tasks.
That is: `<CTA />` or `<Link />` should be used where navigation is elicited, and `<Button />` where a non-navigational action is being invoked.
For example, a 'Submit' control on 'Sign in' view (form) would suit a `<Button />` because its job is to submit the form.
However, 'Register' call-to-action—taking the user to a different form, in a different page or screen—would benefit from `<CTA />` or `<Link />` semantics.
Do not use `<Button />s` as navigational elements. Instead, use Links (or CTAs) when the desired action is to take the user to a new page.
## Usage
Under the hood, CTA outputs `<a>` element.
Call To Actions has slighlty different UI than regular `<Link block />` as our intention here was to make these more prominent.
`<CTA />` is basically a `<Link />` therefore standard link markup should be used, including an `href` (links omitting `href` attributes are not focusable by keyboard).
### Icons
Icon can be placed in front of a label to both clarify an action and call attention to a CTA.
By passing an object in `icon` prop, there will be automatically included `<Icon />` component.
By default, the icon itself won't be customizable. Icons within CTAs are hidden from assistive technology, so make sure your button has descriptive enough text, or is otherwise well-labelled (e.g. with `aria-label`).
#### Icon-only
There are certain instances where an icon will suffice in place of a text label, but use these cautiously.
Icon-only CTAs should always use clear and obvious icon, ideally accompanied by a title. Icon-only CTAs will require an `aria-label` to ensure they are understood by users of assistive technologies.
##### Fully rounded
In most cases, whenever we display icon-only CTAs we tend to make them fully rounded - use `rounded` prop for that.
Other than that please avoid customizing border-radius on CTAs.
## Design
<iframe style="border: 1px solid rgba(0, 0, 0, 0.1);" width="100%" height="450" src="https://www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com%2Ffile%2FgHpwugeu9jFGW4d9u3ReuR%2FCrayons%3Fnode-id%3D1%253A29" allowfullscreen></iframe>

View file

@ -0,0 +1,53 @@
import { h } from 'preact';
import { CTA } from '../';
import CTAsDoc from './CTAs.mdx';
import CogIcon from '@images/cog.svg';
export default {
component: CTA,
title: 'BETA/Navigation/CTAs',
parameters: {
docs: {
page: CTAsDoc,
},
},
argTypes: {
variant: {
control: {
type: 'select',
options: ['default', 'branded'],
},
description:
'There are two available variants (styles) to pick from: _default_ and _branded_. The primary difference is color: _default_ uses grey color and _branded_ uses accent color.',
table: {
defaultValue: { summary: 'default' },
},
},
rounded: {
description:
'By enabling this setting you can turn all corners of the CTA to be fully rounded. We usually use it **only** for CTAs containing **icon without label**.',
table: {
defaultValue: { summary: false },
},
},
},
};
export const Default = (args) => <CTA {...args} />;
Default.args = {
children: 'Call to action',
variant: 'default',
rounded: false,
};
export const Branded = (args) => <CTA {...args} />;
Branded.args = {
...Default.args,
variant: 'branded',
};
export const WithIcon = (args) => <CTA {...args} />;
WithIcon.args = {
...Default.args,
icon: CogIcon,
};

View file

@ -0,0 +1,30 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import { CTA } from '@crayons';
describe('<CTA />', () => {
it('has no accessibility errors in default variant', async () => {
const { container } = render(<CTA>Hello world!</CTA>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no accessibility errors in branded variant', async () => {
const { container } = render(<CTA variant="branded">Hello world!</CTA>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders default CTA', () => {
const { container } = render(<CTA>Hello world!</CTA>);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders branded CTA', () => {
const { container } = render(<CTA variant="branded">Hello world!</CTA>);
expect(container.innerHTML).toMatchSnapshot();
});
});

View file

@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CTA /> renders branded CTA 1`] = `"<a href=\\"#\\" class=\\"c-link c-link--block c-cta c-cta--branded\\">Hello world!</a>"`;
exports[`<CTA /> renders default CTA 1`] = `"<a href=\\"#\\" class=\\"c-link c-link--block c-cta c-cta--default\\">Hello world!</a>"`;

View file

@ -0,0 +1 @@
export * from './CTA';

View file

@ -0,0 +1,28 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import classNames from 'classnames/bind';
export const Icon = ({
src: InternalIcon,
native,
className,
...otherProps
}) => {
return (
<InternalIcon
className={classNames('crayons-icon', {
'crayons-icon--default': native,
[className]: className,
})}
{...otherProps}
/>
);
};
Icon.displayName = 'Icon';
Icon.propTypes = {
native: PropTypes.bool,
className: PropTypes.string,
src: PropTypes.node.isRequired,
};

View file

@ -0,0 +1,43 @@
# Icons
This component is supposed to make it easy to add icons to your JSX code.
## How?
You can pass an object as `src` prop:
```
import Twitter from '@images/twitter.svg';
<Icon src={Twitter} />
```
Yes, we have an alias `@images` that should make it easier to access `/app/assets/images` folder.
## Colors
If asset (SVG file) was exported correctly (see Rules section below) and `<Icon />` component was used correctly,
icon should inherit some Crayons styling that will, for example, automatically apply container's color to the icon itself.
For example, icon will automatically inherit link color whenever it's placed inside a link element.
### Native colors
By adding `native` prop we tell icon to NOT inherit its parent color.
Instead it will keep whatever colors were defined in the SVG code.
This is helpful for branded icons that come with their own specific color (logos like Twitter, Facebook, etc.) or for multicolor icons.
## Rules
Most of the icons you would ever need should already be inside `/app/assets/images` folder. If you can't find one, ask your designer.
If you want to export icon by yourself, go for it, just follow couple rules:
1. Icons must be 24x24px. **It should include appropriate padding.**
2. Icons must be in SVG format.
3. Icons must be placed in `/app/assets/images` folder.
4. We primarily use icons from [remixicon.com](http://remixicon.com/) (open source library) with bunch of exceptions and custom made ones.
5. Icons must be optimized in a tool like [SVGOMG](https://jakearchibald.github.io/svgomg/).
6. Icons should **not** have any `fill` attribute - this will let us style them pragmatically with Crayons magic (see "Colors" section above).
- Only exception is for multicolor and/or branded icons.
## Design
<iframe style="border: 1px solid rgba(0, 0, 0, 0.1);" width="100%" height="450" src="https://www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com%2Ffile%2FgHpwugeu9jFGW4d9u3ReuR%2FCrayons%3Fnode-id%3D1%253A163" allowfullscreen></iframe>

View file

@ -0,0 +1,33 @@
import { h } from 'preact';
import { Icon } from '..';
import IconsDoc from './Icons.mdx';
import CogIcon from '@images/twitter.svg';
export default {
component: Icon,
title: 'BETA/Icons',
parameters: {
docs: {
page: IconsDoc,
},
},
argTypes: {
native: {
description:
'Whether or not icon should maintain its original color. By default (`false`) icon will inherit color from parent container. Most of our icons actually come "unstyled" - which means they are meant to inherit color from parent. Using native colors should be an exception for e.g. branded icons that come with their own specific color (some logos like Twitter, Facebook, etc.) or multicolor icons.',
table: {
defaultValue: { summary: false },
},
},
},
};
export const Default = (args) => <Icon src={CogIcon} {...args} />;
Default.args = {
native: false,
};
export const NativeColors = (args) => <Icon src={CogIcon} {...args} />;
NativeColors.args = {
native: true,
};

View file

@ -0,0 +1 @@
export * from './Icon';

View file

@ -0,0 +1,53 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import classNames from 'classnames/bind';
import { defaultChildrenPropTypes } from '../../common-prop-types/default-children-prop-types';
import { Icon } from '@crayons';
export const Link = (props) => {
const {
children,
href = '#',
variant,
block,
icon,
rounded,
className,
...otherProps
} = props;
const classes = classNames('c-link', {
[`c-link--${variant}`]: variant,
'c-link--icon-left': icon && children,
'c-link--icon-alone': icon && !children,
'c-link--block': block,
'radius-full': rounded,
[className]: className,
});
return (
<a href={href} className={classes} {...otherProps}>
{icon && (
<Icon
src={icon}
aria-hidden="true"
focusable="false"
className={classNames('c-link__icon')}
/>
)}
{children}
</a>
);
};
Link.displayName = 'Link';
Link.propTypes = {
variant: PropTypes.oneOf(['default', 'branded']),
block: PropTypes.bool,
rounded: PropTypes.bool,
href: PropTypes.string.isRequired,
className: PropTypes.string,
children: defaultChildrenPropTypes,
icon: PropTypes.ReactNode,
};

View file

@ -0,0 +1,48 @@
# Links
Links are used as navigational elements. They may appear on their own, within a sentence or paragraph, or directly following the content.
## CTAs vs Links vs Buttons
The most important of these rules is that the `<CTA />` (or `<Link />`) and `<Button />` elements are assigned to appropriate respective tasks.
That is: `<CTA />` or `<Link />` should be used where navigation is elicited, and `<Button />` where a non-navigational action is being invoked.
For example, a 'Submit' control on 'Sign in' view (form) would suit a `<Button />` because its job is to submit the form.
However, 'Register' call-to-action—taking the user to a different form, in a different page or screen—would benefit from `<CTA />` or `<Link />` semantics.
Do not use `<Button />s` as navigational elements. Instead, use Links (or CTAs) when the desired action is to take the user to a new page.
## Usage
In Crayons you can either use inline link (which basically is plain text) or block link (which has a bit of padding and is slightly visually heavier).
Both variants are just fine and which one you gonna use is dependent on the context.
`<Link />` component is just an HTML's `<a>` element under the hood so it can use the same attributes, like `href` for setting path, `target` for defining whether link should open in new window, etc.
## Configuration
### Variants (styles)
There are two available variants (styles) to pick from: _default_ and _branded_. The primary difference is color: _default_ uses grey color and _branded_ uses accent color.
It is recommended to always use `branded` variant for inline links, epsecially when link is used alongside other text - making it `branded` will make it more distinguishable.
### Icons
Icons are only supported in *block* links (`block`).
Icon can be placed in front of a label to both clarify an action and call attention to a link.
By passing an object in `src` prop, there will be automatically included `<Icon />` component.
By default, the icon itself won't be customizable.
#### Icon-only
There are certain instances where an icon will suffice in place of a text label, but use these cautiously.
Icon-only links should always use clear and obvious icon, ideally accompanied by a title. Icon-only links will require an `aria-label` to ensure they are understood by users of assistive technologies.
##### Fully rounded
In most cases, whenever we display icon-only links we tend to make them fully rounded - use `rounded` prop for that.
Other than that please avoid customizing border-radius on links.
## Design
<iframe style="border: 1px solid rgba(0, 0, 0, 0.1);" width="100%" height="450" src="https://www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com%2Ffile%2FgHpwugeu9jFGW4d9u3ReuR%2FCrayons%3Fnode-id%3D1%253A29" allowfullscreen></iframe>

View file

@ -0,0 +1,71 @@
import { h } from 'preact';
import { Link } from '..';
import LinksDoc from './Links.mdx';
import CogIcon from '@images/cog.svg';
export default {
component: Link,
title: 'BETA/Navigation/Links',
parameters: {
docs: {
page: LinksDoc,
},
},
argTypes: {
variant: {
control: {
type: 'select',
options: ['default', 'branded'],
},
description:
'There are two available variants (styles) to pick from: _default_ and _branded_. The primary difference is color: _default_ uses grey color and _branded_ uses accent color.',
table: {
defaultValue: { summary: 'default' },
},
},
block: {
description:
'By adding this prop your link will turn from inline to block one (it will be visually heavier - having some extra padding and taking all available width).',
table: {
defaultValue: { summary: false },
},
},
rounded: {
description:
'By enabling this setting you can turn all corners of the link to be fully rounded. We usually use it **only** for block links containing **icon without label**.',
table: {
defaultValue: { summary: false },
},
},
icon: {
control: false,
description:
'Icons are only supported in *block* links (`block`). Even though the generated JSX code in this component will have an empty function as prop value (`<Link block icon={() => {}}>`), the actual value should be an icon object imported from `@images`. Read more about icons in their dedicated Storybook page.',
table: {
defaultValue: { summary: 'CogIcon' },
},
},
},
};
export const Inline = (args) => <Link {...args} />;
Inline.args = {
variant: 'branded',
block: false,
rounded: false,
children: 'Inline link',
};
export const Block = (args) => <Link {...args} />;
Block.args = {
...Inline.args,
block: true,
variant: 'default',
children: 'Block link',
};
export const WithIcon = (args) => <Link block {...args} />;
WithIcon.args = {
...Block.args,
icon: CogIcon,
};

View file

@ -0,0 +1,69 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import CogIcon from '@images/cog.svg';
import { Link } from '@crayons';
describe('<Link />', () => {
it('has no accessibility violations', async () => {
const { container } = render(<Link>Hello world!</Link>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders default link', () => {
const { container } = render(<Link href="/url">Hello world!</Link>);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders branded link', () => {
const { container } = render(
<Link variant="branded" href="/url">
Hello world!
</Link>,
);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders block link', () => {
const { container } = render(
<Link block href="/url">
Hello world!
</Link>,
);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders rounded link', () => {
const { container } = render(
<Link rounded href="/url">
Hello world!
</Link>,
);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders a link with icon alone', () => {
const { container } = render(<Link icon={CogIcon} href="/url" />);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders with icon and text', () => {
const { container } = render(
<Link icon={CogIcon} href="/url">
Hello world!
</Link>,
);
expect(container.innerHTML).toMatchSnapshot();
});
it('renders with additional classnames', () => {
const { container } = render(
<Link className="one two three" href="/url">
Hello world!
</Link>,
);
expect(container.innerHTML).toMatchSnapshot();
});
});

View file

@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Link /> renders a link with icon alone 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--icon-alone\\"><svg class=\\"crayons-icon c-link__icon\\" aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg></a>"`;
exports[`<Link /> renders block link 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--block\\">Hello world!</a>"`;
exports[`<Link /> renders branded link 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--branded\\">Hello world!</a>"`;
exports[`<Link /> renders default link 1`] = `"<a href=\\"/url\\" class=\\"c-link\\">Hello world!</a>"`;
exports[`<Link /> renders rounded link 1`] = `"<a href=\\"/url\\" class=\\"c-link radius-full\\">Hello world!</a>"`;
exports[`<Link /> renders with additional classnames 1`] = `"<a href=\\"/url\\" class=\\"c-link one two three\\">Hello world!</a>"`;
exports[`<Link /> renders with icon and text 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--icon-left\\"><svg class=\\"crayons-icon c-link__icon\\" aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg>Hello world!</a>"`;

View file

@ -0,0 +1 @@
export * from './Link';

View file

@ -1,7 +1,11 @@
export * from '@crayons/Button';
export * from '@crayons/Buttons';
export * from '@crayons/Links';
export * from '@crayons/CTAs';
export * from '@crayons/ButtonGroup';
export * from '@crayons/Dropdown';
export * from '@crayons/formElements';
export * from '@crayons/Icons';
export * from '@crayons/Modal';
export * from '@crayons/Spinner';
export * from '@crayons/MobileDrawer';

View file

@ -1,5 +1,4 @@
import { h, render } from 'preact';
import 'focus-visible';
import { SearchFormSync } from '../Search/SearchFormSync';
document.addEventListener('DOMContentLoaded', () => {

View file

@ -1,4 +1,5 @@
/* global Runtime */
import 'focus-visible';
import {
initializeMobileMenu,
setCurrentPageIconLink,

View file

@ -34,6 +34,7 @@ module.exports = {
moduleNameMapper: {
'\\.(svg|png|css)$': '<rootDir>/empty-module.js',
'^@crayons(.*)$': '<rootDir>/app/javascript/crayons$1',
'^@images(.*)$': '<rootDir>/app/assets/images$1',
'^@utilities(.*)$': '<rootDir>/app/javascript/utilities$1',
'^@components(.*)$': '<rootDir>/app/javascript/shared/components$1',
'^react$': 'preact/compat',

View file

@ -110,6 +110,7 @@
"@rails/ujs": "6.1.4",
"@rails/webpacker": "5.4.3",
"@reach/combobox": "^0.16.4",
"@storybook/addon-controls": "^6.3.12",
"ahoy.js": "^0.4.0",
"autoprefixer": "^10.4.0",
"babel-plugin-inline-react-svg": "^2.0.1",
@ -117,6 +118,7 @@
"babel-preset-preact": "^2.0.0",
"canvas": "^2.8.0",
"chart.js": "^3.6.0",
"classnames": "^2.3.1",
"clipboard-polyfill": "^3.0.3",
"core-js": "3",
"file-loader": "^6.2.0",

View file

@ -2015,6 +2015,20 @@
util-deprecate "^1.0.2"
uuid-browser "^3.1.0"
"@storybook/addon-controls@^6.3.12":
version "6.3.12"
resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-6.3.12.tgz#dbb732c62cf06fb7ccaf87d6ab11c876d14456fc"
integrity sha512-WO/PbygE4sDg3BbstJ49q0uM3Xu5Nw4lnHR5N4hXSvRAulZt1d1nhphRTHjfX+CW+uBcfzkq9bksm6nKuwmOyw==
dependencies:
"@storybook/addons" "6.3.12"
"@storybook/api" "6.3.12"
"@storybook/client-api" "6.3.12"
"@storybook/components" "6.3.12"
"@storybook/node-logger" "6.3.12"
"@storybook/theming" "6.3.12"
core-js "^3.8.2"
ts-dedent "^2.0.0"
"@storybook/addon-docs@^6.3.12":
version "6.3.12"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-6.3.12.tgz#2ec73b4f231d9f190d5c89295bc47bea6a95c6d1"
@ -5370,6 +5384,11 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
classnames@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
clean-css@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"