feat(ui): flatten the Field pattern and introduce Orienter component

This commit is contained in:
Brian McGee
2025-07-03 16:57:27 +01:00
parent d2a76f4e83
commit dcb7e546ca
16 changed files with 169 additions and 122 deletions

View File

@@ -43,9 +43,11 @@
"eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"eslint-plugin-unused-imports": "^4.1.4",
"extend": "^3.0.2",
"http-server": "^14.1.1",
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
@@ -4529,6 +4531,13 @@
"node": ">=12.0.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5684,6 +5693,19 @@
"node": ">=10"
}
},
"node_modules/markdown-to-jsx": {
"version": "7.7.10",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.10.tgz",
"integrity": "sha512-au62yyLyJukhC2P1TYi3uBi/RScGYai69uT72D8a048QH8rRj+yhND3C21GdZHE+6emtsf6Yqemcf//K+EIWDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -43,9 +43,11 @@
"eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"eslint-plugin-unused-imports": "^4.1.4",
"extend": "^3.0.2",
"http-server": "^14.1.1",
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",

View File

@@ -144,7 +144,7 @@ export default meta;
type Story = StoryObj<ButtonProps>;
const timeout = process.env.NODE_ENV === "test" ? 100 : 2000;
const timeout = process.env.NODE_ENV === "test" ? 500 : 2000;
export const Primary: Story = {
args: {
@@ -158,12 +158,6 @@ export const Primary: Story = {
}
}),
},
parameters: {
test: {
// increase test timeout to allow for the loading action
mockTimers: true,
},
},
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
const buttons = await canvas.findAllByRole("button");
@@ -195,16 +189,19 @@ export const Primary: Story = {
await userEvent.click(button);
// check the button has changed
await waitFor(async () => {
// the action handler should have been called
await expect(args.onAction).toHaveBeenCalled();
// the button should have a loading class
await expect(button).toHaveClass("loading");
// the loader should be visible
await expect(loader.clientWidth).toBeGreaterThan(0);
// the pointer should have changed to wait
await expect(getCursorStyle(button)).toEqual("wait");
});
await waitFor(
async () => {
// the action handler should have been called
await expect(args.onAction).toHaveBeenCalled();
// the button should have a loading class
await expect(button).toHaveClass("loading");
// the loader should be visible
await expect(loader.clientWidth).toBeGreaterThan(0);
// the pointer should have changed to wait
await expect(getCursorStyle(button)).toEqual("wait");
},
{ timeout: timeout + 500 },
);
// wait for the action handler to finish
await waitFor(

View File

@@ -1,15 +1,14 @@
import "./Divider.css";
import cx from "classnames";
import { Orientation } from "@/src/components/v2/shared";
export interface DividerProps {
inverted?: boolean;
orientation?: Orientation;
orientation?: "horizontal" | "vertical";
}
export const Divider = (props: DividerProps) => {
const inverted = props.inverted || false;
const orientation = props.orientation || "horizontal";
const orientation = () => props.orientation || "horizontal";
return <div class={cx("divider", orientation, { inverted: inverted })} />;
return <div class={cx("divider", orientation(), { inverted: inverted })} />;
};

View File

@@ -1,10 +1,8 @@
@import "./Field.css";
div.form-field {
&.checkbox {
@apply items-start;
& > div.checkbox-control {
& div.checkbox-control {
@apply w-5 h-5 rounded-sm bg-def-1 border border-inv-1 p-[0.0625rem];
&:hover {
@@ -21,13 +19,9 @@ div.form-field {
}
}
&.horizontal.checkbox {
@apply items-center;
}
&.inverted {
&.checkbox {
& > div.checkbox-control {
& div.checkbox-control {
@apply bg-inv-1;
&:hover,
@@ -43,7 +37,7 @@ div.form-field {
}
&.s {
& > div.checkbox-control {
& div.checkbox-control {
@apply w-4 h-4;
}
}

View File

@@ -10,6 +10,7 @@ import { Label } from "./Label";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import "./Checkbox.css";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
export type CheckboxProps = FieldProps &
KCheckboxRootProps & {
@@ -24,21 +25,23 @@ export const Checkbox = (props: CheckboxProps) => (
})}
{...props}
>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}
{...props}
/>
<KCheckbox.Input {...props.input} />
<KCheckbox.Control class="checkbox-control">
<KCheckbox.Indicator>
<Icon
icon="Checkmark"
inverted={props.inverted}
color="secondary"
size="100%"
/>
</KCheckbox.Indicator>
</KCheckbox.Control>
<Orienter orientation={props.orientation} align={"start"}>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}
{...props}
/>
<KCheckbox.Input {...props.input} />
<KCheckbox.Control class="checkbox-control">
<KCheckbox.Indicator>
<Icon
icon="Checkmark"
inverted={props.inverted}
color="secondary"
size="100%"
/>
</KCheckbox.Indicator>
</KCheckbox.Control>
</Orienter>
</KCheckbox>
);

View File

@@ -1,14 +1,12 @@
@import "Field.css";
div.form-field.combobox {
& > div.control {
div.control {
@apply flex flex-col w-full gap-2;
& > div.selected-options {
div.selected-options {
@apply flex flex-wrap gap-1 w-full min-h-5;
}
& > div.input-container {
div.input-container {
@apply relative left-0 top-0;
@apply inline-flex justify-between w-full;
@@ -68,13 +66,13 @@ div.form-field.combobox {
&.horizontal {
@apply flex-row gap-2 justify-between;
& > div.control {
div.control {
@apply w-1/2 grow;
}
}
&.s {
& > div.control > div.input-container {
div.control > div.input-container {
& > input {
@apply px-1.5 py-1;
font-size: 0.75rem;
@@ -87,7 +85,7 @@ div.form-field.combobox {
}
&.inverted {
& > div.control > div.input-container {
div.control > div.input-container {
& > button.trigger {
@apply bg-inv-2;
}
@@ -118,7 +116,7 @@ div.form-field.combobox {
}
&.ghost {
& > div.control > div.input-container {
div.control > div.input-container {
& > input {
@apply outline-none;

View File

@@ -10,6 +10,7 @@ import { CollectionNode } from "@kobalte/core";
import { Label } from "./Label";
import cx from "classnames";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
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";
@@ -100,6 +101,8 @@ export const Combobox = <Option, OptGroup = never>(
const itemControl = () => props.itemControl || DefaultItemControl;
const itemComponent = () => props.itemComponent || DefaultItemComponent;
const align = () => (props.orientation === "horizontal" ? "start" : "center");
return (
<KCombobox
class={cx("form-field", "combobox", props.size, props.orientation, {
@@ -109,29 +112,31 @@ export const Combobox = <Option, OptGroup = never>(
{...props}
itemComponent={itemComponent()}
>
<Label
labelComponent={KCombobox.Label}
descriptionComponent={KCombobox.Description}
{...props}
/>
<Orienter orientation={props.orientation} align={align()}>
<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.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.Portal>
<KCombobox.Content class="combobox-content">
<KCombobox.Listbox class="listbox" />
</KCombobox.Content>
</KCombobox.Portal>
</Orienter>
</KCombobox>
);
};

View File

@@ -1,11 +0,0 @@
div.form-field {
@apply flex flex-col gap-2 items-center w-full;
&.horizontal {
@apply flex-row gap-2 justify-between;
& > div.form-label {
@apply w-1/2 shrink;
}
}
}

View File

@@ -1,6 +1,3 @@
import { Size } from "@/src/components/v2/Form/Label";
import { Orientation } from "@/src/components/v2/shared";
export interface FieldProps {
class?: string;
label?: string;
@@ -8,7 +5,7 @@ export interface FieldProps {
tooltip?: string;
ghost?: boolean;
size?: Size;
orientation?: Orientation;
size?: "default" | "s";
orientation?: "horizontal" | "vertical";
inverted?: boolean;
}

View File

@@ -0,0 +1,22 @@
div.orienter {
@apply flex flex-col gap-2 w-full;
&.vertical {
}
&.horizontal {
@apply flex-row gap-2 justify-between;
& > div.form-label {
@apply w-1/2 shrink;
}
}
&.align-center {
@apply items-center;
}
&.align-start {
@apply items-start;
}
}

View File

@@ -0,0 +1,20 @@
import cx from "classnames";
import { JSX } from "solid-js";
import "./Orienter.css";
export interface OrienterProps {
orientation?: "vertical" | "horizontal";
align?: "center" | "start";
children: JSX.Element;
}
export const Orienter = (props: OrienterProps) => {
const alignment = () => `align-${props.align || "center"}`;
return (
<div class={cx("orienter", alignment(), props.orientation)}>
{props.children}
</div>
);
};

View File

@@ -10,6 +10,7 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
import "./TextInput.css";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
export type TextAreaProps = FieldProps &
TextFieldRootProps & {
@@ -24,11 +25,13 @@ export const TextArea = (props: TextAreaProps) => (
})}
{...props}
>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<TextField.TextArea {...props.input} />
<Orienter orientation={props.orientation} align={"start"}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<TextField.TextArea {...props.input} />
</Orienter>
</TextField>
);

View File

@@ -1,5 +1,3 @@
@import "./Field.css";
div.form-field {
&.text input,
&.textarea textarea {
@@ -47,10 +45,6 @@ div.form-field {
&.textarea textarea {
@apply w-1/2 grow;
}
&.textarea:has(> textarea) {
@apply items-start;
}
}
&.text div.input-container {

View File

@@ -10,6 +10,7 @@ import { Label } from "./Label";
import "./TextInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
export type TextInputProps = FieldProps &
TextFieldRootProps & {
@@ -25,23 +26,25 @@ export const TextInput = (props: TextInputProps) => (
})}
{...props}
>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<div class="input-container">
{props.icon && (
<Icon
icon={props.icon}
inverted={props.inverted}
color={props.disabled ? "tertiary" : "quaternary"}
/>
)}
<TextField.Input
{...props.input}
classList={{ "has-icon": props.icon }}
<Orienter orientation={props.orientation}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
</div>
<div class="input-container">
{props.icon && (
<Icon
icon={props.icon}
inverted={props.inverted}
color={props.disabled ? "tertiary" : "quaternary"}
/>
)}
<TextField.Input
{...props.input}
classList={{ "has-icon": props.icon }}
/>
</div>
</Orienter>
</TextField>
);

View File

@@ -1 +0,0 @@
export type Orientation = "horizontal" | "vertical";