Four New UI Primitives in rn-markdown-editor v1.0.24 — ActionBar, FloatingPanel, Alert, and EditableInputsteemCreated with Sketch.

in Steem Dev13 days ago

Previously:


Version v1.0.24 is now live on npm. Where v1.0.23 filled in the foundational layout and typography primitives, this release goes the other direction — interactive, overlay-driven components that sit on top of everything else in the tree: a floating contextual toolbar, a draggable/resizable window, a redesigned status banner, and a tap-to-edit text field. Four new additions shipped in this version. Here's a detailed breakdown of each one.


ActionBar

A compound, portal-rendered contextual toolbar — the pattern you reach for on a "3 items selected" bulk-action bar, a floating comment composer, or any bottom-anchored action strip that needs to animate in and out above the rest of the screen. The API is intentionally shaped to match the Ark/Zag ActionBar primitive so it's a near drop-in for teams porting a web design system to React Native.

How it works internally:

ActionBar is a compound component — Root, Positioner, Content, Separator, and CloseTrigger — built around a React context that carries open state, size, generated element ids, an Reanimated SharedValue progress value, and the resolved placement. Every subcomponent besides Root calls useActionBarContext, which throws a descriptive error if it's rendered outside a Root, so misuse fails fast in development rather than silently rendering nothing.

Open/close is delegated to a useActionBarState hook so Root stays agnostic to whether the bar is controlled (open), uncontrolled (defaultOpen), or externally kept alive (present) — the same three-tier pattern used elsewhere in the library for disclosure-style components. mounted (from that hook) and open are tracked separately so unmountOnExit can still play the exit animation before the subtree actually unmounts, rather than yanking it off screen instantly.

The show/hide transition itself runs on the UI thread: a shared progress value is animated with withTiming — cubic ease-out on the way in, cubic ease-in on the way out — and Content reads that value inside useAnimatedStyle to drive opacity, a vertical slide (24px, flipped depending on whether the bar is placed at the top or bottom of the screen), and a subtle scale-up from 0.96 to 1. When the exit animation finishes, runOnJS hands control back to JS to fire handleExitComplete, which is what actually unmounts the bar if unmountOnExit is set.

Placement (top / bottom, each with optional -start / -end) is resolved against useSafeAreaInsets plus a configurable offset, so the bar clears the notch/home-indicator area automatically instead of needing manual padding per screen. Outside-press dismissal measures each ref in persistentElements with measureInWindow and checks whether the touch point falls inside any of their rects — that's what lets you keep the bar open while the user taps a button that triggered it, without that button counting as "outside."

By default ActionBar.Content wraps its children in a horizontal ScrollView capped at maxWidthRatio (92% of screen width) so a bar with many actions scrolls instead of overflowing off-screen on narrow devices; passing scrollable={false} falls back to a plain flex row for bars that are known to fit.

function SelectionActionBar({ count, onArchive, onDelete, onClear }: {
  count: number;
  onArchive: () => void;
  onDelete: () => void;
  onClear: () => void;
}) {
  return (
    <ActionBar.Root open={count > 0} positioning={{ placement: "bottom", offset: 24 }}>
      <ActionBar.Positioner>
        <ActionBar.Content>
          <Text>{count} selected</Text>
          <ActionBar.Separator />
          <Button label="Archive" onPress={onArchive} variant="ghost" />
          <Button label="Delete" onPress={onDelete} variant="ghost" />
          <ActionBar.Separator />
          <ActionBar.CloseTrigger onPress={onClear}>
            <Icons.Close size={16} />
          </ActionBar.CloseTrigger>
        </ActionBar.Content>
      </ActionBar.Positioner>
    </ActionBar.Root>
  );
}

Key props (ActionBar.Root):

PropTypeDefaultDescription
open / defaultOpenbooleanControlled / uncontrolled visibility
positioning{ placement, offset }{ placement: "bottom", offset: 16 }Anchor edge and inset from safe area
size`"sm" \"default" \"lg"`"default"Padding, gap, and corner radius scale
modalbooleanfalseTraps outside interaction when true
closeOnInteractOutsidebooleantrueDismiss on outside press
lazyMount / unmountOnExitbooleanfalse / trueMount timing controls
portalledbooleantrueRender through the app's portal layer

FloatingPanel

A draggable, resizable, minimize/maximize/close window — the mobile-native answer to a desktop-style floating panel, ported from the web-oriented Zag/Ark-UI floating-panel shape. Use it for things like a persistent mini-player, a movable debug console, or a picture-in-picture-style overlay that the user can reposition anywhere on screen.

