ui/form: use css modules for form components

This commit is contained in:
Glen Huang
2025-09-22 19:52:48 +08:00
parent 5de0d37f0e
commit bc045ee972
13 changed files with 134 additions and 106 deletions

View File

@@ -13,6 +13,7 @@ import styles from "./Checkbox.module.css";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; import { Orienter } from "./Orienter";
import { Match, mergeProps, splitProps, Switch } from "solid-js"; import { Match, mergeProps, splitProps, Switch } from "solid-js";
import { keepTruthy } from "@/src/util";
export type CheckboxProps = FieldProps & export type CheckboxProps = FieldProps &
KCheckboxRootProps & { KCheckboxRootProps & {
@@ -62,6 +63,9 @@ export const Checkbox = (props: CheckboxProps) => {
<Label <Label
labelComponent={KCheckbox.Label} labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description} descriptionComponent={KCheckbox.Description}
in={keepTruthy(
local.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props} {...props}
/> />
<KCheckbox.Input {...local.input} /> <KCheckbox.Input {...local.input} />

View File

@@ -5,17 +5,17 @@
@apply mb-2.5 w-full; @apply mb-2.5 w-full;
} }
div.fields { .fields {
@apply flex flex-col gap-4 w-full rounded-md; @apply flex flex-col gap-4 w-full rounded-md;
@apply px-4 py-5 bg-def-2; @apply px-4 py-5 bg-def-2;
} }
div.error { .error {
@apply w-full; @apply w-full py-2;
} }
&.inverted { &.inverted {
div.fields { .fields {
@apply bg-inv-2; @apply bg-inv-2;
} }
} }

View File

@@ -1,5 +1,5 @@
import "./Fieldset.css"; import styles from "./Fieldset.module.css";
import { JSX, splitProps } from "solid-js"; import { JSX } from "solid-js";
import cx from "classnames"; import cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
@@ -21,23 +21,15 @@ export type FieldsetProps = Pick<FieldProps, "orientation" | "inverted"> & {
}; };
export const Fieldset = (props: FieldsetProps) => { export const Fieldset = (props: FieldsetProps) => {
const [fieldProps] = splitProps(props, [
"orientation",
"inverted",
"disabled",
"error",
"children",
]);
const children = () => const children = () =>
typeof props.children === "function" typeof props.children === "function"
? props.children(fieldProps) ? props.children(props)
: props.children; : props.children;
return ( return (
<div <div
role="group" role="group"
class={cx("fieldset", { inverted: props.inverted })} class={cx(styles.fieldset, { [styles.inverted]: props.inverted })}
aria-disabled={props.disabled || undefined} aria-disabled={props.disabled || undefined}
> >
{props.legend && ( {props.legend && (
@@ -55,9 +47,9 @@ export const Fieldset = (props: FieldsetProps) => {
</Typography> </Typography>
</legend> </legend>
)} )}
<div class="fields">{children()}</div> <div class={styles.fields}>{children()}</div>
{props.error && ( {props.error && (
<div class="error" role="alert"> <div class={styles.error} role="alert">
<Typography <Typography
hierarchy="body" hierarchy="body"
size="xxs" size="xxs"

View File

@@ -14,6 +14,7 @@ import { Orienter } from "./Orienter";
import { createSignal, splitProps } from "solid-js"; import { createSignal, splitProps } from "solid-js";
import { Tooltip } from "@kobalte/core/tooltip"; import { Tooltip } from "@kobalte/core/tooltip";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { keepTruthy } from "@/src/util";
export type HostFileInputProps = FieldProps & export type HostFileInputProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
@@ -40,8 +41,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
} }
}; };
const [styleProps, otherProps] = splitProps(props, [ const [local, other] = splitProps(props, [
"class",
"size", "size",
"orientation", "orientation",
"inverted", "inverted",
@@ -49,51 +49,40 @@ export const HostFileInput = (props: HostFileInputProps) => {
]); ]);
return ( return (
<TextField <TextField {...other}>
class={cx(
styleProps.class,
"form-field",
styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherProps}
>
<Orienter <Orienter
orientation={styleProps.orientation} orientation={local.orientation}
align={styleProps.orientation == "horizontal" ? "center" : "start"} align={local.orientation == "horizontal" ? "center" : "start"}
> >
<Label <Label
labelComponent={TextField.Label} labelComponent={TextField.Label}
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}
in={keepTruthy(
local.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props} {...props}
/> />
<TextField.Input <TextField.Input
{...props.input}
hidden={true} hidden={true}
value={value()} value={value()}
ref={(el: HTMLInputElement) => { ref={(el: HTMLInputElement) => {
actualInputElement = el; // Capture for local use actualInputElement = el; // Capture for local use
}} }}
{...props.input}
/> />
{!value() && ( {!value() && (
<Button <Button
hierarchy="secondary" hierarchy="secondary"
size={styleProps.size} size={local.size}
icon="Folder" icon="Folder"
onClick={selectFile} onClick={selectFile}
disabled={props.disabled || props.readOnly} disabled={other.disabled || other.readOnly}
elasticity={ elasticity={local.orientation === "vertical" ? "fit" : undefined}
styleProps.orientation === "vertical" ? "fit" : undefined
}
in={ in={
styleProps.orientation == "horizontal" local.orientation == "horizontal"
? `HostFileInput-${styleProps.orientation}` ? `HostFileInput-${local.orientation}`
: undefined : undefined
} }
> >
@@ -104,12 +93,16 @@ export const HostFileInput = (props: HostFileInputProps) => {
{value() && ( {value() && (
<Tooltip placement="top"> <Tooltip placement="top">
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content class={styles.tooltipContent}> <Tooltip.Content
class={cx(styles.tooltipContent, {
[styles.inverted]: local.inverted,
})}
>
<Typography <Typography
hierarchy="body" hierarchy="body"
size="xs" size="xs"
weight="medium" weight="medium"
inverted={!styleProps.inverted} inverted={!local.inverted}
> >
{value()} {value()}
</Typography> </Typography>
@@ -118,16 +111,14 @@ export const HostFileInput = (props: HostFileInputProps) => {
</Tooltip.Portal> </Tooltip.Portal>
<Tooltip.Trigger <Tooltip.Trigger
as={Button} as={Button}
elasticity={ elasticity={local.orientation === "vertical" ? "fit" : undefined}
styleProps.orientation === "vertical" ? "fit" : undefined
}
in={ in={
styleProps.orientation == "horizontal" local.orientation == "horizontal"
? `HostFileInput-${styleProps.orientation}` ? `HostFileInput-${local.orientation}`
: undefined : undefined
} }
hierarchy="secondary" hierarchy="secondary"
size={styleProps.size} size={local.size}
icon="Folder" icon="Folder"
onClick={selectFile} onClick={selectFile}
disabled={props.disabled || props.readOnly} disabled={props.disabled || props.readOnly}

View File

@@ -1,4 +1,4 @@
div.form-label { .label {
@apply flex flex-col gap-1 w-full; @apply flex flex-col gap-1 w-full;
& > span, & > span,
@@ -14,3 +14,6 @@ div.form-label {
@apply flex items-center gap-1; @apply flex items-center gap-1;
} }
} }
.label.in-Orienter-horizontal {
@apply w-1/2 shrink;
}

View File

@@ -1,4 +1,4 @@
import { Show } from "solid-js"; import { mergeProps, Show } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { Tooltip } from "@/src/components/Tooltip/Tooltip"; import { Tooltip } from "@/src/components/Tooltip/Tooltip";
import Icon from "@/src/components/Icon/Icon"; import Icon from "@/src/components/Icon/Icon";
@@ -6,7 +6,9 @@ import { TextField } from "@kobalte/core/text-field";
import { Checkbox } from "@kobalte/core/checkbox"; import { Checkbox } from "@kobalte/core/checkbox";
import { Combobox } from "@kobalte/core/combobox"; import { Combobox } from "@kobalte/core/combobox";
import { Select } from "@kobalte/core/select"; import { Select } from "@kobalte/core/select";
import "./Label.css"; import styles from "./Label.module.css";
import cx from "classnames";
import { getInClasses } from "@/src/util";
export type Size = "default" | "s"; export type Size = "default" | "s";
@@ -22,6 +24,7 @@ export type DescriptionComponent =
| typeof Combobox.Description | typeof Combobox.Description
| typeof Select.Description; | typeof Select.Description;
type In = "Orienter-horizontal";
export interface LabelProps { export interface LabelProps {
labelComponent: LabelComponent; labelComponent: LabelComponent;
descriptionComponent: DescriptionComponent; descriptionComponent: DescriptionComponent;
@@ -34,52 +37,57 @@ export interface LabelProps {
inverted?: boolean; inverted?: boolean;
readOnly?: boolean; readOnly?: boolean;
validationState?: "valid" | "invalid"; validationState?: "valid" | "invalid";
in?: In | In[];
} }
export const Label = (props: LabelProps) => { export const Label = (props: LabelProps) => {
const local = mergeProps(
{ size: "default", labelWeight: "bold", validationState: "valid" } as const,
props,
);
const descriptionSize = () => (props.size == "default" ? "s" : "xs"); const descriptionSize = () => (props.size == "default" ? "s" : "xs");
return ( return (
<Show when={props.label}> <Show when={local.label}>
<div class="form-label"> <div class={cx(styles.label, getInClasses(styles, local.in))}>
<props.labelComponent> <local.labelComponent>
<Typography <Typography
hierarchy="label" hierarchy="label"
size={props.size || "default"} size={local.size}
color={props.validationState == "invalid" ? "error" : "primary"} color={local.validationState == "invalid" ? "error" : "primary"}
weight={props.labelWeight || "bold"} weight={local.labelWeight}
inverted={props.inverted} inverted={local.inverted}
in="Label" in="Label"
> >
{props.label} {local.label}
</Typography> </Typography>
{props.tooltip && ( {local.tooltip && (
<Tooltip <Tooltip
placement="top" placement="top"
inverted={props.inverted} inverted={local.inverted}
description={props.tooltip} description={local.tooltip}
> >
<Icon <Icon
icon="Info" icon="Info"
color="tertiary" color="tertiary"
inverted={props.inverted} inverted={local.inverted}
size={props.size == "default" ? "0.85em" : "0.75rem"} size={local.size == "default" ? "0.85em" : "0.75rem"}
/> />
</Tooltip> </Tooltip>
)} )}
</props.labelComponent> </local.labelComponent>
{props.description && ( {local.description && (
<props.descriptionComponent> <local.descriptionComponent>
<Typography <Typography
hierarchy="body" hierarchy="body"
size={descriptionSize()} size={descriptionSize()}
color="secondary" color="secondary"
weight="normal" weight="normal"
inverted={props.inverted} inverted={local.inverted}
> >
{props.description} {local.description}
</Typography> </Typography>
</props.descriptionComponent> </local.descriptionComponent>
)} )}
</div> </div>
</Show> </Show>

View File

@@ -177,7 +177,7 @@ export const MachineTags = (props: MachineTagsProps) => {
return ( return (
<Combobox<MachineTag> <Combobox<MachineTag>
multiple multiple
class={cx("form-field", styles.machineTags, props.orientation)} class={cx(styles.machineTags, props.orientation)}
{...splitProps(props, ["defaultValue"])[1]} {...splitProps(props, ["defaultValue"])[1]}
defaultValue={defaultValue} defaultValue={defaultValue}
value={selectedOptions()} value={selectedOptions()}
@@ -196,6 +196,9 @@ export const MachineTags = (props: MachineTagsProps) => {
<Label <Label
labelComponent={Combobox.Label} labelComponent={Combobox.Label}
descriptionComponent={Combobox.Description} descriptionComponent={Combobox.Description}
in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props} {...props}
/> />

View File

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

View File

@@ -1,7 +1,7 @@
import cx from "classnames"; import cx from "classnames";
import { JSX } from "solid-js"; import { JSX, mergeProps } from "solid-js";
import "./Orienter.css"; import styles from "./Orienter.module.css";
export interface OrienterProps { export interface OrienterProps {
orientation?: "vertical" | "horizontal"; orientation?: "vertical" | "horizontal";
@@ -10,11 +10,17 @@ export interface OrienterProps {
} }
export const Orienter = (props: OrienterProps) => { export const Orienter = (props: OrienterProps) => {
const alignment = () => `align-${props.align || "center"}`; const local = mergeProps({ align: "center" } as const, props);
return ( return (
<div class={cx("orienter", alignment(), props.orientation)}> <div
{props.children} class={cx(
styles.orienter,
styles[`align-${local.align}`],
local.orientation && styles[local.orientation],
)}
>
{local.children}
</div> </div>
); );
}; };

View File

@@ -12,6 +12,7 @@ import { createEffect, createSignal, splitProps } from "solid-js";
import "./TextInput.css"; import "./TextInput.css";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; import { Orienter } from "./Orienter";
import { keepTruthy } from "@/src/util";
export type TextAreaProps = FieldProps & export type TextAreaProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
@@ -117,6 +118,9 @@ export const TextArea = (props: TextAreaProps) => {
<Label <Label
labelComponent={TextField.Label} labelComponent={TextField.Label}
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}
in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props} {...props}
/> />
<TextField.TextArea <TextField.TextArea

View File

@@ -18,6 +18,7 @@ import {
onMount, onMount,
splitProps, splitProps,
} from "solid-js"; } from "solid-js";
import { keepTruthy } from "@/src/util";
export type TextInputProps = FieldProps & export type TextInputProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
@@ -88,6 +89,9 @@ export const TextInput = (props: TextInputProps) => {
<Label <Label
labelComponent={TextField.Label} labelComponent={TextField.Label}
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}
in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props} {...props}
/> />
<div class="input-container"> <div class="input-container">

View File

@@ -47,7 +47,7 @@
} }
} }
.options_content { .optionsContent {
z-index: var(--z-index); z-index: var(--z-index);
@apply bg-def-1 px-1 py-3 rounded-[4px] -mt-8 pt-10 -mx-1; @apply bg-def-1 px-1 py-3 rounded-[4px] -mt-8 pt-10 -mx-1;

View File

@@ -2,11 +2,19 @@ import { Select as KSelect, SelectPortalProps } from "@kobalte/core/select";
import Icon from "../Icon/Icon"; import Icon from "../Icon/Icon";
import { Orienter } from "../Form/Orienter"; import { Orienter } from "../Form/Orienter";
import { Label, LabelProps } from "../Form/Label"; import { Label, LabelProps } from "../Form/Label";
import { createEffect, createSignal, JSX, Show, splitProps } from "solid-js"; import {
createEffect,
createSignal,
JSX,
mergeProps,
Show,
splitProps,
} from "solid-js";
import styles from "./Select.module.css"; import styles from "./Select.module.css";
import { Typography } from "../Typography/Typography"; import { Typography } from "../Typography/Typography";
import cx from "classnames"; import cx from "classnames";
import { useModalContext } from "../Modal/Modal"; import { useModalContext } from "../Modal/Modal";
import { keepTruthy } from "@/src/util";
export interface Option { export interface Option {
value: string; value: string;
@@ -46,13 +54,19 @@ export type SelectProps = {
); );
export const Select = (props: SelectProps) => { export const Select = (props: SelectProps) => {
const [root, selectProps] = splitProps( const [root, selectProps, rest] = splitProps(
props, mergeProps(
{
orientation: "horizontal",
noOptionsText: "No options available",
} as const,
props,
),
["name", "placeholder", "required", "disabled"], ["name", "placeholder", "required", "disabled"],
["placeholder", "ref", "onInput", "onChange", "onBlur"], ["placeholder", "ref", "onInput", "onChange", "onBlur"],
); );
const zIndex = () => props.zIndex ?? 40; const zIndex = () => rest.zIndex ?? 40;
const [getValue, setValue] = createSignal<Option>(); const [getValue, setValue] = createSignal<Option>();
@@ -61,29 +75,29 @@ export const Select = (props: SelectProps) => {
// Internal loading state for async options // Internal loading state for async options
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
createEffect(async () => { createEffect(async () => {
if (props.getOptions) { if (rest.getOptions) {
setLoading(true); setLoading(true);
try { try {
const options = await props.getOptions(); const options = await rest.getOptions();
setResolvedOptions(options); setResolvedOptions(options);
} finally { } finally {
setLoading(false); setLoading(false);
} }
} else if (props.options) { } else if (rest.options) {
setResolvedOptions(props.options); setResolvedOptions(rest.options);
} }
}); });
const options = () => props.options ?? resolvedOptions(); const options = () => rest.options ?? resolvedOptions();
createEffect(() => { createEffect(() => {
console.log("options,", options()); console.log("options,", options());
setValue(options().find((option) => props.value === option.value)); setValue(options().find((option) => rest.value === option.value));
}); });
const modalContext = useModalContext(); const modalContext = useModalContext();
const defaultMount = const defaultMount =
props.portalProps?.mount || modalContext?.portalRef || document.body; rest.portalProps?.mount || modalContext?.portalRef || document.body;
createEffect(() => { createEffect(() => {
console.debug("Select component mounted at:", defaultMount); console.debug("Select component mounted at:", defaultMount);
@@ -101,7 +115,7 @@ export const Select = (props: SelectProps) => {
optionValue="value" optionValue="value"
optionTextValue="label" optionTextValue="label"
optionDisabled="disabled" optionDisabled="disabled"
validationState={props.error ? "invalid" : "valid"} validationState={rest.error ? "invalid" : "valid"}
itemComponent={(props) => ( itemComponent={(props) => (
<KSelect.Item item={props.item} class="flex gap-1 p-2"> <KSelect.Item item={props.item} class="flex gap-1 p-2">
<KSelect.ItemIndicator> <KSelect.ItemIndicator>
@@ -147,11 +161,11 @@ export const Select = (props: SelectProps) => {
color="secondary" color="secondary"
in="Select-item-label" in="Select-item-label"
> >
{props.noOptionsText || "No options available"} {rest.noOptionsText}
</Typography> </Typography>
} }
> >
<Show when={props.placeholder}> <Show when={root.placeholder}>
<Typography <Typography
hierarchy="label" hierarchy="label"
size="s" size="s"
@@ -159,19 +173,22 @@ export const Select = (props: SelectProps) => {
family="condensed" family="condensed"
in="Select-item-label" in="Select-item-label"
> >
{props.placeholder} {root.placeholder}
</Typography> </Typography>
</Show> </Show>
</Show> </Show>
</Show> </Show>
} }
> >
<Orienter orientation={props.orientation || "horizontal"}> <Orienter orientation={rest.orientation}>
<Label <Label
{...props.label} {...rest.label}
labelComponent={KSelect.Label} labelComponent={KSelect.Label}
descriptionComponent={KSelect.Description} descriptionComponent={KSelect.Description}
validationState={props.error ? "invalid" : "valid"} validationState={rest.error ? "invalid" : "valid"}
in={keepTruthy(
rest.orientation == "horizontal" && "Orienter-horizontal",
)}
/> />
<KSelect.HiddenSelect {...selectProps} /> <KSelect.HiddenSelect {...selectProps} />
<KSelect.Trigger <KSelect.Trigger
@@ -201,9 +218,9 @@ export const Select = (props: SelectProps) => {
</KSelect.Icon> </KSelect.Icon>
</KSelect.Trigger> </KSelect.Trigger>
</Orienter> </Orienter>
<KSelect.Portal mount={defaultMount} {...props.portalProps}> <KSelect.Portal mount={defaultMount} {...rest.portalProps}>
<KSelect.Content <KSelect.Content
class={styles.options_content} class={styles.optionsContent}
style={{ "--z-index": zIndex() }} style={{ "--z-index": zIndex() }}
> >
<KSelect.Listbox> <KSelect.Listbox>
@@ -238,7 +255,7 @@ export const Select = (props: SelectProps) => {
</KSelect.Content> </KSelect.Content>
</KSelect.Portal> </KSelect.Portal>
{/* TODO: Display error next to the problem */} {/* TODO: Display error next to the problem */}
{/* <KSelect.ErrorMessage>{props.error}</KSelect.ErrorMessage> */} {/* <KSelect.ErrorMessage>{rest.error}</KSelect.ErrorMessage> */}
</KSelect> </KSelect>
); );
}; };