menu.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { BlockWrapper } from 'app/components/block-wrapper'
  2. import { View, Row } from 'app/design/view'
  3. import { Text } from 'app/design/typography'
  4. import { Icon } from 'app/ui/atoms/icon'
  5. import Link from 'app/ui/atoms/link'
  6. import { components } from 'app/components/registry';
  7. import { appSetting } from 'app/lib/util';
  8. import { useState } from 'react';
  9. const conductorTheme = appSetting('theme', 'conductor');
  10. /**
  11. * A menu item UNA describes with `info` (already translated), shown as a full-width choice:
  12. * icon, title, the description under it, chevron. E.g. the profile types of `sys_add_profile`.
  13. */
  14. function MenuChoiceRow({ item, href }) {
  15. const title = item?.title || item?.name
  16. return (
  17. <Link href={href} mode="plain" haptics="Light" alt={title} className="w-full">
  18. <Row className="w-full items-center gap-3 rounded-xl border border-border bg-card p-3 sm:p-4 web:hover:bg-muted/60 web:transition-colors">
  19. <View className="size-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
  20. <Icon icon={item?.icon || 'Circle'} size={20} className="text-primary" />
  21. </View>
  22. <View className="min-w-0 flex-1 gap-0.5">
  23. <Text className="text-base font-semibold text-card-foreground">{title}</Text>
  24. <Text className="text-sm text-muted-foreground">{item.info}</Text>
  25. </View>
  26. <Icon icon="ChevronRight" size={18} className="shrink-0 text-muted-foreground" />
  27. </Row>
  28. </Link>
  29. )
  30. }
  31. export default function ElementMenu({ data, blockWrapperProps, url }) {
  32. const MenuItemSidebarWithWrapper = components['menu-item']['sidebar_with_wrapper'];
  33. const currentPath = String(url || '');
  34. const currentPathComparable = currentPath.replace(/^\/+/, '');
  35. const depthClassNameMap = {
  36. 0: '',
  37. 1: conductorTheme.menu_categ_indent,
  38. 2: 'pl-12',
  39. 3: 'pl-16',
  40. };
  41. const getDepthClassName = (depth) => depthClassNameMap[depth] || '';
  42. const getItemId = (item, indexPath) => String(item?.id || item?.name || item?.url || item?.link || indexPath);
  43. const hasItemPath = (item) => {
  44. const path = String(item?.url || item?.link || '');
  45. return Boolean(path && path !== 'javascript:void(0)');
  46. };
  47. const hasActiveDescendant = (item) => {
  48. const itemPath = String(item?.url || item?.link || '');
  49. if (itemPath && itemPath !== 'javascript:void(0)') {
  50. const itemPathComparable = itemPath.replace(/^\/+/, '');
  51. if (itemPathComparable && itemPathComparable === currentPathComparable) return true;
  52. }
  53. const children = item?.subitems || [];
  54. return children.some((child) => hasActiveDescendant(child));
  55. };
  56. const topLevelItems = data?.content?.items || [];
  57. const buildInitialExpandedMap = (items = [], parentIndexPath = '') => {
  58. const map = {};
  59. items.forEach((item, index) => {
  60. const indexPath = parentIndexPath ? `${parentIndexPath}-${index}` : String(index);
  61. const itemId = getItemId(item, indexPath);
  62. const children = item?.subitems || [];
  63. if (!children.length) return;
  64. if (hasActiveDescendant(item)) {
  65. map[itemId] = true;
  66. }
  67. Object.assign(map, buildInitialExpandedMap(children, indexPath));
  68. });
  69. return map;
  70. };
  71. const [expandedMap, setExpandedMap] = useState(() => buildInitialExpandedMap(topLevelItems));
  72. const toggleExpanded = (id) => {
  73. setExpandedMap((prev) => ({ ...prev, [id]: !prev[id] }));
  74. };
  75. const renderItems = (items = [], depth = 0, parentIndexPath = '') =>
  76. items.map((item, index) => {
  77. const indexPath = parentIndexPath ? `${parentIndexPath}-${index}` : String(index);
  78. const itemId = getItemId(item, indexPath);
  79. const itemPath = (() => {
  80. const p = String(item?.url || item?.link || '');
  81. if (!p || p === 'javascript:void(0)') return '';
  82. return p.startsWith('/') ? p : `/${p}`;
  83. })();
  84. const itemPathComparable = itemPath.replace(/^\/+/, '');
  85. const isActive = Boolean(itemPathComparable && itemPathComparable === currentPathComparable);
  86. const children = item?.subitems || [];
  87. const hasChildren = children.length > 0;
  88. const isExpanded = Boolean(expandedMap[itemId]);
  89. const title = item?.title || item?.name;
  90. const icon = item?.icon || 'Circle';
  91. const canNavigate = hasItemPath(item);
  92. return (
  93. <View key={`lmenu-${itemId}`} className="w-full">
  94. {/* Indent on a wrapper: on the button it loses to the inline contentInsets padding. */}
  95. <View className={getDepthClassName(depth)}>
  96. <MenuItemSidebarWithWrapper
  97. link={canNavigate ? itemPath : undefined}
  98. title={title}
  99. icon={icon}
  100. iconEnd={!canNavigate && hasChildren ? (isExpanded ? 'ChevronDown' : 'ChevronRight') : undefined}
  101. isActive={isActive}
  102. onPress={!canNavigate && hasChildren ? () => toggleExpanded(itemId) : undefined}
  103. />
  104. </View>
  105. {hasChildren && isExpanded ? (
  106. <View className="gap-0.5">
  107. {renderItems(children, depth + 1, indexPath)}
  108. </View>
  109. ) : null}
  110. </View>
  111. );
  112. });
  113. // Flat menus whose items all carry a description render as choices instead of sidebar links.
  114. const isChoiceList = topLevelItems.length > 0 && topLevelItems.every(
  115. (item) => typeof item?.info === 'string' && item.info.trim() && !(item?.subitems || []).length
  116. );
  117. if (isChoiceList) {
  118. return (
  119. <BlockWrapper {...blockWrapperProps}>
  120. <View className="w-full gap-3">
  121. {topLevelItems.map((item, index) => {
  122. const path = String(item?.url || item?.link || '');
  123. const href = !path || /^https?:/.test(path) || path.startsWith('/') ? path : `/${path}`;
  124. return <MenuChoiceRow key={getItemId(item, String(index))} item={item} href={href} />;
  125. })}
  126. </View>
  127. </BlockWrapper>
  128. );
  129. }
  130. return (
  131. <BlockWrapper {...blockWrapperProps}>
  132. <View className="w-full gap-0.5">
  133. {renderItems(topLevelItems)}
  134. </View>
  135. </BlockWrapper>
  136. );
  137. }