Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] [DRAFT] feat: Toaster Svelte #1088

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions core/src/components/toast/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ interface ToastExtraProps {
* @defaultValue `5000`
*/
delay: number;
/**
* Directive passed to the Toast so that a parent can intercept the events, for example to pause the dismiss timer.
*/
eventsDirective?: Directive;
/**
* ID
*/
id?: number;
}

interface ExtraDirectives {
Expand Down Expand Up @@ -64,6 +72,8 @@ export type ToastWidget = Widget<ToastProps, ToastState, ToastApi, ToastDirectiv
const toastDefaultConfig: ToastExtraProps = {
autoHide: true,
delay: 5000,
id: undefined,
eventsDirective: undefined,
};

const toastConfigValidator: ConfigValidator<ToastExtraProps> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import sampleDefault from '@agnos-ui/samples/bootstrap/toast/default';
import sampleDynamic from '@agnos-ui/samples/bootstrap/toast/dynamic';
import sampleAction from '@agnos-ui/samples/bootstrap/toast/action';
import sampleToaster from '@agnos-ui/samples/bootstrap/toast/toaster';
import Sample from '$lib/layout/Sample.svelte';
import Section from '$lib/layout/Section.svelte';
import Accessibility from './accessibility.svelte';
Expand All @@ -16,6 +17,9 @@
<Section label="Toast with action" id="action" level={2}>
<Sample title="Toast with action" sample={sampleAction} height={215} />
</Section>
<Section label="Toaster" id="toaster" level={2}>
<Sample title="Toaster example" sample={sampleToaster} height={600} />
</Section>
<Section label="Accessibility" id="accessibility" level={2}>
<Accessibility />
</Section>
1 change: 1 addition & 0 deletions svelte/bootstrap/src/components/toast/Toast.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
use:transitionDirective
use:autoHideDirective
use:bodyDirective
use:state.eventsDirective={state.id}
>
<Slot content={state.structure} props={widget} />
</div>
Expand Down
31 changes: 31 additions & 0 deletions svelte/bootstrap/src/components/toast/Toaster.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script lang="ts" module>
import Toast from './Toast.svelte';
import {ToasterService} from '../../services/toasterService.svelte';
const toaster = new ToasterService();
const {toasts, addToast, removeToast, options, eventsDirective} = toaster;
export {addToast};
export {options as toasterOptions};
</script>

<div class="d-flex mt-2 mr-2 w-100" aria-live="polite" aria-atomic="true">
<div class={`toast-container p-3 ${options.position}`}>
{#if options.closeAll && toasts.length > 1}
<div class="d-flex position-relative align-items-end pb-2">
<button class="btn btn-secondary me-0 ms-auto pe-auto" on:click={() => toaster.closeAll()}>{options.closeAllLabel || 'Close all'}</button>
</div>
{/if}
{#each toasts as toast (toast.id)}
{@const {structure, children, header} = toast.props}
<Toast
autoHide={false}
dismissible={options.dismissible}
{eventsDirective}
id={toast.id}
{structure}
{children}
{header}
onHidden={() => removeToast(toast.id)}
/>
{/each}
</div>
</div>
4 changes: 3 additions & 1 deletion svelte/bootstrap/src/components/toast/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Toast from './Toast.svelte';

export * from './Toaster.svelte';
import Toaster from './Toaster.svelte';
export * from './toast.gen';
export {Toast};
export {Toaster};
2 changes: 2 additions & 0 deletions svelte/bootstrap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ export * from './components/slider';
export * from './components/toast';
export * from './components/tree';

export * from './services/toasterService.svelte';

export * from './generated';
6 changes: 6 additions & 0 deletions svelte/bootstrap/src/services/toasterService.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type {ToastProps} from '@agnos-ui/core-bootstrap/components/toast';
import {Toaster as headlessToaster} from '@agnos-ui/svelte-headless/services/toasterService.svelte';
import type {ToasterProps} from '@agnos-ui/svelte-headless/services/toasterService.svelte';

export class ToasterService extends headlessToaster<Partial<Pick<ToastProps, 'children' | 'header' | 'structure'>>> {}
export type {ToasterProps};
40 changes: 40 additions & 0 deletions svelte/demo/src/bootstrap/samples/toast/Toaster.route.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script lang="ts">
import {addToast, toasterOptions, Toaster} from '@agnos-ui/svelte-bootstrap/components/toast';
import {ToastPositions} from '@agnos-ui/common/samples/toast/toast-positions.enum';
const positions = Object.entries(ToastPositions).map((entry) => {
return {
value: entry[1],
label: entry[0],
};
});
let index = 0;
</script>

<p class="mb-2">Similar to dynamic stacking, we expose a service to ease the usage of Toasts</p>
<div class="d-flex flex-column justify-content-between gap-2">
<div class="d-flex form-group align-items-center gap-3">
<label class="me-3" for="positionSelect">Position: </label>
<select id="positionSelect" class="form-select w-auto" bind:value={toasterOptions.position}>
{#each positions as { value, label }}
<option {value}> {label}</option>
{/each}
</select>
<label class="me-3" for="dismissible">Dismissible: </label>
<input type="checkbox" class="form-check-input" id="dismissible" bind:checked={toasterOptions.dismissible} />
<label class="me-3" for="duration">Duration: </label>
<input id="duration" type="number" class="form-control w-auto" bind:value={toasterOptions.duration} />
</div>
<div class="d-flex form-group align-items-center gap-3">
<label class="me-3" for="pause">Pause timer on hover: </label>
<input type="checkbox" class="form-check-input" id="pause" bind:checked={toasterOptions.pauseOnHover} />
<label class="me-3" for="closeAll">Close all toasts button</label>
<input type="checkbox" class="form-check-input" id="closeAll" bind:checked={toasterOptions.closeAll} />
<button class="btn btn-primary addToast ms-2" onclick={() => addToast({children: `Simple toast ${index++}`, header: 'I am header'})}
>Show toast</button
>
</div>
</div>

<div style="height: 500px; background-color: gray;">
<Toaster />
</div>
3 changes: 2 additions & 1 deletion svelte/headless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
},
"scripts": {
"build": "wireit",
"check": "wireit"
"check": "wireit",
"test": "vitest run"
},
"wireit": {
"generate:exports": {
Expand Down
1 change: 1 addition & 0 deletions svelte/headless/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './generated';
export * from './services/toasterService.svelte';
170 changes: 170 additions & 0 deletions svelte/headless/src/services/toasterService.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {createAttributesDirective} from '@agnos-ui/core/utils/directive';
import {type ToastProps} from '@agnos-ui/core/components/toast';

export enum ToastPositions {
topLeft = 'top-0 start-0',
topCenter = 'top-0 start-50 translate-middle-x',
topRight = 'top-0 end-0',
middleLeft = 'top-50 start-0 translate-middle-y',
middleCenter = 'top-50 start-50 translate-middle',
middleRight = 'top-50 end-0 translate-middle-y',
bottomLeft = 'bottom-0 start-0',
bottomCenter = 'bottom-0 start-50 translate-middle-x',
bottomRight = 'bottom-0 end-0',
}

/**
* Props of the toaster
*/
export interface ToasterProps {
/** How much time a toast is displayed; 0 means it won't be removed until a manual action */
duration: number;
/** Where to position the toasts */
position: ToastPositions;
/** Limit the number of toasts at the same time */
limit?: number;
/** Pause toast when hover??? */
pauseOnHover?: boolean;
/** Display a dismiss button on each toast. When duration = 0, this is enforced to true */
dismissible: boolean;
/** Add a button to close all the toasts at once */
closeAll?: boolean;
/** Close all label */
closeAllLabel?: string;
}

/**
* Toast object
* @template Props Type of the toast properties.
*/
export interface Toast<Props> {
/** Identifier of the toasts in the toaster */
id: number;
/** Properties of the toast */
props: Props;
}

const defaults: ToasterProps = {
duration: 5000,
position: ToastPositions.bottomRight,
dismissible: false,
};

/**
* Create a toaster provider with helpers and state.
* @param props Options for the toaster.
* @template Props Type of the toast properties.
* @returns {} Helpers and state for the toaster.
*/
export class Toaster<Props = ToastProps> {
idCount = 0;
readonly #toasts: Toast<Props>[] = $state([]);
options: ToasterProps = $state(defaults);
readonly #timers = $state(new Map<number, {timeout: ReturnType<typeof setTimeout>; started: number; paused?: number; duration?: number}>());
constructor(props?: Partial<ToasterProps>) {
this.options = {
...defaults,
...props,
};
$effect.root(() => {
$effect(() => this.updateToasts());
});
}

/**
* Get the toasts in the toaster
* @returns Toasts
*/
get toasts() {
return this.#toasts;
}

/**
* Add timer for a toast
* @param id Id of the toast
* @param duration Duration of the timer, by default taken from options
*/
addTimer = (id: number, duration = this.options.duration) => {
if (duration > 0) {
this.#timers.set(id, {
timeout: setTimeout(() => this.removeToast(id), duration),
started: performance.now(),
});
}
};

/**
* Pause a timer for a toast
* @param id Id of the toast
*/
pauseTimer = (id: number) => {
if (this.#timers.has(id)) {
const timer = this.#timers.get(id);
if (timer) {
clearTimeout(timer.timeout);
timer.paused = performance.now();
}
}
};

/**
* Resume a timer for a toast
* @param id Id of the toast
*/
resumeTimer = (id: number) => {
if (this.#timers.has(id)) {
const timer = this.#timers.get(id);
if (timer) {
const paused = timer.paused ?? timer.started;
const elapsed = paused - timer.started - (timer.duration ?? 0);
const remaining = this.options.duration - elapsed;
this.addTimer(id, remaining);
timer.duration = (timer.duration ?? 0) + performance.now() - paused;
timer.paused = undefined;
}
}
};

/**
* Events directive is used to set events on the Toast component, to keep track for example of pointer enter/leave,
* used to pause / resume the timer in case of duration and pauseOnHover are specified.
*/
eventsDirective = createAttributesDirective<number>((id) => ({
events: {
pointerenter: () => this.options.pauseOnHover && this.pauseTimer(id()),
pointerleave: () => this.options.pauseOnHover && this.resumeTimer(id()),
},
}));

/**
* Helper to add a toast to the viewport.
* @param props Options for the toast. //TODO: Simplify ans expose only useful properties to avoid re-exporting the service
*/
addToast = (props: Props) => {
this.#toasts.push({id: this.idCount++, props});
this.addTimer(this.idCount - 1);
};

/**
* Helper to remove a toast to the viewport.
* @param id Id of the toast to remove.
*/
removeToast = (id: number) => {
this.#toasts.splice(
this.#toasts.findIndex((t) => t.id === id),
1,
);
};

/** Helper to update toasts when options change */
updateToasts = () => {
if (this.options.duration === 0) {
this.options.dismissible = true;
}
};

/** Helper to close all toasts at once */
closeAll() {
this.#toasts.splice(0, this.#toasts.length);
}
}
Loading