@jbrowse/app-core 5.0.0-beta.1 → 5.0.0-beta.11

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 (75) hide show
  1. package/esm/AppSession/AppSessionMixin.d.ts +42 -316
  2. package/esm/Assemblies/AssembliesMixin.d.ts +172 -103
  3. package/esm/Assemblies/AssembliesMixin.js +13 -8
  4. package/esm/HistoryManagement/index.d.ts +6 -11
  5. package/esm/HistoryManagement/index.js +24 -28
  6. package/esm/JBrowseConfig/RootConfiguration.d.ts +80 -124
  7. package/esm/JBrowseConfig/RootConfiguration.js +5 -11
  8. package/esm/JBrowseConfig/defaultShareUrl.d.ts +1 -0
  9. package/esm/JBrowseConfig/defaultShareUrl.js +5 -0
  10. package/esm/JBrowseConfig/index.d.ts +89 -83
  11. package/esm/JBrowseConfig/index.js +6 -4
  12. package/esm/JBrowseModel/index.d.ts +93 -96
  13. package/esm/JBrowseModel/index.js +43 -43
  14. package/esm/JbApi/index.d.ts +2 -0
  15. package/esm/JbApi/index.js +1 -0
  16. package/esm/JbApi/jbApi.d.ts +2767 -0
  17. package/esm/JbApi/jbApi.js +1383 -0
  18. package/esm/RootMenu/index.d.ts +2 -11
  19. package/esm/RootMenu/index.js +0 -11
  20. package/esm/SessionSpec/index.d.ts +1 -1
  21. package/esm/SessionSpec/index.js +1 -1
  22. package/esm/SessionSpec/lgvUrlInit.js +5 -2
  23. package/esm/SessionSpec/loadSessionSpec.d.ts +56 -2
  24. package/esm/SessionSpec/loadSessionSpec.js +285 -123
  25. package/esm/SessionSpec/parseSessionSpecUrl.d.ts +2 -8
  26. package/esm/SessionSpec/parseSessionSpecUrl.js +2 -17
  27. package/esm/SessionSpec/types.d.ts +16 -12
  28. package/esm/WorkspaceLayout/WorkspaceContainer.js +4 -2
  29. package/esm/WorkspaceLayout/WorkspaceTab.js +2 -6
  30. package/esm/WorkspaceLayout/model.d.ts +25 -5
  31. package/esm/WorkspaceLayout/model.js +77 -18
  32. package/esm/WorkspaceLayout/spec.d.ts +40 -10
  33. package/esm/WorkspaceLayout/spec.js +81 -8
  34. package/esm/index.d.ts +4 -2
  35. package/esm/index.js +4 -1
  36. package/esm/menus.d.ts +15 -29
  37. package/esm/menus.js +76 -92
  38. package/esm/queryParams.d.ts +1 -1
  39. package/esm/queryParams.js +10 -9
  40. package/esm/ui/App/App.js +18 -11
  41. package/esm/ui/App/AppToolbar.js +23 -8
  42. package/esm/ui/App/ClassicViewsContainer.d.ts +2 -2
  43. package/esm/ui/App/DialogQueue.d.ts +2 -2
  44. package/esm/ui/App/DialogQueue.js +3 -3
  45. package/esm/ui/App/DrawerHeader.js +2 -3
  46. package/esm/ui/App/ThemeModeButton.d.ts +12 -0
  47. package/esm/ui/App/ThemeModeButton.js +34 -0
  48. package/esm/ui/App/ViewButtons.js +2 -2
  49. package/esm/ui/App/ViewContainer.js +1 -17
  50. package/esm/ui/App/ViewContainerTitle.js +15 -12
  51. package/esm/ui/App/ViewLauncher.js +1 -1
  52. package/esm/ui/App/ViewMenu.js +2 -2
  53. package/esm/ui/App/ViewStack.js +7 -1
  54. package/esm/ui/App/ViewsContainer.d.ts +2 -4
  55. package/esm/ui/App/ViewsContainer.js +3 -9
  56. package/esm/ui/App/index.d.ts +1 -1
  57. package/esm/ui/App/index.js +1 -1
  58. package/esm/ui/App/lazyParts.d.ts +32 -0
  59. package/esm/ui/App/lazyParts.js +8 -0
  60. package/esm/ui/App/preloadAppShell.d.ts +14 -0
  61. package/esm/ui/App/preloadAppShell.js +41 -0
  62. package/esm/ui/App/types.d.ts +12 -7
  63. package/esm/ui/App/useViewVisibility.d.ts +10 -9
  64. package/esm/ui/App/useViewVisibility.js +12 -11
  65. package/esm/ui/App/viewTitle.d.ts +10 -3
  66. package/esm/ui/App/viewTitle.js +13 -5
  67. package/esm/ui/index.d.ts +1 -1
  68. package/esm/ui/index.js +1 -1
  69. package/package.json +7 -7
  70. package/esm/ui/App/AppReadyMarker.d.ts +0 -34
  71. package/esm/ui/App/AppReadyMarker.js +0 -56
  72. package/esm/ui/App/DrawerControls.d.ts +0 -1
  73. package/esm/ui/App/DrawerControls.js +0 -1
  74. package/esm/ui/App/copyView.d.ts +0 -1
  75. package/esm/ui/App/copyView.js +0 -29
