Move stuff around

This commit is contained in:
Gregory Schier
2023-03-13 23:30:14 -07:00
parent 7d4e9894c3
commit f81a3ae8e7
51 changed files with 52 additions and 202 deletions

View File

@@ -0,0 +1,93 @@
import classnames from 'classnames';
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react';
import { forwardRef } from 'react';
import { Link } from 'react-router-dom';
import { Icon } from './Icon';
const colorStyles = {
custom: '',
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
gray: 'text-gray-800 bg-gray-100 enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
primary: 'bg-blue-400 text-white hover:bg-blue-500',
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
warning: 'bg-orange-400 text-white hover:bg-orange-500',
danger: 'bg-red-400 text-white hover:bg-red-500',
};
export type ButtonProps = {
to?: string;
color?: keyof typeof colorStyles;
size?: 'sm' | 'md';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
onClick?: (event: MouseEvent<HTMLElement>) => void;
onDoubleClick?: (event: MouseEvent<HTMLElement>) => void;
contentEditable?: boolean;
onKeyDown?: (event: KeyboardEvent<HTMLElement>) => void;
forDropdown?: boolean;
className?: string;
children?: ReactNode;
disabled?: boolean;
title?: string;
tabIndex?: number;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Button = forwardRef<any, ButtonProps>(function Button(
{
to,
className,
children,
forDropdown,
color,
justify = 'center',
size = 'md',
...props
}: ButtonProps,
ref,
) {
if (typeof to === 'string') {
return (
<Link
ref={ref}
to={to}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</Link>
);
} else {
return (
<button
ref={ref}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</button>
);
}
});

View File

@@ -0,0 +1,13 @@
import classnames from 'classnames';
import type { ButtonProps } from './Button';
import { Button } from './Button';
type Props = ButtonProps & {
to: string;
};
export function ButtonLink({ to, className, ...buttonProps }: Props) {
return (
<Button to={to} className={classnames(className, 'w-full')} tabIndex={-1} {...buttonProps} />
);
}

View File

@@ -0,0 +1,58 @@
import * as D from '@radix-ui/react-dialog';
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
interface Props {
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
className?: string;
wide?: boolean;
}
export function Dialog({
children,
className,
wide,
open,
onOpenChange,
title,
description,
}: Props) {
return (
<D.Root open={open} onOpenChange={onOpenChange}>
<D.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<D.Overlay className="fixed inset-0 bg-gray-600/60 dark:bg-black/50" />
<D.Content>
<div
className={classnames(
className,
'absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-gray-100',
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
'dark:border border-gray-200 shadow-md shadow-black/10',
wide && 'w-[80vw] max-w-[50rem]',
)}
>
<D.Close asChild className="ml-auto absolute right-1 top-1">
<IconButton aria-label="Close" icon="x" size="sm" />
</D.Close>
<VStack space={3}>
<HStack alignItems="center" className="pb-3">
<D.Title className="text-xl font-semibold">{title}</D.Title>
</HStack>
{description && <D.Description>{description}</D.Description>}
<div>{children}</div>
</VStack>
</div>
</D.Content>
</motion.div>
</D.Portal>
</D.Root>
);
}

View File

@@ -0,0 +1,23 @@
import * as Separator from '@radix-ui/react-separator';
import classnames from 'classnames';
interface Props {
orientation?: 'horizontal' | 'vertical';
decorative?: boolean;
className?: string;
}
export function Divider({ className, orientation = 'horizontal', decorative }: Props) {
return (
<Separator.Root
className={classnames(
className,
'bg-gray-50',
orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]',
)}
orientation={orientation}
decorative={decorative}
/>
);
}

View File

