mirror of
https://github.com/spbleadersofdigtal/frontend.git
synced 2024-11-21 18:06:33 +03:00
123
This commit is contained in:
parent
11c5dbab3b
commit
4e4a66108c
19
package.json
19
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"
|
||||
}
|
||||
}
|
||||
|
|
12
src/.prettierrc.js
Normal file
12
src/.prettierrc.js
Normal file
|
@ -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
|
||||
};
|
38
src/App.css
38
src/App.css
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
26
src/App.tsx
26
src/App.tsx
|
@ -1,26 +0,0 @@
|
|||
import React from 'react';
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
36
src/api/deck/createAnswer.ts
Normal file
36
src/api/deck/createAnswer.ts
Normal file
|
@ -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<CreateAnswerResponse> => {
|
||||
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<typeof createAnswer>;
|
||||
};
|
||||
|
||||
export const useCreateAnswer = ({ config }: UseCreateAnswerOptions = {}) => {
|
||||
return useMutation({
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries([QUERY_KEY_ANSWER]);
|
||||
},
|
||||
...config,
|
||||
mutationFn: createAnswer
|
||||
});
|
||||
};
|
29
src/api/deck/createDeck.ts
Normal file
29
src/api/deck/createDeck.ts
Normal file
|
@ -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<PitchDeck, 'description'>;
|
||||
|
||||
export type CreateDeckResponse = PitchDeck;
|
||||
|
||||
export const createDeck = (data: CreateDeckDTO): Promise<CreateDeckResponse> => {
|
||||
return axios.post(DECKS_API_URL, data);
|
||||
};
|
||||
|
||||
type UseCreateDeckOptions = {
|
||||
config?: MutationConfig<typeof createDeck>;
|
||||
};
|
||||
|
||||
export const useCreateDeck = ({ config }: UseCreateDeckOptions = {}) => {
|
||||
|
||||
return useMutation({
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries([QUERY_KEY_DECKS]);
|
||||
},
|
||||
...config,
|
||||
mutationFn: createDeck
|
||||
});
|
||||
};
|
31
src/api/deck/getFirstQuestion.ts
Normal file
31
src/api/deck/getFirstQuestion.ts
Normal file
|
@ -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<GetFirstQuestionResponse> => {
|
||||
return axios.get(FIRST_QUESTION_API_URL.replace(`:${FIRST_QUESTION_PARAM}`, String(deckId)));
|
||||
};
|
||||
|
||||
type QueryFnType = typeof getFirstQuestion;
|
||||
|
||||
type UseFirstQuestionOptions = {
|
||||
deckId: number;
|
||||
config?: QueryConfig<QueryFnType>;
|
||||
};
|
||||
|
||||
export const useFirstQuestion = ({ deckId, config }: UseFirstQuestionOptions) => {
|
||||
return useQuery<ExtractFnReturnType<QueryFnType>>({
|
||||
...config,
|
||||
queryKey: [QUERY_KEY_FIRST_QUESTION, deckId],
|
||||
queryFn: async () => {
|
||||
const process = await getFirstQuestion({ deckId });
|
||||
|
||||
return process;
|
||||
},
|
||||
});
|
||||
};
|
38
src/api/deck/getQuestion.ts
Normal file
38
src/api/deck/getQuestion.ts
Normal file
|
@ -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<GetQuestionResponse> => {
|
||||
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<QueryFnType>;
|
||||
};
|
||||
|
||||
export const useQuestion = ({ deckId, questionId, config }: UseQuestionOptions) => {
|
||||
return useQuery<ExtractFnReturnType<QueryFnType>>({
|
||||
...config,
|
||||
queryKey: [QUERY_KEY_QUESTION, deckId, questionId],
|
||||
queryFn: async () => {
|
||||
return await getQuestion({ deckId, questionId });
|
||||
},
|
||||
});
|
||||
};
|
6
src/api/deck/index.ts
Normal file
6
src/api/deck/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from './types';
|
||||
export * from './urlKeys';
|
||||
export * from './queryKeys';
|
||||
|
||||
export * from './createDeck';
|
||||
export * from './getFirstQuestion';
|
4
src/api/deck/queryKeys.ts
Normal file
4
src/api/deck/queryKeys.ts
Normal file
|
@ -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';
|
45
src/api/deck/types.ts
Normal file
45
src/api/deck/types.ts
Normal file
|
@ -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;
|
||||
}
|
9
src/api/deck/urlKeys.ts
Normal file
9
src/api/deck/urlKeys.ts
Normal file
|
@ -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}`;
|
15
src/app/App.tsx
Normal file
15
src/app/App.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {AppRoutes} from './routes';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<Helmet title={"Pitch Deck"}>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
</Helmet>
|
||||
<AppRoutes />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
13
src/app/AppProvider.tsx
Normal file
13
src/app/AppProvider.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import {FC, PropsWithChildren} from 'react';
|
||||
import {BrowserRouter} from 'react-router-dom';
|
||||
import {ReactQueryProvider} from './providers';
|
||||
|
||||
export const AppProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ReactQueryProvider>
|
||||
{children}
|
||||
</ReactQueryProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
2
src/app/index.ts
Normal file
2
src/app/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './App';
|
||||
export * from './AppProvider';
|
1
src/app/providers/index.ts
Normal file
1
src/app/providers/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './react-query';
|
11
src/app/providers/react-query.tsx
Normal file
11
src/app/providers/react-query.tsx
Normal file
|
@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
13
src/app/routes/AppRoutes.tsx
Normal file
13
src/app/routes/AppRoutes.tsx
Normal file
|
@ -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 (
|
||||
<Routes>
|
||||
<Route path={CHAT_PAGE_ROUTE} element={<ChatPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
2
src/app/routes/index.ts
Normal file
2
src/app/routes/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './AppRoutes';
|
||||
export * from './routes';
|
1
src/app/routes/routes.ts
Normal file
1
src/app/routes/routes.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const CHAT_PAGE_ROUTE = `/chat`;
|
153
src/app/styles/baseText.scss
Normal file
153
src/app/styles/baseText.scss
Normal file
|
@ -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;
|
||||
}
|
71
src/app/styles/breakpoints.scss
Normal file
71
src/app/styles/breakpoints.scss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
71
src/app/styles/colors.scss
Normal file
71
src/app/styles/colors.scss
Normal file
|
@ -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;
|
48
src/app/styles/fonts.scss
Normal file
48
src/app/styles/fonts.scss
Normal file
|
@ -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;
|
||||
}
|
37
src/app/styles/index.scss
Normal file
37
src/app/styles/index.scss
Normal file
|
@ -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;
|
||||
}
|
220
src/app/styles/mixins.scss
Normal file
220
src/app/styles/mixins.scss
Normal file
|
@ -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 */
|
||||
}
|
||||
}
|
6
src/app/styles/radius.scss
Normal file
6
src/app/styles/radius.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
$radius-none: 0;
|
||||
$radius-small: 4px;
|
||||
$radius-medium: 8px;
|
||||
$radius-large: 12px;
|
||||
$radius-large-x: 16px;
|
||||
$radius-large-xx: 24px;
|
7
src/app/styles/shadow.scss
Normal file
7
src/app/styles/shadow.scss
Normal file
|
@ -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%);
|
12
src/app/styles/spacing.scss
Normal file
12
src/app/styles/spacing.scss
Normal file
|
@ -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;
|
8
src/app/styles/vars.scss
Normal file
8
src/app/styles/vars.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
@import 'colors';
|
||||
@import 'baseText';
|
||||
@import 'breakpoints';
|
||||
@import 'mixins';
|
||||
@import 'radius';
|
||||
@import 'spacing';
|
||||
@import 'z-index';
|
||||
@import 'shadow';
|
11
src/app/styles/z-index.scss
Normal file
11
src/app/styles/z-index.scss
Normal file
|
@ -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;
|
BIN
src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff
Normal file
BIN
src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff
Normal file
Binary file not shown.
BIN
src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff2
Normal file
BIN
src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Raleway/Raleway-Bold.woff
Normal file
BIN
src/assets/fonts/Raleway/Raleway-Bold.woff
Normal file
Binary file not shown.
BIN
src/assets/fonts/Raleway/Raleway-Bold.woff2
Normal file
BIN
src/assets/fonts/Raleway/Raleway-Bold.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Raleway/Raleway-Medium.woff
Normal file
BIN
src/assets/fonts/Raleway/Raleway-Medium.woff
Normal file
Binary file not shown.
BIN
src/assets/fonts/Raleway/Raleway-Medium.woff2
Normal file
BIN
src/assets/fonts/Raleway/Raleway-Medium.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Raleway/Raleway-Regular.woff
Normal file
BIN
src/assets/fonts/Raleway/Raleway-Regular.woff
Normal file
Binary file not shown.
BIN
src/assets/fonts/Raleway/Raleway-Regular.woff2
Normal file
BIN
src/assets/fonts/Raleway/Raleway-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Raleway/Raleway-SemiBold.woff
Normal file
BIN
src/assets/fonts/Raleway/Raleway-SemiBold.woff
Normal file
Binary file not shown.
BIN
src/assets/fonts/Raleway/Raleway-SemiBold.woff2
Normal file
BIN
src/assets/fonts/Raleway/Raleway-SemiBold.woff2
Normal file
Binary file not shown.
5
src/assets/icons/right.svg
Normal file
5
src/assets/icons/right.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.834 9.99983H4.16731" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6673 14.1665L15.834 9.99983" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6673 5.83317L15.834 9.99983" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 494 B |
119
src/components/Button/Button.module.scss
Normal file
119
src/components/Button/Button.module.scss
Normal file
|
@ -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%;
|
||||
}
|
148
src/components/Button/Button.tsx
Normal file
148
src/components/Button/Button.tsx
Normal file
|
@ -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<ComponentType extends ElementType = ButtonDefaultComponentType> {
|
||||
/**
|
||||
* Размер ("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<ComponentType>['ref'];
|
||||
/**
|
||||
* Дополнительные css-классы элементов
|
||||
*/
|
||||
classes?: {
|
||||
content?: string;
|
||||
contentLeft?: string;
|
||||
contentRight?: string;
|
||||
text?: string;
|
||||
icon?: string;
|
||||
};
|
||||
/**
|
||||
* Вариант растягивания кнопки ("fit", "fill")
|
||||
*/
|
||||
stretch?: ButtonStretch;
|
||||
}
|
||||
|
||||
export type ButtonProps<ComponentType extends ElementType = ButtonDefaultComponentType> = PolyExtends<
|
||||
ComponentType,
|
||||
ButtonSelfProps<ComponentType>,
|
||||
React.ComponentProps<ComponentType>
|
||||
>;
|
||||
|
||||
export function Button<ComponentType extends ElementType = ButtonDefaultComponentType>({
|
||||
component,
|
||||
className,
|
||||
style,
|
||||
size = ButtonSize.medium,
|
||||
variant = ButtonVariant.primary,
|
||||
disabled,
|
||||
hovered,
|
||||
children,
|
||||
leftIcon,
|
||||
right,
|
||||
left,
|
||||
rightIcon,
|
||||
isLoading,
|
||||
color,
|
||||
innerRef,
|
||||
classes,
|
||||
stretch,
|
||||
...props
|
||||
}: ButtonProps<ComponentType>) {
|
||||
// Чтобы 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 (
|
||||
<Component
|
||||
ref={innerRef}
|
||||
className={clsx(
|
||||
s.Button,
|
||||
{
|
||||
[s[`Button_size_${size}`]]: size,
|
||||
[s[`Button_variant_${variant}`]]: variant,
|
||||
[s[`Button_stretch_${stretch}`]]: stretch,
|
||||
[s.Button_disabled]: disabled,
|
||||
[s.Button_hovered]: hovered,
|
||||
[s.Button_hasLeft]: hasLeft,
|
||||
[s.Button_hasRight]: hasRight
|
||||
},
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
color,
|
||||
...style
|
||||
}}
|
||||
disabled={disabled}
|
||||
{...buttonProps}
|
||||
{...props}>
|
||||
<div className={clsx(s.Button__content, classes?.content)}>
|
||||
<div className={classes?.text}>{children}</div>
|
||||
|
||||
{/*{isLoading && <Loader className={s.Button__loader} size={LoaderSize.small} />}*/}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
Button.Variant = ButtonVariant;
|
||||
Button.Size = ButtonSize;
|
1
src/components/Button/index.ts
Normal file
1
src/components/Button/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Button';
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<BaseCheckboxIconProps> = ({
|
||||
className,
|
||||
checked,
|
||||
disabled,
|
||||
radio,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
s.BaseCheckboxIcon,
|
||||
{
|
||||
[s.BaseCheckboxIcon_radio]: radio,
|
||||
[s.BaseCheckboxIcon_disabled]: disabled,
|
||||
[s.BaseCheckboxIcon_checked]: checked
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<div
|
||||
className={clsx(s.BaseCheckboxIcon__icon, {
|
||||
[s.BaseCheckboxIcon__icon_disabled]: disabled,
|
||||
[s.BaseCheckboxIcon__icon_visible]: checked
|
||||
})}>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
1
src/components/Checkbox/BaseCheckboxIcon/index.ts
Normal file
1
src/components/Checkbox/BaseCheckboxIcon/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './BaseCheckboxIcon';
|
81
src/components/Checkbox/Checkbox.module.scss
Normal file
81
src/components/Checkbox/Checkbox.module.scss
Normal file
|
@ -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;
|
||||
}
|
121
src/components/Checkbox/Checkbox.tsx
Normal file
121
src/components/Checkbox/Checkbox.tsx
Normal file
|
@ -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<InputPropsWithoutRef, 'label' | 'onChange'> {
|
||||
/**
|
||||
* Состояние Checkbox: включен или выключен
|
||||
*/
|
||||
checked?: boolean;
|
||||
/**
|
||||
* Неактивное состояние Checkbox - состояние, при котором компонент отображается, но недоступен для действий пользователя
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Если true, то компонент аналогичен компоненту Radio
|
||||
*/
|
||||
radio?: boolean;
|
||||
/**
|
||||
* Слот подписи
|
||||
*/
|
||||
label?: ReactNode;
|
||||
/**
|
||||
* Обработчик изменения состояния Checkbox. Принимает на вход новое значение состояния Checkbox (в случае, если checked, то новое значение - false, иначе - true) и ChangeEvent
|
||||
*/
|
||||
onChange?: (value: boolean, e: ChangeEvent<HTMLInputElement>) => void;
|
||||
/**
|
||||
* Слот для замены иконки чекбокса
|
||||
*/
|
||||
checkboxIcon?: React.ReactNode;
|
||||
/**
|
||||
* Дополнительные css-классы элементов
|
||||
* * input – класс элемента input
|
||||
* * label – класс лейбла
|
||||
* * icon – класс иконки чекбокса
|
||||
*/
|
||||
classes?: {
|
||||
input?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
};
|
||||
/**
|
||||
* Дополнительный css-класс корневого элемента
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Реф на корневой элемент
|
||||
*/
|
||||
innerRef?: React.Ref<HTMLLabelElement>;
|
||||
/**
|
||||
* Реф на input
|
||||
*/
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
/**
|
||||
* Дополнительные пропы корневого элемента
|
||||
*/
|
||||
rootProps?: IntrinsicPropsWithoutRef<'label'>;
|
||||
}
|
||||
|
||||
export const Checkbox: FunctionComponent<ICheckboxProps> = ({
|
||||
className,
|
||||
checked,
|
||||
disabled,
|
||||
radio,
|
||||
label,
|
||||
onChange,
|
||||
style,
|
||||
checkboxIcon,
|
||||
classes,
|
||||
innerRef,
|
||||
inputRef,
|
||||
rootProps,
|
||||
...props
|
||||
}) => {
|
||||
const handleChange = onChange
|
||||
? (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(!checked, e);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={innerRef}
|
||||
className={clsx(
|
||||
s.Checkbox,
|
||||
{
|
||||
[s.Checkbox_radio]: radio,
|
||||
[s.Checkbox_disabled]: disabled,
|
||||
[s.Checkbox_checked]: checked
|
||||
},
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
{...rootProps}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={radio ? 'radio' : 'checkbox'}
|
||||
className={clsx(s.Checkbox__input, classes?.input)}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
<BaseCheckboxIcon
|
||||
className={clsx(
|
||||
s.Checkbox__icon,
|
||||
{
|
||||
[s.Checkbox__icon_radio]: radio
|
||||
},
|
||||
classes?.icon
|
||||
)}
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
radio={radio}>
|
||||
{checkboxIcon}
|
||||
</BaseCheckboxIcon>
|
||||
{label && <div className={clsx(s.Checkbox__label, classes?.label)}>{label}</div>}
|
||||
</label>
|
||||
);
|
||||
};
|
1
src/components/Checkbox/index.ts
Normal file
1
src/components/Checkbox/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Checkbox';
|
30
src/components/Form/Form.tsx
Normal file
30
src/components/Form/Form.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { useForm, UseFormReturn, SubmitHandler, UseFormProps, FieldValues } from 'react-hook-form';
|
||||
|
||||
type FormProps<TFormValues extends FieldValues> = {
|
||||
className?: string;
|
||||
onSubmit?: SubmitHandler<TFormValues>;
|
||||
children: (methods: UseFormReturn<TFormValues>) => React.ReactNode;
|
||||
options?: UseFormProps<TFormValues>;
|
||||
id?: string;
|
||||
innerRef?: React.Ref<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export const Form = <TFormValues extends FieldValues = FieldValues>({
|
||||
onSubmit,
|
||||
children,
|
||||
className,
|
||||
options,
|
||||
id,
|
||||
innerRef
|
||||
}: FormProps<TFormValues>) => {
|
||||
const methods = useForm<TFormValues>({ ...options });
|
||||
|
||||
return (
|
||||
// <form className={className} onSubmit={onSubmit && methods.handleSubmit(onSubmit)} id={id} ref={innerRef}>
|
||||
<>
|
||||
{children(methods)}
|
||||
</>
|
||||
// </form>
|
||||
);
|
||||
};
|
1
src/components/Form/index.ts
Normal file
1
src/components/Form/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Form';
|
83
src/components/Hint/Hint.module.scss
Normal file
83
src/components/Hint/Hint.module.scss
Normal file
|
@ -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;
|
||||
}
|
72
src/components/Hint/Hint.tsx
Normal file
72
src/components/Hint/Hint.tsx
Normal file
|
@ -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<HintProps> = (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 (
|
||||
<div className={clsx(s.Hint__container, hovered && s.Hint__container_hovered)} onClick={() => {
|
||||
if (!isMobile) {
|
||||
onClickProp?.();
|
||||
}
|
||||
}}>
|
||||
<div className={s.Hint__tip} />
|
||||
<div className={clsx(s.Hint, className, {[s.Hint_hovered]: hovered})} {...hintProps} onClick={onClick}>
|
||||
{children}
|
||||
|
||||
{isMobile && hovered && (
|
||||
<Button variant={ButtonVariant.secondary} className={s.Hint__button} onClick={(e: any) => {
|
||||
e.stopPropagation();
|
||||
if (isMobile) {
|
||||
onClickProp?.();
|
||||
}
|
||||
}}>Использовать</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface HintsContainerProps {
|
||||
isLoading?: boolean;
|
||||
margin?: boolean;
|
||||
}
|
||||
|
||||
export const HintsContainer: ReactFCC<HintsContainerProps> = ({ children, isLoading, margin }) => {
|
||||
return (
|
||||
<div className={clsx(s.Hints, (children || isLoading) && margin && s.Hints_margin)}>{!isLoading ? children : (
|
||||
<div className={s.Hints__stub}>Загрузка подсказок...</div>
|
||||
)}</div>
|
||||
)
|
||||
}
|
1
src/components/Hint/index.ts
Normal file
1
src/components/Hint/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Hint';
|
130
src/components/Input/Input.module.scss
Normal file
130
src/components/Input/Input.module.scss
Normal file
|
@ -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;
|
||||
}
|
103
src/components/Input/Input.tsx
Normal file
103
src/components/Input/Input.tsx
Normal file
|
@ -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<IntrinsicPropsWithoutRef<'input'>, 'onClick'> {
|
||||
/**
|
||||
* Состояние ошибки
|
||||
*/
|
||||
error?: boolean;
|
||||
/**
|
||||
* Проп для контролируемого включения состояния фокуса
|
||||
*/
|
||||
focused?: boolean;
|
||||
/**
|
||||
* Ref на input-элемент
|
||||
*/
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
/**
|
||||
* Обработчик нажатия на Input
|
||||
*/
|
||||
onClick?: (event: React.MouseEvent<HTMLDivElement, 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<UseFormRegisterReturn>;
|
||||
}
|
||||
|
||||
export type InputType = React.ForwardRefExoticComponent<InputProps & React.RefAttributes<HTMLDivElement>>;
|
||||
|
||||
const InputForwardedRef = React.forwardRef<HTMLDivElement, InputProps>(
|
||||
(
|
||||
{
|
||||
error,
|
||||
focused: focusedProp,
|
||||
className,
|
||||
classes,
|
||||
onClick,
|
||||
onInput,
|
||||
inputRef: inputRefProp,
|
||||
style,
|
||||
type,
|
||||
value,
|
||||
registration,
|
||||
...inputProps
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const delegateProps = useDelegateFocus<HTMLDivElement, HTMLInputElement>(inputRef, { onClick });
|
||||
const { focused, ...focusProps } = useFocus({ ...inputProps, ...registration });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
s.Input,
|
||||
{
|
||||
[s.Input_focus]: focusedProp ?? focused,
|
||||
[s.Input_error]: error,
|
||||
[s.Input_disabled]: inputProps.disabled
|
||||
},
|
||||
className,
|
||||
classes?.root
|
||||
)}
|
||||
style={style}
|
||||
{...delegateProps}>
|
||||
<input
|
||||
type={type}
|
||||
className={clsx(s.Input__input, { [s.Input__input_disabled]: inputProps.disabled }, classes?.input)}
|
||||
autoComplete={'off'}
|
||||
{...registration}
|
||||
// @ts-ignore
|
||||
ref={composeRefs(inputRef, inputRefProp, registration?.ref)}
|
||||
{...inputProps}
|
||||
{...focusProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputForwardedRef.displayName = 'Input';
|
||||
|
||||
export const Input: InputType = InputForwardedRef;
|
1
src/components/Input/index.ts
Normal file
1
src/components/Input/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Input';
|
29
src/components/Message/Message.module.scss
Normal file
29
src/components/Message/Message.module.scss
Normal file
|
@ -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;
|
||||
}
|
33
src/components/Message/Message.tsx
Normal file
33
src/components/Message/Message.tsx
Normal file
|
@ -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<MessageProps> = (props) => {
|
||||
const { children, className, variant, type } = props;
|
||||
|
||||
return (
|
||||
<div className={clsx(s.Message, s[`Message_variant_${variant}`], s[`Message_type_${type}`], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
1
src/components/Message/index.ts
Normal file
1
src/components/Message/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Message';
|
19
src/components/Radio/Radio.tsx
Normal file
19
src/components/Radio/Radio.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React, { ChangeEvent, FunctionComponent } from 'react';
|
||||
import {Checkbox, ICheckboxProps} from '../Checkbox';
|
||||
|
||||
export interface RadioProps extends Omit<ICheckboxProps, 'radio' | 'onChange'> {
|
||||
/**
|
||||
* Обработчик изменения значения Radio. Принимает на вход значение Radio и ChangeEvent
|
||||
*/
|
||||
onChange?: (value: string, e: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const Radio: FunctionComponent<RadioProps> = ({ onChange, ...props }) => {
|
||||
const handleChange = onChange
|
||||
? (value: boolean, event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.target.value, event);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return <Checkbox {...props} radio={true} onChange={handleChange} />;
|
||||
};
|
1
src/components/Radio/index.tsx
Normal file
1
src/components/Radio/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Radio';
|
132
src/components/Range/Range.module.scss
Normal file
132
src/components/Range/Range.module.scss
Normal file
|
@ -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;
|
||||
}
|
55
src/components/Range/Range.tsx
Normal file
55
src/components/Range/Range.tsx
Normal file
|
@ -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<InputPropsWithoutRef, 'value' | 'onChange'> {
|
||||
/**
|
||||
* Дополнительный css-класс
|
||||
*/
|
||||
className?: string;
|
||||
label?: string;
|
||||
value: number;
|
||||
onChange: (value: number, e: any) => void;
|
||||
format?: boolean;
|
||||
}
|
||||
|
||||
export const Range: ReactFCC<RangeProps> = (props) => {
|
||||
const {className, min = 0, max = 100, label, value, format, onChange: onChangeProp, ...rest } = props;
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={clsx(s.Range, className)}>
|
||||
<div className={s.Range__label}>{label}</div>
|
||||
|
||||
<div className={s.Range__container}>
|
||||
<div className={s.Range__tip}>{format ? currencyFormatter.format(min as number) : min}</div>
|
||||
|
||||
<div className={s.Range__inputContainer}>
|
||||
<div className={s.Range__progress} style={{ width: `calc(${percent}% - 1px)` }} />
|
||||
<input className={s.Range__input} type="range" value={value} onChange={onChange} min={min} max={max} {...rest} />
|
||||
</div>
|
||||
|
||||
<div className={clsx(s.Range__tip, s.Range__tip_active)}>{format ? currencyFormatter.format(value as number) : value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
1
src/components/Range/index.ts
Normal file
1
src/components/Range/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Range';
|
136
src/components/Textarea/Textarea.module.scss
Normal file
136
src/components/Textarea/Textarea.module.scss
Normal file
|
@ -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;
|
||||
}
|
113
src/components/Textarea/Textarea.tsx
Normal file
113
src/components/Textarea/Textarea.tsx
Normal file
|
@ -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<IntrinsicPropsWithoutRef<'textarea'>, 'onClick' | 'onBlur'> {
|
||||
/**
|
||||
* Состояние ошибки
|
||||
*/
|
||||
error?: boolean;
|
||||
/**
|
||||
* Проп для контролируемого включения состояния фокуса
|
||||
*/
|
||||
focused?: boolean;
|
||||
/**
|
||||
* Ref на input-элемент
|
||||
*/
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
/**
|
||||
* Обработчик нажатия на Input
|
||||
*/
|
||||
onClick?: (event: React.MouseEvent<HTMLDivElement, 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<UseFormRegisterReturn>;
|
||||
}
|
||||
|
||||
export type InputType = React.ForwardRefExoticComponent<TextAreaProps & React.RefAttributes<HTMLDivElement>>;
|
||||
|
||||
const TextareaForwardedRef = React.forwardRef<HTMLDivElement, TextAreaProps>(
|
||||
(
|
||||
{
|
||||
error,
|
||||
focused: focusedProp,
|
||||
className,
|
||||
classes,
|
||||
onClick,
|
||||
onInput,
|
||||
inputRef: inputRefProp,
|
||||
style,
|
||||
registration,
|
||||
...inputProps
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const delegateProps = useDelegateFocus<HTMLDivElement, HTMLInputElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
s.Input,
|
||||
{
|
||||
[s.Input_focus]: focusedProp ?? focused,
|
||||
[s.Input_error]: error,
|
||||
[s.Input_disabled]: inputProps.disabled
|
||||
},
|
||||
className,
|
||||
classes?.root
|
||||
)}
|
||||
style={style}
|
||||
{...delegateProps}>
|
||||
<textarea
|
||||
className={clsx(s.Input__input, { [s.Input__input_disabled]: inputProps.disabled }, classes?.input)}
|
||||
autoComplete={'off'}
|
||||
{...registration}
|
||||
// @ts-ignore
|
||||
ref={composeRefs(inputRef, inputRefProp, registration?.ref)}
|
||||
{...inputProps}
|
||||
{...focusProps}
|
||||
// @ts-ignore
|
||||
onInput={(e) => {
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = '1px';
|
||||
inputRef.current.style.height = inputRef.current.scrollHeight + 'px';
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TextareaForwardedRef.displayName = 'Input';
|
||||
|
||||
export const Textarea: InputType = TextareaForwardedRef;
|
1
src/components/Textarea/index.ts
Normal file
1
src/components/Textarea/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Textarea';
|
2
src/config.ts
Normal file
2
src/config.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const API_URL = 'http://192.168.83.181:8000/api';
|
||||
// export const API_URL = 'http://192.168.22.4:8000/api';
|
32
src/hooks/useDelegateFocus.ts
Normal file
32
src/hooks/useDelegateFocus.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Прокидывает фокус на targetRef при взаимодействии с элементом
|
||||
* @param targetRef - реф элемента, куда прокидывать клик
|
||||
* @param props
|
||||
* @param props.onClick - внешний обработчик клика
|
||||
* @returns
|
||||
* - onClick - новый обработчик клика для элемента
|
||||
*/
|
||||
export const useDelegateFocus = <Root extends HTMLElement, Target extends HTMLElement>(
|
||||
targetRef: React.MutableRefObject<Target | null>,
|
||||
{
|
||||
onClick: onClickProp
|
||||
}: {
|
||||
onClick?: React.MouseEventHandler<Root>;
|
||||
} = {}
|
||||
) => {
|
||||
const onClick = useCallback(
|
||||
(event: React.MouseEvent<Root, MouseEvent>) => {
|
||||
if (targetRef.current && event.target !== targetRef.current) {
|
||||
targetRef.current.focus();
|
||||
}
|
||||
onClickProp?.(event);
|
||||
},
|
||||
[onClickProp, targetRef]
|
||||
);
|
||||
|
||||
return {
|
||||
onClick
|
||||
};
|
||||
};
|
13
src/hooks/useFactoryRef.ts
Normal file
13
src/hooks/useFactoryRef.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Единожды вызывает функцию и сохраняет ее значение в реф
|
||||
* @param factory - функция, создающая значение рефа
|
||||
*/
|
||||
export function useFactoryRef<T>(factory: () => T): T {
|
||||
const ref = useRef<T | null>(null);
|
||||
if (!ref.current) {
|
||||
ref.current = factory();
|
||||
}
|
||||
return ref.current;
|
||||
}
|
55
src/hooks/useFocus.ts
Normal file
55
src/hooks/useFocus.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { FocusEvent, FocusEventHandler, useState } from 'react';
|
||||
|
||||
import { useFactoryRef } from './useFactoryRef';
|
||||
import { usePropsRef } from './usePropsRef';
|
||||
|
||||
export interface IUseFocusProps<Element> {
|
||||
/**
|
||||
* Внешний обработчик onFocus
|
||||
*/
|
||||
onFocus?: FocusEventHandler<Element>;
|
||||
/**
|
||||
* Внешний обработчик onBlur
|
||||
*/
|
||||
onBlur?: FocusEventHandler<Element>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Позволяет использовать focused как состояние компонента.
|
||||
* Активируется, когда элемент или его потомки становятся focus-visible
|
||||
*
|
||||
* @returns
|
||||
* - focused - состояние фокуса (true если :focus-visible)
|
||||
* - onFocus, onBlur - обработчики, которые надо повесить на элемент
|
||||
*/
|
||||
export function useFocus<Element extends HTMLElement>({ onFocus, onBlur }: IUseFocusProps<Element> = {}) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
/**
|
||||
* Используем useRef вместо useCallback, чтобы прервать каскад ререндеров
|
||||
* при смене onFocus/onBlur в пропах хука
|
||||
*/
|
||||
|
||||
const propsRef = usePropsRef<IUseFocusProps<Element>>({
|
||||
onFocus,
|
||||
onBlur
|
||||
});
|
||||
const funcsRef = useFactoryRef(() => ({
|
||||
onFocus: (e: FocusEvent<Element>) => {
|
||||
if (e.target.classList.contains('focus-visible')) {
|
||||
setFocused(true);
|
||||
}
|
||||
propsRef.onFocus?.(e);
|
||||
},
|
||||
onBlur: (e: FocusEvent<Element>) => {
|
||||
setFocused(false);
|
||||
propsRef.onBlur?.(e);
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
focused,
|
||||
onFocus: funcsRef.onFocus,
|
||||
onBlur: funcsRef.onBlur
|
||||
};
|
||||
}
|
52
src/hooks/useHover.ts
Normal file
52
src/hooks/useHover.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { MouseEvent, MouseEventHandler, useState } from 'react';
|
||||
|
||||
import { useFactoryRef } from './useFactoryRef';
|
||||
import { usePropsRef } from './usePropsRef';
|
||||
|
||||
export interface IUseHoverProps<Element> {
|
||||
/**
|
||||
* Внешний обработчик onMouseOver
|
||||
*/
|
||||
onMouseOver?: MouseEventHandler<Element>;
|
||||
/**
|
||||
* Внешний обработчик onMouseOut
|
||||
*/
|
||||
onMouseOut?: MouseEventHandler<Element>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Позволяет использовать hovered как состояние компонента.
|
||||
*
|
||||
* @returns
|
||||
* - hovered - состояние фокуса
|
||||
* - onMouseOver, onMouseOut - обработчики, которые надо повесить на элемент
|
||||
*/
|
||||
export function useHover<Element extends HTMLElement>({ onMouseOver, onMouseOut }: IUseHoverProps<Element> = {}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
/**
|
||||
* Используем useRef вместо useCallback, чтобы прервать каскад ререндеров
|
||||
* при смене onMouseOver/onMouseOut в пропах хука
|
||||
*/
|
||||
const propsRef = usePropsRef<IUseHoverProps<Element>>({
|
||||
onMouseOver,
|
||||
onMouseOut
|
||||
});
|
||||
|
||||
const funcsRef = useFactoryRef(() => ({
|
||||
onMouseOver: (e: MouseEvent<Element>) => {
|
||||
setHovered(true);
|
||||
propsRef.onMouseOver?.(e);
|
||||
},
|
||||
onMouseOut: (e: MouseEvent<Element>) => {
|
||||
setHovered(false);
|
||||
propsRef.onMouseOut?.(e);
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
hovered,
|
||||
onMouseOver: funcsRef.onMouseOver,
|
||||
onMouseOut: funcsRef.onMouseOut
|
||||
};
|
||||
}
|
3
src/hooks/useIsDesktop.ts
Normal file
3
src/hooks/useIsDesktop.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import {mediaQuery, useMediaQueryResult} from './useMediaQueryResult';
|
||||
|
||||
export const useIsDesktop = () => useMediaQueryResult(mediaQuery.tabletSmallDown);
|
3
src/hooks/useIsMobile.ts
Normal file
3
src/hooks/useIsMobile.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import {mediaQuery, useMediaQueryResult} from './useMediaQueryResult';
|
||||
|
||||
export const useIsMobile = () => useMediaQueryResult(mediaQuery.tabletSmallDown);
|
19
src/hooks/useLatestCallbackRef.ts
Normal file
19
src/hooks/useLatestCallbackRef.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { useRef } from "react";
|
||||
import { useFactoryRef } from './useFactoryRef';
|
||||
|
||||
/**
|
||||
* Позволяет обернуть меняющийся колбек в неменяющйеся с помощью рефа
|
||||
* @param callback - колбек
|
||||
* @returns постоянная функция, которая вызывает последний переданный колбек
|
||||
* @example
|
||||
* const t = new Date();
|
||||
* const onClick = useLatestCallbackRef(() => console.log(t));
|
||||
* onClick -> постоянная функция, которая вызывает последний переданный колбек
|
||||
*/
|
||||
export function useLatestCallbackRef<Args extends any[], Result>(
|
||||
callback: (...args: Args) => Result
|
||||
): (...args: Args) => Result {
|
||||
const callbackRef = useRef<(...args: Args) => Result>(callback);
|
||||
callbackRef.current = callback;
|
||||
return useFactoryRef(() => (...args: Args) => callbackRef.current(...args));
|
||||
}
|
67
src/hooks/useLiveInput.ts
Normal file
67
src/hooks/useLiveInput.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useTimeoutRef } from "./useTimeoutRef";
|
||||
import { useLatestCallbackRef } from "./useLatestCallbackRef";
|
||||
|
||||
/**
|
||||
* Дебаунсит событие изменения, сохраняя в стейте флаг активности дебауса.
|
||||
* Например, при использовании с формой поиска, когда пользователь нажимает
|
||||
* какую-либо клавишу, стейт isNewInput становится true, но колбек onSubmit
|
||||
* не вызывается. В этот момент можно показать пользователю индикатор загрузки,
|
||||
* сделав вид, что запрос отправился, хотя на самом деле запрос отправится
|
||||
* только после того, как пользователь не будет ничего нажимать в течение
|
||||
* времени delay.
|
||||
*
|
||||
* @param onSubmit - колбек, который вызывается с дебаунсом
|
||||
* @param delay - время дебаунса
|
||||
* @example
|
||||
* // Какой-то хук загрузки данных
|
||||
* const [data, isLoading, loadData] = useData();
|
||||
*
|
||||
* const [search, setSearch] = useState('');
|
||||
* const [isNewInput, submit] = useLiveInput((value) => {
|
||||
* loadData(value);
|
||||
* });
|
||||
* const handleChange = (e) => {
|
||||
* setSearch(search);
|
||||
* // При вводе в инпут инициируем запрос с дебаунсом
|
||||
* submit(e.target.value, true);
|
||||
* };
|
||||
* const handleSubmit = () => {
|
||||
* // При отправке формы отправляем запрос сразу
|
||||
* submit(search, false);
|
||||
* };
|
||||
*
|
||||
* return <div>
|
||||
* <form onSubmit={handleSubmit}>
|
||||
* <input onChange={handleChange} name="search" value={search} />
|
||||
* <button>Search</button>
|
||||
* </form>
|
||||
* <div>
|
||||
* {(isNewInput || isLoading) ? <Spinner /> : <Data data={data} />}
|
||||
* </div>
|
||||
* </div>;
|
||||
*/
|
||||
export function useLiveInput<Value>(onSubmit: (value: Value) => void, delay: number = 500) {
|
||||
const [isNewInput, setIsNewInput] = useState(false);
|
||||
const submitTimeoutRef = useTimeoutRef();
|
||||
const setFiltersRef = useLatestCallbackRef((value: Value) => {
|
||||
setIsNewInput(false);
|
||||
onSubmit(value);
|
||||
});
|
||||
const handleSubmit = useCallback(
|
||||
(value: Value, useTimeout: boolean = false) => {
|
||||
if (useTimeout) {
|
||||
setIsNewInput(true);
|
||||
submitTimeoutRef.set(() => {
|
||||
setFiltersRef(value);
|
||||
}, delay);
|
||||
} else {
|
||||
submitTimeoutRef.clear();
|
||||
setFiltersRef(value);
|
||||
}
|
||||
},
|
||||
[submitTimeoutRef, setFiltersRef, delay],
|
||||
);
|
||||
|
||||
return [isNewInput, handleSubmit] as const;
|
||||
}
|
39
src/hooks/useMediaQueryResult.ts
Normal file
39
src/hooks/useMediaQueryResult.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useMemo, useState, useEffect } from 'react';
|
||||
|
||||
export const breakpointMobileSmall = 320;
|
||||
export const breakpointMobileLarge = 375;
|
||||
export const breakpointTabletSmall = 768;
|
||||
export const breakpointTabletLarge = 1024;
|
||||
export const breakpointDesktopSmall = 1280;
|
||||
export const breakpointDesktopMedium = 1440;
|
||||
export const breakpointDesktopLarge = 1920;
|
||||
|
||||
export const mediaQuery = {
|
||||
mobileLargeDown: `(max-width: ${breakpointMobileLarge - 1}px)`, // 374
|
||||
tabletSmallDown: `(max-width: ${breakpointTabletSmall - 1}px)`, // 767
|
||||
tabletSmallUp: `(min-width: ${breakpointTabletSmall}px)`, // 767
|
||||
tabletLargeDown: `(max-width: ${breakpointTabletLarge - 1}px)`, // 1023
|
||||
tabletLargeUp: `(min-width: ${breakpointTabletLarge}px)`, // 1024
|
||||
desktopSmallDown: `(max-width: ${breakpointDesktopSmall - 1}px)`, // 1279
|
||||
desktopSmallUp: `(min-width: ${breakpointDesktopSmall}px)`, // 1280
|
||||
desktopMediumDown: `(max-width: ${breakpointDesktopMedium - 1}px)`, // 1439
|
||||
desktopMediumUp: `(min-width: ${breakpointDesktopMedium}px)`, // 1440
|
||||
desktopLarge: `(min-width: ${breakpointDesktopLarge}px)` // 1919
|
||||
};
|
||||
|
||||
export function useMediaQueryResult(mediaQueryString: string) {
|
||||
const mediaQueryList = useMemo(() => {
|
||||
return window.matchMedia(mediaQueryString);
|
||||
}, [mediaQueryString]);
|
||||
|
||||
const [queryResult, setQueryResult] = useState(mediaQueryList.matches);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMediaQueryListChange = (e: MediaQueryListEvent) => setQueryResult(e.matches);
|
||||
|
||||
mediaQueryList.addEventListener('change', handleMediaQueryListChange);
|
||||
return () => mediaQueryList.removeEventListener('change', handleMediaQueryListChange);
|
||||
}, [mediaQueryList]);
|
||||
|
||||
return queryResult;
|
||||
}
|
15
src/hooks/usePrevious.ts
Normal file
15
src/hooks/usePrevious.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const usePrevious = <T>(value: T, overwriteWhenUndefined = true): T | undefined => {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!overwriteWhenUndefined && value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.current = value;
|
||||
}, [value, overwriteWhenUndefined]);
|
||||
|
||||
return ref.current;
|
||||
};
|
7
src/hooks/usePropsRef.ts
Normal file
7
src/hooks/usePropsRef.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { useRef } from 'react';
|
||||
|
||||
export function usePropsRef<T extends object>(props: T) {
|
||||
const ref = useRef<T>(props);
|
||||
Object.assign(ref.current, props);
|
||||
return ref.current;
|
||||
}
|
6
src/hooks/useSingleTimeout.ts
Normal file
6
src/hooks/useSingleTimeout.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { SingleTimeoutManager } from '../utils/SingleTimeoutManager';
|
||||
import { useFactoryRef } from './useFactoryRef';
|
||||
|
||||
export const useSingleTimeout = () => {
|
||||
return useFactoryRef(() => new SingleTimeoutManager());
|
||||
};
|
39
src/hooks/useTimeoutRef.ts
Normal file
39
src/hooks/useTimeoutRef.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import {SingleTimeoutManager} from '../utils/SingleTimeoutManager';
|
||||
|
||||
/**
|
||||
* Создает таймаут менеджер, который автоматически очищает активный
|
||||
* таймаут при анмаунте компонента. Активен может быть только один
|
||||
* таймаут. При создании нового предыдущий отключается.
|
||||
*
|
||||
* @example
|
||||
* function MyComponent() {
|
||||
* const [loading, setLoading] = useState(false);
|
||||
* const timeoutRef = useTimeoutRef();
|
||||
* const handleClick = () => {
|
||||
* setLoading(true);
|
||||
* timeoutRef.set(() => {
|
||||
* setLoading(false);
|
||||
* }, 2000);
|
||||
* };
|
||||
*
|
||||
* return <button type="button" onClick={handleClick}>
|
||||
* {loading ? 'Loading' : 'Click'}
|
||||
* </button>;
|
||||
* }
|
||||
*/
|
||||
export function useTimeoutRef() {
|
||||
const ref = useRef<SingleTimeoutManager | null>(null);
|
||||
|
||||
if (!ref.current) {
|
||||
ref.current = new SingleTimeoutManager();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ref.current) ref.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ref.current;
|
||||
}
|
37
src/hooks/useToggle.ts
Normal file
37
src/hooks/useToggle.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
type TUseToggleReturn = [
|
||||
value: boolean,
|
||||
funcs: {
|
||||
set: () => void;
|
||||
unset: () => void;
|
||||
toggle: () => void;
|
||||
change: (value: boolean) => void;
|
||||
}
|
||||
];
|
||||
|
||||
export const useToggle = (defaultValue: boolean = false): TUseToggleReturn => {
|
||||
const [value, change] = useState(defaultValue);
|
||||
|
||||
const set = useCallback(() => {
|
||||
change(true);
|
||||
}, [change]);
|
||||
|
||||
const unset = useCallback(() => {
|
||||
change(false);
|
||||
}, [change]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
change((v) => !v);
|
||||
}, [change]);
|
||||
|
||||
return [
|
||||
value,
|
||||
{
|
||||
change,
|
||||
set,
|
||||
unset,
|
||||
toggle
|
||||
}
|
||||
];
|
||||
};
|
24
src/hooks/useUrlParam.ts
Normal file
24
src/hooks/useUrlParam.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export interface UseUrlParamOptions<T> {
|
||||
parser?: (value: string) => T;
|
||||
}
|
||||
|
||||
export const useUrlParam = <T = string | null>(key: string, options: UseUrlParamOptions<T> = {}) => {
|
||||
const { [key]: param } = useParams();
|
||||
|
||||
const value = useMemo(() => {
|
||||
if (param === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.parser) {
|
||||
return options.parser(param);
|
||||
}
|
||||
|
||||
return param;
|
||||
}, [param, options]);
|
||||
|
||||
return value as T | null;
|
||||
};
|
|
@ -1,13 +0,0 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
|
@ -1,16 +1,25 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import {App} from './app';
|
||||
import {AppProvider} from './app';
|
||||
|
||||
import 'focus-visible';
|
||||
|
||||
import 'sanitize.css';
|
||||
import 'sanitize.css/forms.css';
|
||||
import 'sanitize.css/typography.css';
|
||||
import './app/styles/index.scss';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
// <React.StrictMode>
|
||||
<AppProvider>
|
||||
<App />
|
||||
</AppProvider>
|
||||
// </React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
|
|
10
src/lib/axios.ts
Normal file
10
src/lib/axios.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import Axios from 'axios';
|
||||
import {API_URL} from '../config';
|
||||
|
||||
export const axios = Axios.create({
|
||||
baseURL: API_URL
|
||||
});
|
||||
|
||||
axios.interceptors.response.use((response) => {
|
||||
return response.data;
|
||||
})
|
38
src/lib/react-query.ts
Normal file
38
src/lib/react-query.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
QueryClient,
|
||||
UseQueryOptions,
|
||||
UseMutationOptions,
|
||||
DefaultOptions,
|
||||
UseInfiniteQueryOptions
|
||||
} from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ExtractFnReturnType } from '../utils/types';
|
||||
|
||||
const queryConfig: DefaultOptions = {
|
||||
queries: {
|
||||
useErrorBoundary: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false
|
||||
}
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({ defaultOptions: queryConfig });
|
||||
|
||||
export type QueryConfig<QueryFnType extends (...args: any) => any> = Omit<
|
||||
UseQueryOptions<ExtractFnReturnType<QueryFnType>>,
|
||||
'queryKey' | 'queryFn'
|
||||
>;
|
||||
|
||||
export type InfiniteQueryConfig<QueryFnType extends (...args: any) => any> = Omit<
|
||||
UseInfiniteQueryOptions<ExtractFnReturnType<QueryFnType>>,
|
||||
'queryKey' | 'queryFn'
|
||||
>;
|
||||
|
||||
export type MutationConfig<MutationFnType extends (...args: any) => any> = UseMutationOptions<
|
||||
ExtractFnReturnType<MutationFnType>,
|
||||
AxiosError,
|
||||
Parameters<MutationFnType>[0],
|
||||
any
|
||||
>;
|
||||
|
||||
export type { ExtractFnReturnType };
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
Before Width: | Height: | Size: 2.6 KiB |
1
src/pages/_layouts/BaseLayout/BaseLayout.tsx
Normal file
1
src/pages/_layouts/BaseLayout/BaseLayout.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export {};
|
1
src/pages/_layouts/BaseLayout/index.ts
Normal file
1
src/pages/_layouts/BaseLayout/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export {};
|
86
src/pages/chat/ChatPage.module.scss
Normal file
86
src/pages/chat/ChatPage.module.scss
Normal file
|
@ -0,0 +1,86 @@
|
|||
@import 'src/app/styles/vars';
|
||||
|
||||
// Общий лейаут
|
||||
|
||||
.ChatPage {
|
||||
|
||||
}
|
||||
|
||||
.ChatPage__inner {
|
||||
position: relative;
|
||||
//width: $breakpoint-tablet-small;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100svh;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background-image: url('./assets/background2.jpg');
|
||||
background-size: 150%;
|
||||
|
||||
@include media-down(tablet-small) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ChatPage__container {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
//background-color: rgba($color-on-surface-dark-100, 0.6);
|
||||
//background-color: $color-surface-primary;
|
||||
background-color: rgba($color-surface-primary, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: $spacing-medium-x $spacing-small 0;
|
||||
|
||||
overflow-y: scroll;
|
||||
|
||||
@include scrollbar;
|
||||
@include flex-col;
|
||||
}
|
||||
|
||||
// Инпут
|
||||
|
||||
.ChatPage__inputContainer {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: $spacing-small;
|
||||
bottom: 0;
|
||||
background-color: rgba($color-surface-primary, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 2;
|
||||
@include flex-col;
|
||||
}
|
||||
|
||||
.ChatPage__skipButton {
|
||||
width: 100% !important;
|
||||
margin-top: $spacing-small;
|
||||
}
|
||||
|
||||
// Секция с контентом и сообщениями
|
||||
|
||||
.ChatPage__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
//justify-content: flex-end;
|
||||
row-gap: $spacing-small;
|
||||
}
|
||||
|
||||
.ChatPage__message {
|
||||
width: fit-content;
|
||||
max-width: 66%;
|
||||
|
||||
@include media-down(tablet-small) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ChatPage__message_right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ChatPage__message_left {
|
||||
margin-right: auto;
|
||||
}
|
188
src/pages/chat/ChatPage.tsx
Normal file
188
src/pages/chat/ChatPage.tsx
Normal file
|
@ -0,0 +1,188 @@
|
|||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import s from './ChatPage.module.scss';
|
||||
import {ReactFCC} from '../../utils/ReactFCC';
|
||||
import {ChatContent} from './components/ChatContent';
|
||||
import {ChatItemType, useChatHistory} from './store/history';
|
||||
import {SubmitHandler} from 'react-hook-form';
|
||||
import {getFirstQuestion, useCreateDeck} from '../../api/deck';
|
||||
import {ChatFormInitial} from './components/ChatForm/ChatFormInittial';
|
||||
import {useChatUi} from './hooks/useChatUi';
|
||||
import {useQuestion} from '../../api/deck/getQuestion';
|
||||
import {useCreateAnswer} from '../../api/deck/createAnswer';
|
||||
import {QuestionFactory} from './components/ChatForm/QuestionFactory';
|
||||
import {useSingleTimeout} from '../../hooks/useSingleTimeout';
|
||||
import {usePrevious} from '../../hooks/usePrevious';
|
||||
import {generateAnswerFromData} from './utils/generateAnswerFromData';
|
||||
import {generateTextFromAnswer} from './utils/generateTextFromAnswer';
|
||||
import {Button, ButtonVariant} from '../../components/Button';
|
||||
|
||||
export interface ChatPageProps {
|
||||
/**
|
||||
* Дополнительный css-класс
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QUESTION_POLLING_MS = 1000;
|
||||
|
||||
const DEFAULT_DECK_ID = 0;
|
||||
const DEFAULT_QUESTION_ID = 0;
|
||||
|
||||
export const ChatPage: ReactFCC<ChatPageProps> = (props) => {
|
||||
const {className} = props;
|
||||
|
||||
const timeout = useSingleTimeout();
|
||||
|
||||
// Работа с UI
|
||||
const { backgroundRef, containerRef, contentRef, inputContainerRef } = useChatUi();
|
||||
|
||||
const { history, pushHistory } = useChatHistory();
|
||||
const initRef = useRef(false);
|
||||
|
||||
// Устанавливаем первый вопрос в чат
|
||||
useEffect(() => {
|
||||
if (!initRef.current) {
|
||||
pushHistory({
|
||||
type: ChatItemType.receive,
|
||||
text: 'Введите описание проекта',
|
||||
});
|
||||
initRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Работа с API
|
||||
*/
|
||||
|
||||
const [deckId, setDeckId] = useState(DEFAULT_DECK_ID);
|
||||
const [questionId, setQuestionId] = useState(DEFAULT_QUESTION_ID);
|
||||
|
||||
const { mutateAsync: createDeck } = useCreateDeck();
|
||||
|
||||
const onSubmitInitial: SubmitHandler<any> = useCallback(async (data) => {
|
||||
const deck = await createDeck({
|
||||
description: data.description
|
||||
});
|
||||
setDeckId(deck.id);
|
||||
pushHistory({
|
||||
type: ChatItemType.send,
|
||||
text: deck.description as string
|
||||
});
|
||||
|
||||
const firstQuestion = await getFirstQuestion({ deckId });
|
||||
setQuestionId(firstQuestion.id);
|
||||
}, [createDeck, deckId, pushHistory]);
|
||||
|
||||
// Начинаем пинг-понг вопросов-ответов
|
||||
|
||||
const { data: question, refetch: refetchQuestion } = useQuestion({
|
||||
deckId,
|
||||
questionId,
|
||||
config: {
|
||||
enabled: !!(deckId && questionId),
|
||||
// keepPreviousData: true,
|
||||
}
|
||||
});
|
||||
|
||||
const prevQuestion = usePrevious(question);
|
||||
|
||||
useEffect(() => {
|
||||
if (question && question.id !== prevQuestion?.id) {
|
||||
timeout.clear();
|
||||
|
||||
pushHistory({
|
||||
type: ChatItemType.receive,
|
||||
text: question.text
|
||||
});
|
||||
|
||||
const startPolling = () => {
|
||||
timeout.set(async () => {
|
||||
const { data: newQuestion } = await refetchQuestion();
|
||||
if (newQuestion?.hint && !newQuestion.hint.type) {
|
||||
startPolling();
|
||||
}
|
||||
}, QUESTION_POLLING_MS);
|
||||
}
|
||||
|
||||
if (question?.hint && !question.hint.type) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pushHistory, question]);
|
||||
|
||||
|
||||
const { mutateAsync: createAnswer } = useCreateAnswer();
|
||||
|
||||
const onSubmit: SubmitHandler<any> = useCallback(async (data) => {
|
||||
if (!question || !data.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
timeout.clear();
|
||||
|
||||
const answerValue = generateAnswerFromData(question, data);
|
||||
|
||||
const answer = await createAnswer({
|
||||
deckId,
|
||||
questionId,
|
||||
answer: answerValue
|
||||
});
|
||||
|
||||
pushHistory({
|
||||
type: ChatItemType.send,
|
||||
text: generateTextFromAnswer(question.type, answer)
|
||||
});
|
||||
|
||||
setQuestionId(question!.next_id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [createAnswer, deckId, pushHistory, question, questionId]);
|
||||
|
||||
// Пропуск вопроса
|
||||
const onSkip = useCallback(() => {
|
||||
if (question && !question.params?.required) {
|
||||
setQuestionId(question.next_id);
|
||||
}
|
||||
}, [question]);
|
||||
|
||||
// ---------- Скролл чата ----------
|
||||
// todo при печатании текста тоже двигать скролл
|
||||
useEffect(() => {
|
||||
if (contentRef.current && inputContainerRef.current) {
|
||||
contentRef.current.style.paddingBottom = inputContainerRef.current.scrollHeight + 'px';
|
||||
containerRef.current?.scrollTo({ top: contentRef.current.scrollHeight });
|
||||
}
|
||||
}, [containerRef, contentRef, history, question, inputContainerRef]);
|
||||
|
||||
return (
|
||||
<div className={clsx(s.ChatPage, className)}>
|
||||
<div className={s.ChatPage__inner} ref={backgroundRef}>
|
||||
<div className={s.ChatPage__container} ref={containerRef}>
|
||||
<div className={s.ChatPage__content} ref={contentRef}>
|
||||
<ChatContent history={history} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.ChatPage__inputContainer} ref={inputContainerRef}>
|
||||
{question ? (
|
||||
<>
|
||||
<QuestionFactory onSubmit={onSubmit} {...question} />
|
||||
{!question.params?.required && (
|
||||
<Button
|
||||
className={s.ChatPage__skipButton}
|
||||
variant={ButtonVariant.secondary}
|
||||
onClick={() => onSkip()}
|
||||
>Пропустить</Button>
|
||||
)}
|
||||
</>
|
||||
) : !deckId ? (
|
||||
<ChatFormInitial onSubmit={onSubmitInitial} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
BIN
src/pages/chat/assets/background.jpg
Normal file
BIN
src/pages/chat/assets/background.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
BIN
src/pages/chat/assets/background2.jpg
Normal file
BIN
src/pages/chat/assets/background2.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 102 KiB |
86
src/pages/chat/components/ChatContent.tsx
Normal file
86
src/pages/chat/components/ChatContent.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import clsx from 'clsx';
|
||||
import {ReactFCC} from '../../../utils/ReactFCC';
|
||||
import s from '../ChatPage.module.scss';
|
||||
import {Message, MessageType, MessageVariant} from '../../../components/Message';
|
||||
import {Fragment, memo} from 'react';
|
||||
import {mediaQuery, useMediaQueryResult} from '../../../hooks/useMediaQueryResult';
|
||||
import {ChatItem, ChatItemType} from '../store/history';
|
||||
|
||||
export interface ChatMockProps {
|
||||
/**
|
||||
* Дополнительный css-класс
|
||||
*/
|
||||
className?: string;
|
||||
history: ChatItem[];
|
||||
}
|
||||
|
||||
export const ChatContent: ReactFCC<ChatMockProps> = memo(function ChatMock(props) {
|
||||
const { history } = props;
|
||||
|
||||
const isLarge = useMediaQueryResult(mediaQuery.desktopMediumUp);
|
||||
const messageTypeForSend = isLarge ? MessageType.left : MessageType.right;
|
||||
const messageTypeClassForSend = isLarge ? s.ChatPage__message_left : s.ChatPage__message_right;
|
||||
|
||||
return (
|
||||
<>
|
||||
{history.map((item, index) => (
|
||||
<Message
|
||||
className={clsx(s.ChatPage__message, {
|
||||
[messageTypeClassForSend]: item.type === ChatItemType.send,
|
||||
[s.ChatPage__message_left]: item.type === ChatItemType.receive,
|
||||
})}
|
||||
type={item.type === ChatItemType.send ? messageTypeForSend : MessageType.left}
|
||||
variant={item.type === ChatItemType.send ? MessageVariant.primary : MessageVariant.secondary}
|
||||
key={index}>
|
||||
{item.text}
|
||||
</Message>
|
||||
))}
|
||||
{/*{Array(5).fill(null).map((_, index) => (*/}
|
||||
{/* <Fragment key={index}>*/}
|
||||
{/* <Message*/}
|
||||
{/* className={clsx(s.ChatPage__message, s.ChatPage__message_left)}*/}
|
||||
{/* type={MessageType.left}*/}
|
||||
{/* variant={MessageVariant.secondary}>*/}
|
||||
{/* Какие метрики вы используете чтобы отслеживать прогресс развития проекта?*/}
|
||||
{/* </Message>*/}
|
||||
|
||||
{/* <Message*/}
|
||||
{/* className={clsx(s.ChatPage__message, messageTypeClassForSend)}*/}
|
||||
{/* type={messageTypeForSend}*/}
|
||||
{/* variant={MessageVariant.primary}>*/}
|
||||
{/* Возможными метриками для отслеживания прогресса могут быть: количество скачиваний и использования приложения/сервиса, количество активных пользователей, уровень удовлетворенности пользователей, объем продаж/дохода, показатели роста/расширения компании и др.*/}
|
||||
{/* </Message>*/}
|
||||
|
||||
{/* <Message*/}
|
||||
{/* className={clsx(s.ChatPage__message, s.ChatPage__message_left)}*/}
|
||||
{/* type={MessageType.left}*/}
|
||||
{/* variant={MessageVariant.secondary}>*/}
|
||||
{/* На чем вы зарабатываете? Сколько и за что вам платят клиенты*/}
|
||||
{/* </Message>*/}
|
||||
|
||||
{/* <Message*/}
|
||||
{/* className={clsx(s.ChatPage__message, messageTypeClassForSend)}*/}
|
||||
{/* type={messageTypeForSend}*/}
|
||||
{/* variant={MessageVariant.primary}>*/}
|
||||
{/* Проект может зарабатывать на платной подписке*/}
|
||||
{/* </Message>*/}
|
||||
|
||||
{/* <Message*/}
|
||||
{/* className={clsx(s.ChatPage__message, s.ChatPage__message_left)}*/}
|
||||
{/* type={MessageType.left}*/}
|
||||
{/* variant={MessageVariant.secondary}>*/}
|
||||
{/* Какие метрики вы используете чтобы отслеживать прогресс развития проекта?*/}
|
||||
{/* </Message>*/}
|
||||
|
||||
{/* <Message*/}
|
||||
{/* className={clsx(s.ChatPage__message, messageTypeClassForSend)}*/}
|
||||
{/* type={messageTypeForSend}*/}
|
||||
{/* variant={MessageVariant.primary}>*/}
|
||||
{/* Возможными метриками для отслеживания прогресса могут быть: количество скачиваний и использования приложения/сервиса, количество активных пользователей, уровень удовлетворенности пользователей, объем продаж/дохода, показатели роста/расширения компании и др.*/}
|
||||
{/* </Message>*/}
|
||||
{/* </Fragment>*/}
|
||||
{/*))}*/}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
54
src/pages/chat/components/ChatForm/ChatFormInittial.tsx
Normal file
54
src/pages/chat/components/ChatForm/ChatFormInittial.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import {ReactFCC} from '../../../../utils/ReactFCC';
|
||||
import s from './components/ChatFormText/ChatFormText.module.scss';
|
||||
import {Textarea} from '../../../../components/Textarea';
|
||||
import {KeyboardEvent} from 'react';
|
||||
import {isKey} from '../../../../utils/isKey';
|
||||
import {Key} from 'ts-key-enum';
|
||||
import {Button} from '../../../../components/Button';
|
||||
import {Form} from '../../../../components/Form';
|
||||
import {SubmitHandler} from 'react-hook-form';
|
||||
import {ReactComponent as RightIcon} from '../../../../assets/icons/right.svg';
|
||||
|
||||
export interface ChatFormInitialProps {
|
||||
/**
|
||||
* Дополнительный css-класс
|
||||
*/
|
||||
className?: string;
|
||||
onSubmit: SubmitHandler<any>;
|
||||
}
|
||||
|
||||
export const ChatFormInitial: ReactFCC<ChatFormInitialProps> = (props) => {
|
||||
const {onSubmit} = props;
|
||||
|
||||
return (
|
||||
<Form>
|
||||
{({ register, handleSubmit }) => {
|
||||
return (
|
||||
<div className={s.ChatFormText__richInput}>
|
||||
<Textarea
|
||||
className={s.ChatFormText__input}
|
||||
placeholder={'Введите сообщение'}
|
||||
rows={1}
|
||||
cols={33}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (isKey(e.nativeEvent, Key.Enter)) {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}
|
||||
}}
|
||||
registration={register('description', {
|
||||
required: true,
|
||||
max: 1000,
|
||||
})}
|
||||
/>
|
||||
|
||||
<Button className={s.ChatFormText__richInputButton} onClick={handleSubmit(onSubmit)}>
|
||||
<RightIcon className={s.ChatFormText__buttonIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user