@zvk/ui 0.1.6 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.7](https://github.com/brandon-schabel/zvk/compare/v0.1.6...v0.1.7) (2026-06-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * harden popup positioning ([efb8c8a](https://github.com/brandon-schabel/zvk/commit/efb8c8a98c2b759f4e84a60f5ae292b1e8900907))
9
+
3
10
  ## [0.1.6](https://github.com/brandon-schabel/zvk/compare/v0.1.5...v0.1.6) (2026-06-12)
4
11
 
5
12
 
@@ -14,6 +14,8 @@ export interface ContextMenuContentProps extends React.HTMLAttributes<HTMLDivEle
14
14
  side?: FloatingSide;
15
15
  align?: FloatingAlign;
16
16
  alignOffset?: number;
17
+ sideOffset?: number;
18
+ collisionPadding?: number;
17
19
  ref?: React.Ref<HTMLDivElement>;
18
20
  }
19
21
  export interface ContextMenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -38,7 +40,7 @@ export interface ContextMenuRadioItemProps extends ContextMenuItemProps {
38
40
  }
39
41
  declare function ContextMenuRoot({ children, className, defaultOpen, onOpenChange, open: openProp, ref, ...props }: ContextMenuProps): React.JSX.Element;
40
42
  declare function ContextMenuTrigger({ asChild, children, className, onContextMenu, onKeyDown, ref, ...props }: ContextMenuTriggerProps): React.JSX.Element;
41
- declare function ContextMenuContent({ align, alignOffset: _alignOffset, children, className, onKeyDown, ref, side, style, ...props }: ContextMenuContentProps): React.JSX.Element | null;
43
+ declare function ContextMenuContent({ align, alignOffset, children, className, collisionPadding, onKeyDown, ref, side, sideOffset, style, ...props }: ContextMenuContentProps): React.JSX.Element | null;
42
44
  declare function ContextMenuItem({ asChild, children, className, disabled, onClick, onKeyDown, onSelect, ref, tone, type, ...props }: ContextMenuItemProps): React.JSX.Element;
43
45
  declare function ContextMenuLabel({ className, ref, ...props }: ContextMenuLabelProps): React.JSX.Element;
44
46
  declare function ContextMenuCheckboxItem({ asChild, checked, children, className, defaultChecked, disabled, onCheckedChange, onClick, onKeyDown, onSelect, ref, tone, type, ...props }: ContextMenuCheckboxItemProps): React.JSX.Element;
@@ -6,6 +6,7 @@ import { cn } from "../../utils/cn.js";
6
6
  import { useControllableState } from "../../hooks/use-controllable-state.js";
7
7
  import { createCollection } from "../../internal/collection/index.js";
8
8
  import { DismissableLayer } from "../../internal/dismissable-layer/index.js";
9
+ import { clamp, computeFloatingPosition } from "../../internal/floating/index.js";
9
10
  import { placementFromSideAlign, placementParts } from "../../internal/floating/placement-aliases.js";
10
11
  import { Portal } from "../../internal/portal/index.js";
11
12
  import { Slot } from "../../internal/slot/index.js";
@@ -114,20 +115,73 @@ function currentIndex(items) {
114
115
  const enabled = items.filter((item) => item.data.disabled !== true && item.data.ref !== null);
115
116
  return enabled.findIndex((item) => item.data.ref === document.activeElement);
116
117
  }
