menu.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import { View, Pressable } from 'app/design/view'
  2. import { appSetting, menuItemsByName } from 'app/lib/util';
  3. import { components } from 'app/components/registry';
  4. import { useCurrentUser } from 'app/context/user'
  5. import { useMemo, memo, useContext } from "react";
  6. import { NeoButton, toNeoStyle, toControlSize } from 'app/design/controls';
  7. import DynamicMenu from 'app/components/nav/menu-dynamic';
  8. import { DropdownMenuOpenContext } from 'app/ui/atoms/dropdown-menu';
  9. import { Platform } from 'react-native'
  10. import { useOpenModalByUrl } from 'app/context/jotai/modal';
  11. import { useTranslation } from 'react-i18next';
  12. // DropdownMenu trigger host classes by the inner NeoButton's controlSize (static literals for Tailwind/Uniwind).
  13. const TRIGGER_HOST_CLASS = { mini: 'u-neo-btn-link hit-area-8', small: 'u-neo-btn-link hit-area-4' };
  14. const ButtonEx = memo(({ visibleItemsCount, params }) => {
  15. const isOpen = useContext(DropdownMenuOpenContext) ?? false;
  16. return (
  17. <View key="btn" className="">
  18. <NeoButton
  19. image="Ellipsis"
  20. style={params.button_style || toNeoStyle(params.button_variant || 'default').style}
  21. controlSize={toControlSize(params.button_size)}
  22. borderShape={params.button_border_shape ?? (params.button_rounded === true || params.button_rounded === 'true' ? 'circle' : undefined)}
  23. // Parent DropdownPopup Pressable owns onPress; interactive
  24. // enables hover/focus visuals on the NeoButton surface.
  25. interactive
  26. selected={isOpen}
  27. />
  28. </View>
  29. );
  30. });
  31. const MenuItemEx = memo(({ object,item, index, sDisplayType, params }) => {
  32. const ItemType = components['menu-item'][String(item.display_type ? item.display_type : sDisplayType)];
  33. const newButtonVariant = params?.button_variant === 'none' ? '' : params?.button_variant;
  34. return (
  35. <ItemType object={object} mode="dropdown-menu" key={item.id ? item.id : item.name} {...item} params={{ ...params, button_variant: newButtonVariant, button_size: 'sm' }} />
  36. )
  37. });
  38. const MenuItem = memo(({ object, item, itemRefs, index, visibleItemsCount, params, bShowVertical, sAlignItems, isUseStaticWidth, isWeb, sDisplayType }) => {
  39. const ItemType = components['menu-item'][String(item.display_type ? item.display_type : sDisplayType)];
  40. const isLastVisible = typeof visibleItemsCount === 'number' ? index >= (visibleItemsCount - 1) : false;
  41. let spacingClass = '';
  42. if (bShowVertical) {
  43. spacingClass = 'w-full ';
  44. } else if (params?.button_full_width === true) {
  45. spacingClass = ' flex-1 ';
  46. } else {
  47. spacingClass = isLastVisible ? ' ' : 'mt-0';
  48. }
  49. return (
  50. <View className={` ${spacingClass} ${sAlignItems == 'stretch' ? 'flex-auto' : ''} `}>
  51. <ItemType object={object} key={item.id ? item.id : item.name} {...item} params={params} />
  52. </View>
  53. )
  54. });
  55. export default function ElementMenu(oProps) {
  56. const isWeb = Platform.OS == 'web'
  57. const { currentUser } = useCurrentUser();
  58. const openModalByUrl = useOpenModalByUrl();
  59. const { t } = useTranslation();
  60. /*
  61. * Display type specified in menu can be overwritten with display type specified in item.
  62. * default display types: mixed, link, button, element, etc.
  63. */
  64. const sDisplayType = oProps.displayType || 'secondary';
  65. //--- auto-filter items using app settings.
  66. const bAutoFilter = oProps?.autoFilter !== 'false';
  67. //--- show only items which match with menu's display_type
  68. const bShowMatched = oProps?.showMatched === true;
  69. const bAutoSize = oProps?.autoSize || false;
  70. //--- show only items with selected display_type and doesn't take in account the menu's display_type
  71. const sShowSelected = oProps?.showSelected || false;
  72. //--- except the following items from output
  73. const aExcept = oProps?.except || [''];
  74. const aExceptTitle = oProps?.except_title || ['BxTemplView', 'BxTemplFavorite', 'BxTemplFeature', 'BxTemplReport', 'BxTimelineModule'];
  75. let sClassName = oProps?.params?.className || 'bx-menu ';
  76. //--- show vertical
  77. const bShowVertical = oProps?.params?.showVertical === true;
  78. const oParams = oProps?.params || {};
  79. // Horizontal gap keeps neighbouring buttons' 44px hit areas apart (mini needs 8px, small 4px).
  80. // `menu_gap` is a class literal from settings; items with their own `menu_item_spacing` get none.
  81. const sRowGap = oParams.menu_gap ?? (oParams.menu_item_spacing != null ? '' : 'gap-x-2');
  82. sClassName += bShowVertical ? ' flex-col items-center gap-y-2 w-full ' : ` flex-row ${sRowGap} `;
  83. //--- horizontal menu items alignment
  84. const sAlignItems = oProps.alignItems || oParams.justify_items || 'between';
  85. sClassName += `justify-${sAlignItems}`;
  86. if (isWeb) {
  87. sClassName += ' ';
  88. }
  89. //--- show menu's content only
  90. const bShowContent = oParams.showContent === 'true';
  91. //--- use iconset if available
  92. let iconset = { ...appSetting('menu_items', 'iconset'), ...appSetting('menu_items', oProps.object, 'iconset') };
  93. if (iconset) oParams.iconset = iconset;
  94. const sortedItems = [...(oProps.items || [])].sort((a, b) => {
  95. const pa = (a.primary === true || a.primary === 1) ? 1 : 0;
  96. const pb = (b.primary === true || b.primary === 1) ? 1 : 0;
  97. return pb - pa;
  98. });
  99. const filteredItems = useMemo(() => {
  100. return (bAutoFilter ? menuItemsByName(oProps.object, sortedItems, currentUser) : sortedItems).filter((aItem) => {
  101. // Check if item should be shown based on `bShowMatched` and `sDisplayType`
  102. if (bShowMatched && aItem.display_type !== sDisplayType) {
  103. return false;
  104. }
  105. // Check if item should be shown based on `sShowSelected`
  106. if (
  107. sShowSelected !== false &&
  108. ((aItem.display_type === undefined && sShowSelected !== 'undefined') ||
  109. (aItem.display_type !== undefined && aItem.display_type !== sShowSelected))
  110. ) {
  111. return false;
  112. }
  113. // Ensure the item has an id or name, and is not in the except lists
  114. if (!(aItem.id || aItem.name) || aExcept.includes(aItem.name) || aExceptTitle.includes(aItem.title)) {
  115. return false;
  116. }
  117. // Check if the `display_type` of the item is supported by `componentsMap`
  118. const sDisplayTypeItem = aItem.display_type ? aItem.display_type : (aItem.link ? 'button' : sDisplayType);
  119. if (sDisplayTypeItem == 'button') {
  120. aItem.display_type='button';
  121. }
  122. if (!components['menu-item'][sDisplayTypeItem]) {
  123. return false;
  124. }
  125. // menu-item/element returns nothing without a resolvable molecule —
  126. // skip so wrappers don't leave an empty flex slot (cover actions gap).
  127. if (sDisplayTypeItem === 'element') {
  128. const moleculeType = aItem?.data?.type;
  129. if (!moleculeType || !components['molecule'][String(moleculeType)]) {
  130. return false;
  131. }
  132. }
  133. // If all checks pass, the item should be included in the filtered list
  134. return true;
  135. });
  136. }, [bAutoFilter, oProps.object, sortedItems, currentUser, bShowMatched, sDisplayType, sShowSelected, aExcept, aExceptTitle]);
  137. if (!oProps.items){
  138. return;
  139. }
  140. if (!oProps?.items?.length)
  141. return [];
  142. let isUseStaticWidth = bShowContent || !bAutoSize || !isWeb;
  143. if (oProps.persistent > 0 && !bShowVertical) {
  144. // isUseStaticWidth = false;
  145. }
  146. if (!isWeb) {
  147. isUseStaticWidth = true;
  148. }
  149. if (oProps?.params?.menu_width) {
  150. sClassName += ` ${oProps.params.menu_width}`;
  151. }
  152. if (isUseStaticWidth) {
  153. // Prepare only actually rendered items to correctly identify the last visible one
  154. const preparedItems = filteredItems
  155. .map((item, originalIndex) => {
  156. const ItemType = components['menu-item'][item.display_type || sDisplayType];
  157. const element = <ItemType object={oProps.object} key={item.id ? item.id : item.name} {...item} params={oProps.params} />
  158. return { item, element, originalIndex };
  159. })
  160. .filter(({ element }) => element != null);
  161. const sItems = preparedItems.map(({ item, element, originalIndex }, i) => {
  162. const Wrapper = item.noAction ? Pressable : View;
  163. const cntProps = {};
  164. if (item.noAction) {
  165. cntProps.onPress = () => openModalByUrl(item.link);
  166. }
  167. const isLast = i === preparedItems.length - 1;
  168. let spacingClass = '';
  169. if (bShowVertical) {
  170. spacingClass = 'w-full ';
  171. } else if (oProps?.params?.button_full_width === true) {
  172. spacingClass = ' flex-1 ';
  173. } else {
  174. spacingClass = oProps?.params?.menu_item_spacing != null ? ` ${oProps.params.menu_item_spacing} ` : '';
  175. }
  176. return (
  177. <View
  178. key={`menu${originalIndex}`}
  179. collapsable={false}
  180. className={` ${spacingClass} ${sAlignItems === 'stretch' ? 'flex-auto' : ''} `}
  181. style={!isWeb && sAlignItems !== 'stretch' ? { flexGrow: 0, flexShrink: 0 } : undefined}
  182. >
  183. <Wrapper {...cntProps}>{element}</Wrapper>
  184. </View>
  185. );
  186. });
  187. if (bShowContent)
  188. return sItems;
  189. if (!bAutoSize) {
  190. // Native Expo UI Host needs a non-collapsible, content-sized row.
  191. // A stretched/collapsed wrapper (default Yoga in CoverMorph overlay)
  192. // reports 0x0 and the buttons never paint — web CSS does not.
  193. return (
  194. <View
  195. collapsable={false}
  196. className={sClassName}
  197. style={!isWeb ? { flexGrow: 0, flexShrink: 0, alignSelf: 'flex-start' } : undefined}
  198. >
  199. {sItems}
  200. </View>
  201. )
  202. }
  203. }
  204. return <DynamicMenu
  205. name="menu"
  206. object={oProps.object}
  207. ButtonEx={({ visibleItemsCount }) => <ButtonEx visibleItemsCount={visibleItemsCount} params={oProps.params} />}
  208. MenuItemEx={({ item, index }) => <MenuItemEx object={oProps.object} item={item} index={index} sDisplayType={sDisplayType} params={oProps.params} />}
  209. MenuItem={({ item, itemRefs, visibleItemsCount, index }) => {
  210. return <MenuItem object={oProps.object} item={item} index={index} visibleItemsCount={visibleItemsCount}
  211. bShowVertical={bShowVertical}
  212. isWeb={isWeb}
  213. itemRefs={itemRefs}
  214. sAlignItems={sAlignItems}
  215. isUseStaticWidth={isUseStaticWidth}
  216. sDisplayType={sDisplayType} params={oProps.params} />
  217. }}
  218. isFixedCount={oProps?.params?.isFixedCount}
  219. persistent={oProps.persistent}
  220. allowZeroPersistant={oProps.allowZeroPersistant}
  221. containerClasses={oProps.containerClasses || "w-full "}
  222. items={filteredItems}
  223. menuClasses={sClassName}
  224. isButtonOutside={false}
  225. menuExClasses="mr-auto ml-3 sm:ml-4 items-end gap-y-2"
  226. triggerAccessibilityLabel={t('More options')}
  227. triggerClassName={TRIGGER_HOST_CLASS[toControlSize(oProps?.params?.button_size)] ?? 'u-neo-btn-link'}
  228. />
  229. }