From c6124c5f009d3f492dd2a7aac0b38ef4698b8dba Mon Sep 17 00:00:00 2001 From: eliyahu-kriel <44751346+elik56a@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:33:32 +0200 Subject: [PATCH] feat(sheet): an inline presentation that expands the picker under its field presentation="inline" on every field, SelectPicker and PhoneField opens the picker in place under the field, pushing the content below down, and folds it back up on a second tap. The height animates on every platform. There's no Cancel / Done: each change commits through useDraft's new `commit` option. useDraft's options move to an object: useDraft(value, visible, { resetKey, commit }). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- package.json | 1 + src/color/ColorPickerSheet.tsx | 2 +- src/core/core.types.ts | 2 + src/core/hooks/usePresence.ts | 8 ++-- src/date/DatePickerSheet.tsx | 11 +++++- src/datetime/DateTimePickerSheet.tsx | 5 ++- src/duration/DurationPickerSheet.tsx | 2 +- src/month/MonthPickerField.test.tsx | 22 +++++++++++ src/month/MonthPickerSheet.tsx | 4 +- src/select/SelectPicker.tsx | 8 ++-- src/select/hooks/useSelectPicker.ts | 5 ++- src/select/select.styles.ts | 16 +++++++- src/sheet/PickerField.tsx | 4 +- src/sheet/PickerSheet.tsx | 14 ++++++- src/sheet/components/InlinePanel.tsx | 20 ++++++++++ src/sheet/hooks/useDraft.test.ts | 31 +++++++++++++-- src/sheet/hooks/useDraft.ts | 24 +++++++++--- src/sheet/hooks/useInlineExpand.test.ts | 38 +++++++++++++++++++ src/sheet/hooks/useInlineExpand.ts | 25 ++++++++++++ src/sheet/hooks/usePickerField.test.ts | 12 ++++++ src/sheet/hooks/usePickerField.ts | 7 +++- src/sheet/sheet.constants.ts | 3 +- src/sheet/sheet.styles.ts | 2 + src/sheet/sheet.types.ts | 32 ++++++++++++++-- src/sheet/sheet.utils.test.ts | 10 +++++ src/sheet/sheet.utils.ts | 3 ++ src/time/TimePickerSheet.tsx | 5 ++- src/wheel/WheelPickerSheet.tsx | 2 +- website/docs/api/reference.mdx | 6 +-- website/docs/components/phone-field.mdx | 2 +- website/docs/components/picker-fields.mdx | 29 +++++++++++--- website/docs/components/picker-sheets.mdx | 12 ++++-- website/docs/components/select-picker.mdx | 5 ++- .../docs/getting-started/core-concepts.mdx | 2 +- website/docs/resources/ai.mdx | 6 +-- website/docusaurus.config.ts | 2 +- website/src/catalog/playgrounds.tsx | 2 +- website/src/home/home.constants.ts | 2 +- website/src/live/controls.test.ts | 2 +- website/src/live/live.constants.ts | 7 +++- 41 files changed, 337 insertions(+), 62 deletions(-) create mode 100644 src/sheet/components/InlinePanel.tsx create mode 100644 src/sheet/hooks/useInlineExpand.test.ts create mode 100644 src/sheet/hooks/useInlineExpand.ts diff --git a/README.md b/README.md index f6db064..f4a87f4 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Every picker comes three ways, with the same props. ``` -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. @@ -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`: 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: diff --git a/package.json b/package.json index bc1cf8e..a60369c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "range-picker", "select", "dropdown", + "inline-picker", "emoji-picker", "country-picker", "country-code-picker", diff --git a/src/color/ColorPickerSheet.tsx b/src/color/ColorPickerSheet.tsx index e9bf7b1..dfd3b6e 100644 --- a/src/color/ColorPickerSheet.tsx +++ b/src/color/ColorPickerSheet.tsx @@ -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 ( void; onHidden?: () => void; } diff --git a/src/core/hooks/usePresence.ts b/src/core/hooks/usePresence.ts index ae2e328..9d8e327 100644 --- a/src/core/hooks/usePresence.ts +++ b/src/core/hooks/usePresence.ts @@ -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); @@ -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, diff --git a/src/date/DatePickerSheet.tsx b/src/date/DatePickerSheet.tsx index 7911376..d3533c4 100644 --- a/src/date/DatePickerSheet.tsx +++ b/src/date/DatePickerSheet.tsx @@ -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(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(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; diff --git a/src/datetime/DateTimePickerSheet.tsx b/src/datetime/DateTimePickerSheet.tsx index 72d424a..63266b2 100644 --- a/src/datetime/DateTimePickerSheet.tsx +++ b/src/datetime/DateTimePickerSheet.tsx @@ -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 ( { - onConfirm(snapTime(draft, rest)); + commit(draft); onClose(); }} > diff --git a/src/duration/DurationPickerSheet.tsx b/src/duration/DurationPickerSheet.tsx index 0afb8c1..fae4c36 100644 --- a/src/duration/DurationPickerSheet.tsx +++ b/src/duration/DurationPickerSheet.tsx @@ -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 ( { 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( + + ); + 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); + }); }); diff --git a/src/month/MonthPickerSheet.tsx b/src/month/MonthPickerSheet.tsx index 6a465d4..5fe0e61 100644 --- a/src/month/MonthPickerSheet.tsx +++ b/src/month/MonthPickerSheet.tsx @@ -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 ( (props: SelectPickerProps) => { @@ -51,7 +51,7 @@ export const SelectPicker = (props: SelectPickerProps) => { isEqual={isEqual} theme={themeOverride} styles={slots} - style={picker.presentation === SELECT_PRESENTATION.dropdown ? styles.dropdownList : styles.sheetList} + style={LIST_STYLE[picker.presentation]} /> ); @@ -60,7 +60,7 @@ export const SelectPicker = (props: SelectPickerProps) => { {label ? : null} {renderTrigger ? ( - renderTrigger({ open: picker.show, selected: picker.selected, disabled }) + renderTrigger({ open: picker.toggle, selected: picker.selected, disabled }) ) : ( (props: SelectPickerProps) => { open={picker.open} disabled={disabled} clearable={clearable} - onOpen={picker.show} + onOpen={picker.toggle} onRemove={picker.removeOption} onClear={picker.clear} renderChevron={renderChevron} diff --git a/src/select/hooks/useSelectPicker.ts b/src/select/hooks/useSelectPicker.ts index 7406aef..8bfe96d 100644 --- a/src/select/hooks/useSelectPicker.ts +++ b/src/select/hooks/useSelectPicker.ts @@ -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'; @@ -38,6 +38,8 @@ export const useSelectPicker = (props: SelectPickerProps, isEqual: (a: T, onClose?.(); }, [onClose]); + const toggle = shouldCollapseOnPress(presentation, open) ? hide : show; + const valueProps: SelectValueProps = props.multiple ? { multiple: true, @@ -80,6 +82,7 @@ export const useSelectPicker = (props: SelectPickerProps, isEqual: (a: T, anchor, show, hide, + toggle, selected, valueProps, removeOption, diff --git a/src/select/select.styles.ts b/src/select/select.styles.ts index 8e44033..8ea0285 100644 --- a/src/select/select.styles.ts +++ b/src/select/select.styles.ts @@ -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 }, @@ -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 = { + [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, +}; diff --git a/src/sheet/PickerField.tsx b/src/sheet/PickerField.tsx index d0ac8f9..809a653 100644 --- a/src/sheet/PickerField.tsx +++ b/src/sheet/PickerField.tsx @@ -39,7 +39,7 @@ export const PickerField = ({ {label ? : null} {renderTrigger ? ( - renderTrigger({ open: field.show, displayValue, disabled }) + renderTrigger({ open: field.toggle, displayValue, disabled }) ) : ( {children}; + if (presentation === FIELD_PRESENTATION.inline) { + return ( + + {content} + + ); + } + if (presentation === FIELD_PRESENTATION.dropdown) { return ( { + const expand = useInlineExpand(visible, theme.animation.duration); + if (!expand.mounted) return null; + + return ( + + + {children} + + + ); +}; diff --git a/src/sheet/hooks/useDraft.test.ts b/src/sheet/hooks/useDraft.test.ts index 432d885..f22012d 100644 --- a/src/sheet/hooks/useDraft.test.ts +++ b/src/sheet/hooks/useDraft.test.ts @@ -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 () => { @@ -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(1, true, { commit })); + await act(() => sheet.result.current[1](2)); + expect(commit).not.toHaveBeenCalled(); + const field = await renderHook(() => useDraft(1, true, { commit }), { wrapper: inline }); + await act(() => field.result.current[1](3)); + expect(field.result.current[0]).toBe(3); + expect(commit).toHaveBeenCalledWith(3); + }); }); diff --git a/src/sheet/hooks/useDraft.ts b/src/sheet/hooks/useDraft.ts index 8b0b1fe..d5b777e 100644 --- a/src/sheet/hooks/useDraft.ts +++ b/src/sheet/hooks/useDraft.ts @@ -1,10 +1,24 @@ -import { useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; -/** Draft state that resets to `value` every time the sheet opens, and whenever `resetKey` changes (e.g. a new mode), open or not. */ -export const useDraft = (value: V, visible: boolean, resetKey?: unknown): [V, (v: V) => void] => { +import { FIELD_PRESENTATION } from '../sheet.constants'; +import type { DraftOptions } from '../sheet.types'; +import { useFieldSheet } from './useFieldSheet'; + +/** + * Draft state that resets to `value` every time the sheet opens, and whenever `resetKey` changes (e.g. a new mode), open or not. + * In an `inline` field there's no Done button, so each change also goes to `commit`. + */ +export const useDraft = (value: V, visible: boolean, { resetKey, commit }: DraftOptions = {}): [V, (v: V) => void] => { const [draft, setDraft] = useState(value); const [wasVisible, setWasVisible] = useState(visible); const [key, setKey] = useState(resetKey); + const inline = useFieldSheet()?.presentation === FIELD_PRESENTATION.inline; + const liveCommit = useRef(commit); + liveCommit.current = inline ? commit : undefined; + const update = useCallback((next: V) => { + setDraft(next); + liveCommit.current?.(next); + }, []); const opening = visible && !wasVisible; const rekeyed = resetKey !== key; if (visible !== wasVisible || rekeyed) { @@ -14,7 +28,7 @@ export const useDraft = (value: V, visible: boolean, resetKey?: unknown): [V, if (opening || rekeyed) { setDraft(value); // A field keeps its sheet mounted while closed, so the rest of this render must already see the reset draft. - return [value, setDraft]; + return [value, update]; } - return [draft, setDraft]; + return [draft, update]; }; diff --git a/src/sheet/hooks/useInlineExpand.test.ts b/src/sheet/hooks/useInlineExpand.test.ts new file mode 100644 index 0000000..6b13996 --- /dev/null +++ b/src/sheet/hooks/useInlineExpand.test.ts @@ -0,0 +1,38 @@ +import type { Animated, LayoutChangeEvent } from 'react-native'; + +import { act, renderHook } from '@testing-library/react-native'; + +import { useInlineExpand } from './useInlineExpand'; + +const layout = (height: number) => ({ nativeEvent: { layout: { x: 0, y: 0, width: 320, height } } }) as LayoutChangeEvent; +const heightOf = (height: Animated.AnimatedInterpolation) => (height as unknown as { __getValue: () => number }).__getValue(); + +describe('useInlineExpand', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('grows to the measured content height on open and unmounts once folded back up', async () => { + const { result, rerender } = await renderHook(({ visible }: { visible: boolean }) => useInlineExpand(visible, 200), { + initialProps: { visible: false }, + }); + expect(result.current.mounted).toBe(false); + await rerender({ visible: true }); + expect(result.current.mounted).toBe(true); + await act(() => result.current.onLayout(layout(180))); + await act(() => jest.advanceTimersByTime(500)); + expect(heightOf(result.current.style.height)).toBe(180); + await rerender({ visible: false }); + expect(result.current.mounted).toBe(true); + await act(() => jest.advanceTimersByTime(500)); + expect(heightOf(result.current.style.height)).toBe(0); + expect(result.current.mounted).toBe(false); + }); + + it('follows the content when it changes height while open', async () => { + const { result } = await renderHook(() => useInlineExpand(true, 200)); + await act(() => result.current.onLayout(layout(180))); + await act(() => jest.advanceTimersByTime(500)); + await act(() => result.current.onLayout(layout(240))); + expect(heightOf(result.current.style.height)).toBe(240); + }); +}); diff --git a/src/sheet/hooks/useInlineExpand.ts b/src/sheet/hooks/useInlineExpand.ts new file mode 100644 index 0000000..2a85479 --- /dev/null +++ b/src/sheet/hooks/useInlineExpand.ts @@ -0,0 +1,25 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; + +import { usePresence } from '../../core/hooks/usePresence'; +import type { InlineExpand } from '../sheet.types'; + +/** Grows down to the content's height on open and folds back up before unmounting on close; the height animates in JS, as the native driver can't animate layout. */ +export const useInlineExpand = (visible: boolean, duration: number): InlineExpand => { + const { mounted, progress } = usePresence({ visible, duration, native: false }); + const [height, setHeight] = useState(0); + const onLayout = useCallback((e: LayoutChangeEvent) => setHeight(e.nativeEvent.layout.height), []); + const style = useMemo( + () => ({ + height: progress.interpolate({ inputRange: [0, 1], outputRange: [0, height] }), + opacity: progress, + }), + [height, progress] + ); + + return { + mounted, + style, + onLayout, + }; +}; diff --git a/src/sheet/hooks/usePickerField.test.ts b/src/sheet/hooks/usePickerField.test.ts index d28c314..da7f42d 100644 --- a/src/sheet/hooks/usePickerField.test.ts +++ b/src/sheet/hooks/usePickerField.test.ts @@ -50,6 +50,18 @@ describe('usePickerField', () => { expect(result.current.sheet.presentation).toBe('dropdown'); }); + it('toggles an inline picker, and only opens the others', async () => { + const inline = await renderHook(() => usePickerField({ presentation: 'inline' })); + await act(() => inline.result.current.toggle()); + expect(inline.result.current.open).toBe(true); + await act(() => inline.result.current.toggle()); + expect(inline.result.current.open).toBe(false); + const sheet = await renderHook(() => usePickerField({ presentation: 'bottomSheet' })); + await act(() => sheet.result.current.toggle()); + await act(() => sheet.result.current.toggle()); + expect(sheet.result.current.open).toBe(true); + }); + it('stays closed while disabled', async () => { const onOpen = jest.fn(); const { result } = await renderHook(() => usePickerField({ disabled: true, onOpen })); diff --git a/src/sheet/hooks/usePickerField.ts b/src/sheet/hooks/usePickerField.ts index d0913e5..22bba27 100644 --- a/src/sheet/hooks/usePickerField.ts +++ b/src/sheet/hooks/usePickerField.ts @@ -3,10 +3,10 @@ import { useCallback, useMemo, useState } from 'react'; import { usePickerPlatform } from '../../core/theme/ThemeProvider'; import { FIELD_PRESENTATION } from '../sheet.constants'; import type { FieldPresentation, FieldSheetContextValue, PickerFieldBaseProps } from '../sheet.types'; -import { resolveFieldPresentation } from '../sheet.utils'; +import { resolveFieldPresentation, shouldCollapseOnPress } from '../sheet.utils'; import { useAnchor } from './useAnchor'; -/** Whether the field's picker is open, and where; opening does nothing while `disabled`. */ +/** Whether the field's picker is open, and where; opening does nothing while `disabled`. `toggle` is the field's tap. */ export const usePickerField = ({ disabled, presentation: presentationProp = FIELD_PRESENTATION.auto, @@ -30,6 +30,8 @@ export const usePickerField = ({ onClose?.(); }, [onClose]); + const toggle = shouldCollapseOnPress(presentation, open) ? hide : show; + const sheet = useMemo( () => ({ presentation, @@ -42,6 +44,7 @@ export const usePickerField = ({ open, show, hide, + toggle, anchor, sheet, }; diff --git a/src/sheet/sheet.constants.ts b/src/sheet/sheet.constants.ts index 5f6c2bf..c2335d4 100644 --- a/src/sheet/sheet.constants.ts +++ b/src/sheet/sheet.constants.ts @@ -10,10 +10,11 @@ export const SHEET_PRESENTATION = { modal: 'modal', } as const; -/** A field opens its picker in one of the sheet presentations, or in a dropdown under the field. */ +/** A field opens its picker in one of the sheet presentations, in a dropdown under the field, or inline: expanded under it. */ export const FIELD_PRESENTATION = { ...SHEET_PRESENTATION, dropdown: 'dropdown', + inline: 'inline', } as const; export const SHEET_SNAP = { diff --git a/src/sheet/sheet.styles.ts b/src/sheet/sheet.styles.ts index b6bf75e..f04dbdf 100644 --- a/src/sheet/sheet.styles.ts +++ b/src/sheet/sheet.styles.ts @@ -22,6 +22,8 @@ export const styles = StyleSheet.create({ dropdownOverlay: { position: 'absolute' }, dropdown: { position: 'absolute', borderWidth: StyleSheet.hairlineWidth, paddingVertical: DROPDOWN_PADDING, overflow: 'hidden' }, dropdownScroll: { flexGrow: 0, flexShrink: 1 }, + inline: { overflow: 'hidden' }, + inlineContent: { position: 'absolute', top: 0, left: 0, right: 0 }, actions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 24 }, dropdownFooter: { paddingTop: 8, diff --git a/src/sheet/sheet.types.ts b/src/sheet/sheet.types.ts index ed9a1bd..114f1d4 100644 --- a/src/sheet/sheet.types.ts +++ b/src/sheet/sheet.types.ts @@ -17,7 +17,7 @@ import type { /** `auto`: a centered modal on web windows wider than the sheet, a bottom sheet everywhere else. */ export type SheetPresentation = ValueOf; -/** A sheet presentation, or `dropdown`: a popover under the field. */ +/** A sheet presentation, `dropdown`: a popover under the field, or `inline`: expanded in place under the field. */ export type FieldPresentation = ValueOf; export interface FieldSheetContextValue { @@ -77,12 +77,21 @@ export interface SheetChromeProps extends ThemableProps { sheetProps?: Partial>; } -export interface PickerSheetProps extends SheetChromeProps { +export interface PickerSheetProps extends Omit { + /** Default: the field's presentation, else 'auto' */ + presentation?: FieldPresentation; onConfirm?: () => void; confirmDisabled?: boolean; children: React.ReactNode; } +export interface DraftOptions { + /** Resets the draft to `value` when it changes, open or not */ + resetKey?: unknown; + /** Called with each change in an `inline` field, which has no Done button */ + commit?: (v: V) => void; +} + export interface SheetContentProps { children: React.ReactNode; /** Narrower side padding, for content with its own inner spacing (e.g. a calendar). */ @@ -200,6 +209,19 @@ export interface AnchorRect { height: number; } +/** An inline picker's animated height and fade, and the `onLayout` that measures its content. */ +export interface InlineExpand { + mounted: boolean; + style: { height: Animated.AnimatedInterpolation; opacity: Animated.Value }; + onLayout: (e: LayoutChangeEvent) => void; +} + +export interface InlinePanelProps { + visible: boolean; + children: React.ReactNode; + theme: PickerTheme; +} + export interface DropdownFrame { left: number; width: number; @@ -239,6 +261,7 @@ export interface ChevronProps { export type PickerFieldSlot = 'label' | 'trigger' | 'triggerText' | 'placeholder' | 'chevron' | 'clear'; export interface PickerFieldTriggerInfo { + /** With the `inline` presentation, a second call collapses the picker */ open: () => void; displayValue: string; disabled: boolean; @@ -262,7 +285,10 @@ export interface PickerFieldBaseProps extends Themable renderChevron?: (info: ChevronInfo) => React.ReactNode; onOpen?: () => void; onClose?: () => void; - /** Where the picker opens: `dropdown` under the field, `bottomSheet`, `modal`, or `auto`: a dropdown on the web, a bottom sheet on native. Default: 'auto' */ + /** + * Where the picker opens: `dropdown` under the field, `bottomSheet`, `modal`, `inline`: expanded in place under the field, committing each + * change (no Cancel / Done) and collapsing on a second tap, or `auto`: a dropdown on the web, a bottom sheet on native. Default: 'auto' + */ presentation?: FieldPresentation; style?: StyleProp; styles?: SlotStyles; diff --git a/src/sheet/sheet.utils.test.ts b/src/sheet/sheet.utils.test.ts index 7b8aa97..dc1dcf5 100644 --- a/src/sheet/sheet.utils.test.ts +++ b/src/sheet/sheet.utils.test.ts @@ -11,6 +11,7 @@ import { resolveFieldPresentation, resolvePresentation, resolveSheetHeight, + shouldCollapseOnPress, shouldDismiss, splitFieldProps, } from './sheet.utils'; @@ -220,6 +221,15 @@ describe('resolveFieldPresentation', () => { }); }); +describe('shouldCollapseOnPress', () => { + it('collapses only an open inline field', () => { + expect(shouldCollapseOnPress('inline', true)).toBe(true); + expect(shouldCollapseOnPress('inline', false)).toBe(false); + expect(shouldCollapseOnPress('dropdown', true)).toBe(false); + expect(shouldCollapseOnPress('bottomSheet', true)).toBe(false); + }); +}); + describe('getSheetDefaults', () => { it('leaves room for the iOS home indicator and status bar, and drags by the header on native only', () => { expect(getSheetDefaults('ios')).toEqual({ diff --git a/src/sheet/sheet.utils.ts b/src/sheet/sheet.utils.ts index c459f8b..9795dd6 100644 --- a/src/sheet/sheet.utils.ts +++ b/src/sheet/sheet.utils.ts @@ -95,6 +95,9 @@ export const resolvePresentation = ( export const resolveFieldPresentation = (presentation: FieldPresentation, platform: PlatformOSType): FieldPresentation => presentation === FIELD_PRESENTATION.auto && platform === PLATFORM.web ? FIELD_PRESENTATION.dropdown : presentation; +/** A tap on an open inline field collapses it; any other tap opens the picker. */ +export const shouldCollapseOnPress = (presentation: FieldPresentation, open: boolean) => open && presentation === FIELD_PRESENTATION.inline; + /** Platform defaults for a bottom sheet. Mouse drags on the header would select its text on the web, so there only the handle drags. */ export const getSheetDefaults = (platform: PlatformOSType) => ({ bottomInset: platform === PLATFORM.ios ? IOS_BOTTOM_INSET : DEFAULT_BOTTOM_INSET, diff --git a/src/time/TimePickerSheet.tsx b/src/time/TimePickerSheet.tsx index 560b7b2..059e6fc 100644 --- a/src/time/TimePickerSheet.tsx +++ b/src/time/TimePickerSheet.tsx @@ -21,12 +21,13 @@ export const TimePickerSheet = ({ ...rest }: TimePickerSheetProps) => { 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 }); return ( { - onConfirm(snapTime(draft, rest)); + commit(draft); onClose(); }} > diff --git a/src/wheel/WheelPickerSheet.tsx b/src/wheel/WheelPickerSheet.tsx index 5697552..548e485 100644 --- a/src/wheel/WheelPickerSheet.tsx +++ b/src/wheel/WheelPickerSheet.tsx @@ -21,7 +21,7 @@ export const WheelPickerSheet = ({ ...rest }: WheelPickerSheetProps) => { const theme = useDesignOverride(themeProp, design); - const [draft, setDraft] = useDraft(value ?? (firstItemValue(rest.items) as T), visible); + const [draft, setDraft] = useDraft(value ?? (firstItemValue(rest.items) as T), visible, { commit: onConfirm }); return ( `](../components/picker-sheets#wheelpickersheet) | WheelPicker in a sheet | | [`EmojiPickerSheet`](../components/picker-sheets#emojipickersheet) | EmojiPicker in a sheet | | [`ColorPickerSheet`](../components/color-picker#in-a-sheet) | ColorPicker in a sheet | -| [`useDraft(value, visible)`](../components/picker-sheets#usedraft) | Draft state that resets on open | +| [`useDraft(value, visible, { resetKey?, commit? })`](../components/picker-sheets#usedraft) | Draft state that resets on open; commits each change inline | ## Fields @@ -129,8 +129,8 @@ type SkinTone = 0 | 1 | 2 | 3 | 4 | 5; /** Every sheet's `presentation`. */ type SheetPresentation = 'auto' | 'bottomSheet' | 'modal'; -/** A field's `presentation`: a sheet presentation, or a dropdown under the field. Its `auto` (the default) is a dropdown on the web. */ -type FieldPresentation = SheetPresentation | 'dropdown'; +/** A field's `presentation`: a sheet presentation, a dropdown under the field, or inline: expanded in place under it. Its `auto` (the default) is a dropdown on the web. */ +type FieldPresentation = SheetPresentation | 'dropdown' | 'inline'; /** SelectPicker's `presentation`; its `auto` is a dropdown on the web. */ type SelectPresentation = FieldPresentation; diff --git a/website/docs/components/phone-field.mdx b/website/docs/components/phone-field.mdx index da6e630..2d72636 100644 --- a/website/docs/components/phone-field.mdx +++ b/website/docs/components/phone-field.mdx @@ -106,7 +106,7 @@ const shown = country ? formatNationalNumber(nationalNumber, country) : user.pho | `countries` / `exclude` / `preferred` | `CountryCode[]` | — | Which countries the list offers, and which come first. See [CountryPicker](./country-picker#a-subset-of-countries). | | `preferredLabel` / `allCountriesLabel` | `string` | `'Suggested'` / `'All countries'` | Headers of the list. | | `locale` | `string` | device locale | Language of the country names. | -| `presentation` | `'auto' \| 'dropdown' \| 'bottomSheet' \| 'modal'` | `'auto'` | Where the country list opens: a dropdown on the web, a bottom sheet on native. | +| `presentation` | `'auto' \| 'dropdown' \| 'bottomSheet' \| 'modal' \| 'inline'` | `'auto'` | Where the country list opens: a dropdown on the web, a bottom sheet on native. | | `countrySheetTitle` | `string` | `'Country code'` | Title of the country sheet. | | `sheet` | `SheetChromeProps` | `sheetProps.height: 0.8` | Options for the country sheet, as [`CountryPicker`](./country-picker#props)'s. | | `theme` / `design` | | | See [Theming](../guides/theming) and [Designs](../guides/designs). | diff --git a/website/docs/components/picker-fields.mdx b/website/docs/components/picker-fields.mdx index 1b6eeab..2f68918 100644 --- a/website/docs/components/picker-fields.mdx +++ b/website/docs/components/picker-fields.mdx @@ -1,6 +1,6 @@ --- title: React Native Date, Time & Wheel Picker Fields -description: "Form fields for React Native that open a picker in a dropdown, bottom sheet or modal and show the picked value: DatePickerField, TimePickerField, DateTimePickerField, DurationPickerField, MonthPickerField, ColorPickerField, WheelPickerField, EmojiPickerField and PickerField." +description: "Form fields for React Native that open a picker in a dropdown, bottom sheet or modal, or expand it inline under the field, and show the picked value: DatePickerField, TimePickerField, DateTimePickerField, DurationPickerField, MonthPickerField, ColorPickerField, WheelPickerField, EmojiPickerField and PickerField." --- import PropPlayground from '@site/src/components/PropPlayground'; @@ -105,13 +105,13 @@ export default function Example() { } ``` -Each field takes the props of the sheet it opens, without `visible`, plus the [field props](#props). The field owns the open state; you keep the value. As with the sheets, `onConfirm` fires only on **Done**, so your state changes only when the user confirms. On the web they open as dropdowns; switch `presentation` in the panel to see the same form with bottom sheets or modals. +Each field takes the props of the sheet it opens, without `visible`, plus the [field props](#props). The field owns the open state; you keep the value. As with the sheets, `onConfirm` fires only on **Done**, so your state changes only when the user confirms (except `inline`, which has no **Done** and commits each change). On the web they open as dropdowns; switch `presentation` in the panel to see the same form with bottom sheets or modals. ## Try the props -## Dropdown, bottom sheet or modal +## Dropdown, bottom sheet, modal or inline `presentation` sets where the picker opens: @@ -121,13 +121,32 @@ Each field takes the props of the sheet it opens, without `visible`, plus the [f | `'dropdown'` | Under the field, like a menu, or above it when there's more room there. **Cancel** and **Done** sit below the picker; a tap outside cancels. It's at least 320 px wide, so a calendar fits under a narrow field. It opens on the side with room for the whole picker; in a window too short for it, the dropdown scrolls. | | `'bottomSheet'` | A bottom sheet with the title and buttons in its header. | | `'modal'` | A centered dialog. | +| `'inline'` | In place, under the field: the picker expands down, pushing the content below it, and folds back up on a second tap on the field. There's no **Cancel** or **Done**: each change goes to `onConfirm` (or `onSelect`) right away, like the iOS Settings and Clock apps. A date range commits once both ends are picked. | ```tsx + ``` -`sheetProps` and `title` apply to the bottom sheet and the modal; the dropdown has no header. [`SelectPicker`](./select-picker#presentation) takes the same values and the same `'auto'` default. +```tsx live +import { useState } from 'react'; +import { View } from 'react-native'; +import { DurationPickerField, TimePickerField } from '@elik56a/react-native-pickers'; + +export default function Example() { + const [alarm, setAlarm] = useState(new Date(2026, 0, 1, 7, 30)); + const [snooze, setSnooze] = useState(9 * 60); + return ( + + + + + ); +} +``` + +`sheetProps` and `title` apply to the bottom sheet and the modal; the dropdown and the inline picker have no header. With `renderTrigger`, `open()` toggles an inline picker, so your trigger collapses it too. [`SelectPicker`](./select-picker#presentation) takes the same values and the same `'auto'` default. ## What the field shows @@ -322,7 +341,7 @@ Every field takes these, plus its sheet's props without `visible`: `value`, `onC | `onClear` | `() => void` | — | Called by ×. Reset your value here. | | `renderTrigger` | `(info: { open, displayValue, disabled }) => ReactNode` | — | Replace the field box. | | `renderChevron` | `(info: { open, theme }) => ReactNode` | — | Custom chevron icon. It still rotates on open and close. | -| `presentation` | `'auto' \| 'dropdown' \| 'bottomSheet' \| 'modal'` | `'auto'` | Where the picker opens: a dropdown on the web, a bottom sheet on native. See [Dropdown, bottom sheet or modal](#dropdown-bottom-sheet-or-modal). | +| `presentation` | `'auto' \| 'dropdown' \| 'bottomSheet' \| 'modal' \| 'inline'` | `'auto'` | Where the picker opens: a dropdown on the web, a bottom sheet on native. See [Dropdown, bottom sheet, modal or inline](#dropdown-bottom-sheet-modal-or-inline). | | `onOpen` / `onClose` | `() => void` | — | The picker opened / closed (Done, Cancel or dismiss). | | `style` | `StyleProp` | — | Wrapper style (label + field). | | `styles` | `SlotStyles` | — | Field slots plus the picker's slots. | diff --git a/website/docs/components/picker-sheets.mdx b/website/docs/components/picker-sheets.mdx index 74fe49c..2b02743 100644 --- a/website/docs/components/picker-sheets.mdx +++ b/website/docs/components/picker-sheets.mdx @@ -30,7 +30,7 @@ The sheets take `presentation`: `'auto'` (default), `'bottomSheet'` or `'modal'` ``` -[`SelectPicker`](./select-picker#presentation) also supports `'dropdown'`, which opens under the field. It's `SelectPicker`'s default on the web. +[`SelectPicker`](./select-picker#presentation) and the [fields](./picker-fields#dropdown-bottom-sheet-modal-or-inline) also support `'dropdown'`, which opens under the field, and `'inline'`, which expands under it. It's `SelectPicker`'s default on the web. ```tsx import { DatePickerSheet, TimePickerSheet, WheelPickerSheet, EmojiPickerSheet, PickerSheet, useDraft } from '@elik56a/react-native-pickers'; @@ -415,7 +415,13 @@ export default function Example() { ## `useDraft` ```ts -function useDraft(value: V, visible: boolean): [draft: V, setDraft: (v: V) => void]; +function useDraft( + value: V, + visible: boolean, + options?: { resetKey?: unknown; commit?: (v: V) => void } +): [draft: V, setDraft: (v: V) => void]; ``` -Returns a draft copy of `value` that **resets to `value` each time `visible` turns true**. It's the hook the built-in sheets use; use it for your own `PickerSheet`s as shown above. +Returns a draft copy of `value` that **resets to `value` each time `visible` turns true** (and whenever `resetKey` changes). It's the hook the built-in sheets use; use it for your own `PickerSheet`s as shown above. + +Inside a [`PickerField`](./picker-fields#dropdown-bottom-sheet-modal-or-inline) set to `presentation="inline"` there's no **Done** button, so `setDraft` also calls `commit` with each change. Pass your save function there to support inline: `useDraft(prefs, visible, { commit: onSave })`. diff --git a/website/docs/components/select-picker.mdx b/website/docs/components/select-picker.mdx index ca05fca..c70bbbc 100644 --- a/website/docs/components/select-picker.mdx +++ b/website/docs/components/select-picker.mdx @@ -1,6 +1,6 @@ --- title: React Native Select & Dropdown Picker -description: "React Native select and dropdown picker: a searchable bottom sheet, modal or dropdown. Single or multi-select with chips, groups, icons, a clear button and server search (autocomplete)." +description: "React Native select and dropdown picker: a searchable bottom sheet, modal, dropdown or inline list. Single or multi-select with chips, groups, icons, a clear button and server search (autocomplete)." --- import PropPlayground from '@site/src/components/PropPlayground'; @@ -499,6 +499,7 @@ export default function Example() { - `'bottomSheet'`: always a bottom sheet. - `'modal'`: always a centered modal. - `'dropdown'`: a popover under the field, as wide as the field. It flips above the field when there's more room there, and a tap outside closes it. +- `'inline'`: the list expands under the field, pushing the content below it, and folds back up on a second tap or once an option is picked (with `closeOnSelect`). It's at most 320 px tall and scrolls past that. ```tsx live import { useState } from 'react'; @@ -625,7 +626,7 @@ export default function Example() { | Prop | Type | Default | Description | | --- | --- | --- | --- | -| `presentation` | `'auto' \| 'bottomSheet' \| 'modal' \| 'dropdown'` | `'auto'` | How the options open. `auto`: dropdown on the web, bottom sheet on native. | +| `presentation` | `'auto' \| 'bottomSheet' \| 'modal' \| 'dropdown' \| 'inline'` | `'auto'` | How the options open. `auto`: dropdown on the web, bottom sheet on native. | | `sheetTitle` | `string` | `label` | Sheet header title. | | `closeOnSelect` | `boolean` | `true` | Single: close on tap. `false` shows a Done button. | | `sheet` | `Partial` | — | `confirmLabel`, `cancelLabel`, `showCancel`, `title`, `sheetProps`. | diff --git a/website/docs/getting-started/core-concepts.mdx b/website/docs/getting-started/core-concepts.mdx index 4b3e18b..b15476f 100644 --- a/website/docs/getting-started/core-concepts.mdx +++ b/website/docs/getting-started/core-concepts.mdx @@ -72,7 +72,7 @@ Wheel-based pickers (`WheelPicker`, `MultiWheelPicker` columns, `TimePicker`, `D ## Fields -In a form, the picker hides behind a field. `SelectPicker` is one, and every sheet has a field version that takes the same props without `visible` / `onClose`: [`DatePickerField`, `TimePickerField`, `DateTimePickerField`, `WheelPickerField`, `EmojiPickerField`](../components/picker-fields). The field owns the open state, shows the confirmed value formatted for `locale`, and looks the same as `SelectPicker`'s field. By default it opens a bottom sheet on iOS and Android and a dropdown under the field on the web; `presentation` picks `'bottomSheet'`, `'modal'` or `'dropdown'`. +In a form, the picker hides behind a field. `SelectPicker` is one, and every sheet has a field version that takes the same props without `visible` / `onClose`: [`DatePickerField`, `TimePickerField`, `DateTimePickerField`, `WheelPickerField`, `EmojiPickerField`](../components/picker-fields). The field owns the open state, shows the confirmed value formatted for `locale`, and looks the same as `SelectPicker`'s field. By default it opens a bottom sheet on iOS and Android and a dropdown under the field on the web; `presentation` picks `'bottomSheet'`, `'modal'`, `'dropdown'` or `'inline'` (expanded in place under the field). ```tsx diff --git a/website/docs/resources/ai.mdx b/website/docs/resources/ai.mdx index 62b32a7..8fb0c6f 100644 --- a/website/docs/resources/ai.mdx +++ b/website/docs/resources/ai.mdx @@ -60,7 +60,7 @@ WeekStrip one week of days in a row: value: Date|null onChange(date) SelectPicker options: {label,value,description?,icon?,group?,disabled?,keywords?}[] value onChange(value, option) multiple → value T[], onChange(values, options), maxSelected, chips label placeholder searchable(dropdown: type in field, default on; sheet: >8 auto) clearable+onClear (single: reset value in onClear) renderTrigger({open,selected}) renderChevron({open,theme}) - presentation 'auto'(dropdown on web, bottom sheet on native)|'bottomSheet'|'modal'|'dropdown' + presentation 'auto'(dropdown on web, bottom sheet on native)|'bottomSheet'|'modal'|'dropdown'|'inline'(expands under the field) SelectList same list inline: options value onChange multiple listHeight server search: loadOptions(query) => Promise (debounced by searchDebounce=300ms, loader until results, stale answers ignored; options = shown before typing; searchPrompt='Type to search' when none, emptyText after an empty search) or manual: onSearchChange(query) + loading + onEndReached; loadingText renderLoading(theme); no local filtering with either @@ -93,13 +93,13 @@ Sheets (draft; commit on Done): DateTimePickerSheet visible onClose value onConfirm + DateTimePicker props DurationPickerSheet visible onClose value onConfirm(seconds) + DurationPicker props MonthPickerSheet visible onClose value onConfirm(month) + MonthPicker props - PickerSheet visible onClose onConfirm children + useDraft(value, visible) + PickerSheet visible onClose onConfirm children + useDraft(value, visible, { resetKey?, commit? }) (commit: each change in an inline field) Fields (a SelectPicker-style field that opens the sheet and shows the confirmed value; field owns open state): DatePickerField / TimePickerField / DateTimePickerField / DurationPickerField / MonthPickerField / ColorPickerField / WheelPickerField / EmojiPickerField = the matching *Sheet props WITHOUT visible (value, onConfirm | onSelect, mode, locale, title...) + label placeholder='Select…' displayValue(default: value formatted for locale) clearable+onClear(reset value there) disabled renderTrigger({open,displayValue,disabled}) renderChevron({open,theme}) onOpen onClose style testID - presentation 'auto'(dropdown on web, bottom sheet on native)|'bottomSheet'|'modal'|'dropdown'; design is passed on to the sheet + presentation 'auto'(dropdown on web, bottom sheet on native)|'bottomSheet'|'modal'|'dropdown'|'inline'(expands under the field, no Done: each change fires onConfirm, second tap collapses); design is passed on to the sheet styles = field slots (label trigger triggerText placeholder chevron clear) + the picker's slots EmojiPickerField also takes value: string (the emoji shown). Sheet title defaults to label. PickerField same field props, children: ({visible, onClose}) => diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 914c846..bec51da 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -70,7 +70,7 @@ const structuredData = { version, author: { '@type': 'Person', name: AUTHOR, url: 'https://github.com/elik56a', sameAs: [LINKEDIN] }, keywords: - 'react native date picker, time picker, duration picker, month picker, week strip, date time picker, wheel picker, calendar, date range picker, select, dropdown, multi-select, autocomplete, segmented control, country picker, phone code picker, phone number input, time zone picker, emoji picker, color picker, bottom sheet, material design, ios picker, expo, react native web, keyboard accessible', + 'react native date picker, time picker, duration picker, month picker, week strip, date time picker, wheel picker, calendar, date range picker, select, dropdown, inline picker, multi-select, autocomplete, segmented control, country picker, phone code picker, phone number input, time zone picker, emoji picker, color picker, bottom sheet, material design, ios picker, expo, react native web, keyboard accessible', }, ], }; diff --git a/website/src/catalog/playgrounds.tsx b/website/src/catalog/playgrounds.tsx index 783da4d..e3f8fe0 100644 --- a/website/src/catalog/playgrounds.tsx +++ b/website/src/catalog/playgrounds.tsx @@ -32,7 +32,7 @@ import { import { styles } from './catalog.styles'; import type { PlaygroundConfig, PlaygroundKey, PlaygroundSelection } from './catalog.types'; -const PRESENTATION = { prop: 'presentation', options: ['auto', 'dropdown', 'bottomSheet', 'modal'] } as const; +const PRESENTATION = { prop: 'presentation', options: ['auto', 'dropdown', 'bottomSheet', 'modal', 'inline'] } as const; const CLEARABLE = { prop: 'clearable', options: [false, true] } as const; const toOptions = (labels: string[]) => diff --git a/website/src/home/home.constants.ts b/website/src/home/home.constants.ts index 194bb20..38f878c 100644 --- a/website/src/home/home.constants.ts +++ b/website/src/home/home.constants.ts @@ -70,7 +70,7 @@ export const COMPONENTS = [ { doc: 'components/color-picker', name: 'ColorPicker', body: "iOS's grid, Google's circles, a web spectrum" }, { doc: 'components/multi-wheel-picker', name: 'MultiWheelPicker', body: 'Units, compound values, dependent columns' }, { doc: 'components/picker-sheets', name: 'Sheets', body: 'Every picker in a bottom sheet or modal' }, - { doc: 'components/picker-fields', name: 'Fields', body: 'Tap a field, pick in a sheet or dropdown' }, + { doc: 'components/picker-fields', name: 'Fields', body: 'Tap a field, pick in a sheet, a dropdown or inline' }, ]; export const HERO_CHIPS = [ diff --git a/website/src/live/controls.test.ts b/website/src/live/controls.test.ts index 07a0454..c3d8146 100644 --- a/website/src/live/controls.test.ts +++ b/website/src/live/controls.test.ts @@ -20,7 +20,7 @@ describe('findControls', () => { const props = findControls(fields).map((c) => c.prop); expect(props).toEqual(['presentation', 'clearable', 'disabled']); expect(control(fields, 'presentation')).toMatchObject({ - options: ['auto', 'dropdown', 'bottomSheet', 'modal'], + options: ['auto', 'dropdown', 'bottomSheet', 'modal', 'inline'], components: ['DatePickerField', 'SelectPicker'], }); }); diff --git a/website/src/live/live.constants.ts b/website/src/live/live.constants.ts index cf60950..6e27fd2 100644 --- a/website/src/live/live.constants.ts +++ b/website/src/live/live.constants.ts @@ -40,7 +40,12 @@ const SELECT = ['SelectPicker', 'CountryPicker']; * A prop listed twice (e.g. `presentation`) becomes one control; the first entry an example matches sets its options. */ export const LIVE_CONTROLS: readonly LiveControl[] = [ - { prop: 'presentation', components: [...FIELDS, ...SELECT], options: ['auto', 'dropdown', 'bottomSheet', 'modal'], default: 'auto' }, + { + prop: 'presentation', + components: [...FIELDS, ...SELECT], + options: ['auto', 'dropdown', 'bottomSheet', 'modal', 'inline'], + default: 'auto', + }, { prop: 'presentation', components: SHEETS, options: ['auto', 'bottomSheet', 'modal'], default: 'auto' }, { prop: 'display', components: DATE_TIME, options: ['wheel', 'calendar'], default: 'wheel' }, { prop: 'format', components: [...TIME, ...DATE_TIME], options: ['auto', '12h', '24h'], default: 'auto' },