Launch analytics events, changelog, better filter styles

This commit is contained in:
Gregory Schier
2024-01-18 14:42:02 -08:00
parent b800f00b7e
commit d932c19513
22 changed files with 631 additions and 275 deletions

View File

@@ -7,6 +7,7 @@ import type {
MouseEvent,
ReactElement,
ReactNode,
SetStateAction,
} from 'react';
import React, {
Children,
@@ -39,7 +40,7 @@ export type DropdownItemDefault = {
label: ReactNode;
hotKeyAction?: HotkeyAction;
hotKeyLabelOnly?: boolean;
variant?: 'danger';
variant?: 'default' | 'danger' | 'notify';
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
@@ -53,6 +54,8 @@ export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
openOnHotKeyAction?: HotkeyAction;
onOpen?: () => void;
onClose?: () => void;
}
export interface DropdownRef {
@@ -66,14 +69,23 @@ export interface DropdownRef {
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, openOnHotKeyAction }: DropdownProps,
{ children, items, openOnHotKeyAction, onOpen, onClose }: DropdownProps,
ref,
) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isOpen, _setIsOpen] = useState<boolean>(false);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
const setIsOpen = useCallback(
(o: SetStateAction<boolean>) => {
_setIsOpen(o);
if (o) onOpen?.();
else onClose?.();
},
[onClose, onOpen],
);
useHotKey(openOnHotKeyAction ?? null, () => {
setIsOpen(true);
});
@@ -112,12 +124,12 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
}),
};
return cloneElement(existingChild, props);
}, [children]);
}, [children, setIsOpen]);
const handleClose = useCallback(() => {
setIsOpen(false);
buttonRef.current?.focus();
}, []);
}, [setIsOpen]);
useEffect(() => {
buttonRef.current?.setAttribute('aria-expanded', isOpen.toString());
@@ -307,11 +319,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const docRect = document.documentElement.getBoundingClientRect();
const width = triggerShape.right - triggerShape.left;
const heightAbove = triggerShape.top;
const heightBelow = docRect.height - triggerShape.bottom;
const hSpaceRemaining = docRect.width - triggerShape.left;
const vSpaceRemaining = docRect.height - triggerShape.bottom;
const top = triggerShape?.bottom + 5;
const onRight = hSpaceRemaining < 200;
const upsideDown = vSpaceRemaining < 200;
const upsideDown = heightAbove > heightBelow && heightBelow < 200;
const containerStyles = {
top: !upsideDown ? top : undefined,
bottom: upsideDown ? docRect.height - top : undefined,
@@ -462,6 +475,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-900 rounded',
item.variant === 'danger' && 'text-red-600',
item.variant === 'notify' && 'text-pink-600',
)}
innerClassName="!text-left"
{...props}

View File

@@ -5,7 +5,18 @@ import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/vie
import classNames from 'classnames';
import { EditorView } from 'codemirror';
import type { MutableRefObject, ReactNode } from 'react';
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import {
Children,
cloneElement,
forwardRef,
isValidElement,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace';
import { IconButton } from '../IconButton';
@@ -145,6 +156,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceUpdateKey]);
const classList = className?.split(/\s+/) ?? [];
const bgClassList = 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
// Initialize the editor when ref mounts
const initEditorRef = useCallback((container: HTMLDivElement | null) => {
if (container === null) {
@@ -184,7 +201,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, className });
syncGutterBg({ parent: container, bgClassList });
if (autoFocus) {
view.focus();
}
@@ -198,6 +215,50 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Add bg classes to actions, so they appear over the text
const decoratedActions = useMemo(() => {
const results = [];
const actionClassName = classNames(
'transition-opacity opacity-0 group-hover:opacity-50 hover:!opacity-100 shadow',
bgClassList,
);
if (format) {
results.push(
<IconButton
showConfirm
key="format"
size="sm"
title="Reformat contents"
icon="magicWand"
className={classNames(actionClassName)}
onClick={() => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const formatted = format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({
changes: { from: 0, to: doc.length, insert: formatted },
});
cm.current.view.contentDOM.blur();
// Fire change event
onChange?.(formatted);
}}
/>,
);
}
results.push(
Children.map(actions, (existingChild) => {
if (!isValidElement(existingChild)) return null;
return cloneElement(existingChild, {
...existingChild.props,
className: classNames(existingChild.props.className, actionClassName),
});
}),
);
return results;
}, [actions, bgClassList, format, onChange]);
const cmContainer = (
<div
ref={initEditorRef}
@@ -219,7 +280,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
return (
<div className="group relative h-full w-full">
{cmContainer}
{(format || actions) && (
{decoratedActions && (
<HStack
space={1}
alignItems="center"
@@ -229,28 +290,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
'pointer-events-none', // No pointer events so we don't block the editor
)}
>
{format && (
<IconButton
showConfirm
size="sm"
title="Reformat contents"
icon="magicWand"
className="transition-all opacity-0 group-hover:opacity-100"
onClick={() => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const formatted = format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({
changes: { from: 0, to: doc.length, insert: formatted },
});
cm.current.view.contentDOM.blur();
// Fire change event
onChange?.(formatted);
}}
/>
)}
{actions}
{decoratedActions}
</HStack>
)}
</div>
@@ -325,19 +365,14 @@ function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
const syncGutterBg = ({
parent,
className = '',
bgClassList,
}: {
parent: HTMLDivElement;
className?: string;
bgClassList: 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);
gutterEl?.classList.add(...bgClassList);
}
};

