UI: fixup Select component design & api

This commit is contained in:
Johannes Kirschbauer
2024-12-20 18:13:06 +01:00
parent fd2ba1e220
commit 94ab273d74
2 changed files with 138 additions and 110 deletions

View File

@@ -7,14 +7,22 @@ import {
createMemo, createMemo,
} from "solid-js"; } from "solid-js";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import cx from "classnames";
import { Label } from "../base/label";
import { useFloating } from "../base"; import { useFloating } from "../base";
import { autoUpdate, flip, hide, shift, size } from "@floating-ui/dom"; import { autoUpdate, flip, hide, offset, shift, size } from "@floating-ui/dom";
import { Button } from "@/src/components/button";
import {
InputBase,
InputError,
InputLabel,
InputLabelProps,
} from "@/src/components/inputBase";
import { FieldLayout } from "./layout";
import Icon from "@/src/components/icon";
export interface Option { export interface Option {
value: string; value: string;
label: string; label: string;
disabled?: boolean;
} }
interface SelectInputpProps { interface SelectInputpProps {
@@ -22,7 +30,7 @@ interface SelectInputpProps {
selectProps: JSX.InputHTMLAttributes<HTMLSelectElement>; selectProps: JSX.InputHTMLAttributes<HTMLSelectElement>;
options: Option[]; options: Option[];
label: JSX.Element; label: JSX.Element;
altLabel?: JSX.Element; labelProps?: InputLabelProps;
helperText?: JSX.Element; helperText?: JSX.Element;
error?: string; error?: string;
required?: boolean; required?: boolean;
@@ -36,6 +44,7 @@ interface SelectInputpProps {
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
multiple?: boolean; multiple?: boolean;
loading?: boolean;
} }
export function SelectInput(props: SelectInputpProps) { export function SelectInput(props: SelectInputpProps) {
@@ -61,6 +70,7 @@ export function SelectInput(props: SelectInputpProps) {
}); });
}, },
}), }),
offset({ mainAxis: 2 }),
shift(), shift(),
flip(), flip(),
hide({ hide({
@@ -114,126 +124,144 @@ export function SelectInput(props: SelectInputpProps) {
return ( return (
<> <>
<label <FieldLayout
class={cx("form-control w-full", props.class)} error={props.error && <InputError error={props.error} />}
aria-disabled={props.disabled} label={
> <InputLabel
<div class="label"> description={""}
<Label label={props.label} required={props.required} />
<span class="label-text-alt block">{props.altLabel}</span>
</div>
<button
type="button"
class="select select-bordered flex items-center gap-2"
ref={setReference}
formnovalidate
onClick={() => {
const popover = document.getElementById(_id);
if (popover) {
popover.togglePopover(); // Show or hide the popover
}
}}
// TODO: Use native popover once Webkti supports it within <form>
// popovertarget={_id}
// popovertargetaction="toggle"
>
<Show when={props.adornment && props.adornment.position === "start"}>
{props.adornment?.content}
</Show>
{props.inlineLabel}
<div class="flex cursor-default flex-row gap-2">
<For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class="btn-ghost btn-xs"
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
</div>
<select
class="hidden"
multiple
{...props.selectProps}
required={props.required} required={props.required}
{...props.labelProps}
> >
<For each={props.options}> {props.label}
{({ label, value }) => ( </InputLabel>
<option value={value} selected={getValues().includes(value)}> }
{label} field={
</option> <>
)} <InputBase
</For> error={!!props.error}
</select> disabled={props.disabled}
<Show when={props.adornment && props.adornment.position === "end"}> required={props.required}
{props.adornment?.content} class="!justify-start"
</Show> divRef={setReference}
</button> inputElem={
<Portal mount={document.body}> <button
<div // TODO: Keyboard acessibililty
id={_id} // Currently the popover only opens with onClick
popover // Options are not selectable with keyboard
ref={setFloating} tabIndex={-1}
style={{ onClick={() => {
margin: 0, const popover = document.getElementById(_id);
position: position.strategy, if (popover) {
top: `${position.y ?? 0}px`, popover.togglePopover(); // Show or hide the popover
left: `${position.x ?? 0}px`, }
}} }}
class="dropdown-content z-[1] rounded-b-box bg-base-100 shadow" type="button"
> class="flex w-full items-center gap-2"
<ul class="menu flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll"> formnovalidate
// TODO: Use native popover once Webkit supports it within <form>
// popovertarget={_id}
// popovertargetaction="toggle"
>
<Show
when={
props.adornment && props.adornment.position === "start"
}
>
{props.adornment?.content}
</Show>
{props.inlineLabel}
<div class="flex cursor-default flex-row gap-2">
<Show
when={
getValues() &&
getValues.length !== 1 &&
getValues()[0] !== ""
}
fallback={props.placeholder}
>
<For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class="btn-ghost btn-xs"
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
</Show>
</div>
<Show
when={props.adornment && props.adornment.position === "end"}
>
{props.adornment?.content}
</Show>
<Icon icon="CaretDown" class="ml-auto mr-2"></Icon>
</button>
}
/>
</>
}
/>
<Portal mount={document.body}>
<div
id={_id}
popover
ref={setFloating}
style={{
margin: 0,
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}}
class="z-[1] shadow"
>
<ul class="menu flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll">
<Show when={!props.loading} fallback={"Loading ...."}>
<For each={props.options}> <For each={props.options}>
{(opt) => ( {(opt) => (
<> <>
<li> <li>
<button <Button
variant="ghost"
class="!justify-start"
onClick={() => handleClickOption(opt)} onClick={() => handleClickOption(opt)}
disabled={opt.disabled}
classList={{ classList={{
active: getValues().includes(opt.value), active:
!opt.disabled && getValues().includes(opt.value),
}} }}
> >
{opt.label} {opt.label}
</button> </Button>
</li> </li>
</> </>
)} )}
</For> </For>
</ul> </Show>
</div> </ul>
</Portal>
<div class="label">
{props.helperText && (
<span class="label-text text-neutral">{props.helperText}</span>
)}
{props.error && (
<span class="label-text-alt font-bold text-error-700">
{props.error}
</span>
)}
</div> </div>
</label> </Portal>
</> </>
); );
} }

View File

@@ -55,7 +55,7 @@ export const InputBase = (props: InputBaseProps) => {
// Cursor // Cursor
"aria-readonly:cursor-no-drop", "aria-readonly:cursor-no-drop",
props.class props.class,
)} )}
classList={{ classList={{
[cx("!border !border-semantic-1 !outline-semantic-1")]: !!props.error, [cx("!border !border-semantic-1 !outline-semantic-1")]: !!props.error,
@@ -160,7 +160,7 @@ interface InputErrorProps {
export const InputError = (props: InputErrorProps) => { export const InputError = (props: InputErrorProps) => {
const [typoClasses, rest] = splitProps( const [typoClasses, rest] = splitProps(
props.typographyProps || { class: "" }, props.typographyProps || { class: "" },
["class"] ["class"],
); );
return ( return (
<Typography <Typography