"use client"
import * as React from "react"
import { usePathname } from "next/navigation"
import { cue, typeCue } from "@/lib/sounds"
/*
* Cuelume cues for every primitive, picked by job as the Cuelume guide lays
* out. Primitives carry data-slot attributes, so one delegated listener and
* one observer cover every style and every component without touching them.
*/
// Surfaces that announce themselves when they open and close.
const MENUS = [
"dropdown-menu-content",
"context-menu-content",
"menubar-content",
"popover-content",
"select-content",
"combobox-content",
"navigation-menu-content",
]
const DIALOGS = ["dialog-content", "alert-dialog-content", "sheet-content", "drawer-content"]
const DISCLOSURES = ["accordion-item", "collapsible"]
// Items picked from a list.
const OPTIONS = [
"dropdown-menu-item",
"dropdown-menu-checkbox-item",
"dropdown-menu-radio-item",
"context-menu-item",
"context-menu-checkbox-item",
"context-menu-radio-item",
"menubar-item",
"menubar-checkbox-item",
"menubar-radio-item",
"select-item",
"command-item",
"combobox-item",
"tabs-trigger",
"radio-group-item",
"toggle-group-item",
"navigation-menu-link",
]
const SWITCHES = ["checkbox", "switch", "toggle"]
const NAV = ["pagination-link", "pagination-previous", "pagination-next", "carousel-previous", "carousel-next"]
const slot = (names: string[]) => names.map((n) => `[data-slot="${n}"]`).join(",")
const OPTION_SEL = slot(OPTIONS)
const SWITCH_SEL = slot(SWITCHES)
const NAV_SEL = slot(NAV)
const MENU_SEL = slot(MENUS)
const DIALOG_SEL = slot(DIALOGS)
const DISCLOSURE_SEL = slot(DISCLOSURES)
const BUTTON_SEL = '[data-slot="button"],[class*="group/button"],button,a[href],[role="button"]'
function isOn(el: Element) {
const state = el.getAttribute("data-state")
return (
el.getAttribute("aria-checked") === "true" ||
el.getAttribute("aria-pressed") === "true" ||
state === "checked" ||
state === "on" ||
el.hasAttribute("data-checked") ||
el.hasAttribute("data-pressed")
)
}
function isOpen(el: Element) {
const state = el.getAttribute("data-state")
if (state) return state === "open"
return el.hasAttribute("data-open")
}
// Taps wait a moment, so a button that opens or closes a dialog plays the
// dialog's cue instead of a tap followed by it.
let pendingTap: ReturnType<typeof setTimeout> | undefined
function tapSoon(options: Parameters<typeof cue>[1]) {
clearTimeout(pendingTap)
pendingTap = setTimeout(() => cue("tap", options), 24)
}
function surfaceCue(...args: Parameters<typeof cue>) {
clearTimeout(pendingTap)
cue(args[0], { ...args[1], force: true })
}
function onClick(e: MouseEvent) {
const target = e.target as Element | null
if (!target?.closest) return
const input = e.detail === 0 ? "keyboard" : pointer
const sw = target.closest(SWITCH_SEL)
if (sw) {
// Read the state once the component has updated.
requestAnimationFrame(() => cue("toggle", { direction: isOn(sw) ? "forward" : "back" }))
return
}
const option = target.closest(OPTION_SEL)
if (option) {
const list = option.parentElement ? [...option.parentElement.querySelectorAll(OPTION_SEL)] : []
const before = list.findIndex((el) => el.getAttribute("data-state") === "active" || el.getAttribute("aria-selected") === "true")
const after = list.indexOf(option)
cue("select", { direction: before >= 0 && after < before ? "back" : "forward" })
return
}
const nav = target.closest(NAV_SEL)
if (nav) {
const back = /previous/.test(nav.getAttribute("data-slot") ?? "")
cue("navigate", { emphasis: "subtle", direction: back ? "back" : "forward" })
return
}
const button = target.closest(BUTTON_SEL)
if (!button || button.matches(":disabled,[aria-disabled='true']")) return
// Triggers stay quiet. The surface they open plays open or close instead.
if (button.hasAttribute("aria-haspopup") || button.hasAttribute("aria-expanded")) return
// Draggable items play their own pick up and drop cues.
if (button.getAttribute("aria-roledescription") === "sortable") return
// Links that change the route play navigate when the route changes.
if (button.matches("a[href]") && !button.getAttribute("href")?.startsWith("#")) return
const variant = button.getAttribute("data-variant")
if (variant === "destructive") return cue("close", { emphasis: "strong" })
const quiet = variant === "ghost" || variant === "outline" || variant === "link" || variant === "secondary"
tapSoon({ emphasis: quiet ? "subtle" : "normal", input })
}
let pointer: "mouse" | "touch" | "pen" = "mouse"
function onPointerDown(e: PointerEvent) {
if (e.pointerType === "mouse" || e.pointerType === "touch" || e.pointerType === "pen") pointer = e.pointerType
}
// Select items pick on pointer up and close before any click, so they are
// heard here, and from Enter or Space on a highlighted item.
function onPointerUp(e: PointerEvent) {
if ((e.target as Element | null)?.closest?.('[data-slot="select-item"]')) cue("select")
}
function onSelectKey(e: KeyboardEvent) {
if ((e.key === "Enter" || e.key === " ") && (e.target as Element | null)?.closest?.('[data-slot="select-content"]'))
cue("select")
}
function onKeyDown(e: KeyboardEvent) {
const el = e.target as HTMLElement | null
if (!el || e.metaKey || e.ctrlKey || e.altKey) return
const editable =
(el instanceof HTMLInputElement && !["password", "checkbox", "radio", "range", "button", "submit"].includes(el.type)) ||
el instanceof HTMLTextAreaElement ||
el.isContentEditable
if (!editable) return
if (e.key === "Backspace" || e.key === "Delete") typeCue("delete")
else if (e.key === "Enter") typeCue("enter")
else if (e.key === " ") typeCue("space")
else if (e.key.length === 1) typeCue("printable")
}
/* Error states: a control that turns invalid shakes (transitions.dev error
state shake) and the error cue plays once, however many fields failed. */
function shake(el: Element) {
const target = el.closest('[data-slot="input-group"]') ?? el
if (!(target instanceof HTMLElement)) return
target.removeAttribute("data-shaking")
void target.offsetWidth
target.setAttribute("data-shaking", "")
setTimeout(() => target.removeAttribute("data-shaking"), 320)
}
function flagErrors(els: Element[]) {
if (!els.length) return
els.forEach(shake)
cue("error", { emphasis: "subtle", force: true })
}
function onSubmit(e: SubmitEvent) {
const form = e.target
if (!(form instanceof HTMLFormElement)) return
// Wait for validation to mark fields, then shake whatever is still invalid.
requestAnimationFrame(() => flagErrors([...form.querySelectorAll('[aria-invalid="true"]')]))
}
let invalidBatch: Element[] = []
function onInvalid(e: Event) {
if (!(e.target instanceof Element)) return
invalidBatch.push(e.target)
if (invalidBatch.length === 1)
queueMicrotask(() => {
flagErrors(invalidBatch)
invalidBatch = []
})
}
const seen = new WeakMap<Element, boolean>()
function announce(el: Element) {
const open = isOpen(el)
if (seen.get(el) === open) return
const first = !seen.has(el)
seen.set(el, open)
if (first && !open) return
if (el.matches(DIALOG_SEL)) surfaceCue(open ? "open" : "close")
// A menu closing right after an item was picked stays quiet.
else if (el.matches(MENU_SEL)) (open ? surfaceCue : cue)(open ? "open" : "close", { emphasis: "subtle" })
else if (el.matches(DISCLOSURE_SEL) && !first) surfaceCue(open ? "open" : "close", { emphasis: "subtle" })
}
function scan(node: Node) {
if (!(node instanceof Element)) return
for (const el of [node, ...node.querySelectorAll(DISCLOSURE_SEL)])
if (el.matches(DISCLOSURE_SEL) && !seen.has(el)) seen.set(el, isOpen(el))
const all = [node, ...node.querySelectorAll(`${MENU_SEL},${DIALOG_SEL}`)]
for (const el of all) if (el.matches(`${MENU_SEL},${DIALOG_SEL}`)) announce(el)
if (node.matches("[data-sonner-toast]")) {
const type = node.getAttribute("data-type")
const name = type === "success" ? "success" : type === "error" ? "error" : type === "warning" ? "warning" : "ready"
cue(name, { emphasis: "subtle", force: true })
}
}
export function SoundBinder() {
const pathname = usePathname()
const first = React.useRef(true)
React.useEffect(() => {
if (first.current) {
first.current = false
return
}
cue("navigate", { force: true })
}, [pathname])
React.useEffect(() => {
// Disclosures already on the page are recorded, so only changes play.
document.querySelectorAll(DISCLOSURE_SEL).forEach((el) => seen.set(el, isOpen(el)))
const observer = new MutationObserver((records) => {
const invalid: Element[] = []
for (const r of records) {
if (r.type === "attributes" && r.attributeName === "aria-invalid") {
if (r.target instanceof Element && r.target.getAttribute("aria-invalid") === "true" && r.oldValue !== "true")
invalid.push(r.target)
continue
}
if (r.type === "childList") r.addedNodes.forEach(scan)
else if (r.target instanceof Element && r.target.matches(`${MENU_SEL},${DIALOG_SEL},${DISCLOSURE_SEL}`))
announce(r.target)
}
flagErrors(invalid)
})
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-state", "data-open", "data-closed", "aria-invalid"],
attributeOldValue: true,
})
document.addEventListener("submit", onSubmit, true)
document.addEventListener("invalid", onInvalid, true)
document.addEventListener("pointerdown", onPointerDown, true)
document.addEventListener("click", onClick)
document.addEventListener("keydown", onKeyDown, true)
document.addEventListener("pointerup", onPointerUp, true)
document.addEventListener("keydown", onSelectKey, true)
return () => {
document.removeEventListener("submit", onSubmit, true)
document.removeEventListener("invalid", onInvalid, true)
document.removeEventListener("pointerup", onPointerUp, true)
document.removeEventListener("keydown", onSelectKey, true)
observer.disconnect()
document.removeEventListener("pointerdown", onPointerDown, true)
document.removeEventListener("click", onClick)
document.removeEventListener("keydown", onKeyDown, true)
}
}, [])
return null
}