github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/GlobalNav.tsx (about) 1 import React, { Component, useMemo, useRef, useState } from "react" 2 import styled from "styled-components" 3 import { AnalyticsAction, AnalyticsType, incr } from "./analytics" 4 import { ReactComponent as ClusterErrorIcon } from "./assets/svg/close.svg" 5 import { ReactComponent as ClusterIcon } from "./assets/svg/cluster-icon.svg" 6 import { ReactComponent as HelpIcon } from "./assets/svg/help.svg" 7 import { ReactComponent as SnapshotIcon } from "./assets/svg/snapshot.svg" 8 import { ReactComponent as UpdateAvailableIcon } from "./assets/svg/update-available.svg" 9 import { ClusterStatusDialog, getDefaultCluster } from "./ClusterStatusDialog" 10 import { useFeatures } from "./feature" 11 import HelpDialog from "./HelpDialog" 12 import { isTargetEditable } from "./shortcut" 13 import { SnapshotAction } from "./snapshot" 14 import { 15 AnimDuration, 16 Color, 17 FontSize, 18 mixinResetButtonStyle, 19 SizeUnit, 20 } from "./style-helpers" 21 import { Cluster } from "./types" 22 import UpdateDialog from "./UpdateDialog" 23 24 type TiltBuild = Proto.corev1alpha1TiltBuild 25 26 export const GlobalNavRoot = styled.div` 27 display: flex; 28 align-items: stretch; 29 ` 30 export const MenuButtonLabel = styled.div` 31 position: absolute; 32 bottom: 0; 33 font-size: ${FontSize.smallest}; 34 color: ${Color.blueDark}; 35 transition: opacity ${AnimDuration.default} ease; 36 opacity: 0; 37 white-space: nowrap; 38 width: 100%; 39 text-align: center; 40 ` 41 export const MenuButtonMixin = ` 42 ${mixinResetButtonStyle}; 43 display: flex; 44 flex-direction: column; 45 justify-content: center; 46 align-items: center; 47 transition: color ${AnimDuration.default} ease; 48 padding: ${SizeUnit(0.5)}; 49 font-size: ${FontSize.smallest}; 50 color: ${Color.blue}; 51 height: 100%; 52 53 & .fillStd { 54 fill: ${Color.blue}; 55 transition: fill ${AnimDuration.default} ease; 56 } 57 &:hover .fillStd :not(.has-error) { 58 fill: ${Color.blueLight}; 59 } 60 & .fillBg { 61 fill: ${Color.grayDarker}; 62 } 63 64 &:disabled { 65 opacity: 0.33; 66 } 67 ` 68 export const MenuButton = styled.button` 69 ${MenuButtonMixin}; 70 ` 71 export const MenuButtonLabeledRoot = styled.div` 72 position: relative; // Anchor MenuButtonLabel, which shouldn't affect this element's width 73 &:is(:hover, :focus, :active) 74 ${MenuButtonLabel}, 75 button[data-open="true"] 76 + ${MenuButtonLabel} { 77 opacity: 1; 78 } 79 ` 80 81 const floatIconMixin = ` 82 display: none; 83 position: absolute; 84 top: 15px; 85 left: 5px; 86 width: 10px; 87 height: 10px; 88 89 &.is-visible { 90 display: block; 91 } 92 ` 93 const UpdateAvailableFloatIcon = styled(UpdateAvailableIcon)` 94 ${floatIconMixin} 95 ` 96 97 const ClusterErrorFloatIcon = styled(ClusterErrorIcon)` 98 ${floatIconMixin} 99 100 .fillStd, 101 &:hover .fillStd { 102 fill: ${Color.red}; 103 } 104 ` 105 106 const ClusterStatusIcon = styled(ClusterIcon)` 107 &.has-error { 108 .fillStd { 109 fill: ${Color.red}; 110 } 111 } 112 ` 113 114 type GlobalNavShortcutsProps = { 115 toggleHelpDialog: () => void 116 snapshot: SnapshotAction 117 } 118 119 /** 120 * Sets up keyboard shortcuts that depend on the tilt menu. 121 */ 122 class GlobalNavShortcuts extends Component<GlobalNavShortcutsProps> { 123 constructor(props: GlobalNavShortcutsProps) { 124 super(props) 125 this.onKeydown = this.onKeydown.bind(this) 126 } 127 128 componentDidMount() { 129 document.body.addEventListener("keydown", this.onKeydown) 130 } 131 132 componentWillUnmount() { 133 document.body.removeEventListener("keydown", this.onKeydown) 134 } 135 136 onKeydown(e: KeyboardEvent) { 137 if (isTargetEditable(e)) { 138 return 139 } 140 if (e.metaKey || e.altKey || e.ctrlKey || e.isComposing) { 141 return 142 } 143 if (e.key === "?") { 144 this.props.toggleHelpDialog() 145 e.preventDefault() 146 } else if (e.key === "s" && this.props.snapshot.enabled) { 147 this.props.snapshot.openModal() 148 e.preventDefault() 149 } 150 } 151 152 render() { 153 return <span></span> 154 } 155 } 156 157 export function MenuButtonLabeled( 158 props: React.PropsWithChildren<{ label?: string }> 159 ) { 160 return ( 161 <MenuButtonLabeledRoot> 162 {props.children} 163 {props.label && <MenuButtonLabel>{props.label}</MenuButtonLabel>} 164 </MenuButtonLabeledRoot> 165 ) 166 } 167 168 export type GlobalNavProps = { 169 isSnapshot: boolean 170 snapshot: SnapshotAction 171 showUpdate: boolean 172 suggestedVersion: string | null | undefined 173 runningBuild: TiltBuild | undefined 174 clusterConnections?: Cluster[] 175 } 176 177 // The snapshot menu item is handled separately in HUD 178 // since it requires access to HUD state. 179 enum NavDialog { 180 Account = "account", 181 Cluster = "cluster", 182 Help = "help", 183 Update = "update", 184 } 185 186 const DIALOG_TO_ANALYTICS_TYPE = { 187 [NavDialog.Account]: AnalyticsType.Account, 188 [NavDialog.Cluster]: AnalyticsType.Cluster, 189 [NavDialog.Help]: AnalyticsType.Shortcut, 190 [NavDialog.Update]: AnalyticsType.Update, 191 } 192 193 export function GlobalNav(props: GlobalNavProps) { 194 const helpButton = useRef<HTMLButtonElement | null>(null) 195 const accountButton = useRef<HTMLButtonElement | null>(null) 196 const updateButton = useRef<HTMLButtonElement | null>(null) 197 const clusterButton = useRef<HTMLButtonElement | null>(null) 198 const snapshotButton = useRef<HTMLButtonElement | null>(null) 199 200 const [openDialog, setOpenDialog] = useState<NavDialog | null>(null) 201 202 const features = useFeatures() 203 204 // Don't display global nav for snapshots 205 if (props.isSnapshot) { 206 return null 207 } 208 209 function toggleDialog(name: NavDialog, action = AnalyticsAction.Click) { 210 const dialogIsOpen = openDialog === name 211 if (!dialogIsOpen) { 212 incr("ui.web.menu", { type: DIALOG_TO_ANALYTICS_TYPE[name], action }) 213 } 214 215 const nextDialogState = dialogIsOpen ? null : name 216 setOpenDialog(nextDialogState) 217 } 218 219 let snapshotMenuItem = props.snapshot.enabled ? ( 220 <MenuButtonLabeled label="Snapshot"> 221 <MenuButton 222 ref={snapshotButton} 223 onClick={() => props.snapshot.openModal(snapshotButton.current)} 224 role="menuitem" 225 aria-label="Snapshot" 226 aria-haspopup="true" 227 > 228 <SnapshotIcon width="24" height="24" /> 229 </MenuButton> 230 </MenuButtonLabeled> 231 ) : null 232 233 // Only display the cluster status menu item if default cluster information is available 234 const defaultClusterInfo = useMemo( 235 () => getDefaultCluster(props.clusterConnections), 236 [props.clusterConnections] 237 ) 238 const clusterStatusButton = defaultClusterInfo ? ( 239 <MenuButtonLabeled label="Cluster"> 240 <MenuButton 241 ref={clusterButton} 242 onClick={() => toggleDialog(NavDialog.Cluster)} 243 data-open={openDialog === NavDialog.Cluster} 244 aria-expanded={openDialog === NavDialog.Cluster} 245 aria-label={`Cluster status: ${ 246 defaultClusterInfo.status?.error ? "error" : "healthy" 247 }`} 248 aria-haspopup="true" 249 role="menuitem" 250 > 251 <ClusterErrorFloatIcon 252 className={defaultClusterInfo.status?.error && "is-visible has-error"} 253 role="presentation" 254 /> 255 <ClusterStatusIcon 256 role="presentation" 257 className={defaultClusterInfo.status?.error && "has-error"} 258 width="24" 259 height="24" 260 /> 261 </MenuButton> 262 </MenuButtonLabeled> 263 ) : null 264 265 const versionButtonLabel = props.showUpdate ? "Get Update" : "Version" 266 267 return ( 268 <GlobalNavRoot role="menu" aria-label="Tilt session menu"> 269 {clusterStatusButton} 270 271 <MenuButtonLabeled label={versionButtonLabel}> 272 <MenuButton 273 ref={updateButton} 274 onClick={() => toggleDialog(NavDialog.Update)} 275 data-open={openDialog === NavDialog.Update} 276 aria-expanded={openDialog === NavDialog.Update} 277 aria-label={versionButtonLabel} 278 aria-haspopup="true" 279 role="menuitem" 280 > 281 <div>v{props.runningBuild?.version || "?"}</div> 282 283 <UpdateAvailableFloatIcon 284 className={props.showUpdate ? "is-visible" : ""} 285 /> 286 </MenuButton> 287 </MenuButtonLabeled> 288 289 {snapshotMenuItem} 290 291 <MenuButtonLabeled label="Help"> 292 <MenuButton 293 ref={helpButton} 294 onClick={() => toggleDialog(NavDialog.Help)} 295 data-open={openDialog === NavDialog.Help} 296 aria-expanded={openDialog === NavDialog.Help} 297 aria-label="Help" 298 aria-haspopup="true" 299 role="menuitem" 300 > 301 <HelpIcon width="24" height="24" /> 302 </MenuButton> 303 </MenuButtonLabeled> 304 305 <ClusterStatusDialog 306 open={openDialog === NavDialog.Cluster} 307 onClose={() => toggleDialog(NavDialog.Cluster)} 308 anchorEl={clusterButton?.current} 309 clusterConnection={defaultClusterInfo} 310 /> 311 <HelpDialog 312 open={openDialog === NavDialog.Help} 313 anchorEl={helpButton?.current} 314 onClose={() => toggleDialog(NavDialog.Help)} 315 /> 316 <UpdateDialog 317 open={openDialog === NavDialog.Update} 318 anchorEl={updateButton?.current} 319 onClose={() => toggleDialog(NavDialog.Update)} 320 showUpdate={props.showUpdate} 321 suggestedVersion={props.suggestedVersion} 322 isNewInterface={true} 323 /> 324 <GlobalNavShortcuts 325 toggleHelpDialog={() => 326 toggleDialog(NavDialog.Help, AnalyticsAction.Shortcut) 327 } 328 snapshot={props.snapshot} 329 /> 330 </GlobalNavRoot> 331 ) 332 }