Skip to content

Commit 542997a

Browse files
committed
feat(toast): introduce Toast component with context management and swipe functionality
1 parent 2041c07 commit 542997a

19 files changed

+965
-1
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { shallowRef } from 'vue';
2+
import { useCollection, useContext } from '../../composables';
3+
import type { ToastProviderContextParams, ToastRootContextParams, ToastThemeContextParams } from './types';
4+
5+
export const { provideCollectionContext, useCollectionContext, useCollectionItem } = useCollection('Toast');
6+
7+
export const [provideToastProviderContext, useToastProviderContext] = useContext(
8+
'ToastProvider',
9+
(params: ToastProviderContextParams) => {
10+
const viewportElement = shallowRef<HTMLElement | null>(null);
11+
12+
const onViewportElementChange = (element: HTMLElement | null) => {
13+
viewportElement.value = element;
14+
};
15+
16+
const isFocusedToastEscapeKeyDownRef = shallowRef(false);
17+
const isClosePausedRef = shallowRef(false);
18+
19+
const toastCount = shallowRef(0);
20+
21+
const onToastAdd = () => {
22+
toastCount.value++;
23+
};
24+
25+
const onToastRemove = () => {
26+
toastCount.value--;
27+
};
28+
29+
return {
30+
...params,
31+
viewportElement,
32+
onViewportElementChange,
33+
isFocusedToastEscapeKeyDownRef,
34+
isClosePausedRef,
35+
toastCount,
36+
onToastAdd,
37+
onToastRemove
38+
};
39+
}
40+
);
41+
42+
export const [provideToastRootContext, useToastRootContext] = useContext(
43+
'ToastRoot',
44+
(params: ToastRootContextParams) => params
45+
);
46+
47+
export const [provideToastThemeContext, useToastThemeContext] = useContext(
48+
'ToastTheme',
49+
(params: ToastThemeContextParams) => params
50+
);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export { default as ToastPortal } from '../portal/portal.vue';
2+
export { default as ToastRoot } from './toast-root.vue';
3+
export { default as ToastViewport } from './toast-viewport.vue';
4+
export { default as ToastClose } from './toast-close.vue';
5+
export { default as ToastAction } from './toast-action.vue';
6+
export { default as ToastTitle } from './toast-title.vue';
7+
export { default as ToastDescription } from './toast-description.vue';
8+
9+
export { provideToastThemeContext } from './context';
10+
11+
export type { PortalProps as ToastPortalProps } from '../portal/types';
12+
export type {
13+
ToastProviderProps,
14+
ToastRootProps,
15+
ToastRootEmits,
16+
ToastViewportProps,
17+
ToastCloseProps,
18+
ToastActionProps,
19+
ToastTitleProps,
20+
ToastDescriptionProps,
21+
ToastThemeSlot,
22+
ToastUi
23+
} from './types';
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { isHTMLElement } from '../../shared';
2+
3+
export const toastCssVars = {
4+
swipeMoveX: '--soybean-toast-swipe-move-x',
5+
swipeMoveY: '--soybean-toast-swipe-move-y',
6+
swipeEndX: '--soybean-toast-swipe-end-x',
7+
swipeEndY: '--soybean-toast-swipe-end-y'
8+
};
9+
10+
export const TOAST_DATA_SWIPE = 'data-swipe';
11+
12+
export const toastSwipe = {
13+
start: 'start',
14+
move: 'move',
15+
cancel: 'cancel',
16+
end: 'end'
17+
} as const;
18+
19+
export const TOAST_SWIPE_START = 'toast.swipeStart';
20+
export const TOAST_SWIPE_MOVE = 'toast.swipeMove';
21+
export const TOAST_SWIPE_CANCEL = 'toast.swipeCancel';
22+
export const TOAST_SWIPE_END = 'toast.swipeEnd';
23+
24+
export const VIEWPORT_NAME = 'ToastViewport';
25+
export const VIEWPORT_DEFAULT_HOTKEY = ['F8'];
26+
export const VIEWPORT_PAUSE = 'toast.viewportPause';
27+
export const VIEWPORT_RESUME = 'toast.viewportResume';
28+
29+
export function getAnnounceTextContent(container: HTMLElement) {
30+
const textContent: string[] = [];
31+
const childNodes = Array.from(container.childNodes);
32+
33+
childNodes.forEach(node => {
34+
if (node.nodeType === node.TEXT_NODE && node.textContent) {
35+
textContent.push(node.textContent);
36+
}
37+
38+
if (!isHTMLElement(node)) return;
39+
const isHidden = node.ariaHidden || node.hidden || node.style.display === 'none';
40+
const isExcluded = node.dataset.soybeanToastAnnounceExclude === '';
41+
42+
if (isHidden) return;
43+
44+
if (isExcluded) {
45+
const altText = node.dataset.soybeanToastAnnounceAlt;
46+
if (altText) {
47+
textContent.push(altText);
48+
}
49+
} else {
50+
textContent.push(...getAnnounceTextContent(node));
51+
}
52+
});
53+
// We return a collection of text rather than a single concatenated string.
54+
// This allows SR VO to naturally pause break between nodes while announcing.
55+
return textContent;
56+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { Primitive } from '../primitive';
4+
import { useToastRootContext, useToastThemeContext } from './context';
5+
import type { ToastActionProps } from './types';
6+
7+
defineOptions({
8+
name: 'ToastAction'
9+
});
10+
11+
const props = withDefaults(defineProps<ToastActionProps>(), {
12+
as: 'button'
13+
});
14+
15+
const themeContext = useToastThemeContext();
16+
const { onClose } = useToastRootContext('ToastAction');
17+
18+
const cls = computed(() => themeContext?.ui?.value?.action);
19+
20+
const tag = computed(() => (props.as === 'button' ? 'button' : undefined));
21+
</script>
22+
23+
<template>
24+
<Primitive
25+
:as="as"
26+
:as-child="asChild"
27+
:class="cls"
28+
:type="tag"
29+
:data-soybean-toast-announce-alt="altText"
30+
data-soybean-toast-announce-exclude
31+
@click="onClose"
32+
>
33+
<slot />
34+
</Primitive>
35+
</template>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue';
3+
import { useTimeout } from '@vueuse/shared';
4+
import { useRafFn } from '@vueuse/core';
5+
import { VisuallyHidden } from '../visually-hidden';
6+
import { useToastProviderContext } from './context';
7+
8+
defineOptions({
9+
name: 'ToastAnnounce'
10+
});
11+
12+
const { label } = useToastProviderContext('ToastAnnounce');
13+
14+
const isAnnounced = useTimeout(1000);
15+
const renderAnnounceText = shallowRef(false);
16+
17+
useRafFn(() => {
18+
renderAnnounceText.value = true;
19+
});
20+
</script>
21+
22+
<template>
23+
<VisuallyHidden v-if="isAnnounced || renderAnnounceText">
24+
{{ label }}
25+
<slot />
26+
</VisuallyHidden>
27+
</template>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { Primitive } from '../primitive';
4+
import { useToastRootContext, useToastThemeContext } from './context';
5+
import type { ToastCloseProps } from './types';
6+
7+
defineOptions({
8+
name: 'ToastClose'
9+
});
10+
11+
const props = withDefaults(defineProps<ToastCloseProps>(), {
12+
as: 'button'
13+
});
14+
15+
const themeContext = useToastThemeContext();
16+
const { onClose } = useToastRootContext('ToastClose');
17+
18+
const cls = computed(() => themeContext?.ui?.value?.close);
19+
20+
const tag = computed(() => (props.as === 'button' ? 'button' : undefined));
21+
</script>
22+
23+
<template>
24+
<Primitive :as="as" :as-child="asChild" :class="cls" :type="tag" data-soybean-toast-announce-exclude @click="onClose">
25+
<slot />
26+
</Primitive>
27+
</template>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { useToastThemeContext } from './context';
4+
import type { ToastDescriptionProps } from './types';
5+
6+
defineOptions({
7+
name: 'ToastDescription'
8+
});
9+
10+
defineProps<ToastDescriptionProps>();
11+
12+
const themeContext = useToastThemeContext();
13+
14+
const cls = computed(() => themeContext?.ui?.value?.description);
15+
</script>
16+
17+
<template>
18+
<div :class="cls">
19+
<slot />
20+
</div>
21+
</template>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<script setup lang="ts">
2+
import { VisuallyHidden } from '../visually-hidden';
3+
import { useToastProviderContext } from './context';
4+
import type { ToastFocusProxyEmits } from './types';
5+
6+
defineOptions({
7+
name: 'ToastFocusProxy'
8+
});
9+
10+
const emit = defineEmits<ToastFocusProxyEmits>();
11+
12+
const { viewportElement } = useToastProviderContext('ToastFocusProxy');
13+
14+
const onFocus = (event: FocusEvent) => {
15+
const prevFocusedElement = event.relatedTarget as HTMLElement | null;
16+
const isFocusFromOutsideViewport = !viewportElement.value?.contains(prevFocusedElement);
17+
if (isFocusFromOutsideViewport) {
18+
emit('focusFromOutsideViewport');
19+
}
20+
};
21+
</script>
22+
23+
<template>
24+
<VisuallyHidden aria-hidden="true" tabindex="0" style="position: fixed" @focus="onFocus">
25+
<slot />
26+
</VisuallyHidden>
27+
</template>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script setup lang="ts">
2+
import { transformPropsToContext } from '../../shared';
3+
import { provideCollectionContext, provideToastProviderContext } from './context';
4+
import type { ToastProviderProps } from './types';
5+
6+
defineOptions({
7+
name: 'ToastProvider'
8+
});
9+
10+
const props = withDefaults(defineProps<ToastProviderProps>(), {
11+
label: 'Notification',
12+
duration: 5000,
13+
swipeDirection: 'right',
14+
swipeThreshold: 50
15+
});
16+
17+
provideCollectionContext();
18+
provideToastProviderContext(transformPropsToContext(props));
19+
</script>
20+
21+
<template>
22+
<slot />
23+
</template>

0 commit comments

Comments
 (0)