@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.
- package/LICENSE +201 -0
- package/README.md +433 -0
- package/esm/DisplayUIProvider.d.ts +70 -0
- package/esm/DisplayUIProvider.js +96 -0
- package/esm/FloatingLegend.d.ts +16 -0
- package/esm/FloatingLegend.js +213 -0
- package/esm/PointerLayer.d.ts +26 -0
- package/esm/PointerLayer.js +27 -0
- package/esm/bottomRightCorner.d.ts +58 -0
- package/esm/bottomRightCorner.js +59 -0
- package/esm/chromeOverlayContext.d.ts +7 -0
- package/esm/chromeOverlayContext.js +44 -0
- package/esm/chromeOverlays.d.ts +88 -0
- package/esm/chromeOverlays.js +1 -0
- package/esm/index.d.ts +22 -0
- package/esm/index.js +51 -0
- package/esm/isLiveModel.d.ts +21 -0
- package/esm/isLiveModel.js +24 -0
- package/esm/plainChromeOverlays.d.ts +18 -0
- package/esm/plainChromeOverlays.js +189 -0
- package/esm/tooLargeBannerText.d.ts +18 -0
- package/esm/tooLargeBannerText.js +34 -0
- package/esm/tooltip/Tooltip.d.ts +52 -0
- package/esm/tooltip/Tooltip.js +67 -0
- package/esm/tooltip/useTooltip.d.ts +50 -0
- package/esm/tooltip/useTooltip.js +116 -0
- package/esm/trackControl/plainTrackControl.d.ts +17 -0
- package/esm/trackControl/plainTrackControl.js +133 -0
- package/esm/trackControl/trackControlContext.d.ts +7 -0
- package/esm/trackControl/trackControlContext.js +30 -0
- package/esm/trackControl/types.d.ts +58 -0
- package/esm/trackControl/types.js +1 -0
- package/esm/trackControl/useTrackControlMenu.d.ts +51 -0
- package/esm/trackControl/useTrackControlMenu.js +163 -0
- package/esm/trackOverlay/TrackOverlayContext.d.ts +1 -0
- package/esm/trackOverlay/TrackOverlayContext.js +18 -0
- package/esm/trackOverlay/TrackOverlayPortal.d.ts +18 -0
- package/esm/trackOverlay/TrackOverlayPortal.js +66 -0
- package/esm/trackOverlay/TrackOverlaySlot.d.ts +54 -0
- package/esm/trackOverlay/TrackOverlaySlot.js +60 -0
- 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;
|