diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a63186762..e8badb6ef3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2082,6 +2082,33 @@ importers: specifier: workspace:* version: link:../../tools/testing-utils + spectrum: + dependencies: + '@semcore/base-trigger': + specifier: ^17.2.1 + version: link:../semcore/base-trigger + '@semcore/button': + specifier: ^17.2.1 + version: link:../semcore/button + '@semcore/core': + specifier: ^17.2.1 + version: link:../semcore/core + '@semcore/dropdown-menu': + specifier: ^17.3.0 + version: link:../semcore/dropdown-menu + '@semcore/icon': + specifier: ^17.2.0 + version: link:../semcore/icon + '@semcore/illustration': + specifier: ^16.0.0 || ^17.0.1 + version: link:../semcore/illustration + react: + specifier: 16.8 - 19 + version: 18.3.1 + react-dom: + specifier: 16.8 - 19 + version: 18.3.1(react@18.3.1) + stories: dependencies: '@storybook/icons': @@ -19540,7 +19567,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(happy-dom@9.20.3)(jsdom@22.1.0)(lightningcss@1.31.1)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(happy-dom@9.20.3)(jsdom@22.1.0)(lightningcss@1.31.1)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.48.0)(yaml@2.8.3) '@vitest/utils@3.2.4': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index faa842031c..44568956ba 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'semcore/*' + - 'spectrum' - 'tools/*' - 'website' - 'stories' diff --git a/spectrum/package.json b/spectrum/package.json new file mode 100644 index 0000000000..fd3416b5ae --- /dev/null +++ b/spectrum/package.json @@ -0,0 +1,34 @@ +{ + "name": "@semcore/spectrum", + "version": "17.3.0", + "description": "Semrush design system package with adobe skin.", + "author": "UI-kit team ", + "license": "MIT", + "sideEffects": false, + "scripts": { + "build": "pnpm vite build && tsgo --emitDeclarationOnly" + }, + "dependencies": { + "@semcore/core": "^17.2.1", + "@semcore/base-trigger": "^17.2.1", + "@semcore/button": "^17.2.1", + "@semcore/dropdown-menu": "^17.3.0" + }, + "peerDependencies": { + "react": "16.8 - 19", + "react-dom": "16.8 - 19", + "@semcore/icon": "^17.2.0", + "@semcore/illustration": "^16.0.0 || ^17.0.1" + }, + "devDependencies": { + "@semcore/core": "workspace:*", + "@semcore/base-trigger": "workspace:*", + "@semcore/button": "workspace:*", + "@semcore/dropdown-menu": "workspace:*" + }, + "repository": { + "type": "git", + "url": "https://github.com/semrush/intergalactic.git", + "directory": "semcore/spectrum" + } +} diff --git a/spectrum/src/base-trigger/FilterTrigger.tsx b/spectrum/src/base-trigger/FilterTrigger.tsx new file mode 100644 index 0000000000..b6fd61e527 --- /dev/null +++ b/spectrum/src/base-trigger/FilterTrigger.tsx @@ -0,0 +1,22 @@ +import { FilterTrigger as UIFilterTrigger } from '@semcore/base-trigger'; +import React from 'react'; + +import styles from './filterTrigger.module.css'; + +function FilterTrigger(props: any) { + const classNames = [styles.filterTrigger, styles.sizeM]; + + if (!props.empty || props.active) { + classNames.push(styles.selected); + } + + return ; +} + +FilterTrigger.Text = UIFilterTrigger.Text; +FilterTrigger.Addon = UIFilterTrigger.Addon; +FilterTrigger.Counter = UIFilterTrigger.Counter; +FilterTrigger.TriggerButton = UIFilterTrigger.TriggerButton; +FilterTrigger.ClearButton = UIFilterTrigger.ClearButton; + +export default FilterTrigger; diff --git a/spectrum/src/base-trigger/filterTrigger.module.css b/spectrum/src/base-trigger/filterTrigger.module.css new file mode 100644 index 0000000000..18217017ce --- /dev/null +++ b/spectrum/src/base-trigger/filterTrigger.module.css @@ -0,0 +1,15 @@ +.filterTrigger.sizeM button { + background: rgb(240, 240, 240); + border-color: rgb(240, 240, 240); + padding: 0 14px; +} + +.filterTrigger.sizeM.selected button { + color: #fff; + background: var(--intergalactic-control-primary-info, oklch(0.23 0.01 140)); + border-color: var(--intergalactic-control-primary-info, oklch(0.23 0.01 140)); +} + +.filterTrigger.sizeM.selected button [data-ui-name='FilterTrigger.Text'] { + color: #fff; +} diff --git a/spectrum/src/base-trigger/index.ts b/spectrum/src/base-trigger/index.ts new file mode 100644 index 0000000000..af02d72ea9 --- /dev/null +++ b/spectrum/src/base-trigger/index.ts @@ -0,0 +1,5 @@ +import FilterTrigger from './FilterTrigger.tsx'; + +export { + FilterTrigger, +}; diff --git a/spectrum/src/button/Button.tsx b/spectrum/src/button/Button.tsx new file mode 100644 index 0000000000..523d9eb4f2 --- /dev/null +++ b/spectrum/src/button/Button.tsx @@ -0,0 +1,30 @@ +import UIButton from '@semcore/button'; +import React from 'react'; + +import styles from './button.module.css'; +import type { NSButton } from './Button.type.ts'; + +function Button(props: NSButton.Props) { + const classNames = [styles.button, styles.size]; + + if (props.theme === 'outline') { + classNames.push(styles.outline); + + if (props.use === 'primary') { + classNames.push(styles.primaryOutline); + } + } + + if (props.use === 'accent') { + classNames.push(styles.accent); + } + + // @ts-expect-error because of spectrum theme + return ; +} + +Button.Text = UIButton.Text; + +Button.Addon = UIButton.Addon; + +export default Button; diff --git a/spectrum/src/button/Button.type.ts b/spectrum/src/button/Button.type.ts new file mode 100644 index 0000000000..5f53bd12ce --- /dev/null +++ b/spectrum/src/button/Button.type.ts @@ -0,0 +1,12 @@ +import type { ButtonProps } from '@semcore/button'; + +declare namespace NSButton { + type Props = Omit & { + use: ButtonProps['use'] | 'accent'; + theme: ButtonProps['theme'] | 'outline'; + }; +} + +export { + NSButton, +}; diff --git a/spectrum/src/button/ButtonLink.tsx b/spectrum/src/button/ButtonLink.tsx new file mode 100644 index 0000000000..5ab0171d86 --- /dev/null +++ b/spectrum/src/button/ButtonLink.tsx @@ -0,0 +1,3 @@ +export default function () { + return 'Button link'; +} diff --git a/spectrum/src/button/button.module.css b/spectrum/src/button/button.module.css new file mode 100644 index 0000000000..07db2c3dfc --- /dev/null +++ b/spectrum/src/button/button.module.css @@ -0,0 +1,23 @@ +.button.size { + border-width: 2px; + border-radius: 16px; +} + +.button.outline { + background: transparent; +} + +.button.primaryOutline { + color: var(--intergalactic-control-primary-info, oklch(0.23 0.01 140)); + border-color: var(--intergalactic-control-primary-info, oklch(0.23 0.01 140)); +} +.button.primaryOutline:hover { + color: var(--intergalactic-control-primary-info, oklch(0.23 0.01 140)); + background: var(--intergalactic-control-secondary-neutral-hover, + oklch(0.176 0.033 175.6 / 0.056)); +} + +.button.accent { + color: #fff; + background-color: #3b63fb; +} diff --git a/spectrum/src/button/index.ts b/spectrum/src/button/index.ts new file mode 100644 index 0000000000..8299fec78f --- /dev/null +++ b/spectrum/src/button/index.ts @@ -0,0 +1,12 @@ +import Button from './Button.tsx'; +import type { NSButton as NSSpectrumButton } from './Button.type.ts'; +import ButtonLink from './ButtonLink.tsx'; + +export type { + NSSpectrumButton, +}; + +export { + Button as default, + ButtonLink, +}; diff --git a/spectrum/src/dropdown-menu/DropdownMenu.tsx b/spectrum/src/dropdown-menu/DropdownMenu.tsx new file mode 100644 index 0000000000..cc90ae4658 --- /dev/null +++ b/spectrum/src/dropdown-menu/DropdownMenu.tsx @@ -0,0 +1,13 @@ +import UIDropdownMenu from '@semcore/dropdown-menu'; +import React from 'react'; + +function DropdownMenu(props: any) { + return ; +} + +DropdownMenu.Trigger = UIDropdownMenu.Trigger; +DropdownMenu.Menu = UIDropdownMenu.Menu; +DropdownMenu.Group = UIDropdownMenu.Group; +DropdownMenu.Item = UIDropdownMenu.Item; + +export default DropdownMenu; diff --git a/spectrum/src/dropdown-menu/index.ts b/spectrum/src/dropdown-menu/index.ts new file mode 100644 index 0000000000..2ba5746cd5 --- /dev/null +++ b/spectrum/src/dropdown-menu/index.ts @@ -0,0 +1,3 @@ +import DropdownMenu from './DropdownMenu'; + +export default DropdownMenu; diff --git a/spectrum/tsconfig.json b/spectrum/tsconfig.json new file mode 100644 index 0000000000..2f2f3edfab --- /dev/null +++ b/spectrum/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib" + } +} diff --git a/spectrum/vite.config.ts b/spectrum/vite.config.ts new file mode 100644 index 0000000000..fb38717523 --- /dev/null +++ b/spectrum/vite.config.ts @@ -0,0 +1,29 @@ +import { unpluginSemcoreResolve } from '@semcore/builder/plugins'; +import copy from 'rollup-plugin-copy'; +import { defineConfig, mergeConfig } from 'vite'; + +import viteConfig from '../commonVite.config'; + +export default defineConfig({ + esbuild: { + legalComments: 'inline', + }, + build: { + minify: false, + emptyOutDir: true, + outDir: 'lib', + lib: { + entry: { + 'base-trigger': './src/base-trigger/index.ts', + 'button': './src/button/index.ts', + 'dropdown-menu': './src/dropdown-menu/index.ts', + }, + formats: ['es'], + fileName: (format, entryName) => `${entryName}.mjs`, + }, + rollupOptions: { + treeshake: false, + external: ['react', 'react-dom', 'react/jsx-runtime', /@babel\/runtime\/*/, /@semcore\/*/], + }, + }, +}); diff --git a/stories/spectrum/button/button.stories.tsx b/stories/spectrum/button/button.stories.tsx new file mode 100644 index 0000000000..663813b215 --- /dev/null +++ b/stories/spectrum/button/button.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; + +import ButtonBaseExample, { defaultButtonProps } from './examples/button_base'; +import { themeArg, THEMES } from './themeUtils'; + +const meta: Meta = { title: 'Spectrum/Button' }; +export default meta; + +const commonArgTypes = { + size: { control: { type: 'select' }, options: ['m', 'l'] }, + use: { control: { type: 'select' }, options: Object.keys(THEMES).concat('accent') }, + theme: { table: { disable: true } }, + theme_primary: themeArg('primary'), + theme_secondary: themeArg('secondary'), + theme_tertiary: themeArg('tertiary'), + active: { control: { type: 'boolean' } }, + disabled: { control: { type: 'boolean' } }, + loading: { control: { type: 'boolean' } }, + style: { control: { type: 'select' }, options: [undefined, { backgroundColor: '#191B23' }] }, +} as const; + +const themeDefaults = { + theme_primary: THEMES.primary[0], + theme_secondary: THEMES.secondary[0], + theme_tertiary: THEMES.tertiary[0], + theme_accent: THEMES.accent[0], +}; + +export const ButtonBase: StoryObj = { + render: ButtonBaseExample, + argTypes: commonArgTypes, + args: { ...defaultButtonProps, ...themeDefaults }, +}; diff --git a/stories/spectrum/button/examples/button_base.tsx b/stories/spectrum/button/examples/button_base.tsx new file mode 100644 index 0000000000..14b38829e7 --- /dev/null +++ b/stories/spectrum/button/examples/button_base.tsx @@ -0,0 +1,108 @@ +import VideoListM from '@semcore/icon/VideoList/m'; +import Button from '@semcore/spectrum/button'; +import type { NSSpectrumButton } from '@semcore/spectrum/button'; +import Badge from '@semcore/ui/badge'; +import { Flex } from '@semcore/ui/base-components'; +import React from 'react'; + +import { themeFor } from '../themeUtils'; + +type ExampleProps = Exclude & { style?: any; theme: string }; +const Demo = (props: ExampleProps) => { + const theme = themeFor(props); + + return ( + + + + + + + + + + + + + + + + + ); +}; + +export const defaultButtonProps: ExampleProps = { + size: 'm', + use: 'primary', + theme: 'info', + hintPlacement: 'top', + style: undefined, // this prop is needed for background to verify some button themes (primary invert for instance) +}; + +Demo.defaultProps = defaultButtonProps; +export default Demo; diff --git a/stories/spectrum/button/themeUtils.ts b/stories/spectrum/button/themeUtils.ts new file mode 100644 index 0000000000..650aaf2faa --- /dev/null +++ b/stories/spectrum/button/themeUtils.ts @@ -0,0 +1,26 @@ +import type { ButtonProps } from '@semcore/button'; + +type Use = NonNullable; +type Theme = ButtonProps['theme'] | 'outline'; + +// Allowed `theme` per `use`. Mirrors SButton[theme='-'] in +// semcore/button/src/component/Button/button.shadow.css. First entry = default. +export const THEMES: Record = { + primary: ['info', 'success', 'brand', 'danger', 'invert'], + secondary: ['muted', 'invert', 'info'], + tertiary: ['muted', 'invert', 'info'], + accent: ['info'], +}; + +export const themeFor = (args: any): Theme => { + const use: Use = args.use ?? 'primary'; + const picked = args[`theme_${use}`]; + return THEMES[use].includes(picked) ? picked : THEMES[use][0]; +}; + +export const themeArg = (use: Use) => ({ + name: `theme (${use})`, + control: { type: 'select' as const }, + options: THEMES[use], + if: { arg: 'use', eq: use }, +}); diff --git a/stories/spectrum/dropdown-menu/Dropdown-menu.stories.tsx b/stories/spectrum/dropdown-menu/Dropdown-menu.stories.tsx new file mode 100644 index 0000000000..4885c9dd19 --- /dev/null +++ b/stories/spectrum/dropdown-menu/Dropdown-menu.stories.tsx @@ -0,0 +1,16 @@ +import DropdownMenu from '@semcore/ui/dropdown-menu'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import BasicExample from './examples/dropdown_menu_base.tsx'; + +const meta: Meta = { + title: 'Spectrum/Dropdown Menu', + component: DropdownMenu, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: BasicExample, +}; diff --git a/stories/spectrum/dropdown-menu/examples/dropdown_menu_base.tsx b/stories/spectrum/dropdown-menu/examples/dropdown_menu_base.tsx new file mode 100644 index 0000000000..bdf3c73edb --- /dev/null +++ b/stories/spectrum/dropdown-menu/examples/dropdown_menu_base.tsx @@ -0,0 +1,40 @@ +import { FilterTrigger } from '@semcore/spectrum/base-trigger'; +import DropdownMenu from '@semcore/spectrum/dropdown-menu'; +import React from 'react'; + +const menuItems: null[] = new Array(10).fill(null); + +const Demo = () => { + const [selected, setSelected] = React.useState([]); + + return ( + + + {selected.join(', ')} + + + + {menuItems.map((_, index) => ( + { + if (!selected.includes(index)) { + setSelected([...selected, index]); + } else { + setSelected(selected.filter((i) => i !== index)); + } + }} + > + Menu item + {' '} + {index + 1} + + ))} + + + + ); +}; + +export default Demo; diff --git a/tools/builder/src/unplugin-semcore.ts b/tools/builder/src/unplugin-semcore.ts index 7886b21653..a43f8816c6 100644 --- a/tools/builder/src/unplugin-semcore.ts +++ b/tools/builder/src/unplugin-semcore.ts @@ -5,7 +5,7 @@ export const unpluginSemcoreResolve = createUnplugin<{ rootPath: string }>((opts name: 'semcore-resolve', async resolveId(id) { - if (!id.includes('@semcore') || id.includes('@semcore/theme')) return null; + if (!id.includes('@semcore') || id.includes('@semcore/theme') || id.includes('@semcore/spectrum')) return null; if (id.endsWith('.md')) return null; return await resolveSemcoreSources(id, opts.rootPath); }, diff --git a/tsconfig.json b/tsconfig.json index 94e72ec746..e35e81eb9f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,12 +23,13 @@ "@semcore/ui/d3-chart": ["${configDir}/semcore/d3-chart"], "@semcore/ui/core/lib/*": ["${configDir}/semcore/core/src/*"], "@semcore/ui/*": ["${configDir}/semcore/*/src"], + "@semcore/spectrum/*": ["${configDir}/spectrum/src/*"], "@semcore/*": ["${configDir}/semcore/*/src"], "@tools/*": ["${configDir}/tools/*"] } }, - "project": ["./semcore/*/tsconfig.json", "./tools/*/tsconfig.json"], - "include": ["semcore", "tools", "stories", "playground"], + "project": ["./semcore/*/tsconfig.json", "./tools/*/tsconfig.json", "./spectrum/tsconfig.json"], + "include": ["semcore", "tools", "stories", "playground", "spectrum"], "exclude": [ "node_modules", "tools/icon-transform-svg/__tests__" diff --git a/vite.config.ts b/vite.config.ts index 92a4239679..44f35f77e6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,6 +38,14 @@ export default defineConfig({ }, })).vite({}), ], + resolve: { + alias: { + '@semcore/spectrum/core': resolvePath(rootDir, 'spectrum', 'src', 'core'), + '@semcore/spectrum/button': resolvePath(rootDir, 'spectrum', 'src', 'button'), + '@semcore/spectrum/dropdown-menu': resolvePath(rootDir, 'spectrum', 'src', 'dropdown-menu'), + '@semcore/spectrum/base-trigger': resolvePath(rootDir, 'spectrum', 'src', 'base-trigger'), + }, + }, build: { rollupOptions: { external: [