feat(ui): support removing a clan

Also fixes:
- close modal on escape key
- handle class attribute in form components correctly
This commit is contained in:
Brian McGee
2025-08-22 16:48:32 +01:00
parent 431e45cc3a
commit b8203cdf73
11 changed files with 205 additions and 81 deletions

View File

@@ -24,6 +24,14 @@ export const Checkbox = (props: CheckboxProps) => {
// we need to separate output the input otherwise it interferes with prop binding
const [_, rootProps] = splitProps(props, ["input"]);
const [styleProps, otherRootProps] = splitProps(rootProps, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
const alignment = () =>
(props.orientation || "vertical") == "vertical" ? "start" : "center";
@@ -47,14 +55,21 @@ export const Checkbox = (props: CheckboxProps) => {
return (
<KCheckbox.Root
class={cx("form-field", "checkbox", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...rootProps}
class={cx(
styleProps.class,
"form-field",
"checkbox",
styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherRootProps}
>
{(state) => (
<Orienter orientation={props.orientation} align={alignment()}>
<Orienter orientation={styleProps.orientation} align={alignment()}>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}

View File

@@ -1,6 +1,7 @@
export interface FieldProps {
class?: string;
label?: string;
labelWeight?: "bold" | "normal";
description?: string;
tooltip?: string;
ghost?: boolean;

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 } from "solid-js";
import { createSignal, splitProps } from "solid-js";
import { Tooltip } from "@kobalte/core/tooltip";
import { Typography } from "@/src/components/Typography/Typography";
@@ -40,17 +40,31 @@ export const HostFileInput = (props: HostFileInputProps) => {
}
};
const [styleProps, otherProps] = splitProps(props, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
return (
<TextField
class={cx("form-field", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
class={cx(
styleProps.class,
"form-field",
styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherProps}
>
<Orienter
orientation={props.orientation}
align={props.orientation == "horizontal" ? "center" : "start"}
orientation={styleProps.orientation}
align={styleProps.orientation == "horizontal" ? "center" : "start"}
>
<Label
labelComponent={TextField.Label}
@@ -70,12 +84,12 @@ export const HostFileInput = (props: HostFileInputProps) => {
{!value() && (
<Button
hierarchy="secondary"
size={props.size}
size={styleProps.size}
startIcon="Folder"
onClick={selectFile}
disabled={props.disabled || props.readOnly}
class={cx(
props.orientation === "vertical"
styleProps.orientation === "vertical"
? styles.vertical_button
: styles.horizontal_button,
)}
@@ -92,7 +106,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
hierarchy="body"
size="xs"
weight="medium"
inverted={!props.inverted}
inverted={!styleProps.inverted}
>
{value()}
</Typography>
@@ -107,7 +121,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
: styles.horizontal_button,
)}
hierarchy="secondary"
size={props.size}
size={styleProps.size}
startIcon="Folder"
onClick={selectFile}
disabled={props.disabled || props.readOnly}

View File

@@ -27,6 +27,7 @@ export interface LabelProps {
descriptionComponent: DescriptionComponent;
size?: Size;
label?: string;
labelWeight?: "bold" | "normal";
description?: string;
tooltip?: string;
icon?: string;
@@ -46,7 +47,7 @@ export const Label = (props: LabelProps) => {
hierarchy="label"
size={props.size || "default"}
color={props.validationState == "invalid" ? "error" : "primary"}
weight={props.readOnly ? "normal" : "bold"}
weight={props.labelWeight || "bold"}
inverted={props.inverted}
>
{props.label}

View File

@@ -85,6 +85,14 @@ export const TextArea = (props: TextAreaProps) => {
"maxRows",
]);
const [styleProps, otherProps] = splitProps(props, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
return (
<TextField
ref={(el: HTMLDivElement) => {
@@ -92,13 +100,20 @@ export const TextArea = (props: TextAreaProps) => {
// but not in webkit, so we capture the parent ref and query for the textarea
textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement;
}}
class={cx("form-field", "textarea", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
class={cx(
styleProps.class,
"form-field",
"textarea",
styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherProps}
>
<Orienter orientation={props.orientation} align={"start"}>
<Orienter orientation={styleProps.orientation} align={"start"}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}

View File

@@ -11,6 +11,7 @@ import "./TextInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { splitProps } from "solid-js";
export type TextInputProps = FieldProps &
TextFieldRootProps & {
@@ -19,15 +20,30 @@ export type TextInputProps = FieldProps &
};
export const TextInput = (props: TextInputProps) => {
const [styleProps, otherProps] = splitProps(props, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
return (
<TextField
class={cx("form-field", "text", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
class={cx(
styleProps.class,
"form-field",
"text",
styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherProps}
>
<Orienter orientation={props.orientation}>
<Orienter orientation={styleProps.orientation}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
@@ -37,7 +53,7 @@ export const TextInput = (props: TextInputProps) => {
{props.icon && !props.readOnly && (
<Icon
icon={props.icon}
inverted={props.inverted}
inverted={styleProps.inverted}
color={props.disabled ? "tertiary" : "quaternary"}
/>
)}

View File

@@ -58,7 +58,10 @@ export const Modal = (props: ModalProps) => {
<KDialog.Portal mount={props.mount}>
<div class={styles.backdrop} />
<div class={styles.contentWrapper}>
<KDialog.Content class={cx(styles.modal_content, props.class)}>
<KDialog.Content
class={cx(styles.modal_content, props.class)}
onEscapeKeyDown={props.onClose}
>
{contentWrapper({
children: (
<>

View File

@@ -1,7 +1,24 @@
.modal {
@apply w-screen max-w-xl h-fit flex flex-col;
.content {
@apply flex flex-col gap-3 size-full;
}
.header {
@apply flex w-full items-center justify-between;
}
.remove {
@apply flex justify-between items-center px-4 py-5 gap-8;
@apply rounded-md bg-semantic-error-2 border-[0.0625rem] border-semantic-error-4;
.clanInput {
@apply grow;
}
.buttons {
@apply flex items-center gap-2;
}
}
}

View File

@@ -20,6 +20,7 @@ import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { callApi } from "@/src/hooks/api";
import { Alert } from "@/src/components/Alert/Alert";
import { removeClanURI } from "@/src/stores/clan";
const schema = v.object({
name: v.pipe(v.optional(v.string())),
@@ -93,6 +94,14 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
return firstFormError || formStore.response.message;
};
const [removeValue, setRemoveValue] = createSignal("");
const removeDisabled = () => removeValue() !== props.model.details.name;
const onRemove = () => {
removeClanURI(props.model.uri);
};
return (
<Modal
class={cx(styles.modal)}
@@ -118,6 +127,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
</div>
)}
>
<div class={cx(styles.content)}>
<Show when={errorMessage()}>
<Alert type="error" title="Error" description={errorMessage()} />
</Show>
@@ -165,6 +175,34 @@ 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();
}
},
}}
/>
<Button
hierarchy="primary"
size="s"
startIcon="Trash"
disabled={removeDisabled()}
onClick={onRemove}
>
Remove
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -78,6 +78,7 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
size="s"
inverted
label="Name"
labelWeight="normal"
required
readOnly={readOnly(editing, "name")}
orientation="horizontal"
@@ -99,6 +100,7 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
size="s"
inverted
label="Platform"
labelWeight="normal"
required
readOnly={readOnly(editing, "machineClass")}
orientation="horizontal"
@@ -119,6 +121,7 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
defaultValue={field.value ?? ""}
size="s"
label="Description"
labelWeight="normal"
inverted
readOnly={readOnly(editing, "description")}
tooltip={tooltipText("description", fieldsSchema()!)}

View File

@@ -51,7 +51,7 @@ def get_clan_details_schema(flake: Flake) -> dict[str, FieldSchema]:
"""
inventory_store = InventoryStore(flake)
write_info = inventory_store.get_writeability_of("meta")
write_info = inventory_store.get_writeability()
field_names = retrieve_typed_field_names(InventoryMeta)
@@ -60,6 +60,7 @@ def get_clan_details_schema(flake: Flake) -> dict[str, FieldSchema]:
"readonly": not is_writeable_key(f"meta.{field}", write_info),
# TODO: Provide a meaningful reason
"reason": None,
"readonly_members": [],
}
for field in field_names
}