ui/modal/select: fix z-index stacking

This commit is contained in:
Johannes Kirschbauer
2025-08-19 19:05:09 +02:00
parent 7399f59652
commit e336d1b19c
11 changed files with 127 additions and 77 deletions

View File

@@ -37,7 +37,8 @@
}
.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 {

View File

@@ -1,7 +1,7 @@
import { TagProps } from "@/src/components/Tag/Tag";
import { Meta, StoryObj } from "@kachurun/storybook-solid";
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 { TextInput } from "@/src/components/Form/TextInput";
import { TextArea } from "@/src/components/Form/TextArea";
@@ -21,7 +21,7 @@ export const Default: Story = {
args: {
title: "Example Modal",
onClose: fn(),
children: ({ close }: ModalContext) => (
children: (
<form class="flex flex-col gap-5">
<Fieldset legend="General">
{(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 styles from "./Modal.module.css";
import { Typography } from "../Typography/Typography";
@@ -6,15 +13,25 @@ import Icon from "../Icon/Icon";
import cx from "classnames";
import { Dynamic } from "solid-js/web";
export interface ModalContext {
close(): void;
}
export type ModalContextType = {
portalRef: HTMLDivElement;
};
const ModalContext = createContext<unknown>();
export const useModalContext = () => {
const context = useContext(ModalContext);
if (!context) {
return null;
}
return context as ModalContextType;
};
export interface ModalProps {
id?: string;
title: string;
onClose: () => void;
children: (ctx: ModalContext) => JSX.Element;
children: JSX.Element;
mount?: Node;
class?: string;
metaHeader?: Component;
@@ -23,6 +40,7 @@ export interface ModalProps {
}
export const Modal = (props: ModalProps) => {
const [portalRef, setPortalRef] = createSignal<HTMLDivElement>();
return (
<Show when={props.open}>
<KDialog id={props.id} open={props.open} modal={true}>
@@ -60,12 +78,11 @@ export const Modal = (props: ModalProps) => {
<div
class={styles.modal_body}
data-no-padding={props.disablePadding}
ref={setPortalRef}
>
{props.children({
close: () => {
props.onClose();
},
})}
<ModalContext.Provider value={{ portalRef: portalRef()! }}>
{props.children}
</ModalContext.Provider>
</div>
</KDialog.Content>
</div>

View File

@@ -60,11 +60,11 @@
/* Option elements (typically <li>) */
& [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],
&:focus-visible {
@apply outline outline-1 outline-inv-2;
@apply outline outline-1 outline-inv-2 outline-offset-[-1px];
}
&:hover {
@@ -77,6 +77,10 @@
}
& [role="listbox"] {
width: var(--kb-popper-anchor-width);
overflow-x: hidden;
overflow-y: scroll;
&:focus-visible {
@apply outline-none;
}

View File

@@ -36,7 +36,10 @@ export const Default: Story = {
description: "Choose your favorite pet from the list",
},
options: [
{ value: "dog", label: "Doggy" },
{
value: "dog",
label: "DoggyDoggyDoggyDoggyDoggyDoggy DoggyDoggyDoggyDoggyDoggy",
},
{ value: "cat", label: "Catty" },
{ value: "fish", label: "Fishy" },
{ 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 { Typography } from "../Typography/Typography";
import cx from "classnames";
import { useModalContext } from "../Modal/Modal";
export interface Option {
value: string;
@@ -79,6 +80,13 @@ export const Select = (props: SelectProps) => {
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 (
<KSelect
{...root}
@@ -174,12 +182,40 @@ export const Select = (props: SelectProps) => {
</KSelect.Icon>
</KSelect.Trigger>
</Orienter>
<KSelect.Portal {...props.portalProps}>
<KSelect.Portal mount={defaultMount} {...props.portalProps}>
<KSelect.Content
class={styles.options_content}
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.Portal>
{/* TODO: Display error next to the problem */}

View File

@@ -3,11 +3,6 @@
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 {
@apply min-w-96;
}

View File

@@ -54,55 +54,43 @@ interface MockProps {
}
const MockCreateMachine = (props: MockProps) => {
let container: Node;
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
return (
<div ref={(el) => (container = el)} class={cx(styles.createBackdrop)}>
<Modal
open={true}
mount={container!}
onClose={() => {
reset(form);
props.onClose();
}}
class={cx(styles.createModal)}
title="Create Machine"
>
{() => (
<Form class="flex flex-col" onSubmit={props.onSubmit}>
<Field name="name">
{(field, props) => (
<>
<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
<Modal
open={true}
onClose={() => {
reset(form);
props.onClose();
}}
class={cx(styles.createModal)}
title="Create Machine"
>
<Form class="flex flex-col" onSubmit={props.onSubmit}>
<Field name="name">
{(field, props) => (
<>
<TextInput
{...field}
label="Name"
size="s"
type="submit"
hierarchy="primary"
onClick={close}
>
Create
</Button>
</div>
</Form>
)}
</Modal>
</div>
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" 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 = {
description: "Welcome step for the installation workflow",
args: {
open: true,
machineName: "Test Machine",
initialStep: "init",
},
@@ -208,6 +209,7 @@ export const Init: Story = {
export const CreateInstallerProse: Story = {
description: "Prose step for creating an installer",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:prose",
},
@@ -215,6 +217,7 @@ export const CreateInstallerProse: Story = {
export const CreateInstallerImage: Story = {
description: "Configure the image to install",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:image",
},
@@ -222,6 +225,7 @@ export const CreateInstallerImage: Story = {
export const CreateInstallerDisk: Story = {
description: "Select a disk to install the image on",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:disk",
},
@@ -229,6 +233,7 @@ export const CreateInstallerDisk: Story = {
export const CreateInstallerProgress: Story = {
description: "Showed while the USB stick is being flashed",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:progress",
},
@@ -236,6 +241,7 @@ export const CreateInstallerProgress: Story = {
export const CreateInstallerDone: Story = {
description: "Installation done step",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:done",
},
@@ -243,6 +249,7 @@ export const CreateInstallerDone: Story = {
export const InstallConfigureAddress: Story = {
description: "Installation configure address step",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:address",
},
@@ -250,6 +257,7 @@ export const InstallConfigureAddress: Story = {
export const InstallCheckHardware: Story = {
description: "Installation check hardware step",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:check-hardware",
},
@@ -257,6 +265,7 @@ export const InstallCheckHardware: Story = {
export const InstallSelectDisk: Story = {
description: "Select disk to install the system on",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:disk",
},
@@ -264,6 +273,7 @@ export const InstallSelectDisk: Story = {
export const InstallVars: Story = {
description: "Fill required credentials and data for the installation",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:data",
},
@@ -271,6 +281,7 @@ export const InstallVars: Story = {
export const InstallSummary: Story = {
description: "Summary of the installation steps",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:summary",
},
@@ -278,6 +289,7 @@ export const InstallSummary: Story = {
export const InstallProgress: Story = {
description: "Shown while the installation is in progress",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:progress",
},
@@ -285,6 +297,7 @@ export const InstallProgress: Story = {
export const InstallDone: Story = {
description: "Shown after the installation is done",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:done",
},

View File

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

View File

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