@@ -0,0 +1,306 @@
import * as D from '@radix-ui/react-dropdown-menu';
import { CheckIcon } from '@radix-ui/react-icons';
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode, ForwardedRef } from 'react';
import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react';
interface DropdownMenuRadioProps {
children: ReactNode;
onValueChange: ((v: { label: string; value: string }) => void) | null;
value: string;
label?: string;
items: {
label: string;
value: string;
}[];
}
export function DropdownMenuRadio({
children,
items,
onValueChange,
label,
value,
}: DropdownMenuRadioProps) {
const handleChange = (value: string) => {
const item = items.find((item) => item.value === value);
if (item && onValueChange) {
onValueChange(item);
}
};
return (
<D.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
<D.DropdownMenuRadioGroup onValueChange={handleChange} value={value}>
{items.map((item) => (
<DropdownMenuRadioItem key={item.value} value={item.value}>
{item.label}
</DropdownMenuRadioItem>
))}
</D.DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</D.Root>
);
}
export interface DropdownProps {
children: ReactNode;
items: (
| {
label: string;
onSelect?: () => void;
disabled?: boolean;
leftSlot?: ReactNode;
}
| '-----'
)[];
}
export function Dropdown({ children, items }: DropdownProps) {
return (
<D.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{items.map((item, i) => {
if (item === '-----') {
return <DropdownMenuSeparator key={i} />;
} else {
return (
<DropdownMenuItem
key={i}
onSelect={() => item.onSelect?.()}
disabled={item.disabled}
leftSlot={item.leftSlot}
>
{item.label}
</DropdownMenuItem>
);
}
})}
</DropdownMenuContent>
</DropdownMenuPortal>
</D.Root>
);
}
interface DropdownMenuPortalProps {
children: ReactNode;
}
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
const container = document.querySelector<Element>('#radix-portal');
if (container === null) return null;
return (
<D.Portal>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{children}
</motion.div>
</D.Portal>
);
}
const DropdownMenuContent = forwardRef<HTMLDivElement, D.DropdownMenuContentProps>(
function DropdownMenuContent(
{ className, children, ...props }: D.DropdownMenuContentProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const [styles, setStyles] = useState<{ maxHeight: number }>();
const [divRef, setDivRef] = useState<HTMLDivElement | null>(null);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => divRef);
const initDivRef = (ref: HTMLDivElement | null) => {
setDivRef(ref);
};
// Calculate the max height so we can scroll
useLayoutEffect(() => {
if (divRef === null) return;
// Needs to be in a setTimeout because the ref is not positioned yet
// TODO: Make this better?
setTimeout(() => {
const windowBox = document.documentElement.getBoundingClientRect();
const menuBox = divRef.getBoundingClientRect();
const styles = { maxHeight: windowBox.height - menuBox.top - 5 - 45 };
setStyles(styles);
});
}, [divRef]);
return (
<D.Content
ref={initDivRef}
align="start"
className={classnames(
className,
'bg-gray-50 rounded-md shadow-lg p-1.5 border border-gray-200',
'overflow-auto m-1',
)}
style={styles}
{...props}
>
{children}
</D.Content>
);
},
);
type DropdownMenuItemProps = D.DropdownMenuItemProps & ItemInnerProps;
function DropdownMenuItem({
leftSlot,
rightSlot,
className,
children,
disabled,
...props
}: DropdownMenuItemProps) {
return (
<D.Item
asChild
disabled={disabled}
className={classnames(className, disabled && 'opacity-30')}
{...props}
>
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
{children}
</ItemInner>
</D.Item>
);
}
// type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
//
// function DropdownMenuCheckboxItem({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuCheckboxItemProps) {
// return (
// <DropdownMenu.CheckboxItem asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.CheckboxItem>
// );
// }
// type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
//
// function DropdownMenuSubTrigger({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuSubTriggerProps) {
// return (
// <DropdownMenu.SubTrigger asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.SubTrigger>
// );
// }
type DropdownMenuRadioItemProps = Omit<D.DropdownMenuRadioItemProps & ItemInnerProps, 'leftSlot'>;
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
return (
<D.RadioItem asChild {...props}>
<ItemInner
leftSlot={
<D.ItemIndicator>
<CheckIcon />
</D.ItemIndicator>
}
rightSlot={rightSlot}
>
{children}
</ItemInner>
</D.RadioItem>
);
}
// const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
// function DropdownMenuSubContent(
// { className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
// ref,
// ) {
// return (
// <DropdownMenu.SubContent
// ref={ref}
// alignOffset={0}
// sideOffset={4}
// className={classnames(className, dropdownMenuClasses)}
// {...props}
// />
// );
// },
// );
function DropdownMenuLabel({ className, children, ...props }: D.DropdownMenuLabelProps) {
return (
<D.Label asChild {...props}>
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
{children}
</ItemInner>
</D.Label>
);
}
function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorProps) {
return (
<D.Separator
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
{...props}
/>
);
}
type DropdownMenuTriggerProps = D.DropdownMenuTriggerProps & {
children: ReactNode;
className?: string;
};
function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) {
return (
<D.Trigger asChild className={classnames(className)} {...props}>
{children}
</D.Trigger>
);
}
interface ItemInnerProps {
leftSlot?: ReactNode;
rightSlot?: ReactNode;
children: ReactNode;
noHover?: boolean;
className?: string;
}
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
ref,
) {
return (
<div
ref={ref}
className={classnames(
className,
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
!noHover && 'focus:bg-gray-50 focus:text-gray-900 rounded',
)}
{...props}
>
{leftSlot && <div className="w-6">{leftSlot}</div>}
<div>{children}</div>
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
</div>
);
});

View File

