[test-entity]: front

This commit is contained in:
it-is-not-alright 2024-11-20 00:06:19 +04:00
parent 4d40a2cacb
commit ee6bbdca8b
164 changed files with 1761 additions and 863 deletions

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
<path d="M0.49,2.99c0-0.13,0.05-0.26,0.15-0.35l2-2c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71L1.71,2.99l1.65,1.65
c0.2,0.2,0.2,0.51,0,0.71s-0.51,0.2-0.71,0l-2-2C0.54,3.25,0.49,3.12,0.49,2.99z"/>
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

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

After

Width:  |  Height:  |  Size: 558 B

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<path d="M1.23,5.47L3,3.71l1.77,1.77c0.2,0.2,0.51,0.2,0.71,0c0.2-0.2,0.2-0.51,0-0.71L3.71,3l1.77-1.77c0.2-0.2,0.2-0.51,0-0.71
c-0.2-0.2-0.51-0.2-0.71,0L3,2.29L1.23,0.53c-0.2-0.2-0.51-0.2-0.71,0s-0.2,0.51,0,0.71L2.29,3L0.53,4.77c-0.2,0.2-0.2,0.51,0,0.71
C0.72,5.67,1.04,5.67,1.23,5.47z"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<line class="st0" x1="0.5" y1="1" x2="5.5" y2="1"/>
<path d="M5.5,1.5h-5C0.22,1.5,0,1.28,0,1s0.22-0.5,0.5-0.5h5C5.78,0.5,6,0.72,6,1S5.78,1.5,5.5,1.5z"/>
</g>
<g>
<line class="st0" x1="0.5" y1="3" x2="5.5" y2="3"/>
<path d="M5.5,3.5h-5C0.22,3.5,0,3.28,0,3s0.22-0.5,0.5-0.5h5C5.78,2.5,6,2.72,6,3S5.78,3.5,5.5,3.5z"/>
</g>
<g>
<line class="st0" x1="0.5" y1="5" x2="5.5" y2="5"/>
<path d="M5.5,5.5h-5C0.22,5.5,0,5.28,0,5s0.22-0.5,0.5-0.5h5C5.78,4.5,6,4.72,6,5S5.78,5.5,5.5,5.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 914 B

View File

@ -1,2 +1,2 @@
export * from './service'; export * from './service';
export { type Park, type TurbineType } from './types'; export * from './types';

View File

