Merge pull request 'ui/modal/select: fix z-index stacking' (#4816) from render-2 into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4816
This commit is contained in:
hsjobeki
2025-08-19 17:19:18 +00:00
11 changed files with 127 additions and 77 deletions

View File

@@ -37,7 +37,8 @@
} }
.backdrop { .backdrop {
@apply absolute left-0 top-0 z-50 size-full bg-black opacity-40; @apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
-webkit-backdrop-filter: blur(4px);
} }
.contentWrapper { .contentWrapper {

View File

@@ -1,7 +1,7 @@
import { TagProps } from "@/src/components/Tag/Tag"; import { TagProps } from "@/src/components/Tag/Tag";
import { Meta, StoryObj } from "@kachurun/storybook-solid"; import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { Modal, ModalContext, ModalProps } from "@/src/components/Modal/Modal"; import { Modal, ModalProps } from "@/src/components/Modal/Modal";
import { Fieldset, FieldsetFieldProps } from "@/src/components/Form/Fieldset"; import { Fieldset, FieldsetFieldProps } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput"; import { TextInput } from "@/src/components/Form/TextInput";
import { TextArea } from "@/src/components/Form/TextArea"; import { TextArea } from "@/src/components/Form/TextArea";
@@ -21,7 +21,7 @@ export const Default: Story = {
args: { args: {
title: "Example Modal", title: "Example Modal",
onClose: fn(), onClose: fn(),
children: ({ close }: ModalContext) => ( children: (
<form class="flex flex-col gap-5"> <form class="flex flex-col gap-5">
<Fieldset legend="General"> <Fieldset legend="General">
{(props: FieldsetFieldProps) => ( {(props: FieldsetFieldProps) => (

View File

@@ -1,4 +1,11 @@
import { Component, JSX, Show } from "solid-js"; import {
Component,
JSX,
Show,
createContext,
createSignal,
useContext,
} 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";
@@ -6,15 +13,25 @@ import Icon from "../Icon/Icon";
import cx from "classnames"; import cx from "classnames";
import { Dynamic } from "solid-js/web"; import { Dynamic } from "solid-js/web";
export interface ModalContext { export type ModalContextType = {
close(): void; portalRef: HTMLDivElement;
} };
const ModalContext = createContext<unknown>();
export const useModalContext = () => {
const context = useContext(ModalContext);
if (!context) {
return null;
}
return context as ModalContextType;
};
export interface ModalProps { export interface ModalProps {
id?: string; id?: string;
title: string; title: string;
onClose: () => void; onClose: () => void;
children: (ctx: ModalContext) => JSX.Element; children: JSX.Element;
mount?: Node; mount?: Node;
class?: string; class?: string;
metaHeader?: Component; metaHeader?: Component;
@@ -23,6 +40,7 @@ export interface ModalProps {
} }
export const Modal = (props: ModalProps) => { export const Modal = (props: ModalProps) => {
const [portalRef, setPortalRef] = createSignal<HTMLDivElement>();
return ( return (
<Show when={props.open}> <Show when={props.open}>
<KDialog id={props.id} open={props.open} modal={true}> <KDialog id={props.id} open={props.open} modal={true}>
@@ -60,12 +78,11 @@ export const Modal = (props: ModalProps) => {
<div <div
class={styles.modal_body} class={styles.modal_body}
data-no-padding={props.disablePadding} data-no-padding={props.disablePadding}
ref={setPortalRef}
> >
{props.children({ <ModalContext.Provider value={{ portalRef: portalRef()! }}>
close: () => { {props.children}
props.onClose(); </ModalContext.Provider>
},
})}
</div> </div>
</KDialog.Content> </KDialog.Content>
</div> </div>

View File

@@ -60,11 +60,11 @@
/* Option elements (typically <li>) */ /* Option elements (typically <li>) */
& [role="option"] { & [role="option"] {
@apply p-2 rounded-sm flex items-center gap-1 flex-shrink-0; @apply w-full p-2 rounded-sm flex items-center gap-1 flex-shrink-0;
&[data-highlighted], &[data-highlighted],
&:focus-visible { &:focus-visible {
@apply outline outline-1 outline-inv-2; @apply outline outline-1 outline-inv-2 outline-offset-[-1px];
} }
&:hover { &:hover {
@@ -77,6 +77,10 @@
} }
& [role="listbox"] { & [role="listbox"] {
width: var(--kb-popper-anchor-width);
overflow-x: hidden;
overflow-y: scroll;
&:focus-visible { &:focus-visible {
@apply outline-none; @apply outline-none;
} }

View File

@@ -36,7 +36,10 @@ export const Default: Story = {
description: "Choose your favorite pet from the list", description: "Choose your favorite pet from the list",
}, },
options: [ options: [
{ value: "dog", label: "Doggy" }, {
value: "dog",
label: "DoggyDoggyDoggyDoggyDoggyDoggy DoggyDoggyDoggyDoggyDoggy",
},
{ value: "cat", label: "Catty" }, { value: "cat", label: "Catty" },
{ value: "fish", label: "Fishy" }, { value: "fish", label: "Fishy" },
{ value: "bird", label: "Birdy" }, { value: "bird", label: "Birdy" },

View File

@@ -6,6 +6,7 @@ import { createEffect, createSignal, JSX, Show, splitProps } from "solid-js";
import styles from "./Select.module.css"; import styles from "./Select.module.css";
import { Typography } from "../Typography/Typography"; import { Typography } from "../Typography/Typography";
import cx from "classnames"; import cx from "classnames";
import { useModalContext } from "../Modal/Modal";
export interface Option { export interface Option {
value: string; value: string;
@@ -79,6 +80,13 @@ export const Select = (props: SelectProps) => {
setValue(options().find((option) => props.value === option.value)); setValue(options().find((option) => props.value === option.value));
}); });
const modalContext = useModalContext();
const defaultMount =
props.portalProps?.mount || modalContext?.portalRef || document.body;
createEffect(() => {
console.debug("Select component mounted at:", defaultMount);
});
return ( return (
<KSelect <KSelect
{...root} {...root}
@@ -174,12 +182,40 @@ export const Select = (props: SelectProps) => {
</KSelect.Icon> </KSelect.Icon>
</KSelect.Trigger> </KSelect.Trigger>
</Orienter> </Orienter>
<KSelect.Portal {...props.portalProps}> <KSelect.Portal mount={defaultMount} {...props.portalProps}>
<KSelect.Content <KSelect.Content
class={styles.options_content} class={styles.options_content}
style={{ "--z-index": zIndex() }} style={{ "--z-index": zIndex() }}
> >
<KSelect.Listbox /> <KSelect.Listbox>
{() => (
<KSelect.Trigger
class={cx(styles.trigger)}
style={{ "--z-index": zIndex() }}
data-loading={loading() || undefined}
>
<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}
data-loading={loading() || undefined}
>
<Icon icon="Expand" color="inherit" />
</KSelect.Icon>
</KSelect.Trigger>
)}
</KSelect.Listbox>
</KSelect.Content> </KSelect.Content>
</KSelect.Portal> </KSelect.Portal>
{/* TODO: Display error next to the problem */} {/* TODO: Display error next to the problem */}

View File

@@ -3,11 +3,6 @@
transition: opacity 0.5s ease; transition: opacity 0.5s ease;
} }
.createBackdrop {
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
-webkit-backdrop-filter: blur(4px);
}
.createModal { .createModal {
@apply min-w-96; @apply min-w-96;
} }

View File

@@ -54,55 +54,43 @@ interface MockProps {
} }
const MockCreateMachine = (props: MockProps) => { const MockCreateMachine = (props: MockProps) => {
let container: Node;
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>(); const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
return ( return (
<div ref={(el) => (container = el)} class={cx(styles.createBackdrop)}> <Modal
<Modal open={true}
open={true} onClose={() => {
mount={container!} reset(form);
onClose={() => { props.onClose();
reset(form); }}
props.onClose(); class={cx(styles.createModal)}
}} title="Create Machine"
class={cx(styles.createModal)} >
title="Create Machine" <Form class="flex flex-col" onSubmit={props.onSubmit}>
> <Field name="name">
{() => ( {(field, props) => (
<Form class="flex flex-col" onSubmit={props.onSubmit}> <>
<Field name="name"> <TextInput
{(field, props) => ( {...field}
<> label="Name"
<TextInput
{...field}
label="Name"
size="s"
required={true}
input={{ ...props, placeholder: "name", autofocus: true }}
/>
</>
)}
</Field>
<div class="mt-4 flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
Cancel
</Button>
<Button
size="s" size="s"
type="submit" required={true}
hierarchy="primary" input={{ ...props, placeholder: "name", autofocus: true }}
onClick={close} />
> </>
Create )}
</Button> </Field>
</div>
</Form> <div class="mt-4 flex w-full items-center justify-end gap-4">
)} <Button size="s" hierarchy="secondary" onClick={props.onClose}>
</Modal> Cancel
</div> </Button>
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
Create
</Button>
</div>
</Form>
</Modal>
); );
}; };

View File

@@ -201,6 +201,7 @@ type Story = StoryObj<typeof InstallModal>;
export const Init: Story = { export const Init: Story = {
description: "Welcome step for the installation workflow", description: "Welcome step for the installation workflow",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "init", initialStep: "init",
}, },
@@ -208,6 +209,7 @@ export const Init: Story = {
export const CreateInstallerProse: Story = { export const CreateInstallerProse: Story = {
description: "Prose step for creating an installer", description: "Prose step for creating an installer",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "create:prose", initialStep: "create:prose",
}, },
@@ -215,6 +217,7 @@ export const CreateInstallerProse: Story = {
export const CreateInstallerImage: Story = { export const CreateInstallerImage: Story = {
description: "Configure the image to install", description: "Configure the image to install",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "create:image", initialStep: "create:image",
}, },
@@ -222,6 +225,7 @@ export const CreateInstallerImage: Story = {
export const CreateInstallerDisk: Story = { export const CreateInstallerDisk: Story = {
description: "Select a disk to install the image on", description: "Select a disk to install the image on",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "create:disk", initialStep: "create:disk",
}, },
@@ -229,6 +233,7 @@ export const CreateInstallerDisk: Story = {
export const CreateInstallerProgress: Story = { export const CreateInstallerProgress: Story = {
description: "Showed while the USB stick is being flashed", description: "Showed while the USB stick is being flashed",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "create:progress", initialStep: "create:progress",
}, },
@@ -236,6 +241,7 @@ export const CreateInstallerProgress: Story = {
export const CreateInstallerDone: Story = { export const CreateInstallerDone: Story = {
description: "Installation done step", description: "Installation done step",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "create:done", initialStep: "create:done",
}, },
@@ -243,6 +249,7 @@ export const CreateInstallerDone: Story = {
export const InstallConfigureAddress: Story = { export const InstallConfigureAddress: Story = {
description: "Installation configure address step", description: "Installation configure address step",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "install:address", initialStep: "install:address",
}, },
@@ -250,6 +257,7 @@ export const InstallConfigureAddress: Story = {
export const InstallCheckHardware: Story = { export const InstallCheckHardware: Story = {
description: "Installation check hardware step", description: "Installation check hardware step",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "install:check-hardware", initialStep: "install:check-hardware",
}, },
@@ -257,6 +265,7 @@ export const InstallCheckHardware: Story = {
export const InstallSelectDisk: Story = { export const InstallSelectDisk: Story = {
description: "Select disk to install the system on", description: "Select disk to install the system on",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "install:disk", initialStep: "install:disk",
}, },
@@ -264,6 +273,7 @@ export const InstallSelectDisk: Story = {
export const InstallVars: Story = { export const InstallVars: Story = {
description: "Fill required credentials and data for the installation", description: "Fill required credentials and data for the installation",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "install:data", initialStep: "install:data",
}, },
@@ -271,6 +281,7 @@ export const InstallVars: Story = {
export const InstallSummary: Story = { export const InstallSummary: Story = {
description: "Summary of the installation steps", description: "Summary of the installation steps",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "install:summary", initialStep: "install:summary",
}, },
@@ -278,6 +289,7 @@ export const InstallSummary: Story = {
export const InstallProgress: Story = { export const InstallProgress: Story = {
description: "Shown while the installation is in progress", description: "Shown while the installation is in progress",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "install:progress", initialStep: "install:progress",
}, },
@@ -285,6 +297,7 @@ export const InstallProgress: Story = {
export const InstallDone: Story = { export const InstallDone: Story = {
description: "Shown after the installation is done", description: "Shown after the installation is done",
args: { args: {
open: true,
machineName: "Test Machine", machineName: "Test Machine",
initialStep: "install:done", initialStep: "install:done",
}, },

View File

@@ -97,7 +97,7 @@ export const InstallModal = (props: InstallModalProps) => {
// @ts-expect-error some steps might not have // @ts-expect-error some steps might not have
disablePadding={stepper.currentStep()?.isSplash} disablePadding={stepper.currentStep()?.isSplash}
> >
{(ctx) => <InstallStepper onDone={ctx.close} />} <InstallStepper onDone={() => props.onClose} />
</Modal> </Modal>
</StepperProvider> </StepperProvider>
); );

View File

@@ -142,18 +142,11 @@ const ConfigureImage = () => {
throw new Error("No data returned from api call"); throw new Error("No data returned from api call");
}; };
let content: Node;
return ( return (
<Form onSubmit={handleSubmit} class="h-full"> <Form onSubmit={handleSubmit} class="h-full">
<StepLayout <StepLayout
body={ body={
<div <div class="flex flex-col gap-2">
class="flex flex-col gap-2"
ref={(el) => {
content = el;
}}
>
<Fieldset> <Fieldset>
<Field name="ssh_key"> <Field name="ssh_key">
{(field, input) => ( {(field, input) => (