refactor(web): metadata, accesibilidad en multiselect y migracion a motion (#13)

This commit is contained in:
Miguel Ortega committed 2026-10-05 19:05:32 -05:00
1 parent 2d6c29a1f3
commit 23abee72d4
12 files changed
+268 -189

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
"@heroui/react": "^3.2.6", "@heroui/react": "^3.2.6",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.104.0", "@tanstack/react-query": "^5.104.0",
"framer-motion": "^13.4.0", "motion": "^12.4.0",
"next": "16.3.5", "next": "16.3.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
@@ -10,6 +10,7 @@ interface ConfirmDialogProps {
title: string; title: string;
description: string; description: string;
confirmLabel?: string; confirmLabel?: string;
pendingLabel?: string;
} }
export function ConfirmDialog({ export function ConfirmDialog({
@@ -19,10 +20,14 @@ export function ConfirmDialog({
title, title,
description, description,
confirmLabel = "Eliminar", confirmLabel = "Eliminar",
pendingLabel,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const activePendingLabel =
pendingLabel ?? (confirmLabel === "Eliminar" ? "Eliminando…" : "Procesando…");
async function handleConfirm() { async function handleConfirm() {
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
@@ -61,7 +66,7 @@ export function ConfirmDialog({
disabled={submitting} disabled={submitting}
className="rounded-lg bg-danger px-3.5 py-2 text-sm font-medium text-danger-foreground transition hover:bg-danger-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50" className="rounded-lg bg-danger px-3.5 py-2 text-sm font-medium text-danger-foreground transition hover:bg-danger-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
> >
{submitting ? "Eliminando…" : confirmLabel} {submitting ? activePendingLabel : confirmLabel}
</button> </button>
</div> </div>
</Dialog> </Dialog>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import { iconCloseSquare } from "../icons"; import { iconCloseSquare } from "../icons";
interface Option { interface Option {
@@ -26,21 +26,38 @@ export function MultiSelect({
}: MultiSelectProps) { }: MultiSelectProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const listboxId = useId();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) {
setActiveIndex(-1);
return;
}
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setActiveIndex(-1);
}
} }
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false); function onGlobalKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
setActiveIndex(-1);
}
} }
document.addEventListener("pointerdown", onPointerDown); document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onGlobalKeyDown);
return () => { return () => {
document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown); document.removeEventListener("keydown", onGlobalKeyDown);
}; };
}, [open]); }, [open]);
@@ -54,6 +71,17 @@ export function MultiSelect({
return options.filter((o) => !q || o.label.toLowerCase().includes(q)).slice(0, 50); return options.filter((o) => !q || o.label.toLowerCase().includes(q)).slice(0, 50);
}, [options, query]); }, [options, query]);
useEffect(() => {
setActiveIndex(-1);
}, [query]);
useEffect(() => {
if (activeIndex >= 0 && listboxRef.current) {
const activeElement = listboxRef.current.children[activeIndex] as HTMLElement | undefined;
activeElement?.scrollIntoView({ block: "nearest" });
}
}, [activeIndex]);
function toggle(id: number) { function toggle(id: number) {
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]); onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
} }
@@ -62,10 +90,49 @@ export function MultiSelect({
onChange(selected.filter((s) => s !== id)); onChange(selected.filter((s) => s !== id));
} }
function handleInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
setActiveIndex(results.length > 0 ? 0 : -1);
} else if (results.length > 0) {
setActiveIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
setActiveIndex(results.length > 0 ? results.length - 1 : -1);
} else if (results.length > 0) {
setActiveIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
}
} else if (e.key === "Enter") {
if (open && activeIndex >= 0 && activeIndex < results.length) {
e.preventDefault();
toggle(results[activeIndex].id);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
setActiveIndex(-1);
}
} else if (e.key === "Backspace" && query === "" && selected.length > 0) {
remove(selected[selected.length - 1]);
} else if (e.key === "Tab") {
setOpen(false);
setActiveIndex(-1);
}
}
return ( return (
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<div <div
onClick={() => setOpen(true)} onClick={() => {
inputRef.current?.focus();
setOpen(true);
}}
className={`flex max-h-24 flex-wrap items-center gap-1.5 overflow-y-auto rounded-field border border-border bg-background p-1.5 transition focus-within:border-focus ${open ? "border-focus" : ""}`} className={`flex max-h-24 flex-wrap items-center gap-1.5 overflow-y-auto rounded-field border border-border bg-background p-1.5 transition focus-within:border-focus ${open ? "border-focus" : ""}`}
> >
{selectedOptions.map((o) => ( {selectedOptions.map((o) => (
@@ -89,14 +156,22 @@ export function MultiSelect({
))} ))}
<input <input
ref={inputRef}
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onKeyDown={handleInputKeyDown}
placeholder={selectedOptions.length === 0 ? placeholder : ""} placeholder={selectedOptions.length === 0 ? placeholder : ""}
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
aria-controls="multi-select-listbox" aria-controls={open ? listboxId : undefined}
aria-autocomplete="list"
aria-activedescendant={
open && activeIndex >= 0 && results[activeIndex]
? `${listboxId}-option-${results[activeIndex].id}`
: undefined
}
autoComplete="off" autoComplete="off"
className="min-w-24 flex-1 bg-transparent px-1.5 py-1 text-sm text-foreground placeholder-muted outline-none" className="min-w-24 flex-1 bg-transparent px-1.5 py-1 text-sm text-foreground placeholder-muted outline-none"
/> />
@@ -104,25 +179,35 @@ export function MultiSelect({
{open && ( {open && (
<ul <ul
id="multi-select-listbox" ref={listboxRef}
id={listboxId}
role="listbox" role="listbox"
aria-multiselectable="true"
className="absolute left-0 top-full z-20 mt-1.5 max-h-40 w-full overflow-y-auto rounded-field border border-border bg-surface p-1.5 shadow-lg" className="absolute left-0 top-full z-20 mt-1.5 max-h-40 w-full overflow-y-auto rounded-field border border-border bg-surface p-1.5 shadow-lg"
> >
{results.length === 0 ? ( {results.length === 0 ? (
<li className="px-2.5 py-2 text-sm text-muted">{emptyMessage}</li> <li className="px-2.5 py-2 text-sm text-muted">{emptyMessage}</li>
) : ( ) : (
results.map((o) => { results.map((o, idx) => {
const isSelected = selected.includes(o.id); const isSelected = selected.includes(o.id);
const isHighlighted = activeIndex === idx;
return ( return (
<li key={o.id} role="option" aria-selected={isSelected}> <li
key={o.id}
id={`${listboxId}-option-${o.id}`}
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActiveIndex(idx)}
>
<button <button
type="button" type="button"
tabIndex={-1}
onClick={() => toggle(o.id)} onClick={() => toggle(o.id)}
className={`w-full rounded-lg px-2.5 py-2 text-left text-sm transition ${ className={`w-full rounded-lg px-2.5 py-2 text-left text-sm transition ${
isSelected isSelected
? "bg-accent-soft text-accent-soft-foreground" ? "bg-accent-soft text-accent-soft-foreground"
: "text-foreground hover:bg-foreground/5" : "text-foreground hover:bg-foreground/5"
}`} } ${isHighlighted ? "ring-1 ring-focus bg-foreground/5" : ""}`}
> >
{o.label} {o.label}
</button> </button>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -79,7 +79,7 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
}`} }`}
> >
<span <span
className={`absolute inset-y-1.5 left-0 w-[3px] rounded-full transition-opacity ${ className={`absolute inset-y-1.5 left-0 w-0.75 rounded-full transition-opacity ${
active ? "bg-accent opacity-100" : "opacity-0" active ? "bg-accent opacity-100" : "opacity-0"
}`} }`}
aria-hidden="true" aria-hidden="true"
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { iconCalendar, iconCheckCircle, iconChevronDown } from "../../_core/icons"; import { iconCalendar, iconCheckCircle, iconChevronDown } from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client"; import { ApiError } from "../../_core/services/api-client";
@@ -147,6 +147,7 @@ export function OperatingDayControl() {
title="Cerrar día de operación" title="Cerrar día de operación"
description="Se cerrará la fecha de operación actual y se abrirá automáticamente la del día siguiente. Esta acción no se puede deshacer." description="Se cerrará la fecha de operación actual y se abrirá automáticamente la del día siguiente. Esta acción no se puede deshacer."
confirmLabel="Cerrar día" confirmLabel="Cerrar día"
pendingLabel="Cerrando día…"
onConfirm={handleClose} onConfirm={handleClose}
/> />
</div> </div>
@@ -2,7 +2,7 @@
import { Avatar } from "@heroui/react"; import { Avatar } from "@heroui/react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTheme } from "../../_core/hooks/use-theme"; import { useTheme } from "../../_core/hooks/use-theme";
import { import {
+3 -3
View File
@@ -14,14 +14,14 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "PDV — Punto de Venta",
description: "Generated by create next app", description: "Sistema de Punto de Venta y Gestión de Inventario",
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html <html
lang="en" lang="es"
suppressHydrationWarning suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { iconStorefrontFill } from "../../_core/icons"; import { iconStorefrontFill } from "../../_core/icons";
@@ -2,7 +2,7 @@
import { Avatar } from "@heroui/react"; import { Avatar } from "@heroui/react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "motion/react";
import { iconIdentificationBadge } from "../../_core/icons"; import { iconIdentificationBadge } from "../../_core/icons";
import type { AuthUser } from "../../_core/types/auth-user"; import type { AuthUser } from "../../_core/types/auth-user";
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
iconAddCircle, iconAddCircle,
+1
View File
@@ -244,6 +244,7 @@ export default function PosHistoryPage() {
title="Devolver venta" title="Devolver venta"
description={`Se repondrá el stock de la venta #${returning?.id} y se registrará el reembolso. Esta acción no se puede deshacer.`} description={`Se repondrá el stock de la venta #${returning?.id} y se registrará el reembolso. Esta acción no se puede deshacer.`}
confirmLabel="Confirmar devolución" confirmLabel="Confirmar devolución"
pendingLabel="Devolviendo…"
onConfirm={handleReturn} onConfirm={handleReturn}
/> />
</div> </div>
+151 -164
View File
File diff suppressed because it is too large. Load diff