Skip to content

Commit 2d0db2a

Browse files
authored
feat(AlertDialog): New component (#8)
1 parent 717882e commit 2d0db2a

File tree

8 files changed

+257
-0
lines changed

8 files changed

+257
-0
lines changed

docs/components/TheHeader.vue

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const dataLinks: DropdownItem[] = [
2121
const layoutLinks: DropdownItem[] = [{ label: 'Marquee', to: '/marquee' }];
2222
2323
const overlayLinks: DropdownItem[] = [
24+
{ label: 'Alert Dialog', to: '/alert-dialog' },
2425
{ label: 'Dialog', to: '/dialog' },
2526
{ label: 'Slideover', to: '/slideover' },
2627
{ label: 'Tooltip', to: '/tooltip' },

docs/pages/alert-dialog.vue

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<script setup lang="ts">
2+
import { UiAlertDialog, UiContainer } from '#components';
3+
4+
const wait = () => new Promise((resolve) => setTimeout(resolve, 2000));
5+
6+
async function onConfirm() {
7+
console.log('Starting action');
8+
await wait();
9+
console.log('Completed action!');
10+
}
11+
</script>
12+
13+
<template>
14+
<UiContainer class="py-8">
15+
<h1 class="demo-page-title">Alert Dialog</h1>
16+
<p class="demo-page-description">
17+
A modal dialog that interrupts the user with important content and expects a response.
18+
</p>
19+
20+
<div class="demo-category-container mt-4 items-start">
21+
<span class="demo-category-title">Demo</span>
22+
23+
<UiAlertDialog
24+
variant="danger"
25+
title="Confirm delete"
26+
description="Do you really want to delete this item? This action cannot be undone"
27+
:confirm-btn="{ label: 'Confirm', action: onConfirm, variant: 'black-solid' }"
28+
:cancel-btn="{ label: 'Nevermind', variant: 'black-ghost' }"
29+
>
30+
<template #trigger>
31+
<UiButton label="Delete item" class="mt-2" />
32+
</template>
33+
</UiAlertDialog>
34+
</div>
35+
</UiContainer>
36+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<script setup lang="ts">
2+
// @ts-expect-error
3+
import appConfig from '#build/app.config';
4+
5+
import UiButton from '#ui/components/elements/Button.vue';
6+
import { useUI } from '#ui/composables/useUI';
7+
import type { AlertDialogProps, Strategy, UiOverlayEmits } from '#ui/types';
8+
import { alertDialog } from '#ui/ui.config';
9+
import { mergeConfig } from '#ui/utils';
10+
import { uiToTransitionProps } from '#ui/utils/transitions';
11+
import { usePreferredReducedMotion, useVModel } from '@vueuse/core';
12+
import { AlertDialog } from 'radix-vue/namespaced';
13+
import { computed, defineOptions, ref, toRef, withDefaults } from 'vue';
14+
15+
const config = mergeConfig<typeof alertDialog>(
16+
appConfig.ui?.alertDialog?.strategy,
17+
appConfig.ui?.alertDialog,
18+
alertDialog,
19+
);
20+
type UiConfig = Partial<typeof config> & { strategy?: Strategy };
21+
22+
defineOptions({ inheritAttrs: false });
23+
24+
const props = withDefaults(defineProps<AlertDialogProps<UiConfig>>(), {
25+
open: undefined,
26+
ui: () => ({}) as UiConfig,
27+
});
28+
29+
const emits = defineEmits<{ (e: 'update:open', value: boolean): void } & UiOverlayEmits>();
30+
31+
const $open = useVModel(props, 'open', emits, {
32+
defaultValue: props.defaultOpen,
33+
passive: (props.open === undefined) as any,
34+
});
35+
36+
const { ui } = useUI('alertDialog', toRef(props, 'ui'), config);
37+
38+
// With config defaults
39+
const variant = computed(() => props.variant ?? ui.value.default.variant);
40+
const iconName = computed(() => props.icon ?? ui.value.variant[variant.value].icon);
41+
42+
// Disable transitions when prefered reduced motion
43+
const reduceMotion = usePreferredReducedMotion();
44+
const contentTransition = computed(() =>
45+
reduceMotion.value === 'no-preference' ? uiToTransitionProps(ui.value.transition) : {},
46+
);
47+
const overlayTransition = computed(() =>
48+
reduceMotion.value === 'no-preference' ? uiToTransitionProps(ui.value.overlay.transition) : {},
49+
);
50+
51+
// Trigger functionality
52+
const loading = ref(false);
53+
54+
async function handleConfirm() {
55+
if (!props.confirmBtn?.action) return;
56+
57+
loading.value = true;
58+
59+
try {
60+
await props.confirmBtn.action();
61+
$open.value = false;
62+
} catch (error) {
63+
console.error('Unhandled error on alert dialog:', error);
64+
}
65+
66+
loading.value = false;
67+
}
68+
</script>
69+
70+
<template>
71+
<AlertDialog.Root v-model:open="$open">
72+
<AlertDialog.Trigger v-if="$slots.trigger" as-child>
73+
<slot name="trigger" :open="$open" />
74+
</AlertDialog.Trigger>
75+
76+
<AlertDialog.Portal>
77+
<Transition v-bind="overlayTransition">
78+
<AlertDialog.Overlay :class="ui.overlay.base" />
79+
</Transition>
80+
81+
<Transition
82+
v-bind="contentTransition"
83+
@before-enter="emits('before-enter')"
84+
@after-enter="emits('after-enter')"
85+
@before-leave="emits('before-leave')"
86+
@after-leave="emits('after-leave')"
87+
>
88+
<AlertDialog.Content :class="[ui.container, ui.layout, ui.size, ui.padding]">
89+
<div :class="[ui.icon.container, ui.icon.rounded, ui.variant[variant].color]">
90+
<UiIcon :name="iconName" :class="ui.icon.size" />
91+
</div>
92+
93+
<div class="flex-1">
94+
<AlertDialog.Title :class="ui.title">{{ props.title }}</AlertDialog.Title>
95+
96+
<AlertDialog.Description :class="ui.description">
97+
{{ props.description }}
98+
</AlertDialog.Description>
99+
100+
<slot name="addon" />
101+
102+
<div :class="ui.actions.container">
103+
<UiButton
104+
v-if="props.confirmBtn"
105+
block
106+
:loading="loading"
107+
:class="ui.actions.btnSize"
108+
:label="props.confirmBtn.label"
109+
:variant="props.confirmBtn.variant"
110+
@click="handleConfirm"
111+
/>
112+
113+
<AlertDialog.Cancel v-if="props.cancelBtn" as-child>
114+
<UiButton
115+
block
116+
:disabled="loading"
117+
:class="ui.actions.btnSize"
118+
:label="props.cancelBtn.label"
119+
:variant="props.cancelBtn.variant"
120+
@click="$open = false"
121+
/>
122+
</AlertDialog.Cancel>
123+
</div>
124+
</div>
125+
</AlertDialog.Content>
126+
</Transition>
127+
</AlertDialog.Portal>
128+
</AlertDialog.Root>
129+
</template>

src/runtime/types/alert-dialog.d.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { AppConfig } from 'nuxt/schema';
2+
import { alertDialog } from '../ui.config';
3+
import type { ButtonVariant } from './button';
4+
import type { ExtractDeepKey } from './utils';
5+
6+
export type AlertDialogVariant =
7+
| keyof typeof alertDialog.variant
8+
| ExtractDeepKey<AppConfig, ['ui', 'alertDialog', 'variant']>;
9+
10+
export interface AlertDialogProps<T> {
11+
open?: boolean;
12+
defaultOpen?: boolean;
13+
ui?: T;
14+
15+
title: string;
16+
description: string;
17+
icon?: string;
18+
variant?: AlertDialogVariant;
19+
20+
confirmBtn?: {
21+
label?: string;
22+
variant?: ButtonVariant;
23+
action?: (() => void) | (() => Promise<void>);
24+
};
25+
cancelBtn?: {
26+
label?: string;
27+
variant?: ButtonVariant;
28+
};
29+
}

src/runtime/types/index.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './alert-dialog';
12
export * from './badge';
23
export * from './button';
34
export * from './combobox';
@@ -6,4 +7,5 @@ export * from './formField';
67
export * from './formInput';
78
export * from './formSelect';
89
export * from './link';
10+
export * from './overlays';
911
export * from './utils';

src/runtime/types/overlays.d.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type UiOverlayEmits = {
2+
(e: 'before-enter'): void;
3+
(e: 'after-enter'): void;
4+
(e: 'before-leave'): void;
5+
(e: 'after-leave'): void;
6+
};

src/runtime/ui.config/alert-dialog.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export default /*ui*/ {
2+
container: 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white shadow-xl',
3+
layout: 'flex flex-col items-center gap-2 sm:flex-row sm:items-start sm:gap-4',
4+
size: 'w-full max-w-lg',
5+
padding: 'px-4 py-6',
6+
title: 'text-center text-lg font-medium text-gray-900 sm:text-left sm:text-xl',
7+
description: 'mt-1 text-center text-gray-600 sm:text-left',
8+
actions: {
9+
container: 'mt-4 flex flex-col gap-2 sm:flex-row',
10+
btnSize: 'sm:w-max',
11+
},
12+
variant: {
13+
danger: {
14+
icon: 'i-heroicons-exclamation-triangle',
15+
color: 'bg-red-100 text-red-600',
16+
},
17+
warn: {
18+
icon: 'i-heroicons-exclamation-triangle',
19+
color: 'bg-amber-100 text-amber-600',
20+
},
21+
info: {
22+
icon: 'i-heroicons-information-circle',
23+
color: 'bg-blue-100 text-blue-600',
24+
},
25+
},
26+
icon: {
27+
container: 'flex size-10 shrink-0 items-center justify-center',
28+
rounded: 'rounded-full',
29+
size: 'size-6',
30+
},
31+
overlay: {
32+
base: 'fixed inset-0 z-40 bg-black/70 backdrop-blur-sm backdrop-filter',
33+
transition: {
34+
enterActive: 'ease-out duration-200',
35+
enterFrom: 'opacity-0',
36+
enterTo: 'opacity-100',
37+
leaveActive: 'ease-in duration-200',
38+
leaveFrom: 'opacity-100',
39+
leaveTo: 'opacity-0',
40+
},
41+
},
42+
transition: {
43+
enterActive: 'transition-[opacity,transform] ease-out duration-300',
44+
enterFrom: 'opacity-0 -translate-y-[40%] scale-95',
45+
enterTo: 'opacity-100 scale-100',
46+
leaveActive: 'transition-[opacity,transform] ease-in duration-200',
47+
leaveFrom: 'opacity-100 scale-100',
48+
leaveTo: 'opacity-0 scale-95',
49+
},
50+
default: {
51+
variant: 'info',
52+
},
53+
};

src/runtime/ui.config/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { default as dropdown } from './dropdown';
77
export { default as container } from './container';
88

99
// Overlays
10+
export { default as alertDialog } from './alert-dialog';
1011
export { default as dialog } from './dialog';
1112
export { default as slideover } from './slideover';
1213
export { default as tooltip } from './tooltip';

0 commit comments

Comments
 (0)