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  }