Skip to content
Open
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
86 changes: 86 additions & 0 deletions projects/client/i18n/meta/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10592,6 +10592,92 @@
"label_genres_drawer_all": {
"default": "All Genres",
"description": "Section label above the full genre catalog in the genres drawer."
},
"action_toast_added_to_watchlist": {
"default": "<b>{title}</b> was added to your watchlist",
"description": "Confirmation toast shown after adding a title to the watchlist. {title} is the movie or show title.",
"variables": {
"title": {
"type": "string"
}
}
},
"action_toast_added_to_watchlist_generic": {
"default": "Added to your watchlist",
"description": "Confirmation toast shown after adding to the watchlist when no single title is available (e.g. bulk actions)."
},
"action_toast_removed_from_watchlist": {
"default": "<b>{title}</b> was removed from your watchlist",
"description": "Confirmation toast shown after removing a title from the watchlist. {title} is the movie or show title.",
"variables": {
"title": {
"type": "string"
}
}
},
"action_toast_removed_from_watchlist_generic": {
"default": "Removed from your watchlist",
"description": "Confirmation toast shown after removing from the watchlist when no single title is available."
},
"action_toast_action_change_list": {
"default": "Change list",
"description": "Inline link in the watchlist confirmation toast that opens the manage-lists drawer to move the item to a different list."
},
"action_toast_label_change_list": {
"default": "Change the lists {title} belongs to",
"description": "Accessible label for the 'change list' link in the watchlist confirmation toast. {title} is the movie or show title.",
"variables": {
"title": {
"type": "string"
}
}
},
"action_toast_action_failed": {
"default": "That didn't work. Please try again.",
"description": "Confirmation toast shown when an action offered by a previous toast, such as Undo, fails."
},
"action_toast_label_undo": {
"default": "Undo the last change",
"description": "Accessible label for the 'Undo' button in an action-confirmation toast."
},
"action_toast_added_to_favorites": {
"default": "<b>{title}</b> was added to your favorites",
"description": "Confirmation toast shown after adding a title to favorites. {title} is the movie or show title.",
"variables": {
"title": {
"type": "string"
}
}
},
"action_toast_removed_from_favorites": {
"default": "<b>{title}</b> was removed from your favorites",
"description": "Confirmation toast shown after removing a title from favorites. {title} is the movie or show title.",
"variables": {
"title": {
"type": "string"
}
}
},
"action_toast_removed_from_history": {
"default": "<b>{title}</b> was removed from your history",
"description": "Confirmation toast shown after removing a title from watch history. {title} is the movie, show, or episode title.",
"variables": {
"title": {
"type": "string"
}
}
},
"action_toast_removed_from_history_generic": {
"default": "Removed from your history",
"description": "Confirmation toast shown after removing from watch history when no single title is available."
},
"preview_feature_title_action_confirmations": {
"default": "Action confirmations",
"description": "Title for the action-confirmations preview feature toggle in settings."
},
"preview_feature_description_action_confirmations": {
"default": "Show a confirmation toast with a quick undo when you add, remove, rate, or mark something as watched.",
"description": "Description for the action-confirmations preview feature toggle in settings."
}
}
}
26 changes: 26 additions & 0 deletions projects/client/src/lib/components/snackbar/Snackbar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import SnackbarMountHarness from '$test/beds/snackbar/SnackbarMountHarness.svelte';
import { render } from '@testing-library/svelte';
import { tick } from 'svelte';
import { describe, expect, it, vi } from 'vitest';

describe('component: Snackbar', () => {
it('should play a fly transition when a parent block mounts it (not just an open toggle)', async () => {
// Svelte drives `css` transitions through the Web Animations API.
const animate = vi.spyOn(Element.prototype, 'animate');

const { rerender, container } = render(SnackbarMountHarness, {
props: { show: false },
});

expect(container.querySelector('.trakt-snackbar')).toBeNull();
animate.mockClear();

await rerender({ show: true });
await tick();

expect(container.querySelector('.trakt-snackbar')).not.toBeNull();
expect(animate).toHaveBeenCalled();

animate.mockRestore();
});
});
30 changes: 28 additions & 2 deletions projects/client/src/lib/components/snackbar/Snackbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@
import AutoCloseButton from "$lib/components/buttons/AutoCloseButton.svelte";
import Button from "$lib/components/buttons/Button.svelte";
import MessageWithLink from "$lib/components/link/MessageWithLink.svelte";
import MessageWithBold from "$lib/components/text/MessageWithBold.svelte";
import { m } from "$lib/features/i18n/messages.ts";
import {
useMedia,
WellKnownMediaQuery,
} from "$lib/stores/css/useMedia.ts";
import { onMount } from "svelte";
import { backOut, cubicIn } from "svelte/easing";
import { fly } from "svelte/transition";

type SnackbarAction = {
text: string;
label: string;
onAction: () => void;
style?: "outline" | "flat";
};

