Skip to content

Commit

Permalink
next: Portal utility component (#716)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Oct 3, 2024
1 parent f48d572 commit ce49f50
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 31 deletions.
5 changes: 5 additions & 0 deletions .changeset/early-points-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

feat: export and document `Portal` utility component
3 changes: 0 additions & 3 deletions packages/bits-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,7 @@
"@internationalized/date": "^3.5.4",
"clsx": "^2.1.1",
"esm-env": "^1.0.0",
"nanoid": "^5.0.7",
"runed": "^0.15.2",
"scule": "^1.3.0",
"style-object-to-css-string": "^1.1.3",
"style-to-object": "^1.0.6",
"svelte-toolbelt": "^0.3.1"
},
Expand Down
1 change: 1 addition & 0 deletions packages/bits-ui/src/lib/bits/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ export * as Toggle from "./toggle/index.js";
export * as ToggleGroup from "./toggle-group/index.js";
export * as Toolbar from "./toolbar/index.js";
export * as Tooltip from "./tooltip/index.js";
export { default as Portal } from "./utilities/portal/portal.svelte";
17 changes: 11 additions & 6 deletions packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { getAllContexts, mount, unmount, untrack } from "svelte";
import { DEV } from "esm-env";
import PortalConsumer from "./portal-consumer.svelte";
import type { PortalProps } from "./types.js";
import { isBrowser } from "$lib/internal/is.js";
Expand All @@ -16,16 +17,20 @@
if (typeof to === "string") {
localTarget = document.querySelector(to);
if (localTarget === null) {
throw new Error(`Target element "${to}" not found.`);
if (DEV) {
throw new Error(`Target element "${to}" not found.`);
}
}
} else if (to instanceof HTMLElement || to instanceof DocumentFragment) {
localTarget = to;
} else {
throw new TypeError(
`Unknown portal target type: ${
to === null ? "null" : typeof to
}. Allowed types: string (CSS selector) or HTMLElement.`
);
if (DEV) {
throw new TypeError(
`Unknown portal target type: ${
to === null ? "null" : typeof to
}. Allowed types: string (query selector), HTMLElement, or DocumentFragment.`
);
}
}
return localTarget;
Expand Down
5 changes: 4 additions & 1 deletion packages/bits-ui/src/lib/bits/utilities/portal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ export type PortalProps = {
to?: HTMLElement | string | DocumentFragment;

/**
* Disable portaling and render the component inline
* Disable portalling and render the component inline
*
* @defaultValue false
*/
disabled?: boolean;

/**
* The children content to render within the portal.
*/
children?: Snippet;
};
2 changes: 1 addition & 1 deletion packages/bits-ui/src/lib/internal/cssToStyleObj.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import parse from "style-to-object";
import { camelCase, pascalCase } from "scule";
import { camelCase, pascalCase } from "$lib/internal/strings.js";
import type { StyleProperties } from "$lib/shared/index.js";

