mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-25 02:41:07 +01:00
[WIP] Encryption for secure values (#183)
This commit is contained in:
6
src-web/components/core/BadgeButton.tsx
Normal file
6
src-web/components/core/BadgeButton.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
|
||||
export function BadgeButton(props: ButtonProps) {
|
||||
return <Button size="2xs" variant="border" className="!rounded-full mx-1" {...props} />;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
export interface BannerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
color?: 'primary' | 'secondary' | 'success' | 'notice' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
export function Banner({ children, className, color }: Props) {
|
||||
export function Banner({ children, className, color }: BannerProps) {
|
||||
return (
|
||||
<div className="w-full mb-auto grid grid-rows-1 max-h-full">
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import classNames from 'classnames';
|
||||
import { type ReactNode } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
import { IconTooltip } from './IconTooltip';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
export interface CheckboxProps {
|
||||
@@ -12,6 +13,7 @@ export interface CheckboxProps {
|
||||
inputWrapperClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
fullWidth?: boolean;
|
||||
help?: ReactNode;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
@@ -23,9 +25,15 @@ export function Checkbox({
|
||||
title,
|
||||
hideLabel,
|
||||
fullWidth,
|
||||
help,
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<HStack as="label" space={2} className={classNames(className, 'text-text mr-auto')}>
|
||||
<HStack
|
||||
as="label"
|
||||
alignItems="center"
|
||||
space={2}
|
||||
className={classNames(className, 'text-text mr-auto')}
|
||||
>
|
||||
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
|
||||
<input
|
||||
aria-hidden
|
||||
@@ -51,6 +59,7 @@ export function Checkbox({
|
||||
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
|
||||
{!hideLabel && title}
|
||||
</div>
|
||||
{help && <IconTooltip content={help} />}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function Dialog({
|
||||
'relative bg-surface pointer-events-auto',
|
||||
'rounded-lg',
|
||||
'border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]',
|
||||
'min-h-[10rem]',
|
||||
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]',
|
||||
size === 'sm' && 'w-[28rem]',
|
||||
size === 'md' && 'w-[45rem]',
|
||||
@@ -88,15 +89,15 @@ export function Dialog({
|
||||
{title}
|
||||
</Heading>
|
||||
) : (
|
||||
<span />
|
||||
<span aria-hidden />
|
||||
)}
|
||||
|
||||
{description ? (
|
||||
<div className="px-6 text-text-subtle" id={descriptionId}>
|
||||
<div className="px-6 text-text-subtle mb-3" id={descriptionId}>
|
||||
{description}
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
<span aria-hidden />
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
32
src-web/components/core/DismissibleBanner.tsx
Normal file
32
src-web/components/core/DismissibleBanner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import classNames from 'classnames';
|
||||
import { useKeyValue } from '../../hooks/useKeyValue';
|
||||
import type { BannerProps } from './Banner';
|
||||
import { Banner } from './Banner';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
export function DismissibleBanner({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}: BannerProps & { id: string }) {
|
||||
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['dismiss-banner', id],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<Banner className={classNames(className, 'relative pr-8')} {...props}>
|
||||
<IconButton
|
||||
className="!absolute right-0 top-0"
|
||||
icon="x"
|
||||
onClick={() => setDismissed((d) => !d)}
|
||||
title="Dismiss message"
|
||||
/>
|
||||
{children}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
@@ -134,30 +134,31 @@
|
||||
.cm-searchMatch {
|
||||
@apply bg-transparent !important;
|
||||
@apply rounded-[2px] outline outline-1;
|
||||
|
||||
&.cm-searchMatch-selected {
|
||||
@apply outline-text;
|
||||
@apply bg-text !important;
|
||||
|
||||
&, * {
|
||||
@apply text-surface font-semibold !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
/*.cm-searchMatch {*/
|
||||
/* @apply bg-transparent !important;*/
|
||||
/* @apply outline outline-[1.5px] outline-text-subtlest rounded-sm;*/
|
||||
/* &.cm-searchMatch-selected {*/
|
||||
/* @apply outline-text;*/
|
||||
/* & * {*/
|
||||
/* @apply text-text font-semibold;*/
|
||||
/* }*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
|
||||
.cm-placeholder {
|
||||
-webkit-text-security: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutterElement {
|
||||
@apply flex items-center;
|
||||
transition: color var(--transition-duration);
|
||||
|
||||
@@ -6,12 +6,13 @@ import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/vie
|
||||
import { emacs } from '@replit/codemirror-emacs';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||
import type {EditorKeymap, EnvironmentVariable} from '@yaakapp-internal/models';
|
||||
import { settingsAtom} from '@yaakapp-internal/models';
|
||||
import type { EditorKeymap, EnvironmentVariable } from '@yaakapp-internal/models';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import { parseTemplate } from '@yaakapp-internal/templates';
|
||||
import classNames from 'classnames';
|
||||
import { EditorView } from 'codemirror';
|
||||
import {useAtomValue} from "jotai";
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { md5 } from 'js-md5';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import {
|
||||
@@ -26,14 +27,15 @@ import {
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables';
|
||||
import { parseTemplate } from '../../../hooks/useParseTemplate';
|
||||
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
||||
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
||||
import { showDialog } from '../../../lib/dialog';
|
||||
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
|
||||
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
|
||||
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
|
||||
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { InlineCode } from '../InlineCode';
|
||||
import { HStack } from '../Stacks';
|
||||
import './Editor.css';
|
||||
import {
|
||||
@@ -203,7 +205,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
useEffect(
|
||||
function configurePlaceholder() {
|
||||
if (cm.current === null) return;
|
||||
const ext = placeholderExt(placeholderElFromText(placeholder, type));
|
||||
const ext = placeholderExt(placeholderElFromText(placeholder));
|
||||
const effects = placeholderCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
},
|
||||
@@ -265,32 +267,39 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
|
||||
const onClickFunction = useCallback(
|
||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||
const initialTokens = await parseTemplate(tagValue);
|
||||
showDialog({
|
||||
id: 'template-function-'+Math.random(), // Allow multiple at once
|
||||
size: 'sm',
|
||||
title: 'Configure Function',
|
||||
description: fn.description,
|
||||
render: ({ hide }) => (
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={fn}
|
||||
hide={hide}
|
||||
initialTokens={initialTokens}
|
||||
onChange={(insert) => {
|
||||
cm.current?.view.dispatch({
|
||||
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
const initialTokens = parseTemplate(tagValue);
|
||||
const show = () =>
|
||||
showDialog({
|
||||
id: 'template-function-' + Math.random(), // Allow multiple at once
|
||||
size: 'sm',
|
||||
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
||||
description: fn.description,
|
||||
render: ({ hide }) => (
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={fn}
|
||||
hide={hide}
|
||||
initialTokens={initialTokens}
|
||||
onChange={(insert) => {
|
||||
cm.current?.view.dispatch({
|
||||
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
if (fn.name === 'secure') {
|
||||
withEncryptionEnabled(show);
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onClickVariable = useCallback(
|
||||
async (_v: EnvironmentVariable, tagValue: string, startPos: number) => {
|
||||
const initialTokens = await parseTemplate(tagValue);
|
||||
const initialTokens = parseTemplate(tagValue);
|
||||
showDialog({
|
||||
size: 'dynamic',
|
||||
id: 'template-variable',
|
||||
@@ -313,7 +322,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
|
||||
const onClickMissingVariable = useCallback(
|
||||
async (_name: string, tagValue: string, startPos: number) => {
|
||||
const initialTokens = await parseTemplate(tagValue);
|
||||
const initialTokens = parseTemplate(tagValue);
|
||||
showDialog({
|
||||
size: 'dynamic',
|
||||
id: 'template-variable',
|
||||
@@ -398,9 +407,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
|
||||
const extensions = [
|
||||
languageCompartment.of(langExt),
|
||||
placeholderCompartment.current.of(
|
||||
placeholderExt(placeholderElFromText(placeholder, type)),
|
||||
),
|
||||
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
|
||||
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
|
||||
tabIndentCompartment.current.of(
|
||||
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
|
||||
@@ -639,17 +646,11 @@ function getExtensions({
|
||||
];
|
||||
}
|
||||
|
||||
const placeholderElFromText = (text: string | undefined, type: EditorProps['type']) => {
|
||||
const placeholderElFromText = (text: string | undefined) => {
|
||||
const el = document.createElement('div');
|
||||
if (type === 'password') {
|
||||
// Will be obscured (dots) so just needs to be something to take up space
|
||||
el.innerHTML = 'something-cool';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
|
||||
// to do with how Yaak "hacks" it with CSS for single line input.
|
||||
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
|
||||
}
|
||||
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
|
||||
// to do with how Yaak "hacks" it with CSS for single line input.
|
||||
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
|
||||
return el;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export function Heading({ className, level = 1, ...props }: Props) {
|
||||
<Component
|
||||
className={classNames(
|
||||
className,
|
||||
'font-semibold text',
|
||||
'font-semibold text-text',
|
||||
level === 1 && 'text-2xl',
|
||||
level === 2 && 'text-xl',
|
||||
level === 3 && 'text-lg',
|
||||
|
||||
@@ -56,8 +56,8 @@ const icons = {
|
||||
git_branch_plus: lucide.GitBranchPlusIcon,
|
||||
git_commit: lucide.GitCommitIcon,
|
||||
git_commit_vertical: lucide.GitCommitVerticalIcon,
|
||||
git_pull_request: lucide.GitPullRequestIcon,
|
||||
git_fork: lucide.GitForkIcon,
|
||||
git_pull_request: lucide.GitPullRequestIcon,
|
||||
grip_vertical: lucide.GripVerticalIcon,
|
||||
hand: lucide.HandIcon,
|
||||
help: lucide.CircleHelpIcon,
|
||||
@@ -65,6 +65,7 @@ const icons = {
|
||||
house: lucide.HomeIcon,
|
||||
import: lucide.ImportIcon,
|
||||
info: lucide.InfoIcon,
|
||||
key_round: lucide.KeyRoundIcon,
|
||||
keyboard: lucide.KeyboardIcon,
|
||||
left_panel_hidden: lucide.PanelLeftOpenIcon,
|
||||
left_panel_visible: lucide.PanelLeftCloseIcon,
|
||||
@@ -87,6 +88,9 @@ const icons = {
|
||||
search: lucide.SearchIcon,
|
||||
send_horizontal: lucide.SendHorizonalIcon,
|
||||
settings: lucide.SettingsIcon,
|
||||
shield: lucide.ShieldIcon,
|
||||
shield_check: lucide.ShieldCheckIcon,
|
||||
shield_off: lucide.ShieldOffIcon,
|
||||
sparkles: lucide.SparklesIcon,
|
||||
sun: lucide.SunIcon,
|
||||
table: lucide.TableIcon,
|
||||
@@ -126,7 +130,7 @@ export const Icon = memo(function Icon({
|
||||
title={title}
|
||||
className={classNames(
|
||||
className,
|
||||
'flex-shrink-0',
|
||||
'flex-shrink-0 transform-cpu',
|
||||
size === 'xl' && 'h-6 w-6',
|
||||
size === 'lg' && 'h-5 w-5',
|
||||
size === 'md' && 'h-4 w-4',
|
||||
@@ -134,7 +138,7 @@ export const Icon = memo(function Icon({
|
||||
size === 'xs' && 'h-3 w-3',
|
||||
size === '2xs' && 'h-2.5 w-2.5',
|
||||
color === 'default' && 'inherit',
|
||||
color === 'danger' && 'text-danger',
|
||||
color === 'danger' && 'text-danger!',
|
||||
color === 'warning' && 'text-warning',
|
||||
color === 'notice' && 'text-notice',
|
||||
color === 'info' && 'text-info',
|
||||
|
||||
@@ -74,10 +74,11 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
size={iconSize}
|
||||
icon={confirmed ? 'check' : icon}
|
||||
spin={spin}
|
||||
color={confirmed ? 'success' : iconColor}
|
||||
color={iconColor}
|
||||
className={classNames(
|
||||
iconClassName,
|
||||
'group-hover/button:text-text',
|
||||
confirmed && '!text-success', // Don't use Icon.color here because it won't override the hover color
|
||||
props.disabled && 'opacity-70',
|
||||
)}
|
||||
/>
|
||||
|
||||
19
src-web/components/core/IconTooltip.tsx
Normal file
19
src-web/components/core/IconTooltip.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import type { TooltipProps } from './Tooltip';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
type Props = Omit<TooltipProps, 'children'> & {
|
||||
icon?: IconProps['icon'];
|
||||
iconSize?: IconProps['size'];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function IconTooltip({ content, icon = 'info', iconSize, ...tooltipProps }: Props) {
|
||||
return (
|
||||
<Tooltip content={content} {...tooltipProps}>
|
||||
<Icon className="opacity-60 hover:opacity-100" icon={icon} size={iconSize} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { Color } from '@yaakapp/api';
|
||||
import classNames from 'classnames';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { ReactNode } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import {
|
||||
analyzeTemplate,
|
||||
convertTemplateToInsecure,
|
||||
convertTemplateToSecure,
|
||||
} from '../../lib/encryption';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { withEncryptionEnabled } from '../../lib/setupOrConfigureEncryption';
|
||||
import { Button } from './Button';
|
||||
import type { DropdownItem } from './Dropdown';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import type { EditorProps } from './Editor/Editor';
|
||||
import { Editor } from './Editor/Editor';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Label } from './Label';
|
||||
import { HStack } from './Stacks';
|
||||
@@ -23,34 +44,46 @@ export type InputProps = Pick<
|
||||
| 'onKeyDown'
|
||||
| 'readOnly'
|
||||
> & {
|
||||
name?: string;
|
||||
type?: 'text' | 'password';
|
||||
label: ReactNode;
|
||||
hideLabel?: boolean;
|
||||
labelPosition?: 'top' | 'left';
|
||||
labelClassName?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
defaultValue?: string | null;
|
||||
disableObscureToggle?: boolean;
|
||||
fullHeight?: boolean;
|
||||
hideLabel?: boolean;
|
||||
inputWrapperClassName?: string;
|
||||
label: ReactNode;
|
||||
labelClassName?: string;
|
||||
labelPosition?: 'top' | 'left';
|
||||
leftSlot?: ReactNode;
|
||||
multiLine?: boolean;
|
||||
name?: string;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onPaste?: (value: string) => void;
|
||||
onPasteOverwrite?: EditorProps['onPasteOverwrite'];
|
||||
defaultValue?: string;
|
||||
leftSlot?: ReactNode;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
rightSlot?: ReactNode;
|
||||
size?: 'xs' | 'sm' | 'md' | 'auto';
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
validate?: boolean | ((v: string) => boolean);
|
||||
required?: boolean;
|
||||
wrapLines?: boolean;
|
||||
multiLine?: boolean;
|
||||
fullHeight?: boolean;
|
||||
stateKey: EditorProps['stateKey'];
|
||||
tint?: Color;
|
||||
type?: 'text' | 'password';
|
||||
validate?: boolean | ((v: string) => boolean);
|
||||
wrapLines?: boolean;
|
||||
};
|
||||
|
||||
export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
export const Input = forwardRef<EditorView, InputProps>(function Input({ type, ...props }, ref) {
|
||||
// If it's a password and template functions are supported (ie. secure(...)) then
|
||||
// use the encrypted input component.
|
||||
if (type === 'password' && props.autocompleteFunctions) {
|
||||
return <EncryptionInput {...props} />;
|
||||
} else {
|
||||
return <BaseInput ref={ref} type={type} {...props} />;
|
||||
}
|
||||
});
|
||||
|
||||
const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
||||
{
|
||||
className,
|
||||
containerClassName,
|
||||
@@ -74,6 +107,8 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
wrapLines,
|
||||
size = 'md',
|
||||
type = 'text',
|
||||
disableObscureToggle,
|
||||
tint,
|
||||
validate,
|
||||
readOnly,
|
||||
stateKey,
|
||||
@@ -83,11 +118,11 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
}: InputProps,
|
||||
ref,
|
||||
) {
|
||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [stateKey, forceUpdateKey]);
|
||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
@@ -116,15 +151,14 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (required && !validateRequire(currentValue)) return false;
|
||||
if (required && !validateRequire(defaultValue ?? '')) return false;
|
||||
if (typeof validate === 'boolean') return validate;
|
||||
if (typeof validate === 'function' && !validate(currentValue)) return false;
|
||||
if (typeof validate === 'function' && !validate(defaultValue ?? '')) return false;
|
||||
return true;
|
||||
}, [required, currentValue, validate]);
|
||||
}, [required, defaultValue, validate]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setCurrentValue(value);
|
||||
onChange?.(value);
|
||||
setHasChanged(true);
|
||||
},
|
||||
@@ -171,7 +205,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
containerClassName,
|
||||
fullHeight && 'h-full',
|
||||
'x-theme-input',
|
||||
'relative w-full rounded-md text',
|
||||
'relative w-full rounded-md text overflow-hidden',
|
||||
'border',
|
||||
focused && !disabled ? 'border-border-focus' : 'border-border',
|
||||
disabled && 'border-dotted',
|
||||
@@ -181,6 +215,21 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
size === 'xs' && 'min-h-xs',
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
'absolute inset-0 opacity-5 pointer-events-none',
|
||||
tint === 'primary' && 'bg-primary',
|
||||
tint === 'secondary' && 'bg-secondary',
|
||||
tint === 'info' && 'bg-info',
|
||||
tint === 'success' && 'bg-success',
|
||||
tint === 'notice' && 'bg-notice',
|
||||
tint === 'warning' && 'bg-warning',
|
||||
tint === 'danger' && 'bg-danger',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{leftSlot}
|
||||
<HStack
|
||||
className={classNames(
|
||||
@@ -219,15 +268,21 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
{...props}
|
||||
/>
|
||||
</HStack>
|
||||
{type === 'password' && (
|
||||
{type === 'password' && !disableObscureToggle && (
|
||||
<IconButton
|
||||
title={obscured ? `Show ${label}` : `Obscure ${label}`}
|
||||
size="xs"
|
||||
className={classNames(
|
||||
'mr-0.5 group/obscure !h-auto my-0.5',
|
||||
disabled && 'opacity-disabled',
|
||||
)}
|
||||
iconClassName="group-hover/obscure:text"
|
||||
className={classNames('mr-0.5 !h-auto my-0.5', disabled && 'opacity-disabled')}
|
||||
color={tint}
|
||||
// iconClassName={classNames(
|
||||
// tint === 'primary' && 'text-primary',
|
||||
// tint === 'secondary' && 'text-secondary',
|
||||
// tint === 'info' && 'text-info',
|
||||
// tint === 'success' && 'text-success',
|
||||
// tint === 'notice' && 'text-notice',
|
||||
// tint === 'warning' && 'text-warning',
|
||||
// tint === 'danger' && 'text-danger',
|
||||
// )}
|
||||
iconSize="sm"
|
||||
icon={obscured ? 'eye' : 'eye_closed'}
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
@@ -242,3 +297,167 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
function validateRequire(v: string) {
|
||||
return v.length > 0;
|
||||
}
|
||||
|
||||
type PasswordFieldType = 'text' | 'encrypted';
|
||||
|
||||
function EncryptionInput({
|
||||
defaultValue,
|
||||
onChange,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
forceUpdateKey: ogForceUpdateKey,
|
||||
...props
|
||||
}: Omit<InputProps, 'type'>) {
|
||||
const isEncryptionEnabled = useIsEncryptionEnabled();
|
||||
const [state, setState] = useStateWithDeps<{
|
||||
fieldType: PasswordFieldType;
|
||||
value: string | null;
|
||||
security: ReturnType<typeof analyzeTemplate> | null;
|
||||
obscured: boolean;
|
||||
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true }, [ogForceUpdateKey]);
|
||||
|
||||
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (state.value != null) {
|
||||
// We already configured it
|
||||
return;
|
||||
}
|
||||
|
||||
const security = analyzeTemplate(defaultValue ?? '');
|
||||
if (analyzeTemplate(defaultValue ?? '') === 'global_secured') {
|
||||
// Lazily update value to decrypted representation
|
||||
convertTemplateToInsecure(defaultValue ?? '').then((value) => {
|
||||
setState({ fieldType: 'encrypted', security, value, obscured: true });
|
||||
});
|
||||
} else if (isEncryptionEnabled && !defaultValue) {
|
||||
// Default to encrypted field for new encrypted inputs
|
||||
setState({ fieldType: 'encrypted', security, value: '', obscured: true });
|
||||
} else if (isEncryptionEnabled) {
|
||||
// Don't obscure plain text when encryption is enabled
|
||||
setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: false });
|
||||
} else {
|
||||
// Don't obscure plain text when encryption is disabled
|
||||
setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: true });
|
||||
}
|
||||
}, [defaultValue, isEncryptionEnabled, setState, state.value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string, fieldType: PasswordFieldType) => {
|
||||
if (fieldType === 'encrypted') {
|
||||
convertTemplateToSecure(value).then((value) => onChange?.(value));
|
||||
} else {
|
||||
onChange?.(value);
|
||||
}
|
||||
setState((s) => {
|
||||
// We can't analyze when encrypted because we don't have the raw value, so assume it's secured
|
||||
const security = fieldType === 'encrypted' ? 'global_secured' : analyzeTemplate(value);
|
||||
// Reset obscured value when the field type is being changed
|
||||
const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== 'text';
|
||||
return { fieldType, value, security, obscured };
|
||||
});
|
||||
},
|
||||
[onChange, setState],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(value: string) => {
|
||||
if (state.fieldType != null) {
|
||||
handleChange(value, state.fieldType);
|
||||
}
|
||||
},
|
||||
[handleChange, state],
|
||||
);
|
||||
|
||||
const handleFieldTypeChange = useCallback(
|
||||
(newFieldType: PasswordFieldType) => {
|
||||
const { value, fieldType } = state;
|
||||
if (value == null || fieldType === newFieldType) {
|
||||
return;
|
||||
}
|
||||
|
||||
withEncryptionEnabled(async () => {
|
||||
const newValue = await convertTemplateToInsecure(value);
|
||||
handleChange(newValue, newFieldType);
|
||||
});
|
||||
},
|
||||
[handleChange, state],
|
||||
);
|
||||
|
||||
const dropdownItems = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: state.obscured ? 'Reveal value' : 'Conceal value',
|
||||
disabled: isEncryptionEnabled && state.fieldType === 'text',
|
||||
leftSlot: <Icon icon={state.obscured ? 'eye' : 'eye_closed'} />,
|
||||
onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: state.fieldType === 'text' ? 'Encrypt Value' : 'Decrypt Value',
|
||||
leftSlot: <Icon icon={state.fieldType === 'text' ? 'lock' : 'lock_open'} />,
|
||||
onSelect: () => handleFieldTypeChange(state.fieldType === 'text' ? 'encrypted' : 'text'),
|
||||
},
|
||||
],
|
||||
[handleFieldTypeChange, isEncryptionEnabled, setState, state.fieldType, state.obscured],
|
||||
);
|
||||
|
||||
let tint: InputProps['tint'];
|
||||
if (!isEncryptionEnabled) {
|
||||
tint = undefined;
|
||||
} else if (state.fieldType === 'encrypted') {
|
||||
tint = 'info';
|
||||
} else if (state.security === 'local_secured') {
|
||||
tint = 'secondary';
|
||||
} else if (state.security === 'insecure') {
|
||||
tint = 'notice';
|
||||
}
|
||||
|
||||
const rightSlot = useMemo(() => {
|
||||
let icon: IconProps['icon'];
|
||||
if (isEncryptionEnabled) {
|
||||
icon = state.security === 'insecure' ? 'shield_off' : 'shield_check';
|
||||
} else {
|
||||
icon = state.obscured ? 'eye_closed' : 'eye';
|
||||
}
|
||||
return (
|
||||
<HStack className="h-auto m-0.5">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={tint}
|
||||
aria-label="Configure encryption"
|
||||
className={classNames(
|
||||
'flex items-center justify-center !h-full !px-1',
|
||||
'opacity-70', // Makes it a bit subtler
|
||||
props.disabled && '!opacity-disabled',
|
||||
)}
|
||||
>
|
||||
<HStack space={0.5}>
|
||||
<Icon size="sm" title="Configure encryption" icon={icon} />
|
||||
<Icon size="xs" title="Configure encryption" icon="chevron_down" />
|
||||
</HStack>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
);
|
||||
}, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);
|
||||
|
||||
const type = state.obscured ? 'password' : 'text';
|
||||
|
||||
return (
|
||||
<BaseInput
|
||||
disableObscureToggle
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
defaultValue={state.value ?? ''}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onChange={handleInputChange}
|
||||
tint={tint}
|
||||
type={type}
|
||||
rightSlot={rightSlot}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { IconTooltip } from './IconTooltip';
|
||||
|
||||
export function Label({
|
||||
htmlFor,
|
||||
@@ -8,21 +9,24 @@ export function Label({
|
||||
visuallyHidden,
|
||||
tags = [],
|
||||
required,
|
||||
help,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLLabelElement> & {
|
||||
htmlFor: string;
|
||||
htmlFor: string | null;
|
||||
required?: boolean;
|
||||
tags?: string[];
|
||||
visuallyHidden?: boolean;
|
||||
children: ReactNode;
|
||||
help?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
htmlFor={htmlFor ?? undefined}
|
||||
className={classNames(
|
||||
className,
|
||||
visuallyHidden && 'sr-only',
|
||||
'flex-shrink-0 text-sm',
|
||||
'text-text-subtle whitespace-nowrap flex items-center gap-1',
|
||||
'text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -35,6 +39,7 @@ export function Label({
|
||||
({tag})
|
||||
</span>
|
||||
))}
|
||||
{help && <IconTooltip content={help} />}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export type PairEditorProps = {
|
||||
valueAutocompleteFunctions?: boolean;
|
||||
valueAutocompleteVariables?: boolean;
|
||||
valuePlaceholder?: string;
|
||||
valueType?: 'text' | 'password';
|
||||
valueType?: InputProps['type'] | ((pair: Pair) => InputProps['type']);
|
||||
valueValidate?: InputProps['validate'];
|
||||
};
|
||||
|
||||
@@ -78,7 +78,6 @@ const MAX_INITIAL_PAIRS = 50;
|
||||
|
||||
export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function PairEditor(
|
||||
{
|
||||
stateKey,
|
||||
allowFileValues,
|
||||
allowMultilineValues,
|
||||
className,
|
||||
@@ -91,6 +90,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
|
||||
noScroll,
|
||||
onChange,
|
||||
pairs: originalPairs,
|
||||
stateKey,
|
||||
valueAutocomplete,
|
||||
valueAutocompleteFunctions,
|
||||
valueAutocompleteVariables,
|
||||
@@ -124,7 +124,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
|
||||
const p = originalPairs[i];
|
||||
if (!p) continue; // Make TS happy
|
||||
if (isPairEmpty(p)) continue;
|
||||
newPairs.push({ ...p, id: p.id ?? generateId() });
|
||||
newPairs.push(ensurePairId(p));
|
||||
}
|
||||
|
||||
// Add empty last pair if there is none
|
||||
@@ -555,7 +555,7 @@ function PairEditorRow({
|
||||
name={`value[${index}]`}
|
||||
onChange={handleChangeValueText}
|
||||
onFocus={handleFocus}
|
||||
type={isLast ? 'text' : valueType}
|
||||
type={isLast ? 'text' : typeof valueType === 'function' ? valueType(pair) : valueType}
|
||||
placeholder={valuePlaceholder ?? 'value'}
|
||||
autocomplete={valueAutocomplete?.(pair.name)}
|
||||
autocompleteFunctions={valueAutocompleteFunctions}
|
||||
@@ -615,7 +615,7 @@ function FileActionsDropdown({
|
||||
[onChangeFile, onChangeText],
|
||||
);
|
||||
|
||||
const extraItems = useMemo<DropdownItem[]>(
|
||||
const itemsAfter = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: 'Edit Multi-Line',
|
||||
@@ -664,7 +664,7 @@ function FileActionsDropdown({
|
||||
value={pair.isFile ? 'file' : 'text'}
|
||||
onChange={onChange}
|
||||
items={fileItems}
|
||||
extraItems={extraItems}
|
||||
itemsAfter={itemsAfter}
|
||||
>
|
||||
<IconButton iconSize="sm" size="xs" icon="chevron_down" title="Select form data type" />
|
||||
</RadioDropdown>
|
||||
@@ -672,12 +672,7 @@ function FileActionsDropdown({
|
||||
}
|
||||
|
||||
function emptyPair(): PairWithId {
|
||||
return {
|
||||
enabled: true,
|
||||
name: '',
|
||||
value: '',
|
||||
id: generateId(),
|
||||
};
|
||||
return ensurePairId({ enabled: true, name: '', value: '' });
|
||||
}
|
||||
|
||||
function isPairEmpty(pair: Pair): boolean {
|
||||
@@ -723,3 +718,12 @@ function MultilineEditDialog({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function ensurePairId(p: Pair): PairWithId {
|
||||
if (typeof p.id === 'string') {
|
||||
return p as PairWithId;
|
||||
} else {
|
||||
return { ...p, id: p.id ?? generateId() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type
|
||||
onFocusRaw?: HTMLAttributes<HTMLInputElement>['onFocus'];
|
||||
type?: 'text' | 'password' | 'number';
|
||||
step?: number;
|
||||
hideObscureToggle?: boolean;
|
||||
};
|
||||
|
||||
export function PlainInput({
|
||||
@@ -31,8 +32,10 @@ export function PlainInput({
|
||||
onPaste,
|
||||
required,
|
||||
rightSlot,
|
||||
hideObscureToggle,
|
||||
size = 'md',
|
||||
type = 'text',
|
||||
tint,
|
||||
validate,
|
||||
autoSelect,
|
||||
placeholder,
|
||||
@@ -115,6 +118,16 @@ export function PlainInput({
|
||||
size === 'xs' && 'min-h-xs',
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
'absolute inset-0 opacity-5 pointer-events-none',
|
||||
tint === 'info' && 'bg-info',
|
||||
tint === 'warning' && 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{leftSlot}
|
||||
<HStack
|
||||
className={classNames(
|
||||
@@ -128,7 +141,7 @@ export function PlainInput({
|
||||
key={forceUpdateKey}
|
||||
id={id}
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
defaultValue={defaultValue}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
@@ -143,7 +156,7 @@ export function PlainInput({
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
/>
|
||||
</HStack>
|
||||
{type === 'password' && (
|
||||
{type === 'password' && !hideObscureToggle && (
|
||||
<IconButton
|
||||
title={obscured ? `Show ${label}` : `Obscure ${label}`}
|
||||
size="xs"
|
||||
|
||||
@@ -17,20 +17,23 @@ export type RadioDropdownItem<T = string | null> =
|
||||
export interface RadioDropdownProps<T = string | null> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
itemsBefore?: DropdownItem[];
|
||||
items: RadioDropdownItem<T>[];
|
||||
extraItems?: DropdownItem[];
|
||||
itemsAfter?: DropdownItem[];
|
||||
children: DropdownProps['children'];
|
||||
}
|
||||
|
||||
export function RadioDropdown<T = string | null>({
|
||||
value,
|
||||
items,
|
||||
extraItems,
|
||||
itemsAfter,
|
||||
itemsBefore,
|
||||
onChange,
|
||||
children,
|
||||
}: RadioDropdownProps<T>) {
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
...((itemsBefore ? [...itemsBefore, { type: 'separator' }] : []) as DropdownItem[]),
|
||||
...items.map((item) => {
|
||||
if (item.type === 'separator') {
|
||||
return item;
|
||||
@@ -44,9 +47,9 @@ export function RadioDropdown<T = string | null>({
|
||||
} as DropdownItem;
|
||||
}
|
||||
}),
|
||||
...((extraItems ? [{ type: 'separator' }, ...extraItems] : []) as DropdownItem[]),
|
||||
...((itemsAfter ? [{ type: 'separator' }, ...itemsAfter] : []) as DropdownItem[]),
|
||||
],
|
||||
[items, extraItems, value, onChange],
|
||||
[itemsBefore, items, itemsAfter, value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,9 +10,15 @@ interface Props<T extends string> {
|
||||
onChange: (value: T) => void;
|
||||
value: T;
|
||||
name: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({ value, onChange, options }: Props<T>) {
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
}: Props<T>) {
|
||||
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
@@ -21,7 +27,12 @@ export function SegmentedControl<T extends string>({ value, onChange, options }:
|
||||
role="group"
|
||||
dir="ltr"
|
||||
space={0.5}
|
||||
className="bg-surface-highlight rounded-md mb-auto opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100 transition-opacity transform-gpu"
|
||||
className={classNames(
|
||||
className,
|
||||
'bg-surface-highlight rounded-md mb-auto opacity-0',
|
||||
'transition-opacity transform-gpu',
|
||||
'group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
const selectedIndex = options.findIndex((o) => o.value === selectedValue);
|
||||
if (e.key === 'ArrowRight') {
|
||||
|
||||
@@ -144,7 +144,7 @@ export function SplitLayout({
|
||||
const containerQueryReady = size.width > 0 || size.height > 0;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={styles} className={classNames(className, 'grid w-full h-full')}>
|
||||
<div ref={containerRef} style={styles} className={classNames(className, 'grid w-full h-full overflow-hidden')}>
|
||||
{containerQueryReady && (
|
||||
<>
|
||||
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
|
||||
|
||||
135
src-web/components/core/Tooltip.tsx
Normal file
135
src-web/components/core/Tooltip.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { Portal } from '../Portal';
|
||||
|
||||
export interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
size?: 'md' | 'lg';
|
||||
}
|
||||
|
||||
const hiddenStyles: CSSProperties = {
|
||||
left: -99999,
|
||||
top: -99999,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
export function Tooltip({ children, content, size = 'md' }: TooltipProps) {
|
||||
const [isOpen, setIsOpen] = useState<CSSProperties>();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const showTimeout = useRef<NodeJS.Timeout>();
|
||||
|
||||
const handleOpenImmediate = () => {
|
||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
||||
clearTimeout(showTimeout.current);
|
||||
setIsOpen(undefined);
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
const styles: CSSProperties = {
|
||||
bottom: docRect.height - triggerRect.top,
|
||||
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
|
||||
maxHeight: triggerRect.top,
|
||||
};
|
||||
setIsOpen(styles);
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
clearTimeout(showTimeout.current);
|
||||
showTimeout.current = setTimeout(handleOpenImmediate, 500);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
clearTimeout(showTimeout.current);
|
||||
setIsOpen(undefined);
|
||||
};
|
||||
|
||||
const handleToggleImmediate = () => {
|
||||
if (isOpen) handleClose();
|
||||
else handleOpenImmediate();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (isOpen && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const id = useRef(`tooltip-${generateId()}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Portal name="tooltip">
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={isOpen ?? hiddenStyles}
|
||||
id={id.current}
|
||||
role="tooltip"
|
||||
aria-hidden={!isOpen}
|
||||
onMouseEnter={handleOpenImmediate}
|
||||
onMouseLeave={handleClose}
|
||||
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto',
|
||||
size === 'md' && 'max-w-sm',
|
||||
size === 'lg' && 'max-w-md',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<Triangle className="text-border mb-2" />
|
||||
</div>
|
||||
</Portal>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-describedby={isOpen ? id.current : undefined}
|
||||
className="flex-grow-0 inline-flex items-center"
|
||||
onClick={handleToggleImmediate}
|
||||
onMouseEnter={handleOpen}
|
||||
onMouseLeave={handleClose}
|
||||
onFocus={handleOpenImmediate}
|
||||
onBlur={handleClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Triangle({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 30 10"
|
||||
preserveAspectRatio="none"
|
||||
shapeRendering="crispEdges"
|
||||
className={classNames(
|
||||
className,
|
||||
'absolute z-50 border-t-[2px] border-surface-highlight',
|
||||
'-bottom-[calc(0.5rem-3px)] left-[calc(50%-0.4rem)]',
|
||||
'h-[0.5rem] w-[0.8rem]',
|
||||
)}
|
||||
>
|
||||
<polygon className="fill-surface-highlight" points="0,0 30,0 15,10" />
|
||||
<path
|
||||
d="M0 0 L15 9 L30 0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinejoin="miter"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user