feat: add basic form

This commit is contained in:
MAZE 2024-06-16 19:00:38 +04:30
parent d73b2bc1ff
commit c272914416
8 changed files with 315 additions and 0 deletions

View File

@ -1,5 +1,7 @@
import { Modal } from '@/components/modal';
import { Form } from './form';
interface TimerProps {
onClose: () => void;
show: boolean;
@ -9,6 +11,7 @@ export function CountdownTimer({ onClose, show }: TimerProps) {
return (
<Modal show={show} onClose={onClose}>
<h1>Hello World</h1>
<Form />
</Modal>
);
}

View File

@ -0,0 +1,27 @@
.field {
flex-grow: 1;
& .label {
display: block;
margin-bottom: 8px;
font-size: var(--font-sm);
font-weight: 500;
& .optional {
font-weight: 400;
color: var(--color-foreground-subtle);
}
}
& .input {
width: 100%;
min-width: 0;
height: 40px;
padding: 0 16px;
color: var(--color-foreground);
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
outline: none;
}
}

View File

@ -0,0 +1,51 @@
import styles from './field.module.css';
interface FieldProps {
children?: React.ReactNode;
label: string;
onChange: (value: string | number) => void;
optional?: boolean;
type: 'text' | 'select';
value: string | number;
}
export function Field({
children,
label,
onChange,
optional,
type,
value,
}: FieldProps) {
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={label.toLowerCase()}>
{label}{' '}
{optional && <span className={styles.optional}>(optional)</span>}
</label>
{type === 'text' && (
<input
autoComplete="off"
className={styles.input}
id={label.toLowerCase()}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
/>
)}
{type === 'select' && (
<select
autoComplete="off"
className={styles.input}
id={label.toLowerCase()}
value={value}
onChange={e => onChange(parseInt(e.target.value))}
>
{children}
</select>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { Field } from './field';

View File

@ -0,0 +1,28 @@
.form {
display: flex;
flex-direction: column;
row-gap: 28px;
& .button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 45px;
font-weight: 500;
color: var(--color-neutral-50);
cursor: pointer;
background-color: var(--color-neutral-950);
border: none;
border-radius: 8px;
outline: none;
box-shadow: inset 0 -3px 0 var(--color-neutral-700);
}
}
.timeFields {
display: flex;
column-gap: 12px;
align-items: flex-end;
justify-content: space-between;
}

View File

@ -0,0 +1,97 @@
import { useState, useMemo } from 'react';
import { Field } from './field';
import { useCountdownTimers } from '@/stores/countdown-timers';
import styles from './form.module.css';
export function Form() {
const [name, setName] = useState('');
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(10);
const [seconds, setSeconds] = useState(0);
const totalSeconds = useMemo(
() => hours * 60 * 60 + minutes * 60 + seconds,
[hours, minutes, seconds],
);
const add = useCountdownTimers(state => state.add);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (totalSeconds === 0) return;
add({
name,
total: totalSeconds,
});
setName('');
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
<Field
label="Timer Name"
optional
type="text"
value={name}
onChange={value => setName(value as string)}
/>
<div className={styles.timeFields}>
<Field
label="Hours"
type="select"
value={hours}
onChange={value => setHours(value as number)}
>
{Array(13)
.fill(null)
.map((_, index) => (
<option key={`hour-${index}`} value={index}>
{index}
</option>
))}
</Field>
<Field
label="Minutes"
type="select"
value={minutes}
onChange={value => setMinutes(value as number)}
>
{Array(60)
.fill(null)
.map((_, index) => (
<option key={`minutes-${index}`} value={index}>
{index}
</option>
))}
</Field>
<Field
label="Seconds"
type="select"
value={seconds}
onChange={value => setSeconds(value as number)}
>
{Array(60)
.fill(null)
.map((_, index) => (
<option key={`seconds-${index}`} value={index}>
{index}
</option>
))}
</Field>
</div>
<button className={styles.button} type="submit">
Add Timer
</button>
</form>
);
}

View File

@ -0,0 +1 @@
export { Form } from './form';

View File

@ -0,0 +1,107 @@
import { v4 as uuid } from 'uuid';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
interface Timer {
id: string;
name: string;
spent: number;
total: number;
}
interface State {
spent: () => number;
timers: Array<Timer>;
total: () => number;
}
interface Actions {
add: (timer: { name: string; total: number }) => void;
delete: (id: string) => void;
getTimer: (id: string) => Timer;
rename: (id: string, newName: string) => void;
reset: (id: string) => void;
tick: (id: string, amount?: number) => void;
}
export const useCountdownTimers = create<State & Actions>()(
persist(
(set, get) => ({
add({ name, total }) {
set(state => ({
timers: [
{
id: uuid(),
name,
spent: 0,
total,
},
...state.timers,
],
}));
},
delete(id) {
set(state => ({
timers: state.timers.filter(timer => timer.id !== id),
}));
},
getTimer(id) {
return get().timers.filter(timer => timer.id === id)[0];
},
rename(id, newName) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
return { ...timer, name: newName };
}),
}));
},
reset(id) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
return { ...timer, spent: 0 };
}),
}));
},
spent() {
return get().timers.reduce((prev, curr) => prev + curr.spent, 0);
},
tick(id, amount = 1) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
const updatedSpent =
timer.spent + amount > timer.total
? timer.total
: timer.spent + amount;
return { ...timer, spent: updatedSpent };
}),
}));
},
timers: [],
total() {
return get().timers.reduce((prev, curr) => prev + curr.total, 0);
},
}),
{
name: 'moodist-countdown-timers',
partialize: state => ({ timers: state.timers }),
skipHydration: true,
storage: createJSONStorage(() => localStorage),
version: 0,
},
),
);