@akinon/pz-theme 2.0.104 → 2.0.105-rc.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 CHANGED
@@ -1,10 +1,46 @@
1
1
  # @akinon/pz-theme
2
2
 
3
+ ## 2.0.105-rc.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [86187b1c]
8
+ - Updated dependencies [0cf9ea239]
9
+ - Updated dependencies [324f97d55]
10
+ - Updated dependencies [51ea06888]
11
+ - Updated dependencies
12
+ - Updated dependencies [2fea41430]
13
+ - Updated dependencies [b55acb768]
14
+ - Updated dependencies [760258c1c]
15
+ - Updated dependencies [143be2b9d]
16
+ - Updated dependencies [7889b08fe]
17
+ - Updated dependencies [9f8cd3bc5]
18
+ - Updated dependencies [bfafa3f49]
19
+ - Updated dependencies [57d7eb305]
20
+ - Updated dependencies [d99a6a7d5]
21
+ - Updated dependencies [9db81a714]
22
+ - Updated dependencies [591e345e1]
23
+ - Updated dependencies [4de5303c5]
24
+ - Updated dependencies [95b139dc1]
25
+ - Updated dependencies [1d00f2d06]
26
+ - Updated dependencies [4ac7b2a1e]
27
+ - Updated dependencies [e9598c71d]
28
+ - Updated dependencies [4998a9631]
29
+ - Updated dependencies [804d2bd6c]
30
+ - Updated dependencies [3909d3224]
31
+ - Updated dependencies [6a3d8a631]
32
+ - Updated dependencies [e18836b20]
33
+ - Updated dependencies [b1111d7b1]
34
+ - Updated dependencies [1595d45]
35
+ - Updated dependencies [dca1281]
36
+ - @akinon/next@2.0.105-rc.0
37
+ - @akinon/theme-contract@2.0.105-rc.0
38
+
3
39
  ## 2.0.104
4
40
 
5
41
  ### Patch Changes
6
42
 
7
- - Updated dependencies [cf621e2]
43
+ - 31d4df3: ZERO-5066: Run the add-to-cart block's action only after the product is added to the basket
8
44
  - @akinon/next@2.0.104
9
45
  - @akinon/theme-contract@2.0.104
10
46
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.104",
4
+ "version": "2.0.105-rc.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.104",
11
+ "@akinon/next": "2.0.105-rc.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.104",
16
+ "@akinon/theme-contract": "2.0.105-rc.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.104",
24
+ "@akinon/next": "2.0.105-rc.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -4,13 +4,15 @@
4
4
  * `getCustomCatalog()` had no caller: a brand could register a custom block
5
5
  * and the editor could never learn the type existed. The announce is the only
6
6
  * channel, and it is shared with the placeholder slugs — which is what makes
7
- * the gating interesting. Three answers have to hold at once:
7
+ * the gating interesting. Four answers have to hold at once:
8
8
  *
9
9
  * - no placeholders, no frame (the editor's subscriber reloads the theme
10
10
  * against whatever slugs a frame carries, so an empty one is destructive)
11
11
  * - no proven designer, no catalog (a storefront is framed in production by
12
12
  * payment providers, and the catalog is brand IP)
13
13
  * - every frame carries the catalog whole, so the receiver can replace
14
+ * - every frame, gated or not, carries the action capabilities (a fact about
15
+ * the theme version, and for most builds the gated frame is the only one)
14
16
  */
15
17
 
16
18
  const EDITOR_ORIGIN = 'http://editor.example';
@@ -318,6 +320,52 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
318
320
  expect(postMessage).toHaveBeenCalledTimes(1);
319
321
  });
320
322
 
