feat(words): drawer `side` prop (left | right) + overlays auto-mirror

The companion drawer was hard-coded to the right column. Now
`<Words.Drawer side="left">` flips the editor grid so the drawer
takes the start column; the floating overlays (block handle grip,
block inserter "+") automatically render on the OPPOSITE margin so
they don't crash with the drawer's gutter. Default stays `'right'`
so existing consumers see no change.

Wiring:

- Eidos `<Words.Drawer>` gains `side?: 'left' | 'right'` (default
  'right'). The value is passthrough'd as `data-side={side}` on the
  rendered drawer root so CSS + sibling overlays can read it from
  the DOM.

- `words.css`:
    - `[data-words]:has([data-words-drawer][data-side='left'])`
      flips `grid-template-columns` + `grid-template-areas` so the
      drawer column sits at the inline-start.
    - `[data-words-drawer][data-side='left']` swaps the visual
      seam from `border-inline-start` to `border-inline-end`.
    - `[data-words-block-inserter][data-side='left']
      [data-words-block-inserter-button]` overrides `left: -28px`
      to `right: -28px` so the "+" lands on the block's right
      margin.

- Block-handle (`words-block-handle.svelte`): new `detectDrawerSide()`
  reads `[data-words-drawer]@data-side` once per measurement. The
  grip's `position.left` becomes `rect.right + 4` when drawer is
  on the left (was always `rect.left - 28`).

- Block-inserter (`words-block-inserter.svelte`): same `$derived`
  read of the drawer's `data-side`, propagated as `data-side` on
  the inserter overlay element so the CSS rule above can flip the
  "+" button.

- Demo: new `let drawerSide = $state<'left' | 'right'>('right')` +
  a chip control "drawer side: right | left". `<Words.Drawer
  side={drawerSide} />`.

Verified `npm run check` clean (pre-existing errors only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 3128b5f13c
commit 4cd54e2111

@ -37,15 +37,30 @@
return Number.isInteger(n) && n >= 0 ? n : undefined; return Number.isInteger(n) && n >= 0 ? n : undefined;
}); });
/**
* Detect drawer side so the grip auto-flips to the OPPOSITE margin.
* Drawer on right (default) → grip on block's left. Drawer on left
* → grip on block's right (so it doesn't fight the drawer's gutter).
* Read from the DOM each measure — cheap, and reactive to layout
* changes (user toggling side prop).
*/
function detectDrawerSide(): 'left' | 'right' {
const drawer = document.querySelector('[data-words-drawer]');
return drawer?.getAttribute('data-side') === 'left' ? 'left' : 'right';
}
function measureFromEl(el: HTMLElement | null) { function measureFromEl(el: HTMLElement | null) {
if (!el) { if (!el) {
position = null; position = null;
return; return;
} }
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const side = detectDrawerSide();
position = { position = {
top: rect.top + rect.height / 2 - 12, top: rect.top + rect.height / 2 - 12,
left: rect.left - 28 // Drawer on RIGHT → grip on block LEFT (left edge − 28).
// Drawer on LEFT → grip on block RIGHT (right edge + 4).
left: side === 'left' ? rect.right + 4 : rect.left - 28
}; };
} }

