From 95e9d22a16cc86914267286788a991f10c638162 Mon Sep 17 00:00:00 2001 From: Sonia Appasamy Date: Mon, 4 Dec 2023 15:20:38 -0500 Subject: [PATCH] client/web: button, link, and other small UI updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the following changes: * Use “link” class in various spots * Remove button appearance on Exit Node dropdown in readonly mode * Update `-stone-` colors to `-gray-` (couple spots missed by original color config commit) * Pull full ui/button component from admin panel, and update buttons throughout UI to use this component * Remove various buttons in readonly view to match mocks * Add route (and “pending approval”) highlights to Subnet router settings card * Delete legacy client button styles from index.css * Fix overflow of IPv6 address on device details view Updates #10261 Signed-off-by: Sonia Appasamy --- client/web/src/components/app.tsx | 5 +- .../web/src/components/exit-node-selector.tsx | 30 +- client/web/src/components/login-toggle.tsx | 4 +- .../web/src/components/update-available.tsx | 14 +- .../components/views/device-details-view.tsx | 27 +- client/web/src/components/views/home-view.tsx | 144 ++++++---- .../web/src/components/views/login-view.tsx | 22 +- .../components/views/subnet-router-view.tsx | 136 ++++----- .../src/components/views/updating-view.tsx | 28 +- client/web/src/index.css | 258 +++++++++--------- client/web/src/ui/button.tsx | 144 +++++++++- client/web/src/ui/collapsible.tsx | 2 +- client/web/src/ui/loading-dots.tsx | 23 ++ client/web/src/util.ts | 11 + 14 files changed, 530 insertions(+), 318 deletions(-) create mode 100644 client/web/src/ui/loading-dots.tsx diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 6d2d438cd..9bf0e159e 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -153,10 +153,7 @@ function Header({ {loc !== "/" && loc !== "/update" && ( - + ← Back to {node.DeviceName} )} diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx index 9e12c84a1..a2d041ecf 100644 --- a/client/web/src/components/exit-node-selector.tsx +++ b/client/web/src/components/exit-node-selector.tsx @@ -85,10 +85,12 @@ export default function ExitNodeSelector({ > - {(advertising || using) && ( + {!disabled && (advertising || using) && ( @@ -252,7 +254,7 @@ function ExitNodeSelectorItem({ return ( ) } diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx index e8b549f35..efa912166 100644 --- a/client/web/src/components/views/device-details-view.tsx +++ b/client/web/src/components/views/device-details-view.tsx @@ -8,6 +8,7 @@ import ACLTag from "src/components/acl-tag" import * as Control from "src/components/control-components" import { UpdateAvailableNotification } from "src/components/update-available" import { NodeData } from "src/hooks/node-data" +import Button from "src/ui/button" import { useLocation } from "wouter" export default function DeviceDetailsView({ @@ -34,20 +35,18 @@ export default function DeviceDetailsView({ })} /> - + {!readonly && ( + + )} {node.Features["auto-update"] && diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index 86a1827de..4dc7658e7 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -2,12 +2,13 @@ // SPDX-License-Identifier: BSD-3-Clause import cx from "classnames" -import React from "react" +import React, { useMemo } from "react" import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg" import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg" import ExitNodeSelector from "src/components/exit-node-selector" import { NodeData, NodeUpdaters } from "src/hooks/node-data" -import { Link } from "wouter" +import { pluralize } from "src/util" +import { Link, useLocation } from "wouter" export default function HomeView({ readonly, @@ -18,6 +19,14 @@ export default function HomeView({ node: NodeData nodeUpdaters: NodeUpdaters }) { + const [allSubnetRoutes, pendingSubnetRoutes] = useMemo( + () => [ + node.AdvertisedRoutes?.length, + node.AdvertisedRoutes?.filter((r) => !r.Approved).length, + ], + [node.AdvertisedRoutes] + ) + return (

This device

@@ -42,41 +51,63 @@ export default function HomeView({ disabled={readonly} /> )} - + View device details →

Settings

