@tooee/search 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,186 +1,227 @@
1
- import { createStore } from "@xstate/store"
2
- import type { Mode } from "@tooee/commands"
1
+ import { createStore } from "@xstate/store";
2
+ import type { Mode } from "@tooee/commands";
3
3
 
4
- export type RowKey = string | number | bigint
5
- export type SearchStatus = "idle" | "editing" | "committed"
4
+ export type RowKey = string | number | bigint;
5
+ export type SearchStatus = "idle" | "editing" | "committed";
6
6
 
7
7
  export interface NavSearchContext {
8
- rowKeys: readonly RowKey[]
9
- cursor: number | null
10
- selectionAnchor: number | null
11
- toggledKeys: ReadonlySet<RowKey>
8
+ rowKeys: readonly RowKey[];
9
+ cursor: number | null;
10
+ selectionAnchor: number | null;
11
+ toggledKeys: ReadonlySet<RowKey>;
12
12
  search: {
13
- status: SearchStatus
14
- query: string
15
- committedQuery: string
16
- matches: readonly number[]
17
- currentMatchIndex: number
18
- preSearchMode: Mode
19
- }
13
+ status: SearchStatus;
14
+ query: string;
15
+ committedQuery: string;
16
+ matches: readonly number[];
17
+ currentMatchIndex: number;
18
+ preSearchMode: Mode;
19
+ };
20
20
  }
21
21
 
22
22
  export interface NavSearchDeps {
23
- isSelectable(index: number): boolean
23
+ isSelectable: (index: number) => boolean;
24
24
  }
25
25
 