117
- function ContextMenuContent({ align, alignOffset: _alignOffset, children, className, onKeyDown, ref, side, style, ...props }) {
118
+ function ContextMenuContent({ align, alignOffset = 0, children, className, collisionPadding = 4, onKeyDown, ref, side, sideOffset = 0, style, ...props }) {
118
119
  const { close, contentId, getItems, open, point } = useContextMenuContext("ContextMenu.Content");
119
120
  const contentPlacement = placementFromSideAlign(side, align, "bottom-start");
120
- const contentPlacementParts = placementParts(contentPlacement);
121
+ const [contentStyle, setContentStyle] = React.useState({});
122
+ const [resolvedPlacement, setResolvedPlacement] = React.useState(contentPlacement);
123
+ const contentRef = React.useRef(null);
124
+ const updatePosition = React.useCallback(() => {
125
+ const node = contentRef.current;
126
+ if (node === null || typeof window === "undefined") {
127
+ return;
128
+ }
129
+ const rect = node.getBoundingClientRect();
130
+ const computed = computeFloatingPosition({
131
+ reference: { x: point.x, y: point.y, width: 0, height: 0 },
132
+ floating: { x: 0, y: 0, width: rect.width, height: rect.height },
133
+ boundary: { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight },
134
+ placement: contentPlacement,
135
+ strategy: "fixed",
136
+ offset: sideOffset,
137
+ alignmentOffset: alignOffset,
138
+ collisionPadding,
139
+ flip: true,
140
+ shift: true
141
+ });
142
+ const minX = collisionPadding;
143
+ const minY = collisionPadding;
144
+ const maxX = Math.max(minX, window.innerWidth - collisionPadding - rect.width);
145
+ const maxY = Math.max(minY, window.innerHeight - collisionPadding - rect.height);
146
+ setContentStyle({
147
+ position: computed.strategy,
148
+ left: `${clamp(computed.x, minX, maxX)}px`,
149
+ top: `${clamp(computed.y, minY, maxY)}px`
150
+ });
151
+ setResolvedPlacement(computed.placement);
152
+ }, [alignOffset, collisionPadding, contentPlacement, point.x, point.y, sideOffset]);
153
+ const schedulePositionUpdate = React.useCallback(() => {
154
+ if (typeof queueMicrotask === "function") {
155
+ queueMicrotask(updatePosition);
156
+ return;
157
+ }
158
+ window.setTimeout(updatePosition, 0);
159
+ }, [updatePosition]);
121
160
  React.useLayoutEffect(() => {
122
161
  if (!open) {
123
162
  return;
124
163
  }
125
164
  queueMicrotask(() => focusItem(getItems(), 0));
126
165
  }, [getItems, open]);
166
+ React.useLayoutEffect(() => {
167
+ if (!open) {
168
+ return;
169
+ }
170
+ updatePosition();
171
+ window.addEventListener("resize", updatePosition);
172
+ return () => {
173
+ window.removeEventListener("resize", updatePosition);
174
+ };
175
+ }, [open, updatePosition]);
127
176
  if (!open) {
128
177
  return null;
129
178
  }
130
- return (_jsx(Portal, { children: _jsx(DismissableLayer, { open: open, onDismiss: close, children: _jsx("div", { ...props, ref: ref, id: contentId, role: "menu", className: cn("zvk-ui-context-menu__content", className), "data-align": contentPlacementParts.align, "data-side": contentPlacementParts.side, style: { ...style, left: `${point.x}px`, top: `${point.y}px` }, onKeyDown: composeEventHandlers(onKeyDown, (event) => {
179
+ return (_jsx(Portal, { children: _jsx(DismissableLayer, { open: open, onDismiss: close, children: _jsx("div", { ...props, ref: (node) => {
180
+ setComposedRef(contentRef, ref, node);
181
+ if (node !== null) {
182
+ schedulePositionUpdate();
183
+ }
184
+ }, id: contentId, role: "menu", className: cn("zvk-ui-context-menu__content", className), "data-align": placementParts(resolvedPlacement).align, "data-side": placementParts(resolvedPlacement).side, style: { ...style, ...contentStyle }, onKeyDown: composeEventHandlers(onKeyDown, (event) => {
131
185
  const items = getItems();
132
186
  const index = currentIndex(items);
133
187
  if (event.key === "ArrowDown") {
@@ -1,2 +1,2 @@
1
1
  export { ContextMenu } from "./context-menu.js";
2
- export type { ContextMenuCheckboxItemProps, ContextMenuContentProps, ContextMenuItemProps, ContextMenuLabelProps, ContextMenuProps, ContextMenuSeparatorProps, ContextMenuTriggerProps } from "./context-menu.js";
2
+ export type { ContextMenuCheckboxItemProps, ContextMenuContentProps, ContextMenuItemProps, ContextMenuLabelProps, ContextMenuProps, ContextMenuRadioItemProps, ContextMenuSeparatorProps, ContextMenuTriggerProps } from "./context-menu.js";
@@ -41,9 +41,9 @@ export type { CommandDialogProps, CommandEmptyProps, CommandGroupProps, CommandI
41
41
  export { Combobox } from "./combobox/index.js";
42
42
  export type { ComboboxOption, ComboboxProps } from "./combobox/index.js";
43
43
  export { ContextMenu } from "./context-menu/index.js";
44
- export type { ContextMenuCheckboxItemProps, ContextMenuContentProps, ContextMenuItemProps, ContextMenuLabelProps, ContextMenuProps, ContextMenuSeparatorProps, ContextMenuTriggerProps } from "./context-menu/index.js";
44
+ export type { ContextMenuCheckboxItemProps, ContextMenuContentProps, ContextMenuItemProps, ContextMenuLabelProps, ContextMenuProps, ContextMenuRadioItemProps, ContextMenuSeparatorProps, ContextMenuTriggerProps } from "./context-menu/index.js";
45
45
  export { Menubar } from "./menubar/index.js";
46
- export type { MenubarContentProps, MenubarItemProps, MenubarLabelProps, MenubarMenuProps, MenubarProps, MenubarSeparatorProps, MenubarShortcutProps, MenubarTriggerProps } from "./menubar/index.js";
46
+ export type { MenubarCheckboxItemProps, MenubarContentProps, MenubarItemProps, MenubarLabelProps, MenubarMenuProps, MenubarProps, MenubarRadioItemProps, MenubarSeparatorProps, MenubarShortcutProps, MenubarTriggerProps } from "./menubar/index.js";
47
47
  export { EmptyState } from "./empty-state/index.js";
48
48
  export type { EmptyStateAlign, EmptyStateProps, EmptyStateSize } from "./empty-state/index.js";
49
49
  export { ErrorBoundary, ErrorFallback } from "./error-boundary/index.js";
@@ -1,2 +1,2 @@
1
1
  export { Menubar } from "./menubar.js";
2
- export type { MenubarContentProps, MenubarItemProps, MenubarLabelProps, MenubarMenuProps, MenubarProps, MenubarSeparatorProps, MenubarShortcutProps, MenubarTriggerProps } from "./menubar.js";
2
+ export type { MenubarCheckboxItemProps, MenubarContentProps, MenubarItemProps, MenubarLabelProps, MenubarMenuProps, MenubarProps, MenubarRadioItemProps, MenubarSeparatorProps, MenubarShortcutProps, MenubarTriggerProps } from "./menubar.js";
@@ -46,7 +46,7 @@ export interface MenubarShortcutProps extends React.HTMLAttributes<HTMLSpanEleme
46
46
  declare function MenubarRoot({ children, className, defaultValue, onKeyDown, onValueChange, ref, value, ...props }: MenubarProps): React.JSX.Element;
47
47
  declare function MenubarMenu({ children, value }: MenubarMenuProps): React.JSX.Element;
48
48
  declare function MenubarTrigger({ asChild, children, className, disabled, onClick, onKeyDown, ref, type, ...props }: MenubarTriggerProps): React.JSX.Element;
49
- declare function MenubarContent({ align, alignOffset: _alignOffset, children, className, onKeyDown, ref, side, sideOffset: _sideOffset, ...props }: MenubarContentProps): React.JSX.Element | null;
49
+ declare function MenubarContent({ align, alignOffset, children, className, onKeyDown, ref, side, sideOffset, style, ...props }: MenubarContentProps): React.JSX.Element | null;
50
50
  declare function MenubarItem({ asChild, children, className, disabled, onClick, onKeyDown, onSelect, ref, type, ...props }: MenubarItemProps): React.JSX.Element;
51
51
  declare function MenubarCheckboxItem({ asChild, checked, children, className, defaultChecked, disabled, onCheckedChange, onClick, onKeyDown, onSelect, ref, type, ...props }: MenubarCheckboxItemProps): React.JSX.Element;
52
52
  declare function MenubarRadioItem({ asChild, checked, children, className, disabled, onClick, onKeyDown, onSelect, ref, type, ...props }: MenubarRadioItemProps): React.JSX.Element;
@@ -6,6 +6,7 @@ import { cn } from "../../utils/cn.js";
6
6
  import { useControllableState } from "../../hooks/use-controllable-state.js";
7
7
  import { createCollection } from "../../internal/collection/index.js";
8
8
  import { DismissableLayer } from "../../internal/dismissable-layer/index.js";
9
+ import { useFloatingPosition } from "../../internal/floating/index.js";
9
10
  import { placementFromSideAlign, placementParts } from "../../internal/floating/placement-aliases.js";
10
11
  import { Portal } from "../../internal/portal/index.js";
11
12
  import { Slot } from "../../internal/slot/index.js";
@@ -55,6 +56,14 @@ function setComposedRef(internalRef, externalRef, node) {
55
56
  externalRef.current = node;
56
57
  }
57
58
  }
59
+ function setForwardedRef(externalRef, node) {
60
+ if (typeof externalRef === "function") {
61
+ externalRef(node);
62
+ }
63
+ else if (externalRef !== undefined && externalRef !== null) {
64
+ externalRef.current = node;
65
+ }
66
+ }
58
67
  function isActivationKey(key) {
59
68
  return key === "Enter" || key === " " || key === "Space" || key === "Spacebar";
60
69
  }
@@ -172,15 +181,34 @@ function MenubarTrigger({ asChild = false, children, className, disabled, onClic
172
181
  setComposedRef(triggerRef, ref, node);
173
182
  }, id: menu.triggerId, type: type, role: "menuitem", disabled: disabled, "aria-controls": menu.contentId, "aria-expanded": menu.open ? "true" : "false", "aria-haspopup": "menu", className: cn("zvk-ui-menubar__trigger", className), "data-disabled": disabled ? "true" : undefined, "data-state": menu.open ? "open" : "closed", onClick: triggerProps.onClick, onKeyDown: triggerProps.onKeyDown, children: children }));
174
183
  }
175
- function MenubarContent({ align, alignOffset: _alignOffset, children, className, onKeyDown, ref, side, sideOffset: _sideOffset, ...props }) {
184
+ function MenubarContent({ align, alignOffset = 0, children, className, onKeyDown, ref, side, sideOffset = 4, style, ...props }) {
176
185
  const menubar = useMenubarContext("Menubar.Content");
177
186
  const menu = useMenuContext("Menubar.Content");
178
187
  const contentPlacement = placementFromSideAlign(side, align, "bottom-start");
179
- const contentPlacementParts = placementParts(contentPlacement);
188
+ const { floatingRef, floatingStyle, placement: resolvedPlacement, referenceRef } = useFloatingPosition({
189
+ open: menu.open,
190
+ placement: contentPlacement,
191
+ strategy: "fixed",
192
+ offset: sideOffset,
193
+ alignmentOffset: alignOffset,
194
+ collisionPadding: 4
195
+ });
196
+ React.useLayoutEffect(() => {
197
+ if (!menu.open) {
198
+ referenceRef(null);
199
+ return;
200
+ }
201
+ const activeTrigger = menubar.getTriggers().find((trigger) => trigger.data.value === menu.value);
202
+ referenceRef(activeTrigger?.data.ref ?? null);
203
+ return () => referenceRef(null);
204
+ }, [menubar, menu.open, menu.value, referenceRef]);
180
205
  if (!menu.open) {
181
206
  return null;
182
207
  }
183
- return (_jsx(Portal, { children: _jsx(DismissableLayer, { open: menu.open, onDismiss: () => menubar.setOpenValue(undefined), children: _jsx("div", { ...props, ref: ref, id: menu.contentId, role: "menu", "aria-label": menu.label, "aria-labelledby": menu.triggerId, className: cn("zvk-ui-menubar__content", className), "data-align": contentPlacementParts.align, "data-side": contentPlacementParts.side, onKeyDown: composeEventHandlers(onKeyDown, (event) => {
208
+ return (_jsx(Portal, { children: _jsx(DismissableLayer, { open: menu.open, onDismiss: () => menubar.setOpenValue(undefined), children: _jsx("div", { ...props, ref: (node) => {
209
+ floatingRef(node);
210
+ setForwardedRef(ref, node);
211
+ }, id: menu.contentId, role: "menu", "aria-label": menu.label, "aria-labelledby": menu.triggerId, className: cn("zvk-ui-menubar__content", className), style: { ...style, ...floatingStyle }, "data-align": placementParts(resolvedPlacement).align, "data-side": placementParts(resolvedPlacement).side, onKeyDown: composeEventHandlers(onKeyDown, (event) => {
184
212
  const items = menu.getItems();
185
213
  const index = activeIndex(items);
186
214
  if (event.key === "ArrowDown") {
@@ -1,18 +1,30 @@
1
1
  function hasBrowserApis() {
2
2
  return typeof window !== "undefined" && typeof document !== "undefined";
3
3
  }
4
+ function scheduleFrame(callback) {
5
+ if (typeof window.requestAnimationFrame === "function") {
6
+ const frame = window.requestAnimationFrame(callback);
7
+ return () => {
8
+ if (typeof window.cancelAnimationFrame === "function") {
9
+ window.cancelAnimationFrame(frame);
10
+ }
11
+ };
12
+ }
13
+ const timeout = window.setTimeout(callback, 0);
14
+ return () => {
15
+ window.clearTimeout(timeout);
16
+ };
17
+ }
4
18
  export function autoUpdateFloating(params) {
5
19
  const { enabled, reference, floating, update } = params;
6
20
  if (!hasBrowserApis() || !enabled || reference === null || floating === null) {
7
21
  return () => { };
8
22
  }
9
- let frameRequest = null;
23
+ let cancelScheduledUpdate = null;
10
24
  const scheduleUpdate = () => {
11
- if (frameRequest !== null) {
12
- cancelAnimationFrame(frameRequest);
13
- }
14
- frameRequest = window.requestAnimationFrame(() => {
15
- frameRequest = null;
25
+ cancelScheduledUpdate?.();
26
+ cancelScheduledUpdate = scheduleFrame(() => {
27
+ cancelScheduledUpdate = null;
16
28
  update();
17
29
  });
18
30
  };
@@ -40,9 +52,9 @@ export function autoUpdateFloating(params) {
40
52
  for (const cleanup of cleanupFns) {
41
53
  cleanup();
42
54
  }
43
- if (frameRequest !== null) {
44
- cancelAnimationFrame(frameRequest);
45
- frameRequest = null;
55
+ if (cancelScheduledUpdate !== null) {
56
+ cancelScheduledUpdate();
57
+ cancelScheduledUpdate = null;
46
58
  }
47
59
  };
48
60
  }
package/dist/styles.css CHANGED
@@ -3663,7 +3663,6 @@
3663
3663
  color: var(--zvk-ui-color-foreground);
3664
3664
  display: grid;
3665
3665
  gap: var(--zvk-ui-space-1);
3666
- margin-block-start: var(--zvk-ui-space-1);
3667
3666
  min-inline-size: 12rem;
3668
3667
  padding: var(--zvk-ui-space-1);
3669
3668
  position: fixed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvk/ui",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "A polished, zero-runtime-dependency React component library for ZvkUi applications.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -270,13 +270,14 @@
270
270
  "test:ssr": "vitest run tests/ssr --environment node",
271
271
  "test:exports": "vitest run tests/exports --environment node",
272
272
  "test:accessibility": "vitest run tests/accessibility",
273
+ "test:browser": "playwright test --config playwright.config.ts",
273
274
  "test:types": "tsd",
274
275
  "docs:lint": "bun run scripts/lint-docs.mjs",
275
276
  "verify:style-contract": "bun run scripts/verify-style-contract.mjs",
276
277
  "validate:exports": "bun run scripts/validate-exports.mjs",
277
278
  "tarball:inspect": "bun run scripts/check-tarball.mjs",
278
279
  "pack:dry": "npm pack --dry-run",
279
- "preflight": "bun run typecheck && bun run build && bun run test:unit && bun run test:ssr && bun run test:types && bun run test:exports && bun run test:accessibility && bun run docs:lint && bun run verify:style-contract && bun run validate:exports && bun run tarball:inspect && bun run pack:dry"
280
+ "preflight": "bun run typecheck && bun run build && bun run test:unit && bun run test:ssr && bun run test:types && bun run test:exports && bun run test:accessibility && bun run test:browser && bun run docs:lint && bun run verify:style-contract && bun run validate:exports && bun run tarball:inspect && bun run pack:dry"
280
281
  },
281
282
  "peerDependencies": {
282
283
  "react": "^19.0.0",
@@ -287,6 +288,7 @@
287
288
  "directory": "tests/types"
288
289
  },
289
290
  "devDependencies": {
291
+ "@playwright/test": "^1.60.0",
290
292
  "@testing-library/jest-dom": "^6.9.1",
291
293
  "@testing-library/react": "^16.3.2",
292
294
  "@testing-library/user-event": "^14.6.1",