@ -1,30 +1,38 @@
import { ParkFormValues, TurbineTypeFormValues } from '@components/ux'; import { ApiResponse } from '@api/types';
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
import { api } from '../api'; import { api } from '../api';
import { WIND_ENDPOINTS } from './constants'; import { WIND_ENDPOINTS } from './constants';
import { Park, ParkTurbine, TurbineType } from './types'; import { Park, ParkTurbine, ParkWithTurbines, TurbineType } from './types';
import { packParkFormValues, packTurbineTypeFormValues } from './utils'; import { packTurbineTypes } from './utils';
export const getTurbineTypes = () => { export const getTurbineTypes = () => {
return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines); return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines);
}; };
export const createTurbineTypes = (values: Partial<TurbineTypeFormValues>) => { export const getTurbineType = (id: string) => {
return api.post( const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.get<TurbineType>(url);
};
export const createTurbineTypes = (
formValues: Partial<TurbineTypeFormValues>,
) => {
return api.post<TurbineType>(
WIND_ENDPOINTS.turbineType, WIND_ENDPOINTS.turbineType,
packTurbineTypeFormValues(values), packTurbineTypes(formValues),
); );
}; };
export const editTurbineTypes = ( export const editTurbineTypes = (
values: Partial<TurbineTypeFormValues>, formValues: Partial<TurbineTypeFormValues>,
id: number, id: string,
) => { ) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`; const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.put(url, packTurbineTypeFormValues(values)); return api.put<TurbineType>(url, packTurbineTypes(formValues));
}; };
export const deleteTurbineTypes = (id: number) => { export const deleteTurbineType = (id: number) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`; const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.delete(url); return api.delete(url);
}; };
@ -33,16 +41,25 @@ export const getParks = () => {
return api.get<Park[]>(WIND_ENDPOINTS.parks); return api.get<Park[]>(WIND_ENDPOINTS.parks);
}; };
export const createPark = (values: Partial<ParkFormValues>) => { export const getPark = (id: string) => {
return api.post(WIND_ENDPOINTS.park, packParkFormValues(values));
};
export const editPark = (values: Partial<ParkFormValues>, id: number) => {
const url = `${WIND_ENDPOINTS.park}/${id}`; const url = `${WIND_ENDPOINTS.park}/${id}`;
return api.put(url, packParkFormValues(values)); return api.get<Park>(url);
}; };
export const getParkTurines = (id: number) => { export const getParkTurbines = (id: string) => {
const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`; const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
return api.get<ParkTurbine[]>(url); return api.get<ParkTurbine[]>(url);
}; };
export const getParkWithTurbines = async (
id: string,
): Promise<ApiResponse<ParkWithTurbines>> => {
const parkURL = `${WIND_ENDPOINTS.park}/${id}`;
const turbinesURL = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
const parkPesponse = await api.get<Park>(parkURL);
const turbinesResponse = await api.get<ParkTurbine[]>(turbinesURL);
return {
data: { ...parkPesponse.data, turbines: turbinesResponse.data },
error: parkPesponse.error || turbinesResponse.error || null,
};
};

View File

@ -13,10 +13,20 @@ export type Park = {
}; };
export type ParkTurbine = { export type ParkTurbine = {
windParkId: number; id: number;
turbineId: number; name: string;
height: number;
bladeLength: number;
xOffset: number; xOffset: number;
yOffset: number; yOffset: number;
angle: number; angle: number;
comment: string; comment: string;
}; };
export type ParkWithTurbines = {
id: number;
name: string;
centerLatitude: number;
centerLongitude: number;
turbines: ParkTurbine[];
};

View File

@ -1,19 +1,9 @@
import { ParkFormValues, TurbineTypeFormValues } from '@components/ux'; import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
export const packTurbineTypeFormValues = ( export const packTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
values: Partial<TurbineTypeFormValues>,
) => {
return { return {
Name: values.name ?? '', Name: values.name ?? '',
Height: parseInt(values.height ?? '0'), Height: parseInt(values.height ?? '0'),
BladeLength: parseInt(values.bladeLength ?? '0'), BladeLength: parseInt(values.bladeLength ?? '0'),
}; };
}; };
export const packParkFormValues = (values: Partial<ParkFormValues>) => {
return {
Name: values.name ?? '',
CenterLatitude: parseInt(values.centerLatitude ?? '0'),
CenterLongitude: parseInt(values.centerLongitude ?? '0'),
};
};

View File

@ -0,0 +1,5 @@
@mixin on-mobile {
@media (width <= 800px) {
@content;
}
}

View File

@ -2,14 +2,12 @@
color-scheme: light; color-scheme: light;
--clr-primary: #4176FF; --clr-primary: #4176FF;
--clr-primary-o50: #3865DA80; --clr-primary-o50: #4176FF80;
--clr-primary-hover: #638FFF; --clr-primary-hover: #638FFF;
--clr-primary-disabled: #3D68D7;
--clr-on-primary: #FFFFFF; --clr-on-primary: #FFFFFF;
--clr-secondary: #EAEAEA; --clr-secondary: #E1EAF8;
--clr-secondary-hover: #EFEFEF; --clr-secondary-hover: #E8ECF0;
--clr-secondary-disabled: #E1E1E1;
--clr-on-secondary: #0D0D0D; --clr-on-secondary: #0D0D0D;
--clr-layer-100: #EBEEF0; --clr-layer-100: #EBEEF0;
@ -20,6 +18,7 @@
--clr-text-100: #8D8D8D; --clr-text-100: #8D8D8D;
--clr-text-200: #6C7480; --clr-text-200: #6C7480;
--clr-text-300: #1D1F20; --clr-text-300: #1D1F20;
--clr-text-primary: #3865DA;
--clr-border-100: #DFDFDF; --clr-border-100: #DFDFDF;
--clr-border-200: #D8D8D8; --clr-border-200: #D8D8D8;
@ -28,6 +27,8 @@
--clr-shadow-200: #00000026; --clr-shadow-200: #00000026;
--clr-ripple: #1D1F2026; --clr-ripple: #1D1F2026;
--clr-error: #E54B4B;
} }
@mixin dark { @mixin dark {
@ -36,7 +37,7 @@
--clr-primary: #3865DA; --clr-primary: #3865DA;
--clr-primary-o50: #3865DA80; --clr-primary-o50: #3865DA80;
--clr-primary-hover: #4073F7; --clr-primary-hover: #4073F7;
--clr-primary-disabled: #2A4DA7; --clr-primary-disabled: #334570;
--clr-on-primary: #FFFFFF; --clr-on-primary: #FFFFFF;
--clr-secondary: #3F3F3F; --clr-secondary: #3F3F3F;
@ -52,6 +53,7 @@
--clr-text-100: #888888; --clr-text-100: #888888;
--clr-text-200: #C5C5C5; --clr-text-200: #C5C5C5;
--clr-text-300: #F0F0F0; --clr-text-300: #F0F0F0;
--clr-text-primary: #4176FF;
--clr-border-100: #3D3D3D; --clr-border-100: #3D3D3D;
--clr-border-200: #545454; --clr-border-200: #545454;
@ -60,4 +62,6 @@
--clr-shadow-200: #00000026; --clr-shadow-200: #00000026;
--clr-ripple: #F0F0F026; --clr-ripple: #F0F0F026;
--clr-error: #FF6363;
} }

View File

@ -2,30 +2,34 @@ import './styles.scss';
import '@public/fonts/styles.css'; import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts'; import { MainLayout } from '@components/layouts';
import { ParksPage, TurbineTypesPage } from '@components/pages'; import {
ParkPage,
ParksPage,
TurbineTypePage,
TurbineTypesPage,
} from '@components/pages';
import { ROUTES } from '@utils/route'; import { ROUTES } from '@utils/route';
import React from 'react'; import React from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
function App() { export function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<MainLayout />}> <Route element={<MainLayout />}>
<Route
index
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
/>
<Route <Route
path={ROUTES.turbineTypes.path} path={ROUTES.turbineTypes.path}
element={<TurbineTypesPage />} element={<TurbineTypesPage />}
/> />
<Route path={ROUTES.turbineType.path} element={<TurbineTypePage />} />
<Route path={ROUTES.parks.path} element={<ParksPage />} /> <Route path={ROUTES.parks.path} element={<ParksPage />} />
<Route path={ROUTES.park.path} element={<ParkPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route
path="*"
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
/>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );
} }
export default App;

View File

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

View File

@ -23,7 +23,7 @@ html[data-theme='default'] {
} }
html { html {
--td-100: 0.2s; --td-100: 0.1s;
} }
body { body {

View File

@ -0,0 +1 @@
export { MainLayout } from './main-layout';

View File

@ -1,3 +0,0 @@
import MainLayout from './main-layout';
export { MainLayout };

View File

@ -0,0 +1,22 @@
import { Header, Sidebar } from '@components/ux';
import { useDeviceType } from '@utils/device';
import React from 'react';
import { Outlet } from 'react-router-dom';
import styles from './styles.module.scss';
export function MainLayout() {
const deviceType = useDeviceType();
return (
<div className={styles.mainLayout}>
{deviceType === 'mobile' ? (
<Header className={styles.header} />
) : (
<Sidebar className={styles.sidebar} />
)}
<main className={styles.main}>
<Outlet />
</main>
</div>
);
}

View File

@ -1,20 +1 @@
import { Sidebar } from '@components/ux'; export * from './component';
import React from 'react';
import { Outlet } from 'react-router-dom';
import styles from './styles.module.scss';
function MainLayout() {
return (
<div className={styles.mainLayout}>
<Sidebar />
<main className={styles.main}>
<div className={styles.content}>
<Outlet />
</div>
</main>
</div>
);
}
export default MainLayout;

View File

@ -1,3 +1,5 @@
@use '@components/mixins.scss' as m;
.mainLayout { .mainLayout {
display: grid; display: grid;
height: 100%; height: 100%;
@ -6,16 +8,31 @@
/ auto minmax(0, 1fr); / auto minmax(0, 1fr);
} }
.main { .sidebar {
display: flex; grid-area: sidebar;
overflow: auto;
height: 100%;
justify-content: center;
} }
.content { .header {
display: grid; grid-area: header;
width: 1000px; }
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr); .main {
display: grid;
overflow: auto;
height: 100%;
grid-area: main;
grid-template-columns: 1fr minmax(0, 1000px) 1fr;
& > * {
grid-column: 2;
}
}
@include m.on-mobile {
.mainLayout {
grid-template:
'header' auto
'main' minmax(0, 1fr)
/ minmax(0, 1fr);
}
} }

View File

@ -1,2 +1,4 @@
export * from './park-page';
export * from './parks-page'; export * from './parks-page';
export * from './turbine-type-page';
export * from './turbine-types-page'; export * from './turbine-types-page';

View File

@ -0,0 +1,83 @@
import { getParkWithTurbines, ParkWithTurbines } from '@api/wind';
import { Button, Heading, NumberInput, TextInput } from '@components/ui';
import { Controller, useForm } from '@utils/form';
import { useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ParkTurbines } from './components';
import styles from './styles.module.scss';
import { ParkFormValues } from './types';
import { unpackPark } from './utils';
export function ParkPage() {
const [park, setPark] = useState<ParkWithTurbines>(null);
const [pending, setPending] = useState<boolean>(false);
const params = useParams();
const route = useRoute();
const { register, control, getValues, reset } = useForm<ParkFormValues>({});
const { id } = params;
const isEdit = id !== 'new';
const heading = isEdit ? 'Edit' : 'Create new';
const fetchPark = async () => {
const response = await getParkWithTurbines(id);
setPark(response.data);
reset(unpackPark(response.data));
};
useEffect(() => {
if (!isEdit) {
return;
}
fetchPark();
}, [id]);
const handleReset = () => {
if (isEdit) {
reset(unpackPark(park));
} else {
reset({});
}
};
return (
<div className={styles.page}>
<Heading tag="h1">{route.title}</Heading>
<form className={styles.form}>
<header>
<Heading tag="h3">{heading}</Heading>
</header>
<TextInput {...register('name')} label={{ text: 'Name' }} />
<div className={styles.inputBox}>
<Controller
{...control('centerLatitude')}
render={(props) => (
<NumberInput label={{ text: 'Center Latitude' }} {...props} />
)}
/>
<Controller
{...control('centerLongitude')}
render={(props) => (
<NumberInput label={{ text: 'Center Longitude' }} {...props} />
)}
/>
</div>
<div className={styles.buttonBox}>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
<Button type="submit" pending={pending}>
Submit
</Button>
</div>
</form>
<Controller
{...control('turbines')}
render={(props) => <ParkTurbines {...props} />}
/>
</div>
);
}

View File

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

View File

@ -0,0 +1,19 @@
import { DataGrid } from '@components/ui/data-grid';
import React from 'react';
import { columns } from './constants';
import { ParkTurbinesProps } from './types';
export function ParkTurbines({ value = [] }: ParkTurbinesProps) {
return (
<div>
<div></div>
<DataGrid
items={value}
columns={columns}
getItemKey={({ id }) => String(id)}
selectedItems={[]}
/>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
import { ParkTurbine } from 'src/api/wind';
export const columns: DataGridColumnConfig<ParkTurbine>[] = [
{ name: 'Id', getText: (t) => String(t.id) },
{ name: 'Name', getText: (t) => t.name },
{ name: 'X', getText: (t) => String(t.xOffset) },
{ name: 'Y', getText: (t) => String(t.yOffset) },
{ name: 'Angle', getText: (t) => String(t.angle) },
{ name: 'Comment', getText: (t) => String(t.comment), flex: '2' },
];

View File

@ -0,0 +1,6 @@
import { ParkTurbine } from '@api/wind';
export type ParkTurbinesProps = {
value?: ParkTurbine[];
onChange?: (value: ParkTurbine[]) => void;
};

View File

@ -0,0 +1,28 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto 1fr;
}
.form {
display: grid;
padding: 20px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
}
.inputBox {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
.buttonBox {
display: flex;
justify-content: end;
padding-top: 20px;
gap: 10px;
}

View File

@ -0,0 +1,8 @@
import { ParkTurbine } from '@api/wind';
export type ParkFormValues = {
name: string;
centerLatitude: string;
centerLongitude: string;
turbines: ParkTurbine[];
};

View File

@ -1,11 +1,12 @@
import { Park } from 'src/api/wind'; import { ParkWithTurbines } from '@api/wind';
import { ParkFormValues } from './types'; import { ParkFormValues } from './types';
export const parkToFormValues = (park: Park): ParkFormValues => { export const unpackPark = (park: ParkWithTurbines): ParkFormValues => {
return { return {
name: park.name, name: park.name,
centerLatitude: String(park.centerLatitude), centerLatitude: String(park.centerLatitude),
centerLongitude: String(park.centerLongitude), centerLongitude: String(park.centerLongitude),
turbines: park.turbines,
}; };
}; };

View File

@ -1,58 +1,52 @@
import { getParks, Park } from '@api/wind';
import { Button, Heading } from '@components/ui'; import { Button, Heading } from '@components/ui';
import { DataGrid } from '@components/ui/data-grid'; import { DataGrid } from '@components/ui/data-grid';
import { ParkModal } from '@components/ux'; import { ROUTES, useRoute } from '@utils/route';
import { useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { getParks, Park } from 'src/api/wind'; import { Link } from 'react-router-dom';
import { columns } from './constants'; import { columns } from './constants';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
export function ParksPage() { export function ParksPage() {
const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [parks, setParks] = useState<Park[]>([]); const [parks, setParks] = useState<Park[]>([]);
const [selected, setSelected] = useState<Park>(null); const [selected, setSelected] = useState<Park>(null);
const route = useRoute(); const route = useRoute();
const fetchParks = async () => { const fetchParks = async () => {
const res = await getParks(); const res = await getParks();
setParks(res.data ?? []); setParks(res.data);
}; };
useEffect(() => { useEffect(() => {
fetchParks(); fetchParks();
}, []); }, []);
const handleCreateButtonClick = () => {
setCreateModalOpen(true);
};
const handleEditButtonClick = () => {
setEditModalOpen(true);
};
const handleParkSelect = (items: Park[]) => { const handleParkSelect = (items: Park[]) => {
setSelected(items[0] ?? null); setSelected(items[0] ?? null);
}; };
const handleDeleteButtonClick = async () => {
//
};
return ( return (
<div className={styles.page}> <div className={styles.page}>
<Heading tag="h1" className={styles.heading}> <Heading tag="h1">{route.title}</Heading>
{route.title}
</Heading>
<div className={styles.actions}> <div className={styles.actions}>
<Button onClick={handleCreateButtonClick}>Create new</Button> <Link to={ROUTES.park.path.replace(':id', 'new')}>
<Button <Button>Create new</Button>
variant="secondary" </Link>
onClick={handleEditButtonClick} {selected && (
disabled={!selected} <Link to={ROUTES.park.path.replace(':id', String(selected.id))}>
> <Button variant="secondary">Edit</Button>
Edit </Link>
</Button> )}
<Button variant="secondary" disabled={!selected}> {selected && (
Delete <Button variant="secondary" onClick={handleDeleteButtonClick}>
</Button> Delete
</Button>
)}
</div> </div>
<div className={styles.dataGridWrapper}> <div className={styles.dataGridWrapper}>
<DataGrid <DataGrid
@ -64,18 +58,6 @@ export function ParksPage() {
multiselect={false} multiselect={false}
/> />
</div> </div>
<ParkModal
park={null}
open={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSuccess={fetchParks}
/>
<ParkModal
park={selected}
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
onSuccess={fetchParks}
/>
</div> </div>
); );
} }

View File

@ -3,6 +3,6 @@ import { Park } from 'src/api/wind';
export const columns: DataGridColumnConfig<Park>[] = [ export const columns: DataGridColumnConfig<Park>[] = [
{ name: 'Name', getText: (t) => t.name, flex: '2' }, { name: 'Name', getText: (t) => t.name, flex: '2' },
{ name: 'Center latitude', getText: (t) => String(t.centerLatitude) }, { name: 'Center Latitude', getText: (t) => String(t.centerLatitude) },
{ name: 'Center longitude', getText: (t) => String(t.centerLongitude) }, { name: 'Center Longitude', getText: (t) => String(t.centerLongitude) },
]; ];

View File

@ -1,6 +1,6 @@
.page { .page {
display: grid; display: grid;
padding: 40px 20px 20px; padding: 40px 20px;
gap: 20px; gap: 20px;
grid-template-rows: auto auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
} }
@ -13,7 +13,7 @@
display: flex; display: flex;
padding: 10px; padding: 10px;
border: 1px solid var(--clr-border-100); border: 1px solid var(--clr-border-100);
border-radius: 10px; border-radius: 15px;
background-color: var(--clr-layer-200); background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100); box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px; gap: 10px;

View File

@ -0,0 +1,100 @@
import {
createTurbineTypes,
editTurbineTypes,
getTurbineType,
TurbineType,
} from '@api/wind';
import { Button, Heading, NumberInput, TextInput } from '@components/ui';
import { Controller, useForm } from '@utils/form';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styles from './styles.module.scss';
import { TurbineTypeFormValues } from './types';
import { unpackTurbineType } from './utils';
export function TurbineTypePage() {
const [turbineType, setTurbineType] = useState<TurbineType>(null);
const [pending, setPending] = useState<boolean>(false);
const params = useParams();
const navigate = useNavigate();
const route = useRoute();
const { register, control, getValues, reset } =
useForm<TurbineTypeFormValues>({});
const { id } = params;
const isEdit = id !== 'new';
const heading = isEdit ? 'Edit' : 'Create new';
const fetchTurbineType = async () => {
const response = await getTurbineType(id);
setTurbineType(response.data);
reset(unpackTurbineType(response.data));
};
useEffect(() => {
if (!isEdit) {
return;
}
fetchTurbineType();
}, [id]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
if (isEdit) {
const response = await editTurbineTypes(getValues(), id);
setTurbineType(response.data);
} else {
const response = await createTurbineTypes(getValues());
navigate(
ROUTES.turbineType.path.replace(':id', String(response.data.id)),
);
}
setPending(false);
};
const handleReset = () => {
if (isEdit) {
reset(unpackTurbineType(turbineType));
} else {
reset({});
}
};
return (
<div className={styles.page} onSubmit={handleSubmit}>
<Heading tag="h1">{route.title}</Heading>
<form className={styles.form}>
<header>
<Heading tag="h3">{heading}</Heading>
</header>
<TextInput {...register('name')} label={{ text: 'Name' }} />
<div className={styles.inputBox}>
<Controller
{...control('height')}
render={(props) => (
<NumberInput label={{ text: 'Height' }} {...props} />
)}
/>
<Controller
{...control('bladeLength')}
render={(props) => (
<NumberInput label={{ text: 'Blade length' }} {...props} />
)}
/>
</div>
<div className={styles.buttonBox}>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
<Button type="submit" pending={pending}>
Submit
</Button>
</div>
</form>
</div>
);
}

View File

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

View File

@ -0,0 +1,28 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto 1fr;
}
.form {
display: grid;
padding: 20px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
}
.inputBox {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
.buttonBox {
display: flex;
justify-content: end;
padding-top: 20px;
gap: 10px;
}

View File

@ -0,0 +1,5 @@
export type TurbineTypeFormValues = {
name: string;
height: string;
bladeLength: string;
};

View File

@ -1,8 +1,8 @@
import { TurbineType } from 'src/api/wind'; import { TurbineType } from '@api/wind';
import { TurbineTypeFormValues } from './types'; import { TurbineTypeFormValues } from './types';
export const turbineTypeToFormValues = ( export const unpackTurbineType = (
turbineType: TurbineType, turbineType: TurbineType,
): TurbineTypeFormValues => { ): TurbineTypeFormValues => {
return { return {

View File

@ -1,16 +1,14 @@
import { deleteTurbineType, getTurbineTypes, TurbineType } from '@api/wind';
import { Button, Heading } from '@components/ui'; import { Button, Heading } from '@components/ui';
import { DataGrid } from '@components/ui/data-grid'; import { DataGrid } from '@components/ui/data-grid';
import { TurbineTypeModal } from '@components/ux'; import { ROUTES, useRoute } from '@utils/route';
import { useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { deleteTurbineTypes, getTurbineTypes, TurbineType } from 'src/api/wind'; import { Link } from 'react-router-dom';
import { columns } from './constants'; import { columns } from './constants';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
export function TurbineTypesPage() { export function TurbineTypesPage() {
const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]); const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
const [selected, setSelected] = useState<TurbineType>(null); const [selected, setSelected] = useState<TurbineType>(null);
const route = useRoute(); const route = useRoute();
@ -24,44 +22,34 @@ export function TurbineTypesPage() {
fetchTurbineTypes(); fetchTurbineTypes();
}, []); }, []);
const handleCreateButtonClick = () => {
setCreateModalOpen(true);
};
const handleEditButtonClick = () => {
setEditModalOpen(true);
};
const handleDeleteButtonClick = async () => {
await deleteTurbineTypes(selected.id);
fetchTurbineTypes();
};
const handleTurbineTypeSelect = (items: TurbineType[]) => { const handleTurbineTypeSelect = (items: TurbineType[]) => {
setSelected(items[0] ?? null); setSelected(items[0] ?? null);
}; };
const handleDeleteButtonClick = async () => {
await deleteTurbineType(selected.id);
fetchTurbineTypes();
};
return ( return (
<div className={styles.page}> <div className={styles.page}>
<Heading tag="h1" className={styles.heading}> <Heading tag="h1">{route.title}</Heading>
{route.title}
</Heading>
<div className={styles.actions}> <div className={styles.actions}>
<Button onClick={handleCreateButtonClick}>Create new</Button> <Link to={ROUTES.turbineType.path.replace(':id', 'new')}>
<Button <Button>Create new</Button>
variant="secondary" </Link>
onClick={handleEditButtonClick} {selected && (
disabled={!selected} <Link
> to={ROUTES.turbineType.path.replace(':id', String(selected.id))}
Edit >
</Button> <Button variant="secondary">Edit</Button>
<Button </Link>
variant="secondary" )}
onClick={handleDeleteButtonClick} {selected && (
disabled={!selected} <Button variant="secondary" onClick={handleDeleteButtonClick}>
> Delete
Delete </Button>
</Button> )}
</div> </div>
<div className={styles.dataGridWrapper}> <div className={styles.dataGridWrapper}>
<DataGrid <DataGrid
@ -73,18 +61,6 @@ export function TurbineTypesPage() {
multiselect={false} multiselect={false}
/> />
</div> </div>
<TurbineTypeModal
turbineType={null}
open={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSuccess={fetchTurbineTypes}
/>
<TurbineTypeModal
turbineType={selected}
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
onSuccess={fetchTurbineTypes}
/>
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
.page { .page {
display: grid; display: grid;
padding: 40px 20px 20px; padding: 40px 20px;
gap: 20px; gap: 20px;
grid-template-rows: auto auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
} }
@ -13,7 +13,7 @@
display: flex; display: flex;
padding: 10px; padding: 10px;
border: 1px solid var(--clr-border-100); border: 1px solid var(--clr-border-100);
border-radius: 10px; border-radius: 15px;
background-color: var(--clr-layer-200); background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100); box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px; gap: 10px;

View File

@ -1,5 +1,11 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react'; import React, {
CSSProperties,
ForwardedRef,
forwardRef,
useEffect,
useState,
} from 'react';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { FadeProps } from './types'; import { FadeProps } from './types';
@ -14,7 +20,7 @@ export function FadeInner(
}: Omit<FadeProps, 'ref'>, }: Omit<FadeProps, 'ref'>,
ref: ForwardedRef<HTMLDivElement>, ref: ForwardedRef<HTMLDivElement>,
) { ) {
const [visibleInner, setVisibleInner] = useState<boolean>(visible); const [visibleInternal, setVisibleInternal] = useState<boolean>(visible);
const classNames = clsx( const classNames = clsx(
styles.fade, styles.fade,
@ -25,22 +31,21 @@ export function FadeInner(
const inlineStyle = { const inlineStyle = {
...style, ...style,
'--animation-duration': `${duration}ms`, '--animation-duration': `${duration}ms`,
} as React.CSSProperties; } as CSSProperties;
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
setVisibleInner(true); setVisibleInternal(true);
return;
} }
}, [visible]); }, [visible]);
const handleAnimationEnd = (event: React.AnimationEvent) => { const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadeout) { if (event.animationName === styles.fadeout) {
setVisibleInner(false); setVisibleInternal(false);
} }
}; };
if (!visibleInner) { if (!visibleInternal) {
return null; return null;
} }

View File

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

View File

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

View File

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

View File

@ -50,6 +50,7 @@ export function Ripple() {
}; };
const handlePointerDown = (event: React.MouseEvent) => { const handlePointerDown = (event: React.MouseEvent) => {
event.stopPropagation();
const { pageX, pageY } = event; const { pageX, pageY } = event;
addWave(pageX, pageY); addWave(pageX, pageY);
}; };

View File

@ -10,14 +10,15 @@
position: absolute; position: absolute;
border-radius: 100%; border-radius: 100%;
background-color: var(--clr-ripple); background-color: var(--clr-ripple);
pointer-events: none;
} }
.visible { .visible {
animation: fadein 0.3s linear; animation: fadein 0.25s linear;
} }
.invisible { .invisible {
animation: fadeout 0.3s linear forwards; animation: fadeout 0.25s linear;
} }
@keyframes fadein { @keyframes fadein {

View File

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

View File

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

View File

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

View File

@ -26,8 +26,6 @@ function AutocompleteInner<T>(
label = {}, label = {},
name, name,
id, id,
className,
...props
}: Omit<AutocompleteProps<T>, 'ref'>, }: Omit<AutocompleteProps<T>, 'ref'>,
ref: ForwardedRef<HTMLDivElement>, ref: ForwardedRef<HTMLDivElement>,
) { ) {
@ -39,18 +37,15 @@ function AutocompleteInner<T>(
useImperativeHandle(ref, () => autocompleteRef.current, []); useImperativeHandle(ref, () => autocompleteRef.current, []);
useMissClick( useMissClick({
[autocompleteRef, menuRef], callback: () => setMenuVisible(false),
() => setMenuVisible(false), enabled: menuVisible,
menuVisible, whitelist: [autocompleteRef, menuRef],
); });
const autocompleteClassName = clsx( const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
styles.autocomplete, [styles.menuVisible]: menuVisible,
styles[scale], });
{ [styles.menuVisible]: menuVisible },
className,
);
const filteredOptions = options.filter((option) => { const filteredOptions = options.filter((option) => {
const label = getOptionLabel(option).toLocaleLowerCase(); const label = getOptionLabel(option).toLocaleLowerCase();
@ -80,7 +75,7 @@ function AutocompleteInner<T>(
}; };
return ( return (
<div className={autocompleteClassName} ref={autocompleteRef} {...props}> <div className={autocompleteClassName} ref={autocompleteRef}>
<TextInput <TextInput
value={value ? getOptionLabel(value) : text} value={value ? getOptionLabel(value) : text}
onClick={handleInputClick} onClick={handleInputClick}

View File

@ -1,14 +1,16 @@
import { ComponentProps, Key } from 'react';
import { LabelProps } from '../label'; import { LabelProps } from '../label';
import { Scale } from '../types'; import { Scale } from '../types';
export type AutocompleteProps<T> = { export type AutocompleteProps<T> = {
options: T[]; options: T[];
value?: T; value?: T;
getOptionKey: (option: T) => React.Key; getOptionKey: (option: T) => Key;
getOptionLabel: (option: T) => string; getOptionLabel: (option: T) => string;
onChange?: (option: T) => void; onChange?: (option: T) => void;
scale?: Scale; scale?: Scale;
label?: LabelProps; label?: LabelProps;
name?: string; name?: string;
id?: string; id?: string;
} & Omit<React.ComponentProps<'div'>, 'onChange'>; } & Omit<ComponentProps<'div'>, 'onChange'>;

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React, { ForwardedRef, forwardRef } from 'react';
import { Ripple } from '../animation/ripple/component'; import { Ripple } from '../animation/ripple/component';
import { Comet } from '../comet'; import { Comet } from '../comet';
@ -8,16 +8,18 @@ import { COMET_VARIANT_MAP } from './constants';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { ButtonProps } from './types.js'; import { ButtonProps } from './types.js';
export function Button({ function ButtonInner(
variant = 'primary', {
scale = 'm', variant = 'primary',
pending = false, scale = 'm',
className, pending = false,
children, className,
disabled, children,
...props ...props
}: ButtonProps) { }: ButtonProps,
const classNames = clsx( ref: ForwardedRef<HTMLButtonElement>,
) {
const buttonClassName = clsx(
styles.button, styles.button,
styles[variant], styles[variant],
styles[scale], styles[scale],
@ -25,11 +27,7 @@ export function Button({
className, className,
); );
return ( return (
<RawButton <RawButton className={buttonClassName} ref={ref} {...props}>
className={classNames}
disabled={pending ? true : disabled}
{...props}
>
{pending && ( {pending && (
<div className={styles.cometWrapper}> <div className={styles.cometWrapper}>
<Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} /> <Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} />
@ -40,3 +38,5 @@ export function Button({
</RawButton> </RawButton>
); );
} }
export const Button = forwardRef(ButtonInner);

View File

@ -3,14 +3,9 @@
.button { .button {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
box-shadow: 0px 2px 2px var(--clr-shadow-200);
font-weight: 500; font-weight: 500;
transition: all var(--td-100) ease-in-out; transition: all var(--td-100) ease-in-out;
&:disabled {
pointer-events: none;
}
&:not(:disabled) { &:not(:disabled) {
cursor: pointer; cursor: pointer;
} }
@ -28,6 +23,8 @@
} }
.pending { .pending {
pointer-events: none;
.childrenWrapper { .childrenWrapper {
visibility: hidden; visibility: hidden;
} }
@ -37,27 +34,21 @@
background-color: var(--clr-primary); background-color: var(--clr-primary);
color: var(--clr-on-primary); color: var(--clr-on-primary);
&:hover { @media (hover: hover) {
background-color: var(--clr-primary-hover); &:hover {
} background-color: var(--clr-primary-hover);
}
&:disabled,
&.pending {
background-color: var(--clr-primary-disabled);
} }
} }
.secondary { .secondary {
background-color: var(--clr-secondary); background-color: var(--clr-secondary);
color: var(--clr-on-secondary); color: var(--clr-on-secondary);
&:hover { @media (hover: hover) {
background-color: var(--clr-secondary-hover); &:hover {
} background-color: var(--clr-secondary-hover);
}
&:disabled,
&.pending {
background-color: var(--clr-secondary-disabled);
} }
} }

View File

@ -1,6 +1,8 @@
import { ComponentProps } from 'react';
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<ComponentProps<'div'>, 'onChange'>;

View File

@ -45,7 +45,7 @@
.checkbox { .checkbox {
display: flex; display: flex;
justify-content: center; justify-content: center;
border: 2px solid var(--clr-border-200); border: 1px 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;

View File

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

View File

@ -1,71 +1,67 @@
import { arrayToObject } from '@utils/array';
import clsx from 'clsx';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { DataGridHeader, DataGridRow } from './components'; import { DataGridHeader, DataGridRow } from './components';
import styles from './styles.module.scss';
import { DataGridProps } from './types'; import { DataGridProps } from './types';
export function DataGrid<T>({ export function DataGrid<T>({
items, items,
columns, columns,
getItemKey, getItemKey,
className,
selectedItems, selectedItems,
onItemsSelect, onItemsSelect,
multiselect = true, multiselect,
className,
...props ...props
}: DataGridProps<T>) { }: DataGridProps<T>) {
const [allRowsSelected, setAllRowsSelected] = useState<boolean>(false); const [allItemsSelected, setAllItemsSelected] = useState<boolean>(false);
const selectedItemsMap = useMemo( const selectedItemsMap = useMemo(() => {
() => arrayToObject(selectedItems, (i) => getItemKey(i)), const map: Record<string, T> = {};
[selectedItems], for (let i = 0; i < selectedItems.length; i += 1) {
); const item = selectedItems[i];
map[String(getItemKey(item))] = item;
}
return map;
}, [selectedItems]);
const handleSelectAllRows = () => { const handleSelectAllItems = () => {
if (!multiselect) { if (!multiselect) {
return; return;
} }
setAllRowsSelected(!allRowsSelected); onItemsSelect?.(allItemsSelected ? [] : [...items]);
if (allRowsSelected) { setAllItemsSelected(!allItemsSelected);
onItemsSelect([]);
} else {
onItemsSelect([...items]);
}
}; };
const handleRowSelect = (item: T) => { const handleItemSelect = (key: string, item: T) => {
setAllRowsSelected(false); const selected = Boolean(selectedItemsMap[key]);
const key = getItemKey(item);
const selected = selectedItemsMap[key];
if (!multiselect) { if (!multiselect) {
onItemsSelect(selected ? [] : [item]); onItemsSelect(selected ? [] : [item]);
} else { return;
onItemsSelect(
selected
? selectedItems.filter((i) => key !== getItemKey(i))
: [...selectedItems, item],
);
} }
onItemsSelect?.(
selected
? selectedItems.filter((i) => key !== getItemKey(i))
: [...selectedItems, item],
);
setAllItemsSelected(false);
}; };
return ( return (
<div className={clsx(styles.dataGrid, className)} {...props}> <div className={className} {...props}>
<DataGridHeader <DataGridHeader
columns={columns} columns={columns}
allRowsSelected={allRowsSelected} allItemsSelected={allItemsSelected}
onSelectAllRows={handleSelectAllRows} onSelectAllItems={handleSelectAllItems}
/> />
{items.map((item) => { {items.map((item) => {
const key = getItemKey(item); const key = String(getItemKey(item));
return ( return (
<DataGridRow <DataGridRow
object={item} object={item}
columns={columns} columns={columns}
selected={selectedItemsMap[key] ? true : false} selected={Boolean(selectedItemsMap[key])}
onSelect={() => handleRowSelect(item)} onSelect={() => handleItemSelect(key, item)}
key={key} key={getItemKey(item)}
/> />
); );
})} })}

View File

@ -12,8 +12,8 @@ import { DataGridHeaderProps } from './types';
export function DataGridHeader<T>({ export function DataGridHeader<T>({
columns, columns,
allRowsSelected, allItemsSelected,
onSelectAllRows, onSelectAllItems,
}: DataGridHeaderProps<T>) { }: DataGridHeaderProps<T>) {
const [sort, setSort] = useState<DataGridSort>({ order: 'asc', column: '' }); const [sort, setSort] = useState<DataGridSort>({ order: 'asc', column: '' });
@ -32,8 +32,8 @@ export function DataGridHeader<T>({
return ( return (
<header className={styles.header}> <header className={styles.header}>
<Checkbox <Checkbox
checked={allRowsSelected} checked={allItemsSelected}
onChange={onSelectAllRows} onChange={onSelectAllItems}
label={{ className: styles.checkboxLabel }} label={{ className: styles.checkboxLabel }}
/> />
{columns.map((column) => { {columns.map((column) => {

View File

@ -6,6 +6,7 @@
padding: 10px; padding: 10px;
border: solid 1px var(--clr-border-100); border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-300); background-color: var(--clr-layer-300);
border-top-left-radius: 10px;
} }
.cell { .cell {
@ -18,12 +19,17 @@
border: solid 1px var(--clr-border-100); border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-300); background-color: var(--clr-layer-300);
cursor: pointer; cursor: pointer;
font-weight: 500;
gap: 10px; gap: 10px;
transition: all var(--td-100) ease-in-out; transition: all var(--td-100) ease-in-out;
&:hover { &:last-of-type {
background-color: var(--clr-layer-300-hover); border-top-right-radius: 10px;
}
@media (hover: hover) {
&:hover {
background-color: var(--clr-layer-300-hover);
}
} }
} }

View File

@ -2,6 +2,6 @@ import { DataGridColumnConfig } from '../../types';
export type DataGridHeaderProps<T> = { export type DataGridHeaderProps<T> = {
columns: DataGridColumnConfig<T>[]; columns: DataGridColumnConfig<T>[];
allRowsSelected: boolean; allItemsSelected: boolean;
onSelectAllRows: () => void; onSelectAllItems: () => void;
}; };

View File

@ -24,7 +24,7 @@ export function DataGridRow<T>({
style={{ flex: column.flex }} style={{ flex: column.flex }}
key={column.name} key={column.name}
> >
<Span color="t200">{column.getText(object)}</Span> <Span>{column.getText(object)}</Span>
</div> </div>
))} ))}
</div> </div>

View File

@ -1,10 +1,19 @@
import { PreviewArticle } from '@components/ui/preview'; import { PreviewArticle } from '@components/ui/preview';
import React from 'react'; import React, { useState } from 'react';
import { DataGrid } from './component'; import { DataGrid } from './component';
import { Cat, DataGridColumnConfig } from './types'; import { DataGridColumnConfig } from './types';
type Cat = {
name: string;
breed: string;
age: string;
color: string;
};
export function DataGridPreview() { export function DataGridPreview() {
const [selectedItems, setSelectedItems] = useState<Cat[]>([]);
const items: Cat[] = [ const items: Cat[] = [
{ name: 'Luna', breed: 'British Shorthair', color: 'Gray', age: '2' }, { name: 'Luna', breed: 'British Shorthair', color: 'Gray', age: '2' },
{ name: 'Simba', breed: 'Siamese', color: 'Cream', age: '1' }, { name: 'Simba', breed: 'Siamese', color: 'Cream', age: '1' },
@ -14,15 +23,24 @@ export function DataGridPreview() {
]; ];
const columns: DataGridColumnConfig<Cat>[] = [ const columns: DataGridColumnConfig<Cat>[] = [
{ name: 'Name', getText: (cat) => cat.name, flex: '2' }, { name: 'Name', getText: (cat) => cat.name },
{ name: 'Breed', getText: (cat) => cat.breed }, { name: 'Breed', getText: (cat) => cat.breed, scale: 2 },
{ name: 'Age', getText: (cat) => cat.age }, { name: 'Age', getText: (cat) => cat.age },
{ name: 'Color', getText: (cat) => cat.color }, { name: 'Color', getText: (cat) => cat.color },
]; ];
return ( return (
<PreviewArticle title="DataGrid"> <PreviewArticle title="DataGrid">
<DataGrid style={{ width: '100%' }} items={items} columns={columns} /> <div style={{ width: '100%', overflow: 'auto' }}>
<DataGrid
style={{ minWidth: 500 }}
items={items}
columns={columns}
getItemKey={({ name }) => name}
selectedItems={selectedItems}
onItemsSelect={setSelectedItems}
/>
</div>
</PreviewArticle> </PreviewArticle>
); );
} }

View File

@ -1,4 +0,0 @@
.dataGrid {
border-radius: 10px;
box-shadow: 0px 2px 2px var(--clr-shadow-200);
}

View File

@ -1,3 +1,5 @@
import { ComponentPropsWithoutRef, Key } from 'react';
export type DataGridColumnConfig<T> = { export type DataGridColumnConfig<T> = {
name: string; name: string;
getText: (object: T) => string; getText: (object: T) => string;
@ -13,15 +15,8 @@ export type DataGridSort = {
export type DataGridProps<T> = { export type DataGridProps<T> = {
items: T[]; items: T[];
columns: DataGridColumnConfig<T>[]; columns: DataGridColumnConfig<T>[];
getItemKey: (object: T) => string; getItemKey: (item: T) => Key;
selectedItems: T[]; selectedItems?: T[];
onItemsSelect: (selectedItems: T[]) => void; onItemsSelect?: (selectedItems: T[]) => void;
multiselect?: boolean; multiselect?: boolean;
} & React.ComponentPropsWithoutRef<'div'>; } & ComponentPropsWithoutRef<'div'>;
export type Cat = {
name: string;
breed: string;
age: string;
color: string;
};

View File

@ -37,11 +37,11 @@ export function DateInput({
setDirtyDate(valueToDirtyDate(value)); setDirtyDate(valueToDirtyDate(value));
}, [value]); }, [value]);
useMissClick( useMissClick({
[wrapperRef, calendarWrapperRef], callback: () => setCalendarVisible(false),
() => setCalendarVisible(false), enabled: calendarVisible,
calendarVisible, whitelist: [wrapperRef, calendarWrapperRef],
); });
const handleCalendarButtonClick = () => { const handleCalendarButtonClick = () => {
setCalendarVisible(!calendarVisible); setCalendarVisible(!calendarVisible);

View File

@ -0,0 +1,43 @@
import { useMissClick } from '@utils/miss-click';
import clsx from 'clsx';
import React, { useRef } from 'react';
import { Fade } from '../animation';
import { Heading } from '../heading';
import { Overlay } from '../overlay';
import { Paragraph } from '../paragraph';
import styles from './styles.module.scss';
import { DialogProps } from './types';
export function Dialog({
open,
onClose,
heading,
message,
triggerElementRef,
className,
children,
...props
}: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useMissClick({
callback: onClose,
enabled: open,
whitelist: [dialogRef, triggerElementRef],
});
const dialogClassName = clsx(styles.dialog, className);
return (
<Overlay open={open} className={styles.overlay}>
<Fade visible={open} className={styles.fade}>
<div className={dialogClassName} ref={dialogRef} {...props}>
<Heading tag="h3">{heading}</Heading>
<Paragraph color="t300">{message}</Paragraph>
{children}
</div>
</Fade>
</Overlay>
);
}

View File

@ -0,0 +1,2 @@
export * from './component';
export * from './preview';

View File

@ -0,0 +1,32 @@
import { PreviewArticle } from '@components/ui/preview';
import React, { useRef, useState } from 'react';
import { Button } from '../button';
import { Dialog } from './component';
export function DialogPreview() {
const openButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState<boolean>(false);
return (
<PreviewArticle title="Dialog">
<Button onClick={() => setOpen(true)} ref={openButtonRef}>
Open dialog
</Button>
<Dialog
open={open}
onClose={() => setOpen(false)}
heading="Dialog"
message="Short dialog message"
triggerElementRef={openButtonRef}
>
<div style={{ display: 'flex', gap: '10px' }}>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => setOpen(false)}>Confirm</Button>
</div>
</Dialog>
</PreviewArticle>
);
}

View File

@ -0,0 +1,26 @@
@use '@components/func.scss' as f;
.overlay {
display: grid;
padding: 20px;
grid-template:
'. . .' 1fr
'. fade .' auto
'. . .' 2fr
/ 1fr auto 1fr;
}
.fade {
grid-area: fade;
}
.dialog {
display: grid;
padding: 20px;
border-radius: 15px;
background-color: var(--clr-layer-300);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px;
text-align: center;
transition: all var(--td-100) ease-in-out;
}

View File

@ -0,0 +1,9 @@
import { ComponentPropsWithoutRef, MutableRefObject } from 'react';
export type DialogProps = {
heading: string;
message: string;
open: boolean;
onClose: () => void;
triggerElementRef?: MutableRefObject<HTMLElement>;
} & ComponentPropsWithoutRef<'div'>;

View File

@ -1,3 +1,5 @@
import { ComponentPropsWithoutRef } from 'react';
import { LabelProps } from '../label'; import { LabelProps } from '../label';
import { RawInputProps } from '../raw'; import { RawInputProps } from '../raw';
import { Scale } from '../types'; import { Scale } from '../types';
@ -8,4 +10,4 @@ export type FileUploaderProps = {
scale?: Scale; scale?: Scale;
label?: LabelProps; label?: LabelProps;
input?: Omit<RawInputProps, 'type'>; input?: Omit<RawInputProps, 'type'>;
} & Omit<React.ComponentPropsWithoutRef<'button'>, 'onChange'>; } & Omit<ComponentPropsWithoutRef<'button'>, 'onChange'>;

View File

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

View File

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

View File

@ -3,44 +3,75 @@
.button { .button {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 100%;
background-color: transparent; background-color: transparent;
cursor: pointer; cursor: pointer;
transition: all var(--td-100) ease-in-out; transition: all var(--td-100) ease-in-out;
&:hover {
background-color: var(--clr-ripple);
svg {
fill: var(--clr-text-300);
}
}
svg { svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
fill: var(--clr-text-100); fill: var(--clr-text-100);
transition: all var(--td-100) ease-in-out; transition: all var(--td-100) ease-in-out;
} }
@media (hover: hover) {
&:hover {
background-color: var(--clr-ripple);
svg {
fill: var(--clr-text-300);
}
}
}
}
.circle {
border-radius: 100%;
} }
$size: 26px; $size: 26px;
$padding: 4px; $rect-padding: 6px;
$circle-padding: 4px;
$border-radius: 8px;
.s { .s {
width: $size; width: $size;
height: $size; height: $size;
padding: $padding;
&.rect {
padding: $rect-padding;
border-radius: $border-radius;
}
&.circle {
padding: $circle-padding;
}
} }
.m { .m {
width: f.m($size); width: f.m($size);
height: f.m($size); height: f.m($size);
padding: f.m($padding);
&.rect {
padding: f.m($rect-padding);
border-radius: f.m($border-radius);
}
&.circle {
padding: f.m($circle-padding);
}
} }
.l { .l {
width: f.l($size); width: f.l($size);
height: f.l($size); height: f.l($size);
padding: f.l($padding);
&.rect {
padding: f.l($rect-padding);
border-radius: f.l($border-radius);
}
&.circle {
padding: f.l($circle-padding);
}
} }

View File

@ -3,5 +3,6 @@ import { Scale } from '@components/ui/types';
import { RawButtonProps } from '../raw'; import { RawButtonProps } from '../raw';
export type IconButtonProps = { export type IconButtonProps = {
variant?: 'circle' | 'rect';
scale?: Scale; scale?: Scale;
} & RawButtonProps; } & RawButtonProps;

View File

@ -6,4 +6,4 @@ export type ImageFileManagerProps = {
onChange?: (value: File | null) => void; onChange?: (value: File | null) => void;
scale?: Scale; scale?: Scale;
label?: LabelProps; label?: LabelProps;
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange'>; } & Omit<ComponentPropsWithoutRef<'div'>, 'onChange'>;

View File

@ -7,9 +7,12 @@ 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 { ImageFileManager } from './image-file-manager';
export { LinkButton } from './link-button';
export { Menu } from './menu'; export { Menu } from './menu';
export { Dialog } from './dialog';
export { NumberInput } from './number-input'; export { NumberInput } from './number-input';
export { Overlay } from './overlay'; export { Overlay } from './overlay';
export { Pagination } from './pagination';
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';

View File

@ -11,6 +11,7 @@ function InputInner(
wrapper = {}, wrapper = {},
leftNode, leftNode,
rightNode, rightNode,
invalid,
className, className,
onFocus, onFocus,
onBlur, onBlur,
@ -24,6 +25,7 @@ function InputInner(
styles.wrapper, styles.wrapper,
focus && styles.wrapperFocus, focus && styles.wrapperFocus,
wrapper.className, wrapper.className,
invalid && styles.invalid,
); );
const inputClassNames = clsx(styles.input, className); const inputClassNames = clsx(styles.input, className);

View File

@ -14,6 +14,10 @@
} }
} }
.invalid {
border-color: var(--clr-error);
}
.wrapperFocus { .wrapperFocus {
z-index: 1; z-index: 1;
border-color: var(--clr-primary); border-color: var(--clr-primary);

View File

@ -1,12 +1,14 @@
import { Scale } from '@components/ui/types'; import { Scale } from '@components/ui/types';
import { ComponentProps, ReactNode } from 'react';
import { RawInputProps } from '../raw'; import { RawInputProps } from '../raw';
type InputProps = { type InputProps = {
scale?: Scale; scale?: Scale;
wrapper?: React.ComponentProps<'div'>; wrapper?: ComponentProps<'div'>;
leftNode?: React.ReactNode; leftNode?: ReactNode;
rightNode?: React.ReactNode; rightNode?: ReactNode;
invalid?: boolean;
} & RawInputProps; } & RawInputProps;
export { InputProps }; export { InputProps };

View File

@ -11,6 +11,7 @@ function LabelInner(
scale, scale,
position = 'top', position = 'top',
required = {}, required = {},
error,
className, className,
children, children,
...props ...props
@ -29,6 +30,11 @@ function LabelInner(
</Span> </Span>
)} )}
{!reversed && children} {!reversed && children}
{error && (
<Span scale={scale} className={styles.error}>
{error}
</Span>
)}
</label> </label>
); );
} }

View File

@ -1,5 +1,9 @@
.label { .label {
display: flex; display: flex;
.error {
color: var(--clr-error);
}
} }
.left, .left,

View File

@ -1,3 +1,5 @@
import { ComponentProps } from 'react';
import { Required, Scale } from '../types'; import { Required, Scale } from '../types';
export type LabelProps = { export type LabelProps = {
@ -5,4 +7,5 @@ export type LabelProps = {
scale?: Scale; scale?: Scale;
position?: 'left' | 'top' | 'right' | 'bottom'; position?: 'left' | 'top' | 'right' | 'bottom';
required?: Required; required?: Required;
} & React.ComponentProps<'label'>; error?: string;
} & ComponentProps<'label'>;

View File

@ -0,0 +1,23 @@
import clsx from 'clsx';
import React from 'react';
import { Link } from 'react-router-dom';
import { Ripple } from '../animation';
import styles from './styles.module.scss';
import { LinkButtonProps } from './types';
export function LinkButton({
scale = 'm',
className,
href,
children,
...props
}: LinkButtonProps) {
const linkClassName = clsx(styles.button, styles[scale], className);
return (
<Link className={linkClassName} to={href} {...props}>
{children}
<Ripple />
</Link>
);
}

View File

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

View File

@ -0,0 +1,37 @@
@use '@components/func.scss' as f;
.button {
position: relative;
overflow: hidden;
color: var(--clr-text-primary);
font-weight: 500;
text-align: center;
text-decoration: none;
transition: all var(--td-100) ease-in-out;
&:not(:disabled) {
cursor: pointer;
}
}
$padding: 10px 16px;
$border-radius: 8px;
$font-size: 12px;
.s {
padding: $padding;
border-radius: $border-radius;
font-size: $font-size;
}
.m {
padding: f.m($padding);
border-radius: f.m($border-radius);
font-size: f.m($font-size);
}
.l {
padding: f.l($padding);
border-radius: f.l($border-radius);
font-size: f.l($font-size);
}

View File

@ -0,0 +1,7 @@
import { ComponentPropsWithoutRef } from 'react';
import { Scale } from '../types';
export type LinkButtonProps = {
scale?: Scale;
} & ComponentPropsWithoutRef<'a'>;

View File

@ -1,7 +1,9 @@
import { ComponentProps, Key } from 'react';
export type MenuProps<T> = { export type MenuProps<T> = {
options: T[]; options: T[];
selected?: T; selected?: T;
getOptionKey: (option: T) => React.Key; getOptionKey: (option: T) => Key;
getOptionLabel: (option: T) => string; getOptionLabel: (option: T) => string;
onSelect?: (option: T) => void; onSelect?: (option: T) => void;
} & Omit<React.ComponentProps<'ul'>, 'onSelect'>; } & Omit<ComponentProps<'ul'>, 'onSelect'>;

View File

@ -1,16 +1,47 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Fade } from '../animation';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { OverlayProps } from './types'; import { OverlayProps } from './types';
export function Overlay({ open, children, variant = 'small' }: OverlayProps) { function OverlayInner(
{ open, className, ...props }: OverlayProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const [openInternal, setOpenInternal] = useState<boolean>(open);
useEffect(() => {
if (open) {
setOpenInternal(true);
}
}, [open]);
const overlayClassName = clsx(
styles.overlay,
{ [styles.closed]: !open },
className,
);
const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadeout) {
setOpenInternal(false);
}
};
if (!openInternal) {
return null;
}
return createPortal( return createPortal(
<Fade visible={open}> <div
<div className={clsx(styles.backdrop, styles[variant])}>{children}</div> className={overlayClassName}
</Fade>, onAnimationEnd={handleAnimationEnd}
ref={ref}
{...props}
/>,
document.body, document.body,
); );
} }
export const Overlay = forwardRef(OverlayInner);

View File

@ -1,27 +1,30 @@
.backdrop { .overlay {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
display: grid; width: 100vw;
width: 100%; height: 100dvh;
height: 100%; animation: fadein 0.25s forwards ease-in-out;
padding: 20px;
backdrop-filter: blur(5px);
background-color: rgba(0 0 0 / 0.75);
} }
.small { .closed {
grid-template: animation: fadeout 0.25s forwards ease-in-out;
'. . .' 1fr
'. form .' auto
'. . .' 5fr
/ 1fr minmax(0, 400px) 1fr;
} }
.large { @keyframes fadein {
grid-template: from {
'. . .' 1fr background-color: rgba(0 0 0 / 0);
'. form .' auto }
'. . .' 5fr to {
/ 1fr minmax(0, 600px) 1fr; background-color: rgba(0 0 0 / 0.5);
}
}
@keyframes fadeout {
from {
background-color: rgba(0 0 0 / 0.5);
}
to {
background-color: rgba(0 0 0 / 0);
}
} }

View File

@ -1,7 +1,5 @@
import { ReactNode } from 'react'; import { ComponentPropsWithoutRef } from 'react';
export type OverlayProps = { export type OverlayProps = {
open: boolean; open: boolean;
children: ReactNode; } & ComponentPropsWithoutRef<'div'>;
variant?: 'small' | 'large';
};

View File

@ -0,0 +1,71 @@
import ArrowLeftIcon from '@public/images/svg/arrow-left.svg';
import ArrowRightIcon from '@public/images/svg/arrow-right.svg';
import clsx from 'clsx';
import React from 'react';
import { PaginationItem } from './components';
import styles from './styles.module.scss';
import { PaginationProps } from './types';
import { getPageNumbers } from './utils';
export function Pagination({
value,
onChange,
total,
sibling = 2,
boundary = 1,
scale = 'm',
}: PaginationProps) {
const pageNumbers = getPageNumbers(value, total, sibling, boundary);
const paginationClassNames = clsx(styles.pagination, styles[scale]);
const handleBackButtonClick = () => {
onChange?.(Math.max(value - 1, 1));
};
const handleNextButtonClick = () => {
onChange?.(Math.min(value + 1, total));
};
return (
<div className={paginationClassNames}>
<PaginationItem
scale={scale}
variant="default"
onClick={handleBackButtonClick}
disabled={value === 1}
>
<ArrowLeftIcon className={styles.icon} />
</PaginationItem>
{pageNumbers.map((number, index) => {
if (number === null) {
return (
<PaginationItem scale={scale} variant="dots" disabled key={index}>
...
</PaginationItem>
);
}
const isCurrent = number === value;
return (
<PaginationItem
scale={scale}
variant={isCurrent ? 'current' : 'default'}
disabled={isCurrent}
key={index}
onClick={() => onChange?.(number)}
>
{number}
</PaginationItem>
);
})}
<PaginationItem
scale={scale}
variant="default"
onClick={handleNextButtonClick}
disabled={value === total}
>
<ArrowRightIcon className={styles.icon} />
</PaginationItem>
</div>
);
}

View File

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

View File

@ -0,0 +1,28 @@
import { Ripple } from '@components/ui/animation';
import { RawButton } from '@components/ui/raw';
import clsx from 'clsx';
import React from 'react';
import styles from './styles.module.scss';
import { PaginationItemProps } from './types';
export function PaginationItem({
scale,
variant,
className,
children,
...props
}: PaginationItemProps) {
const itemClassName = clsx(
styles.item,
styles[variant],
styles[scale],
className,
);
return (
<RawButton className={itemClassName} {...props}>
{children}
<Ripple />
</RawButton>
);
}

View File

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

View File

@ -0,0 +1,69 @@
@use '@components/func.scss' as f;
.item {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
cursor: pointer;
font-weight: 500;
transition: all var(--td-100) ease-in-out;
}
.current {
background-color: var(--clr-primary);
color: var(--clr-on-primary);
}
.dots {
color: var(--clr-on-secondary);
}
.default {
background-color: var(--clr-secondary);
color: var(--clr-on-secondary);
@media (hover: hover) {
&:hover {
background-color: var(--clr-secondary-hover);
}
}
}
$size: 30px;
$border-radius: 8px;
$font-size: 12px;
.s {
min-width: $size;
height: $size;
border-radius: $border-radius;
font-size: $font-size;
svg {
height: $font-size;
}
}
.m {
min-width: f.m($size);
height: f.m($size);
border-radius: f.m($border-radius);
font-size: f.m($font-size);
svg {
height: f.m($font-size);
}
}
.l {
min-width: f.l($size);
height: f.l($size);
border-radius: f.l($border-radius);
font-size: f.l($font-size);
svg {
height: f.l($font-size);
}
}

View File

@ -0,0 +1,7 @@
import { Scale } from '@components/ui/types';
import { ComponentPropsWithoutRef } from 'react';
export type PaginationItemProps = {
scale: Scale;
variant: 'current' | 'dots' | 'default';
} & ComponentPropsWithoutRef<'button'>;

View File

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

View File

@ -0,0 +1,18 @@
import { PreviewArticle } from '@components/ui/preview';
import React, { useState } from 'react';
import { Pagination } from './component';
export function PaginationPreview() {
const [value1, setValue1] = useState<number>(1);
const [value2, setValue2] = useState<number>(1);
const [value3, setValue3] = useState<number>(1);
return (
<PreviewArticle title="Pagination">
<Pagination value={value1} onChange={setValue1} total={20} scale="s" />
<Pagination value={value2} onChange={setValue2} total={20} scale="m" />
<Pagination value={value3} onChange={setValue3} total={20} scale="l" />
</PreviewArticle>
);
}

View File

@ -0,0 +1,25 @@
@use '@components/func.scss' as f;
.pagination {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.icon {
fill: var(--clr-on-secondary);
}
$gap: 5px;
.s {
gap: $gap;
}
.m {
gap: f.m($gap);
}
.l {
gap: f.l($gap);
}

View File

@ -0,0 +1,10 @@
import { Scale } from '../types';
export type PaginationProps = {
value: number;
onChange?: (value: number) => void;
total: number;
sibling?: number;
boundary?: number;
scale?: Scale;
};

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