feat: add page layouts documentation and visual system design for Rdx UI components

- Introduced `page-layouts.md` to outline approved page patterns for progressive adoption.
- Created `visual-system-design.md` to consolidate visual, architectural, and adoption decisions for a uniform UI/UX system in the ERP frontend.
- Added `RdxEditorActionBar` component for managing action bars in editor screens.
- Implemented `RdxPanel` component for grouping related content with optional headers and actions.
This commit is contained in:
David Arranz 2026-09-04 11:07:59 +02:00
parent d92ab05f35
commit 3d2ce8b981
42 changed files with 306 additions and 210 deletions

View File

@ -1,14 +1,14 @@
import {
RdxBadge,
RdxContextPanel,
RdxEditorActionBar,
RdxEditorPageLayout,
RdxEditorSection,
RdxEditorStickyFooter,
RdxFormGrid,
RdxPageActions,
RdxPageContainer,
RdxPageHeader,
RdxPageStack,
RdxPanel,
RdxRowActions,
RightPanel,
} from "@repo/rdx-ui/components";
@ -761,26 +761,26 @@ export default function UiKitPage() {
</div>
</ExampleCard>
<ExampleCard title="RdxEditorSection plain and panel">
<ExampleCard title="RdxPanel plain and panel">
<div className="grid gap-4 lg:grid-cols-2">
<SurfaceExample>
<RdxEditorSection
<RdxPanel
description="Default section without card."
icon={<PlusIcon className="size-4" />}
title="Plain section"
>
<div className="text-sm text-muted-foreground">Section body</div>
</RdxEditorSection>
</RdxPanel>
</SurfaceExample>
<SurfaceExample>
<RdxEditorSection
<RdxPanel
description="Independent surface reserved for exceptional blocks."
title="Panel section"
variant="panel"
variant="card"
>
<div className="text-sm text-muted-foreground">Panel body</div>
</RdxEditorSection>
</RdxPanel>
</SurfaceExample>
</div>
</ExampleCard>
@ -814,7 +814,7 @@ export default function UiKitPage() {
</div>
<div className="flex min-w-0 flex-col gap-6">
<RdxEditorSection
<RdxPanel
description="Primary customer information"
icon={<PlusIcon className="size-4" />}
title="Basic information"
@ -827,9 +827,9 @@ export default function UiKitPage() {
Tax ID field
</div>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description="Secondary preferences"
icon={<PlusIcon className="size-4" />}
title="Preferences"
@ -842,7 +842,7 @@ export default function UiKitPage() {
Currency field
</div>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
</div>
</div>
</div>
@ -873,16 +873,13 @@ export default function UiKitPage() {
<RdxEditorPageLayout
aside={
<div className="flex min-w-0 flex-col gap-6">
<RdxEditorSection
description="Secondary recipient controls."
title="Recipient"
>
<RdxPanel description="Secondary recipient controls." title="Recipient">
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
Customer selector and helper actions
</div>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description="Fiscal defaults and toggles."
title="Tax settings"
>
@ -894,7 +891,7 @@ export default function UiKitPage() {
Default tax selector
</div>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
</div>
}
complexity="complex"
@ -905,7 +902,7 @@ export default function UiKitPage() {
Inline feedback aligned with the editor content
</div>
<RdxEditorSection title="Document information">
<RdxPanel title="Document information">
<RdxFormGrid>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
Reference field
@ -917,7 +914,7 @@ export default function UiKitPage() {
Series field
</div>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
<section className="min-w-0 border-b border-border pb-6">
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
@ -930,7 +927,7 @@ export default function UiKitPage() {
</RdxEditorPageLayout>
</div>
<RdxEditorStickyFooter
<RdxEditorActionBar
leading={
<div className="flex min-w-0 flex-wrap items-center gap-x-5 gap-y-2 text-sm">
<span className="text-muted-foreground">Sticky totals region</span>

View File

@ -1,5 +1,5 @@
import { Outlet } from "react-router-dom";
import type React from "react";
import { Outlet } from "react-router-dom";
import { AppMain } from "./app-main";
@ -8,11 +8,14 @@ interface AppFullscreenLayoutFrameProps {
}
export const AppFullscreenLayoutFrame = ({ children }: AppFullscreenLayoutFrameProps) => {
return (
<AppMain className="h-dvh w-dvw">{children}</AppMain>
);
return <AppMain className="h-dvh w-dvw">{children}</AppMain>;
};
/**
* Shell de aplicación para páginas que ocupan toda el área disponible.
* Se utiliza en editores y vistas de detalle sin sidebar ni topbar global.
*/
export const AppFullscreenLayout = () => {
return (
<AppFullscreenLayoutFrame>

View File

@ -6,6 +6,19 @@ interface AppMainProps {
className?: string;
}
/**
* Área principal de contenido de la aplicación.
*
* Ocupa todo el espacio disponible dentro de su contenedor padre.
* No decide cómo se muestra su contenido.
*/
export const AppMain = ({ children, className }: AppMainProps) => {
return <main className={cn("min-h-0 flex-1 overflow-auto bg-muted", className)}>{children}</main>;
return (
<main
className={cn("flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-muted", className)}
>
{children}
</main>
);
};

View File

@ -21,7 +21,7 @@ export const AppSidebarLayoutFrame = ({ children }: AppSidebarLayoutFrameProps)
}
>
<AppSidebar />
<SidebarInset className="flex h-screen min-w-0 flex-col overflow-hidden bg-background">
<SidebarInset className="flex h-dvh min-w-0 flex-col overflow-hidden bg-background">
<AppTopbar />
<AppMain>{children}</AppMain>
</SidebarInset>
@ -29,6 +29,17 @@ export const AppSidebarLayoutFrame = ({ children }: AppSidebarLayoutFrameProps)
);
};
/**
* Shell principal de la aplicación con navegación lateral.
*
* Compone:
* - sidebar global
* - topbar global
* - área principal de contenido
*
* No decide cómo se muestra su contenido.
*/
export const AppSidebarLayout = () => {
return (
<AppSidebarLayoutFrame>

View File

@ -1,5 +1,5 @@
import { Outlet } from "react-router-dom";
import type React from "react";
import { Outlet } from "react-router-dom";
interface AuthLayoutFrameProps {
children: React.ReactNode;

View File

@ -66,7 +66,7 @@ Deben recibir props ya preparadas por la capa consumidora.
- por defecto, las pantallas cuya finalidad principal sea crear, editar o configurar mediante formulario deben usar shell `app-fullscreen` y no el shell con sidebar;
- usar `PageFormHeader` o el header de dominio ya existente para la navegación mínima de salida, sin duplicar una segunda navegación lateral;
- `RdxEditorSection` con `variant="plain"` es el patrón por defecto del editor simple;
- `RdxPanel` con `variant="plain"` es el patrón por defecto del editor simple;
- usar una única superficie editable cuando sea útil separar claramente la página del área de formulario;
- no envolver cada sección en `Card` por defecto;
- no duplicar padding entre shell, layout y superficie editable;
@ -81,7 +81,7 @@ Deben recibir props ya preparadas por la capa consumidora.
- el `aside` debe apilarse debajo del `main` cuando no haya ancho suficiente;
- si no existe `aside`, no debe reservarse hueco lateral;
- mantener una única superficie principal para el formulario, también en editores complejos;
- `RdxEditorSection` puede usar `variant="panel"` dentro del `aside` cuando el bloque necesite superficie independiente;
- `RdxPanel` puede usar `variant="card"` dentro del `aside` cuando el bloque necesite superficie independiente;
- `RdxFormGrid` debe quedarse en grupos de campos y no envolver tablas, line editors ni layouts funcionales especializados;
- no mover los totales al `aside` por defecto;
- si el editor usa footer sticky inferior, no duplicar `Guardar` y `Cancelar` completos en el header;

View File

@ -94,8 +94,8 @@ Reglas:
scroll container
└── RdxEditorPageLayout complexity="simple"
└── form stack
├── RdxEditorSection
├── RdxEditorSection
├── RdxPanel
├── RdxPanel
└── ...
```
@ -105,7 +105,7 @@ Reglas:
- listados, dashboards, detalle y navegación de settings mantienen `app-sidebar` salvo necesidad funcional explícita;
- `RdxEditorPageLayout` resuelve padding interior, ancho centrado y `min-w-0`;
- el editor simple puede apoyarse en una única superficie contenedora para diferenciar la página del área editable, sin volver a cards por sección;
- `RdxEditorSection` expresa jerarquía por defecto con spacing y divisor inferior;
- `RdxPanel` expresa jerarquía por defecto con spacing y divisor inferior;
- `RdxFormGrid` mantiene la retícula del formulario sin conocer RHF ni dominio;
- priorizar lectura y velocidad de escaneo;
- usar secciones planas con espacio o divisores;
@ -122,9 +122,9 @@ Ejemplo validado en `customers/update`, `customers/create` y `/account`:
{notice}
<form className="flex min-w-0 flex-col gap-6">
<RdxEditorSection title={title} description={description}>
<RdxPanel title={title} description={description}>
<RdxFormGrid>{fields}</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
</form>
</div>
</RdxEditorPageLayout>
@ -166,8 +166,8 @@ shell
│ └── RdxEditorPageLayout complexity="complex"
│ ├── main
│ │ └── única superficie editable
│ │ ├── RdxEditorSection
│ │ ├── RdxEditorSection
│ │ ├── RdxPanel
│ │ ├── RdxPanel
│ │ ├── bloque de líneas con layout propio
│ │ └── ...
│ └── aside
@ -206,9 +206,9 @@ Ejemplo validado en `proformas/update`:
>
<div className="rounded-2xl border border-border bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<form className="flex min-w-0 flex-col gap-6">
<RdxEditorSection title={documentTitle}>
<RdxPanel title={documentTitle}>
<RdxFormGrid>{documentFields}</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
{lineEditor}
</form>

View File

@ -28,7 +28,7 @@ RdxContextPanel
RdxRowActions
RdxListPageLayout
RdxEditorPageLayout
RdxEditorSection
RdxPanel
RdxFormGrid
RdxEditorStickyFooter
RdxDetailPageLayout
@ -525,7 +525,7 @@ RdxPageActions
RdxContextPanel
RdxRowActions
RdxEditorPageLayout
RdxEditorSection
RdxPanel
RdxFormGrid
RdxEditorStickyFooter
```
@ -1074,14 +1074,14 @@ Usar `customers/update` como piloto para:
```txt
RdxEditorPageLayout
RdxEditorSection
RdxPanel
RdxFormGrid
```
Resultado observado a 6 de agosto de 2026:
- `RdxEditorPageLayout` absorbe el ancho centrado y el padding interior del editor simple;
- `RdxEditorSection` sustituye en `customers/update` el uso sistemático de `FormSectionCard` cuando la superficie no necesita card;
- `RdxPanel` sustituye en `customers/update` el uso sistemático de `FormSectionCard` cuando la superficie no necesita card;
- `RdxFormGrid` consolida la retícula ya existente sin mover lógica de formulario fuera del consumidor;
- `PageFormHeader` se mantiene sin cambios funcionales;
- queda pendiente validar visualmente si el header sticky full-width se percibe demasiado separado del ancho centrado del editor.
@ -1096,7 +1096,7 @@ customers/create
Resultado observado a 6 de agosto de 2026:
- `customers/create` reutiliza `RdxEditorPageLayout`, `RdxEditorSection` y `RdxFormGrid` sin requerir nuevas primitives;
- `customers/create` reutiliza `RdxEditorPageLayout`, `RdxPanel` y `RdxFormGrid` sin requerir nuevas primitives;
- create y update comparten shell, header legacy, superficie editable única y secciones planas internas;
- las diferencias reales quedan en defaults, submit y wiring del controller, no en la composición visual;
- `PageFormHeader` sigue siendo funcionalmente suficiente, aunque se mantiene una deuda visual menor por su anchura full-width frente al contenido centrado.
@ -1120,7 +1120,7 @@ Resultado observado a 10 de agosto de 2026:
- el footer sticky de totales se mantiene como dominio visual y económico de Proformas;
- `RdxEditorStickyFooter` queda justificado para combinar en una única superficie sticky la región de totales/contexto y las acciones `Cancelar` / `Guardar`;
- `PageFormHeader` queda reducido a navegación y contexto solo en `proformas/update`, sin cambio global del componente legacy;
- `RdxEditorSection` y `RdxFormGrid` siguen siendo válidos para bloques de campos, pero no deben forzar el layout del editor de líneas ni el footer de totales.
- `RdxPanel` y `RdxFormGrid` siguen siendo válidos para bloques de campos, pero no deben forzar el layout del editor de líneas ni el footer de totales.
## Fase 8 — Detalle
@ -1166,9 +1166,9 @@ No hacer:
- `RightPanel` es suficiente como primitive de superficie lateral y no debe renombrarse globalmente.
- `RdxContextPanel` está justificado como composición visual común sobre `RightPanel`.
- `RdxRowActions` está justificado para normalizar icon buttons, overflow, disabled reason y destructivas en tablas.
- `RdxEditorPageLayout`, `RdxEditorSection` y `RdxFormGrid` quedan confirmados tras migrar `customers/update` y `customers/create`.
- `RdxEditorPageLayout`, `RdxPanel` y `RdxFormGrid` quedan confirmados tras migrar `customers/update` y `customers/create`.
- `RdxEditorPageLayout` también es apto para `proformas/update` cuando el `aside` contiene bloques secundarios ya existentes y el `main` conserva la única superficie editable.
- `RdxEditorSection` soporta tanto secciones planas de formulario como grupos secundarios del `aside` sin volver a `Card` por defecto.
- `RdxPanel` soporta tanto secciones planas de formulario como grupos secundarios del `aside` sin volver a `Card` por defecto.
- `RdxFormGrid` queda confirmado para campos en editores complejos, pero no debe envolver tablas o editores de líneas.
- `RdxEditorStickyFooter` está justificado para el patrón de editor complejo con una sola zona inferior persistente y sin duplicidad de acciones con el header.

View File

@ -119,7 +119,7 @@ Todos los textos visibles pertenecen al namespace `Suppliers`. Se mantienen recu
## 17. Componentes reutilizados
- `RdxPageContainer`, `RdxPageStack`, `RdxPageHeader`, `RdxPageActions`;
- `RdxEditorPageLayout`, `RdxEditorSection`, `RdxFormGrid`;
- `RdxEditorPageLayout`, `RdxPanel`, `RdxFormGrid`;
- `DataTable`, `SkeletonDataTable`, `DataTableColumnHeader`, `RdxRowActions`;
- `TextField`, `SelectField`, `PageFormHeader`, `FormActionsBar`;
- `SimpleSearchInput`, `PageErrorState`, `ErrorAlert`;

View File

@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { mock, test } from "node:test";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
@ -73,13 +74,9 @@ mock.module("@repo/rdx-ui/components", {
capturedResetProps = props;
return null;
},
RdxEditorSection: ({
children,
title,
}: {
children: ReactNode;
title: string;
}) => <section data-title={title}>{children}</section>,
RdxPanel: ({ children, title }: { children: ReactNode; title: string }) => (
<section data-title={title}>{children}</section>
),
RdxEditorStickyFooter: () => null,
RdxFormGrid: ({ children }: { children: ReactNode }) => <div>{children}</div>,
RhfSubmitActionButton: (props: Record<string, unknown>) => {
@ -108,7 +105,7 @@ mock.module("../../../../i18n", {
useTranslation: () => ({
t: (key: string) =>
(
{
({
"settings.companyDetails.title": "Datos de empresa",
"settings.companyDetails.loading": "Cargando datos de la empresa activa...",
"settings.companyDetails.description":
@ -157,7 +154,8 @@ mock.module("../../../../i18n", {
"Debe pertenecer a la empresa activa y estar activo para nuevas asignaciones.",
"settings.companyDetails.sections.paymentTerm.field.placeholder":
"Selecciona un vencimiento",
"settings.companyDetails.sections.paymentTerm.field.none": "Sin vencimiento por defecto",
"settings.companyDetails.sections.paymentTerm.field.none":
"Sin vencimiento por defecto",
"settings.companyDetails.actions.save": "Guardar cambios",
"settings.companyDetails.actions.saving": "Guardando...",
"settings.companyDetails.actions.reset": "Restablecer",
@ -165,7 +163,7 @@ mock.module("../../../../i18n", {
"settings.companyDetails.feedback.saving": "Guardando configuracion de empresa.",
"settings.companyDetails.pageError.actions.retry": "Reintentar",
"settings.companyDetails.pageError.actions.retrying": "Reintentando...",
} as Record<string, string>
}) as Record<string, string>
)[key] ?? key,
}),
},

View File

@ -3,9 +3,9 @@ import { UnsavedChangesProvider } from "@erp/core/hooks";
import {
AppContent,
CancelActionButton,
RdxEditorSection,
RdxEditorStickyFooter,
RdxEditorActionBar,
RdxFormGrid,
RdxPanel,
RhfSubmitActionButton,
SelectField,
TextField,
@ -88,11 +88,11 @@ export const CompanyDetailsSettingsPage = () => {
onKeyDown={ctrl.onFormKeyDown}
onSubmit={ctrl.onSubmit}
>
<RdxEditorSection
<RdxPanel
description={t("settings.companyDetails.sections.general.description")}
icon={<ScrollTextIcon className="size-5" />}
title={t("settings.companyDetails.sections.general.title")}
variant="panel"
variant="card"
>
<RdxFormGrid className="md:grid-cols-2">
<TextField<CompanySettingsForm>
@ -132,13 +132,13 @@ export const CompanyDetailsSettingsPage = () => {
placeholder={t("settings.companyDetails.sections.general.fields.tin.placeholder")}
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description={t("settings.companyDetails.sections.contact.description")}
icon={<ContactIcon className="size-5" />}
title={t("settings.companyDetails.sections.contact.title")}
variant="panel"
variant="card"
>
<RdxFormGrid className="md:grid-cols-2">
<TextField<CompanySettingsForm>
@ -184,13 +184,13 @@ export const CompanyDetailsSettingsPage = () => {
typePreset="url"
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description={t("settings.companyDetails.sections.paymentTerm.description")}
icon={<LandmarkIcon className="size-5" />}
title={t("settings.companyDetails.sections.paymentTerm.title")}
variant="panel"
variant="card"
>
<RdxFormGrid className="md:grid-cols-2">
<SelectField<CompanySettingsForm>
@ -207,7 +207,7 @@ export const CompanyDetailsSettingsPage = () => {
placeholder={t("settings.companyDetails.sections.paymentTerm.field.placeholder")}
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
</form>
<p aria-live="polite" className="sr-only">
@ -216,7 +216,7 @@ export const CompanyDetailsSettingsPage = () => {
: t("settings.companyDetails.feedback.idle")}
</p>
<RdxEditorStickyFooter
<RdxEditorActionBar
primary={
<RhfSubmitActionButton
busyLabel={t("settings.companyDetails.actions.saving")}

View File

@ -6,7 +6,7 @@ import type {
ProformaDetailActionsViewModel,
ProformaDetailHeaderViewModel,
} from "../../entities";
import { ProformaDetailHeaderMeta } from "../components";
import { ProformaDetailArchivedBadge, ProformaDetailHeaderMeta } from "../components";
import { ProformaDetailHeaderActions } from "./proforma-detail-header-actions";

View File

@ -75,7 +75,9 @@ export const ProformaCompactTotals = ({
<div
className={cn(
"grid gap-4 text-sm",
isEmbedded ? "border-b border-border pb-4 lg:grid-cols-3 xl:gap-8" : "max-w-3xl lg:grid-cols-3 xl:max-w-5xl xl:gap-8 2xl:gap-16"
isEmbedded
? "border-b border-border pb-4 lg:grid-cols-3 xl:gap-8"
: "max-w-3xl lg:grid-cols-3 xl:max-w-5xl xl:gap-8 2xl:gap-16"
)}
>
{/* Descuentos */}
@ -184,15 +186,22 @@ export const ProformaCompactTotals = ({
<div
className={cn(
"flex flex-wrap items-center gap-4",
isEmbedded ? "min-w-0 justify-between bg-transparent py-0" : "rounded-none bg-muted px-6 py-3"
isEmbedded
? "min-w-0 justify-between bg-transparent py-0"
: "rounded-none bg-muted px-6 py-3"
)}
>
<Button onClick={() => setIsExpanded(!isExpanded)} size="sm" type="button" variant="outline">
<Button
onClick={() => setIsExpanded(!isExpanded)}
size="sm"
type="button"
variant="outline"
>
{isExpanded ? <ChevronDown className="size-5" /> : <ChevronUp className="size-5" />}
{isExpanded ? "Ocultar desglose" : "Ver desglose"}
</Button>
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-x-5 gap-y-2 text-sm">
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-x-5 gap-y-2 text-base">
<div className="hidden items-center gap-2 lg:flex">
<span className="text-muted-foreground">
{t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")}
@ -210,7 +219,7 @@ export const ProformaCompactTotals = ({
</TooltipContent>
</Tooltip>
<span className="sr-only">
<span className="text-teal-700 sr-only 2xl:not-sr-only">
{t("proformas.update.totals.totalDiscountAmount", "Dtos")}
</span>
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">{`-${formatMoney(totals.totalDiscountAmount)}`}</span>
@ -230,7 +239,7 @@ export const ProformaCompactTotals = ({
{t("proformas.update.totals.taxTotal", "Total impuestos")}
</TooltipContent>
</Tooltip>
<span className="text-muted-foreground sr-only">
<span className=" text-orange-700 sr-only 2xl:not-sr-only">
{t("proformas.update.totals.taxTotal", "Impuestos")}
</span>
<span className="font-medium tabular-nums text-orange-700 dark:text-orange-400">
@ -246,7 +255,7 @@ export const ProformaCompactTotals = ({
{t("proformas.update.totals.retentionAmount", "Total retenciones")}
</TooltipContent>
</Tooltip>
<span className="sr-only">
<span className="sr-only 2xl:not-sr-only">
{t("proformas.update.totals.retentionAmount", "Retenc.")}
</span>
<span className="font-medium tabular-nums">
@ -258,7 +267,7 @@ export const ProformaCompactTotals = ({
{isEmbedded ? (
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<ReceiptTextIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Total</span>
<span className="text-base font-medium text-foreground">Total</span>
<span className="text-base font-bold tabular-nums text-foreground">
{formatMoney(totals.total)}
</span>

View File

@ -308,7 +308,9 @@ export const ProformaLineFooterEditor = ({
<div className="flex items-end justify-between mt-4 pt-4 border-t border-gray-200">
{/* Global discount */}
<div className="flex items-center gap-3">
<FormFieldLabel>Descuento global (%):</FormFieldLabel>
<FormFieldLabel className="text-base">
{t("proformas.update.totals.globalDiscountPercentage", "Descuento global")} (%):
</FormFieldLabel>
<div className="flex items-center gap-2 w-24">
<PercentageField
disabled={disabled}
@ -319,7 +321,7 @@ export const ProformaLineFooterEditor = ({
</div>
<div className="px-6 py-3">
<div className="flex items-baseline gap-4">
<div className="flex items-baseline gap-4 font-medium">
{t("form_fields.items.total.label", "Total dto. global")}:
<span className=" font-medium text-amber-600 tabular-nums">
{MoneyHelper.formatCurrency(totals.globalDiscountAmount, 2, currency)}
@ -335,7 +337,7 @@ export const ProformaLineFooterEditor = ({
{/* Totals summary */}
<div className="flex items-center gap-6">
<div className="px-6 py-3">
<div className="px-6 py-3 font-medium">
<div className="flex items-baseline gap-4">
{t("form_fields.items.total.label", "Total dto. líneas")}:
<span className=" font-medium text-amber-600 tabular-nums">
@ -345,8 +347,8 @@ export const ProformaLineFooterEditor = ({
</div>
<div className="rounded-md border px-6 py-3">
<div className="flex items-baseline gap-4">
{t("form_fields.items.total.label", "Total")}:
<div className="flex items-baseline gap-4 font-medium">
{t("form_fields.items.total.label", "Total de líneas")}:
<span className=" font-bold tabular-nums">
{MoneyHelper.formatCurrency(itemsTotals.total, 2, currency)}
</span>

View File

@ -1,9 +1,4 @@
import {
RdxEditorSection,
RdxFormGrid,
SelectField,
type SelectFieldItem,
} from "@repo/rdx-ui/components";
import { RdxFormGrid, RdxPanel, SelectField, type SelectFieldItem } from "@repo/rdx-ui/components";
import { CreditCardIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -30,12 +25,12 @@ export const ProformaUpdatePaymentEditor = ({
const { t } = useTranslation();
return (
<RdxEditorSection
<RdxPanel
className={className}
description={t("form_groups.proformas.payment.description", "Forma de pago")}
icon={<CreditCardIcon className="size-5" />}
title={t("form_groups.proformas.payment.title", "Condiciones de pago")}
variant="panel"
variant="card"
>
<RdxFormGrid className="w-full">
<SelectField
@ -68,6 +63,6 @@ export const ProformaUpdatePaymentEditor = ({
readOnly={readOnly}
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,8 +1,8 @@
import {
DatePickerField,
RdxBadge,
RdxEditorSection,
RdxFormGrid,
RdxPanel,
SelectField,
type SelectFieldItem,
TextAreaField,
@ -29,11 +29,11 @@ export const ProformaUpdateHeaderEditor = ({
const { t } = useTranslation();
return (
<RdxEditorSection
<RdxPanel
className={className}
icon={<FileTextIcon className="size-5" />}
title={t("form_groups.proformas.basic_info.title")}
variant="panel"
variant="card"
>
<RdxFormGrid className="md:grid-cols-6 2xl:grid-cols-12">
<TextField
@ -109,6 +109,6 @@ export const ProformaUpdateHeaderEditor = ({
readOnly={readOnly}
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,4 +1,4 @@
import { RdxEditorSection } from "@repo/rdx-ui/components";
import { RdxPanel } from "@repo/rdx-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ListIcon } from "lucide-react";
@ -29,7 +29,7 @@ export const ProformaUpdateItemsEditor = ({
const { itemErrors } = itemsCtrl;
return (
<RdxEditorSection
<RdxPanel
className={cn(
itemErrors.length
? "rounded-xl border border-destructive/40 bg-destructive/5 px-4 py-4 md:px-5"
@ -37,7 +37,7 @@ export const ProformaUpdateItemsEditor = ({
)}
icon={<ListIcon className="size-3" />}
title={t("form_groups.items.title", "Líneas de detalle")}
variant="panel"
variant="card"
>
<ProformaLineEditor
addItemAtStart={itemsCtrl.addItemAtStart}
@ -57,6 +57,6 @@ export const ProformaUpdateItemsEditor = ({
showLineTaxes={taxCtrl.usesPerLineTax}
totals={totals}
/>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,5 +1,5 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { RdxEditorSection } from "@repo/rdx-ui/components";
import { RdxPanel } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon, RefreshCwIcon, UserIcon } from "lucide-react";
@ -32,12 +32,15 @@ export const ProformaUpdateRecipientEditor = ({
const showActions = !readOnly;
return (
<RdxEditorSection
<RdxPanel
className={className}
description={t("form_groups.proformas.customer.description", "Cliente destinatario de la proforma")}
description={t(
"form_groups.proformas.customer.description",
"Cliente destinatario de la proforma"
)}
icon={<UserIcon className="size-5" />}
title={t("form_groups.proformas.customer.title", "Cliente")}
variant="panel"
variant="card"
>
<div className="min-w-0 space-y-5">
<SelectedRecipientSummary disabled={disabled} recipient={selectedCustomer} />
@ -69,6 +72,6 @@ export const ProformaUpdateRecipientEditor = ({
</div>
) : null}
</div>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,6 +1,6 @@
import {
RdxEditorSection,
RdxFormGrid,
RdxPanel,
SelectField,
type SelectFieldItem,
SwitchField,
@ -38,7 +38,7 @@ export const ProformaUpdateTaxEditor = ({
const { t } = useTranslation();
return (
<RdxEditorSection
<RdxPanel
className={className}
description={t(
"form_groups.proformas.taxes.description",
@ -46,7 +46,7 @@ export const ProformaUpdateTaxEditor = ({
)}
icon={<LandmarkIcon className="size-5" />}
title={t("form_groups.proformas.taxes.title", "Configuración fiscal")}
variant="panel"
variant="card"
>
<RdxFormGrid>
<SelectField
@ -160,6 +160,6 @@ export const ProformaUpdateTaxEditor = ({
serialize={(value) => (typeof value === "number" ? String(value) : "")}
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -5,7 +5,7 @@ import {
CancelActionButton,
PercentageField,
RdxBadge,
RdxEditorStickyFooter,
RdxEditorActionBar,
RdxPageContainer,
RdxPageStack,
RhfSubmitActionButton,
@ -204,7 +204,7 @@ export const ProformaUpdatePage = () => {
/>
</RdxPageContainer>
<RdxEditorStickyFooter
<RdxEditorActionBar
leading={
<ProformaCompactTotals
currency={updateCtrl.currencyCode}

View File

@ -3,14 +3,13 @@ import { UnsavedChangesProvider } from "@erp/core/hooks";
import {
AppContent,
CancelActionButton,
RdxEditorStickyFooter,
RdxEditorActionBar,
RhfSubmitActionButton,
} from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import { useCompanyVerifactuSettingsPageController } from "../../controllers";
import { CompanyVerifactuSettingsSkeleton } from "../components/company-verifactu-settings-skeleton";
import { CompanyVerifactuConfigurationSection } from "../sections/company-verifactu-configuration-section";
import { CompanyVerifactuDefaultsSection } from "../sections/company-verifactu-defaults-section";
@ -85,7 +84,7 @@ export const CompanyVerifactuSettingsPage = () => {
>
<CompanyVerifactuStatusSection />
{(profileCtrl.showExemptionFields || profileCtrl.showVerifactuFields) ? (
{profileCtrl.showExemptionFields || profileCtrl.showVerifactuFields ? (
<CompanyVerifactuConfigurationSection
disabled={profileCtrl.isUpdating}
showExemptionFields={profileCtrl.showExemptionFields}
@ -106,7 +105,7 @@ export const CompanyVerifactuSettingsPage = () => {
: t("settings.verifactu.feedback.idle")}
</p>
<RdxEditorStickyFooter
<RdxEditorActionBar
primary={
<RhfSubmitActionButton
busyLabel={t("settings.verifactu.actions.saving")}

View File

@ -1,8 +1,8 @@
import type { SelectFieldItem } from "@repo/rdx-ui/components";
import {
DatePickerField,
RdxEditorSection,
RdxFormGrid,
RdxPanel,
SelectField,
TextAreaField,
} from "@repo/rdx-ui/components";
@ -28,11 +28,11 @@ export const CompanyVerifactuConfigurationSection = ({
];
return (
<RdxEditorSection
<RdxPanel
description={t("settings.verifactu.configuration.description")}
icon={<ShieldCheckIcon className="size-5" />}
title={t("settings.verifactu.configuration.title")}
variant="panel"
variant="card"
>
<RdxFormGrid className="md:grid-cols-2">
{showExemptionFields ? (
@ -63,8 +63,8 @@ export const CompanyVerifactuConfigurationSection = ({
/>
<SelectField
clearable
className="col-span-full md:col-span-1"
clearable
description={t("settings.verifactu.auto_renew.description")}
disabled={disabled}
emptyValue=""
@ -76,6 +76,6 @@ export const CompanyVerifactuConfigurationSection = ({
</>
) : null}
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,9 +1,4 @@
import {
RdxEditorSection,
RdxFormGrid,
SelectField,
type SelectFieldItem,
} from "@repo/rdx-ui/components";
import { RdxFormGrid, RdxPanel, SelectField, type SelectFieldItem } from "@repo/rdx-ui/components";
import { LandmarkIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -22,16 +17,16 @@ export const CompanyVerifactuDefaultsSection = ({
const { t } = useTranslation();
return (
<RdxEditorSection
<RdxPanel
description={t("settings.verifactu.defaults.description")}
icon={<LandmarkIcon className="size-5" />}
title={t("settings.verifactu.defaults.title")}
variant="panel"
variant="card"
>
<RdxFormGrid className="md:grid-cols-2">
<SelectField
clearable
className="col-span-full"
clearable
description={t("settings.verifactu.defaults.tax_regime.description")}
disabled={disabled || isTaxRegimesLoading}
emptyValue=""
@ -45,6 +40,6 @@ export const CompanyVerifactuDefaultsSection = ({
{t("settings.verifactu.defaults.pending_catalogs")}
</div>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,4 +1,4 @@
import { FormFieldLabel, RdxEditorSection } from "@repo/rdx-ui/components";
import { FormFieldLabel, RdxPanel } from "@repo/rdx-ui/components";
import {
Field,
FieldContent,
@ -19,11 +19,11 @@ export const CompanyVerifactuStatusSection = () => {
const { control } = useFormContext<CompanyVerifactuProfileForm>();
return (
<RdxEditorSection
<RdxPanel
description={t("settings.verifactu.status.description")}
icon={<FileCogIcon className="size-5" />}
title={t("settings.verifactu.status.title")}
variant="panel"
variant="card"
>
<Controller
control={control}
@ -97,6 +97,6 @@ export const CompanyVerifactuStatusSection = () => {
</Field>
)}
/>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,9 +1,4 @@
import {
FormFieldLabel,
RdxEditorSection,
RdxFormGrid,
TextField,
} from "@repo/rdx-ui/components";
import { FormFieldLabel, RdxFormGrid, RdxPanel, TextField } from "@repo/rdx-ui/components";
import {
Field,
FieldContent,
@ -30,7 +25,7 @@ export const QuickCustomerCreateFormFields = ({
return (
<div className="flex min-w-0 flex-col gap-6">
<RdxEditorSection
<RdxPanel
description={t("form_groups.basic_info.description")}
title={t("form_groups.basic_info.title")}
>
@ -111,9 +106,9 @@ export const QuickCustomerCreateFormFields = ({
)}
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description={t("form_groups.contact_info.description")}
title={t("form_groups.contact_info.title")}
>
@ -129,7 +124,7 @@ export const QuickCustomerCreateFormFields = ({
typePreset="email"
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
</div>
);
};

View File

@ -1,4 +1,4 @@
import { RdxEditorSection, RdxFormGrid, SelectField } from "@repo/rdx-ui/components";
import { RdxFormGrid, RdxPanel, SelectField } from "@repo/rdx-ui/components";
import { Settings2Icon } from "lucide-react";
import { useTranslation } from "../../../i18n";
@ -16,7 +16,7 @@ export const CustomerAdditionalConfigEditor = ({
const { t } = useTranslation();
return (
<RdxEditorSection
<RdxPanel
description={t("form_groups.preferences.description")}
icon={<Settings2Icon className="size-5" />}
title={t("form_groups.preferences.title")}
@ -45,6 +45,6 @@ export const CustomerAdditionalConfigEditor = ({
required
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,9 +1,4 @@
import {
RdxEditorSection,
RdxFormGrid,
SelectField,
TextField,
} from "@repo/rdx-ui/components";
import { RdxFormGrid, RdxPanel, SelectField, TextField } from "@repo/rdx-ui/components";
import { MapPinIcon } from "lucide-react";
import { useTranslation } from "../../../i18n";
@ -21,7 +16,7 @@ export const CustomerAddressEditor = ({
const { t } = useTranslation();
return (
<RdxEditorSection
<RdxPanel
description={t("form_groups.address.description")}
icon={<MapPinIcon className="size-5" />}
title={t("form_groups.address.title")}
@ -82,6 +77,6 @@ export const CustomerAddressEditor = ({
required
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,7 +1,7 @@
import {
FormFieldLabel,
RdxEditorSection,
RdxFormGrid,
RdxPanel,
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
@ -32,7 +32,7 @@ export const CustomerBasicInfoEditor = ({
const { control } = useFormContext();
return (
<RdxEditorSection
<RdxPanel
description={t("form_groups.basic_info.description")}
icon={<FileTextIcon className="size-5" />}
title={t("form_groups.basic_info.title")}
@ -145,6 +145,6 @@ export const CustomerBasicInfoEditor = ({
readOnly={readOnly}
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,4 +1,4 @@
import { RdxEditorSection, RdxFormGrid, TextField } from "@repo/rdx-ui/components";
import { RdxFormGrid, RdxPanel, TextField } from "@repo/rdx-ui/components";
import {
Collapsible,
CollapsibleContent,
@ -23,7 +23,7 @@ export const CustomerContactEditor = ({
const [open, setOpen] = useState(true);
return (
<RdxEditorSection
<RdxPanel
description={t("form_groups.contact_info.description")}
icon={<PhoneIcon className="size-5" />}
title={t("form_groups.contact_info.title")}
@ -140,6 +140,6 @@ export const CustomerContactEditor = ({
</CollapsibleContent>
</Collapsible>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -1,6 +1,6 @@
import {
RdxEditorSection,
RdxFormGrid,
RdxPanel,
SelectField,
type SelectFieldItem,
SwitchField,
@ -27,7 +27,7 @@ export const CustomerFiscalEditor = ({
const { t } = useTranslation();
return (
<RdxEditorSection
<RdxPanel
description={t("form_groups.fiscal_info.description")}
icon={<LandmarkIcon className="size-5" />}
title={t("form_groups.fiscal_info.title")}
@ -88,6 +88,6 @@ export const CustomerFiscalEditor = ({
readOnly={readOnly}
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
);
};

View File

@ -2,9 +2,9 @@ import { ErrorAlert, PageErrorState, PageFormHeader } from "@erp/core/components
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import {
CancelActionButton,
RdxEditorActionBar,
RdxEditorPageLayout,
RdxEditorSection,
RdxEditorStickyFooter,
RdxPanel,
RhfSubmitActionButton,
SelectField,
TextField,
@ -283,7 +283,7 @@ export const AccountPage = () => {
onKeyDown={ctrl.onFormKeyDown}
onSubmit={ctrl.onProfileSubmit}
>
<RdxEditorSection
<RdxPanel
description={t("account.sections.personal.description")}
title={t("account.sections.personal.title")}
>
@ -337,7 +337,7 @@ export const AccountPage = () => {
)}
/>
</div>
</RdxEditorSection>
</RdxPanel>
</form>
<p aria-live="polite" className="sr-only">
@ -346,7 +346,7 @@ export const AccountPage = () => {
: t("account.feedback.idle")}
</p>
<RdxEditorStickyFooter
<RdxEditorActionBar
primary={
<RhfSubmitActionButton
busyLabel={t("account.actions.savingProfile")}
@ -369,7 +369,7 @@ export const AccountPage = () => {
</UnsavedChangesProvider>
</FormProvider>
<RdxEditorSection
<RdxPanel
description={t("account.sections.security.description")}
title={t("account.sections.security.title")}
>
@ -387,9 +387,9 @@ export const AccountPage = () => {
{t("account.actions.changePassword")}
</Button>
</div>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description={t("account.sections.session.description")}
title={t("account.sections.session.title")}
>
@ -401,7 +401,7 @@ export const AccountPage = () => {
{t("account.actions.logout")}
</Button>
</div>
</RdxEditorSection>
</RdxPanel>
</div>
</div>
</RdxEditorPageLayout>

View File

@ -1,7 +1,7 @@
import {
FormFieldLabel,
RdxEditorSection,
RdxFormGrid,
RdxPanel,
SelectField,
TextField,
} from "@repo/rdx-ui/components";
@ -58,7 +58,7 @@ export const SupplierEditorForm = ({
return (
<form className="flex min-w-0 flex-col gap-6" id={formId} noValidate onSubmit={onSubmit}>
<RdxEditorSection
<RdxPanel
description={t("form.sections.identity.description")}
icon={<FileTextIcon className="size-5" />}
title={t("form.sections.identity.title")}
@ -105,9 +105,9 @@ export const SupplierEditorForm = ({
<TextField disabled={isSubmitting} label={t("fields.trade_name")} name="tradeName" />
<TextField disabled={isSubmitting} label={t("fields.reference")} name="reference" />
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description={t("form.sections.address.description")}
icon={<MapPinIcon className="size-5" />}
title={t("form.sections.address.title")}
@ -125,9 +125,9 @@ export const SupplierEditorForm = ({
name="country"
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description={t("form.sections.contact.description")}
icon={<PhoneIcon className="size-5" />}
title={t("form.sections.contact.title")}
@ -168,9 +168,9 @@ export const SupplierEditorForm = ({
<TextField disabled={isSubmitting} label={t("fields.fax")} name="fax" />
<TextField disabled={isSubmitting} label={t("fields.website")} name="website" />
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
<RdxEditorSection
<RdxPanel
description={t("form.sections.preferences.description")}
icon={<LanguagesIcon className="size-5" />}
title={t("form.sections.preferences.title")}
@ -191,7 +191,7 @@ export const SupplierEditorForm = ({
required
/>
</RdxFormGrid>
</RdxEditorSection>
</RdxPanel>
</form>
);
};

View File

@ -1,3 +1,3 @@
export * from "./rdx-editor-action-bar.tsx";
export * from "./rdx-editor-page-layout.tsx";
export * from "./rdx-editor-section.tsx";
export * from "./rdx-editor-sticky-footer.tsx";
export * from "./rdx-panel.tsx";

View File

@ -1,29 +1,43 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export interface RdxEditorStickyFooterProps
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
export interface RdxEditorActionBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
leading?: ReactNode;
status?: ReactNode;
secondary?: ReactNode;
primary?: ReactNode;
sticky?: boolean;
}
export const RdxEditorStickyFooter = ({
/**
* Barra inferior de acciones de una pantalla de edición.
*
* Puede mostrar:
* - contenido auxiliar a la izquierda
* - estado de edición
* - acciones secundarias
* - acción principal
*
* Puede funcionar dentro o fuera de un área scrollable.
*/
export const RdxEditorActionBar = ({
leading,
status,
secondary,
primary,
sticky = false,
className,
...props
}: RdxEditorStickyFooterProps) => {
}: RdxEditorActionBarProps) => {
const hasLeading = Boolean(leading);
const hasActionRegion = Boolean(status || secondary || primary);
return (
<div
className={cn(
"sticky bottom-0 z-20 shrink-0 border-t border-border bg-background shadow-xs",
"shrink-0 border-t border-border bg-background shadow-xs",
sticky && "sticky bottom-0 z-20",
className
)}
{...props}
@ -40,9 +54,10 @@ export const RdxEditorStickyFooter = ({
>
{status ? <div className="min-w-0 text-sm text-muted-foreground">{status}</div> : null}
{(secondary || primary) ? (
{secondary || primary ? (
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:justify-end">
{secondary ? <div className="flex min-w-0">{secondary}</div> : null}
{primary ? <div className="flex min-w-0">{primary}</div> : null}
</div>
) : null}

View File

@ -5,9 +5,9 @@ import { renderToStaticMarkup } from "react-dom/server";
import { RdxFormGrid } from "../form/rdx-form-grid.tsx";
import { RdxEditorActionBar } from "./rdx-editor-action-bar.tsx";
import { RdxEditorPageLayout } from "./rdx-editor-page-layout.tsx";
import { RdxEditorSection } from "./rdx-editor-section.tsx";
import { RdxEditorStickyFooter } from "./rdx-editor-sticky-footer.tsx";
import { RdxPanel } from "./rdx-panel.tsx";
test("RdxEditorPageLayout aplica complejidad simple por defecto y renderiza children", () => {
const html = renderToStaticMarkup(
@ -47,16 +47,16 @@ test("RdxEditorPageLayout no reserva aside cuando no existe incluso en complex",
assert.doesNotMatch(html, /grid-cols-\[/);
});
test("RdxEditorSection plain renderiza header semántico y contenido sin card", () => {
test("RdxPanel plain renderiza header semántico y contenido sin card", () => {
const html = renderToStaticMarkup(
<RdxEditorSection
<RdxPanel
actions={<button type="button">Action</button>}
description="Section description"
icon={<span>Icon</span>}
title="Section title"
>
<div>Section body</div>
</RdxEditorSection>
</RdxPanel>
);
assert.match(html, /<h2[^>]*>Section title<\/h2>/);
@ -66,11 +66,11 @@ test("RdxEditorSection plain renderiza header semántico y contenido sin card",
assert.doesNotMatch(html, /shadow/);
});
test("RdxEditorSection panel conserva superficie independiente", () => {
test("RdxPanel panel conserva superficie independiente", () => {
const html = renderToStaticMarkup(
<RdxEditorSection title="Panel section" variant="panel">
<RdxPanel title="Panel section" variant="card">
<div>Panel body</div>
</RdxEditorSection>
</RdxPanel>
);
assert.match(html, /rounded-xl/);
@ -95,7 +95,7 @@ test("RdxFormGrid mantiene la retícula base y combina className", () => {
test("RdxEditorStickyFooter renderiza regiones presentes sin wrappers vacíos", () => {
const html = renderToStaticMarkup(
<RdxEditorStickyFooter
<RdxEditorActionBar
className="custom-sticky"
leading={<div>Totals summary</div>}
primary={<button type="submit">Save</button>}
@ -116,7 +116,7 @@ test("RdxEditorStickyFooter renderiza regiones presentes sin wrappers vacíos",
test("RdxEditorStickyFooter omite regiones opcionales vacías", () => {
const html = renderToStaticMarkup(
<RdxEditorStickyFooter primary={<button type="submit">Save</button>} />
<RdxEditorActionBar primary={<button type="submit">Save</button>} />
);
assert.match(html, /Save/);

View File

@ -20,6 +20,15 @@ const ASIDE_GRID_CLASSNAME: Record<RdxEditorWidth, string> = {
full: "xl:grid-cols-[minmax(0,1fr)_22rem]",
};
/**
* Distribuye el contenido interno de un contenedor en:
*
* - una región principal
* - un aside opcional.
*
* No controla padding, scroll ni estructura general de página.
*/
export const RdxEditorPageLayout = ({
children,
aside,

View File

@ -2,24 +2,37 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { useId } from "react";
export type RdxEditorSectionVariant = "plain" | "panel";
export type RdxPanelVariant = "plain" | "card";
export interface RdxEditorSectionProps
export interface RdxPanelProps
extends Omit<ComponentPropsWithoutRef<"section">, "children" | "title"> {
title: ReactNode;
description?: ReactNode;
icon?: ReactNode;
actions?: ReactNode;
children: ReactNode;
variant?: RdxEditorSectionVariant;
variant?: RdxPanelVariant;
}
const VARIANT_CLASSNAME: Record<RdxEditorSectionVariant, string> = {
const VARIANT_CLASSNAME: Record<RdxPanelVariant, string> = {
plain: "min-w-0 border-b border-border pb-6 last:border-b-0 last:pb-0",
panel: "min-w-0 rounded-xl border border-border bg-background p-4 shadow-xs sm:p-5",
card: "min-w-0 rounded-xl border border-border bg-background p-4 shadow-xs sm:p-5",
};
export const RdxEditorSection = ({
/**
* Contenedor para agrupar contenido relacionado.
*
* Proporciona:
* - título
* - descripción
* - icono
* - acciones
* - contenido
*
* Puede representarse sin border (`plain`) o como una tarjeta (`card`).
*/
export const RdxPanel = ({
title,
description,
icon,
@ -28,7 +41,7 @@ export const RdxEditorSection = ({
variant = "plain",
className,
...props
}: RdxEditorSectionProps) => {
}: RdxPanelProps) => {
const titleId = useId();
const hasHeader = Boolean(title || description || icon || actions);

View File

@ -7,6 +7,16 @@ export interface RdxPageActionsProps extends Omit<ComponentPropsWithoutRef<"div"
overflow?: ReactNode;
}
/**
* Contenedor para mostrar las acciones de una página según su importancia.
*
* Agrupa:
* - acciones secundarias
* - acción principal
* - acciones overflow
*
*/
export const RdxPageActions = ({
primary,
secondary,
@ -14,7 +24,7 @@ export const RdxPageActions = ({
className,
...props
}: RdxPageActionsProps) => {
if (!primary && !secondary && !overflow) {
if (!(primary || secondary || overflow)) {
return null;
}

View File

@ -21,6 +21,21 @@ const PADDING_CLASSNAME: Record<RdxPageContainerPadding, string> = {
none: "",
};
/**
* Contenedor visual del contenido de una página.
*
* Controla:
* - ancho máximo
* - centrado horizontal
* - padding exterior
*
* No controla:
* - scroll
* - altura de la página
* - header/footer
* - estructura del editor
*/
export const RdxPageContainer = ({
children,
className,

View File

@ -21,6 +21,20 @@ export interface RdxPageHeaderProps
children?: ReactNode;
}
/**
* Cabecera estándar de una página.
*
* Muestra:
* - navegación de retorno opcional
* - título
* - descripción o metadata opcional
* - estado opcional
* - acciones de página
*
* Puede permanecer fija dentro de un contenedor scrollable
* mediante `sticky`.
*/
export const RdxPageHeader = ({
title,
description,

View File

@ -15,6 +15,11 @@ const SPACING_CLASSNAME: Record<RdxPageStackSpacing, string> = {
relaxed: "gap-8",
};
/**
* Contenedor para layout para apilar contenido verticalmente.
* No proporciona estructura de página ni controla scroll.
*/
export const RdxPageStack = ({
children,
className,