- Notifications
You must be signed in to change notification settings - Fork1.1k
feat: create UI badges for labeling beta features#14661
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
f843f2343874a4fb70781c2470f9693946fc2d7cda129613b6adea6bd4455a88ae71d30ad68af781a609a981864f47d059ad617636e16aaa6a19b61a2338678cbe6397e1ec681b58e4debc5397a9b689731e1fa7e16b1403aeb3c071323acda31c84c7308c3230aa1dfb4b7345a7bbd39521f2566389429a18e51b093a999647fc60197466fb869640f645336a2cfcfeb2b1c20b46eca4302b3dbe8121e1e5d62ba82440439daf80File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -120,6 +120,7 @@ | ||
| "stretchr", | ||
| "STTY", | ||
| "stuntest", | ||
| "subpage", | ||
aslilac marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
| "tailbroker", | ||
| "tailcfg", | ||
| "tailexchange", | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import type { Meta, StoryObj } from "@storybook/react"; | ||
| import { FeatureStageBadge } from "./FeatureStageBadge"; | ||
| const meta: Meta<typeof FeatureStageBadge> = { | ||
| title: "components/FeatureStageBadge", | ||
| component: FeatureStageBadge, | ||
| args: { | ||
| contentType: "beta", | ||
| }, | ||
| }; | ||
| export default meta; | ||
| type Story = StoryObj<typeof FeatureStageBadge>; | ||
| export const MediumBeta: Story = { | ||
| args: { | ||
| size: "md", | ||
| }, | ||
| }; | ||
| export const SmallBeta: Story = { | ||
| args: { | ||
| size: "sm", | ||
| }, | ||
| }; | ||
| export const LargeBeta: Story = { | ||
| args: { | ||
| size: "lg", | ||
| }, | ||
| }; | ||
| export const MediumExperimental: Story = { | ||
| args: { | ||
| size: "md", | ||
| contentType: "experimental", | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| 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, HTMLAttributes, ReactNode } 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 featureStageBadgeTypes = { | ||
| beta: "beta", | ||
| experimental: "experimental", | ||
| } as const satisfies Record<string, ReactNode>; | ||
| type FeatureStageBadgeProps = Readonly< | ||
| Omit<HTMLAttributes<HTMLSpanElement>, "children"> & { | ||
| contentType: keyof typeof featureStageBadgeTypes; | ||
| size?: "sm" | "md" | "lg"; | ||
| } | ||
| >; | ||
| export const FeatureStageBadge: FC<FeatureStageBadgeProps> = ({ | ||
| contentType, | ||
| size = "md", | ||
| ...delegatedProps | ||
| }) => { | ||
| return ( | ||
| <Popover mode="hover"> | ||
| <PopoverTrigger> | ||
| {({ isOpen }) => ( | ||
| <span | ||
| css={[ | ||
| styles.badge, | ||
| size === "sm" && styles.badgeSmallText, | ||
| size === "lg" && styles.badgeLargeText, | ||
| isOpen && styles.badgeHover, | ||
| ]} | ||
| {...delegatedProps} | ||
| > | ||
| <span style={visuallyHidden}> (This is a</span> | ||
| {featureStageBadgeTypes[contentType]} | ||
| <span style={visuallyHidden}> feature)</span> | ||
| </span> | ||
| )} | ||
| </PopoverTrigger> | ||
| <HelpTooltipContent | ||
| anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | ||
| transformOrigin={{ vertical: "top", horizontal: "center" }} | ||
| > | ||
| <p css={styles.tooltipDescription}> | ||
| This feature has not yet reached general 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> | ||
| ); | ||
| }; | ||
| const styles = { | ||
Member There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. I think we generally define | ||
| 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 medium badges will be the default | ||
| fontSize: "0.75rem", | ||
| cursor: "default", | ||
| flexShrink: 0, | ||
| padding: "4px 8px", | ||
| lineHeight: 1, | ||
| whiteSpace: "nowrap", | ||
| border: `1px solid ${theme.branding.featureStage.border}`, | ||
| color: theme.branding.featureStage.text, | ||
| backgroundColor: theme.branding.featureStage.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.branding.featureStage.hover.text, | ||
| borderColor: theme.branding.featureStage.hover.border, | ||
| backgroundColor: theme.branding.featureStage.hover.background, | ||
| }), | ||
| badgeLargeText: { | ||
| fontSize: "1rem", | ||
| }, | ||
| badgeSmallText: { | ||
| // Have to beef up font weight so that the letters still maintain the | ||
| // same relative thickness as all our other main UI text | ||
| fontWeight: 500, | ||
| fontSize: "0.625rem", | ||
| }, | ||
| 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>>; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,19 @@ | ||
| import MuiPopover, { | ||
| type PopoverProps as MuiPopoverProps, | ||
| // biome-ignore lint/nursery/noRestrictedImports:This is thebase component that our custom popover is based on | ||
ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. I might have Biome configured wrong because my Biome plugin is flagging all of these comments as not doing anything Accidentally removed this at first, but that's definitely a me problem, right? Member There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. I think it doesn't like that our biome.json config is in the site/ directory 🙄 I hate that extensions never bother to handle subdirectories well. | ||
| } from "@mui/material/Popover"; | ||
| import { | ||
| type FC, | ||
| type HTMLAttributes, | ||
| type PointerEvent, | ||
| type PointerEventHandler, | ||
| type ReactElement, | ||
| type ReactNode, | ||
| type RefObject, | ||
| cloneElement, | ||
| createContext, | ||
| useContext, | ||
| useEffect, | ||
| useId, | ||
| useRef, | ||
| useState, | ||
| @@ -20,10 +23,13 @@ type TriggerMode = "hover" | "click"; | ||
| type TriggerRef = RefObject<HTMLElement>; | ||
| // Have to append ReactNode type to satisfy React's cloneElement function. It | ||
| // has absolutely no bearing on what happens at runtime | ||
| type TriggerElement = ReactNode & | ||
| ReactElement<{ | ||
| ref: TriggerRef; | ||
| onClick?: () => void; | ||
| }>; | ||
| type PopoverContextValue = { | ||
| id: string; | ||
| @@ -61,6 +67,15 @@ export const Popover: FC<PopoverProps> = (props) => { | ||
| const [uncontrolledOpen, setUncontrolledOpen] = useState(false); | ||
| const triggerRef: TriggerRef = useRef(null); | ||
| // Helps makes sure that popovers close properly when the user switches to | ||
| // a different tab. This won't help with controlled instances of the | ||
| // component, but this is basically the most we can do from here | ||
| useEffect(() => { | ||
| const closeOnTabSwitch = () => setUncontrolledOpen(false); | ||
| window.addEventListener("blur", closeOnTabSwitch); | ||
| return () => window.removeEventListener("blur", closeOnTabSwitch); | ||
| }, []); | ||
| const value: PopoverContextValue = { | ||
| triggerRef, | ||
| id: `${hookId}-popover`, | ||
| @@ -86,30 +101,47 @@ export const usePopover = () => { | ||
| return context; | ||
| }; | ||
| type PopoverTriggerRenderProps = Readonly<{ | ||
| isOpen: boolean; | ||
| }>; | ||
| type PopoverTriggerProps = Readonly< | ||
| Omit<HTMLAttributes<HTMLElement>, "children"> & { | ||
| children: | ||
| | TriggerElement | ||
| | ((props: PopoverTriggerRenderProps) => TriggerElement); | ||
| } | ||
| >; | ||
| export const PopoverTrigger: FC<PopoverTriggerProps> = (props) => { | ||
| const popover = usePopover(); | ||
| const { children, onClick, onPointerEnter, onPointerLeave, ...elementProps } = | ||
| props; | ||
| const clickProps = { | ||
| onClick: (event: PointerEvent<HTMLElement>) => { | ||
| popover.setOpen(true); | ||
| onClick?.(event); | ||
| }, | ||
| }; | ||
| const hoverProps = { | ||
| onPointerEnter: (event: PointerEvent<HTMLElement>) => { | ||
| popover.setOpen(true); | ||
| onPointerEnter?.(event); | ||
| }, | ||
| onPointerLeave: (event: PointerEvent<HTMLElement>) => { | ||
| popover.setOpen(false); | ||
| onPointerLeave?.(event); | ||
| }, | ||
| }; | ||
| const evaluatedChildren = | ||
| typeof children === "function" | ||
| ? children({ isOpen: popover.open }) | ||
| : children; | ||
| return cloneElement(evaluatedChildren, { | ||
| ...elementProps, | ||
| ...(popover.mode === "click" ? clickProps : hoverProps), | ||
| "aria-haspopup": true, | ||
| @@ -130,6 +162,8 @@ export type PopoverContentProps = Omit< | ||
| export const PopoverContent: FC<PopoverContentProps> = ({ | ||
| horizontal = "left", | ||
| onPointerEnter, | ||
| onPointerLeave, | ||
| ...popoverProps | ||
| }) => { | ||
| const popover = usePopover(); | ||
| @@ -152,7 +186,7 @@ export const PopoverContent: FC<PopoverContentProps> = ({ | ||
| }, | ||
| }} | ||
| {...horizontalProps(horizontal)} | ||
| {...modeProps(popover, onPointerEnter, onPointerLeave)} | ||
| {...popoverProps} | ||
| id={popover.id} | ||
| open={popover.open} | ||
| @@ -162,14 +196,20 @@ export const PopoverContent: FC<PopoverContentProps> = ({ | ||
| ); | ||
| }; | ||
| const modeProps = ( | ||
| popover: PopoverContextValue, | ||
| externalOnPointerEnter: PointerEventHandler<HTMLDivElement> | undefined, | ||
| externalOnPointerLeave: PointerEventHandler<HTMLDivElement> | undefined, | ||
| ) => { | ||
| if (popover.mode === "hover") { | ||
| return { | ||
| onPointerEnter: (event: PointerEvent<HTMLDivElement>) => { | ||
| popover.setOpen(true); | ||
| externalOnPointerEnter?.(event); | ||
| }, | ||
| onPointerLeave: (event: PointerEvent<HTMLDivElement>) => { | ||
| popover.setOpen(false); | ||
| externalOnPointerLeave?.(event); | ||
| }, | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.