@jbrowse/display-ui 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +433 -0
  3. package/esm/DisplayUIProvider.d.ts +70 -0
  4. package/esm/DisplayUIProvider.js +96 -0
  5. package/esm/FloatingLegend.d.ts +16 -0
  6. package/esm/FloatingLegend.js +213 -0
  7. package/esm/PointerLayer.d.ts +26 -0
  8. package/esm/PointerLayer.js +27 -0
  9. package/esm/bottomRightCorner.d.ts +58 -0
  10. package/esm/bottomRightCorner.js +59 -0
  11. package/esm/chromeOverlayContext.d.ts +7 -0
  12. package/esm/chromeOverlayContext.js +44 -0
  13. package/esm/chromeOverlays.d.ts +88 -0
  14. package/esm/chromeOverlays.js +1 -0
  15. package/esm/index.d.ts +22 -0
  16. package/esm/index.js +51 -0
  17. package/esm/isLiveModel.d.ts +21 -0
  18. package/esm/isLiveModel.js +24 -0
  19. package/esm/plainChromeOverlays.d.ts +18 -0
  20. package/esm/plainChromeOverlays.js +189 -0
  21. package/esm/tooLargeBannerText.d.ts +18 -0
  22. package/esm/tooLargeBannerText.js +34 -0
  23. package/esm/tooltip/Tooltip.d.ts +52 -0
  24. package/esm/tooltip/Tooltip.js +67 -0
  25. package/esm/tooltip/useTooltip.d.ts +50 -0
  26. package/esm/tooltip/useTooltip.js +116 -0
  27. package/esm/trackControl/plainTrackControl.d.ts +17 -0
  28. package/esm/trackControl/plainTrackControl.js +133 -0
  29. package/esm/trackControl/trackControlContext.d.ts +7 -0
  30. package/esm/trackControl/trackControlContext.js +30 -0
  31. package/esm/trackControl/types.d.ts +58 -0
  32. package/esm/trackControl/types.js +1 -0
  33. package/esm/trackControl/useTrackControlMenu.d.ts +51 -0
  34. package/esm/trackControl/useTrackControlMenu.js +163 -0
  35. package/esm/trackOverlay/TrackOverlayContext.d.ts +1 -0
  36. package/esm/trackOverlay/TrackOverlayContext.js +18 -0
  37. package/esm/trackOverlay/TrackOverlayPortal.d.ts +18 -0
  38. package/esm/trackOverlay/TrackOverlayPortal.js +66 -0
  39. package/esm/trackOverlay/TrackOverlaySlot.d.ts +54 -0
  40. package/esm/trackOverlay/TrackOverlaySlot.js +60 -0
  41. package/package.json +48 -0
