diff --git a/.npmrc b/.npmrc index 4c2f52b3..ab0b6bd7 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,4 @@ auto-install-peers=true strict-peer-dependencies=false +link-workspace-packages=true +prefer-workspace-packages=true \ No newline at end of file diff --git a/apps/docs/package.json b/apps/docs/package.json index f25b6330..5dab22e0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -34,37 +34,38 @@ "dependencies": { "@docsearch/css": "3.5.2", "@docsearch/js": "3.5.2", - "@kobalte/core": "0.13.7", + "@kobalte/core": "workspace:*", "@solidjs/meta": "0.29.3", "@solidjs/router": "0.12.4", + "@solidjs/start": "0.6.1", "@tanstack/solid-virtual": "3.0.0-beta.6", "clsx": "2.0.0", + "minisearch": "7.1.0", "solid-js": "1.8.15", - "@solidjs/start": "0.6.1", - "vinxi": "0.3.9", - "undici": "5.23.0" + "undici": "5.23.0", + "vinxi": "0.3.9" }, "devDependencies": { "@kobalte/tailwindcss": "0.9.0", "@mdx-js/mdx": "3.0.0", "@mdx-js/rollup": "3.0.0", "@tailwindcss/typography": "0.5.9", + "@vinxi/plugin-mdx": "3.7.1", "acorn": "8.10.0", "autoprefixer": "10.4.15", "github-slugger": "2.0.0", "postcss": "8.4.28", "rehype-pretty-code": "0.12.3", - "remark-shiki-twoslash": "3.1.3", "rehype-raw": "7.0.0", "rehype-slug": "6.0.0", "remark-gfm": "4.0.0", + "remark-shiki-twoslash": "3.1.3", "shiki": "0.14.7", "solid-mdx": "0.0.7", "tailwindcss": "3.3.3", "typescript": "4.9.5", "unist-util-visit": "5.0.0", - "vite": "5.1.4", - "@vinxi/plugin-mdx": "3.7.1" + "vite": "5.1.4" }, "engines": { "node": ">=18" diff --git a/apps/docs/src/components/icons.tsx b/apps/docs/src/components/icons.tsx index f38bb1a8..73ad954a 100644 --- a/apps/docs/src/components/icons.tsx +++ b/apps/docs/src/components/icons.tsx @@ -269,3 +269,41 @@ export function ArrowIcon(props: ComponentProps<"svg">) { ); } + +export function MagnifyingGlassIcon(props: ComponentProps<"svg">) { + return ( + + Magnifying Glass + + + ); +} + +export function ReloadIcon(props: ComponentProps<"svg">) { + return ( + + Reload + + + ); +} diff --git a/apps/docs/src/examples/search.module.css b/apps/docs/src/examples/search.module.css new file mode 100644 index 00000000..fef753d0 --- /dev/null +++ b/apps/docs/src/examples/search.module.css @@ -0,0 +1,285 @@ +.search__control { + overflow: hidden; + display: inline-flex; + justify-content: space-between; + width: 250px; + border-radius: 6px; + font-size: 16px; + line-height: 1; + outline: none; + background-color: white; + border: 1px solid hsl(240 6% 90%); + color: hsl(240 4% 16%); + transition: + border-color 250ms, + color 250ms; +} + +.search__control_multi { + width: 100%; + min-width: 250px; + max-width: 300px; +} + +.search__input { + appearance: none; + display: inline-flex; + width: 100%; + min-height: 40px; + padding-left: 16px; + font-size: 16px; + background: transparent; + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + outline: none; +} + +.search__input::placeholder { + color: hsl(240 4% 46%); +} + +.search__indicator { + appearance: none; + display: inline-flex; + justify-content: center; + align-items: center; + width: auto; + outline: none; + padding: 0 10px; + background-color: hsl(240 5% 96%); + border-right: 1px solid hsl(240 6% 90%); + color: hsl(240 4% 16%); + font-size: 16px; + line-height: 0; + transition: 250ms background-color; +} + +.search__icon { + height: 20px; + width: 20px; + flex: 0 0 16px; + display: grid; + justify-items: center; +} + +.load__icon { + height: 20px; + width: 20px; + display: grid; + justify-items: center; + flex: 0 0 14px; +} + +.center__icon { + margin: auto; +} + +.spin__icon { + animation: spin 600ms linear; + animation-iteration-count: infinite; + margin: auto; +} + +.search__description { + margin-top: 8px; + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; +} + +.search__content { + background-color: white; + border-radius: 6px; + border: 1px solid hsl(240 6% 90%); + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1); + transform-origin: var(--kb-search-content-transform-origin); + animation: contentHide 250ms ease-in forwards; +} + +.search__content[data-expanded] { + animation: contentShow 250ms ease-out; +} + +.search__listbox { + overflow-y: auto; + max-height: 360px; + padding: 8px; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr; + row-gap: 6px; + column-gap: 6px; + line-height: 1; +} + +.search__listbox:focus { + outline: none; +} + +.search__item { + font-size: 24px; + line-height: 1; + color: hsl(240 4% 16%); + border-radius: 16px; + padding: 8px; + position: relative; + user-select: none; + outline: none; + display: grid; + justify-items: center; +} + +.search__item[data-disabled] { + color: hsl(240 5% 65%); + opacity: 0.5; + pointer-events: none; +} + +.search__item[data-highlighted] { + outline: none; + box-sizing: border-box; + box-shadow: inset 0 0 0 2px hsl(200 98% 39%); +} + +.search__section { + padding: 8px 0 0 8px; + font-size: 14px; + line-height: 32px; + color: hsl(240 4% 46%); +} + +.search__no_result { + text-align: center; + padding: 8px; + padding-bottom: 24px; + margin: auto; + color: hsl(240 4% 46%); +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes contentShow { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes contentHide { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +[data-kb-theme="dark"] .search__control { + background-color: hsl(240 4% 16%); + border: 1px solid hsl(240 5% 34%); + color: hsl(0 100% 100% / 0.9); +} + +[data-kb-theme="dark"] .search__control:hover { + border-color: hsl(240 4% 46%); +} + +[data-kb-theme="dark"] .search__input::placeholder { + color: hsl(0 100% 100% / 0.5); +} + +[data-kb-theme="dark"] .search__indicator { + background: hsl(240 5% 26%); + border-right: 1px solid hsl(240 5% 34%); + color: hsl(0 100% 100% / 0.9); +} + +[data-kb-theme="dark"] .search__content { + border: 1px solid hsl(240 5% 26%); + background-color: hsl(240 4% 16%); + box-shadow: none; +} + +[data-kb-theme="dark"] .search__section { + color: hsl(0 100% 100% / 0.7); +} + +[data-kb-theme="dark"] .search__item { + color: hsl(0 100% 100% / 0.9); +} + +[data-kb-theme="dark"] .search__description { + color: hsl(0 100% 100% / 0.7); +} + +.result__content { + margin-top: 16px; + font-size: 16px; + line-height: 1; +} + +.search__root_cmdk { + min-height: 240px; + min-width: 250px; + display: flex; + flex-direction: column; +} + +.search__control_cmdk { + overflow: hidden; + display: inline-flex; + justify-content: space-between; + border-radius: 12px 12px 0 0; + font-size: 16px; + line-height: 1; + outline: none; + background-color: white; + border: 1px solid hsl(240 6% 90%); + color: hsl(240 4% 16%); + transition: + border-color 250ms, + color 250ms; +} + +.search__content_cmdk { + height: 100%; + flex-grow: 1; + background-color: white; + border-radius: 0 0 12px 12px; + border: 1px solid hsl(240 6% 90%); + border-top: none; + display: flex; + flex-direction: column; +} + +.search__no_result_cmdk { + text-align: center; + margin: auto; + padding-bottom: 24px; + color: hsl(240 4% 46%); +} + +[data-kb-theme="dark"] .search__control_cmdk { + background-color: hsl(240 4% 16%); + border: 1px solid hsl(240 5% 34%); + color: hsl(0 100% 100% / 0.9); +} + +[data-kb-theme="dark"] .search__content_cmdk { + background-color: hsl(240 4% 16%); + border: 1px solid hsl(240 5% 34%); +} diff --git a/apps/docs/src/examples/search.tsx b/apps/docs/src/examples/search.tsx new file mode 100644 index 00000000..4409d76b --- /dev/null +++ b/apps/docs/src/examples/search.tsx @@ -0,0 +1,707 @@ +import MiniSearch from "minisearch"; +import { createSignal } from "solid-js"; +import { Search } from "../../../../packages/core/src/search"; +import { MagnifyingGlassIcon, ReloadIcon } from "../components"; +import style from "./search.module.css"; + +type EmojiDatum = { + emoji: string; + name: string; + id?: number; +}; + +const RAW_EMOJI_DATA: EmojiDatum[] = ([] as any) + .concat( + ...Object.values({ + "face-smiling": [ + { + emoji: "๐Ÿ˜€", + name: "grinning face", + }, + { + emoji: "๐Ÿ˜ƒ", + name: "grinning face with big eyes", + }, + { + emoji: "๐Ÿ˜„", + name: "grinning face with smiling eyes", + }, + { + emoji: "๐Ÿ˜", + name: "beaming face with smiling eyes", + }, + { + emoji: "๐Ÿ˜†", + name: "grinning squinting face", + }, + { + emoji: "๐Ÿ˜…", + name: "grinning face with sweat", + }, + { + emoji: "๐Ÿคฃ", + name: "rolling on the floor laughing", + }, + { + emoji: "๐Ÿ˜‚", + name: "face with tears of joy", + }, + { + emoji: "๐Ÿ™‚", + name: "slightly smiling face", + }, + { + emoji: "๐Ÿ™ƒ", + name: "upside-down face", + }, + { + emoji: "๐Ÿซ ", + name: "melting face", + }, + { + emoji: "๐Ÿ˜‰", + name: "winking face", + }, + { + emoji: "๐Ÿ˜Š", + name: "smiling face with smiling eyes", + }, + { + emoji: "๐Ÿ˜‡", + name: "smiling face with halo", + }, + ], + "face-affection": [ + { + emoji: "๐Ÿฅฐ", + name: "smiling face with hearts", + }, + { + emoji: "๐Ÿ˜", + name: "smiling face with heart-eyes", + }, + { + emoji: "๐Ÿคฉ", + name: "star-struck", + }, + { + emoji: "๐Ÿ˜˜", + name: "face blowing a kiss", + }, + { + emoji: "๐Ÿ˜—", + name: "kissing face", + }, + { + emoji: "๐Ÿ˜š", + name: "kissing face with closed eyes", + }, + { + emoji: "๐Ÿ˜™", + name: "kissing face with smiling eyes", + }, + { + emoji: "๐Ÿฅฒ", + name: "smiling face with tear", + }, + ], + "face-tongue": [ + { + emoji: "๐Ÿ˜‹", + name: "face savoring food", + }, + { + emoji: "๐Ÿ˜›", + name: "face with tongue", + }, + { + emoji: "๐Ÿ˜œ", + name: "winking face with tongue", + }, + { + emoji: "๐Ÿคช", + name: "zany face", + }, + { + emoji: "๐Ÿ˜", + name: "squinting face with tongue", + }, + { + emoji: "๐Ÿค‘", + name: "money-mouth face", + }, + ], + "face-hand": [ + { + emoji: "๐Ÿค—", + name: "smiling face with open hands", + }, + { + emoji: "๐Ÿคญ", + name: "face with hand over mouth", + }, + { + emoji: "๐Ÿซข", + name: "face with open eyes and hand over mouth", + }, + { + emoji: "๐Ÿซฃ", + name: "face with peeking eye", + }, + { + emoji: "๐Ÿคซ", + name: "shushing face", + }, + { + emoji: "๐Ÿค”", + name: "thinking face", + }, + ], + "face-neutral-skeptical": [ + { + emoji: "๐Ÿค", + name: "zipper-mouth face", + }, + { + emoji: "๐Ÿคจ", + name: "face with raised eyebrow", + }, + { + emoji: "๐Ÿ˜", + name: "neutral face", + }, + { + emoji: "๐Ÿ˜‘", + name: "expressionless face", + }, + { + emoji: "๐Ÿ˜ถ", + name: "face without mouth", + }, + { + emoji: "๐Ÿ˜ถโ€๐ŸŒซ๏ธ", + name: "face in clouds", + }, + { + emoji: "๐Ÿ˜", + name: "smirking face", + }, + { + emoji: "๐Ÿ˜’", + name: "unamused face", + }, + { + emoji: "๐Ÿ™„", + name: "face with rolling eyes", + }, + { + emoji: "๐Ÿ˜ฌ", + name: "grimacing face", + }, + { + emoji: "๐Ÿ˜ฎโ€๐Ÿ’จ", + name: "face exhaling", + }, + { + emoji: "๐Ÿคฅ", + name: "lying face", + }, + { + emoji: "๐Ÿซจ", + name: "shaking face", + }, + ], + "face-sleepy": [ + { + emoji: "๐Ÿ˜Œ", + name: "relieved face", + }, + { + emoji: "๐Ÿ˜”", + name: "pensive face", + }, + { + emoji: "๐Ÿ˜ช", + name: "sleepy face", + }, + { + emoji: "๐Ÿคค", + name: "drooling face", + }, + { + emoji: "๐Ÿ˜ด", + name: "sleeping face", + }, + ], + "face-unwell": [ + { + emoji: "๐Ÿ˜ท", + name: "face with medical mask", + }, + { + emoji: "๐Ÿค’", + name: "face with thermometer", + }, + { + emoji: "๐Ÿค•", + name: "face with head-bandage", + }, + { + emoji: "๐Ÿคข", + name: "nauseated face", + }, + { + emoji: "๐Ÿคฎ", + name: "face vomiting", + }, + { + emoji: "๐Ÿคง", + name: "sneezing face", + }, + { + emoji: "๐Ÿฅต", + name: "hot face", + }, + { + emoji: "๐Ÿฅถ", + name: "cold face", + }, + { + emoji: "๐Ÿฅด", + name: "woozy face", + }, + { + emoji: "๐Ÿ˜ต", + name: "face with crossed-out eyes", + }, + { + emoji: "๐Ÿ˜ตโ€๐Ÿ’ซ", + name: "face with spiral eyes", + }, + { + emoji: "๐Ÿคฏ", + name: "exploding head", + }, + ], + "face-hat": [ + { + emoji: "๐Ÿค ", + name: "cowboy hat face", + }, + { + emoji: "๐Ÿฅณ", + name: "partying face", + }, + { + emoji: "๐Ÿฅธ", + name: "disguised face", + }, + ], + "face-glasses": [ + { + emoji: "๐Ÿ˜Ž", + name: "smiling face with sunglasses", + }, + { + emoji: "๐Ÿค“", + name: "nerd face", + }, + { + emoji: "๐Ÿง", + name: "face with monocle", + }, + ], + "face-concerned": [ + { + emoji: "๐Ÿ˜•", + name: "confused face", + }, + { + emoji: "๐Ÿซค", + name: "face with diagonal mouth", + }, + { + emoji: "๐Ÿ˜Ÿ", + name: "worried face", + }, + { + emoji: "๐Ÿ™", + name: "slightly frowning face", + }, + { + emoji: "๐Ÿ˜ฎ", + name: "face with open mouth", + }, + { + emoji: "๐Ÿ˜ฏ", + name: "hushed face", + }, + { + emoji: "๐Ÿ˜ฒ", + name: "astonished face", + }, + { + emoji: "๐Ÿ˜ณ", + name: "flushed face", + }, + { + emoji: "๐Ÿฅบ", + name: "pleading face", + }, + { + emoji: "๐Ÿฅน", + name: "face holding back tears", + }, + { + emoji: "๐Ÿ˜ฆ", + name: "frowning face with open mouth", + }, + { + emoji: "๐Ÿ˜ง", + name: "anguished face", + }, + { + emoji: "๐Ÿ˜จ", + name: "fearful face", + }, + { + emoji: "๐Ÿ˜ฐ", + name: "anxious face with sweat", + }, + { + emoji: "๐Ÿ˜ฅ", + name: "sad but relieved face", + }, + { + emoji: "๐Ÿ˜ข", + name: "crying face", + }, + { + emoji: "๐Ÿ˜ญ", + name: "loudly crying face", + }, + { + emoji: "๐Ÿ˜ฑ", + name: "face screaming in fear", + }, + { + emoji: "๐Ÿ˜–", + name: "confounded face", + }, + { + emoji: "๐Ÿ˜ฃ", + name: "persevering face", + }, + { + emoji: "๐Ÿ˜ž", + name: "disappointed face", + }, + { + emoji: "๐Ÿ˜“", + name: "downcast face with sweat", + }, + { + emoji: "๐Ÿ˜ฉ", + name: "weary face", + }, + { + emoji: "๐Ÿ˜ซ", + name: "tired face", + }, + { + emoji: "๐Ÿฅฑ", + name: "yawning face", + }, + ], + "face-negative": [ + { + emoji: "๐Ÿ˜ค", + name: "face with steam from nose", + }, + { + emoji: "๐Ÿ˜ก", + name: "enraged face", + }, + { + emoji: "๐Ÿ˜ ", + name: "angry face", + }, + { + emoji: "๐Ÿคฌ", + name: "face with symbols on mouth", + }, + { + emoji: "๐Ÿ˜ˆ", + name: "smiling face with horns", + }, + { + emoji: "๐Ÿ‘ฟ", + name: "angry face with horns", + }, + { + emoji: "๐Ÿ’€", + name: "skull", + }, + ], + "face-costume": [ + { + emoji: "๐Ÿ’ฉ", + name: "pile of poo", + }, + { + emoji: "๐Ÿคก", + name: "clown face", + }, + { + emoji: "๐Ÿ‘น", + name: "ogre", + }, + { + emoji: "๐Ÿ‘บ", + name: "goblin", + }, + { + emoji: "๐Ÿ‘ป", + name: "ghost", + }, + { + emoji: "๐Ÿ‘ฝ", + name: "alien", + }, + { + emoji: "๐Ÿ‘พ", + name: "alien monster", + }, + { + emoji: "๐Ÿค–", + name: "robot", + }, + ], + "cat-face": [ + { + emoji: "๐Ÿ˜บ", + name: "grinning cat", + }, + { + emoji: "๐Ÿ˜ธ", + name: "grinning cat with smiling eyes", + }, + { + emoji: "๐Ÿ˜น", + name: "cat with tears of joy", + }, + { + emoji: "๐Ÿ˜ป", + name: "smiling cat with heart-eyes", + }, + { + emoji: "๐Ÿ˜ผ", + name: "cat with wry smile", + }, + { + emoji: "๐Ÿ˜ฝ", + name: "kissing cat", + }, + { + emoji: "๐Ÿ™€", + name: "weary cat", + }, + { + emoji: "๐Ÿ˜ฟ", + name: "crying cat", + }, + { + emoji: "๐Ÿ˜พ", + name: "pouting cat", + }, + ], + "monkey-face": [ + { + emoji: "๐Ÿ™ˆ", + name: "see-no-evil monkey", + }, + { + emoji: "๐Ÿ™‰", + name: "hear-no-evil monkey", + }, + { + emoji: "๐Ÿ™Š", + name: "speak-no-evil monkey", + }, + ], + }), + ) + .map((datum: EmojiDatum, id: number) => { + return { ...datum, id }; + }); + +/** Initialise search engine */ +const minisearch = new MiniSearch({ + fields: ["name"], + storeFields: ["name", "emoji"], + tokenize: (string: string, _fieldName: string | undefined) => + string.split(" "), +}); +minisearch.addAll(RAW_EMOJI_DATA); + +const queryEmojiData = (query: string, numSuggestions = 20) => { + return minisearch + .search(query, { fuzzy: 0.5, combineWith: "OR" }) + .slice(0, numSuggestions) as never as EmojiDatum[]; +}; + +export function BasicExample() { + const [options, setOptions] = createSignal([]); + const [emoji, setEmoji] = createSignal(); + return ( + <> + { + setOptions(queryEmojiData(query)); + }} + onChange={(result: EmojiDatum | null) => setEmoji(result)} + optionValue="name" + optionLabel="name" + placeholder="Search an emojiโ€ฆ" + itemComponent={(props: any) => ( + + {props.item.rawValue.emoji} + + )} + > + + + + + + + + + + + e.preventDefault()} + > + + + ๐Ÿ˜ฌ No emoji found + + + + + +
+ Emoji selected: {emoji()?.emoji} {emoji()?.name} +
+ + ); +} + +export function DebounceExample() { + const [options, setOptions] = createSignal([]); + const [emoji, setEmoji] = createSignal(); + return ( + <> + { + setOptions(queryEmojiData(query)); + }} + onChange={(result: EmojiDatum | null) => setEmoji(result)} + debounceOptionsMillisecond={300} + optionValue="name" + optionLabel="name" + placeholder="Search an emojiโ€ฆ" + itemComponent={(props: any) => ( + + {props.item.rawValue.emoji} + + )} + > + + + + + } + > + + + + + + + + + + e.preventDefault()} + > + + + + ๐Ÿ˜ฌ No emoji found + + + + + +
+ Emoji selected: {emoji()?.emoji} {emoji()?.name} +
+ + ); +} + +export function CmdkStyleExample() { + const [options, setOptions] = createSignal([]); + const [emoji, setEmoji] = createSignal(); + return ( + <> + { + setOptions(queryEmojiData(query)); + }} + onChange={(result: EmojiDatum | null) => setEmoji(result)} + optionValue="name" + optionLabel="name" + placeholder="Search an emojiโ€ฆ" + itemComponent={(props: any) => ( + + {props.item.rawValue.emoji} + + )} + class={style.search__root_cmdk} + > + + + + + + + + + +
+ + + ๐Ÿ˜ฌ No emoji found + +
+
+ +
+ Emoji selected: {emoji()?.emoji} {emoji()?.name} +
+ + ); +} diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index dc9f20f8..a6c5807b 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -160,6 +160,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ title: "Radio Group", href: "/docs/core/components/radio-group", }, + { + title: "Search", + href: "/docs/core/components/search", + status: "new", + }, { title: "Select", href: "/docs/core/components/select", diff --git a/apps/docs/src/routes/docs/core/components/combobox.mdx b/apps/docs/src/routes/docs/core/components/combobox.mdx index 2f347007..7f877fe8 100644 --- a/apps/docs/src/routes/docs/core/components/combobox.mdx +++ b/apps/docs/src/routes/docs/core/components/combobox.mdx @@ -935,6 +935,7 @@ We expose a CSS custom property `--kb-combobox-content-transform-origin` which c | readOnly | `boolean`
Whether the combobox items can be selected but not changed by the user. | | autoComplete | `string`
Describes the type of autocomplete functionality the input should provide if any. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete) | | translations | [`ComboboxIntlTranslations`](https://github.com/kobaltedev/kobalte/blob/main/packages/core/src/combobox/combobox.intl.ts)
Localization strings. | +| noResetInputOnBlur | `boolean`
Prevents input reset on combobox blur when content is displayed. | `Combobox` also accepts the following props to customize the placement of the `Combobox.Content`. @@ -1069,4 +1070,4 @@ We expose a CSS custom property `--kb-combobox-content-transform-origin` which c | Alt + ArrowUp | When focus is on the input, closes the combobox. | | Home | When focus is on the input, moves virtual focus to first item. | | End | When focus is on the input, moves virtual focus to last item. | -| Esc | When the combobox is open, closes the combobox.
When the combobox is closed, clear the input and selection. | +| Esc | When the combobox is open, closes the combobox.
When the combobox is closed, clear the input and selection unless `noResetInputOnBlur` is set to `true`. | diff --git a/apps/docs/src/routes/docs/core/components/search.mdx b/apps/docs/src/routes/docs/core/components/search.mdx new file mode 100644 index 00000000..26373664 --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/search.mdx @@ -0,0 +1,582 @@ +import { Preview, TabsSnippets, Kbd, Callout } from "../../../../components"; +import { BasicExample, DebounceExample, CmdkStyleExample } from "../../../../examples/search"; + +# Search + +Search a searchbox text input with a menu. +Handle the case where dataset filtering needs to occur outside the combobox component. + +## Import + +```ts +import { Search } from "@kobalte/core/search"; +// or +import { Root, Label, ... } from "@kobalte/core/search"; +``` + +## Features + +- Inherits all the features of [combobox](/docs/core/components/combobox), except result filtering which should be managed externally. +- Debouncing text input to rate limit search suggestions calls. +- Optional indicator to show when suggestions are loading. + +## Anatomy + +The search consists of: + +- **Search:** The root container for a search component. +- **Search.Label:** The label that gives the user information on the search component. +- **Search.Description:** The description that gives the user more information on the component. +- **Search.Control:** Contains the search input and indicator. +- **Search.Indicator:** Wrapper for icon to indicate loading status. +- **Search.Icon:** A small icon often displayed next to the input as a visual affordance for the fact it can be open. +- **Search.Input:** The input used to search and reflects the selected suggestion values. +- **Search.Portal:** Portals its children into the `body` when the search is open. +- **Search.Content:** Contains the content to be rendered when the search is open. +- **Search.Arrow:** An optional arrow element to render alongside the search content. +- **Search.Listbox:** Contains a list of items and allows a user to search one or more of them. +- **Search.Section:** Used to render the label of an option group. It won't be focusable using arrow keys. +- **Search.Item:** An item of the search suggestion. +- **Search.ItemLabel:** An accessible label to be announced for the item. +- **Search.ItemDescription:** An optional accessible description to be announced for the item. +- **Search.NoResult:** Displayed when no suggestion options are given. + +```tsx + + + + + + + + + + + + + + + + + + + + +``` + +## Example + + + + + + + + index.tsx + style.css + + {/* */} + + ```tsx + import { Search } from "@kobalte/core/search"; + import { MagnifyingGlassIcon, ReloadIcon } from "some-icon-library"; + import { createSignal } from "solid-js"; + import "./style.css"; + + import { queryEmojiData } from "your-search-function"; + + function App() { + const [options, setOptions] = createSignal([]); + const [emoji, setEmoji] = createSignal(); + return ( + <> + setOptions(queryEmojiData(query))} + onChange={result => setEmoji(result)} + optionValue="name" + optionLabel="name" + placeholder="Search an emojiโ€ฆ" + itemComponent={props => ( + + {props.item.rawValue.emoji} + + )} + > + + + + + } + > + + + + + + + + e.preventDefault()}> + + + ๐Ÿ˜ฌ No emoji found + + + + +
+ Emoji selected: {emoji()?.emoji} {emoji()?.name} +
+ + ) + } + ``` + +
+ + ```css + .search__control { + display: inline-flex; + justify-content: space-between; + width: 240px; + border-radius: 6px; + font-size: 16px; + line-height: 1; + outline: none; + background-color: white; + border: 1px solid hsl(240 6% 90%); + color: hsl(240 4% 16%); + transition: + border-color 250ms, + color 250ms; + } + .search__control[data-invalid] { + border-color: hsl(0 72% 51%); + color: hsl(0 72% 51%); + } + .search__control_multi { + width: 100%; + min-width: 240px; + max-width: 300px; + } + .search__input { + appearance: none; + display: inline-flex; + width: 100%; + min-height: 40px; + padding-left: 16px; + font-size: 16px; + background: transparent; + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + outline: none; + } + .search__input::placeholder { + color: hsl(240 4% 46%); + } + .search__indicator { + appearance: none; + display: inline-flex; + justify-content: center; + align-items: center; + width: auto; + outline: none; + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + padding: 0 10px; + background-color: hsl(240 5% 96%); + border-right: 1px solid hsl(240 6% 90%); + color: hsl(240 4% 16%); + font-size: 16px; + line-height: 0; + transition: 250ms background-color; + } + .search__icon { + height: 20px; + width: 20px; + flex: 0 0 16px; + display: grid; + justify-items: center; + } + .load__icon { + height: 20px; + width: 20px; + display: grid; + justify-items: center; + flex: 0 0 14px; + } + .center__icon { + margin: auto; + } + .spin__icon { + animation: spin 600ms linear; + animation-iteration-count: infinite; + margin: auto; + } + .search__description { + margin-top: 8px; + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; + } + .search__content { + background-color: white; + border-radius: 6px; + border: 1px solid hsl(240 6% 90%); + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1); + transform-origin: var(--kb-search-content-transform-origin); + animation: contentHide 250ms ease-in forwards; + } + .search__content[data-expanded] { + animation: contentShow 250ms ease-out; + } + .search__listbox { + overflow-y: auto; + max-height: 360px; + padding: 8px; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr; + row-gap: 6px; + column-gap: 6px; + line-height: 1; + } + .search__listbox:focus { + outline: none; + } + .search__item { + font-size: 24px; + line-height: 1; + color: hsl(240 4% 16%); + border-radius: 16px; + padding: 8px; + position: relative; + user-select: none; + outline: none; + display: grid; + justify-items: center; + } + .search__item[data-disabled] { + color: hsl(240 5% 65%); + opacity: 0.5; + pointer-events: none; + } + .search__item[data-highlighted] { + outline: none; + box-sizing: border-box; + background-color: hsl(240 5% 96%); + box-shadow: inset 0 0 0 2px hsl(200 98% 39%); + } + .search__section { + padding: 8px 0 0 8px; + font-size: 14px; + line-height: 32px; + color: hsl(240 4% 46%); + } + .search__no_result { + text-align: center; + padding: 8px; + padding-bottom: 24px; + margin: auto; + color: hsl(240 4% 46%); + } + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + @keyframes contentShow { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes contentHide { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } + } + .result__content { + margin-top: 16px; + font-size: 16px; + line-height: 1; + } + ``` + + + {/* */} +
+ +## Usage + +### Debounce + +Set `debounceOptionsMillisecond`, to prevent new search queries immediately on input change. Instead, search queries are requested once input is idle for a set time. + +Show a debouncing icon by adding a `loadingComponent` to `Search.Indicator`. + + + + + + ```tsx + setOptions(queryEmojiData(query))} + onChange={result => setEmoji(result)} + debounceOptionsMillisecond={300} + optionValue="name" + optionLabel="name" + placeholder="Search an emojiโ€ฆ" + itemComponent={(props: any) => ( + + {props.item.rawValue.emoji} + + )} + > + + + + + } + > + + + + + + + + e.preventDefault()}> + + + ๐Ÿ˜ฌ No emoji found + + + + + ``` + +### Cmdk style + +To achieve the command menu look, add the `open` prop to permanently open dropdown. Replace `Search.Portal` and `Search.Content` with a `div` to directly mount your content below the search input. + + + + + + ```tsx + setOptions(queryEmojiData(query))} + onChange={result => setEmoji(result)} + debounceOptionsMillisecond={300} + optionValue="name" + optionLabel="name" + placeholder="Search an emojiโ€ฆ" + itemComponent={(props: any) => ( + + {props.item.rawValue.emoji} + + )} + > + + + + + + + + +
+ + + ๐Ÿ˜ฌ No emoji found + +
+
+ ``` + +## API Reference + +### Search + +`Search` is equivalent to the `Root` import from `@kobalte/core/search`. + +| Prop | Description | +| :---------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| options | `Array`
An array of options to display as the available options. | +| optionValue | `keyof T \| ((option: T) => string \| number)`
Property name or getter function to use as the value of an option. This is the value that will be submitted when the search component is part of a `
`. | +| optionTextValue | `keyof T \| ((option: T) => string)`
Property name or getter function to use as the text value of an option for typeahead purpose. | +| optionLabel | `keyof T \| ((option: T) => string)`
Property name or getter function to use as the label of an option. This is the string representation of the option to display in the `Search.Input`. | +| optionDisabled | `keyof T \| ((option: T) => boolean)`
Property name or getter function to use as the disabled flag of an option. | +| optionGroupChildren | `keyof U`
Property name that refers to the children options of an option group. | +| itemComponent | `Component>`
When NOT virtualized, the component to render as an item in the `Search.Listbox`. | +| sectionComponent | `Component>`
When NOT virtualized, the component to render as a section in the `Search.Listbox`. | +| multiple | `boolean`
Whether the search component allows multi-selection. | +| placeholder | `JSX.Element`
The content that will be rendered when no value or defaultValue is set. | +| value | `T \| Array`
The controlled value of the search input. | +| defaultValue | `T \| Array`
The value of the search input when initially rendered. Useful when you do not need to control the value. | +| onChange | `(value: T \| Array) => void`
Event handler called when the value changes. | +| open | `boolean`
The controlled open state of the search suggestion. | +| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | +| onOpenChange | `(open: boolean, triggerMode?: SearchTriggerMode) => void`
Event handler called when the open state of the search component changes. Returns the new open state and the action that caused the opening of the menu. | +| onInputChange | `(value: string) => void`
Handler that is called when the search input value changes. | +| triggerMode | `SearchTriggerMode`
The interaction required to display search suggestion, it can be one of the following:
- **input**: open search suggestion when the user is typing.
- **focus**: open search suggestion when the input is focused.
- **manual**: open search suggestion when pressing arrow down/up while focus is on the input or clicking on the trigger. | +| removeOnBackspace | `boolean`
When `multiple` is true, whether the last selected option should be removed when the user press the Backspace key and the input is empty. | +| allowDuplicateSelectionEvents | `boolean`
Whether `onChange` should fire even if the new value is the same as the last. | +| disallowEmptySelection | `boolean`
Whether the search component allows empty selection or not. | +| allowsEmptyCollection | `boolean`
Whether the search component allows the menu to be open when the collection is empty. | +| closeOnSelection | `boolean`
Whether the search component closes after selection. | +| selectionBehavior | `'toggle' \| 'replace'`
How selection should behave in the search component. | +| virtualized | `boolean`
Whether the search suggestion uses virtual scrolling. | +| modal | `boolean`
Whether the search component should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the search component content.
- elements outside the search component content will not be visible for screen readers. | +| preventScroll | `boolean`
Whether the scroll should be locked even if the search suggestion is not modal. | +| forceMount | `boolean`
Used to force mounting the search suggestion (portal, positioner and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | +| name | `string`
The name of the search component. Submitted with its owning form as part of a name/value pair. | +| validationState | `'valid' \| 'invalid'`
Whether the search component should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the user must select an item before the owning form can be submitted. | +| disabled | `boolean`
Whether the search component is disabled. | +| readOnly | `boolean`
Whether the search component items can be selected but not changed by the user. | +| autoComplete | `string`
Describes the type of autocomplete functionality the input should provide if any. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete) | +| translations | [`SearchIntlTranslations`](https://github.com/kobaltedev/kobalte/blob/main/packages/core/src/combobox/combobox.intl.ts)
Localization strings. | + +`Search` also accepts the following props to customize the placement of the `Search.Content`. + +| Prop | Description | +| :--------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| placement | `Placement`
The placement of the search component content. | +| gutter | `number`
The distance between the search component content and the trigger element. | +| shift | `number`
The skidding of the search component content along the trigger element. | +| flip | `boolean \| string`
Controls the behavior of the search component content when it overflows the viewport:
- If a `boolean`, specifies whether the search component content should flip to the opposite side when it overflows.
- If a `string`, indicates the preferred fallback placements when it overflows.
The placements must be spaced-delimited, e.g. "top left". | +| slide | `boolean`
Whether the search component content should slide when it overflows. | +| overlap | `boolean`
Whether the search component content can overlap the trigger element when it overflows. | +| sameWidth | `boolean`
Whether the search component content should have the same width as the trigger element. This will be exposed to CSS as `--kb-popper-anchor-width`. | +| fitViewport | `boolean`
Whether the search component content should fit the viewport. If this is set to true, the search component content will have `maxWidth` and `maxHeight` set to the viewport size. This will be exposed to CSS as `--kb-popper-available-width` and `--kb-popper-available-height`. | +| hideWhenDetached | `boolean`
Whether to hide the search component content when the trigger element becomes occluded. | +| detachedPadding | `number`
The minimum padding in order to consider the trigger element occluded. | +| arrowPadding | `number`
The minimum padding between the arrow and the search component content corner. | +| overflowPadding | `number`
The minimum padding between the search component content and the viewport edge. This will be exposed to CSS as `--kb-popper-overflow-padding`. | + +| Data attribute | Description | +| :------------- | :--------------------------------------------------------------------------------- | +| data-valid | Present when the search component is valid according to the validation rules. | +| data-invalid | Present when the search component is invalid according to the validation rules. | +| data-required | Present when the user must select an item before the owning form can be submitted. | +| data-disabled | Present when the search component is disabled. | +| data-readonly | Present when the search component is read only. | + +`Search.Label`, `Search.Control`, `Search.Input`, `Search.Trigger`, `Search.Description` and `Search.ErrorMesssage` shares the same data-attributes. + +### Search.Control + +| Render Prop | Description | +| :-------------- | :----------------------------------------------------------------------------- | +| selectedOptions | `Accessor`
An array of selected options. | +| remove | `(option: T) => void`
A function to remove an option from the selection. | +| clear | `() => void`
A function to clear the selection. | + +### Search.Indicator + +| Prop | Description | +| :--------------- | :--------------------------------------------------------------------------------------------- | +| loadingComponent | `JSX.Element`
The component that is displayed when suggestion options are being fetched. | + +### Search.Icon + +| Data attribute | Description | +| :------------- | :------------------------------------------ | +| data-expanded | Present when the search component is open. | +| data-closed | Present when the search component is close. | + +### Search.Content + +| Data attribute | Description | +| :------------- | :------------------------------------------ | +| data-expanded | Present when the search component is open. | +| data-closed | Present when the search component is close. | + +### Search.Arrow + +| Prop | Description | +| :--- | :------------------------------------ | +| size | `number`
The size of the arrow. | + +### Search.Listbox + +| Prop | Description | +| :----------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| scrollRef | `Accessor`
The ref attached to the scrollable element, used to provide automatic scrolling on item focus. If not provided, defaults to the listbox. | +| scrollToItem | `(key: string) => void`
When virtualized, the Virtualizer function used to scroll to the item of the given key. | +| children | `(items: Accessor>>) => JSX.Element`
When virtualized, a map function that receives an _items_ signal representing all items and sections. | + +### Search.Item + +| Prop | Description | +| :--- | :---------------------------------------------------- | +| item | `CollectionNode`
The collection node to render. | + +| Data attribute | Description | +| :--------------- | :------------------------------------ | +| data-disabled | Present when the item is disabled. | +| data-selected | Present when the item is selected. | +| data-highlighted | Present when the item is highlighted. | + +`Search.ItemLabel` and `Search.ItemDescription` shares the same data-attributes. + +## Rendered elements + +| Component | Default rendered element | +| :----------------------- | :----------------------- | +| `Search` | `div` | +| `Search.Label` | `span` | +| `Search.Description` | `div` | +| `Search.Control` | `div` | +| `Search.Indicator` | `div` | +| `Search.Icon` | `span` | +| `Search.Input` | `input` | +| `Search.Portal` | `Portal` | +| `Search.Content` | `div` | +| `Search.Arrow` | `div` | +| `Search.Listbox` | `ul` | +| `Search.Section` | `li` | +| `Search.Item` | `li` | +| `Search.ItemLabel` | `div` | +| `Search.ItemDescription` | `div` | +| `Search.NoResult` | `div` | + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| :------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Enter | When focus is virtualy on an item, selects the focused item. | +| ArrowDown | When focus is on the input, opens the search suggestion and virtual focuses the first or selected item.
When focus is virtualy on an item, moves virtual focus to the next item. | +| ArrowUp | When focus is on the input, opens the search suggestion and virtual focuses the last or selected item.
When focus is virtualy on an item, moves virtual focus to the previous item. | +| Alt + ArrowDown | When focus is on the input, opens the search suggestion. | +| Alt + ArrowUp | When focus is on the input, closes the search suggestion. | +| Home | When focus is on the input, moves virtual focus to first item. | +| End | When focus is on the input, moves virtual focus to last item. | +| Esc | When the search suggestion is open, closes the search suggestion.
When the search suggestion is closed, clear the input and selection. | diff --git a/packages/core/src/combobox/combobox-base.tsx b/packages/core/src/combobox/combobox-base.tsx index 8841ee24..74622890 100644 --- a/packages/core/src/combobox/combobox-base.tsx +++ b/packages/core/src/combobox/combobox-base.tsx @@ -87,6 +87,9 @@ export interface ComboboxBaseOptions PopperRootOptions, "anchorRef" | "contentRef" | "onCurrentPlacementChange" > { + /** Prevents input reset on combobox blur when content is displayed. */ + noResetInputOnBlur?: boolean; + /** The localized strings of the component. */ translations?: ComboboxIntlTranslations; @@ -301,6 +304,7 @@ export function ComboboxBase< const [local, popperProps, formControlProps, others] = splitProps( mergedProps, [ + "noResetInputOnBlur", "translations", "itemComponent", "sectionComponent", @@ -714,9 +718,10 @@ export function ComboboxBase< const selectedOption = allOptions().find( (option) => getOptionValue(option) === selectedKey, ); - + if (local.noResetInputOnBlur && !selectedOption) return; setInputValue(selectedOption ? getOptionLabel(selectedOption) : ""); } else { + if (local.noResetInputOnBlur) return; setInputValue(""); } }; diff --git a/packages/core/src/combobox/combobox-input.tsx b/packages/core/src/combobox/combobox-input.tsx index fd21950b..7ff0aa68 100644 --- a/packages/core/src/combobox/combobox-input.tsx +++ b/packages/core/src/combobox/combobox-input.tsx @@ -146,7 +146,7 @@ export function ComboboxInput( context.close(); } } else { - if (collection().getSize() > 0) { + if (collection().getSize() > 0 || context.allowsEmptyCollection()) { context.open(false, "input"); } } diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index a5650b68..4a72a633 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -1,11 +1,5 @@ // utils -export * from "./colors/intl"; -export * from "./colors/types"; -export * from "./colors/utils"; export * from "./color-mode"; -export * from "./colors/intl"; -export * from "./colors/types"; -export * from "./colors/utils"; export * from "./form-control"; export * from "./i18n"; export * from "./list"; diff --git a/packages/core/src/search/index.tsx b/packages/core/src/search/index.tsx new file mode 100644 index 00000000..7c26a63a --- /dev/null +++ b/packages/core/src/search/index.tsx @@ -0,0 +1,212 @@ +import { + FormControlDescription as Description, + FormControlLabel as Label, + type FormControlDescriptionCommonProps as SearchDescriptionCommonProps, + type FormControlDescriptionOptions as SearchDescriptionOptions, + type FormControlDescriptionProps as SearchDescriptionProps, + type FormControlDescriptionRenderProps as SearchDescriptionRenderProps, + type FormControlLabelCommonProps as SearchLabelCommonProps, + type FormControlLabelOptions as SearchLabelOptions, + type FormControlLabelProps as SearchLabelProps, + type FormControlLabelRenderProps as SearchLabelRenderProps, +} from "../form-control"; +import { + Item, + ItemDescription, + ItemLabel, + type ListboxItemCommonProps as SearchItemCommonProps, + type ListboxItemDescriptionCommonProps as SearchItemDescriptionCommonProps, + type ListboxItemDescriptionOptions as SearchItemDescriptionOptions, + type ListboxItemDescriptionProps as SearchItemDescriptionProps, + type ListboxItemDescriptionRenderProps as SearchItemDescriptionRenderProps, + type ListboxItemLabelCommonProps as SearchItemLabelCommonProps, + type ListboxItemLabelOptions as SearchItemLabelOptions, + type ListboxItemLabelProps as SearchItemLabelProps, + type ListboxItemLabelRenderProps as SearchItemLabelRenderProps, + type ListboxItemOptions as SearchItemOptions, + type ListboxItemProps as SearchItemProps, + type ListboxItemRenderProps as SearchItemRenderProps, + type ListboxSectionCommonProps as SearchSectionCommonProps, + type ListboxSectionOptions as SearchSectionOptions, + type ListboxSectionProps as SearchSectionProps, + type ListboxSectionRenderProps as SearchSectionRenderProps, + Section, +} from "../listbox"; +import { + Arrow, + type PopperArrowOptions as SearchArrowOptions, + type PopperArrowProps as SearchArrowProps, +} from "../popper"; + +import type { + ComboboxBaseItemComponentProps as SearchRootItemComponentProps, + ComboboxBaseSectionComponentProps as SearchRootSectionComponentProps, +} from "../combobox/combobox-base"; +// Search implements combobox without filter, hence the import alias +import { + ComboboxContent as Content, + type ComboboxContentCommonProps as SearchContentCommonProps, + type ComboboxContentOptions as SearchContentOptions, + type ComboboxContentProps as SearchContentProps, + type ComboboxContentRenderProps as SearchContentRenderProps, +} from "../combobox/combobox-content"; +import { + ComboboxControl as Control, + type ComboboxControlCommonProps as SearchControlCommonProps, + type ComboboxControlOptions as SearchControlOptions, + type ComboboxControlProps as SearchControlProps, + type ComboboxControlRenderProps as SearchControlRenderProps, +} from "../combobox/combobox-control"; +import { + ComboboxHiddenSelect as HiddenSelect, + type ComboboxHiddenSelectProps as SearchHiddenSelectProps, +} from "../combobox/combobox-hidden-select"; +import { + ComboboxIcon as Icon, + type ComboboxIconProps as SearchIconProps, +} from "../combobox/combobox-icon"; +import { + ComboboxInput as Input, + type ComboboxInputCommonProps as SearchInputCommonProps, + type ComboboxInputOptions as SearchInputOptions, + type ComboboxInputProps as SearchInputProps, + type ComboboxInputRenderProps as SearchInputRenderProps, +} from "../combobox/combobox-input"; +import { + ComboboxListbox as Listbox, + type ComboboxListboxCommonProps as SearchListboxCommonProps, + type ComboboxListboxOptions as SearchListboxOptions, + type ComboboxListboxProps as SearchListboxProps, + type ComboboxListboxRenderProps as SearchListboxRenderProps, +} from "../combobox/combobox-listbox"; +import { + ComboboxPortal as Portal, + type ComboboxPortalProps as SearchPortalProps, +} from "../combobox/combobox-portal"; +import type { ComboboxTriggerMode as SearchTriggerMode } from "../combobox/types"; + +import { + SearchIndicator as Indicator, + type SearchIndicatorCommonProps, + type SearchIndicatorOptions, + type SearchIndicatorProps, +} from "./search-indicator"; +import { + SearchNoResult as NoResult, + type SearchNoResultCommonProps, + type SearchNoResultOptions, + type SearchNoResultProps, +} from "./search-no-result"; + +// Wrappers over Combobox need to redefine prop types +import { + SearchRoot as Root, + type SearchMultipleSelectionOptions, + type SearchRootCommonProps, + type SearchRootOptions, + type SearchRootProps, + type SearchRootRenderProps, + type SearchSingleSelectionOptions, +} from "./search-root"; + +export type { + SearchArrowOptions, + SearchArrowProps, + SearchContentOptions, + SearchContentCommonProps, + SearchContentRenderProps, + SearchContentProps, + SearchControlOptions, + SearchControlCommonProps, + SearchControlRenderProps, + SearchControlProps, + SearchDescriptionOptions, + SearchDescriptionCommonProps, + SearchDescriptionRenderProps, + SearchDescriptionProps, + SearchHiddenSelectProps, + SearchIconProps, + SearchInputOptions, + SearchInputCommonProps, + SearchInputRenderProps, + SearchInputProps, + SearchItemDescriptionOptions, + SearchItemDescriptionCommonProps, + SearchItemDescriptionRenderProps, + SearchItemDescriptionProps, + SearchItemLabelOptions, + SearchItemLabelCommonProps, + SearchItemLabelRenderProps, + SearchItemLabelProps, + SearchItemOptions, + SearchItemCommonProps, + SearchItemRenderProps, + SearchItemProps, + SearchLabelOptions, + SearchLabelCommonProps, + SearchLabelRenderProps, + SearchLabelProps, + SearchListboxOptions, + SearchListboxCommonProps, + SearchListboxRenderProps, + SearchListboxProps, + SearchMultipleSelectionOptions, + SearchPortalProps, + SearchRootItemComponentProps, + SearchRootOptions, + SearchRootCommonProps, + SearchRootRenderProps, + SearchRootProps, + SearchRootSectionComponentProps, + SearchSectionOptions, + SearchSectionCommonProps, + SearchSectionRenderProps, + SearchSectionProps, + SearchSingleSelectionOptions, + SearchTriggerMode, + SearchNoResultOptions, + SearchNoResultCommonProps, + SearchNoResultProps, + SearchIndicatorCommonProps, + SearchIndicatorOptions, + SearchIndicatorProps, +}; + +export { + Arrow, + Content, + Control, + Description, + HiddenSelect, + Icon, + Input, + Item, + ItemDescription, + ItemLabel, + Label, + Listbox, + Portal, + Root, + Section, + NoResult, + Indicator, +}; + +export const Search = Object.assign(Root, { + Arrow, + Content, + Control, + Description, + HiddenSelect, + Icon, + Input, + Item, + ItemDescription, + ItemLabel, + Label, + Listbox, + Portal, + Section, + NoResult, + Indicator, +}); diff --git a/packages/core/src/search/search-context.tsx b/packages/core/src/search/search-context.tsx new file mode 100644 index 00000000..22135658 --- /dev/null +++ b/packages/core/src/search/search-context.tsx @@ -0,0 +1,23 @@ +import { type Accessor, createContext, useContext } from "solid-js"; + +export interface SearchContextValue { + /** No results found */ + noResult: Accessor; + + /** Are we currently loading suggestions? */ + isLoadingSuggestions: Accessor; +} + +export const SearchContext = createContext(); + +export function useSearchContext() { + const context = useContext(SearchContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `useSearchContext` must be used within a `Search` component", + ); + } + + return context; +} diff --git a/packages/core/src/search/search-indicator.tsx b/packages/core/src/search/search-indicator.tsx new file mode 100644 index 00000000..e60ec6cd --- /dev/null +++ b/packages/core/src/search/search-indicator.tsx @@ -0,0 +1,44 @@ +import { type JSX, Show, type ValidComponent, splitProps } from "solid-js"; + +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { useSearchContext } from "./search-context"; + +export interface SearchIndicatorOptions {} + +export interface SearchIndicatorCommonProps< + T extends HTMLElement = HTMLElement, +> { + children: JSX.Element; + loadingComponent?: JSX.Element; +} + +export interface SearchIndicatorRenderProps + extends SearchIndicatorCommonProps {} + +export type SearchIndicatorProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = SearchIndicatorOptions & Partial>>; + +export function SearchIndicator( + props: PolymorphicProps>, +) { + const [local, other] = splitProps(props, ["loadingComponent"]); + const context = useSearchContext(); + + return ( + + ); +} diff --git a/packages/core/src/search/search-no-result.tsx b/packages/core/src/search/search-no-result.tsx new file mode 100644 index 00000000..2fcb5562 --- /dev/null +++ b/packages/core/src/search/search-no-result.tsx @@ -0,0 +1,34 @@ +import { Show, type ValidComponent } from "solid-js"; +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { useSearchContext } from "./search-context"; + +export interface SearchNoResultOptions {} + +export interface SearchNoResultCommonProps< + T extends HTMLElement = HTMLElement, +> {} + +export interface SearchNoResultRenderProps extends SearchNoResultCommonProps {} + +export type SearchNoResultProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = SearchNoResultOptions & Partial>>; + +/** + * Displayed in portal when no options are presented + */ +export function SearchNoResult( + props: PolymorphicProps>, +) { + const context = useSearchContext(); + + return ( + + + + ); +} diff --git a/packages/core/src/search/search-root.tsx b/packages/core/src/search/search-root.tsx new file mode 100644 index 00000000..d255c584 --- /dev/null +++ b/packages/core/src/search/search-root.tsx @@ -0,0 +1,147 @@ +import { + type ValidComponent, + createEffect, + createMemo, + createSignal, + splitProps, +} from "solid-js"; +import { + ComboboxBase, + type ComboboxBaseOptions, + type ComboboxBaseRenderProps as SearchBaseRenderProps, +} from "../combobox/combobox-base"; +import type { + ComboboxMultipleSelectionOptions as SearchMultipleSelectionOptions, + ComboboxSingleSelectionOptions as SearchSingleSelectionOptions, +} from "../combobox/combobox-root"; +import type { ElementOf, PolymorphicProps } from "../polymorphic"; +import { SearchContext, type SearchContextValue } from "./search-context"; +import { DebouncerTimeout } from "./utils"; + +export type { SearchSingleSelectionOptions, SearchMultipleSelectionOptions }; + +// SearchBase wraps Combobox without `defaultFilter` as filtering is handled externally - eg: on database +export interface SearchBaseOptions + extends Omit, "defaultFilter"> { + /** Debounces input before making suggestions */ + debounceOptionsMillisecond?: number; +} + +export type SearchRootOptions = ( + | SearchSingleSelectionOptions