partial toolbar: basic writable store approach
This commit is contained in:
@@ -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
78
src/lib/Toolbar.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user