menu.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  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. // `itemsClassName`: classes for the list of links in place of `w-full`, e.g. the Conductor's left
  32. // panel lines them up with its own links (`-mx-1`: a fixed `w-full` would cancel the right margin).
  33. export default function ElementMenu({ data, blockWrapperProps, url, itemsClassName = '' }) {
  34. const MenuItemSidebarWithWrapper = components['menu-item']['sidebar_with_wrapper'];
  35. const currentPath = String(url || '');
  36. const currentPathComparable = currentPath.replace(/^\/+/, '');
  37. const depthClassNameMap = {
  38. 0: '',
  39. 1: conductorTheme.menu_categ_indent,
  40. 2: 'pl-12',
  41. 3: 'pl-16',
  42. };
  43. const getDepthClassName = (depth) => depthClassNameMap[depth] || '';
  44. const getItemId = (item, indexPath) => String(item?.id || item?.name || item?.url || item?.link || indexPath);
  45. const hasItemPath = (item) => {
  46. const path = String(item?.url || item?.link || '');
  47. return Boolean(path && path !== 'javascript:void(0)');
  48. };
  49. const hasActiveDescendant = (item) => {
  50. const itemPath = String(item?.url || item?.link || '');
  51. if (itemPath && itemPath !== 'javascript:void(0)') {
  52. const itemPathComparable = itemPath.replace(/^\/+/, '');
  53. if (itemPathComparable && itemPathComparable === currentPathComparable) return true;
  54. }
  55. const children = item?.subitems || [];
  56. return children.some((child) => hasActiveDescendant(child));
  57. };
  58. const topLevelItems = data?.content?.items || [];
  59. const buildInitialExpandedMap = (items = [], parentIndexPath = '') => {
  60. const map = {};
  61. items.forEach((item, index) => {
  62. const indexPath = parentIndexPath ? `${parentIndexPath}-${index}` : String(index);
  63. const itemId = getItemId(item, indexPath);
  64. const children = item?.subitems || [];
  65. if (!children.length) return;
  66. if (hasActiveDescendant(item)) {
  67. map[itemId] = true;
  68. }
  69. Object.assign(map, buildInitialExpandedMap(children, indexPath));
  70. });
  71. return map;
  72. };
  73. const [expandedMap, setExpandedMap] = useState(() => buildInitialExpandedMap(topLevelItems));
  74. const toggleExpanded = (id) => {
  75. setExpandedMap((prev) => ({ ...prev, [id]: !prev[id] }));
  76. };
  77. const renderItems = (items = [], depth = 0, parentIndexPath = '') =>
  78. items.map((item, index) => {
  79. const indexPath = parentIndexPath ? `${parentIndexPath}-${index}` : String(index);
  80. const itemId = getItemId(item, indexPath);
  81. const itemPath = (() => {
  82. const p = String(item?.url || item?.link || '');
  83. if (!p || p === 'javascript:void(0)') return '';
  84. return p.startsWith('/') ? p : `/${p}`;
  85. })();
  86. const itemPathComparable = itemPath.replace(/^\/+/, '');
  87. const isActive = Boolean(itemPathComparable && itemPathComparable === currentPathComparable);
  88. const children = item?.subitems || [];
  89. const hasChildren = children.length > 0;
  90. const isExpanded = Boolean(expandedMap[itemId]);
  91. const title = item?.title || item?.name;
  92. const icon = item?.icon || 'Circle';
  93. const canNavigate = hasItemPath(item);
  94. return (
  95. <View key={`lmenu-${itemId}`} className="w-full">
  96. {/* Indent on a wrapper: on the button it loses to the inline contentInsets padding. */}
  97. <View className={getDepthClassName(depth)}>
  98. <MenuItemSidebarWithWrapper
  99. link={canNavigate ? itemPath : undefined}
  100. title={title}
  101. icon={icon}
  102. iconEnd={!canNavigate && hasChildren ? (isExpanded ? 'ChevronDown' : 'ChevronRight') : undefined}
  103. isActive={isActive}
  104. onPress={!canNavigate && hasChildren ? () => toggleExpanded(itemId) : undefined}
  105. />
  106. </View>
  107. {hasChildren && isExpanded ? (
  108. <View className="gap-0.5">
  109. {renderItems(children, depth + 1, indexPath)}
  110. </View>
  111. ) : null}
  112. </View>
  113. );
  114. });
  115. // Flat menus whose items all carry a description render as choices instead of sidebar links.
  116. const isChoiceList = topLevelItems.length > 0 && topLevelItems.every(
  117. (item) => typeof item?.info === 'string' && item.info.trim() && !(item?.subitems || []).length
  118. );
  119. if (isChoiceList) {
  120. return (
  121. <BlockWrapper {...blockWrapperProps}>
  122. <View className="w-full gap-3">
  123. {topLevelItems.map((item, index) => {
  124. const path = String(item?.url || item?.link || '');
  125. const href = !path || /^https?:/.test(path) || path.startsWith('/') ? path : `/${path}`;
  126. return <MenuChoiceRow key={getItemId(item, String(index))} item={item} href={href} />;
  127. })}
  128. </View>
  129. </BlockWrapper>
  130. );
  131. }
  132. return (
  133. <BlockWrapper {...blockWrapperProps}>
  134. <View className={`gap-0.5 ${itemsClassName || 'w-full'}`}>
  135. {renderItems(topLevelItems)}
  136. </View>
  137. </BlockWrapper>
  138. );
  139. }