export function cssToStyleObj(css: string | null | undefined): StyleProperties {
Expand Down
82 changes: 82 additions & 0 deletions packages/bits-ui/src/lib/internal/strings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const NUMBER_CHAR_RE = /\d/;
const STR_SPLITTERS = ["-", "_", "/", "."];

function isUppercase(char = ""): boolean | undefined {
if (NUMBER_CHAR_RE.test(char)) return undefined;
return char !== char.toLowerCase();
}

function splitByCase(str: string) {
const parts: string[] = [];

let buff = "";

let previousUpper: boolean | undefined;
let previousSplitter: boolean | undefined;

for (const char of str) {
// Splitter
const isSplitter = STR_SPLITTERS.includes(char);
if (isSplitter === true) {
parts.push(buff);
buff = "";
previousUpper = undefined;
continue;
}

const isUpper = isUppercase(char);
if (previousSplitter === false) {
// Case rising edge
if (previousUpper === false && isUpper === true) {
parts.push(buff);
buff = char;
previousUpper = isUpper;
continue;
}
// Case falling edge
if (previousUpper === true && isUpper === false && buff.length > 1) {
const lastChar = buff.at(-1);
parts.push(buff.slice(0, Math.max(0, buff.length - 1)));
buff = lastChar + char;
previousUpper = isUpper;
continue;
}
}

// Normal char
buff += char;
previousUpper = isUpper;
previousSplitter = isSplitter;
}

parts.push(buff);

return parts;
}

export function pascalCase(str?: string) {
if (!str) return "";
return splitByCase(str)
.map((p) => upperFirst(p))
.join("");
}

export function camelCase(str?: string) {
return lowerFirst(pascalCase(str || ""));
}

export function kebabCase(str?: string) {
return str
? splitByCase(str)
.map((p) => p.toLowerCase())
.join("-")
: "";
}

function upperFirst(str: string) {
return str ? str[0]!.toUpperCase() + str.slice(1) : "";
}

function lowerFirst(str: string) {
return str ? str[0]!.toLowerCase() + str.slice(1) : "";
}
2 changes: 1 addition & 1 deletion packages/bits-ui/src/lib/internal/style.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import styleToCSS from "style-object-to-css-string";
import { styleToCSS } from "./styleToCSS.js";
import type { StyleProperties } from "$lib/shared/index.js";

export function styleToString(style: StyleProperties = {}): string {
Expand Down
29 changes: 29 additions & 0 deletions packages/bits-ui/src/lib/internal/styleToCSS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
function createParser(matcher: string | RegExp, replacer: (match: string) => string) {
const regex = RegExp(matcher, "g");
return (str: string): string => {
// throw an error if not a string
if (typeof str !== "string") {
throw new TypeError(`expected an argument of type string, but got ${typeof str}`);
}

// if no match between string and matcher
if (!str.match(regex)) return str;

// executes the replacer function for each match
return str.replace(regex, replacer);
};
}

const camelToKebab = createParser(/[A-Z]/, (match) => `-${match.toLowerCase()}`);

export function styleToCSS(styleObj: object) {
if (!styleObj || typeof styleObj !== "object" || Array.isArray(styleObj)) {
throw new TypeError(`expected an argument of type object, but got ${typeof styleObj}`);
}
return Object.keys(styleObj)
.map(
(property) =>
`${camelToKebab(property)}: ${styleObj[property as keyof typeof styleObj]};`
)
.join("\n");
}
10 changes: 0 additions & 10 deletions packages/bits-ui/src/lib/shared/style-object-to-css-string.d.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/bits-ui/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ export type * from "$lib/bits/toggle/types.js";
export type * from "$lib/bits/toggle-group/types.js";
export type * from "$lib/bits/toolbar/types.js";
export type * from "$lib/bits/tooltip/types.js";
export type { PortalProps } from "$lib/bits/utilities/portal/types.js";
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions sites/docs/content/utilities/portal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
title: Portal
description: A component that renders its children in a portal, preventing layout issues in complex UI structures.
---

## Overview

The Portal component is a utility component that renders its children in a portal, preventing layout issues in complex UI structures. This component is used for the various Bits UI component that have a `Portal` sub-component.

## Usage

### Default behavior

By default, the `Portal` component will render its children in the `body` element.

```svelte
<script lang="ts">
import { Portal } from "bits-ui";
</script>
<Portal>
<div>This content will be portalled to the body</div>
</Portal>
```

### Custom target

You can use the `to` prop to specify a custom target element or selector to render the content to.

```svelte
<script lang="ts">
import { Portal } from "bits-ui";
</script>
<div id="custom-target"></div>
<div>
<Portal to="#custom-target">
<div>This content will be portalled to the #custom-target element</div>
</Portal>
</div>
```

### Disable

You can use the `disabled` prop to disable the portal behavior.

```svelte
<script lang="ts">
import { Portal } from "bits-ui";
</script>
<Portal disabled>
<div>This content will not be portalled</div>
</Portal>
```

0 comments on commit ce49f50

Please sign in to comment.