type SnackbarProps = {
Expand All @@ -36,6 +43,20 @@

let navbarHeight = $state(0);

const prefersReducedMotion = useMedia(WellKnownMediaQuery.reducedMotion);

const enterTransition = $derived(
$prefersReducedMotion
? { y: 0, duration: 120 }
: { y: 150, duration: 400, easing: backOut },
);

const exitTransition = $derived(
$prefersReducedMotion
? { y: 0, duration: 120 }
: { y: 150, duration: 250, easing: cubicIn },
);

onMount(() => {
let currentNavbar: Element | null = null;
let resizeObserver: ResizeObserver | undefined;
Expand Down Expand Up @@ -78,6 +99,7 @@
size="small"
variant="primary"
color="purple"
style={action.style ?? "flat"}
label={action.label}
onclick={action.onAction}
>
Expand All @@ -94,7 +116,8 @@
aria-atomic="true"
style="bottom: {navbarHeight}px;"
data-variant={variant}
transition:fly={{ y: 20, duration: 200 }}
in:fly|global={enterTransition}
out:fly|global={exitTransition}
>
{#if title}
<span class="bold">{title}</span>
Expand All @@ -105,7 +128,7 @@
{#if href}
<MessageWithLink {message} {href} target="_blank" />
{:else}
{message}
<MessageWithBold {message} />
{/if}
</p>
{@render actionButton()}
Expand All @@ -122,6 +145,8 @@
@use "$style/scss/mixins/index" as *;

.trakt-snackbar {
--color-outline-stroke: var(--color-snackbar-action-stroke);

position: fixed;
left: 50%;
transform: translateX(-50%);
Expand All @@ -136,6 +161,7 @@
gap: var(--gap-micro);

background-color: var(--color-modal-background);
border: var(--ni-1) solid var(--color-border);
border-radius: var(--border-radius-l);
box-shadow: var(--shadow-raised);
backdrop-filter: blur(var(--ni-16));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { render, waitFor } from '@testing-library/svelte';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ActionToastHost from './ActionToastHost.svelte';
import { actionToastStore } from './_internal/actionToastStore.ts';

vi.mock('$lib/features/feature-flag/useFeatureFlag.ts', () => ({
useFeatureFlag: () => ({ isEnabled: () => of(true) }),
}));

describe('component: ActionToastHost', () => {
beforeEach(() => {
actionToastStore.dismiss();
});

it('should surface an error toast when the action handler rejects', async () => {
const { container, getByLabelText } = render(ActionToastHost);

actionToastStore.notify({
message: 'Removed from your history',
action: {
text: 'Undo',
label: 'Undo removing from history',
onAction: () => Promise.reject(new Error('nope')),
},
});

await waitFor(() => expect(getByLabelText('Undo removing from history')));
getByLabelText('Undo removing from history').click();

await waitFor(() =>
expect(container.querySelector('[data-variant="error"]')).not.toBeNull()
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<script lang="ts">
import Snackbar from "$lib/components/snackbar/Snackbar.svelte";
import { FeatureFlag } from "$lib/features/feature-flag/models/FeatureFlag.ts";
import { useFeatureFlag } from "$lib/features/feature-flag/useFeatureFlag.ts";
import { m } from "$lib/features/i18n/messages.ts";
import { actionToastStore } from "./_internal/actionToastStore.ts";
import { ACTION_TOAST_DURATION } from "./constants/index.ts";

const { isEnabled } = useFeatureFlag();
const isActionConfirmationsEnabled = isEnabled(
FeatureFlag.ActionConfirmations,
);

$effect(() => {
actionToastStore.setEnabled($isActionConfirmationsEnabled);
});

const toast = $derived($actionToastStore);

const dismiss = () => actionToastStore.dismiss();

const snackbarAction = $derived.by(() => {
const action = toast?.action;
if (!action) {
return undefined;
}

return {
text: action.text,
label: action.label,
style: "outline" as const,
onAction: async () => {
// Dismiss first: the handler may queue a follow-up toast.
actionToastStore.dismiss();

try {
await action.onAction();
} catch {
actionToastStore.notify({
message: m.action_toast_action_failed(),
variant: 'error',
});
}
},
};
});
</script>

{#if toast}
{#key toast.id}
<Snackbar
open
onDismiss={dismiss}
message={toast.message}
action={snackbarAction}
variant={toast.variant}
dismissDurationMs={ACTION_TOAST_DURATION}
/>
{/key}
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { BehaviorSubject } from 'rxjs';
import type { ActionToast } from '../models/ActionToast.ts';

function createActionToastStore() {
const current = new BehaviorSubject<ActionToast | null>(null);

let isEnabled = false;

return {
subscribe: current.subscribe.bind(current),

setEnabled: (value: boolean) => {
isEnabled = value;
if (!value) {
current.next(null);
}
},

notify: (toast: Omit<ActionToast, 'id'>) => {
if (!isEnabled) {
return;
}

current.next({ ...toast, id: crypto.randomUUID() });
},

dismiss: () => current.next(null),
};
}

export const actionToastStore = createActionToastStore();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { time } from '$lib/utils/timing/time.ts';

export const ACTION_TOAST_DURATION = time.seconds(6);
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type ActionToastAction = {
text: string;
label: string;
onAction: () => void | Promise<void>;
};

export type ActionToast = {
id: string;
message: string;
action?: ActionToastAction;
variant?: 'default' | 'error';
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import { toGatedNotify } from './toGatedNotify.ts';

describe('util: toGatedNotify', () => {
it('should forward the toast when enabled', () => {
const notify = vi.fn();

toGatedNotify(notify, true)({ message: 'hello' });

expect(notify).toHaveBeenCalledWith({ message: 'hello' });
});

it('should swallow the toast when disabled', () => {
const notify = vi.fn();

toGatedNotify(notify, false)({ message: 'hello' });

expect(notify).not.toHaveBeenCalled();
});
});
13 changes: 13 additions & 0 deletions projects/client/src/lib/features/action-toast/toGatedNotify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ActionToast } from './models/ActionToast.ts';

type Notify = (toast: Omit<ActionToast, 'id'>) => void;

export function toGatedNotify(notify: Notify, isEnabled: boolean): Notify {
return (toast) => {
if (!isEnabled) {
return;
}

notify(toast);
};
}
Loading
Loading