From abddd6449789766867fbac34983d123602ccb416 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 5 Apr 2022 14:01:45 -0300 Subject: [PATCH] feat: Add sidebar and panel (#866) * feat: Add sidebar and panel * fix: fix sidebar values --- site/src/components/Panel/Panel.stories.tsx | 21 + site/src/components/Panel/index.tsx | 102 ++++ .../components/Sidebar/Sidebar.stories.tsx | 20 + site/src/components/Sidebar/index.tsx | 104 ++++ site/src/theme/overrides.ts | 562 ++++++++++++++++++ site/src/theme/props.ts | 57 ++ site/src/theme/{theme.tsx => theme.ts} | 8 + site/src/util/combine-classes.ts | 42 ++ 8 files changed, 916 insertions(+) create mode 100644 site/src/components/Panel/Panel.stories.tsx create mode 100644 site/src/components/Panel/index.tsx create mode 100644 site/src/components/Sidebar/Sidebar.stories.tsx create mode 100644 site/src/components/Sidebar/index.tsx create mode 100644 site/src/theme/overrides.ts create mode 100644 site/src/theme/props.ts rename site/src/theme/{theme.tsx => theme.ts} (60%) create mode 100644 site/src/util/combine-classes.ts diff --git a/site/src/components/Panel/Panel.stories.tsx b/site/src/components/Panel/Panel.stories.tsx new file mode 100644 index 0000000000..9db07820d9 --- /dev/null +++ b/site/src/components/Panel/Panel.stories.tsx @@ -0,0 +1,21 @@ +import { Story } from "@storybook/react" +import React from "react" +import { Panel, PanelProps } from "./" + +export default { + title: "Page/Panel", + component: Panel, +} + +const Template: Story = (args: PanelProps) => + +export const Example = Template.bind({}) +Example.args = { + title: "Panel title", + activeTab: "oauthSettings", + menuItems: [ + { label: "OAuth Settings", value: "oauthSettings" }, + { label: "Security", value: "oauthSettings", hasChanges: true }, + { label: "Hardware", value: "oauthSettings" }, + ], +} diff --git a/site/src/components/Panel/index.tsx b/site/src/components/Panel/index.tsx new file mode 100644 index 0000000000..d4294d82fa --- /dev/null +++ b/site/src/components/Panel/index.tsx @@ -0,0 +1,102 @@ +import { makeStyles } from "@material-ui/core/styles" +import { fade } from "@material-ui/core/styles/colorManipulator" +import React from "react" +import { Sidebar, SidebarItem } from "../Sidebar" + +export type AdminMenuItemCallback = (menuItem: string) => void + +export interface PanelProps { + title: string + menuItems: SidebarItem[] + activeTab: string + onSelect: AdminMenuItemCallback +} + +export const Panel: React.FC = ({ children, title, menuItems, activeTab, onSelect }) => { + const styles = useStyles() + + return ( +
+
+
+
{title}
+ +
+ +
{children}
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + minHeight: 400, + marginBottom: theme.spacing(2), + // Prevents scrollbar jitter from long content + marginRight: "calc(-1 * (100vw - 100%))", + }, + + inner: { + display: "flex", + maxWidth: 1920, + padding: theme.spacing(5, 3.5, 0, 4), + }, + + icon: { + fontSize: 100, + position: "absolute", + left: -50, + top: 31, + color: fade(theme.palette.common.black, 0.1), + transition: "transform 0.3s ease", + zIndex: -1, + }, + + menuPanel: { + paddingRight: 40, + }, + + title: { + marginTop: theme.spacing(4), + fontSize: 32, + letterSpacing: -theme.spacing(0.0375), + }, + + contentPanel: { + display: "flex", + flexDirection: "column", + width: "100%", + maxWidth: 930, + }, + + [theme.breakpoints.up("lg")]: { + icon: { + position: "relative", + top: -11, + left: 30, + }, + contentPanel: { + width: 930, + }, + }, + + [theme.breakpoints.down("lg")]: { + contentPanel: { + width: 890, + }, + }, + [theme.breakpoints.down("md")]: { + contentPanel: { + width: 700, + }, + }, + [theme.breakpoints.down("sm")]: { + contentPanel: { + width: 550, + }, + root: { + marginRight: 0, //disabled scrollbar jump trick to avoid small screen bug with menu + }, + }, +})) diff --git a/site/src/components/Sidebar/Sidebar.stories.tsx b/site/src/components/Sidebar/Sidebar.stories.tsx new file mode 100644 index 0000000000..2dc682773d --- /dev/null +++ b/site/src/components/Sidebar/Sidebar.stories.tsx @@ -0,0 +1,20 @@ +import { Story } from "@storybook/react" +import React from "react" +import { Sidebar, SidebarProps } from "./" + +export default { + title: "Page/Sidebar", + component: Sidebar, +} + +const Template: Story = (args: SidebarProps) => + +export const Example = Template.bind({}) +Example.args = { + activeItem: "oauthSettings", + menuItems: [ + { label: "OAuth Settings", value: "oauthSettings" }, + { label: "Security", value: "security", hasChanges: true }, + { label: "Hardware", value: "hardware" }, + ], +} diff --git a/site/src/components/Sidebar/index.tsx b/site/src/components/Sidebar/index.tsx new file mode 100644 index 0000000000..2058621775 --- /dev/null +++ b/site/src/components/Sidebar/index.tsx @@ -0,0 +1,104 @@ +import List from "@material-ui/core/List" +import ListItem from "@material-ui/core/ListItem" +import { makeStyles } from "@material-ui/core/styles" +import React from "react" +import { combineClasses } from "../../util/combine-classes" + +export interface SidebarItem { + value: string + label: string + hasChanges?: boolean +} + +export interface SidebarProps { + menuItems: SidebarItem[] + activeItem?: string + onSelect?: (value: string) => void +} + +export const Sidebar: React.FC = ({ menuItems, activeItem, onSelect }) => { + const styles = useStyles() + + return ( + + {menuItems.map(({ hasChanges, ...tab }) => { + const isActive = activeItem === tab.value + return ( + onSelect(tab.value) : undefined} + className={styles.menuItem} + disableRipple + focusRipple={false} + component="li" + > + + {hasChanges ? `${tab.label}*` : tab.label} + + + ) + })} + + ) +} + +const useStyles = makeStyles((theme) => ({ + menu: { + minWidth: 160, + marginTop: theme.spacing(5), + }, + + menuItem: { + letterSpacing: -theme.spacing(0.0375), + padding: 0, + fontSize: 18, + color: theme.palette.text.secondary, + + "&.MuiListItem-button:hover": { + backgroundColor: "transparent", + }, + }, + + menuItemSpan: { + paddingTop: theme.spacing(1.5), + paddingBottom: theme.spacing(1), + transition: "300ms all ease", + position: "relative", + + "&:hover": { + color: theme.palette.text.primary, + }, + "&.active": { + color: theme.palette.primary.dark, + }, + + "&.active, &:hover": { + "&::before": { + opacity: 1, + }, + "&::after": { + opacity: 1, + }, + }, + }, + + [theme.breakpoints.up("lg")]: { + menuItemSpan: { + "&::before": { + content: "'{'", + position: "absolute", + left: -15, + opacity: 0, + transition: "inherit", + }, + "&::after": { + content: "'}'", + position: "absolute", + right: -15, + opacity: 0, + transition: "inherit", + }, + }, + }, +})) diff --git a/site/src/theme/overrides.ts b/site/src/theme/overrides.ts new file mode 100644 index 0000000000..a2377ecf02 --- /dev/null +++ b/site/src/theme/overrides.ts @@ -0,0 +1,562 @@ +import { darken, fade } from "@material-ui/core/styles/colorManipulator" +import { Breakpoints } from "@material-ui/core/styles/createBreakpoints" +import { + BODY_FONT_FAMILY, + borderRadius, + buttonBorderWidth, + emptyBoxShadow, + lightButtonShadow, + spacing, +} from "./constants" +import { CustomPalette } from "./palettes" +import { typography } from "./typography" + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const getOverrides = (palette: CustomPalette, breakpoints: Breakpoints) => { + const containedButtonShadow = palette.type === "dark" ? emptyBoxShadow : lightButtonShadow + return { + MuiAvatar: { + root: { + width: 32, + height: 32, + fontSize: 24, + }, + }, + MuiTable: { + root: { + backgroundColor: palette.background.paper, + borderRadius: borderRadius * 2, + boxShadow: `0 0 0 1px ${palette.action.hover}`, + + "&.is-inline": { + background: "none", + borderRadius: 0, + boxShadow: "none", + }, + }, + }, + MuiTableHead: { + root: { + "& .MuiTableCell-root": { + ...typography.overline, + paddingTop: spacing, + paddingBottom: spacing, + color: palette.text.secondary, + backgroundColor: palette.background.default, + + ".MuiTable-root.is-inline &": { + background: "none", + }, + }, + "& .MuiTableRow-root:first-child .MuiTableCell-root:first-child": { + borderTopLeftRadius: borderRadius * 2, + + ".MuiTable-root.is-inline &": { + borderRadius: 0, + }, + }, + "& .MuiTableRow-root:first-child .MuiTableCell-root:last-child": { + borderTopRightRadius: borderRadius * 2, + + ".MuiTable-root.is-inline &": { + borderRadius: 0, + }, + }, + }, + }, + MuiTableSortLabel: { + root: { + lineHeight: "20px", + + "&$active": { + "&& $icon": { + color: palette.text.primary, + }, + }, + }, + icon: { + "&.MuiSvgIcon-root": { + width: 18, + height: 18, + marginLeft: 2, + }, + }, + }, + MuiTableCell: { + root: { + "&:first-child": { + paddingLeft: spacing * 4, + }, + }, + }, + MuiTablePagination: { + input: { + height: "auto", + marginTop: 0, + border: `1px solid ${palette.action.disabled}`, + }, + + select: { + paddingTop: 6, + fontSize: 14, + }, + + selectIcon: { + right: -4, + }, + }, + MuiList: { + root: { + boxSizing: "border-box", + + "& .MuiDivider-root": { + opacity: 0.5, + }, + }, + }, + MuiListItem: { + root: { + minHeight: 24, + position: "relative", + "&.active": { + color: "white", + }, + }, + gutters: { + padding: spacing / 2, + paddingLeft: spacing * 2, + paddingRight: spacing * 2, + }, + }, + MuiListItemAvatar: { + root: { + minWidth: 46, + }, + }, + MuiListItemIcon: { + root: { + minWidth: 36, + color: palette.text.primary, + + "& .MuiSvgIcon-root": { + width: 20, + height: 20, + }, + }, + }, + MuiListSubheader: { + root: { + ...typography.overline, + lineHeight: 2.5, + }, + sticky: { + backgroundColor: palette.background.paper, + }, + }, + MuiMenu: { + paper: { + marginTop: spacing, + }, + }, + MuiMenuItem: { + root: { + minHeight: 48, + fontSize: 16, + [breakpoints.up("sm")]: { + minHeight: 48, + }, + }, + gutters: { + padding: spacing, + paddingLeft: spacing * 2, + paddingRight: spacing * 2, + }, + }, + MuiListItemText: { + primary: { + fontWeight: 500, + }, + secondary: { + fontSize: 12, + }, + }, + MuiOutlinedInput: { + root: { + fontSize: 14, + "&$focused $notchedOutline": { + borderWidth: 1, + }, + }, + notchedOutline: { + borderWidth: 1, + borderColor: palette.action.disabled, + }, + }, + MuiButton: { + root: { + minHeight: 40, + paddingLeft: 20, + paddingRight: 20, + fontWeight: 500, + + "& .MuiSvgIcon-root": { + verticalAlign: "middle", + }, + }, + + contained: { + backgroundColor: palette.hero.button, + color: palette.primary.contrastText, + boxShadow: containedButtonShadow, + "&:hover": { + backgroundColor: darken(palette.hero.button, 0.25), + }, + "&$disabled": { + color: fade(palette.text.disabled, 0.5), + }, + }, + containedPrimary: { + boxShadow: containedButtonShadow, + }, + containedSecondary: { + boxShadow: containedButtonShadow, + }, + + outlined: { + borderColor: palette.action.disabled, + borderWidth: buttonBorderWidth, + "&:hover": { + color: palette.primary.main, + borderColor: palette.primary.main, + borderWidth: buttonBorderWidth, + }, + "&$disabled": { + color: fade(palette.text.disabled, 0.5), + }, + }, + outlinedPrimary: { + borderColor: palette.primary.main, + borderWidth: buttonBorderWidth, + "&:hover": { + borderWidth: buttonBorderWidth, + }, + }, + outlinedSecondary: { + borderColor: palette.secondary.main, + borderWidth: buttonBorderWidth, + "&:hover": { + color: palette.secondary.main, + borderWidth: buttonBorderWidth, + }, + }, + + text: { + "&$disabled": { + color: fade(palette.text.disabled, 0.5), + }, + }, + + sizeSmall: { + minHeight: 32, + paddingLeft: 10, + paddingRight: 10, + letterSpacing: 1.2, + fontSize: 13, + + "&.MuiButton-outlined": { + borderWidth: 1, + }, + }, + sizeLarge: { + minHeight: 46, + paddingLeft: spacing * 3, + paddingRight: spacing * 3, + }, + }, + MuiButtonGroup: { + contained: { + boxShadow: containedButtonShadow, + }, + }, + MuiLink: { + root: { + fontWeight: 600, + }, + }, + MuiInputBase: { + root: { + minHeight: 40, + background: palette.background.paper, + marginTop: "12px", + borderRadius, + + "&$disabled": { + background: palette.action.disabledBackground, + }, + + "&$focused .MuiSelect-icon": { + color: palette.primary.light, + }, + }, + + input: { + fontSize: 16, + + "&::placeholder": { + color: palette.text.secondary, + opacity: 1, + }, + + // See this thread https://stackoverflow.com/questions/69196525/how-to-change-the-color-of-the-calendar-icon-of-the-textfield-date-picker-in-ma + "&::-webkit-calendar-picker-indicator": { + filter: + palette.type === "dark" + ? "invert(95%) sepia(0%) saturate(1082%) hue-rotate(173deg) brightness(84%) contrast(80%);" + : undefined, + }, + }, + }, + MuiInputLabel: { + shrink: { + fontSize: 14, + marginTop: "2px", + left: "-12px", + transform: "translate(16px, -6px) scale(0.8)", + color: palette.text.primary, + }, + outlined: { + letterSpacing: "0.2px", + lineHeight: "16px", + fontFamily: BODY_FONT_FAMILY, + + "&$shrink": { + transform: "translate(14px, -13px)", + }, + }, + }, + MuiChip: { + root: { + fontSize: 16, + fontWeight: 500, + borderRadius: borderRadius, + backgroundColor: fade(palette.text.secondary, 0.1), + color: palette.text.secondary, + }, + sizeSmall: { + height: 20, + }, + labelSmall: { + fontSize: 12, + paddingLeft: 6, + paddingRight: 6, + }, + colorPrimary: { + backgroundColor: fade(palette.primary.main, 0.1), + color: palette.primary.main, + }, + colorSecondary: { + backgroundColor: fade(palette.secondary.main, 0.15), + color: palette.secondary.main, + }, + }, + MuiSelect: { + root: { + // Matches MuiInputBase-input height + lineHeight: "1.1875em", + overflow: "hidden", + }, + select: { + "&:focus": { + borderRadius, + }, + }, + icon: { + "&.MuiSvgIcon-root": { + fontSize: 20, + margin: "3px 6px 0 0", + }, + }, + iconOutlined: { + right: 0, + }, + iconOpen: { + transform: "none", + }, + }, + + MuiDialog: { + paper: { + borderRadius: 11, + }, + paperWidthSm: { + maxWidth: "530px", + }, + }, + MuiDialogTitle: { + root: { + color: palette.text.primary, + padding: `27px ${spacing * 6}px 0 ${spacing * 6}px`, + fontSize: 32, + fontWeight: 700, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + background: palette.background.paper, + }, + }, + MuiDialogContent: { + root: { + padding: `0 ${spacing * 6}px ${spacing * 6}px ${spacing * 6}px`, + }, + }, + MuiDialogContentText: { + root: { + color: palette.action.active, + }, + }, + MuiDialogActions: { + root: { + padding: 0, + }, + spacing: { + "& > :not(:first-child)": { + marginLeft: 0, + }, + }, + }, + MuiFormHelperText: { + root: { + fontSize: 12, + lineHeight: "16px", + }, + contained: { + marginTop: 6, + marginLeft: spacing * 0.5, + }, + }, + MuiBackdrop: { + root: { + backgroundColor: fade("#242424", 0.6), + }, + }, + MuiInputAdornment: { + root: { + "& .MuiSvgIcon-root": { + color: palette.text.secondary, + }, + }, + positionStart: { + marginRight: 12, + marginLeft: 6, + }, + }, + MuiCheckbox: { + root: { + "& .MuiSvgIcon-root": { + width: 16, + height: 16, + margin: 1, + }, + }, + colorPrimary: { + "&.MuiCheckbox-root": { + color: palette.primary.main, + "&$disabled": { + color: palette.action.disabled, + }, + }, + }, + colorSecondary: { + "&.MuiCheckbox-root": { + color: palette.secondary.main, + "&$disabled": { + color: palette.action.disabled, + }, + }, + }, + }, + MuiRadio: { + root: { + "& .MuiSvgIcon-root": { + width: 18, + height: 18, + }, + }, + colorPrimary: { + "&.MuiRadio-root": { + color: palette.primary.main, + "&$disabled": { + color: palette.action.disabled, + }, + }, + }, + colorSecondary: { + "&.MuiRadio-root": { + color: palette.secondary.main, + "&$disabled": { + color: palette.action.disabled, + }, + }, + }, + }, + MuiTabs: { + root: { + minHeight: 40, + }, + indicator: { + height: 3, + }, + }, + MuiTab: { + root: { + minHeight: 70, + marginRight: spacing * 5, + textTransform: "none", + fontWeight: 400, + fontSize: 16, + letterSpacing: 3, + + "&:last-child": { + marginRight: 0, + }, + + "&.MuiButtonBase-root": { + minWidth: 0, + padding: 0, + }, + + "&.Mui-selected .MuiTab-wrapper": { + color: palette.primary.contrastText, + fontWeight: "bold", + }, + }, + wrapper: {}, + textColorPrimary: { + color: palette.text.secondary, + }, + textColorSecondary: { + color: palette.text.secondary, + }, + }, + MuiStepper: { + root: { + background: "none", + }, + }, + // Labs components aren't typed properly, so cast overrides + MuiAutocomplete: { + endAdornment: { + "& .MuiSvgIcon-root": { + fontSize: 20, + }, + }, + inputRoot: { + '&[class*="MuiOutlinedInput-root"]': { + "& $endAdornment": { + right: 6, + }, + }, + }, + }, + } +} diff --git a/site/src/theme/props.ts b/site/src/theme/props.ts new file mode 100644 index 0000000000..57738409bf --- /dev/null +++ b/site/src/theme/props.ts @@ -0,0 +1,57 @@ +import { ComponentsProps } from "@material-ui/core/styles/props" + +/** + * These are global overrides to MUI components and we may move away from using them in the future. + */ +export const props = { + MuiButton: { + variant: "contained", + }, + MuiTextField: { + margin: "dense", + InputProps: { + labelWidth: 0, + }, + variant: "outlined", + spellCheck: false, + }, + MuiInputLabel: { + shrink: true, + }, + MuiFormControl: { + variant: "outlined", + margin: "dense", + }, + MuiInput: { + spellCheck: false, + autoCorrect: "off", + }, + MuiOutlinedInput: { + notched: false, + }, + MuiDialogTitle: { + disableTypography: true, + }, + MuiMenu: { + anchorOrigin: { + vertical: "bottom", + horizontal: "right", + }, + transformOrigin: { + vertical: "top", + horizontal: "right", + }, + // Disable the behavior of placing the select at the selected option + getContentAnchorEl: null, + }, + MuiList: { + disablePadding: true, + }, + MuiTabs: { + textColor: "primary", + indicatorColor: "primary", + }, + MuiTab: { + disableTouchRipple: true, + }, +} as ComponentsProps diff --git a/site/src/theme/theme.tsx b/site/src/theme/theme.ts similarity index 60% rename from site/src/theme/theme.tsx rename to site/src/theme/theme.ts index d2c998ead3..e17c25bafc 100644 --- a/site/src/theme/theme.tsx +++ b/site/src/theme/theme.ts @@ -1,15 +1,23 @@ import { createMuiTheme } from "@material-ui/core/styles" +import { Overrides } from "@material-ui/core/styles/overrides" import { borderRadius } from "./constants" +import { getOverrides } from "./overrides" import { CustomPalette, darkPalette, lightPalette } from "./palettes" +import { props } from "./props" import { typography } from "./typography" const makeTheme = (palette: CustomPalette) => { + // Grab defaults to re-use in overrides + const { breakpoints } = createMuiTheme() + return createMuiTheme({ palette, typography, shape: { borderRadius, }, + props, + overrides: getOverrides(palette, breakpoints) as Overrides, }) } diff --git a/site/src/util/combine-classes.ts b/site/src/util/combine-classes.ts new file mode 100644 index 0000000000..3fca6af5db --- /dev/null +++ b/site/src/util/combine-classes.ts @@ -0,0 +1,42 @@ +export const appendCSSString = (cssString: string, cssClass: string): string => { + if (cssString === "") { + return cssClass + } + return `${cssString} ${cssClass}` +} + +/** + * @param classes May be an object or an array. When using an object, each key + * is a css class name and its associated value indicates whether it should be + * applied. When using an array, only truthy values are applied. + * @returns A string with each class that should be included. Classes are + * separated by two spaces. If no classes are included, returns `undefined`. + * @example + * combineClasses() // -> undefined + * combineClasses({ text: true }) // -> "text" + * combineClasses({ text: true, success: true }) // -> "text success" + * combineClasses({ text: true, success: false }) // -> "text" + * combineClasses([ "text", false, "" ]) // -> "text" + */ +export const combineClasses = ( + classes: Array | Record = {}, +): string | undefined => { + let result = "" + + if (Array.isArray(classes)) { + for (const cssClass of classes) { + if (cssClass) { + result = appendCSSString(result, cssClass) + } + } + } else { + for (const cssClass in classes) { + const useClass = classes[cssClass] + if (useClass) { + result = appendCSSString(result, cssClass) + } + } + } + + return result.length ? result : undefined +}