@elberpg/ex-select-modal
v2.1.0
Published
Composable scroll-based column selector with modal shells for React Native / Expo
Maintainers
Readme
@elberpg/ex-select-modal
Composable scroll-column selector for React Native / Expo.
1, 2, or 3 independent drum-scroll columns — no native dependencies, no @elberpg/ex-modal required.
Spanish docs: README_ES.md
Preview
| 1 Column | 2 Columns | 3 Columns |
|:-:|:-:|:-:|
|
|
|
|
Fully themeable via global style objects — no props required.
| Dark ☕ | Square ⬛ |
|:-:|:-:|
|
|
|
Installation
npm install @elberpg/ex-select-modalConcept — composable pattern
The column picker and the modal are independent components. You compose them:
<SelectModal visible={open} onClose={onClose} onConfirm={() => onConfirm(draft)}>
<Select columns={columns} value={draft} onChange={setDraft} />
</SelectModal>This gives full style control over each part independently.
Select
Scroll-column picker with 1, 2, or 3 independent columns. Use standalone or inside any modal.
import { Select, SelectColumn } from '@elberpg/ex-select-modal';
const COL_QTY: SelectColumn = { title: 'Qty', items: ['1','2','3','4','5'], titleNull: '—' };
const COL_UNIT: SelectColumn = { title: 'Unit', items: ['kg', 'lb', 'g', 'oz'] };
<Select
columns={[COL_QTY, COL_UNIT]}
value={draft}
onChange={setDraft}
/>Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| columns | SelectColumns | — | 1, 2, or 3 column definitions |
| value | (string \| null)[] | — | Current value; one element per column |
| onChange | (values: (string \| null)[], item?: SelectItem \| null) => void | — | Called on every scroll change |
SelectColumn
interface SelectColumn {
title: string; // Label above the column
items: SelectItemInput[]; // string or { label, value, extra? }
titleNull?: string; // Null option shown first (returns null when selected)
renderItem?: (item: SelectItem, selected: boolean) => ReactNode; // Custom row render
}Plain strings (simple case)
const COL_UNIT: SelectColumn = {
title: 'Unit',
items: ['kg', 'lb', 'g', 'oz'],
};Label / value objects
Show a long label in the picker but store a short value in state:
const COL_UNIT: SelectColumn = {
title: 'Unit',
items: [
{ label: 'Kilograms', value: 'kg' },
{ label: 'Pounds', value: 'lb' },
{ label: 'Grams', value: 'g' },
],
};
// onChange receives:
// values → ['kg'] short value — what you store
// item → { label: 'Kilograms', value: 'kg' } full object
<Select
columns={[COL_UNIT]}
value={draft}
onChange={(values, item) => {
setDraft(values);
console.log(item?.label); // 'Kilograms'
}}
/>Extra metadata + renderItem
Use extra to attach any data (icons, symbols, flags…) and renderItem to fully control how each row looks:
const COL_CURRENCY: SelectColumn = {
title: 'Currency',
items: [
{ label: 'Dollar', value: 'USD', extra: { icon: '🇺🇸', symbol: '$' } },
{ label: 'Euro', value: 'EUR', extra: { icon: '🇪🇺', symbol: '€' } },
{ label: 'Peso MX', value: 'MXN', extra: { icon: '🇲🇽', symbol: '$' } },
],
renderItem: (item, selected) => {
const ex = item.extra as { icon: string; symbol: string };
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text>{ex.icon}</Text>
<Text style={{ flex: 1, color: selected ? '#111' : '#999', fontWeight: selected ? '600' : '400' }}>
{item.label}
</Text>
<Text style={{ color: selected ? '#2563eb' : '#bbb' }}>{ex.symbol}</Text>
</View>
);
},
};
// onChange still gives you the plain value + full item:
<Select
columns={[COL_CURRENCY]}
value={draft}
onChange={(values, item) => {
setDraft(values);
// values[0] → 'USD'
// item.extra → { icon: '🇺🇸', symbol: '$' }
}}
/>SelectItem type
interface SelectItem {
label: string; // Text shown in the column
value: string; // Value stored in state
extra?: unknown; // Any metadata you need (icon, color, symbol…)
}
type SelectItemInput = string | SelectItem; // both forms are valid in items[]titleNull — null option
const COL_SHADE: SelectColumn = {
title: 'Shade',
items: ['Light', 'Medium', 'Dark'],
titleNull: 'None', // First option — column returns null when selected
};SelectModal — native RN Modal
Bottom-sheet using React Native's Modal component.
import { Select, SelectModal } from '@elberpg/ex-select-modal';
const [open, setOpen] = useState(false);
const [val, setVal] = useState<(string | null)[]>([null, 'kg']);
const [draft, setDraft] = useState<(string | null)[]>([null, 'kg']);
<Pressable onPress={() => { setDraft(val); setOpen(true); }}>
<Text>{val.filter(Boolean).join(' · ') || 'Select...'}</Text>
</Pressable>
<SelectModal
visible={open}
onClose={() => setOpen(false)}
onConfirm={() => { setVal(draft); setOpen(false); }}
title="Quantity and unit"
clearable
onClear={() => { setVal([null, null]); setOpen(false); }}
>
<Select columns={[COL_QTY, COL_UNIT]} value={draft} onChange={setDraft} />
</SelectModal>SelectModalOverlay — style-based overlay
Same API as SelectModal but uses position: absolute instead of RN's Modal.
Avoids conflicts with toasts, sheets, and other overlays. Must be placed at root level.
import { Select, SelectModalOverlay } from '@elberpg/ex-select-modal';
<SelectModalOverlay
visible={open}
onClose={() => setOpen(false)}
onConfirm={() => { setVal(draft); setOpen(false); }}
title="Quantity and unit"
>
<Select columns={[COL_QTY, COL_UNIT]} value={draft} onChange={setDraft} />
</SelectModalOverlay>Modal props — SelectModalProps
Shared by SelectModal and SelectModalOverlay.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| visible | boolean | — | Shows or hides the modal |
| onClose | () => void | — | Called when the backdrop or Cancel is tapped |
| onConfirm | () => void | — | Called when Confirm is tapped |
| children | ReactNode | — | The <Select> to render inside |
| title | string | — | Modal title |
| confirmLabel | string | 'Confirm' | Confirm button text |
| cancelLabel | string | 'Cancel' | Cancel button text |
| clearable | boolean | false | Show a Clear button |
| clearLabel | string | 'Clear' | Clear button text |
| onClear | () => void | — | Called when Clear is tapped |
| btnConfirm | ({ onPress }) => ReactNode | — | Replace the Confirm button |
| btnCancel | ({ onPress }) => ReactNode | — | Replace the Cancel button |
| btnClear | ({ onPress }) => ReactNode | — | Replace the Clear button |
onChange — parameter flow
items: [{ label: 'Kilograms', value: 'kg', extra: {...} }, ...]
│
│ user scrolls
▼
onChange(values, item)
values → ['kg'] one value per column (what you store)
item → { label: 'Kilograms', value: 'kg' } the item that just changed (optional)values[i]— the shortvaluestring (ornulliftitleNullis selected)item— the fullSelectItemthat just changed; use it to readlabelorextra
Global styles
Two separate style objects — one for the picker columns, one for the modal shell.
ExSelect.styles — picker (scroll columns)
import { ExSelect } from '@elberpg/ex-select-modal';
ExSelect.styles.selectedColor = '#0a84ff';
ExSelect.styles.unselectedColor = '#8e8e93';
ExSelect.styles.itemHeight = 52;
ExSelect.styles.fontFamily = 'Georgia';
ExSelect.styles.lineColor = '#3a3a3c';
ExSelect.styles.columnTitle = { fontSize: 11, color: '#888', textTransform: 'uppercase' };| Field | Type | Description |
|-------|------|-------------|
| selectedColor | string | Color of the selected scroll item |
| unselectedColor | string | Color of unselected scroll items |
| itemHeight | number | Height of each scroll row in px |
| fontFamily | string | Font family for scroll item text |
| lineColor | string | Color of the two selection indicator lines |
| columnTitle | TextStyle | Label above each scroll column |
ExSelectModal.styles — modal shell
import { ExSelectModal } from '@elberpg/ex-select-modal';
ExSelectModal.styles.card = {
backgroundColor: '#1c1c1e',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
};
ExSelectModal.styles.title = { fontSize: 17, fontWeight: '700', color: '#fff' };
ExSelectModal.styles.btnConfirm = { backgroundColor: '#0a84ff' };
ExSelectModal.styles.btnConfirmText = { fontSize: 15, fontWeight: '600', color: '#fff' };
ExSelectModal.styles.backdropColor = 'rgba(0,0,0,0.65)';| Field | Type | Description |
|-------|------|-------------|
| card | ViewStyle | Main card (background, border radius, padding) |
| pill | ViewStyle | Drag pill at the top |
| title | TextStyle | Modal title (fontSize, color, fontFamily…) |
| headerBorder | ViewStyle | Separator below the title |
| btnConfirm | ViewStyle | Confirm button container |
| btnCancel | ViewStyle | Cancel button container |
| btnClear | ViewStyle | Clear button container |
| btnConfirmText | TextStyle | Confirm button text |
| btnCancelText | TextStyle | Cancel button text |
| btnClearText | TextStyle | Clear button text |
| backdropColor | string | Backdrop color + opacity (rgba(…)) |
Custom button example
<SelectModal
btnConfirm={({ onPress }) => (
<Pressable onPress={onPress} style={{ backgroundColor: '#0a84ff', borderRadius: 12, flex: 1, height: 48, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '700' }}>Done</Text>
</Pressable>
)}
>
<Select columns={[COL_UNIT]} value={draft} onChange={setDraft} />
</SelectModal>Important rule
Place modals outside any ScrollView.
// ✅ Correct
return (
<>
<SafeAreaView>
<ScrollView>...</ScrollView>
</SafeAreaView>
<SelectModal visible={open} onClose={onClose} onConfirm={onConfirm}>
<Select columns={columns} value={draft} onChange={setDraft} />
</SelectModal>
</>
);
// ❌ Wrong — the modal scrolls with the content
return (
<ScrollView>
<SelectModal visible={open} .../>
</ScrollView>
);