blob: 77fed134ee582d82ac74bd3b57231f98861de085 [file] [edit]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {ensureIsInstance} from './assert';
// Check if a mithril component vnode has children
export function hasChildren<T>({children}: m.Vnode<T>): boolean {
return (
Array.isArray(children) &&
children.length > 0 &&
children.some((value) => value)
);
}
export function childrenValid(children: m.Children): boolean {
if (children === null || children === undefined) return false;
if (Array.isArray(children)) {
return children.length > 0 && children.some(childrenValid);
}
return true;
}
// A component which simply passes through it's children.
// Can be used for having something to attach lifecycle hooks to without having
// to add an extra HTML element to the DOM.
export const Passthrough = {
view({children}: m.VnodeDOM) {
return children;
},
};
export interface GateAttrs {
open: boolean;
}
// The gate component is a wrapper which can either be open or closed.
// - When open, children are rendered inside a div where display = contents.
// - When closed, children are rendered inside a div where display = none, and
// children's view functions are not called.
//
// Use this component when we want to conditionally render certain children, but
// we want to retain their state, such as page and tab views.
export class Gate implements m.ClassComponent<GateAttrs> {
private previousChildren: m.Children;
private wasOpen?: boolean;
view({attrs, children}: m.Vnode<GateAttrs>) {
return m(
'',
{
'data-gate-open': String(attrs.open),
'style': {display: attrs.open ? 'contents' : 'none'},
},
this.renderChildren(attrs.open, children),
);
}
private renderChildren(open: boolean, children: m.Children) {
// If the gate is open, pass the latest children through, otherwise pass the
// cached children through. When Mithril sees the same children as in the
// previous render cycle, it doesn't re-render those children. This is a
// performance optimization, as children that are not visible typically
// don't need to be re-rendered.
//
// Note: Render the children once more after the gate has been closed, which
// allows out-of-tree elements like popups to close properly, as the
// display: none doesn't apply to them.
if (open || this.wasOpen) {
this.previousChildren = children;
}
this.wasOpen = open;
return this.previousChildren;
}
}
export interface GateDetectorAttrs {
onVisibilityChanged: (visible: boolean, dom: Element) => void;
}
// A component that detects visibility changes from an ancestor Gate component.
// Place this inside a Gate's subtree to receive callbacks when the Gate opens
// or closes. Uses MutationObserver to watch for changes to the Gate's
// data-gate-open attribute.
//
// Usage:
// view() {
// return m(GateDetector, {
// onVisibilityChanged: (visible, dom) => {
// console.log('Gate is now visible:', visible);
// }
// }, m(...));
// }
export class GateDetector implements m.ClassComponent<GateDetectorAttrs> {
private observer?: MutationObserver;
private gateElement?: HTMLElement;
private dom?: Element;
private wasVisible?: boolean;
private callback?: (visible: boolean, dom: Element) => void;
view({children}: m.Vnode<GateDetectorAttrs>): m.Children {
return children;
}
oncreate(vnode: m.VnodeDOM<GateDetectorAttrs>) {
this.callback = vnode.attrs.onVisibilityChanged;
this.dom = vnode.dom;
// Find closest Gate wrapper using data attribute
const gateElem = this.dom.closest('[data-gate-open]') ?? undefined;
this.gateElement = gateElem as HTMLElement | undefined;
if (!this.gateElement) return;
this.observer = new MutationObserver(this.checkVisibility.bind(this));
this.observer.observe(this.gateElement, {
attributes: true,
attributeFilter: ['data-gate-open'],
});
// Fire initial state
this.checkVisibility();
}
onupdate(vnode: m.VnodeDOM<GateDetectorAttrs>) {
this.callback = vnode.attrs.onVisibilityChanged;
}
private checkVisibility() {
if (!this.gateElement || !this.dom) return;
const visible = this.gateElement.dataset.gateOpen === 'true';
if (visible !== this.wasVisible) {
this.wasVisible = visible;
this.callback?.(visible, this.dom);
}
}
onremove() {
this.observer?.disconnect();
}
}
export type MithrilEvent<T extends Event = Event> = T & {redraw: boolean};
// Check if a mithril children is empty (falsy or empty array). If
// it is any of these, mithril will not render anything. Useful for when we want
// to optionally avoid rendering a wrapper for some children for instance.
export function isEmptyVnodes(children: m.Children): boolean {
if (!Boolean(children)) return true;
if (Array.isArray(children)) {
return children.length === 0 || children.every(isEmptyVnodes);
}
return false;
}
/**
* Creates a React-style context for passing data through the component tree
* without having to drill props manually through every level.
*
* Wrap content that needs access to context values in a Consumer vnode. The
* Consumer takes a function that receives the current context value and returns
* mithril vnodes.
*
* @template T The type of value stored in the context
* @param initialValue Optional default value when no Provider is present
* @returns An object with Provider and Consumer components
*
* @example
* // Basic usage - create a context and use it
* const ThemeContext = createContext('light');
*
* // Provider sets the context value for its children
* m(ThemeContext.Provider, {value: 'dark'}, [
* m(Header),
* m(Content),
* ]);
*
* // Consumer wraps content that needs the context value.
* m(ThemeContext.Consumer, (theme) => {
* return m('.pf-button', {
* class: theme === 'dark' ? 'pf-dark' : 'pf-light',
* }, 'Click me');
* });
*
* @example
* // Using Consumer within a component
* class Button implements m.ClassComponent {
* view() {
* return m(ThemeContext.Consumer, (theme) => {
* return m('.pf-button', {
* class: theme === 'dark' ? 'pf-dark' : 'pf-light',
* }, 'Click me');
* });
* }
* }
*
* @example
* // Using Consumer inline within a vnode tree
* m('.page', [
* m('h1', 'My App'),
* m(ThemeContext.Consumer, (theme) =>
* m('.content', {class: theme}, 'Content')
* ),
* m(Footer),
* ]);
*
* @example
* // Context without a default value
* interface User {
* name: string;
* id: number;
* }
* const UserContext = createContext<User>();
*
* // Consumer receives undefined when no Provider is present
* m(UserContext.Consumer, (user) => {
* if (!user) return m('.pf-greeting', 'Not logged in');
* return m('.pf-greeting', `Welcome ${user.name}`);
* });
*
* @example
* // Nesting Providers creates scoped context values
* m(ThemeContext.Provider, {value: 'light'}, [
* m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'light'
* m(ThemeContext.Provider, {value: 'dark'}, [
* m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'dark'
* ]),
* m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'light' again
* ]);
*/
export function createContext<T>(initialValue: T): {
Provider: m.Component<{value: T}>;
Consumer: m.Component<(value: T) => m.Children | void>;
};
export function createContext<T>(): {
Provider: m.Component<{value: T}>;
Consumer: m.Component<(value: T | undefined) => m.Children | void>;
};
export function createContext<T>(initialValue?: T): {
Provider: m.Component<{value: T}>;
Consumer: m.Component<(value: T | undefined) => m.Children | void>;
} {
let currentContext: T | undefined = initialValue;
return {
Provider: {
view({attrs, children}: m.Vnode<{value: T}>): m.Children {
const previousContext = currentContext;
currentContext = attrs.value;
return [
children,
m({
view() {
currentContext = previousContext;
},
}),
];
},
},
Consumer: {
view({children}: m.Vnode<(value: T | undefined) => m.Children | void>) {
const viewFn: (context: T | undefined) => m.Children | void =
Array.isArray(children) && typeof children[0] === 'function'
? children[0]
: () => null;
return viewFn(currentContext);
},
},
};
}
/**
* Options for {@link startDragGesture}.
*/
export interface DragOptions {
// The pointerdown event that initiates the drag. Its `currentTarget` must
// be an HTMLElement and is the element that captures the pointer for the
// duration of the gesture.
readonly e: PointerEvent;
// The distance in pixels the pointer must move from its initial position
// before the drag is considered started. Defaults to 0px (i.e. dragging
// starts immediately on pointerdown).
readonly deadzonePx?: number;
// Called once when the pointer first moves beyond `deadzonePx`. May
// return per-gesture handlers that take precedence over the top-level
// `onDrag`/`onDragEnd` for the rest of this gesture — useful when the
// drag needs to capture state at the moment it starts. If you don't need
// to initialize anything, omit this and use `onDrag`/`onDragEnd` directly.
readonly onDragStart?: (e: PointerEvent) => {
readonly onDrag?: (e: PointerEvent) => void;
readonly onDragEnd?: (e: PointerEvent) => void;
} | void;
// Called for each pointermove after the deadzone is crossed. Ignored for
// a given gesture if `onDragStart` returned its own `onDrag`.
readonly onDrag?: (e: PointerEvent) => void;
// Called when the gesture ends (pointerup/pointercancel) after the
// deadzone was crossed. Ignored for a given gesture if `onDragStart`
// returned its own `onDragEnd`.
readonly onDragEnd?: (e: PointerEvent) => void;
// Called if the pointer is released before crossing the deadzone — i.e.
// the gesture turned out to be a click rather than a drag. `onDragStart`
// will not have been invoked when this fires.
readonly onDragFailed?: (e: PointerEvent) => void;
}
/**
* Turns a pointerdown event into a deadzone-gated drag gesture.
*
* Captures the pointer on `e.currentTarget`, then waits for movement to
* exceed `deadzonePx` before invoking `onDragStart`. Subsequent pointermove
* events are routed to `onDrag`; pointerup/pointercancel ends the
* gesture and invokes `onDragEnd`. If the pointer is released before the
* deadzone is crossed, `onDragFailed` is invoked instead — useful for
* distinguishing clicks from drags on the same element.
*
* Pointer capture is released and all listeners are removed automatically
* when the gesture ends, regardless of how it ends.
*
* @example
* m('.draggable', {
* onpointerdown: (e: PointerEvent) => {
* startDragGesture({
* e,
* deadzonePx: 5,
* onDrag: (ev) => console.log('moved to', ev.clientX, ev.clientY),
* onDragEnd: () => console.log('drag ended'),
* onDragFailed: () => console.log('was a click, not a drag'),
* });
* },
* });
*
* Use `onDragStart` instead when the gesture needs to capture state at the
* moment dragging actually begins (e.g. clone a node):
*
* @example
* startDragGesture({
* e,
* deadzonePx: 5,
* onDragStart: (ev) => {
* const cloned = element.cloneNode();
* return {
* onDrag: (e2) => cloned.style.transform = `translate(...)`,
* onDragEnd: () => cloned.remove(),
* };
* },
* });
*/
export function startDragGesture(options: DragOptions): void {
const {
e,
deadzonePx = 0,
onDragStart,
onDragFailed,
onDrag,
onDragEnd,
} = options;
const el = ensureIsInstance(e.currentTarget, HTMLElement);
const startX = e.clientX;
const startY = e.clientY;
el.setPointerCapture(e.pointerId);
let started = false;
let handlers: {
onDrag?: (e: PointerEvent) => void;
onDragEnd?: (e: PointerEvent) => void;
} = {};
function teardown() {
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerup', onPointerUp);
el.removeEventListener('pointercancel', onPointerUp);
el.removeEventListener('contextmenu', onContextMenu);
if (el.hasPointerCapture(e.pointerId)) {
el.releasePointerCapture(e.pointerId);
}
}
// Suppress the context menu for the duration of the gesture. Right-click
// drags otherwise pop up the browser menu over the dragged element. We
// also can't rely on the menu cleaning up our state for us: Chrome does
// not fire `pointercancel` (or `lostpointercapture` until the next pointer
// move after the menu closes) when a native context menu opens over a
// captured pointer, so a drag started with the right button can be left
// stuck if we don't preventDefault here. See w3c/pointerevents#408.
function onContextMenu(ev: Event) {
ev.preventDefault();
ev.stopPropagation();
}
function start(ev: PointerEvent) {
started = true;
const perGesture = onDragStart?.(ev);
handlers = {
onDrag: perGesture?.onDrag ?? onDrag,
onDragEnd: perGesture?.onDragEnd ?? onDragEnd,
};
}
function onPointerMove(ev: PointerEvent) {
m.redraw();
if (!started) {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
if (Math.hypot(dx, dy) < deadzonePx) return;
start(ev);
} else {
handlers.onDrag?.(ev);
}
}
function onPointerUp(ev: PointerEvent) {
m.redraw();
teardown();
if (started) {
handlers.onDragEnd?.(ev);
} else {
onDragFailed?.(ev);
}
}
el.addEventListener('pointermove', onPointerMove);
el.addEventListener('pointerup', onPointerUp);
el.addEventListener('pointercancel', onPointerUp);
el.addEventListener('contextmenu', onContextMenu);
// With a zero deadzone the gesture starts immediately on pointerdown, so
// even a click-without-moving counts as a drag (no `onDragFailed` path).
if (deadzonePx === 0) start(e);
}