test-entity #6
@ -1,7 +0,0 @@
|
|||||||
<?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>
|
|
Before Width: | Height: | Size: 541 B |
15
front/public/images/svg/upload.svg
Normal file
15
front/public/images/svg/upload.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?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 15 13" style="enable-background:new 0 0 15 13;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M7.85,7.08C7.81,7.04,7.75,7,7.69,6.98c-0.12-0.05-0.26-0.05-0.38,0C7.25,7,7.19,7.04,7.15,7.08l-2.5,2.5
|
||||||
|
c-0.2,0.2-0.2,0.51,0,0.71s0.51,0.2,0.71,0L7,8.64v3.79c0,0.28,0.22,0.5,0.5,0.5S8,12.71,8,12.44V8.64l1.65,1.65
|
||||||
|
c0.1,0.1,0.23,0.15,0.35,0.15s0.26-0.05,0.35-0.15c0.2-0.2,0.2-0.51,0-0.71L7.85,7.08z"/>
|
||||||
|
<path d="M9.38,0.06c-2.6,0-4.83,1.85-5.36,4.38H3.44c-1.83,0-3.31,1.49-3.31,3.31c0,1.01,0.45,1.95,1.23,2.58
|
||||||
|
c0.27,0.22,0.57,0.39,0.89,0.51c0.06,0.02,0.12,0.03,0.18,0.03c0.2,0,0.39-0.12,0.47-0.32c0.1-0.26-0.03-0.55-0.29-0.65
|
||||||
|
C2.38,9.82,2.17,9.7,1.99,9.55c-0.55-0.44-0.86-1.1-0.86-1.8c0-1.27,1.04-2.31,2.31-2.31h1c0.25,0,0.46-0.19,0.5-0.44
|
||||||
|
c0.28-2.24,2.19-3.94,4.44-3.94c2.48,0,4.5,2.02,4.5,4.5c0,1.29-0.56,2.53-1.53,3.38c-0.21,0.18-0.23,0.5-0.05,0.71
|
||||||
|
c0.18,0.21,0.5,0.23,0.71,0.05c1.19-1.04,1.87-2.55,1.87-4.13C14.88,2.53,12.41,0.06,9.38,0.06z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
@ -1,2 +0,0 @@
|
|||||||
// export const BASE_URL = 'http://localhost:8000/api';
|
|
||||||
export const BASE_URL = 'http://192.168.1.110:8000/api';
|
|
@ -1 +0,0 @@
|
|||||||
export { downloadImage, getWindmillData } from './service';
|
|
@ -1,26 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
@ -1,4 +0,0 @@
|
|||||||
export type GetWindmillDataRes = {
|
|
||||||
file_name: string;
|
|
||||||
data: number[];
|
|
||||||
};
|
|
@ -1,9 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export * from './floris';
|
|
19
front/src/components/_func.scss
Normal file
19
front/src/components/_func.scss
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
@function scale($values, $factor) {
|
||||||
|
@if type-of($values) == 'list' {
|
||||||
|
$m-values: ();
|
||||||
|
@each $value in $values {
|
||||||
|
$m-values: append($m-values, $value * $factor);
|
||||||
|
}
|
||||||
|
@return $m-values;
|
||||||
|
} @else {
|
||||||
|
@return nth($values, 1) * $factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@function m($values) {
|
||||||
|
@return scale($values, 1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@function l($values) {
|
||||||
|
@return scale($values, 1.5);
|
||||||
|
}
|
@ -4,12 +4,12 @@
|
|||||||
--clr-primary: #4176FF;
|
--clr-primary: #4176FF;
|
||||||
--clr-primary-o50: #3865DA80;
|
--clr-primary-o50: #3865DA80;
|
||||||
--clr-primary-hover: #638FFF;
|
--clr-primary-hover: #638FFF;
|
||||||
--clr-primary-active: #3D68D7;
|
--clr-primary-disabled: #3D68D7;
|
||||||
--clr-on-primary: #FFFFFF;
|
--clr-on-primary: #FFFFFF;
|
||||||
|
|
||||||
--clr-secondary: #EAEAEA;
|
--clr-secondary: #EAEAEA;
|
||||||
--clr-secondary-hover: #EFEFEF;
|
--clr-secondary-hover: #EFEFEF;
|
||||||
--clr-secondary-active: #E1E1E1;
|
--clr-secondary-disabled: #E1E1E1;
|
||||||
--clr-on-secondary: #0D0D0D;
|
--clr-on-secondary: #0D0D0D;
|
||||||
|
|
||||||
--clr-layer-100: #EBEEF0;
|
--clr-layer-100: #EBEEF0;
|
||||||
@ -36,12 +36,12 @@
|
|||||||
--clr-primary: #3865DA;
|
--clr-primary: #3865DA;
|
||||||
--clr-primary-o50: #3865DA80;
|
--clr-primary-o50: #3865DA80;
|
||||||
--clr-primary-hover: #4073F7;
|
--clr-primary-hover: #4073F7;
|
||||||
--clr-primary-active: #2A4DA7;
|
--clr-primary-disabled: #2A4DA7;
|
||||||
--clr-on-primary: #FFFFFF;
|
--clr-on-primary: #FFFFFF;
|
||||||
|
|
||||||
--clr-secondary: #3F3F3F;
|
--clr-secondary: #3F3F3F;
|
||||||
--clr-secondary-hover: #4D4D4D;
|
--clr-secondary-hover: #4D4D4D;
|
||||||
--clr-secondary-active: #323232;
|
--clr-secondary-disabled: #323232;
|
||||||
--clr-on-secondary: #FFFFFF;
|
--clr-on-secondary: #FFFFFF;
|
||||||
|
|
||||||
--clr-layer-100: #1B1B1B;
|
--clr-layer-100: #1B1B1B;
|
||||||
|
@ -2,7 +2,7 @@ import './styles.scss';
|
|||||||
import '@public/fonts/styles.css';
|
import '@public/fonts/styles.css';
|
||||||
|
|
||||||
import { MainLayout } from '@components/layouts';
|
import { MainLayout } from '@components/layouts';
|
||||||
import { HomePage } from '@components/pages';
|
import { FormPage, HomePage } from '@components/pages';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
<Route path={'/'} element={<HomePage />} />
|
<Route path={'/'} element={<HomePage />} />
|
||||||
|
<Route path={'/form'} element={<FormPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Header } from '@components/ux';
|
import { Sidebar } from '@components/ux';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ import styles from './styles.module.scss';
|
|||||||
function MainLayout() {
|
function MainLayout() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.mainLayout}>
|
<div className={styles.mainLayout}>
|
||||||
<Header />
|
<Sidebar />
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
@ -2,9 +2,8 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
grid-template:
|
grid-template:
|
||||||
'header' auto
|
'sidebar main' minmax(0, 1fr)
|
||||||
'main' minmax(0, 1fr)
|
/ auto minmax(0, 1fr);
|
||||||
/ minmax(0, 1fr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
12
front/src/components/pages/form-page/component.tsx
Normal file
12
front/src/components/pages/form-page/component.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { SignInForm } from '@components/ux';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
export function FormPage() {
|
||||||
|
return (
|
||||||
|
<div className={styles.about}>
|
||||||
|
<SignInForm className={styles.form} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
front/src/components/pages/form-page/index.ts
Normal file
1
front/src/components/pages/form-page/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { FormPage } from './component';
|
11
front/src/components/pages/form-page/styles.module.scss
Normal file
11
front/src/components/pages/form-page/styles.module.scss
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.about {
|
||||||
|
display: grid;
|
||||||
|
padding: 20px;
|
||||||
|
grid-template:
|
||||||
|
'. form .' auto
|
||||||
|
/ auto minmax(0, 380px) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
grid-area: form;
|
||||||
|
}
|
@ -1,40 +1,35 @@
|
|||||||
import { Heading, Paragraph } from '@components/ui';
|
import { AutocompletePreview } from '@components/ui/autocomplete';
|
||||||
import { WindmillForm } from '@components/ux';
|
import { ButtonPreview } from '@components/ui/button';
|
||||||
import { WindmillFormResponse } from '@components/ux/windmill-form';
|
import { CheckboxGroupPreview } from '@components/ui/checkbox-group';
|
||||||
import React, { useState } from 'react';
|
import { DataGridPreview } from '@components/ui/data-grid';
|
||||||
|
import { DateInputPreview } from '@components/ui/date-input';
|
||||||
|
import { ImageFileManagerPreview } from '@components/ui/image-file-manager/preview';
|
||||||
|
import { NumberInputPreview } from '@components/ui/number-input/preview';
|
||||||
|
import { PasswordInputPreview } from '@components/ui/password-input';
|
||||||
|
import { RadioGroupPreview } from '@components/ui/radio-group';
|
||||||
|
import { SelectPreview } from '@components/ui/select';
|
||||||
|
import { TextAreaPreview } from '@components/ui/text-area/preview';
|
||||||
|
import { TextInputPreview } from '@components/ui/text-input';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
export function HomePage() {
|
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 (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.home}>
|
||||||
<div className={styles.wrapperForm}>
|
<div className={styles.content}>
|
||||||
<WindmillForm onSuccess={handleFormSuccess} onFail={handleFormFail} />
|
<DataGridPreview />
|
||||||
</div>
|
<ButtonPreview />
|
||||||
<div className={styles.result}>
|
<TextInputPreview />
|
||||||
<Heading tag="h3">Result</Heading>
|
<PasswordInputPreview />
|
||||||
{result && (
|
<SelectPreview />
|
||||||
<>
|
<AutocompletePreview />
|
||||||
<div className={styles.power}>{result.power.join(' ')}</div>
|
<DateInputPreview />
|
||||||
<div className={styles.image}>
|
<NumberInputPreview />
|
||||||
{result.image && <img src={result.image} alt="Image" />}
|
<TextAreaPreview />
|
||||||
</div>
|
<CheckboxGroupPreview />
|
||||||
</>
|
<RadioGroupPreview />
|
||||||
)}
|
<ImageFileManagerPreview />
|
||||||
{error && <Paragraph>{error}</Paragraph>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,41 +1,14 @@
|
|||||||
.page {
|
.home {
|
||||||
display: grid;
|
display: grid;
|
||||||
padding: 20px;
|
|
||||||
gap: 20px;
|
|
||||||
grid-template:
|
grid-template:
|
||||||
'. form result .' auto
|
'. content .' auto
|
||||||
/ auto minmax(0, 380px) minmax(0, 700px) auto;
|
/ auto minmax(0, 1000px) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapperForm {
|
.content {
|
||||||
grid-area: form;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 20px;
|
padding: 20px 20px 60px 20px;
|
||||||
border-radius: 10px;
|
gap: 30px;
|
||||||
background-color: var(--clr-layer-200);
|
grid-area: content;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1 +1,2 @@
|
|||||||
|
export { FormPage } from './form-page';
|
||||||
export { HomePage } from './home-page';
|
export { HomePage } from './home-page';
|
||||||
|
@ -1,68 +1,65 @@
|
|||||||
import React, {
|
import clsx from 'clsx';
|
||||||
ForwardedRef,
|
import React, { useRef } from 'react';
|
||||||
forwardRef,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { RippleWave } from './parts/ripple-wave';
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import { RippleProps } from './types';
|
|
||||||
import { calcRippleWaveStyle } from './utils';
|
import { calcRippleWaveStyle } from './utils';
|
||||||
|
|
||||||
export function RippleInner(
|
export function Ripple() {
|
||||||
props: RippleProps,
|
|
||||||
ref: ForwardedRef<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const rippleRef = useRef<HTMLDivElement | null>(null);
|
const rippleRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [waves, setWaves] = useState<React.JSX.Element[]>([]);
|
|
||||||
const [isTouch, setIsTouch] = useState(false);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => rippleRef.current, []);
|
const clean = () => {
|
||||||
|
document.removeEventListener('touchend', clean);
|
||||||
|
document.removeEventListener('mouseup', clean);
|
||||||
|
if (!rippleRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { lastChild: wave } = rippleRef.current;
|
||||||
|
if (!wave || !(wave instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wave.dataset.isMouseReleased = 'true';
|
||||||
|
if (wave.dataset.isAnimationComplete) {
|
||||||
|
wave.classList.replace(styles.visible, styles.invisible);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleWaveOnDone = () => {
|
const handleAnimationEnd = (event: AnimationEvent) => {
|
||||||
setWaves((prev) => prev.slice(1));
|
const { target: wave, animationName } = event;
|
||||||
|
if (!(wave instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (animationName === styles.fadein) {
|
||||||
|
wave.dataset.isAnimationComplete = 'true';
|
||||||
|
if (wave.dataset.isMouseReleased) {
|
||||||
|
wave.classList.replace(styles.visible, styles.invisible);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wave.remove();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addWave = (x: number, y: number) => {
|
const addWave = (x: number, y: number) => {
|
||||||
|
const wave = document.createElement('div');
|
||||||
const style = calcRippleWaveStyle(x, y, rippleRef.current);
|
const style = calcRippleWaveStyle(x, y, rippleRef.current);
|
||||||
const wave = (
|
Object.assign(wave.style, style);
|
||||||
<RippleWave
|
wave.className = clsx(styles.wave, styles.visible);
|
||||||
key={new Date().getTime()}
|
wave.addEventListener('animationend', handleAnimationEnd);
|
||||||
style={style}
|
rippleRef.current.appendChild(wave);
|
||||||
onDone={handleWaveOnDone}
|
document.addEventListener('touchend', clean);
|
||||||
/>
|
document.addEventListener('mouseup', clean);
|
||||||
);
|
|
||||||
setWaves([...waves, wave]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (event: React.MouseEvent) => {
|
const handlePointerDown = (event: React.MouseEvent) => {
|
||||||
if (isTouch) {
|
event.stopPropagation();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { pageX, pageY } = event;
|
const { pageX, pageY } = event;
|
||||||
addWave(pageX, pageY);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rippleRef}
|
|
||||||
className={styles.ripple}
|
className={styles.ripple}
|
||||||
onMouseDown={handleMouseDown}
|
ref={rippleRef}
|
||||||
onTouchStart={handleTouchStart}
|
onPointerDown={handlePointerDown}
|
||||||
{...props}
|
/>
|
||||||
>
|
|
||||||
{waves}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Ripple = forwardRef(RippleInner);
|
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export { RippleWave } from './component';
|
|
@ -1,33 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { CSSProperties } from 'react';
|
|
||||||
|
|
||||||
export type RippleWaveProps = {
|
|
||||||
style: CSSProperties;
|
|
||||||
onDone: () => void;
|
|
||||||
};
|
|
@ -5,3 +5,37 @@
|
|||||||
width: 200%;
|
width: 200%;
|
||||||
height: 200%;
|
height: 200%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export type RippleProps = {} & React.ComponentProps<'div'>;
|
|
@ -1,3 +1,4 @@
|
|||||||
|
import { px } from '@utils/css';
|
||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
export const calcRippleWaveStyle = (
|
export const calcRippleWaveStyle = (
|
||||||
@ -8,7 +9,7 @@ export const calcRippleWaveStyle = (
|
|||||||
const wrapperRect = ripple.getBoundingClientRect();
|
const wrapperRect = ripple.getBoundingClientRect();
|
||||||
const diameter = Math.max(wrapperRect.width, wrapperRect.height);
|
const diameter = Math.max(wrapperRect.width, wrapperRect.height);
|
||||||
const radius = diameter / 2;
|
const radius = diameter / 2;
|
||||||
const left = x - wrapperRect.left - radius;
|
const left = px(x - wrapperRect.left - radius);
|
||||||
const top = y - wrapperRect.top - radius;
|
const top = px(y - wrapperRect.top - radius);
|
||||||
return { left, top, width: diameter, height: diameter };
|
return { left, top, width: px(diameter), height: px(diameter) };
|
||||||
};
|
};
|
||||||
|
119
front/src/components/ui/autocomplete/component.tsx
Normal file
119
front/src/components/ui/autocomplete/component.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
|
||||||
|
import { useMissClick } from '@utils/miss-click';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, {
|
||||||
|
ForwardedRef,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { Menu } from '../menu';
|
||||||
|
import { Popover } from '../popover';
|
||||||
|
import { TextInput } from '../text-input';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { AutocompleteProps } from './types';
|
||||||
|
|
||||||
|
function AutocompleteInner<T>(
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
getOptionKey,
|
||||||
|
getOptionLabel,
|
||||||
|
onChange,
|
||||||
|
scale = 'm',
|
||||||
|
label = {},
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
}: Omit<AutocompleteProps<T>, 'ref'>,
|
||||||
|
ref: ForwardedRef<HTMLDivElement>,
|
||||||
|
) {
|
||||||
|
const autocompleteRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLUListElement | null>(null);
|
||||||
|
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [menuVisible, setMenuVisible] = useState<boolean>(false);
|
||||||
|
const [text, setText] = useState<string>('');
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => autocompleteRef.current, []);
|
||||||
|
|
||||||
|
useMissClick(
|
||||||
|
[autocompleteRef, menuRef],
|
||||||
|
() => setMenuVisible(false),
|
||||||
|
menuVisible,
|
||||||
|
);
|
||||||
|
|
||||||
|
const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
|
||||||
|
[styles.menuVisible]: menuVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredOptions = options.filter((option) => {
|
||||||
|
const label = getOptionLabel(option).toLocaleLowerCase();
|
||||||
|
const raw = text.trim().toLocaleLowerCase();
|
||||||
|
return label.includes(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInputClick = () => {
|
||||||
|
setMenuVisible(!menuVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuSelect = (option: T) => {
|
||||||
|
setMenuVisible(false);
|
||||||
|
onChange?.(option);
|
||||||
|
setText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { value } = event.target;
|
||||||
|
setText(value);
|
||||||
|
const option = options.find((option) => {
|
||||||
|
const label = getOptionLabel(option).toLocaleLowerCase();
|
||||||
|
const raw = value.toLocaleLowerCase();
|
||||||
|
return label === raw;
|
||||||
|
});
|
||||||
|
onChange?.(option ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={autocompleteClassName} ref={autocompleteRef}>
|
||||||
|
<TextInput
|
||||||
|
value={value ? getOptionLabel(value) : text}
|
||||||
|
onClick={handleInputClick}
|
||||||
|
scale={scale}
|
||||||
|
label={label}
|
||||||
|
name={name}
|
||||||
|
id={id}
|
||||||
|
wrapper={{ ref: inputWrapperRef }}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
rightNode={
|
||||||
|
<div className={styles.iconBox}>
|
||||||
|
<ArrowDownIcon className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
visible={menuVisible}
|
||||||
|
anchorRef={autocompleteRef}
|
||||||
|
position="bottom"
|
||||||
|
horizontalAlign="stretch"
|
||||||
|
flip
|
||||||
|
element={
|
||||||
|
<div className={styles.menuWrapper}>
|
||||||
|
<Menu
|
||||||
|
options={filteredOptions}
|
||||||
|
selected={value}
|
||||||
|
getOptionKey={getOptionKey}
|
||||||
|
getOptionLabel={getOptionLabel}
|
||||||
|
onSelect={handleMenuSelect}
|
||||||
|
ref={menuRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Autocomplete = forwardRef(AutocompleteInner) as <T>(
|
||||||
|
props: AutocompleteProps<T>,
|
||||||
|
) => ReturnType<typeof AutocompleteInner>;
|
3
front/src/components/ui/autocomplete/index.ts
Normal file
3
front/src/components/ui/autocomplete/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { Autocomplete } from './component';
|
||||||
|
export { AutocompletePreview } from './preview';
|
||||||
|
export { type AutocompleteProps } from './types';
|
44
front/src/components/ui/autocomplete/preview.tsx
Normal file
44
front/src/components/ui/autocomplete/preview.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { PreviewArticle } from '@components/ui/preview';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Autocomplete } from './component';
|
||||||
|
|
||||||
|
export function AutocompletePreview() {
|
||||||
|
const [selectValue, setSelectValue] = useState<string>();
|
||||||
|
const options = ['Orange', 'Banana', 'Apple', 'Avocado'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewArticle title="Autocomplete">
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
getOptionKey={(o) => o}
|
||||||
|
getOptionLabel={(o) => o}
|
||||||
|
label={{ text: 'Select your favorite fruit' }}
|
||||||
|
scale="s"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(o) => setSelectValue(o)}
|
||||||
|
name="fruit"
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
getOptionKey={(o) => o}
|
||||||
|
getOptionLabel={(o) => o}
|
||||||
|
label={{ text: 'Select your favorite fruit' }}
|
||||||
|
scale="m"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(o) => setSelectValue(o)}
|
||||||
|
name="fruit"
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
getOptionKey={(o) => o}
|
||||||
|
getOptionLabel={(o) => o}
|
||||||
|
label={{ text: 'Select your favorite fruit' }}
|
||||||
|
scale="l"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(o) => setSelectValue(o)}
|
||||||
|
name="fruit"
|
||||||
|
/>
|
||||||
|
</PreviewArticle>
|
||||||
|
);
|
||||||
|
}
|
62
front/src/components/ui/autocomplete/styles.module.scss
Normal file
62
front/src/components/ui/autocomplete/styles.module.scss
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
|
.autocomplete {
|
||||||
|
position: relative;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
fill: var(--clr-text-100);
|
||||||
|
transition: all var(--td-100) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuVisible {
|
||||||
|
.icon {
|
||||||
|
rotate: 180deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuWrapper {
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$padding-right: 7px;
|
||||||
|
$size: 10px;
|
||||||
|
|
||||||
|
.s {
|
||||||
|
.iconBox {
|
||||||
|
padding-right: $padding-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: $size;
|
||||||
|
height: $size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m {
|
||||||
|
.iconBox {
|
||||||
|
padding-right: f.m($padding-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: f.m($size);
|
||||||
|
height: f.m($size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.l {
|
||||||
|
.iconBox {
|
||||||
|
padding-right: f.l($padding-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: f.l($size);
|
||||||
|
height: f.l($size);
|
||||||
|
}
|
||||||
|
}
|
14
front/src/components/ui/autocomplete/types.ts
Normal file
14
front/src/components/ui/autocomplete/types.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { LabelProps } from '../label';
|
||||||
|
import { Scale } from '../types';
|
||||||
|
|
||||||
|
export type AutocompleteProps<T> = {
|
||||||
|
options: T[];
|
||||||
|
value?: T;
|
||||||
|
getOptionKey: (option: T) => React.Key;
|
||||||
|
getOptionLabel: (option: T) => string;
|
||||||
|
onChange?: (option: T) => void;
|
||||||
|
scale?: Scale;
|
||||||
|
label?: LabelProps;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
|
@ -1,7 +1,7 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Ripple } from '../animation';
|
import { Ripple } from '../animation/ripple/component';
|
||||||
import { Comet } from '../comet';
|
import { Comet } from '../comet';
|
||||||
import { RawButton } from '../raw';
|
import { RawButton } from '../raw';
|
||||||
import { COMET_VARIANT_MAP } from './constants';
|
import { COMET_VARIANT_MAP } from './constants';
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -40,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.pending {
|
&.pending {
|
||||||
background-color: var(--clr-primary-active);
|
background-color: var(--clr-primary-disabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,24 +55,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.pending {
|
&.pending {
|
||||||
background-color: var(--clr-secondary-active);
|
background-color: var(--clr-secondary-disabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$padding: 10px 16px;
|
||||||
|
$border-radius: 8px;
|
||||||
|
$font-size: 12px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
padding: 10px 16px;
|
padding: $padding;
|
||||||
border-radius: 8px;
|
border-radius: $border-radius;
|
||||||
font-size: 12px;
|
font-size: $font-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
padding: 14px 20px;
|
padding: f.m($padding);
|
||||||
border-radius: 10px;
|
border-radius: f.m($border-radius);
|
||||||
font-size: 16px;
|
font-size: f.m($font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
padding: 18px 24px;
|
padding: f.l($padding);
|
||||||
border-radius: 12px;
|
border-radius: f.l($border-radius);
|
||||||
font-size: 20px;
|
font-size: f.l($font-size);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
|
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { CalendarDays } from './parts';
|
import { CalendarDays } from './components';
|
||||||
import { CalendarProps } from './types';
|
import { CalendarProps } from './types';
|
||||||
|
|
||||||
function CalendarInner(
|
function CalendarInner(
|
||||||
|
@ -33,7 +33,7 @@ export function CalendarDays({
|
|||||||
}, [date, min, max]);
|
}, [date, min, max]);
|
||||||
|
|
||||||
const handleChange = (newValue: string) => {
|
const handleChange = (newValue: string) => {
|
||||||
onChange?.(newValue);
|
onChange(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
@ -40,19 +40,12 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--clr-text-100);
|
color: var(--clr-text-100);
|
||||||
transition: all var(--td-100) ease-in-out;
|
|
||||||
|
|
||||||
&:not(:disabled) {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all var(--td-100) ease-in-out;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--clr-layer-300-hover);
|
background-color: var(--clr-layer-300-hover);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
color: var(--clr-text-100);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.currentMonthDay {
|
.currentMonthDay {
|
@ -9,7 +9,7 @@ export type CalendarDay = {
|
|||||||
|
|
||||||
export type CalendarDaysProps = {
|
export type CalendarDaysProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
min: Date | null;
|
min: Date | null;
|
||||||
max: Date | null;
|
max: Date | null;
|
||||||
date: Date;
|
date: Date;
|
@ -1,11 +1,18 @@
|
|||||||
import { dateToInputString } from '@utils/date';
|
|
||||||
|
|
||||||
import { CalendarDay, GetCalendarDaysParams } from './types';
|
import { CalendarDay, GetCalendarDaysParams } from './types';
|
||||||
|
|
||||||
const addDays = (date: Date, days: number) => {
|
const addDays = (date: Date, days: number) => {
|
||||||
date.setDate(date.getDate() + days);
|
date.setDate(date.getDate() + days);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function dateToInputString(date: Date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
const daysAreEqual = (date1: Date, date2: Date) => {
|
const daysAreEqual = (date1: Date, date2: Date) => {
|
||||||
return (
|
return (
|
||||||
date1.getDate() === date2.getDate() &&
|
date1.getDate() === date2.getDate() &&
|
@ -1,6 +1,6 @@
|
|||||||
export type CalendarProps = {
|
export type CalendarProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
min: Date | null;
|
min: Date | null;
|
||||||
max: Date | null;
|
max: Date | null;
|
||||||
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
|
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
|
||||||
|
@ -1,22 +1,26 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.checkBoxGroup {
|
.checkBoxGroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$margin-bottom: 4px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 3px;
|
margin-bottom: $margin-bottom;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 5px;
|
margin-bottom: f.m($margin-bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 7px;
|
margin-bottom: f.l($margin-bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import CheckIcon from '@public/images/svg/check.svg';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { ForwardedRef, forwardRef } from 'react';
|
import React, { ForwardedRef, forwardRef } from 'react';
|
||||||
|
|
||||||
import { Ripple } from '../animation';
|
import { Ripple } from '../animation/ripple/component';
|
||||||
import { Label, LabelProps } from '../label';
|
import { Label, LabelProps } from '../label';
|
||||||
import { RawInput } from '../raw';
|
import { RawInput } from '../raw';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.checkbox {
|
.checkbox {
|
||||||
@ -42,7 +45,7 @@
|
|||||||
.checkbox {
|
.checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid var(--clr-border-200);
|
border: 2px solid var(--clr-border-200);
|
||||||
background-color: var(--clr-layer-300);
|
background-color: var(--clr-layer-300);
|
||||||
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
||||||
transition: all var(--td-100) ease-in-out;
|
transition: all var(--td-100) ease-in-out;
|
||||||
@ -54,35 +57,40 @@
|
|||||||
transition: all var(--td-100) ease-in-out;
|
transition: all var(--td-100) ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$padding-outer: 4px;
|
||||||
|
$size: 16px;
|
||||||
|
$padding-inner: 2px;
|
||||||
|
$border-radius: 5px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
padding: 3px;
|
padding: $padding-outer;
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
width: 16px;
|
width: $size;
|
||||||
height: 16px;
|
height: $size;
|
||||||
padding: 2px;
|
padding: $padding-inner;
|
||||||
border-radius: 5px;
|
border-radius: $border-radius;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
padding: 5px;
|
padding: f.m($padding-outer);
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
width: 20px;
|
width: f.m($size);
|
||||||
height: 20px;
|
height: f.m($size);
|
||||||
padding: 3px;
|
padding: f.m($padding-inner);
|
||||||
border-radius: 6px;
|
border-radius: f.m($border-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
padding: 7px;
|
padding: f.l($padding-outer);
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
width: 24px;
|
width: f.l($size);
|
||||||
height: 24px;
|
height: f.l($size);
|
||||||
padding: 4px;
|
padding: f.l($padding-inner);
|
||||||
border-radius: 7px;
|
border-radius: f.l($border-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.comet {
|
.comet {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spinner-comet 1s infinite linear;
|
animation: spinner-comet 1s infinite linear;
|
||||||
@ -9,23 +11,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$size: 12px;
|
||||||
|
$offset: 1.75px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
width: 12px;
|
width: $size;
|
||||||
height: 12px;
|
height: $size;
|
||||||
mask: radial-gradient(farthest-side, #0000 calc(100% - 2px), #000 0);
|
mask: radial-gradient(
|
||||||
|
farthest-side,
|
||||||
|
#0000 calc(100% - $offset),
|
||||||
|
#000 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
width: 16px;
|
width: f.m($size);
|
||||||
height: 16px;
|
height: f.m($size);
|
||||||
mask: radial-gradient(farthest-side, #0000 calc(100% - 2.5px), #000 0);
|
mask: radial-gradient(
|
||||||
|
farthest-side,
|
||||||
|
#0000 calc(100% - f.m($offset)),
|
||||||
|
#000 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
width: 20px;
|
width: f.l($size);
|
||||||
height: 20px;
|
height: f.l($size);
|
||||||
mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
|
mask: radial-gradient(
|
||||||
}
|
farthest-side,
|
||||||
|
#0000 calc(100% - f.l($offset)),
|
||||||
|
#000 0
|
||||||
|
);}
|
||||||
|
|
||||||
.onPrimary {
|
.onPrimary {
|
||||||
background: conic-gradient(#0000 10%, var(--clr-on-primary));
|
background: conic-gradient(#0000 10%, var(--clr-on-primary));
|
||||||
|
50
front/src/components/ui/data-grid/component.tsx
Normal file
50
front/src/components/ui/data-grid/component.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { DataGridHeader, DataGridRow } from './components';
|
||||||
|
import { DataGridProps } from './types';
|
||||||
|
|
||||||
|
export function DataGrid<T>({
|
||||||
|
items,
|
||||||
|
columns,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DataGridProps<T>) {
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Record<string, boolean>>({});
|
||||||
|
const [allRowsSelected, setAllRowsSelected] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleSelectAllRows = () => {
|
||||||
|
const newSelectedRows: Record<string, boolean> = {};
|
||||||
|
items.forEach((_, index) => {
|
||||||
|
newSelectedRows[index] = !allRowsSelected;
|
||||||
|
});
|
||||||
|
setSelectedRows(newSelectedRows);
|
||||||
|
setAllRowsSelected(!allRowsSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowSelect = (rowIndex: number) => {
|
||||||
|
setSelectedRows({
|
||||||
|
...selectedRows,
|
||||||
|
[rowIndex]: selectedRows[rowIndex] ? !selectedRows[rowIndex] : true,
|
||||||
|
});
|
||||||
|
setAllRowsSelected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} {...props}>
|
||||||
|
<DataGridHeader
|
||||||
|
columns={columns}
|
||||||
|
allRowsSelected={allRowsSelected}
|
||||||
|
onSelectAllRows={handleSelectAllRows}
|
||||||
|
/>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<DataGridRow
|
||||||
|
object={item}
|
||||||
|
columns={columns}
|
||||||
|
selected={selectedRows[index] ?? false}
|
||||||
|
onSelect={() => handleRowSelect(index)}
|
||||||
|
key={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
import { Ripple } from '@components/ui/animation';
|
||||||
|
import { Checkbox } from '@components/ui/checkbox';
|
||||||
|
import { RawButton } from '@components/ui/raw';
|
||||||
|
import { Span } from '@components/ui/span';
|
||||||
|
import ArrowUpIcon from '@public/images/svg/arrow-up.svg';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { DataGridSort } from '../../types';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { DataGridHeaderProps } from './types';
|
||||||
|
|
||||||
|
export function DataGridHeader<T>({
|
||||||
|
columns,
|
||||||
|
allRowsSelected,
|
||||||
|
onSelectAllRows,
|
||||||
|
}: DataGridHeaderProps<T>) {
|
||||||
|
const [sort, setSort] = useState<DataGridSort>({ order: 'asc', column: '' });
|
||||||
|
|
||||||
|
const handleSortButtonClick = (column: string) => {
|
||||||
|
if (column === sort.column) {
|
||||||
|
if (sort.order === 'asc') {
|
||||||
|
setSort({ order: 'desc', column });
|
||||||
|
} else {
|
||||||
|
setSort({ order: 'desc', column: '' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSort({ order: 'asc', column });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<Checkbox
|
||||||
|
checked={allRowsSelected}
|
||||||
|
onChange={onSelectAllRows}
|
||||||
|
label={{ className: styles.checkboxLabel }}
|
||||||
|
/>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const isActive = sort.column === column.name;
|
||||||
|
const cellClassName = clsx(styles.cell, {
|
||||||
|
[styles.activeCell]: isActive,
|
||||||
|
[styles.desc]: isActive && sort.order === 'desc',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<RawButton
|
||||||
|
style={{ flex: column.flex }}
|
||||||
|
className={cellClassName}
|
||||||
|
key={column.name}
|
||||||
|
onClick={() => handleSortButtonClick(column.name)}
|
||||||
|
>
|
||||||
|
<Span color="t300" className={styles.name}>
|
||||||
|
{column.name}
|
||||||
|
</Span>
|
||||||
|
<ArrowUpIcon className={styles.icon} />
|
||||||
|
<Ripple />
|
||||||
|
</RawButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel {
|
||||||
|
padding: 10px;
|
||||||
|
border: solid 1px var(--clr-border-100);
|
||||||
|
background-color: var(--clr-layer-300);
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border: solid 1px var(--clr-border-100);
|
||||||
|
background-color: var(--clr-layer-300);
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all var(--td-100) ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--clr-layer-300-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
fill: transparent;
|
||||||
|
transition: all var(--td-100) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeCell {
|
||||||
|
.icon {
|
||||||
|
fill: var(--clr-text-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
.icon {
|
||||||
|
rotate: 180deg;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
import { DataGridColumnConfig } from '../../types';
|
||||||
|
|
||||||
|
export type DataGridHeaderProps<T> = {
|
||||||
|
columns: DataGridColumnConfig<T>[];
|
||||||
|
allRowsSelected: boolean;
|
||||||
|
onSelectAllRows: () => void;
|
||||||
|
};
|
@ -0,0 +1,32 @@
|
|||||||
|
import { Checkbox } from '@components/ui/checkbox';
|
||||||
|
import { Span } from '@components/ui/span';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { DataGridRowProps } from './types';
|
||||||
|
|
||||||
|
export function DataGridRow<T>({
|
||||||
|
object,
|
||||||
|
columns,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: DataGridRowProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
label={{ className: styles.checkboxLabel }}
|
||||||
|
onChange={onSelect}
|
||||||
|
/>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div
|
||||||
|
className={styles.cell}
|
||||||
|
style={{ flex: column.flex }}
|
||||||
|
key={column.name}
|
||||||
|
>
|
||||||
|
<Span>{column.getText(object)}</Span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export * from './component';
|
@ -0,0 +1,18 @@
|
|||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel {
|
||||||
|
padding: 10px;
|
||||||
|
border: solid 1px var(--clr-border-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1 0 0;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border: solid 1px var(--clr-border-100);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { DataGridColumnConfig } from '../../types';
|
||||||
|
|
||||||
|
export type DataGridRowProps<T> = {
|
||||||
|
object: T;
|
||||||
|
columns: DataGridColumnConfig<T>[];
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
2
front/src/components/ui/data-grid/components/index.ts
Normal file
2
front/src/components/ui/data-grid/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './DataGridHeader';
|
||||||
|
export * from './DataGridRow';
|
2
front/src/components/ui/data-grid/index.ts
Normal file
2
front/src/components/ui/data-grid/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './component';
|
||||||
|
export * from './preview';
|
28
front/src/components/ui/data-grid/preview.tsx
Normal file
28
front/src/components/ui/data-grid/preview.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { PreviewArticle } from '@components/ui/preview';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { DataGrid } from './component';
|
||||||
|
import { Cat, DataGridColumnConfig } from './types';
|
||||||
|
|
||||||
|
export function DataGridPreview() {
|
||||||
|
const items: Cat[] = [
|
||||||
|
{ name: 'Luna', breed: 'British Shorthair', color: 'Gray', age: '2' },
|
||||||
|
{ name: 'Simba', breed: 'Siamese', color: 'Cream', age: '1' },
|
||||||
|
{ name: 'Bella', breed: 'Maine Coon', color: 'Brown Tabby', age: '3' },
|
||||||
|
{ name: 'Oliver', breed: 'Persian', color: 'White', age: '4' },
|
||||||
|
{ name: 'Milo', breed: 'Sphynx', color: 'Pink', age: '2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: DataGridColumnConfig<Cat>[] = [
|
||||||
|
{ name: 'Name', getText: (cat) => cat.name, flex: '2' },
|
||||||
|
{ name: 'Breed', getText: (cat) => cat.breed },
|
||||||
|
{ name: 'Age', getText: (cat) => cat.age },
|
||||||
|
{ name: 'Color', getText: (cat) => cat.color },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewArticle title="DataGrid">
|
||||||
|
<DataGrid style={{ width: '100%' }} items={items} columns={columns} />
|
||||||
|
</PreviewArticle>
|
||||||
|
);
|
||||||
|
}
|
23
front/src/components/ui/data-grid/types.ts
Normal file
23
front/src/components/ui/data-grid/types.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export type DataGridColumnConfig<T> = {
|
||||||
|
name: string;
|
||||||
|
getText: (object: T) => string;
|
||||||
|
sortable?: boolean;
|
||||||
|
flex?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridSort = {
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
column: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridProps<T> = {
|
||||||
|
items: T[];
|
||||||
|
columns: DataGridColumnConfig<T>[];
|
||||||
|
} & React.ComponentPropsWithoutRef<'div'>;
|
||||||
|
|
||||||
|
export type Cat = {
|
||||||
|
name: string;
|
||||||
|
breed: string;
|
||||||
|
age: string;
|
||||||
|
color: string;
|
||||||
|
};
|
@ -1,5 +1,4 @@
|
|||||||
import CalendarIcon from '@public/images/svg/calendar.svg';
|
import CalendarIcon from '@public/images/svg/calendar.svg';
|
||||||
import { px } from '@utils/css';
|
|
||||||
import { useMissClick } from '@utils/miss-click';
|
import { useMissClick } from '@utils/miss-click';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
@ -48,6 +47,14 @@ export function DateInput({
|
|||||||
setCalendarVisible(!calendarVisible);
|
setCalendarVisible(!calendarVisible);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCalendarButtonMouseDown = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCalendarButtonMouseUp = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newDirtyDate = inputToDirtyDate(event.target.value);
|
const newDirtyDate = inputToDirtyDate(event.target.value);
|
||||||
if (newDirtyDate.length === 10) {
|
if (newDirtyDate.length === 10) {
|
||||||
@ -58,42 +65,19 @@ export function DateInput({
|
|||||||
(!minDate || date >= minDate) &&
|
(!minDate || date >= minDate) &&
|
||||||
(!maxDate || date <= maxDate)
|
(!maxDate || date <= maxDate)
|
||||||
) {
|
) {
|
||||||
onChange?.(newValue);
|
onChange(newValue);
|
||||||
} else {
|
} else {
|
||||||
onChange?.('');
|
onChange('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDirtyDate(newDirtyDate);
|
setDirtyDate(newDirtyDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCalendarChange = (newValue: string) => {
|
const handleCalendarChange = (newValue: string) => {
|
||||||
onChange?.(newValue);
|
onChange(newValue);
|
||||||
setCalendarVisible(false);
|
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 (
|
return (
|
||||||
<div className={styles.wrapper} ref={wrapperRef}>
|
<div className={styles.wrapper} ref={wrapperRef}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -104,7 +88,12 @@ export function DateInput({
|
|||||||
wrapper={{ ref: inputWrapperRef }}
|
wrapper={{ ref: inputWrapperRef }}
|
||||||
rightNode={
|
rightNode={
|
||||||
<>
|
<>
|
||||||
<IconButton scale={scale} onClick={handleCalendarButtonClick}>
|
<IconButton
|
||||||
|
scale={scale}
|
||||||
|
onClick={handleCalendarButtonClick}
|
||||||
|
onMouseDown={handleCalendarButtonMouseDown}
|
||||||
|
onMouseUp={handleCalendarButtonMouseUp}
|
||||||
|
>
|
||||||
<CalendarIcon />
|
<CalendarIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{rightNode}
|
{rightNode}
|
||||||
@ -113,7 +102,10 @@ export function DateInput({
|
|||||||
/>
|
/>
|
||||||
<Popover
|
<Popover
|
||||||
visible={calendarVisible}
|
visible={calendarVisible}
|
||||||
calcStyles={calcPopoverStyles}
|
anchorRef={wrapperRef}
|
||||||
|
position="bottom"
|
||||||
|
horizontalAlign="center"
|
||||||
|
flip
|
||||||
element={
|
element={
|
||||||
<div className={styles.calendarWrapper} ref={calendarWrapperRef}>
|
<div className={styles.calendarWrapper} ref={calendarWrapperRef}>
|
||||||
<Calendar
|
<Calendar
|
||||||
|
@ -2,7 +2,7 @@ import { TextInputProps } from '../text-input';
|
|||||||
|
|
||||||
export type DateInputProps = {
|
export type DateInputProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
max?: string;
|
max?: string;
|
||||||
min?: string;
|
min?: string;
|
||||||
} & Omit<TextInputProps, 'type' | 'value' | 'onChange'>;
|
} & Omit<TextInputProps, 'type' | 'value' | 'onChange'>;
|
||||||
|
78
front/src/components/ui/file-uploader/component.tsx
Normal file
78
front/src/components/ui/file-uploader/component.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import UploadIcon from '@public/images/svg/upload.svg';
|
||||||
|
import { getFileExtension } from '@utils/file';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
|
||||||
|
import { Ripple } from '../animation';
|
||||||
|
import { Label } from '../label';
|
||||||
|
import { RawButton, RawInput } from '../raw';
|
||||||
|
import { Span } from '../span';
|
||||||
|
import styles from './style.module.scss';
|
||||||
|
import { FileUploaderProps } from './types';
|
||||||
|
|
||||||
|
export function FileUploader({
|
||||||
|
extensions,
|
||||||
|
onChange,
|
||||||
|
scale = 'm',
|
||||||
|
label = {},
|
||||||
|
input = {},
|
||||||
|
...props
|
||||||
|
}: FileUploaderProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const uploaderClassName = clsx(styles.uploader, styles[scale]);
|
||||||
|
|
||||||
|
const handleChange = (files: FileList) => {
|
||||||
|
if (!files || !onChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const array = Array.from(files);
|
||||||
|
const filtered = extensions
|
||||||
|
? array.filter((file) => extensions.includes(getFileExtension(file)))
|
||||||
|
: array;
|
||||||
|
onChange(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleButtonClick = () => {
|
||||||
|
inputRef.current.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
handleChange(event.target.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleChange(event.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label scale={scale} {...label}>
|
||||||
|
<RawButton
|
||||||
|
className={uploaderClassName}
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<UploadIcon className={styles.icon} />
|
||||||
|
<Span color="t300" scale={scale}>
|
||||||
|
Drag and drop file here or click to upload
|
||||||
|
</Span>
|
||||||
|
<RawInput
|
||||||
|
type="file"
|
||||||
|
className={styles.input}
|
||||||
|
ref={inputRef}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
accept={extensions && extensions.map((ext) => `.${ext}`).join(',')}
|
||||||
|
{...input}
|
||||||
|
/>
|
||||||
|
<Ripple />
|
||||||
|
</RawButton>
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
1
front/src/components/ui/file-uploader/index.ts
Normal file
1
front/src/components/ui/file-uploader/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { FileUploader } from './component';
|
14
front/src/components/ui/file-uploader/preview.tsx
Normal file
14
front/src/components/ui/file-uploader/preview.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { PreviewArticle } from '@components/ui/preview';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { FileUploader } from './component';
|
||||||
|
|
||||||
|
export function FileUploaderPreview() {
|
||||||
|
return (
|
||||||
|
<PreviewArticle title="FileUploader">
|
||||||
|
<FileUploader scale="s" label={{ text: 'File uploader' }} />
|
||||||
|
<FileUploader scale="m" label={{ text: 'File uploader' }} />
|
||||||
|
<FileUploader scale="l" label={{ text: 'File uploader' }} />
|
||||||
|
</PreviewArticle>
|
||||||
|
);
|
||||||
|
}
|
68
front/src/components/ui/file-uploader/style.module.scss
Normal file
68
front/src/components/ui/file-uploader/style.module.scss
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
|
.uploader {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px dashed var(--clr-border-200);
|
||||||
|
background-color: var(--clr-layer-300);
|
||||||
|
box-shadow: 0px 2px 2px var(--clr-shadow-100);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--td-100) ease-in-out;
|
||||||
|
|
||||||
|
&:not(.wrapperFocus):hover {
|
||||||
|
background-color: var(--clr-layer-300-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
fill: var(--clr-text-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
$padding: 10px 16px;
|
||||||
|
$border-radius: 8px;
|
||||||
|
$font-size: 12px;
|
||||||
|
$icon-size: 24px;
|
||||||
|
$gap: 5px;
|
||||||
|
|
||||||
|
.s {
|
||||||
|
padding: $padding;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
font-size: $font-size;
|
||||||
|
gap: $gap;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: $icon-size;
|
||||||
|
height: $icon-size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m {
|
||||||
|
padding: f.m($padding);
|
||||||
|
border-radius: f.m($border-radius);
|
||||||
|
font-size: f.m($font-size);
|
||||||
|
gap: f.m($gap);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: f.m($icon-size);
|
||||||
|
height: f.m($icon-size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.l {
|
||||||
|
padding: f.l($padding);
|
||||||
|
border-radius: f.l($border-radius);
|
||||||
|
font-size: f.l($font-size);
|
||||||
|
gap: f.l($gap);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: f.l($icon-size);
|
||||||
|
height: f.l($icon-size);
|
||||||
|
}
|
||||||
|
}
|
11
front/src/components/ui/file-uploader/types.ts
Normal file
11
front/src/components/ui/file-uploader/types.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { LabelProps } from '../label';
|
||||||
|
import { RawInputProps } from '../raw';
|
||||||
|
import { Scale } from '../types';
|
||||||
|
|
||||||
|
export type FileUploaderProps = {
|
||||||
|
extensions?: string[];
|
||||||
|
onChange?: (value: File[]) => void;
|
||||||
|
scale?: Scale;
|
||||||
|
label?: LabelProps;
|
||||||
|
input?: Omit<RawInputProps, 'type'>;
|
||||||
|
} & Omit<React.ComponentPropsWithoutRef<'button'>, 'onChange'>;
|
@ -1,3 +1,5 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -22,20 +24,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$size: 26px;
|
||||||
|
$padding: 4px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
width: 27px;
|
width: $size;
|
||||||
height: 27px;
|
height: $size;
|
||||||
padding: 4px;
|
padding: $padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
width: 35px;
|
width: f.m($size);
|
||||||
height: 35px;
|
height: f.m($size);
|
||||||
padding: 6px;
|
padding: f.m($padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
width: 43px;
|
width: f.l($size);
|
||||||
height: 43px;
|
height: f.l($size);
|
||||||
padding: 8px;
|
padding: f.l($padding);
|
||||||
}
|
}
|
||||||
|
40
front/src/components/ui/image-file-manager/component.tsx
Normal file
40
front/src/components/ui/image-file-manager/component.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { FileUploader } from '../file-uploader';
|
||||||
|
import { ImageViewer } from '../image-viewer';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { ImageFileManagerProps } from './types';
|
||||||
|
|
||||||
|
export function ImageFileManager({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
scale = 'm',
|
||||||
|
label = {},
|
||||||
|
}: ImageFileManagerProps) {
|
||||||
|
const managerClassName = clsx(styles.manager, styles[scale]);
|
||||||
|
|
||||||
|
const handleFileUploaderChange = (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange?.(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={managerClassName}>
|
||||||
|
<FileUploader
|
||||||
|
scale={scale}
|
||||||
|
label={label}
|
||||||
|
onChange={handleFileUploaderChange}
|
||||||
|
extensions={['png', 'jpg', 'jpeg']}
|
||||||
|
/>
|
||||||
|
<ImageViewer file={value} scale={scale} onClear={handleClear} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
front/src/components/ui/image-file-manager/index.ts
Normal file
1
front/src/components/ui/image-file-manager/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ImageFileManager } from './component';
|
31
front/src/components/ui/image-file-manager/preview.tsx
Normal file
31
front/src/components/ui/image-file-manager/preview.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { PreviewArticle } from '@components/ui/preview';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { ImageFileManager } from './component';
|
||||||
|
|
||||||
|
export function ImageFileManagerPreview() {
|
||||||
|
const [value, setValue] = useState<File>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewArticle title="ImageFileManager">
|
||||||
|
<ImageFileManager
|
||||||
|
scale="s"
|
||||||
|
label={{ text: 'Image uploader' }}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
<ImageFileManager
|
||||||
|
scale="m"
|
||||||
|
label={{ text: 'Image uploader' }}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
<ImageFileManager
|
||||||
|
scale="l"
|
||||||
|
label={{ text: 'Image uploader' }}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
</PreviewArticle>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
|
.manager {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
$gap: 12px;
|
||||||
|
|
||||||
|
.s {
|
||||||
|
gap: $gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m {
|
||||||
|
gap: f.m($gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.l {
|
||||||
|
gap: f.l($gap);
|
||||||
|
}
|
9
front/src/components/ui/image-file-manager/types.ts
Normal file
9
front/src/components/ui/image-file-manager/types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { LabelProps } from '../label';
|
||||||
|
import { Scale } from '../types';
|
||||||
|
|
||||||
|
export type ImageFileManagerProps = {
|
||||||
|
value?: File | null;
|
||||||
|
onChange?: (value: File | null) => void;
|
||||||
|
scale?: Scale;
|
||||||
|
label?: LabelProps;
|
||||||
|
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange'>;
|
32
front/src/components/ui/image-viewer/component.tsx
Normal file
32
front/src/components/ui/image-viewer/component.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import DeleteIcon from '@public/images/svg/delete.svg';
|
||||||
|
import { formatFileSize } from '@utils/file';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { IconButton } from '../icon-button';
|
||||||
|
import { Span } from '../span';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { ImageViewerProps } from './types';
|
||||||
|
|
||||||
|
export function ImageViewer({ file, scale = 'm', onClear }: ImageViewerProps) {
|
||||||
|
const viewerClassName = clsx(styles.viewer, styles[scale]);
|
||||||
|
return (
|
||||||
|
<div className={viewerClassName}>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<img className={styles.image} src={URL.createObjectURL(file)} />
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<Span scale={scale}>{formatFileSize(file.size)}</Span>
|
||||||
|
<IconButton scale={scale} onClick={onClear}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<Span scale={scale}>File not uploaded</Span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
front/src/components/ui/image-viewer/index.ts
Normal file
1
front/src/components/ui/image-viewer/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ImageViewer } from './component';
|
56
front/src/components/ui/image-viewer/styles.module.scss
Normal file
56
front/src/components/ui/image-viewer/styles.module.scss
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
|
.viewer {
|
||||||
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0px 2px 2px var(--clr-shadow-100);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--clr-layer-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: var(--clr-layer-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
$padding: 10px 16px;
|
||||||
|
$border-radius: 8px;
|
||||||
|
|
||||||
|
.s {
|
||||||
|
border-radius: $border-radius;
|
||||||
|
|
||||||
|
.placeholder,
|
||||||
|
.footer {
|
||||||
|
padding: $padding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m {
|
||||||
|
border-radius: f.m($border-radius);
|
||||||
|
|
||||||
|
.placeholder,
|
||||||
|
.footer {
|
||||||
|
padding: f.m($padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.l {
|
||||||
|
border-radius: f.l($border-radius);
|
||||||
|
|
||||||
|
.placeholder,
|
||||||
|
.footer {
|
||||||
|
padding: f.l($padding);
|
||||||
|
}
|
||||||
|
}
|
7
front/src/components/ui/image-viewer/types.ts
Normal file
7
front/src/components/ui/image-viewer/types.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Scale } from '../types';
|
||||||
|
|
||||||
|
export type ImageViewerProps = {
|
||||||
|
file?: File;
|
||||||
|
scale?: Scale;
|
||||||
|
onClear?: () => void;
|
||||||
|
};
|
@ -1,13 +1,18 @@
|
|||||||
|
export { Autocomplete } from './autocomplete';
|
||||||
export { Button } from './button';
|
export { Button } from './button';
|
||||||
export { Checkbox } from './checkbox';
|
export { Checkbox } from './checkbox';
|
||||||
export { CheckboxGroup } from './checkbox-group';
|
export { CheckboxGroup } from './checkbox-group';
|
||||||
export { DateInput } from './date-input';
|
export { DateInput } from './date-input';
|
||||||
|
export { FileUploader } from './file-uploader';
|
||||||
export { Heading } from './heading';
|
export { Heading } from './heading';
|
||||||
export { IconButton } from './icon-button';
|
export { IconButton } from './icon-button';
|
||||||
|
export { ImageFileManager } from './image-file-manager';
|
||||||
export { Menu } from './menu';
|
export { Menu } from './menu';
|
||||||
|
export { NumberInput } from './number-input';
|
||||||
export { Paragraph } from './paragraph';
|
export { Paragraph } from './paragraph';
|
||||||
export { PasswordInput } from './password-input';
|
export { PasswordInput } from './password-input';
|
||||||
export { RadioGroup } from './radio-group';
|
export { RadioGroup } from './radio-group';
|
||||||
export { Select } from './select';
|
export { Select } from './select';
|
||||||
export { Span } from './span';
|
export { Span } from './span';
|
||||||
|
export { TextArea } from './text-area';
|
||||||
export { TextInput } from './text-input';
|
export { TextInput } from './text-input';
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -26,29 +28,33 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$border-radius: 8px;
|
||||||
|
$padding: 9px;
|
||||||
|
$font-size: 12px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
border-radius: 8px;
|
border-radius: $border-radius;
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
padding: 9px;
|
padding: $padding;
|
||||||
font-size: 12px;
|
font-size: $font-size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
border-radius: 10px;
|
border-radius: f.m($border-radius);
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
padding: 13px;
|
padding: f.m($padding);
|
||||||
font-size: 16px;
|
font-size: f.m($font-size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
border-radius: 12px;
|
border-radius: f.l($border-radius);
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
padding: 17px;
|
padding: f.l($padding);
|
||||||
font-size: 20px;
|
font-size: f.l($font-size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
38
front/src/components/ui/number-input/component.tsx
Normal file
38
front/src/components/ui/number-input/component.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { TextInput } from '../text-input';
|
||||||
|
import { NumberInputProps } from './types';
|
||||||
|
|
||||||
|
export function NumberInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
float = false,
|
||||||
|
negative = false,
|
||||||
|
...props
|
||||||
|
}: NumberInputProps) {
|
||||||
|
const extractNumber = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { value } = event.target;
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
let pattern = '';
|
||||||
|
if (negative) {
|
||||||
|
pattern += '-?';
|
||||||
|
}
|
||||||
|
pattern += '\\d*';
|
||||||
|
if (float) {
|
||||||
|
pattern += '\\.?\\d*';
|
||||||
|
}
|
||||||
|
return value.match(pattern)?.[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const num = extractNumber(event);
|
||||||
|
if (num === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange?.(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <TextInput value={value ?? ''} onChange={handleChange} {...props} />;
|
||||||
|
}
|
1
front/src/components/ui/number-input/index.tsx
Normal file
1
front/src/components/ui/number-input/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { NumberInput } from './component';
|
39
front/src/components/ui/number-input/preview.tsx
Normal file
39
front/src/components/ui/number-input/preview.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { PreviewArticle } from '@components/ui/preview';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { NumberInput } from './component';
|
||||||
|
|
||||||
|
export function NumberInputPreview() {
|
||||||
|
const [value1, setValue1] = useState<string>('');
|
||||||
|
const [value2, setValue2] = useState<string>('');
|
||||||
|
const [value3, setValue3] = useState<string>('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewArticle title="NumberInput">
|
||||||
|
<NumberInput
|
||||||
|
scale="s"
|
||||||
|
label={{ text: 'Number' }}
|
||||||
|
value={value1}
|
||||||
|
onChange={setValue1}
|
||||||
|
negative
|
||||||
|
float
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
scale="m"
|
||||||
|
label={{ text: 'Number' }}
|
||||||
|
value={value2}
|
||||||
|
onChange={setValue2}
|
||||||
|
negative
|
||||||
|
float
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
scale="l"
|
||||||
|
label={{ text: 'Number' }}
|
||||||
|
value={value3}
|
||||||
|
onChange={setValue3}
|
||||||
|
negative
|
||||||
|
float
|
||||||
|
/>
|
||||||
|
</PreviewArticle>
|
||||||
|
);
|
||||||
|
}
|
8
front/src/components/ui/number-input/types.ts
Normal file
8
front/src/components/ui/number-input/types.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { TextInputProps } from '../text-input';
|
||||||
|
|
||||||
|
export type NumberInputProps = {
|
||||||
|
float?: boolean;
|
||||||
|
negative?: boolean;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
} & Omit<TextInputProps, 'type' | 'value' | 'onChange'>;
|
@ -1,17 +1,21 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.paragraph {
|
.paragraph {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$font-size: 12px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
font-size: 12px;
|
font-size: $font-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
font-size: 16px;
|
font-size: f.m($font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
font-size: 20px;
|
font-size: f.l($font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.t100 {
|
.t100 {
|
||||||
|
@ -10,8 +10,16 @@ import { createPortal } from 'react-dom';
|
|||||||
import { Fade } from '../animation';
|
import { Fade } from '../animation';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import { PopoverProps } from './types';
|
import { PopoverProps } from './types';
|
||||||
|
import { calcFadeStyles } from './utils';
|
||||||
|
|
||||||
export function Popover({ element, visible, calcStyles }: PopoverProps) {
|
export function Popover({
|
||||||
|
visible,
|
||||||
|
anchorRef,
|
||||||
|
position,
|
||||||
|
horizontalAlign,
|
||||||
|
element,
|
||||||
|
flip = false,
|
||||||
|
}: PopoverProps) {
|
||||||
const elementRef = useRef<HTMLElement | null>(null);
|
const elementRef = useRef<HTMLElement | null>(null);
|
||||||
const fadeRef = useRef<HTMLDivElement | null>(null);
|
const fadeRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [elementRect, setElementRect] = useState<DOMRect | null>(null);
|
const [elementRect, setElementRect] = useState<DOMRect | null>(null);
|
||||||
@ -25,7 +33,14 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updateStyles = () => {
|
const updateStyles = () => {
|
||||||
Object.assign(fadeRef.current.style, calcStyles(elementRect));
|
const style = calcFadeStyles(
|
||||||
|
elementRect,
|
||||||
|
anchorRef,
|
||||||
|
position,
|
||||||
|
horizontalAlign,
|
||||||
|
flip,
|
||||||
|
);
|
||||||
|
Object.assign(fadeRef.current.style, style);
|
||||||
};
|
};
|
||||||
window.addEventListener('scroll', updateStyles, true);
|
window.addEventListener('scroll', updateStyles, true);
|
||||||
window.addEventListener('resize', updateStyles);
|
window.addEventListener('resize', updateStyles);
|
||||||
@ -36,7 +51,10 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) {
|
|||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
if (elementRect === null) {
|
if (elementRect === null) {
|
||||||
return cloneElement(element, { ref: elementRef });
|
return cloneElement(element, {
|
||||||
|
ref: elementRef,
|
||||||
|
style: { position: 'absolute' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@ -44,7 +62,13 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) {
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
className={styles.fade}
|
className={styles.fade}
|
||||||
ref={fadeRef}
|
ref={fadeRef}
|
||||||
style={{ ...calcStyles(elementRect) }}
|
style={calcFadeStyles(
|
||||||
|
elementRect,
|
||||||
|
anchorRef,
|
||||||
|
position,
|
||||||
|
horizontalAlign,
|
||||||
|
flip,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{element}
|
{element}
|
||||||
</Fade>,
|
</Fade>,
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
export { Popover } from './component';
|
|
||||||
export { type PopoverProps, type PopoverStyles } from './types';
|
|
1
front/src/components/ui/popover/index.tsx
Normal file
1
front/src/components/ui/popover/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Popover } from './component';
|
@ -1,5 +1,4 @@
|
|||||||
.fade {
|
.fade {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
z-index: 2;
|
||||||
left: 0;
|
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { ReactElement } from 'react';
|
export type PopoverPosition = 'top' | 'bottom';
|
||||||
|
|
||||||
export type PopoverStyles = {
|
export type PopoverHorizontalAlign = 'left' | 'right' | 'center' | 'stretch';
|
||||||
left?: string;
|
|
||||||
top?: string;
|
|
||||||
width?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PopoverProps = {
|
export type PopoverProps = {
|
||||||
element: ReactElement;
|
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
calcStyles: (elementRect: DOMRect | null) => PopoverStyles;
|
anchorRef: React.MutableRefObject<HTMLElement>;
|
||||||
|
position: PopoverPosition;
|
||||||
|
horizontalAlign: PopoverHorizontalAlign;
|
||||||
|
element: React.ReactElement;
|
||||||
|
flip?: boolean;
|
||||||
};
|
};
|
||||||
|
92
front/src/components/ui/popover/utils.ts
Normal file
92
front/src/components/ui/popover/utils.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { px } from '@utils/css';
|
||||||
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
import { PopoverHorizontalAlign, PopoverPosition } from './types';
|
||||||
|
|
||||||
|
const applyPositionTop = (
|
||||||
|
anchorRect: DOMRect,
|
||||||
|
elementRect: DOMRect,
|
||||||
|
styles: CSSProperties,
|
||||||
|
) => {
|
||||||
|
styles.top = px(anchorRect.bottom - anchorRect.height - elementRect.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPositionBottom = (anchorRect: DOMRect, styles: CSSProperties) => {
|
||||||
|
styles.top = px(anchorRect.bottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPosition = (
|
||||||
|
elementRect: DOMRect,
|
||||||
|
anchorRect: DOMRect,
|
||||||
|
position: PopoverPosition,
|
||||||
|
flip: boolean,
|
||||||
|
styles: CSSProperties,
|
||||||
|
) => {
|
||||||
|
if (position === 'bottom') {
|
||||||
|
if (flip) {
|
||||||
|
const bottomSpace = window.innerHeight - anchorRect.bottom;
|
||||||
|
if (bottomSpace >= elementRect.height) {
|
||||||
|
applyPositionBottom(anchorRect, styles);
|
||||||
|
} else {
|
||||||
|
applyPositionTop(anchorRect, elementRect, styles);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applyPositionBottom(anchorRect, styles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position === 'top') {
|
||||||
|
if (flip) {
|
||||||
|
const topSpace = anchorRect.top;
|
||||||
|
if (topSpace >= elementRect.height) {
|
||||||
|
applyPositionTop(anchorRect, elementRect, styles);
|
||||||
|
} else {
|
||||||
|
applyPositionBottom(anchorRect, styles);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applyPositionTop(anchorRect, elementRect, styles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyHorizontalAlign = (
|
||||||
|
elementRect: DOMRect,
|
||||||
|
anchorRect: DOMRect,
|
||||||
|
horizontalAlign: PopoverHorizontalAlign,
|
||||||
|
styles: CSSProperties,
|
||||||
|
) => {
|
||||||
|
if (horizontalAlign === 'left') {
|
||||||
|
styles.left = px(anchorRect.left);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (horizontalAlign === 'right') {
|
||||||
|
styles.left = px(anchorRect.left + anchorRect.width - elementRect.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (horizontalAlign === 'center') {
|
||||||
|
styles.left = px(
|
||||||
|
anchorRect.left + (anchorRect.width - elementRect.width) / 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (horizontalAlign === 'stretch') {
|
||||||
|
styles.left = px(anchorRect.left);
|
||||||
|
styles.width = px(anchorRect.width);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calcFadeStyles = (
|
||||||
|
elementRect: DOMRect,
|
||||||
|
anchorRef: React.MutableRefObject<HTMLElement>,
|
||||||
|
position: PopoverPosition,
|
||||||
|
horizontalAlign: PopoverHorizontalAlign,
|
||||||
|
flip: boolean,
|
||||||
|
): CSSProperties => {
|
||||||
|
const anchorRect = anchorRef.current.getBoundingClientRect();
|
||||||
|
const styles: CSSProperties = {};
|
||||||
|
|
||||||
|
applyPosition(elementRect, anchorRect, position, flip, styles);
|
||||||
|
applyHorizontalAlign(elementRect, anchorRect, horizontalAlign, styles);
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
};
|
@ -1,22 +1,26 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.checkBoxGroup {
|
.checkBoxGroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$margin-bottom: 4px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 3px;
|
margin-bottom: $margin-bottom;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 5px;
|
margin-bottom: f.m($margin-bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 7px;
|
margin-bottom: f.l($margin-bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -24,7 +26,7 @@
|
|||||||
.radio {
|
.radio {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid var(--clr-border-200);
|
border: 2px solid var(--clr-border-200);
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
background-color: var(--clr-layer-300);
|
background-color: var(--clr-layer-300);
|
||||||
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
||||||
@ -55,32 +57,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$padding-outer: 4px;
|
||||||
|
$size: 16px;
|
||||||
|
$padding-inner: 4px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
padding: 3px;
|
padding: $padding-outer;
|
||||||
|
|
||||||
.radio {
|
.radio {
|
||||||
width: 16px;
|
width: $size;
|
||||||
height: 16px;
|
height: $size;
|
||||||
padding: 4px;
|
padding: $padding-inner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
padding: 4px;
|
padding: f.m($padding-outer);
|
||||||
|
|
||||||
.radio {
|
.radio {
|
||||||
width: 20px;
|
width: f.m($size);
|
||||||
height: 20px;
|
height: f.m($size);
|
||||||
padding: 5px;
|
padding: f.m($padding-inner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
padding: 5px;
|
padding: f.l($padding-outer);
|
||||||
|
|
||||||
.radio {
|
.radio {
|
||||||
width: 24px;
|
width: f.l($size);
|
||||||
height: 24px;
|
height: f.l($size);
|
||||||
padding: 6px;
|
padding: f.l($padding-inner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
|
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
|
||||||
import { px } from '@utils/css';
|
|
||||||
import { useMissClick } from '@utils/miss-click';
|
import { useMissClick } from '@utils/miss-click';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, {
|
import React, {
|
||||||
@ -52,25 +51,6 @@ function SelectInner<T>(
|
|||||||
onChange?.(option);
|
onChange?.(option);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calcPopoverStyles = (menuRect: DOMRect) => {
|
|
||||||
if (menuRect === null) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputWrapperRect = inputWrapperRef.current.getBoundingClientRect();
|
|
||||||
const { width, left, bottom, top } = inputWrapperRect;
|
|
||||||
|
|
||||||
const bottomSpace = window.innerHeight - bottom;
|
|
||||||
const popoverTop =
|
|
||||||
bottomSpace >= menuRect.height ? bottom : top - menuRect.height;
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: px(width),
|
|
||||||
left: px(left),
|
|
||||||
top: px(popoverTop),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={selectClassName} ref={selectRef}>
|
<div className={selectClassName} ref={selectRef}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -90,7 +70,10 @@ function SelectInner<T>(
|
|||||||
/>
|
/>
|
||||||
<Popover
|
<Popover
|
||||||
visible={menuVisible}
|
visible={menuVisible}
|
||||||
calcStyles={calcPopoverStyles}
|
anchorRef={selectRef}
|
||||||
|
position="bottom"
|
||||||
|
horizontalAlign="stretch"
|
||||||
|
flip
|
||||||
element={
|
element={
|
||||||
<div className={styles.menuWrapper}>
|
<div className={styles.menuWrapper}>
|
||||||
<Menu
|
<Menu
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.select {
|
.select {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
@ -22,35 +25,38 @@
|
|||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$padding-right: 7px;
|
||||||
|
$size: 10px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
.iconBox {
|
.iconBox {
|
||||||
padding-right: 7px;
|
padding-right: $padding-right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 10px;
|
width: $size;
|
||||||
height: 10px;
|
height: $size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
.iconBox {
|
.iconBox {
|
||||||
padding-right: 9px;
|
padding-right: f.m($padding-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 12px;
|
width: f.m($size);
|
||||||
height: 12px;
|
height: f.m($size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
.iconBox {
|
.iconBox {
|
||||||
padding-right: 11px;
|
padding-right: f.l($padding-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 14px;
|
width: f.l($size);
|
||||||
height: 14px;
|
height: f.l($size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { LabelProps } from '../label';
|
import { LabelProps } from '../label';
|
||||||
|
import { TextInputProps } from '../text-input';
|
||||||
import { Scale } from '../types';
|
import { Scale } from '../types';
|
||||||
|
|
||||||
export type SelectProps<T> = {
|
export type SelectProps<T> = {
|
||||||
@ -11,4 +12,5 @@ export type SelectProps<T> = {
|
|||||||
label?: LabelProps;
|
label?: LabelProps;
|
||||||
name?: string;
|
name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
input?: TextInputProps;
|
||||||
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
|
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
|
$font-size: 12px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
font-size: 12px;
|
font-size: $font-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
font-size: 16px;
|
font-size: f.m($font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
font-size: 20px;
|
font-size: f.l($font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.t100 {
|
.t100 {
|
||||||
|
20
front/src/components/ui/text-area/component.tsx
Normal file
20
front/src/components/ui/text-area/component.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Label } from '../label';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { TextAreaProps } from './types';
|
||||||
|
|
||||||
|
export function TextArea({
|
||||||
|
scale = 'm',
|
||||||
|
label = {},
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: TextAreaProps) {
|
||||||
|
const textAreaClassName = clsx(styles.textarea, styles[scale], className);
|
||||||
|
return (
|
||||||
|
<Label scale={scale} {...label}>
|
||||||
|
<textarea {...props} className={textAreaClassName} />
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
1
front/src/components/ui/text-area/index.ts
Normal file
1
front/src/components/ui/text-area/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { TextArea } from './component';
|
14
front/src/components/ui/text-area/preview.tsx
Normal file
14
front/src/components/ui/text-area/preview.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { PreviewArticle } from '@components/ui/preview';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { TextArea } from './component';
|
||||||
|
|
||||||
|
export function TextAreaPreview() {
|
||||||
|
return (
|
||||||
|
<PreviewArticle title="TextArea">
|
||||||
|
<TextArea scale="s" label={{ text: 'Text area' }} />
|
||||||
|
<TextArea scale="m" label={{ text: 'Text area' }} />
|
||||||
|
<TextArea scale="l" label={{ text: 'Text area' }} />
|
||||||
|
</PreviewArticle>
|
||||||
|
);
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user