Compare commits

..

2 Commits

Author SHA1 Message Date
yyh
6a06bb45b3 fix stories 2026-05-26 19:06:25 +08:00
yyh
34b19422a2 fix(plugin): align local install modal spacing 2026-05-26 18:39:06 +08:00
6 changed files with 294 additions and 80 deletions

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual' import type { Virtualizer } from '@tanstack/react-virtual'
import type { RefObject } from 'react' import type { RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { import {
Autocomplete, Autocomplete,
AutocompleteClear, AutocompleteClear,
@ -159,6 +159,29 @@ const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, in
const getSuggestionLabel = (item: Suggestion) => item.label const getSuggestionLabel = (item: Suggestion) => item.label
async function searchSuggestions(
suggestions: Suggestion[],
query: string,
filter: (item: string, query: string) => boolean,
): Promise<{ items: Suggestion[], error: string | null }> {
await new Promise(resolve => window.setTimeout(resolve, 500))
if (query === 'will_error') {
return {
items: [],
error: 'Failed to load suggestions. Please try again.',
}
}
return {
items: suggestions.filter(item => (
filter(item.label, query)
|| (item.description ? filter(item.description, query) : false)
)),
error: null,
}
}
const SuggestionItem = ({ const SuggestionItem = ({
item, item,
dense, dense,
@ -227,6 +250,7 @@ const BasicTagAutocomplete = ({
<Autocomplete <Autocomplete
items={tagSuggestions} items={tagSuggestions}
itemToStringValue={getSuggestionLabel} itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick openOnInputClick
> >
<AutocompleteInputGroup size={size}> <AutocompleteInputGroup size={size}>
@ -311,32 +335,64 @@ const LimitedStatus = ({
} }
const AsyncSearchDemo = () => { const AsyncSearchDemo = () => {
const [value, setValue] = useState('agent') const [searchValue, setSearchValue] = useState('')
const [loading, setLoading] = useState(false) const [searchResults, setSearchResults] = useState<Suggestion[]>([])
const [items, setItems] = useState(remoteSuggestions) const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const { contains } = useAutocompleteFilter()
const abortControllerRef = useRef<AbortController | null>(null)
useEffect(() => { const status = (() => {
setLoading(true) if (isPending)
const timeout = window.setTimeout(() => { return 'Searching remote suggestions…'
setItems(
value.trim()
? remoteSuggestions.filter(item => item.label.toLowerCase().includes(value.trim().toLowerCase()))
: remoteSuggestions,
)
setLoading(false)
}, 500)
return () => window.clearTimeout(timeout) if (error)
}, [value]) return error
if (searchValue === '')
return null
if (searchResults.length === 0)
return `No remote suggestion matches "${searchValue}".`
return `${searchResults.length} remote suggestion${searchResults.length === 1 ? '' : 's'} found`
})()
return ( return (
<div className={inputWidth}> <div className={inputWidth}>
<Autocomplete <Autocomplete
items={items} items={searchResults}
value={value} value={searchValue}
onValueChange={setValue} onValueChange={(nextSearchValue) => {
setSearchValue(nextSearchValue)
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
if (nextSearchValue === '') {
setSearchResults([])
setError(null)
return
}
startTransition(async () => {
setError(null)
const result = await searchSuggestions(remoteSuggestions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
itemToStringValue={getSuggestionLabel} itemToStringValue={getSuggestionLabel}
openOnInputClick filter={null}
mode="list"
> >
<AutocompleteInputGroup> <AutocompleteInputGroup>
<span className="i-ri-cloud-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> <span className="i-ri-cloud-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
@ -344,16 +400,15 @@ const AsyncSearchDemo = () => {
<AutocompleteClear /> <AutocompleteClear />
<AutocompleteTrigger /> <AutocompleteTrigger />
</AutocompleteInputGroup> </AutocompleteInputGroup>
<AutocompleteContent> <AutocompleteContent portalProps={{ hidden: !status }} popupProps={{ 'aria-busy': isPending || undefined }}>
<AutocompleteStatus> <AutocompleteStatus>
{loading ? 'Loading suggestions…' : `${items.length} remote suggestions`} {status}
</AutocompleteStatus> </AutocompleteStatus>
<AutocompleteList> <AutocompleteList>
{(item: Suggestion) => ( {(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} /> <SuggestionItem key={item.value} item={item} />
)} )}
</AutocompleteList> </AutocompleteList>
<AutocompleteEmpty>No remote suggestion. Keep the typed query.</AutocompleteEmpty>
</AutocompleteContent> </AutocompleteContent>
</Autocomplete> </Autocomplete>
</div> </div>
@ -467,6 +522,7 @@ const FuzzyMatchingDemo = () => {
onValueChange={setValue} onValueChange={setValue}
filter={contains} filter={contains}
itemToStringValue={getSuggestionLabel} itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick openOnInputClick
> >
<AutocompleteInputGroup> <AutocompleteInputGroup>
@ -567,6 +623,7 @@ export const GroupedSuggestions: Story = {
<Autocomplete <Autocomplete
items={groupedSuggestions} items={groupedSuggestions}
itemToStringValue={getSuggestionLabel} itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick openOnInputClick
> >
<AutocompleteInputGroup> <AutocompleteInputGroup>
@ -595,6 +652,7 @@ export const LimitResults: Story = {
items={workflowSuggestions} items={workflowSuggestions}
itemToStringValue={getSuggestionLabel} itemToStringValue={getSuggestionLabel}
limit={5} limit={5}
mode="list"
openOnInputClick openOnInputClick
> >
<AutocompleteInputGroup> <AutocompleteInputGroup>
@ -627,6 +685,7 @@ export const CommandPalette: Story = {
inline inline
items={commandGroups} items={commandGroups}
itemToStringValue={getSuggestionLabel} itemToStringValue={getSuggestionLabel}
mode="list"
autoHighlight="always" autoHighlight="always"
keepHighlight keepHighlight
> >
@ -649,6 +708,7 @@ const VirtualizedLongSuggestionsDemo = () => {
<Autocomplete <Autocomplete
items={virtualizedSuggestions} items={virtualizedSuggestions}
itemToStringValue={getSuggestionLabel} itemToStringValue={getSuggestionLabel}
mode="list"
virtualized virtualized
openOnInputClick openOnInputClick
onItemHighlighted={(item, details) => { onItemHighlighted={(item, details) => {
@ -686,6 +746,7 @@ export const Empty: Story = {
items={tagSuggestions} items={tagSuggestions}
itemToStringValue={getSuggestionLabel} itemToStringValue={getSuggestionLabel}
defaultValue="private-release-note" defaultValue="private-release-note"
mode="list"
openOnInputClick openOnInputClick
> >
<AutocompleteInputGroup> <AutocompleteInputGroup>
@ -710,7 +771,7 @@ export const Empty: Story = {
export const DisabledAndReadOnly: Story = { export const DisabledAndReadOnly: Story = {
render: () => ( render: () => (
<div className="flex w-80 flex-col gap-3"> <div className="flex w-80 flex-col gap-3">
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" disabled> <Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" mode="list" disabled>
<AutocompleteInputGroup> <AutocompleteInputGroup>
<AutocompleteInput aria-label="Disabled tag autocomplete" /> <AutocompleteInput aria-label="Disabled tag autocomplete" />
<AutocompleteClear /> <AutocompleteClear />
@ -724,7 +785,7 @@ export const DisabledAndReadOnly: Story = {
</AutocompleteList> </AutocompleteList>
</AutocompleteContent> </AutocompleteContent>
</Autocomplete> </Autocomplete>
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" readOnly> <Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" mode="both" readOnly>
<AutocompleteInputGroup> <AutocompleteInputGroup>
<AutocompleteInput aria-label="Read-only prompt autocomplete" /> <AutocompleteInput aria-label="Read-only prompt autocomplete" />
<AutocompleteClear /> <AutocompleteClear />

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual' import type { Virtualizer } from '@tanstack/react-virtual'
import type { RefObject } from 'react' import type { RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { import {
Combobox, Combobox,
ComboboxChip, ComboboxChip,
@ -26,6 +26,7 @@ import {
ComboboxStatus, ComboboxStatus,
ComboboxTrigger, ComboboxTrigger,
ComboboxValue, ComboboxValue,
useComboboxFilter,
useComboboxFilteredItems, useComboboxFilteredItems,
} from '.' } from '.'
import { cn } from '../cn' import { cn } from '../cn'
@ -178,8 +179,34 @@ const defaultPopupDataSource = dataSourceOptions[1]!
const readOnlyDataSource = dataSourceOptions[2]! const readOnlyDataSource = dataSourceOptions[2]!
const defaultTool = toolGroups[0]!.items[0]! const defaultTool = toolGroups[0]!.items[0]!
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!] const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!]
const defaultAsyncReviewers = [reviewerOptions[1]!]
const defaultTag = tagOptions[2]! const defaultTag = tagOptions[2]!
const getOptionLabel = (option: Option) => option.label
async function searchOptions(
options: Option[],
query: string,
filter: (item: string, query: string) => boolean,
): Promise<{ items: Option[], error: string | null }> {
await new Promise(resolve => window.setTimeout(resolve, 450))
if (query === 'will_error') {
return {
items: [],
error: 'Failed to fetch matches. Please try again.',
}
}
return {
items: options.filter(option => (
filter(option.label, query)
|| (option.meta ? filter(option.meta, query) : false)
)),
error: null,
}
}
const renderOptionItem = (option: Option) => ( const renderOptionItem = (option: Option) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled} className="h-auto min-h-8 py-1.5"> <ComboboxItem key={option.value} value={option} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
<ComboboxItemText className="flex items-center gap-2 px-0"> <ComboboxItemText className="flex items-center gap-2 px-0">
@ -348,35 +375,88 @@ const VirtualizedLongListDemo = () => {
} }
const AsyncDirectoryDemo = () => { const AsyncDirectoryDemo = () => {
const [inputValue, setInputValue] = useState('ma') const [searchResults, setSearchResults] = useState<Option[]>([])
const [value, setValue] = useState<Option | null>(null) const [selectedValue, setSelectedValue] = useState<Option | null>(null)
const [items, setItems] = useState(directoryOptions.slice(0, 3)) const [searchValue, setSearchValue] = useState('')
const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const { contains } = useComboboxFilter()
const abortControllerRef = useRef<AbortController | null>(null)
const trimmedSearchValue = searchValue.trim()
const items = useMemo(() => {
if (!selectedValue || searchResults.some(option => option.value === selectedValue.value))
return searchResults
useEffect(() => { return [...searchResults, selectedValue]
setLoading(true) }, [searchResults, selectedValue])
const timeout = window.setTimeout(() => {
const query = inputValue.trim().toLowerCase()
setItems(
query
? directoryOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
: directoryOptions.slice(0, 5),
)
setLoading(false)
}, 450)
return () => window.clearTimeout(timeout) const status = (() => {
}, [inputValue]) if (isPending)
return 'Searching directory matches…'
if (error)
return error
if (trimmedSearchValue === '')
return selectedValue ? null : 'Start typing to search owners…'
if (searchResults.length === 0)
return `No matches for "${trimmedSearchValue}".`
return `${searchResults.length} owner${searchResults.length === 1 ? '' : 's'} found`
})()
const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
? null
: 'Try a different owner search.'
return ( return (
<FieldRoot name="owner" className={fieldWidth}> <FieldRoot name="owner" className={fieldWidth}>
<FieldLabel>Owner</FieldLabel> <FieldLabel>Owner</FieldLabel>
<Combobox <Combobox
items={value && !items.some(item => item.value === value.value) ? [value, ...items] : items} items={items}
value={value} itemToStringLabel={getOptionLabel}
onValueChange={setValue} filter={null}
inputValue={inputValue} value={selectedValue}
onInputValueChange={setInputValue} onOpenChangeComplete={(open) => {
if (!open && selectedValue)
setSearchResults([selectedValue])
}}
onValueChange={(nextSelectedValue) => {
setSelectedValue(nextSelectedValue)
setSearchValue('')
setError(null)
}}
onInputValueChange={(nextSearchValue, { reason }) => {
setSearchValue(nextSearchValue)
if (nextSearchValue === '') {
setSearchResults([])
setError(null)
return
}
if (reason === 'item-press')
return
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
startTransition(async () => {
setError(null)
const result = await searchOptions(directoryOptions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
> >
<ComboboxInputGroup className="h-8 min-h-8 px-2"> <ComboboxInputGroup className="h-8 min-h-8 px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
@ -384,12 +464,12 @@ const AsyncDirectoryDemo = () => {
<ComboboxClear className="mr-0.5" /> <ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" /> <ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup> </ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]"> <ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}>
<ComboboxStatus className="border-b border-divider-subtle"> <ComboboxStatus className="border-b border-divider-subtle">
{loading ? 'Loading directory matches…' : `${items.length} selectable owners`} {status}
</ComboboxStatus> </ComboboxStatus>
<ComboboxList>{renderOptionItem}</ComboboxList> <ComboboxList>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>No owner matches this query</ComboboxEmpty> <ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
</FieldRoot> </FieldRoot>
@ -397,38 +477,111 @@ const AsyncDirectoryDemo = () => {
} }
const AsyncReviewerDemo = () => { const AsyncReviewerDemo = () => {
const [inputValue, setInputValue] = useState('ma') const [searchResults, setSearchResults] = useState<Option[]>([])
const [value, setValue] = useState<Option[]>([reviewerOptions[1]!]) const [selectedValues, setSelectedValues] = useState<Option[]>(defaultAsyncReviewers)
const [items, setItems] = useState(reviewerOptions.slice(0, 3)) const [searchValue, setSearchValue] = useState('')
const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null)
const [blockStartStatus, setBlockStartStatus] = useState(false)
const [isPending, startTransition] = useTransition()
const { contains } = useComboboxFilter()
const abortControllerRef = useRef<AbortController | null>(null)
const selectedValuesRef = useRef<Option[]>(defaultAsyncReviewers)
const trimmedSearchValue = searchValue.trim()
useEffect(() => { const items = useMemo(() => {
setLoading(true) if (selectedValues.length === 0)
const timeout = window.setTimeout(() => { return searchResults
const query = inputValue.trim().toLowerCase()
const matches = query
? reviewerOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
: reviewerOptions
setItems(matches) const merged = [...searchResults]
setLoading(false)
}, 450)
return () => window.clearTimeout(timeout) selectedValues.forEach((selected) => {
}, [inputValue]) if (!searchResults.some(result => result.value === selected.value))
merged.push(selected)
})
const selectedItems = value.filter(selected => !items.some(item => item.value === selected.value)) return merged
}, [searchResults, selectedValues])
const status = (() => {
if (isPending)
return 'Searching reviewer matches…'
if (error)
return error
if (trimmedSearchValue === '' && !blockStartStatus)
return selectedValues.length > 0 ? null : 'Start typing to search reviewers…'
if (searchResults.length === 0 && !blockStartStatus)
return `No matches for "${trimmedSearchValue}".`
return `${searchResults.length} reviewer${searchResults.length === 1 ? '' : 's'} found`
})()
const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
? null
: 'Try a different reviewer search.'
return ( return (
<FieldRoot name="asyncReviewers" className={fieldWidth}> <FieldRoot name="asyncReviewers" className={fieldWidth}>
<FieldLabel>Async reviewers</FieldLabel> <FieldLabel>Async reviewers</FieldLabel>
<Combobox <Combobox
items={[...selectedItems, ...items]} items={items}
itemToStringLabel={getOptionLabel}
multiple multiple
value={value} filter={null}
onValueChange={setValue} value={selectedValues}
inputValue={inputValue} onOpenChangeComplete={(open) => {
onInputValueChange={setInputValue} if (!open) {
setSearchResults(selectedValuesRef.current)
setBlockStartStatus(false)
}
}}
onValueChange={(nextSelectedValues) => {
selectedValuesRef.current = nextSelectedValues
setSelectedValues(nextSelectedValues)
setSearchValue('')
setError(null)
if (nextSelectedValues.length === 0) {
setSearchResults([])
setBlockStartStatus(false)
}
else {
setBlockStartStatus(true)
}
}}
onInputValueChange={(nextSearchValue, { reason }) => {
setSearchValue(nextSearchValue)
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
if (nextSearchValue === '') {
setSearchResults(selectedValuesRef.current)
setError(null)
setBlockStartStatus(false)
return
}
if (reason === 'item-press')
return
startTransition(async () => {
setError(null)
const result = await searchOptions(reviewerOptions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
> >
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1"> <ComboboxInputGroup className="h-auto min-h-8 items-start py-1">
<ComboboxChips> <ComboboxChips>
@ -447,12 +600,12 @@ const AsyncReviewerDemo = () => {
</ComboboxValue> </ComboboxValue>
</ComboboxChips> </ComboboxChips>
</ComboboxInputGroup> </ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]"> <ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}>
<ComboboxStatus className="border-b border-divider-subtle"> <ComboboxStatus className="border-b border-divider-subtle">
{loading ? 'Loading reviewer matches…' : `${items.length} selectable reviewers`} {status}
</ComboboxStatus> </ComboboxStatus>
<ComboboxList>{renderOptionItem}</ComboboxList> <ComboboxList>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>No reviewer matches this query</ComboboxEmpty> <ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
<FieldDescription>Selected reviewers stay available while async matches change.</FieldDescription> <FieldDescription>Selected reviewers stay available while async matches change.</FieldDescription>

View File

@ -17,7 +17,7 @@ const Placeholder = ({
loadingFileName, loadingFileName,
}: Props) => { }: Props) => {
return ( return (
<div className={wrapClassName}> <div className={cn(wrapClassName, 'p-3')}>
<SkeletonRow> <SkeletonRow>
<div <div
className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px] className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]

View File

@ -30,7 +30,7 @@ const Installed: FC<Props> = ({
} }
return ( return (
<> <>
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> <div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<p className="system-md-regular text-text-secondary">{(isFailed && errMsg) ? errMsg : t(`installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`, { ns: 'plugin' })}</p> <p className="system-md-regular text-text-secondary">{(isFailed && errMsg) ? errMsg : t(`installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`, { ns: 'plugin' })}</p>
{payload && ( {payload && (
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2"> <div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">

View File

@ -116,7 +116,7 @@ const Installed: FC<Props> = ({
return ( return (
<> <>
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> <div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<div className="system-md-regular text-text-secondary"> <div className="system-md-regular text-text-secondary">
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p> <p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
<p> <p>

View File

@ -106,7 +106,7 @@ const Uploading: FC<Props> = ({
}, [handleUpload]) }, [handleUpload])
return ( return (
<> <>
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> <div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<div className="flex items-center gap-1 self-stretch"> <div className="flex items-center gap-1 self-stretch">
<span className="i-ri-loader-2-line size-4 animate-spin-slow text-text-accent" /> <span className="i-ri-loader-2-line size-4 animate-spin-slow text-text-accent" />
<div className="system-md-regular text-text-secondary"> <div className="system-md-regular text-text-secondary">