Merge pull request 'ui/form: use css modules for form components' (#5232) from hgl-ui-form into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5232
Reviewed-by: brianmcgee <brian@bmcgee.ie>
This commit is contained in:
brianmcgee
2025-09-22 13:06:02 +00:00
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 { Orienter } from "./Orienter";
import { Match, mergeProps, splitProps, Switch } from "solid-js";
import { keepTruthy } from "@/src/util";
export type CheckboxProps = FieldProps &
KCheckboxRootProps & {
@@ -62,6 +63,9 @@ export const Checkbox = (props: CheckboxProps) => {
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}
in={keepTruthy(
local.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props}
/>
<KCheckbox.Input {...local.input} />

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
div.form-label {
.label {
@apply flex flex-col gap-1 w-full;
& > span,
@@ -14,3 +14,6 @@ div.form-label {
@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 { Tooltip } from "@/src/components/Tooltip/Tooltip";
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 { Combobox } from "@kobalte/core/combobox";
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";
@@ -22,6 +24,7 @@ export type DescriptionComponent =
| typeof Combobox.Description
| typeof Select.Description;
type In = "Orienter-horizontal";
export interface LabelProps {
labelComponent: LabelComponent;
descriptionComponent: DescriptionComponent;
@@ -34,52 +37,57 @@ export interface LabelProps {
inverted?: boolean;
readOnly?: boolean;
validationState?: "valid" | "invalid";
in?: In | In[];
}
export const Label = (props: LabelProps) => {
const local = mergeProps(
{ size: "default", labelWeight: "bold", validationState: "valid" } as const,
props,
);
const descriptionSize = () => (props.size == "default" ? "s" : "xs");
return (
<Show when={props.label}>
<div class="form-label">
<props.labelComponent>
<Show when={local.label}>
<div class={cx(styles.label, getInClasses(styles, local.in))}>
<local.labelComponent>
<Typography
hierarchy="label"
size={props.size || "default"}
color={props.validationState == "invalid" ? "error" : "primary"}
weight={props.labelWeight || "bold"}
inverted={props.inverted}
size={local.size}
color={local.validationState == "invalid" ? "error" : "primary"}
weight={local.labelWeight}
inverted={local.inverted}
in="Label"
>
{props.label}
{local.label}
</Typography>
{props.tooltip && (
{local.tooltip && (
<Tooltip
placement="top"
inverted={props.inverted}
description={props.tooltip}
inverted={local.inverted}
description={local.tooltip}
>
<Icon
icon="Info"
color="tertiary"
inverted={props.inverted}
size={props.size == "default" ? "0.85em" : "0.75rem"}
inverted={local.inverted}
size={local.size == "default" ? "0.85em" : "0.75rem"}
/>
</Tooltip>
)}
</props.labelComponent>
{props.description && (
<props.descriptionComponent>
</local.labelComponent>
{local.description && (
<local.descriptionComponent>
<Typography
hierarchy="body"
size={descriptionSize()}
color="secondary"
weight="normal"
inverted={props.inverted}
inverted={local.inverted}
>
{props.description}
{local.description}
</Typography>
</props.descriptionComponent>
</local.descriptionComponent>
)}
</div>
</Show>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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