跳至正文

Combobox

支持过滤的单值或多值ARIA组合框。

浏览器
Chrome 114+Firefox 125+Safari 17+
层级
2
压缩后大小
5.18 kB
强制布局预算
0 ms
axe 预算
0
支持 SSR
是
公开契约
测试环境

交互示例

Combobox

默认

苹果
香蕉
樱桃
椰枣

多选

ReactUI
React
UI
TypeScript
无障碍
设计系统

打开

苹果
香蕉
樱桃
椰枣

大型

"use client";

import { Combobox } from "@gunkin/uiify";

const skills = [
  { label: "React", value: "React" },
  { label: "UI", value: "UI" },
  { label: "TypeScript", value: "TypeScript" },
  { label: "Accessibility", value: "Accessibility" },
];

export default function Example() {
  return (
    <div className="">
      <Combobox.Root multiple defaultValue={["React", "UI"]} items={skills}>
        <Combobox.Control className="">
          <Combobox.Tags className="" />
          <Combobox.Input className="" aria-label="Add tag" placeholder="Add tag" />
        </Combobox.Control>
        <Combobox.Content className="">
          {(items) =>
            items.length === 0 ? (
              <Combobox.Empty>Nothing matched. Try another search.</Combobox.Empty>
            ) : (
              items.map((item) => <Combobox.Item className="" item={item} key={item.value} />)
            )
          }
        </Combobox.Content>
      </Combobox.Root>
    </div>
  );
}
/* Combobox — text input + listbox surface + option rows. The active option is
   tracked via aria-activedescendant, surfaced here as [data-highlighted]. */

@layer components {
  [data-uiify-combobox] {
    position: relative;
    inline-size: 100%;
    max-inline-size: 18rem;
  }

  [data-uiify-combobox] [data-part="control"] {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-xs);
    inline-size: 100%;
    min-block-size: 2.5rem;
    padding-block: var(--space-sm);
    padding-inline: var(--space-md);
    border: var(--border-width) solid var(--input);
    border-radius: var(--radius-md);
    corner-shape: squircle;
    background-color: var(--card);
    color: var(--foreground);
    transition: var(--transition-colors);
  }

  [data-uiify-combobox] [data-part="control"]:focus-within {
    border-color: var(--primary);
  }

  [data-uiify-combobox] [data-part="control"][data-disabled],
  [data-uiify-combobox] [data-part="control"]:has([data-part="input"]:disabled) {
    opacity: 0.5;
    cursor: not-allowed;
  }

  [data-uiify-combobox] [data-part="control"] [data-part="tags"] {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-xs);
  }

  [data-uiify-combobox] [data-part="control"] [data-part="tag"] {
    display: inline-flex;
    align-items: center;
    gap: var(--space-xs);
    padding-block: var(--space-xs);
    padding-inline: var(--space-sm);
    border-radius: var(--radius-xs);
    corner-shape: squircle;
    background-color: var(--accent);
    color: var(--accent-foreground);
    font-size: var(--text-xs);
    font-weight: var(--weight-medium);
  }

  [data-uiify-combobox] [data-part="control"] [data-part="tag-remove"] {
    display: inline-grid;
    place-items: center;
    inline-size: 1.25rem;
    block-size: 1.25rem;
    flex: 0 0 1.25rem;
    padding: 0;
    border: 0;
    border-radius: var(--radius-xs);
    corner-shape: squircle;
    background: transparent;
    color: inherit;
    font: inherit;
    line-height: 1;
    cursor: pointer;
  }

  [data-uiify-combobox] [data-part="control"] [data-part="tag-remove"]:hover,
  [data-uiify-combobox] [data-part="control"] [data-part="tag-remove"]:focus-visible {
    background-color: var(--primary);
    color: var(--primary-foreground);
  }

  [data-uiify-combobox] [data-part="input"] {
    inline-size: 100%;
    padding-block: var(--space-sm);
    padding-inline: var(--space-md);
    border: var(--border-width) solid var(--input);
    border-radius: var(--radius-md);
    corner-shape: squircle;
    background-color: var(--card);
    color: var(--foreground);
    font-size: var(--uiify-control-text);
    transition: var(--transition-colors);
  }

  [data-uiify-combobox] [data-part="input"]:focus-visible {
    border-color: var(--primary);
  }

  [data-uiify-combobox] [data-part="control"] [data-part="input"] {
    flex: 1 1 5rem;
    min-inline-size: 5rem;
    inline-size: auto;
    padding-block: var(--space-xs);
    padding-inline: 0;
    border: 0;
    border-radius: 0;
    background: transparent;
    outline: none;
    box-shadow: none;
  }

  [data-uiify-combobox] [data-part="content"]:where(:not([popover]), :popover-open) {
    display: flex;
  }

  [data-uiify-combobox] [data-part="content"] {
    flex-direction: column;
    min-inline-size: 12rem;
    max-block-size: min(24rem, calc(100dvb - 2rem));
    overflow-block: auto;
    overscroll-behavior: contain;
    padding: var(--space-xs);
    border: var(--border-width) solid var(--border);
    border-radius: var(--radius-lg);
    corner-shape: squircle;
    background-color: var(--card);
    color: var(--foreground);
    box-shadow: var(--shadow-popover);
    transform-origin: var(--transform-origin, top);
  }

  [data-uiify-combobox] [data-part="content"][data-state="closed"] {
    display: none;
  }

  [data-uiify-combobox] [data-part="item"] {
    display: flex;
    align-items: center;
    inline-size: 100%;
    padding-block: var(--space-sm);
    padding-inline: var(--space-md);
    border: 0;
    border-radius: calc(var(--radius-lg) - var(--space-xs));
    corner-shape: squircle;
    background: transparent;
    color: inherit;
    font: inherit;
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
    text-align: start;
    cursor: pointer;
    user-select: none;
  }

  [data-uiify-combobox] [data-part="item"]:hover,
  [data-uiify-combobox] [data-part="item"]:focus-visible,
  [data-uiify-combobox] [data-part="item"][data-highlighted],
  [data-uiify-combobox] [data-part="item"][aria-selected="true"],
  [data-uiify-combobox] [data-part="item"][data-selected] {
    background-color: var(--accent);
    color: var(--accent-foreground);
    outline: none;
  }

  [data-uiify-combobox] [data-part="item"][aria-disabled="true"],
  [data-uiify-combobox] [data-part="item"][data-disabled] {
    opacity: 0.5;
    pointer-events: none;
  }

  [data-uiify-combobox] [data-part="empty"] {
    padding: var(--space-sm) var(--space-md);
    color: var(--muted-foreground);
    font-size: var(--text-sm);
  }

  @media (prefers-reduced-motion: no-preference) {
    [data-uiify-combobox] [data-part="content"] {
      transition: var(--transition-presence);
    }

    [data-uiify-combobox] [data-part="content"][popover]:not(:popover-open) {
      opacity: 0;
      scale: var(--presence-scale);
    }

    @starting-style {
      [data-uiify-combobox] [data-part="content"]:popover-open {
        opacity: 0;
        scale: var(--presence-scale);
      }
    }
  }
}
"use client";

