mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 12:36:57 +00:00
Reorganise code - use src folder
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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='© <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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user