Skip to content
Merged
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
7 changes: 0 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
"alpinejs": "^3.1.1",
"autosize": "~6.0.1",
"axios": "^1.12.1",
"body-scroll-lock": "^4.0.0-beta.0",
"clsx": "^2.1.1",
"codemirror": "5.65.12",
"cookies-js": "^1.2.2",
Expand Down
14 changes: 0 additions & 14 deletions resources/js/components/portals/PortalTargets.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
</template>

<script>
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';

export default {
computed: {
portals() {
Expand Down Expand Up @@ -38,22 +36,10 @@ export default {
}
}
});

disableBodyScroll(this.$el, {
allowTouchMove: (el) => {
while (el && el !== document.body) {
if (el.classList.contains('overflow-scroll')) {
return true;
}
el = el.parentElement;
}
},
});
},

destroyStacks() {
this.$events.$off('stacks.hit-area-clicked');
enableBodyScroll(this.$el);
},
},
};
Expand Down
20 changes: 7 additions & 13 deletions resources/js/components/ui/Modal/Close.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
<script setup>
import { DialogClose } from 'reka-ui';
import { computed, useSlots } from 'vue';
import { inject } from 'vue';
import Button from '../Button/Button.vue';

const slots = useSlots();

const hasSlot = computed(() => !!slots.default);
const closeModal = inject('closeModal');
</script>

<template>
<DialogClose data-ui-modal-close :asChild="!hasSlot">
<template v-if="hasSlot">
<slot />
</template>
<template v-else>
<Button variant="ghost" size="sm" icon="x" class="absolute top-3 right-2" />
</template>
</DialogClose>
<div data-ui-modal-close @click="closeModal">
<slot>
<Button variant="ghost" size="sm" icon="x" class="absolute top-3 right-2" />
</slot>
</div>
</template>
150 changes: 101 additions & 49 deletions resources/js/components/ui/Modal/Modal.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
<script setup>
import { cva } from 'cva';
import { hasComponent } from '@/composables/has-component.js';
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui';
import { computed, getCurrentInstance, ref, watch } from 'vue';
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, provide, ref, useAttrs, useSlots, watch } from 'vue';
import Icon from '../Icon/Icon.vue';
import Heading from '../Heading.vue';
import { portals, keys } from '@api';
import wait from '@/util/wait';

defineOptions({
inheritAttrs: false,
});

const attrs = useAttrs();
const slots = useSlots();
const emit = defineEmits(['update:open', 'dismissed']);

