ui: use css modules for TextArea and TextInput

This commit is contained in:
Glen Huang
2025-09-22 22:35:50 +08:00
parent 519ff4c0f3
commit 7be9e3f333
10 changed files with 157 additions and 192 deletions

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import styles from "./HostFileInput.module.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { createSignal, splitProps } from "solid-js";
import { createSignal, mergeProps, splitProps } from "solid-js";
import { Tooltip } from "@kobalte/core/tooltip";
import { Typography } from "@/src/components/Typography/Typography";
import { keepTruthy } from "@/src/util";
@@ -24,7 +24,14 @@ export type HostFileInputProps = FieldProps &
};
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;
@@ -41,13 +48,6 @@ export const HostFileInput = (props: HostFileInputProps) => {
}
};
const [local, other] = splitProps(props, [
"size",
"orientation",
"inverted",
"ghost",
]);
return (
<TextField {...other}>
<Orienter
@@ -60,7 +60,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
in={keepTruthy(
local.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props}
{...withDefaults}
/>
<TextField.Input

View File

@@ -52,7 +52,8 @@ const sortedAndUniqueOptions = (options: MachineTag[]) =>
sortedOptions(uniqueOptions(options));
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[]
const defaultValue = sortedAndUniqueOptions(
@@ -199,7 +200,7 @@ export const MachineTags = (props: MachineTagsProps) => {
in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props}
{...withDefaults}
/>
<Combobox.HiddenSelect

View File

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

View File

@@ -7,38 +7,50 @@ import {
import cx from "classnames";
import { Label } from "./Label";
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 { Orienter } from "./Orienter";
import { keepTruthy } from "@/src/util";
export type TextAreaProps = FieldProps &
TextFieldRootProps & {
input: PolymorphicProps<"textarea", TextFieldTextAreaProps<"input">> & {
autoResize?: boolean;
minRows?: number;
maxRows?: number;
};
input: PolymorphicProps<"textarea", TextFieldTextAreaProps<"input">>;
autoResize?: boolean;
minRows?: number;
maxRows?: number;
};
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;
const [lineHeight, setLineHeight] = createSignal(0);
const autoResize = () => {
const input = props.input;
if (!(textareaRef && input.autoResize && lineHeight() > 0)) return;
if (!(textareaRef && local.autoResize && lineHeight() > 0)) return;
// Reset height to auto to get accurate scrollHeight
textareaRef.style.height = "auto";
// Calculate min and max heights based on rows
const minHeight = (input.minRows || 1) * lineHeight();
const maxHeight = input.maxRows ? input.maxRows * lineHeight() : Infinity;
const minHeight = local.minRows * lineHeight();
const maxHeight = local.maxRows * lineHeight();
// Set the height based on content, respecting min/max
const newHeight = Math.min(
@@ -53,7 +65,7 @@ export const TextArea = (props: TextAreaProps) => {
// Set up auto-resize effect
createEffect(() => {
if (textareaRef && props.input.autoResize) {
if (textareaRef && local.autoResize) {
// Get computed line height
const computedStyle = window.getComputedStyle(textareaRef);
const computedLineHeight = parseFloat(computedStyle.lineHeight);
@@ -68,32 +80,14 @@ export const TextArea = (props: TextAreaProps) => {
// Watch for value changes to trigger resize
createEffect(() => {
if (props.input.autoResize && textareaRef) {
if (local.autoResize && textareaRef) {
// 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
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 (
<TextField
ref={(el: HTMLDivElement) => {
@@ -102,46 +96,44 @@ export const TextArea = (props: TextAreaProps) => {
textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement;
}}
class={cx(
styleProps.class,
"form-field",
"textarea",
styleProps.size,
styleProps.orientation,
styles.textField,
local.size != "default" && styles[local.size],
local.orientation == "horizontal" && styles[local.orientation],
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
[styles.inverted]: local.inverted,
[styles.ghost]: local.ghost,
},
)}
{...otherProps}
{...other}
>
<Orienter orientation={styleProps.orientation} align={"start"}>
<Orienter orientation={local.orientation} align={"start"}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props}
{...withDefaults}
/>
<TextField.TextArea
class={cx(input.class, {
"auto-resize": input.autoResize,
class={cx({
[styles.autoResize]: local.autoResize,
})}
onInput={(e) => {
autoResize();
if (!input.onInput) {
if (!local.input.onInput) {
return;
}
// Call original onInput if it exists
if (typeof input.onInput === "function") {
input.onInput(e);
} else if (Array.isArray(input.onInput)) {
input.onInput.forEach((handler) => handler(e));
if (typeof local.input.onInput === "function") {
local.input.onInput(e);
} else if (Array.isArray(local.input.onInput)) {
local.input.onInput.forEach((handler) => handler(e));
}
}}
{...textareaProps}
{...local.input}
/>
</Orienter>
</TextField>

View File

@@ -1,6 +1,6 @@
div.form-field {
&.text input,
&.textarea textarea {
.textField {
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;
@@ -38,12 +38,12 @@ div.form-field {
}
}
&.textarea textarea {
textarea {
&[data-readonly] {
@apply overflow-y-hidden;
}
&.auto-resize {
&.autoResize {
@apply resize-none overflow-y-auto;
transition: height 0.1s ease-out;
}
@@ -52,48 +52,15 @@ div.form-field {
&.horizontal {
@apply flex-row gap-2 justify-between;
&.text div.input-container,
&.textarea textarea {
.inputContainer,
textarea {
@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 {
&.text input,
&.textarea textarea {
input,
textarea {
@apply px-1.5 py-1;
font-size: 0.75rem;
@@ -102,26 +69,18 @@ div.form-field {
}
}
&.text div.input-container {
.inputContainer {
@apply h-[1.25rem];
input {
@apply h-[1.25rem];
}
input.has-icon {
@apply pl-6;
}
& > .icon {
@apply w-[0.6875rem] h-[0.6875rem];
}
}
}
&.inverted {
&.text input,
&.textarea textarea {
input,
textarea {
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
&::placeholder {
@@ -151,13 +110,33 @@ div.form-field {
}
&.ghost {
&.text input,
&.textarea textarea {
input,
textarea {
@apply outline-none;
}
}
&:hover {
@apply outline-none;
}
.inputContainer {
@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,
TextFieldRootProps,
} from "@kobalte/core/text-field";
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
import cx from "classnames";
import { Label } from "./Label";
import "./TextInput.css";
import styles from "./TextField.module.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
@@ -15,6 +14,7 @@ import {
Component,
createEffect,
createSignal,
mergeProps,
onMount,
splitProps,
} from "solid-js";
@@ -22,19 +22,21 @@ import { keepTruthy } from "@/src/util";
export type TextInputProps = FieldProps &
TextFieldRootProps & {
icon?: IconVariant;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
startComponent?: Component<Pick<FieldProps, "inverted">>;
endComponent?: Component<Pick<FieldProps, "inverted">>;
};
export const TextInput = (props: TextInputProps) => {
const [styleProps, otherProps] = splitProps(props, [
"class",
const withDefaults = mergeProps({ size: "default" } as const, props);
const [local, other] = splitProps(withDefaults, [
"size",
"orientation",
"inverted",
"ghost",
"input",
"startComponent",
"endComponent",
]);
let inputRef: HTMLInputElement | undefined;
@@ -73,50 +75,35 @@ export const TextInput = (props: TextInputProps) => {
return (
<TextField
class={cx(
styleProps.class,
"form-field",
"text",
styleProps.size,
styleProps.orientation,
styles.textField,
local.size != "default" && styles[local.size],
local.orientation == "horizontal" && styles[local.orientation],
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
[styles.inverted]: local.inverted,
[styles.ghost]: local.ghost,
},
)}
{...otherProps}
{...other}
>
<Orienter orientation={styleProps.orientation}>
<Orienter orientation={local.orientation}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
in={keepTruthy(
props.orientation == "horizontal" && "Orienter-horizontal",
)}
{...props}
{...withDefaults}
/>
<div class="input-container">
{props.startComponent && !props.readOnly && (
<div ref={startComponentRef} class="start-component">
{props.startComponent({ inverted: props.inverted })}
<div class={styles.inputContainer}>
{local.startComponent && !other.readOnly && (
<div ref={startComponentRef} class={styles.startComponent}>
{local.startComponent({ inverted: local.inverted })}
</div>
)}
{props.icon && !props.readOnly && (
<Icon
icon={props.icon}
inverted={styleProps.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 })}
<TextField.Input ref={inputRef} {...local.input} />
{local.endComponent && !other.readOnly && (
<div ref={endComponentRef} class={styles.endComponent}>
{local.endComponent({ inverted: local.inverted })}
</div>
)}
</div>

View File

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

View File

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