diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a47f932..a819af831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Reactist follows [semantic versioning](https://semver.org/) and doesn't introduce breaking changes (API-wise) in minor or patch releases. However, the appearance of a component might change in a minor or patch release so keep an eye on redesigns and make sure your app still looks and feels like you expect it. +# v25.0.0-beta + +- [BREAKING] Removed the `ButtonLink` component. +- [BREAKING] `Button` no longer accepts props that render it as an icon-only button. +- [Feat] Introduce a new `IconButton` component. +- [Feat] The `Button` and `IconButton` component can be rendered as a link using the `render={} prop. + # v24.2.0-beta - [Fix] Include changes from [v23.3.0](#v2330) in the beta release @@ -17,7 +24,7 @@ Reactist follows [semantic versioning](https://semver.org/) and doesn't introduc # v24.1.3-beta - [Fix] Remove unsupported `onPointerEnterCapture` and `onPointerLeaveCapture` props from `heading`-, `input`-, and `textarea`-based components. - - Normally, this would be considered a breaking change, but the v24 version is still pre-release and already contains breaking changes. +- Normally, this would be considered a breaking change, but the v24 version is still pre-release and already contains breaking changes. # v24.1.2-beta diff --git a/package-lock.json b/package-lock.json index b5307c84c..197172762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@doist/reactist", - "version": "24.2.0-beta", + "version": "25.0.0-beta", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@doist/reactist", - "version": "24.2.0-beta", + "version": "25.0.0-beta", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9d0063b83..41a16034f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "email": "henning@doist.com", "url": "http://doist.com" }, - "version": "24.2.0-beta", + "version": "25.0.0-beta", "license": "MIT", "homepage": "https://github.com/Doist/reactist#readme", "repository": { diff --git a/src/banner/banner.stories.mdx b/src/banner/banner.stories.mdx index cd43f1ecd..a1bac33ef 100644 --- a/src/banner/banner.stories.mdx +++ b/src/banner/banner.stories.mdx @@ -3,7 +3,7 @@ import { Box } from '../box' import { Text } from '../text' import { Stack } from '../stack' import { Banner } from './banner' -import { ButtonLink } from '../button-link' +import { Button } from '../button' + ) } diff --git a/src/base-button/base-button.stories.mdx b/src/base-button/base-button.stories.mdx deleted file mode 100644 index 2602fc77a..000000000 --- a/src/base-button/base-button.stories.mdx +++ /dev/null @@ -1,54 +0,0 @@ -import { Meta, ArgsTable, Description } from '@storybook/addon-docs' -import { BaseButton } from '../base-button' - - - -# BaseButton - -The component that powers `Button` and `ButtonLink`, where the button styling logic and some common -functionality resides. This component is internal to Reactist. - -## `` - - - - -## Colors - -The following CSS custom properties are available to customize the button-like element appearance. - -``` ---reactist-actionable-primary-idle-tint ---reactist-actionable-primary-idle-fill ---reactist-actionable-primary-hover-tint ---reactist-actionable-primary-hover-fill ---reactist-actionable-primary-disabled-tint ---reactist-actionable-primary-disabled-fill - ---reactist-actionable-secondary-idle-tint ---reactist-actionable-secondary-idle-fill ---reactist-actionable-secondary-hover-tint ---reactist-actionable-secondary-hover-fill ---reactist-actionable-secondary-disabled-tint ---reactist-actionable-secondary-disabled-fill - ---reactist-actionable-tertiary-idle-tint ---reactist-actionable-tertiary-idle-fill ---reactist-actionable-tertiary-hover-tint ---reactist-actionable-tertiary-hover-fill ---reactist-actionable-tertiary-disabled-tint ---reactist-actionable-tertiary-disabled-fill - ---reactist-actionable-destructive-idle-tint ---reactist-actionable-destructive-idle-fill ---reactist-actionable-destructive-hover-tint ---reactist-actionable-destructive-hover-fill ---reactist-actionable-destructive-disabled-tint ---reactist-actionable-destructive-disabled-fill -``` diff --git a/src/base-button/base-button.tsx b/src/base-button/base-button.tsx deleted file mode 100644 index d6d48df80..000000000 --- a/src/base-button/base-button.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import * as React from 'react' -import { Tooltip, TooltipProps } from '../tooltip' -import { polymorphicComponent } from '../utils/polymorphism' -import { Box } from '../box' -import { Spinner } from '../spinner' -import styles from './base-button.module.css' - -function preventDefault(event: React.SyntheticEvent) { - event.preventDefault() -} - -type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'quaternary' -type ButtonTone = 'normal' | 'destructive' -type ButtonSize = 'small' | 'normal' | 'large' -type IconElement = React.ReactChild - -type CommonProps = { - /** - * The button's variant. - */ - variant: ButtonVariant - - /** - * The button's tone. - * @default 'normal' - */ - tone?: ButtonTone - - /** - * The button's size. - * @default 'normal' - */ - size?: ButtonSize - - /** - * Controls the shape of the button. Specifically, it allows to make it have slightly curved - * corners (the default) vs. having them fully curved to the point that they are as round as - * possible. In icon-only buttons this allows to have the button be circular. - * @default 'normal' - */ - shape?: 'normal' | 'rounded' - - /** - * Whether the button is disabled or not. - * @default false - */ - disabled?: boolean - - /** - * Whether the button is busy/loading. - * - * A button in this state is functionally and semantically disabled. Visually is does not look - * dimmed (as disabled buttons normally do), but it shows a loading spinner instead. - * - * @default false - */ - loading?: boolean - - /** - * A tooltip linked to the button element. - */ - tooltip?: TooltipProps['content'] -} - -type AlignmentProps = { - width: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'full' - align?: 'start' | 'center' | 'end' -} - -type AutoWidthProps = { - width?: 'auto' - align?: never -} - -type IconButtonProps = { - icon: IconElement - 'aria-label': string - children?: never - startIcon?: never - endIcon?: never - width?: never - align?: never -} - -type LabelledButtonProps = { - children: NonNullable - startIcon?: IconElement - endIcon?: IconElement - icon?: never -} & (AutoWidthProps | AlignmentProps) - -export type BaseButtonProps = CommonProps & (IconButtonProps | LabelledButtonProps) - -/** - * The component that powers `Button` and `ButtonLink`, where the button styling logic and some - * common functionality resides. This component is internal to Reactist. - * - * @see Button - * @see ButtonLink - */ -export const BaseButton = polymorphicComponent<'div', BaseButtonProps>(function BaseButton( - { - as = 'div', - variant, - tone = 'normal', - size = 'normal', - shape = 'normal', - disabled = false, - loading = false, - tooltip, - onClick, - exceptionallySetClassName, - children, - startIcon, - endIcon, - icon, - width = 'auto', - align = 'center', - ...props - }, - ref, -) { - const isDisabled = loading || disabled - const buttonElement = ( - - {icon ? ( - (loading && ) || icon - ) : ( - <> - {startIcon ? ( - - {loading && !endIcon ? : startIcon} - - ) : null} - {children ? ( - - {children} - - ) : null} - {endIcon || (loading && !startIcon) ? ( - - {loading ? : endIcon} - - ) : null} - - )} - - ) - - // If it's an icon-only button, make sure it uses the aria-label as tooltip if no tooltip was provided - const tooltipContent = icon && tooltip === undefined ? props['aria-label'] : tooltip - return tooltipContent ? ( - {buttonElement} - ) : ( - buttonElement - ) -}) diff --git a/src/base-button/index.ts b/src/base-button/index.ts deleted file mode 100644 index 83434c611..000000000 --- a/src/base-button/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './base-button' diff --git a/src/box/box.tsx b/src/box/box.tsx index 6458a35e6..14613c12b 100644 --- a/src/box/box.tsx +++ b/src/box/box.tsx @@ -89,6 +89,92 @@ interface BoxProps extends WithEnhancedClassName, ReusableBoxProps, BoxMarginPro textAlign?: ResponsiveProp } +function getBoxClassNames({ + position = 'static', + display, + flexDirection = 'row', + flexWrap, + flexGrow, + flexShrink, + gap, + alignItems, + justifyContent, + alignSelf, + overflow, + width, + height, + background, + border, + borderRadius, + minWidth, + maxWidth, + textAlign, + padding, + paddingY, + paddingX, + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + margin, + marginY, + marginX, + marginTop, + marginRight, + marginBottom, + marginLeft, + className, +}: BoxProps) { + const resolvedPaddingTop = paddingTop ?? paddingY ?? padding + const resolvedPaddingRight = paddingRight ?? paddingX ?? padding + const resolvedPaddingBottom = paddingBottom ?? paddingY ?? padding + const resolvedPaddingLeft = paddingLeft ?? paddingX ?? padding + + const resolvedMarginTop = marginTop ?? marginY ?? margin + const resolvedMarginRight = marginRight ?? marginX ?? margin + const resolvedMarginBottom = marginBottom ?? marginY ?? margin + const resolvedMarginLeft = marginLeft ?? marginX ?? margin + + const omitFlex = + !display || (typeof display === 'string' && display !== 'flex' && display !== 'inlineFlex') + + return classNames( + className, + styles.box, + display ? getClassNames(styles, 'display', display) : null, + position !== 'static' ? getClassNames(styles, 'position', position) : null, + minWidth != null ? getClassNames(widthStyles, 'minWidth', String(minWidth)) : null, + getClassNames(widthStyles, 'maxWidth', maxWidth), + getClassNames(styles, 'textAlign', textAlign), + // padding + getClassNames(paddingStyles, 'paddingTop', resolvedPaddingTop), + getClassNames(paddingStyles, 'paddingRight', resolvedPaddingRight), + getClassNames(paddingStyles, 'paddingBottom', resolvedPaddingBottom), + getClassNames(paddingStyles, 'paddingLeft', resolvedPaddingLeft), + // margin + getClassNames(marginStyles, 'marginTop', resolvedMarginTop), + getClassNames(marginStyles, 'marginRight', resolvedMarginRight), + getClassNames(marginStyles, 'marginBottom', resolvedMarginBottom), + getClassNames(marginStyles, 'marginLeft', resolvedMarginLeft), + // flex props + omitFlex ? null : getClassNames(styles, 'flexDirection', flexDirection), + omitFlex ? null : getClassNames(styles, 'flexWrap', flexWrap), + omitFlex ? null : getClassNames(styles, 'alignItems', alignItems), + omitFlex ? null : getClassNames(styles, 'justifyContent', justifyContent), + alignSelf != null ? getClassNames(styles, 'alignSelf', alignSelf) : null, + flexShrink != null ? getClassNames(styles, 'flexShrink', String(flexShrink)) : null, + flexGrow != null ? getClassNames(styles, 'flexGrow', String(flexGrow)) : null, + gap ? getClassNames(gapStyles, 'gap', gap) : null, + // other props + getClassNames(styles, 'overflow', overflow), + width != null ? getClassNames(widthStyles, 'width', String(width)) : null, + getClassNames(styles, 'height', height), + getClassNames(styles, 'bg', background), + borderRadius !== 'none' ? getClassNames(styles, 'borderRadius', borderRadius) : null, + border !== 'none' ? getClassNames(styles, 'border', border) : null, + ) +} + const Box = polymorphicComponent<'div', BoxProps, 'keepClassName'>(function Box( { as: component = 'div', @@ -131,65 +217,46 @@ const Box = polymorphicComponent<'div', BoxProps, 'keepClassName'>(function Box( }, ref, ) { - const resolvedPaddingTop = paddingTop ?? paddingY ?? padding - const resolvedPaddingRight = paddingRight ?? paddingX ?? padding - const resolvedPaddingBottom = paddingBottom ?? paddingY ?? padding - const resolvedPaddingLeft = paddingLeft ?? paddingX ?? padding - - const resolvedMarginTop = marginTop ?? marginY ?? margin - const resolvedMarginRight = marginRight ?? marginX ?? margin - const resolvedMarginBottom = marginBottom ?? marginY ?? margin - const resolvedMarginLeft = marginLeft ?? marginX ?? margin - - const omitFlex = - !display || (typeof display === 'string' && display !== 'flex' && display !== 'inlineFlex') - return React.createElement( component, { ...props, - className: - classNames( - className, - styles.box, - display ? getClassNames(styles, 'display', display) : null, - position !== 'static' ? getClassNames(styles, 'position', position) : null, - minWidth != null - ? getClassNames(widthStyles, 'minWidth', String(minWidth)) - : null, - getClassNames(widthStyles, 'maxWidth', maxWidth), - getClassNames(styles, 'textAlign', textAlign), - // padding - getClassNames(paddingStyles, 'paddingTop', resolvedPaddingTop), - getClassNames(paddingStyles, 'paddingRight', resolvedPaddingRight), - getClassNames(paddingStyles, 'paddingBottom', resolvedPaddingBottom), - getClassNames(paddingStyles, 'paddingLeft', resolvedPaddingLeft), - // margin - getClassNames(marginStyles, 'marginTop', resolvedMarginTop), - getClassNames(marginStyles, 'marginRight', resolvedMarginRight), - getClassNames(marginStyles, 'marginBottom', resolvedMarginBottom), - getClassNames(marginStyles, 'marginLeft', resolvedMarginLeft), - // flex props - omitFlex ? null : getClassNames(styles, 'flexDirection', flexDirection), - omitFlex ? null : getClassNames(styles, 'flexWrap', flexWrap), - omitFlex ? null : getClassNames(styles, 'alignItems', alignItems), - omitFlex ? null : getClassNames(styles, 'justifyContent', justifyContent), - alignSelf != null ? getClassNames(styles, 'alignSelf', alignSelf) : null, - flexShrink != null - ? getClassNames(styles, 'flexShrink', String(flexShrink)) - : null, - flexGrow != null ? getClassNames(styles, 'flexGrow', String(flexGrow)) : null, - gap ? getClassNames(gapStyles, 'gap', gap) : null, - // other props - getClassNames(styles, 'overflow', overflow), - width != null ? getClassNames(widthStyles, 'width', String(width)) : null, - getClassNames(styles, 'height', height), - getClassNames(styles, 'bg', background), - borderRadius !== 'none' - ? getClassNames(styles, 'borderRadius', borderRadius) - : null, - border !== 'none' ? getClassNames(styles, 'border', border) : null, - ) || undefined, + className: getBoxClassNames({ + position, + display, + flexDirection, + flexWrap, + flexGrow, + flexShrink, + gap, + alignItems, + justifyContent, + alignSelf, + overflow, + width, + height, + background, + border, + borderRadius, + minWidth, + maxWidth, + textAlign, + padding, + paddingY, + paddingX, + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + margin, + marginY, + marginX, + marginTop, + marginRight, + marginBottom, + marginLeft, + className, + }), ref, }, children, @@ -215,4 +282,4 @@ export type { BoxBorderRadius, } -export { Box } +export { Box, getBoxClassNames } diff --git a/src/button-link/button-link-story-wrapper.tsx b/src/button-link/button-link-story-wrapper.tsx deleted file mode 100644 index 6a754c69c..000000000 --- a/src/button-link/button-link-story-wrapper.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react' -import { ButtonLink, ButtonLinkProps } from './button-link' - -function ButtonLinkStoryWrapper(props: ButtonLinkProps) { - return ( - { - event.preventDefault() - props.onClick?.(event) - }} - href="https://doist.com" - /> - ) -} - -ButtonLinkStoryWrapper.displayName = 'ButtonLink' - -export { ButtonLinkStoryWrapper as ButtonLink } diff --git a/src/button-link/button-link.stories.mdx b/src/button-link/button-link.stories.mdx deleted file mode 100644 index ec1321182..000000000 --- a/src/button-link/button-link.stories.mdx +++ /dev/null @@ -1,796 +0,0 @@ -import { useEffect, useState } from 'react' -import { Meta, Story, Canvas, ArgsTable, Description } from '@storybook/addon-docs' -import { Box } from '../box' -import { Inline } from '../inline' -import { Stack } from '../stack' -import { Text } from '../text' -import { Heading } from '../heading' -import * as ButtonLinkModule from './button-link' -import { ButtonLink } from './button-link-story-wrapper' - - - -export function Icon() { - return ( - - - - ) -} - -# ButtonLink - -'A semantic link that looks like a button, exactly matching the `ButtonLink` component in all visual -aspects.' - - - - - -
tone="normal"
- - - Primary - - - Secondary - - - Tertiary - - - Quaternary - - - - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - -
- -
tone="destructive"
- - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - - - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - -
-
-
-
- -## `` - - - - -## Style customization - -Check out `BaseButton` for documentation about how to customize it visually. - -## Stories - -### With label and icon - -To have icons rendered before or after the label, you can use the `startIcon` or `endIcon` prop for -this purpose. - -Nothing prevents you from passing both a `startIcon` and an `endIcon` at the same time. However, -this is discouraged, and not guaranteed to be supported in the future. - - - - - - Icon before the label - - - }> - Primary - - - - }> - Secondary - - - - }> - Tertiary - - - - }> - Quaternary - - - - - - Icon after the label - - - }> - Primary - - - - }> - Secondary - - - - }> - Tertiary - - - - }> - Quaternary - - - - - - - - -## Icon-only buttons - -Alternatively, you can render an icon-only `ButtonLink`. To do so use the `icon` prop. - -Icon-only buttons do not support receiving the `children` prop, or any of the `startIcon` or -`endIcon` props. They also force you to pass a `aria-label` prop. The `aria-label` will also be used -as a tooltip if no tooltip is provided. - - - - - - - } /> - - - } /> - - - } /> - - - } /> - - - - - } disabled /> - - - } - disabled - /> - - - } disabled /> - - - } - disabled - /> - - - - - - -### With different size - -Buttons have a default `normal` size, but they can also be larger or smaller. Use the `size` prop -for this purpose. - - - - - -
size="small"
- - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - - - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - -
- -
size="normal"
- - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - - - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - -
- -
size="large"
- - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - - - - - Primary - - - - - Secondary - - - - - Tertiary - - - - - Quaternary - - - -
-
-
-
- -### Customized colors - -Though probably not the final official way to do it, this is a way to achieve one-off alternative -styles for buttons. - -```jsx - - Click me - -``` - -And here's how that look like: - - - - - - - Enabled - - - - - Disabled - - - - - - -export function LoadingButtonLink(props) { - const [loading, setLoading] = useState(false) - useEffect(() => { - if (!loading) return undefined - const timeout = setTimeout(() => setLoading(false), 3000) - return () => clearTimeout(timeout) - }, [loading]) - return setLoading(true)} /> -} - -### Full-width - -export function FullWidthTemplate({ label, ...otherProps }) { - if (label === 'Click me now') { - label = ( - <> - Click me now - - ) - } - const props = { - ...otherProps, - startIcon: , - endIcon: , - } - return ( - - Full-width buttons and label alignment - - When buttons have `width` other than the default `auto` they can also customize how - the label is aligned horizontally. - - - - - {label} - - - {label} - - - {label} - - - - - ) -} - - - now', - 'If you click me now, youΚΌll take away the biggest part of me', - ], - defaultValue: 'Submit', - }, - variant: { - options: ['primary', 'secondary', 'tertiary', 'quaternary'], - control: { type: 'select' }, - defaultValue: 'primary', - }, - tone: { - options: ['normal', 'destructive'], - control: { type: 'inline-radio' }, - defaultValue: 'normal', - }, - size: { - options: ['small', 'normal', 'large'], - control: { type: 'inline-radio' }, - defaultValue: 'normal', - }, - shape: { - options: ['normal', 'rounded'], - control: { type: 'inline-radio' }, - defaultValue: 'normal', - }, - width: { - options: ['none', 'xsmall', 'small', 'medium', 'large', 'xlarge', 'full'], - control: { type: 'select' }, - defaultValue: 'full', - }, - align: { control: false }, - disabled: { control: false }, - startIcon: { control: false }, - endIcon: { control: false }, - icon: { control: false }, - tooltip: { control: false }, - exceptionallySetClassName: { control: false }, - }} - name="Full-width" - > - {FullWidthTemplate.bind({})} - - - -### Playground - -export function Template({ label, ...props }) { - let textLabel = label - if (label === 'Click me now') { - label = ( - <> - Click me now - - ) - textLabel = 'Click me now' - } - return ( - - Click on the buttons to see the loading state - - - - {label} - - - - {label} - - - - - - }> - {label} - - - - } disabled> - {label} - - - - - - }> - {label} - - - - } disabled> - {label} - - - - - - } - /> - - - } - disabled - /> - - - - - ) -} - - - now', - 'If you click me now, youΚΌll take away the biggest part of me', - ], - defaultValue: 'Submit', - }, - variant: { - options: ['primary', 'secondary', 'tertiary', 'quaternary'], - control: { type: 'select' }, - defaultValue: 'primary', - }, - tone: { - options: ['normal', 'destructive'], - control: { type: 'inline-radio' }, - defaultValue: 'normal', - }, - size: { - options: ['small', 'normal', 'large'], - control: { type: 'inline-radio' }, - defaultValue: 'normal', - }, - shape: { - options: ['normal', 'rounded'], - control: { type: 'inline-radio' }, - defaultValue: 'normal', - }, - disabled: { control: false }, - startIcon: { control: false }, - endIcon: { control: false }, - icon: { control: false }, - tooltip: { control: false }, - openInNewTab: { control: false }, - target: { control: false }, - rel: { control: false }, - as: { control: false }, - exceptionallySetClassName: { control: false }, - }} - name="Playground" - > - {Template.bind({})} - - - -### Dark mode - -export function DarkModeTemplate(props) { - return ( - -