Build or extend a shared React component in src/frontend/web/components — which folder it belongs in, the cva variant + cn pattern, forwardRef, Formik-aware form fields, icon-only controls with tooltips, portaled menus, barrel exports, and the dark-mode/RTL class rules. Use when adding a reusable component, a new form field, or a variant to an existing one.
Install
npx skillscat add ma496/easy-for-net/ui-component Install via the SkillsCat registry.
Shared components
Which folder
| Folder | For | Examples |
|---|---|---|
components/ui/ |
Generic, app-agnostic primitives | Button, IconButton, Badge, Card, Modal, Dropdown, Loader, Tabs, Tooltip, TreeView, Truncated, DateView, PriceView, Breadcrumbs, LocalizedLink, ApiErrorMessages |
components/ui/form/ |
Inputs — a bare one and, for most, a Formik-bound Form* twin |
Input/FormInput, Select/FormSelect, MultiSelect/FormMultiSelect, Checkbox/FormCheckbox, DatePicker/FormDatePicker, FormLazySelect, FormLazyMultiSelect, FileUpload, MultiFileUpload |
components/ui/data-table/ |
The table system | DataTableProvider, DataTable, DataTableToolbar, DataTableToolbarButton, DataTableFilterButton, DataTableExportButton, DataTableRowActions, DataTablePagination, DataTableSortIcon, DataTableCheckboxCell |
components/layouts/ |
App shell and page chrome | PageContent, Sidebar, Header, Footer, MainContainer, SearchComponent, ServiceUnavailableView, ProviderComponent, TranslationProvider |
components/custom/ |
App-specific composites tied to domain concepts | NavUser, TenantLink, LanguageDropdown, ThemeChanger, ImagePreview, BackLink, CookieConsentDialog |
components/notifications/ |
The notification bell/panel/item trio | NotificationBell, NotificationPanel, NotificationItem |
Screen-specific pieces do not go here — they live in the route's _components/ folder, or the
route group's _components/ when several screens of one feature share them. Promote a component tocomponents/ only once a second feature needs it.
Anatomy
One component per kebab-case file, named export, JSDoc above it, props interface named<Component>Props (or I<Component>Props where that is already the local habit, as IBadgeProps)
declared directly above. Add the export to the folder's barrel — consumers import from@/components/ui, @/components/ui/form, @/components/ui/data-table, @/components/layouts,@/components/custom, @/components/notifications, never from the deep path.
Variants use class-variance-authority + cn (trimmed from button.tsx):
import React, { ButtonHTMLAttributes } from 'react'
import { VariantProps, cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
const buttonVariants = cva('btn', {
variants: {
variant: { default: 'btn-primary', primary: 'btn-primary', outline: 'btn-secondary', secondary: 'btn-secondary', soft: 'btn-soft', ghost: 'btn-ghost', danger: 'btn-danger' },
size: { default: "[&_svg:not([class*='size-'])]:size-4", sm: "btn-sm [&_svg:not([class*='size-'])]:size-3.5", lg: "btn-lg [&_svg:not([class*='size-'])]:size-5" },
rounded: { default: '', full: 'rounded-full' },
},
defaultVariants: { variant: 'default', size: 'default', rounded: 'default' },
})
/** Props for the Button component… */
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
isLoading?: boolean
icon?: React.ReactNode
}
/** Button is a styled native button with variant/size/rounded options, a loading spinner and an optional leading icon. */
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, rounded, isLoading, disabled, children, icon, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, rounded }), className)} ref={ref} disabled={disabled || isLoading} {...props}>
{isLoading && <Loader2 className="animate-spin" />}
{icon && !isLoading && <span className="inline-flex shrink-0">{icon}</span>}
{children}
</button>
))
Button.displayName = 'Button'
export { Button, buttonVariants }Rules that fall out of this:
- Always merge with
cn(...)(clsx + tailwind-merge) so a caller'sclassNamecan override. - Always extend the native element's HTML attributes and spread
...props— callers passtype,aria-*,onClickwithout new props being added. - Export the variants object next to the component (
buttonVariants,badgeVariants,iconButtonVariants) so other components can reuse the classes. forwardRef+displayNamefor anything that wraps a DOM element (Button,IconButton,Card,DataTableToolbarButton— which must forward so it can serve as a Headless UIMenuButtonviaas). Simple presentational components (Badge,PageContent) skip it.- Cross-cutting combinations belong in
compoundVariants(seeBadge's solid/outline matrix)
rather than in conditional logic inside the component. - Compound components expose subcomponents from the same file:
Card/CardHeader/CardTitle/CardContent/CardFooteras named exports, andModalwith staticModal.Header/Modal.FootersharingonClosethrough a localModalContext.
Icon-only controls and menus
- A control with no visible text carries its label twice: as the
Tooltipcontentand asaria-label.DataTableToolbarButtonis the model —labeldrives both,hrefrenders aLocalizedLinkinstead of a button,activemarks an on toggle, and a disabled control keeps the
not-allowed cursor. - Menus and popovers use Headless UI (
Menu/MenuButton/MenuItems) withanchor, so they are
portaled and never clipped by a table's scroll container, and pick the anchor side from direction:anchor={isRTL ? 'bottom start' : 'bottom end'}(DataTableRowActions,DataTableExportButton). - A list of actions a caller filters by permission takes a
hiddenflag per entry and renders nothing
when all are hidden, so call sites list every action and gate each inline. - Any link inside a component is a
LocalizedLinktaking an unprefixed path.
Form fields
Most inputs exist twice: a bare version taking value/onChange (and error) for use outside
Formik — the filter panels use the bare Select — and a Form* version bound by field name.FormLazySelect/FormLazyMultiSelect exist only Formik-bound; FileUpload/MultiFileUpload only
bare, since they upload on selection and report through onUploaded (see file-storage). Add both
variants when you add an ordinary field type.
'use client'
import { useField, useFormikContext } from 'formik'
import { cn } from '@/lib/utils'
import { useId } from 'react'
export const FormInput = ({ label, name, id, showValidation = true, className, icon, autoComplete = 'off', required = false, ...props }: FormInputProps) => {
const [field, meta] = useField(name)
const { submitCount } = useFormikContext()
const isDirty = meta.initialValue !== meta.value
const hasError = (isDirty || submitCount > 0) && meta.error
const generatedId = useId()
const inputId = id ?? generatedId
return (
<div className={cn(className, (isDirty || submitCount > 0) && (hasError ? 'has-error' : ''))}>
{label && (
<label htmlFor={inputId} className="label form-label">
{label}
{required && <span className="ms-1 text-danger">*</span>}
</label>
)}
<div className="relative text-muted-foreground">
<input {...field} {...props} id={inputId} name={name} autoComplete={autoComplete} className={cn('form-input', icon && 'ps-10')} />
{icon && <span className="pointer-events-none absolute inset-s-3 top-1/2 -translate-y-1/2">{icon}</span>}
</div>
{showValidation && hasError && <div className="mt-1.5 text-xs font-medium text-danger">{meta.error}</div>}
</div>
)
}The conventions to keep: errors show only once the field is dirty or the form has been
submitted; the wrapper gets has-error; useId() is called unconditionally and links label and
input; required renders a *; autoComplete defaults to 'off'; the label text is passed in
already translated — components call t() only for their own fixed copy (an aria label, "no
results"), never for caller-supplied text.
Styling
- Colors are semantic tokens, never values.
styles/tailwind.cssdefines each color twice — on:rootfor light and on.darkfor dark — and exposes it to Tailwind through@theme:bg-background,bg-surface/bg-surface-2/bg-surface-3,text-foreground,text-muted-foreground,text-subtle-foreground,border-border,border-input,ring-ring,bg-overlay, and the accentsprimary,secondary,success,warning,danger,info(soft
tints with opacity:bg-primary/10 text-primary). Because the tokens switch with the theme,
dark mode needs nodark:color classes — never pair a light color with adark:override,
and never write a hex value or a stock palette class (gray-*,slate-*,red-*,bg-white,text-black). A shade the tokens lack becomes a new token instyles/tailwind.css.styles/tokens.test.tsfails on any of these inapp/andcomponents/. - The theme ships component classes built on the tokens —
panel,panel-2,btnwithbtn-primary|secondary|soft|ghost|danger|…andbtn-sm|lg,icon-btn,chip-btn,badgewithbadge-<color>(soft),badge-solid-<color>,badge-outline-<color>,form-input|select|textarea| checkbox|radio|label,has-error,menu-surface,table-base,kbd. Prefer them over
hand-rolled Tailwind (v4), and add utilities only for layout and spacing. - Shape and type: controls
rounded-mdand 36px high, cardsrounded-xlwith a hairline border and
at mostshadow-xs(shadows are for floating layers), page titlestext-2xl font-semibold tracking-tight, section titlestext-base font-semibold, numberstabular-nums. - RTL is mandatory: use logical utilities —
ms-/me-,ps-/pe-,inset-s-/inset-e-,text-start/text-end,border-s/border-e,rounded-s/rounded-e— neverml-/pl-/left-/text-left. Where a logical utility does not exist, pair theltr:andrtl:variants
(ltr:text-left rtl:text-right). Components that must branch in JS readuseAppSelector((state) => state.theme.rtlClass) === 'rtl'.i18n/tenant-screens.test.tsfails on
physical utilities (andtranslate-x-) in the tenancy, select-tenant, localization and settings
screens,NavUser,TenantLinkand the sign-in and sign-up forms. - Icons come from
lucide-react, sized with classes (h-4 w-4) or thesizeprop.
Client vs server
Add 'use client' as the first line whenever the component uses hooks, context, browser APIs or
event handlers — that covers most of components/ui. Pure wrappers such as Button, IconButton,Card and PageContent stay server-compatible; do not add the directive unnecessarily.
Checklist
- Right folder, kebab-case file, named export, JSDoc,
<Component>Props -
cvavariants +cn(...)merge + native attributes spread -
forwardRefanddisplayNamewhen wrapping a DOM element - Icon-only control has a tooltip and an
aria-label; menus are anchored/portaled - Semantic color tokens only (no hex, stock palette or
dark:color classes) and logical (RTL-safe) utilities - Exported from the folder's barrel
- For a form field: the bare and the
Form*variant, wired to Formik the same way -
npm run lintandnpx tsc --noEmitpass