diff --git a/site/src/components/FeatureBadge/FeatureBadge.stories.tsx b/site/src/components/FeatureBadge/FeatureBadge.stories.tsx new file mode 100644 index 0000000000000..714b461ea4140 --- /dev/null +++ b/site/src/components/FeatureBadge/FeatureBadge.stories.tsx @@ -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 = { + title: "components/FeatureBadge", + component: FeatureBadge, + args: { + type: "beta", + }, +}; + +export default meta; +type Story = StoryObj; + +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 ( + + ); + }, +}; diff --git a/site/src/components/FeatureBadge/FeatureBadge.tsx b/site/src/components/FeatureBadge/FeatureBadge.tsx new file mode 100644 index 0000000000000..75b7c15045cde --- /dev/null +++ b/site/src/components/FeatureBadge/FeatureBadge.tsx @@ -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; + +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>; + +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, "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 = ({ + 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 = ( + + (This is a + {featureType} + feature) + + ); + + if (variant !== "interactive") { + return coreContent; + } + + return ( + + { + setIsBadgeHovering(true); + onPointerEnter?.(event); + }} + onPointerLeave={(event) => { + setIsBadgeHovering(false); + onPointerLeave?.(event); + }} + > + {coreContent} + + + setIsTooltipHovering(true)} + onPointerLeave={() => setIsTooltipHovering(false)} + > +
+ {capitalizeFirstLetter(featureType)} Feature +
+ +

+ This is {grammaticalArticle(featureType)} {featureType} feature. It + has not yet reached generally availability (GA). +

