@lotics/ui 1.11.1 → 1.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -20,6 +20,9 @@ export interface CalendarViewProps<T = unknown> {
20
20
  labels?: Partial<CalendarLabels>;
21
21
  onEventPress?: (event: CalendarEvent<T>) => void;
22
22
  onDayPress?: (day: Date) => void;
23
+ /** When set, month-view event chips become draggable; dropping on another day
24
+ * reschedules the event (duration preserved). Receives event + new start/end. */
25
+ onEventDrop?: (event: CalendarEvent<T>, newStart: Date, newEnd: Date | null) => void;
23
26
  }
24
27
 
25
28
  /**
@@ -28,7 +31,7 @@ export interface CalendarViewProps<T = unknown> {
28
31
  * primitive is drop-in; the consumer only supplies events + callbacks.
29
32
  */
30
33
  export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
31
- const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
34
+ const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress, onEventDrop } = props;
32
35
  const L = { ...DEFAULT_CALENDAR_LABELS, ...props.labels };
33
36
  const [view, setView] = useState<CalendarViewMode>(defaultView);
34
37
  const [date, setDate] = useState<Date>(defaultDate ?? new Date());
@@ -88,6 +91,7 @@ export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
88
91
  moreLabel={L.more}
89
92
  onEventPress={onEventPress}
90
93
  onDayPress={drillToDay}
94
+ onEventDrop={onEventDrop}
91
95
  />
92
96
  ) : (
93
97
  <TimeGridView
@@ -1,9 +1,10 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useRef } from "react";
2
2
  import { View, Pressable, StyleSheet } from "react-native";
3
3
  import { Text } from "../text";
4
4
  import { colors } from "../colors";
5
+ import { usePointerDrag } from "../use_pointer_drag";
5
6
  import { packEventLanes } from "./layout";
6
- import { daysInView, isSameMonth, isToday, weekdayShort } from "./dates";
7
+ import { addDays, dayDiff, daysInView, isSameMonth, isToday, startOfDay, weekdayShort } from "./dates";
7
8
  import type { CalendarEvent, Weekday } from "./types";
8
9
 
9
10
  const LANE_H = 19;
@@ -19,15 +20,39 @@ export interface MonthViewProps<T = unknown> {
19
20
  moreLabel?: (count: number) => string;
20
21
  onEventPress?: (event: CalendarEvent<T>) => void;
21
22
  onDayPress?: (day: Date) => void;
23
+ /** When set, event chips become draggable; dropping on another day reschedules
24
+ * the event, preserving its duration. Receives the event + new start/end. */
25
+ onEventDrop?: (event: CalendarEvent<T>, newStart: Date, newEnd: Date | null) => void;
22
26
  }
23
27
 
24
28
  export function MonthView<T = unknown>(props: MonthViewProps<T>) {
25
- const { date, events, weekStartsOn = 1, locale, moreLabel = (n) => `+${n} more`, onEventPress, onDayPress } = props;
29
+ const { date, events, weekStartsOn = 1, locale, moreLabel = (n) => `+${n} more`, onEventPress, onDayPress, onEventDrop } = props;
26
30
  const days = useMemo(() => daysInView("month", date, weekStartsOn), [date, weekStartsOn]);
27
31
  const weeks = useMemo(() => Array.from({ length: 6 }, (_, w) => days.slice(w * 7, w * 7 + 7)), [days]);
28
32
  const weekdayLabels = useMemo(() => days.slice(0, 7).map((d) => weekdayShort(d, locale)), [days, locale]);
29
33
  const now = new Date();
30
34
 
35
+ // Drag-to-reschedule: each week row registers its DOM rect; on drop we map the
36
+ // pointer to a (week, column) → day, then shift the event by whole days.
37
+ const eventById = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]);
38
+ const weekNodes = useRef<Map<number, HTMLElement>>(new Map());
39
+ const { live, bind } = usePointerDrag((id, pointer) => {
40
+ const ev = eventById.get(id);
41
+ if (!ev || !onEventDrop) return;
42
+ for (const [w, el] of weekNodes.current) {
43
+ const r = el.getBoundingClientRect();
44
+ if (r.width > 0 && pointer.y >= r.top && pointer.y <= r.bottom) {
45
+ const col = Math.min(6, Math.max(0, Math.floor((pointer.x - r.left) / (r.width / 7))));
46
+ const target = days[w * 7 + col];
47
+ if (!target) return;
48
+ const shift = dayDiff(startOfDay(ev.start), target);
49
+ if (shift === 0) return;
50
+ onEventDrop(ev, addDays(ev.start, shift), ev.end ? addDays(ev.end, shift) : null);
51
+ return;
52
+ }
53
+ }
54
+ });
55
+
31
56
  return (
32
57
  <View style={styles.root}>
33
58
  <View style={styles.weekdayHeader}>
@@ -49,7 +74,15 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
49
74
  }
50
75
 
