refactor(web): metadata, accesibilidad en multiselect y migracion a motion (#13)
This commit is contained in:
1 parent
2d6c29a1f3
commit
23abee72d4
12 files changed
+268
-189
No files matched your search
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Generated
+151
-164
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user