@devseed-ui/button
Advanced tools
Comparing version 3.1.0 to 4.0.0
# @devseed-ui/button | ||
## 4.0.0 | ||
`@devseed-ui/*` Now uses a fixed versioning system. | ||
The global changelog is at the root of the repo. | ||
---- | ||
## 3.1.0 | ||
@@ -4,0 +11,0 @@ |
{ | ||
"name": "@devseed-ui/button", | ||
"version": "3.1.0", | ||
"version": "4.0.0", | ||
"description": "devseed UI Kit Button", | ||
"browser": "./dist/index.web.js", | ||
"main": "./dist/index.node.js", | ||
"main": "dist/index.cjs.js", | ||
"module": "dist/index.es.js", | ||
"types": "./src/index.d.ts", | ||
"sideEffects": false, | ||
"scripts": { | ||
"build": "yarn webpack --config ../../webpack.config.js", | ||
"watch": "yarn webpack --watch --config ../../webpack.config.js" | ||
"build": "yarn rollup -c ../../rollup.config.js", | ||
"watch": "yarn rollup -c ../../rollup.config.js -w" | ||
}, | ||
@@ -28,5 +30,6 @@ "license": "MIT", | ||
"dependencies": { | ||
"@devseed-ui/theme-provider": "^3.0.0", | ||
"@devseed-ui/collecticons": "3.2.0" | ||
} | ||
"@devseed-ui/collecticons": "^4.0.0", | ||
"@devseed-ui/theme-provider": "^4.0.0" | ||
}, | ||
"gitHead": "6d2abc5d6d83c14f401f0c29fc01d9a6888bdf8f" | ||
} |
158
README.md
# @devseed-ui/button | ||
```hint|neutral | ||
This component **may** require [collecticons](/collecticons) to be included if you're using the `useIcon` prop. | ||
You'll see strange characters (example �) in place of icons if collecticons is missing. | ||
``` | ||
#### Variation | ||
Buttons come in different variations: | ||
- primary-raised-light | ||
- primary-raised-semidark | ||
- primary-raised-dark | ||
- primary-plain | ||
- danger-raised-light | ||
- danger-raised-dark | ||
- danger-plain | ||
- success-raised-light | ||
- success-raised-dark | ||
- success-plain | ||
- achromic-plain | ||
- achromic-glass | ||
- base-raised-light | ||
- base-raised-semidark | ||
- base-raised-dark | ||
- base-plain | ||
```react | ||
<DevseedUiThemeProvider> | ||
<style>{` | ||
.line { | ||
margin-bottom: 1rem; | ||
} | ||
.line > * { | ||
margin-right: 0.5rem; | ||
} | ||
.achromic { | ||
background: #443F3F; | ||
padding: 0.5rem; | ||
} | ||
`}</style> | ||
<p className="line"> | ||
<Button variation="base-plain">base-plain</Button> | ||
<Button variation="primary-plain">primary-plain</Button> | ||
<Button variation="success-plain">success-plain</Button> | ||
</p> | ||
<p className="line"> | ||
<Button variation="primary-raised-light">primary-raised-light</Button> | ||
<Button variation="primary-raised-semidark">primary-raised-semidark</Button> | ||
<Button variation="primary-raised-dark">primary-raised-dark</Button> | ||
</p> | ||
<p className="line"> | ||
<Button variation="danger-raised-light">danger-raised-light</Button> | ||
<Button variation="danger-raised-dark">danger-raised-dark</Button> | ||
</p> | ||
<p className="line"> | ||
<Button variation="success-raised-light">success-raised-light</Button> | ||
<Button variation="success-raised-dark">success-raised-dark</Button> | ||
</p> | ||
<p className="line achromic"> | ||
<Button variation="achromic-plain">achromic-plain</Button> | ||
<Button variation="achromic-glass">achromic-glass</Button> | ||
</p> | ||
<p className="line"> | ||
<Button variation="base-raised-light">base-raised-light</Button> | ||
<Button variation="base-raised-semidark">base-raised-semidark</Button> | ||
<Button variation="base-raised-dark">base-raised-dark</Button> | ||
</p> | ||
</DevseedUiThemeProvider> | ||
``` | ||
#### Size | ||
Button supports three sizes – large for emphasized actions, medium as default, and small as alternative to medium. | ||
```react | ||
<DevseedUiThemeProvider> | ||
<Button | ||
variation="base-raised-light" | ||
size="small" | ||
className="button-class" | ||
title="sample button" | ||
onClick={() => {}} | ||
> | ||
Click Me | ||
</Button> | ||
<Button | ||
variation="base-raised-light" | ||
size="medium" | ||
className="button-class" | ||
title="sample button" | ||
onClick={() => {}} | ||
> | ||
Click Me | ||
</Button> | ||
<Button | ||
variation="base-raised-light" | ||
size="large" | ||
className="button-class" | ||
title="sample button" | ||
onClick={() => {}} | ||
> | ||
Click Me | ||
</Button> | ||
</DevseedUiThemeProvider> | ||
``` | ||
## API Documentation | ||
```table | ||
rows: | ||
- Prop name: "variation" | ||
Type: "One of: primary-raised-light | primary-raised-semidark | primary-raised-dark | primary-plain | danger-raised-light | danger-raised-dark | danger-plain | success-raised-light | success-raised-dark | success-plain | achromic-plain | achromic-glass | base-raised-light | base-raised-semidark | base-raised-dark | base-plain" | ||
Description: "Sets the style variant of the button" | ||
Default value: "base-plain" | ||
- Prop name: "size" | ||
Type: "oneOf ['small', 'medium', 'large', 'xlarge']" | ||
Description: "Sets the size of the button" | ||
Default value: "medium" | ||
- Prop name: "radius" | ||
Type: "oneOf ['ellipsoid','square', 'rounded']" | ||
Description: "The value for the radius" | ||
Default value: "rounded" | ||
- Prop name: "box" | ||
Type: "oneOf ['block','semi-fluid', 'null']" | ||
Description: "The value for the box." | ||
Default value: "null" | ||
- Prop name: "active" | ||
Type: "bool" | ||
Description: "Whether the button is in an active state." | ||
Default value: "false" | ||
- Prop name: "hideText" | ||
Type: "bool" | ||
Description: "Whether the button text should be hidden" | ||
Default value: "false" | ||
- Prop name: "disabled" | ||
Type: "bool" | ||
Description: "Whether the button should be disabled." | ||
Default value: "false" | ||
- Prop name: "visuallyDisabled" | ||
Type: "bool" | ||
Description: "Whether the button should be visually disabled. A visually disabled button looks disabled but retains the mouse events. This is useful to trigger tooltips on hover." | ||
Default value: "false" | ||
- Prop name: "useIcon" | ||
Type: "oneOf [array, string]" | ||
Description: "The value for the icon. Has to be the name of a collecticon. If an array is used instead of a string, the first position is the name of the icon, and the second the position ('before' | 'after')." | ||
Default value: "null" | ||
- Prop name: "onClick" | ||
Type: "func" | ||
Description: "Click event handler" | ||
Default value: "f => f" | ||
``` | ||
Buttons and button groups. |
@@ -1,33 +0,111 @@ | ||
import React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import styled from 'styled-components'; | ||
import styled, { css } from 'styled-components'; | ||
import { rgba } from 'polished'; | ||
import { themeVal, visuallyHidden } from '@devseed-ui/theme-provider'; | ||
import { themeVal, glsp, disabled } from '@devseed-ui/theme-provider'; | ||
import React from 'react'; | ||
import { useButtonGroup } from './context'; | ||
import { | ||
renderIcon, | ||
renderDisabledState, | ||
renderButtonVariation, | ||
renderButtonRadius, | ||
renderButtonSize, | ||
renderButtonBox, | ||
renderHideText, | ||
} from './styled'; | ||
export const createButtonStyles = (props) => { | ||
return css` | ||
position: relative; | ||
display: inline-flex; | ||
align-items: center; | ||
justify-content: center; | ||
gap: ${glsp(0.25)}; | ||
user-select: none; | ||
line-height: inherit; | ||
border: ${themeVal('button.shape.border')} solid transparent; | ||
background: transparent; | ||
font-family: ${themeVal('button.type.family')}; | ||
font-weight: ${themeVal('button.type.weight')}; | ||
font-style: ${themeVal('button.type.style')}; | ||
font-variation-settings: ${themeVal('button.type.settings')}; | ||
text-transform: ${themeVal('button.type.case')}; | ||
outline: 0 solid transparent; | ||
text-decoration: none; | ||
transition: background-color 0.24s ease-in-out 0s, | ||
outline-width 0.16s ease-in-out 0s; | ||
// This empty element is needed so we can use the styled component property | ||
// "forwardedAs" and replace it while maintaining the component structure. | ||
// Because of this, buttons SHOULD NOT be used with "as". | ||
const El = styled.button``; | ||
&:focus-visible { | ||
outline-width: 0.25rem; | ||
} | ||
/* eslint-disable-next-line react/display-name */ | ||
const BaseButton = React.forwardRef(({ children, ...rest }, ref) => { | ||
const elType = rest.type || (!rest.as ? 'button' : undefined); | ||
return ( | ||
<El ref={ref} {...rest} type={elType}> | ||
{!!children && <span>{children}</span>} | ||
</El> | ||
); | ||
}); | ||
&:focus:not(:focus-visible) { | ||
outline: 0; | ||
} | ||
BaseButton.propTypes = { | ||
/* Disabled state based on prop. */ | ||
${renderDisabledState(props)} | ||
&[disabled] { | ||
${disabled()} | ||
} | ||
/* Size. */ | ||
${renderButtonSize(props)} | ||
/* Variation. */ | ||
${renderButtonVariation(props)} | ||
/* Content Fit */ | ||
${renderButtonFitting(props)} | ||
/* Radius */ | ||
&, | ||
&::after { | ||
${renderButtonRadius(props)} | ||
} | ||
&::after { | ||
position: absolute; | ||
z-index: 10; | ||
top: -${themeVal('button.shape.border')}; | ||
right: -${themeVal('button.shape.border')}; | ||
bottom: -${themeVal('button.shape.border')}; | ||
left: -${themeVal('button.shape.border')}; | ||
content: ''; | ||
background: transparent; | ||
pointer-events: none; | ||
} | ||
`; | ||
}; | ||
// Styled component of a button | ||
const StyledButton = styled.button.attrs((props) => { | ||
return { | ||
'aria-pressed': String(!!props.active), | ||
type: props.type || (!props.as ? 'button' : undefined) | ||
}; | ||
})` | ||
${createButtonStyles} | ||
`; | ||
/* eslint-disable react/display-name, react/prop-types */ | ||
// Wrap the styled button in a high order component to pass the context | ||
// provider. This component needs to also become a styled-component to take | ||
// advantage of the styled component functionalities. | ||
// To be sure that this component maintains its structure you MUST use the | ||
// styled-component's `forwardedAs` property when replacing what's being | ||
// rendered, instead of using `as`. | ||
export const Button = styled( | ||
React.forwardRef((props, ref) => { | ||
const { size, variation, radius } = useButtonGroup(); | ||
return ( | ||
<StyledButton | ||
size={size} | ||
variation={variation} | ||
radius={radius} | ||
{...props} | ||
ref={ref} | ||
/> | ||
); | ||
}) | ||
)` | ||
/* styled-component */ | ||
`; | ||
/* eslint-enable */ | ||
Button.propTypes = { | ||
children: PropTypes.node, | ||
@@ -37,105 +115,255 @@ variation: PropTypes.string, | ||
radius: PropTypes.string, | ||
box: PropTypes.string, | ||
fitting: PropTypes.string, | ||
active: PropTypes.bool, | ||
hideText: PropTypes.bool, | ||
disabled: PropTypes.bool, | ||
visuallyDisabled: PropTypes.bool, | ||
useIcon: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), | ||
disabled: PropTypes.bool | ||
}; | ||
/** | ||
* Renders a Button element with a span inside it. | ||
* Renders the button disabled stated. | ||
* | ||
* @param {string} variation Select the button variation to render. One of: | ||
* "primary-raised-light" | "primary-raised-semidark" | | ||
* "primary-raised-dark" | "primary-plain" | "danger-raised-light" | | ||
* "danger-raised-dark" | "danger-plain" | "success-raised-light" | | ||
* "success-raised-dark" | "success-plain" | "achromic-plain" | | ||
* "achromic-glass" | "base-raised-light" | "base-raised-semidark" | | ||
* "base-raised-dark" | "base-plain" | ||
* @param {string} size The value for the box. One of "small" | "medium" | | ||
* "large" | "xlarge" | ||
* @param {string} radius The value for the radius. One of "ellipsoid" | | ||
* "square" | "rounded" | ||
* @param {string} box The value for the box. One of "block" | "semi-fluid" | | ||
* "null" | ||
* @param {boolean} active Whether the button is in an active state. | ||
* @param {boolean} hideText Whether the button text should be hidden. | ||
* @param {boolean} disabled Whether the button should be disabled. | ||
* @param {boolean} visuallyDisabled Whether the button should be visually | ||
* disabled. A visually disabled button looks disabled but retains the | ||
* mouse events. This is useful to trigger tooltips on hover. | ||
* @param {string|array} useIcon The value for the icon. Has to be the name of a | ||
* collecticon. If an array is used instead of a string, the first | ||
* position is the name of the icon, and the second the position | ||
* ("before" | "after"). | ||
* @param {object} props The element props | ||
* @param {bool} props.disabled Whether the button should be disabled. | ||
*/ | ||
const Button = styled(BaseButton)` | ||
cursor: pointer; | ||
user-select: none; | ||
display: inline-block; | ||
text-align: center; | ||
white-space: nowrap; | ||
vertical-align: middle; | ||
line-height: 1.5rem; | ||
font-size: 1rem; | ||
padding: 0.25rem 0.75rem; | ||
min-width: 2rem; | ||
background: none; | ||
text-shadow: none; | ||
border: 0; | ||
font-family: ${themeVal('type.button.family')}; | ||
font-weight: ${themeVal('type.button.weight')}; | ||
font-style: ${themeVal('type.button.style')}; | ||
font-variation-settings: ${themeVal('type.button.settings')}; | ||
text-transform: ${themeVal('type.button.case')}; | ||
function renderDisabledState(props) { | ||
return props.disabled | ||
? disabled() | ||
: css` | ||
cursor: pointer; | ||
`; | ||
} | ||
/* States */ | ||
/** | ||
* Renders the button size based on the props. | ||
* | ||
* @param {object} props The element props | ||
* @param {string} props.size The value for the box. One of | ||
* "small" | "medium" | "large" | "xlarge" | ||
*/ | ||
function renderButtonSize(props) { | ||
const { size = 'medium' } = props; | ||
&, | ||
&:focus { | ||
/* This causes usability problems. Needs fixing. */ | ||
outline: none; | ||
switch (size) { | ||
case 'small': | ||
return css` | ||
min-width: 1.5rem; | ||
height: 1.5rem; | ||
font-size: 0.875rem; | ||
padding: 0 0.5rem; | ||
`; | ||
case 'medium': | ||
return css` | ||
min-width: 2rem; | ||
height: 2rem; | ||
font-size: 1rem; | ||
padding: 0 0.75rem; | ||
`; | ||
case 'large': | ||
return css` | ||
min-width: 2.5rem; | ||
height: 2.5rem; | ||
font-size: 1rem; | ||
padding: 0 1rem; | ||
`; | ||
case 'xlarge': | ||
return css` | ||
min-width: 3rem; | ||
height: 3rem; | ||
font-size: 1rem; | ||
padding: 0 1.25rem; | ||
`; | ||
default: | ||
throw new Error( | ||
`Invalid button size (${size}). Must be one of [small, medium, large, xlarge].` | ||
); | ||
} | ||
} | ||
&:hover { | ||
opacity: 1; | ||
function renderButtonVariation(props) { | ||
const [color, type] = props.variation?.split('-') || ['base', 'text']; | ||
// Function to get the color from the theme. | ||
const tColor = (c) => themeVal(['color', c]); | ||
if (!['text', 'fill', 'outline'].includes(type)) { | ||
throw new Error( | ||
`Invalid button type (${type}). Must be one of [text, fill, outline].` | ||
); | ||
} | ||
/* Icon handling */ | ||
${props => renderIcon(props)} | ||
if (color === 'achromic') { | ||
if (type === 'fill') { | ||
throw new Error(`achromic-fill buttons do not exist.`); | ||
} | ||
/* Checkbox/radio handling */ | ||
> input[type=checkbox], | ||
> input[type=radio] { | ||
${visuallyHidden()} | ||
switch (type) { | ||
case 'text': | ||
return css` | ||
color: #fff; | ||
&:hover { | ||
background-color: ${rgba('#fff', 0.04)}; | ||
} | ||
/* Print & when prop is passed */ | ||
${({ active }) => active && '&,'} | ||
&:active, | ||
&.active { | ||
background-color: ${rgba('#fff', 0.08)}; | ||
} | ||
&:focus-visible { | ||
outline-color: ${rgba('#fff', 0.16)}; | ||
} | ||
`; | ||
case 'outline': | ||
return css` | ||
border-color: #fff; | ||
color: #fff; | ||
&:hover { | ||
background-color: ${rgba('#fff', 0.04)}; | ||
} | ||
/* Print & when prop is passed */ | ||
${({ active }) => active && '&,'} | ||
&:active, | ||
&.active { | ||
background-color: ${rgba('#fff', 0.04)}; | ||
} | ||
&:focus-visible { | ||
outline-color: ${rgba('#fff', 0.16)}; | ||
} | ||
`; | ||
} | ||
} | ||
/* Animation */ | ||
transition: background-color 0.24s ease 0s; | ||
switch (type) { | ||
case 'text': | ||
return css` | ||
color: ${tColor(color)}; | ||
/* Variations */ | ||
${props => renderButtonVariation(props)} | ||
&:hover { | ||
background-color: ${tColor(`${color}-50a`)}; | ||
} | ||
/* Size */ | ||
${props => renderButtonSize(props)} | ||
/* Print & when prop is passed */ | ||
${({ active }) => active && '&,'} | ||
&:active, | ||
&.active { | ||
background-color: ${tColor(`${color}-100a`)}; | ||
} | ||
/* Radius */ | ||
${props => renderButtonRadius(props)} | ||
&:focus-visible { | ||
outline-color: ${tColor(`${color}-200a`)}; | ||
} | ||
`; | ||
case 'outline': | ||
return css` | ||
border-color: ${tColor(color)}; | ||
color: ${tColor(color)}; | ||
/* Box */ | ||
${props => renderButtonBox(props)} | ||
&:hover { | ||
background-color: ${tColor(`${color}-50a`)}; | ||
} | ||
/* Hide Text */ | ||
${props => renderHideText(props)} | ||
/* Print & when prop is passed */ | ||
${({ active }) => active && '&,'} | ||
&:active, | ||
&.active { | ||
background-color: ${tColor(`${color}-100a`)}; | ||
} | ||
/* Disabled state */ | ||
${props => renderDisabledState(props)} | ||
`; | ||
&:focus-visible { | ||
outline-color: ${tColor(`${color}-200a`)}; | ||
} | ||
`; | ||
case 'fill': | ||
return css` | ||
border-color: ${themeVal('color.base-200a')}; | ||
background-color: ${tColor(color)}; | ||
color: #fff; | ||
Button.defaultProps = { | ||
variation: 'base-plain', | ||
size: 'medium', | ||
}; | ||
&:hover { | ||
background-color: ${tColor(`${color}-600`)}; | ||
} | ||
export default Button; | ||
/* Print & when prop is passed */ | ||
${({ active }) => active && '&,'} | ||
&:active, | ||
&.active { | ||
background-color: ${tColor(`${color}-700`)}; | ||
} | ||
&:focus-visible { | ||
outline-color: ${tColor(`${color}-200a`)}; | ||
} | ||
&::after { | ||
box-shadow: ${themeVal('boxShadow.elevationB')}; | ||
} | ||
`; | ||
} | ||
} | ||
/** | ||
* Renders the border radius based on the props. | ||
* | ||
* @param {object} props The element props | ||
* @param {string} props.radius The value for the radius. One of | ||
* "ellipsoid" | "square" | "rounded" | ||
*/ | ||
function renderButtonRadius(props) { | ||
const { radius = 'rounded' } = props; | ||
switch (radius) { | ||
case 'rounded': | ||
return css` | ||
border-radius: ${themeVal('shape.rounded')}; | ||
`; | ||
case 'ellipsoid': | ||
return css` | ||
border-radius: ${themeVal('shape.ellipsoid')}; | ||
`; | ||
case 'square': | ||
return css` | ||
border-radius: 0; | ||
`; | ||
default: | ||
throw new Error( | ||
`Invalid button radius (${radius}). Must be one of [rounded, ellipsoid, square].` | ||
); | ||
} | ||
} | ||
/** | ||
* Renders the content fit based on the props. | ||
* | ||
* @param {object} props The element props | ||
* @param {string} props.fitting The value for the content fit. One of | ||
* "skinny" | "regular" | "relaxed" | "baggy" | ||
*/ | ||
function renderButtonFitting(props) { | ||
const { fitting = 'regular' } = props; | ||
switch (fitting) { | ||
case 'skinny': | ||
return css` | ||
padding-left: 0; | ||
padding-right: 0; | ||
`; | ||
case 'regular': | ||
return ''; | ||
case 'relaxed': | ||
return css` | ||
min-width: 12rem; | ||
`; | ||
case 'baggy': | ||
return css` | ||
width: 100%; | ||
`; | ||
default: | ||
throw new Error( | ||
`Invalid button fitting (${fitting}). Must be one of [skinny, regular, relaxed, baggy].` | ||
); | ||
} | ||
} |
@@ -11,3 +11,3 @@ import React from 'react'; | ||
describe('packages/Button', () => { | ||
describe('<Button />', () => { | ||
const onClick = jest.fn(); | ||
@@ -21,3 +21,3 @@ const className = 'test-button-class'; | ||
{child} | ||
</Button>, | ||
</Button> | ||
); | ||
@@ -27,25 +27,25 @@ | ||
test(`renders "${className}" in the button's classList`, () => { | ||
it(`renders "${className}" in the button's classList`, () => { | ||
expect(renderedButton.classList.contains(className)).toBe(true); | ||
}); | ||
test(`renders "${child}" as the button's textContent`, () => { | ||
it(`renders "${child}" as the button's textContent`, () => { | ||
expect(renderedButton.textContent).toBe(child); | ||
}); | ||
test(`renders "${title}" as the button title`, () => { | ||
it(`renders "${title}" as the button title`, () => { | ||
expect(renderedButton.title).toBe(title); | ||
}); | ||
test(`renders inside of a button tag by default`, () => { | ||
it(`renders inside of a button tag by default`, () => { | ||
expect(renderedButton.tagName.toLowerCase()).toBe('button'); | ||
}); | ||
test(`renders a button with the "button" type by default`, () => { | ||
it(`renders a button with the "button" type by default`, () => { | ||
expect(renderedButton.type).toBe('button'); | ||
}); | ||
test(`renders a button with the given type when one is set`, () => { | ||
it(`renders a button with the given type when one is set`, () => { | ||
const submitButton = renderWithTheme( | ||
<Button type="submit">My submit button</Button>, | ||
<Button type='submit'>My submit button</Button> | ||
).container.firstChild; | ||
@@ -55,5 +55,5 @@ expect(submitButton.type).toBe('submit'); | ||
test(`renders component inside of a React Element/HTML tag based on as prop`, () => { | ||
it(`renders component inside of a React Element/HTML tag based on forwardedAs prop`, () => { | ||
const { container } = renderWithTheme( | ||
<Button forwardedAs="div">Click me!</Button>, | ||
<Button forwardedAs='div'>Click me!</Button> | ||
); | ||
@@ -64,7 +64,7 @@ const buttonComponent = container.firstChild; | ||
test(`renders component inside of a React Element/HTML tag based on as prop, even when "href" is set`, () => { | ||
it(`renders component inside of a React Element/HTML tag based on forwardedAs prop, even when "href" is set`, () => { | ||
const { container } = renderWithTheme( | ||
<Button forwardedAs="div" href="http://developmentseed.org"> | ||
<Button forwardedAs='div' href='http://developmentseed.org'> | ||
Click me! | ||
</Button>, | ||
</Button> | ||
); | ||
@@ -76,2 +76,12 @@ const buttonComponent = container.firstChild; | ||
it(`renders a active button`, () => { | ||
const rendered = renderWithTheme(<Button active>Btn</Button>); | ||
expect(rendered.asFragment()).toMatchSnapshot(); | ||
}); | ||
it(`renders a disabled button`, () => { | ||
const rendered = renderWithTheme(<Button disabled>Btn</Button>); | ||
expect(rendered.asFragment()).toMatchSnapshot(); | ||
}); | ||
it('renders SSR compatible <Button />', () => { | ||
@@ -82,6 +92,53 @@ const renderOnServer = () => | ||
<Button>Some button</Button> | ||
</DevseedUiThemeProvider>, | ||
</DevseedUiThemeProvider> | ||
); | ||
expect(renderOnServer).not.toThrow(); | ||
}); | ||
const btnVariations = [ | ||
'base-fill', | ||
'base-text', | ||
'base-outline', | ||
'primary-fill', | ||
'primary-text', | ||
'primary-outline', | ||
'achromic-text', | ||
'achromic-outline' | ||
]; | ||
describe('button variations', () => { | ||
it.each(btnVariations)(`should render: %s`, (variation) => { | ||
const rendered = renderWithTheme( | ||
<Button variation={variation}>Btn</Button> | ||
); | ||
expect(rendered.asFragment()).toMatchSnapshot(); | ||
}); | ||
}); | ||
const btnSize = ['small', 'medium', 'large', 'xlarge']; | ||
describe('button size', () => { | ||
it.each(btnSize)(`should render: %s`, (size) => { | ||
const rendered = renderWithTheme(<Button size={size}>Btn</Button>); | ||
expect(rendered.asFragment()).toMatchSnapshot(); | ||
}); | ||
}); | ||
const btnRadius = ['rounded', 'ellipsoid', 'square']; | ||
describe('button radius', () => { | ||
it.each(btnRadius)(`should render: %s`, (radius) => { | ||
const rendered = renderWithTheme(<Button radius={radius}>Btn</Button>); | ||
expect(rendered.asFragment()).toMatchSnapshot(); | ||
}); | ||
}); | ||
const btnFitting = ['skinny', 'regular', 'relaxed', 'baggy']; | ||
describe('button fitting', () => { | ||
it.each(btnFitting)(`should render: %s`, (fitting) => { | ||
const rendered = renderWithTheme(<Button fitting={fitting}>Btn</Button>); | ||
expect(rendered.asFragment()).toMatchSnapshot(); | ||
}); | ||
}); | ||
}); |
@@ -1,8 +0,10 @@ | ||
import PropTypes from 'prop-types'; | ||
import React from 'react'; | ||
import T from 'prop-types'; | ||
import styled, { css } from 'styled-components'; | ||
import Button from './Button'; | ||
import { themeVal } from '@devseed-ui/theme-provider'; | ||
import { Button } from './Button'; | ||
import { ButtonGroupProvider } from './context'; | ||
/** | ||
@@ -15,70 +17,159 @@ * Renders the button group orientation based on the props. | ||
*/ | ||
function renderOrientation({ orientation }) { | ||
if (orientation === 'horizontal') { | ||
return css` | ||
flex-flow: row nowrap; | ||
> ${Button}:first-child:not(:last-child) { | ||
border-top-right-radius: 0; | ||
border-bottom-right-radius: 0; | ||
clip-path: inset(-100% 0 -100% -100%); | ||
} | ||
> ${Button}:last-child:not(:first-child) { | ||
border-top-left-radius: 0; | ||
border-bottom-left-radius: 0; | ||
clip-path: inset(-100% -100% -100% 0); | ||
} | ||
> ${Button}:not(:first-child):not(:last-child) { | ||
border-radius: 0; | ||
clip-path: inset(-100% 0); | ||
} | ||
> ${Button} + ${Button} { | ||
margin-left: -${themeVal('layout.border')}; | ||
} | ||
`; | ||
} | ||
function renderOrientation(props) { | ||
const { orientation = 'horizontal' } = props; | ||
if (orientation === 'vertical') { | ||
return css` | ||
flex-flow: column; | ||
> ${Button}:first-child:not(:last-child) { | ||
border-bottom-right-radius: 0; | ||
border-bottom-left-radius: 0; | ||
clip-path: inset(-100% -100% 0 -100%); | ||
} | ||
> ${Button}:last-child:not(:first-child) { | ||
border-top-left-radius: 0; | ||
border-top-right-radius: 0; | ||
clip-path: inset(0 -100% -100% -100%); | ||
} | ||
> ${Button}:not(:first-child):not(:last-child) { | ||
border-radius: 0; | ||
clip-path: inset(0 -100%); | ||
} | ||
> ${Button} + ${Button} { | ||
margin-top: -${themeVal('layout.border')}; | ||
} | ||
`; | ||
switch (orientation) { | ||
case 'horizontal': | ||
return css` | ||
flex-flow: row nowrap; | ||
> ${Button}:first-child:not(:last-child) { | ||
&, | ||
&::after { | ||
border-top-right-radius: 0; | ||
border-bottom-right-radius: 0; | ||
} | ||
&::after { | ||
clip-path: inset(-100% 0 -100% -100%); | ||
} | ||
} | ||
> ${Button}:last-child:not(:first-child) { | ||
&, | ||
&::after { | ||
border-top-left-radius: 0; | ||
border-bottom-left-radius: 0; | ||
} | ||
&::after { | ||
clip-path: inset(-100% -100% -100% 0); | ||
} | ||
} | ||
> ${Button}:not(:first-child):not(:last-child) { | ||
&, | ||
&::after { | ||
border-radius: 0; | ||
} | ||
&::after { | ||
clip-path: inset(-100% 0); | ||
} | ||
} | ||
> ${Button} + ${Button} { | ||
margin-left: -${themeVal('button.shape.border')}; | ||
} | ||
`; | ||
case 'vertical': | ||
return css` | ||
flex-flow: column; | ||
> ${Button}:first-child:not(:last-child) { | ||
&, | ||
&::after { | ||
border-bottom-right-radius: 0; | ||
border-bottom-left-radius: 0; | ||
} | ||
&::after { | ||
clip-path: inset(-100% -100% 0 -100%); | ||
} | ||
} | ||
> ${Button}:last-child:not(:first-child) { | ||
&, | ||
&::after { | ||
border-top-left-radius: 0; | ||
border-top-right-radius: 0; | ||
} | ||
&::after { | ||
clip-path: inset(0 -100% -100% -100%); | ||
} | ||
} | ||
> ${Button}:not(:first-child):not(:last-child) { | ||
&, | ||
&::after { | ||
border-radius: 0; | ||
} | ||
&::after { | ||
clip-path: inset(0 -100%); | ||
} | ||
} | ||
> ${Button} + ${Button} { | ||
margin-top: -${themeVal('button.shape.border')}; | ||
} | ||
`; | ||
default: | ||
throw new Error( | ||
`Invalid button group orientation (${orientation}). Must be one of [horizontal, vertical].` | ||
); | ||
} | ||
} | ||
const ButtonGroup = styled.div` | ||
position: relative; | ||
display: inline-flex; | ||
> ${Button} { | ||
display: block; | ||
export const createButtonGroupStyles = (props) => { | ||
return css` | ||
position: relative; | ||
margin: 0; | ||
z-index: 2; | ||
} | ||
display: inline-flex; | ||
/* Group orientation */ | ||
${props => renderOrientation(props)} | ||
> ${Button} { | ||
position: relative; | ||
flex: 1 1 auto; | ||
&:focus { | ||
z-index: 1; | ||
} | ||
} | ||
/* Group orientation */ | ||
${renderOrientation(props)} | ||
`; | ||
}; | ||
// Styled component of the button group. | ||
const StyledButtonGroup = styled.div` | ||
${createButtonGroupStyles} | ||
`; | ||
/* eslint-disable react/display-name, react/prop-types */ | ||
// Wrap the styled button in a high order component to pass the context | ||
// provider. This component needs to also become a styled-component to take | ||
// advantage of the styled component functionalities. | ||
// To be sure that this component maintains its structure you MUST use the | ||
// styled-component's `forwardedAs` property when replacing what's being | ||
// rendered, instead of using `as`. | ||
export const ButtonGroup = styled( | ||
React.forwardRef((props, ref) => { | ||
const { | ||
className, | ||
children, | ||
size, | ||
variation, | ||
radius, | ||
role = 'group', | ||
...rest | ||
} = props; | ||
return ( | ||
<ButtonGroupProvider size={size} variation={variation} radius={radius}> | ||
<StyledButtonGroup | ||
className={className} | ||
role={role} | ||
ref={ref} | ||
{...rest} | ||
> | ||
{children} | ||
</StyledButtonGroup> | ||
</ButtonGroupProvider> | ||
); | ||
}) | ||
)` | ||
/* styled-component */ | ||
`; | ||
/* eslint-enable */ | ||
ButtonGroup.propTypes = { | ||
children: PropTypes.node, | ||
orientation: PropTypes.string, | ||
children: T.node, | ||
className: T.string, | ||
size: T.string, | ||
variation: T.string, | ||
radius: T.string, | ||
role: T.string | ||
}; | ||
export default ButtonGroup; |
@@ -1,9 +0,4 @@ | ||
import Button from './Button'; | ||
import { | ||
buttonVariation, | ||
buttonVariationHoverCss, | ||
buttonVariationBaseCss, | ||
buttonVariationActiveCss, | ||
} from './styled'; | ||
import ButtonGroup from './ButtonGroup'; | ||
import { Button, createButtonStyles } from './Button'; | ||
import { ButtonGroup, createButtonGroupStyles } from './ButtonGroup'; | ||
import { ButtonGroupProvider, useButtonGroup } from './context'; | ||
@@ -13,6 +8,6 @@ export { | ||
Button, | ||
buttonVariation, | ||
buttonVariationBaseCss, | ||
buttonVariationHoverCss, | ||
buttonVariationActiveCss, | ||
createButtonStyles, | ||
createButtonGroupStyles, | ||
ButtonGroupProvider, | ||
useButtonGroup | ||
}; |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
498544
20
2591
1
4
1
+ Added@devseed-ui/collecticons@4.1.0(transitive)
+ Added@devseed-ui/theme-provider@4.1.0(transitive)
- Removed@devseed-ui/collecticons@3.2.0(transitive)
- Removed@devseed-ui/theme-provider@3.0.0(transitive)