View File

@@ -6,6 +6,7 @@ import { memo } from 'react';
const icons = {
archive: lucide.ArchiveIcon,
box: lucide.BoxIcon,
cake: lucide.CakeIcon,
chat: lucide.MessageSquare,
check: lucide.CheckIcon,
chevronDown: lucide.ChevronDownIcon,
@@ -13,6 +14,8 @@ const icons = {
code: lucide.CodeIcon,
copy: lucide.CopyIcon,
download: lucide.DownloadIcon,
folderInput: lucide.FolderInputIcon,
folderOutput: lucide.FolderOutputIcon,
externalLink: lucide.ExternalLinkIcon,
eye: lucide.EyeIcon,
eyeClosed: lucide.EyeOffIcon,

View File

@@ -13,6 +13,7 @@ type Props = IconProps &
iconClassName?: string;
iconSize?: IconProps['size'];
title: string;
showBadge?: boolean;
};
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
@@ -26,6 +27,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
tabIndex,
size = 'md',
iconSize,
showBadge,
...props
}: Props,
ref,
@@ -49,7 +51,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
innerClassName="flex items-center justify-center"
className={classNames(
className,
'flex-shrink-0 text-gray-700 hover:text-gray-1000',
'relative flex-shrink-0 text-gray-700 hover:text-gray-1000',
'!px-0',
size === 'md' && 'w-9',
size === 'sm' && 'w-8',
@@ -58,6 +60,11 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
size={size}
{...props}
>
{showBadge && (
<div className="absolute top-0 right-0 w-1/2 h-1/2 flex items-center justify-center">
<div className="w-2.5 h-2.5 bg-pink-500 rounded-full" />
</div>
)}
<Icon
size={iconSize}
icon={confirmed ? 'check' : icon}

View File

@@ -0,0 +1,74 @@
import classNames from 'classnames';
interface Props<T extends string> {
name: string;
label: string;
labelPosition?: 'top' | 'left';
labelClassName?: string;
hideLabel?: boolean;
value: string;
options: Record<T, string>;
onChange: (value: T) => void;
size?: 'xs' | 'sm' | 'md' | 'lg';
}
export function Select<T extends string>({
labelPosition = 'top',
name,
labelClassName,
hideLabel,
label,
value,
options,
onChange,
size = 'md',
}: Props<T>) {
const id = `input-${name}`;
return (
<div
className={classNames(
'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent
labelPosition === 'left' && 'flex items-center gap-2',
labelPosition === 'top' && 'flex-row gap-0.5',
)}
>
<label
htmlFor={id}
className={classNames(
labelClassName,
'text-sm text-gray-900 whitespace-nowrap',
hideLabel && 'sr-only',
)}
>
{label}
</label>
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
className={classNames(
'font-mono text-xs border w-full px-2 outline-none bg-transparent',
'border-highlight focus:border-focus',
size === 'xs' && 'h-xs',
size === 'sm' && 'h-sm',
size === 'md' && 'h-md',
size === 'lg' && 'h-lg',
)}
>
{Object.entries<string>(options).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
);
}
const selectBackgroundStyles = {
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 0.5rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.5em 1.5em',
};