@designcrowd/library.brand-page 6.1.17-ast-header-button → 6.1.17-ast-header-button-2

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.
Files changed (63) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-Cwq9enJs.js → CreatedOnBrandCrowd-de-de-CGQppEEP.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-ILK04gFj.js → CreatedOnBrandCrowd-en-us-Do8u4cNA.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-CbjxLxtu.js → CreatedOnBrandCrowd-es-es-Cw9i-wIi.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-BwBDiK9z.js → CreatedOnBrandCrowd-fr-ca-DK9cVARn.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-BwBDiK9z.js → CreatedOnBrandCrowd-fr-fr-DK9cVARn.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-DvhJROpG.js → CreatedOnBrandCrowd-pt-br-CKvle-Uo.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-DvhJROpG.js → CreatedOnBrandCrowd-pt-pt-CKvle-Uo.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-BK9imWOh.js → CreatedOnDcom-de-de-DZGQbS8x.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-CyH-_qz1.js → CreatedOnDcom-en-us-CAGyRg9Z.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-Dh51qYEA.js → CreatedOnDcom-es-es-B_Xbs4CX.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-BArRxwWX.js → CreatedOnDcom-fr-ca-B_Af6mgX.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-CdglI-ZB.js → CreatedOnDcom-fr-fr-Dn8OKUFf.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-DCehns-G.js → CreatedOnDcom-pt-br-DFsESCzy.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-B8hl-oew.js → CreatedOnDcom-pt-pt-CjbOJw5o.js} +1 -1
  15. package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.d.ts +0 -11
  16. package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.js +6 -11
  17. package/lib/src/brand-page/components/page/Page.mixin.d.ts +2 -1
  18. package/lib/src/brand-page/components/page/Page.mixin.js +11 -1
  19. package/lib/src/brand-page/components/page-header/HeaderLayout.stories.d.ts +1 -0
  20. package/lib/src/brand-page/components/page-header/HeaderLayout.stories.js +9 -0
  21. package/lib/src/brand-page/components/page-header/PageHeader.mixin.js +3 -3
  22. package/lib/src/brand-page/constants/sections/header-display.constants.d.ts +8 -0
  23. package/lib/src/brand-page/constants/sections/header-display.constants.js +11 -0
  24. package/lib/src/brand-page/models/index.d.ts +4 -1
  25. package/lib/src/brand-page/models/index.js +5 -3
  26. package/lib/src/brand-page/utils/view-model.util.d.ts +0 -1
  27. package/lib/src/brand-page/utils/view-model.util.js +5 -5
  28. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +1 -0
  29. package/lib/src/bundled-translations-Cs7jWCj8.js +181 -0
  30. package/lib/src/bundles/bundled-translations.json +3 -0
  31. package/lib/src/css/library.brand-page-brandCrowd.min.css +216 -159
  32. package/lib/src/css/library.brand-page-brandPage.min.css +216 -159
  33. package/lib/src/css/library.brand-page-designCom.min.css +216 -159
  34. package/lib/src/css/library.brand-page-preview.min.css +216 -159
  35. package/lib/src/index.mjs +1 -1
  36. package/lib/src/{lib-KFjPFfxn.js → lib-BFusPgV2.js} +12063 -12041
  37. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.js +4 -4
  38. package/package.json +1 -1
  39. package/src/brand-page/components/navigation/hamburger/HamburgerNavigation.vue +1 -0
  40. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.ts +6 -11
  41. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue +35 -3
  42. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue.test.ts +23 -17
  43. package/src/brand-page/components/page/Page.mixin.ts +17 -0
  44. package/src/brand-page/components/page/Page.vue +2 -6
  45. package/src/brand-page/components/page-header/HeaderLayout.stories.ts +10 -0
  46. package/src/brand-page/components/page-header/HeaderLayout.vue +35 -35
  47. package/src/brand-page/components/page-header/PageHeader.mixin.ts +3 -3
  48. package/src/brand-page/components/page-header/PageHeader.vue.test.ts +9 -3
  49. package/src/brand-page/components/page-header/i18n/page-header.json +5 -0
  50. package/src/brand-page/constants/sections/header-display.constants.test.ts +45 -0
  51. package/src/brand-page/constants/sections/header-display.constants.ts +19 -0
  52. package/src/brand-page/models/index.ts +10 -3
  53. package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +0 -6
  54. package/src/brand-page/utils/__snapshots__/migration2.util.test.ts.snap +0 -2
  55. package/src/brand-page/utils/__snapshots__/model.util.test.ts.snap +0 -4
  56. package/src/brand-page/utils/view-model.util.test.ts +7 -2
  57. package/src/brand-page/utils/view-model.util.ts +6 -6
  58. package/src/bundles/bundled-translations.json +3 -0
  59. package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.button-section.test.ts +25 -14
  60. package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.vue +38 -26
  61. package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.vue +34 -9
  62. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.ts +4 -4
  63. package/lib/src/bundled-translations-B8fAEel0.js +0 -179
