refactor(web): metadata, accesibilidad en multiselect y migracion a motion (#13) #29
No files matched your search
@@ -12,7 +12,7 @@
|
||||
"@heroui/react": "^3.2.6",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.104.0",
|
||||
"framer-motion": "^13.4.0",
|
||||
"motion": "^12.4.0",
|
||||
"next": "16.3.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ConfirmDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
pendingLabel?: string;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
@@ -19,10 +20,14 @@ export function ConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Eliminar",
|
||||
pendingLabel,
|
||||
}: ConfirmDialogProps) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activePendingLabel =
|
||||
pendingLabel ?? (confirmLabel === "Eliminar" ? "Eliminando…" : "Procesando…");
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
@@ -61,7 +66,7 @@ export function ConfirmDialog({
|
||||
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"
|
||||
>
|
||||
{submitting ? "Eliminando…" : confirmLabel}
|
||||
{submitting ? activePendingLabel : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { iconCloseSquare } from "../icons";
|
||||
|
||||
interface Option {
|
||||
@@ -26,21 +26,38 @@ export function MultiSelect({
|
||||
}: MultiSelectProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listboxRef = useRef<HTMLUListElement>(null);
|
||||
const listboxId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
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("keydown", onKeyDown);
|
||||
document.addEventListener("keydown", onGlobalKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keydown", onGlobalKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -54,6 +71,17 @@ export function MultiSelect({
|
||||
return options.filter((o) => !q || o.label.toLowerCase().includes(q)).slice(0, 50);
|
||||
}, [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) {
|
||||
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
|
||||
}
|
||||
@@ -62,10 +90,49 @@ export function MultiSelect({
|
||||
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 (
|
||||
<div ref={rootRef} className="relative">
|
||||
<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" : ""}`}
|
||||
>
|
||||
{selectedOptions.map((o) => (
|
||||
@@ -89,14 +156,22 @@ export function MultiSelect({
|
||||
))}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={selectedOptions.length === 0 ? placeholder : ""}
|
||||
role="combobox"
|
||||
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"
|
||||
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 && (
|
||||
<ul
|
||||
id="multi-select-listbox"
|
||||
ref={listboxRef}
|
||||
id={listboxId}
|
||||
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"
|
||||
>
|
||||
{results.length === 0 ? (
|
||||
<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 isHighlighted = activeIndex === idx;
|
||||
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
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => toggle(o.id)}
|
||||
className={`w-full rounded-lg px-2.5 py-2 text-left text-sm transition ${
|
||||
isSelected
|
||||
? "bg-accent-soft text-accent-soft-foreground"
|
||||
: "text-foreground hover:bg-foreground/5"
|
||||
}`}
|
||||
} ${isHighlighted ? "ring-1 ring-focus bg-foreground/5" : ""}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -79,7 +79,7 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||
}`}
|
||||
>
|
||||
<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"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { iconCalendar, iconCheckCircle, iconChevronDown } from "../../_core/icons";
|
||||
import { ApiError } from "../../_core/services/api-client";
|
||||
@@ -147,6 +147,7 @@ export function OperatingDayControl() {
|
||||
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."
|
||||
confirmLabel="Cerrar día"
|
||||
pendingLabel="Cerrando día…"
|
||||
onConfirm={handleClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Avatar } from "@heroui/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 { useTheme } from "../../_core/hooks/use-theme";
|
||||
import {
|
||||
|
||||
@@ -14,14 +14,14 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "PDV — Punto de Venta",
|
||||
description: "Sistema de Punto de Venta y Gestión de Inventario",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="es"
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { useRouter } from "next/navigation";
|
||||
import { iconStorefrontFill } from "../../_core/icons";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Avatar } from "@heroui/react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { iconIdentificationBadge } from "../../_core/icons";
|
||||
import type { AuthUser } from "../../_core/types/auth-user";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
iconAddCircle,
|
||||
|
||||
@@ -244,6 +244,7 @@ export default function PosHistoryPage() {
|
||||
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.`}
|
||||
confirmLabel="Confirmar devolución"
|
||||
pendingLabel="Devolviendo…"
|
||||
onConfirm={handleReturn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Generated
+151
-164
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user