Files
healer-man/src/ui/AbilityBindingsPanel.tsx
T
2026-09-17 09:13:15 -04:00

544 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { classById } from "../app/characterCatalog";
import {
ACTION_CONTROLS,
ACTION_CONTROL_DEFINITIONS,
ACTION_LAYERS,
ACTION_LAYER_LABELS,
actionBindingId,
type ActionBindingControl,
type ActionBindingLayer,
} from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore";
import {
abilityAtLevel,
abilityTalentNodeId,
defaultActionBarForCharacter,
learnedAbilityRank,
type AbilityDefinition,
} from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip";
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [icon]);
return (
<span className="ability-bindings__icon" style={{ "--ability-color": color } as CSSProperties} aria-hidden="true">
{icon && !failed ? <img src={icon} alt="" onError={() => setFailed(true)} /> : sigil}
</span>
);
}
type RestoreScope = "layer" | "all";
type BindingLayout = "controller" | "wow";
type BindingControl = ActionBindingControl | number;
interface RestoreRequest {
scope: RestoreScope;
layout: BindingLayout;
layer: ActionBindingLayer;
bar: number;
cancelFocusId: string;
completeFocusId: string;
}
const layerControlId = (layer: ActionBindingLayer) => `binding-layer-${layer}`;
const barControlId = (bar: number) => `binding-bar-${bar}`;
const slotControlId = (control: BindingControl) => typeof control === "number" ? `binding-wow-slot-${control}` : `binding-slot-${control}`;
const choiceControlId = (abilityId: string) => `binding-choice-${abilityId}`;
const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["neighbors"]>>> = {
"binding-layout-controller": {
up: "binding-restore-layer", down: "binding-layer-primary", left: "binding-layout-wow", right: "binding-layout-wow",
},
"binding-layout-wow": {
up: "binding-restore-all", down: "binding-layer-tertiary", left: "binding-layout-controller", right: "binding-layout-controller",
},
"binding-layer-primary": {
up: "binding-layout-controller", down: "binding-slot-dpad-left", left: "binding-layer-tertiary", right: "binding-layer-secondary",
},
"binding-layer-secondary": {
up: "binding-layout-controller", down: "binding-slot-r1", left: "binding-layer-primary", right: "binding-layer-tertiary",
},
"binding-layer-tertiary": {
up: "binding-layout-wow", down: "binding-slot-r2", left: "binding-layer-secondary", right: "binding-layer-primary",
},
"binding-slot-r1": {
up: "binding-layer-secondary", down: "binding-slot-face-top", left: "binding-slot-dpad-right", right: "binding-slot-r2",
},
"binding-slot-r2": {
up: "binding-layer-tertiary", down: "binding-slot-face-right", left: "binding-slot-r1", right: "binding-slot-dpad-left",
},
"binding-slot-dpad-left": {
up: "binding-layer-primary", down: "binding-restore-layer", left: "binding-slot-face-right", right: "binding-slot-dpad-right",
},
"binding-slot-dpad-right": {
up: "binding-slot-r1", down: "binding-restore-layer", left: "binding-slot-dpad-left", right: "binding-slot-face-left",
},
"binding-slot-face-top": {
up: "binding-slot-r1", down: "binding-slot-face-bottom", left: "binding-slot-face-left", right: "binding-slot-face-right",
},
"binding-slot-face-right": {
up: "binding-slot-face-top", down: "binding-slot-face-bottom", left: "binding-slot-face-left", right: "binding-slot-dpad-left",
},
"binding-slot-face-bottom": {
up: "binding-slot-face-top", down: "binding-restore-all", left: "binding-slot-face-left", right: "binding-slot-face-right",
},
"binding-slot-face-left": {
up: "binding-slot-face-top", down: "binding-slot-face-bottom", left: "binding-slot-dpad-right", right: "binding-slot-face-right",
},
"binding-restore-layer": {
up: "binding-slot-dpad-left", down: "binding-layer-primary", left: "binding-restore-all", right: "binding-restore-all",
},
"binding-restore-all": {
up: "binding-slot-face-bottom", down: "binding-layer-tertiary", left: "binding-restore-layer", right: "binding-restore-layer",
},
};
function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
const panel = useRef<HTMLElement>(null);
const keyLabel = useInputBindingLabels();
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
const openOverlay = useGameStore((state) => state.openOverlay);
const classId = useCombatStore((state) => state.classId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level);
const abilities = useCombatStore((state) => state.abilities);
const talentRanks = useCombatStore((state) => state.talentRanks);
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
if (!definition) return null;
const talentRank = learnedAbilityRank(definition, talentRanks);
const ability = abilityAtLevel(definition, level, talentRank);
return {
name: ability.name, description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
talentLocked: Boolean(abilityTalentNodeId(ability)) && !talentRank,
};
};
const bindings = useCombatStore((state) => state.actionBindings);
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
const [bar, setBar] = useState(0);
const [layer, setLayer] = useState<ActionBindingLayer>("primary");
const [editingControl, setEditingControl] = useState<BindingControl | null>(null);
const [restoreRequest, setRestoreRequest] = useState<RestoreRequest | null>(null);
const [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
const classColor = classById(classId).color;
const isWow = layout === "wow";
const assignedIds = assignedActionBarAbilityIds(pcLayout, bindings, isWow);
const isMissing = (ability: AbilityDefinition) => ability.unlockLevel <= level
&& (!abilityTalentNodeId(ability) || Boolean(learnedAbilityRank(ability, talentRanks)))
&& !assignedIds.has(ability.id);
const assignment = (control: BindingControl) => typeof control === "number"
? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)];
const controlLabel = (control: BindingControl) => typeof control === "number"
? `Bar ${bar + 1} · Button ${control + 1}${pcLayout.bars[bar].slots[control].key ? ` · ${actionKeyLabel(pcLayout.bars[bar].slots[control].key)}` : ""}`
: bindingChord(layer, control);
const editingAbilityId = editingControl !== null ? assignment(editingControl) : null;
const pickerAbilities = useMemo(() => {
const ordered = abilities
.filter((ability) => !ability.passive)
.sort((left, right) => left.unlockLevel - right.unlockLevel);
return editingAbilityId
? [
...ordered.filter((ability) => ability.id === editingAbilityId),
...ordered.filter((ability) => ability.id !== editingAbilityId),
]
: ordered;
}, [abilities, editingAbilityId]);
const close = onClose ?? (() => openOverlay("pause"));
const saveWowLayout = (update: (current: UiLayout) => UiLayout) => {
try {
const ui = useUiLayoutStore.getState();
ui.persist({ ...ui.preferences, current: { ...ui.preferences.current, pc: update(ui.preferences.current.pc) } });
return true;
} catch (error) {
setNotice(error instanceof Error ? error.message : "Could not save WoW ability bindings.");
return false;
}
};
const setBinding = (control: BindingControl, abilityId: string | null) => {
if (typeof control === "number") return saveWowLayout((current) => assignDesktopAbility(current, bar, control, abilityId));
useCombatStore.getState().setActionBinding(layer, control, abilityId);
return true;
};
const chooseLayout = (next: BindingLayout) => {
setLayout(next); setEditingControl(null); setRestoreRequest(null);
setNotice(`Choose a ${next === "wow" ? "WoW bar" : "controller"} button, then choose its ability.`);
controller.select(`binding-layout-${next}`);
};
const chooseBar = (next: number) => {
setBar(next); setEditingControl(null); setRestoreRequest(null);
setNotice(`Bar ${next + 1} selected.`);
controller.select(barControlId(next));
};
const chooseLayer = (next: ActionBindingLayer) => {
setLayer(next);
setEditingControl(null);
setRestoreRequest(null);
setNotice(`${ACTION_LAYER_LABELS[next]} layer selected.`);
controller.select(layerControlId(next));
};
const beginEditing = (control: BindingControl) => {
const abilityId = assignment(control);
setEditingControl(control);
setRestoreRequest(null);
controller.select(abilityId ? choiceControlId(abilityId) : "binding-choice-empty");
};
const cancelEditing = () => {
if (editingControl === null) return;
const control = editingControl;
setEditingControl(null);
controller.select(slotControlId(control));
};
const assign = (abilityId: string | null) => {
if (editingControl === null) return;
const control = editingControl;
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
if (!setBinding(control, abilityId)) return;
setNotice(`${controlLabel(control)}: ${ability?.name ?? "Empty"}. Saved.`);
setEditingControl(null);
controller.select(slotControlId(control));
};
const clearSelected = (control: BindingControl | null = editingControl) => {
if (control === null) return;
if (!setBinding(control, null)) return;
setNotice(`${controlLabel(control)} cleared. Saved.`);
setEditingControl(null);
controller.select(slotControlId(control));
};
const requestRestore = (scope: RestoreScope, focusId: string) => {
setRestoreRequest({
scope,
layout,
layer,
bar,
cancelFocusId: focusId,
completeFocusId: editingControl !== null ? slotControlId(editingControl) : focusId,
});
controller.select("binding-confirm-cancel");
};
const cancelRestore = () => {
if (!restoreRequest) return;
const focusId = restoreRequest.cancelFocusId;
setRestoreRequest(null);
controller.select(focusId);
};
const confirmRestore = () => {
if (!restoreRequest) return;
const request = restoreRequest;
if (request.layout === "wow") {
const defaults = defaultActionBarForCharacter(classId, secondaryClassId, level);
if (!saveWowLayout((current) => resetDesktopAbilities(current, defaults, request.scope === "layer" ? request.bar : undefined))) return;
setNotice(request.scope === "layer" ? `Bar ${request.bar + 1} abilities restored. Saved.` : "All WoW bar abilities restored. Saved.");
} else if (request.scope === "layer") {
useCombatStore.getState().resetActionBindingLayer(request.layer);
setNotice(`${ACTION_LAYER_LABELS[request.layer]} layer restored.`);
} else {
useCombatStore.getState().resetActionBindings();
setNotice("All controller ability bindings restored.");
}
setEditingControl(null);
setRestoreRequest(null);
controller.select(request.completeFocusId);
};
const actions = useMemo<MenuAction[]>(() => {
if (restoreRequest) {
return [
{
id: "binding-confirm-cancel",
run: cancelRestore,
neighbors: { left: "binding-confirm-restore", right: "binding-confirm-restore" },
},
{
id: "binding-confirm-restore",
run: confirmRestore,
neighbors: { left: "binding-confirm-cancel", right: "binding-confirm-cancel" },
},
];
}
if (editingControl !== null) {
return [
...pickerAbilities.map((ability) => ({ id: choiceControlId(ability.id), run: () => assign(ability.id) })),
{ id: "binding-choice-empty", run: () => assign(null) },
];
}
return [
...(["controller", "wow"] as const).map((next) => ({
id: `binding-layout-${next}`, run: () => chooseLayout(next),
neighbors: isWow ? { up: "binding-restore-all", down: barControlId(bar), left: `binding-layout-${next === "wow" ? "controller" : "wow"}`, right: `binding-layout-${next === "wow" ? "controller" : "wow"}` } : MAIN_ACTION_NEIGHBORS[`binding-layout-${next}`],
})),
...(isWow ? [
...pcLayout.bars.map((_, index) => ({ id: barControlId(index), run: () => chooseBar(index), neighbors: {
up: "binding-layout-wow", down: slotControlId(index), left: barControlId((index + 4) % 5), right: barControlId((index + 1) % 5),
} })),
...pcLayout.bars[bar].slots.map((_, slot) => ({ id: slotControlId(slot), run: () => beginEditing(slot), neighbors: {
up: slot < 4 ? barControlId(bar) : slotControlId(slot - 4),
down: slot >= 8 ? "binding-restore-layer" : slotControlId(slot + 4),
left: slotControlId((slot + 11) % 12), right: slotControlId((slot + 1) % 12),
} })),
] : [
...ACTION_LAYERS.map((nextLayer) => {
const id = layerControlId(nextLayer);
return { id, run: () => chooseLayer(nextLayer), neighbors: MAIN_ACTION_NEIGHBORS[id] };
}),
...ACTION_CONTROLS.map((control) => {
const id = slotControlId(control);
return { id, run: () => beginEditing(control), neighbors: MAIN_ACTION_NEIGHBORS[id] };
}),
]),
{
id: "binding-restore-layer",
run: () => requestRestore("layer", "binding-restore-layer"),
neighbors: isWow ? { up: slotControlId(8), down: "binding-layout-controller", left: "binding-restore-all", right: "binding-restore-all" } : MAIN_ACTION_NEIGHBORS["binding-restore-layer"],
},
{
id: "binding-restore-all",
run: () => requestRestore("all", "binding-restore-all"),
neighbors: isWow ? { up: slotControlId(11), down: "binding-layout-wow", left: "binding-restore-layer", right: "binding-restore-layer" } : MAIN_ACTION_NEIGHBORS["binding-restore-all"],
},
];
}, [abilities, bindings, classId, secondaryClassId, level, editingControl, layer, layout, bar, pcLayout, pickerAbilities, restoreRequest]);
const controller = useMenuController(actions, {
columns: restoreRequest ? 2 : editingControl !== null ? 1 : isWow ? 4 : 3,
initialId: restoreRequest
? "binding-confirm-cancel"
: editingControl !== null
? editingAbilityId ? choiceControlId(editingAbilityId) : "binding-choice-empty"
: isWow ? barControlId(bar) : layerControlId(layer),
onBack: restoreRequest ? cancelRestore : editingControl !== null ? cancelEditing : close,
});
useEffect(() => {
panel.current?.querySelector(".is-controller-selected")?.scrollIntoView({ block: "nearest" });
}, [controller.selectedId]);
useEffect(() => {
const run = (id: string | undefined) => {
if (restoreRequest) return;
if (id === "editor-layer-2") { if (isWow) chooseBar(1); else chooseLayer("secondary"); }
else if (id === "editor-layer-3") { if (isWow) chooseBar(2); else chooseLayer("tertiary"); }
else if (id === "editor-clear") {
if (editingControl !== null) clearSelected(editingControl);
else {
const prefix = isWow ? "binding-wow-slot-" : "binding-slot-";
if (controller.selectedId.startsWith(prefix)) clearSelected(isWow ? Number(controller.selectedId.slice(prefix.length)) : controller.selectedId.slice(prefix.length) as ActionBindingControl);
}
} else if (id === "editor-reset") requestRestore("layer", controller.selectedId);
};
const unsubscribe = subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false || repeat || isCapturingInput()) return;
run(INPUT_ACTIONS.find((entry) => entry.context === "ability-editor" && controllerBindingMatches(getInputPreferences().controller[entry.id], token, getControllerHeldTokens()))?.id);
});
const onKey = (event: KeyboardEvent) => {
if (event.repeat || event.defaultPrevented || isCapturingInput() || (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable='true']"))) return;
const key = inputKeyForEvent(event);
const id = key && INPUT_ACTIONS.find((entry) => entry.context === "ability-editor" && getInputPreferences().pc[entry.id] === key)?.id;
if (id) { event.preventDefault(); run(id); }
};
window.addEventListener("keydown", onKey);
return () => { unsubscribe(); window.removeEventListener("keydown", onKey); };
}, [controller.selectedId, editingControl, layer, layout, bar, pcLayout, restoreRequest]);
const bindingSlot = (control: ActionBindingControl) => {
const definition = ACTION_CONTROL_DEFINITIONS[control];
const abilityId = bindings[actionBindingId(layer, control)];
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
const locked = Boolean(ability && ability.unlockLevel > level);
return (
<AbilityTooltipTarget key={control} ability={tooltipData(ability)} bindingLabel={bindingChord(layer, control)}>
<ControllerButton
key={control}
controlId={slotControlId(control)}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={`ability-binding-slot ability-binding-slot--${control} ${editingControl === control ? "is-editing" : ""} ${locked ? "is-locked" : ""}`}
aria-label={`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-pressed={editingControl === control}
onClick={() => beginEditing(control)}
>
<kbd>{keyLabel("controller", `ability:${layer}:${control}`)}</kbd>
{ability
? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
</ControllerButton>
</AbilityTooltipTarget>
);
};
return (
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section ref={panel} className="game-panel ability-bindings-panel" role="dialog" aria-modal="true" aria-labelledby="ability-bindings-title">
<header className="game-panel__header">
<div>
<p className="eyebrow">{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}</p>
<h2 id="ability-bindings-title">Ability controls</h2>
</div>
<button type="button" className="icon-button" onClick={close} aria-label="Close button assignments">×</button>
</header>
<nav className="ability-bindings__layouts" aria-label="Action bar style">
{(["controller", "wow"] as const).map((next) => (
<ControllerButton key={next} controlId={`binding-layout-${next}`} selectedId={controller.selectedId} select={controller.select}
type="button" aria-pressed={layout === next} className={layout === next ? "is-active" : ""} onClick={() => chooseLayout(next)}>
{next === "wow" ? "WoW bars" : "Controller bars"}
</ControllerButton>
))}
</nav>
{isWow ? <nav className="ability-bindings__layers ability-bindings__bars" aria-label="WoW action bars">
{pcLayout.bars.map((_, index) => (
<ControllerButton key={index} controlId={barControlId(index)} selectedId={controller.selectedId} select={controller.select}
type="button" aria-pressed={bar === index} className={bar === index ? "is-active" : ""} onClick={() => chooseBar(index)}>
<kbd>{index + 1}</kbd><span><strong>Bar {index + 1}</strong><small>{pcLayout.wowBars && index < pcLayout.barCount ? "Enabled" : "Inactive"}</small></span>
</ControllerButton>
))}
</nav> : <nav className="ability-bindings__layers" aria-label="Ability binding layers">
{ACTION_LAYERS.map((nextLayer) => (
<ControllerButton
key={nextLayer}
controlId={layerControlId(nextLayer)}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={layer === nextLayer ? "is-active" : ""}
aria-pressed={layer === nextLayer}
onClick={() => chooseLayer(nextLayer)}
>
<kbd>{nextLayer === "primary" ? "—" : ACTION_LAYER_LABELS[nextLayer]}</kbd>
<span><strong>{ACTION_LAYER_LABELS[nextLayer]}</strong><small>{nextLayer === "primary" ? "No modifier" : `Hold ${ACTION_LAYER_LABELS[nextLayer]}`}</small></span>
</ControllerButton>
))}
</nav>}
<div className="ability-bindings__layout">
{isWow ? <section className="ability-bindings__wow" aria-label={`Bar ${bar + 1} ability assignments`}>
<div className="ability-bindings__wow-slots">
{pcLayout.bars[bar].slots.map((slot, index) => {
const ability = abilities.find((candidate) => candidate.id === slot.abilityId);
const locked = Boolean(ability && ability.unlockLevel > level);
const name = ability?.name ?? (slot.abilityId ? "Unavailable for this character" : "Empty");
return <AbilityTooltipTarget key={index} ability={tooltipData(ability)} bindingLabel={controlLabel(index)}>
<ControllerButton controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
type="button" className={`ability-binding-slot ${locked ? "is-locked" : ""} ${editingControl === index ? "is-editing" : ""}`}
aria-label={`${controlLabel(index)}: ${name}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-pressed={editingControl === index} title={`${controlLabel(index)}: ${name}`} onClick={() => beginEditing(index)}>
<kbd>{actionKeyLabel(slot.key) || "—"}</kbd>
{ability ? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} /> : <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>Button {index + 1}</strong><small>{locked ? `${name} · Level ${ability!.unlockLevel}` : name}</small></span>
</ControllerButton></AbilityTooltipTarget>;
})}
</div>
<p className="ability-bindings__wow-note">{!pcLayout.wowBars ? "Enable WoW action bars in UI layout to use these assignments on PC." : bar >= pcLayout.barCount ? "This bar is inactive. Increase the visible bar count in UI layout to use it." : "Choose a button to change its ability. Changes save immediately."}</p>
<div className="ability-bindings__restore">
<ControllerButton controlId="binding-restore-layer" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("layer", "binding-restore-layer")}>Restore Bar {bar + 1}</ControllerButton>
<ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all WoW bars</ControllerButton>
</div>
</section> : <section className="ability-bindings__diagram" aria-label={`${ACTION_LAYER_LABELS[layer]} controller layout`}>
<div className="ability-bindings__shoulders">
{bindingSlot("r1")}
{bindingSlot("r2")}
</div>
<div className="ability-bindings__dpad" aria-label="D-pad abilities">
{bindingSlot("dpad-left")}
<span aria-hidden="true">+</span>
{bindingSlot("dpad-right")}
</div>
<div className="ability-bindings__face" aria-label="Face button abilities">
{bindingSlot("face-top")}
{bindingSlot("face-right")}
{bindingSlot("face-bottom")}
{bindingSlot("face-left")}
</div>
<div className="ability-bindings__restore">
<ControllerButton controlId="binding-restore-layer" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("layer", "binding-restore-layer")}>Restore {ACTION_LAYER_LABELS[layer]}</ControllerButton>
<ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all</ControllerButton>
</div>
</section>}
<aside className="ability-bindings__picker" aria-live="polite">
{restoreRequest ? (
<>
<p className="eyebrow">Confirm {restoreRequest.layout === "wow" ? "WoW bar" : "controller"} reset</p>
<h3>{restoreRequest.layout === "wow"
? restoreRequest.scope === "all" ? "Restore all WoW bar abilities?" : `Restore Bar ${restoreRequest.bar + 1} abilities?`
: restoreRequest.scope === "all"
? "Restore all ability controls?"
: `Restore the ${ACTION_LAYER_LABELS[restoreRequest.layer]} layer?`}</h3>
<p className="ability-bindings__confirm-warning">{restoreRequest.layout === "wow"
? `This replaces ${restoreRequest.scope === "all" ? "all 60 WoW slots" : "this bar's 12 slots"} with the class defaults. Keyboard shortcuts and bar layout are kept.`
: restoreRequest.scope === "all"
? "This replaces every customized controller slot with the class defaults."
: `This replaces all eight ${ACTION_LAYER_LABELS[restoreRequest.layer]} slots with their class defaults.`}</p>
<div className="ability-bindings__choices ability-bindings__choices--confirm">
<ControllerButton controlId="binding-confirm-cancel" selectedId={controller.selectedId} select={controller.select} type="button" onClick={cancelRestore}>
<span className="ability-bindings__empty" aria-hidden="true">○</span>
<span><strong>Cancel</strong><small>Keep current bindings</small></span>
</ControllerButton>
<ControllerButton controlId="binding-confirm-restore" selectedId={controller.selectedId} select={controller.select} type="button" className="is-danger" onClick={confirmRestore}>
<span className="ability-bindings__empty" aria-hidden="true">△</span>
<span><strong>Restore defaults</strong><small>This cannot be undone</small></span>
</ControllerButton>
</div>
</>
) : editingControl !== null ? (
<>
<p className="eyebrow">Assign {controlLabel(editingControl)}</p>
<h3>Choose an ability</h3>
<p className="action-bar-missing-legend">Light blue: missing from action bars</p>
<div className="ability-bindings__choices">
{pickerAbilities.map((ability) => (
<AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""} ${isMissing(ability) ? "is-missing-from-bars" : ""}`} aria-label={`${ability.name}${isMissing(ability) ? ", missing from action bars" : ""}`} onClick={() => assign(ability.id)}>
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
</ControllerButton>
</AbilityTooltipTarget>
))}
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}>
<span className="ability-bindings__empty" aria-hidden="true">×</span>
<span><strong>Leave empty</strong><small>{editingAbilityId === null ? "Current assignment" : "Remove the current assignment"}</small></span>
</ControllerButton>
</div>
</>
) : (
<>
<p className="eyebrow">{isWow ? `Bar ${bar + 1}` : `${ACTION_LAYER_LABELS[layer]} layer`}</p>
<h3>Select a {isWow ? "WoW bar" : "controller"} button</h3>
<p>{isWow ? "Each WoW bar has 12 buttons. Assign abilities on any of the five bars, including inactive bars. Change keyboard shortcuts in Options → Keybinds." : "Each layer has the four face buttons, R1, R2, and both horizontal D-pad directions. Abilities may be reused on more than one button."}</p>
</>
)}
</aside>
</div>
<p className="ability-bindings__notice" role="status">{notice}</p>
<footer className="game-panel__footer">
{restoreRequest
? <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Choose</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Cancel</span></>
: <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Assign</span><span><kbd>{keyLabel("controller", "editor-clear")}</kbd> Clear</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Back</span><span><kbd>{keyLabel("controller", "editor-reset")}</kbd> Restore {isWow ? "bar" : "layer"}</span></>}
</footer>
</section>
</div>
);
}
export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const storeOpen = useGameStore((state) => state.overlay === "bindings");
return (open ?? storeOpen) ? <AbilityBindingsDialog onClose={onClose} /> : null;
}