diff --git a/src/components/AmbassadorCard.tsx b/src/components/AmbassadorCard.tsx index 16bf92ff..4749e09a 100644 --- a/src/components/AmbassadorCard.tsx +++ b/src/components/AmbassadorCard.tsx @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useRef, type Ref } from "react"; +import { + useCallback, + useEffect, + useRef, + type KeyboardEventHandler, + type Ref, +} from "react"; import type { CreateTypes } from "canvas-confetti"; import Confetti from "react-canvas-confetti"; @@ -28,6 +34,7 @@ const stringifyLifespan = (value: number | { min: number; max: number }) => { export interface AmbassadorCardProps { ambassador: string; onClose?: () => void; + onKeyDown?: KeyboardEventHandler; className?: string; ref?: Ref; disableCardEffects?: boolean; @@ -131,6 +138,7 @@ export default function AmbassadorCard(props: AmbassadorCardProps) { className, )} ref={callbackRef} + tabIndex={-1} // Overlay view needs this for the onKeyDown listener {...extras} > {birthday && ( @@ -141,13 +149,14 @@ export default function AmbassadorCard(props: AmbassadorCardProps) { /> )} {ambassador.image.alt}
@@ -166,7 +175,10 @@ export default function AmbassadorCard(props: AmbassadorCardProps) { {ambassador.name}
-
+
{mod && (
diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 69fdd2cd..0584179e 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { classes } from "../utils/classes"; import Ring from "./Ring"; @@ -6,10 +7,17 @@ interface CardProps { children?: React.ReactNode; title?: string; className?: string; + autoFocus?: boolean; + tabIndex?: number | undefined; } export default function Card(props: CardProps) { - const { children, title, className } = props; + const { children, title, className, autoFocus, tabIndex } = props; + + const autoFocusCallback = useCallback((node: HTMLDivElement | null) => { + // node?.focus(); + setTimeout(() => node?.focus(), 10); + }, []); return (
{title && (

diff --git a/src/components/TiltCard.tsx b/src/components/TiltCard.tsx index 9c79ad55..e4c2e004 100644 --- a/src/components/TiltCard.tsx +++ b/src/components/TiltCard.tsx @@ -11,6 +11,7 @@ import { classes } from "../utils/classes"; interface ConditionalTiltCardProps extends TiltCardProps { disabled?: boolean; + tabIndex?: number | undefined; } function ConditionalTiltCard({ diff --git a/src/components/Welcome.tsx b/src/components/Welcome.tsx index 3d2bbb2c..aff2787c 100644 --- a/src/components/Welcome.tsx +++ b/src/components/Welcome.tsx @@ -19,10 +19,11 @@ const socialClass = interface WelcomeProps { className?: string; + isActiveOverlay?: boolean; } export default function Welcome(props: WelcomeProps) { - const { className } = props; + const { className, isActiveOverlay } = props; const channel = useChannel(); const nonDefault = useMemo( @@ -31,7 +32,12 @@ export default function Welcome(props: WelcomeProps) { ); return ( - +

Alveus Sanctuary is a 501(c)(3) non-profit organization that functions as a wildlife sanctuary and as a virtual education center. These diff --git a/src/pages/overlay/App.tsx b/src/pages/overlay/App.tsx index 42cbecb6..d06c11fe 100644 --- a/src/pages/overlay/App.tsx +++ b/src/pages/overlay/App.tsx @@ -19,6 +19,8 @@ export default function App() { sleeping, wake, sleep, + caffeinate, + uncaffeinate, on: addSleepListener, off: removeSleepListener, } = useSleeping(); @@ -52,8 +54,16 @@ export default function App() { onMouseMove={interacted} onWheel={interacted} onTouchMove={interacted} - onKeyDown={interacted} + onKeyUp={(event) => { + // onFocus events capture when we click on the element in addition to + // tabbing + if (event.key === "Tab") { + caffeinate(); + } + }} + onKeyDown={() => interacted()} onMouseLeave={sleep} + onBlur={() => uncaffeinate(timeout)} >

diff --git a/src/pages/overlay/components/Buttons.tsx b/src/pages/overlay/components/Buttons.tsx index a416a66b..bec60db0 100644 --- a/src/pages/overlay/components/Buttons.tsx +++ b/src/pages/overlay/components/Buttons.tsx @@ -1,4 +1,4 @@ -import { useMemo, type JSX } from "react"; +import { useMemo, type JSX, type MouseEvent } from "react"; import Tooltip from "../../../components/Tooltip"; import Ring from "../../../components/Ring"; @@ -16,7 +16,7 @@ type ButtonsOptions = Readonly; interface ButtonsProps { options: T; - onClick: (key: T[number]["key"] | "") => void; + onClick: (event: MouseEvent, key: T[number]["key"] | "") => void; active?: string; } @@ -31,7 +31,8 @@ export default function Buttons( options .map((option) => ({ ...option, - onClick: () => onClick(active === option.key ? "" : option.key), + onClick: (event: MouseEvent) => + onClick(event, active === option.key ? "" : option.key), active: active === option.key, })) .sort((a, b) => { diff --git a/src/pages/overlay/components/overlay/Ambassadors.tsx b/src/pages/overlay/components/overlay/Ambassadors.tsx index 97763a87..a3e7540e 100644 --- a/src/pages/overlay/components/overlay/Ambassadors.tsx +++ b/src/pages/overlay/components/overlay/Ambassadors.tsx @@ -5,6 +5,7 @@ import { useMemo, useRef, type MouseEvent, + type KeyboardEventHandler, } from "react"; import AmbassadorButton from "../../../../components/AmbassadorButton"; @@ -21,6 +22,8 @@ import type { OverlayOptionProps } from "./Overlay"; import IconChevron from "../../../../components/icons/IconChevron"; import useSettings from "../../hooks/useSettings"; +import * as keyBinds from "../../../../utils/keyBinds"; + const arrowClass = "absolute border-0 cursor-pointer text-alveus-green w-full h-[var(--list-fade-padding)] z-20 transition-opacity group pt-[var(--twitch-vertical-padding)] pb-4 box-content"; const arrowSvgClass = @@ -53,6 +56,8 @@ export default function Ambassadors(props: AmbassadorsProps) { [rawAmbassadors, plants, settings.ambassadorSort.value], ); + const activeAmbassadorButtonRef = useRef(null); + const activeAmbassadorRef = useRef(null); const upArrowRef = useRef(null); const ambassadorList = useRef(null); const downArrowRef = useRef(null); @@ -128,6 +133,54 @@ export default function Ambassadors(props: AmbassadorsProps) { return () => window.removeEventListener("resize", handleArrowVisibility); }, [handleArrowVisibility, ambassadors]); + const ambassadorListCallback = useCallback( + (node: HTMLDivElement | null) => { + if (node && props.isActiveOverlay) { + // node.focus(); + setTimeout(() => node.focus(), 10); + } + + ambassadorList.current = node; + }, + [props.isActiveOverlay], + ); + + const activeAmbassadorOnClose = () => { + setActiveAmbassador({}); + activeAmbassadorButtonRef.current?.focus(); + activeAmbassadorButtonRef.current = null; + activeAmbassadorRef.current = null; + }; + + const activeAmbassadorOnKeyDown: KeyboardEventHandler = (event) => { + if (event.defaultPrevented) { + return; + } + + // Select keys used here to mimic default browser behavior. If you press + // enter or space, it counts as clicking the ambassador's button again to + // close it. + if ( + keyBinds.BACK.includes(event.code as keyBinds.KeyCode) || + (keyBinds.SELECT.includes(event.code as keyBinds.KeyCode) && + event.target === activeAmbassadorRef.current) + ) { + event.preventDefault(); + activeAmbassadorOnClose(); + return; + } + }; + + const activeAmbassadorRefCallback = useCallback( + (node: HTMLDivElement | null) => { + activeAmbassadorRef.current = node; + + // Auto focus the ambassador that's currently active + node?.focus(); + }, + [], + ); + return (
{ambassadors.map(([key]) => ( { + onClick={(event) => { + if (event.target instanceof HTMLButtonElement) { + activeAmbassadorButtonRef.current = event.target; + } + setActiveAmbassador((prev) => prev.key === key ? {} : { key }, ); @@ -166,6 +224,7 @@ export default function Ambassadors(props: AmbassadorsProps) { onClick={(e) => ambassadorListScroll(e, 250)} title="Scroll up" type="button" + tabIndex={-1} data-transparent-clicks > @@ -180,6 +239,7 @@ export default function Ambassadors(props: AmbassadorsProps) { onClick={(e) => ambassadorListScroll(e, -250)} title="Scroll down" type="button" + tabIndex={-1} data-transparent-clicks > @@ -192,7 +252,17 @@ export default function Ambassadors(props: AmbassadorsProps) { setActiveAmbassador({})} + onClose={activeAmbassadorOnClose} + onKeyDown={ + activeAmbassador.key === key + ? activeAmbassadorOnKeyDown + : undefined + } + ref={ + activeAmbassador.key === key + ? activeAmbassadorRefCallback + : undefined + } disableCardEffects={settings.disableCardEffects.value} />
diff --git a/src/pages/overlay/components/overlay/Overlay.tsx b/src/pages/overlay/components/overlay/Overlay.tsx index 3808a2be..fd5be189 100644 --- a/src/pages/overlay/components/overlay/Overlay.tsx +++ b/src/pages/overlay/components/overlay/Overlay.tsx @@ -7,6 +7,7 @@ import { type SetStateAction, type Dispatch, type JSX, + type KeyboardEventHandler, } from "react"; import Welcome from "../../../../components/Welcome"; @@ -32,6 +33,8 @@ import SettingsOverlay from "./Settings"; import Buttons, { type ButtonsOption } from "../Buttons"; +import * as keyBinds from "../../../../utils/keyBinds"; + // Show command-triggered popups for 10s const commandTimeout = 10_000; @@ -102,6 +105,7 @@ export interface OverlayOptionProps { setActiveAmbassador: Dispatch>; }; className?: string; + isActiveOverlay: boolean; } const hiddenClass = @@ -135,6 +139,8 @@ export default function Overlay() { const timeoutRef = useRef(null); const awakingRef = useRef(false); + const activeOverlayButtonRef = useRef(null); + // update setting when opened menu changes useEffect(() => { settings.openedMenu.change(visibleOption); @@ -145,50 +151,51 @@ export default function Overlay() { setVisibleOption(settings.openedMenu.value); }, [settings.openedMenu.value]); + // TODO(flakey5) move this elsewhere // When a chat command is run, wake the overlay - useChatCommand( - useCallback( - (command: string) => { - if (command === "refresh") { - setTimeout( - () => { - refresh?.(); - }, - Math.floor(Math.random() * 120 * 1000), - ); - return; - } - - if (!settings.disableChatPopup.value) { - const ambassador = ambassadors?.[command]; - if (ambassador) - setActiveAmbassador({ key: command, isCommand: true }); - else if (command !== "welcome") return; - - // Show the card - setVisibleOption( - ambassador - ? ambassador.species.class.key === "plantae" - ? "ambassadorPlants" - : "ambassadors" - : "welcome", - ); - - // Dismiss the overlay after a delay - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - setVisibleOption(""); - setActiveAmbassador({}); - }, commandTimeout); - - // Track that we're waking up, so that we don't immediately clear the timeout, and wake the overlay - awakingRef.current = true; - wake(commandTimeout); - } - }, - [refresh, settings.disableChatPopup.value, ambassadors, wake], - ), - ); + // useChatCommand( + // useCallback( + // (command: string) => { + // if (command === "refresh") { + // setTimeout( + // () => { + // refresh?.(); + // }, + // Math.floor(Math.random() * 120 * 1000), + // ); + // return; + // } + + // if (!settings.disableChatPopup.value) { + // const ambassador = ambassadors?.[command]; + // if (ambassador) + // setActiveAmbassador({ key: command, isCommand: true }); + // else if (command !== "welcome") return; + + // // Show the card + // setVisibleOption( + // ambassador + // ? ambassador.species.class.key === "plantae" + // ? "ambassadorPlants" + // : "ambassadors" + // : "welcome", + // ); + + // // Dismiss the overlay after a delay + // if (timeoutRef.current) clearTimeout(timeoutRef.current); + // timeoutRef.current = setTimeout(() => { + // setVisibleOption(""); + // setActiveAmbassador({}); + // }, commandTimeout); + + // // Track that we're waking up, so that we don't immediately clear the timeout, and wake the overlay + // awakingRef.current = true; + // wake(commandTimeout); + // } + // }, + // [refresh, settings.disableChatPopup.value, ambassadors, wake], + // ), + // ); // Ensure we clean up the timer when we unmount useEffect( @@ -258,6 +265,30 @@ export default function Overlay() { } }, [ambassadors, activeAmbassador.key]); + const onKeyDown: KeyboardEventHandler = (event) => { + if (event.defaultPrevented) { + return; + } + + if ( + visibleOption !== "" && + keyBinds.BACK.includes(event.code as keyBinds.KeyCode) + ) { + // Close current overlay + setVisibleOption(""); + + // De-select active ambassador in case this got triggered while one is + // selected + setActiveAmbassador({}); + + activeOverlayButtonRef.current?.focus(); + activeOverlayButtonRef.current = null; + + event.preventDefault(); + return; + } + }; + return (
{ + if (event.target instanceof HTMLButtonElement) { + activeOverlayButtonRef.current = event.target; + } + + setVisibleOption(option); + }} active={visibleOption} />
@@ -284,6 +322,7 @@ export default function Overlay() { "transition-[opacity,visibility,transform,translate] will-change-[opacity,transform,translate]", visibleOption !== option.key && hiddenClass, )} + isActiveOverlay={visibleOption === option.key} /> ))}
diff --git a/src/pages/overlay/components/overlay/Settings.tsx b/src/pages/overlay/components/overlay/Settings.tsx index 719cdb39..2f4d5307 100644 --- a/src/pages/overlay/components/overlay/Settings.tsx +++ b/src/pages/overlay/components/overlay/Settings.tsx @@ -10,13 +10,14 @@ import Toggle from "../Toggle"; import type { OverlayOptionProps } from "./Overlay"; export default function Settings(props: OverlayOptionProps) { - const { className } = props; + const { className, isActiveOverlay } = props; const settings = useSettings(); return (
    {typeSafeObjectEntries(settings).map(([key, setting]) => { diff --git a/src/pages/overlay/hooks/useHiddenCursor.ts b/src/pages/overlay/hooks/useHiddenCursor.ts index 42a46015..8dc2f11d 100644 --- a/src/pages/overlay/hooks/useHiddenCursor.ts +++ b/src/pages/overlay/hooks/useHiddenCursor.ts @@ -12,9 +12,11 @@ const useHiddenCursor = () => { // Show the cursor for x milliseconds const show = useCallback( - (time: number) => { + (time: number | undefined) => { setHidden(false); - startTimer(() => setHidden(true), time); + if (time !== undefined) { + startTimer(() => setHidden(true), time); + } }, [startTimer], ); diff --git a/src/pages/overlay/hooks/useSleeping.tsx b/src/pages/overlay/hooks/useSleeping.tsx index 3444db20..e889957f 100644 --- a/src/pages/overlay/hooks/useSleeping.tsx +++ b/src/pages/overlay/hooks/useSleeping.tsx @@ -10,7 +10,7 @@ import { import useIntelligentTimer from "./useIntelligentTimer"; type Events = { - wake: ((time: number) => void) | (() => void); + wake: ((time: number | undefined) => void) | (() => void); sleep: () => void; }; @@ -20,6 +20,8 @@ export type Sleeping = { sleeping: boolean; wake: (time: number) => void; sleep: () => void; + caffeinate: () => void; + uncaffeinate: (time: number) => void; on: (event: Event, fn: Events[Event]) => void; off: (event: Event, fn: Events[Event]) => void; }; @@ -29,6 +31,7 @@ const Context = createContext(undefined); export const SleepingProvider = ({ children }: { children: ReactNode }) => { const [startTimer, stopTimer] = useIntelligentTimer(); const [sleeping, setSleeping] = useState(false); + const [isCaffeinated, setCaffeinated] = useState(false); // Allow subscriptions to wake/sleep events // This allows logic to know when the overlay was re-awoken, even if it was already awake @@ -49,19 +52,60 @@ export const SleepingProvider = ({ children }: { children: ReactNode }) => { // Wake the overlay for x milliseconds const wake = useCallback( (time: number) => { + if (isCaffeinated) { + return; + } + + console.debug("wake"); + setSleeping(false); callbacks.wake.forEach((fn) => fn(time)); startTimer(() => setSleeping(true), time); }, - [callbacks.wake, startTimer], + [callbacks.wake, startTimer, isCaffeinated], ); // Immediately sleep the overlay const sleep = useCallback(() => { + if (isCaffeinated) { + return; + } + + console.debug("sleep"); + setSleeping(true); callbacks.sleep.forEach((fn) => fn()); stopTimer(); - }, [callbacks.sleep, stopTimer]); + }, [callbacks.sleep, stopTimer, isCaffeinated]); + + // Pause the timer and keep the overlay awake + const caffeinate = useCallback(() => { + if (isCaffeinated) { + return; + } + + console.debug("caffeinate"); + + setCaffeinated(true); + setSleeping(false); + // callbacks.wake.forEach((fn) => fn(undefined)); + stopTimer(); + }, [stopTimer, isCaffeinated]); + + // Resume the timer + const uncaffeinate = useCallback( + (time: number) => { + if (!isCaffeinated) { + return; + } + + console.debug("uncaffeinate"); + + setCaffeinated(false); + startTimer(() => sleep, time); + }, + [startTimer, isCaffeinated], + ); // Expose the full object for sleeping const obj = useMemo( @@ -69,6 +113,8 @@ export const SleepingProvider = ({ children }: { children: ReactNode }) => { sleeping, wake, sleep, + caffeinate, + uncaffeinate, on, off, }), diff --git a/src/utils/keyBinds.ts b/src/utils/keyBinds.ts new file mode 100644 index 00000000..a316206b --- /dev/null +++ b/src/utils/keyBinds.ts @@ -0,0 +1,4 @@ +export type KeyCode = "Escape" | "Enter" | "Space"; + +export const BACK: KeyCode[] = ["Escape"]; +export const SELECT: KeyCode[] = ["Enter", "Space"];