import { Combobox } from "@gunkin/uiify";

const skills = [
  { label: "React", value: "React" },
  { label: "UI", value: "UI" },
  { label: "TypeScript", value: "TypeScript" },
  { label: "Accessibility", value: "Accessibility" },
];

export default function Example() {
  return (
    <div className="relative w-full max-w-72">
      <Combobox.Root multiple defaultValue={["React", "UI"]} items={skills}>
        <Combobox.Control
          className="
            flex flex-wrap items-center gap-xs w-full min-h-10 py-sm px-md border-[length:var(--border-width)]
            border-solid border-input rounded-md [corner-shape:squircle] bg-card text-foreground
            [transition:var(--transition-colors)] focus-within:border-primary data-[disabled]:opacity-50
            data-[disabled]:cursor-not-allowed has-[[data-part=input]:disabled]:opacity-50
            has-[[data-part=input]:disabled]:cursor-not-allowed
          "
        >
          <Combobox.Tags
            className="
              flex flex-wrap items-center gap-xs [&>[data-part=tag]]:inline-flex [&>[data-part=tag]]:items-center
              [&>[data-part=tag]]:gap-xs [&>[data-part=tag]]:py-xs [&>[data-part=tag]]:px-sm
              [&>[data-part=tag]]:rounded-xs [&>[data-part=tag]]:[corner-shape:squircle] [&>[data-part=tag]]:bg-accent
              [&>[data-part=tag]]:text-accent-foreground [&>[data-part=tag]]:text-xs [&>[data-part=tag]]:font-medium
              [&_[data-part=tag-remove]]:inline-grid [&_[data-part=tag-remove]]:place-items-center
              [&_[data-part=tag-remove]]:size-5 [&_[data-part=tag-remove]]:flex-none [&_[data-part=tag-remove]]:p-0
              [&_[data-part=tag-remove]]:border-0 [&_[data-part=tag-remove]]:rounded-xs
              [&_[data-part=tag-remove]]:bg-transparent [&_[data-part=tag-remove]]:text-inherit
              [&_[data-part=tag-remove]]:[font:inherit] [&_[data-part=tag-remove]]:leading-none
              [&_[data-part=tag-remove]]:cursor-pointer [&_[data-part=tag-remove]:hover]:bg-primary
              [&_[data-part=tag-remove]:hover]:text-primary-foreground
              [&_[data-part=tag-remove]:focus-visible]:bg-primary
              [&_[data-part=tag-remove]:focus-visible]:text-primary-foreground
            "
          />
          <Combobox.Input
            className="
              w-full py-sm px-md border-[length:var(--border-width)] border-solid border-input rounded-md
              [corner-shape:squircle] bg-card text-foreground [font-size:var(--uiify-control-text)]
              [transition:var(--transition-colors)] focus-visible:border-primary [[data-part=control]_&]:flex-[1_1_5rem]
              [[data-part=control]_&]:[min-inline-size:5rem] [[data-part=control]_&]:[inline-size:auto]
              [[data-part=control]_&]:py-xs [[data-part=control]_&]:px-0 [[data-part=control]_&]:border-0
              [[data-part=control]_&]:rounded-none [[data-part=control]_&]:bg-transparent
              [[data-part=control]_&]:[outline:none] [[data-part=control]_&]:[box-shadow:none]
            "
            aria-label="Add tag"
            placeholder="Add tag"
          />
        </Combobox.Control>
        <Combobox.Content
          className="
            [&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)]
            [&_[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)]
            [&[popover]:popover-open:not([data-positioning=native])]:[margin:revert]
            [&_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] flex-col [min-inline-size:12rem]
            [max-block-size:min(24rem,calc(100dvb_-_2rem))] overflow-y-auto overscroll-contain p-xs
            border-[length:var(--border-width)] border-solid border-border rounded-lg [corner-shape:squircle] bg-card
            text-foreground shadow-popover [transform-origin:var(--transform-origin,top)] data-[state=closed]:hidden
            [&:where(:not([popover]),:popover-open)]:flex motion-safe:[transition:var(--transition-presence)]
            motion-safe:data-[state=closed]:opacity-0 motion-safe:data-[state=closed]:[scale:var(--presence-scale)]
            motion-safe:starting:[&:popover-open]:opacity-0
            motion-safe:starting:[&:popover-open]:[scale:var(--presence-scale)]
          "
        >
          {(items) =>
            items.length === 0 ? (
              <Combobox.Empty>Nothing matched. Try another search.</Combobox.Empty>
            ) : (
              items.map((item) => (
                <Combobox.Item
                  className="
                    flex items-center w-full py-sm px-md border-0 rounded-[calc(var(--radius-lg)_-_var(--space-xs))]
                    [corner-shape:squircle] bg-transparent text-inherit [font:inherit] text-sm
                    [line-height:var(--leading-normal)] text-start cursor-pointer select-none hover:bg-accent
                    hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground
                    focus-visible:outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
                    aria-[selected=true]:bg-accent aria-[selected=true]:text-accent-foreground data-[selected]:bg-accent
                    data-[selected]:text-accent-foreground aria-[disabled=true]:opacity-50
                    aria-[disabled=true]:pointer-events-none data-[disabled]:opacity-50
                    data-[disabled]:pointer-events-none
                  "
                  item={item}
                  key={item.value}
                />
              ))
            )
          }
        </Combobox.Content>
      </Combobox.Root>
    </div>
  );
}
/* app.css */

