@versini/ui-menu 6.2.1 → 6.3.1

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/README.md CHANGED
@@ -17,7 +17,7 @@ The Menu package provides dropdown menus with full keyboard navigation, focus ma
17
17
 
18
18
  ## Features
19
19
 
20
- - **Composable**: `Menu`, `MenuItem`, `MenuSeparator`, `MenuGroupLabel`, `MenuSub`
20
+ - **Composable**: `Menu`, `MenuItem`, `MenuSeparator`, `MenuLabel`, `MenuGroup`, `MenuSub`
21
21
  - **Nested Sub-menus**: Support for multi-level menu hierarchies with automatic positioning
22
22
  - **Accessible**: Built with ARIA roles & WAI-ARIA menu patterns for robust a11y
23
23
  - **Keyboard Support**: Arrow navigation, ESC / click outside to close
@@ -107,36 +107,17 @@ function AccountMenu() {
107
107
  }
108
108
  ```
109
109
 
110
- ### Raw Custom Item
110
+ ### Grouped Menu Items
111
111
 
112
- ```tsx
113
- <Menu
114
- trigger={
115
- <ButtonIcon label="More">
116
- <IconMenu />
117
- </ButtonIcon>
118
- }
119
- >
120
- <MenuItem raw ignoreClick>
121
- <div className="p-2 text-xs uppercase tracking-wide text-copy-medium">
122
- Custom Header
123
- </div>
124
- </MenuItem>
125
- <MenuItem label="Action" />
126
- </Menu>
127
- ```
128
-
129
- ### Nested Sub-menus
130
-
131
- Create hierarchical menus using `MenuSub`:
112
+ Use `MenuGroup` to visually group related items with an optional label:
132
113
 
133
114
  ```tsx
134
- import { Menu, MenuItem, MenuSub, MenuGroupLabel } from "@versini/ui-menu";
115
+ import { Menu, MenuGroup, MenuItem } from "@versini/ui-menu";
135
116
  import { ButtonIcon } from "@versini/ui-button";
136
117
  import { IconSettings, IconOpenAI, IconAnthropic } from "@versini/ui-icons";
137
118
 