- {node.Features["advertise-routes"] && ( - - )} - {node.Features["ssh"] && ( - , - } - : undefined - } - /> - )} - {/* TODO(sonia,will): hiding unimplemented settings pages until implemented */} - {/* + {node.Features["advertise-routes"] && ( + + )} + {node.Features["ssh"] && ( + + ), + } + : undefined + } + /> + )} + {/* TODO(sonia,will): hiding unimplemented settings pages until implemented */} + {/* */} + ) } @@ -86,6 +117,7 @@ function SettingsCard({ link, body, badge, + footer, className, }: { title: string @@ -95,35 +127,43 @@ function SettingsCard({ text: string icon?: JSX.Element } + footer?: string className?: string }) { + const [, setLocation] = useLocation() + return ( - setLocation(link)} > -
-
-

- {title} -

- {badge && ( -
- {badge.icon} -
- {badge.text} +
+
+
+

+ {title} +

+ {badge && ( +
+ {badge.icon} +
+ {badge.text} +
-
- )} + )} +
+

{body}

+
+
+
-

{body}

-
-
-
- + {footer && ( + <> +
+
{footer}
+ + )} + ) } diff --git a/client/web/src/components/views/login-view.tsx b/client/web/src/components/views/login-view.tsx index b70ed5f9e..f36d69836 100644 --- a/client/web/src/components/views/login-view.tsx +++ b/client/web/src/components/views/login-view.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useState } from "react" import { apiFetch } from "src/api" import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg" import { NodeData } from "src/hooks/node-data" +import Button from "src/ui/button" import Collapsible from "src/ui/collapsible" import Input from "src/ui/input" @@ -40,12 +41,13 @@ export default function LoginView({ Your device is disconnected from Tailscale.

