Skip to content

Commit

Permalink
next: Popper layer (#488)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Apr 20, 2024
1 parent 09d568c commit 0188fb8
Show file tree
Hide file tree
Showing 29 changed files with 274 additions and 179 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"prettier-plugin-tailwindcss": "0.5.13",
"svelte": "5.0.0-next.108",
"svelte": "5.0.0-next.109",
"svelte-eslint-parser": "^0.34.1",
"wrangler": "^3.44.0"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/bits-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"jsdom": "^24.0.0",
"publint": "^0.2.7",
"resize-observer-polyfill": "^1.5.1",
"svelte": "5.0.0-next.108",
"svelte": "5.0.0-next.109",
"svelte-check": "^3.6.9",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
Expand Down
4 changes: 2 additions & 2 deletions packages/bits-ui/src/lib/bits/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ export * as Collapsible from "./collapsible/index.js";
export * as Combobox from "./combobox/index.js";
export * as ContextMenu from "./context-menu/index.js";
export * as DateField from "./date-field/index.js";
export * as DatePicker from "./date-picker/index.js";
// export * as DatePicker from "./date-picker/index.js";
export * as DateRangeField from "./date-range-field/index.js";
export * as DateRangePicker from "./date-range-picker/index.js";
// export * as DateRangePicker from "./date-range-picker/index.js";
export * as Dialog from "./dialog/index.js";
export * as DropdownMenu from "./dropdown-menu/index.js";
export * as Label from "./label/index.js";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import type { ContentProps } from "../index.js";
import { setPopoverContentState } from "../popover.svelte.js";
import { FloatingLayer, PresenceLayer } from "$lib/bits/utilities/index.js";
import { PopperLayer } from "$lib/bits/utilities/index.js";
import { readonlyBox } from "$lib/internal/box.svelte.js";
import { generateId } from "$lib/internal/id.js";
Expand All @@ -21,24 +21,27 @@
});
</script>

