Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Button.disabled can be a tooltip #202

Merged
merged 6 commits into from
Aug 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/components/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Button, ButtonProps } from "src";
import { Css } from "src/Css";

export default {
title: "Components/Buttons",
title: "Components/Button",
component: Button,
args: { onClick: action("onPress") },
argTypes: {
Expand All @@ -20,7 +20,7 @@ export default {
},
} as Meta<ButtonProps>;

export function Buttons(args: ButtonProps) {
export function ButtonVariations(args: ButtonProps) {
const buttonRowStyles = Css.df.childGap1.my1.$;
return (
<div css={Css.dg.flexColumn.childGap2.$}>
Expand Down Expand Up @@ -130,3 +130,20 @@ export function Buttons(args: ButtonProps) {
</div>
);
}

export function ButtonWithTooltip() {
return (
<Button
disabled={
<div>
You <b>cannot</b> currently perform this operation because of:
<ul>
<li>reason one</li>
<li>reason two</li>
</ul>
</div>
}
label="Upload"
/>
);
}
24 changes: 16 additions & 8 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AriaButtonProps } from "@react-types/button";
import { ReactNode, RefObject, useMemo, useRef } from "react";
import { useButton, useFocusRing, useHover } from "react-aria";
import { Icon, IconProps } from "src";
import { Tooltip } from "src/components/Tooltip";
import { Css } from "src/Css";
import { BeamButtonProps, BeamFocusableProps } from "src/interfaces";

Expand All @@ -16,13 +17,9 @@ export interface ButtonProps extends BeamButtonProps, BeamFocusableProps {
buttonRef?: RefObject<HTMLButtonElement>;
}

export function Button({
onClick: onPress,
disabled: isDisabled,
endAdornment,
menuTriggerProps,
...otherProps
}: ButtonProps) {
export function Button(props: ButtonProps) {
const { onClick: onPress, disabled, endAdornment, menuTriggerProps, ...otherProps } = props;
const isDisabled = !!disabled;
const ariaProps = { onPress, isDisabled, ...otherProps, ...menuTriggerProps };
const { label, icon, variant = "primary", size = "sm", buttonRef } = ariaProps;
const ref = buttonRef || useRef(null);
Expand All @@ -35,7 +32,7 @@ export function Button({
]);
const focusRingStyles = useMemo(() => (variant === "danger" ? Css.bshDanger.$ : Css.bshFocus.$), [variant]);

return (
const button = (
<button
ref={ref}
{...buttonProps}
Expand All @@ -55,6 +52,17 @@ export function Button({
{endAdornment && <span css={Css.ml1.$}>{endAdornment}</span>}
</button>
);

// If we're disabled b/c of a non-boolean ReactNode, show it in a tooltip
if (isDisabled && typeof disabled !== "boolean") {
return (
<Tooltip title={disabled} delay={100}>
{button}
</Tooltip>
);
}

return button;
}

function getButtonStyles(variant: ButtonVariant, size: ButtonSize) {
Expand Down
39 changes: 17 additions & 22 deletions src/components/ButtonGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,31 @@ import React, { useRef } from "react";
import { useButton, useFocusRing, useHover } from "react-aria";
import { Icon, IconProps } from "src/components/Icon";
import { Css } from "src/Css";
import { BeamButtonProps, BeamFocusableProps } from "src/interfaces";
import { Callback } from "src/types";

export interface ButtonGroupProps {
buttons: ButtonGroupButton[];
/** Disables all buttons in ButtonGroup */
disabled?: boolean;
/**
* ButtonGroupButtonProps in an internal API.
* This is only exposing props that will be publicly accessible.
*/
buttons: Pick<ButtonGroupButtonProps, "text" | "icon" | "active" | "onClick" | "disabled">[];
size?: ButtonGroupSize;
}

interface ButtonGroupButtonProps extends BeamButtonProps, BeamFocusableProps {
text?: string;
export type ButtonGroupButton = {
icon?: IconProps["icon"];
// Active is used to indicate the active/selected button, as in a tab or toggle.
text?: string;
onClick?: Callback;
/** Disables the button. Note we don't support the `disabled: ReactNode`/tooltip for now. */
disabled?: boolean;
/** Indicates the active/selected button, as in a tab or toggle. */
active?: boolean;
size: ButtonGroupSize;
}
};

export function ButtonGroup(props: ButtonGroupProps) {
const { buttons, disabled = false, size = "sm" } = props;
return (
<div css={Css.mPx(4).$}>
{buttons.map(({ disabled: buttonDisabled, ...buttonProps }, i) => (
<ButtonGroupButton
<GroupButton
key={i}
{...buttonProps}
{...{
Expand All @@ -43,19 +41,16 @@ export function ButtonGroup(props: ButtonGroupProps) {
);
}

function ButtonGroupButton({
icon,
text,
active,
onClick: onPress,
disabled,
size,
...otherProps
}: ButtonGroupButtonProps) {
interface GroupButtonProps extends ButtonGroupButton {
size: ButtonGroupSize;
}

function GroupButton(props: GroupButtonProps) {
const { icon, text, active, onClick: onPress, disabled, size, ...otherProps } = props;
const ariaProps = { onPress, isDisabled: disabled, ...otherProps };
const ref = useRef(null);
const { buttonProps, isPressed } = useButton(ariaProps, ref);
const { isFocusVisible, focusProps } = useFocusRing(ariaProps);
const { isFocusVisible, focusProps } = useFocusRing();
const { hoverProps, isHovered } = useHover(ariaProps);

return (
Expand Down
35 changes: 26 additions & 9 deletions src/components/IconButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { action } from "@storybook/addon-actions";
import { Meta } from "@storybook/react";
import { IconButton as IconButtonComponent, IconButtonProps, iconButtonStylesHover, Icons } from "src";
import { IconButton as IconButton, IconButtonProps, iconButtonStylesHover, Icons } from "src";
import { Css, Palette } from "src/Css";

export default {
title: "Components/Icon Button",
component: IconButtonComponent,
component: IconButton,
args: {
icon: "arrowBack",
onClick: action("onPress"),
Expand All @@ -20,43 +20,60 @@ export default {
},
} as Meta<IconButtonProps>;

export const IconButton = (args: IconButtonProps) => (
export const IconButtonStyles = (args: IconButtonProps) => (
<div css={{ h1: Css.xl2Em.mbPx(30).$, h2: Css.smEm.$ }}>
<h1>Icon Only Button</h1>
<div css={Css.df.gapPx(90).$}>
<div>
<h2>Default</h2>
<IconButtonComponent {...args} />
<IconButton {...args} />
</div>
<div>
<h2>Hover</h2>
<HoveredIconButton {...args} />
</div>
<div>
<h2>Focused</h2>
<IconButtonComponent {...args} autoFocus />
<IconButton {...args} autoFocus />
</div>
<div>
<h2>Disabled</h2>
<IconButtonComponent {...args} disabled />
<IconButton {...args} disabled />
</div>
<div>
<h2>Colored</h2>
<IconButtonComponent {...args} color={Palette.Red700} />
<IconButton {...args} color={Palette.Red700} />
</div>
<div>
<h2>Smaller</h2>
<IconButtonComponent {...args} inc={2} />
<IconButton {...args} inc={2} />
</div>
</div>
</div>
);

export function IconButtonDisabled() {
return (
<IconButton
disabled={
<div>
You <b>cannot</b> currently perform this operation because of:
<ul>
<li>reason one</li>
<li>reason two</li>
</ul>
</div>
}
icon="arrowBack"
/>
);
}

/** Hover styled version of the IconButton */
function HoveredIconButton(args: IconButtonProps) {
return (
<div css={{ button: iconButtonStylesHover }}>
<IconButtonComponent {...args} />
<IconButton {...args} />
</div>
);
}
20 changes: 16 additions & 4 deletions src/components/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AriaButtonProps } from "@react-types/button";
import { RefObject, useMemo, useRef } from "react";
import { useButton, useFocusRing, useHover } from "react-aria";
import { Icon, IconProps } from "src/components";
import { Icon, IconProps, Tooltip } from "src/components";
import { Css, Palette } from "src/Css";
import { BeamButtonProps, BeamFocusableProps } from "src/interfaces";
import { useTestIds } from "src/utils/useTestIds";
Expand All @@ -18,7 +18,8 @@ export interface IconButtonProps extends BeamButtonProps, BeamFocusableProps {
}

export function IconButton(props: IconButtonProps) {
const { onClick: onPress, disabled: isDisabled, color, icon, autoFocus, inc, buttonRef, menuTriggerProps } = props;
const { onClick: onPress, disabled, color, icon, autoFocus, inc, buttonRef, menuTriggerProps } = props;
const isDisabled = !!disabled;
const ariaProps = { onPress, isDisabled, autoFocus, ...menuTriggerProps };
// eslint-disable-next-line react-hooks/rules-of-hooks
const ref = buttonRef || useRef(null);
Expand All @@ -37,15 +38,26 @@ export function IconButton(props: IconButtonProps) {
[isHovered, isFocusVisible, isDisabled],
);

return (
const button = (
<button {...testIds} {...buttonProps} {...focusProps} {...hoverProps} ref={ref} css={styles}>
<Icon icon={icon} color={color || (isDisabled ? Palette.Gray400 : Palette.Gray900)} inc={inc} />
</button>
);

// If we're disabled b/c of a non-boolean ReactNode, show it in a tooltip
if (isDisabled && typeof disabled !== "boolean") {
return (
<Tooltip title={disabled} delay={100}>
{button}
</Tooltip>
);
}

return button;
}

const iconButtonStylesReset = Css.hPx(28).wPx(28).br8.bTransparent.bsSolid.bw2.bgTransparent.cursorPointer.outline0.p0
.df.itemsCenter.justifyCenter.transition.$;
.dif.itemsCenter.justifyCenter.transition.$;
export const iconButtonStylesHover = Css.bgGray100.$;
const iconButtonStylesFocus = Css.bLightBlue700.$;
const iconButtonStylesDisabled = Css.cursorNotAllowed.$;
18 changes: 13 additions & 5 deletions src/components/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import { Meta } from "@storybook/react";
import { Css } from "src/Css";
import { Placement, Tooltip as TooltipComponent } from "./Tooltip";
import { Placement, Tooltip } from "./Tooltip";

export default {
component: TooltipComponent,
component: Tooltip,
title: "Components/Tooltip",
} as Meta;

export function Tooltip() {
export function TooltipPlacements() {
const placements: Placement[] = ["auto", "bottom", "left", "right", "top"];
return (
<div css={Css.w25.ml("25%").$}>
{placements.map((placement, i) => (
<TooltipComponent title="Tooltip Info" placement={placement} key={i}>
<Tooltip title="Tooltip Info" placement={placement} key={i}>
<span css={Css.db.tc.my5.bgGray400.br4.$}>
This tooltip is positioned at: <span css={Css.b.$}>{placement}</span>
</span>
</TooltipComponent>
</Tooltip>
))}
</div>
);
}

export function TooltipDisabled() {
return (
<Tooltip title="Tooltip Info" disabled={true}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! I never knew a Tooltip could be disabled!

<span css={Css.db.tc.my5.bgGray400.br4.$}>Content</span>
</Tooltip>
);
}
13 changes: 7 additions & 6 deletions src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactElement, ReactNode, useRef, useState } from "react";
import React, { cloneElement, ReactElement, ReactNode, useRef, useState } from "react";
import { mergeProps, useTooltip, useTooltipTrigger } from "react-aria";
import { usePopper } from "react-popper";
import { useTooltipTriggerState } from "react-stately";
Expand All @@ -10,27 +10,28 @@ import { Css } from "src/Css";

interface TooltipProps {
/** The content that shows up when hovered */
title: string;
title: ReactNode;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! @kbesingeryeh the fix got in!

children: ReactElement;
placement?: Placement;
delay?: number;
disabled?: boolean;
}

export function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({ delay: 500, ...props });
const { placement, children, title, disabled, delay } = props;

const state = useTooltipTriggerState({ delay, isDisabled: disabled });
const triggerRef = React.useRef(null);
const { placement, children, title, disabled } = props;
const { triggerProps, tooltipProps: _tooltipProps } = useTooltipTrigger(
{ ...props, isDisabled: disabled },
{ delay, isDisabled: disabled },
state,
triggerRef,
);
const { tooltipProps } = useTooltip(_tooltipProps, state);

return (
<>
{React.cloneElement(children, { ref: triggerRef, ...triggerProps })}
{cloneElement(children, { ref: triggerRef, ...triggerProps })}
{state.isOpen && (
<Popper
{...mergeProps(_tooltipProps, tooltipProps)}
Expand Down
8 changes: 6 additions & 2 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ export interface BeamFocusableProps {
}

export interface BeamButtonProps {
/** Whether the interactive element is disabled. */
disabled?: boolean;
/**
* Whether the interactive element is disabled.
*
* If a ReactNode, it's treated as a "disabled reason" that's shown in a tooltip.
*/
disabled?: boolean | ReactNode;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

/** Handler that is called when the press is released over the target. */
onClick?: (e: PressEvent) => void;
}
Expand Down