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