@@ -0,0 +1,166 @@
.cm-wrapper {
@apply h-full overflow-hidden;
.cm-editor {
@apply w-full block text-base;
* {
@apply cursor-text;
}
&.cm-focused {
outline: none !important;
}
.cm-line {
@apply text-gray-900 pl-1 pr-1.5;
}
.cm-placeholder {
@apply text-placeholder;
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-gray-400;
}
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/60;
.cm-gutterElement {
@apply cursor-default;
}
}
.placeholder-widget {
@apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
/* Bring above on hover */
@apply hover:z-10 relative;
}
}
&.cm-singleline {
.cm-editor {
@apply h-full w-full;
}
.cm-scroller {
@apply font-mono flex text-[0.8rem];
align-items: center !important;
overflow: hidden !important;
}
.cm-line {
@apply px-0;
}
}
&.cm-multiline {
&.cm-full-height {
@apply relative;
.cm-editor {
@apply inset-0 absolute;
position: absolute !important;
}
}
.cm-editor {
@apply h-full;
}
.cm-scroller {
@apply font-mono text-[0.75rem];
}
}
}
.cm-editor .cm-gutterElement {
transition: color var(--transition-duration);
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.3em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
}
.cm-editor .fold-gutter-icon::after {
@apply block w-1.5 h-1.5 border-transparent -rotate-45
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
}
.cm-editor .fold-gutter-icon[data-open] {
@apply pt-[0.4em] pl-[0.3em];
}
.cm-editor .fold-gutter-icon[data-open]::after {
@apply rotate-[-135deg];
}
.cm-editor .fold-gutter-icon:hover {
@apply text-gray-900 bg-gray-300/50;
}
.cm-editor .cm-foldPlaceholder {
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
@apply hover:text-gray-800 hover:border-gray-400;
}
.cm-editor .cm-activeLineGutter {
@apply bg-transparent;
}
.cm-wrapper:not(.cm-readonly) .cm-editor {
&.cm-focused .cm-activeLineGutter {
@apply text-gray-800;
}
.cm-cursor {
@apply border-l-2 border-gray-800;
}
}
.cm-singleline .cm-editor {
.cm-content {
@apply h-full flex items-center;
}
}
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;
* {
@apply transition-none;
}
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[40vh];
}
& > ul > li {
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
}
& > ul > li[aria-selected] {
@apply bg-gray-100 text-gray-900;
}
& > ul > li:hover {
@apply text-gray-800;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
}
}

View File

