@zvk/ui 0.1.6 → 0.1.8

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.8](https://github.com/brandon-schabel/zvk/compare/v0.1.7...v0.1.8) (2026-06-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * add composite component package ([dc0920f](https://github.com/brandon-schabel/zvk/commit/dc0920fe77dd2bd63015a40b3d7675fe8f1d0067))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * restore preview source aliases ([6fd494a](https://github.com/brandon-schabel/zvk/commit/6fd494af071bc4dd3fe1685bce9cd5c44aa22bd6))
14
+
15
+ ## [0.1.7](https://github.com/brandon-schabel/zvk/compare/v0.1.6...v0.1.7) (2026-06-15)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * harden popup positioning ([efb8c8a](https://github.com/brandon-schabel/zvk/commit/efb8c8a98c2b759f4e84a60f5ae292b1e8900907))
21
+
3
22
  ## [0.1.6](https://github.com/brandon-schabel/zvk/compare/v0.1.5...v0.1.6) (2026-06-12)
4
23
 
5
24
 
package/README.md CHANGED
@@ -22,8 +22,8 @@ import "@zvk/ui/styles.css";
22
22
 
23
23
  GitHub Actions runs `bun run preflight` before release publishing. Release Please opens
24
24
  reviewable version-bump PRs from conventional commits; merging a release PR creates the
25
- GitHub release and publishes `@zvk/ui` to npm through trusted publishing in the protected
26
- `npm-publish` environment.
25
+ GitHub release and publishes any package with a created release, including `@zvk/ui`, to npm
26
+ through trusted publishing in the protected `npm-publish` environment.
27
27
 
28
28
  ## Component Surface
29
29
 
@@ -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.8",
4
4
  "description": "A polished, zero-runtime-dependency React component library for ZvkUi applications.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -258,6 +258,7 @@
258
258
  },
259
259
  "scripts": {
260
260
  "clean": "bun run scripts/clean.mjs",
261
+ "dev": "bun run preview",
261
262
  "build:types": "tsc -p tsconfig.build.json",
262
263
  "build:css": "bun run scripts/build-css.mjs",
263
264
  "build": "bun run clean && bun run build:types && bun run build:css",
@@ -270,13 +271,14 @@
270
271
  "test:ssr": "vitest run tests/ssr --environment node",
271
272
  "test:exports": "vitest run tests/exports --environment node",
272
273
  "test:accessibility": "vitest run tests/accessibility",
274
+ "test:browser": "playwright test --config playwright.config.ts",
273
275
  "test:types": "tsd",
274
276
  "docs:lint": "bun run scripts/lint-docs.mjs",
275
277
  "verify:style-contract": "bun run scripts/verify-style-contract.mjs",
276
278
  "validate:exports": "bun run scripts/validate-exports.mjs",
277
279
  "tarball:inspect": "bun run scripts/check-tarball.mjs",
278
280
  "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"
281
+ "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
282
  },
281
283
  "peerDependencies": {
282
284
  "react": "^19.0.0",
@@ -287,6 +289,7 @@
287
289
  "directory": "tests/types"
288
290
  },
289
291
  "devDependencies": {
292
+ "@playwright/test": "^1.60.0",
290
293
  "@testing-library/jest-dom": "^6.9.1",
291
294
  "@testing-library/react": "^16.3.2",
292
295
  "@testing-library/user-event": "^14.6.1",