From f2fc3d9fad85d0978d55f46ee9a646014adebf72 Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Sat, 16 May 2026 13:39:58 -0400 Subject: [PATCH] feat(mobile): make the builder fully responsive Adds a mobile breakpoint (`useIsMobile` hook, matching Tailwind's `md`) and reworks the UI so the builder is usable on phone-sized viewports: - Palette: persistent left aside on desktop, bottom-sheet on mobile (triggered from a new "Blocks" button in the toolbar). Tap-to-add closes the sheet so the user sees the new row land in the surface. - Toolbar: wraps when narrow, collapses surface/theme labels behind icons on small screens, and grows aria-labels so the icon-only buttons stay accessible. - DnD: adds TouchSensor (150ms press-and-hold so the surface can still scroll) and KeyboardSensor for non-pointer reordering. - Surface, block row, sheet, dialogs: misc spacing and overflow fixes so nothing escapes the viewport. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/block-kitchen.tsx | 67 ++++++++++++++++++--- src/components/block-row.tsx | 69 +++++++++++++++++++-- src/components/issues-sheet.tsx | 2 +- src/components/json-drawer.tsx | 4 +- src/components/palette.tsx | 96 +++++++++++++++++++++++++----- src/components/send-dialog.tsx | 2 +- src/components/surface.tsx | 49 ++++++++++++--- src/components/template-picker.tsx | 17 +++--- src/components/toolbar.tsx | 43 +++++++++---- src/lib/ui/sheet.tsx | 70 +++++++++++++++------- src/lib/use-is-mobile.ts | 33 ++++++++++ 11 files changed, 372 insertions(+), 80 deletions(-) create mode 100644 src/lib/use-is-mobile.ts diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index ba4532c..2cdc9a8 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -5,15 +5,20 @@ import { type DragEndEvent, DragOverlay, type DragStartEvent, + KeyboardSensor, PointerSensor, pointerWithin, + TouchSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { GripVertical } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { buildVariantById, defaultPalette } from '../lib/default-blocks'; +import { Sheet, SheetContent, SheetDescription, SheetTitle } from '../lib/ui/sheet'; import { TooltipProvider } from '../lib/ui/tooltip'; +import { useIsMobile } from '../lib/use-is-mobile'; import { useBlockKitValidation } from '../state/use-block-kit-validation'; import { useBlockKitchenState } from '../state/use-block-kitchen-state'; import type { BlockKitchenProps, PreviewSurface, PreviewTheme } from '../types'; @@ -69,18 +74,28 @@ export function BlockKitchen(props: BlockKitchenProps) { const [jsonOpen, setJsonOpen] = useState(false); const [sendOpen, setSendOpen] = useState(false); const [issuesOpen, setIssuesOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); const [openBlockId, setOpenBlockId] = useState(null); const [previewTheme, setPreviewTheme] = useState(defaultPreviewTheme); const [previewSurface, setPreviewSurface] = useState(allowedSurfaces[0]); const [activePaletteVariantId, setActivePaletteVariantId] = useState(null); + const isMobile = useIsMobile(); + // Always validate against the `message` surface: that's where Send posts // to. If we scoped validation to the preview surface, a user could switch // to `modal`, drop in modal-only blocks, see `errorCount === 0`, and have // Send accept a payload Slack will reject. const validation = useBlockKitValidation(blocks, 'message'); - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + // Touch needs a 150ms press-and-hold to start a drag so scrolling the + // surface doesn't accidentally pick up a block. Pointer keeps the small + // 4px distance threshold so mouse clicks still open the editor cleanly. + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); // Pick the block directly under the cursor when possible so the drop // target tracks the cursor rather than whichever droppable's geometric @@ -151,12 +166,13 @@ export function BlockKitchen(props: BlockKitchenProps) { onDragEnd={handleDragEnd} onDragCancel={handleDragCancel} > -
+
replaceAll([])} onOpenJson={() => setJsonOpen(true)} onOpenIssues={() => setIssuesOpen(true)} onOpenSend={() => setSendOpen(true)} + onOpenPalette={() => setPaletteOpen(true)} canSend={blocks.length > 0} canClear={blocks.length > 0} errorCount={validation.total} @@ -169,13 +185,17 @@ export function BlockKitchen(props: BlockKitchenProps) { docsLink={docsLink} />
- addBlock(block)} - sections={paletteSections} - showSearch={showPaletteSearch} - searchPlaceholder={paletteSearchPlaceholder} - defaultOpenSections={defaultOpenSections} - /> + {/* Desktop: persistent left aside. Mobile: collapsed to the + palette sheet trigger in the toolbar. */} +
+ addBlock(block)} + sections={paletteSections} + showSearch={showPaletteSearch} + searchPlaceholder={paletteSearchPlaceholder} + defaultOpenSections={defaultOpenSections} + /> +
setPaletteOpen(true)} />
@@ -201,6 +222,34 @@ export function BlockKitchen(props: BlockKitchenProps) {
) : null} + {/* Mobile-only palette sheet. The desktop aside above stays put; + this opens from the bottom on a tap of the toolbar's Blocks + button. Tap-to-add closes the sheet so the user sees the new + row land in the surface. */} + + +
+ Add a block + Tap a block to add it to the bottom of your draft. +
+
+ { + addBlock(block); + setPaletteOpen(false); + }} + sections={paletteSections} + showSearch={showPaletteSearch} + searchPlaceholder={paletteSearchPlaceholder} + defaultOpenSections={defaultOpenSections} + variant="sheet" + /> +
+
+
; return ( -
+
{showDropIndicator ? ( + {/* Mobile-only inline action bar. Lives in the flow under the block + preview so it never overlaps content (the floating desktop bar + gets in its own way when every row shows it). Type label sits on + the left, action triplet on the right. */} +
+ + {BLOCK_TYPE_LABELS[builderBlock.block.type]} + +
+ {hasErrors ? ( + + ) : null} + + + +
+
); } diff --git a/src/components/issues-sheet.tsx b/src/components/issues-sheet.tsx index 9c1fc11..d66f564 100644 --- a/src/components/issues-sheet.tsx +++ b/src/components/issues-sheet.tsx @@ -46,7 +46,7 @@ export function IssuesSheet({ return ( - +
{validation.total === 0 ? ( diff --git a/src/components/json-drawer.tsx b/src/components/json-drawer.tsx index df0d3c6..a90a81c 100644 --- a/src/components/json-drawer.tsx +++ b/src/components/json-drawer.tsx @@ -110,8 +110,8 @@ export function JsonDrawer({ return ( - -
+ +
Block Kit JSON Edits update the preview as you type. Parse errors show below.
diff --git a/src/components/palette.tsx b/src/components/palette.tsx index 620c357..eabf92b 100644 --- a/src/components/palette.tsx +++ b/src/components/palette.tsx @@ -1,5 +1,6 @@ import { useDraggable } from '@dnd-kit/core'; import { ChevronDown, ChevronRight, GripVertical, Search } from 'lucide-react'; +import type * as React from 'react'; import { useMemo, useState } from 'react'; import { cn } from '../lib/cn'; import type { PaletteSection as PaletteSectionDef, PaletteVariant } from '../lib/default-blocks'; @@ -90,13 +91,20 @@ export function Palette({ sections, defaultOpenSections = true, showSearch = true, - searchPlaceholder = 'Search blocks…' + searchPlaceholder = 'Search blocks…', + variant = 'aside' }: { onAddBlock: (block: SupportedBlock) => void; sections: readonly PaletteSectionDef[]; defaultOpenSections?: DefaultOpenSections; showSearch?: boolean; searchPlaceholder?: string; + /** + * `'aside'` — persistent left rail (default). Fixed width with right border. + * `'sheet'` — full-width content for mobile bottom-sheet hosting. No + * own borders or width constraints so it fills the sheet body. + */ + variant?: 'aside' | 'sheet'; }) { const [query, setQuery] = useState(''); const visibleSections = useMemo( @@ -104,25 +112,41 @@ export function Palette({ [sections, query, showSearch] ); const queryActive = showSearch && query.trim().length > 0; + const isSheet = variant === 'sheet'; return ( -