- + ) : data.IP ? ( <> @@ -64,12 +66,13 @@ export default function LoginView({ .

- + ) : ( <> @@ -89,7 +92,7 @@ export default function LoginView({ .

- +

Auth Key

diff --git a/client/web/src/components/views/subnet-router-view.tsx b/client/web/src/components/views/subnet-router-view.tsx index c4ac3beb7..3dd26794b 100644 --- a/client/web/src/components/views/subnet-router-view.tsx +++ b/client/web/src/components/views/subnet-router-view.tsx @@ -19,10 +19,11 @@ export default function SubnetRouterView({ node: NodeData nodeUpdaters: NodeUpdaters }) { - const advertisedRoutes = useMemo( - () => node.AdvertisedRoutes || [], - [node.AdvertisedRoutes] - ) + const [advertisedRoutes, hasUnapprovedRoutes] = useMemo(() => { + const routes = node.AdvertisedRoutes || [] + return [routes, routes.find((r) => !r.Approved)] + }, [node.AdvertisedRoutes]) + const [inputOpen, setInputOpen] = useState( advertisedRoutes.length === 0 && !readonly ) @@ -42,42 +43,49 @@ export default function SubnetRouterView({ Learn more →

- {inputOpen ? ( -
-

Advertise new routes

- setInputText(e.target.value)} - /> -

- Add multiple routes by providing a comma-separated list. -

+ {!readonly && + (inputOpen ? ( +
+

+ Advertise new routes +

+ setInputText(e.target.value)} + /> +

+ Add multiple routes by providing a comma-separated list. +

+ +
+ ) : ( -
- ) : ( - - )} + ))}
{advertisedRoutes.length > 0 ? ( <> @@ -96,7 +104,7 @@ export default function SubnetRouterView({ )} {r.Approved ? ( -
+
Approved
) : ( @@ -105,37 +113,39 @@ export default function SubnetRouterView({
)}
- + {!readonly && ( + + )} ))} - - To approve routes, in the admin console go to{" "} - - the machine’s route settings - - . - + {hasUnapprovedRoutes && ( + + To approve routes, in the admin console go to{" "} + + the machine’s route settings + + . + + )} ) : ( -
+
Not advertising any routes
)} diff --git a/client/web/src/components/views/updating-view.tsx b/client/web/src/components/views/updating-view.tsx index dc1d692a7..b6c76d072 100644 --- a/client/web/src/components/views/updating-view.tsx +++ b/client/web/src/components/views/updating-view.tsx @@ -10,8 +10,9 @@ import { useInstallUpdate, VersionInfo, } from "src/hooks/self-update" +import Button from "src/ui/button" import Spinner from "src/ui/spinner" -import { Link } from "wouter" +import { useLocation } from "wouter" /** * UpdatingView is rendered when the user initiates a Tailscale update, and @@ -24,6 +25,7 @@ export function UpdatingView({ versionInfo?: VersionInfo currentVersion: string }) { + const [, setLocation] = useLocation() const { updateState, updateLog } = useInstallUpdate( currentVersion, versionInfo @@ -51,9 +53,13 @@ export function UpdatingView({ : null} .

- + ) : updateState === UpdateState.UpToDate ? ( <> @@ -63,9 +69,13 @@ export function UpdatingView({ You are already running Tailscale {currentVersion}, which is the newest version available.

- + ) : ( /* TODO(naman,sonia): Figure out the body copy and design for this view. */ @@ -79,9 +89,13 @@ export function UpdatingView({ : null}{" "} failed.

- + )}
diff --git a/client/web/src/index.css b/client/web/src/index.css
index 1575e2205..b6da3af80 100644
--- a/client/web/src/index.css
+++ b/client/web/src/index.css
@@ -175,14 +175,20 @@
   .card h2 {
     @apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
   }
+  .card table {
+    @apply w-full;
+  }
   .card tbody {
     @apply flex flex-col gap-2;
   }
+  .card tr {
+    @apply grid grid-flow-col grid-cols-3 gap-2;
+  }
   .card td:first-child {
-    @apply w-40 text-gray-500 text-sm leading-tight flex-shrink-0;
+    @apply text-gray-500 text-sm leading-tight truncate;
   }
   .card td:last-child {
-    @apply text-gray-800 text-sm leading-tight;
+    @apply col-span-2 text-gray-800 text-sm leading-tight truncate;
   }
 
   .description {
@@ -286,6 +292,39 @@
     @apply w-[0.675rem] translate-x-[0.55rem];
   }
 
+  /**
+   * .button encapsulates all the base button styles we use across the app.
+   */
+
+  .button {
+    @apply relative inline-flex flex-nowrap items-center justify-center font-medium py-2 px-4 rounded-md border border-transparent text-center whitespace-nowrap;
+    transition-property: background-color, border-color, color, box-shadow;
+    transition-duration: 120ms;
+    box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
+  }
+  .button:focus-visible {
+    @apply outline-none ring;
+  }
+  .button:disabled {
+    @apply pointer-events-none select-none;
+  }
+
+  .button-group {
+    @apply whitespace-nowrap;
+  }
+
+  .button-group .button {
+    @apply min-w-[60px];
+  }
+
+  .button-group .button:not(:first-child) {
+    @apply rounded-l-none;
+  }
+
+  .button-group .button:not(:last-child) {
+    @apply rounded-r-none border-r-0;
+  }
+
   /**
    * .input defines default text input field styling. These styles should
    * correspond to .button, sharing a similar height and rounding, since .input
@@ -321,157 +360,108 @@
   .input-error {
     @apply border-red-200;
   }
-}
-
-@layer utilities {
-  .h-input {
-    @apply h-[2.375rem];
-  }
-}
-
-/**
- * Non-Tailwind styles begin here.
- */
 
-.bg-gray-0 {
-  --tw-bg-opacity: 1;
-  background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
-}
-
-.bg-gray-50 {
-  --tw-bg-opacity: 1;
-  background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
-}
-
-html {
-  letter-spacing: -0.015em;
-  text-rendering: optimizeLegibility;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-}
+  /**
+   * .loading-dots creates a set of three dots that pulse for indicating loading
+   * states where a more horizontal appearance is helpful.
+   */
 
-.link {
-  --text-opacity: 1;
-  color: #4b70cc;
-  color: rgba(75, 112, 204, var(--text-opacity));
-}
+  .loading-dots {
+    @apply inline-flex items-center;
+  }
 
-.link:hover,
-.link:active {
-  --text-opacity: 1;
-  color: #19224a;
-  color: rgba(25, 34, 74, var(--text-opacity));
-}
+  .loading-dots span {
+    @apply inline-block w-[0.35rem] h-[0.35rem] rounded-full bg-current mx-[0.15em];
+    animation-name: loading-dots-blink;
+    animation-duration: 1.4s;
+    animation-iteration-count: infinite;
+    animation-fill-mode: both;
+  }
 
-.link-underline {
-  text-decoration: underline;
-}
+  .loading-dots span:nth-child(2) {
+    animation-delay: 200ms;
+  }
 
-.link-underline:hover,
-.link-underline:active {
-  text-decoration: none;
-}
+  .loading-dots span:nth-child(3) {
+    animation-delay: 400ms;
+  }
 
-.link-muted {
-  /* same as text-gray-500 */
-  --tw-text-opacity: 1;
-  color: rgba(112, 110, 109, var(--tw-text-opacity));
-}
+  @keyframes loading-dots-blink {
+    0% {
+      opacity: 0.2;
+    }
+    20% {
+      opacity: 1;
+    }
+    100% {
+      opacity: 0.2;
+    }
+  }
 
-.link-muted:hover,
-.link-muted:active {
-  /* same as text-gray-500 */
-  --tw-text-opacity: 1;
-  color: rgba(68, 67, 66, var(--tw-text-opacity));
-}
+  /**
+  * .spinner creates a circular animated spinner, most often used to indicate a
+  * loading state. The .spinner element must define a width, height, and
+  * border-width for the spinner to apply.
+  */
 
-.button {
-  font-weight: 500;
-  padding-top: 0.45rem;
-  padding-bottom: 0.45rem;
-  padding-left: 1rem;
-  padding-right: 1rem;
-  border-radius: 0.375rem;
-  border-width: 1px;
-  border-color: transparent;
-  transition-property: background-color, border-color, color, box-shadow;
-  transition-duration: 120ms;
-  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
-  min-width: 80px;
-}
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
+    100% {
+      transform: rotate(360deg);
+    }
+  }
 
-.button:focus {
-  outline: 0;
-  box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
-}
+  .spinner {
+    @apply border-transparent border-t-current border-l-current rounded-full;
+    animation: spin 700ms linear infinite;
+  }
 
-.button:disabled {
-  cursor: not-allowed;
-  -webkit-user-select: none;
-  -ms-user-select: none;
-  user-select: none;
-}
+  /**
+   * .link applies standard styling to links across the app. By default we unstyle
+   * all anchor tags. While this might sound crazy for a website, it's _very_
+   * helpful in an app, since anchor tags can be used to wrap buttons, icons,
+   * and all manner of UI component. As a result, all anchor tags intended to look
+   * like links should have a .link class.
+   */
 
-.button-blue {
-  --bg-opacity: 1;
-  background-color: #4b70cc;
-  background-color: rgba(75, 112, 204, var(--bg-opacity));
-  --border-opacity: 1;
-  border-color: #4b70cc;
-  border-color: rgba(75, 112, 204, var(--border-opacity));
-  --text-opacity: 1;
-  color: #fff;
-  color: rgba(255, 255, 255, var(--text-opacity));
-}
+  .link {
+    @apply text-text-primary;
+  }
 
-.button-blue:enabled:hover {
-  --bg-opacity: 1;
-  background-color: #3f5db3;
-  background-color: rgba(63, 93, 179, var(--bg-opacity));
-  --border-opacity: 1;
-  border-color: #3f5db3;
-  border-color: rgba(63, 93, 179, var(--border-opacity));
-}
+  .link:hover,
+  .link:active {
+    @apply text-blue-700;
+  }
 
-.button-blue:disabled {
-  --text-opacity: 1;
-  color: #cedefd;
-  color: rgba(206, 222, 253, var(--text-opacity));
-  --bg-opacity: 1;
-  background-color: #6c94ec;
-  background-color: rgba(108, 148, 236, var(--bg-opacity));
-  --border-opacity: 1;
-  border-color: #6c94ec;
-  border-color: rgba(108, 148, 236, var(--border-opacity));
-}
+  .link-destructive {
+    @apply text-text-danger;
+  }
 
-.button-red {
-  background-color: #d04841;
-  border-color: #d04841;
-  color: #fff;
-}
+  .link-destructive:hover,
+  .link-destructive:active {
+    @apply text-red-700;
+  }
 
-.button-red:enabled:hover {
-  background-color: #b22d30;
-  border-color: #b22d30;
-}
+  .link-fade {
+  }
 
-/**
-  * .spinner creates a circular animated spinner, most often used to indicate a
-  * loading state. The .spinner element must define a width, height, and
-  * border-width for the spinner to apply.
-  */
+  .link-fade:hover {
+    @apply opacity-75;
+  }
 
-@keyframes spin {
-  0% {
-    transform: rotate(0deg);
+  .link-underline {
+    @apply underline;
   }
-  100% {
-    transform: rotate(360deg);
+
+  .link-underline:hover {
+    @apply opacity-75;
   }
 }
 
-.spinner {
-  @apply border-transparent border-t-current border-l-current rounded-full;
-  animation: spin 700ms linear infinite;
+@layer utilities {
+  .h-input {
+    @apply h-[2.375rem];
+  }
 }
diff --git a/client/web/src/ui/button.tsx b/client/web/src/ui/button.tsx
index aaee82c64..18dc2939f 100644
--- a/client/web/src/ui/button.tsx
+++ b/client/web/src/ui/button.tsx
@@ -2,32 +2,148 @@
 // SPDX-License-Identifier: BSD-3-Clause
 
 import cx from "classnames"
-import React, { ButtonHTMLAttributes } from "react"
+import React, { HTMLProps } from "react"
+import LoadingDots from "src/ui/loading-dots"
 
 type Props = {
-  intent?: "primary" | "secondary"
-} & ButtonHTMLAttributes
+  type?: "button" | "submit" | "reset"
+  sizeVariant?: "input" | "small" | "medium" | "large"
+  /**
+   * variant is the visual style of the button. By default, this is a filled
+   * button. For a less prominent button, use minimal.
+   */
+  variant?: Variant
+  /**
+   * intent describes the semantic meaning of the button's action. For
+   * dangerous or destructive actions, use danger. For actions that should
+   * be the primary focus, use primary.
+   */
+  intent?: Intent
 
-export default function Button(props: Props) {
-  const { intent = "primary", className, disabled, children, ...rest } = props
+  active?: boolean
+  /**
+   * prefixIcon is an icon or piece of content shown at the start of a button.
+   */
+  prefixIcon?: React.ReactNode
+  /**
+   * suffixIcon is an icon or piece of content shown at the end of a button.
+   */
+  suffixIcon?: React.ReactNode
+  /**
+   * loading displays a loading indicator inside the button when set to true.
+   * The sizing of the button is not affected by this prop.
+   */
+  loading?: boolean
+  /**
+   * iconOnly indicates that the button contains only an icon. This is used to
+   * adjust styles to be appropriate for an icon-only button.
+   */
+  iconOnly?: boolean
+  /**
+   * textAlign align the text center or left. If left aligned, any icons will
+   * move to the sides of the button.
+   */
+  textAlign?: "center" | "left"
+} & HTMLProps
+
+export type Variant = "filled" | "minimal"
+export type Intent = "base" | "primary" | "warning" | "danger" | "black"
+
+const Button = React.forwardRef((props, ref) => {
+  const {
+    className,
+    variant = "filled",
+    intent = "base",
+    sizeVariant = "large",
+    disabled,
+    children,
+    loading,
+    active,
+    iconOnly,
+    prefixIcon,
+    suffixIcon,
+    textAlign,
+    ...rest
+  } = props
+
+  const hasIcon = Boolean(prefixIcon || suffixIcon)
 
   return (
     
   )
-}
+})
+
+export default Button
diff --git a/client/web/src/ui/collapsible.tsx b/client/web/src/ui/collapsible.tsx
index e954ce008..409464c1e 100644
--- a/client/web/src/ui/collapsible.tsx
+++ b/client/web/src/ui/collapsible.tsx
@@ -24,7 +24,7 @@ export default function Collapsible(props: CollapsibleProps) {
         onOpenChange?.(open)
       }}
     >
-      
+      
         
           
         
diff --git a/client/web/src/ui/loading-dots.tsx b/client/web/src/ui/loading-dots.tsx
new file mode 100644
index 000000000..6b47552a9
--- /dev/null
+++ b/client/web/src/ui/loading-dots.tsx
@@ -0,0 +1,23 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import cx from "classnames"
+import React, { HTMLAttributes } from "react"
+
+type Props = HTMLAttributes
+
+/**
+ * LoadingDots provides a set of horizontal dots to indicate a loading state.
+ * These dots are helpful in horizontal contexts (like buttons) where a spinner
+ * doesn't fit as well.
+ */
+export default function LoadingDots(props: Props) {
+  const { className, ...rest } = props
+  return (
+    
+ + + +
+ ) +} diff --git a/client/web/src/util.ts b/client/web/src/util.ts index 57c46d75e..fa5cbe3b8 100644 --- a/client/web/src/util.ts +++ b/client/web/src/util.ts @@ -8,3 +8,14 @@ export function assertNever(a: never): never { return a } + +/** + * pluralize is a very simple function that returns either + * the singular or plural form of a string based on the given + * quantity. + * + * TODO: Ideally this would use a localized pluralization. + */ +export function pluralize(signular: string, plural: string, qty: number) { + return qty === 1 ? signular : plural +}