26
- export type NavSearchEvents = {
27
- rowsChanged: { keys: readonly RowKey[]; preserveCursorByKey?: boolean }
28
- move: { delta: number }
29
- jump: { index: number; direction: 1 | -1 }
30
- setCursor: { index: number }
31
- enterSelect: {}
32
- cancelSelect: {}
33
- toggleCurrent: {}
34
- toggleAndMove: { delta: number }
35
- searchStarted: { mode: Mode }
36
- searchChanged: { query: string; matches: readonly number[] }
37
- searchSubmitted: {}
38
- searchCancelled: {}
39
- searchNext: {}
40
- searchPrevious: {}
26
+ interface NavSearchEventDefinitions {
27
+ rowsChanged: { keys: readonly RowKey[]; preserveCursorByKey?: boolean };
28
+ move: { delta: number };
29
+ jump: { index: number; direction: 1 | -1 };
30
+ setCursor: { index: number };
31
+ enterSelect: Record<never, never>;
32
+ cancelSelect: Record<never, never>;
33
+ toggleCurrent: Record<never, never>;
34
+ toggleAndMove: { delta: number };
35
+ searchStarted: { mode: Mode };
36
+ searchChanged: { query: string; matches: readonly number[] };
37
+ searchSubmitted: Record<never, never>;
38
+ searchCancelled: Record<never, never>;
39
+ searchNext: Record<never, never>;
40
+ searchPrevious: Record<never, never>;
41
41
  }
42
+ export type NavSearchEvents = {
43
+ [EventName in keyof NavSearchEventDefinitions]: NavSearchEventDefinitions[EventName];
44
+ };
42
45
 
43
- type NavSearchEmitted = {
44
- restoreMode: { mode: Mode }
45
- jumped: { index: number }
46
+ interface NavSearchEmittedDefinitions {
47
+ restoreMode: { mode: Mode };
48
+ jumped: { index: number };
46
49
  }
50
+ type NavSearchEmitted = {
51
+ [EventName in keyof NavSearchEmittedDefinitions]: NavSearchEmittedDefinitions[EventName];
52
+ };
47
53
 
48
- export function resolveIndex(
54
+ export const resolveIndex = function resolveIndex(
49
55
  target: number,
50
56
  direction: 1 | -1,
51
57
  rowCount: number,
52
58
  isSelectable: (index: number) => boolean,
53
59
  ): number | null {
54
- if (rowCount <= 0) return null
55
- const max = rowCount - 1
56
- const clamped = Math.max(0, Math.min(target, max))
57
- if (isSelectable(clamped)) return clamped
60
+ if (rowCount <= 0) {
61
+ return null;
62
+ }
63
+ const max = rowCount - 1;
64
+ const clamped = Math.max(0, Math.min(target, max));
65
+ if (isSelectable(clamped)) {
66
+ return clamped;
67
+ }
58
68
  for (
59
69
  let index = clamped + direction;
60
70
  direction === 1 ? index <= max : index >= 0;
61
71
  index += direction
62
72
  ) {
63
- if (isSelectable(index)) return index
73
+ if (isSelectable(index)) {
74
+ return index;
75
+ }
64
76
  }
65
77
  for (
66
78
  let index = clamped - direction;
67
79
  direction === 1 ? index >= 0 : index <= max;
68
80
  index -= direction
69
81
  ) {
70
- if (isSelectable(index)) return index
82
+ if (isSelectable(index)) {
83
+ return index;
84
+ }
71
85
  }
72
- return null
73
- }
86
+ return null;
87
+ };
88
+
89
+ const toggle = function toggle(ctx: NavSearchContext): NavSearchContext {
90
+ if (ctx.cursor === null) {
91
+ return ctx;
92
+ }
93
+ const key = ctx.rowKeys[ctx.cursor];
94
+ if (key === undefined) {
95
+ return ctx;
96
+ }
97
+ const toggledKeys = new Set(ctx.toggledKeys);
98
+ if (toggledKeys.has(key)) {
99
+ toggledKeys.delete(key);
100
+ } else {
101
+ toggledKeys.add(key);
102
+ }
103
+ return { ...ctx, toggledKeys };
104
+ };
105
+
106
+ const searchStep = function searchStep(
107
+ ctx: NavSearchContext,
108
+ delta: 1 | -1,
109
+ enqueue: { emit: { jumped: (event: { index: number }) => void } },
110
+ ): NavSearchContext {
111
+ if (ctx.search.matches.length === 0) {
112
+ return ctx;
113
+ }
114
+ const currentMatchIndex =
115
+ (ctx.search.currentMatchIndex + delta + ctx.search.matches.length) % ctx.search.matches.length;
116
+ const cursor = ctx.search.matches[currentMatchIndex];
117
+ enqueue.emit.jumped({ index: cursor });
118
+ return { ...ctx, cursor, search: { ...ctx.search, currentMatchIndex } };
119
+ };
74
120
 
75
- const EMPTY_KEYS: readonly RowKey[] = []
121
+ const EMPTY_KEYS: readonly RowKey[] = [];
76
122
 
77
- export function createNavSearchStore(
123
+ export const createNavSearchStore = function createNavSearchStore(
78
124
  options: {
79
- keys?: readonly RowKey[]
80
- deps?: NavSearchDeps
125
+ keys?: readonly RowKey[];
126
+ deps?: NavSearchDeps;
81
127
  } = {},
82
128
  ) {
83
- const deps = options.deps ?? { isSelectable: () => true }
84
- const keys = options.keys ?? EMPTY_KEYS
129
+ const deps = options.deps ?? { isSelectable: () => true };
130
+ const keys = options.keys ?? EMPTY_KEYS;
85
131
  const resolve = (index: number, direction: 1 | -1, count = keys.length) =>
86
- resolveIndex(index, direction, count, deps.isSelectable)
132
+ resolveIndex(index, direction, count, deps.isSelectable);
87
133
 
88
134
  return createStore<NavSearchContext, NavSearchEvents, NavSearchEmitted>({
89
135
  context: {
90
- rowKeys: keys,
91
136
  cursor: resolve(0, 1),
92
- selectionAnchor: null,
93
- toggledKeys: new Set(),
137
+ rowKeys: keys,
94
138
  search: {
95
- status: "idle",
96
- query: "",
97
139
  committedQuery: "",
98
- matches: [],
99
140
  currentMatchIndex: 0,
141
+ matches: [],
100
142
  preSearchMode: "cursor",
143
+ query: "",
144
+ status: "idle",
101
145
  },
146
+ selectionAnchor: null,
147
+ toggledKeys: new Set(),
102
148
  },
103
149
  on: {
150
+ cancelSelect: (ctx) =>
151
+ ctx.selectionAnchor === null ? ctx : { ...ctx, selectionAnchor: null },
152
+ enterSelect: (ctx) => ({ ...ctx, selectionAnchor: ctx.cursor }),
153
+ jump: (ctx, event) => ({
154
+ ...ctx,
155
+ cursor: resolveIndex(event.index, event.direction, ctx.rowKeys.length, deps.isSelectable),
156
+ }),
157
+ move: (ctx, event) => {
158
+ if (ctx.cursor === null) {
159
+ return ctx;
160
+ }
161
+ const direction: 1 | -1 = event.delta < 0 ? -1 : 1;
162
+ const cursor = resolveIndex(
163
+ ctx.cursor + event.delta,
164
+ direction,
165
+ ctx.rowKeys.length,
166
+ deps.isSelectable,
167
+ );
168
+ return cursor === null || cursor === ctx.cursor ? ctx : { ...ctx, cursor };
169
+ },
104
170
  rowsChanged: (ctx, event) => {
105
- const previousKey = ctx.cursor === null ? undefined : ctx.rowKeys[ctx.cursor]
106
- let cursor = ctx.cursor
107
- if (event.keys.length === 0) cursor = null
108
- else if (event.preserveCursorByKey && previousKey !== undefined) {
109
- const preserved = event.keys.indexOf(previousKey)
171
+ const previousCursor = ctx.cursor;
172
+ const previousKey = previousCursor === null ? undefined : ctx.rowKeys[previousCursor];
173
+ let { cursor } = ctx;
174
+ if (event.keys.length === 0) {
175
+ cursor = null;
176
+ } else if (
177
+ event.preserveCursorByKey === true &&
178
+ previousCursor !== null &&
179
+ previousKey !== undefined
180
+ ) {
181
+ const preserved = event.keys.indexOf(previousKey);
110
182
  cursor =
111
- preserved >= 0
112
- ? resolveIndex(preserved, 1, event.keys.length, deps.isSelectable)
113
- : resolveIndex(
114
- Math.min(ctx.cursor!, event.keys.length - 1),
183
+ preserved === -1
184
+ ? resolveIndex(
185
+ Math.min(previousCursor, event.keys.length - 1),
115
186
  1,
116
187
  event.keys.length,
117
188
  deps.isSelectable,
118
189
  )
190
+ : resolveIndex(preserved, 1, event.keys.length, deps.isSelectable);
119
191
  } else if (cursor === null || cursor >= event.keys.length) {
120
192
  cursor = resolveIndex(
121
193
  cursor === null ? 0 : event.keys.length - 1,
122
194
  cursor === null ? 1 : -1,
123
195
  event.keys.length,
124
196
  deps.isSelectable,
125
- )
197
+ );
126
198
  } else if (!deps.isSelectable(cursor)) {
127
- cursor = resolveIndex(cursor, 1, event.keys.length, deps.isSelectable)
199
+ cursor = resolveIndex(cursor, 1, event.keys.length, deps.isSelectable);
128
200
  }
129
- const available = new Set(event.keys)
130
- const filtered = new Set([...ctx.toggledKeys].filter((key) => available.has(key)))
131
- const toggledKeys = filtered.size === ctx.toggledKeys.size ? ctx.toggledKeys : filtered
132
- return { ...ctx, rowKeys: event.keys, cursor, toggledKeys }
201
+ const available = new Set(event.keys);
202
+ const filtered = new Set([...ctx.toggledKeys].filter((key) => available.has(key)));
203
+ const toggledKeys = filtered.size === ctx.toggledKeys.size ? ctx.toggledKeys : filtered;
204
+ return { ...ctx, cursor, rowKeys: event.keys, toggledKeys };
133
205
  },
134
- move: (ctx, event) => {
135
- if (ctx.cursor === null) return ctx
136
- const direction: 1 | -1 = event.delta < 0 ? -1 : 1
137
- const cursor = resolveIndex(
138
- ctx.cursor + event.delta,
139
- direction,
140
- ctx.rowKeys.length,
141
- deps.isSelectable,
142
- )
143
- return cursor === null || cursor === ctx.cursor ? ctx : { ...ctx, cursor }
144
- },
145
- jump: (ctx, event) => ({
146
- ...ctx,
147
- cursor: resolveIndex(event.index, event.direction, ctx.rowKeys.length, deps.isSelectable),
148
- }),
149
- setCursor: (ctx, event) => {
150
- const direction: 1 | -1 = ctx.cursor !== null && event.index < ctx.cursor ? -1 : 1
151
- const cursor = resolveIndex(event.index, direction, ctx.rowKeys.length, deps.isSelectable)
152
- return cursor === null ? ctx : { ...ctx, cursor }
153
- },
154
- enterSelect: (ctx) => ({ ...ctx, selectionAnchor: ctx.cursor }),
155
- cancelSelect: (ctx) =>
156
- ctx.selectionAnchor === null ? ctx : { ...ctx, selectionAnchor: null },
157
- toggleCurrent: (ctx) => toggle(ctx),
158
- toggleAndMove: (ctx, event) => {
159
- const toggled = toggle(ctx)
160
- if (toggled.cursor === null) return toggled
161
- const direction: 1 | -1 = event.delta < 0 ? -1 : 1
162
- const cursor = resolveIndex(
163
- toggled.cursor + event.delta,
164
- direction,
165
- toggled.rowKeys.length,
166
- deps.isSelectable,
167
- )
168
- return cursor === null ? toggled : { ...toggled, cursor }
206
+ searchCancelled: (ctx, _event, enqueue) => {
207
+ enqueue.emit.restoreMode({ mode: ctx.search.preSearchMode });
208
+ return {
209
+ ...ctx,
210
+ search: {
211
+ ...ctx.search,
212
+ committedQuery: "",
213
+ currentMatchIndex: 0,
214
+ matches: [],
215
+ query: "",
216
+ status: "idle",
217
+ },
218
+ };
169
219
  },
170
- searchStarted: (ctx, event) => ({
171
- ...ctx,
172
- search: {
173
- ...ctx.search,
174
- status: "editing",
175
- query: "",
176
- matches: [],
177
- currentMatchIndex: 0,
178
- preSearchMode: event.mode,
179
- },
180
- }),
181
220
  searchChanged: (ctx, event, enqueue) => {
182
- const first = event.matches[0]
183
- if (first !== undefined) enqueue.emit.jumped({ index: first })
221
+ const [first] = event.matches;
222
+ if (first !== undefined) {
223
+ enqueue.emit.jumped({ index: first });
224
+ }
184
225
  return {
185
226
  ...ctx,
186
227
  cursor:
@@ -189,16 +230,31 @@ export function createNavSearchStore(
189
230
  : resolveIndex(first, 1, ctx.rowKeys.length, deps.isSelectable),
190
231
  search: {
191
232
  ...ctx.search,
192
- query: event.query,
193
- matches: event.matches,
194
233
  currentMatchIndex: 0,
234
+ matches: event.matches,
235
+ query: event.query,
195
236
  },
196
- }
237
+ };
197
238
  },
239
+ searchNext: (ctx, _event, enqueue) => searchStep(ctx, 1, enqueue),
240
+ searchPrevious: (ctx, _event, enqueue) => searchStep(ctx, -1, enqueue),
241
+ searchStarted: (ctx, event) => ({
242
+ ...ctx,
243
+ search: {
244
+ ...ctx.search,
245
+ currentMatchIndex: 0,
246
+ matches: [],
247
+ preSearchMode: event.mode,
248
+ query: "",
249
+ status: "editing",
250
+ },
251
+ }),
198
252
  searchSubmitted: (ctx, _event, enqueue) => {
199
- const first = ctx.search.matches[0]
200
- if (first !== undefined) enqueue.emit.jumped({ index: first })
201
- enqueue.emit.restoreMode({ mode: ctx.search.preSearchMode })
253
+ const [first] = ctx.search.matches;
254
+ if (first !== undefined) {
255
+ enqueue.emit.jumped({ index: first });
256
+ }
257
+ enqueue.emit.restoreMode({ mode: ctx.search.preSearchMode });
202
258
  return {
203
259
  ...ctx,
204
260
  cursor:
@@ -207,72 +263,53 @@ export function createNavSearchStore(
207
263
  : resolveIndex(first, 1, ctx.rowKeys.length, deps.isSelectable),
208
264
  search: {
209
265
  ...ctx.search,
210
- status: "committed",
211
266
  committedQuery: ctx.search.query,
212
267
  currentMatchIndex: 0,
268
+ status: "committed",
213
269
  },
214
- }
270
+ };
215
271
  },
216
- searchCancelled: (ctx, _event, enqueue) => {
217
- enqueue.emit.restoreMode({ mode: ctx.search.preSearchMode })
218
- return {
219
- ...ctx,
220
- search: {
221
- ...ctx.search,
222
- status: "idle",
223
- query: "",
224
- committedQuery: "",
225
- matches: [],
226
- currentMatchIndex: 0,
227
- },
272
+ setCursor: (ctx, event) => {
273
+ const direction: 1 | -1 = ctx.cursor !== null && event.index < ctx.cursor ? -1 : 1;
274
+ const cursor = resolveIndex(event.index, direction, ctx.rowKeys.length, deps.isSelectable);
275
+ return cursor === null ? ctx : { ...ctx, cursor };
276
+ },
277
+ toggleAndMove: (ctx, event) => {
278
+ const toggled = toggle(ctx);
279
+ if (toggled.cursor === null) {
280
+ return toggled;
228
281
  }
282
+ const direction: 1 | -1 = event.delta < 0 ? -1 : 1;
283
+ const cursor = resolveIndex(
284
+ toggled.cursor + event.delta,
285
+ direction,
286
+ toggled.rowKeys.length,
287
+ deps.isSelectable,
288
+ );
289
+ return cursor === null ? toggled : { ...toggled, cursor };
229
290
  },
230
- searchNext: (ctx, _event, enqueue) => searchStep(ctx, 1, enqueue),
231
- searchPrevious: (ctx, _event, enqueue) => searchStep(ctx, -1, enqueue),
291
+ toggleCurrent: (ctx) => toggle(ctx),
232
292
  },
233
- })
234
- }
235
-
236
- function toggle(ctx: NavSearchContext): NavSearchContext {
237
- if (ctx.cursor === null) return ctx
238
- const key = ctx.rowKeys[ctx.cursor]
239
- if (key === undefined) return ctx
240
- const toggledKeys = new Set(ctx.toggledKeys)
241
- if (toggledKeys.has(key)) toggledKeys.delete(key)
242
- else toggledKeys.add(key)
243
- return { ...ctx, toggledKeys }
244
- }
245
-
246
- function searchStep(
247
- ctx: NavSearchContext,
248
- delta: 1 | -1,
249
- enqueue: { emit: { jumped: (event: { index: number }) => void } },
250
- ): NavSearchContext {
251
- if (ctx.search.matches.length === 0) return ctx
252
- const currentMatchIndex =
253
- (ctx.search.currentMatchIndex + delta + ctx.search.matches.length) % ctx.search.matches.length
254
- const cursor = ctx.search.matches[currentMatchIndex]!
255
- enqueue.emit.jumped({ index: cursor })
256
- return { ...ctx, cursor, search: { ...ctx.search, currentMatchIndex } }
257
- }
293
+ });
294
+ };
258
295
 
259
- export type NavSearchStore = ReturnType<typeof createNavSearchStore>
296
+ export type NavSearchStore = ReturnType<typeof createNavSearchStore>;
260
297
 
261
- export const selectCursor = (ctx: NavSearchContext) => ctx.cursor
262
- export const selectRowKeys = (ctx: NavSearchContext) => ctx.rowKeys
263
- export const selectSelectionAnchor = (ctx: NavSearchContext) => ctx.selectionAnchor
264
- export const selectToggledKeys = (ctx: NavSearchContext) => ctx.toggledKeys
265
- export const selectSearchStatus = (ctx: NavSearchContext) => ctx.search.status
266
- export const selectSearchActive = (ctx: NavSearchContext) => ctx.search.status === "editing"
298
+ export const selectCursor = (ctx: NavSearchContext) => ctx.cursor;
299
+ export const selectRowKeys = (ctx: NavSearchContext) => ctx.rowKeys;
300
+ export const selectSelectionAnchor = (ctx: NavSearchContext) => ctx.selectionAnchor;
301
+ export const selectToggledKeys = (ctx: NavSearchContext) => ctx.toggledKeys;
302
+ export const selectSearchStatus = (ctx: NavSearchContext) => ctx.search.status;
303
+ export const selectSearchActive = (ctx: NavSearchContext) => ctx.search.status === "editing";
267
304
  export const selectSearchQuery = (ctx: NavSearchContext) =>
268
- ctx.search.status === "editing" ? ctx.search.query : ctx.search.committedQuery
269
- export const selectMatches = (ctx: NavSearchContext) => ctx.search.matches
270
- export const selectCurrentMatchIndex = (ctx: NavSearchContext) => ctx.search.currentMatchIndex
271
- export function deriveSelection(ctx: NavSearchContext, mode: Mode) {
305
+ ctx.search.status === "editing" ? ctx.search.query : ctx.search.committedQuery;
306
+ export const selectMatches = (ctx: NavSearchContext) => ctx.search.matches;
307
+ export const selectCurrentMatchIndex = (ctx: NavSearchContext) => ctx.search.currentMatchIndex;
308
+ export const deriveSelection = function deriveSelection(ctx: NavSearchContext, mode: Mode) {
272
309
  return mode === "select" && ctx.selectionAnchor !== null && ctx.cursor !== null
273
310
  ? {
274
- start: Math.min(ctx.selectionAnchor, ctx.cursor),
275
311
  end: Math.max(ctx.selectionAnchor, ctx.cursor),
312
+ start: Math.min(ctx.selectionAnchor, ctx.cursor),
276
313
  }
277
- : null
278
- }
314
+ : null;
315
+ };
@@ -1,37 +1,37 @@
1
- import { useTheme } from "@tooee/themes"
1
+ import { useTheme } from "@tooee/themes";
2
2
 
3
3
  export interface SearchBarProps {
4
- query: string
5
- onQueryChange: (query: string) => void
6
- onSubmit: () => void
7
- onCancel: () => void
8
- matchCount?: number
9
- currentMatch?: number
4
+ query: string;
5
+ onQueryChange: (query: string) => void;
6
+ onSubmit: () => void;
7
+ onCancel: () => void;
8
+ matchCount?: number;
9
+ currentMatch?: number;
10
10
  }
11
11
 
12
- export function SearchBar({
12
+ export const SearchBar = function SearchBar({
13
13
  query,
14
14
  onQueryChange,
15
15
  onSubmit,
16
16
  onCancel: _onCancel,
17
17
  matchCount,
18
18
  currentMatch,
19
- }: SearchBarProps) {
20
- const { theme } = useTheme()
19
+ }: SearchBarProps): React.ReactNode {
20
+ const { theme } = useTheme();
21
21
 
22
- const matchDisplay =
23
- matchCount !== undefined && matchCount > 0
24
- ? `${(currentMatch ?? 0) + 1}/${matchCount}`
25
- : matchCount === 0 && query.length > 0
26
- ? "No matches"
27
- : ""
22
+ let matchDisplay = "";
23
+ if (matchCount !== undefined && matchCount > 0) {
24
+ matchDisplay = `${(currentMatch ?? 0) + 1}/${matchCount}`;
25
+ } else if (matchCount === 0 && query.length > 0) {
26
+ matchDisplay = "No matches";
27
+ }
28
28
 
29
29
  return (
30
30
  <box
31
31
  style={{
32
+ backgroundColor: theme.backgroundPanel,
32
33
  flexDirection: "row",
33
34
  flexShrink: 0,
34
- backgroundColor: theme.backgroundPanel,
35
35
  paddingLeft: 1,
36
36
  paddingRight: 1,
37
37
  }}
@@ -46,10 +46,10 @@ export function SearchBar({
46
46
  focusedBackgroundColor="transparent"
47
47
  textColor={theme.text}
48
48
  cursorColor={theme.accent}
49
- cursorStyle={{ style: "line", blinking: true }}
49
+ cursorStyle={{ blinking: true, style: "line" }}
50
50
  style={{ flexGrow: 1 }}
51
51
  />
52
52
  {matchDisplay ? <text content={` ${matchDisplay}`} style={{ fg: theme.textMuted }} /> : null}
53
53
  </box>
54
- )
55
- }
54
+ );
55
+ };