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; } /** 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