@layer reset, ui, app, tokens, base, demo, components, utilities, theme;

@import "@gunkin/uiify/styles/reset";

@import "@gunkin/uiify/components/behavior.css";

@import "@gunkin/uiify/styles/tokens";

@import "@gunkin/uiify/styles/tailwind";

@import "tailwindcss/utilities.css";

@theme { --spacing: 0.25rem; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; }

@source inline('relative w-full max-w-72 flex flex-wrap items-center gap-xs min-h-10 py-sm px-md border-[length:var(--border-width)] border-solid border-input rounded-md [corner-shape:squircle] bg-card text-foreground [transition:var(--transition-colors)] focus-within:border-primary data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed has-[[data-part=input]:disabled]:opacity-50 has-[[data-part=input]:disabled]:cursor-not-allowed [&>[data-part=tag]]:inline-flex [&>[data-part=tag]]:items-center [&>[data-part=tag]]:gap-xs [&>[data-part=tag]]:py-xs [&>[data-part=tag]]:px-sm [&>[data-part=tag]]:rounded-xs [&>[data-part=tag]]:[corner-shape:squircle] [&>[data-part=tag]]:bg-accent [&>[data-part=tag]]:text-accent-foreground [&>[data-part=tag]]:text-xs [&>[data-part=tag]]:font-medium [&_[data-part=tag-remove]]:inline-grid [&_[data-part=tag-remove]]:place-items-center [&_[data-part=tag-remove]]:size-5 [&_[data-part=tag-remove]]:flex-none [&_[data-part=tag-remove]]:p-0 [&_[data-part=tag-remove]]:border-0 [&_[data-part=tag-remove]]:rounded-xs [&_[data-part=tag-remove]]:bg-transparent [&_[data-part=tag-remove]]:text-inherit [&_[data-part=tag-remove]]:[font:inherit] [&_[data-part=tag-remove]]:leading-none [&_[data-part=tag-remove]]:cursor-pointer [&_[data-part=tag-remove]:hover]:bg-primary [&_[data-part=tag-remove]:hover]:text-primary-foreground [&_[data-part=tag-remove]:focus-visible]:bg-primary [&_[data-part=tag-remove]:focus-visible]:text-primary-foreground [font-size:var(--uiify-control-text)] focus-visible:border-primary [[data-part=control]_&]:flex-[1_1_5rem] [[data-part=control]_&]:[min-inline-size:5rem] [[data-part=control]_&]:[inline-size:auto] [[data-part=control]_&]:py-xs [[data-part=control]_&]:px-0 [[data-part=control]_&]:border-0 [[data-part=control]_&]:rounded-none [[data-part=control]_&]:bg-transparent [[data-part=control]_&]:[outline:none] [[data-part=control]_&]:[box-shadow:none] [&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&_[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&[popover]:popover-open:not([data-positioning=native])]:[margin:revert] [&_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] flex-col [min-inline-size:12rem] [max-block-size:min(24rem,calc(100dvb_-_2rem))] overflow-y-auto overscroll-contain p-xs border-border rounded-lg shadow-popover [transform-origin:var(--transform-origin,top)] data-[state=closed]:hidden [&:where(:not([popover]),:popover-open)]:flex motion-safe:[transition:var(--transition-presence)] motion-safe:data-[state=closed]:opacity-0 motion-safe:data-[state=closed]:[scale:var(--presence-scale)] motion-safe:starting:[&:popover-open]:opacity-0 motion-safe:starting:[&:popover-open]:[scale:var(--presence-scale)] border-0 rounded-[calc(var(--radius-lg)_-_var(--space-xs))] bg-transparent text-inherit [font:inherit] text-sm [line-height:var(--leading-normal)] text-start cursor-pointer select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground aria-[selected=true]:bg-accent aria-[selected=true]:text-accent-foreground data-[selected]:bg-accent data-[selected]:text-accent-foreground aria-[disabled=true]:opacity-50 aria-[disabled=true]:pointer-events-none data-[disabled]:pointer-events-none');

