chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user