@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.
- package/package.json +21 -7
- package/src/alert.tsx +35 -5
- package/src/avatar.tsx +28 -3
- package/src/back_button.tsx +4 -2
- package/src/button.tsx +35 -5
- package/src/calendar/calendar_view.tsx +16 -7
- package/src/calendar/index.ts +2 -1
- package/src/calendar/month_view.tsx +43 -5
- package/src/calendar/time_grid_view.tsx +3 -1
- package/src/calendar/types.ts +29 -0
- package/src/checkbox_input.tsx +9 -3
- package/src/command_menu.tsx +50 -4
- package/src/css_modules.d.ts +2 -0
- package/src/dialog.tsx +1 -1
- package/src/form_field.tsx +77 -25
- package/src/form_switch.tsx +22 -3
- package/src/gantt/gantt_view.tsx +54 -14
- package/src/gantt/index.ts +2 -1
- package/src/gantt/types.ts +15 -0
- package/src/grid/select_header_cell.tsx +1 -0
- package/src/icon.tsx +14 -8
- package/src/icon_button.tsx +10 -4
- package/src/index.css +11 -0
- package/src/landmark.tsx +34 -0
- package/src/menu_button.tsx +21 -0
- package/src/menu_list_item.tsx +3 -0
- package/src/number_input.tsx +10 -1
- package/src/pill_button.tsx +1 -0
- package/src/popover.tsx +75 -4
- package/src/popover_header.tsx +4 -2
- package/src/pressable_highlight.tsx +24 -0
- package/src/radio_picker.tsx +63 -5
- package/src/section_heading.tsx +5 -3
- package/src/skip_link.tsx +46 -0
- package/src/switch.tsx +9 -1
- package/src/switch_button.tsx +3 -0
- package/src/tabs.tsx +81 -19
- package/src/text.tsx +33 -0
- package/src/text_input_field.tsx +31 -0
- package/src/tooltip.tsx +43 -6
- package/src/use_pointer_drag.ts +99 -0
package/src/text_input_field.tsx
CHANGED
|
@@ -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
|
|
194
|
-
(
|
|
193
|
+
const showFor = useCallback(
|
|
194
|
+
(target: unknown) => {
|
|
195
195
|
if (!text || !context) return;
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|