/**
* SelectionMode — turning a list into one you can pick several things out of.
*
* ```tsx
*
*
* (
* open(item)}>
* - …
*
* )}
* />
*
* } destructive onPress={remove}>
* Delete
*
*
*
* ```
*
* ## Two ways to present it
*
* On a screen it is a *mode*: the list is there to be read, and a long press
* turns it into one you can pick from. In a sheet it is a *picker*:
* `SelectionMode.Sheet` was opened in order to choose something, so it is
* choosing from the moment it appears, with the actions in the sheet's footer.
*
* ## The items stay yours
*
* `SelectionMode.Item` wraps whatever you put in it rather than replacing it.
* It adds the circle and takes over what a press means; what the item looks
* like is yours. That is what lets one component hold a row of people, a grid
* of colours, a run of slides and a list of files without growing a prop for
* each of them.
*
* ## A mode has to be obvious
*
* There are two states and the list behaves differently in each: normally a tap
* opens a row, and in selection a tap picks it. That is only safe if leaving is
* always available and never hidden — hence a cancel in the header, the Android
* back button, and the count in front of the reader the whole time.
*
* Entering is a long press on a row, which is the gesture the platform has used
* for this for fifteen years, and the row you pressed is the first one picked.
* Entering with nothing selected leaves the reader in a changed list with no
* explanation of what changed.
*
* ## Selection is a set of values, not of rows
*
* The component holds ids, never indices or elements. A list that reorders,
* pages in more rows or drops one underneath the reader would invalidate
* anything positional; a set of ids survives all three, and is also the shape
* the action at the end needs — deleting takes ids.
*/
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
import Animated, {
FadeIn,
FadeOut,
SlideInDown,
SlideOutDown,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { useBackHandler } from '../../hooks/use-back-handler';
import { CheckIcon, IconColorProvider, XIcon } from '../../icons';
import { AnimatedPressable } from '../../primitives/animated-pressable';
import { Collapse } from '../../primitives/collapse';
import { Text, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { selectionTick } from '../../utils/haptics';
import { BottomSheet } from '../bottom-sheet';
import {
canEnterSelection,
handleSelectionItemPress,
selectAllValues,
selectionOwnsPress,
selectionTarget,
selectValue,
toggleValue,
} from './selection-mode-contracts';
/** How long the circle takes to come and go, in milliseconds. */
const REVEAL_DURATION = 180;
/**
* The circle's width, and the gap after it — both in points, and both mirrors
* of classes on the parts below (`h-6 w-6`, `gap-3`). Written out because the
* circle's slot is animated between nothing and its full size, and an animation
* needs the number rather than the class.
*/
const INDICATOR_SIZE = 24;
const ROW_GAP = 12;
/** The spring the tick lands with — the same one the checkbox uses. */
const TICK_SPRING = { damping: 15, stiffness: 300, mass: 0.5 } as const;
/** How far a floating action bar sits from the edges, in points. */
const DEFAULT_BAR_OFFSET = 16;
/**
* Fill a height that is offered, and take the content's own when none is.
*
* `flexBasis: 'auto'` rather than the `0` that `flex: 1` sets. A `flex: 1` box
* inside a parent of indefinite height resolves to *nothing* — its basis is
* zero and there is no free space to grow into — so a selection list dropped
* into a scrolling page would collapse to a hairline instead of showing its
* rows.
*/
const FILL = { flexGrow: 1, flexShrink: 1, flexBasis: 'auto' } as const;
const selectionVariants = tv({
slots: {
circle: 'h-6 w-6 items-center justify-center rounded-full border-2 border-muted-foreground',
fill: 'absolute inset-0 items-center justify-center rounded-full bg-primary',
header: 'h-14 flex-row items-center gap-3 border-b border-border px-4',
title: 'flex-1 text-center text-base font-semibold text-foreground',
close: 'h-10 w-10 items-center justify-center rounded-full bg-muted',
group: 'overflow-hidden rounded-2xl',
ring: 'rounded-full border-2 border-transparent p-0.5',
bar: 'flex-row items-stretch',
action: 'flex-1 items-center justify-center gap-1.5 px-2 py-3',
actionLabel: 'text-xs font-medium text-foreground',
groupLabel: 'pb-2 ps-1',
},
variants: {
selected: {
true: { circle: 'border-primary', ring: 'border-foreground' },
},
/**
* The action laid out along its label instead of above it.
*
* A screen's bar carries three or four actions side by side, so each one is
* a narrow column and the label belongs under the glyph. A sheet's footer
* usually carries one, full width — stacked there it is a tall block that
* costs the list a row of its own for no gain.
*/
compact: {
true: { action: 'flex-row gap-2 px-4 py-2.5', actionLabel: 'text-sm' },
},
destructive: {
true: { actionLabel: 'text-destructive' },
},
disabled: {
true: { action: 'opacity-[0.44]' },
},
/**
* The group's surface, which depends on what is behind it.
*
* `card` is a step *down* from the background on a screen, which is what a
* grouped list wants there. A sheet is already `popover`, and popover sits
* above card in every dark theme — so the same card inside a sheet is
* darker than the sheet around it and reads as a hole rather than a raised
* set of rows.
*
* `muted` is an alpha tint, so it steps up from whatever it is drawn on and
* is correct against either. It is only used in the sheet because the
* screen's card is right where it is.
*/
surface: {
screen: { group: 'bg-card' },
sheet: { group: 'bg-muted' },
},
/**
* Flush to the bottom edge, or lifted off it.
*
* `bar` is the platform shape — full width against the edge, a hairline
* along the top, and the same background as the screen's own chrome. It is
* the default because it is what a list with a selection in it does on both
* platforms, and because it does not take width away from the list.
*/
placement: {
bar: { bar: 'border-t border-border bg-popover' },
floating: { bar: 'rounded-2xl border border-border bg-popover shadow-lg' },
},
},
defaultVariants: {
placement: 'bar',
surface: 'screen',
},
});
type SelectionVariantProps = VariantProps;
interface SelectionModeContextValue {
active: boolean;
enter: (value?: string) => void;
exit: () => void;
selected: string[];
isSelected: (value: string) => boolean;
toggle: (value: string) => void;
selectAll: () => void;
clear: () => void;
/** True when everything selectable is picked, and there is something to pick. */
allSelected: boolean;
count: number;
/** How many rows `values` says there are, or 0 when it was not given. */
total: number;
max?: number;
haptics: boolean;
/**
* Whether the selection is being presented in a sheet.
*
* A sheet is opened *in order to* pick something, so there is no mode to
* enter and nothing to long-press for — and the action bar belongs to the
* sheet's footer rather than floating over the screen.
*/
sheet: boolean;
}
const SelectionModeContext = createContext(null);
/**
* Read the selection from anywhere inside a `SelectionMode` — for a header of
* your own, a count somewhere else on the screen, or an action that has to know
* what is picked.
*/
export function useSelectionMode(): SelectionModeContextValue {
const context = useContext(SelectionModeContext);
if (!context) {
throw new Error('useSelectionMode must be used within a ');
}
return context;
}
export interface SelectionModeProps extends ViewProps {
className?: string;
/**
* Every value that can be picked, in list order.
*
* Only "select all" and the "n of m" in the header need it — picking rows one
* at a time works without it. Give it the same ids you give the list.
*/
values?: string[];
/** Controlled selection mode. Leave it out and a long press turns it on. */
active?: boolean;
/** Whether selection mode starts on. */
defaultActive?: boolean;
onActiveChange?: (active: boolean) => void;
/** Controlled selection. */
selected?: string[];
defaultSelected?: string[];
onSelectedChange?: (selected: string[]) => void;
/**
* The most that can be picked at once.
*
* A row that would go over it does not toggle on, and "select all" stops at
* the limit rather than refusing. Leave it out for no limit.
*/
max?: number;
/**
* A tick when a row is picked and when the mode is entered. Off by default —
* needs the optional `expo-haptics`, and is silent without it.
*/
haptics?: boolean;
children: ReactNode;
}
function SelectionModeRoot({
className,
values,
active: activeProp,
defaultActive = false,
onActiveChange,
selected: selectedProp,
defaultSelected,
onSelectedChange,
max,
haptics = false,
children,
...props
}: SelectionModeProps) {
const [internalActive, setInternalActive] = useState(defaultActive);
const [internalSelected, setInternalSelected] = useState(defaultSelected ?? []);
const active = activeProp ?? internalActive;
const selected = selectedProp ?? internalSelected;
const selectedRef = useRef(selected);
selectedRef.current = selected;
const setSelected = useCallback(
(next: string[]) => {
if (selectedProp === undefined) setInternalSelected(next);
onSelectedChange?.(next);
},
[selectedProp, onSelectedChange]
);
const setActive = useCallback(
(next: boolean) => {
if (activeProp === undefined) setInternalActive(next);
onActiveChange?.(next);
},
[activeProp, onActiveChange]
);
const enter = useCallback(
(value?: string) => {
if (haptics) selectionTick();
setActive(true);
// Entering with the row that was pressed already picked. Entering with
// nothing picked leaves the reader in a list that has changed under them
// with nothing to show for it.
if (value !== undefined) {
const next = selectValue(selectedRef.current, value, max);
if (next !== selectedRef.current) setSelected(next);
}
},
[haptics, max, setActive, setSelected]
);
/*
* Leaving clears the selection.
*
* A selection that outlived the mode would come back the next time it was
* entered, and the reader who left by pressing cancel is exactly the reader
* who meant "not those". Keep it across a mode change by controlling
* `selected` yourself.
*/
const exit = useCallback(() => {
setActive(false);
setSelected([]);
}, [setActive, setSelected]);
const isSelected = useCallback(
(value: string) => selectedRef.current.includes(value),
[]
);
const toggle = useCallback(
(value: string) => {
const current = selectedRef.current;
const next = toggleValue(current, value, max);
if (next === current) return;
if (!current.includes(value) && haptics) selectionTick();
setSelected(next);
},
[max, haptics, setSelected]
);
const selectAll = useCallback(() => {
if (!values) return;
// At the limit rather than refusing: somebody who asked for all of them and
// can only have twenty wants the twenty, not an error.
setSelected(selectAllValues(values, max));
}, [values, max, setSelected]);
const clear = useCallback(() => setSelected([]), [setSelected]);
const total = values?.length ?? 0;
const count = selected.length;
const target = selectionTarget(total, max);
const allSelected = target > 0 && count >= target;
// An open mode owns the back button: back should leave the mode, not the
// screen the list is on.
useBackHandler(active, exit);
const context = useMemo(
() => ({
active,
enter,
exit,
selected,
isSelected,
toggle,
selectAll,
clear,
allSelected,
count,
total,
max,
haptics,
sheet: false,
}),
[
active,
enter,
exit,
selected,
isSelected,
toggle,
selectAll,
clear,
allSelected,
count,
total,
max,
haptics,
]
);
return (
{textChildren(children)}
);
}
/* -------------------------------------------------------------------------- *
* Indicator
* -------------------------------------------------------------------------- */
export interface SelectionModeIndicatorProps {
className?: string;
/** Which row this stands for. Defaults to the row it is inside. */
value?: string;
}
/**
* The circle at the left of a row.
*
* Round rather than square, and that is the convention doing real work: a
* square box is a form control the reader is filling in, a round one is a thing
* they are picking out of a list. `Checkbox` is the former and stays that way.
*
* `Item` draws one for you. This is exported for a row that wants it somewhere
* else — over a photo's corner, at the end instead of the start.
*/
function SelectionModeIndicator({ className, value }: SelectionModeIndicatorProps) {
const { isSelected } = useSelectionMode();
const row = useContext(SelectionModeItemContext);
const target = value ?? row?.value;
const selected = target !== undefined && isSelected(target);
const reducedMotion = useReducedMotion();
const progress = useSharedValue(selected ? 1 : 0);
const tickColor = useCSSVariable('--color-primary-foreground');
const slots = selectionVariants({ selected });
useEffect(() => {
if (reducedMotion) {
progress.value = selected ? 1 : 0;
return;
}
progress.value = selected
? withSpring(1, TICK_SPRING)
: withTiming(0, { duration: 120 });
}, [selected, reducedMotion, progress]);
const fillStyle = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ scale: 0.6 + progress.value * 0.4 }],
}));
return (
);
}
/* -------------------------------------------------------------------------- *
* Item
* -------------------------------------------------------------------------- */
/** What an indicator inside a row needs to know, without being told twice. */
const SelectionModeItemContext = createContext<{ value: string } | null>(null);
export interface SelectionModeItemProps extends Omit {
className?: string;
/** This row's id. What ends up in `selected`. */
value: string;
/** What the row does whenever selection does not own its press. */
onPress?: () => void;
/**
* Stop this row entering selection mode, and being picked once in it. Its
* ordinary `onPress` still runs, including while selection is active. For a
* header row, an advert, a "load more" — anything in the list that is not one
* of the things being chosen between.
*/
disabled?: boolean;
/** Draw the circle without waiting for the mode. */
alwaysShowIndicator?: boolean;
/**
* How being picked is drawn.
*
* `leading` puts the circle in front of the item, which is what a row wants.
* `ring` draws a ring around whatever you gave it instead — for a swatch, a
* thumbnail or a photo, where a circle beside it would be a second thing to
* look at and the item itself can carry the state. `none` draws nothing and
* leaves it to you; read `useSelectionMode().isSelected`.
*/
indicator?: 'leading' | 'ring' | 'none';
children: ReactNode;
}
/**
* One row, with the circle in front of it.
*
* The press behaviour is the whole component: off mode, a press is the row's
* own and a long press turns the mode on with this row picked; in it, a press
* picks and unpicks. A row excluded from selection keeps its ordinary press in
* both states. Two meanings for one gesture is exactly why the mode has to be
* visible from the header.
*/
function SelectionModeItem({
className,
value,
onPress,
disabled = false,
alwaysShowIndicator = false,
indicator = 'leading',
children,
...props
}: SelectionModeItemProps) {
const { active, enter, toggle, isSelected, sheet } = useSelectionMode();
const selected = isSelected(value);
const showing = alwaysShowIndicator || active;
const selecting = selectionOwnsPress(active, disabled);
const reducedMotion = useReducedMotion();
const context = useMemo(() => ({ value }), [value]);
/*
* The circle's arrival and departure, as width rather than as opacity.
*
* Fading a circle that has already been taken out of the row's layout fades
* it over content that has finished moving: the row snaps left the frame the
* mode ends, and a ghost of the circle dissolves where it used to be. The
* space is what the reader sees change, so the space is what animates, and
* the negative margin takes the row's own gap with it — a zero-width child
* still costs a gap, so without it the row would still jump the last twelve
* points.
*/
const reveal = useSharedValue(showing ? 1 : 0);
useEffect(() => {
if (reducedMotion) {
reveal.value = showing ? 1 : 0;
return;
}
reveal.value = withTiming(showing ? 1 : 0, { duration: REVEAL_DURATION });
}, [showing, reducedMotion, reveal]);
const slotStyle = useAnimatedStyle(() => ({
width: reveal.value * INDICATOR_SIZE,
opacity: reveal.value,
marginEnd: (reveal.value - 1) * ROW_GAP,
}));
return (
{
handleSelectionItemPress(active, disabled, () => toggle(value), onPress);
}}
onLongPress={
canEnterSelection(active, disabled, sheet) ? () => enter(value) : undefined
}
className={cn(
indicator === 'leading' ? 'flex-row items-center gap-3 px-4 py-2.5' : '',
className
)}
{...props}
>
{indicator === 'leading' ? (
<>
{/*
* `minWidth: 0` as well as growing. Without it a long title refuses
* to be narrower than its own text, pushes the row past the screen
* and takes the layout with it — which is what a flex child does by
* default, and why a name ends up broken across lines mid-word.
*/}
{textChildren(children)}
>
) : indicator === 'ring' && showing ? (
{textChildren(children)}
) : (
textChildren(children)
)}
);
}
/* -------------------------------------------------------------------------- *
* Group
* -------------------------------------------------------------------------- */
export interface SelectionModeGroupProps extends ViewProps {
className?: string;
/**
* Lay the items out in a grid this many across instead of stacking them.
*
* For things recognised by sight rather than read — swatches, thumbnails,
* slides. A grid of six colours is one glance; the same six as rows is a
* scroll.
*
* Ignored when `horizontal` is set.
*/
columns?: number;
/**
* Lay the items out in one row that scrolls sideways.
*
* For a strip of small things next to other controls — swatches above a
* slider, filters above a list. A grid of the same items claims as many rows
* as it needs and pushes everything below it off the sheet; a strip costs one
* row whatever the count.
*
* Wins over `columns`, which asks for the opposite arrangement.
*/
horizontal?: boolean;
/** How wide each item is in a horizontal strip, in points. */
itemWidth?: number;
/** Space between items in a grid or a strip, in points. */
gap?: number;
/**
* A caption above the items, on the leading edge.
*
* Worth setting on anything picked by sight. A strip of colours with nothing
* in front of it is a row of circles the reader has to work out the purpose
* of, and a screen reader has nothing at all to announce it by — so this is
* also the group's accessibility label.
*/
label?: string;
/** Extra classes for that caption. */
labelClassName?: string;
/** Hairlines between stacked items. On by default; off in a grid or a strip. */
separators?: boolean;
children: ReactNode;
}
/**
* A rounded card holding a run of items.
*
* Grouping is what makes a sheet of choices readable: one card of options with
* hairlines between them reads as a set, and the same rows loose on the sheet's
* background read as a list that has not finished loading. It is also what the
* platform's own sheets do.
*
* Stacked by default, with a rule between each item. Pass `columns` for a grid.
*/
function SelectionModeGroup({
className,
columns,
horizontal = false,
itemWidth = 44,
gap = 12,
label,
labelClassName,
separators = true,
children,
style,
...props
}: SelectionModeGroupProps) {
const items = Children.toArray(children).filter(Boolean);
// A sheet's own surface already sits above a card's, so which fill the group
// takes depends on where it is. Every other part reads the context too.
const { sheet } = useSelectionMode();
const slots = selectionVariants({ surface: sheet ? 'sheet' : 'screen' });
/**
* The caption, and the wrapper that carries it. A group with no `label` is
* the view it always was, so nothing gains a level of nesting for a prop it
* did not pass.
*/
const captioned = (content: ReactNode) =>
label === undefined ? (
content
) : (
{label}
{content}
);
if (horizontal) {
/*
* `gap` on the row rather than padding inside each cell, which is the
* opposite of the grid below. A strip is not dividing a fixed width between
* its items, so there is no wrap to protect against — and the gap has to be
* between them rather than around them, or the strip starts inset from
* whatever it is in.
*/
return captioned(
{items.map((item, index) => (
{item}
))}
);
}
if (columns && columns > 0) {
/*
* The gap is padding inside each cell, not `gap` on the row.
*
* A row of cells `100 / columns` wide with a gap between them is wider than
* the row by the gaps, so the last column wraps and the grid loses a
* column. Padding inside the cell keeps every cell an exact share of the
* width, and the negative margin cancels the outer half so the grid still
* sits flush against whatever it is in.
*/
const half = gap / 2;
return captioned(
{items.map((item, index) => (
{item}
))}
);
}
return captioned(
{items.map((item, index) => (
{separators && index > 0 ? (
// Inset from the left so the rule starts under the text rather than
// under the circle, which is what a grouped list does.
) : null}
{item}
))}
);
}
/* -------------------------------------------------------------------------- *
* Header
* -------------------------------------------------------------------------- */
export interface SelectionModeHeaderProps extends ViewProps {
className?: string;
/** The word in front of the count. */
title?: string;
/** Hide the select-all control, for a list where picking everything is wrong. */
hideSelectAll?: boolean;
/** Replaces the whole header's contents, keeping only its layout. */
children?: ReactNode;
}
/**
* The bar that says the mode is on: a way out, how many are picked, and all
* of them at once.
*
* Rendered only while the mode is on, and it is the thing that makes the mode
* legible — a list whose rows have quietly changed what a tap does, with no
* banner saying so, is a list that loses somebody's work.
*/
function SelectionModeHeader({
className,
title = 'Select',
hideSelectAll = false,
children,
...props
}: SelectionModeHeaderProps) {
const { active, exit, count, total, allSelected, selectAll, clear, sheet } =
useSelectionMode();
const slots = selectionVariants({});
/*
* Collapsed rather than unmounted.
*
* A fade on a view that has already been taken out of the flow fades nothing
* — the list under it has jumped up 56 points on the frame the mode ended,
* and what is left dissolving is a header nobody is looking at any more.
* Giving up the height *is* the transition, so that is the part that animates.
*/
return (
{children ? (
textChildren(children)
) : (
<>
{/*
* The two ends are the same width, so the title between them is
* centred on the screen rather than on whatever is left over. A title
* that shifts sideways as the count goes from 9 to 10 reads as the
* header being rebuilt.
*/}
{/* A sheet dismisses itself — by its handle, its scrim or the back
gesture — so a second way out inside it is one too many. */}
{sheet ? null : (
)}
{count > 0 ? `${title} (${count})` : title}
{hideSelectAll || total === 0 ? null : (
{allSelected ? 'Clear' : 'All'}
)}
>
)}
);
}
/* -------------------------------------------------------------------------- *
* Bar
* -------------------------------------------------------------------------- */
export interface SelectionModeBarProps
extends ViewProps,
Pick {
className?: string;
/**
* Room under the actions, in points — your safe-area inset.
*
* A bar against the bottom edge sits over the home indicator on a phone that
* has one, and an action under a home indicator is an action that takes two
* tries. `floating` uses it as the gap on all four sides instead.
*/
inset?: number;
/**
* Keep the bar up with nothing picked.
*
* Off by default: every action on it needs something to act on, and a row of
* buttons that all refuse is worse than a row that is not there yet.
*/
showWhenEmpty?: boolean;
children: ReactNode;
}
/**
* The actions, across the bottom of the list.
*
* Over the list rather than under it, because the list is as long as it is and
* a bar in the flow would be somewhere off the end of it. **Pad the bottom of
* your list so the last row can clear this** — nothing here can work out how
* tall the list is.
*
* Flush to the edge by default. A bar inset from the sides is a card floating
* over a list, which reads as something that arrived rather than as the mode
* the screen is in — and it takes width away from the actions, which are the
* one row of controls on screen that must not be cramped.
*/
function SelectionModeBar({
className,
placement,
inset = 0,
showWhenEmpty = false,
children,
style,
...props
}: SelectionModeBarProps) {
const { active, count, sheet } = useSelectionMode();
const reducedMotion = useReducedMotion();
const floating = placement === 'floating';
const slots = selectionVariants({ placement });
if (!active || (count === 0 && !showWhenEmpty)) return null;
/*
* In a sheet the bar is the sheet's footer: it is already at the bottom of
* something, already the width of it, and the footer draws the rule above it.
* Positioning it absolutely there would take it out of the sheet's layout and
* hang it over the content instead of under it.
*/
if (sheet) {
return (
{textChildren(children)}
);
}
const edge = floating ? inset || DEFAULT_BAR_OFFSET : 0;
return (
{textChildren(children)}
);
}
/* -------------------------------------------------------------------------- *
* Action
* -------------------------------------------------------------------------- */
export interface SelectionModeActionProps
extends Omit,
Pick {
className?: string;
/** The glyph above the label. */
icon?: ReactNode;
/**
* What it does. Handed the selection, so the common case needs no other
* wiring — and leaving the mode afterwards is up to you, because whether the
* list still makes sense depends on what you did to it.
*/
onPress?: (selected: string[]) => void;
/** Leave selection mode after the action runs. */
exitOnPress?: boolean;
disabled?: boolean;
/** Extra classes for the label. */
labelClassName?: string;
children?: ReactNode;
}
/**
* One action in the bar: a glyph with its name under it.
*
* Labelled, always. A row of bare glyphs at the bottom of a screen is a row of
* guesses, and one of them usually deletes something.
*/
function SelectionModeAction({
className,
icon,
onPress,
exitOnPress = false,
disabled = false,
destructive,
labelClassName,
children,
...props
}: SelectionModeActionProps) {
const { selected, exit, count, sheet } = useSelectionMode();
const destructiveColor = useCSSVariable('--color-destructive');
// Nothing picked is nothing to act on, so the action is off rather than
// pressable-and-inert.
const off = disabled || count === 0;
// A sheet's footer is one action wide, not four, so it lies down.
const slots = selectionVariants({ destructive, disabled: off, compact: sheet });
return (
{
onPress?.(selected);
if (exitOnPress) exit();
}}
className={cn(slots.action(), className)}
{...props}
>
{icon}
{textChildren(children, (text) => (
{text}
))}
);
}
/* -------------------------------------------------------------------------- *
* Sheet
* -------------------------------------------------------------------------- */
export interface SelectionModeSheetProps {
className?: string;
/** Controlled open state of the sheet. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** The word in front of the count. */
title?: string;
/** Hide the select-all control. */
hideSelectAll?: boolean;
/**
* How tall the sheet opens.
*
* `full` by default, and deliberately not `auto`. A sheet that sizes to its
* content gives its scrolling body no height to fill, and a list inside a box
* of no height draws nothing — which looks like an empty sheet rather than
* like a missing style.
*
* Full rather than half because a picker spends a header and a footer before
* it draws a single row. At half a screen that leaves four or five rows for
* the thing the sheet was opened to do, and the reader scrolls a list that
* would have fitted. Pass `half` for a sheet of two or three choices.
*/
size?: 'auto' | 'half' | 'full';
/**
* The things to pick between, and optionally a `SelectionMode.Bar` of
* actions. The bar is lifted into the sheet's footer wherever it is written.
*/
children: ReactNode;
}
/**
* The whole selection, presented in a bottom sheet.
*
* A picker rather than a mode. The list on a screen has to be *turned into* one
* you can pick from — hence the long press, the cancel and the count — but a
* sheet was opened in order to pick something, so it is picking from the moment
* it appears and there is nothing to enter or leave.
*
* What goes in it is anything: a column of friends, a grid of colours, a run of
* slides. `SelectionMode.Item` wraps whatever you give it, so the sheet does
* not need to know what it is holding.
*
* ```tsx
*
*
* {people.map((person) => (
*
* - …
*
* ))}
*
* } onPress={share}>Send
*
*
*
* ```
*/
function SelectionModeSheet({
className,
open,
defaultOpen,
onOpenChange,
title = 'Select',
hideSelectAll = false,
size = 'full',
children,
}: SelectionModeSheetProps) {
const parent = useSelectionMode();
/*
* The bar is pulled out of the children and put in the sheet's footer,
* wherever it was written. A footer is a place in the sheet's layout rather
* than a thing you can position into from the middle of the body — and
* writing the bar last, after the items, is how it reads.
*/
const { bar, rest } = useMemo(() => {
const others: ReactNode[] = [];
let found: ReactNode = null;
Children.forEach(children, (child) => {
if (isValidElement(child) && child.type === SelectionModeBar) found = child;
else others.push(child);
});
return { bar: found, rest: others };
}, [children]);
// Picking from the moment it opens: a sheet is not a mode to be entered.
const context = useMemo(
() => ({ ...parent, active: true, sheet: true }),
[parent]
);
return (
{/*
* No close button. The sheet's own sits in the top trailing corner, which
* is where the header puts "All" — two targets a few points apart, one of
* which throws the selection away. The sheet is still dismissed by its
* handle, by the scrim and by the back gesture, which is the same reason
* the header does not draw an X of its own in here.
*/}
{/*
* The provider goes *inside* the sheet's content, not around the sheet.
*
* A sheet renders its content through a portal, which mounts it at the
* app root — nowhere below this component. A provider wrapped around
* the outside is therefore not an ancestor of anything in the sheet,
* and every part inside it throws for want of a context that is on
* screen but in the wrong branch of the tree.
*/}
{/*
* `pe-0` gives the trailing edge back. `BottomSheet.Header` reserves
* room there for the close button, and this sheet draws none — left
* alone, the reserved 48 points push the centred title off the middle
* of the sheet and hold "All" that far in from the corner.
*/}
{/* The sheet already draws the rule and the padding. */}
{textChildren(rest)}
{bar ? {bar} : null}
);
}
SelectionModeRoot.displayName = 'SelectionMode';
SelectionModeSheet.displayName = 'SelectionMode.Sheet';
SelectionModeItem.displayName = 'SelectionMode.Item';
SelectionModeGroup.displayName = 'SelectionMode.Group';
SelectionModeIndicator.displayName = 'SelectionMode.Indicator';
SelectionModeHeader.displayName = 'SelectionMode.Header';
SelectionModeBar.displayName = 'SelectionMode.Bar';
SelectionModeAction.displayName = 'SelectionMode.Action';
export const SelectionMode = Object.assign(SelectionModeRoot, {
Sheet: SelectionModeSheet,
Group: SelectionModeGroup,
Item: SelectionModeItem,
Indicator: SelectionModeIndicator,
Header: SelectionModeHeader,
Bar: SelectionModeBar,
Action: SelectionModeAction,
});