Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions js-packages/web-console/src/lib/components/layout/Drawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,42 @@

import OverlayDrawer from '$lib/components/layout/OverlayDrawer.svelte'
import { useIsTablet } from '$lib/compositions/layout/useIsMobile.svelte'
import type { Percent } from '$lib/functions/common/percent'
import type { Snippet } from '$lib/types/svelte'

const isTablet = useIsTablet()

let {
open = $bindable(),
side,
main,
children,
width,
width = 'w-[700px]',
onClose,
inlineClass = ''
inlineClass = '',
defaultSize = '30%',
minSize = '20%',
maxSize = '80%',
localStorageKey
}: {
open: boolean
side: 'right' | 'left'
/**
* Always-visible content. On wide screens the drawer resizes against it
* via a draggable divider; on tablet/mobile the drawer overlays it.
*/
main: Snippet
/** Drawer content. */
children: Snippet
width: string
/** Overlay-mode drawer width on tablet/mobile, e.g. `w-[700px]`. */
width?: string
inlineClass?: string
/** Inline drawer pane size on open, as a percentage of the container. */
defaultSize?: Percent
minSize?: Percent
maxSize?: Percent
/** localStorage key to persist the resized inline layout. */
localStorageKey?: string
/**
* Called when the drawer requests dismissal (currently fired by the
* modal-backdrop click in the tablet/mobile overlay variant).
Expand All @@ -28,8 +47,8 @@
} = $props()
</script>

<!-- {#if isTablet.matches} -->
{#if isTablet.current}
{@render main()}
<OverlayDrawer
{width}
bind:open
Expand All @@ -40,5 +59,15 @@
class="bg-white-dark p-4"
></OverlayDrawer>
{:else}
<InlineDrawer {width} {open} {side} {children} class="bg-white-dark {inlineClass}"></InlineDrawer>
<InlineDrawer
{open}
{side}
{main}
{children}
{defaultSize}
{minSize}
{maxSize}
{localStorageKey}
class="bg-white-dark {inlineClass}"
></InlineDrawer>
{/if}
Original file line number Diff line number Diff line change
@@ -1,38 +1,75 @@
<script lang="ts">
import { Pane, PaneGroup, PaneResizer } from 'paneforge'
import { type Percent, percentValue } from '$lib/functions/common/percent'
import type { Snippet } from '$lib/types/svelte'

const {
side,
open,
main,
children,
width,
defaultSize = '33%',
minSize = '20%',
maxSize = '80%',
localStorageKey,
class: _class = ''
}: {
/** Drawer open state */
open: boolean
/** Whether the drawer opens on the left or right of the container */
side: 'right' | 'left'
/** Always-visible content the drawer resizes against. */
main: Snippet
/** Drawer content, shown next to `main` when `open` is true. */
children: Snippet
width: string
/** Drawer pane size on open, as a percentage of the container width. */
defaultSize?: Percent
/** Smallest the drawer pane can be dragged, as a percentage of the container width. */
minSize?: Percent
/** Largest the drawer pane can be dragged, as a percentage of the container width. */
maxSize?: Percent
/** localStorage key to persist the resized layout across sessions. */
localStorageKey?: string
/** Classes of the drawer body wrapper */
class?: string
} = $props()

const positionClass = {
right: 'left-0',
left: 'right-0 '
}

const shadowClass = {
right: 'shadow-[-1px_0px_4px_0px_rgba(0,0,0,0.1)] [clip-path:inset(0_0_0_-5px)] pl-4 ml-4',
left: 'shadow-[1px_0px_-4px_0px_rgba(0,0,0,0.1)] [clip-path:inset(0_5px_0_0)] pr-4 mr-4'
}
const mainOrder = $derived(side === 'right' ? 1 : 2)
const drawerOrder = $derived(side === 'right' ? 2 : 1)
const mainMinSize = $derived(100 - percentValue(maxSize))
</script>

