feat(ui): add form field with text, textarea and checkbox support

This commit is contained in:
Brian McGee
2025-06-25 13:31:29 +01:00
parent fbb93c8412
commit 04c59c76ee
9 changed files with 721 additions and 1 deletions

View File

@@ -0,0 +1,68 @@
import meta, { Story } from "./TextField.stories";
const checkboxMeta = {
...meta,
title: "Components/Form/Checkbox",
};
export default checkboxMeta;
export const Bare: Story = {
args: {
type: "checkbox",
},
};
export const Label: Story = {
args: {
...Bare.args,
label: "Accept Terms",
},
};
export const Description: Story = {
args: {
...Label.args,
description: "That stuff you never bother reading",
},
};
export const Required: Story = {
args: {
...Description.args,
required: true,
},
};
export const Tooltip: Story = {
args: {
...Required.args,
tooltip:
"Let people know how you got here, great achievements or obstacles overcome",
},
};
export const Invalid: Story = {
args: {
...Tooltip.args,
invalid: true,
},
};
export const Disabled: Story = {
args: {
...Tooltip.args,
disabled: true,
},
};
export const ReadOnly: Story = {
args: {
...Tooltip.args,
readOnly: true,
checkbox: {
...Tooltip.args.checkbox,
defaultChecked: true,
},
},
};

View File

@@ -0,0 +1,212 @@
div.form-field {
@apply flex items-center w-full;
& > div.meta {
@apply flex flex-col gap-1 w-full;
& > label,
& > div {
@apply w-full;
/* remove line height which messes with sizing */
@apply leading-none;
}
& > label {
@apply flex items-center gap-1;
}
& > label[data-required] {
span.typography::after {
@apply fg-def-4 ml-1;
content: "*";
font-family: "Commit Mono", monospace;
font-size: 0.6875rem;
}
}
.tooltip-trigger {
}
}
& input,
& textarea {
@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-weight: 500;
font-family: "Archivo", sans-serif;
line-height: 132%;
&::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;
}
}
&.orientation-vertical {
@apply flex-col gap-2;
}
&.orientation-horizontal {
@apply flex-row gap-2 justify-between;
& > div.meta {
@apply w-1/2 shrink;
}
& div.input-container,
& textarea {
@apply w-1/2 grow;
}
&:has(> textarea) {
@apply items-start;
}
}
div.input-container {
@apply inline-block relative w-full;
/* I'm unsure why I have to do this */
@apply leading-none;
& > input {
@apply w-full;
&.has-icon {
@apply pl-7;
}
}
& > .icon {
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
@apply w-[0.875rem] h-[0.875rem] pointer-events-none;
}
}
&.form-field-checkbox {
@apply items-start;
& > div.checkbox-control {
@apply w-5 h-5 rounded-sm bg-def-1 border border-inv-1 p-[0.0625rem];
&[data-disabled] {
@apply border-def-2;
}
&[data-invalid] {
@apply border-semantic-error-4;
}
}
}
&.size-default {
& input,
& textarea {
font-size: 0.875rem;
}
div.input-container {
@apply h-[1.875rem];
input {
@apply h-[1.875rem];
}
}
}
&.size-s {
& input,
& textarea {
@apply px-1.5 py-1;
font-size: 0.75rem;
}
div.input-container {
@apply h-[1.25rem];
input {
@apply h-[1.25rem];
}
input.has-icon {
@apply pl-6;
}
& > .icon {
@apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2;
}
}
}
&.inverted {
& input,
& textarea {
@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;
}
}
&.form-field-checkbox {
& > div.checkbox-control {
@apply bg-inv-acc-4;
&[data-disabled] {
@apply bg-def-4 border-none;
}
}
}
}
&.ghost {
& input,
& textarea {
@apply outline-none;
&:hover {
@apply outline-none;
}
}
}
}

View File

