feat(words): add toolbarLayout grouped|inline (responsive)

New optional prop on <Words> 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<WordsToolbarLayout> for breakpoint-aware
switching, e.g. `toolbarLayout={{ base: 'grouped', md: 'inline' }}`.

Implementation:

- types.ts: new WordsToolbarLayout = 'grouped' | 'inline'; new
  toolbarLayout?: ResponsiveProp<WordsToolbarLayout> 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
  <ToolbarFamilyMenu> (grouped) and {#each leafs as leaf}{@render
  leafItem(leaf, snippetProps)}{/each} (inline).
- demo: toolbarLayout state added + wired to <Words>.

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 58c2c0babf
commit 85b8a56ab0

@ -81,6 +81,7 @@ export type {
WordsToolbarGroupConfig,
WordsToolbarHeadingPickerConfig,
WordsToolbarItemConfig,
WordsToolbarLayout,
WordsToolbarLeafItemConfig,
WordsToolbarLinkEditorConfig,
WordsToolbarGroupProps as ToolbarGroupProps,

@ -29,6 +29,27 @@ import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos
export type WordsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
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<WordsToolbarLayout>;
};
export type WordsContentProps = ContentProps;

@ -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 `<ToolbarFamilyMenu>` 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 [];
}
}
</script>
<SomaWords.Provider
@ -212,6 +267,39 @@
data-variant={variant}
data-color={color}
>
{#snippet leafItem(item: WordsToolbarLeafItemConfig, snippetProps: any)}
{#if isLinkEditorConfig(item)}
<LinkEditor />
{:else if isHeadingPickerConfig(item)}
<HeadingPicker
currentBlock={snippetProps.currentBlock}
currentHeadingLevel={snippetProps.currentHeadingLevel}
/>
{:else if isCodeLanguagePickerConfig(item)}
<CodeLanguagePicker
currentBlock={snippetProps.currentBlock}
currentCodeLanguage={snippetProps.currentCodeLanguage}
disabled={snippetProps.isDisabled}
readonly={snippetProps.isReadonly}
label={typeof item === 'object' ? item.label : undefined}
languages={typeof item === 'object' ? item.languages : undefined}
setCodeLanguage={snippetProps.setCodeLanguage}
/>
{:else if isFindReplaceConfig(item)}
<FindReplace />
{:else}
{@const command = normalizeCommand(item)}
<CommandButton
command={command.command}
level={command.level}
listKind={command.listKind}
aria-label={command.label}
>
<CommandIcon command={command.command} />
</CommandButton>
{/if}
{/snippet}
{#snippet children(snippetProps)}
{#if visibleToolbarGroups.length}
<Toolbar orientation={toolbarOrientation} aria-label="Formatting toolbar">
@ -219,46 +307,29 @@
<ToolbarGroup orientation={toolbarOrientation}>
{#each group as item}
{#if isToolbarFamilyConfig(item)}
<ToolbarFamilyMenu
family={familyNameFromConfig(item)}
label={familyLabelFromConfig(item)}
items={familyItemsFromConfig(item)}
currentBlock={snippetProps.currentBlock}
currentHeadingLevel={snippetProps.currentHeadingLevel}
currentCodeLanguage={snippetProps.currentCodeLanguage}
disabled={snippetProps.isDisabled}
readonly={snippetProps.isReadonly}
setCodeLanguage={snippetProps.setCodeLanguage}
/>
{:else if isLinkEditorConfig(item)}
<LinkEditor />
{:else if isHeadingPickerConfig(item)}
<HeadingPicker
currentBlock={snippetProps.currentBlock}
currentHeadingLevel={snippetProps.currentHeadingLevel}
/>
{:else if isCodeLanguagePickerConfig(item)}
<CodeLanguagePicker
currentBlock={snippetProps.currentBlock}
currentCodeLanguage={snippetProps.currentCodeLanguage}
disabled={snippetProps.isDisabled}
readonly={snippetProps.isReadonly}
label={typeof item === 'object' ? item.label : undefined}
languages={typeof item === 'object' ? item.languages : undefined}
setCodeLanguage={snippetProps.setCodeLanguage}
/>
{:else if isFindReplaceConfig(item)}
<FindReplace />
{#if resolvedToolbarLayout === 'inline'}
{@const leafs = flattenFamilyToLeafItems(
familyNameFromConfig(item),
familyItemsFromConfig(item)
)}
{#each leafs as leaf}
{@render leafItem(leaf, snippetProps)}
{/each}
{:else}
<ToolbarFamilyMenu
family={familyNameFromConfig(item)}
label={familyLabelFromConfig(item)}
items={familyItemsFromConfig(item)}
currentBlock={snippetProps.currentBlock}
currentHeadingLevel={snippetProps.currentHeadingLevel}
currentCodeLanguage={snippetProps.currentCodeLanguage}
disabled={snippetProps.isDisabled}
readonly={snippetProps.isReadonly}
setCodeLanguage={snippetProps.setCodeLanguage}
/>
{/if}
{:else}
{@const command = normalizeCommand(item)}
<CommandButton
command={command.command}
level={command.level}
listKind={command.listKind}
aria-label={command.label}
>
<CommandIcon command={command.command} />
</CommandButton>
{@render leafItem(item, snippetProps)}
{/if}
{/each}
</ToolbarGroup>

@ -193,6 +193,7 @@
let showSlashMenu = $state(true);
let showStatus = $state(true);
let orientation = $state<WordsToolbarOrientation>('horizontal');
let toolbarLayout = $state<'grouped' | 'inline'>('grouped');
let size = $state<WordsSize>('md');
let variant = $state<WordsVariant>('surface');
let color = $state<WordsColor>('primary');
@ -1025,6 +1026,7 @@
{color}
toolbar={toolbarConfig}
toolbarOrientation={orientation}
{toolbarLayout}
onValueCommit={(_, reason) => (lastCommit = reason)}
>
{#snippet children({ selectedLink })}

Loading…
Cancel
Save

Powered by TurnKey Linux.