@luigisbox/luma 0.7.0 → 0.8.0-beta.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.
- package/CHANGELOG.md +20 -0
- package/README.md +115 -4
- package/build/vite.mjs +28 -9
- package/docs/ACCESSIBILITY.md +56 -1
- package/package.json +1 -1
- package/src/core/a11y.ts +11 -4
- package/src/core/context.ts +2 -1
- package/src/core/features.ts +10 -1
- package/src/core/image-load.ts +21 -14
- package/src/core/integration.ts +3 -3
- package/src/core/own.ts +9 -0
- package/src/locales/cs.json +5 -1
- package/src/locales/de.json +5 -1
- package/src/locales/en.json +5 -1
- package/src/main.ts +87 -2
- package/src/types.ts +124 -0
- package/src/widgets/autocomplete/index.ts +64 -8
- package/src/widgets/autocomplete/style-autocomplete.css +31 -0
- package/src/widgets/autocomplete/template-autocomplete-assist.ts +16 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +2 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +2 -0
- package/src/widgets/convo/assist.ts +216 -0
- package/src/widgets/convo/icons.ts +8 -0
- package/src/widgets/convo/index.ts +713 -0
- package/src/widgets/convo/markdown.ts +76 -0
- package/src/widgets/convo/protocol.ts +154 -0
- package/src/widgets/convo/strings.ts +93 -0
- package/src/widgets/convo/style-convo.css +173 -0
- package/src/widgets/convo/template-convo-box.ts +125 -0
- package/src/widgets/convo/template-convo-preview.ts +37 -0
- package/src/widgets/convo/template-convo-result-default.ts +13 -0
- package/src/widgets/convo/trigger.ts +44 -0
- package/src/widgets/listing/element.ts +7 -6
- package/src/widgets/listing/instant.ts +14 -4
package/src/main.ts
CHANGED
|
@@ -25,15 +25,17 @@ import { plpAccepts, searchAccepts, searchReadQuery } from './core/routes';
|
|
|
25
25
|
import { clearEarlyRules, earlyHold, plpRule, searchRule, writeEarlyRules, type EarlyRoute } from './core/early-rules';
|
|
26
26
|
import { askChallenge, storedToken } from './api/token-key';
|
|
27
27
|
import { loadDictionary } from './core/locales';
|
|
28
|
-
import { ANALYTICS, AUTOCOMPLETE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_WHILE_TYPING, NAMED_SPECULATION, PLP, QUERY_URL, RECOMMENDER, SEARCH, SSR } from './core/features';
|
|
28
|
+
import { ANALYTICS, AUTOCOMPLETE, CONVO, CONVO_ASSIST, CONVO_TRIGGER, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_WHILE_TYPING, NAMED_SPECULATION, PLP, QUERY_URL, RECOMMENDER, SEARCH, SSR } from './core/features';
|
|
29
29
|
import { keepDl } from './core/kept';
|
|
30
|
+
import { created } from './core/own';
|
|
30
31
|
import './core/boot-state'; // the page's state at boot, for lbx-place's reconciler
|
|
31
32
|
import { onFlush, schedule } from './core/scheduler';
|
|
32
33
|
import { onUrlChange, startSensing } from './core/sense';
|
|
33
34
|
import { layered, setCss, whenApplied } from './core/styles';
|
|
35
|
+
import { session } from './core/storage';
|
|
34
36
|
import type { ListingRoute } from './core/listings';
|
|
35
37
|
import type { Reco } from './core/recommenders';
|
|
36
|
-
import type { GlobalConfigType, LumaConfigType, Result, SearchConfigType } from './types';
|
|
38
|
+
import type { ConvoConfigType, GlobalConfigType, LumaConfigType, Result, SearchConfigType } from './types';
|
|
37
39
|
|
|
38
40
|
declare const __VERSION__: string;
|
|
39
41
|
// Replaced at build time; the dev server (the loader's development mode) does not replace it.
|
|
@@ -57,6 +59,8 @@ export interface WidgetContext<C = any> {
|
|
|
57
59
|
helpers: Record<string, unknown>;
|
|
58
60
|
/** The search page of a phrase where the site's server redirects its form there (`search.queryUrl`): the autocomplete's "show all" link. */
|
|
59
61
|
queryUrl?: SearchConfigType['queryUrl'];
|
|
62
|
+
/** The conversational search, where it asks about what is typed (`convo.assist`): the autocomplete's nudge. */
|
|
63
|
+
assist?: ConvoConfigType;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export const startIntegration = async (files: IntegrationFiles) => {
|
|
@@ -114,6 +118,7 @@ const modules = {
|
|
|
114
118
|
search: SEARCH ? () => import('./widgets/search') : null,
|
|
115
119
|
plp: PLP ? () => import('./widgets/plp') : null,
|
|
116
120
|
recommender: RECOMMENDER ? () => import('./widgets/recommender') : null,
|
|
121
|
+
convo: CONVO ? () => import('./widgets/convo') : null,
|
|
117
122
|
};
|
|
118
123
|
type Kind = keyof typeof modules;
|
|
119
124
|
|
|
@@ -469,6 +474,86 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
469
474
|
onFlush(() => recent() && check());
|
|
470
475
|
}
|
|
471
476
|
|
|
477
|
+
// --- the conversational search (widgets/convo, lbx-convo): a click on an
|
|
478
|
+
// element with data-lbx-convo (the site's markup, or a template's) opens
|
|
479
|
+
// it, its value the first question; a pointer over one, or its focus,
|
|
480
|
+
// fetches the chunk ahead. Where the site's assistant was found off this
|
|
481
|
+
// session (widgets/convo ON), its triggers are hidden. `trigger`: a button
|
|
482
|
+
// of ours in the site's search field (`anchor`), opening it with the
|
|
483
|
+
// phrase typed; kept there on every reconcile (the host may re-render it).
|
|
484
|
+
const cv = config.convo;
|
|
485
|
+
if (CONVO && cv && !cv.disabled) {
|
|
486
|
+
if (session.get('lbx:convo:on') === false) setCss('convo:off', '[data-lbx-convo]{display:none!important}');
|
|
487
|
+
const hostConvo = hostCss('convo');
|
|
488
|
+
if (hostConvo) setCss('host-convo', hostConvo);
|
|
489
|
+
// Luma's sparkle in the site's search field: built and placed by lbx-place (widgets/convo/trigger.ts,
|
|
490
|
+
// the reconciler's chunk, loaded after boot); only where convo/_config.ts names `trigger`.
|
|
491
|
+
const tr = cv.trigger;
|
|
492
|
+
if (CONVO_TRIGGER && tr?.anchor)
|
|
493
|
+
import('./widgets/convo/trigger')
|
|
494
|
+
.then((m) => m.place(tr, g))
|
|
495
|
+
.catch((e) => reportError('convo trigger', e));
|
|
496
|
+
const loadCv = () => load('convo', 'convo', ['_global', 'convo'], cv, ['convo']);
|
|
497
|
+
// The assistant's answer to a search page's query (convo.assist): /assist (lbx-assist)
|
|
498
|
+
// asked as the page is the search route's, with its listing, for a phrase of 3+ words
|
|
499
|
+
// (the autocomplete's rule); the panel's chunk and a box above the results only where it
|
|
500
|
+
// answers, on that URL only (a late answer to an earlier query is not shown). Asked for a
|
|
501
|
+
// search the shopper made here: reached from the site's own pages, or a new query in place.
|
|
502
|
+
// A page reached from elsewhere (a crafted link's phrase) shows only an answer this tab
|
|
503
|
+
// already has: a phrase is never asked in the shopper's name. Asked again on a page
|
|
504
|
+
// restored from the back/forward cache (an answer streaming was cut as it was left).
|
|
505
|
+
if (CONVO_ASSIST && cv.assist && search) {
|
|
506
|
+
const readQ = searchReadQuery(search);
|
|
507
|
+
// A referrer is serialised with a path: the site's own pages, not a look-alike host or another port.
|
|
508
|
+
const here = document.referrer.startsWith(`${location.origin}/`);
|
|
509
|
+
const phrase = (u: URL) => readQ(u)?.trim().replace(/\s+/g, ' ').toLowerCase() ?? '';
|
|
510
|
+
// The phrase a page reached from elsewhere came with: never the shopper's, whatever later
|
|
511
|
+
// writes the URL (a filter, a sort, the host's own replaceState, Back to it).
|
|
512
|
+
const landed = here ? '' : phrase(new URL(location.href));
|
|
513
|
+
const ask = (u: URL) => {
|
|
514
|
+
if (session.get('lbx:convo:lean') === false || !routes.some((r) => r.kind === 'search' && r.accepts(u))) return;
|
|
515
|
+
const q = readQ(u)?.trim().replace(/\s+/g, ' ');
|
|
516
|
+
if (q && q.split(' ').length >= 3)
|
|
517
|
+
import('./widgets/convo/assist')
|
|
518
|
+
// Held as it comes (the box's): the autocomplete sharing it never cuts it, even before the panel's chunk is here.
|
|
519
|
+
.then((m) => m.ask(g, cv, q, !!landed && phrase(u) === landed).then((a) => (a && m.hold(a), a)))
|
|
520
|
+
.then((a) => a && run('convo', loadCv(), ({ mod, ctx }) => mod.preview(ctx, a, u.href)))
|
|
521
|
+
.catch((e) => reportError('convo assist', e));
|
|
522
|
+
};
|
|
523
|
+
ask(new URL(location.href));
|
|
524
|
+
onUrlChange(ask);
|
|
525
|
+
addEventListener('pageshow', (e) => e.persisted && ask(new URL(location.href)));
|
|
526
|
+
}
|
|
527
|
+
// composedPath: a trigger in a shadow root (a template of ours, the site's web component).
|
|
528
|
+
const triggerOf = (e: Event) => e.composedPath().find((n): n is Element => n instanceof Element && n.hasAttribute('data-lbx-convo'));
|
|
529
|
+
const ahead = (e: Event) => {
|
|
530
|
+
if (!triggerOf(e)) return;
|
|
531
|
+
for (const t of ['pointerover', 'focusin']) removeEventListener(t, ahead, true);
|
|
532
|
+
loadCv().catch(() => {});
|
|
533
|
+
};
|
|
534
|
+
for (const t of ['pointerover', 'focusin']) addEventListener(t, ahead, true);
|
|
535
|
+
addEventListener(
|
|
536
|
+
'click',
|
|
537
|
+
(e) => {
|
|
538
|
+
const t = triggerOf(e);
|
|
539
|
+
if (!t) return;
|
|
540
|
+
e.preventDefault(); // a link or a form's button as the trigger: the dialog instead
|
|
541
|
+
// Our sparkle (by identity): the phrase typed in the site's search input
|
|
542
|
+
// (`inputSelector`, a text or search field; nothing else of their forms).
|
|
543
|
+
const i = CONVO_TRIGGER && created.has(t) && g.inputSelector ? (t.closest('form') ?? document).querySelector(g.inputSelector) : null;
|
|
544
|
+
const typed = i instanceof HTMLInputElement && /^(text|search)$/.test(i.type) ? i.value : null;
|
|
545
|
+
// Its chunk failed to load: the site's link or submit button does what it would have
|
|
546
|
+
// (a same-window web page only: a target or download link is left, as is a click elsewhere).
|
|
547
|
+
const fallback = () => {
|
|
548
|
+
if (t instanceof HTMLAnchorElement) return void (/^https?:$/.test(t.protocol) && (!t.target || t.target === '_self') && !t.hasAttribute('download') && location.assign(t.href));
|
|
549
|
+
if (t instanceof HTMLButtonElement && t.type === 'submit' && t.form) t.form.requestSubmit(t);
|
|
550
|
+
};
|
|
551
|
+
run('convo', loadCv(), ({ mod, ctx }) => mod.open(ctx, typed ?? t.getAttribute('data-lbx-convo') ?? '', t), fallback);
|
|
552
|
+
},
|
|
553
|
+
true,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
472
557
|
// --- analytics (src/analytics, lbx-analytics): where the integration has an
|
|
473
558
|
// _analytics.ts (nothing is sent that it does not say), every page once it
|
|
474
559
|
// has loaded or with the first widget (its chains on our tiles listen then).
|
package/src/types.ts
CHANGED
|
@@ -596,8 +596,55 @@ export interface RecommenderConfigType extends AppConfig<RecommenderConfigType>
|
|
|
596
596
|
intent?: (target: Element) => string | null;
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
+
/**
|
|
600
|
+
* The conversational search (integration/convo/): a dialog where the shopper
|
|
601
|
+
* asks in their own words and an assistant answers with text and products.
|
|
602
|
+
* Opened by a click on any element with `data-lbx-convo` (its value, when
|
|
603
|
+
* not empty, is the first question). The conversation is Luigi's Box's
|
|
604
|
+
* conversation service (keyed by the site's tracker id); its products are
|
|
605
|
+
* looked up on the channel's collection surface, so tiles are the catalog's
|
|
606
|
+
* as every other widget's.
|
|
607
|
+
*/
|
|
608
|
+
export interface ConvoConfigType extends AppConfig<ConvoConfigType> {
|
|
609
|
+
/** The site's tracker id in Luigi's Box's conversation service (`1234-5678`). */
|
|
610
|
+
trackerId: string;
|
|
611
|
+
/** The conversation service's base URL (default `https://conversational-proxy.luigisbox.com/api/v2`). */
|
|
612
|
+
endpoint?: string;
|
|
613
|
+
/** Collection surface (lbs_collection_…) the conversation's products are looked up on. */
|
|
614
|
+
surface: string;
|
|
615
|
+
/** The catalog field holding the identity the conversation names a product by. */
|
|
616
|
+
idField: string;
|
|
617
|
+
/** Object type of the products (default: _global.itemType). */
|
|
618
|
+
type?: string;
|
|
619
|
+
/** return_fields the tiles need (keeps the response small). */
|
|
620
|
+
fields?: string[];
|
|
621
|
+
/** The language the assistant answers in (en, sk, cs, pl, de, hu); default _global.locale. */
|
|
622
|
+
locale?: string;
|
|
623
|
+
/** The catalog fields the assistant reads a price and availability from, when the service asks for them. */
|
|
624
|
+
priceField?: string;
|
|
625
|
+
availabilityField?: string;
|
|
626
|
+
/**
|
|
627
|
+
* A button of Luma's (a sparkle, `[data-lbx="convo-trigger"]`) placed
|
|
628
|
+
* at `anchor` (`position`, default `append`), typically in the site's
|
|
629
|
+
* search form: it opens the dialog with the phrase typed in the form's
|
|
630
|
+
* search input. `label` names it (default "Ask the shopping assistant").
|
|
631
|
+
* The folder's style-host.css places it in the field. Built only where
|
|
632
|
+
* this key is in convo/_config.ts (about 0.5 kB of every page).
|
|
633
|
+
*/
|
|
634
|
+
trigger?: { anchor: string; position?: Position; label?: string };
|
|
635
|
+
/**
|
|
636
|
+
* Ask the assistant about what the shopper types (lean trackers only):
|
|
637
|
+
* the autocomplete shows a row with its answer's first words when it has
|
|
638
|
+
* one (a settled phrase of 3+ words), the search page a box with the answer
|
|
639
|
+
* above the results; either continues in the panel. Built only where this
|
|
640
|
+
* key is in convo/_config.ts.
|
|
641
|
+
*/
|
|
642
|
+
assist?: boolean;
|
|
643
|
+
}
|
|
644
|
+
|
|
599
645
|
export interface LumaConfigType {
|
|
600
646
|
_global: GlobalConfigType;
|
|
647
|
+
convo?: ConvoConfigType;
|
|
601
648
|
autocomplete?: AutocompleteConfigType;
|
|
602
649
|
search?: SearchConfigType;
|
|
603
650
|
plp?: PlpConfigType;
|
|
@@ -789,6 +836,14 @@ export interface SearchResultScope extends SearchScope {
|
|
|
789
836
|
|
|
790
837
|
export interface AutocompleteScope extends Helpers {
|
|
791
838
|
query: string;
|
|
839
|
+
/**
|
|
840
|
+
* The assistant's answer to what is typed (`convo.assist`: a phrase of 3+
|
|
841
|
+
* words), as its first words stream in; null without one. Render it as a
|
|
842
|
+
* button with `data-lbx-convo=${assist.query}` and `data-lbx-assist`: a
|
|
843
|
+
* click opens the panel with the answer (Tab reaches it before "Show all
|
|
844
|
+
* products").
|
|
845
|
+
*/
|
|
846
|
+
assist: { query: string; text: string; done: boolean } | null;
|
|
792
847
|
/** One group per configured type that has results, in config order; `kind` is the type. */
|
|
793
848
|
groups: { kind: string; label: string; rows: { r: Result; i: number }[] }[];
|
|
794
849
|
/** Rows of one type ([] when none). */
|
|
@@ -864,3 +919,72 @@ export interface RecommenderResultScope extends RecommenderScope {
|
|
|
864
919
|
r: Result;
|
|
865
920
|
i: number;
|
|
866
921
|
}
|
|
922
|
+
|
|
923
|
+
/** A control the assistant offers (a suggestion, a question's choice, a product's "tell me more"). */
|
|
924
|
+
export interface ConvoAction {
|
|
925
|
+
label: string;
|
|
926
|
+
action_ref?: string | null;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* A block of an answer, as the conversation service sends it (rendered in
|
|
930
|
+
* its order): `text` (`text`, Markdown: render it with `md`), `suggestions` (`actions`), `question` (`text`, `choices`,
|
|
931
|
+
* `text_input`, `question_ref`), `product_grid` and `product_preview`
|
|
932
|
+
* (`products`: read them with `products(block)`), `comparison` (`products`,
|
|
933
|
+
* `rows` of `label` and `values`), `notice` and `support` (`title`, `text`,
|
|
934
|
+
* `actions`), `provenance` (`items`). An unknown type renders nothing.
|
|
935
|
+
*/
|
|
936
|
+
export interface ConvoBlock {
|
|
937
|
+
block_id: string;
|
|
938
|
+
type: string;
|
|
939
|
+
heading?: string | null;
|
|
940
|
+
[field: string]: any;
|
|
941
|
+
}
|
|
942
|
+
export interface ConvoMessage {
|
|
943
|
+
role: 'user' | 'assistant';
|
|
944
|
+
/** The question ('' for an answer: its blocks hold its text, in its places). */
|
|
945
|
+
text: string;
|
|
946
|
+
blocks: ConvoBlock[];
|
|
947
|
+
status: 'streaming' | 'done' | 'failed' | 'ignored';
|
|
948
|
+
/** What went wrong (a key of the widget's translations: error, busy, gone, unavailable, limit, stopped). */
|
|
949
|
+
error?: string;
|
|
950
|
+
/** The failed turn can be sent again (`retry`). */
|
|
951
|
+
retry?: boolean;
|
|
952
|
+
}
|
|
953
|
+
export interface ConvoScope extends Helpers {
|
|
954
|
+
messages: ConvoMessage[];
|
|
955
|
+
/** A turn is on its way: Stop instead of Send. */
|
|
956
|
+
busy: boolean;
|
|
957
|
+
/** `unavailable`: the site's assistant is off; `ended`: start over to go on; `final`: it cannot go on. */
|
|
958
|
+
state: 'ready' | 'unavailable' | 'ended' | 'final';
|
|
959
|
+
/** What the assistant goes by (filters, preferences), each with a remove control. */
|
|
960
|
+
refinements: { state_item_id: string; label: string; remove?: { action_ref: string; accessible_label: string } | null }[];
|
|
961
|
+
send: (text: string) => void;
|
|
962
|
+
act: (a: ConvoAction) => void;
|
|
963
|
+
answer: (questionRef: string, text: string) => void;
|
|
964
|
+
retry: () => void;
|
|
965
|
+
stop: () => void;
|
|
966
|
+
startOver: () => void;
|
|
967
|
+
close: () => void;
|
|
968
|
+
/** The products of a block, as results, in its order (those the catalog has). */
|
|
969
|
+
products: (b: ConvoBlock) => Result[];
|
|
970
|
+
/** A product's own controls in a block ("Tell me more"); the same array every draw. */
|
|
971
|
+
actionsOf: (b: ConvoBlock, r: Result) => ConvoAction[];
|
|
972
|
+
/** The assistant's Markdown (paragraphs, lists, headings, emphasis, tables, same-site links) as markup. */
|
|
973
|
+
md: (text: string) => unknown;
|
|
974
|
+
}
|
|
975
|
+
/** The search page's box (`convo.assist`): the answer to its query, streaming in. */
|
|
976
|
+
export interface ConvoPreviewScope extends ConvoScope {
|
|
977
|
+
query: string;
|
|
978
|
+
/** The answer's blocks so far (text in its places). */
|
|
979
|
+
blocks: ConvoBlock[];
|
|
980
|
+
/** It has all come. */
|
|
981
|
+
done: boolean;
|
|
982
|
+
/** The panel, going on with this conversation; with a suggestion, that turn next; `from`: the control pressed (focus goes back to it as the panel closes). */
|
|
983
|
+
open: (then?: ConvoAction, from?: Element | null) => void;
|
|
984
|
+
}
|
|
985
|
+
export interface ConvoResultScope extends ConvoScope {
|
|
986
|
+
r: Result;
|
|
987
|
+
i: number;
|
|
988
|
+
/** The product's own controls ("Tell me more"). */
|
|
989
|
+
actions: ConvoAction[];
|
|
990
|
+
}
|
|
@@ -18,9 +18,9 @@ import { borrow, liveRegion, useContrast } from '../../core/a11y';
|
|
|
18
18
|
import { luminanceOf, readable } from '../../core/color';
|
|
19
19
|
import { defaultSubmitter, formDataOf } from '../../core/default-button';
|
|
20
20
|
import { onOwnBack } from '../../core/entries';
|
|
21
|
-
import { AC_HOOKS, ANALYTICS, LIGHT_FULL_SCREEN, QUERY_URL } from '../../core/features';
|
|
21
|
+
import { AC_HOOKS, ANALYTICS, CONVO_ASSIST, LIGHT_FULL_SCREEN, QUERY_URL } from '../../core/features';
|
|
22
22
|
import { mark, reportError } from '../../core/health';
|
|
23
|
-
import { modalOf } from '../../core/own';
|
|
23
|
+
import { created, modalOf } from '../../core/own';
|
|
24
24
|
import { byName, view } from '../../core/templates';
|
|
25
25
|
import { first, navigable, plain, safeUrl } from '../../core/result';
|
|
26
26
|
import { afterFrame } from '../../core/scheduler';
|
|
@@ -330,6 +330,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
330
330
|
if (host) return;
|
|
331
331
|
host = document.createElement('div');
|
|
332
332
|
host.setAttribute('data-lbx', 'ac-overlay');
|
|
333
|
+
created.add(host);
|
|
333
334
|
host.setAttribute('popover', 'manual');
|
|
334
335
|
host.id = (light && ac.hostId) || 'lbx-ac-overlay';
|
|
335
336
|
if (light) {
|
|
@@ -396,6 +397,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
396
397
|
return;
|
|
397
398
|
}
|
|
398
399
|
if (e.key !== 'Tab' && e.key !== 'Escape') return;
|
|
400
|
+
// The nudge and "Show all products": Tab and Shift+Tab between them.
|
|
401
|
+
if (CONVO_ASSIST && e.key === 'Tab') {
|
|
402
|
+
const at = e.composedPath()[0] as Element;
|
|
403
|
+
const sel = !e.shiftKey && at?.closest?.('[data-lbx-assist]') ? '[data-lbx-all]' : e.shiftKey && at?.closest?.('[data-lbx-all]') ? '[data-lbx-assist]' : '';
|
|
404
|
+
const to = sel ? box.querySelector<HTMLElement>(sel) : null;
|
|
405
|
+
if (to) return void (e.preventDefault(), to.focus());
|
|
406
|
+
}
|
|
399
407
|
if (e.key === 'Escape') {
|
|
400
408
|
e.preventDefault();
|
|
401
409
|
e.stopPropagation(); // ours, not a host modal's
|
|
@@ -894,7 +902,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
894
902
|
mirror.setSelectionRange(from, to);
|
|
895
903
|
} catch {}
|
|
896
904
|
}
|
|
897
|
-
query(mirror?.value ?? i.value);
|
|
905
|
+
query(mirror?.value ?? i.value, true);
|
|
898
906
|
rehearse(); // the first results' one-time costs, while the visitor starts typing
|
|
899
907
|
};
|
|
900
908
|
|
|
@@ -944,6 +952,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
944
952
|
// Answers still on their way fill the cache but do not reopen it: only the
|
|
945
953
|
// visitor does (typing, focus, a press into the input).
|
|
946
954
|
want = '';
|
|
955
|
+
if (CONVO_ASSIST) clearTimeout(assistTimer);
|
|
947
956
|
clearTimeout(timer); // a debounce still pending asks for nothing
|
|
948
957
|
timer = waiting = 0;
|
|
949
958
|
if (!open) return;
|
|
@@ -1057,6 +1066,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1057
1066
|
unhover,
|
|
1058
1067
|
heroVariant: ac.hero?.variant === 'spec' ? 'spec' : 'image',
|
|
1059
1068
|
productLayout: ac.productLayout === 'list' ? 'list' : 'grid',
|
|
1069
|
+
assist: CONVO_ASSIST ? (assisted && assisted.q === want && !assisted.a.failed ? { query: assisted.q, text: assisted.a.text(), done: assisted.a.done } : null) : null,
|
|
1060
1070
|
field: (r: Result, name: string) => {
|
|
1061
1071
|
const f = name === 'badge' ? ac.hero?.badgeField : (ac.productFields as Record<string, string | undefined> | undefined)?.[name];
|
|
1062
1072
|
if (!f) return '';
|
|
@@ -1131,8 +1141,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1131
1141
|
return '';
|
|
1132
1142
|
}
|
|
1133
1143
|
};
|
|
1134
|
-
/** "Show all products" (`data-lbx-all`), the dropdown's
|
|
1144
|
+
/** "Show all products" (`data-lbx-all`), the arrows' last stop and the dropdown's tab stop, while it is open. */
|
|
1135
1145
|
const allLink = () => (open ? (box.querySelector('[data-lbx-all]') as HTMLElement | null) : null);
|
|
1146
|
+
/** Where Tab from the input goes: the assistant's nudge (`data-lbx-assist`), else "Show all products". */
|
|
1147
|
+
const tabStop = () => (CONVO_ASSIST && open ? (box.querySelector('[data-lbx-assist]') as HTMLElement | null) : null) ?? allLink();
|
|
1136
1148
|
/** Inside our dropdown (its shadow root too). */
|
|
1137
1149
|
const inOverlay = (el: Element | null) => !!el && !!host && (host.contains(el) || !!host.shadowRoot?.contains(el));
|
|
1138
1150
|
/** Focus back on the input from our dropdown, without it counting as the visitor's focus. */
|
|
@@ -1301,10 +1313,54 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1301
1313
|
launch(want);
|
|
1302
1314
|
};
|
|
1303
1315
|
|
|
1304
|
-
|
|
1316
|
+
// The assistant (convo.assist): asked about a phrase of 3+ words once it has settled
|
|
1317
|
+
// (0.6 s: an answer costs the site a generated reply), its answer's first words a row
|
|
1318
|
+
// on top of the dropdown (the nudge). Its client is its own chunk (lbx-assist).
|
|
1319
|
+
let assistTimer = 0;
|
|
1320
|
+
let assisted: { q: string; a: import('../convo/assist').Answer; off: () => void } | null = null;
|
|
1321
|
+
const assistSoon = (q: string) => {
|
|
1322
|
+
clearTimeout(assistTimer);
|
|
1323
|
+
const cv = ctx.assist;
|
|
1324
|
+
if (!cv || q.split(' ').length < 3 || assisted?.q === q) return;
|
|
1325
|
+
// The chunk on its way while the phrase settles: the request goes as the pause ends, not a round trip later.
|
|
1326
|
+
const mod = import('../convo/assist');
|
|
1327
|
+
assistTimer = window.setTimeout(
|
|
1328
|
+
() =>
|
|
1329
|
+
void mod
|
|
1330
|
+
.then((m) =>
|
|
1331
|
+
// The phrase as found, not typed: only an answer this tab has (a crafted link's phrase is never asked).
|
|
1332
|
+
m.ask(ctx.global, cv, q, q === found).then((a) => {
|
|
1333
|
+
// Typed past it meanwhile: its stream is cut (not kept), unless it has come whole.
|
|
1334
|
+
if (!a || want !== q) return void (a && m.drop(q));
|
|
1335
|
+
if (assisted) m.drop(assisted.q);
|
|
1336
|
+
assisted?.off();
|
|
1337
|
+
// Drawn at most once a frame, and only when what the row shows changes (its first words, its end).
|
|
1338
|
+
let shown = '';
|
|
1339
|
+
let frame = 0;
|
|
1340
|
+
const off = a.listen(() => {
|
|
1341
|
+
const now = a.done ? `${a.failed}` : a.text();
|
|
1342
|
+
if (now === shown || frame) return;
|
|
1343
|
+
shown = now;
|
|
1344
|
+
frame = requestAnimationFrame(() => ((frame = 0), want === q && render()));
|
|
1345
|
+
});
|
|
1346
|
+
assisted = { q, a, off };
|
|
1347
|
+
render();
|
|
1348
|
+
live.say(t('assist.title'), 700, true);
|
|
1349
|
+
}),
|
|
1350
|
+
)
|
|
1351
|
+
.catch((e) => reportError('autocomplete assist', e)),
|
|
1352
|
+
600,
|
|
1353
|
+
);
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
/** The phrase the field held as the shopper came to it (a search page's query: maybe a link's from elsewhere), not typed. */
|
|
1357
|
+
let found = '';
|
|
1358
|
+
const query = (value: string, reopened = false) => {
|
|
1305
1359
|
const q = norm(value);
|
|
1306
1360
|
clearTimeout(timer);
|
|
1307
1361
|
timer = 0;
|
|
1362
|
+
if (reopened) found = q;
|
|
1363
|
+
if (CONVO_ASSIST) assistSoon(q);
|
|
1308
1364
|
if (q.length < minChars) {
|
|
1309
1365
|
waiting = 0;
|
|
1310
1366
|
queued = '';
|
|
@@ -1583,7 +1639,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1583
1639
|
watchRoots(i);
|
|
1584
1640
|
adopt(i);
|
|
1585
1641
|
prepare(i);
|
|
1586
|
-
if (input.value.trim().length >= minChars) query(input.value);
|
|
1642
|
+
if (input.value.trim().length >= minChars) query(input.value, true);
|
|
1587
1643
|
};
|
|
1588
1644
|
|
|
1589
1645
|
const onFocusIn = once<'focusin'>((_, target) => {
|
|
@@ -1676,7 +1732,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1676
1732
|
// "Show all products" is the dropdown's tab stop: Tab reaches it (it sits
|
|
1677
1733
|
// at the end of <body> or in the top layer, never next to the input in
|
|
1678
1734
|
// the page's own order). Alt+Tab too (Safari's Option+Tab).
|
|
1679
|
-
const all =
|
|
1735
|
+
const all = tabStop();
|
|
1680
1736
|
if (!all) return;
|
|
1681
1737
|
consume(e);
|
|
1682
1738
|
// The one thing lit: the options' selection goes (its focus ring shows where Tab went).
|
|
@@ -1702,7 +1758,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1702
1758
|
if (matches(target) && !open && target === focused()) {
|
|
1703
1759
|
if (phone()) return engage(target);
|
|
1704
1760
|
input = target;
|
|
1705
|
-
if (input.value.trim().length >= minChars) query(input.value);
|
|
1761
|
+
if (input.value.trim().length >= minChars) query(input.value, true);
|
|
1706
1762
|
return;
|
|
1707
1763
|
}
|
|
1708
1764
|
if (!open || target === input || (host && e.composedPath().includes(host))) return;
|
|
@@ -278,3 +278,34 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
278
278
|
}
|
|
279
279
|
.all, .pill, .try, .compact { border: 1px solid CanvasText; }
|
|
280
280
|
}
|
|
281
|
+
|
|
282
|
+
/* @lbx-part autocomplete-assist: the assistant's nudge (convo.assist): a row on top of the panel,
|
|
283
|
+
its answer's first words streaming in; a shimmer sweeps across it once as it arrives (not under reduced motion). */
|
|
284
|
+
.nudge { display: flex; align-items: center; gap: 12px; width: 100%; margin: 0 0 12px; padding: 10px 14px; border: 1px solid var(--c-tint, var(--lbx-tint)); border-radius: var(--lbx-radius-row); background: var(--lbx-tint); color: var(--c-fg); text-align: start; }
|
|
285
|
+
.nudge:hover, .nudge:focus-visible { border-color: var(--c-accent); }
|
|
286
|
+
.nudge-mark { flex: none; display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 50%; background: var(--c-fill); color: var(--c-on-fill); }
|
|
287
|
+
.nudge-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
288
|
+
.nudge-title { font-weight: 600; }
|
|
289
|
+
/* Its words in the text colour (the row's, inherited): on the accent's tint, the muted grey and the accent itself can fall under 4.5:1 (the accent is guarded on white only). */
|
|
290
|
+
.nudge-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
291
|
+
.nudge-go { flex: none; display: inline-flex; align-items: center; gap: 4px; font-weight: 600; }
|
|
292
|
+
.nudge-go svg { color: var(--c-accent); }
|
|
293
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
294
|
+
.nudge { background-image: linear-gradient(100deg, transparent 30%, rgb(255 255 255 / .7) 50%, transparent 70%); background-size: 250% 100%; background-repeat: no-repeat; animation: lbx-nudge 1.4s ease-out 1 both, lbx-nudge-in .25s ease-out; }
|
|
295
|
+
}
|
|
296
|
+
/* Until its first words come: three dots, moving (fading in turn under reduced motion): it is on its way, not stuck. */
|
|
297
|
+
.nudge-wait { display: inline-flex; gap: 4px; padding: 6px 0; vertical-align: middle; }
|
|
298
|
+
/* Borders, not a background: forced colours keep them, as the text colour. */
|
|
299
|
+
.nudge-wait i { width: 0; height: 0; border: 3px solid; border-radius: 50%; animation: lbx-nudge-fade 1.2s ease-in-out infinite; }
|
|
300
|
+
.nudge-wait i:nth-child(2) { animation-delay: .15s; }
|
|
301
|
+
.nudge-wait i:nth-child(3) { animation-delay: .3s; }
|
|
302
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
303
|
+
.nudge-wait i { animation-name: lbx-nudge-dot; }
|
|
304
|
+
}
|
|
305
|
+
@keyframes lbx-nudge-dot { 0%, 60%, 100% { transform: none; opacity: .4; } 30% { transform: translateY(-4px); opacity: 1; } }
|
|
306
|
+
@keyframes lbx-nudge-fade { 0%, 60%, 100% { opacity: .3; } 30% { opacity: 1; } }
|
|
307
|
+
@keyframes lbx-nudge { from { background-position: 120% 0; } to { background-position: -120% 0; } }
|
|
308
|
+
@keyframes lbx-nudge-in { from { opacity: 0; transform: translateY(-4px); } }
|
|
309
|
+
.sh-assist { padding: 8px 12px 0; }
|
|
310
|
+
.sh-assist .nudge { margin: 0; }
|
|
311
|
+
/* @lbx-part-end */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { html, nothing } from '../../lit';
|
|
2
|
+
import type { AutocompleteScope } from '../../types';
|
|
3
|
+
import { ARROW } from './template-autocomplete-box';
|
|
4
|
+
|
|
5
|
+
// The assistant's nudge (`convo.assist`): a row on top of the panel, its
|
|
6
|
+
// answer's first words streaming in (dots moving until the first ones come),
|
|
7
|
+
// a one-time shimmer; a click opens the
|
|
8
|
+
// panel with the answer. Built only where convo/_config.ts names `assist`.
|
|
9
|
+
export default (s: AutocompleteScope) =>
|
|
10
|
+
s.assist
|
|
11
|
+
? html`<button type="button" class="nudge" data-lbx-part="ac-assist" data-lbx-assist data-lbx-convo=${s.assist.query}>
|
|
12
|
+
<span class="nudge-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M10 3l1.6 4.4L16 9l-4.4 1.6L10 15l-1.6-4.4L4 9l4.4-1.6zM18 13l.9 2.1L21 16l-2.1.9L18 19l-.9-2.1L15 16l2.1-.9z"></path></svg></span>
|
|
13
|
+
<span class="nudge-body"><span class="nudge-title">${s.t('assist.title')}</span><span class="nudge-text">${s.assist.text || (s.assist.done ? nothing : html`<span class="nudge-wait" aria-hidden="true"><i></i><i></i><i></i></span>`)}</span></span>
|
|
14
|
+
<span class="nudge-go">${s.t('assist.continue')}${ARROW}</span>
|
|
15
|
+
</button>`
|
|
16
|
+
: nothing;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html, nothing, repeat } from '../../lit';
|
|
2
|
+
import { CONVO_ASSIST } from '../../core/features';
|
|
2
3
|
import type { AutocompleteScope } from '../../types';
|
|
3
4
|
|
|
4
5
|
// The house design's panel: an accent
|
|
@@ -37,6 +38,7 @@ export default (s: AutocompleteScope) => {
|
|
|
37
38
|
<div class=${cls} data-lbx-part="ac-panel">
|
|
38
39
|
<span class="caret" aria-hidden="true"></span>
|
|
39
40
|
<div class="panel">
|
|
41
|
+
${CONVO_ASSIST ? s.include('autocomplete-assist') : nothing}
|
|
40
42
|
${s.state !== 'results'
|
|
41
43
|
? s.include('autocomplete-empty')
|
|
42
44
|
: html`<div class="cols" role="listbox" id="lbx-ac-listbox" aria-label=${s.t('label')}>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html, nothing, repeat } from '../../lit';
|
|
2
|
+
import { CONVO_ASSIST } from '../../core/features';
|
|
2
3
|
import type { AutocompleteScope } from '../../types';
|
|
3
4
|
import { POWERED, group, showAll } from './template-autocomplete-box';
|
|
4
5
|
|
|
@@ -45,6 +46,7 @@ export default (s: AutocompleteScope) => {
|
|
|
45
46
|
? html`<button type="button" class="icon clear" aria-label=${s.t('clear')} @click=${() => s.clear()}><span class="x"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"></path></svg></span></button>`
|
|
46
47
|
: nothing}
|
|
47
48
|
</div>
|
|
49
|
+
${CONVO_ASSIST && s.assist ? html`<div class="sh-assist">${s.include('autocomplete-assist')}</div>` : nothing}
|
|
48
50
|
${s.state === 'empty'
|
|
49
51
|
? s.include('autocomplete-empty')
|
|
50
52
|
: results
|