Files
ottyl/webapp/src/components/CollapsibleSection.tsx
T
Hugh KaznowskiandClaude Sonnet 5 4cc9205abf Move TimeRange types to lib/timeRange to break import cycle
CollapsibleSection.tsx and LogTimeline.tsx imported from each other
(TimeRange/TimeRangePresetId one way, sectionHeaderClassName the
other). Move the range types/helpers into a dependency-free
webapp/src/lib/timeRange.ts and repoint all importers there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:35:05 +01:00

138 lines
6.1 KiB
TypeScript

import { useState } from 'react'
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
/// Shared look for a primary section's title bar: shaded so it reads as a
/// distinct band rather than blending into the section body below it.
/// The bar itself is the collapse/expand click target (see `sectionHeaderInteractiveClassName`) —
/// this className alone (no click handling) is for the rare header that stays static.
export const sectionHeaderClassName =
'flex items-center justify-between gap-2 border-b border-border-soft bg-surface-1 px-3 py-1.5'
/// `sectionHeaderClassName` plus the affordance for a header row that toggles collapse on click:
/// pointer cursor and a hover tint so the whole bar reads as clickable, not just the chevron.
export const sectionHeaderInteractiveClassName = `${sectionHeaderClassName} cursor-pointer select-none hover:bg-surface-2`
/// Left-hand cluster of a section header: title text, help icon, collapse/pop-out controls.
export const sectionHeaderLeftClassName = 'flex items-center gap-1.5 text-sm font-medium text-text-secondary'
/// localStorage key holding a record of section id -> collapsed, mirroring LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern.
const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections'
function loadCollapsedSections(): Record<string, boolean> {
try {
const raw = localStorage.getItem(COLLAPSED_SECTIONS_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function saveCollapsedSections(sections: Record<string, boolean>) {
try {
localStorage.setItem(COLLAPSED_SECTIONS_STORAGE_KEY, JSON.stringify(sections))
} catch {
// best-effort — a private browsing mode or full storage just means the
// collapsed state won't persist across reloads.
}
}
/// Returns only the boolean + setter, not a conditional-render helper — callers keep rendering their subtree and hide it with CSS (`hidden`), so collapsing never unmounts in-progress form state.
export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] {
const [collapsed, setCollapsedState] = useState(() => loadCollapsedSections()[sectionId] ?? false)
function setCollapsed(next: boolean) {
setCollapsedState(next)
const all = loadCollapsedSections()
all[sectionId] = next
saveCollapsedSections(all)
}
return [collapsed, setCollapsed]
}
/// Chevron indicator for a section's collapsed/expanded state. For the title-bar sections
/// (Alerts/Metrics/Params/Dashboards), the whole header row is the click target (see
/// `sectionHeaderInteractiveClassName`) and `onToggle` is omitted — this renders as a
/// non-interactive, decorative `span` rather than a nested `<button>`, with `aria-expanded`
/// living on the header row itself. `onToggle` stays available for the log viewer's toggle
/// (`App.tsx`), which sits in a filter toolbar rather than a plain title bar — there the
/// chevron itself remains a dedicated button.
export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; onToggle?: () => void }) {
if (!onToggle) {
return (
<span
aria-hidden="true"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border-soft bg-surface-2 text-xs leading-none text-text-muted"
>
{collapsed ? '▸' : '▾'}
</span>
)
}
return (
<button
type="button"
onClick={onToggle}
title={collapsed ? 'Expand section' : 'Collapse section'}
aria-label={collapsed ? 'Expand section' : 'Collapse section'}
aria-expanded={!collapsed}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border-soft bg-surface-2 text-xs leading-none text-text-muted hover:border-border-strong hover:text-text-secondary"
>
{collapsed ? '▸' : '▾'}
</button>
)
}
/// Keyboard handler for a header row using `sectionHeaderInteractiveClassName`: Enter/Space
/// activate it like a button, matching the `role="button"` semantics on the row.
export function handleSectionHeaderKeyDown(onToggle: () => void) {
return (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
}
/// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads.
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
/// A rolling preset (anything but a manually dragged/fixed range) is carried over by id so the popped-out tab can resolve and keep re-resolving its own independent window of the same size, instead of freezing whatever absolute bounds happened to be active at click time. A fixed/dragged range has no such id, so it's carried over as absolute bounds, same as before.
function popOutUrl(section: PopOutSectionId, options?: { range?: TimeRange | null; presetId?: TimeRangePresetId | null }): string {
const query = new URLSearchParams({ section })
if (options?.presetId != null) {
query.set('preset', options.presetId)
} else if (options?.range) {
query.set('startMs', String(options.range.startMs))
query.set('endMs', String(options.range.endMs))
}
return `?${query.toString()}`
}
/// `range`/`activePreset` are only needed for sections without their own LogTimeline — the popped-out page can't pick a range itself until it mounts, so the current one is carried over via query params.
export function PopOutButton({
section,
range,
activePreset,
}: {
section: PopOutSectionId
range?: TimeRange | null
activePreset?: TimeRangePresetId | null
}) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
window.open(popOutUrl(section, { range, presetId: activePreset }), '_blank')
}}
title="Open in new tab"
aria-label="Open in new tab"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border-soft bg-surface-2 text-xs leading-none text-text-muted hover:border-border-strong hover:text-text-secondary"
>
↗
</button>
)
}