feat(ui): Combobox component and style tooltip for label
This commit is contained in:
214
pkgs/clan-app/ui/src/components/v2/Form/Combobox.css
Normal file
214
pkgs/clan-app/ui/src/components/v2/Form/Combobox.css
Normal 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);
|
||||
}
|
||||
}
|
||||
135
pkgs/clan-app/ui/src/components/v2/Form/Combobox.stories.tsx
Normal file
135
pkgs/clan-app/ui/src/components/v2/Form/Combobox.stories.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
166
pkgs/clan-app/ui/src/components/v2/Form/Combobox.tsx
Normal file
166
pkgs/clan-app/ui/src/components/v2/Form/Combobox.tsx
Normal 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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user