diff --git a/README.md b/README.md index 97fb3e2..0dc39f6 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,9 @@ export function MyBuilderPage() { | `customEmojis` | `CustomEmoji[]` | no | Workspace custom emoji (`{ name, url, alias }`) the preview resolves. Entries with a `url` render `:name:` as the workspace image; alias entries (`url: null`) fall back to their target emoji. Render-only — never serialized into the emitted Block Kit JSON. A caller-supplied `previewHooks.emoji` takes precedence. | | `palette` | `PaletteSection[]` | no | The left-hand palette of draggable variants. Defaults to `defaultPalette`. Spread it to filter, reorder, or add your own pre-configured variants — see [Customizing the palette](#customizing-the-palette). | | `disabledBlockTypes` | `SupportedBlockType[]` | no | Block types to hide from the palette without rebuilding it. Filters at the variant level — a section keeps any variants whose block types aren't disabled; sections that end up empty are dropped. Convenient when you want the default palette minus a few types (e.g. `['image', 'table']` for a text-only builder). | +| `paletteMode` | `'advanced' \| 'simple'` | no | How much of the palette the user meets first. `'advanced'` (default) renders the full sectioned palette with its search input. `'simple'` opens on a flat list of the variants flagged `basic` — in the built-in palette, just **Rich Text Section** — with no search and an "Advanced" link at the top that swaps in the full palette. Nothing is removed; it's a smaller first screen. See [Simple vs advanced palette](#simple-vs-advanced-palette). | | `defaultOpenSections` | `boolean \| string[]` | no | Which palette section headers are expanded on first paint. `true` (default) opens all sections; `false` collapses all (Slack-style); an array opens only sections whose `name` is in the list (e.g. `['Section', 'Actions']`). The palette also has a built-in search input that expands matching sections on demand. | -| `showPaletteSearch` | `boolean` | no | Whether the palette renders the quick-search input above the section list. Defaults to `true`. Set `false` for compact palettes (e.g. when you've passed a small custom `palette`) where scanning by eye is faster than typing. | +| `showPaletteSearch` | `boolean` | no | Whether the palette renders the quick-search input above the section list. Defaults to `true`. Set `false` for compact palettes (e.g. when you've passed a small custom `palette`) where scanning by eye is faster than typing. Simple mode has no search either way — it appears with the rest of the palette behind "Advanced". | | `paletteSearchPlaceholder` | `string` | no | Placeholder text for the palette search input. Defaults to `'Search blocks…'`. Useful for localization. | | `allowedSurfaces` | `PreviewSurface[]` | no | Allowlist of preview surfaces (`'message'`, `'modal'`, `'app_home'`). Defaults to `['message']` — surface dropdown is hidden when only one surface is allowed. The first entry is the initial selection. | | `showThemeControl` | `boolean` | no | Defaults to `true`. When `false`, the toolbar's light/dark toggle is hidden and the theme stays at `defaultPreviewTheme`. Ignored when `previewTheme` is set (a controlled theme always hides the toggle). | @@ -452,6 +453,107 @@ const PALETTE: readonly PaletteSection[] = [ Variant `id`s must be unique across the array — the drag-drop lookup keys by id. +### Simple vs advanced palette + +By default the palette opens on everything it has: every section, plus the search input. For consumers whose users are writing ordinary messages rather than building interactive apps, `paletteMode="simple"` opens on a one-block starter list instead — no sections, no search — behind an **Advanced** link that swaps in the full palette (and back). + +```tsx + +``` + +Which variants the simple list holds is a property of the palette, not the flag: a variant opts in with `basic: true`. The built-in palette flags exactly one — **Rich Text Section** — so a custom palette that wants its own starter block says so: + +```tsx +const PALETTE: readonly PaletteSection[] = [ + { + name: "Company presets", + icon: AlignLeft, + variants: [ + { id: "help_footer", label: "Help footer", basic: true, factory: () => ({ ... }) }, + { id: "company_divider", label: "Company divider", factory: () => ({ ... }) }, + ], + }, +]; +``` + +A palette that flags none of its variants `basic` has no simple list to show, so it renders as `'advanced'` regardless — the link is withheld rather than leading to an empty rail. + +## Templates (`TemplatePicker`) + +`TemplatePicker` is a standalone, page-sized picker that renders `Template`s as a grid of cards with live block previews, grouped into category sections — modeled on Slack's own Block Kit Builder templates page. It's pure UI: it owns no dialog and no layout, so you decide whether it's a route, a modal, a slide-over, or a sidebar next to the builder. + +```tsx +import { + BlockKitchen, + TemplatePicker, + type SupportedBlock, + type Template, +} from "@tightknitai/block-kitchen"; +import { useState } from "react"; + +const TEMPLATES: Template[] = [ + { + id: "standup", + name: "Daily standup", + description: "Yesterday / today / blockers", + surface: "message", + category: "Team", // optional — groups cards into sections + blocks: [ + /* SupportedBlock[] */ + ], + }, +]; + +function Builder() { + const [blocks, setBlocks] = useState([]); + // `initialBlocks` is read once at mount, so bump a key to re-seed. + const [seedKey, setSeedKey] = useState(0); + + return ( +
+
+ +
+ {/* `.bk-root` is required — see below */} + +
+ ); +} +``` + +| Prop | Type | Required | Description | +| --- | --- | --- | --- | +| `templates` | `Template[]` | yes | Templates to show. Order is preserved within each category section. | +| `onSelect` | `(template: Template) => void` | yes | Called with the whole template when a card is clicked. Nothing is applied to the builder for you — see [Applying a selection](#applying-a-selection). | +| `surface` | `'message' \| 'modal' \| 'app_home'` | no | Filter to templates whose `surface` matches. Omit to show all. | +| `theme` | `'light' \| 'dark'` | no | Preview theme used inside the card thumbnails. Defaults to `'light'`; pass the builder's `previewTheme` to keep them in step. | +| `heading` | `string` | no | Heading rendered above the grid. Omitted means no heading. | +| `emptyLabel` | `string` | no | Shown when the `surface` filter matches nothing. Defaults to `'No templates available.'`. | +| `className` | `string` | no | Merged onto the root element. | + +A `Template` is `{ id, name, surface, blocks }` plus optional `description` and `category`. Templates without a `category` fall into a trailing **Other** section when at least one other template has one; with no categories at all, the grid renders flat without section headers. + +### Bring your own templates + +The package ships **no** templates on purpose — they're use-case examples ("Expense approval" belongs to an approvals product, "Daily standup" to a team product), so they belong in your app's config rather than the library bundle. The live demo defines its own set in [`demo/src/templates.ts`](demo/src/templates.ts) if you want a starting point to crib from. + +### Applying a selection + +`onSelect` hands you the template and stops there — the picker never reaches into the builder. Because [`initialBlocks`](#props) is read once at mount, replacing the current draft means re-seeding with a changed `key`, as above. Selecting a template this way discards the draft and its undo history, so gate it behind a confirmation if that's a surprise in your app. + +### It needs a `.bk-root` ancestor + +Unlike `BlockKitchen`, the picker does not add the `bk-root` class itself. The stylesheet ships its utilities inside `@scope (.bk-root, .bk-portal-content)`, so a picker mounted outside both roots renders unstyled. Wrap it (or any ancestor) in `className="bk-root"`. + ## Boundary The package is deliberately decoupled from any Slack SDK or backend. It does not import HTTP clients, OAuth libraries, or workspace-state systems. Everything I/O-shaped is brokered through props — and the send flow itself is optional: see [compose-only mode](#compose-only-mode-bring-your-own-send-flow) when your app owns the moment of commitment. @@ -465,6 +567,7 @@ import { decodeBlocksFromString, defaultPalette, // the built-in palette — spread to customize SendDialog, // the built-in send dialog, standalone (bespoke send flows) + TemplatePicker, // the standalone templates grid — see Templates above useSlackSignIn, // the OAuth sign-in state machine behind the identity picker SlackSignInButton, // the "Sign in with Slack" button + polling spinner } from "@tightknitai/block-kitchen"; @@ -480,6 +583,7 @@ import type { BlockKitchenComposeOnlyProps, // the trio explicitly absent + `primaryAction` PaletteSection, PaletteVariant, + PaletteMode, // 'advanced' (default) | 'simple' SendPayload, SendResult, SendDialogProps, @@ -488,6 +592,7 @@ import type { PrimaryActionContext, ChannelOption, SendAsUserStatus, + Template, // one entry in a TemplatePicker gallery ValidationSummary, PreviewHooks, } from "@tightknitai/block-kitchen"; diff --git a/demo/src/App.tsx b/demo/src/App.tsx index b1775ed..8aac8f4 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -4,6 +4,7 @@ import { type ChannelOption, type LoadMessageInput, type LoadResult, + type PaletteMode, type RecentMessage, type SendAsUserStatus, type SendPayload, @@ -34,6 +35,18 @@ const PRESET_OPTIONS: { value: BrandPreset; label: string }[] = [ { value: 'cyberpunk', label: 'Cyberpunk' } ]; +type EditingMode = 'write-only' | 'read-write'; + +const EDITING_MODE_OPTIONS: { value: EditingMode; label: string }[] = [ + { value: 'write-only', label: 'Write-only' }, + { value: 'read-write', label: 'Read & Write' } +]; + +const PALETTE_MODE_OPTIONS: { value: PaletteMode; label: string }[] = [ + { value: 'advanced', label: 'Advanced' }, + { value: 'simple', label: 'Simple' } +]; + const MOCK_CHANNELS: ChannelOption[] = [ { id: 'C0001', name: 'general' }, { id: 'C0002', name: 'random' }, @@ -473,6 +486,9 @@ const AUTO_COLLAPSE_BELOW = 960; export function App() { const [theme, setTheme] = useState<'light' | 'dark'>('light'); const [preset, setPreset] = useState('default'); + // Which palette the builder opens on. 'advanced' is the package default — + // 'simple' starts on the one-block list behind an "Advanced" link. + const [paletteMode, setPaletteMode] = useState('advanced'); // Mirror `theme` onto so the .dark CSS-variable rule reaches // Radix portals (sheets, dialogs, popovers, tooltips). They mount @@ -773,15 +789,28 @@ export function App() {
- setEditingEnabled(v === 'read-write')} + /> + + = { * makes the load → update round-trip observable: copy a message's link, load * it in the builder, update, and watch the blocks change. */ -function EditingMenu({ +/** + * The demo's mock backend, on show: the user-token switches the send/edit + * dialogs read, and the message store "Find message" resolves links against. + * + * Reachable in either editing mode — the store is what a reader needs in + * order to *choose* a mode, so hiding it behind Read & Write meant you had + * to already be in the mode to see what it was for. Write-only just gets a + * line saying the load path is off. + */ +function SampleDataDialog({ editingEnabled, - onEditingEnabledChange, canSendAsUser, onCanSendAsUserChange, includeOauthUrl, @@ -978,7 +1016,6 @@ function EditingMenu({ store }: { editingEnabled: boolean; - onEditingEnabledChange: (v: boolean) => void; canSendAsUser: boolean; onCanSendAsUserChange: (v: boolean) => void; includeOauthUrl: boolean; @@ -1003,31 +1040,6 @@ function EditingMenu({ window.setTimeout(() => setCopiedTs((cur) => (cur === msg.ts ? null : cur)), 1200); }; - // Segmented switcher: a muted track with the active segment raised on a - // solid background, so it reads as a button-style mode toggle. - const tab = (label: string, active: boolean, onSelect: () => void) => ( - - ); - const checkbox = (label: string, checked: boolean, onChange: (v: boolean) => void, disabled?: boolean) => (