From b7792a34c83e84d8aec685520e18098cfd19c166 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Tue, 1 Jul 2025 17:50:06 +0100 Subject: [PATCH] feat(ui): Combobox component and style tooltip for label --- .../ui/src/components/v2/Form/Combobox.css | 214 ++++++++++++++++++ .../components/v2/Form/Combobox.stories.tsx | 135 +++++++++++ .../ui/src/components/v2/Form/Combobox.tsx | 166 ++++++++++++++ .../components/v2/Form/Fieldset.stories.tsx | 5 +- .../ui/src/components/v2/Form/Label.css | 37 +++ .../ui/src/components/v2/Form/Label.tsx | 15 +- .../src/components/v2/TagGroup/TagGroup.tsx | 3 +- 7 files changed, 567 insertions(+), 8 deletions(-) create mode 100644 pkgs/clan-app/ui/src/components/v2/Form/Combobox.css create mode 100644 pkgs/clan-app/ui/src/components/v2/Form/Combobox.stories.tsx create mode 100644 pkgs/clan-app/ui/src/components/v2/Form/Combobox.tsx diff --git a/pkgs/clan-app/ui/src/components/v2/Form/Combobox.css b/pkgs/clan-app/ui/src/components/v2/Form/Combobox.css new file mode 100644 index 000000000..8163c617f --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Form/Combobox.css @@ -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); + } +} diff --git a/pkgs/clan-app/ui/src/components/v2/Form/Combobox.stories.tsx b/pkgs/clan-app/ui/src/components/v2/Form/Combobox.stories.tsx new file mode 100644 index 000000000..804de9335 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Form/Combobox.stories.tsx @@ -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) => ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+); + +const meta = { + title: "Components/Form/Combobox", + component: ComboboxExamples, + decorators: [ + (Story: StoryObj, context: StoryContext>) => { + return ( +
+ +
+ ); + }, + ], +} satisfies Meta>; + +export default meta; + +export type Story = StoryObj; + +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, + }, +}; diff --git a/pkgs/clan-app/ui/src/components/v2/Form/Combobox.tsx b/pkgs/clan-app/ui/src/components/v2/Form/Combobox.tsx new file mode 100644 index 000000000..c9b1b92fe --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Form/Combobox.tsx @@ -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 = FieldProps & + KComboboxRootOptions & { + inverted: boolean; + itemControl?: Component>; + }; + +export const DefaultItemComponent = ( + props: ComboboxItemComponentProps