tasklists-panel.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import Svg, { Circle } from 'react-native-svg';
  4. import { View, Row, Pressable } from 'app/design/view';
  5. import { Text } from 'app/design/typography';
  6. import { Card } from 'app/ui/molecules/page/card';
  7. import { Icon } from 'app/ui/atoms/icon';
  8. import { useIconClassColor } from 'app/ui/atoms/icon-class-color';
  9. import { cn, decodeTextNode } from 'app/lib/util';
  10. import { TaskActionsDropdown } from './actions';
  11. import { isSameListId, isTasklistGroup, listIdOrNull, tasklistProgress } from './helpers';
  12. const PROGRESS_SIZE = 14;
  13. const PROGRESS_VIEW = 16;
  14. const PROGRESS_STROKE = 2.2;
  15. const PROGRESS_CX = PROGRESS_VIEW / 2;
  16. const PROGRESS_RADIUS = (PROGRESS_VIEW - PROGRESS_STROKE) / 2;
  17. const PROGRESS_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RADIUS;
  18. function ProgressArc({ className, dasharray, cap = false, opacity = 1 }) {
  19. const color = useIconClassColor(className) || 'currentColor';
  20. return (
  21. <View className={cn('absolute inset-0 items-center justify-center', className)}>
  22. <Svg width={PROGRESS_SIZE} height={PROGRESS_SIZE} viewBox={`0 0 ${PROGRESS_VIEW} ${PROGRESS_VIEW}`}>
  23. <Circle
  24. cx={PROGRESS_CX}
  25. cy={PROGRESS_CX}
  26. r={PROGRESS_RADIUS}
  27. stroke={color}
  28. strokeWidth={PROGRESS_STROKE}
  29. strokeOpacity={opacity}
  30. fill="none"
  31. strokeLinecap={cap ? 'round' : 'butt'}
  32. strokeDasharray={dasharray}
  33. strokeDashoffset={dasharray ? PROGRESS_CIRCUMFERENCE * 0.25 : undefined}
  34. />
  35. </Svg>
  36. </View>
  37. );
  38. }
  39. function TasklistProgress({ percent = 0 }) {
  40. const clamped = Math.min(100, Math.max(0, Number(percent) || 0));
  41. const dash = (clamped / 100) * PROGRESS_CIRCUMFERENCE;
  42. return (
  43. <View
  44. className="relative shrink-0"
  45. style={{ width: PROGRESS_SIZE, height: PROGRESS_SIZE }}
  46. accessibilityRole="progressbar"
  47. accessibilityValue={{ min: 0, max: 100, now: Math.round(clamped) }}
  48. >
  49. <ProgressArc className="text-muted-foreground" opacity={0.35} />
  50. {clamped > 0 ? (
  51. <ProgressArc
  52. className="text-primary"
  53. dasharray={clamped >= 100 ? undefined : `${dash} ${PROGRESS_CIRCUMFERENCE}`}
  54. cap={clamped < 100}
  55. />
  56. ) : null}
  57. </View>
  58. );
  59. }
  60. function isRowSelected(item, selectedId, selectedContextId) {
  61. if (!isSameListId(selectedId, item?.id)) return false;
  62. if (item?.context_id == null || selectedContextId == null) return true;
  63. return String(item.context_id) === String(selectedContextId);
  64. }
  65. function Count({ value }) {
  66. if (value == null) return null;
  67. return <Text className="shrink-0 text-xs tabular-nums text-muted-foreground">{value}</Text>;
  68. }
  69. function Chevron({ expanded, size = 16 }) {
  70. return (
  71. <Icon
  72. icon={expanded ? 'ChevronDown' : 'ChevronRight'}
  73. size={size}
  74. className="text-muted-foreground shrink-0"
  75. />
  76. );
  77. }
  78. function TasklistRow({ item, selected, onSelect, actionHandlers }) {
  79. const { percent, total } = tasklistProgress(item);
  80. return (
  81. <Row className={cn('items-center gap-1 rounded-lg pr-1', selected && 'bg-muted/50')}>
  82. <Pressable
  83. onPress={() => onSelect(item)}
  84. className={cn(
  85. 'min-w-0 flex-1 flex-row items-center gap-2 px-2 py-2 web:hover:bg-muted/50 rounded-lg'
  86. )}
  87. accessibilityLabel={total
  88. ? `${item.title}, ${Math.round(percent)}% of ${total}`
  89. : item.title}
  90. >
  91. <TasklistProgress percent={percent} />
  92. <Text className="min-w-0 flex-1 text-sm font-medium text-card-foreground" numberOfLines={1}>
  93. {item.title}
  94. </Text>
  95. <Count value={item.count} />
  96. </Pressable>
  97. {item.actions?.length ? (
  98. <TaskActionsDropdown actions={item.actions} {...actionHandlers} />
  99. ) : null}
  100. </Row>
  101. );
  102. }
  103. /** A context (space/profile) with its tasklists, collapsible. */
  104. function TasklistGroup({ group, selectedId, selectedContextId, onSelect, actionHandlers }) {
  105. const { t } = useTranslation();
  106. const [expanded, setExpanded] = useState(true);
  107. const groupSelected = selectedContextId != null
  108. && String(selectedContextId) === String(group.id)
  109. && listIdOrNull(selectedId) == null;
  110. return (
  111. <View className="gap-1">
  112. <Row className={cn(
  113. 'items-center rounded-lg',
  114. groupSelected && 'bg-muted/50',
  115. )}>
  116. <Pressable
  117. onPress={() => onSelect(group)}
  118. className="min-w-0 flex-1 flex-row items-center gap-2 py-2 px-2 web:hover:bg-muted/50 rounded-lg"
  119. accessibilityRole="button"
  120. accessibilityLabel={decodeTextNode(group.title)}
  121. >
  122. <Text className="min-w-0 flex-1 text-sm font-semibold text-foreground" numberOfLines={1}>
  123. {group.title}
  124. </Text>
  125. <Count value={group.count} />
  126. </Pressable>
  127. <Pressable
  128. onPress={() => setExpanded((prev) => !prev)}
  129. className="shrink-0 p-2 web:hover:bg-muted/50 rounded-lg"
  130. accessibilityRole="button"
  131. accessibilityLabel={expanded ? t('Collapse') : t('Expand')}
  132. >
  133. <Chevron expanded={expanded} size={14} />
  134. </Pressable>
  135. </Row>
  136. {expanded ? (group.children || []).map((item) => (
  137. <TasklistRow
  138. key={item.id}
  139. item={item}
  140. selected={isRowSelected(item, selectedId, selectedContextId)}
  141. onSelect={onSelect}
  142. actionHandlers={actionHandlers}
  143. />
  144. )) : null}
  145. </View>
  146. );
  147. }
  148. /**
  149. * Sidebar with tasklists. `tasklists` is either a flat list (inside a context)
  150. * or groups with `children` (profile home, one group per context).
  151. */
  152. export default function TasklistsPanel({
  153. tasklists,
  154. selectedId,
  155. selectedContextId,
  156. onSelect,
  157. actionHandlers,
  158. defaultCollapsed = false,
  159. fill = false,
  160. }) {
  161. const { t } = useTranslation();
  162. const [expanded, setExpanded] = useState(!defaultCollapsed);
  163. if (!tasklists.length) return null;
  164. const itemProps = { selectedId, selectedContextId, onSelect, actionHandlers };
  165. return (
  166. <Card
  167. padding="p-0"
  168. className={cn(
  169. 'w-full lg:w-72 shrink-0 rounded-xl overflow-visible',
  170. fill && 'lg:h-full lg:max-h-full lg:min-h-0',
  171. )}
  172. >
  173. <Pressable
  174. onPress={() => setExpanded((prev) => !prev)}
  175. className="flex-row items-center justify-start gap-2 mx-4 my-2.5 shrink-0 self-start"
  176. accessibilityRole="button"
  177. accessibilityLabel={expanded ? t('Collapse tasklists') : t('Expand tasklists')}
  178. >
  179. <Text className="text-base font-semibold text-foreground shrink-0">{t('Tasklists')}</Text>
  180. <Chevron expanded={expanded} />
  181. </Pressable>
  182. {expanded ? (
  183. <View className={cn(
  184. 'p-2 gap-1',
  185. fill && 'lg:min-h-0 lg:flex-1 lg:overflow-y-auto',
  186. )}>
  187. {tasklists.map((item) => (
  188. isTasklistGroup(item) ? (
  189. <TasklistGroup key={item.id} group={item} {...itemProps} />
  190. ) : (
  191. <TasklistRow
  192. key={item.id}
  193. item={item}
  194. selected={isRowSelected(item, selectedId, selectedContextId)}
  195. onSelect={onSelect}
  196. actionHandlers={actionHandlers}
  197. />
  198. )
  199. ))}
  200. </View>
  201. ) : null}
  202. </Card>
  203. );
  204. }