@remit/ui 0.0.146 → 0.0.147

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": "@remit/ui",
3
- "version": "0.0.146",
3
+ "version": "0.0.147",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -102,6 +102,12 @@ export interface FolderTreePickerProps {
102
102
  ) => Promise<FolderTreeNode>;
103
103
  /** Escape. The picker never owns its presentation, so it cannot close itself. */
104
104
  onCancel?: () => void;
105
+ /**
106
+ * Takes focus on mount. A picker that opens away from where it was triggered
107
+ * — a portalled popover — leaves the keyboard behind on the trigger, so Tab
108
+ * walks the surrounding toolbar instead of entering the tree.
109
+ */
110
+ autoFocusFilter?: boolean;
105
111
  /** The provider's hierarchy separator. */
106
112
  delimiter?: string;
107
113
  labels?: FolderTreePickerLabels;
@@ -147,6 +153,7 @@ export const FolderTreePicker = ({
147
153
  onCancel,
148
154
  delimiter = "/",
149
155
  labels,
156
+ autoFocusFilter = false,
150
157
  }: FolderTreePickerProps) => {
151
158
  const text = { ...defaultLabels, ...labels };
152
159
  const [query, setQuery] = useState("");
@@ -163,10 +170,16 @@ export const FolderTreePicker = ({
163
170
  const [focusedIndex, setFocusedIndex] = useState(-1);
164
171
 
165
172
  const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);
173
+ const filterRef = useRef<HTMLInputElement>(null);
166
174
  const roving = useRef(false);
167
175
  const createAbort = useRef<AbortController | null>(null);
168
176
  useEffect(() => () => createAbort.current?.abort(), []);
169
177
 
178
+ useEffect(() => {
179
+ if (!autoFocusFilter) return;
180
+ filterRef.current?.focus();
181
+ }, [autoFocusFilter]);
182
+
170
183
  const trimmedQuery = query.trim().toLowerCase();
171
184
  const ordered = useMemo(
172
185
  () => orderFolderNodes(folders, delimiter),
@@ -413,6 +426,7 @@ export const FolderTreePicker = ({
413
426
  return (
414
427
  <div className="flex min-h-0 w-full min-w-0 flex-col">
415
428
  <Input
429
+ ref={filterRef}
416
430
  variant="inline"
417
431
  className="border-b border-line px-3 py-2"
418
432
  icon={<Search className="size-4" aria-hidden="true" />}
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Mail, MailOpen, Tag } from "lucide-react";
3
- import { PopoverMenu } from "./popover-menu.js";
3
+ import { useState } from "react";
4
+ import { PopoverMenu, PopoverMenuRow } from "./popover-menu.js";
4
5
 
5
6
  const meta: Meta<typeof PopoverMenu> = {
6
7
  title: "Kit/PopoverMenu",
@@ -78,6 +79,50 @@ export const ManyItems: Story = {
78
79
  },
79
80
  };
80
81
 
82
+ function GrowingMenu() {
83
+ const [rows, setRows] = useState(2);
84
+ return (
85
+ <div className="flex h-screen items-end justify-end p-4">
86
+ <PopoverMenu
87
+ triggerLabel="More actions"
88
+ items={Array.from({ length: rows }, (_, i) => ({
89
+ key: `label-${i}`,
90
+ label: `Label ${i + 1}`,
91
+ icon: <Tag className="size-4" />,
92
+ onSelect: () => undefined,
93
+ }))}
94
+ >
95
+ <PopoverMenuRow
96
+ label="Show more"
97
+ onSelect={() => setRows((current) => current + 10)}
98
+ />
99
+ </PopoverMenu>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ /**
105
+ * A panel that grows after it has been placed — a list arriving from a fetch,
106
+ * a confirmation bar appearing on a pick. It stays anchored to its trigger as
107
+ * it grows, rather than keeping the position it was given at its opening size
108
+ * and running off the bottom of the screen.
109
+ */
110
+ export const GrowsAfterOpening: Story = {
111
+ name: "Panel grows after it opens",
112
+ parameters: { layout: "fullscreen" },
113
+ render: () => <GrowingMenu />,
114
+ play: async ({ canvasElement }) => {
115
+ canvasElement
116
+ .querySelector<HTMLButtonElement>('[aria-label="More actions"]')
117
+ ?.click();
118
+ await new Promise((resolve) => setTimeout(resolve, 60));
119
+ // The panel is portalled onto the body, so it is outside the canvas.
120
+ Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
121
+ .find((button) => button.textContent?.trim() === "Show more")
122
+ ?.click();
123
+ },
124
+ };
125
+
81
126
  export const WithNestedPicker: Story = {
82
127
  args: {
83
128
  triggerLabel: "More actions",
@@ -53,8 +53,11 @@ function clampToViewport(
53
53
  /**
54
54
  * Measures the anchor and the panel's own size, then keeps the panel's fixed
55
55
  * position clamped to the viewport for as long as it is open — reset on every
56
- * resize and on scroll anywhere in the ancestor chain, since a fixed position
57
- * does not follow a scrolled anchor on its own.
56
+ * resize, on scroll anywhere in the ancestor chain (a fixed position does not
57
+ * follow a scrolled anchor on its own), and whenever the panel's own box
58
+ * changes. A panel placed once at the size it opened with runs off the bottom
59
+ * as soon as its content arrives: a loading line gives way to a full list, a
60
+ * confirmation bar appears on a pick, a filter shrinks it back.
58
61
  */
59
62
  function useAnchoredPlacement(
60
63
  panelRef: RefObject<HTMLElement | null>,
@@ -92,7 +95,14 @@ function useAnchoredPlacement(
92
95
  place();
93
96
  window.addEventListener("resize", place);
94
97
  window.addEventListener("scroll", place, true);
98
+ const panel = panelRef.current;
99
+ let observer: ResizeObserver | null = null;
100
+ if (panel && typeof ResizeObserver !== "undefined") {
101
+ observer = new ResizeObserver(place);
102
+ observer.observe(panel);
103
+ }
95
104
  return () => {
105
+ observer?.disconnect();
96
106
  window.removeEventListener("resize", place);
97
107
  window.removeEventListener("scroll", place, true);
98
108
  };
@@ -116,6 +126,11 @@ export interface PopoverMenuPortalProps {
116
126
  * page — the compose body, a card, anything with its own `overflow` — no
117
127
  * matter how high its `z-index` climbs; escaping that ancestor takes leaving
118
128
  * its DOM subtree, which only a portal does.
129
+ *
130
+ * The wrapper carries the menu layer's own `z-50`: as a body child it would
131
+ * otherwise stack by document order alone and lose to every fixed surface
132
+ * already on the page. `z-[60]` stays above it, for confirmation dialogs and
133
+ * error banners that must cover an open menu.
119
134
  */
120
135
  export function PopoverMenuPortal({
121
136
  open,
@@ -127,7 +142,10 @@ export function PopoverMenuPortal({
127
142
  const style = useAnchoredPlacement(panelRef, open, align, getAnchor);
128
143
  if (!open) return null;
129
144
  return createPortal(
130
- <div style={style ?? { position: "fixed", visibility: "hidden" }}>
145
+ <div
146
+ className="z-50"
147
+ style={style ?? { position: "fixed", visibility: "hidden" }}
148
+ >
131
149
  {children}
132
150
  </div>,
133
151
  document.body,
package/src/index.ts CHANGED
@@ -493,6 +493,7 @@ export {
493
493
  type PopoverMenuItem,
494
494
  PopoverMenuPanel,
495
495
  type PopoverMenuPanelProps,
496
+ PopoverMenuPortal,
496
497
  type PopoverMenuProps,
497
498
  PopoverMenuRow,
498
499
  type PopoverMenuRowProps,