This commit is contained in:
jorgemike
2025-03-22 11:59:45 -05:00
parent 8f302831bc
commit e5f007044a
4 changed files with 299 additions and 171 deletions
+105 -42
View File
@@ -6,54 +6,117 @@ import Input from "@/components/input";
export default function Page() {
return (
<div className="container my-5">
<h1 className="mb-4">
Componente: <code>Input</code>
<h1 className="mb-4 display-5">
📦 Componente: <code>Input</code>
</h1>
<p className="mb-4">
Este componente encapsula un <code>{`<input>`}</code> con su respectiva
etiqueta y funcionalidad adicional para mostrar u ocultar contraseñas si
el tipo es <code>password</code>. Puedes personalizar las clases de cada
parte del input.
<p className="mb-4 text-muted">
El componente <code>{`<Input />`}</code> encapsula un campo de entrada
HTML estándar con mejoras en accesibilidad, estilo y experiencia de
usuario. Soporta etiquetas, clases personalizadas, inputs
deshabilitados, y funcionalidad para mostrar u ocultar contraseñas.
</p>
<h2 className="h5 mt-4">🔹 Uso básico</h2>
<Input label="Nombre" name="nombre" placeholder="Escribe tu nombre" />
<hr className="my-5" />
<h2 className="h5 mt-4">🔹 Campo de tipo contraseña</h2>
<p className="mb-2">
Incluye un botón para mostrar u ocultar el contenido del campo.
</p>
<Input
label="Contraseña"
name="password"
type="password"
placeholder="••••••••"
/>
<h2 className="h5 mt-4">🔹 Input deshabilitado</h2>
<Input
label="Correo electrónico"
name="email"
placeholder="email@ejemplo.com"
disabled
/>
<h2 className="h5 mt-4">🔹 Personalización de clases</h2>
<Input
label="Usuario"
name="username"
placeholder="ej: miguel_dev"
className={{
container: "mb-3 bg-light p-3 rounded",
label: "fw-bold text-primary",
input: "border border-primary",
}}
/>
<div style={{width: '300px'}} className="mt-5 ms-5">
<section>
<h2 className="h5">🔹 Uso básico</h2>
<p>Un campo de texto simple con etiqueta y placeholder.</p>
<Input label="Nombre" name="nombre" placeholder="Escribe tu nombre" />
</div>
<pre className="mt-2 bg-light p-3 rounded">
<code>{`<Input label="Nombre" name="nombre" placeholder="Escribe tu nombre" />`}</code>
</pre>
</section>
<hr className="my-5" />
<section>
<h2 className="h5">🔐 Campo de tipo contraseña</h2>
<p className="mb-2">
Incluye un botón para mostrar u ocultar el contenido del campo.
</p>
<Input
label="Contraseña"
name="password"
type="password"
placeholder="••••••••"
/>
<pre className="mt-2 bg-light p-3 rounded">
<code>{`<Input
label="Contraseña"
name="password"
type="password"
placeholder="••••••••"
/>`}</code>
</pre>
</section>
<hr className="my-5" />
<section>
<h2 className="h5">🚫 Input deshabilitado</h2>
<Input
label="Correo electrónico"
name="email"
placeholder="email@ejemplo.com"
disabled
/>
<pre className="mt-2 bg-light p-3 rounded">
<code>{`<Input
label="Correo electrónico"
name="email"
placeholder="email@ejemplo.com"
disabled
/>`}</code>
</pre>
</section>
<hr className="my-5" />
<section>
<h2 className="h5">🎨 Personalización de clases</h2>
<p>
Puedes personalizar el estilo de cada parte del input: contenedor,
etiqueta e input.
</p>
<Input
label="Usuario"
name="username"
placeholder="ej: miguel_dev"
className={{
container: "mb-3 bg-light p-3 rounded",
label: "fw-bold text-primary",
input: "border border-primary",
}}
/>
<pre className="mt-2 bg-light p-3 rounded">
<code>{`<Input
label="Usuario"
name="username"
placeholder="ej: miguel_dev"
className={{
container: "mb-3 bg-light p-3 rounded",
label: "fw-bold text-primary",
input: "border border-primary",
}}
/>`}</code>
</pre>
</section>
<hr className="my-5" />
<section>
<h2 className="h5">📏 Input en contenedor reducido</h2>
<p>Se adapta a contenedores pequeños sin problemas.</p>
<div style={{ width: "300px" }} className="mt-3 ms-1 border p-3">
<Input label="Nombre" name="nombre" placeholder="Escribe tu nombre" />
</div>
<pre className="mt-2 bg-light p-3 rounded">
<code>{`<div style={{ width: "300px" }}>
<Input label="Nombre" name="nombre" placeholder="Escribe tu nombre" />
</div>`}</code>
</pre>
</section>
</div>
);
}
+61 -30
View File
@@ -1,19 +1,15 @@
"use client";
import Table, { TablaSkeleton } from "@/components/table";
import Table, { Header, TablaSkeleton } from "@/components/table";
import React from "react";
/**
* Página de demostración del componente Table.
* Incluye explicación de uso y ejemplos con distintas configuraciones.
*/
export default function Page() {
const headers: { key: "name" | "age" | "email"; label: string }[] = [
{ key: "name", label: "Nombre" },
{ key: "age", label: "Edad" },
{ key: "email", label: "Correo" },
const headers: Header<{ name: string; age: number; email: string; acciones?: null }>[] = [
{ key: "name", label: "Nombre", width: "30%" },
{ key: "age", label: "Edad", width: "15%" },
{ key: "email", label: "Correo", width: "40%" },
];
const data = [
{ name: "Juan", age: 20, email: "juan@example.com" },
{ name: "María", age: 25, email: "maria@example.com" },
@@ -23,21 +19,27 @@ export default function Page() {
return (
<div className="container my-5">
<h1 className="mb-4">Componente: Table</h1>
<h1 className="mb-4 display-5">
📊 Componente: <code>Table</code>
</h1>
<p className="mb-4">
El componente <code>Table</code> permite renderizar tablas dinámicas con
paginación y ordenamiento por columna. Acepta un arreglo de objetos como
datos, y una lista de headers que indican qué propiedades mostrar.
<p className="mb-4 text-muted">
El componente <code>&lt;Table /&gt;</code> permite mostrar datos
tabulares con soporte para ordenamiento, paginación, renderizado
personalizado, columnas clicables y estado de carga.
</p>
<h2 className="h5 mt-4">🔹 Uso básico</h2>
<hr className="my-5" />
<h2 className="h5">🔹 Uso básico</h2>
<Table headers={headers} data={data} />
<h2 className="h5 mt-5">🔹 Con filas clicables</h2>
<hr className="my-5" />
<h2 className="h5">🖱 Filas clicables</h2>
<p className="mb-2">
Puedes pasar una función <code>onRowClick</code> para manejar clics en
filas.
Usa la propiedad <code>onRowClick</code> para manejar interacciones por
fila.
</p>
<Table
headers={headers}
@@ -45,22 +47,51 @@ export default function Page() {
onRowClick={(row) => alert(`Seleccionaste a ${row.name}`)}
/>
<h2 className="h5 mt-5">🔹 Con paginación personalizada</h2>
<hr className="my-5" />
<h2 className="h5">📄 Paginación personalizada</h2>
<p className="mb-2">
Puedes controlar cuántas filas se muestran por página usando{" "}
<code>rowsPerPage</code>.
Usa <code>rowsPerPage</code> para definir cuántas filas mostrar por
página.
</p>
<Table headers={headers} data={data} rowsPerPage={2} />
<h2 className="h5 mt-5">🔹 Tabla en estado de carga</h2>
<p className="mb-2">
El componente <code>TablaSkeleton</code> muestra una tabla con
placeholders mientras se cargan los datos reales. Puedes personalizar el
número de <strong>filas</strong> y<strong>columnas</strong> usando las
props <code>rows</code> y <code>columns</code>.
</p>
<hr className="my-5" />
<TablaSkeleton rows={3} columns={5} />
<h2 className="h5">🎨 Render personalizado por celda</h2>
<p>
Puedes usar la propiedad <code>render</code> dentro de cada{" "}
<code>header</code> para personalizar cómo se muestra una columna.
</p>
<Table
headers={[
...headers,
{
key: "acciones",
label: "Acciones",
width: "15%",
render: (_, row) => (
<button
className="btn btn-sm btn-primary"
onClick={() => alert(row.email)}
>
Ver correo
</button>
),
},
]}
data={data.map((d) => ({ ...d, acciones: null as null }))}
/>
<hr className="my-5" />
<h2 className="h5"> Tabla en estado de carga</h2>
<p className="mb-2">
El componente <code>TablaSkeleton</code> muestra una tabla de ejemplo
mientras se cargan los datos. Puedes personalizar <code>rows</code> y{" "}
<code>columns</code>.
</p>
<TablaSkeleton rows={3} columns={4} />
</div>
);
}
+50 -24
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import React, { useState } from "react";
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "className"> {
label: string;
className?: {
container?: string;
@@ -9,62 +9,88 @@ export interface InputProps
input?: string;
};
}
/**
* Componente de entrada (`Input`) reutilizable en React.
*
* Este componente renderiza un campo de entrada (`<input>`) con una etiqueta opcional y
* Este componente renderiza un campo de entrada (`<input>`) con una etiqueta y
* funcionalidad para mostrar u ocultar contraseñas cuando el tipo es `password`.
* También permite personalizar clases CSS y es accesible mediante `aria-label` y `htmlFor`.
*
* @component
* @param {Object} props - Propiedades del componente.
* @param {string} props.label - Etiqueta del campo de entrada.
* @param {Object} [props.className] - Clases CSS personalizables.
* @param {string} props.label - Etiqueta visible asociada al campo de entrada.
* @param {Object} [props.className] - Clases CSS personalizables para distintos elementos.
* @param {string} [props.className.container] - Clases para el contenedor del input.
* @param {string} [props.className.label] - Clases para la etiqueta del input.
* @param {string} [props.className.input] - Clases para el input en sí.
* @param {string} [props.type] - Tipo del input (`text`, `password`, etc.).
* @param {string} [props.name] - Nombre del input (para formularios).
* @param {React.InputHTMLAttributes<HTMLInputElement>} [rest] - Atributos adicionales para el input.
* @param {string} [props.type] - Tipo del input (`text`, `password`, `email`, etc.).
* @param {string} [props.name] - Nombre del input (útil para formularios).
* @param {string} [props.id] - ID personalizado para el input (para accesibilidad).
* @param {string} [props.value] - Valor del input (para uso controlado).
* @param {function} [props.onChange] - Manejador del cambio de valor del input.
* @param {React.InputHTMLAttributes<HTMLInputElement>} [rest] - Atributos adicionales compatibles con `<input>`.
*
* @example
* // Ejemplo de uso
* // Ejemplo de uso con contraseña:
* <Input
* label="Contraseña"
* name="password"
* type="password"
* className={{ container: "mb-4", label: "text-sm", input: "border" }}
* className={{
* container: "mb-4",
* label: "text-sm font-bold",
* input: "border"
* }}
* />
*
* @example
* // Ejemplo de input controlado:
* <Input
* label="Correo electrónico"
* name="email"
* type="email"
* value={email}
* onChange={(e) => setEmail(e.target.value)}
* />
*/
export default function Input(props: InputProps) {
const { className, label, type, ...rest } = props;
const { className, label, type, name, id, ...rest } = props;
const [showPassword, setShowPassword] = useState(false);
const togglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
const isPassword = type === "password";
const inputType = isPassword && showPassword ? "text" : type;
const inputId = id || name || `input-${label.replace(/\s+/g, "-")}`;
const togglePasswordVisibility = () => setShowPassword(!showPassword);
return (
<div className={`${className?.container}`}>
<label htmlFor={props.name} className={`form-label ${className?.label}`}>
<div className={className?.container || ""}>
<label
htmlFor={inputId}
className={`form-label ${className?.label || ""}`}
>
{label}
</label>
<div className="input-group">
<input
type={showPassword ? 'text' : type}
className={`form-control bg-white ${className?.input}`}
id={inputId}
name={name}
type={inputType}
className={`form-control bg-white ${className?.input || ""}`}
{...rest}
/>
{props.type === 'password' && (
{isPassword && (
<button
type="button"
className="btn btn-light border"
onClick={togglePasswordVisibility}
aria-label={
showPassword ? "Ocultar contraseña" : "Mostrar contraseña"
}
title={showPassword ? "Ocultar contraseña" : "Mostrar contraseña"}
>
{showPassword ? (
<i className="bi bi-eye-slash"></i>
) : (
<i className="bi bi-eye"></i>
)}
<i className={`bi ${showPassword ? "bi-eye-slash" : "bi-eye"}`}></i>
</button>
)}
</div>
+83 -75
View File
@@ -1,59 +1,49 @@
import React, { useState, useMemo } from "react";
import Pagination from "./pagination";
/**
* Representa la definición de una columna para la tabla.
*
* @template T - Tipo genérico de los datos.
*/
export interface Header<T> {
/** Clave del objeto a mostrar en esta columna. */
key: keyof T;
/** Etiqueta que se mostrará como encabezado. */
label: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
width?: string;
className?: string;
}
/**
* Propiedades del componente Table.
*
* @template T - Tipo genérico de los datos.
*/
interface TableProps<T> {
/** Arreglo de encabezados de columna. */
headers: Header<T>[];
/** Datos que se mostrarán en la tabla. */
data: T[];
/** Número de filas por página (por defecto: 10). */
rowsPerPage?: number;
/** Callback que se ejecuta al hacer clic en una fila. */
onRowClick?: (rowData: T) => void;
rowKey?: (row: T) => string | number;
className?: {
table?: string;
row?: string;
cell?: string;
header?: string;
};
controlledPage?: number;
onPageChange?: (page: number) => void;
}
/**
* Componente genérico de tabla con paginación y ordenamiento.
*
* @template T - Tipo de los objetos a renderizar en las filas.
*
* @param {TableProps<T>} props - Propiedades del componente.
* @returns {JSX.Element} Tabla renderizada.
*/
export default function Table<T>({
headers,
data,
rowsPerPage = 10,
onRowClick,
rowKey,
className,
controlledPage,
onPageChange,
}: TableProps<T>) {
const [sortConfig, setSortConfig] = useState<{
key: keyof T;
direction: "asc" | "desc";
} | null>(null);
const [currentPage, setCurrentPage] = useState(1);
/**
* Maneja el ordenamiento al hacer clic en un encabezado.
*
* @param {keyof T} key - Clave del dato a ordenar.
*/
const [internalPage, setInternalPage] = useState(1);
const currentPage = controlledPage ?? internalPage;
const setPage = onPageChange ?? setInternalPage;
const handleSort = (key: keyof T) => {
let direction: "asc" | "desc" = "asc";
if (
@@ -66,24 +56,17 @@ export default function Table<T>({
setSortConfig({ key, direction });
};
/**
* Datos ordenados de acuerdo con `sortConfig`.
*/
const sortedData = useMemo(() => {
if (!sortConfig) return data;
return [...data].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
if (a[sortConfig.key]! < b[sortConfig.key]!)
return sortConfig.direction === "asc" ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
if (a[sortConfig.key]! > b[sortConfig.key]!)
return sortConfig.direction === "asc" ? 1 : -1;
}
return 0;
});
}, [data, sortConfig]);
// Paginación
const totalPages = Math.ceil(sortedData.length / rowsPerPage);
const paginatedData = sortedData.slice(
(currentPage - 1) * rowsPerPage,
@@ -92,52 +75,88 @@ export default function Table<T>({
return (
<div className="table-container">
<table className="e-table">
<thead style={{ userSelect: "none", cursor: "pointer" }}>
<table className={`e-table ${className?.table || ""}`}>
<colgroup>
{headers.map((header, index) => (
<col key={index} style={{ width: header.width }} />
))}
</colgroup>
<thead>
<tr>
{headers.map((header, index) => (
<th
key={index}
onClick={() => handleSort(header.key)}
className={
role="button"
tabIndex={0}
aria-sort={
sortConfig?.key === header.key
? sortConfig.direction === "asc"
? "ascending"
: "descending"
: "none"
}
style={{ width: header.width }}
className={`sortable ${className?.header || ""} ${
header.className || ""
} ${
sortConfig?.key === header.key
? `sorted-${sortConfig.direction}`
: ""
}
}`}
>
{header.label}
{sortConfig?.key === header.key &&
(sortConfig.direction === "asc" ? (
<i className="bi bi-caret-up"></i>
) : (
<i className="bi bi-caret-down"></i>
))}
{sortConfig?.key === header.key && (
<i
className={`ms-1 bi bi-caret-${
sortConfig.direction === "asc" ? "up" : "down"
}`}
></i>
)}
</th>
))}
</tr>
</thead>
<tbody>
{paginatedData.map((row, rowIndex) => (
<tr
key={rowIndex}
onClick={() => onRowClick && onRowClick(row)}
className={onRowClick ? "clickable-row" : ""}
>
{headers.map((header, cellIndex) => (
<td key={cellIndex} data-titulo={header.label}>
{row[header.key] as string}
</td>
))}
{paginatedData.length === 0 ? (
<tr>
<td colSpan={headers.length} className="text-center text-muted">
No hay datos disponibles
</td>
</tr>
))}
) : (
paginatedData.map((row, rowIndex) => (
<tr
key={rowKey ? rowKey(row) : rowIndex}
onClick={() => onRowClick?.(row)}
className={`${onRowClick ? "clickable-row" : ""} ${
className?.row || ""
}`}
>
{headers.map((header, cellIndex) => (
<td
key={cellIndex}
className={`${className?.cell || ""} ${
header.className || ""
}`}
style={{ width: header.width }}
data-titulo={header.label}
>
{header.render
? header.render(row[header.key], row)
: (row[header.key] as React.ReactNode)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
{/* Paginación */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
onPageChange={setCurrentPage}
onPageChange={setPage}
totalPages={totalPages}
/>
)}
@@ -146,20 +165,11 @@ export default function Table<T>({
}
interface TablaSkeletonProps {
/** Número de renglones que se desean renderizar */
rows?: number;
/** Número de columnas por fila (default: 5) */
columns?: number;
}
/**
* Componente de tabla en estado de carga con placeholders.
*
* @param rows - Número de filas a mostrar (default: 5)
* @param columns - Número de columnas por fila (default: 5)
* @returns JSX.Element
*/
function TablaSkeleton({ rows = 5, columns = 5 }: TablaSkeletonProps) {
export function TablaSkeleton({ rows = 5, columns = 5 }: TablaSkeletonProps) {
return (
<table className="e-table placeholder-glow">
<thead>
@@ -185,5 +195,3 @@ function TablaSkeleton({ rows = 5, columns = 5 }: TablaSkeletonProps) {
</table>
);
}
export { TablaSkeleton };