@lotics/ui 1.10.0 → 1.11.1

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.
Files changed (55) hide show
  1. package/package.json +24 -7
  2. package/src/alert.tsx +35 -5
  3. package/src/avatar.tsx +28 -3
  4. package/src/back_button.tsx +4 -2
  5. package/src/button.tsx +35 -5
  6. package/src/calendar/calendar_view.tsx +127 -0
  7. package/src/calendar/dates.ts +102 -0
  8. package/src/calendar/index.ts +20 -0
  9. package/src/calendar/layout.test.ts +103 -0
  10. package/src/calendar/layout.ts +142 -0
  11. package/src/calendar/month_view.tsx +159 -0
  12. package/src/calendar/time_grid_view.tsx +263 -0
  13. package/src/calendar/types.ts +67 -0
  14. package/src/checkbox_input.tsx +9 -3
  15. package/src/command_menu.tsx +50 -4
  16. package/src/dialog.tsx +1 -1
  17. package/src/download.ts +14 -2
  18. package/src/form_field.tsx +77 -25
  19. package/src/form_switch.tsx +22 -3
  20. package/src/gantt/gantt_view.tsx +145 -0
  21. package/src/gantt/index.ts +5 -0
  22. package/src/gantt/scale.test.ts +47 -0
  23. package/src/gantt/scale.ts +92 -0
  24. package/src/gantt/types.ts +51 -0
  25. package/src/grid/select_header_cell.tsx +1 -0
  26. package/src/icon.tsx +14 -8
  27. package/src/icon_button.tsx +10 -4
  28. package/src/index.css +11 -0
  29. package/src/kanban/constants.ts +18 -0
  30. package/src/kanban/default_renderers.tsx +160 -0
  31. package/src/kanban/drag_preview.tsx +157 -0
  32. package/src/kanban/index.ts +13 -0
  33. package/src/kanban/insert_card_zone.tsx +135 -0
  34. package/src/kanban/kanban_board.tsx +616 -0
  35. package/src/kanban/kanban_card.tsx +312 -0
  36. package/src/kanban/kanban_column.tsx +487 -0
  37. package/src/kanban/placeholders.tsx +54 -0
  38. package/src/kanban/types.ts +116 -0
  39. package/src/landmark.tsx +34 -0
  40. package/src/menu_button.tsx +21 -0
  41. package/src/menu_list_item.tsx +3 -0
  42. package/src/number_input.tsx +10 -1
  43. package/src/pill_button.tsx +1 -0
  44. package/src/popover.tsx +47 -2
  45. package/src/popover_header.tsx +4 -2
  46. package/src/pressable_highlight.tsx +24 -0
  47. package/src/radio_picker.tsx +63 -5
  48. package/src/section_heading.tsx +5 -3
  49. package/src/skip_link.tsx +46 -0
  50. package/src/switch.tsx +9 -1
  51. package/src/switch_button.tsx +3 -0
  52. package/src/tabs.tsx +81 -19
  53. package/src/text.tsx +33 -0
  54. package/src/text_input_field.tsx +31 -0
  55. package/src/tooltip.tsx +43 -6
