Architektura form komponent

Ahojte,

chtěl jsem promyslet a navrhnout lepší architekturu form komponent, protože momentální development verze mi přišla pro používání zbytečně komplikovaná.

Cíle:

  1. Zjednodušit primitives a jejich api + pokud možno mít je by default SSR-friendly

  2. Přizpůsobit architekturu po vzoru shadcn hlavně z hlediska dx (implementace) a zároveň maximálně využít již existující vlastnosti / api headless-ui komponent (e.g. accessibility, předdefinované form komponenty) které si wrappujeme.

Momentální stav (příklad na input + jeho submission form)

  1. Input
import { Input as InputHeadless, type InputProps } from '@headlessui/react';
import React from 'react';

type InputCompProps = React.ComponentProps<'input'> & InputProps;

export function InputComp({ invalid, type, name, placeholder, ...props }: InputCompProps, ref: React.Ref<HTMLInputElement>) {
  return (
    <InputHeadless invalid={invalid} ref={ref} {...props} type={type} placeholder={placeholder} className="ko:w-full ko:border ko:border-black ko:rounded-2xl ko:rounded-br-none ko:p-4" name={name} />
  );
}

export const Input = React.forwardRef<HTMLInputElement, InputCompProps>(InputComp);

  1. Form

*tato komponent vznikla jako rychlý test, zbytek headless-ui ještě přebranduji, ale víceméně tyto asi nemá smysl moc customizovat, validace je teď na rychlo (v produkci počítám se zodem), ještě mrknu na nějaké ty nativní headless-ui api (pokud budou pro nás využitelné)

import { Description, Field, Label } from '@headlessui/react';
import { Input } from '@repo/design-system/components';
import { type SubmitHandler, useForm } from 'react-hook-form';

type EmailSubmissionFormProps = {
  email: string;
};

export function EmailSubmissionForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isValid },
  } = useForm<EmailSubmissionFormProps>();

  const onSubmit: SubmitHandler<EmailSubmissionFormProps> = (data) => {
    console.log('Form submitted:', data);
  };

  return (
    <div className="ko:w-1/2">
      <form onSubmit={handleSubmit(onSubmit)} className="ko:flex">
        <Field className="ko:relative">
          <Label className="ko:hidden">Jméno</Label>
          <Input
            type="email"
            placeholder="Zadejte e-mail"
            invalid={!isValid}
            {...register('email', {
              required: 'Email is required',
              pattern: {
                value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
                message: 'Invalid email address',
              },
            })}
          />
          {errors.email && <Description className="ko:absolute ko:-bottom-1.5 ko:right-4 ko:px-1 ko:bg-white ko:text-xs ko:text-red-500">{errors.email.message}</Description>}
        </Field>
        <button className="ko:w-fit ko:px-4  ko:border-black ko:rounded-2xl ko:rounded-tr-none ko:border-2 " type="submit">
          Submit
        </button>
      </form>
    </div>
  );
}

a) Ten přístup by z toho měl být jasný (separace a zjednodušení primitives, zbytek delegovat na specifickou form komponentu)

b) Dejte vědět @martinwenisch , @krystof-k

c) Ještě by stálo za zvážení postavení custom form komponenty obdobně jako shadcn, otázka, jak je to teď zásadní

d) Rozpracoval jsem si radiogroup, a pillgroup; pošlu asap

1 Like

To mi prijde dobre. Jak jsem psal, ta slozitost byla dana hlavne tim, ze bylo potreba sledovat prazdny Input, coz samotne css neumi. Nahodis PR s verzi k review, ta struktura vypada dobre.

1 Like

Níže pillFilter filter komponenta, kód by měl být víceméně self-explanatory.

FilterForm *** nestnutý ve formu a spravován react-hook-forms

export function FilterForm({ items, toggleItem, toggleAllItems }: FilterFormProps) {
  return (
    <form>
      <PillGroup items={items} togglePill={(value) => toggleItem(value)} toggleAllPills={toggleAllItems} />
    </form>
  );
}

PillGroup

export function PillGroup({ items, togglePill, toggleAllPills }: PillGroupProps) {
  return (
    <div className="ko:flex ko:gap-2">
      <PillGroupItem togglePill={toggleAllPills}>Vybrat vše</PillGroupItem>
      {items.map((item) => (
        <PillGroupItem togglePill={(value) => togglePill(value)} isChecked={item.checked} key={item.value} value={item.value}>
          {item.label}
        </PillGroupItem>
      ))}
    </div>
  );
}

PillGroupItem *** headless-ui komponenty ještě rebrandnu

