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} />
</div>
{loc !== "/" && loc !== "/update" && (
<Link
to="/"
className="text-blue-500 font-medium leading-snug block mb-[10px]"
>
<Link to="/" className="link font-medium block mb-[10px]">
&larr; Back to {node.DeviceName}
</Link>
)}

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

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

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

@ -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({
})}
/>
</div>
<button
className={cx(
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-gray-800 text-sm font-medium",
{ "cursor-not-allowed": readonly }
)}
{!readonly && (
<Button
sizeVariant="small"
onClick={() =>
apiFetch("/local/v0/logout", "POST")
.then(() => setLocation("/"))
.catch((err) => alert("Logout failed: " + err.message))
}
disabled={readonly}
>
Disconnect
</button>
</Button>
)}
</div>
</div>
{node.Features["auto-update"] &&

@ -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 (
<div className="mb-12 w-full">
<h2 className="mb-3">This device</h2>
@ -42,30 +51,51 @@ export default function HomeView({
disabled={readonly}
/>
)}
<Link className="text-blue-500 font-medium leading-snug" to="/details">
<Link className="link font-medium" to="/details">
View device details &rarr;
</Link>
</div>
<h2 className="mb-3">Settings</h2>
<div className="grid gap-3">
{node.Features["advertise-routes"] && (
<SettingsCard
link="/subnets"
className="mb-3"
title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them."
badge={
allSubnetRoutes
? {
text: `${allSubnetRoutes} ${pluralize(
"route",
"routes",
allSubnetRoutes
)}`,
}
: undefined
}
footer={
pendingSubnetRoutes
? `${pendingSubnetRoutes} ${pluralize(
"route",
"routes",
pendingSubnetRoutes
)} pending approval`
: undefined
}
/>
)}
{node.Features["ssh"] && (
<SettingsCard
link="/ssh"
className="mb-3"
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" />,
icon: (
<div className="w-2 h-2 bg-emerald-500 rounded-full" />
),
}
: undefined
}
@ -78,6 +108,7 @@ export default function HomeView({
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
/> */}
</div>
</div>
)
}
@ -86,6 +117,7 @@ function SettingsCard({
link,
body,
badge,
footer,
className,
}: {
title: string
@ -95,23 +127,24 @@ function SettingsCard({
text: string
icon?: JSX.Element
}
footer?: string
className?: string
}) {
const [, setLocation] = useLocation()
return (
<Link
to={link}
className={cx(
"-mx-5 card flex justify-between items-center cursor-pointer",
className
)}
<button
className={cx("-mx-5 card cursor-pointer", className)}
onClick={() => setLocation(link)}
>
<div className="flex justify-between items-center">
<div>
<div className="flex gap-2">
<p className="text-gray-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2">
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-gray-500 text-xs font-medium">
{badge.text}
@ -124,6 +157,13 @@ function SettingsCard({
<div>
<ArrowRight className="ml-3" />
</div>
</Link>
</div>
{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 { 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.
</p>
</div>
<button
<Button
onClick={() => login({})}
className="button button-blue w-full mb-4"
className="w-full mb-4"
intent="primary"
>
Connect to Tailscale
</button>
</Button>
</>
) : data.IP ? (
<>
@ -64,12 +66,13 @@ export default function LoginView({
.
</p>
</div>
<button
<Button
onClick={() => login({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
className="w-full mb-4"
intent="primary"
>
Reauthenticate
</button>
</Button>
</>
) : (
<>
@ -89,7 +92,7 @@ export default function LoginView({
.
</p>
</div>
<button
<Button
onClick={() =>
login({
Reauthenticate: true,
@ -97,10 +100,11 @@ export default function LoginView({
AuthKey: authKey,
})
}
className="button button-blue w-full mb-4"
className="w-full mb-4"
intent="primary"
>
Log In
</button>
</Button>
<Collapsible trigger="Advanced options">
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
<p className="text-sm text-gray-500">

@ -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<boolean>(
advertisedRoutes.length === 0 && !readonly
)
@ -42,9 +43,12 @@ export default function SubnetRouterView({
Learn more &rarr;
</a>
</p>
{inputOpen ? (
{!readonly &&
(inputOpen ? (
<div className="-mx-5 card shadow">
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
<p className="font-medium leading-snug mb-3">
Advertise new routes
</p>
<Input
type="text"
className="text-sm"
@ -56,6 +60,7 @@ export default function SubnetRouterView({
Add multiple routes by providing a comma-separated list.
</p>
<Button
intent="primary"
onClick={() =>
nodeUpdaters
.postSubnetRoutes([
@ -67,17 +72,20 @@ export default function SubnetRouterView({
setInputOpen(false)
})
}
disabled={readonly || !inputText}
disabled={!inputText}
>
Advertise routes
</Button>
</div>
) : (
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
<Plus />
<Button
intent="primary"
prefixIcon={<Plus />}
onClick={() => setInputOpen(true)}
>
Advertise new route
</Button>
)}
))}
<div className="-mx-5 mt-10">
{advertisedRoutes.length > 0 ? (
<>
@ -96,7 +104,7 @@ export default function SubnetRouterView({
<Clock className="w-4 h-4" />
)}
{r.Approved ? (
<div className="text-emerald-800 text-sm leading-tight">
<div className="text-green-500 text-sm leading-tight">
Approved
</div>
) : (
@ -105,9 +113,9 @@ export default function SubnetRouterView({
</div>
)}
</div>
{!readonly && (
<Button
intent="secondary"
className="text-sm font-medium"
sizeVariant="small"
onClick={() =>
nodeUpdaters.postSubnetRoutes(
advertisedRoutes
@ -115,14 +123,15 @@ export default function SubnetRouterView({
.filter((it) => it !== r.Route)
)
}
disabled={readonly}
>
Stop advertising
</Button>
)}
</div>
</div>
))}
</div>
{hasUnapprovedRoutes && (
<Control.AdminContainer
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"
node={node}
@ -133,9 +142,10 @@ export default function SubnetRouterView({
</Control.AdminLink>
.
</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
</div>
)}

@ -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}
. <ChangelogText version={versionInfo?.LatestVersion} />
</p>
<Link className="button button-blue text-sm m-3" to="/">
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Log in to access
</Link>
</Button>
</>
) : updateState === UpdateState.UpToDate ? (
<>
@ -63,9 +69,13 @@ export function UpdatingView({
You are already running Tailscale {currentVersion}, which is the
newest version available.
</p>
<Link className="button button-blue text-sm m-3" to="/">
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Return
</Link>
</Button>
</>
) : (
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
@ -79,9 +89,13 @@ export function UpdatingView({
: null}{" "}
failed.
</p>
<Link className="button button-blue text-sm m-3" to="/">
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Return
</Link>
</Button>
</>
)}
<pre className="h-64 overflow-scroll m-3">

@ -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.
/**
* .loading-dots creates a set of three dots that pulse for indicating loading
* states where a more horizontal appearance is helpful.
*/
.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;
}
.link {
--text-opacity: 1;
color: #4b70cc;
color: rgba(75, 112, 204, var(--text-opacity));
}
.link:hover,
.link:active {
--text-opacity: 1;
color: #19224a;
color: rgba(25, 34, 74, var(--text-opacity));
}
.link-underline {
text-decoration: underline;
}
.link-underline:hover,
.link-underline:active {
text-decoration: none;
}
.link-muted {
/* same as text-gray-500 */
--tw-text-opacity: 1;
color: rgba(112, 110, 109, var(--tw-text-opacity));
}
.link-muted:hover,
.link-muted:active {
/* same as text-gray-500 */
--tw-text-opacity: 1;
color: rgba(68, 67, 66, var(--tw-text-opacity));
}
.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;
}
.button:focus {
outline: 0;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}
.button:disabled {
cursor: not-allowed;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.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));
}
.loading-dots {
@apply inline-flex items-center;
}
.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));
}
.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;
}
.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));
}
.loading-dots span:nth-child(2) {
animation-delay: 200ms;
}
.button-red {
background-color: #d04841;
border-color: #d04841;
color: #fff;
}
.loading-dots span:nth-child(3) {
animation-delay: 400ms;
}
.button-red:enabled:hover {
background-color: #b22d30;
border-color: #b22d30;
}
@keyframes loading-dots-blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
/**
/**
* .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.
*/
@keyframes spin {
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
.spinner {
.spinner {
@apply border-transparent border-t-current border-l-current rounded-full;
animation: spin 700ms linear infinite;
}
/**
* .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.
*/
.link {
@apply text-text-primary;
}
.link:hover,
.link:active {
@apply text-blue-700;
}
.link-destructive {
@apply text-text-danger;
}
.link-destructive:hover,
.link-destructive:active {
@apply text-red-700;
}
.link-fade {
}
.link-fade:hover {
@apply opacity-75;
}
.link-underline {
@apply underline;
}
.link-underline:hover {
@apply opacity-75;
}
}
@layer utilities {
.h-input {
@apply h-[2.375rem];
}
}

@ -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<HTMLButtonElement>
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<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 (
<button
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,
"bg-blue-400 text-blue-200": intent === "primary" && disabled,
"bg-stone-50 shadow border border-stone-200 text-gray-800":
intent === "secondary",
"cursor-not-allowed": disabled,
// base filled
"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":
intent === "base" && variant === "filled",
"enabled:bg-gray-200 enabled:border-gray-300":
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
)}
ref={ref}
disabled={disabled || loading}
{...rest}
disabled={disabled}
>
{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>
)
}
})
export default Button

@ -24,7 +24,7 @@ export default function Collapsible(props: CollapsibleProps) {
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">
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
</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 {
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