Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,27 @@ export function MyBuilderPage() {
}
```

### Sizing

The builder is an app shell: a fixed toolbar over a palette rail and a preview pane that scroll independently. It fills its container, so the height you give that container is the height it takes.

```tsx
// Give it a definite height and it fills exactly that.
<div style={{ height: "100%" }}>
<BlockKitchen {...props} />
</div>
```

Given no height to work with — a plain `<div>` in ordinary document flow — it bounds itself to `100svh` rather than growing to fit the palette's full block list, which would run a couple of thousand pixels down the page. Override that bound with `--bk-max-height` on any ancestor:

```css
/* Room for a 4rem page header above the builder. */
.builder-host { --bk-max-height: calc(100svh - 4rem); }

/* Or opt out entirely and let it grow with its content. */
.builder-host { --bk-max-height: none; }
```

## Props

| Prop | Type | Required | Description |
Expand Down
10 changes: 8 additions & 2 deletions src/components/block-kitchen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,9 +470,15 @@ export function BlockKitchen(props: BlockKitchenProps) {
{/* The builder shell doubles as the keydown scope for undo/redo
shortcuts: the handler only augments already-focusable children
(toolbar buttons, block rows, fields) and never acts as a
control itself, so it needs no role or tabindex. */}
control itself, so it needs no role or tabindex.

`h-full` computes to `auto` in ordinary document flow, so the
shell grew to its tallest child — the palette — instead of
scrolling it. The `max-h` bounds that case and stays inert when
the host's own height is smaller. Override with
`--bk-max-height` on any ancestor (`none` opts out). */}
<div
className="bk-root flex h-full w-full flex-col overflow-hidden rounded-md border bg-background text-foreground"
className="bk-root flex h-full max-h-[var(--bk-max-height,100svh)] w-full flex-col overflow-hidden rounded-md border bg-background text-foreground"
onKeyDown={handleKeyDown}
>
<Toolbar
Expand Down
103 changes: 53 additions & 50 deletions src/components/palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,56 +194,59 @@ export function Palette({
)}
>
{offersSimple || searchVisible ? (
<div
className={cn(
'sticky top-0 z-10 flex flex-col gap-2 border-b px-3 pt-3 pb-2 backdrop-blur',
isSheet ? 'bg-background' : 'bg-muted/20'
)}
>
{offersSimple ? (
// Titled row: with the sections gone, simple mode's header would
// otherwise be a lone right-aligned link over empty space, and
// nothing would name what the rail below it holds.
<div className="flex items-center justify-between gap-2">
<span
className={cn(
'min-w-0 truncate font-medium uppercase tracking-wide text-muted-foreground',
isSheet ? 'text-xs' : 'text-[11px]'
)}
>
{PALETTE_TITLE}
</span>
<ModeLink
advanced={advanced}
isSheet={isSheet}
onToggle={() => {
// Leaving advanced drops the query with it: it filters a list
// that's no longer on screen, and coming back to a palette
// pre-filtered by something typed minutes ago reads as a bug.
setQuery('');
setAdvancedOpen((v) => !v);
}}
/>
</div>
) : null}
{searchVisible ? (
<div className="relative">
<Search
className={cn(
'pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground',
isSheet ? 'h-4 w-4' : 'h-3.5 w-3.5'
)}
/>
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className={cn(isSheet ? 'h-10 pl-8 text-base' : 'h-8 pl-7 text-sm')}
/>
</div>
) : null}
// Two elements so the header is opaque *and* the exact color of the
// rail. Painted with the rail's own `bg-muted/20`, it composited over
// the scrolled list instead and headings read through the search box
// (a `backdrop-blur` softened them, it didn't hide them). Restoring
// the `bg-background` base under the tint rebuilds the rail's own two
// layers, so the match holds under any theme.
<div className="sticky top-0 z-10 shrink-0 bg-background">
<div className={cn('flex flex-col gap-2 border-b px-3 pt-3 pb-2', isSheet ? 'bg-background' : 'bg-muted/20')}>
{offersSimple ? (
// Titled row: with the sections gone, simple mode's header would
// otherwise be a lone right-aligned link over empty space, and
// nothing would name what the rail below it holds.
<div className="flex items-center justify-between gap-2">
<span
className={cn(
'min-w-0 truncate font-medium uppercase tracking-wide text-muted-foreground',
isSheet ? 'text-xs' : 'text-[11px]'
)}
>
{PALETTE_TITLE}
</span>
<ModeLink
advanced={advanced}
isSheet={isSheet}
onToggle={() => {
// Leaving advanced drops the query with it: it filters a list
// that's no longer on screen, and coming back to a palette
// pre-filtered by something typed minutes ago reads as a bug.
setQuery('');
setAdvancedOpen((v) => !v);
}}
/>
</div>
) : null}
{searchVisible ? (
<div className="relative">
<Search
className={cn(
'pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground',
isSheet ? 'h-4 w-4' : 'h-3.5 w-3.5'
)}
/>
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className={cn(isSheet ? 'h-10 pl-8 text-base' : 'h-8 pl-7 text-sm')}
/>
</div>
) : null}
</div>
</div>
) : null}
<div className={cn('flex flex-col', isSheet ? 'px-2 pb-6' : 'p-3')}>
Expand Down
13 changes: 13 additions & 0 deletions src/components/toolbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ export const HistoryAvailable: Story = {
}
};

// Clear and View JSON rest as bare icons and grow their label on hover or
// keyboard focus. The reveal itself is pure CSS and `userEvent`'s synthetic
// pointer events never set `:hover`, so it's measured in
// test/toolbar-expanding-labels.test.tsx; this story is the visual reference.
export const UtilityLabelsCollapsed: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Whatever the labels do visually, the names stay addressable.
await expect(await canvas.findByRole('button', { name: 'View JSON' })).toBeInTheDocument();
await expect(await canvas.findByRole('button', { name: 'Clear all blocks' })).toBeInTheDocument();
}
};

export const DocsLinkHidden: Story = {
args: { docsLink: false }
};
Expand Down
53 changes: 48 additions & 5 deletions src/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
Undo2,
X
} from 'lucide-react';
import type { ComponentType, KeyboardEvent } from 'react';
import type { ComponentType, KeyboardEvent, ReactNode } from 'react';
import { useRef, useState } from 'react';
import { cn } from '../lib/cn';
import { Button } from '../lib/ui/button';
Expand Down Expand Up @@ -346,15 +346,22 @@ export function Toolbar({
size="sm"
onClick={onClear}
disabled={!canClear}
className="hover:bg-destructive/10 hover:text-destructive"
className="group gap-0 hover:bg-destructive/10 hover:text-destructive"
aria-label="Clear all blocks"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden md:inline">Clear</span>
<ExpandingLabel>Clear</ExpandingLabel>
</Button>
<Button type="button" variant="ghost" size="sm" onClick={onOpenJson} aria-label="View JSON">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenJson}
className="group gap-0"
aria-label="View JSON"
>
<Code2 className="h-3.5 w-3.5" />
<span className="hidden md:inline">View JSON</span>
<ExpandingLabel>View JSON</ExpandingLabel>
</Button>
{!showSend ? (
primaryAction ? (
Expand Down Expand Up @@ -462,6 +469,42 @@ export function Toolbar({
);
}

/**
* A toolbar label that rests collapsed at zero width and slides open on
* hover or keyboard focus, leaving the icon as the button's resting state.
* Keeps the secondary utilities (Clear, View JSON) a compact icon cluster
* with the name one hover or one Tab away.
*
* Animated as a `0fr` → `1fr` grid track, since the flex factor
* interpolates to the item's max-content width and `width: auto` can't.
* All three elements are load-bearing: the grid owns the track, the middle
* span is the item sized to it and clipping, and the inner block carries
* the gap to the icon — padding on the clipped item would survive the
* collapse, since `border-box` floors width at padding. Callers pass
* `gap-0` for the same reason.
*
* The text stays in the DOM, but these buttons all carry an `aria-label`,
* so it was never the accessible name.
* @param props - label props
* @param props.children - the label text to reveal
* @returns the rendered expanding label
*/
function ExpandingLabel({ children }: { children: ReactNode }) {
return (
<span
className={cn(
'grid grid-cols-[0fr] transition-[grid-template-columns] duration-200 ease-out',
'group-hover:grid-cols-[1fr] group-focus-visible:grid-cols-[1fr]',
'motion-reduce:transition-none'
)}
>
<span className="overflow-hidden">
<span className="block whitespace-nowrap pl-1.5">{children}</span>
</span>
</span>
);
}

/**
* Single-select dropdown menu rendered inside a Popover. Adds proper
* `role="menu"` / `role="menuitem"` semantics and arrow-key navigation
Expand Down
Loading
Loading