Skip to content

Commit

Permalink
feat: Update Switch component styling
Browse files Browse the repository at this point in the history
Adds new 'labelStyle: "centered"'. This will stack the label on top of the switch and center both elements in the column.
Updates how tooltips are rendered for Switches. Now includes an icon to denote there is a tooltip, rather than requiring the user to discover it by hovering over it.

Adds this tooltip functionality to the Label component, though it is only currently used by Switch. At some point it would be good to move all Tooltips to this sort of UX.
  • Loading branch information
Brandon Dow committed Dec 14, 2023
1 parent 79a9a43 commit 7daea33
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 68 deletions.
35 changes: 27 additions & 8 deletions src/components/Label.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React, { LabelHTMLAttributes } from "react";
import React, { LabelHTMLAttributes, ReactNode } from "react";
import { VisuallyHidden } from "react-aria";
import { Css } from "src/Css";
import { Css, Font, Only, Palette, Xss } from "src/Css";
import { Icon } from "src";

interface LabelProps {
type LabelXss = Font | "color";

interface LabelProps<X> {
// We don't usually have `fooProps`-style props, but this is for/from react-aria
labelProps?: LabelHTMLAttributes<HTMLLabelElement>;
label: string;
Expand All @@ -11,22 +14,38 @@ interface LabelProps {
hidden?: boolean;
contrast?: boolean;
multiline?: boolean;
tooltip?: ReactNode;
// Removes margin bottom if true - This is different from InlineLabel. InlineLabel expects to be rendered visually within the field element. Rather just on the same line.
inline?: boolean;
xss?: X;
}

/** An internal helper component for rendering form labels. */
export const Label = React.memo((props: LabelProps) => {
const { labelProps, label, hidden, suffix, contrast = false, ...others } = props;
function LabelComponent<X extends Only<Xss<LabelXss>, X>>(props: LabelProps<X>) {
const { labelProps, label, hidden, suffix, contrast = false, tooltip, inline, xss, ...others } = props;
const labelEl = (
<label {...labelProps} {...others} css={Css.dib.sm.gray700.mbPx(4).if(contrast).white.$}>
<label
{...labelProps}
{...others}
css={{ ...Css.df.aic.gap1.sm.gray700.mbPx(inline ? 0 : 4).if(contrast).white.$, ...xss }}
>
{label}
{suffix && ` ${suffix}`}
{tooltip && (
<span css={Css.fs0.$}>
<Icon icon="infoCircle" tooltip={tooltip} inc={2} color={contrast ? Palette.White : Palette.Gray700} />
</span>
)}
</label>
);
return hidden ? <VisuallyHidden>{labelEl}</VisuallyHidden> : labelEl;
});
}

export const Label = React.memo(LabelComponent) as typeof LabelComponent;

type InlineLabelProps = Omit<LabelProps<unknown>, "xss" | "inline">;
/** Used for showing labels within text fields. */
export function InlineLabel({ labelProps, label, contrast, multiline = false, ...others }: LabelProps) {
export function InlineLabel({ labelProps, label, contrast, multiline = false, ...others }: InlineLabelProps) {
return (
<label
{...labelProps}
Expand Down
8 changes: 8 additions & 0 deletions src/inputs/Switch.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ export const LabelStyles = () => {
<SwitchComponent label="Example Label" onChange={setSelected} selected={selected} labelStyle="hidden" />
<h2 css={Css.baseMd.mb1.mt3.pt2.bt.bGray200.$}>Left</h2>
<SwitchComponent label="Example Label" onChange={setSelected} selected={selected} labelStyle="left" />
<h2 css={Css.baseMd.mb1.mt3.pt2.bt.bGray200.$}>Centered</h2>
<SwitchComponent
label="Example Label"
tooltip="Tooltip example"
onChange={setSelected}
selected={selected}
labelStyle="centered"
/>
</div>
);
};
Expand Down
115 changes: 55 additions & 60 deletions src/inputs/Switch.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ReactNode, useRef } from "react";
import { useFocusRing, useHover, useSwitch, VisuallyHidden } from "react-aria";
import { maybeTooltip, resolveTooltip } from "src/components";
import { resolveTooltip } from "src/components";
import { Label } from "src/components/Label";
import { Css, Palette } from "src/Css";
import { Icon } from "../components/Icon";
Expand All @@ -16,7 +16,7 @@ export interface SwitchProps {
/** Input label */
label: string;
/** Where to put the label. */
labelStyle?: "form" | "inline" | "filter" | "hidden" | "left"; // TODO: Update `labelStyle` to make consistent with other `labelStyle` properties in the library
labelStyle?: "form" | "inline" | "filter" | "hidden" | "left" | "centered"; // TODO: Update `labelStyle` to make consistent with other `labelStyle` properties in the library
/** Whether or not to hide the label */
hideLabel?: boolean;
/** Handler when the interactive element state changes. */
Expand All @@ -25,6 +25,8 @@ export interface SwitchProps {
selected: boolean;
/** Whether to include icons like the check mark */
withIcon?: boolean;
/** Adds tooltip for the switch */
tooltip?: ReactNode;
}

export function Switch(props: SwitchProps) {
Expand All @@ -46,74 +48,67 @@ export function Switch(props: SwitchProps) {
const { inputProps } = useSwitch({ ...ariaProps, "aria-label": label }, state, ref);
const { isFocusVisible: isKeyboardFocus, focusProps } = useFocusRing(otherProps);
const { hoverProps, isHovered } = useHover(ariaProps);
const tooltip = resolveTooltip(disabled);
const tooltip = resolveTooltip(disabled, props.tooltip);

return maybeTooltip({
title: tooltip,
placement: "top",
children: (
<label
{...hoverProps}
return (
<div
{...hoverProps}
css={{
...Css.relative.cursorPointer.df.w("max-content").selectNone.$,
...(labelStyle === "form" && Css.fdc.$),
...(labelStyle === "left" && Css.w100.aic.$),
...(labelStyle === "inline" && Css.gap2.aic.$),
...(labelStyle === "filter" && Css.jcsb.gap1.aic.wa.sm.$),
...(labelStyle === "centered" && Css.fdc.aic.$),
...(isDisabled && Css.cursorNotAllowed.gray400.$),
}}
>
{labelStyle !== "inline" && labelStyle !== "hidden" && (
<div css={Css.if(labelStyle === "left").w50.$}>
<Label
label={label}
tooltip={tooltip}
xss={Css.if(labelStyle === "filter").gray900.$}
inline={labelStyle === "left" || labelStyle === "filter"}
/>
</div>
)}
{/* Background */}
<div
aria-hidden="true"
css={{
...Css.relative.cursorPointer.df.w("max-content").smMd.selectNone.$,
...(labelStyle === "form" && Css.fdc.$),
...(labelStyle === "left" && Css.w100.fdr.$),
...(labelStyle === "inline" && Css.gap2.aic.$),
...(labelStyle === "filter" && Css.jcsb.gap1.aic.wa.sm.$),
...(isDisabled && Css.cursorNotAllowed.gray400.$),
...Css.wPx(40).hPx(toggleHeight(compact)).bgGray200.br12.relative.transition.$,
...(isHovered && switchHoverStyles),
...(isKeyboardFocus && switchFocusStyles),
...(isDisabled && Css.bgGray300.$),
...(isSelected && Css.bgBlue700.$),
...(isSelected && isHovered && switchSelectedHoverStyles),
}}
aria-label={label}
>
{(labelStyle === "form" || labelStyle === "left") && (
<div css={Css.if(labelStyle === "left").w50.$}>
<Label label={label} />
</div>
)}
{labelStyle === "filter" && <span>{label}</span>}
{/* Background */}
{/* Circle */}
<div
aria-hidden="true"
css={{
...Css.wPx(40).hPx(toggleHeight(compact)).bgGray200.br12.relative.transition.$,
...(isHovered && switchHoverStyles),
...(isKeyboardFocus && switchFocusStyles),
...(isDisabled && Css.bgGray300.$),
...(isSelected && Css.bgBlue700.$),
...(isSelected && isHovered && switchSelectedHoverStyles),
...switchCircleDefaultStyles(compact),
...(isDisabled && Css.bgGray100.$),
...(isSelected && switchCircleSelectedStyles(compact)),
}}
>
{/* Circle */}
<div
css={{
...switchCircleDefaultStyles(compact),
...(isDisabled && Css.bgGray100.$),
...(isSelected && switchCircleSelectedStyles(compact)),
}}
>
{/* Icon */}
{withIcon && (
<Icon icon={isSelected ? "check" : "x"} color={isSelected ? Palette.Blue700 : Palette.Gray400} />
)}
</div>
{/* Icon */}
{withIcon && (
<Icon icon={isSelected ? "check" : "x"} color={isSelected ? Palette.Blue700 : Palette.Gray400} />
)}
</div>
{/* Since we are using childGap, we must wrap the label in an element and
</div>
{/* Since we are using childGap, we must wrap the label in an element and
match the height of the icon for horizontal alignment */}
{labelStyle === "inline" && (
<span
css={{
// LineHeight is conditionally applied to handle compact version text alignment
...Css.if(compact).add("lineHeight", "1").$,
}}
>
{label}
</span>
)}
<VisuallyHidden>
<input ref={ref} {...inputProps} {...focusProps} />
</VisuallyHidden>
</label>
),
});
{labelStyle === "inline" && (
<Label label={label} tooltip={tooltip} inline xss={Css.smMd.gray900.if(compact).add("lineHeight", "1").$} />
)}
<VisuallyHidden>
<input ref={ref} {...inputProps} {...focusProps} />
</VisuallyHidden>
</div>
);
}

/** Styles */
Expand Down

0 comments on commit 7daea33

Please sign in to comment.