@@ -0,0 +1,214 @@
import {
TextField as KTextField,
TextFieldInputProps as KTextFieldInputProps,
TextFieldTextAreaProps as KTextFieldTextAreaProps,
} from "@kobalte/core/text-field";
import {
Checkbox as KCheckbox,
CheckboxInputProps as KCheckboxInputProps,
} from "@kobalte/core/checkbox";
import { Typography } from "@/src/components/v2/Typography/Typography";
import Icon, { IconVariant } from "@/src/components/v2/Icon/Icon";
import cx from "classnames";
import { Match, splitProps, Switch } from "solid-js";
import { Dynamic } from "solid-js/web";
import { Tooltip as KTooltip } from "@kobalte/core/tooltip";
import "./Field.css";
type Size = "default" | "s";
type Orientation = "horizontal" | "vertical";
type FieldType = "text" | "textarea" | "checkbox";
export interface TextFieldProps {
input?: KTextFieldInputProps;
value?: string;
onChange?: (value: string) => void;
defaultValue?: string;
}
export interface TextAreaProps {
input?: KTextFieldTextAreaProps;
value?: string;
onChange?: (value: string) => void;
defaultValue?: string;
}
export interface CheckboxProps {
input?: KCheckboxInputProps;
checked?: boolean;
defaultChecked?: boolean;
indeterminate?: boolean;
onChange?: (value: boolean) => void;
}
export interface FieldProps {
class?: string;
name?: string;
label?: string;
description?: string;
tooltip?: string;
icon?: IconVariant;
ghost?: boolean;
size?: Size;
orientation?: Orientation;
inverted?: boolean;
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
invalid?: boolean;
type: FieldType;
text?: TextFieldProps;
textarea?: TextAreaProps;
checkbox?: CheckboxProps;
}
const componentsForType = {
text: {
container: KTextField,
label: KTextField.Label,
description: KTextField.Description,
},
textarea: {
container: KTextField,
label: KTextField.Label,
description: KTextField.Description,
},
checkbox: {
container: KCheckbox,
label: KCheckbox.Label,
description: KCheckbox.Description,
},
};
export const Field = (props: FieldProps) => {
const [commonProps] = splitProps(props, [
"name",
"class",
"required",
"disabled",
"readOnly",
]);
const [textProps] = splitProps(props.text || props.textarea || {}, [
"value",
"onChange",
"defaultValue",
]);
const [checkboxProps] = splitProps(props.checkbox || {}, [
"checked",
"defaultChecked",
"indeterminate",
]);
const validationState = () => (props.invalid ? "invalid" : "valid");
const labelSize = () => `size-${props.size || "default"}`;
const orientation = () => `orientation-${props.orientation || "vertical"}`;
const descriptionSize = () => (labelSize() == "size-default" ? "xs" : "xxs");
const fieldClass = () => (props.type ? `form-field-${props.type}` : "");
const { container, label, description } = componentsForType[props.type];
return (
<Dynamic
component={container}
class={cx("form-field", fieldClass(), labelSize(), orientation(), {
inverted: props.inverted,
ghost: props.ghost,
})}
validationState={validationState()}
{...commonProps}
{...textProps}
{...checkboxProps}
>
{props.label && (
<div class="meta">
{props.label && (
<Dynamic component={label}>
<Typography
hierarchy="label"
size={props.size || "default"}
color={props.invalid ? "error" : "primary"}
weight="bold"
inverted={props.inverted}
>
{props.label}
</Typography>
{props.tooltip && (
<KTooltip>
<KTooltip.Trigger class="tooltip-trigger">
<Icon
icon="Info"
color="tertiary"
inverted={props.inverted}
size={props.size == "default" ? "0.85em" : "0.75rem"}
/>
<KTooltip.Portal>
<KTooltip.Content>
<Typography hierarchy="body" size="xs">
{props.tooltip}
</Typography>
</KTooltip.Content>
</KTooltip.Portal>
</KTooltip.Trigger>
</KTooltip>
)}
</Dynamic>
)}
{props.description && (
<Dynamic component={description}>
<Typography
hierarchy="body"
size={descriptionSize()}
color="secondary"
weight="normal"
inverted={props.inverted}
>
{props.description}
</Typography>
</Dynamic>
)}
</div>
)}
<Switch>
<Match when={props.type == "text"}>
<div class="input-container">
{props.icon && (
<Icon
icon={props.icon}
inverted={props.inverted}
color={props.disabled ? "quaternary" : "tertiary"}
/>
)}
<KTextField.Input
{...props.text?.input}
classList={{ "has-icon": props.icon }}
/>
</div>
</Match>
<Match when={props.type == "textarea"}>
<KTextField.TextArea {...props.textarea?.input} />
</Match>
<Match when={props.type == "checkbox"}>
<KCheckbox.Input {...props.checkbox?.input} />
<KCheckbox.Control class="checkbox-control">
<KCheckbox.Indicator>
<Icon
icon="Checkmark"
inverted={props.inverted}
color="secondary"
/>
</KCheckbox.Indicator>
</KCheckbox.Control>
</Match>
</Switch>
</Dynamic>
);
};

View File

@@ -0,0 +1,82 @@
import meta, { Icon, Story } from "./TextField.stories";
const textAreaMeta = {
...meta,
title: "Components/Form/TextArea",
};
export default textAreaMeta;
export const Bare: Story = {
args: {
type: "textarea",
textarea: {
input: {
rows: 10,
placeholder: "I like craft beer and long walks on the beach",
},
},
},
};
export const Label: Story = {
args: {
...Bare.args,
label: "Biography",
},
};
export const Description: Story = {
args: {
...Label.args,
description: "Tell us about yourself",
},
};
export const Required: Story = {
args: {
...Description.args,
required: true,
},
};
export const Tooltip: Story = {
args: {
...Required.args,
tooltip:
"Let people know how you got here, great achievements or obstacles overcome",
},
};
export const Ghost: Story = {
args: {
...Tooltip.args,
ghost: true,
},
};
export const Invalid: Story = {
args: {
...Tooltip.args,
invalid: true,
},
};
export const Disabled: Story = {
args: {
...Tooltip.args,
disabled: true,
},
};
export const ReadOnly: Story = {
args: {
...Tooltip.args,
readOnly: true,
textarea: {
...Tooltip.args.textarea,
defaultValue:
"Good evening. I'm Ron Burgundy, and this is what's happening in your world tonight. ",
},
},
};

