@tensor_1/react 2.26.1 → 2.26.3

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.
@@ -54,6 +54,9 @@ declare const EMOJI_GRID_COLUMNS = 8;
54
54
  * Emoji picker in a native popover: search, quick picks, recently used and the
55
55
  * Unicode categories, with names in English or German. The emoji dataset loads on
56
56
  * first open. The grid is one tab stop; arrow keys, Home and End move within it.
57
+ * The picker is one size in every state (loading, searching, failed), so it keeps the
58
+ * side it opened on; it stays attached to its trigger while the page scrolls and
59
+ * closes once the trigger is scrolled out of view.
57
60
  */
58
61
  declare function EmojiPicker({ onSelect, trigger, labels: labelOverrides, locale, quickPicks, loadData, recentStorageKey, placement, onOpenChange, className, emojiImageBaseUrl, }: EmojiPickerProps): react.JSX.Element;
59
62
 
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { cloneElement, isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3
+ import { cloneElement, isValidElement, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { Popover } from "./Popover.js";
6
6
  import {
@@ -23,6 +23,14 @@ const DEFAULT_EMOJI_PICKER_LABELS = {
23
23
  loadFailed: "Emoji could not be loaded."
24
24
  };
25
25
  const EMOJI_GRID_COLUMNS = 8;
26
+ const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
27
+ function revealInGrid(grid, cell) {
28
+ const g = grid.getBoundingClientRect();
29
+ const c = cell.getBoundingClientRect();
30
+ const top = g.top + (parseFloat(getComputedStyle(grid).scrollPaddingTop) || 0);
31
+ if (c.top < top) grid.scrollTop -= top - c.top;
32
+ else if (c.bottom > g.bottom) grid.scrollTop += c.bottom - g.bottom;
33
+ }
26
34
  function EmojiPicker({
27
35
  onSelect,
28
36
  trigger,
@@ -44,6 +52,8 @@ function EmojiPicker({
44
52
  const searchRef = useRef(null);
45
53
  const gridRef = useRef(null);
46
54
  const pointerWasOpen = useRef(null);
55
+ const closedByAnchor = useRef(false);
56
+ const pendingJump = useRef(null);
47
57
  const [open, setOpen] = useState(false);
48
58
  const [data, setData] = useState(null);
49
59
  const [failed, setFailed] = useState(false);
@@ -67,10 +77,14 @@ function EmojiPicker({
67
77
  setOpen(nowOpen);
68
78
  onOpenChange?.(nowOpen);
69
79
  if (nowOpen) {
80
+ closedByAnchor.current = false;
81
+ pendingJump.current = null;
70
82
  setRecent(readRecentEmoji(recentStorageKey));
71
83
  setQuery("");
72
84
  setActive(0);
73
- requestAnimationFrame(() => searchRef.current?.focus());
85
+ requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
86
+ } else if (closedByAnchor.current) {
87
+ closedByAnchor.current = false;
74
88
  } else if (el.contains(document.activeElement) || document.activeElement === document.body) {
75
89
  focusTrigger();
76
90
  }
@@ -102,10 +116,7 @@ function EmojiPicker({
102
116
  const el = popoverEl();
103
117
  if (el && isOpen(el)) el.hidePopover();
104
118
  };
105
- const sections = useMemo(() => {
106
- if (query.trim()) {
107
- return data ? [{ key: "results", title: labels.search, emojis: searchEmoji(data, query).map((e) => e.emoji) }] : [];
108
- }
119
+ const browseSections = useMemo(() => {
109
120
  const list = [];
110
121
  if (quickPicks?.length) list.push({ key: "quick", title: labels.quickPicks, emojis: [...new Set(quickPicks)] });
111
122
  if (recent.length) list.push({ key: "recent", title: labels.recent, emojis: recent });
@@ -115,7 +126,11 @@ function EmojiPicker({
115
126
  });
116
127
  }
117
128
  return list;
118
- }, [query, data, quickPicks, recent, labels.search, labels.quickPicks, labels.recent]);
129
+ }, [data, quickPicks, recent, labels.quickPicks, labels.recent]);
130
+ const sections = useMemo(() => {
131
+ if (!query.trim()) return browseSections;
132
+ return data ? [{ key: "results", title: labels.search, emojis: searchEmoji(data, query).map((e) => e.emoji) }] : [];
133
+ }, [query, data, browseSections, labels.search]);
119
134
  const flat = useMemo(() => sections.flatMap((s) => s.emojis), [sections]);
120
135
  const sectionStart = useMemo(() => {
121
136
  const starts = /* @__PURE__ */ new Map();
@@ -132,13 +147,43 @@ function EmojiPicker({
132
147
  onSelect(emoji);
133
148
  hide();
134
149
  };
135
- const focusCell = useCallback((index) => {
150
+ const focusCell = useCallback((index, reveal = true) => {
136
151
  setActive(index);
137
152
  const find = () => gridRef.current?.querySelector(`[data-emoji-index="${index}"]`);
153
+ const move = (cell2) => {
154
+ cell2.focus({ preventScroll: true });
155
+ if (reveal && gridRef.current) revealInGrid(gridRef.current, cell2);
156
+ };
138
157
  const cell = find();
139
- if (cell) cell.focus();
140
- else requestAnimationFrame(() => find()?.focus());
158
+ if (cell) move(cell);
159
+ else requestAnimationFrame(() => {
160
+ const later = find();
161
+ if (later) move(later);
162
+ });
141
163
  }, []);
164
+ const showSection = (key) => {
165
+ const grid = gridRef.current;
166
+ const section = grid?.querySelector(`[data-section="${key}"]`);
167
+ if (!grid || !section) return;
168
+ grid.scrollTop += section.getBoundingClientRect().top - grid.getBoundingClientRect().top;
169
+ const start = sectionStart.get(key);
170
+ if (start !== void 0) focusCell(start, false);
171
+ };
172
+ const onCategory = (key) => {
173
+ if (!query.trim()) {
174
+ showSection(key);
175
+ return;
176
+ }
177
+ pendingJump.current = key;
178
+ setQuery("");
179
+ setActive(0);
180
+ };
181
+ useIsomorphicLayoutEffect(() => {
182
+ const key = pendingJump.current;
183
+ if (key === null || query.trim()) return;
184
+ pendingJump.current = null;
185
+ showSection(key);
186
+ }, [query, sections]);
142
187
  const onGridKeyDown = (event) => {
143
188
  const target = event.target;
144
189
  const index = Number(target.dataset.emojiIndex ?? -1);
@@ -146,7 +191,7 @@ function EmojiPicker({
146
191
  const next = emojiGridMove(index, event.key, flat.length, EMOJI_GRID_COLUMNS);
147
192
  if (event.key === "ArrowUp" && next === null) {
148
193
  event.preventDefault();
149
- searchRef.current?.focus();
194
+ searchRef.current?.focus({ preventScroll: true });
150
195
  return;
151
196
  }
152
197
  if (next === null) return;
@@ -202,6 +247,10 @@ function EmojiPicker({
202
247
  className: "uix-emoji-picker-popover",
203
248
  role: "dialog",
204
249
  "aria-label": labels.dialog,
250
+ closeWhenAnchorHidden: true,
251
+ onAnchorHidden: () => {
252
+ closedByAnchor.current = true;
253
+ },
205
254
  children: open ? /* @__PURE__ */ jsxs("div", { className: "uix-emoji-picker uix-emoji-picker--full", lang: locale, children: [
206
255
  /* @__PURE__ */ jsx(
207
256
  "input",
@@ -221,50 +270,56 @@ function EmojiPicker({
221
270
  spellCheck: false
222
271
  }
223
272
  ),
224
- data && !query.trim() ? /* @__PURE__ */ jsx("div", { className: "uix-emoji-picker__nav", role: "group", "aria-label": labels.categories, children: sections.map((section) => /* @__PURE__ */ jsx(
225
- "button",
273
+ /* @__PURE__ */ jsx(
274
+ "div",
226
275
  {
227
- type: "button",
228
- className: "uix-emoji-picker__nav-btn",
229
- "aria-label": section.title,
230
- title: section.title,
231
- onClick: () => {
232
- const start = section.emojis.length ? sectionStart.get(section.key) ?? -1 : -1;
233
- gridRef.current?.querySelector(`[data-section="${section.key}"]`)?.scrollIntoView({ block: "start" });
234
- if (start >= 0) focusCell(start);
235
- },
236
- children: /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: section.emojis[0] ? renderEmoji(section.emojis[0]) : null })
237
- },
238
- section.key
239
- )) }) : null,
240
- /* @__PURE__ */ jsx("div", { className: "uix-emoji-picker__grid", ref: gridRef, onKeyDown: onGridKeyDown, children: sections.map((section) => {
241
- const start = offset;
242
- offset += section.emojis.length;
243
- if (!section.emojis.length) return null;
244
- const headingId = `${id}-${section.key}`;
245
- return /* @__PURE__ */ jsxs("section", { className: "uix-emoji-picker__section", "data-section": section.key, "aria-labelledby": headingId, children: [
246
- /* @__PURE__ */ jsx("h3", { id: headingId, className: "uix-emoji-picker__heading", children: section.title }),
247
- /* @__PURE__ */ jsx("div", { className: "uix-emoji-picker__cells", role: "group", "aria-labelledby": headingId, children: section.emojis.map((emoji, i) => {
248
- const index = start + i;
249
- return /* @__PURE__ */ jsx(
250
- "button",
251
- {
252
- type: "button",
253
- className: "uix-emoji-picker__btn",
254
- "data-emoji-index": index,
255
- tabIndex: index === activeIndex ? 0 : -1,
256
- "aria-label": names?.get(emoji) ?? emoji,
257
- title: names?.get(emoji),
258
- onClick: () => choose(emoji),
259
- onFocus: () => setActive(index),
260
- children: renderEmoji(emoji)
261
- },
262
- `${section.key}-${emoji}`
263
- );
264
- }) })
265
- ] }, section.key);
266
- }) }),
267
- /* @__PURE__ */ jsx("p", { className: "uix-emoji-picker__status", role: "status", children: status })
276
+ className: "uix-emoji-picker__nav",
277
+ role: data ? "group" : void 0,
278
+ "aria-label": data ? labels.categories : void 0,
279
+ children: data ? browseSections.map((section) => /* @__PURE__ */ jsx(
280
+ "button",
281
+ {
282
+ type: "button",
283
+ className: "uix-emoji-picker__nav-btn",
284
+ "aria-label": section.title,
285
+ title: section.title,
286
+ onClick: () => onCategory(section.key),
287
+ children: /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: section.emojis[0] ? renderEmoji(section.emojis[0]) : null })
288
+ },
289
+ section.key
290
+ )) : null
291
+ }
292
+ ),
293
+ /* @__PURE__ */ jsxs("div", { className: "uix-emoji-picker__grid", ref: gridRef, onKeyDown: onGridKeyDown, children: [
294
+ sections.map((section) => {
295
+ const start = offset;
296
+ offset += section.emojis.length;
297
+ if (!section.emojis.length) return null;
298
+ const headingId = `${id}-${section.key}`;
299
+ return /* @__PURE__ */ jsxs("section", { className: "uix-emoji-picker__section", "data-section": section.key, "aria-labelledby": headingId, children: [
300
+ /* @__PURE__ */ jsx("h3", { id: headingId, className: "uix-emoji-picker__heading", children: section.title }),
301
+ /* @__PURE__ */ jsx("div", { className: "uix-emoji-picker__cells", role: "group", "aria-labelledby": headingId, children: section.emojis.map((emoji, i) => {
302
+ const index = start + i;
303
+ return /* @__PURE__ */ jsx(
304
+ "button",
305
+ {
306
+ type: "button",
307
+ className: "uix-emoji-picker__btn",
308
+ "data-emoji-index": index,
309
+ tabIndex: index === activeIndex ? 0 : -1,
310
+ "aria-label": names?.get(emoji) ?? emoji,
311
+ title: names?.get(emoji),
312
+ onClick: () => choose(emoji),
313
+ onFocus: () => setActive(index),
314
+ children: renderEmoji(emoji)
315
+ },
316
+ `${section.key}-${emoji}`
317
+ );
318
+ }) })
319
+ ] }, section.key);
320
+ }),
321
+ /* @__PURE__ */ jsx("p", { className: "uix-emoji-picker__status", role: "status", children: status })
322
+ ] })
268
323
  ] }) : null
269
324
  }
270
325
  )
@@ -10,16 +10,26 @@ interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
10
10
  popover?: 'auto' | 'manual';
11
11
  /**
12
12
  * Element (or ref) to anchor against. When set, the popover is placed with
13
- * cross-browser JS positioning — flip when it won't fit, shift to stay on-screen —
14
- * instead of relying on CSS `anchor()`, which is Chromium-only and detaches the
15
- * popover from its trigger everywhere else. The native Popover API still provides
16
- * the top layer (so it escapes `overflow` clipping) and light-dismiss.
13
+ * cross-browser JS positioning — flip when it won't fit, shift to stay on-screen.
14
+ * Where the browser supports CSS anchor positioning, the placement is written as an
15
+ * offset from the anchor, so it stays attached while the page scrolls; elsewhere it
16
+ * follows scroll from JS. The native Popover API still provides the top layer (so it
17
+ * escapes `overflow` clipping) and light-dismiss.
17
18
  */
18
19
  anchor?: RefObject<HTMLElement | null> | HTMLElement | null;
19
20
  /** Preferred placement when `anchor` is set. Default `'bottom-start'`. */
20
21
  placement?: Placement;
21
22
  /** Gap between the trigger and the popover, in px. Default 6. */
22
23
  offset?: number;
24
+ /**
25
+ * Close the popover once its `anchor` has scrolled completely out of view (outside the
26
+ * viewport or a clipping ancestor), e.g. a picker opened from an editor that the user
27
+ * scrolls away from. Focus inside the popover is let go rather than sent back to the
28
+ * hidden anchor. Needs `anchor`. Default `false`.
29
+ */
30
+ closeWhenAnchorHidden?: boolean;
31
+ /** Called when `closeWhenAnchorHidden` is about to close the popover. */
32
+ onAnchorHidden?: () => void;
23
33
  children?: ReactNode;
24
34
  }
25
35
  /**
@@ -27,6 +37,6 @@ interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
27
37
  * things like a filter popover, menu, or rich select by putting controls inside.
28
38
  * Pass `anchor` to get collision-aware, cross-browser placement (UIX-FIX-02).
29
39
  */
30
- declare function Popover({ popover, anchor, placement, offset, className, children, ...props }: PopoverProps): react.JSX.Element;
40
+ declare function Popover({ popover, anchor, placement, offset, closeWhenAnchorHidden, onAnchorHidden, className, children, ...props }: PopoverProps): react.JSX.Element;
31
41
 
32
42
  export { Popover, type PopoverProps };
@@ -1,19 +1,38 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState } from "react";
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { cx } from "../cx.js";
5
- import { useAnchoredPosition } from "../hooks/useAnchoredPosition.js";
5
+ import { resolveAnchor, useAnchoredPosition } from "../hooks/useAnchoredPosition.js";
6
+ const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
7
+ const PLACING = "data-uix-placing";
8
+ const isPopoverOpen = (el) => {
9
+ try {
10
+ return el.matches(":popover-open");
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+ const settle = (el) => {
16
+ if (!el.hasAttribute(PLACING)) return;
17
+ getComputedStyle(el).getPropertyValue("transform");
18
+ el.removeAttribute(PLACING);
19
+ };
6
20
  function Popover({
7
21
  popover = "auto",
8
22
  anchor,
9
23
  placement = "bottom-start",
10
24
  offset = 6,
25
+ closeWhenAnchorHidden = false,
26
+ onAnchorHidden,
11
27
  className,
12
28
  children,
13
29
  ...props
14
30
  }) {
15
31
  const ref = useRef(null);
16
32
  const [open, setOpen] = useState(false);
33
+ const openRef = useRef(false);
34
+ const onAnchorHiddenRef = useRef(onAnchorHidden);
35
+ onAnchorHiddenRef.current = onAnchorHidden;
17
36
  const popoverAttr = { popover };
18
37
  const reposition = useAnchoredPosition(anchor ?? null, ref, {
19
38
  open: open && !!anchor,
@@ -23,14 +42,55 @@ function Popover({
23
42
  useEffect(() => {
24
43
  const el = ref.current;
25
44
  if (!el || !anchor) return;
45
+ const hold = (event) => {
46
+ if (event.newState !== "open") return;
47
+ el.setAttribute(PLACING, "");
48
+ window.setTimeout(() => settle(el), 150);
49
+ };
26
50
  const sync = () => {
27
- const isOpen = el.matches(":popover-open");
28
- if (isOpen) reposition();
29
- setOpen(isOpen);
51
+ if (!isPopoverOpen(el)) {
52
+ el.removeAttribute(PLACING);
53
+ setOpen(false);
54
+ return;
55
+ }
56
+ reposition();
57
+ window.setTimeout(() => {
58
+ if (!isPopoverOpen(el)) return;
59
+ if (!openRef.current) {
60
+ setOpen(true);
61
+ return;
62
+ }
63
+ reposition();
64
+ settle(el);
65
+ }, 0);
30
66
  };
67
+ el.addEventListener("beforetoggle", hold);
31
68
  el.addEventListener("toggle", sync);
32
- return () => el.removeEventListener("toggle", sync);
69
+ return () => {
70
+ el.removeEventListener("beforetoggle", hold);
71
+ el.removeEventListener("toggle", sync);
72
+ el.removeAttribute(PLACING);
73
+ };
33
74
  }, [anchor, reposition]);
75
+ useIsomorphicLayoutEffect(() => {
76
+ openRef.current = open;
77
+ if (open && ref.current) settle(ref.current);
78
+ }, [open]);
79
+ useEffect(() => {
80
+ const el = ref.current;
81
+ const anchorEl = resolveAnchor(anchor);
82
+ if (!closeWhenAnchorHidden || !open || !el || !anchorEl || typeof IntersectionObserver !== "function") return;
83
+ const observer = new IntersectionObserver((entries) => {
84
+ const latest = entries[entries.length - 1];
85
+ if (!latest || latest.isIntersecting || !isPopoverOpen(el)) return;
86
+ onAnchorHiddenRef.current?.();
87
+ const active = el.ownerDocument.activeElement;
88
+ if (active && el.contains(active)) active.blur();
89
+ el.hidePopover();
90
+ });
91
+ observer.observe(anchorEl);
92
+ return () => observer.disconnect();
93
+ }, [closeWhenAnchorHidden, open, anchor]);
34
94
  return /* @__PURE__ */ jsx("div", { ref, className: cx("uix-popover", className), ...popoverAttr, ...props, children });
35
95
  }
36
96
  export {
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactElement } from 'react';
3
- import { P as Placement } from './overlay-position-CEGSz6Z2.cjs';
3
+ import { P as Placement } from './overlay-position-Cjv5E7mn.cjs';
4
4
 
5
5
  type EmojiLocale = 'en' | 'de';
6
6
  interface EmojiEntry {
@@ -106,6 +106,9 @@ declare const EMOJI_GRID_COLUMNS = 8;
106
106
  * Emoji picker in a native popover: search, quick picks, recently used and the
107
107
  * Unicode categories, with names in English or German. The emoji dataset loads on
108
108
  * first open. The grid is one tab stop; arrow keys, Home and End move within it.
109
+ * The picker is one size in every state (loading, searching, failed), so it keeps the
110
+ * side it opened on; it stays attached to its trigger while the page scrolls and
111
+ * closes once the trigger is scrolled out of view.
109
112
  */
110
113
  declare function EmojiPicker({ onSelect, trigger, labels: labelOverrides, locale, quickPicks, loadData, recentStorageKey, placement, onOpenChange, className, emojiImageBaseUrl, }: EmojiPickerProps): react.JSX.Element;
111
114