feat(ui): simplify form components

Better pass through to the underlying Kobalte API without re-defining types.
This commit is contained in:
Brian McGee
2025-07-01 10:32:05 +01:00
parent 9d257c1538
commit 3b4fa41840
16 changed files with 611 additions and 543 deletions

View File

@@ -1,7 +1,6 @@
import "./Divider.css"; import "./Divider.css";
import cx from "classnames"; import cx from "classnames";
import { Orientation } from "@/src/components/v2/shared";
export type Orientation = "horizontal" | "vertical";
export interface DividerProps { export interface DividerProps {
inverted?: boolean; inverted?: boolean;

View File

@@ -0,0 +1,50 @@
@import "./Field.css";
div.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];
&:hover {
@apply bg-def-acc-2;
}
&[data-disabled] {
@apply border-def-2;
}
&[data-invalid] {
@apply border-semantic-error-4;
}
}
}
&.horizontal.checkbox {
@apply items-center;
}
&.inverted {
&.checkbox {
& > div.checkbox-control {
@apply bg-inv-1;
&:hover,
&[data-checked] {
@apply bg-inv-acc-4;
}
&[data-disabled] {
@apply bg-def-4 border-none;
}
}
}
}
&.s {
& > div.checkbox-control {
@apply w-4 h-4;
}
}
}

View File

@@ -1,16 +1,54 @@
import meta, { Story } from "./TextField.stories"; import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames";
import { Checkbox, CheckboxProps } from "@/src/components/v2/Form/Checkbox";
const checkboxMeta = { const Examples = (props: CheckboxProps) => (
...meta, <div class="flex flex-col gap-8">
title: "Components/Form/Fields/Checkbox", <div class="flex flex-col gap-8 p-8">
}; <Checkbox {...props} />
<Checkbox {...props} size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Checkbox {...props} inverted={true} />
<Checkbox {...props} inverted={true} size="s" />
</div>
<div class="flex flex-col gap-8 p-8">
<Checkbox {...props} orientation="horizontal" />
<Checkbox {...props} orientation="horizontal" size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Checkbox {...props} inverted={true} orientation="horizontal" />
<Checkbox {...props} inverted={true} orientation="horizontal" size="s" />
</div>
</div>
);
export default checkboxMeta; const meta = {
title: "Components/Form/Checkbox",
component: Examples,
decorators: [
(Story: StoryObj, context: StoryContext<CheckboxProps>) => {
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<CheckboxProps>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Bare: Story = { export const Bare: Story = {
args: { args: {},
type: "checkbox",
},
}; };
export const Label: Story = { export const Label: Story = {
@@ -45,7 +83,7 @@ export const Tooltip: Story = {
export const Invalid: Story = { export const Invalid: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
invalid: true, validationState: "invalid",
}, },
}; };
@@ -60,9 +98,6 @@ export const ReadOnly: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
readOnly: true, readOnly: true,
checkbox: { defaultChecked: true,
...Tooltip.args.checkbox,
defaultChecked: true,
},
}, },
}; };

View File

