Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Every picker comes three ways, with the same props.
<DatePickerField label="Date" value={date} onConfirm={setDate} />
```

Sheets open as a bottom sheet, or as a centered modal on wide web windows (`presentation="bottomSheet" | "modal"`). Fields and `SelectPicker` also open as a dropdown under the field, their default on the web (`presentation="dropdown"`). `PickerField` wraps any sheet of your own in a field.
Sheets open as a bottom sheet, or as a centered modal on wide web windows (`presentation="bottomSheet" | "modal"`). Fields and `SelectPicker` also open as a dropdown under the field, their default on the web (`presentation="dropdown"`), or expand inline under it, pushing the content below, like the iOS Settings app (`presentation="inline"`). `PickerField` wraps any sheet of your own in a field.

Button text is yours too: `confirmLabel`, `cancelLabel` and `showCancel` on any sheet or field.

Expand Down Expand Up @@ -348,7 +348,7 @@ A length of time, such as a timer or a workout, rather than a time of day. The v
| `searchable` | `boolean` | more than 8 options |
| `loadOptions` | `(query) => Promise<options>`: search a server | |
| `clearable` / `onClear` | `boolean` / `() => void` | `false` |
| `presentation` | `'auto' \| 'bottomSheet' \| 'modal' \| 'dropdown'` | `'auto'`: dropdown on the web |
| `presentation` | `'auto' \| 'bottomSheet' \| 'modal' \| 'dropdown' \| 'inline'` | `'auto'`: dropdown on the web |

`loadOptions` waits for a pause in typing, shows a loader and ignores answers to older queries:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"range-picker",
"select",
"dropdown",
"inline-picker",
"emoji-picker",
"country-picker",
"country-code-picker",
Expand Down
2 changes: 1 addition & 1 deletion src/color/ColorPickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const ColorPickerSheet = ({
...rest
}: ColorPickerSheetProps) => {
const theme = useDesignOverride(themeProp, design);
const [draft, setDraft] = useDraft((value && normalizeHex(value)) ?? DEFAULT_COLOR, visible);
const [draft, setDraft] = useDraft((value && normalizeHex(value)) ?? DEFAULT_COLOR, visible, { commit: onConfirm });
return (
<PickerSheet
{...{ visible, onClose, title, confirmLabel, cancelLabel, showCancel, presentation, sheetProps, theme }}
Expand Down
2 changes: 2 additions & 0 deletions src/core/core.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export interface PresenceOptions {
/** Exit duration, and the entry duration unless `enterDuration` is set. */
duration: number;
enterDuration?: number;
/** Default: the native driver wherever it exists. Off for layout props such as `height`, which it can't animate. */
native?: boolean;
onShown?: () => void;
onHidden?: () => void;
}
Expand Down
8 changes: 4 additions & 4 deletions src/core/hooks/usePresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { timing } from '../utils/animation.utils';
import { useAnimatedValue } from './useAnimatedValue';

/** Mounts when `visible` turns on and animates `progress` in; on hide it animates out first and unmounts after. */
export const usePresence = ({ visible, duration, enterDuration = duration, onShown, onHidden }: PresenceOptions): Presence => {
export const usePresence = ({ visible, duration, enterDuration = duration, native, onShown, onHidden }: PresenceOptions): Presence => {
const [mounted, setMounted] = useState(visible);
const progress = useAnimatedValue(0);
const wasVisible = useRef(false);
Expand All @@ -16,18 +16,18 @@ export const usePresence = ({ visible, duration, enterDuration = duration, onSho
useEffect(() => {
if (visible) {
setMounted(true);
timing(progress, 1, { duration: enterDuration }).start(({ finished }) => {
timing(progress, 1, { duration: enterDuration, native }).start(({ finished }) => {
if (finished) callbacks.current.onShown?.();
});
} else if (wasVisible.current) {
timing(progress, 0, { duration, easing: EASE_IN }).start(({ finished }) => {
timing(progress, 0, { duration, easing: EASE_IN, native }).start(({ finished }) => {
if (!finished) return;
setMounted(false);
callbacks.current.onHidden?.();
});
}
wasVisible.current = visible;
}, [duration, enterDuration, progress, visible]);
}, [duration, enterDuration, native, progress, visible]);

return {
mounted,
Expand Down
11 changes: 9 additions & 2 deletions src/date/DatePickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,19 @@ export const DatePickerSheet = (props: DatePickerSheetProps) => {
const display = getSheetDisplay(mode, displayProp);
// The sheet holds whichever value shape the mode uses; `mode` ties value, draft and onConfirm together, so a new mode starts a new draft.
const confirmDraft = onConfirm as (draft: DateDraft) => void;
const [draft, setDraft] = useDraft<DateDraft>(value ?? getEmptyDraft(mode, display), visible, mode);
const confirmsOnSelect = !!confirmOnSelect && mode === CALENDAR_MODE.single && display === PICKER_DISPLAY.calendar;
// Inline, a range commits once both ends are picked; confirmOnSelect commits on its own.
const commit = (next: DateDraft) => {
if (isDraftReady(mode, next)) confirmDraft(next);
};
const [draft, setDraft] = useDraft<DateDraft>(value ?? getEmptyDraft(mode, display), visible, {
resetKey: mode,
commit: confirmsOnSelect ? undefined : commit,
});
const ready = isDraftReady(mode, draft);
const resolved = usePickerTheme(theme);
const headline =
DATE_HEADLINE[resolved.design] && display === PICKER_DISPLAY.calendar ? formatDateHeadline(mode, draft, calendarProps.locale) : null;
const confirmsOnSelect = !!confirmOnSelect && mode === CALENDAR_MODE.single && display === PICKER_DISPLAY.calendar;

const confirm = () => {
if (!ready) return;
Expand Down
5 changes: 3 additions & 2 deletions src/datetime/DateTimePickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ export const DateTimePickerSheet = ({
...rest
}: DateTimePickerSheetProps) => {
const theme = useDesignOverride(themeProp, design);
const [draft, setDraft] = useDraft(value ?? new Date(), visible);
const commit = (next: Date) => onConfirm(snapTime(next, rest));
const [draft, setDraft] = useDraft(value ?? new Date(), visible, { commit });
const { design: resolvedDesign } = usePickerTheme(theme);
const display = rest.display ?? DATETIME_DESIGN[resolvedDesign].display;
return (
<PickerSheet
{...{ visible, onClose, title, confirmLabel, cancelLabel, showCancel, presentation, sheetProps, theme }}
onConfirm={() => {
onConfirm(snapTime(draft, rest));
commit(draft);
onClose();
}}
>
Expand Down
2 changes: 1 addition & 1 deletion src/duration/DurationPickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const DurationPickerSheet = ({
...rest
}: DurationPickerSheetProps) => {
const theme = useDesignOverride(themeProp, design);
const [draft, setDraft] = useDraft(snapDuration(value ?? 0, rest), visible);
const [draft, setDraft] = useDraft(snapDuration(value ?? 0, rest), visible, { commit: onConfirm });
return (
<PickerSheet
{...{ visible, onClose, title, confirmLabel, cancelLabel, showCancel, presentation, sheetProps, theme }}
Expand Down
22 changes: 22 additions & 0 deletions src/month/MonthPickerField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,26 @@ describe('MonthPickerField', () => {
await fireEvent.press(screen.getByRole('button', { name: 'Done' }));
expect(onConfirm).toHaveBeenCalledWith(new Date(2026, 11, 1));
});

it('expands inline, commits each change without a Done button, and collapses on a second tap', async () => {
const onConfirm = jest.fn();
const onClose = jest.fn();
await render(
<MonthPickerField
label="Report"
locale="en-US"
value={new Date(2026, 9, 1)}
presentation="inline"
onConfirm={onConfirm}
onClose={onClose}
design="web"
/>
);
await fireEvent.press(screen.getByRole('button', { name: 'Report' }));
expect(screen.queryByRole('button', { name: 'Done' })).toBeNull();
await fireEvent.press(screen.getByText('Dec'));
expect(onConfirm).toHaveBeenCalledWith(new Date(2026, 11, 1));
await fireEvent.press(screen.getByRole('button', { name: 'Report' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
4 changes: 3 additions & 1 deletion src/month/MonthPickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export const MonthPickerSheet = ({
...rest
}: MonthPickerSheetProps) => {
const theme = useDesignOverride(themeProp, design);
const [draft, setDraft] = useDraft(startOfMonth(clampDate(value ?? new Date(), rest.minDate, rest.maxDate)), visible);
const [draft, setDraft] = useDraft(startOfMonth(clampDate(value ?? new Date(), rest.minDate, rest.maxDate)), visible, {
commit: onConfirm,
});
return (
<PickerSheet
{...{ visible, onClose, title, confirmLabel, cancelLabel, showCancel, presentation, sheetProps, theme }}
Expand Down
8 changes: 4 additions & 4 deletions src/select/SelectPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SelectTrigger } from './components/SelectTrigger';
import { useSelectPicker } from './hooks/useSelectPicker';
import { SelectList } from './SelectList';
import { DEFAULT_SEARCH_PLACEHOLDER, SELECT_DROPDOWN_MIN_WIDTH, SELECT_PRESENTATION } from './select.constants';
import { styles } from './select.styles';
import { LIST_STYLE } from './select.styles';
import type { SelectListBaseProps, SelectPickerProps } from './select.types';

export const SelectPicker = <T,>(props: SelectPickerProps<T>) => {
Expand Down Expand Up @@ -51,7 +51,7 @@ export const SelectPicker = <T,>(props: SelectPickerProps<T>) => {
isEqual={isEqual}
theme={themeOverride}
styles={slots}
style={picker.presentation === SELECT_PRESENTATION.dropdown ? styles.dropdownList : styles.sheetList}
style={LIST_STYLE[picker.presentation]}
/>
);

Expand All @@ -60,7 +60,7 @@ export const SelectPicker = <T,>(props: SelectPickerProps<T>) => {
{label ? <FieldLabel label={label} theme={theme} style={slots?.label} /> : null}
<View ref={picker.anchor.ref} collapsable={false}>
{renderTrigger ? (
renderTrigger({ open: picker.show, selected: picker.selected, disabled })
renderTrigger({ open: picker.toggle, selected: picker.selected, disabled })
) : (
<SelectTrigger
selected={picker.selected}
Expand All @@ -71,7 +71,7 @@ export const SelectPicker = <T,>(props: SelectPickerProps<T>) => {
open={picker.open}
disabled={disabled}
clearable={clearable}
onOpen={picker.show}
onOpen={picker.toggle}
onRemove={picker.removeOption}
onClear={picker.clear}
renderChevron={renderChevron}
Expand Down
5 changes: 4 additions & 1 deletion src/select/hooks/useSelectPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react';

import { usePickerPlatform } from '../../core/theme/ThemeProvider';
import { useAnchor } from '../../sheet/hooks/useAnchor';
import { resolveFieldPresentation } from '../../sheet/sheet.utils';
import { resolveFieldPresentation, shouldCollapseOnPress } from '../../sheet/sheet.utils';
import { NO_OPTIONS, SELECT_PRESENTATION } from '../select.constants';
import type { SelectOption, SelectPickerProps, SelectValueProps } from '../select.types';
import { findOptions, shouldSearchInField, toValueList } from '../select.utils';
Expand Down Expand Up @@ -38,6 +38,8 @@ export const useSelectPicker = <T>(props: SelectPickerProps<T>, isEqual: (a: T,
onClose?.();
}, [onClose]);

const toggle = shouldCollapseOnPress(presentation, open) ? hide : show;

const valueProps: SelectValueProps<T> = props.multiple
? {
multiple: true,
Expand Down Expand Up @@ -80,6 +82,7 @@ export const useSelectPicker = <T>(props: SelectPickerProps<T>, isEqual: (a: T,
anchor,
show,
hide,
toggle,
selected,
valueProps,
removeOption,
Expand Down
16 changes: 15 additions & 1 deletion src/select/select.styles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { StyleSheet } from 'react-native';
import { StyleSheet, type ViewStyle } from 'react-native';

import { DROPDOWN_MAX_HEIGHT } from '../sheet/sheet.constants';
import { SELECT_PRESENTATION } from './select.constants';
import type { SelectPresentation } from './select.types';

export const styles = StyleSheet.create({
flex: { flex: 1 },
Expand All @@ -17,4 +21,14 @@ export const styles = StyleSheet.create({
chip: { paddingHorizontal: 10, paddingVertical: 4 },
dropdownList: { flexShrink: 1 },
sheetList: { flexGrow: 1, flexShrink: 1 },
/** Inline, the list scrolls on its own instead of stretching the page. */
inlineList: { maxHeight: DROPDOWN_MAX_HEIGHT },
});

export const LIST_STYLE: Record<SelectPresentation, ViewStyle> = {
[SELECT_PRESENTATION.auto]: styles.sheetList,
[SELECT_PRESENTATION.bottomSheet]: styles.sheetList,
[SELECT_PRESENTATION.modal]: styles.sheetList,
[SELECT_PRESENTATION.dropdown]: styles.dropdownList,
[SELECT_PRESENTATION.inline]: styles.inlineList,
};
4 changes: 2 additions & 2 deletions src/sheet/PickerField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const PickerField = ({
{label ? <FieldLabel label={label} theme={theme} style={slots?.label} /> : null}
<View ref={field.anchor.ref} collapsable={false}>
{renderTrigger ? (
renderTrigger({ open: field.show, displayValue, disabled })
renderTrigger({ open: field.toggle, displayValue, disabled })
) : (
<FieldTrigger
displayValue={displayValue}
Expand All @@ -49,7 +49,7 @@ export const PickerField = ({
open={field.open}
disabled={disabled}
clearable={clearable}
onOpen={field.show}
onOpen={field.toggle}
onClear={onClear}
renderChevron={renderChevron}
theme={theme}
Expand Down
14 changes: 13 additions & 1 deletion src/sheet/PickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ScrollView, View } from 'react-native';
import { usePickerTheme } from '../core';
import { useDesignOverride } from '../core/theme/ThemeProvider';
import { BottomSheet } from './BottomSheet';
import { InlinePanel } from './components/InlinePanel';
import { SheetButton } from './components/SheetButton';
import { Dropdown } from './Dropdown';
import { useDropdownFit } from './hooks/useDropdownFit';
Expand All @@ -11,7 +12,10 @@ import { DEFAULT_CANCEL_LABEL, FIELD_PRESENTATION, PICKER_DROPDOWN_MIN_WIDTH, SH
import { styles } from './sheet.styles';
import type { PickerSheetProps } from './sheet.types';

/** BottomSheet with Cancel / Done buttons where the design puts them; inside a field set to `dropdown`, a dropdown under it with the buttons below. */
/**
* BottomSheet with Cancel / Done buttons where the design puts them; inside a field set to `dropdown`, a dropdown under it with the buttons
* below; inside one set to `inline`, the content alone, expanded under it.
*/
export const PickerSheet = ({
visible,
onClose,
Expand Down Expand Up @@ -48,6 +52,14 @@ export const PickerSheet = ({
// A sheet opened from inside this one isn't in the field.
const content = <FieldSheetContext.Provider value={null}>{children}</FieldSheetContext.Provider>;

if (presentation === FIELD_PRESENTATION.inline) {
return (
<InlinePanel visible={visible} theme={theme}>
{content}
</InlinePanel>
);
}

if (presentation === FIELD_PRESENTATION.dropdown) {
return (
<Dropdown
Expand Down
20 changes: 20 additions & 0 deletions src/sheet/components/InlinePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Animated, View } from 'react-native';

import { OwnScroll } from '../../core/components/OwnScroll';
import { useInlineExpand } from '../hooks/useInlineExpand';
import { styles } from '../sheet.styles';
import type { InlinePanelProps } from '../sheet.types';

/** The picker expanded under its field, pushing the content below down. The content lays out at full height and is clipped while the panel grows. */
export const InlinePanel = ({ visible, children, theme }: InlinePanelProps) => {
const expand = useInlineExpand(visible, theme.animation.duration);
if (!expand.mounted) return null;

return (
<Animated.View style={[styles.inline, expand.style]} pointerEvents={visible ? 'auto' : 'none'}>
<View style={[styles.inlineContent, { paddingTop: theme.spacing.sm }]} onLayout={expand.onLayout}>
<OwnScroll>{children}</OwnScroll>
</View>
</Animated.View>
);
};
31 changes: 28 additions & 3 deletions src/sheet/hooks/useDraft.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { createElement, type ReactNode } from 'react';

import { act, renderHook } from '@testing-library/react-native';

import { useDraft } from './useDraft';
import { FieldSheetContext } from './useFieldSheet';

describe('useDraft', () => {
it('keeps edits while open and resets to the value on reopen', async () => {
Expand All @@ -18,13 +21,35 @@ describe('useDraft', () => {
});

it('resets to the value when the reset key changes', async () => {
const { result, rerender } = await renderHook(({ value, mode }: { value: number; mode: string }) => useDraft(value, true, mode), {
initialProps: { value: 1, mode: 'single' },
});
const { result, rerender } = await renderHook(
({ value, mode }: { value: number; mode: string }) => useDraft(value, true, { resetKey: mode }),
{
initialProps: { value: 1, mode: 'single' },
}
);
await act(() => result.current[1](2));
await rerender({ value: 7, mode: 'single' });
expect(result.current[0]).toBe(2);
await rerender({ value: 7, mode: 'multiple' });
expect(result.current[0]).toBe(7);
});

it('commits each change only inside an inline field', async () => {
const commit = jest.fn();
const inline = ({ children }: { children: ReactNode }) =>
createElement(FieldSheetContext.Provider, {
value: {
presentation: 'inline',
anchor: null,
},
children,
});
const sheet = await renderHook(() => useDraft<number>(1, true, { commit }));
await act(() => sheet.result.current[1](2));
expect(commit).not.toHaveBeenCalled();
const field = await renderHook(() => useDraft<number>(1, true, { commit }), { wrapper: inline });
await act(() => field.result.current[1](3));
expect(field.result.current[0]).toBe(3);
expect(commit).toHaveBeenCalledWith(3);
});
});
Loading