mirror of https://github.com/tailscale/tailscale/
client/web: add copyable components throughout UI
Updates the IP address on home view to open a copyable list of node addresses on click. And makes various values on the details view copyable text items, mirroring the machine admin panel table. As part of these changes, pulls the AddressCard, NiceIP and QuickCopy components from the admin panel, with the AddressCard slightly modified to avoid needing to also pull in the CommandLine component. A new toaster interface is also added, allowing us to display success and failure toasts throughout the UI. The toaster code is slightly modified from it's admin form to avoid the need for some excess libraries. Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>pull/10479/head
parent
650c67a0a1
commit
a95b3cbfa8
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 9H11C9.89543 9 9 9.89543 9 11V20C9 21.1046 9.89543 22 11 22H20C21.1046 22 22 21.1046 22 20V11C22 9.89543 21.1046 9 20 9Z" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 649 B |
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 6L18 18" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 277 B |
@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import * as Primitive from "@radix-ui/react-popover"
|
||||||
|
import cx from "classnames"
|
||||||
|
import React, { useCallback } from "react"
|
||||||
|
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||||
|
import { ReactComponent as Copy } from "src/assets/icons/copy.svg"
|
||||||
|
import NiceIP from "src/components/nice-ip"
|
||||||
|
import useToaster from "src/hooks/toaster"
|
||||||
|
import Button from "src/ui/button"
|
||||||
|
import { copyText } from "src/utils/clipboard"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AddressCard renders a clickable IP address text that opens a
|
||||||
|
* dialog with a copyable list of all addresses (IPv4, IPv6, DNS)
|
||||||
|
* for the machine.
|
||||||
|
*/
|
||||||
|
export default function AddressCard({
|
||||||
|
v4Address,
|
||||||
|
v6Address,
|
||||||
|
shortDomain,
|
||||||
|
fullDomain,
|
||||||
|
triggerClassName,
|
||||||
|
}: {
|
||||||
|
v4Address: string
|
||||||
|
v6Address: string
|
||||||
|
shortDomain?: string
|
||||||
|
fullDomain?: string
|
||||||
|
triggerClassName?: string
|
||||||
|
}) {
|
||||||
|
const children = (
|
||||||
|
<ul className="flex flex-col divide-y rounded-md overflow-hidden">
|
||||||
|
{shortDomain && <AddressRow label="short domain" value={shortDomain} />}
|
||||||
|
{fullDomain && <AddressRow label="full domain" value={fullDomain} />}
|
||||||
|
{v4Address && (
|
||||||
|
<AddressRow
|
||||||
|
key={v4Address}
|
||||||
|
label="IPv4 address"
|
||||||
|
ip={true}
|
||||||
|
value={v4Address}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{v6Address && (
|
||||||
|
<AddressRow
|
||||||
|
key={v6Address}
|
||||||
|
label="IPv6 address"
|
||||||
|
ip={true}
|
||||||
|
value={v6Address}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Primitive.Root>
|
||||||
|
<Primitive.Trigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="minimal"
|
||||||
|
className="-ml-1 px-1 py-0 hover:!bg-transparent font-normal"
|
||||||
|
suffixIcon={
|
||||||
|
<ChevronDown className="w-5 h-5" stroke="#232222" /* gray-800 */ />
|
||||||
|
}
|
||||||
|
aria-label="See all addresses for this device."
|
||||||
|
>
|
||||||
|
<NiceIP className={triggerClassName} ip={v4Address ?? v6Address} />
|
||||||
|
</Button>
|
||||||
|
</Primitive.Trigger>
|
||||||
|
<Primitive.Content
|
||||||
|
className="shadow-popover origin-radix-popover state-open:animate-scale-in state-closed:animate-scale-out bg-white rounded-md z-50 max-w-sm"
|
||||||
|
sideOffset={10}
|
||||||
|
side="top"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Primitive.Content>
|
||||||
|
</Primitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddressRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
ip,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
ip?: boolean
|
||||||
|
}) {
|
||||||
|
const toaster = useToaster()
|
||||||
|
const onCopyClick = useCallback(() => {
|
||||||
|
copyText(value)
|
||||||
|
.then(() => toaster.show({ message: `Copied ${label} to clipboard` }))
|
||||||
|
.catch(() =>
|
||||||
|
toaster.show({
|
||||||
|
message: `Failed to copy ${label} to clipboard`,
|
||||||
|
variant: "danger",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, [label, toaster, value])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="py flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className={cx(
|
||||||
|
"relative flex group items-center transition-colors",
|
||||||
|
"focus:outline-none focus-visible:ring",
|
||||||
|
"disabled:text-text-muted enabled:hover:text-gray-500",
|
||||||
|
"w-60 text-sm flex-1"
|
||||||
|
)}
|
||||||
|
onClick={onCopyClick}
|
||||||
|
aria-label={`Copy ${value} to your clip board.`}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden pl-3 pr-10 py-2 tabular-nums">
|
||||||
|
{ip ? (
|
||||||
|
<NiceIP ip={value} />
|
||||||
|
) : (
|
||||||
|
<div className="truncate m-w-full">{value}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"absolute right-0 pl-6 pr-3 bg-gradient-to-r from-transparent",
|
||||||
|
"text-gray-900 group-hover:text-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import cx from "classnames"
|
||||||
|
import React from "react"
|
||||||
|
import { isTailscaleIPv6 } from "src/utils/util"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ip: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NiceIP displays IP addresses with nice truncation.
|
||||||
|
*/
|
||||||
|
export default function NiceIP(props: Props) {
|
||||||
|
const { ip, className } = props
|
||||||
|
|
||||||
|
if (!isTailscaleIPv6(ip)) {
|
||||||
|
return <span className={className}>{ip}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const [trimmable, untrimmable] = splitIPv6(ip)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx("inline-flex justify-start min-w-0 max-w-full", className)}
|
||||||
|
>
|
||||||
|
{trimmable.length > 0 && (
|
||||||
|
<span className="truncate w-fit flex-shrink">{trimmable}</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-grow-0 flex-shrink-0">{untrimmable}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an IPv6 address into two pieces, to help with truncating the middle.
|
||||||
|
* Only exported for testing purposes. Do not use.
|
||||||
|
*/
|
||||||
|
export function splitIPv6(ip: string): [string, string] {
|
||||||
|
// We want to split the IPv6 address into segments, but not remove the delimiter.
|
||||||
|
// So we inject an invalid IPv6 character ("|") as a delimiter into the string,
|
||||||
|
// then split on that.
|
||||||
|
const parts = ip.replace(/(:{1,2})/g, "|$1").split("|")
|
||||||
|
|
||||||
|
// Then we find the number of end parts that fits within the character limit,
|
||||||
|
// and join them back together.
|
||||||
|
const characterLimit = 12
|
||||||
|
let characterCount = 0
|
||||||
|
let idxFromEnd = 1
|
||||||
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
|
const part = parts[i]
|
||||||
|
if (characterCount + part.length > characterLimit) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
characterCount += part.length
|
||||||
|
idxFromEnd++
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = parts.slice(0, -idxFromEnd).join("")
|
||||||
|
const end = parts.slice(-idxFromEnd).join("")
|
||||||
|
|
||||||
|
return [start, end]
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import { useRawToasterForHook } from "src/ui/toaster"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useToaster provides a mechanism to display toasts. It returns an object with
|
||||||
|
* methods to show, dismiss, or clear all toasts:
|
||||||
|
*
|
||||||
|
* const toastKey = toaster.show({ message: "Hello world" })
|
||||||
|
* toaster.dismiss(toastKey)
|
||||||
|
* toaster.clear()
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const useToaster = useRawToasterForHook
|
||||||
|
|
||||||
|
export default useToaster
|
@ -0,0 +1,160 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import cx from "classnames"
|
||||||
|
import React, { useEffect, useRef, useState } from "react"
|
||||||
|
import useToaster from "src/hooks/toaster"
|
||||||
|
import { copyText } from "src/utils/clipboard"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string
|
||||||
|
hideAffordance?: boolean
|
||||||
|
/**
|
||||||
|
* primaryActionSubject is the subject of the toast confirmation message
|
||||||
|
* "Copied <subject> to clipboard"
|
||||||
|
*/
|
||||||
|
primaryActionSubject: string
|
||||||
|
primaryActionValue: string
|
||||||
|
secondaryActionName?: string
|
||||||
|
secondaryActionValue?: string
|
||||||
|
/**
|
||||||
|
* secondaryActionSubject is the subject of the toast confirmation message
|
||||||
|
* prompted by the secondary action "Copied <subject> to clipboard"
|
||||||
|
*/
|
||||||
|
secondaryActionSubject?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onSecondaryAction is used to trigger events when the secondary copy
|
||||||
|
* function is used. It is not used when the secondary action is hidden.
|
||||||
|
*/
|
||||||
|
onSecondaryAction?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuickCopy is a UI component that allows for copying textual content in one click.
|
||||||
|
*/
|
||||||
|
export default function QuickCopy(props: Props) {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
hideAffordance,
|
||||||
|
primaryActionSubject,
|
||||||
|
primaryActionValue,
|
||||||
|
secondaryActionValue,
|
||||||
|
secondaryActionName,
|
||||||
|
secondaryActionSubject,
|
||||||
|
onSecondaryAction,
|
||||||
|
children,
|
||||||
|
} = props
|
||||||
|
const toaster = useToaster()
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const buttonRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [showButton, setShowButton] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showButton) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!containerRef.current || !buttonRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// We don't need to watch any `resize` event because it's pretty unlikely
|
||||||
|
// the browser will resize while their cursor is over one of these items.
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
const maximumPossibleWidth = window.innerWidth - rect.left + 4
|
||||||
|
|
||||||
|
// We add the border-width (1px * 2 sides) and the padding (0.5rem * 2 sides)
|
||||||
|
// and add 1px for rounding up the calculation in order to get the final
|
||||||
|
// maxWidth value. This should be kept in sync with the CSS classes below.
|
||||||
|
buttonRef.current.style.maxWidth = `${maximumPossibleWidth}px`
|
||||||
|
buttonRef.current.style.visibility = "visible"
|
||||||
|
}, [showButton])
|
||||||
|
|
||||||
|
const handlePrimaryAction = () => {
|
||||||
|
copyText(primaryActionValue)
|
||||||
|
toaster.show({
|
||||||
|
message: `Copied ${primaryActionSubject} to the clipboard`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSecondaryAction = () => {
|
||||||
|
if (!secondaryActionValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copyText(secondaryActionValue)
|
||||||
|
toaster.show({
|
||||||
|
message: `Copied ${
|
||||||
|
secondaryActionSubject || secondaryActionName
|
||||||
|
} to the clipboard`,
|
||||||
|
})
|
||||||
|
onSecondaryAction?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex relative min-w-0"
|
||||||
|
ref={containerRef}
|
||||||
|
// Since the affordance is a child of this element, we assign both event
|
||||||
|
// handlers here.
|
||||||
|
onMouseLeave={() => setShowButton(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setShowButton(true)}
|
||||||
|
className={cx("truncate", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{!hideAffordance && (
|
||||||
|
<button
|
||||||
|
onMouseEnter={() => setShowButton(true)}
|
||||||
|
onClick={handlePrimaryAction}
|
||||||
|
className={cx("cursor-pointer text-blue-500", { "ml-2": children })}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showButton && (
|
||||||
|
<div
|
||||||
|
className="absolute -mt-1 -ml-2 -top-px -left-px
|
||||||
|
shadow-md cursor-pointer rounded-md active:shadow-sm
|
||||||
|
transition-shadow duration-100 ease-in-out z-50"
|
||||||
|
style={{ visibility: "hidden" }}
|
||||||
|
ref={buttonRef}
|
||||||
|
>
|
||||||
|
<div className="flex border rounded-md button-outline bg-white">
|
||||||
|
<div
|
||||||
|
className={cx("flex min-w-0 py-1 px-2 hover:bg-gray-0", {
|
||||||
|
"rounded-md": !secondaryActionValue,
|
||||||
|
"rounded-l-md": secondaryActionValue,
|
||||||
|
})}
|
||||||
|
onClick={handlePrimaryAction}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx(className, "inline-block select-none truncate")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={cx("cursor-pointer text-blue-500", {
|
||||||
|
"ml-2": children,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{secondaryActionValue && (
|
||||||
|
<div
|
||||||
|
className="text-blue-500 py-1 px-2 border-l hover:bg-gray-100 rounded-r-md"
|
||||||
|
onClick={handleSecondaryAction}
|
||||||
|
>
|
||||||
|
{secondaryActionName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,280 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import cx from "classnames"
|
||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { ReactComponent as X } from "src/assets/icons/x.svg"
|
||||||
|
import { noop } from "src/utils/util"
|
||||||
|
import { create } from "zustand"
|
||||||
|
import { shallow } from "zustand/shallow"
|
||||||
|
|
||||||
|
// Set up root element on the document body for toasts to render into.
|
||||||
|
const root = document.createElement("div")
|
||||||
|
root.id = "toast-root"
|
||||||
|
root.classList.add("relative", "z-20")
|
||||||
|
document.body.append(root)
|
||||||
|
|
||||||
|
const toastSpacing = remToPixels(1)
|
||||||
|
|
||||||
|
export type Toaster = {
|
||||||
|
clear: () => void
|
||||||
|
dismiss: (key: string) => void
|
||||||
|
show: (props: Toast) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = {
|
||||||
|
key?: string // key is a unique string value that ensures only one toast with a given key is shown at a time.
|
||||||
|
className?: string
|
||||||
|
variant?: "danger" // styling for the toast, undefined is neutral, danger is for failed requests
|
||||||
|
message: React.ReactNode
|
||||||
|
timeout?: number
|
||||||
|
added?: number // timestamp of when the toast was added
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToastWithKey = Toast & { key: string }
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
toasts: ToastWithKey[]
|
||||||
|
maxToasts: number
|
||||||
|
clear: () => void
|
||||||
|
dismiss: (key: string) => void
|
||||||
|
show: (props: Toast) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const useToasterState = create<State>((set, get) => ({
|
||||||
|
toasts: [],
|
||||||
|
maxToasts: 5,
|
||||||
|
clear: () => {
|
||||||
|
set({ toasts: [] })
|
||||||
|
},
|
||||||
|
dismiss: (key: string) => {
|
||||||
|
set((prev) => ({
|
||||||
|
toasts: prev.toasts.filter((t) => t.key !== key),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
show: (props: Toast) => {
|
||||||
|
const { toasts: prevToasts, maxToasts } = get()
|
||||||
|
|
||||||
|
const propsWithKey = {
|
||||||
|
key: Date.now().toString(),
|
||||||
|
...props,
|
||||||
|
}
|
||||||
|
const prevIdx = prevToasts.findIndex((t) => t.key === propsWithKey.key)
|
||||||
|
|
||||||
|
// If the toast already exists, update it. Otherwise, append it.
|
||||||
|
const nextToasts =
|
||||||
|
prevIdx !== -1
|
||||||
|
? [
|
||||||
|
...prevToasts.slice(0, prevIdx),
|
||||||
|
propsWithKey,
|
||||||
|
...prevToasts.slice(prevIdx + 1),
|
||||||
|
]
|
||||||
|
: [...prevToasts, propsWithKey]
|
||||||
|
|
||||||
|
set({
|
||||||
|
// Get the last `maxToasts` toasts of the set.
|
||||||
|
toasts: nextToasts.slice(-maxToasts),
|
||||||
|
})
|
||||||
|
return propsWithKey.key
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const clearSelector = (state: State) => state.clear
|
||||||
|
|
||||||
|
const toasterSelector = (state: State) => ({
|
||||||
|
show: state.show,
|
||||||
|
dismiss: state.dismiss,
|
||||||
|
clear: state.clear,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useRawToasterForHook is meant to supply the hook function for hooks/toaster.
|
||||||
|
* Use hooks/toaster instead.
|
||||||
|
*/
|
||||||
|
export const useRawToasterForHook = () =>
|
||||||
|
useToasterState(toasterSelector, shallow)
|
||||||
|
|
||||||
|
type ToastProviderProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
canEscapeKeyClear?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToastProvider is the top-level toaster component. It stores the toast state.
|
||||||
|
*/
|
||||||
|
export default function ToastProvider(props: ToastProviderProps) {
|
||||||
|
const { children, canEscapeKeyClear = true } = props
|
||||||
|
const clear = useToasterState(clearSelector)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (!canEscapeKeyClear) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === "Esc" || e.key === "Escape") {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [canEscapeKeyClear, clear])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<ToastContainer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastContainerSelector = (state: State) => ({
|
||||||
|
toasts: state.toasts,
|
||||||
|
dismiss: state.dismiss,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToastContainer manages the positioning and animation for all currently
|
||||||
|
* displayed toasts. It should only be used by ToastProvider.
|
||||||
|
*/
|
||||||
|
function ToastContainer() {
|
||||||
|
const { toasts, dismiss } = useToasterState(toastContainerSelector, shallow)
|
||||||
|
|
||||||
|
const [prevToasts, setPrevToasts] = useState<ToastWithKey[]>(toasts)
|
||||||
|
useEffect(() => setPrevToasts(toasts), [toasts])
|
||||||
|
|
||||||
|
const [refMap] = useState(() => new Map<string, HTMLDivElement>())
|
||||||
|
const getOffsetForToast = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
let arr = toasts
|
||||||
|
let index = arr.findIndex((t) => t.key === key)
|
||||||
|
if (index === -1) {
|
||||||
|
arr = prevToasts
|
||||||
|
index = arr.findIndex((t) => t.key === key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = arr.length; i > index; i--) {
|
||||||
|
if (!arr[i]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const ref = refMap.get(arr[i].key)
|
||||||
|
if (!ref) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offset -= ref.offsetHeight
|
||||||
|
offset -= toastSpacing
|
||||||
|
}
|
||||||
|
return offset
|
||||||
|
},
|
||||||
|
[refMap, prevToasts, toasts]
|
||||||
|
)
|
||||||
|
|
||||||
|
const toastsWithStyles = useMemo(
|
||||||
|
() =>
|
||||||
|
toasts.map((toast) => ({
|
||||||
|
toast: toast,
|
||||||
|
style: {
|
||||||
|
transform: `translateY(${getOffsetForToast(toast.key)}px) scale(1.0)`,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[getOffsetForToast, toasts]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
throw new Error("Could not find toast root") // should never happen
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed bottom-6 right-6 z-[99]">
|
||||||
|
{toastsWithStyles.map(({ toast, style }) => (
|
||||||
|
<ToastBlock
|
||||||
|
key={toast.key}
|
||||||
|
ref={(ref) => ref && refMap.set(toast.key, ref)}
|
||||||
|
toast={toast}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
root
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToastBlock is the display of an individual toast, and also manages timeout
|
||||||
|
* settings for a particular toast.
|
||||||
|
*/
|
||||||
|
const ToastBlock = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
toast: ToastWithKey
|
||||||
|
onDismiss?: (key: string) => void
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
>(({ toast, onDismiss = noop, style }, ref) => {
|
||||||
|
const { message, key, timeout = 5000, variant } = toast
|
||||||
|
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
const dismiss = useCallback(() => onDismiss(key), [onDismiss, key])
|
||||||
|
const onFocus = useCallback(() => setFocused(true), [])
|
||||||
|
const onBlur = useCallback(() => setFocused(false), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeout <= 0 || focused) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timerId = setTimeout(() => dismiss(), timeout)
|
||||||
|
return () => clearTimeout(timerId)
|
||||||
|
}, [dismiss, timeout, focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"transition ease-in-out animate-scale-in",
|
||||||
|
"bottom-0 right-0 z-[99] w-[85vw] origin-bottom",
|
||||||
|
"sm:min-w-[400px] sm:max-w-[500px]",
|
||||||
|
"absolute shadow-sm rounded-md text-md flex items-center justify-between",
|
||||||
|
{
|
||||||
|
"text-white bg-gray-700": variant === undefined,
|
||||||
|
"text-white bg-orange-400": variant === "danger",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
ref={ref}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onMouseEnter={onFocus}
|
||||||
|
onMouseLeave={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<span className="pl-4 py-3 pr-2">{message}</span>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer opacity-75 hover:opacity-50 transition-opacity py-3 px-3"
|
||||||
|
onClick={dismiss}
|
||||||
|
>
|
||||||
|
<X className="w-[1em] h-[1em] stroke-current" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function remToPixels(rem: number) {
|
||||||
|
return (
|
||||||
|
rem * Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import { isPromise } from "src/utils/util"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* copyText copies text to the clipboard, handling cross-browser compatibility
|
||||||
|
* issues with different clipboard APIs.
|
||||||
|
*
|
||||||
|
* To support copying after running a network request (eg. generating an invite),
|
||||||
|
* pass a promise that resolves to the text to copy.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* copyText("Hello, world!")
|
||||||
|
* copyText(generateInvite().then(res => res.data.inviteCode))
|
||||||
|
*/
|
||||||
|
export function copyText(text: string | Promise<string | void>) {
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
if (isPromise(text)) {
|
||||||
|
return text.then((val) => fallbackCopy(validateString(val)))
|
||||||
|
}
|
||||||
|
return fallbackCopy(text)
|
||||||
|
}
|
||||||
|
if (isPromise(text)) {
|
||||||
|
if (typeof ClipboardItem === "undefined") {
|
||||||
|
return text.then((val) =>
|
||||||
|
navigator.clipboard.writeText(validateString(val))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
"text/plain": text.then(
|
||||||
|
(val) => new Blob([validateString(val)], { type: "text/plain" })
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return navigator.clipboard.writeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateString(val: unknown): string {
|
||||||
|
if (typeof val !== "string" || val.length === 0) {
|
||||||
|
throw new TypeError("Expected string, got " + typeof val)
|
||||||
|
}
|
||||||
|
if (val.length === 0) {
|
||||||
|
throw new TypeError("Expected non-empty string")
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text: string) {
|
||||||
|
const el = document.createElement("textarea")
|
||||||
|
el.value = text
|
||||||
|
el.setAttribute("readonly", "")
|
||||||
|
el.className = "absolute opacity-0 pointer-events-none"
|
||||||
|
document.body.append(el)
|
||||||
|
|
||||||
|
// Check if text is currently selected
|
||||||
|
let selection = document.getSelection()
|
||||||
|
const selected =
|
||||||
|
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : false
|
||||||
|
|
||||||
|
el.select()
|
||||||
|
document.execCommand("copy")
|
||||||
|
el.remove()
|
||||||
|
|
||||||
|
// Restore selection
|
||||||
|
if (selected) {
|
||||||
|
selection = document.getSelection()
|
||||||
|
if (selection) {
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
Loading…
Reference in New Issue