From e834d9c9a721af6ff53c6c79e595ffe776f2a9f6 Mon Sep 17 00:00:00 2001 From: Klink <85062+dogmar@users.noreply.github.com> Date: Fri, 16 Jun 2023 08:19:40 -0700 Subject: [PATCH] feat: Light theme (#495) --- .storybook/preview-head.html | 5 + .storybook/preview.ts | 45 +- package.json | 2 + src/GlobalStyle.tsx | 79 +- src/ThemeDecorator.tsx | 25 +- src/components/AppList.tsx | 16 +- src/components/Highlight.tsx | 28 +- src/components/InlineCode.tsx | 8 +- src/components/contexts/ColorModeProvider.tsx | 32 + src/index.ts | 11 +- src/stories/Card.stories.tsx | 57 +- src/stories/Colors.tsx | 123 ++ src/stories/FilledBox.tsx | 9 + src/stories/FlexWrap.tsx | 7 + src/stories/ItemLabel.tsx | 6 + src/stories/Typography.tsx | 147 ++ src/stories/_SemanticSystem.stories.tsx | 251 +-- src/theme.tsx | 1390 +++++++++-------- src/theme/borders.ts | 14 +- src/theme/boxShadows.ts | 37 +- src/theme/colors-base.ts | 111 ++ src/theme/colors-cloudshell-dark.ts | 22 + src/theme/colors-cloudshell-light.ts | 1 + src/theme/colors-codeblock-dark.ts | 19 + src/theme/colors-codeblock-light.ts | 1 + src/theme/colors-semantic-dark.ts | 116 ++ src/theme/colors-semantic-light.ts | 125 ++ src/theme/colors.ts | 193 +-- src/theme/editor.ts | 2 +- src/theme/focus.ts | 58 +- src/theme/marketingText.ts | 42 +- src/theme/scrollBar.ts | 10 +- src/theme/spacing.ts | 49 +- src/theme/text.ts | 10 +- src/types/fromEntries.d.ts | 17 + src/utils/ts-utils.ts | 81 + yarn.lock | 27 + 37 files changed, 1949 insertions(+), 1227 deletions(-) create mode 100644 src/components/contexts/ColorModeProvider.tsx create mode 100644 src/stories/Colors.tsx create mode 100644 src/stories/FilledBox.tsx create mode 100644 src/stories/FlexWrap.tsx create mode 100644 src/stories/ItemLabel.tsx create mode 100644 src/stories/Typography.tsx create mode 100644 src/theme/colors-base.ts create mode 100644 src/theme/colors-cloudshell-dark.ts create mode 100644 src/theme/colors-cloudshell-light.ts create mode 100644 src/theme/colors-codeblock-dark.ts create mode 100644 src/theme/colors-codeblock-light.ts create mode 100644 src/theme/colors-semantic-dark.ts create mode 100644 src/theme/colors-semantic-light.ts create mode 100644 src/types/fromEntries.d.ts create mode 100644 src/utils/ts-utils.ts diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index e8c10bd6..fade2006 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -77,6 +77,11 @@ font-weight: 600; } + diff --git a/.storybook/preview.ts b/.storybook/preview.ts index f4f7fcbb..b27892f9 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,24 +1,45 @@ import * as jest from 'jest-mock' -import ThemeDecorator from '../src/ThemeDecorator' +import { type Preview } from '@storybook/react' + +import themeDecorator from '../src/ThemeDecorator' +import { COLOR_MODES, DEFAULT_COLOR_MODE } from '../src/theme' // @ts-expect-error window.jest = jest -export const parameters = { - layout: 'fullscreen', - actions: { argTypesRegex: '^on[A-Z].*' }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, +const preview: Preview = { + parameters: { + layout: 'fullscreen', + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + options: { + storySort: { + order: ['Semantic System', '*'], + }, }, }, - options: { - storySort: { - order: ['Semantic System', '*'], + globalTypes: { + theme: { + description: 'Global theme for components', + defaultValue: DEFAULT_COLOR_MODE, + toolbar: { + // The label to show for this toolbar item + title: 'Theme', + icon: 'circlehollow', + // Array of plain string values or MenuItem shape (see below) + items: COLOR_MODES, + // Change title based on selected value + dynamicTitle: true, + }, }, }, + decorators: [themeDecorator], } -export const decorators = [ThemeDecorator] +export default preview diff --git a/package.json b/package.json index 7485f1a9..acf540ba 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@markdoc/markdoc": "0.3.0", "@monaco-editor/react": "4.5.1", "@react-aria/utils": "3.17.0", + "@react-hooks-library/core": "0.5.1", "@react-stately/utils": "3.6.0", "@react-types/shared": "3.18.1", "@tanstack/match-sorter-utils": "8.8.4", @@ -58,6 +59,7 @@ "rehype-raw": "6.1.1", "resize-observer-polyfill": "1.5.1", "styled-container-query": "1.3.5", + "type-fest": "3.11.1", "use-immer": "0.9.0", "usehooks-ts": "2.9.1" }, diff --git a/src/GlobalStyle.tsx b/src/GlobalStyle.tsx index 14f906de..6b185297 100644 --- a/src/GlobalStyle.tsx +++ b/src/GlobalStyle.tsx @@ -1,18 +1,26 @@ import { createGlobalStyle } from 'styled-components' -import { styledTheme as theme } from './theme' +import { + COLOR_THEME_KEY, + type ColorMode, + DEFAULT_COLOR_MODE, + styledTheme as theme, +} from './theme' +import { getBoxShadows } from './theme/boxShadows' +import { baseColors } from './theme/colors-base' +import { semanticColorsDark } from './theme/colors-semantic-dark' +import { semanticColorsLight } from './theme/colors-semantic-light' const { borderRadiuses, borders, borderStyles, borderWidths, - boxShadows, fontFamilies, spacing, } = theme -const colorsToCSSVars: (colors: unknown) => any = (colors) => { +export const colorsToCSSVars: (colors: unknown) => any = (colors) => { function inner(colors: unknown, prefix = '') { Object.entries(colors).forEach(([key, value]) => { if (typeof value === 'string') { @@ -30,22 +38,33 @@ const colorsToCSSVars: (colors: unknown) => any = (colors) => { return cssVars } -const fontsToCSSVars = Object.fromEntries( +const semanticColorCSSVars = { + dark: colorsToCSSVars(semanticColorsDark), + light: colorsToCSSVars(semanticColorsLight), +} + +const baseColorCSSVars = colorsToCSSVars(baseColors) + +const getSemanticColorCSSVars = ({ mode }: { mode: ColorMode }) => + semanticColorCSSVars[mode] || semanticColorCSSVars[DEFAULT_COLOR_MODE] + +const fontCSSVars = Object.fromEntries( Object.entries(fontFamilies).map(([name, value]) => [`--font-${name}`, value]) ) -const shadowsToCSSVars = Object.fromEntries( - Object.entries(boxShadows).map(([name, value]) => [ - `--box-shadow-${name}`, - value, - ]) -) -const spacingToCSSVars = Object.fromEntries( +const getShadowCSSVars = ({ mode }: { mode: ColorMode }) => + Object.fromEntries( + Object.entries(getBoxShadows({ mode })).map(([name, value]) => [ + `--box-shadow-${name}`, + value, + ]) + ) +const spacingCSSVars = Object.fromEntries( Object.entries(spacing).map(([name, value]) => [ `--space-${name}`, `${value}px`, ]) ) -const radiiToCSSVars = Object.fromEntries( +const radiiCSSVars = Object.fromEntries( Object.entries(borderRadiuses).map(([name, value]) => [ `--radius-${name}`, `${value}px`, @@ -67,17 +86,43 @@ const bordersToCSSVars = Object.fromEntries( Object.entries(borders).map(([name, value]) => [`--border-${name}`, value]) ) +function cssSwapper(selPrimary: string, otherSel: string, limit = 6) { + let str = selPrimary + const selectors = [selPrimary] + + for (let i = 0; i < limit; ++i) { + str += ` ${otherSel} ${selPrimary}` + selectors.push(str) + } + const ret = selectors.join(',\n') + + return ret +} + const GlobalStyle = createGlobalStyle(({ theme }) => ({ ':root': { - ...(theme.colors ? colorsToCSSVars(theme.colors) : {}), - ...fontsToCSSVars, - ...shadowsToCSSVars, - ...spacingToCSSVars, - ...radiiToCSSVars, + ...baseColorCSSVars, + ...getSemanticColorCSSVars({ mode: theme.mode }), + ...fontCSSVars, + ...getShadowCSSVars({ mode: theme.mode }), + ...spacingCSSVars, + ...radiiCSSVars, ...borderStylesCSSVars, ...borderWidthsToToCSSVars, ...bordersToCSSVars, }, + [cssSwapper( + `[data-${COLOR_THEME_KEY}=dark]`, + `[data-${COLOR_THEME_KEY}=light]` + )]: { + ...getSemanticColorCSSVars({ mode: 'dark' }), + }, + [cssSwapper( + `[data-${COLOR_THEME_KEY}=light]`, + `[data-${COLOR_THEME_KEY}=dark]` + )]: { + ...getSemanticColorCSSVars({ mode: 'light' }), + }, '*': theme.partials.scrollBar({ fillLevel: 0 }), })) diff --git a/src/ThemeDecorator.tsx b/src/ThemeDecorator.tsx index 75070fd3..bac5b90e 100644 --- a/src/ThemeDecorator.tsx +++ b/src/ThemeDecorator.tsx @@ -1,5 +1,5 @@ import { Grommet } from 'grommet' -import { type ComponentType } from 'react' +import { type ComponentType, useEffect } from 'react' import { CssBaseline, Div, @@ -7,13 +7,30 @@ import { } from 'honorable' import { ThemeProvider as StyledThemeProvider } from 'styled-components' -import theme, { styledTheme } from './theme' +import { + honorableThemeDark, + honorableThemeLight, + setThemeColorMode, + styledThemeDark, + styledThemeLight, + useThemeColorMode, +} from './theme' import StyledCss from './GlobalStyle' -function ThemeDecorator(Story: ComponentType) { +function ThemeDecorator(Story: ComponentType, context: any) { + const colorMode = useThemeColorMode() + + useEffect(() => { + setThemeColorMode(context.globals.theme) + }, [context.globals.theme]) + + const honorableTheme = + colorMode === 'light' ? honorableThemeLight : honorableThemeDark + const styledTheme = colorMode === 'light' ? styledThemeLight : styledThemeDark + return ( - + diff --git a/src/components/AppList.tsx b/src/components/AppList.tsx index 157d98b8..506f4001 100644 --- a/src/components/AppList.tsx +++ b/src/components/AppList.tsx @@ -112,12 +112,14 @@ function AppListUnstyled({ return (
{!isEmpty(apps) && ( - } - placeholder="Filter applications" - value={filter} - onChange={({ target: { value } }) => setFilter(value)} - /> +
+ } + placeholder="Filter applications" + value={filter} + onChange={({ target: { value } }) => setFilter(value)} + /> +
)}
+ onSelectionChange={(key: any) => actions.find((action) => action.label === key)?.onSelect() } isDisabled={!isAlive} diff --git a/src/components/Highlight.tsx b/src/components/Highlight.tsx index 76bd2269..d13905d3 100644 --- a/src/components/Highlight.tsx +++ b/src/components/Highlight.tsx @@ -26,7 +26,7 @@ const LineNumbers = styled(StyledPre)(({ theme }) => ({ })) const StyledHighlight = styled.div( - (_) => ` + ({ theme }) => ` pre code.hljs { display: block; overflow-x: auto; @@ -40,21 +40,21 @@ code.hljs { .hljs ::selection, .hljs::selection { background-color: #383a62; - color: #ebeff0; + color: ${theme.colors['code-block-light-grey']}; } .hljs-comment { - color: #747b8b; + color: ${theme.colors['code-block-dark-grey']}; } .hljs-tag { - color: #c5c9d3; + color: ${theme.colors['code-block-mid-grey']}; } .hljs-operator, .hljs-punctuation, .hljs-subst { - color: #ebeff0; + color: ${theme.colors['code-block-light-grey']}; } .hljs-operator { @@ -67,7 +67,7 @@ code.hljs { .hljs-selector-tag, .hljs-template-variable, .hljs-variable { - color: #c5c9d3; + color: ${theme.colors['code-block-mid-grey']}; } .hljs-attr, @@ -77,30 +77,36 @@ code.hljs { .hljs-symbol, .hljs-variable.constant_ { color: #969af8; + color: ${theme.colors['code-block-purple']}; + } .hljs-class .hljs-title, .hljs-title, .hljs-title.class_ { - color: #7075f5; + color: ${theme.colors['code-block-dark-purple']}; + } .hljs-strong { font-weight: 700; - color: #7075f5; + color: ${theme.colors['code-block-dark-purple']}; + } .hljs-addition, .hljs-code, .hljs-string, .hljs-title.class_.inherited__ { color: #8fd6ff; + color: ${theme.colors['code-block-mid-blue']}; + } .hljs-built_in, .hljs-doctag, .hljs-keyword.hljs-atrule, .hljs-quote, .hljs-regexp { - color: #c2e9ff; + color: ${theme.colors['code-block-light-blue']}; } .hljs-attribute, @@ -108,7 +114,7 @@ code.hljs { .hljs-section, .hljs-title.function_, .ruby .hljs-property { - color: #3cecaf; + color: ${theme.colors[`code-block-dark-green`]}; } .diff .hljs-meta, @@ -116,6 +122,7 @@ code.hljs { .hljs-template-tag, .hljs-type { color: #fff48f; + color: ${theme.colors[`code-block-yellow`]}; } .hljs-emphasis { @@ -127,6 +134,7 @@ code.hljs { .hljs-meta .hljs-keyword, .hljs-meta .hljs-string { color: #99f5d5; + color: ${theme.colors[`code-block-light-green`]}; } .hljs-meta .hljs-keyword, diff --git a/src/components/InlineCode.tsx b/src/components/InlineCode.tsx index ec03bd00..77a421df 100644 --- a/src/components/InlineCode.tsx +++ b/src/components/InlineCode.tsx @@ -40,8 +40,8 @@ const parentFillLevelToBorderColor: { // consistent when INLINE_CODE_MIN_PX changes. const PADDING_EMS = 0.1669 -const Code = styled.code<{ parentFillLevel: FillLevel }>( - ({ theme, parentFillLevel }) => ({ +const CodeElt = styled.code<{ $parentFillLevel: FillLevel }>( + ({ theme, $parentFillLevel: parentFillLevel }) => ({ ...theme.partials.text.inlineCode, border: theme.borders.default, borderRadius: theme.borderRadiuses.large, @@ -73,9 +73,9 @@ const InlineCode = forwardRef>( return ( <> - diff --git a/src/components/contexts/ColorModeProvider.tsx b/src/components/contexts/ColorModeProvider.tsx new file mode 100644 index 00000000..5659e852 --- /dev/null +++ b/src/components/contexts/ColorModeProvider.tsx @@ -0,0 +1,32 @@ +import { ThemeProvider as HonorableThemeProvider } from 'honorable' +import { type ComponentProps } from 'react' + +import styled, { ThemeProvider as StyledThemeProvider } from 'styled-components' + +import { + COLOR_THEME_KEY, + type ColorMode, + honorableThemeDark, + honorableThemeLight, + styledThemeDark, + styledThemeLight, +} from '../../theme' + +const Wrapper = styled.div`` + +export function ColorModeProvider({ + mode = 'dark', + ...props +}: { mode: ColorMode } & ComponentProps) { + return ( + + + + + + ) +} diff --git a/src/index.ts b/src/index.ts index 8b6993f7..20083a74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,9 +125,18 @@ export { useSetBreadcrumbs, type Breadcrumb, } from './components/contexts/BreadcrumbsContext' +export { ColorModeProvider } from './components/contexts/ColorModeProvider' // Theme -export { default as theme, styledTheme } from './theme' +export { + honorableThemeDark as theme, + styledTheme, + styledThemeLight, + honorableThemeLight, + styledThemeDark, +} from './theme' +export type { SemanticColorKey, SemanticColorCssVar } from './theme/colors' +export { semanticColorKeys, semanticColorCssVars } from './theme/colors' export { default as GlobalStyle } from './GlobalStyle' // Utils diff --git a/src/stories/Card.stories.tsx b/src/stories/Card.stories.tsx index 21695e8e..a13a2888 100644 --- a/src/stories/Card.stories.tsx +++ b/src/stories/Card.stories.tsx @@ -1,6 +1,8 @@ import { Flex } from 'honorable' import { type ComponentProps } from 'react' +import { useTheme } from 'styled-components' + import { type FillLevel } from '../components/contexts/FillLevelContext' import { Card } from '../index' @@ -65,38 +67,45 @@ function FillLevelTemplate({ selected, width, }: { width: number } & CardProps) { + const theme = useTheme() + return ( - - {fillLevels.map((fillLevel) => ( - - fillLevel="{fillLevel}" -
-
+
+
fill-zero
+ + {fillLevels.map((fillLevel) => ( - -
- Each Card background should be one level lighter than its parent, - but not exceed fill-three -
-
+ fillLevel= + {fillLevel === undefined ? 'undefined' : `"${fillLevel}"`} + {!fillLevel && ` (1-3 determined by context)`} +
+
+ + +
+ Each Card background should be one level lighter than its + parent, but not exceed fill-three +
+
+
-
- ))} -
+ ))} + +
) } diff --git a/src/stories/Colors.tsx b/src/stories/Colors.tsx new file mode 100644 index 00000000..fda1d123 --- /dev/null +++ b/src/stories/Colors.tsx @@ -0,0 +1,123 @@ +import styled, { useTheme } from 'styled-components' +import { partition } from 'lodash-es' + +import { Flex } from 'honorable' + +import Divider from '../components/Divider' + +import { FlexWrap } from './FlexWrap' +import { FilledBox } from './FilledBox' +import { ItemLabel } from './ItemLabel' + +const ColorBox = styled(FilledBox)<{ $colorKey: string | number }>( + ({ theme, $colorKey }) => ({ + boxShadow: theme.boxShadows.moderate, + backgroundColor: (theme.colors as any)[$colorKey], + }) +) + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const ColorBoxWrap = styled.div((_) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + width: '64px', +})) + +export function Colors({ + title, + colors, +}: { + title: string + colors: [string | number, string][] +}) { + return ( +
+ + + {colors.map(([key]) => ( + + + {key} + + ))} + +
+ ) +} + +function Template() { + const theme = useTheme() + const colors = { ...theme.colors } + + const colorEntries = Object.entries(colors).filter( + (x): x is [(typeof x)[0], string] => typeof x[1] === 'string' + ) + const [fills, rest1] = partition(colorEntries, (key) => + `${key}`.startsWith('fill') + ) + const [borders, rest2] = partition(rest1, (key) => + `${key}`.startsWith('border') + ) + const [text, rest3] = partition(rest2, (key) => `${key}`.startsWith('text')) + const [icons, rest4] = partition(rest3, (key) => `${key}`.startsWith('icon')) + const [codeBlock, rest5] = partition(rest4, (key) => + `${key}`.startsWith('code-block') + ) + const [cloudShell, rest6] = partition(rest5, (key) => + `${key}`.startsWith('cloud-shell') + ) + const [action, rest7] = partition(rest6, (key) => + `${key}`.startsWith('action') + ) + + const misc = rest7 + + return ( + + + + + + + + + + + ) +} + +const Exp = Template.bind({}) + +Exp.args = {} +export default Exp diff --git a/src/stories/FilledBox.tsx b/src/stories/FilledBox.tsx new file mode 100644 index 00000000..279caaa0 --- /dev/null +++ b/src/stories/FilledBox.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components' + +export const FilledBox = styled.div<{ $bgColor?: string }>( + ({ theme, $bgColor }) => ({ + width: '64px', + height: '64px', + backgroundColor: $bgColor || theme.colors['fill-one'], + }) +) diff --git a/src/stories/FlexWrap.tsx b/src/stories/FlexWrap.tsx new file mode 100644 index 00000000..0b82487f --- /dev/null +++ b/src/stories/FlexWrap.tsx @@ -0,0 +1,7 @@ +import styled from 'styled-components' + +export const FlexWrap = styled.div(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing.large, +})) diff --git a/src/stories/ItemLabel.tsx b/src/stories/ItemLabel.tsx new file mode 100644 index 00000000..2a9ac4c6 --- /dev/null +++ b/src/stories/ItemLabel.tsx @@ -0,0 +1,6 @@ +import styled from 'styled-components' + +export const ItemLabel = styled.div(({ theme }) => ({ + ...theme.partials.text.caption, + marginTop: theme.spacing.xxsmall, +})) diff --git a/src/stories/Typography.tsx b/src/stories/Typography.tsx new file mode 100644 index 00000000..3e294a7f --- /dev/null +++ b/src/stories/Typography.tsx @@ -0,0 +1,147 @@ +import styled from 'styled-components' + +import Divider from '../components/Divider' + +import { type styledTheme } from '..' + +const SemanticText = styled.div<{ + $typeStyle?: keyof typeof styledTheme.partials.text +}>(({ theme, $typeStyle: typeStyle }) => ({ + ...theme.partials.text[typeStyle], + marginBottom: theme.spacing.large, +})) + +export function Typography({ + exampleText: txt = 'Lorem ipsum dolor sit amet', +}: { + exampleText: string +}) { + return ( + <> + H1 - {txt} + H2 - {txt} + H3 - {txt} + H4 - {txt} + Title 1 - {txt} + Title 2 - {txt} + Subtitle 1 - {txt} + Subtitle 2 - {txt} + Body 1 (Bold) - {txt} + Body 1 - {txt} + Body 2 (Bold) - {txt} + Body 2 - {txt} + + Body 2 Loose Line Height - {txt} + + Caption - {txt} + Badge Label - {txt} + Large Button - {txt} + Small Button - {txt} + Overline - {txt} + Code - {txt} + + ) +} +const MktgText = styled.div<{ + $typeStyle?: keyof typeof styledTheme.partials.marketingText +}>(({ theme, $typeStyle: typeStyle }) => ({ + ...theme.partials.marketingText[typeStyle], + display: 'block', + marginBottom: theme.spacing.large, +})) +const MarketingInlineLink = styled.a(({ theme }) => ({ + ...theme.partials.marketingText.inlineLink, +})) + +export function MarketingTypography({ + exampleText: txt = 'Lorem ipsum dolor sit amet', +}: { + exampleText: string +}) { + return ( + <> + + Big Header ( + Inline link) - {txt} + + + Hero 1 (Inline link) + - {txt} + + + Hero 2 (Inline link) + - {txt} + + + Title 1 (Inline link + ) - {txt} + + + Title 2 (Inline link + ) - {txt} + + + Subtitle 1 ( + Inline link) - {txt} + + + Subtitle 2 ( + Inline link) - {txt} + + + Body 1 (Bold) ( + Inline link) - {txt} + + + Body 1 (Inline link) + - {txt} + + + Body 2 (Bold) ( + Inline link) - {txt} + + + Body 2 (Inline link) + - {txt} + + Standalone link - {txt} + + Component text ( + Inline link) - {txt} + + Component link - {txt} + + Small component link - {txt} + + + Label (Inline link) + - {txt} + + Nav link - {txt} + + ) +} + +function Template({ exampleText }: { exampleText?: string }) { + return ( + <> + + + + + + ) +} + +const Exp = Template.bind({}) + +export default Exp +Exp.args = { + exampleText: 'Lorem ipsum dolor sit amet', +} diff --git a/src/stories/_SemanticSystem.stories.tsx b/src/stories/_SemanticSystem.stories.tsx index 78d55a2f..0bc141f7 100644 --- a/src/stories/_SemanticSystem.stories.tsx +++ b/src/stories/_SemanticSystem.stories.tsx @@ -1,47 +1,35 @@ import styled, { useTheme } from 'styled-components' import { type FillLevel } from '../components/contexts/FillLevelContext' -import { type styledTheme } from '..' import Divider from '../components/Divider' +import { baseSpacing } from '../theme/spacing' -const fillLevelToBGColor: Record = { - 0: 'fill-zero', - 1: 'fill-one', - 2: 'fill-two', - 3: 'fill-three', -} +import { ItemLabel } from './ItemLabel' +import { FilledBox } from './FilledBox' +import { FlexWrap } from './FlexWrap' export default { title: 'Semantic System', component: null, } -const ItemLabel = styled.div(({ theme }) => ({ - ...theme.partials.text.caption, - marginTop: theme.spacing.xxsmall, -})) +const fillLevelToBGColor: Record = { + 0: 'fill-zero', + 1: 'fill-one', + 2: 'fill-two', + 3: 'fill-three', +} const BlockWrapper = styled.div(({ theme }) => ({ marginBottom: theme.spacing.large, })) -const FilledBox = styled.div(({ theme }) => ({ - width: '64px', - height: '64px', - backgroundColor: theme.colors['fill-one'], -})) - -function Template({ exampleText }: { exampleText?: string }) { +function Template() { return ( <> - - - - - - ) } -const ColorBox = styled(FilledBox)<{ color: string }>(({ theme, color }) => ({ - boxShadow: theme.boxShadows.moderate, - backgroundColor: (theme.colors as any)[color], -})) - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const ColorBoxWrap = styled.div((_p) => ({ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - width: '64px', -})) - -const FlexWrap = styled.div(({ theme }) => ({ - display: 'flex', - flexWrap: 'wrap', - gap: theme.spacing.large, -})) - -function Colors() { - const theme = useTheme() - - const colors = { ...theme.colors } - - delete colors.blue - delete colors.grey - delete colors.green - delete colors.yellow - delete colors.red - - return ( - - {Object.entries(colors).map(([key]) => ( - - - {key} - - ))} - - ) -} - const ShadowedBox = styled(FilledBox)<{ shadow: string }>( ({ theme, shadow }) => ({ boxShadow: (theme.boxShadows as any)[shadow] }) ) @@ -142,20 +78,24 @@ function Shadows() { ) } -const RadiusedBox = styled(FilledBox)<{ radius: 'medium' | 'large' }>( - ({ theme, radius }) => ({ +const RadiusedBox = styled(FilledBox)<{ $radius: 'medium' | 'large' }>( + ({ theme, $radius: radius }) => ({ borderRadius: theme.borderRadiuses[radius], }) ) function BoxRadiuses() { const radii: ('medium' | 'large')[] = ['medium', 'large'] + const theme = useTheme() return ( {radii.map((key) => ( - + {key} ))} @@ -259,161 +199,32 @@ function Scrollbars() { ) } -const SpacingBox = styled.div<{ space: string }>(({ theme, space }) => ({ +const SpacingBox = styled.div<{ $space: number }>(({ theme, $space }) => ({ borderRadius: 0, backgroundColor: theme.colors['action-primary'], margin: 0, - paddingRight: (theme.spacing as any)[space], - paddingTop: (theme.spacing as any)[space], + paddingRight: $space, + paddingTop: $space, width: 'min-content', })) function Spacing() { return ( <> - {[ - 'xxxsmall', - 'xxsmall', - 'xsmall', - 'small', - 'medium', - 'large', - 'xlarge', - 'xxlarge', - 'xxxlarge', - 'xxxxlarge', - 'xxxxxlarge', - ].map((key) => ( + {Object.entries(baseSpacing).map(([key, val]) => ( - - {key} + + + {key}: {val}px + ))} ) } -const SemanticText = styled.div<{ - typeStyle?: keyof typeof styledTheme.partials.text -}>(({ theme, typeStyle }) => ({ - ...theme.partials.text[typeStyle], - marginBottom: theme.spacing.large, -})) +export const Miscellaneous = Template.bind({}) +Miscellaneous.args = {} -function Typography({ - exampleText: txt = 'Lorem ipsum dolor sit amet', -}: { - exampleText: string -}) { - return ( - <> - H1 - {txt} - H2 - {txt} - H3 - {txt} - H4 - {txt} - Title 1 - {txt} - Title 2 - {txt} - Subtitle 1 - {txt} - Subtitle 2 - {txt} - Body 1 (Bold) - {txt} - Body 1 - {txt} - Body 2 (Bold) - {txt} - Body 2 - {txt} - - Body 2 Loose Line Height - {txt} - - Caption - {txt} - Badge Label - {txt} - Large Button - {txt} - Small Button - {txt} - Overline - {txt} - Code - {txt} - - ) -} - -const MktgText = styled.div<{ - typeStyle?: keyof typeof styledTheme.partials.marketingText -}>(({ theme, typeStyle }) => ({ - ...theme.partials.marketingText[typeStyle], - display: 'block', - marginBottom: theme.spacing.large, -})) - -const MarketingInlineLink = styled.a(({ theme }) => ({ - ...theme.partials.marketingText.inlineLink, -})) - -function MarketingTypography({ - exampleText: txt = 'Lorem ipsum dolor sit amet', -}: { - exampleText: string -}) { - return ( - <> - - Big Header ( - Inline link) - {txt} - - - Hero 1 (Inline link) - - {txt} - - - Hero 2 (Inline link) - - {txt} - - - Title 1 (Inline link - ) - {txt} - - - Title 2 (Inline link - ) - {txt} - - - Subtitle 1 ( - Inline link) - {txt} - - - Subtitle 2 ( - Inline link) - {txt} - - - Body 1 (Bold) ( - Inline link) - {txt} - - - Body 1 (Inline link) - - {txt} - - - Body 2 (Bold) ( - Inline link) - {txt} - - - Body 2 (Inline link) - - {txt} - - Standalone link - {txt} - - Component text ( - Inline link) - {txt} - - Component link - {txt} - - Small component link - {txt} - - - Label (Inline link) - - {txt} - - Nav link - {txt} - - ) -} - -export const SemanticSystem = Template.bind({}) -SemanticSystem.args = { - exampleText: 'Lorem ipsum dolor sit amet', -} +export { default as Colors } from './Colors' +export { default as Typography } from './Typography' diff --git a/src/theme.tsx b/src/theme.tsx index 29af0610..98e2e189 100644 --- a/src/theme.tsx +++ b/src/theme.tsx @@ -2,15 +2,13 @@ import { mergeTheme } from 'honorable' import defaultTheme from 'honorable-theme-default' import mapperRecipe from 'honorable-recipe-mapper' -import { - blue, - green, - grey, - purple, - red, - semanticColors, - yellow, -} from './theme/colors' +import { useState } from 'react' + +import { useMutationObserver } from '@react-hooks-library/core' + +import { semanticColorsDark } from './theme/colors-semantic-dark' +import { semanticColorsLight } from './theme/colors-semantic-light' +import { baseColors } from './theme/colors-base' import { spacing } from './theme/spacing' import { fontFamilies } from './theme/fonts' import { textPartials } from './theme/text' @@ -20,14 +18,20 @@ import { borderWidths, borders, } from './theme/borders' -import { boxShadows } from './theme/boxShadows' +import { getBoxShadows } from './theme/boxShadows' import { scrollBar } from './theme/scrollBar' import { zIndexes } from './theme/zIndexes' -import { focusPartials } from './theme/focus' +import { getFocusPartials } from './theme/focus' import { resetPartials } from './theme/resets' import { marketingTextPartials } from './theme/marketingText' import gradients from './theme/gradients' +export const COLOR_THEME_KEY = 'theme-mode' + +export const COLOR_MODES = ['light', 'dark'] as const +export type ColorMode = (typeof COLOR_MODES)[number] +export const DEFAULT_COLOR_MODE: ColorMode = 'dark' + export type StringObj = { [key: string]: string | StringObj } const spacers = { @@ -53,731 +57,807 @@ const portals = { }, } -const baseTheme = { - name: 'Plural', - mode: 'dark', - breakpoints: { - // We'll add mobile breakpoints later - desktopSmall: 1000, - desktop: 1280, - desktopLarge: 1440, - }, - colors: { - // Base palette, - blue, - grey, - green, - yellow, - red, - purple, - // Semantic colors, - ...semanticColors, - }, -} +const colorsDark = { + ...baseColors, + ...semanticColorsDark, +} as const -const honorableTheme = mergeTheme(defaultTheme, { - ...baseTheme, - stylesheet: { - html: [ - { - fontSize: 14, - fontFamily: fontFamilies.sans, - backgroundColor: 'fill-zero', - }, - ], - '::placeholder': [ - { - color: 'text-xlight', - }, - ], - }, - global: [ - /* Spacing */ - mapperRecipe('gap', spacing), - ...Object.entries(spacers).map( - ([key, nextKeys]) => - (props: any) => - props[key] !== null && - typeof props[key] !== 'undefined' && - Object.fromEntries( - nextKeys.map((nextKey) => [ - nextKey, - (spacing as any)[props[key]] || props[key], - ]) - ) - ), - ({ fill }: any) => - fill === true && { - // === true to prevent the `fill` css property to apply here - width: '100%', - height: '100%', - }, - /* Border radiuses */ - mapperRecipe('borderRadius', borderRadiuses), - /* Shadows */ - mapperRecipe('boxShadow', boxShadows), - /* Texts */ - ({ h1 }: any) => h1 && textPartials.h1, - ({ h2 }: any) => h2 && textPartials.h2, - ({ h3 }: any) => h3 && textPartials.h3, - ({ h4 }: any) => h4 && textPartials.h4, - ({ title1 }: any) => title1 && textPartials.title1, - ({ title2 }: any) => title2 && textPartials.title2, - ({ subtitle1 }: any) => subtitle1 && textPartials.subtitle1, - ({ subtitle2 }: any) => subtitle2 && textPartials.subtitle2, - ({ body1, body2, bold }: any) => ({ - ...(body1 && textPartials.body1), - ...(body2 && textPartials.body2), - ...((body1 || body2) && bold && textPartials.bodyBold), - }), - ({ body2LooseLineHeight, bold }: any) => ({ - ...(body2LooseLineHeight && textPartials.body2LooseLineHeight), - ...(body2LooseLineHeight && bold && textPartials.bodyBold), - }), - ({ caption }: any) => caption && textPartials.caption, - ({ badgeLabel }: any) => badgeLabel && textPartials.badgeLabel, - ({ buttonMedium }: any) => buttonMedium && textPartials.buttonMedium, - ({ buttonLarge }: any) => buttonLarge && textPartials.buttonLarge, - ({ buttonSmall }: any) => buttonSmall && textPartials.buttonSmall, - ({ overline }: any) => overline && textPartials.overline, - ({ truncate }: any) => truncate && textPartials.truncate, - /* Deprecated */ - ({ body0 }: any) => - body0 && { - fontSize: 18, - lineHeight: '28px', - }, - /* Deprecated */ - ({ font }: any) => - font === 'action' && { - fontFamily: 'Monument', - letterSpacing: 1, - fontWeight: 500, - }, - /* deprecated in favor of _hover */ - ({ hoverIndicator }: any) => - hoverIndicator && { - '&:hover': { - backgroundColor: hoverIndicator, +const colorsLight = { + ...baseColors, + ...semanticColorsLight, +} as const + +const getBaseTheme = ({ mode }: { mode: ColorMode }) => + ({ + name: 'Plural', + mode, + breakpoints: { + // We'll add mobile breakpoints later + desktopSmall: 1000, + desktop: 1280, + desktopLarge: 1440, + }, + } as const) + +const getHonorableThemeProps = ({ mode }: { mode: ColorMode }) => { + const boxShadows = getBoxShadows({ mode }) + const focusPartials = getFocusPartials({ mode }) + + return { + stylesheet: { + html: [ + { + fontSize: 14, + fontFamily: fontFamilies.sans, + backgroundColor: 'fill-zero', + }, + ], + '::placeholder': [ + { + color: 'text-xlight', }, - }, - ], - A: { - Root: [ - { - color: 'text', - }, - ({ inline }: any) => inline && textPartials.inlineLink, - ], - }, - Accordion: { - Root: [ - ({ ghost }: any) => - ghost && { - border: 'none', - elevation: 0, - backgroundColor: 'inherit', + ], + }, + global: [ + /* Spacing */ + mapperRecipe('gap', spacing), + ...Object.entries(spacers).map( + ([key, nextKeys]) => + (props: any) => + props[key] !== null && + typeof props[key] !== 'undefined' && + Object.fromEntries( + nextKeys.map((nextKey) => [ + nextKey, + (spacing as any)[props[key]] || props[key], + ]) + ) + ), + ({ fill }: any) => + fill === true && { + // === true to prevent the `fill` css property to apply here + width: '100%', + height: '100%', + }, + /* Border radiuses */ + mapperRecipe('borderRadius', borderRadiuses), + /* Shadows */ + mapperRecipe('boxShadow', boxShadows), + /* Texts */ + ({ h1 }: any) => h1 && textPartials.h1, + ({ h2 }: any) => h2 && textPartials.h2, + ({ h3 }: any) => h3 && textPartials.h3, + ({ h4 }: any) => h4 && textPartials.h4, + ({ title1 }: any) => title1 && textPartials.title1, + ({ title2 }: any) => title2 && textPartials.title2, + ({ subtitle1 }: any) => subtitle1 && textPartials.subtitle1, + ({ subtitle2 }: any) => subtitle2 && textPartials.subtitle2, + ({ body1, body2, bold }: any) => ({ + ...(body1 && textPartials.body1), + ...(body2 && textPartials.body2), + ...((body1 || body2) && bold && textPartials.bodyBold), + }), + ({ body2LooseLineHeight, bold }: any) => ({ + ...(body2LooseLineHeight && textPartials.body2LooseLineHeight), + ...(body2LooseLineHeight && bold && textPartials.bodyBold), + }), + ({ caption }: any) => caption && textPartials.caption, + ({ badgeLabel }: any) => badgeLabel && textPartials.badgeLabel, + ({ buttonMedium }: any) => buttonMedium && textPartials.buttonMedium, + ({ buttonLarge }: any) => buttonLarge && textPartials.buttonLarge, + ({ buttonSmall }: any) => buttonSmall && textPartials.buttonSmall, + ({ overline }: any) => overline && textPartials.overline, + ({ truncate }: any) => truncate && textPartials.truncate, + /* Deprecated */ + ({ body0 }: any) => + body0 && { + fontSize: 18, + lineHeight: '28px', + }, + /* Deprecated */ + ({ font }: any) => + font === 'action' && { + fontFamily: 'Monument', + letterSpacing: 1, + fontWeight: 500, + }, + /* deprecated in favor of _hover */ + ({ hoverIndicator }: any) => + hoverIndicator && { + '&:hover': { + backgroundColor: hoverIndicator, + }, }, ], - }, - Avatar: { - Root: [ - { - backgroundColor: 'action-primary', - borderRadius: 4, // TODO 3 or 6 - fontWeight: 400, - }, - ], - }, - Button: { - Root: [ - { - buttonMedium: true, - display: 'flex', - borderRadius: 'normal', - backgroundColor: 'action-primary', - border: '1px solid action-primary', - paddingTop: spacing.xsmall - 1, - paddingBottom: spacing.xsmall - 1, - paddingRight: spacing.medium - 1, - paddingLeft: spacing.medium - 1, - _focus: { - outline: 'none', - }, - _focusVisible: { - ...focusPartials.button, - }, - ':hover': { - backgroundColor: 'action-primary-hover', - border: '1px solid action-primary-hover', - }, - ':active': { - backgroundColor: 'action-primary', - border: '1px solid action-primary', + A: { + Root: [ + { + color: 'text', }, - ':disabled': { - color: 'text-primary-disabled', - backgroundColor: 'action-primary-disabled', - border: '1px solid action-primary-disabled', - ':hover': { - backgroundColor: 'action-primary-disabled', - border: '1px solid action-primary-disabled', + ({ inline }: any) => inline && textPartials.inlineLink, + ], + }, + Accordion: { + Root: [ + ({ ghost }: any) => + ghost && { + border: 'none', + elevation: 0, + backgroundColor: 'inherit', }, + ], + }, + Avatar: { + Root: [ + { + backgroundColor: 'action-primary', + borderRadius: 4, // TODO 3 or 6 + fontWeight: 400, }, - }, - ({ secondary }: any) => - secondary && { - color: 'text-light', - backgroundColor: 'transparent', - border: '1px solid border-input', + ], + }, + Button: { + Root: [ + { + buttonMedium: true, + display: 'flex', + borderRadius: 'normal', + backgroundColor: 'action-primary', + border: '1px solid action-primary', + paddingTop: spacing.xsmall - 1, + paddingBottom: spacing.xsmall - 1, + paddingRight: spacing.medium - 1, + paddingLeft: spacing.medium - 1, + _focus: { + outline: 'none', + }, + _focusVisible: { + ...focusPartials.button, + }, ':hover': { - color: 'text', - backgroundColor: 'action-input-hover', - border: '1px solid border-input', + backgroundColor: 'action-primary-hover', + border: '1px solid action-primary-hover', }, ':active': { - color: 'text', - backgroundColor: 'transparent', - border: '1px solid border-input', - }, - ':focus-visible': { - color: 'text', - backgroundColor: 'action-input-hover', + backgroundColor: 'action-primary', + border: '1px solid action-primary', }, ':disabled': { - color: 'text-disabled', + color: 'text-primary-disabled', + backgroundColor: 'action-primary-disabled', + border: '1px solid action-primary-disabled', + ':hover': { + backgroundColor: 'action-primary-disabled', + border: '1px solid action-primary-disabled', + }, + }, + }, + ({ secondary }: any) => + secondary && { + color: 'text-light', backgroundColor: 'transparent', border: '1px solid border-input', ':hover': { + color: 'text', + backgroundColor: 'action-input-hover', + border: '1px solid border-input', + }, + ':active': { + color: 'text', backgroundColor: 'transparent', border: '1px solid border-input', }, + ':focus-visible': { + color: 'text', + backgroundColor: 'action-input-hover', + }, + ':disabled': { + color: 'text-disabled', + backgroundColor: 'transparent', + border: '1px solid border-input', + ':hover': { + backgroundColor: 'transparent', + border: '1px solid border-input', + }, + }, }, - }, - ({ tertiary }: any) => - tertiary && { - color: 'text-light', - backgroundColor: 'transparent', - border: '1px solid transparent', - ':hover': { - color: 'text', - backgroundColor: 'action-input-hover', - border: '1px solid transparent', - }, - ':active': { - color: 'text', - backgroundColor: 'transparent', - border: '1px solid transparent', - }, - ':focus-visible': { - color: 'text', - backgroundColor: 'action-input-hover', - }, - ':disabled': { - color: 'text-disabled', + ({ tertiary }: any) => + tertiary && { + color: 'text-light', backgroundColor: 'transparent', border: '1px solid transparent', ':hover': { + color: 'text', + backgroundColor: 'action-input-hover', + border: '1px solid transparent', + }, + ':active': { + color: 'text', backgroundColor: 'transparent', border: '1px solid transparent', }, + ':focus-visible': { + color: 'text', + backgroundColor: 'action-input-hover', + }, + ':disabled': { + color: 'text-disabled', + backgroundColor: 'transparent', + border: '1px solid transparent', + ':hover': { + backgroundColor: 'transparent', + border: '1px solid transparent', + }, + }, }, - }, - ({ tertiary, padding }: any) => - tertiary && - padding === 'none' && { - color: 'text-light', - backgroundColor: 'transparent', - border: '1px solid transparent', - paddingHorizontal: '0', - ':hover': { - backgroundColor: 'transparent', - textDecoration: 'underline', - }, - ':active': { - textDecoration: 'underline', - }, - ':focus-visible': { + ({ tertiary, padding }: any) => + tertiary && + padding === 'none' && { + color: 'text-light', backgroundColor: 'transparent', - textDecoration: 'underline', - }, - }, - ({ destructive }: any) => - destructive && { - color: 'text-danger', - backgroundColor: 'transparent', - border: '1px solid border-danger', - ':hover': { - backgroundColor: 'action-input-hover', - border: '1px solid border-danger', + border: '1px solid transparent', + paddingHorizontal: '0', + ':hover': { + backgroundColor: 'transparent', + textDecoration: 'underline', + }, + ':active': { + textDecoration: 'underline', + }, + ':focus-visible': { + backgroundColor: 'transparent', + textDecoration: 'underline', + }, }, - ':active': { + ({ destructive }: any) => + destructive && { + color: 'text-danger', backgroundColor: 'transparent', border: '1px solid border-danger', - }, - ':focus-visible': { - backgroundColor: 'action-input-hover', - }, - ':disabled': { - color: 'text-disabled', - backgroundColor: 'transparent', - border: '1px solid border-disabled', ':hover': { + backgroundColor: 'action-input-hover', + border: '1px solid border-danger', + }, + ':active': { + backgroundColor: 'transparent', + border: '1px solid border-danger', + }, + ':focus-visible': { + backgroundColor: 'action-input-hover', + }, + ':disabled': { + color: 'text-disabled', backgroundColor: 'transparent', border: '1px solid border-disabled', + ':hover': { + backgroundColor: 'transparent', + border: '1px solid border-disabled', + }, }, }, - }, - ({ floating }: any) => - floating && { - color: 'text-light', - backgroundColor: 'fill-two', - border: '1px solid border-input', - // boxShadow isn't getting set when placed in the root here, - // but using the '&' prop gets around it - '&': { - boxShadow: boxShadows.slight, - }, - ':hover': { - color: 'text', - backgroundColor: 'fill-two-hover', - border: '1px solid border-input', - boxShadow: boxShadows.moderate, - }, - ':active': { - color: 'text', - backgroundColor: 'fill-two-hover', - border: '1px solid border-input', - }, - ':focus-visible': { - color: 'text', - backgroundColor: 'fill-two-selected', - }, - ':disabled': { - color: 'text-disabled', - backgroundColor: 'transparent', + ({ floating }: any) => + floating && { + color: 'text-light', + backgroundColor: 'fill-two', border: '1px solid border-input', + // boxShadow isn't getting set when placed in the root here, + // but using the '&' prop gets around it + '&': { + boxShadow: boxShadows.slight, + }, ':hover': { + color: 'text', + backgroundColor: 'fill-two-hover', + border: '1px solid border-input', + boxShadow: boxShadows.moderate, + }, + ':active': { + color: 'text', + backgroundColor: 'fill-two-hover', + border: '1px solid border-input', + }, + ':focus-visible': { + color: 'text', + backgroundColor: 'fill-two-selected', + }, + ':disabled': { + color: 'text-disabled', backgroundColor: 'transparent', border: '1px solid border-input', + ':hover': { + backgroundColor: 'transparent', + border: '1px solid border-input', + }, }, }, - }, - ({ large }: any) => - large && { - buttonLarge: true, - paddingTop: spacing.small - 1, - paddingBottom: spacing.small - 1, - paddingRight: spacing.large - 1, - paddingLeft: spacing.large - 1, - }, - ({ small }: any) => - small && { - buttonSmall: true, - paddingTop: spacing.xxsmall - 1, - paddingBottom: spacing.xxsmall - 1, - paddingRight: spacing.medium - 1, - paddingLeft: spacing.medium - 1, - minHeight: 32, - }, - ], - StartIcon: [ - { - margin: '0 12px 0 0 !important', - }, - ({ large }: any) => - large && { - margin: '0 16px 0 0 !important', - }, - ({ small }: any) => - small && { + ({ large }: any) => + large && { + buttonLarge: true, + paddingTop: spacing.small - 1, + paddingBottom: spacing.small - 1, + paddingRight: spacing.large - 1, + paddingLeft: spacing.large - 1, + }, + ({ small }: any) => + small && { + buttonSmall: true, + paddingTop: spacing.xxsmall - 1, + paddingBottom: spacing.xxsmall - 1, + paddingRight: spacing.medium - 1, + paddingLeft: spacing.medium - 1, + minHeight: 32, + }, + ], + StartIcon: [ + { margin: '0 12px 0 0 !important', }, - ], - EndIcon: [ - { - margin: '0 0 0 12px !important', - }, - ({ large }: any) => - large && { - margin: '0 0 0 16px !important', - }, - ({ small }: any) => - small && { + ({ large }: any) => + large && { + margin: '0 16px 0 0 !important', + }, + ({ small }: any) => + small && { + margin: '0 12px 0 0 !important', + }, + ], + EndIcon: [ + { margin: '0 0 0 12px !important', }, - ], - }, - ButtonGroup: { - Root: [ - { - border: '1px solid border', - borderRadius: 4, - '& > button': { - border: '1px solid transparent', - }, - overflow: 'hidden', - }, - ({ direction }: any) => - direction === 'row' && { + ({ large }: any) => + large && { + margin: '0 0 0 16px !important', + }, + ({ small }: any) => + small && { + margin: '0 0 0 12px !important', + }, + ], + }, + ButtonGroup: { + Root: [ + { + border: '1px solid border', + borderRadius: 4, '& > button': { - borderLeft: '1px solid border', - '&:first-of-type': { - borderLeft: '1px solid transparent', + border: '1px solid transparent', + }, + overflow: 'hidden', + }, + ({ direction }: any) => + direction === 'row' && { + '& > button': { + borderLeft: '1px solid border', + '&:first-of-type': { + borderLeft: '1px solid transparent', + }, }, }, - }, - ({ direction }: any) => - direction === 'column' && { - '& > button': { - borderTop: '1px solid border', - '&:first-of-type': { - borderTop: '1px solid transparent', + ({ direction }: any) => + direction === 'column' && { + '& > button': { + borderTop: '1px solid border', + '&:first-of-type': { + borderTop: '1px solid transparent', + }, }, }, - }, - ], - }, - Checkbox: { - Root: [ - ({ small }: any) => - small && { - '> span': { - borderWidth: '.75px', + ], + }, + Checkbox: { + Root: [ + ({ small }: any) => + small && { + '> span': { + borderWidth: '.75px', + }, }, + ], + Control: [ + { + width: 24, + height: 24, + borderRadius: 'normal', + }, + ({ small }: any) => + small && { + width: 16, + height: 16, + }, + ], + }, + H1: { + Root: [ + { + fontFamily: 'Monument', }, - ], - Control: [ - { - width: 24, - height: 24, - borderRadius: 'normal', - }, - ({ small }: any) => - small && { - width: 16, - height: 16, - }, - ], - }, - H1: { - Root: [ - { - fontFamily: 'Monument', - }, - ], - }, - H2: { - Root: [ - { - fontFamily: 'Monument', - }, - ], - }, - H3: { - Root: [ - { - fontFamily: 'Monument', - }, - ], - }, - H4: { - Root: [ - { - fontFamily: 'Monument', - }, - ], - }, - H5: { - Root: [ - { - fontFamily: 'Monument', - }, - ], - }, - H6: { - Root: [ - { - fontFamily: 'Monument', - }, - ], - }, - Input: { - Root: [ - { - body2: true, - display: 'flex', - overflow: 'hidden', - justifyContent: 'space-between', - align: 'center', - height: 'auto', - minHeight: 'auto', - width: 'auto', - paddingTop: 0, - paddingBottom: 0, - paddingRight: 'medium', - paddingLeft: 'medium', - border: '1px solid border-input', - borderRadius: 'normal', - _focusWithin: { - borderColor: 'border-outline-focused', - }, - }, - ({ valid }: any) => - valid && { - borderColor: 'border-outline', + ], + }, + H2: { + Root: [ + { + fontFamily: 'Monument', }, - ({ error }: any) => - error && { - borderColor: 'border-danger', + ], + }, + H3: { + Root: [ + { + fontFamily: 'Monument', }, - ({ small }: any) => - small && { - caption: true, + ], + }, + H4: { + Root: [ + { + fontFamily: 'Monument', }, - ({ disabled }: any) => - disabled && { - backgroundColor: 'transparent', - color: 'text-disabled', - borderColor: 'border-disabled', + ], + }, + H5: { + Root: [ + { + fontFamily: 'Monument', }, - ], - InputBase: [ - { - width: '100%', - flex: '1 1', - height: '38px', - lineHeight: '38px', - color: 'text', - _placeholder: { - color: 'text-xlight', + ], + }, + H6: { + Root: [ + { + fontFamily: 'Monument', }, - }, - ({ small }: any) => - small && { - height: '30px', - lineHeight: '30px', - }, - ({ large }: any) => - large && { - height: '46px', - lineHeight: '46px', - }, - ({ disabled }: any) => - disabled && { - backgroundColor: 'transparent', - color: 'text-disabled', - _placeholder: { - color: 'text-disabled', + ], + }, + Input: { + Root: [ + { + body2: true, + display: 'flex', + overflow: 'hidden', + justifyContent: 'space-between', + align: 'center', + height: 'auto', + minHeight: 'auto', + width: 'auto', + paddingTop: 0, + paddingBottom: 0, + paddingRight: 'medium', + paddingLeft: 'medium', + border: '1px solid border-input', + borderRadius: 'normal', + _focusWithin: { + borderColor: 'border-outline-focused', }, }, - ], - StartIcon: [ - { - marginRight: 'xsmall', - }, - ({ disabled }: any) => - disabled && { - '& *': { + ({ valid }: any) => + valid && { + borderColor: 'border-outline', + }, + ({ error }: any) => + error && { + borderColor: 'border-danger', + }, + ({ small }: any) => + small && { + caption: true, + }, + ({ disabled }: any) => + disabled && { + backgroundColor: 'transparent', color: 'text-disabled', + borderColor: 'border-disabled', + }, + ], + InputBase: [ + { + width: '100%', + flex: '1 1', + height: '38px', + lineHeight: '38px', + color: 'text', + _placeholder: { + color: 'text-xlight', }, }, - ], - EndIcon: [ - { - marginLeft: 'small', - }, - ({ disabled }: any) => - disabled && { - '& *': { + ({ small }: any) => + small && { + height: '30px', + lineHeight: '30px', + }, + ({ large }: any) => + large && { + height: '46px', + lineHeight: '46px', + }, + ({ disabled }: any) => + disabled && { + backgroundColor: 'transparent', color: 'text-disabled', + _placeholder: { + color: 'text-disabled', + }, }, + ], + StartIcon: [ + { + marginRight: 'xsmall', + }, + ({ disabled }: any) => + disabled && { + '& *': { + color: 'text-disabled', + }, + }, + ], + EndIcon: [ + { + marginLeft: 'small', + }, + ({ disabled }: any) => + disabled && { + '& *': { + color: 'text-disabled', + }, + }, + ], + }, + Menu: { + Root: [ + { + paddingTop: '4px', + paddingBottom: '4px', + backgroundColor: 'fill-two', + border: '1px solid border', + borderRadius: 'normal', + boxShadow: 'moderate', + elevation: 0, // reset from honorable-theme-default }, - ], - }, - Menu: { - Root: [ - { - paddingTop: '4px', - paddingBottom: '4px', - backgroundColor: 'fill-two', - border: '1px solid border', - borderRadius: 'normal', - boxShadow: 'moderate', - elevation: 0, // reset from honorable-theme-default - }, - ], - }, - MenuItem: { - Root: [ - { - '& > div': { - borderTop: '1px solid border-fill-two', - }, - '&:first-of-type > div': { - borderTop: 'none', - }, - }, - ], - Children: [ - { - padding: '8px 16px', - }, - ({ active }: any) => - active && { - backgroundColor: 'fill-two-hover', - borderColor: 'fill-two-hover', + ], + }, + MenuItem: { + Root: [ + { + '& > div': { + borderTop: '1px solid border-fill-two', + }, + '&:first-of-type > div': { + borderTop: 'none', + }, }, - ], - }, - Modal: { - Root: [ - { - backgroundColor: 'fill-one', - border: '1px solid border', - boxShadow: 'modal', - paddingTop: 'large', - paddingRight: 'large', - paddingBottom: 'large', - paddingLeft: 'large', - }, - ], - Backdrop: [ - { - backgroundColor: 'modal-backdrop', - zIndex: zIndexes.modal, - }, - ], - }, - Select: { - Root: [ - { - border: '1px solid border-input', - }, - ], - }, - Spinner: { - Root: [ - { - '&:before': { - borderTop: '2px solid white', + ], + Children: [ + { + padding: '8px 16px', }, - }, - ], - }, - Switch: { - Root: [ - ({ checked }: any) => ({ - padding: 8, - paddingLeft: 0, - color: checked ? 'text' : 'action-link-inactive', - '> div:first-of-type': { - backgroundColor: checked ? 'action-primary' : 'transparent', + ({ active }: any) => + active && { + backgroundColor: 'fill-two-hover', + borderColor: 'fill-two-hover', + }, + ], + }, + Modal: { + Root: [ + { + backgroundColor: 'fill-one', + border: '1px solid border', + boxShadow: 'modal', + paddingTop: 'large', + paddingRight: 'large', + paddingBottom: 'large', + paddingLeft: 'large', + }, + ], + Backdrop: [ + { + backgroundColor: 'modal-backdrop', + zIndex: zIndexes.modal, + }, + ], + }, + Select: { + Root: [ + { border: '1px solid border-input', - '> span': { - backgroundColor: checked - ? 'action-link-active' - : 'action-link-inactive', + }, + ], + }, + Spinner: { + Root: [ + { + '&:before': { + borderTop: '2px solid white', }, }, - ':hover': { - color: 'text', + ], + }, + Switch: { + Root: [ + ({ checked }: any) => ({ + padding: 8, + paddingLeft: 0, + color: checked ? 'text' : 'action-link-inactive', '> div:first-of-type': { - backgroundColor: checked - ? 'action-primary-hover' - : 'action-input-hover', + backgroundColor: checked ? 'action-primary' : 'transparent', border: '1px solid border-input', '> span': { backgroundColor: checked ? 'action-link-active' - : 'action-link-active', + : 'action-link-inactive', + }, + }, + ':hover': { + color: 'text', + '> div:first-of-type': { + backgroundColor: checked + ? 'action-primary-hover' + : 'action-input-hover', + border: '1px solid border-input', + '> span': { + backgroundColor: checked + ? 'action-link-active' + : 'action-link-active', + }, }, }, + }), + ], + Control: [ + { + width: 42, + height: 24, + borderRadius: 12, + '&:hover': { + boxShadow: 'none', + }, }, - }), - ], - Control: [ - { - width: 42, - height: 24, - borderRadius: 12, - '&:hover': { - boxShadow: 'none', + ], + Handle: [ + ({ checked }: any) => ({ + width: 16, + height: 16, + borderRadius: '50%', + top: 3, + left: checked ? 'calc(100% - 16px - 3px)' : 3, + }), + ], + }, + Tooltip: { + Root: [ + { + caption: true, + backgroundColor: 'fill-two', + paddingVertical: 'xxsmall', + paddingHorizontal: 'xsmall', + borderRadius: 'medium', + boxShadow: 'moderate', + color: 'text-light', }, - }, - ], - Handle: [ - ({ checked }: any) => ({ - width: 16, - height: 16, - borderRadius: '50%', - top: 3, - left: checked ? 'calc(100% - 16px - 3px)' : 3, - }), - ], - }, - Tooltip: { - Root: [ - { - caption: true, - backgroundColor: 'fill-two', - paddingVertical: 'xxsmall', - paddingHorizontal: 'xsmall', - borderRadius: 'medium', - boxShadow: 'moderate', - color: 'text-light', - }, - ], - Arrow: [ - { - backgroundColor: 'fill-two', - borderRadius: '1px', - top: '50%', - left: 0, - transformOrigin: '50% 50%', - transform: - 'translate(calc(-50% + 1px), -50%) scaleY(0.77) rotate(45deg)', - }, - ], - }, - Ul: { - Root: [ - { - marginBlockStart: 0, - marginBlockEnd: 0, - paddingInlineStart: 24, - }, - ], - }, + ], + Arrow: [ + { + backgroundColor: 'fill-two', + borderRadius: '1px', + top: '50%', + left: 0, + transformOrigin: '50% 50%', + transform: + 'translate(calc(-50% + 1px), -50%) scaleY(0.77) rotate(45deg)', + }, + ], + }, + Ul: { + Root: [ + { + marginBlockStart: 0, + marginBlockEnd: 0, + paddingInlineStart: 24, + }, + ], + }, + } +} + +export const honorableThemeDark = mergeTheme(defaultTheme, { + ...getBaseTheme({ mode: 'dark' }), + colors: colorsDark, + ...getHonorableThemeProps({ mode: 'dark' }), }) -export default honorableTheme +export const honorableThemeLight = mergeTheme(defaultTheme, { + ...getBaseTheme({ mode: 'light' }), + colors: colorsLight, + ...getHonorableThemeProps({ mode: 'light' }), +}) -export const styledTheme = { - ...baseTheme, - ...{ - spacing, - boxShadows, - borderRadiuses, - fontFamilies, - borders, - borderStyles, - borderWidths, - zIndexes, - portals, - gradients, - partials: { - text: textPartials, - marketingText: marketingTextPartials, - focus: focusPartials, - scrollBar, - reset: resetPartials, - dropdown: { - arrowTransition: ({ isOpen = false }) => ({ - transition: 'transform 0.1s ease', - transform: `scaleY(${isOpen ? -1 : 1})`, - }), - }, +const getStyledTheme = ({ mode }: { mode: ColorMode }) => + ({ + ...getBaseTheme({ mode }), + ...{ + spacing, + boxShadows: getBoxShadows({ mode }), + borderRadiuses, + fontFamilies, + borders, + borderStyles, + borderWidths, + zIndexes, + portals, + gradients, + partials: { + text: textPartials, + marketingText: marketingTextPartials, + focus: getFocusPartials({ mode }), + scrollBar, + reset: resetPartials, + dropdown: { + arrowTransition: ({ isOpen = false }) => ({ + transition: 'transform 0.1s ease', + transform: `scaleY(${isOpen ? -1 : 1})`, + }), + }, + }, + colors: mode === 'dark' ? colorsDark : colorsLight, }, - }, + } as const) + +export const styledThemeDark = getStyledTheme({ mode: 'dark' }) + +export const styledThemeLight = { + ...getStyledTheme({ mode: 'light' }), + colors: colorsLight, +} as const + +// Deprecate these later? +export const styledTheme = styledThemeDark +export default honorableThemeDark + +export const setThemeColorMode = ( + mode: ColorMode, + { + dataAttrName = COLOR_THEME_KEY, + element = document?.documentElement, + }: { + dataAttrName?: string + element?: HTMLElement + } = {} +) => { + if (!element) { + return + } + localStorage.setItem(dataAttrName, mode) + element.setAttribute(`data-${dataAttrName}`, mode) +} + +export const useThemeColorMode = ({ + dataAttrName = COLOR_THEME_KEY, + defaultMode = 'dark', + element = document?.documentElement, +}: { + dataAttrName?: string + defaultMode?: ColorMode + element?: HTMLElement +} = {}) => { + const attrName = `data-${dataAttrName}` + const [thisTheme, setThisTheme] = useState( + element?.getAttribute(attrName) || defaultMode + ) + + useMutationObserver( + element, + (mutations) => { + mutations.forEach((mutation) => { + if ( + mutation?.attributeName === attrName && + mutation.target instanceof HTMLElement + ) { + setThisTheme(mutation.target.getAttribute(attrName) || defaultMode) + } + }) + }, + { attributeFilter: [attrName] } + ) + + return thisTheme } diff --git a/src/theme/borders.ts b/src/theme/borders.ts index 1df94e77..3b9f6f79 100644 --- a/src/theme/borders.ts +++ b/src/theme/borders.ts @@ -1,6 +1,6 @@ import { type CSSProperties } from 'react' -import { semanticColors } from './colors' +import { semanticColorCssVars } from './colors' export const borderWidths = { default: 1, @@ -12,12 +12,12 @@ export const borderStyles = { } as const satisfies Record export const borders = { - default: `${borderWidths.default}px ${borderStyles.default} ${semanticColors.border}`, - 'fill-one': `${borderWidths.default}px ${borderStyles.default} ${semanticColors.border}`, - 'fill-two': `${borderWidths.default}px ${borderStyles.default} ${semanticColors['border-fill-two']}`, - 'fill-three': `${borderWidths.default}px ${borderStyles.default} ${semanticColors['border-input']}`, - input: `${borderWidths.default}px ${borderStyles.default} ${semanticColors['border-input']}`, - 'outline-focused': `${borderWidths.default}px ${borderStyles.default} ${semanticColors['border-outline-focused']}`, + default: `${borderWidths.default}px ${borderStyles.default} ${semanticColorCssVars.border}`, + 'fill-one': `${borderWidths.default}px ${borderStyles.default} ${semanticColorCssVars.border}`, + 'fill-two': `${borderWidths.default}px ${borderStyles.default} ${semanticColorCssVars['border-fill-two']}`, + 'fill-three': `${borderWidths.default}px ${borderStyles.default} ${semanticColorCssVars['border-input']}`, + input: `${borderWidths.default}px ${borderStyles.default} ${semanticColorCssVars['border-input']}`, + 'outline-focused': `${borderWidths.default}px ${borderStyles.default} ${semanticColorCssVars['border-outline-focused']}`, } as const satisfies Record export const borderRadiuses = { diff --git a/src/theme/boxShadows.ts b/src/theme/boxShadows.ts index e22ad1e3..4d10c2c4 100644 --- a/src/theme/boxShadows.ts +++ b/src/theme/boxShadows.ts @@ -3,15 +3,30 @@ import { type CSSProperties } from 'react' import { borderWidths } from './borders' -import { grey, semanticColors } from './colors' +import { semanticColorCssVars } from './colors' +import { semanticColorsDark as cDark } from './colors-semantic-dark' +import { semanticColorsLight as cLight } from './colors-semantic-light' -export const boxShadows = { - slight: `0px 2px 4px ${chroma(grey[950]).alpha(0.14)}, 0px 2px 7px ${chroma( - grey[950] - ).alpha(0.18)}`, - moderate: `0px 3px 6px ${chroma(grey[950]).alpha( - 0.2 - )}, 0px 10px 20px ${chroma(grey[950]).alpha(0.3)}`, - modal: `0px 20px 50px ${chroma(grey[950]).alpha(0.6)}`, - focused: `0px 0px 0px ${borderWidths.focus}px ${semanticColors['border-outline-focused']}`, -} as const satisfies Record +export const getBoxShadows = ({ mode }: { mode: 'dark' | 'light' }) => + ({ + ...(mode === 'dark' + ? { + slight: `0px 2px 4px ${chroma(cDark['shadow-default']).alpha( + 0.14 + )}, 0px 2px 7px ${chroma(cDark['shadow-default']).alpha(0.18)}`, + moderate: `0px 3px 6px ${chroma(cDark['shadow-default']).alpha( + 0.2 + )}, 0px 10px 20px ${chroma(cDark['shadow-default']).alpha(0.3)}`, + modal: `0px 20px 50px ${chroma(cDark['shadow-default']).alpha(0.6)}`, + } + : { + slight: `0px 2px 4px ${chroma(cLight['shadow-default']).alpha( + 0.14 + )}, 0px 2px 7px ${chroma(cLight['shadow-default']).alpha(0.18)}`, + moderate: `0px 3px 6px ${chroma(cLight['shadow-default']).alpha( + 0.2 + )}, 0px 10px 20px ${chroma(cLight['shadow-default']).alpha(0.3)}`, + modal: `0px 20px 50px ${chroma(cLight['shadow-default']).alpha(0.6)}`, + }), + focused: `0px 0px 0px ${borderWidths.focus}px ${semanticColorCssVars['border-outline-focused']}`, + } as const satisfies Record) diff --git a/src/theme/colors-base.ts b/src/theme/colors-base.ts new file mode 100644 index 00000000..1f7c494d --- /dev/null +++ b/src/theme/colors-base.ts @@ -0,0 +1,111 @@ +import chroma from 'chroma-js' +import { type CSSProperties } from 'styled-components' + +export const grey = { + 950: '#0E1015', + 900: '#171A21', + 875: '#1B1F27', + 850: '#21242C', + 825: '#252932', + 800: '#2A2E37', + 775: '#303540', + 750: '#383D47', + 725: '#3D424D', + 700: '#454954', + 675: '#4C505C', + 600: '#5D626F', + 500: '#747B8B', + 400: '#A1A5B0', + 300: '#B2B7C3', + 200: '#C5C9D3', + 100: '#DFE2E7', + 50: '#EBEFF0', +} as const satisfies Record + +export const purple = { + 950: '#020318', + 900: '#030530', + 850: '#050847', + 800: '#070A5F', + 700: '#0A0F8F', + 600: '#0D14BF', + 500: '#111AEE', + 400: '#4A51F2', + 350: '#5D63F4', + 300: '#747AF6', + 200: '#9FA3F9', + 100: '#CFD1FC', + 50: '#F1F1FE', +} as const satisfies Record + +export const blue = { + 950: '#001019', + 900: '#002033', + 850: '#00304D', + 800: '#004166', + 700: '#006199', + 600: '#0081CC', + 500: '#06A0F9', + 400: '#33B4FF', + 350: '#4DBEFF', + 300: '#66C7FF', + 200: '#99DAFF', + 100: '#C2E9FF', + 50: '#F0F9FF', +} as const satisfies Record + +export const green = { + 950: '#032117', + 900: '#053827', + 850: '#074F37', + 800: '#0A6B4A', + 700: '#0F996A', + 600: '#13C386', + 500: '#17E8A0', + 400: '#3CECAF', + 300: '#6AF1C2', + 200: '#99F5D5', + 100: '#C7FAE8', + 50: '#F1FEF9', +} as const satisfies Record + +export const yellow = { + 950: '#242000', + 900: '#3D2F00', + 850: '#574500', + 800: '#756200', + 700: '#A88C00', + 600: '#D6BA00', + 500: '#FFE500', + 400: '#FFEB33', + 300: '#FFF170', + 200: '#FFF59E', + 100: '#FFF9C2', + 50: '#FFFEF0', +} as const satisfies Record + +export const red = { + 950: '#130205', + 900: '#200308', + 850: '#38060E', + 800: '#660A19', + 700: '#8B0E23', + 600: '#BA1239', + 500: '#E81748', + 400: '#E95374', + 300: '#F2788D', + 200: '#F599A8', + 100: '#FAC7D0', + 50: '#FFF0F2', +} as const satisfies Record + +export const baseColors = { + // Base palette, + blue, + grey, + green, + yellow, + red, + purple, + 'modal-backdrop': `${chroma(grey[900]).alpha(0.6)}`, +} as const diff --git a/src/theme/colors-cloudshell-dark.ts b/src/theme/colors-cloudshell-dark.ts new file mode 100644 index 00000000..af330838 --- /dev/null +++ b/src/theme/colors-cloudshell-dark.ts @@ -0,0 +1,22 @@ +import { type CSSProperties } from 'styled-components' + +import { prefixKeys } from '../utils/ts-utils' + +export const colorsCloudShellDark = prefixKeys( + { + [`mid-grey`]: '#C5C9D3', + [`dark-grey`]: '#747B8B', + [`dark-red`]: '#F2788D', + [`light-red`]: '#F599A8', + [`green`]: '#3CECAF', + [`dark-yellow`]: '#3CECAF', + [`light-yellow`]: '#FFF9C2', + [`blue`]: '#8FD6FF', + [`dark-lilac`]: '#BE5EEB', + [`light-lilac`]: '#D596F4', + [`dark-purple`]: '#7075F5', + [`light-purple`]: '#969AF8', + [`light-grey`]: '#969AF8', + } as const satisfies Record, + 'cloud-shell-' +) diff --git a/src/theme/colors-cloudshell-light.ts b/src/theme/colors-cloudshell-light.ts new file mode 100644 index 00000000..bb3b2381 --- /dev/null +++ b/src/theme/colors-cloudshell-light.ts @@ -0,0 +1 @@ +export { colorsCloudShellDark as colorsCloudShellLight } from './colors-cloudshell-dark' diff --git a/src/theme/colors-codeblock-dark.ts b/src/theme/colors-codeblock-dark.ts new file mode 100644 index 00000000..a0d263e8 --- /dev/null +++ b/src/theme/colors-codeblock-dark.ts @@ -0,0 +1,19 @@ +import { type CSSProperties } from 'styled-components' + +import { prefixKeys } from '../utils/ts-utils' + +export const colorsCodeBlockDark = prefixKeys( + { + [`light-green`]: '#99F5D5', + [`dark-grey`]: '#747B8B', + [`purple`]: '#969AF8', + [`mid-blue`]: '#8FD6FF', + [`yellow`]: '#FFF48F', + [`mid-grey`]: '#C5C9D3', + [`dark-green`]: '#3CECAF', + [`dark-purple`]: '#7075F5', + [`light-grey`]: '#EBEFF0', + [`light-blue`]: '#C2E9FF', + } as const satisfies Record, + 'code-block-' +) diff --git a/src/theme/colors-codeblock-light.ts b/src/theme/colors-codeblock-light.ts new file mode 100644 index 00000000..efae346e --- /dev/null +++ b/src/theme/colors-codeblock-light.ts @@ -0,0 +1 @@ +export { colorsCodeBlockDark as colorsCodeBlockLight } from './colors-codeblock-dark' diff --git a/src/theme/colors-semantic-dark.ts b/src/theme/colors-semantic-dark.ts new file mode 100644 index 00000000..81c5cb20 --- /dev/null +++ b/src/theme/colors-semantic-dark.ts @@ -0,0 +1,116 @@ +import chroma from 'chroma-js' +import { type CSSProperties } from 'styled-components' + +import { blue, green, grey, purple, red, yellow } from './colors-base' +import { colorsCloudShellDark } from './colors-cloudshell-dark' +import { colorsCodeBlockDark } from './colors-codeblock-dark' + +export const semanticColorsDark = { + // Fill + // + // fill-zero + 'fill-zero': grey[900], + 'fill-zero-hover': grey[875], + 'fill-zero-selected': grey[825], + // fill-one + 'fill-one': grey[850], + 'fill-one-hover': grey[825], + 'fill-one-selected': grey[775], + // fill-two + 'fill-two': grey[800], + 'fill-two-hover': grey[775], + 'fill-two-selected': grey[725], + // fill-three + 'fill-three': grey[750], + 'fill-three-hover': grey[725], + 'fill-three-selected': grey[675], + 'fill-primary': purple[400], + 'fill-primary-hover': purple[350], + + // Action + // + // primary + 'action-primary': purple[400], + 'action-primary-hover': purple[350], + 'action-primary-disabled': grey[825], + // link + 'action-link-inactive': grey[200], + 'action-link-active': grey[50], + // link-inline + 'action-link-inline': blue[200], + 'action-link-inline-hover': blue[100], + 'action-link-inline-visited': purple[300], + 'action-link-inline-visited-hover': purple[200], + 'action-input-hover': `${chroma('#E9ECF0').alpha(0.04)}`, + + // Border + // + border: grey[800], + 'border-input': grey[700], + 'border-fill-two': grey[750], + 'border-fill-three': grey[700], + 'border-disabled': grey[700], + 'border-outline-focused': blue[300], + 'border-primary': purple[300], + 'border-secondary': blue[400], + 'border-success': green[300], + 'border-warning': yellow[200], + 'border-danger': red[300], + 'border-selected': grey[100], + 'border-info': blue[300], + + // Text + // + text: grey[50], + 'text-light': grey[200], + 'text-xlight': grey[400], + 'text-long-form': grey[300], + 'text-disabled': grey[700], + 'text-input-disabled': grey[500], + 'text-primary-accent': blue[200], + 'text-primary-disabled': grey[500], + 'text-success': green[500], + 'text-success-light': green[200], + 'text-warning': yellow[400], + 'text-warning-light': yellow[100], + 'text-danger': red[400], + 'text-danger-light': red[200], + + // Icon + // + 'icon-default': grey[100], + 'icon-light': grey[200], + 'icon-xlight': grey[400], + 'icon-disabled': grey[700], + 'icon-primary': purple[300], + 'icon-secondary': blue[400], + 'icon-info': blue[200], + 'icon-success': green[200], + 'icon-warning': yellow[100], + 'icon-danger': red[200], + 'icon-danger-critical': red[400], + + // Marketing + // + 'marketing-white': '#FFFFFF', + 'marketing-black': '#000000', + + // Shadows + // + 'shadow-default': grey[950], + + // Code blocks + // + ...colorsCodeBlockDark, + + // Cloud shell + // + ...colorsCloudShellDark, + + // Deprecated (Remove after all 'error' colors converted to 'danger' in app) + // + 'border-error': red[300], + 'text-error': red[400], + 'text-error-light': red[200], + 'icon-error': red[200], +} as const satisfies Record diff --git a/src/theme/colors-semantic-light.ts b/src/theme/colors-semantic-light.ts new file mode 100644 index 00000000..67689be7 --- /dev/null +++ b/src/theme/colors-semantic-light.ts @@ -0,0 +1,125 @@ +import chroma from 'chroma-js' +import { type CSSProperties } from 'styled-components' + +import { blue, green, grey, purple, red, yellow } from './colors-base' +import { colorsCloudShellLight } from './colors-cloudshell-light' +import { colorsCodeBlockLight } from './colors-codeblock-light' +import { type semanticColorsDark } from './colors-semantic-dark' + +export const semanticColorsLight = { + // Fill + // + // fill-zero + 'fill-zero': '#F9FAFB', + 'fill-zero-hover': grey[50], + 'fill-zero-selected': grey[100], + // fill-one + 'fill-one': '#FFFFFF', + 'fill-one-hover': grey[100], + 'fill-one-selected': grey[200], + // fill-two + 'fill-two': '#F0F4F5', + 'fill-two-hover': grey[200], + 'fill-two-selected': '#A9AFBC', + // fill-three + 'fill-three': grey[100], + 'fill-three-hover': grey[400], + 'fill-three-selected': grey[300], + // primary + 'fill-primary': purple[400], + 'fill-primary-hover': purple[350], + + // Action + // + // primary + 'action-primary': purple[400], + 'action-primary-hover': purple[350], + 'action-primary-disabled': grey[100], + // link + 'action-link-inactive': '#A9AFBC', + 'action-link-active': blue[600], + 'action-link-inline': blue[700], + // link-inline + + // Check with design team that this is correct + 'action-link-inline-hover': blue[600], + + 'action-link-inline-visited': purple[300], + 'action-link-inline-visited-hover': purple[200], + // input + 'action-input-hover': `${chroma(grey[950]).alpha(0.04)}`, // text color w/ alpha + + // Border + // + border: grey[100], + 'border-input': grey[300], + 'border-fill-two': grey[200], + 'border-fill-three': grey[400], + 'border-disabled': grey[200], + 'border-outline-focused': blue[400], + 'border-primary': purple[400], + 'border-secondary': blue[400], + 'border-success': green[500], + 'border-warning': '#FFF175', + 'border-danger': '#ED4578', + 'border-selected': grey[100], + 'border-info': blue[300], + + // Text + // + text: grey[950], + 'text-light': '#5D626F', + 'text-xlight': '#A9AFBC', + 'text-long-form': grey[300], + 'text-disabled': grey[200], + 'text-input-disabled': grey[200], + 'text-primary-accent': '#38B6FF', + 'text-primary-disabled': grey[500], + 'text-success': green[700], + 'text-success-light': green[600], + 'text-warning': yellow[500], + 'text-warning-light': '#FFE500', + 'text-danger': '#ED4578', + 'text-danger-light': red[300], + + // Icon + // + 'icon-default': grey[600], + 'icon-light': grey[500], + 'icon-xlight': grey[400], + 'icon-disabled': grey[100], + 'icon-primary': purple[300], + 'icon-secondary': blue[400], + 'icon-info': blue[200], + 'icon-success': green[700], + 'icon-warning': '#FF9900', + 'icon-danger': red[300], + 'icon-danger-critical': '#ED4578', + + // Marketing + // + 'marketing-white': '#000000', + 'marketing-black': '#FFFFFF', + + // Shadows + // + 'shadow-default': grey[950], + + // Code-blocks + // + ...colorsCodeBlockLight, + + // Cloud shell + // + ...colorsCloudShellLight, + + // Deprecated (Remove after all 'error' colors converted to 'danger' in app) + // + 'border-error': red[300], + 'text-error': 'blue', + 'text-error-light': 'blue', + 'icon-error': 'blue', +} as const satisfies Record< + keyof typeof semanticColorsDark, + CSSProperties['color'] +> diff --git a/src/theme/colors.ts b/src/theme/colors.ts index a92dbc44..095c8418 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -1,184 +1,25 @@ -import chroma from 'chroma-js' -import { type CSSProperties } from 'react' +import { type Entries } from 'type-fest' -export const grey = { - 950: '#0E1015', - 900: '#171A21', - 875: '#1B1F27', - 850: '#21242C', - 825: '#252932', - 800: '#2A2E37', - 775: '#303540', - 750: '#383D47', - 725: '#3D424D', - 700: '#454954', - 675: '#4C505C', - 600: '#5D626F', - 500: '#747B8B', - 400: '#A1A5B0', - 300: '#B2B7C3', - 200: '#C5C9D3', - 100: '#DFE2E7', - 50: '#EBEFF0', -} as const satisfies Record +import { affixKeysToValues } from '../utils/ts-utils' -export const purple = { - 950: '#020318', - 900: '#030530', - 850: '#050847', - 800: '#070A5F', - 700: '#0A0F8F', - 600: '#0D14BF', - 500: '#111AEE', - 400: '#4A51F2', - 350: '#5D63F4', - 300: '#747AF6', - 200: '#9FA3F9', - 100: '#CFD1FC', - 50: '#F1F1FE', -} as const satisfies Record +import { semanticColorsDark } from './colors-semantic-dark' -export const blue = { - 950: '#001019', - 900: '#002033', - 850: '#00304D', - 800: '#004166', - 700: '#006199', - 600: '#0081CC', - 500: '#06A0F9', - 400: '#33B4FF', - 350: '#4DBEFF', - 300: '#66C7FF', - 200: '#99DAFF', - 100: '#C2E9FF', - 50: '#F0F9FF', -} as const satisfies Record +export const semanticColorKeys = ( + Object.entries(semanticColorsDark) as Entries +).map(([key]) => key) -export const green = { - 950: '#032117', - 900: '#053827', - 850: '#074F37', - 800: '#0A6B4A', - 700: '#0F996A', - 600: '#13C386', - 500: '#17E8A0', - 400: '#3CECAF', - 300: '#6AF1C2', - 200: '#99F5D5', - 100: '#C7FAE8', - 50: '#F1FEF9', -} as const satisfies Record +export type SemanticColorKey = keyof typeof semanticColorsDark -export const yellow = { - 950: '#242000', - 900: '#3D2F00', - 850: '#574500', - 800: '#756200', - 700: '#A88C00', - 600: '#D6BA00', - 500: '#FFE500', - 400: '#FFEB33', - 300: '#FFF170', - 200: '#FFF59E', - 100: '#FFF9C2', - 50: '#FFFEF0', -} as const satisfies Record +const cssVarPrefix = '--color-' -export const red = { - 950: '#130205', - 900: '#200308', - 850: '#38060E', - 800: '#660A19', - 700: '#8B0E23', - 600: '#BA1239', - 500: '#E81748', - 400: '#E95374', - 300: '#F2788D', - 200: '#F599A8', - 100: '#FAC7D0', - 50: '#FFF0F2', -} as const satisfies Record +export function colorKeyToCssVar(key: T): string { + return `${cssVarPrefix}${key}` as `${typeof cssVarPrefix}${T}` +} -const baseColors = { - // Fill - 'fill-zero': grey[900], - 'fill-zero-hover': grey[875], - 'fill-zero-selected': grey[825], - 'fill-one': grey[850], - 'fill-one-hover': grey[825], - 'fill-one-selected': grey[775], - 'fill-two': grey[800], - 'fill-two-hover': grey[775], - 'fill-two-selected': grey[725], - 'fill-three': grey[750], - 'fill-three-hover': grey[725], - 'fill-three-selected': grey[675], - 'fill-primary': purple[400], - 'fill-primary-hover': purple[350], - // Action - 'action-primary': purple[400], - 'action-primary-hover': purple[350], - 'action-primary-disabled': grey[825], - 'action-link-inactive': grey[200], - 'action-link-active': grey[50], - 'action-link-inline': blue[200], - 'action-link-inline-hover': blue[100], - 'action-link-inline-visited': purple[300], - 'action-link-inline-visited-hover': purple[200], - 'action-input-hover': `${chroma('#E9ECF0').alpha(0.04)}`, - // Border - border: grey[800], - 'border-input': grey[700], - 'border-fill-two': grey[750], - 'border-fill-three': grey[700], - 'border-disabled': grey[700], - 'border-outline-focused': blue[300], - 'border-primary': purple[300], - 'border-secondary': blue[400], - 'border-success': green[300], - 'border-warning': yellow[200], - 'border-danger': red[300], - 'border-selected': grey[100], - 'border-info': blue[300], - // Content - text: grey[50], - 'text-light': grey[200], - 'text-xlight': grey[400], - 'text-long-form': grey[300], - 'text-disabled': grey[700], // deprecated - 'text-input-disabled': grey[500], - 'text-primary-accent': blue[200], // deprecated maybe? - 'text-primary-disabled': grey[500], - 'text-success': green[500], - 'text-success-light': green[200], - 'text-warning': yellow[400], - 'text-warning-light': yellow[100], - 'text-danger': red[400], - 'text-danger-light': red[200], - // Icon - 'icon-default': grey[100], - 'icon-light': grey[200], - 'icon-xlight': grey[400], - 'icon-disabled': grey[700], - 'icon-primary': purple[300], - 'icon-secondary': blue[400], - 'icon-info': blue[200], - 'icon-success': green[200], - 'icon-warning': yellow[100], - 'icon-danger': red[200], - 'icon-danger-critical': red[400], - // Deprecated (Remove after all 'error' colors converted to 'danger' in app) - 'border-error': red[300], - 'text-error': red[400], - 'text-error-light': red[200], - 'icon-error': red[200], - // Marketing - 'marketing-white': '#FFFFFF', - 'marketing-black': '#000000', -} as const satisfies Record +export const semanticColorCssVars = affixKeysToValues(semanticColorsDark, { + prefix: `var(${cssVarPrefix}`, + suffix: `)`, +}) -export const semanticColors = { - ...baseColors, - // Modals - 'modal-backdrop': `${chroma(baseColors['fill-zero']).alpha(0.6)}`, -} as const satisfies Record +export type SemanticColorCssVar = + (typeof semanticColorCssVars)[keyof typeof semanticColorCssVars] diff --git a/src/theme/editor.ts b/src/theme/editor.ts index 6a19a59c..e2a188ba 100644 --- a/src/theme/editor.ts +++ b/src/theme/editor.ts @@ -1,4 +1,4 @@ -import { semanticColors } from './colors' +import { semanticColorsDark as semanticColors } from './colors-semantic-dark' export const editorTheme = { inherit: true, diff --git a/src/theme/focus.ts b/src/theme/focus.ts index 7d4f78ed..3dbca41f 100644 --- a/src/theme/focus.ts +++ b/src/theme/focus.ts @@ -1,31 +1,37 @@ import { type CSSObject } from '../types' -import { boxShadows } from './boxShadows' +import { type ColorMode } from '../theme' + +import { getBoxShadows } from './boxShadows' import { borderWidths } from './borders' -import { semanticColors } from './colors' +import { semanticColorCssVars } from './colors' + +export const getFocusPartials = ({ mode }: { mode: ColorMode }) => { + const boxShadows = getBoxShadows({ mode }) -export const focusPartials = { - default: { - outline: 'none', - boxShadow: boxShadows.focused, - }, - outline: { - outline: `${borderWidths.focus}px solid ${semanticColors['border-outline-focused']}`, - }, - button: { - outline: `1px solid ${semanticColors['border-outline-focused']}`, - outlineOffset: '-1px', - }, - insetAbsolute: { - outline: 'none', - position: 'absolute', - content: "''", - pointerEvents: 'none', - top: `${borderWidths.focus}px`, - right: `${borderWidths.focus}px`, - left: `${borderWidths.focus}px`, - bottom: `${borderWidths.focus}px`, - boxShadow: boxShadows.focused, - }, -} as const satisfies Record + return { + default: { + outline: 'none', + boxShadow: boxShadows.focused, + }, + outline: { + outline: `${borderWidths.focus}px solid ${semanticColorCssVars['border-outline-focused']}`, + }, + button: { + outline: `1px solid ${semanticColorCssVars['border-outline-focused']}`, + outlineOffset: '-1px', + }, + insetAbsolute: { + outline: 'none', + position: 'absolute', + content: "''", + pointerEvents: 'none', + top: `${borderWidths.focus}px`, + right: `${borderWidths.focus}px`, + left: `${borderWidths.focus}px`, + bottom: `${borderWidths.focus}px`, + boxShadow: boxShadows.focused, + }, + } as const satisfies Record +} diff --git a/src/theme/marketingText.ts b/src/theme/marketingText.ts index 985eb57e..b5189ff9 100644 --- a/src/theme/marketingText.ts +++ b/src/theme/marketingText.ts @@ -1,7 +1,7 @@ import { type CSSObject } from '../types' import { fontFamilies } from './fonts' -import { semanticColors } from './colors' +import { semanticColorCssVars } from './colors' const body1 = { fontFamily: fontFamilies.sans, @@ -9,7 +9,7 @@ const body1 = { lineHeight: '140%', fontWeight: 300, letterSpacing: '0.25px', - color: semanticColors['text-xlight'], + color: semanticColorCssVars['text-xlight'], } as const satisfies CSSObject const body2 = { @@ -18,12 +18,12 @@ const body2 = { lineHeight: '160%', fontWeight: 300, letterSpacing: '0.5px', - color: semanticColors['text-xlight'], + color: semanticColorCssVars['text-xlight'], } as const satisfies CSSObject const bodyBold = { fontWeight: 700, - color: semanticColors['text-light'], + color: semanticColorCssVars['text-light'], } as const satisfies CSSObject const marketingTextPartials = { @@ -33,7 +33,7 @@ const marketingTextPartials = { lineHeight: '120%', fontWeight: 500, letterSpacing: 0, - color: semanticColors.text, + color: semanticColorCssVars.text, }, hero1: { fontFamily: fontFamilies.sansHero, @@ -41,7 +41,7 @@ const marketingTextPartials = { lineHeight: '120%', fontWeight: 700, letterSpacing: 0, - color: semanticColors.text, + color: semanticColorCssVars.text, }, hero2: { fontFamily: fontFamilies.sansHero, @@ -49,7 +49,7 @@ const marketingTextPartials = { lineHeight: '125%', fontWeight: 500, letterSpacing: 0, - color: semanticColors.text, + color: semanticColorCssVars.text, }, title1: { fontFamily: fontFamilies.sansHero, @@ -57,7 +57,7 @@ const marketingTextPartials = { lineHeight: '140%', fontWeight: 500, letterSpacing: '0.25px', - color: semanticColors.text, + color: semanticColorCssVars.text, }, title2: { fontFamily: fontFamilies.sansHero, @@ -65,7 +65,7 @@ const marketingTextPartials = { lineHeight: '140%', fontWeight: 500, letterSpacing: '0.25px', - color: semanticColors.text, + color: semanticColorCssVars.text, }, subtitle1: { fontFamily: fontFamilies.sans, @@ -73,7 +73,7 @@ const marketingTextPartials = { lineHeight: '140%', fontWeight: 600, letterSpacing: '0.25px', - color: semanticColors.text, + color: semanticColorCssVars.text, }, subtitle2: { fontFamily: fontFamilies.sans, @@ -81,7 +81,7 @@ const marketingTextPartials = { lineHeight: '150%', fontWeight: 600, letterSpacing: '0.25px', - color: semanticColors.text, + color: semanticColorCssVars.text, }, body1, body2, @@ -95,13 +95,13 @@ const marketingTextPartials = { ...bodyBold, }, inlineLink: { - color: semanticColors['action-link-inline'], + color: semanticColorCssVars['action-link-inline'], textDecoration: 'underline', '&:hover': { - color: semanticColors['action-link-inline-hover'], + color: semanticColorCssVars['action-link-inline-hover'], }, '&:visited, &:active': { - color: semanticColors['action-link-inline-visited'], + color: semanticColorCssVars['action-link-inline-visited'], }, }, navLink: { @@ -110,9 +110,9 @@ const marketingTextPartials = { lineHeight: '150%', fontWeight: '300', letterSpacing: '0.5px', - color: semanticColors['text-light'], + color: semanticColorCssVars['text-light'], '&:hover': { - color: semanticColors.text, + color: semanticColorCssVars.text, textDecoration: 'underline', }, }, @@ -122,7 +122,7 @@ const marketingTextPartials = { lineHeight: '150%', fontWeight: 500, letterSpacing: '0.5px', - color: semanticColors.text, + color: semanticColorCssVars.text, cursor: 'pointer', '&:hover': { textDecoration: 'underline', @@ -134,7 +134,7 @@ const marketingTextPartials = { lineHeight: '150%', fontWeight: 300, letterSpacing: '0.5px', - color: semanticColors['text-xlight'], + color: semanticColorCssVars['text-xlight'], }, componentLink: { fontFamily: fontFamilies.sans, @@ -142,7 +142,7 @@ const marketingTextPartials = { lineHeight: '150%', fontWeight: 600, letterSpacing: '0.25px', - color: semanticColors['text-light'], + color: semanticColorCssVars['text-light'], cursor: 'pointer', '&:hover': { textDecoration: 'underline', @@ -154,7 +154,7 @@ const marketingTextPartials = { lineHeight: '150%', fontWeight: 400, letterSpacing: '0.25px', - color: semanticColors['text-light'], + color: semanticColorCssVars['text-light'], cursor: 'pointer', '&:hover': { textDecoration: 'underline', @@ -166,7 +166,7 @@ const marketingTextPartials = { lineHeight: '150%', fontWeight: 300, letterSpacing: '1px', - color: semanticColors['text-xlight'], + color: semanticColorCssVars['text-xlight'], textTransform: 'uppercase', }, } as const satisfies Record diff --git a/src/theme/scrollBar.ts b/src/theme/scrollBar.ts index c7eb553d..92e4da3e 100644 --- a/src/theme/scrollBar.ts +++ b/src/theme/scrollBar.ts @@ -4,15 +4,17 @@ import { type StringObj } from '../theme' import { type FillLevel } from '../components/contexts/FillLevelContext' -import { semanticColors } from './colors' +import { semanticColorCssVars } from './colors' export const scrollBar = ({ fillLevel }: { fillLevel: FillLevel }) => { const trackColor = - fillLevel >= 2 ? semanticColors['fill-three'] : semanticColors['fill-two'] + fillLevel >= 2 + ? semanticColorCssVars['fill-three'] + : semanticColorCssVars['fill-two'] const barColor = fillLevel >= 2 - ? semanticColors['text-xlight'] - : semanticColors['fill-three'] + ? semanticColorCssVars['text-xlight'] + : semanticColorCssVars['fill-three'] const barWidth = 6 const barRadius = barWidth / 2 diff --git a/src/theme/spacing.ts b/src/theme/spacing.ts index 7559523a..c7e9d230 100644 --- a/src/theme/spacing.ts +++ b/src/theme/spacing.ts @@ -1,25 +1,30 @@ +import { type PrefixKeys } from '../utils/ts-utils' + +export const baseSpacing = { + xxxsmall: 2, // 1/8 * 16 + xxsmall: 4, // 1/4 * 16 + xsmall: 8, // 1/2 * 16 + small: 12, // 3/4 * 16 + medium: 16, // 1 * 16 + large: 24, // 1.5 * 16 + xlarge: 32, // 2 * 16 + xxlarge: 48, // 3 * 16 + xxxlarge: 64, // 4 * 16 + xxxxlarge: 96, // 6 * 16 + xxxxxlarge: 128, // 8 * 16 + xxxxxxlarge: 192, // 12 * 16 +} as const satisfies Record + +const negativePrefix = 'minus-' as const +const negativeSpacing = Object.fromEntries( + Object.entries(baseSpacing).map((key, val) => [ + `${negativePrefix}${key}`, + -val, + ]) +) as PrefixKeys + export const spacing = { - 'minus-xxxxxlarge': -128, - 'minus-xxxxlarge': -96, - 'minus-xxxlarge': -64, - 'minus-xxlarge': -48, - 'minus-xlarge': -32, - 'minus-large': -24, - 'minus-medium': -16, - 'minus-small': -12, - 'minus-xsmall': -8, - 'minus-xxsmall': -4, - 'minus-xxxsmall': -2, none: 0, - xxxsmall: 2, - xxsmall: 4, - xsmall: 8, - small: 12, - medium: 16, - large: 24, - xlarge: 32, - xxlarge: 48, - xxxlarge: 64, - xxxxlarge: 96, - xxxxxlarge: 128, + ...baseSpacing, + ...negativeSpacing, } as const satisfies Record diff --git a/src/theme/text.ts b/src/theme/text.ts index 87df2ba9..93692fcb 100644 --- a/src/theme/text.ts +++ b/src/theme/text.ts @@ -1,7 +1,7 @@ import { type CSSObject } from '../types' import { fontFamilies } from './fonts' -import { semanticColors } from './colors' +import { semanticColorCssVars } from './colors' export const INLINE_CODE_EMS = 0.8 export const INLINE_CODE_MIN_PX = 12 @@ -156,15 +156,15 @@ const textPartials = { textOverflow: 'ellipsis', }, inlineLink: { - color: semanticColors['action-link-inline'], + color: semanticColorCssVars['action-link-inline'], textDecoration: 'underline', '&:hover': { - color: semanticColors['action-link-inline-hover'], + color: semanticColorCssVars['action-link-inline-hover'], }, '&:visited, &:active': { - color: semanticColors['action-link-inline-visited'], + color: semanticColorCssVars['action-link-inline-visited'], '&:hover': { - color: semanticColors['action-link-inline-visited-hover'], + color: semanticColorCssVars['action-link-inline-visited-hover'], }, }, }, diff --git a/src/types/fromEntries.d.ts b/src/types/fromEntries.d.ts new file mode 100644 index 00000000..80c9cbe5 --- /dev/null +++ b/src/types/fromEntries.d.ts @@ -0,0 +1,17 @@ +export type ArrayElement = A extends readonly (infer T)[] ? T : never + +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable } + +type Cast = X extends Y ? X : Y + +type FromEntries = T extends [infer Key, any][] + ? { [K in Cast]: Extract, [K, any]>[1] } // <- Change here `string` -> `PropertyKey`. + : { [key in string]: any } + +export type FromEntriesWithReadOnly = FromEntries> + +declare global { + interface ObjectConstructor { + fromEntries(obj: T): FromEntriesWithReadOnly + } +} diff --git a/src/utils/ts-utils.ts b/src/utils/ts-utils.ts new file mode 100644 index 00000000..0492bd80 --- /dev/null +++ b/src/utils/ts-utils.ts @@ -0,0 +1,81 @@ +export type PrefixString< + T extends string, + Prefix extends string +> = `${Prefix}${T}` + +export type PrefixKeys = { + [key in keyof T as PrefixString]: Val extends void + ? T[key] + : Val +} + +export type SuffixString< + T extends string, + Suffix extends string +> = `${T}${Suffix}` + +export type SuffixKeys = { + [key in keyof T as SuffixString]: Val extends void + ? T[key] + : Val +} + +export function prefixKeys( + obj: T, + prefix: Prefix +) { + return Object.fromEntries( + Object.entries(obj).map(([key, val]) => [`${prefix}${key}`, val]) + ) as PrefixKeys +} + +export function suffixKeys( + obj: T, + suffix: Suffix +) { + return Object.fromEntries( + Object.entries(obj).map(([key, val]) => [`${key}${suffix}`, val]) + ) as SuffixKeys +} + +export type AffixValues< + T extends Record, + Prefix extends string = '', + Suffix extends string = '' +> = { + [key in keyof T]: `${Prefix}${T[key]}${Suffix}` +} + +export function affixValues< + T extends Record, + Prefix extends string = '', + Suffix extends string = '' +>(obj: T, { prefix, suffix }: { prefix?: Prefix; suffix?: Suffix }) { + return Object.fromEntries( + Object.entries(obj).map(([key, val]) => [ + key, + `${prefix || ''}${val}${suffix || ''}`, + ]) + ) as AffixValues +} + +export type AffixKeyToValue< + T extends Record, + Prefix extends string = '', + Suffix extends string = '' +> = { + [key in keyof T]: `${Prefix}${key & string}${Suffix}` +} + +export function affixKeysToValues< + T extends Record, + Prefix extends string = '', + Suffix extends string = '' +>(obj: T, { prefix, suffix }: { prefix?: Prefix; suffix?: Suffix }) { + return Object.fromEntries( + Object.entries(obj).map(([key]) => [ + key, + `${prefix || ''}${key}${suffix || ''}`, + ]) + ) as AffixKeyToValue +} diff --git a/yarn.lock b/yarn.lock index 693d6d08..4f49857f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4240,6 +4240,7 @@ __metadata: "@monaco-editor/react": 4.5.1 "@pluralsh/eslint-config-typescript": 2.5.41 "@react-aria/utils": 3.17.0 + "@react-hooks-library/core": 0.5.1 "@react-stately/utils": 3.6.0 "@react-types/shared": 3.18.1 "@storybook/addon-actions": 7.0.18 @@ -4308,6 +4309,7 @@ __metadata: storybook: 7.0.18 styled-components: 5.3.11 styled-container-query: 1.3.5 + type-fest: 3.11.1 typescript: 4.9.5 use-immer: 0.9.0 usehooks-ts: 2.9.1 @@ -5168,6 +5170,24 @@ __metadata: languageName: node linkType: hard +"@react-hooks-library/core@npm:0.5.1": + version: 0.5.1 + resolution: "@react-hooks-library/core@npm:0.5.1" + dependencies: + "@react-hooks-library/shared": 0.5.1 + peerDependencies: + react: ">=16.9.0" + checksum: d334e5705cf2a12ce6344cb31489c40ae891529f121c558730908aec86a43658b03b8ec4c94ace408ad88f640b0ca760365cbca2966369429f349c9102011a4c + languageName: node + linkType: hard + +"@react-hooks-library/shared@npm:0.5.1": + version: 0.5.1 + resolution: "@react-hooks-library/shared@npm:0.5.1" + checksum: 8c1271d440c7e1cff32bda700e230223c6f7e1c9cbc0784fa641ab0b29fafccb2c539f37705a24d55b3db758da491d55b23dd63d27f4c7469eff99553a3812e7 + languageName: node + linkType: hard + "@react-spring/animated@npm:~9.7.2": version: 9.7.2 resolution: "@react-spring/animated@npm:9.7.2" @@ -19133,6 +19153,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:3.11.1": + version: 3.11.1 + resolution: "type-fest@npm:3.11.1" + checksum: 33be49e3b671c2ff3b5e320ef8c160c488205b08ab7631369116909a1baf2aebfcf45234c045e6902b8aa35730ac2bfd0655ea9e0fe3f8d26af9d99a16b07abd + languageName: node + linkType: hard + "type-fest@npm:^0.16.0": version: 0.16.0 resolution: "type-fest@npm:0.16.0"