) => {
+ isNavigationPrevented = event.defaultPrevented
+ })
+
+ const onClick = jest.fn()
+ render(
+
+ }
+ icon="π"
+ aria-label="Click me"
+ />
+
,
+ )
+ const link = screen.getByRole('link', { name: 'Click me' })
+
+ expect(link).toHaveAttribute('aria-disabled', 'true')
+ expect(onClick).not.toHaveBeenCalled()
+ userEvent.click(link)
+ expect(onClick).not.toHaveBeenCalled()
+ expect(isNavigationPrevented).toBe(true)
+ })
+
+ it('only applies a soft disabled state to the link', () => {
+ render(
+ }
+ icon="π"
+ aria-label="Click me"
+ />,
+ )
+ const link = screen.getByRole('link', { name: 'Click me' })
+ expect(link).not.toBeDisabled()
+ expect(link).toHaveAttribute('aria-disabled', 'true')
+ userEvent.tab()
+ expect(link).toHaveFocus()
})
})
diff --git a/src/button/button.tsx b/src/button/button.tsx
index 23a58e395..11ef758e1 100644
--- a/src/button/button.tsx
+++ b/src/button/button.tsx
@@ -1,47 +1,280 @@
import * as React from 'react'
-import { BaseButton } from '../base-button'
-import type { BaseButtonProps } from '../base-button'
+import classNames from 'classnames'
+import { Role, RoleProps } from '@ariakit/react'
-type NativeButtonProps = Omit<
- React.AllHTMLAttributes,
- 'aria-disabled' | 'className' | keyof BaseButtonProps
->
+import { Box, getBoxClassNames } from '../box'
+import { Spinner } from '../spinner'
+import { Tooltip, TooltipProps } from '../tooltip'
-export type ButtonProps = NativeButtonProps &
- BaseButtonProps & {
- type?: 'button' | 'submit' | 'reset'
- exceptionallySetClassName?: string
- }
+import styles from './button.module.css'
+
+import type { ObfuscatedClassName } from '../utils/common-types'
+
+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
+
+interface CommonButtonProps
+ extends ObfuscatedClassName,
+ Omit, 'className'>,
+ Pick {
+ /**
+ * 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.
+ *
+ * Buttons are disabled using aria-disabled, rather than the HTML disabled attribute. This
+ * allows the buttons to be focusable, which can aid discoverability. This way, users can tab to
+ * the button and read its label, even if they can't activate it.
+ *
+ * It is also convenient when buttons are rendered as a link. Links cannot normally be disabled,
+ * but by using aria-disabled, we can make them behave as if they were.
+ *
+ * The `onClick` handler is automatically prevented when the button is disabled in this way, to
+ * mimic the behavior of a native disabled attribute.
+ *
+ * @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']
+
+ /**
+ * The type of the button.
+ *
+ * @default 'button'
+ */
+ type?: 'button' | 'submit' | 'reset'
+}
+
+interface ButtonProps extends CommonButtonProps {
+ /**
+ * The button label content.
+ */
+ children: React.ReactNode
+
+ /**
+ * The icon to display at the start of the button (before the label).
+ */
+ startIcon?: IconElement
+
+ /**
+ * The icon to display at the end of the button (after the label).
+ */
+ endIcon?: IconElement
+
+ /**
+ * The width of the button.
+ *
+ * - `'auto'`: The button will be as wide as its content.
+ * - `'full'`: The button will be as wide as its container.
+ *
+ * @default 'auto'
+ */
+ width?: 'auto' | 'full'
+
+ /**
+ * The alignment of the button label inside the button.
+ *
+ * @default 'center'
+ */
+ align?: 'start' | 'center' | 'end'
+}
+
+/**
+ * A button element that displays a text label and optionally a start or end icon. It follows the
+ * [WAI-ARIA Button Pattern](https://www.w3.org/TR/wai-aria-practices/#button).
+ */
+const Button = React.forwardRef(function Button(
+ {
+ variant,
+ tone = 'normal',
+ size = 'normal',
+ shape = 'normal',
+ type = 'button',
+ disabled = false,
+ loading = false,
+ tooltip,
+ render,
+ onClick,
+ exceptionallySetClassName,
+ children,
+ startIcon,
+ endIcon,
+ width = 'auto',
+ align = 'center',
+ ...props
+ },
+ ref,
+) {
+ const isDisabled = loading || disabled
+ const buttonElement = (
+
+ <>
+ {startIcon ? (
+
+ {loading && !endIcon ? : startIcon}
+
+ ) : null}
+
+ {children ? (
+
+ {children}
+
+ ) : null}
+
+ {endIcon || (loading && !startIcon) ? (
+
+ {loading ? : endIcon}
+
+ ) : null}
+ >
+
+ )
+
+ return tooltip ? {buttonElement} : buttonElement
+})
+
+interface IconButtonProps extends CommonButtonProps {
+ /**
+ * The icon to display inside the button.
+ */
+ icon: IconElement
+
+ /**
+ * The button label.
+ *
+ * It is used for assistive technologies, and it is also shown as a tooltip (if not tooltip is
+ * provided explicitly).
+ */
+ 'aria-label': string
+}
/**
- * A semantic button that also looks like a button, and provides all the necessary visual variants.
- * It follows the [WAI-ARIA Button Pattern](https://www.w3.org/TR/wai-aria-practices/#button).
- *
- * @see ButtonLink
+ * A button element that displays an icon only, visually, though it is semantically labelled. It
+ * also makes sure to always show a tooltip with its label. It follows the
+ * [WAI-ARIA Button Pattern](https://www.w3.org/TR/wai-aria-practices/#button).
*/
-export const Button = React.forwardRef(function Button(
+const IconButton = React.forwardRef(function Button(
{
variant,
tone = 'normal',
size = 'normal',
+ shape = 'normal',
type = 'button',
disabled = false,
+ loading = false,
+ tooltip,
+ render,
+ onClick,
exceptionallySetClassName,
+ children,
+ icon,
...props
},
ref,
) {
- return (
-
+ aria-disabled={isDisabled}
+ onClick={isDisabled ? preventDefault : onClick}
+ className={classNames([
+ exceptionallySetClassName,
+ styles.baseButton,
+ styles[`variant-${variant}`],
+ styles[`tone-${tone}`],
+ styles[`size-${size}`],
+ shape === 'rounded' ? styles['shape-rounded'] : null,
+ styles.iconButton,
+ disabled ? styles.disabled : null,
+ ])}
+ >
+ {(loading && ) || icon}
+
+ )
+
+ const tooltipContent = tooltip === undefined ? props['aria-label'] : tooltip
+ return tooltipContent ? (
+ {buttonElement}
+ ) : (
+ buttonElement
)
})
+
+export type { ButtonProps, IconButtonProps, ButtonVariant, ButtonTone }
+export { Button, IconButton }
diff --git a/src/button/icon-button.stories.mdx b/src/button/icon-button.stories.mdx
new file mode 100644
index 000000000..62cb65f27
--- /dev/null
+++ b/src/button/icon-button.stories.mdx
@@ -0,0 +1,569 @@
+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 { IconButton } from '../button'
+
+
+
+export function Icon() {
+ return (
+
+ )
+}
+
+export function LoadingButton(props) {
+ const [loading, setLoading] = useState(false)
+ useEffect(() => {
+ if (!loading) return undefined
+ const timeout = setTimeout(() => setLoading(false), 3000)
+ return () => clearTimeout(timeout)
+ }, [loading])
+ return (
+ setLoading(true)} icon={} />
+ )
+}
+
+# IconButton
+
+
+
+
+## Main demo
+
+
+
+### With different size
+
+Buttons have a default `normal` size, but they can also be larger or smaller. Use the `size` prop
+for this purpose.
+
+
+
+### 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
+
+```
+
+And here's how that look like:
+
+
+
+### Playground
+
+export function PlaygroundTemplate({ label, ...props }) {
+ return (
+
+ Click on the buttons to see the loading state
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+
+
+### Dark mode
+
+export function DarkModeTemplate(props) {
+ return (
+
+
+
+ )
+}
+
+Even though Reactist does not yet owns the concept of dark mode, and leaves it for apps to set it
+up, here's a demo on how easily you can set it up by manipulating color variables only.
+
+
+
+## Style customization
+
+### 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/index.ts b/src/index.ts
index 0f4c87a18..f6b8fe9b6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -23,7 +23,6 @@ export * from './prose'
// links
export * from './button'
-export * from './button-link'
export * from './text-link'
// form fields
diff --git a/src/loading/loading.tsx b/src/loading/loading.tsx
index ce8db51f1..95cfd507c 100644
--- a/src/loading/loading.tsx
+++ b/src/loading/loading.tsx
@@ -5,7 +5,7 @@ import { Spinner } from '../spinner'
type Size = 'xsmall' | 'small' | 'medium' | 'large'
type NativeProps = Omit<
- JSX.IntrinsicElements['div'],
+ React.HTMLAttributes,
'className' | 'aria-describedby' | 'aria-label' | 'aria-labelledby' | 'role' | 'size'
>
diff --git a/src/menu/menu.stories.mdx b/src/menu/menu.stories.mdx
index b540de448..8c825a53b 100644
--- a/src/menu/menu.stories.mdx
+++ b/src/menu/menu.stories.mdx
@@ -1,11 +1,10 @@
import { Meta, Story, Canvas, ArgsTable, Description } from '@storybook/addon-docs'
import KeyboardShortcut from '../components/keyboard-shortcut'
-import { Button } from '../button'
+import { IconButton } from '../button'
import { Inline } from '../inline'
import { Stack } from '../stack'
import { Columns, Column } from '../columns'
import { ContextMenuTrigger, Menu, MenuButton, MenuList, MenuItem, MenuGroup, SubMenu } from '.'
-import { ButtonLink } from '../button-link'
import { Text } from '../text'
}
aria-label="More"
diff --git a/src/menu/menu.tsx b/src/menu/menu.tsx
index 225be21ff..7752c4254 100644
--- a/src/menu/menu.tsx
+++ b/src/menu/menu.tsx
@@ -28,8 +28,6 @@ import {
import './menu.less'
-type NativeProps = React.DetailedHTMLProps, E>
-
type MenuContextState = {
menuStore: MenuStore
handleItemSelect?: (value: string | null | undefined) => void
@@ -340,7 +338,7 @@ const SubMenu = React.forwardRef(function SubMenu(
// MenuGroup
//
-type MenuGroupProps = Omit, 'className'> & {
+type MenuGroupProps = Omit, 'className'> & {
/**
* A label to be shown visually and also used to semantically label the group.
*/
diff --git a/src/modal/modal-examples.stories.tsx b/src/modal/modal-examples.stories.tsx
index a7c587fba..23dd07d3c 100644
--- a/src/modal/modal-examples.stories.tsx
+++ b/src/modal/modal-examples.stories.tsx
@@ -2,7 +2,7 @@ import * as React from 'react'
import { action } from '@storybook/addon-actions'
import { Box } from '../box'
-import { Button as ReactistButton } from '../button'
+import { IconButton } from '../button'
import { Column, Columns } from '../columns'
import { Heading } from '../heading'
import { Inline } from '../inline'
@@ -65,9 +65,13 @@ export function ModalWithStandardActionsFooter() {