@mailwoman/react 0.0.1 → 7.5.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.
Files changed (143) hide show
  1. package/README.md +34 -4
  2. package/common/ClientOnly.tsx +30 -0
  3. package/common/CopyButton.tsx +44 -0
  4. package/common/KindBadge.tsx +44 -0
  5. package/common/LoadingIndicator.tsx +104 -0
  6. package/common/PresetChips.tsx +43 -0
  7. package/common/cx.ts +34 -0
  8. package/common/useClipboard.ts +73 -0
  9. package/common/useDebouncedValue.ts +23 -0
  10. package/index.ts +86 -4
  11. package/out/common/ClientOnly.d.ts +22 -0
  12. package/out/common/ClientOnly.d.ts.map +1 -0
  13. package/out/common/ClientOnly.js +20 -0
  14. package/out/common/ClientOnly.js.map +1 -0
  15. package/out/common/CopyButton.d.ts +21 -0
  16. package/out/common/CopyButton.d.ts.map +1 -0
  17. package/out/common/CopyButton.js +7 -0
  18. package/out/common/CopyButton.js.map +1 -0
  19. package/out/common/KindBadge.d.ts +24 -0
  20. package/out/common/KindBadge.d.ts.map +1 -0
  21. package/out/common/KindBadge.js +6 -0
  22. package/out/common/KindBadge.js.map +1 -0
  23. package/out/common/LoadingIndicator.d.ts +27 -0
  24. package/out/common/LoadingIndicator.d.ts.map +1 -0
  25. package/out/common/LoadingIndicator.js +41 -0
  26. package/out/common/LoadingIndicator.js.map +1 -0
  27. package/out/common/PresetChips.d.ts +22 -0
  28. package/out/common/PresetChips.d.ts.map +1 -0
  29. package/out/common/PresetChips.js +5 -0
  30. package/out/common/PresetChips.js.map +1 -0
  31. package/out/common/cx.d.ts +13 -0
  32. package/out/common/cx.d.ts.map +1 -0
  33. package/out/common/cx.js +28 -0
  34. package/out/common/cx.js.map +1 -0
  35. package/out/common/useClipboard.d.ts +16 -0
  36. package/out/common/useClipboard.d.ts.map +1 -0
  37. package/out/common/useClipboard.js +56 -0
  38. package/out/common/useClipboard.js.map +1 -0
  39. package/out/common/useDebouncedValue.d.ts +11 -0
  40. package/out/common/useDebouncedValue.d.ts.map +1 -0
  41. package/out/common/useDebouncedValue.js +19 -0
  42. package/out/common/useDebouncedValue.js.map +1 -0
  43. package/out/index.d.ts +57 -4
  44. package/out/index.d.ts.map +1 -1
  45. package/out/index.js +37 -4
  46. package/out/index.js.map +1 -1
  47. package/out/pipeline/CandidatePicker.d.ts +17 -0
  48. package/out/pipeline/CandidatePicker.d.ts.map +1 -0
  49. package/out/pipeline/CandidatePicker.js +6 -0
  50. package/out/pipeline/CandidatePicker.js.map +1 -0
  51. package/out/pipeline/ComponentTable.d.ts +14 -0
  52. package/out/pipeline/ComponentTable.d.ts.map +1 -0
  53. package/out/pipeline/ComponentTable.js +6 -0
  54. package/out/pipeline/ComponentTable.js.map +1 -0
  55. package/out/pipeline/ConfidenceCell.d.ts +13 -0
  56. package/out/pipeline/ConfidenceCell.d.ts.map +1 -0
  57. package/out/pipeline/ConfidenceCell.js +22 -0
  58. package/out/pipeline/ConfidenceCell.js.map +1 -0
  59. package/out/pipeline/PipelineExplorer.d.ts +25 -0
  60. package/out/pipeline/PipelineExplorer.d.ts.map +1 -0
  61. package/out/pipeline/PipelineExplorer.js +27 -0
  62. package/out/pipeline/PipelineExplorer.js.map +1 -0
  63. package/out/pipeline/QueryForm.d.ts +19 -0
  64. package/out/pipeline/QueryForm.d.ts.map +1 -0
  65. package/out/pipeline/QueryForm.js +9 -0
  66. package/out/pipeline/QueryForm.js.map +1 -0
  67. package/out/pipeline/ResolvedPlace.d.ts +16 -0
  68. package/out/pipeline/ResolvedPlace.d.ts.map +1 -0
  69. package/out/pipeline/ResolvedPlace.js +14 -0
  70. package/out/pipeline/ResolvedPlace.js.map +1 -0
  71. package/out/pipeline/copy.d.ts +11 -0
  72. package/out/pipeline/copy.d.ts.map +1 -0
  73. package/out/pipeline/copy.js +31 -0
  74. package/out/pipeline/copy.js.map +1 -0
  75. package/out/pipeline/presets.d.ts +12 -0
  76. package/out/pipeline/presets.d.ts.map +1 -0
  77. package/out/pipeline/presets.js +21 -0
  78. package/out/pipeline/presets.js.map +1 -0
  79. package/out/pipeline/types.d.ts +110 -0
  80. package/out/pipeline/types.d.ts.map +1 -0
  81. package/out/pipeline/types.js +12 -0
  82. package/out/pipeline/types.js.map +1 -0
  83. package/out/pipeline/useParsePipeline.d.ts +34 -0
  84. package/out/pipeline/useParsePipeline.d.ts.map +1 -0
  85. package/out/pipeline/useParsePipeline.js +55 -0
  86. package/out/pipeline/useParsePipeline.js.map +1 -0
  87. package/out/poi/AbstainPanel.d.ts +14 -0
  88. package/out/poi/AbstainPanel.d.ts.map +1 -0
  89. package/out/poi/AbstainPanel.js +5 -0
  90. package/out/poi/AbstainPanel.js.map +1 -0
  91. package/out/poi/LiveResultsBlock.d.ts +21 -0
  92. package/out/poi/LiveResultsBlock.d.ts.map +1 -0
  93. package/out/poi/LiveResultsBlock.js +7 -0
  94. package/out/poi/LiveResultsBlock.js.map +1 -0
  95. package/out/poi/OverpassBlock.d.ts +15 -0
  96. package/out/poi/OverpassBlock.d.ts.map +1 -0
  97. package/out/poi/OverpassBlock.js +11 -0
  98. package/out/poi/OverpassBlock.js.map +1 -0
  99. package/out/poi/POIExplorer.d.ts +31 -0
  100. package/out/poi/POIExplorer.d.ts.map +1 -0
  101. package/out/poi/POIExplorer.js +43 -0
  102. package/out/poi/POIExplorer.js.map +1 -0
  103. package/out/poi/QueryInput.d.ts +17 -0
  104. package/out/poi/QueryInput.d.ts.map +1 -0
  105. package/out/poi/QueryInput.js +5 -0
  106. package/out/poi/QueryInput.js.map +1 -0
  107. package/out/poi/SubjectPanel.d.ts +16 -0
  108. package/out/poi/SubjectPanel.d.ts.map +1 -0
  109. package/out/poi/SubjectPanel.js +12 -0
  110. package/out/poi/SubjectPanel.js.map +1 -0
  111. package/out/poi/runtime.d.ts +43 -0
  112. package/out/poi/runtime.d.ts.map +1 -0
  113. package/out/poi/runtime.js +67 -0
  114. package/out/poi/runtime.js.map +1 -0
  115. package/out/poi/types.d.ts +113 -0
  116. package/out/poi/types.d.ts.map +1 -0
  117. package/out/poi/types.js +12 -0
  118. package/out/poi/types.js.map +1 -0
  119. package/out/poi/usePOISearch.d.ts +42 -0
  120. package/out/poi/usePOISearch.d.ts.map +1 -0
  121. package/out/poi/usePOISearch.js +158 -0
  122. package/out/poi/usePOISearch.js.map +1 -0
  123. package/package.json +52 -8
  124. package/pipeline/CandidatePicker.tsx +45 -0
  125. package/pipeline/ComponentTable.tsx +41 -0
  126. package/pipeline/ConfidenceCell.tsx +33 -0
  127. package/pipeline/PipelineExplorer.tsx +148 -0
  128. package/pipeline/QueryForm.tsx +52 -0
  129. package/pipeline/ResolvedPlace.tsx +50 -0
  130. package/pipeline/copy.ts +37 -0
  131. package/pipeline/presets.ts +24 -0
  132. package/pipeline/types.ts +118 -0
  133. package/pipeline/useParsePipeline.ts +84 -0
  134. package/poi/AbstainPanel.tsx +22 -0
  135. package/poi/LiveResultsBlock.tsx +70 -0
  136. package/poi/OverpassBlock.tsx +37 -0
  137. package/poi/POIExplorer.tsx +133 -0
  138. package/poi/QueryInput.tsx +26 -0
  139. package/poi/SubjectPanel.tsx +79 -0
  140. package/poi/runtime.ts +78 -0
  141. package/poi/types.ts +112 -0
  142. package/poi/usePOISearch.ts +234 -0
  143. package/styles.css +648 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `usePOISearch` — the headless core of the POI explorer. Owns the taxonomy-runtime load, the