export function PillGroupItem({ value, children, isChecked, togglePill }: PillGroupItemProps) {
  useEffect(() => {
    setChecked(isChecked);
  }, [isChecked]);

  function onToggleChange() {
    setChecked((prevValue) => !prevValue);
    // solve here
    togglePill(value);
  }

  const [checked, setChecked] = useState(isChecked);
  return (
    <Field>
      <Label>
        <Checkbox value={value} checked={checked} onChange={onToggleChange} className="ko:peer ko:hidden" />
        <span className="ko:peer-data-[checked]:bg-white ko:cursor-pointer ko:font-sans ko:select-none ko:rounded-[1rem] ko:border-[0.0625em] ko:border-black ko:bg-black ko:px-4 ko:py-2 ko:text-[0.625rem] ko:font-bold ko:uppercase ko:text-white ko:peer-checked:border-neutral ko:peer-checked:bg-neutral ko:peer-data-[checked]:text-black">
          {children}
        </span>
      </Label>
    </Field>
  );
}

RadioGroup komponenta

DistrictSelectionForm

import { Field, Label, Radio, RadioGroup } from '@headlessui/react';
import { Fragment, useState } from 'react';
import { useForm } from 'react-hook-form';

type DistrictSelectionForm = {
  options: any[];
};

export function DistrictSelectionForm({ options }: DistrictSelectionForm) {
  const [selected, setSelected] = useState('');

  const {
    handleSubmit,
    register,
    formState: { isDirty },
    setValue,
  } = useForm({
    defaultValues: {
      radio: selected,
    },
  });

  function handleRadioChange(value: string) {
    setSelected(value);
    setValue('radio', value, {
      shouldDirty: true,
    });
  }

  const onSubmit = (data: any) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div className="ko:w-full ko:px-4 ">
        <div className="ko:w-full">
          <RadioGroup value={selected} {...register('radio')} onChange={handleRadioChange} aria-label="District selection form">
            {options.map((option) => (
              <Field key={option.value}>
                <Label className="ko:group ko:flex ko:cursor-pointer ko:items-center ko:border-b ko:border-[#d7dad8] ko:px-4 ko:py-3">
                  <Radio as={Fragment} value={option.value}>
                    {({ checked }) => (
                      <div className="ko:group ko:flex">
                        <span
                          className={`
                            ko:flex ko:size-6 ko:items-center ko:justify-center ko:rounded-full ko:border-2 ko:border-[#565252] ko:group-hover:border-[#ADA6A6] ${checked ? 'ko:border-[#565252] ko:after:size-3 ko:after:bg-[#565252] ko:after:rounded-full ko:group-hover:after:bg-[#ADA6A6]' : ''} ko:bg-white`}
                        />
                        <span className="ko:ml-4 ko:text-[#565252]">{option.label}</span>
                      </div>
                    )}
                  </Radio>
                </Label>
              </Field>
            ))}
          </RadioGroup>
        </div>
      </div>
      <button className="ko:disabled:bg-gray-300 p-4 ko:bg-blue-400" type="submit" disabled={!isDirty}>
        Potvrdit a pokračovat
      </button>
    </form>
  );
}

Ještě jsem ty komponenty nerozházel, ale ale zřejmě tady udělám custom Label (RadioLabel) kvůli stylingu a celkové přehlednosti (alternativou by bylo udělat jeden label + cva, u těch forms mi asi přijde přehlednější to udělat raději custom, co myslíte? Naposledy s @martinwenisch jsme byli spíše pro custom variantu.

Ta struktura vypada dobre, pozor na vytvareni toho Tailwind stringu, nikdy nepouzivej string template, vzdycky twMerge().

1 Like

a) v tomto případě mohu použít &&
b) o něco přehlednější se mi zdá cn(), approach pro umožnění syntaxe níže

return (
  <button
    className={cn("bg-blue-500 py-2 px-4", className, {
      "bg-gray-500": pending,
    })}
  />
);

clsx nainstalovaný je, takže by stačilo vyrobit tu util fn

1 Like

ahojte, ty forms už konečně mají nějakou konkrétnější podobu, můžete na ně prosím mrknout?

a) nakonec jsem tedy rebrandoval headless, ať v tom není zmatek (zde si ještě musím projet ty primitivy obzvlášť ty s komplexnějšími headless typy a případně vyčistit)

b) ještě ladím store koncepci v rámci celého průchodu (default stav, propsy komponent atp.). tady se ještě high-level může něco změnit, ale architektura komponent (až na stylování) by měla zůstat. districtForm v rámci story už používá provider skrze provider (useEffectu se budu moci vyhnout, pokud promyslím ten default stav)

c) jde mi teď hlavně o to, zda struktura a používání je v pohodě (viz provizorní dokumentace ve storybooku) ať každému je jasné, jak má postavit nový funkční formulář, bude-li to třeba. jak jsem již zmiňoval výše snažil jsem z těch headless vytlačit maximum (struktura, logika, a11y) a customizovat jen nutné věci per komponent / group