<PresenceLayer.Root forceMount={true} present={state.root.open.value || forceMount} {id}>
{#snippet presence({ present })}
<FloatingLayer.Content {id} {style} {...restProps}>
{#snippet content({ props })}
{@const mergedProps = {
...state.props,
...props,
hidden: present.value ? undefined : true,
...restProps,
}}
{#if asChild}
{@render child?.({ props: mergedProps })}
{:else}
<div {...mergedProps} bind:this={el}>
{@render children?.()}
</div>
{/if}
{/snippet}
</FloatingLayer.Content>
<PopperLayer.Root
{...restProps}
forceMount={true}
present={state.root.open.value || forceMount}
{id}
{style}
onInteractOutside={state.root.close}
onEscape={state.root.close}
>
{#snippet popper({ props })}
{@const mergedProps = {
...restProps,
...state.props,
...props,
}}
{#if asChild}
{@render child?.({ props: mergedProps })}
{:else}
<div {...mergedProps} bind:this={el}>
{@render children?.()}
</div>
{/if}
{/snippet}
</PresenceLayer.Root>
</PopperLayer.Root>
6 changes: 6 additions & 0 deletions packages/bits-ui/src/lib/bits/popover/popover.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ class PopoverRootState {
this.open.value = !this.open.value;
}

close = () => {
if (!this.open.value) return;
this.open.value = false;
};

createTrigger(props: PopoverTriggerStateProps) {
return new PopoverTriggerState(props, this);
}
Expand Down Expand Up @@ -82,6 +87,7 @@ class PopoverTriggerState {

#onkeydown = (e: KeyboardEvent) => {
if (!(e.key === kbd.ENTER || e.key === kbd.SPACE)) return;
e.preventDefault();
this.root.toggleOpen();
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
onInteractOutsideStart = noop,
id,
children,
present,
}: DismissableLayerProps = $props();
useDismissableLayer({
id: readonlyBox(() => id),
behaviorType: readonlyBox(() => behaviorType),
onInteractOutside: readonlyBox(() => onInteractOutside),
onInteractOutsideStart: readonlyBox(() => onInteractOutsideStart),
present: readonlyBox(() => present),
});
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { onDestroy } from "svelte";
import { untrack } from "svelte";
import type {
DismissableLayerProps,
InteractOutsideBehaviorType,
Expand All @@ -17,6 +17,7 @@ import {
getOwnerDocument,
isElement,
isOrContainsTarget,
noop,
useNodeById,
} from "$lib/internal/index.js";

Expand Down Expand Up @@ -55,26 +56,48 @@ export class DismissableLayerState {
#isResponsibleLayer = false;
node: Box<HTMLElement | null>;
#documentObj = undefined as unknown as Document;
#present: ReadonlyBox<boolean>;

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

layers.set(this, this.#behaviorType);
this.#present = props.present;

$effect(() => {
this.#documentObj = getOwnerDocument(this.node.value);
});

const unsubEvents = this.#addEventListeners();
let unsubEvents = noop;

$effect(() => {
if (this.#present.value) {
layers.set(
this,
untrack(() => this.#behaviorType)
);
unsubEvents = this.#addEventListeners();
}
return () => {
unsubEvents();
this.#resetState.destroy();
this.#resetState();
layers.delete(this);
this.#onInteractOutsideStart.destroy();
this.#onInteractOutside.destroy();
layers.delete(this);
unsubEvents();
};
});

$effect(() => {
return () => {
// onDestroy, cleanup anything leftover
untrack(() => {
this.#resetState.destroy();
layers.delete(this);
this.#onInteractOutsideStart.destroy();
this.#onInteractOutside.destroy();
unsubEvents();
});
};
});
}
Expand Down Expand Up @@ -132,19 +155,29 @@ export class DismissableLayerState {
}

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

#onInteractOutside = debounce((e: InteractOutsideEvent) => {
const node = this.node.value!;
if (!this.node.value) return;

const behaviorType = this.#behaviorType.value;
if (!this.#isResponsibleLayer || this.#isAnyEventIntercepted() || !isValidEvent(e, node))
if (
!this.#isResponsibleLayer ||
this.#isAnyEventIntercepted() ||
!isValidEvent(e, this.node.value)
) {
return;
}
if (behaviorType !== "close" && behaviorType !== "defer-otherwise-close") return;
if (!this.#isPointerDownOutside) return;
this.#interactOutsideProp.value(e);
Expand All @@ -159,8 +192,8 @@ export class DismissableLayerState {
};

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

#resetState = debounce(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export type DismissableLayerProps = {
* @defaultValue `close`
*/
behaviorType?: InteractOutsideBehaviorType;

/**
* Whether the layer is active. Currently, we determine this with the
* `presence` returned from the `presence` layer.
*/
present: boolean;
};

export type InteractOutsideInterceptEventType =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import { useEscapeLayer } from "./escape-layer.svelte.js";
import { noop, readonlyBox } from "$lib/internal/index.js";
let { behaviorType = "close", onEscape = noop, children }: EscapeLayerProps = $props();
let { behaviorType = "close", onEscape = noop, children, present }: EscapeLayerProps = $props();
useEscapeLayer({
behaviorType: readonlyBox(() => behaviorType),
onEscape: readonlyBox(() => onEscape),
present: readonlyBox(() => present),
});
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { onDestroy } from "svelte";
import { untrack } from "svelte";
import type { EscapeBehaviorType, EscapeLayerProps } from "./types.js";
import type { ReadonlyBox, ReadonlyBoxedValues } from "$lib/internal/box.svelte.js";
import { type EventCallback, addEventListener } from "$lib/internal/events.js";
import { kbd } from "$lib/internal/kbd.js";
import { noop } from "$lib/internal/callbacks.js";

const layers = new Map<EscapeLayerState, ReadonlyBox<EscapeBehaviorType>>();

Expand All @@ -11,14 +12,23 @@ type EscapeLayerStateProps = ReadonlyBoxedValues<Required<Omit<EscapeLayerProps,
export class EscapeLayerState {
#onEscapeProp: ReadonlyBox<EventCallback<KeyboardEvent>>;
#behaviorType: ReadonlyBox<EscapeBehaviorType>;
#present: ReadonlyBox<boolean>;

constructor(props: EscapeLayerStateProps) {
this.#behaviorType = props.behaviorType;
this.#onEscapeProp = props.onEscape;
layers.set(this, this.#behaviorType);
this.#present = props.present;

$effect.root(() => {
const unsubEvents = this.#addEventListener();
let unsubEvents = noop;

$effect(() => {
if (this.#present.value) {
layers.set(
this,
untrack(() => this.#behaviorType)
);
unsubEvents = this.#addEventListener();
}

return () => {
unsubEvents();
Expand Down
6 changes: 6 additions & 0 deletions packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ export type EscapeLayerProps = {
* @defaultValue `close`
*/
behaviorType?: EscapeBehaviorType;

/**
* Whether the layer is enabled. Currently, we determine this with the
* `presence` returned from the `presence` layer.
*/
present: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
strategy = "fixed",
dir = "ltr",
style = {},
present,
}: ContentProps = $props();
const state = setFloatingContentState({
Expand All @@ -40,6 +41,7 @@
strategy: readonlyBox(() => strategy),
dir: readonlyBox(() => dir),
style: readonlyBox(() => style),
present: readonlyBox(() => present),
});
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { VirtualElement } from "@floating-ui/core";
import { getContext, setContext, untrack } from "svelte";
import {
type Middleware,
Expand All @@ -17,6 +16,7 @@ import {
type Box,
type ReadonlyBox,
type ReadonlyBoxedValues,
afterTick,
boxedState,
generateId,
styleToString,
Expand Down Expand Up @@ -83,6 +83,7 @@ export type FloatingContentStateProps = ReadonlyBoxedValues<{
onPlaced: () => void;
dir: TextDirection;
style: StyleProperties;
present: boolean;
}>;

class FloatingContentState {
Expand All @@ -104,6 +105,7 @@ class FloatingContentState {
updatePositionStrategy =
undefined as unknown as FloatingContentStateProps["updatePositionStrategy"];
onPlaced = undefined as unknown as FloatingContentStateProps["onPlaced"];
present = undefined as unknown as FloatingContentStateProps["present"];
arrowSize: {
readonly value:
| {
Expand Down Expand Up @@ -201,7 +203,7 @@ class FloatingContentState {
...this.style.value,
// if the FloatingContent hasn't been placed yet (not all measurements done)
// we prevent animations so that users's animation don't kick in too early referring wrong sides
animation: !this.floating.isPositioned ? "none" : undefined,
// animation: !this.floating.isPositioned ? "none" : undefined,
}),
});

Expand All @@ -223,6 +225,9 @@ class FloatingContentState {
this.dir = props.dir;
this.style = props.style;
this.root = root;
this.present = props.present;
this.arrowSize = useSize(this.root.arrowNode);
this.root.contentNode = useNodeById(this.id);
this.floating = useFloating({
strategy: () => this.strategy.value,
placement: () => this.desiredPlacement,
Expand All @@ -234,12 +239,9 @@ class FloatingContentState {
});
return cleanup;
},
open: () => this.present.value,
});

this.arrowSize = useSize(this.root.arrowNode);

this.root.contentNode = useNodeById(this.id);

$effect(() => {
if (this.floating.isPositioned) {
this.onPlaced?.value();
Expand Down
Loading

0 comments on commit 0188fb8

Please sign in to comment.