@lotics/ui 1.11.0 → 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.
@@ -12,6 +12,7 @@ import { ShortcutBadge } from "./shortcut_badge";
12
12
  import { fontFamilyRegular, getInputLineHeight, getInputTextStyle } from "./text_utils";
13
13
  import { useScreenSize } from "./use_screen_size";
14
14
  import { useAutoGrowHeight } from "./use_auto_grow_height";
15
+ import { useFormField } from "./form_field";
15
16
  import type { ShortcutDescriptor } from "./keyboard";
16
17
 
17
18
  interface TextInputFieldProps extends RNTextInputProps {
@@ -25,6 +26,13 @@ interface TextInputFieldProps extends RNTextInputProps {
25
26
  autoGrow?: boolean;
26
27
  /** Keyboard shortcut badge shown in the right slot when the input is empty. */
27
28
  shortcut?: string | ShortcutDescriptor;
29
+ /** Accessible name for the clear button. Default: "Clear". Pass a translated string from the consumer. */
30
+ clearLabel?: string;
31
+ // DOM-only ARIA attrs not declared on React Native's TextInputProps. They
32
+ // are forwarded verbatim to the underlying web input.
33
+ "aria-controls"?: string;
34
+ "aria-activedescendant"?: string;
35
+ "aria-autocomplete"?: "none" | "inline" | "list" | "both";
28
36
  }
29
37
 
30
38
  export function TextInputField(props: TextInputFieldProps) {
@@ -40,12 +48,29 @@ export function TextInputField(props: TextInputFieldProps) {
40
48
  disabled,
41
49
  autoGrow,
42
50
  shortcut,
51
+ clearLabel = "Clear",
43
52
  ref,
53
+ "aria-controls": ariaControls,
54
+ "aria-activedescendant": ariaActivedescendant,
55
+ "aria-autocomplete": ariaAutocomplete,
44
56
  ...inputProps
45
57
  } = props;
46
58
 
59
+ // Forwarded to the DOM input on RN Web. RN's TextInput type does not declare
60
+ // these attrs, but react-native-web passes unknown props through to the
61
+ // element, which is the correct behavior for combobox patterns.
62
+ const webAriaAttrs: Record<string, unknown> = {};
63
+ if (ariaControls !== undefined) webAriaAttrs["aria-controls"] = ariaControls;
64
+ if (ariaActivedescendant !== undefined) webAriaAttrs["aria-activedescendant"] = ariaActivedescendant;
65
+ if (ariaAutocomplete !== undefined) webAriaAttrs["aria-autocomplete"] = ariaAutocomplete;
66
+
47
67
  const { small } = useScreenSize();
48
68
  const lineHeight = getInputLineHeight(small);
69
+ const binding = useFormField();
70
+
71
+ // Describedby chains description and error so both are read. We join them
72
+ // explicitly here because React Native Web does not flatten array attrs.
73
+ const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
49
74
 
50
75
  const minHeight =
51
76
  numberOfLines && numberOfLines > 1
@@ -90,11 +115,16 @@ export function TextInputField(props: TextInputFieldProps) {
90
115
  )}
91
116
  <RNTextInput
92
117
  {...inputProps}
118
+ {...webAriaAttrs}
93
119
  ref={mergedRef}
94
120
  value={value}
95
121
  onChangeText={handleChangeText}
96
122
  onFocus={inputProps.onFocus}
97
123
  onBlur={inputProps.onBlur}
124
+ nativeID={binding?.inputId ?? inputProps.nativeID}
125
+ accessibilityLabelledBy={binding?.labelId ?? inputProps.accessibilityLabelledBy}
126
+ aria-describedby={describedBy}
127
+ aria-invalid={binding?.invalid || undefined}
98
128
  style={[
99
129
  styles.input,
100
130
  getInputTextStyle(),
@@ -115,6 +145,7 @@ export function TextInputField(props: TextInputFieldProps) {
115
145
  {!!clearable && !!value ? (
116
146
  <IconButton
117
147
  icon="x"
148
+ tooltip={clearLabel}
118
149
  onPress={() => {
119
150
  onChangeText?.("");
120
151
  onClear?.();
package/src/tooltip.tsx CHANGED
@@ -190,17 +190,27 @@ export function useTooltip(options?: string | UseTooltipOptions) {
190
190
  const side = typeof options === "string" ? "top" : (options?.side ?? "top");
191
191
  const offset = typeof options === "string" ? undefined : options?.offset;
192
192
 
193
- const onMouseEnter = useCallback(
194
- (e: React.MouseEvent | { currentTarget: HTMLElement }) => {
193
+ const showFor = useCallback(
194
+ (target: unknown) => {
195
195
  if (!text || !context) return;
196
- const target = e.currentTarget as HTMLElement;
197
- if (!target) return;
196
+ // Tooltips are a web-only UI. `typeof` guards the native platforms where
197
+ // `HTMLElement` does not exist as a global, then `instanceof` narrows
198
+ // without an unchecked cast.
199
+ if (typeof HTMLElement === "undefined") return;
200
+ if (!(target instanceof HTMLElement)) return;
198
201
  const rect = target.getBoundingClientRect();
199
202
  context.show(text, rect, side, offset);
200
203
  },
201
204
  [text, context, side, offset],
202
205
  );
203
206
 
207
+ const onMouseEnter = useCallback(
208
+ (e: { currentTarget: unknown }) => {
209
+ showFor(e.currentTarget);
210
+ },
211
+ [showFor],
212
+ );
213
+
204
214
  const onMouseLeave = useCallback(() => {
205
215
  if (!context) return;
206
216
  context.hide();
@@ -211,15 +221,42 @@ export function useTooltip(options?: string | UseTooltipOptions) {
211
221
  context.hide();
212
222
  }, [context]);
213
223
 
214
- // If no text or no provider, return empty object
224
+ // Keyboard focus must reveal the same label as hover. `:focus-visible` CSS
225
+ // handles the visual ring; we tie tooltip visibility to real focus so
226
+ // screen-reader-off keyboard users still see names.
227
+ //
228
+ // Typed as `unknown` because these handlers spread onto both
229
+ // `react-native-web`'s `Pressable` (expects `NativeSyntheticEvent<TargetedEvent>`)
230
+ // and regular DOM elements (expect `React.FocusEvent`). At runtime on web,
231
+ // both shapes expose `currentTarget` as an `HTMLElement`, which is all we read.
232
+ const onFocus = useCallback(
233
+ (e: { currentTarget: unknown }) => {
234
+ showFor(e.currentTarget);
235
+ },
236
+ [showFor],
237
+ );
238
+
239
+ const onBlur = useCallback(() => {
240
+ if (!context) return;
241
+ context.hide();
242
+ }, [context]);
243
+
215
244
  if (!text || !context) {
216
- return {};
245
+ return {
246
+ onMouseEnter: undefined,
247
+ onMouseLeave: undefined,
248
+ onMouseDown: undefined,
249
+ onFocus: undefined,
250
+ onBlur: undefined,
251
+ };
217
252
  }
218
253
 
219
254
  return {
220
255
  onMouseEnter,
221
256
  onMouseLeave,
222
257
  onMouseDown,
258
+ onFocus,
259
+ onBlur,
223
260
  };
224
261
  }
225
262
 
@@ -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
+ }