Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: add UI badges for labeling beta features #11

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

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
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
89 changes: 89 additions & 0 deletions site/src/components/FeatureBadge/FeatureBadge.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useTheme } from "@emotion/react";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { FeatureBadge } from "./FeatureBadge";

const meta: Meta<typeof FeatureBadge> = {
title: "components/FeatureBadge",
component: FeatureBadge,
args: {
type: "beta",
},
};

export default meta;
type Story = StoryObj<typeof FeatureBadge>;

export const SmallInteractiveBeta: Story = {
args: {
type: "beta",
size: "sm",
variant: "interactive",
},
};

export const SmallInteractiveExperimental: Story = {
args: {
type: "experimental",
size: "sm",
variant: "interactive",
},
};

export const LargeInteractiveBeta: Story = {
args: {
type: "beta",
size: "lg",
variant: "interactive",
},
};

export const LargeStaticBeta: Story = {
args: {
type: "beta",
size: "lg",
variant: "static",
},
};

export const HoverControlledByParent: Story = {
args: {
type: "experimental",
size: "sm",
},

decorators: (Story, context) => {
const theme = useTheme();
const [isHovering, setIsHovering] = useState(false);

return (
<button
type="button"
onPointerEnter={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
css={[
{
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
display: "flex",
flexFlow: "row nowrap",
alignItems: "center",
columnGap: "16px",
},
isHovering && {
backgroundColor: "green",
},
]}
>
<span>Blah</span>
{Story({
args: {
...context.initialArgs,
variant: "static",
highlighted: isHovering,
},
})}
</button>
);
},
};
204 changes: 204 additions & 0 deletions site/src/components/FeatureBadge/FeatureBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import type { Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import { visuallyHidden } from "@mui/utils";
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
import { Popover, PopoverTrigger } from "components/Popover/Popover";
import {
type FC,
type HTMLAttributes,
type ReactNode,
useEffect,
useState,
} from "react";
import { docs } from "utils/docs";

/**
* All types of feature that we are currently supporting. Defined as record to
* ensure that we can't accidentally make typos when writing the badge text.
*/
const featureBadgeTypes = {
beta: "beta",
experimental: "experimental",
} as const satisfies Record<string, ReactNode>;

const styles = {
badge: (theme) => ({
// Base type is based on a span so that the element can be placed inside
// more types of HTML elements without creating invalid markdown, but we
// still want the default display behavior to be div-like
display: "block",
maxWidth: "fit-content",

// Base style assumes that small badges will be the default
fontSize: "0.75rem",

cursor: "default",
flexShrink: 0,
padding: "4px 8px",
lineHeight: 1,
whiteSpace: "nowrap",
border: `1px solid ${theme.roles.preview.outline}`,
color: theme.roles.preview.text,
backgroundColor: theme.roles.preview.background,
borderRadius: "6px",
transition:
"color 0.2s ease-in-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out",
}),

badgeHover: (theme) => ({
color: theme.roles.preview.hover.text,
borderColor: theme.roles.preview.hover.outline,
backgroundColor: theme.roles.preview.hover.background,
}),

badgeLargeText: {
fontSize: "1rem",
},

tooltipTitle: (theme) => ({
color: theme.palette.text.primary,
fontWeight: 600,
fontFamily: "inherit",
fontSize: 18,
margin: 0,
lineHeight: 1,
paddingBottom: "8px",
}),

tooltipDescription: {
margin: 0,
lineHeight: 1.4,
paddingBottom: "8px",
},

tooltipLink: {
fontWeight: 600,
lineHeight: 1.2,
},
} as const satisfies Record<string, Interpolation<Theme>>;

function grammaticalArticle(nextWord: string): string {
const vowels = ["a", "e", "i", "o", "u"];
const firstLetter = nextWord.slice(0, 1).toLowerCase();
return vowels.includes(firstLetter) ? "an" : "a";
}

function capitalizeFirstLetter(text: string): string {
return text.slice(0, 1).toUpperCase() + text.slice(1);
}

type FeatureBadgeProps = Readonly<
Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
type: keyof typeof featureBadgeTypes;
size?: "sm" | "lg";
} & (
| {
/**
* Defines whether the FeatureBadge should act as a
* controlled or uncontrolled component with its hover and
* general interaction styling.
*/
variant: "interactive";

// Had to specify the highlighted key for this union option
// even though it won't be used, because otherwise the type
// ergonomics for users would be too clunky.
highlighted?: undefined;
}
| { variant: "static"; highlighted?: boolean }
)
>;

export const FeatureBadge: FC<FeatureBadgeProps> = ({
type,
size = "sm",
variant = "interactive",
highlighted = false,
onPointerEnter,
onPointerLeave,
...delegatedProps
}) => {
// Not a big fan of having two hover variables, but we need to make sure the
// badge maintains its hover styling while the mouse is inside the tooltip
const [isBadgeHovering, setIsBadgeHovering] = useState(false);
const [isTooltipHovering, setIsTooltipHovering] = useState(false);

useEffect(() => {
const onWindowBlur = () => {
setIsBadgeHovering(false);
setIsTooltipHovering(false);
};

window.addEventListener("blur", onWindowBlur);
return () => window.removeEventListener("blur", onWindowBlur);
}, []);

const featureType = featureBadgeTypes[type];
const showBadgeHoverStyle =
highlighted ||
(variant === "interactive" && (isBadgeHovering || isTooltipHovering));

const coreContent = (
<span
css={[
styles.badge,
size === "lg" && styles.badgeLargeText,
showBadgeHoverStyle && styles.badgeHover,
]}
onPointerEnter={variant === "interactive" ? undefined : onPointerEnter}
onPointerLeave={variant === "interactive" ? undefined : onPointerLeave}
{...delegatedProps}
>
<span style={visuallyHidden}> (This is a</span>
{featureType}
<span style={visuallyHidden}> feature)</span>
</span>
);

if (variant !== "interactive") {
return coreContent;
}

return (
<Popover mode="hover">
<PopoverTrigger
onPointerEnter={(event) => {
setIsBadgeHovering(true);
onPointerEnter?.(event);
}}
onPointerLeave={(event) => {
setIsBadgeHovering(false);
onPointerLeave?.(event);
}}
>
{coreContent}
</PopoverTrigger>

<HelpTooltipContent
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
onPointerEnter={() => setIsTooltipHovering(true)}
onPointerLeave={() => setIsTooltipHovering(false)}
>
<h5 css={styles.tooltipTitle}>
{capitalizeFirstLetter(featureType)} Feature
</h5>

<p css={styles.tooltipDescription}>
This is {grammaticalArticle(featureType)} {featureType} feature. It
has not yet reached generally availability (GA).
</p>

<Link
href={docs("/contributing/feature-stages")}
target="_blank"
rel="noreferrer"
css={styles.tooltipLink}
>
Learn about feature stages
<span style={visuallyHidden}> (link opens in new tab)</span>
</Link>
</HelpTooltipContent>
</Popover>
);
};
29 changes: 21 additions & 8 deletions site/src/components/Popover/Popover.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import MuiPopover, {
type PopoverProps as MuiPopoverProps,
// biome-ignore lint/nursery/noRestrictedImports: Used as base component
// biome-ignore lint/nursery/noRestrictedImports: This is the base component that our custom popover is based on
} from "@mui/material/Popover";
import {
type FC,
type HTMLAttributes,
type PointerEvent,
type PointerEventHandler,
type ReactElement,
type ReactNode,
type RefObject,
Expand Down Expand Up @@ -95,17 +97,20 @@ export const PopoverTrigger = (
const { children, ...elementProps } = props;

const clickProps = {
onClick: () => {
onClick: (event: PointerEvent<HTMLElement>) => {
popover.setOpen(true);
elementProps.onClick?.(event);
},
};

const hoverProps = {
onPointerEnter: () => {
onPointerEnter: (event: PointerEvent<HTMLElement>) => {
popover.setOpen(true);
elementProps.onPointerEnter?.(event);
},
onPointerLeave: () => {
onPointerLeave: (event: PointerEvent<HTMLElement>) => {
popover.setOpen(false);
elementProps.onPointerLeave?.(event);
},
};

Expand All @@ -130,6 +135,8 @@ export type PopoverContentProps = Omit<

export const PopoverContent: FC<PopoverContentProps> = ({
horizontal = "left",
onPointerEnter,
onPointerLeave,
...popoverProps
}) => {
const popover = usePopover();
Expand All @@ -152,7 +159,7 @@ export const PopoverContent: FC<PopoverContentProps> = ({
},
}}
{...horizontalProps(horizontal)}
{...modeProps(popover)}
{...modeProps(popover, onPointerEnter, onPointerLeave)}
{...popoverProps}
id={popover.id}
open={popover.open}
Expand All @@ -162,14 +169,20 @@ export const PopoverContent: FC<PopoverContentProps> = ({
);
};

const modeProps = (popover: PopoverContextValue) => {
const modeProps = (
popover: PopoverContextValue,
externalOnPointerEnter: PointerEventHandler<HTMLDivElement> | undefined,
externalOnPointerLeave: PointerEventHandler<HTMLDivElement> | undefined,
) => {
if (popover.mode === "hover") {
return {
onPointerEnter: () => {
onPointerEnter: (event: PointerEvent<HTMLDivElement>) => {
popover.setOpen(true);
externalOnPointerEnter?.(event);
},
onPointerLeave: () => {
onPointerLeave: (event: PointerEvent<HTMLDivElement>) => {
popover.setOpen(false);
externalOnPointerLeave?.(event);
},
};
}
Expand Down
Loading