Factorise Button and Input components

This commit is contained in:
Timothy Armes
2024-10-03 14:21:09 +02:00
parent 0aaf14e112
commit 4181b92670
24 changed files with 393 additions and 233 deletions
+5 -1
View File
@@ -53,5 +53,9 @@
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"css.lint.unknownAtRules": "ignore", "css.lint.unknownAtRules": "ignore",
"tailwindCSS.experimental.classRegex": ["[a-zA-Z]*ClassName=\"([^\"]+)\""] "tailwindCSS.experimental.classRegex": [
["[a-zA-Z]*ClassName=\"([^\"]+)\""],
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
} }
+1
View File
@@ -24,6 +24,7 @@
"@turf/boolean-point-in-polygon": "^7.0.0-alpha.114", "@turf/boolean-point-in-polygon": "^7.0.0-alpha.114",
"@types/node": "^22.7.2", "@types/node": "^22.7.2",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"class-variance-authority": "^0.7.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"geojson": "^0.5.0", "geojson": "^0.5.0",
"jotai": "^2.6.1", "jotai": "^2.6.1",
@@ -8,6 +8,7 @@ import dynamic from "next/dynamic";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button";
import { clearMessage } from "@/components/InfoMessage"; import { clearMessage } from "@/components/InfoMessage";
import InfoMessage from "@/components/InfoMessage"; import InfoMessage from "@/components/InfoMessage";
import LoadingMap from "@/components/LoadingMap"; import LoadingMap from "@/components/LoadingMap";
@@ -174,12 +175,14 @@ const TournamentForm = () => {
required required
/> />
</div> </div>
<div className="col-span-4 row-span-2 sm:col-span-2">
<div className="col-span-4 row-span-2 self-stretch sm:col-span-2">
<TextAreaField <TextAreaField
className="h-full"
childrenWrapperClassName="h-full"
name="message" name="message"
control={form.control} control={form.control}
label={t("messageLabel")} label={t("messageLabel")}
rows={6}
placeholder={t("messagePlaceholder")} placeholder={t("messagePlaceholder")}
/> />
</div> </div>
@@ -207,21 +210,16 @@ const TournamentForm = () => {
<Map /> <Map />
</section> </section>
</div> </div>
<button
disabled={form.formState.isSubmitting}
type="submit"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{form.formState.isSubmitting ? t("sending") : t("sendButton")}
</button>
<button
onClick={() => form.reset()}
type="button"
className="ml-4 rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{t("clearForm")}
</button>
<div className="flex gap-4">
<Button disabled={form.formState.isSubmitting} type="submit">
{form.formState.isSubmitting ? t("sending") : t("sendButton")}
</Button>
<Button onClick={() => form.reset()} type="button" intent="secondary">
{t("clearForm")}
</Button>
</div>
<InfoMessage responseMessage={responseMessage} /> <InfoMessage responseMessage={responseMessage} />
</form> </form>
</FormProvider> </FormProvider>
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button";
import InfoMessage from "@/components/InfoMessage"; import InfoMessage from "@/components/InfoMessage";
import { clearMessage } from "@/components/InfoMessage"; import { clearMessage } from "@/components/InfoMessage";
import { TextAreaField } from "@/components/form/TextAreaField"; import { TextAreaField } from "@/components/form/TextAreaField";
@@ -77,13 +78,9 @@ const ContactForm = () => {
required required
/> />
<button <Button disabled={form.formState.isSubmitting} type="submit">
disabled={form.formState.isSubmitting}
type="submit"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{form.formState.isSubmitting ? t("sending") : t("sendButton")} {form.formState.isSubmitting ? t("sending") : t("sendButton")}
</button> </Button>
<InfoMessage responseMessage={responseMessage} /> <InfoMessage responseMessage={responseMessage} />
</form> </form>
@@ -5,6 +5,7 @@ import { useState } from "react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/Button";
import InfoMessage from "@/components/InfoMessage"; import InfoMessage from "@/components/InfoMessage";
import { deleteAccount } from "@/server/deleteAccount"; import { deleteAccount } from "@/server/deleteAccount";
import { useRouter } from "@/utils/routing"; import { useRouter } from "@/utils/routing";
@@ -67,21 +68,21 @@ export default function Contact() {
)} )}
<div className="flex items-center justify-center space-x-4 text-sm font-bold"> <div className="flex items-center justify-center space-x-4 text-sm font-bold">
<button <Button
type="button" type="button"
onClick={() => router.back()} onClick={() => router.back()}
className="rounded-lg border border-primary px-5 py-3 text-center text-xs text-primary hover:bg-primary hover:text-white focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:focus:ring-primary-800 sm:w-fit" intent="secondary"
> >
{at("cancelButton")} {at("cancelButton")}
</button> </Button>
<button <Button
onClick={() => onDeleteAccount()} onClick={() => onDeleteAccount()}
disabled={deleting} disabled={deleting}
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit" intent="primary"
> >
{t("deleteButton")} {t("deleteButton")}
</button> </Button>
</div> </div>
</> </>
)} )}
+20 -14
View File
@@ -8,6 +8,7 @@ import { IoAdd, IoCloseOutline } from "react-icons/io5";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button";
import { RadioGroupField } from "@/components/form/RadioGroupField"; import { RadioGroupField } from "@/components/form/RadioGroupField";
import { SelectField } from "@/components/form/SelectField"; import { SelectField } from "@/components/form/SelectField";
import { TextField } from "@/components/form/TextField"; import { TextField } from "@/components/form/TextField";
@@ -176,26 +177,31 @@ export const ManualEloForm = () => {
}))} }))}
required required
/> />
{gameFields.length > 1 && ( {gameFields.length > 1 && (
<button <Button
className="hidden h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-600 dark:hover:bg-neutral-700 sm:flex" intent="tertiary"
size="compacted"
className="hidden h-8 w-8 sm:flex"
type="button" type="button"
onClick={() => removeGame(i)} onClick={() => removeGame(i)}
> >
<IoCloseOutline className="h-6 w-6 text-gray-900 transition-all duration-200 hover:text-primary dark:text-neutral-400 hover:dark:text-white" /> <IoCloseOutline className="h-6 w-6" />
</button> </Button>
)} )}
</div> </div>
</div> </div>
{gameFields.length > 1 && ( {gameFields.length > 1 && (
<button <Button
className="flex h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-600 dark:hover:bg-neutral-700 sm:hidden" intent="tertiary"
size="compacted"
className="flex h-8 w-8 sm:hidden"
type="button" type="button"
onClick={() => removeGame(i)} onClick={() => removeGame(i)}
> >
<IoCloseOutline className="h-6 w-6 text-gray-900 transition-all duration-200 hover:text-primary dark:text-neutral-400 hover:dark:text-white" /> <IoCloseOutline className="h-6 w-6" />
</button> </Button>
)} )}
</div> </div>
@@ -244,26 +250,26 @@ export const ManualEloForm = () => {
<div className="mt-8 flex justify-end gap-4"> <div className="mt-8 flex justify-end gap-4">
{!isDefault && ( {!isDefault && (
<button <Button
onClick={() => { onClick={() => {
form.reset(); form.reset();
}} }}
type="button" type="button"
className="px-5 py-3 text-center text-sm font-medium text-primary-600 hover:text-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:text-primary-700 dark:focus:ring-primary-800 sm:w-fit" intent="secondary"
> >
{t("clearButton")} {t("clearButton")}
</button> </Button>
)} )}
<button <Button
onClick={() => { onClick={() => {
appendGame({}); appendGame({});
}} }}
type="button" type="button"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit" intent="primary"
> >
<IoAdd className="mr-2 inline-block h-5 w-5" /> <IoAdd className="mr-2 inline-block h-5 w-5" />
{t("addGameButton")} {t("addGameButton")}
</button> </Button>
</div> </div>
</form> </form>
</FormProvider> </FormProvider>
+5 -3
View File
@@ -10,6 +10,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button";
import { ErrorBox } from "@/components/ErrorBox"; import { ErrorBox } from "@/components/ErrorBox";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { TranslatedError } from "@/components/TranslatedError"; import { TranslatedError } from "@/components/TranslatedError";
@@ -176,7 +177,7 @@ export default function Elo() {
return ( return (
<section className="grid place-items-center bg-white pb-20 dark:bg-gray-800"> <section className="grid place-items-center bg-white pb-20 dark:bg-gray-800">
<div className="max-w-xl px-4 py-8 lg:py-16"> <div className="max-w-xxl px-4 py-8 lg:py-16">
<h2 <h2
className="mb-10 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white" className="mb-10 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
data-test="header2" data-test="header2"
@@ -189,13 +190,14 @@ export default function Elo() {
{hasTournamentId && !isFetching && !error && ( {hasTournamentId && !isFetching && !error && (
<div className="mx-auto mb-8 flex justify-center"> <div className="mx-auto mb-8 flex justify-center">
<button <Button
intent="secondary"
type="button" type="button"
onClick={clearForm} onClick={clearForm}
className="rounded-lg border border-primary px-3 py-2 text-center text-sm text-primary hover:bg-primary hover:text-white focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:focus:ring-primary-800 sm:w-fit" className="rounded-lg border border-primary px-3 py-2 text-center text-sm text-primary hover:bg-primary hover:text-white focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:focus:ring-primary-800 sm:w-fit"
> >
{t("enterManualResultsButton")} {t("enterManualResultsButton")}
</button> </Button>
</div> </div>
)} )}
@@ -85,6 +85,7 @@ const TournamentTable = () => {
classNameOverrides={{ classNameOverrides={{
container: () => "flex flex-1", container: () => "flex flex-1",
}} }}
size="small"
/> />
</div> </div>
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button";
import { InlineSwitchField } from "@/components/form/InlineSwitchField"; import { InlineSwitchField } from "@/components/form/InlineSwitchField";
import { Label } from "@/components/form/Label"; import { Label } from "@/components/form/Label";
import { TextField } from "@/components/form/TextField"; import { TextField } from "@/components/form/TextField";
@@ -110,20 +111,13 @@ export const ZoneForm = ({
</div> </div>
<div className="flex justify-end gap-4"> <div className="flex justify-end gap-4">
<button <Button intent="secondary" onClick={onCancel}>
onClick={onCancel}
className="rounded-lg border border-primary px-3 py-2 text-center text-xs text-primary hover:bg-primary hover:text-white focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:focus:ring-primary-800 sm:w-fit"
>
{cancelTitle ?? at("cancelButton")} {cancelTitle ?? at("cancelButton")}
</button> </Button>
<button <Button disabled={form.formState.isSubmitting} type="submit">
disabled={form.formState.isSubmitting}
type="submit"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{submitTitle ?? at("saveButton")} {submitTitle ?? at("saveButton")}
</button> </Button>
</div> </div>
</form> </form>
</FormProvider> </FormProvider>
+11 -13
View File
@@ -6,6 +6,7 @@ import { useFormatter, useTranslations } from "next-intl";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { IoAdd } from "react-icons/io5"; import { IoAdd } from "react-icons/io5";
import { Button, buttonVariants } from "@/components/Button";
import InfoMessage, { clearMessage } from "@/components/InfoMessage"; import InfoMessage, { clearMessage } from "@/components/InfoMessage";
import { Modal } from "@/components/Modal"; import { Modal } from "@/components/Modal";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
@@ -116,17 +117,18 @@ const Zones = () => {
pathname: "/zones/edit/[id]", pathname: "/zones/edit/[id]",
params: { id: zone.id }, params: { id: zone.id },
}} }}
className="rounded-lg border border-primary px-3 py-2 text-center text-xs text-primary hover:bg-primary hover:text-white focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:focus:ring-primary-800 sm:w-fit" className={buttonVariants({ intent: "secondary" })}
> >
{at("editButton")} {at("editButton")}
</Link> </Link>
<button
<Button
intent="secondary"
type="button" type="button"
onClick={() => setDeletingZoneId(zone.id)} onClick={() => setDeletingZoneId(zone.id)}
className="rounded-lg border border-primary px-3 py-2 text-center text-xs text-primary hover:bg-primary hover:text-white focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:focus:ring-primary-800 sm:w-fit"
> >
{at("deleteButton")} {at("deleteButton")}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -155,19 +157,15 @@ const Zones = () => {
<InfoMessage responseMessage={responseMessage} /> <InfoMessage responseMessage={responseMessage} />
<div className="flex items-center justify-between space-x-4 text-sm font-bold"> <div className="flex items-center justify-between space-x-4 text-sm font-bold">
<button <Button
intent="secondary"
type="button" type="button"
onClick={() => setDeletingZoneId(null)} onClick={() => setDeletingZoneId(null)}
className="rounded-lg border border-primary px-3 py-2 text-center text-xs text-primary hover:bg-primary hover:text-white focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:focus:ring-primary-800 sm:w-fit"
> >
{at("cancelButton")} {at("cancelButton")}
</button> </Button>
<button
onClick={() => onDelete()} <Button onClick={() => onDelete()}>{at("deleteButton")}</Button>
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{at("deleteButton")}
</button>
</div> </div>
</Modal> </Modal>
</> </>
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { Menu } from "@headlessui/react"; import { Button, Menu } from "@headlessui/react";
import { signIn, signOut, useSession } from "next-auth/react"; import { signIn, signOut, useSession } from "next-auth/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
+61
View File
@@ -0,0 +1,61 @@
import { type VariantProps, cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
export const buttonVariants = cva(
"flex items-center justify-center transition-all duration-200",
{
variants: {
intent: {
primary: [
"text-center",
"text-white",
"focus:outline-none focus:ring-2 focus:ring-primary-500",
"bg-primary-600",
"hover:bg-primary-800",
"disabled:opacity-25",
"dark:text-white dark:hover:bg-primary-700",
],
secondary: [
"text-primary dark:text-white",
"hover:text-white dark:hover:text-white",
"hover:bg-primary hover:text-white",
"border border-primary",
"focus:outline-none focus:ring-2 focus:ring-primary-500",
"disabled:opacity-25",
],
tertiary: [
"text-gray-900 dark:text-neutral-400",
"hover:text-primary dark:hover:text-white",
"focus:outline-none focus:ring-2 focus:ring-primary-500",
"disabled:opacity-25",
],
},
size: {
small: ["text-sm font-medium", "px-3 py-2", "sm:w-fit", "rounded-lg"],
medium: ["text-sm font-medium", "px-5 py-3", "sm:w-fit", "rounded-lg"],
compacted: ["rounded-none", "px-0 py-0"],
},
},
defaultVariants: {
intent: "primary",
size: "medium",
},
},
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;
export type ButtonProps = React.ComponentProps<"button"> & ButtonVariants;
export const Button = ({
className,
intent,
size,
...props
}: ButtonProps & { className?: string }) => (
<button
className={twMerge(buttonVariants({ intent, size, className }))}
{...props}
/>
);
+7 -7
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Float } from "@headlessui-float/react"; import { Float } from "@headlessui-float/react";
import { Menu } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { IoMdMore } from "react-icons/io"; import { IoMdMore } from "react-icons/io";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -37,15 +37,15 @@ export const DropdownMenu = ({
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
{buttonComponent ?? ( {buttonComponent ?? (
<Menu.Button> <MenuButton>
<IoMdMore /> <IoMdMore />
</Menu.Button> </MenuButton>
)} )}
<Menu.Items className="z-20 flex w-auto items-start overflow-hidden rounded-md bg-neutral-200 focus:outline-none dark:bg-neutral-600"> <MenuItems className="z-20 flex w-auto items-start overflow-hidden rounded-md bg-neutral-200 focus:outline-none dark:bg-neutral-600">
<div className="flex flex-col px-1 py-1"> <div className="flex flex-col px-1 py-1">
{items.map(({ title, onClick, className, disabled }, i) => ( {items.map(({ title, onClick, className, disabled }, i) => (
<Menu.Item key={i}> <MenuItem key={i}>
<button <button
type="button" type="button"
className={twMerge( className={twMerge(
@@ -61,10 +61,10 @@ export const DropdownMenu = ({
> >
{title} {title}
</button> </button>
</Menu.Item> </MenuItem>
))} ))}
</div> </div>
</Menu.Items> </MenuItems>
</Float> </Float>
</Menu> </Menu>
); );
+7 -3
View File
@@ -16,6 +16,8 @@ import MarkerClusterGroup from "react-leaflet-cluster";
import { debouncedHoveredListIdAtom, mapBoundsAtom } from "@/atoms"; import { debouncedHoveredListIdAtom, mapBoundsAtom } from "@/atoms";
import { MapEvents } from "@/components/MapEvents"; import { MapEvents } from "@/components/MapEvents";
import { Button } from "./Button";
export type MarkerRef = { export type MarkerRef = {
getMarker: () => L.Marker<any>; getMarker: () => L.Marker<any>;
bounce: () => void; bounce: () => void;
@@ -238,12 +240,14 @@ export const Map = ({
</MapContainer> </MapContainer>
<div className="flex items-center justify-center lg:hidden"> <div className="flex items-center justify-center lg:hidden">
<button <Button
className="p-3 text-primary-900 dark:text-white" intent="tertiary"
size="compacted"
className="p-3"
onClick={onScrollToTable} onClick={onScrollToTable}
> >
<FaAngleDoubleDown /> <FaAngleDoubleDown />
</button> </Button>
</div> </div>
</section> </section>
); );
+21 -18
View File
@@ -7,6 +7,9 @@ import { twMerge } from "tailwind-merge";
import { searchStringAtom } from "@/atoms"; import { searchStringAtom } from "@/atoms";
import { Button } from "./Button";
import { Input } from "./form/Input";
type SearchBarProps = { type SearchBarProps = {
className?: string; className?: string;
}; };
@@ -27,8 +30,14 @@ const SearchBar = ({ className }: SearchBarProps) => {
<label htmlFor="table-search" className="sr-only"> <label htmlFor="table-search" className="sr-only">
{t("searchLabel")} {t("searchLabel")}
</label> </label>
<div className="relative"> <div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <Input
type="search"
id="table-search"
placeholder={t("searchPlaceholder")}
size="small"
startIcon={
<svg <svg
className="h-5 w-5 text-gray-500 dark:text-gray-400" className="h-5 w-5 text-gray-500 dark:text-gray-400"
fill="currentColor" fill="currentColor"
@@ -41,24 +50,18 @@ const SearchBar = ({ className }: SearchBarProps) => {
clipRule="evenodd" clipRule="evenodd"
></path> ></path>
</svg> </svg>
</div> }
endIcon={
{searchString !== "" && ( searchString !== "" && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-900 dark:text-white"> <Button
<button onClick={() => setSearchString("")}> intent="tertiary"
size="compacted"
onClick={() => setSearchString("")}
>
<IoCloseOutline className="h-5 w-5" /> <IoCloseOutline className="h-5 w-5" />
</button> </Button>
</div> )
)} }
<input
type="search"
id="table-search"
className={twMerge(
"block rounded-lg border border-gray-300 bg-gray-50 p-2.5 px-10 text-gray-900 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-blue-500 dark:focus:ring-primary-500",
className,
)}
placeholder={t("searchPlaceholder")}
value={searchString} value={searchString}
onChange={(e) => updateSearchString(e.target.value)} onChange={(e) => updateSearchString(e.target.value)}
/> />
+3 -6
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -91,13 +92,9 @@ export const SignInForm = ({ callbackPath }: SignInFormProps) => {
required required
/> />
<button <Button disabled={form.formState.isSubmitting} type="submit">
disabled={form.formState.isSubmitting}
type="submit"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{t("signInButton")} {t("signInButton")}
</button> </Button>
<InfoMessage <InfoMessage
className="text-center" className="text-center"
+8 -5
View File
@@ -15,7 +15,7 @@ import AsyncSelect from "react-select/async";
import { Prettify } from "@/types"; import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field"; import { Field, GenericFieldProps } from "./Field";
import { BaseOption, classNames } from "./Select"; import { BaseOption, createSelectClassNamesFn } from "./Select";
export type AsyncSelectFieldProps< export type AsyncSelectFieldProps<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
@@ -34,6 +34,7 @@ export type AsyncSelectFieldProps<
loadOptions: (searchString?: string) => Promise<BaseOption<T, D>[]>; loadOptions: (searchString?: string) => Promise<BaseOption<T, D>[]>;
onInformChange?: (data: BaseOption<T, D>[] | null) => void; onInformChange?: (data: BaseOption<T, D>[] | null) => void;
separators?: boolean; separators?: boolean;
size?: "small" | "medium";
} }
>; >;
@@ -50,6 +51,7 @@ export const AsyncSelectField = <
label, label,
hideErrorMessage, hideErrorMessage,
required, required,
size,
loadOption, loadOption,
loadOptions, loadOptions,
@@ -132,6 +134,10 @@ export const AsyncSelectField = <
return options; return options;
}; };
const classNames = createSelectClassNamesFn<BaseOption<T, D>, IsMulti>({
size,
});
return ( return (
<Field {...{ name, control, className, label, hideErrorMessage, required }}> <Field {...{ name, control, className, label, hideErrorMessage, required }}>
<Controller <Controller
@@ -184,10 +190,7 @@ export const AsyncSelectField = <
}, },
}), }),
}} }}
classNames={classNames<BaseOption<T, D>, IsMulti>( classNames={classNames(hasError, separators ?? false)}
hasError,
separators ?? false,
)}
{...selectProps} {...selectProps}
/> />
); );
@@ -7,6 +7,8 @@ import { ReactDatePickerCustomHeaderProps } from "react-datepicker";
import { IoChevronBack, IoChevronForward } from "react-icons/io5"; import { IoChevronBack, IoChevronForward } from "react-icons/io5";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Button } from "@/components/Button";
const chevronClasses = { const chevronClasses = {
chevronRoot: "h-4 w-4 dark:text-white", chevronRoot: "h-4 w-4 dark:text-white",
chevronClassDisabled: "!text-neutral-500", chevronClassDisabled: "!text-neutral-500",
@@ -23,7 +25,9 @@ export const DatePickerCustomHeader = ({
return ( return (
<div className="mb-2 flex !w-full items-center justify-between border-b border-neutral-500 px-6 pb-4"> <div className="mb-2 flex !w-full items-center justify-between border-b border-neutral-500 px-6 pb-4">
<button <Button
intent="tertiary"
size="compacted"
onClick={decreaseMonth} onClick={decreaseMonth}
disabled={prevMonthButtonDisabled} disabled={prevMonthButtonDisabled}
className={chevronClasses.chevronRoot} className={chevronClasses.chevronRoot}
@@ -34,9 +38,10 @@ export const DatePickerCustomHeader = ({
prevMonthButtonDisabled && chevronClasses.chevronClassDisabled, prevMonthButtonDisabled && chevronClasses.chevronClassDisabled,
)} )}
/> />
</button> </Button>
{format(date, "LLLL yyyy", { locale: locale === "fr" ? fr : enGB })} {format(date, "LLLL yyyy", { locale: locale === "fr" ? fr : enGB })}
<button <Button
intent="tertiary"
onClick={increaseMonth} onClick={increaseMonth}
disabled={nextMonthButtonDisabled} disabled={nextMonthButtonDisabled}
className={chevronClasses.chevronRoot} className={chevronClasses.chevronRoot}
@@ -47,7 +52,7 @@ export const DatePickerCustomHeader = ({
nextMonthButtonDisabled && chevronClasses.chevronClassDisabled, nextMonthButtonDisabled && chevronClasses.chevronClassDisabled,
)} )}
/> />
</button> </Button>
</div> </div>
); );
}; };
+80
View File
@@ -0,0 +1,80 @@
import { VariantProps, cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
export const inputVariants = cva(
[
"flex w-full content-center border",
"transition-all duration-200",
"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",
],
{
variants: {
state: {
disabled: ["dark:bg-gray-50"],
error: ["!border-orange-700 focus:!border-orange-700"],
},
size: {
small: ["font-medium", "px-3 py-2", "rounded-lg"],
medium: ["font-medium", "p-3", "rounded-lg"],
},
},
defaultVariants: {
size: "medium",
},
},
);
export type InputVariants = VariantProps<typeof inputVariants>;
export type InputProps = Omit<React.ComponentProps<"input">, "size"> &
Omit<InputVariants, "state"> & {
hasError?: boolean;
startIcon?: React.ReactNode;
endIcon?: React.ReactNode;
};
export const Input = ({
className,
hasError,
startIcon,
endIcon,
size,
disabled,
required,
...rest
}: InputProps) => {
return (
<div className="relative">
{startIcon && (
<div className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-900 dark:text-white">
{startIcon}
</div>
)}
<input
aria-required={required}
aria-invalid={hasError}
disabled={disabled}
className={twMerge(
inputVariants({
size,
state: hasError ? "error" : disabled ? "disabled" : undefined,
}),
startIcon && "pl-10",
endIcon && "pr-10",
className,
)}
{...rest}
/>
{endIcon && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-900 dark:text-white">
{endIcon}
</div>
)}
</div>
);
};
+19 -7
View File
@@ -13,26 +13,34 @@ export type BaseOption<T = string, D = unknown> = {
data?: D; data?: D;
}; };
export const classNames = <Option, IsMulti extends boolean = false>( export const createSelectClassNamesFn =
<Option, IsMulti extends boolean = false>({
size,
}: {
size?: "small" | "medium";
}) =>
(
hasError: boolean, hasError: boolean,
separators: boolean, separators: boolean,
): ClassNamesConfig<Option, IsMulti, GroupBase<Option>> => ({ ): ClassNamesConfig<Option, IsMulti, GroupBase<Option>> => ({
container: () => "w-full", container: () => "w-full",
valueContainer: () => "text-gray-900 dark:text-white", valueContainer: () => "text-gray-900 dark:text-white",
indicatorsContainer: () => "flex items-center self-stretch shrink-0", indicatorsContainer: () => "flex items-center self-stretch shrink-0",
clearIndicator: () => "flex items-center pr-2 text-gray-900 dark:text-white", clearIndicator: () =>
"flex items-center pr-2 text-gray-900 dark:text-white",
dropdownIndicator: () => dropdownIndicator: () =>
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white", "pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
indicatorSeparator: () => "w-px text-gray-900 dark:text-white", indicatorSeparator: () => "w-px text-gray-900 dark:text-white",
control: (state) => control: (state) =>
twMerge( twMerge(
"group flex w-full items-center justify-between rounded-lg border p-2.5", "group flex w-full items-center justify-between rounded-lg border p-3",
"border-gray-300 bg-gray-50 text-gray-900 focus-within:border-primary-500 focus-within:ring-primary-500", "border-gray-300 bg-gray-50 text-gray-900 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", "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", hasError && "ring-1 ring-error",
state.isDisabled && "cursor-not-allowed", state.isDisabled && "cursor-not-allowed",
state.isFocused && "border-primary ring-1 ring-primary ring-opacity-50", state.isFocused && "border-primary ring-1 ring-primary ring-opacity-50",
size === "small" && "p-2",
), ),
multiValue: () => "bg-fieldGray border rounded-lg flex space-x-1 pl-1 m-1", multiValue: () => "bg-fieldGray border rounded-lg flex space-x-1 pl-1 m-1",
multiValueLabel: () => "", multiValueLabel: () => "",
@@ -66,6 +74,7 @@ export type SelectProps<
required?: boolean; required?: boolean;
separators?: boolean; separators?: boolean;
hasError?: boolean; hasError?: boolean;
size?: "small" | "medium";
classNameOverrides?: ClassNamesConfig< classNameOverrides?: ClassNamesConfig<
BaseOption<T, D>, BaseOption<T, D>,
IsMulti, IsMulti,
@@ -81,6 +90,7 @@ export const Select = <
>({ >({
className, className,
required, required,
size,
placeholder, placeholder,
separators, separators,
@@ -90,6 +100,11 @@ export const Select = <
...selectProps ...selectProps
}: SelectProps<IsMulti, T, D>) => { }: SelectProps<IsMulti, T, D>) => {
const t = useTranslations("App"); const t = useTranslations("App");
const classNames = createSelectClassNamesFn<BaseOption<T, D>, IsMulti>({
size,
});
return ( return (
<ReactSelect <ReactSelect
options={options} options={options}
@@ -105,10 +120,7 @@ export const Select = <
}), }),
}} }}
classNames={{ classNames={{
...classNames<BaseOption<T, D>, IsMulti>( ...classNames(hasError ?? false, separators ?? false),
hasError ?? false,
separators ?? false,
),
...(classNameOverrides ?? {}), ...(classNameOverrides ?? {}),
}} }}
{...selectProps} {...selectProps}
+5 -12
View File
@@ -12,6 +12,7 @@ import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types"; import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field"; import { Field, GenericFieldProps } from "./Field";
import { inputVariants } from "./Input";
type TextAreaFieldProps< type TextAreaFieldProps<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
@@ -72,12 +73,6 @@ export const TextAreaField = <
control={control} control={control}
name={name} name={name}
render={({ field }) => ( 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 <textarea
{...form.register(name, { {...form.register(name, {
required, required,
@@ -86,11 +81,10 @@ export const TextAreaField = <
aria-invalid={hasError} aria-invalid={hasError}
disabled={disabled} disabled={disabled}
className={twMerge( className={twMerge(
"flex w-full content-center rounded-lg border p-3", inputVariants({
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus:border-primary-500 focus:ring-primary-500", state: hasError ? "error" : disabled ? "disabled" : undefined,
"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", className,
disabled && "dark:bg-gray-50",
)} )}
{...rest} {...rest}
onChange={(e) => { onChange={(e) => {
@@ -98,7 +92,6 @@ export const TextAreaField = <
}} }}
value={field.value} value={field.value}
/> />
</div>
)} )}
/> />
</div> </div>
+7 -19
View File
@@ -12,13 +12,14 @@ import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types"; import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field"; import { Field, GenericFieldProps } from "./Field";
import { Input, InputProps } from "./Input";
type TextFieldProps< type TextFieldProps<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify< > = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & GenericFieldProps<TFieldValues, TFieldName> &
Omit<React.HTMLProps<HTMLInputElement>, "ref" | "name"> & { Omit<InputProps, "ref" | "name" | "size"> & {
handleChanged?: (props: { name: string }) => void; handleChanged?: (props: { name: string }) => void;
startIcon?: React.ReactNode; startIcon?: React.ReactNode;
} }
@@ -89,29 +90,17 @@ export const TextField = <
control={control} control={control}
name={name} name={name}
render={({ field }) => ( render={({ field }) => (
<div className="relative"> <Input
{startIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{startIcon}
</div>
)}
<input
type={type} type={type}
{...form.register(name, { {...form.register(name, {
valueAsNumber: type === "number", valueAsNumber: type === "number",
required, required,
})} })}
aria-required={required} hasError={hasError}
aria-invalid={hasError} required={required}
disabled={disabled} disabled={disabled}
className={twMerge( startIcon={startIcon}
"flex w-full content-center rounded-lg border p-3", className={twMerge(startIcon && "pl-10")}
"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} {...rest}
onChange={(e) => { onChange={(e) => {
field.onChange(output(e)); field.onChange(output(e));
@@ -121,7 +110,6 @@ export const TextField = <
if (handleChanged) handleChanged({ name }); if (handleChanged) handleChanged({ name });
}} }}
/> />
</div>
)} )}
/> />
</div> </div>
+3 -3
View File
@@ -37,7 +37,7 @@
"signIn": "Se connecter", "signIn": "Se connecter",
"signOut": "Se déconnecter", "signOut": "Se déconnecter",
"myZones": "Vos Régions", "myZones": "Vos Régions",
"deleteAccount": "Supprimer le compte" "deleteAccount": "Supprimer mon compte"
}, },
"Footer": { "Footer": {
@@ -263,9 +263,9 @@
}, },
"DeleteAccount": { "DeleteAccount": {
"title": "Supprimer le compte", "title": "Supprimer mon compte",
"info": "Êtes-vous sûr de vouloir supprimer votre compte\u00a0? Cette action est irréversible.", "info": "Êtes-vous sûr de vouloir supprimer votre compte\u00a0? Cette action est irréversible.",
"deleteButton": "Supprimer le compte", "deleteButton": "Supprimer mon compte",
"success": "Votre compte a été supprimé avec succès.", "success": "Votre compte a été supprimé avec succès.",
"failure": "Oups, quelque chose s'est mal passé. Veuillez réessayer." "failure": "Oups, quelque chose s'est mal passé. Veuillez réessayer."
}, },
+12
View File
@@ -2584,6 +2584,13 @@ chokidar@^3.5.3:
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
class-variance-authority@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.0.tgz#1c3134d634d80271b1837452b06d821915954522"
integrity sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==
dependencies:
clsx "2.0.0"
classnames@^2.2.6, classnames@^2.3.0: classnames@^2.2.6, classnames@^2.3.0:
version "2.5.1" version "2.5.1"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
@@ -2601,6 +2608,11 @@ client-only@0.0.1:
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
clsx@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b"
integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
clsx@^2.0.0, clsx@^2.1.1: clsx@^2.0.0, clsx@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"