From 4181b926708f36e199a7e1a96aa0df315a432bd5 Mon Sep 17 00:00:00 2001 From: Timothy Armes Date: Thu, 3 Oct 2024 14:21:09 +0200 Subject: [PATCH] Factorise Button and Input components --- .vscode/settings.json | 6 +- package.json | 1 + .../(main)/add-tournament/TournamentForm.tsx | 30 +++-- .../(main)/contact-us/ContactForm.tsx | 9 +- .../[locale]/(main)/delete-account/page.tsx | 13 ++- src/app/[locale]/(main)/elo/ManualEloForm.tsx | 34 +++--- src/app/[locale]/(main)/elo/page.tsx | 8 +- .../(main)/tournaments/TournamentTable.tsx | 1 + .../(main)/zones/components/ZoneForm.tsx | 16 +-- src/app/[locale]/(main)/zones/page.tsx | 24 ++-- src/components/AuthButton.tsx | 2 +- src/components/Button.tsx | 61 ++++++++++ src/components/DropdownMenu.tsx | 14 +-- src/components/Map.tsx | 10 +- src/components/SearchBar.tsx | 59 +++++----- src/components/SignInForm.tsx | 9 +- src/components/form/AsyncSelectField.tsx | 13 ++- .../components/DatePickerCustomHeader.tsx | 13 ++- src/components/form/Input.tsx | 80 ++++++++++++++ src/components/form/Select.tsx | 104 ++++++++++-------- src/components/form/TextAreaField.tsx | 45 ++++---- src/components/form/TextField.tsx | 56 ++++------ src/messages/fr.json | 6 +- yarn.lock | 12 ++ 24 files changed, 393 insertions(+), 233 deletions(-) create mode 100644 src/components/Button.tsx create mode 100644 src/components/form/Input.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index b74065b..fc9ff2a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -53,5 +53,9 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "css.lint.unknownAtRules": "ignore", - "tailwindCSS.experimental.classRegex": ["[a-zA-Z]*ClassName=\"([^\"]+)\""] + "tailwindCSS.experimental.classRegex": [ + ["[a-zA-Z]*ClassName=\"([^\"]+)\""], + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ] } diff --git a/package.json b/package.json index 22cade2..80b747d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@turf/boolean-point-in-polygon": "^7.0.0-alpha.114", "@types/node": "^22.7.2", "autoprefixer": "^10.4.16", + "class-variance-authority": "^0.7.0", "date-fns": "^4.1.0", "geojson": "^0.5.0", "jotai": "^2.6.1", diff --git a/src/app/[locale]/(main)/add-tournament/TournamentForm.tsx b/src/app/[locale]/(main)/add-tournament/TournamentForm.tsx index 51e2b9c..d14a869 100644 --- a/src/app/[locale]/(main)/add-tournament/TournamentForm.tsx +++ b/src/app/[locale]/(main)/add-tournament/TournamentForm.tsx @@ -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 /> -
+ +
@@ -207,21 +210,16 @@ const TournamentForm = () => {
- - +
+ + + +
diff --git a/src/app/[locale]/(main)/contact-us/ContactForm.tsx b/src/app/[locale]/(main)/contact-us/ContactForm.tsx index 963abe9..bbcd210 100644 --- a/src/app/[locale]/(main)/contact-us/ContactForm.tsx +++ b/src/app/[locale]/(main)/contact-us/ContactForm.tsx @@ -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 /> - + diff --git a/src/app/[locale]/(main)/delete-account/page.tsx b/src/app/[locale]/(main)/delete-account/page.tsx index cb3405b..f390ffd 100644 --- a/src/app/[locale]/(main)/delete-account/page.tsx +++ b/src/app/[locale]/(main)/delete-account/page.tsx @@ -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() { )}
- + - +
)} diff --git a/src/app/[locale]/(main)/elo/ManualEloForm.tsx b/src/app/[locale]/(main)/elo/ManualEloForm.tsx index 49a4378..6446f6f 100644 --- a/src/app/[locale]/(main)/elo/ManualEloForm.tsx +++ b/src/app/[locale]/(main)/elo/ManualEloForm.tsx @@ -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 && ( - + + )} {gameFields.length > 1 && ( - + + )} @@ -244,26 +250,26 @@ export const ManualEloForm = () => {
{!isDefault && ( - + )} - +
diff --git a/src/app/[locale]/(main)/elo/page.tsx b/src/app/[locale]/(main)/elo/page.tsx index 35437fe..cda02c1 100644 --- a/src/app/[locale]/(main)/elo/page.tsx +++ b/src/app/[locale]/(main)/elo/page.tsx @@ -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 (
-
+

- +

)} diff --git a/src/app/[locale]/(main)/tournaments/TournamentTable.tsx b/src/app/[locale]/(main)/tournaments/TournamentTable.tsx index e8ecdf4..7044059 100644 --- a/src/app/[locale]/(main)/tournaments/TournamentTable.tsx +++ b/src/app/[locale]/(main)/tournaments/TournamentTable.tsx @@ -85,6 +85,7 @@ const TournamentTable = () => { classNameOverrides={{ container: () => "flex flex-1", }} + size="small" />
diff --git a/src/app/[locale]/(main)/zones/components/ZoneForm.tsx b/src/app/[locale]/(main)/zones/components/ZoneForm.tsx index 0ea9a32..02ddf79 100644 --- a/src/app/[locale]/(main)/zones/components/ZoneForm.tsx +++ b/src/app/[locale]/(main)/zones/components/ZoneForm.tsx @@ -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 = ({
- + - +
diff --git a/src/app/[locale]/(main)/zones/page.tsx b/src/app/[locale]/(main)/zones/page.tsx index 37b441e..cd5d7a4 100644 --- a/src/app/[locale]/(main)/zones/page.tsx +++ b/src/app/[locale]/(main)/zones/page.tsx @@ -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")} - + @@ -155,19 +157,15 @@ const Zones = () => {
- - + + +
diff --git a/src/components/AuthButton.tsx b/src/components/AuthButton.tsx index a95d7f7..ab1fcab 100644 --- a/src/components/AuthButton.tsx +++ b/src/components/AuthButton.tsx @@ -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"; diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..489fdcf --- /dev/null +++ b/src/components/Button.tsx @@ -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; +export type ButtonProps = React.ComponentProps<"button"> & ButtonVariants; + +export const Button = ({ + className, + intent, + size, + ...props +}: ButtonProps & { className?: string }) => ( + - + ))} - + ); diff --git a/src/components/Map.tsx b/src/components/Map.tsx index b1cd651..d1909f3 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -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; bounce: () => void; @@ -238,12 +240,14 @@ export const Map = ({
- +
); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index f99d57b..c9e5309 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -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) => { +
-
- - - -
- - {searchString !== "" && ( -
- -
- )} - - + + + } + endIcon={ + searchString !== "" && ( + + ) + } value={searchString} onChange={(e) => updateSearchString(e.target.value)} /> diff --git a/src/components/SignInForm.tsx b/src/components/SignInForm.tsx index 81250e3..ab4cf78 100644 --- a/src/components/SignInForm.tsx +++ b/src/components/SignInForm.tsx @@ -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 /> - + Promise[]>; onInformChange?: (data: BaseOption[] | 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, IsMulti>({ + size, + }); + return ( , IsMulti>( - hasError, - separators ?? false, - )} + classNames={classNames(hasError, separators ?? false)} {...selectProps} /> ); diff --git a/src/components/form/DateField/components/DatePickerCustomHeader.tsx b/src/components/form/DateField/components/DatePickerCustomHeader.tsx index 3aa93ad..96da1d1 100644 --- a/src/components/form/DateField/components/DatePickerCustomHeader.tsx +++ b/src/components/form/DateField/components/DatePickerCustomHeader.tsx @@ -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 (
- + {format(date, "LLLL yyyy", { locale: locale === "fr" ? fr : enGB })} - +
); }; diff --git a/src/components/form/Input.tsx b/src/components/form/Input.tsx new file mode 100644 index 0000000..aa75f26 --- /dev/null +++ b/src/components/form/Input.tsx @@ -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; +export type InputProps = Omit, "size"> & + Omit & { + hasError?: boolean; + startIcon?: React.ReactNode; + endIcon?: React.ReactNode; + }; + +export const Input = ({ + className, + hasError, + startIcon, + endIcon, + + size, + + disabled, + required, + ...rest +}: InputProps) => { + return ( +
+ {startIcon && ( +
+ {startIcon} +
+ )} + + + + {endIcon && ( +
+ {endIcon} +
+ )} +
+ ); +}; diff --git a/src/components/form/Select.tsx b/src/components/form/Select.tsx index f9052d4..93c8fad 100644 --- a/src/components/form/Select.tsx +++ b/src/components/form/Select.tsx @@ -13,49 +13,57 @@ export type BaseOption = { data?: D; }; -export const classNames = ( - hasError: boolean, - separators: boolean, -): ClassNamesConfig> => ({ - 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 = + ({ + size, + }: { + size?: "small" | "medium"; + }) => + ( + hasError: boolean, + separators: boolean, + ): ClassNamesConfig> => ({ + 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, IsMulti, @@ -81,6 +90,7 @@ export const Select = < >({ className, required, + size, placeholder, separators, @@ -90,6 +100,11 @@ export const Select = < ...selectProps }: SelectProps) => { const t = useTranslations("App"); + + const classNames = createSelectClassNamesFn, IsMulti>({ + size, + }); + return ( , IsMulti>( - hasError ?? false, - separators ?? false, - ), + ...classNames(hasError ?? false, separators ?? false), ...(classNameOverrides ?? {}), }} {...selectProps} diff --git a/src/components/form/TextAreaField.tsx b/src/components/form/TextAreaField.tsx index fea2796..717f576 100644 --- a/src/components/form/TextAreaField.tsx +++ b/src/components/form/TextAreaField.tsx @@ -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 }) => ( -
- {startIcon && ( -
- {startIcon} -
+