@@ -0,0 +1,188 @@
import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
import { EditorView } from 'codemirror';
import { formatSdl } from 'format-graphql';
import { useEffect, useRef } from 'react';
import { useDebounce, useUnmount } from 'react-use';
import { debounce } from '../../../lib/debounce';
import { IconButton } from '../IconButton';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import { singleLineExt } from './singleLine';
export interface _EditorProps {
id?: string;
readOnly?: boolean;
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
autoFocus?: boolean;
defaultValue?: string;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
onChange?: (value: string) => void;
singleLine?: boolean;
}
export function _Editor({
readOnly,
heightMode,
contentType,
autoFocus,
placeholder,
useTemplating,
defaultValue,
onChange,
className,
singleLine,
}: _EditorProps) {
const cm = useRef<{ view: EditorView; langHolder: Compartment } | null>(null);
// Unmount the editor
useUnmount(() => {
cm.current?.view.destroy();
cm.current = null;
});
// Update language extension when contentType changes
useEffect(() => {
if (cm.current === null) return;
const { view, langHolder } = cm.current;
const ext = getLanguageExtension({ contentType, useTemplating });
view.dispatch({ effects: langHolder.reconfigure(ext) });
}, [contentType]);
// Initialize the editor
const initDivRef = (el: HTMLDivElement | null) => {
if (el === null || cm.current !== null) return;
try {
const langHolder = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating });
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
langHolder.of(langExt),
...getExtensions({
container: el,
readOnly,
placeholder,
singleLine,
onChange,
contentType,
useTemplating,
}),
],
});
const view = new EditorView({ state, parent: el });
cm.current = { view, langHolder };
syncGutterBg({ parent: el, className });
if (autoFocus) view.focus();
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
};
return (
<div
ref={initDivRef}
className={classnames(
className,
'cm-wrapper text-base bg-gray-50',
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
singleLine ? 'cm-singleline' : 'cm-multiline',
readOnly && 'cm-readonly',
)}
>
{contentType?.includes('graphql') && (
<IconButton
icon="eye"
className="absolute right-3 bottom-3 z-10"
onClick={() => {
const doc = cm.current?.view.state.doc ?? '';
const insert = formatSdl(doc.toString());
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
}}
/>
)}
</div>
);
}
function getExtensions({
container,
readOnly,
singleLine,
placeholder,
onChange,
contentType,
useTemplating,
}: Pick<
_EditorProps,
'singleLine' | 'onChange' | 'contentType' | 'useTemplating' | 'placeholder' | 'readOnly'
> & { container: HTMLDivElement | null }) {
const ext = getLanguageExtension({ contentType, useTemplating });
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>('#cm-portal') ??
undefined;
return [
...baseExtensions,
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(ext ? [ext] : []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
...(placeholder ? [placeholderExt(placeholder)] : []),
...(singleLine
? [
EditorView.domEventHandlers({
focus: (e, view) => {
// select all text on focus, like a regular input does
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
},
keydown: (e) => {
// Submit nearest form on enter if there is one
if (e.key === 'Enter') {
const el = e.currentTarget as HTMLElement;
const form = el.closest('form');
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
},
}),
]
: []),
// Handle onChange
EditorView.updateListener.of((update) => {
if (typeof onChange === 'function' && update.docChanged) {
onChange(update.state.doc.toString());
}
}),
];
}
const syncGutterBg = ({
parent,
className = '',
}: {
parent: HTMLDivElement;
className?: string;
}) => {
const gutterEl = parent.querySelector<HTMLDivElement>('.cm-gutters');
const classList = className?.split(/\s+/) ?? [];
const bgClasses = classList
.filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes
.map((c) => c.replace(/^bg-/, '!bg-')) // !important
.map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important
if (gutterEl) {
gutterEl?.classList.add(...bgClasses);
}
};

View File

@@ -0,0 +1,35 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import { EditorView } from 'codemirror';
import { debounce } from '../../../lib/debounce';
/*
* Debounce autocomplete until user stops typing for `millis` milliseconds.
*/
export function debouncedAutocompletionDisplay({ millis }: { millis: number }) {
// TODO: Figure out how to show completion without setting context.explicit = true
const debouncedStartCompletion = debounce(function (view: EditorView) {
startCompletion(view);
}, millis);
return EditorView.updateListener.of(({ view, docChanged }) => {
// const completions = currentCompletions(view.state);
// const status = completionStatus(view.state);
if (!view.hasFocus) {
debouncedStartCompletion.cancel();
closeCompletion(view);
return;
}
if (view.state.doc.length === 0) {
debouncedStartCompletion.cancel();
closeCompletion(view);
return;
}
// If the document hasn't changed, we don't need to do anything
if (docChanged) {
debouncedStartCompletion(view);
}
});
}

View File

@@ -0,0 +1,149 @@
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import {
bracketMatching,
foldGutter,
foldKeymap,
HighlightStyle,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { EditorState } from '@codemirror/state';
import {
crosshairCursor,
drawSelection,
dropCursor,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphqlLanguageSupport } from 'cm6-graphql';
import { debouncedAutocompletionDisplay } from './autocomplete';
import { twig } from './twig/extension';
import { url } from './url/extension';
export const myHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: '#757b93',
fontStyle: 'italic',
},
{
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: 'hsl(var(--color-blue-600))',
},
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
{ tag: [t.attributeName], color: 'hsl(var(--color-violet-600))' },
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
]);
// export const defaultHighlightStyle = HighlightStyle.define([
// { tag: t.meta, color: '#404740' },
// { tag: t.link, textDecoration: 'underline' },
// { tag: t.heading, textDecoration: 'underline', fontWeight: 'bold' },
// { tag: t.emphasis, fontStyle: 'italic' },
// { tag: t.strong, fontWeight: 'bold' },
// { tag: t.strikethrough, textDecoration: 'line-through' },
// { tag: t.keyword, color: '#708' },
// { tag: [t.atom, t.bool, t.url, t.contentSeparator, t.labelName], color: '#219' },
// { tag: [t.literal, t.inserted], color: '#164' },
// { tag: [t.string, t.deleted], color: '#a11' },
// { tag: [t.regexp, t.escape, t.special(t.string)], color: '#e40' },
// { tag: t.definition(t.variableName), color: '#00f' },
// { tag: t.local(t.variableName), color: '#30a' },
// { tag: [t.typeName, t.namespace], color: '#085' },
// { tag: t.className, color: '#167' },
// { tag: [t.special(t.variableName), t.macroName], color: '#256' },
// { tag: t.definition(t.propertyName), color: '#00c' },
// { tag: t.comment, color: '#940' },
// { tag: t.invalid, color: '#f00' },
// ]);
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql+json': graphqlLanguageSupport(),
'application/json': json(),
'application/javascript': javascript(),
'text/html': html(),
'application/xml': xml(),
'text/xml': xml(),
url: url(),
};
export function getLanguageExtension({
contentType,
useTemplating,
}: {
contentType?: string;
useTemplating?: boolean;
}) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? json();
if (!useTemplating) {
return [base];
}
return twig(base);
}
export const baseExtensions = [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({ closeOnBlur: true, interactionDelay: 300 }),
syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true),
];
export const multiLineExtensions = [
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
el.classList.add('fold-gutter-icon');
el.tabIndex = -1;
if (open) {
el.setAttribute('data-open', '');
}
return el;
},
}),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
closeBrackets(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLineGutter(),
highlightSelectionMatches({ minSelectionLength: 2 }),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap,
]),
];

View File

