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 TimezoneInput } from './TimezoneInput.svelte';
|
||||||
export { default as ToggleGroup } from './ToggleGroup.svelte';
|
export { default as ToggleGroup } from './ToggleGroup.svelte';
|
||||||
export { default as ToggleSelect } from './ToggleSelect.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.
|
* Generates a unique identifier string unless an identifier is provided.
|
||||||
* If a prefix is provided, it will be prepended to the identifier.
|
* If a prefix is provided, it will be prepended to the identifier.
|
||||||
|
|||||||
@@ -17,13 +17,23 @@
|
|||||||
import TimezoneInput from '$lib/TimezoneInput.svelte';
|
import TimezoneInput from '$lib/TimezoneInput.svelte';
|
||||||
import ToggleGroup from '$lib/ToggleGroup.svelte';
|
import ToggleGroup from '$lib/ToggleGroup.svelte';
|
||||||
import ToggleSelect from '$lib/ToggleSelect.svelte';
|
import ToggleSelect from '$lib/ToggleSelect.svelte';
|
||||||
import Toolbar, { toolbarItem } from '$lib/Toolbar.svelte';
|
import { Toolbar, type ToolbarToggleState } from '$lib/Toolbar.svelte';
|
||||||
|
import {
|
||||||
import { BowlFood } from 'phosphor-svelte';
|
ArrowUUpLeft,
|
||||||
|
ArrowUUpRight,
|
||||||
|
TextB,
|
||||||
|
TextItalic,
|
||||||
|
TextStrikethrough,
|
||||||
|
TextUnderline
|
||||||
|
} from 'phosphor-svelte';
|
||||||
|
|
||||||
let dateInputValue = $state<Date | undefined>(undefined);
|
let dateInputValue = $state<Date | undefined>(undefined);
|
||||||
let checkboxValue = $state<CheckboxState>('indeterminate');
|
let checkboxValue = $state<CheckboxState>('indeterminate');
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
|
|
||||||
|
const toolbar = new Toolbar();
|
||||||
|
const fontGroup = toolbar.group('font');
|
||||||
|
const { state: boldState, ...boldToggle } = toolbar.toggle({ group: 'font' });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<title>sui</title>
|
<title>sui</title>
|
||||||
@@ -208,21 +218,59 @@
|
|||||||
<div class="component">
|
<div class="component">
|
||||||
<p class="title">Toolbar</p>
|
<p class="title">Toolbar</p>
|
||||||
|
|
||||||
<Toolbar
|
<div class="my-2">
|
||||||
items={[
|
<p>Bold is enabled: {$boldState}</p>
|
||||||
toolbarItem({
|
</div>
|
||||||
type: 'button',
|
|
||||||
title: 'Button Action',
|
<div
|
||||||
render: {
|
class="border-sui-text flex w-full min-w-max items-center gap-4 border-b
|
||||||
type: 'component',
|
bg-white px-3 py-3 text-neutral-700 shadow-xs"
|
||||||
component: BowlFood,
|
>
|
||||||
props: {
|
<div class="flex items-center gap-1">
|
||||||
size: '1.5em'
|
<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>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -247,4 +295,29 @@
|
|||||||
.component {
|
.component {
|
||||||
@apply mb-6 rounded-lg border p-4;
|
@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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user