Skip to content

Commit

Permalink
next: dismissable layer (#474)
Browse files Browse the repository at this point in the history
  • Loading branch information
anatolzak authored Apr 17, 2024
1 parent c645dd9 commit 50ff2eb
Show file tree
Hide file tree
Showing 13 changed files with 402 additions and 31 deletions.
18 changes: 9 additions & 9 deletions packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ type AccordionBaseStateProps = ReadonlyBoxedValues<{

class AccordionBaseState {
id: ReadonlyBox<string>;
node = boxedState<HTMLElement | null>(null);
node: Box<HTMLElement | null>;
disabled: ReadonlyBox<boolean>;

constructor(props: AccordionBaseStateProps) {
this.id = props.id;
this.disabled = props.disabled;

useNodeById(this.id, this.node);
this.node = useNodeById(this.id);
}

getTriggerNodes() {
Expand All @@ -59,7 +59,7 @@ class AccordionBaseState {
* SINGLE
*/

type AccordionSingleStateProps = AccordionBaseStateProps & BoxedValues<{ value: string }>;
type AccordionSingleStateProps = AccordionBaseStateProps & BoxedValues<{ value: string; }>;

export class AccordionSingleState extends AccordionBaseState {
#value: Box<string>;
Expand All @@ -83,7 +83,7 @@ export class AccordionSingleState extends AccordionBaseState {
* MULTIPLE
*/

type AccordionMultiStateProps = AccordionBaseStateProps & BoxedValues<{ value: string[] }>;
type AccordionMultiStateProps = AccordionBaseStateProps & BoxedValues<{ value: string[]; }>;

export class AccordionMultiState extends AccordionBaseState {
#value: Box<string[]>;
Expand Down Expand Up @@ -176,7 +176,7 @@ type AccordionTriggerStateProps = ReadonlyBoxedValues<{
class AccordionTriggerState {
#disabled: ReadonlyBox<boolean>;
#id: ReadonlyBox<string>;
#node = boxedState<HTMLElement | null>(null);
#node: Box<HTMLElement | null>;
#root: AccordionState;
#itemState: AccordionItemState;
#composedClick: EventCallback<MouseEvent>;
Expand All @@ -190,7 +190,7 @@ class AccordionTriggerState {
this.#composedClick = composeHandlers(props.onclick, this.#onclick);
this.#composedKeydown = composeHandlers(props.onkeydown, this.#onkeydown);

useNodeById(this.#id, this.#node);
this.#node = useNodeById(this.#id);
}

get #isDisabled() {
Expand Down Expand Up @@ -259,9 +259,9 @@ type AccordionContentStateProps = ReadonlyBoxedValues<{

class AccordionContentState {
item: AccordionItemState;
node = boxedState<HTMLElement | null>(null);
node: Box<HTMLElement | null>;
#id: ReadonlyBox<string>;
#originalStyles: { transitionDuration: string; animationName: string } | undefined = undefined;
#originalStyles: { transitionDuration: string; animationName: string; } | undefined = undefined;
#isMountAnimationPrevented = false;
#width = $state(0);
#height = $state(0);
Expand All @@ -275,7 +275,7 @@ class AccordionContentState {
this.#id = props.id;
this.#styleProp = props.style;

useNodeById(this.#id, this.node);
this.node = useNodeById(this.#id);

$effect.pre(() => {
const rAF = requestAnimationFrame(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ type CollapsibleContentStateProps = ReadonlyBoxedValues<{

class CollapsibleContentState {
root: CollapsibleRootState;
#originalStyles: { transitionDuration: string; animationName: string } | undefined;
#originalStyles: { transitionDuration: string; animationName: string; } | undefined;
#styleProp: ReadonlyBox<StyleProperties>;
node = boxedState<HTMLElement | null>(null);
node: Box<HTMLElement | null>;
#isMountAnimationPrevented = $state(false);
#width = $state(0);
#height = $state(0);
Expand All @@ -78,7 +78,7 @@ class CollapsibleContentState {
this.root.contentId = props.id;
this.#styleProp = props.style;

useNodeById(this.root.contentId, this.node);
this.node = useNodeById(this.root.contentId);

$effect.pre(() => {
const rAF = requestAnimationFrame(() => {
Expand Down
21 changes: 6 additions & 15 deletions packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getContext, setContext } from "svelte";
import { getAriaChecked, getAriaRequired, getDataDisabled } from "$lib/internal/attrs.js";
import {
type Box,
type BoxedValues,
type ReadonlyBoxedValues,
boxedState,
Expand All @@ -9,7 +10,6 @@ import { useNodeById } from "$lib/internal/elements.svelte.js";
import { type EventCallback, composeHandlers } from "$lib/internal/events.js";
import { getDirectionalKeys, kbd } from "$lib/internal/kbd.js";
import { getElemDirection } from "$lib/internal/locale.js";
import { withTick } from "$lib/internal/with-tick.js";
import type { Orientation } from "$lib/shared/index.js";
import { verifyContextDeps } from "$lib/internal/context.js";

Expand All @@ -21,11 +21,11 @@ type RadioGroupRootStateProps = ReadonlyBoxedValues<{
orientation: Orientation;
name: string | undefined;
}> &
BoxedValues<{ value: string }>;
BoxedValues<{ value: string; }>;

class RadioGroupRootState {
id: RadioGroupRootStateProps["id"];
node = boxedState<HTMLElement | null>(null);
node: Box<HTMLElement | null>;
disabled: RadioGroupRootStateProps["disabled"];
required: RadioGroupRootStateProps["required"];
loop: RadioGroupRootStateProps["loop"];
Expand All @@ -41,15 +41,7 @@ class RadioGroupRootState {
this.orientation = props.orientation;
this.name = props.name;
this.value = props.value;

$effect.pre(() => {
// eslint-disable-next-line no-unused-expressions
this.id.value;

withTick(() => {
this.node.value = document.getElementById(this.id.value);
});
});
this.node = useNodeById(this.id);
}

isChecked(value: string) {
Expand Down Expand Up @@ -96,7 +88,7 @@ type RadioGroupItemStateProps = ReadonlyBoxedValues<{

class RadioGroupItemState {
#id: RadioGroupItemStateProps["id"];
#node = boxedState<HTMLElement | null>(null);
#node: Box<HTMLElement | null>;
#root: RadioGroupRootState;
#disabled: RadioGroupItemStateProps["disabled"];
#value: RadioGroupItemStateProps["value"];
Expand All @@ -108,11 +100,10 @@ class RadioGroupItemState {
this.#value = props.value;
this.#root = root;
this.#id = props.id;

this.#composedClick = composeHandlers(props.onclick, this.#onclick);
this.#composedKeydown = composeHandlers(props.onkeydown, this.#onkeydown);

useNodeById(this.#id, this.#node);
this.#node = useNodeById(this.#id);
}

#onclick = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
import type { DismissableLayerProps } from "./types.js";
import { dismissableLayerState } from "./dismissable-layer.svelte.js";
import { readonlyBox } from "$lib/internal/box.svelte.js";
import { noop } from "$lib/index.js";
let {
behaviorType = "close",
onInteractOutside = noop,
onInteractOutsideStart = noop,
id,
children,
}: DismissableLayerProps = $props();
dismissableLayerState({
id: readonlyBox(() => id),
behaviorType: readonlyBox(() => behaviorType),
onInteractOutside: readonlyBox(() => onInteractOutside),
onInteractOutsideStart: readonlyBox(() => onInteractOutsideStart),
});
</script>

{@render children?.()}
195 changes: 195 additions & 0 deletions packages/bits-ui/src/lib/dismissable-layer/dismissable-layer.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { onDestroy } from "svelte";
import type {
DismissableLayerProps,
InteractOutsideBehaviorType,
InteractOutsideEvent,
InteractOutsideInterceptEventType,
} from "./types.js";
import type { Box, ReadonlyBox, ReadonlyBoxedValues } from "$lib/internal/box.svelte.js";
import { useNodeById } from "$lib/internal/elements.svelte.js";
import { type EventCallback, addEventListener, composeHandlers } from "$lib/internal/events.js";
import { getOwnerDocument, isOrContainsTarget } from "$lib/helpers/elements.js";
import { debounce } from "$lib/helpers/debounce.js";
import { executeCallbacks } from "$lib/helpers/callbacks.js";
import { isElement } from "$lib/internal/is.js";

const layers = new Map<DismissableLayerState, ReadonlyBox<InteractOutsideBehaviorType>>();

const interactOutsideStartEvents = [
"pointerdown",
"mousedown",
"touchstart",
] satisfies InteractOutsideInterceptEventType[];
const interactOutsideEndEvents = [
"pointerup",
"mouseup",
"touchend",
"click",
] satisfies InteractOutsideInterceptEventType[];

type DismissableLayerStateProps = ReadonlyBoxedValues<
Required<Omit<DismissableLayerProps, "children">>
>;

export class DismissableLayerState {
#interactOutsideStartProp: ReadonlyBox<EventCallback<InteractOutsideEvent>>;
#interactOutsideProp: ReadonlyBox<EventCallback<InteractOutsideEvent>>;
#behaviorType: ReadonlyBox<InteractOutsideBehaviorType>;
#interceptedEvents: Record<InteractOutsideInterceptEventType, boolean> = {
pointerdown: false,
pointerup: false,
mousedown: false,
mouseup: false,
touchstart: false,
touchend: false,
click: false,
};
#isPointerDown = false;
#isPointerDownInside = false;
#isResponsibleLayer = false;
node: Box<HTMLElement | null>;
#documentObj: Document;

constructor(props: DismissableLayerStateProps) {
this.node = useNodeById(props.id);
this.#documentObj = getOwnerDocument(this.node.value);
this.#behaviorType = props.behaviorType;
this.#interactOutsideStartProp = props.onInteractOutsideStart;
this.#interactOutsideProp = props.onInteractOutside;

const unsubEvents = this.#addEventListeners();

onDestroy(() => {
unsubEvents();
this.#resetState.destroy();
this.#onInteractOutsideStart.destroy();
this.#onInteractOutside.destroy();
});
}

#addEventListeners() {
return executeCallbacks(
/**
* CAPTURE INTERACTION START
* mark interaction-start event as intercepted.
* mark responsible layer during interaction start
* to avoid checking if is responsible layer during interaction end
* when a new floating element may have been opened.
*/
addEventListener(
this.#documentObj,
interactOutsideStartEvents,
composeHandlers(this.#markInterceptedEvent, this.#markResponsibleLayer),
true
),

/**
* CAPTURE INTERACTION END
* mark interaction-end event as intercepted. Debounce reset state to allow
* bubble events to be processed before resetting the state.
*/
addEventListener(
this.#documentObj,
interactOutsideEndEvents,
composeHandlers(this.#markInterceptedEvent, this.#resetState),
true
),

/**
* BUBBLE INTERACTION START
* Mark interaction-start event as non-intercepted. Debounce `onInteractOutsideStart`
* to avoid prematurely checking if other events were intercepted.
*/
addEventListener(
this.#documentObj,
interactOutsideStartEvents,
composeHandlers(this.#markNonInterceptedEvent, this.#onInteractOutsideStart)
),

/**
* BUBBLE INTERACTION END
* Mark interaction-end event as non-intercepted. Debounce `onInteractOutside`
* to avoid prematurely checking if other events were intercepted.
*/
addEventListener(
this.#documentObj,
interactOutsideStartEvents,
composeHandlers(this.#markNonInterceptedEvent, this.#onInteractOutside)
)
);
}

#onInteractOutsideStart = debounce((e: InteractOutsideEvent) => {
const node = this.node.value!;
if (!this.#isResponsibleLayer || this.#isAnyEventIntercepted() || !isValidEvent(e, node))
return;
this.#interactOutsideStartProp.value(e);
if (e.defaultPrevented) return;
this.#isPointerDownInside = true;
this.#isPointerDown = true;
}, 10);

#onInteractOutside = debounce((e: InteractOutsideEvent) => {
const node = this.node.value!;
const behaviorType = this.#behaviorType.value;
if (!this.#isResponsibleLayer || this.#isAnyEventIntercepted() || !isValidEvent(e, node))
return;
if (behaviorType !== "close" && behaviorType !== "defer-otherwise-close") return;
if (!this.#isPointerDown || this.#isPointerDownInside) return;
this.#interactOutsideProp.value(e);
}, 10);

#markInterceptedEvent = (e: HTMLElementEventMap[InteractOutsideInterceptEventType]) => {
this.#interceptedEvents[e.type as InteractOutsideInterceptEventType] = true;
};

#markNonInterceptedEvent = (e: HTMLElementEventMap[InteractOutsideInterceptEventType]) => {
this.#interceptedEvents[e.type as InteractOutsideInterceptEventType] = false;
};

#markResponsibleLayer = () => {
const node = this.node.value!;
this.#isResponsibleLayer = isResponsibleLayer(node);
};

#resetState = debounce(() => {
for (const eventType in this.#interceptedEvents) {
this.#interceptedEvents[eventType as InteractOutsideInterceptEventType] = false;
}
this.#isPointerDown = false;
this.#isPointerDownInside = false;
this.#isResponsibleLayer = false;
}, 20);

#isAnyEventIntercepted() {
return Object.values(this.#interceptedEvents).some(Boolean);
}
}

export function dismissableLayerState(props: DismissableLayerStateProps) {
return new DismissableLayerState(props);
}

function isResponsibleLayer(node: HTMLElement): boolean {
const layersArr = [...layers];
/**
* We first check if we can find a top layer with `close` or `ignore`.
* If that top layer was found and matches the provided node, then the node is
* responsible for the outside interaction. Otherwise, we know that all layers defer so
* the first layer is the responsible one.
*/
const topMostLayer = layersArr.findLast(
([_, { value: behaviorType }]) => behaviorType === "close" || behaviorType === "ignore"
);
if (topMostLayer) return topMostLayer[0].node.value === node;
const [firstLayerNode] = layersArr[0]!;
return firstLayerNode.node.value === node;
}

function isValidEvent(e: InteractOutsideEvent, node: HTMLElement): boolean {
if ("button" in e && e.button > 0) return false;
const target = e.target;
if (!isElement(target)) return false;
const ownerDocument = getOwnerDocument(target);
return ownerDocument.documentElement.contains(target) && !isOrContainsTarget(node, target);
}
3 changes: 3 additions & 0 deletions packages/bits-ui/src/lib/dismissable-layer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as DismissableLayer } from "./dismissable-layer.svelte";

export type { DismissableLayerProps as Props } from "./types.js";
Loading

0 comments on commit 50ff2eb

Please sign in to comment.