323
+ it('announces the add-to-cart action capability before the editor proves itself', () => {
324
+ const { registry } = load();
325
+
326
+ registry.registerPlaceholder('header');
327
+ jest.advanceTimersByTime(100);
328
+
329
+ // On the gated frame, not only the designer one. A build with no custom
330
+ // catalog never sends a second frame (see the test above), so for it this
331
+ // is the only frame the editor ever gets — and the editor reads a frame
332
+ // without the field as a renderer that still runs the action on the click,
333
+ // keeping "On Click" and every option on a block that no longer does.
334
+ //
335
+ // The literal string rather than the constant: the editor matches it by
336
+ // name and drops a name it does not know, so this pins the wire value a
337
+ // rename would silently break.
338
+ expect(frames()[0].actionCapabilities).toEqual(['add-to-cart-after-add']);
339
+ });
340
+
341
+ it('keeps the action capability on the designer re-announce too', () => {
342
+ const { registry, register } = load();
343
+
344
+ register.registerCustomBlocks('acme', { 'promo-strip': Promo });
345
+ registry.registerPlaceholder('header');
346
+ jest.advanceTimersByTime(100);
347
+
348
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
349
+ true;
350
+ window.dispatchEvent(
351
+ new MessageEvent('message', {
352
+ origin: EDITOR_ORIGIN,
353
+ source: window.parent as Window,
354
+ data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
355
+ })
356
+ );
357
+ jest.advanceTimersByTime(100);
358
+
359
+ // Every frame, not just the first. The editor replaces its capability set
360
+ // with each announce, so a later frame without the field would turn the
361
+ // panel back to "On Click" halfway through a session.
362
+ expect(postMessage).toHaveBeenCalledTimes(2);
363
+ expect(frames().map((frame) => frame.actionCapabilities)).toEqual([
364
+ ['add-to-cart-after-add'],
365
+ ['add-to-cart-after-add']
366
+ ]);
367
+ });
368
+
321
369
  it('sends the whole catalog every time, so a re-announce replaces', () => {
322
370
  (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
323
371
  true;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What this build's block renderer does with an authored action beyond running
3
+ * it on the click, announced to the editor.
4
+ *
5
+ * The editor words a block's action panel after what the storefront in front
6
+ * of it will actually do, so it has to be told — one Omnitron serves channels
7
+ * whose storefronts pin their own theme version, and a v1 brand cannot build
8
+ * past the line it is on, so "the repo has it" is not a fact the editor may
9
+ * assume.
10
+ *
11
+ * - `add-to-cart-after-add` the Add to Cart block runs its stored action
12
+ * itself once the add succeeds, rather than leaving
13
+ * it to the wrapper to run on the click (see
14
+ * `NATIVELY_INTERACTIVE_BLOCK_TYPES`). The editor
15
+ * reads it to say "After Adding to Cart" instead of
16
+ * "On Click", to trim the option list, and to stop
17
+ * offering Focus and Pressed root chips on a
18
+ * wrapper that is no longer a control.
19
+ *
20
+ * Names rather than a version number, for the reason the state pins are: the
21
+ * editor must not have to know which release added what. A build that does not
22
+ * send the list is read as doing none of the above, which is exactly what
23
+ * every renderer shipped before this one did.
24
+ *
25
+ * The editor matches these strings exactly and silently drops a name it does
26
+ * not know, so a rename here narrows the panel back to the baseline instead of
27
+ * failing anywhere. Change one only together with `StorefrontActionCapability`
28
+ * in pz-dynamic-widget.
29
+ *
30
+ * Its own module rather than a constant beside the block that implements it:
31
+ * the placeholder registry announces this, and it must not have to pull a
32
+ * client component in to say one list of strings.
33
+ */
34
+ export type ActionCapability = 'add-to-cart-after-add';
35
+
36
+ export const ACTION_CAPABILITIES: readonly ActionCapability[] = [
37
+ 'add-to-cart-after-add'
38
+ ];
@@ -0,0 +1,377 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ import AddToCartBlock from '../add-to-cart-block';
5
+ import { WithDesignerFeatures } from '../../components/with-designer-features';
6
+ import { ThemePageContextProvider } from '../../theme-page-context';
7
+ import { ModalHostIdProvider } from '../../overlays/modal-host-context';
8
+ import {
9
+ __resetOverlayBusForTests,
10
+ getOpenModalIds,
11
+ isModalOpen,
12
+ openModal,
13
+ registerModalHost,
14
+ subscribeToModals
15
+ } from '../../overlays/overlay-bus';
16
+ import { applyVisibilityRulesToSections } from '../../utils/visibility-rules';
17
+ import { Block } from '../../theme-block';
18
+ import { Section } from '../../theme-section';
19
+
20
+ /**
21
+ * "On Click: Open a modal / Modal: Added to basket" on the PDP add-to-cart.
22
+ *
23
+ * The designer means "once the product is in the basket". The wrapper every
24
+ * block renders inside used to run the action on the same click, before the
25
+ * add request had returned, so the modal opened on a failed add, the quantity
26
+ * stepper's +/- opened it too, and Enter on the real button opened it while
27
+ * the wrapper's keydown cancel swallowed the add itself.
28
+ *
29
+ * The block now runs the SAME saved action (no new property) itself, after the
30
+ * add succeeds — so every composition already published is fixed by the
31
+ * upgrade alone.
32
+ *
33
+ * Rendered the way a storefront renders it: the wrapper from `ThemeBlock`
34
+ * around the renderer, the product from the PDP's page context, and an add
35
+ * request the test holds open so "while pending" is a state it can look at.
36
+ */
37
+
38
+ const mockAddProduct = jest.fn();
39
+
40
+ // Every `@akinon/next/data/client/*` subpath maps to the same mock file, so
41
+ // one factory has to serve the product client (the mutation) and the basket
42
+ // client (`basketApi`) alike.
43
+ jest.mock('@akinon/next/data/client/product', () => ({
44
+ __esModule: true,
45
+ useAddProductMutation: () => [
46
+ (payload: unknown) => mockAddProduct(payload),
47
+ { isLoading: false }
48
+ ],
49
+ basketApi: {
50
+ util: { invalidateTags: () => ({ type: 'basket/invalidateTags' }) }
51
+ }
52
+ }));
53
+
54
+ jest.mock('@akinon/next/redux/hooks', () => ({
55
+ __esModule: true,
56
+ useAppSelector: () => undefined,
57
+ useAppDispatch: () => () => undefined
58
+ }));
59
+
60
+ const MODAL = 'added-to-basket';
61
+ const PAGE = { product: { pk: 101, name: 'Polo', in_stock: true } };
62
+
63
+ const addToCartBlock = (properties: Record<string, unknown> = {}): Block =>
64
+ ({
65
+ id: 'atc-1',
66
+ type: 'add-to-cart',
67
+ label: 'Add to Cart',
68
+ value: '',
69
+ order: 0,
70
+ hidden: false,
71
+ properties: {
72
+ actionType: 'open-modal',
73
+ actionTarget: MODAL,
74
+ actionAudience: 'all',
75
+ ...properties
76
+ },
77
+ styles: {}
78
+ } as unknown as Block);
79
+
80
+ const renderAddToCart = (
81
+ block: Block,
82
+ { isDesigner = false, hostModalId = '' } = {}
83
+ ) => {
84
+ const tree = (
85
+ <ThemePageContextProvider value={PAGE}>
86
+ <WithDesignerFeatures
87
+ block={block}
88
+ placeholderId="p"
89
+ sectionId="s"
90
+ isDesigner={isDesigner}
91
+ >
92
+ <AddToCartBlock
93
+ block={block}
94
+ placeholderId="p"
95
+ sectionId="s"
96
+ isDesigner={isDesigner}
97
+ />
98
+ </WithDesignerFeatures>
99
+ </ThemePageContextProvider>
100
+ );
101
+
102
+ const view = render(
103
+ hostModalId ? (
104
+ <ModalHostIdProvider value={hostModalId}>{tree}</ModalHostIdProvider>
105
+ ) : (
106
+ tree
107
+ )
108
+ );
109
+
110
+ return {
111
+ ...view,
112
+ button: view.container.querySelector(
113
+ '[data-add-to-cart]'
114
+ ) as HTMLButtonElement,
115
+ wrapper: view.container.querySelector(
116
+ '[data-block-id="atc-1"]'
117
+ ) as HTMLElement
118
+ };
119
+ };
120
+
121
+ /** The add request, held open until the test settles it. */
122
+ const pendingAdd = () => {
123
+ let resolve!: (value: unknown) => void;
124
+ let reject!: (reason: unknown) => void;
125
+ const promise = new Promise((res, rej) => {
126
+ resolve = res;
127
+ reject = rej;
128
+ });
129
+ // The handler awaits it; this only keeps an unawaited rejection quiet.
130
+ promise.catch(() => undefined);
131
+
132
+ mockAddProduct.mockImplementation(() => ({ unwrap: () => promise }));
133
+
134
+ return { resolve, reject };
135
+ };
136
+
137
+ /** Settle the request and let the click handler run to its end. */
138
+ const settle = async (finish: () => void) => {
139
+ await act(async () => {
140
+ finish();
141
+ await new Promise((done) => setTimeout(done, 0));
142
+ });
143
+ };
144
+
145
+ beforeEach(() => {
146
+ __resetOverlayBusForTests();
147
+ registerModalHost();
148
+ mockAddProduct.mockReset();
149
+ });
150
+
151
+ describe('add-to-cart with an "open modal" action', () => {
152
+ it('opens the modal only once the add has succeeded', async () => {
153
+ const request = pendingAdd();
154
+ const { button } = renderAddToCart(addToCartBlock());
155
+
156
+ act(() => {
157
+ fireEvent.click(button);
158
+ });
159
+
160
+ expect(mockAddProduct).toHaveBeenCalledTimes(1);
161
+ // The request is in flight: nothing is in the basket yet, so nothing may
162
+ // say it is.
163
+ expect(isModalOpen(MODAL)).toBe(false);
164
+
165
+ await settle(() => request.resolve({}));
166
+
167
+ expect(isModalOpen(MODAL)).toBe(true);
168
+ expect(button).toHaveAttribute('data-add-to-cart-state', 'added');
169
+ });
170
+
171
+ it('never opens it for an add that failed, and shows the error instead', async () => {
172
+ const request = pendingAdd();
173
+ const { button } = renderAddToCart(addToCartBlock());
174
+
175
+ act(() => {
176
+ fireEvent.click(button);
177
+ });
178
+ await settle(() => request.reject(new Error('out of stock')));
179
+
180
+ expect(isModalOpen(MODAL)).toBe(false);
181
+ expect(getOpenModalIds()).toEqual([]);
182
+ expect(button).toHaveAttribute('data-add-to-cart-state', 'error');
183
+ });
184
+
185
+ it('treats the quantity stepper as the stepper: no add, no modal', () => {
186
+ pendingAdd();
187
+ renderAddToCart(addToCartBlock({ showQuantitySelector: true }));
188
+
189
+ act(() => {
190
+ fireEvent.click(screen.getByLabelText('Increase quantity'));
191
+ });
192
+
193
+ // The click did land — the amount moved — it just is not an add.
194
+ expect(
195
+ document.querySelector('[data-part="quantity-input"]')
196
+ ).toHaveTextContent('2');
197
+ expect(mockAddProduct).not.toHaveBeenCalled();
198
+ expect(isModalOpen(MODAL)).toBe(false);
199
+ });
200
+
201
+ it('leaves Enter on the real button to the button', async () => {
202
+ const request = pendingAdd();
203
+ const { button } = renderAddToCart(addToCartBlock());
204
+
205
+ button.focus();
206
+ const notPrevented = fireEvent.keyDown(button, { key: 'Enter' });
207
+
208
+ // Cancelling this keydown is what used to swallow the add: the browser
209
+ // turns an uncancelled Enter on a <button> into its click.
210
+ expect(notPrevented).toBe(true);
211
+ expect(isModalOpen(MODAL)).toBe(false);
212
+
213
+ // jsdom does not synthesise that click, so play the browser's part.
214
+ act(() => {
215
+ fireEvent.click(button);
216
+ });
217
+ expect(isModalOpen(MODAL)).toBe(false);
218
+
219
+ await settle(() => request.resolve({}));
220
+
221
+ expect(mockAddProduct).toHaveBeenCalledTimes(1);
222
+ expect(isModalOpen(MODAL)).toBe(true);
223
+ });
224
+
225
+ it('does not turn the wrapper into a second control around the button', () => {
226
+ const { wrapper, button } = renderAddToCart(addToCartBlock());
227
+
228
+ expect(wrapper).not.toHaveAttribute('role');
229
+ expect(wrapper).not.toHaveAttribute('tabindex');
230
+ expect(wrapper).not.toHaveAttribute('aria-haspopup');
231
+ expect(wrapper).not.toHaveAttribute('aria-expanded');
232
+ // Nor does the button claim to open a dialog: pressing it adds a product,
233
+ // and only a successful add opens one.
234
+ expect(button).not.toHaveAttribute('aria-haspopup');
235
+ expect(button).not.toHaveAttribute('aria-expanded');
236
+ expect(screen.getAllByRole('button')).toEqual([button]);
237
+ });
238
+
239
+ it('neither adds nor opens in the designer, where a click selects', () => {
240
+ pendingAdd();
241
+ const { button } = renderAddToCart(addToCartBlock(), { isDesigner: true });
242
+
243
+ act(() => {
244
+ fireEvent.click(button);
245
+ });
246
+
247
+ expect(mockAddProduct).not.toHaveBeenCalled();
248
+ expect(isModalOpen(MODAL)).toBe(false);
249
+ });
250
+ });
251
+
252
+ describe('who the action runs for', () => {
253
+ const shopperContext = (authState: 'authenticated' | 'guest') => ({
254
+ authState,
255
+ breakpoint: 'desktop',
256
+ locale: 'tr',
257
+ currency: 'TRY',
258
+ pathname: '/product/101/',
259
+ searchParams: null
260
+ });
261
+
262
+ /** The block as the placeholder hands it to the renderer for this shopper. */
263
+ const resolvedFor = (block: Block, authState: 'authenticated' | 'guest') =>
264
+ applyVisibilityRulesToSections(
265
+ [
266
+ {
267
+ id: 's',
268
+ type: 'custom',
269
+ label: 'PDP',
270
+ order: 0,
271
+ hidden: false,
272
+ properties: {},
273
+ styles: {},
274
+ blocks: [block]
275
+ } as unknown as Section
276
+ ],
277
+ shopperContext(authState)
278
+ )[0].blocks[0];
279
+
280
+ const membersOnly = addToCartBlock({ actionAudience: 'authenticated' });
281
+
282
+ it('adds for a guest outside the audience, without opening the modal', async () => {
283
+ const request = pendingAdd();
284
+ const { button } = renderAddToCart(resolvedFor(membersOnly, 'guest'));
285
+
286
+ act(() => {
287
+ fireEvent.click(button);
288
+ });
289
+ await settle(() => request.resolve({}));
290
+
291
+ expect(mockAddProduct).toHaveBeenCalledTimes(1);
292
+ expect(button).toHaveAttribute('data-add-to-cart-state', 'added');
293
+ expect(isModalOpen(MODAL)).toBe(false);
294
+ });
295
+
296
+ it('opens it for a member, who is the audience', async () => {
297
+ const request = pendingAdd();
298
+ const { button } = renderAddToCart(
299
+ resolvedFor(membersOnly, 'authenticated')
300
+ );
301
+
302
+ act(() => {
303
+ fireEvent.click(button);
304
+ });
305
+ await settle(() => request.resolve({}));
306
+
307
+ expect(isModalOpen(MODAL)).toBe(true);
308
+ });
309
+ });
310
+
311
+ describe('an add-to-cart inside a quick-view modal', () => {
312
+ it('closes the quick view first, then opens the confirmation', async () => {
313
+ const request = pendingAdd();
314
+ const { button } = renderAddToCart(
315
+ addToCartBlock({ closeHostModalOnSuccess: true }),
316
+ { hostModalId: 'quick-view' }
317
+ );
318
+
319
+ act(() => {
320
+ openModal('quick-view');
321
+ });
322
+
323
+ const seen: string[][] = [];
324
+ subscribeToModals(() => seen.push([...getOpenModalIds()]));
325
+
326
+ act(() => {
327
+ fireEvent.click(button);
328
+ });
329
+ expect(isModalOpen('quick-view')).toBe(true);
330
+
331
+ await settle(() => request.resolve({}));
332
+
333
+ // One modal handing over to the next, never both at once.
334
+ expect(seen).toEqual([[], [MODAL]]);
335
+ });
336
+ });
337
+
338
+ describe('an add-to-cart inside a product card that is one big link', () => {
339
+ it.each([
340
+ ['with an action', addToCartBlock()],
341
+ ['without one', addToCartBlock({ actionType: 'none' })]
342
+ ])('keeps the shopper on the page while the add runs, %s', (_name, block) => {
343
+ pendingAdd();
344
+ const { container } = render(
345
+ <ThemePageContextProvider value={PAGE}>
346
+ <a href="/product/101/">
347
+ <WithDesignerFeatures
348
+ block={block}
349
+ placeholderId="p"
350
+ sectionId="s"
351
+ isDesigner={false}
352
+ >
353
+ <AddToCartBlock
354
+ block={block}
355
+ placeholderId="p"
356
+ sectionId="s"
357
+ isDesigner={false}
358
+ />
359
+ </WithDesignerFeatures>
360
+ </a>
361
+ </ThemePageContextProvider>
362
+ );
363
+ const button = container.querySelector(
364
+ '[data-add-to-cart]'
365
+ ) as HTMLButtonElement;
366
+
367
+ let notPrevented = true;
368
+ act(() => {
369
+ notPrevented = fireEvent.click(button);
370
+ });
371
+
372
+ // A cancelled click is what stops both a plain <a> and NextLink, which
373
+ // checks `defaultPrevented` before routing.
374
+ expect(notPrevented).toBe(false);
375
+ expect(mockAddProduct).toHaveBeenCalledTimes(1);
376
+ });
377
+ });
@@ -11,6 +11,7 @@ import {
11
11
  readItemsCurrency
12
12
  } from '../utils/analytics';
13
13
  import { useHostModalCloser } from '../overlays/modal-host-context';
14
+ import { useBlockAction } from '../hooks/use-block-action';
14
15
  import { BlockRendererProps } from './block-renderer-registry';
15
16
  import {
16
17
  DesignerPlaceholder,
@@ -85,6 +86,10 @@ const readAvailableStock = (product: ThemeProduct | null): number | null => {
85
86
  * added, the optional icon, the width and — through named parts — the styling
86
87
  * of the button in every state.
87
88
  *
89
+ * The shared action (`actionType` / `actionTarget`, e.g. "open the Added to
90
+ * basket modal") runs after a SUCCESSFUL add rather than on the click: a
91
+ * failed add, the quantity stepper and the designer canvas never run it.
92
+ *
88
93
  * Parts: button, label, icon, row, quantity, quantity-button, quantity-input.
89
94
  * Properties: label, addingLabel, addedLabel, errorLabel, outOfStockLabel,
90
95
  * fullWidth, quantity, showIcon, icon, iconSize, iconGap, iconPosition,
@@ -131,6 +136,14 @@ const AddToCartBlock = ({
131
136
  );
132
137
  const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
133
138
 
139
+ /**
140
+ * The authored action ("open the Added to basket modal"), which this block
141
+ * runs itself once the add succeeds — see `NATIVELY_INTERACTIVE_BLOCK_TYPES`.
142
+ * Only `run` is used: nothing is spread onto the button, because pressing it
143
+ * does not open a dialog; a successful add does. Called above the early
144
+ * returns below, as every hook here must be.
145
+ */
146
+ const action = useBlockAction(block, { currentBreakpoint, isDesigner });
134
147
 
135
148
  const label = prop('label', 'Add to Cart');
136
149
  const addingLabel = prop('addingLabel', 'Adding…');
@@ -285,10 +298,18 @@ const AddToCartBlock = ({
285
298
  );
286
299
  };
287
300
 
288
- const handleClick = async () => {
301
+ const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
302
+ // A product card is often one big link with this button inside it. The
303
+ // button has no default of its own (`type="button"`), so cancelling the
304
+ // click only stops the card from navigating away while the add is in
305
+ // flight — the wrapper used to do this by accident when it ran the action.
306
+ event.preventDefault();
307
+
289
308
  // A click inside the designer must never touch the shopper's real basket.
290
309
  if (isDesigner || disabled) return;
291
310
 
311
+ let added = false;
312
+
292
313
  try {
293
314
  await addProduct({
294
315
  product: productPk,
@@ -305,11 +326,18 @@ const AddToCartBlock = ({
305
326
  currency: readItemsCurrency(product),
306
327
  items: [buildCommerceItem(product, { quantity })]
307
328
  });
329
+ added = true;
308
330
  } catch {
309
331
  setFeedback('error');
310
332
  }
311
333
 
312
334
  scheduleReset();
335
+
336
+ // The authored action confirms the add, so a failed add never runs it. It
337
+ // runs after the host modal has closed, so a quick view can hand over to
338
+ // the "Added to basket" modal, and outside the try so an action that
339
+ // throws cannot relabel a product that IS in the basket as an error.
340
+ if (added) action.run();
313
341
  };
314
342
 
315
343
  const iconNode = showIcon ? (
@@ -0,0 +1,176 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render } from '@testing-library/react';
3
+
4
+ import { WithDesignerFeatures } from '../with-designer-features';
5
+ import {
6
+ __resetOverlayBusForTests,
7
+ isModalOpen,
8
+ registerModalHost
9
+ } from '../../overlays/overlay-bus';
10
+
11
+ /**
12
+ * The keyboard half of "an authored action makes any block a trigger".
13
+ *
14
+ * The wrapper claims `role="button"`, so Enter and Space on it must act — and
15
+ * it cancels them, or Space would scroll the page under the modal it opened.
16
+ * But keydown BUBBLES: a key pressed on a control inside the block reached the
17
+ * same handler and was cancelled too. Enter on a real button then opened the
18
+ * wrapper's modal instead of pressing the button, and a space could not be
19
+ * typed into a field. A key pressed on a native control inside the block is
20
+ * that control's; its own click still reaches the wrapper, exactly as a mouse
21
+ * click on it does.
22
+ */
23
+
24
+ const MODAL = 'campaign';
25
+
26
+ const blockOf = (type: string) => ({
27
+ id: 'blk-1',
28
+ type,
29
+ label: type,
30
+ properties: { actionType: 'open-modal', actionTarget: MODAL },
31
+ styles: {},
32
+ order: 0,
33
+ hidden: false
34
+ });
35
+
36
+ const renderBlock = (type: string, children: React.ReactNode) => {
37
+ const view = render(
38
+ <WithDesignerFeatures
39
+ block={blockOf(type)}
40
+ placeholderId="p"
41
+ sectionId="s"
42
+ isDesigner={false}
43
+ >
44
+ {children}
45
+ </WithDesignerFeatures>
46
+ );
47
+
48
+ return {
49
+ ...view,
50
+ wrapper: view.container.querySelector(
51
+ '[data-block-id="blk-1"]'
52
+ ) as HTMLElement
53
+ };
54
+ };
55
+
56
+ beforeEach(() => {
57
+ __resetOverlayBusForTests();
58
+ registerModalHost();
59
+ });
60
+
61
+ describe('a key pressed on the wrapper itself', () => {
62
+ it.each(['Enter', ' '])(
63
+ 'runs the action on %p and cancels the key',
64
+ (key) => {
65
+ const { wrapper } = renderBlock(
66
+ 'image',
67
+ // eslint-disable-next-line @next/next/no-img-element
68
+ <img src="/banner.jpg" alt="Sale" />
69
+ );
70
+
71
+ expect(wrapper).toHaveAttribute('role', 'button');
72
+ expect(wrapper).toHaveAttribute('tabindex', '0');
73
+
74
+ wrapper.focus();
75
+ let notPrevented = true;
76
+ act(() => {
77
+ notPrevented = fireEvent.keyDown(wrapper, { key });
78
+ });
79
+
80
+ expect(notPrevented).toBe(false);
81
+ expect(isModalOpen(MODAL)).toBe(true);
82
+ }
83
+ );
84
+
85
+ it('ignores every other key', () => {
86
+ const { wrapper } = renderBlock('text', <p>Free shipping</p>);
87
+
88
+ wrapper.focus();
89
+ const notPrevented = fireEvent.keyDown(wrapper, { key: 'Tab' });
90
+
91
+ expect(notPrevented).toBe(true);
92
+ expect(isModalOpen(MODAL)).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe('a key pressed on a control inside the block', () => {
97
+ const card = (
98
+ <div>
99
+ <span>Summer sale</span>
100
+ <a href="#details">Details</a>
101
+ <button type="button">Notify me</button>
102
+ <input aria-label="E-mail" />
103
+ </div>
104
+ );
105
+
106
+ it.each([
107
+ ['link', 'a'],
108
+ ['button', 'button']
109
+ ])('leaves Enter on a nested %s to that control', (_name, selector) => {
110
+ const { container } = renderBlock('group', card);
111
+ const control = container.querySelector(selector) as HTMLElement;
112
+
113
+ control.focus();
114
+ const notPrevented = fireEvent.keyDown(control, { key: 'Enter' });
115
+
116
+ // Uncancelled, the browser turns it into the control's own click, which
117
+ // then bubbles to the wrapper like a mouse click on the control would.
118
+ // The keydown alone runs nothing.
119
+ expect(notPrevented).toBe(true);
120
+ expect(isModalOpen(MODAL)).toBe(false);
121
+ });
122
+
123
+ it('lets a space be typed into a nested field', () => {
124
+ const { container } = renderBlock('group', card);
125
+ const field = container.querySelector('input') as HTMLInputElement;
126
+
127
+ field.focus();
128
+ const notPrevented = fireEvent.keyDown(field, { key: ' ' });
129
+
130
+ expect(notPrevented).toBe(true);
131
+ expect(isModalOpen(MODAL)).toBe(false);
132
+ });
133
+
134
+ it('still answers a mouse click on the content, as it always has', () => {
135
+ const { getByText } = renderBlock('group', card);
136
+
137
+ act(() => {
138
+ fireEvent.click(getByText('Summer sale'));
139
+ });
140
+
141
+ expect(isModalOpen(MODAL)).toBe(true);
142
+ });
143
+ });
144
+
145
+ describe('a trigger inside a trigger', () => {
146
+ it('answers Enter on the inner one the way it answers a click: both run', () => {
147
+ const inner = {
148
+ ...blockOf('image'),
149
+ id: 'blk-inner',
150
+ properties: { actionType: 'open-modal', actionTarget: 'size-guide' }
151
+ };
152
+ const { container } = renderBlock(
153
+ 'group',
154
+ <WithDesignerFeatures
155
+ block={inner}
156
+ placeholderId="p"
157
+ sectionId="s"
158
+ isDesigner={false}
159
+ >
160
+ {/* eslint-disable-next-line @next/next/no-img-element */}
161
+ <img src="/chart.jpg" alt="Size chart" />
162
+ </WithDesignerFeatures>
163
+ );
164
+ const innerWrapper = container.querySelector(
165
+ '[data-block-id="blk-inner"]'
166
+ ) as HTMLElement;
167
+
168
+ innerWrapper.focus();
169
+ act(() => {
170
+ fireEvent.keyDown(innerWrapper, { key: 'Enter' });
171
+ });
172
+
173
+ expect(isModalOpen('size-guide')).toBe(true);
174
+ expect(isModalOpen(MODAL)).toBe(true);
175
+ });
176
+ });
@@ -15,6 +15,11 @@ import {
15
15
  HOVER_SCOPE_ATTRIBUTE,
16
16
  REVEAL_ON_HOVER_ATTRIBUTE
17
17
  } from '../utils/property-switches';
18
+
19
+ /** Elements that already act on Enter/Space themselves. */
20
+ const NATIVE_KEYBOARD_CONTROLS =
21
+ 'a[href], button, input, select, textarea, summary, [contenteditable]:not([contenteditable="false"])';
22
+
18
23
  interface WithDesignerFeaturesProps {
19
24
  block: any;
20
25
  placeholderId: string;
@@ -94,9 +99,11 @@ export function WithDesignerFeatures({
94
99
  * only element in the chain that already exists, so a block that becomes
95
100
  * clickable gains no extra box and its layout does not move.
96
101
  *
97
- * `button` and `link` are excluded: they render a real `<button>`/`<a>` and
98
- * wire the action onto it themselves. Handling it here too would nest a
99
- * control inside a control, which is both invalid and double-fires.
102
+ * The `NATIVELY_INTERACTIVE_BLOCK_TYPES` are excluded because they run the
103
+ * action themselves: `button` and `link` wire it onto their real
104
+ * `<button>`/`<a>` (handling it here too would nest a control inside a
105
+ * control, which is both invalid and double-fires), and `add-to-cart` runs
106
+ * it once the add succeeds, which a click on this wrapper cannot know.
100
107
  */
101
108
  const action = useBlockAction(block, { currentBreakpoint, isDesigner });
102
109
  const ownsAction =
@@ -115,6 +122,21 @@ export function WithDesignerFeatures({
115
122
  */
116
123
  const handleRootKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
117
124
  if (!ownsAction) return;
125
+ // A key pressed on a native control inside the block — a real button,
126
+ // link or field — bubbles here too but belongs to that control: cancelling
127
+ // it swallowed the control's own activation (Enter on add-to-cart opened
128
+ // the modal and added nothing) and made a space impossible to type into a
129
+ // field. Left alone, the control's own click still bubbles up to
130
+ // `handleRootClick`, so the keyboard now does what the mouse does. A nested
131
+ // trigger wrapper is not a native control, so a key on it still reaches
132
+ // this one as well — again, as a click would.
133
+ if (
134
+ event.target !== event.currentTarget &&
135
+ event.target instanceof Element &&
136
+ event.target.matches(NATIVE_KEYBOARD_CONTROLS)
137
+ ) {
138
+ return;
139
+ }
118
140
  if (event.key !== 'Enter' && event.key !== ' ') return;
119
141
  // Space would scroll the page underneath the modal it just opened.
120
142
  event.preventDefault();
@@ -0,0 +1,199 @@
1
+ import React from 'react';
2
+ import {
3
+ act,
4
+ fireEvent,
5
+ render,
6
+ renderHook,
7
+ screen
8
+ } from '@testing-library/react';
9
+
10
+ import {
11
+ __resetOverlayBusForTests,
12
+ getOpenModalIds,
13
+ isModalOpen,
14
+ openModal,
15
+ registerModalHost
16
+ } from '../../overlays/overlay-bus';
17
+ import {
18
+ BlockActionType,
19
+ NATIVELY_INTERACTIVE_BLOCK_TYPES,
20
+ useBlockAction
21
+ } from '../use-block-action';
22
+
23
+ /**
24
+ * `run()`: the action without the click.
25
+ *
26
+ * The add-to-cart block's action confirms an add, so it runs when the request
27
+ * succeeds — long after the click event is gone. `run` is that path, and it
28
+ * must be exactly as inert as `onClick` where `onClick` is inert: in the
29
+ * designer, and on a block with no action (which is also what an audience the
30
+ * shopper is outside of turns an action into).
31
+ */
32
+
33
+ const mkBlock = (properties: Record<string, unknown>) => ({ properties });
34
+
35
+ const openAction = {
36
+ actionType: 'open-modal',
37
+ actionTarget: 'added-to-basket'
38
+ };
39
+
40
+ beforeEach(() => {
41
+ __resetOverlayBusForTests();
42
+ registerModalHost();
43
+ });
44
+
45
+ describe('run()', () => {
46
+ it('performs the action with no event', () => {
47
+ const { result } = renderHook(() => useBlockAction(mkBlock(openAction)));
48
+
49
+ act(() => {
50
+ result.current.run();
51
+ });
52
+
53
+ expect(isModalOpen('added-to-basket')).toBe(true);
54
+ });
55
+
56
+ it('performs the targetless ones too', () => {
57
+ openModal('a');
58
+ openModal('b');
59
+
60
+ const { result } = renderHook(() =>
61
+ useBlockAction(mkBlock({ actionType: 'close-top-modal' }))
62
+ );
63
+
64
+ act(() => {
65
+ result.current.run();
66
+ });
67
+
68
+ expect(getOpenModalIds()).toEqual(['a']);
69
+ });
70
+
71
+ it('does nothing in the designer', () => {
72
+ const { result } = renderHook(() =>
73
+ useBlockAction(mkBlock(openAction), { isDesigner: true })
74
+ );
75
+
76
+ act(() => {
77
+ result.current.run();
78
+ });
79
+
80
+ expect(isModalOpen('added-to-basket')).toBe(false);
81
+ });
82
+
83
+ it('is present and does nothing when there is no action', () => {
84
+ const cases = [
85
+ {},
86
+ { actionType: 'none', actionTarget: 'added-to-basket' },
87
+ // An action with nowhere to go is no action.
88
+ { actionType: 'open-modal' },
89
+ { actionType: 'link', actionTarget: '/cart' }
90
+ ];
91
+
92
+ cases.forEach((properties) => {
93
+ const { result } = renderHook(() => useBlockAction(mkBlock(properties)));
94
+
95
+ expect(result.current.isActive).toBe(false);
96
+ expect(typeof result.current.run).toBe('function');
97
+
98
+ act(() => {
99
+ result.current.run();
100
+ });
101
+ });
102
+
103
+ expect(getOpenModalIds()).toEqual([]);
104
+ });
105
+
106
+ it('keeps its identity across renders, so it can sit in a dependency list', () => {
107
+ const block = mkBlock(openAction);
108
+ const { result, rerender } = renderHook(() => useBlockAction(block));
109
+ const first = result.current.run;
110
+
111
+ rerender();
112
+
113
+ expect(result.current.run).toBe(first);
114
+ });
115
+ });
116
+
117
+ describe('onClick, now that it shares the performer', () => {
118
+ const Trigger = ({
119
+ properties,
120
+ isDesigner = false
121
+ }: {
122
+ properties: Record<string, unknown>;
123
+ isDesigner?: boolean;
124
+ }) => {
125
+ const action = useBlockAction(mkBlock(properties), { isDesigner });
126
+
127
+ return (
128
+ <a href="#elsewhere" onClick={action.onClick}>
129
+ trigger
130
+ </a>
131
+ );
132
+ };
133
+
134
+ /** false when the click's default (the navigation) was cancelled. */
135
+ const clickIsLetThrough = () => fireEvent.click(screen.getByText('trigger'));
136
+
137
+ const interactive: Array<[BlockActionType, string]> = [
138
+ ['open-modal', 'm'],
139
+ ['close-modal', 'm'],
140
+ ['toggle-modal', 'm'],
141
+ ['close-top-modal', ''],
142
+ ['scroll-to', 'reviews'],
143
+ ['auth-step', 'login'],
144
+ ['auth-back', ''],
145
+ ['otp-resend', ''],
146
+ ['otp-change-number', '']
147
+ ];
148
+
149
+ it.each(interactive)(
150
+ 'cancels the default for %s, as before',
151
+ (actionType, actionTarget) => {
152
+ render(<Trigger properties={{ actionType, actionTarget }} />);
153
+
154
+ // A trigger that also navigated would race what it just started.
155
+ expect(clickIsLetThrough()).toBe(false);
156
+ }
157
+ );
158
+
159
+ it.each([
160
+ ['none', ''],
161
+ ['link', '/elsewhere']
162
+ ])('lets the navigation through for %s', (actionType, actionTarget) => {
163
+ render(<Trigger properties={{ actionType, actionTarget }} />);
164
+
165
+ expect(clickIsLetThrough()).toBe(true);
166
+ });
167
+
168
+ it('cancels nothing in the designer, where the wrapper owns the click', () => {
169
+ render(
170
+ <Trigger
171
+ properties={{ actionType: 'open-modal', actionTarget: 'm' }}
172
+ isDesigner
173
+ />
174
+ );
175
+
176
+ expect(clickIsLetThrough()).toBe(true);
177
+ expect(isModalOpen('m')).toBe(false);
178
+ });
179
+
180
+ it('still performs the action it cancelled the default for', () => {
181
+ render(
182
+ <Trigger properties={{ actionType: 'open-modal', actionTarget: 'm' }} />
183
+ );
184
+
185
+ clickIsLetThrough();
186
+
187
+ expect(isModalOpen('m')).toBe(true);
188
+ });
189
+ });
190
+
191
+ describe('NATIVELY_INTERACTIVE_BLOCK_TYPES', () => {
192
+ it('holds the blocks that run their own action, add-to-cart included', () => {
193
+ expect(Array.from(NATIVELY_INTERACTIVE_BLOCK_TYPES).sort()).toEqual([
194
+ 'add-to-cart',
195
+ 'button',
196
+ 'link'
197
+ ]);
198
+ });
199
+ });
@@ -17,7 +17,7 @@ import {
17
17
  resolveAuthStepTarget,
18
18
  setAuthStep
19
19
  } from '../auth/auth-flow-bus';
20
- import { useOtpForm } from '../auth/otp-form-context';
20
+ import { OtpFormApi, useOtpForm } from '../auth/otp-form-context';
21
21
 
22
22
  /**
23
23
  * What a block does when it is clicked.
@@ -67,17 +67,23 @@ const MODAL_ACTIONS: BlockActionType[] = [
67
67
  ];
68
68
 
69
69
  /**
70
- * Blocks that render a real `<button>` or `<a>` and therefore wire the action
71
- * onto that element themselves.
70
+ * Blocks that run their authored action themselves, so the generic path in
71
+ * `WithDesignerFeatures` must leave it alone. Two reasons put a block here:
72
72
  *
73
- * The generic path in `WithDesignerFeatures` must skip them: a wrapper
74
- * claiming `role="button"` around a real button nests one control inside
75
- * another, which is invalid markup, announces twice, and fires the action
76
- * twice on one click.
73
+ * - It renders a real `<button>` or `<a>` and wires the action onto that
74
+ * element (`button`, `link`). A wrapper claiming `role="button"` around a
75
+ * real button nests one control inside another, which is invalid markup,
76
+ * announces twice, and fires the action twice on one click.
77
+ * - Its action is the CONFIRMATION of something asynchronous it does itself
78
+ * (`add-to-cart`). "Open the Added to basket modal" means "once the product
79
+ * is in the basket", so it runs when the add succeeds, not on the click. Run
80
+ * by the wrapper it fired before the request returned — the modal opened on
81
+ * a failed add, and on the quantity stepper's own +/- buttons too.
77
82
  */
78
83
  export const NATIVELY_INTERACTIVE_BLOCK_TYPES: ReadonlySet<string> = new Set([
79
84
  'button',
80
- 'link'
85
+ 'link',
86
+ 'add-to-cart'
81
87
  ]);
82
88
 
83
89
  export interface BlockActionSource {
@@ -119,6 +125,14 @@ export interface UseBlockActionResult {
119
125
  isActive: boolean;
120
126
  /** Attach to the clickable element. `undefined` when there is no action. */
121
127
  onClick?: (event: React.MouseEvent<HTMLElement>) => void;
128
+ /**
129
+ * Perform the action with no event in hand, for a block whose action
130
+ * confirms something it did itself rather than answering a click — the
131
+ * add-to-cart block runs it once the add has succeeded. Always present and
132
+ * inert by the same rule as `onClick` (in the designer, or when the block has
133
+ * no action), so a caller can fire it unconditionally.
134
+ */
135
+ run: () => void;
122
136
  /**
123
137
  * Spread onto the same element. Announces a dialog trigger as one, which is
124
138
  * what turns a styled div into something a screen reader user can act on
@@ -143,6 +157,65 @@ const scrollToAnchor = (target: string) => {
143
157
  element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
144
158
  };
145
159
 
160
+ /** What an action reaches that only the rendering tree can supply. */
161
+ export interface BlockActionScope {
162
+ /** The auth flow a trigger inside an `auth-flow` block belongs to. */
163
+ contextFlowId?: string;
164
+ /** The OTP form a trigger stands inside, for resend / change number. */
165
+ otpForm?: OtpFormApi | null;
166
+ }
167
+
168
+ /**
169
+ * Perform an authored action — the part of a click that is not the click.
170
+ *
171
+ * Split from the handler because not every action answers a click: the
172
+ * add-to-cart block's action is the confirmation of an add, so it runs when
173
+ * the request succeeds, long after the click event is gone. Both paths call
174
+ * this, so an action behaves the same whichever way it was reached.
175
+ */
176
+ export const performBlockAction = (
177
+ { type, target }: BlockAction,
178
+ { contextFlowId = '', otpForm = null }: BlockActionScope = {}
179
+ ): void => {
180
+ switch (type) {
181
+ case 'open-modal':
182
+ openModal(target);
183
+ break;
184
+ case 'close-modal':
185
+ closeModal(target);
186
+ break;
187
+ case 'toggle-modal':
188
+ toggleModal(target);
189
+ break;
190
+ case 'close-top-modal':
191
+ closeTopModal();
192
+ break;
193
+ case 'scroll-to':
194
+ scrollToAnchor(target);
195
+ break;
196
+ case 'auth-step': {
197
+ const resolved = resolveAuthStepTarget(target, contextFlowId);
198
+ if (resolved) setAuthStep(resolved.flowId, resolved.stepKey);
199
+ break;
200
+ }
201
+ case 'otp-resend':
202
+ // No-ops while the countdown runs — the form owns that rule, so a
203
+ // resend placed anywhere obeys the same one.
204
+ otpForm?.resend();
205
+ break;
206
+ case 'otp-change-number':
207
+ otpForm?.changeNumber();
208
+ break;
209
+ case 'auth-back':
210
+ // The optional target names a flow; without one the nearest (or
211
+ // default) flow is walked back.
212
+ goBackAuthStep(target || contextFlowId || DEFAULT_AUTH_FLOW_ID);
213
+ break;
214
+ default:
215
+ break;
216
+ }
217
+ };
218
+
146
219
  /**
147
220
  * Wire a block's authored action to the overlay bus.
148
221
  *
@@ -183,54 +256,12 @@ export const useBlockAction = (
183
256
  (event: React.MouseEvent<HTMLElement>) => {
184
257
  if (isDesigner) return;
185
258
 
186
- switch (type) {
187
- case 'open-modal':
188
- // A trigger that also navigates would race its own modal.
189
- event.preventDefault();
190
- openModal(target);
191
- break;
192
- case 'close-modal':
193
- event.preventDefault();
194
- closeModal(target);
195
- break;
196
- case 'toggle-modal':
197
- event.preventDefault();
198
- toggleModal(target);
199
- break;
200
- case 'close-top-modal':
201
- event.preventDefault();
202
- closeTopModal();
203
- break;
204
- case 'scroll-to':
205
- event.preventDefault();
206
- scrollToAnchor(target);
207
- break;
208
- case 'auth-step': {
209
- // A trigger that also navigates would race the step it opens.
210
- event.preventDefault();
211
- const resolved = resolveAuthStepTarget(target, contextFlowId);
212
- if (resolved) setAuthStep(resolved.flowId, resolved.stepKey);
213
- break;
214
- }
215
- case 'otp-resend':
216
- event.preventDefault();
217
- // No-ops while the countdown runs — the form owns that rule, so a
218
- // resend placed anywhere obeys the same one.
219
- otpForm?.resend();
220
- break;
221
- case 'otp-change-number':
222
- event.preventDefault();
223
- otpForm?.changeNumber();
224
- break;
225
- case 'auth-back':
226
- event.preventDefault();
227
- // The optional target names a flow; without one the nearest (or
228
- // default) flow is walked back.
229
- goBackAuthStep(target || contextFlowId || DEFAULT_AUTH_FLOW_ID);
230
- break;
231
- default:
232
- break;
233
- }
259
+ // Every action this hook performs cancels the element's default, and
260
+ // `none`/`link` keep it: a trigger that also navigates would race the
261
+ // modal, step or scroll it just started.
262
+ if (isInteractiveAction(type)) event.preventDefault();
263
+
264
+ performBlockAction({ type, target }, { contextFlowId, otpForm });
234
265
  },
235
266
  [isDesigner, type, target, contextFlowId, otpForm]
236
267
  );
@@ -247,8 +278,17 @@ export const useBlockAction = (
247
278
  isInteractiveAction(type) &&
248
279
  (TARGETLESS_ACTIONS.includes(type) || Boolean(target));
249
280
 
281
+ // Called before the early return below — a hook cannot sit on a branch — and
282
+ // guarded by the same two conditions `onClick` is, so the designer canvas and
283
+ // a block with no action stay inert whichever way the action is reached.
284
+ const run = useCallback(() => {
285
+ if (!isActive || isDesigner) return;
286
+
287
+ performBlockAction({ type, target }, { contextFlowId, otpForm });
288
+ }, [isActive, isDesigner, type, target, contextFlowId, otpForm]);
289
+
250
290
  if (!isActive) {
251
- return { isActive: false, ariaProps: {} };
291
+ return { isActive: false, run, ariaProps: {} };
252
292
  }
253
293
 
254
294
  const opensDialog = type === 'open-modal' || type === 'toggle-modal';
@@ -269,6 +309,7 @@ export const useBlockAction = (
269
309
  return {
270
310
  isActive: true,
271
311
  onClick: handleClick,
312
+ run,
272
313
  ariaProps: { 'aria-disabled': true }
273
314
  };
274
315
  }
@@ -276,6 +317,7 @@ export const useBlockAction = (
276
317
  return {
277
318
  isActive: true,
278
319
  onClick: handleClick,
320
+ run,
279
321
  ariaProps: opensDialog
280
322
  ? {
281
323
  'aria-haspopup': 'dialog',
@@ -1,5 +1,9 @@
1
1
  import { getCatalogVendor, getCustomCatalog } from './custom/catalog';
2
2
  import { getCustomSectionCatalog } from './custom/section-catalog';
3
+ import {
4
+ ACTION_CAPABILITIES,
5
+ type ActionCapability
6
+ } from './action-capabilities';
3
7
  import {
4
8
  STATE_PIN_CAPABILITIES,
5
9
  type StatePinCapability
@@ -72,6 +76,22 @@ interface PlaceholdersAvailablePayload {
72
76
  * listing it would make an old storefront look LESS capable than it is.
73
77
  */
74
78
  statePins?: readonly StatePinCapability[];
79
+
80
+ /**
81
+ * What THIS build's block renderer does with an authored action beyond
82
+ * running it on the click, so the editor can word the action panel after
83
+ * the storefront in front of it (see `action-capabilities.ts`).
84
+ *
85
+ * Additive and by name, on the same terms as `statePins`, and read the same
86
+ * way: a build that does not send it is taken to do none of it, which is the
87
+ * truth for every renderer shipped before the field existed.
88
+ *
89
+ * Unlike `statePins` it rides EVERY frame. The editor replaces what it holds
90
+ * with each frame, so a frame without it would put the panel back to "On
91
+ * Click" — and the only frame some documents ever send is the one that went
92
+ * out before the designer flag arrived (see `handleEditorMessage`).
93
+ */
94
+ actionCapabilities?: readonly ActionCapability[];
75
95
  }
76
96
 
77
97
  const isFramed = (): boolean =>
@@ -105,7 +125,14 @@ const flush = () => {
105
125
  if (!isFramed()) return;
106
126
 
107
127
  const data: PlaceholdersAvailablePayload = {
108
- placeholders: Array.from(placeholderRegistry)
128
+ placeholders: Array.from(placeholderRegistry),
129
+ // Outside the designer gate below, on purpose. It says what this theme
130
+ // version does, not what this brand built — nothing a framing payment
131
+ // provider could not read off the bundle — so there is nothing to
132
+ // withhold. Gated, it would be lost for the life of the document on a slow
133
+ // handshake: the designer re-announce only fires for a build that
134
+ // registers a custom catalog, and most brands register none.
135
+ actionCapabilities: ACTION_CAPABILITIES
109
136
  };
110
137
 
111
138
  // Being framed is NOT enough to hand the catalog over. A storefront is