Queue
The queued-actions list: a collapsible section with the count on the trigger and a remove action per row. The component NEVER owns the queue — caller state, onRemove callback, and the pure remove/enqueue/count helpers in queue.logic. Rows compose the item primitive; actions are always visible (the web's hover-reveal is dead on touch) and the list scrolls under a max-h cap.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/queue.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/queue.jsonDependencies
react-native-reanimated
https://reactnativereusables.com/r/nativewind/badge.json |
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/collapsible.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/item.json |
https://rnraielements.com/r/nativewind/status.json |
https://reactnativereusables.com/r/uniwind/badge.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/collapsible.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/item.json |
https://rnraielements.com/r/uniwind/status.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Badge } from '@/registry/nativewind/components/ui/badge';
import { Button } from '@/registry/nativewind/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/registry/nativewind/components/ui/collapsible';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Item } from '@/registry/nativewind/components/ui/item';
import { Text } from '@/registry/nativewind/components/ui/text';
import { statusColor } from '@/registry/nativewind/lib/status';
import { cn } from '@/registry/nativewind/lib/utils';
import {
ChevronDownIcon,
CircleCheckIcon,
CircleIcon,
FileIcon,
ListTodoIcon,
XIcon,
type LucideIcon,
} from 'lucide-react-native';
import * as React from 'react';
import { Image, Platform, ScrollView, View, type ViewProps } from 'react-native';
import Animated, {
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
/**
* Queue — the queued-actions list: what will run after the current turn, with a remove
* action per row (UC web original: Collapsible of queued items, count on the trigger).
*
* THE COMPONENT NEVER OWNS THE QUEUE. There is no useQueue hook here — caller state,
* caller mutations (speech-input precedent: a caller-supplied contract only). The root
* takes `onRemove(id)` and reports; queue.logic.ts ships the pure remove/enqueue/count
* helpers so the caller's reducer stays one import away. With no `onRemove` wired, the
* remove buttons DISABLE rather than hide — a control that disappears mid-press is the
* failure this shape prevents.
*
* LISTS COMPOSE THE `item` PRIMITIVE — the PRD's own verdict ("RNR list composition
* plus the new item primitive"), realized: each row is Item, so media/content/actions
* alignment and the published TextClassContext are item's, never re-derived.
*
* THE WEB'S HOVER-REVEAL IS DEAD ON TOUCH. QueueItemActions renders always-visible —
* message.tsx's actions precedent, declared. The list scrolls (the web ScrollArea
* becomes a plain ScrollView with a max-h cap, the scroll-area substitute) so a long
* queue never pushes the composer off screen.
*
* Rows read as PENDING until marked done: the indicator carries the house status tones
* (lib/status.ts), and a completed row strikes through exactly as the web original does
* — visible, but visually retired.
*/
type QueueContextValue = {
onRemove?: (id: string) => void;
};
const QueueContext = React.createContext<QueueContextValue | null>(null);
function useQueue() {
const ctx = React.useContext(QueueContext);
if (!ctx) throw new Error('Queue sub-components must be used within <Queue>');
return ctx;
}
type QueueProps = ViewProps & {
/** Called with the item's id. Omit it and every remove control disables. */
onRemove?: (id: string) => void;
children?: React.ReactNode;
};
function Queue({ onRemove, className, children, ...props }: QueueProps) {
return (
<QueueContext.Provider value={{ onRemove }}>
<View className={cn('gap-2', className)} {...props}>
{children}
</View>
</QueueContext.Provider>
);
}
type QueueSectionContextValue = { open: boolean };
const QueueSectionContext = React.createContext<QueueSectionContextValue | null>(null);
function useQueueSection() {
const ctx = React.useContext(QueueSectionContext);
if (!ctx) throw new Error('QueueSection sub-components must be used within <QueueSection>');
return ctx;
}
type QueueSectionProps = Omit<ViewProps, 'children'> & {
/** Web parity: a queue section arrives open. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
};
function QueueSection({
defaultOpen = true,
onOpenChange,
className,
children,
...props
}: QueueSectionProps) {
const [open, setOpen] = React.useState(defaultOpen);
function handleOpenChange(next: boolean) {
setOpen(next);
onOpenChange?.(next);
}
return (
<QueueSectionContext.Provider value={{ open }}>
<Collapsible
open={open}
onOpenChange={handleOpenChange}
className={cn('rounded-md border border-border bg-muted/50', className)}
{...props}
>
{children}
</Collapsible>
</QueueSectionContext.Provider>
);
}
type QueueSectionTriggerProps = {
/** The section's name — "Queued actions", "Follow-ups", whatever the queue is of. */
label: string;
/** The item count, glanceable while collapsed. Omit it and no count badge renders. */
count?: number;
/** Defaults to the house list icon. */
icon?: LucideIcon;
className?: string;
};
function QueueSectionTrigger({ label, count, icon = ListTodoIcon, className }: QueueSectionTriggerProps) {
const { open } = useQueueSection();
const rotation = useSharedValue(open ? 1 : 0);
React.useEffect(() => {
rotation.value = withTiming(open ? 1 : 0, {
duration: open ? 250 : 200,
reduceMotion: ReduceMotion.System,
});
}, [open, rotation]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value * 180}deg` }],
}));
return (
<CollapsibleTrigger
accessibilityLabel={`${label}${typeof count === 'number' ? `, ${count} item${count === 1 ? '' : 's'}` : ''}`}
className={cn(
'flex-row items-center gap-2 rounded-md p-3 active:bg-accent',
Platform.select({ web: 'transition-colors hover:bg-accent/50' }),
className,
)}
>
<Icon as={icon} size={14} className="text-muted-foreground" />
<Text numberOfLines={1} className="flex-1 text-left text-sm font-medium text-foreground">
{label}
</Text>
{typeof count === 'number' ? (
<Badge variant="outline">
<Text className="text-xs text-muted-foreground">{count}</Text>
</Badge>
) : null}
<Animated.View style={style}>
<Icon as={ChevronDownIcon} size={14} className="text-muted-foreground" />
</Animated.View>
</CollapsibleTrigger>
);
}
function QueueSectionContent({
className,
children,
...props
}: ViewProps & { children?: React.ReactNode }) {
return (
<CollapsibleContent className={cn('px-2 pb-2', className)} {...props}>
{children}
</CollapsibleContent>
);
}
/**
* The web ScrollArea becomes a plain ScrollView with a max-h cap — the scroll-area
* substitute. A long queue scrolls; it never pushes the composer off screen.
*/
function QueueList({ className, ...props }: React.ComponentProps<typeof ScrollView>) {
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
className={cn('max-h-52', className)}
contentContainerClassName="gap-1"
{...props}
/>
);
}
type QueueItemContextValue = {
id: string;
completed: boolean;
removable: boolean;
remove: () => void;
};
const QueueItemContext = React.createContext<QueueItemContextValue | null>(null);
function useQueueItem() {
const ctx = React.useContext(QueueItemContext);
if (!ctx) throw new Error('QueueItem sub-components must be used within <QueueItem>');
return ctx;
}
type QueueItemProps = ViewProps & {
/** The item's identity — what `onRemove` receives. */
id: string;
completed?: boolean;
children?: React.ReactNode;
};
function QueueItem({ id, completed = false, className, children, ...props }: QueueItemProps) {
const { onRemove } = useQueue();
const removable = typeof onRemove === 'function';
const remove = React.useCallback(() => {
onRemove?.(id);
}, [onRemove, id]);
return (
<QueueItemContext.Provider value={{ id, completed, removable, remove }}>
<Item variant="outline" className={cn('items-start py-2', className)} {...props}>
{children}
</Item>
</QueueItemContext.Provider>
);
}
function QueueItemIndicator({ className }: { className?: string }) {
const { completed } = useQueueItem();
// The icon is decorative proof of state — an accessible View group carries the
// label for a screen reader (house rule: color is never the sole channel).
return completed ? (
<View accessible accessibilityLabel="Status: Completed">
<Icon
as={CircleCheckIcon}
size={16}
className={cn('pt-0.5', statusColor.success, className)}
/>
</View>
) : (
<View accessible accessibilityLabel="Status: Pending">
<Icon as={CircleIcon} size={16} className={cn('pt-0.5', statusColor.pending, className)} />
</View>
);
}
function QueueItemContent({ className, ...props }: React.ComponentProps<typeof Text>) {
const { completed } = useQueueItem();
return (
<Text
numberOfLines={2}
className={cn(
'text-sm text-foreground',
completed && 'text-muted-foreground line-through',
className,
)}
// Redundant with `line-through` on purpose: the class rides uniwind's compiled
// stylesheet, whose candidate extraction has been observed on device to drop
// classes from newly added files between incremental rebuilds — the strike
// silently vanished. Plain RN style is immune to stylesheet state.
style={completed ? { textDecorationLine: 'line-through' } : undefined}
{...props}
/>
);
}
function QueueItemDescription({ className, ...props }: React.ComponentProps<typeof Text>) {
const { completed } = useQueueItem();
return (
<Text
numberOfLines={2}
className={cn(
'text-xs text-muted-foreground',
completed && 'line-through',
className,
)}
// Same stylesheet-immunity rationale as QueueItemContent above.
style={completed ? { textDecorationLine: 'line-through' } : undefined}
{...props}
/>
);
}
function QueueItemFile({ children, className }: { children: string; className?: string }) {
return (
<View
className={cn(
'flex-row items-center gap-1 self-start rounded-sm border border-border bg-background px-1.5 py-0.5',
className,
)}
>
<Icon as={FileIcon} size={12} className="text-muted-foreground" />
<Text numberOfLines={1} className="text-xs text-muted-foreground">
{children}
</Text>
</View>
);
}
type QueueItemImageProps = {
source: { uri: string };
className?: string;
};
function QueueItemImage({ source, className }: QueueItemImageProps) {
return (
<Image
source={source}
resizeMode="cover"
className={cn('size-10 rounded-md border border-border bg-muted/50', className)}
/>
);
}
/**
* ALWAYS VISIBLE. The web original reveals these on hover, which on touch renders
* identically at rest and is simply unreachable — message.tsx's actions precedent.
*/
function QueueItemActions({ className, ...props }: ViewProps) {
return (
<View className={cn('flex-row items-center gap-1', className)} {...props} />
);
}
type QueueItemActionProps = {
/** The icon. Defaults to the house remove mark. */
icon?: LucideIcon;
/** The accessibility label — a bare icon has no text for a screen reader. */
label: string;
onPress?: () => void;
disabled?: boolean;
className?: string;
};
function QueueItemAction({
icon = XIcon,
label,
onPress,
disabled,
className,
}: QueueItemActionProps) {
return (
<Button
variant="ghost"
size="icon"
disabled={disabled}
onPress={onPress}
accessibilityLabel={label}
accessibilityState={{ disabled: Boolean(disabled) }}
// House formula (confirmation.tsx): RNR's h-10 (40pt) + hitSlop 2/side = the 44pt
// platform minimum. No size-8 override — a 32pt visual with a 40pt hit area
// undershot the wave's own standard.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn('rounded-full', className)}
>
<Icon as={icon} size={14} className="text-muted-foreground" />
</Button>
);
}
/** The remove control, wired to the row's id. Disables when no onRemove is wired. */
function QueueItemRemove({ className }: { className?: string }) {
const { remove, removable } = useQueueItem();
return (
<QueueItemAction
icon={XIcon}
label="Remove from queue"
onPress={remove}
disabled={!removable}
className={className}
/>
);
}
export {
Queue,
QueueItem,
QueueItemAction,
QueueItemActions,
QueueItemContent,
QueueItemDescription,
QueueItemFile,
QueueItemImage,
QueueItemIndicator,
QueueItemRemove,
QueueList,
QueueSection,
QueueSectionContent,
QueueSectionTrigger,
useQueue,
useQueueItem,
useQueueSection,
};
/**
* Queue — pure logic. Zero react-native imports, so the Vitest tier owns the helpers.
*
* THE COMPONENT NEVER OWNS THE QUEUE. The web original's `useQueue` hook holds the
* array and a send sentinel; this registry's law is the opposite — caller state,
* caller-owned mutations (speech-input precedent: a caller-supplied contract only).
* What ships here are the PURE operations a queue consumer needs, so nobody reimplements
* remove-with-identity or duplicate-guarded enqueue per screen.
*
* Items stay caller-shaped: the helpers only ask for an `id` (and, for the count, a
* `completed` flag). Nothing here knows what a queue item carries.
*/
/** The one field every queue item has, whatever else the caller carries. */
export type QueueItemLike = { id: string };
/**
* Removes the one item with this id, preserving order. Pure: the input array is never
* mutated, and a missing id is a no-op returning the same items in the same order —
* never an exception, never a reordered list.
*/
export function removeQueueItem<T extends QueueItemLike>(items: readonly T[], id: string): T[] {
return items.filter((item) => item.id !== id);
}
/**
* Appends items, skipping any whose id is already queued — the duplicate guard the web
* original's useQueue enforces (the same action queued twice must not send twice).
* Accepts one item or many; input is never mutated; order is arrival order.
*/
export function enqueueQueueItems<T extends QueueItemLike>(
items: readonly T[],
next: T | readonly T[],
): T[] {
const incoming = Array.isArray(next) ? next : [next];
const known = new Set(items.map((item) => item.id));
const additions = incoming.filter((item) => !known.has(item.id));
return [...items, ...additions];
}
/** How many queued items are already done — the trigger badge's completed clause. */
export function queueCompletedCount(items: ReadonlyArray<{ completed?: boolean }>): number {
return items.filter((item) => item.completed).length;
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| onRemove | ((id: string) => void) | undefined | — | Called with the item's id. Omit it and every remove control disables. |
| children | React.ReactNode | — | — |
Theming
Themed via border-border, bg-muted, bg-accent, text-muted-foreground, text-foreground, bg-background.
Usage
() => (
<Queue onRemove={() => {}}>
<QueueSection defaultOpen>
<QueueSectionTrigger label="Queued installs" count={2} />
<QueueList>
<QueueItem id="q-1">
<QueueItemContent>
<Text className="text-sm text-foreground">terminal.json</Text>
<QueueItemDescription>ANSI surface · runs after the current install</QueueItemDescription>
</QueueItemContent>
<QueueItemRemove label="Remove terminal.json from the queue" />
</QueueItem>
<QueueItem id="q-2" completed>
<QueueItemContent>
<Text className="text-sm text-foreground">conversation.json</Text>
<QueueItemDescription>installed from v0.2.0</QueueItemDescription>
</QueueItemContent>
</QueueItem>
</QueueList>
</QueueSection>
</Queue>
)