Skip to content

Commit 13726bc

Browse files
authored
Merge pull request #136 from merefield/quick_find_current_location
FEATURE: Quick find current location for User Selector!
2 parents 94a8a9c + b0e3b7c commit 13726bc

File tree

4 files changed

+481
-5
lines changed

4 files changed

+481
-5
lines changed
Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
// plugins/discourse-locations/assets/javascripts/discourse/components/geo-d-multi-select.gjs
2+
import Component from "@glimmer/component";
3+
import { tracked } from "@glimmer/tracking";
4+
import { fn } from "@ember/helper";
5+
import { on } from "@ember/modifier";
6+
import { action } from "@ember/object";
7+
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
8+
import { htmlSafe } from "@ember/template";
9+
import DButton from "discourse/components/d-button";
10+
import DropdownMenu from "discourse/components/dropdown-menu";
11+
import TextField from "discourse/components/text-field";
12+
import DMenu from "discourse/float-kit/components/d-menu";
13+
import concatClass from "discourse/helpers/concat-class";
14+
import icon from "discourse/helpers/d-icon";
15+
import element from "discourse/helpers/element";
16+
import discourseDebounce from "discourse/lib/debounce";
17+
import { INPUT_DELAY } from "discourse/lib/environment";
18+
import { makeArray } from "discourse/lib/helpers";
19+
import scrollIntoView from "discourse/modifiers/scroll-into-view";
20+
import { eq } from "discourse/truth-helpers";
21+
import { i18n } from "discourse-i18n";
22+
23+
class Skeleton extends Component {
24+
get width() {
25+
return htmlSafe(`width: ${Math.floor(Math.random() * 70) + 20}%`);
26+
}
27+
28+
<template>
29+
<div class="d-multi-select__skeleton">
30+
<div class="d-multi-select__skeleton-checkbox" />
31+
<div class="d-multi-select__skeleton-text" style={{this.width}} />
32+
</div>
33+
</template>
34+
}
35+
36+
export default class GeoDMultiSelect extends Component {
37+
@tracked searchTerm = "";
38+
@tracked preselectedItem = null;
39+
40+
// async state (plugin-safe)
41+
@tracked isPending = false;
42+
@tracked isResolved = false;
43+
@tracked isRejected = false;
44+
@tracked value = null;
45+
@tracked error = null;
46+
47+
// when true, the next resolved search will auto-pick first result
48+
@tracked autoPickNextResult = false;
49+
50+
compareKey = "id";
51+
_requestId = 0;
52+
53+
get hasSelection() {
54+
return (this.args.selection?.length ?? 0) > 0;
55+
}
56+
57+
get label() {
58+
return this.args.label ?? i18n("multi_select.label");
59+
}
60+
61+
get availableOptions() {
62+
if (!this.isResolved || !this.value) {
63+
return this.value;
64+
}
65+
66+
return this.value.filter(
67+
(item) =>
68+
!this.args.selection?.some((selected) => this.compare(item, selected))
69+
);
70+
}
71+
72+
#debouncedSearch() {
73+
discourseDebounce(
74+
this,
75+
this.#performSearch,
76+
this.args.loadFn,
77+
this.searchTerm,
78+
INPUT_DELAY
79+
);
80+
}
81+
82+
@action
83+
search(event) {
84+
// normal typing path: DO NOT auto-pick
85+
this.autoPickNextResult = false;
86+
87+
this.preselectedItem = null;
88+
this.searchTerm = event.target.value;
89+
this.#debouncedSearch();
90+
}
91+
92+
@action
93+
focus(input) {
94+
this.preselectedItem = null;
95+
input.focus({ preventScroll: true });
96+
}
97+
98+
@action
99+
setPreselected(item) {
100+
this.preselectedItem = item;
101+
}
102+
103+
@action
104+
handleKeydown(event) {
105+
if (!this.isResolved) {
106+
return;
107+
}
108+
109+
if (event.key === "Enter") {
110+
event.preventDefault();
111+
event.stopPropagation();
112+
113+
if (
114+
this.preselectedItem &&
115+
this.availableOptions?.some((item) =>
116+
this.compare(item, this.preselectedItem)
117+
)
118+
) {
119+
this.toggle(this.preselectedItem, event);
120+
}
121+
}
122+
123+
if (event.key === "ArrowDown") {
124+
event.preventDefault();
125+
if (!this.availableOptions?.length) {
126+
return;
127+
}
128+
129+
if (this.preselectedItem === null) {
130+
this.preselectedItem = this.availableOptions[0];
131+
} else {
132+
const currentIndex = this.availableOptions.findIndex((item) =>
133+
this.compare(item, this.preselectedItem)
134+
);
135+
if (currentIndex < this.availableOptions.length - 1) {
136+
this.preselectedItem = this.availableOptions[currentIndex + 1];
137+
}
138+
}
139+
}
140+
141+
if (event.key === "ArrowUp") {
142+
event.preventDefault();
143+
if (!this.availableOptions?.length) {
144+
return;
145+
}
146+
147+
if (this.preselectedItem === null) {
148+
this.preselectedItem = this.availableOptions[0];
149+
} else {
150+
const currentIndex = this.availableOptions.findIndex((item) =>
151+
this.compare(item, this.preselectedItem)
152+
);
153+
if (currentIndex > 0) {
154+
this.preselectedItem = this.availableOptions[currentIndex - 1];
155+
}
156+
}
157+
}
158+
}
159+
160+
@action
161+
remove(selectedItem, event) {
162+
event?.stopPropagation();
163+
this.preselectedItem = null;
164+
165+
this.args.onChange?.(
166+
this.args.selection?.filter((item) => !this.compare(item, selectedItem))
167+
);
168+
}
169+
170+
@action
171+
toggle(result, event) {
172+
event?.stopPropagation();
173+
174+
const currentSelection = makeArray(this.args.selection);
175+
if (currentSelection.some((item) => this.compare(item, result))) {
176+
return;
177+
}
178+
179+
this.preselectedItem = null;
180+
this.args.onChange?.(currentSelection.concat(result));
181+
}
182+
183+
@action
184+
compare(a, b) {
185+
if (this.args.compareFn) {
186+
return this.args.compareFn(a, b);
187+
} else {
188+
return a?.[this.compareKey] === b?.[this.compareKey];
189+
}
190+
}
191+
192+
getDisplayText(item) {
193+
return item?.name;
194+
}
195+
196+
// Choose the first "real" location result:
197+
// - skip provider/footer item (your code adds { provider: ... })
198+
// - skip anything falsy
199+
// - skip anything already selected (availableOptions already filters selection, but keep safe)
200+
#firstSelectableResult() {
201+
const opts = this.availableOptions || [];
202+
return opts.find((r) => r && !r.provider);
203+
}
204+
205+
#autoPickIfNeeded() {
206+
if (!this.autoPickNextResult) {
207+
return;
208+
}
209+
210+
// only auto-pick once
211+
this.autoPickNextResult = false;
212+
213+
const first = this.#firstSelectableResult();
214+
if (!first) {
215+
return;
216+
}
217+
218+
// mimic a click selection
219+
const currentSelection = makeArray(this.args.selection);
220+
this.args.onChange?.(currentSelection.concat(first));
221+
}
222+
223+
#performSearch(loadFn, term) {
224+
const requestId = ++this._requestId;
225+
226+
this.isPending = true;
227+
this.isResolved = false;
228+
this.isRejected = false;
229+
this.error = null;
230+
231+
return Promise.resolve(loadFn?.(term))
232+
.then((val) => {
233+
if (requestId !== this._requestId) {
234+
return;
235+
}
236+
237+
this.value = val;
238+
this.isResolved = true;
239+
240+
// if bullseye triggered the search, auto-select the first result
241+
this.#autoPickIfNeeded();
242+
})
243+
.catch((e) => {
244+
if (requestId !== this._requestId) {
245+
return;
246+
}
247+
this.error = e;
248+
this.isRejected = true;
249+
// no auto-pick on error
250+
this.autoPickNextResult = false;
251+
})
252+
.finally(() => {
253+
if (requestId !== this._requestId) {
254+
return;
255+
}
256+
this.isPending = false;
257+
});
258+
}
259+
260+
@action
261+
useCurrentLocation() {
262+
if (!navigator.geolocation) {
263+
return;
264+
}
265+
266+
// next search (coords) should auto-pick
267+
this.autoPickNextResult = true;
268+
269+
navigator.geolocation.getCurrentPosition(
270+
({ coords }) => {
271+
const term = `${coords.latitude}, ${coords.longitude}`;
272+
this.preselectedItem = null;
273+
this.searchTerm = term;
274+
275+
// run immediately (debounced is fine too, but immediate makes UX snappier)
276+
this.#performSearch(this.args.loadFn, term);
277+
},
278+
() => {
279+
this.autoPickNextResult = false;
280+
}
281+
);
282+
}
283+
284+
<template>
285+
<DMenu
286+
@identifier="d-multi-select"
287+
@triggerComponent={{element "div"}}
288+
@triggerClass={{concatClass (if this.hasSelection "--has-selection")}}
289+
@visibilityOptimizer={{@visibilityOptimizer}}
290+
@placement={{@placement}}
291+
@allowedPlacements={{@allowedPlacements}}
292+
@offset={{@offset}}
293+
@matchTriggerMinWidth={{@matchTriggerMinWidth}}
294+
@matchTriggerWidth={{@matchTriggerWidth}}
295+
...attributes
296+
>
297+
<:trigger>
298+
<div class="geo-d-multi-select-trigger">
299+
<div class="geo-d-multi-select-trigger__content">
300+
{{#if @selection}}
301+
<div class="d-multi-select-trigger__selection">
302+
{{#each @selection as |item|}}
303+
<button
304+
class="d-multi-select-trigger__selected-item"
305+
{{on "click" (fn this.remove item)}}
306+
title={{this.getDisplayText item}}
307+
>
308+
<span class="d-multi-select-trigger__selection-label">
309+
{{yield item to="selection"}}
310+
</span>
311+
{{icon
312+
"xmark"
313+
class="d-multi-select-trigger__remove-selection-icon"
314+
}}
315+
</button>
316+
{{/each}}
317+
</div>
318+
{{else}}
319+
<span class="d-multi-select-trigger__label">{{this.label}}</span>
320+
{{/if}}
321+
</div>
322+
323+
<div class="geo-d-multi-select-trigger__actions">
324+
<DButton
325+
@icon="bullseye"
326+
class="btn btn-default location-current-btn"
327+
@action={{this.useCurrentLocation}}
328+
/>
329+
<DButton
330+
@icon="angle-down"
331+
class="d-multi-select-trigger__expand-btn btn-transparent"
332+
@action={{@componentArgs.show}}
333+
/>
334+
</div>
335+
</div>
336+
</:trigger>
337+
338+
<:content>
339+
<DropdownMenu class="d-multi-select__content" as |menu|>
340+
<menu.item class="d-multi-select__search-container">
341+
{{icon "magnifying-glass"}}
342+
<TextField
343+
class="d-multi-select__search-input"
344+
autocomplete="off"
345+
@placeholder={{i18n "multi_select.search"}}
346+
@type="search"
347+
{{on "input" this.search}}
348+
{{on "keydown" this.handleKeydown}}
349+
{{didInsert this.focus}}
350+
@value={{readonly this.searchTerm}}
351+
/>
352+
</menu.item>
353+
354+
<menu.divider />
355+
356+
{{#if this.isPending}}
357+
<div class="d-multi-select__skeletons">
358+
<Skeleton />
359+
<Skeleton />
360+
<Skeleton />
361+
<Skeleton />
362+
<Skeleton />
363+
</div>
364+
{{else if this.isRejected}}
365+
<div class="d-multi-select__error">
366+
{{yield this.error to="error"}}
367+
</div>
368+
{{else if this.isResolved}}
369+
{{#if this.availableOptions.length}}
370+
<div class="d-multi-select__search-results">
371+
{{#each this.availableOptions as |result|}}
372+
<menu.item
373+
class={{concatClass
374+
"d-multi-select__result"
375+
(if (eq result this.preselectedItem) "--preselected" "")
376+
}}
377+
role="button"
378+
title={{this.getDisplayText result}}
379+
{{scrollIntoView (eq result this.preselectedItem)}}
380+
{{on "mouseenter" (fn this.setPreselected result)}}
381+
{{on "click" (fn this.toggle result)}}
382+
>
383+
<span class="d-multi-select__result-label">
384+
{{yield result to="result"}}
385+
</span>
386+
</menu.item>
387+
{{/each}}
388+
</div>
389+
{{else}}
390+
<div class="d-multi-select__search-no-results">
391+
{{i18n "multi_select.no_results"}}
392+
</div>
393+
{{/if}}
394+
{{/if}}
395+
</DropdownMenu>
396+
</:content>
397+
</DMenu>
398+
</template>
399+
}

0 commit comments

Comments
 (0)