"use client"
import * as React from "react"
import { EllipsisIcon, FileIcon, FolderIcon } from "lucide-react"
import type { AccessibleName } from "../internal/accessible-name.js"
import { isToggleModifier, useSelection, type SelectionProps } from "../internal/selection.js"
import { cn } from "../lib/utils.js"
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "./context-menu.js"
/**
* La vista de íconos de iCloud Drive: cada archivo es una miniatura en una caja de 96 (con el filo de
* 1 px y radio 4 de las miniaturas de Drive, catálogo §1.6), el nombre en 14 hasta dos líneas y el tipo
* en 12 gris. Con el puntero encima, una sola caja gris de radio 12 envuelve miniatura, nombre y tipo,
* como Drive; la elegida lleva además un borde de 2 px del acento por dentro. El nombre sigue en
* `label` (sin la píldora del acento del Finder).
*
* Drive web no se midió en esta vista (cambiarla escribe la preferencia de la cuenta): las medidas
* salen de la lista de Drive y de la grilla de Photos (§2.6).
*
* Es un `listbox`: flechas en dos dimensiones (↑↓ saltan una fila del layout real; en RTL ← y →
* se invierten), Home/End, type-ahead, Enter abre, y la selección sigue al foco. Con
* `selectionMode="multiple"`, el modelo de selección múltiple de WAI-ARIA (ver `SelectionProps`).
*/
type FileGridItem = {
id: string
name: string
/** El tipo, debajo del nombre en 12 gris: «PDF», «Carpeta», «12 ítems». */
kind?: React.ReactNode
/** La miniatura: un `` (se ajusta sin recortar) o un ícono propio. */
thumbnail?: React.ReactNode
/** Sin miniatura, muestra el ícono de carpeta en vez del de archivo. */
folder?: boolean
disabled?: boolean
}
const normalize = (text: string) => text.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()
type FileGridPropsBase = Omit, "children" | "defaultValue" | "onSelect"> & {
items: FileGridItem[]
/** Enter o doble click: abrir el archivo, entrar en la carpeta. */
onOpen?: (item: FileGridItem) => void
/**
* Las acciones de un ítem: los ítems de un menú (`ContextMenuItem`, o `DropdownMenuItem`, que es el
* mismo componente de Base UI), sin el `ContextMenuContent`. Un solo juego que abren el click
* derecho, Shift+F10 o la tecla de menú sobre el ítem enfocado, y el click en el «…» que la grilla
* pone arriba a la derecha de la caja (con el puntero o el foco; fuera del orden de Tab y del
* lector, porque el teclado ya tiene el menú contextual).
*
* Como Drive, abrirlo sobre un ítem que no estaba elegido lo elige. `selected` son los ítems sobre
* los que actúa: en `multiple`, sobre uno de los elegidos, todos los elegidos; si no, ese solo.
*/
menu?: (item: FileGridItem, selected: FileGridItem[]) => React.ReactNode
}
// Shift+F10 o la tecla de menú: el `contextmenu` que el navegador no manda bien (llega con las
// coordenadas en 0), sintetizado sobre el elemento. Mismo motivo que en `ContextMenuTrigger`.
function openMenuAt(element: HTMLElement, x: number, y: number) {
element.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: Math.round(x), clientY: Math.round(y) }))
}
function FileGrid({
className,
items,
selectionMode,
selected: selectedProp,
defaultSelected,
onSelectedChange,
onOpen,
menu,
onKeyDown: onKeyDownProp,
...props
}: FileGridProps) {
const selection = useSelection(
{ selectionMode, selected: selectedProp, defaultSelected, onSelectedChange } as SelectionProps,
(id) => items.find((item) => item.id === id)
)
const [focused, setFocused] = React.useState(null)
// Si un elegido ya no está en `items`, sale de la selección y se avisa (igual que en `Tree`).
React.useEffect(() => {
const exists = new Set(items.map((item) => item.id))
selection.prune((id) => exists.has(id))
})
const refs = React.useRef(new Map())
// Un callback estable por ítem: uno nuevo en cada render hace que React saque y vuelva a poner todos
// los refs en cada commit, y lo que corre en el medio (el foco que devuelve el menú al cerrar) veía
// el mapa vacío.
const refCallbacks = React.useRef(new Map void>())
const refFor = (id: string) => {
let callback = refCallbacks.current.get(id)
if (!callback) {
callback = (element) => {
if (element) refs.current.set(id, element)
else {
refs.current.delete(id)
refCallbacks.current.delete(id)
}
}
refCallbacks.current.set(id, callback)
}
return callback
}
// El ítem del menú abierto (o del último: queda durante la animación de salida) y su lugar, para
// devolverle el foco a la vecina si la acción lo saca.
const [target, setTarget] = React.useState<{ item: FileGridItem; index: number; selected: FileGridItem[] } | null>(null)
const typed = React.useRef({ text: "", at: 0 })
const tabStop =
(focused != null && items.some((item) => item.id === focused) && focused) ||
items.find((item) => selection.isSelected(item.id))?.id ||
items[0]?.id
const select = (item: FileGridItem) => {
if (item.disabled) return
selection.only(item)
}
// En `single` la selección sigue al foco. En `multiple` las flechas solas mueven el foco (y el
// ancla), y con ⇧ extienden el rango desde el ancla.
const moveTo = (item: FileGridItem | undefined, extend = false) => {
if (!item) return
const from = focused ?? item.id
setFocused(item.id)
if (!selection.multiple) select(item)
else if (extend) selection.extend(item, items, from)
else selection.anchorAt(item.id)
refs.current.get(item.id)?.focus()
}
const onItemClick = (event: React.MouseEvent, item: FileGridItem) => {
const from = focused ?? item.id
setFocused(item.id)
if (item.disabled) return
if (selection.multiple && event.shiftKey) selection.extend(item, items, from)
else if (selection.multiple && isToggleModifier(event)) selection.toggle(item, items)
else selection.only(item)
}
// Antes de abrir el menú de un ítem: como Drive, si no estaba elegido pasa a ser el único elegido.
// Con el dedo (`choose` en `false`) solo se anota: un scroll que arranca sobre un ítem no lo elige;
// se elige cuando el menú de verdad se abre (`onOpenChange`).
const prepareMenu = (item: FileGridItem, choose = true) => {
const inSelection = selection.multiple && selection.isSelected(item.id)
if (choose) chooseForMenu(item)
setTarget({
item,
index: items.findIndex((candidate) => candidate.id === item.id),
selected: inSelection ? items.filter((candidate) => selection.isSelected(candidate.id)) : [item],
})
}
const chooseForMenu = (item: FileGridItem) => {
setFocused(item.id)
if (!(selection.multiple ? selection.isSelected(item.id) : selection.ids[0] === item.id)) selection.only(item)
}
// Al cerrar, el foco vuelve al ítem del menú; si la acción lo sacó, a la que quedó en su lugar (o a
// la última), y si no queda ninguna, a donde Base UI lo deje. El disparador no es enfocable: sin
// esto el foco caía en el .
const menuReturnFocus = () => {
if (!target) return true
const own = refs.current.get(target.item.id)
if (own) return own
const neighbor = items[Math.min(target.index, items.length - 1)]
return (neighbor && refs.current.get(neighbor.id)) || (tabStop && refs.current.get(tabStop)) || true
}
// Cuántos ítems hay en la fila del actual, según el layout: los que comparten su `top`.
const rowStep = (index: number, direction: 1 | -1) => {
const top = (i: number) => refs.current.get(items[i]!.id)?.getBoundingClientRect().top ?? 0
const left = (i: number) => refs.current.get(items[i]!.id)?.getBoundingClientRect().left ?? 0
const here = { top: top(index), left: left(index) }
let target: number | undefined
for (let i = index + direction; i >= 0 && i < items.length; i += direction) {
if (top(i) === here.top) continue
// La primera fila distinta: el ítem de esa fila más cerca en horizontal.
if (target !== undefined && top(i) !== top(target)) break
if (target === undefined || Math.abs(left(i) - here.left) < Math.abs(left(target) - here.left)) target = i
}
return target
}
const onKeyDown = (event: React.KeyboardEvent) => {
// El de la app va primero y puede cancelar el nuestro con `preventDefault()`; antes el suyo, que
// llegaba por `...props` después, reemplazaba la navegación entera.
onKeyDownProp?.(event)
if (event.defaultPrevented) return
const index = items.findIndex((item) => item.id === tabStop)
const item = items[index]
if (!item) return
if (menu && (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey))) {
// Siempre se cancela: si no, el `ContextMenuTrigger` lo abriría anclado a la grilla entera.
event.preventDefault()
const element = refs.current.get(item.id)
if (element && !item.disabled) {
const rect = element.getBoundingClientRect()
openMenuAt(element, rect.left + 8, rect.top + 8)
}
return
}
if (selection.multiple && isToggleModifier(event) && event.key.toLowerCase() === "a") {
event.preventDefault()
selection.all(items)
return
}
let next: number | undefined
// En RTL la grilla corre de derecha a izquierda: → va al anterior.
const forward = event.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl" ? -1 : 1
switch (event.key) {
case "ArrowRight":
next = index + forward
break
case "ArrowLeft":
next = index - forward
break
case "ArrowDown":
next = rowStep(index, 1)
break
case "ArrowUp":
next = rowStep(index, -1)
break
case "Home":
next = 0
break
case "End":
next = items.length - 1
break
case "Enter":
if (!item.disabled) onOpen?.(item)
break
case " ":
if (!item.disabled) selection.toggle(item, items)
break
default: {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return
// Type-ahead, como en el Finder: lo tipeado en el último medio segundo, desde el que sigue; la
// misma letra repetida recorre los que empiezan con ella.
const now = Date.now()
let text = (now - typed.current.at > 500 ? "" : typed.current.text) + normalize(event.key)
typed.current = { text, at: now }
if (text.length > 1 && [...text].every((char) => char === text[0])) text = text[0]!
const ordered = [...items.slice(index + (text.length === 1 ? 1 : 0)), ...items.slice(0, index + 1)]
const found = ordered.find((candidate) => normalize(candidate.name).startsWith(text))
if (found) next = items.indexOf(found)
}
}
event.preventDefault()
// Type-ahead no extiende: ⇧ + una letra es una mayúscula.
if (next !== undefined) moveTo(items[next], event.shiftKey && event.key.length > 1)
}
const grid = (
{
if (!(event.target as Element).closest("[role=option]")) event.stopPropagation()
}}
// Tampoco mantener apretado el espacio vacío o un deshabilitado: el long-press de Base UI escucha
// todo el contenedor y abriría el menú del último ítem anotado.
onTouchStart={
menu
? (event) => {
if (!(event.target as Element).closest("[role=option]:not([aria-disabled])")) event.stopPropagation()
}
: undefined
}
className="grid grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-x-2 gap-y-4 outline-none"
{...props}
>
{items.map((item) => {
const isSelected = selection.isSelected(item.id)
return (
setFocused(item.id)}
onClick={(event) => onItemClick(event, item)}
onDoubleClick={() => !item.disabled && onOpen?.(item)}
onContextMenu={(event) => {
if (item.disabled) {
// Ni el de la grilla ni el nativo del navegador.
event.preventDefault()
event.stopPropagation()
} else if (menu) prepareMenu(item)
}}
onTouchStart={menu && !item.disabled ? () => prepareMenu(item, false) : undefined}
// La caja gris de Drive: una sola, radio 12, alrededor de miniatura, nombre y tipo. Con el
// puntero, solo la caja; elegida (una o varias), la caja más 2 px del acento por dentro
// (`inset-ring`: no corre el layout y sigue el radio). El foco en una no elegida es el
// anillo de siempre; en una elegida el borde ya es del acento, así que el foco lo duplica
// (2 → 4 px): en `multiple` hay varias elegidas y la enfocada se tiene que distinguir.
className={cn(
"group/selectable relative flex min-w-0 cursor-default flex-col items-center gap-1 rounded-menu p-1.5 pb-2 outline-none select-none hover:bg-fill-1 focus-visible:focus-ring aria-disabled:opacity-40 aria-disabled:hover:bg-transparent",
"data-[state=selected]:bg-selection-inactive data-[state=selected]:inset-ring-2 data-[state=selected]:inset-ring-selection-border data-[state=selected]:focus-visible:inset-ring-4"
)}
>
{menu && !item.disabled && (
// El «…» de Drive: un círculo gris translúcido de 24 en la esquina de la caja, solo en la
// del puntero o la del foco (no en cada elegida). Es del puntero (el teclado tiene
// Shift+F10): fuera del lector y del orden de Tab.
)}
{item.name}
{item.kind != null && {item.kind}}
)
})}
)
if (!menu) {
return (
{grid}
)
}
return (
{
if (open && target) chooseForMenu(target.item)
}}
>
{grid}
{target && menu(target.item, target.selected)}
)
}
type FileGridProps = FileGridPropsBase & SelectionProps & AccessibleName
export { FileGrid, type FileGridItem, type FileGridProps }