Reorganise code - use src folder

This commit is contained in:
Timothy Armes
2023-10-11 08:45:36 +02:00
committed by Owen Rees
parent 2dc5f477c9
commit b5061b1c78
89 changed files with 70 additions and 69 deletions
+31
View File
@@ -0,0 +1,31 @@
import { Dispatch, SetStateAction } from "react";
import { ResponseMessage } from "@/types";
const InfoMessage = ({
responseMessage,
}: {
responseMessage: ResponseMessage;
}) => (
<p
className={`${
responseMessage.isSuccessful ? "text-green-600" : "text-red-600"
} italic`}
data-test="info-message"
>
{responseMessage.message}
</p>
);
export const clearMessage = (
setResponseMessage: Dispatch<SetStateAction<ResponseMessage>>,
) => {
setTimeout(() => {
setResponseMessage({
isSuccessful: false,
message: "",
});
}, 10000);
};
export default InfoMessage;
+12
View File
@@ -0,0 +1,12 @@
import { useTranslations } from "next-intl";
const LoadingMap = () => {
const t = useTranslations("Tournaments");
return (
<div className="grid h-content place-self-center bg-white text-center text-gray-900 dark:bg-gray-800 dark:text-white">
<p>{t("loading")}</p>
</div>
);
};
export default LoadingMap;
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import React from "react";
import { useAtomValue, useSetAtom } from "jotai";
import L, {
DomUtil,
LatLngLiteral,
Marker,
MarkerClusterGroupOptions,
} from "leaflet";
import "leaflet-defaulticon-compatibility";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet.smooth_marker_bouncing";
import "leaflet/dist/leaflet.css";
import { FaAngleDoubleDown } from "react-icons/fa";
import { LayerGroup, MapContainer, TileLayer } from "react-leaflet";
import MarkerClusterGroup from "react-leaflet-cluster";
import { debouncedHoveredListIdAtom, mapBoundsAtom } from "@/atoms";
import MapEvents from "@/components/MapEvents";
export type MarkerRef = {
getMarker: () => L.Marker<any>;
bounce: () => void;
};
// Declare a class type that adds in methods etc. defined by leaflet.smooth_marker_bouncing
// to keep Typescript happy
declare class BouncingMarker extends Marker {
isBouncing(): boolean;
bounce(): void;
stopBouncing(): void;
_icon: HTMLElement;
_bouncingMotion?: {
bouncingAnimationPlaying: boolean;
};
}
const stopBouncingMarkers = () => {
const markers =
// @ts-ignore
Marker.prototype._orchestration.getBouncingMarkers() as BouncingMarker[];
markers.forEach((marker) => {
if (marker.isBouncing()) {
try {
marker.stopBouncing();
// The plugin keeps bouncing until the end of the animation. We want to stop it immediately
// An issue has been raised on the project (https://github.com/hosuaby/Leaflet.SmoothMarkerBouncing/issues/52), until then we have this hack.
// We remove the class and reset some internal state
DomUtil.removeClass(marker._icon, "bouncing");
if (marker?._bouncingMotion?.bouncingAnimationPlaying === true)
marker._bouncingMotion.bouncingAnimationPlaying = false;
} catch {}
}
});
};
export type MapMarker = {
markerId: string;
tableIds: string[];
component: React.ReactElement;
};
type MapProps = {
filters?: React.ReactNode;
legend?: React.ReactNode;
markers: MapMarker[];
iconCreateFunction?: MarkerClusterGroupOptions["iconCreateFunction"];
};
export const Map = ({
filters,
legend,
markers: markers,
iconCreateFunction,
}: MapProps) => {
const setMapBounds = useSetAtom(mapBoundsAtom);
const hoveredListTournamentId = useAtomValue(debouncedHoveredListIdAtom);
const markerRefs = useRef<Record<string, MarkerRef>>({});
const clusterRef = useRef<L.MarkerClusterGroup | null>(null);
const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(null);
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
const onScrollToTable = () => {
const tournamentTable = document.getElementById("listing");
tournamentTable?.scrollIntoView({ behavior: "smooth" });
};
const expandAndBounceIfNeeded = useCallback(() => {
if (hoveredListTournamentId) {
const marker = markers.find((m) =>
m.tableIds.includes(hoveredListTournamentId),
);
if (marker) {
const markerRef = markerRefs.current[marker.markerId];
if (markerRef) {
if (clusterRef.current) {
const visibleMarker = clusterRef.current.getVisibleParent(
markerRef.getMarker(),
);
if (!visibleMarker) return;
// @ts-ignore
if (visibleMarker.__proto__ === L.MarkerCluster.prototype) {
// This is a cluster icon, we expand it.
const clusterMarker = visibleMarker as L.MarkerCluster;
if (
expandedClusterMarkerRef.current &&
expandedClusterMarkerRef.current !== clusterMarker
) {
expandedClusterMarkerRef.current.unspiderfy();
}
clusterMarker.spiderfy();
// Sometimes there marker that's still bouncing from the last time the group was expanded.
// We stop it quickly.
setTimeout(() => {
stopBouncingMarkers();
}, 50);
} else {
// This is a standard marker, we bounce it.
const marker = visibleMarker as BouncingMarker;
if (!marker.isBouncing()) {
stopBouncingMarkers();
markerRef.bounce();
}
}
return true;
}
}
}
}
stopBouncingMarkers();
return false;
}, [markers, hoveredListTournamentId]);
const onSpiderified = useCallback(
(e: L.MarkerClusterSpiderfyEvent) => {
// Once expanded, bounce the appropriate marker
if (hoveredListTournamentId) {
const marker = markers.find((m) =>
m.tableIds.includes(hoveredListTournamentId),
);
if (!marker) return;
expandedClusterMarkerRef.current = e.cluster;
const markerRef = markerRefs.current[marker.markerId];
if (markerRef && e.markers.includes(markerRef.getMarker())) {
stopBouncingMarkers();
markerRef.bounce();
}
}
},
[markers, hoveredListTournamentId],
);
const onUnSpiderified = useCallback(
(e: L.MarkerClusterSpiderfyEvent) => {
if (expandedClusterMarkerRef.current === e.cluster)
expandedClusterMarkerRef.current = null;
// Once closed, we can expand the next group if needed
expandAndBounceIfNeeded();
},
[expandAndBounceIfNeeded],
);
useEffect(() => {
const ref = clusterRef.current;
if (clusterRef.current) {
clusterRef.current.on("spiderfied", onSpiderified);
clusterRef.current.on("unspiderfied", onUnSpiderified);
}
return () => {
if (ref) {
ref.off("spiderfied", onSpiderified);
ref.off("unspiderfied", onUnSpiderified);
}
};
}, [onSpiderified, onUnSpiderified]);
useEffect(() => {
// Expand/contract as hoveredListTournamentId changes
if (expandAndBounceIfNeeded()) return;
if (expandedClusterMarkerRef.current)
expandedClusterMarkerRef.current.unspiderfy();
}, [expandAndBounceIfNeeded, hoveredListTournamentId]);
const referencedMarkers = useMemo(
() =>
markers.map((marker) =>
React.cloneElement(marker.component, {
ref: (ref: MarkerRef) => {
markerRefs.current[marker.markerId] = ref;
},
}),
),
[markers],
);
return (
<section id="tournament-map" className="flex h-content flex-col">
{filters && <div className="p-3 lg:hidden">{filters}</div>}
<MapContainer
center={center}
zoom={5}
scrollWheelZoom={false}
style={{
flexGrow: 1,
}}
ref={(map) => {
if (map) {
setMapBounds(map.getBounds());
}
}}
>
<MapEvents />
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{legend}
<LayerGroup>
<MarkerClusterGroup
ref={clusterRef}
chunkedLoading
iconCreateFunction={iconCreateFunction}
maxClusterRadius={40}
showCoverageOnHover={false}
spiderfyOnMaxZoom
>
{referencedMarkers}
</MarkerClusterGroup>
</LayerGroup>
</MapContainer>
<div className="flex items-center justify-center lg:hidden">
<button
className="p-3 text-primary-900 dark:text-white"
onClick={onScrollToTable}
>
<FaAngleDoubleDown />
</button>
</div>
</section>
);
};
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useTransition } from "react";
import { useSetAtom } from "jotai";
import L from "leaflet";
import { useMapEvent } from "react-leaflet";
import { mapBoundsAtom } from "@/atoms";
const MapEvents = () => {
const setMapBounds = useSetAtom(mapBoundsAtom);
const [isPending, startTransition] = useTransition();
const worldBounds = L.latLngBounds(L.latLng(-90, -180), L.latLng(90, 180));
const franceBounds = L.latLngBounds(
L.latLng(42.08, -5.12),
L.latLng(51.17, 9.53),
);
const map = useMapEvent("moveend", () => {
// Set the map bounds atoms when the user pans/zooms
startTransition(() => {
setMapBounds(map.getBounds());
});
});
// Viewport agnostic centering of France & max world bounds
useEffect(() => {
map.setView(franceBounds.getCenter(), map.getBoundsZoom(franceBounds));
map.setMaxBounds(worldBounds);
map.options.maxBoundsViscosity = 1.0; // Prevents going past bounds while dragging
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return null;
};
export default MapEvents;
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { FaArrowUp } from "react-icons/fa";
import { useBreakpoint } from "@/hooks/tailwind";
import { ScrollableElement } from "@/types";
import { handleScrollToTop } from "@/utils/scrollHandlers";
const ScrollToTopButton = () => {
const scrollToTopElementRef = useRef<ScrollableElement | null>(null);
const isLgScreen = useBreakpoint("lg");
// determine scrollable element based on screen size - window or div
useEffect(() => {
isLgScreen
? (scrollToTopElementRef.current = document.getElementById("listing"))
: (scrollToTopElementRef.current = window);
}, [isLgScreen]);
const scrollToTopButtonClass = isLgScreen
? "absolute bottom-0 right-3 p-10"
: "fixed bottom-20 right-3 py-10";
return (
<button
className={`${scrollToTopButtonClass} z-10 text-2xl text-primary-900 dark:text-white`}
data-test="scroll-to-top-button"
>
<FaArrowUp
onClick={() => handleScrollToTop(scrollToTopElementRef.current)}
/>
</button>
);
};
export default ScrollToTopButton;
+62
View File
@@ -0,0 +1,62 @@
import { useTransition } from "react";
import { useAtom } from "jotai/index";
import { useTranslations } from "next-intl";
import { IoCloseOutline } from "react-icons/io5";
import { searchStringAtom } from "@/atoms";
const SearchBar = () => {
const t = useTranslations("Tournaments");
const [searchString, setSearchString] = useAtom(searchStringAtom);
const [isPending, startTransition] = useTransition();
const updateSearchString = (str: string) => {
startTransition(() => {
setSearchString(str);
});
};
return (
<div className="bg-white dark:bg-gray-800">
<label htmlFor="table-search" className="sr-only">
{t("searchLabel")}
</label>
<div className="relative mt-1">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-gray-500 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
></path>
</svg>
</div>
{searchString !== "" && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-900 dark:text-white">
<button onClick={() => setSearchString("")}>
<IoCloseOutline className="h-5 w-5" />
</button>
</div>
)}
<input
type="search"
id="table-search"
className="block rounded-lg border border-gray-300 bg-gray-50 p-2.5 px-10 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
placeholder={t("searchPlaceholder")}
value={searchString}
onChange={(e) => updateSearchString(e.target.value)}
/>
</div>
</div>
);
};
export default SearchBar;
+22
View File
@@ -0,0 +1,22 @@
export const Spinner = () => {
return (
<div role="status">
<svg
aria-hidden="true"
className="mr-2 h-8 w-8 animate-spin fill-primary text-gray-200 dark:text-gray-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
);
};
@@ -0,0 +1,53 @@
import React from "react";
import { format } from "date-fns";
import { enGB, fr } from "date-fns/locale";
import { useLocale } from "next-intl";
import { ReactDatePickerCustomHeaderProps } from "react-datepicker";
import { IoChevronBack, IoChevronForward } from "react-icons/io5";
import { twMerge } from "tailwind-merge";
const chevronClasses = {
chevronRoot: "h-4 w-4 dark:text-white",
chevronClassDisabled: "!text-neutral-500",
};
export const DatePickerCustomHeader = ({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}: ReactDatePickerCustomHeaderProps) => {
const locale = useLocale();
return (
<div className="mb-2 flex !w-full items-center justify-between border-b border-neutral-500 px-6 pb-4">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
className={chevronClasses.chevronRoot}
>
<IoChevronBack
className={twMerge(
chevronClasses.chevronRoot,
prevMonthButtonDisabled && chevronClasses.chevronClassDisabled,
)}
/>
</button>
{format(date, "LLLL yyyy", { locale: locale === "fr" ? fr : enGB })}
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
className={chevronClasses.chevronRoot}
>
<IoChevronForward
className={twMerge(
chevronClasses.chevronRoot,
nextMonthButtonDisabled && chevronClasses.chevronClassDisabled,
)}
/>
</button>
</div>
);
};
@@ -0,0 +1,69 @@
import React, { forwardRef } from "react";
import InputMask from "react-input-mask";
import { twMerge } from "tailwind-merge";
interface InputProps {
error?: boolean;
className?: string;
inputContainerClass?: string;
inputClass?: string;
mask?: string | (string | RegExp)[];
}
type AllProps = Omit<
React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>,
"ref"
> &
InputProps;
export const InputDatePicker = forwardRef<HTMLInputElement, AllProps>(
(
{
error,
className,
children,
inputContainerClass,
inputClass,
mask,
...props
},
inputRef,
) => {
return (
<div
className={twMerge(
"flex w-full content-center rounded-lg border p-3 text-sm",
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 ",
!error &&
"focus-within:border-primary-500 focus-within:ring-primary-500 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500",
error && "!border-orange-700 focus:!border-orange-700",
inputContainerClass,
)}
>
<InputMask
inputRef={inputRef}
mask={mask ?? ""}
className={twMerge(
"w-full border-none bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0",
"text-gray-900 dark:text-white",
inputClass,
)}
onKeyDown={(e) => {
if (e.code === "Enter") {
e.stopPropagation();
}
}}
{...props}
/>
</div>
);
},
);
InputDatePicker.displayName = "InputDatePicker";
@@ -0,0 +1 @@
export * from "./InputDatePicker";
+145
View File
@@ -0,0 +1,145 @@
import React, { useRef } from "react";
import { getYear, isValid, parse } from "date-fns";
import fr from "date-fns/locale/fr";
import { get } from "lodash";
import { useLocale, useTranslations } from "next-intl";
import DatePicker, { registerLocale } from "react-datepicker";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "../Field";
import { InputDatePicker } from "./components";
import { DatePickerCustomHeader } from "./components/DatePickerCustomHeader";
registerLocale("fr", fr);
type DateFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
maxDate?: Date;
minDate?: Date;
dateFormat?: string;
className?: string;
datePickerPopperClass?: string;
required?: boolean;
}
>;
export const DateField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
minDate,
maxDate,
dateFormat = "dd/MM/yyyy",
className,
datePickerPopperClass,
name,
control,
required,
...otherFieldProps
}: DateFieldProps<TFieldValues, TFieldName>) => {
const locale = useLocale();
const at = useTranslations("App");
const min = minDate ? minDate.getFullYear() : 1900;
const inputRef = useRef(null);
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = name && !!get(errors, name)?.message;
return (
<Field name={name} control={control} {...otherFieldProps}>
<div className={twMerge("relative flex w-full flex-col", className)}>
<Controller
control={control}
name={name}
render={({ field }) => (
<DatePicker
locale={locale}
closeOnScroll={true}
placeholderText={at("datePlaceholder")}
portalId="calendar-portal"
dateFormat={dateFormat}
disabledKeyboardNavigation={true}
formatWeekDay={(day: string) => (
<div className="flex h-8 flex-col items-center justify-center">
{day.slice(0, 3)}
</div>
)}
weekDayClassName={() =>
"text-xs h-8 w-8 flex-1 bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white"
}
dayClassName={() =>
"text-xs h-8 w-8 flex-1 bg-gray-50 cursor-pointer hover:font-bold text-gray-900 dark:bg-gray-700 dark:text-white"
}
value={field.value}
selected={field.value}
onSelect={field.onChange}
onChange={field.onChange}
onChangeRaw={(e) => {
// Called when the user types in the input
if (e.currentTarget.value) {
const value = e.currentTarget.value;
const date = parse(value, at("dateParseFormat"), new Date());
if (
isValid(date) &&
date.getFullYear() >= min &&
maxDate &&
getYear(maxDate) >= getYear(date)
) {
field.onChange(date);
}
}
}}
popperPlacement="bottom-start"
popperClassName={twMerge(
"z-50 mt-[12px] rounded border",
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
datePickerPopperClass,
)}
showPopperArrow={false}
calendarClassName={twMerge(
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
)}
minDate={minDate}
showFullMonthYearPicker
maxDate={maxDate}
renderCustomHeader={(props) => (
<DatePickerCustomHeader {...props} />
)}
customInput={
<InputDatePicker
mask={at("dateMask")}
inputClass="p-0"
className="text-left"
ref={inputRef}
error={!!hasError}
/>
}
/>
)}
/>
</div>
</Field>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { useTranslations } from "next-intl";
type ErrorMessageProps = {
errorMessage: string;
};
export const ErrorMessage = ({ errorMessage }: ErrorMessageProps) => {
const t = useTranslations();
type TranslationKey = Parameters<typeof t>[0];
return errorMessage !== undefined ? (
<div className="mt-2 font-medium text-orange-700">
<p>
{errorMessage.startsWith("FormValidation")
? t(errorMessage as TranslationKey)
: errorMessage}
</p>
</div>
) : null;
};
+88
View File
@@ -0,0 +1,88 @@
import { ReactNode } from "react";
import { get } from "lodash";
import {
Control,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { ErrorMessage } from "./ErrorMessage";
import { Label } from "./Label";
export type GenericFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TFieldName;
control: Control<TFieldValues>;
className?: string;
labelClassName?: string;
childrenWrapperClassName?: string;
label?: React.ReactNode;
hideErrorMessage?: boolean;
};
type FieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
required?: boolean;
children: ReactNode;
}
>;
export const Field = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: FieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
className,
labelClassName,
childrenWrapperClassName,
label,
children,
required,
hideErrorMessage = false,
} = props;
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = name && !!get(errors, name)?.message;
return (
<div className={twMerge("flex w-full flex-col items-start", className)}>
{label ? (
<Label htmlFor={name} required={required} className={labelClassName}>
{label}
</Label>
) : null}
<div
className={twMerge(
"flex w-full",
label && "mt-2",
childrenWrapperClassName,
)}
>
{children}
</div>
{hideErrorMessage || !hasError ? null : (
<ErrorMessage errorMessage={String(get(errors, name)?.message)} />
)}
</div>
);
};
+35
View File
@@ -0,0 +1,35 @@
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
type LabelProps = Prettify<
React.DetailedHTMLProps<
React.LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
> & {
required?: boolean;
className?: string;
tooltip?: React.ReactNode;
}
>;
export const Label = ({
required,
className,
children,
tooltip,
...labelProps
}: LabelProps) => {
return (
<label
{...labelProps}
className={twMerge(
"flex items-center text-sm font-medium text-gray-900 dark:text-gray-300",
required && "after:ml-0.5 after:content-['*']",
className,
)}
>
<div>{children}</div>
</label>
);
};
+113
View File
@@ -0,0 +1,113 @@
import React from "react";
import { RadioGroup } from "@headlessui/react";
import { isNil } from "lodash";
import { useTranslations } from "next-intl";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
export type BaseOption<T = string, D = unknown> = {
value: T;
label: string | JSX.Element;
disabled?: boolean;
data?: D;
};
export type RadioGroupFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
T = string,
D = unknown,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
required?: boolean;
options: BaseOption<T, D>[];
}
>;
export const RadioGroupField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
T extends React.Key = string,
D = unknown,
>({
name,
control,
className,
labelClassName,
childrenWrapperClassName,
label,
hideErrorMessage,
required,
options,
}: RadioGroupFieldProps<TFieldValues, TFieldName, T, D>) => {
const t = useTranslations("App");
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const valueToOption = (value: T): BaseOption<T, D> =>
options.find((o) => o.value === value) ?? { value, label: "" };
return (
<Field
{...{
name,
control,
className,
label,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
required,
}}
>
<Controller
name={name}
control={control}
render={({ field: { onChange, value } }) => {
const optionValue = isNil(value) ? null : valueToOption(value);
return (
<RadioGroup
value={value}
onChange={onChange}
className="flex w-full justify-stretch gap-2"
>
{options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ checked }) =>
twMerge(
"flex flex-1 cursor-pointer items-center justify-center rounded-lg border px-5 py-3 text-center text-sm font-medium focus:outline-none",
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
checked &&
"border-primary text-primary dark:border-primary dark:text-primary",
)
}
disabled={option.disabled}
>
<RadioGroup.Label as="span">{option.label}</RadioGroup.Label>
</RadioGroup.Option>
))}
</RadioGroup>
);
}}
/>
</Field>
);
};
+187
View File
@@ -0,0 +1,187 @@
import React from "react";
import { flatMap, get, isArray, isNil, isObject } from "lodash";
import { useTranslations } from "next-intl";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import Select, {
ClassNamesConfig,
GroupBase,
OnChangeValue,
Props,
} from "react-select";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
export type BaseOption<T = string, D = unknown> = {
value: T;
label: string | JSX.Element;
disabled?: boolean;
data?: D;
};
export const classNames = <Option, IsMulti extends boolean = false>(
hasError: boolean,
): ClassNamesConfig<Option, IsMulti, GroupBase<Option>> => ({
container: () => "w-full",
valueContainer: () => "text-gray-900 dark:text-white",
indicatorsContainer: () => "flex items-center self-stretch shrink-0",
clearIndicator: () =>
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
dropdownIndicator: () =>
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
indicatorSeparator: () => "w-px text-gray-900 dark:text-white",
control: (state) =>
twMerge(
"group flex w-full items-center justify-between rounded-lg border p-3 text-sm",
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus-within:border-primary-500 focus-within:ring-primary-500",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500",
hasError && "ring-1 ring-error",
state.isDisabled && "cursor-not-allowed",
state.isFocused && "border-primary ring ring-primary ring-opacity-50",
),
multiValue: () => "bg-fieldGray border rounded-lg flex space-x-1 pl-1 m-1",
multiValueLabel: () => "",
multiValueRemove: () => "items-center px-1 hover:text-primary",
placeholder: () =>
"block truncate pr-2 placeholder-gray dark:placeholder-gray-400",
menu: () =>
twMerge(
"!z-10 mt-2 rounded-lg border border-gray-200 bg-white p-1",
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
),
groupHeading: () => "ml-3 mt-2 mb-1 text-textGray text-sm uppercase",
option: ({ isFocused, isDisabled }) =>
twMerge(
"px-3 py-2 hover:cursor-pointer",
isDisabled && "opacity-50",
isFocused && "hover:bg-primary-500 hover:text-white",
),
noOptionsMessage: () =>
"text-textGray p-2 bg-gray-50 border border-dashed border-gray-200 rounded-sm",
});
export type SelectFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
IsMulti extends boolean = false,
T = string,
D = unknown,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<
Props<BaseOption<T, D>, IsMulti, GroupBase<BaseOption<T, D>>>,
"onChange" | "value" | "classNames" | "name"
> & {
required?: boolean;
}
>;
export const SelectField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
IsMulti extends boolean = false,
T = string,
D = unknown,
>({
name,
control,
className,
labelClassName,
childrenWrapperClassName,
label,
hideErrorMessage,
required,
placeholder,
options,
...selectProps
}: SelectFieldProps<TFieldValues, TFieldName, IsMulti, T, D>) => {
const t = useTranslations("App");
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = !!get(errors, name)?.message;
const isGroup = (
option: BaseOption<T, D> | GroupBase<BaseOption<T, D>>,
): option is GroupBase<BaseOption<T, D>> =>
isObject(option) && "options" in option;
const flattenedOptions = flatMap(options, (option) => {
return isGroup(option) ? option.options : option;
});
const valueToOption = (value: T): BaseOption<T, D> =>
flattenedOptions.find((o) => o.value === value) ?? { value, label: "" };
return (
<Field
{...{
name,
control,
className,
label,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
required,
}}
>
<Controller
name={name}
control={control}
render={({ field: { onChange, value } }) => {
const onSelectChange = (
newValue: OnChangeValue<BaseOption<T, D>, IsMulti>,
) => {
if (isNil(newValue)) onChange(null);
else if (isArray(newValue)) {
onChange(newValue.map((option) => option.value));
} else onChange((newValue as BaseOption<T, D>).value);
};
const optionValue = isNil(value)
? null
: isArray(value)
? // @ts-ignore - this is too complex for TS to understand
value.map(valueToOption)
: valueToOption(value);
return (
<Select
value={optionValue}
onChange={onSelectChange}
options={options}
noOptionsMessage={() => t("noOptionsMessage")}
placeholder={placeholder ?? t("selectPlaceholder")}
unstyled
styles={{
input: (base) => ({
...base,
"input:focus": {
boxShadow: "none",
},
}),
}}
classNames={classNames<BaseOption<T, D>, IsMulti>(hasError)}
{...selectProps}
/>
);
}}
/>
</Field>
);
};
+82
View File
@@ -0,0 +1,82 @@
import React from "react";
import { Switch, SwitchProps } from "@headlessui/react";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
type SwitchFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<SwitchProps<"button">, "name">
>;
export const SwitchField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: SwitchFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
label,
disabled,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
...rest
} = props;
const form = useFormContext<TFieldValues>();
return (
<Field
{...{
name,
control,
type: "checkbox",
label,
disabled,
className,
labelClassName,
}}
>
<div className="flex w-full flex-col">
<Controller
control={control}
name={name}
render={({ field }) => (
<div className="flex h-[40px] items-center">
<Switch
checked={field.value}
onChange={field.onChange}
className={twMerge(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none",
"ui-not-checked:bg-neutral-300 ui-not-checked:hover:bg-neutral-400",
"ui-not-checked:dark:bg-neutral-500 ui-not-checked:dark:hover:bg-neutral-600",
"ui-checked:bg-primary-500 ui-checked:hover:bg-primary-600",
)}
{...rest}
>
<span className="inline-block h-[14px] w-[14px] transform rounded-full bg-white transition-transform ui-checked:translate-x-[19px] ui-not-checked:translate-x-[3px]" />
</Switch>
</div>
)}
/>
</div>
</Field>
);
};
+107
View File
@@ -0,0 +1,107 @@
import React from "react";
import { get } from "lodash";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
type TextAreaFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<React.HTMLProps<HTMLTextAreaElement>, "ref" | "name"> & {
handleChanged?: (props: { name: string }) => void;
startIcon?: React.ReactNode;
required?: boolean;
}
>;
export const TextAreaField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: TextAreaFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
label,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage = false,
startIcon,
handleChanged,
required,
disabled,
...rest
} = props;
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = !!get(errors, name)?.message;
return (
<Field
{...{
name,
control,
label,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
}}
>
<div className="flex w-full flex-col">
<Controller
control={control}
name={name}
render={({ field }) => (
<div className="relative">
{startIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{startIcon}
</div>
)}
<textarea
{...form.register(name, {
required,
})}
aria-required={required}
aria-invalid={hasError}
disabled={disabled}
className={twMerge(
"flex w-full content-center rounded-lg border p-3 text-sm",
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus:border-primary-500 focus:ring-primary-500",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500",
hasError && "!border-orange-700 focus:!border-orange-700",
disabled && "dark:bg-gray-50",
)}
{...rest}
onChange={(e) => {
field.onChange(e);
}}
value={field.value}
/>
</div>
)}
/>
</div>
</Field>
);
};
+130
View File
@@ -0,0 +1,130 @@
import React from "react";
import { get } from "lodash";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
type TextFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<React.HTMLProps<HTMLInputElement>, "ref" | "name"> & {
handleChanged?: (props: { name: string }) => void;
startIcon?: React.ReactNode;
}
>;
export const TextField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: TextFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
type = "text",
label,
required,
disabled,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage = false,
startIcon,
handleChanged,
...rest
} = props;
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = !!get(errors, name)?.message;
const input = (value: string | number) => {
return typeof value === "number"
? isNaN(value)
? ""
: value.toString()
: value ?? "";
};
const output =
type === "number"
? (e: React.ChangeEvent<HTMLInputElement>) => {
const output = parseFloat(e.target.value);
return isNaN(output) ? 0 : output;
}
: (e: React.ChangeEvent<HTMLInputElement>) => e.target.value;
return (
<Field
{...{
name,
control,
label,
required,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
}}
>
<div className="flex w-full flex-col">
<Controller
control={control}
name={name}
render={({ field }) => (
<div className="relative">
{startIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{startIcon}
</div>
)}
<input
type={type}
{...form.register(name, {
valueAsNumber: type === "number",
required,
})}
aria-required={required}
aria-invalid={hasError}
disabled={disabled}
className={twMerge(
"flex w-full content-center rounded-lg border p-3 text-sm",
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus:border-primary-500 focus:ring-primary-500",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500",
hasError && "!border-orange-700 focus:!border-orange-700",
disabled && "dark:bg-gray-50",
startIcon && "pl-10",
)}
{...rest}
onChange={(e) => {
field.onChange(output(e));
}}
value={input(field.value)}
onBlur={() => {
if (handleChanged) handleChanged({ name });
}}
/>
</div>
)}
/>
</div>
</Field>
);
};