rework toolbar to use attachment pattern, simpler external control

This commit is contained in:
Elijah Duffy
2025-07-09 18:37:11 -07:00
parent 3f4f021fef
commit b9328dc9a7
2 changed files with 129 additions and 58 deletions

View File

@@ -1,5 +1,5 @@
import { createAttachmentKey, type Attachment } from 'svelte/attachments'; import { createAttachmentKey, type Attachment } from 'svelte/attachments';
import { writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
export type ToolbarToggleState = 'on' | 'off'; export type ToolbarToggleState = 'on' | 'off';
@@ -14,62 +14,98 @@ export type ToolbarToggleOptions = {
const toggleValue = (current: ToolbarToggleState): ToolbarToggleState => const toggleValue = (current: ToolbarToggleState): ToolbarToggleState =>
current === 'on' ? 'off' : 'on'; current === 'on' ? 'off' : 'on';
/** createGenericToggle creates a toggle. If a list of toggles is passed, the toggle is created as a group item */ /**
const createGenericToggle = ( * A single toggleable toolbar item. Can coordinate with other toggles in a group.
toggles: Writable<ToolbarToggleState>[] | null, */
opts: ToolbarToggleOptions = {} export class ToolbarToggle {
) => { private _state: Writable<ToolbarToggleState>;
const toggleState = opts.state ?? writable<ToolbarToggleState>('off'); private _parent: ToolbarGroup | undefined;
// If this is a group, add this toggle to it
if (toggles !== null) toggles.push(toggleState);
return { constructor(opts: ToolbarToggleOptions = {}) {
state: toggleState, this._state = opts.state ?? writable('off');
[createAttachmentKey()]: ((node) => { }
/**
* Creates a new toggleable toolbar item with a parent group.
*
* @param parent The parent group to which this toggle belongs.
* @param opts Options for the toggle.
* @returns A new ToolbarToggle instance.
*/
static withParent(parent: ToolbarGroup, opts: ToolbarToggleOptions = {}) {
const toggle = new ToolbarToggle(opts);
toggle._parent = parent;
return toggle;
}
/**
* Returns the store that manages the state of the toggle.
*/
get store() {
return this._state;
}
/**
* Returns the current state of the toggle.
*/
get state() {
return get(this._state);
}
/**
* Sets the state of the toggle.
*/
set state(value: ToolbarToggleState) {
this._state.set(value);
}
/**
* Returns a Svelte 5 Attachment for the toggle.
*/
get attachment(): Attachment {
return (node) => {
const el = node as HTMLElement; const el = node as HTMLElement;
// Subscribe to store // Subscribe to store
const unsubscribe = toggleState.subscribe((state) => { const unsubscribe = this._state.subscribe((state) => {
el.dataset.state = state; el.dataset.state = state;
}); });
// Add a listener for the click event
el.addEventListener('click', (e: MouseEvent) => {
const target = e.currentTarget as HTMLElement;
if (!target) return;
this._state.update(toggleValue);
// If this is a group, turn off all other toggles
if (this._parent) {
this._parent.disableAll(this);
}
});
return unsubscribe; return unsubscribe;
}) as Attachment, };
onclick: (e: MouseEvent) => { }
const target = e.currentTarget as HTMLElement;
if (!target) return;
toggleState.update(toggleValue); /**
* Returns props including the attachment for the toggle, allowing easy spreading
// If this is a group, turn off all other toggles * to an HTML element. Should NOT be used in addition to manually attaching the toggle.
if (toggles !== null) { *
toggles.forEach((toggle) => { * Can be advantageous as spreading seems to allow CSS tree-shaking to work properly.
if (toggle !== toggleState) { */
toggle.update(() => 'off'); get props() {
} return {
}); [createAttachmentKey()]: this.attachment
} };
} }
}; }
};
/** /**
* A group of toggles in a toolbar. Makes each toggle exclusive of the others, disabling all other * A group of toggles in a toolbar. Makes each toggle exclusive of the others, disabling all other
* toggles when any given toggle is enabled. * toggles when any given toggle is enabled.
*/ */
export class ToolbarGroup { export class ToolbarGroup {
private _name: string; private _toggles: ToolbarToggle[] = [];
private _toggles: Writable<ToolbarToggleState>[] = [];
constructor(name: string) {
this._name = name;
}
/**
* The name of the toolbar group.
*/
get name() {
return this._name;
}
/** /**
* Creates a new toolbar toogle item in the group. * Creates a new toolbar toogle item in the group.
@@ -77,7 +113,35 @@ export class ToolbarGroup {
* @returns object containing state and properties * @returns object containing state and properties
*/ */
toggle(opts: ToolbarToggleOptions = {}) { toggle(opts: ToolbarToggleOptions = {}) {
return createGenericToggle(this._toggles, opts); const toggle = ToolbarToggle.withParent(this, opts);
this._toggles.push(toggle);
return toggle;
}
/**
* Disables all toggles in the group except the one specified.
* @param except - The toggle to exclude from disabling.
*/
disableAll(except?: ToolbarToggle) {
this._toggles.forEach((t) => {
if (t !== except) {
t.state = 'off';
}
});
}
/**
* Enables the specified toggle and disables all other toggles in the group.
* @param toggle - The toggle to enable.
*/
enable(toggle: ToolbarToggle) {
this._toggles.forEach((t) => {
if (t == toggle) {
t.state = 'on';
} else {
t.state = 'off';
}
});
} }
} }
@@ -91,14 +155,10 @@ export class Toolbar {
/** /**
* Creates a new toolbar group. * Creates a new toolbar group.
* @param name The name of the group.
* @returns The newly created toolbar group. * @returns The newly created toolbar group.
*/ */
group(name: string) { group() {
if (this._groups.some((g) => g.name === name)) { const g = new ToolbarGroup();
throw new Error(`Toolbar group "${name}" already exists.`);
}
const g = new ToolbarGroup(name);
this._groups.push(g); this._groups.push(g);
return g; return g;
} }
@@ -109,6 +169,6 @@ export class Toolbar {
* @returns object containing state and properties * @returns object containing state and properties
*/ */
toggle(opts: ToolbarToggleOptions = {}) { toggle(opts: ToolbarToggleOptions = {}) {
return createGenericToggle(null, opts); return new ToolbarToggle(opts);
} }
} }

