@@ -40,3 +38,5 @@ export function Button({
);
}
+
+export const Button = forwardRef(ButtonInner);
diff --git a/front/src/components/ui/button/styles.module.scss b/front/src/components/ui/button/styles.module.scss
index 4069c35..6830e1a 100644
--- a/front/src/components/ui/button/styles.module.scss
+++ b/front/src/components/ui/button/styles.module.scss
@@ -1,14 +1,11 @@
+@use '@components/func.scss' as f;
+
.button {
position: relative;
overflow: hidden;
- box-shadow: 0px 2px 2px var(--clr-shadow-200);
font-weight: 500;
transition: all var(--td-100) ease-in-out;
- &:disabled {
- pointer-events: none;
- }
-
&:not(:disabled) {
cursor: pointer;
}
@@ -26,6 +23,8 @@
}
.pending {
+ pointer-events: none;
+
.childrenWrapper {
visibility: hidden;
}
@@ -35,42 +34,42 @@
background-color: var(--clr-primary);
color: var(--clr-on-primary);
- &:hover {
- background-color: var(--clr-primary-hover);
- }
-
- &.pending {
- background-color: var(--clr-primary-active);
+ @media (hover: hover) {
+ &:hover {
+ background-color: var(--clr-primary-hover);
+ }
}
}
.secondary {
background-color: var(--clr-secondary);
color: var(--clr-on-secondary);
-
- &:hover {
- background-color: var(--clr-secondary-hover);
- }
-
- &.pending {
- background-color: var(--clr-secondary-active);
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: var(--clr-secondary-hover);
+ }
}
}
+$padding: 10px 16px;
+$border-radius: 8px;
+$font-size: 12px;
+
.s {
- padding: 10px 16px;
- border-radius: 8px;
- font-size: 12px;
+ padding: $padding;
+ border-radius: $border-radius;
+ font-size: $font-size;
}
.m {
- padding: 14px 20px;
- border-radius: 10px;
- font-size: 16px;
+ padding: f.m($padding);
+ border-radius: f.m($border-radius);
+ font-size: f.m($font-size);
}
.l {
- padding: 18px 24px;
- border-radius: 12px;
- font-size: 20px;
+ padding: f.l($padding);
+ border-radius: f.l($border-radius);
+ font-size: f.l($font-size);
}
diff --git a/front/src/components/ui/calendar/component.tsx b/front/src/components/ui/calendar/component.tsx
index 33e5451..a44ef64 100644
--- a/front/src/components/ui/calendar/component.tsx
+++ b/front/src/components/ui/calendar/component.tsx
@@ -1,6 +1,6 @@
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
-import { CalendarDays } from './parts';
+import { CalendarDays } from './components';
import { CalendarProps } from './types';
function CalendarInner(
diff --git a/front/src/components/ui/calendar/parts/calendar-days/component.tsx b/front/src/components/ui/calendar/components/calendar-days/component.tsx
similarity index 98%
rename from front/src/components/ui/calendar/parts/calendar-days/component.tsx
rename to front/src/components/ui/calendar/components/calendar-days/component.tsx
index da2bc54..7c3b559 100644
--- a/front/src/components/ui/calendar/parts/calendar-days/component.tsx
+++ b/front/src/components/ui/calendar/components/calendar-days/component.tsx
@@ -33,7 +33,7 @@ export function CalendarDays({
}, [date, min, max]);
const handleChange = (newValue: string) => {
- onChange?.(newValue);
+ onChange(newValue);
};
return (
diff --git a/front/src/components/ui/calendar/parts/calendar-days/constants.ts b/front/src/components/ui/calendar/components/calendar-days/constants.ts
similarity index 100%
rename from front/src/components/ui/calendar/parts/calendar-days/constants.ts
rename to front/src/components/ui/calendar/components/calendar-days/constants.ts
diff --git a/front/src/components/ui/calendar/parts/calendar-days/index.ts b/front/src/components/ui/calendar/components/calendar-days/index.ts
similarity index 100%
rename from front/src/components/ui/calendar/parts/calendar-days/index.ts
rename to front/src/components/ui/calendar/components/calendar-days/index.ts
diff --git a/front/src/components/ui/calendar/parts/calendar-days/styles.module.scss b/front/src/components/ui/calendar/components/calendar-days/styles.module.scss
similarity index 87%
rename from front/src/components/ui/calendar/parts/calendar-days/styles.module.scss
rename to front/src/components/ui/calendar/components/calendar-days/styles.module.scss
index 46dd70c..652a3b6 100644
--- a/front/src/components/ui/calendar/parts/calendar-days/styles.module.scss
+++ b/front/src/components/ui/calendar/components/calendar-days/styles.module.scss
@@ -40,18 +40,11 @@
justify-content: center;
border-radius: 10px;
color: var(--clr-text-100);
+ cursor: pointer;
transition: all var(--td-100) ease-in-out;
- &:not(:disabled) {
- cursor: pointer;
-
- &:hover {
- background-color: var(--clr-layer-300-hover);
- }
- }
-
- &:disabled {
- color: var(--clr-text-100);
+ &:hover {
+ background-color: var(--clr-layer-300-hover);
}
}
diff --git a/front/src/components/ui/calendar/parts/calendar-days/types.ts b/front/src/components/ui/calendar/components/calendar-days/types.ts
similarity index 92%
rename from front/src/components/ui/calendar/parts/calendar-days/types.ts
rename to front/src/components/ui/calendar/components/calendar-days/types.ts
index ccd3598..7f6d27f 100644
--- a/front/src/components/ui/calendar/parts/calendar-days/types.ts
+++ b/front/src/components/ui/calendar/components/calendar-days/types.ts
@@ -9,7 +9,7 @@ export type CalendarDay = {
export type CalendarDaysProps = {
value?: string;
- onChange?: (value: string) => void;
+ onChange: (value: string) => void;
min: Date | null;
max: Date | null;
date: Date;
diff --git a/front/src/components/ui/calendar/parts/calendar-days/utils.ts b/front/src/components/ui/calendar/components/calendar-days/utils.ts
similarity index 77%
rename from front/src/components/ui/calendar/parts/calendar-days/utils.ts
rename to front/src/components/ui/calendar/components/calendar-days/utils.ts
index 7acda21..1eb333e 100644
--- a/front/src/components/ui/calendar/parts/calendar-days/utils.ts
+++ b/front/src/components/ui/calendar/components/calendar-days/utils.ts
@@ -1,11 +1,18 @@
-import { dateToInputString } from '@utils/date';
-
import { CalendarDay, GetCalendarDaysParams } from './types';
const addDays = (date: Date, days: number) => {
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) => {
return (
date1.getDate() === date2.getDate() &&
diff --git a/front/src/components/ui/calendar/parts/index.ts b/front/src/components/ui/calendar/components/index.ts
similarity index 100%
rename from front/src/components/ui/calendar/parts/index.ts
rename to front/src/components/ui/calendar/components/index.ts
diff --git a/front/src/components/ui/calendar/types.ts b/front/src/components/ui/calendar/types.ts
index 107f219..13481a1 100644
--- a/front/src/components/ui/calendar/types.ts
+++ b/front/src/components/ui/calendar/types.ts
@@ -1,6 +1,8 @@
+import { ComponentProps } from 'react';
+
export type CalendarProps = {
value?: string;
- onChange?: (value: string) => void;
+ onChange: (value: string) => void;
min: Date | null;
max: Date | null;
-} & Omit
, 'onChange'>;
+} & Omit, 'onChange'>;
diff --git a/front/src/components/ui/checkbox-group/styles.module.scss b/front/src/components/ui/checkbox-group/styles.module.scss
index b1533e6..2393402 100644
--- a/front/src/components/ui/checkbox-group/styles.module.scss
+++ b/front/src/components/ui/checkbox-group/styles.module.scss
@@ -1,22 +1,26 @@
+@use '@components/func.scss' as f;
+
.checkBoxGroup {
display: flex;
flex-direction: column;
}
+$margin-bottom: 4px;
+
.s {
.label {
- margin-bottom: 3px;
+ margin-bottom: $margin-bottom;
}
}
.m {
.label {
- margin-bottom: 5px;
+ margin-bottom: f.m($margin-bottom);
}
}
.l {
.label {
- margin-bottom: 7px;
+ margin-bottom: f.l($margin-bottom);
}
}
diff --git a/front/src/components/ui/checkbox/component.tsx b/front/src/components/ui/checkbox/component.tsx
index fc287ed..074113c 100644
--- a/front/src/components/ui/checkbox/component.tsx
+++ b/front/src/components/ui/checkbox/component.tsx
@@ -2,7 +2,7 @@ import CheckIcon from '@public/images/svg/check.svg';
import clsx from 'clsx';
import React, { ForwardedRef, forwardRef } from 'react';
-import { Ripple } from '../animation';
+import { Ripple } from '../animation/ripple/component';
import { Label, LabelProps } from '../label';
import { RawInput } from '../raw';
import styles from './styles.module.scss';
diff --git a/front/src/components/ui/checkbox/styles.module.scss b/front/src/components/ui/checkbox/styles.module.scss
index 560f3c8..7a348d7 100644
--- a/front/src/components/ui/checkbox/styles.module.scss
+++ b/front/src/components/ui/checkbox/styles.module.scss
@@ -1,8 +1,11 @@
+@use '@components/func.scss' as f;
+
.wrapper {
position: relative;
overflow: hidden;
border-radius: 100%;
cursor: pointer;
+ user-select: none;
&:hover {
.checkbox {
@@ -54,35 +57,40 @@
transition: all var(--td-100) ease-in-out;
}
+$padding-outer: 4px;
+$size: 16px;
+$padding-inner: 2px;
+$border-radius: 5px;
+
.s {
- padding: 3px;
+ padding: $padding-outer;
.checkbox {
- width: 16px;
- height: 16px;
- padding: 2px;
- border-radius: 5px;
+ width: $size;
+ height: $size;
+ padding: $padding-inner;
+ border-radius: $border-radius;
}
}
.m {
- padding: 5px;
+ padding: f.m($padding-outer);
.checkbox {
- width: 20px;
- height: 20px;
- padding: 3px;
- border-radius: 6px;
+ width: f.m($size);
+ height: f.m($size);
+ padding: f.m($padding-inner);
+ border-radius: f.m($border-radius);
}
}
.l {
- padding: 7px;
+ padding: f.l($padding-outer);
.checkbox {
- width: 24px;
- height: 24px;
- padding: 4px;
- border-radius: 7px;
+ width: f.l($size);
+ height: f.l($size);
+ padding: f.l($padding-inner);
+ border-radius: f.l($border-radius);
}
}
diff --git a/front/src/components/ui/comet/styles.module.scss b/front/src/components/ui/comet/styles.module.scss
index 1df98da..13fef98 100644
--- a/front/src/components/ui/comet/styles.module.scss
+++ b/front/src/components/ui/comet/styles.module.scss
@@ -1,3 +1,5 @@
+@use '@components/func.scss' as f;
+
.comet {
border-radius: 50%;
animation: spinner-comet 1s infinite linear;
@@ -9,23 +11,37 @@
}
}
+$size: 12px;
+$offset: 1.75px;
+
.s {
- width: 12px;
- height: 12px;
- mask: radial-gradient(farthest-side, #0000 calc(100% - 2px), #000 0);
+ width: $size;
+ height: $size;
+ mask: radial-gradient(
+ farthest-side,
+ #0000 calc(100% - $offset),
+ #000 0
+ );
}
.m {
- width: 16px;
- height: 16px;
- mask: radial-gradient(farthest-side, #0000 calc(100% - 2.5px), #000 0);
+ width: f.m($size);
+ height: f.m($size);
+ mask: radial-gradient(
+ farthest-side,
+ #0000 calc(100% - f.m($offset)),
+ #000 0
+ );
}
.l {
- width: 20px;
- height: 20px;
- mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
-}
+ width: f.l($size);
+ height: f.l($size);
+ mask: radial-gradient(
+ farthest-side,
+ #0000 calc(100% - f.l($offset)),
+ #000 0
+ );}
.onPrimary {
background: conic-gradient(#0000 10%, var(--clr-on-primary));
diff --git a/front/src/components/ui/comet/types.ts b/front/src/components/ui/comet/types.ts
index 2508855..be329ec 100644
--- a/front/src/components/ui/comet/types.ts
+++ b/front/src/components/ui/comet/types.ts
@@ -1,6 +1,8 @@
+import { ComponentProps } from 'react';
+
import { Scale } from '../types';
export type CometProps = {
scale?: Scale;
variant?: 'onPrimary' | 'onSecondary';
-} & React.ComponentProps<'div'>;
+} & ComponentProps<'div'>;
diff --git a/front/src/components/ui/data-grid/component.tsx b/front/src/components/ui/data-grid/component.tsx
new file mode 100644
index 0000000..1927b22
--- /dev/null
+++ b/front/src/components/ui/data-grid/component.tsx
@@ -0,0 +1,77 @@
+import React, { useMemo, useState } from 'react';
+
+import { DataGridHeader, DataGridRow } from './components';
+import { DataGridProps } from './types';
+
+export function DataGrid({
+ items,
+ columns,
+ getItemKey,
+ selectedItems,
+ onItemsSelect,
+ multiselect,
+ className,
+ ...props
+}: DataGridProps) {
+ const [allItemsSelected, setAllItemsSelected] = useState(false);
+
+ const columnsTemplate = useMemo(() => {
+ const main = columns.map((c) => `${c.width ?? 1}fr`).join(' ');
+ return `auto ${main}`;
+ }, []);
+
+ const selectedItemsMap = useMemo(() => {
+ const map: Record = {};
+ for (let i = 0; i < selectedItems.length; i += 1) {
+ const item = selectedItems[i];
+ map[String(getItemKey(item))] = item;
+ }
+ return map;
+ }, [selectedItems]);
+
+ const handleSelectAllItems = () => {
+ if (!multiselect) {
+ return;
+ }
+ onItemsSelect?.(allItemsSelected ? [] : [...items]);
+ setAllItemsSelected(!allItemsSelected);
+ };
+
+ const handleItemSelect = (key: string, item: T) => {
+ const selected = Boolean(selectedItemsMap[key]);
+ if (!multiselect) {
+ onItemsSelect?.(selected ? [] : [item]);
+ return;
+ }
+ onItemsSelect?.(
+ selected
+ ? selectedItems.filter((i) => key !== getItemKey(i))
+ : [...selectedItems, item],
+ );
+ setAllItemsSelected(false);
+ };
+
+ return (
+
+
+ {items.map((item) => {
+ const key = String(getItemKey(item));
+ return (
+ handleItemSelect(key, item)}
+ key={getItemKey(item)}
+ columnsTemplate={columnsTemplate}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/component.tsx b/front/src/components/ui/data-grid/components/DataGridHeader/component.tsx
new file mode 100644
index 0000000..5baf054
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/DataGridHeader/component.tsx
@@ -0,0 +1,65 @@
+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({
+ columns,
+ allItemsSelected,
+ onSelectAllItems,
+ columnsTemplate,
+}: DataGridHeaderProps) {
+ const [sort, setSort] = useState({ 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 (
+
+
+ {columns.map((column) => {
+ const isActive = sort.column === column.name;
+ const cellClassName = clsx(styles.cell, {
+ [styles.activeCell]: isActive,
+ [styles.desc]: isActive && sort.order === 'desc',
+ });
+ return (
+ handleSortButtonClick(column.name)}
+ >
+
+ {column.name}
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/index.ts b/front/src/components/ui/data-grid/components/DataGridHeader/index.ts
new file mode 100644
index 0000000..bb82484
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/DataGridHeader/index.ts
@@ -0,0 +1 @@
+export * from './component';
diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss b/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss
new file mode 100644
index 0000000..9e87119
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss
@@ -0,0 +1,59 @@
+.header {
+ display: grid;
+}
+
+.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;
+ 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;
+
+ &:last-of-type {
+ border-top-right-radius: 10px;
+ }
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: var(--clr-layer-300-hover);
+ }
+ }
+}
+
+.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;
+ }
+}
diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/types.ts b/front/src/components/ui/data-grid/components/DataGridHeader/types.ts
new file mode 100644
index 0000000..013d3fd
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/DataGridHeader/types.ts
@@ -0,0 +1,8 @@
+import { DataGridColumnConfig } from '../../types';
+
+export type DataGridHeaderProps = {
+ columns: DataGridColumnConfig[];
+ allItemsSelected: boolean;
+ onSelectAllItems: () => void;
+ columnsTemplate: string;
+};
diff --git a/front/src/components/ui/data-grid/components/DataGridRow/component.tsx b/front/src/components/ui/data-grid/components/DataGridRow/component.tsx
new file mode 100644
index 0000000..ec35b86
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/DataGridRow/component.tsx
@@ -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({
+ object,
+ columns,
+ selected,
+ onSelect,
+ columnsTemplate,
+}: DataGridRowProps) {
+ return (
+
+
+ {columns.map((column) => (
+
+ {column.getText(object)}
+
+ ))}
+
+ );
+}
diff --git a/front/src/components/ui/data-grid/components/DataGridRow/index.ts b/front/src/components/ui/data-grid/components/DataGridRow/index.ts
new file mode 100644
index 0000000..bb82484
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/DataGridRow/index.ts
@@ -0,0 +1 @@
+export * from './component';
diff --git a/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss b/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss
new file mode 100644
index 0000000..3513e15
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss
@@ -0,0 +1,19 @@
+.row {
+ display: grid;
+}
+
+.checkboxLabel {
+ padding: 10px;
+ border: solid 1px var(--clr-border-100);
+ background-color: var(--clr-layer-200);
+}
+
+.cell {
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ padding: 10px;
+ border: solid 1px var(--clr-border-100);
+ background-color: var(--clr-layer-200);
+ overflow-wrap: anywhere;
+}
diff --git a/front/src/components/ui/data-grid/components/DataGridRow/types.ts b/front/src/components/ui/data-grid/components/DataGridRow/types.ts
new file mode 100644
index 0000000..54fc089
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/DataGridRow/types.ts
@@ -0,0 +1,9 @@
+import { DataGridColumnConfig } from '../../types';
+
+export type DataGridRowProps = {
+ object: T;
+ columns: DataGridColumnConfig[];
+ selected: boolean;
+ onSelect: () => void;
+ columnsTemplate: string;
+};
diff --git a/front/src/components/ui/data-grid/components/index.ts b/front/src/components/ui/data-grid/components/index.ts
new file mode 100644
index 0000000..f703f00
--- /dev/null
+++ b/front/src/components/ui/data-grid/components/index.ts
@@ -0,0 +1,2 @@
+export * from './DataGridHeader';
+export * from './DataGridRow';
diff --git a/front/src/components/ui/data-grid/index.ts b/front/src/components/ui/data-grid/index.ts
new file mode 100644
index 0000000..fb2697a
--- /dev/null
+++ b/front/src/components/ui/data-grid/index.ts
@@ -0,0 +1,2 @@
+export * from './component';
+export * from './preview';
diff --git a/front/src/components/ui/data-grid/preview.tsx b/front/src/components/ui/data-grid/preview.tsx
new file mode 100644
index 0000000..710c434
--- /dev/null
+++ b/front/src/components/ui/data-grid/preview.tsx
@@ -0,0 +1,46 @@
+import { PreviewArticle } from '@components/ui/preview';
+import React, { useState } from 'react';
+
+import { DataGrid } from './component';
+import { DataGridColumnConfig } from './types';
+
+type Cat = {
+ name: string;
+ breed: string;
+ age: string;
+ color: string;
+};
+
+export function DataGridPreview() {
+ const [selectedItems, setSelectedItems] = useState([]);
+
+ 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[] = [
+ { name: 'Name', getText: (cat) => cat.name },
+ { name: 'Breed', getText: (cat) => cat.breed, width: 2 },
+ { name: 'Age', getText: (cat) => cat.age },
+ { name: 'Color', getText: (cat) => cat.color },
+ ];
+
+ return (
+
+
+ name}
+ selectedItems={selectedItems}
+ onItemsSelect={setSelectedItems}
+ />
+
+
+ );
+}
diff --git a/front/src/components/ui/data-grid/types.ts b/front/src/components/ui/data-grid/types.ts
new file mode 100644
index 0000000..736918e
--- /dev/null
+++ b/front/src/components/ui/data-grid/types.ts
@@ -0,0 +1,22 @@
+import { ComponentPropsWithoutRef, Key } from 'react';
+
+export type DataGridColumnConfig = {
+ name: string;
+ getText: (object: T) => string;
+ sortable?: boolean;
+ width?: number;
+};
+
+export type DataGridSort = {
+ order: 'asc' | 'desc';
+ column: string;
+};
+
+export type DataGridProps = {
+ items: T[];
+ columns: DataGridColumnConfig[];
+ getItemKey: (item: T) => Key;
+ selectedItems?: T[];
+ onItemsSelect?: (selectedItems: T[]) => void;
+ multiselect?: boolean;
+} & ComponentPropsWithoutRef<'div'>;
diff --git a/front/src/components/ui/date-input/component.tsx b/front/src/components/ui/date-input/component.tsx
index 5eaf3c6..1a80cd3 100644
--- a/front/src/components/ui/date-input/component.tsx
+++ b/front/src/components/ui/date-input/component.tsx
@@ -1,5 +1,4 @@
import CalendarIcon from '@public/images/svg/calendar.svg';
-import { px } from '@utils/css';
import { useMissClick } from '@utils/miss-click';
import React, { useEffect, useMemo, useRef, useState } from 'react';
@@ -38,16 +37,24 @@ export function DateInput({
setDirtyDate(valueToDirtyDate(value));
}, [value]);
- useMissClick(
- [wrapperRef, calendarWrapperRef],
- () => setCalendarVisible(false),
- calendarVisible,
- );
+ useMissClick({
+ callback: () => setCalendarVisible(false),
+ enabled: calendarVisible,
+ whitelist: [wrapperRef, calendarWrapperRef],
+ });
const handleCalendarButtonClick = () => {
setCalendarVisible(!calendarVisible);
};
+ const handleCalendarButtonMouseDown = (event: React.MouseEvent) => {
+ event.preventDefault();
+ };
+
+ const handleCalendarButtonMouseUp = (event: React.MouseEvent) => {
+ event.preventDefault();
+ };
+
const handleInputChange = (event: React.ChangeEvent) => {
const newDirtyDate = inputToDirtyDate(event.target.value);
if (newDirtyDate.length === 10) {
@@ -58,42 +65,19 @@ export function DateInput({
(!minDate || date >= minDate) &&
(!maxDate || date <= maxDate)
) {
- onChange?.(newValue);
+ onChange(newValue);
} else {
- onChange?.('');
+ onChange('');
}
}
setDirtyDate(newDirtyDate);
};
const handleCalendarChange = (newValue: string) => {
- onChange?.(newValue);
+ onChange(newValue);
setCalendarVisible(false);
};
- const calcPopoverStyles = (calendarRect: DOMRect) => {
- if (calendarRect === null) {
- return {};
- }
-
- const inputWrapperRect = inputWrapperRef.current.getBoundingClientRect();
- const { left, bottom, top } = inputWrapperRect;
-
- const rightSpace = window.innerWidth - left;
- const rightOverflow = calendarRect.width - rightSpace;
- const bottomSpace = window.innerHeight - bottom;
-
- const popoverLeft = rightOverflow <= 0 ? left : left - rightOverflow;
-
- const popoverTop =
- bottomSpace >= calendarRect.height ? bottom : top - calendarRect.height;
-
- return {
- left: px(popoverLeft),
- top: px(popoverTop),
- };
- };
-
return (
-
+
{rightNode}
@@ -113,7 +102,10 @@ export function DateInput({
/>
void;
+ onChange: (value: string) => void;
max?: string;
min?: string;
} & Omit;
diff --git a/front/src/components/ui/dialog/component.tsx b/front/src/components/ui/dialog/component.tsx
new file mode 100644
index 0000000..0fcfa10
--- /dev/null
+++ b/front/src/components/ui/dialog/component.tsx
@@ -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(null);
+
+ useMissClick({
+ callback: onClose,
+ enabled: open,
+ whitelist: [dialogRef, triggerElementRef],
+ });
+
+ const dialogClassName = clsx(styles.dialog, className);
+
+ return (
+
+
+
+
{heading}
+
{message}
+ {children}
+
+
+
+ );
+}
diff --git a/front/src/components/ui/dialog/index.ts b/front/src/components/ui/dialog/index.ts
new file mode 100644
index 0000000..fb2697a
--- /dev/null
+++ b/front/src/components/ui/dialog/index.ts
@@ -0,0 +1,2 @@
+export * from './component';
+export * from './preview';
diff --git a/front/src/components/ui/dialog/preview.tsx b/front/src/components/ui/dialog/preview.tsx
new file mode 100644
index 0000000..e3a9d44
--- /dev/null
+++ b/front/src/components/ui/dialog/preview.tsx
@@ -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(null);
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/front/src/components/ui/dialog/styles.module.scss b/front/src/components/ui/dialog/styles.module.scss
new file mode 100644
index 0000000..71b4e34
--- /dev/null
+++ b/front/src/components/ui/dialog/styles.module.scss
@@ -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;
+}
diff --git a/front/src/components/ui/dialog/types.ts b/front/src/components/ui/dialog/types.ts
new file mode 100644
index 0000000..9478b35
--- /dev/null
+++ b/front/src/components/ui/dialog/types.ts
@@ -0,0 +1,9 @@
+import { ComponentPropsWithoutRef, MutableRefObject } from 'react';
+
+export type DialogProps = {
+ heading: string;
+ message: string;
+ open: boolean;
+ onClose: () => void;
+ triggerElementRef?: MutableRefObject;
+} & ComponentPropsWithoutRef<'div'>;
diff --git a/front/src/components/ui/file-uploader/component.tsx b/front/src/components/ui/file-uploader/component.tsx
new file mode 100644
index 0000000..d797c96
--- /dev/null
+++ b/front/src/components/ui/file-uploader/component.tsx
@@ -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(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) => {
+ 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 (
+
+ );
+}
diff --git a/front/src/components/ui/file-uploader/index.ts b/front/src/components/ui/file-uploader/index.ts
new file mode 100644
index 0000000..1d94b2f
--- /dev/null
+++ b/front/src/components/ui/file-uploader/index.ts
@@ -0,0 +1 @@
+export { FileUploader } from './component';
diff --git a/front/src/components/ui/file-uploader/preview.tsx b/front/src/components/ui/file-uploader/preview.tsx
new file mode 100644
index 0000000..8108d07
--- /dev/null
+++ b/front/src/components/ui/file-uploader/preview.tsx
@@ -0,0 +1,14 @@
+import { PreviewArticle } from '@components/ui/preview';
+import React from 'react';
+
+import { FileUploader } from './component';
+
+export function FileUploaderPreview() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/front/src/components/ui/file-uploader/style.module.scss b/front/src/components/ui/file-uploader/style.module.scss
new file mode 100644
index 0000000..c90402e
--- /dev/null
+++ b/front/src/components/ui/file-uploader/style.module.scss
@@ -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);
+ }
+}
diff --git a/front/src/components/ui/file-uploader/types.ts b/front/src/components/ui/file-uploader/types.ts
new file mode 100644
index 0000000..564bbdc
--- /dev/null
+++ b/front/src/components/ui/file-uploader/types.ts
@@ -0,0 +1,13 @@
+import { ComponentPropsWithoutRef } from 'react';
+
+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;
+} & Omit, 'onChange'>;
diff --git a/front/src/components/ui/heading/types.ts b/front/src/components/ui/heading/types.ts
index 0b11346..d9e8965 100644
--- a/front/src/components/ui/heading/types.ts
+++ b/front/src/components/ui/heading/types.ts
@@ -1,3 +1,5 @@
+import { ComponentProps } from 'react';
+
import { TextColor } from '../types';
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 = {
tag: T;
color?: TextColor;
-} & React.ComponentProps;
+} & ComponentProps;
diff --git a/front/src/components/ui/icon-button/component.tsx b/front/src/components/ui/icon-button/component.tsx
index 1e8fcbd..1f30bb5 100644
--- a/front/src/components/ui/icon-button/component.tsx
+++ b/front/src/components/ui/icon-button/component.tsx
@@ -1,22 +1,33 @@
import clsx from 'clsx';
-import React from 'react';
+import React, { ForwardedRef, forwardRef } from 'react';
import { Ripple } from '../animation';
import { RawButton } from '../raw';
import styles from './styles.module.scss';
import { IconButtonProps } from './types';
-export function IconButton({
- scale = 'm',
- className,
- children,
- ...props
-}: IconButtonProps) {
- const classes = clsx(styles.button, styles[scale], className);
+function IconButtonInner(
+ {
+ variant = 'circle',
+ scale = 'm',
+ className,
+ children,
+ ...props
+ }: IconButtonProps,
+ ref: ForwardedRef,
+) {
+ const buttonClassName = clsx(
+ styles.button,
+ styles[scale],
+ styles[variant],
+ className,
+ );
return (
-
+
{children}
);
}
+
+export const IconButton = forwardRef(IconButtonInner);
diff --git a/front/src/components/ui/icon-button/styles.module.scss b/front/src/components/ui/icon-button/styles.module.scss
index f971457..d0c39c1 100644
--- a/front/src/components/ui/icon-button/styles.module.scss
+++ b/front/src/components/ui/icon-button/styles.module.scss
@@ -1,41 +1,77 @@
+@use '@components/func.scss' as f;
+
.button {
position: relative;
overflow: hidden;
- border-radius: 100%;
background-color: transparent;
cursor: pointer;
transition: all var(--td-100) ease-in-out;
- &:hover {
- background-color: var(--clr-ripple);
-
- svg {
- fill: var(--clr-text-300);
- }
- }
-
svg {
width: 100%;
height: 100%;
fill: var(--clr-text-100);
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;
+$rect-padding: 6px;
+$circle-padding: 4px;
+$border-radius: 8px;
+
.s {
- width: 27px;
- height: 27px;
- padding: 4px;
+ width: $size;
+ height: $size;
+
+ &.rect {
+ padding: $rect-padding;
+ border-radius: $border-radius;
+ }
+
+ &.circle {
+ padding: $circle-padding;
+ }
}
.m {
- width: 35px;
- height: 35px;
- padding: 6px;
+ width: f.m($size);
+ height: f.m($size);
+
+ &.rect {
+ padding: f.m($rect-padding);
+ border-radius: f.m($border-radius);
+ }
+
+ &.circle {
+ padding: f.m($circle-padding);
+ }
}
.l {
- width: 43px;
- height: 43px;
- padding: 8px;
+ width: f.l($size);
+ height: f.l($size);
+
+ &.rect {
+ padding: f.l($rect-padding);
+ border-radius: f.l($border-radius);
+ }
+
+ &.circle {
+ padding: f.l($circle-padding);
+ }
}
diff --git a/front/src/components/ui/icon-button/types.ts b/front/src/components/ui/icon-button/types.ts
index 82de98a..bfaee29 100644
--- a/front/src/components/ui/icon-button/types.ts
+++ b/front/src/components/ui/icon-button/types.ts
@@ -3,5 +3,6 @@ import { Scale } from '@components/ui/types';
import { RawButtonProps } from '../raw';
export type IconButtonProps = {
+ variant?: 'circle' | 'rect';
scale?: Scale;
} & RawButtonProps;
diff --git a/front/src/components/ui/image-file-manager/component.tsx b/front/src/components/ui/image-file-manager/component.tsx
new file mode 100644
index 0000000..e652e7a
--- /dev/null
+++ b/front/src/components/ui/image-file-manager/component.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/front/src/components/ui/image-file-manager/index.ts b/front/src/components/ui/image-file-manager/index.ts
new file mode 100644
index 0000000..0fff9fa
--- /dev/null
+++ b/front/src/components/ui/image-file-manager/index.ts
@@ -0,0 +1 @@
+export { ImageFileManager } from './component';
diff --git a/front/src/components/ui/image-file-manager/preview.tsx b/front/src/components/ui/image-file-manager/preview.tsx
new file mode 100644
index 0000000..4ecbe2f
--- /dev/null
+++ b/front/src/components/ui/image-file-manager/preview.tsx
@@ -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(null);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/front/src/components/ui/image-file-manager/styles.module.scss b/front/src/components/ui/image-file-manager/styles.module.scss
new file mode 100644
index 0000000..80eff0a
--- /dev/null
+++ b/front/src/components/ui/image-file-manager/styles.module.scss
@@ -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);
+}
diff --git a/front/src/components/ui/image-file-manager/types.ts b/front/src/components/ui/image-file-manager/types.ts
new file mode 100644
index 0000000..9014ff8
--- /dev/null
+++ b/front/src/components/ui/image-file-manager/types.ts
@@ -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, 'onChange'>;
diff --git a/front/src/components/ui/image-viewer/component.tsx b/front/src/components/ui/image-viewer/component.tsx
new file mode 100644
index 0000000..f7860ec
--- /dev/null
+++ b/front/src/components/ui/image-viewer/component.tsx
@@ -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 (
+
+ {file ? (
+ <>
+
+
+ >
+ ) : (
+
+ File not uploaded
+
+ )}
+
+ );
+}
diff --git a/front/src/components/ui/image-viewer/index.ts b/front/src/components/ui/image-viewer/index.ts
new file mode 100644
index 0000000..52d66d1
--- /dev/null
+++ b/front/src/components/ui/image-viewer/index.ts
@@ -0,0 +1 @@
+export { ImageViewer } from './component';
diff --git a/front/src/components/ui/image-viewer/styles.module.scss b/front/src/components/ui/image-viewer/styles.module.scss
new file mode 100644
index 0000000..87d1617
--- /dev/null
+++ b/front/src/components/ui/image-viewer/styles.module.scss
@@ -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);
+ }
+}
diff --git a/front/src/components/ui/image-viewer/types.ts b/front/src/components/ui/image-viewer/types.ts
new file mode 100644
index 0000000..2b44ce3
--- /dev/null
+++ b/front/src/components/ui/image-viewer/types.ts
@@ -0,0 +1,7 @@
+import { Scale } from '../types';
+
+export type ImageViewerProps = {
+ file?: File;
+ scale?: Scale;
+ onClear?: () => void;
+};
diff --git a/front/src/components/ui/index.tsx b/front/src/components/ui/index.ts
similarity index 54%
rename from front/src/components/ui/index.tsx
rename to front/src/components/ui/index.ts
index e485f62..9831603 100644
--- a/front/src/components/ui/index.tsx
+++ b/front/src/components/ui/index.ts
@@ -1,13 +1,23 @@
+export { Autocomplete } from './autocomplete';
export { Button } from './button';
export { Checkbox } from './checkbox';
export { CheckboxGroup } from './checkbox-group';
export { DateInput } from './date-input';
+export { Dialog } from './dialog';
+export { FileUploader } from './file-uploader';
export { Heading } from './heading';
export { IconButton } from './icon-button';
+export { ImageFileManager } from './image-file-manager';
+export { LinkButton } from './link-button';
export { Menu } from './menu';
+export { NumberField } from './number-field';
+export { NumberInput } from './number-input';
+export { Overlay } from './overlay';
+export { Pagination } from './pagination';
export { Paragraph } from './paragraph';
export { PasswordInput } from './password-input';
export { RadioGroup } from './radio-group';
export { Select } from './select';
export { Span } from './span';
+export { TextArea } from './text-area';
export { TextInput } from './text-input';
diff --git a/front/src/components/ui/input/component.tsx b/front/src/components/ui/input/component.tsx
index 97921b6..8eeb60c 100644
--- a/front/src/components/ui/input/component.tsx
+++ b/front/src/components/ui/input/component.tsx
@@ -11,6 +11,7 @@ function InputInner(
wrapper = {},
leftNode,
rightNode,
+ invalid,
className,
onFocus,
onBlur,
@@ -24,6 +25,7 @@ function InputInner(
styles.wrapper,
focus && styles.wrapperFocus,
wrapper.className,
+ invalid && styles.invalid,
);
const inputClassNames = clsx(styles.input, className);
diff --git a/front/src/components/ui/input/styles.module.scss b/front/src/components/ui/input/styles.module.scss
index c4d2fc2..30d43f3 100644
--- a/front/src/components/ui/input/styles.module.scss
+++ b/front/src/components/ui/input/styles.module.scss
@@ -1,3 +1,5 @@
+@use '@components/func.scss' as f;
+
.wrapper {
display: flex;
align-items: center;
@@ -12,6 +14,10 @@
}
}
+.invalid {
+ border-color: var(--clr-error);
+}
+
.wrapperFocus {
z-index: 1;
border-color: var(--clr-primary);
@@ -26,29 +32,33 @@
outline: none;
}
+$border-radius: 8px;
+$padding: 9px;
+$font-size: 12px;
+
.s {
- border-radius: 8px;
+ border-radius: $border-radius;
.input {
- padding: 9px;
- font-size: 12px;
+ padding: $padding;
+ font-size: $font-size;
}
}
.m {
- border-radius: 10px;
+ border-radius: f.m($border-radius);
.input {
- padding: 13px;
- font-size: 16px;
+ padding: f.m($padding);
+ font-size: f.m($font-size);
}
}
.l {
- border-radius: 12px;
+ border-radius: f.l($border-radius);
.input {
- padding: 17px;
- font-size: 20px;
+ padding: f.l($padding);
+ font-size: f.l($font-size);
}
}
diff --git a/front/src/components/ui/input/types.ts b/front/src/components/ui/input/types.ts
index 4b88da0..8a4a0cc 100644
--- a/front/src/components/ui/input/types.ts
+++ b/front/src/components/ui/input/types.ts
@@ -1,12 +1,14 @@
import { Scale } from '@components/ui/types';
+import { ComponentProps, ReactNode } from 'react';
import { RawInputProps } from '../raw';
type InputProps = {
scale?: Scale;
- wrapper?: React.ComponentProps<'div'>;
- leftNode?: React.ReactNode;
- rightNode?: React.ReactNode;
+ wrapper?: ComponentProps<'div'>;
+ leftNode?: ReactNode;
+ rightNode?: ReactNode;
+ invalid?: boolean;
} & RawInputProps;
export { InputProps };
diff --git a/front/src/components/ui/label/component.tsx b/front/src/components/ui/label/component.tsx
index 4802220..8741eb8 100644
--- a/front/src/components/ui/label/component.tsx
+++ b/front/src/components/ui/label/component.tsx
@@ -11,6 +11,7 @@ function LabelInner(
scale,
position = 'top',
required = {},
+ error,
className,
children,
...props
@@ -29,6 +30,11 @@ function LabelInner(
)}
{!reversed && children}
+ {error && (
+
+ {error}
+
+ )}
);
}
diff --git a/front/src/components/ui/label/styles.module.scss b/front/src/components/ui/label/styles.module.scss
index 184cd50..e8a5779 100644
--- a/front/src/components/ui/label/styles.module.scss
+++ b/front/src/components/ui/label/styles.module.scss
@@ -1,5 +1,9 @@
.label {
display: flex;
+
+ .error {
+ color: var(--clr-error);
+ }
}
.left,
diff --git a/front/src/components/ui/label/types.ts b/front/src/components/ui/label/types.ts
index a3113e4..4360229 100644
--- a/front/src/components/ui/label/types.ts
+++ b/front/src/components/ui/label/types.ts
@@ -1,3 +1,5 @@
+import { ComponentProps } from 'react';
+
import { Required, Scale } from '../types';
export type LabelProps = {
@@ -5,4 +7,5 @@ export type LabelProps = {
scale?: Scale;
position?: 'left' | 'top' | 'right' | 'bottom';
required?: Required;
-} & React.ComponentProps<'label'>;
+ error?: string;
+} & ComponentProps<'label'>;
diff --git a/front/src/components/ui/link-button/component.tsx b/front/src/components/ui/link-button/component.tsx
new file mode 100644
index 0000000..ce6207a
--- /dev/null
+++ b/front/src/components/ui/link-button/component.tsx
@@ -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 (
+
+ {children}
+
+
+ );
+}
diff --git a/front/src/components/ui/link-button/index.tsx b/front/src/components/ui/link-button/index.tsx
new file mode 100644
index 0000000..bb82484
--- /dev/null
+++ b/front/src/components/ui/link-button/index.tsx
@@ -0,0 +1 @@
+export * from './component';
diff --git a/front/src/components/ui/link-button/styles.module.scss b/front/src/components/ui/link-button/styles.module.scss
new file mode 100644
index 0000000..f477aea
--- /dev/null
+++ b/front/src/components/ui/link-button/styles.module.scss
@@ -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);
+}
diff --git a/front/src/components/ui/link-button/types.ts b/front/src/components/ui/link-button/types.ts
new file mode 100644
index 0000000..e9e0d95
--- /dev/null
+++ b/front/src/components/ui/link-button/types.ts
@@ -0,0 +1,7 @@
+import { ComponentPropsWithoutRef } from 'react';
+
+import { Scale } from '../types';
+
+export type LinkButtonProps = {
+ scale?: Scale;
+} & ComponentPropsWithoutRef<'a'>;
diff --git a/front/src/components/ui/menu/types.ts b/front/src/components/ui/menu/types.ts
index bcc3787..5c22e09 100644
--- a/front/src/components/ui/menu/types.ts
+++ b/front/src/components/ui/menu/types.ts
@@ -1,7 +1,9 @@
+import { ComponentProps, Key } from 'react';
+
export type MenuProps = {
options: T[];
selected?: T;
- getOptionKey: (option: T) => React.Key;
+ getOptionKey: (option: T) => Key;
getOptionLabel: (option: T) => string;
onSelect?: (option: T) => void;
-} & Omit, 'onSelect'>;
+} & Omit, 'onSelect'>;
diff --git a/front/src/components/ui/number-field/component.tsx b/front/src/components/ui/number-field/component.tsx
new file mode 100644
index 0000000..e9adcb3
--- /dev/null
+++ b/front/src/components/ui/number-field/component.tsx
@@ -0,0 +1,28 @@
+import React, { ForwardedRef, forwardRef } from 'react';
+
+import { Label, LabelProps } from '../label';
+import { NumberInput } from '../number-input';
+import { NumberFieldProps } from './types';
+
+function NumberFieldInner(
+ { scale, label = {}, required, ...props }: Omit,
+ ref: ForwardedRef,
+) {
+ const labelProps: LabelProps = {
+ ...label,
+ required: { value: required, ...label.required },
+ };
+ return (
+
+ );
+}
+
+export const NumberField = forwardRef(NumberFieldInner);
diff --git a/front/src/components/ui/number-field/index.tsx b/front/src/components/ui/number-field/index.tsx
new file mode 100644
index 0000000..bb82484
--- /dev/null
+++ b/front/src/components/ui/number-field/index.tsx
@@ -0,0 +1 @@
+export * from './component';
diff --git a/front/src/components/ui/number-field/types.ts b/front/src/components/ui/number-field/types.ts
new file mode 100644
index 0000000..0050137
--- /dev/null
+++ b/front/src/components/ui/number-field/types.ts
@@ -0,0 +1,6 @@
+import { LabelProps } from '../label/types';
+import { NumberInputProps } from '../number-input/types';
+
+export type NumberFieldProps = {
+ label?: LabelProps;
+} & NumberInputProps;
diff --git a/front/src/components/ui/number-input/component.tsx b/front/src/components/ui/number-input/component.tsx
new file mode 100644
index 0000000..c47bbdc
--- /dev/null
+++ b/front/src/components/ui/number-input/component.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+
+import { Input } from '../input';
+import { NumberInputProps } from './types';
+
+export function NumberInput({
+ value,
+ onChange,
+ float = false,
+ negative = false,
+ ...props
+}: NumberInputProps) {
+ const extractNumber = (event: React.ChangeEvent) => {
+ 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) => {
+ const num = extractNumber(event);
+ if (num === null) {
+ return;
+ }
+ onChange?.(num);
+ };
+
+ return ;
+}
diff --git a/front/src/components/ui/number-input/index.tsx b/front/src/components/ui/number-input/index.tsx
new file mode 100644
index 0000000..101f010
--- /dev/null
+++ b/front/src/components/ui/number-input/index.tsx
@@ -0,0 +1 @@
+export { NumberInput } from './component';
diff --git a/front/src/components/ui/number-input/preview.tsx b/front/src/components/ui/number-input/preview.tsx
new file mode 100644
index 0000000..185c1ab
--- /dev/null
+++ b/front/src/components/ui/number-input/preview.tsx
@@ -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('');
+ const [value2, setValue2] = useState('');
+ const [value3, setValue3] = useState('');
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/front/src/components/ui/number-input/types.ts b/front/src/components/ui/number-input/types.ts
new file mode 100644
index 0000000..ea020c7
--- /dev/null
+++ b/front/src/components/ui/number-input/types.ts
@@ -0,0 +1,8 @@
+import { TextInputProps } from '../text-input';
+
+export type NumberInputProps = {
+ float?: boolean;
+ negative?: boolean;
+ value?: string;
+ onChange?: (value: string) => void;
+} & Omit;
diff --git a/front/src/components/ui/overlay/component.tsx b/front/src/components/ui/overlay/component.tsx
new file mode 100644
index 0000000..1d4952d
--- /dev/null
+++ b/front/src/components/ui/overlay/component.tsx
@@ -0,0 +1,47 @@
+import clsx from 'clsx';
+import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+import styles from './styles.module.scss';
+import { OverlayProps } from './types';
+
+function OverlayInner(
+ { open, className, ...props }: OverlayProps,
+ ref: ForwardedRef,
+) {
+ const [openInternal, setOpenInternal] = useState(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(
+ ,
+ document.body,
+ );
+}
+
+export const Overlay = forwardRef(OverlayInner);
diff --git a/front/src/components/ui/overlay/index.tsx b/front/src/components/ui/overlay/index.tsx
new file mode 100644
index 0000000..bb82484
--- /dev/null
+++ b/front/src/components/ui/overlay/index.tsx
@@ -0,0 +1 @@
+export * from './component';
diff --git a/front/src/components/ui/overlay/styles.module.scss b/front/src/components/ui/overlay/styles.module.scss
new file mode 100644
index 0000000..58ac456
--- /dev/null
+++ b/front/src/components/ui/overlay/styles.module.scss
@@ -0,0 +1,30 @@
+.overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100dvh;
+ animation: fadein 0.25s forwards ease-in-out;
+}
+
+.closed {
+ animation: fadeout 0.25s forwards ease-in-out;
+}
+
+@keyframes fadein {
+ from {
+ background-color: rgba(0 0 0 / 0);
+ }
+ to {
+ 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);
+ }
+}
diff --git a/front/src/components/ui/overlay/types.ts b/front/src/components/ui/overlay/types.ts
new file mode 100644
index 0000000..166871f
--- /dev/null
+++ b/front/src/components/ui/overlay/types.ts
@@ -0,0 +1,5 @@
+import { ComponentPropsWithoutRef } from 'react';
+
+export type OverlayProps = {
+ open: boolean;
+} & ComponentPropsWithoutRef<'div'>;
diff --git a/front/src/components/ui/pagination/component.tsx b/front/src/components/ui/pagination/component.tsx
new file mode 100644
index 0000000..2bec239
--- /dev/null
+++ b/front/src/components/ui/pagination/component.tsx
@@ -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 (
+
+
+
+
+ {pageNumbers.map((number, index) => {
+ if (number === null) {
+ return (
+
+ ...
+
+ );
+ }
+ const isCurrent = number === value;
+ return (
+
onChange?.(number)}
+ >
+ {number}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/front/src/components/ui/pagination/components/index.ts b/front/src/components/ui/pagination/components/index.ts
new file mode 100644
index 0000000..ea4561b
--- /dev/null
+++ b/front/src/components/ui/pagination/components/index.ts
@@ -0,0 +1 @@
+export * from './pagination-item';
diff --git a/front/src/components/ui/pagination/components/pagination-item/component.tsx b/front/src/components/ui/pagination/components/pagination-item/component.tsx
new file mode 100644
index 0000000..8e27bf5
--- /dev/null
+++ b/front/src/components/ui/pagination/components/pagination-item/component.tsx
@@ -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 (
+
+ {children}
+
+
+ );
+}
diff --git a/front/src/components/ui/pagination/components/pagination-item/index.ts b/front/src/components/ui/pagination/components/pagination-item/index.ts
new file mode 100644
index 0000000..bb82484
--- /dev/null
+++ b/front/src/components/ui/pagination/components/pagination-item/index.ts
@@ -0,0 +1 @@
+export * from './component';
diff --git a/front/src/components/ui/pagination/components/pagination-item/styles.module.scss b/front/src/components/ui/pagination/components/pagination-item/styles.module.scss
new file mode 100644
index 0000000..b78bdf0
--- /dev/null
+++ b/front/src/components/ui/pagination/components/pagination-item/styles.module.scss
@@ -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);
+ }
+}
diff --git a/front/src/components/ui/pagination/components/pagination-item/types.ts b/front/src/components/ui/pagination/components/pagination-item/types.ts
new file mode 100644
index 0000000..a2ea2fa
--- /dev/null
+++ b/front/src/components/ui/pagination/components/pagination-item/types.ts
@@ -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'>;
diff --git a/front/src/components/ui/pagination/index.ts b/front/src/components/ui/pagination/index.ts
new file mode 100644
index 0000000..1d89bd8
--- /dev/null
+++ b/front/src/components/ui/pagination/index.ts
@@ -0,0 +1,2 @@
+export { Pagination } from './component';
+export { PaginationPreview } from './preview';
diff --git a/front/src/components/ui/pagination/preview.tsx b/front/src/components/ui/pagination/preview.tsx
new file mode 100644
index 0000000..1c57082
--- /dev/null
+++ b/front/src/components/ui/pagination/preview.tsx
@@ -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(1);
+ const [value2, setValue2] = useState(1);
+ const [value3, setValue3] = useState(1);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/front/src/components/ui/pagination/styles.module.scss b/front/src/components/ui/pagination/styles.module.scss
new file mode 100644
index 0000000..50ed4da
--- /dev/null
+++ b/front/src/components/ui/pagination/styles.module.scss
@@ -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);
+}
diff --git a/front/src/components/ui/pagination/types.ts b/front/src/components/ui/pagination/types.ts
new file mode 100644
index 0000000..fde3f63
--- /dev/null
+++ b/front/src/components/ui/pagination/types.ts
@@ -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;
+};
diff --git a/front/src/components/ui/pagination/utils.ts b/front/src/components/ui/pagination/utils.ts
new file mode 100644
index 0000000..e5a7b11
--- /dev/null
+++ b/front/src/components/ui/pagination/utils.ts
@@ -0,0 +1,30 @@
+export const getPageNumbers = (
+ page: number,
+ total: number,
+ sibling: number,
+ boundary: number,
+): number[] => {
+ const pages: number[] = [];
+ const visible = Math.min(total, (boundary + sibling + 1) * 2 + 1);
+
+ const isOverflow = visible !== total;
+ const isLeftOverflow = isOverflow && page > boundary + sibling + 2;
+ const isRightOverflow = isOverflow && total - page > boundary + sibling + 1;
+
+ let cursor = 1;
+
+ for (let i = 1; i <= visible; i += 1) {
+ if (i === boundary + 1 && isLeftOverflow) {
+ pages.push(null);
+ cursor = Math.min(page - sibling, total - boundary - sibling * 2 - 1);
+ } else if (i === visible - boundary && isRightOverflow) {
+ pages.push(null);
+ cursor = total - boundary + 1;
+ } else {
+ pages.push(cursor);
+ cursor += 1;
+ }
+ }
+
+ return pages;
+};
diff --git a/front/src/components/ui/paragraph/styles.module.scss b/front/src/components/ui/paragraph/styles.module.scss
index 03d0c00..466d030 100644
--- a/front/src/components/ui/paragraph/styles.module.scss
+++ b/front/src/components/ui/paragraph/styles.module.scss
@@ -1,17 +1,21 @@
+@use '@components/func.scss' as f;
+
.paragraph {
margin: 0;
}
+$font-size: 12px;
+
.s {
- font-size: 12px;
+ font-size: $font-size;
}
.m {
- font-size: 16px;
+ font-size: f.m($font-size);
}
.l {
- font-size: 20px;
+ font-size: f.l($font-size);
}
.t100 {
diff --git a/front/src/components/ui/paragraph/types.ts b/front/src/components/ui/paragraph/types.ts
index 8aa96c0..44071b1 100644
--- a/front/src/components/ui/paragraph/types.ts
+++ b/front/src/components/ui/paragraph/types.ts
@@ -1,8 +1,10 @@
+import { ComponentPropsWithoutRef } from 'react';
+
import { Scale, TextColor } from '../types';
type ParagraphProps = {
scale?: Scale;
color?: TextColor;
-} & React.ComponentPropsWithoutRef<'p'>;
+} & ComponentPropsWithoutRef<'p'>;
export { ParagraphProps };
diff --git a/front/src/components/ui/popover/component.tsx b/front/src/components/ui/popover/component.tsx
index 254b47f..351dfd6 100644
--- a/front/src/components/ui/popover/component.tsx
+++ b/front/src/components/ui/popover/component.tsx
@@ -10,8 +10,16 @@ import { createPortal } from 'react-dom';
import { Fade } from '../animation';
import styles from './styles.module.scss';
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(null);
const fadeRef = useRef(null);
const [elementRect, setElementRect] = useState(null);
@@ -25,7 +33,14 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) {
return;
}
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('resize', updateStyles);
@@ -36,7 +51,10 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) {
}, [visible]);
if (elementRect === null) {
- return cloneElement(element, { ref: elementRef });
+ return cloneElement(element, {
+ ref: elementRef,
+ style: { position: 'absolute' },
+ });
}
return createPortal(
@@ -44,7 +62,13 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) {
visible={visible}
className={styles.fade}
ref={fadeRef}
- style={{ ...calcStyles(elementRect) }}
+ style={calcFadeStyles(
+ elementRect,
+ anchorRef,
+ position,
+ horizontalAlign,
+ flip,
+ )}
>
{element}
,
diff --git a/front/src/components/ui/popover/index.ts b/front/src/components/ui/popover/index.ts
deleted file mode 100644
index 192728d..0000000
--- a/front/src/components/ui/popover/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { Popover } from './component';
-export { type PopoverProps, type PopoverStyles } from './types';
diff --git a/front/src/components/ui/popover/index.tsx b/front/src/components/ui/popover/index.tsx
new file mode 100644
index 0000000..31eec59
--- /dev/null
+++ b/front/src/components/ui/popover/index.tsx
@@ -0,0 +1 @@
+export { Popover } from './component';
diff --git a/front/src/components/ui/popover/styles.module.scss b/front/src/components/ui/popover/styles.module.scss
index 4bb9de8..3614187 100644
--- a/front/src/components/ui/popover/styles.module.scss
+++ b/front/src/components/ui/popover/styles.module.scss
@@ -1,5 +1,4 @@
.fade {
position: absolute;
- top: 0;
- left: 0;
+ z-index: 2;
}
diff --git a/front/src/components/ui/popover/types.ts b/front/src/components/ui/popover/types.ts
index d2a30d4..17c15a7 100644
--- a/front/src/components/ui/popover/types.ts
+++ b/front/src/components/ui/popover/types.ts
@@ -1,13 +1,14 @@
-import { ReactElement } from 'react';
+import { MutableRefObject, ReactElement } from 'react';
-export type PopoverStyles = {
- left?: string;
- top?: string;
- width?: string;
-};
+export type PopoverPosition = 'top' | 'bottom';
+
+export type PopoverHorizontalAlign = 'left' | 'right' | 'center' | 'stretch';
export type PopoverProps = {
- element: ReactElement;
visible: boolean;
- calcStyles: (elementRect: DOMRect | null) => PopoverStyles;
+ anchorRef: MutableRefObject;
+ position: PopoverPosition;
+ horizontalAlign: PopoverHorizontalAlign;
+ element: ReactElement;
+ flip?: boolean;
};
diff --git a/front/src/components/ui/popover/utils.ts b/front/src/components/ui/popover/utils.ts
new file mode 100644
index 0000000..7ceb356
--- /dev/null
+++ b/front/src/components/ui/popover/utils.ts
@@ -0,0 +1,92 @@
+import { px } from '@utils/css';
+import { CSSProperties, MutableRefObject } 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: MutableRefObject,
+ 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;
+};
diff --git a/front/src/components/ui/preview/preview-article/types.ts b/front/src/components/ui/preview/preview-article/types.ts
index 5ed3e57..b7ab9f6 100644
--- a/front/src/components/ui/preview/preview-article/types.ts
+++ b/front/src/components/ui/preview/preview-article/types.ts
@@ -1,3 +1,5 @@
+import { ComponentProps } from 'react';
+
export type PreviewArticleProps = {
title: string;
-} & React.ComponentProps<'div'>;
+} & ComponentProps<'div'>;
diff --git a/front/src/components/ui/preview/preview-box/types.ts b/front/src/components/ui/preview/preview-box/types.ts
index 3663ae7..925bb31 100644
--- a/front/src/components/ui/preview/preview-box/types.ts
+++ b/front/src/components/ui/preview/preview-box/types.ts
@@ -1 +1,3 @@
-export type PreviewBoxProps = {} & React.ComponentProps<'div'>;
+import { ComponentProps } from 'react';
+
+export type PreviewBoxProps = {} & ComponentProps<'div'>;
diff --git a/front/src/components/ui/radio-group/styles.module.scss b/front/src/components/ui/radio-group/styles.module.scss
index b1533e6..2393402 100644
--- a/front/src/components/ui/radio-group/styles.module.scss
+++ b/front/src/components/ui/radio-group/styles.module.scss
@@ -1,22 +1,26 @@
+@use '@components/func.scss' as f;
+
.checkBoxGroup {
display: flex;
flex-direction: column;
}
+$margin-bottom: 4px;
+
.s {
.label {
- margin-bottom: 3px;
+ margin-bottom: $margin-bottom;
}
}
.m {
.label {
- margin-bottom: 5px;
+ margin-bottom: f.m($margin-bottom);
}
}
.l {
.label {
- margin-bottom: 7px;
+ margin-bottom: f.l($margin-bottom);
}
}
diff --git a/front/src/components/ui/radio-group/types.ts b/front/src/components/ui/radio-group/types.ts
index 61939dd..416318a 100644
--- a/front/src/components/ui/radio-group/types.ts
+++ b/front/src/components/ui/radio-group/types.ts
@@ -1,3 +1,5 @@
+import { Key } from 'react';
+
import { Scale } from '../types';
export type RadioGroupProps = {
@@ -5,7 +7,7 @@ export type RadioGroupProps = {
value: T;
items: T[];
onChange: (value: T) => void;
- getItemKey: (item: T) => React.Key;
+ getItemKey: (item: T) => Key;
getItemLabel: (item: T) => string;
scale?: Scale;
label?: string;
diff --git a/front/src/components/ui/radio/styles.module.scss b/front/src/components/ui/radio/styles.module.scss
index d3be59c..38845ea 100644
--- a/front/src/components/ui/radio/styles.module.scss
+++ b/front/src/components/ui/radio/styles.module.scss
@@ -1,3 +1,5 @@
+@use '@components/func.scss' as f;
+
.wrapper {
position: relative;
overflow: hidden;
@@ -55,32 +57,36 @@
}
}
+$padding-outer: 4px;
+$size: 16px;
+$padding-inner: 4px;
+
.s {
- padding: 3px;
+ padding: $padding-outer;
.radio {
- width: 16px;
- height: 16px;
- padding: 4px;
+ width: $size;
+ height: $size;
+ padding: $padding-inner;
}
}
.m {
- padding: 4px;
+ padding: f.m($padding-outer);
.radio {
- width: 20px;
- height: 20px;
- padding: 5px;
+ width: f.m($size);
+ height: f.m($size);
+ padding: f.m($padding-inner);
}
}
.l {
- padding: 5px;
+ padding: f.l($padding-outer);
.radio {
- width: 24px;
- height: 24px;
- padding: 6px;
+ width: f.l($size);
+ height: f.l($size);
+ padding: f.l($padding-inner);
}
}
diff --git a/front/src/components/ui/raw/raw-button/styles.module.scss b/front/src/components/ui/raw/raw-button/styles.module.scss
index 7c7e305..68d9190 100644
--- a/front/src/components/ui/raw/raw-button/styles.module.scss
+++ b/front/src/components/ui/raw/raw-button/styles.module.scss
@@ -3,4 +3,8 @@
border: none;
background-color: transparent;
font: inherit;
+
+ &:disabled {
+ pointer-events: none;
+ }
}
diff --git a/front/src/components/ui/raw/raw-button/types.ts b/front/src/components/ui/raw/raw-button/types.ts
index 5cfecd4..a418e27 100644
--- a/front/src/components/ui/raw/raw-button/types.ts
+++ b/front/src/components/ui/raw/raw-button/types.ts
@@ -1 +1,3 @@
-export type RawButtonProps = {} & React.ComponentProps<'button'>;
+import { ComponentProps } from 'react';
+
+export type RawButtonProps = {} & ComponentProps<'button'>;
diff --git a/front/src/components/ui/raw/raw-input/types.ts b/front/src/components/ui/raw/raw-input/types.ts
index c47878a..452da97 100644
--- a/front/src/components/ui/raw/raw-input/types.ts
+++ b/front/src/components/ui/raw/raw-input/types.ts
@@ -1 +1,3 @@
-export type RawInputProps = {} & React.ComponentProps<'input'>;
+import { ComponentProps } from 'react';
+
+export type RawInputProps = {} & ComponentProps<'input'>;
diff --git a/front/src/components/ui/select/component.tsx b/front/src/components/ui/select/component.tsx
index 1c60248..d6e271c 100644
--- a/front/src/components/ui/select/component.tsx
+++ b/front/src/components/ui/select/component.tsx
@@ -1,5 +1,4 @@
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
-import { px } from '@utils/css';
import { useMissClick } from '@utils/miss-click';
import clsx from 'clsx';
import React, {
@@ -37,7 +36,11 @@ function SelectInner(
useImperativeHandle(ref, () => selectRef.current, []);
- useMissClick([selectRef, menuRef], () => setMenuVisible(false), menuVisible);
+ useMissClick({
+ callback: () => setMenuVisible(false),
+ enabled: menuVisible,
+ whitelist: [selectRef, menuRef],
+ });
const selectClassName = clsx(styles.select, styles[scale], {
[styles.menuVisible]: menuVisible,
@@ -52,25 +55,6 @@ function SelectInner(
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 (
(
/>