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": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0", "eslint-plugin-tailwindcss": "^3.17.0",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"extend": "^3.0.2",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"knip": "^5.61.2", "knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0", "playwright": "~1.52.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-url": "^10.1.3", "postcss-url": "^10.1.3",
@@ -4529,6 +4531,13 @@
"node": ">=12.0.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5684,6 +5693,19 @@
"node": ">=10" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

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

View File

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

View File

@@ -1,15 +1,14 @@
import "./Divider.css"; import "./Divider.css";
import cx from "classnames"; import cx from "classnames";
import { Orientation } from "@/src/components/v2/shared";
export interface DividerProps { export interface DividerProps {
inverted?: boolean; inverted?: boolean;
orientation?: Orientation; orientation?: "horizontal" | "vertical";
} }
export const Divider = (props: DividerProps) => { export const Divider = (props: DividerProps) => {
const inverted = props.inverted || false; 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 { div.form-field {
&.checkbox { &.checkbox {
@apply items-start; @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]; @apply w-5 h-5 rounded-sm bg-def-1 border border-inv-1 p-[0.0625rem];
&:hover { &:hover {
@@ -21,13 +19,9 @@ div.form-field {
} }
} }
&.horizontal.checkbox {
@apply items-center;
}
&.inverted { &.inverted {
&.checkbox { &.checkbox {
& > div.checkbox-control { & div.checkbox-control {
@apply bg-inv-1; @apply bg-inv-1;
&:hover, &:hover,
@@ -43,7 +37,7 @@ div.form-field {
} }
&.s { &.s {
& > div.checkbox-control { & div.checkbox-control {
@apply w-4 h-4; @apply w-4 h-4;
} }
} }

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import { CollectionNode } from "@kobalte/core";
import { Label } from "./Label"; import { Label } from "./Label";
import cx from "classnames"; import cx from "classnames";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { Typography } from "@/src/components/v2/Typography/Typography"; import { Typography } from "@/src/components/v2/Typography/Typography";
import { Accessor, Component, For, Show, splitProps } from "solid-js"; import { Accessor, Component, For, Show, splitProps } from "solid-js";
import { Tag } from "@/src/components/v2/Tag/Tag"; import { Tag } from "@/src/components/v2/Tag/Tag";
@@ -100,6 +101,8 @@ export const Combobox = <Option, OptGroup = never>(
const itemControl = () => props.itemControl || DefaultItemControl; const itemControl = () => props.itemControl || DefaultItemControl;
const itemComponent = () => props.itemComponent || DefaultItemComponent; const itemComponent = () => props.itemComponent || DefaultItemComponent;
const align = () => (props.orientation === "horizontal" ? "start" : "center");
return ( return (
<KCombobox <KCombobox
class={cx("form-field", "combobox", props.size, props.orientation, { class={cx("form-field", "combobox", props.size, props.orientation, {
@@ -109,29 +112,31 @@ export const Combobox = <Option, OptGroup = never>(
{...props} {...props}
itemComponent={itemComponent()} itemComponent={itemComponent()}
> >
<Label <Orienter orientation={props.orientation} align={align()}>
labelComponent={KCombobox.Label} <Label
descriptionComponent={KCombobox.Description} labelComponent={KCombobox.Label}
{...props} descriptionComponent={KCombobox.Description}
/> {...props}
/>
<KCombobox.Control<Option> class="control"> <KCombobox.Control<Option> class="control">
{(state) => { {(state) => {
const [controlProps] = splitProps(props, [ const [controlProps] = splitProps(props, [
"inverted", "inverted",
"multiple", "multiple",
"readOnly", "readOnly",
"disabled", "disabled",
]); ]);
return itemControl()({ ...state, ...controlProps }); return itemControl()({ ...state, ...controlProps });
}} }}
</KCombobox.Control> </KCombobox.Control>
<KCombobox.Portal> <KCombobox.Portal>
<KCombobox.Content class="combobox-content"> <KCombobox.Content class="combobox-content">
<KCombobox.Listbox class="listbox" /> <KCombobox.Listbox class="listbox" />
</KCombobox.Content> </KCombobox.Content>
</KCombobox.Portal> </KCombobox.Portal>
</Orienter>
</KCombobox> </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 { export interface FieldProps {
class?: string; class?: string;
label?: string; label?: string;
@@ -8,7 +5,7 @@ export interface FieldProps {
tooltip?: string; tooltip?: string;
ghost?: boolean; ghost?: boolean;
size?: Size; size?: "default" | "s";
orientation?: Orientation; orientation?: "horizontal" | "vertical";
inverted?: boolean; 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 "./TextInput.css";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
export type TextAreaProps = FieldProps & export type TextAreaProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
@@ -24,11 +25,13 @@ export const TextArea = (props: TextAreaProps) => (
})} })}
{...props} {...props}
> >
<Label <Orienter orientation={props.orientation} align={"start"}>
labelComponent={TextField.Label} <Label
descriptionComponent={TextField.Description} labelComponent={TextField.Label}
{...props} descriptionComponent={TextField.Description}
/> {...props}
<TextField.TextArea {...props.input} /> />
<TextField.TextArea {...props.input} />
</Orienter>
</TextField> </TextField>
); );

View File

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

View File

@@ -10,6 +10,7 @@ import { Label } from "./Label";
import "./TextInput.css"; import "./TextInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic"; import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
export type TextInputProps = FieldProps & export type TextInputProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
@@ -25,23 +26,25 @@ export const TextInput = (props: TextInputProps) => (
})} })}
{...props} {...props}
> >
<Label <Orienter orientation={props.orientation}>
labelComponent={TextField.Label} <Label
descriptionComponent={TextField.Description} labelComponent={TextField.Label}
{...props} 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 }}
/> />
</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> </TextField>
); );

View File

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