Merge pull request 'UI: extend components to prepare install workflows' (#4576) from install-ui into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4576
This commit is contained in:
hsjobeki
2025-08-05 13:36:31 +00:00
17 changed files with 913 additions and 10 deletions

View File

@@ -4,6 +4,7 @@ import { Separator, SeparatorRootProps } from "@kobalte/core/separator";
export interface DividerProps extends Pick<SeparatorRootProps, "orientation"> { export interface DividerProps extends Pick<SeparatorRootProps, "orientation"> {
inverted?: boolean; inverted?: boolean;
class?: string;
} }
export const Divider = (props: DividerProps) => { export const Divider = (props: DividerProps) => {
@@ -11,7 +12,7 @@ export const Divider = (props: DividerProps) => {
return ( return (
<Separator <Separator
class={cx({ inverted: inverted })} class={cx({ inverted: inverted }, props?.class)}
orientation={props.orientation} orientation={props.orientation}
/> />
); );

View File

@@ -19,6 +19,7 @@ export type HostFileInputProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
onSelectFile: () => Promise<string>; onSelectFile: () => Promise<string>;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>; input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
placeholder?: string;
}; };
export const HostFileInput = (props: HostFileInputProps) => { export const HostFileInput = (props: HostFileInputProps) => {
@@ -79,7 +80,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
: styles.horizontal_button, : styles.horizontal_button,
)} )}
> >
No Selection {props.placeholder || "No Selection"}
</Button> </Button>
)} )}

View File

