271 lines
7.0 KiB
Svelte
271 lines
7.0 KiB
Svelte
<script lang="ts" context="module">
|
|
import {
|
|
getContext,
|
|
setContext,
|
|
createEventDispatcher,
|
|
tick,
|
|
onDestroy,
|
|
onMount,
|
|
} from "svelte";
|
|
export enum DialogStates {
|
|
Open,
|
|
Closed,
|
|
}
|
|
|
|
export interface StateDefinition {
|
|
dialogState: DialogStates;
|
|
|
|
titleId?: string;
|
|
|
|
setTitleId(id?: string): void;
|
|
|
|
close(): void;
|
|
}
|
|
|
|
const DIALOG_CONTEXT_NAME = "headlessui-dialog-context";
|
|
|
|
export function useDialogContext(
|
|
component: string
|
|
): Readable<StateDefinition> {
|
|
let context = getContext(DIALOG_CONTEXT_NAME) as
|
|
| Writable<StateDefinition>
|
|
| undefined;
|
|
if (context === undefined) {
|
|
throw new Error(
|
|
`<${component} /> is missing a parent <Dialog /> component.`
|
|
);
|
|
}
|
|
return context;
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { State, useOpenClosed } from "$lib/internal/open-closed";
|
|
import { Readable, writable, Writable } from "svelte/store";
|
|
import { match } from "$lib/utils/match";
|
|
import { useId } from "$lib/hooks/use-id";
|
|
import { useInertOthers } from "$lib/hooks/use-inert-others";
|
|
import { contains } from "$lib/internal/dom-containers";
|
|
import { Keys } from "$lib/utils/keyboard";
|
|
import FocusTrap from "$lib/components/focus-trap/FocusTrap.svelte";
|
|
import StackContextProvider, {
|
|
StackMessage,
|
|
} from "$lib/internal/StackContextProvider.svelte";
|
|
import DescriptionProvider from "$lib/components/description/DescriptionProvider.svelte";
|
|
import ForcePortalRootContext from "$lib/internal/ForcePortalRootContext.svelte";
|
|
import Portal from "$lib/components/portal/Portal.svelte";
|
|
import PortalGroup from "$lib/components/portal/PortalGroup.svelte";
|
|
export let open: Boolean | undefined = undefined;
|
|
export let initialFocus: HTMLElement | null = null;
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
let containers: Set<HTMLElement> = new Set();
|
|
let openClosedState = useOpenClosed();
|
|
|
|
$: {
|
|
open =
|
|
open === undefined && openClosedState !== undefined
|
|
? match($openClosedState!, {
|
|
[State.Open]: true,
|
|
[State.Closed]: false,
|
|
})
|
|
: open;
|
|
|
|
// Validations
|
|
let hasOpen = open !== undefined || openClosedState !== null;
|
|
|
|
if (!hasOpen) {
|
|
throw new Error(
|
|
`You forgot to provide an \`open\` prop to the \`Dialog\`.`
|
|
);
|
|
}
|
|
|
|
if (typeof open !== "boolean") {
|
|
throw new Error(
|
|
`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${open}`
|
|
);
|
|
}
|
|
}
|
|
|
|
$: dialogState = open ? DialogStates.Open : DialogStates.Closed;
|
|
$: visible =
|
|
openClosedState !== undefined
|
|
? $openClosedState === State.Open
|
|
: dialogState === DialogStates.Open;
|
|
|
|
let internalDialogRef: HTMLDivElement | null = null;
|
|
$: enabled = dialogState === DialogStates.Open;
|
|
|
|
const id = `headlessui-dialog-${useId()}`;
|
|
|
|
$: _cleanup = (() => {
|
|
if (_cleanup) {
|
|
_cleanup();
|
|
}
|
|
return useInertOthers(internalDialogRef, enabled);
|
|
})();
|
|
|
|
onDestroy(() => {
|
|
if (_cleanup) {
|
|
_cleanup();
|
|
}
|
|
});
|
|
|
|
let titleId: StateDefinition["titleId"];
|
|
|
|
let api: Writable<StateDefinition> = writable({
|
|
titleId,
|
|
dialogState,
|
|
setTitleId(id?: string) {
|
|
if (titleId === id) return;
|
|
titleId = id;
|
|
},
|
|
close() {
|
|
dispatch("close", false);
|
|
},
|
|
});
|
|
setContext(DIALOG_CONTEXT_NAME, api);
|
|
$: api.update((obj) => {
|
|
return {
|
|
...obj,
|
|
titleId,
|
|
dialogState,
|
|
};
|
|
});
|
|
|
|
// Handle outside click
|
|
async function handleWindowMousedown(event: MouseEvent) {
|
|
let target = event.target as HTMLElement;
|
|
|
|
if (dialogState !== DialogStates.Open) return;
|
|
if (containers.size !== 1) return;
|
|
if (contains(containers, target)) return;
|
|
|
|
$api.close();
|
|
await tick();
|
|
target?.focus();
|
|
}
|
|
|
|
// Handle `Escape` to close
|
|
function handleWindowKeydown(event: KeyboardEvent) {
|
|
if (event.key !== Keys.Escape) return;
|
|
if (dialogState !== DialogStates.Open) return;
|
|
if (containers.size > 1) return; // 1 is myself, otherwise other elements in the Stack
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
$api.close();
|
|
}
|
|
|
|
let mounted = false;
|
|
onMount(() => (mounted = true));
|
|
$: _cleanupScrollLock = (() => {
|
|
if (_cleanupScrollLock) {
|
|
_cleanupScrollLock();
|
|
}
|
|
if (dialogState !== DialogStates.Open) return;
|
|
if (!mounted) return;
|
|
|
|
let overflow = document.documentElement.style.overflow;
|
|
let paddingRight = document.documentElement.style.paddingRight;
|
|
|
|
let scrollbarWidth =
|
|
window.innerWidth - document.documentElement.clientWidth;
|
|
|
|
document.documentElement.style.overflow = "hidden";
|
|
document.documentElement.style.paddingRight = `${scrollbarWidth}px`;
|
|
|
|
return () => {
|
|
document.documentElement.style.overflow = overflow;
|
|
document.documentElement.style.paddingRight = paddingRight;
|
|
};
|
|
})();
|
|
onDestroy(() => {
|
|
if (_cleanupScrollLock) {
|
|
_cleanupScrollLock();
|
|
}
|
|
});
|
|
|
|
$: _cleanupClose = (() => {
|
|
if (_cleanupClose) {
|
|
_cleanupClose();
|
|
}
|
|
if (dialogState !== DialogStates.Open) return;
|
|
let container = internalDialogRef;
|
|
if (!container) return;
|
|
|
|
let observer = new IntersectionObserver((entries) => {
|
|
for (let entry of entries) {
|
|
if (
|
|
entry.boundingClientRect.x === 0 &&
|
|
entry.boundingClientRect.y === 0 &&
|
|
entry.boundingClientRect.width === 0 &&
|
|
entry.boundingClientRect.height === 0
|
|
) {
|
|
$api.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(container);
|
|
|
|
return () => observer.disconnect();
|
|
})();
|
|
onDestroy(() => {
|
|
if (_cleanupClose) {
|
|
_cleanupClose();
|
|
}
|
|
});
|
|
|
|
function handleClick(event: MouseEvent) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
$: propsWeControl = {
|
|
id,
|
|
role: "dialog",
|
|
"aria-modal": dialogState === DialogStates.Open ? true : undefined,
|
|
"aria-labelledby": titleId,
|
|
};
|
|
</script>
|
|
|
|
<svelte:window
|
|
on:mousedown={handleWindowMousedown}
|
|
on:keydown={handleWindowKeydown}
|
|
/>
|
|
{#if visible}
|
|
<FocusTrap {containers} {enabled} options={{ initialFocus }} />
|
|
<StackContextProvider
|
|
element={internalDialogRef}
|
|
onUpdate={(message, element) => {
|
|
return match(message, {
|
|
[StackMessage.Add]() {
|
|
containers.add(element);
|
|
},
|
|
[StackMessage.Remove]() {
|
|
containers.delete(element);
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<ForcePortalRootContext force={true}>
|
|
<Portal>
|
|
<PortalGroup target={internalDialogRef}>
|
|
<ForcePortalRootContext force={false}>
|
|
<DescriptionProvider name={"Dialog.Description"} let:describedby>
|
|
<div
|
|
{...{ ...$$restProps, ...propsWeControl }}
|
|
bind:this={internalDialogRef}
|
|
aria-describedby={describedby}
|
|
on:click={handleClick}
|
|
>
|
|
<slot {open} />
|
|
</div>
|
|
</DescriptionProvider>
|
|
</ForcePortalRootContext>
|
|
</PortalGroup>
|
|
</Portal>
|
|
</ForcePortalRootContext>
|
|
</StackContextProvider>
|
|
{/if}
|