package/src/icon.tsx CHANGED
@@ -392,12 +392,18 @@ interface IconProps {
392
392
 
393
393
  export function Icon({ name, size = 24, color = colors.zinc["900"], testID }: IconProps) {
394
394
  const IconComponent = iconComponents[name] ?? iconComponents["box"];
395
- if (testID) {
396
- return (
397
- <View testID={testID}>
398
- <IconComponent size={size} color={color} />
399
- </View>
400
- );
401
- }
402
- return <IconComponent size={size} color={color} />;
395
+ // Icons are always decorative: the enclosing control (button, menu item,
396
+ // status badge) carries the accessible name. Exposing the SVG to assistive
397
+ // tech doubles announcements ("check check-mark"). Callers that need a
398
+ // standalone meaningful icon must wrap it in a View with their own label.
399
+ return (
400
+ <View
401
+ testID={testID}
402
+ accessibilityElementsHidden
403
+ importantForAccessibility="no-hide-descendants"
404
+ aria-hidden
405
+ >
406
+ <IconComponent size={size} color={color} />
407
+ </View>
408
+ );
403
409
  }
@@ -5,21 +5,27 @@ import { PressableHighlight } from "./pressable_highlight";
5
5
  import { Ref, useCallback } from "react";
6
6
  import { TooltipSide } from "./tooltip";
7
7
 
8
- export interface IconButtonProps {
8
+ interface IconButtonBase {
9
9
  ref?: Ref<View>;
10
10
  testID?: string;
11
11
  icon: IconName;
12
12
  color?: "none" | "secondary" | "white";
13
13
  iconColor?: string;
14
- tooltip?: string;
15
14
  tooltipSide?: TooltipSide;
16
- /** Accessible name for the button. Falls back to `tooltip` when omitted. */
17
- accessibilityLabel?: string;
18
15
  onPress?: (event: GestureResponderEvent) => void;
19
16
  style?: StyleProp<ViewStyle>;
20
17
  disabled?: boolean;
21
18
  }
22
19
 
20
+ /**
21
+ * An icon-only button has no visible text, so it must carry an accessible
22
+ * name. The compiler enforces at least one of `tooltip` (also shown visually
23
+ * on hover) or `accessibilityLabel` (a11y-only). When both are present,
24
+ * `accessibilityLabel` is the announced name.
25
+ */
26
+ export type IconButtonProps = IconButtonBase &
27
+ ({ tooltip: string; accessibilityLabel?: string } | { tooltip?: string; accessibilityLabel: string });
28
+
23
29
  export function IconButton(props: IconButtonProps) {
24
30
  const {
25
31
  ref,
package/src/index.css CHANGED
@@ -336,6 +336,17 @@ html {
336
336
  color: var(--color-zinc-900);
337
337
  }
338
338
 
339
+ /* Keyboard focus ring. `:focus-visible` only matches keyboard-driven focus,
340
+ so pointer/touch interactions stay visually unchanged. */
341
+ :focus-visible {
342
+ outline: 2px solid var(--color-zinc-900);
343
+ outline-offset: 2px;
344
+ border-radius: 4px;
345
+ }
346
+ :focus:not(:focus-visible) {
347
+ outline: none;
348
+ }
349
+
339
350
  /* @font-face declarations are NOT included here — each app provides its own
340
351
  font loading because paths differ per platform:
341
352
  - Frontend: /fonts/Inter_*.woff2 (served from public/)
@@ -0,0 +1,18 @@
1
+ // Column layout
2
+ export const DEFAULT_COLUMN_WIDTH = 280;
3
+ export const MINIMIZED_COLUMN_WIDTH = 44;
4
+ export const DEFAULT_COLUMN_GAP = 16;
5
+ export const DEFAULT_ITEM_GAP = 8;
6
+ export const COLUMN_CONTENT_PADDING = 12;
7
+
8
+ // Auto-scroll behavior
9
+ export const AUTO_SCROLL_THRESHOLD = 60;
10
+ export const AUTO_SCROLL_SPEED = 8;
11
+
12
+ // Drag detection
13
+ export const DRAG_THRESHOLD = 5;
14
+
15
+ // Touch-specific drag activation (like dnd-kit's delay activation)
16
+ // On touch devices, require a hold before drag can start to distinguish from tap
17
+ export const TOUCH_DRAG_DELAY = 200; // ms - hold time before drag activates on touch
18
+ export const TOUCH_TOLERANCE = 10; // px - max movement allowed during delay period
@@ -0,0 +1,160 @@
1
+ import { Pressable, StyleSheet, Text, View } from "react-native";
2
+
3
+ import { Icon } from "../icon";
4
+ import { colors } from "../colors";
5
+
6
+ import {
7
+ KanbanRenderAddCardPlaceholderProps,
8
+ KanbanRenderCollapsedColumnProps,
9
+ KanbanRenderColumnHeaderProps,
10
+ } from "./types";
11
+
12
+ export function defaultRenderColumnHeader({
13
+ title,
14
+ itemCount,
15
+ isMinimized,
16
+ onMinimizeToggle,
17
+ }: KanbanRenderColumnHeaderProps): React.ReactNode {
18
+ if (isMinimized) {
19
+ return null;
20
+ }
21
+
22
+ return (
23
+ <View style={styles.header}>
24
+ <View style={styles.headerContent}>
25
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
26
+ <Text style={styles.headerTitleText}>{title}</Text>
27
+ <View style={styles.headerCount}>
28
+ <Text style={styles.headerCountText}>{itemCount}</Text>
29
+ </View>
30
+ </View>
31
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
32
+ <Pressable onPressIn={(e) => e.stopPropagation()} onPress={onMinimizeToggle} hitSlop={8}>
33
+ <Icon name="minimize-2" size={16} color={colors.zinc[400]} />
34
+ </Pressable>
35
+ </View>
36
+ </View>
37
+ </View>
38
+ );
39
+ }
40
+
41
+ export function defaultRenderAddCardPlaceholder({
42
+ onPress,
43
+ }: KanbanRenderAddCardPlaceholderProps): React.ReactNode {
44
+ return (
45
+ <Pressable style={styles.addCard} onPress={onPress}>
46
+ <Icon name="plus" size={16} color={colors.zinc[400]} />
47
+ <Text style={styles.addCardText}>Add card</Text>
48
+ </Pressable>
49
+ );
50
+ }
51
+
52
+ export function defaultRenderCollapsedColumn({
53
+ title,
54
+ itemCount,
55
+ showDropIndicator,
56
+ onExpand,
57
+ }: KanbanRenderCollapsedColumnProps): React.ReactNode {
58
+ return (
59
+ <Pressable style={styles.collapsedColumn} onPress={onExpand}>
60
+ <View style={styles.collapsedContent}>
61
+ <View style={styles.collapsedCount}>
62
+ <Text style={styles.collapsedCountText}>{itemCount}</Text>
63
+ </View>
64
+ <Text style={styles.collapsedTitle} numberOfLines={1}>
65
+ {title}
66
+ </Text>
67
+ </View>
68
+ {showDropIndicator && <View style={styles.collapsedDropIndicator} />}
69
+ </Pressable>
70
+ );
71
+ }
72
+
73
+ const styles = StyleSheet.create({
74
+ header: {
75
+ padding: 12,
76
+ borderBottomWidth: 1,
77
+ borderBottomColor: colors.zinc[200],
78
+ },
79
+ headerContent: {
80
+ flexDirection: "row",
81
+ alignItems: "center",
82
+ justifyContent: "space-between",
83
+ },
84
+ headerTitleText: {
85
+ fontWeight: "600",
86
+ color: colors.zinc[700],
87
+ },
88
+ headerCount: {
89
+ backgroundColor: colors.zinc[200],
90
+ paddingHorizontal: 8,
91
+ paddingVertical: 2,
92
+ borderRadius: 10,
93
+ },
94
+ headerCountText: {
95
+ fontSize: 12,
96
+ color: colors.zinc[500],
97
+ },
98
+ addCard: {
99
+ flexDirection: "row",
100
+ alignItems: "center",
101
+ justifyContent: "center",
102
+ gap: 6,
103
+ paddingVertical: 8,
104
+ borderRadius: 6,
105
+ borderWidth: 1,
106
+ borderColor: colors.zinc[200],
107
+ borderStyle: "dashed",
108
+ backgroundColor: colors.white,
109
+ },
110
+ addCardText: {
111
+ fontSize: 13,
112
+ color: colors.zinc[400],
113
+ },
114
+ collapsedColumn: {
115
+ backgroundColor: colors.zinc[50],
116
+ borderRadius: 8,
117
+ borderWidth: 1,
118
+ borderColor: colors.zinc[200],
119
+ alignItems: "center",
120
+ flex: 1,
121
+ paddingVertical: 12,
122
+ paddingHorizontal: 4,
123
+ alignSelf: "stretch",
124
+ },
125
+ collapsedContent: {
126
+ flex: 1,
127
+ alignItems: "center",
128
+ gap: 8,
129
+ },
130
+ collapsedCount: {
131
+ backgroundColor: colors.zinc[200],
132
+ paddingHorizontal: 8,
133
+ paddingVertical: 2,
134
+ borderRadius: 10,
135
+ },
136
+ collapsedCountText: {
137
+ fontSize: 12,
138
+ fontWeight: "500",
139
+ color: colors.zinc[600],
140
+ },
141
+ collapsedTitle: {
142
+ writingDirection: "ltr",
143
+ transform: [{ rotate: "180deg" }],
144
+ // @ts-expect-error web-only property for vertical text
145
+ writingMode: "vertical-rl",
146
+ fontSize: 14,
147
+ fontWeight: "600",
148
+ color: colors.zinc[700],
149
+ textAlign: "center",
150
+ },
151
+ collapsedDropIndicator: {
152
+ position: "absolute",
153
+ top: 8,
154
+ left: 4,
155
+ right: 4,
156
+ height: 3,
157
+ backgroundColor: colors.blue[500],
158
+ borderRadius: 2,
159
+ },
160
+ });
@@ -0,0 +1,157 @@
1
+ import React, { memo, useEffect, useState } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "../colors";
4
+ import {
5
+ DragInfo,
6
+ DragPreviewInfo,
7
+ KanbanColumn,
8
+ KanbanRenderCardProps,
9
+ KanbanRenderColumnHeaderProps,
10
+ } from "./types";
11
+
12
+ interface DragPreviewProps<T> {
13
+ columns: KanbanColumn<T>[];
14
+ dragInfo: DragInfo | null;
15
+ dragPreview: DragPreviewInfo | null;
16
+ renderCard: (props: KanbanRenderCardProps<T>) => React.ReactNode;
17
+ renderColumnHeader: (props: KanbanRenderColumnHeaderProps) => React.ReactNode;
18
+ onPositionUpdate: (callback: (x: number, y: number) => void) => void;
19
+ initialPosition: { x: number; y: number } | null;
20
+ }
21
+
22
+ function DragPreviewInner<T>({
23
+ columns,
24
+ dragInfo,
25
+ dragPreview,
26
+ renderCard,
27
+ renderColumnHeader,
28
+ onPositionUpdate,
29
+ initialPosition,
30
+ }: DragPreviewProps<T>) {
31
+ const [position, setPosition] = useState(initialPosition);
32
+
33
+ // Register position update callback
34
+ useEffect(() => {
35
+ onPositionUpdate((x, y) => setPosition({ x, y }));
36
+ return () => onPositionUpdate(() => {});
37
+ }, [onPositionUpdate]);
38
+
39
+ // Sync with initial position
40
+ useEffect(() => {
41
+ if (initialPosition) setPosition(initialPosition);
42
+ }, [initialPosition]);
43
+
44
+ // Clear position when drag ends
45
+ useEffect(() => {
46
+ if (!dragInfo) setPosition(null);
47
+ }, [dragInfo]);
48
+
49
+ if (!dragInfo || !position || !dragPreview) return null;
50
+
51
+ const left = position.x - dragPreview.offsetX;
52
+ const top = position.y - dragPreview.offsetY;
53
+
54
+ if (dragInfo.type === "card") {
55
+ const column = columns.find((col) => col.key === dragInfo.sourceColumnKey);
56
+ const item = column?.items.find((i) => i.id === dragInfo.id);
57
+ if (!item) return null;
58
+
59
+ return (
60
+ <div style={{ ...previewStyle, left, top, width: dragPreview.width }}>
61
+ {renderCard({
62
+ item: item.data,
63
+ columnKey: dragInfo.sourceColumnKey,
64
+ id: dragInfo.id,
65
+ hovered: false,
66
+ isDragging: true,
67
+ })}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ if (dragInfo.type === "column") {
73
+ const column = columns.find((col) => col.key === dragInfo.id);
74
+ if (!column) return null;
75
+
76
+ return (
77
+ <div
78
+ style={{
79
+ ...previewStyle,
80
+ left,
81
+ top,
82
+ width: dragPreview.width,
83
+ height: dragPreview.height,
84
+ }}
85
+ >
86
+ <View style={styles.columnPreview}>
87
+ <View style={styles.columnPreviewHeader}>
88
+ {renderColumnHeader({
89
+ columnKey: column.key,
90
+ title: column.title,
91
+ itemCount: column.items.length,
92
+ isMinimized: false,
93
+ onMinimizeToggle: () => {},
94
+ })}
95
+ </View>
96
+ <View style={styles.columnPreviewContent}>
97
+ {column.items.slice(0, 3).map((item) => (
98
+ <View key={item.id} style={styles.columnPreviewCard}>
99
+ {renderCard({
100
+ item: item.data,
101
+ columnKey: column.key,
102
+ id: item.id,
103
+ hovered: false,
104
+ isDragging: true,
105
+ })}
106
+ </View>
107
+ ))}
108
+ {column.items.length > 3 && (
109
+ <View style={styles.columnPreviewMore}>
110
+ <span style={{ color: colors.zinc[500], fontSize: 12 }}>
111
+ +{column.items.length - 3} more
112
+ </span>
113
+ </View>
114
+ )}
115
+ </View>
116
+ </View>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ export const DragPreview = memo(DragPreviewInner) as typeof DragPreviewInner;
125
+
126
+ const previewStyle: React.CSSProperties = {
127
+ position: "fixed",
128
+ zIndex: 9999,
129
+ pointerEvents: "none",
130
+ opacity: 0.85,
131
+ transform: "rotate(2deg)",
132
+ boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
133
+ };
134
+
135
+ const styles = StyleSheet.create({
136
+ columnPreview: {
137
+ backgroundColor: colors.zinc[50],
138
+ borderRadius: 8,
139
+ borderWidth: 1,
140
+ borderColor: colors.zinc[200],
141
+ overflow: "hidden",
142
+ },
143
+ columnPreviewHeader: {
144
+ borderBottomWidth: 1,
145
+ borderBottomColor: colors.zinc[200],
146
+ },
147
+ columnPreviewContent: {
148
+ padding: 4,
149
+ },
150
+ columnPreviewCard: {
151
+ marginVertical: 2,
152
+ },
153
+ columnPreviewMore: {
154
+ padding: 8,
155
+ alignItems: "center",
156
+ },
157
+ });
@@ -0,0 +1,13 @@
1
+ export { KanbanBoard } from "./kanban_board";
2
+ export type { KanbanBoardProps } from "./kanban_board";
3
+ export type {
4
+ KanbanColumn,
5
+ KanbanItem,
6
+ KanbanRenderCardProps,
7
+ KanbanRenderColumnHeaderProps,
8
+ KanbanRenderCollapsedColumnProps,
9
+ KanbanRenderAddCardPlaceholderProps,
10
+ KanbanRenderInsertCardButtonProps,
11
+ CardMoveResult,
12
+ ColumnMoveResult,
13
+ } from "./types";
@@ -0,0 +1,135 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { KanbanRenderInsertCardButtonProps } from "./types";
3
+ import { Pressable, View, StyleSheet } from "react-native";
4
+ import { colors } from "../colors";
5
+ import { Icon } from "../icon";
6
+
7
+ const HOVER_DELAY_MS = 200;
8
+
9
+ // Interactive zone that shows an insert button on hover
10
+ export function InsertCardZone({
11
+ height,
12
+ columnKey,
13
+ index,
14
+ onAddCardAtIndex,
15
+ renderInsertCardButton,
16
+ isDragInProgress,
17
+ }: {
18
+ height: number;
19
+ columnKey: string;
20
+ index: number;
21
+ onAddCardAtIndex: (columnKey: string, index: number) => void;
22
+ renderInsertCardButton?: (props: KanbanRenderInsertCardButtonProps) => React.ReactNode;
23
+ /** When true, drag is in progress and insert UI should be hidden */
24
+ isDragInProgress?: boolean;
25
+ }) {
26
+ const [isHovered, setIsHovered] = useState(false);
27
+ const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28
+
29
+ // Cleanup timeout on unmount to prevent memory leak
30
+ useEffect(() => {
31
+ return () => {
32
+ if (hoverTimeoutRef.current) {
33
+ clearTimeout(hoverTimeoutRef.current);
34
+ hoverTimeoutRef.current = null;
35
+ }
36
+ };
37
+ }, []);
38
+
39
+ const handlePointerEnter = useCallback(() => {
40
+ hoverTimeoutRef.current = setTimeout(() => {
41
+ setIsHovered(true);
42
+ }, HOVER_DELAY_MS);
43
+ }, []);
44
+
45
+ const handlePointerLeave = useCallback(() => {
46
+ if (hoverTimeoutRef.current) {
47
+ clearTimeout(hoverTimeoutRef.current);
48
+ hoverTimeoutRef.current = null;
49
+ }
50
+ setIsHovered(false);
51
+ }, []);
52
+
53
+ const handlePress = useCallback(() => {
54
+ onAddCardAtIndex(columnKey, index);
55
+ }, [columnKey, index, onAddCardAtIndex]);
56
+
57
+ // Don't show insert UI during drag operations
58
+ const showInsertUI = isHovered && !isDragInProgress;
59
+
60
+ if (renderInsertCardButton) {
61
+ return (
62
+ <View
63
+ style={[insertZoneStyles.container, { height }]}
64
+ onPointerEnter={handlePointerEnter}
65
+ onPointerLeave={handlePointerLeave}
66
+ >
67
+ {showInsertUI &&
68
+ renderInsertCardButton({
69
+ columnKey,
70
+ index,
71
+ onPress: handlePress,
72
+ })}
73
+ </View>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <Pressable
79
+ onPress={handlePress}
80
+ onPointerEnter={handlePointerEnter}
81
+ onPointerLeave={handlePointerLeave}
82
+ style={[insertZoneStyles.container, { height }]}
83
+ >
84
+ {showInsertUI && (
85
+ <View style={insertZoneStyles.buttonWrapper}>
86
+ <View style={insertZoneStyles.buttonContent}>
87
+ <View style={insertZoneStyles.line} />
88
+ <View style={insertZoneStyles.plusCircle}>
89
+ <Icon name="plus" size={16} color={colors.white} />
90
+ </View>
91
+ <View style={insertZoneStyles.line} />
92
+ </View>
93
+ </View>
94
+ )}
95
+ </Pressable>
96
+ );
97
+ }
98
+
99
+ const insertZoneStyles = StyleSheet.create({
100
+ container: {
101
+ justifyContent: "center",
102
+ alignItems: "center",
103
+ overflow: "visible",
104
+ },
105
+ buttonWrapper: {
106
+ position: "absolute",
107
+ left: 0,
108
+ right: 0,
109
+ top: -3,
110
+ height: 14,
111
+ zIndex: 10,
112
+ },
113
+ buttonContent: {
114
+ flexDirection: "row",
115
+ alignItems: "center",
116
+ width: "100%",
117
+ height: "100%",
118
+ paddingHorizontal: 4,
119
+ },
120
+ line: {
121
+ flex: 1,
122
+ height: 2,
123
+ backgroundColor: colors.zinc[400],
124
+ borderRadius: 1,
125
+ },
126
+ plusCircle: {
127
+ width: 18,
128
+ height: 18,
129
+ borderRadius: 6,
130
+ backgroundColor: colors.zinc[900],
131
+ justifyContent: "center",
132
+ alignItems: "center",
133
+ marginHorizontal: 4,
134
+ },
135
+ });