Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

next: Popper layer #488

Merged
merged 4 commits into from
Apr 20, 2024
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
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
Loading