partial toolbar: basic writable store approach

This commit is contained in:
Elijah Duffy
2025-07-09 12:41:28 -07:00
parent e953c362cf
commit 414b63e8bc
5 changed files with 177 additions and 113 deletions

View File

@@ -1,94 +0,0 @@
<script lang="ts" module>
type ToolbarRenderable<P extends unknown[] = any[]> =
| { type: 'component'; component: Component; props?: P }
| { type: 'snippet'; snippet: Snippet<P>; props?: P }
| { type: 'string'; value: string };
interface ToolbarButton<P extends unknown[] = any[]> {
type: 'toggle' | 'button';
title: string;
render: ToolbarRenderable<P>;
}
interface ToolbarDivider {
type: 'divider';
}
interface ToolbarGroup {
type: 'group';
items: ToolbarItem[];
}
export type ToolbarItem = ToolbarButton<any> | ToolbarDivider | ToolbarGroup;
type SnippetArgs<T> = T extends Snippet<infer Args extends unknown[]> ? Args : never;
// For snippet-based buttons
export function toolbarItem<S extends Snippet<any>>(item: {
type: 'button' | 'toggle';
title: string;
render: {
type: 'snippet';
snippet: S;
props: SnippetArgs<S>;
};
}): typeof item;
// For component-based buttons (optional, for completeness)
export function toolbarItem<C extends Component>(item: {
type: 'button' | 'toggle';
title: string;
render: {
type: 'component';
component: C;
props: ComponentProps<C>;
};
}): typeof item;
// For other items (divider, group, etc)
export function toolbarItem<T extends ToolbarDivider | ToolbarGroup>(item: T): T;
// Implementation
export function toolbarItem(item: any): any {
return item;
}
</script>
<script lang="ts">
import { type Component, type Snippet, type ComponentProps } from 'svelte';
interface Props {
items: ToolbarItem[];
}
let { items }: Props = $props();
</script>
<div>
{#each items as toolbarItem}
{@render item(toolbarItem)}
{/each}
</div>
{#snippet item(i: ToolbarItem)}
{#if i.type === 'button' || i.type === 'toggle'}
<button class="toolbar-button">
{#if i.render.type === 'component'}
<item.render.component {...i.render.props} />
{:else if i.render.type === 'snippet'}
{@render i.render.snippet(i.render.props)}
{:else if i.render.type === 'string'}
<span>{i.render.value}</span>
{/if}
<span>{i.title}</span>
</button>
{:else if i.type === 'divider'}
<hr class="toolbar-divider" />
{:else if i.type === 'group'}
<div class="toolbar-group">
{#each i.items as groupItem}
{@render item(groupItem)}
{/each}
</div>
{/if}
{/snippet}

78
src/lib/Toolbar.svelte.ts Normal file
View File

@@ -0,0 +1,78 @@
import { writable, type Writable } from 'svelte/store';
export type ToolbarToggleState = 'on' | 'off';
export type ToolbarToggleOptions = {
state?: Writable<ToolbarToggleState>;
group?: string;
};
export class Toolbar {
private _groups: string[] = [];
constructor() {}
group(name: string) {
if (this._groups.includes(name)) {
throw new Error(`Toolbar group "${name}" already exists.`);
}
this._groups.push(name);
return {
['data-sui-toolbar-group']: name
};
}
toggle(opts: ToolbarToggleOptions = {}) {
if (opts.group && !this._groups.includes(opts.group)) {
throw new Error(`Toolbar group "${opts.group}" does not exist.`);
}
const toggleState = opts.state ?? writable<ToolbarToggleState>('off');
const bindDataState = (node: HTMLElement) => {
const unsubscribe = toggleState.subscribe((value) => {
console.log('state changed', value);
node.dataset.state = value;
});
return { destroy: unsubscribe };
};
return {
['data-sui-toolbar-toggle']: opts?.group || '',
onclick: (event: MouseEvent) => {
const target = event.currentTarget as HTMLElement;
if (!target) return;
// Toggle data-state
// const currentState = target.dataset.state || 'off';
// if (opts?.state) {
// opts.state = currentState === 'on' ? 'off' : 'on';
// } else {
// target.dataset.state = currentState === 'on' ? 'off' : 'on';
// }
toggleState.update((current) => (current === 'on' ? 'off' : 'on'));
// opts.state = opts.state === 'on' ? 'off' : 'on';
// If this is a member of a group, find the adjacent toggles and turn them off
if (opts?.group) {
const groupToggles = document.querySelectorAll(
`[data-sui-toolbar-toggle="${opts.group}"]`
) as NodeListOf<HTMLElement>;
groupToggles.forEach((toggle) => {
if (toggle !== target) {
toggle.dataset.state = 'off';
}
});
}
console.log('onlick', opts.group, toggleState);
},
bindDataState, // expose data-state attribute binder
state: toggleState // expose state to consumer
};
}
}

View File

@@ -23,4 +23,4 @@ export { default as TimeInput } from './TimeInput.svelte';
export { default as TimezoneInput } from './TimezoneInput.svelte';
export { default as ToggleGroup } from './ToggleGroup.svelte';
export { default as ToggleSelect } from './ToggleSelect.svelte';
export { type Option, getLabel, getValue } from './util';
export { type Option, type MaybeGetter, getLabel, getValue } from './util';

View File

@@ -1,3 +1,10 @@
/**
* MaybeGetter is a type that can either be a value of type T or a function that returns a value of type T.
* This is useful for cases where you might want to pass a value directly or a function that computes the
* value later, potentially taking advantage of reactivity.
*/
export type MaybeGetter<T> = T | (() => T);
/**
* Generates a unique identifier string unless an identifier is provided.
* If a prefix is provided, it will be prepended to the identifier.

View File

@@ -17,13 +17,23 @@
import TimezoneInput from '$lib/TimezoneInput.svelte';
import ToggleGroup from '$lib/ToggleGroup.svelte';
import ToggleSelect from '$lib/ToggleSelect.svelte';
import Toolbar, { toolbarItem } from '$lib/Toolbar.svelte';
import { BowlFood } from 'phosphor-svelte';
import { Toolbar, type ToolbarToggleState } from '$lib/Toolbar.svelte';
import {
ArrowUUpLeft,
ArrowUUpRight,
TextB,
TextItalic,
TextStrikethrough,
TextUnderline
} from 'phosphor-svelte';
let dateInputValue = $state<Date | undefined>(undefined);
let checkboxValue = $state<CheckboxState>('indeterminate');
let dialogOpen = $state(false);
const toolbar = new Toolbar();
const fontGroup = toolbar.group('font');
const { state: boldState, ...boldToggle } = toolbar.toggle({ group: 'font' });
</script>
<title>sui</title>
@@ -208,21 +218,59 @@
<div class="component">
<p class="title">Toolbar</p>
<Toolbar
items={[
toolbarItem({
type: 'button',
title: 'Button Action',
render: {
type: 'component',
component: BowlFood,
props: {
size: '1.5em'
}
}
})
]}
/>
<div class="my-2">
<p>Bold is enabled: {$boldState}</p>
</div>
<div
class="border-sui-text flex w-full min-w-max items-center gap-4 border-b
bg-white px-3 py-3 text-neutral-700 shadow-xs"
>
<div class="flex items-center gap-1">
<button type="button" class="item" title="Undo" aria-label="undo">
<ArrowUUpLeft size="1.25em" />
</button>
<button type="button" class="item" title="Redo" aria-label="redo">
<ArrowUUpRight size="1.25em" />
</button>
</div>
<div class="bg-sui-text/50 w-[1px] self-stretch"></div>
<div class="flex items-center gap-1" {...fontGroup}>
<button
class="item"
title="Toggle Bold"
aria-label="bold"
{...boldToggle}
use:boldToggle.bindDataState
>
<TextB size="1.25em" />
</button>
<button
class="item"
title="Toggle Italic"
aria-label="italic"
{...toolbar.toggle({ group: 'font' })}
>
<TextItalic size="1.25em" />
</button>
<button
class="item"
title="Toggle Underline"
aria-label="underline"
{...toolbar.toggle({ group: 'font' })}
>
<TextUnderline size="1.25em" />
</button>
<button
class="item"
title="Toggle Strikethrough"
aria-label="strikethrough"
{...toolbar.toggle({ group: 'font' })}
>
<TextStrikethrough size="1.25em" />
</button>
</div>
</div>
</div>
<Dialog
@@ -247,4 +295,29 @@
.component {
@apply mb-6 rounded-lg border p-4;
}
.item {
@apply flex items-center;
padding: theme('spacing.1');
border-radius: theme('borderRadius.md');
&:hover {
background-color: theme('colors.sui-secondary.100');
}
&[data-state='on'] {
background-color: theme('colors.sui-secondary.200');
color: theme('colors.sui-accent.900');
}
/* &:focus {
@apply ring-accent-400 ring-2;
} */
}
.separator {
width: 1px;
background-color: theme('colors.neutral.300');
align-self: stretch;
}
</style>