@lotics/ui 1.11.1 → 1.13.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/package.json +7 -4
- package/src/accordion.tsx +97 -0
- package/src/button.tsx +1 -1
- package/src/calendar/calendar_view.tsx +5 -1
- package/src/calendar/month_view.tsx +40 -4
- package/src/cell_date_format.test.ts +32 -0
- package/src/cell_date_format.ts +28 -3
- package/src/checkbox_input.tsx +8 -3
- package/src/css_modules.d.ts +2 -0
- package/src/date_calendar.tsx +679 -0
- package/src/date_field.tsx +172 -0
- package/src/date_picker.tsx +403 -28
- package/src/date_picker_value.test.ts +167 -0
- package/src/date_picker_value.ts +128 -0
- package/src/date_segments.test.ts +206 -0
- package/src/date_segments.ts +347 -0
- package/src/date_segments_field.tsx +418 -0
- package/src/gantt/gantt_view.tsx +31 -5
- package/src/icon.tsx +2 -0
- package/src/menu_button.tsx +1 -1
- package/src/menu_list_item.tsx +1 -1
- package/src/popover.tsx +28 -2
- package/src/radio_picker.tsx +1 -1
- package/src/stepper.tsx +83 -0
- package/src/switch.tsx +1 -1
- package/src/switch_button.tsx +1 -1
- package/src/tabs.tsx +1 -1
- package/src/time_field.tsx +300 -0
- package/src/use_pointer_drag.ts +99 -0
- package/src/datetime_picker.tsx +0 -44
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
NativeSyntheticEvent,
|
|
4
|
+
Platform,
|
|
5
|
+
StyleProp,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
TextInput as RNTextInput,
|
|
8
|
+
TextInputKeyPressEventData,
|
|
9
|
+
View,
|
|
10
|
+
ViewStyle,
|
|
11
|
+
} from "react-native";
|
|
12
|
+
import { colors } from "./colors";
|
|
13
|
+
import { Text } from "./text";
|
|
14
|
+
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
15
|
+
import {
|
|
16
|
+
SegmentBuffer,
|
|
17
|
+
SegmentLabels,
|
|
18
|
+
SegmentType,
|
|
19
|
+
SegmentsConfig,
|
|
20
|
+
displayValue,
|
|
21
|
+
fieldOrder,
|
|
22
|
+
incrementSegment,
|
|
23
|
+
placeholderFor,
|
|
24
|
+
setHourField,
|
|
25
|
+
to12h,
|
|
26
|
+
typeDigit,
|
|
27
|
+
withDayPeriod,
|
|
28
|
+
} from "./date_segments";
|
|
29
|
+
|
|
30
|
+
export interface DateSegmentsProps {
|
|
31
|
+
/** Canonical value (`YYYY-MM-DD` / `YYYY-MM-DDTHH:mm`), `""` when empty. */
|
|
32
|
+
value: string;
|
|
33
|
+
onChange: (value: string) => void;
|
|
34
|
+
/** Layout + value↔buffer mapping; see `dateSegmentsConfig`. */
|
|
35
|
+
config: SegmentsConfig;
|
|
36
|
+
/** Accessible names per segment. */
|
|
37
|
+
segmentLabels: SegmentLabels;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
autoFocus?: boolean;
|
|
40
|
+
testID?: string;
|
|
41
|
+
/** Accessible name for the segment group (e.g. "Start date"). */
|
|
42
|
+
accessibilityLabel?: string;
|
|
43
|
+
/** Fires when a segment in this group gains focus. */
|
|
44
|
+
onFocus?: () => void;
|
|
45
|
+
/** Fires when a segment in this group loses focus. */
|
|
46
|
+
onBlur?: () => void;
|
|
47
|
+
style?: StyleProp<ViewStyle>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- buffer transforms (pure) -----------------------------------------------
|
|
51
|
+
|
|
52
|
+
function applyField(
|
|
53
|
+
buffer: SegmentBuffer,
|
|
54
|
+
type: SegmentType,
|
|
55
|
+
value: number,
|
|
56
|
+
hour12: boolean,
|
|
57
|
+
): SegmentBuffer {
|
|
58
|
+
switch (type) {
|
|
59
|
+
case "year":
|
|
60
|
+
return { ...buffer, year: value };
|
|
61
|
+
case "month":
|
|
62
|
+
return { ...buffer, month: value };
|
|
63
|
+
case "day":
|
|
64
|
+
return { ...buffer, day: value };
|
|
65
|
+
case "minute":
|
|
66
|
+
return { ...buffer, minute: value };
|
|
67
|
+
case "hour":
|
|
68
|
+
return { ...buffer, hour: setHourField(buffer, value, hour12) };
|
|
69
|
+
case "dayPeriod":
|
|
70
|
+
return buffer;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearField(buffer: SegmentBuffer, type: SegmentType): SegmentBuffer {
|
|
75
|
+
switch (type) {
|
|
76
|
+
case "year":
|
|
77
|
+
return { ...buffer, year: null };
|
|
78
|
+
case "month":
|
|
79
|
+
return { ...buffer, month: null };
|
|
80
|
+
case "day":
|
|
81
|
+
return { ...buffer, day: null };
|
|
82
|
+
case "minute":
|
|
83
|
+
return { ...buffer, minute: null };
|
|
84
|
+
case "hour":
|
|
85
|
+
return { ...buffer, hour: null };
|
|
86
|
+
case "dayPeriod":
|
|
87
|
+
return buffer;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fieldNumeric(type: SegmentType, buffer: SegmentBuffer, hour12: boolean): number | null {
|
|
92
|
+
switch (type) {
|
|
93
|
+
case "year":
|
|
94
|
+
return buffer.year;
|
|
95
|
+
case "month":
|
|
96
|
+
return buffer.month;
|
|
97
|
+
case "day":
|
|
98
|
+
return buffer.day;
|
|
99
|
+
case "minute":
|
|
100
|
+
return buffer.minute;
|
|
101
|
+
case "hour":
|
|
102
|
+
return buffer.hour == null ? null : hour12 ? to12h(buffer.hour).h12 : buffer.hour;
|
|
103
|
+
case "dayPeriod":
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Borderless segmented date / datetime editor — one editable segment per locale
|
|
110
|
+
* field (in locale order, 12/24h derived) plus literal separators. Emits a canonical
|
|
111
|
+
* ISO value only once the segments form a complete, valid date. The bordered frame,
|
|
112
|
+
* focus ring, and calendar button live in {@link DateField}, which composes one (or
|
|
113
|
+
* two, for a range) of these.
|
|
114
|
+
*
|
|
115
|
+
* Segment editing (digits, arrows, AM/PM) is keyboard-driven on web. On native the
|
|
116
|
+
* segments are plain numeric inputs — the calendar/sheet is the primary path there.
|
|
117
|
+
*/
|
|
118
|
+
export function DateSegments(props: DateSegmentsProps) {
|
|
119
|
+
const {
|
|
120
|
+
value,
|
|
121
|
+
onChange,
|
|
122
|
+
config,
|
|
123
|
+
segmentLabels,
|
|
124
|
+
disabled,
|
|
125
|
+
autoFocus,
|
|
126
|
+
testID,
|
|
127
|
+
accessibilityLabel,
|
|
128
|
+
onFocus,
|
|
129
|
+
onBlur,
|
|
130
|
+
style,
|
|
131
|
+
} = props;
|
|
132
|
+
|
|
133
|
+
const layout = config.layout;
|
|
134
|
+
const order = useMemo(() => fieldOrder(layout), [layout]);
|
|
135
|
+
const firstFieldIndex = useMemo(
|
|
136
|
+
() => layout.segments.findIndex((s) => s.kind === "field"),
|
|
137
|
+
[layout],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// The field owns an editing buffer (the resolved value is its output). It is
|
|
141
|
+
// resynced when `value` changes from outside (calendar pick, parent reset) but
|
|
142
|
+
// not while the user types a still-incomplete value — same controlled-with-buffer
|
|
143
|
+
// shape as TimePicker's draft.
|
|
144
|
+
const [buffer, setBuffer] = useState<SegmentBuffer>(() => config.toBuffer(value));
|
|
145
|
+
const [activeType, setActiveType] = useState<SegmentType | null>(null);
|
|
146
|
+
const [activeText, setActiveText] = useState("");
|
|
147
|
+
|
|
148
|
+
const lastEmitted = useRef<string>(value);
|
|
149
|
+
const fieldRefs = useRef<Partial<Record<SegmentType, RNTextInput | null>>>({});
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (value !== lastEmitted.current) {
|
|
153
|
+
lastEmitted.current = value;
|
|
154
|
+
setBuffer(config.toBuffer(value));
|
|
155
|
+
setActiveText("");
|
|
156
|
+
}
|
|
157
|
+
}, [value, config]);
|
|
158
|
+
|
|
159
|
+
const commit = useCallback(
|
|
160
|
+
(next: SegmentBuffer) => {
|
|
161
|
+
const out = config.toValue(next);
|
|
162
|
+
if (out !== null) {
|
|
163
|
+
lastEmitted.current = out;
|
|
164
|
+
onChange(out);
|
|
165
|
+
} else if (config.isEmpty(next)) {
|
|
166
|
+
lastEmitted.current = "";
|
|
167
|
+
onChange("");
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
[config, onChange],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const focusField = useCallback((type: SegmentType | undefined) => {
|
|
174
|
+
if (type) fieldRefs.current[type]?.focus();
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const focusNext = useCallback(
|
|
178
|
+
(type: SegmentType) => focusField(order[order.indexOf(type) + 1]),
|
|
179
|
+
[order, focusField],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const focusPrev = useCallback(
|
|
183
|
+
(type: SegmentType) => {
|
|
184
|
+
const i = order.indexOf(type);
|
|
185
|
+
if (i > 0) focusField(order[i - 1]);
|
|
186
|
+
},
|
|
187
|
+
[order, focusField],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const handleDigit = useCallback(
|
|
191
|
+
(type: SegmentType, digit: string) => {
|
|
192
|
+
if (type === "dayPeriod") return;
|
|
193
|
+
const currentText = activeType === type ? activeText : "";
|
|
194
|
+
const { text, value: v, complete } = typeDigit(type, currentText, digit, layout.hour12);
|
|
195
|
+
const next = applyField(buffer, type, v, layout.hour12);
|
|
196
|
+
setBuffer(next);
|
|
197
|
+
commit(next);
|
|
198
|
+
setActiveType(type);
|
|
199
|
+
if (complete) {
|
|
200
|
+
setActiveText("");
|
|
201
|
+
focusNext(type);
|
|
202
|
+
} else {
|
|
203
|
+
setActiveText(text);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
[activeType, activeText, buffer, layout.hour12, commit, focusNext],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const handleStep = useCallback(
|
|
210
|
+
(type: SegmentType, delta: number) => {
|
|
211
|
+
let next: SegmentBuffer;
|
|
212
|
+
if (type === "dayPeriod") {
|
|
213
|
+
const pm = buffer.hour == null ? false : to12h(buffer.hour).pm;
|
|
214
|
+
next = { ...buffer, hour: withDayPeriod(buffer, !pm) };
|
|
215
|
+
} else {
|
|
216
|
+
const value = incrementSegment(
|
|
217
|
+
type,
|
|
218
|
+
fieldNumeric(type, buffer, layout.hour12),
|
|
219
|
+
delta,
|
|
220
|
+
layout.hour12,
|
|
221
|
+
);
|
|
222
|
+
next = applyField(buffer, type, value, layout.hour12);
|
|
223
|
+
}
|
|
224
|
+
setBuffer(next);
|
|
225
|
+
commit(next);
|
|
226
|
+
setActiveType(type);
|
|
227
|
+
setActiveText("");
|
|
228
|
+
},
|
|
229
|
+
[buffer, layout.hour12, commit],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const handleBackspace = useCallback(
|
|
233
|
+
(type: SegmentType) => {
|
|
234
|
+
if (activeType === type && activeText !== "") {
|
|
235
|
+
const text = activeText.slice(0, -1);
|
|
236
|
+
setActiveText(text);
|
|
237
|
+
const next =
|
|
238
|
+
text === ""
|
|
239
|
+
? clearField(buffer, type)
|
|
240
|
+
: applyField(buffer, type, Number(text), layout.hour12);
|
|
241
|
+
setBuffer(next);
|
|
242
|
+
commit(next);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (fieldNumeric(type, buffer, layout.hour12) == null) {
|
|
246
|
+
focusPrev(type);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const next = clearField(buffer, type);
|
|
250
|
+
setBuffer(next);
|
|
251
|
+
commit(next);
|
|
252
|
+
setActiveType(type);
|
|
253
|
+
setActiveText("");
|
|
254
|
+
},
|
|
255
|
+
[activeType, activeText, buffer, layout.hour12, commit, focusPrev],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const setHalfDay = useCallback(
|
|
259
|
+
(pm: boolean) => {
|
|
260
|
+
const next = { ...buffer, hour: withDayPeriod(buffer, pm) };
|
|
261
|
+
setBuffer(next);
|
|
262
|
+
commit(next);
|
|
263
|
+
setActiveText("");
|
|
264
|
+
},
|
|
265
|
+
[buffer, commit],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const handleKeyPress = useCallback(
|
|
269
|
+
(type: SegmentType, e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
270
|
+
const key = e.nativeEvent.key;
|
|
271
|
+
if (/^[0-9]$/.test(key)) {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
handleDigit(type, key);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
switch (key) {
|
|
277
|
+
case "ArrowUp":
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
handleStep(type, 1);
|
|
280
|
+
break;
|
|
281
|
+
case "ArrowDown":
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
handleStep(type, -1);
|
|
284
|
+
break;
|
|
285
|
+
case "ArrowLeft":
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
focusPrev(type);
|
|
288
|
+
break;
|
|
289
|
+
case "ArrowRight":
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
focusNext(type);
|
|
292
|
+
break;
|
|
293
|
+
case "Backspace":
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
handleBackspace(type);
|
|
296
|
+
break;
|
|
297
|
+
default:
|
|
298
|
+
if (type === "dayPeriod") {
|
|
299
|
+
const k = key.toLowerCase();
|
|
300
|
+
if (k === "a") {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
setHalfDay(false);
|
|
303
|
+
} else if (k === "p") {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
setHalfDay(true);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
[handleDigit, handleStep, handleBackspace, focusPrev, focusNext, setHalfDay],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const handleChangeText = useCallback(
|
|
314
|
+
(type: SegmentType, txt: string) => {
|
|
315
|
+
// Web digit/arrow keys are handled (and prevented) in onKeyPress, so this fires
|
|
316
|
+
// only for a paste or for native typing.
|
|
317
|
+
if (txt.length > 1 || /[^0-9]/.test(txt)) {
|
|
318
|
+
const parsed = config.parse(txt);
|
|
319
|
+
if (parsed) {
|
|
320
|
+
const next = config.toBuffer(parsed);
|
|
321
|
+
setBuffer(next);
|
|
322
|
+
commit(next);
|
|
323
|
+
setActiveText("");
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (Platform.OS !== "web" && txt) handleDigit(type, txt);
|
|
328
|
+
},
|
|
329
|
+
[config, commit, handleDigit],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const handleFocus = useCallback(
|
|
333
|
+
(type: SegmentType) => {
|
|
334
|
+
setActiveType(type);
|
|
335
|
+
setActiveText("");
|
|
336
|
+
onFocus?.();
|
|
337
|
+
},
|
|
338
|
+
[onFocus],
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const handleBlur = useCallback(() => {
|
|
342
|
+
setActiveType(null);
|
|
343
|
+
setActiveText("");
|
|
344
|
+
onBlur?.();
|
|
345
|
+
}, [onBlur]);
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<View style={[styles.segments, style]} testID={testID} accessibilityLabel={accessibilityLabel}>
|
|
349
|
+
{layout.segments.map((seg, i) => {
|
|
350
|
+
if (seg.kind === "literal") {
|
|
351
|
+
return (
|
|
352
|
+
<Text key={i} size="sm" color="muted">
|
|
353
|
+
{seg.value}
|
|
354
|
+
</Text>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const type = seg.type;
|
|
358
|
+
const committed = displayValue(type, buffer, layout);
|
|
359
|
+
const isActive = activeType === type && activeText !== "";
|
|
360
|
+
const text = isActive ? activeText : (committed ?? "");
|
|
361
|
+
return (
|
|
362
|
+
<RNTextInput
|
|
363
|
+
key={i}
|
|
364
|
+
ref={(node) => {
|
|
365
|
+
fieldRefs.current[type] = node;
|
|
366
|
+
}}
|
|
367
|
+
value={text}
|
|
368
|
+
placeholder={placeholderFor(type)}
|
|
369
|
+
placeholderTextColor={colors.zinc["400"]}
|
|
370
|
+
editable={!disabled}
|
|
371
|
+
inputMode={type === "dayPeriod" ? "text" : "numeric"}
|
|
372
|
+
autoFocus={autoFocus && i === firstFieldIndex}
|
|
373
|
+
role={Platform.OS === "web" ? "spinbutton" : undefined}
|
|
374
|
+
aria-label={segmentLabels[type]}
|
|
375
|
+
onKeyPress={(e) => handleKeyPress(type, e)}
|
|
376
|
+
onChangeText={(t) => handleChangeText(type, t)}
|
|
377
|
+
onFocus={() => handleFocus(type)}
|
|
378
|
+
onBlur={handleBlur}
|
|
379
|
+
style={[
|
|
380
|
+
styles.segment,
|
|
381
|
+
type === "year" ? styles.year : type === "dayPeriod" ? styles.ampm : styles.twoDigit,
|
|
382
|
+
disabled && styles.segmentDisabled,
|
|
383
|
+
]}
|
|
384
|
+
/>
|
|
385
|
+
);
|
|
386
|
+
})}
|
|
387
|
+
</View>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const styles = StyleSheet.create({
|
|
392
|
+
segments: {
|
|
393
|
+
flexDirection: "row",
|
|
394
|
+
alignItems: "center",
|
|
395
|
+
},
|
|
396
|
+
segment: {
|
|
397
|
+
...getInputTextStyle(),
|
|
398
|
+
fontFamily: fontFamilyRegular,
|
|
399
|
+
letterSpacing: -0.4,
|
|
400
|
+
color: colors.zinc["900"],
|
|
401
|
+
textAlign: "center",
|
|
402
|
+
paddingVertical: 0,
|
|
403
|
+
paddingHorizontal: 0,
|
|
404
|
+
outlineStyle: "none" as unknown as "solid",
|
|
405
|
+
},
|
|
406
|
+
segmentDisabled: {
|
|
407
|
+
color: colors.zinc["400"],
|
|
408
|
+
},
|
|
409
|
+
twoDigit: {
|
|
410
|
+
width: 26,
|
|
411
|
+
},
|
|
412
|
+
year: {
|
|
413
|
+
width: 38,
|
|
414
|
+
},
|
|
415
|
+
ampm: {
|
|
416
|
+
width: 28,
|
|
417
|
+
},
|
|
418
|
+
});
|
package/src/gantt/gantt_view.tsx
CHANGED
|
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import { View, ScrollView, Pressable, StyleSheet } from "react-native";
|
|
3
3
|
import { Text } from "../text";
|
|
4
4
|
import { colors } from "../colors";
|
|
5
|
-
import { dayDiff } from "../calendar/dates";
|
|
5
|
+
import { addDays, dayDiff } from "../calendar/dates";
|
|
6
|
+
import { usePointerDrag } from "../use_pointer_drag";
|
|
6
7
|
import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
|
|
7
8
|
import { DEFAULT_GANTT_LABELS } from "./types";
|
|
8
9
|
import type { GanttLabels, GanttScale, GanttTask } from "./types";
|
|
@@ -11,6 +12,8 @@ const LABEL_W = 188;
|
|
|
11
12
|
const HEADER_H = 38;
|
|
12
13
|
const ROW_H = 40;
|
|
13
14
|
const BAR_H = 22;
|
|
15
|
+
const HANDLE_W = 8;
|
|
16
|
+
const MIN_BAR = 8;
|
|
14
17
|
const SCALES: GanttScale[] = ["day", "week", "month"];
|
|
15
18
|
|
|
16
19
|
export interface GanttViewProps<T = unknown> {
|
|
@@ -23,6 +26,9 @@ export interface GanttViewProps<T = unknown> {
|
|
|
23
26
|
/** User-facing chrome strings; defaults to English. */
|
|
24
27
|
labels?: Partial<GanttLabels>;
|
|
25
28
|
onTaskPress?: (task: GanttTask<T>) => void;
|
|
29
|
+
/** When set, each bar gets a right-edge resize handle; dragging it adjusts the
|
|
30
|
+
* task's end. Receives the task + the new inclusive end day (clamped ≥ start). */
|
|
31
|
+
onTaskResize?: (task: GanttTask<T>, newEnd: Date) => void;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
/**
|
|
@@ -33,10 +39,20 @@ export interface GanttViewProps<T = unknown> {
|
|
|
33
39
|
* — wrap in a ScrollView for very long task lists.
|
|
34
40
|
*/
|
|
35
41
|
export function GanttView<T = unknown>(props: GanttViewProps<T>) {
|
|
36
|
-
const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress } = props;
|
|
42
|
+
const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress, onTaskResize } = props;
|
|
37
43
|
const L = { ...DEFAULT_GANTT_LABELS, ...props.labels };
|
|
38
44
|
const [scale, setScale] = useState<GanttScale>(defaultScale);
|
|
39
45
|
|
|
46
|
+
const taskById = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
|
|
47
|
+
const { live, bind } = usePointerDrag((id, _pointer, delta) => {
|
|
48
|
+
const t = taskById.get(id);
|
|
49
|
+
if (!t || !onTaskResize) return;
|
|
50
|
+
const days = Math.round(delta.dx / pxPerDay(scale));
|
|
51
|
+
if (days === 0) return;
|
|
52
|
+
const newEnd = addDays(t.end ?? t.start, days);
|
|
53
|
+
onTaskResize(t, newEnd < t.start ? t.start : newEnd);
|
|
54
|
+
});
|
|
55
|
+
|
|
40
56
|
const { start: axisStart, end: axisEnd } = useMemo(() => axisRange(tasks, today), [tasks, today]);
|
|
41
57
|
const ticks = useMemo(() => buildTicks(axisStart, axisEnd, scale, locale), [axisStart, axisEnd, scale, locale]);
|
|
42
58
|
const axisWidth = useMemo(() => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale), [axisStart, axisEnd, scale]);
|
|
@@ -100,7 +116,9 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
|
|
|
100
116
|
|
|
101
117
|
{/* Task rows */}
|
|
102
118
|
{tasks.map((t) => {
|
|
103
|
-
const
|
|
119
|
+
const base = barGeometry(t, axisStart, scale);
|
|
120
|
+
const dragging = live && live.id === t.id ? live : null;
|
|
121
|
+
const width = dragging ? Math.max(MIN_BAR, base.width + dragging.dx) : base.width;
|
|
104
122
|
const accent = t.color || colors.teal[600];
|
|
105
123
|
return (
|
|
106
124
|
<View key={t.id} style={{ height: ROW_H, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] }}>
|
|
@@ -113,12 +131,20 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
|
|
|
113
131
|
onPress={onTaskPress ? () => onTaskPress(t) : undefined}
|
|
114
132
|
accessibilityRole={onTaskPress ? "button" : undefined}
|
|
115
133
|
accessibilityLabel={t.label}
|
|
116
|
-
style={{ position: "absolute", left:
|
|
134
|
+
style={{ position: "absolute", left: base.left, width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
|
|
117
135
|
>
|
|
118
|
-
<View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
|
|
136
|
+
<View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8, opacity: dragging ? 0.85 : 1 }}>
|
|
119
137
|
<Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>{t.label}</Text>
|
|
120
138
|
</View>
|
|
121
139
|
</Pressable>
|
|
140
|
+
{onTaskResize ? (
|
|
141
|
+
<View
|
|
142
|
+
ref={bind(t.id, "ew-resize")}
|
|
143
|
+
style={{ position: "absolute", left: base.left + width - HANDLE_W, width: HANDLE_W + 6, top: (ROW_H - BAR_H) / 2, height: BAR_H, alignItems: "center", justifyContent: "center", zIndex: 2 }}
|
|
144
|
+
>
|
|
145
|
+
<View style={{ width: 3, height: 11, borderRadius: 2, backgroundColor: "rgba(255,255,255,0.75)" }} />
|
|
146
|
+
</View>
|
|
147
|
+
) : null}
|
|
122
148
|
</View>
|
|
123
149
|
);
|
|
124
150
|
})}
|
package/src/icon.tsx
CHANGED
|
@@ -31,6 +31,7 @@ import Ban from "lucide-react-native/dist/esm/icons/ban";
|
|
|
31
31
|
import Bell from "lucide-react-native/dist/esm/icons/bell";
|
|
32
32
|
import Bolt from "lucide-react-native/dist/esm/icons/bolt";
|
|
33
33
|
import Brain from "lucide-react-native/dist/esm/icons/brain";
|
|
34
|
+
import Building2 from "lucide-react-native/dist/esm/icons/building-2";
|
|
34
35
|
import BookMarked from "lucide-react-native/dist/esm/icons/book-marked";
|
|
35
36
|
import BookOpen from "lucide-react-native/dist/esm/icons/book-open";
|
|
36
37
|
import BookText from "lucide-react-native/dist/esm/icons/book-text";
|
|
@@ -218,6 +219,7 @@ const iconComponents = {
|
|
|
218
219
|
bell: Bell,
|
|
219
220
|
bold: Bold,
|
|
220
221
|
bolt: Bolt,
|
|
222
|
+
"building-2": Building2,
|
|
221
223
|
"book-marked": BookMarked,
|
|
222
224
|
"book-open": BookOpen,
|
|
223
225
|
"book-text": BookText,
|
package/src/menu_button.tsx
CHANGED
|
@@ -105,7 +105,7 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
105
105
|
style={containerStyle}
|
|
106
106
|
role={role}
|
|
107
107
|
accessibilityLabel={resolvedLabel}
|
|
108
|
-
|
|
108
|
+
aria-selected={!!selected} aria-disabled={disabled || undefined}
|
|
109
109
|
>
|
|
110
110
|
{inner}
|
|
111
111
|
</PressableHighlight>
|
package/src/menu_list_item.tsx
CHANGED
|
@@ -51,7 +51,7 @@ export function MenuListItem(props: MenuListItemProps) {
|
|
|
51
51
|
style={containerStyle}
|
|
52
52
|
accessibilityRole="button"
|
|
53
53
|
accessibilityLabel={description ? `${title}, ${description}` : title}
|
|
54
|
-
|
|
54
|
+
aria-disabled={disabled || undefined}
|
|
55
55
|
>
|
|
56
56
|
{inner}
|
|
57
57
|
</PressableHighlight>
|
package/src/popover.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import React, {
|
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
5
|
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
6
7
|
useRef,
|
|
7
8
|
useState,
|
|
8
9
|
} from "react";
|
|
@@ -203,12 +204,29 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
203
204
|
const [triggerWidth, setTriggerWidth] = useState<number>(0);
|
|
204
205
|
const [isBottomSheetShown, setIsBottomSheetShown] = useState(false);
|
|
205
206
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
207
|
+
// Last known trigger geometry. The trigger can unmount while the popover is
|
|
208
|
+
// open — a hover-revealed menu button stops being rendered the moment our
|
|
209
|
+
// overlay covers its row and the hover ends — so positioning cannot rely on
|
|
210
|
+
// `triggerRef.current` still being live when it runs.
|
|
211
|
+
const triggerRectRef = useRef<DOMRect | null>(null);
|
|
206
212
|
|
|
207
213
|
const handleClose = useCallback(() => {
|
|
208
214
|
if (!open) return;
|
|
209
215
|
onOpenChange(false);
|
|
210
216
|
}, [onOpenChange, open]);
|
|
211
217
|
|
|
218
|
+
// Snapshot the trigger geometry synchronously the moment we open, while it is
|
|
219
|
+
// guaranteed to still be mounted. `calculatePosition` runs later in a rAF, by
|
|
220
|
+
// which point a hover-revealed trigger may already be gone (our overlay covers
|
|
221
|
+
// its row, hover ends, the button unmounts). Capturing here keeps the popover
|
|
222
|
+
// anchored to where the trigger was instead of stranding it off-screen.
|
|
223
|
+
useLayoutEffect(() => {
|
|
224
|
+
if (!open || small) return;
|
|
225
|
+
if (triggerRef.current) {
|
|
226
|
+
triggerRectRef.current = triggerRef.current.getBoundingClientRect();
|
|
227
|
+
}
|
|
228
|
+
}, [open, small, triggerRef]);
|
|
229
|
+
|
|
212
230
|
// Focus management: when the popover opens, remember what had focus and move
|
|
213
231
|
// focus into the popover (the content div is tab-able via `tabIndex=-1`).
|
|
214
232
|
// When it closes, restore focus to the prior element so keyboard users don't
|
|
@@ -280,10 +298,17 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
280
298
|
}, [open, small, onOpenChange]);
|
|
281
299
|
|
|
282
300
|
const calculatePosition = useCallback(() => {
|
|
283
|
-
if (!
|
|
301
|
+
if (!popoverRef.current) return;
|
|
284
302
|
if (small) return;
|
|
285
303
|
|
|
286
|
-
|
|
304
|
+
// Prefer the live trigger when it is still mounted (so the popover tracks
|
|
305
|
+
// scroll/layout); fall back to the rect captured at open time when the
|
|
306
|
+
// trigger has since unmounted, instead of stranding the popover off-screen.
|
|
307
|
+
const liveTriggerRect = triggerRef.current?.getBoundingClientRect();
|
|
308
|
+
if (liveTriggerRect) triggerRectRef.current = liveTriggerRect;
|
|
309
|
+
const triggerRect = triggerRectRef.current;
|
|
310
|
+
if (!triggerRect) return;
|
|
311
|
+
|
|
287
312
|
const popoverRect = popoverRef.current.getBoundingClientRect();
|
|
288
313
|
const viewportWidth = window.innerWidth;
|
|
289
314
|
const viewportHeight = window.innerHeight;
|
|
@@ -403,6 +428,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
403
428
|
if (!open) {
|
|
404
429
|
setPosition((previous) => (previous === null ? previous : null));
|
|
405
430
|
setIsBottomSheetShown((previous) => (previous ? false : previous));
|
|
431
|
+
triggerRectRef.current = null;
|
|
406
432
|
return;
|
|
407
433
|
}
|
|
408
434
|
|
package/src/radio_picker.tsx
CHANGED
|
@@ -115,7 +115,7 @@ function RadioOption<T extends string | number | symbol>(
|
|
|
115
115
|
onPress={handlePress}
|
|
116
116
|
accessibilityRole="radio"
|
|
117
117
|
accessibilityLabel={description ? `${label}, ${description}` : label}
|
|
118
|
-
|
|
118
|
+
aria-checked={selected}
|
|
119
119
|
// Roving tabindex: exactly one radio is the tab-stop. Arrow keys cycle.
|
|
120
120
|
focusable={isTabStop}
|
|
121
121
|
onKeyDown={onKeyDown}
|
package/src/stepper.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import { colors } from "./colors";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
|
|
6
|
+
export type StepStatus = "done" | "current" | "upcoming";
|
|
7
|
+
|
|
8
|
+
export interface StepperProps {
|
|
9
|
+
/** Ordered step labels, left → right. */
|
|
10
|
+
steps: string[];
|
|
11
|
+
/** Index of the in-progress step; lower indices render complete. */
|
|
12
|
+
current: number;
|
|
13
|
+
/** Accent for the completed track, current ring, and current label.
|
|
14
|
+
* Defaults to the neutral ink — pass a brand color to theme it. */
|
|
15
|
+
color?: string;
|
|
16
|
+
accessibilityLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function statusOf(index: number, current: number): StepStatus {
|
|
20
|
+
if (index < current) return "done";
|
|
21
|
+
if (index === current) return "current";
|
|
22
|
+
return "upcoming";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Horizontal step / status tracker — a row of milestones with completed,
|
|
27
|
+
* current, and upcoming states and a connecting track. For a vertical event
|
|
28
|
+
* log use `Timeline` instead.
|
|
29
|
+
*/
|
|
30
|
+
export function Stepper(props: StepperProps) {
|
|
31
|
+
const { steps, current, color = colors.zinc[900], accessibilityLabel } = props;
|
|
32
|
+
const last = steps.length - 1;
|
|
33
|
+
const safe = Math.max(0, Math.min(current, last));
|
|
34
|
+
return (
|
|
35
|
+
<View
|
|
36
|
+
accessibilityRole="progressbar"
|
|
37
|
+
accessibilityLabel={accessibilityLabel ?? `Step ${safe + 1} of ${steps.length}: ${steps[safe] ?? ""}`}
|
|
38
|
+
style={{ flexDirection: "row" }}
|
|
39
|
+
>
|
|
40
|
+
{steps.map((label, i) => {
|
|
41
|
+
const status = statusOf(i, current);
|
|
42
|
+
const leftFilled = i <= current && i > 0;
|
|
43
|
+
const rightFilled = i < current && i < last;
|
|
44
|
+
const isCurrent = status === "current";
|
|
45
|
+
return (
|
|
46
|
+
<View key={label} style={{ flex: 1, alignItems: "center", gap: 8 }}>
|
|
47
|
+
<View style={{ flexDirection: "row", alignItems: "center", width: "100%" }}>
|
|
48
|
+
<View style={{ flex: 1, height: 2, backgroundColor: leftFilled ? color : colors.zinc[200] }} />
|
|
49
|
+
<StepDot status={status} color={color} />
|
|
50
|
+
<View style={{ flex: 1, height: 2, backgroundColor: rightFilled ? color : colors.zinc[200] }} />
|
|
51
|
+
</View>
|
|
52
|
+
<Text
|
|
53
|
+
size="xs"
|
|
54
|
+
color={isCurrent ? "default" : "muted"}
|
|
55
|
+
weight={isCurrent ? "semibold" : "regular"}
|
|
56
|
+
style={isCurrent ? { color } : undefined}
|
|
57
|
+
>
|
|
58
|
+
{label}
|
|
59
|
+
</Text>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function StepDot({ status, color }: { status: StepStatus; color: string }) {
|
|
68
|
+
if (status === "done") {
|
|
69
|
+
return <Icon name="circle-check" size={16} color={color} />;
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<View
|
|
73
|
+
style={{
|
|
74
|
+
width: 16,
|
|
75
|
+
height: 16,
|
|
76
|
+
borderRadius: 8,
|
|
77
|
+
backgroundColor: colors.white,
|
|
78
|
+
borderWidth: status === "current" ? 3 : 1.5,
|
|
79
|
+
borderColor: status === "current" ? color : colors.zinc[300],
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|