51
76
  return (
52
- <View key={w} style={styles.weekRow}>
77
+ <View
78
+ key={w}
79
+ style={styles.weekRow}
80
+ ref={(node) => {
81
+ const el = node as unknown as HTMLElement | null;
82
+ if (el) weekNodes.current.set(w, el);
83
+ else weekNodes.current.delete(w);
84
+ }}
85
+ >
53
86
  {/* Date numbers */}
54
87
  <View style={styles.dateRow}>
55
88
  {weekDays.map((day) => {
@@ -89,9 +122,11 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
89
122
  {visible.map((bar) => {
90
123
  const accent = bar.event.color || colors.teal[600];
91
124
  const banner = bar.span > 1 || !!bar.event.allDay;
125
+ const drag = live && live.id === bar.event.id ? live : null;
92
126
  return (
93
127
  <Pressable
94
128
  key={bar.event.id}
129
+ ref={onEventDrop ? bind(bar.event.id, "grab") : undefined}
95
130
  onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
96
131
  accessibilityRole={onEventPress ? "button" : undefined}
97
132
  accessibilityLabel={bar.event.title}
@@ -102,6 +137,7 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
102
137
  left: `${(bar.startCol / 7) * 100}%`,
103
138
  width: `${(bar.span / 7) * 100}%`,
104
139
  paddingHorizontal: 2,
140
+ ...(drag ? { transform: [{ translateX: drag.dx }, { translateY: drag.dy }], zIndex: 20, opacity: 0.9 } : null),
105
141
  }}
106
142
  >
107
143
  <View
@@ -2,3 +2,5 @@ declare module "*.module.css" {
2
2
  const classes: { [key: string]: string };
3
3
  export default classes;
4
4
  }
5
+
6
+ declare module "*.css";
@@ -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 bar = barGeometry(t, axisStart, scale);
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: bar.left, width: bar.width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
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/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 (!triggerRef.current || !popoverRef.current) return;
301
+ if (!popoverRef.current) return;
284
302
  if (small) return;
285
303
 
286
- const triggerRect = triggerRef.current.getBoundingClientRect();
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
 
@@ -0,0 +1,99 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Platform } from "react-native";
3
+
4
+ const DRAG_THRESHOLD = 4; // px the pointer must travel before a press becomes a drag
5
+
6
+ /** Live state of an in-progress drag — the pointer delta from where it started. */
7
+ export interface DragLive {
8
+ id: string;
9
+ dx: number;
10
+ dy: number;
11
+ }
12
+
13
+ export interface PointerDragApi {
14
+ /** Non-null only while a drag is active (past the threshold). Consumers render
15
+ * live feedback (translate / resize) from `dx`/`dy`. */
16
+ live: DragLive | null;
17
+ /** Ref for a draggable element — attaches a pointerdown listener tagged with
18
+ * `id` and sets the web-only `cursor` + `touch-action: none` on the node (so
19
+ * touch-drag doesn't scroll). Stable per id+cursor, so it doesn't thrash
20
+ * listeners across renders. */
21
+ bind: (id: string, cursor?: string) => (node: unknown) => void;
22
+ }
23
+
24
+ /**
25
+ * Self-contained pointer-event drag for time-axis primitives (calendar event
26
+ * chips, gantt bar handles). Mirrors the kanban's proven approach — pointerdown
27
+ * on the element, a movement threshold to distinguish taps, then window-level
28
+ * pointermove/up — but with no floating clone and no column/card coupling. On
29
+ * release, `onDrop` receives the final client pointer position plus the total
30
+ * delta; the consumer maps that to a date. Web-only (RN has no pointer events);
31
+ * on native it's an inert no-op so taps/press handlers still work.
32
+ */
33
+ export function usePointerDrag(
34
+ onDrop: (id: string, pointer: { x: number; y: number }, delta: { dx: number; dy: number }) => void,
35
+ ): PointerDragApi {
36
+ const [live, setLive] = useState<DragLive | null>(null);
37
+ const press = useRef<{ id: string; x: number; y: number; active: boolean } | null>(null);
38
+ const onDropRef = useRef(onDrop);
39
+ onDropRef.current = onDrop;
40
+
41
+ // Per-id DOM node + its pointerdown handler, so we can detach on unmount/rebind.
42
+ const bound = useRef<Map<string, { el: HTMLElement; handler: (e: PointerEvent) => void }>>(new Map());
43
+ const refCache = useRef<Map<string, (node: unknown) => void>>(new Map());
44
+
45
+ useEffect(() => {
46
+ if (Platform.OS !== "web") return;
47
+ const move = (e: PointerEvent) => {
48
+ const p = press.current;
49
+ if (!p) return;
50
+ const dx = e.clientX - p.x;
51
+ const dy = e.clientY - p.y;
52
+ if (!p.active && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
53
+ p.active = true;
54
+ e.preventDefault();
55
+ setLive({ id: p.id, dx, dy });
56
+ };
57
+ const up = (e: PointerEvent) => {
58
+ const p = press.current;
59
+ press.current = null;
60
+ setLive(null);
61
+ if (p?.active) {
62
+ onDropRef.current(p.id, { x: e.clientX, y: e.clientY }, { dx: e.clientX - p.x, dy: e.clientY - p.y });
63
+ }
64
+ };
65
+ window.addEventListener("pointermove", move);
66
+ window.addEventListener("pointerup", up);
67
+ return () => {
68
+ window.removeEventListener("pointermove", move);
69
+ window.removeEventListener("pointerup", up);
70
+ };
71
+ }, []);
72
+
73
+ const bind = useCallback((id: string, cursor = "grab") => {
74
+ const key = `${id}:${cursor}`;
75
+ const cached = refCache.current.get(key);
76
+ if (cached) return cached;
77
+ const cb = (node: unknown) => {
78
+ const el = node as HTMLElement | null;
79
+ const prev = bound.current.get(id);
80
+ if (prev && prev.el !== el) {
81
+ prev.el.removeEventListener("pointerdown", prev.handler);
82
+ bound.current.delete(id);
83
+ }
84
+ if (!el || Platform.OS !== "web") return;
85
+ if (bound.current.has(id)) return;
86
+ el.style.touchAction = "none";
87
+ el.style.cursor = cursor;
88
+ const handler = (e: PointerEvent) => {
89
+ press.current = { id, x: e.clientX, y: e.clientY, active: false };
90
+ };
91
+ el.addEventListener("pointerdown", handler);
92
+ bound.current.set(id, { el, handler });
93
+ };
94
+ refCache.current.set(key, cb);
95
+ return cb;
96
+ }, []);
97
+
98
+ return { live, bind };
99
+ }