Skip to content

Commit f3d7200

Browse files
Merge pull request #5 from JasperVercammen/feature/added-accessibility
2 parents 7fb9e86 + 55efa20 commit f3d7200

8 files changed

Lines changed: 528 additions & 64 deletions

File tree

.github/copilot-instructions.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# React Native Date Time Spinner - Copilot Instructions
2+
3+
## Project Overview
4+
5+
A lightweight, pure JavaScript React Native library providing spinner-style date and time pickers. No native linking required. Built as a monorepo with the library in `src/` and Expo example app in `examples/example-expo/`.
6+
7+
## React Native Performance
8+
9+
When reviewing or writing React Native code, apply the optimization guidelines from:
10+
11+
- ~/.github/agent-skills/skills/react-native-best-practices/SKILL.md (main reference)
12+
- ~/.github/agent-skills/skills/react-native-best-practices/references/ (detailed skills)
13+
14+
Key patterns:
15+
16+
- Use FlashList over FlatList for large lists
17+
- Avoid barrel exports
18+
- Profile before optimizing
19+
20+
## Architecture
21+
22+
### Component Hierarchy
23+
24+
- **`DateTimeSpinner`** (entry point): Orchestrates date/datetime picking with configurable columns
25+
26+
- Wraps multiple **`DurationScroll`** instances (one per column: day, month, year, hour, minute)
27+
- Manages state synchronization between columns (e.g., adjusting days when month changes)
28+
- Handles min/max date constraints and value clamping
29+
30+
- **`DurationScroll`**: Low-level FlatList-based scrollable picker column
31+
- Implements infinite scrolling via `repeatNumbersNTimes` prop (repeats item array N times)
32+
- Calculates scroll offsets and snaps to items based on `interval` and `padWithNItems`
33+
- Location: `src/components/DurationScroll/`
34+
35+
### Key Architectural Patterns
36+
37+
1. **Forwarded refs**: Both components expose imperative handles (`setValue`, `reset`, `latestValue`) via `forwardRef`/`useImperativeHandle`
38+
2. **Utility functions are pure**: All `src/utils/` functions are stateless helpers (e.g., `generateNumbers.ts`, `getInitialScrollIndex.ts`)
39+
3. **Styles are generated functions**: `generateStyles()` in `styles.ts` merges custom styles with defaults at render time
40+
4. **Optional gradient overlays**: Components accept `LinearGradient` and `MaskedView` props (not bundled) for fade effects
41+
42+
## Development Workflow
43+
44+
### Setup & Running
45+
46+
```bash
47+
yarn setup # Install all dependencies (root + workspaces)
48+
yarn start # Start Expo dev server (example-expo)
49+
yarn android/ios # Run on device (from example-expo workspace)
50+
yarn build # Build library with react-native-builder-bob
51+
```
52+
53+
### Testing
54+
55+
```bash
56+
yarn test # Run Jest tests (silent mode, force exit)
57+
yarn ts # TypeScript type checking (no emit)
58+
yarn lint:fix # Auto-fix ESLint issues
59+
```
60+
61+
- Tests live in `src/tests/` with mocks in `src/tests/__mocks__/`
62+
- Test file naming: matches source file name (e.g., `DurationScroll.test.tsx`)
63+
- Use `@testing-library/react-native` for component tests
64+
65+
### Build System
66+
67+
- **react-native-builder-bob** generates 3 outputs: `commonjs`, `module`, `typescript` (see `package.json` config)
68+
- Entry points: `main`, `module`, `types` fields point to `dist/` (generated on `yarn prepare`)
69+
- Source lives in `src/`, excluded: `src/tests`, `examples/`
70+
71+
## Code Conventions
72+
73+
### Component Props Pattern
74+
75+
```tsx
76+
// Always destructure props with defaults at top of component
77+
const {
78+
allowFontScaling = false,
79+
padWithNItems = 2,
80+
onDateChange,
81+
// ... rest
82+
} = props;
83+
```
84+
85+
### TypeScript Patterns
86+
87+
- Export types from component index files: `export { ComponentProps, ComponentRef } from "./Component"`
88+
- Ref types: `ComponentRef` interface with imperative methods
89+
- Props types: `ComponentProps` interface
90+
- Value types: `ComponentValue` type (e.g., `DateTimeSpinnerValue` with `date`, `day`, `month`, etc.)
91+
92+
### Styling
93+
94+
- Use `CustomDateTimeSpinnerStyles` type for style props (subset of React Native styles)
95+
- Theme support via `theme?: "light" | "dark"` style prop
96+
- Always merge custom styles with defaults: `...customStyles?.pickerItem`
97+
98+
### Date Handling
99+
100+
- Internal state uses separate `day`, `month`, `year`, `hour`, `minute` numbers
101+
- Constraining logic in `getSafeInitialDateValue.ts` handles invalid dates (e.g., Feb 31 → Feb 28)
102+
- Min/max dates are swapped if provided in wrong order (see `DateTimeSpinner.tsx` line ~95)
103+
- Normalize dates to midnight when comparing date-only values
104+
105+
### Accessibility
106+
107+
- All picker columns have `accessibilityRole="adjustable"` for VoiceOver/TalkBack support
108+
- Each column gets descriptive labels: "Day", "Month", "Year", "Hour", "Minute", "Date"
109+
- Users can override labels via `accessibilityLabels` prop for internationalization
110+
- Container uses auto-generated label: "Date picker" or "Date and time picker"
111+
- Time separator marked as `accessible={false}` to avoid screen reader confusion
112+
113+
## File Organization
114+
115+
```
116+
src/
117+
├── index.ts # Public API exports
118+
├── components/
119+
│ ├── DateTimeSpinner/
120+
│ │ ├── DateTimeSpinner.tsx # Main component (~1200 lines)
121+
│ │ ├── types.ts # Props, Ref, Value types
122+
│ │ ├── styles.ts # generateStyles() function
123+
│ │ └── index.ts # Component exports
124+
│ └── DurationScroll/ # Reusable scroll column
125+
└── utils/ # Pure functions only
126+
```
127+
128+
## Common Tasks
129+
130+
### Adding a new prop to DateTimeSpinner
131+
132+
1. Add to `DateTimeSpinnerProps` in `types.ts`
133+
2. Destructure with default in component
134+
3. Pass through to `DurationScroll` if needed
135+
4. Update README.md Props table
136+
5. Add example to `examples/example-expo/App.tsx`
137+
138+
### Modifying date constraint logic
139+
140+
- Edit `src/utils/getSafeInitialDateValue.ts` (handles clamping/validation)
141+
- Update tests in `src/tests/getSafeInitialDateValue.test.ts`
142+
- Date parts are clamped independently then validated together
143+
144+
### Debugging scroll behavior
145+
146+
- Check `getInitialScrollIndex.ts` for initial positioning math
147+
- Check `getDurationAndIndexFromScrollOffset.ts` for scroll-to-value conversion
148+
- Verify `repeatNumbersNTimes` calculation in `DurationScroll.tsx` line ~75 (dynamic repeat logic)
149+
150+
## Dependencies
151+
152+
- **Peer deps**: React >=18.2, React Native >=0.72
153+
- **Optional**: `expo-linear-gradient` or `react-native-linear-gradient` for gradients
154+
- **Dev**: Jest, Testing Library, react-native-builder-bob
155+
- **Example app**: Uses `date-fns` for formatting, Expo 54+
156+
157+
## Key Gotchas
158+
159+
- Infinite scroll requires `repeatNumbersNTimes >= 2` (enforced in `DurationScroll.tsx`)
160+
- Single-item pickers force `repeatNumbersNTimes = 1` to avoid empty lists
161+
- `formatDateToParts` overrides individual `padDayWithZero`/`padMonthWithZero` props
162+
- `dateTimeOrder` only applies in `mode="datetime"` (ignored in `mode="date"`)

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,63 @@ export default function Example() {
265265
| `MaskedView` | `any` | `undefined` | Mask component to apply gradient as a mask. |
266266
| `styles` | `CustomDateTimeSpinnerStyles` | `undefined` | Style overrides (see below). |
267267
| `pickerContainerProps` | `View` props | `undefined` | Extra props for outer container. |
268+
| `accessibilityLabel` | `string` | Auto-generated | Accessibility label for screen readers. Auto-generated based on mode. |
269+
| `accessibilityLabels` | `object` | English defaults | Custom labels for each column (see Accessibility section below). |
270+
271+
## ♿ Accessibility
272+
273+
The date spinner is fully accessible and works with VoiceOver (iOS) and TalkBack (Android):
274+
275+
- Each column (day, month, year, hour, minute) is announced with clear labels
276+
- Users can adjust values using the "Adjust Value" gesture (swipe up/down with VoiceOver)
277+
- The container is announced as "Date picker" or "Date and time picker" based on mode
278+
- You can provide a custom `accessibilityLabel` prop to override the default announcement
279+
280+
**Example with custom accessibility label:**
281+
282+
```tsx
283+
<DateTimeSpinner
284+
accessibilityLabel="Select your birthday"
285+
initialValue={new Date(1990, 0, 1)}
286+
onDateChange={({ date }) => console.log(date)}
287+
/>
288+
```
289+
290+
### Internationalization
291+
292+
Use `accessibilityLabels` to provide column labels in any language:
293+
294+
```tsx
295+
<DateTimeSpinner
296+
mode="datetime"
297+
accessibilityLabels={{
298+
picker: "Sélecteur de date et heure",
299+
date: "Date",
300+
hour: "Heure",
301+
minute: "Minute",
302+
day: "Jour",
303+
month: "Mois",
304+
year: "Année",
305+
hint: "Balayez vers le haut ou vers le bas pour ajuster",
306+
}}
307+
initialValue={new Date(2025, 5, 15, 14, 30)}
308+
onDateChange={({ date }) => console.log(date)}
309+
/>
310+
```
311+
312+
**Available label keys:**
313+
314+
| Key | Default (English) | Used in Mode |
315+
| -------- | --------------------------------------- | ----------------------- |
316+
| `picker` | "Date picker" or "Date and time picker" | All modes |
317+
| `day` | "Day" | `date` |
318+
| `month` | "Month" | `date` |
319+
| `year` | "Year" | `date` |
320+
| `date` | "Date" | `datetime` |
321+
| `hour` | "Hour" | `datetime` |
322+
| `minute` | "Minute" | `datetime` |
323+
| `hint` | "Swipe up or down to adjust" | All modes (each column) |
324+
| `minute` | "Minute" | `datetime` |
268325

269326
## 🎨 Styling
270327

examples/example-expo/App.tsx

Lines changed: 40 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export default function App() {
4242
})}
4343
initialValue={selectedDate}
4444
LinearGradient={LinearGradient}
45-
maxDate={subYears(new Date(), 18)}
4645
onDateChange={({ date }) => setSelectedDate(date)}
4746
pickerGradientOverlayProps={{
4847
locations: [0, 0.5, 0.5, 1],
@@ -81,52 +80,48 @@ export default function App() {
8180
/>
8281
<View accessible={false} style={styles.selected}></View>
8382
</View>
83+
</View>
8484

85-
<View style={styles.section}>
86-
<Text style={styles.label}>With min/max bounds</Text>
87-
<Text style={styles.value}>
88-
{formatDate(boundedDate)}
89-
</Text>
90-
<DateTimeSpinner
91-
initialValue={boundedDate}
92-
maxDate={maxDate}
93-
minDate={minDate}
94-
onDateChange={({ date }) => setBoundedDate(date)}
95-
styles={{
96-
pickerItem: styles.pickerItem,
97-
pickerLabel: styles.pickerLabel,
98-
}}
99-
/>
100-
<Text style={styles.helper}>
101-
Allowed range: {formatDate(minDate)}{" "}
102-
{formatDate(maxDate)}
103-
</Text>
104-
</View>
85+
<View style={styles.section}>
86+
<Text style={styles.label}>With min/max bounds</Text>
87+
<Text style={styles.value}>{formatDate(boundedDate)}</Text>
88+
<DateTimeSpinner
89+
initialValue={boundedDate}
90+
maxDate={maxDate}
91+
minDate={minDate}
92+
onDateChange={({ date }) => setBoundedDate(date)}
93+
styles={{
94+
pickerItem: styles.pickerItem,
95+
pickerLabel: styles.pickerLabel,
96+
}}
97+
/>
98+
<Text style={styles.helper}>
99+
Allowed range: {formatDate(minDate)}{" "}
100+
{formatDate(maxDate)}
101+
</Text>
102+
</View>
105103

106-
<View style={styles.section}>
107-
<Text style={styles.label}>
108-
Custom formatting & order
109-
</Text>
110-
<Text style={styles.value}>
111-
{format(boundedDate, "do MMM yyyy")}
112-
</Text>
113-
<DateTimeSpinner
114-
columnOrder={["month", "day", "year"]}
115-
formatDateToParts={(date) => ({
116-
day: format(date, "do"),
117-
month: format(date, "MMM"),
118-
year: format(date, "''yy"),
119-
})}
120-
initialValue={boundedDate}
121-
maxDate={maxDate}
122-
minDate={minDate}
123-
onDateChange={({ date }) => setBoundedDate(date)}
124-
styles={{
125-
pickerItem: styles.pickerItem,
126-
pickerLabel: styles.pickerLabel,
127-
}}
128-
/>
129-
</View>
104+
<View style={styles.section}>
105+
<Text style={styles.label}>Custom formatting & order</Text>
106+
<Text style={styles.value}>
107+
{format(boundedDate, "do MMM yyyy")}
108+
</Text>
109+
<DateTimeSpinner
110+
columnOrder={["month", "day", "year"]}
111+
formatDateToParts={(date) => ({
112+
day: format(date, "do"),
113+
month: format(date, "MMM"),
114+
year: format(date, "''yy"),
115+
})}
116+
initialValue={boundedDate}
117+
maxDate={maxDate}
118+
minDate={minDate}
119+
onDateChange={({ date }) => setBoundedDate(date)}
120+
styles={{
121+
pickerItem: styles.pickerItem,
122+
pickerLabel: styles.pickerLabel,
123+
}}
124+
/>
130125
</View>
131126
</View>
132127
</ScrollView>

0 commit comments

Comments
 (0)