Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './nav_rail.module.css';
3 : import symbols from '@ankoh/dashql-svg-symbols';
4 :
5 : import { useLocation } from 'react-router-dom';
6 : import { Tooltip } from '../ui/foundations/tooltip.js';
7 : import { SESSION_PATH, SELECT_SESSION, useRouteContext, useRouterNavigate } from './router/router.js';
8 : import { SessionViewMode, useSessionViewMode } from '../session/session_view_mode.js';
9 : import { classNames } from '../utils/classnames.js';
10 : import { useAppConfig, useAppReconfigure } from '../config/app_config.js';
11 : import { useAppFlavor } from '../config/flavor_provider.js';
12 : import type { AppFlavorRailPage } from '../config/flavors.js';
13 :
14 : /// Is the pathname a page of the workspace, the sessions and their connections?
15 1 : export function isWorkspacePath(pathname: string): boolean {
16 1 : return pathname === '/' || pathname === '/session' || pathname.startsWith('/sessions/');
17 1 : }
18 :
19 : /// Open the workbench of the current session, leaving the shell
20 1 : export function useOpenWorkbench(): () => void {
21 1 : const route = useRouteContext();
22 1 : const navigate = useRouterNavigate();
23 1 : const { setMode } = useSessionViewMode();
24 1 : return React.useCallback(() => {
25 0 : setMode(SessionViewMode.Session);
26 0 : navigate(route.sessionId == null
27 0 : ? { type: SESSION_PATH, value: null }
28 0 : : { type: SELECT_SESSION, value: route.sessionId });
29 1 : }, [navigate, route.sessionId, setMode]);
30 1 : }
31 :
32 : /// Open the shell of the current session
33 1 : function useOpenShell(): (() => void) | null {
34 1 : const route = useRouteContext();
35 1 : const navigate = useRouterNavigate();
36 1 : const { setMode } = useSessionViewMode();
37 1 : const sessionId = route.sessionId;
38 1 : const openShell = React.useCallback(() => {
39 0 : if (sessionId == null) return;
40 0 : setMode(SessionViewMode.Shell);
41 0 : navigate({ type: SELECT_SESSION, value: sessionId });
42 1 : }, [navigate, sessionId, setMode]);
43 1 : return sessionId == null ? null : openShell;
44 1 : }
45 :
46 : /// Is the rail wide, showing the labels of its buttons? Persisted in the app settings.
47 1 : export function useNavRailWide(): [boolean, () => void] {
48 1 : const config = useAppConfig();
49 1 : const reconfigure = useAppReconfigure();
50 1 : const wide = config?.settings?.navRailWide ?? false;
51 1 : const toggle = React.useCallback(() => {
52 0 : reconfigure(value => value == null ? null : {
53 0 : ...value,
54 0 : settings: { ...value.settings, navRailWide: !value.settings?.navRailWide },
55 0 : });
56 1 : }, [reconfigure]);
57 1 : return [wide, toggle];
58 1 : }
59 :
60 : type NavRailButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & {
61 : label: string;
62 : active?: boolean;
63 : /// Show the label next to the icon instead of in a tooltip
64 : wide: boolean;
65 : ref?: React.Ref<HTMLButtonElement>;
66 : children: React.ReactNode;
67 : };
68 :
69 : /// An icon button of the rail, labeled by a tooltip or, in the wide rail, by its label
70 1 : export const NavRailButton = ({ label, active, wide, className, children, ...props }: NavRailButtonProps) => {
71 1 : const button = (
72 1 : <button
73 1 : {...props}
74 1 : type="button"
75 1 : className={classNames(styles.button, className, { [styles.active]: active, [styles.button_wide]: wide })}
76 1 : aria-label={props['aria-label'] ?? label}
77 : >
78 1 : {children}
79 1 : {wide && <span className={styles.button_label}>{label}</span>}
80 1 : </button>
81 : );
82 1 : return wide ? button : <Tooltip type="label" direction="e" text={label}>{button}</Tooltip>;
83 1 : };
84 :
85 : /// An icon of the rail from the svg sprite
86 1 : const NavRailIcon = (props: { icon: string }) => (
87 1 : <svg width="16px" height="16px" aria-hidden="true">
88 1 : <use xlinkHref={`${symbols}#${props.icon}`} />
89 1 : </svg>
90 : );
91 :
92 : /// The pages of the rail in the groups of the flavor, with a separator between the groups.
93 : /// The pages that are not there yet are buttons that do nothing.
94 1 : export const NavRailPages = (props: { wide: boolean }): React.ReactElement => {
95 1 : const flavor = useAppFlavor();
96 1 : const location = useLocation();
97 1 : const { mode } = useSessionViewMode();
98 1 : const openWorkbench = useOpenWorkbench();
99 1 : const openShell = useOpenShell();
100 1 : const onSessionPath = isWorkspacePath(location.pathname);
101 1 : const shellActive = onSessionPath && mode === SessionViewMode.Shell;
102 1 : const workspaceActive = onSessionPath && !shellActive;
103 1 : const renderPage = (page: AppFlavorRailPage) => {
104 1 : switch (page.type) {
105 1 : case 'workspace':
106 1 : return (
107 1 : <NavRailButton key="workspace" label="Workspace" wide={props.wide} active={workspaceActive} aria-current={workspaceActive ? 'page' : undefined} onClick={openWorkbench}>
108 1 : <NavRailIcon icon="compose_24" />
109 1 : </NavRailButton>
110 : );
111 1 : case 'shell':
112 1 : return openShell == null ? null : (
113 1 : <NavRailButton key="shell" label="Shell" wide={props.wide} active={shellActive} aria-current={shellActive ? 'page' : undefined} onClick={openShell}>
114 1 : <NavRailIcon icon="command_palette_24" />
115 1 : </NavRailButton>
116 : );
117 1 : case 'upcoming':
118 1 : return (
119 : // Not disabled but aria-disabled, so that the tooltip still names the page
120 1 : <NavRailButton key={page.label} label={page.label} wide={props.wide} className={styles.button_upcoming} aria-disabled="true">
121 1 : <NavRailIcon icon={page.icon} />
122 1 : </NavRailButton>
123 : );
124 1 : }
125 1 : };
126 1 : return (
127 1 : <>
128 1 : {flavor.railPages.map((group, i) => (
129 1 : <React.Fragment key={i}>
130 1 : {i > 0 && <div className={styles.separator} role="separator" />}
131 1 : {group.map(renderPage)}
132 1 : </React.Fragment>
133 1 : ))}
134 1 : </>
135 : );
136 1 : };
137 :
138 : /// The rail left of the page card that switches between the pages of the app.
139 : /// The shell is a page of its own although it shows the current session.
140 : /// The button at the bottom widens the rail to show the labels of the buttons.
141 1 : export const NavRail = (): React.ReactElement => {
142 1 : const [wide, toggleWide] = useNavRailWide();
143 1 : return (
144 : // Electron excludes the buttons via the global no-drag rules
145 1 : <nav className={classNames(styles.rail, { [styles.rail_wide]: wide })} aria-label="Areas" data-electron-drag-region>
146 1 : <div className={styles.group}>
147 1 : <NavRailPages wide={wide} />
148 1 : </div>
149 1 : <div className={styles.group}>
150 : {/* The octicons show the sidebar on the right, flipped it sits on the left like the rail */}
151 1 : <NavRailButton label={wide ? 'Collapse' : 'Expand'} wide={wide} aria-expanded={wide} onClick={toggleWide}>
152 1 : <svg className={styles.icon_flipped} width="16px" height="16px" aria-hidden="true">
153 1 : <use xlinkHref={`${symbols}#${wide ? 'sidebar_collapse_24' : 'sidebar_expand_24'}`} />
154 1 : </svg>
155 1 : </NavRailButton>
156 1 : </div>
157 1 : </nav>
158 : );
159 1 : };
|