@usequeek/theme-kit 0.1.23 → 0.1.25
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 +45 -0
- package/LICENSE +1 -1
- package/README.md +164 -21
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-switcher.tsx +1 -0
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +8 -13
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +29 -8
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +18 -10
- package/sdk/queek-client.ts +12 -2
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -90
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-menu.test.tsx +0 -598
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/menu-placement.test.ts +0 -63
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- package/tests/use-shop-prefetch.test.tsx +0 -87
|
@@ -1,598 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { act, type ReactNode } from 'react';
|
|
5
|
-
import { createRoot, type Root } from 'react-dom/client';
|
|
6
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
-
|
|
9
|
-
const nav = vi.hoisted(() => ({ pathname: '/shop', push: vi.fn() }));
|
|
10
|
-
vi.mock('next/navigation', () => ({
|
|
11
|
-
usePathname: () => nav.pathname,
|
|
12
|
-
useRouter: () => ({ push: nav.push, replace: vi.fn(), back: vi.fn(), refresh: vi.fn() }),
|
|
13
|
-
}));
|
|
14
|
-
|
|
15
|
-
import { LanguageSwitcher, type LanguageSwitcherProps } from '../components/language-switcher';
|
|
16
|
-
import { StorefrontProvider } from '../provider';
|
|
17
|
-
import type { StoreLocaleInput } from '../utils/locale-switch';
|
|
18
|
-
|
|
19
|
-
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
-
|
|
21
|
-
const row = (locale: string, name: string, native: string, extra: Partial<StoreLocaleInput> = {}): StoreLocaleInput => ({
|
|
22
|
-
locale,
|
|
23
|
-
name,
|
|
24
|
-
native_name: native,
|
|
25
|
-
is_primary: locale === 'en',
|
|
26
|
-
is_default: locale === 'en',
|
|
27
|
-
path_prefix: locale === 'en' ? '' : locale.toLowerCase(),
|
|
28
|
-
hreflang: locale,
|
|
29
|
-
html_lang: locale,
|
|
30
|
-
dir: 'ltr',
|
|
31
|
-
...extra,
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const LOCALES: StoreLocaleInput[] = [
|
|
35
|
-
row('en', 'English', 'English'),
|
|
36
|
-
row('fr', 'French', 'Français'),
|
|
37
|
-
row('de', 'German', 'Deutsch'),
|
|
38
|
-
row('ar', 'Arabic', 'العربية', { dir: 'rtl' }),
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
const TWENTY: StoreLocaleInput[] = [
|
|
42
|
-
row('en', 'English', 'English'),
|
|
43
|
-
...Array.from({ length: 19 }, (_, i) => row(`x${String.fromCharCode(97 + i)}`, `Lang ${i}`, `Lang ${i}`)),
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
function Provider({ locales = LOCALES, locale = null, children }: { locales?: StoreLocaleInput[]; locale?: string | null; children: ReactNode }) {
|
|
47
|
-
return (
|
|
48
|
-
<StorefrontProvider
|
|
49
|
-
vendor={{ id: 'v1', slug: 'acme', name: 'Acme' } as never}
|
|
50
|
-
config={{} as never}
|
|
51
|
-
menus={[]}
|
|
52
|
-
basePath={locale && locale !== 'en' ? `/${locale}` : ''}
|
|
53
|
-
locale={locale}
|
|
54
|
-
locales={locales}
|
|
55
|
-
>
|
|
56
|
-
{children}
|
|
57
|
-
</StorefrontProvider>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
let container: HTMLDivElement;
|
|
62
|
-
let root: Root;
|
|
63
|
-
|
|
64
|
-
function mount(props: LanguageSwitcherProps = {}, options: { locales?: StoreLocaleInput[]; locale?: string | null } = {}): void {
|
|
65
|
-
act(() => {
|
|
66
|
-
root.render(
|
|
67
|
-
<Provider {...options}>
|
|
68
|
-
<button id="before">before</button>
|
|
69
|
-
<LanguageSwitcher variant="menu" {...props} />
|
|
70
|
-
<button id="after">after</button>
|
|
71
|
-
</Provider>,
|
|
72
|
-
);
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const q = <T extends Element = HTMLElement>(sel: string) => container.querySelector(sel) as T;
|
|
77
|
-
const trigger = () => q<HTMLButtonElement>('[data-part="trigger"]');
|
|
78
|
-
const popover = () => q<HTMLElement>('[data-part="popover"]');
|
|
79
|
-
const links = () => [...container.querySelectorAll<HTMLAnchorElement>('[data-part="option"]')];
|
|
80
|
-
const rootEl = () => q('[data-qn-language-switcher]');
|
|
81
|
-
|
|
82
|
-
function key(target: Element, k: string, init: KeyboardEventInit = {}): void {
|
|
83
|
-
act(() => {
|
|
84
|
-
target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
function click(target: Element, init: MouseEventInit = {}): MouseEvent {
|
|
88
|
-
const event = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0, ...init });
|
|
89
|
-
act(() => {
|
|
90
|
-
target.dispatchEvent(event);
|
|
91
|
-
});
|
|
92
|
-
return event;
|
|
93
|
-
}
|
|
94
|
-
function openMenu(): void {
|
|
95
|
-
click(trigger());
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** matchMedia mock: `sheet` toggles whether the narrow-screen query matches. */
|
|
99
|
-
function mockMatchMedia(initialSheet: boolean) {
|
|
100
|
-
const listeners = new Set<() => void>();
|
|
101
|
-
const state = { sheet: initialSheet };
|
|
102
|
-
window.matchMedia = ((query: string) => ({
|
|
103
|
-
media: query,
|
|
104
|
-
get matches() {
|
|
105
|
-
return query.includes('max-width') ? state.sheet : false;
|
|
106
|
-
},
|
|
107
|
-
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
|
|
108
|
-
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
|
|
109
|
-
})) as never;
|
|
110
|
-
return {
|
|
111
|
-
set(sheet: boolean) {
|
|
112
|
-
state.sheet = sheet;
|
|
113
|
-
act(() => listeners.forEach((fn) => fn()));
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
beforeEach(() => {
|
|
119
|
-
nav.pathname = '/shop';
|
|
120
|
-
nav.push.mockClear();
|
|
121
|
-
window.history.replaceState(null, '', '/shop');
|
|
122
|
-
delete (window as { matchMedia?: unknown }).matchMedia;
|
|
123
|
-
container = document.createElement('div');
|
|
124
|
-
document.body.appendChild(container);
|
|
125
|
-
root = createRoot(container);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
afterEach(() => {
|
|
129
|
-
act(() => root.unmount());
|
|
130
|
-
container.remove();
|
|
131
|
-
vi.useRealTimers();
|
|
132
|
-
document.body.className = '';
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe('menu: markup and aria', () => {
|
|
136
|
-
it('renders a disclosure button and a closed list of real links', () => {
|
|
137
|
-
mount();
|
|
138
|
-
expect(trigger().tagName).toBe('BUTTON');
|
|
139
|
-
expect(trigger().getAttribute('aria-expanded')).toBe('false');
|
|
140
|
-
expect(trigger().hasAttribute('aria-haspopup')).toBe(false);
|
|
141
|
-
expect(trigger().getAttribute('aria-label')).toBe('Language: English');
|
|
142
|
-
expect(trigger().getAttribute('aria-controls')).toBe(popover().id);
|
|
143
|
-
expect(popover().hidden).toBe(true);
|
|
144
|
-
expect(rootEl().getAttribute('data-state')).toBe('closed');
|
|
145
|
-
expect(links().map((a) => a.getAttribute('href'))).toEqual(['/shop', '/fr/shop', '/de/shop', '/ar/shop']);
|
|
146
|
-
expect(links().map((a) => a.getAttribute('lang'))).toEqual(['en', 'fr', 'de', 'ar']);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('uses link semantics, not listbox semantics, and marks exactly one link current', () => {
|
|
150
|
-
mount({}, { locale: 'fr' });
|
|
151
|
-
openMenu();
|
|
152
|
-
expect(container.querySelector('[role="listbox"], [role="option"], [aria-selected]')).toBeNull();
|
|
153
|
-
expect(
|
|
154
|
-
links()
|
|
155
|
-
.filter((a) => a.getAttribute('aria-current') === 'true')
|
|
156
|
-
.map((a) => a.querySelector('[data-part="name"]')!.textContent),
|
|
157
|
-
).toEqual(['Français']);
|
|
158
|
-
const list = q('[data-part="list"]');
|
|
159
|
-
expect(list.tagName).toBe('UL');
|
|
160
|
-
expect(list.getAttribute('aria-labelledby')).toBe(q('[data-part="title"]').id);
|
|
161
|
-
expect(q('[data-part="title"]').textContent).toBe('Language');
|
|
162
|
-
expect(trigger().getAttribute('aria-label')).toBe('Language: Français');
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('renders every documented part', () => {
|
|
166
|
-
mount();
|
|
167
|
-
for (const part of ['trigger', 'trigger-label', 'trigger-code', 'chevron', 'scrim', 'popover', 'handle', 'title', 'list', 'item', 'option', 'name', 'code', 'mark']) {
|
|
168
|
-
expect(container.querySelector(`[data-part="${part}"]`), part).not.toBeNull();
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('exposes state and placement as data attributes', () => {
|
|
173
|
-
mount({ side: 'top', align: 'start', showCode: true });
|
|
174
|
-
expect(rootEl().getAttribute('data-side')).toBe('top');
|
|
175
|
-
expect(rootEl().getAttribute('data-align')).toBe('start');
|
|
176
|
-
expect(rootEl().getAttribute('data-show-code')).toBe('true');
|
|
177
|
-
expect(rootEl().getAttribute('data-count')).toBe('4');
|
|
178
|
-
expect(rootEl().hasAttribute('data-many')).toBe(false);
|
|
179
|
-
expect(rootEl().hasAttribute('data-mode')).toBe(false);
|
|
180
|
-
expect(rootEl().className).toBe('qn-lang qn-lang--menu');
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('honours className, showNativeName and showCode', () => {
|
|
184
|
-
mount({ className: 'in-header', showNativeName: false });
|
|
185
|
-
expect(rootEl().className).toBe('qn-lang qn-lang--menu in-header');
|
|
186
|
-
expect(links().map((a) => a.querySelector('[data-part="name"]')!.textContent)).toEqual(['English', 'French', 'German', 'Arabic']);
|
|
187
|
-
expect(q('[data-part="trigger-label"]').textContent).toBe('English');
|
|
188
|
-
expect(q('[data-part="trigger-code"]').textContent).toBe('EN');
|
|
189
|
-
expect(links()[1]!.querySelector('[data-part="code"]')!.textContent).toBe('FR');
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('marks right-to-left names with their direction', () => {
|
|
193
|
-
mount();
|
|
194
|
-
const ar = links()[3]!.querySelector('bdi')!;
|
|
195
|
-
expect(ar.getAttribute('dir')).toBe('rtl');
|
|
196
|
-
expect(ar.getAttribute('lang')).toBe('ar');
|
|
197
|
-
expect(links()[0]!.querySelector('bdi')!.hasAttribute('dir')).toBe(false);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('renders nothing for one language, and on the server too', () => {
|
|
201
|
-
mount({}, { locales: [LOCALES[0]!] });
|
|
202
|
-
expect(container.querySelector('[data-qn-language-switcher], [data-part]')).toBeNull();
|
|
203
|
-
const html = renderToStaticMarkup(
|
|
204
|
-
<Provider locales={[LOCALES[0]!]}>
|
|
205
|
-
<LanguageSwitcher variant="menu" />
|
|
206
|
-
</Provider>,
|
|
207
|
-
);
|
|
208
|
-
expect(html).not.toContain('qn-lang');
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('server markup: closed, hidden popover, links with hrefs, no query string', () => {
|
|
212
|
-
window.history.replaceState(null, '', '/shop?sort=new');
|
|
213
|
-
const html = renderToStaticMarkup(
|
|
214
|
-
<Provider>
|
|
215
|
-
<LanguageSwitcher variant="menu" />
|
|
216
|
-
</Provider>,
|
|
217
|
-
);
|
|
218
|
-
expect(html).toContain('data-state="closed"');
|
|
219
|
-
expect(html).toMatch(/data-part="popover"[^>]*hidden/);
|
|
220
|
-
expect(html).toContain('href="/fr/shop"');
|
|
221
|
-
expect(html).not.toContain('sort=new');
|
|
222
|
-
expect(html).not.toContain('data-mode');
|
|
223
|
-
expect(html).toContain('aria-expanded="false"');
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it('has no focusable control in an aria-hidden subtree, open or closed', () => {
|
|
227
|
-
const offenders = () =>
|
|
228
|
-
[...container.querySelectorAll('[aria-hidden="true"]')].flatMap((el) =>
|
|
229
|
-
[...el.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')].concat(
|
|
230
|
-
el.matches('a[href], button, input, select, textarea, [tabindex]') ? [el] : [],
|
|
231
|
-
),
|
|
232
|
-
);
|
|
233
|
-
mount();
|
|
234
|
-
expect(offenders()).toEqual([]);
|
|
235
|
-
openMenu();
|
|
236
|
-
expect(offenders()).toEqual([]);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('works with 20 languages: every link, many-flag, scrollable list', () => {
|
|
240
|
-
mount({}, { locales: TWENTY });
|
|
241
|
-
expect(links()).toHaveLength(20);
|
|
242
|
-
expect(rootEl().getAttribute('data-many')).toBe('true');
|
|
243
|
-
expect(rootEl().getAttribute('data-count')).toBe('20');
|
|
244
|
-
openMenu();
|
|
245
|
-
key(links()[0]!, 'End');
|
|
246
|
-
expect(document.activeElement).toBe(links()[19]);
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
describe('menu: opening, closing and focus', () => {
|
|
251
|
-
it('opens on click, focuses the current link, closes on a second click', () => {
|
|
252
|
-
mount({}, { locale: 'de' });
|
|
253
|
-
openMenu();
|
|
254
|
-
expect(trigger().getAttribute('aria-expanded')).toBe('true');
|
|
255
|
-
expect(rootEl().getAttribute('data-state')).toBe('open');
|
|
256
|
-
expect(popover().hidden).toBe(false);
|
|
257
|
-
expect(document.activeElement).toBe(links()[2]);
|
|
258
|
-
click(trigger());
|
|
259
|
-
expect(trigger().getAttribute('aria-expanded')).toBe('false');
|
|
260
|
-
expect(popover().hidden).toBe(true);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it('ArrowDown and ArrowUp on the trigger open at the current and last link', () => {
|
|
264
|
-
mount();
|
|
265
|
-
key(trigger(), 'ArrowDown');
|
|
266
|
-
expect(trigger().getAttribute('aria-expanded')).toBe('true');
|
|
267
|
-
expect(document.activeElement).toBe(links()[0]);
|
|
268
|
-
key(links()[0]!, 'Escape');
|
|
269
|
-
expect(trigger().getAttribute('aria-expanded')).toBe('false');
|
|
270
|
-
key(trigger(), 'ArrowUp');
|
|
271
|
-
expect(document.activeElement).toBe(links()[3]);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it('Enter and Space on the trigger open it (native button click)', () => {
|
|
275
|
-
mount();
|
|
276
|
-
// A <button> turns Enter/Space into a click; jsdom does not, so dispatch the click.
|
|
277
|
-
click(trigger());
|
|
278
|
-
expect(trigger().getAttribute('aria-expanded')).toBe('true');
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it('arrow keys move between links and wrap; Home and End jump', () => {
|
|
282
|
-
mount();
|
|
283
|
-
openMenu();
|
|
284
|
-
key(links()[0]!, 'ArrowDown');
|
|
285
|
-
expect(document.activeElement).toBe(links()[1]);
|
|
286
|
-
key(links()[1]!, 'ArrowDown');
|
|
287
|
-
key(links()[2]!, 'ArrowDown');
|
|
288
|
-
expect(document.activeElement).toBe(links()[3]);
|
|
289
|
-
key(links()[3]!, 'ArrowDown');
|
|
290
|
-
expect(document.activeElement).toBe(links()[0]);
|
|
291
|
-
key(links()[0]!, 'ArrowUp');
|
|
292
|
-
expect(document.activeElement).toBe(links()[3]);
|
|
293
|
-
key(links()[3]!, 'Home');
|
|
294
|
-
expect(document.activeElement).toBe(links()[0]);
|
|
295
|
-
key(links()[0]!, 'End');
|
|
296
|
-
expect(document.activeElement).toBe(links()[3]);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it('uses a roving tabindex: only the focused link is in the tab order', () => {
|
|
300
|
-
mount();
|
|
301
|
-
openMenu();
|
|
302
|
-
expect(links().map((a) => a.tabIndex)).toEqual([0, -1, -1, -1]);
|
|
303
|
-
key(links()[0]!, 'ArrowDown');
|
|
304
|
-
expect(links().map((a) => a.tabIndex)).toEqual([-1, 0, -1, -1]);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it('typeahead matches names and codes and resets after 500ms', () => {
|
|
308
|
-
vi.useFakeTimers();
|
|
309
|
-
mount();
|
|
310
|
-
openMenu();
|
|
311
|
-
key(links()[0]!, 'd');
|
|
312
|
-
expect(document.activeElement).toBe(links()[2]); // Deutsch
|
|
313
|
-
key(links()[2]!, 'x'); // "dx": no match, stays
|
|
314
|
-
expect(document.activeElement).toBe(links()[2]);
|
|
315
|
-
act(() => vi.advanceTimersByTime(600));
|
|
316
|
-
key(links()[2]!, 'f');
|
|
317
|
-
expect(document.activeElement).toBe(links()[1]); // Français
|
|
318
|
-
act(() => vi.advanceTimersByTime(600));
|
|
319
|
-
key(links()[1]!, 'a');
|
|
320
|
-
key(links()[0]!, 'r');
|
|
321
|
-
expect(document.activeElement).toBe(links()[3]); // "ar": the Arabic name/code
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it('Escape closes from a link or the trigger and returns focus to the trigger', () => {
|
|
325
|
-
mount();
|
|
326
|
-
openMenu();
|
|
327
|
-
key(links()[0]!, 'Escape');
|
|
328
|
-
expect(popover().hidden).toBe(true);
|
|
329
|
-
expect(document.activeElement).toBe(trigger());
|
|
330
|
-
openMenu();
|
|
331
|
-
act(() => trigger().focus());
|
|
332
|
-
key(trigger(), 'Escape');
|
|
333
|
-
expect(popover().hidden).toBe(true);
|
|
334
|
-
expect(document.activeElement).toBe(trigger());
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
it('Tab closes the menu without stealing focus', () => {
|
|
338
|
-
mount();
|
|
339
|
-
openMenu();
|
|
340
|
-
key(links()[0]!, 'Tab');
|
|
341
|
-
expect(popover().hidden).toBe(true);
|
|
342
|
-
expect(trigger().getAttribute('aria-expanded')).toBe('false');
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it('an outside pointerdown closes; one inside does not; focus is left where it went', () => {
|
|
346
|
-
mount();
|
|
347
|
-
openMenu();
|
|
348
|
-
act(() => {
|
|
349
|
-
popover().dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
|
|
350
|
-
});
|
|
351
|
-
expect(popover().hidden).toBe(false);
|
|
352
|
-
const after = q<HTMLButtonElement>('#after');
|
|
353
|
-
act(() => {
|
|
354
|
-
after.focus();
|
|
355
|
-
after.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
|
|
356
|
-
});
|
|
357
|
-
expect(popover().hidden).toBe(true);
|
|
358
|
-
expect(document.activeElement).toBe(after);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it('a scrim tap closes the menu', () => {
|
|
362
|
-
mount();
|
|
363
|
-
openMenu();
|
|
364
|
-
click(q('[data-part="scrim"]'));
|
|
365
|
-
expect(popover().hidden).toBe(true);
|
|
366
|
-
expect(q<HTMLElement>('[data-part="scrim"]').hidden).toBe(true);
|
|
367
|
-
});
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
describe('menu: choosing a language', () => {
|
|
371
|
-
beforeEach(() => {
|
|
372
|
-
window.history.replaceState(null, '', '/shop?sort=new');
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it('a plain click navigates with the live URL, closes and refocuses the trigger', () => {
|
|
376
|
-
mount();
|
|
377
|
-
openMenu();
|
|
378
|
-
const event = click(links()[1]!);
|
|
379
|
-
expect(event.defaultPrevented).toBe(true);
|
|
380
|
-
expect(nav.push).toHaveBeenCalledWith('/fr/shop?sort=new');
|
|
381
|
-
expect(popover().hidden).toBe(true);
|
|
382
|
-
expect(document.activeElement).toBe(trigger());
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it('Enter follows the focused link (native click) and Space activates it', () => {
|
|
386
|
-
mount();
|
|
387
|
-
openMenu();
|
|
388
|
-
// Enter on a link is a native click.
|
|
389
|
-
click(links()[2]!);
|
|
390
|
-
expect(nav.push).toHaveBeenLastCalledWith('/de/shop?sort=new');
|
|
391
|
-
openMenu();
|
|
392
|
-
key(links()[3]!, ' ');
|
|
393
|
-
expect(nav.push).toHaveBeenLastCalledWith('/ar/shop?sort=new');
|
|
394
|
-
expect(popover().hidden).toBe(true);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it('choosing the current language just closes', () => {
|
|
398
|
-
mount();
|
|
399
|
-
openMenu();
|
|
400
|
-
click(links()[0]!);
|
|
401
|
-
expect(nav.push).not.toHaveBeenCalled();
|
|
402
|
-
expect(popover().hidden).toBe(true);
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
it('modified clicks keep the browser default (new tab, copy link)', () => {
|
|
406
|
-
mount();
|
|
407
|
-
openMenu();
|
|
408
|
-
const swallow = (e: Event) => e.preventDefault(); // jsdom cannot navigate
|
|
409
|
-
document.addEventListener('click', swallow);
|
|
410
|
-
click(links()[1]!, { metaKey: true });
|
|
411
|
-
click(links()[1]!, { ctrlKey: true });
|
|
412
|
-
click(links()[1]!, { button: 1 });
|
|
413
|
-
document.removeEventListener('click', swallow);
|
|
414
|
-
expect(nav.push).not.toHaveBeenCalled();
|
|
415
|
-
expect(popover().hidden).toBe(false);
|
|
416
|
-
});
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
describe('menu: sheet or popover', () => {
|
|
420
|
-
it('is a popover by default: no scroll lock, data-mode=popover', () => {
|
|
421
|
-
mockMatchMedia(false);
|
|
422
|
-
mount();
|
|
423
|
-
openMenu();
|
|
424
|
-
expect(rootEl().getAttribute('data-mode')).toBe('popover');
|
|
425
|
-
expect(document.body.classList.contains('qn-lang-lock')).toBe(false);
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it('is a sheet below the breakpoint: scroll lock while open, released on close', () => {
|
|
429
|
-
mockMatchMedia(true);
|
|
430
|
-
mount();
|
|
431
|
-
openMenu();
|
|
432
|
-
expect(rootEl().getAttribute('data-mode')).toBe('sheet');
|
|
433
|
-
expect(document.body.classList.contains('qn-lang-lock')).toBe(true);
|
|
434
|
-
key(links()[0]!, 'Escape');
|
|
435
|
-
expect(document.body.classList.contains('qn-lang-lock')).toBe(false);
|
|
436
|
-
expect(rootEl().hasAttribute('data-mode')).toBe(false);
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it('closing by selection and by unmounting releases the scroll lock', () => {
|
|
440
|
-
mockMatchMedia(true);
|
|
441
|
-
mount();
|
|
442
|
-
openMenu();
|
|
443
|
-
click(links()[1]!);
|
|
444
|
-
expect(document.body.classList.contains('qn-lang-lock')).toBe(false);
|
|
445
|
-
openMenu();
|
|
446
|
-
expect(document.body.classList.contains('qn-lang-lock')).toBe(true);
|
|
447
|
-
act(() => root.render(<p>gone</p>));
|
|
448
|
-
expect(document.body.classList.contains('qn-lang-lock')).toBe(false);
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
it('follows the viewport crossing the breakpoint while open', () => {
|
|
452
|
-
const media = mockMatchMedia(false);
|
|
453
|
-
mount();
|
|
454
|
-
openMenu();
|
|
455
|
-
expect(rootEl().getAttribute('data-mode')).toBe('popover');
|
|
456
|
-
media.set(true);
|
|
457
|
-
expect(rootEl().getAttribute('data-mode')).toBe('sheet');
|
|
458
|
-
expect(document.body.classList.contains('qn-lang-lock')).toBe(true);
|
|
459
|
-
media.set(false);
|
|
460
|
-
expect(document.body.classList.contains('qn-lang-lock')).toBe(false);
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
it('the sheet shows the same markup as the popover (layout is CSS only)', () => {
|
|
464
|
-
mockMatchMedia(true);
|
|
465
|
-
mount();
|
|
466
|
-
const closed = container.innerHTML;
|
|
467
|
-
mockMatchMedia(false);
|
|
468
|
-
mount();
|
|
469
|
-
expect(container.innerHTML).toBe(closed);
|
|
470
|
-
});
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
describe('menu: placement', () => {
|
|
474
|
-
function stubRects(trigger: DOMRect, pop: { width: number; height: number }) {
|
|
475
|
-
return vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
476
|
-
if (this.matches('[data-part="trigger"]')) return trigger;
|
|
477
|
-
if (this.matches('[data-part="popover"]')) {
|
|
478
|
-
return { ...pop, top: 0, left: 0, right: pop.width, bottom: pop.height, x: 0, y: 0, toJSON() {} } as DOMRect;
|
|
479
|
-
}
|
|
480
|
-
return { width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} } as DOMRect;
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
const rect = (left: number, top: number, w = 100, h = 44) =>
|
|
484
|
-
({ left, top, right: left + w, bottom: top + h, width: w, height: h, x: left, y: top, toJSON() {} }) as DOMRect;
|
|
485
|
-
|
|
486
|
-
afterEach(() => vi.restoreAllMocks());
|
|
487
|
-
|
|
488
|
-
it('keeps the preferred side when it fits', () => {
|
|
489
|
-
stubRects(rect(800, 10), { width: 240, height: 200 });
|
|
490
|
-
mount();
|
|
491
|
-
openMenu();
|
|
492
|
-
expect(rootEl().getAttribute('data-side')).toBe('bottom');
|
|
493
|
-
expect(rootEl().getAttribute('data-align')).toBe('end');
|
|
494
|
-
expect(popover().style.getPropertyValue('--qn-lang-shift')).toBe('');
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
it('flips up near the bottom of the viewport and caps the height', () => {
|
|
498
|
-
stubRects(rect(800, 700), { width: 240, height: 300 });
|
|
499
|
-
mount();
|
|
500
|
-
openMenu();
|
|
501
|
-
expect(rootEl().getAttribute('data-side')).toBe('top');
|
|
502
|
-
expect(popover().style.getPropertyValue('--qn-lang-avail')).toBe(`${700 - 8 - 8}px`);
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
it('a footer menu with side="top" opens up, and flips down with no room above', () => {
|
|
506
|
-
stubRects(rect(800, 700), { width: 240, height: 300 });
|
|
507
|
-
mount({ side: 'top' });
|
|
508
|
-
openMenu();
|
|
509
|
-
expect(rootEl().getAttribute('data-side')).toBe('top');
|
|
510
|
-
key(links()[0]!, 'Escape');
|
|
511
|
-
vi.restoreAllMocks();
|
|
512
|
-
stubRects(rect(800, 10), { width: 240, height: 300 });
|
|
513
|
-
openMenu();
|
|
514
|
-
expect(rootEl().getAttribute('data-side')).toBe('bottom');
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
it('flips the alignment when the end edge would leave the viewport', () => {
|
|
518
|
-
stubRects(rect(20, 10), { width: 240, height: 200 });
|
|
519
|
-
mount();
|
|
520
|
-
openMenu();
|
|
521
|
-
expect(rootEl().getAttribute('data-align')).toBe('start');
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
it('reads the page direction for right-to-left storefronts', () => {
|
|
525
|
-
document.documentElement.setAttribute('dir', 'rtl');
|
|
526
|
-
try {
|
|
527
|
-
stubRects(rect(20, 10), { width: 240, height: 200 });
|
|
528
|
-
mount();
|
|
529
|
-
openMenu();
|
|
530
|
-
// In rtl `end` is the left edge, so a trigger near the left edge keeps `end`.
|
|
531
|
-
expect(rootEl().getAttribute('data-align')).toBe('end');
|
|
532
|
-
} finally {
|
|
533
|
-
document.documentElement.removeAttribute('dir');
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
describe('menu: render-prop and defaults', () => {
|
|
539
|
-
it('the render-prop receives open and setOpen', () => {
|
|
540
|
-
let seen: { open: boolean; setOpen: (v: boolean) => void } | null = null;
|
|
541
|
-
mount({
|
|
542
|
-
variant: 'select',
|
|
543
|
-
children: (state) => {
|
|
544
|
-
seen = { open: state.open, setOpen: state.setOpen };
|
|
545
|
-
return <p data-testid="custom">{String(state.open)}</p>;
|
|
546
|
-
},
|
|
547
|
-
});
|
|
548
|
-
expect(q('[data-testid="custom"]').textContent).toBe('false');
|
|
549
|
-
act(() => seen!.setOpen(true));
|
|
550
|
-
expect(q('[data-testid="custom"]').textContent).toBe('true');
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
it('select stays the default variant, unchanged', () => {
|
|
554
|
-
act(() =>
|
|
555
|
-
root.render(
|
|
556
|
-
<Provider>
|
|
557
|
-
<LanguageSwitcher />
|
|
558
|
-
</Provider>,
|
|
559
|
-
),
|
|
560
|
-
);
|
|
561
|
-
expect(container.querySelector('select')).not.toBeNull();
|
|
562
|
-
expect(container.querySelector('[data-variant="menu"]')).toBeNull();
|
|
563
|
-
});
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
describe('menu: stylesheet', () => {
|
|
567
|
-
const css = readFileSync(join(__dirname, '..', 'components', 'language-menu.css'), 'utf8');
|
|
568
|
-
const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
569
|
-
|
|
570
|
-
it('uses logical properties only', () => {
|
|
571
|
-
expect(code).not.toMatch(/(^|[\s{;])(left|right|margin-left|margin-right|padding-left|padding-right|border-left|border-right)\s*:/m);
|
|
572
|
-
expect(code).not.toMatch(/text-align:\s*(left|right)/);
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
it('every variable is --qn-* and has a fallback', () => {
|
|
576
|
-
const names = [...code.matchAll(/var\((--[a-z0-9-]+)/g)].map((m) => m[1]!);
|
|
577
|
-
expect(names.filter((n) => !n.startsWith('--qn-'))).toEqual([]);
|
|
578
|
-
expect(code).not.toMatch(/var\(--qn-[a-z0-9-]+\)/);
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it('declares the sheet breakpoint and respects reduced motion', () => {
|
|
582
|
-
expect(code).toContain('@media (max-width: 639.98px)');
|
|
583
|
-
expect(code).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*80ms/);
|
|
584
|
-
expect(code).toMatch(/@media \(prefers-reduced-motion: no-preference\)/);
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
it('the sheet breakpoint in CSS and in JS agree', async () => {
|
|
588
|
-
const { LANGUAGE_MENU_SHEET_QUERY } = await import('../components/language-menu');
|
|
589
|
-
expect(code).toContain(`@media ${LANGUAGE_MENU_SHEET_QUERY}`);
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it('documents every part the markup renders', () => {
|
|
593
|
-
mount();
|
|
594
|
-
for (const part of [...container.querySelectorAll('[data-part]')].map((el) => el.getAttribute('data-part')!)) {
|
|
595
|
-
expect(css, part).toContain(`data-part="${part}"`);
|
|
596
|
-
}
|
|
597
|
-
});
|
|
598
|
-
});
|