diff --git a/.gitignore b/.gitignore index b14870f..1bfe250 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,7 @@ android/generated # React Native Nitro Modules nitrogen/ + +# CodeQL local databases (generated by the CodeQL CLI; not source) +codeql-db-js/ +codeql-db-*/ diff --git a/README.md b/README.md index 576ea33..9536a30 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ iOS preview coming soon — see the `ios-build` CI job for validation status. - Custom text colors and sizes - TypeScript API - Flexible `DateDrumPicker` wrapper (day / month / year columns) +- Flexible `TimeDrumPicker` wrapper (hour / minute / second / AM·PM columns, 12h or 24h, minute & second intervals) - Fabric View / New Architecture ## Installation @@ -54,7 +55,7 @@ npx react-native run-android |----------|--------| | Android | Supported | | iOS | Supported | -| Web | Not supported | +| Web | Fallback (`` element instead of throwing at module +load. This means: + +- The library is **SSR-safe** — `import { DrumPicker } from 'react-native-drum-picker'` + in a server-rendered React app no longer crashes the bundle. +- The web rendering is **keyboard-navigable and screen-reader-friendly by + default** (browser-provided semantics). +- The `onChange` contract matches native — callers read + `event.nativeEvent.index` and `event.nativeEvent.value` the same way + cross-platform, so app code doesn't need a `Platform.OS` branch. + +A full drum-style scroll wheel on web is a future enhancement; the +current fallback prioritizes correctness and accessibility. + ### React Native 0.81+ event dispatch If Android Kotlin compile fails with `No value passed for parameter 'uiManagerType'`, upgrade to **0.1.3+** (Fabric `UIManagerType.FABRIC`). @@ -157,6 +175,46 @@ const [index, setIndex] = useState(1); /> ``` +### Labeled items: display one thing, receive another + +When you want to render human-readable text but receive a typed identifier +(an enum value, database id, country code, etc.) on selection, pass +`{ label, value }` items instead of strings: + +```tsx +type CountryCode = 'us' | 'de' | 'jp'; + +const COUNTRIES: Array<{ label: string; value: CountryCode }> = [ + { label: 'United States', value: 'us' }, + { label: 'Germany', value: 'de' }, + { label: 'Japan', value: 'jp' }, +]; + + + items={COUNTRIES} + onChange={(event) => { + // event.nativeEvent.value === 'United States' (the label that was shown) + // event.nativeEvent.item === 'us' (the typed value, fully inferred) + setCountry(event.nativeEvent.item); + }} +/> +``` + +Plain string items keep working exactly as before — for them, `item` simply +equals `value`, so `event.nativeEvent.item` is always safe to read. + +`value` can be any type — primitives, ids, or full objects: + +```tsx + console.log(e.nativeEvent.item.iso)} +/> +``` + ### `onChange` and expensive side effects Native emits `onChange` when the wheel **snaps to idle** and the **centered index changes** (duplicate indices are ignored). Use it for UI state. For AsyncStorage, APIs, or analytics, **debounce** in your app: @@ -220,7 +278,7 @@ Day count follows month/year (e.g. February has 28/29 days). | Prop | Type | Default | Description | |------|------|---------|-------------| -| `items` | `string[]` | required | Wheel labels | +| `items` | `Array` | required | Wheel rows. Strings are used as both label and value; labeled items render `label` and report `value` back on `onChange` | | `selectedIndex` | `number` | `0` | Selected row index | | `itemHeight` | `number` | `44` | Row height (dp) | | `visibleItemCount` | `number` | `5` | Visible rows (odd recommended) | @@ -293,6 +351,104 @@ type DateDrumPickerMode = | 'year-month-day'; ``` +## TimeDrumPicker + +A composed wrapper for picking a time of day. It is built on top of `DrumPicker`, so it +inherits the same native rendering, theming, and haptics — no extra native dependencies. + +```tsx +import { TimeDrumPicker } from 'react-native-drum-picker'; + +function Example() { + const [time, setTime] = useState({ hour: 9, minute: 30 }); + + return ( + + ); +} +``` + +Hour values in `value` / `onChange` are **always 24-hour** (`0..23`), regardless of +display mode. The component handles the 12h ↔ 24h conversion internally so callers +do not have to track AM/PM state separately. + +### `TimeDrumPicker` props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `mode` | `TimeDrumPickerMode` | `'hour-minute'` | Which columns to show | +| `value` | `{ hour?: 0..23; minute?: 0..59; second?: 0..59 }` | now | Controlled value (24h) | +| `onChange` | `(value) => void` | – | Called with the full clamped `{ hour, minute, second }` | +| `hourFormat` | `'12' \| '24'` | inferred from `mode` | Force a specific hour column format | +| `minuteInterval` | `1 \| 2 \| 3 \| 4 \| 5 \| 6 \| 10 \| 12 \| 15 \| 20 \| 30` | `1` | Step between minute items (mirrors `UIDatePicker.minuteInterval`) | +| `secondInterval` | same as `minuteInterval` | `1` | Step between second items | +| `padWithZero` | `boolean` | `true` | Pad single-digit values with `0` | +| `amLabel` / `pmLabel` | `string` | `'AM'` / `'PM'` | Localized period labels | +| `columnTestIDs` | `Partial>` | – | Per-column `testID`s | + +All shared visual props from `DrumPicker` (`itemHeight`, `visibleItemCount`, +`textColor`, `selectedTextColor`, `textSize`, `selectedTextSize`, +`showSelectionIndicator`, `selectionIndicatorColor`, `selectionIndicatorHeight`, +`backgroundColor`, `itemBackgroundColor`, `containerBackgroundColor`, +`hapticFeedback`) are forwarded to every column. + +### `TimeDrumPicker` modes + +| Mode | Columns | +|------|---------| +| `hour` | hour | +| `minute` | minute | +| `hour-minute` | hour, minute | +| `hour-minute-second` | hour, minute, second | +| `hour-minute-period` | hour (12h), minute, AM/PM | +| `hour-minute-second-period` | hour (12h), minute, second, AM/PM | + +```ts +type TimeDrumPickerMode = + | 'hour' + | 'minute' + | 'hour-minute' + | 'hour-minute-second' + | 'hour-minute-period' + | 'hour-minute-second-period'; +``` + +### Behavior notes + +- **Controlled value clamping.** If you pass a value outside the supported range + (e.g. `minute: 53` with `minuteInterval={15}`), the component clamps to the + nearest valid value and calls `onChange` once so your state stays in sync with + what the picker actually shows. This mirrors `DateDrumPicker`'s clamp-and-notify + contract for invalid February dates. +- **Uncontrolled mode.** Omit `value` and the component manages its own state; it + initializes from `new Date()`. +- **12h ↔ 24h.** `value` and `onChange` are always 24-hour. Flipping AM/PM keeps + the displayed 12h hour and shifts the 24h hour by ±12. + +## Accessibility + +`DrumPicker` accepts an `accessibilityLabel` prop. It is forwarded to the +native view's `accessibilityLabel` and, on web, to the `` element so that: + * + * - SSR / `react-native-web` builds don't crash at module load + * - The picker is keyboard-navigable and screen-reader-friendly by default + * - The same `value` and `onChange` contract works cross-platform — callers + * read `event.nativeEvent.index`, `value`, and `item` the same way + * + * Labeled `{ label, value }` items are supported here too: the wheel renders + * `label`, and `onChange` reports the resolved `value` on `nativeEvent.item`. + * + * A drum-style scroll wheel on web is a separate, larger feature; this + * fallback gives the lib a useful baseline web experience today. + */ +export function DrumPicker({ + items, + selectedIndex = DEFAULTS.selectedIndex, + itemHeight = DEFAULTS.itemHeight, + visibleItemCount = DEFAULTS.visibleItemCount, + textColor = DEFAULTS.textColor, + selectedTextColor = DEFAULTS.selectedTextColor, + textSize = DEFAULTS.textSize, + backgroundColor = DEFAULTS.backgroundColor, + accessibilityLabel = DEFAULTS.accessibilityLabel, + onChange, + style, + testID, +}: DrumPickerProps) { + const safeIndex = Math.min( + Math.max(selectedIndex, 0), + Math.max(items.length - 1, 0) + ); + + // Mirror native's "don't re-emit identical index" contract so React state + // bouncing back into the controlled value doesn't loop. + const lastEmittedIndexRef = useRef(safeIndex); + useEffect(() => { + lastEmittedIndexRef.current = safeIndex; + }, [safeIndex]); + + const flatStyle = useMemo( + () => + (StyleSheet.flatten(style as StyleProp) ?? {}) as ViewStyle, + [style] + ); + + const inlineStyle = useMemo(() => { + const heightFromStyle = + typeof flatStyle.height === 'number' ? flatStyle.height : undefined; + const widthFromStyle = + typeof flatStyle.width === 'number' ? flatStyle.width : undefined; + return { + width: widthFromStyle ?? '100%', + height: heightFromStyle ?? itemHeight * Math.max(visibleItemCount, 1), + color: selectedTextColor, + background: backgroundColor, + fontSize: textSize, + border: 'none', + outline: 'none', + WebkitAppearance: 'none', + MozAppearance: 'none', + appearance: 'none', + textAlign: 'center', + textAlignLast: 'center', + }; + }, [ + backgroundColor, + flatStyle.height, + flatStyle.width, + itemHeight, + selectedTextColor, + textSize, + visibleItemCount, + ]); + + const optionStyle = useMemo( + () => ({ + color: textColor, + background: backgroundColor, + }), + [backgroundColor, textColor] + ); + + const handleChange = useCallback( + (event: WebSelectChangeEvent) => { + const index = event.target.selectedIndex; + if (index === lastEmittedIndexRef.current) { + return; + } + lastEmittedIndexRef.current = index; + if (!onChange) { + return; + } + const source = items[index]; + const label = + source !== undefined ? getItemLabel(source) : ''; + const item: T = + source !== undefined + ? getItemValue(source) + : (label as unknown as T); + // Synthesize a payload shaped like the native event so calling code + // does not need to branch on platform. + const synthetic = { + nativeEvent: { index, value: label, item }, + target: event.target, + currentTarget: event.currentTarget, + bubbles: event.bubbles, + cancelable: event.cancelable, + defaultPrevented: event.defaultPrevented, + eventPhase: event.eventPhase, + isTrusted: event.isTrusted, + timeStamp: event.timeStamp, + type: 'change', + preventDefault: () => event.preventDefault(), + stopPropagation: () => event.stopPropagation(), + isDefaultPrevented: () => event.defaultPrevented, + isPropagationStopped: () => false, + persist: () => {}, + }; + onChange( + synthetic as unknown as Parameters>[0] + ); + }, + [items, onChange] + ); + + // Use createElement so we don't depend on react-native-web's createElement + // override and we keep this file framework-light. + return createElement( + 'select', + { + 'value': safeIndex, + 'onChange': handleChange, + 'style': inlineStyle, + 'data-testid': testID, + 'aria-label': accessibilityLabel, + 'size': Math.max(visibleItemCount, 1), + }, + items.map((item, index) => + createElement( + 'option', + { key: index, value: index, style: optionStyle }, + getItemLabel(item) + ) + ) ); } diff --git a/src/TimeDrumPicker.tsx b/src/TimeDrumPicker.tsx new file mode 100644 index 0000000..b999ff2 --- /dev/null +++ b/src/TimeDrumPicker.tsx @@ -0,0 +1,403 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + StyleSheet, + View, + type NativeSyntheticEvent, + type StyleProp, + type ViewStyle, +} from 'react-native'; +import { DrumPicker } from './DrumPicker'; +import { + buildHourItems, + buildMinuteItems, + buildPeriodItems, + buildSecondItems, + clampTimeValue, + from12Hour, + hourIndex, + minuteIndex, + normalizeInterval, + periodIndex, + to12Hour, + type TimeDrumPickerHourFormat, + type TimeDrumPickerInterval, + type TimeDrumPickerPeriod, + type TimeDrumPickerValue, +} from './timeDrumPickerLogic'; +import type { DrumPickerChangeEvent } from './types'; + +export type { + TimeDrumPickerHourFormat, + TimeDrumPickerInterval, + TimeDrumPickerPeriod, + TimeDrumPickerValue, +}; + +export type TimeDrumPickerMode = + | 'hour' + | 'minute' + | 'hour-minute' + | 'hour-minute-second' + | 'hour-minute-period' + | 'hour-minute-second-period'; + +export type TimeDrumPickerColumnKey = 'hour' | 'minute' | 'second' | 'period'; + +export type TimeDrumPickerProps = { + mode?: TimeDrumPickerMode; + value?: TimeDrumPickerValue; + onChange?: (value: Required) => void; + /** + * Force 12-hour or 24-hour hour column. Defaults to 12-hour when the mode + * includes a period column, otherwise 24-hour. + */ + hourFormat?: TimeDrumPickerHourFormat; + minuteInterval?: TimeDrumPickerInterval; + secondInterval?: TimeDrumPickerInterval; + /** Pad single-digit values with a leading zero. Defaults to `true`. */ + padWithZero?: boolean; + amLabel?: string; + pmLabel?: string; + itemHeight?: number; + visibleItemCount?: number; + textColor?: string; + selectedTextColor?: string; + textSize?: number; + selectedTextSize?: number; + showSelectionIndicator?: boolean; + selectionIndicatorColor?: string; + selectionIndicatorHeight?: number; + backgroundColor?: string; + itemBackgroundColor?: string; + containerBackgroundColor?: string; + hapticFeedback?: boolean; + style?: StyleProp; + columnStyle?: StyleProp; + columnStyles?: Partial>>; + columnTestIDs?: Partial>; + /** + * Accessibility labels per column. Defaults to `Hour` / `Minute` / + * `Second` / `AM/PM` so each wheel is distinguishable to assistive tech + * instead of all reading "Picker". + */ + columnAccessibilityLabels?: Partial< + Record + >; +}; + +const DEFAULT_COLUMN_ACCESSIBILITY_LABELS: Record< + TimeDrumPickerColumnKey, + string +> = { + hour: 'Hour', + minute: 'Minute', + second: 'Second', + period: 'AM/PM', +}; + +const COLUMN_ORDER: Record = { + 'hour': ['hour'], + 'minute': ['minute'], + 'hour-minute': ['hour', 'minute'], + 'hour-minute-second': ['hour', 'minute', 'second'], + 'hour-minute-period': ['hour', 'minute', 'period'], + 'hour-minute-second-period': ['hour', 'minute', 'second', 'period'], +}; + +const COLUMN_WIDTH: Record = { + hour: 72, + minute: 72, + second: 72, + period: 72, +}; + +const DEFAULT_ITEM_HEIGHT = 44; +const DEFAULT_VISIBLE_ITEM_COUNT = 5; + +function defaultHourFormat(mode: TimeDrumPickerMode): TimeDrumPickerHourFormat { + return mode === 'hour-minute-period' || mode === 'hour-minute-second-period' + ? '12' + : '24'; +} + +export function TimeDrumPicker({ + mode = 'hour-minute', + value, + onChange, + hourFormat, + minuteInterval, + secondInterval, + padWithZero = true, + amLabel = 'AM', + pmLabel = 'PM', + itemHeight = DEFAULT_ITEM_HEIGHT, + visibleItemCount = DEFAULT_VISIBLE_ITEM_COUNT, + textColor, + selectedTextColor, + textSize, + selectedTextSize, + showSelectionIndicator, + selectionIndicatorColor, + selectionIndicatorHeight, + backgroundColor = 'transparent', + itemBackgroundColor = 'transparent', + containerBackgroundColor = 'transparent', + hapticFeedback = false, + style, + columnStyle, + columnStyles, + columnTestIDs, + columnAccessibilityLabels, +}: TimeDrumPickerProps) { + const resolvedHourFormat = hourFormat ?? defaultHourFormat(mode); + const normalizedMinuteInterval = useMemo( + () => normalizeInterval(minuteInterval), + [minuteInterval] + ); + const normalizedSecondInterval = useMemo( + () => normalizeInterval(secondInterval), + [secondInterval] + ); + + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(() => + clampTimeValue( + value ?? undefined, + normalizedMinuteInterval, + normalizedSecondInterval + ) + ); + + const resolvedValue = useMemo(() => { + if (isControlled) { + return clampTimeValue( + value, + normalizedMinuteInterval, + normalizedSecondInterval + ); + } + return internalValue; + }, [ + isControlled, + value, + internalValue, + normalizedMinuteInterval, + normalizedSecondInterval, + ]); + + const columns = COLUMN_ORDER[mode]; + const hourItems = useMemo( + () => buildHourItems(resolvedHourFormat, padWithZero), + [resolvedHourFormat, padWithZero] + ); + const minuteItems = useMemo( + () => buildMinuteItems(normalizedMinuteInterval, padWithZero), + [normalizedMinuteInterval, padWithZero] + ); + const secondItems = useMemo( + () => buildSecondItems(normalizedSecondInterval, padWithZero), + [normalizedSecondInterval, padWithZero] + ); + const periodItems = useMemo( + () => buildPeriodItems(amLabel, pmLabel), + [amLabel, pmLabel] + ); + + const pickerHeight = itemHeight * visibleItemCount; + + const emitChange = useCallback( + (patch: Partial) => { + const next = clampTimeValue( + { ...resolvedValue, ...patch }, + normalizedMinuteInterval, + normalizedSecondInterval + ); + if (!isControlled) { + setInternalValue(next); + } + onChange?.(next); + }, + [ + resolvedValue, + normalizedMinuteInterval, + normalizedSecondInterval, + isControlled, + onChange, + ] + ); + + // Controlled callers may pass an out-of-range minute / hour. Mirror the + // DateDrumPicker behavior of clamping once and notifying so state stays in + // sync with what the picker actually displays. + useEffect(() => { + if (!isControlled || !onChange || value === undefined) { + return; + } + const clamped = clampTimeValue( + value, + normalizedMinuteInterval, + normalizedSecondInterval + ); + const hour = value.hour ?? clamped.hour; + const minute = value.minute ?? clamped.minute; + const second = value.second ?? clamped.second; + if ( + hour !== clamped.hour || + minute !== clamped.minute || + second !== clamped.second + ) { + onChange(clamped); + } + }, [ + isControlled, + onChange, + value, + normalizedMinuteInterval, + normalizedSecondInterval, + ]); + + const sharedPickerProps = { + itemHeight, + visibleItemCount, + textColor, + selectedTextColor, + textSize, + selectedTextSize, + showSelectionIndicator, + selectionIndicatorColor, + selectionIndicatorHeight, + backgroundColor, + itemBackgroundColor, + containerBackgroundColor, + hapticFeedback, + }; + + const columnContainerStyle = ( + column: TimeDrumPickerColumnKey + ): StyleProp => [ + styles.column, + { width: COLUMN_WIDTH[column], height: pickerHeight }, + columnStyle, + columnStyles?.[column], + ]; + + const columnAccessibilityLabel = ( + column: TimeDrumPickerColumnKey + ): string => + columnAccessibilityLabels?.[column] ?? + DEFAULT_COLUMN_ACCESSIBILITY_LABELS[column]; + + const renderHour = () => ( + ) => { + const index = event.nativeEvent.index; + if (resolvedHourFormat === '24') { + emitChange({ hour: Math.min(23, Math.max(0, index)) }); + return; + } + const hour12 = Math.min(12, Math.max(1, index + 1)); + const period = to12Hour(resolvedValue.hour).period; + emitChange({ hour: from12Hour(hour12, period) }); + }} + /> + ); + + const renderMinute = () => ( + ) => { + const minute = Math.min( + 59, + Math.max(0, event.nativeEvent.index * normalizedMinuteInterval) + ); + emitChange({ minute }); + }} + /> + ); + + const renderSecond = () => ( + ) => { + const second = Math.min( + 59, + Math.max(0, event.nativeEvent.index * normalizedSecondInterval) + ); + emitChange({ second }); + }} + /> + ); + + const renderPeriod = () => ( + ) => { + const nextPeriod: TimeDrumPickerPeriod = + event.nativeEvent.index === 0 ? 'AM' : 'PM'; + const { hour12 } = to12Hour(resolvedValue.hour); + emitChange({ hour: from12Hour(hour12, nextPeriod) }); + }} + /> + ); + + const renderColumn = (column: TimeDrumPickerColumnKey) => { + switch (column) { + case 'hour': + return renderHour(); + case 'minute': + return renderMinute(); + case 'second': + return renderSecond(); + case 'period': + return renderPeriod(); + } + }; + + return ( + + {columns.map((column) => renderColumn(column))} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'transparent', + }, + column: { + backgroundColor: 'transparent', + }, +}); diff --git a/src/__tests__/DrumPicker.labeledItems.test.tsx b/src/__tests__/DrumPicker.labeledItems.test.tsx new file mode 100644 index 0000000..a67da6a --- /dev/null +++ b/src/__tests__/DrumPicker.labeledItems.test.tsx @@ -0,0 +1,110 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; +import { + fireNativeDrumPickerChange, + getLatestNativeDrumPickerProps, + resetNativeDrumPickerMocks, +} from '../__mocks__/DrumPickerViewNativeComponent'; +import { DrumPicker } from '../DrumPicker.native'; + +describe('DrumPicker — labeled items', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetNativeDrumPickerMocks(); + }); + + it('passes only string labels to the native component when given labeled items', () => { + render( + + ); + expect(getLatestNativeDrumPickerProps()?.items).toEqual([ + 'Small', + 'Medium', + 'Large', + ]); + }); + + it('surfaces the resolved item on onChange.nativeEvent.item', () => { + const onChange = jest.fn(); + render( + + ); + fireNativeDrumPickerChange(2, 'Large'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].nativeEvent).toEqual({ + index: 2, + value: 'Large', + item: 'l', + }); + }); + + it('supports non-string item values (numbers, objects)', () => { + type Country = { id: number; iso: string }; + const items: Array<{ label: string; value: Country }> = [ + { label: 'United States', value: { id: 1, iso: 'us' } }, + { label: 'Germany', value: { id: 2, iso: 'de' } }, + ]; + const onChange = jest.fn(); + render( items={items} onChange={onChange} />); + fireNativeDrumPickerChange(1, 'Germany'); + expect(onChange.mock.calls[0][0].nativeEvent.item).toEqual({ + id: 2, + iso: 'de', + }); + }); + + it('still works with plain string items (back-compat)', () => { + const onChange = jest.fn(); + render( + + ); + fireNativeDrumPickerChange(1, 'Beta'); + expect(onChange.mock.calls[0][0].nativeEvent).toEqual({ + index: 1, + value: 'Beta', + item: 'Beta', + }); + }); + + it('handles mixed string + labeled items', () => { + const onChange = jest.fn(); + render( + + ); + expect(getLatestNativeDrumPickerProps()?.items).toEqual(['Plain', 'Fancy']); + fireNativeDrumPickerChange(1, 'Fancy'); + expect(onChange.mock.calls[0][0].nativeEvent).toEqual({ + index: 1, + value: 'Fancy', + item: 'fancy-id', + }); + }); + + it('falls back to value when the native index is out of bounds', () => { + const onChange = jest.fn(); + render( + + ); + fireNativeDrumPickerChange(5, 'Stale'); + expect(onChange.mock.calls[0][0].nativeEvent.item).toBe('Stale'); + }); +}); diff --git a/src/__tests__/DrumPicker.test.tsx b/src/__tests__/DrumPicker.test.tsx index 3d7a826..b2e6c37 100644 --- a/src/__tests__/DrumPicker.test.tsx +++ b/src/__tests__/DrumPicker.test.tsx @@ -39,6 +39,7 @@ describe('DrumPicker', () => { expect(onChange.mock.calls[0][0].nativeEvent).toEqual({ index: 1, value: 'Beta', + item: 'Beta', }); }); diff --git a/src/__tests__/DrumPickerWeb.test.tsx b/src/__tests__/DrumPickerWeb.test.tsx new file mode 100644 index 0000000..9565eee --- /dev/null +++ b/src/__tests__/DrumPickerWeb.test.tsx @@ -0,0 +1,167 @@ +/** + * Web-fallback tests for `DrumPicker`. + * + * Jest's `react-native` preset resolves `'../DrumPicker'` to the + * `.native.tsx` variant. We import the `.tsx` file explicitly so this test + * exercises the web fallback module without changing project-wide resolver + * config. + */ +import * as React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { DrumPicker as WebDrumPicker } from '../DrumPicker.tsx'; + +type SelectInstance = { + type: string; + props: { + 'value': number; + 'onChange': (e: { + target: { selectedIndex: number }; + preventDefault?: () => void; + stopPropagation?: () => void; + }) => void; + 'style': React.CSSProperties; + 'data-testid'?: string; + 'aria-label'?: string; + 'size': number; + 'children': Array<{ type: string; props: { children: string } }>; + }; +}; + +function renderWeb(props: Parameters[0]): SelectInstance { + let renderer: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(React.createElement(WebDrumPicker, props)); + }); + const root = renderer!.toTree(); + // The root rendered element is the , got: ${ + rendered ? rendered.type : JSON.stringify(root) + }` + ); + } + return rendered; +} + +describe('DrumPicker — web fallback', () => { + it('does not throw at import time (SSR safety)', () => { + expect(typeof WebDrumPicker).toBe('function'); + }); + + it('renders a ` element's + * `aria-label`. Defaults to `'Picker'` on web when omitted. + */ + accessibilityLabel?: string; + onChange?: (event: NativeSyntheticEvent>) => void; style?: StyleProp; testID?: string; }; + +/** + * Internal helper: extract the label string for a picker item. Exported so + * the native wrapper (`DrumPicker.native.tsx`) and the web fallback + * (`DrumPicker.tsx`) share one normalization path before handing items to the + * platform layer. + */ +export function getItemLabel( + item: string | DrumPickerLabeledItem +): string { + return typeof item === 'string' ? item : item.label; +} + +/** + * Internal helper: extract the resolved value for a picker item. For plain + * strings this returns the string itself (typed as `T` since plain-string + * items are only allowed when `T = string`). + */ +export function getItemValue(item: string | DrumPickerLabeledItem): T { + return (typeof item === 'string' ? item : item.value) as T; +}