<div class="{open ? shadowClass[side] : ''} overflow-clip">
<div
class={' relative h-full transition-[width] duration-300 ease-in-out ' +
(open ? width : 'w-0 ')}
{#snippet mainPane()}
<Pane order={mainOrder} minSize={mainMinSize} class="!overflow-visible">
{@render main()}
</Pane>
{/snippet}
{#snippet drawerPane()}
<Pane
order={drawerOrder}
defaultSize={percentValue(defaultSize)}
minSize={percentValue(minSize)}
maxSize={percentValue(maxSize)}
class="!overflow-visible"
>
<div class=" absolute h-full {width} {positionClass[side]} {_class}">
<div class="h-full {_class}">
{@render children()}
</div>
</div>
</div>
</Pane>
{/snippet}

<PaneGroup
direction="horizontal"
class="h-full min-w-0 flex-1 !overflow-visible"
autoSaveId={localStorageKey}
>
{#if open && side === 'left'}
{@render drawerPane()}
<PaneResizer class="pane-divider-vertical mx-2" />
{/if}
{@render mainPane()}
{#if open && side === 'right'}
<PaneResizer class="pane-divider-vertical mx-2" />
{@render drawerPane()}
{/if}
</PaneGroup>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Component tests for the resizable inline drawer. The drawer lays its
* always-visible `main` content next to a resizable `children` pane using
* PaneForge; a draggable separator (`[data-pane-resizer]`) appears only while
* the drawer is open. These tests run irrespective of viewport, since
* `InlineDrawer` is the wide-screen variant selected by `Drawer`.
*/

import { createRawSnippet } from 'svelte'
import { describe, expect, it } from 'vitest'
import { page } from 'vitest/browser'
import { render } from 'vitest-browser-svelte'
import InlineDrawer from './InlineDrawer.svelte'

const textSnippet = (text: string) =>
createRawSnippet(() => ({ render: () => `<div>${text}</div>` }))

const renderDrawer = (props: { open: boolean; side: 'right' | 'left' }) =>
render(InlineDrawer, {
...props,
main: textSnippet('MAIN CONTENT'),
children: textSnippet('DRAWER CONTENT')
})

describe('InlineDrawer', () => {
it('shows only the main content and no resize handle when closed', async () => {
const { container } = renderDrawer({ open: false, side: 'right' })

await expect.element(page.getByText('MAIN CONTENT')).toBeInTheDocument()
// Closed: the main pane fills the container, so there is nothing to drag.
expect(container.querySelector('[data-pane-resizer]')).toBeNull()
expect(container.textContent).not.toContain('DRAWER CONTENT')
})

it('renders the drawer content and a draggable resize handle when open', async () => {
const { container } = renderDrawer({ open: true, side: 'right' })

await expect.element(page.getByText('DRAWER CONTENT')).toBeInTheDocument()
await expect.element(page.getByText('MAIN CONTENT')).toBeInTheDocument()
// The separator is what makes the drawer resizable — the crux of the feature.
// Remove the `{#if open}` guard around the resizer to see this assertion fail.
expect(container.querySelector('[data-pane-resizer]')).not.toBeNull()
})

it('places the drawer before the main content when anchored to the left', async () => {
const { container } = renderDrawer({ open: true, side: 'left' })

await expect.element(page.getByText('DRAWER CONTENT')).toBeInTheDocument()
// Declared `order` keeps the drawer pane first regardless of render order.
const panes = [...container.querySelectorAll('[data-pane]')]
expect(panes[0]?.textContent).toContain('DRAWER CONTENT')
expect(panes[1]?.textContent).toContain('MAIN CONTENT')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@
<div
role="dialog"
aria-modal="true"
class={'flex h-full flex-col' + (open ? ' shadow-xl ' : ' ') + `${width} ` + _class}
class={'flex h-full max-w-full flex-col' +
(open ? ' shadow-xl ' : ' ') +
`${width} ` +
_class}
>
{@render children?.()}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Component tests for the overlay drawer used on tablet/mobile. The drawer has
* a nominal width (e.g. `w-[700px]`) but must never grow wider than the screen,
* otherwise its contents scroll the page horizontally. These tests run at the
* browser's default narrow viewport, where the nominal width exceeds the screen.
*/

import { createRawSnippet } from 'svelte'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-svelte'
import OverlayDrawer from './OverlayDrawer.svelte'

const textSnippet = (text: string) =>
createRawSnippet(() => ({ render: () => `<div>${text}</div>` }))

const renderOverlay = () =>
render(OverlayDrawer, {
open: true,
side: 'right',
modal: true,
width: 'w-[700px]',
class: 'p-4',
children: textSnippet('DRAWER CONTENT')
})

describe('OverlayDrawer', () => {
it('caps the drawer at the viewport width on narrow screens', async () => {
// Precondition: the nominal width is wider than the screen, so an
// uncapped drawer would overflow. Guard against a wide test viewport.
expect(window.innerWidth).toBeLessThan(700)

const { container } = renderOverlay()
const dialog = container.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()

// The dialog shrinks to fit instead of forcing 700px.
// (Drop `max-w-full` from the dialog class to see this fail.)
expect(dialog.getBoundingClientRect().width).toBeLessThanOrEqual(window.innerWidth)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -345,15 +345,23 @@
{/if}

<Pane class="!overflow-visible">
<PaneGroup direction="vertical" class="!overflow-visible">
<PaneGroup
direction="vertical"
class="!overflow-visible"
autoSaveId="layout/pipelines/monitoringPanel/size"
>
{#if pipelineBannerMessage}
<div class="pb-2 md:pb-4">
<PipelineBanner {...pipelineBannerMessage}></PipelineBanner>
</div>
{/if}

<Pane defaultSize={60} minSize={15} class="!overflow-visible">
<PaneGroup direction="horizontal" class="!overflow-visible">
<PaneGroup
direction="horizontal"
class="!overflow-visible"
autoSaveId="layout/pipelines/interactionPanel/size"
>
<Pane minSize={30} class="!overflow-visible">
{#if pipeline.current}
<PipelineCodePanel
Expand Down
Loading
Loading