Soundorv0.13.0

Portal

Children rendered elsewhere in the view, over everything or into a host.

Portal renders its children elsewhere in the plugin view while they stay in their component's React tree: their context, state and effects are kept. It is a Soundor extension, not a React Native component, built on the renderer's own portals: no ReactDOM, and no other window.

A tooltip is the trigger's box in surface coordinates and a portal:

import { Portal, Text, View } from '@soundor/react';
import { useRef, useState } from 'react';
import type { LayoutRect, UiNode } from 'soundor:ui';

// A tooltip: the trigger's box in surface coordinates, and a portal that
// draws above everything else, out of any clipping.
function Hint({ label }: { label: string }) {
  const ref = useRef<UiNode>(null);
  const [box, setBox] = useState<LayoutRect | null>(null);

  return (
    <View
      ref={ref}
      onPointerEnter={() => setBox(ref.current!.getBoundingClientRect())}
      onPointerLeave={() => setBox(null)}
      style={{
        width: 28,
        height: 28,
        borderRadius: 14,
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#3a7bfd',
      }}
    >
      <Text style={{ color: 'white' }}>?</Text>
      {box && (
        <Portal>
          <View
            style={{
              position: 'absolute',
              left: box.x,
              top: box.y + box.height + 6,
              padding: 8,
              borderRadius: 6,
              backgroundColor: '#e6e7ea',
            }}
          >
            <Text style={{ color: '#17181c' }}>{label}</Text>
          </View>
        </Portal>
      )}
    </View>
  );
}

export default function Tooltip() {
  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
      <Text style={{ color: '#e6e7ea', fontSize: 16 }}>Drive</Text>
      <Hint label="Saturation before the filter" />
    </View>
  );
}
tooltip.tsxSource

Over everything

<Portal> (also host={null}) renders into the view's overlay layer:

  • above all of the content, whatever its zIndex, and out of any overflow clipping or scrolling;
  • in surface coordinates: place it with an event's pageX/pageY or a node's getBoundingClientRect() (coordinates);
  • filling the view, letting the pointer through where it draws nothing;
  • above the portals opened before it, so one opened from inside another (a dropdown in a dialog) is on top of it.

There is one overlay layer, not one per kind of overlay, and it is not exported: <Portal> is the way in. A context menu is onContextMenu, the pointer's position and a portal:

import { Portal, Pressable, Text, View } from '@soundor/react';
import { useState } from 'react';

const ACTIONS = ['Copy', 'Paste', 'Reset to default'];

// Right-click (or Ctrl-click) the track: onContextMenu gives the pointer's
// position in the view, and a portal draws the menu there, above everything.
export default function ContextMenu() {
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
  const [last, setLast] = useState('nothing yet');

  return (
    <View style={{ alignItems: 'center', gap: 10 }}>
      <View
        onContextMenu={(event) => {
          event.preventDefault();
          setMenu({ x: event.pageX, y: event.pageY });
        }}
        onPointerDown={(event) => {
          if (event.button === 0) setMenu(null);
        }}
        style={{
          width: 260,
          padding: 16,
          borderRadius: 8,
          backgroundColor: '#24262c',
        }}
      >
        <Text style={{ color: '#e6e7ea' }}>Track 1 · right-click me</Text>
      </View>
      <Text style={{ color: '#8b8f98' }}>Last action: {last}</Text>
      {menu && (
        <Portal>
          <View
            style={{
              position: 'absolute',
              left: menu.x,
              top: menu.y,
              padding: 4,
              borderRadius: 8,
              backgroundColor: '#e6e7ea',
            }}
          >
            {ACTIONS.map((action) => (
              <Pressable
                key={action}
                accessibilityRole="menuitem"
                onPress={() => {
                  setLast(action);
                  setMenu(null);
                }}
                style={({ hovered }) => ({
                  paddingVertical: 6,
                  paddingHorizontal: 10,
                  borderRadius: 4,
                  backgroundColor: hovered ? '#c9d8ff' : 'transparent',
                })}
              >
                <Text style={{ color: '#17181c' }}>{action}</Text>
              </Pressable>
            ))}
          </View>
        </Portal>
      )}
    </View>
  );
}
context-menu.tsxSource

Into a host

<Portal host={host}> renders into the <Portal.Host host={host}>, as its children: clipped, stacked and scrolled wherever that host is. Hosts are opaque handles from createPortalHost().

const panelHost = createPortalHost();

<View style={{ overflow: 'hidden' }}>
  <Portal.Host
    host={panelHost}
    style={{ position: 'absolute', inset: 0, pointerEvents: 'box-none' }}
  />
</View>

<Portal host={panelHost}>…</Portal>

Content waits, rendering nothing, until its host mounts, goes when the host unmounts, and comes back with it. Showing one host in two places at once is an error.

Events and focus

Pointer and key events bubble through the nodes as they are shown: from a portal's content up to its host (or the overlay layer), not through the component that declared it. Focus follows React: content a FocusScope renders through a portal belongs to that scope.

Props

Prop

Type

Portal.Host:

Prop

Type

On this page