Skip to content

rn-motion-ui@3.4.0

Choose a tag to compare

@github-actions github-actions released this 29 Jul 03:24
· 158 commits to main since this release
771e2ad

Minor Changes

  • 281ac6a: feat(a11y): accessibility sweep of the overlay, carousel, progress and decorative components, plus a writing-direction primitive

    Modal semantics. BottomSheet and ActionFeedbackModal now expose role="dialog" with aria-modal, take an accessibilityLabel, and contain keyboard focus on the web through the new useFocusTrap hook — react-native-web renders Modal as an ordinary fixed <div>, so Tab previously walked straight out of an open sheet and into the page behind it, where a keyboard user could operate controls they could not see. Native already had containment from Modal itself, so the hook is a no-op there.

    BottomSheet also gains closeAccessibilityLabel (default 'Close'): the backdrop is now a labelled button, because the drag handle it sits next to is a pointer-only affordance and was the only way to dismiss the sheet. The handle itself is now hidden from assistive technology.

    Announcements. ActionFeedbackModal wraps its state content in a persistent live region, so a spinner resolving to success or error is announced instead of changing silently. iOS gets an explicit announceForAccessibilityaccessibilityLiveRegion is Android-only and VoiceOver does not re-read a subtree that mutated under it.

    Values. CylinderCarousel is now an adjustable control with a position value and working increment/decrement actions, giving it a non-pointer way to change slides for the first time. ScrollProgress reports role="progressbar" and a live percentage, mirrored off the UI thread in 5% steps so the indicator stays frame-driven.

    RangeSlider is fixed as part of this: it set React Native's nested accessibilityValue, which react-native-web does not read at all — it forwards only the flat aria-value* props — so on the web the slider announced no value whatsoever. Every value-bearing component now emits both spellings.

    Decorative content. Skeleton and Marquee's duplicated track are hidden from assistive technology on native as well as web. The marquee previously read its entire contents out twice on iOS and Android.

    Writing direction. New rn-motion-ui/hooks/use-direction (useDirection, useIsRTL) and rn-motion-ui/hooks/direction-provider (DirectionProvider). These exist because I18nManager.isRTL cannot be the answer on its own: react-native-web's I18nManager is a stub whose isRTL is hard-coded false, so any component branching on it is silently LTR-only in every browser. The hook reads the right source per platform, and the provider states it explicitly for a subtree.

    Marquee is the first component wired up: direction accepts the logical values 'start' (new default, identical to the old 'left' under LTR) and 'end' alongside the existing physical ones, and mirrors its travel under RTL — where the platform flips the belt's own row and the old direction tore a gap open in the loop instead of cycling.

    Tabs was audited and needs no change: its indicator and slide direction are both computed from measured geometry, which the platform mirrors along with the layout, so they come out right in either direction. That is now covered by an RTL story rather than left as an assumption. TabsList gained an optional testID — the sliding indicator is exposed as ${testID}-indicator, so its position can be asserted.

    RangeSlider now mirrors under RTL: minimum on the right, filling leftwards, the way a native slider does in an RTL locale. Four things flip together — the pointer mapping (locationX is measured from the physical left edge whichever way the page reads, so without this the slider painted mirrored and then jumped to the wrong value on the first press), the fill's growth origin, the thumb's travel, and the tick positions. A new optional writingDirection prop opts out, for a track whose axis is a thing rather than a quantity — a timeline or a seek bar.

    Table cell alignment now follows the writing direction when a column does not set align. Previously the default paired a direction-relative alignItems: 'flex-start' with a hard-left textAlign, so under RTL the text sat on the left inside a right-aligned cell. An explicit align: 'left' | 'right' stays physical — a column of numbers asking for right means right. Column order is untouched and now documented as the consumer's call: the table renders the columns array as given, since whether the first column belongs on the right depends on what the data means.

    Table column drag-to-reorder now mirrors as well. Its drop boundaries are accumulated from column widths in column order rather than measured, so unlike Tabs it could not inherit the platform's mirroring — the boundary table describes the logical axis while the pointer's pageX is physical, and under RTL the two run opposite ways. Dropping a column on the trailing physical edge now appends it in both directions, and the drop indicator lands on the boundary it marks rather than a column away. The row and column action overlays follow the trailing edge too, instead of pinning to the right.

    That geometry moved out of the hook into three new pure exports on rn-motion-ui/table-utilscolumnBoundaries, dropIndexAt, dropIndicatorX — so the same drop-target maths a custom header needs is available without reimplementing it, and is unit-testable without a gesture.

    No breaking changes: every new prop is optional and the defaults preserve current behaviour.

  • 6c97690: feat(FileSystem): renderBody slot for wrapping the file area

    renderBody decorates the file area instead of replacing it. Where renderHeader and renderFooter hand you a state snapshot and take whatever you return, this one also hands you state.content — the active view, or the empty/loading placeholder standing in for it — so a drop hint, an upload overlay or a details rail can sit alongside the four views without reimplementing any of them. Returning state.content unchanged is a no-op.

    <FileSystem
      renderBody={({ content, isEmpty }) => (
        <View className="flex-1">
          {content}
          {isEmpty ? <DropHint /> : null}
        </View>
      )}
    />

    The snapshot is the state that produced the content — currentPath, entries, view, selectedEntry, searchValue, isSearching, hasActiveFilters, isLoadingCurrentFolder, isEmpty — exported as FileSystemBodyState, so a wrapper tracks the same selection and folder the views do without recomputing any of it.

    isEmpty is not the same as "the placeholder is showing": the columns view keeps its panes over an empty folder, since that is how Finder lets you walk back up a trail, so it only yields to the placeholder while searching or filtering.

    Unlike the header and footer slots, renderBody is called as a plain function rather than mounted as a component. An inline arrow is a new function identity on every render, and a component whose type changes remounts its entire subtree — here that subtree is the active view, so every keystroke in the search field would have reset its scroll offset, its panes and any in-flight drag. Calling it keeps the returned elements in the parent's own tree, where reconciliation compares them by position as usual. The consequence for callers: don't call hooks directly inside renderBody — put them in a component you render inside the returned tree.

    The wrapper renders inside the file-area node rather than around it, so bodyClassName still applies and the area keeps its flex sizing and web text-selection guard however you nest things. Give the returned tree flex-1 (or size-full) if it should fill the area the way the built-in views do.

  • 6c97690: feat(elevated): export SURFACE_CLASSNAME, and drop the built-in frame from FileSystem and the AdaptiveDropdown panel

    New SURFACE_CLASSNAME on rn-motion-ui/elevated — a level-indexed map pairing each surface background with the matching elevation shadow, so a custom surface can take both halves of the ladder at one level without calling surfaceBackground and elevatedShadow separately.

    import { SURFACE_CLASSNAME } from "rn-motion-ui/elevated";
    
    <View className={SURFACE_CLASSNAME[5]} />; // bg-surface-5 shadow-elevated-5

    It is a plain record, not a function, so it is indexed rather than clamped: surfaceBackground and elevatedShadow still take any number and clamp it into range, while an out-of-range index here is a type error and, from untyped JS, undefined. Reach for the functions when the level is computed at runtime.

    Visual change. FileSystem's root no longer draws rounded-xl border border-border, and AdaptiveDropdown's floating panel no longer draws border border-border. Both now render an unframed surface, leaving the frame to the container they sit in — a FileSystem inside a card or a pane of its own was stacking two borders, and there was no way to opt out.

    FileSystem takes the old chrome back through className="rounded-xl border border-border"; the shared cn resolves consumer classes last-wins, so it applies. The dropdown panel has no such escape hatch — contentClassName reaches the body inside the panel, not the panel itself — so its border cannot currently be restored from the outside. It keeps its rounded-2xl and its elevation shadow, which is what separates it from the page.

    Internally, the per-file cn copies in Card, Skeleton and AdaptiveModal — each a comment claiming the package ships no shared cn — are replaced by the real src/lib/cn.ts. Those copies only concatenated, so a consumer class and a component default targeting the same utility group both survived into the class string and the winner came down to stylesheet order. They now resolve last-wins in the consumer's favour, which is what their prop docs already promised.

  • 58c7e45: feat(hooks): export useSafeInsets at rn-motion-ui/hooks/use-safe-insets

    The hook shipped in the source tree with the safeArea overlay work but was never added to the package's exports map, so consumers could not import it — rn-motion-ui/hooks/use-safe-insets resolved to nothing while every other hook was reachable.

    It resolves device safe-area insets through react-native-safe-area-context when that optional peer is installed and a <SafeAreaProvider> is above in the tree, and returns zeros otherwise — the same resolution the overlay components use internally, now available for building your own full-screen surfaces.

    import { useSafeInsets } from "rn-motion-ui/hooks/use-safe-insets";

Patch Changes

  • 2c7878d: fix(MorphingModal): close on overlay tap on the web

    Tapping the scrim did nothing on react-native-web. The layer that positions the card fills the whole modal and is meant to let taps through to the scrim behind it, which it asked for with style={{ pointerEvents: 'box-none' }}. But box-none is not real CSS — react-native-web implements it in the StyleSheet compiler, which expands it into pointer-events: none on the node plus pointer-events: auto on its direct children. That expansion only runs for compiled styles; the inline-style path passes the value straight to the DOM, where the browser discards pointer-events: box-none as invalid and the node keeps the default auto. The positioning layer therefore sat on top of the scrim and swallowed every tap. Moving the style into StyleSheet.create runs it through the compiler. Native reads the same style object directly and was unaffected.

    testID now also propagates to the scrim as <testID>-backdrop, matching BottomSheet, so the dismiss target is addressable from tests.