@@ -0,0 +1,6 @@
import { memo } from 'react';
import { _Editor } from './Editor';
import type { _EditorProps } from './Editor';
export type EditorProps = _EditorProps;
export const Editor = memo(_Editor);

View File

@@ -0,0 +1,29 @@
import type { Transaction, TransactionSpec } from '@codemirror/state';
import { EditorSelection, EditorState } from '@codemirror/state';
export function singleLineExt() {
return EditorState.transactionFilter.of(
(tr: Transaction): TransactionSpec | TransactionSpec[] => {
if (!tr.isUserEvent('input')) return tr;
const trs: TransactionSpec[] = [];
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
let insert = '';
let newlinesRemoved = 0;
for (const line of inserted) {
const newLine = line.replace('\n', '');
newlinesRemoved += line.length - newLine.length;
insert += newLine;
}
// Update cursor position based on how many newlines were removed
const cursor = EditorSelection.cursor(toB - newlinesRemoved);
const selection = EditorSelection.create([cursor], 0);
const changes = [{ from: fromB, to: toA, insert }];
trs.push({ ...tr, selection, changes });
});
return trs;
},
);
}

View File

@@ -0,0 +1,55 @@
import type { CompletionContext } from '@codemirror/autocomplete';
const openTag = '${[ ';
const closeTag = ' ]}';
const variables = [
{ name: 'DOMAIN' },
{ name: 'BASE_URL' },
{ name: 'TOKEN' },
{ name: 'PROJECT_ID' },
{ name: 'DUMMY' },
{ name: 'DUMMY_2' },
{ name: 'STRIPE_PUB_KEY' },
{ name: 'RAILWAY_TOKEN' },
{ name: 'SECRET' },
{ name: 'PORT' },
];
const MIN_MATCH_VAR = 2;
const MIN_MATCH_NAME = 4;
export function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
if (failedVarLen && !context.explicit) {
return null;
}
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
if (failedNameLen && !context.explicit) {
return null;
}
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
// open it, then it closes when you type the next character.
return {
from: toMatch.from,
options: variables
.map((v) => ({
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
apply: `${openTag}${v.name}${closeTag}`,
type: 'variable',
matchLen,
}))
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
};
}

View File

@@ -0,0 +1,41 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import { completions } from './completion';
import { placeholders } from '../widgets';
import { parser as twigParser } from './twig';
export function twig(base?: LanguageSupport) {
const language = mixedOrPlainLanguage(base);
const completion = language.data.of({
autocomplete: completions,
});
const languageSupport = new LanguageSupport(language, [completion]);
if (base) {
const completion2 = base.language.data.of({ autocomplete: completions });
const languageSupport2 = new LanguageSupport(base.language, [completion2]);
return [languageSupport, languageSupport2, placeholders, base.support];
} else {
return [languageSupport, placeholders];
}
}
function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage {
const name = 'twig';
if (base == null) {
return LRLanguage.define({ name, parser: twigParser });
}
const parser = twigParser.configure({
wrap: parseMixed((node) => {
if (!node.type.isTop) return null;
return {
parser: base.language.parser,
overlay: (node) => node.type.name === 'Text',
};
}),
});
return LRLanguage.define({ name, parser });
}

View File

@@ -0,0 +1,7 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
'if endif': t.controlKeyword,
'${[ ]}': t.meta,
DirectiveContent: t.variableName,
});

View File

@@ -0,0 +1,19 @@
@top Template { (directive | Text)* }
directive {
Insert
}
@skip {space} {
Insert { "${[" DirectiveContent "]}" }
}
@tokens {
Text { ![$] Text? }
space { @whitespace+ }
DirectiveContent { ![\]}] DirectiveContent? }
@precedence { space DirectiveContent }
"${[" "]}"
}
@external propSource highlight from "./highlight"

View File

@@ -0,0 +1,6 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Template = 1,
Insert = 2,
DirectiveContent = 4,
Text = 6

View File

@@ -0,0 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "zQVOPOOO_QQO'#C^OOOO'#Cc'#CcQVOPOOOdQQO,58xOOOO-E6a-E6aOOOO1G.d1G.d",
stateData: "l~OYOS~ORPOUQO~OSSO~OTUO~OYS~",
goto: "cWPPXPPPP]TQORQRORTR",
nodeNames: "⚠ Template Insert ${[ DirectiveContent ]} Text",
maxTerm: 10,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: ")gRRmOX!|X^$y^p!|pq$yqt!|tu&}u#P!|#P#Q(k#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R#TXUPSQOt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r;'S!|;'S;=`$s<%lO!|Q#uTSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pQ$XP;=`<%l#pP$aSUPOt$[u;'S$[;'S;=`$m<%lO$[P$pP;=`<%l$[R$vP;=`<%l!|R%SmUPYQSQOX!|X^$y^p!|pq$yqt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R'SVSQO#P#p#Q#o#p#o#p'i#p#q#p#r;'S#p;'S;=`$U<%lO#pR'nVSQO!}#p!}#O(T#O#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR([TRPSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR(pUUPOt$[u#q$[#q#r)S#r;'S$[;'S;=`$m<%lO$[R)ZSTQUPOt$[u;'S$[;'S;=`$m<%lO$[",
tokenizers: [0, 1],
topRules: {"Template":[0,1]},
tokenPrec: 25
})

