fix(web): use popup-open selectors for trigger styles (#36471)

This commit is contained in:
yyh
2026-05-21 14:13:11 +08:00
committed by GitHub
parent 66f5ab4cfc
commit 7f633622aa
31 changed files with 211 additions and 193 deletions

View File

@@ -11,6 +11,8 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props.
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
- Prefer Base UI data attributes and CSS variables for visual states; do not mirror state in React solely to add classes.
- When a Base UI API or selector contract is unclear, read the docs linked from `README.md` and the local `@base-ui/react` `.d.ts` files before coding.
## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover

View File

@@ -3,6 +3,7 @@
Shared UI primitives, design tokens, CSS-first Tailwind styles, and the `cn()` utility consumed by Dify's `web/` app.
The primitives are thin, opinionated wrappers around [Base UI] headless components, styled with `cva` + `cn` and Dify design tokens.
For upstream component docs, start from the [Base UI docs index].
> `private: true` — this package is consumed by `web/` via the pnpm workspace and is not published to npm. Treat the API as internal to Dify, but stable within the workspace.
@@ -39,12 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./form`, `./field`, `./fieldset`, `./checkbox`, `./checkbox-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
Utilities:
@@ -174,5 +179,6 @@ See `[AGENTS.md](./AGENTS.md)` for:
[Base UI Fieldset]: https://base-ui.com/react/components/fieldset
[Base UI Form]: https://base-ui.com/react/components/form
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
[Base UI docs index]: https://base-ui.com/llms.txt
[Base UI]: https://base-ui.com/react
[Overlay & portal contract]: #overlay--portal-contract

View File

@@ -30,7 +30,6 @@ export type AutocompleteRootHighlightEventDetails = BaseAutocomplete.Root.Highli
const autocompletePopupClassName = [
'w-(--anchor-width) max-w-[min(28rem,var(--available-width))] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden',
'data-side-top:origin-bottom data-side-bottom:origin-top data-side-left:origin-right data-side-right:origin-left',
]
const autocompleteListClassName = [

View File

@@ -273,6 +273,7 @@ describe('Combobox wrappers', () => {
it('should render item text indicator status and empty wrappers with design classes', async () => {
const screen = await renderSelectLikeCombobox({ open: true })
expect(screen.getByRole('option', { name: 'Workflow' }).element().className).not.toContain('mx-1')
await expect.element(screen.getByTestId('list').getByText('Workflow')).toHaveClass('system-sm-medium')
await expect.element(screen.getByTestId('status')).toHaveClass('text-text-tertiary')
await expect.element(screen.getByTestId('empty')).toHaveClass('system-sm-regular')

View File

@@ -29,6 +29,11 @@ import {
useComboboxFilteredItems,
} from '.'
import { cn } from '../cn'
import {
FieldDescription,
FieldLabel,
FieldRoot,
} from '../field'
type Option = {
value: string
@@ -44,8 +49,6 @@ type OptionGroup = {
}
const fieldWidth = 'w-80'
const wideFieldWidth = 'w-[520px]'
const nativeFieldLabelClassName = 'mb-1 block text-text-secondary system-sm-medium'
type StoryVirtualizer = Virtualizer<HTMLDivElement, Element>
@@ -174,11 +177,11 @@ const defaultDataSource = dataSourceOptions[0]!
const defaultPopupDataSource = dataSourceOptions[1]!
const readOnlyDataSource = dataSourceOptions[2]!
const defaultTool = toolGroups[0]!.items[0]!
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[2]!]
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!, reviewerOptions[2]!, reviewerOptions[3]!]
const defaultTag = tagOptions[2]!
const renderOptionItem = (option: Option, index?: number) => (
<ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled}>
<ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
<ComboboxItemText className="flex items-center gap-2 px-0">
{option.icon && <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} />}
<span className="min-w-0 flex-1">
@@ -204,18 +207,20 @@ const PopupSearchInput = ({
label: string
placeholder: string
}) => (
<ComboboxInputGroup className="mb-1 border-divider-subtle bg-components-input-bg-normal">
<span aria-hidden className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput aria-label={label} placeholder={`${placeholder}`} className="pl-2" />
<ComboboxClear />
</ComboboxInputGroup>
<div className="p-1 pb-0">
<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" />
<ComboboxInput aria-label={label} placeholder={`${placeholder}`} className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0" />
</ComboboxInputGroup>
</div>
)
const GroupedToolList = () => {
const groups = useComboboxFilteredItems<OptionGroup>()
return (
<ComboboxList className="p-0">
<ComboboxList>
{groups.map((group, groupIndex) => (
<ComboboxGroup key={group.label} items={group.items}>
{groupIndex > 0 && <ComboboxSeparator />}
@@ -318,7 +323,7 @@ const VirtualizedLongListDemo = () => {
<ComboboxTrigger aria-label="Model catalog">
<ComboboxValue placeholder="Select model" />
</ComboboxTrigger>
<ComboboxContent popupClassName="w-[440px] p-1">
<ComboboxContent popupClassName="w-[440px]">
<PopupSearchInput label="Filter model catalog" placeholder="Filter 1,000 models" />
<FilteredModelStatus />
<VirtualizedModelList virtualizerRef={virtualizerRef} />
@@ -351,7 +356,8 @@ const AsyncDirectoryDemo = () => {
}, [inputValue])
return (
<div className={fieldWidth}>
<FieldRoot name="owner" className={fieldWidth}>
<FieldLabel>Owner</FieldLabel>
<Combobox
items={value && !items.some(item => item.value === value.value) ? [value, ...items] : items}
value={value}
@@ -360,15 +366,12 @@ const AsyncDirectoryDemo = () => {
onInputValueChange={setInputValue}
autoHighlight
>
<label className={nativeFieldLabelClassName}>
Owner
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search owners…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<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" />
<ComboboxInput placeholder="Search owners…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]">
<ComboboxStatus className="border-b border-divider-subtle">
{loading ? 'Loading directory matches…' : `${items.length} selectable owners`}
@@ -377,12 +380,12 @@ const AsyncDirectoryDemo = () => {
<ComboboxEmpty>No owner matches this query</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</div>
</FieldRoot>
)
}
const meta = {
title: 'Base/UI/Combobox',
title: 'Base/Form/Combobox',
component: Combobox,
parameters: {
layout: 'centered',
@@ -398,24 +401,46 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
export const SelectLikeDefault: Story = {
export const Default: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={providerOptions} defaultValue={defaultProvider} autoHighlight>
<ComboboxLabel>Model provider</ComboboxLabel>
<ComboboxTrigger aria-label="Model provider">
<ComboboxValue placeholder="Select provider" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search model providers" placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<ComboboxLabel>Connect source</ComboboxLabel>
<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" />
<ComboboxInput placeholder="Search data sources…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
export const PopupInputSearchableSelect: Story = {
export const FormField: Story = {
render: () => (
<FieldRoot name="sourceConnector" className={fieldWidth}>
<FieldLabel>Connect source</FieldLabel>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<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" />
<ComboboxInput placeholder="Search data sources…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>Type to filter, then choose a remembered data source.</FieldDescription>
</FieldRoot>
),
}
export const CompactTriggerWithPopupSearch: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource} autoHighlight>
@@ -423,9 +448,9 @@ export const PopupInputSearchableSelect: Story = {
<ComboboxTrigger aria-label="Data source">
<ComboboxValue placeholder="Choose source" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<ComboboxContent>
<PopupSearchInput label="Search data sources" placeholder="Search sources" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
@@ -436,38 +461,20 @@ export const AsyncSearchSingle: Story = {
render: () => <AsyncDirectoryDemo />,
}
export const InputGroupSearchable: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<label className={nativeFieldLabelClassName}>
Connect source
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search data sources…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
{(['small', 'medium', 'large'] as const).map(size => (
<Combobox key={size} items={sizeOptions} defaultValue={defaultProvider} autoHighlight>
<ComboboxTrigger aria-label={`${size} model provider`} size={size}>
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label={`Search ${size} model providers`} placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
<ComboboxLabel>{`${size[0]!.toUpperCase()}${size.slice(1)}`}</ComboboxLabel>
<ComboboxInputGroup size={size} className="px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput size={size} placeholder="Search providers…" className="px-1" />
<ComboboxClear size={size} className="mr-0.5" />
<ComboboxInputTrigger size={size} className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
))}
@@ -483,7 +490,7 @@ export const Grouped: Story = {
<ComboboxTrigger aria-label="Workflow tool">
<ComboboxValue placeholder="Select tool" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<ComboboxContent>
<PopupSearchInput label="Search workflow tools" placeholder="Search workflow tools" />
<GroupedToolList />
</ComboboxContent>
@@ -496,35 +503,34 @@ const MultipleChipsDemo = () => {
const [value, setValue] = useState<Option[]>(defaultReviewers)
return (
<div className={wideFieldWidth}>
<FieldRoot name="reviewers" className={fieldWidth}>
<FieldLabel>Reviewers</FieldLabel>
<Combobox items={reviewerOptions} multiple value={value} onValueChange={setValue} autoHighlight>
<label className={nativeFieldLabelClassName}>
Reviewers
<ComboboxInputGroup className="mt-1 h-auto min-h-8 flex-nowrap py-1">
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1 pr-1">
<ComboboxChips>
<ComboboxValue>
{(selectedValue: Option[]) => (
<>
<ComboboxChips className="flex-nowrap">
{selectedValue.map(item => (
<ComboboxChip key={item.value}>
<span className="max-w-32 truncate">{item.label}</span>
<ComboboxChipRemove aria-label={`Remove ${item.label}`} />
</ComboboxChip>
))}
</ComboboxChips>
<ComboboxInput placeholder={selectedValue.length ? '' : 'Assign reviewers…'} className="min-w-16 px-2" />
{selectedValue.map(item => (
<ComboboxChip key={item.value}>
<span className="max-w-32 truncate">{item.label}</span>
<ComboboxChipRemove aria-label={`Remove ${item.label}`} />
</ComboboxChip>
))}
<ComboboxInput placeholder={selectedValue.length ? '' : 'Assign reviewers…'} className="min-w-24 px-1 py-0.5" />
</>
)}
</ComboboxValue>
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
</ComboboxChips>
<ComboboxClear className="mt-0.5 mr-0.5" />
<ComboboxInputTrigger className="mt-0.5 mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<FieldDescription>Selected reviewers wrap inside the input instead of scrolling horizontally.</FieldDescription>
</FieldRoot>
)
}
@@ -538,53 +544,53 @@ export const VirtualizedLongList: Story = {
export const EmptyAndStatus: Story = {
render: () => (
<div className={fieldWidth}>
<FieldRoot name="connector" className={fieldWidth}>
<FieldLabel>Connector</FieldLabel>
<Combobox items={emptyOptions} defaultInputValue="salesforce" autoHighlight>
<label className={nativeFieldLabelClassName}>
Connector
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search connectors…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<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" />
<ComboboxInput placeholder="Search connectors…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxStatus>Search workspace connectors</ComboboxStatus>
<ComboboxEmpty>No connectors found</ComboboxEmpty>
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FieldRoot>
),
}
export const DisabledAndReadOnly: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
<ComboboxLabel>Disabled provider</ComboboxLabel>
<ComboboxTrigger aria-label="Disabled model provider">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
<label className={nativeFieldLabelClassName}>
Read-only source
<ComboboxInputGroup className="mt-1">
<ComboboxInput placeholder="Read-only data source…" />
<ComboboxClear />
<ComboboxInputTrigger />
<FieldRoot name="disabledProvider" disabled>
<FieldLabel>Disabled provider</FieldLabel>
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
<ComboboxTrigger aria-label="Disabled model provider">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent>
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</FieldRoot>
<FieldRoot name="readOnlySource">
<FieldLabel>Read-only source</FieldLabel>
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<ComboboxInput placeholder="Read-only data source…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
</label>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</FieldRoot>
</div>
),
}
@@ -594,16 +600,18 @@ const ControlledDemo = () => {
return (
<div className="flex w-80 flex-col items-start gap-3">
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
<ComboboxLabel>Default app tag</ComboboxLabel>
<ComboboxTrigger aria-label="Default app tag">
<ComboboxValue placeholder="Select tag" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
<ComboboxList className="p-0">{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<div className="w-full">
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
<ComboboxLabel>Default app tag</ComboboxLabel>
<ComboboxTrigger aria-label="Default app tag">
<ComboboxValue placeholder="Select tag" />
</ComboboxTrigger>
<ComboboxContent>
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<span className="rounded-md border border-divider-subtle bg-components-panel-bg px-2 py-1 text-text-tertiary system-xs-regular">
Selected:
{' '}

View File

@@ -31,7 +31,6 @@ export type ComboboxRootHighlightEventDetails = BaseCombobox.Root.HighlightEvent
const comboboxPopupClassName = [
'w-(--anchor-width) max-w-[min(28rem,var(--available-width))] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden',
'data-side-top:origin-bottom data-side-bottom:origin-top data-side-left:origin-right data-side-right:origin-left',
]
const comboboxListClassName = [
@@ -40,7 +39,7 @@ const comboboxListClassName = [
]
const comboboxItemClassName = [
'mx-1 grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
'grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
'hover:bg-state-base-hover-alt hover:text-text-primary',
'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary',
'data-selected:text-text-primary',
@@ -51,7 +50,7 @@ const comboboxItemClassName = [
const comboboxTriggerVariants = cva(
[
'group/combobox-trigger flex w-full min-w-0 items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden transition-colors',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-open:bg-state-base-hover-alt',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
@@ -101,7 +100,7 @@ export function ComboboxTrigger({
{children}
</span>
{icon !== false && (
<BaseCombobox.Icon className="shrink-0 text-text-quaternary transition-colors group-hover/combobox-trigger:text-text-secondary group-data-open/combobox-trigger:text-text-secondary group-data-readonly/combobox-trigger:hidden">
<BaseCombobox.Icon className="shrink-0 text-text-quaternary transition-colors group-hover/combobox-trigger:text-text-secondary group-data-popup-open/combobox-trigger:text-text-secondary group-data-readonly/combobox-trigger:hidden">
{icon ?? <span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />}
</BaseCombobox.Icon>
)}
@@ -115,7 +114,7 @@ const comboboxInputGroupVariants = cva(
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs',
'data-open:border-components-input-border-active data-open:bg-components-input-bg-active',
'data-popup-open:border-components-input-border-active data-popup-open:bg-components-input-bg-active',
'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled',
'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled',
'data-readonly:shadow-none data-readonly:hover:border-transparent data-readonly:hover:bg-components-input-bg-normal',

View File

@@ -174,7 +174,7 @@ describe('Select wrappers', () => {
it('should include open state feedback classes', async () => {
const screen = await renderOpenSelect()
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-open:bg-state-base-hover-alt')
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt')
})
})

View File

@@ -21,7 +21,7 @@ export const SelectGroup = BaseSelect.Group
const selectTriggerVariants = cva(
[
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-open:bg-state-base-hover-alt',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
@@ -60,7 +60,7 @@ export function SelectTrigger({
<span className="min-w-0 grow truncate">
{children}
</span>
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-open:text-text-secondary">
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-popup-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
</BaseSelect.Icon>
</BaseSelect.Trigger>

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ReactNode } from 'react'
import { toast } from '.'
import { toast, ToastHost } from '.'
const buttonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs transition-colors hover:bg-state-base-hover'
const cardClassName = 'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3'
@@ -272,28 +272,31 @@ const UpdateExamples = () => {
const ToastDocsDemo = () => {
return (
<div className="min-h-screen bg-background-default-subtle px-6 py-12">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div className="space-y-3">
<div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">
Base UI toast docs
<>
<ToastHost />
<div className="min-h-screen bg-background-default-subtle px-6 py-12">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div className="space-y-3">
<div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">
Base UI toast docs
</div>
<h2 className="text-[24px] leading-8 font-semibold text-text-primary">
Shared stacked toast examples
</h2>
<p className="max-w-3xl text-sm leading-6 text-text-secondary">
Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior.
</p>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<VariantExamples />
<StackExamples />
<PromiseExamples />
<ActionExamples />
<UpdateExamples />
</div>
<h2 className="text-[24px] leading-8 font-semibold text-text-primary">
Shared stacked toast examples
</h2>
<p className="max-w-3xl text-sm leading-6 text-text-secondary">
Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior.
</p>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<VariantExamples />
<StackExamples />
<PromiseExamples />
<ActionExamples />
<UpdateExamples />
</div>
</div>
</div>
</>
)
}

View File

@@ -99,6 +99,7 @@ export const PopoverTrigger = ({
...childProps,
'data-testid': childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'popover-trigger',
'data-popover-trigger': 'true',
'data-popup-open': open ? '' : undefined,
'onClick': (event: React.MouseEvent<HTMLElement>) => {
childProps.onClick?.(event)
onClick?.(event)
@@ -113,6 +114,7 @@ export const PopoverTrigger = ({
<div
data-testid="popover-trigger"
data-popover-trigger="true"
data-popup-open={open ? '' : undefined}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented)

View File

@@ -2,7 +2,6 @@
import type { FC } from 'react'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiArrowDownSLine } from '@remixicon/react'
import dayjs from 'dayjs'
@@ -74,9 +73,9 @@ const RangeSelector: FC<Props> = ({
<SelectTrigger
className="h-auto w-fit max-w-none border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', open && 'bg-state-base-hover-alt')}>
<div className="flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3 group-data-popup-open:bg-state-base-hover-alt">
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : selectedItem?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', open && 'text-text-secondary')} />
<RiArrowDownSLine className="size-4 text-text-quaternary group-data-popup-open:text-text-secondary" />
</div>
</SelectTrigger>
<SelectContent className="translate-x-[-24px]" popupClassName="w-[200px]" listClassName="p-1">

View File

@@ -49,7 +49,7 @@ const AppSidebarDropdown = ({ navigation, appInfoActions }: Props) => {
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
'data-popup-open:bg-background-default-hover',
)}
>
<AppIcon

View File

@@ -63,7 +63,7 @@ const DatasetSidebarDropdown = ({
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
'data-popup-open:bg-background-default-hover',
)}
>
<AppIcon

View File

@@ -109,7 +109,7 @@ export default function AddMemberOrGroupDialog() {
aria-label={t('operation.add', { ns: 'common' })}
icon={false}
size="small"
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-open:bg-state-accent-hover"
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
>
<RiAddCircleFill className="size-4" aria-hidden="true" />
<span>{t('operation.add', { ns: 'common' })}</span>

View File

@@ -52,11 +52,11 @@ const VarPicker: FC<Props> = ({
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn(triggerClassName)}>
<div className={cn('group', triggerClassName)}>
<div className={cn(
className,
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
'bg-transparent group-data-popup-open:bg-components-button-secondary-bg',
`
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
font-medium shadow-xs
@@ -74,7 +74,7 @@ const VarPicker: FC<Props> = ({
</div>
)}
</div>
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'size-3.5')} />
<ChevronDownIcon className="size-3.5 group-data-popup-open:rotate-180 group-data-popup-open:text-text-tertiary" />
</div>
</div>
)}

View File

@@ -71,10 +71,10 @@ const ModelInfo: FC<Props> = ({
<div className="relative">
<PopoverTrigger
render={(
<button type="button" className="block border-none bg-transparent p-0">
<button type="button" className="group block border-none bg-transparent p-0">
<div className={cn(
'cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover',
open && 'bg-components-button-tertiary-bg-hover',
'group-data-popup-open:bg-components-button-tertiary-bg-hover',
)}
>
<RiInformation2Line className="size-4 text-text-tertiary" />

View File

@@ -153,7 +153,7 @@ export function DocumentPicker({
aria-label={value?.name || t('operation.search', { ns: 'common' })}
icon={false}
className={cn(
'ml-1 flex size-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active data-open:bg-state-base-hover',
'ml-1 flex size-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active data-popup-open:bg-state-base-hover',
)}
>
<ComboboxValue>

View File

@@ -247,9 +247,8 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail,
'inline-flex items-center justify-center',
!isListScene && 'h-8! w-8! rounded-lg backdrop-blur-[5px]',
isOperationsMenuOpen
? 'shadow-none! hover:bg-state-base-hover!'
: isListScene && 'bg-transparent!',
isListScene && 'bg-transparent!',
'data-popup-open:shadow-none! data-popup-open:hover:bg-state-base-hover!',
)}
onClick={(e) => {
e.stopPropagation()

View File

@@ -113,7 +113,7 @@ function ModelSelector({
<ComboboxTrigger
aria-label={t('detailPanel.configureModel', { ns: 'plugin' })}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-popup-open:bg-transparent"
disabled={readonly}
>
<ModelSelectorTrigger

View File

@@ -134,7 +134,7 @@ export function AppPicker({
<ComboboxTrigger
aria-label={t('appSelector.label', { ns: 'app' })}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-popup-open:bg-transparent"
>
{trigger}
</ComboboxTrigger>

View File

@@ -43,7 +43,7 @@ const CategoriesFilter = ({
<div className={cn(
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
selectedTagsLength && 'text-text-secondary',
open && 'bg-state-base-hover',
'data-popup-open:bg-state-base-hover',
)}
>
<div className={cn(

View File

@@ -43,7 +43,7 @@ const TagsFilter = ({
<div className={cn(
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
selectedTagsLength && 'text-text-secondary',
open && 'bg-state-base-hover',
'data-popup-open:bg-state-base-hover',
)}
>
<div className={cn(

View File

@@ -47,7 +47,7 @@ function LabelSelector({
<PopoverTrigger
className={cn(
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 text-left hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover hover:bg-components-input-bg-hover',
'data-popup-open:bg-components-input-bg-hover data-popup-open:hover:bg-components-input-bg-hover',
)}
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-4.5 text-text-secondary', !value.length && 'text-text-quaternary!')}>

View File

@@ -196,8 +196,9 @@ describe('MethodSelector', () => {
await user.click(trigger)
await waitFor(() => {
const openTrigger = document.querySelector('.bg-background-section-burn\\!')
expect(openTrigger)!.toBeInTheDocument()
const openTrigger = screen.getByTestId('popover-trigger')
expect(openTrigger).toHaveAttribute('data-popup-open')
expect(openTrigger).toHaveClass('data-popup-open:bg-background-section-burn!')
})
})

View File

@@ -36,7 +36,7 @@ const MethodSelector: FC<MethodSelectorProps> = ({
render={(
<div className={cn(
'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
open && 'bg-background-section-burn! hover:bg-background-section-burn',
'data-popup-open:bg-background-section-burn! data-popup-open:hover:bg-background-section-burn',
)}
>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>

View File

@@ -74,17 +74,17 @@ const WorkflowChecklist = ({
<button
type="button"
className={cn(
'relative ml-0.5 flex size-7 items-center justify-center rounded-md border-none bg-transparent p-0',
'group relative ml-0.5 flex size-7 items-center justify-center rounded-md border-none bg-transparent p-0',
disabled && 'cursor-not-allowed opacity-50',
)}
disabled={disabled || undefined}
aria-label={checklistLabel}
>
<span
className={cn('group flex size-full items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
className="flex size-full items-center justify-center rounded-md group-data-popup-open:bg-state-accent-hover hover:bg-state-accent-hover"
>
<span
className={cn('i-ri-list-check-3 size-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
className="i-ri-list-check-3 size-4 text-components-button-ghost-text group-hover:text-components-button-secondary-accent-text group-data-popup-open:text-components-button-secondary-accent-text"
aria-hidden="true"
/>
</span>

View File

@@ -79,7 +79,7 @@ const ViewHistory = ({
className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
open && 'bg-components-button-secondary-bg-hover',
'data-popup-open:bg-components-button-secondary-bg-hover',
)}
>
<span className="mr-1 i-custom-vender-line-time-clock-play size-4" />
@@ -98,12 +98,12 @@ const ViewHistory = ({
<button
type="button"
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
className={cn('group flex size-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
className="group flex size-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover data-popup-open:bg-state-accent-hover"
onClick={() => {
onClearLogAndMessageModal?.()
}}
>
<span className={cn('i-custom-vender-line-time-clock-play', 'size-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
<span className="i-custom-vender-line-time-clock-play size-4 text-components-button-ghost-text group-hover:text-components-button-secondary-accent-text group-data-popup-open:text-components-button-secondary-accent-text" />
</button>
)}
/>

View File

@@ -157,7 +157,7 @@ const ViewWorkflowHistory = () => {
aria-label={t('changeHistory.title', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={
cn('box-border inline-flex size-8 max-h-8 min-h-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
cn('box-border inline-flex size-8 max-h-8 min-h-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary data-popup-open:bg-state-accent-active data-popup-open:text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => {
if (nodesReadOnly)

View File

@@ -55,7 +55,7 @@ const ButtonStyleDropdown: FC<Props> = ({
>
<PopoverTrigger
render={(
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1 data-popup-open:bg-components-button-tertiary-bg-hover', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover')}>
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
<RiFontSize className="size-4" />
</Button>

View File

@@ -74,7 +74,7 @@ export const TagFilter = ({
aria-label={triggerLabel}
icon={false}
className={cn(
'flex h-8 max-w-60 min-w-28 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-0 text-left select-none hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal data-open:bg-components-input-bg-normal',
'flex h-8 max-w-60 min-w-28 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-0 text-left select-none hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal data-popup-open:bg-components-input-bg-normal',
!!value.length && 'pr-6 shadow-xs',
)}
>

View File

@@ -214,8 +214,7 @@ export const TagSelector = ({
<ComboboxTrigger
aria-label={triggerLabel}
className={cn(
open ? 'bg-state-base-hover' : 'bg-transparent',
'block h-auto w-full rounded-lg border-0 bg-transparent p-0 text-left hover:bg-transparent focus:outline-hidden focus-visible:bg-transparent focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:ring-inset data-open:bg-state-base-hover data-open:hover:bg-state-base-hover',
'block h-auto w-full rounded-lg border-0 bg-transparent p-0 text-left hover:bg-transparent focus:outline-hidden focus-visible:bg-transparent focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:ring-inset data-popup-open:bg-state-base-hover data-popup-open:hover:bg-state-base-hover',
)}
icon={false}
>