@studio-piot/native-ui 0.17.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/dist/components/DateTime/DateTimePresenter.android.d.ts +4 -0
- package/dist/components/DateTime/DateTimePresenter.android.d.ts.map +1 -0
- package/dist/components/DateTime/DateTimePresenter.android.js +47 -0
- package/dist/components/DateTime/DateTimePresenter.android.js.map +1 -0
- package/dist/components/DateTime/DateTimePresenter.d.ts +4 -0
- package/dist/components/DateTime/DateTimePresenter.d.ts.map +1 -0
- package/dist/components/DateTime/DateTimePresenter.ios.d.ts +9 -0
- package/dist/components/DateTime/DateTimePresenter.ios.d.ts.map +1 -0
- package/dist/components/DateTime/DateTimePresenter.ios.js +69 -0
- package/dist/components/DateTime/DateTimePresenter.ios.js.map +1 -0
- package/dist/components/DateTime/DateTimePresenter.js +8 -0
- package/dist/components/DateTime/DateTimePresenter.js.map +1 -0
- package/dist/components/DateTime/FieldDateTime.d.ts +8 -0
- package/dist/components/DateTime/FieldDateTime.d.ts.map +1 -0
- package/dist/components/DateTime/FieldDateTime.js +90 -0
- package/dist/components/DateTime/FieldDateTime.js.map +1 -0
- package/dist/components/DateTime/dateTimeThemeRoles.d.ts +13 -0
- package/dist/components/DateTime/dateTimeThemeRoles.d.ts.map +1 -0
- package/dist/components/DateTime/dateTimeThemeRoles.js +15 -0
- package/dist/components/DateTime/dateTimeThemeRoles.js.map +1 -0
- package/dist/components/DateTime/dateTimeValue.d.ts +11 -0
- package/dist/components/DateTime/dateTimeValue.d.ts.map +1 -0
- package/dist/components/DateTime/dateTimeValue.js +40 -0
- package/dist/components/DateTime/dateTimeValue.js.map +1 -0
- package/dist/components/DateTime/index.d.ts +5 -0
- package/dist/components/DateTime/index.d.ts.map +1 -0
- package/dist/components/DateTime/index.js +10 -0
- package/dist/components/DateTime/index.js.map +1 -0
- package/dist/components/DateTime/types.d.ts +57 -0
- package/dist/components/DateTime/types.d.ts.map +1 -0
- package/dist/components/DateTime/types.js +3 -0
- package/dist/components/DateTime/types.js.map +1 -0
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.js +2 -9
- package/dist/components/Select/Select.js.map +1 -1
- package/dist/components/Tabs/Tabs.d.ts +8 -0
- package/dist/components/Tabs/Tabs.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.js +124 -0
- package/dist/components/Tabs/Tabs.js.map +1 -0
- package/dist/components/Tabs/index.d.ts +4 -0
- package/dist/components/Tabs/index.d.ts.map +1 -0
- package/dist/components/Tabs/index.js +8 -0
- package/dist/components/Tabs/index.js.map +1 -0
- package/dist/components/Tabs/tabsLayout.d.ts +13 -0
- package/dist/components/Tabs/tabsLayout.d.ts.map +1 -0
- package/dist/components/Tabs/tabsLayout.js +20 -0
- package/dist/components/Tabs/tabsLayout.js.map +1 -0
- package/dist/components/Tabs/tabsThemeRoles.d.ts +8 -0
- package/dist/components/Tabs/tabsThemeRoles.d.ts.map +1 -0
- package/dist/components/Tabs/tabsThemeRoles.js +10 -0
- package/dist/components/Tabs/tabsThemeRoles.js.map +1 -0
- package/dist/components/Tabs/types.d.ts +30 -0
- package/dist/components/Tabs/types.d.ts.map +1 -0
- package/dist/components/Tabs/types.js +3 -0
- package/dist/components/Tabs/types.js.map +1 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +5 -2
- package/dist/components/index.js.map +1 -1
- package/dist/date-time.d.ts +6 -0
- package/dist/date-time.d.ts.map +1 -0
- package/dist/date-time.js +22 -0
- package/dist/date-time.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/layout/SheetContainer.d.ts +6 -0
- package/dist/layout/SheetContainer.d.ts.map +1 -0
- package/dist/layout/SheetContainer.js +14 -0
- package/dist/layout/SheetContainer.js.map +1 -0
- package/docs/components/field-date-time.md +89 -0
- package/docs/components/tabs.md +85 -0
- package/package.json +16 -2
- package/src/components/DateTime/DateTimePresenter.android.tsx +76 -0
- package/src/components/DateTime/DateTimePresenter.ios.tsx +122 -0
- package/src/components/DateTime/DateTimePresenter.tsx +6 -0
- package/src/components/DateTime/FieldDateTime.tsx +164 -0
- package/src/components/DateTime/dateTimeThemeRoles.test.ts +18 -0
- package/src/components/DateTime/dateTimeThemeRoles.ts +13 -0
- package/src/components/DateTime/dateTimeValue.test.ts +49 -0
- package/src/components/DateTime/dateTimeValue.ts +36 -0
- package/src/components/DateTime/index.ts +4 -0
- package/src/components/DateTime/types.ts +58 -0
- package/src/components/Select/Select.tsx +1 -9
- package/src/components/Tabs/Tabs.tsx +148 -0
- package/src/components/Tabs/index.ts +3 -0
- package/src/components/Tabs/tabsLayout.test.ts +36 -0
- package/src/components/Tabs/tabsLayout.ts +23 -0
- package/src/components/Tabs/tabsThemeRoles.test.ts +14 -0
- package/src/components/Tabs/tabsThemeRoles.ts +8 -0
- package/src/components/Tabs/types.ts +31 -0
- package/src/components/index.ts +7 -0
- package/src/date-time.ts +5 -0
- package/src/index.ts +8 -1
- package/src/layout/SheetContainer.tsx +11 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Tabs — usage
|
|
2
|
+
|
|
3
|
+
Underline tabs that switch between views of the same screen, for example **Planned** and **Completed** on an Activities list. Equal-width labels sit on a hairline, and an underline slides to the active tab.
|
|
4
|
+
|
|
5
|
+
Tabs change what one screen shows; they don't navigate. For a choice inside a form, use [SegmentedControl](./segmented-control.md). For the app's main sections, use the native tab bar.
|
|
6
|
+
|
|
7
|
+
## Basic usage
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { Page, Tabs } from "@studio-piot/native-ui";
|
|
11
|
+
import { useState } from "react";
|
|
12
|
+
|
|
13
|
+
type View = "planned" | "completed";
|
|
14
|
+
|
|
15
|
+
export default function Activities() {
|
|
16
|
+
const [view, setView] = useState<View>("planned");
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Page>
|
|
20
|
+
<Tabs
|
|
21
|
+
value={view}
|
|
22
|
+
onChange={setView}
|
|
23
|
+
tabs={[
|
|
24
|
+
{ value: "planned", label: "Planned" },
|
|
25
|
+
{ value: "completed", label: "Completed" },
|
|
26
|
+
]}
|
|
27
|
+
/>
|
|
28
|
+
{view === "planned" ? <PlannedList /> : <CompletedList />}
|
|
29
|
+
</Page>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **Controlled.** Hold `value` in state; `onChange` gets the tapped tab's value.
|
|
35
|
+
- **Width.** Tabs fill their parent and share it equally. Inside a padded `Page`, the line runs between the page margins.
|
|
36
|
+
- **Edge-to-edge line.** To run the hairline across the whole screen while the tabs stay in line with the content, take the row to the edges with `PageBleed` and set `inset` to the page margin:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
const { screen } = useThemeSpacing();
|
|
40
|
+
|
|
41
|
+
<PageBleed>
|
|
42
|
+
<Tabs inset={screen} value={view} onChange={setView} tabs={tabs} />
|
|
43
|
+
</PageBleed>
|
|
44
|
+
```
|
|
45
|
+
- **Icons.** Give a tab an `icon` render function, like `Button`'s, to show it left of the label. It gets the label's colour (active or inactive) and size 17; it's hidden from screen readers.
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
{ value: "planned", label: "Planned", icon: ({ color, size }) => <SymbolView name="calendar" size={size} tintColor={color} /> }
|
|
49
|
+
```
|
|
50
|
+
- **Disabled tabs** show at 40% and can't be pressed.
|
|
51
|
+
|
|
52
|
+
## Accessibility
|
|
53
|
+
|
|
54
|
+
The row is a tab list and each tab is a tab, with `selected` on the active one, so VoiceOver reads "Planned, tab, selected, 1 of 2".
|
|
55
|
+
|
|
56
|
+
## Theme roles
|
|
57
|
+
|
|
58
|
+
| Usage | Role |
|
|
59
|
+
|---|---|
|
|
60
|
+
| Hairline under the tabs | `tabsBorder` |
|
|
61
|
+
| Inactive label | `tabsLabel` |
|
|
62
|
+
| Active label | `tabsActiveLabel` |
|
|
63
|
+
| Sliding underline | `tabsIndicator` |
|
|
64
|
+
|
|
65
|
+
## Props
|
|
66
|
+
|
|
67
|
+
| Prop | Type | Default | Notes |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| `value` | `T` | — | Required. The active tab |
|
|
70
|
+
| `onChange` | `(value: T) => void` | — | Required |
|
|
71
|
+
| `tabs` | `TabItem<T>[]` | — | Required. `{ value, label, icon?, disabled? }`. Empty renders nothing |
|
|
72
|
+
| `inset` | `number` | `0` | Space each side for the tabs and underline; the hairline still spans the full width |
|
|
73
|
+
| `borderColor` | `string` | theme `tabsBorder` | Optional override |
|
|
74
|
+
| `labelColor` | `string` | theme `tabsLabel` | Optional override |
|
|
75
|
+
| `activeLabelColor` | `string` | theme `tabsActiveLabel` | Optional override |
|
|
76
|
+
| `indicatorColor` | `string` | theme `tabsIndicator` | Optional override |
|
|
77
|
+
| `style` | view style | — | The tab row |
|
|
78
|
+
|
|
79
|
+
Layout: labels 15pt semibold with 12pt above and below; the underline is 2pt and slides over 300ms. Uses `react-native-reanimated`, already a peer for `SegmentedControl` and `Select`.
|
|
80
|
+
|
|
81
|
+
## Imports
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { Tabs, TABS_THEME_ROLES, type TabItem, type TabsProps } from "@studio-piot/native-ui";
|
|
85
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@studio-piot/native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Shared React Native components for Studio Piot apps.",
|
|
5
5
|
"homepage": "https://github.com/Studio-Piot/studio-native-ui#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -29,6 +29,13 @@
|
|
|
29
29
|
"import": "./dist/components/index.js",
|
|
30
30
|
"require": "./dist/components/index.js",
|
|
31
31
|
"default": "./dist/components/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./date-time": {
|
|
34
|
+
"types": "./dist/date-time.d.ts",
|
|
35
|
+
"react-native": "./dist/date-time.js",
|
|
36
|
+
"import": "./dist/date-time.js",
|
|
37
|
+
"require": "./dist/date-time.js",
|
|
38
|
+
"default": "./dist/date-time.js"
|
|
32
39
|
}
|
|
33
40
|
},
|
|
34
41
|
"typesVersions": {
|
|
@@ -50,6 +57,7 @@
|
|
|
50
57
|
"access": "public"
|
|
51
58
|
},
|
|
52
59
|
"peerDependencies": {
|
|
60
|
+
"@expo/ui": ">=57.0.0",
|
|
53
61
|
"@gorhom/bottom-sheet": "^5.2.14",
|
|
54
62
|
"@react-native-community/slider": "^5.2.0",
|
|
55
63
|
"react": ">=19.0.0",
|
|
@@ -67,9 +75,10 @@
|
|
|
67
75
|
"clean": "rm -rf dist *.tsbuildinfo",
|
|
68
76
|
"dev": "tsc --watch",
|
|
69
77
|
"prepare": "tsc",
|
|
70
|
-
"prepublishOnly": "
|
|
78
|
+
"prepublishOnly": "pnpm run build"
|
|
71
79
|
},
|
|
72
80
|
"devDependencies": {
|
|
81
|
+
"@expo/ui": "~57.0.19",
|
|
73
82
|
"@gorhom/bottom-sheet": "^5.2.14",
|
|
74
83
|
"@react-native-community/slider": "^5.2.0",
|
|
75
84
|
"@types/react": "^19.2.2",
|
|
@@ -81,5 +90,10 @@
|
|
|
81
90
|
"react-native-screens": "~4.26.0",
|
|
82
91
|
"react-native-svg": "^15.15.4",
|
|
83
92
|
"typescript": "^5.9.3"
|
|
93
|
+
},
|
|
94
|
+
"peerDependenciesMeta": {
|
|
95
|
+
"@expo/ui": {
|
|
96
|
+
"optional": true
|
|
97
|
+
}
|
|
84
98
|
}
|
|
85
99
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { DatePickerDialog, Host, TimePickerDialog } from "@expo/ui/jetpack-compose";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { withDay, withTime } from "./dateTimeValue";
|
|
4
|
+
import type { DateTimePresenterProps } from "./types";
|
|
5
|
+
|
|
6
|
+
type Step = "date" | "time" | null;
|
|
7
|
+
|
|
8
|
+
/** Android: the system date dialog, then the time dialog (for datetime). */
|
|
9
|
+
export function DateTimePresenter({
|
|
10
|
+
open,
|
|
11
|
+
value,
|
|
12
|
+
mode,
|
|
13
|
+
minimumDate,
|
|
14
|
+
maximumDate,
|
|
15
|
+
colors,
|
|
16
|
+
onConfirm,
|
|
17
|
+
onClose,
|
|
18
|
+
}: DateTimePresenterProps) {
|
|
19
|
+
const [step, setStep] = useState<Step>(null);
|
|
20
|
+
const [draft, setDraft] = useState(value);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (open) {
|
|
24
|
+
setDraft(value);
|
|
25
|
+
setStep(mode === "time" ? "time" : "date");
|
|
26
|
+
} else {
|
|
27
|
+
setStep(null);
|
|
28
|
+
}
|
|
29
|
+
// Restart from the value only when opened.
|
|
30
|
+
}, [open]);
|
|
31
|
+
|
|
32
|
+
const close = () => {
|
|
33
|
+
setStep(null);
|
|
34
|
+
onClose();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (step === "date") {
|
|
38
|
+
return (
|
|
39
|
+
<Host>
|
|
40
|
+
<DatePickerDialog
|
|
41
|
+
color={colors.accent}
|
|
42
|
+
initialDate={draft.toISOString()}
|
|
43
|
+
onDateSelected={(day) => {
|
|
44
|
+
const next = withDay(draft, day);
|
|
45
|
+
if (mode === "datetime") {
|
|
46
|
+
setDraft(next);
|
|
47
|
+
setStep("time");
|
|
48
|
+
} else {
|
|
49
|
+
setStep(null);
|
|
50
|
+
onConfirm(next);
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
onDismissRequest={close}
|
|
54
|
+
selectableDates={{ start: minimumDate, end: maximumDate }}
|
|
55
|
+
/>
|
|
56
|
+
</Host>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (step === "time") {
|
|
60
|
+
return (
|
|
61
|
+
<Host>
|
|
62
|
+
<TimePickerDialog
|
|
63
|
+
color={colors.accent}
|
|
64
|
+
initialDate={draft.toISOString()}
|
|
65
|
+
is24Hour
|
|
66
|
+
onDateSelected={(time) => {
|
|
67
|
+
setStep(null);
|
|
68
|
+
onConfirm(withTime(draft, time));
|
|
69
|
+
}}
|
|
70
|
+
onDismissRequest={close}
|
|
71
|
+
/>
|
|
72
|
+
</Host>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { DatePicker, Host } from "@expo/ui/swift-ui";
|
|
2
|
+
import { datePickerStyle, tint } from "@expo/ui/swift-ui/modifiers";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
|
|
5
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
6
|
+
import { SHEET_TOP_RADIUS } from "../../layout/sheet";
|
|
7
|
+
import type { DateTimePresenterProps } from "./types";
|
|
8
|
+
|
|
9
|
+
const BACKDROP = "rgba(0, 0, 0, 0.4)";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* iOS: a bottom panel in a React Native Modal with SwiftUI's picker — a
|
|
13
|
+
* calendar for dates, wheels for times — and Done. Tapping outside keeps
|
|
14
|
+
* the old value. A Modal (not a bottom-sheet modal) so it opens from
|
|
15
|
+
* anywhere, including inside another sheet or a native modal.
|
|
16
|
+
*/
|
|
17
|
+
export function DateTimePresenter({
|
|
18
|
+
open,
|
|
19
|
+
value,
|
|
20
|
+
mode,
|
|
21
|
+
minimumDate,
|
|
22
|
+
maximumDate,
|
|
23
|
+
title,
|
|
24
|
+
doneLabel,
|
|
25
|
+
colors,
|
|
26
|
+
onConfirm,
|
|
27
|
+
onClose,
|
|
28
|
+
}: DateTimePresenterProps) {
|
|
29
|
+
const insets = useSafeAreaInsets();
|
|
30
|
+
const [draft, setDraft] = useState(value);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
// Start from the field's value each time the picker opens.
|
|
34
|
+
if (open) setDraft(value);
|
|
35
|
+
}, [open]);
|
|
36
|
+
|
|
37
|
+
const components: ("date" | "hourAndMinute")[] =
|
|
38
|
+
mode === "date" ? ["date"] : mode === "time" ? ["hourAndMinute"] : ["date", "hourAndMinute"];
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Modal
|
|
42
|
+
animationType="slide"
|
|
43
|
+
onRequestClose={onClose}
|
|
44
|
+
transparent
|
|
45
|
+
visible={open}
|
|
46
|
+
>
|
|
47
|
+
<View style={styles.root}>
|
|
48
|
+
<Pressable
|
|
49
|
+
accessibilityLabel="Close"
|
|
50
|
+
accessibilityRole="button"
|
|
51
|
+
onPress={onClose}
|
|
52
|
+
style={[StyleSheet.absoluteFill, { backgroundColor: BACKDROP }]}
|
|
53
|
+
/>
|
|
54
|
+
<View
|
|
55
|
+
style={[
|
|
56
|
+
styles.panel,
|
|
57
|
+
{ backgroundColor: colors.background, paddingBottom: insets.bottom + 16 },
|
|
58
|
+
]}
|
|
59
|
+
>
|
|
60
|
+
<View style={styles.header}>
|
|
61
|
+
<Text accessibilityRole="header" style={[styles.title, { color: colors.text }]}>
|
|
62
|
+
{title}
|
|
63
|
+
</Text>
|
|
64
|
+
<Pressable accessibilityRole="button" hitSlop={8} onPress={() => onConfirm(draft)}>
|
|
65
|
+
<Text style={[styles.done, { color: colors.accent }]}>{doneLabel}</Text>
|
|
66
|
+
</Pressable>
|
|
67
|
+
</View>
|
|
68
|
+
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
|
69
|
+
<View style={styles.picker}>
|
|
70
|
+
<Host matchContents>
|
|
71
|
+
<DatePicker
|
|
72
|
+
displayedComponents={components}
|
|
73
|
+
modifiers={[
|
|
74
|
+
datePickerStyle(mode === "date" ? "graphical" : "wheel"),
|
|
75
|
+
tint(colors.accent),
|
|
76
|
+
]}
|
|
77
|
+
onDateChange={setDraft}
|
|
78
|
+
range={{ start: minimumDate, end: maximumDate }}
|
|
79
|
+
selection={draft}
|
|
80
|
+
/>
|
|
81
|
+
</Host>
|
|
82
|
+
</View>
|
|
83
|
+
</View>
|
|
84
|
+
</View>
|
|
85
|
+
</Modal>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const styles = StyleSheet.create({
|
|
90
|
+
root: {
|
|
91
|
+
flex: 1,
|
|
92
|
+
justifyContent: "flex-end",
|
|
93
|
+
},
|
|
94
|
+
panel: {
|
|
95
|
+
borderTopLeftRadius: SHEET_TOP_RADIUS,
|
|
96
|
+
borderTopRightRadius: SHEET_TOP_RADIUS,
|
|
97
|
+
},
|
|
98
|
+
header: {
|
|
99
|
+
alignItems: "center",
|
|
100
|
+
flexDirection: "row",
|
|
101
|
+
gap: 12,
|
|
102
|
+
paddingHorizontal: 16,
|
|
103
|
+
paddingVertical: 14,
|
|
104
|
+
},
|
|
105
|
+
title: {
|
|
106
|
+
flex: 1,
|
|
107
|
+
fontSize: 17,
|
|
108
|
+
fontWeight: "600",
|
|
109
|
+
},
|
|
110
|
+
done: {
|
|
111
|
+
fontSize: 17,
|
|
112
|
+
fontWeight: "600",
|
|
113
|
+
},
|
|
114
|
+
divider: {
|
|
115
|
+
height: StyleSheet.hairlineWidth,
|
|
116
|
+
},
|
|
117
|
+
picker: {
|
|
118
|
+
alignItems: "center",
|
|
119
|
+
paddingHorizontal: 16,
|
|
120
|
+
paddingTop: 8,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import { buttonRadiusValue } from "../Button/buttonRadius";
|
|
4
|
+
import { useErrorBannerDismiss, useErrorBannerField } from "../ErrorBanner";
|
|
5
|
+
import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
|
|
6
|
+
import { DateTimePresenter } from "./DateTimePresenter";
|
|
7
|
+
import { DATE_TIME_THEME_ROLES } from "./dateTimeThemeRoles";
|
|
8
|
+
import { clampDate, formatDateTimeValue } from "./dateTimeValue";
|
|
9
|
+
import type { FieldDateTimeProps } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A date, time, or date-and-time field. Looks like Select: label, value,
|
|
13
|
+
* description or error. Tapping opens the native picker — a sheet with
|
|
14
|
+
* Done on iOS, the system dialogs on Android. Needs `@expo/ui`.
|
|
15
|
+
*/
|
|
16
|
+
export function FieldDateTime({
|
|
17
|
+
name,
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
mode = "datetime",
|
|
21
|
+
label,
|
|
22
|
+
description,
|
|
23
|
+
error,
|
|
24
|
+
placeholder = mode === "time" ? "Choose a time" : "Choose a date",
|
|
25
|
+
minimumDate,
|
|
26
|
+
maximumDate,
|
|
27
|
+
formatValue,
|
|
28
|
+
sheetTitle,
|
|
29
|
+
doneLabel = "Done",
|
|
30
|
+
radius = "xl",
|
|
31
|
+
editable = true,
|
|
32
|
+
backgroundColor: backgroundColorProp,
|
|
33
|
+
textColor: textColorProp,
|
|
34
|
+
placeholderColor: placeholderColorProp,
|
|
35
|
+
labelColor: labelColorProp,
|
|
36
|
+
descriptionColor: descriptionColorProp,
|
|
37
|
+
borderColor: borderColorProp,
|
|
38
|
+
errorColor: errorColorProp,
|
|
39
|
+
accentColor: accentColorProp,
|
|
40
|
+
style,
|
|
41
|
+
}: FieldDateTimeProps) {
|
|
42
|
+
const backgroundColor = useResolvedFieldColor(backgroundColorProp, DATE_TIME_THEME_ROLES.backgroundColor);
|
|
43
|
+
const textColor = useResolvedFieldColor(textColorProp, DATE_TIME_THEME_ROLES.textColor);
|
|
44
|
+
const placeholderColor = useResolvedFieldColor(placeholderColorProp, DATE_TIME_THEME_ROLES.placeholderColor);
|
|
45
|
+
const labelColor = useResolvedFieldColor(labelColorProp, DATE_TIME_THEME_ROLES.labelColor);
|
|
46
|
+
const descriptionColor = useResolvedFieldColor(descriptionColorProp, DATE_TIME_THEME_ROLES.descriptionColor);
|
|
47
|
+
const borderColor = useResolvedFieldColor(borderColorProp, DATE_TIME_THEME_ROLES.borderColor);
|
|
48
|
+
const errorColor = useResolvedFieldColor(errorColorProp, DATE_TIME_THEME_ROLES.errorColor);
|
|
49
|
+
const accentColor = useResolvedFieldColor(accentColorProp, DATE_TIME_THEME_ROLES.accentColor);
|
|
50
|
+
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
const dismissBanner = useErrorBannerDismiss();
|
|
53
|
+
const { viewRef, listed } = useErrorBannerField(name, () => setOpen(true));
|
|
54
|
+
const hasError = Boolean(error) || Boolean(listed);
|
|
55
|
+
const inlineError = listed ? undefined : error;
|
|
56
|
+
const display = value
|
|
57
|
+
? (formatValue ?? ((date: Date) => formatDateTimeValue(date, mode)))(value)
|
|
58
|
+
: placeholder;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
ref={viewRef}
|
|
63
|
+
collapsable={false}
|
|
64
|
+
style={[styles.column, !editable && styles.blocked, style]}
|
|
65
|
+
>
|
|
66
|
+
{label ? <Text style={[styles.label, { color: labelColor }]}>{label}</Text> : null}
|
|
67
|
+
<Pressable
|
|
68
|
+
accessibilityHint={listed?.message ?? "Opens a date and time picker"}
|
|
69
|
+
accessibilityLabel={label ? `${label}. ${display}` : display}
|
|
70
|
+
accessibilityRole="button"
|
|
71
|
+
accessibilityState={{ disabled: !editable }}
|
|
72
|
+
disabled={!editable}
|
|
73
|
+
onPress={() => setOpen(true)}
|
|
74
|
+
style={[
|
|
75
|
+
styles.trigger,
|
|
76
|
+
{
|
|
77
|
+
backgroundColor,
|
|
78
|
+
borderColor: hasError ? errorColor : borderColor,
|
|
79
|
+
borderRadius: buttonRadiusValue(radius),
|
|
80
|
+
},
|
|
81
|
+
]}
|
|
82
|
+
>
|
|
83
|
+
<Text
|
|
84
|
+
numberOfLines={1}
|
|
85
|
+
style={[styles.triggerText, { color: value ? textColor : placeholderColor }]}
|
|
86
|
+
>
|
|
87
|
+
{display}
|
|
88
|
+
</Text>
|
|
89
|
+
<Text
|
|
90
|
+
accessibilityElementsHidden
|
|
91
|
+
importantForAccessibility="no"
|
|
92
|
+
style={[styles.chevron, { color: placeholderColor }]}
|
|
93
|
+
>
|
|
94
|
+
›
|
|
95
|
+
</Text>
|
|
96
|
+
</Pressable>
|
|
97
|
+
{inlineError ? (
|
|
98
|
+
<Text style={[styles.helper, { color: errorColor }]}>{inlineError}</Text>
|
|
99
|
+
) : description ? (
|
|
100
|
+
<Text style={[styles.helper, { color: descriptionColor }]}>{description}</Text>
|
|
101
|
+
) : null}
|
|
102
|
+
|
|
103
|
+
<DateTimePresenter
|
|
104
|
+
colors={{
|
|
105
|
+
background: backgroundColor,
|
|
106
|
+
text: textColor,
|
|
107
|
+
handle: placeholderColor,
|
|
108
|
+
border: borderColor,
|
|
109
|
+
accent: accentColor,
|
|
110
|
+
}}
|
|
111
|
+
doneLabel={doneLabel}
|
|
112
|
+
maximumDate={maximumDate}
|
|
113
|
+
minimumDate={minimumDate}
|
|
114
|
+
mode={mode}
|
|
115
|
+
onClose={() => setOpen(false)}
|
|
116
|
+
onConfirm={(next) => {
|
|
117
|
+
setOpen(false);
|
|
118
|
+
onChange(clampDate(next, minimumDate, maximumDate));
|
|
119
|
+
dismissBanner();
|
|
120
|
+
}}
|
|
121
|
+
open={open}
|
|
122
|
+
title={sheetTitle ?? label ?? "Date and time"}
|
|
123
|
+
value={clampDate(value ?? new Date(), minimumDate, maximumDate)}
|
|
124
|
+
/>
|
|
125
|
+
</View>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const styles = StyleSheet.create({
|
|
130
|
+
column: {
|
|
131
|
+
gap: 6,
|
|
132
|
+
width: "100%",
|
|
133
|
+
},
|
|
134
|
+
blocked: {
|
|
135
|
+
opacity: 0.4,
|
|
136
|
+
},
|
|
137
|
+
label: {
|
|
138
|
+
fontSize: 13,
|
|
139
|
+
fontWeight: "600",
|
|
140
|
+
},
|
|
141
|
+
trigger: {
|
|
142
|
+
alignItems: "center",
|
|
143
|
+
borderWidth: 1,
|
|
144
|
+
flexDirection: "row",
|
|
145
|
+
gap: 8,
|
|
146
|
+
minHeight: 48,
|
|
147
|
+
paddingHorizontal: 14,
|
|
148
|
+
paddingVertical: 12,
|
|
149
|
+
width: "100%",
|
|
150
|
+
},
|
|
151
|
+
triggerText: {
|
|
152
|
+
flex: 1,
|
|
153
|
+
fontSize: 17,
|
|
154
|
+
},
|
|
155
|
+
chevron: {
|
|
156
|
+
fontSize: 22,
|
|
157
|
+
fontWeight: "300",
|
|
158
|
+
lineHeight: 24,
|
|
159
|
+
transform: [{ rotate: "90deg" }],
|
|
160
|
+
},
|
|
161
|
+
helper: {
|
|
162
|
+
fontSize: 13,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { DATE_TIME_THEME_ROLES } from "./dateTimeThemeRoles.ts";
|
|
4
|
+
|
|
5
|
+
describe("DATE_TIME_THEME_ROLES", () => {
|
|
6
|
+
it("shares the Field roles, plus the Select accent for Done", () => {
|
|
7
|
+
assert.deepEqual(DATE_TIME_THEME_ROLES, {
|
|
8
|
+
backgroundColor: "fieldBackground",
|
|
9
|
+
textColor: "fieldText",
|
|
10
|
+
placeholderColor: "fieldPlaceholder",
|
|
11
|
+
labelColor: "fieldLabel",
|
|
12
|
+
descriptionColor: "fieldDescription",
|
|
13
|
+
borderColor: "fieldBorder",
|
|
14
|
+
errorColor: "fieldError",
|
|
15
|
+
accentColor: "fieldSelectSelected",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The same roles as FieldText and Select, so date fields match the form. */
|
|
2
|
+
export const DATE_TIME_THEME_ROLES = {
|
|
3
|
+
backgroundColor: "fieldBackground",
|
|
4
|
+
textColor: "fieldText",
|
|
5
|
+
placeholderColor: "fieldPlaceholder",
|
|
6
|
+
labelColor: "fieldLabel",
|
|
7
|
+
descriptionColor: "fieldDescription",
|
|
8
|
+
borderColor: "fieldBorder",
|
|
9
|
+
errorColor: "fieldError",
|
|
10
|
+
accentColor: "fieldSelectSelected",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type DateTimeThemeColorProp = keyof typeof DATE_TIME_THEME_ROLES;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { clampDate, formatDateTimeValue, withDay, withTime } from "./dateTimeValue.ts";
|
|
4
|
+
|
|
5
|
+
const value = new Date(2026, 9, 3, 8, 5);
|
|
6
|
+
|
|
7
|
+
describe("formatDateTimeValue", () => {
|
|
8
|
+
it("shows the parts the mode picks", () => {
|
|
9
|
+
assert.equal(formatDateTimeValue(value, "date"), "Sat 3 Oct 2026");
|
|
10
|
+
assert.equal(formatDateTimeValue(value, "time"), "08:05");
|
|
11
|
+
assert.equal(formatDateTimeValue(value, "datetime"), "Sat 3 Oct 2026 · 08:05");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("withDay and withTime", () => {
|
|
16
|
+
it("takes the day from one date and keeps the time of the other", () => {
|
|
17
|
+
const next = withDay(value, new Date(2026, 11, 25, 17, 30));
|
|
18
|
+
assert.deepEqual(
|
|
19
|
+
[next.getFullYear(), next.getMonth(), next.getDate(), next.getHours(), next.getMinutes()],
|
|
20
|
+
[2026, 11, 25, 8, 5],
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("takes the time from one date and keeps the day of the other", () => {
|
|
25
|
+
const next = withTime(value, new Date(2020, 0, 1, 19, 45, 30));
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
[next.getFullYear(), next.getMonth(), next.getDate(), next.getHours(), next.getMinutes(), next.getSeconds()],
|
|
28
|
+
[2026, 9, 3, 19, 45, 0],
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("clampDate", () => {
|
|
34
|
+
const min = new Date(2026, 9, 3, 9, 0);
|
|
35
|
+
const max = new Date(2026, 9, 10, 9, 0);
|
|
36
|
+
|
|
37
|
+
it("keeps a date in range", () => {
|
|
38
|
+
assert.equal(clampDate(new Date(2026, 9, 5), min, max).getTime(), new Date(2026, 9, 5).getTime());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("moves an earlier or later date to the limit", () => {
|
|
42
|
+
assert.equal(clampDate(value, min, max).getTime(), min.getTime());
|
|
43
|
+
assert.equal(clampDate(new Date(2027, 0, 1), min, max).getTime(), max.getTime());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("ignores a missing limit", () => {
|
|
47
|
+
assert.equal(clampDate(value, undefined, undefined).getTime(), value.getTime());
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Which parts of the value the field edits. */
|
|
2
|
+
export type DateTimeMode = "date" | "time" | "datetime";
|
|
3
|
+
|
|
4
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
5
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
6
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
7
|
+
|
|
8
|
+
/** Default display: "Sat 3 Oct 2026", "08:05" or "Sat 3 Oct 2026 · 08:05", local time. Pure. */
|
|
9
|
+
export function formatDateTimeValue(value: Date, mode: DateTimeMode): string {
|
|
10
|
+
const day = `${WEEKDAYS[value.getDay()]} ${value.getDate()} ${MONTHS[value.getMonth()]} ${value.getFullYear()}`;
|
|
11
|
+
const time = `${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
|
12
|
+
if (mode === "date") return day;
|
|
13
|
+
if (mode === "time") return time;
|
|
14
|
+
return `${day} · ${time}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `value` moved to the calendar day of `day`, keeping its time. */
|
|
18
|
+
export function withDay(value: Date, day: Date): Date {
|
|
19
|
+
const next = new Date(value);
|
|
20
|
+
next.setFullYear(day.getFullYear(), day.getMonth(), day.getDate());
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `value` at the hour and minute of `time`, keeping its day. */
|
|
25
|
+
export function withTime(value: Date, time: Date): Date {
|
|
26
|
+
const next = new Date(value);
|
|
27
|
+
next.setHours(time.getHours(), time.getMinutes(), 0, 0);
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `value` moved inside the optional limits. */
|
|
32
|
+
export function clampDate(value: Date, minimum?: Date, maximum?: Date): Date {
|
|
33
|
+
if (minimum && value < minimum) return new Date(minimum);
|
|
34
|
+
if (maximum && value > maximum) return new Date(maximum);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from "react-native";
|
|
2
|
+
import type { ButtonRadius } from "../Button/buttonRadius";
|
|
3
|
+
import type { DateTimeMode } from "./dateTimeValue";
|
|
4
|
+
|
|
5
|
+
export type FieldDateTimeProps = {
|
|
6
|
+
/** Matches `FieldError.field` for the error banner. */
|
|
7
|
+
name?: string;
|
|
8
|
+
value: Date | null;
|
|
9
|
+
onChange: (value: Date) => void;
|
|
10
|
+
/** Default `"datetime"`. */
|
|
11
|
+
mode?: DateTimeMode;
|
|
12
|
+
label?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
/** Shown while `value` is null. Default "Choose a date" / "Choose a time". */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
minimumDate?: Date;
|
|
18
|
+
maximumDate?: Date;
|
|
19
|
+
/** Field text. Default: "Sat 3 Oct 2026 · 08:05" style. */
|
|
20
|
+
formatValue?: (value: Date) => string;
|
|
21
|
+
/** iOS sheet title. Defaults to `label`. */
|
|
22
|
+
sheetTitle?: string;
|
|
23
|
+
/** Default "Done". */
|
|
24
|
+
doneLabel?: string;
|
|
25
|
+
radius?: ButtonRadius;
|
|
26
|
+
editable?: boolean;
|
|
27
|
+
backgroundColor?: string;
|
|
28
|
+
textColor?: string;
|
|
29
|
+
placeholderColor?: string;
|
|
30
|
+
labelColor?: string;
|
|
31
|
+
descriptionColor?: string;
|
|
32
|
+
borderColor?: string;
|
|
33
|
+
errorColor?: string;
|
|
34
|
+
/** Done button (iOS) and picker tint. */
|
|
35
|
+
accentColor?: string;
|
|
36
|
+
style?: StyleProp<ViewStyle>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Platform picker opened by the field. */
|
|
40
|
+
export type DateTimePresenterProps = {
|
|
41
|
+
open: boolean;
|
|
42
|
+
/** Starting point: the value, or now within the limits. */
|
|
43
|
+
value: Date;
|
|
44
|
+
mode: DateTimeMode;
|
|
45
|
+
minimumDate?: Date;
|
|
46
|
+
maximumDate?: Date;
|
|
47
|
+
title: string;
|
|
48
|
+
doneLabel: string;
|
|
49
|
+
colors: {
|
|
50
|
+
background: string;
|
|
51
|
+
text: string;
|
|
52
|
+
handle: string;
|
|
53
|
+
border: string;
|
|
54
|
+
accent: string;
|
|
55
|
+
};
|
|
56
|
+
onConfirm: (value: Date) => void;
|
|
57
|
+
onClose: () => void;
|
|
58
|
+
};
|