View File

@@ -0,0 +1,19 @@
import type { CompletionContext } from '@codemirror/autocomplete';
const options = [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
];
const MIN_MATCH = 1;
export function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/^[\w:/]*/);
if (toMatch === null) return null;
const matchedMinimumLength = toMatch.to - toMatch.from >= MIN_MATCH;
if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
return { from: toMatch.from, options: optionsWithoutExactMatches };
}

View File

@@ -0,0 +1,14 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { completions } from './completion';
import { parser } from './url';
const urlLanguage = LRLanguage.define({
parser,
languageData: {},
});
const completion = urlLanguage.data.of({ autocomplete: completions });
export function url() {
return new LanguageSupport(urlLanguage, [completion]);
}

View File

@@ -0,0 +1,9 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
Protocol: t.comment,
// Port: t.attributeName,
// Host: t.variableName,
// Path: t.bool,
// Query: t.string,
});

View File

@@ -0,0 +1,18 @@
@top url { Protocol? Host Port? Path? Query? }
Query {
"?" queryPair ("&" queryPair)*
}
@tokens {
Protocol { $[a-zA-Z]+ "://" }
Path { ("/" $[a-zA-Z0-9\-_.]*)+ }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
Port { ":" $[0-9]+ }
Host { $[a-zA-Z0-9-_.]+ }
// Protocol/host overlaps, so give proto explicit precedence
@precedence { Protocol, Host }
}
@external propSource highlight from "./highlight"

View File

@@ -0,0 +1,8 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
url = 1,
Protocol = 2,
Host = 3,
Port = 4,
Path = 5,
Query = 6

View File

@@ -0,0 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "!jOQOPOOQYOPOOOTOPOOOeOQO'#CbQOOOOOQ`OPOOQ]OPOOOjOPO,58|OrOQO'#CcOwOPO1G.hOOOO,58},58}OOOO-E6a-E6a",
stateData: "!S~OQQORPO~OSUOTTOXRO~OYVO~OZWOWUa~OYYO~OZWOWUi~OQR~",
goto: "dWPPPPPPX^VSPTUQXVRZX",
nodeNames: "⚠ url Protocol Host Port Path Query",
maxTerm: 11,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "%[~RYvwq}!Ov!O!Pv!P!Q!_!Q![!y![!]#u!a!b$T!c!}$Y#R#Sv#T#o$Y~vOZ~P{URP}!Ov!O!Pv!Q![v!c!}v#R#Sv#T#ov~!dVT~}!O!_!O!P!_!P!Q!_!Q![!_!c!}!_#R#S!_#T#o!_R#QVYQRP}!Ov!O!Pv!Q![!y!_!`#g!c!}!y#R#Sv#T#o!yQ#lRYQ!Q![#g!c!}#g#T#o#g~#xP!Q![#{~$QPS~!Q![#{~$YOX~R$aWYQRP}!Ov!O!Pv!Q![!y![!]$y!_!`#g!c!}$Y#R#Sv#T#o$YP$|P!P!Q%PP%SP!P!Q%VP%[OQP",
tokenizers: [0, 1],
topRules: {"url":[0,1]},
tokenPrec: 47
})

View File

@@ -0,0 +1,76 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
class PlaceholderWidget extends WidgetType {
constructor(readonly name: string) {
super();
}
eq(other: PlaceholderWidget) {
return this.name == other.name;
}
toDOM() {
const elt = document.createElement('span');
elt.className = 'placeholder-widget';
elt.textContent = this.name;
return elt;
}
ignoreEvent() {
return false;
}
}
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}
const placeholderMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) return null;
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return null;
}
return Decoration.replace({
inclusive: true,
widget: new PlaceholderWidget(groupMatch),
});
},
});
export const placeholders = ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);

View File