+ + + Learn about feature stages + (link opens in new tab) + +
+
+ ); +}; diff --git a/site/src/components/Popover/Popover.tsx b/site/src/components/Popover/Popover.tsx index 7db3c4eda1799..8b4479d95b3a4 100644 --- a/site/src/components/Popover/Popover.tsx +++ b/site/src/components/Popover/Popover.tsx @@ -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, @@ -95,17 +97,20 @@ export const PopoverTrigger = ( const { children, ...elementProps } = props; const clickProps = { - onClick: () => { + onClick: (event: PointerEvent) => { popover.setOpen(true); + elementProps.onClick?.(event); }, }; const hoverProps = { - onPointerEnter: () => { + onPointerEnter: (event: PointerEvent) => { popover.setOpen(true); + elementProps.onPointerEnter?.(event); }, - onPointerLeave: () => { + onPointerLeave: (event: PointerEvent) => { popover.setOpen(false); + elementProps.onPointerLeave?.(event); }, }; @@ -130,6 +135,8 @@ export type PopoverContentProps = Omit< export const PopoverContent: FC = ({ horizontal = "left", + onPointerEnter, + onPointerLeave, ...popoverProps }) => { const popover = usePopover(); @@ -152,7 +159,7 @@ export const PopoverContent: FC = ({ }, }} {...horizontalProps(horizontal)} - {...modeProps(popover)} + {...modeProps(popover, onPointerEnter, onPointerLeave)} {...popoverProps} id={popover.id} open={popover.open} @@ -162,14 +169,20 @@ export const PopoverContent: FC = ({ ); }; -const modeProps = (popover: PopoverContextValue) => { +const modeProps = ( + popover: PopoverContextValue, + externalOnPointerEnter: PointerEventHandler | undefined, + externalOnPointerLeave: PointerEventHandler | undefined, +) => { if (popover.mode === "hover") { return { - onPointerEnter: () => { + onPointerEnter: (event: PointerEvent) => { popover.setOpen(true); + externalOnPointerEnter?.(event); }, - onPointerLeave: () => { + onPointerLeave: (event: PointerEvent) => { popover.setOpen(false); + externalOnPointerLeave?.(event); }, }; } diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx index a4e520c2b7137..6559e31723156 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx @@ -175,23 +175,23 @@ const ThemePreview: FC = ({
-
-
-
+
+
+
-
-
+
+
-
+
-
-
-
-
-
+
+
+
+
+
diff --git a/site/src/theme/dark/roles.ts b/site/src/theme/dark/roles.ts index 32a9ea4f12992..dfefd1d10909b 100644 --- a/site/src/theme/dark/roles.ts +++ b/site/src/theme/dark/roles.ts @@ -1,7 +1,7 @@ import type { Roles } from "../roles"; import colors from "../tailwindColors"; -export default { +const roles: Roles = { danger: { background: colors.orange[950], outline: colors.orange[500], @@ -143,13 +143,35 @@ export default { }, }, preview: { - background: colors.violet[950], - outline: colors.violet[500], - text: colors.violet[50], + background: colors.cyan[950], + outline: colors.cyan[600], + text: colors.cyan[400], fill: { - solid: colors.violet[400], - outline: colors.violet[400], + solid: colors.cyan[400], + outline: colors.cyan[400], text: colors.white, }, + hover: { + background: colors.zinc[950], + outline: colors.cyan[500], + text: colors.cyan[300], + fill: { + text: colors.white, + outline: colors.cyan[600], + solid: colors.cyan[600], + }, + }, + disabled: { + background: colors.zinc[950], + outline: colors.cyan[500], + text: colors.cyan[300], + fill: { + text: colors.white, + outline: colors.cyan[600], + solid: colors.cyan[600], + }, + }, }, -} satisfies Roles; +}; + +export default roles; diff --git a/site/src/theme/darkBlue/roles.ts b/site/src/theme/darkBlue/roles.ts index 744b7329249b9..a0f36daa810b9 100644 --- a/site/src/theme/darkBlue/roles.ts +++ b/site/src/theme/darkBlue/roles.ts @@ -1,7 +1,7 @@ import type { Roles } from "../roles"; import colors from "../tailwindColors"; -export default { +const roles: Roles = { danger: { background: colors.orange[950], outline: colors.orange[500], @@ -143,13 +143,35 @@ export default { }, }, preview: { - background: colors.violet[950], - outline: colors.violet[500], - text: colors.violet[50], + background: colors.cyan[950], + outline: colors.cyan[600], + text: colors.cyan[400], fill: { - solid: colors.violet[400], - outline: colors.violet[400], + solid: colors.cyan[400], + outline: colors.cyan[400], text: colors.white, }, + hover: { + background: colors.zinc[950], + outline: colors.cyan[500], + text: colors.cyan[300], + fill: { + text: colors.white, + outline: colors.cyan[600], + solid: colors.cyan[600], + }, + }, + disabled: { + background: colors.zinc[950], + outline: colors.cyan[500], + text: colors.cyan[300], + fill: { + text: colors.white, + outline: colors.cyan[600], + solid: colors.cyan[600], + }, + }, }, -} satisfies Roles; +}; + +export default roles; diff --git a/site/src/theme/light/roles.ts b/site/src/theme/light/roles.ts index fe3d1d9687bfa..905d45384f247 100644 --- a/site/src/theme/light/roles.ts +++ b/site/src/theme/light/roles.ts @@ -1,7 +1,7 @@ import type { Roles } from "../roles"; import colors from "../tailwindColors"; -export default { +const roles: Roles = { danger: { background: colors.orange[50], outline: colors.orange[400], @@ -143,13 +143,35 @@ export default { }, }, preview: { - background: colors.violet[50], - outline: colors.violet[500], - text: colors.violet[950], + background: colors.cyan[100], + outline: colors.cyan[500], + text: colors.cyan[950], fill: { - solid: colors.violet[600], - outline: colors.violet[600], + solid: colors.cyan[600], + outline: colors.cyan[600], text: colors.white, }, + hover: { + background: colors.cyan[50], + outline: colors.cyan[600], + text: colors.cyan[950], + fill: { + outline: colors.cyan[500], + solid: colors.cyan[500], + text: colors.white, + }, + }, + disabled: { + background: colors.cyan[50], + outline: colors.cyan[800], + text: colors.cyan[200], + fill: { + solid: colors.cyan[800], + outline: colors.cyan[800], + text: colors.white, + }, + }, }, -} satisfies Roles; +}; + +export default roles; diff --git a/site/src/theme/roles.ts b/site/src/theme/roles.ts index 13d54f8840d20..87620bd43ef8c 100644 --- a/site/src/theme/roles.ts +++ b/site/src/theme/roles.ts @@ -36,7 +36,7 @@ export interface Roles { /** This isn't quite ready for prime-time, but you're welcome to look around! * Preview features, experiments, unstable, etc. */ - preview: Role; + preview: InteractiveRole; } /**