menu.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /**
  2. * Menu reduction pure core. One group per source;
  3. * generation-gated settlement; empty ready groups auto-close. Zero React /
  4. * DOM / cordis. Stale or no-op events return the same state reference so
  5. * store subscribers skip re-renders.
  6. *
  7. * Roster protocol: the frozen `hit` event carries no source roster, so the
  8. * reducer cannot invent groups. Opening from a closed state, the shell seeds
  9. * the roster with {@link seedGroups} and then dispatches `hit`; a `hit`
  10. * while open (query refinement) resets the existing groups to pending under
  11. * a new generation while keeping their items on screen until the new fetch
  12. * settles (stale-while-revalidate — the render layer shows skeletons only
  13. * for a pending group with no items). Auto-close and explicit close drop
  14. * the groups.
  15. */
  16. import type { InputTriggerCandidate, InputTriggerSource } from '../types.ts'
  17. import type { ExactMatch, MenuReduce, MenuState } from './contract.ts'
  18. /** Closed rest state with generation 0; store initializer and test seed. */
  19. export const MENU_CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
  20. /**
  21. * Replace the group roster with pending groups for `sources`, in order.
  22. * Shell-side step before dispatching `hit` on a fresh menu open.
  23. *
  24. * @param state - Current menu state.
  25. * @param sources - Sources registered for the hit trigger, in menu order.
  26. * @returns State carrying the new pending roster; highlight cleared.
  27. */
  28. export function seedGroups(
  29. state: MenuState,
  30. sources: readonly Pick<InputTriggerSource, 'name' | 'showGroupTitle'>[],
  31. ): MenuState {
  32. return {
  33. ...state,
  34. groups: sources.map(source => ({
  35. source: source.name,
  36. ...(source.showGroupTitle === false ? { showGroupTitle: false } : {}),
  37. status: 'pending',
  38. items: [],
  39. })),
  40. highlight: null,
  41. }
  42. }
  43. /** Close, preserving the generation so in-flight settlements stay droppable. */
  44. const closed = (state: MenuState): MenuState =>
  45. state.open || state.hit !== null || state.groups.length > 0 || state.highlight !== null
  46. ? { open: false, hit: null, generation: state.generation, groups: [], highlight: null }
  47. : state
  48. /** First item of the first non-empty ready group, or null. */
  49. function firstHighlight(groups: MenuState['groups']): MenuState['highlight'] {
  50. for (const g of groups) {
  51. if (g.status === 'ready' && g.items.length > 0) return { source: g.source, index: 0 }
  52. }
  53. return null
  54. }
  55. /** The highlight itself when it still points at a ready item, else null. */
  56. function validHighlight(highlight: MenuState['highlight'], groups: MenuState['groups']): MenuState['highlight'] {
  57. if (!highlight) return null
  58. const g = groups.find(x => x.source === highlight.source)
  59. return g && g.status === 'ready' && highlight.index < g.items.length ? highlight : null
  60. }
  61. /** Flatten ready items into (source, index) positions in group order. */
  62. function positions(groups: MenuState['groups']): { source: string; index: number }[] {
  63. const out: { source: string; index: number }[] = []
  64. for (const g of groups) {
  65. if (g.status !== 'ready') continue
  66. for (let i = 0; i < g.items.length; i++) out.push({ source: g.source, index: i })
  67. }
  68. return out
  69. }
  70. /** True when every group is ready with zero items (the auto-close condition). */
  71. const allReadyEmpty = (groups: MenuState['groups']): boolean =>
  72. groups.every(g => g.status === 'ready' && g.items.length === 0)
  73. /**
  74. * Pure menu reducer. `hit` opens a new generation over the seeded roster
  75. * (null hit closes); `source-settled` outside the current generation, the
  76. * open menu, or the roster is dropped; a settlement or failure leaving every
  77. * group ready-and-empty (or no groups) auto-closes; `source-failed` silently
  78. * removes the group (the shell logs); `move` cycles the highlight across
  79. * ready items; `hover` parks it on one ready item (pointer and keyboard
  80. * share the single highlight — last input wins).
  81. *
  82. * @param state - Current menu state.
  83. * @param ev - Menu event.
  84. * @returns Next state; the same reference when stale or a no-op.
  85. */
  86. export const menuReduce: MenuReduce = (state, ev) => {
  87. switch (ev.type) {
  88. case 'hit': {
  89. if (ev.hit === null) return closed(state)
  90. return {
  91. open: true,
  92. hit: ev.hit,
  93. generation: state.generation + 1,
  94. // Items and highlight survive the refinement (stale-while-revalidate):
  95. // the previous query's candidates stay rendered with the highlight
  96. // parked where it was while the new fetch runs, and the settled
  97. // generation replaces the items and revalidates the highlight
  98. // wholesale. Pending status still fences picks off the stale rows.
  99. groups: state.groups.map(g => ({ ...g, status: 'pending' })),
  100. highlight: state.highlight,
  101. }
  102. }
  103. case 'source-settled': {
  104. if (!state.open || ev.generation !== state.generation) return state
  105. const idx = state.groups.findIndex(g => g.source === ev.source)
  106. if (idx < 0) return state
  107. const items: readonly InputTriggerCandidate[] = ev.items ?? []
  108. const groups = state.groups.map((g, i) =>
  109. i === idx ? { ...g, status: 'ready' as const, items } : g)
  110. if (allReadyEmpty(groups)) return closed(state)
  111. const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups)
  112. return { ...state, groups, highlight }
  113. }
  114. case 'source-failed': {
  115. if (!state.open || ev.generation !== state.generation) return state
  116. if (!state.groups.some(g => g.source === ev.source)) return state
  117. const groups = state.groups.filter(g => g.source !== ev.source)
  118. if (groups.length === 0 || allReadyEmpty(groups)) return closed(state)
  119. const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups)
  120. return { ...state, groups, highlight }
  121. }
  122. case 'move': {
  123. if (!state.open) return state
  124. const pos = positions(state.groups)
  125. if (pos.length === 0) return state
  126. const hl = state.highlight
  127. const at = hl ? pos.findIndex(p => p.source === hl.source && p.index === hl.index) : -1
  128. const next = pos[at < 0
  129. ? (ev.dir === 1 ? 0 : pos.length - 1)
  130. : (at + ev.dir + pos.length) % pos.length]
  131. if (next === undefined) return state
  132. if (hl && next.source === hl.source && next.index === hl.index) return state
  133. return { ...state, highlight: next }
  134. }
  135. case 'hover': {
  136. if (!state.open) return state
  137. const target = validHighlight({ source: ev.source, index: ev.index }, state.groups)
  138. if (target === null) return state
  139. const hl = state.highlight
  140. if (hl && hl.source === target.source && hl.index === target.index) return state
  141. return { ...state, highlight: target }
  142. }
  143. case 'close':
  144. return closed(state)
  145. }
  146. }
  147. /**
  148. * Exact-name lookup in one source's ready group.
  149. *
  150. * @param groups - Menu groups.
  151. * @param source - Source (group) name.
  152. * @param name - Candidate name to match exactly.
  153. * @returns The candidate, or null when the group is absent, not ready, or
  154. * has no candidate of that name.
  155. */
  156. export const exactMatch: ExactMatch = (groups, source, name) => {
  157. const group = groups.find(g => g.source === source)
  158. if (!group || group.status !== 'ready') return null
  159. return group.items.find(c => c.name === name) ?? null
  160. }