const props = defineProps({
Expand All @@ -15,10 +23,8 @@ const props = defineProps({
dismissible: { type: Boolean, default: true },
});

const hasModalTitleComponent = hasComponent('ModalTitle');

const overlayClasses = cva({
base: 'data-[state=open]:show fixed inset-0 z-(--z-index-portal) bg-gray-800/20 dark:bg-gray-800/50',
base: 'fixed inset-0 z-(--z-index-portal) bg-gray-800/20 dark:bg-gray-800/50',
variants: {
blur: {
true: 'backdrop-blur-[2px]',
Expand All @@ -33,65 +39,111 @@ const modalClasses = cva({
'shadow-[0_8px_5px_-6px_rgba(0,0,0,0.12),_0_3px_8px_0_rgba(0,0,0,0.02),_0_30px_22px_-22px_rgba(39,39,42,0.35)]',
'dark:shadow-[0_5px_20px_rgba(0,0,0,.5)]',
'duration-200 will-change-[transform,opacity]',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'slide-in-from-top-2',
],
})({});

const instance = getCurrentInstance();
const hasModalTitleComponent = hasComponent('ModalTitle');
const isUsingOpenProp = computed(() => instance?.vnode.props?.hasOwnProperty('open'));

const open = ref(props.open);
const instance = getCurrentInstance();

watch(
() => props.open,
(value) => open.value = value,
);
const modal = ref(null);
const mounted = ref(false);
const visible = ref(false);
const escBinding = ref(null);

// When the parent component controls the open state, emit an update event
// so it can update its state, which eventually gets passed down as a prop.
// Otherwise, update the local state.
function updateOpen(value) {
if (isUsingOpenProp.value) {
emit('update:open', value);
return;
}
const portal = computed(() => modal.value ? `#portal-target-${modal.value.id}` : null);

function open() {
if (!modal.value) modal.value = portals.create('modal');

mounted.value = true;

nextTick(() => {
visible.value = true;
escBinding.value = keys.bindGlobal('esc', dismiss);
});
}

function close() {
visible.value = false;

open.value = value;
wait(300).then(() => {
mounted.value = false;
updateOpen(false);
});
}

function preventIfNotDismissible(event) {
if (!props.dismissible) event.preventDefault();
function dismiss() {
if (!props.dismissible) return;

emit('dismissed');
emit('dismissed');
close();
}

provide('closeModal', close);

function updateOpen(value) {
if (isUsingOpenProp.value) {
emit('update:open', value);
}
}

watch(
() => props.open,
(value) => value ? open() : close(),
);

onMounted(() => {
if (props.open) open();
});

onBeforeUnmount(() => {
modal.value?.destroy();
escBinding.value?.destroy();
});

defineExpose({
open,
close,
});
</script>

<template>
<DialogRoot :open="open" @update:open="updateOpen">
<DialogTrigger data-ui-modal-trigger as-child>
<slot name="trigger" />
</DialogTrigger>
<DialogPortal>
<DialogOverlay :class="overlayClasses" />
<DialogContent
:class="[modalClasses, $attrs.class]"
data-ui-modal-content
:aria-describedby="undefined"
@pointer-down-outside="preventIfNotDismissible"
@escape-key-down="preventIfNotDismissible"
<div v-if="slots.trigger" @click="open">
<slot name="trigger" />
</div>
<teleport :to="portal" v-if="mounted && portal">
<div class="vue-portal-target modal">
<transition
enter-active-class="duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="visible" :class="overlayClasses" @click="dismiss" />
</transition>
<transition
enter-active-class="duration-200"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div class="relative space-y-3 rounded-xl overflow-auto max-h-[60vh] border border-gray-400/60 bg-white p-4 shadow-[0_1px_16px_-2px_rgba(63,63,71,0.2)] dark:border-none dark:bg-gray-800 dark:shadow-[0_1px_16px_-2px_rgba(0,0,0,.5)] dark:inset-shadow-2xs dark:inset-shadow-white/10" >
<DialogTitle v-if="!hasModalTitleComponent" data-ui-modal-title class="flex items-center gap-2">
<Icon :name="icon" v-if="icon" class="size-4" />
<ui-heading :text="title" size="lg" class="font-medium" />
</DialogTitle>
<slot />
<div v-if="visible" :class="[modalClasses, attrs.class]" data-ui-modal-content>
<div class="relative space-y-3 rounded-xl overflow-auto max-h-[60vh] border border-gray-400/60 bg-white p-4 shadow-[0_1px_16px_-2px_rgba(63,63,71,0.2)] dark:border-none dark:bg-gray-800 dark:shadow-[0_1px_16px_-2px_rgba(0,0,0,.5)] dark:inset-shadow-2xs dark:inset-shadow-white/10">
<div v-if="!hasModalTitleComponent && (title || icon)" data-ui-modal-title class="flex items-center gap-2">
<Icon :name="icon" v-if="icon" class="size-4" />
<Heading :text="title" size="lg" class="font-medium" />
</div>
<slot />
</div>
<slot name="footer" />
</div>
<slot name="footer" />
</DialogContent>
</DialogPortal>
</DialogRoot>
</transition>
</div>
</teleport>
</template>
6 changes: 2 additions & 4 deletions resources/js/components/ui/Modal/Title.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
<script setup>
import { DialogTitle } from 'reka-ui';

defineOptions({
name: 'ModalTitle',
});
</script>

<template>
<DialogTitle data-ui-modal-title>
<div data-ui-modal-title>
<slot />
</DialogTitle>
</div>
</template>