client/web: button, link, and other small UI updates

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 <sonia@tailscale.com>
pull/10458/head
Sonia Appasamy 12 months ago committed by Sonia Appasamy
parent 64a26b221b
commit 95e9d22a16

@ -153,10 +153,7 @@ function Header({
<LoginToggle node={node} auth={auth} newSession={newSession} /> <LoginToggle node={node} auth={auth} newSession={newSession} />
</div> </div>
{loc !== "/" && loc !== "/update" && ( {loc !== "/" && loc !== "/update" && (
<Link <Link to="/" className="link font-medium block mb-[10px]">
to="/"
className="text-blue-500 font-medium leading-snug block mb-[10px]"
>
&larr; Back to {node.DeviceName} &larr; Back to {node.DeviceName}
</Link> </Link>
)} )}

@ -85,10 +85,12 @@ export default function ExitNodeSelector({
> >
<button <button
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", { className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
"bg-white hover:bg-stone-100": none, "bg-white": none,
"bg-amber-600 hover:bg-orange-400": advertising, "hover:bg-gray-100": none && !disabled,
"bg-blue-500 hover:bg-blue-400": using, "bg-orange-600": advertising,
"cursor-not-allowed": disabled, "hover:bg-orange-400": advertising && !disabled,
"bg-blue-500": using,
"hover:bg-blue-400": using && !disabled,
})} })}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
disabled={disabled} disabled={disabled}
@ -116,27 +118,27 @@ export default function ExitNodeSelector({
? "Running as exit node" ? "Running as exit node"
: selected.Name} : selected.Name}
</p> </p>
<ChevronDown {!disabled && (
className={cx("ml-1", { <ChevronDown
"stroke-neutral-800": none, className={cx("ml-1", {
"stroke-white": advertising || using, "stroke-gray-800": none,
})} "stroke-white": advertising || using,
/> })}
/>
)}
</div> </div>
</button> </button>
{(advertising || using) && ( {!disabled && (advertising || using) && (
<button <button
className={cx("px-3 py-2 rounded-sm text-white", { className={cx("px-3 py-2 rounded-sm text-white", {
"bg-orange-400": advertising, "bg-orange-400": advertising,
"bg-blue-400": using, "bg-blue-400": using,
"cursor-not-allowed": disabled,
})} })}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
handleSelect(noExitNode) handleSelect(noExitNode)
}} }}
disabled={disabled}
> >
Disable Disable
</button> </button>
@ -252,7 +254,7 @@ function ExitNodeSelectorItem({
return ( return (
<button <button
key={node.ID} key={node.ID}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100" className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-gray-100"
onClick={onSelect} onClick={onSelect}
> >
<div> <div>

@ -230,9 +230,11 @@ function SignInButton({
}) { }) {
return ( return (
<Button <Button
className={cx("w-full text-sm mt-2", { className={cx("text-center w-full mt-2", {
"mb-2": auth.viewerIdentity, "mb-2": auth.viewerIdentity,
})} })}
intent="primary"
sizeVariant="small"
onClick={onClick} onClick={onClick}
> >
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"} {auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}

@ -3,13 +3,16 @@
import React from "react" import React from "react"
import { VersionInfo } from "src/hooks/self-update" import { VersionInfo } from "src/hooks/self-update"
import { Link } from "wouter" import Button from "src/ui/button"
import { useLocation } from "wouter"
export function UpdateAvailableNotification({ export function UpdateAvailableNotification({
details, details,
}: { }: {
details: VersionInfo details: VersionInfo
}) { }) {
const [, setLocation] = useLocation()
return ( return (
<div className="card"> <div className="card">
<h2 className="mb-2"> <h2 className="mb-2">
@ -22,12 +25,13 @@ export function UpdateAvailableNotification({
: "A new update"}{" "} : "A new update"}{" "}
is now available. <ChangelogText version={details.LatestVersion} /> is now available. <ChangelogText version={details.LatestVersion} />
</p> </p>
<Link <Button
className="button button-blue mt-3 text-sm inline-block" className="mt-3 inline-block"
to="/update" sizeVariant="small"
onClick={() => setLocation("/update")}
> >
Update now Update now
</Link> </Button>
</div> </div>
) )
} }

@ -8,6 +8,7 @@ import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components" import * as Control from "src/components/control-components"
import { UpdateAvailableNotification } from "src/components/update-available" import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/hooks/node-data" import { NodeData } from "src/hooks/node-data"
import Button from "src/ui/button"
import { useLocation } from "wouter" import { useLocation } from "wouter"
export default function DeviceDetailsView({ export default function DeviceDetailsView({
@ -34,20 +35,18 @@ export default function DeviceDetailsView({
})} })}
/> />
</div> </div>
<button {!readonly && (
className={cx( <Button
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-gray-800 text-sm font-medium", sizeVariant="small"
{ "cursor-not-allowed": readonly } onClick={() =>
)} apiFetch("/local/v0/logout", "POST")
onClick={() => .then(() => setLocation("/"))
apiFetch("/local/v0/logout", "POST") .catch((err) => alert("Logout failed: " + err.message))
.then(() => setLocation("/")) }
.catch((err) => alert("Logout failed: " + err.message)) >
} Disconnect
disabled={readonly} </Button>
> )}
Disconnect
</button>
</div> </div>
</div> </div>
{node.Features["auto-update"] && {node.Features["auto-update"] &&

@ -2,12 +2,13 @@
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames" 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 ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg" import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
import ExitNodeSelector from "src/components/exit-node-selector" import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdaters } from "src/hooks/node-data" 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({ export default function HomeView({
readonly, readonly,
@ -18,6 +19,14 @@ export default function HomeView({
node: NodeData node: NodeData
nodeUpdaters: NodeUpdaters nodeUpdaters: NodeUpdaters
}) { }) {
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
() => [
node.AdvertisedRoutes?.length,
node.AdvertisedRoutes?.filter((r) => !r.Approved).length,
],
[node.AdvertisedRoutes]
)
return ( return (
<div className="mb-12 w-full"> <div className="mb-12 w-full">
<h2 className="mb-3">This device</h2> <h2 className="mb-3">This device</h2>
@ -42,41 +51,63 @@ export default function HomeView({
disabled={readonly} disabled={readonly}
/> />
)} )}
<Link className="text-blue-500 font-medium leading-snug" to="/details"> <Link className="link font-medium" to="/details">
View device details &rarr; View device details &rarr;
</Link> </Link>
</div> </div>
<h2 className="mb-3">Settings</h2> <h2 className="mb-3">Settings</h2>
{node.Features["advertise-routes"] && ( <div className="grid gap-3">
<SettingsCard {node.Features["advertise-routes"] && (
link="/subnets" <SettingsCard
className="mb-3" link="/subnets"
title="Subnet router" title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them." body="Add devices to your tailnet without installing Tailscale on them."
/> badge={
)} allSubnetRoutes
{node.Features["ssh"] && ( ? {
<SettingsCard text: `${allSubnetRoutes} ${pluralize(
link="/ssh" "route",
className="mb-3" "routes",
title="Tailscale SSH server" allSubnetRoutes
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it." )}`,
badge={ }
node.RunningSSHServer : undefined
? { }
text: "Running", footer={
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />, pendingSubnetRoutes
} ? `${pendingSubnetRoutes} ${pluralize(
: undefined "route",
} "routes",
/> pendingSubnetRoutes
)} )} pending approval`
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */} : undefined
{/* <SettingsCard }
/>
)}
{node.Features["ssh"] && (
<SettingsCard
link="/ssh"
title="Tailscale SSH server"
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
badge={
node.RunningSSHServer
? {
text: "Running",
icon: (
<div className="w-2 h-2 bg-emerald-500 rounded-full" />
),
}
: undefined
}
/>
)}
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <SettingsCard
link="/serve" link="/serve"
title="Share local content" title="Share local content"
body="Share local ports, services, and content to your Tailscale network or to the broader internet." body="Share local ports, services, and content to your Tailscale network or to the broader internet."
/> */} /> */}
</div>
</div> </div>
) )
} }
@ -86,6 +117,7 @@ function SettingsCard({
link, link,
body, body,
badge, badge,
footer,
className, className,
}: { }: {
title: string title: string
@ -95,35 +127,43 @@ function SettingsCard({
text: string text: string
icon?: JSX.Element icon?: JSX.Element
} }
footer?: string
className?: string className?: string
}) { }) {
const [, setLocation] = useLocation()
return ( return (
<Link <button
to={link} className={cx("-mx-5 card cursor-pointer", className)}
className={cx( onClick={() => setLocation(link)}
"-mx-5 card flex justify-between items-center cursor-pointer",
className
)}
> >
<div> <div className="flex justify-between items-center">
<div className="flex gap-2"> <div>
<p className="text-gray-800 font-medium leading-tight mb-2"> <div className="flex gap-2">
{title} <p className="text-gray-800 font-medium leading-tight mb-2">
</p> {title}
{badge && ( </p>
<div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2"> {badge && (
{badge.icon} <div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
<div className="text-gray-500 text-xs font-medium"> {badge.icon}
{badge.text} <div className="text-gray-500 text-xs font-medium">
{badge.text}
</div>
</div> </div>
</div> )}
)} </div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div> </div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div> </div>
</Link> {footer && (
<>
<hr className="my-3" />
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
</>
)}
</button>
) )
} }

@ -5,6 +5,7 @@ import React, { useCallback, useState } from "react"
import { apiFetch } from "src/api" import { apiFetch } from "src/api"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg" import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import { NodeData } from "src/hooks/node-data" import { NodeData } from "src/hooks/node-data"
import Button from "src/ui/button"
import Collapsible from "src/ui/collapsible" import Collapsible from "src/ui/collapsible"
import Input from "src/ui/input" import Input from "src/ui/input"
@ -40,12 +41,13 @@ export default function LoginView({
Your device is disconnected from Tailscale. Your device is disconnected from Tailscale.
</p> </p>
</div> </div>
<button <Button
onClick={() => login({})} onClick={() => login({})}
className="button button-blue w-full mb-4" className="w-full mb-4"
intent="primary"
> >
Connect to Tailscale Connect to Tailscale
</button> </Button>
</> </>
) : data.IP ? ( ) : data.IP ? (
<> <>
@ -64,12 +66,13 @@ export default function LoginView({
. .
</p> </p>
</div> </div>
<button <Button
onClick={() => login({ Reauthenticate: true })} onClick={() => login({ Reauthenticate: true })}
className="button button-blue w-full mb-4" className="w-full mb-4"
intent="primary"
> >
Reauthenticate Reauthenticate
</button> </Button>
</> </>
) : ( ) : (
<> <>
@ -89,7 +92,7 @@ export default function LoginView({
. .
</p> </p>
</div> </div>
<button <Button
onClick={() => onClick={() =>
login({ login({
Reauthenticate: true, Reauthenticate: true,
@ -97,10 +100,11 @@ export default function LoginView({
AuthKey: authKey, AuthKey: authKey,
}) })
} }
className="button button-blue w-full mb-4" className="w-full mb-4"
intent="primary"
> >
Log In Log In
</button> </Button>
<Collapsible trigger="Advanced options"> <Collapsible trigger="Advanced options">
<h4 className="font-medium mb-1 mt-2">Auth Key</h4> <h4 className="font-medium mb-1 mt-2">Auth Key</h4>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">

@ -19,10 +19,11 @@ export default function SubnetRouterView({
node: NodeData node: NodeData
nodeUpdaters: NodeUpdaters nodeUpdaters: NodeUpdaters
}) { }) {
const advertisedRoutes = useMemo( const [advertisedRoutes, hasUnapprovedRoutes] = useMemo(() => {
() => node.AdvertisedRoutes || [], const routes = node.AdvertisedRoutes || []
[node.AdvertisedRoutes] return [routes, routes.find((r) => !r.Approved)]
) }, [node.AdvertisedRoutes])
const [inputOpen, setInputOpen] = useState<boolean>( const [inputOpen, setInputOpen] = useState<boolean>(
advertisedRoutes.length === 0 && !readonly advertisedRoutes.length === 0 && !readonly
) )
@ -42,42 +43,49 @@ export default function SubnetRouterView({
Learn more &rarr; Learn more &rarr;
</a> </a>
</p> </p>
{inputOpen ? ( {!readonly &&
<div className="-mx-5 card shadow"> (inputOpen ? (
<p className="font-medium leading-snug mb-3">Advertise new routes</p> <div className="-mx-5 card shadow">
<Input <p className="font-medium leading-snug mb-3">
type="text" Advertise new routes
className="text-sm" </p>
placeholder="192.168.0.0/24" <Input
value={inputText} type="text"
onChange={(e) => setInputText(e.target.value)} className="text-sm"
/> placeholder="192.168.0.0/24"
<p className="my-2 h-6 text-gray-500 text-sm leading-tight"> value={inputText}
Add multiple routes by providing a comma-separated list. onChange={(e) => setInputText(e.target.value)}
</p> />
<p className="my-2 h-6 text-gray-500 text-sm leading-tight">
Add multiple routes by providing a comma-separated list.
</p>
<Button
intent="primary"
onClick={() =>
nodeUpdaters
.postSubnetRoutes([
...advertisedRoutes.map((r) => r.Route),
...inputText.split(","),
])
.then(() => {
setInputText("")
setInputOpen(false)
})
}
disabled={!inputText}
>
Advertise routes
</Button>
</div>
) : (
<Button <Button
onClick={() => intent="primary"
nodeUpdaters prefixIcon={<Plus />}
.postSubnetRoutes([ onClick={() => setInputOpen(true)}
...advertisedRoutes.map((r) => r.Route),
...inputText.split(","),
])
.then(() => {
setInputText("")
setInputOpen(false)
})
}
disabled={readonly || !inputText}
> >
Advertise routes Advertise new route
</Button> </Button>
</div> ))}
) : (
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
<Plus />
Advertise new route
</Button>
)}
<div className="-mx-5 mt-10"> <div className="-mx-5 mt-10">
{advertisedRoutes.length > 0 ? ( {advertisedRoutes.length > 0 ? (
<> <>
@ -96,7 +104,7 @@ export default function SubnetRouterView({
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
)} )}
{r.Approved ? ( {r.Approved ? (
<div className="text-emerald-800 text-sm leading-tight"> <div className="text-green-500 text-sm leading-tight">
Approved Approved
</div> </div>
) : ( ) : (
@ -105,37 +113,39 @@ export default function SubnetRouterView({
</div> </div>
)} )}
</div> </div>
<Button {!readonly && (
intent="secondary" <Button
className="text-sm font-medium" sizeVariant="small"
onClick={() => onClick={() =>
nodeUpdaters.postSubnetRoutes( nodeUpdaters.postSubnetRoutes(
advertisedRoutes advertisedRoutes
.map((it) => it.Route) .map((it) => it.Route)
.filter((it) => it !== r.Route) .filter((it) => it !== r.Route)
) )
} }
disabled={readonly} >
> Stop advertising
Stop advertising </Button>
</Button> )}
</div> </div>
</div> </div>
))} ))}
</div> </div>
<Control.AdminContainer {hasUnapprovedRoutes && (
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight" <Control.AdminContainer
node={node} className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"
> node={node}
To approve routes, in the admin console go to{" "} >
<Control.AdminLink node={node} path={`/machines/${node.IP}`}> To approve routes, in the admin console go to{" "}
the machines route settings <Control.AdminLink node={node} path={`/machines/${node.IP}`}>
</Control.AdminLink> the machines route settings
. </Control.AdminLink>
</Control.AdminContainer> .
</Control.AdminContainer>
)}
</> </>
) : ( ) : (
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-gray-500"> <div className="px-5 py-4 bg-gray-50 rounded-lg border border-gray-200 text-center text-gray-500">
Not advertising any routes Not advertising any routes
</div> </div>
)} )}

@ -10,8 +10,9 @@ import {
useInstallUpdate, useInstallUpdate,
VersionInfo, VersionInfo,
} from "src/hooks/self-update" } from "src/hooks/self-update"
import Button from "src/ui/button"
import Spinner from "src/ui/spinner" 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 * UpdatingView is rendered when the user initiates a Tailscale update, and
@ -24,6 +25,7 @@ export function UpdatingView({
versionInfo?: VersionInfo versionInfo?: VersionInfo
currentVersion: string currentVersion: string
}) { }) {
const [, setLocation] = useLocation()
const { updateState, updateLog } = useInstallUpdate( const { updateState, updateLog } = useInstallUpdate(
currentVersion, currentVersion,
versionInfo versionInfo
@ -51,9 +53,13 @@ export function UpdatingView({
: null} : null}
. <ChangelogText version={versionInfo?.LatestVersion} /> . <ChangelogText version={versionInfo?.LatestVersion} />
</p> </p>
<Link className="button button-blue text-sm m-3" to="/"> <Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Log in to access Log in to access
</Link> </Button>
</> </>
) : updateState === UpdateState.UpToDate ? ( ) : updateState === UpdateState.UpToDate ? (
<> <>
@ -63,9 +69,13 @@ export function UpdatingView({
You are already running Tailscale {currentVersion}, which is the You are already running Tailscale {currentVersion}, which is the
newest version available. newest version available.
</p> </p>
<Link className="button button-blue text-sm m-3" to="/"> <Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Return Return
</Link> </Button>
</> </>
) : ( ) : (
/* TODO(naman,sonia): Figure out the body copy and design for this view. */ /* TODO(naman,sonia): Figure out the body copy and design for this view. */
@ -79,9 +89,13 @@ export function UpdatingView({
: null}{" "} : null}{" "}
failed. failed.
</p> </p>
<Link className="button button-blue text-sm m-3" to="/"> <Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Return Return
</Link> </Button>
</> </>
)} )}
<pre className="h-64 overflow-scroll m-3"> <pre className="h-64 overflow-scroll m-3">

@ -175,14 +175,20 @@
.card h2 { .card h2 {
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide; @apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
} }
.card table {
@apply w-full;
}
.card tbody { .card tbody {
@apply flex flex-col gap-2; @apply flex flex-col gap-2;
} }
.card tr {
@apply grid grid-flow-col grid-cols-3 gap-2;
}
.card td:first-child { .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 { .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 { .description {
@ -286,6 +292,39 @@
@apply w-[0.675rem] translate-x-[0.55rem]; @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 * .input defines default text input field styling. These styles should
* correspond to .button, sharing a similar height and rounding, since .input * correspond to .button, sharing a similar height and rounding, since .input
@ -321,157 +360,108 @@
.input-error { .input-error {
@apply border-red-200; @apply border-red-200;
} }
}
@layer utilities {
.h-input {
@apply h-[2.375rem];
}
}
/**
* Non-Tailwind styles begin here.
*/
.bg-gray-0 { /**
--tw-bg-opacity: 1; * .loading-dots creates a set of three dots that pulse for indicating loading
background-color: rgba(250, 249, 248, var(--tw-bg-opacity)); * states where a more horizontal appearance is helpful.
} */
.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;
}
.link { .loading-dots {
--text-opacity: 1; @apply inline-flex items-center;
color: #4b70cc; }
color: rgba(75, 112, 204, var(--text-opacity));
}
.link:hover, .loading-dots span {
.link:active { @apply inline-block w-[0.35rem] h-[0.35rem] rounded-full bg-current mx-[0.15em];
--text-opacity: 1; animation-name: loading-dots-blink;
color: #19224a; animation-duration: 1.4s;
color: rgba(25, 34, 74, var(--text-opacity)); animation-iteration-count: infinite;
} animation-fill-mode: both;
}
.link-underline { .loading-dots span:nth-child(2) {
text-decoration: underline; animation-delay: 200ms;
} }
.link-underline:hover, .loading-dots span:nth-child(3) {
.link-underline:active { animation-delay: 400ms;
text-decoration: none; }
}
.link-muted { @keyframes loading-dots-blink {
/* same as text-gray-500 */ 0% {
--tw-text-opacity: 1; opacity: 0.2;
color: rgba(112, 110, 109, var(--tw-text-opacity)); }
} 20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
.link-muted:hover, /**
.link-muted:active { * .spinner creates a circular animated spinner, most often used to indicate a
/* same as text-gray-500 */ * loading state. The .spinner element must define a width, height, and
--tw-text-opacity: 1; * border-width for the spinner to apply.
color: rgba(68, 67, 66, var(--tw-text-opacity)); */
}
.button { @keyframes spin {
font-weight: 500; 0% {
padding-top: 0.45rem; transform: rotate(0deg);
padding-bottom: 0.45rem; }
padding-left: 1rem; 100% {
padding-right: 1rem; transform: rotate(360deg);
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;
}
.button:focus { .spinner {
outline: 0; @apply border-transparent border-t-current border-l-current rounded-full;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5); animation: spin 700ms linear infinite;
} }
.button:disabled { /**
cursor: not-allowed; * .link applies standard styling to links across the app. By default we unstyle
-webkit-user-select: none; * all anchor tags. While this might sound crazy for a website, it's _very_
-ms-user-select: none; * helpful in an app, since anchor tags can be used to wrap buttons, icons,
user-select: none; * and all manner of UI component. As a result, all anchor tags intended to look
} * like links should have a .link class.
*/
.button-blue { .link {
--bg-opacity: 1; @apply text-text-primary;
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));
}
.button-blue:enabled:hover { .link:hover,
--bg-opacity: 1; .link:active {
background-color: #3f5db3; @apply text-blue-700;
background-color: rgba(63, 93, 179, var(--bg-opacity)); }
--border-opacity: 1;
border-color: #3f5db3;
border-color: rgba(63, 93, 179, var(--border-opacity));
}
.button-blue:disabled { .link-destructive {
--text-opacity: 1; @apply text-text-danger;
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));
}
.button-red { .link-destructive:hover,
background-color: #d04841; .link-destructive:active {
border-color: #d04841; @apply text-red-700;
color: #fff; }
}
.button-red:enabled:hover { .link-fade {
background-color: #b22d30; }
border-color: #b22d30;
}
/** .link-fade:hover {
* .spinner creates a circular animated spinner, most often used to indicate a @apply opacity-75;
* loading state. The .spinner element must define a width, height, and }
* border-width for the spinner to apply.
*/
@keyframes spin { .link-underline {
0% { @apply underline;
transform: rotate(0deg);
} }
100% {
transform: rotate(360deg); .link-underline:hover {
@apply opacity-75;
} }
} }
.spinner { @layer utilities {
@apply border-transparent border-t-current border-l-current rounded-full; .h-input {
animation: spin 700ms linear infinite; @apply h-[2.375rem];
}
} }

@ -2,32 +2,148 @@
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames" import cx from "classnames"
import React, { ButtonHTMLAttributes } from "react" import React, { HTMLProps } from "react"
import LoadingDots from "src/ui/loading-dots"
type Props = { type Props = {
intent?: "primary" | "secondary" type?: "button" | "submit" | "reset"
} & ButtonHTMLAttributes<HTMLButtonElement> 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) { active?: boolean
const { intent = "primary", className, disabled, children, ...rest } = props /**
* 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<HTMLButtonElement>
export type Variant = "filled" | "minimal"
export type Intent = "base" | "primary" | "warning" | "danger" | "black"
const Button = React.forwardRef<HTMLButtonElement, Props>((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 ( return (
<button <button
className={cx( className={cx(
"px-3 py-2 rounded shadow justify-center items-center gap-2.5 inline-flex font-medium", "button",
{ {
"bg-blue-500 text-white": intent === "primary" && !disabled, // base filled
"bg-blue-400 text-blue-200": intent === "primary" && disabled, "bg-gray-0 border-gray-300 enabled:hover:bg-gray-100 enabled:hover:border-gray-300 enabled:hover:text-gray-900 disabled:border-gray-200 disabled:text-gray-400":
"bg-stone-50 shadow border border-stone-200 text-gray-800": intent === "base" && variant === "filled",
intent === "secondary", "enabled:bg-gray-200 enabled:border-gray-300":
"cursor-not-allowed": disabled, intent === "base" && variant === "filled" && active,
// primary filled
"bg-blue-500 border-blue-500 text-white enabled:hover:bg-blue-600 enabled:hover:border-blue-600 disabled:text-blue-50 disabled:bg-blue-300 disabled:border-blue-300":
intent === "primary" && variant === "filled",
// danger filled
"bg-red-400 border-red-400 text-white enabled:hover:bg-red-500 enabled:hover:border-red-500 disabled:text-red-50 disabled:bg-red-300 disabled:border-red-300":
intent === "danger" && variant === "filled",
// warning filled
"bg-yellow-300 border-yellow-300 text-white enabled:hover:bg-yellow-400 enabled:hover:border-yellow-400 disabled:text-yellow-50 disabled:bg-yellow-200 disabled:border-yellow-200":
intent === "warning" && variant === "filled",
// black filled
"bg-gray-800 border-gray-800 text-white enabled:hover:bg-gray-900 enabled:hover:border-gray-900 disabled:opacity-75":
intent === "black" && variant === "filled",
// minimal button (base variant, black is also included because its not supported for minimal buttons)
"bg-transparent border-transparent shadow-none disabled:border-transparent disabled:text-gray-400":
variant === "minimal",
"text-gray-700 enabled:focus-visible:bg-gray-100 enabled:hover:bg-gray-100 enabled:hover:text-gray-800":
variant === "minimal" && (intent === "base" || intent === "black"),
"enabled:bg-gray-200 border-gray-300":
variant === "minimal" &&
(intent === "base" || intent === "black") &&
active,
// primary minimal
"text-blue-600 enabled:focus-visible:bg-blue-0 enabled:hover:bg-blue-0 enabled:hover:text-blue-800":
variant === "minimal" && intent === "primary",
// danger minimal
"text-red-600 enabled:focus-visible:bg-red-0 enabled:hover:bg-red-0 enabled:hover:text-red-800":
variant === "minimal" && intent === "danger",
// warning minimal
"text-yellow-600 enabled:focus-visible:bg-orange-0 enabled:hover:bg-orange-0 enabled:hover:text-orange-800":
variant === "minimal" && intent === "warning",
// sizeVariants
"px-3 py-[0.35rem]": sizeVariant === "medium",
"h-input": sizeVariant === "input",
"px-3 text-sm py-[0.35rem]": sizeVariant === "small",
"button-active relative z-10": active === true,
"px-3":
iconOnly && (sizeVariant === "large" || sizeVariant === "input"),
"px-2":
iconOnly && (sizeVariant === "medium" || sizeVariant === "small"),
"icon-parent gap-2": hasIcon,
}, },
className className
)} )}
ref={ref}
disabled={disabled || loading}
{...rest} {...rest}
disabled={disabled}
> >
{children} {prefixIcon && <span className="flex-shrink-0">{prefixIcon}</span>}
{loading && (
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-current" />
)}
{children && (
<span
className={cx({
"text-transparent": loading === true,
"text-left flex-1": textAlign === "left",
})}
>
{children}
</span>
)}
{suffixIcon && <span className="flex-shrink-0">{suffixIcon}</span>}
</button> </button>
) )
} })
export default Button

@ -24,7 +24,7 @@ export default function Collapsible(props: CollapsibleProps) {
onOpenChange?.(open) onOpenChange?.(open)
}} }}
> >
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-stone-100 rounded text-sm font-medium pr-3 py-1 transition-colors"> <Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-gray-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0"> <span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" /> <ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
</span> </span>

@ -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<HTMLDivElement>
/**
* 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 (
<div className={cx(className, "loading-dots")} {...rest}>
<span />
<span />
<span />
</div>
)
}

@ -8,3 +8,14 @@
export function assertNever(a: never): never { export function assertNever(a: never): never {
return a 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
}

Loading…
Cancel
Save