7
+ * debounced classify → subject → OverpassQL derivation, and the "Search live" state machine. The
8
+ * runtime loader and the live-search probe are both INJECTABLE, so stories/tests drive it with mocks
9
+ * and no network or db. Presentation is entirely the caller's concern.
10
+ */
11
+
12
+ import { matchPOISubject } from "@mailwoman/kind-classifier"
13
+ import { emitOverpassQL } from "@mailwoman/poi-taxonomy/overpass"
14
+ import type { OverpassIntentLike } from "@mailwoman/poi-taxonomy/overpass"
15
+ import { computeQueryShape } from "@mailwoman/query-shape"
16
+ import { useCallback, useEffect, useRef, useState } from "react"
17
+
18
+ import { useDebouncedValue } from "../common/useDebouncedValue.ts"
19
+ import { loadPOIRuntime } from "./runtime.ts"
20
+ import type { LiveSearchState, LoadPOIRuntime, POIExplorerResult, POILiveSearch, POIRuntime } from "./types.ts"
21
+
22
+ export interface UsePOISearchOptions {
23
+ /** The current query text (controlled by the caller). */
24
+ text: string
25
+ /** Runtime loader. @default loadPOIRuntime */
26
+ loadRuntime?: LoadPOIRuntime
27
+ /** Injected live-search probe. Absent ⇒ the live-results affordance is disabled. */
28
+ runLiveSearch?: POILiveSearch
29
+ /**
30
+ * Whether the injected probe can serve BRAND subjects (fetch by Wikidata QID). Default false: brand subjects show the
31
+ * intent + QID chip but no live-search affordance. The docs' httpvfs probe leaves this off — brand-wide row hydration
32
+ * is pathological over byte-range (measured) — so brand live search is a server-side-backend capability. Category
33
+ * live search is unaffected either way.
34
+ */
35
+ brandLiveSearch?: boolean
36
+ /** Debounce before (re)classifying. @default 250 */
37
+ debounceMs?: number
38
+ }
39
+
40
+ export interface UsePOISearch {
41
+ /** True once the taxonomy runtime has loaded. */
42
+ runtimeReady: boolean
43
+ /** The intent result for the current (debounced) query, or null for empty input. */
44
+ result: POIExplorerResult | null
45
+ /** State of the on-demand live poi.db search. */
46
+ liveSearch: LiveSearchState
47
+ /** Whether a live search can run right now (a probe is wired + there's a resolved subject with an anchor). */
48
+ canSearchLive: boolean
49
+ /** Kick off a live search for the current subject. No-op when {@link canSearchLive} is false. */
50
+ searchLive: () => Promise<void>
51
+ }
52
+
53
+ /** Compute the OverpassQL export for a matched subject, capturing any emitter error rather than throwing. */
54
+ function buildOverpass(
55
+ runtime: POIRuntime,
56
+ categoryID: string,
57
+ matchedPhrase: string,
58
+ remainder: string
59
+ ): { overpassQL?: string; overpassError?: string } {
60
+ const category = runtime.lookup.getPOICategory(categoryID)
61
+
62
+ if (!category) {
63
+ return {}
64
+ }
65
+
66
+ const intent: OverpassIntentLike = {
67
+ subject: { kind: "category", categoryID, matched: matchedPhrase },
68
+ ...(remainder ? { anchor: { text: remainder } } : {}),
69
+ }
70
+
71
+ try {
72
+ return { overpassQL: emitOverpassQL(intent, category.osmTag ? { osmTag: category.osmTag } : {}) }
73
+ } catch (err) {
74
+ return { overpassError: err instanceof Error ? err.message : String(err) }
75
+ }
76
+ }
77
+
78
+ export function usePOISearch({
79
+ text,
80
+ loadRuntime = loadPOIRuntime,
81
+ runLiveSearch,
82
+ brandLiveSearch = false,
83
+ debounceMs = 250,
84
+ }: UsePOISearchOptions): UsePOISearch {
85
+ const [runtime, setRuntime] = useState<POIRuntime | null>(null)
86
+ const [result, setResult] = useState<POIExplorerResult | null>(null)
87
+ const [liveSearch, setLiveSearch] = useState<LiveSearchState>({ status: "idle" })
88
+
89
+ const debouncedText = useDebouncedValue(text, debounceMs)
90
+
91
+ // Capture the loader in a ref so the load fires exactly ONCE on mount, regardless of whether the
92
+ // caller passes a fresh `loadRuntime` closure each render (an inline `async () => …` would otherwise
93
+ // retrigger the effect → reload → re-render loop). The runtime is a load-once resource.
94
+ const loadRuntimeRef = useRef(loadRuntime)
95
+ loadRuntimeRef.current = loadRuntime
96
+
97
+ useEffect(() => {
98
+ let cancelled = false
99
+
100
+ loadRuntimeRef.current().then((loaded) => {
101
+ if (!cancelled) {
102
+ setRuntime(loaded)
103
+ }
104
+ })
105
+
106
+ return () => {
107
+ cancelled = true
108
+ }
109
+ }, [])
110
+
111
+ // Classify the debounced query and derive the subject + OverpassQL (async, so it lives in an effect).
112
+ useEffect(() => {
113
+ if (!runtime) return
114
+
115
+ let cancelled = false
116
+ const trimmed = debouncedText.trim()
117
+
118
+ // A new query invalidates live results from the previous one.
119
+ setLiveSearch({ status: "idle" })
120
+
121
+ if (!trimmed) {
122
+ setResult(null)
123
+
124
+ return
125
+ }
126
+
127
+ const input = { raw: trimmed, normalized: trimmed }
128
+ const shape = computeQueryShape(trimmed)
129
+
130
+ runtime.classify(input, shape).then((kindResult) => {
131
+ if (cancelled) return
132
+
133
+ const matched = kindResult.kind === "poi_query" ? matchPOISubject(trimmed, undefined, runtime.lexicon) : null
134
+
135
+ if (!matched) {
136
+ setResult({ kindResult })
137
+
138
+ return
139
+ }
140
+
141
+ // Brand subject: the lexicon carries the brand's canonical name as `categoryID` + its Wikidata QID. No category
142
+ // record, no OverpassQL (brands are searched by QID against the layer's `brand_wikidata` index, not OSM tags).
143
+ if ((matched.match.kind ?? "category") === "brand") {
144
+ setResult({
145
+ kindResult,
146
+ subject: {
147
+ kind: "brand",
148
+ name: matched.match.categoryID,
149
+ ...(matched.match.wikidata ? { wikidata: matched.match.wikidata } : {}),
150
+ matchedPhrase: matched.match.matchedPhrase,
151
+ confidence: matched.match.confidence,
152
+ remainder: matched.remainder,
153
+ },
154
+ })
155
+
156
+ return
157
+ }
158
+
159
+ const category = runtime.lookup.getPOICategory(matched.match.categoryID)
160
+
161
+ if (!category) {
162
+ setResult({ kindResult })
163
+
164
+ return
165
+ }
166
+
167
+ setResult({
168
+ kindResult,
169
+ subject: {
170
+ kind: "category",
171
+ category,
172
+ matchedPhrase: matched.match.matchedPhrase,
173
+ confidence: matched.match.confidence,
174
+ remainder: matched.remainder,
175
+ buildLocal: runtime.lookup.requiresBuildLocalLayer(category),
176
+ },
177
+ ...buildOverpass(runtime, matched.match.categoryID, matched.match.matchedPhrase, matched.remainder),
178
+ })
179
+ })
180
+
181
+ return () => {
182
+ cancelled = true
183
+ }
184
+ }, [debouncedText, runtime])
185
+
186
+ const subject = result?.subject
187
+ // A subject is live-searchable when a probe is wired, it has an anchor, and: a CATEGORY that isn't build-local, or a
188
+ // BRAND with a QID AND a brand-capable probe (`brandLiveSearch`). Brands without a QID / without a brand probe show
189
+ // the intent + QID chip but no live affordance.
190
+ const subjectLiveCapable =
191
+ subject !== undefined &&
192
+ (subject.kind === "brand" ? brandLiveSearch && subject.wikidata !== undefined : !subject.buildLocal)
193
+ const canSearchLive = Boolean(
194
+ runLiveSearch && runtime && subject && subjectLiveCapable && subject.remainder.trim().length > 0
195
+ )
196
+
197
+ const searchLive = useCallback(async () => {
198
+ if (!runLiveSearch || !runtime || !subject || !subject.remainder.trim()) return
199
+
200
+ if (subject.kind === "category" ? subject.buildLocal : !(brandLiveSearch && subject.wikidata)) return
201
+
202
+ setLiveSearch({ status: "loading" })
203
+
204
+ try {
205
+ const outcome = await runLiveSearch(
206
+ subject.kind === "brand"
207
+ ? {
208
+ categoryID: subject.name,
209
+ overtureCategoryIDs: [],
210
+ anchor: subject.remainder,
211
+ brandWikidata: subject.wikidata,
212
+ }
213
+ : {
214
+ categoryID: subject.category.id,
215
+ // Fan the canonical seed id out over its Overture leaves — the same translation the Node reader uses.
216
+ overtureCategoryIDs: runtime.lookup.resolveOvertureCategories(subject.category.id),
217
+ anchor: subject.remainder,
218
+ }
219
+ )
220
+
221
+ if (outcome.status === "success") {
222
+ setLiveSearch({ status: "success", hits: outcome.hits, centerName: outcome.centerName })
223
+ } else if (outcome.status === "unplaced") {
224
+ setLiveSearch({ status: "error", message: `couldn't place "${outcome.anchor}"` })
225
+ } else {
226
+ setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
227
+ }
228
+ } catch {
229
+ setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
230
+ }
231
+ }, [runLiveSearch, runtime, subject, brandLiveSearch])
232
+
233
+ return { runtimeReady: runtime !== null, result, liveSearch, canSearchLive, searchLive }
234
+ }