rn-motion-ui@3.4.0
Minor Changes
-
281ac6a: feat(a11y): accessibility sweep of the overlay, carousel, progress and decorative components, plus a writing-direction primitive
Modal semantics.
BottomSheetandActionFeedbackModalnow exposerole="dialog"witharia-modal, take anaccessibilityLabel, and contain keyboard focus on the web through the newuseFocusTraphook — react-native-web rendersModalas 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 fromModalitself, so the hook is a no-op there.BottomSheetalso gainscloseAccessibilityLabel(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.
ActionFeedbackModalwraps 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 explicitannounceForAccessibility—accessibilityLiveRegionis Android-only and VoiceOver does not re-read a subtree that mutated under it.Values.
CylinderCarouselis 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.ScrollProgressreportsrole="progressbar"and a live percentage, mirrored off the UI thread in 5% steps so the indicator stays frame-driven.RangeSlideris fixed as part of this: it set React Native's nestedaccessibilityValue, which react-native-web does not read at all — it forwards only the flataria-value*props — so on the web the slider announced no value whatsoever. Every value-bearing component now emits both spellings.Decorative content.
SkeletonandMarquee'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) andrn-motion-ui/hooks/direction-provider(DirectionProvider). These exist becauseI18nManager.isRTLcannot be the answer on its own: react-native-web'sI18nManageris a stub whoseisRTLis hard-codedfalse, 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.Marqueeis the first component wired up:directionaccepts 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.Tabswas 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.TabsListgained an optionaltestID— the sliding indicator is exposed as${testID}-indicator, so its position can be asserted.RangeSlidernow 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 (locationXis 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 optionalwritingDirectionprop opts out, for a track whose axis is a thing rather than a quantity — a timeline or a seek bar.Tablecell alignment now follows the writing direction when a column does not setalign. Previously the default paired a direction-relativealignItems: 'flex-start'with a hard-lefttextAlign, so under RTL the text sat on the left inside a right-aligned cell. An explicitalign: 'left' | 'right'stays physical — a column of numbers asking forrightmeans right. Column order is untouched and now documented as the consumer's call: the table renders thecolumnsarray as given, since whether the first column belongs on the right depends on what the data means.Tablecolumn drag-to-reorder now mirrors as well. Its drop boundaries are accumulated from column widths in column order rather than measured, so unlikeTabsit could not inherit the platform's mirroring — the boundary table describes the logical axis while the pointer'spageXis 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-utils—columnBoundaries,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):
renderBodyslot for wrapping the file arearenderBodydecorates the file area instead of replacing it. WhererenderHeaderandrenderFooterhand you a state snapshot and take whatever you return, this one also hands youstate.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. Returningstate.contentunchanged 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 asFileSystemBodyState, so a wrapper tracks the same selection and folder the views do without recomputing any of it.isEmptyis 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,
renderBodyis 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 insiderenderBody— put them in a component you render inside the returned tree.The wrapper renders inside the file-area node rather than around it, so
bodyClassNamestill applies and the area keeps its flex sizing and web text-selection guard however you nest things. Give the returned treeflex-1(orsize-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 fromFileSystemand theAdaptiveDropdownpanelNew
SURFACE_CLASSNAMEonrn-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 callingsurfaceBackgroundandelevatedShadowseparately.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:
surfaceBackgroundandelevatedShadowstill 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 drawsrounded-xl border border-border, andAdaptiveDropdown's floating panel no longer drawsborder border-border. Both now render an unframed surface, leaving the frame to the container they sit in — aFileSysteminside a card or a pane of its own was stacking two borders, and there was no way to opt out.FileSystemtakes the old chrome back throughclassName="rounded-xl border border-border"; the sharedcnresolves consumer classes last-wins, so it applies. The dropdown panel has no such escape hatch —contentClassNamereaches the body inside the panel, not the panel itself — so its border cannot currently be restored from the outside. It keeps itsrounded-2xland itselevationshadow, which is what separates it from the page.Internally, the per-file
cncopies inCard,SkeletonandAdaptiveModal— each a comment claiming the package ships no sharedcn— are replaced by the realsrc/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
useSafeInsetsatrn-motion-ui/hooks/use-safe-insetsThe hook shipped in the source tree with the
safeAreaoverlay work but was never added to the package'sexportsmap, so consumers could not import it —rn-motion-ui/hooks/use-safe-insetsresolved to nothing while every other hook was reachable.It resolves device safe-area insets through
react-native-safe-area-contextwhen 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' }}. Butbox-noneis not real CSS — react-native-web implements it in the StyleSheet compiler, which expands it intopointer-events: noneon the node pluspointer-events: autoon its direct children. That expansion only runs for compiled styles; the inline-style path passes the value straight to the DOM, where the browser discardspointer-events: box-noneas invalid and the node keeps the defaultauto. The positioning layer therefore sat on top of the scrim and swallowed every tap. Moving the style intoStyleSheet.createruns it through the compiler. Native reads the same style object directly and was unaffected.testIDnow also propagates to the scrim as<testID>-backdrop, matchingBottomSheet, so the dismiss target is addressable from tests.