@@ -1,6 +1,7 @@
div.form-label { div.form-label {
@apply flex flex-col gap-1 w-full; @apply flex flex-col gap-1 w-full;
& > span,
& > label, & > label,
& > div { & > div {
@apply w-full; @apply w-full;
@@ -8,12 +9,14 @@ div.form-label {
@apply leading-none; @apply leading-none;
} }
& > span,
& > label { & > label {
@apply flex items-center gap-1; @apply flex items-center gap-1;
} }
& > span[data-required]:not(span[data-readonly]),
& > label[data-required]:not(label[data-readonly]) { & > label[data-required]:not(label[data-readonly]) {
span.typography::after { .typography::after {
@apply fg-def-4 ml-1; @apply fg-def-4 ml-1;
content: "*"; content: "*";

View File

@@ -5,6 +5,7 @@ import Icon from "@/src/components/Icon/Icon";
import { TextField } from "@kobalte/core/text-field"; import { TextField } from "@kobalte/core/text-field";
import { Checkbox } from "@kobalte/core/checkbox"; import { Checkbox } from "@kobalte/core/checkbox";
import { Combobox } from "@kobalte/core/combobox"; import { Combobox } from "@kobalte/core/combobox";
import { Select } from "@kobalte/core/select";
import "./Label.css"; import "./Label.css";
export type Size = "default" | "s"; export type Size = "default" | "s";
@@ -12,11 +13,14 @@ export type Size = "default" | "s";
export type LabelComponent = export type LabelComponent =
| typeof TextField.Label | typeof TextField.Label
| typeof Checkbox.Label | typeof Checkbox.Label
| typeof Combobox.Label; | typeof Combobox.Label
| typeof Select.Label;
export type DescriptionComponent = export type DescriptionComponent =
| typeof TextField.Description | typeof TextField.Description
| typeof Checkbox.Description | typeof Checkbox.Description
| typeof Combobox.Description; | typeof Combobox.Description
| typeof Select.Description;
export interface LabelProps { export interface LabelProps {
labelComponent: LabelComponent; labelComponent: LabelComponent;

View File

@@ -4,14 +4,17 @@
/* todo replace with a theme() color */ /* todo replace with a theme() color */
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32); box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
@apply border border-def-3 rounded-bl-md rounded-br-md;
} }
.modal_header { .modal_header {
@apply flex items-center justify-center; @apply flex items-center justify-center;
@apply w-full px-2 py-1.5; @apply w-full px-2 py-1.5;
@apply bg-def-3; @apply bg-def-3;
@apply border border-def-2 rounded-tl-md rounded-tr-md; @apply rounded-tl-md rounded-tr-md;
@apply border-b-def-3; /* @apply border-b-def-3; */
border-bottom: solid 1px theme(colors.border.def.2);
} }
.modal_title { .modal_title {
@@ -19,6 +22,9 @@
} }
.modal_body { .modal_body {
@apply p-6 bg-def-1; @apply rounded-md p-6 pt-4 bg-def-1;
@apply border border-def-2 rounded-bl-md rounded-br-md; }
.header_divider {
@apply bg-def-3 h-[6px] border-def-2 border-t-[1px];
} }

View File

@@ -1,4 +1,4 @@
import { createSignal, JSX } from "solid-js"; import { createSignal, JSX, Show } from "solid-js";
import { Dialog as KDialog } from "@kobalte/core/dialog"; import { Dialog as KDialog } from "@kobalte/core/dialog";
import styles from "./Modal.module.css"; import styles from "./Modal.module.css";
import { Typography } from "../Typography/Typography"; import { Typography } from "../Typography/Typography";
@@ -16,6 +16,7 @@ export interface ModalProps {
children: (ctx: ModalContext) => JSX.Element; children: (ctx: ModalContext) => JSX.Element;
mount?: Node; mount?: Node;
class?: string; class?: string;
metaHeader?: () => JSX.Element;
} }
export const Modal = (props: ModalProps) => { export const Modal = (props: ModalProps) => {
@@ -43,6 +44,14 @@ export const Modal = (props: ModalProps) => {
<Icon icon="Close" size="0.75rem" /> <Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton> </KDialog.CloseButton>
</div> </div>
<Show when={props.metaHeader?.()}>
{(metaHeader) => (
<>
{metaHeader()}
<div class={styles.header_divider} />
</>
)}
</Show>
<div class={styles.modal_body}> <div class={styles.modal_body}>
{props.children({ {props.children({
close: () => { close: () => {

View File

@@ -0,0 +1,88 @@
.trigger {
@apply bg-def-1 flex flex-grow justify-between items-center gap-2 h-7 px-2 py-1;
@apply rounded-[4px];
&[data-expanded] {
@apply outline-def-2 outline-1 outline;
z-index: 40;
}
&[data-highlighted] {
@apply outline outline-1 outline-inv-2
}
&:hover {
@apply bg-def-2;
& .icon {
@apply bg-def-1 text-fg-def-1;
}
}
&:active {
@apply bg-def-4;
& .icon {
@apply bg-inv-4 text-fg-inv-1;
}
}
&:focus-visible {
@apply outline-def-2 outline-1 outline;
}
}
.icon {
@apply bg-def-2 rounded-sm;
@apply flex items-center justify-center h-[14px] w-[14px] p-[2px];
&[data-disabled] {
@apply cursor-not-allowed;
}
}
.options_content {
@apply bg-def-1 px-1 py-3 rounded-[4px] -mt-8 pt-10 -mx-1;
@apply outline-def-2 outline-1 outline;
transform-origin: var(--kb-popper-content-transform-origin);
&[data-expanded] {
animation: overlayShow 250ms ease-out;
}
/* Option elements (typically <li>) */
& [role="option"] {
@apply px-1 py-2 rounded-sm flex items-center gap-1 flex-shrink-0 ;
&[data-highlighted],
&:focus-visible {
@apply outline outline-1 outline-inv-2
}
&:hover {
@apply bg-def-2;
}
&:active {
@apply bg-def-4;
}
}
& [role="listbox"] {
&:focus-visible {
@apply outline-none;
}
}
}
@keyframes overlayShow {
from {
opacity: 0;
transform: scaleY(0.94);
}
to {
opacity: 1;
transform: scaleY(1);
}
}

View File

@@ -0,0 +1,71 @@
import { TagProps } from "@/src/components/Tag/Tag";
import { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { Select, SelectProps } from "./Select";
import { Fieldset } from "../Form/Fieldset";
// const meta: Meta<SelectProps> = {
// title: "Components/Select",
// component: Select,
// };
const meta = {
title: "Components/Form/Select",
component: Select,
decorators: [
(Story: StoryObj, context: StoryContext<SelectProps>) => {
return (
<div class={`w-[600px]`}>
<Fieldset>
<Story />
</Fieldset>
</div>
);
},
],
} satisfies Meta<SelectProps>;
export default meta;
type Story = StoryObj<TagProps>;
export const Default: Story = {
args: {
required: true,
label: {
label: "Select your pet",
description: "Choose your favorite pet from the list",
},
options: [
{ value: "dog", label: "Doggy" },
{ value: "cat", label: "Catty" },
{ value: "fish", label: "Fishy" },
{ value: "bird", label: "Birdy" },
{ value: "hamster", label: "Hammy" },
{ value: "snake", label: "Snakey" },
{ value: "turtle", label: "Turtly" },
],
placeholder: "Select your pet",
},
};
// <Field name="language">
// {(field, input) => (
// <Select
// required
// label={{
// label: "Language",
// description: "Select your preferred language",
// }}
// options={[
// { value: "en", label: "English" },
// { value: "fr", label: "Français" },
// ]}
// placeholder="Language"
// onChange={(opt) => {
// setValue(formStore, "language", opt?.value || "");
// }}
// name={field.name}
// validationState={field.error ? "invalid" : "valid"}
// />
// )}
// </Field>

View File

@@ -0,0 +1,118 @@
import { Select as KSelect } from "@kobalte/core/select";
import Icon from "../Icon/Icon";
import { Orienter } from "../Form/Orienter";
import { Label, LabelProps } from "../Form/Label";
import { createEffect, createSignal, JSX, splitProps } from "solid-js";
import styles from "./Select.module.css";
import { Typography } from "../Typography/Typography";
import cx from "classnames";
export interface Option { value: string; label: string; disabled?: boolean }
export interface SelectProps {
// Kobalte Select props, for modular forms
name: string;
placeholder?: string | undefined;
options: Option[];
value: string | undefined;
error: string;
required?: boolean | undefined;
disabled?: boolean | undefined;
ref: (element: HTMLSelectElement) => void;
onInput: JSX.EventHandler<HTMLSelectElement, InputEvent>;
onChange: JSX.EventHandler<HTMLSelectElement, Event>;
onBlur: JSX.EventHandler<HTMLSelectElement, FocusEvent>;
// Custom props
orientation?: "horizontal" | "vertical";
label?: Omit<LabelProps, "labelComponent" | "descriptionComponent">;
}
export const Select = (props: SelectProps) => {
const [root, selectProps] = splitProps(
props,
["name", "placeholder", "options", "required", "disabled"],
["placeholder", "ref", "onInput", "onChange", "onBlur"],
);
const [getValue, setValue] = createSignal<Option>();
createEffect(() => {
setValue(props.options.find((option) => props.value === option.value));
});
return (
<KSelect
{...root}
sameWidth={true}
gutter={0}
multiple={false}
value={getValue()}
onChange={setValue}
optionValue="value"
optionTextValue="label"
optionDisabled="disabled"
validationState={props.error ? "invalid" : "valid"}
itemComponent={(props) => (
<KSelect.Item item={props.item} class="flex gap-1 p-2">
<KSelect.ItemIndicator>
<Icon icon="Checkmark" />
</KSelect.ItemIndicator>
<KSelect.ItemLabel>
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
>
{props.item.rawValue.label}
</Typography>
</KSelect.ItemLabel>
</KSelect.Item>
)}
placeholder={
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
>
{props.placeholder}
</Typography>
}
>
<Orienter orientation={props.orientation || "horizontal"}>
<Label
{...props.label}
labelComponent={KSelect.Label}
descriptionComponent={KSelect.Description}
validationState={props.error ? "invalid" : "valid"}
/>
<KSelect.HiddenSelect {...selectProps} />
<KSelect.Trigger class={cx(styles.trigger)}>
<KSelect.Value<Option>>
{(state) => (
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
>
{state.selectedOption().label}
</Typography>
)}
</KSelect.Value>
<KSelect.Icon as="button" class={styles.icon}>
<Icon icon="Expand" color="inherit" />
</KSelect.Icon>
</KSelect.Trigger>
</Orienter>
<KSelect.Portal>
<KSelect.Content class={styles.options_content}>
<KSelect.Listbox />
</KSelect.Content>
</KSelect.Portal>
{/* TODO: Display error next to the problem */}
{/* <KSelect.ErrorMessage>{props.error}</KSelect.ErrorMessage> */}
</KSelect>
);
};

View File

@@ -0,0 +1,120 @@
import {
Accessor,
createContext,
createSignal,
JSX,
Setter,
useContext,
} from "solid-js";
export interface StepBase {
id: string;
}
export type Step<ExtraFields = unknown> = StepBase & ExtraFields;
export interface StepOptions<Id> {
initialStep: Id;
}
export function createStepper<
T extends readonly Step<Extra>[],
StepId extends T[number]["id"],
Extra = unknown,
>(s: { steps: T }, stepOpts: StepOptions<StepId>): StepperReturn<T> {
const [activeStep, setActiveStep] = createSignal<T[number]["id"]>(
stepOpts.initialStep,
);
/**
* Hooks to manage the current step in the workflow.
* It provides the active step and a function to set the active step.
*/
return {
activeStep,
setActiveStep,
currentStep: () => {
const curr = s.steps.find((step) => step.id === activeStep());
if (!curr) {
throw new Error(`Step with id ${activeStep()} not found`);
}
return curr;
},
next: () => {
const currentIndex = s.steps.findIndex(
(step) => step.id === activeStep(),
);
if (currentIndex === -1 || currentIndex === s.steps.length - 1) {
throw new Error("No next step available");
}
setActiveStep(s.steps[currentIndex + 1].id);
},
previous: () => {
const currentIndex = s.steps.findIndex(
(step) => step.id === activeStep(),
);
if (currentIndex <= 0) {
throw new Error("No previous step available");
}
setActiveStep(s.steps[currentIndex - 1].id);
},
hasPrevious: () => {
const currentIndex = s.steps.findIndex(
(step) => step.id === activeStep(),
);
return currentIndex > 0;
},
hasNext: () => {
const currentIndex = s.steps.findIndex(
(step) => step.id === activeStep(),
);
return currentIndex >= 0 && currentIndex < s.steps.length - 1;
},
};
}
export interface StepperReturn<
T extends readonly Step[],
StepId = T[number]["id"],
> {
activeStep: Accessor<StepId>;
setActiveStep: Setter<StepId>;
currentStep: () => T[number];
next: () => void;
previous: () => void;
hasPrevious: () => boolean;
hasNext: () => boolean;
}
const StepperContext = createContext<unknown>(); // Concrete type will be provided by the provider
// Default assignment to "never" forces users to specify the type when using the hook, otherwise the return type will be `never`.
export function useStepper<T extends readonly Step[] = never>() {
const ctx = useContext(StepperContext);
if (!ctx) throw new Error("useStepper must be used inside StepperProvider");
return ctx as T extends never ? never : StepperReturn<T, T[number]["id"]>; // type casting required due to context limitations
}
interface ProviderProps<T extends readonly Step[], StepId> {
stepper: StepperReturn<T, StepId>;
children: JSX.Element;
}
interface ProviderProps<
T extends readonly Step[],
StepId extends T[number]["id"],
> {
stepper: StepperReturn<T, StepId>;
children: JSX.Element;
}
export function StepperProvider<
T extends readonly Step[],
StepId extends T[number]["id"],
>(props: ProviderProps<T, StepId>) {
return (
<StepperContext.Provider value={props.stepper}>
{props.children}
</StepperContext.Provider>
);
}

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { InstallModal } from "./install";
const meta: Meta = {
title: "workflows/install",
component: InstallModal,
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {
machineName: "Test Machine",
initialStep: "create:iso-1",
},
};

View File

@@ -0,0 +1,81 @@
import { Modal } from "@/src/components/Modal/Modal";
import {
createStepper,
StepperProvider,
useStepper,
} from "@/src/hooks/stepper";
import { createForm, FieldValues, SubmitHandler } from "@modular-forms/solid";
import { Show } from "solid-js";
import { Dynamic } from "solid-js/web";
import { InitialStep } from "./steps/Initial";
import { createInstallerSteps } from "./steps/createInstaller";
import { installSteps } from "./steps/installSteps";
interface InstallForm extends FieldValues {
data_from_step_1: string;
data_from_step_2?: string;
data_from_step_3?: string;
}
const InstallStepper = () => {
const stepSignal = useStepper<InstallSteps>();
const [formStore, { Form, Field, FieldArray }] = createForm<InstallForm>();
const handleSubmit: SubmitHandler<InstallForm> = (values, event) => {
console.log("Installation started (submit)", values);
stepSignal.setActiveStep("install:progress");
};
return (
<Form onSubmit={handleSubmit}>
<div class="gap-6">
<Dynamic
component={stepSignal.currentStep().content}
machineName={"karl"}
/>
</div>
</Form>
);
};
export interface InstallModalProps {
machineName: string;
initialStep?: string;
}
const steps = [InitialStep, ...createInstallerSteps, ...installSteps] as const;
export type InstallSteps = typeof steps;
export const InstallModal = (props: InstallModalProps) => {
const stepper = createStepper(
{
steps,
},
{ initialStep: "init" },
);
return (
<StepperProvider stepper={stepper}>
<Modal
title="Install machine"
onClose={() => {
console.log("Install aborted");
}}
metaHeader={() => {
// @ts-expect-error some steps might not have a title
const HeaderComponent = stepper.currentStep()?.title;
return (
<Show when={HeaderComponent}>
{(C) => (
<Dynamic component={C()} machineName={props.machineName} />
)}
</Show>
);
}}
>
{(ctx) => <InstallStepper />}
</Modal>
</StepperProvider>
);
};

View File

@@ -0,0 +1,64 @@
import { useStepper } from "@/src/hooks/stepper";
import { InstallSteps } from "../install";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { Divider } from "@/src/components/Divider/Divider";
const InitialChoice = () => {
const stepSignal = useStepper<InstallSteps>();
return (
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2">
<div class="flex flex-col gap-1 px-1">
<Typography
hierarchy="label"
size="xs"
weight="bold"
color="primary"
>
Remote setup
</Typography>
<Typography
hierarchy="body"
size="xxs"
weight="normal"
color="secondary"
>
Is your machine currently online? Does it have an IP-address, can
you SSH into it? And does it support Kexec?
</Typography>
</div>
<Button
type="button"
ghost
hierarchy="secondary"
icon="CaretRight"
onClick={() => stepSignal.setActiveStep("install:machine-0")}
></Button>
</div>
<Divider orientation="horizontal" class="bg-def-3" />
<div class="flex items-center justify-between gap-2">
<Typography hierarchy="label" size="xs" weight="bold">
I don't have an installer, yet
</Typography>
<Button
ghost
hierarchy="secondary"
endIcon="Flash"
type="button"
onClick={() => stepSignal.setActiveStep("create:iso-0")}
>
Create USB Installer
</Button>
</div>
</div>
</div>
);
};
export const InitialStep = {
id: "init",
content: InitialChoice,
};

View File

@@ -0,0 +1,206 @@
import { useStepper } from "@/src/hooks/stepper";
import {
createForm,
getError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import * as v from "valibot";
import { InstallSteps } from "../install";
import { callApi } from "@/src/hooks/api";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { Select } from "@/src/components/Select/Select";
import { BackButton, NextButton, StepFooter, StepLayout } from "../../Steps";
import { Typography } from "@/src/components/Typography/Typography";
const CreateHeader = (props: { machineName: string }) => {
return (
<div class="px-6 py-2">
<Typography
hierarchy="label"
size="default"
family="mono"
weight="medium"
>
Create installer
</Typography>
</div>
);
};
const CreateFlashSchema = v.object({
ssh_key: v.pipe(
v.string("Please select a key."),
v.nonEmpty("Please select a key."),
),
language: v.pipe(v.string(), v.nonEmpty("Please choose a language.")),
keymap: v.pipe(v.string(), v.nonEmpty("Please select a keyboard layout.")),
});
type FlashFormType = v.InferInput<typeof CreateFlashSchema>;
const CreateIso = () => {
const [formStore, { Form, Field }] = createForm<FlashFormType>({
validate: valiForm(CreateFlashSchema),
});
const stepSignal = useStepper<InstallSteps>();
// TODO: push values to the parent form Store
const handleSubmit: SubmitHandler<FlashFormType> = (values, event) => {
console.log("ISO creation submitted", values);
// Here you would typically trigger the ISO creation process
stepSignal.next();
};
const onSelectFile = async () => {
const req = callApi("get_system_file", {
file_request: {
mode: "select_folder",
title: "Select a folder for you new Clan",
},
});
const resp = await req.result;
if (resp.status === "error") {
// just throw the first error, I can't imagine why there would be multiple
// errors for this call
throw new Error(resp.errors[0].message);
}
if (resp.status === "success" && resp.data) {
return resp.data[0];
}
throw new Error("No data returned from api call");
};
return (
<Form onSubmit={handleSubmit}>
<StepLayout
body={
<div class="flex flex-col gap-2">
<Fieldset>
<Field name="ssh_key">
{(field, input) => (
<HostFileInput
description="Public Key for connecting to the machine"
onSelectFile={onSelectFile}
{...field}
value={field.value}
label="Select directory"
orientation="horizontal"
placeholder="Select SSH Key"
required={true}
validationState={
getError(formStore, "ssh_key") ? "invalid" : "valid"
}
input={input}
/>
)}
</Field>
</Fieldset>
<Fieldset>
<Field name="language">
{(field, props) => (
<Select
{...props}
value={field.value}
error={field.error}
required
label={{
label: "Language",
description: "Select your preferred language",
}}
options={[
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
]}
placeholder="Language"
name={field.name}
/>
)}
</Field>
<Field name="keymap">
{(field, props) => (
<Select
{...props}
value={field.value}
error={field.error}
required
label={{
label: "Keymap",
description: "Select your keyboard layout",
}}
options={[
{ value: "EN_US", label: "QWERTY" },
{ value: "DE_DE", label: "QWERTZ" },
]}
placeholder="Keymap"
name={field.name}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-between">
<BackButton />
<NextButton type="submit" />
</div>
}
/>
</Form>
);
};
export const createInstallerSteps = [
{
id: "create:iso-0",
content: () => (
<StepLayout
body={
<>
<div class="flex h-36 w-full flex-col justify-center gap-3 rounded-md px-4 py-6 text-fg-inv-1 outline-2 outline-bg-def-acc-3 bg-inv-4">
<div class="flex flex-col gap-3">
<Typography
hierarchy="label"
size="xs"
weight="medium"
color="inherit"
>
Create a portable installer
</Typography>
<Typography
hierarchy="headline"
size="default"
weight="bold"
color="inherit"
>
Grab a disposable USB stick and plug it in
</Typography>
</div>
</div>
<div class="flex flex-col gap-1">
<Typography hierarchy="body" size="default" weight="bold">
We will erase everything on it during this process
</Typography>
<Typography hierarchy="body" size="xs">
Create a portable installer tool that can turn any machine into
a fully configured Clan machine.
</Typography>
</div>
</>
}
footer={<StepFooter />}
/>
),
},
{
id: "create:iso-1",
title: CreateHeader,
content: CreateIso,
},
] as const;

View File

@@ -0,0 +1,43 @@
import { Typography } from "@/src/components/Typography/Typography";
import { NextButton } from "../../Steps";
export const InstallHeader = (props: { machineName: string }) => {
return (
<Typography hierarchy="label" size="default">
Installing: {props.machineName}
</Typography>
);
};
export const installSteps = [
{
id: "install:machine-0",
title: InstallHeader,
content: () => (
<div>
Enter the targetHost
<NextButton />
</div>
),
},
{
id: "install:confirm",
title: InstallHeader,
content: (props: { machineName: string }) => (
<div>
Confirm the installation of {props.machineName}
<NextButton />
</div>
),
},
{
id: "install:progress",
title: InstallHeader,
content: () => (
<div>
<p>Installation in progress...</p>
<p>Please wait while we set up your machine.</p>
</div>
),
},
] as const;

View File

@@ -0,0 +1,70 @@
import { JSX } from "solid-js";
import { useStepper } from "../hooks/stepper";
import { Button } from "../components/Button/Button";
import { InstallSteps } from "./Install/install";
interface StepLayoutProps {
body: JSX.Element;
footer: JSX.Element;
}
export const StepLayout = (props: StepLayoutProps) => {
return (
<div class="flex flex-col gap-6">
{props.body}
{props.footer}
</div>
);
};
type NextButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {};
export const NextButton = (props: NextButtonProps) => {
// TODO: Make this type generic
const stepSignal = useStepper<InstallSteps>();
return (
<Button
type="submit"
hierarchy="primary"
disabled={!stepSignal.hasNext()}
endIcon="ArrowRight"
{...props}
>
Next
</Button>
);
};
export const BackButton = () => {
const stepSignal = useStepper<InstallSteps>();
return (
<Button
hierarchy="secondary"
disabled={!stepSignal.hasPrevious()}
startIcon="ArrowLeft"
onClick={() => {
stepSignal.previous();
}}
>
Back
</Button>
);
};
/**
* Renders a footer with Back and Next buttons.
* The Next button will trigger the next step in the stepper.
* The Back button will go to the previous step.
*
* Does not trigger submission on any form
*
* Use this for overview steps where no form submission is required.
*/
export const StepFooter = () => {
const stepper = useStepper<InstallSteps>();
return (
<div class="flex justify-between">
<BackButton />
<NextButton type="button" onClick={() => stepper.next()} />
</div>
);
};