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

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)