feat(ui): Combobox component and style tooltip for label

This commit is contained in:
Brian McGee
2025-07-01 17:50:06 +01:00
parent 17b4f95055
commit c3ba72e82c
7 changed files with 567 additions and 8 deletions

View File

@@ -0,0 +1,214 @@
@import "Field.css";
div.form-field.combobox {
& > div.control {
@apply flex flex-col w-full gap-2;
& > div.selected-options {
@apply flex flex-wrap gap-1 w-full min-h-5;
}
& > div.input-container {
@apply relative left-0 top-0;
@apply inline-flex justify-between w-full;
input {
@apply w-full px-2 py-1.5 rounded-sm;
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1;
font-size: 0.875rem;
font-weight: 500;
font-family: "Archivo", sans-serif;
line-height: 1;
&::placeholder {
@apply fg-def-4;
}
&:hover {
@apply bg-def-acc-1 outline-def-acc-2;
}
&:focus-visible {
@apply bg-def-1 outline-def-acc-3;
box-shadow:
0 0 0 0.125rem theme(colors.bg.def.1),
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
}
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-disabled] {
@apply outline-def-2 fg-def-4 cursor-not-allowed;
}
&[data-readonly] {
@apply outline-def-2 cursor-not-allowed;
}
}
& > button.trigger {
@apply flex items-center justify-center w-8;
@apply absolute right-2 top-1 h-5 w-6 bg-def-2 rounded-sm;
&[data-disabled] {
@apply cursor-not-allowed;
}
& > span.icon {
@apply h-full w-full py-0.5 px-1;
}
}
}
}
&.horizontal {
@apply flex-row gap-2 justify-between;
& > div.control {
@apply w-1/2 grow;
}
}
&.s {
& > div.control > div.input-container {
& > input {
@apply px-1.5 py-1;
font-size: 0.75rem;
}
& > button.trigger {
@apply top-[0.1875rem] h-4 w-5;
}
}
}
&.inverted {
& > div.control > div.input-container {
& > button.trigger {
@apply bg-inv-2;
}
& > input {
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
&::placeholder {
@apply fg-inv-4;
}
&:hover {
@apply bg-inv-acc-2 outline-inv-acc-2;
}
&:focus-visible {
@apply bg-inv-acc-4;
box-shadow:
0 0 0 0.125rem theme(colors.bg.inv.1),
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
}
&[data-invalid] {
@apply outline-semantic-error-4;
}
}
}
}
&.ghost {
& > div.control > div.input-container {
& > input {
@apply outline-none;
&:hover {
@apply outline-none;
}
}
}
}
}
div.combobox-content {
@apply rounded-sm bg-def-1 border border-def-2;
transform-origin: var(--kb-combobox-content-transform-origin);
animation: comboboxContentHide 250ms ease-in forwards;
&[data-expanded] {
animation: comboboxContentShow 250ms ease-out;
}
& > ul.listbox {
overflow-y: auto;
max-height: 360px;
@apply px-2 py-3;
&:focus {
outline: none;
}
li.item {
@apply flex items-center justify-between;
@apply relative px-2 py-1;
@apply select-none outline-none rounded-[0.25rem];
color: hsl(240 4% 16%);
height: 32px;
&[data-disabled] {
color: hsl(240 5% 65%);
opacity: 0.5;
pointer-events: none;
}
&[data-highlighted] {
@apply outline-none bg-def-4;
}
}
.item-indicator {
height: 20px;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
}
div.combobox-control {
@apply flex flex-col w-full gap-2;
& > div.selected-options {
@apply flex gap-2 flex-wrap w-full;
}
& > div.input-container {
@apply w-full flex gap-2;
}
}
@keyframes comboboxContentShow {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes comboboxContentHide {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}

View File

@@ -0,0 +1,135 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames";
import { Combobox, ComboboxProps } from "./Combobox";
const ComboboxExamples = (props: ComboboxProps<string>) => (
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-8 p-8">
<Combobox {...props} />
<Combobox {...props} size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Combobox {...props} inverted={true} />
<Combobox {...props} inverted={true} size="s" />
</div>
<div class="flex flex-col gap-8 p-8">
<Combobox {...props} orientation="horizontal" />
<Combobox {...props} orientation="horizontal" size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Combobox {...props} inverted={true} orientation="horizontal" />
<Combobox {...props} inverted={true} orientation="horizontal" size="s" />
</div>
</div>
);
const meta = {
title: "Components/Form/Combobox",
component: ComboboxExamples,
decorators: [
(Story: StoryObj, context: StoryContext<ComboboxProps<string>>) => {
return (
<div
class={cx({
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
"w-[1024px]": context.args.orientation == "horizontal",
"bg-inv-acc-3": context.args.inverted,
})}
>
<Story />
</div>
);
},
],
} satisfies Meta<ComboboxProps<string>>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Bare: Story = {
args: {
options: ["foo", "bar", "baz"],
defaultValue: "foo",
},
};
export const Label: Story = {
args: {
...Bare.args,
label: "DOB",
},
};
export const Description: Story = {
args: {
...Label.args,
description: "The date you were born",
},
};
export const Required: Story = {
args: {
...Description.args,
required: true,
},
};
export const Multiple: Story = {
args: {
...Description.args,
required: true,
multiple: true,
defaultValue: ["foo", "bar"],
},
};
export const Tooltip: Story = {
args: {
...Required.args,
tooltip: "The day you came out of your momma",
},
};
export const Ghost: Story = {
args: {
...Tooltip.args,
ghost: true,
},
};
export const Invalid: Story = {
args: {
...Tooltip.args,
validationState: "invalid",
},
};
export const Disabled: Story = {
args: {
...Tooltip.args,
disabled: true,
},
};
export const MultipleDisabled: Story = {
args: {
...Multiple.args,
disabled: true,
},
};
export const ReadOnly: Story = {
args: {
...Tooltip.args,
readOnly: true,
},
};
export const MultipleReadonly: Story = {
args: {
...Multiple.args,
readOnly: true,
},
};

View File

@@ -0,0 +1,166 @@
import Icon from "@/src/components/v2/Icon/Icon";
import {
Combobox as KCombobox,
ComboboxRootOptions as KComboboxRootOptions,
} from "@kobalte/core/combobox";
import { isFunction } from "@kobalte/utils";
import "./Combobox.css";
import { CollectionNode } from "@kobalte/core";
import { Label } from "./Label";
import cx from "classnames";
import { FieldProps } from "./Field";
import { Typography } from "@/src/components/v2/Typography/Typography";
import { Accessor, Component, For, Show, splitProps } from "solid-js";
import { Tag } from "@/src/components/v2/Tag/Tag";
export type ComboboxProps<Option, OptGroup = never> = FieldProps &
KComboboxRootOptions<Option, OptGroup> & {
inverted: boolean;
itemControl?: Component<ComboboxControlState<Option>>;
};
export const DefaultItemComponent = <Option,>(
props: ComboboxItemComponentProps<Option>,
) => {
return (
<ComboboxItem item={props.item} class="item">
<ComboboxItemLabel>
<Typography hierarchy="body" size="xs" weight="bold">
{props.item.textValue}
</Typography>
</ComboboxItemLabel>
<ComboboxItemIndicator class="item-indicator">
<Icon icon="Checkmark" />
</ComboboxItemIndicator>
</ComboboxItem>
);
};
// adapted from https://github.com/kobaltedev/kobalte/blob/98a4810903c0c425d28cef4f0d1984192a225788/packages/core/src/combobox/combobox-base.tsx#L439
const getOptionTextValue = <Option,>(
option: Option,
optionTextValue:
| keyof Exclude<Option, null>
| ((option: Exclude<Option, null>) => string)
| undefined,
) => {
if (optionTextValue == null) {
// If no `optionTextValue`, the option itself is the label (ex: string[] of options).
return String(option);
}
// Get the label from the option object as a string.
return String(
isFunction(optionTextValue)
? optionTextValue(option as never)
: (option as never)[optionTextValue],
);
};
export const DefaultItemControl = <Option,>(
props: ComboboxControlState<Option>,
) => (
<>
<Show when={props.multiple}>
<div class="selected-options">
<For each={props.selectedOptions()}>
{(option) => (
<Tag
inverted={props.inverted}
label={getOptionTextValue<Option>(option, props.optionTextValue)}
action={
props.disabled || props.readOnly
? undefined
: {
icon: "Close",
onClick: () => props.remove(option),
}
}
/>
)}
</For>
</div>
</Show>
<div class="input-container">
<KCombobox.Input />
<KCombobox.Trigger class="trigger">
<KCombobox.Icon class="icon">
<Icon icon="Expand" inverted={props.inverted} size="100%" />
</KCombobox.Icon>
</KCombobox.Trigger>
</div>
</>
);
// todo aria-label on combobox.control and combobox.input
export const Combobox = <Option, OptGroup = never>(
props: ComboboxProps<Option, OptGroup>,
) => {
const itemControl = () => props.itemControl || DefaultItemControl;
const itemComponent = () => props.itemComponent || DefaultItemComponent;
return (
<KCombobox
class={cx("form-field", "combobox", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
itemComponent={itemComponent()}
>
<Label
labelComponent={KCombobox.Label}
descriptionComponent={KCombobox.Description}
{...props}
/>
<KCombobox.Control<Option> class="control">
{(state) => {
const [controlProps] = splitProps(props, [
"inverted",
"multiple",
"readOnly",
"disabled",
]);
return itemControl()({ ...state, ...controlProps });
}}
</KCombobox.Control>
<KCombobox.Portal>
<KCombobox.Content class="combobox-content">
<KCombobox.Listbox class="listbox" />
</KCombobox.Content>
</KCombobox.Portal>
</KCombobox>
);
};
// todo can we replicate the . notation that Kobalte achieves with their type definitions?
export const ComboboxItem = KCombobox.Item;
export const ComboboxItemDescription = KCombobox.ItemDescription;
export const ComboboxItemIndicator = KCombobox.ItemIndicator;
export const ComboboxItemLabel = KCombobox.ItemLabel;
// these interfaces were not exported, so we re-declare them
export interface ComboboxItemComponentProps<Option> {
/** The item to render. */
item: CollectionNode<Option>;
}
export interface ComboboxSectionComponentProps<OptGroup> {
/** The section to render. */
section: CollectionNode<OptGroup>;
}
type ComboboxControlState<Option> = Pick<
ComboboxProps<Option>,
"optionTextValue" | "inverted" | "multiple" | "size" | "readOnly" | "disabled"
> & {
/** The selected options. */
selectedOptions: Accessor<Option[]>;
/** A function to remove an option from the selection. */
remove: (option: Option) => void;
/** A function to clear the selection. */
clear: () => void;
};

View File

@@ -1,8 +1,5 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import {
Fieldset,
FieldsetProps,
} from "@/src/components/v2/Form/Fieldset";
import { Fieldset, FieldsetProps } from "@/src/components/v2/Form/Fieldset";
import cx from "classnames";
import { TextInput } from "@/src/components/v2/Form/TextInput";
import { TextArea } from "@/src/components/v2/Form/TextArea";

View File

@@ -22,3 +22,40 @@ div.form-label {
}
}
}
div.tooltip-content {
@apply z-50 px-2 py-0.5 bg-inv-4 rounded-[0.125rem] leading-none;
max-width: min(calc(100vw - 16px), 380px);
transform-origin: var(--kb-tooltip-content-transform-origin);
animation: tooltipHide 250ms ease-in forwards;
&[data-expanded] {
animation: tooltipShow 250ms ease-out;
}
&.inverted {
@apply bg-def-2;
}
}
@keyframes tooltipShow {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes tooltipHide {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

View File

@@ -6,6 +6,7 @@ import { TextField } from "@kobalte/core/text-field";
import { Checkbox } from "@kobalte/core/checkbox";
import { Combobox } from "@kobalte/core/combobox";
import "./Label.css";
import cx from "classnames";
export type Size = "default" | "s";
@@ -47,7 +48,7 @@ export const Label = (props: LabelProps) => {
{props.label}
</Typography>
{props.tooltip && (
<KTooltip>
<KTooltip placement="top">
<KTooltip.Trigger>
<Icon
icon="Info"
@@ -56,10 +57,18 @@ export const Label = (props: LabelProps) => {
size={props.size == "default" ? "0.85em" : "0.75rem"}
/>
<KTooltip.Portal>
<KTooltip.Content>
<Typography hierarchy="body" size="xs">
<KTooltip.Content
class={cx("tooltip-content", { inverted: props.inverted })}
>
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted={!props.inverted}
>
{props.tooltip}
</Typography>
<KTooltip.Arrow />
</KTooltip.Content>
</KTooltip.Portal>
</KTooltip.Trigger>

View File

@@ -4,6 +4,7 @@ import { For } from "solid-js";
import { Tag } from "@/src/components/v2/Tag/Tag";
export interface TagGroupProps {
class?: string;
labels: string[];
inverted?: boolean;
}
@@ -12,7 +13,7 @@ export const TagGroup = (props: TagGroupProps) => {
const inverted = () => props.inverted || false;
return (
<div class={cx("tag-group", { inverted: inverted() })}>
<div class={cx("tag-group", props.class, { inverted: inverted() })}>
<For each={props.labels}>
{(label) => <Tag label={label} inverted={inverted()} />}
</For>