Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions js/app/packages/app/component/ResponsiveBlockToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type BlockTool = {
condition?: () => boolean;
isActive?: () => boolean;
buttonComponent?: () => JSX.Element;
focusTarget?: () => HTMLElement | null;
divideAbove?: boolean;
hotkeyToken?: HotkeyToken;
};
Expand Down
295 changes: 295 additions & 0 deletions js/app/packages/app/component/SimpleDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import { isMobile } from '@core/mobile/isMobile';
import { isTouchDevice } from '@core/mobile/isTouchDevice';
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from '@floating-ui/dom';
import { DropdownMenu } from '@kobalte/core/dropdown-menu';
import { cn } from '@ui/utils/classname';
import {
type Accessor,
type Component,
createContext,
createSignal,
type JSX,
onCleanup,
onMount,
Show,
splitProps,
useContext,
type ValidComponent,
} from 'solid-js';
import { Dynamic, Portal } from 'solid-js/web';

// SimpleDropdown is a hand-rolled dropdown with no focus management — Kobalte's
// DropdownMenu unconditionally restores focus to its trigger on close, which
// conflicts with MobileDrawer focus control. Use SimpleDropdown (via
// ResponsiveDropdown) on touch devices wherever you need to own focus yourself.

// --- Internal floating content ---

function FloatingContent(props: {
anchor: HTMLElement;
boundary: HTMLElement | null;
onClose: () => void;
children: JSX.Element;
}) {
let ref!: HTMLDivElement;
const [pos, setPos] = createSignal({ x: 0, y: 0 });

onMount(() => {
const update = async () => {
const { x, y } = await computePosition(props.anchor, ref, {
strategy: 'fixed',
placement: 'bottom-end',
middleware: [
offset(8),
flip({
boundary: props.boundary ?? 'clippingAncestors',
fallbackStrategy: 'initialPlacement',
}),
shift({
padding: 8,
boundary: props.boundary ?? 'clippingAncestors',
}),
],
});
setPos({ x, y });
};

const cleanupAutoUpdate = autoUpdate(props.anchor, ref, update);

const handlePointerDown = (e: PointerEvent) => {
if (
!ref.contains(e.target as Node) &&
!props.anchor.contains(e.target as Node)
) {
props.onClose();
}
};

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
props.onClose();
}
};

document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);

onCleanup(() => {
cleanupAutoUpdate();
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
});
});

return (
<div
ref={ref}
style={{ position: 'fixed', left: `${pos().x}px`, top: `${pos().y}px` }}
class="bg-menu w-fit p-1 border border-edge-muted rounded-xs shadow z-highlight-menu"
>
{props.children}
</div>
);
}

// --- Context ---

type SimpleDropdownContextValue = {
open: Accessor<boolean>;
onOpenChange: (open: boolean) => void;
boundary: Accessor<HTMLElement | null>;
anchor: Accessor<HTMLElement | undefined>;
setAnchor: (el: HTMLElement) => void;
};

const SimpleDropdownContext = createContext<SimpleDropdownContextValue>();

function useSimpleDropdownContext() {
const ctx = useContext(SimpleDropdownContext);
if (!ctx)
throw new Error(
'SimpleDropdown sub-components must be used inside <SimpleDropdown>'
);
return ctx;
}

// --- Item ---

export type DropdownItemProps = {
text: string | JSX.Element;
icon?: Component<JSX.SvgSVGAttributes<SVGSVGElement>>;
onClick?: () => void;
disabled?: boolean;
class?: string;
};

const ITEM_BASE_CLASS = `flex flex-row w-full gap-1.5 tracking-tight ${isMobile() ? 'py-2 px-1 text-base' : 'py-1 pl-2 pr-2 text-sm'} font-medium justify-between items-center focus-bracket rounded-xs`;

function ItemInner(props: Pick<DropdownItemProps, 'icon' | 'text'>) {
return (
<>
<Show when={props.icon}>
<Dynamic
component={props.icon}
class={cn('shrink-0', isMobile() ? 'w-5 h-5' : 'w-4 h-4')}
/>
</Show>
<Show when={props.text}>
<div class="flex-1 truncate">{props.text}</div>
</Show>
</>
);
}

function TouchItem(props: DropdownItemProps) {
return (
<div
onClick={props.onClick}
class={cn(
ITEM_BASE_CLASS,
props.disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-hover hover-transition-bg',
props.class
)}
>
<ItemInner icon={props.icon} text={props.text} />
</div>
);
}
Comment thread
peterchinman marked this conversation as resolved.

function KobalteItem(props: DropdownItemProps) {
return (
<DropdownMenu.Item
onSelect={props.onClick}
disabled={props.disabled}
class={cn(
ITEM_BASE_CLASS,
props.disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-hover hover-transition-bg',
props.class
)}
>
<ItemInner icon={props.icon} text={props.text} />
</DropdownMenu.Item>
);
}

const DropdownItem = isTouchDevice() ? TouchItem : KobalteItem;
Comment thread
peterchinman marked this conversation as resolved.

// --- Sub-components ---

function SimpleDropdownTrigger(props: {
as?: ValidComponent;
onClick?: (e: MouseEvent) => void;
children?: JSX.Element;
[key: string]: unknown;
}) {
const ctx = useSimpleDropdownContext();
const [local, others] = splitProps(props, ['as', 'onClick']);
return (
<Dynamic
component={(local.as ?? 'button') as ValidComponent}
ref={(el: HTMLElement) => ctx.setAnchor(el)}
onClick={(e: MouseEvent) => {
local.onClick?.(e);
ctx.onOpenChange(!ctx.open());
}}
{...(others as Record<string, unknown>)}
/>
);
}