138
119
  function SettingsMenu() {
139
- const [engine, setEngine] = useState("openai");
120
+ const [selected, setSelected] = useState(0);
140
121
 
141
122
  return (
142
123
  <Menu
@@ -146,24 +127,117 @@ function SettingsMenu() {
146
127
  </ButtonIcon>
147
128
  }
148
129
  >
149
- <MenuItem label="Profile" />
150
- <MenuItem label="Preferences" />
151
-
152
- {/* Nested sub-menu with icon */}
153
- <MenuSub label="AI Settings" icon={<IconSettings />}>
154
- <MenuGroupLabel icon={<IconSettings />}>Engines</MenuGroupLabel>
130
+ <MenuGroup label="Engines">
155
131
  <MenuItem
156
132
  label="OpenAI"
157
133
  icon={<IconOpenAI />}
158
- selected={engine === "openai"}
159
- onSelect={() => setEngine("openai")}
134
+ selected={selected === 1}
135
+ onClick={() => setSelected(1)}
160
136
  />
161
137
  <MenuItem
162
138
  label="Anthropic"
163
139
  icon={<IconAnthropic />}
164
- selected={engine === "anthropic"}
165
- onSelect={() => setEngine("anthropic")}
140
+ selected={selected === 2}
141
+ onClick={() => setSelected(2)}
166
142
  />
143
+ </MenuGroup>
144
+
145
+ <MenuGroup label="Personas" className="mt-2">
146
+ <MenuItem label="Diggidy" selected={selected === 3} />
147
+ <MenuItem label="French Teacher" selected={selected === 4} />
148
+ </MenuGroup>
149
+
150
+ <MenuItem label="About" />
151
+ </Menu>
152
+ );
153
+ }
154
+ ```
155
+
156
+ ### Menu with a Label
157
+
158
+ Use `MenuLabel` to add a non-interactive heading inside a menu:
159
+
160
+ ```tsx
161
+ import { Menu, MenuItem, MenuLabel } from "@versini/ui-menu";
162
+ import { ButtonIcon } from "@versini/ui-button";
163
+ import { IconBookSparkles, IconMagic, IconProofread } from "@versini/ui-icons";
164
+
165
+ function PromptsMenu() {
166
+ return (
167
+ <Menu
168
+ trigger={
169
+ <ButtonIcon label="Prompts">
170
+ <IconBookSparkles />
171
+ </ButtonIcon>
172
+ }
173
+ >
174
+ <MenuLabel>Prompts</MenuLabel>
175
+ <MenuItem label="Summarize..." icon={<IconMagic />} />
176
+ <MenuItem label="Proofread..." icon={<IconProofread />} />
177
+ </Menu>
178
+ );
179
+ }
180
+ ```
181
+
182
+ ### Nested Sub-menus
183
+
184
+ Create hierarchical menus using `MenuSub`. Groups work inside sub-menus too:
185
+
186
+ ```tsx
187
+ import {
188
+ Menu, MenuItem, MenuSub, MenuGroup, MenuSeparator
189
+ } from "@versini/ui-menu";
190
+ import { ButtonIcon } from "@versini/ui-button";
191
+ import {
192
+ IconSettings, IconOpenAI, IconAnthropic,
193
+ IconStarInCircle, IconFrenchFlag
194
+ } from "@versini/ui-icons";
195
+
196
+ function SettingsMenu() {
197
+ const [model, setModel] = useState("openai");
198
+ const [persona, setPersona] = useState("diggidy");
199
+
200
+ return (
201
+ <Menu
202
+ trigger={
203
+ <ButtonIcon label="Settings">
204
+ <IconSettings />
205
+ </ButtonIcon>
206
+ }
207
+ >
208
+ <MenuItem label="Profile" />
209
+ <MenuItem label="Statistics" />
210
+ <MenuSeparator />
211
+
212
+ <MenuSub label="Engines and Personas" icon={<IconSettings />}>
213
+ <MenuGroup label="Engines">
214
+ <MenuItem
215
+ label="OpenAI"
216
+ icon={<IconOpenAI />}
217
+ selected={model === "openai"}
218
+ onClick={() => setModel("openai")}
219
+ />
220
+ <MenuItem
221
+ label="Anthropic"
222
+ icon={<IconAnthropic />}
223
+ selected={model === "anthropic"}
224
+ onClick={() => setModel("anthropic")}
225
+ />
226
+ </MenuGroup>
227
+ <MenuGroup label="Personas" className="mt-2">
228
+ <MenuItem
229
+ label="Diggidy"
230
+ icon={<IconStarInCircle />}
231
+ selected={persona === "diggidy"}
232
+ onClick={() => setPersona("diggidy")}
233
+ />
234
+ <MenuItem
235
+ label="French Teacher"
236
+ icon={<IconFrenchFlag />}
237
+ selected={persona === "french_teacher"}
238
+ onClick={() => setPersona("french_teacher")}
239
+ />
240
+ </MenuGroup>
167
241
  </MenuSub>
168
242
 
169
243
  <MenuItem label="About" />
@@ -221,11 +295,20 @@ function SettingsMenu() {
221
295
  | `disabled` | `boolean` | `false` | Whether the sub-menu is disabled. |
222
296
  | `sideOffset` | `number` | `14` | Offset from sub-menu trigger. |
223
297
 
298
+ ### MenuGroup Props
299
+
300
+ | Prop | Type | Default | Description |
301
+ | ----------- | ----------------- | ------- | ----------------------------------------------- |
302
+ | `label` | `string` | - | Label displayed at the top of the group. |
303
+ | `icon` | `React.ReactNode` | - | Icon to display on the left of the group label. |
304
+ | `children` | `React.ReactNode` | - | MenuItems to render inside the group. |
305
+ | `className` | `string` | - | Custom CSS class for styling. |
306
+
224
307
  ### MenuSeparator Props
225
308
 
226
309
  Standard `React.HTMLAttributes<HTMLDivElement>` - use `className` for custom styling.
227
310
 
228
- ### MenuGroupLabel Props
311
+ ### MenuLabel Props
229
312
 
230
313
  | Prop | Type | Default | Description |
231
314
  | ----------- | ----------------- | ------- | ----------------------------------------- |
package/dist/index.d.ts CHANGED
@@ -5,17 +5,29 @@ export declare const Menu: {
5
5
  displayName: string;
6
6
  };
7
7
 
8
- export declare const MenuGroupLabel: {
9
- ({ className, icon, children, ...props }: MenuGroupLabelProps): JSX.Element;
8
+ export declare const MenuGroup: {
9
+ ({ children, label, className, icon, }: MenuGroupProps): JSX.Element;
10
10
  displayName: string;
11
11
  };
12
12
 
13
- declare type MenuGroupLabelProps = {
13
+ declare type MenuGroupProps = {
14
+ /**
15
+ * The label for the menu group.
16
+ */
17
+ label: string;
18
+ /**
19
+ * The children to render inside the menu group.
20
+ */
21
+ children?: React.ReactNode;
14
22
  /**
15
23
  * A React component of type Icon to be placed on the left of the label.
16
24
  */
17
25
  icon?: React.ReactNode;
18
- } & React.HTMLAttributes<HTMLDivElement>;
26
+ /**
27
+ * Additional class names to apply to the menu group.
28
+ */
29
+ className?: string;
30
+ };
19
31
 
20
32
  export declare const MenuItem: {
21
33
  ({ label, disabled, icon, raw, children, ignoreClick, selected, onSelect, onClick, onFocus, onMouseEnter, ...props }: MenuItemProps): JSX.Element;
@@ -73,6 +85,18 @@ declare type MenuItemProps = {
73
85
  onMouseEnter?: (event: React.MouseEvent<HTMLDivElement>) => void;
74
86
  };
75
87
 
88
+ export declare const MenuLabel: {
89
+ ({ className, icon, children, ...props }: MenuLabelProps): JSX.Element;
90
+ displayName: string;
91
+ };
92
+
93
+ declare type MenuLabelProps = {
94
+ /**
95
+ * A React component of type Icon to be placed on the left of the label.
96
+ */
97
+ icon?: React.ReactNode;
98
+ } & React.HTMLAttributes<HTMLDivElement>;
99
+
76
100
  declare type MenuProps = {
77
101
  /**
78
102
  * The component to use to open the menu, e.g. a ButtonIcon, a Button, etc.
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  /*!
2
- @versini/ui-menu v6.2.1
2
+ @versini/ui-menu v6.3.1
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
6
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
7
  import { useUniqueId } from "@versini/ui-hooks/use-unique-id";
8
+ import clsx_0 , { clsx } from "clsx";
8
9
  import { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useRef, useState } from "react";
9
- import clsx from "clsx";
10
- import { IconNext, IconSelected, IconUnSelected } from "@versini/ui-icons";
10
+ import { IconNext } from "@versini/ui-icons";
11
+
11
12
 
12
13
 
13
14
 
@@ -245,6 +246,7 @@ function getLastEnabledIndex(items) {
245
246
  };
246
247
  }
247
248
 
249
+
248
250
  const getDisplayName = (element)=>{
249
251
  if (typeof element === "string") {
250
252
  return element;
@@ -336,6 +338,12 @@ const calculatePosition = (triggerRect, menuRect, placement, sideOffset, viewpor
336
338
  left
337
339
  };
338
340
  };
341
+ const getMenuItemClasses = ({ isSub })=>{
342
+ return clsx("flex flex-row items-center", "w-full", "m-0 first:mt-0 mt-2 sm:mt-1 px-2 py-1", "rounded-md border border-transparent", "text-left text-base select-none cursor-pointer", "outline-hidden", "disabled:cursor-not-allowed disabled:text-copy-medium", "text-copy-dark", "data-highlighted:bg-surface-dark", "data-highlighted:text-copy-light", "in-data-menu-group:text-copy-light", "in-data-menu-group:data-highlighted:bg-surface-dark", "in-data-menu-group:data-highlighted:border-border-medium", {
343
+ "data-[state=open]:bg-surface-darker data-[state=open]:text-copy-light justify-between": isSub,
344
+ "data-disabled:cursor-not-allowed data-disabled:text-copy-medium": !isSub
345
+ });
346
+ };
339
347
 
340
348
 
341
349
 
@@ -388,7 +396,8 @@ function useMenuPosition({ triggerRef, menuRef, placement, sideOffset, isOpen })
388
396
 
389
397
 
390
398
 
391
- const CONTENT_CLASS = "z-100 rounded-md bg-surface-light shadow-sm shadow-border-dark outline-hidden p-3 sm:p-2 plume plume-dark";
399
+
400
+ const CONTENT_CLASS = clsx("z-100 rounded-md outline-hidden", "bg-surface-light", "text-copy-dark", "p-3 sm:p-2", "plume plume-dark");
392
401
  const Menu = ({ trigger, children, label = "Open menu", defaultPlacement = "bottom-start", onOpenChange, mode = "system", focusMode = "system", sideOffset = 10 })=>{
393
402
  const [isOpen, setIsOpen] = useState(false);
394
403
  const [activeIndex, setActiveIndex] = useState(-1);
@@ -610,33 +619,38 @@ Menu.displayName = "Menu";
610
619
 
611
620
 
612
621
 
613
-
614
- const MenuGroupLabel = ({ className, icon, children, ...props })=>{
615
- const groupLabelClass = clsx(className, "pt-1 pb-2 mb-2", "text-sm text-copy-dark font-bold", "border-b border-border-medium", {
616
- "flex items-center": icon
617
- });
618
- const labelSpanClass = icon ? "px-2" : "";
622
+ const MenuGroup = ({ children, label, className, icon })=>{
623
+ const groupClass = clsx_0("rounded-md", "p-2", "bg-surface-dark", "text-copy-light", className);
624
+ const labelContainerClass = clsx_0("pt-2 pb-2", "px-3 sm:px-2", "-mx-1 -mt-1", "flex items-center justify-between", "text-xs text-copy-medium uppercase font-bold", "rounded-t-md");
619
625
  return /*#__PURE__*/ jsxs("div", {
620
- className: groupLabelClass,
621
- ...props,
626
+ role: "group",
627
+ className: groupClass,
628
+ "data-menu-group": true,
622
629
  children: [
623
- icon,
624
- /*#__PURE__*/ jsx("span", {
625
- className: labelSpanClass,
626
- children: children
627
- })
630
+ label && /*#__PURE__*/ jsx("div", {
631
+ className: labelContainerClass,
632
+ children: /*#__PURE__*/ jsxs("span", {
633
+ className: "flex items-center gap-1",
634
+ children: [
635
+ icon,
636
+ label
637
+ ]
638
+ })
639
+ }),
640
+ children
628
641
  ]
629
642
  });
630
643
  };
631
- MenuGroupLabel.displayName = "MenuGroupLabel";
632
-
644
+ MenuGroup.displayName = "MenuGroup";
633
645
 
634
646
 
635
647
 
636
648
 
637
649
 
638
650
 
639
- const ITEM_CLASS = clsx("flex flex-row items-center", "w-full", "m-0 first:mt-0 mt-2 sm:mt-1 px-2 py-1", "rounded-md border border-transparent", "text-left text-base select-none cursor-pointer", "outline-hidden focus:border focus:border-border-medium focus:bg-surface-lighter focus:underline", "disabled:cursor-not-allowed disabled:text-copy-medium", "data-highlighted:bg-surface-lighter data-highlighted:border-border-medium data-highlighted:underline", "data-disabled:cursor-not-allowed data-disabled:text-copy-medium");
651
+ const ITEM_CLASS = getMenuItemClasses({
652
+ isSub: false
653
+ });
640
654
  const MenuItem = ({ label, disabled, icon, raw = false, children, ignoreClick = false, selected, onSelect, onClick, onFocus, onMouseEnter, ...props })=>{
641
655
  const itemRef = useRef(null);
642
656
  const { closeAll } = useContext(MenuRootContext);
@@ -725,7 +739,7 @@ const MenuItem = ({ label, disabled, icon, raw = false, children, ignoreClick =
725
739
  if (icon) {
726
740
  buttonSpanClass = "pl-2";
727
741
  }
728
- const itemClass = clsx(ITEM_CLASS, {
742
+ const itemClass = clsx_0(ITEM_CLASS, {
729
743
  "bg-none": !disabled && !selected
730
744
  });
731
745
  return /*#__PURE__*/ jsxs("div", {
@@ -741,13 +755,14 @@ const MenuItem = ({ label, disabled, icon, raw = false, children, ignoreClick =
741
755
  onMouseEnter: handleMouseEnter,
742
756
  ...props,
743
757
  children: [
744
- selected === true && /*#__PURE__*/ jsx(IconSelected, {
745
- className: "text-copy-success mr-2",
746
- size: "size-4"
758
+ selected === true && /*#__PURE__*/ jsx("span", {
759
+ className: "mr-2 flex size-4 shrink-0 items-center justify-center rounded-full border border-copy-success",
760
+ children: /*#__PURE__*/ jsx("span", {
761
+ className: "size-1.5 rounded-full bg-copy-success-light"
762
+ })
747
763
  }),
748
- selected === false && /*#__PURE__*/ jsx(IconUnSelected, {
749
- className: "text-copy-medium mr-2",
750
- size: "size-4"
764
+ selected === false && /*#__PURE__*/ jsx("span", {
765
+ className: clsx_0("mr-2 size-4 shrink-0", "rounded-full border border-copy-medium")
751
766
  }),
752
767
  icon,
753
768
  label && /*#__PURE__*/ jsx("span", {
@@ -761,8 +776,29 @@ MenuItem.displayName = "MenuItem";
761
776
 
762
777
 
763
778
 
779
+ const MenuLabel = ({ className, icon, children, ...props })=>{
780
+ const groupLabelClass = clsx_0(className, "pt-1 pb-2", "text-xs text-copy-medium uppercase font-bold", "border-b border-border-medium", {
781
+ "flex items-center": icon
782
+ });
783
+ const labelSpanClass = icon ? "px-2" : "";
784
+ return /*#__PURE__*/ jsxs("div", {
785
+ className: groupLabelClass,
786
+ ...props,
787
+ children: [
788
+ icon,
789
+ /*#__PURE__*/ jsx("span", {
790
+ className: labelSpanClass,
791
+ children: children
792
+ })
793
+ ]
794
+ });
795
+ };
796
+ MenuLabel.displayName = "MenuLabel";
797
+
798
+
799
+
764
800
  const MenuSeparator = ({ className, ...props })=>{
765
- const separatorClass = clsx(className, "my-1 border-t border-border-medium");
801
+ const separatorClass = clsx_0(className, "my-1 border-t border-border-medium");
766
802
  return /*#__PURE__*/ jsx("div", {
767
803
  role: "separator",
768
804
  className: separatorClass,
@@ -779,8 +815,12 @@ MenuSeparator.displayName = "MenuSeparator";
779
815
 
780
816
 
781
817
 
782
- const SUB_CONTENT_CLASS = "z-[60] rounded-md bg-surface-light shadow-sm shadow-border-dark outline-hidden p-3 sm:p-2 mx-3";
783
- const SUB_TRIGGER_CLASS = clsx("flex items-center flex-row justify-between", "w-full", "m-0 first:mt-0 mt-2 sm:mt-1 px-2 py-1", "rounded-md border border-transparent", "text-left text-base select-none cursor-pointer", "outline-hidden focus:border focus:border-border-medium focus:bg-surface-lighter focus:underline", "disabled:cursor-not-allowed disabled:text-copy-medium", "data-highlighted:bg-surface-lighter data-highlighted:border-border-medium data-highlighted:underline", "data-[state=open]:bg-surface-lighter");
818
+
819
+
820
+ const SUB_CONTENT_CLASS = clsx_0("bg-surface-light", "z-60 rounded-md text-copy-light outline-hidden p-3 sm:p-2 mx-3");
821
+ const SUB_TRIGGER_CLASS = getMenuItemClasses({
822
+ isSub: true
823
+ });
784
824
  const MenuSub = ({ label, icon, children, disabled = false, sideOffset = 14 })=>{
785
825
  const subMenuId = useUniqueId("av-menu-sub-");
786
826
  const [isSubOpen, setIsSubOpen] = useState(false);
@@ -1005,4 +1045,5 @@ MenuSub.displayName = "MenuSub";
1005
1045
 
1006
1046
 
1007
1047
 
1008
- export { Menu, MenuGroupLabel, MenuItem, MenuSeparator, MenuSub };
1048
+
1049
+ export { Menu, MenuGroup, MenuItem, MenuLabel, MenuSeparator, MenuSub };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@versini/ui-menu",
3
- "version": "6.2.1",
3
+ "version": "6.3.1",
4
4
  "license": "MIT",
5
5
  "author": "Arno Versini",
6
6
  "publishConfig": {
@@ -49,5 +49,5 @@
49
49
  "sideEffects": [
50
50
  "**/*.css"
51
51
  ],
52
- "gitHead": "4031959707f3c3fd06007b28b07f77d7a7692516"
52
+ "gitHead": "b964cc70097cded97831d68776fda8d03e0820b7"
53
53
  }