From 2fc79c85ab5b23d7cd97e699c284ae2f6f99cd58 Mon Sep 17 00:00:00 2001 From: Luke Belton <58511679+luke-belton@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:45:49 +0100 Subject: [PATCH] Make questionnaire cards vertically resizable Adds a drag handle to the top edge of the question permission card so users can shrink it to read more of the transcript above before answering, then grow it back (capped at 80vh). Inner content scrolls within the chosen height. Implemented as an opt-in `resizable` prop on the shared ActionSelector primitive, enabled by QuestionPermission. Generated-By: PostHog Code Task-Id: 6b270274-048e-4383-b258-62703ac84b9a --- .../permissions/QuestionPermission.tsx | 1 + .../action-selector/ActionSelector.tsx | 95 ++++++++++++++++++- .../src/primitives/action-selector/types.ts | 7 ++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/permissions/QuestionPermission.tsx b/packages/ui/src/features/permissions/QuestionPermission.tsx index 2f25065b31..9e304e50f2 100644 --- a/packages/ui/src/features/permissions/QuestionPermission.tsx +++ b/packages/ui/src/features/permissions/QuestionPermission.tsx @@ -309,6 +309,7 @@ export function QuestionPermission({ } multiSelect={isOnSubmitStep ? false : isMultiSelect} hideSubmitButton={isOnSubmitStep} + resizable allowCustomInput={!isOnSubmitStep} customInputPlaceholder="Type your answer..." currentStep={activeStep} diff --git a/packages/ui/src/primitives/action-selector/ActionSelector.tsx b/packages/ui/src/primitives/action-selector/ActionSelector.tsx index 19e8576f96..ffe1d3462f 100644 --- a/packages/ui/src/primitives/action-selector/ActionSelector.tsx +++ b/packages/ui/src/primitives/action-selector/ActionSelector.tsx @@ -1,12 +1,17 @@ import { compactHomePath } from "@posthog/shared"; import { Box, Flex, Text } from "@radix-ui/themes"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { isCancelOption, isSubmitOption } from "./constants"; import { OptionRow } from "./OptionRow"; import { StepTabs } from "./StepTabs"; import type { ActionSelectorProps } from "./types"; import { useActionSelectorState } from "./useActionSelectorState"; +// Floor keeps the options and submit row visible even at the smallest size; +// ceiling matches the card's default `max-h-[80vh]` cap. +const MIN_CARD_HEIGHT = 160; +const MAX_CARD_HEIGHT_FRACTION = 0.8; + export function ActionSelector({ title, pendingAction, @@ -20,6 +25,7 @@ export function ActionSelector({ initialSelections, initialCustomInput, hideSubmitButton = false, + resizable = false, onSelect, onMultiSelect, onCancel, @@ -76,6 +82,68 @@ export function ActionSelector({ onCancel?.(); }, [onCancel]); + // User-chosen height in px once the card has been dragged; null means the + // card sizes naturally under its `max-h-[80vh]` cap. + const [cardHeight, setCardHeight] = useState(null); + const [isResizing, setIsResizing] = useState(false); + const resizeStartRef = useRef({ y: 0, height: 0 }); + + const handleResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + const container = containerRef.current; + if (!container) return; + e.preventDefault(); + resizeStartRef.current = { + y: e.clientY, + height: container.getBoundingClientRect().height, + }; + setIsResizing(true); + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + }, + [containerRef], + ); + + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + const { y, height } = resizeStartRef.current; + // Dragging up (clientY decreases) grows the card; down shrinks it, + // revealing more of the transcript above. + const next = height + (y - e.clientY); + const max = window.innerHeight * MAX_CARD_HEIGHT_FRACTION; + setCardHeight(Math.max(MIN_CARD_HEIGHT, Math.min(max, next))); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizing]); + + // If the card unmounts mid-drag no mouseup fires — clear the global cursor + // and text-selection lock so the app isn't left stuck. + const isResizingRef = useRef(isResizing); + isResizingRef.current = isResizing; + useEffect( + () => () => { + if (isResizingRef.current) { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + }, + [], + ); + const handlersRef = useRef({ moveUp, moveDown, @@ -226,9 +294,32 @@ export function ActionSelector({ }} style={{ outline: "none", + ...(resizable && cardHeight !== null ? { height: cardHeight } : {}), }} - className="flex max-h-[80vh] flex-col rounded-(--radius-3) border border-(--gray-6) bg-(--gray-1)" + className="relative flex max-h-[80vh] flex-col rounded-(--radius-3) border border-(--gray-6) bg-(--gray-1)" > + {resizable && ( + // Drag handle riding the top edge — the card is anchored to the bottom + // of the chat, so dragging up grows it and dragging down shrinks it. + + + + )} + {isResizing && ( + // Keeps the row-resize cursor while the pointer crosses content that + // sets its own cursor. + + )} {hasSteps && steps && ( void; onMultiSelect?: (optionIds: string[], customInput?: string) => void; onCancel?: () => void;