mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(app): gate the expression info card behind an experiment (#2773)
This commit is contained in:
1 parent
7c9274847f
commit
6339c3b8ad
110 files changed
+12827
-5191
No files matched your search
@@ -20,8 +20,13 @@
|
||||
}
|
||||
|
||||
.emoji {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
|
||||
@@ -50,7 +50,7 @@ export const EmojiTooltipContent = React.forwardRef<HTMLDivElement, EmojiTooltip
|
||||
src={emojiUrl}
|
||||
alt={emojiAlt}
|
||||
draggable={false}
|
||||
className={clsx('emoji', styles.emoji, 'jumboable', emojiClassName)}
|
||||
className={clsx(styles.emoji, emojiClassName)}
|
||||
data-flx="ui.emoji-tooltip-content.emoji-tooltip-content.render-emoji.emoji"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isPopoutDragClick} from '@app/features/ui/popover/PopoverDragClickUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const PRESS = {x: 120, y: 240, button: 0};
|
||||
|
||||
describe('isPopoutDragClick', () => {
|
||||
it('treats a click far from the press as a drag', () => {
|
||||
expect(isPopoutDragClick(PRESS, {detail: 1, button: 0, clientX: 160, clientY: 240})).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a click within the tolerance of the press', () => {
|
||||
expect(isPopoutDragClick(PRESS, {detail: 1, button: 0, clientX: 122, clientY: 242})).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a click from a different button than the press', () => {
|
||||
expect(isPopoutDragClick(PRESS, {detail: 1, button: 2, clientX: 900, clientY: 900})).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a click with no recorded press', () => {
|
||||
expect(isPopoutDragClick(null, {detail: 1, button: 0, clientX: 900, clientY: 900})).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a keyboard click even when a stale press is recorded', () => {
|
||||
expect(isPopoutDragClick(PRESS, {detail: 0, button: 0, clientX: 0, clientY: 0})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const CLICK_DRAG_TOLERANCE_PX = 3;
|
||||
|
||||
export interface PopoutClickStart {
|
||||
x: number;
|
||||
y: number;
|
||||
button: number;
|
||||
}
|
||||
|
||||
interface PopoutClickGeometry {
|
||||
detail: number;
|
||||
button: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export function isPopoutDragClick(clickStart: PopoutClickStart | null, event: PopoutClickGeometry): boolean {
|
||||
if (event.detail === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!clickStart || clickStart.button !== event.button) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Math.abs(event.clientX - clickStart.x) > CLICK_DRAG_TOLERANCE_PX ||
|
||||
Math.abs(event.clientY - clickStart.y) > CLICK_DRAG_TOLERANCE_PX
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type PopoutReferenceRect,
|
||||
usePopoutKeyContext,
|
||||
} from '@app/features/ui/popover';
|
||||
import {isPopoutDragClick, type PopoutClickStart} from '@app/features/ui/popover/PopoverDragClickUtils';
|
||||
import styles from '@app/features/ui/popover/PopoverPopout.module.css';
|
||||
import {schedulePopoutPortalCleanup} from '@app/features/ui/popover/PopoverPortalCleanup';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
@@ -27,7 +28,6 @@ import {autorun} from 'mobx';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
let currentId = 1;
|
||||
const CLICK_DRAG_TOLERANCE_PX = 3;
|
||||
const MENU_ITEM_SELECTOR =
|
||||
'[data-roving-focus="true"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
|
||||
|
||||
@@ -68,12 +68,6 @@ interface OpenPopoutOptions extends Partial<PopoutProps> {
|
||||
onContentMouseLeave?: () => void;
|
||||
}
|
||||
|
||||
interface ClickStart {
|
||||
x: number;
|
||||
y: number;
|
||||
button: number;
|
||||
}
|
||||
|
||||
type PopoutOpenMode = 'click' | 'hover';
|
||||
|
||||
export const openPopout = (target: HTMLElement, props: OpenPopoutOptions, key: string | number, clickPos = 0) => {
|
||||
@@ -190,7 +184,7 @@ export const Popout = React.forwardRef<HTMLElement, PopoutProps>((props, ref) =>
|
||||
const isContentHoveringRef = useRef(false);
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const closeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const clickStartRef = useRef<ClickStart | null>(null);
|
||||
const clickStartRef = useRef<PopoutClickStart | null>(null);
|
||||
const openModeRef = useRef<PopoutOpenMode | null>(null);
|
||||
useEffect(() => {
|
||||
if (props.children) {
|
||||
@@ -454,13 +448,7 @@ export const Popout = React.forwardRef<HTMLElement, PopoutProps>((props, ref) =>
|
||||
const consumeDragClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
const clickStart = clickStartRef.current;
|
||||
clickStartRef.current = null;
|
||||
if (!clickStart || clickStart.button !== event.button) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Math.abs(event.clientX - clickStart.x) > CLICK_DRAG_TOLERANCE_PX ||
|
||||
Math.abs(event.clientY - clickStart.y) > CLICK_DRAG_TOLERANCE_PX
|
||||
);
|
||||
return isPopoutDragClick(clickStart, event);
|
||||
};
|
||||
const enhancedChild = React.cloneElement(child, {
|
||||
...popoutTriggerProps,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {PopoutResizePositionContext} from '@app/features/ui/popover/PopoutResize
|
||||
import {getPopoutFocusManagerInsideElements} from '@app/features/ui/popover/PopoverFocusManagerUtils';
|
||||
import styles from '@app/features/ui/popover/PopoverPopout.module.css';
|
||||
import {scheduleFloatingPortalSweep} from '@app/features/ui/popover/PopoverPortalCleanup';
|
||||
import {observePopoutKeyboardDismiss, resolvePopoutReturnFocus} from '@app/features/ui/popover/PopoverReturnFocusUtils';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import LayerManager from '@app/features/ui/state/LayerManager';
|
||||
import PopoutState from '@app/features/ui/state/Popout';
|
||||
import {isScrollbarDragActive} from '@app/features/ui/utils/ScrollbarDragState';
|
||||
@@ -209,9 +211,11 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
|
||||
}, [focusRefs, target]);
|
||||
const mergedPopoutRef = useMergeRefs([setFloating, focusRefs.setFloating]);
|
||||
const prefersReducedMotion = Accessibility.useReducedMotion;
|
||||
const isKeyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [targetInDOM, setTargetInDOM] = useState(() => ownerDocument.contains(target));
|
||||
const hasFocusedInitialRef = useRef(false);
|
||||
const keyboardDismissReturnRef = useRef<HTMLElement | null>(null);
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const beginClose = useCallback(() => {
|
||||
if (closeTimerRef.current != null) return;
|
||||
@@ -290,6 +294,22 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
|
||||
if (returnFocusRef != null) returnFocusElement = returnFocusRef.current;
|
||||
return getPopoutFocusManagerInsideElements(target, returnFocusElement);
|
||||
}, [target, returnFocusRef]);
|
||||
const resolveKeyboardDismissFocusTarget = useCallback((): HTMLElement | null => {
|
||||
if (!LayerManager.isTopLayer('popout', popoutKey)) {
|
||||
return null;
|
||||
}
|
||||
return returnFocusRef?.current ?? target;
|
||||
}, [popoutKey, returnFocusRef, target]);
|
||||
useEffect(() => {
|
||||
if (ownerWindow == null) {
|
||||
return;
|
||||
}
|
||||
return observePopoutKeyboardDismiss({
|
||||
ownerWindow,
|
||||
keyboardDismissRef: keyboardDismissReturnRef,
|
||||
resolveFocusTarget: resolveKeyboardDismissFocusTarget,
|
||||
});
|
||||
}, [ownerWindow, resolveKeyboardDismissFocusTarget]);
|
||||
useEffect(() => {
|
||||
const el = popoutRef.current;
|
||||
const targetIsConnected = ownerDocument.contains(target);
|
||||
@@ -378,7 +398,13 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
|
||||
<FloatingFocusManager
|
||||
context={focusContext}
|
||||
disabled={!isTopmost}
|
||||
returnFocus={returnFocusOnClose ? (returnFocusRef == null ? targetInDOM : returnFocusRef) : false}
|
||||
returnFocus={resolvePopoutReturnFocus({
|
||||
restoreFocusPolicy: returnFocusOnClose,
|
||||
isKeyboardModeEnabled,
|
||||
returnFocusRef,
|
||||
keyboardDismissRef: keyboardDismissReturnRef,
|
||||
isTargetInDOM: targetInDOM,
|
||||
})}
|
||||
initialFocus={focusRefs.floating}
|
||||
getInsideElements={getFocusManagerInsideElements}
|
||||
data-flx="ui.popover.popouts.popout-item.floating-focus-manager"
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {resolve} from 'node:path';
|
||||
import {observePopoutKeyboardDismiss, resolvePopoutReturnFocus} from '@app/features/ui/popover/PopoverReturnFocusUtils';
|
||||
import {FloatingFocusManager, useFloating} from '@floating-ui/react';
|
||||
import {act, createElement, useLayoutEffect} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const RETURN_FOCUS_REF = {current: null};
|
||||
const KEYBOARD_DISMISS_REF = {current: null};
|
||||
|
||||
describe('resolvePopoutReturnFocus', () => {
|
||||
it('returns focus to the target when the open-time policy allows it', () => {
|
||||
expect(
|
||||
resolvePopoutReturnFocus({
|
||||
restoreFocusPolicy: true,
|
||||
isKeyboardModeEnabled: false,
|
||||
keyboardDismissRef: KEYBOARD_DISMISS_REF,
|
||||
isTargetInDOM: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns focus to the target when the viewer is navigating by keyboard', () => {
|
||||
expect(
|
||||
resolvePopoutReturnFocus({
|
||||
restoreFocusPolicy: false,
|
||||
isKeyboardModeEnabled: true,
|
||||
keyboardDismissRef: KEYBOARD_DISMISS_REF,
|
||||
isTargetInDOM: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('defers a pointer-driven popout to the late-bound keyboard-dismiss target', () => {
|
||||
expect(
|
||||
resolvePopoutReturnFocus({
|
||||
restoreFocusPolicy: false,
|
||||
isKeyboardModeEnabled: false,
|
||||
keyboardDismissRef: KEYBOARD_DISMISS_REF,
|
||||
isTargetInDOM: true,
|
||||
}),
|
||||
).toBe(KEYBOARD_DISMISS_REF);
|
||||
});
|
||||
|
||||
it('prefers an explicit return-focus target over the popout target', () => {
|
||||
expect(
|
||||
resolvePopoutReturnFocus({
|
||||
restoreFocusPolicy: false,
|
||||
isKeyboardModeEnabled: true,
|
||||
returnFocusRef: RETURN_FOCUS_REF,
|
||||
keyboardDismissRef: KEYBOARD_DISMISS_REF,
|
||||
isTargetInDOM: true,
|
||||
}),
|
||||
).toBe(RETURN_FOCUS_REF);
|
||||
});
|
||||
|
||||
it('defers an explicit return-focus target to the keyboard-dismiss target when no return is wanted', () => {
|
||||
expect(
|
||||
resolvePopoutReturnFocus({
|
||||
restoreFocusPolicy: false,
|
||||
isKeyboardModeEnabled: false,
|
||||
returnFocusRef: RETURN_FOCUS_REF,
|
||||
keyboardDismissRef: KEYBOARD_DISMISS_REF,
|
||||
isTargetInDOM: true,
|
||||
}),
|
||||
).toBe(KEYBOARD_DISMISS_REF);
|
||||
});
|
||||
|
||||
it('does not return focus to a target that left the document', () => {
|
||||
expect(
|
||||
resolvePopoutReturnFocus({
|
||||
restoreFocusPolicy: true,
|
||||
isKeyboardModeEnabled: true,
|
||||
keyboardDismissRef: KEYBOARD_DISMISS_REF,
|
||||
isTargetInDOM: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('observePopoutKeyboardDismiss', () => {
|
||||
let trigger: HTMLElement;
|
||||
let keyboardDismissRef: {current: HTMLElement | null};
|
||||
let dispose: (() => void) | null = null;
|
||||
let layerManagerStub: ((event: KeyboardEvent) => void) | null = null;
|
||||
|
||||
const pressKey = (key: string): void => {
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
trigger = document.createElement('button');
|
||||
document.body.append(trigger);
|
||||
keyboardDismissRef = {current: null};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.();
|
||||
dispose = null;
|
||||
if (layerManagerStub) {
|
||||
document.removeEventListener('keydown', layerManagerStub, {capture: true});
|
||||
layerManagerStub = null;
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('records the focus target when Escape is pressed', () => {
|
||||
dispose = observePopoutKeyboardDismiss({
|
||||
ownerWindow: window,
|
||||
keyboardDismissRef,
|
||||
resolveFocusTarget: () => trigger,
|
||||
});
|
||||
|
||||
pressKey('Escape');
|
||||
|
||||
expect(keyboardDismissRef.current).toBe(trigger);
|
||||
});
|
||||
|
||||
it('records the focus target even though the layer manager stops immediate propagation first', () => {
|
||||
let didCloseTopLayer = false;
|
||||
layerManagerStub = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
didCloseTopLayer = true;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
document.addEventListener('keydown', layerManagerStub, {capture: true});
|
||||
dispose = observePopoutKeyboardDismiss({
|
||||
ownerWindow: window,
|
||||
keyboardDismissRef,
|
||||
resolveFocusTarget: () => trigger,
|
||||
});
|
||||
|
||||
pressKey('Escape');
|
||||
|
||||
expect(didCloseTopLayer).toBe(true);
|
||||
expect(keyboardDismissRef.current).toBe(trigger);
|
||||
});
|
||||
|
||||
it('ignores keys other than Escape', () => {
|
||||
dispose = observePopoutKeyboardDismiss({
|
||||
ownerWindow: window,
|
||||
keyboardDismissRef,
|
||||
resolveFocusTarget: () => trigger,
|
||||
});
|
||||
|
||||
pressKey('Enter');
|
||||
pressKey('Tab');
|
||||
|
||||
expect(keyboardDismissRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it('records nothing when the popout is not the layer Escape closes', () => {
|
||||
dispose = observePopoutKeyboardDismiss({
|
||||
ownerWindow: window,
|
||||
keyboardDismissRef,
|
||||
resolveFocusTarget: () => null,
|
||||
});
|
||||
|
||||
pressKey('Escape');
|
||||
|
||||
expect(keyboardDismissRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it('stops recording once disposed', () => {
|
||||
const stop = observePopoutKeyboardDismiss({
|
||||
ownerWindow: window,
|
||||
keyboardDismissRef,
|
||||
resolveFocusTarget: () => trigger,
|
||||
});
|
||||
stop();
|
||||
|
||||
pressKey('Escape');
|
||||
|
||||
expect(keyboardDismissRef.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FloatingFocusManager return-focus ref contract', () => {
|
||||
let host: HTMLDivElement;
|
||||
let trigger: HTMLButtonElement;
|
||||
let root: Root;
|
||||
|
||||
function ReturnFocusHarness({
|
||||
reference,
|
||||
returnFocus,
|
||||
}: {
|
||||
reference: HTMLElement;
|
||||
returnFocus: {current: HTMLElement | null};
|
||||
}) {
|
||||
const {refs, context} = useFloating({open: true});
|
||||
useLayoutEffect(() => {
|
||||
refs.setReference(reference);
|
||||
}, [refs, reference]);
|
||||
return createElement(FloatingFocusManager, {
|
||||
context,
|
||||
returnFocus,
|
||||
initialFocus: refs.floating,
|
||||
children: createElement('div', {ref: refs.setFloating, tabIndex: -1, 'data-testid': 'floating'}),
|
||||
});
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
trigger = document.createElement('button');
|
||||
document.body.append(host, trigger);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('focuses a return target assigned after the popout opened', async () => {
|
||||
const returnFocus: {current: HTMLElement | null} = {current: null};
|
||||
await act(async () => {
|
||||
root.render(createElement(ReturnFocusHarness, {reference: trigger, returnFocus}));
|
||||
});
|
||||
await settle();
|
||||
host.querySelector<HTMLElement>('[data-testid="floating"]')?.focus();
|
||||
|
||||
returnFocus.current = trigger;
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('leaves focus alone when no return target was assigned', async () => {
|
||||
const returnFocus: {current: HTMLElement | null} = {current: null};
|
||||
await act(async () => {
|
||||
root.render(createElement(ReturnFocusHarness, {reference: trigger, returnFocus}));
|
||||
});
|
||||
await settle();
|
||||
host.querySelector<HTMLElement>('[data-testid="floating"]')?.focus();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(document.activeElement).not.toBe(trigger);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PopoverPopouts keyboard-dismiss wiring', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/features/ui/popover/PopoverPopouts.tsx'), 'utf8');
|
||||
|
||||
it('shares one ref between the Escape watcher and the return-focus resolver', () => {
|
||||
expect(source.split('keyboardDismissRef: keyboardDismissReturnRef').length - 1).toBe(2);
|
||||
expect(source).toContain('returnFocus={resolvePopoutReturnFocus({');
|
||||
expect(source).toContain('resolveFocusTarget: resolveKeyboardDismissFocusTarget');
|
||||
});
|
||||
|
||||
it('only records Escape for the layer the layer manager would close', () => {
|
||||
expect(source).toContain("LayerManager.isTopLayer('popout', popoutKey)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
interface PopoutReturnFocusInput<TReturnFocusRef> {
|
||||
restoreFocusPolicy: boolean;
|
||||
isKeyboardModeEnabled: boolean;
|
||||
returnFocusRef?: TReturnFocusRef | null;
|
||||
keyboardDismissRef: TReturnFocusRef;
|
||||
isTargetInDOM: boolean;
|
||||
}
|
||||
|
||||
export function resolvePopoutReturnFocus<TReturnFocusRef>({
|
||||
restoreFocusPolicy,
|
||||
isKeyboardModeEnabled,
|
||||
returnFocusRef,
|
||||
keyboardDismissRef,
|
||||
isTargetInDOM,
|
||||
}: PopoutReturnFocusInput<TReturnFocusRef>): boolean | TReturnFocusRef {
|
||||
if (!restoreFocusPolicy && !isKeyboardModeEnabled) {
|
||||
return keyboardDismissRef;
|
||||
}
|
||||
if (returnFocusRef != null) {
|
||||
return returnFocusRef;
|
||||
}
|
||||
return isTargetInDOM;
|
||||
}
|
||||
|
||||
interface PopoutKeyboardDismissInput {
|
||||
ownerWindow: Window;
|
||||
keyboardDismissRef: {current: HTMLElement | null};
|
||||
resolveFocusTarget: () => HTMLElement | null;
|
||||
}
|
||||
|
||||
export function observePopoutKeyboardDismiss({
|
||||
ownerWindow,
|
||||
keyboardDismissRef,
|
||||
resolveFocusTarget,
|
||||
}: PopoutKeyboardDismissInput): () => void {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
keyboardDismissRef.current = resolveFocusTarget();
|
||||
};
|
||||
ownerWindow.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
ownerWindow.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user