Skip to content

Commit 9d2c439

Browse files
centdixclaude
andauthored
fix: resource drawer opening behind dialog in chat mode (#8328)
* fix: resource drawer opening behind dialog in chat mode Integrate Modal into the Disposable z-index stacking system so drawers opened from within a modal (e.g. "Add a new resource") correctly appear above the dialog instead of behind it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resource drawer opening behind dialog in chat mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: simplify minZIndex tracking by removing unnecessary refcount Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use map-based minZIndex tracking and conditional chat elevation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use plain object instead of Map for reactive minZIndex tracking $state(new Map()) is not deeply reactive in Svelte 5 — only plain objects and arrays are proxied. Replaced with Record<string, number> so that property assignments properly trigger $derived updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fb12b31 commit 9d2c439

File tree

2 files changed

+117
-59
lines changed

2 files changed

+117
-59
lines changed

frontend/src/lib/components/common/drawer/Disposable.svelte

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
<script lang="ts" module>
22
export let openedDrawers: { val: string[] } = $state({ val: [] })
3+
4+
// When a disposable with minZIndex is open, all disposables use that as
5+
// their z-index base so that overlays opened on top (e.g. a Drawer from
6+
// inside a Modal) stack correctly above it.
7+
// We track per-id entries so concurrent modals don't clobber each other
8+
// (closing one must not reset the base while another is still open).
9+
let minZIndexEntries: Record<string, number> = $state({})
10+
let activeMinZIndex = $derived.by(() => {
11+
const values = Object.values(minZIndexEntries)
12+
return values.length > 0 ? Math.max(...values) : 0
13+
})
314
</script>
415

516
<script lang="ts">
@@ -11,6 +22,11 @@
1122
id?: any
1223
preventEscape?: boolean
1324
initialOffset?: number
25+
/** Minimum z-index base for this overlay. While any disposable with a
26+
* minZIndex is open, all disposables use that as their base so that
27+
* subsequent overlays stack above it (e.g. zIndexes.aiChat + 1 for
28+
* modals that need to render above the AI chat panel). */
29+
minZIndex?: number
1430
children?: import('svelte').Snippet<[any]>
1531
onOpen?: () => void
1632
onClose?: () => void
@@ -21,13 +37,17 @@
2137
id = (Math.random() + 1).toString(36).substring(10),
2238
preventEscape = false,
2339
initialOffset = 0,
40+
minZIndex = 0,
2441
children,
2542
onOpen,
2643
onClose
2744
}: Props = $props()
2845
2946
let offset = $state(untrack(() => initialOffset))
30-
let zIndex = $derived(zIndexes.disposables + offset)
47+
// Note: when a Modal with minZIndex is open, all disposables (including
48+
// already-open Drawers) are elevated. This is acceptable — relative
49+
// stacking order is preserved by the per-instance offset.
50+
let zIndex = $derived(Math.max(zIndexes.disposables, activeMinZIndex) + offset)
3151
3252
export function toggleDrawer() {
3353
if (!open) {
@@ -44,13 +64,19 @@
4464
}
4565
openedDrawers.val.push(id)
4666
offset = initialOffset + openedDrawers.val.length
67+
if (minZIndex > 0) {
68+
minZIndexEntries[id] = minZIndex
69+
}
4770
}
4871
4972
export function closeDrawer() {
5073
open = false
5174
offset = initialOffset
5275
if (openedDrawers.val.includes(id)) {
5376
openedDrawers.val = openedDrawers.val.filter((drawer) => drawer !== id)
77+
if (minZIndex > 0) {
78+
delete minZIndexEntries[id]
79+
}
5480
}
5581
}
5682
@@ -89,6 +115,9 @@
89115
if (open) {
90116
openedDrawers.val.push(untrack(() => id))
91117
offset = untrack(() => initialOffset) + openedDrawers.val.length
118+
if (minZIndex > 0) {
119+
minZIndexEntries[untrack(() => id)] = minZIndex
120+
}
92121
}
93122
94123
let wasEverOpen = false

frontend/src/lib/components/common/modal/Modal.svelte

