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:
parent
ad8506ee8c
commit
245e70d4a8
39 changed files with 1221 additions and 11 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
app/assets/stylesheets/components/ctas.scss
Normal file
40
app/assets/stylesheets/components/ctas.scss
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
93
app/assets/stylesheets/components/links.scss
Normal file
93
app/assets/stylesheets/components/links.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. /////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ Foo.parameters = {
|
|||
};
|
||||
|
||||
export const parameters = {
|
||||
controls: { expanded: true },
|
||||
options: {
|
||||
storySort: {
|
||||
order: [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
84
app/javascript/crayons/Buttons/Button.jsx
Normal file
84
app/javascript/crayons/Buttons/Button.jsx
Normal 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,
|
||||
};
|
||||
63
app/javascript/crayons/Buttons/__stories__/Buttons.mdx
Normal file
63
app/javascript/crayons/Buttons/__stories__/Buttons.mdx
Normal 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 user’s 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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
110
app/javascript/crayons/Buttons/__tests__/Buttons.test.jsx
Normal file
110
app/javascript/crayons/Buttons/__tests__/Buttons.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>"`;
|
||||
1
app/javascript/crayons/Buttons/index.js
Normal file
1
app/javascript/crayons/Buttons/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Button';
|
||||
22
app/javascript/crayons/CTAs/CTA.jsx
Normal file
22
app/javascript/crayons/CTAs/CTA.jsx
Normal 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,
|
||||
};
|
||||
40
app/javascript/crayons/CTAs/__stories__/CTAs.mdx
Normal file
40
app/javascript/crayons/CTAs/__stories__/CTAs.mdx
Normal 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>
|
||||
53
app/javascript/crayons/CTAs/__stories__/CTAs.stories.jsx
Normal file
53
app/javascript/crayons/CTAs/__stories__/CTAs.stories.jsx
Normal 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,
|
||||
};
|
||||
30
app/javascript/crayons/CTAs/__tests__/CTA.test.jsx
Normal file
30
app/javascript/crayons/CTAs/__tests__/CTA.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>"`;
|
||||
1
app/javascript/crayons/CTAs/index.js
Normal file
1
app/javascript/crayons/CTAs/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './CTA';
|
||||
28
app/javascript/crayons/Icons/Icon.jsx
Normal file
28
app/javascript/crayons/Icons/Icon.jsx
Normal 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,
|
||||
};
|
||||
43
app/javascript/crayons/Icons/__stories__/Icons.mdx
Normal file
43
app/javascript/crayons/Icons/__stories__/Icons.mdx
Normal 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>
|
||||
33
app/javascript/crayons/Icons/__stories__/Icons.stories.jsx
Normal file
33
app/javascript/crayons/Icons/__stories__/Icons.stories.jsx
Normal 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,
|
||||
};
|
||||
1
app/javascript/crayons/Icons/index.js
Normal file
1
app/javascript/crayons/Icons/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Icon';
|
||||
53
app/javascript/crayons/Links/Link.jsx
Normal file
53
app/javascript/crayons/Links/Link.jsx
Normal 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,
|
||||
};
|
||||
48
app/javascript/crayons/Links/__stories__/Links.mdx
Normal file
48
app/javascript/crayons/Links/__stories__/Links.mdx
Normal 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>
|
||||
71
app/javascript/crayons/Links/__stories__/Links.stories.jsx
Normal file
71
app/javascript/crayons/Links/__stories__/Links.stories.jsx
Normal 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,
|
||||
};
|
||||
69
app/javascript/crayons/Links/__tests__/Link.test.jsx
Normal file
69
app/javascript/crayons/Links/__tests__/Link.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>"`;
|
||||
1
app/javascript/crayons/Links/index.js
Normal file
1
app/javascript/crayons/Links/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Link';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { h, render } from 'preact';
|
||||
import 'focus-visible';
|
||||
import { SearchFormSync } from '../Search/SearchFormSync';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/* global Runtime */
|
||||
import 'focus-visible';
|
||||
import {
|
||||
initializeMobileMenu,
|
||||
setCurrentPageIconLink,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
19
yarn.lock
19
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue