Merge pull request 'front' (#5) from front into main

Reviewed-on: #5
This commit is contained in:
shadowik 2024-11-02 16:40:19 +04:00
commit 61a3002b54
310 changed files with 3740 additions and 9047 deletions

1
.gitignore vendored
View File

@ -17,7 +17,6 @@ eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/

1
front/.gitignore vendored
View File

@ -1 +1,2 @@
/node_modules
/build

7
front/.stylelintrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"plugins": ["stylelint-scss", "stylelint-order"],
"extends": [
"stylelint-config-idiomatic-order"
],
"rules": {}
}

Binary file not shown.

View File

@ -16,4 +16,13 @@ export default tseslint.config(
'simple-import-sort/exports': 'warn',
},
},
{
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ varsIgnorePattern: '_', argsIgnorePattern: '_' },
],
},
},
);

8774
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,28 @@
{
"name": "template",
"name": "app",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"build": "webpack --mode production",
"build-storybook": "storybook build",
"lint": "eslint src/**/*.{js,ts,tsx}",
"lint-fix": "npm run lint -- --fix",
"start": "webpack serve --mode development",
"storybook": "storybook dev -p 6006",
"style-lint": "npx stylelint '**/*.{css,scss}'",
"style-lint-fix": "npm run style-lint -- --fix",
"test": "jest"
"style-lint-fix": "npm run style-lint -- --fix"
},
"dependencies": {
"@svgr/webpack": "^8.1.0",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "6.26.0",
"react-router-dom": "^6.26.2",
"zustand": "5.0.0-rc.2"
},
"devDependencies": {
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@chromatic-com/storybook": "^1.6.1",
"@eslint/js": "^9.9.0",
"@storybook/addon-essentials": "^8.2.9",
"@storybook/addon-interactions": "^8.2.9",
"@storybook/addon-links": "^8.2.9",
"@storybook/addon-onboarding": "^8.2.9",
"@storybook/addon-webpack5-compiler-swc": "^1.0.5",
"@storybook/blocks": "^8.2.9",
"@storybook/react": "^8.2.9",
"@storybook/react-webpack5": "^8.2.9",
"@storybook/test": "^8.2.9",
"@svgr/webpack": "^8.1.0",
"@types/eslint__js": "^8.42.3",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
@ -46,15 +33,14 @@
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"html-webpack-plugin": "^5.5.0",
"lint-staged": "^15.2.9",
"prettier": "^3.3.3",
"sass": "^1.77.8",
"sass-loader": "^16.0.0",
"storybook": "^8.2.9",
"style-loader": "^4.0.0",
"stylelint": "^16.8.1",
"stylelint-config-standard-scss": "^13.1.0",
"ts-jest": "^29.2.4",
"stylelint": "^16.9.0",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-order": "^6.0.4",
"stylelint-scss": "^6.7.0",
"tsconfig-paths-webpack-plugin": "^4.1.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.1.0",

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 4" style="enable-background:new 0 0 6 4;" xml:space="preserve">
<path d="M3,3.5c-0.13,0-0.26-0.05-0.35-0.15l-2-2c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0L3,2.29l1.65-1.65
c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71l-2,2C3.26,3.45,3.13,3.5,3,3.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 4" style="enable-background:new 0 0 6 4;" xml:space="preserve">
<path d="M3.01,0.49c0.13,0,0.26,0.05,0.35,0.15l2,2c0.2,0.2,0.2,0.51,0,0.71c-0.2,0.2-0.51,0.2-0.71,0L3.01,1.7L1.36,3.35
c-0.2,0.2-0.51,0.2-0.71,0s-0.2-0.51,0-0.71l2-2C2.75,0.54,2.88,0.49,3.01,0.49z"/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<path d="M9.68,12H3.32C2.04,12,1,10.88,1,9.5v-5C1,3.12,2.04,2,3.32,2h6.36C10.96,2,12,3.12,12,4.5v5C12,10.88,10.96,12,9.68,12z
M3.32,3C2.59,3,2,3.67,2,4.5v5C2,10.33,2.59,11,3.32,11h6.36C10.41,11,11,10.33,11,9.5v-5C11,3.67,10.41,3,9.68,3H3.32z"/>
<rect x="1" y="5" width="11" height="1"/>
<path d="M4.5,4L4.5,4C4.22,4,4,3.78,4,3.5v-2C4,1.22,4.22,1,4.5,1h0C4.78,1,5,1.22,5,1.5v2C5,3.78,4.78,4,4.5,4z"/>
<path d="M8.5,4L8.5,4C8.22,4,8,3.78,8,3.5v-2C8,1.22,8.22,1,8.5,1h0C8.78,1,9,1.22,9,1.5v2C9,3.78,8.78,4,8.5,4z"/>
</svg>

After

Width:  |  Height:  |  Size: 877 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 4" style="enable-background:new 0 0 6 4;" xml:space="preserve">
<path d="M2.25,4C2.12,4,1.99,3.95,1.9,3.85l-1.5-1.5c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0l1.15,1.15L4.9,0.15
c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71l-3,3C2.51,3.95,2.38,4,2.25,4z"/>
</svg>

After

Width:  |  Height:  |  Size: 542 B

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<g>
<path d="M11.5,3H11H9V2.5C9,1.67,8.33,1,7.5,1h-2C4.67,1,4,1.67,4,2.5V3H2H1.5C1.22,3,1,3.22,1,3.5S1.22,4,1.5,4H2v5.5
C2,10.88,3.12,12,4.5,12h4c1.38,0,2.5-1.12,2.5-2.5V4h0.5C11.78,4,12,3.78,12,3.5S11.78,3,11.5,3z M5,2.5C5,2.22,5.22,2,5.5,2h2
C7.78,2,8,2.22,8,2.5V3H5V2.5z M10,9.5c0,0.83-0.67,1.5-1.5,1.5h-4C3.67,11,3,10.33,3,9.5V4h7V9.5z"/>
<path d="M5.25,9.5c0.28,0,0.5-0.22,0.5-0.5V6c0-0.28-0.22-0.5-0.5-0.5S4.75,5.72,4.75,6v3C4.75,9.28,4.97,9.5,5.25,9.5z"/>
<path d="M7.75,9.5c0.28,0,0.5-0.22,0.5-0.5V6c0-0.28-0.22-0.5-0.5-0.5S7.25,5.72,7.25,6v3C7.25,9.28,7.47,9.5,7.75,9.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 959 B

View File

@ -2,15 +2,17 @@
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<path d="M1.55,11.45L1.55,11.45c-0.2-0.2-0.2-0.51,0-0.71l9.19-9.19c0.2-0.2,0.51-0.2,0.71,0l0,0c0.2,0.2,0.2,0.51,0,0.71
l-9.19,9.19C2.06,11.65,1.75,11.65,1.55,11.45z"/>
<g>
<path d="M6.5,9C7.88,9,9,7.88,9,6.5c0-0.12-0.01-0.24-0.03-0.35L6.15,8.97C6.26,8.99,6.38,9,6.5,9z"/>
<path d="M5.12,8.58L5.12,8.58l0.73-0.73l0,0l1.99-1.99l0.73-0.73l2.09-2.09L11.8,1.9L11.1,1.2L9.85,2.44
C8.86,1.85,7.71,1.5,6.5,1.5c-2.65,0-5,1.58-6.01,4.04c-0.25,0.61-0.25,1.31,0,1.92c0.41,1,1.05,1.84,1.84,2.51L1.2,11.1L1.9,11.8
l2.6-2.6L5.12,8.58z M5.15,7.14C5.06,6.95,5,6.73,5,6.5C5,5.67,5.67,5,6.5,5c0.23,0,0.45,0.06,0.64,0.15L5.15,7.14z M1.42,7.08
c-0.15-0.37-0.15-0.8,0-1.17C2.27,3.84,4.26,2.5,6.5,2.5c0.94,0,1.83,0.25,2.62,0.68L7.88,4.42C7.48,4.15,7.01,4,6.5,4
C5.12,4,4,5.12,4,6.5c0,0.51,0.15,0.98,0.42,1.38L3.04,9.26C2.34,8.69,1.77,7.96,1.42,7.08z"/>
<path d="M12.51,5.54c-0.28-0.68-0.67-1.27-1.13-1.8l-0.71,0.71c0.37,0.43,0.69,0.92,0.91,1.47c0.15,0.37,0.15,0.79,0,1.17
C10.73,9.16,8.74,10.5,6.5,10.5c-0.56,0-1.1-0.1-1.62-0.26L4.1,11.02c0.75,0.3,1.56,0.48,2.4,0.48c2.65,0,5.01-1.58,6.01-4.04
<path d="M12.51,5.54c-0.28-0.67-0.66-1.28-1.12-1.8l-0.71,0.71c0.37,0.44,0.68,0.93,0.9,1.48c0.15,0.37,0.15,0.79,0,1.17
C10.73,9.16,8.74,10.5,6.5,10.5c-0.56,0-1.11-0.09-1.63-0.25L4.1,11.02c0.75,0.3,1.56,0.48,2.4,0.48c2.65,0,5.01-1.58,6.01-4.04
C12.76,6.85,12.76,6.15,12.51,5.54z"/>
<path d="M1.42,7.08c-0.15-0.37-0.15-0.8,0-1.17C2.27,3.84,4.26,2.5,6.5,2.5c1.3,0,2.5,0.46,3.46,1.24l0.71-0.71
C9.53,2.07,8.07,1.5,6.5,1.5c-2.65,0-5,1.58-6.01,4.04c-0.25,0.61-0.25,1.31,0,1.92c0.54,1.33,1.49,2.39,2.66,3.09l0.73-0.73
C2.8,9.23,1.91,8.29,1.42,7.08z"/>
<path d="M6.5,9C7.88,9,9,7.88,9,6.5c0-0.12-0.02-0.23-0.03-0.34L6.16,8.97C6.27,8.98,6.38,9,6.5,9z"/>
<path d="M6.5,4C5.12,4,4,5.12,4,6.5c0,0.87,0.45,1.64,1.12,2.08l0.73-0.73C5.35,7.61,5,7.1,5,6.5C5,5.67,5.67,5,6.5,5
c0.6,0,1.11,0.35,1.35,0.86l0.73-0.73C8.14,4.45,7.37,4,6.5,4z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<path d="M9.5,6H7V3.5C7,3.22,6.78,3,6.5,3S6,3.22,6,3.5V6H3.5C3.22,6,3,6.22,3,6.5S3.22,7,3.5,7H6v2.5C6,9.78,6.22,10,6.5,10
S7,9.78,7,9.5V7h2.5C9.78,7,10,6.78,10,6.5S9.78,6,9.5,6z"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<circle cx="3" cy="3" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

View File

@ -0,0 +1,2 @@
// export const BASE_URL = 'http://localhost:8000/api';
export const BASE_URL = 'http://192.168.1.110:8000/api';

View File

@ -0,0 +1 @@
export { downloadImage, getWindmillData } from './service';

View File

@ -0,0 +1,26 @@
import { WindmillFormStore } from '@components/ux/windmill-form';
import { BASE_URL } from './constants';
import { GetWindmillDataRes } from './types';
import { getWindmillDataParams } from './utils';
export const getWindmillData = async (store: Partial<WindmillFormStore>) => {
const params = getWindmillDataParams(store);
const url = `${BASE_URL}/floris/get_windmill_data?${params}`;
const init: RequestInit = {
method: 'GET',
};
const res: Response = await fetch(url, init);
const data: GetWindmillDataRes = await res.json();
return data;
};
export const downloadImage = async (imageName: string) => {
const url = `${BASE_URL}/floris/download_image/${imageName}`;
const init: RequestInit = {
method: 'GET',
};
const res: Response = await fetch(url, init);
const data = await res.blob();
return data;
};

View File

@ -0,0 +1,4 @@
export type GetWindmillDataRes = {
file_name: string;
data: number[];
};

View File

@ -0,0 +1,9 @@
import { WindmillFormStore } from '@components/ux/windmill-form';
export const getWindmillDataParams = (store: Partial<WindmillFormStore>) => {
const layoutX = store.windmills?.map((row) => `layout_x=${row.x}`).join('&');
const layoutY = store.windmills?.map((row) => `layout_y=${row.y}`).join('&');
const dateStart = `date_start=${store.dateFrom?.substring(0, 10)}`;
const dateEnd = `date_end=${store.dateTo?.substring(0, 10)}`;
return `${layoutX}&${layoutY}&${dateStart}&${dateEnd}`;
};

1
front/src/api/index.tsx Normal file
View File

@ -0,0 +1 @@
export * from './floris';

View File

View File

@ -1,37 +1,63 @@
@mixin light {
color-scheme: light;
--clr-primary: #363a4e;
--clr-primary: #4176FF;
--clr-primary-o50: #3865DA80;
--clr-primary-hover: #638FFF;
--clr-primary-active: #3D68D7;
--clr-on-primary: #FFFFFF;
--clr-secondary: #bca59f;
--clr-secondary: #EAEAEA;
--clr-secondary-hover: #EFEFEF;
--clr-secondary-active: #E1E1E1;
--clr-on-secondary: #0D0D0D;
--clr-accent: #80845c;
--clr-accent-o50: #80845c80;
--clr-layer-100: #EAECF1;
--clr-layer-200: #DFE1E6;
--clr-layer-100: #EBEEF0;
--clr-layer-200: #FFFFFF;
--clr-layer-300: #FFFFFF;
--clr-layer-300-hover: #EAEAEA;
--clr-text-100: #0f1015;
--clr-text-100: #8D8D8D;
--clr-text-200: #6C7480;
--clr-text-300: #1D1F20;
--clr-shadow: #363a4e1A;
--clr-border-100: #DFDFDF;
--clr-border-200: #D8D8D8;
--clr-shadow-100: #0000001A;
--clr-shadow-200: #00000026;
--clr-ripple: #1D1F2026;
}
@mixin dark {
color-scheme: dark;
--clr-primary: #b1b5c9;
--clr-primary: #3865DA;
--clr-primary-o50: #3865DA80;
--clr-primary-hover: #4073F7;
--clr-primary-active: #2A4DA7;
--clr-on-primary: #FFFFFF;
--clr-secondary: #604943;
--clr-secondary: #3F3F3F;
--clr-secondary-hover: #4D4D4D;
--clr-secondary-active: #323232;
--clr-on-secondary: #FFFFFF;
--clr-accent: #9fa37b;
--clr-accent-o50: #9fa37b80;
--clr-layer-100: #1B1B1B;
--clr-layer-200: #232323;
--clr-layer-300: #2F2F2F;
--clr-layer-300-hover: #3E3E3E;
--clr-layer-100: #0E1015;
--clr-layer-200: #191B20;
--clr-layer-300: #2E3139;
--clr-text-100: #888888;
--clr-text-200: #C5C5C5;
--clr-text-300: #F0F0F0;
--clr-text-100: #eaebf0;
--clr-border-100: #3D3D3D;
--clr-border-200: #545454;
--clr-shadow: transparent;
--clr-shadow-100: #0000001A;
--clr-shadow-200: #00000026;
--clr-ripple: #F0F0F026;
}

View File

@ -2,7 +2,7 @@ import './styles.scss';
import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts';
import { About, Home } from '@components/pages';
import { HomePage } from '@components/pages';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
@ -11,8 +11,7 @@ function App() {
<BrowserRouter>
<Routes>
<Route element={<MainLayout />}>
<Route path={'/'} element={<Home />} />
<Route path={'/about'} element={<About />} />
<Route path={'/'} element={<HomePage />} />
</Route>
</Routes>
</BrowserRouter>

View File

@ -1,20 +0,0 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body, h1, h2, h3, h4, p,
figure, blockquote, dl, dd {
margin: 0;
}
img,
svg,
picture {
display: block;
}
input {
font: inherit;
}

View File

@ -1,6 +1,12 @@
@use './reset';
@use './theme' as theme;
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html[data-theme='light'] {
@include theme.light;
}
@ -16,11 +22,23 @@ html[data-theme='default'] {
}
}
html {
--td-100: 0.2s;
}
body {
font-family: Rubik, sans-serif;
overflow: hidden;
margin: 0;
background-color: var(--clr-layer-100);
font-family: Rubik, sans-serif;
}
#root {
height: 100dvh;
}
img,
svg,
picture {
display: block;
}

