chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
export * from './lib/collapsible';
export * from './lib/popover';
export * from './lib/button';
export * from './lib/spinner';
export * from './lib/modal';
export * from './lib/error-renderer';
export * from './lib/tooltips';
export * from './lib/copy-to-clipboard-button';
export * from './lib/dropdown';
export * from './lib/debounced-text-input';
export * from './lib/tag';
export * from './lib/multi-select';
+31
View File
@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
interface ButtonProps {
onClick: () => void;
children: ReactNode;
disabled?: boolean;
className?: string;
}
export function Button({
children,
onClick,
disabled,
className = '',
}: ButtonProps) {
return (
<div>
<button
onClick={onClick}
disabled={disabled}
className={`rounded-lg px-4 py-2 font-semibold transition ${
disabled
? 'cursor-not-allowed bg-gray-400'
: 'bg-blue-500 text-white hover:bg-blue-600'
} ${className}`}
>
{children}
</button>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { motion, AnimatePresence } from 'framer-motion';
import { ReactNode } from 'react';
interface CollapsibleProps {
isOpen: boolean;
children: ReactNode;
className?: string;
}
export function Collapsible({ isOpen, children, className }: CollapsibleProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
className={`overflow-hidden ${className}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,61 @@
import {
ClipboardDocumentCheckIcon,
ClipboardDocumentIcon,
} from '@heroicons/react/24/outline';
import { ReactNode, useEffect, useState } from 'react';
import clipboard from 'react-copy-to-clipboard';
const { CopyToClipboard } = clipboard;
export interface CopyToClipboardButtonProps {
text: string;
tooltipText?: string;
tooltipAlignment?: 'left' | 'right';
className?: string;
children?: ReactNode;
}
export function CopyToClipboardButton({
text,
tooltipAlignment,
tooltipText,
className,
children,
}: CopyToClipboardButtonProps) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => {
setCopied(false);
}, 3000);
return () => clearTimeout(t);
}, [copied]);
return (
<CopyToClipboard
text={text}
onCopy={() => {
setCopied(true);
}}
>
<button
type="button"
data-tooltip={tooltipText ? tooltipText : null}
data-tooltip-align-right={tooltipAlignment === 'right'}
data-tooltip-align-left={tooltipAlignment === 'left'}
className={className}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{copied ? (
<ClipboardDocumentCheckIcon className="inline h-5 w-5 text-blue-500 dark:text-sky-500" />
) : (
<ClipboardDocumentIcon className="inline h-5 w-5" />
)}
{children}
</button>
</CopyToClipboard>
);
}
@@ -0,0 +1,85 @@
import { BackspaceIcon, FunnelIcon } from '@heroicons/react/24/outline';
import { KeyboardEvent, useEffect, useState } from 'react';
import { useDebounce } from './use-debounce';
export interface DebouncedTextInputProps {
initialText: string;
placeholderText: string;
resetTextFilter: () => void;
updateTextFilter: (textFilter: string) => void;
}
export function DebouncedTextInput({
initialText,
placeholderText,
resetTextFilter,
updateTextFilter,
}: DebouncedTextInputProps) {
const [currentTextFilter, setCurrentTextFilter] = useState(initialText ?? '');
const [debouncedValue, setDebouncedValue] = useDebounce(
currentTextFilter,
500
);
function onTextFilterKeyUp(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
onTextInputChange(event.currentTarget.value);
}
}
function onTextInputChange(change: string) {
if (change === '') {
setCurrentTextFilter('');
setDebouncedValue('');
resetTextFilter();
} else {
setCurrentTextFilter(change);
}
}
function resetClicked() {
setCurrentTextFilter('');
setDebouncedValue('');
resetTextFilter();
}
useEffect(() => {
if (debouncedValue !== '') {
updateTextFilter(debouncedValue);
}
}, [debouncedValue, updateTextFilter]);
return (
<form
className="shadow-xs group relative flex rounded-md"
onSubmit={(event) => event.preventDefault()}
>
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-50 p-2 dark:border-slate-900 dark:bg-slate-800">
<FunnelIcon className="h-4 w-4"></FunnelIcon>
</span>
<input
type="text"
className={`block w-full flex-1 rounded-none rounded-r-md border border-slate-300 bg-white p-1.5 font-light text-slate-400 placeholder:font-light placeholder:text-slate-400 dark:border-slate-900 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700`}
placeholder={placeholderText}
data-cy="textFilterInput"
name="filter"
value={currentTextFilter}
onKeyUp={onTextFilterKeyUp}
onChange={(event) => onTextInputChange(event.currentTarget.value)}
></input>
{currentTextFilter.length > 0 ? (
<button
data-cy="textFilterReset"
type="reset"
onClick={resetClicked}
className="absolute right-1 top-1 inline-block rounded-md bg-slate-50 p-1 text-slate-400 dark:bg-slate-800"
>
<BackspaceIcon className="h-5 w-5"></BackspaceIcon>
</button>
) : null}
</form>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { HTMLAttributes, ReactNode } from 'react';
export type DropdownProps = {
children: ReactNode[];
} & HTMLAttributes<HTMLSelectElement>;
export function Dropdown(props: DropdownProps) {
const { className, children, ...rest } = props;
return (
<select
className={`form-select shadow-xs flex items-center rounded-md border border-slate-300 bg-white py-2 pl-4 pr-8 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700 ${className}`}
{...rest}
>
{children}
</select>
);
}
@@ -0,0 +1,64 @@
// nx-ignore-next-line
import type { GraphError } from 'nx/src/command-line/graph/graph';
export function ErrorRenderer({ errors }: { errors: GraphError[] }) {
return (
<div>
{errors.map((error, index) => {
const errorHeading =
error.pluginName && error.name
? `${error.name} - ${error.pluginName}`
: (error.name ?? error.message);
const fileSpecifier =
isCauseWithLocation(error.cause) && error.cause.errors.length === 1
? `${error.fileName}:${error.cause.errors[0].location.line}:${error.cause.errors[0].location.column}`
: error.fileName;
return (
<div className="overflow-hidden pb-4" key={index}>
<span className="inline-flex max-w-full flex-col break-words font-bold font-normal text-gray-900 md:inline dark:text-slate-200">
<span>{errorHeading}</span>
{fileSpecifier && (
<span className="hidden px-1 md:inline">-</span>
)}
<span>{fileSpecifier}</span>
</span>
<pre className="overflow-x-scroll pl-4 pt-3">
{isCauseWithErrors(error.cause) &&
error.cause.errors.length === 1 ? (
<div>
{error.message} <br />
{error.cause.errors[0].text}{' '}
</div>
) : (
<div>{error.stack}</div>
)}
</pre>
</div>
);
})}
</div>
);
}
function isCauseWithLocation(cause: unknown): cause is {
errors: {
location: {
column: number;
line: number;
};
text: string;
}[];
} {
return (
isCauseWithErrors(cause) &&
(cause as any).errors[0].location &&
(cause as any).errors[0].location.column &&
(cause as any).errors[0].location.line
);
}
function isCauseWithErrors(
cause: unknown
): cause is { errors: { text: string }[] } {
return cause && (cause as any).errors && (cause as any).errors[0].text;
}
@@ -0,0 +1,69 @@
import { Transition } from '@headlessui/react';
import { ExclamationCircleIcon } from '@heroicons/react/24/outline';
// nx-ignore-next-line
import { GraphError } from 'nx/src/command-line/graph/graph';
import { ForwardedRef, forwardRef, useImperativeHandle, useRef } from 'react';
import { ErrorRenderer } from './error-renderer';
import { Modal, ModalHandle } from '../modal';
export type ErrorToastUIProps = {
errors?: GraphError[] | undefined;
onModalOpen?: () => void;
onModalClose?: () => void;
};
export type ErrorToastUIImperativeHandle = {
closeModal: () => void;
openModal: () => void;
};
export const ErrorToastUI = forwardRef(
(
{ errors, onModalOpen, onModalClose }: ErrorToastUIProps,
ref: ForwardedRef<ErrorToastUIImperativeHandle | undefined>
) => {
const inputsModalRef = useRef<ModalHandle>();
useImperativeHandle(ref, () => ({
openModal: () => {
inputsModalRef?.current?.openModal();
},
closeModal: () => {
inputsModalRef?.current?.closeModal();
},
}));
return (
<Transition
as="div"
show={!!errors && errors.length > 0}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-x-0 top-4 z-50 px-4 py-2 text-center">
<div
onClick={() => inputsModalRef.current?.openModal()}
className="z-50 flex w-fit max-w-sm cursor-pointer items-center rounded-md bg-red-600 p-4 text-slate-200 shadow-lg"
>
<ExclamationCircleIcon className="mr-2 inline-block h-6 w-6" />
Some project information might be missing. Click to see errors.
</div>
{errors?.length > 0 && (
<Modal
title={`Project Graph Errors`}
ref={inputsModalRef}
onOpen={onModalOpen}
onClose={onModalClose}
>
<ErrorRenderer errors={errors ?? []} />
</Modal>
)}
</div>
</Transition>
);
}
);
@@ -0,0 +1,103 @@
// nx-ignore-next-line
import type { GraphError } from 'nx/src/command-line/graph/graph';
import {
ForwardedRef,
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useRef,
} from 'react';
import { SetURLSearchParams, useSearchParams } from 'react-router-dom';
import { ErrorToastUI, ErrorToastUIImperativeHandle } from './error-toast-ui';
export interface ErrorToastImperativeHandle {
closeModal: () => void;
openModal: () => void;
}
interface ErrorToastProps {
errors?: GraphError[] | undefined;
}
export const ErrorToast = forwardRef(
(
{ errors }: ErrorToastProps,
ref: ForwardedRef<ErrorToastImperativeHandle>
) => {
const errorToastUIRef = useRef<ErrorToastUIImperativeHandle>();
const [searchParams, setSearchParams] = useSearchParams();
useImperativeHandle(ref, () => ({
openModal: () => {
errorToastUIRef?.current?.openModal();
},
closeModal: () => {
errorToastUIRef?.current?.closeModal();
},
}));
const handleModalOpen = useCallback(() => {
if (searchParams.get('show-error') === 'true') return;
setSearchParams(
(currentSearchParams) => {
currentSearchParams.set('show-error', 'true');
return currentSearchParams;
},
{ replace: true, preventScrollReset: true }
);
}, [setSearchParams, searchParams]);
const handleModalClose = useCallback(() => {
if (!searchParams.get('show-error')) return;
setSearchParams(
(currentSearchParams) => {
currentSearchParams.delete('show-error');
return currentSearchParams;
},
{ replace: true, preventScrollReset: true }
);
}, [setSearchParams, searchParams]);
useLayoutEffect(() => {
if (searchParams.get('show-error') === 'true') {
if (errors && errors.length > 0) {
errorToastUIRef.current?.openModal();
} else {
setSearchParams(
(currentSearchParams) => {
currentSearchParams.delete('show-error');
return currentSearchParams;
},
{ replace: true, preventScrollReset: true }
);
}
}
}, [searchParams, errorToastUIRef, errors, setSearchParams]);
return (
<ErrorToastUI
ref={errorToastUIRef}
errors={errors}
onModalOpen={handleModalOpen}
onModalClose={handleModalClose}
></ErrorToastUI>
);
}
);
export const useRouterHandleModalOpen = (
searchParams: URLSearchParams,
setSearchParams: SetURLSearchParams
) =>
useCallback(() => {
if (searchParams.get('show-error') === 'true') return;
setSearchParams(
(currentSearchParams) => {
currentSearchParams.set('show-error', 'true');
return currentSearchParams;
},
{ replace: true, preventScrollReset: true }
);
}, [setSearchParams, searchParams]);
@@ -0,0 +1,3 @@
export * from './error-renderer';
export * from './error-toast-ui';
export * from './error-toast';
+113
View File
@@ -0,0 +1,113 @@
// component from https://tailwindui.com/components/application-ui/overlays/dialogs
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import {
ForwardedRef,
Fragment,
ReactNode,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react';
export interface ModalProps {
children: ReactNode;
title: string;
onOpen?: () => void;
onClose?: () => void;
}
export interface ModalHandle {
openModal: () => void;
closeModal: () => void;
}
export const Modal = forwardRef(
(
{ children, title, onOpen, onClose }: ModalProps,
ref: ForwardedRef<ModalHandle | undefined>
) => {
const [open, setOpen] = useState(false);
useEffect(() => {
if (open) {
onOpen?.();
} else {
onClose?.();
}
}, [open, onOpen, onClose]);
useImperativeHandle(ref, () => ({
closeModal: () => {
setOpen(false);
},
openModal: () => {
setOpen(true);
},
}));
return (
<Transition show={open} as={Fragment}>
<Dialog as="div" className="relative z-[9999]" onClose={setOpen}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500/75 transition-opacity" />
</TransitionChild>
<div className="fixed inset-0 z-[9999] w-screen overflow-y-auto">
<div className="flex h-full min-h-full items-end items-center justify-center p-4 text-center sm:p-0">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel
className={`relative mx-4 my-8 w-full transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all md:max-w-5xl xl:max-w-7xl dark:bg-slate-900`}
>
<div className="flex items-center justify-between rounded-t border-b bg-white p-2 pb-1 md:p-4 md:pb-2 dark:border-gray-600 dark:bg-slate-900">
<DialogTitle
as="h3"
className="text-lg font-medium leading-6 text-gray-900 dark:text-slate-200"
>
{title}
</DialogTitle>
<button
type="button"
className="ms-auto inline-flex h-8 w-8 items-center justify-center rounded-lg bg-transparent text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="default-modal"
onClick={() => setOpen(false)}
>
<XMarkIcon />
<span className="sr-only">Close modal</span>
</button>
</div>
<div className="max-h-[90vh] overflow-y-auto bg-white p-2 md:p-4 dark:bg-slate-900">
{children}
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
);
+252
View File
@@ -0,0 +1,252 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
CheckIcon,
ChevronDownIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
export interface MultiSelectOption {
value: string;
label: string;
}
export interface MultiSelectProps {
options: MultiSelectOption[];
value: string[];
onChange: (values: string[]) => void;
placeholder?: string;
className?: string;
id?: string;
debounceMs?: number;
}
export function MultiSelect({
options,
value,
onChange,
placeholder = 'Select options...',
className = '',
id,
debounceMs = 300,
}: MultiSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [pendingValue, setPendingValue] = useState<string[]>(value);
const [searchTerm, setSearchTerm] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
setPendingValue(value);
}, [value]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
const debouncedOnChange = useCallback(
(newValue: string[]) => {
setPendingValue(newValue);
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
onChange(newValue);
}, debounceMs);
},
[onChange, debounceMs]
);
const handleOptionToggle = (optionValue: string) => {
const newValue = pendingValue.includes(optionValue)
? pendingValue.filter((v) => v !== optionValue)
: [...pendingValue, optionValue];
debouncedOnChange(newValue);
};
const handleSelectAll = () => {
const filteredValues = filteredOptions.map((option) => option.value);
const allFilteredSelected = filteredValues.every((value) =>
pendingValue.includes(value)
);
if (allFilteredSelected && filteredValues.length > 0) {
// Remove all filtered options from selection
debouncedOnChange(
pendingValue.filter((value) => !filteredValues.includes(value))
);
} else {
// Add all filtered options to selection
const newValue = [...new Set([...pendingValue, ...filteredValues])];
debouncedOnChange(newValue);
}
};
const selectedCount = pendingValue.length;
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
);
const removeValue = (valueToRemove: string) => {
debouncedOnChange(pendingValue.filter((v) => v !== valueToRemove));
};
return (
<div className={`relative ${className}`} ref={containerRef}>
<div
className="flex min-h-[40px] w-full cursor-pointer items-center justify-between rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-xs hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex min-h-[24px] flex-1 flex-wrap items-center gap-1">
{selectedCount === 0 ? (
<span className="text-slate-500 dark:text-slate-400">
{placeholder}
</span>
) : selectedCount > 3 ? (
<span className="text-slate-700 dark:text-slate-300">
{selectedCount} targets selected
</span>
) : (
pendingValue.map((selectedValue) => {
const option = options.find((opt) => opt.value === selectedValue);
return (
<div
key={selectedValue}
className="inline-flex items-center gap-1 rounded-sm bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/50 dark:text-blue-200"
onClick={(e) => e.stopPropagation()}
>
<span>{option?.label || selectedValue}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeValue(selectedValue);
}}
className="text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100"
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
);
})
)}
</div>
<ChevronDownIcon
className={`ml-2 h-4 w-4 flex-shrink-0 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</div>
{isOpen && (
<div className="absolute z-10 mt-1 w-full rounded-md border border-slate-300 bg-white shadow-lg dark:border-slate-600 dark:bg-slate-800">
{/* Search input */}
<div className="border-b border-slate-200 p-2 dark:border-slate-600">
<input
type="text"
placeholder="Search targets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-sm border border-slate-300 px-2 py-1 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="max-h-60 overflow-auto">
{/* Select All / Clear All option */}
<div
className="flex cursor-pointer items-center px-4 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700"
onClick={handleSelectAll}
>
<div className="mr-3 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border border-slate-300 dark:border-slate-600">
{(() => {
const filteredValues = filteredOptions.map(
(opt) => opt.value
);
const allFilteredSelected = filteredValues.every((value) =>
pendingValue.includes(value)
);
const someFilteredSelected = filteredValues.some((value) =>
pendingValue.includes(value)
);
if (allFilteredSelected && filteredValues.length > 0) {
return (
<CheckIcon className="h-3 w-3 text-blue-500 dark:text-sky-500" />
);
} else if (someFilteredSelected) {
return (
<div className="h-2 w-2 rounded-xs bg-blue-500 dark:bg-sky-500" />
);
}
return null;
})()}
</div>
<span className="min-w-0 flex-1 font-medium text-slate-700 dark:text-slate-300">
{(() => {
const filteredValues = filteredOptions.map(
(opt) => opt.value
);
const allFilteredSelected = filteredValues.every((value) =>
pendingValue.includes(value)
);
return allFilteredSelected && filteredValues.length > 0
? 'Clear All'
: 'Select All';
})()}
</span>
</div>
{/* Separator */}
<div className="border-t border-slate-200 dark:border-slate-600" />
{/* Individual options */}
{filteredOptions.length === 0 ? (
<div className="px-4 py-2 text-sm text-slate-500 dark:text-slate-400">
No targets found
</div>
) : (
filteredOptions.map((option) => {
const isSelected = pendingValue.includes(option.value);
return (
<div
key={option.value}
className="flex cursor-pointer items-center px-4 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700"
onClick={() => handleOptionToggle(option.value)}
>
<div className="mr-3 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border border-slate-300 dark:border-slate-600">
{isSelected && (
<CheckIcon className="h-3 w-3 text-blue-500 dark:text-sky-500" />
)}
</div>
<span
className="min-w-0 flex-1 truncate text-slate-700 dark:text-slate-300"
title={option.label}
>
{option.label}
</span>
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { useFloating, useDismiss, useInteractions } from '@floating-ui/react';
export interface PopoverProps {
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
position?: { top?: string; left?: string; right?: string; bottom?: string };
}
export function Popover({ isOpen, onClose, children, position }: PopoverProps) {
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: (open) => {
if (!open) onClose();
},
});
const dismiss = useDismiss(context, { referencePress: false });
const { getFloatingProps } = useInteractions([dismiss]);
if (!isOpen) return null;
return (
<div
ref={refs.setFloating}
style={{ ...floatingStyles, ...position }}
{...getFloatingProps()}
className="animate-fadeIn absolute z-50 flex w-64 flex-col gap-1 rounded-md border border-slate-300/[0.25] bg-white text-slate-700 shadow-lg transition-opacity duration-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{children}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Spinner component from https://tailwindcss.com/docs/animation#spin
*/
import type { SVGProps } from 'react';
export type SpinnerProps = SVGProps<SVGSVGElement>;
export function Spinner({ className, ...rest }: SpinnerProps) {
return (
<svg
className={`${className} h-8 w-8 animate-spin`}
viewBox="0 0 24 24"
{...rest}
>
<circle
className="opacity-10"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
className="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
export type TagProps = Partial<{
className: string;
children: ReactNode | ReactNode[];
}> &
React.HTMLAttributes<HTMLSpanElement>;
export function Tag({ className, children, ...rest }: TagProps) {
return (
<span
className={`${className} inline-block rounded-md bg-slate-300 p-2 font-sans text-xs font-semibold uppercase leading-4 tracking-wide text-slate-700`}
{...rest}
>
{children}
</span>
);
}
@@ -0,0 +1,2 @@
export * from './tooltip';
export * from './tooltip-button';
@@ -0,0 +1,30 @@
import type { HTMLAttributes } from 'react';
import { Link, LinkProps } from 'react-router-dom';
const sharedClasses =
'inline-flex justify-center rounded-md border border-slate-300 bg-slate-50 py-2 px-4 mt-2 text-slate-500 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700';
export function TooltipButton({
className,
children,
...rest
}: HTMLAttributes<HTMLButtonElement>) {
return (
<button className={`${sharedClasses} ${className}`} {...rest}>
{children}
</button>
);
}
export function TooltipLinkButton({
to,
className,
children,
...rest
}: LinkProps) {
return (
<Link className={`${sharedClasses} ${className}`} to={to} {...rest}>
{children}
</Link>
);
}
@@ -0,0 +1,174 @@
import {
arrow,
autoUpdate,
flip,
FloatingPortal,
offset,
Placement,
ReferenceType,
safePolygon,
shift,
useClick,
useDismiss,
useFloating,
useHover,
useInteractions,
useRole,
useTransitionStyles,
} from '@floating-ui/react';
import {
Attributes,
cloneElement,
HTMLAttributes,
ReactElement,
ReactNode,
useLayoutEffect,
useRef,
useState,
} from 'react';
export type TooltipProps = Omit<HTMLAttributes<HTMLDivElement>, 'content'> & {
open?: boolean;
content: ReactNode;
children?: ReactElement;
placement?: Placement;
reference?: ReferenceType;
openAction?: 'click' | 'hover' | 'manual';
buffer?: number;
showTooltipArrow?: boolean;
strategy?: 'absolute' | 'fixed';
usePortal?: boolean;
};
export function Tooltip({
children,
open = false,
content,
placement = 'top',
reference: externalReference,
openAction = 'click',
strategy = 'absolute',
buffer = 0,
showTooltipArrow = true,
usePortal = false,
}: TooltipProps) {
const [isOpen, setIsOpen] = useState(open);
const arrowRef = useRef(null);
const {
x,
y,
refs,
strategy: appliedStrategy,
placement: finalPlacement,
middlewareData: { arrow: { x: arrowX, y: arrowY } = {} },
context,
} = useFloating({
placement,
whileElementsMounted: strategy === 'fixed' ? autoUpdate : undefined,
open: isOpen,
onOpenChange: setIsOpen,
strategy,
middleware: [
offset(6),
flip(),
shift({ padding: 6 }),
arrow({ element: arrowRef }),
],
});
const { isMounted, styles: animationStyles } = useTransitionStyles(context, {
duration: 200,
initial: {
opacity: openAction === 'hover' ? 0 : 1,
},
});
const staticSide: string =
{
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[finalPlacement.split('-')[0]] || 'bottom';
useLayoutEffect(() => {
if (externalReference) {
refs.setReference(externalReference);
}
}, [refs, externalReference]);
const click = useClick(context, { enabled: openAction === 'click' });
const dismiss = useDismiss(context, {
enabled: openAction === 'click',
referencePress: false,
outsidePress: true,
outsidePressEvent: 'mousedown',
});
const hover = useHover(context, {
restMs: 300,
enabled: openAction === 'hover',
delay: { open: 0, close: 150 },
handleClose: safePolygon({ buffer }),
});
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
hover,
dismiss,
role,
]);
const cloneProps: Partial<any> & Attributes = {
ref: refs.setReference,
...getReferenceProps(),
};
const renderTooltip = () => (
<div
ref={refs.setFloating}
style={{
position: appliedStrategy,
top: showTooltipArrow ? y : y + 8,
left: x ?? 0,
width: 'max-content',
...animationStyles,
}}
className="z-20 min-w-[250px] max-w-prose rounded-md border border-slate-500"
{...getFloatingProps()}
>
{showTooltipArrow && (
<div
style={{
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
}}
className="absolute -z-10 h-4 w-4 rotate-45 bg-slate-500"
ref={arrowRef}
></div>
)}
<div className="select-text rounded-md bg-white p-3 dark:bg-slate-900 dark:text-slate-400">
{content}
</div>
</div>
);
return (
<>
{!externalReference && !!children
? cloneElement(children, cloneProps)
: children}
{isOpen && isMounted ? (
usePortal ? (
<FloatingPortal>{renderTooltip()}</FloatingPortal>
) : (
renderTooltip()
)
) : null}
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Dispatch, SetStateAction } from 'react';
import { useEffect, useState } from 'react';
export function useDebounce(
value: string,
delay: number
): [string, Dispatch<SetStateAction<string>>] {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler);
};
},
[value, delay] // Only re-call effect if value or delay changes
);
return [debouncedValue, setDebouncedValue];
}