- Notifications
You must be signed in to change notification settings - Fork924
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
f843f23
43874a4
fb70781
c2470f9
693946f
c2d7cda
129613b
6adea6b
d4455a8
8ae71d3
0ad68af
781a609
a981864
f47d059
ad61763
6e16aaa
6a19b61
a233867
8cbe639
7e1ec68
1b58e4d
ebc5397
a9b6897
31e1fa7
e16b140
3aeb3c0
71323ac
da31c84
c7308c3
230aa1d
fb4b734
5a7bbd3
9521f25
6638942
9a18e51
b093a99
9647fc6
0197466
fb86964
0f64533
6a2cfcf
eb2b1c2
0b46eca
4302b3d
be8121e
1e5d62b
a824404
39daf80
File 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 = { | ||
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 | ||
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? 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.