Panel: add peeking support to the store

This commit is contained in:
Enrico Ros
2025-06-15 11:29:11 -07:00
parent e1dd9c0117
commit 61366b7096
2 changed files with 66 additions and 53 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
// configuration
export const OPTIMA_DRAWER_BACKGROUND = 'var(--joy-palette-background-popup)';
export const OPTIMA_DRAWER_MOBILE_RADIUS = 'var(--joy-radius-lg)';
export const OPTIMA_DRAWER_HOVER_TIMEOUT = 300; // ms - exit delay: time to hide after mouse leaves
export const OPTIMA_DRAWER_HOVER_ENTER_DELAY = 200; // ms - enter delay: prevents accidental triggers
export const OPTIMA_DRAWER_OPEN_DEBOUNCE = 100; // ms
export const OPTIMA_OPEN_DEBOUNCE = 100; // ms - prevent accidental immediate close
export const OPTIMA_PEEK_HOVER_ENTER_DELAY = 200; // ms - enter delay: prevents accidental triggers
export const OPTIMA_PEEK_HOVER_TIMEOUT = 300; // ms - exit delay: time to hide after mouse leaves
export const OPTIMA_NAV_RADIUS = 'sm';
export const OPTIMA_PANEL_GROUPS_SPACING = 2.5;
+63 -50
View File
@@ -5,7 +5,7 @@ import { getIsMobile } from '~/common/components/useMatchMedia';
import { isBrowser } from '~/common/util/pwaUtils';
import { navItems } from '~/common/app.nav';
import { OPTIMA_DRAWER_HOVER_ENTER_DELAY, OPTIMA_DRAWER_HOVER_TIMEOUT, OPTIMA_DRAWER_OPEN_DEBOUNCE } from './optima.config';
import { OPTIMA_OPEN_DEBOUNCE, OPTIMA_PEEK_HOVER_ENTER_DELAY, OPTIMA_PEEK_HOVER_TIMEOUT } from './optima.config';
export type PreferencesTabId = 'chat' | 'voice' | 'draw' | 'tools' | undefined;
@@ -20,6 +20,7 @@ interface OptimaState {
drawerIsOpen: boolean;
drawerIsPeeking: boolean;
panelIsOpen: boolean;
panelIsPeeking: boolean;
// modals
showAIXDebugger: boolean;
@@ -63,6 +64,7 @@ const initialState: OptimaState = {
drawerIsOpen: initialDrawerOpen(),
drawerIsPeeking: false,
panelIsOpen: false,
panelIsPeeking: false,
// modals that can overlay anything
...modalsClosedState,
@@ -86,6 +88,8 @@ export interface OptimaActions {
closePanel: () => void;
openPanel: () => void;
togglePanel: () => void;
peekPanelEnter: () => void;
peekPanelLeave: () => void;
closeAIXDebugger: () => void;
openAIXDebugger: () => void;
@@ -108,19 +112,50 @@ export interface OptimaActions {
}
// global state: peek hover logic timers
let peekEnterTimeoutId: ReturnType<typeof setTimeout> | null = null;
let peekLeaveTimeoutId: ReturnType<typeof setTimeout> | null = null;
const drawerPeek = createPeekHandlers('drawerIsOpen', 'drawerIsPeeking');
const panelPeek = createPeekHandlers('panelIsOpen', 'panelIsPeeking');
function cancelPeekTimers(which?: 'enter' | 'leave') {
if (which !== 'leave' && peekEnterTimeoutId) {
clearTimeout(peekEnterTimeoutId);
peekEnterTimeoutId = null;
}
if (which !== 'enter' && peekLeaveTimeoutId) {
clearTimeout(peekLeaveTimeoutId);
peekLeaveTimeoutId = null;
}
function createPeekHandlers<
TOpenKey extends keyof OptimaState,
TPeekingKey extends keyof OptimaState,
>(isOpenKey: TOpenKey, isPeekingKey: TPeekingKey, overrideEnterDelay?: number) {
let enterTimer: any = null;
let leaveTimer: any = null;
return {
cancel: () => {
clearTimeout(enterTimer);
clearTimeout(leaveTimer);
enterTimer = null;
leaveTimer = null;
},
enter: (_get: () => OptimaState, _set: (state: Partial<OptimaState>) => void) => {
clearTimeout(leaveTimer);
leaveTimer = null;
const state = _get();
if (state[isOpenKey] || state[isPeekingKey]) return;
clearTimeout(enterTimer);
enterTimer = setTimeout(() => {
_set({ [isPeekingKey]: true } as Partial<OptimaState>);
enterTimer = null;
}, overrideEnterDelay ?? OPTIMA_PEEK_HOVER_ENTER_DELAY);
},
leave: (_get: () => OptimaState, _set: (state: Partial<OptimaState>) => void) => {
clearTimeout(enterTimer);
enterTimer = null;
const state = _get();
if (!state[isPeekingKey]) return;
clearTimeout(leaveTimer);
leaveTimer = setTimeout(() => {
_set({ [isPeekingKey]: false } as Partial<OptimaState>);
leaveTimer = null;
}, OPTIMA_PEEK_HOVER_TIMEOUT);
},
};
}
@@ -132,53 +167,31 @@ export const useLayoutOptimaStore = create<OptimaState & OptimaActions>((_set, _
closeDrawer: () => {
// prevent accidental immediate close (e.g. double-click, animation protection)
if (Date.now() - _get().lastDrawerOpenTime < OPTIMA_DRAWER_OPEN_DEBOUNCE) return;
cancelPeekTimers();
if (Date.now() - _get().lastDrawerOpenTime < OPTIMA_OPEN_DEBOUNCE) return;
drawerPeek.cancel();
_set({ drawerIsOpen: false, drawerIsPeeking: false });
},
openDrawer: () => {
cancelPeekTimers();
drawerPeek.cancel();
_set({ drawerIsOpen: true, drawerIsPeeking: false, lastDrawerOpenTime: Date.now() });
},
toggleDrawer: () => _get().drawerIsOpen ? _get().closeDrawer() : _get().openDrawer(),
peekDrawerEnter: () => {
cancelPeekTimers('leave');
// if drawer is already open, no need to peek
const { drawerIsOpen, drawerIsPeeking } = _get();
if (drawerIsOpen || drawerIsPeeking) return;
// start a new timer to show the drawer after a small delay
cancelPeekTimers('enter');
peekEnterTimeoutId = setTimeout(() => {
_set({ drawerIsPeeking: true });
peekEnterTimeoutId = null;
}, OPTIMA_DRAWER_HOVER_ENTER_DELAY);
},
peekDrawerLeave: () => {
cancelPeekTimers('enter');
// only start leave timer if currently peeking
const { drawerIsPeeking } = _get();
if (!drawerIsPeeking) return;
// start a new timer to hide the drawer
cancelPeekTimers('leave');
peekLeaveTimeoutId = setTimeout(() => {
_set({ drawerIsPeeking: false });
peekLeaveTimeoutId = null;
}, OPTIMA_DRAWER_HOVER_TIMEOUT);
},
peekDrawerEnter: () => drawerPeek.enter(_get, _set),
peekDrawerLeave: () => drawerPeek.leave(_get, _set),
closePanel: () => {
// NOTE: would this make sense?
// if (Date.now() - _get().lastPanelOpenTime >= 100)
// _set({ panelIsOpen: false });
_set({ panelIsOpen: false });
// prevent accidental immediate close (e.g. double-click, animation protection)
if (Date.now() - _get().lastPanelOpenTime < OPTIMA_OPEN_DEBOUNCE) return;
panelPeek.cancel();
_set({ panelIsOpen: false, panelIsPeeking: false });
},
openPanel: () => {
panelPeek.cancel();
_set({ panelIsOpen: true, panelIsPeeking: false, lastPanelOpenTime: Date.now() });
},
openPanel: () => _set({ panelIsOpen: true, lastPanelOpenTime: Date.now() }),
togglePanel: () => _get().panelIsOpen ? _get().closePanel() : _get().openPanel(),
peekPanelEnter: () => panelPeek.enter(_get, _set),
peekPanelLeave: () => panelPeek.leave(_get, _set),
closeAIXDebugger: () => _set({ showAIXDebugger: false }),
openAIXDebugger: () => _set({ ...modalsClosedState, showAIXDebugger: true }),