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
@@ -8,6 +8,7 @@ import dynamic from "next/dynamic";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/Button";
import { clearMessage } from "@/components/InfoMessage";
import InfoMessage from "@/components/InfoMessage";
import LoadingMap from "@/components/LoadingMap";
@@ -174,12 +175,14 @@ const TournamentForm = () => {
required
/>
</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
className="h-full"
childrenWrapperClassName="h-full"
name="message"
control={form.control}
label={t("messageLabel")}
rows={6}
placeholder={t("messagePlaceholder")}
/>
</div>
@@ -207,21 +210,16 @@ const TournamentForm = () => {
<Map />
</section>
</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} />
</form>
</FormProvider>
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/Button";
import InfoMessage from "@/components/InfoMessage";
import { clearMessage } from "@/components/InfoMessage";
import { TextAreaField } from "@/components/form/TextAreaField";
@@ -77,13 +78,9 @@ const ContactForm = () => {
required
/>
<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"
>
<Button disabled={form.formState.isSubmitting} type="submit">
{form.formState.isSubmitting ? t("sending") : t("sendButton")}
</button>
</Button>
<InfoMessage responseMessage={responseMessage} />
</form>
@@ -5,6 +5,7 @@ import { useState } from "react";
import { signOut } from "next-auth/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/Button";
import InfoMessage from "@/components/InfoMessage";
import { deleteAccount } from "@/server/deleteAccount";
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">
<button
<Button
type="button"
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")}
</button>
</Button>
<button
<Button
onClick={() => onDeleteAccount()}
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")}
</button>
</Button>
</div>
</>
)}
+20 -14
View File
@@ -8,6 +8,7 @@ import { IoAdd, IoCloseOutline } from "react-icons/io5";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { Button } from "@/components/Button";
import { RadioGroupField } from "@/components/form/RadioGroupField";
import { SelectField } from "@/components/form/SelectField";
import { TextField } from "@/components/form/TextField";
@@ -176,26 +177,31 @@ export const ManualEloForm = () => {
}))}
required
/>
{gameFields.length > 1 && (
<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"
<Button
intent="tertiary"
size="compacted"
className="hidden h-8 w-8 sm:flex"
type="button"
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" />
</button>
<IoCloseOutline className="h-6 w-6" />
</Button>
)}
</div>
</div>
{gameFields.length > 1 && (
<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"
<Button
intent="tertiary"
size="compacted"
className="flex h-8 w-8 sm:hidden"
type="button"
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" />
</button>
<IoCloseOutline className="h-6 w-6" />
</Button>
)}
</div>
@@ -244,26 +250,26 @@ export const ManualEloForm = () => {
<div className="mt-8 flex justify-end gap-4">
{!isDefault && (
<button
<Button
onClick={() => {
form.reset();
}}
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")}
</button>
</Button>
)}
<button
<Button
onClick={() => {
appendGame({});
}}
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" />
{t("addGameButton")}
</button>
</Button>
</div>
</form>
</FormProvider>
+5 -3
View File
@@ -10,6 +10,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/Button";
import { ErrorBox } from "@/components/ErrorBox";
import { Spinner } from "@/components/Spinner";
import { TranslatedError } from "@/components/TranslatedError";
@@ -176,7 +177,7 @@ export default function Elo() {
return (
<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
className="mb-10 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
data-test="header2"
@@ -189,13 +190,14 @@ export default function Elo() {
{hasTournamentId && !isFetching && !error && (
<div className="mx-auto mb-8 flex justify-center">
<button
<Button
intent="secondary"
type="button"
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"
>
{t("enterManualResultsButton")}
</button>
</Button>
</div>
)}
@@ -85,6 +85,7 @@ const TournamentTable = () => {
classNameOverrides={{
container: () => "flex flex-1",
}}
size="small"
/>
</div>
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/Button";
import { InlineSwitchField } from "@/components/form/InlineSwitchField";
import { Label } from "@/components/form/Label";
import { TextField } from "@/components/form/TextField";
@@ -110,20 +111,13 @@ export const ZoneForm = ({
</div>
<div className="flex justify-end gap-4">
<button
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"
>
<Button intent="secondary" onClick={onCancel}>
{cancelTitle ?? at("cancelButton")}
</button>
</Button>
<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"
>
<Button disabled={form.formState.isSubmitting} type="submit">
{submitTitle ?? at("saveButton")}
</button>
</Button>
</div>
</form>
</FormProvider>
+11 -13
View File
@@ -6,6 +6,7 @@ import { useFormatter, useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import { IoAdd } from "react-icons/io5";
import { Button, buttonVariants } from "@/components/Button";
import InfoMessage, { clearMessage } from "@/components/InfoMessage";
import { Modal } from "@/components/Modal";
import { Spinner } from "@/components/Spinner";
@@ -116,17 +117,18 @@ const Zones = () => {
pathname: "/zones/edit/[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")}
</Link>
<button
<Button
intent="secondary"
type="button"
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")}
</button>
</Button>
</div>
</div>
</div>
@@ -155,19 +157,15 @@ const Zones = () => {
<InfoMessage responseMessage={responseMessage} />
<div className="flex items-center justify-between space-x-4 text-sm font-bold">
<button
<Button
intent="secondary"
type="button"
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")}
</button>
<button
onClick={() => onDelete()}
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>
</Button>
<Button onClick={() => onDelete()}>{at("deleteButton")}</Button>
</div>
</Modal>
</>
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { Menu } from "@headlessui/react";
import { Button, Menu } from "@headlessui/react";
import { signIn, signOut, useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
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 { 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 { twMerge } from "tailwind-merge";
@@ -37,15 +37,15 @@ export const DropdownMenu = ({
leaveTo="transform opacity-0 scale-95"
>
{buttonComponent ?? (
<Menu.Button>
<MenuButton>
<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">
{items.map(({ title, onClick, className, disabled }, i) => (
<Menu.Item key={i}>
<MenuItem key={i}>
<button
type="button"
className={twMerge(
@@ -61,10 +61,10 @@ export const DropdownMenu = ({
>
{title}
</button>
</Menu.Item>
</MenuItem>
))}
</div>
</Menu.Items>
</MenuItems>
</Float>
</Menu>
);
+7 -3
View File
@@ -16,6 +16,8 @@ import MarkerClusterGroup from "react-leaflet-cluster";
import { debouncedHoveredListIdAtom, mapBoundsAtom } from "@/atoms";
import { MapEvents } from "@/components/MapEvents";
import { Button } from "./Button";
export type MarkerRef = {
getMarker: () => L.Marker<any>;
bounce: () => void;
@@ -238,12 +240,14 @@ export const Map = ({
</MapContainer>
<div className="flex items-center justify-center lg:hidden">
<button
className="p-3 text-primary-900 dark:text-white"
<Button
intent="tertiary"
size="compacted"
className="p-3"
onClick={onScrollToTable}
>
<FaAngleDoubleDown />
</button>
</Button>
</div>
</section>
);
+31 -28
View File
@@ -7,6 +7,9 @@ import { twMerge } from "tailwind-merge";
import { searchStringAtom } from "@/atoms";
import { Button } from "./Button";
import { Input } from "./form/Input";
type SearchBarProps = {
className?: string;
};
@@ -27,38 +30,38 @@ const SearchBar = ({ className }: SearchBarProps) => {
<label htmlFor="table-search" className="sr-only">
{t("searchLabel")}
</label>
<div className="relative">
<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
<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")}
size="small"
startIcon={
<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>
}
endIcon={
searchString !== "" && (
<Button
intent="tertiary"
size="compacted"
onClick={() => setSearchString("")}
>
<IoCloseOutline className="h-5 w-5" />
</Button>
)
}
value={searchString}
onChange={(e) => updateSearchString(e.target.value)}
/>
+3 -6
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { Button } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
@@ -91,13 +92,9 @@ export const SignInForm = ({ callbackPath }: SignInFormProps) => {
required
/>
<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"
>
<Button disabled={form.formState.isSubmitting} type="submit">
{t("signInButton")}
</button>
</Button>
<InfoMessage
className="text-center"
+8 -5
View File
@@ -15,7 +15,7 @@ import AsyncSelect from "react-select/async";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
import { BaseOption, classNames } from "./Select";
import { BaseOption, createSelectClassNamesFn } from "./Select";
export type AsyncSelectFieldProps<
TFieldValues extends FieldValues = FieldValues,
@@ -34,6 +34,7 @@ export type AsyncSelectFieldProps<
loadOptions: (searchString?: string) => Promise<BaseOption<T, D>[]>;
onInformChange?: (data: BaseOption<T, D>[] | null) => void;
separators?: boolean;
size?: "small" | "medium";
}
>;
@@ -50,6 +51,7 @@ export const AsyncSelectField = <
label,
hideErrorMessage,
required,
size,
loadOption,
loadOptions,
@@ -132,6 +134,10 @@ export const AsyncSelectField = <
return options;
};
const classNames = createSelectClassNamesFn<BaseOption<T, D>, IsMulti>({
size,
});
return (
<Field {...{ name, control, className, label, hideErrorMessage, required }}>
<Controller
@@ -184,10 +190,7 @@ export const AsyncSelectField = <
},
}),
}}
classNames={classNames<BaseOption<T, D>, IsMulti>(
hasError,
separators ?? false,
)}
classNames={classNames(hasError, separators ?? false)}
{...selectProps}
/>
);
@@ -7,6 +7,8 @@ import { ReactDatePickerCustomHeaderProps } from "react-datepicker";
import { IoChevronBack, IoChevronForward } from "react-icons/io5";
import { twMerge } from "tailwind-merge";
import { Button } from "@/components/Button";
const chevronClasses = {
chevronRoot: "h-4 w-4 dark:text-white",
chevronClassDisabled: "!text-neutral-500",
@@ -23,7 +25,9 @@ export const DatePickerCustomHeader = ({
return (
<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}
disabled={prevMonthButtonDisabled}
className={chevronClasses.chevronRoot}
@@ -34,9 +38,10 @@ export const DatePickerCustomHeader = ({
prevMonthButtonDisabled && chevronClasses.chevronClassDisabled,
)}
/>
</button>
</Button>
{format(date, "LLLL yyyy", { locale: locale === "fr" ? fr : enGB })}
<button
<Button
intent="tertiary"
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
className={chevronClasses.chevronRoot}
@@ -47,7 +52,7 @@ export const DatePickerCustomHeader = ({
nextMonthButtonDisabled && chevronClasses.chevronClassDisabled,
)}
/>
</button>
</Button>
</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>
);
};
+58 -46
View File
@@ -13,49 +13,57 @@ export type BaseOption<T = string, D = unknown> = {
data?: D;
};
export const classNames = <Option, IsMulti extends boolean = false>(
hasError: boolean,
separators: 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: () => "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-2.5",
"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",
export const createSelectClassNamesFn =
<Option, IsMulti extends boolean = false>({
size,
}: {
size?: "small" | "medium";
}) =>
(
hasError: boolean,
separators: 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: () =>
"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",
"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",
hasError && "ring-1 ring-error",
state.isDisabled && "cursor-not-allowed",
state.isFocused && "border-primary ring-1 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-30 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: () => "mx-3 mt-2 mb-1 text-textGray text-sm uppercase",
option: ({ isFocused, isDisabled }) =>
twMerge(
"px-3 py-2 hover:cursor-pointer",
separators && "border-b border-gray-200",
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",
});
hasError && "ring-1 ring-error",
state.isDisabled && "cursor-not-allowed",
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",
multiValueLabel: () => "",
multiValueRemove: () => "items-center px-1 hover:text-primary",
placeholder: () =>
"block truncate pr-2 placeholder-gray dark:placeholder-gray-400",
menu: () =>
twMerge(
"!z-30 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: () => "mx-3 mt-2 mb-1 text-textGray text-sm uppercase",
option: ({ isFocused, isDisabled }) =>
twMerge(
"px-3 py-2 hover:cursor-pointer",
separators && "border-b border-gray-200",
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 SelectProps<
IsMulti extends boolean = false,
@@ -66,6 +74,7 @@ export type SelectProps<
required?: boolean;
separators?: boolean;
hasError?: boolean;
size?: "small" | "medium";
classNameOverrides?: ClassNamesConfig<
BaseOption<T, D>,
IsMulti,
@@ -81,6 +90,7 @@ export const Select = <
>({
className,
required,
size,
placeholder,
separators,
@@ -90,6 +100,11 @@ export const Select = <
...selectProps
}: SelectProps<IsMulti, T, D>) => {
const t = useTranslations("App");
const classNames = createSelectClassNamesFn<BaseOption<T, D>, IsMulti>({
size,
});
return (
<ReactSelect
options={options}
@@ -105,10 +120,7 @@ export const Select = <
}),
}}
classNames={{
...classNames<BaseOption<T, D>, IsMulti>(
hasError ?? false,
separators ?? false,
),
...classNames(hasError ?? false, separators ?? false),
...(classNameOverrides ?? {}),
}}
{...selectProps}
+19 -26
View File
@@ -12,6 +12,7 @@ import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
import { inputVariants } from "./Input";
type TextAreaFieldProps<
TFieldValues extends FieldValues = FieldValues,
@@ -72,33 +73,25 @@ export const TextAreaField = <
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(
inputVariants({
state: hasError ? "error" : disabled ? "disabled" : undefined,
}),
className,
)}
<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",
"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>
{...rest}
onChange={(e) => {
field.onChange(e);
}}
value={field.value}
/>
)}
/>
</div>
+22 -34
View File
@@ -12,13 +12,14 @@ import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
import { Input, InputProps } from "./Input";
type TextFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<React.HTMLProps<HTMLInputElement>, "ref" | "name"> & {
Omit<InputProps, "ref" | "name" | "size"> & {
handleChanged?: (props: { name: string }) => void;
startIcon?: React.ReactNode;
}
@@ -89,39 +90,26 @@ export const TextField = <
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",
"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>
<Input
type={type}
{...form.register(name, {
valueAsNumber: type === "number",
required,
})}
hasError={hasError}
required={required}
disabled={disabled}
startIcon={startIcon}
className={twMerge(startIcon && "pl-10")}
{...rest}
onChange={(e) => {
field.onChange(output(e));
}}
value={input(field.value)}
onBlur={() => {
if (handleChanged) handleChanged({ name });
}}
/>
)}
/>
</div>
+3 -3
View File
@@ -37,7 +37,7 @@
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"myZones": "Vos Régions",
"deleteAccount": "Supprimer le compte"
"deleteAccount": "Supprimer mon compte"
},
"Footer": {
@@ -263,9 +263,9 @@
},
"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.",
"deleteButton": "Supprimer le compte",
"deleteButton": "Supprimer mon compte",
"success": "Votre compte a été supprimé avec succès.",
"failure": "Oups, quelque chose s'est mal passé. Veuillez réessayer."
},