fix(frontend): graceful small-screen timeframe picker on the runs page (#10073)

* fix(frontend): prevent runs timeframe calendar popover overflow on small screens

The Runs page timeframe picker rendered its popover as a wide 3-column row
(preset list + two side-by-side calendars). With the right-aligned trigger and
a center-anchored `bottom` placement, the popup ran off the right edge on
narrow viewports.

Anchor the popover to the right edge (`placement="bottom-end"`) and make its
content reflow to a vertical stack below the `sm` breakpoint, capped at
`max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto` so it can never exceed the
viewport. The desktop side-by-side layout is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): compact runs timeframe picker with a Start/End toggle on small screens

The two-calendar desktop popover needs ~780px (two min-w-9 grids + presets +
popover padding); below that it overflows. Under 800px, show a single calendar
with a Start/End toggle picking which bound it edits, using set-start/set-end so
each bound keeps its date and HH:MM time inputs — the same precision the desktop
start/end pair offers.

On short/landscape viewports the compact panel is scroll-contained within the
popover's fitViewport height (contentClasses overflow-y-auto, scoped to the
small layout) so its lower controls stay reachable. The desktop two-calendar
layout is unchanged.

Presets are shared between both layouts via a snippet, and the active range is
preserved across the breakpoint since both branches drive the same value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): let InlineCalendarInput month/year selects portal, use in compact timeframe picker

Add an opt-in `portalSelects` prop to InlineCalendarInput that portals the
month/year dropdowns to the body (default keeps them in-flow, so existing
consumers are unchanged). The compact runs timeframe picker enables it so the
dropdowns escape its scroll-contained (overflow-y-auto) popover instead of
being clipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-07-15 17:55:21 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2fe999f66c
commit af177cefe0
2 changed files with 100 additions and 38 deletions
@@ -63,9 +63,18 @@
type Props = (DateProps | RangeProps) & {
class?: string
// Portal the month/year dropdowns to the body so they escape an `overflow`
// ancestor (e.g. a scroll-contained popover). Off by default: in-flow.
portalSelects?: boolean
}
let { mode = 'date', value = $bindable(), class: className, ...rest }: Props = $props()
let {
mode = 'date',
value = $bindable(),
class: className,
portalSelects = false,
...rest
}: Props = $props()
const onClickBehavior = $derived(
mode === 'range' ? ((rest as RangeProps).onClickBehavior ?? 'set-range') : 'set-range'
@@ -425,14 +434,14 @@
<Select
class="basis-1/2"
inputClass="text-center !rounded-r-none !border-r-0"
disablePortal
disablePortal={!portalSelects}
bind:value={viewMonth}
items={MONTH_NAMES.map((name, i) => ({ label: name, value: i + 1 }))}
/>
<Select
class="basis-1/2"
inputClass="text-center !rounded-l-none !border-l-0"
disablePortal
disablePortal={!portalSelects}
bind:value={viewYear}
onCreateItem={(val) => (viewYear = parseInt(val) || viewYear)}
items={YEAR_LIST.map((year) => ({ label: year.toString(), value: year }))}
@@ -78,6 +78,8 @@
<script lang="ts">
import { CalendarIcon, RefreshCw } from 'lucide-svelte'
import { Button } from '../common'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import Popover from '../meltComponents/Popover.svelte'
import { watch } from 'runed'
import { page } from '$app/state'
@@ -98,6 +100,14 @@
let isOpen = $state(false)
// The two-calendar desktop popover needs ~780px (two min-w-9 grids + presets +
// popover padding); below that it overflows. Under 800px use a single calendar
// with a Start/End toggle, which keeps each bound's date + time inputs.
const TWO_CALENDAR_MIN_WIDTH = 800
let innerWidth = $state<number | undefined>(undefined)
const isSmall = $derived(innerWidth != undefined && innerWidth < TWO_CALENDAR_MIN_WIDTH)
let smallBound = $state<'start' | 'end'>('start')
function onManualInput(input: { minTs?: string | null; maxTs?: string | null }) {
if (value.type !== 'manual')
value = buildManualTimeframe(input.minTs ?? null, input.maxTs ?? null)
@@ -113,6 +123,22 @@
}
</script>
<svelte:window bind:innerWidth />
{#snippet presetButtons()}
{#each items as item (item.label)}
<Button
onClick={() => (value = { ...item })}
variant="subtle"
unifiedSize="md"
selected={value.label === item.label}
btnClasses="justify-start text-nowrap"
>
{item.label}
</Button>
{/each}
{/snippet}
<div class="relative flex {wrapperClasses}">
<Button
unifiedSize="md"
@@ -134,7 +160,12 @@
Reset
</Button>
{/if}
<Popover enableFlyTransition bind:isOpen>
<Popover
enableFlyTransition
placement="bottom-end"
contentClasses={isSmall ? 'overflow-y-auto' : ''}
bind:isOpen
>
{#snippet trigger()}
<Button
unifiedSize="md"
@@ -152,41 +183,63 @@
value.type === 'manual' && value.minTs ? new Date(value.minTs) : undefined
)
}}
<div class="flex divide-x">
<div class="flex flex-col p-2">
{#each items as item}
<Button
onClick={() => (value = { ...item })}
variant="subtle"
unifiedSize="md"
selected={value.label === item.label}
btnClasses="justify-start text-nowrap"
>
{item.label}
</Button>
{/each}
{#if isSmall}
<div class="flex flex-col divide-y max-w-[calc(100vw-2rem)]">
<div class="flex flex-row flex-wrap gap-1 p-2">
{@render presetButtons()}
</div>
<div class="flex flex-col gap-2 p-2">
<ToggleButtonGroup bind:selected={smallBound} class="w-full">
{#snippet children({ item })}
<ToggleButton value="start" label="Start" {item} />
<ToggleButton value="end" label="End" {item} />
{/snippet}
</ToggleButtonGroup>
<InlineCalendarInput
class="w-full"
infiniteRange
portalSelects
mode="range"
onClickBehavior={smallBound === 'end' ? 'set-end' : 'set-start'}
bind:value={
() => range,
(v) =>
onManualInput(
smallBound === 'end'
? { maxTs: fromCalendarDate(v.end)?.toISOString() ?? null }
: { minTs: fromCalendarDate(v.start)?.toISOString() ?? null }
)
}
/>
</div>
</div>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-start"
bind:value={
() => range,
(v) => onManualInput({ minTs: fromCalendarDate(v.start)?.toISOString() ?? null })
}
/>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-end"
bind:value={
() => range,
(v) => onManualInput({ maxTs: fromCalendarDate(v.end)?.toISOString() ?? null })
}
/>
</div>
{:else}
<div class="flex divide-x">
<div class="flex flex-col p-2">
{@render presetButtons()}
</div>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-start"
bind:value={
() => range,
(v) => onManualInput({ minTs: fromCalendarDate(v.start)?.toISOString() ?? null })
}
/>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-end"
bind:value={
() => range,
(v) => onManualInput({ maxTs: fromCalendarDate(v.end)?.toISOString() ?? null })
}
/>
</div>
{/if}
{/snippet}
</Popover>
</div>