@layer base { :root { --uiify-control-text: var(--text-sm); } @media (pointer: coarse) { :root { --uiify-control-text: var(--text-base); } } }

/* Authored animation and registered properties used by the Tailwind skin.
 * Visual rules belong in utility recipes; only at-rule definitions live here. */
@layer components {
  @keyframes uiify-presence-in {
    from {
      opacity: 0;
      scale: var(--presence-scale);
    }
  }
}

@layer components {
  @keyframes uiify-presence-out {
    to {
      opacity: 0;
      scale: var(--presence-scale);
    }
  }
}

@property --uiify-spinner-tail {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --uiify-spinner-length {
  syntax: "<angle>";
  inherits: false;
  initial-value: 36deg;
}

@layer components {
  @keyframes uiify-spinner-rotate {
    to {
      transform: rotate(360deg);
    }
  }
}

@layer components {
  @keyframes uiify-spinner-orbit {
    0% {
      --uiify-spinner-tail: 0deg;
      --uiify-spinner-length: 36deg;
    }

    50% {
      --uiify-spinner-tail: 90deg;
      --uiify-spinner-length: 324deg;
    }

    100% {
      --uiify-spinner-tail: 360deg;
      --uiify-spinner-length: 36deg;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-pulse {
    50% {
      opacity: 0.45;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-shimmer {
    to {
      background-position: -200% 0;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-reveal {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
}

@property --candle-body-height {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-body-top {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --candle-slot {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 100%;
}

@property --candle-wick-height {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-wick-top {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --candle-x {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-scale {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@property --line-y {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --line-prev-y {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --line-count {
  syntax: "<integer>";
  inherits: false;
  initial-value: 1;
}

@property --line-scale {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@layer components {
  @keyframes uiify-otp-input-caret-blink {
    50% {
      opacity: 0;
    }
  }
}

API

Combobox.Root

属性类型默认值说明
childrenReactNode—Combobox组成部分:控件、标签、输入框、触发器和内容。
items*ComboboxItemData[]—供输入框过滤的完整选项列表。
defaultInputValuestring—输入值非受控时,输入框中的初始文本。
defaultOpenboolean—打开状态非受控时,列表是否初始打开。
defaultValuestring | readonly string[]—选择状态非受控时,首次渲染所选的一个或多个值。
multiplebooleanfalse允许将多个值作为标签选择和移除。
allowCustomValuebooleanfalse允许将不在items中的输入文本作为所选值。
addOnPastebooleantrue在多选模式下,从粘贴文本中添加多个由分隔符分开的值。
delimitersreadonly string[][","]在多选模式下,用于提交或拆分输入值的字符。
maxValuesnumber—多选模式下可选择的最大值数量。
validateValue(value: string) => boolean—返回false时拒绝自定义值。
filterFn(items: ComboboxItemData[], inputValue: string) => ComboboxItemData[]—替换内置过滤器,后者会随用户输入缩小项目范围。
inputValuestring—输入框中的文本,使输入值受控。
onInputValueChange(value: string) => void—输入框变化时,使用新文本调用。
onOpenChange(open: boolean, details: OpenChangeDetails) => void—列表打开或关闭时调用,并提供变更原因。
onValueChange((value: string) => void) | ((value: string[]) => void)—使用所选值或完整的已选值数组调用。
openboolean—列表是否打开,使打开状态受控。
valuestring | readonly string[]—所选的一个或多个值,使选择状态受控。

Combobox.Control

属性类型默认值说明
childrenReactNode—在共享视觉控件内渲染的标签和输入框。
classNamestring—应用于控件元素的类名。
renderReactElement | ((props, state) => ReactElement)—替换渲染元素,同时保留行为和无障碍连接关系。

Combobox.Tags

属性类型默认值说明
childrenReactNode—附加在自动生成的可移除标签之后的内容。
classNamestring—应用于自动生成的标签容器的类名。
renderReactElement | ((props, state) => ReactElement)—替换渲染元素,同时保留行为和无障碍连接关系。

Combobox.Input

属性类型默认值说明
classNamestring—应用于文本输入框的类名。
renderReactElement | ((props, state) => ReactElement)—替换渲染元素,同时保留行为和无障碍连接关系。

Combobox.Trigger

属性类型默认值说明
childrenReactNode—打开列表的按钮内容,例如图标。
classNamestring—应用于触发器按钮的类名。
renderReactElement | ((props, state) => ReactElement)—替换渲染元素,同时保留行为和无障碍连接关系。

Combobox.Content

属性类型默认值说明
align"start" | "center" | "end"—列表沿所选侧边相对于输入框的对齐方式。
children*(filteredItems: ComboboxItemData[]) => ReactNode—使用过滤后剩余的项目调用的渲染函数。
classNamestring—应用于列表弹出层的类名。
side"top" | "right" | "bottom" | "left"—列表位于输入框的哪一侧。

Combobox.Item

属性类型默认值说明
classNamestring—应用于选项元素的类名。
item*ComboboxItemData—此选项渲染的项目,通常来自content回调。

Combobox.Empty

属性类型默认值说明
childrenReactNode—过滤后没有项目时显示的内容。
classNamestring—应用于空状态元素的类名。

ComboboxItemData

属性类型默认值说明
disabledboolean—使此项目不可选择。
label*string—该项目显示的文本,也是过滤器匹配的文本。
value*string—选择该项目时报告的值。

键盘

按键操作
↓ / ↑打开并在选项间移动焦点
Enter选择
Escape关闭
a–z键入字符以过滤选项

无障碍

ARIA组合框结合listbox。通过aria-activedescendant播报活动选项。Content暴露open/closed状态;选项暴露highlighted、disabled和多选模式下的selected状态。无匹配项时使用Empty作出说明。启用减少动态效果时,禁用弹出层的出现和消失动画。

样式钩子

data-statedata-highlighteddata-disableddata-selected
本页由机器翻译,尚未经人工审核。