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 absolute left-2 top-1/2 transform -translate-y-1/2;
@apply w-[0.875rem] h-[0.875rem] pointer-events-none; @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 {
@@ -101,7 +114,7 @@ div.form-field {
} }
& > .icon { & > .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 type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames"; import cx from "classnames";
import { TextInput, TextInputProps } from "@/src/components/Form/TextInput"; import { TextInput, TextInputProps } from "@/src/components/Form/TextInput";
import Icon from "../Icon/Icon";
import { Button } from "@kobalte/core/button";
const Examples = (props: TextInputProps) => ( const Examples = (props: TextInputProps) => (
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
@@ -83,16 +85,38 @@ export const Tooltip: Story = {
}, },
}; };
export const Icon: Story = { export const WithIcon: Story = {
args: { args: {
...Tooltip.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 = { export const Ghost: Story = {
args: { args: {
...Icon.args, ...WithIcon.args,
ghost: true, ghost: true,
}, },
}; };
@@ -106,14 +130,14 @@ export const Invalid: Story = {
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
...Icon.args, ...WithIcon.args,
disabled: true, disabled: true,
}, },
}; };
export const ReadOnly: Story = { export const ReadOnly: Story = {
args: { args: {
...Icon.args, ...WithIcon.args,
readOnly: true, readOnly: true,
defaultValue: "14/05/02", defaultValue: "14/05/02",
}, },

View File

@@ -11,12 +11,20 @@ import "./TextInput.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 { splitProps } from "solid-js"; import {
Component,
createEffect,
createSignal,
onMount,
splitProps,
} from "solid-js";
export type TextInputProps = FieldProps & export type TextInputProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
icon?: IconVariant; icon?: IconVariant;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>; input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
startComponent?: Component<Pick<FieldProps, "inverted">>;
endComponent?: Component<Pick<FieldProps, "inverted">>;
}; };
export const TextInput = (props: TextInputProps) => { export const TextInput = (props: TextInputProps) => {
@@ -28,6 +36,39 @@ export const TextInput = (props: TextInputProps) => {
"ghost", "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 ( return (
<TextField <TextField
class={cx( class={cx(
@@ -50,6 +91,11 @@ export const TextInput = (props: TextInputProps) => {
{...props} {...props}
/> />
<div class="input-container"> <div class="input-container">
{props.startComponent && !props.readOnly && (
<div ref={startComponentRef} class="start-component">
{props.startComponent({ inverted: props.inverted })}
</div>
)}
{props.icon && !props.readOnly && ( {props.icon && !props.readOnly && (
<Icon <Icon
icon={props.icon} icon={props.icon}
@@ -58,9 +104,17 @@ export const TextInput = (props: TextInputProps) => {
/> />
)} )}
<TextField.Input <TextField.Input
ref={inputRef}
{...props.input} {...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> </div>
</Orienter> </Orienter>
</TextField> </TextField>

View File

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

View File

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