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  }