@@ -349,10 +349,10 @@ export default defineComponent({
349
349
  ...this.headerButton,
350
350
  ...patch,
351
351
  };
352
- // Desktop and mobile must hold the SAME button config (BP-5852 AC). The
353
- // whole object is written to both from one source rather than merged
354
- // per-viewport, so a pair that somehow diverged is reconciled instead of
355
- // each side keeping its own leftovers.
352
+ // Desktop and mobile must hold the SAME button config. The whole object
353
+ // is written to both from one source rather than merged per-viewport, so
354
+ // a pair that somehow diverged is reconciled instead of each side keeping
355
+ // its own leftovers.
356
356
  //
357
357
  // Each viewport gets its own copy: one shared object would alias the two
358
358
  // branches of store state to a single instance.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designcrowd/library.brand-page",
3
3
  "description": "A collection of Maker/Designer applications",
4
- "version": "6.1.17-ast-header-button",
4
+ "version": "6.1.17-ast-header-button-2",
5
5
  "author": "Design.com Eng",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
@@ -2,6 +2,7 @@
2
2
  <div data-component="hamburger-navigation">
3
3
  <div class="tw-w-full tw-flex tw-justify-end">
4
4
  <button
5
+ data-test-hamburger-toggle
5
6
  :style="iconStyle"
6
7
  class="tw-cursor-pointer tw-p-2"
7
8
  :class="{
@@ -26,18 +26,13 @@ export default defineComponent({
26
26
  type: Object as PropType<NavigationDesktop | NavigationMobile>,
27
27
  required: true,
28
28
  },
29
+ // Provided only when the header row does not render the CTA (the caller
30
+ // decides via headerRowRendersCta) — absent means the drawer shows none.
29
31
  headerButtonViewModel: {
30
32
  type: Object as PropType<ButtonViewModel>,
31
33
  required: false,
32
34
  default: undefined,
33
35
  },
34
- // The desktop drawer suppresses the header CTA — the desktop header row already
35
- // renders it. Only the mobile drawer carries the button.
36
- isMobile: {
37
- type: Boolean,
38
- required: false,
39
- default: false,
40
- },
41
36
  },
42
37
  emits: ['close-side-menu'],
43
38
  data() {
@@ -87,7 +82,7 @@ export default defineComponent({
87
82
  return rest;
88
83
  },
89
84
  showHeaderButton(): boolean {
90
- return this.isMobile && Boolean(this.headerButtonViewModel);
85
+ return Boolean(this.headerButtonViewModel);
91
86
  },
92
87
  routes(): any[] {
93
88
  return this.visibleNavItems.map((navItem, _i) => {
@@ -129,9 +124,9 @@ export default defineComponent({
129
124
  closeSideMenu(): void {
130
125
  this.$emit('close-side-menu');
131
126
  },
132
- // In the maker preview the CTA is inert (BP-5852 decision): capture-phase so the
133
- // prevent lands before the inner anchor/router-link fires. On the published site
134
- // the click follows the link and the drawer closes behind it.
127
+ // In the maker preview the CTA is inert (see docs/decisions/0019): capture-phase
128
+ // so the prevent lands before the inner anchor/router-link fires. On the
129
+ // published site the click follows the link and the drawer closes behind it.
135
130
  onDrawerCtaClick(event: MouseEvent): void {
136
131
  if (this.context.isPreview) {
137
132
  event.preventDefault();
@@ -9,15 +9,19 @@
9
9
  }"
10
10
  @click="closeSideMenu"
11
11
  />
12
+ <!-- Without a CTA the panel itself scrolls, as it always has. With one, the nav
13
+ list takes over the scrolling so the pinned button never scrolls away. -->
12
14
  <div
13
15
  class="c-sidebar"
14
16
  :class="{
15
- 'c-sidebar--open tw-overflow-auto': isSideMenuOpen,
17
+ 'c-sidebar--open': isSideMenuOpen,
16
18
  'c-sidebar--closed': !isSideMenuOpen,
19
+ 'tw-overflow-auto': isSideMenuOpen && !showHeaderButton,
20
+ 'c-sidebar--has-cta': showHeaderButton,
17
21
  }"
18
22
  :style="defaultButtonStyle"
19
23
  >
20
- <div class="tw-w-full tw-flex tw-justify-end">
24
+ <div class="c-drawer-header tw-w-full tw-flex tw-justify-end">
21
25
  <button
22
26
  class="tw-mt-4 tw-w-8 tw-h-8 tw-bg-transparent tw-border-none tw-cursor-pointer"
23
27
  :style="closeButtonStyle"
@@ -27,7 +31,7 @@
27
31
  <Icon name="close" size="sm" />
28
32
  </button>
29
33
  </div>
30
- <div class="tw-flex tw-flex-col">
34
+ <div class="c-nav-list tw-flex tw-flex-col">
31
35
  <template
32
36
  v-for="(navItem, index) in visibleNavItems"
33
37
  :key="`${navItem.relativePath}-${index}`"
@@ -166,10 +170,38 @@ export default {
166
170
  transform: rotate(180deg);
167
171
  }
168
172
 
173
+ /* Design: the drawer CTA is pinned to the bottom of the panel and stays put while a
174
+ long link list scrolls. Every rule here is scoped to --has-cta, so a drawer without
175
+ the button keeps its original block flow and panel-level scrolling. */
176
+ .c-sidebar--has-cta {
177
+ @apply tw-flex tw-flex-col;
178
+ overflow: hidden;
179
+ }
180
+
181
+ .c-sidebar--has-cta .c-drawer-header {
182
+ flex-shrink: 0;
183
+ }
184
+
185
+ .c-sidebar--has-cta .c-nav-list {
186
+ /* min-height:0 is what lets the list shrink below its content height, so the list —
187
+ not the panel — is what scrolls once there are more links than fit. */
188
+ min-height: 0;
189
+ overflow-y: auto;
190
+ }
191
+
169
192
  .c-drawer-cta {
170
193
  @apply tw-relative tw-p-4;
171
194
  }
172
195
 
196
+ .c-sidebar--has-cta .c-drawer-cta {
197
+ /* Pushes the button to the bottom when the links don't fill the panel; the nav
198
+ list's own scrolling keeps it there when they overflow. */
199
+ margin-top: auto;
200
+ flex-shrink: 0;
201
+ /* Opaque against the panel so scrolled links don't show through. */
202
+ background-color: inherit;
203
+ }
204
+
173
205
  /* Hairline separator from the nav list, drawn in the drawer's own text colour so it
174
206
  works on any panel background. A pseudo-element carries the fade — opacity on the
175
207
  section itself would wash out the button. */
@@ -21,7 +21,6 @@ const createContext = (): BrandPageContext =>
21
21
  ({
22
22
  brandPageSlug: SLUG,
23
23
  brandPageToken: '',
24
- isMobile: false,
25
24
  isPreview: false,
26
25
  isProduction: false,
27
26
  isServerRender: false,
@@ -67,7 +66,6 @@ const aHeaderButton = (): HeaderButton => ({
67
66
  });
68
67
 
69
68
  const mountDrawer = async (options: {
70
- isMobile?: boolean;
71
69
  headerButton?: HeaderButton;
72
70
  isPreview?: boolean;
73
71
  }) => {
@@ -104,7 +102,6 @@ const mountDrawer = async (options: {
104
102
  slug: SLUG,
105
103
  styles: headerViewModel.navigation,
106
104
  headerButtonViewModel: headerViewModel.headerButtonViewModel,
107
- isMobile: options.isMobile ?? false,
108
105
  },
109
106
  global: { plugins: [router] },
110
107
  });
@@ -117,7 +114,6 @@ describe('NavigationSideMenu - header call-to-action button (BP-5852)', () => {
117
114
 
118
115
  test('renders the CTA in the mobile drawer', async () => {
119
116
  const wrapper = await mountDrawer({
120
- isMobile: true,
121
117
  headerButton: aHeaderButton(),
122
118
  });
123
119
 
@@ -127,29 +123,40 @@ describe('NavigationSideMenu - header call-to-action button (BP-5852)', () => {
127
123
  expect(wrapper.get(CTA).text()).toContain('Book now');
128
124
  });
129
125
 
130
- // The desktop hamburger header row already renders the button, so the desktop
131
- // drawer must not repeat it.
132
- test('renders no CTA in the desktop drawer', async () => {
126
+ test('renders no CTA when the site has no header button', async () => {
127
+ const wrapper = await mountDrawer({});
128
+
129
+ expect(wrapper.find(CTA).exists()).toBe(false);
130
+ expect(
131
+ wrapper.find('[data-component="navigation-side-menu-cta"]').exists(),
132
+ ).toBe(false);
133
+ });
134
+
135
+ test('with a CTA the panel hands scrolling to the nav list and pins the button last', async () => {
133
136
  const wrapper = await mountDrawer({
134
- isMobile: false,
135
137
  headerButton: aHeaderButton(),
136
138
  });
137
139
 
138
- expect(wrapper.find(CTA).exists()).toBe(false);
140
+ const sidebar = wrapper.get('.c-sidebar');
141
+
142
+ expect(sidebar.classes()).toContain('c-sidebar--has-cta');
143
+ expect(sidebar.classes()).not.toContain('tw-overflow-auto');
144
+ expect(sidebar.element.lastElementChild).toBe(
145
+ wrapper.get('.c-drawer-cta').element,
146
+ );
139
147
  });
140
148
 
141
- test('renders no CTA when the site has no header button', async () => {
142
- const wrapper = await mountDrawer({ isMobile: true });
149
+ test('without a CTA the panel keeps its original scrolling and no layout class', async () => {
150
+ const wrapper = await mountDrawer({});
143
151
 
144
- expect(wrapper.find(CTA).exists()).toBe(false);
145
- expect(
146
- wrapper.find('[data-component="navigation-side-menu-cta"]').exists(),
147
- ).toBe(false);
152
+ const sidebar = wrapper.get('.c-sidebar');
153
+
154
+ expect(sidebar.classes()).toContain('tw-overflow-auto');
155
+ expect(sidebar.classes()).not.toContain('c-sidebar--has-cta');
148
156
  });
149
157
 
150
158
  test('closes the drawer when the CTA is clicked', async () => {
151
159
  const wrapper = await mountDrawer({
152
- isMobile: true,
153
160
  headerButton: aHeaderButton(),
154
161
  });
155
162
 
@@ -160,7 +167,6 @@ describe('NavigationSideMenu - header call-to-action button (BP-5852)', () => {
160
167
 
161
168
  test('a preview CTA click is inert: no navigation, drawer stays open', async () => {
162
169
  const wrapper = await mountDrawer({
163
- isMobile: true,
164
170
  headerButton: aHeaderButton(),
165
171
  isPreview: true,
166
172
  });
@@ -2,6 +2,7 @@ import type { PropType } from 'vue';
2
2
  import { defineComponent } from 'vue';
3
3
  import type {
4
4
  BrandPageViewModel,
5
+ ButtonViewModel,
5
6
  ContentViewModel,
6
7
  PageViewModel,
7
8
  } from '@brand-page/view-models';
@@ -14,6 +15,7 @@ import {
14
15
  DEFAULT_FONT_FAMILY_KEY,
15
16
  FALLBACK_FONT_FAMILIES,
16
17
  FONT_FAMILY_CONFIGS,
18
+ headerRowRendersCta,
17
19
  } from '../../constants';
18
20
  import {
19
21
  getSlugFromContext,
@@ -370,6 +372,21 @@ export default defineComponent({
370
372
  );
371
373
  },
372
374
 
375
+ // The mobile drawer carries the header CTA exactly when the header row does
376
+ // not — one shared predicate so the two can never render it twice.
377
+ drawerHeaderButtonViewModel(): ButtonViewModel | undefined {
378
+ const header = this.viewModel.headerViewModelMobile;
379
+
380
+ if (
381
+ !header.headerButtonViewModel ||
382
+ headerRowRendersCta(header.displayStyle, this.shouldShowNavigation)
383
+ ) {
384
+ return undefined;
385
+ }
386
+
387
+ return header.headerButtonViewModel;
388
+ },
389
+
373
390
  shouldShowNavigation(): boolean {
374
391
  // Show navigation if more than 1 non-hidden page exists, or if there's 1 page with non-hidden subpages
375
392
  const visibleNavItems = this.context.nav.navItems.filter(
@@ -296,8 +296,7 @@
296
296
  :page-navigation="viewModel.nav"
297
297
  @close-side-menu="closeSideMenu"
298
298
  />
299
- <!-- No header CTA here on purpose: the desktop hamburger header row
300
- already renders it (BP-5852 placement spec). -->
299
+ <!-- No header CTA: the desktop hamburger header row renders it. -->
301
300
  </template>
302
301
  <template #mobile>
303
302
  <NavigationSideMenu
@@ -306,10 +305,7 @@
306
305
  :slug="slug"
307
306
  :styles="viewModel.headerViewModelMobile.navigation"
308
307
  :page-navigation="viewModel.nav"
309
- :header-button-view-model="
310
- viewModel.headerViewModelMobile.headerButtonViewModel
311
- "
312
- :is-mobile="true"
308
+ :header-button-view-model="drawerHeaderButtonViewModel"
313
309
  @close-side-menu="closeSideMenu"
314
310
  />
315
311
  </template>
@@ -43,6 +43,7 @@ const Template: StoryFn = (args) => ({
43
43
  nav-id="nav-1"
44
44
  :show-nav="args.showNav"
45
45
  :display-style="args.displayStyle"
46
+ :has-cta="args.hasCta"
46
47
  logo-width="100px"
47
48
  >
48
49
  <template v-slot:logo>
@@ -51,6 +52,9 @@ const Template: StoryFn = (args) => ({
51
52
  <template v-slot:nav>
52
53
  <div style="border: 1px solid red; display: inline-block;">Nav | Nav | Nav | Nav | Nav | Nav | Nav | Nav</div>
53
54
  </template>
55
+ <template v-slot:cta>
56
+ <div style="border: 1px solid green; display: inline-block; padding: 8px 16px;">Contact Us</div>
57
+ </template>
54
58
  </HeaderLayout>
55
59
  </div>
56
60
  `,
@@ -60,3 +64,9 @@ export const Sample = Template.bind({});
60
64
  Sample.args = {
61
65
  displayStyle: HEADER_DISPLAY.DESKTOP_TAB_5,
62
66
  };
67
+
68
+ export const WithHeaderButton = Template.bind({});
69
+ WithHeaderButton.args = {
70
+ displayStyle: HEADER_DISPLAY.DESKTOP_TAB_1,
71
+ hasCta: true,
72
+ };
@@ -17,18 +17,14 @@
17
17
  <slot name="nav" />
18
18
  </div>
19
19
  </div>
20
- <div
21
- v-if="showCta"
22
- data-component="header-layout-cta"
23
- class="cta-container"
24
- >
20
+ <div v-if="showCta" data-test-header-layout-cta class="cta-container">
25
21
  <slot name="cta" />
26
22
  </div>
27
23
  </header>
28
24
  </template>
29
25
  <script setup lang="ts">
30
26
  import { computed, withDefaults } from 'vue';
31
- import { HEADER_DISPLAY, type HeaderDisplayStyle } from '../../constants';
27
+ import { headerRowRendersCta, type HeaderDisplayStyle } from '../../constants';
32
28
 
33
29
  const props = withDefaults(
34
30
  defineProps<{
@@ -44,25 +40,9 @@ const props = withDefaults(
44
40
  },
45
41
  );
46
42
 
47
- // The mobile hamburger row is deliberately CTA-free: the drawer carries the button
48
- // (NavigationSideMenu) so the compact row keeps only the logo and the icon. When the
49
- // nav itself is hidden (single-page site) there is no icon and no drawer, so the row
50
- // carries the button after all — otherwise it would be unreachable on mobile.
51
- const DRAWER_ONLY_CTA_DISPLAY_STYLES: HeaderDisplayStyle[] = [
52
- HEADER_DISPLAY.MOBILE_HAMBURGER_1,
53
- HEADER_DISPLAY.MOBILE_HAMBURGER_2,
54
- HEADER_DISPLAY.MOBILE_HAMBURGER_3,
55
- HEADER_DISPLAY.MOBILE_HAMBURGER_4,
56
- HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_1,
57
- HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_2,
58
- HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_3,
59
- ];
60
-
61
43
  const showCta = computed(
62
44
  (): boolean =>
63
- props.hasCta &&
64
- (props.showNav === false ||
65
- !DRAWER_ONLY_CTA_DISPLAY_STYLES.includes(props.displayStyle)),
45
+ props.hasCta && headerRowRendersCta(props.displayStyle, props.showNav),
66
46
  );
67
47
  </script>
68
48
  <style scoped>
@@ -413,15 +393,11 @@ const showCta = computed(
413
393
  }
414
394
 
415
395
  /* ==========================================================================
416
- Header CTA placement (BP-5852)
417
-
418
- Every rule here is additive and only applies once a CTA is rendered — the
419
- track/flow-changing ones are gated on `.has-header-cta` — so a header with no
420
- button lays out exactly as it did before. Four placements:
421
- join - inline immediately after the nav links
422
- edge - pinned to the header's outer right edge, nav left untouched
423
- corner - floats top-right, out of flow, independent of a centered stack
424
- row - full-width row below the whole stack (mobile bar nav)
396
+ Header CTA placement. Every rule is additive and gated on the CTA being
397
+ rendered (`.has-header-cta` / `.cta-container`), so a header without the
398
+ button lays out exactly as before. The placement archetypes (join / edge /
399
+ corner / row) and the per-family reasoning live in
400
+ docs/architecture/capabilities/page-header.md.
425
401
  ========================================================================== */
426
402
 
427
403
  /* join - DESKTOP_TAB_1: appended flex child; the nav's `flex: 1` right-aligns the
@@ -468,13 +444,26 @@ const showCta = computed(
468
444
  right: 0;
469
445
  }
470
446
 
471
- /* row - MOBILE_TAB_1 / MOBILE_TAB_2: auto-places into an implicit third grid row
472
- below the stack (both explicit rows are taken by the logo and the nav). */
447
+ /* row - MOBILE_TAB_1: auto-places into an implicit third grid row below the stack
448
+ (both explicit rows are taken by the logo and the nav), giving logo / nav / button. */
473
449
  .mobile-tab-vertical-stack-centered-logo-centered-menu .cta-container,
474
450
  .mobile-tab-vertical-stack-centered-menu-centered-logo .cta-container {
475
451
  width: 100%;
476
452
  justify-self: stretch;
477
453
  }
454
+
455
+ /* row - MOBILE_TAB_2: the button belongs directly under the nav and ABOVE the logo,
456
+ so the grid grows a middle row for it and the logo's existing `row2` names move
457
+ down to the third. Auto-placement would otherwise drop it below the logo. */
458
+ .mobile-tab-vertical-stack-centered-menu-centered-logo.show-nav.has-header-cta {
459
+ grid-template-rows:
460
+ [row1-start] var(--tab-nav-height)
461
+ [row1-end cta-start] auto [cta-end row2-start] 1fr [row2-end];
462
+ }
463
+ .mobile-tab-vertical-stack-centered-menu-centered-logo.show-nav.has-header-cta
464
+ .cta-container {
465
+ grid-row: cta-start / cta-end;
466
+ }
478
467
  .mobile-tab-vertical-stack-centered-logo-centered-menu.no-nav.has-header-cta,
479
468
  .mobile-tab-vertical-stack-centered-menu-centered-logo.no-nav.has-header-cta {
480
469
  @apply tw-flex-wrap tw-justify-center;
@@ -525,7 +514,18 @@ const showCta = computed(
525
514
  .desktop-hamburger-horizontal-stack-left-logo-right-menu .cta-container,
526
515
  .desktop-hamburger-horizontal-stack-centered-logo-right-menu .cta-container {
527
516
  grid-column: cta-start;
528
- align-self: center;
517
+ /* Explicit row: the CTA sits in DOM order after the nav, and sparse grid
518
+ auto-placement never backtracks to an earlier column on the same row —
519
+ without this the button wraps to an implicit second row. */
520
+ grid-row: 1;
521
+ /* Centre the button on the hamburger icon's line: the icon is start-aligned
522
+ with the nav's --page-padding margin, so mirror that margin and let the
523
+ (taller) button overflow a line of the icon's height evenly. */
524
+ align-self: start;
525
+ margin-top: var(--page-padding);
526
+ height: var(--hamburger-nav-height);
527
+ display: flex;
528
+ align-items: center;
529
529
  @apply tw-mr-4;
530
530
  }
531
531
 
@@ -60,9 +60,9 @@ export default defineComponent({
60
60
  onHeaderContainerClick() {
61
61
  this.$emit('header-container-click');
62
62
  },
63
- // In the maker preview the CTA is inert (BP-5852 decision): capture-phase so the
64
- // prevent lands before the inner anchor/router-link fires, and the stop keeps the
65
- // click from selecting the header panel. On the published site it does nothing.
63
+ // In the maker preview the CTA is inert (see docs/decisions/0019): capture-phase
64
+ // so the prevent lands before the inner anchor/router-link fires, and the stop
65
+ // keeps the click from selecting the header panel. Published sites: no-op.
66
66
  onHeaderCtaClick(event: MouseEvent): void {
67
67
  if (this.context.isPreview) {
68
68
  event.preventDefault();
@@ -19,6 +19,7 @@ import {
19
19
  } from '@src/brand-page/constants';
20
20
  import type { NavItemViewModel } from '@brand-page/view-models';
21
21
  import { mapToPageHeaderViewModel } from '@brand-page/utils';
22
+ import { setLocaleAsync } from '@src/composables/useTranslate';
22
23
 
23
24
  // NavigationCarousel calls `new ResizeObserver(...)`, so this has to be constructible.
24
25
  global.ResizeObserver = class {
@@ -148,6 +149,11 @@ const mountHeader = async (options: {
148
149
  };
149
150
 
150
151
  describe('PageHeader - header call-to-action button (BP-5852)', () => {
152
+ // the empty-label fallback resolves through i18n
153
+ beforeAll(async () => {
154
+ await setLocaleAsync('en-US');
155
+ });
156
+
151
157
  beforeEach(() => {
152
158
  setActivePinia(createPinia());
153
159
  });
@@ -157,9 +163,9 @@ describe('PageHeader - header call-to-action button (BP-5852)', () => {
157
163
  const wrapper = await mountHeader({});
158
164
 
159
165
  expect(wrapper.find(CTA).exists()).toBe(false);
160
- expect(
161
- wrapper.find('[data-component="header-layout-cta"]').exists(),
162
- ).toBe(false);
166
+ expect(wrapper.find('[data-test-header-layout-cta]').exists()).toBe(
167
+ false,
168
+ );
163
169
  });
164
170
 
165
171
  test('renders no CTA when the button is configured but hidden', async () => {
@@ -0,0 +1,5 @@
1
+ {
2
+ "pageHeader": {
3
+ "buttonFallbackLabel": "Contact Us"
4
+ }
5
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ HEADER_DISPLAY,
3
+ headerRowRendersCta,
4
+ } from './header-display.constants';
5
+
6
+ // One predicate decides header-row vs drawer for the CTA; Page.mixin uses it
7
+ // inverted for the drawer, so these cases pin that the two can never overlap.
8
+ describe('headerRowRendersCta', () => {
9
+ test.each([
10
+ HEADER_DISPLAY.MOBILE_HAMBURGER_1,
11
+ HEADER_DISPLAY.MOBILE_HAMBURGER_2,
12
+ HEADER_DISPLAY.MOBILE_HAMBURGER_3,
13
+ HEADER_DISPLAY.MOBILE_HAMBURGER_4,
14
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_1,
15
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_2,
16
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_3,
17
+ ])('%s with a visible nav: the drawer carries the CTA', (displayStyle) => {
18
+ expect(headerRowRendersCta(displayStyle, true)).toBe(false);
19
+ });
20
+
21
+ test.each([
22
+ HEADER_DISPLAY.MOBILE_HAMBURGER_1,
23
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_1,
24
+ ])(
25
+ '%s with the nav hidden: no drawer exists, the row carries it',
26
+ (displayStyle) => {
27
+ expect(headerRowRendersCta(displayStyle, false)).toBe(true);
28
+ },
29
+ );
30
+
31
+ test.each([
32
+ HEADER_DISPLAY.DESKTOP_HAMBURGER_1,
33
+ HEADER_DISPLAY.DESKTOP_HAMBURGER_2,
34
+ HEADER_DISPLAY.DESKTOP_TAB_1,
35
+ HEADER_DISPLAY.MOBILE_TAB_1,
36
+ HEADER_DISPLAY.MOBILE_TAB_2,
37
+ HEADER_DISPLAY.MOBILE_TAB_NO_LOGO_1,
38
+ ])('%s: the row carries the CTA', (displayStyle) => {
39
+ expect(headerRowRendersCta(displayStyle, true)).toBe(true);
40
+ });
41
+
42
+ test('an unknown/deprecated display style defaults to the row', () => {
43
+ expect(headerRowRendersCta('align-left', true)).toBe(true);
44
+ });
45
+ });
@@ -442,3 +442,22 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
442
442
  },
443
443
  },
444
444
  ];
445
+
446
+ /**
447
+ * Whether the header ROW renders the call-to-action button, as opposed to the
448
+ * mobile nav drawer. The compact mobile hamburger row hands the CTA to the
449
+ * drawer — unless the nav is hidden (single visible page), when there is no
450
+ * drawer and the row must carry it. Derived from the configs so a future
451
+ * style classifies itself by its own viewMode/navType.
452
+ */
453
+ export const headerRowRendersCta = (
454
+ displayStyle: string,
455
+ showNav: boolean,
456
+ ): boolean =>
457
+ !showNav ||
458
+ !HEADER_DISPLAY_STYLE_CONFIGS.some(
459
+ (config) =>
460
+ config.option.value === displayStyle &&
461
+ config.viewMode === 'mobile' &&
462
+ config.navType === 'hamburger',
463
+ );
@@ -753,8 +753,11 @@ export abstract class AbstractPageHeaderStyles {
753
753
  style: BrandPageCssStyle;
754
754
  viewport: ViewportsType;
755
755
 
756
- /** Absent means no header call-to-action button — never seed a default. */
757
- headerButton?: HeaderButton;
756
+ /** Absent means no header call-to-action button — never seed a default.
757
+ * `declare` keeps the class field type-only: a plain optional field is still
758
+ * DEFINED on every instance (an own `undefined` key), which would change the
759
+ * serialized shape released migrations receive. */
760
+ declare headerButton?: HeaderButton;
758
761
 
759
762
  protected constructor(options: AbstractPageHeaderStylesType = {}) {
760
763
  if (typeof options.viewport === 'undefined') {
@@ -794,7 +797,11 @@ export abstract class AbstractPageHeaderStyles {
794
797
  backgroundOpacity: options.backgroundStyle?.backgroundOpacity ?? 100,
795
798
  };
796
799
 
797
- this.headerButton = options.headerButton;
800
+ // Assign only when present: constructor output is part of released
801
+ // migrations' input contract, so an absent field must stay absent.
802
+ if (options.headerButton) {
803
+ this.headerButton = options.headerButton;
804
+ }
798
805
  }
799
806
  }
800
807
 
@@ -156,7 +156,6 @@ exports[`migration.util > Banner Contents - text Align and Font Family should no
156
156
  "backgroundOpacity": 100,
157
157
  },
158
158
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
159
- "headerButton": undefined,
160
159
  "isLogoOptional": false,
161
160
  "isLogoVisible": true,
162
161
  "logoImage": {
@@ -232,7 +231,6 @@ exports[`migration.util > Banner Contents - text Align and Font Family should no
232
231
  "backgroundOpacity": 100,
233
232
  },
234
233
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
235
- "headerButton": undefined,
236
234
  "isLogoOptional": false,
237
235
  "isLogoVisible": true,
238
236
  "logoImage": {
@@ -655,7 +653,6 @@ exports[`migration.util > migrateBrandPageModel should return brand page model w
655
653
  "backgroundOpacity": 100,
656
654
  },
657
655
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
658
- "headerButton": undefined,
659
656
  "isLogoOptional": false,
660
657
  "isLogoVisible": true,
661
658
  "logoImage": {
@@ -731,7 +728,6 @@ exports[`migration.util > migrateBrandPageModel should return brand page model w
731
728
  "backgroundOpacity": 100,
732
729
  },
733
730
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
734
- "headerButton": undefined,
735
731
  "isLogoOptional": false,
736
732
  "isLogoVisible": true,
737
733
  "logoImage": {
@@ -1138,7 +1134,6 @@ exports[`migration.util > migrateBrandPageModel should return brand page model w
1138
1134
  "backgroundOpacity": 100,
1139
1135
  },
1140
1136
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
1141
- "headerButton": undefined,
1142
1137
  "isLogoOptional": false,
1143
1138
  "isLogoVisible": true,
1144
1139
  "logoImage": {
@@ -1214,7 +1209,6 @@ exports[`migration.util > migrateBrandPageModel should return brand page model w
1214
1209
  "backgroundOpacity": 100,
1215
1210
  },
1216
1211
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
1217
- "headerButton": undefined,
1218
1212
  "isLogoOptional": false,
1219
1213
  "isLogoVisible": true,
1220
1214
  "logoImage": {
@@ -141,7 +141,6 @@ exports[`migration02.ts > migration02 should return converted letterspacing 1`]
141
141
  "backgroundOpacity": 100,
142
142
  },
143
143
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
144
- "headerButton": undefined,
145
144
  "isLogoOptional": false,
146
145
  "isLogoVisible": true,
147
146
  "logoDraftToken": "",
@@ -218,7 +217,6 @@ exports[`migration02.ts > migration02 should return converted letterspacing 1`]
218
217
  "backgroundOpacity": 100,
219
218
  },
220
219
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
221
- "headerButton": undefined,
222
220
  "isLogoOptional": false,
223
221
  "isLogoVisible": true,
224
222
  "logoDraftToken": "",