Skip to content

Commit 661d226

Browse files
authored
feat: create UI badges for labeling beta features (#14661)
* chore: finish draft work for FeatureBadge component * fix: add visually-hidden helper text for screen readers * chore: add stories for highlighted state * fix: update base styles * chore: remove debug display option * chore: update Popover to propagate events * wip: commit progress on FeatureBadge update * wip: commit more progress * chore: update tag definitions to satify Biome * chore: update all colors for preview role * fix: make sure badge shows as hovered while inside tooltip * wip: commit progress on adding story for controlled variant * fix: sort imports * refactor: change component API to be more obvious/ergonomic * fix: add biome-ignore comments to more base files * fix: update import order again * chore: revert biome-ignore comment * chore: update body text for tooltip * chore: update dark preivew role to use sky palette * chore: update color palettes for light/darkBlue themes * chore: add beta badge to organizations subheader * chore: add beta badge to organizations settings page * chore: beef up font weight for form header * fix: update text contrast for org menu list * chore: add beta badge to deployment dropdown * fix: run biome on imports * chore: remove API for controlling FeatureBadge hover styling externally * chore: add xs size for badge * fix: update font weight for xs feature badges * chore: add beta badges to all org headers * fix: turn badges and tooltips into separate concerns * fix: update hover styling * docs: update wording on comment * fix: apply formatting * chore: rename FeatureBadge to FeatureStageBadge * refactor: redefine FeatureStageBadge * chore: update stories * fix: add blur behavior to popover * chore: revert theme colors * chore: create featureStage branding namespace * fix: make sure cleanup function is set up properly * docs: update wording on comment for clarity * refactor: move styles down
1 parent 3338f32 commit 661d226

File tree

22 files changed

+417
-95
lines changed

22 files changed

+417
-95
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
"stretchr",
121121
"STTY",
122122
"stuntest",
123+
"subpage",
123124
"tailbroker",
124125
"tailcfg",
125126
"tailexchange",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { FeatureStageBadge } from "./FeatureStageBadge";
3+
4+
const meta: Meta<typeof FeatureStageBadge> = {
5+
title: "components/FeatureStageBadge",
6+
component: FeatureStageBadge,
7+
args: {
8+
contentType: "beta",
9+
},
10+
};
11+
12+
export default meta;
13+
type Story = StoryObj<typeof FeatureStageBadge>;
14+
15+
export const MediumBeta: Story = {
16+
args: {
17+
size: "md",
18+
},
19+
};
20+
21+
export const SmallBeta: Story = {
22+
args: {
23+
size: "sm",
24+
},
25+
};
26+
27+
export const LargeBeta: Story = {
28+
args: {
29+
size: "lg",
30+
},
31+
};
32+
33+
export const MediumExperimental: Story = {
34+
args: {
35+
size: "md",
36+
contentType: "experimental",
37+
},
38+
};
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { Interpolation, Theme } from "@emotion/react";
2+
import Link from "@mui/material/Link";
3+
import { visuallyHidden } from "@mui/utils";
4+
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
5+
import { Popover, PopoverTrigger } from "components/Popover/Popover";
6+
import type { FC, HTMLAttributes, ReactNode } from "react";
7+
import { docs } from "utils/docs";
8+
9+
/**
10+
* All types of feature that we are currently supporting. Defined as record to
11+
* ensure that we can't accidentally make typos when writing the badge text.
12+
*/
13+
const featureStageBadgeTypes = {
14+
beta: "beta",
15+
experimental: "experimental",
16+
} as const satisfies Record<string, ReactNode>;
17+
18+
type FeatureStageBadgeProps = Readonly<
19+
Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
20+
contentType: keyof typeof featureStageBadgeTypes;
21+
size?: "sm" | "md" | "lg";
22+
}
23+
>;
24+
25+
export const FeatureStageBadge: FC<FeatureStageBadgeProps> = ({
26+
contentType,
27+
size = "md",
28+
...delegatedProps
29+
}) => {
30+
return (
31+
<Popover mode="hover">
32+
<PopoverTrigger>
33+
{({ isOpen }) => (
34+
<span
35+
css={[
36+
styles.badge,
37+
size === "sm" && styles.badgeSmallText,
38+
size === "lg" && styles.badgeLargeText,
39+
isOpen && styles.badgeHover,
40+
]}
41+
{...delegatedProps}
42+
>
43+
<span style={visuallyHidden}> (This is a</span>
44+
{featureStageBadgeTypes[contentType]}
45+
<span style={visuallyHidden}> feature)</span>
46+
</span>
47+
)}
48+
</PopoverTrigger>
49+
50+
<HelpTooltipContent
51+
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
52+
transformOrigin={{ vertical: "top", horizontal: "center" }}
53+
>
54+
<p css={styles.tooltipDescription}>
55+
This feature has not yet reached general availability (GA).
56+
</p>
57+
58+
<Link
59+
href={docs("/contributing/feature-stages")}
60+
target="_blank"
61+
rel="noreferrer"
62+
css={styles.tooltipLink}
63+
>
64+
Learn about feature stages
65+
<span style={visuallyHidden}> (link opens in new tab)</span>
66+
</Link>
67+
</HelpTooltipContent>
68+
</Popover>
69+
);
70+
};
71+
72+
const styles = {
73+
badge: (theme) => ({
74+
// Base type is based on a span so that the element can be placed inside
75+
// more types of HTML elements without creating invalid markdown, but we
76+
// still want the default display behavior to be div-like
77+
display: "block",
78+
maxWidth: "fit-content",
79+
80+
// Base style assumes that medium badges will be the default
81+
fontSize: "0.75rem",
82+
83+
cursor: "default",
84+
flexShrink: 0,
85+
padding: "4px 8px",
86+
lineHeight: 1,
87+
whiteSpace: "nowrap",
88+
border: `1px solid ${theme.branding.featureStage.border}`,
89+
color: theme.branding.featureStage.text,
90+
backgroundColor: theme.branding.featureStage.background,
91+
borderRadius: "6px",
92+
transition:
93+
"color 0.2s ease-in-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out",
94+
}),
95+
96+
badgeHover: (theme) => ({
97+
color: theme.branding.featureStage.hover.text,
98+
borderColor: theme.branding.featureStage.hover.border,
99+
backgroundColor: theme.branding.featureStage.hover.background,
100+
}),
101+
102+
badgeLargeText: {
103+
fontSize: "1rem",
104+
},
105+
106+
badgeSmallText: {
107+
// Have to beef up font weight so that the letters still maintain the
108+
// same relative thickness as all our other main UI text
109+
fontWeight: 500,
110+
fontSize: "0.625rem",
111+
},
112+
113+
tooltipTitle: (theme) => ({
114+
color: theme.palette.text.primary,
115+
fontWeight: 600,
116+
fontFamily: "inherit",
117+
fontSize: 18,
118+
margin: 0,
119+
lineHeight: 1,
120+
paddingBottom: "8px",
121+
}),
122+
123+
tooltipDescription: {
124+
margin: 0,
125+
lineHeight: 1.4,
126+
paddingBottom: "8px",
127+
},
128+
129+
tooltipLink: {
130+
fontWeight: 600,
131+
lineHeight: 1.2,
132+
},
133+
} as const satisfies Record<string, Interpolation<Theme>>;

