Add great meta data

This commit is contained in:
Nate Kelley 2025-09-11 23:43:51 -06:00
parent 4819a4aa4d
commit 5ef9acd931
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
10 changed files with 421 additions and 138 deletions

View File

@ -1,25 +1,44 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { BusterInput } from './BusterInput';
import type { BusterInputProps } from './BusterInput.types';
const meta: Meta<typeof BusterInput> = {
title: 'UI/Inputs/BusterInput',
component: BusterInput,
tags: ['autodocs'],
args: {},
decorators: [
(Story) => (
<div className="p-4">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof BusterInput>;
const items: BusterInputProps['items'] = [
...Array.from({ length: 3 }, (_, i) => ({
label: `Item ${i + 1}`,
value: `item${i + 1}`,
})),
{
label: 'Item (disabled)',
value: 'item-disabled',
disabled: true,
},
{
label: 'Item (loading)',
value: 'item-loading',
loading: true,
},
{
label: 'Item do not close on select',
value: 'asdf',
closeOnSelect: false,
},
];
const mentions = [];
export const Default: Story = {
args: {
value: 'Sample text value',
items,
mentions: [],
},
};

View File

@ -1,127 +1,86 @@
import { Command } from 'cmdk';
import type React from 'react';
import { useState } from 'react';
import { Mention, MentionsInput } from 'react-mentions';
import { cn } from '@/lib/utils';
import type { BusterInputProps } from './BusterInput.types';
import { useCallback, useState } from 'react';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import type { BusterInputProps, BusterOnSelectParams } from './BusterInput.types';
import { BusterInputEmpty } from './BusterInputEmpty';
import { BusterInputSeparator } from './BusterInputSeparator';
import { DEFAULT_MENTION_MARKUP } from './parse-input';
import { BusterInputList } from './BusterInputList';
import { BusterItemsSelector } from './BusterItemSelector';
import { BusterMentionsInput } from './BusterMentionsInput';
const users = [
{ id: '1', display: 'BigNate' },
{ id: '2', display: 'ReactFan42' },
{ id: '3', display: 'NextJSDev' },
];
export const BusterInput = ({
placeholder,
mentions,
defaultValue,
value: valueProp,
export const BusterInput = ({ defaultValue, value: valueProp, onChange }: BusterInputProps) => {
emptyComponent,
items,
closeOnSelect = true,
addValueToInput = true,
submitting,
onSubmit,
onStop,
sendIcon,
secondaryActions,
variant = 'default',
onChange,
onItemClick,
ariaLabel = 'Buster Input',
}: BusterInputProps) => {
const [hasClickedSelect, setHasClickedSelect] = useState(false);
const [value, setValue] = useState(valueProp ?? defaultValue);
const showList = !hasClickedSelect && items.length > 0;
const onChangeInputValue = useCallback((value: string) => {
setValue(value);
setHasClickedSelect(false);
// onChange?.(value);
}, []);
const onSelectItem = useMemoizedFn(({ onClick, ...params }: BusterOnSelectParams) => {
const { addValueToInput, value, loading, inputValue, label, disabled } = params;
if (disabled) {
console.warn('Item is disabled', params);
return;
}
if (submitting) {
console.warn('Input is submitting');
return;
}
if (loading) {
console.warn('Item is loading', params);
return;
}
if (addValueToInput) setValue(inputValue ?? String(label));
onClick?.();
if (closeOnSelect) setHasClickedSelect(true);
onItemClick?.(params);
});
return (
<div className="flex flex-col gap-2">
<MentionsInput
value={value}
onChange={(e) => {
setValue(e.target.value);
console.log(e.target.value);
}}
placeholder="Type @ to mention…"
style={{
control: { fontSize: 16, minHeight: 46 },
highlighter: { padding: 8 },
input: { padding: 8 },
}}
classNames={{
highlighter: 'bg-red-500/10',
suggestions: 'bg-blue-500/20 border',
item: 'text-red-500',
}}
>
{/* Always render a valid Mention node */}
<Mention
trigger="@"
markup={DEFAULT_MENTION_MARKUP}
data={users}
displayTransform={(_, display) => `@${display}`}
appendSpaceOnAdd
renderSuggestion={(d) => {
return d.display;
}}
/>
</MentionsInput>
<div className="w-full h-px bg-border" />
<Command
label="Command Menu"
onValueChange={(e) => {
console.log(e);
}}
>
<Command.Input
<Command label={ariaLabel}>
<BusterMentionsInput
className="w-full outline-1 outline-amber-600"
value={value}
onValueChange={setValue}
asChild
autoFocus
defaultValue={defaultValue}
readOnly
placeholder="Type @ to mention…"
>
<textarea />
</Command.Input>
<Command.List>
<BusterInputEmpty>No results found.</BusterInputEmpty>
<CommandGroup heading="Letters">
<CommandItem
onSelect={() => {
setValue('a');
}}
>
a
</CommandItem>
<CommandItem>b</CommandItem>
<BusterInputSeparator />
<CommandItem>c</CommandItem>
</CommandGroup>
<CommandItem>Apple</CommandItem>
</Command.List>
placeholder={placeholder}
mentions={mentions}
value={value}
onChangeInputValue={onChangeInputValue}
/>
<BusterInputList show={showList}>
<BusterItemsSelector
items={items}
onSelect={onSelectItem}
addValueToInput={addValueToInput}
closeOnSelect={closeOnSelect}
/>
{emptyComponent && <BusterInputEmpty>{emptyComponent}</BusterInputEmpty>}
</BusterInputList>
</Command>
</div>
);
};
const CommandGroup = ({
children,
...props
}: React.ComponentPropsWithoutRef<typeof Command.Group>) => {
return (
<Command.Group
className={cn(
'text-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
props.className
)}
{...props}
>
{children}
</Command.Group>
);
};
const CommandItem = ({
children,
...props
}: React.ComponentPropsWithoutRef<typeof Command.Item>) => {
return (
<Command.Item
className={cn(
'data-[selected=true]:bg-item-hover data-[selected=true]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-base outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
props.className
)}
{...props}
>
{children}
</Command.Item>
);
};

View File

@ -1,8 +1,18 @@
import type { Command } from 'cmdk';
import type React from 'react';
import type { DisplayTransformFunc } from 'react-mentions';
/**
* @description Override the addValueToInput and closeOnSelect props for the item based on the group props
*/
export type GroupOverrideProps = {
addValueToInput: boolean | undefined;
closeOnSelect: boolean | undefined;
};
export type BusterInputDropdownItem<T = string> = {
value: T;
inputValue?: string; //if this is undefined, the label will be used (string casted), must have addValueToInput set to true
label: string | React.ReactNode;
shortcut?: string;
icon?: React.ReactNode;
@ -10,22 +20,48 @@ export type BusterInputDropdownItem<T = string> = {
disabled?: boolean;
loading?: boolean;
closeOnSelect?: boolean; //defaults to parent
type?: 'item';
addValueToInput?: boolean; //defaults to group addValueToInput
};
export type BusterOnSelectParams = NonNullable<
Pick<
NonNullable<BusterInputDropdownItem>,
| 'value'
| 'label'
| 'addValueToInput'
| 'onClick'
| 'inputValue'
| 'closeOnSelect'
| 'disabled'
| 'loading'
>
>;
export type BusterOnMentionClickParams = Pick<BusterMentionItem, 'value' | 'label'>;
export type BusterInputDropdownGroup<T = string> = {
label: string | React.ReactNode;
items: BusterInputDropdownItem<T>[];
addValueToInput?: boolean;
closeOnSelect?: boolean;
type: 'group';
};
export type BusterMentionItem<V = string> = {
export type BusterInputSeperator = {
type: 'separator';
};
export type BusterMentionItem<V = string, M = unknown> = {
value: V;
parsedValue?: string; //if this is undefined, the value will be used
label: string | React.ReactNode;
selected?: boolean;
meta?: M;
};
export type BusterMentionItems<V = string, T = string> = {
items: BusterMentionItem<V>[];
export type BusterMentionItems<T = string, V = string, M = unknown> = {
items: BusterMentionItem<V, M>[];
displayTransform?: DisplayTransformFunc;
style?: React.CSSProperties;
appendSpaceOnAdd?: boolean; //defaults to true
@ -33,22 +69,34 @@ export type BusterMentionItems<V = string, T = string> = {
popoverContent?: (v: BusterMentionItem<V>) => React.ReactNode;
};
export type BusterMentionRecords<V = string, T extends string = string> = {
[K in T]: BusterMentionItems<V, T>;
};
export type BusterMentions<V = string, T extends string = string, M = unknown> = BusterMentionItems<
V,
T,
M
>[];
export type BusterInputProps<T = string> = {
export type BusterInputProps<
T = string,
TMention = string,
VMention extends string = string,
MMention = unknown,
> = {
defaultValue: string;
value?: string;
onChange?: (value: string) => void;
submitting?: boolean;
onSubmit: (value: string) => void;
onStop: () => void;
onItemClick?: (value: string) => void;
items: (BusterInputDropdownItem<T> | BusterInputDropdownGroup<T>)[];
mentions?: BusterMentionRecords<T>;
onItemClick?: (params: Omit<BusterOnSelectParams, 'onClick'>) => void;
onMentionClick?: (params: BusterMentionItem<T>) => void;
items: (BusterInputDropdownItem<T> | BusterInputDropdownGroup<T> | BusterInputSeperator)[];
mentions?: BusterMentions<TMention, VMention, MMention>;
variant?: 'default';
sendIcon?: React.ReactNode;
secondaryActions?: React.ReactNode;
addValueToInput?: boolean; //defaults to true
closeOnSelect?: boolean; //defaults to true
};
placeholder?: string;
ariaLabel?: string;
emptyComponent?: React.ReactNode | string | false; //if false, no empty component will be shown
} & Pick<React.ComponentProps<typeof Command>, 'filter' | 'shouldFilter'>;

View File

@ -0,0 +1,40 @@
import { Command } from 'cmdk';
import type React from 'react';
import { cn } from '@/lib/utils';
import type { BusterInputDropdownGroup, BusterOnSelectParams } from './BusterInput.types';
import { BusterItemsSelector } from './BusterItemSelector';
export type BusterInputGroupProps = BusterInputDropdownGroup & {
onSelect: (params: BusterOnSelectParams) => void;
} & {
className?: string;
style?: React.CSSProperties;
};
export const BusterInputGroup = ({
items,
label,
onSelect,
addValueToInput,
className,
closeOnSelect,
style,
}: BusterInputGroupProps) => {
return (
<Command.Group
className={cn(
'text-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
style={style}
heading={label}
>
<BusterItemsSelector
items={items}
onSelect={onSelect}
addValueToInput={addValueToInput}
closeOnSelect={closeOnSelect}
/>
</Command.Group>
);
};

View File

@ -0,0 +1,51 @@
import { Command } from 'cmdk';
import type React from 'react';
import { cn } from '@/lib/utils';
import type { BusterInputDropdownItem, BusterOnSelectParams } from './BusterInput.types';
export type BusterInputItemProps = {
onSelect: (d: BusterOnSelectParams) => void;
} & BusterInputDropdownItem & {
className?: string;
style?: React.CSSProperties;
};
export const BusterInputItem = ({
value,
inputValue,
label,
shortcut,
icon,
onClick,
disabled,
loading,
closeOnSelect,
type,
addValueToInput,
onSelect,
...props
}: BusterInputItemProps) => {
return (
<Command.Item
className={cn(
'data-[selected=true]:bg-item-hover data-[selected=true]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-base outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
props.className
)}
{...props}
onSelect={() => {
onSelect({
value,
inputValue,
label,
onClick,
addValueToInput,
closeOnSelect,
disabled,
loading,
});
}}
>
{label}
</Command.Item>
);
};

View File

@ -0,0 +1,23 @@
import { Command } from 'cmdk';
import type React from 'react';
interface BusterInputListProps {
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
show?: boolean;
}
export const BusterInputList = ({
className,
style,
children,
show = true,
}: BusterInputListProps) => {
if (!show) return null;
return (
<Command.List className={className} style={style}>
{children}
</Command.List>
);
};

View File

@ -0,0 +1,62 @@
import type {
BusterInputProps,
BusterOnSelectParams,
GroupOverrideProps,
} from './BusterInput.types';
import { BusterInputGroup } from './BusterInputGroup';
import { BusterInputItem } from './BusterInputItem';
import { BusterInputSeparator } from './BusterInputSeparator';
export const BusterItemSelector = ({
item,
onSelect,
addValueToInput,
closeOnSelect,
}: {
item: BusterInputProps['items'][number];
onSelect: (params: BusterOnSelectParams) => void;
} & GroupOverrideProps) => {
if (item.type === 'separator') {
return <BusterInputSeparator />;
}
if (item.type === 'group') {
return <BusterInputGroup {...item} onSelect={onSelect} />;
}
return (
<BusterInputItem
{...item}
onSelect={onSelect}
addValueToInput={item?.addValueToInput ?? addValueToInput}
closeOnSelect={item?.closeOnSelect ?? closeOnSelect}
/>
);
};
export const BusterItemsSelector = ({
items,
onSelect,
addValueToInput,
closeOnSelect,
}: {
items: BusterInputProps['items'];
onSelect: (params: BusterOnSelectParams) => void;
} & GroupOverrideProps) => {
if (!items) return null;
return items.map((item, index) => (
<BusterItemSelector
key={keySelector(item, index)}
item={item}
onSelect={onSelect}
addValueToInput={addValueToInput}
closeOnSelect={closeOnSelect}
/>
));
};
const keySelector = (item: BusterInputProps['items'][number], index: number) => {
if (item.type === 'separator') return `separator${index}`;
if (item.type === 'group') return `${item.label} + index`;
return item.value;
};

View File

@ -0,0 +1,81 @@
/** biome-ignore-all lint/complexity/noUselessFragments: Intersting bug when NOT using fragments */
import { Command } from 'cmdk';
import React, { useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Mention, type MentionProps, MentionsInput } from 'react-mentions';
import type { BusterInputProps } from './BusterInput.types';
import { DEFAULT_MENTION_MARKUP } from './parse-input';
export type BusterMentionsInputProps = Pick<
BusterInputProps,
'mentions' | 'value' | 'placeholder' | 'defaultValue'
> & {
onChangeInputValue: (value: string) => void;
} & React.ComponentPropsWithoutRef<typeof Command.Input>;
export const BusterMentionsInput = ({
children,
value: valueProp,
placeholder,
defaultValue,
mentions,
value,
onChangeInputValue,
...props
}: BusterMentionsInputProps) => {
return (
<React.Fragment>
<MentionsInput
value={value}
onChange={(e) => onChangeInputValue(e.target.value)}
placeholder={placeholder}
style={{
control: { fontSize: 16, minHeight: 46 },
highlighter: { padding: 8 },
input: { padding: 8 },
}}
className="swag"
classNames={{
highlighter: 'bg-red-500/10',
suggestions: 'bg-blue-500/20 border',
item: 'text-red-500',
}}
>
{mentions?.length ? (
mentions.map((mention) => <FormattedMention key={mention.trigger} {...mention} />)
) : (
<Mention trigger="" markup={DEFAULT_MENTION_MARKUP} data={[]} appendSpaceOnAdd />
)}
</MentionsInput>
<Command.Input value={value} {...props}>
{children}
</Command.Input>
</React.Fragment>
);
};
const FormattedMention = React.memo(
(
mention: NonNullable<BusterInputProps['mentions']>[number]
): React.ReactElement<MentionProps> => {
const formattedItems = mention.items.map((item) => ({
id: String(item.value),
display: typeof item.label === 'string' ? item.label : String(item.value),
}));
return (
<Mention
key={mention.trigger}
trigger={mention.trigger}
markup={DEFAULT_MENTION_MARKUP}
data={formattedItems}
displayTransform={mention.displayTransform}
appendSpaceOnAdd={mention.appendSpaceOnAdd ?? true}
renderSuggestion={(d) => d.display}
/>
);
}
);
FormattedMention.displayName = 'FormattedMention';

View File

@ -21,12 +21,12 @@ describe('parseMarkupInput', () => {
},
];
const mockItemsRecord = {
'@': {
const mockItemsRecord = [
{
items: mockItems,
trigger: '@' as const,
},
};
];
it('should replace mention markup with parsedValue when available', () => {
const input = 'Hello @[BigNate](1) how are you?';

View File

@ -3,10 +3,10 @@ import type { BusterMentionItems } from './BusterInput.types';
export const DEFAULT_MENTION_MARKUP = '@[__display__](__id__)';
const DEFAULT_MENTION_REGEX = /@\[([^\]]+)\]\(([^)]+)\)/g;
type BusterMentionItemsRecord<V = string, T extends string = string> = Record<
T,
Pick<BusterMentionItems<V, T>, 'items' | 'trigger'>
>;
type BusterMentionItemsRecord<V = string, T extends string = string> = Pick<
BusterMentionItems<V, T>,
'items' | 'trigger'
>[];
export const parseMarkupInput = <V = string, T extends string = string>({
input,