fix: one shape and one baseline for the ribbon's buttons

Ten of the fifteen caret buttons ran the same toggleMenu from both halves,
so they were split for nothing. They are single buttons now, with the caret
under the label rather than beside it — 13px narrower each, and Word's own
Paste button.

The tint takes the whole button, label included: a row mixes buttons whose
label is inside the button with pickers whose caption is a sibling, so half
of them lit up under the words and half did not. `captionClicks` gives the
caption the click to match, and the picker's trigger takes the icon box's
34px so glyphs and words land on one line.
This commit is contained in:
Steffen Becker committed 2026-08-12 19:51:36 +02:00
1 parent a78e21b524
commit 4b84e2efac
10 files changed
+113 -41

No files matched your search

+37 -12
View File
@@ -34,17 +34,26 @@
} = $props();
const ICON_SIZE = { big: 28, small: 14, icon: 20 } as const;
// Nothing to split when the whole button opens the menu, so the caret joins the
// column under the label instead of sitting beside the icon.
let stackCaret = $derived(caret && !onCaret && variant === 'big');
</script>
{#snippet face(withLabel = true)}
<span class="rb-face">
{#if content}{@render content()}
{:else if icon}<Icon name={icon} size={ICON_SIZE[variant]} />{/if}
{#if caret && !onCaret}
{#if caret && !onCaret && !stackCaret}
<Icon name="chevronDown" size={10} />
{/if}
</span>
{#if withLabel && label && variant !== 'icon'}<span class="rb-label">{label}</span>{/if}
<!-- Held open on every big button, carrying a caret or nothing: the group centres
its controls, so one taller button lifts its own icon and label off the row. -->
{#if variant === 'big' && !onCaret}
<span class="rb-stack-caret">{#if caret}<Icon name="chevronDown" size={10} />{/if}</span>
{/if}
{/snippet}
{#if caret && onCaret}
@@ -67,7 +76,15 @@
</button>
</span>
{:else}
<button class="rb rb-{variant}" class:active {disabled} {title} {onclick}>
<button
class="rb rb-{variant}"
class:active
{disabled}
{title}
aria-haspopup={caret ? 'menu' : undefined}
aria-expanded={caret ? active : undefined}
{onclick}
>
{@render face()}
</button>
{/if}
@@ -98,8 +115,8 @@
gap: 2px;
}
/* Icon over label. The hover tint paints the icon box, not the whole button —
Word's signature, and what keeps a labelled column from looking like a slab. */
/* Icon over label, and the tint takes both. Word paints only the icon box, but
then the words beside an identical-looking neighbour light up and these do not. */
.rb-big {
flex-direction: column;
gap: 4px;
@@ -115,9 +132,9 @@
margin: 0 -4px;
}
.rb-big:hover:not(:disabled) .rb-face { background: var(--w-hover); }
.rb-big:active:not(:disabled) .rb-face { background: var(--w-pressed); }
.rb-big.active .rb-face { background: var(--w-active); }
.rb-big:hover:not(:disabled) { background: var(--w-hover); }
.rb-big:active:not(:disabled) { background: var(--w-pressed); }
.rb-big.active { background: var(--w-active); }
/* A row in a stacked mini-column (Cut / Copy under a big Paste). */
.rb-small {
@@ -145,6 +162,14 @@
color: inherit;
}
/* Rides closer to the label than the 4px column gap: the two read as one line. */
.rb-stack-caret {
display: flex;
height: 10px;
margin-top: -3px;
color: var(--w-text-dim);
}
/* Split button: the two halves read as one control, so the hover outline sits on
the wrapper and each half only tints its own surface. */
.rb-split {
@@ -169,15 +194,15 @@
}
/* A big split button stacks, as in Word: the icon runs the command, the label
and its caret open the menu. Welded to the right the caret would float at
icon height, beside the label rather than with it. */
and the caret under it open the menu. Icon, label and caret each get their
own line — abreast, the caret widens the button by its own width. */
.rb-split-col { flex-direction: column; }
.rb-split-col .rb-big { padding: 4px 7px 0; }
.rb-split-col .rb-big { padding: 3px 7px 0; }
.rb-split-col .rb-caret {
flex-direction: column;
width: auto;
gap: 3px;
padding: 4px 7px 6px;
padding: 0 7px 2px;
border-radius: 0 0 3px 3px;
color: var(--w-text);
}
+13
View File
@@ -111,3 +111,16 @@ export function clickOutside(node: HTMLElement, id?: string) {
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
// A reused picker keeps its trigger inside its own component, so the caption the
// ribbon puts beside it is not part of that button: hovering the words lit nothing
// and clicking them did nothing. Forwards a click that missed every button to the
// first one, so glyph and caption behave as the single control they look like.
export function captionClicks(node: HTMLElement) {
function handler(e: MouseEvent) {
if ((e.target as HTMLElement).closest('button, select, input')) return;
node.querySelector('button')?.click();
}
node.addEventListener('click', handler);
return { destroy() { node.removeEventListener('click', handler); } };
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { captionClicks } from '../menu.svelte';
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
@@ -70,7 +71,7 @@
{#each SHAPES as s}
<RibbonButton variant="big" icon={s.key === 'ellipse' ? 'shapeEllipse' : s.key === 'roundRect' ? 'shapeRound' : 'shapeRect'} label={s.label()} title={s.label()} active={shapeKind === s.key} onclick={() => editor?.chain().focus().setTextBoxAttrs({ shapeKind: s.key }).run()} />
{/each}
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<ColorPicker
{editor}
currentColor={fillColor ?? null}
@@ -84,7 +85,7 @@
/>
<span class="rb-caption">{t().textBox.fillColor}</span>
</div>
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<ColorPicker
{editor}
currentColor={strokeColor ?? null}
@@ -195,7 +195,7 @@
<RibbonButton title={t().toolbarExpanded.growFont} content={growIcon} onclick={() => editor && stepFontSize(editor, 1)} />
<RibbonButton title={t().toolbarExpanded.shrinkFont} content={shrinkIcon} onclick={() => editor && stepFontSize(editor, -1)} />
<div class="rb-menu-wrap" use:clickOutside={'case'}>
<RibbonButton icon="changeCase" title={t().ribbon.case.title} caretActive={isMenuOpen('case')} caret onCaret={() => toggleMenu('case')} onclick={() => toggleMenu('case')} />
<RibbonButton icon="changeCase" title={t().ribbon.case.title} active={isMenuOpen('case')} caret onclick={() => toggleMenu('case')} />
{#if isMenuOpen('case')}
<div class="ribbon-menu" use:anchored role="menu">
{#each CASES as c}
@@ -311,7 +311,7 @@
/>
{/each}
<div class="rb-menu-wrap" use:clickOutside={'lineHeight'}>
<RibbonButton icon="lineSpacing" title={t().toolbarExpanded.lineSpacing} caret caretActive={isMenuOpen('lineHeight')} onclick={() => toggleMenu('lineHeight')} onCaret={() => toggleMenu('lineHeight')} />
<RibbonButton icon="lineSpacing" title={t().toolbarExpanded.lineSpacing} caret active={isMenuOpen('lineHeight')} onclick={() => toggleMenu('lineHeight')} />
{#if isMenuOpen('lineHeight')}
<div class="ribbon-menu" use:anchored role="menu">
{#each LINE_HEIGHTS as h}
@@ -9,7 +9,7 @@
import BookmarkDialog from '../../BookmarkDialog.svelte';
import CrossRefDialog from '../../CrossRefDialog.svelte';
import FormulaDialog from '../../FormulaDialog.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { OPEN_LINK_DIALOG_EVENT } from '../../../editor/extensions/link';
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames, findBookmark } from '../../../editor/extensions/bookmark';
import { OPEN_CROSS_REF_DIALOG_EVENT } from '../../../editor/extensions/crossReference';
@@ -198,7 +198,7 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.tables}>
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<TablePicker
{editor}
bind:open={tableOpen}
@@ -242,7 +242,7 @@
<RibbonButton variant="big" icon="header" label={t().ribbon.header} title={t().toolbarExpanded.editHeader} disabled={!editor} onclick={() => onEditZone?.('header')} />
<RibbonButton variant="big" icon="footer" label={t().ribbon.footer} title={t().toolbarExpanded.editFooter} disabled={!editor} onclick={() => onEditZone?.('footer')} />
<div class="rb-menu-wrap" use:clickOutside={'pageField'}>
<RibbonButton variant="big" icon="pageNumber" label={t().ribbon.pageNumber} title={t().ribbon.pageNumber} caret caretActive={isMenuOpen('pageField')} onclick={() => toggleMenu('pageField')} onCaret={() => toggleMenu('pageField')} />
<RibbonButton variant="big" icon="pageNumber" label={t().ribbon.pageNumber} title={t().ribbon.pageNumber} caret active={isMenuOpen('pageField')} onclick={() => toggleMenu('pageField')} />
{#if isMenuOpen('pageField')}
<div class="ribbon-menu" use:anchored role="menu">
<button onclick={() => insertPageField('pageNumber')}>{t().hf.pageNumber}</button>
@@ -255,7 +255,7 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.text}>
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<DateTimePicker
bind:open={dateOpen}
{editor}
@@ -268,7 +268,7 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.symbols}>
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<SpecialCharPicker
bind:open={charOpen}
{editor}
@@ -76,7 +76,7 @@
<RibbonGroup label={t().ribbon.groups.pageSetup}>
<div class="rb-menu-wrap" use:clickOutside={'margins'}>
<RibbonButton variant="big" icon="margins" label={t().ribbon.margins} title={t().toolbarExpanded.pageMargins} caret caretActive={isMenuOpen('margins')} onclick={() => toggleMenu('margins')} onCaret={() => toggleMenu('margins')} />
<RibbonButton variant="big" icon="margins" label={t().ribbon.margins} title={t().toolbarExpanded.pageMargins} caret active={isMenuOpen('margins')} onclick={() => toggleMenu('margins')} />
{#if isMenuOpen('margins')}
<div class="ribbon-menu margin-menu" use:anchored role="menu">
{#each MARGIN_PRESETS as p}
@@ -106,7 +106,7 @@
</div>
<div class="rb-menu-wrap" use:clickOutside={'orientation'}>
<RibbonButton variant="big" icon="orientation" label={t().toolbarExpanded.orientation} title={t().toolbarExpanded.orientation} caret caretActive={isMenuOpen('orientation')} onclick={() => toggleMenu('orientation')} onCaret={() => toggleMenu('orientation')} />
<RibbonButton variant="big" icon="orientation" label={t().toolbarExpanded.orientation} title={t().toolbarExpanded.orientation} caret active={isMenuOpen('orientation')} onclick={() => toggleMenu('orientation')} />
{#if isMenuOpen('orientation')}
<div class="ribbon-menu" use:anchored role="menu">
{#each (['portrait', 'landscape'] as const) as o}
@@ -117,7 +117,7 @@
</div>
<div class="rb-menu-wrap" use:clickOutside={'pageFormat'}>
<RibbonButton variant="big" icon="pageSize" label={t().ribbon.size} title={t().toolbarExpanded.pageFormat} caret caretActive={isMenuOpen('pageFormat')} onclick={() => toggleMenu('pageFormat')} onCaret={() => toggleMenu('pageFormat')} />
<RibbonButton variant="big" icon="pageSize" label={t().ribbon.size} title={t().toolbarExpanded.pageFormat} caret active={isMenuOpen('pageFormat')} onclick={() => toggleMenu('pageFormat')} />
{#if isMenuOpen('pageFormat')}
<div class="ribbon-menu format-menu" use:anchored role="menu">
<div class="menu-scroll">
@@ -140,9 +140,8 @@
title={hfActive ? t().toolbarExpanded.columnsNotInHf : t().toolbarExpanded.columns}
disabled={!editor || !!hfActive}
caret
caretActive={isMenuOpen('columns')}
active={isMenuOpen('columns')}
onclick={() => toggleMenu('columns')}
onCaret={() => toggleMenu('columns')}
/>
{#if isMenuOpen('columns')}
<div class="ribbon-menu" use:anchored role="menu">
@@ -174,9 +173,8 @@
title={t().ribbon.breaks}
disabled={!editor || !!hfActive}
caret
caretActive={isMenuOpen('breaks')}
active={isMenuOpen('breaks')}
onclick={() => toggleMenu('breaks')}
onCaret={() => toggleMenu('breaks')}
/>
{#if isMenuOpen('breaks')}
<div class="ribbon-menu breaks-menu" use:anchored role="menu">
@@ -271,6 +269,13 @@
gap: 3px 12px;
}
.field span { min-width: 74px; }
/* The label takes whatever its grid column is wide, so the boxes line up on the
column's right edge. A fixed label width only holds until a translation
outgrows it, and then one row of the pair sits deeper than the other. */
.margin-field > span, .field > span {
flex: 1;
white-space: nowrap;
}
.field input:disabled { opacity: 0.5; }
</style>
@@ -48,9 +48,8 @@
title={t().ribbon.tocOptions}
disabled={!toc}
caret
caretActive={isMenuOpen('tocLevels')}
active={isMenuOpen('tocLevels')}
onclick={() => toggleMenu('tocLevels')}
onCaret={() => toggleMenu('tocLevels')}
/>
{#if isMenuOpen('tocLevels')}
<div class="ribbon-menu" use:anchored role="menu">
@@ -3,7 +3,7 @@
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import LanguagePicker from '../../LanguagePicker.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu } from '../menu.svelte';
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu } from '../menu.svelte';
import { countText, type TextStats } from '../../../utils/wordCount';
import type { DocumentLanguage } from '../../../storage/documentLanguage';
import { t } from '../../../i18n/i18n.svelte';
@@ -65,7 +65,7 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.language}>
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<LanguagePicker value={documentLanguage} onChange={onLanguage} />
<span class="rb-caption">{t().spellPicker.label}</span>
</div>
@@ -7,7 +7,7 @@
import TableBorderPicker from '../../TableBorderPicker.svelte';
import TableStylePicker from '../../TableStylePicker.svelte';
import TableSplitDialog from '../../TableSplitDialog.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { isHeaderStyled } from '../../../editor/extensions/tableHeaderRow';
import { DEFAULT_CELL_PADDING, parseCellPadding, type CellPadding } from '../../../editor/extensions/tableCellPadding';
import type { CellVerticalAlign } from '../../../editor/extensions/tableCellAlign';
@@ -85,7 +85,7 @@
{#if which === 'design'}
<RibbonGroup label={t().styles.tableStyles}>
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<TableStylePicker {editor} {tick} />
<span class="rb-caption">{t().table.tableStyle}</span>
</div>
@@ -94,7 +94,7 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.tableDecor}>
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<ColorPicker
{editor}
currentColor={cellColor}
@@ -108,7 +108,7 @@
/>
<span class="rb-caption">{t().table.cellShading}</span>
</div>
<div class="rb-captioned">
<div class="rb-captioned" use:captionClicks>
<TableBorderPicker {editor} {tick} />
<span class="rb-caption">{t().borders.title}</span>
</div>
@@ -186,9 +186,8 @@
title={t().ribbon.cellMargins}
disabled={!padding}
caret
caretActive={isMenuOpen('cellMargins')}
active={isMenuOpen('cellMargins')}
onclick={() => toggleMenu('cellMargins')}
onCaret={() => toggleMenu('cellMargins')}
/>
{#if isMenuOpen('cellMargins') && padding}
<div class="ribbon-menu margin-menu" use:anchored role="menu">
+32 -2
View File
@@ -452,7 +452,38 @@
justify-content: center;
gap: 4px;
padding: 4px 7px 6px;
--toolbar-btn-size: 34px;
border-radius: 4px;
cursor: pointer;
--toolbar-btn-size: 28px;
}
/* Mirrors .rb-big's box, down to the slot it holds open for a caret, so a picker
and a labelled button in the same row put their glyph and their words on one
line. Their triggers carry their own frame and padding, which is dropped. */
/* A picker root is a block, so its inline-flex trigger would sit on a text
baseline and carry the descender gap with it. */
.rb-captioned > * { display: flex; }
.rb-captioned :is(.table-trigger, .char-trigger, .dt-trigger, .tsp-trigger, .bp-trigger, .lang-picker) {
height: 34px;
min-height: 34px;
padding: 0;
margin: 0;
}
.rb-captioned::after {
content: '';
display: block;
height: 10px;
margin-top: -3px;
}
/* The trigger lives in the picker's own component, so the tint goes on the wrapper
instead — glyph and caption light up together, as they do on a ribbon button.
`captionClicks` gives the caption the click to match. */
.rb-captioned:hover { background: var(--w-hover); }
.rb-captioned:hover :is(.table-trigger, .char-trigger, .dt-trigger, .tsp-trigger, .bp-trigger, .color-split) {
background: none;
}
/* The first svg is the glyph; a second one is the trigger's own caret and keeps
@@ -464,7 +495,6 @@
/* The spell-check picker is chromeless for the status bar; in a group it needs
the combo frame and the same box as the labelled buttons beside it. */
.rb-captioned .lang-picker {
height: 34px;
padding: 0 6px;
border: 1px solid var(--w-border-strong);
border-radius: 3px;