View File

@@ -0,0 +1,125 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { Field, FieldProps } from "./Field";
import cx from "classnames";
const FieldExamples = (props: FieldProps) => (
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-8 p-8">
<Field {...props} />
<Field {...props} size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Field {...props} inverted={true} />
<Field {...props} inverted={true} size="s" />
</div>
<div class="flex flex-col gap-8 p-8">
<Field {...props} orientation="horizontal" />
<Field {...props} orientation="horizontal" size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Field {...props} inverted={true} orientation="horizontal" />
<Field {...props} inverted={true} orientation="horizontal" size="s" />
</div>
</div>
);
const meta = {
title: "Components/Form/TextField",
component: FieldExamples,
decorators: [
(Story: StoryObj, context: StoryContext<FieldProps>) => {
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<FieldProps>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Bare: Story = {
args: {
type: "text",
text: {
input: {
placeholder: "e.g. 11/06/89",
},
},
},
};
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 Tooltip: Story = {
args: {
...Required.args,
tooltip: "The day you came out of your momma",
},
};
export const Icon: Story = {
args: {
...Tooltip.args,
icon: "Checkmark",
},
};
export const Ghost: Story = {
args: {
...Icon.args,
ghost: true,
},
};
export const Invalid: Story = {
args: {
...Tooltip.args,
invalid: true,
},
};
export const Disabled: Story = {
args: {
...Icon.args,
disabled: true,
},
};
export const ReadOnly: Story = {
args: {
...Icon.args,
readOnly: true,
text: {
defaultValue: "14/05/02",
},
},
};

View File

@@ -67,6 +67,12 @@
line-height: 1;
letter-spacing: 0.0075rem;
}
&.size-xxs {
font-size: 0.6875rem;
line-height: 1;
letter-spacing: normal;
}
}
&.family-mono {

View File

@@ -21,6 +21,7 @@ interface SizeForHierarchy {
default: string;
s: string;
xs: string;
xxs: string;
};
headline: {
default: string;
@@ -62,6 +63,7 @@ const sizeHierarchyMap: SizeForHierarchy = {
default: cx("size-default"),
s: cx("size-s"),
xs: cx("size-xs"),
xxs: cx("size-xxs"),
},
teaser: {
default: cx("size-default"),

View File

@@ -3,6 +3,7 @@ export type Color =
| "secondary"
| "tertiary"
| "quaternary"
| "error"
| "inherit";
export const AllColors: Color[] = [
@@ -10,6 +11,7 @@ export const AllColors: Color[] = [
"secondary",
"tertiary",
"quaternary",
"error",
"inherit",
];
@@ -18,6 +20,7 @@ const colorMap: Record<Color, string> = {
secondary: "fg-def-2",
tertiary: "fg-def-3",
quaternary: "fg-def-4",
error: "fg-semantic-error-4",
inherit: "text-inherit",
};
@@ -26,6 +29,7 @@ const invertedColorMap: Record<Color, string> = {
secondary: "fg-inv-2",
tertiary: "fg-inv-3",
quaternary: "fg-inv-4",
error: "fg-semantic-error-2",
inherit: "text-inherit",
};

View File

@@ -202,7 +202,7 @@ const colorSystem = {
1: primaries.secondary["950"],
2: primaries.secondary["900"],
3: primaries.secondary["700"],
4: primaries.secondary["400"],
4: primaries.secondary["500"],
},
inv: {
1: primaries.off.white,
@@ -283,6 +283,13 @@ export default plugin.withOptions(
addUtilities(mkColorUtil(["border-r"], "borderRight", border));
addUtilities(mkColorUtil(["border-b"], "borderBottom", border));
addUtilities(mkColorUtil(["border-l"], "borderLeft", border));
// re-use the border colors for outline colors
addUtilities(mkColorUtil(["outline"], "outlineColor", border));
addUtilities(mkColorUtil(["outline-t"], "outlineTop", border));
addUtilities(mkColorUtil(["outline-r"], "outlineRight", border));
addUtilities(mkColorUtil(["outline-b"], "outlineBottom", border));
addUtilities(mkColorUtil(["outline-l"], "outlineLeft", border));
},
// add configuration which is merged with the final config
() => ({