@@ -0,0 +1,116 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Suspense, lazy, useEffect, useId, useRef, useState } from 'react';
3
+ // The bubble is `BaseTooltip`, the same component the ten display tooltips draw
4
+ // — this hook supplies the hover, the focus and the dismissal, and hands it an
5
+ // element to hang off instead of a cursor position. There is one tooltip in
6
+ // JBrowse and one @floating-ui in the tree; a second one here would have
7
+ // differed only in which reference it positioned against.
8
+ //
9
+ // The `lazy` is the point rather than an optimization. @floating-ui is ~266KB
10
+ // and this package's chrome — the plain overlay set, the corner control, the
11
+ // legend — is imported eagerly by every display that renders one, so a static
12
+ // import would put a positioning library on the cold shell's critical path of
13
+ // every JBrowse product and every embed for a box nobody has hovered yet.
14
+ // `@jbrowse/core` broke a published ABI over exactly this, moving `BaseTooltip`
15
+ // out of its `ui` barrel onto its own lazy module (see `ReExports/modules.ts`);
16
+ // `eagerBoundary.test.ts` beside this file is what stops someone quietly
17
+ // turning the import below into an `import … from`.
18
+ const BaseTooltip = lazy(() => import('@jbrowse/core/ui/BaseTooltip'));
19
+ // How long the pointer rests before the tooltip appears. The browser's own
20
+ // `title` waited about a second, which is long enough that people stop
21
+ // expecting anything and move on; this is roughly Material UI's delay.
22
+ //
23
+ // Keyboard focus skips it entirely — a tab landing on a control is deliberate
24
+ // in a way a pointer crossing it is not.
25
+ const ENTER_DELAY_MS = 400;
26
+ /**
27
+ * #api
28
+ * A hover/focus label for one control, as props to spread — the headless half
29
+ * of {@link Tooltip}, for a host writing its own chrome rather than restyling
30
+ * ours. Same relationship `useTrackControlMenu` has to `plainTrackControl`.
31
+ *
32
+ * ```tsx
33
+ * const { triggerProps, tooltip } = useTooltip('Hide legend')
34
+ * return (
35
+ * <>
36
+ * <button {...triggerProps} aria-label="Hide legend" onClick={onDismiss}>
37
+ * ×
38
+ * </button>
39
+ * {tooltip}
40
+ * </>
41
+ * )
42
+ * ```
43
+ *
44
+ * `triggerProps` carries no `onClick`, so a control's own handler does not
45
+ * collide with it. Any other handler on this list has to compose rather than
46
+ * replace — spread first, then call `triggerProps.onFocus` from yours.
47
+ */
48
+ export function useTooltip(
49
+ /** What the bubble says. It stays down while this is empty. */
50
+ title, { placement = 'top' } = {}) {
51
+ const [anchor, setAnchor] = useState(null);
52
+ const [open, setOpen] = useState(false);
53
+ const timer = useRef(undefined);
54
+ const id = useId();
55
+ const cancel = () => {
56
+ clearTimeout(timer.current);
57
+ timer.current = undefined;
58
+ };
59
+ const hide = () => {
60
+ cancel();
61
+ setOpen(false);
62
+ };
63
+ useEffect(() => {
64
+ if (!open) {
65
+ return;
66
+ }
67
+ // Escape is the one dismissal a pointer leaving cannot cover: a tooltip
68
+ // raised by keyboard focus stays up until the focus moves, and a bubble
69
+ // over the thing being read is in the way. Scrolling and resizing need no
70
+ // handler — the bubble follows its anchor through `autoUpdate` rather than
71
+ // being measured once, which is the difference between this and the menu in
72
+ // `useTrackControlMenu`, where a scroll dismisses.
73
+ const onKey = (event) => {
74
+ if (event.key === 'Escape') {
75
+ setOpen(false);
76
+ }
77
+ };
78
+ document.addEventListener('keydown', onKey);
79
+ return () => {
80
+ document.removeEventListener('keydown', onKey);
81
+ };
82
+ }, [open]);
83
+ useEffect(() => () => {
84
+ clearTimeout(timer.current);
85
+ }, []);
86
+ const show = open && !!anchor && title !== '' && title !== undefined;
87
+ return {
88
+ open: show,
89
+ triggerProps: {
90
+ ref: setAnchor,
91
+ 'aria-describedby': show ? id : undefined,
92
+ onPointerEnter: event => {
93
+ // A touch "hover" is the press that is also about to be a click, and a
94
+ // bubble over the thing being tapped helps nobody. Touch users get the
95
+ // `aria-label` the control already carries.
96
+ if (event.pointerType === 'touch') {
97
+ return;
98
+ }
99
+ cancel();
100
+ timer.current = setTimeout(() => {
101
+ setOpen(true);
102
+ }, ENTER_DELAY_MS);
103
+ },
104
+ onPointerLeave: hide,
105
+ // The press answers "what is this?", so a label still hanging over the
106
+ // control that just did something reads as a stuck tooltip.
107
+ onPointerDown: hide,
108
+ onFocus: () => {
109
+ cancel();
110
+ setOpen(true);
111
+ },
112
+ onBlur: hide,
113
+ },
114
+ tooltip: show ? (_jsx(Suspense, { children: _jsx(BaseTooltip, { id: id, anchor: anchor, placement: placement, children: title }) })) : null,
115
+ };
116
+ }
@@ -0,0 +1,17 @@
1
+ import type { TrackControlProps } from './types.ts';
2
+ /**
3
+ * #api
4
+ * A display's ambient bottom-right control — track sizing, the isoform notice,
5
+ * the show-only badge — drawn with no UI toolkit, the corner-control
6
+ * counterpart of `plainChromeOverlays`.
7
+ *
8
+ * `DisplayUIProvider` installs this by default. The behaviour is not in here:
9
+ * dismissal, the keyboard, focus, the top layer and the anchoring are
10
+ * `useTrackControlMenu`, so writing your own control means writing markup
11
+ * rather than re-deriving why the menu opens upward.
12
+ *
13
+ * The package exports it as `plainTrackControl`, lower-cased to match
14
+ * `plainChromeOverlays` — the two are a pair, and both are things you hand to a
15
+ * provider rather than render yourself.
16
+ */
17
+ export default function PlainTrackControl({ icon, tooltip, label, options, onClick, onMenuClose, onDelete, warning, }: TrackControlProps): import("react").JSX.Element;
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createPortal } from 'react-dom';
3
+ import Tooltip from '../tooltip/Tooltip.js';
4
+ import { useTrackControlMenu } from './useTrackControlMenu.js';
5
+ // A dependency-free `TrackControlComponent`, the corner-control counterpart of
6
+ // `plainChromeOverlays`: no theme provider to mount, no emotion injected into
7
+ // the host page, and nothing that reads as a stray Material widget inside
8
+ // someone else's design system. Install it with `TrackControlProvider` (or just
9
+ // mount `DisplayUIProvider`) and a stock feature or alignments display stops
10
+ // rendering Material UI in its corner.
11
+ //
12
+ // Deliberately plain rather than pretty. Colors come from `currentColor` and the
13
+ // CSS system colors (`Canvas`/`CanvasText`), so the host's own cascade drives
14
+ // them and light/dark tracks the host with no theme object.
15
+ //
16
+ // **The behaviour is not in here.** Dismissal, focus, the top layer and the
17
+ // anchoring maths are `useTrackControlMenu`, so writing your own control means
18
+ // writing markup rather than re-deriving why the menu opens upward. This file is
19
+ // what is left once that is factored out, which is the size it should be.
20
+ // Icon paths, drawn here rather than imported, because importing an icon set is
21
+ // the thing this file exists to avoid. 16x16, stroked so one path serves any
22
+ // size and any color.
23
+ const ICON_PATHS = {
24
+ // vertical double-headed arrow: make me taller/shorter
25
+ height: 'M8 2.5v11M5 5.5 8 2.5l3 3M5 10.5l3 3 3-3',
26
+ // two chevrons folding onto a line: several things shown as one. The line is
27
+ // load-bearing -- without it the two chevrons read as an ✕ at this size.
28
+ isoform: 'M2.5 8h11M4 4l4 3 4-3M4 12l4-3 4 3',
29
+ // funnel: showing only some of them
30
+ filter: 'M2.5 3.5h11l-4.5 5v4l-2 1.5v-5.5z',
31
+ };
32
+ // The one literal color in this file. Every other rule reads the host's cascade,
33
+ // but "something is wrong here" has no CSS system color to borrow, and the
34
+ // warning state exists precisely so the user sees it without hovering. Chosen to
35
+ // stay legible on both a light and a dark track.
36
+ const WARNING_COLOR = '#d97706';
37
+ function Icon({ icon }) {
38
+ return (_jsx("svg", { viewBox: "0 0 16 16", width: 12, height: 12, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", style: { flex: 'none' }, children: _jsx("path", { d: ICON_PATHS[icon] }) }));
39
+ }
40
+ // The ▾ after a label whose press opens a menu. Drawn like the icons, for the
41
+ // same reason; smaller, because it qualifies the label rather than naming it.
42
+ function Caret() {
43
+ return (_jsx("svg", { viewBox: "0 0 8 8", width: 8, height: 8, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", style: { flex: 'none', marginLeft: -1 }, children: _jsx("path", { d: "M1.5 3l2.5 2.5L6.5 3" }) }));
44
+ }
45
+ function triggerStyle(warning) {
46
+ return {
47
+ font: 'inherit',
48
+ fontSize: '0.7rem',
49
+ lineHeight: 1.4,
50
+ display: 'inline-flex',
51
+ alignItems: 'center',
52
+ gap: 4,
53
+ padding: '1px 5px',
54
+ cursor: 'pointer',
55
+ color: warning ? WARNING_COLOR : 'CanvasText',
56
+ border: '1px solid',
57
+ borderColor: warning
58
+ ? WARNING_COLOR
59
+ : 'color-mix(in srgb, CanvasText 35%, transparent)',
60
+ borderRadius: 3,
61
+ // Nearly opaque: these sit over a canvas whose colors are not known here,
62
+ // and a translucent chip over dense data was measurably hard to read.
63
+ background: 'color-mix(in srgb, Canvas 94%, transparent)',
64
+ };
65
+ }
66
+ /**
67
+ * #api
68
+ * A display's ambient bottom-right control — track sizing, the isoform notice,
69
+ * the show-only badge — drawn with no UI toolkit, the corner-control
70
+ * counterpart of `plainChromeOverlays`.
71
+ *
72
+ * `DisplayUIProvider` installs this by default. The behaviour is not in here:
73
+ * dismissal, the keyboard, focus, the top layer and the anchoring are
74
+ * `useTrackControlMenu`, so writing your own control means writing markup
75
+ * rather than re-deriving why the menu opens upward.
76
+ *
77
+ * The package exports it as `plainTrackControl`, lower-cased to match
78
+ * `plainChromeOverlays` — the two are a pair, and both are things you hand to a
79
+ * provider rather than render yourself.
80
+ */
81
+ export default function PlainTrackControl({ icon, tooltip, label, options, onClick, onMenuClose, onDelete, warning, }) {
82
+ const { open, triggerProps, menuProps, getOptionProps } = useTrackControlMenu(onMenuClose);
83
+ return (_jsxs("span", { style: { display: 'inline-flex', alignItems: 'center' }, children: [_jsx(Tooltip, { title: tooltip, children: _jsxs("button", { type: "button", "aria-label": tooltip, "data-testid": `track-control-${icon}`, ...(options ? triggerProps : undefined), style: {
84
+ ...triggerStyle(!!warning),
85
+ // the (×) butts straight up against this one, so they read as a
86
+ // single control rather than two
87
+ borderRadius: onDelete ? '3px 0 0 3px' : 3,
88
+ },
89
+ // `options` and `onClick` are mutually exclusive, so this replaces
90
+ // the spread handler rather than composing with it. Both stop the
91
+ // click reaching the track/view (drag-select, deselect).
92
+ onClick: options
93
+ ? triggerProps.onClick
94
+ : event => {
95
+ event.stopPropagation();
96
+ onClick?.();
97
+ }, children: [_jsx(Icon, { icon: icon }), label, label !== undefined && options ? _jsx(Caret, {}) : null] }) }), onDelete ? (_jsx("button", { type: "button", "aria-label": "Dismiss", "data-testid": "track-control-dismiss", style: {
98
+ ...triggerStyle(!!warning),
99
+ marginLeft: -1,
100
+ padding: '1px 4px',
101
+ borderRadius: '0 3px 3px 0',
102
+ }, onClick: event => {
103
+ event.stopPropagation();
104
+ onDelete();
105
+ }, children: "\u00D7" })) : null, open && options
106
+ ? createPortal(_jsx("div", { ...menuProps, style: {
107
+ ...menuProps.style,
108
+ zIndex: 2147483647,
109
+ padding: '2px 0',
110
+ font: 'inherit',
111
+ fontSize: '0.75rem',
112
+ color: 'CanvasText',
113
+ background: 'Canvas',
114
+ border: '1px solid',
115
+ borderColor: 'color-mix(in srgb, CanvasText 35%, transparent)',
116
+ borderRadius: 4,
117
+ }, children: options.map(option => (_jsxs("button", { type: "button", ...getOptionProps(option), style: {
118
+ display: 'block',
119
+ width: '100%',
120
+ font: 'inherit',
121
+ textAlign: 'left',
122
+ padding: '3px 10px 3px 20px',
123
+ whiteSpace: 'nowrap',
124
+ // the check mark lives in the padding, so selecting an
125
+ // option can't reflow the list
126
+ textIndent: option.selected ? '-12px' : 0,
127
+ cursor: 'pointer',
128
+ color: 'inherit',
129
+ background: 'transparent',
130
+ border: 'none',
131
+ }, children: [option.selected ? '✓ ' : '', option.label] }, option.label))) }), document.body)
132
+ : null] }));
133
+ }
@@ -0,0 +1,7 @@
1
+ import type { TrackControlComponent } from './types.ts';
2
+ export declare const TrackControlProvider: import("react").Provider<TrackControlComponent | undefined>;
3
+ /**
4
+ * The component a provider installed, or `undefined` for "nobody asked". The
5
+ * caller supplies the default, which is what keeps this module free of one.
6
+ */
7
+ export declare function useTrackControlOverride(): TrackControlComponent | undefined;
@@ -0,0 +1,30 @@
1
+ import { createContext, use } from 'react';
2
+ // The context alone, apart from `MuiTrackControl` — the same split
3
+ // `chromeOverlayContext.ts` makes, for the reason written there: an override
4
+ // channel that lives in the module binding the default drags the default in
5
+ // with it, and no DOM census can see that.
6
+ /**
7
+ * Runtime override for how ambient track controls are drawn. **Defaults to
8
+ * undefined, not to a component** — the same choice the overlay context makes,
9
+ * for the same reason: a plain default would silently change the look of every
10
+ * display that renders outside a provider (unit tests, SVG export,
11
+ * breakpoint-split-view's overlayUtils), and that degradation is invisible.
12
+ * Undefined means "nobody asked", so JBrowse keeps its own look.
13
+ *
14
+ * This is the corner-control half of the bring-your-own-UI story; the status
15
+ * states (loading scrim, error bar, too-large gate) go through the overlay
16
+ * context. Two contexts because two different things render them — the chrome
17
+ * renders the status states, the display's own component renders the controls —
18
+ * and folding them together would put entries in `DisplayChromeOverlays` that
19
+ * `DisplayChromeBase` never uses. `DisplayUIProvider` mounts both, which is what
20
+ * an embedder actually wants.
21
+ */
22
+ const TrackControlContext = createContext(undefined);
23
+ export const TrackControlProvider = TrackControlContext.Provider;
24
+ /**
25
+ * The component a provider installed, or `undefined` for "nobody asked". The
26
+ * caller supplies the default, which is what keeps this module free of one.
27
+ */
28
+ export function useTrackControlOverride() {
29
+ return use(TrackControlContext);
30
+ }
@@ -0,0 +1,58 @@
1
+ import type { ComponentType } from 'react';
2
+ /**
3
+ * Icons are *named* rather than passed in as elements, which is the whole
4
+ * mechanism: an element would have to come from somewhere, and every call site
5
+ * would end up importing `@mui/icons-material` whatever the renderer was. A name
6
+ * lets each implementation draw it its own way.
7
+ *
8
+ * A closed set on purpose — a display wanting a new one adds it here and to both
9
+ * implementations, which is the moment to check the plain set still has an
10
+ * answer.
11
+ */
12
+ export type TrackControlIcon = 'height' | 'isoform' | 'filter';
13
+ /** One row of the control's menu. Rendered as a radio group. */
14
+ export interface TrackControlOption {
15
+ label: string;
16
+ selected: boolean;
17
+ onSelect: () => void;
18
+ }
19
+ export interface TrackControlProps {
20
+ icon: TrackControlIcon;
21
+ /**
22
+ * Always required: it is the only place the control explains itself, and
23
+ * these controls are deliberately small and wordless.
24
+ */
25
+ tooltip: string;
26
+ /**
27
+ * Present: a text chip, for a state the user should notice without looking
28
+ * (the isoform notice, the show-only count). Absent: a quiet icon button, for
29
+ * a control that is always there and should not shout.
30
+ */
31
+ label?: string;
32
+ /**
33
+ * Pressing opens these as a radio list. Mutually exclusive with `onClick`.
34
+ * A labelled control with options draws a ▾ after its label — the one thing
35
+ * telling a chip that opens a menu from one that acts (`onClick`), since the
36
+ * two are otherwise drawn alike.
37
+ */
38
+ options?: TrackControlOption[];
39
+ /** Pressing does this. Mutually exclusive with `options`. */
40
+ onClick?: () => void;
41
+ /**
42
+ * The `options` menu closed, by any route — a pick, Escape, a press outside.
43
+ * A notice that wants opening the menu to count as having been read hangs
44
+ * its acknowledgement here rather than on a (×) of its own. On close and not
45
+ * on open: a chip that shrinks while its menu is still up takes the menu's
46
+ * anchor with it.
47
+ */
48
+ onMenuClose?: () => void;
49
+ /** Adds a (×). What it means is the caller's business — dismiss, clear, undo. */
50
+ onDelete?: () => void;
51
+ /**
52
+ * Something is wrong and a tooltip nobody opens is not a disclosure — today
53
+ * this is features the layout dropped outright. Renders in an alert tone
54
+ * rather than the quiet default.
55
+ */
56
+ warning?: boolean;
57
+ }
58
+ export type TrackControlComponent = ComponentType<TrackControlProps>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import type { TrackControlOption } from './types.ts';
2
+ import type { CSSProperties } from 'react';
3
+ export interface TrackControlMenu {
4
+ /** Whether the menu is up. The caller renders `menuProps` only when true. */
5
+ open: boolean;
6
+ close: () => void;
7
+ /**
8
+ * Spread onto the button that opens the menu. `data-state` is a styling hook
9
+ * — a host's stylesheet can reach `[data-state='open']` without this having to
10
+ * take a `className` for every part.
11
+ */
12
+ triggerProps: {
13
+ 'aria-expanded': boolean;
14
+ 'aria-haspopup': 'menu';
15
+ 'data-state': 'open' | 'closed';
16
+ onClick: (event: React.MouseEvent<HTMLElement>) => void;
17
+ };
18
+ /** Spread onto the menu container — portal it to `document.body`. */
19
+ menuProps: {
20
+ ref: React.Ref<HTMLDivElement>;
21
+ role: 'menu';
22
+ tabIndex: -1;
23
+ style: CSSProperties;
24
+ };
25
+ /**
26
+ * Spread onto each option. Selecting closes and returns focus to the trigger.
27
+ *
28
+ * `role="menuitemradio"` is also how the arrow keys find their targets — the
29
+ * hook queries the container for it rather than collecting refs, so any markup
30
+ * that spreads these props is navigable without handing anything back.
31
+ */
32
+ getOptionProps: (option: TrackControlOption) => {
33
+ role: 'menuitemradio';
34
+ 'aria-checked': boolean;
35
+ onClick: () => void;
36
+ };
37
+ }
38
+ /**
39
+ * #api
40
+ * The behaviour behind a bottom-right track control's menu, as prop getters to
41
+ * spread — dismissal (Escape, an outside press, an ancestor scrolling), the
42
+ * keyboard (arrows, Home/End), focus, and the anchoring that clears both the
43
+ * display's `contain: strict` box and the window edge.
44
+ *
45
+ * For writing your own control rather than restyling `plainTrackControl`: each
46
+ * of those rules is a bug when missed and none of them shows up in a
47
+ * screenshot. Render `menuProps` only while `open`, and portal it to
48
+ * `document.body` — `createPortal` is the caller's to aim, the maths is here.
49
+ * `menuProps.style` carries position only.
50
+ */
51
+ export declare function useTrackControlMenu(onClose?: () => void): TrackControlMenu;
@@ -0,0 +1,163 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ /**
3
+ * #api
4
+ * The behaviour behind a bottom-right track control's menu, as prop getters to
5
+ * spread — dismissal (Escape, an outside press, an ancestor scrolling), the
6
+ * keyboard (arrows, Home/End), focus, and the anchoring that clears both the
7
+ * display's `contain: strict` box and the window edge.
8
+ *
9
+ * For writing your own control rather than restyling `plainTrackControl`: each
10
+ * of those rules is a bug when missed and none of them shows up in a
11
+ * screenshot. Render `menuProps` only while `open`, and portal it to
12
+ * `document.body` — `createPortal` is the caller's to aim, the maths is here.
13
+ * `menuProps.style` carries position only.
14
+ */
15
+ export function useTrackControlMenu(
16
+ // Runs on every close route — a pick, Escape, an outside press, a scroll —
17
+ // so a caller acknowledging "the menu was opened" has one place to hear it.
18
+ onClose) {
19
+ const [anchor, setAnchor] = useState(null);
20
+ const ref = useRef(null);
21
+ const onCloseRef = useRef(onClose);
22
+ onCloseRef.current = onClose;
23
+ // Mirrors `anchor` so `close` can tell a real close from a repeat — two
24
+ // routes can fire off one gesture (a scroll and the resize it causes) — and
25
+ // fire `onClose` once. Not read inside the state updater: React re-runs
26
+ // updaters to check they are pure, and a side effect there ran twice.
27
+ const openRef = useRef(false);
28
+ openRef.current = !!anchor;
29
+ const close = useCallback(() => {
30
+ if (openRef.current) {
31
+ openRef.current = false;
32
+ onCloseRef.current?.();
33
+ }
34
+ setAnchor(null);
35
+ }, []);
36
+ useEffect(() => {
37
+ if (!anchor) {
38
+ return;
39
+ }
40
+ // Found by role rather than by a collected ref: the caller owns the markup,
41
+ // and `getOptionProps` already stamps the role onto every option.
42
+ const items = () => [
43
+ ...(ref.current?.querySelectorAll('[role="menuitemradio"]') ?? []),
44
+ ];
45
+ const moveFocus = (to) => {
46
+ const list = items();
47
+ if (list.length === 0) {
48
+ return;
49
+ }
50
+ const from = list.indexOf(document.activeElement);
51
+ const next = to === 'first'
52
+ ? 0
53
+ : to === 'last'
54
+ ? list.length - 1
55
+ : from === -1
56
+ ? 0
57
+ : // wraps, which is what a menu of a handful of options wants:
58
+ // the list is short enough that walking off one end and
59
+ // arriving at the other is faster than reversing
60
+ (from + to + list.length) % list.length;
61
+ list[next]?.focus();
62
+ };
63
+ // Opening lands on the option already chosen, so the current setting is
64
+ // what a screen reader reads first and one keypress reaches its neighbour.
65
+ // Focusing the container instead announces the menu and none of its
66
+ // contents.
67
+ const list = items();
68
+ (list.find(el => el.getAttribute('aria-checked') === 'true') ??
69
+ list[0] ??
70
+ ref.current)?.focus();
71
+ const moves = {
72
+ ArrowDown: 1,
73
+ ArrowUp: -1,
74
+ Home: 'first',
75
+ End: 'last',
76
+ };
77
+ const onKey = (event) => {
78
+ if (event.key === 'Escape') {
79
+ close();
80
+ anchor.focus();
81
+ return;
82
+ }
83
+ const to = moves[event.key];
84
+ if (to !== undefined) {
85
+ // Without this the arrows scroll the page, and a page scroll closes
86
+ // this menu — so every attempt to walk the list would dismiss it.
87
+ event.preventDefault();
88
+ moveFocus(to);
89
+ }
90
+ };
91
+ // pointerdown rather than click: the press is what dismisses, and listening
92
+ // for the click would let the same press both close this and re-open it
93
+ // through the trigger underneath
94
+ const onPointerDown = (event) => {
95
+ const target = event.target;
96
+ if (target instanceof Node &&
97
+ !ref.current?.contains(target) &&
98
+ !anchor.contains(target)) {
99
+ close();
100
+ }
101
+ };
102
+ document.addEventListener('keydown', onKey);
103
+ document.addEventListener('pointerdown', onPointerDown, true);
104
+ // The position below is measured off the trigger once, at open time, so
105
+ // anything that moves the trigger afterwards leaves the menu floating
106
+ // somewhere of its own. Close rather than re-measure: the trigger is on a
107
+ // track's bottom edge and a page scroll usually takes it off screen
108
+ // entirely, so a re-measured menu would follow it out of view instead of
109
+ // going away. Capture phase, so a scroll in any ancestor counts and not just
110
+ // one on the document.
111
+ window.addEventListener('scroll', close, true);
112
+ window.addEventListener('resize', close);
113
+ return () => {
114
+ document.removeEventListener('keydown', onKey);
115
+ document.removeEventListener('pointerdown', onPointerDown, true);
116
+ window.removeEventListener('scroll', close, true);
117
+ window.removeEventListener('resize', close);
118
+ };
119
+ }, [anchor, close]);
120
+ const rect = anchor?.getBoundingClientRect();
121
+ return {
122
+ open: !!anchor,
123
+ close,
124
+ triggerProps: {
125
+ 'aria-haspopup': 'menu',
126
+ 'aria-expanded': !!anchor,
127
+ 'data-state': anchor ? 'open' : 'closed',
128
+ onClick: event => {
129
+ // don't let the click bubble to the track/view (drag-select, deselect)
130
+ event.stopPropagation();
131
+ if (anchor) {
132
+ close();
133
+ }
134
+ else {
135
+ setAnchor(event.currentTarget);
136
+ }
137
+ },
138
+ },
139
+ menuProps: {
140
+ ref,
141
+ role: 'menu',
142
+ tabIndex: -1,
143
+ style: rect
144
+ ? {
145
+ position: 'fixed',
146
+ right: Math.max(4, window.innerWidth - rect.right),
147
+ bottom: window.innerHeight - rect.top + 4,
148
+ minWidth: rect.width,
149
+ maxWidth: 'calc(100vw - 8px)',
150
+ }
151
+ : {},
152
+ },
153
+ getOptionProps: option => ({
154
+ role: 'menuitemradio',
155
+ 'aria-checked': option.selected,
156
+ onClick: () => {
157
+ option.onSelect();
158
+ close();
159
+ anchor?.focus();
160
+ },
161
+ }),
162
+ };
163
+ }
@@ -0,0 +1 @@
1
+ export declare const TrackOverlayContext: import("react").Context<HTMLElement | null>;
@@ -0,0 +1,18 @@
1
+ import { createContext } from 'react';
2
+ // A per-track DOM node the TrackContainer mounts *above* its inter-region masks
3
+ // (PaddingBlocks: elided/boundary/region-separator blocks). A display's own
4
+ // React tree — sealed inside a `contain:strict` sandbox that those masks paint
5
+ // over — can `createPortal` floating chrome into this node to escape above the
6
+ // masks, without the LGV knowing anything about that chrome. The multi-wiggle
7
+ // color legend uses it so the separators don't bury it at whole-genome scale;
8
+ // so do the tree sidebar's dendrogram panel and its per-sample row labels, which
9
+ // otherwise get striped by a separator bar at every region boundary.
10
+ //
11
+ // The node is the display's own box — same origin, same height — so chrome
12
+ // portaled here keeps the coordinates it would have had inline. A display that
13
+ // draws its chrome somewhere other than its own top-left (maf stacks bands above
14
+ // its rows) passes that offset down itself; nothing here can measure it.
15
+ //
16
+ // null when a display renders outside a TrackContainer (e.g. some tests); a
17
+ // consumer must render nothing in that case.
18
+ export const TrackOverlayContext = createContext(null);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * #api
3
+ * Lift floating track chrome out of the display's `contain: strict` sandbox and
4
+ * into the host's overlay node, so the LGV's inter-region masks cannot bury it
5
+ * at multi-region scale. `TrackOverlaySlot` is the other end.
6
+ *
7
+ * The overlay node takes no pointer events, so anything of yours the user
8
+ * hovers or clicks sets `pointer-events: auto` on its own positioned box.
9
+ *
10
+ * With no slot above it this renders the children in place (`fallbackInline`,
11
+ * the default), which preserves chrome that is still meaningful unescaped. Pass
12
+ * `fallbackInline={false}` for chrome that only makes sense above the masks and
13
+ * draws itself some other way elsewhere.
14
+ */
15
+ export declare const TrackOverlayPortal: ({ children, fallbackInline, }: {
16
+ children: React.ReactNode;
17
+ fallbackInline?: boolean;
18
+ }) => import("react").ReactNode;