site/src/components/Form/Form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ const styles = {
170170
formSectionInfoTitle: (theme) => ({
171171
fontSize: 20,
172172
color: theme.palette.text.primary,
173-
fontWeight: 400,
173+
fontWeight: 500,
174174
margin: 0,
175175
marginBottom: 8,
176176
display: "flex",

site/src/components/Popover/Popover.tsx

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import MuiPopover, {
22
type PopoverProps as MuiPopoverProps,
3-
// biome-ignore lint/nursery/noRestrictedImports: Used as base component
3+
// biome-ignore lint/nursery/noRestrictedImports: This is the base component that our custom popover is based on
44
} from "@mui/material/Popover";
55
import {
66
type FC,
77
type HTMLAttributes,
8+
type PointerEvent,
9+
type PointerEventHandler,
810
type ReactElement,
911
type ReactNode,
1012
type RefObject,
1113
cloneElement,
1214
createContext,
1315
useContext,
16+
useEffect,
1417
useId,
1518
useRef,
1619
useState,
@@ -20,10 +23,13 @@ type TriggerMode = "hover" | "click";
2023

2124
type TriggerRef = RefObject<HTMLElement>;
2225

23-
type TriggerElement = ReactElement<{
24-
ref: TriggerRef;
25-
onClick?: () => void;
26-
}>;
26+
// Have to append ReactNode type to satisfy React's cloneElement function. It
27+
// has absolutely no bearing on what happens at runtime
28+
type TriggerElement = ReactNode &
29+
ReactElement<{
30+
ref: TriggerRef;
31+
onClick?: () => void;
32+
}>;
2733

2834
type PopoverContextValue = {
2935
id: string;
@@ -61,6 +67,15 @@ export const Popover: FC<PopoverProps> = (props) => {
6167
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
6268
const triggerRef: TriggerRef = useRef(null);
6369

70+
// Helps makes sure that popovers close properly when the user switches to
71+
// a different tab. This won't help with controlled instances of the
72+
// component, but this is basically the most we can do from here
73+
useEffect(() => {
74+
const closeOnTabSwitch = () => setUncontrolledOpen(false);
75+
window.addEventListener("blur", closeOnTabSwitch);
76+
return () => window.removeEventListener("blur", closeOnTabSwitch);
77+
}, []);
78+
6479
const value: PopoverContextValue = {
6580
triggerRef,
6681
id: `${hookId}-popover`,
@@ -86,30 +101,47 @@ export const usePopover = () => {
86101
return context;
87102
};
88103

89-
export const PopoverTrigger = (
90-
props: HTMLAttributes<HTMLElement> & {
91-
children: TriggerElement;
92-
},
93-
) => {
104+
type PopoverTriggerRenderProps = Readonly<{
105+
isOpen: boolean;
106+
}>;
107+
108+
type PopoverTriggerProps = Readonly<
109+
Omit<HTMLAttributes<HTMLElement>, "children"> & {
110+
children:
111+
| TriggerElement
112+
| ((props: PopoverTriggerRenderProps) => TriggerElement);
113+
}
114+
>;
115+
116+
export const PopoverTrigger: FC<PopoverTriggerProps> = (props) => {
94117
const popover = usePopover();
95-
const { children, ...elementProps } = props;
118+
const { children, onClick, onPointerEnter, onPointerLeave, ...elementProps } =
119+
props;
96120

97121
const clickProps = {
98-
onClick: () => {
122+
onClick: (event: PointerEvent<HTMLElement>) => {
99123
popover.setOpen(true);
124+
onClick?.(event);
100125
},
101126
};
102127

103128
const hoverProps = {
104-
onPointerEnter: () => {
129+
onPointerEnter: (event: PointerEvent<HTMLElement>) => {
105130
popover.setOpen(true);
131+
onPointerEnter?.(event);
106132
},
107-
onPointerLeave: () => {
133+
onPointerLeave: (event: PointerEvent<HTMLElement>) => {
108134
popover.setOpen(false);
135+
onPointerLeave?.(event);
109136
},
110137
};
111138

112-
return cloneElement(props.children, {
139+
const evaluatedChildren =
140+
typeof children === "function"
141+
? children({ isOpen: popover.open })
142+
: children;
143+
144+
return cloneElement(evaluatedChildren, {
113145
...elementProps,
114146
...(popover.mode === "click" ? clickProps : hoverProps),
115147
"aria-haspopup": true,
@@ -130,6 +162,8 @@ export type PopoverContentProps = Omit<
130162

131163
export const PopoverContent: FC<PopoverContentProps> = ({
132164
horizontal = "left",
165+
onPointerEnter,
166+
onPointerLeave,
133167
...popoverProps
134168
}) => {
135169
const popover = usePopover();
@@ -152,7 +186,7 @@ export const PopoverContent: FC<PopoverContentProps> = ({
152186
},
153187
}}
154188
{...horizontalProps(horizontal)}
155-
{...modeProps(popover)}
189+
{...modeProps(popover, onPointerEnter, onPointerLeave)}
156190
{...popoverProps}
157191
id={popover.id}
158192
open={popover.open}
@@ -162,14 +196,20 @@ export const PopoverContent: FC<PopoverContentProps> = ({
162196
);
163197
};
164198

165-
const modeProps = (popover: PopoverContextValue) => {
199+
const modeProps = (
200+
popover: PopoverContextValue,
201+
externalOnPointerEnter: PointerEventHandler<HTMLDivElement> | undefined,
202+
externalOnPointerLeave: PointerEventHandler<HTMLDivElement> | undefined,
203+
) => {
166204
if (popover.mode === "hover") {
167205
return {
168-
onPointerEnter: () => {
206+
onPointerEnter: (event: PointerEvent<HTMLDivElement>) => {
169207
popover.setOpen(true);
208+
externalOnPointerEnter?.(event);
170209
},
171-
onPointerLeave: () => {
210+
onPointerLeave: (event: PointerEvent<HTMLDivElement>) => {
172211
popover.setOpen(false);
212+
externalOnPointerLeave?.(event);
173213
},
174214
};
175215
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy