feat(ui): refine input to allow start and end components

This commit is contained in:
Brian McGee
2025-09-03 10:03:12 +01:00
parent c9b71496eb
commit f4d7728f3f
5 changed files with 165 additions and 40 deletions

View File

@@ -76,6 +76,19 @@ div.form-field {
@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 {
@@ -101,7 +114,7 @@ div.form-field {
}
& > .icon {
@apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2;
@apply w-[0.6875rem] h-[0.6875rem];
}
}
}

View File

@@ -1,6 +1,8 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames";
import { TextInput, TextInputProps } from "@/src/components/Form/TextInput";
import Icon from "../Icon/Icon";
import { Button } from "@kobalte/core/button";
const Examples = (props: TextInputProps) => (
<div class="flex flex-col gap-8">
@@ -83,16 +85,38 @@ export const Tooltip: Story = {
},
};
export const Icon: Story = {
export const WithIcon: Story = {
args: {
...Tooltip.args,
icon: "Checkmark",
startComponent: () => <Icon icon="EyeClose" color="quaternary" inverted />,
},
};
export const WithStartComponent: Story = {
args: {
...Tooltip.args,
startComponent: (props: { inverted?: boolean }) => (
<Button>
<Icon icon="EyeClose" color="quaternary" {...props} />
</Button>
),
},
};
export const WithEndComponent: Story = {
args: {
...Tooltip.args,
endComponent: (props: { inverted?: boolean }) => (
<Button>
<Icon icon="EyeOpen" color="quaternary" {...props} />
</Button>
),
},
};
export const Ghost: Story = {
args: {
...Icon.args,
...WithIcon.args,
ghost: true,
},
};
@@ -106,14 +130,14 @@ export const Invalid: Story = {
export const Disabled: Story = {
args: {
...Icon.args,
...WithIcon.args,
disabled: true,
},
};
export const ReadOnly: Story = {
args: {
...Icon.args,
...WithIcon.args,
readOnly: true,
defaultValue: "14/05/02",
},

View File

@@ -11,12 +11,20 @@ import "./TextInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { splitProps } from "solid-js";
import {
Component,
createEffect,
createSignal,
onMount,
splitProps,
} from "solid-js";
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) => {
@@ -28,6 +36,39 @@ export const TextInput = (props: TextInputProps) => {
"ghost",
]);
let inputRef: HTMLInputElement | undefined;
let startComponentRef: HTMLDivElement | undefined;
let endComponentRef: HTMLDivElement | undefined;
const [startComponentSize, setStartComponentSize] = createSignal({
width: 0,
height: 0,
});
const [endComponentSize, setEndComponentSize] = createSignal({
width: 0,
height: 0,
});
onMount(() => {
if (startComponentRef) {
const rect = startComponentRef.getBoundingClientRect();
setStartComponentSize({ width: rect.width, height: rect.height });
}
if (endComponentRef) {
const rect = endComponentRef.getBoundingClientRect();
setEndComponentSize({ width: rect.width, height: rect.height });
}
});
createEffect(() => {
if (inputRef) {
const padding = props.size == "s" ? 6 : 8;
inputRef.style.paddingLeft = `${startComponentSize().width + padding * 2}px`;
inputRef.style.paddingRight = `${endComponentSize().width + padding * 2}px`;
}
});
return (
<TextField
class={cx(
@@ -50,6 +91,11 @@ export const TextInput = (props: TextInputProps) => {
{...props}
/>
<div class="input-container">
{props.startComponent && !props.readOnly && (
<div ref={startComponentRef} class="start-component">
{props.startComponent({ inverted: props.inverted })}
</div>
)}
{props.icon && !props.readOnly && (
<Icon
icon={props.icon}
@@ -58,9 +104,17 @@ export const TextInput = (props: TextInputProps) => {
/>
)}
<TextField.Input
ref={inputRef}
{...props.input}
classList={{ "has-icon": props.icon && !props.readOnly }}
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>
</Orienter>
</TextField>

View File

@@ -75,7 +75,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "line",
prompt_type: "hidden",
display: {
helperText: null,
label: "(2) Password",
@@ -113,7 +113,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "line",
prompt_type: "hidden",
display: {
helperText: null,
label: "(5) Password",

View File

@@ -18,7 +18,7 @@ import {
} from "../InstallMachine";
import { TextInput } from "@/src/components/Form/TextInput";
import { Alert, AlertProps } from "@/src/components/Alert/Alert";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { createSignal, For, Match, Show, Switch, JSX } from "solid-js";
import { Divider } from "@/src/components/Divider/Divider";
import { Orienter } from "@/src/components/Form/Orienter";
import { Button } from "@/src/components/Button/Button";
@@ -35,6 +35,7 @@ import { useClanURI } from "@/src/hooks/clan";
import { useApiClient } from "@/src/hooks/ApiClient";
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
import { Loader } from "@/src/components/Loader/Loader";
import { Button as KButton } from "@kobalte/core/button";
export const InstallHeader = (props: { machineName: string }) => {
return (
@@ -566,35 +567,68 @@ const PromptsFields = (props: PromptsFieldsProps) => {
<Field
name={`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`}
>
{(f, props) => (
<TextInput
{...f}
label={
fieldInfo.prompt.display?.label ||
fieldInfo.prompt.name
}
description={fieldInfo.prompt.description}
value={f.value || fieldInfo.value || ""}
required={fieldInfo.prompt.display?.required}
orientation="horizontal"
validationState={
getError(
formStore,
`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`,
)
? "invalid"
: "valid"
}
input={{
type: fieldInfo.prompt.prompt_type.includes(
"hidden",
)
? "password"
: "text",
...props,
}}
/>
)}
{(f, props) => {
const defaultInputType =
fieldInfo.prompt.prompt_type.includes("hidden")
? "password"
: "text";
const [inputType, setInputType] =
createSignal(defaultInputType);
let endComponent:
| ((props: { inverted?: boolean }) => JSX.Element)
| undefined = undefined;
if (defaultInputType === "password") {
endComponent = (props) => (
<KButton
onClick={() => {
setInputType((type) =>
type === "password" ? "text" : "password",
);
}}
>
<Icon
icon={
inputType() == "password"
? "EyeClose"
: "EyeOpen"
}
color="quaternary"
inverted={props.inverted}
/>
</KButton>
);
}
return (
<TextInput
{...f}
label={
fieldInfo.prompt.display?.label ||
fieldInfo.prompt.name
}
endComponent={endComponent}
description={fieldInfo.prompt.description}
value={f.value || fieldInfo.value || ""}
required={fieldInfo.prompt.display?.required}
orientation="horizontal"
validationState={
getError(
formStore,
`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`,
)
? "invalid"
: "valid"
}
input={{
type: inputType(),
...props,
}}
/>
);
}}
</Field>
)}
</For>