Lines changed: 87 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
import { createBubbler, stopPropagation } from 'svelte/legacy'
33
44
const bubble = createBubbler()
5-
import { createEventDispatcher } from 'svelte'
5+
import { createEventDispatcher, untrack } from 'svelte'
66
import { fade } from 'svelte/transition'
77
import Button from '../button/Button.svelte'
88
import { twMerge } from 'tailwind-merge'
99
import CloseButton from '../CloseButton.svelte'
10+
import Disposable from '../drawer/Disposable.svelte'
11+
import { zIndexes } from '$lib/zIndexes'
12+
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
1013
1114
interface Props {
1215
title: string
@@ -28,12 +31,28 @@
2831
cancelText = undefined,
2932
kind = 'button',
3033
settings,
31-
children,
34+
children: children_render,
3235
actions
3336
}: Props = $props()
3437
3538
const dispatch = createEventDispatcher()
3639
40+
let disposable: Disposable | undefined = $state(undefined)
41+
42+
// Only elevate above the AI chat panel when it's actually open —
43+
// when chat is closed there's nothing at z-index 1200 to stack above.
44+
const minZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : 0)
45+
46+
// Both `bind:open` and this $effect are needed: bind:open syncs the
47+
// boolean, while the effect calls openDrawer/closeDrawer to register
48+
// the disposable in the stacking system (same pattern as Drawer.svelte).
49+
$effect(() => {
50+
open
51+
untrack(() => {
52+
open ? disposable?.openDrawer() : disposable?.closeDrawer()
53+
})
54+
})
55+
3756
function onKeyDown(event: KeyboardEvent) {
3857
if (open) {
3958
switch (event.key) {
@@ -58,70 +77,80 @@
5877

5978
<svelte:window onkeydowncapture={onKeyDown} />
6079

61-
{#if open}
62-
<!-- svelte-ignore a11y_click_events_have_key_events -->
63-
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
64-
<div
65-
onclick={() => (open = false)}
66-
transition:fadeFast|local
67-
class={'fixed top-0 bottom-0 left-0 right-0 z-[9999]'}
68-
role="dialog"
69-
tabindex="-1"
70-
>
71-
<div
72-
class={twMerge(
73-
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
74-
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
75-
)}
76-
></div>
77-
78-
<div class="fixed inset-0 z-10 overflow-y-auto">
79-
<div class="flex min-h-full items-center justify-center p-4">
80-
<!-- svelte-ignore a11y_no_static_element_interactions -->
80+
<Disposable bind:open bind:this={disposable} preventEscape {minZIndex}>
81+
{#snippet children({ zIndex })}
82+
{#if open}
83+
<!-- svelte-ignore a11y_click_events_have_key_events -->
84+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
85+
<div
86+
onclick={() => (open = false)}
87+
transition:fadeFast|local
88+
class="fixed top-0 bottom-0 left-0 right-0"
89+
style="z-index: {zIndex}"
90+
role="dialog"
91+
tabindex="-1"
92+
>
8193
<div
82-
onclick={stopPropagation(bubble('click'))}
8394
class={twMerge(
84-
'relative transform overflow-hidden rounded-md bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
85-
c,
95+
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
8696
open
87-
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
88-
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
97+
? 'ease-out duration-300 opacity-100'
98+
: 'ease-in duration-200 opacity-0'
8999
)}
90-
{style}
91-
>
92-
{#if kind == 'X'}
93-
<div class="absolute top-4 right-4"><CloseButton on:close={() => (open = false)} /></div
100+
></div>
101+
102+
<div class="fixed inset-0 z-10 overflow-y-auto">
103+
<div class="flex min-h-full items-center justify-center p-4">
104+
<!-- svelte-ignore a11y_no_static_element_interactions -->
105+
<div
106+
onclick={stopPropagation(bubble('click'))}
107+
class={twMerge(
108+
'relative transform overflow-hidden rounded-md bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
109+
c,
110+
open
111+
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
112+
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
113+
)}
114+
{style}
94115
>
95-
{/if}
96-
<div class="flex">
97-
<div class="text-left flex-1">
98-
<div class="flex flex-row items-center justify-between">
99-
<h3 class="text-emphasis text-lg font-semibold">{title}</h3>
100-
{@render settings?.()}
101-
</div>
116+
{#if kind == 'X'}
117+
<div class="absolute top-4 right-4"
118+
><CloseButton on:close={() => (open = false)} /></div
119+
>
120+
{/if}
121+
<div class="flex">
122+
<div class="text-left flex-1">
123+
<div class="flex flex-row items-center justify-between">
124+
<h3 class="text-emphasis text-lg font-semibold">{title}</h3>
125+
{@render settings?.()}
126+
</div>
102127

103-
<div class="mt-4 text-sm text-primary">
104-
{@render children?.()}
128+
<div class="mt-4 text-sm text-primary">
129+
{@render children_render?.()}
130+
</div>
131+
</div>
105132
</div>
133+
{#if kind == 'button'}
134+
<div
135+
class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4"
136+
>
137+
{@render actions?.()}
138+
<Button
139+
on:click={() => {
140+
dispatch('canceled')
141+
open = false
142+
}}
143+
color="light"
144+
size="sm"
145+
>
146+
{cancelText ?? 'Cancel'}
147+
</Button>
148+
</div>
149+
{/if}
106150
</div>
107151
</div>
108-
{#if kind == 'button'}
109-
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
110-
{@render actions?.()}
111-
<Button
112-
on:click={() => {
113-
dispatch('canceled')
114-
open = false
115-
}}
116-
color="light"
117-
size="sm"
118-
>
119-
{cancelText ?? 'Cancel'}
120-
</Button>
121-
</div>
122-
{/if}
123152
</div>
124153
</div>
125-
</div>
126-
</div>
127-
{/if}
154+
{/if}
155+
{/snippet}
156+
</Disposable>

0 commit comments

Comments
 (0)