Preparacion del proyecto

This commit is contained in:
jorgemike
2025-04-01 09:50:01 -06:00
parent 94b752df72
commit 37bf6a6396
37 changed files with 1914 additions and 320 deletions
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { useEffect } from 'react';
export default function BootstrapClient() {
useEffect(() => {
const loadBootstrap = async () => {
if (typeof document !== 'undefined') {
// @ts-expect-error: Bootstrap is loaded dynamically
await import('bootstrap/dist/js/bootstrap.bundle.min.js');
}
};
loadBootstrap();
}, []);
return null;
}
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
/**
* Botón reutilizable con soporte para variantes de Bootstrap y personalización de íconos.
*
* @component
*/
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/**
* Variante Bootstrap o personalizada. Ej: `primary`, `danger`, `mi-clase-custom`.
* @default "primary"
*/
variant?: string;
/**
* Si se usa variante `outline` (por ejemplo, `btn-outline-primary`).
* @default false
*/
outline?: boolean;
/**
* Ícono Bootstrap (`string`, como `check-circle`) o componente React.
*/
icon?: string | React.ReactNode;
/**
* Tamaño del botón: `sm` o `lg` para tamaños Bootstrap.
*/
size?: 'sm' | 'lg';
/**
* Clases adicionales personalizadas para el botón.
*/
className?: string;
}
/**
* Componente `Button` con estilos de Bootstrap y soporte para íconos.
*/
const Button: React.FC<ButtonProps> = ({
children,
type = 'button',
icon,
className = '',
variant = 'primary',
outline = false,
disabled = false,
size,
...rest
}) => {
const btnVariant = outline ? `btn-outline-${variant}` : `btn-${variant}`;
const sizeClass = size ? `btn-${size}` : '';
const combinedClasses = `btn ${btnVariant} ${sizeClass} ${className}`.trim();
return (
<button
type={type}
disabled={disabled}
className={combinedClasses}
{...rest}
>
{typeof icon === 'string' ? (
<i className={`me-2 bi bi-${icon}`}></i>
) : (
icon && <span className="me-2">{icon}</span>
)}
{children}
</button>
);
};
export default Button;
+58
View File
@@ -0,0 +1,58 @@
import React from "react";
export interface CheckboxOption<T = unknown> {
label: string;
value: T;
disabled?: boolean;
}
interface CheckboxOptionGroupProps<T = unknown> {
name: string;
options: CheckboxOption<T>[];
selectedValues: T[];
onChange: (value: T, checked: boolean) => void;
maxSelected?: number;
disabled?: boolean;
className?: string;
}
export default function CheckboxOptionGroup<T = unknown>({
name,
options,
selectedValues,
onChange,
maxSelected,
disabled = false,
className = "",
}: CheckboxOptionGroupProps<T>) {
const isSelected = (value: T) => selectedValues.includes(value);
return (
<div className={`d-flex flex-column gap-2 mb-3 ${className}`}>
{options.map((option, index) => {
const checked = isSelected(option.value);
const limitReached =
maxSelected !== undefined &&
selectedValues.length >= maxSelected &&
!checked;
return (
<label
key={index}
className="option list-group-item-action d-flex align-items-center"
>
<input
type="checkbox"
name={name}
className="form-check-input me-2"
disabled={disabled || option.disabled || limitReached}
checked={checked}
onChange={(e) => onChange(option.value, e.target.checked)}
/>
{option.label}
</label>
);
})}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import React, { useState } from "react";
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "className"> {
label?: string;
className?: {
container?: string;
label?: string;
input?: string;
};
}
/**
* Componente de entrada (`Input`) reutilizable en React.
*
* Este componente renderiza un campo de entrada (`<input>`) con una etiqueta opcional 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 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`, `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>`.
*/
export default function Input(props: InputProps) {
const { className, label, type, name, id, ...rest } = props;
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
const inputType = isPassword && showPassword ? "text" : type;
const inputId =
id || name || (label ? `input-${label.replace(/\s+/g, "-")}` : undefined);
const togglePasswordVisibility = () => setShowPassword(!showPassword);
return (
<div className={className?.container || "mb-3"}>
{label && inputId && (
<label
htmlFor={inputId}
className={`form-label ${className?.label || ""}`}
>
{label}
</label>
)}
<div className="input-group">
<input
id={inputId}
name={name}
type={inputType}
className={`form-control bg-white ${className?.input || ""}`}
{...rest}
/>
{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"}
>
<i className={`bi ${showPassword ? "bi-eye-slash" : "bi-eye"}`}></i>
</button>
)}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
export default function Footer() {
const date = new Date();
const year = date.getFullYear();
return (
<footer className='bg-azul p-3 text-center text-white small'>
<p className='m-0'>
Hecho en México. Todos los derechos reservados {year}.
</p>
<p className='m-0'>
Esta página puede ser reproducida con fines no lucrativos,
siempre y cuando no se mutile, se cite la fuente completa y su
dirección electrónica. De otra forma, requiere permiso previo
por escrito de la institución.
</p>
</footer>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Image from 'next/image';
import React from 'react';
import Link from 'next/link';
export default function Header({ small = false }: { small?: boolean }) {
return (
<header className='bg-azul p-3'>
<div className='container flex-center justify-content-sm-between'>
<Link
href={'https://www.unam.mx/'}
target='_blank'
className='d-none d-sm-block'
>
<Image
src='/logo-unam.png'
alt='Logo de la UNAM'
width={small ? 200 : 280}
height={small ? 60 : 84}
/>
</Link>
<Link href={'https://acatlan.unam.mx/'} target='_blank'>
<Image
src='/logo-fes.png'
alt='Logo de la fes Acatlàn'
width={small ? 200 : 250}
height={small ? 48 : 60}
/>
</Link>
</div>
</header>
);
}
+74
View File
@@ -0,0 +1,74 @@
import React from "react";
import Link from "next/link";
const Navbar: React.FC = () => {
return (
<nav className="navbar navbar-expand-lg navbar-light bg-transparent fixed-top">
<div className="container">
<button
className="navbar-toggler border-0 ms-auto"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvasNavbar"
aria-controls="offcanvasNavbar"
>
<span className="navbar-toggler-icon"></span>
</button>
<div
className="offcanvas offcanvas-end"
tabIndex={-1}
id="offcanvasNavbar"
aria-labelledby="offcanvasNavbarLabel"
>
<div className="offcanvas-header">
<h5 className="offcanvas-title" id="offcanvasNavbarLabel">
Menu
</h5>
<button
type="button"
className="btn-close"
data-bs-dismiss="offcanvas"
aria-label="Close"
></button>
</div>
<div className="offcanvas-body justify-content-center">
<ul className="navbar-nav gap-lg-5">
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/">
Inicio
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/table">
Table
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/input">
Input
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/pagination">
Pagination
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/options">
Options
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/styles">
Styles
</Link>
</li>
</ul>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;
+105
View File
@@ -0,0 +1,105 @@
import React, { useMemo } from "react";
interface PaginationProps {
totalPages: number;
currentPage: number;
onPageChange: (page: number) => void;
delta?: number;
}
export default function Pagination({
currentPage,
onPageChange,
totalPages,
delta = 1,
}: PaginationProps) {
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages && page !== currentPage) {
onPageChange(page);
}
};
const visiblePages = useMemo(() => {
const pages: (number | -1)[] = [];
if (totalPages <= 5 + delta * 2) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const left = Math.max(2, currentPage - delta);
const right = Math.min(totalPages - 1, currentPage + delta);
pages.push(1);
if (left > 2) pages.push(-1);
for (let i = left; i <= right; i++) {
pages.push(i);
}
if (right < totalPages - 1) pages.push(-1);
pages.push(totalPages);
return pages;
}, [currentPage, totalPages, delta]);
return (
<nav aria-label="Page navigation">
<ul className="pagination gap-1">
{/* Botón Anterior */}
<li className={`page-item ${currentPage === 1 ? "disabled" : ""}`}>
<button
className="page-link shadow-sm rounded border-0 bg-white"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous"
>
&laquo;
</button>
</li>
{/* Números de Página */}
{visiblePages.map((page, index) =>
page === -1 ? (
<li key={`ellipsis-${index}`} className="page-item disabled">
<span className="page-link shadow-sm rounded border-0 bg-white">
...
</span>
</li>
) : (
<li
key={page}
className={`page-item ${currentPage === page ? "active" : ""}`}
>
<button
className={`page-link shadow-sm rounded border-0 ${
currentPage === page
? ""
: "bg-white text-primary"
}`}
onClick={() => handlePageChange(page)}
>
{page}
</button>
</li>
)
)}
{/* Botón Siguiente */}
<li
className={`page-item ${
currentPage === totalPages ? "disabled" : ""
}`}
>
<button
className="page-link shadow-sm rounded border-0 bg-white"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next"
>
&raquo;
</button>
</li>
</ul>
</nav>
);
}
+46
View File
@@ -0,0 +1,46 @@
import React from "react";
export interface RadioOption<T = unknown> {
label: string;
value: T;
disabled?: boolean;
}
interface RadioOptionGroupProps<T = unknown> {
name: string;
options: RadioOption<T>[];
selectedValue?: T;
onChange: (value: T) => void;
disabled?: boolean;
className?: string;
}
export default function RadioOptionGroup<T>({
name,
options,
selectedValue,
onChange,
disabled = false,
className = "",
}: RadioOptionGroupProps<T>) {
return (
<div className={`d-flex flex-column gap-2 mb-3 ${className}`}>
{options.map((option, index) => (
<label
key={index}
className="option list-group-item-action d-flex align-items-center"
>
<input
type="radio"
name={name}
className="form-check-input me-2"
disabled={disabled || option.disabled}
checked={option.value === selectedValue}
onChange={() => onChange(option.value)}
/>
{option.label}
</label>
))}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import React, { useId } from 'react';
interface Option {
value: string | number;
label: string;
}
interface SelectProps
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'className'> {
label: string;
className?: {
container?: string;
label?: string;
input?: string;
};
options: Option[];
placeholder?: string;
}
/**
* Componente `Select` reutilizable en React.
*
* Este componente renderiza un elemento `<select>` con una etiqueta visible,
* opciones personalizadas y estilos configurables por clase. Es compatible
* con el uso controlado (`value`) o no controlado (`defaultValue`).
*
* Además, genera automáticamente un `id` si no se proporciona, manteniendo
* buenas prácticas de accesibilidad.
*
* @component
* @example
* // Uso básico
* <Select
* label="País"
* name="pais"
* options={[
* { value: 'mx', label: 'México' },
* { value: 'us', label: 'Estados Unidos' },
* { value: 'ca', label: 'Canadá' }
* ]}
* placeholder="Selecciona un país"
* defaultValue=""
* />
*
* @param {Object} props - Propiedades del componente.
* @param {string} props.label - Etiqueta visible asociada al campo.
* @param {Array<{ value: string | number, label: string }>} props.options - Lista de opciones disponibles en el select.
* @param {string} [props.placeholder] - Opción deshabilitada inicial que actúa como guía o título (si se proporciona).
* @param {string} [props.name] - Nombre del campo (útil en formularios).
* @param {string} [props.id] - ID del campo; si no se proporciona, se genera automáticamente.
* @param {string | number} [props.defaultValue] - Valor por defecto del select (modo no controlado).
* @param {string | number} [props.value] - Valor actual del select (modo controlado).
* @param {Object} [props.className] - Objeto para aplicar clases CSS personalizadas.
* @param {string} [props.className.container] - Clases del contenedor principal.
* @param {string} [props.className.label] - Clases de la etiqueta.
* @param {string} [props.className.input] - Clases del `<select>`.
* @param {React.SelectHTMLAttributes<HTMLSelectElement>} [rest] - Cualquier otra prop válida para `<select>`.
*
* @returns {JSX.Element} Un elemento `<select>` con sus opciones y etiqueta asociada.
*/
const Select: React.FC<SelectProps> = ({
label,
className,
options,
placeholder,
id,
name,
defaultValue,
value,
...rest
}) => {
const generatedId = useId();
const selectId = id || name || generatedId;
return (
<div className={`mb-3 ${className?.container ?? ''}`}>
<label htmlFor={selectId} className={`form-label ${className?.label ?? ''}`}>
{label}
</label>
<select
id={selectId}
name={name}
className={`form-select bg-white ${className?.input ?? ''}`}
defaultValue={defaultValue ?? (value === undefined ? '' : undefined)}
value={value}
{...rest}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value.toString()} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};
export default Select;
+174
View File
@@ -0,0 +1,174 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React, { useEffect, useState } from "react";
export interface SidebarLink {
icon?: string;
text?: string;
href?: string;
show?: boolean;
className?: string;
onClick?: () => void;
render?: () => React.ReactNode;
}
interface SidebarProps {
id: string;
userName?: string;
userRole?: string;
logoSrc?: string;
appTitle?: string;
links: SidebarLink[];
}
export default function Sidebar({
userName = "Usuario",
userRole = "Administrador",
logoSrc = "/unam.png",
appTitle = "Sistema de -------",
links,
id,
}: SidebarProps) {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const open = localStorage.getItem(id) === "true";
setIsOpen(open);
}, [id]);
const toggleSidebar = () => {
localStorage.setItem(id, `${!isOpen}`);
setIsOpen(!isOpen);
};
return (
<aside className={`sidebar ${isOpen ? "open" : "close"}`}>
{/* Header for mobile */}
<div className="d-flex justify-content-between align-items-center p-2 d-lg-none">
<h1 className="h3 m-0">{appTitle}</h1>
<button
type="button"
className="m-0 p-0 border-0 bg-transparent"
data-bs-toggle="offcanvas"
data-bs-target={`#${id}-offcanvas`}
aria-controls={`${id}-offcanvas`}
>
<i className="bi bi-list fs-2"></i>
</button>
</div>
<div
className="offcanvas-lg offcanvas-end"
tabIndex={-1}
id={`${id}-offcanvas`}
aria-labelledby={`${id}-offcanvas-label`}
>
<div className="offcanvas-header">
<h5 className="offcanvas-title" id={`${id}-offcanvas-label`}>
{appTitle}
</h5>
<button
type="button"
className="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target={`#${id}-offcanvas`}
aria-label="Close"
/>
</div>
<div className="offcanvas-body position-relative">
{/* Toggle sidebar button */}
<div
className="d-none d-lg-flex position-absolute"
style={{ top: "0", right: "-2rem" }}
>
<button
className="bg-white border-0 shadow-sm px-2 py-1"
type="button"
onClick={toggleSidebar}
>
<i
className={`bi bi-layout-sidebar-${
isOpen ? "inset" : "inset-reverse"
} text-muted`}
></i>
</button>
</div>
<div>
{/* User Info */}
<div className="d-flex align-items-center gap-3 my-2">
<Image src={logoSrc} width={40} height={40} alt="Logo" />
<div className="nav-link">
<span>
<b>{userName}</b>
<br />
<p className="text-muted m-0">{userRole}</p>
</span>
</div>
</div>
<hr />
<ul className="nav flex-column gap-2">
{links
.filter((link) => link.show !== false)
.map((link, index) => {
if (link.render) {
return (
<React.Fragment key={index}>
{link.render()}
</React.Fragment>
);
}
const isActive =
!!link.href &&
(pathname === link.href ||
(pathname?.startsWith(link.href) && link.href !== "/u"));
const baseClass = `nav-item ${isActive ? "active" : ""} ${
link.className || ""
}`;
return (
<li
key={index}
className={baseClass}
data-tooltip={link.text}
data-bs-dismiss="offcanvas"
data-bs-target="#offcanvasResponsive"
aria-label="Close"
>
{link.href ? (
<Link
href={link.href}
className="nav-link"
onClick={link.onClick}
>
{link.icon && <i className={`bi ${link.icon}`}></i>}
<span>{link.text}</span>
</Link>
) : (
<button
className="nav-link w-100 text-start"
onClick={link.onClick}
>
{link.icon && <i className={`bi ${link.icon}`}></i>}
<span>{link.text}</span>
</button>
)}
</li>
);
})}
</ul>
</div>
</div>
</div>
</aside>
);
}
+196
View File
@@ -0,0 +1,196 @@
import React, { useState, useMemo } from "react";
import Pagination from "./pagination";
export interface Header<T> {
key: keyof T;
label: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
width?: string;
className?: string;
}
interface TableProps<T> {
headers: Header<T>[];
data: T[];
rowsPerPage?: number;
onRowClick?: (rowData: T) => void;
rowKey?: (row: T) => string | number;
className?: {
table?: string;
row?: string;
cell?: string;
header?: string;
};
controlledPage?: number;
onPageChange?: (page: number) => void;
}
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 [internalPage, setInternalPage] = useState(1);
const currentPage = controlledPage ?? internalPage;
const setPage = onPageChange ?? setInternalPage;
const handleSort = (key: keyof T) => {
let direction: "asc" | "desc" = "asc";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc";
}
setSortConfig({ key, direction });
};
const sortedData = useMemo(() => {
if (!sortConfig) return data;
return [...data].sort((a, b) => {
if (a[sortConfig.key]! < b[sortConfig.key]!)
return sortConfig.direction === "asc" ? -1 : 1;
if (a[sortConfig.key]! > b[sortConfig.key]!)
return sortConfig.direction === "asc" ? 1 : -1;
return 0;
});
}, [data, sortConfig]);
const totalPages = Math.ceil(sortedData.length / rowsPerPage);
const paginatedData = sortedData.slice(
(currentPage - 1) * rowsPerPage,
currentPage * rowsPerPage
);
return (
<div className="table-container">
<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)}
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 && (
<i
className={`ms-1 bi bi-caret-${
sortConfig.direction === "asc" ? "up" : "down"
}`}
></i>
)}
</th>
))}
</tr>
</thead>
<tbody>
{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>
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
onPageChange={setPage}
totalPages={totalPages}
/>
)}
</div>
);
}
interface TablaSkeletonProps {
rows?: number;
columns?: number;
}
export function TableSkeleton({ rows = 5, columns = 5 }: TablaSkeletonProps) {
return (
<table className="e-table placeholder-glow">
<thead>
<tr>
{Array.from({ length: columns }).map((_, index) => (
<th key={index}>
<span className="placeholder rounded col-12"></span>
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex}>
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex}>
<span className="placeholder rounded col-12"></span>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
+74
View File
@@ -0,0 +1,74 @@
import React, { useState, JSX } from "react";
interface Tab {
id: string;
title: string | JSX.Element;
content: JSX.Element;
disabled?: boolean;
}
interface TabsProps {
tabs: Tab[];
style?: "default" | "box";
className?: {
container?: string;
tab?: string;
content?: string;
};
}
export default function Tabs({
tabs,
style = "default",
className,
}: TabsProps) {
const [activeTab, setActiveTab] = useState(tabs[0]?.id || "");
return (
<>
<ul className={`nav nav-tabs style-${style}`} id="myTab" role="tablist">
{tabs.map((tab) => (
<li
key={tab.id}
className="nav-item"
role="presentation"
title={tab.title.toString()}
>
<button
className={`nav-link ${tab.disabled ? "disabled" : ""} ${
activeTab === tab.id ? "active" : ""
}`}
id={`tab-${tab.id}`}
data-bs-toggle="tab"
data-bs-target={`#tab-pane-${tab.id}`}
type="button"
role="tab"
aria-controls={`tab-pane-${tab.id}`}
aria-selected={activeTab === tab.id}
onClick={() => !tab.disabled && setActiveTab(tab.id)}
>
{tab.title.toString().length > 35
? `${tab.title.toString().slice(0, 35)}...`
: tab.title}
</button>
</li>
))}
</ul>
<div className="tab-content mt-3" id="myTabContent">
{tabs.map((tab) => (
<div
key={tab.id}
className={`tab-pane fade ${
activeTab === tab.id ? "show active" : ""
} ${className?.content}`}
id={`tab-pane-${tab.id}`}
role="tabpanel"
aria-labelledby={`tab-${tab.id}`}
>
{activeTab === tab.id && tab.content}
</div>
))}
</div>
</>
);
}