@@ -0,0 +1,44 @@
import {
Checkbox as KCheckbox,
CheckboxInputProps as KCheckboxInputProps,
CheckboxRootProps as KCheckboxRootProps,
} from "@kobalte/core/checkbox";
import Icon from "@/src/components/v2/Icon/Icon";
import cx from "classnames";
import { Label } from "./Label";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import "./Checkbox.css";
import { FieldProps } from "./Field";
export type CheckboxProps = FieldProps &
KCheckboxRootProps & {
input?: PolymorphicProps<"input", KCheckboxInputProps<"input">>;
};
export const Checkbox = (props: CheckboxProps) => (
<KCheckbox
class={cx("form-field", "checkbox", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...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>
</KCheckbox>
);

View File

@@ -1,221 +1,11 @@
div.form-field { div.form-field {
@apply flex items-center w-full; @apply flex flex-col gap-2 items-center w-full;
& > div.meta { &.horizontal {
@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; @apply flex-row gap-2 justify-between;
& > div.meta { & > div.form-label {
@apply w-1/2 shrink; @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];
&:hover {
@apply bg-def-acc-2;
}
&[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-1;
&:hover,
&[data-checked] {
@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

@@ -1,214 +1,14 @@
import { import { Size } from "@/src/components/v2/Form/Label";
TextField as KTextField, import { Orientation } from "@/src/components/v2/shared";
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";
export 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 { export interface FieldProps {
class?: string; class?: string;
name?: string;
label?: string; label?: string;
description?: string; description?: string;
tooltip?: string; tooltip?: string;
icon?: IconVariant;
ghost?: boolean; ghost?: boolean;
size?: Size; size?: Size;
orientation?: Orientation; orientation?: Orientation;
inverted?: boolean; 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

@@ -1,6 +1,13 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid"; import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { Fieldset, FieldsetProps } from "@/src/components/v2/Form/Fieldset"; import {
Fieldset,
FieldsetProps,
} from "@/src/components/v2/Form/Fieldset";
import cx from "classnames"; import cx from "classnames";
import { TextInput } from "@/src/components/v2/Form/TextInput";
import { TextArea } from "@/src/components/v2/Form/TextArea";
import { Checkbox } from "@/src/components/v2/Form/Checkbox";
import { FieldProps } from "./Field";
const FieldsetExamples = (props: FieldsetProps) => ( const FieldsetExamples = (props: FieldsetProps) => (
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
@@ -36,30 +43,28 @@ export type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {
args: { args: {
legend: "Signup", legend: "Signup",
fields: [ fields: (props: FieldProps) => (
{ <>
type: "text", <TextInput
label: "First Name", {...props}
required: true, label="First Name"
control: { placeholder: "Ron" }, required={true}
}, input={{ placeholder: "Ron" }}
{ />
type: "text", <TextInput
label: "Last Name", {...props}
required: true, label="Last Name"
control: { placeholder: "Burgundy" }, required={true}
}, input={{ placeholder: "Burgundy" }}
{ />
type: "textarea", <TextArea
label: "Bio", {...props}
control: { placeholder: "Tell us a bit about yourself", rows: 8 }, label="Bio"
}, input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
{ />
type: "checkbox", <Checkbox {...props} label="Accept Terms" required={true} />
label: "Accept Terms & Conditions", </>
required: true, ),
},
],
}, },
}; };
@@ -88,32 +93,34 @@ export const Error: Story = {
args: { args: {
legend: "Signup", legend: "Signup",
error: "You must enter a First Name", error: "You must enter a First Name",
fields: [ fields: (props: FieldProps) => (
{ <>
type: "text", <TextInput
label: "First Name", {...props}
required: true, label="First Name"
invalid: true, required={true}
control: { placeholder: "Ron" }, validationState="invalid"
}, input={{ placeholder: "Ron" }}
{ />
type: "text", <TextInput
label: "Last Name", {...props}
required: true, label="Last Name"
invalid: true, required={true}
control: { placeholder: "Burgundy" }, validationState="invalid"
}, input={{ placeholder: "Burgundy" }}
{ />
type: "textarea", <TextArea
label: "Bio", {...props}
control: { placeholder: "Tell us a bit about yourself", rows: 8 }, label="Bio"
}, input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
{ />
type: "checkbox", <Checkbox
label: "Accept Terms & Conditions", {...props}
invalid: true, label="Accept Terms"
required: true, validationState="invalid"
}, required={true}
], />
</>
),
}, },
}; };

View File

@@ -1,16 +1,14 @@
import "./Fieldset.css"; import "./Fieldset.css";
import { Field, FieldProps, Orientation } from "./Field"; import { JSX } from "solid-js";
import { For } from "solid-js";
import cx from "classnames"; import cx from "classnames";
import { Typography } from "@/src/components/v2/Typography/Typography"; import { Typography } from "@/src/components/v2/Typography/Typography";
import { FieldProps } from "./Field";
export interface FieldsetProps { export interface FieldsetProps extends FieldProps {
legend: string; legend: string;
fields: FieldProps[]; disabled: boolean;
inverted?: boolean;
disabled?: boolean;
orientation?: Orientation;
error?: string; error?: string;
fields: (props: FieldProps) => JSX.Element;
} }
export const Fieldset = (props: FieldsetProps) => { export const Fieldset = (props: FieldsetProps) => {
@@ -36,18 +34,7 @@ export const Fieldset = (props: FieldsetProps) => {
</Typography> </Typography>
</legend> </legend>
<div class="fields"> <div class="fields">
<For each={props.fields}> {props.fields({ ...props, orientation: orientation() })}
{(fieldProps) => {
return (
<Field
{...fieldProps}
orientation={orientation()}
disabled={props.disabled}
inverted={props.inverted}
/>
);
}}
</For>
</div> </div>
{props.error && ( {props.error && (
<div class="error" role="alert"> <div class="error" role="alert">

View File

@@ -0,0 +1,24 @@
div.form-label {
@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;
}
}
}

View File

@@ -0,0 +1,85 @@
import { Show } from "solid-js";
import { Typography } from "@/src/components/v2/Typography/Typography";
import { Tooltip as KTooltip } from "@kobalte/core/tooltip";
import Icon from "@/src/components/v2/Icon/Icon";
import { TextField } from "@kobalte/core/text-field";
import { Checkbox } from "@kobalte/core/checkbox";
import { Combobox } from "@kobalte/core/combobox";
import "./Label.css";
export type Size = "default" | "s";
export type LabelComponent =
| typeof TextField.Label
| typeof Checkbox.Label
| typeof Combobox.Label;
export type DescriptionComponent =
| typeof TextField.Description
| typeof Checkbox.Description
| typeof Combobox.Description;
export interface LabelProps {
labelComponent: LabelComponent;
descriptionComponent: DescriptionComponent;
size?: Size;
label?: string;
description?: string;
tooltip?: string;
icon?: string;
inverted?: boolean;
validationState?: "valid" | "invalid";
}
export const Label = (props: LabelProps) => {
const descriptionSize = () => (props.size == "default" ? "xs" : "xxs");
return (
<Show when={props.label}>
<div class="form-label">
<props.labelComponent>
<Typography
hierarchy="label"
size={props.size || "default"}
color={props.validationState == "invalid" ? "error" : "primary"}
weight="bold"
inverted={props.inverted}
>
{props.label}
</Typography>
{props.tooltip && (
<KTooltip>
<KTooltip.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>
)}
</props.labelComponent>
{props.description && (
<props.descriptionComponent>
<Typography
hierarchy="body"
size={descriptionSize()}
color="secondary"
weight="normal"
inverted={props.inverted}
>
{props.description}
</Typography>
</props.descriptionComponent>
)}
</div>
</Show>
);
};

View File

@@ -1,20 +1,57 @@
import meta, { Story } from "./TextField.stories"; import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames";
import { TextArea, TextAreaProps } from "./TextArea";
const textAreaMeta = { const Examples = (props: TextAreaProps) => (
...meta, <div class="flex flex-col gap-8">
title: "Components/Form/Fields/TextArea", <div class="flex flex-col gap-8 p-8">
}; <TextArea {...props} />
<TextArea {...props} size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<TextArea {...props} inverted={true} />
<TextArea {...props} inverted={true} size="s" />
</div>
<div class="flex flex-col gap-8 p-8">
<TextArea {...props} orientation="horizontal" />
<TextArea {...props} orientation="horizontal" size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<TextArea {...props} inverted={true} orientation="horizontal" />
<TextArea {...props} inverted={true} orientation="horizontal" size="s" />
</div>
</div>
);
export default textAreaMeta; const meta = {
title: "Components/Form/TextArea",
component: Examples,
decorators: [
(Story: StoryObj, context: StoryContext<TextAreaProps>) => {
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<TextAreaProps>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Bare: Story = { export const Bare: Story = {
args: { args: {
type: "textarea", input: {
textarea: { rows: 10,
input: { placeholder: "I like craft beer and long walks on the beach",
rows: 10,
placeholder: "I like craft beer and long walks on the beach",
},
}, },
}, },
}; };
@@ -58,7 +95,7 @@ export const Ghost: Story = {
export const Invalid: Story = { export const Invalid: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
invalid: true, validationState: "invalid",
}, },
}; };
@@ -73,10 +110,7 @@ export const ReadOnly: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
readOnly: true, readOnly: true,
textarea: { defaultValue:
...Tooltip.args.textarea, "Good evening. I'm Ron Burgundy, and this is what's happening in your world tonight. ",
defaultValue:
"Good evening. I'm Ron Burgundy, and this is what's happening in your world tonight. ",
},
}, },
}; };

View File

@@ -0,0 +1,34 @@
import {
TextField,
TextFieldRootProps,
TextFieldTextAreaProps,
} from "@kobalte/core/text-field";
import cx from "classnames";
import { Label } from "./Label";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import "./TextInput.css";
import { FieldProps } from "./Field";
export type TextAreaProps = FieldProps &
TextFieldRootProps & {
input?: PolymorphicProps<"textarea", TextFieldTextAreaProps<"input">>;
};
export const TextArea = (props: TextAreaProps) => (
<TextField
class={cx("form-field", "textarea", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<TextField.TextArea {...props.input} />
</TextField>
);

View File

@@ -0,0 +1,136 @@
@import "./Field.css";
div.form-field {
&.text input,
&.textarea 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-size: 0.875rem;
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;
}
}
&.horizontal {
@apply flex-row gap-2 justify-between;
&.text div.input-container,
&.textarea textarea {
@apply w-1/2 grow;
}
&.textarea:has(> textarea) {
@apply items-start;
}
}
&.text div.input-container {
@apply inline-block relative w-full h-[1.875rem];
/* I'm unsure why I have to do this */
@apply leading-none;
& > input {
@apply w-full h-[1.875rem];
&.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;
}
}
&.s {
&.text input,
&.textarea textarea {
@apply px-1.5 py-1;
font-size: 0.75rem;
}
&.text 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 {
&.text input,
&.textarea 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;
}
}
}
&.ghost {
&.text input,
&.textarea textarea {
@apply outline-none;
&:hover {
@apply outline-none;
}
}
}
}

View File

@@ -1,33 +1,33 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid"; import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { Field, FieldProps } from "./Field";
import cx from "classnames"; import cx from "classnames";
import { TextInput, TextInputProps } from "@/src/components/v2/Form/TextInput";
const FieldExamples = (props: FieldProps) => ( const Examples = (props: TextInputProps) => (
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
<div class="flex flex-col gap-8 p-8"> <div class="flex flex-col gap-8 p-8">
<Field {...props} /> <TextInput {...props} />
<Field {...props} size="s" /> <TextInput {...props} size="s" />
</div> </div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3"> <div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Field {...props} inverted={true} /> <TextInput {...props} inverted={true} />
<Field {...props} inverted={true} size="s" /> <TextInput {...props} inverted={true} size="s" />
</div> </div>
<div class="flex flex-col gap-8 p-8"> <div class="flex flex-col gap-8 p-8">
<Field {...props} orientation="horizontal" /> <TextInput {...props} orientation="horizontal" />
<Field {...props} orientation="horizontal" size="s" /> <TextInput {...props} orientation="horizontal" size="s" />
</div> </div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3"> <div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Field {...props} inverted={true} orientation="horizontal" /> <TextInput {...props} inverted={true} orientation="horizontal" />
<Field {...props} inverted={true} orientation="horizontal" size="s" /> <TextInput {...props} inverted={true} orientation="horizontal" size="s" />
</div> </div>
</div> </div>
); );
const meta = { const meta = {
title: "Components/Form/Fields/TextField", title: "Components/Form/TextInput",
component: FieldExamples, component: Examples,
decorators: [ decorators: [
(Story: StoryObj, context: StoryContext<FieldProps>) => { (Story: StoryObj, context: StoryContext<TextInputProps>) => {
return ( return (
<div <div
class={cx({ class={cx({
@@ -41,7 +41,7 @@ const meta = {
); );
}, },
], ],
} satisfies Meta<FieldProps>; } satisfies Meta<TextInputProps>;
export default meta; export default meta;
@@ -49,11 +49,8 @@ export type Story = StoryObj<typeof meta>;
export const Bare: Story = { export const Bare: Story = {
args: { args: {
type: "text", input: {
text: { placeholder: "e.g. 11/06/89",
input: {
placeholder: "e.g. 11/06/89",
},
}, },
}, },
}; };
@@ -103,7 +100,7 @@ export const Ghost: Story = {
export const Invalid: Story = { export const Invalid: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
invalid: true, validationState: "invalid",
}, },
}; };
@@ -118,8 +115,6 @@ export const ReadOnly: Story = {
args: { args: {
...Icon.args, ...Icon.args,
readOnly: true, readOnly: true,
text: { defaultValue: "14/05/02",
defaultValue: "14/05/02",
},
}, },
}; };

View File

@@ -0,0 +1,47 @@
import {
TextField,
TextFieldInputProps,
TextFieldRootProps,
} from "@kobalte/core/text-field";
import Icon, { IconVariant } from "@/src/components/v2/Icon/Icon";
import cx from "classnames";
import { Label } from "./Label";
import "./TextInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
export type TextInputProps = FieldProps &
TextFieldRootProps & {
icon?: IconVariant;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
};
export const TextInput = (props: TextInputProps) => (
<TextField
class={cx("form-field", "text", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...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 }}
/>
</div>
</TextField>
);

View File

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