@open-mercato/ui 0.6.6-develop.6257.1.6d0af84d26 → 0.6.6-develop.6286.1.d985c5c374

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.
@@ -120,7 +120,7 @@ function ActionsDropdown({
120
120
  {
121
121
  ref: menuRef,
122
122
  role: "menu",
123
- className: "fixed w-52 rounded-md border bg-background p-1 shadow-md focus-visible:outline-none z-dropdown",
123
+ className: "fixed min-w-52 w-max max-w-xs rounded-md border bg-background p-1 shadow-md focus-visible:outline-none z-dropdown",
124
124
  onMouseEnter: handleMouseEnter,
125
125
  onMouseLeave: handleMouseLeave,
126
126
  style: {
@@ -136,7 +136,7 @@ function ActionsDropdown({
136
136
  type: "button",
137
137
  variant: "ghost",
138
138
  size: "sm",
139
- className: "w-full justify-start",
139
+ className: "w-full justify-start h-auto min-h-8 py-1.5 whitespace-normal text-left leading-snug",
140
140
  role: "menuitem",
141
141
  disabled: item.disabled,
142
142
  onClick: () => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/forms/ActionsDropdown.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport { Zap, ChevronDown, Loader2, MoreHorizontal } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type ActionItem = {\n /** Unique key */\n id: string\n /** Display label */\n label: string\n /** Lucide icon component (optional) */\n icon?: React.ComponentType<{ className?: string }>\n /** Click handler */\n onSelect: () => void\n /** Disable the item */\n disabled?: boolean\n /** Show a loading spinner instead of the icon */\n loading?: boolean\n}\n\nexport type ActionsDropdownProps = {\n /** Items to render inside the dropdown */\n items: ActionItem[]\n /** Button label (default: translated 'Actions') */\n label?: string\n /** Trigger style */\n triggerMode?: 'label' | 'icon'\n /** Accessible label for icon trigger */\n ariaLabel?: string\n /** Button size (default: 'sm') */\n size?: 'sm' | 'default'\n}\n\nexport function ActionsDropdown({\n items,\n label,\n triggerMode = 'label',\n ariaLabel,\n size = 'sm',\n}: ActionsDropdownProps) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const btnRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)\n const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)\n const [direction, setDirection] = React.useState<'down' | 'up'>('down')\n\n const resolvedLabel = label ?? t('ui.actions.actions', 'Actions')\n const resolvedAriaLabel = ariaLabel ?? resolvedLabel\n\n const updatePosition = React.useCallback(() => {\n if (!btnRef.current) return\n const rect = btnRef.current.getBoundingClientRect()\n setAnchorRect(rect)\n const spaceBelow = window.innerHeight - rect.bottom\n const spaceAbove = rect.top\n setDirection(spaceBelow < 200 && spaceAbove > spaceBelow ? 'up' : 'down')\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n updatePosition()\n function onDocClick(event: MouseEvent) {\n const target = event.target as Node\n if (\n menuRef.current && !menuRef.current.contains(target) &&\n btnRef.current && !btnRef.current.contains(target)\n ) {\n setOpen(false)\n }\n }\n function onKey(event: KeyboardEvent) {\n if (event.key === 'Escape') {\n setOpen(false)\n btnRef.current?.focus()\n }\n }\n function onScrollOrResize() {\n updatePosition()\n }\n document.addEventListener('mousedown', onDocClick)\n document.addEventListener('keydown', onKey)\n window.addEventListener('scroll', onScrollOrResize, true)\n window.addEventListener('resize', onScrollOrResize)\n return () => {\n document.removeEventListener('mousedown', onDocClick)\n document.removeEventListener('keydown', onKey)\n window.removeEventListener('scroll', onScrollOrResize, true)\n window.removeEventListener('resize', onScrollOrResize)\n }\n }, [open, updatePosition])\n\n React.useEffect(() => {\n return () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n }\n }, [])\n\n const handleMouseEnter = () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n setOpen(true)\n updatePosition()\n }\n\n const handleMouseLeave = (event: React.MouseEvent) => {\n const nextTarget = event.relatedTarget as Node | null\n if (nextTarget && (btnRef.current?.contains(nextTarget) || menuRef.current?.contains(nextTarget))) {\n return\n }\n hoverTimeoutRef.current = setTimeout(() => {\n setOpen(false)\n }, 150)\n }\n\n if (!items.length) return null\n\n return (\n <div\n className=\"relative inline-block\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n >\n <Button\n ref={btnRef}\n type=\"button\"\n variant=\"outline\"\n size={size}\n className={triggerMode === 'icon' ? 'px-2' : undefined}\n aria-haspopup=\"menu\"\n aria-expanded={open}\n aria-label={resolvedAriaLabel}\n onClick={() => {\n setOpen((prev) => !prev)\n requestAnimationFrame(updatePosition)\n }}\n >\n {triggerMode === 'icon' ? (\n <>\n <MoreHorizontal className=\"size-4\" />\n <span className=\"sr-only\">{resolvedAriaLabel}</span>\n </>\n ) : (\n <>\n {resolvedLabel}\n <Zap className=\"size-4 ml-1\" />\n <ChevronDown className={`size-3.5 transition-transform duration-150 ${open ? 'rotate-180' : ''}`} />\n </>\n )}\n </Button>\n {open && anchorRect && createPortal(\n <div\n ref={menuRef}\n role=\"menu\"\n className=\"fixed w-52 rounded-md border bg-background p-1 shadow-md focus-visible:outline-none z-dropdown\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n style={{\n top: direction === 'down' ? anchorRect.bottom + 4 : anchorRect.top - 4,\n left: anchorRect.right,\n transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,\n }}\n >\n {items.map((item) => {\n const Icon = item.icon\n return (\n <Button\n key={item.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start\"\n role=\"menuitem\"\n disabled={item.disabled}\n onClick={() => {\n setOpen(false)\n item.onSelect()\n }}\n >\n {item.loading ? (\n <Loader2 className=\"size-4 animate-spin\" />\n ) : Icon ? (\n <Icon className=\"size-4\" />\n ) : (\n <span className=\"size-4\" />\n )}\n {item.label}\n </Button>\n )\n })}\n </div>,\n document.body,\n )}\n </div>\n )\n}\n"],
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport { Zap, ChevronDown, Loader2, MoreHorizontal } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type ActionItem = {\n /** Unique key */\n id: string\n /** Display label */\n label: string\n /** Lucide icon component (optional) */\n icon?: React.ComponentType<{ className?: string }>\n /** Click handler */\n onSelect: () => void\n /** Disable the item */\n disabled?: boolean\n /** Show a loading spinner instead of the icon */\n loading?: boolean\n}\n\nexport type ActionsDropdownProps = {\n /** Items to render inside the dropdown */\n items: ActionItem[]\n /** Button label (default: translated 'Actions') */\n label?: string\n /** Trigger style */\n triggerMode?: 'label' | 'icon'\n /** Accessible label for icon trigger */\n ariaLabel?: string\n /** Button size (default: 'sm') */\n size?: 'sm' | 'default'\n}\n\nexport function ActionsDropdown({\n items,\n label,\n triggerMode = 'label',\n ariaLabel,\n size = 'sm',\n}: ActionsDropdownProps) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const btnRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)\n const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)\n const [direction, setDirection] = React.useState<'down' | 'up'>('down')\n\n const resolvedLabel = label ?? t('ui.actions.actions', 'Actions')\n const resolvedAriaLabel = ariaLabel ?? resolvedLabel\n\n const updatePosition = React.useCallback(() => {\n if (!btnRef.current) return\n const rect = btnRef.current.getBoundingClientRect()\n setAnchorRect(rect)\n const spaceBelow = window.innerHeight - rect.bottom\n const spaceAbove = rect.top\n setDirection(spaceBelow < 200 && spaceAbove > spaceBelow ? 'up' : 'down')\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n updatePosition()\n function onDocClick(event: MouseEvent) {\n const target = event.target as Node\n if (\n menuRef.current && !menuRef.current.contains(target) &&\n btnRef.current && !btnRef.current.contains(target)\n ) {\n setOpen(false)\n }\n }\n function onKey(event: KeyboardEvent) {\n if (event.key === 'Escape') {\n setOpen(false)\n btnRef.current?.focus()\n }\n }\n function onScrollOrResize() {\n updatePosition()\n }\n document.addEventListener('mousedown', onDocClick)\n document.addEventListener('keydown', onKey)\n window.addEventListener('scroll', onScrollOrResize, true)\n window.addEventListener('resize', onScrollOrResize)\n return () => {\n document.removeEventListener('mousedown', onDocClick)\n document.removeEventListener('keydown', onKey)\n window.removeEventListener('scroll', onScrollOrResize, true)\n window.removeEventListener('resize', onScrollOrResize)\n }\n }, [open, updatePosition])\n\n React.useEffect(() => {\n return () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n }\n }, [])\n\n const handleMouseEnter = () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n setOpen(true)\n updatePosition()\n }\n\n const handleMouseLeave = (event: React.MouseEvent) => {\n const nextTarget = event.relatedTarget as Node | null\n if (nextTarget && (btnRef.current?.contains(nextTarget) || menuRef.current?.contains(nextTarget))) {\n return\n }\n hoverTimeoutRef.current = setTimeout(() => {\n setOpen(false)\n }, 150)\n }\n\n if (!items.length) return null\n\n return (\n <div\n className=\"relative inline-block\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n >\n <Button\n ref={btnRef}\n type=\"button\"\n variant=\"outline\"\n size={size}\n className={triggerMode === 'icon' ? 'px-2' : undefined}\n aria-haspopup=\"menu\"\n aria-expanded={open}\n aria-label={resolvedAriaLabel}\n onClick={() => {\n setOpen((prev) => !prev)\n requestAnimationFrame(updatePosition)\n }}\n >\n {triggerMode === 'icon' ? (\n <>\n <MoreHorizontal className=\"size-4\" />\n <span className=\"sr-only\">{resolvedAriaLabel}</span>\n </>\n ) : (\n <>\n {resolvedLabel}\n <Zap className=\"size-4 ml-1\" />\n <ChevronDown className={`size-3.5 transition-transform duration-150 ${open ? 'rotate-180' : ''}`} />\n </>\n )}\n </Button>\n {open && anchorRect && createPortal(\n <div\n ref={menuRef}\n role=\"menu\"\n className=\"fixed min-w-52 w-max max-w-xs rounded-md border bg-background p-1 shadow-md focus-visible:outline-none z-dropdown\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n style={{\n top: direction === 'down' ? anchorRect.bottom + 4 : anchorRect.top - 4,\n left: anchorRect.right,\n transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,\n }}\n >\n {items.map((item) => {\n const Icon = item.icon\n return (\n <Button\n key={item.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start h-auto min-h-8 py-1.5 whitespace-normal text-left leading-snug\"\n role=\"menuitem\"\n disabled={item.disabled}\n onClick={() => {\n setOpen(false)\n item.onSelect()\n }}\n >\n {item.loading ? (\n <Loader2 className=\"size-4 animate-spin\" />\n ) : Icon ? (\n <Icon className=\"size-4\" />\n ) : (\n <span className=\"size-4\" />\n )}\n {item.label}\n </Button>\n )\n })}\n </div>,\n document.body,\n )}\n </div>\n )\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.6-develop.6257.1.6d0af84d26",
3
+ "version": "0.6.6-develop.6286.1.d985c5c374",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.6-develop.6257.1.6d0af84d26",
158
+ "@open-mercato/shared": "0.6.6-develop.6286.1.d985c5c374",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.6-develop.6257.1.6d0af84d26",
164
+ "@open-mercato/shared": "0.6.6-develop.6286.1.d985c5c374",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -173,7 +173,8 @@
173
173
  "react": "19.2.7",
174
174
  "react-dom": "19.2.7",
175
175
  "react-is": "^19.2.7",
176
- "ts-jest": "^29.4.11"
176
+ "ts-jest": "^29.4.11",
177
+ "typescript": "^6.0.3"
177
178
  },