package/esm/menus.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MenuItem, MenuItemsGetter } from '@jbrowse/core/ui';
1
+ import type { MenuItem } from '@jbrowse/core/ui/menuItems';
2
2
  /**
3
3
  * Ensure a top-level menu exists. `position` appends when omitted and counts
4
4
  * from the end when negative. A menu whose label is already on the bar is left
@@ -22,47 +22,33 @@ interface AddItemAction {
22
22
  menuItem: MenuItem;
23
23
  position?: number;
24
24
  }
25
- interface SetMenusAction {
26
- type: 'setMenus';
27
- newMenus: MenuDefinition[];
28
- }
29
- /**
30
- * `addMenu`/`setMenus` change which menus exist, so they resolve eagerly — the
31
- * app bar renders one button per menu before anything is opened. `addItem` only
32
- * changes a menu's contents, so it resolves when that menu opens, against
33
- * whatever its items are at that moment.
34
- */
35
- export type MenuAction = AddMenuAction | AddItemAction | SetMenusAction;
36
25
  /**
37
- * A menu as a root model authors it. Items may be a plain array, or a thunk for
38
- * a menu whose contents depend on state that would otherwise be read — and so
39
- * tracked — every time the app bar renders.
26
+ * `addMenu` changes which menus exist, so it resolves eagerly — the app bar
27
+ * renders one button per menu before anything is opened. `addItem` only changes
28
+ * a menu's contents, so it resolves when that menu opens, against whatever its
29
+ * items are at that moment.
40
30
  */
41
- export interface MenuDefinition {
42
- label: string;
43
- menuItems: MenuItemsGetter;
44
- }
31
+ export type MenuAction = AddMenuAction | AddItemAction;
45
32
  /**
46
- * A menu as the app bar consumes it. Always a thunk returning a fresh array: a
47
- * menu's items are produced when it opens and never before, so no consumer has
48
- * to care which form the root model authored, and a plugin's contributions are
49
- * merged in at that point rather than spliced into the definition.
33
+ * A top-level menu. `menuItems` is a thunk the app bar calls when the menu
34
+ * opens and never before, so a root model's reads stay out of the bar's
35
+ * tracking scope and a plugin's contributions are merged onto each fresh
36
+ * result rather than spliced into a definition.
50
37
  */
51
38
  export interface Menu {
52
39
  label: string;
53
40
  menuItems: () => MenuItem[];
54
41
  }
55
42
  /**
56
- * Resolve a root model's menu definitions against the contributions plugins
57
- * have pushed (via `RootAppMenuMixin`). Structural contributions apply now;
58
- * item contributions are recorded against their target menu and applied when
59
- * that menu opens, so a menu that computes its items lazily stays lazy and
60
- * nothing is ever spliced into a definition.
43
+ * Resolve a root model's menus against the contributions plugins have pushed
44
+ * (via `RootAppMenuMixin`). Structural contributions apply now; item
45
+ * contributions are grouped by path and merged when the menu they target
46
+ * opens.
61
47
  *
62
48
  * Runs on every `menus()` evaluation, so it must not mutate `base` or anything
63
49
  * an action carries.
64
50
  */
