Merge pull request 'ui: use css modules for TextArea and TextInput' (#5235) from hgl-ui-textfield into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5235
This commit is contained in:
hsjobeki
2025-09-23 13:59:21 +00:00
10 changed files with 157 additions and 192 deletions

View File

@@ -21,10 +21,17 @@ export type CheckboxProps = FieldProps &
}; };
export const Checkbox = (props: CheckboxProps) => { export const Checkbox = (props: CheckboxProps) => {
const [local, other] = splitProps( const withDefaults = mergeProps(
mergeProps({ size: "default", orientation: "vertical" } as const, props), { size: "default", orientation: "vertical" } as const,
["size", "orientation", "inverted", "ghost", "input"], props,
); );
const [local, other] = splitProps(withDefaults, [
"size",
"orientation",
"inverted",
"ghost",
"input",
]);
const iconChecked = ( const iconChecked = (
<Icon <Icon
@@ -66,7 +73,7 @@ export const Checkbox = (props: CheckboxProps) => {
in={keepTruthy( in={keepTruthy(
local.orientation == "horizontal" && "Orienter-horizontal", local.orientation == "horizontal" && "Orienter-horizontal",
)} )}
{...props} {...withDefaults}
/> />
<KCheckbox.Input {...local.input} /> <KCheckbox.Input {...local.input} />
<KCheckbox.Control class={styles.checkboxControl}> <KCheckbox.Control class={styles.checkboxControl}>

View File

@@ -1,5 +1,4 @@
export interface FieldProps { export interface FieldProps {
class?: string;
label?: string; label?: string;
labelWeight?: "bold" | "normal"; labelWeight?: "bold" | "normal";
description?: string; description?: string;

View File

@@ -11,7 +11,7 @@ import styles from "./HostFileInput.module.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"; import { Orienter } from "./Orienter";
import { createSignal, splitProps } from "solid-js"; import { createSignal, mergeProps, 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"; import { keepTruthy } from "@/src/util";
@@ -24,7 +24,14 @@ export type HostFileInputProps = FieldProps &
}; };
export const HostFileInput = (props: HostFileInputProps) => { export const HostFileInput = (props: HostFileInputProps) => {
const [value, setValue] = createSignal<string>(props.value || ""); const withDefaults = mergeProps({ value: "" } as const, props);
const [local, other] = splitProps(withDefaults, [
"size",
"orientation",
"inverted",
"ghost",
]);
const [value, setValue] = createSignal<string>(other.value);
let actualInputElement: HTMLInputElement | undefined; let actualInputElement: HTMLInputElement | undefined;
@@ -41,13 +48,6 @@ export const HostFileInput = (props: HostFileInputProps) => {
} }
}; };
const [local, other] = splitProps(props, [
"size",
"orientation",
"inverted",
"ghost",
]);
return ( return (
<TextField {...other}> <TextField {...other}>
<Orienter <Orienter
@@ -60,7 +60,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
in={keepTruthy( in={keepTruthy(
local.orientation == "horizontal" && "Orienter-horizontal", local.orientation == "horizontal" && "Orienter-horizontal",
)} )}
{...props} {...withDefaults}
/> />
<TextField.Input <TextField.Input

View File

@@ -52,7 +52,8 @@ const sortedAndUniqueOptions = (options: MachineTag[]) =>
sortedOptions(uniqueOptions(options)); sortedOptions(uniqueOptions(options));
export const MachineTags = (props: MachineTagsProps) => { export const MachineTags = (props: MachineTagsProps) => {
const [local, rest] = splitProps(props, ["defaultValue"]); const withDefaults = props;
const [local, rest] = splitProps(withDefaults, ["defaultValue"]);
// // convert default value string[] into MachineTag[] // // convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions( const defaultValue = sortedAndUniqueOptions(
@@ -199,7 +200,7 @@ export const MachineTags = (props: MachineTagsProps) => {
in={keepTruthy( in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal", props.orientation == "horizontal" && "Orienter-horizontal",
)} )}
{...props} {...withDefaults}
/> />
<Combobox.HiddenSelect <Combobox.HiddenSelect

View File

@@ -121,11 +121,11 @@ export const AutoResize: Story = {
description: description:
"This textarea automatically adjusts its height based on content", "This textarea automatically adjusts its height based on content",
tooltip: "Try typing multiple lines to see it grow", tooltip: "Try typing multiple lines to see it grow",
input: {
placeholder: "Start typing to see the textarea grow...",
autoResize: true, autoResize: true,
minRows: 2, minRows: 2,
maxRows: 10, maxRows: 10,
input: {
placeholder: "Start typing to see the textarea grow...",
}, },
}, },
}; };
@@ -134,10 +134,10 @@ export const AutoResizeNoMax: Story = {
args: { args: {
label: "Auto-resize without max height", label: "Auto-resize without max height",
description: "This textarea grows indefinitely with content", description: "This textarea grows indefinitely with content",
input: {
placeholder: "This will grow as much as needed...",
autoResize: true, autoResize: true,
minRows: 3, minRows: 3,
input: {
placeholder: "This will grow as much as needed...",
}, },
}, },
}; };

View File

@@ -7,38 +7,50 @@ import {
import cx from "classnames"; import cx from "classnames";
import { Label } from "./Label"; import { Label } from "./Label";
import { PolymorphicProps } from "@kobalte/core/polymorphic"; import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { createEffect, createSignal, splitProps } from "solid-js"; import { createEffect, createSignal, mergeProps, splitProps } from "solid-js";
import "./TextInput.css"; import styles from "./TextField.module.css";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; import { Orienter } from "./Orienter";
import { keepTruthy } from "@/src/util"; import { keepTruthy } from "@/src/util";
export type TextAreaProps = FieldProps & export type TextAreaProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
input: PolymorphicProps<"textarea", TextFieldTextAreaProps<"input">> & { input: PolymorphicProps<"textarea", TextFieldTextAreaProps<"input">>;
autoResize?: boolean; autoResize?: boolean;
minRows?: number; minRows?: number;
maxRows?: number; maxRows?: number;
}; };
};
export const TextArea = (props: TextAreaProps) => { export const TextArea = (props: TextAreaProps) => {
const withDefaults = mergeProps(
{ size: "default", minRows: 1, maxRows: Infinity } as const,
props,
);
const [local, other] = splitProps(withDefaults, [
"autoResize",
"minRows",
"maxRows",
"size",
"orientation",
"inverted",
"ghost",
"input",
]);
let textareaRef: HTMLTextAreaElement; let textareaRef: HTMLTextAreaElement;
const [lineHeight, setLineHeight] = createSignal(0); const [lineHeight, setLineHeight] = createSignal(0);
const autoResize = () => { const autoResize = () => {
const input = props.input; if (!(textareaRef && local.autoResize && lineHeight() > 0)) return;
if (!(textareaRef && input.autoResize && lineHeight() > 0)) return;
// Reset height to auto to get accurate scrollHeight // Reset height to auto to get accurate scrollHeight
textareaRef.style.height = "auto"; textareaRef.style.height = "auto";
// Calculate min and max heights based on rows // Calculate min and max heights based on rows
const minHeight = (input.minRows || 1) * lineHeight(); const minHeight = local.minRows * lineHeight();
const maxHeight = input.maxRows ? input.maxRows * lineHeight() : Infinity; const maxHeight = local.maxRows * lineHeight();
// Set the height based on content, respecting min/max // Set the height based on content, respecting min/max
const newHeight = Math.min( const newHeight = Math.min(
@@ -53,7 +65,7 @@ export const TextArea = (props: TextAreaProps) => {
// Set up auto-resize effect // Set up auto-resize effect
createEffect(() => { createEffect(() => {
if (textareaRef && props.input.autoResize) { if (textareaRef && local.autoResize) {
// Get computed line height // Get computed line height
const computedStyle = window.getComputedStyle(textareaRef); const computedStyle = window.getComputedStyle(textareaRef);
const computedLineHeight = parseFloat(computedStyle.lineHeight); const computedLineHeight = parseFloat(computedStyle.lineHeight);
@@ -68,32 +80,14 @@ export const TextArea = (props: TextAreaProps) => {
// Watch for value changes to trigger resize // Watch for value changes to trigger resize
createEffect(() => { createEffect(() => {
if (props.input.autoResize && textareaRef) { if (local.autoResize && textareaRef) {
// Access the value to create a dependency // Access the value to create a dependency
const _ = props.value || props.defaultValue || ""; const _ = other.value || other.defaultValue;
// Trigger resize on the next tick to ensure DOM is updated // Trigger resize on the next tick to ensure DOM is updated
setTimeout(autoResize, 0); setTimeout(autoResize, 0);
} }
}); });
const input = props.input;
// TextField.Textarea already has an `autoResize` prop
// We filter our props out to avoid conflicting behaviour
const [_, textareaProps] = splitProps(input, [
"autoResize",
"minRows",
"maxRows",
]);
const [styleProps, otherProps] = splitProps(props, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
return ( return (
<TextField <TextField
ref={(el: HTMLDivElement) => { ref={(el: HTMLDivElement) => {
@@ -102,46 +96,44 @@ export const TextArea = (props: TextAreaProps) => {
textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement; textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement;
}} }}
class={cx( class={cx(
styleProps.class, styles.textField,
"form-field", local.size != "default" && styles[local.size],
"textarea", local.orientation == "horizontal" && styles[local.orientation],
styleProps.size,
styleProps.orientation,
{ {
inverted: styleProps.inverted, [styles.inverted]: local.inverted,
ghost: styleProps.ghost, [styles.ghost]: local.ghost,
}, },
)} )}
{...otherProps} {...other}
> >
<Orienter orientation={styleProps.orientation} align={"start"}> <Orienter orientation={local.orientation} align={"start"}>
<Label <Label
labelComponent={TextField.Label} labelComponent={TextField.Label}
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}
in={keepTruthy( in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal", props.orientation == "horizontal" && "Orienter-horizontal",
)} )}
{...props} {...withDefaults}
/> />
<TextField.TextArea <TextField.TextArea
class={cx(input.class, { class={cx({
"auto-resize": input.autoResize, [styles.autoResize]: local.autoResize,
})} })}
onInput={(e) => { onInput={(e) => {
autoResize(); autoResize();
if (!input.onInput) { if (!local.input.onInput) {
return; return;
} }
// Call original onInput if it exists // Call original onInput if it exists
if (typeof input.onInput === "function") { if (typeof local.input.onInput === "function") {
input.onInput(e); local.input.onInput(e);
} else if (Array.isArray(input.onInput)) { } else if (Array.isArray(local.input.onInput)) {
input.onInput.forEach((handler) => handler(e)); local.input.onInput.forEach((handler) => handler(e));
} }
}} }}
{...textareaProps} {...local.input}
/> />
</Orienter> </Orienter>
</TextField> </TextField>

View File

@@ -1,6 +1,6 @@
div.form-field { .textField {
&.text input, input,
&.textarea textarea { textarea {
@apply w-full px-2 py-1.5 rounded-sm; @apply w-full px-2 py-1.5 rounded-sm;
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1; @apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1;
@@ -38,12 +38,12 @@ div.form-field {
} }
} }
&.textarea textarea { textarea {
&[data-readonly] { &[data-readonly] {
@apply overflow-y-hidden; @apply overflow-y-hidden;
} }
&.auto-resize { &.autoResize {
@apply resize-none overflow-y-auto; @apply resize-none overflow-y-auto;
transition: height 0.1s ease-out; transition: height 0.1s ease-out;
} }
@@ -52,48 +52,15 @@ div.form-field {
&.horizontal { &.horizontal {
@apply flex-row gap-2 justify-between; @apply flex-row gap-2 justify-between;
&.text div.input-container, .inputContainer,
&.textarea textarea { textarea {
@apply w-1/2 grow; @apply w-1/2 grow;
} }
} }
&.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;
}
& > .start-component {
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
}
& > .end-component {
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
}
& > .start-component,
& > .end-component {
@apply size-fit;
}
}
&.s { &.s {
&.text input, input,
&.textarea textarea { textarea {
@apply px-1.5 py-1; @apply px-1.5 py-1;
font-size: 0.75rem; font-size: 0.75rem;
@@ -102,26 +69,18 @@ div.form-field {
} }
} }
&.text div.input-container { .inputContainer {
@apply h-[1.25rem]; @apply h-[1.25rem];
input { input {
@apply h-[1.25rem]; @apply h-[1.25rem];
} }
input.has-icon {
@apply pl-6;
}
& > .icon {
@apply w-[0.6875rem] h-[0.6875rem];
}
} }
} }
&.inverted { &.inverted {
&.text input, input,
&.textarea textarea { textarea {
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1; @apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
&::placeholder { &::placeholder {
@@ -151,13 +110,33 @@ div.form-field {
} }
&.ghost { &.ghost {
&.text input, input,
&.textarea textarea { textarea {
@apply outline-none; @apply outline-none;
}
}
&:hover { .inputContainer {
@apply outline-none; @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];
}
& > .startComponent {
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
}
& > .endComponent {
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
}
& > .startComponent,
& > .endComponent {
@apply size-fit;
} }
} }
} }

View File

@@ -3,11 +3,10 @@ import {
TextFieldInputProps, TextFieldInputProps,
TextFieldRootProps, TextFieldRootProps,
} from "@kobalte/core/text-field"; } from "@kobalte/core/text-field";
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
import cx from "classnames"; import cx from "classnames";
import { Label } from "./Label"; import { Label } from "./Label";
import "./TextInput.css"; import styles from "./TextField.module.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"; import { Orienter } from "./Orienter";
@@ -15,6 +14,7 @@ import {
Component, Component,
createEffect, createEffect,
createSignal, createSignal,
mergeProps,
onMount, onMount,
splitProps, splitProps,
} from "solid-js"; } from "solid-js";
@@ -22,19 +22,21 @@ import { keepTruthy } from "@/src/util";
export type TextInputProps = FieldProps & export type TextInputProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
icon?: IconVariant;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>; input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
startComponent?: Component<Pick<FieldProps, "inverted">>; startComponent?: Component<Pick<FieldProps, "inverted">>;
endComponent?: Component<Pick<FieldProps, "inverted">>; endComponent?: Component<Pick<FieldProps, "inverted">>;
}; };
export const TextInput = (props: TextInputProps) => { export const TextInput = (props: TextInputProps) => {
const [styleProps, otherProps] = splitProps(props, [ const withDefaults = mergeProps({ size: "default" } as const, props);
"class", const [local, other] = splitProps(withDefaults, [
"size", "size",
"orientation", "orientation",
"inverted", "inverted",
"ghost", "ghost",
"input",
"startComponent",
"endComponent",
]); ]);
let inputRef: HTMLInputElement | undefined; let inputRef: HTMLInputElement | undefined;
@@ -73,50 +75,35 @@ export const TextInput = (props: TextInputProps) => {
return ( return (
<TextField <TextField
class={cx( class={cx(
styleProps.class, styles.textField,
"form-field", local.size != "default" && styles[local.size],
"text", local.orientation == "horizontal" && styles[local.orientation],
styleProps.size,
styleProps.orientation,
{ {
inverted: styleProps.inverted, [styles.inverted]: local.inverted,
ghost: styleProps.ghost, [styles.ghost]: local.ghost,
}, },
)} )}
{...otherProps} {...other}
> >
<Orienter orientation={styleProps.orientation}> <Orienter orientation={local.orientation}>
<Label <Label
labelComponent={TextField.Label} labelComponent={TextField.Label}
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}
in={keepTruthy( in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal", props.orientation == "horizontal" && "Orienter-horizontal",
)} )}
{...props} {...withDefaults}
/> />
<div class="input-container"> <div class={styles.inputContainer}>
{props.startComponent && !props.readOnly && ( {local.startComponent && !other.readOnly && (
<div ref={startComponentRef} class="start-component"> <div ref={startComponentRef} class={styles.startComponent}>
{props.startComponent({ inverted: props.inverted })} {local.startComponent({ inverted: local.inverted })}
</div> </div>
)} )}
{props.icon && !props.readOnly && ( <TextField.Input ref={inputRef} {...local.input} />
<Icon {local.endComponent && !other.readOnly && (
icon={props.icon} <div ref={endComponentRef} class={styles.endComponent}>
inverted={styleProps.inverted} {local.endComponent({ inverted: local.inverted })}
color={props.disabled ? "tertiary" : "quaternary"}
/>
)}
<TextField.Input
ref={inputRef}
{...props.input}
class={cx({
"has-icon": props.icon && !props.readOnly,
})}
/>
{props.endComponent && !props.readOnly && (
<div ref={endComponentRef} class="end-component">
{props.endComponent({ inverted: props.inverted })}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,3 @@
import cx from "classnames";
import styles from "./ClanSettingsModal.module.css"; import styles from "./ClanSettingsModal.module.css";
import { Modal } from "@/src/components/Modal/Modal"; import { Modal } from "@/src/components/Modal/Modal";
import { ClanDetails } from "@/src/hooks/queries"; import { ClanDetails } from "@/src/hooks/queries";
@@ -104,7 +103,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
return ( return (
<Modal <Modal
class={cx(styles.modal)} class={styles.modal}
open open
title="Settings" title="Settings"
onClose={props.onClose} onClose={props.onClose}
@@ -112,7 +111,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
<Form onSubmit={handleSubmit}>{props.children}</Form> <Form onSubmit={handleSubmit}>{props.children}</Form>
)} )}
metaHeader={() => ( metaHeader={() => (
<div class={cx(styles.header)}> <div class={styles.header}>
<Typography <Typography
hierarchy="label" hierarchy="label"
family="mono" family="mono"
@@ -127,7 +126,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
</div> </div>
)} )}
> >
<div class={cx(styles.content)}> <div class={styles.content}>
<Show when={errorMessage()}> <Show when={errorMessage()}>
<Alert type="error" title="Error" description={errorMessage()} /> <Alert type="error" title="Error" description={errorMessage()} />
</Show> </Show>
@@ -164,11 +163,11 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
"A description of this Clan", "A description of this Clan",
)} )}
orientation="horizontal" orientation="horizontal"
autoResize={true}
minRows={2}
maxRows={4}
input={{ input={{
...input, ...input,
autoResize: true,
minRows: 2,
maxRows: 4,
placeholder: "No description", placeholder: "No description",
}} }}
/> />
@@ -176,9 +175,9 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
</Field> </Field>
</Fieldset> </Fieldset>
<div class={cx(styles.remove)}> <div class={styles.remove}>
<div class={styles.clanInput}>
<TextInput <TextInput
class={cx(styles.clanInput)}
orientation="horizontal" orientation="horizontal"
onChange={setRemoveValue} onChange={setRemoveValue}
input={{ input={{
@@ -191,6 +190,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
}, },
}} }}
/> />
</div>
<Button <Button
hierarchy="primary" hierarchy="primary"

View File

@@ -126,11 +126,11 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
readOnly={readOnly(editing, "description")} readOnly={readOnly(editing, "description")}
tooltip={tooltipText("description", fieldsSchema()!)} tooltip={tooltipText("description", fieldsSchema()!)}
orientation="horizontal" orientation="horizontal"
autoResize={true}
minRows={2}
maxRows={4}
input={{ input={{
...input, ...input,
autoResize: true,
minRows: 2,
maxRows: 4,
placeholder: "No description", placeholder: "No description",
}} }}
/> />