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
1 change: 1 addition & 0 deletions src/components/block-kitchen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export function BlockKitchen(props: BlockKitchenProps) {
onUpdate={updateBlock}
onDuplicate={duplicateBlock}
onDelete={removeBlock}
onReorder={reorderBlock}
isPaletteDrag={activePaletteVariant !== null}
/>
</div>
Expand Down
65 changes: 59 additions & 6 deletions src/components/block-row.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { AlertTriangle, Copy, Pencil, Trash2 } from 'lucide-react';
import { AlertTriangle, ArrowDown, ArrowUp, Copy, Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import type { RichTextBlock } from 'slack-web-api-client';
import { cn } from '../lib/cn';
Expand Down Expand Up @@ -59,6 +59,9 @@ export function BlockRow({
onUpdate,
onDuplicate,
onDelete,
index,
total,
onReorder,
isPaletteDrag = false
}: {
builderBlock: BuilderBlock;
Expand All @@ -73,6 +76,12 @@ export function BlockRow({
onUpdate: (id: string, block: SupportedBlock) => void;
onDuplicate: (id: string) => void;
onDelete: (id: string) => void;
/** Zero-based index of this row in the surface; powers move up/down. */
index?: number;
/** Total number of rows on the surface; powers move up/down. */
total?: number;
/** Move this row to a new index. Wires up the keyboard-accessible move buttons. */
onReorder?: (id: string, toIndex: number) => void;
/** True while a palette item is being dragged (vs. reordering an existing block). */
isPaletteDrag?: boolean;
}) {
Expand Down Expand Up @@ -143,6 +152,15 @@ export function BlockRow({
aria-label="Edit block"
{...sortableA11yAttrs}
{...listeners}
onKeyDown={(e) => {
// The div trigger is keyboard-activatable via role="button" + tabIndex=0,
// but Space on a non-button element scrolls the page by default. Toggle the
// popover ourselves on Enter/Space and swallow the event so the page stays put.
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenChange?.(!isOpen);
}
}}
className={cn(
'block w-full cursor-grab rounded-sm transition-shadow hover:shadow-md focus-visible:ring-1 focus-visible:ring-ring active:cursor-grabbing',
hasErrors ? 'ring-1 ring-destructive/60 hover:ring-destructive' : 'hover:ring-1 hover:ring-border'
Expand All @@ -166,7 +184,10 @@ export function BlockRow({
<div
className={cn(
'absolute -top-3 right-2 z-10 flex items-center gap-0.5 rounded-md border bg-background p-0.5 shadow-sm transition-opacity',
hasErrors ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
// Reveal on hover, on focus inside the row (so Tab-stops on the
// floating buttons themselves keep them visible), and when there
// are validation errors worth surfacing immediately.
hasErrors ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
)}
>
{hasErrors ? (
Expand All @@ -176,7 +197,7 @@ export function BlockRow({
type="button"
aria-label={`Show ${errors!.length} validation ${errors!.length === 1 ? 'issue' : 'issues'}`}
onClick={() => onOpenChange?.(true)}
className="flex h-6 w-6 items-center justify-center rounded text-destructive hover:bg-destructive/10"
className="flex h-6 w-6 items-center justify-center rounded text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<AlertTriangle className="h-3.5 w-3.5" />
</button>
Expand All @@ -191,6 +212,38 @@ export function BlockRow({
</TooltipContent>
</Tooltip>
) : null}
{onReorder && typeof index === 'number' && typeof total === 'number' ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Move block up"
disabled={index === 0}
onClick={() => onReorder(builderBlock.id, index - 1)}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Move up</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Move block down"
disabled={index >= total - 1}
onClick={() => onReorder(builderBlock.id, index + 1)}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Move down</TooltipContent>
</Tooltip>
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
Expand All @@ -203,7 +256,7 @@ export function BlockRow({
onOpenChange?.(true);
}
}}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Pencil className="h-3.5 w-3.5" />
</button>
Expand All @@ -216,7 +269,7 @@ export function BlockRow({
type="button"
aria-label="Duplicate block"
onClick={() => onDuplicate(builderBlock.id)}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Copy className="h-3.5 w-3.5" />
</button>
Expand All @@ -229,7 +282,7 @@ export function BlockRow({
type="button"
aria-label="Delete block"
onClick={() => onDelete(builderBlock.id)}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
Expand Down
1 change: 1 addition & 0 deletions src/components/json-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function JsonDrawer({
</div>
<textarea
ref={textareaRef}
aria-label="Block Kit JSON"
value={value}
onChange={(e) => handleChange(e.target.value)}
onScroll={(e) => {
Expand Down
51 changes: 28 additions & 23 deletions src/components/surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function Surface({
onUpdate,
onDuplicate,
onDelete,
onReorder,
isPaletteDrag = false
}: {
blocks: BuilderBlock[];
Expand All @@ -59,6 +60,8 @@ export function Surface({
onUpdate: (id: string, block: SupportedBlock) => void;
onDuplicate: (id: string) => void;
onDelete: (id: string) => void;
/** Move a block to a new index. Powers the keyboard-accessible move up / move down buttons. */
onReorder?: (id: string, toIndex: number) => void;
/** True while a palette item is being dragged (vs. reordering an existing block). */
isPaletteDrag?: boolean;
}) {
Expand Down Expand Up @@ -87,7 +90,7 @@ export function Surface({
<EmptyState isDark={isDark} isPaletteDrag={isPaletteDrag} isOver={isOver} />
) : (
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
{blocks.map((block) => (
{blocks.map((block, idx) => (
<BlockRow
key={block.id}
builderBlock={block}
Expand All @@ -99,6 +102,9 @@ export function Surface({
onUpdate={onUpdate}
onDuplicate={onDuplicate}
onDelete={onDelete}
index={idx}
total={blocks.length}
onReorder={onReorder}
isPaletteDrag={isPaletteDrag}
/>
))}
Expand All @@ -109,7 +115,10 @@ export function Surface({
);

return (
<div className={cn('flex flex-1 flex-col p-6', isDark ? 'bg-[#0e0f12]' : 'bg-[#f4f4f4]')}>
<main
aria-label="Block preview"
className={cn('flex flex-1 flex-col p-6', isDark ? 'bg-[#0e0f12]' : 'bg-[#f4f4f4]')}
>
<div className="mx-auto w-full max-w-2xl">
{previewSurface === 'modal' ? (
<ModalFrame isDark={isDark}>{blocksList}</ModalFrame>
Expand All @@ -123,7 +132,7 @@ export function Surface({
</MessageFrame>
)}
</div>
</div>
</main>
);
}

Expand Down Expand Up @@ -194,6 +203,12 @@ function MessageFrame({
* @returns the rendered modal frame
*/
function ModalFrame({ isDark, children }: { isDark: boolean; children: ReactNode }) {
// The X / Cancel / Submit affordances are visual chrome — they exist to
// make the preview look like a real Slack modal. They aren't wired to
// anything, so render them as `<span aria-hidden>` to keep them out of
// the focus order and the accessibility tree (a keyboard or screen
// reader user activating a "Submit" button that did nothing would be a
// worse experience than not seeing the chrome at all).
return (
<div
className={cn(
Expand All @@ -208,43 +223,33 @@ function ModalFrame({ isDark, children }: { isDark: boolean; children: ReactNode
)}
>
<h2 className={cn('text-base font-bold', isDark ? 'text-white' : 'text-[#1d1c1d]')}>Modal title</h2>
<button
type="button"
aria-label="Close"
<span
aria-hidden="true"
className={cn(
'flex h-6 w-6 items-center justify-center rounded',
isDark
? 'text-white/60 hover:bg-white/10 hover:text-white'
: 'text-[#616061] hover:bg-[#f3f3f3] hover:text-[#1d1c1d]'
isDark ? 'text-white/60' : 'text-[#616061]'
)}
>
<X className="h-4 w-4" />
</button>
</span>
</div>
{children}
<div
aria-hidden="true"
className={cn(
'flex items-center justify-end gap-2 border-t px-5 py-3',
isDark ? 'border-[#2c2d30]' : 'border-[#e8e8e8]'
)}
>
<button
type="button"
<span
className={cn(
'cursor-pointer rounded-sm border px-3 py-1.5 text-sm font-medium',
isDark
? 'border-white/20 bg-transparent text-white hover:bg-white/5'
: 'border-[#e8e8e8] bg-white text-[#1d1c1d] hover:bg-[#f3f3f3]'
'rounded-sm border px-3 py-1.5 text-sm font-medium',
isDark ? 'border-white/20 bg-transparent text-white' : 'border-[#e8e8e8] bg-white text-[#1d1c1d]'
)}
>
Cancel
</button>
<button
type="button"
className="cursor-pointer rounded-sm bg-[#007a5a] px-3 py-1.5 text-sm font-bold text-white hover:bg-[#148567]"
>
Submit
</button>
</span>
<span className="rounded-sm bg-[#007a5a] px-3 py-1.5 text-sm font-bold text-white">Submit</span>
</div>
</div>
);
Expand Down
Loading
Loading