How it works internally:

The panel renders through a transparent, full-screen React Native Modal rather than inline in the component tree. That's a deliberate choice: it means the drag/resize boundary is always the full device viewport (via useWindowDimensions), no matter how small or scroll-clipped the parent container that mounted <FloatingPanel /> happens to be — the same way a native window floats independently of whatever spawned it. Touches outside the panel's bounds pass straight through to the screen behind it via pointerEvents="box-none".

Position and size are each independently controllable or uncontrolled, backed by a single useMergeState<Rect> call so a drag update only patches the x/y axes and a resize update only patches width/height, instead of reconstructing the whole rect object on every gesture frame. Drag and resize gestures are built with react-native-gesture-handler's Gesture.Pan(), one shared drag gesture plus eight independent resize gestures — one per handle axis (n, s, e, w, and the four corners). Each resize gesture snapshots the starting rect onBegin, computes the new rect from the pan translation onUpdate via computeResizedRect (which also enforces minSize/maxSize and optional lockAspectRatio), and snaps the result to gridSize before clamping it to the boundary.

Three stages — default, minimized, maximized — are tracked in local state. Minimizing collapses the panel to just its header height; maximizing stashes the pre-maximize rect in a ref (restoreRectRef) so restoring returns to the exact previous position and size rather than a recomputed default. Resize handles are only rendered when stage === "default", since a minimized or maximized panel isn't meant to be manually resized.

The header cluster (grip icon, title, minimize/maximize/close buttons) is split into its own memoized HeaderActions component specifically so that dragging or resizing — which updates the shared rect value on every gesture frame — doesn't force that subtree to reconcile; none of its props actually change while the panel is simply being moved.

const panelRef = useRef<FloatingPanelHandle>(null);

<FloatingPanel
  ref={panelRef}
  title="Debug Console"
  defaultOpen
  defaultPosition={{ x: 24, y: 100 }}
  defaultSize={{ width: 320, height: 240 }}
  minSize={{ width: 200, height: 44 }}
  closable
  minimizable
  maximizable
  onClose={() => console.log("panel closed")}
>
  <ConsoleOutput />
</FloatingPanel>

// Imperative control from elsewhere in the app
panelRef.current?.minimize();
panelRef.current?.setPosition({ x: 0, y: 0 });

Key props:

PropTypeDefaultDescription
open / defaultOpenbooleanfalseControlled / uncontrolled visibility
position / defaultPositionPointcentered in boundaryControlled / uncontrolled position
size / defaultSizeSize{ width: 360, height: 220 }Controlled / uncontrolled size
minSize / maxSizeSize160×44 / 4096×4096Resize bounds
draggable / resizablebooleantrue / trueEnables the respective gesture
lockAspectRatiobooleanfalsePreserves aspect ratio while resizing
gridSizenumber1Snaps position/size to a grid
closable / minimizable / maximizablebooleantrueToggles each header button
persistRectbooleantrueRemembers rect across reopen when false is unset

Alert

A status banner with Chakra-style status + colorPallete theming — four visual variants, three sizes, an optional icon, and an optional dismiss button. It's the primitive that inline form errors, page-level success banners, and toast-adjacent notices all end up built from.

How it works internally:

Ten color palettes (gray, red, orange, yellow, green, teal, blue, cyan, purple, pink) are defined as base HSL triples, tuned so the solid tone stays legible as text/icon color across the subtle, surface, and outline variants while also reading cleanly as a background behind white text in solid. status (info / warning / success / error / neutral) picks a sensible default palette and a matching icon from Icons, but an explicit colorPallete prop overrides that mapping — so you can have an error-status alert rendered in your brand's purple, for example.

All of the bg/text/border/icon color resolution for a given variant + palette combination happens inside a single useMemo: subtle gives a low-opacity tinted background with no border, surface adds a slightly-more-opaque background plus a tinted border, outline is transparent with a solid-color border, and solid fills the background with the full-strength color and switches text/icon to colors.primaryForeground for contrast. Explicit bgColor/textColor/borderColor/iconColor props take precedence over whatever that computation produces, so the variant system is a set of sensible defaults rather than a hard constraint.

Title and description are rendered through a small memoized AlertText component so that updating one line of text doesn't force the icon or container to re-render. children also accepts a render-prop form — (props: { textColor, iconColor }) => ReactNode — for cases where you need fully custom content but still want it to inherit the resolved variant colors.

<Alert
  status="success"
  variant="surface"
  title="Changes saved"
  description="Your profile has been updated."
  close
  onClose={() => setShow(false)}
