layish
blockasksource: ok · hit
Ask
A questionnaire wrap with HITL onResult for AI SDK addToolOutput.
npx shadcn add "https://layish.vercel.app/r/ask.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighttoastssonner
Registry dependencies
questionnairecardbuttonpopoverkbd
Files2 · source cached 10/14/2026, 11:37:13 AM
registry/new-york/blocks/ask/ask.tsx
"use client"
import * as React from "react"
import {
Questionnaire as QuestionnairePrimitive,
type QuestionnaireItemStatus,
} from "@shadcn/react/questionnaire"
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
CheckIcon,
CornerDownLeftIcon,
XIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import {
useInteractiveFocusRegistration,
isOwnedPortaledOverlay,
rootHasOpenPortaledOverlay,
type InteractiveFocusTrigger,
} from "@/registry/new-york/blocks/ask/interactive-focus"
export {
InteractiveFocusProvider,
InteractiveFocusSurface,
useInteractiveFocusSurface,
} from "@/registry/new-york/blocks/ask/interactive-focus"
export type { InteractiveFocusTrigger } from "@/registry/new-york/blocks/ask/interactive-focus"
const DEFAULT_AUTO_ADVANCE_DELAY_MS = 380
const CARD_ROW_CLASS =
"group/questionnaire-choice relative flex min-h-11 items-center justify-between gap-3 rounded-md border border-input px-2.5 py-2 text-start text-sm transition-[color,background-color] outline-none select-none hover:bg-accent/60 has-[>input:focus-visible]:ring-1 has-[>input:focus-visible]:ring-ring/70 sm:border-transparent sm:px-2 sm:py-1.5"
const PLAIN_ROW_CLASS =
"group/questionnaire-choice relative flex min-h-11 items-start justify-between gap-3 rounded-lg border border-input bg-transparent px-3 py-2.5 text-start text-sm transition-colors outline-none select-none hover:bg-muted/50 has-[>input:focus-visible]:border-foreground/40 data-checked:border-primary/40 data-checked:bg-muted"
const TOUCH_ACTION_CLASS = "min-h-11 sm:min-h-0"
const BATCH_BADGE_CLASS =
"inline-flex size-6 shrink-0 items-center justify-center rounded-md border font-mono text-xs font-medium"
type OtherDraft = {
text: string
committed: boolean
}
type OtherTrailingAction = "commit" | "deselect-keep-text" | "focus-input"
function emptyOtherDraft(): OtherDraft {
return { text: "", committed: false }
}
function resolveOtherTrailingAction(args: {
committed: boolean
focused: boolean
text: string
}): OtherTrailingAction {
if (args.focused && args.text.trim().length > 0) return "commit"
if (args.committed && !args.focused) return "deselect-keep-text"
return "focus-input"
}
export type AskChoice = {
value: string
label: string
/** Optional subtext under the answer label. */
description?: string
}
export type AskVariant = "card" | "plain"
type AskItemBase = {
name: string
title: string
description?: string
required?: boolean
choices: AskChoice[]
input?: {
label: string
placeholder?: string
}
}
export type AskItem =
| (AskItemBase & {
multiple?: false
/** After a single choice, go to the next slide (or review). Not valid on `multiple`. */
autoAdvance?: boolean
})
| (AskItemBase & {
multiple: true
})
export type AskLabels = {
previous?: string
next?: string
skip?: string
submit?: string
review?: string
cancel?: string
cancelTitle?: string
cancelDescription?: string
cancelConfirm?: string
cancelKeep?: string
}
export type AskAnswer = {
name: string
title: string
value: string | string[] | null
label: string
}
export type AskResult =
| { status: "submitted"; answers: AskAnswer[] }
| { status: "canceled" }
export type AskProps = {
items: AskItem[]
className?: string
/**
* Host-owned submit handler. Optional — playground embeds work without it.
* Receives the submitted `AskResult`. May return a Promise; while that
* Promise is pending, the final Submit action stays disabled.
* After settle (resolve or reject), Ask clears pending and leaves
* post-submit UI to the host (toast, reset, chat bubble, etc.).
*/
onSubmit?: (result: AskResult) => void | Promise<void>
/**
* HITL-shaped result. Pass this straight to `addToolOutput({ output })`.
* Submit → `{ status: "submitted", answers }`. Cancel → `{ status: "canceled" }`.
*/
onResult?: (result: AskResult) => void
/** After the last question, show a review step before submit. Default false. */
review?: boolean
/** Show a confirm-to-cancel control. Default false. */
cancel?: boolean
onCancel?: () => void
autoAdvanceDelay?: number
/**
* Visual shell. `card` keeps the Card chrome (default).
* `plain` drops the card background and uses bordered answer rows
* (shadcn Questionnaire look).
*/
variant?: AskVariant
/** Answer shortcut keys on choices. Default `"numbers"`. */
shortcuts?: "numbers" | "letters" | false
/**
* While Command (Meta) or Ctrl is held, show Kbd hints inline on
* Previous / Skip / Next / Submit. Default true. Only applies when
* `shortcuts` is not `false`.
*/
shortcutHints?: boolean
/**
* Gate keyboard shortcuts behind interactive focus. Clicking the Ask
* card (or any `focusTriggers` / shared focus surface) activates it.
* With `InteractiveFocusProvider`, Tab cycles cards by `focusPriority`.
* Default false.
*/
focusable?: boolean
/**
* When multiple focusable cards share a trigger (e.g. chat background),
* higher priority wins. Default 0.
*/
focusPriority?: number
/**
* Extra elements that activate this Ask when clicked (chat pane, preview
* chrome, etc.). Shared surfaces from `InteractiveFocusSurface` are
* included automatically.
*/
focusTriggers?: InteractiveFocusTrigger[]
/** Fires when interactive focus becomes active or inactive. */
onFocusChange?: (focused: boolean) => void
labels?: AskLabels
defaultItem?: string
item?: string
onItemChange?: (item: string) => void
}
export const ASK_SHORTCUTS = {
previous: "ArrowLeft",
next: "ArrowRight",
skip: "ArrowRight",
submit: "Enter",
} as const
function itemAutoAdvances(item: AskItem) {
if (item.multiple) return false
return item.autoAdvance === true
}
function otherBadge(
item: AskItem,
shortcuts: AskProps["shortcuts"],
) {
const index = item.choices.length
if (shortcuts === "letters") {
return index < 26 ? String.fromCharCode(65 + index) : String(index + 1)
}
return String(index + 1)
}
function otherShortcutKey(
item: AskItem,
shortcuts: AskProps["shortcuts"],
) {
if (shortcuts === false || shortcuts == null) return null
const index = item.choices.length
if (shortcuts === "numbers") {
return index < 9 ? String(index + 1) : null
}
return index < 26 ? String.fromCharCode(65 + index) : null
}
type ItemSelection = string | string[] | null
function emptySelection(item: AskItem): ItemSelection {
return item.multiple ? [] : null
}
function isChoiceSelected(
item: AskItem,
choiceValue: string,
selection: Record<string, ItemSelection>,
) {
const value = selection[item.name]
if (item.multiple) {
return Array.isArray(value) && value.includes(choiceValue)
}
return value === choiceValue
}
function selectedValues(value: ItemSelection | undefined): string[] {
return Array.isArray(value) ? value : []
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (target instanceof HTMLTextAreaElement) return true
if (!(target instanceof HTMLInputElement)) return false
return !["button", "checkbox", "radio", "reset", "submit"].includes(
target.type,
)
}
function isChoiceInput(
target: EventTarget | null,
): target is HTMLInputElement {
return (
target instanceof HTMLInputElement &&
(target.type === "radio" || target.type === "checkbox")
)
}
function clickSlot(
form: HTMLFormElement | null,
slot: "previous" | "skip" | "next" | "submit",
) {
const button = form?.querySelector<HTMLButtonElement>(
`[data-slot=questionnaire-${slot}]`,
)
if (!button || button.disabled || button.hidden) return false
if (button.getAttribute("aria-hidden") === "true") return false
button.click()
return true
}
function activeChoiceInputs(form: HTMLFormElement | null) {
if (!form) return []
const items = [
...form.querySelectorAll<HTMLElement>("[data-slot=questionnaire-item]"),
]
const active = items.find((item) => !item.hidden)
if (!active) return []
const choices = [
...active.querySelectorAll<HTMLInputElement>(
"[data-slot=questionnaire-choice-input]",
),
].filter((input) => !input.disabled)
const other = active.querySelector<HTMLInputElement>(
"[data-slot=questionnaire-other-row] input:not([disabled])",
)
return other ? [...choices, other] : choices
}
function moveChoiceFocus(form: HTMLFormElement | null, delta: 1 | -1) {
const inputs = activeChoiceInputs(form)
if (inputs.length === 0) return
const currentIndex = inputs.findIndex(
(input) => input === document.activeElement,
)
const nextIndex =
currentIndex < 0
? delta === 1
? 0
: inputs.length - 1
: (currentIndex + delta + inputs.length) % inputs.length
inputs[nextIndex]?.focus()
}
function useModifierHeld(enabled = true) {
const [held, setHeld] = React.useState(false)
React.useEffect(() => {
if (!enabled) {
setHeld(false)
return
}
const sync = (event: KeyboardEvent) => {
setHeld(event.metaKey || event.ctrlKey)
}
const onBlur = () => setHeld(false)
window.addEventListener("keydown", sync)
window.addEventListener("keyup", sync)
window.addEventListener("blur", onBlur)
return () => {
window.removeEventListener("keydown", sync)
window.removeEventListener("keyup", sync)
window.removeEventListener("blur", onBlur)
}
}, [enabled])
return enabled ? held : false
}
function AskProgress({ className }: { className?: string }) {
return (
<QuestionnaireProgress
className={cn(
"min-w-0 whitespace-nowrap text-sm font-normal tabular-nums",
className,
)}
render={(props, state) => (
<div
{...props}
aria-label={`Question ${state.current} of ${state.total}`}
>
<span className="sm:hidden">
{state.current}/{state.total}
</span>
<span className="hidden sm:inline">
Question {state.current} of {state.total}
</span>
</div>
)}
/>
)
}
function AskOptionRow({
children,
description,
className,
isPending = false,
showHoverArrow = false,
variant = "card",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice> & {
description?: string
isPending?: boolean
showHoverArrow?: boolean
variant?: AskVariant
}) {
const plain = variant === "plain"
const hasDescription = Boolean(description)
return (
<QuestionnairePrimitive.Choice
data-slot="questionnaire-choice"
className={cn(
plain ? PLAIN_ROW_CLASS : CARD_ROW_CLASS,
!plain &&
"cursor-default data-checked:bg-accent data-checked:text-accent-foreground sm:cursor-pointer",
plain &&
"cursor-default data-checked:text-accent-foreground sm:cursor-pointer",
"data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
hasDescription && !plain && "items-start",
isPending && "ring-1 ring-primary/50",
className,
)}
{...props}
>
<QuestionnairePrimitive.ChoiceInput
data-slot="questionnaire-choice-input"
className="absolute inset-0 size-full cursor-default opacity-0 sm:cursor-pointer"
/>
<QuestionnairePrimitive.ChoiceLabel
data-slot="questionnaire-choice-label"
className={cn(
"min-w-0 flex-1 leading-snug",
hasDescription && "flex flex-col gap-0.5",
)}
>
<span className={cn(hasDescription && "font-medium")}>{children}</span>
{description ? (
<span
data-slot="questionnaire-choice-description"
className="text-sm font-normal text-muted-foreground"
>
{description}
</span>
) : null}
</QuestionnairePrimitive.ChoiceLabel>
<span className="relative size-6 shrink-0">
<QuestionnairePrimitive.ChoiceShortcut
data-slot="questionnaire-choice-shortcut"
className={cn(
BATCH_BADGE_CLASS,
"pointer-events-none border-transparent text-muted-foreground group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground",
hasDescription && "translate-y-0.5",
showHoverArrow && "group-hover/questionnaire-choice:hidden",
)}
/>
{showHoverArrow ? (
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 hidden items-center justify-center rounded-md bg-primary text-primary-foreground group-hover/questionnaire-choice:inline-flex",
hasDescription && "translate-y-0.5",
)}
>
<ArrowRightIcon className="size-3" />
</span>
) : null}
</span>
</QuestionnairePrimitive.Choice>
)
}
function AskOtherRow({
name,
label,
placeholder = "Other",
badge,
disabled = false,
isPending = false,
committed,
text,
autoAdvance,
multiple = false,
variant = "card",
inputRef,
onTextChange,
onCommit,
onUncommitKeepText,
onResetDraft,
onFocusChange,
}: {
name: string
label: string
placeholder?: string
badge: string
disabled?: boolean
isPending?: boolean
committed: boolean
text: string
autoAdvance: boolean
multiple?: boolean
variant?: AskVariant
inputRef: (node: HTMLInputElement | null) => void
onTextChange: (value: string) => void
onCommit: () => void
onUncommitKeepText: () => void
onResetDraft: (refocus: boolean) => void
onFocusChange: (focused: boolean) => void
}) {
const localRef = React.useRef<HTMLInputElement | null>(null)
const [focused, setFocused] = React.useState(false)
const plain = variant === "plain"
function setInputNode(node: HTMLInputElement | null) {
localRef.current = node
inputRef(node)
}
React.useLayoutEffect(() => {
if (committed) return
const el = localRef.current
if (el && el.value !== text) el.value = text
}, [committed, text])
React.useLayoutEffect(() => {
const isFocus = localRef.current === document.activeElement
setFocused(isFocus)
}, [committed])
const showClear = focused && text.length > 0
const showCommitArrow =
focused && text.trim().length > 0 && autoAdvance
const showCommitCheck =
focused && text.trim().length > 0 && (multiple || !autoAdvance)
const highlighted = focused || committed || isPending
const trailingAction = resolveOtherTrailingAction({
committed,
focused,
text,
})
function handleTrailingMouseDown(event: React.MouseEvent) {
if (trailingAction === "commit" || trailingAction === "deselect-keep-text") {
event.preventDefault()
}
}
function handleTrailingClick() {
if (disabled) return
if (trailingAction === "commit") {
onCommit()
return
}
if (trailingAction === "deselect-keep-text") {
onUncommitKeepText()
localRef.current?.blur()
return
}
const input = localRef.current
if (!input) return
input.focus()
const end = input.value.length
input.setSelectionRange(end, end)
}
return (
<div
data-slot="questionnaire-other-row"
className={cn(
plain ? PLAIN_ROW_CLASS : CARD_ROW_CLASS,
plain
? (committed || isPending) &&
"border-primary/40 bg-muted text-accent-foreground"
: highlighted && "bg-accent text-accent-foreground",
isPending && "ring-1 ring-primary/50",
disabled && "cursor-not-allowed opacity-50",
)}
onClick={(event) => {
if (disabled) return
if (event.target instanceof HTMLButtonElement) return
localRef.current?.focus()
}}
>
{committed ? (
<QuestionnairePrimitive.Input
key={`${name}-committed`}
ref={setInputNode}
aria-label={label}
disabled={disabled}
placeholder={placeholder}
value={text}
onBlur={() => {
setFocused(false)
onFocusChange(false)
}}
onChange={(event) => {
const next = event.currentTarget.value
onTextChange(next)
if (next.trim().length === 0) onUncommitKeepText()
}}
onFocus={() => {
setFocused(true)
onFocusChange(true)
}}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.stopPropagation()
return
}
if (event.key === "Backspace" && text.length === 0) {
event.preventDefault()
event.stopPropagation()
onResetDraft(false)
localRef.current?.blur()
return
}
if (event.key === "Enter") {
event.preventDefault()
event.stopPropagation()
onCommit()
}
}}
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-sm shadow-none outline-none placeholder:text-muted-foreground focus-visible:ring-0 disabled:cursor-not-allowed"
/>
) : (
<QuestionnairePrimitive.Input
key={`${name}-draft`}
ref={setInputNode}
aria-label={label}
disabled={disabled}
placeholder={placeholder}
onBlur={() => {
setFocused(false)
onFocusChange(false)
}}
onChange={(event) => {
event.preventDefault()
onTextChange(event.currentTarget.value)
}}
onFocus={() => {
setFocused(true)
onFocusChange(true)
}}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.stopPropagation()
return
}
if (event.key === "Backspace" && text.length === 0) {
event.preventDefault()
event.stopPropagation()
onResetDraft(false)
localRef.current?.blur()
return
}
if (event.key === "Enter") {
event.preventDefault()
event.stopPropagation()
onCommit()
}
}}
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-sm shadow-none outline-none placeholder:text-muted-foreground focus-visible:ring-0 disabled:cursor-not-allowed"
/>
)}
{showClear ? (
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label="Clear Other text"
className={cn(
BATCH_BADGE_CLASS,
"cursor-pointer border-transparent text-muted-foreground hover:bg-accent hover:text-foreground disabled:cursor-not-allowed",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onResetDraft(true)}
>
<XIcon className="size-3.5" />
</button>
) : null}
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={
showCommitCheck || showCommitArrow
? "Save Other answer"
: committed && !focused
? "Other — click to deselect and keep your text"
: `Other option ${badge}`
}
className={cn(
BATCH_BADGE_CLASS,
"cursor-pointer disabled:cursor-not-allowed",
committed && !focused
? "border-primary bg-primary text-primary-foreground"
: "border-transparent bg-transparent text-muted-foreground",
(showCommitCheck || showCommitArrow) &&
"border-primary bg-primary text-primary-foreground",
)}
onMouseDown={handleTrailingMouseDown}
onClick={handleTrailingClick}
>
{showCommitArrow ? (
<ArrowRightIcon className="size-3" />
) : showCommitCheck ? (
<CheckIcon className="size-3.5" />
) : (
<span>{badge}</span>
)}
</button>
</div>
)
}
function skippedAnswer(item: AskItem): AskAnswer {
return {
name: item.name,
title: item.title,
value: null,
label: "Skipped",
}
}
function labelsForValues(item: AskItem, values: string[]) {
return values.map(
(value) =>
item.choices.find((choice) => choice.value === value)?.label ?? value,
)
}
function committedOtherText(
itemName: string,
otherDrafts: Record<string, OtherDraft>,
) {
const draft = otherDrafts[itemName]
if (!draft?.committed) return ""
return draft.text.trim()
}
function readAnswers(
form: HTMLFormElement,
items: AskItem[],
selection: Record<string, ItemSelection>,
otherDrafts: Record<string, OtherDraft>,
): AskAnswer[] {
const data = new FormData(form)
return items.map((item) => {
const other = committedOtherText(item.name, otherDrafts)
if (item.multiple) {
const selected = selectedValues(selection[item.name])
const fromForm = data
.getAll(item.name)
.map(String)
.filter((value) => value.length > 0 && value !== other)
const values = selected.length > 0 ? selected : fromForm
if (values.length === 0 && !other) return skippedAnswer(item)
const labels = labelsForValues(item, values)
if (other) labels.push(other)
return {
name: item.name,
title: item.title,
value: other ? [...values, other] : values,
label: labels.join(", "),
}
}
if (other) {
return {
name: item.name,
title: item.title,
value: other,
label: other,
}
}
const selected = selection[item.name]
const fromForm = data.get(item.name)?.toString() ?? ""
const value =
typeof selected === "string" && selected.length > 0 ? selected : fromForm
if (!value) return skippedAnswer(item)
const choice = item.choices.find((entry) => entry.value === value)
return {
name: item.name,
title: item.title,
value,
label: choice?.label ?? value,
}
})
}
function useAutoAdvance(delay: number) {
const [pendingKey, setPendingKey] = React.useState<string | null>(null)
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const clear = React.useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setPendingKey(null)
}, [])
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])
const schedule = React.useCallback(
(key: string, advance: () => void) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setPendingKey(key)
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null
setPendingKey(null)
advance()
}, delay)
},
[delay],
)
return { pendingKey, schedule, clear }
}
function ShortcutKey({ part }: { part: string }) {
if (part === "ArrowLeft") {
return (
<Kbd>
<ArrowLeftIcon aria-label="Left arrow" />
</Kbd>
)
}
if (part === "ArrowRight") {
return (
<Kbd>
<ArrowRightIcon aria-label="Right arrow" />
</Kbd>
)
}
if (part === "ArrowUp") {
return (
<Kbd>
<ArrowUpIcon aria-label="Up arrow" />
</Kbd>
)
}
if (part === "ArrowDown") {
return (
<Kbd>
<ArrowDownIcon aria-label="Down arrow" />
</Kbd>
)
}
if (part === "Enter") {
return (
<Kbd>
<CornerDownLeftIcon aria-label="Enter" />
</Kbd>
)
}
return <Kbd>{part}</Kbd>
}
export function AskShortcutKbd({
shortcut,
className,
}: {
shortcut: string
className?: string
}) {
const parts = shortcut.split("+")
return (
<KbdGroup className={cn("align-middle", className)}>
{parts.map((part, index) => (
<ShortcutKey key={`${shortcut}-${index}-${part}`} part={part} />
))}
</KbdGroup>
)
}
function ActionShortcutHint({
shortcut,
visible,
}: {
shortcut: string
visible: boolean
}) {
if (!visible) return null
return (
<AskShortcutKbd
shortcut={shortcut}
className="shrink-0 **:data-[slot=kbd]:bg-current/15 **:data-[slot=kbd]:text-current"
/>
)
}
function CancelBatchButton({
labels,
onCancel,
className,
}: {
labels?: AskLabels
onCancel?: () => void
className?: string
}) {
const [open, setOpen] = React.useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
nativeButton={false}
render={
<Button
type="button"
variant="ghost"
size="icon"
className={cn("size-11 sm:size-9", className)}
aria-label={labels?.cancel ?? "Cancel batch"}
/>
}
>
<XIcon />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-72">
<PopoverHeader>
<PopoverTitle>
{labels?.cancelTitle ?? "Cancel this batch?"}
</PopoverTitle>
<PopoverDescription>
{labels?.cancelDescription ??
"Your current answers in this batch will be discarded."}
</PopoverDescription>
</PopoverHeader>
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setOpen(false)}
>
{labels?.cancelKeep ?? "Keep answering"}
</Button>
<Button
type="button"
size="sm"
onClick={() => {
setOpen(false)
onCancel?.()
}}
>
{labels?.cancelConfirm ?? "Cancel batch"}
</Button>
</div>
</PopoverContent>
</Popover>
)
}
export function Ask({
items,
className,
onSubmit,
onResult,
review = false,
cancel = false,
onCancel,
autoAdvanceDelay = DEFAULT_AUTO_ADVANCE_DELAY_MS,
variant = "card",
shortcuts = "numbers",
shortcutHints = true,
focusable = false,
focusPriority = 0,
focusTriggers,
onFocusChange,
labels,
defaultItem,
item: itemProp,
onItemChange,
}: AskProps) {
const plain = variant === "plain"
const firstName = items[0]?.name ?? ""
const lastName = items.at(-1)?.name
const formRef = React.useRef<HTMLFormElement>(null)
const [submitting, setSubmitting] = React.useState(false)
const { focused: interactiveFocused } = useInteractiveFocusRegistration({
enabled: focusable,
priority: focusPriority,
rootRef: formRef as React.RefObject<HTMLElement | null>,
triggers: focusTriggers,
onFocusChange,
})
const keyboardArmed = !focusable || interactiveFocused
const modifierHeld = useModifierHeld(keyboardArmed)
const showShortcutHints =
shortcuts !== false && shortcutHints && modifierHeld && keyboardArmed
const [phase, setPhase] = React.useState<"questions" | "review">("questions")
const [reviewAnswers, setReviewAnswers] = React.useState<
AskAnswer[]
>([])
const [itemStatus, setItemStatus] = React.useState<
Partial<Record<string, QuestionnaireItemStatus>>
>({})
const [selection, setSelection] = React.useState<
Record<string, ItemSelection>
>({})
const selectionRef = React.useRef(selection)
const [otherDrafts, setOtherDrafts] = React.useState<
Record<string, OtherDraft>
>({})
const otherDraftsRef = React.useRef(otherDrafts)
const [otherFocusedName, setOtherFocusedName] = React.useState<string | null>(
null,
)
const otherInputRefs = React.useRef<Record<string, HTMLInputElement | null>>(
{},
)
const [uncontrolledItem, setUncontrolledItem] = React.useState(
() => defaultItem ?? firstName,
)
const activeItem = itemProp ?? uncontrolledItem
const activeItemRef = React.useRef(activeItem)
React.useEffect(() => {
selectionRef.current = selection
}, [selection])
React.useEffect(() => {
otherDraftsRef.current = otherDrafts
}, [otherDrafts])
React.useEffect(() => {
activeItemRef.current = activeItem
}, [activeItem])
const { pendingKey, schedule, clear } = useAutoAdvance(autoAdvanceDelay)
const setActiveItem = React.useCallback(
(next: string) => {
if (itemProp == null) setUncontrolledItem(next)
onItemChange?.(next)
},
[itemProp, onItemChange],
)
function handleItemChange(next: string) {
clear()
setActiveItem(next)
}
function restoreBatchKeyboard() {
requestAnimationFrame(() => {
formRef.current?.focus({ preventScroll: true })
})
}
function blurOther(name: string) {
otherInputRefs.current[name]?.blur()
setOtherFocusedName((current) => (current === name ? null : current))
}
function enterReview() {
clear()
if (formRef.current) {
setReviewAnswers(
readAnswers(
formRef.current,
items,
selectionRef.current,
otherDraftsRef.current,
),
)
}
setPhase("review")
}
React.useLayoutEffect(() => {
if (phase !== "review") return
formRef.current?.focus({ preventScroll: true })
}, [phase])
function leaveReview() {
setPhase("questions")
}
function continueForward() {
if (submitting) return
if (showReviewNext) {
if (hasAnswer) enterReview()
return
}
if (clickSlot(formRef.current, "next")) return
clickSlot(formRef.current, "submit")
}
function handleAskKeyDown(
event: KeyboardEvent | React.KeyboardEvent<HTMLFormElement>,
) {
if (!keyboardArmed) return
if (event.defaultPrevented) return
const composing =
"nativeEvent" in event
? event.nativeEvent.isComposing
: event.isComposing
if (composing) return
if (event.metaKey || event.ctrlKey || event.altKey) return
const form = formRef.current
if (rootHasOpenPortaledOverlay(form)) return
const target = event.target
const key = event.key
// ↑/↓ still cycle choices (including Other) while the Other text field is focused.
const isVerticalChoiceNav = key === "ArrowUp" || key === "ArrowDown"
if (form && target instanceof Node && !form.contains(target)) {
if (isEditableTarget(target)) return
if (isOwnedPortaledOverlay(form, target)) return
} else if (isEditableTarget(target) && !isVerticalChoiceNav) {
return
}
if (phase === "review") {
if (key === "ArrowLeft") {
event.preventDefault()
if (!submitting) leaveReview()
} else if (key === "Enter") {
event.preventDefault()
if (!submitting) clickSlot(form, "submit")
}
return
}
if (pendingKey != null && (key === "ArrowLeft" || key === "ArrowRight")) {
event.preventDefault()
return
}
if (isVerticalChoiceNav) {
event.preventDefault()
event.stopPropagation()
moveChoiceFocus(form, key === "ArrowDown" ? 1 : -1)
return
}
if (key === "ArrowLeft") {
event.preventDefault()
event.stopPropagation()
clickSlot(form, "previous")
return
}
if (key === "ArrowRight") {
event.preventDefault()
event.stopPropagation()
if (nextIsShowing) {
if (showReviewNext) {
if (hasAnswer) enterReview()
return
}
clickSlot(form, "next")
return
}
if (!skipWouldSubmit) clickSlot(form, "skip")
return
}
if (
activeSlide?.input &&
otherShortcutKey(activeSlide, shortcuts) &&
key.toUpperCase() === otherShortcutKey(activeSlide, shortcuts)
) {
event.preventDefault()
applyOtherTrailing(activeSlide)
return
}
if (key === " " && isChoiceInput(target)) {
event.preventDefault()
target.click()
return
}
if (key !== "Enter") return
if (isChoiceInput(target) && activeSlide) {
const focusedSelected = isChoiceSelected(
activeSlide,
target.value,
selection,
)
if (!hasAnswer || !focusedSelected) {
event.preventDefault()
target.click()
return
}
event.preventDefault()
continueForward()
return
}
if (hasAnswer) {
event.preventDefault()
continueForward()
}
}
const handleAskKeyDownRef = React.useRef(handleAskKeyDown)
handleAskKeyDownRef.current = handleAskKeyDown
React.useEffect(() => {
if (!focusable || !keyboardArmed) return
// Capture-phase so ←/→ never hit native radio group navigation.
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key !== "ArrowLeft" &&
event.key !== "ArrowRight" &&
event.key !== "ArrowUp" &&
event.key !== "ArrowDown"
) {
return
}
handleAskKeyDownRef.current(event)
}
window.addEventListener("keydown", onKeyDown, true)
return () => window.removeEventListener("keydown", onKeyDown, true)
}, [focusable, keyboardArmed])
function goToNextFrom(fromName: string) {
if (activeItemRef.current !== fromName) return
const index = items.findIndex((item) => item.name === fromName)
const next = items[index + 1]
if (next) {
setActiveItem(next.name)
return
}
if (review) enterReview()
}
function emitCancel() {
onResult?.({ status: "canceled" })
onCancel?.()
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
// Skip on the last item calls form.requestSubmit(). Intercept that
// while questions are still open so review is not bypassed.
if (review && phase === "questions") {
event.preventDefault()
enterReview()
return
}
event.preventDefault()
if (submitting) return
const answers = readAnswers(
event.currentTarget,
items,
selectionRef.current,
otherDraftsRef.current,
)
const result: AskResult = { status: "submitted", answers }
onResult?.(result)
const maybePromise = onSubmit?.(result)
if (
maybePromise != null &&
typeof (maybePromise as PromiseLike<void>).then === "function"
) {
setSubmitting(true)
Promise.resolve(maybePromise).then(
() => {
setSubmitting(false)
},
() => {
// Host owns error handling inside onSubmit; clear pending only.
setSubmitting(false)
},
)
}
}
const collection = items.map((item) => ({
name: item.name,
required: item.required,
choices: item.choices.map((choice) => ({ value: choice.value })),
}))
const showReviewNext =
review && phase === "questions" && activeItem === lastName
const activeSlide = items.find((item) => item.name === activeItem)
const hasAnswer = itemStatus[activeItem ?? ""] === "answered"
const autoAdvanceSlide =
Boolean(activeSlide) && itemAutoAdvances(activeSlide!)
const hideAutoAdvanceNext =
autoAdvanceSlide && (!hasAnswer || pendingKey != null)
const nextIsShowing =
!hideAutoAdvanceNext && Boolean(showReviewNext || activeItem !== lastName)
const skipWouldSubmit = activeItem === lastName && !review
const skipHasArrow = !nextIsShowing && !skipWouldSubmit
const typingOtherSingle =
Boolean(activeSlide && !activeSlide.multiple && activeSlide.input) &&
(otherDrafts[activeItem ?? ""]?.text.length ?? 0) > 0
const hideSkip = pendingKey != null || typingOtherSingle
const hasPrevious =
phase === "review" || (items.length > 1 && activeItem !== firstName)
const hasSkip =
phase === "questions" &&
activeSlide != null &&
!activeSlide.required &&
!hideSkip
const hasNext = phase === "questions" && nextIsShowing
const hasSubmit =
phase === "review" ||
(phase === "questions" && activeItem === lastName && !review)
const showActions = hasPrevious || hasSkip || hasNext || hasSubmit
const backLabel = labels?.previous ?? "Back"
const skipLabel = labels?.skip ?? "Skip"
const nextLabel = labels?.next ?? "Next"
const showCancel = cancel
function clearItemSelection(name: string) {
const item = items.find((entry) => entry.name === name)
if (!item) return
setSelection((current) => ({
...current,
[name]: emptySelection(item),
}))
}
function patchOtherDraft(name: string, patch: Partial<OtherDraft>) {
setOtherDrafts((current) => {
const previous = current[name] ?? emptyOtherDraft()
return {
...current,
[name]: { ...previous, ...patch },
}
})
}
function resetOtherDraft(name: string) {
setOtherDrafts((current) => ({
...current,
[name]: emptyOtherDraft(),
}))
}
function uncommitOther(item: AskItem, keepText = true) {
setOtherDrafts((current) => {
const previous = current[item.name] ?? emptyOtherDraft()
return {
...current,
[item.name]: {
text: keepText ? previous.text : "",
committed: false,
},
}
})
if (!item.multiple && keepText) {
const selected = selectionRef.current[item.name]
if (
typeof selected === "string" &&
selected ===
committedOtherText(item.name, otherDraftsRef.current)
) {
setSelection((current) => ({
...current,
[item.name]: null,
}))
}
}
blurOther(item.name)
restoreBatchKeyboard()
}
function commitOther(item: AskItem) {
if (pendingKey != null) return
const raw = otherDraftsRef.current[item.name]?.text ?? ""
const trimmed = raw.trim()
if (!trimmed) {
resetOtherDraft(item.name)
if (!item.multiple) {
setSelection((current) => ({
...current,
[item.name]: null,
}))
}
blurOther(item.name)
restoreBatchKeyboard()
return
}
setOtherDrafts((current) => ({
...current,
[item.name]: { text: raw, committed: true },
}))
if (item.multiple) {
blurOther(item.name)
restoreBatchKeyboard()
return
}
setSelection((current) => ({
...current,
[item.name]: trimmed,
}))
blurOther(item.name)
restoreBatchKeyboard()
const isLast = item.name === lastName
const hasNext = !isLast || review
if (!itemAutoAdvances(item) || !hasNext) return
schedule(`${item.name}:other`, () => goToNextFrom(item.name))
}
function applyOtherTrailing(item: AskItem) {
if (pendingKey != null) return
const draft = otherDrafts[item.name] ?? emptyOtherDraft()
const action = resolveOtherTrailingAction({
committed: draft.committed,
focused: otherFocusedName === item.name,
text: draft.text,
})
if (action === "commit") {
commitOther(item)
return
}
if (action === "deselect-keep-text") {
uncommitOther(item)
return
}
const input = otherInputRefs.current[item.name]
if (!input) return
input.focus()
const end = input.value.length
input.setSelectionRange(end, end)
}
return (
<Questionnaire
ref={formRef}
tabIndex={-1}
data-ask-focused={interactiveFocused ? "true" : "false"}
className={cn("w-full outline-none", className)}
defaultItem={defaultItem}
item={activeItem || undefined}
items={collection}
shortcuts={shortcuts === false ? undefined : shortcuts}
onItemChange={handleItemChange}
onKeyDown={(event) => {
if (focusable && !keyboardArmed) {
// Form may still be focused briefly; don't let Questionnaire act.
event.preventDefault()
event.stopPropagation()
return
}
handleAskKeyDownRef.current(event)
}}
onSubmit={handleSubmit}
>
<Card
className={cn(
!plain && "gap-2!",
plain &&
"gap-4 rounded-none bg-transparent py-0 ring-0 [--card-spacing:--spacing(0)] has-data-[slot=card-footer]:pb-0",
showCancel && phase === "questions" && "relative",
showCancel && phase === "questions" && !plain && "pt-2",
focusable && !plain && "bg-sidebar",
focusable && !plain && !interactiveFocused && "ring-0",
)}
>
{showCancel && phase === "questions" ? (
<CancelBatchButton
className={cn(
"absolute z-10",
plain ? "top-0 right-0" : "top-2 right-2",
)}
labels={labels}
onCancel={emitCancel}
/>
) : null}
<div hidden={phase === "review"}>
{items.map((item) => {
const titleId = `ask-${item.name}-title`
const canAutoAdvance = itemAutoAdvances(item)
const isLast = item.name === lastName
const hasNext = !isLast || review
return (
<QuestionnaireItem
key={item.name}
aria-labelledby={titleId}
name={item.name}
required={item.required}
multiple={item.multiple}
onStatusChange={(status) => {
setItemStatus((current) => ({
...current,
[item.name]: status,
}))
}}
>
<CardHeader
className={cn(
"gap-0.5",
plain && "rounded-none px-0",
)}
>
{showCancel ? (
<AskProgress className="pr-10" />
) : null}
<QuestionnaireTitle
id={titleId}
className="mb-0"
render={<CardTitle />}
>
{item.title}
</QuestionnaireTitle>
{showCancel ? null : (
<CardAction className="row-span-1">
<AskProgress />
</CardAction>
)}
{item.description ? (
<QuestionnaireDescription
className={cn(!showCancel && "col-span-full")}
render={<CardDescription />}
>
{item.description}
</QuestionnaireDescription>
) : null}
</CardHeader>
<CardContent className={cn(plain && "px-0")}>
<QuestionnaireChoices
className={cn(plain ? "gap-2" : "gap-2 sm:gap-1")}
>
{item.choices.map((choice) => {
const choiceKey = `${item.name}:${choice.value}`
const isPending = pendingKey === choiceKey
const isSelected = isChoiceSelected(
item,
choice.value,
selection,
)
const showHoverArrow =
canAutoAdvance &&
itemStatus[item.name] !== "answered" &&
!isSelected
return (
<AskOptionRow
key={choice.value}
checked={isSelected}
description={choice.description}
disabled={pendingKey != null && !isPending}
isPending={isPending}
showHoverArrow={showHoverArrow}
value={choice.value}
variant={variant}
onClick={() => {
if (item.multiple || !isSelected) return
clear()
clearItemSelection(item.name)
}}
onChange={(event) => {
const checked = event.currentTarget.checked
const value = choice.value
if (!item.multiple) {
setOtherDrafts((current) => {
const previous =
current[item.name] ?? emptyOtherDraft()
if (!previous.committed) return current
return {
...current,
[item.name]: {
...previous,
committed: false,
},
}
})
}
setSelection((current) => {
if (item.multiple) {
const selected = selectedValues(
current[item.name],
)
return {
...current,
[item.name]: checked
? [...selected, value]
: selected.filter((entry) => entry !== value),
}
}
return {
...current,
[item.name]: checked ? value : null,
}
})
if (!checked) {
clear()
return
}
if (!canAutoAdvance || !hasNext) return
if (itemStatus[item.name] === "answered") return
schedule(choiceKey, () => goToNextFrom(item.name))
}}
>
{choice.label}
</AskOptionRow>
)
})}
{item.input ? (
<AskOtherRow
name={item.name}
label={item.input.label}
placeholder={item.input.placeholder}
badge={otherBadge(item, shortcuts)}
disabled={pendingKey != null}
isPending={pendingKey === `${item.name}:other`}
committed={
otherDrafts[item.name]?.committed ?? false
}
text={otherDrafts[item.name]?.text ?? ""}
autoAdvance={canAutoAdvance}
multiple={item.multiple === true}
variant={variant}
inputRef={(node) => {
otherInputRefs.current[item.name] = node
}}
onTextChange={(value) => {
patchOtherDraft(item.name, { text: value })
if (
!item.multiple &&
(otherDrafts[item.name]?.committed ?? false)
) {
setSelection((current) => ({
...current,
[item.name]: value.trim() || null,
}))
}
}}
onCommit={() => commitOther(item)}
onUncommitKeepText={() => uncommitOther(item)}
onResetDraft={(refocus) => {
resetOtherDraft(item.name)
if (!item.multiple) {
const selected = selectionRef.current[item.name]
const other = committedOtherText(
item.name,
otherDraftsRef.current,
)
if (
typeof selected === "string" &&
selected === other
) {
setSelection((current) => ({
...current,
[item.name]: null,
}))
}
}
if (refocus) {
requestAnimationFrame(() =>
otherInputRefs.current[item.name]?.focus(),
)
} else {
blurOther(item.name)
restoreBatchKeyboard()
}
}}
onFocusChange={(focused) =>
setOtherFocusedName(focused ? item.name : null)
}
/>
) : null}
</QuestionnaireChoices>
<QuestionnaireError />
</CardContent>
</QuestionnaireItem>
)
})}
</div>
{phase === "review" ? (
<>
<CardHeader className={cn(plain && "rounded-none px-0")}>
<CardTitle>{labels?.review ?? "Review"}</CardTitle>
<CardDescription>Submit this batch?</CardDescription>
{showCancel ? (
<CardAction>
<CancelBatchButton labels={labels} onCancel={emitCancel} />
</CardAction>
) : null}
</CardHeader>
<CardContent className={cn(plain && "px-0")}>
<ul className="flex flex-col gap-4">
{reviewAnswers.map((answer) => (
<li key={answer.name} className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
{answer.title}
</p>
<p className="text-sm font-medium">{answer.label}</p>
</li>
))}
</ul>
</CardContent>
</>
) : null}
{showActions ? (
<CardFooter
className={cn(
"border-t-0 bg-transparent p-0 px-(--card-spacing) pt-1 pb-(--card-spacing)",
plain && "px-0 pb-0",
)}
>
<QuestionnaireActions className="w-full">
{phase === "review" ? (
<>
<Button
type="button"
variant="outline"
disabled={submitting}
className={cn(
"col-start-1 row-start-1 justify-self-start",
TOUCH_ACTION_CLASS,
)}
onClick={leaveReview}
>
<ActionShortcutHint
shortcut={ASK_SHORTCUTS.previous}
visible={showShortcutHints}
/>
{backLabel}
</Button>
<QuestionnaireSubmit disabled={submitting} aria-busy={submitting}>
{labels?.submit ?? "Submit"}
<ActionShortcutHint
shortcut={ASK_SHORTCUTS.submit}
visible={showShortcutHints && !submitting}
/>
</QuestionnaireSubmit>
</>
) : (
<>
<QuestionnairePrevious>
<ActionShortcutHint
shortcut={ASK_SHORTCUTS.previous}
visible={showShortcutHints}
/>
{backLabel}
</QuestionnairePrevious>
<QuestionnaireSkip
className={cn(
hideSkip && "hidden",
skipHasArrow && "col-start-3",
)}
disabled={hideSkip}
onClick={() => {
if (!activeItem) return
clear()
clearItemSelection(activeItem)
resetOtherDraft(activeItem)
}}
>
{skipLabel}
<ActionShortcutHint
shortcut={ASK_SHORTCUTS.skip}
visible={showShortcutHints && !hideSkip}
/>
</QuestionnaireSkip>
{showReviewNext ? (
hideAutoAdvanceNext ? null : !hasAnswer ? (
<span className="col-start-3 row-start-1 inline-flex justify-self-end">
<Button
type="button"
disabled
className={TOUCH_ACTION_CLASS}
onClick={enterReview}
>
{nextLabel}
<ActionShortcutHint
shortcut={ASK_SHORTCUTS.next}
visible={showShortcutHints}
/>
</Button>
</span>
) : (
<Button
type="button"
className={cn(
"col-start-3 row-start-1 justify-self-end",
TOUCH_ACTION_CLASS,
)}
onClick={enterReview}
>
{nextLabel}
<ActionShortcutHint
shortcut={ASK_SHORTCUTS.next}
visible={showShortcutHints}
/>
</Button>
)
) : (
<>
{hideAutoAdvanceNext ? null : (
<QuestionnaireNext>
{nextLabel}
<ActionShortcutHint
shortcut={ASK_SHORTCUTS.next}
visible={showShortcutHints}
/>
</QuestionnaireNext>
)}
<QuestionnaireSubmit disabled={submitting} aria-busy={submitting}>
{labels?.submit ?? "Submit"}
<ActionShortcutHint
shortcut={ASK_SHORTCUTS.submit}
visible={showShortcutHints && !submitting}
/>
</QuestionnaireSubmit>
</>
)}
</>
)}
</QuestionnaireActions>
</CardFooter>
) : null}
</Card>
</Questionnaire>
)
}
registry/new-york/blocks/ask/interactive-focus.tsx
"use client"
import * as React from "react"
export type InteractiveFocusTrigger =
| React.RefObject<HTMLElement | null>
| HTMLElement
| null
| undefined
type InteractiveFocusEntry = {
id: string
priority: number
getRoot: () => HTMLElement | null
getTriggers: () => InteractiveFocusTrigger[]
activate: () => void
deactivate: () => void
}
type InteractiveFocusContextValue = {
register: (entry: InteractiveFocusEntry) => () => void
requestFocus: (id: string) => void
focusedId: string | null
}
const InteractiveFocusContext =
React.createContext<InteractiveFocusContextValue | null>(null)
const InteractiveFocusSurfaceContext =
React.createContext<React.RefObject<HTMLElement | null> | null>(null)
function resolveElement(
trigger: InteractiveFocusTrigger,
): HTMLElement | null {
if (!trigger) return null
if (typeof HTMLElement !== "undefined" && trigger instanceof HTMLElement) {
return trigger
}
if (typeof trigger === "object" && "current" in trigger) {
return trigger.current
}
return null
}
function isEditableKeyboardTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (!(target instanceof HTMLInputElement)) {
return (
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
)
}
return !["button", "checkbox", "radio", "reset", "submit"].includes(
target.type,
)
}
function findPortaledOverlay(target: EventTarget | null): Element | null {
if (!(target instanceof Element)) return null
return (
target.closest("[data-slot=popover-content]") ??
target.closest("[data-slot=dialog-content]") ??
target.closest("[role=dialog]")
)
}
/**
* Portaled popovers/dialogs sit outside the Ask root in the DOM. Treat them as
* owned when their trigger lives under `root` (aria-controls) or the root has
* an open popover/dialog trigger while the event is inside overlay content.
*/
export function isOwnedPortaledOverlay(
root: HTMLElement | null,
target: EventTarget | null,
): boolean {
if (!root) return false
const overlay = findPortaledOverlay(target)
if (!overlay) return false
const overlayId = overlay.id
if (
overlayId &&
root.querySelector(`[aria-controls="${CSS.escape(overlayId)}"]`)
) {
return true
}
const slot = overlay.getAttribute("data-slot")
if (
(slot === "popover-content" ||
overlay.closest("[data-slot=popover-content]") != null) &&
root.querySelector(
'[data-slot=popover-trigger][data-state=open], [data-slot=popover-trigger][aria-expanded="true"]',
)
) {
return true
}
if (
(slot === "dialog-content" || overlay.getAttribute("role") === "dialog") &&
root.querySelector(
'[data-slot=dialog-trigger][data-state=open], [aria-haspopup="dialog"][aria-expanded="true"]',
)
) {
return true
}
return false
}
export function rootHasOpenPortaledOverlay(
root: HTMLElement | null,
): boolean {
if (!root) return false
return (
root.querySelector(
'[data-slot=popover-trigger][data-state=open], [data-slot=popover-trigger][aria-expanded="true"]',
) != null ||
root.querySelector(
'[data-slot=dialog-trigger][data-state=open], [aria-haspopup="dialog"][aria-expanded="true"]',
) != null
)
}
function eventInside(
entry: InteractiveFocusEntry,
target: EventTarget | null,
) {
if (!(target instanceof Node)) return false
const root = entry.getRoot()
if (root?.contains(target)) return true
for (const trigger of entry.getTriggers()) {
const el = resolveElement(trigger)
if (el?.contains(target)) return true
}
if (isOwnedPortaledOverlay(root, target)) return true
return false
}
function blurEntryRoot(getRoot: () => HTMLElement | null) {
const root = getRoot()
if (!root) return
const active = document.activeElement
if (active instanceof HTMLElement && root.contains(active)) {
active.blur()
}
root.blur()
}
export function InteractiveFocusProvider({
children,
}: {
children: React.ReactNode
}) {
const entriesRef = React.useRef(new Map<string, InteractiveFocusEntry>())
const [focusedId, setFocusedId] = React.useState<string | null>(null)
const focusedIdRef = React.useRef<string | null>(null)
const setFocused = React.useCallback((nextId: string | null) => {
const previous = focusedIdRef.current
if (previous === nextId) return
if (previous) entriesRef.current.get(previous)?.deactivate()
focusedIdRef.current = nextId
setFocusedId(nextId)
if (nextId) entriesRef.current.get(nextId)?.activate()
}, [])
const requestFocus = React.useCallback(
(id: string) => {
if (!entriesRef.current.has(id)) return
setFocused(id)
},
[setFocused],
)
const register = React.useCallback((entry: InteractiveFocusEntry) => {
entriesRef.current.set(entry.id, entry)
// Do not auto-focus on mount — host click / Tab arms a card.
return () => {
entriesRef.current.delete(entry.id)
if (focusedIdRef.current === entry.id) {
focusedIdRef.current = null
setFocusedId(null)
}
}
}, [])
React.useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
const target = event.target
const matches = [...entriesRef.current.values()].filter((entry) =>
eventInside(entry, target),
)
if (matches.length === 0) {
setFocused(null)
return
}
matches.sort((a, b) => b.priority - a.priority)
setFocused(matches[0]!.id)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab") return
if (event.defaultPrevented || event.isComposing) return
if (isEditableKeyboardTarget(event.target)) return
const focusedEntry = focusedIdRef.current
? entriesRef.current.get(focusedIdRef.current)
: undefined
// Let Tab move inside portaled confirm UI (e.g. cancel popover).
if (
focusedEntry &&
rootHasOpenPortaledOverlay(focusedEntry.getRoot())
) {
return
}
const ranked = [...entriesRef.current.values()].sort(
(a, b) => b.priority - a.priority || a.id.localeCompare(b.id),
)
if (ranked.length < 2) return
if (focusedIdRef.current == null) return
event.preventDefault()
const currentIndex = ranked.findIndex(
(entry) => entry.id === focusedIdRef.current,
)
const delta = event.shiftKey ? -1 : 1
const nextIndex =
currentIndex < 0
? 0
: (currentIndex + delta + ranked.length) % ranked.length
setFocused(ranked[nextIndex]!.id)
}
document.addEventListener("pointerdown", onPointerDown, true)
document.addEventListener("keydown", onKeyDown, true)
return () => {
document.removeEventListener("pointerdown", onPointerDown, true)
document.removeEventListener("keydown", onKeyDown, true)
}
}, [setFocused])
const value = React.useMemo(
() => ({ register, requestFocus, focusedId }),
[register, requestFocus, focusedId],
)
return (
<InteractiveFocusContext.Provider value={value}>
{children}
</InteractiveFocusContext.Provider>
)
}
/**
* Marks a container (e.g. chat pane / preview chrome) as a shared focus
* surface. Interactive cards inside can treat it as a focus trigger.
*/
export function InteractiveFocusSurface({
children,
className,
...props
}: React.ComponentProps<"div">) {
const ref = React.useRef<HTMLDivElement>(null)
return (
<InteractiveFocusSurfaceContext.Provider value={ref}>
<div
ref={ref}
className={className}
data-slot="interactive-focus-surface"
{...props}
>
{children}
</div>
</InteractiveFocusSurfaceContext.Provider>
)
}
export function useInteractiveFocusSurface() {
return React.useContext(InteractiveFocusSurfaceContext)
}
export function useInteractiveFocusRegistration(options: {
enabled: boolean
priority?: number
rootRef: React.RefObject<HTMLElement | null>
triggers?: InteractiveFocusTrigger[]
onFocusChange?: (focused: boolean) => void
}) {
const ctx = React.useContext(InteractiveFocusContext)
const surface = useInteractiveFocusSurface()
const id = React.useId()
const [soloFocused, setSoloFocused] = React.useState(false)
const onFocusChangeRef = React.useRef(options.onFocusChange)
const triggersRef = React.useRef(options.triggers)
const priority = options.priority ?? 0
const ctxRegister = ctx?.register
const ctxRequestFocus = ctx?.requestFocus
React.useEffect(() => {
onFocusChangeRef.current = options.onFocusChange
}, [options.onFocusChange])
React.useEffect(() => {
triggersRef.current = options.triggers
}, [options.triggers])
const focused = !options.enabled
? true
: ctx
? ctx.focusedId === id
: soloFocused
React.useEffect(() => {
if (!options.enabled) return
onFocusChangeRef.current?.(focused)
}, [focused, options.enabled])
const activate = React.useCallback(() => {
options.rootRef.current?.focus({ preventScroll: true })
}, [options.rootRef])
const deactivate = React.useCallback(() => {
blurEntryRoot(() => options.rootRef.current)
}, [options.rootRef])
React.useEffect(() => {
if (!options.enabled) return
if (!ctxRegister) {
setSoloFocused(false)
const onPointerDown = (event: PointerEvent) => {
const target = event.target
const root = options.rootRef.current
const triggers = [...(triggersRef.current ?? []), surface]
const hit =
(root != null &&
target instanceof Node &&
root.contains(target)) ||
triggers.some((trigger) => {
const el = resolveElement(trigger)
return (
el != null && target instanceof Node && el.contains(target)
)
}) ||
isOwnedPortaledOverlay(root, target)
if (hit) {
setSoloFocused(true)
// Keep focus inside owned portals (cancel confirm) so buttons work.
if (!isOwnedPortaledOverlay(root, target)) {
options.rootRef.current?.focus({ preventScroll: true })
}
return
}
setSoloFocused(false)
blurEntryRoot(() => options.rootRef.current)
}
document.addEventListener("pointerdown", onPointerDown, true)
return () =>
document.removeEventListener("pointerdown", onPointerDown, true)
}
return ctxRegister({
id,
priority,
getRoot: () => options.rootRef.current,
getTriggers: () => [...(triggersRef.current ?? []), surface],
activate,
deactivate,
})
}, [
activate,
ctxRegister,
deactivate,
id,
options.enabled,
options.rootRef,
priority,
surface,
])
const requestFocus = React.useCallback(() => {
if (!options.enabled) return
if (ctxRequestFocus) {
ctxRequestFocus(id)
return
}
setSoloFocused(true)
options.rootRef.current?.focus({ preventScroll: true })
}, [ctxRequestFocus, id, options.enabled, options.rootRef])
return {
focused,
requestFocus,
focusedId: ctx?.focusedId ?? null,
}
}