// No-op: portaling is handled internally by Content.
function SimpleDropdownPortal(props: { children: JSX.Element }) {
return <>{props.children}</>;
}

function SimpleDropdownContent(props: {
class?: string;
children: JSX.Element;
}) {
const ctx = useSimpleDropdownContext();
return (
<Show when={ctx.open() && ctx.anchor()}>
<Portal>
<FloatingContent
anchor={ctx.anchor()!}
boundary={ctx.boundary()}
onClose={() => ctx.onOpenChange(false)}
>
{props.children}
</FloatingContent>
</Portal>
</Show>
);
}

// --- Root ---

function SimpleDropdownRoot(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
boundary?: Accessor<HTMLElement | null>;
children: JSX.Element;
}) {
const [anchor, setAnchor] = createSignal<HTMLElement>();
return (
<SimpleDropdownContext.Provider
value={{
open: () => props.open,
onOpenChange: props.onOpenChange,
boundary: props.boundary ?? (() => null),
anchor,
setAnchor,
}}
>
{props.children}
</SimpleDropdownContext.Provider>
);
}

export const SimpleDropdown = Object.assign(SimpleDropdownRoot, {
Trigger: SimpleDropdownTrigger,
Portal: SimpleDropdownPortal,
Content: SimpleDropdownContent,
Item: DropdownItem,
});

export type DropdownMenuLike = {
(props: {
open: boolean;
onOpenChange: (v: boolean) => void;
boundary?: unknown;
children: JSX.Element;
}): JSX.Element;
Trigger: Component<any>;
Portal: Component<any>;
Content: Component<any>;
Item: Component<any>;
};

// On desktop: use Kobalte's root/trigger/portal/content (for keyboard nav and
// focus management) but replace Item with the wrapped KobalteItem so that
// callers can use the text/icon/onClick interface.
const DesktopDropdown = Object.assign(
(props: any) => <DropdownMenu {...props} />,
{
Trigger: DropdownMenu.Trigger,
Portal: DropdownMenu.Portal,
Content: DropdownMenu.Content,
Item: KobalteItem,
}
);

export const ResponsiveDropdown: DropdownMenuLike = isTouchDevice()
? (SimpleDropdown as unknown as DropdownMenuLike)
: (DesktopDropdown as unknown as DropdownMenuLike);
Comment thread
peterchinman marked this conversation as resolved.
45 changes: 24 additions & 21 deletions js/app/packages/app/component/mobile/MobileDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { virtualKeyboardVisible } from '@core/mobile/virtualKeyboard';
import { isEditableInput } from '@core/util/isEditableInput';
import Drawer from '@corvu/drawer';
import { cn } from '@ui/utils/classname';
import {
createSignal,
onCleanup,
splitProps,
type ComponentProps,
type ValidComponent,
Expand All @@ -13,21 +15,18 @@ import { Dynamic } from 'solid-js/web';
* focused input/textarea to `offset` px from the container's top edge.
*
* Usage:
* <div onFocusIn={(e) => scrollToFocusedInput(e, e.currentTarget)}>
* <div onFocusIn={(e) => scrollToFocusedInput(e)}>
*/
export function scrollToFocusedInput(
e: FocusEvent & { currentTarget: HTMLElement },
offset = 40
) {
if (
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
)
let scrollTimer: ReturnType<typeof setTimeout> | undefined;

export function scrollToFocusedInput(e: FocusEvent, offset = 40) {
if (!isEditableInput(e.target as Element) || scrollTimer !== undefined)
return;
const input = e.target as HTMLElement;
const container = e.currentTarget;
const container = e.currentTarget as HTMLElement;
// Has to be delayed until after browser's native keyboard-show scroll completes
setTimeout(() => {
scrollTimer = setTimeout(() => {
scrollTimer = undefined;
const inputRect = input.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
container.scrollTo({
Expand All @@ -50,22 +49,20 @@ export function scrollToFocusedInput(
*/
function MobileDrawerContent(props: ComponentProps<typeof Drawer.Content>) {
const [local, rest] = splitProps(props, ['class']);
const [inputFocused, setInputFocused] = createSignal(false);

const isInputEl = (target: EventTarget | null) =>
target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
onCleanup(() => {
clearTimeout(scrollTimer);
scrollTimer = undefined;
});

return (
<Drawer.Content
onFocusIn={(e: FocusEvent) => {
if (isInputEl(e.target)) setInputFocused(true);
}}
onFocusOut={(e: FocusEvent) => {
if (isInputEl(e.target)) setInputFocused(false);
scrollToFocusedInput(e);
}}
class={cn(
'bottom-(--virtual-keyboard-height) fixed left-0 right-0 z-modal bg-page rounded-t-2xl flex flex-col max-h-[80vh] data-transitioning:transition-transform data-transitioning:duration-200 ease-out',
inputFocused()
virtualKeyboardVisible()
? 'pb-0 max-h-[calc(80vh-var(--virtual-keyboard-height))] overflow-y-auto'
: 'pb-(--safe-bottom)',
local.class
Expand Down Expand Up @@ -107,7 +104,13 @@ function MobileDrawerSection<T extends ValidComponent = 'div'>(
*/
export const MobileDrawer = Object.assign(
(props: ComponentProps<typeof Drawer>) => (
<Drawer breakPoints={[0.8]} {...props} />
<Drawer
breakPoints={[0.8]}
closeOnOutsideFocus={false}
noOutsidePointerEvents={false}
restoreFocus={false}
{...props}
/>
Comment thread
peterchinman marked this conversation as resolved.
),
{
Trigger: Drawer.Trigger,
Expand Down
Loading
Loading