Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
189 lines
5.2 KiB
TypeScript
189 lines
5.2 KiB
TypeScript
'use client'
|
|
|
|
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
|
|
import { MOTION, LAYERS } from './depth-system'
|
|
import { usePerformance } from './PerformanceContext'
|
|
|
|
/**
|
|
* Focus Context - Manages focus mode for the UI
|
|
*
|
|
* When an element enters "focus mode", the rest of the UI dims and blurs,
|
|
* creating a spotlight effect that helps users concentrate on the task at hand.
|
|
*
|
|
* This is particularly useful for:
|
|
* - Replying to messages
|
|
* - Editing content
|
|
* - Modal-like interactions without actual modals
|
|
*/
|
|
|
|
interface FocusContextType {
|
|
/** Whether focus mode is active */
|
|
isFocused: boolean
|
|
/** The ID of the focused element (if any) */
|
|
focusedElementId: string | null
|
|
/** Enter focus mode */
|
|
enterFocus: (elementId: string) => void
|
|
/** Exit focus mode */
|
|
exitFocus: () => void
|
|
/** Toggle focus mode */
|
|
toggleFocus: (elementId: string) => void
|
|
}
|
|
|
|
const FocusContext = createContext<FocusContextType | null>(null)
|
|
|
|
interface FocusProviderProps {
|
|
children: React.ReactNode
|
|
/** Duration of the focus transition in ms */
|
|
transitionDuration?: number
|
|
/** Blur amount for unfocused elements (px) */
|
|
blurAmount?: number
|
|
/** Dim amount for unfocused elements (0-1) */
|
|
dimAmount?: number
|
|
}
|
|
|
|
export function FocusProvider({
|
|
children,
|
|
transitionDuration = 300,
|
|
blurAmount = 4,
|
|
dimAmount = 0.6,
|
|
}: FocusProviderProps) {
|
|
const [isFocused, setIsFocused] = useState(false)
|
|
const [focusedElementId, setFocusedElementId] = useState<string | null>(null)
|
|
const { settings } = usePerformance()
|
|
|
|
const enterFocus = useCallback((elementId: string) => {
|
|
setFocusedElementId(elementId)
|
|
setIsFocused(true)
|
|
}, [])
|
|
|
|
const exitFocus = useCallback(() => {
|
|
setIsFocused(false)
|
|
// Delay clearing the ID to allow exit animation
|
|
setTimeout(() => {
|
|
setFocusedElementId(null)
|
|
}, transitionDuration)
|
|
}, [transitionDuration])
|
|
|
|
const toggleFocus = useCallback(
|
|
(elementId: string) => {
|
|
if (isFocused && focusedElementId === elementId) {
|
|
exitFocus()
|
|
} else {
|
|
enterFocus(elementId)
|
|
}
|
|
},
|
|
[isFocused, focusedElementId, enterFocus, exitFocus]
|
|
)
|
|
|
|
// Keyboard handler for Escape
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isFocused) {
|
|
exitFocus()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [isFocused, exitFocus])
|
|
|
|
// Adaptive values based on performance
|
|
const adaptiveBlur = settings.enableBlur ? blurAmount * settings.blurIntensity : 0
|
|
const adaptiveDuration = Math.round(transitionDuration * settings.animationSpeed)
|
|
|
|
return (
|
|
<FocusContext.Provider
|
|
value={{
|
|
isFocused,
|
|
focusedElementId,
|
|
enterFocus,
|
|
exitFocus,
|
|
toggleFocus,
|
|
}}
|
|
>
|
|
{/* Focus backdrop - dims and blurs unfocused content */}
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex: LAYERS.floating.zIndex,
|
|
pointerEvents: isFocused ? 'auto' : 'none',
|
|
opacity: isFocused ? 1 : 0,
|
|
backgroundColor: `rgba(0, 0, 0, ${isFocused ? dimAmount : 0})`,
|
|
backdropFilter: isFocused && adaptiveBlur > 0 ? `blur(${adaptiveBlur}px)` : 'none',
|
|
WebkitBackdropFilter: isFocused && adaptiveBlur > 0 ? `blur(${adaptiveBlur}px)` : 'none',
|
|
transition: `
|
|
opacity ${adaptiveDuration}ms ${MOTION.standard.easing},
|
|
backdrop-filter ${adaptiveDuration}ms ${MOTION.standard.easing}
|
|
`,
|
|
}}
|
|
onClick={exitFocus}
|
|
aria-hidden={!isFocused}
|
|
/>
|
|
|
|
{children}
|
|
</FocusContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useFocus() {
|
|
const context = useContext(FocusContext)
|
|
if (!context) {
|
|
return {
|
|
isFocused: false,
|
|
focusedElementId: null,
|
|
enterFocus: () => {},
|
|
exitFocus: () => {},
|
|
toggleFocus: () => {},
|
|
}
|
|
}
|
|
return context
|
|
}
|
|
|
|
/**
|
|
* Hook to check if a specific element is focused
|
|
*/
|
|
export function useIsFocused(elementId: string): boolean {
|
|
const { isFocused, focusedElementId } = useFocus()
|
|
return isFocused && focusedElementId === elementId
|
|
}
|
|
|
|
/**
|
|
* FocusTarget - Wrapper that makes children focusable
|
|
*/
|
|
interface FocusTargetProps {
|
|
children: React.ReactNode
|
|
/** Unique ID for this focus target */
|
|
id: string
|
|
/** Additional class names */
|
|
className?: string
|
|
/** Style overrides */
|
|
style?: React.CSSProperties
|
|
}
|
|
|
|
export function FocusTarget({ children, id, className = '', style }: FocusTargetProps) {
|
|
const { isFocused, focusedElementId } = useFocus()
|
|
const { settings } = usePerformance()
|
|
|
|
const isThisElement = focusedElementId === id
|
|
const shouldElevate = isFocused && isThisElement
|
|
|
|
const duration = Math.round(MOTION.emphasis.duration * settings.animationSpeed)
|
|
|
|
return (
|
|
<div
|
|
className={className}
|
|
style={{
|
|
...style,
|
|
position: 'relative',
|
|
zIndex: shouldElevate ? LAYERS.overlay.zIndex + 1 : 'auto',
|
|
transition: `z-index ${duration}ms ${MOTION.standard.easing}`,
|
|
}}
|
|
data-focus-target={id}
|
|
data-focused={shouldElevate}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|