|
1 // widget_dropdown.ysl2 |
|
2 |
|
3 widget_desc("DropDown") { |
|
4 |
|
5 longdesc |
|
6 || |
|
7 DropDown widget let user select an entry in a list of texts, given as |
|
8 arguments. Single variable path is index of selection. |
|
9 |
|
10 It needs "text" (svg:text), "box" (svg:rect), "button" (svg:*), |
|
11 and "highlight" (svg:rect) labeled elements. |
|
12 |
|
13 When user clicks on "button", "text" is duplicated to display enties in the |
|
14 limit of available space in page, and "box" is extended to contain all |
|
15 texts. "highlight" is moved over pre-selected entry. |
|
16 |
|
17 When only one argument is given, and argment contains "#langs" then list of |
|
18 texts is automatically set to the list of human-readable languages supported |
|
19 by this HMI. |
|
20 || |
|
21 |
|
22 shortdesc > Let user select text entry in a drop-down menu |
|
23 |
|
24 arg name="entries" count="many" accepts="string" > drop-down menu entries |
|
25 |
|
26 path name="selection" accepts="HMI_INT" > selection index |
|
27 } |
|
28 |
|
29 // TODO: support i18n of menu entries using svg:text elements with labels starting with "_" |
|
30 |
|
31 widget_class("DropDown") { |
|
32 || |
|
33 dispatch(value) { |
|
34 if(!this.opened) this.set_selection(value); |
|
35 } |
|
36 init() { |
|
37 this.button_elt.onclick = this.on_button_click.bind(this); |
|
38 // Save original size of rectangle |
|
39 this.box_bbox = this.box_elt.getBBox() |
|
40 this.highlight_bbox = this.highlight_elt.getBBox() |
|
41 this.highlight_elt.style.visibility = "hidden"; |
|
42 |
|
43 // Compute margins |
|
44 this.text_bbox = this.text_elt.getBBox(); |
|
45 let lmargin = this.text_bbox.x - this.box_bbox.x; |
|
46 let tmargin = this.text_bbox.y - this.box_bbox.y; |
|
47 this.margins = [lmargin, tmargin].map(x => Math.max(x,0)); |
|
48 |
|
49 // Index of first visible element in the menu, when opened |
|
50 this.menu_offset = 0; |
|
51 |
|
52 // How mutch to lift the menu vertically so that it does not cross bottom border |
|
53 this.lift = 0; |
|
54 |
|
55 // Event handlers cannot be object method ('this' is unknown) |
|
56 // as a workaround, handler given to addEventListener is bound in advance. |
|
57 this.bound_close_on_click_elsewhere = this.close_on_click_elsewhere.bind(this); |
|
58 this.bound_on_selection_click = this.on_selection_click.bind(this); |
|
59 this.bound_on_backward_click = this.on_backward_click.bind(this); |
|
60 this.bound_on_forward_click = this.on_forward_click.bind(this); |
|
61 this.opened = false; |
|
62 this.clickables = []; |
|
63 } |
|
64 on_button_click() { |
|
65 this.open(); |
|
66 } |
|
67 // Called when a menu entry is clicked |
|
68 on_selection_click(selection) { |
|
69 this.close(); |
|
70 this.apply_hmi_value(0, selection); |
|
71 } |
|
72 on_backward_click(){ |
|
73 this.scroll(false); |
|
74 } |
|
75 on_forward_click(){ |
|
76 this.scroll(true); |
|
77 } |
|
78 set_selection(value) { |
|
79 let display_str; |
|
80 if(value >= 0 && value < this.content.length){ |
|
81 // if valid selection resolve content |
|
82 display_str = this.content[value]; |
|
83 this.last_selection = value; |
|
84 } else { |
|
85 // otherwise show problem |
|
86 display_str = "?"+String(value)+"?"; |
|
87 } |
|
88 // It is assumed that first span always stays, |
|
89 // and contains selection when menu is closed |
|
90 this.text_elt.firstElementChild.textContent = display_str; |
|
91 } |
|
92 grow_text(up_to) { |
|
93 let count = 1; |
|
94 let txt = this.text_elt; |
|
95 let first = txt.firstElementChild; |
|
96 // Real world (pixels) boundaries of current page |
|
97 let bounds = svg_root.getBoundingClientRect(); |
|
98 this.lift = 0; |
|
99 while(count < up_to) { |
|
100 let next = first.cloneNode(); |
|
101 // relative line by line text flow instead of absolute y coordinate |
|
102 next.removeAttribute("y"); |
|
103 next.setAttribute("dy", "1.1em"); |
|
104 // default content to allow computing text element bbox |
|
105 next.textContent = "..."; |
|
106 // append new span to text element |
|
107 txt.appendChild(next); |
|
108 // now check if text extended by one row fits to page |
|
109 // FIXME : exclude margins to be more accurate on box size |
|
110 let rect = txt.getBoundingClientRect(); |
|
111 if(rect.bottom > bounds.bottom){ |
|
112 // in case of overflow at the bottom, lift up one row |
|
113 let backup = first.getAttribute("dy"); |
|
114 // apply lift as a dy added too first span (y attrib stays) |
|
115 first.setAttribute("dy", "-"+String((this.lift+1)*1.1)+"em"); |
|
116 rect = txt.getBoundingClientRect(); |
|
117 if(rect.top > bounds.top){ |
|
118 this.lift += 1; |
|
119 } else { |
|
120 // if it goes over the top, then backtrack |
|
121 // restore dy attribute on first span |
|
122 if(backup) |
|
123 first.setAttribute("dy", backup); |
|
124 else |
|
125 first.removeAttribute("dy"); |
|
126 // remove unwanted child |
|
127 txt.removeChild(next); |
|
128 return count; |
|
129 } |
|
130 } |
|
131 count++; |
|
132 } |
|
133 return count; |
|
134 } |
|
135 close_on_click_elsewhere(e) { |
|
136 // inhibit events not targetting spans (menu items) |
|
137 if([this.text_elt, this.element].indexOf(e.target.parentNode) == -1){ |
|
138 e.stopPropagation(); |
|
139 // close menu in case click is outside box |
|
140 if(e.target !== this.box_elt) |
|
141 this.close(); |
|
142 } |
|
143 } |
|
144 close(){ |
|
145 // Stop hogging all click events |
|
146 svg_root.removeEventListener("pointerdown", this.numb_event, true); |
|
147 svg_root.removeEventListener("pointerup", this.numb_event, true); |
|
148 svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true); |
|
149 // Restore position and sixe of widget elements |
|
150 this.reset_text(); |
|
151 this.reset_clickables(); |
|
152 this.reset_box(); |
|
153 this.reset_highlight(); |
|
154 // Put the button back in place |
|
155 this.element.appendChild(this.button_elt); |
|
156 // Mark as closed (to allow dispatch) |
|
157 this.opened = false; |
|
158 // Dispatch last cached value |
|
159 this.apply_cache(); |
|
160 } |
|
161 // Make item (text span) clickable by overlaying a rectangle on top of it |
|
162 make_clickable(span, func) { |
|
163 let txt = this.text_elt; |
|
164 let original_text_y = this.text_bbox.y; |
|
165 let highlight = this.highlight_elt; |
|
166 let original_h_y = this.highlight_bbox.y; |
|
167 let clickable = highlight.cloneNode(); |
|
168 let yoffset = span.getBBox().y - original_text_y; |
|
169 clickable.y.baseVal.value = original_h_y + yoffset; |
|
170 clickable.style.pointerEvents = "bounding-box"; |
|
171 //clickable.style.visibility = "hidden"; |
|
172 //clickable.onclick = () => alert("love JS"); |
|
173 clickable.onclick = func; |
|
174 this.element.appendChild(clickable); |
|
175 this.clickables.push(clickable) |
|
176 } |
|
177 reset_clickables() { |
|
178 while(this.clickables.length){ |
|
179 this.element.removeChild(this.clickables.pop()); |
|
180 } |
|
181 } |
|
182 // Set text content when content is smaller than menu (no scrolling) |
|
183 set_complete_text(){ |
|
184 let spans = this.text_elt.children; |
|
185 let c = 0; |
|
186 for(let item of this.content){ |
|
187 let span=spans[c]; |
|
188 span.textContent = item; |
|
189 let sel = c; |
|
190 this.make_clickable(span, (evt) => this.bound_on_selection_click(sel)); |
|
191 c++; |
|
192 } |
|
193 } |
|
194 // Move partial view : |
|
195 // false : upward, lower value |
|
196 // true : downward, higher value |
|
197 scroll(forward){ |
|
198 let contentlength = this.content.length; |
|
199 let spans = this.text_elt.children; |
|
200 let spanslength = spans.length; |
|
201 // reduce accounted menu size according to prsence of scroll buttons |
|
202 // since we scroll there is necessarly one button |
|
203 spanslength--; |
|
204 if(forward){ |
|
205 // reduce accounted menu size because of back button |
|
206 // in current view |
|
207 if(this.menu_offset > 0) spanslength--; |
|
208 this.menu_offset = Math.min( |
|
209 contentlength - spans.length + 1, |
|
210 this.menu_offset + spanslength); |
|
211 }else{ |
|
212 // reduce accounted menu size because of back button |
|
213 // in view once scrolled |
|
214 if(this.menu_offset - spanslength > 0) spanslength--; |
|
215 this.menu_offset = Math.max( |
|
216 0, |
|
217 this.menu_offset - spanslength); |
|
218 } |
|
219 if(this.menu_offset == 1) |
|
220 this.menu_offset = 0; |
|
221 |
|
222 this.reset_highlight(); |
|
223 |
|
224 this.reset_clickables(); |
|
225 this.set_partial_text(); |
|
226 |
|
227 this.highlight_selection(); |
|
228 } |
|
229 // Setup partial view text content |
|
230 // with jumps at first and last entry when appropriate |
|
231 set_partial_text(){ |
|
232 let spans = this.text_elt.children; |
|
233 let contentlength = this.content.length; |
|
234 let spanslength = spans.length; |
|
235 let i = this.menu_offset, c = 0; |
|
236 let m = this.box_bbox; |
|
237 while(c < spanslength){ |
|
238 let span=spans[c]; |
|
239 let onclickfunc; |
|
240 // backward jump only present if not exactly at start |
|
241 if(c == 0 && i != 0){ |
|
242 span.textContent = "▲"; |
|
243 onclickfunc = this.bound_on_backward_click; |
|
244 let o = span.getBBox(); |
|
245 span.setAttribute("dx", (m.width - o.width)/2); |
|
246 // presence of forward jump when not right at the end |
|
247 }else if(c == spanslength-1 && i < contentlength - 1){ |
|
248 span.textContent = "▼"; |
|
249 onclickfunc = this.bound_on_forward_click; |
|
250 let o = span.getBBox(); |
|
251 span.setAttribute("dx", (m.width - o.width)/2); |
|
252 // otherwise normal content |
|
253 }else{ |
|
254 span.textContent = this.content[i]; |
|
255 let sel = i; |
|
256 onclickfunc = (evt) => this.bound_on_selection_click(sel); |
|
257 span.removeAttribute("dx"); |
|
258 i++; |
|
259 } |
|
260 this.make_clickable(span, onclickfunc); |
|
261 c++; |
|
262 } |
|
263 } |
|
264 numb_event(e) { |
|
265 e.stopPropagation(); |
|
266 } |
|
267 open(){ |
|
268 let length = this.content.length; |
|
269 // systematically reset text, to strip eventual whitespace spans |
|
270 this.reset_text(); |
|
271 // grow as much as needed or possible |
|
272 let slots = this.grow_text(length); |
|
273 // Depending on final size |
|
274 if(slots == length) { |
|
275 // show all at once |
|
276 this.set_complete_text(); |
|
277 } else { |
|
278 // eventualy align menu to current selection, compensating for lift |
|
279 let offset = this.last_selection - this.lift; |
|
280 if(offset > 0) |
|
281 this.menu_offset = Math.min(offset + 1, length - slots + 1); |
|
282 else |
|
283 this.menu_offset = 0; |
|
284 // show surrounding values |
|
285 this.set_partial_text(); |
|
286 } |
|
287 // Now that text size is known, we can set the box around it |
|
288 this.adjust_box_to_text(); |
|
289 // Take button out until menu closed |
|
290 this.element.removeChild(this.button_elt); |
|
291 // Rise widget to top by moving it to last position among siblings |
|
292 this.element.parentNode.appendChild(this.element.parentNode.removeChild(this.element)); |
|
293 // disable interaction with background |
|
294 svg_root.addEventListener("pointerdown", this.numb_event, true); |
|
295 svg_root.addEventListener("pointerup", this.numb_event, true); |
|
296 svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true); |
|
297 this.highlight_selection(); |
|
298 |
|
299 // mark as open |
|
300 this.opened = true; |
|
301 } |
|
302 // Put text element in normalized state |
|
303 reset_text(){ |
|
304 let txt = this.text_elt; |
|
305 let first = txt.firstElementChild; |
|
306 // remove attribute eventually added to first text line while opening |
|
307 first.onclick = null; |
|
308 first.removeAttribute("dy"); |
|
309 first.removeAttribute("dx"); |
|
310 // keep only the first line of text |
|
311 for(let span of Array.from(txt.children).slice(1)){ |
|
312 txt.removeChild(span) |
|
313 } |
|
314 } |
|
315 // Put rectangle element in saved original state |
|
316 reset_box(){ |
|
317 let m = this.box_bbox; |
|
318 let b = this.box_elt; |
|
319 b.x.baseVal.value = m.x; |
|
320 b.y.baseVal.value = m.y; |
|
321 b.width.baseVal.value = m.width; |
|
322 b.height.baseVal.value = m.height; |
|
323 } |
|
324 highlight_selection(){ |
|
325 if(this.last_selection == undefined) return; |
|
326 let highlighted_row = this.last_selection - this.menu_offset; |
|
327 if(highlighted_row < 0) return; |
|
328 let spans = this.text_elt.children; |
|
329 let spanslength = spans.length; |
|
330 let contentlength = this.content.length; |
|
331 if(this.menu_offset != 0) { |
|
332 spanslength--; |
|
333 highlighted_row++; |
|
334 } |
|
335 if(this.menu_offset + spanslength < contentlength - 1) spanslength--; |
|
336 if(highlighted_row > spanslength) return; |
|
337 let original_text_y = this.text_bbox.y; |
|
338 let highlight = this.highlight_elt; |
|
339 let span = spans[highlighted_row]; |
|
340 let yoffset = span.getBBox().y - original_text_y; |
|
341 highlight.y.baseVal.value = this.highlight_bbox.y + yoffset; |
|
342 highlight.style.visibility = "visible"; |
|
343 } |
|
344 reset_highlight(){ |
|
345 let highlight = this.highlight_elt; |
|
346 highlight.y.baseVal.value = this.highlight_bbox.y; |
|
347 highlight.style.visibility = "hidden"; |
|
348 } |
|
349 // Use margin and text size to compute box size |
|
350 adjust_box_to_text(){ |
|
351 let [lmargin, tmargin] = this.margins; |
|
352 let m = this.text_elt.getBBox(); |
|
353 let b = this.box_elt; |
|
354 // b.x.baseVal.value = m.x - lmargin; |
|
355 b.y.baseVal.value = m.y - tmargin; |
|
356 // b.width.baseVal.value = 2 * lmargin + m.width; |
|
357 b.height.baseVal.value = 2 * tmargin + m.height; |
|
358 } |
|
359 || |
|
360 } |
|
361 |
|
362 widget_defs("DropDown") { |
|
363 labels("text box button highlight"); |
|
364 // It is assumed that list content conforms to Array interface. |
|
365 > content: |
|
366 choose{ |
|
367 // special case when used for language selection |
|
368 when "count(arg) = 1 and arg[1]/@value = '#langs'" { |
|
369 > langs |
|
370 } |
|
371 otherwise { |
|
372 > [\n |
|
373 foreach "arg" | "«@value»", |
|
374 > ] |
|
375 } |
|
376 } |
|
377 > ,\n |
|
378 } |