From 85b8a56ab01abad8c498f8f1b901a81b1fd5787f Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 14:42:55 +0200 Subject: [PATCH] feat(words): add toolbarLayout grouped|inline (responsive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New optional prop on for the toolbar layout strategy: - 'grouped' (default): each family (text/block/list/align/insert/table/ link/tools) becomes a single trigger button that opens a popover with its commands. Compact, recommended for narrow viewports. - 'inline': every command in every family renders flat in the toolbar, with the toolbar group separators marking family boundaries. High discoverability, recommended for wide editors. Tools that own their own panel UI (LinkEditor, FindReplace, HeadingPicker, CodeLanguagePicker) remain popovers/dropdowns in both layouts by design — they are not "inlineable". Accepts ResponsiveProp for breakpoint-aware switching, e.g. `toolbarLayout={{ base: 'grouped', md: 'inline' }}`. Implementation: - types.ts: new WordsToolbarLayout = 'grouped' | 'inline'; new toolbarLayout?: ResponsiveProp on WordsProps. - index.ts: export WordsToolbarLayout type. - words.svelte: resolved via eidos.resolve(toolbarLayout, 'grouped'). New helper flattenFamilyToLeafItems(family, customItems) returns the default leaf items per family for the inline expansion. Render refactored: leaf rendering extracted to a Svelte snippet reusable by both layouts, so the conditional only chooses between (grouped) and {#each leafs as leaf}{@render leafItem(leaf, snippetProps)}{/each} (inline). - demo: toolbarLayout state added + wired to . Verified visually in browser (preview + screenshot): - grouped: 9 family menu triggers + 1 sibling tool = 10 toolbar buttons - inline: 0 family triggers + 28 command buttons + 5 tools = 33 buttons Tests: 161/161 pass in src/uix/{contracts,soma/components/words}. TypeScript: 0 new errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/eidos/components/words/index.ts | 1 + src/uix/eidos/components/words/types.ts | 31 ++++ src/uix/eidos/components/words/words.svelte | 149 ++++++++++++++----- web/routes/uix/components/words/+page.svelte | 2 + 4 files changed, 144 insertions(+), 39 deletions(-) diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts index 994253ef6..4fdf82726 100644 --- a/src/uix/eidos/components/words/index.ts +++ b/src/uix/eidos/components/words/index.ts @@ -81,6 +81,7 @@ export type { WordsToolbarGroupConfig, WordsToolbarHeadingPickerConfig, WordsToolbarItemConfig, + WordsToolbarLayout, WordsToolbarLeafItemConfig, WordsToolbarLinkEditorConfig, WordsToolbarGroupProps as ToolbarGroupProps, diff --git a/src/uix/eidos/components/words/types.ts b/src/uix/eidos/components/words/types.ts index 86d89128e..fe35eb948 100644 --- a/src/uix/eidos/components/words/types.ts +++ b/src/uix/eidos/components/words/types.ts @@ -29,6 +29,27 @@ import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos export type WordsSize = Extract; export type WordsVariant = ControlVariant; export type WordsColor = ColorRole; +/** + * Toolbar layout strategy. + * + * - `'grouped'` (default): each toolbar family (`text` / `block` / `list` / + * `align` / `insert` / `table` / `link` / `tools`) becomes a single + * trigger button that opens a popover with its commands. Compact — + * recommended for narrow viewports or when the editor lives in a side + * panel. + * - `'inline'`: every command in every family is rendered flat in the + * toolbar (with separators between families). High discoverability, + * high horizontal footprint — recommended for wide editors and desktop + * contexts. + * + * Tools that own their own panel UI (LinkEditor, FindReplace, + * HeadingPicker, CodeLanguagePicker) keep their popover/dropdown nature + * in both layouts — they are not "inlineable" by design. + * + * Accepts `ResponsiveProp` so a single editor can switch layout per + * viewport, e.g. `{ base: 'grouped', md: 'inline' }`. + */ +export type WordsToolbarLayout = 'grouped' | 'inline'; export type WordsToolbarPreset = 'minimal' | 'formatting' | 'full'; export type WordsToolbarButtonCommandName = Exclude< WordsCommandName, @@ -122,6 +143,16 @@ export type WordsProps = ProviderProps & { toolbar?: WordsToolbarConfig; /** Orientation for the toolbar rendered from the `toolbar` prop. @default 'horizontal' */ toolbarOrientation?: WordsToolbarOrientation; + /** + * Toolbar layout strategy (`grouped` family popovers vs `inline` flat + * commands). Accepts ResponsiveProp for breakpoint-aware switching — + * e.g. `{ base: 'grouped', md: 'inline' }`. See `WordsToolbarLayout` + * for the perceptual rationale. Tools that own their own panel + * (LinkEditor / FindReplace / HeadingPicker / CodeLanguagePicker) + * stay as popovers/dropdowns in both layouts. + * @default 'grouped' + */ + toolbarLayout?: ResponsiveProp; }; export type WordsContentProps = ContentProps; diff --git a/src/uix/eidos/components/words/words.svelte b/src/uix/eidos/components/words/words.svelte index 72b63f1ae..0c9a1065d 100644 --- a/src/uix/eidos/components/words/words.svelte +++ b/src/uix/eidos/components/words/words.svelte @@ -33,6 +33,7 @@ color = 'primary', toolbar = false, toolbarOrientation = 'horizontal', + toolbarLayout = 'grouped', value = $bindable(), selection = $bindable(null), children: bodyContent, @@ -41,6 +42,7 @@ const eidos = ActiveEidos.require(); const resolvedSize = $derived(eidos.resolve(size, 'md')); + const resolvedToolbarLayout = $derived(eidos.resolve(toolbarLayout, 'grouped')); const toolbarGroups = $derived(resolveToolbarGroups(toolbar)); const visibleToolbarGroups = $derived.by((): readonly WordsVisibleToolbarGroupConfig[] => toolbarGroups.map((group) => group.filter(isToolbarItem)).filter((group) => group.length > 0) @@ -202,6 +204,59 @@ ): item is WordsVisibleToolbarItemConfig { return Boolean(item); } + + /** + * Default leaf items per family — used when `toolbarLayout === 'inline'` + * to expand the family popover into flat commands. When a family config + * passes its own `items` explicitly, those win over these defaults. + * + * Source of truth: these mirror what `` renders + * internally when `items` is unspecified. Keep both in sync — if a + * family adds/removes a default command in the menu, update here too. + */ + function flattenFamilyToLeafItems( + family: WordsToolbarFamilyName, + customItems?: readonly WordsToolbarLeafItemConfig[] + ): readonly WordsToolbarLeafItemConfig[] { + if (customItems && customItems.length > 0) return customItems; + switch (family) { + case 'history': + return ['undo', 'redo']; + case 'text': + return ['bold', 'italic', 'underline', 'strike', 'code', 'clear-formatting']; + case 'block': + return ['heading-picker', 'quote', 'code-block']; + case 'list': + return [ + 'unordered-list', + 'ordered-list', + 'check-list', + 'increase-indent', + 'decrease-indent' + ]; + case 'align': + return ['align-left', 'align-center', 'align-right', 'align-justify']; + case 'insert': + return ['insert-table']; + case 'table': + return [ + 'insert-table-row', + 'insert-table-column', + 'delete-table-row', + 'delete-table-column', + 'toggle-table-header-row', + 'toggle-table-header-column', + 'toggle-table-striped', + 'toggle-table-compact' + ]; + case 'link': + return ['link-editor']; + case 'tools': + return ['find-replace', 'code-language-picker']; + default: + return []; + } + } + {#snippet leafItem(item: WordsToolbarLeafItemConfig, snippetProps: any)} + {#if isLinkEditorConfig(item)} + + {:else if isHeadingPickerConfig(item)} + + {:else if isCodeLanguagePickerConfig(item)} + + {:else if isFindReplaceConfig(item)} + + {:else} + {@const command = normalizeCommand(item)} + + + + {/if} + {/snippet} + {#snippet children(snippetProps)} {#if visibleToolbarGroups.length} @@ -219,46 +307,29 @@ {#each group as item} {#if isToolbarFamilyConfig(item)} - - {:else if isLinkEditorConfig(item)} - - {:else if isHeadingPickerConfig(item)} - - {:else if isCodeLanguagePickerConfig(item)} - - {:else if isFindReplaceConfig(item)} - + {#if resolvedToolbarLayout === 'inline'} + {@const leafs = flattenFamilyToLeafItems( + familyNameFromConfig(item), + familyItemsFromConfig(item) + )} + {#each leafs as leaf} + {@render leafItem(leaf, snippetProps)} + {/each} + {:else} + + {/if} {:else} - {@const command = normalizeCommand(item)} - - - + {@render leafItem(item, snippetProps)} {/if} {/each} diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index 7a758b2d0..60ffa9e19 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -193,6 +193,7 @@ let showSlashMenu = $state(true); let showStatus = $state(true); let orientation = $state('horizontal'); + let toolbarLayout = $state<'grouped' | 'inline'>('grouped'); let size = $state('md'); let variant = $state('surface'); let color = $state('primary'); @@ -1025,6 +1026,7 @@ {color} toolbar={toolbarConfig} toolbarOrientation={orientation} + {toolbarLayout} onValueCommit={(_, reason) => (lastCommit = reason)} > {#snippet children({ selectedLink })}