This commit is contained in:
Brian McGee
2025-07-31 11:28:14 +01:00
parent d19ac1b9f5
commit 045332ba5e
4 changed files with 416 additions and 27 deletions

View File

@@ -12,12 +12,15 @@ import cx from "classnames";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { Typography } from "@/src/components/Typography/Typography";
import { Accessor, Component, For, Show, splitProps } from "solid-js";
import { Accessor, Component, ComponentProps, For, Show, splitProps } from "solid-js";
import { Tag } from "@/src/components/Tag/Tag";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { TextFieldInputProps } from "@kobalte/core/text-field";
export type ComboboxProps<Option, OptGroup = never> = FieldProps &
KComboboxRootOptions<Option, OptGroup> & {
inverted: boolean;
input?: ComponentProps<"select">;
itemControl?: Component<ComboboxControlState<Option>>;
};
@@ -129,6 +132,7 @@ export const Combobox = <Option, OptGroup = never>(
{...props}
/>
<KCombobox.HiddenSelect {...props.input} />
<KCombobox.Control<Option> class="control">
{(state) => {
const [controlProps] = splitProps(props, [

View File

@@ -0,0 +1,222 @@
div.form-field.machine-tags {
div.control {
@apply flex flex-col size-full gap-2;
div.selected-options {
@apply flex flex-wrap gap-2 size-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-none border-none bg-inherit;
@apply p-0 resize-none;
}
}
& > 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;
&[data-readonly] {
@apply p-0;
}
}
& > 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;
}
&[data-readonly] {
@apply outline-none border-none bg-inherit cursor-auto;
}
}
}
}
&.ghost {
div.control > div.input-container {
& > input {
@apply outline-none;
&:hover {
@apply outline-none;
}
}
}
}
}
div.machine-tags-content {
@apply rounded-sm bg-def-1 border border-def-2;
transform-origin: var(--kb-combobox-content-transform-origin);
animation: machineTagsContentHide 250ms ease-in forwards;
&[data-expanded] {
animation: machineTagsContentShow 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.machine-tags-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 machineTagsContentShow {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes machineTagsContentHide {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}

View File

@@ -0,0 +1,164 @@
import {
Combobox as KCombobox,
Combobox,
ComboboxRootProps,
} from "@kobalte/core/combobox";
import { FieldProps } from "./Field";
import { Accessor, createSignal, For, Show } from "solid-js";
import Icon from "../Icon/Icon";
import cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography";
import {
ComboboxItem,
ComboboxItemIndicator,
ComboboxItemLabel,
ComboboxProps,
} from "@/src/components/Form/Combobox";
import { a as CollectionNode } from "@kobalte/core/dist/types-f8ae18e5";
import { Tag } from "@/src/components/Tag/Tag";
import "./MachineTags.css";
import { Label } from "@/src/components/Form/Label";
import { Orienter } from "@/src/components/Form/Orienter";
interface MachineTag {
value: string;
disabled?: boolean;
}
export type MachineTagsProps = FieldProps & ComboboxRootProps<MachineTag> & {};
const deduplicateOptions = (options: MachineTag[]) => {
return options.filter((option, index) => {
return options.findIndex((o) => o.value === option.value) === index;
});
};
const sortOptions = (options: MachineTag[]) => {
return options.sort((a, b) => {
if (a.disabled && !b.disabled) return -1;
return a.value.localeCompare(b.value);
});
};
const ItemComponent = (props: { item: CollectionNode<MachineTag> }) => {
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>
);
};
const Control = (props: {
inverted?: boolean;
readOnly?: boolean;
disabled?: boolean;
}) => (
<Combobox.Control<MachineTag> class="control">
{(state) => (
<div class="selected-options">
<For each={state.selectedOptions()}>
{(option) => (
<Tag
label={option.value}
inverted={props.inverted}
action={
option.disabled || props.disabled || props.readOnly
? undefined
: {
icon: "Close",
onClick: () => state.remove(option),
}
}
/>
)}
</For>
<Show when={!props.readOnly}>
<div class="input-container">
<Combobox.Input />
<Combobox.Trigger class="trigger">
<Combobox.Icon class="icon">
<Icon icon="Expand" inverted={!props.inverted} size="100%" />
</Combobox.Icon>
</Combobox.Trigger>
</div>
</Show>
</div>
)}
</Combobox.Control>
);
export const MachineTags = (props: MachineTagsProps) => {
// options which are applied to all machines and cannot be interacted with
const alwaysOptions = [{ value: "All", disabled: true }];
const [options, setOptions] = createSignal<MachineTag[]>(
sortOptions([
...alwaysOptions,
...((props.defaultValue as MachineTag[]) || []),
]),
);
const _setOptions = (options: MachineTag[] | null) => {
if (options === null) setOptions([]);
setOptions(
sortOptions(deduplicateOptions([...alwaysOptions, ...(options || [])])),
);
};
const align = () => {
if (props.readOnly) {
return "center";
} else {
return props.orientation === "horizontal" ? "start" : "center";
}
};
return (
<Combobox<MachineTag>
multiple
class={cx("form-field", "machine-tags", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
itemComponent={ItemComponent}
defaultValue={options()}
value={options()}
optionLabel={(option) => option.value}
optionTextValue="value"
options={options()}
onChange={_setOptions}
placeholder="Tag a machine"
>
<Orienter orientation={props.orientation} align={align()}>
<Label
labelComponent={Combobox.Label}
descriptionComponent={Combobox.Description}
{...props}
/>
{/*<KCombobox.HiddenSelect {...props.input} />*/}
<Control
inverted={props.inverted}
readOnly={props.readOnly}
disabled={props.disabled}
/>
</Orienter>
<Combobox.Portal>
<Combobox.Content class="machine-tags-content">
<Combobox.Listbox class="listbox" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>
);
};

View File

@@ -12,6 +12,8 @@ import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm"
import * as v from "valibot";
import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { Combobox } from "../Form/Combobox";
import { MachineTags } from "@/src/components/Form/MachineTags";
type Story = StoryObj<SidebarPaneProps>;
@@ -126,32 +128,29 @@ export const Default: Story = {
)}
</SidebarSectionForm>
{/* todo fix tags component */}
{/*<SidebarSectionForm*/}
{/* title="Tags"*/}
{/* schema={schema}*/}
{/* initialValues={profiles.ron}*/}
{/* onSubmit={async () => {*/}
{/* console.log("saving general");*/}
{/* }}*/}
{/*>*/}
{/* {({ editing, Field }) => (*/}
{/* <Field name="tags">*/}
{/* {(field, input) => (*/}
{/* <Combobox*/}
{/* {...field}*/}
{/* value={field.value}*/}
{/* options={field.value || []}*/}
{/* size="s"*/}
{/* inverted*/}
{/* required*/}
{/* readOnly={!editing}*/}
{/* orientation="horizontal"*/}
{/* multiple*/}
{/* />*/}
{/* )}*/}
{/* </Field>*/}
{/* )}*/}
{/*</SidebarSectionForm>*/}
<SidebarSectionForm
title="Tags"
schema={schema}
initialValues={profiles.ron}
onSubmit={async () => {
console.log("saving general");
}}
>
{({ editing, Field }) => (
<Field name="tags">
{(field, input) => (
<MachineTags
size="s"
inverted
required
readOnly={!editing}
orientation="horizontal"
multiple
/>
)}
</Field>
)}
</SidebarSectionForm>
<SidebarSection title="Simple" class="flex flex-col">
<Typography tag="h2" hierarchy="title" size="m" inverted>
Static Content