From c163acf45ced9118703ab048ab81435c19b27e99 Mon Sep 17 00:00:00 2001 From: Artem Kyrychenko Date: Mon, 2 Nov 2026 16:35:58 +0200 Subject: [PATCH 1/6] feat(ui): add ResizeHandle and useResizableDetail hook --- src/ui/ResizeHandle.css | 23 ++++++++ src/ui/ResizeHandle.tsx | 52 +++++++++++++++++ src/ui/use-resizable-detail.test.ts | 24 ++++++++ src/ui/use-resizable-detail.ts | 86 +++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 src/ui/ResizeHandle.css create mode 100644 src/ui/ResizeHandle.tsx create mode 100644 src/ui/use-resizable-detail.test.ts create mode 100644 src/ui/use-resizable-detail.ts diff --git a/src/ui/ResizeHandle.css b/src/ui/ResizeHandle.css new file mode 100644 index 0000000..2779876 --- /dev/null +++ b/src/ui/ResizeHandle.css @@ -0,0 +1,23 @@ +[data-solid-resize-handle] { + position: absolute; + top: 0; + bottom: 0; + left: -4px; + width: 8px; + z-index: 2; + + cursor: ew-resize; + touch-action: none; +} + +[data-solid-resize-handle]:hover, +[data-solid-resize-handle]:active, +[data-solid-resize-handle][data-dragging] { + background: linear-gradient( + to right, + transparent 3px, + var(--start-dt-accent) 3px, + var(--start-dt-accent) 5px, + transparent 5px + ); +} diff --git a/src/ui/ResizeHandle.tsx b/src/ui/ResizeHandle.tsx new file mode 100644 index 0000000..9ab5cd3 --- /dev/null +++ b/src/ui/ResizeHandle.tsx @@ -0,0 +1,52 @@ +import type { JSX } from '@solidjs/web'; +import { createSignal } from 'solid-js'; +import './ResizeHandle.css'; + +export interface ResizeHandleProps { + onResize: (deltaX: number) => void; + onResizeEnd?: () => void; + onReset?: () => void; + title?: string; +} + +export function ResizeHandle(props: ResizeHandleProps): JSX.Element { + const [dragging, setDragging] = createSignal(false); + + function onPointerDown(event: PointerEvent): void { + if (event.button !== 0) return; + event.preventDefault(); + setDragging(true); + + let startX = event.clientX; + + const onPointerMove = (next: PointerEvent): void => { + const deltaX = startX - next.clientX; + startX = next.clientX; + props.onResize(deltaX); + }; + + const onPointerUp = (): void => { + setDragging(false); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + props.onResizeEnd?.(); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerUp); + } + + return ( +
props.onReset?.()} + /> + ); +} diff --git a/src/ui/use-resizable-detail.test.ts b/src/ui/use-resizable-detail.test.ts new file mode 100644 index 0000000..823454e --- /dev/null +++ b/src/ui/use-resizable-detail.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + clampDetailWidth, + DEFAULT_DETAIL_WIDTH, + MIN_DETAIL_WIDTH, +} from './use-resizable-detail.js'; + +describe('clampDetailWidth', () => { + it('keeps width within standard bounds', () => { + expect(clampDetailWidth(300, 200, 600)).toBe(300); + expect(clampDetailWidth(150, 200, 600)).toBe(200); + expect(clampDetailWidth(750, 200, 600)).toBe(600); + }); + + it('rounds non-integer widths', () => { + expect(clampDetailWidth(300.4, 200, 600)).toBe(300); + expect(clampDetailWidth(300.6, 200, 600)).toBe(301); + }); + + it('falls back to default min width', () => { + expect(clampDetailWidth(100)).toBe(MIN_DETAIL_WIDTH); + expect(DEFAULT_DETAIL_WIDTH).toBeGreaterThanOrEqual(MIN_DETAIL_WIDTH); + }); +}); diff --git a/src/ui/use-resizable-detail.ts b/src/ui/use-resizable-detail.ts new file mode 100644 index 0000000..2f674a7 --- /dev/null +++ b/src/ui/use-resizable-detail.ts @@ -0,0 +1,86 @@ +import { createSignal, onSettled } from 'solid-js'; + +const STORAGE_KEY = 'solid-start-devtools:detail-width'; +export const DEFAULT_DETAIL_WIDTH = 320; +export const MIN_DETAIL_WIDTH = 240; + +function readSavedWidth(key: string): number | undefined { + try { + const raw = localStorage.getItem(key); + if (!raw) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function writeSavedWidth(key: string, width: number): void { + try { + localStorage.setItem(key, String(width)); + } catch { + // Storage can be blocked. + } +} + +export function clampDetailWidth( + width: number, + min = MIN_DETAIL_WIDTH, + max = typeof window !== 'undefined' ? window.innerWidth * 0.7 : 800, +): number { + return Math.round(Math.min(Math.max(width, min), max)); +} + +export interface UseResizableDetailOptions { + storageKey?: string; + defaultWidth?: number; + minWidth?: number; +} + +export interface ResizableDetail { + width: () => number; + setWidth: (width: number) => void; + onResizeDelta: (deltaX: number) => void; + onResizeEnd: () => void; + reset: () => void; +} + +export function useResizableDetail(options: UseResizableDetailOptions = {}): ResizableDetail { + const key = options.storageKey ?? STORAGE_KEY; + const initial = options.defaultWidth ?? DEFAULT_DETAIL_WIDTH; + const min = options.minWidth ?? MIN_DETAIL_WIDTH; + + const [width, setWidthState] = createSignal(initial); + + onSettled(() => { + const saved = readSavedWidth(key); + if (typeof saved === 'number') { + setWidthState(clampDetailWidth(saved, min)); + } + }); + + function setWidth(next: number): void { + setWidthState(clampDetailWidth(next, min)); + } + + function onResizeDelta(deltaX: number): void { + setWidthState((current) => clampDetailWidth(current + deltaX, min)); + } + + function onResizeEnd(): void { + writeSavedWidth(key, width()); + } + + function reset(): void { + setWidthState(initial); + writeSavedWidth(key, initial); + } + + return { + width, + setWidth, + onResizeDelta, + onResizeEnd, + reset, + }; +} From d7b479b2cadde4de40ad5bfaebbde8ea81dc0ae0 Mon Sep 17 00:00:00 2001 From: Artem Kyrychenko Date: Mon, 2 Nov 2026 16:39:52 +0200 Subject: [PATCH 2/6] feat(ownership): make component detail panel resizable --- src/dev-toolbar/ownership/index.tsx | 10 +++++++++- src/dev-toolbar/ownership/styles.css | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx index c043692..fc8fe93 100644 --- a/src/dev-toolbar/ownership/index.tsx +++ b/src/dev-toolbar/ownership/index.tsx @@ -3,7 +3,9 @@ import { createEffect, createMemo, createSignal, For, getOwner, Show } from 'sol import { Badge } from '../../ui/Badge.js'; import IconButton from '../../ui/IconButton.js'; import Placeholder from '../../ui/Placeholder.js'; +import { ResizeHandle } from '../../ui/ResizeHandle.js'; import { Text } from '../../ui/Text.js'; +import { useResizableDetail } from '../../ui/use-resizable-detail.js'; import { CollapseIcon, ExpandIcon, EyeIcon, PauseIcon, PlayIcon } from '../icons.js'; import { openInEditor } from '../open-in-editor.js'; import { CopyReportButton } from '../report/CopyReportButton.js'; @@ -64,6 +66,7 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen const [query, setQuery] = createSignal(''); const [collapsed, setCollapsed] = createSignal([]); const [selected, setSelected] = createSignal(); + const detail = useResizableDetail(); const firstSeen = new Map(); let appliedFocus: { owner: object } | undefined; @@ -334,7 +337,12 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen {(node) => ( -