diff --git a/package.json b/package.json index 8bf1b07..066b85a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "dependencies": { + "@seznam/compose-react-refs": "^1.0.6", + "@tanstack/react-query": "^4.33.0", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", @@ -10,11 +12,22 @@ "@types/node": "^16.7.13", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "axios": "^1.4.0", + "clsx": "^2.0.0", + "date-fns": "^2.30.0", + "focus-visible": "^5.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.45.4", + "react-router-dom": "^6.15.0", "react-scripts": "5.0.1", + "sanitize.css": "^13.0.0", + "sass": "^1.66.1", + "ts-key-enum": "^2.0.12", "typescript": "^4.4.2", - "web-vitals": "^2.1.0" + "web-vitals": "^2.1.0", + "zustand": "^4.4.1" }, "scripts": { "start": "react-scripts start", @@ -39,5 +52,9 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/date-fns": "^2.6.0", + "@types/react-helmet": "^6.1.6" } } diff --git a/src/.prettierrc.js b/src/.prettierrc.js new file mode 100644 index 0000000..92d8a9a --- /dev/null +++ b/src/.prettierrc.js @@ -0,0 +1,12 @@ +module.exports = { + arrowParens: 'always', + bracketSameLine: true, + bracketSpacing: true, + endOfLine: 'auto', + printWidth: 120, + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'none', + useTabs: false +}; diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 74b5e05..0000000 --- a/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 2a68616..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index a53698a..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import logo from './logo.svg'; -import './App.css'; - -function App() { - return ( -
-
- logo -

- Edit src/App.tsx and save to reload. -

- - Learn React - -
-
- ); -} - -export default App; diff --git a/src/api/deck/createAnswer.ts b/src/api/deck/createAnswer.ts new file mode 100644 index 0000000..1eec40c --- /dev/null +++ b/src/api/deck/createAnswer.ts @@ -0,0 +1,36 @@ +import {Answer, PitchDeck} from './types'; +import {DECKS_API_URL, QUESTION_API_URL, QUESTION_PARAM_DECK_ID, QUESTION_PARAM_QUESTION_ID} from './urlKeys'; +import {MutationConfig, queryClient} from '../../lib/react-query'; +import {useMutation} from '@tanstack/react-query'; +import {QUERY_KEY_ANSWER} from './queryKeys'; +import { axios } from '../../lib/axios'; + +export type CreateAnswerDTO = { + deckId: number; + questionId: number; + answer: any; +}; + +export type CreateAnswerResponse = Answer; + +export const createAnswer = (data: CreateAnswerDTO): Promise => { + const path = QUESTION_API_URL + .replace(`:${QUESTION_PARAM_DECK_ID}`, String(data.deckId)) + .replace(`:${QUESTION_PARAM_QUESTION_ID}`, String(data.questionId)) + '/' + + return axios.post(path, { answer: data.answer }); +}; + +type UseCreateAnswerOptions = { + config?: MutationConfig; +}; + +export const useCreateAnswer = ({ config }: UseCreateAnswerOptions = {}) => { + return useMutation({ + onMutate: async () => { + await queryClient.cancelQueries([QUERY_KEY_ANSWER]); + }, + ...config, + mutationFn: createAnswer + }); +}; diff --git a/src/api/deck/createDeck.ts b/src/api/deck/createDeck.ts new file mode 100644 index 0000000..e3b7b6c --- /dev/null +++ b/src/api/deck/createDeck.ts @@ -0,0 +1,29 @@ +import {PitchDeck} from './types'; +import {DECKS_API_URL} from './urlKeys'; +import {MutationConfig, queryClient} from '../../lib/react-query'; +import {useMutation} from '@tanstack/react-query'; +import {QUERY_KEY_DECKS} from './queryKeys'; +import { axios } from '../../lib/axios'; + +export type CreateDeckDTO = Pick; + +export type CreateDeckResponse = PitchDeck; + +export const createDeck = (data: CreateDeckDTO): Promise => { + return axios.post(DECKS_API_URL, data); +}; + +type UseCreateDeckOptions = { + config?: MutationConfig; +}; + +export const useCreateDeck = ({ config }: UseCreateDeckOptions = {}) => { + + return useMutation({ + onMutate: async () => { + await queryClient.cancelQueries([QUERY_KEY_DECKS]); + }, + ...config, + mutationFn: createDeck + }); +}; diff --git a/src/api/deck/getFirstQuestion.ts b/src/api/deck/getFirstQuestion.ts new file mode 100644 index 0000000..15aa23e --- /dev/null +++ b/src/api/deck/getFirstQuestion.ts @@ -0,0 +1,31 @@ +import {Question} from './types'; +import {axios} from '../../lib/axios'; +import {FIRST_QUESTION_API_URL, FIRST_QUESTION_PARAM} from './urlKeys'; +import {ExtractFnReturnType, QueryConfig} from '../../lib/react-query'; +import {useQuery} from '@tanstack/react-query'; +import {QUERY_KEY_FIRST_QUESTION} from './queryKeys'; + +export type GetFirstQuestionResponse = Question; + +export const getFirstQuestion = ({ deckId }: { deckId: number; }): Promise => { + return axios.get(FIRST_QUESTION_API_URL.replace(`:${FIRST_QUESTION_PARAM}`, String(deckId))); +}; + +type QueryFnType = typeof getFirstQuestion; + +type UseFirstQuestionOptions = { + deckId: number; + config?: QueryConfig; +}; + +export const useFirstQuestion = ({ deckId, config }: UseFirstQuestionOptions) => { + return useQuery>({ + ...config, + queryKey: [QUERY_KEY_FIRST_QUESTION, deckId], + queryFn: async () => { + const process = await getFirstQuestion({ deckId }); + + return process; + }, + }); +}; diff --git a/src/api/deck/getQuestion.ts b/src/api/deck/getQuestion.ts new file mode 100644 index 0000000..e0f546a --- /dev/null +++ b/src/api/deck/getQuestion.ts @@ -0,0 +1,38 @@ +import {Question} from './types'; +import {axios} from '../../lib/axios'; +import { + QUESTION_API_URL, + QUESTION_PARAM_DECK_ID, + QUESTION_PARAM_QUESTION_ID, +} from './urlKeys'; +import {ExtractFnReturnType, QueryConfig} from '../../lib/react-query'; +import {useQuery} from '@tanstack/react-query'; +import {QUERY_KEY_QUESTION} from './queryKeys'; + +export type GetQuestionResponse = Question; + +export const getQuestion = ({ deckId, questionId }: { deckId: number; questionId: number; }): Promise => { + return axios.get( + QUESTION_API_URL + .replace(`:${QUESTION_PARAM_DECK_ID}`, String(deckId)) + .replace(`:${QUESTION_PARAM_QUESTION_ID}`, String(questionId)) + ); +}; + +type QueryFnType = typeof getQuestion; + +type UseQuestionOptions = { + deckId: number; + questionId: number; + config?: QueryConfig; +}; + +export const useQuestion = ({ deckId, questionId, config }: UseQuestionOptions) => { + return useQuery>({ + ...config, + queryKey: [QUERY_KEY_QUESTION, deckId, questionId], + queryFn: async () => { + return await getQuestion({ deckId, questionId }); + }, + }); +}; diff --git a/src/api/deck/index.ts b/src/api/deck/index.ts new file mode 100644 index 0000000..cdb7fe3 --- /dev/null +++ b/src/api/deck/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './urlKeys'; +export * from './queryKeys'; + +export * from './createDeck'; +export * from './getFirstQuestion'; \ No newline at end of file diff --git a/src/api/deck/queryKeys.ts b/src/api/deck/queryKeys.ts new file mode 100644 index 0000000..d414265 --- /dev/null +++ b/src/api/deck/queryKeys.ts @@ -0,0 +1,4 @@ +export const QUERY_KEY_DECKS = 'decks'; +export const QUERY_KEY_FIRST_QUESTION = 'firstQuestion'; +export const QUERY_KEY_QUESTION = 'question'; +export const QUERY_KEY_ANSWER = 'answer'; \ No newline at end of file diff --git a/src/api/deck/types.ts b/src/api/deck/types.ts new file mode 100644 index 0000000..98ddcd3 --- /dev/null +++ b/src/api/deck/types.ts @@ -0,0 +1,45 @@ +export enum EntityType { + text = 'text', // + number = 'number', // + range = 'range', // + multiple_range = 'multiple_range', // + select = 'select', // + link = 'link', // добавить валидацию + date = 'date', // добавить правильную установку хинта + photo = 'photo', + multiple_photo = 'multiple_photo', + photo_description = 'photo_description', + multiple_link_description = 'multiple_link_description', + multiple_photo_description = 'multiple_photo_description', + multiple_links = 'multiple_links', + multiple_date_description = 'multiple_date_description', // + text_array = 'text_array', // используется только в подсказке + cards = 'cards', // используется только в подсказке +} + +export type PitchDeck = { + id: number; + name: string; + description?: string; + questions?: any[]; +}; + +export type Hint = { + type: EntityType; + value: any; +} + +export type Question = { + id: number; + text: string; + type: EntityType; + hint: Hint | false; + next_id: number; + params: { [key: string]: any } | null; +}; + +export type Answer = { + answer: any; + deck: number; + question: number; +} \ No newline at end of file diff --git a/src/api/deck/urlKeys.ts b/src/api/deck/urlKeys.ts new file mode 100644 index 0000000..53c8b2b --- /dev/null +++ b/src/api/deck/urlKeys.ts @@ -0,0 +1,9 @@ +export const DECKS_API_URL = '/decks/'; + + +export const FIRST_QUESTION_PARAM = 'deckId'; +export const FIRST_QUESTION_API_URL = `/decks/question/:${FIRST_QUESTION_PARAM}`; + +export const QUESTION_PARAM_DECK_ID = 'deckId'; +export const QUESTION_PARAM_QUESTION_ID = 'questionId'; +export const QUESTION_API_URL = `/decks/question/:${FIRST_QUESTION_PARAM}/:${QUESTION_PARAM_QUESTION_ID}`; \ No newline at end of file diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..d5ddfc2 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import {AppRoutes} from './routes'; + +export function App() { + return ( + <> + + + + + + ); +} + diff --git a/src/app/AppProvider.tsx b/src/app/AppProvider.tsx new file mode 100644 index 0000000..3a1fa35 --- /dev/null +++ b/src/app/AppProvider.tsx @@ -0,0 +1,13 @@ +import {FC, PropsWithChildren} from 'react'; +import {BrowserRouter} from 'react-router-dom'; +import {ReactQueryProvider} from './providers'; + +export const AppProvider: FC = ({ children }) => { + return ( + + + {children} + + + ) +} \ No newline at end of file diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 0000000..77b9d24 --- /dev/null +++ b/src/app/index.ts @@ -0,0 +1,2 @@ +export * from './App'; +export * from './AppProvider'; \ No newline at end of file diff --git a/src/app/providers/index.ts b/src/app/providers/index.ts new file mode 100644 index 0000000..0dd2cb5 --- /dev/null +++ b/src/app/providers/index.ts @@ -0,0 +1 @@ +export * from './react-query'; \ No newline at end of file diff --git a/src/app/providers/react-query.tsx b/src/app/providers/react-query.tsx new file mode 100644 index 0000000..bd4f159 --- /dev/null +++ b/src/app/providers/react-query.tsx @@ -0,0 +1,11 @@ +import {ReactFCC} from '../../utils/ReactFCC'; +import {QueryClientProvider} from '@tanstack/react-query'; +import {queryClient} from '../../lib/react-query'; + +export const ReactQueryProvider: ReactFCC = ({ children }) => { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx new file mode 100644 index 0000000..fc18f3c --- /dev/null +++ b/src/app/routes/AppRoutes.tsx @@ -0,0 +1,13 @@ +import {Route, Routes} from 'react-router-dom'; +import {ChatPage} from '../../pages/chat'; +import {CHAT_PAGE_ROUTE} from './routes'; + +export const AppRoutes = () => { + + return ( + + } /> + + ); +}; + diff --git a/src/app/routes/index.ts b/src/app/routes/index.ts new file mode 100644 index 0000000..61f74e0 --- /dev/null +++ b/src/app/routes/index.ts @@ -0,0 +1,2 @@ +export * from './AppRoutes'; +export * from './routes'; \ No newline at end of file diff --git a/src/app/routes/routes.ts b/src/app/routes/routes.ts new file mode 100644 index 0000000..972cf7b --- /dev/null +++ b/src/app/routes/routes.ts @@ -0,0 +1 @@ +export const CHAT_PAGE_ROUTE = `/chat`; \ No newline at end of file diff --git a/src/app/styles/baseText.scss b/src/app/styles/baseText.scss new file mode 100644 index 0000000..8f210f3 --- /dev/null +++ b/src/app/styles/baseText.scss @@ -0,0 +1,153 @@ +$font-family-raleway: 'Raleway'; +$font-family-ibm-plex-mono: 'IBM Plex Mono'; + +$font-family-default: system-ui, /* macOS 10.11-10.12 */ -apple-system, /* Windows 6+ */ 'Segoe UI', + /* Android 4+ */ 'Roboto', /* Ubuntu 10.10+ */ 'Ubuntu', /* Gnome 3+ */ 'Cantarell', /* KDE Plasma 5+ */ 'Noto Sans', + /* fallback */ sans-serif, /* macOS emoji */ 'Apple Color Emoji', /* Windows emoji */ 'Segoe UI Emoji', + /* Windows emoji */ 'Segoe UI Symbol', /* Linux emoji */ 'Noto Color Emoji'; + +// Моноширинный набор шрифтов. +$font-family-mono: 'Menlo', 'Consolas', 'Roboto Mono', 'Ubuntu Monospace', 'Noto Mono', 'Oxygen Mono', 'Liberation Mono', + monospace, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + +$font-size-10: 10px; +$font-size-12: 12px; +$font-size-14: 14px; +$font-size-16: 16px; +$font-size-18: 18px; +$font-size-24: 24px; +$font-size-32: 32px; +$font-size-56: 56px; + +$font-weight-400: 400; +$font-weight-500: 500; +$font-weight-600: 600; +$font-weight-700: 700; + +$line-height-16: 16px; +$line-height-20: 20px; +$line-height-24: 24px; +$line-height-32: 32px; +$line-height-40: 40px; +$line-height-67: 67px; + +@mixin text-header-h1 { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-56; + font-weight: $font-weight-700; + line-height: $line-height-67; +} + +@mixin text-header-h2 { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-32; + font-weight: $font-weight-700; + line-height: $line-height-40; +} + +@mixin text-header-h3 { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-24; + font-weight: $font-weight-700; + line-height: $line-height-32; +} + +@mixin text-header-h4 { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-18; + font-weight: $font-weight-700; + line-height: $line-height-24; +} + +@mixin text-body-l-medium { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-16; + font-weight: $font-weight-600; + line-height: $line-height-20; +} + +@mixin text-body-l-regular { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-16; + font-weight: $font-weight-400; + line-height: $line-height-20; +} + +@mixin text-body-m-medium { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-14; + font-weight: $font-weight-600; + line-height: $line-height-20; +} + +@mixin text-body-m-regular { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-14; + font-weight: $font-weight-400; + line-height: $line-height-20; +} + +@mixin text-body-s-medium { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-12; + font-weight: $font-weight-600; + line-height: $line-height-16; +} + +@mixin text-body-s-regular { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-12; + font-weight: $font-weight-400; + line-height: $line-height-16; +} + +@mixin text-caption-m-medium { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-14; + font-weight: $font-weight-500; + line-height: $line-height-20; +} + +@mixin text-caption-m-regular { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-14; + font-weight: $font-weight-400; + line-height: $line-height-20; +} + +@mixin text-caption-s-medium { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-10; + font-weight: $font-weight-500; + line-height: $line-height-16; +} + +@mixin text-caption-s-regular { + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-10; + font-weight: $font-weight-400; + line-height: $line-height-16; +} + +@mixin text-caption-all-caps { + letter-spacing: 0.04em; + text-transform: uppercase; + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-12; + font-weight: $font-weight-600; + line-height: $line-height-20; +} + +@mixin text-programming-code-medium { + font-family: $font-family-ibm-plex-mono, $font-family-mono; + font-size: $font-size-12; + font-weight: $font-weight-400; + line-height: $line-height-20; +} + +@mixin text-programming-code-regular { + font-family: $font-family-ibm-plex-mono, $font-family-mono; + font-size: $font-size-12; + font-weight: $font-weight-400; + line-height: $line-height-16; +} diff --git a/src/app/styles/breakpoints.scss b/src/app/styles/breakpoints.scss new file mode 100644 index 0000000..701932d --- /dev/null +++ b/src/app/styles/breakpoints.scss @@ -0,0 +1,71 @@ +$breakpoint-mobile-small: 320px; +$breakpoint-mobile-large: 375px; +$breakpoint-tablet-small: 768px; +$breakpoint-tablet-large: 1024px; +$breakpoint-desktop-small: 1280px; +$breakpoint-desktop-medium: 1440px; +$breakpoint-desktop-large: 1920px; + +@mixin media-up($breakpoint) { + @if $breakpoint == mobile-small { + @media screen and (min-width: $breakpoint-mobile-small) { + @content; + } + } @else if $breakpoint == mobile-large { + @media screen and (min-width: $breakpoint-mobile-large) { + @content; + } + } @else if $breakpoint == tablet-small { + @media screen and (min-width: $breakpoint-tablet-small) { + @content; + } + } @else if $breakpoint == tablet-large { + @media screen and (min-width: $breakpoint-tablet-large) { + @content; + } + } @else if $breakpoint == desktop-small { + @media screen and (min-width: $breakpoint-desktop-small) { + @content; + } + } @else if $breakpoint == desktop-medium { + @media screen and (min-width: $breakpoint-desktop-medium) { + @content; + } + } @else if $breakpoint == desktop-large { + @media screen and (min-width: $breakpoint-desktop-large) { + @content; + } + } +} + +@mixin media-down($breakpoint) { + @if $breakpoint == mobile-small { + @media screen and (max-width: $breakpoint-mobile-small - 1) { + @content; + } + } @else if $breakpoint == mobile-large { + @media screen and (max-width: $breakpoint-mobile-large - 1) { + @content; + } + } @else if $breakpoint == tablet-small { + @media screen and (max-width: $breakpoint-tablet-small - 1) { + @content; + } + } @else if $breakpoint == tablet-large { + @media screen and (max-width: $breakpoint-tablet-large - 1) { + @content; + } + } @else if $breakpoint == desktop-small { + @media screen and (max-width: $breakpoint-desktop-small - 1) { + @content; + } + } @else if $breakpoint == desktop-medium { + @media screen and (max-width: $breakpoint-desktop-medium - 1) { + @content; + } + } @else if $breakpoint == desktop-large { + @media screen and (max-width: $breakpoint-desktop-large - 1) { + @content; + } + } +} diff --git a/src/app/styles/colors.scss b/src/app/styles/colors.scss new file mode 100644 index 0000000..6a6bc7c --- /dev/null +++ b/src/app/styles/colors.scss @@ -0,0 +1,71 @@ +/* --- surface --- */ +$color-surface-primary: #141414; + +/* --- on-surface --- */ +$color-on-surface-dark-100: #1d1d1d; +$color-on-surface-dark-200: #252525; +$color-on-surface-dark-300: #2d2d2d; +$color-on-surface-dark-400: #3e3e3e; +$color-on-surface-light-400: #5e6166; +$color-on-surface-light-300: #868a92; +$color-on-surface-light-200: #c5c6ca; +$color-on-surface-light-100: #fafafa; +$color-on-surface-quaternary: #2b2b2b; + +/* --- text --- */ +$color-text-primary: #fafafa; +$color-text-secondary: #c5c6ca; +$color-text-tertiary: #868a92; +$color-text-quaternary: #5e6166; +$color-text-dark: #2b2b2b; +$color-text-brand: #8e85e5; + +/* --- brand --- */ +$color-brand-primary: #695fcf; +$color-brand-hover: #9288f8; +$color-brand-disabled: #49428e; + +/* --- system --- */ +$color-system-link: #7f9ef3; +$color-system-success: #10b981; +$color-system-warning: #f59e0b; +$color-system-error: #f43f5e; +$color-system-link-weak: #142144; +$color-system-success-weak: #042c1f; +$color-system-warning-weak: #3d2907; +$color-system-error-weak: #2d1015; +$color-system-link-hover: #a8c1f8; + +/* --- accent --- */ +$color-accent-blue-100: #7f9ef3; +$color-accent-blue-200: #2b4acb; +$color-accent-blue-300: #203175; +$color-accent-blue-400: #191e40; +$color-accent-pink-100: #f37fb7; +$color-accent-pink-200: #cb2b83; +$color-accent-pink-300: #75204f; +$color-accent-pink-400: #40162f; +$color-accent-purple-100: #ab7ae0; +$color-accent-purple-200: #642ab5; +$color-accent-purple-300: #3e2069; +$color-accent-purple-400: #21183b; +$color-accent-green-100: #8fd460; +$color-accent-green-200: #49aa19; +$color-accent-green-300: #306317; +$color-accent-green-400: #1d3712; +$color-accent-cyan-100: #58d1c9; +$color-accent-cyan-200: #13a8a8; +$color-accent-cyan-300: #146262; +$color-accent-cyan-400: #163b2e; +$color-accent-orange-100: #f3b765; +$color-accent-orange-200: #d87a16; +$color-accent-orange-300: #7c4a15; +$color-accent-orange-400: #442a11; +$color-accent-yellow-100: #f3ea62; +$color-accent-yellow-200: #d8bd14; +$color-accent-yellow-300: #7c6e14; +$color-accent-yellow-400: #443611; +$color-accent-red-100: #f37370; +$color-accent-red-200: #d32029; +$color-accent-red-300: #791a1f; +$color-accent-red-400: #421716; \ No newline at end of file diff --git a/src/app/styles/fonts.scss b/src/app/styles/fonts.scss new file mode 100644 index 0000000..a394c49 --- /dev/null +++ b/src/app/styles/fonts.scss @@ -0,0 +1,48 @@ +@import './baseText'; + +$fonts-path: '../../assets/fonts' !default; + +@font-face { + font-family: $font-family-raleway; + src: url('#{$fonts-path}/Raleway/Raleway-Regular.woff2') format('woff2'), + url('#{$fonts-path}/Raleway/Raleway-Regular.woff') format('woff'); + font-weight: $font-weight-400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: $font-family-raleway; + src: url('#{$fonts-path}/Raleway/Raleway-Medium.woff2') format('woff2'), + url('#{$fonts-path}/Raleway/Raleway-Medium.woff') format('woff'); + font-weight: $font-weight-500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: $font-family-raleway; + src: url('#{$fonts-path}/Raleway/Raleway-SemiBold.woff2') format('woff2'), + url('#{$fonts-path}/Raleway/Raleway-SemiBold.woff') format('woff'); + font-weight: $font-weight-600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: $font-family-raleway; + src: url('#{$fonts-path}/Raleway/Raleway-Bold.woff2') format('woff2'), + url('#{$fonts-path}/Raleway/Raleway-Bold.woff') format('woff'); + font-weight: $font-weight-700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: $font-family-ibm-plex-mono; + src: url('#{$fonts-path}/IBM_Plex_Mono/IBMPlexMono-Regular.woff2') format('woff2'), + url('#{$fonts-path}/IBM_Plex_Mono/IBMPlexMono-Regular.woff') format('woff'); + font-weight: $font-weight-400; + font-style: normal; + font-display: swap; +} diff --git a/src/app/styles/index.scss b/src/app/styles/index.scss new file mode 100644 index 0000000..c6beee2 --- /dev/null +++ b/src/app/styles/index.scss @@ -0,0 +1,37 @@ +@import './vars'; +@import './fonts'; + +html { + color: $color-text-primary; + //background-color: $color-surface-primary; + background-color: black; + //background-color: $color-on-surface-dark-100; + font-family: $font-family-raleway, $font-family-default; + font-size: $font-size-16; + line-height: $line-height-20; +} + +html, +body { + width: 100%; + margin: 0; + padding: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'pnum' on, 'lnum' on; +} + +h1, +h2, +h3, +h4, +h5, +h6, +p { + margin: 0; +} + +a { + text-decoration: none; + color: inherit; +} diff --git a/src/app/styles/mixins.scss b/src/app/styles/mixins.scss new file mode 100644 index 0000000..352e585 --- /dev/null +++ b/src/app/styles/mixins.scss @@ -0,0 +1,220 @@ +@use 'sass:list'; + +/* positions */ + +@mixin position-absolute-full-screen { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +/* size */ + +@mixin size($size) { + min-width: $size; + max-width: $size; + min-height: $size; + max-height: $size; +} + +/* placeholder */ + +@mixin placeholder { + &::placeholder { + @content; + } +} + +/* buttons */ + +@mixin reset-button { + align-items: flex-start; + margin: 0; + padding: 0; + text-align: center; + text-decoration: none; + text-indent: 0; + letter-spacing: inherit; + word-spacing: inherit; + text-transform: none; + color: inherit; + border: none; + outline: none; + background: none; + text-shadow: none; + font: inherit; + line-height: inherit; + cursor: default; + box-sizing: border-box; + + &:focus { + outline: none; + } +} + +@mixin reset-default-input { + padding: unset; + border: none; + + &:focus { + outline: none; + outline-offset: initial; + } +} + +/* Хак для того, чтобы убрать браузерные autofill стили */ +@mixin remove-autofill-style { + &:-webkit-autofill { + /* stylelint-disable-next-line */ + -webkit-background-clip: text; + } +} + +/* links */ + +@mixin link-reset { + all: unset; +} + +@mixin focus-visible { + /* stylelint-disable-next-line */ + &:global(.focus-visible) { + @content; + } +} + +@mixin line-clamp($n) { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: $n; + -webkit-box-orient: vertical; +} + +@mixin hide-default-input { + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 0; + height: 0; + opacity: 0; +} + +/* stylelint-disable order/order */ + +@mixin transition($properties...) { + $declarations: (); + + @each $declaration in $properties { + $duration: 0.2s; + $delay: false; + $timing-function: ease-out; + $property: all; + + @if type-of($declaration) == 'map' { + $duration: if(map_get($declaration, 'duration'), #{map_get($declaration, 'duration')}, $duration); + $delay: if(map_get($declaration, 'delay'), #{map_get($declaration, 'delay')}, $delay); + $timing-function: if( + map_get($declaration, 'timing-function'), + #{map_get($declaration, 'timing-function')}, + $timing-function + ); + $property: if(map_get($declaration, 'property'), #{map_get($declaration, 'property')}, $property); + } @else { + $property: $declaration; + } + + $delay-value: if($delay, ' ' + $delay, ''); + $declarations: list.append( + $declarations, + #{$property + ' ' + $duration + $delay-value + ' ' + $timing-function}, + comma + ); + } + + transition: $declarations; +} + +/* stylelint-enable order/order */ + +@mixin flex { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: flex-start; +} + +@mixin flex-center { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +@mixin flex-middle { + display: flex; + flex-direction: row; + align-items: center; +} + +@mixin flex-col { + display: flex; + flex-direction: column; +} + +@mixin flex-col-middle { + display: flex; + flex-direction: column; + align-items: center; +} + +@mixin text-overflow { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + + +/* scrollbar */ + +$scrollbar-width: 12px; + +@mixin scrollbar { + &::-webkit-scrollbar { + width: $scrollbar-width; + height: $scrollbar-width; + } + + &::-webkit-scrollbar-thumb { + border: 4px solid transparent; + border-radius: 6px; + background-color: $color-on-surface-light-400; + background-clip: content-box; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: $color-on-surface-light-300; + } +} + +@mixin hide-scrollbar { + overflow-y: auto; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; /* for Chrome, Safari, and Opera */ + } +} \ No newline at end of file diff --git a/src/app/styles/radius.scss b/src/app/styles/radius.scss new file mode 100644 index 0000000..94cfb5e --- /dev/null +++ b/src/app/styles/radius.scss @@ -0,0 +1,6 @@ +$radius-none: 0; +$radius-small: 4px; +$radius-medium: 8px; +$radius-large: 12px; +$radius-large-x: 16px; +$radius-large-xx: 24px; diff --git a/src/app/styles/shadow.scss b/src/app/styles/shadow.scss new file mode 100644 index 0000000..ce1c543 --- /dev/null +++ b/src/app/styles/shadow.scss @@ -0,0 +1,7 @@ +$shadow-dropdown: 0 1px 24px 0 rgb(0 0 0 / 40%); +$shadow-hover: 0 4px 24px 0 rgb(0 0 0 / 24%); +$shadow-bottom-item: 0 -2px 12px 0 rgb(0 0 0 / 25%); +$shadow-top: 0 -4px 1px rgb(20 20 20 / 8%), 0 -12px 6px rgb(20 20 20 / 16%), 0 -8px 20px rgb(20 20 20 / 24%); +$shadow-left: -4px 0 1px rgb(20 20 20 / 8%), -12px 0 6px rgb(20 20 20 / 16%), -8px 0 20px rgb(20 20 20 / 24%); +$shadow-right: 4px 0 1px rgb(20 20 20 / 8%), 12px 0 6px rgb(20 20 20 / 16%), 8px 0 20px rgb(20 20 20 / 24%); +$shadow-short: 0 6px 16px -12px rgb(23 40 77 / 6%); diff --git a/src/app/styles/spacing.scss b/src/app/styles/spacing.scss new file mode 100644 index 0000000..2ea6835 --- /dev/null +++ b/src/app/styles/spacing.scss @@ -0,0 +1,12 @@ +$spacing-small-4x: 4px; +$spacing-small-3x: 8px; +$spacing-small-2x: 12px; +$spacing-small-x: 16px; +$spacing-small: 24px; +$spacing-medium: 32px; +$spacing-medium-x: 40px; +$spacing-large: 48px; +$spacing-large-x: 64px; +$spacing-large-2x: 72px; +$spacing-large-3x: 96px; +$spacing-large-4x: 124px; diff --git a/src/app/styles/vars.scss b/src/app/styles/vars.scss new file mode 100644 index 0000000..d64e0ca --- /dev/null +++ b/src/app/styles/vars.scss @@ -0,0 +1,8 @@ +@import 'colors'; +@import 'baseText'; +@import 'breakpoints'; +@import 'mixins'; +@import 'radius'; +@import 'spacing'; +@import 'z-index'; +@import 'shadow'; \ No newline at end of file diff --git a/src/app/styles/z-index.scss b/src/app/styles/z-index.scss new file mode 100644 index 0000000..de250aa --- /dev/null +++ b/src/app/styles/z-index.scss @@ -0,0 +1,11 @@ +$z-index-behind: -1; +$z-index-default: 0; +$z-index-primary: 1; +$z-index-secondary: 2; +$z-index-dropdown-menu: 10; +$z-index-sidebar: 100; +$z-index-editor: 100; +$z-index-overlay: 101; +$z-index-modal: 1000; +$z-index-notification: 1001; +$z-index-max: 99999; diff --git a/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff b/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff new file mode 100644 index 0000000..fbde09c Binary files /dev/null and b/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff differ diff --git a/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff2 b/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff2 new file mode 100644 index 0000000..64ad179 Binary files /dev/null and b/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff2 differ diff --git a/src/assets/fonts/Raleway/Raleway-Bold.woff b/src/assets/fonts/Raleway/Raleway-Bold.woff new file mode 100644 index 0000000..e21696e Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Bold.woff differ diff --git a/src/assets/fonts/Raleway/Raleway-Bold.woff2 b/src/assets/fonts/Raleway/Raleway-Bold.woff2 new file mode 100644 index 0000000..e9cc3bf Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Bold.woff2 differ diff --git a/src/assets/fonts/Raleway/Raleway-Medium.woff b/src/assets/fonts/Raleway/Raleway-Medium.woff new file mode 100644 index 0000000..4f9ca5f Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Medium.woff differ diff --git a/src/assets/fonts/Raleway/Raleway-Medium.woff2 b/src/assets/fonts/Raleway/Raleway-Medium.woff2 new file mode 100644 index 0000000..c2d95d8 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Medium.woff2 differ diff --git a/src/assets/fonts/Raleway/Raleway-Regular.woff b/src/assets/fonts/Raleway/Raleway-Regular.woff new file mode 100644 index 0000000..8423494 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Regular.woff differ diff --git a/src/assets/fonts/Raleway/Raleway-Regular.woff2 b/src/assets/fonts/Raleway/Raleway-Regular.woff2 new file mode 100644 index 0000000..18e4f7b Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Regular.woff2 differ diff --git a/src/assets/fonts/Raleway/Raleway-SemiBold.woff b/src/assets/fonts/Raleway/Raleway-SemiBold.woff new file mode 100644 index 0000000..f13b8e2 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-SemiBold.woff differ diff --git a/src/assets/fonts/Raleway/Raleway-SemiBold.woff2 b/src/assets/fonts/Raleway/Raleway-SemiBold.woff2 new file mode 100644 index 0000000..64b8ce4 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-SemiBold.woff2 differ diff --git a/src/assets/icons/right.svg b/src/assets/icons/right.svg new file mode 100644 index 0000000..f10aeba --- /dev/null +++ b/src/assets/icons/right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss new file mode 100644 index 0000000..41e8167 --- /dev/null +++ b/src/components/Button/Button.module.scss @@ -0,0 +1,119 @@ +@import 'src/app/styles/vars'; + +$button-border-width: 2px; + +.Button { + @include text-body-m-medium; + @include transition(color, background-color, border-color); + + position: relative; + display: flex; + align-items: center; + width: max-content; + padding: 0 ($spacing-small - $button-border-width); + text-decoration: none; + color: $color-text-primary; + border: $button-border-width solid transparent; + //border-radius: $radius-small; + border-radius: $radius-large-x; + background-color: rgb(255 255 255 / 0%); + box-shadow: none; + user-select: none; + cursor: pointer; + box-sizing: border-box; + appearance: none; + + &, + &:hover, + &:focus { + outline: none; + } +} + +.Button_disabled { + pointer-events: none; +} + +.Button:focus-visible { + border-color: $color-brand-hover; +} + +.Button_hasLeft { + padding-left: $spacing-small-x - $button-border-width; +} + +.Button_hasRight { + padding-right: $spacing-small-x - $button-border-width; +} + +.Button__content { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + text-align: center; +} + +.Button__loader, +.Button__contentLeft, +.Button__contentRight { + display: flex; + align-items: center; +} + +.Button__contentLeft { + margin-right: $spacing-small-3x; +} + +.Button__loader, +.Button__contentRight { + margin-left: $spacing-small-3x; +} + +.Button_size_small { + height: 32px; +} + +.Button_size_medium { + height: 40px; +} + +.Button_size_large { + height: 48px; +} + +.Button_variant_primary { + background-color: $color-brand-primary; + + &.Button_hovered, + &:hover { + background-color: $color-brand-hover; + } + + &.Button_disabled { + color: $color-text-tertiary; + background-color: $color-brand-disabled; + } +} + +.Button_variant_secondary { + background-color: $color-on-surface-dark-300; + + &.Button_hovered, + &:hover { + background-color: $color-on-surface-dark-400; + } + + &.Button_disabled { + color: $color-text-quaternary; + } +} + +.Button_stretch_fit { + width: fit-content; +} + +.Button_stretch_fill { + width: 100%; +} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx new file mode 100644 index 0000000..b46f2ca --- /dev/null +++ b/src/components/Button/Button.tsx @@ -0,0 +1,148 @@ +import React, { ElementType, useMemo } from 'react'; +import clsx from 'clsx'; +import s from './Button.module.scss'; +import {PolyExtends} from '../../utils/types'; + +export enum ButtonSize { + small = 'small', + medium = 'medium', + large = 'large' +} + +export enum ButtonVariant { + primary = 'primary', + secondary = 'secondary', + tertiary = 'tertiary' +} + +export enum ButtonStretch { + fit = 'fit', + fill = 'fill' +} + +export const ButtonDefaultComponent = 'button' as const; +export type ButtonDefaultComponentType = typeof ButtonDefaultComponent; + +export interface ButtonSelfProps { + /** + * Размер ("small", "medium", "large") + */ + size?: ButtonSize; + /** + * Вариант оформления ("primary", "secondary", "tertiary") + */ + variant?: ButtonVariant; + /** + * Заблокированное состояние + */ + disabled?: boolean; + /** + * Проп для контролируемого включения состояния ховера + */ + hovered?: boolean; + /** + * Цвет фона в hex-формате + */ + color?: string; + /** + * Состояние со спиннером + */ + isLoading?: boolean; + /** + * Потомки компонента + */ + children?: React.ReactNode; + /** + * Реф на корневой DOM-элемент + */ + innerRef?: React.ComponentProps['ref']; + /** + * Дополнительные css-классы элементов + */ + classes?: { + content?: string; + contentLeft?: string; + contentRight?: string; + text?: string; + icon?: string; + }; + /** + * Вариант растягивания кнопки ("fit", "fill") + */ + stretch?: ButtonStretch; +} + +export type ButtonProps = PolyExtends< + ComponentType, + ButtonSelfProps, + React.ComponentProps +>; + +export function Button({ + component, + className, + style, + size = ButtonSize.medium, + variant = ButtonVariant.primary, + disabled, + hovered, + children, + leftIcon, + right, + left, + rightIcon, + isLoading, + color, + innerRef, + classes, + stretch, + ...props +}: ButtonProps) { + // Чтобы ts не проверял корректность передачи пропов + const Component = (component || ButtonDefaultComponent) as React.ElementType; + + const buttonProps = useMemo(() => { + if (Component === 'button') { + return { type: 'button' }; + } + return undefined; + }, [Component]); + + const hasLeft = left || leftIcon; + + const hasRight = right || rightIcon; + + return ( + +
+
{children}
+ + {/*{isLoading && }*/} +
+
+ ); +} + +Button.Variant = ButtonVariant; +Button.Size = ButtonSize; diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts new file mode 100644 index 0000000..8b166a8 --- /dev/null +++ b/src/components/Button/index.ts @@ -0,0 +1 @@ +export * from './Button'; diff --git a/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.module.scss b/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.module.scss new file mode 100644 index 0000000..51fab5b --- /dev/null +++ b/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.module.scss @@ -0,0 +1,69 @@ +@import 'src/app/styles/vars'; + +.BaseCheckboxIcon { + @include transition(border, background-color); + + position: relative; + display: flex; + flex: 0 0 16px; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: 1px solid $color-on-surface-light-300; + border-radius: $radius-small; + background-color: transparent; + box-sizing: border-box; +} + +.BaseCheckboxIcon__icon { + position: absolute; + top: 50%; + left: 50%; + display: flex; + align-items: center; + justify-content: center; + transform: translate(-50%, -50%); +} + +.BaseCheckboxIcon__icon > * { + @include transition(opacity, color); + + color: $color-on-surface-light-100; + opacity: 0; +} + +.BaseCheckboxIcon__icon_visible > * { + opacity: 1; +} + +.BaseCheckboxIcon_disabled { + color: $color-accent-blue-100; + border-color: $color-accent-blue-300; +} + +.BaseCheckboxIcon_checked { + border-color: transparent; + background-color: $color-accent-blue-200; + + &.BaseCheckboxIcon_disabled { + background-color: $color-accent-blue-300; + } +} + +.BaseCheckboxIcon_radio { + border-radius: 50%; + + &.BaseCheckboxIcon_checked { + border: 4px solid $color-accent-blue-200; + background-color: $color-on-surface-light-100; + } + + &.BaseCheckboxIcon_disabled { + border-color: $color-accent-blue-300; + } + + &.BaseCheckboxIcon_checked.BaseCheckboxIcon_disabled { + background-color: $color-accent-blue-100; + } +} diff --git a/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.tsx b/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.tsx new file mode 100644 index 0000000..e566050 --- /dev/null +++ b/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.tsx @@ -0,0 +1,49 @@ +import React, { FunctionComponent } from 'react'; +import clsx from 'clsx'; +import s from './BaseCheckboxIcon.module.scss'; +import {DivPropsWithoutRef} from '../../../utils/types'; + +export interface BaseCheckboxIconProps extends DivPropsWithoutRef { + /** + * Состояние Checkbox: включен или выключен + */ + checked?: boolean; + /** + * Неактивное состояние Checkbox - состояние, при котором компонент отображается, но недоступен для действий пользователя + */ + disabled?: boolean; + /** + * Свойство, устанавливающие тип radio для BaseCheckboxIcon + */ + radio?: boolean; +} + +export const BaseCheckboxIcon: FunctionComponent = ({ + className, + checked, + disabled, + radio, + children, + ...props +}) => { + return ( +
+
+
+
+ ); +}; diff --git a/src/components/Checkbox/BaseCheckboxIcon/index.ts b/src/components/Checkbox/BaseCheckboxIcon/index.ts new file mode 100644 index 0000000..912e528 --- /dev/null +++ b/src/components/Checkbox/BaseCheckboxIcon/index.ts @@ -0,0 +1 @@ +export * from './BaseCheckboxIcon'; diff --git a/src/components/Checkbox/Checkbox.module.scss b/src/components/Checkbox/Checkbox.module.scss new file mode 100644 index 0000000..1aaa64c --- /dev/null +++ b/src/components/Checkbox/Checkbox.module.scss @@ -0,0 +1,81 @@ +@import 'src/app/styles/vars'; + +.Checkbox { + position: relative; + display: inline-flex; + align-items: center; + vertical-align: middle; + user-select: none; + cursor: pointer; + + &, + &:focus { + outline: none; + } +} + +.Checkbox__input { + @include hide-default-input; +} + +.Checkbox__label { + @include text-caption-m-regular; + @include transition(color); + + display: inline-flex; + margin-left: $spacing-small-3x; + vertical-align: middle; + color: $color-text-primary; +} + +.Checkbox__icon { + &::before { + @include transition(border-color); + + position: absolute; + top: 50%; + left: 50%; + width: 32px; + height: 32px; + content: ''; + border: 2px solid transparent; + border-radius: $radius-medium; + transform: translate(-50%, -50%); + } +} + +.Checkbox__input:focus-visible + .Checkbox__icon { + &::before { + border-color: $color-brand-hover; + } +} + +.Checkbox:hover .Checkbox__icon { + border-color: $color-accent-blue-100; +} + +.Checkbox_disabled { + cursor: default; + pointer-events: none; + + .Checkbox__label { + color: $color-text-tertiary; + } +} + +.Checkbox_checked { + &:hover { + .Checkbox__icon:not(.Checkbox__icon_radio) { + background-color: $color-accent-blue-100; + } + + .Checkbox__icon_radio { + border-color: $color-accent-blue-100; + } + } +} + +.Checkbox_radio.Checkbox_checked.Checkbox_disabled:hover .BaseCheckboxIcon_radio { + border: 6px solid brown; + background-color: transparent; +} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..f0a5ad8 --- /dev/null +++ b/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,121 @@ +import React, { ChangeEvent, FunctionComponent, ReactNode } from 'react'; +import clsx from 'clsx'; +import s from './Checkbox.module.scss'; +import {InputPropsWithoutRef, IntrinsicPropsWithoutRef} from '../../utils/types'; +import {BaseCheckboxIcon} from './BaseCheckboxIcon'; + +export interface ICheckboxProps extends Omit { + /** + * Состояние Checkbox: включен или выключен + */ + checked?: boolean; + /** + * Неактивное состояние Checkbox - состояние, при котором компонент отображается, но недоступен для действий пользователя + */ + disabled?: boolean; + /** + * Если true, то компонент аналогичен компоненту Radio + */ + radio?: boolean; + /** + * Слот подписи + */ + label?: ReactNode; + /** + * Обработчик изменения состояния Checkbox. Принимает на вход новое значение состояния Checkbox (в случае, если checked, то новое значение - false, иначе - true) и ChangeEvent + */ + onChange?: (value: boolean, e: ChangeEvent) => void; + /** + * Слот для замены иконки чекбокса + */ + checkboxIcon?: React.ReactNode; + /** + * Дополнительные css-классы элементов + * * input – класс элемента input + * * label – класс лейбла + * * icon – класс иконки чекбокса + */ + classes?: { + input?: string; + label?: string; + icon?: string; + }; + /** + * Дополнительный css-класс корневого элемента + */ + className?: string; + /** + * Реф на корневой элемент + */ + innerRef?: React.Ref; + /** + * Реф на input + */ + inputRef?: React.Ref; + /** + * Дополнительные пропы корневого элемента + */ + rootProps?: IntrinsicPropsWithoutRef<'label'>; +} + +export const Checkbox: FunctionComponent = ({ + className, + checked, + disabled, + radio, + label, + onChange, + style, + checkboxIcon, + classes, + innerRef, + inputRef, + rootProps, + ...props +}) => { + const handleChange = onChange + ? (e: ChangeEvent) => { + onChange(!checked, e); + } + : undefined; + + return ( + + ); +}; diff --git a/src/components/Checkbox/index.ts b/src/components/Checkbox/index.ts new file mode 100644 index 0000000..f5c939f --- /dev/null +++ b/src/components/Checkbox/index.ts @@ -0,0 +1 @@ +export * from './Checkbox'; diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx new file mode 100644 index 0000000..3695e7b --- /dev/null +++ b/src/components/Form/Form.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useForm, UseFormReturn, SubmitHandler, UseFormProps, FieldValues } from 'react-hook-form'; + +type FormProps = { + className?: string; + onSubmit?: SubmitHandler; + children: (methods: UseFormReturn) => React.ReactNode; + options?: UseFormProps; + id?: string; + innerRef?: React.Ref; +}; + +export const Form = ({ + onSubmit, + children, + className, + options, + id, + innerRef +}: FormProps) => { + const methods = useForm({ ...options }); + + return ( + //
+ <> + {children(methods)} + + //
+ ); +}; diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts new file mode 100644 index 0000000..511d53b --- /dev/null +++ b/src/components/Form/index.ts @@ -0,0 +1 @@ +export * from './Form'; \ No newline at end of file diff --git a/src/components/Hint/Hint.module.scss b/src/components/Hint/Hint.module.scss new file mode 100644 index 0000000..d03c0f3 --- /dev/null +++ b/src/components/Hint/Hint.module.scss @@ -0,0 +1,83 @@ +@import 'src/app/styles/vars'; + +.Hint__container { + position: relative; + width: fit-content; + height: 44px; + + @include media-down(tablet-small) { + width: 100%; + } +} + +.Hint__container_hovered { + height: unset; +} + +.Hint { + @include transition(background-color, color, width); + position: relative; + //display: inline-block; + padding: $spacing-small-2x; + border-radius: $radius-large-x; + background-color: $color-on-surface-light-400; + //margin-right: $spacing-small; + width: fit-content; + color: $color-text-secondary; + cursor: pointer; + + &:not(.Hint_hovered) { + white-space: nowrap; + max-width: 500px; + overflow: hidden; + text-overflow: ellipsis; + + @include media-down(tablet-small) { + width: 100%; + max-width: 100%; + } + } +} + +.Hint_hovered { + width: 100%; + background-color: $color-brand-hover; + color: white; +} + +.Hint__tip { + @include text-caption-m-medium; + position: absolute; + top: -4px; + right: -4px; + background-color: white; + z-index: 1; + padding: 2px 4px; + width: 16px; + height: 16px; + border-radius: 20px; +} + +.Hint__button { + width: 100% !important; + margin-top: $spacing-small-2x; +} + +.Hints { + @include flex; + flex-wrap: wrap; + gap: $spacing-small-x; + + @include media-down(tablet-small) { + flex-direction: column; + } +} + +.Hints_margin { + margin-bottom: $spacing-small; +} + +.Hints__stub { + @include text-body-s-regular; + color: $color-text-secondary; +} diff --git a/src/components/Hint/Hint.tsx b/src/components/Hint/Hint.tsx new file mode 100644 index 0000000..200732c --- /dev/null +++ b/src/components/Hint/Hint.tsx @@ -0,0 +1,72 @@ +import clsx from 'clsx'; +import s from './Hint.module.scss'; +import {ReactFCC} from '../../utils/ReactFCC'; +import {FC} from 'react'; +import {useHover} from '../../hooks/useHover'; +import {useIsMobile} from '../../hooks/useIsMobile'; +import {useToggle} from '../../hooks/useToggle'; +import {Button, ButtonVariant} from '../Button'; + +export interface HintProps { + /** + * Дополнительный css-класс + */ + className?: string; + children: string; + onClick?: () => void; +} + +export const Hint: FC = (props) => { + const {children, className, onClick: onClickProp} = props; + + const isMobile = useIsMobile(); + + const [hoveredMobile, { toggle }] = useToggle(); + + const { hovered: hoveredDesktop, ...hoverProps } = useHover(); + + const hintProps = isMobile ? {} : hoverProps; + + const onClick = () => { + if (isMobile) { + toggle() + } + } + + const hovered = isMobile ? hoveredMobile : hoveredDesktop; + + return ( +
{ + if (!isMobile) { + onClickProp?.(); + } + }}> +
+
+ {children} + + {isMobile && hovered && ( + + )} +
+
+ ); +}; + +export interface HintsContainerProps { + isLoading?: boolean; + margin?: boolean; +} + +export const HintsContainer: ReactFCC = ({ children, isLoading, margin }) => { + return ( +
{!isLoading ? children : ( +
Загрузка подсказок...
+ )}
+ ) +} diff --git a/src/components/Hint/index.ts b/src/components/Hint/index.ts new file mode 100644 index 0000000..46ec2ce --- /dev/null +++ b/src/components/Hint/index.ts @@ -0,0 +1 @@ +export * from './Hint'; \ No newline at end of file diff --git a/src/components/Input/Input.module.scss b/src/components/Input/Input.module.scss new file mode 100644 index 0000000..e240473 --- /dev/null +++ b/src/components/Input/Input.module.scss @@ -0,0 +1,130 @@ +@import 'src/app/styles/vars'; + +.Input { + @include text-body-m-regular; + @include transition(border-color, box-shadow); + + position: relative; + display: flex; + align-items: center; + height: 40px; + padding: 0 $spacing-small-x; + color: $color-text-primary; + overflow: hidden; + border: 1px solid transparent; + border-radius: $radius-small; + background-color: $color-on-surface-dark-400; + cursor: text; + box-sizing: border-box; + + &:hover { + box-shadow: $shadow-hover; + } +} + +.Input_error { + border-color: $color-system-error; +} + +.Input_focus { + border-color: $color-brand-primary; +} + +.Input_disabled { + color: $color-text-tertiary; + border-color: transparent; + background: $color-on-surface-dark-300; + cursor: default; + + &:hover { + box-shadow: none; + } +} + +.Input__input { + @include reset-default-input; + @include remove-autofill-style; + + position: relative; + flex-grow: 1; + min-width: 0; + height: 100%; + + &:placeholder-shown + .Input__clear { + display: none; + } + + &[type='number'] { + appearance: textfield; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + } + + &:-webkit-autofill, + &:-webkit-autofill:hover, + &:-webkit-autofill:focus, + &:-webkit-autofill:active { + caret-color: $color-text-primary; + -webkit-text-fill-color: $color-text-primary; + } +} + +.Input__input_disabled { + cursor: default; +} + +.Input__input::placeholder { + color: $color-text-tertiary; + opacity: 1; +} + +.Input__icon { + display: flex; + flex: 0 0 auto; + align-items: center; + max-height: 100%; + color: $color-on-surface-light-300; +} + +.Input__icon_active { + @include transition(color); + + cursor: pointer; + + &:hover { + color: $color-on-surface-light-200; + } +} + +.Input__icon_left { + justify-content: flex-start; + margin-right: $spacing-small-3x; + margin-left: -$spacing-small-3x; +} + +.Input__icon_right { + justify-content: flex-end; + margin-left: $spacing-small-3x; + + &:last-child { + margin-right: -$spacing-small-3x; + } +} + +.Input__icon_clear { + @include transition(color); + + position: relative; + cursor: pointer; + + &:hover { + color: $color-on-surface-light-200; + } +} + +.Input_disabled .Input__icon_clear { + pointer-events: none; +} diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx new file mode 100644 index 0000000..b91dc7a --- /dev/null +++ b/src/components/Input/Input.tsx @@ -0,0 +1,103 @@ +import React, {ChangeEvent, useCallback, useRef, useState} from 'react'; +import clsx from 'clsx'; +import composeRefs from '@seznam/compose-react-refs'; +import s from './Input.module.scss'; +import {IntrinsicPropsWithoutRef} from '../../utils/types'; +import {useDelegateFocus} from '../../hooks/useDelegateFocus'; +import {useFocus} from '../../hooks/useFocus'; +import {useLiveInput} from '../../hooks/useLiveInput'; +import {UseFormRegisterReturn} from 'react-hook-form'; + +export interface InputProps extends Omit, 'onClick'> { + /** + * Состояние ошибки + */ + error?: boolean; + /** + * Проп для контролируемого включения состояния фокуса + */ + focused?: boolean; + /** + * Ref на input-элемент + */ + inputRef?: React.Ref; + /** + * Обработчик нажатия на Input + */ + onClick?: (event: React.MouseEvent) => void; + /** + * Дополнительные css-классы элементов: + * * root - внешний контейнер + * * input - элемент input + * * icon - иконки слева и справа Input + * * iconLeft - иконка слева Input + * * iconRight - иконка справа Input + */ + classes?: { + root?: string; + icon?: string; + iconLeft?: string; + iconRight?: string; + input?: string; + }; + registration?: Partial; +} + +export type InputType = React.ForwardRefExoticComponent>; + +const InputForwardedRef = React.forwardRef( + ( + { + error, + focused: focusedProp, + className, + classes, + onClick, + onInput, + inputRef: inputRefProp, + style, + type, + value, + registration, + ...inputProps + }, + ref + ) => { + const inputRef = useRef(null); + + const delegateProps = useDelegateFocus(inputRef, { onClick }); + const { focused, ...focusProps } = useFocus({ ...inputProps, ...registration }); + + return ( +
+ +
+ ); + } +); + +InputForwardedRef.displayName = 'Input'; + +export const Input: InputType = InputForwardedRef; diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts new file mode 100644 index 0000000..ba9fe7e --- /dev/null +++ b/src/components/Input/index.ts @@ -0,0 +1 @@ +export * from './Input'; diff --git a/src/components/Message/Message.module.scss b/src/components/Message/Message.module.scss new file mode 100644 index 0000000..c21fb5d --- /dev/null +++ b/src/components/Message/Message.module.scss @@ -0,0 +1,29 @@ +@import 'src/app/styles/vars'; + +//$message-radius: $radius-large; +$message-radius: $radius-large-xx; + +.Message { + @include text-body-l-regular; + + padding: $spacing-small-x; + box-shadow: $shadow-short; + white-space: pre-wrap; +} + +.Message_variant_primary { + background-color: $color-brand-primary; +} + +.Message_variant_secondary { + background-color: $color-on-surface-dark-400; + color: #d0d1d5; +} + +.Message_type_right { + border-radius: $message-radius $message-radius 0 $message-radius; +} + +.Message_type_left { + border-radius: $message-radius $message-radius $message-radius 0; +} \ No newline at end of file diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx new file mode 100644 index 0000000..bb115ba --- /dev/null +++ b/src/components/Message/Message.tsx @@ -0,0 +1,33 @@ +import clsx from 'clsx'; +import s from './Message.module.scss'; +import {ReactFCC} from '../../utils/ReactFCC'; + +export enum MessageVariant { + primary = 'primary', + secondary = 'secondary' +} + +export enum MessageType { + right = 'right', + left = 'left' +} + +export interface MessageProps { + /** + * Дополнительный css-класс + */ + className?: string; + variant?: MessageVariant; + type: MessageType; +} + +export const Message: ReactFCC = (props) => { + const { children, className, variant, type } = props; + + return ( +
+ {children} +
+ ); +}; + diff --git a/src/components/Message/index.ts b/src/components/Message/index.ts new file mode 100644 index 0000000..3dd21cb --- /dev/null +++ b/src/components/Message/index.ts @@ -0,0 +1 @@ +export * from './Message'; \ No newline at end of file diff --git a/src/components/Radio/Radio.tsx b/src/components/Radio/Radio.tsx new file mode 100644 index 0000000..5f3a029 --- /dev/null +++ b/src/components/Radio/Radio.tsx @@ -0,0 +1,19 @@ +import React, { ChangeEvent, FunctionComponent } from 'react'; +import {Checkbox, ICheckboxProps} from '../Checkbox'; + +export interface RadioProps extends Omit { + /** + * Обработчик изменения значения Radio. Принимает на вход значение Radio и ChangeEvent + */ + onChange?: (value: string, e: ChangeEvent) => void; +} + +export const Radio: FunctionComponent = ({ onChange, ...props }) => { + const handleChange = onChange + ? (value: boolean, event: ChangeEvent) => { + onChange(event.target.value, event); + } + : undefined; + + return ; +}; diff --git a/src/components/Radio/index.tsx b/src/components/Radio/index.tsx new file mode 100644 index 0000000..bfbe6d0 --- /dev/null +++ b/src/components/Radio/index.tsx @@ -0,0 +1 @@ +export * from './Radio'; diff --git a/src/components/Range/Range.module.scss b/src/components/Range/Range.module.scss new file mode 100644 index 0000000..a9b7bfd --- /dev/null +++ b/src/components/Range/Range.module.scss @@ -0,0 +1,132 @@ +@import 'src/app/styles/vars'; + +$range-track-height: 10px; +$range-thumb-height: 24px; + +@mixin range-track { + @include transition(background); + + background: $color-on-surface-dark-400; + height: $range-track-height; + border-radius: $range-track-height; + + &:hover { + background: $color-on-surface-light-400; + } +} + +@mixin range-thumb { + @include transition(background); + + width: $range-thumb-height; + height: $range-thumb-height; + margin-top: -($range-thumb-height / 2 - $range-track-height / 2); + border-radius: 50%; + background: $color-brand-primary; + + + &:hover { + background: $color-brand-hover; + } +} + +.Range { + @include flex-col; + gap: $spacing-small-3x; +} + +.Range__label { + +} + +.Range__container { + @include flex-middle; + column-gap: $spacing-small-3x; +} + +.Range__inputContainer { + position: relative; + height: $range-thumb-height; + flex: 1; +} + +.Range__input { + display: block; + bottom: -7px; + position: relative; + width: 100%; + z-index: 1; + + // reset styles + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + + // track + &::-webkit-slider-runnable-track { + @include range-track; + } + + &::-moz-range-track { + @include range-track; + } + + // thumb + &::-webkit-slider-thumb { + -webkit-appearance: none; /* Override default look */ + appearance: none; + @include range-thumb; + } + + &::-moz-range-thumb { + border: none; /*Removes extra border that FF applies*/ + border-radius: 0; /*Removes default border-radius that FF applies*/ + @include range-thumb; + } +} + +.Range__inputContainer:hover { + .Range__input::-webkit-slider-thumb { + background: $color-brand-hover; + } + + .Range__input::-moz-range-thumb { + background: $color-brand-hover; + } + + .Range__input::-webkit-slider-runnable-track { + background: $color-on-surface-light-400; + } + + .Range__input::-moz-range-track { + background: $color-on-surface-light-400; + } +} + +.Range__progress { + @include transition(background); + position: absolute; + background: $color-brand-primary; + height: $range-track-height; + border-radius: $range-track-height 0 0 $range-track-height; + top: 7px; + left: 0; + cursor: pointer; + pointer-events: none; + z-index: 2; +} + +.Range__inputContainer:hover .Range__progress { + background: $color-brand-hover; +} + +.Range__tip { + @include text-caption-m-regular; + color: $color-text-secondary; +} + +.Range__tip_active { + color: $color-text-brand; + min-width: 25px; +} \ No newline at end of file diff --git a/src/components/Range/Range.tsx b/src/components/Range/Range.tsx new file mode 100644 index 0000000..cf99d40 --- /dev/null +++ b/src/components/Range/Range.tsx @@ -0,0 +1,55 @@ +import clsx from 'clsx'; +import s from './Range.module.scss'; +import {ReactFCC} from '../../utils/ReactFCC'; +import {ChangeEvent, useMemo} from 'react'; +import {InputPropsWithoutRef} from '../../utils/types'; +import {currencyFormatter} from '../../utils/fomat'; + +export interface RangeProps extends Omit { + /** + * Дополнительный css-класс + */ + className?: string; + label?: string; + value: number; + onChange: (value: number, e: any) => void; + format?: boolean; +} + +export const Range: ReactFCC = (props) => { + const {className, min = 0, max = 100, label, value, format, onChange: onChangeProp, ...rest } = props; + + const onChange = (e: ChangeEvent) => { + const val = Number(e.target.value); + onChangeProp?.(val, e); + } + + const percent = useMemo(() => { + const percent = ((Number(value) - Number(min)) / (Number(max) - Number(min))) * 100; + if (percent < 0) { + return 0; + } else if (percent > 100) { + return 100; + } + + return percent; + }, [max, min, value]); + + return ( +
+
{label}
+ +
+
{format ? currencyFormatter.format(min as number) : min}
+ +
+
+ +
+ +
{format ? currencyFormatter.format(value as number) : value}
+
+
+ ); +}; + diff --git a/src/components/Range/index.ts b/src/components/Range/index.ts new file mode 100644 index 0000000..efb484c --- /dev/null +++ b/src/components/Range/index.ts @@ -0,0 +1 @@ +export * from './Range'; \ No newline at end of file diff --git a/src/components/Textarea/Textarea.module.scss b/src/components/Textarea/Textarea.module.scss new file mode 100644 index 0000000..cd187e6 --- /dev/null +++ b/src/components/Textarea/Textarea.module.scss @@ -0,0 +1,136 @@ +@import 'src/app/styles/vars'; + +.Input { + @include text-body-m-regular; + @include transition(border-color, box-shadow); + + position: relative; + display: flex; + align-items: center; + min-height: 50px; + padding: 15px $spacing-small-x; + color: $color-text-primary; + overflow: hidden; + border: 1px solid transparent; + border-radius: $radius-small; + background-color: $color-on-surface-dark-400; + cursor: text; + box-sizing: border-box; + + overflow-y: auto; + word-wrap: break-word; + + &:hover { + box-shadow: $shadow-hover; + } +} + +.Input_error { + border-color: $color-system-error; +} + +.Input_focus { + border-color: $color-brand-primary; +} + +.Input_disabled { + color: $color-text-tertiary; + border-color: transparent; + background: $color-on-surface-dark-300; + cursor: default; + + &:hover { + box-shadow: none; + } +} + +.Input__input { + @include reset-default-input; + @include remove-autofill-style; + + position: relative; + flex-grow: 1; + min-width: 0; + height: 100%; + + resize: none; + white-space: break-spaces; + + &:placeholder-shown + .Input__clear { + display: none; + } + + &[type='number'] { + appearance: textfield; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + } + + &:-webkit-autofill, + &:-webkit-autofill:hover, + &:-webkit-autofill:focus, + &:-webkit-autofill:active { + caret-color: $color-text-primary; + -webkit-text-fill-color: $color-text-primary; + } +} + +.Input__input_disabled { + cursor: default; +} + +.Input__input::placeholder { + color: $color-text-tertiary; + opacity: 1; +} + +.Input__icon { + display: flex; + flex: 0 0 auto; + align-items: center; + max-height: 100%; + color: $color-on-surface-light-300; +} + +.Input__icon_active { + @include transition(color); + + cursor: pointer; + + &:hover { + color: $color-on-surface-light-200; + } +} + +.Input__icon_left { + justify-content: flex-start; + margin-right: $spacing-small-3x; + margin-left: -$spacing-small-3x; +} + +.Input__icon_right { + justify-content: flex-end; + margin-left: $spacing-small-3x; + + &:last-child { + margin-right: -$spacing-small-3x; + } +} + +.Input__icon_clear { + @include transition(color); + + position: relative; + cursor: pointer; + + &:hover { + color: $color-on-surface-light-200; + } +} + +.Input_disabled .Input__icon_clear { + pointer-events: none; +} diff --git a/src/components/Textarea/Textarea.tsx b/src/components/Textarea/Textarea.tsx new file mode 100644 index 0000000..2901b77 --- /dev/null +++ b/src/components/Textarea/Textarea.tsx @@ -0,0 +1,113 @@ +import React, {useEffect, useRef} from 'react'; +import clsx from 'clsx'; +import composeRefs from '@seznam/compose-react-refs'; +import s from './Textarea.module.scss'; +import {IntrinsicPropsWithoutRef} from '../../utils/types'; +import {useDelegateFocus} from '../../hooks/useDelegateFocus'; +import {useFocus} from '../../hooks/useFocus'; +import {UseFormRegisterReturn} from 'react-hook-form'; + +export interface TextAreaProps extends Omit, 'onClick' | 'onBlur'> { + /** + * Состояние ошибки + */ + error?: boolean; + /** + * Проп для контролируемого включения состояния фокуса + */ + focused?: boolean; + /** + * Ref на input-элемент + */ + inputRef?: React.Ref; + /** + * Обработчик нажатия на Input + */ + onClick?: (event: React.MouseEvent) => void; + /** + * Дополнительные css-классы элементов: + * * root - внешний контейнер + * * input - элемент input + * * icon - иконки слева и справа Input + * * iconLeft - иконка слева Input + * * iconRight - иконка справа Input + */ + classes?: { + root?: string; + icon?: string; + iconLeft?: string; + iconRight?: string; + input?: string; + }; + registration?: Partial; +} + +export type InputType = React.ForwardRefExoticComponent>; + +const TextareaForwardedRef = React.forwardRef( + ( + { + error, + focused: focusedProp, + className, + classes, + onClick, + onInput, + inputRef: inputRefProp, + style, + registration, + ...inputProps + }, + ref + ) => { + const inputRef = useRef(null); + + const delegateProps = useDelegateFocus(inputRef, { onClick }); + const { focused, ...focusProps } = useFocus({ ...inputProps, ...registration }); + + if (inputRef.current) { + inputRef.current.style.height = '1px'; + inputRef.current.style.height = inputRef.current.scrollHeight + 'px'; + } + + return ( +
+