178
179
  "publishConfig": {
179
180
  "access": "public"
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { screen, fireEvent } from '@testing-library/react'
7
+ import { ActionsDropdown, type ActionItem } from '../forms/ActionsDropdown'
8
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
9
+
10
+ // Regression coverage for issue #3580: in the Polish locale the long
11
+ // "Oznacz wszystko jako nieprzeczytane" label overflowed the conversation
12
+ // actions dropdown because the menu used a fixed `w-52` width and the items
13
+ // inherited `whitespace-nowrap` from the Button primitive, clipping the label.
14
+ const LONG_POLISH_LABEL = 'Oznacz wszystko jako nieprzeczytane'
15
+ const TRIGGER_LABEL = 'Conversation actions'
16
+
17
+ function renderDropdown(items: ActionItem[]) {
18
+ return renderWithProviders(
19
+ <ActionsDropdown items={items} triggerMode="icon" ariaLabel={TRIGGER_LABEL} />,
20
+ { dict: {} },
21
+ )
22
+ }
23
+
24
+ function openMenu() {
25
+ fireEvent.click(screen.getByRole('button', { name: TRIGGER_LABEL }))
26
+ }
27
+
28
+ describe('ActionsDropdown', () => {
29
+ it('grows to fit long labels instead of clipping them with a fixed width (issue #3580)', () => {
30
+ renderDropdown([{ id: 'mark-all-unread', label: LONG_POLISH_LABEL, onSelect: jest.fn() }])
31
+
32
+ openMenu()
33
+
34
+ const menu = screen.getByRole('menu')
35
+ expect(menu.className).toContain('w-max')
36
+ expect(menu.className).toContain('max-w-xs')
37
+ expect(menu.className).toContain('min-w-52')
38
+ // The original bug was a hard `w-52` cap that truncated longer localized labels.
39
+ expect(menu.className).not.toMatch(/(^|\s)w-52(\s|$)/)
40
+
41
+ expect(screen.getByText(LONG_POLISH_LABEL)).toBeInTheDocument()
42
+ })
43
+
44
+ it('lets long menu-item labels wrap to a second line instead of staying on one clipped line', () => {
45
+ renderDropdown([{ id: 'mark-all-unread', label: LONG_POLISH_LABEL, onSelect: jest.fn() }])
46
+
47
+ openMenu()
48
+
49
+ const item = screen.getByRole('menuitem', { name: LONG_POLISH_LABEL })
50
+ expect(item.className).toContain('whitespace-normal')
51
+ expect(item.className).toContain('h-auto')
52
+ expect(item.className).not.toMatch(/(^|\s)whitespace-nowrap(\s|$)/)
53
+ })
54
+
55
+ it('still invokes the action handler when a menu item is clicked', () => {
56
+ const onSelect = jest.fn()
57
+ renderDropdown([{ id: 'mark-all-unread', label: LONG_POLISH_LABEL, onSelect }])
58
+
59
+ openMenu()
60
+ fireEvent.click(screen.getByRole('menuitem', { name: LONG_POLISH_LABEL }))
61
+
62
+ expect(onSelect).toHaveBeenCalledTimes(1)
63
+ })
64
+ })
@@ -159,7 +159,7 @@ export function ActionsDropdown({
159
159
  <div
160
160
  ref={menuRef}
161
161
  role="menu"
162
- className="fixed w-52 rounded-md border bg-background p-1 shadow-md focus-visible:outline-none z-dropdown"
162
+ className="fixed min-w-52 w-max max-w-xs rounded-md border bg-background p-1 shadow-md focus-visible:outline-none z-dropdown"
163
163
  onMouseEnter={handleMouseEnter}
164
164
  onMouseLeave={handleMouseLeave}
165
165
  style={{
@@ -176,7 +176,7 @@ export function ActionsDropdown({
176
176
  type="button"
177
177
  variant="ghost"
178
178
  size="sm"
179
- className="w-full justify-start"
179
+ className="w-full justify-start h-auto min-h-8 py-1.5 whitespace-normal text-left leading-snug"
180
180
  role="menuitem"
181
181
  disabled={item.disabled}
182
182
  onClick={() => {