@luigisbox/luma 0.2.0 → 0.3.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/README.md +8 -3
- package/docs/ACCESSIBILITY.md +8 -3
- package/package.json +1 -1
- package/src/core/entries.ts +10 -6
- package/src/core/stash.ts +24 -4
- package/src/types.ts +11 -2
- package/src/widgets/autocomplete/index.ts +186 -31
- package/src/widgets/autocomplete/style-autocomplete.css +11 -5
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
- package/src/widgets/listing/element.ts +209 -21
- package/src/widgets/listing/instant.ts +83 -23
- package/src/widgets/listing/stash.ts +31 -8
- package/src/widgets/listing/view.ts +12 -2
- package/src/widgets/search/range.ts +40 -18
- package/src/widgets/search/style-search.css +2 -2
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@ This package is what an integration is built with. Each integration lives in
|
|
|
10
10
|
its own repository and depends on `@luigisbox/luma`
|
|
11
11
|
([Integration repository](#integration-repository)).
|
|
12
12
|
|
|
13
|
-
> **Pre-release.** Until 1.0, a minor version (0.
|
|
13
|
+
> **Pre-release.** Until 1.0, a minor version (0.4.0) may change the
|
|
14
14
|
> integration API (config keys, template scope, the build); a patch version
|
|
15
|
-
> (0.
|
|
15
|
+
> (0.3.1) only fixes. Depend on `^0.3.0` to take fixes without breaking
|
|
16
16
|
> changes.
|
|
17
17
|
|
|
18
18
|
## Core principles
|
|
@@ -331,6 +331,11 @@ export default ({ r, i, img, price }: SearchResultScope) => html`
|
|
|
331
331
|
`_global.images` (srcset, sizes, the eager first row, placeholders).
|
|
332
332
|
- **Pagination** is "show more" only: templates get `results`, `more` and
|
|
333
333
|
`showMore`; there are no numbered pages.
|
|
334
|
+
- **No results under filters**: the scope still holds the selected groups,
|
|
335
|
+
their `chips` and `clear`. A template that leaves the facets out when
|
|
336
|
+
`total` is 0 must keep them (or at least `clear`) while `chips.length` is
|
|
337
|
+
not 0: otherwise, where no drawer or chips show elsewhere, Back is the
|
|
338
|
+
shopper's only way to undo the filter.
|
|
334
339
|
- **Translations** are JSON dictionaries per locale (`src/locales/<locale>.json`,
|
|
335
340
|
`{name}` placeholders, plural forms by `Intl.PluralRules` category); only the
|
|
336
341
|
site's locale is loaded. Override keys in `_translations.ts`, keyed by locale:
|
|
@@ -348,7 +353,7 @@ On by default unless noted; each falls back to what the page would do anyway.
|
|
|
348
353
|
|---|---|---|
|
|
349
354
|
| `search.inPlaceQuery` | a new query on the search page renders in place, without a page load | the host's page-view analytics or server-rendered parts of the search page must follow every query |
|
|
350
355
|
| `search.prefetchWhileTyping` | the search result is fetched once the typed phrase settles, so Enter shows it at once | |
|
|
351
|
-
| `restoreOnBack` | Back from a product restores the listing and the followed tile's position | |
|
|
356
|
+
| `restoreOnBack` | Back from a product restores the listing (every page shown) and the followed tile's position, as does the page loaded again within a minute of following a link (a reload, a link whose server redirects back to it); Luma turns the browser's own scroll restoration (`history.scrollRestoration`) off for the listing's history entry until it has placed the page, unless the site set it | |
|
|
352
357
|
| `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
|
|
353
358
|
| `contentVisibility` (opt-in) | tiles out of view skip style, layout and paint, each at its own measured size: a long list's re-render about halves | keep it off where a tile draws outside its box (a hover panel, a badge hanging out) or holds a `position: fixed` element |
|
|
354
359
|
| `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
|
package/docs/ACCESSIBILITY.md
CHANGED
|
@@ -106,8 +106,13 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
106
106
|
they are not results; the help box's phone link is a plain link. "Show all
|
|
107
107
|
products" is a real link (`href`: the
|
|
108
108
|
search page the site's form would submit to) and the open dropdown's one
|
|
109
|
-
tab stop: Tab from the input reaches it
|
|
110
|
-
|
|
109
|
+
tab stop: Tab from the input reaches it (the options' selection cleared:
|
|
110
|
+
one thing lit), Shift+Tab goes back, Escape closes and goes back, Tab moves
|
|
111
|
+
on to the page's next control after the input. It is also the arrow keys'
|
|
112
|
+
last stop after the options (Arrow Up from the input reaches it first),
|
|
113
|
+
lit as when focused and referenced as the active descendant (it is in the
|
|
114
|
+
combobox's popup; in our shadow root, said by the status region instead),
|
|
115
|
+
Enter its search; focus stays in the input.
|
|
111
116
|
- **Status messages**: "Suggestions: N" or "No results for “…”", in a polite
|
|
112
117
|
`role="status"` region inside the dropdown, once typing pauses (700 ms), and
|
|
113
118
|
only when it changes within an opening (not per keystroke).
|
|
@@ -123,7 +128,7 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
123
128
|
never a faint colour alone: an accent fill (pills), or the tint with an
|
|
124
129
|
accent ring (cards) or edge (rows). Tokens an integration sets
|
|
125
130
|
(`autocomplete.tokens`, its CSS) are its own to keep at 4.5:1 (`yarn luma a11y`).
|
|
126
|
-
- **The phone sheet** (house design, viewport up to 600 px): its own input is
|
|
131
|
+
- **The phone sheet** (house design, viewport up to 600 px, or a short touch screen: a phone in landscape): its own input is
|
|
127
132
|
the combobox (`aria-controls` the listboxes on screen,
|
|
128
133
|
`aria-activedescendant` the selected option: the same shadow root); the
|
|
129
134
|
site's input is left as it was. The back button is named "Close search",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luigisbox/luma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/core/entries.ts
CHANGED
|
@@ -16,10 +16,14 @@ export const isOwnEntry = (href: string) => own.has(canon(href));
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* A history entry of ours that only stands for something open (the
|
|
19
|
-
* autocomplete's phone sheet
|
|
20
|
-
* hears that popstate (the URL is the
|
|
21
|
-
* popstate was
|
|
19
|
+
* autocomplete's phone sheet, the listing's staged filter drawer): Back
|
|
20
|
+
* closes it, and no router of the host's hears that popstate (the URL is the
|
|
21
|
+
* same). Each handler returns true when the popstate was its entry's (set by
|
|
22
|
+
* the widget that pushed it). Returns the removal.
|
|
22
23
|
*/
|
|
23
|
-
|
|
24
|
-
export const onOwnBack = (f: () => boolean) =>
|
|
25
|
-
export const ownBack = () =>
|
|
24
|
+
const backs = new Set<() => boolean>();
|
|
25
|
+
export const onOwnBack = (f: () => boolean) => (backs.add(f), () => void backs.delete(f));
|
|
26
|
+
export const ownBack = () => {
|
|
27
|
+
for (const f of backs) if (f()) return true;
|
|
28
|
+
return false;
|
|
29
|
+
};
|
package/src/core/stash.ts
CHANGED
|
@@ -17,21 +17,41 @@ export const navigation = () => {
|
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A listing left for another page, loaded again within this long by a reload
|
|
22
|
+
* or a navigation from itself (a server round trip: a "compare" or "add to
|
|
23
|
+
* cart" link that redirects back to the page it came from): the listing as
|
|
24
|
+
* left, as for Back.
|
|
25
|
+
*/
|
|
26
|
+
export const ROUND_TRIP_MS = 60e3;
|
|
27
|
+
|
|
28
|
+
/** The page loaded from itself: its referrer is this page (a redirect back keeps the referrer of the page that started it). */
|
|
29
|
+
const fromItself = () => {
|
|
30
|
+
try {
|
|
31
|
+
return !!document.referrer && pageKey(document.referrer) === pageKey(location.href);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
20
37
|
/**
|
|
21
38
|
* Which load a stash kind is for: a typed query's page by a navigation (or a
|
|
22
39
|
* prerender of it, `prerender` from its start through its activation), a
|
|
23
|
-
* listing left for a product by back/forward
|
|
40
|
+
* listing left for a product by back/forward, or by a reload or a navigation
|
|
41
|
+
* from itself within ROUND_TRIP_MS of leaving (`at`: when it was written).
|
|
24
42
|
*/
|
|
25
|
-
export const stashFits = (kind: string) => {
|
|
43
|
+
export const stashFits = (kind: string, at = 0) => {
|
|
26
44
|
const t = navigation().type;
|
|
27
|
-
|
|
45
|
+
if (kind !== 'back') return t === 'navigate' || t === 'prerender';
|
|
46
|
+
if (t === 'back_forward') return true;
|
|
47
|
+
return Date.now() - at < ROUND_TRIP_MS && (t === 'reload' || (t === 'navigate' && fromItself()));
|
|
28
48
|
};
|
|
29
49
|
|
|
30
50
|
/** A stash this load will likely use (the listing checks it fully). Reads only the start of the entry. */
|
|
31
51
|
export const hasStash = (href: string) => {
|
|
32
52
|
try {
|
|
33
53
|
const head = sessionStorage.getItem(stashKey(href))?.slice(0, 60);
|
|
34
|
-
return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec');
|
|
54
|
+
return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec', Number(/"at":(\d+)/.exec(head)?.[1]) || 0);
|
|
35
55
|
} catch {
|
|
36
56
|
return false;
|
|
37
57
|
}
|
package/src/types.ts
CHANGED
|
@@ -245,8 +245,17 @@ export interface AutocompleteConfigType extends AppConfig<AutocompleteConfigType
|
|
|
245
245
|
* `'input'`: always the input. At least 360 px wide; on a phone (below
|
|
246
246
|
* 480 px) a field spanning most of the screen exactly, else the screen's
|
|
247
247
|
* width.
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
*
|
|
249
|
+
* An object sets the field and, for the house panel (`dom: 'shadow'`), the
|
|
250
|
+
* page's content column it stays within: `{ field?: 'input' | string;
|
|
251
|
+
* column?: 'viewport' | string }`. `column`: a selector (the field's
|
|
252
|
+
* nearest ancestor matching it, else the first element matching it), or
|
|
253
|
+
* `'viewport'` (the screen less 24 px each side). By default the panel
|
|
254
|
+
* stays within the field's outermost ancestor that is at least 1000 px
|
|
255
|
+
* wide and narrower than the screen (a centred page container), else the
|
|
256
|
+
* screen.
|
|
257
|
+
*/
|
|
258
|
+
alignTo?: 'input' | string | { field?: 'input' | string; column?: 'viewport' | string };
|
|
250
259
|
/** _init.ts hooks, for sites whose own code reacts to their dropdown opening, closing or a pick. */
|
|
251
260
|
onOpen?: () => void;
|
|
252
261
|
/** Why it closed: a pick, Enter / Escape in the input, focus leaving, a press outside, a route change, … */
|
|
@@ -47,10 +47,23 @@ const PANEL_MAX = 1200;
|
|
|
47
47
|
const PANEL_LIST = 560;
|
|
48
48
|
const PANEL_GUTTER = 24;
|
|
49
49
|
const PANEL_WIDE = 1000;
|
|
50
|
+
/** A content column inset at least this far from the screen's edges is the page's (its edges hold, inside the gutter too); nearer, it is the screen's width. */
|
|
51
|
+
const COLUMN_INSET = 8;
|
|
52
|
+
/** `active` on "Show all products" (`data-lbx-all`): the arrow keys' last stop in the dropdown, after the options. */
|
|
53
|
+
const ALL = -2;
|
|
54
|
+
/** The id an option, or "Show all products", is referenced by (aria-activedescendant). */
|
|
55
|
+
const idOf = (i: number) => (i === ALL ? 'lbx-ac-all' : `lbx-ac-${i}`);
|
|
50
56
|
/** Products beside the top product on the panel (2 x 3). */
|
|
51
57
|
const GRID_MAX = 6;
|
|
52
|
-
/**
|
|
53
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Phones: the house design is a full-screen sheet. A narrow viewport, or a
|
|
60
|
+
* touch screen (no hover, as the entry tells one) that is short: a phone in
|
|
61
|
+
* landscape (844 x 390) got the desktop panel, most of it under the
|
|
62
|
+
* on-screen keyboard. A tablet (768 px and taller) keeps the panel.
|
|
63
|
+
*/
|
|
64
|
+
export const SHEET_MEDIA = '(max-width: 600px), (hover: none) and (max-height: 500px)';
|
|
65
|
+
/** The sheet's visible height below which its top product is drawn smaller (the keyboard up): more of the list shows. */
|
|
66
|
+
const SHEET_SHORT = 480;
|
|
54
67
|
|
|
55
68
|
/** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
|
|
56
69
|
const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
|
|
@@ -129,14 +142,58 @@ export const dropdownBox = (r: { left: number; right: number; width: number }, v
|
|
|
129
142
|
* for a batch without products), centred under the field, kept 24 px from
|
|
130
143
|
* the screen's edges; its caret under the field's left edge + 30 px (inside
|
|
131
144
|
* the panel's corners). `wide`: the top product has its own column.
|
|
145
|
+
* `col`: the page's content column (`columnOf`): the panel also kept within
|
|
146
|
+
* its left and right edges (no wider than it), as the page's own content is;
|
|
147
|
+
* an edge of it inset from the screen's holds inside the gutter too (a
|
|
148
|
+
* column 20 px from the edges: the panel at 20, not 24), one at the screen's
|
|
149
|
+
* edge (less than COLUMN_INSET) leaves the gutter.
|
|
132
150
|
*/
|
|
133
|
-
export const panelBox = (r: { left: number; width: number }, vw: number, listOnly = false) => {
|
|
134
|
-
const
|
|
135
|
-
const
|
|
151
|
+
export const panelBox = (r: { left: number; width: number }, vw: number, listOnly = false, col?: { left: number; right: number } | null) => {
|
|
152
|
+
const lo = col && col.left >= COLUMN_INSET ? col.left : PANEL_GUTTER;
|
|
153
|
+
const hi = col && col.right <= vw - COLUMN_INSET ? col.right : vw - PANEL_GUTTER;
|
|
154
|
+
const width = Math.max(0, Math.min(listOnly ? PANEL_LIST : PANEL_MAX, hi - lo));
|
|
155
|
+
const left = Math.max(lo, Math.min(r.left + r.width / 2 - width / 2, hi - width));
|
|
136
156
|
const caret = Math.max(16, Math.min(r.left + 30 - left, width - 30));
|
|
137
157
|
return { left, width, caret, wide: width >= PANEL_WIDE };
|
|
138
158
|
};
|
|
139
159
|
|
|
160
|
+
/** `alignTo`'s parts: the field (a string: that alone) and the content column. */
|
|
161
|
+
const alignOf = (a: AutocompleteConfigType['alignTo']) => (typeof a === 'object' && a ? a : { field: a, column: undefined });
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The page's content column the house panel stays within (`alignTo.column`),
|
|
165
|
+
* read once per opening with the field:
|
|
166
|
+
* - `'viewport'`: none (the screen, less the gutter);
|
|
167
|
+
* - a selector: the field's nearest ancestor matching it, else the first
|
|
168
|
+
* element on the page matching it (a header and a content column side by
|
|
169
|
+
* side), else none;
|
|
170
|
+
* - by default, the outermost ancestor of the field that is inset from both
|
|
171
|
+
* of the screen's edges (COLUMN_INSET) and at least PANEL_WIDE wide (or the
|
|
172
|
+
* screen less the gutters, on a narrower one): a storefront's centred
|
|
173
|
+
* `max-width` container (the shop's logo and cart at its edges), also
|
|
174
|
+
* where it nearly fills a 1024 px screen. A narrower box is a part of the
|
|
175
|
+
* header, not the page's column.
|
|
176
|
+
*/
|
|
177
|
+
export const columnOf = (field: Element, column?: string): Element | null => {
|
|
178
|
+
if (column === 'viewport') return null;
|
|
179
|
+
if (column) {
|
|
180
|
+
try {
|
|
181
|
+
for (let el = up(field); el; el = up(el)) if (el.matches(column)) return el;
|
|
182
|
+
return document.querySelector(column);
|
|
183
|
+
} catch {
|
|
184
|
+
return null; // not a selector
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const vw = document.documentElement.clientWidth;
|
|
188
|
+
let found: Element | null = null;
|
|
189
|
+
const min = Math.min(PANEL_WIDE, vw - 2 * PANEL_GUTTER);
|
|
190
|
+
for (let el = up(field); el && el !== document.body && el !== document.documentElement; el = up(el)) {
|
|
191
|
+
const b = el.getBoundingClientRect();
|
|
192
|
+
if (b.width >= min && b.left >= COLUMN_INSET && b.right <= vw - COLUMN_INSET) found = el;
|
|
193
|
+
}
|
|
194
|
+
return found;
|
|
195
|
+
};
|
|
196
|
+
|
|
140
197
|
/** The house tokens a config may set (`autocomplete.tokens`), as their custom properties. */
|
|
141
198
|
const TOKEN_PROPS: Record<string, string> = {
|
|
142
199
|
font: 'font',
|
|
@@ -187,6 +244,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
187
244
|
/** The arrow keys' order over `items` (the house design's reading order: pills, lists, the top product, products). */
|
|
188
245
|
let order: number[] = [];
|
|
189
246
|
let lastQuery = '';
|
|
247
|
+
/** The arrow keys' selection: an option of `items`, -1 none (the input), ALL "Show all products". */
|
|
190
248
|
let active = -1;
|
|
191
249
|
let open = false;
|
|
192
250
|
let raf = 0;
|
|
@@ -195,6 +253,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
195
253
|
let quietFocus = false;
|
|
196
254
|
/** The phrase the dropdown is for (normalised input), '' while closed by the visitor or below minChars. */
|
|
197
255
|
let want = '';
|
|
256
|
+
/** Scrolled lists in the dropdown (the panel's columns, the sheet's list): back to their top for a new result set. */
|
|
257
|
+
const scrolled = new Set<Element>();
|
|
258
|
+
const toTop = () => {
|
|
259
|
+
for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
|
|
260
|
+
scrolled.clear();
|
|
261
|
+
};
|
|
198
262
|
/** Per type, what the dropdown shows. */
|
|
199
263
|
const shown = new Map<string, Result[]>();
|
|
200
264
|
/** The house panel: wide enough for the top product's column; a batch without products (the list alone, narrower). */
|
|
@@ -206,6 +270,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
206
270
|
let tab = '';
|
|
207
271
|
let entry = false;
|
|
208
272
|
let afterBack: (() => void) | null = null;
|
|
273
|
+
/** What the site's input held when the sheet opened: put back when the sheet is cancelled (its back button, Escape, Back). */
|
|
274
|
+
let before = '';
|
|
209
275
|
// Accessibility (docs/ACCESSIBILITY.md, "Autocomplete"): the host's input is
|
|
210
276
|
// the combobox. Its ARIA is borrowed while it has focus and put back when
|
|
211
277
|
// focus leaves it; the result count is announced politely, once typing pauses.
|
|
@@ -319,6 +385,20 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
319
385
|
backToInput();
|
|
320
386
|
}
|
|
321
387
|
});
|
|
388
|
+
// Which of its lists the shopper scrolled (read in their own scroll
|
|
389
|
+
// event, layout clean): a new result set starts at their top (toTop).
|
|
390
|
+
// Bug: a list scrolled down for "fiets" drew "fiets slot" scrolled down,
|
|
391
|
+
// the top product out of sight.
|
|
392
|
+
box.addEventListener(
|
|
393
|
+
'scroll',
|
|
394
|
+
(e) => {
|
|
395
|
+
const el = e.composedPath()[0];
|
|
396
|
+
if (!(el instanceof Element)) return;
|
|
397
|
+
if (el.scrollTop > 0) scrolled.add(el);
|
|
398
|
+
else scrolled.delete(el);
|
|
399
|
+
},
|
|
400
|
+
{ capture: true, passive: true },
|
|
401
|
+
);
|
|
322
402
|
// Focus leaving the dropdown for anywhere but the input: closed, as when
|
|
323
403
|
// it leaves the input. Not the sheet: it covers the page, and its input
|
|
324
404
|
// lets go whenever the shopper puts the keyboard away to read.
|
|
@@ -357,6 +437,14 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
357
437
|
|
|
358
438
|
/** What the dropdown lines up with for this opening (`alignTo`): the visible search field, else the input. */
|
|
359
439
|
let field: Element | null = null;
|
|
440
|
+
/** The page's content column the house panel stays within, for this opening (`alignTo.column`; none: the screen). */
|
|
441
|
+
let column: Element | null = null;
|
|
442
|
+
const align = alignOf(ac.alignTo);
|
|
443
|
+
/** The field and the content column, read once per opening (styles and boxes, before our writes). */
|
|
444
|
+
const lineUp = (i: HTMLInputElement) => {
|
|
445
|
+
field = fieldOf(i, align.field);
|
|
446
|
+
column = light ? null : columnOf(field, align.column);
|
|
447
|
+
};
|
|
360
448
|
|
|
361
449
|
/** Where the field is (null: hidden), read before anything of ours is written in the task. */
|
|
362
450
|
const measure = () => {
|
|
@@ -364,7 +452,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
364
452
|
// checkVisibility: not in Safari before 17.4.
|
|
365
453
|
if (!input.isConnected || input.getBoundingClientRect().width === 0 || (input.checkVisibility && !input.checkVisibility({ checkVisibilityCSS: true }))) return null;
|
|
366
454
|
const at = (field?.isConnected ? field : input).getBoundingClientRect();
|
|
367
|
-
|
|
455
|
+
const col = column?.isConnected ? column.getBoundingClientRect() : null;
|
|
456
|
+
return { r: at.width ? at : input.getBoundingClientRect(), vw: document.documentElement.clientWidth, col: col?.width ? col : null };
|
|
368
457
|
};
|
|
369
458
|
/**
|
|
370
459
|
* Where the dropdown is, every frame while open; `frame`: from the frame
|
|
@@ -376,26 +465,34 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
376
465
|
if (!open || !input) return;
|
|
377
466
|
const s = host!.style;
|
|
378
467
|
if (sheet) {
|
|
379
|
-
// Full screen: what shows of the page (the visual
|
|
380
|
-
// on-screen keyboard), so the sticky "Show all"
|
|
468
|
+
// Full screen: its content in what shows of the page (the visual
|
|
469
|
+
// viewport, above the on-screen keyboard), so the sticky "Show all"
|
|
470
|
+
// sits above the keys. The host reaches on down to the layout
|
|
471
|
+
// viewport's bottom, painted (openSheet): iOS 26 Safari ends the visual
|
|
472
|
+
// viewport above its floating address bar, and between the two the
|
|
473
|
+
// page showed through, see-through bars over it.
|
|
381
474
|
const vv = window.visualViewport;
|
|
382
|
-
|
|
475
|
+
const top = Math.round(vv?.offsetTop ?? 0);
|
|
476
|
+
const h = Math.round(vv?.height ?? innerHeight);
|
|
477
|
+
s.top = `${top}px`;
|
|
383
478
|
s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
|
|
384
479
|
s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
|
|
385
|
-
s.height = `${Math.
|
|
480
|
+
s.height = `${Math.max(h, innerHeight - top)}px`;
|
|
481
|
+
s.setProperty('--lbx-vh', `${h}px`);
|
|
482
|
+
host!.toggleAttribute('data-lbx-short', h < SHEET_SHORT);
|
|
386
483
|
raf = requestAnimationFrame(() => position(true));
|
|
387
484
|
return;
|
|
388
485
|
}
|
|
389
486
|
const got = m === undefined ? measure() : m;
|
|
390
487
|
if (!got) return close('hidden');
|
|
391
|
-
const { r, vw } = got;
|
|
488
|
+
const { r, vw, col } = got;
|
|
392
489
|
s.top = `${Math.round(r.bottom + 4)}px`;
|
|
393
490
|
if (light) {
|
|
394
491
|
const { left, width } = dropdownBox(r, vw);
|
|
395
492
|
s.left = `${Math.round(left)}px`;
|
|
396
493
|
s.width = `${Math.round(width)}px`;
|
|
397
494
|
} else {
|
|
398
|
-
const p = panelBox(r, vw, listOnly);
|
|
495
|
+
const p = panelBox(r, vw, listOnly, col);
|
|
399
496
|
s.left = `${Math.round(p.left)}px`;
|
|
400
497
|
s.width = `${Math.round(p.width)}px`;
|
|
401
498
|
s.setProperty('--lbx-caret', `${Math.round(p.caret)}px`);
|
|
@@ -458,7 +555,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
458
555
|
* (not for a row the pointer is over: `say` false).
|
|
459
556
|
*/
|
|
460
557
|
const markActive = (say = true) => {
|
|
461
|
-
const
|
|
558
|
+
const all = active === ALL ? allLink() : null;
|
|
559
|
+
if (all && !all.id) all.id = idOf(ALL);
|
|
560
|
+
const row = all ?? (active >= 0 ? (box.getRootNode() as Document | ShadowRoot).getElementById(idOf(active)) : null);
|
|
462
561
|
if (sheet && mirror) {
|
|
463
562
|
if (row) mirror.setAttribute('aria-activedescendant', row.id);
|
|
464
563
|
else mirror.removeAttribute('aria-activedescendant');
|
|
@@ -480,7 +579,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
480
579
|
// The field and the site's colours: read after the focus's frame (prepare), else now.
|
|
481
580
|
// Reads first (the field, where it is, the colours), then our writes.
|
|
482
581
|
const fresh = prepared !== input;
|
|
483
|
-
if (fresh)
|
|
582
|
+
if (fresh) lineUp(input!);
|
|
484
583
|
const m = measure();
|
|
485
584
|
if (fresh && !light) sampleTokens(input!, field!);
|
|
486
585
|
prepared = null;
|
|
@@ -511,8 +610,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
511
610
|
const prepare = (i: HTMLInputElement) =>
|
|
512
611
|
afterFrame(() => {
|
|
513
612
|
if (open || input !== i || !i.isConnected || sheet) return;
|
|
514
|
-
|
|
515
|
-
if (!light) sampleTokens(i, field);
|
|
613
|
+
lineUp(i);
|
|
614
|
+
if (!light) sampleTokens(i, field!);
|
|
516
615
|
prepared = i;
|
|
517
616
|
place(); // after the reads: it dirties the page's style
|
|
518
617
|
rehearse();
|
|
@@ -593,7 +692,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
593
692
|
st.setProperty('visibility', 'hidden');
|
|
594
693
|
st.top = '0px';
|
|
595
694
|
st.left = '0px';
|
|
596
|
-
st.width = `${Math.round(panelBox(m.r, m.vw).width)}px`;
|
|
695
|
+
st.width = `${Math.round(panelBox(m.r, m.vw, false, m.col).width)}px`;
|
|
597
696
|
st.setProperty('display', 'block', 'important');
|
|
598
697
|
}
|
|
599
698
|
}
|
|
@@ -630,8 +729,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
630
729
|
if (!open) {
|
|
631
730
|
sheet = true;
|
|
632
731
|
tab = '';
|
|
732
|
+
before = i.value;
|
|
633
733
|
place();
|
|
634
734
|
sampleTokens(i, i);
|
|
735
|
+
// Below the visible part, its background (position).
|
|
736
|
+
host!.style.background = 'var(--lbx-c-bg, var(--lbx-bg))';
|
|
737
|
+
// What the site's input holds now, not what the sheet's held when it last
|
|
738
|
+
// closed (letters typed into the site's input before the hand-off, a
|
|
739
|
+
// value the site's page set since).
|
|
740
|
+
if (mirror) mirror.value = i.value;
|
|
635
741
|
host!.style.setProperty('display', 'block', 'important');
|
|
636
742
|
host!.showPopover();
|
|
637
743
|
open = true;
|
|
@@ -648,11 +754,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
648
754
|
}
|
|
649
755
|
if (mirror) {
|
|
650
756
|
mirror.focus();
|
|
651
|
-
// The caret or selection the site's input has (
|
|
652
|
-
//
|
|
757
|
+
// The caret or selection the site's input has (all of it after a Tab):
|
|
758
|
+
// typing goes on as it would have there. A caret at its start is a tap's
|
|
759
|
+
// focus not yet placed (Chromium places it after the focus): the end.
|
|
760
|
+
// Bug: a tap on "helm" then "fietstas" typed "fietstashelm".
|
|
653
761
|
const end = mirror.value.length;
|
|
762
|
+
let [from, to] = [Math.min(i.selectionStart ?? end, end), Math.min(i.selectionEnd ?? end, end)];
|
|
763
|
+
if (!from && !to) from = to = end;
|
|
654
764
|
try {
|
|
655
|
-
mirror.setSelectionRange(
|
|
765
|
+
mirror.setSelectionRange(from, to);
|
|
656
766
|
} catch {}
|
|
657
767
|
}
|
|
658
768
|
query(mirror?.value ?? i.value);
|
|
@@ -723,8 +833,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
723
833
|
entry = false; // left in history (the same URL) unless Back took it
|
|
724
834
|
setCss('ac:sheet', null);
|
|
725
835
|
host!.style.height = '';
|
|
836
|
+
host!.style.background = 'transparent';
|
|
837
|
+
host!.style.removeProperty('--lbx-vh');
|
|
838
|
+
host!.removeAttribute('data-lbx-short');
|
|
726
839
|
mirror?.blur();
|
|
727
840
|
if (focused() === input) input?.blur();
|
|
841
|
+
// Cancelled: the site's input as it was (what the page still shows
|
|
842
|
+
// results for). Bug: the sheet's back arrow left "helmfietstas" in the
|
|
843
|
+
// site's input over a page of results for "helm".
|
|
844
|
+
if (reason === 'back' && input && input.value !== before) setValue(input, before);
|
|
728
845
|
}
|
|
729
846
|
aria?.set('aria-expanded', 'false');
|
|
730
847
|
aria?.set('aria-activedescendant', null);
|
|
@@ -768,7 +885,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
768
885
|
: sheet
|
|
769
886
|
? [...phrases, ...(tab === PRODUCT ? products : byType(tab))].map((x) => x.i)
|
|
770
887
|
: [...phrases, ...lists.flatMap((g) => g.rows), ...(wide && hero ? [hero] : []), ...grid].map((x) => x.i);
|
|
771
|
-
if (active >= 0 && !order.includes(active)) active = -1;
|
|
888
|
+
if ((active >= 0 && !order.includes(active)) || (active === ALL && sheet)) active = -1;
|
|
772
889
|
const phoneNumber = t('help.phone');
|
|
773
890
|
const help = phoneNumber && phoneNumber !== 'help.phone' ? { title: t('help.title'), phone: phoneNumber, tel: phoneNumber.replace(/[^\d+]/g, ''), hours: t('help.hours').replace(/^help\.hours$/, '') } : null;
|
|
774
891
|
const value = (drawnValue = mirror?.value ?? input?.value ?? '');
|
|
@@ -793,7 +910,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
793
910
|
lists,
|
|
794
911
|
hero: sheet || wide ? hero : null,
|
|
795
912
|
products: grid,
|
|
796
|
-
tabs: kinds.map((kind) => ({ kind, label: t(`types.${kind}`), selected: kind === tab, select: () => ((tab = kind), active >= 0 && (active = -1), render(), markActive()) })),
|
|
913
|
+
tabs: kinds.map((kind) => ({ kind, label: t(`types.${kind}`), selected: kind === tab, select: () => ((tab = kind), active >= 0 && (active = -1), toTop(), render(), markActive()) })),
|
|
797
914
|
tab,
|
|
798
915
|
empty: { tries, browse, fallback, help },
|
|
799
916
|
hover,
|
|
@@ -827,6 +944,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
827
944
|
},
|
|
828
945
|
back: () => leave('back'),
|
|
829
946
|
});
|
|
947
|
+
// "Show all products" as the arrow keys' selection: lit as when it has the focus (its CSS: [data-lbx-active]).
|
|
948
|
+
const all = box.querySelector('[data-lbx-all]');
|
|
949
|
+
if (all && all.hasAttribute('data-lbx-active') !== (active === ALL)) all.toggleAttribute('data-lbx-active', active === ALL);
|
|
950
|
+
if (active === ALL && !all) active = -1;
|
|
830
951
|
};
|
|
831
952
|
|
|
832
953
|
/**
|
|
@@ -1069,6 +1190,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1069
1190
|
active = sel ? items.findIndex((r) => r.type === sel.type && r.id === sel.id) : -1;
|
|
1070
1191
|
lastQuery = q;
|
|
1071
1192
|
listOnly = results.length > 0 && !results.some((r) => r.type === PRODUCT);
|
|
1193
|
+
toTop();
|
|
1072
1194
|
// The sheet takes what arrives while its input has let go (the keyboard put away to read).
|
|
1073
1195
|
if (!sheet && focused() !== input) return;
|
|
1074
1196
|
// The first opening with an onOpen (the site's own code: one storefront opens its
|
|
@@ -1108,14 +1230,18 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1108
1230
|
const typed = () => {
|
|
1109
1231
|
const i = input;
|
|
1110
1232
|
if (!i || !mirror || i.value === mirror.value) return;
|
|
1233
|
+
setValue(i, mirror.value);
|
|
1234
|
+
};
|
|
1235
|
+
/** A value into the site's input as typing would put it (an `input` event), which ours ignores. */
|
|
1236
|
+
function setValue(i: HTMLInputElement, value: string) {
|
|
1111
1237
|
suppressInput = true;
|
|
1112
1238
|
try {
|
|
1113
|
-
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i,
|
|
1239
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, value);
|
|
1114
1240
|
i.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
1115
1241
|
} finally {
|
|
1116
1242
|
suppressInput = false;
|
|
1117
1243
|
}
|
|
1118
|
-
}
|
|
1244
|
+
}
|
|
1119
1245
|
const handToHost = (from: HTMLInputElement, q: string) => {
|
|
1120
1246
|
// A host that re-rendered its header meanwhile: its input as it is now.
|
|
1121
1247
|
const i = from.isConnected ? from : (document.querySelector(selector) as HTMLInputElement | null) ?? from;
|
|
@@ -1154,19 +1280,24 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1154
1280
|
}
|
|
1155
1281
|
}
|
|
1156
1282
|
|
|
1157
|
-
/**
|
|
1283
|
+
/**
|
|
1284
|
+
* The arrow keys: the next option in `order`, then "Show all products" (the
|
|
1285
|
+
* dropdown's, not the sheet's), then back to the input (-1); Arrow Up the
|
|
1286
|
+
* other way round.
|
|
1287
|
+
*/
|
|
1158
1288
|
const step = (by: 1 | -1) => {
|
|
1159
|
-
const
|
|
1160
|
-
const
|
|
1289
|
+
const stops = !sheet && allLink() ? [...order, ALL] : order;
|
|
1290
|
+
const n = stops.length + 1; // positions: -1 (back in input) .. stops.length-1
|
|
1291
|
+
const at = stops.indexOf(active);
|
|
1161
1292
|
const next = ((at + by + 1 + n) % n) - 1;
|
|
1162
|
-
active = next < 0 ? -1 :
|
|
1293
|
+
active = next < 0 ? -1 : stops[next];
|
|
1163
1294
|
byPointer = false;
|
|
1164
1295
|
render();
|
|
1165
1296
|
// Into view once the frame with it has rendered (layout clean then): in
|
|
1166
1297
|
// the key's task, right after our render, it forced a style and layout of
|
|
1167
1298
|
// the page (most of typing's forced layout; the key's response waited).
|
|
1168
1299
|
const row = markActive();
|
|
1169
|
-
if (row) afterFrame(() => row.isConnected && row.id ===
|
|
1300
|
+
if (row) afterFrame(() => row.isConnected && row.id === idOf(active) && row.scrollIntoView({ block: 'nearest' }));
|
|
1170
1301
|
};
|
|
1171
1302
|
/** The pointer over an option: the same selection the arrow keys move. A
|
|
1172
1303
|
* touch is not a hover (a tap's pointerenter would leave the row lit). */
|
|
@@ -1214,6 +1345,21 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1214
1345
|
if (!mirror.value !== !drawnValue) render();
|
|
1215
1346
|
return;
|
|
1216
1347
|
}
|
|
1348
|
+
// Typed into the site's input while the sheet has the focus: keys the
|
|
1349
|
+
// phone sent during the hand-off (the tap focused the site's input, the
|
|
1350
|
+
// sheet's took over in that task). Carried into the sheet's input. Bug:
|
|
1351
|
+
// letters typed within a few ms of the tap were lost (the next one typed
|
|
1352
|
+
// into the sheet wrote its value over theirs).
|
|
1353
|
+
if (sheet && mirror && input && target === input && !suppressInput) {
|
|
1354
|
+
if (input.value === mirror.value) return;
|
|
1355
|
+
mirror.value = input.value;
|
|
1356
|
+
try {
|
|
1357
|
+
mirror.setSelectionRange(mirror.value.length, mirror.value.length);
|
|
1358
|
+
} catch {}
|
|
1359
|
+
query(mirror.value);
|
|
1360
|
+
if (!mirror.value !== !drawnValue) render();
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1217
1363
|
// Not one being typed into: a value set for the visitor (search/in-place.ts
|
|
1218
1364
|
// on back/forward) asks for nothing; a focus later asks for what it holds.
|
|
1219
1365
|
if (suppressInput || !matches(target) || target !== focused()) return;
|
|
@@ -1325,7 +1471,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1325
1471
|
consume(e); // also keeps the host's hidden suggestion list from moving
|
|
1326
1472
|
step(e.key === 'ArrowDown' ? 1 : -1);
|
|
1327
1473
|
} else if (e.key === 'Enter') {
|
|
1328
|
-
if (active
|
|
1474
|
+
if (active === ALL) {
|
|
1475
|
+
consume(e);
|
|
1476
|
+
submitAll();
|
|
1477
|
+
} else if (active >= 0) {
|
|
1329
1478
|
consume(e); // our selection wins over the host's Enter handler
|
|
1330
1479
|
choose(active);
|
|
1331
1480
|
} else if (sheet) {
|
|
@@ -1344,6 +1493,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1344
1493
|
const all = allLink();
|
|
1345
1494
|
if (!all) return;
|
|
1346
1495
|
consume(e);
|
|
1496
|
+
// The one thing lit: the options' selection goes (its focus ring shows where Tab went).
|
|
1497
|
+
if (active !== -1) {
|
|
1498
|
+
active = -1;
|
|
1499
|
+
render();
|
|
1500
|
+
markActive(false);
|
|
1501
|
+
}
|
|
1347
1502
|
all.focus();
|
|
1348
1503
|
}
|
|
1349
1504
|
});
|
|
@@ -1379,7 +1534,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1379
1534
|
close('navigate');
|
|
1380
1535
|
});
|
|
1381
1536
|
|
|
1382
|
-
hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && (
|
|
1537
|
+
hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
|
|
1383
1538
|
|
|
1384
1539
|
// The interaction that loaded us has already happened: pick it up.
|
|
1385
1540
|
const now = focused();
|
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
--lbx-radius-row: calc(var(--lbx-radius) * .6);
|
|
37
37
|
--lbx-radius-tile: calc(var(--lbx-radius) * .5);
|
|
38
38
|
}
|
|
39
|
-
/* The render root: the sheet
|
|
40
|
-
|
|
39
|
+
/* The render root: the sheet is the visual viewport's height (--lbx-vh, index.ts; the host reaches on
|
|
40
|
+
below it, painted); the panel is as tall as it is. */
|
|
41
|
+
.lbx-root { height: var(--lbx-vh, 100%); }
|
|
41
42
|
/* What the rules read: the tokens, or the high-contrast palette while it is on. */
|
|
42
43
|
.box, .sheet {
|
|
43
44
|
--c-fg: var(--lbx-c-fg, var(--lbx-fg));
|
|
@@ -105,14 +106,15 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
105
106
|
.clamp, .card .t, .mproduct .t, .compact .t { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
106
107
|
|
|
107
108
|
/* The top product: the whole card is the option. */
|
|
108
|
-
.hero { flex: 1; flex-direction: column; align-items: stretch; gap:
|
|
109
|
+
.hero { flex: 1; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 8px; padding: 10px; margin: -10px; border-radius: var(--lbx-radius-row); }
|
|
109
110
|
.pic { position: relative; height: 230px; display: flex; align-items: center; justify-content: center; border-radius: var(--lbx-radius-tile); background: var(--lbx-soft); overflow: hidden; }
|
|
110
111
|
.v-spec .pic { height: 120px; }
|
|
111
112
|
.badge { position: absolute; top: 10px; left: 10px; height: 24px; padding: 0 10px; display: flex; align-items: center; border-radius: 999px; background: var(--c-fill); color: var(--c-on-fill); font-size: 12px; font-weight: 700; }
|
|
112
113
|
.hero .t { font-size: 17px; font-weight: 700; }
|
|
113
114
|
.hero .stock { font-size: 13px; gap: 8px; }
|
|
114
115
|
.hero .dot { width: 8px; height: 8px; }
|
|
115
|
-
|
|
116
|
+
/* Under the title, not at the column's foot; the old price first, as on the cards. */
|
|
117
|
+
.hero .prices { margin-top: 4px; align-items: baseline; gap: 8px; }
|
|
116
118
|
.hero .now { font-size: 26px; letter-spacing: -.01em; }
|
|
117
119
|
.hero .old { font-size: 13px; }
|
|
118
120
|
.specs { display: flex; flex-direction: column; border-top: 1px solid var(--c-line); }
|
|
@@ -136,7 +138,7 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
136
138
|
/* "Show all products": the PRODUCTS column's foot, outlined; filled when focused or hovered. Outside the listbox. */
|
|
137
139
|
.all { display: flex; align-items: center; justify-content: center; gap: 8px; height: 46px; border-radius: var(--lbx-radius-row); box-shadow: inset 0 0 0 1.5px var(--c-fill);
|
|
138
140
|
color: var(--c-accent); font-size: 15px; font-weight: 700; background: var(--c-bg); cursor: pointer; }
|
|
139
|
-
.all:hover, .all:focus-visible { background: var(--c-fill); color: var(--c-on-fill); }
|
|
141
|
+
.all:hover, .all:focus-visible, .all[data-lbx-active] { background: var(--c-fill); color: var(--c-on-fill); }
|
|
140
142
|
.box .all { position: absolute; right: 24px; bottom: 20px; left: 24px; }
|
|
141
143
|
.box.has-list .all { left: 304px; }
|
|
142
144
|
.box.has-hero .all { left: 334px; }
|
|
@@ -208,6 +210,10 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
208
210
|
.mhero .t { font-size: 16px; line-height: 1.3; font-weight: 700; }
|
|
209
211
|
.mhero .stock, .mproduct .stock { font-size: 13px; }
|
|
210
212
|
.mhero .now { font-size: 22px; }
|
|
213
|
+
/* The keyboard up (a short sheet): a smaller top product, more of the list in view. */
|
|
214
|
+
:host([data-lbx-short]) .mhero .tile { width: 72px; height: 72px; }
|
|
215
|
+
:host([data-lbx-short]) .m-hero { padding: 12px 16px; gap: 8px; }
|
|
216
|
+
:host([data-lbx-short]) .mhero .now { font-size: 18px; }
|
|
211
217
|
.mproduct { padding: 12px 16px; gap: 14px; border-bottom: 1px solid var(--c-line); }
|
|
212
218
|
.mproduct .tile { width: 72px; height: 72px; }
|
|
213
219
|
.mproduct .t { font-size: 15px; font-weight: 700; }
|