Browse Source

feat(ui): Storybook - Add theme switcher & noBorder option to <Sample /> (#29765)

* feat(ui): Storybook - Add theme switcher & noBorder option to <Sample />

Adding two optional props to <Sample />:
 - noBorder: remove border and padding around the wrapper
 - showThemeSwitcher: add a toggle for switching between light and dark mode. Sample now provides a local theme context to automatically update the styles of all of its children.

* Update sample.tsx

Based on @billyvg's comments in #29765.
Vu Luong 3 years ago
parent
commit
96dd778bf7
3 changed files with 136 additions and 4 deletions
  1. 115 4
      docs-ui/components/sample.tsx
  2. 20 0
      static/app/icons/iconMoon.tsx
  3. 1 0
      static/app/icons/index.tsx

+ 115 - 4
docs-ui/components/sample.tsx

@@ -1,12 +1,89 @@
+import {createContext, ReactChild, useState} from 'react';
+import {ThemeProvider, useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 
+import {IconMoon} from 'app/icons';
 import space from 'app/styles/space';
+import {darkTheme, lightTheme, Theme} from 'app/utils/theme';
 
-const Sample = styled('div')`
+type ThemeName = 'dark' | 'light';
+
+type Props = {
+  children?: ReactChild;
+  /**
+   * Show the theme switcher, which allows for
+   * switching the local theme context between
+   * light and dark mode. Useful for previewing
+   * components in both modes.
+   */
+  showThemeSwitcher?: boolean;
+  /** Remove the outer border and padding */
+  noBorder?: boolean;
+};
+
+/** Expose the selected theme to children of <Sample /> */
+export const SampleThemeContext = createContext<ThemeName>('light');
+
+const Sample = ({children, showThemeSwitcher = false, noBorder = false}: Props) => {
+  const [themeName, setThemeName] = useState<ThemeName>('light');
+
+  /**
+   * If theme switcher is shown, use the correct theme object based on themeName.
+   * Else, fall back to the global theme object.
+   */
+  const [theme, setTheme] = useState<Theme>(
+    showThemeSwitcher ? (themeName === 'light' ? lightTheme : darkTheme) : useTheme()
+  );
+
+  const toggleTheme = () => {
+    if (themeName === 'light') {
+      setThemeName('dark');
+      setTheme(darkTheme);
+    } else {
+      setThemeName('light');
+      setTheme(lightTheme);
+    }
+  };
+
+  return (
+    <Wrap>
+      {showThemeSwitcher && (
+        <ThemeSwitcher onClick={toggleTheme} active={themeName === 'dark'}>
+          <IconMoon />
+        </ThemeSwitcher>
+      )}
+      <ThemeProvider theme={theme}>
+        <InnerWrap noBorder={noBorder} addTopMargin={showThemeSwitcher}>
+          <SampleThemeContext.Provider value={themeName}>
+            {children}
+          </SampleThemeContext.Provider>
+        </InnerWrap>
+      </ThemeProvider>
+    </Wrap>
+  );
+};
+
+export default Sample;
+
+const Wrap = styled('div')`
+  position: relative;
+`;
+
+const InnerWrap = styled('div')<{noBorder: boolean; addTopMargin: boolean}>`
+  position: relative;
   border-radius: ${p => p.theme.borderRadius};
-  border: dashed 1px ${p => p.theme.border};
-  padding: ${space(1)} ${space(2)};
   margin: ${space(2)} 0;
+  color: ${p => p.theme.textColor};
+
+  ${p =>
+    !p.noBorder &&
+    `
+    border: solid 1px ${p.theme.border};
+    background: ${p.theme.background};
+    padding: ${space(2)} ${space(2)};
+    `}
+
+  ${p => p.addTopMargin && `margin-top: calc(${space(4)} + ${space(2)});`}
 
   & > *:first-of-type {
     margin-top: 0;
@@ -15,6 +92,40 @@ const Sample = styled('div')`
   & > *:last-of-type {
     margin-bottom: 0;
   }
+
+  /* Overwrite text color that was set in previewGlobalStyles.tsx */
+  div,
+  p,
+  a,
+  button {
+    color: ${p => p.theme.textColor};
+  }
 `;
 
-export default Sample;
+const ThemeSwitcher = styled('button')<{active: boolean}>`
+  position: absolute;
+  top: 0;
+  right: ${space(0.5)};
+  transform: translateY(calc(-100% - ${space(0.5)}));
+  border: none;
+  border-radius: ${p => p.theme.borderRadius};
+  background: transparent;
+
+  display: flex;
+  align-items: center;
+  padding: ${space(1)};
+  margin-bottom: ${space(0.5)};
+  color: ${p => p.theme.subText};
+
+  &:hover {
+    background: ${p => p.theme.innerBorder};
+    color: ${p => p.theme.textColor};
+  }
+
+  ${p =>
+    p.active &&
+    `&, &:hover {
+      color: ${p.theme.textColor};
+    }
+    `}
+`;

+ 20 - 0
static/app/icons/iconMoon.tsx

@@ -0,0 +1,20 @@
+import * as React from 'react';
+
+import SvgIcon from './svgIcon';
+
+type Props = React.ComponentProps<typeof SvgIcon>;
+
+const IconMoon = React.forwardRef(function IconMoon(
+  props: Props,
+  ref: React.Ref<SVGSVGElement>
+) {
+  return (
+    <SvgIcon {...props} ref={ref}>
+      <path d="M7.9 15.7C3.5 15.7 0 12.2 0 7.8C0 7.6 0 7.3 0 7.1C0 6.9 0.1 6.7 0.3 6.5C0.6 6.3 0.9 6.3 1.2 6.5C1.3 6.6 1.3 6.6 1.4 6.7C2.1 7.7 3.3 8.3 4.5 8.3C6.6 8.3 8.3 6.6 8.3 4.5C8.3 3.3 7.7 2.1 6.7 1.4C6.4 1.2 6.3 0.9 6.4 0.6C6.5 0.3 6.7 0.1 7 0C7.3 0 7.5 0 7.8 0C12.2 0 15.7 3.5 15.7 7.9C15.7 12.3 12.3 15.7 7.9 15.7ZM1.6 8.8C2.1 11.8 4.8 14.1 7.9 14.1C11.4 14.1 14.3 11.2 14.3 7.7C14.3 4.5 12 1.9 8.9 1.4C9.5 2.3 9.8 3.3 9.8 4.3C9.8 7.2 7.4 9.6 4.5 9.6C3.5 9.7 2.5 9.4 1.6 8.8Z" />
+    </SvgIcon>
+  );
+});
+
+IconMoon.displayName = 'IconMoon';
+
+export {IconMoon};

+ 1 - 0
static/app/icons/index.tsx

@@ -57,6 +57,7 @@ export {IconMarkdown} from './iconMarkdown';
 export {IconMegaphone} from './iconMegaphone';
 export {IconMenu} from './iconMenu';
 export {IconMobile} from './iconMobile';
+export {IconMoon} from './iconMoon';
 export {IconMute} from './iconMute';
 export {IconNext} from './iconNext';
 export {IconNot} from './iconNot';