From 697e80ef72a4b4f9d79bd74742b6d5078ca9a5a1 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 03:01:52 +0700 Subject: [PATCH] VisuallyHidden and Portal utilities --- packages/core/src/utilities/portal/index.ts | 2 ++ packages/core/src/utilities/portal/portal.tsx | 25 ++++++++++++++ .../src/utilities/visually-hidden/index.ts | 2 ++ .../visually-hidden/visually-hidden.tsx | 34 +++++++++++++++++++ packages/core/tests/utilities/portal.test.tsx | 23 +++++++++++++ 5 files changed, 86 insertions(+) create mode 100644 packages/core/src/utilities/portal/index.ts create mode 100644 packages/core/src/utilities/portal/portal.tsx create mode 100644 packages/core/src/utilities/visually-hidden/index.ts create mode 100644 packages/core/src/utilities/visually-hidden/visually-hidden.tsx create mode 100644 packages/core/tests/utilities/portal.test.tsx diff --git a/packages/core/src/utilities/portal/index.ts b/packages/core/src/utilities/portal/index.ts new file mode 100644 index 0000000..140833d --- /dev/null +++ b/packages/core/src/utilities/portal/index.ts @@ -0,0 +1,2 @@ +export { Portal } from "./portal"; +export type { PortalProps } from "./portal"; diff --git a/packages/core/src/utilities/portal/portal.tsx b/packages/core/src/utilities/portal/portal.tsx new file mode 100644 index 0000000..c5ce33e --- /dev/null +++ b/packages/core/src/utilities/portal/portal.tsx @@ -0,0 +1,25 @@ +import type { JSX } from "solid-js"; +import { isServer } from "solid-js/web"; +import { Portal as SolidPortal } from "solid-js/web"; + +export interface PortalProps { + /** Target container. Defaults to document.body. */ + target?: Element | null; + children: JSX.Element; +} + +/** + * SSR-safe portal. During SSR, renders content inline. + * On the client, moves content to the target container. + */ +export function Portal(props: PortalProps): JSX.Element { + if (isServer) { + return <>{props.children}; + } + + return ( + + {props.children} + + ); +} diff --git a/packages/core/src/utilities/visually-hidden/index.ts b/packages/core/src/utilities/visually-hidden/index.ts new file mode 100644 index 0000000..763840b --- /dev/null +++ b/packages/core/src/utilities/visually-hidden/index.ts @@ -0,0 +1,2 @@ +export { VisuallyHidden } from "./visually-hidden"; +export type { VisuallyHiddenProps } from "./visually-hidden"; diff --git a/packages/core/src/utilities/visually-hidden/visually-hidden.tsx b/packages/core/src/utilities/visually-hidden/visually-hidden.tsx new file mode 100644 index 0000000..51cf641 --- /dev/null +++ b/packages/core/src/utilities/visually-hidden/visually-hidden.tsx @@ -0,0 +1,34 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface VisuallyHiddenProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +const visuallyHiddenStyle: JSX.CSSProperties = { + position: "absolute", + border: "0", + width: "1px", + height: "1px", + padding: "0", + margin: "-1px", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + "white-space": "nowrap", + "word-wrap": "normal", +}; + +/** + * Renders content visually hidden but accessible to screen readers. + */ +export function VisuallyHidden(props: VisuallyHiddenProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "style"]); + return ( + + {local.children} + + ); +} diff --git a/packages/core/tests/utilities/portal.test.tsx b/packages/core/tests/utilities/portal.test.tsx new file mode 100644 index 0000000..3f6dc7a --- /dev/null +++ b/packages/core/tests/utilities/portal.test.tsx @@ -0,0 +1,23 @@ +import { render } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { Portal } from "../../src/utilities/portal/portal"; + +describe("Portal", () => { + it("renders children into document.body by default", () => { + render(() =>
hello
); + // Content should be in document.body, not the render container + expect(document.body.querySelector("[data-testid='portal-content']")).toBeTruthy(); + }); + + it("renders children into a custom target", () => { + const target = document.createElement("div"); + document.body.appendChild(target); + render(() => ( + +
hello
+
+ )); + expect(target.querySelector("[data-testid='custom-portal']")).toBeTruthy(); + document.body.removeChild(target); + }); +});