65
- export declare function processMutableMenuActions(base: MenuDefinition[], actions: MenuAction[]): Menu[];
51
+ export declare function processMutableMenuActions(base: Menu[], actions: MenuAction[]): Menu[];
66
52
  /**
67
53
  * Flatten resolved menus to plain data by opening every one of them. For tests
68
54
  * and other callers that want to assert on menu contents rather than render
package/esm/menus.js CHANGED
@@ -1,82 +1,77 @@
1
- // a contribution that threw is reported once rather than on every open — the
2
- // menu re-resolves on each open and, while open, on each observer re-render
1
+ // a conflict is reported once rather than on every open — the menu re-resolves
2
+ // on each open and, while open, on each observer re-render — and the action is
3
+ // what outlives the nodes, which are rebuilt on every menus() evaluation
3
4
  const reported = new WeakSet();
4
- /**
5
- * The action log is permanent; the menu it builds is thrown away when the menu
6
- * closes. Everything a resolution walks therefore has to belong to the
7
- * throwaway, because resolving a path is a **write**:
8
- * `appendToSubMenu(['Add', 'My plugin'], two)` means "find the array labelled
9
- * My plugin and push into it".
10
- *
11
- * So when an earlier action is what supplied that array —
12
- * `appendToMenu('Add', { label: 'My plugin', subMenu: [one] })` — and it goes
13
- * into the menu by reference, the push lands in the stored action instead. The
14
- * next open replays a log that now reads `subMenu: [one, two]` and pushes
15
- * again, so the sub-menu gains an item on every open, and on every observer
16
- * re-render while open.
17
- *
18
- * Cloning is what keeps the two apart: the copy is what the path resolves to
19
- * and what gets written, and it dies with the open. Leaf items are shared by
20
- * reference — nothing writes into them — so this costs an allocation only for
21
- * the rows that nest.
22
- */
23
- function cloneMenuItem(item) {
24
- return 'subMenu' in item
25
- ? { ...item, subMenu: cloneMenuItems(item.subMenu) }
26
- : item;
27
- }
28
- function cloneMenuItems(items) {
29
- return items.map(item => cloneMenuItem(item));
30
- }
31
- function materialize(menuItems) {
32
- return typeof menuItems === 'function' ? menuItems() : menuItems;
5
+ function register(node, action, depth) {
6
+ const label = action.menuPath[depth];
7
+ if (label === undefined) {
8
+ node.entries.push(action);
9
+ }
10
+ else {
11
+ let entry = node.entries.find((e) => e.type === 'subMenu' && e.label === label);
12
+ if (!entry) {
13
+ entry = {
14
+ type: 'subMenu',
15
+ label,
16
+ path: action.menuPath.slice(0, depth + 1),
17
+ node: { entries: [] },
18
+ first: action,
19
+ };
20
+ node.entries.push(entry);
21
+ }
22
+ register(entry.node, action, depth + 1);
23
+ }
33
24
  }
34
25
  // splice is already the "counts from the end when negative" rule, and clamps
35
26
  // rather than wrapping past the start; the default is what makes it an append
36
27
  function insertAt(items, item, position = items.length) {
37
28
  items.splice(position, 0, item);
38
29
  }
30
+ // the author's form is kept: an array sub-menu resolves now, a thunk sub-menu
31
+ // resolves when its panel opens, with the contributions merged in either way
32
+ function withContributions(item, node) {
33
+ const { subMenu } = item;
34
+ return {
35
+ ...item,
36
+ subMenu: typeof subMenu === 'function'
37
+ ? () => apply(subMenu(), node)
38
+ : apply(subMenu, node),
39
+ };
40
+ }
39
41
  /**
40
- * Walk `menuPath` past its first segment (the top-level menu, already
41
- * resolved), creating empty sub-menus as needed, and return the deepest
42
- * sub-menu's item array. Throws if a path segment names an item that is not a
43
- * sub-menu.
42
+ * Return a copy of `items` with a node's contributions merged in. Nothing is
43
+ * written into `items`, into any sub-menu it holds, or into a contribution's
44
+ * own payload: a sub-menu that gains contributions is replaced by a copy whose
45
+ * `subMenu` resolves them, so the arrays behind a definition or an action are
46
+ * the same on every open.
47
+ *
48
+ * A path segment naming a row that is not a sub-menu is a plugin bug. It
49
+ * costs that subtree of contributions, reported once, and nothing else — this
50
+ * runs from the app bar's click handler, where a throw goes straight through
51
+ * React.
44
52
  */
45
- function resolveSubMenu(items, menuPath) {
46
- let level = items;
47
- for (const [idx, menuName] of menuPath.slice(1).entries()) {
48
- let sub = level.find((mi) => 'subMenu' in mi && mi.label === menuName);
49
- if (!sub) {
50
- if (level.some(mi => 'label' in mi && mi.label === menuName)) {
51
- const pathSoFar = menuPath.slice(0, idx + 2).join(' > ');
52
- throw new Error(`"${menuName}" in path "${pathSoFar}" is not a subMenu`);
53
- }
54
- sub = { label: menuName, subMenu: [] };
55
- level.push(sub);
53
+ function apply(items, node) {
54
+ const result = [...items];
55
+ for (const entry of node.entries) {
56
+ if (entry.type === 'addItem') {
57
+ insertAt(result, entry.menuItem, entry.position);
56
58
  }
57
- level = sub.subMenu;
58
- }
59
- return level;
60
- }
61
- // each contribution is applied independently: a plugin that names a bad path
62
- // loses its own item rather than every other plugin's, and never the session —
63
- // this runs from the app bar's click handler, where a throw goes straight
64
- // through React
65
- function applyItemActions(items, actions) {
66
- for (const action of actions) {
67
- try {
68
- const target = resolveSubMenu(items, action.menuPath);
69
- // cloned rather than inserted by reference — see cloneMenuItem
70
- insertAt(target, cloneMenuItem(action.menuItem), action.position);
71
- }
72
- catch (error) {
73
- if (!reported.has(action)) {
74
- reported.add(action);
75
- console.error(error);
59
+ else {
60
+ const idx = result.findIndex(mi => 'label' in mi && mi.label === entry.label);
61
+ const found = result[idx];
62
+ if (found === undefined) {
63
+ result.push({ label: entry.label, subMenu: apply([], entry.node) });
64
+ }
65
+ else if ('subMenu' in found) {
66
+ result[idx] = withContributions(found, entry.node);
67
+ }
68
+ else if (!reported.has(entry.first)) {
69
+ reported.add(entry.first);
70
+ console.error(new Error(`"${entry.label}" in path "${entry.path.join(' > ')}" is not a subMenu`));
76
71
  }
77
72
  }
78
73
  }
79
- return items;
74
+ return result;
80
75
  }
81
76
  // first menu with this label, or a new empty one — an item action naming a menu
82
77
  // that doesn't exist creates it, so a plugin can populate its own menu without
@@ -86,47 +81,33 @@ function findOrCreateMenu(pending, label, position) {
86
81
  if (found) {
87
82
  return found;
88
83
  }
89
- const menu = { label, base: [], itemActions: [] };
90
- insertAt(pending, menu, position);
91
- return menu;
92
- }
93
- function toMenu({ label, base, itemActions }) {
94
- return {
84
+ const menu = {
95
85
  label,
96
- menuItems: () => applyItemActions(cloneMenuItems(materialize(base)), itemActions),
86
+ menuItems: () => [],
87
+ node: { entries: [] },
97
88
  };
89
+ insertAt(pending, menu, position);
90
+ return menu;
98
91
  }
99
92
  /**
100
- * Resolve a root model's menu definitions against the contributions plugins
101
- * have pushed (via `RootAppMenuMixin`). Structural contributions apply now;
102
- * item contributions are recorded against their target menu and applied when
103
- * that menu opens, so a menu that computes its items lazily stays lazy and
104
- * nothing is ever spliced into a definition.
93
+ * Resolve a root model's menus against the contributions plugins have pushed
94
+ * (via `RootAppMenuMixin`). Structural contributions apply now; item
95
+ * contributions are grouped by path and merged when the menu they target
96
+ * opens.
105
97
  *
106
98
  * Runs on every `menus()` evaluation, so it must not mutate `base` or anything
107
99
  * an action carries.
108
100
  */
109
101
  export function processMutableMenuActions(base, actions) {
110
- const toPending = (m) => ({
111
- label: m.label,
112
- base: m.menuItems,
113
- itemActions: [],
114
- });
115
- let pending = base.map(toPending);
102
+ const pending = base.map(m => ({ ...m, node: { entries: [] } }));
116
103
  for (const action of actions) {
117
104
  switch (action.type) {
118
- case 'setMenus': {
119
- // replaces the menu bar wholesale, so item contributions made before it
120
- // are dropped along with the menus they targeted
121
- pending = action.newMenus.map(toPending);
122
- break;
123
- }
124
105
  case 'addMenu': {
125
106
  findOrCreateMenu(pending, action.menuName, action.position);
126
107
  break;
127
108
  }
128
109
  case 'addItem': {
129
- findOrCreateMenu(pending, action.menuPath[0]).itemActions.push(action);
110
+ register(findOrCreateMenu(pending, action.menuPath[0]).node, action, 1);
130
111
  break;
131
112
  }
132
113
  default: {
@@ -134,7 +115,10 @@ export function processMutableMenuActions(base, actions) {
134
115
  }
135
116
  }
136
117
  }
137
- return pending.map(m => toMenu(m));
118
+ return pending.map(({ label, menuItems, node }) => ({
119
+ label,
120
+ menuItems: () => apply(menuItems(), node),
121
+ }));
138
122
  }
139
123
  /**
140
124
  * Flatten resolved menus to plain data by opening every one of them. For tests
@@ -1,5 +1,5 @@
1
1
  export declare function readQueryParams<T extends string>(keys: T[]): Record<T, string | undefined>;
2
- export declare function readAllQueryParams(): URLSearchParams;
2
+ export declare function readAllQueryParams(url?: URL | Location): URLSearchParams;
3
3
  export declare function deleteQueryParams(keys: readonly string[]): void;
4
4
  export declare function setQueryParams(values: Record<string, string | undefined>): void;
5
5
  /**
@@ -1,11 +1,11 @@
1
1
  import { useCallback, useSyncExternalStore } from 'react';
2
2
  // The single decision of where this URL keeps its params. Computed once per
3
3
  // operation and threaded through read+write so the two can't disagree.
4
- function paramLocation() {
5
- return window.location.hash.includes('=') ? 'hash' : 'search';
4
+ function paramLocation(url = window.location) {
5
+ return url.hash.includes('=') ? 'hash' : 'search';
6
6
  }
7
- function readParams(loc) {
8
- return new URLSearchParams(loc === 'hash' ? window.location.hash.slice(1) : window.location.search);
7
+ function readParams(loc, url = window.location) {
8
+ return new URLSearchParams(loc === 'hash' ? url.hash.slice(1) : url.search);
9
9
  }
10
10
  // history.replaceState fires no event, so a write has to notify readers itself.
11
11
  // popstate alone covers only back/forward, which nothing here does: desktop's
@@ -52,11 +52,12 @@ export function readQueryParams(keys) {
52
52
  }
53
53
  return result;
54
54
  }
55
- // Reads the full current param set from wherever this URL keeps them (hash XOR
56
- // search). Producers that need every param at once (buildShareUrl) share this
57
- // so the hash/search decision stays in this one module.
58
- export function readAllQueryParams() {
59
- return readParams(paramLocation());
55
+ // Reads the full param set from wherever a URL keeps them (hash XOR search),
56
+ // the page's own by default. Producers that need every param at once
57
+ // (buildShareUrl) share this so the hash/search decision stays in this one
58
+ // module.
59
+ export function readAllQueryParams(url = window.location) {
60
+ return readParams(paramLocation(url), url);
60
61
  }
61
62
  export function deleteQueryParams(keys) {
62
63
  const loc = paramLocation();
package/esm/ui/App/App.js CHANGED
@@ -1,18 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Suspense, lazy } from 'react';
2
+ import { Suspense, useEffect } from 'react';
3
3
  import Snackbar from '@jbrowse/core/ui/Snackbar';
4
4
  import { makeStyles } from '@jbrowse/core/util/tss-react';
5
- import { ModalWidget, drawerGridTemplateColumns } from '@jbrowse/product-core';
6
- import { AppBar } from '@mui/material';
5
+ import { AppReadyMarker, ModalWidget, drawerGridTemplateColumns, } from '@jbrowse/product-core';
6
+ import { AppBar, useMediaQuery, useTheme } from '@mui/material';
7
7
  import { observer } from 'mobx-react';
8
8
  import AppFab from './AppFab.js';
9
- import AppReadyMarker from './AppReadyMarker.js';
10
9
  import AppToolbar from './AppToolbar.js';
11
10
  import DialogQueue from './DialogQueue.js';
12
11
  import ViewsContainer from './ViewsContainer.js';
13
- import { lazyChunk } from './lazyChunk.js';
14
- // lazies
15
- const DrawerWidget = lazy(lazyChunk('DrawerWidget', () => import('./DrawerWidget.js')));
12
+ import { DrawerWidget } from './lazyParts.js';
16
13
  const useStyles = makeStyles()(theme => ({
17
14
  root: {
18
15
  display: 'grid',
@@ -33,6 +30,9 @@ const useStyles = makeStyles()(theme => ({
33
30
  gridColumn: 'main',
34
31
  display: 'grid',
35
32
  gridTemplateRows: '[menubar] min-content [components] minmax(0, 1fr)',
33
+ // an implicit column floors at the toolbar's min-content, which widened
34
+ // the page past a phone's screen rather than ellipsizing the session name
35
+ gridTemplateColumns: 'minmax(0, 1fr)',
36
36
  height: '100%',
37
37
  },
38
38
  appBar: {
@@ -43,14 +43,21 @@ const useStyles = makeStyles()(theme => ({
43
43
  const App = observer(function App(props) {
44
44
  const { session } = props;
45
45
  const { classes } = useStyles();
46
- const { drawerVisible, drawerWidth, drawerPosition, poppedOut } = session;
46
+ const { drawerVisible, drawerWidth, drawerPosition, modalWidgetVisible } = session;
47
+ // a phone has no room for a drawer column beside the views
48
+ const narrow = useMediaQuery(useTheme().breakpoints.down('sm'), {
49
+ noSsr: true,
50
+ });
51
+ useEffect(() => {
52
+ session.setModalWidgets(narrow);
53
+ }, [session, narrow]);
47
54
  const gridTemplateColumns = drawerGridTemplateColumns({
48
55
  drawerVisible,
49
56
  drawerPosition,
50
57
  drawerWidth,
51
58
  });
52
- return (_jsxs("div", { className: classes.root, style: { gridTemplateColumns }, children: [poppedOut ? (_jsx(ModalWidget, { session: session, onClose: () => {
53
- session.returnWidgetToDrawer();
54
- } })) : null, _jsx(DialogQueue, { session: session }), _jsxs("div", { className: classes.appContainer, children: [_jsx(AppBar, { className: classes.appBar, position: "static", "data-testid": "app-bar", children: _jsx(AppToolbar, { ...props }) }), _jsx(ViewsContainer, { ...props })] }), _jsx(AppFab, { session: session }), _jsx(AppReadyMarker, { session: session }), drawerVisible ? (_jsx(Suspense, { fallback: null, children: _jsx(DrawerWidget, { session: session }) })) : null, _jsx(Snackbar, { session: session })] }));
59
+ return (_jsxs("div", { className: classes.root, style: { gridTemplateColumns }, children: [modalWidgetVisible ? (_jsx(ModalWidget, { session: session, fullScreen: narrow, onClose: () => {
60
+ session.closeModalWidget();
61
+ } })) : null, _jsx(DialogQueue, { session: session }), _jsxs("div", { className: classes.appContainer, children: [_jsx(AppBar, { className: classes.appBar, position: "static", "data-testid": "app-bar", children: _jsx(AppToolbar, { ...props }) }), _jsx(ViewsContainer, { session: session })] }), _jsx(AppFab, { session: session }), _jsx(AppReadyMarker, { session: session }), drawerVisible ? (_jsx(Suspense, { fallback: null, children: _jsx(DrawerWidget, { session: session }) })) : null, _jsx(Snackbar, { session: session })] }));
55
62
  });
56
63
  export { App };
@@ -3,14 +3,24 @@ import AppLogo from '@jbrowse/core/ui/AppLogo';
3
3
  import DropDownMenu from '@jbrowse/core/ui/DropDownMenu';
4
4
  import EditableTypography from '@jbrowse/core/ui/EditableTypography';
5
5
  import { makeStyles } from '@jbrowse/core/util/tss-react';
6
- import { Toolbar, Tooltip } from '@mui/material';
6
+ import { Toolbar, Tooltip, useMediaQuery, useTheme } from '@mui/material';
7
7
  import { observer } from 'mobx-react';
8
+ import ThemeModeButton from './ThemeModeButton.js';
8
9
  const useStyles = makeStyles()(theme => ({
9
10
  grow: {
10
11
  flexGrow: 1,
11
12
  },
13
+ // a long session name ellipsizes rather than pushing Share off a phone
14
+ sessionName: {
15
+ minWidth: 0,
16
+ },
12
17
  inputBase: {
13
18
  color: theme.palette.primary.contrastText,
19
+ maxWidth: '100%',
20
+ },
21
+ input: {
22
+ maxWidth: '100%',
23
+ textOverflow: 'ellipsis',
14
24
  },
15
25
  inputRoot: {
16
26
  '&:hover': {
@@ -40,6 +50,10 @@ const useStyles = makeStyles()(theme => ({
40
50
  const AppToolbar = observer(function AppToolbar({ session, HeaderButtons = _jsx("div", {}), }) {
41
51
  const { classes } = useStyles();
42
52
  const { name, menus } = session;
53
+ // a phone needs the logo's 150px for the menus and Share
54
+ const narrow = useMediaQuery(useTheme().breakpoints.down('sm'), {
55
+ noSsr: true,
56
+ });
43
57
  return (_jsxs(Toolbar, { children: [menus().map(menu => (_jsx(DropDownMenu, { menuTitle: menu.label, menuItems: menu.menuItems,
44
58
  // a root model's menu items take the session as their argument, and
45
59
  // the renderer decides how to invoke onClick — so this is the whole
@@ -47,12 +61,13 @@ const AppToolbar = observer(function AppToolbar({ session, HeaderButtons = _jsx(
47
61
  // menu, rebuilt on each open, to arrive at the same call
48
62
  onMenuItemClick: callback => {
49
63
  callback(session);
50
- } }, menu.label))), _jsx("div", { className: classes.grow }), _jsx(Tooltip, { title: "Rename session", arrow: true, children: _jsx(EditableTypography, { value: name, variant: "body1", classes: {
51
- inputBase: classes.inputBase,
52
- inputRoot: classes.inputRoot,
53
- inputFocused: classes.inputFocused,
54
- }, setValue: newName => {
55
- session.renameCurrentSession(newName);
56
- } }) }), HeaderButtons, _jsx("div", { className: classes.grow }), _jsx("div", { className: classes.logo, children: _jsx(AppLogo, { session: session }) })] }));
64
+ } }, menu.label))), _jsx("div", { className: classes.grow }), _jsx(Tooltip, { title: "Rename session", arrow: true, children: _jsx("div", { className: classes.sessionName, children: _jsx(EditableTypography, { value: name, variant: "body1", classes: {
65
+ input: classes.input,
66
+ inputBase: classes.inputBase,
67
+ inputRoot: classes.inputRoot,
68
+ inputFocused: classes.inputFocused,
69
+ }, setValue: newName => {
70
+ session.renameCurrentSession(newName);
71
+ } }) }) }), HeaderButtons, _jsx(ThemeModeButton, { session: session }), _jsx("div", { className: classes.grow }), narrow ? null : (_jsx("div", { className: classes.logo, children: _jsx(AppLogo, { session: session }) }))] }));
57
72
  });
58
73
  export default AppToolbar;
@@ -1,6 +1,6 @@
1
1
  import type { SessionWithFocusedViewAndDrawerWidgets } from '@jbrowse/core/util';
2
- interface Props {
2
+ export interface ClassicViewsContainerProps {
3
3
  session: SessionWithFocusedViewAndDrawerWidgets;
4
4
  }
5
- declare const ClassicViewsContainer: ({ session, }: Props) => import("react").JSX.Element;
5
+ declare const ClassicViewsContainer: ({ session, }: ClassicViewsContainerProps) => import("react").JSX.Element;
6
6
  export default ClassicViewsContainer;
@@ -12,8 +12,8 @@ import type { AbstractSessionModel } from '@jbrowse/core/util';
12
12
  * jbrowse-web's fatal-error dialog, which takes every view the user had open.
13
13
  * ADR-069, and `cancer_sv/multihop_split_view` is the figure it took.
14
14
  *
15
- * Keyed on the component so the queue advancing to a different dialog clears a
16
- * banner belonging to the one before it.
15
+ * Reset on the queue entry, so the queue advancing clears a banner belonging to
16
+ * the dialog before it, including one of the same component.
17
17
  */
18
18
  declare const DialogQueue: ({ session, }: {
19
19
  session: AbstractSessionModel;
@@ -25,12 +25,12 @@ function dismissActiveDialog(session) {
25
25
  * jbrowse-web's fatal-error dialog, which takes every view the user had open.
26
26
  * ADR-069, and `cancer_sv/multihop_split_view` is the figure it took.
27
27
  *
28
- * Keyed on the component so the queue advancing to a different dialog clears a
29
- * banner belonging to the one before it.
28
+ * Reset on the queue entry, so the queue advancing clears a banner belonging to
29
+ * the dialog before it, including one of the same component.
30
30
  */
31
31
  const DialogQueue = observer(function DialogQueue({ session, }) {
32
32
  const { DialogComponent, DialogProps } = session;
33
- return DialogComponent ? (_jsx(ErrorBoundary, { resetKeys: [DialogComponent], FallbackComponent: ({ error }) => (_jsx(Dialog, { open: true, title: "Error", onClose: () => {
33
+ return DialogComponent ? (_jsx(ErrorBoundary, { resetKeys: [DialogComponent, DialogProps], FallbackComponent: ({ error }) => (_jsx(Dialog, { open: true, title: "Error", onClose: () => {
34
34
  dismissActiveDialog(session);
35
35
  }, children: _jsx(DialogContent, { children: _jsx(ErrorMessage, { error: error }) }) })), children: _jsx(Suspense, { fallback: null, children: _jsx(DialogComponent, { ...DialogProps }) }) })) : null;
36
36
  });
@@ -2,11 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Suspense, lazy, useCallback } from 'react';
3
3
  import { getEnv } from '@jbrowse/core/util';
4
4
  import { makeStyles } from '@jbrowse/core/util/tss-react';
5
- import { DrawerWidgetSelector } from '@jbrowse/product-core';
5
+ import { DrawerControls, DrawerWidgetSelector } from '@jbrowse/product-core';
6
6
  import LaunchIcon from '@mui/icons-material/Launch';
7
7
  import { AppBar, IconButton, Toolbar, Tooltip } from '@mui/material';
8
8
  import { observer } from 'mobx-react';
9
- import DrawerControls from './DrawerControls.js';
10
9
  const DrawerHeaderHelpButton = lazy(() => import('./DrawerHeaderHelpButton.js'));
11
10
  const useStyles = makeStyles()(theme => ({
12
11
  spacer: {
@@ -45,7 +44,7 @@ const DrawerHeader = observer(function DrawerHeader({ session, setToolbarHeight,
45
44
  observer.disconnect();
46
45
  };
47
46
  }, [setToolbarHeight]);
48
- return (_jsx(AppBar, { position: "sticky", className: focusedViewId === viewWidgetId
47
+ return (_jsx(AppBar, { position: "sticky", className: viewWidgetId !== undefined && focusedViewId === viewWidgetId
49
48
  ? classes.headerFocused
50
49
  : classes.headerUnfocused, ref: appBarRef, children: _jsxs(Toolbar, { disableGutters: true, children: [_jsx(DrawerWidgetSelector, { session: session }), _jsx(Tooltip, { title: "Open drawer in dialog", children: _jsx(IconButton, { color: "inherit", "data-testid": "drawer-popout", onClick: () => {
51
50
  session.popoutWidget();
@@ -0,0 +1,12 @@
1
+ import type { ThemeSwitchSession } from './types.ts';
2
+ /**
3
+ * A light/dark switch for a session that follows the OS, showing the mode
4
+ * drawn. A click holds the other mode, a second click follows the OS again. A
5
+ * session on an explicit light or dark gets nothing, which is every session
6
+ * that has not asked for this in Preferences, so a light reader is offered no
7
+ * route into dark.
8
+ */
9
+ declare const ThemeModeButton: ({ session, }: {
10
+ session: ThemeSwitchSession;
11
+ }) => import("react").JSX.Element | null;
12
+ export default ThemeModeButton;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import DarkModeIcon from '@mui/icons-material/DarkMode';
3
+ import LightModeIcon from '@mui/icons-material/LightMode';
4
+ import { IconButton, Tooltip } from '@mui/material';
5
+ import { observer } from 'mobx-react';
6
+ /**
7
+ * A light/dark switch for a session that follows the OS, showing the mode
8
+ * drawn. A click holds the other mode, a second click follows the OS again. A
9
+ * session on an explicit light or dark gets nothing, which is every session
10
+ * that has not asked for this in Preferences, so a light reader is offered no
11
+ * route into dark.
12
+ */
13
+ const ThemeModeButton = observer(function ThemeModeButton({ session, }) {
14
+ const dark = session.themeIsDark;
15
+ const drawnMode = dark ? 'dark' : 'light';
16
+ // A palette pinned to its own mode draws dark whatever the OS says, so the
17
+ // two disagree and the system is steering nothing to report. Everything else
18
+ // that hides this control is `themeMode`.
19
+ const followsSystem = session.themeMode === 'system' && drawnMode === session.effectiveThemeMode;
20
+ if (!followsSystem) {
21
+ return null;
22
+ }
23
+ const held = session.systemThemeOverride;
24
+ const otherMode = dark ? 'light' : 'dark';
25
+ const Icon = dark ? DarkModeIcon : LightModeIcon;
26
+ const label = held
27
+ ? `${dark ? 'Dark' : 'Light'} until your system theme changes`
28
+ : `Following your system theme (${drawnMode})`;
29
+ const action = held ? `to follow it (${otherMode})` : `for ${otherMode}`;
30
+ return (_jsx(Tooltip, { title: `${label}. Click ${action}.`, arrow: true, children: _jsx(IconButton, { "data-testid": "theme-mode-button", "aria-label": label, color: "inherit", size: "small", onClick: () => {
31
+ session.setSystemThemeOverride(held ? undefined : otherMode);
32
+ }, children: _jsx(Icon, { fontSize: "small" }) }) }));
33
+ });
34
+ export default ThemeModeButton;
@@ -20,9 +20,9 @@ const useStyles = makeStyles()(theme => ({
20
20
  const ViewButtons = observer(function ViewButtons({ view, }) {
21
21
  const { classes } = useStyles();
22
22
  const session = getSession(view);
23
- return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "data-testid": "minimize_view", onClick: () => {
23
+ return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "aria-label": view.minimized ? 'Restore view' : 'Minimize view', "data-testid": "minimize_view", onClick: () => {
24
24
  view.setMinimized(!view.minimized);
25
- }, children: view.minimized ? (_jsx(AddIcon, { className: classes.icon, fontSize: "small" })) : (_jsx(MinimizeIcon, { className: classes.icon, fontSize: "small" })) }), _jsx(IconButton, { "data-testid": "close_view", onClick: () => {
25
+ }, children: view.minimized ? (_jsx(AddIcon, { className: classes.icon, fontSize: "small" })) : (_jsx(MinimizeIcon, { className: classes.icon, fontSize: "small" })) }), _jsx(IconButton, { "aria-label": "Close view", "data-testid": "close_view", onClick: () => {
26
26
  session.removeView(view);
27
27
  }, children: _jsx(CloseIcon, { className: classes.icon, fontSize: "small" }) })] }));
28
28
  });
@@ -9,22 +9,6 @@ import ViewHeader from './ViewHeader.js';
9
9
  import ViewWrapper from './ViewWrapper.js';
10
10
  import { useViewVisibility } from './useViewVisibility.js';
11
11
  import { viewTitle } from './viewTitle.js';
12
- // Intended to keep views mounted within ~1.5 viewport-heights of the visible
13
- // band. **It does nothing, and making it work was measured as a regression.**
14
- //
15
- // An observer clips the target against every scrolling ancestor before applying
16
- // the margin, which expands only the root's box — and both view containers are
17
- // `overflow-y: auto`, so through one of them `150% 0px` qualifies exactly the
18
- // views a `0px` margin would. Rooting the observer at the scroll port instead
19
- // (`scrollPortOf`) does restore the band, and it came out a wash on scroll cost
20
- // while holding 9-13 live GPU canvases instead of 6. The mount band trades
21
- // pipeline rebuilds for live contexts, and with the ceiling at 16 live WebGL2
22
- // contexts the contexts are the tighter constraint.
23
- // See agent-docs/reference/GPU_CONTEXT_BUDGET.md.
24
- //
25
- // So don't tune this number expecting an effect, and don't make it live without
26
- // first cutting contexts per display.
27
- const VIEW_VISIBILITY_ROOT_MARGIN = '150% 0px';
28
12
  // Scroll-space reserved for a view that has never been measured yet, so a fresh
29
13
  // load spreads views down the page instead of stacking them all in the viewport
30
14
  // (which would mark them all visible and defeat the lazy mount).
@@ -100,7 +84,7 @@ const useStyles = makeStyles()(theme => ({
100
84
  const ViewContainer = observer(function ViewContainer({ view, session, scrollOnMount, }) {
101
85
  const ref = useWidthSetter(view);
102
86
  const { classes } = useStyles();
103
- const { ref: bodyRef, visible, measuredHeight, } = useViewVisibility(VIEW_VISIBILITY_ROOT_MARGIN);
87
+ const { ref: bodyRef, visible, measuredHeight } = useViewVisibility();
104
88
  useFocusOnInteraction(ref, () => {
105
89
  session.setFocusedViewId(view.id);
106
90
  });