@ -210,12 +210,27 @@
}); });
seam = null; seam = null;
} }
/**
* Mirror the drawer's side so the "+" button auto-flips to the
* OPPOSITE gutter (CSS reads `data-side` to swap left:-28 vs
* right:-28). Drawer right (default) → "+" on left margin;
* drawer left → "+" on right margin.
*/
const drawerSide = $derived.by(() => {
// Read reactively each time seam changes — re-evaluating the
// DOM is cheap and the side rarely flips during a session.
if (!seam) return 'right' as const;
const drawer = document.querySelector('[data-words-drawer]');
return drawer?.getAttribute('data-side') === 'left' ? 'left' : 'right';
});
</script> </script>
{#if seam} {#if seam}
<div <div
data-words-block-inserter data-words-block-inserter
data-mode={isDragging ? 'drop' : 'insert'} data-mode={isDragging ? 'drop' : 'insert'}
data-side={drawerSide}
style="top: {seam.y}px; left: {seam.left}px; width: {seam.right - seam.left}px;" style="top: {seam.y}px; left: {seam.left}px; width: {seam.right - seam.left}px;"
role="presentation" role="presentation"
> >

@ -47,9 +47,17 @@
open = $bindable(undefined), open = $bindable(undefined),
defaultOpen = true, defaultOpen = true,
disabled = false, disabled = false,
/**
* Which side of the editor the drawer occupies. Default `'right'`.
* When `'left'`, the editor grid flips so the drawer sits at the
* start of the inline axis; the floating overlays (block handle,
* block inserter "+" button) automatically render on the
* opposite margin to stay out of the drawer's gutter.
*/
side = 'right' as 'left' | 'right',
children: customChildren, children: customChildren,
...rest ...rest
}: Omit<DrawerProps, 'child'> = $props(); }: Omit<DrawerProps, 'child'> & { side?: 'left' | 'right' } = $props();
const MODE_LABELS: Record<WordsDrawerMode, string> = { const MODE_LABELS: Record<WordsDrawerMode, string> = {
default: 'Insert', default: 'Insert',
@ -153,7 +161,7 @@
} }
</script> </script>
<Words.Drawer bind:open {defaultOpen} {disabled} {...rest}> <Words.Drawer bind:open {defaultOpen} {disabled} {...rest} data-side={side}>
{#snippet children(d: DrawerSnippetProps)} {#snippet children(d: DrawerSnippetProps)}
{@const { modes, snippet, toggle: toggleDrawer } = d} {@const { modes, snippet, toggle: toggleDrawer } = d}

@ -125,12 +125,14 @@
opacity: var(--words-disabled-opacity); opacity: var(--words-disabled-opacity);
} }
/* ── Companion Drawer (right-rail) ─────────────────────────────────────── /* ── Companion Drawer ────────────────────────────────────────────────────
Layout: when the editor host contains a [data-words-drawer], place Layout: when the editor host contains a [data-words-drawer], place
the drawer in a dedicated right column that spans toolbar+content+ the drawer in a dedicated column that spans toolbar+content+
status rows. Children without a grid-area placement (BubbleMenu / status rows. Default side is right; `data-side='left'` on the drawer
SlashMenu / HiddenInput / find-replace etc.) are absolute-positioned flips the column order. Children without a grid-area placement
or hidden inputs that don't participate in the visual flow. */ (BubbleMenu / SlashMenu / HiddenInput / find-replace etc.) are
absolute-positioned or hidden inputs that don't participate in the
visual flow. */
[data-words]:has([data-words-drawer]) { [data-words]:has([data-words-drawer]) {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) max-content; grid-template-columns: minmax(0, 1fr) max-content;
@ -142,6 +144,18 @@
column-gap: 0; column-gap: 0;
} }
/* Left-side drawer — flip the column order so the drawer takes the
start column and the editor flow takes the rest. The drawer's
own border-inline-start (toward the content) is what visually
separates them; we swap it to border-inline-end below. */
[data-words]:has([data-words-drawer][data-side='left']) {
grid-template-columns: max-content minmax(0, 1fr);
grid-template-areas:
'drawer toolbar'
'drawer content'
'drawer status';
}
[data-words]:has([data-words-drawer]) > [data-words-toolbar] { [data-words]:has([data-words-drawer]) > [data-words-toolbar] {
grid-area: toolbar; grid-area: toolbar;
} }
@ -172,6 +186,13 @@
transition: inline-size 160ms ease; transition: inline-size 160ms ease;
} }
/* Left-side drawer: the visual seam goes on the inline-end edge
(toward the editor content) instead of inline-start. */
[data-words-drawer][data-side='left'] {
border-inline-start: 0;
border-inline-end: var(--words-border-width) solid var(--words-toolbar-border);
}
[data-words-drawer]:not([data-open]) { [data-words-drawer]:not([data-open]) {
inline-size: 2.25rem; inline-size: 2.25rem;
max-inline-size: 2.25rem; max-inline-size: 2.25rem;
@ -847,6 +868,14 @@
color: var(--_words-accent-on); color: var(--_words-accent-on);
} }
/* When the drawer is on the LEFT side, flip the "+" button to the
block's right margin so it doesn't clash with the drawer's
gutter (mirrors the block-handle grip behaviour). */
[data-words-block-inserter][data-side='left'] [data-words-block-inserter-button] {
left: auto;
right: -28px;
}
[data-words-block-inserter-line] { [data-words-block-inserter-line] {
pointer-events: auto; pointer-events: auto;
flex: 1 1 auto; flex: 1 1 auto;

@ -191,6 +191,7 @@
let showSlashMenu = $state(true); let showSlashMenu = $state(true);
let showStatus = $state(true); let showStatus = $state(true);
let showDrawer = $state(true); let showDrawer = $state(true);
let drawerSide = $state<'left' | 'right'>('right');
let toolbarLayout = $state<'grouped' | 'inline'>('grouped'); let toolbarLayout = $state<'grouped' | 'inline'>('grouped');
let size = $state<WordsSize>('md'); let size = $state<WordsSize>('md');
let variant = $state<WordsVariant>('surface'); let variant = $state<WordsVariant>('surface');
@ -1061,7 +1062,7 @@
{/if} {/if}
<Words.HiddenInput /> <Words.HiddenInput />
{#if showDrawer} {#if showDrawer}
<Words.Drawer /> <Words.Drawer side={drawerSide} />
{/if} {/if}
<Words.ImageFloatBar snippet={snippetProps} /> <Words.ImageFloatBar snippet={snippetProps} />
<Words.BlockHandle snippet={snippetProps} /> <Words.BlockHandle snippet={snippetProps} />
@ -1195,6 +1196,20 @@
{/each} {/each}
</span> </span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>drawer side</span>
<span data-uix-chips role="radiogroup">
{#each ['right', 'left'] as const as item}
<button
data-uix-chip
data-active={drawerSide === item}
onclick={() => (drawerSide = item)}
>
{item}
</button>
{/each}
</span>
</label>
{#each ['disabled', 'readonly', 'required', 'invalid', 'commitOnBlur', 'linkEditor', 'findReplace', 'bubbleMenu', 'slashMenu', 'status'] as key} {#each ['disabled', 'readonly', 'required', 'invalid', 'commitOnBlur', 'linkEditor', 'findReplace', 'bubbleMenu', 'slashMenu', 'status'] as key}
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>{key}</span> <span data-uix-control-label>{key}</span>

Loading…
Cancel
Save

Powered by TurnKey Linux.