View File

@@ -32,8 +32,9 @@
let dialogOpen = $state(false); let dialogOpen = $state(false);
const toolbar = new Toolbar(); const toolbar = new Toolbar();
const fontGroup = toolbar.group('font'); const fontGroup = toolbar.group();
const { state: boldState, ...boldProps } = fontGroup.toggle(); const boldToggle = fontGroup.toggle();
const boldStore = boldToggle.store;
</script> </script>
<title>sui</title> <title>sui</title>
@@ -219,7 +220,7 @@
<p class="title">Toolbar</p> <p class="title">Toolbar</p>
<div class="my-2"> <div class="my-2">
<p>Bold is enabled: {$boldState}</p> <p>Bold is enabled: {$boldStore}</p>
</div> </div>
<div <div
@@ -236,20 +237,30 @@
</div> </div>
<div class="bg-sui-text/50 w-[1px] self-stretch"></div> <div class="bg-sui-text/50 w-[1px] self-stretch"></div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button class="item" title="Toggle Bold" aria-label="bold" {...boldProps}> <button class="item" title="Toggle Bold" aria-label="bold" {@attach boldToggle.attachment}>
<TextB size="1.25em" /> <TextB size="1.25em" />
</button> </button>
<button class="item" title="Toggle Italic" aria-label="italic" {...fontGroup.toggle()}> <button
class="item"
title="Toggle Italic"
aria-label="italic"
{@attach fontGroup.toggle().attachment}
>
<TextItalic size="1.25em" /> <TextItalic size="1.25em" />
</button> </button>
<button class="item" title="Toggle Underline" aria-label="underline" {...fontGroup.toggle()}> <button
class="item"
title="Toggle Underline"
aria-label="underline"
{@attach fontGroup.toggle().attachment}
>
<TextUnderline size="1.25em" /> <TextUnderline size="1.25em" />
</button> </button>
<button <button
class="item" class="item"
title="Toggle Strikethrough" title="Toggle Strikethrough"
aria-label="strikethrough" aria-label="strikethrough"
{...fontGroup.toggle()} {...fontGroup.toggle().props}
> >
<TextStrikethrough size="1.25em" /> <TextStrikethrough size="1.25em" />
</button> </button>