/>

// Custom palette override + render-prop children
<Alert status="error" colorPallete="purple" variant="solid">
  {({ textColor }) => (
    <Text style={{ color: textColor, fontWeight: "600" }}>
      Sync failed — tap to retry
    </Text>
  )}
</Alert>

Props:

PropTypeDefaultDescription
status`"info" \"warning" \"success" \"error" \"neutral"`"info"Selects default palette + icon
colorPalleteone of 10 named colorsderived from statusOverrides the resolved color
variant`"subtle" \"surface" \"outline" \"solid"`"subtle"Visual treatment
size`"sm" \"md" \"lg"`"md"Padding, gap, icon and text scale
closebooleanfalseShows a dismiss button
icon`ReactNode \false`status iconCustom icon, or false to hide it
title / descriptionReactNodeDefault content slots
bgColor / textColor / borderColor / iconColorstringExplicit overrides

EditableInput

A text field that starts out as a locked, borderless label and switches into an editable field on double-tap (or double-click on web), ending edit mode on blur. It's the "click to rename" pattern — list item titles, board column names, inline-editable settings — without wiring up the mode-switching by hand each time.

How it works internally:

EditableInput wraps the library's existing Input component and simply drives three of its props — variant, typeable, and readOnly — off a single isEditing boolean, rather than being a separate input implementation. In preview mode that resolves to previewVariant ("transparent" by default) with typeable={false} and readOnly={true}; in edit mode it flips to editVariant ("outline" by default) with typeable={true} and readOnly={false}.

Double-tap detection is hand-rolled for native: a ref tracks the timestamp of the last tap, and a second tap within DOUBLE_TAP_DELAY (300ms) triggers edit mode. On web, that fallback is layered under a real onDoubleClick handler, since browsers already dispatch double-click reliably and there's no reason to rely on tap-timing there. Entering edit mode also calls requestAnimationFrame before focusing the underlying TextInput — the extra tick ensures the field has actually become editable before the focus call fires, which avoids a native focus request landing on a still-disabled input.

While in preview mode, a Pressable is absolutely positioned over the entire field (StyleSheet.absoluteFill) to capture the tap-timing without requiring the underlying Input itself to handle press events — once editing starts, that overlay unmounts entirely so it doesn't intercept keystrokes or cursor placement. Blur exits edit mode and, if submitOnBlur is true (the default), fires onSubmit with the current value; an imperative handle also exposes edit(), cancel(), focus(), and blur() for triggering the same transitions from outside — a "rename" button next to the field, for instance.

const [title, setTitle] = useState("Untitled board");
const editableRef = useRef<EditableInputRef>(null);

<EditableInput
  value={title}
  onChange={setTitle}
  onSubmit={(finalValue) => saveBoardTitle(finalValue)}
  previewVariant="transparent"
  editVariant="outline"
/>

// Trigger edit mode from a separate "Rename" button
<Button label="Rename" onPress={() => editableRef.current?.edit()} />

Props:

PropTypeDefaultDescription
valuestringControlled text value
onChange(text: string) => voidFires on every keystroke while editing
onSubmit(value: string) => voidFires when editing ends (on blur, if submitOnBlur)
onCancel(value: string) => voidFires when editing is cancelled (Escape on web)
disabledbooleanfalseFully disables entering edit mode
previewVariant / editVariantInputVariant"transparent" / "outline"Variant shown outside/inside edit mode
submitOnBlurbooleantrueAuto-exits edit mode on blur
containerStyleViewStyleStyle for the wrapping container

Installation

All four components are available now in v1.0.24:

npm install rn-markdown-editor

or

yarn add rn-markdown-editor

ActionBar and FloatingPanel additionally depend on react-native-reanimated, react-native-gesture-handler, and react-native-safe-area-context being installed and configured in your app — the same peer dependencies already required for the editor's existing gesture-driven components.

More components are in active development and will be covered in the next release post as soon as they land.


System Reliability & Support

The development team remains committed to maintaining a secure, efficient, and highly optimized open-source toolkit. Continuous updates are actively deployed to ensure total cross-platform stability across iOS, Android, and Web deployments.

For technical assistance or to report issues, please submit on the comments or our official NPM Project Page.

Thanks for your time and support. Let's make Steem great, again.


Official NPM Link: https://www.npmjs.com/package/rn-markdown-editor
Primary Architecture: TypeScript / JavaScript
Supported Environments: Expo & Bare React Native CLI
New Peer Dependencies: react-native-reanimated, react-native-gesture-handler