@tunji_xy/ui 0.0.0-stage → 0.10.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.
@@ -0,0 +1,680 @@
1
+ <template>
2
+ <div
3
+ ref="root"
4
+ :class="cn('flex flex-col gap-1 text-foreground', attrs.class as string)"
5
+ @focusout="onFocusout"
6
+ >
7
+ <label
8
+ v-if="label"
9
+ :id="labelId"
10
+ class="text-sm font-semibold"
11
+ @click="triggerEl?.focus()"
12
+ >
13
+ {{ label }}
14
+ </label>
15
+
16
+ <button
17
+ :id="triggerId"
18
+ ref="trigger"
19
+ v-bind="triggerAttrs"
20
+ type="button"
21
+ role="combobox"
22
+ :class="triggerClasses"
23
+ :disabled="disabled"
24
+ aria-haspopup="listbox"
25
+ :aria-expanded="open"
26
+ :aria-controls="listId"
27
+ :aria-activedescendant="
28
+ open && activeIndex !== -1 ? optionId(activeIndex) : undefined
29
+ "
30
+ :aria-labelledby="label ? labelId : undefined"
31
+ :aria-required="required || undefined"
32
+ :aria-invalid="error ? true : undefined"
33
+ :aria-describedby="message ? messageId : undefined"
34
+ @click="toggle"
35
+ @keydown="onKeydown"
36
+ @keyup.space.prevent
37
+ >
38
+ <span
39
+ v-if="selected?.icon"
40
+ class="inline-flex shrink-0 text-muted-foreground [&>svg]:size-4"
41
+ >
42
+ <OptionIcon :icon="selected.icon" />
43
+ </span>
44
+ <span
45
+ :class="
46
+ cn('min-w-0 flex-1 truncate', !selected && 'text-muted-foreground/80')
47
+ "
48
+ >
49
+ {{ selected?.label ?? placeholder }}
50
+ </span>
51
+ <ChevronDownIcon
52
+ :class="
53
+ cn(
54
+ 'size-4 shrink-0 text-muted-foreground transition-transform',
55
+ open && 'rotate-180',
56
+ )
57
+ "
58
+ />
59
+ </button>
60
+
61
+ <input v-if="name" type="hidden" :name="name" :value="model ?? ''" />
62
+
63
+ <div
64
+ ref="popover"
65
+ popover="manual"
66
+ :class="popoverClasses"
67
+ :style="popoverStyle"
68
+ @mousedown="onPopoverMousedown"
69
+ >
70
+ <!-- Same height and padding as the trigger. -->
71
+ <div
72
+ v-if="searchable"
73
+ :class="
74
+ cn(
75
+ 'flex shrink-0 items-center gap-2 border-b border-border',
76
+ sizes[size].trigger,
77
+ )
78
+ "
79
+ >
80
+ <SearchIcon class="size-4 shrink-0 text-muted-foreground" />
81
+ <input
82
+ ref="searchInput"
83
+ type="text"
84
+ role="combobox"
85
+ class="h-full min-w-0 flex-1 bg-transparent p-0 text-inherit outline-none placeholder:text-muted-foreground/80"
86
+ :value="query"
87
+ :placeholder="searchPlaceholder"
88
+ :aria-label="searchPlaceholder"
89
+ aria-autocomplete="list"
90
+ :aria-expanded="open"
91
+ :aria-controls="listId"
92
+ :aria-activedescendant="
93
+ activeIndex !== -1 ? optionId(activeIndex) : undefined
94
+ "
95
+ autocomplete="off"
96
+ spellcheck="false"
97
+ @input="onSearchInput"
98
+ @keydown="onListKeydown"
99
+ />
100
+ <button
101
+ v-if="query"
102
+ type="button"
103
+ tabindex="-1"
104
+ class="inline-flex shrink-0 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
105
+ aria-label="Clear search"
106
+ @click="setQuery('')"
107
+ >
108
+ <XIcon class="size-4" />
109
+ </button>
110
+ </div>
111
+
112
+ <!-- tabindex -1: browsers make scrolling areas Tab stops, but focus belongs on the trigger or search. -->
113
+ <div
114
+ :id="listId"
115
+ ref="list"
116
+ role="listbox"
117
+ tabindex="-1"
118
+ :aria-labelledby="label ? labelId : undefined"
119
+ :class="
120
+ cn(
121
+ 'relative min-h-0 flex-1 overflow-y-auto overscroll-contain',
122
+ items.length > 0 && 'py-1',
123
+ )
124
+ "
125
+ >
126
+ <div
127
+ v-for="(section, s) in sections"
128
+ :key="section.name"
129
+ :role="section.name ? 'group' : undefined"
130
+ :aria-labelledby="section.name ? `${listId}-group-${s}` : undefined"
131
+ >
132
+ <div
133
+ v-if="section.name"
134
+ :id="`${listId}-group-${s}`"
135
+ role="presentation"
136
+ :class="
137
+ cn(
138
+ 'pt-3 pb-1 text-xs font-semibold tracking-wider text-muted-foreground uppercase',
139
+ sizes[size].group,
140
+ )
141
+ "
142
+ >
143
+ {{ section.name }}
144
+ </div>
145
+ <div
146
+ v-for="{ option, index } in section.options"
147
+ :id="optionId(index)"
148
+ :key="option.value"
149
+ role="option"
150
+ :aria-selected="option.value === model"
151
+ :aria-disabled="option.disabled || undefined"
152
+ :class="optionClasses(option, index)"
153
+ @mousemove="hover(index)"
154
+ @click="select(index)"
155
+ >
156
+ <span
157
+ v-if="option.icon"
158
+ class="inline-flex shrink-0 text-muted-foreground [&>svg]:size-4"
159
+ >
160
+ <OptionIcon :icon="option.icon" />
161
+ </span>
162
+ <span class="flex min-w-0 flex-1 flex-col">
163
+ <span
164
+ :class="
165
+ cn('truncate', option.value === model && 'font-semibold')
166
+ "
167
+ >
168
+ {{ option.label }}
169
+ </span>
170
+ <span
171
+ v-if="option.description"
172
+ class="text-xs text-muted-foreground"
173
+ >
174
+ {{ option.description }}
175
+ </span>
176
+ </span>
177
+ <CheckIcon
178
+ v-if="option.value === model"
179
+ class="size-4 shrink-0"
180
+ />
181
+ </div>
182
+ </div>
183
+ </div>
184
+
185
+ <p
186
+ v-if="!items.length"
187
+ class="px-3 py-6 text-center text-sm text-muted-foreground"
188
+ >
189
+ {{ query ? noResultsText : "No results." }}
190
+ </p>
191
+ </div>
192
+
193
+ <p
194
+ v-if="message"
195
+ :id="messageId"
196
+ :class="cn('text-sm', error ? 'text-danger' : 'text-muted-foreground')"
197
+ :role="error ? 'alert' : undefined"
198
+ >
199
+ {{ message }}
200
+ </p>
201
+ </div>
202
+ </template>
203
+
204
+ <script setup lang="ts">
205
+ import {
206
+ computed,
207
+ h,
208
+ onBeforeUnmount,
209
+ ref,
210
+ useAttrs,
211
+ useId,
212
+ useTemplateRef,
213
+ watch,
214
+ type Component,
215
+ type FunctionalComponent,
216
+ } from "vue";
217
+ import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "@lucide/vue";
218
+ import { cn } from "../lib/cn";
219
+
220
+ export interface SelectOption {
221
+ value: string | number;
222
+ label: string;
223
+ description?: string;
224
+ icon?: string | Component;
225
+ disabled?: boolean;
226
+ group?: string;
227
+ }
228
+
229
+ export interface SelectProps {
230
+ options: SelectOption[];
231
+ label?: string;
232
+ placeholder?: string;
233
+ hint?: string;
234
+ error?: string;
235
+ variant?: "outline" | "filled" | "ghost";
236
+ size?: "sm" | "md" | "lg";
237
+ radius?: "none" | "sm" | "md" | "lg" | "full";
238
+ disabled?: boolean;
239
+ required?: boolean;
240
+ id?: string;
241
+ name?: string;
242
+ searchable?: boolean;
243
+ searchPlaceholder?: string;
244
+ noResultsText?: string;
245
+ }
246
+
247
+ const props = withDefaults(defineProps<SelectProps>(), {
248
+ placeholder: "Select…",
249
+ variant: "outline",
250
+ size: "md",
251
+ searchPlaceholder: "Search…",
252
+ noResultsText: "No results.",
253
+ });
254
+
255
+ const model = defineModel<string | number | null>({ default: null });
256
+
257
+ // `class` styles the outer wrapper; every other attribute and listener
258
+ // (aria-label, @focus, data-*) goes to the trigger button.
259
+ defineOptions({ inheritAttrs: false });
260
+ const attrs = useAttrs();
261
+ const triggerAttrs = computed(() => {
262
+ const { class: _class, ...rest } = attrs;
263
+ return rest;
264
+ });
265
+
266
+ const autoId = useId();
267
+ const triggerId = computed(() => props.id ?? `ui-select-${autoId}`);
268
+ const labelId = computed(() => `${triggerId.value}-label`);
269
+ const listId = computed(() => `${triggerId.value}-listbox`);
270
+ const messageId = computed(() => `${triggerId.value}-message`);
271
+ const optionId = (index: number) => `${listId.value}-option-${index}`;
272
+ const message = computed(() => props.error || props.hint);
273
+
274
+ const query = ref("");
275
+
276
+ // With `searchable`, only the options whose label contains the search.
277
+ const visibleOptions = computed(() => {
278
+ const term = query.value.trim().toLowerCase();
279
+ if (!term) return props.options;
280
+ return props.options.filter((option) =>
281
+ option.label.toLowerCase().includes(term),
282
+ );
283
+ });
284
+
285
+ // Options grouped by `group`, in order of first appearance. `index` is the
286
+ // option's position in the list as shown, which keyboard navigation follows.
287
+ const sections = computed(() => {
288
+ const groups = new Map<string, SelectOption[]>();
289
+ for (const option of visibleOptions.value) {
290
+ const name = option.group ?? "";
291
+ groups.set(name, [...(groups.get(name) ?? []), option]);
292
+ }
293
+ let index = 0;
294
+ return [...groups].map(([name, options]) => ({
295
+ name,
296
+ options: options.map((option) => ({ option, index: index++ })),
297
+ }));
298
+ });
299
+ const items = computed(() =>
300
+ sections.value.flatMap((section) => section.options.map((o) => o.option)),
301
+ );
302
+ const selected = computed(() =>
303
+ props.options.find((option) => option.value === model.value),
304
+ );
305
+
306
+ // Renders an option's icon, whether it's a string or a component.
307
+ const OptionIcon: FunctionalComponent<{ icon: string | Component }> = ({
308
+ icon,
309
+ }) => (typeof icon === "string" ? icon : h(icon));
310
+
311
+ const rootEl = useTemplateRef<HTMLDivElement>("root");
312
+ const triggerEl = useTemplateRef<HTMLButtonElement>("trigger");
313
+ const popoverEl = useTemplateRef<HTMLDivElement>("popover");
314
+ const listEl = useTemplateRef<HTMLDivElement>("list");
315
+ // Not "search": a template ref sharing a name with a variable (the typeahead
316
+ // buffer below) binds to that variable in production builds, leaving this null.
317
+ const searchEl = useTemplateRef<HTMLInputElement>("searchInput");
318
+ const open = ref(false);
319
+ const activeIndex = ref(-1);
320
+
321
+ // The nearest enabled option from `from`, stepping by `step`, or -1.
322
+ function findEnabled(from: number, step: 1 | -1) {
323
+ for (let i = from; i >= 0 && i < items.value.length; i += step) {
324
+ if (!items.value[i]?.disabled) return i;
325
+ }
326
+ return -1;
327
+ }
328
+
329
+ const firstEnabled = () => findEnabled(0, 1);
330
+ const lastEnabled = () => findEnabled(items.value.length - 1, -1);
331
+
332
+ function moveBy(delta: number) {
333
+ const target = Math.min(
334
+ Math.max(activeIndex.value + delta, 0),
335
+ items.value.length - 1,
336
+ );
337
+ const step = delta > 0 ? 1 : -1;
338
+ const next = findEnabled(target, step);
339
+ activeIndex.value = next !== -1 ? next : findEnabled(target, -step as -1);
340
+ }
341
+
342
+ function show(focus: "selected" | "first" | "last" = "selected") {
343
+ if (props.disabled || open.value) return;
344
+ query.value = "";
345
+ const selectedIndex = items.value.findIndex(
346
+ (option) => option.value === model.value,
347
+ );
348
+ activeIndex.value =
349
+ focus === "last"
350
+ ? lastEnabled()
351
+ : focus === "selected" && selectedIndex !== -1
352
+ ? selectedIndex
353
+ : firstEnabled();
354
+ open.value = true;
355
+ // Safari doesn't focus a button on click, and the keyboard and blur handling need it.
356
+ triggerEl.value?.focus();
357
+ }
358
+
359
+ function close(restoreFocus = true) {
360
+ if (!open.value) return;
361
+ open.value = false;
362
+ query.value = "";
363
+ // The search field is about to be hidden, so hand focus back to the trigger.
364
+ if (restoreFocus && popoverEl.value?.contains(document.activeElement)) {
365
+ triggerEl.value?.focus();
366
+ }
367
+ }
368
+
369
+ const toggle = () => (open.value ? close() : show());
370
+
371
+ function select(index: number) {
372
+ const option = items.value[index];
373
+ if (!option || option.disabled) return;
374
+ model.value = option.value;
375
+ close();
376
+ }
377
+
378
+ function pickActive() {
379
+ if (activeIndex.value === -1) close();
380
+ else select(activeIndex.value);
381
+ }
382
+
383
+ function hover(index: number) {
384
+ if (!items.value[index]?.disabled) activeIndex.value = index;
385
+ }
386
+
387
+ function setQuery(value: string) {
388
+ query.value = value;
389
+ activeIndex.value = firstEnabled();
390
+ if (listEl.value) listEl.value.scrollTop = 0;
391
+ }
392
+
393
+ const onSearchInput = (event: Event) =>
394
+ setQuery((event.target as HTMLInputElement).value);
395
+
396
+ // Close when focus leaves the component. Moving between the trigger and the
397
+ // search field (both inside it) keeps the list open.
398
+ function onFocusout(event: FocusEvent) {
399
+ if (!rootEl.value?.contains(event.relatedTarget as Node | null)) {
400
+ close(false);
401
+ }
402
+ }
403
+
404
+ // Clicking in the list keeps focus where it is: on the trigger, or in the search field.
405
+ const onPopoverMousedown = (event: MouseEvent) => {
406
+ if (event.target !== searchEl.value) event.preventDefault();
407
+ };
408
+
409
+ let search = "";
410
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
411
+
412
+ // Jumps to the next option starting with what's been typed. Pressing the
413
+ // same letter again cycles through the options starting with it.
414
+ function typeahead(char: string) {
415
+ clearTimeout(searchTimer);
416
+ searchTimer = setTimeout(() => (search = ""), 500);
417
+ search += char.toLowerCase();
418
+
419
+ const repeated = [...search].every((c) => c === search[0]);
420
+ const prefix = repeated ? search[0]! : search;
421
+ const start = Math.max(activeIndex.value + (repeated ? 1 : 0), 0);
422
+ const count = items.value.length;
423
+ for (let i = 0; i < count; i++) {
424
+ const index = (start + i) % count;
425
+ const option = items.value[index]!;
426
+ if (!option.disabled && option.label.toLowerCase().startsWith(prefix)) {
427
+ activeIndex.value = index;
428
+ return;
429
+ }
430
+ }
431
+ }
432
+
433
+ function onListKeydown(event: KeyboardEvent) {
434
+ switch (event.key) {
435
+ case "ArrowDown":
436
+ moveBy(1);
437
+ break;
438
+ case "ArrowUp":
439
+ if (event.altKey) pickActive();
440
+ else moveBy(-1);
441
+ break;
442
+ case "PageDown":
443
+ moveBy(10);
444
+ break;
445
+ case "PageUp":
446
+ moveBy(-10);
447
+ break;
448
+ case "Enter":
449
+ pickActive();
450
+ break;
451
+ case "Escape":
452
+ event.stopPropagation();
453
+ close();
454
+ break;
455
+ default:
456
+ return false;
457
+ }
458
+ event.preventDefault();
459
+ return true;
460
+ }
461
+
462
+ function onKeydown(event: KeyboardEvent) {
463
+ const { key } = event;
464
+ const printable =
465
+ key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
466
+
467
+ if (!open.value) {
468
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(key)) show();
469
+ else if (key === "Home") show("first");
470
+ else if (key === "End") show("last");
471
+ else if (printable) {
472
+ show();
473
+ // Start the search with the key that opened the list.
474
+ if (props.searchable) setQuery(key);
475
+ else typeahead(key);
476
+ } else return;
477
+ event.preventDefault();
478
+ return;
479
+ }
480
+
481
+ if (onListKeydown(event)) return;
482
+ switch (key) {
483
+ case "Home":
484
+ activeIndex.value = firstEnabled();
485
+ break;
486
+ case "End":
487
+ activeIndex.value = lastEnabled();
488
+ break;
489
+ case " ":
490
+ // A space mid-search is part of the search, e.g. "united s".
491
+ if (search) typeahead(key);
492
+ else pickActive();
493
+ break;
494
+ default:
495
+ if (!printable) return;
496
+ typeahead(key);
497
+ }
498
+ event.preventDefault();
499
+ }
500
+
501
+ const MAX_HEIGHT = 288;
502
+ const GAP = 6;
503
+ const popoverStyle = ref<Record<string, string>>({});
504
+
505
+ // The list is a popover in the browser's top layer, so it's never clipped by
506
+ // a scrolling parent or a <UiModal>. It sits under the trigger, or above it
507
+ // when there's more room there.
508
+ function position() {
509
+ const trigger = triggerEl.value;
510
+ const popover = popoverEl.value;
511
+ const list = listEl.value;
512
+ if (!trigger || !popover || !list) return;
513
+ const rect = trigger.getBoundingClientRect();
514
+ const viewport = document.documentElement.clientHeight;
515
+ const below = viewport - rect.bottom - GAP * 2;
516
+ const above = rect.top - GAP * 2;
517
+ // The height it would be with the whole list showing.
518
+ const height = Math.min(
519
+ popover.offsetHeight - list.clientHeight + list.scrollHeight,
520
+ MAX_HEIGHT,
521
+ );
522
+ const flip = height > below && above > below;
523
+ popoverStyle.value = {
524
+ left: `${rect.left}px`,
525
+ width: `${rect.width}px`,
526
+ maxHeight: `${Math.min(MAX_HEIGHT, flip ? above : below)}px`,
527
+ top: flip ? "auto" : `${rect.bottom + GAP}px`,
528
+ bottom: flip ? `${viewport - rect.top + GAP}px` : "auto",
529
+ };
530
+ }
531
+
532
+ const onScroll = (event: Event) => {
533
+ if (event.target !== listEl.value) position();
534
+ };
535
+
536
+ function stopTracking() {
537
+ window.removeEventListener("scroll", onScroll, true);
538
+ window.removeEventListener("resize", position);
539
+ }
540
+
541
+ // Keeps the active option (and its group heading) scrolled into view.
542
+ function scrollToActive() {
543
+ const list = listEl.value;
544
+ const option = document.getElementById(optionId(activeIndex.value));
545
+ if (!list || !option) return;
546
+ const heading = option.previousElementSibling as HTMLElement | null;
547
+ const top =
548
+ activeIndex.value === firstEnabled()
549
+ ? 0
550
+ : heading?.getAttribute("role") === "presentation"
551
+ ? heading.offsetTop
552
+ : option.offsetTop;
553
+ const bottom = option.offsetTop + option.offsetHeight;
554
+ if (top < list.scrollTop) list.scrollTop = top;
555
+ else if (bottom > list.scrollTop + list.clientHeight)
556
+ list.scrollTop = bottom - list.clientHeight;
557
+ }
558
+
559
+ watch(
560
+ open,
561
+ (isOpen) => {
562
+ const popover = popoverEl.value;
563
+ if (!popover) return;
564
+ if (isOpen) {
565
+ if (!popover.matches(":popover-open")) popover.showPopover();
566
+ position();
567
+ searchEl.value?.focus({ preventScroll: true });
568
+ scrollToActive();
569
+ window.addEventListener("scroll", onScroll, {
570
+ capture: true,
571
+ passive: true,
572
+ });
573
+ window.addEventListener("resize", position);
574
+ } else {
575
+ if (popover.matches(":popover-open")) popover.hidePopover();
576
+ stopTracking();
577
+ }
578
+ },
579
+ { flush: "post" },
580
+ );
581
+
582
+ watch(
583
+ activeIndex,
584
+ () => {
585
+ if (open.value) scrollToActive();
586
+ },
587
+ { flush: "post" },
588
+ );
589
+
590
+ onBeforeUnmount(() => {
591
+ stopTracking();
592
+ clearTimeout(searchTimer);
593
+ });
594
+
595
+ const variants = {
596
+ outline: "border border-border bg-surface focus:ring-3",
597
+ filled: "border border-transparent bg-foreground/5 focus:ring-3",
598
+ ghost: "rounded-none border-b border-border px-0 focus:shadow-[0_1px_0_0]",
599
+ } as const;
600
+
601
+ // The variants' focus ring, applied while the list is open.
602
+ const rings = {
603
+ outline: "ring-3",
604
+ filled: "ring-3",
605
+ ghost: "shadow-[0_1px_0_0]",
606
+ } as const;
607
+
608
+ const hovers = {
609
+ outline: "hover:border-border-strong",
610
+ filled: "hover:bg-foreground/10",
611
+ ghost: "hover:not-focus:border-border-strong",
612
+ } as const;
613
+
614
+ const sizes = {
615
+ sm: {
616
+ trigger: "h-8 px-2 text-sm",
617
+ option: "px-2 py-1.5 text-sm",
618
+ group: "px-2",
619
+ },
620
+ md: {
621
+ trigger: "h-10 px-3 text-base sm:text-sm",
622
+ option: "px-3 py-2 text-base sm:text-sm",
623
+ group: "px-3",
624
+ },
625
+ lg: {
626
+ trigger: "h-12 px-4 text-base",
627
+ option: "px-4 py-2.5 text-base",
628
+ group: "px-4",
629
+ },
630
+ } as const;
631
+
632
+ const radii = {
633
+ none: "rounded-none",
634
+ sm: "rounded-sm",
635
+ md: "rounded-md",
636
+ lg: "rounded-lg",
637
+ full: "rounded-full",
638
+ } as const;
639
+
640
+ const triggerClasses = computed(() =>
641
+ cn(
642
+ "flex w-full cursor-pointer items-center gap-2 rounded-control text-left outline-none transition-[background-color,border-color,box-shadow]",
643
+ sizes[props.size].trigger,
644
+ props.radius && radii[props.radius],
645
+ variants[props.variant],
646
+ props.error
647
+ ? "border-danger focus:ring-danger/30 focus:shadow-danger"
648
+ : "focus:border-primary focus:ring-primary/35 focus:shadow-primary",
649
+ open.value && rings[props.variant],
650
+ open.value &&
651
+ (props.error
652
+ ? "ring-danger/30 shadow-danger"
653
+ : "border-primary ring-primary/35 shadow-primary"),
654
+ !props.error && !props.disabled && !open.value && hovers[props.variant],
655
+ props.disabled && "cursor-not-allowed opacity-60",
656
+ props.disabled && props.variant !== "ghost" && "bg-muted",
657
+ ),
658
+ );
659
+
660
+ const popoverClasses = computed(() =>
661
+ cn(
662
+ "inset-auto m-0 max-h-72 animate-fade-in flex-col overflow-hidden rounded-control border border-border bg-surface p-0 text-foreground shadow-lg outline-none open:flex dark:scheme-dark",
663
+ props.radius &&
664
+ (props.radius === "full" ? "rounded-lg" : radii[props.radius]),
665
+ ),
666
+ );
667
+
668
+ const optionClasses = (option: SelectOption, index: number) =>
669
+ cn(
670
+ "flex cursor-pointer items-center gap-2",
671
+ sizes[props.size].option,
672
+ index === activeIndex.value && "bg-muted",
673
+ option.disabled && "cursor-not-allowed opacity-50",
674
+ );
675
+
676
+ defineExpose({
677
+ focus: () => triggerEl.value?.focus(),
678
+ el: triggerEl,
679
+ });
680
+ </script>