@@ -0,0 +1,15 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
type Props = {
className?: string;
children?: ReactNode;
};
export function Heading({ className, children, ...props }: Props) {
return (
<h1 className={classnames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
{children}
</h1>
);
}

View File

@@ -0,0 +1,15 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
export function HotKey({ children }: HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={classnames(
'bg-gray-400 bg-opacity-20 px-1.5 py-0.5 rounded text-sm',
'font-mono text-gray-500 tracking-widest',
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,75 @@
import {
ArchiveIcon,
CameraIcon,
CheckIcon,
ClockIcon,
CodeIcon,
ColorWheelIcon,
Cross2Icon,
EyeOpenIcon,
GearIcon,
HomeIcon,
MoonIcon,
ListBulletIcon,
PaperPlaneIcon,
PlusCircledIcon,
PlusIcon,
QuestionMarkIcon,
SunIcon,
TrashIcon,
TriangleDownIcon,
TriangleLeftIcon,
TriangleRightIcon,
UpdateIcon,
RowsIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
const icons = {
archive: ArchiveIcon,
camera: CameraIcon,
check: CheckIcon,
clock: ClockIcon,
code: CodeIcon,
colorWheel: ColorWheelIcon,
eye: EyeOpenIcon,
gear: GearIcon,
home: HomeIcon,
listBullet: ListBulletIcon,
moon: MoonIcon,
paperPlane: PaperPlaneIcon,
plus: PlusIcon,
plusCircle: PlusCircledIcon,
question: QuestionMarkIcon,
rows: RowsIcon,
sun: SunIcon,
trash: TrashIcon,
triangleDown: TriangleDownIcon,
triangleLeft: TriangleLeftIcon,
triangleRight: TriangleRightIcon,
update: UpdateIcon,
x: Cross2Icon,
};
export interface IconProps {
icon: keyof typeof icons;
className?: string;
size?: 'xs' | 'sm' | 'md';
spin?: boolean;
}
export function Icon({ icon, spin, size = 'md', className }: IconProps) {
const Component = icons[icon] ?? icons.question;
return (
<Component
className={classnames(
className,
'text-inherit',
size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3 w-3',
size === 'xs' && 'h-2 w-2',
spin && 'animate-spin',
)}
/>
);
}

View File

@@ -0,0 +1,35 @@
import classnames from 'classnames';
import { forwardRef } from 'react';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
type Props = IconProps & ButtonProps & { iconClassName?: string; iconSize?: IconProps['size'] };
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon, spin, className, iconClassName, size = 'md', iconSize, ...props }: Props,
ref,
) {
return (
<Button
ref={ref}
className={classnames(
className,
'text-gray-700 hover:text-gray-1000',
'!px-0',
size === 'md' && 'w-9',
size === 'sm' && 'w-9',
)}
size={size}
{...props}
>
<Icon
size={iconSize}
icon={icon}
spin={spin}
className={classnames(iconClassName, props.disabled && 'opacity-70')}
/>
</Button>
);
});

View File

@@ -0,0 +1,96 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
import type { EditorProps } from './Editor';
import { Editor } from './Editor';
import { HStack, VStack } from './Stacks';
interface Props {
name: string;
label: string;
hideLabel?: boolean;
labelClassName?: string;
containerClassName?: string;
onChange?: (value: string) => void;
useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating'>;
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
size?: 'sm' | 'md';
className?: string;
placeholder?: string;
autoFocus?: boolean;
}
export function Input({
label,
hideLabel,
className,
containerClassName,
labelClassName,
onChange,
placeholder,
size = 'md',
useEditor,
name,
leftSlot,
rightSlot,
defaultValue,
...props
}: Props) {
const id = `input-${name}`;
const inputClassName = classnames(
className,
'!bg-transparent pl-3 pr-2 min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
!!leftSlot && '!pl-0.5',
!!rightSlot && '!pr-0.5',
);
return (
<VStack>
<label
htmlFor={id}
className={classnames(
labelClassName,
'font-semibold text-sm uppercase text-gray-700',
hideLabel && 'sr-only',
)}
>
{label}
</label>
<HStack
alignItems="center"
className={classnames(
containerClassName,
'relative w-full rounded-md text-gray-900',
'border border-gray-200 focus-within:border-blue-400/40',
size === 'md' && 'h-9',
size === 'sm' && 'h-7',
)}
>
{leftSlot}
{useEditor ? (
<Editor
id={id}
singleLine
defaultValue={defaultValue}
placeholder={placeholder}
onChange={onChange}
className={inputClassName}
{...props}
{...useEditor}
/>
) : (
<input
id={id}
onChange={(e) => onChange?.(e.currentTarget.value)}
placeholder={placeholder}
defaultValue={defaultValue}
className={inputClassName}
{...props}
/>
)}
{rightSlot}
</HStack>
</VStack>
);
}

View File

@@ -0,0 +1,34 @@
import * as S from '@radix-ui/react-scroll-area';
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function ScrollArea({ children, className }: Props) {
return (
<S.Root className={classnames(className, 'group/scroll')} type="always">
<S.Viewport>{children}</S.Viewport>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
<S.Corner />
</S.Root>
);
}
function ScrollBar({ orientation }: { orientation: 'vertical' | 'horizontal' }) {
return (
<S.Scrollbar
orientation={orientation}
className={classnames(
'flex bg-transparent rounded-full',
orientation === 'vertical' && 'w-1.5',
orientation === 'horizontal' && 'h-1.5 flex-col',
)}
>
<S.Thumb className="flex-1 bg-gray-100 group-hover/scroll:bg-gray-200 rounded-full" />
</S.Scrollbar>
);
}

View File

@@ -0,0 +1,106 @@
import classnames from 'classnames';
import type { ComponentType, ReactNode } from 'react';
import { Children, Fragment } from 'react';
const spaceClassesX = {
0: 'pr-0',
1: 'pr-1',
2: 'pr-2',
3: 'pr-3',
4: 'pr-4',
5: 'pr-5',
6: 'pr-6',
};
const spaceClassesY = {
0: 'pt-0',
1: 'pt-1',
2: 'pt-2',
3: 'pt-3',
4: 'pt-4',
5: 'pt-5',
6: 'pt-6',
};
interface HStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesX;
children?: ReactNode;
}
export function HStack({ className, space, children, ...props }: HStackProps) {
return (
<BaseStack className={classnames(className, 'flex-row')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesX[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
</BaseStack>
);
}
export interface VStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesY;
children: ReactNode;
}
export function VStack({ className, space, children, ...props }: VStackProps) {
return (
<BaseStack className={classnames(className, 'w-full h-full flex-col')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesY[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
</BaseStack>
);
}
interface BaseStackProps {
as?: ComponentType | 'ul';
alignItems?: 'start' | 'center';
justifyContent?: 'start' | 'center' | 'end';
className?: string;
children?: ReactNode;
}
function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) {
const Component = as ?? 'div';
return (
<Component
className={classnames(
className,
'flex',
alignItems === 'center' && 'items-center',
alignItems === 'start' && 'items-start',
justifyContent === 'start' && 'justify-start',
justifyContent === 'center' && 'justify-center',
justifyContent === 'end' && 'justify-end',
)}
>
{children}
</Component>
);
}

View File

@@ -0,0 +1,23 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
statusCode: number;
children: ReactNode;
}
export function StatusColor({ statusCode, children }: Props) {
return (
<span
className={classnames(
statusCode >= 100 && statusCode < 200 && 'text-green-600',
statusCode >= 200 && statusCode < 300 && 'text-green-600',
statusCode >= 300 && statusCode < 400 && 'text-pink-600',
statusCode >= 400 && statusCode < 500 && 'text-orange-600',
statusCode >= 500 && statusCode < 600 && 'text-red-600',
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,5 @@
.tab-content {
&[data-state="inactive"] {
@apply hidden;
}
}

View File

@@ -0,0 +1,88 @@
import * as T from '@radix-ui/react-tabs';
import classnames from 'classnames';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { Button } from '../Button';
import { ScrollArea } from '../ScrollArea';
import { HStack } from '../Stacks';
import './Tabs.css';
interface Props {
defaultValue?: string;
label: string;
tabs: { value: string; label: ReactNode }[];
tabListClassName?: string;
className?: string;
children: ReactNode;
}
export function Tabs({ defaultValue, label, children, tabs, className, tabListClassName }: Props) {
const [value, setValue] = useState(defaultValue);
return (
<T.Root
defaultValue={defaultValue}
onValueChange={setValue}
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
<T.List
aria-label={label}
className={classnames(
tabListClassName,
'h-auto flex items-center overflow-x-auto mb-1 pb-1',
)}
>
{/*<ScrollArea className="w-full pb-2">*/}
<HStack space={1}>
{tabs.map((t) => (
<TabTrigger key={t.value} value={t.value} active={t.value === value}>
{t.label}
</TabTrigger>
))}
</HStack>
{/*</ScrollArea>*/}
</T.List>
{children}
</T.Root>
);
}
interface TabTriggerProps {
value: string;
children: ReactNode;
active?: boolean;
}
export function TabTrigger({ value, children, active }: TabTriggerProps) {
return (
<T.Trigger value={value} asChild>
<Button
color="custom"
size="sm"
className={classnames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900',
)}
>
{children}
</Button>
</T.Trigger>
);
}
interface TabContentProps {
value: string;
children: ReactNode;
className?: string;
}
export function TabContent({ value, children, className }: TabContentProps) {
return (
<T.Content
forceMount
value={value}
className={classnames(className, 'tab-content', 'w-full h-full overflow-auto')}
>
{children}
</T.Content>
);
}

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react';
interface Props {
body: string;
contentType: string;
url: string;
}
export function Webview({ body, url, contentType }: Props) {
const contentForIframe: string | undefined = useMemo(() => {
if (!contentType.includes('html')) return;
if (body.includes('<head>')) {
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
}
return body;
}, [body, contentType]);
return (
<div className="px-2 pb-2">
<iframe
title="Response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-md border border-gray-100/20"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
className?: string;
children?: ReactNode;
}
export function WindowDragRegion({ className, ...props }: Props) {
return (
<div
data-tauri-drag-region
className={classnames(className, 'w-full h-12 flex-shrink-0')}
{...props}
/>
);
}