@ankhorage/zora 2.4.5 → 2.4.7
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/CHANGELOG.md +12 -0
- package/README.md +188 -0
- package/dist/components/date-picker/DatePicker.d.ts +4 -0
- package/dist/components/date-picker/DatePicker.d.ts.map +1 -0
- package/dist/components/date-picker/DatePicker.js +144 -0
- package/dist/components/date-picker/DatePicker.js.map +1 -0
- package/dist/components/date-picker/index.d.ts +3 -0
- package/dist/components/date-picker/index.d.ts.map +1 -0
- package/dist/components/date-picker/index.js +2 -0
- package/dist/components/date-picker/index.js.map +1 -0
- package/dist/components/date-picker/meta.d.ts +9 -0
- package/dist/components/date-picker/meta.d.ts.map +1 -0
- package/dist/components/date-picker/meta.js +9 -0
- package/dist/components/date-picker/meta.js.map +1 -0
- package/dist/components/date-picker/types.d.ts +18 -0
- package/dist/components/date-picker/types.d.ts.map +1 -0
- package/dist/components/date-picker/types.js +2 -0
- package/dist/components/date-picker/types.js.map +1 -0
- package/dist/components/pagination/Pagination.d.ts +4 -0
- package/dist/components/pagination/Pagination.d.ts.map +1 -0
- package/dist/components/pagination/Pagination.js +92 -0
- package/dist/components/pagination/Pagination.js.map +1 -0
- package/dist/components/pagination/index.d.ts +3 -0
- package/dist/components/pagination/index.d.ts.map +1 -0
- package/dist/components/pagination/index.js +2 -0
- package/dist/components/pagination/index.js.map +1 -0
- package/dist/components/pagination/meta.d.ts +9 -0
- package/dist/components/pagination/meta.d.ts.map +1 -0
- package/dist/components/pagination/meta.js +9 -0
- package/dist/components/pagination/meta.js.map +1 -0
- package/dist/components/pagination/types.d.ts +18 -0
- package/dist/components/pagination/types.d.ts.map +1 -0
- package/dist/components/pagination/types.js +2 -0
- package/dist/components/pagination/types.js.map +1 -0
- package/dist/components/time-picker/TimePicker.d.ts +4 -0
- package/dist/components/time-picker/TimePicker.d.ts.map +1 -0
- package/dist/components/time-picker/TimePicker.js +80 -0
- package/dist/components/time-picker/TimePicker.js.map +1 -0
- package/dist/components/time-picker/index.d.ts +3 -0
- package/dist/components/time-picker/index.d.ts.map +1 -0
- package/dist/components/time-picker/index.js +2 -0
- package/dist/components/time-picker/index.js.map +1 -0
- package/dist/components/time-picker/meta.d.ts +9 -0
- package/dist/components/time-picker/meta.d.ts.map +1 -0
- package/dist/components/time-picker/meta.js +9 -0
- package/dist/components/time-picker/meta.js.map +1 -0
- package/dist/components/time-picker/types.d.ts +19 -0
- package/dist/components/time-picker/types.d.ts.map +1 -0
- package/dist/components/time-picker/types.js +2 -0
- package/dist/components/time-picker/types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/metadata/componentMeta.d.ts.map +1 -1
- package/dist/metadata/componentMeta.js +6 -0
- package/dist/metadata/componentMeta.js.map +1 -1
- package/package.json +1 -1
- package/src/components/date-picker/DatePicker.tsx +242 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-picker/meta.ts +10 -0
- package/src/components/date-picker/types.ts +20 -0
- package/src/components/pagination/Pagination.tsx +189 -0
- package/src/components/pagination/index.ts +2 -0
- package/src/components/pagination/meta.ts +10 -0
- package/src/components/pagination/types.ts +19 -0
- package/src/components/time-picker/TimePicker.tsx +152 -0
- package/src/components/time-picker/index.ts +2 -0
- package/src/components/time-picker/meta.ts +10 -0
- package/src/components/time-picker/types.ts +21 -0
- package/src/index.ts +6 -0
- package/src/metadata/componentMeta.ts +6 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Field } from '@ankhorage/surface';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import { Box, Stack } from '../../foundation';
|
|
5
|
+
import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
|
|
6
|
+
import { ActionSheet } from '../action-sheet';
|
|
7
|
+
import { Button } from '../button';
|
|
8
|
+
import { Text } from '../text';
|
|
9
|
+
import type { DatePickerProps } from './types';
|
|
10
|
+
|
|
11
|
+
const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
|
|
12
|
+
const DAY_CELL_WIDTH = 42;
|
|
13
|
+
|
|
14
|
+
function renderLabel(label: React.ReactNode, description: React.ReactNode | undefined) {
|
|
15
|
+
return (
|
|
16
|
+
<Stack gap="xs">
|
|
17
|
+
<Text variant="label" weight="semiBold">
|
|
18
|
+
{label}
|
|
19
|
+
</Text>
|
|
20
|
+
{description ? (
|
|
21
|
+
<Text emphasis="muted" variant="bodySmall">
|
|
22
|
+
{description}
|
|
23
|
+
</Text>
|
|
24
|
+
) : null}
|
|
25
|
+
</Stack>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function startOfLocalDay(value: Date): Date {
|
|
30
|
+
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isSameLocalDay(left: Date | null, right: Date): boolean {
|
|
34
|
+
if (!left) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return startOfLocalDay(left).getTime() === startOfLocalDay(right).getTime();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isBeforeLocalDay(left: Date, right: Date): boolean {
|
|
42
|
+
return startOfLocalDay(left).getTime() < startOfLocalDay(right).getTime();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isAfterLocalDay(left: Date, right: Date): boolean {
|
|
46
|
+
return startOfLocalDay(left).getTime() > startOfLocalDay(right).getTime();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isDateDisabled(
|
|
50
|
+
value: Date,
|
|
51
|
+
minDate: Date | undefined,
|
|
52
|
+
maxDate: Date | undefined,
|
|
53
|
+
): boolean {
|
|
54
|
+
if (minDate && isBeforeLocalDay(value, minDate)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (maxDate && isAfterLocalDay(value, maxDate)) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatMonthLabel(value: Date): string {
|
|
66
|
+
return value.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatDefaultDate(value: Date): string {
|
|
70
|
+
return value.toLocaleDateString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createMonthDays(month: Date): (Date | null)[] {
|
|
74
|
+
const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
|
|
75
|
+
const dayCount = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
|
76
|
+
const days: (Date | null)[] = [];
|
|
77
|
+
|
|
78
|
+
for (let offset = 0; offset < firstDay.getDay(); offset += 1) {
|
|
79
|
+
days.push(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (let day = 1; day <= dayCount; day += 1) {
|
|
83
|
+
days.push(new Date(month.getFullYear(), month.getMonth(), day));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
while (days.length % 7 !== 0) {
|
|
87
|
+
days.push(null);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return days;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveInitialMonth(value: Date | null, minDate: Date | undefined): Date {
|
|
94
|
+
const base = value ?? minDate ?? new Date();
|
|
95
|
+
return new Date(base.getFullYear(), base.getMonth(), 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function canNavigateToMonth(
|
|
99
|
+
month: Date,
|
|
100
|
+
minDate: Date | undefined,
|
|
101
|
+
maxDate: Date | undefined,
|
|
102
|
+
): boolean {
|
|
103
|
+
const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
|
|
104
|
+
const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0);
|
|
105
|
+
|
|
106
|
+
if (maxDate && isAfterLocalDay(firstDay, maxDate)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (minDate && isBeforeLocalDay(lastDay, minDate)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function DatePickerInner({
|
|
118
|
+
themeId: _themeId,
|
|
119
|
+
mode: _mode,
|
|
120
|
+
value,
|
|
121
|
+
onValueChange,
|
|
122
|
+
label,
|
|
123
|
+
description,
|
|
124
|
+
error,
|
|
125
|
+
placeholder = 'Choose date',
|
|
126
|
+
minDate,
|
|
127
|
+
maxDate,
|
|
128
|
+
disabled = false,
|
|
129
|
+
required = false,
|
|
130
|
+
formatDate,
|
|
131
|
+
testID,
|
|
132
|
+
}: DatePickerProps) {
|
|
133
|
+
const [visible, setVisible] = React.useState(false);
|
|
134
|
+
const [displayMonth, setDisplayMonth] = React.useState(() => resolveInitialMonth(value, minDate));
|
|
135
|
+
const monthDays = React.useMemo(() => createMonthDays(displayMonth), [displayMonth]);
|
|
136
|
+
const previousMonth = new Date(displayMonth.getFullYear(), displayMonth.getMonth() - 1, 1);
|
|
137
|
+
const nextMonth = new Date(displayMonth.getFullYear(), displayMonth.getMonth() + 1, 1);
|
|
138
|
+
const displayValue = value
|
|
139
|
+
? formatDate
|
|
140
|
+
? formatDate(value)
|
|
141
|
+
: formatDefaultDate(value)
|
|
142
|
+
: placeholder;
|
|
143
|
+
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
if (visible) {
|
|
146
|
+
setDisplayMonth(resolveInitialMonth(value, minDate));
|
|
147
|
+
}
|
|
148
|
+
}, [minDate, value, visible]);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Field
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
errorText={error}
|
|
154
|
+
invalid={Boolean(error)}
|
|
155
|
+
label={label ? renderLabel(label, description) : undefined}
|
|
156
|
+
required={required}
|
|
157
|
+
testID={testID}
|
|
158
|
+
>
|
|
159
|
+
<Button
|
|
160
|
+
disabled={disabled}
|
|
161
|
+
onPress={() => setVisible(true)}
|
|
162
|
+
testID={testID ? `${testID}-trigger` : undefined}
|
|
163
|
+
trailingIcon={{ name: 'calendar-outline' }}
|
|
164
|
+
variant="outline"
|
|
165
|
+
>
|
|
166
|
+
{displayValue}
|
|
167
|
+
</Button>
|
|
168
|
+
<ActionSheet
|
|
169
|
+
description={description}
|
|
170
|
+
onDismiss={() => setVisible(false)}
|
|
171
|
+
testID={testID ? `${testID}-sheet` : undefined}
|
|
172
|
+
title={label ?? 'Choose date'}
|
|
173
|
+
visible={visible}
|
|
174
|
+
>
|
|
175
|
+
<Stack gap="m">
|
|
176
|
+
<Stack align="center" direction="row" justify="space-between">
|
|
177
|
+
<Button
|
|
178
|
+
disabled={!canNavigateToMonth(previousMonth, minDate, maxDate)}
|
|
179
|
+
onPress={() => setDisplayMonth(previousMonth)}
|
|
180
|
+
size="s"
|
|
181
|
+
variant="ghost"
|
|
182
|
+
>
|
|
183
|
+
Previous
|
|
184
|
+
</Button>
|
|
185
|
+
<Text align="center" variant="label" weight="semiBold">
|
|
186
|
+
{formatMonthLabel(displayMonth)}
|
|
187
|
+
</Text>
|
|
188
|
+
<Button
|
|
189
|
+
disabled={!canNavigateToMonth(nextMonth, minDate, maxDate)}
|
|
190
|
+
onPress={() => setDisplayMonth(nextMonth)}
|
|
191
|
+
size="s"
|
|
192
|
+
variant="ghost"
|
|
193
|
+
>
|
|
194
|
+
Next
|
|
195
|
+
</Button>
|
|
196
|
+
</Stack>
|
|
197
|
+
|
|
198
|
+
<Stack direction="row" justify="space-between">
|
|
199
|
+
{WEEKDAY_LABELS.map((weekday) => (
|
|
200
|
+
<Box key={weekday} style={{ width: DAY_CELL_WIDTH }}>
|
|
201
|
+
<Text align="center" emphasis="muted" variant="caption" weight="semiBold">
|
|
202
|
+
{weekday}
|
|
203
|
+
</Text>
|
|
204
|
+
</Box>
|
|
205
|
+
))}
|
|
206
|
+
</Stack>
|
|
207
|
+
|
|
208
|
+
<Stack direction="row" gap="xs" style={{ flexWrap: 'wrap' }}>
|
|
209
|
+
{monthDays.map((day, index) => {
|
|
210
|
+
if (!day) {
|
|
211
|
+
return <Box key={`empty-${index}`} style={{ width: DAY_CELL_WIDTH }} />;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const selected = isSameLocalDay(value, day);
|
|
215
|
+
const dayDisabled = isDateDisabled(day, minDate, maxDate);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<Box key={day.toISOString()} style={{ width: DAY_CELL_WIDTH }}>
|
|
219
|
+
<Button
|
|
220
|
+
color={selected ? 'primary' : 'neutral'}
|
|
221
|
+
disabled={dayDisabled}
|
|
222
|
+
onPress={() => {
|
|
223
|
+
onValueChange?.(startOfLocalDay(day));
|
|
224
|
+
setVisible(false);
|
|
225
|
+
}}
|
|
226
|
+
size="s"
|
|
227
|
+
testID={testID ? `${testID}-day-${day.getDate()}` : undefined}
|
|
228
|
+
variant={selected ? 'solid' : 'ghost'}
|
|
229
|
+
>
|
|
230
|
+
{day.getDate()}
|
|
231
|
+
</Button>
|
|
232
|
+
</Box>
|
|
233
|
+
);
|
|
234
|
+
})}
|
|
235
|
+
</Stack>
|
|
236
|
+
</Stack>
|
|
237
|
+
</ActionSheet>
|
|
238
|
+
</Field>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const DatePicker = withZoraThemeScope(DatePickerInner);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ZoraComponentMeta } from '../../metadata';
|
|
2
|
+
|
|
3
|
+
export const datePickerMeta = {
|
|
4
|
+
name: 'DatePicker',
|
|
5
|
+
category: 'component',
|
|
6
|
+
directManifestNode: false,
|
|
7
|
+
allowedChildren: [],
|
|
8
|
+
note: 'Code-facing ActionSheet-backed date picker; not represented as a manifest node in v1.',
|
|
9
|
+
props: {},
|
|
10
|
+
} as const satisfies ZoraComponentMeta;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
|
|
4
|
+
|
|
5
|
+
export type DatePickerValue = Date | null;
|
|
6
|
+
|
|
7
|
+
export interface DatePickerProps extends ZoraBaseProps {
|
|
8
|
+
value: DatePickerValue;
|
|
9
|
+
onValueChange?: (value: DatePickerValue) => void;
|
|
10
|
+
label?: React.ReactNode;
|
|
11
|
+
description?: React.ReactNode;
|
|
12
|
+
error?: React.ReactNode;
|
|
13
|
+
placeholder?: React.ReactNode;
|
|
14
|
+
minDate?: Date;
|
|
15
|
+
maxDate?: Date;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
formatDate?: (value: Date) => React.ReactNode;
|
|
19
|
+
testID?: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { Stack } from '../../foundation';
|
|
4
|
+
import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
|
|
5
|
+
import { Button } from '../button';
|
|
6
|
+
import { Text } from '../text';
|
|
7
|
+
import type { PaginationProps } from './types';
|
|
8
|
+
|
|
9
|
+
type PaginationItem = number | 'ellipsis';
|
|
10
|
+
|
|
11
|
+
function clampInteger(value: number, min: number, max: number): number {
|
|
12
|
+
if (!Number.isFinite(value)) {
|
|
13
|
+
return min;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createRange(start: number, end: number): number[] {
|
|
20
|
+
if (end < start) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeCount(value: number | undefined, fallback: number): number {
|
|
28
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Math.max(0, Math.trunc(value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createPaginationItems({
|
|
36
|
+
page,
|
|
37
|
+
pageCount,
|
|
38
|
+
siblingCount,
|
|
39
|
+
boundaryCount,
|
|
40
|
+
compact,
|
|
41
|
+
}: {
|
|
42
|
+
page: number;
|
|
43
|
+
pageCount: number;
|
|
44
|
+
siblingCount: number;
|
|
45
|
+
boundaryCount: number;
|
|
46
|
+
compact: boolean;
|
|
47
|
+
}): readonly PaginationItem[] {
|
|
48
|
+
if (pageCount <= 0) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (compact) {
|
|
53
|
+
return [page];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const boundaries = createRange(1, Math.min(boundaryCount, pageCount));
|
|
57
|
+
const trailingBoundaries = createRange(Math.max(pageCount - boundaryCount + 1, 1), pageCount);
|
|
58
|
+
const siblings = createRange(
|
|
59
|
+
Math.max(page - siblingCount, 1),
|
|
60
|
+
Math.min(page + siblingCount, pageCount),
|
|
61
|
+
);
|
|
62
|
+
const pages = [...new Set([...boundaries, ...siblings, ...trailingBoundaries])].sort(
|
|
63
|
+
(left, right) => left - right,
|
|
64
|
+
);
|
|
65
|
+
const items: PaginationItem[] = [];
|
|
66
|
+
|
|
67
|
+
pages.forEach((nextPage, index) => {
|
|
68
|
+
const previousPage = pages[index - 1];
|
|
69
|
+
if (previousPage !== undefined && nextPage - previousPage > 1) {
|
|
70
|
+
items.push('ellipsis');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
items.push(nextPage);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return items;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function PaginationInner({
|
|
80
|
+
themeId: _themeId,
|
|
81
|
+
mode: _mode,
|
|
82
|
+
page,
|
|
83
|
+
pageCount,
|
|
84
|
+
onPageChange,
|
|
85
|
+
disabled = false,
|
|
86
|
+
siblingCount = 1,
|
|
87
|
+
boundaryCount = 1,
|
|
88
|
+
showFirstLast = false,
|
|
89
|
+
previousLabel = 'Previous',
|
|
90
|
+
nextLabel = 'Next',
|
|
91
|
+
firstLabel = 'First',
|
|
92
|
+
lastLabel = 'Last',
|
|
93
|
+
compact = false,
|
|
94
|
+
testID,
|
|
95
|
+
}: PaginationProps) {
|
|
96
|
+
const normalizedPageCount = Math.max(0, Math.trunc(Number.isFinite(pageCount) ? pageCount : 0));
|
|
97
|
+
const currentPage = clampInteger(page, 1, Math.max(normalizedPageCount, 1));
|
|
98
|
+
const safeSiblingCount = normalizeCount(siblingCount, 1);
|
|
99
|
+
const safeBoundaryCount = normalizeCount(boundaryCount, 1);
|
|
100
|
+
const items = createPaginationItems({
|
|
101
|
+
boundaryCount: safeBoundaryCount,
|
|
102
|
+
compact,
|
|
103
|
+
page: currentPage,
|
|
104
|
+
pageCount: normalizedPageCount,
|
|
105
|
+
siblingCount: safeSiblingCount,
|
|
106
|
+
});
|
|
107
|
+
const canGoPrevious = currentPage > 1;
|
|
108
|
+
const canGoNext = currentPage < normalizedPageCount;
|
|
109
|
+
const navigationDisabled = disabled || normalizedPageCount <= 1;
|
|
110
|
+
|
|
111
|
+
const changePage = (nextPage: number) => {
|
|
112
|
+
const clampedPage = clampInteger(nextPage, 1, Math.max(normalizedPageCount, 1));
|
|
113
|
+
if (disabled || clampedPage === currentPage) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
onPageChange?.(clampedPage);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Stack align="center" direction="row" gap="xs" testID={testID}>
|
|
122
|
+
{showFirstLast ? (
|
|
123
|
+
<Button
|
|
124
|
+
disabled={navigationDisabled || currentPage === 1}
|
|
125
|
+
onPress={() => changePage(1)}
|
|
126
|
+
size="s"
|
|
127
|
+
testID={testID ? `${testID}-first` : undefined}
|
|
128
|
+
variant="ghost"
|
|
129
|
+
>
|
|
130
|
+
{firstLabel}
|
|
131
|
+
</Button>
|
|
132
|
+
) : null}
|
|
133
|
+
|
|
134
|
+
<Button
|
|
135
|
+
disabled={navigationDisabled || !canGoPrevious}
|
|
136
|
+
onPress={() => changePage(currentPage - 1)}
|
|
137
|
+
size="s"
|
|
138
|
+
testID={testID ? `${testID}-previous` : undefined}
|
|
139
|
+
variant="ghost"
|
|
140
|
+
>
|
|
141
|
+
{previousLabel}
|
|
142
|
+
</Button>
|
|
143
|
+
|
|
144
|
+
{items.map((item, index) =>
|
|
145
|
+
item === 'ellipsis' ? (
|
|
146
|
+
<Text key={`ellipsis-${index}`} emphasis="muted" variant="bodySmall">
|
|
147
|
+
…
|
|
148
|
+
</Text>
|
|
149
|
+
) : (
|
|
150
|
+
<Button
|
|
151
|
+
color={item === currentPage ? 'primary' : 'neutral'}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
key={item}
|
|
154
|
+
onPress={() => changePage(item)}
|
|
155
|
+
size="s"
|
|
156
|
+
testID={testID ? `${testID}-page-${item}` : undefined}
|
|
157
|
+
variant={item === currentPage ? 'solid' : 'ghost'}
|
|
158
|
+
>
|
|
159
|
+
{item}
|
|
160
|
+
</Button>
|
|
161
|
+
),
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
<Button
|
|
165
|
+
disabled={navigationDisabled || !canGoNext}
|
|
166
|
+
onPress={() => changePage(currentPage + 1)}
|
|
167
|
+
size="s"
|
|
168
|
+
testID={testID ? `${testID}-next` : undefined}
|
|
169
|
+
variant="ghost"
|
|
170
|
+
>
|
|
171
|
+
{nextLabel}
|
|
172
|
+
</Button>
|
|
173
|
+
|
|
174
|
+
{showFirstLast ? (
|
|
175
|
+
<Button
|
|
176
|
+
disabled={navigationDisabled || currentPage === normalizedPageCount}
|
|
177
|
+
onPress={() => changePage(normalizedPageCount)}
|
|
178
|
+
size="s"
|
|
179
|
+
testID={testID ? `${testID}-last` : undefined}
|
|
180
|
+
variant="ghost"
|
|
181
|
+
>
|
|
182
|
+
{lastLabel}
|
|
183
|
+
</Button>
|
|
184
|
+
) : null}
|
|
185
|
+
</Stack>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const Pagination = withZoraThemeScope(PaginationInner);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ZoraComponentMeta } from '../../metadata';
|
|
2
|
+
|
|
3
|
+
export const paginationMeta = {
|
|
4
|
+
name: 'Pagination',
|
|
5
|
+
category: 'component',
|
|
6
|
+
directManifestNode: false,
|
|
7
|
+
allowedChildren: [],
|
|
8
|
+
note: 'Code-facing controlled pagination component; not represented as a manifest node in v1.',
|
|
9
|
+
props: {},
|
|
10
|
+
} as const satisfies ZoraComponentMeta;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
|
|
4
|
+
|
|
5
|
+
export interface PaginationProps extends ZoraBaseProps {
|
|
6
|
+
page: number;
|
|
7
|
+
pageCount: number;
|
|
8
|
+
onPageChange?: (page: number) => void;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
siblingCount?: number;
|
|
11
|
+
boundaryCount?: number;
|
|
12
|
+
showFirstLast?: boolean;
|
|
13
|
+
previousLabel?: React.ReactNode;
|
|
14
|
+
nextLabel?: React.ReactNode;
|
|
15
|
+
firstLabel?: React.ReactNode;
|
|
16
|
+
lastLabel?: React.ReactNode;
|
|
17
|
+
compact?: boolean;
|
|
18
|
+
testID?: string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Field } from '@ankhorage/surface';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { ScrollView } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { Stack } from '../../foundation';
|
|
6
|
+
import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
|
|
7
|
+
import { ActionSheet, ActionSheetItem } from '../action-sheet';
|
|
8
|
+
import { Button } from '../button';
|
|
9
|
+
import { Text } from '../text';
|
|
10
|
+
import type { TimePickerProps } from './types';
|
|
11
|
+
|
|
12
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
13
|
+
const DEFAULT_STEP_MINUTES = 30;
|
|
14
|
+
|
|
15
|
+
function renderLabel(label: React.ReactNode, description: React.ReactNode | undefined) {
|
|
16
|
+
return (
|
|
17
|
+
<Stack gap="xs">
|
|
18
|
+
<Text variant="label" weight="semiBold">
|
|
19
|
+
{label}
|
|
20
|
+
</Text>
|
|
21
|
+
{description ? (
|
|
22
|
+
<Text emphasis="muted" variant="bodySmall">
|
|
23
|
+
{description}
|
|
24
|
+
</Text>
|
|
25
|
+
) : null}
|
|
26
|
+
</Stack>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseTimeToMinutes(value: string | undefined): number | undefined {
|
|
31
|
+
if (!value) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
|
36
|
+
if (!match) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hours = Number(match[1]);
|
|
41
|
+
const minutes = Number(match[2]);
|
|
42
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return hours * 60 + minutes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatMinutes(value: number): string {
|
|
50
|
+
const hours = Math.floor(value / 60);
|
|
51
|
+
const minutes = value % 60;
|
|
52
|
+
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveStepMinutes(stepMinutes: number | undefined): number {
|
|
56
|
+
if (stepMinutes === undefined || !Number.isFinite(stepMinutes) || stepMinutes <= 0) {
|
|
57
|
+
return DEFAULT_STEP_MINUTES;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return Math.max(1, Math.floor(stepMinutes));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function generateTimeOptions({
|
|
64
|
+
minTime,
|
|
65
|
+
maxTime,
|
|
66
|
+
stepMinutes,
|
|
67
|
+
}: Pick<TimePickerProps, 'maxTime' | 'minTime' | 'stepMinutes'>): string[] {
|
|
68
|
+
const step = resolveStepMinutes(stepMinutes);
|
|
69
|
+
const min = parseTimeToMinutes(minTime) ?? 0;
|
|
70
|
+
const max = parseTimeToMinutes(maxTime) ?? MINUTES_PER_DAY - 1;
|
|
71
|
+
const options: string[] = [];
|
|
72
|
+
|
|
73
|
+
for (let minute = 0; minute < MINUTES_PER_DAY; minute += step) {
|
|
74
|
+
if (minute >= min && minute <= max) {
|
|
75
|
+
options.push(formatMinutes(minute));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return options;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function TimePickerInner({
|
|
83
|
+
themeId: _themeId,
|
|
84
|
+
mode: _mode,
|
|
85
|
+
value,
|
|
86
|
+
onValueChange,
|
|
87
|
+
label,
|
|
88
|
+
description,
|
|
89
|
+
error,
|
|
90
|
+
placeholder = 'Choose time',
|
|
91
|
+
minTime,
|
|
92
|
+
maxTime,
|
|
93
|
+
stepMinutes,
|
|
94
|
+
disabled = false,
|
|
95
|
+
required = false,
|
|
96
|
+
formatTime,
|
|
97
|
+
testID,
|
|
98
|
+
}: TimePickerProps) {
|
|
99
|
+
const [visible, setVisible] = React.useState(false);
|
|
100
|
+
const options = React.useMemo(
|
|
101
|
+
() => generateTimeOptions({ maxTime, minTime, stepMinutes }),
|
|
102
|
+
[maxTime, minTime, stepMinutes],
|
|
103
|
+
);
|
|
104
|
+
const displayValue = value ? (formatTime ? formatTime(value) : value) : placeholder;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Field
|
|
108
|
+
disabled={disabled}
|
|
109
|
+
errorText={error}
|
|
110
|
+
invalid={Boolean(error)}
|
|
111
|
+
label={label ? renderLabel(label, description) : undefined}
|
|
112
|
+
required={required}
|
|
113
|
+
testID={testID}
|
|
114
|
+
>
|
|
115
|
+
<Button
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
onPress={() => setVisible(true)}
|
|
118
|
+
testID={testID ? `${testID}-trigger` : undefined}
|
|
119
|
+
trailingIcon={{ name: 'chevron-down-outline' }}
|
|
120
|
+
variant="outline"
|
|
121
|
+
>
|
|
122
|
+
{displayValue}
|
|
123
|
+
</Button>
|
|
124
|
+
<ActionSheet
|
|
125
|
+
description={description}
|
|
126
|
+
onDismiss={() => setVisible(false)}
|
|
127
|
+
testID={testID ? `${testID}-sheet` : undefined}
|
|
128
|
+
title={label ?? 'Choose time'}
|
|
129
|
+
visible={visible}
|
|
130
|
+
>
|
|
131
|
+
<ScrollView style={{ maxHeight: 360 }}>
|
|
132
|
+
<Stack gap="xxs">
|
|
133
|
+
{options.map((option) => (
|
|
134
|
+
<ActionSheetItem
|
|
135
|
+
key={option}
|
|
136
|
+
label={formatTime ? formatTime(option) : option}
|
|
137
|
+
onPress={() => {
|
|
138
|
+
onValueChange?.(option);
|
|
139
|
+
setVisible(false);
|
|
140
|
+
}}
|
|
141
|
+
selected={option === value}
|
|
142
|
+
testID={testID ? `${testID}-option-${option}` : undefined}
|
|
143
|
+
/>
|
|
144
|
+
))}
|
|
145
|
+
</Stack>
|
|
146
|
+
</ScrollView>
|
|
147
|
+
</ActionSheet>
|
|
148
|
+
</Field>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const TimePicker = withZoraThemeScope(TimePickerInner);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ZoraComponentMeta } from '../../metadata';
|
|
2
|
+
|
|
3
|
+
export const timePickerMeta = {
|
|
4
|
+
name: 'TimePicker',
|
|
5
|
+
category: 'component',
|
|
6
|
+
directManifestNode: false,
|
|
7
|
+
allowedChildren: [],
|
|
8
|
+
note: 'Code-facing ActionSheet-backed time picker; not represented as a manifest node in v1.',
|
|
9
|
+
props: {},
|
|
10
|
+
} as const satisfies ZoraComponentMeta;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
|
|
4
|
+
|
|
5
|
+
export type TimePickerValue = string | null;
|
|
6
|
+
|
|
7
|
+
export interface TimePickerProps extends ZoraBaseProps {
|
|
8
|
+
value: TimePickerValue;
|
|
9
|
+
onValueChange?: (value: TimePickerValue) => void;
|
|
10
|
+
label?: React.ReactNode;
|
|
11
|
+
description?: React.ReactNode;
|
|
12
|
+
error?: React.ReactNode;
|
|
13
|
+
placeholder?: React.ReactNode;
|
|
14
|
+
minTime?: string;
|
|
15
|
+
maxTime?: string;
|
|
16
|
+
stepMinutes?: number;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
formatTime?: (value: string) => React.ReactNode;
|
|
20
|
+
testID?: string;
|
|
21
|
+
}
|