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 })}