oriUI

useDismissable

A headless dismiss layer — the "close the overlay on an outside interaction" glue a non-platform overlay needs, the pattern Radix DismissableLayer / Floating-UI useDismiss standardise. While enabled, it attaches document listeners and calls onDismiss() when an interaction lands outside the overlay's own elements. Each overlay picks the strategy that fits it:

  • a menu (no single focus anchor) closes on pointerdown outside (pointerDownOutside);
  • a combobox (focus lives on its input) closes on focus-out (focusOutside).

OriMenu and OriCombobox use this internally. (An OriPopover / OriDialog get dismiss for free from the native [popover] / <dialog> top-layer, and Escape lives in the core connects — so they don't need it.) The pure isTargetOutside(target, elements) predicate it's built on is exported from @oriui/headless.

Import

ts
import { useDismissable } from '@oriui/headless/vue';

Options

useDismissable(() => ({ … })) takes its options as a getter (Svelte: a plain object or a store) so enabled / elements stay reactive.

OptionTypeDefaultDescription
enabledbooleanAttach the listeners only while this is true — typically the overlay's open.
elements() => (HTMLElement | null | undefined)[]The overlay's own elements (content + trigger). An interaction inside ANY of them does NOT dismiss.
onDismiss() => voidCalled to dismiss — typically () => setOpen(false).
pointerDownOutsidebooleanfalseClose on a pointerdown outside elements.
focusOutsidebooleanfalseClose when focus lands outside elements.

It returns nothing — it just manages the listeners (removed when enabled flips false or the scope disposes). Attach happens after the render flush, so the interaction that opened the overlay can't immediately dismiss it.

Usage

Pass the overlay's open, its element refs, and a close callback — pick the strategy for your widget:

vue
<!-- MyMenu.vue — pointerdown-outside (a menu has no single focus anchor) -->
<script setup lang="ts">
import { ref } from 'vue';
import { useDismissable } from '@oriui/headless/vue';

const open = ref(false);
const content = ref<HTMLElement>();
const trigger = ref<HTMLElement>();

useDismissable(() => ({
    enabled: open.value,
    elements: () => [content.value, trigger.value],
    onDismiss: () => (open.value = false),
    pointerDownOutside: true
}));
</script>

The Svelte binding is the same — options are a plain object or a store, and it wires the identical listeners over the shared isTargetOutside:

svelte
<script>
    import { writable, derived } from 'svelte/store';
    import { useDismissable } from '@oriui/headless/svelte';

    export let controlEl; // bound to the combobox control
    const open = writable(false);

    // focus-out for a combobox (focus lives on its input)
    useDismissable(derived(open, ($open) => ({
        enabled: $open,
        elements: () => [controlEl],
        onDismiss: () => open.set(false),
        focusOutside: true
    })));
</script>

Accessibility

  • Dismiss is one half of an overlay's a11y contract; the other keys live elsewhere — Escape is handled by the Menu / Combobox core connects, and roving focus by their own machines. useDismissable only adds the outside-interaction close.
  • pointerDownOutside fires on pointerdown (before click), so the overlay closes as the outside press begins — matching native menus. focusOutside closes when focus genuinely leaves the widget (Tab-away or a click that moves focus out), so a screen-reader user Tabbing past a combobox closes its list.

See also