View File

@ -8,7 +8,7 @@ function MainLayout() {
return (
<div className={styles.mainLayout}>
<Header />
<main>
<main className={styles.main}>
<Outlet />
</main>
</div>

View File

@ -6,3 +6,8 @@
'main' minmax(0, 1fr)
/ minmax(0, 1fr);
}
.main {
overflow: auto;
height: 100%;
}

View File

@ -1,15 +0,0 @@
import { Button } from '@components/ui';
import React from 'react';
import styles from './styles.module.scss';
function About() {
return (
<div className={styles.about}>
<p>About Page</p>
<Button variant="secondary">Button</Button>
</div>
);
}
export default About;

View File

@ -1,3 +0,0 @@
.about {
padding: 10px;
}

View File

@ -0,0 +1,41 @@
import { Heading, Paragraph } from '@components/ui';
import { WindmillForm } from '@components/ux';
import { WindmillFormResponse } from '@components/ux/windmill-form';
import React, { useState } from 'react';
import styles from './styles.module.scss';
export function HomePage() {
const [result, setResult] = useState<WindmillFormResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFormSuccess = (response: WindmillFormResponse) => {
setResult(response);
setError(null);
};
const handleFormFail = (message: string) => {
setError(message);
setResult(null);
};
return (
<div className={styles.page}>
<div className={styles.wrapperForm}>
<WindmillForm onSuccess={handleFormSuccess} onFail={handleFormFail} />
</div>
<div className={styles.result}>
<Heading tag="h3">Result</Heading>
{result && (
<>
<div className={styles.power}>{result.power.join(' ')}</div>
<div className={styles.image}>
{result.image && <img src={result.image} alt="Image" />}
</div>
</>
)}
{error && <Paragraph>{error}</Paragraph>}
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { HomePage } from './component';

View File

@ -0,0 +1,41 @@
.page {
display: grid;
padding: 20px;
gap: 20px;
grid-template:
'. form result .' auto
/ auto minmax(0, 380px) minmax(0, 700px) auto;
}
.wrapperForm {
grid-area: form;
}
.result {
display: flex;
flex-direction: column;
padding: 20px;
border-radius: 10px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
grid-area: result;
}
.image {
width: 100%;
img {
max-width: 100%;
border-radius: 10px;
}
}
@media (width <= 1000px) {
.page {
grid-template:
'form' auto
'result' auto
/ 1fr;
}
}

View File

@ -1,30 +0,0 @@
import { Button, PasswordTextField, TextField } from '@components/ui';
import React from 'react';
import styles from './styles.module.scss';
function Home() {
return (
<div className={styles.home}>
<div className={styles.box}>
<Button>Button</Button>
<Button variant="secondary">Button</Button>
</div>
<div className={styles.box}>
<Button scale="s">Button</Button>
<Button>Button</Button>
<Button scale="l">Button</Button>
</div>
<div className={styles.box}>
<TextField scale="m" />
<PasswordTextField scale="m" />
</div>
<div className={styles.box}>
<TextField scale="l" />
<PasswordTextField scale="l" />
</div>
</div>
);
}
export default Home;

View File

@ -1,12 +0,0 @@
.home {
padding: 10px;
display: flex;
gap: 10px;
flex-direction: column;
}
.box {
display: flex;
gap: 10px;
align-items: center
}

View File

@ -1,4 +1 @@
import About from './about';
import Home from './home/index';
export { About, Home };
export { HomePage } from './home-page';

View File

@ -0,0 +1,58 @@
import clsx from 'clsx';
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
import styles from './styles.module.scss';
import { FadeProps } from './types';
export function FadeInner(
{
visible,
duration = 200,
className,
style,
...props
}: Omit<FadeProps, 'ref'>,
ref: ForwardedRef<HTMLDivElement>,
) {
const [visibleInner, setVisibleInner] = useState<boolean>(visible);
const classNames = clsx(
styles.fade,
{ [styles.invisible]: !visible },
className,
);
const inlineStyle = {
...style,
'--animation-duration': `${duration}ms`,
} as React.CSSProperties;
useEffect(() => {
if (visible) {
setVisibleInner(true);
return;
}
}, [visible]);
const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadeout) {
setVisibleInner(false);
}
};
if (!visibleInner) {
return null;
}
return (
<div
className={classNames}
ref={ref}
style={inlineStyle}
onAnimationEnd={handleAnimationEnd}
{...props}
/>
);
}
export const Fade = forwardRef(FadeInner);

View File

@ -0,0 +1,2 @@
export { Fade } from './component';
export { type FadeProps } from './types';

View File

@ -0,0 +1,29 @@
.fade {
animation: fadein var(--animation-duration);
}
.invisible {
animation: fadeout var(--animation-duration) forwards ease-in-out;
}
@keyframes fadein {
from {
opacity: 0;
transform: scale(0.9) translateY(-30px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes fadeout {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.9) translateY(-30px);
}
}

View File

@ -0,0 +1,4 @@
export type FadeProps = {
visible: boolean;
duration?: number;
} & React.ComponentProps<'div'>;

View File

@ -0,0 +1,2 @@
export * from './fade';
export * from './ripple';

View File

@ -0,0 +1,68 @@
import React, {
ForwardedRef,
forwardRef,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { RippleWave } from './parts/ripple-wave';
import styles from './styles.module.scss';
import { RippleProps } from './types';
import { calcRippleWaveStyle } from './utils';
export function RippleInner(
props: RippleProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const rippleRef = useRef<HTMLDivElement | null>(null);
const [waves, setWaves] = useState<React.JSX.Element[]>([]);
const [isTouch, setIsTouch] = useState(false);
useImperativeHandle(ref, () => rippleRef.current, []);
const handleWaveOnDone = () => {
setWaves((prev) => prev.slice(1));
};
const addWave = (x: number, y: number) => {
const style = calcRippleWaveStyle(x, y, rippleRef.current);
const wave = (
<RippleWave
key={new Date().getTime()}
style={style}
onDone={handleWaveOnDone}
/>
);
setWaves([...waves, wave]);
};
const handleMouseDown = (event: React.MouseEvent) => {
if (isTouch) {
return;
}
const { pageX, pageY } = event;
addWave(pageX, pageY);
};
const handleTouchStart = (event: React.TouchEvent) => {
setIsTouch(true);
const { touches, changedTouches } = event;
const { pageX, pageY } = touches[0] ?? changedTouches[0];
addWave(pageX, pageY);
};
return (
<div
ref={rippleRef}
className={styles.ripple}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
{...props}
>
{waves}
</div>
);
}
export const Ripple = forwardRef(RippleInner);

View File

@ -0,0 +1 @@
export { Ripple } from './component';

View File

@ -0,0 +1,39 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import styles from './style.module.scss';
import { RippleWaveProps } from './types';
export function RippleWave({ style, onDone }: RippleWaveProps) {
const [isMouseUp, setIsMouseUp] = useState(false);
const [isAnimationEnd, setIsAnimationEnd] = useState(false);
useEffect(() => {
const mouseUpListener = () => setIsMouseUp(true);
document.addEventListener('mouseup', mouseUpListener, { once: true });
document.addEventListener('touchend', mouseUpListener, { once: true });
}, []);
const visible = !isMouseUp || !isAnimationEnd;
const className = clsx(
styles.wave,
visible ? styles.visible : styles.invisible,
);
const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadein) {
setIsAnimationEnd(true);
} else {
onDone();
}
};
return (
<div
className={className}
onAnimationEnd={handleAnimationEnd}
style={style}
/>
);
}

View File

@ -0,0 +1 @@
export { RippleWave } from './component';

View File

@ -0,0 +1,33 @@
.wave {
position: absolute;
border-radius: 100%;
background-color: var(--clr-ripple);
}
.visible {
animation: fadein 0.3s linear;
}
.invisible {
animation: fadeout 0.3s linear forwards;
}
@keyframes fadein {
from {
opacity: 0;
scale: 0;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@ -0,0 +1,6 @@
import { CSSProperties } from 'react';
export type RippleWaveProps = {
style: CSSProperties;
onDone: () => void;
};

View File

@ -0,0 +1,7 @@
.ripple {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
}

View File

@ -0,0 +1 @@
export type RippleProps = {} & React.ComponentProps<'div'>;

View File

@ -0,0 +1,14 @@
import { CSSProperties } from 'react';
export const calcRippleWaveStyle = (
x: number,
y: number,
ripple: HTMLDivElement,
): CSSProperties => {
const wrapperRect = ripple.getBoundingClientRect();
const diameter = Math.max(wrapperRect.width, wrapperRect.height);
const radius = diameter / 2;
const left = x - wrapperRect.left - radius;
const top = y - wrapperRect.top - radius;
return { left, top, width: diameter, height: diameter };
};

View File

@ -0,0 +1,42 @@
import clsx from 'clsx';
import React from 'react';
import { Ripple } from '../animation';
import { Comet } from '../comet';
import { RawButton } from '../raw';
import { COMET_VARIANT_MAP } from './constants';
import styles from './styles.module.scss';
import { ButtonProps } from './types.js';
export function Button({
variant = 'primary',
scale = 'm',
pending = false,
className,
children,
disabled,
...props
}: ButtonProps) {
const classNames = clsx(
styles.button,
styles[variant],
styles[scale],
{ [styles.pending]: pending },
className,
);
return (
<RawButton
className={classNames}
disabled={pending ? true : disabled}
{...props}
>
{pending && (
<div className={styles.cometWrapper}>
<Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} />
</div>
)}
<div className={styles.childrenWrapper}>{children}</div>
<Ripple />
</RawButton>
);
}

View File

@ -0,0 +1,10 @@
import { CometProps } from '../comet';
import { ButtonProps } from './types';
export const COMET_VARIANT_MAP: Record<
ButtonProps['variant'],
CometProps['variant']
> = {
primary: 'onPrimary',
secondary: 'onSecondary',
};

View File

@ -0,0 +1,3 @@
export { Button } from './component';
export { ButtonPreview } from './preview';
export { type ButtonProps } from './types';

View File

@ -1,23 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { RawButton } from '../raw';
import styles from './styles.module.scss';
import { ButtonProps } from './types.js';
function Button({
variant = 'primary',
scale = 'm',
className,
...props
}: ButtonProps) {
const classes = clsx(
styles.button,
styles[variant],
styles[scale],
className,
);
return <RawButton className={classes} {...props} />;
}
export default Button;

View File

@ -0,0 +1,49 @@
import { PreviewArticle, PreviewBox } from '@components/ui/preview';
import React from 'react';
import { Button } from './component';
export function ButtonPreview() {
return (
<PreviewArticle title="Button">
<PreviewBox>
<Button scale="s">Button</Button>
<Button scale="m">Button</Button>
<Button scale="l">Button</Button>
</PreviewBox>
<PreviewBox>
<Button scale="s" pending>
Button
</Button>
<Button scale="m" pending>
Button
</Button>
<Button scale="l" pending>
Button
</Button>
</PreviewBox>
<PreviewBox>
<Button scale="s" variant="secondary">
Button
</Button>
<Button scale="m" variant="secondary">
Button
</Button>
<Button scale="l" variant="secondary">
Button
</Button>
</PreviewBox>
<PreviewBox>
<Button scale="s" variant="secondary" pending>
Button
</Button>
<Button scale="m" variant="secondary" pending>
Button
</Button>
<Button scale="l" variant="secondary" pending>
Button
</Button>
</PreviewBox>
</PreviewArticle>
);
}

View File

@ -1,39 +1,76 @@
.button {
border-radius: 5px;
cursor: pointer;
position: relative;
overflow: hidden;
box-shadow: 0px 2px 2px var(--clr-shadow-200);
font-weight: 500;
transition: all 0.1s ease-in-out;
transition: all var(--td-100) ease-in-out;
&:hover {
filter: brightness(0.9);
&:disabled {
pointer-events: none;
}
&:active {
filter: brightness(0.8);
&:not(:disabled) {
cursor: pointer;
}
}
.cometWrapper {
position: absolute;
top: 0;
left: 0;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
.pending {
.childrenWrapper {
visibility: hidden;
}
}
.primary {
background-color: var(--clr-primary);
color: var(--clr-layer-100);
color: var(--clr-on-primary);
&:hover {
background-color: var(--clr-primary-hover);
}
&.pending {
background-color: var(--clr-primary-active);
}
}
.secondary {
background-color: var(--clr-secondary);
color: var(--clr-text-100);
color: var(--clr-on-secondary);
&:hover {
background-color: var(--clr-secondary-hover);
}
&.pending {
background-color: var(--clr-secondary-active);
}
}
.s {
padding: 8px 16px;
padding: 10px 16px;
border-radius: 8px;
font-size: 12px;
}
.m {
padding: 10px 20px;
padding: 14px 20px;
border-radius: 10px;
font-size: 16px;
}
.l {
padding: 12px 24px;
padding: 18px 24px;
border-radius: 12px;
font-size: 20px;
}

View File

@ -1,6 +1,9 @@
import { RawButtonProps } from '../raw/raw-button/types';
import { Scale } from '@components/ui/types';
import { RawButtonProps } from '../raw';
export type ButtonProps = {
variant?: 'primary' | 'secondary';
scale?: 's' | 'm' | 'l';
scale?: Scale;
pending?: boolean;
} & RawButtonProps;

View File

@ -0,0 +1,36 @@
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
import { CalendarDays } from './parts';
import { CalendarProps } from './types';
function CalendarInner(
{ value, onChange, min, max, ...props }: Omit<CalendarProps, 'ref'>,
ref: ForwardedRef<HTMLDivElement>,
) {
const [date, setDate] = useState<Date>(value ? new Date(value) : new Date());
useEffect(() => {
setDate(value ? new Date(value) : new Date());
}, [value]);
const handleMonthChange = (delta: number) => {
const newDate = new Date(date);
newDate.setMonth(newDate.getMonth() + delta);
setDate(newDate);
};
return (
<div {...props} ref={ref}>
<CalendarDays
value={value}
onChange={onChange}
min={min}
max={max}
date={date}
onMonthChange={handleMonthChange}
/>
</div>
);
}
export const Calendar = forwardRef(CalendarInner);

View File

@ -0,0 +1,2 @@
export { Calendar } from './component';
export { CalendarPreview } from './preview';

View File

@ -0,0 +1,81 @@
import { IconButton } from '@components/ui/icon-button';
import { RawButton } from '@components/ui/raw';
import { Span } from '@components/ui/span';
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
import ArrowUpIcon from '@public/images/svg/arrow-up.svg';
import clsx from 'clsx';
import React, { useMemo } from 'react';
import { DAYS_OF_THE_WEEK, MONTHS } from './constants';
import styles from './styles.module.scss';
import { CalendarDaysProps } from './types';
import { getCalendarDays } from './utils';
export function CalendarDays({
value,
onChange,
min,
max,
date,
onMonthChange,
}: CalendarDaysProps) {
const today = useMemo(() => new Date(), []);
const days = useMemo(() => {
return getCalendarDays({
year: date.getFullYear(),
monthIndex: date.getMonth(),
today,
selectedDateStr: value,
min,
max,
});
}, [date, min, max]);
const handleChange = (newValue: string) => {
onChange?.(newValue);
};
return (
<div>
<header className={styles.header}>
<Span color="t300" className={styles.title}>
{MONTHS[date.getMonth()]} {date.getFullYear()}
</Span>
<IconButton
className={styles.turnButton}
onClick={() => onMonthChange(-1)}
>
<ArrowUpIcon />
</IconButton>
<IconButton
className={styles.turnButton}
onClick={() => onMonthChange(1)}
>
<ArrowDownIcon />
</IconButton>
</header>
<div className={styles.daysGrid}>
{DAYS_OF_THE_WEEK.map((day) => (
<Span className={styles.dayOfTheWeek} scale="none" key={day}>
{day}
</Span>
))}
{days.map((day, index) => (
<RawButton
key={index}
disabled={day.isDisabled}
className={clsx(styles.day, {
[styles.selectedDay]: day.isSelected,
[styles.currentMonthDay]: day.isCurrentMonth,
})}
onClick={() => handleChange(day.string)}
>
{day.number}
{day.isToday && <div className={styles.todayIndicator} />}
</RawButton>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
export const DAYS_OF_THE_WEEK = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
export const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];

View File

@ -0,0 +1,2 @@
export { CalendarDays } from './component';
export { type CalendarDaysProps } from './types';

View File

@ -0,0 +1,85 @@
.header {
display: flex;
align-items: center;
margin-bottom: 10px;
gap: 10px;
}
.title {
flex: 1;
padding-left: 5px;
font-size: 18px;
font-weight: 500;
}
.turnButton {
padding: 10px;
}
.daysGrid {
display: grid;
gap: 5px;
grid-template-columns: repeat(7, auto);
}
.dayOfTheWeek {
display: flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
font-size: 14px;
}
.day {
position: relative;
display: flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 10px;
color: var(--clr-text-100);
transition: all var(--td-100) ease-in-out;
&:not(:disabled) {
cursor: pointer;
&:hover {
background-color: var(--clr-layer-300-hover);
}
}
&:disabled {
color: var(--clr-text-100);
}
}
.currentMonthDay {
color: var(--clr-text-300);
}
.selectedDay {
background-color: var(--clr-primary);
box-shadow: 0px 2px 2px var(--clr-shadow-200);
color: var(--clr-on-primary);
&:hover {
background-color: var(--clr-primary-hover);
}
.todayIndicator {
background-color: var(--clr-on-primary);
}
}
.todayIndicator {
position: absolute;
bottom: 12%;
left: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--clr-text-300);
transform: translateX(-50%);
}

View File

@ -0,0 +1,26 @@
export type CalendarDay = {
number: number;
isDisabled: boolean;
isSelected: boolean;
isToday: boolean;
string: string;
isCurrentMonth: boolean;
};
export type CalendarDaysProps = {
value?: string;
onChange?: (value: string) => void;
min: Date | null;
max: Date | null;
date: Date;
onMonthChange: (delta: number) => void;
};
export type GetCalendarDaysParams = {
year: number;
monthIndex: number;
today: Date;
selectedDateStr?: string;
min: Date | null;
max: Date | null;
};

View File

@ -0,0 +1,52 @@
import { dateToInputString } from '@utils/date';
import { CalendarDay, GetCalendarDaysParams } from './types';
const addDays = (date: Date, days: number) => {
date.setDate(date.getDate() + days);
};
const daysAreEqual = (date1: Date, date2: Date) => {
return (
date1.getDate() === date2.getDate() &&
date1.getMonth() === date2.getMonth() &&
date1.getFullYear() === date2.getFullYear()
);
};
export const getCalendarDays = ({
year,
monthIndex,
today,
selectedDateStr,
min,
max,
}: GetCalendarDaysParams) => {
const selectedDate = new Date(selectedDateStr);
const firstDayOfMonth = new Date(year, monthIndex, 1);
const daysFromPrevMonth = (firstDayOfMonth.getDay() || 7) - 1;
const date = new Date(year, monthIndex, 1);
addDays(date, -daysFromPrevMonth);
const days: CalendarDay[] = [];
for (let i = 0; i < 42; i += 1) {
const number = date.getDate();
const isDisabled = (min && date < min) || (max && date > max);
const isSelected = daysAreEqual(date, selectedDate);
const isToday = daysAreEqual(date, today);
const string = dateToInputString(date);
const isCurrentMonth = date.getMonth() === monthIndex;
days.push({
number,
isDisabled,
isSelected,
isToday,
string,
isCurrentMonth,
});
addDays(date, 1);
}
return days;
};

View File

@ -0,0 +1 @@
export * from './calendar-days';

View File

@ -0,0 +1,19 @@
import { PreviewArticle } from '@components/ui/preview';
import React, { useState } from 'react';
import { Calendar } from './component';
export function CalendarPreview() {
const [date, setDate] = useState<string>('2024-10-09T01:07');
return (
<PreviewArticle title="Calendar">
<Calendar
value={date}
onChange={setDate}
min="2024-10-02T00:00"
max="2024-10-15T00:00"
/>
</PreviewArticle>
);
}

View File

@ -0,0 +1,6 @@
export type CalendarProps = {
value?: string;
onChange?: (value: string) => void;
min: Date | null;
max: Date | null;
} & Omit<React.ComponentProps<'div'>, 'onChange'>;

View File

@ -0,0 +1,42 @@
import clsx from 'clsx';
import React from 'react';
import { Checkbox } from '../checkbox';
import { Span } from '../span';
import styles from './styles.module.scss';
import { CheckboxGroupProps } from './types';
export function CheckboxGroup<T>({
name,
value,
items,
onChange,
getItemKey,
getItemLabel,
scale = 'm',
label,
}: CheckboxGroupProps<T>) {
const classNames = clsx(styles.checkBoxGroup, styles[scale]);
const handleChange = (index: number) => {
onChange(value.with(index, !value[index]));
};
return (
<div className={classNames}>
<Span color="t300" scale={scale} className={styles.label}>
{label}
</Span>
{items.map((item, index) => (
<Checkbox
name={name}
label={{ text: getItemLabel(item) }}
checked={value[index]}
key={getItemKey(item)}
onChange={() => handleChange(index)}
scale={scale}
/>
))}
</div>
);
}

View File

@ -0,0 +1,2 @@
export { CheckboxGroup } from './component';
export { CheckboxGroupPreview } from './preview';

View File

@ -0,0 +1,46 @@
import { PreviewArticle } from '@components/ui/preview';
import React, { useState } from 'react';
import { CheckboxGroup } from './component';
export function CheckboxGroupPreview() {
const [value1, setValue1] = useState<boolean[]>([true, true, false]);
const [value2, setValue2] = useState<boolean[]>([true, true, false]);
const [value3, setValue3] = useState<boolean[]>([true, true, false]);
const items = ['Pop', 'Rap', 'Rock'];
return (
<PreviewArticle title="CheckboxGroup">
<CheckboxGroup
name="animal-s"
value={value1}
items={items}
onChange={setValue1}
getItemKey={(i) => i}
getItemLabel={(i) => i}
label="What genre of music do you like?"
scale="s"
/>
<CheckboxGroup
name="animal-m"
value={value2}
items={items}
onChange={setValue2}
getItemKey={(i) => i}
getItemLabel={(i) => i}
label="What genre of music do you like?"
scale="m"
/>
<CheckboxGroup
name="animal-l"
value={value3}
items={items}
onChange={setValue3}
getItemKey={(i) => i}
getItemLabel={(i) => i}
label="What genre of music do you like?"
scale="l"
/>
</PreviewArticle>
);
}

View File

@ -0,0 +1,22 @@
.checkBoxGroup {
display: flex;
flex-direction: column;
}
.s {
.label {
margin-bottom: 3px;
}
}
.m {
.label {
margin-bottom: 5px;
}
}
.l {
.label {
margin-bottom: 7px;
}
}

View File

@ -0,0 +1,12 @@
import { Scale } from '../types';
export type CheckboxGroupProps<T> = {
name: string;
value: boolean[];
items: T[];
onChange: (value: boolean[]) => void;
getItemKey: (item: T) => React.Key;
getItemLabel: (item: T) => string;
scale?: Scale;
label?: string;
};

View File

@ -0,0 +1,43 @@
import CheckIcon from '@public/images/svg/check.svg';
import clsx from 'clsx';
import React, { ForwardedRef, forwardRef } from 'react';
import { Ripple } from '../animation';
import { Label, LabelProps } from '../label';
import { RawInput } from '../raw';
import styles from './styles.module.scss';
import { CheckboxProps } from './types';
function CheckboxInner(
{ scale = 'm', label = {}, required, ...props }: Omit<CheckboxProps, 'ref'>,
ref: ForwardedRef<HTMLInputElement>,
) {
const wrapperClassName = clsx(styles.wrapper, styles[scale]);
const labelProps: LabelProps = {
position: 'right',
scale: scale,
...label,
required: { value: required, ...label.required },
};
return (
<Label {...labelProps}>
<div className={wrapperClassName}>
<RawInput
className={styles.input}
type="checkbox"
ref={ref}
required={required}
{...props}
/>
<div className={styles.checkbox}>
<CheckIcon className={styles.icon} />
</div>
<Ripple />
</div>
</Label>
);
}
export const Checkbox = forwardRef(CheckboxInner);

View File

@ -0,0 +1,2 @@
export { Checkbox } from './component';
export { type CheckboxProps } from './types';

View File

@ -0,0 +1,88 @@
.wrapper {
position: relative;
overflow: hidden;
border-radius: 100%;
cursor: pointer;
&:hover {
.checkbox {
background-color: var(--clr-layer-300-hover);
}
}
}
.input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
&:checked {
& ~ .checkbox {
border-width: 0;
background-color: var(--clr-primary);
.icon {
width: 100%;
fill: white;
}
}
&:hover {
& ~ .checkbox {
background-color: var(--clr-primary-hover);
}
}
}
}
.checkbox {
display: flex;
justify-content: center;
border: 1px solid var(--clr-border-200);
background-color: var(--clr-layer-300);
box-shadow: 0px 2px 2px var(--clr-shadow-200);
transition: all var(--td-100) ease-in-out;
}
.icon {
width: 0%;
fill: var(--clr-text-200);
transition: all var(--td-100) ease-in-out;
}
.s {
padding: 3px;
.checkbox {
width: 16px;
height: 16px;
padding: 2px;
border-radius: 5px;
}
}
.m {
padding: 5px;
.checkbox {
width: 20px;
height: 20px;
padding: 3px;
border-radius: 6px;
}
}
.l {
padding: 7px;
.checkbox {
width: 24px;
height: 24px;
padding: 4px;
border-radius: 7px;
}
}

View File

@ -0,0 +1,8 @@
import { LabelProps } from '../label';
import { RawInputProps } from '../raw';
import { Scale } from '../types';
export type CheckboxProps = {
scale?: Scale;
label?: LabelProps;
} & Omit<RawInputProps, 'type'>;

View File

@ -0,0 +1,19 @@
import clsx from 'clsx';
import React from 'react';
import styles from './styles.module.scss';
import { CometProps } from './types';
export function Comet({
scale = 'm',
variant = 'onPrimary',
className,
}: CometProps) {
const classNames = clsx(
styles.comet,
styles[scale],
styles[variant],
className,
);
return <div className={classNames}></div>;
}

View File

@ -0,0 +1,2 @@
export { Comet } from './component';
export { type CometProps } from './types';

View File

@ -0,0 +1,36 @@
.comet {
border-radius: 50%;
animation: spinner-comet 1s infinite linear;
}
@keyframes spinner-comet {
to {
transform: rotate(1turn);
}
}
.s {
width: 12px;
height: 12px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 2px), #000 0);
}
.m {
width: 16px;
height: 16px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 2.5px), #000 0);
}
.l {
width: 20px;
height: 20px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
}
.onPrimary {
background: conic-gradient(#0000 10%, var(--clr-on-primary));
}
.onSecondary {
background: conic-gradient(#0000 10%, var(--clr-on-secondary));
}

View File

@ -0,0 +1,6 @@
import { Scale } from '../types';
export type CometProps = {
scale?: Scale;
variant?: 'onPrimary' | 'onSecondary';
} & React.ComponentProps<'div'>;

View File

@ -1,23 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import styles from './styles.module.scss';
import { ControlLabelProps } from './types';
export default function ControlLabel({
text,
position = 'top',
className,
children,
...props
}: ControlLabelProps) {
const classes = clsx(styles.label, styles[position], className);
const reversed = position === 'right' || position === 'bottom';
return (
<label className={classes} {...props}>
{reversed && children}
{text && <p>{text}</p>}
{!reversed && children}
</label>
);
}

View File

@ -1,4 +0,0 @@
export type ControlLabelProps = {
text?: string;
position?: 'left' | 'top' | 'right' | 'bottom';
} & React.ComponentPropsWithoutRef<'label'>;

View File

@ -0,0 +1,131 @@
import CalendarIcon from '@public/images/svg/calendar.svg';
import { px } from '@utils/css';
import { useMissClick } from '@utils/miss-click';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Calendar } from '../calendar';
import { IconButton } from '../icon-button';
import { Popover } from '../popover';
import { TextInput } from '../text-input';
import styles from './styles.module.scss';
import { DateInputProps } from './types';
import { dirtyDateToValue, inputToDirtyDate, valueToDirtyDate } from './utils';
export function DateInput({
value,
onChange,
rightNode,
scale,
min,
max,
...props
}: DateInputProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
const calendarWrapperRef = useRef<HTMLDivElement | null>(null);
const [calendarVisible, setCalendarVisible] = useState<boolean>(false);
const [dirtyDate, setDirtyDate] = useState<string>('');
const minDate = useMemo(() => {
return min ? new Date(min) : null;
}, [min]);
const maxDate = useMemo(() => {
return max ? new Date(max) : null;
}, [max]);
useEffect(() => {
setDirtyDate(valueToDirtyDate(value));
}, [value]);
useMissClick(
[wrapperRef, calendarWrapperRef],
() => setCalendarVisible(false),
calendarVisible,
);
const handleCalendarButtonClick = () => {
setCalendarVisible(!calendarVisible);
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newDirtyDate = inputToDirtyDate(event.target.value);
if (newDirtyDate.length === 10) {
const newValue = dirtyDateToValue(newDirtyDate);
const date = new Date(newValue);
if (
!isNaN(date.getDate()) &&
(!minDate || date >= minDate) &&
(!maxDate || date <= maxDate)
) {
onChange?.(newValue);
} else {
onChange?.('');
}
}
setDirtyDate(newDirtyDate);
};
const handleCalendarChange = (newValue: string) => {
onChange?.(newValue);
setCalendarVisible(false);
};
const calcPopoverStyles = (calendarRect: DOMRect) => {
if (calendarRect === null) {
return {};
}
const inputWrapperRect = inputWrapperRef.current.getBoundingClientRect();
const { left, bottom, top } = inputWrapperRect;
const rightSpace = window.innerWidth - left;
const rightOverflow = calendarRect.width - rightSpace;
const bottomSpace = window.innerHeight - bottom;
const popoverLeft = rightOverflow <= 0 ? left : left - rightOverflow;
const popoverTop =
bottomSpace >= calendarRect.height ? bottom : top - calendarRect.height;
return {
left: px(popoverLeft),
top: px(popoverTop),
};
};
return (
<div className={styles.wrapper} ref={wrapperRef}>
<TextInput
{...props}
scale={scale}
value={dirtyDate}
onChange={handleInputChange}
wrapper={{ ref: inputWrapperRef }}
rightNode={
<>
<IconButton scale={scale} onClick={handleCalendarButtonClick}>
<CalendarIcon />
</IconButton>
{rightNode}
</>
}
/>
<Popover
visible={calendarVisible}
calcStyles={calcPopoverStyles}
element={
<div className={styles.calendarWrapper} ref={calendarWrapperRef}>
<Calendar
value={value}
onChange={handleCalendarChange}
className={styles.calendar}
min={minDate}
max={maxDate}
/>
</div>
}
/>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { DateInput } from './component';
export { DateInputPreview } from './preview';
export { type DateInputProps } from './types';

View File

@ -0,0 +1,33 @@
import { PreviewArticle } from '@components/ui/preview';
import React, { useState } from 'react';
import { DateInput } from './component';
export function DateInputPreview() {
const [date, setDate] = useState<string>();
return (
<PreviewArticle title="DateInput">
<DateInput
value={date}
onChange={(v) => setDate(v)}
scale="s"
label={{ text: 'Date' }}
name="date"
/>
<DateInput
value={date}
onChange={(v) => setDate(v)}
scale="m"
label={{ text: 'Date' }}
name="date"
/>
<DateInput
value={date}
onChange={(v) => setDate(v)}
scale="l"
label={{ text: 'Date' }}
name="date"
/>
</PreviewArticle>
);
}

View File

@ -0,0 +1,20 @@
.wrapper {
position: relative;
}
.fade {
position: absolute;
z-index: 1;
}
.calendarWrapper {
padding: 5px 0;
}
.calendar {
padding: 15px;
border: 1px solid var(--clr-border-200);
border-radius: 10px;
background-color: var(--clr-layer-300);
box-shadow: 0px 4px 6px var(--clr-shadow-100);
}

View File

@ -0,0 +1,8 @@
import { TextInputProps } from '../text-input';
export type DateInputProps = {
value?: string;
onChange?: (value: string) => void;
max?: string;
min?: string;
} & Omit<TextInputProps, 'type' | 'value' | 'onChange'>;

View File

@ -0,0 +1,21 @@
export const valueToDirtyDate = (value: string = '') => {
const year = value.substring(0, 4);
const month = value.substring(5, 7);
const day = value.substring(8, 10);
return `${day}${month && '.'}${month}${year && '.'}${year}`;
};
export const inputToDirtyDate = (input: string) => {
const value = input.replace(/\D/g, '');
const day = value.substring(0, 2);
const month = value.substring(2, 4);
const year = value.substring(4, 8);
return `${day}${month && '.'}${month}${year && '.'}${year}`;
};
export const dirtyDateToValue = (dirtyDate: string) => {
const day = dirtyDate.substring(0, 2);
const month = dirtyDate.substring(3, 5);
const year = dirtyDate.substring(6, 10);
return `${year}-${month}-${day}T00:00`;
};

View File

@ -0,0 +1,15 @@
import clsx from 'clsx';
import { createElement } from 'react';
import styles from './styles.module.scss';
import { HeadingProps, HeadingTag } from './types';
export function Heading<T extends HeadingTag>({
tag,
color = 't300',
className,
...props
}: HeadingProps<T>) {
const classNames = clsx(styles.heading, styles[color], className);
return createElement(tag, { ...props, className: classNames });
}

View File

@ -0,0 +1,2 @@
export { Heading } from './component';
export { type HeadingProps } from './types';

View File

@ -0,0 +1,15 @@
.heading {
margin: 0;
}
.t100 {
color: var(--clr-text-100);
}
.t200 {
color: var(--clr-text-200);
}
.t300 {
color: var(--clr-text-300);
}

View File

@ -0,0 +1,8 @@
import { TextColor } from '../types';
export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
export type HeadingProps<T extends HeadingTag> = {
tag: T;
color?: TextColor;
} & React.ComponentProps<T>;

View File

@ -1,15 +1,22 @@
import clsx from 'clsx';
import React from 'react';
import { Ripple } from '../animation';
import { RawButton } from '../raw';
import styles from './styles.module.scss';
import { IconButtonProps } from './types';
export default function IconButton({
export function IconButton({
scale = 'm',
className,
children,
...props
}: IconButtonProps) {
const classes = clsx(styles.button, styles[scale], className);
return <RawButton className={classes} {...props} />;
return (
<RawButton className={classes} {...props}>
{children}
<Ripple />
</RawButton>
);
}

View File

@ -0,0 +1,2 @@
export { IconButton } from './component';
export { type IconButtonProps } from './types';

View File

@ -1,32 +1,41 @@
.button {
position: relative;
overflow: hidden;
border-radius: 100%;
background-color: transparent;
cursor: pointer;
transition: all var(--td-100) ease-in-out;
&:hover {
background-color: var(--clr-accent-o50);
background-color: var(--clr-ripple);
svg {
fill: var(--clr-text-300);
}
}
svg {
fill: var(--clr-primary);
height: 100%;
width: 100%;
height: 100%;
fill: var(--clr-text-100);
transition: all var(--td-100) ease-in-out;
}
}
.s {
width: 27px;
height: 27px;
padding: 4px;
height: 28px;
width: 28px;
}
.m {
width: 35px;
height: 35px;
padding: 6px;
height: 36px;
width: 36px;
}
.l {
width: 43px;
height: 43px;
padding: 8px;
height: 44px;
width: 44px;
}

View File

@ -1,5 +1,7 @@
import { RawButtonProps } from '../raw/raw-button/types';
import { Scale } from '@components/ui/types';
import { RawButtonProps } from '../raw';
export type IconButtonProps = {
scale?: 's' | 'm' | 'l';
scale?: Scale;
} & RawButtonProps;

View File

@ -1,19 +1,13 @@
import Button from './button';
import ControlLabel from './control-label';
import IconButton from './icon-button';
import Input from './input';
import Menu from './menu';
import PasswordTextField from './password-text-field';
import Select from './select';
import TextField from './text-field';
export {
Button,
ControlLabel,
IconButton,
Input,
Menu,
PasswordTextField,
Select,
TextField,
};
export { Button } from './button';
export { Checkbox } from './checkbox';
export { CheckboxGroup } from './checkbox-group';
export { DateInput } from './date-input';
export { Heading } from './heading';
export { IconButton } from './icon-button';
export { Menu } from './menu';
export { Paragraph } from './paragraph';
export { PasswordInput } from './password-input';
export { RadioGroup } from './radio-group';
export { Select } from './select';
export { Span } from './span';
export { TextInput } from './text-input';

View File

@ -0,0 +1,55 @@
import clsx from 'clsx';
import React, { ForwardedRef, forwardRef, useState } from 'react';
import { RawInput } from '../raw';
import styles from './styles.module.scss';
import { InputProps } from './types';
function InputInner(
{
scale = 'm',
wrapper = {},
leftNode,
rightNode,
className,
onFocus,
onBlur,
...props
}: Omit<InputProps, 'ref'>,
ref: ForwardedRef<HTMLInputElement>,
) {
const [focus, setFocus] = useState<boolean>(false);
const wrapperClassNames = clsx(
styles[scale],
styles.wrapper,
focus && styles.wrapperFocus,
wrapper.className,
);
const inputClassNames = clsx(styles.input, className);
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
onFocus?.(event);
setFocus(true);
};
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
onBlur?.(event);
setFocus(false);
};
return (
<div {...wrapper} className={wrapperClassNames}>
{leftNode}
<RawInput
className={inputClassNames}
onFocus={handleFocus}
onBlur={handleBlur}
ref={ref}
{...props}
/>
{rightNode}
</div>
);
}
export const Input = forwardRef(InputInner);

Some files were not shown because too many files have changed in this diff Show More