rn-motion-ui@5.0.0
Major Changes
-
44a0672: FileSystem: drag and drop now runs on
Draggable/Dragzone/MultiDragManager.Every view had brought its own drag. The list and icons grids shared one hook, the
columns pane had a second one that mirrored its architecture, web had a third for
the HTML5 half and external drops a fourth — five hooks, each measuring boxes,
hit-testing points and tracking a session, and each with its own idea of which
folder a release belonged to. Adding a view meant writing a sixth.They are gone, replaced by the components the library already ships. An entry is a
<MultiDraggable>, a folder is a<Dragzone>, and the panes and the background are
zones too — so the ladder a drop falls down (entry, then the column under it, then
the open folder) is expressed as zone priority rather than as branches inside a
resolver.propsare unchanged:draggable,onMoveandonExternalDropmean
exactly what they did.What changes is behaviour that used to differ per view, and now cannot:
- The three draggable views drag identically — list, icons and columns — because
none of them implements dragging any more. They resolve a drop through one hit
test rather than three that agreed by hand. - A multi-select drag carries the selection, via
MultiDragManager: drag one of
three selected rows and all three move. The members left behind fade, and lifting
an unselected entry still moves just it. - Autoscroll while dragging near an edge now works in the columns panes too,
each scrolling on its own, where before only the list and icons grid had it. - A drop is resolved from measured boxes, so a touch pan and a mouse drag land
on the same folder. Previously the web path read the DOMdroptarget and the
pan path hit-tested rows, which could disagree at a row boundary.
Two fixes fall out of the same work:
onExternalDropnow fires for an in-library<Draggable>from elsewhere on the
page, not just for an OS file drag. Its documented contract always covered "a
custom element on the page that sets drag data"; a payload with no FileSystem
entries in it is foreign whether or not this library started the drag, and it
reaches the consumer either way.- The hover highlight stands down for the length of a drag, in the list, icons
and columns views alike, so it cannot mark one cell while a zone outlines
another. It comes back on the first pointer move after the drop. A mouse drag is
an HTML5 drag and the browser stops the pointer stream while one runs, so the
highlight now takes the lift itself as its cue rather than waiting for a
pointercancelthat not every engine sends.
FS_DRAG_CONTAINER_TEST_IDstill names each draggable view's scroll surface, and
every entry answers to<root>-entry-<path>in all four views. The internal hooks
useFileSystemDrag,useFileSystemColumnsDrag,useFileSystemIconsDrag,
useFileSystemDragWebanduseFileSystemExternalDropare deleted; none was
exported from the package. - The three draggable views drag identically — list, icons and columns — because
-
706dac3: Breaking:
rn-motion-ui/iconsis gone. Icons live inrn-motion-ui-icons.The 109 icons this package used to re-export were a subset of Lucide,
hand-picked because each one had to be committed as generated source. That is a
bad deal for a consumer: the icon you want is either in that list or it does not
exist for you, and the list only grew when a component here happened to need
something.rn-motion-ui-iconsreplaces it with the whole MingCute set — 3335 icons, one
subpath each. Every icon this package renders internally now comes from there,
so what components use and what you can use are the same set.-import { Check, ChevronRight } from 'rn-motion-ui/icons'; +import { CheckLine } from 'rn-motion-ui-icons/icons/check-line'; +import { RightLine } from 'rn-motion-ui-icons/icons/right-line';
IconPropsmoved too, and is no longer exported from this package at all:-import type { IconProps } from 'rn-motion-ui/icons'; +import type { IconProps } from 'rn-motion-ui-icons/icon-props';
Install it alongside this package —
rn-motion-uidepends on it, so anything
that takes an icon (ThemedIcon,CommandIcon,BloomIcon,FileSystem's
action icons) is already typed against the newIconPropsand needs no change
beyond the import.strokeWidthis gone fromIconProps. Lucide's geometry is stroked and
took a width; MingCute ships fill and stroke variants with the weight baked into
the path, so there is nothing to widen. Drop the prop — it is a type error now.
Where this package passedstrokeWidth={2.5}for a slightly heavier check
(Input,OTPInput,StatefulButton,AnimatedBadge), those icons now render
at MingCute's own weight, which is a visible but deliberate change.Names do not carry over: MingCute names its own icons, and most differ from
Lucide's. Every icon is suffixed-lineor-fill(1667 line, 1668 fill), and
the component name is the PascalCase of the file —icons/check-lineexports
CheckLine. The mapping used for the internal migration, if you were relying on
the same names:was (Lucide) now (MingCute) AlertCircle,Infoicons/information-line→InformationLineAlertTriangleicons/alert-line→AlertLineCheckicons/check-line→CheckLineChevronDown/Up/Left/Righticons/down-line/up-line/left-line/right-lineCircleicons/round-line→RoundLineFileText,ScrollTexticons/file-line→FileLineFolderClosed,FolderKanbanicons/folder-line→FolderLineGripVerticalicons/dots-vertical-line→DotsVerticalLineLoaderCircleicons/loading-line→LoadingLineMoreHorizontalicons/more-1-line→More1LinePlusicons/add-line→AddLineTrash2icons/delete-2-line→Delete2LineUsericons/user-2-line→User2LineXicons/close-line→CloseLineThe rest resolve the same way: kebab-case the concept, add
-lineor-fill,
and the export is its PascalCase.
Minor Changes
-
a4e9e3e: AdaptiveDropdown: rename
headerRight→headerSuffix, removeshowCloseprop; simplify header layout (no fixed height, no border, no built-in close button).BottomSheet: increase top corner radius to
rounded-t-2xl. -
99e42b1: Breadcrumbs: new component — the trail FileSystem already drew, now its own.
A breadcrumb trail: the levels above the current one, each a way back, with the
current one as plain text at the end. It knows nothing about what a level is —
pass the segments outermost-first and read back the pressedid, so a folder
path, a route key and a wizard step are all the same thing to it.import { Breadcrumbs } from "rn-motion-ui/breadcrumbs"; <Breadcrumbs items={[ { id: "", label: "Files" }, { id: "documents", label: "Documents" }, { id: "documents/reports", label: "Reports" }, ]} onNavigate={navigateTo} />;
A deep trail scrolls horizontally by default, keeping one line. Set
maxVisible
instead to hold it to a fixed number of levels: the middle folds behind a…
that says how much it hides and hands those levels back when pressed, so nothing
becomes unreachable.scrollable={false}wraps instead of scrolling.The rest of the surface:
separatorreplaces the chevron with any node,size
picks the text scale ('sm'|'base') and takes the separator and icons with
it, aniconper item rides ahead of its label, andcurrentIdpicks which level
is the destination —nullmakes every level pressable, for a trail whose leaf is
not where you are.className,contentClassNameanditemClassNamereach the
container, the segment row and each segment.Accessibility: the container is a
listnamedBreadcrumb, every earlier level
is abuttonnamedGo to {label}(override per item withaccessibilityLabel),
and the current level is text — being the one unpressable segment is what marks
it as current. RN has noaria-current, so the trail does not claim one.FileSystemnow renders this component instead of its own private trail. No
API change and no visual change: same placement under the header, same hiding at
the root, samerootLabelas the leading segment, and the sameGo to {label}
names its stories already query. Both trails — the bar and the per-row ones under
search results — are now built from onebuildCrumbs. -
ae616b8:
Card: passonPressand the surface becomes pressableA card that stands for something you can open had to be wrapped in a
Pressable
by hand, which meant a second element around the one that already draws the
frame. GiveCardanonPressand it renders as thePressableitself:<Card elevation={2} onPress={() => open(project.id)}> <Text>{project.name}</Text> </Card>
Omit it and nothing changes — the card is the plain
Viewit always was, with no
press responder in the tree. The size, elevation andclassNamehandling are the
same either way. -
92504b5: Dragzone, DragManager: the receiving half, so a drag can mean something.
Draggablecould carry a payload but had nowhere to put it: you got points in
window coordinates and wrote the hit testing yourself, per screen, per platform.
Two components close that, and they compose rather than nest — a source and a
zone find each other through one module-level store, so a pair of them works with
no provider anywhere above:<Draggable data={{ 'application/x-card': card.id }} groups={['cards']}> <Card {...card} /> </Draggable> <Dragzone groups={['cards']} overClassName="border-info bg-info/10" onDrop={({ transfer }) => move(transfer.getData('application/x-card'))}> <Column /> </Dragzone>
groupsis the whole compatibility mechanism, and eligibility and acceptance are
one predicate rather than two: the same check that lights a zone up decides whether
it takes the release, so a zone cannot highlight and then refuse.acceptsgets
the last word for a rule only the payload knows,disabledremoves a zone from
the decision entirely, andeligibleClassName/overClassName— or the render-prop
form,{(state) => …}— handle the affordance without a state machine of your own.One authority on both platforms. Zones publish their measured boxes to the
store and the store hit-tests points against them, so a native pan and a browser
drag resolve a drop through the same code. Overlap is settled without
configuration: explicitpriority, then nesting depth, then the smaller box, then
mount order — which is what makes a trash can inside a board work with neither
side declaring anything.priorityis there for when the geometry does not say
what you meant.<DragManager>is optional, and adds the four things that need a place in the
tree to mean anything: a default group for its whole subtree, a boundary drags
cannot cross (isolate), a frame to draw the ghost in that survives a clipping
ancestor, and one vantage point to observe every drag beneath it from —
onDragStart/onDragMove/onDragEnd/onDropcover the subtree, not just the
children you can point at. Managers nest by publishing a path of ids rather than
by stacking providers, so an inner board isolates from an outer one without either
knowing the other exists, and a zone mounted through a portal or on another screen
stays reachable as long as no isolating manager sits between.The payload is readable for the whole drag, which on web it is not by default.
The DOM drag data store is protected on every event betweendragstartand the
drop —typesstill lists the formats,getDatareturns'', a privacy rule so a
page cannot read what is merely being dragged across it — and that applies to the
source's own listeners too,dragendincluded. So a zone askingacceptswhat is
coming, or anonDropreading it out, would get nothing under a real mouse drag.
The transfer handed to every callback is therefore a readable mirror, snapshotted
at lift while the store still answers, with writes going through to the browser's
own so a format added mid-drag still crosses to a plaindroplistener.External drags.
acceptsExternallets a zone take a payload the library never
saw start — OS files, another tab — arriving asdrag: null,external: true, and
fileson the event. Off by default, because a zone that has not asked for files
should not swallow the page's own drop handling.Two subscription channels keep the cost honest: render-visible state (a drag
starting, a zone edge crossed, a drag ending) goes throughuseSyncExternalStore,
while pointer movement goes to a separate move channel that no component re-renders
for. Travelling inside one zone publishes nothing at all.Both are pointer-only, on every platform. A manager is the natural home for the
non-pointer path the same outcome owes its users, since it already sees every drop.Draggable,DragzoneandDragManagernow live under agesturescategory —
rn-motion-ui/draggableis unchanged, and the store and its hooks are exported at
rn-motion-ui/drag-storeandrn-motion-ui/use-drag-storefor custom transports
or zones. New types:DragzonePropsandDragManagerPropsfrom their own
subpaths, and — fromrn-motion-ui/drag-types—DragzoneHandle,
DragzoneRenderState,DragzoneDropEvent,DragzoneAcceptEvent,
DragManagerHandle,DragManagerEvent,ActiveDrag,DragSnapshot,
DragEndOutcome. -
b7ea1df: Draggable: one grab-and-carry wrapper for web and native.
Making something draggable meant writing the platform down. On web you set
draggableon a DOM node and ride the HTML5 events — and under
react-native-web you cannot even do that from JSX, becauseViewdrops unknown
HTML attributes, so it took auseEffectreaching forref.currentas an
HTMLElement. On native there is no such API at all, so you wired a pan gesture
by hand.Draggableis that work done once:<Draggable data={{ "application/x-my-item": JSON.stringify(item) }} effectAllowed="copy" onDragEnd={({ canceled, transfer }) => { if (!canceled) console.log(transfer.getData("application/x-my-item")); }} > <Chip label={item.name} /> </Draggable>
datais a MIME-keyed payload, written into the transfer when the drag starts.
onDragStart/onDragMove/onDragEndfire the same shapes on both platforms,
with points in window coordinates.Three transports sit behind that one contract, each the one actually native to
where it runs. A mouse on web rides a real HTML5 drag rather than a synthesized
one, and hands the browser's ownDataTransferstraight through — which is what
makes the payload cross to code that has never heard of this component: an
existingdragover/droplistener, or<FileSystem onExternalDrop>, receives
these drags with no adapter. Touch on web gets a pointer-driven pan instead,
because mobile browsers fire no HTML5 drag for touch at all and the component
would otherwise simply not work on a phone. Native arms an RNGH pan. Both pans
wait out a 300ms hold, matching the context-menu hold so the two never both fire,
and draw a ghost that follows the finger.transportspins the choice when you
need to:'pan'keeps a drag inside the library with a uniform ghost and no OS
drag session,'html5'opts a component out of touch dragging.onDragEndreports the platform's verdict instead of guessing:dropEffectis
what a zone claimed, andcanceledisdropEffect === 'none'on both sides. A
native zone claims a drag exactly as a browser one does, by writing
transfer.dropEffectwhile the drag is over it.The ref is a
DraggableHandle:isDragging(),getTransfer(),getNode(),
measure()(a promise on both platforms, since native'smeasureInWindowis
callback-based), andcancel().cancel()is honestly partial on web — once the
browser owns a drag, only the user can end it, so it clears component state and
the store's session while the browser's drag image keeps following the cursor.groupsnames what this drag is, and a<Dragzone>takes it when their labels
intersect — omit them on both sides and everything matches everything, which is
the right default for a tree with one kind of drag in it. See the drag system
changeset for the receiving half.A drag is pointer-only on both platforms, so anything expressed only as a drag
needs a second non-pointer path to the same outcome. New types:
DraggableProps,DraggableTransports,DraggableHandle,DragTransfer,
DragStartEvent,DragMoveEvent,DragEndEvent,DragPoint,DragRect,
DragDropEffect,DragEffectAllowed,DragGroups. -
072fe70: FileSystemColumnsView: cross-column drag and drop.
draggableandonMovenow work in the columns view as they do in the others:
an entry can be dragged across panes and dropped on any valid folder, or on a
column's own background to land in the folder that pane is showing. A ghost chip
tracks the pointer, and the receiving row — or the whole pane, for a drop on its
background — outlines itself.- Geometry constants
COLUMN_ROW_HEIGHT,COLUMN_ROW_STRIDE,COLUMN_PADDING
and thecolumnRowHitAthit-test helper are exported from
file-system-column, shared with the marquee and hover resolvers. FS_DRAG_CONTAINER_TEST_IDgains acolumnkey.
- Geometry constants
-
e86dced: FileSystem:
onExternalDrop— accept a drop from outside the component.onMovecovers dragging entries around inside the browser. It has nothing to say
about a file dragged in from the OS, or a chip dragged from elsewhere on the page.
onExternalDropis that second half, and it hands you the raw transfer rather
than trying to interpret it:<FileSystem entries={entries} onExternalDrop={({ dataTransfer, destination }) => { for (const file of dataTransfer.files) upload(file, destination); }} />
destinationis the folder the drop landed in, with a trailing slash;''is the
implicit root. ReaddataTransfer.filesfor OS files or
dataTransfer.getData(mime)for data another element set in itsdragstart. The
component never inspects the transfer, so any MIME the browser will carry works.Passing the prop is what arms it — the file area accepts external drags and shows
a dashed drop-zone overlay while one hovers. Leave it out and nothing binds.The list and columns views resolve the pointer to a row on every
dragover, so a
folder row under the cursor takes the drop and gets the same per-row highlight an
internal drag draws. A file row, the padding, or empty space falls back to the
open folder and the background overlay. Icons and gallery take the background
overlay for the whole area.Web only, and a no-op on native: the HTML5 drag API this rides on
(dragenter/dragover/dragleave/drop) does not exist there. New type:
FileSystemExternalDropEvent. -
d6b0d95: FileSystem: filtering goes headless, and the browser gets breadcrumbs and a
real search view. The component used to own its own toolbar: a sort select, a
filter menu, a Finder-style search field that collapsed to a button at narrow
widths, a row of filter pills beneath the header, and the date-range modal the
filter menu raised. All of it is gone. What stays is the pipeline behind it —
search, sort, file-type and date filtering, custom ranges — now reachable
through arenderFiltersslot that hands you every action and no markup.<FileSystem items={items} renderFilters={({ searchValue, setSearchValue, fileTypeOptions, toggleFileType, count, }) => ( <MyFilterBar count={count} onSearch={setSearchValue} onToggleType={toggleFileType} search={searchValue} types={fileTypeOptions} /> )} />
The header keeps back/forward, the folder name and the view switcher.
Migrating:
Before Now built-in search field, sort select, filter menu and filter pills supply them through renderFilters, or ship no filter UI at allFileSystemHeaderState.isSearchExpanded/setSearchExpandedgone — the collapse-to-a-button behaviour was the built-in field's, and there is no built-in field renderHeaderreceivingfilters,fileTypeOptions,toggleFileType,selectDatePreset,openCustomRange,clearFiltersthose moved to renderFilters;renderHeaderkeeps navigation, view, sort and the raw search valueopenCustomRange(type), which raised the built-in date-range modalapplyCustomRange(type, from, to)— bring your own picker, hand the two ends overrenderFiltersgets everything the old toolbar drove —searchValue/
setSearchValue,sort/setSortKey,filters,fileTypeOptions,
toggleFileType,selectDatePreset,applyCustomRange,clearFilters,
hasActiveFilters,isSearching— pluscount, the visible entry count after
search and filtering. Omit the prop and no filter row renders.Headless goes all the way down: the date-range modal the filter menu used to
raise is gone too, so the component now ships no filter UI whatsoever. Dates come
in throughselectDatePreset(type, preset)for a relative cutoff ('1 week ago'
and friends) orapplyCustomRange(type, from, to)for two explicit ends, which
is where your own calendar hands off.Each active filter carries an
id, and three actions take one:setFilterOperator
negates a row,setFilterDatePresetre-values a date row, andremoveFilter
drops it. That's what a filter-pill UI needs to reach one row without rebuilding
the rest — previously onlyclearFilterswas reachable, which emptied all of
them.Two additions that are not about the slot:
Breadcrumbs. A trail between the header and the file area, one segment per
folder down to the current one, each navigating on press. Hidden at the root,
scrolls horizontally on deep paths. Nothing to opt into.Search shows every match at once. A query used to filter the current
folder's view in place, which meant a match three folders down showed up only as
the ancestor folder leading to it. It now swaps the view for a flat result list
— every matching file at every depth, each row naming the folder it came from,
so a search reads as a search rather than as a filtered folder.Search input is also debounced 200ms before it recomputes, so typing into a
large manifest no longer re-runs the pipeline per keystroke. The field stays
immediate; only the results wait. Navigating clears the query and cancels a
pending recompute, so a debounce in flight can't land on the folder you just
opened.Also:
computeVisiblePathsshort-circuits the ancestor walk for direct children
of the current folder, instead of walking theparentPathchain through the
index every time. -
1d16717: FileSystem: context menus now use
HoldContextMenuthroughout — the same interaction the rest of the app uses.Every entry long-press opens a
HoldContextMenupanel instead of the previous custom modal. The background long-press (empty space in list/icons views and the empty-folder placeholder) does the same. On web the right-click path was already correct; this change brings native into line with it.Breaking changes
FileSystemProps.contextMenuWideBreakpointis removed. The breakpoint that switched the old modal into a sidebar is no longer meaningful —HoldContextMenuhandles its own sizing, and the panel never needed a sidebar mode. Remove the prop from any<FileSystem>usage.FileSystemContextMenuProvideris no longer exported from this package. It was an internal implementation detail of the old modal approach. If you imported it directly, remove the import; the context menu is now self-contained inside each entry row.HoldContextMenu: new
trigger="passive"mode with controlledopen/onOpenChangeWhen the host needs to control exactly when the menu opens — for example, a button that calls
setOpen(true), or an entry row that already owns the long-press gesture — settrigger="passive":const [open, setOpen] = useState(false); <HoldContextMenu items={items} open={open} onOpenChange={setOpen} trigger="passive" > <Pressable onPress={() => setOpen(true)}> <Text>Open menu</Text> </Pressable> </HoldContextMenu>;
trigger="passive"skipsHoldContextMenu's ownPressablewrapper entirely; the host renders whatever gesture target it needs inside. The webcontextmenulistener (right-click / Shift+F10) remains active so keyboard users still reach the panel without extra wiring. -
2f9bfc7: FileSystem:
isLoadingCurrentFolderis nowisLoading.Breaking. The field named the folder it was about, which every other field in
the same snapshot also is —currentPath,entriesandhasActiveFiltersare
all the current folder's, and none of them say so. The qualifier only made this
one longer.FileSystemBodyState.isLoadingCurrentFolder→isLoading, which is what
renderBodyreceives:// Before <FileSystem renderBody={({ content, isLoadingCurrentFolder }) => …} /> // After <FileSystem renderBody={({ content, isLoading }) => …} />
Same value, same meaning:
truewhile the current folder's children are being
fetched. Nothing else about the snapshot changes, and a slot that never read the
field is unaffected. -
e7acea7: FileSystem:
pinnedAt,favoritedAt, andrenderEntryIcon.Three new capabilities land together because they share the same wiring path through the component tree.
Pinned entries
Add
pinnedAt(ISO-8601 string ornull) to any item and it floats to the top of its parent folder, ahead of every unpinned sibling, regardless of the active sort key or direction. Within the pinned group the chosen sort still applies normally.{ kind: 'file', path: 'README.md', pinnedAt: '2026-06-01T00:00:00Z', … }
Favorited entries
Add
favoritedAtto mark an entry as a favorite. The flag is surfaced visually in every view and is already consumed by search (boosts hits) but does not reorder entries within a folder — that stays the caller's responsibility.{ kind: 'file', path: 'Invoice.pdf', favoritedAt: '2026-05-01T00:00:00Z', … }
Visual badges
All four views (list, column, icons, gallery strip) now render a Pin badge and a Heart badge when the corresponding field is set:
- List and column: inline icons flanking the entry name.
- Icons tile: inline in the label chip, tinted to match the selection state.
- Gallery strip: absolute badges pinned to the tile corners, with a translucent halo for readability over thumbnails.
renderEntryIconPass a renderer to substitute a custom icon for any entry. The component falls back to its default glyph when the callback returns
nullorundefined, so partial overrides work without branching on every entry type.<FileSystem renderEntryIcon={(entry, size) => { if (entry.kind === 'folder' && entry.path.startsWith('Archive/')) return <ArchiveIcon size={size} />; }} … />
The prop is forwarded into every view context — icons, list, column, gallery strip — so one callback covers the whole component.
All three additions are purely additive. Existing items without
pinnedAtorfavoritedAtrender exactly as before, andrenderEntryIconis optional. -
234a3cb: FileSystem: search results always say where they live, and show what matched.
Three changes to the flat result list a query swaps the view for.Every row now carries its folder trail, root included. It used to drop the second
line for a hit sitting at the top level, which meant the one thing a result list
is scanned for — where each match lives — was answered for some rows and not
others. The trail is now always there, and the root segment is named rather than
implied.That name is the new
rootLabelprop:<FileSystem items={items} title="Files" rootLabel="My Drive" />
It defaults to
title, so nothing changes unless you set it. It also names the
leading segment of the breadcrumb bar under the header, which previously always
usedtitle— one prop for how the root reads in a trail, withtitleleft as
the header's own name.Whatever matched is highlighted, in the name and in the trail both — a folder
can be the reason a row is in the list at all, since its own name matching is
what puts it there. Every occurrence is marked, case-insensitively, the label's
own casing is untouched, and a label the query matches end to end is marked
whole.The trail separator is a caret, not a slash.
Files › invoices › 2024rather
thaninvoices/2024— it reads as a trail rather than as a path to copy, and it
matches the chevrons the breadcrumb bar above already uses.One note for tests. Highlighting splits a matched label across nested nodes, and
testing-library'sgetByTextreads a single node's own text — so
getByText('Q1-report.pdf')no longer finds a search result row whose name the
query matched. Query the row by its entry test id instead, which is stable across
every view:// `<root testID>-entry-<path>`, or `file-system-entry-<path>` untagged const row = await canvas.findByTestId( "file-system-entry-Reports/Q1-report.pdf" ); expect(row).toHaveTextContent("Files › Reports");
toHaveTextContentreads the whole subtree, so it sees through both the
highlight spans and the trail separators. Rows outside a search are unaffected —
nothing is split when there is no query. -
234a3cb: FileSystem: search can be scoped to the open folder or to the whole tree.
A query used to always run over the open folder's subtree, so finding something
you could not place meant navigating back to the root first and searching again.renderFiltersnow hands the slot a scope it can offer as a control:renderFilters={({ folderName, isAtRoot, rootLabel, searchScope, setSearchScope }) => ( <View className="flex-row items-center gap-1.5"> <Text>Search:</Text> <Chip active={searchScope === 'root'} onPress={() => setSearchScope('root')}> {rootLabel} </Chip> {isAtRoot ? null : ( <Chip active={searchScope === 'folder'} onPress={() => setSearchScope('folder')}> {folderName} </Chip> )} </View> )}
searchScope—'folder'(the open folder and everything under it, the
previous behavior and still the default) or'root'(the whole manifest).setSearchScope— switches it, taking effect immediately on a live query with
no debounce, since the press is the whole gesture.rootLabel,folderName,isAtRoot— enough to name both scopes and to know
that at the root they are the same tree, so only one is worth offering.
The exported
FileSystemSearchScopetype is the union.Two deliberate boundaries. Only a query widens: filters stay scoped to the
folder they are shown against whichever way the scope is set, because a filter
bar reads as being about the folder you are looking at. And the scope outlives a
query — navigating clears the query but keeps the scope armed, so switching to
root once does not have to be redone for every subsequent search.Nothing changes for existing consumers: the default scope is what the component
already did, and a slot that ignores the new state keeps behaving exactly as it
did before. -
3f7a5df: Headless calendar, date picker and date range picker. Three hooks that own
the date arithmetic, the keyboard, and the accessibility payload, and render
nothing. There is no styled<Calendar />here on purpose: a calendar is mostly
markup decisions — seven cells in a row, or aFlatList, or a table — and every
styled one ends up fought with.FileSystem'sapplyCustomRange(type, from, to)
has been waiting for exactly this on the other side of the handoff.import { useCalendar } from "rn-motion-ui/hooks/use-calendar"; const calendar = useCalendar({ mode: "range", numberOfMonths: 2, minDate: "2026-01-01", }); <View {...calendar.getRootProps()}> {calendar.months.map((month) => ( <View key={month.month} {...calendar.getMonthProps(month.month)}> <Text {...calendar.getMonthLabelProps(month.month)}>{month.label}</Text> <View {...calendar.getGridProps(month.month)}> {month.weeks.map((week, index) => ( <View key={index} {...calendar.getWeekProps(month.month, index)}> {week.map((day) => ( <Pressable key={day.date} {...calendar.getDayProps(day)}> <Text>{day.day}</Text> </Pressable> ))} </View> ))} </View> </View> ))} </View>;
Dates are ISO
'YYYY-MM-DD'strings everywhere — arguments, return values,
callbacks. NoDatecrosses the API, so a value compares with===, sorts as a
string, survives JSON, and sits in a dependency array without a stable-reference
dance. The arithmetic underneath runs in UTC, because+1 dayin local time
lands back on the same date across a DST boundary, andnew Date().toISOString()
names tomorrow for anyone east of UTC in the evening.Data arrives decorated. Each cell carries
isSelected,isToday,isInRange,
isRangeStart,isRangeEnd,isPreview,isDisabled,outsideand
isWeekend, so styling is a lookup rather than a recomputation per render — and
the preview band that follows the pointer while a range's second endpoint is
unchosen is maintained for you.What the getters carry beyond the obvious:
- A roving tab stop. Exactly one cell per calendar has
tabIndex: 0. Tab
reaches the grid once and arrows move within it; 42 cells in the tab order is
not the grid pattern. - Keyboard. Arrows step a day or a week,
Home/Endreach the ends of the
week,PageUp/PageDownstep a month and a year with shift. A step past the
visible months pages the view, and focus follows onto the destination cell even
when that cell mounts after the step.isRTLmirrors the horizontal axis
only — up is still up.preventDefaultfires only for keys the grid acts on,
so Tab still leaves. - Disabled days keep their tab stop. They get
aria-disabledand
accessibilityStatebut notdisabled, because a day you cannot reach cannot
tell you why it is unavailable. The press handler refuses. - Both a11y dialects, always together. Native
accessibilityRole/
accessibilityStateand webaria-*, since react-native-web maps only the
aria-form.
The two pickers add a disclosure, a typeable field per date, and a backdrop:
const picker = useDatePicker({ onSelectDate: setValue, testID: 'depart' }); <TextInput {...picker.getFieldProps()} /> <Pressable {...picker.getTriggerProps()}><Text>Pick a date</Text></Pressable> {picker.isOpen ? ( <> <Pressable {...picker.getDismissProps()} /> <View {...picker.getPanelProps()}>{/* the calendar, as above */}</View> </> ) : null}
The field is forgiving where a date field has to be: typing shows a draft without
committing, blur commits, submit commits and closes. Unparseable text snaps back
to the current value instead of silently discarding it, and a complete allowed
date moves the grid as you type so field and calendar never disagree.format
takes a{ parse, format }pair for a non-ISO field order.useDateRangePickerdiffers only where a range does: two months by default, two
independent field drafts, and it closes when the range is complete rather
than on the first press, which only starts it. A range entered backwards is
reordered rather than rejected, clearing one field leaves a half-open range the
next press completes, and a date typed into the end field is revealed in the
last month on screen so the start stays visible beside it.The trigger is a button with
aria-expanded, deliberately not acombobox: RN
has neitheraria-controlsnoraria-haspopup, so a combobox would announce a
popup assistive tech cannot then find. The panel is adialogwhose three modal
flags all follow onemodaloption, so an inline calendar never claims to trap
focus that nothing has trapped. A day cell is a button rather than agridcell,
which RN's role union does not have.Pass
testIDand every child derives one (depart-day-2026-08-05,
depart-grid-2026-08,depart-trigger,depart-panel); pass nothing and no
testIDis emitted anywhere, so a tree stays clean by default.New subpaths:
hooks/use-calendar,hooks/use-date-picker,
hooks/use-date-range-picker, plus the pure modules behind them —calendar
(the date core),calendar-format,calendar-props,calendar-selection,
date-fieldanddate-picker-props— exported so a consumer can type a render
function or reuse the arithmetic without the hooks. - A roving tab stop. Exactly one cell per calendar has
-
4c88409: HoldContextMenu:
trigger="passive", and a controlledopen.The component has always owned the press: it wraps
childrenin aPressable,
reads the gestureactivateOnnames, and squeezes under the finger. That is the
wrong shape when the children are already a button, or already own a long-press —
you get a second button nested in the first, an extra tab stop per item in a long
list, and on native two press responders competing for the same touch.trigger="passive"drops thePressableentirely. What is left is the measured
wrapper the panel anchors to, and the host opens the menu itself:const [open, setOpen] = useState(false); <HoldContextMenu items={items} open={open} onOpenChange={setOpen} trigger="passive" > <Pressable onPress={() => setOpen(true)}> <Text>Open menu</Text> </Pressable> </HoldContextMenu>;
openmakes the component controlled and works under either trigger mode. The
anchor is measured when it flips true, so a host can open the menu without a
gesture having measured anything first — the panel paints one commit later, once
that measurement lands. Leaveopenout and the trigger keeps the state, exactly
as before.Two things survive the missing
Pressable. Web's right-click still opens the
panel: thecontextmenulistener sits on the wrapper and the event bubbles to it
from whatever the children render, so keyboard users reach the menu through
Shift+F10 without extra wiring. And nothing squeezes — the press it previewed
belongs to someone else now — so the lifted copy springs from rest rather than
fromHOLD_ITEM_SCALE, which would otherwise read as a 5% pop out of an item the
user never touched.New:
wrapperRef, which hands you the measured node — a real DOM element on web —
for attaching your own listeners to it. New type:HoldContextMenuTriggerMode. -
dfbc2da: HoldContextMenu: new component — an action menu that reads as the platform's
own. On iOS and Android, hold an item: it lifts off the page, the rest of the
screen dims, and an iOS-style panel grows out of the corner nearest it, both
travelling together so the pair stays on screen. On web it is a right-click, and
what opens is a plain dropdown anchored to the item — no hold, no lift, no dim.
A mouse already has a button for this, and once the gesture is a click there is
no press to animate and nothing to lift out from under a finger. One component,
two honest presentations;activateOn="tap"and"double-tap"read the same
either way, and get the dropdown on web too.A port of react-native-hold-menu
(MIT, Enes Öztürk) onto this package's primitives. Same interaction, none of the
upstream dependencies: no@gorhom/portal,expo-blur,expo-haptics,nanoid,
lodash.isequalorreact-native-gesture-handler.import { HoldContextMenu } from "rn-motion-ui/hold-context-menu"; <HoldContextMenu items={[ { id: "reply", label: "Reply", icon: MessageCircle, onPress: () => reply(message.id), }, { id: "copy", label: "Copy", icon: Copy, onPress: () => copy(message.body), separator: true, }, { id: "delete", label: "Delete", icon: Trash2, destructive: true, onPress: () => remove(message.id), }, ]} > <MessageBubble message={message} /> </HoldContextMenu>;
Coming from upstream:
Upstream Here <HoldMenuProvider>at app root + portal hostnothing — each menu owns a ModalthroughOverlayShellexpo-blurscrimnative dims and blurs with backdrop-blur-xs; web paints nothing, since a dropdown does not dim the pagehapticFeedback="Medium"(expo-hapticsstyle name)haptics—truebuzzes on Android, or pass your own functionitems[].text/isTitle/isDestructive/withSeparatoritems[].label/heading/destructive/separator, plusidanddisabledactionParamsmap keyed by labelclose over what you need in the item's onPressbottom+menuAnchorPositionside+align, matchingPopoverandAdaptiveDropdowntheme="light" | "dark"theme tokens — follows the active scheme on its own menu width fixed at 60% of the screen menuWidth, default 240, clamped to the viewportcloseOnTapdefaults tofalsedefaults to true, the iOS behaviour — native-only, as nothing lifts on web to tappanel scales out of the corner from 0.6, lift on a spring of its ownthe shared anchored-menu motion — scale from 0.96with an 8px slide toward the trigger, lift on the panel's own transition so the two move as one thing.motion={{ scale: 0.6, offset: 0 }}restores upstream's entranceBeyond the port: the right-click that opens the menu on web (
openOnContextMenu,
on by default) is also the keyboard path — browsers raisecontextmenufor
Shift+F10 and the ContextMenu key on the focused trigger, so the panel is
reachable without the pointer a hold gesture requires. Enter and Space are left
alone there, so they still reach whatever you nest inside the trigger. On native,
screen readers open it through alongpressaccessibility action. Rows are
menuitems, aheadingrow ispresentation, a disabled row carries
aria-disabled, and the scrim is a named button rather than a bare tap target.
useReducedMotionswaps every spring for a short fade.The right-click is the mouse path on web; a touch press still holds — mobile
web has no right button, soholdDurationis always pinned into the gesture's
holdDelay, even for a draggable trigger whose drag tuning would otherwise
have no hold on web at all. While the hold charges, the trigger squeezes to
95%, and the squeeze releases the moment the menu opens, not when the press
ends: on native the lifted copy takes the scale over, and on web — where
nothing lifts — a squeeze that outlived the open would spring back to full size
exactly as the menu closed or a drag escaped, a flicker on the trigger at the
worst moment. Either way, closing the menu leaves the trigger motionless. The
drag ghost a menu-escape lifts starts exactly over the item it came from, too —
its first frame used to sit offset by wherever the finger grabbed.onHoldfires alongside the menu, right after it opens — one gesture, both
outcomes — and still fires when the menu has no items to show, so a
multi-select toggle riding it keeps working while the panel stays silent.Panel placement is measured per-open from
useWindowDimensionsand the safe-area
insets, not from module-levelDimensionsconstants read at import time, so it
survives a rotation. The height estimate the layout runs on is corrected from the
panel's realonLayoutheight, so a row that wrapped under a large accessibility
font still gets a panel that fits.The panel opens on the same motion as every other menu a trigger summons, and
motionretunes it —enter,exit,scaleandoffsetshared with
AdaptiveDropdown,HoverMenuandPopover, plusscrimandliftfor the two
surfaces only this menu has.useReducedMotionstill wins over all of it.// Upstream's entrance, for anyone who ported from it and wants the old feel back. <HoldContextMenu motion={{ offset: 0, scale: 0.6 }} items={items}> <MessageBubble message={message} /> </HoldContextMenu>
-
e9f5fe0: feat(Menu): composable menu list for dropdowns and context menus
Menutakes anentriesarray and renders the inside of a menu — action rows,
separators, group labels, and arbitrary nodes — so consumers stop hand-rolling a
Viewfull ofMenuItems. Entries compose with&&, so a conditional row is
justcondition && { id, label, onSelect }; falsy entries are dropped.Four entry kinds, discriminated on
type, which is optional for the common one:{ id, label, onSelect }— an action row (type: 'item'implied){ type: 'separator' }{ type: 'label', label }— a non-interactive group caption{ type: 'node', node }, or a bareReactElementas shorthand
It carries the semantics (
role="menu",role="menuitem",accessibilityState,
aria-disabled,role="presentation"on captions), closes the panel before
running the action so a navigating row does not strand a modal, and aligns labels
across a mixed list via an auto-detected icon gutter (iconGutter).Menuowns no frame: no background, border, radius, width, or horizontal
padding. The surface belongs to whatever holds it —AdaptiveDropdown's panel,
HoldContextMenu's, aCard, a sidebar column. The one exception is the
vertical inset, which the list keeps for itself so the end rows clear a rounded
panel corner without every container having to remember to pad for them.Also:
MenuItemgains adestructiveprop (danger-tinted label and icon, with
thebg-infoactive fill still winning over it) and now dims whiledisabled. -
f3e006d:
MenuItem:modeprop, hover/press feedback, retuned default scaleNew:
mode. The default (non-iconBackgroundColor) variant now comes in two
flavours, because the same row serves two jobs: a command-palette list, where
every entry reads as equally available, and a sidebar, where the selected entry
has to win against its neighbours.modeLabel Leading icon 'menu'(default)foreground, normal weight — active or notforeground, active or not'sidebar'font-medium;muted-foregroundwhen inactivemuted-foregroundwhen inactive// Sidebar: the active row is the only one at full contrast <MenuItem label="General" icon={Settings} mode="sidebar" active={tab === "general"} onPress={go} />
modeis ignored wheniconBackgroundColoris set — that variant keeps its own
treatment (coloured icon square, filled active row, label inverted over the
fill), and'sidebar'no longer leaks its medium weight onto that label.
The default is'menu', which is the previous behaviour for active rows, so
existing call sites keep their look except for the scale changes below.New type:
MenuItemMode.Hover and press feedback. The row now fills on hover (
surface-hover) and
on press (surface-selected). Both are suppressed whileactiveordisabled,
so the active highlight is never double-painted and a disabled row stays inert.Driving those fills means the row owns
onHoverIn,onHoverOut,onPressInand
onPressOut, so each one forwards to a caller's handler of the same name after
setting its own state. Passing any of the four still works exactly as before —
CommandPaletterelies on this, usingonPressInto move its active row.Retuned default scale. The
mdandlgrows were loose next to the
palettes and sidebars they're used in —mdlost vertical padding and a label
step,lggained horizontal padding and a larger icon:Size Changed smlabel pinned to 12px (was text-xs, the same size)mdpy-2→py-1.5; label 16px → 14px; icon spacerh-4 w-4→h-5 w-5lgpx-3→px-4; icon 22 → 24; icon spacerh-4.5 w-4.5→h-6 w-6; label pinned to 18pxLabel sizes are now explicit pixel values rather than Tailwind's
text-*steps.
TheiconPlaceholderspacer sizes are matched to the rendered icon at each size,
so a row without an icon now aligns with one that has it. -
603c921: Menu motion is now one definition, and it is yours to retune. Every panel a
trigger summons —AdaptiveDropdown,HoverMenu,Popover,HoldContextMenu—
had drifted onto its own spring, its own exit and its own idea of whether to scale
or slide. Four menus, four entrances. They now share one: a fade up from0.96
with an 8px travel toward the trigger, growing out of the corner facing it, and a
200ms ease-in on the way out.Each of the four takes a
motionprop to change that:import { HoverMenu } from 'rn-motion-ui/hover-menu'; // Slower, softer, and further. <HoverMenu motion={{ enter: { type: 'spring', stiffness: 140, damping: 22 }, offset: 16 }} items={items}> <Button>Insert</Button> </HoverMenu> // No movement at all — just the fade. <Popover motion={{ offset: 0, scale: 1 }}>…</Popover>
enterandexitarePartial<MotiTransitionProp>, merged over the preset the
same wayButton,Tabs,Switch,RadioandCheckboxalready take theirs, so
a partial override changes only what it names.scale: 1drops the scale and
offset: 0drops the slide.HoldContextMenuaddsscrimandliftfor the two
surfaces only it has.useReducedMotionoverrides all of it — amotionprop
cannot animate a menu for someone who asked the OS for less.rn-motion-ui/theme/motionexports what the four run on, for anyone matching a
custom overlay to them:resolveMenuMotionreturns the whole
from/animate/exit/transitionset for a side,menuTransformOrigingives
the corner a panel should grow from for a side/align pair, and
MENU_ENTER_SCALE/MENU_ENTER_OFFSET/MENU_EXIT_TRANSITION/
MENU_SCRIM_TRANSITIONare the tokens behind the defaults.Visible changes from this:
AdaptiveDropdown,HoverMenuandPopovernow scale
slightly as they open rather than only sliding, and all three grow from the corner
nearest their trigger instead of their centre.HoverMenu's exit goes 180ms →
200ms. -
1d9a279: Menu: captions, separators and node entries are addressable by
testID.Give the list a
testIDand every action row already derives one from it —
${testID}-item-${id}, which is howHoldContextMenu's rows have been reachable
in a test. The other three entry kinds got nothing, and they are the ones with no
alternative: a caption isrole="presentation"and a separator is a bare band, so
neither carries a role or an accessible name to query by. Selecting them meant
getByTextagainst user-facing copy, or nothing at all.Each non-action entry now takes the list's
testIDplus the React key the list
had already assigned it:<Menu testID="row-actions" entries={[ { type: "label", id: "group", label: "Message" }, // row-actions-group { id: "reply", label: "Reply", onSelect: reply }, // row-actions-item-reply { type: "separator", id: "after-reply" }, // row-actions-after-reply { type: "separator" }, // row-actions-separator-0 ]} />
An entry with an
idis named by it. One without falls back to the same per-type
running count that already keys it — and that count only advances for the unnamed
ones, so the numbering is positional among them rather than among all separators.
Pin anidon anything you plan to select. Any entry can also settestID
outright to override the derivation, as action rows could already.MenuSeparatorandMenuLabeltake atestIDtoo, so anodeentry drawing its
own matching hairline can name it the same way the list would have.HoldContextMenu inherits this: its panel already passes
${testID}-menudown,
so aheadingrow is now${testID}-menu-<id>and the band aseparator: true
row ends its group with is${testID}-menu-<id>-separator. -
d8c1833: refactor(menus)!: the anchored panels fill themselves with
MenuMenuarrived as the list you could put inside a panel. This makes it the
list the panels actually use.HoldContextMenuhad its own row component — a
port of react-native-hold-menu'sMenuItem, with its own hover and press fills,
its own disabled dimming, its own heading branch — which is now a second
implementation of something this package already has. It is gone, and the panel
renders aMenu.hold-context-menu-row.tsxis deleted. Nothing imported it directly: it was
never an entry point, and both types it exported (HoldContextMenuItem,
HoldContextMenuIcon) are still exported fromrn-motion-ui/hold-context-menu.
The item type is unchanged and stays the component's public API — it reads in
upstream's vocabulary (heading,separator) rather thanMenu's, so a new
hold-context-menu-item.tstranslates one into the other.The rows look different. Adopting
Menu's row means adopting its layout:- the icon leads the label instead of trailing it
- a
headingrow isMenu's group caption — left-aligned and muted, where it
used to be centred - rows are shorter (44 → 32px floor), and the heading with them (34 → 24px)
- the hairline under every row is gone. Only a row with
separator: true
draws anything, and what it draws is the band that ends a group
Breaking: the panel's testID is now
`${testID}-panel`; it used to be
`${testID}-menu`. Row testIDs are unchanged
(`${testID}-menu-item-<id>`). Queries by role are unaffected.role="menu"moved onto the list. The panel used to carry it while the rows
carriedmenuitem, with a wrapper in between; now the element with the role
owns its rows directly, and there is no chance of one menu nesting inside
another. The accessible name moved with it, soaccessibilityLabelstill names
the menu.Menunow owns its vertical inset. Asking every container to remember
contentClassName="p-1"was the wrong split: the clearance the first and last
row need from a rounded panel corner is the same in every panel that holds the
list, and forgetting it left rows sitting against the corner. So the list caps
itself top and bottom (py-2.5) and still draws no horizontal padding, no
surface, no border, no radius, no width. Retune it withclassName="py-*", or
py-0to drop it —HoldContextMenupins its own, because it has to predict
its height before layout and wants a number it chose.Also in
Menu: a separator is a 4px band with margins around it (12px total at
md) rather than a 1px hairline, group captions sit tighter to the group they
name, andMENU_SEPARATOR_HEIGHTis exported for panels that have to predict
their own height before layout.HoldContextMenu's pre-layout height estimate follows the new metrics, and now
counts the panel's border and the list's inset once rather than leaving them
out.HOLD_MENU_MIN_PANEL_HEIGHTis the floor a panel is clamped to — border,
inset and one row, so the row that survives the clamp is a whole one.CommandPalettecaps its scroller withmax-h-[60vh]instead of measuring
useWindowDimensions(). uniwind compilesvhagainst the same window
dimensions and the same resize event, so this is the same height with one less
hook. -
97a2b25: Breaking:
rn-motion-ui/moti/progressandrn-motion-ui/moti/motify-svgare gone.Both came over with the Moti layer and neither earned its place.
MotiProgressBar
was a progress bar with hardcoded hex defaults (#333,#eee,#00C806) that
answered to nothing in the token system, so it could not sit next to anything else
in this library without looking foreign. Nothing here rendered it.-import { MotiProgressBar } from 'rn-motion-ui/moti/progress';A bar is two views and a translate. If you were using it, the replacement is a
MotiViewinside a clipping parent — which is all it ever was, minus the
re-render warnings:import { MotiView } from "rn-motion-ui/moti/view"; <View className="h-3 w-full overflow-hidden rounded-full bg-muted"> <MotiView className="h-full w-full rounded-full bg-primary" animate={{ translateX: `${Math.round(progress * 100) - 100}%` }} transition={{ type: "timing", duration: 200 }} /> </View>;
motifySvgwas a secondmotifythat wrote toanimatedPropsinstead ofstyle,
for animating SVG attributes likerorstrokeDashoffsetthat are props rather
than styles. Every SVG animation in this package is written directly against
Reanimated'suseAnimatedPropsinstead —Loader,ScrollProgress,
AnimatedBadge,Checkbox,StarRating— so the wrapper was carrying an API
surface no caller used.-import { motifySvg } from 'rn-motion-ui/moti/motify-svg'; -const MotiCircle = motifySvg(Circle)(); +import Animated, { useAnimatedProps } from 'react-native-reanimated'; +import { Circle } from 'react-native-svg'; +const AnimatedCircle = Animated.createAnimatedComponent(Circle);
react-native-svgstays a peer dependency — the components above still need it. -
b2aabe1: MultiDragManager, MultiDraggable: drag a selection, not an item.
<Draggable>knows what it holds and nothing about the list it sits in. So
dragging one of three selected rows moves one row — the other two stay put,
because no single draggable was ever told they existed. Every list with
multi-select ends up re-deriving the same three things to fix that: which ids a
lift should carry, one payload built from all of them, and a way for the members
left behind to know they are moving too.<MultiDragManager selectedIds={selected} getGroupData={(ids) => ({ "application/x-rows": JSON.stringify(ids) })} renderPreview={(ids) => <Chip label={`${ids.length} items`} />} > {rows.map((row) => ( <MultiDraggable id={row.id} key={row.id}> <Row row={row} dimmed={useIsLifting(row.id)} /> </MultiDraggable> ))} <Dragzone onDrop={({ transfer }) => move(readMultiDragIds(transfer))} /> </MultiDragManager>
Lifting a selected item carries every selected id; lifting an unselected one
carries just it and leaves the selection alone. That rule isresolveIds, and
the default is the one a file manager, a mail list and a canvas all want —
replace it for a list where it is not.The ids in flight are read back off the drag's own transfer rather than recorded
at lift time, which is what keeps them right on every transport and after a
cancel: nothing to clean up, because the set empties when the drag does.
useIsLifting(id)is how a member that is not under the pointer knows it is
nonetheless moving — the hook that fades the rest of the selection.The group also travels as
application/x-multi-drag-idson the transfer, so a
plain<Dragzone onDrop>reads it withreadMultiDragIds— and under the HTML5
mouse transport, so does adroplistener that has never heard of this library.
withMultiDragIdsadds the same key to a payload you are building yourself.It is a
<DragManager>underneath, with all of its props: zones, isolation,
groups and the ghost overlay behave exactly the same.renderPreviewdraws the
group ghost for the pan transports; under HTML5 the browser draws its own drag
image and it is not consulted.A multi-select drag needs its keyboard equivalent more than a single one, since
the selection it acts on is already reachable without a pointer — a "Move
selected to…" command belongs next to theonDropthat performs it.New subpaths:
rn-motion-ui/multi-drag-manager,rn-motion-ui/multi-draggable,
rn-motion-ui/multi-dragandrn-motion-ui/multi-drag-scope. New types:
MultiDragManagerProps,MultiDraggableProps,MultiDragIdResolver,
MultiDragScope. -
51dd604: Seven components now name their repeated children from the root
testID.Each of these already took a
testIDand put it on its outer element, which left
the interesting parts unaddressable: the stars, the wheel options, the swipe
actions. A test could reach them only throughaccessibilityLabel, which ties the
selector to user-facing copy, or not at all where the element carries no role and
no name.Component New testIDs StarRating-star-<n>— both the interactive andreadOnlypathsWheelPicker-option-<value>SwipeableList-row-<id>, and-row-<id>-action-<id>per swipe actionBouncyAccordion-item-<id>BloomMenu-item-<index>,-trigger,-closeCommandPalette-item-<id>,-group-<group>OverflowActions-item-<id>Five of the seven add nothing when
testIDis omitted, so a component that does
not ask for one renders exactly as before. The exceptions areWheelPickerand
SwipeableList, whose roots already defaulted to'wheel-picker'and
'swipeable-list'— their children derive from that default, so options and rows
are named either way. Where an item type already had its own optionaltestID
(BouncyAccordion,BloomMenu,CommandPalette,OverflowActions), it still
wins; the derived name is the fallback.Two of these render their children twice, and only the live copy is named.
WheelPickerpaints a secondaria-hiddendrum for the bright centre band and
OverflowActionskeeps an offscreen measurer to feed its width spring; naming
both copies would return two nodes pergetByTestId.aria-hiddendoes not hide
an element fromgetByTestIdthe way it hides one fromfindByRole, which is why
the role queries in these components were never ambiguous but the testIDs would
have been.OverflowActionshad that bug already: its measurer duplicated anyitem.testID
a caller set, so naming an action made every query for it ambiguous.
ActionButtonPropsnow takes an already-derivedtestIDinstead of reading
item.testIDitself, which is what lets the measurer stay silent no matter what
the item carries.SwipeableListkeeps its old default. Rows were hardcoded to
swipeable-row-<id>, ignoring the root entirely — two lists on one screen
collided. Rows now derive from the root when there is one and fall back to the
old string when there is not, so existing selectors keep working while a named
list gets<testID>-row-<id>.StarButton(exported) gains an optionaltestID.MenuItemitself is untouched —
it already forwarded one; what changed is the components above it passing a derived
name down.Four stories now assert through the new IDs rather than around them.
SwipeableList
is the clearest case: every row repeats the same four action labels, so its old
findAllByRole('button', { name: 'Trash' })[0]could assert that something was
pressed but not which row, and the buttons sit behind the draggable surface with no
swipe to reveal them in jsdom. It now names the row and the action together and
asserts the payload. TheWheelPickerandOverflowActionsplays query by testID
specifically so a single match proves the duplicate copy stayed anonymous. -
f3e006d:
Switch: reduced motion cuts the thumb, and a tighter label gapReduced motion now wins over
thumbTransition. When the OS asks for reduced
motion, the thumb cuts straight to its position (TIMING_INSTANT) instead of
springing, matching how every other animated control in the library treats the
setting. Previously the spring ran regardless, and a caller-supplied
thumbTransitionwas merged in even under reduced motion — now it is ignored in
that case. With reduced motion off,thumbTransitionmerges over the default
spring exactly as before.The gap between the track and the label shrinks from 12px to 8px, and is now
set by agap-2class on the root rather than an inlinestyle. Rows of
switches read slightly tighter; pass astyleto set your own spacing. -
04e622d: Switch:
sm,md, andlgsize variants.The track, thumb, travel distance and label text scale now all respond to a single
sizeprop.'md'is the default, so existing usage is unchanged.<Switch isSelected={on} onSelectedChange={setOn} size="sm" label="Compact" /> <Switch isSelected={on} onSelectedChange={setOn} size="md" label="Default" /> <Switch isSelected={on} onSelectedChange={setOn} size="lg" label="Large" />
size track thumb travel sm16 × 32 px 12 × 20 px 8 px md20 × 44 px 16 × 26 px 14 px lg28 × 56 px 24 × 36 px 16 px The thumb's height is not a per-size number: it insets 2px from the top and the
bottom of whatever track holds it, so the two always agree and a retuned track
carries the thumb with it.Size is threaded through context, so
Switch.Thumband custom children pick it up
automatically — no extra prop is needed on sub-components. -
51c43f7: Breaking:
AnimatedNumberandNumberTickerare merged into a single
TextNumberTicker, exported fromrn-motion-ui/text-number-ticker. The
/animated-numberand/number-tickersubpaths are gone.The two components animated the same thing two ways:
NumberTickerrolled a
column per digit,AnimatedNumbercounted one label up to the value. That is
now themodeprop —'roll'(default) and'count':// Before <NumberTicker value={48273} locale={true} stagger={0.04} /> <AnimatedNumber value={129480} duration={1.2} /> // After <TextNumberTicker value={48273} locale={true} stagger={0.04} /> <TextNumberTicker mode="count" value={129480} duration={1.2} />
durationkeeps each component's old default per mode (0.9s per digit in
'roll', 1.2s total in'count'), so neither migration changes timing.Props that were only on one of the two now apply to both where it makes sense:
'count'gainedpad,locale,prefixandsuffix, and'roll'gained
format.staggeranddigitClassNamestay'roll'-only. A customformat
in'count'receives the in-flight fractional value and owns its rounding,
which is what lets a compact formatter stay legible mid-count; without one the
value is rounded before formatting.NumberTicker'sblurprop is dropped rather than carried over. It was
accepted for web API parity and documented as having no visual effect on React
Native, so nothing rendered differently for it.
Patch Changes
-
6605e30: fix(AnimatedBadge): the loading spin and the pulse survive a parent re-render
Both loops were declarative
MotiViews withtransition={{ loop: true }}. Moti
resolves its pose inside auseAnimatedStylewhose dependency list includes the
animateobject, and that object is a fresh literal on every render — so any
re-render from above (a status change, an interval tick, a theme swap) re-ran the
worklet and re-issued the tween from the current value. The spin restarted
mid-revolution with a full second to cover the remaining arc, which read as a
stutter and a speed change rather than one steady turn; a badge under a parent
that re-renders every 50ms barely moved at all.withTiming's default easing was the second half of it.Easing.inOut(Easing.quad)
eases to a stop at each revolution boundary, so even an uninterrupted loop paused
once per turn.The spin and the halo are now two small components driving one shared value each,
started in an effect and cancelled on unmount, withEasing.linearon the spin —
the same shape the Marquee and TextShimmer loops already use. Re-renders never
touch a shared value, so the loop keeps its phase.One shared value now drives both the halo's opacity and its scale, which also
keeps them in phase: as two moti properties they drifted apart, since moti
defaultsscaleto spring whileopacityis timing.No API change —
status="loading"and the pulse behave as documented, they just
actually animate continuously now. -
0756779: fix(Draggable, FileSystem): one press, three outcomes — a tap, a hold, or a drag, arbitrated in one place
Holding an entry in
<FileSystem draggable>did nothing on touch: the context menu never opened. Right-click on web was unaffected, which is why this only ever showed up on native and on web touch.The cause was two timers in two different gesture systems with nothing arbitrating between them. Both pan transports started the drag straight off their own 300ms hold timer, and RNGH cancels the touches under an activating handler — so at t=300 the entry's
Pressablelost its long-press timer, which undertrigger="passive"is the only way into the menu. The drag always won, 200ms before the press could fire.<Draggable>now owns the whole timeline, because it is the only thing that sees every touch, and reports the outcomes it does not keep:onHold— the press stayed down past the hold delay without travelling. Open a menu, toggle a selection, start a preview.onHoldEscape— a drag took the gesture back off a hold that had already fired. Undo what the hold did.
The press reads in three phases, off four numbers both transports share so the gesture feels identical on either:
before armDelay(150ms)movement belongs to the scroll — the pan gives the gesture up, so a list inside stays scrollable after it slop(10px) of travel lifts a dragat holdDelay(300ms), still stillonHoldfires, and no drag lifts from this press unless it travelsescapeSlop(24px)The last row is the escape hatch: whatever the hold put on screen is under the finger by then, so getting out from under it takes a deliberate shove rather than the drift of a hand that thought it had finished.
Those four numbers are not one set. They default per platform and are overridable per platform, through
behavioron<Draggable>or once for a whole subtree on<DragManager>, inPlatform.selectvocabulary — flat fields everywhere,nativefor everything but web, then a block per OS:<DragManager behavior={{ armDelay: 100, android: { slop: 12 }, ios: { holdDelay: 400 } }}>
Web, macOS and Windows default to no hold at all (
holdDelay: null, so no timer is armed andonHoldnever fires). A desktop already has a gesture for "tell me about this thing" and it is the right button; on web, a long press on touch is already the browser's own text selection and context menu, and a second meaning layered on top fights both. The arm window stays even there, because web touch still shares the surface with the page's scroll. Android tightensslopto 8px andescapeSlopto 20px, matchingViewConfiguration's scaled touch slop where iOS follows UIKit's more forgiving 10pt.The resolver is pure and separately tested:
resolveDragBehavior(behavior, os)flattens the whole thing to four numbers,DRAG_TUNING_DEFAULTSis the per-OS table, anduseDragBehaviorresolves against the running platform. Everything downstream reads those numbers and never asks which OS it is on again.useDraggable, and a<Draggable>that draws nothingThe drag is now available without the wrapper's markup.
useDraggable()is the whole of<Draggable>bar the three elements it renders — transport selection, the press timeline, the session, store registration, the measured rect, the handle — exposed asgetRootProps()/getGhostProps()plusgesturefor theGestureDetectora hook cannot render itself. For a row in aFlatListthat must not gain a wrapperView, or aPressablehost, or a ghost drawn a different way.<Draggable>stays, as that hook plus aView, and has lost its own styling: nocursor-grab, nocursor-grabbing, no lifted state.classNameandstyleland on the host untouched. The replacement isisDragging, now reactive render state rather than only the imperativehandle.isDragging()— and true under every transport, including the ones that draw no ghost here. The one style the hook still supplies isuserSelect: 'none'on web, which is functional: without it a drag starting on text selects the text instead of lifting.Native switches from
activateAfterLongPress(300)toGesture.Pan().manualActivation(true), with the phase decision inonTouchesDown/onTouchesMoveworklets. That prop cannot express this gesture: it flips to ACTIVE off a timer without consulting distance, and fails the pan if the finger travels first.In
FileSystem,useEntryHoldreplacesuseEntryLongPressand reconciles all three claimants on an entry — the tap, multi-selection, and the context menu. Multi-selection still wins the hold whenselectionMode="multiple", as before. Both hold paths stay wired (the pans see touch, thePressablesees a mouse) and the first to fire locks the other out for that gesture, so a hold cannot run its action twice — which for a selection toggle meant undoing itself. A release that already produced a hold no longer counts as a tap, so the entry behind an open panel is not also selected.An entry stays a drag source while its own context menu is open, which is what makes the escape possible: the finger that opened the menu is still delivering to the view it started in, so that entry's own pan is what detects the shove and closes the panel.
HoldableandHoldDraggableTwo new components exposing the same timeline without requiring a
<DragManager>:<Holdable>— hold only, no drag. Wraps children in the four-phase press timeline (pending → active → hold) and exposes the current state via a render-prop child:<Holdable onHold={() => select(id)}> {({ isPressed, isHeld }) => ( <Chip pressed={isPressed} selected={isHeld} label={name} /> )} </Holdable>
isPressedflips atarmDelay;onHoldfires atholdDelay. A cancel or release ends the press quietly —onHoldEscapeis a drag's crossing out of a fired hold, and a bare<Holdable>has nothing to drag, so it never reports one (the prop exists so a consumer can move between<Holdable>and<HoldDraggable>without rewiring). The hold defaults fire on every platform (unlike<Draggable>, which has no hold on web by default) — but a mouse press still does nothing: a held left button is a text selection and a held right button is the context menu; the hold-and-lift is a touch idiom.<HoldDraggable>— hold + drag in one. Identical to<Draggable trackPhase>but with the render-prop always enabled, so the child always gets a live phase without a separate state lift:<HoldDraggable data={{ "application/x-item": item.id }} onHold={() => openMenu(item)} onHoldEscape={closeMenu} > {({ isPressed, isHeld }) => ( <Row row={item} pressed={isPressed} selected={isHeld} /> )} </HoldDraggable>
Web's drag transport defaults to
holdDelay: null, soonHoldon web needsbehavior={{ holdDelay: 300 }}(or a platform-specificweb: { holdDelay: 300 }block) to fire. Touch on web and all native platforms hold by default.HoldContextMenu— hold gesture rebuilt onHoldableHoldContextMenuno longer drives the squeeze animation fromPressable.onLongPress. On native foractivateOn="hold", the trigger is now a<Holdable>(or a<HoldDraggable>whendragOptionsis set), and the squeeze fills exactly the gap betweenarmDelayandholdDelayfrom the resolved tuning.Two new props:
behavior?: DragBehavior— timing and slop overrides forwarded to the gesture widget; merged withholdDurationif given.dragOptions?: HoldContextMenuDragOptions— upgrades the hold gesture to<HoldDraggable>. A move pastescapeSlopafter arming lifts a drag; the hold still opens the menu, and an escape closes it before the drag takes over. Works wherever the hold does: native, and touch on web. A desktop mouse keeps the right-click, whose dropdown has no gesture to escape from.
Touch on the web: the gesture now survives the browser's own ideas
Three fights with the browser's touch pipeline, each found by driving the real
gesture with real input rather than synthetic events:- A hold that fired no longer "clicks" on release. The browser synthesizes
mousedown/clickat the release point aftertouchend— and with a hold
menu open, the topmost element there is the menu's own scrim, so the phantom
click dismissed the menu the instant the finger lifted. Both pointer
transports now cancel thetouchendof a press that reached its hold, which
is the documented way to suppress the compat events. - The drag no longer dies one move after it lifts. Taking explicit pointer
capture at the lift releases the touch's implicit capture on the child under
the finger, and thatlostpointercapturebubbles — the transport read its
own capture handoff as "the system took the pointer" and cancelled the drag
it had just started. A capture loss now only aborts the trip when it happens
on the captured node itself. - Chromium's native drag is refused while the pan owns the press. A touch
long-press on anydraggable=trueelement starts a native HTML5 drag,
cancelling the pointer stream under the pan. The HTML5 transport now
preventDefaults thatdragstartwhenever the press timeline is mid-gesture
— and once a hold has fired,touchmoveis cancelled from the first move, so
the escape's opening travel cannot be read as a scroll either.
-
631606c: Separate
FileSystemandFileIconinto their ownfile-systemcategory.- Moves
FileSystemout ofdisplayinto a new top-levelfile-systemcategory - Extracts
FileTypeIcon,FileSystemFolderGlyphand their supporting utilities into a standaloneFileIconcomponent at./file-icon - Storybook titles updated to
File System/FileSystemandFile System/FileIcon - No API changes; existing
./file-systemand new./file-iconexport paths are stable
- Moves
-
e86dced: fix(FileSystem): the marquee works in the columns view, and no longer collapses the column it runs in
Two bugs stopped the selection box from being usable in
ColumnsView.It could not start. The web drag transport captured the pointer as soon as the
press passed the drag slop, then askedbegin()whether there was anything to
drag. On empty spacebegin()returns false and the trip resets — but the capture
had already happened, so the column's marquee listener never saw the pointer it
was waiting for. Capture now happens afterbegin()confirms a source, which
leaves the pointer free for a child listener when the press lands on nothing. The
list and icons views run the same transport and gain the same ordering.It collapsed the trail. Each column past the first exists because a folder is
selected in the column to its left, and a marquee replaces the selection with
whatever it covers. Sweeping inside a sub-column therefore deselected the parent
folder that opened it, and the column vanished from under the pointer mid-drag.
Each column now injects the trail paths into the marquee's base, so the folders
that opened it stay selected. Column 0 has no trail to protect and is unchanged.FileSystemColumn'sonClearSelectionis gone with this — an empty-space press
now resolves through the marquee, which reports an empty covered set and clears
the selection on its own. The component is internal, so the public API is
unchanged. -
85d5ada: fix(FileSystem): list-view width init, external drop indicator, and store refactors
- Initialize
widthstate tonullinstead of0inFileSystemListView— distinguishes "not yet measured" from a genuine zero, soshowDatedefaults to visible and the date column no longer flickers on mount at wide breakpoints. - Replace the two conditional external-drop JSX branches with an
ExternalDropIndicatorcomponent that encapsulates the folder-row vs. full-area fallback logic. - Use
rowsRef.current.lengthdirectly inhitTestand remove the now-redundantrowCountRef. - Split react-native mixed
importinto aimport typeblock + a value import block. - Extract
resolveFolderNamehelper infile-system-context.tsx— eliminates four identical inline ternaries that computed the current folder display name. - Extract
historyStephelper —goBackandgoForwardwere duplicating the same nav/search/entries recompute patch; both now delegate to a single function. - Add result-caching to
computeFileTypeOptionskeyed on index identity — the walk-and-sort runs once per index change instead of once per store action. - Use
cancelSearchDebounce()consistently insetSearchInputinstead of a directclearTimeout.
fix(MultiStepMenu): reduce sidebar divider from
border-r-2toborder-r - Initialize
-
56b1f2f: fix(FileSystem): correct search and subfolder scoping in
computeVisiblePaths- Match search query against
entry.nameinstead of the entry path — id-based paths don't embed the display name so the old path-substring test eliminated every result on any non-empty query. - Replace
path.startsWith(currentPath)with aparentPathchain walk through the index — flat parentPath manifests assign each entry a single-segment id path, so string-prefix containment never held for nested folders. - Apply the same
parentPath-aware ancestor walk inmarkVisibleso highlighted entries correctly bubble up to the current folder in flat manifests.
- Match search query against
-
ff3582a: fix(HoverMenu): hover opens a pressable trigger, and a controlled
openrenders the panelThe hover pair was
Pressable'sonHoverIn/onHoverOut. react-native-web
implements those withuseHover({ contain: true }), which dispatches a bubbling
react-gui:hover:lockevent on enter — and an ancestor using the same hook reads
a lock from a different target as its own hover-end. Every nestedPressable
therefore cancelled its ancestors' hover:- A pressable trigger (a
Button) fired the lock as the pointer reached it, so
the wrapper's hover ended one tick after starting andhandleHoverOutcleared
the pending open timer. The menu only ever opened on press. MenuItemis aPressabletoo, so moving onto an item ended the panel's hover
and scheduled a close while the pointer was still inside it.
Both are now on
onPointerEnter/onPointerLeave— plain DOM events with no lock
protocol, which fire once for the element-plus-descendants region and ignore
movement between children. That is exactly the wanted semantics, and a nested
pressable is invisible to them. RNW forwards both props to the DOM node and they
are part of RN's ownViewProps, so this stays type-safe and is inert on native,
wherecanHovergates it anyway.Separately, a controlled
openflipped from outside the menu left the panel
invisible. The panel renders onopen && rect, and the trigger was only measured
on the paths the menu drives itself — the hover timer andtoggle. A consumer
settingopenfrom a keyboard shortcut, a switch, or a route change never touched
either, sorectstayed null. Measuring is now keyed onopenbecoming true,
whatever set it, which is also correct in general: the trigger may have moved since
the last measurement.measurebails on an identical rect so the extra pass costs
no re-render. - A pressable trigger (a
-
e0b326e:
MenuItem: the active icon-tile row fills withinforather thanprimaryThe
iconBackgroundColorvariant painted its active rowbg-primary/75, with a
font-semibold text-primary-foregroundlabel.primaryis the monochrome token
consumers are meant to repaint with their own brand colour, so the active row
came out near-black in light mode and near-white in dark — an inverted row rather
than a selected one — and any consumer who retintedprimarygot their brand
colour as the selection fill whether they meant to or not.It is
bg-infonow, at full opacity, with atext-info-foregroundlabel: the
same vivid blue that already reads as "this one is picked" elsewhere in the
library, on both schemes, and not a token consumers are invited to repaint. The
label drops to normal weight — the fill carries the state, so the extra weight
was doing the same job twice, and it kept the active row a hair wider than its
neighbours.text-info-foregroundis white in both schemes, which is what a vivid blue fill
wants.primary-foregroundwould have flipped to near-black in dark mode.MultiStepMenu'sMenuRowpicks this up, since it renders the variant. Nothing
to change on your side unless you were relying onprimaryto tint the active
row; that hook is gone on purpose. -
48e1a5c: fix(theme): native read the dark values in both schemes — tokens.css now declares each scheme through
@variantEvery surface on native rendered its dark value regardless of the active scheme. In the native Storybook the symptom was a page that disagreed with itself: white chrome, dark cards. Web was unaffected throughout.
tokens.css expressed dark mode the way a plain Tailwind sheet does — light values in
@theme, dark values in@media (prefers-color-scheme: dark) { :root:not(.light) }plus a bare.darkblock. Both dark forms are correct CSS and both work on web. Neither is a shape uniwind recognises as a theme.uniwind builds
vars.lightandvars.darkfrom one shared global base that diverges solely via per-theme override buckets, and it fills those buckets only from declarations whose selector carries:where(.light, …)/:where(.dark, …). Compiling the sheet produced no buckets at all: the.darkblock parsed as a utility class nameddark, and the media-query dark values fell through to the global base as the last write. So both scheme maps existed, both were byte-identical, and both held dark values — which is also why toggling the scheme changed nothing.The per-scheme values now live in top-level
@variant light/@variant darkblocks, which expand to exactly that:where()shape.@themekeeps the light values, since that is what registers each token with Tailwind and what lands on:rootfor the web base, and keeps the tokens that don't flip (--shadow-elevated-*,--spacing-button-*,--radius-button-*) — a value that lives only in@themeis now theme-independent by construction.Web behaviour is unchanged.
@variantcompiles to the same three rules the sheet previously spelled out by hand: a.darkclass rule, a.lightclass rule, and an OS-preference rule that either class suppresses. A.lightclass is still an absolute override that opts out of OS dark.Two consequences worth knowing:
- uniwind is now load-bearing for scheme switching on native, where before the sheet also carried a plain-CSS fallback. It was already required for
classNameto do anything at all, so nothing that worked before stops working. - Both
@variantblocks must declare the identical token set. uniwind logs a parity error per missing token rather than throwing, so drift is quiet.scripts/check-token-parity.mjsnow holds@theme⇄@variant light⇄@variant dark⇄ the nativeLIGHT_OKLCH/DARK_OKLCHtables to one another, and runs in CI.
The native Storybook preview also drives the scheme through
Uniwind.setTheme()instead ofAppearance.setColorScheme(), seeded at module scope so the first paint is already correct, and synced from thedarkModearg in an effect rather than during render.setTheme()notifies every mountedclassNameconsumer; doing that mid-render was tearing down Storybook's in-flight render, which is where thecannot render when not preparedandcanvasElement is unsetrejections came from. - uniwind is now load-bearing for scheme switching on native, where before the sheet also carried a plain-CSS fallback. It was already required for
-
Updated dependencies [706dac3]
-
Updated dependencies [4eea56d]
-
Updated dependencies [9fd7f7d]
- rn-motion-ui-icons@0.0.1