Skip to content

Commit

Permalink
feat: add Combobox component (#243)
Browse files Browse the repository at this point in the history
Co-authored-by: Hunter Johnston <johnstonhuntera@gmail.com>
Co-authored-by: Hunter Johnston <64506580+huntabyte@users.noreply.github.com>
  • Loading branch information
3 people authored Feb 10, 2024
1 parent f58e9fb commit d1b0fc9
Show file tree
Hide file tree
Showing 26 changed files with 1,514 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/moody-apes-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": minor
---

New Component: [Combobox](https://bits-ui.com/docs/components/combobox)
39 changes: 39 additions & 0 deletions content/components/combobox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
title: Combobox
description: Enables users to pick from a list of options displayed in a dropdown.
---

<script>
import { APISection, ComponentPreview, ComboboxDemo } from '@/components'
export let schemas;
</script>

<ComponentPreview name="combobox-demo" comp="combobox">

<ComboboxDemo slot="preview" />

</ComponentPreview>

## Structure

```svelte
<script lang="ts">
import { Combobox } from "bits-ui";
</script>
<Combobox.Root>
<Combobox.Input />
<Combobox.Label />
<Combobox.Content>
<Combobox.Item>
<Combobox.ItemIndicator />
</Combobox.Item>
<Combobox.Separator>
</Combobox.Content>
<Combobox.Arrow />
<Combobox.HiddenInput />
</Combobox.Root>
```

<APISection {schemas} />
53 changes: 53 additions & 0 deletions src/components/demos/combobox-demo.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script lang="ts">
import { Combobox } from "$lib";
import { flyAndScale } from "@/utils";
import { Check, OrangeSlice, CaretUpDown } from "$icons/index.js";
const fruits = [
{ value: "mango", label: "Mango" },
{ value: "watermelon", label: "Watermelon" },
{ value: "apple", label: "Apple" },
{ value: "pineapple", label: "Pineapple" },
{ value: "orange", label: "Orange" },
];
let inputValue = "";
$: filteredFruits = inputValue
? fruits.filter((fruit) => fruit.value.includes(inputValue.toLowerCase()))
: fruits;
</script>

<Combobox.Root items={filteredFruits} bind:inputValue>
<div class="relative">
<OrangeSlice class="absolute start-3 top-1/2 size-6 -translate-y-1/2 text-muted-foreground" />
<Combobox.Input
class="inline-flex h-input w-[296px] truncate rounded-9px border border-border-input bg-background px-11 text-sm transition-colors placeholder:text-foreground-alt/50 focus:outline-none focus:ring-2 focus:ring-foreground focus:ring-offset-2 focus:ring-offset-background"
placeholder="Select a fruit"
aria-label="Select a fruit"
/>
<CaretUpDown class="absolute end-3 top-1/2 size-6 -translate-y-1/2 text-muted-foreground" />
</div>

<Combobox.Content
class="w-full rounded-xl border border-muted bg-background px-1 py-3 shadow-popover outline-none"
transition={flyAndScale}
sideOffset={8}
>
{#each filteredFruits as fruit (fruit.value)}
<Combobox.Item
class="flex h-10 w-full select-none items-center rounded-button py-3 pl-5 pr-1.5 text-sm capitalize outline-none transition-all duration-75 data-[highlighted]:bg-muted"
value={fruit.value}
label={fruit.label}
>
{fruit.label}
<Combobox.ItemIndicator class="ml-auto" asChild={false}>
<Check />
</Combobox.ItemIndicator>
</Combobox.Item>
{:else}
<span class="block px-5 py-2 text-sm text-muted-foreground"> No results found </span>
{/each}
</Combobox.Content>
<Combobox.HiddenInput name="favoriteFruit" />
</Combobox.Root>
1 change: 1 addition & 0 deletions src/components/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { default as ButtonDemo } from "./button-demo.svelte";
export { default as CalendarDemo } from "./calendar-demo.svelte";
export { default as CheckboxDemo } from "./checkbox-demo.svelte";
export { default as CollapsibleDemo } from "./collapsible-demo.svelte";
export { default as ComboboxDemo } from "./combobox-demo.svelte";
export { default as ContextMenuDemo } from "./context-menu-demo.svelte";
export { default as DateFieldDemo } from "./date-field-demo.svelte";
export { default as DateRangeFieldDemo } from "./date-range-field-demo.svelte";
Expand Down
1 change: 1 addition & 0 deletions src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export { default as Compass } from "phosphor-svelte/lib/Compass";
export { default as Sticker } from "phosphor-svelte/lib/Sticker";
export { default as UserCircle } from "phosphor-svelte/lib/UserCircle";
export { default as PlusCircle } from "phosphor-svelte/lib/PlusCircle";
export { default as OrangeSlice } from "phosphor-svelte/lib/OrangeSlice";

export type IconProps = Partial<HTMLAttributes<SVGElement>> & {
class?: string;
Expand Down
8 changes: 6 additions & 2 deletions src/config/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ export const navigation: Navigation = {
{
title: "Calendar",
href: "/docs/components/calendar",
label: "New",
items: [],
},
{
Expand All @@ -96,6 +95,12 @@ export const navigation: Navigation = {
href: "/docs/components/collapsible",
items: [],
},
{
title: "Combobox",
href: "/docs/components/combobox",
label: "New",
items: [],
},
{
title: "Context Menu",
href: "/docs/components/context-menu",
Expand Down Expand Up @@ -154,7 +159,6 @@ export const navigation: Navigation = {
{
title: "PIN Input",
href: "/docs/components/pin-input",
label: "New",
items: [],
},
{
Expand Down
251 changes: 251 additions & 0 deletions src/content/api-reference/combobox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import type { APISchema } from "@/types/index.js";
import {
arrowProps,
asChild,
attrsSlotProp,
domElProps,
enums,
idsSlotProp,
portalProp,
transitionProps,
builderAndAttrsSlotProps,
onOutsideClickProp,
} from "@/content/api-reference/helpers.js";
import { floatingPositioning } from "./floating.js";
import * as C from "@/content/constants.js";
import type * as Combobox from "$lib/bits/combobox/_types.js";

export const root: APISchema<Combobox.Props> = {
title: "Root",
description: "The root combobox component which manages & scopes the state of the select.",
props: {
disabled: {
default: C.FALSE,
type: C.BOOLEAN,
description: "Whether or not the combobox component is disabled.",
},
multiple: {
default: C.FALSE,
type: C.BOOLEAN,
description: "Whether or not the combobox menu allows multiple selections.",
},
preventScroll: {
default: C.TRUE,
type: C.BOOLEAN,
description: "Whether or not to prevent scrolling the body when the menu is open.",
},
closeOnEscape: {
default: C.TRUE,
type: C.BOOLEAN,
description: "Whether to close the combobox menu when the escape key is pressed.",
},
closeOnOutsideClick: {
type: C.BOOLEAN,
default: C.TRUE,
description: "Whether to close the combobox menu when a click occurs outside of it.",
},
loop: {
type: C.BOOLEAN,
default: C.FALSE,
description:
"Whether or not to loop through the menu items when navigating with the keyboard.",
},
open: {
type: C.BOOLEAN,
default: C.FALSE,
description: "The open state of the combobox menu.",
},
onOpenChange: {
type: {
type: C.FUNCTION,
definition: "(open: boolean) => void",
},
description: "A callback that is fired when the combobox menu's open state changes.",
},
selected: {
type: {
type: C.OBJECT,
definition: "{ value: unknown; label?: string }",
},
description: "The value of the currently selected item.",
},
onSelectedChange: {
type: {
type: C.FUNCTION,
definition: "(value: unknown | undefined) => void",
},
description: "A callback that is fired when the combobox menu's value changes.",
},
portal: { ...portalProp("combobox menu") },
highlightOnHover: {
type: C.BOOLEAN,
default: C.TRUE,
description: "Whether or not to highlight the currently hovered item.",
},
name: {
type: C.STRING,
description: "The name to apply to the hidden input element for form submission.",
},
required: {
default: C.FALSE,
type: C.BOOLEAN,
description: "Whether or not the combobox menu is required.",
},
scrollAlignment: {
default: "'nearest'",
type: {
type: C.ENUM,
definition: enums("nearest", "center"),
},
description: "The alignment of the highlighted item when scrolling.",
},
inputValue: {
default: "",
type: C.STRING,
description: "The value of the combobox input element.",
},
items: {
type: {
type: "Selected[]",
definition: "Array<{ value: T; label?: string }>",
},
description: "An array of items to add type-safety to the `onSelectedChange` callback.",
},
onOutsideClick: onOutsideClickProp,
},
slotProps: { ids: idsSlotProp },
};

export const content: APISchema<Combobox.ContentProps> = {
title: "Content",
description: "The element which contains the combobox menu's items.",
props: { ...transitionProps, ...floatingPositioning, ...domElProps("HTMLDivElement") },
slotProps: { ...builderAndAttrsSlotProps },
dataAttributes: [
{
name: "combobox-content",
description: "Present on the content element.",
},
],
};

export const item: APISchema<Combobox.ItemProps> = {
title: "Item",
description: "A combobox item, which must be a child of the `Combobox.Content` component.",
props: {
label: {
type: C.STRING,
description: "The label of the select item, which is displayed in the menu.",
},
value: {
type: C.UNKNOWN,
description: "The value of the select item.",
},
disabled: {
type: C.BOOLEAN,
default: C.FALSE,
description:
"Whether or not the combobox item is disabled. This will prevent interaction/selection.",
},
...domElProps("HTMLDivElement"),
},
slotProps: { ...builderAndAttrsSlotProps },
dataAttributes: [
{
name: "state",
description: "The state of the item.",
value: enums("selected", "hovered"),
isEnum: true,
},
{
name: "disabled",
description: "Present when the item is disabled.",
},
{
name: "combobox-item",
description: "Present on the item element.",
},
],
};

export const input: APISchema = {
title: "Input",
description:
"A representation of the combobox input element, which is typically displayed in the content.",
props: {
placeholder: {
type: C.STRING,
description: "A placeholder value to display when no value is selected.",
},
asChild,
},
slotProps: {
attrs: attrsSlotProp,
label: {
type: C.STRING,
description: "The label of the currently selected item.",
},
},
dataAttributes: [
{
name: "select-input",
description: "Present on the input element.",
},
],
};

export const hiddenInput: APISchema<Combobox.InputProps> = {
title: "hidden-input",
description:
"A hidden input element which is used to store the combobox menu's value, used for form submission. It receives the same value as the `Select.Value` component and can receive any props that a normal input element can receive.",
props: domElProps("HTMLInputElement"),
slotProps: { ...builderAndAttrsSlotProps },
};

export const label: APISchema<Combobox.LabelProps> = {
title: "Label",
description:
"A label for the combobox input element, which is typically displayed in the content.",
props: domElProps("HTMLLabelElement"),
slotProps: { ...builderAndAttrsSlotProps },
dataAttributes: [
{
name: "combobox-label",
description: "Present on the label element.",
},
],
};

export const indicator: APISchema<Combobox.IndicatorProps> = {
title: "Indicator",
description: "A visual indicator for use between combobox items or groups.",
props: domElProps("HTMLDivElement"),
slotProps: {
attrs: attrsSlotProp,
isSelected: {
type: C.BOOLEAN,
description: "Whether or not the item is selected.",
},
},
dataAttributes: [
{
name: "combobox-indicator",
description: "Present on the indicator element.",
},
],
};

export const arrow: APISchema<Combobox.ArrowProps> = {
title: "Arrow",
description: "An optional arrow element which points to the selected item when menu open.",
props: arrowProps,
slotProps: { ...builderAndAttrsSlotProps },
dataAttributes: [
{
name: "arrow",
description: "Present on the arrow element.",
},
],
};

export const combobox = [root, content, item, input, label, hiddenInput, arrow];
Loading

0 comments on commit d1b0fc9

Please sign in to comment.