@designcrowd/library.brand-page 6.2.2 → 6.2.3-ast-faq

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 (147) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-7P2gNr-R.js → CreatedOnBrandCrowd-de-de-BRisMzFY.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-Dl7BymW6.js → CreatedOnBrandCrowd-en-us-CDDsL9GX.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-CO4FvWVw.js → CreatedOnBrandCrowd-es-es-BkZrEo4j.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-DHNqakQu.js → CreatedOnBrandCrowd-fr-ca-sx_ury7R.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-DHNqakQu.js → CreatedOnBrandCrowd-fr-fr-sx_ury7R.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-CGUC-ZbP.js → CreatedOnBrandCrowd-pt-br-DCWiAfkU.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-CGUC-ZbP.js → CreatedOnBrandCrowd-pt-pt-DCWiAfkU.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-Dvds-Vmy.js → CreatedOnDcom-de-de-C1S7f22L.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-fd3C-8Tp.js → CreatedOnDcom-en-us-DgTlWaXE.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-DTXZW35-.js → CreatedOnDcom-es-es-CxSQJe_V.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-DRCYOKPs.js → CreatedOnDcom-fr-ca-Bbam2tt6.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-DpIB8x2x.js → CreatedOnDcom-fr-fr-Cb0XDRPL.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-Esnxy8wX.js → CreatedOnDcom-pt-br-BjdX3-E3.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-CaiVHFXR.js → CreatedOnDcom-pt-pt-BXpbfopB.js} +1 -1
  15. package/lib/src/brand-page/constants/misc.constants.d.ts +8 -0
  16. package/lib/src/brand-page/constants/misc.constants.js +7 -0
  17. package/lib/src/brand-page/constants/styles.constants.d.ts +2 -0
  18. package/lib/src/brand-page/constants/styles.constants.js +2 -0
  19. package/lib/src/brand-page/models/brand-page-context.model.d.ts +0 -2
  20. package/lib/src/brand-page/models/brand-page-context.model.js +0 -12
  21. package/lib/src/brand-page/models/content.model.d.ts +1 -0
  22. package/lib/src/brand-page/models/content.model.js +1 -0
  23. package/lib/src/brand-page/models/index.d.ts +19 -0
  24. package/lib/src/brand-page/utils/content-callback-util.d.ts +2 -1
  25. package/lib/src/brand-page/utils/content-callback-util.js +3 -0
  26. package/lib/src/brand-page/utils/model.util.d.ts +2 -1
  27. package/lib/src/brand-page/utils/model.util.js +3 -0
  28. package/lib/src/brand-page/utils/view-model.util.d.ts +2 -0
  29. package/lib/src/brand-page/utils/view-model.util.js +24 -0
  30. package/lib/src/brand-page/view-models.d.ts +9 -2
  31. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.d.ts +0 -1
  32. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.js +0 -9
  33. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +10 -42
  34. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +10 -42
  35. package/lib/src/brand-page-maker/components/preview/Preview.mixin.d.ts +0 -14
  36. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +0 -42
  37. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +0 -42
  38. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +0 -42
  39. package/lib/src/brand-page-maker/components/seo-settings/UseExistingDomainModal.mixin.d.ts +0 -42
  40. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +0 -42
  41. package/lib/src/brand-page-maker/components/upgrade-to-premium-button/UpgradeToPremiumButton.mixin.d.ts +0 -42
  42. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.d.ts +13 -0
  43. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +64 -30
  44. package/lib/src/brand-page-maker/constants/config-settings-constant.d.ts +2 -1
  45. package/lib/src/brand-page-maker/constants/config-settings-constant.js +101 -2
  46. package/lib/src/brand-page-maker/convertors/faq-display-style.convertor.d.ts +3 -0
  47. package/lib/src/brand-page-maker/convertors/faq-display-style.convertor.js +47 -0
  48. package/lib/src/brand-page-maker/models/content-settings-configs.d.ts +22 -1
  49. package/lib/src/brand-page-maker/models/content-settings-configs.js +1 -0
  50. package/lib/src/brand-page-maker/models/content-settings.d.ts +4 -1
  51. package/lib/src/brand-page-maker/models/content-settings.js +45 -1
  52. package/lib/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.d.ts +0 -42
  53. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +1 -0
  54. package/lib/src/brand-page-maker/utils/apply-color-strategy.js +47 -17
  55. package/lib/src/brand-page-maker/utils/rich-text-editor.util.d.ts +6 -0
  56. package/lib/src/brand-page-maker/utils/rich-text-editor.util.js +24 -1
  57. package/lib/src/brand-page-maker/validators/field.validator.js +2 -0
  58. package/lib/src/bundled-translations-DY9YvuL-.js +179 -0
  59. package/lib/src/bundles/bundled-translations.json +20 -2
  60. package/lib/src/composables/useTranslate.d.ts +1 -1
  61. package/lib/src/composables/useTranslate.js +1 -1
  62. package/lib/src/css/library.brand-page-brandCrowd.min.css +143 -36
  63. package/lib/src/css/library.brand-page-brandPage.min.css +143 -36
  64. package/lib/src/css/library.brand-page-designCom.min.css +143 -36
  65. package/lib/src/css/library.brand-page-preview.min.css +143 -36
  66. package/lib/src/index.mjs +159 -158
  67. package/lib/src/{lib-V7aqfuiB.js → lib-DwpSAbLZ.js} +26889 -26178
  68. package/lib/src/metadata-builder/parsers/section-tool-arguments.d.ts +1 -1
  69. package/lib/src/shared/color-strategies/color-strategies.js +4 -0
  70. package/lib/src/shared/color-strategies/rte-field-pairs.d.ts +12 -0
  71. package/lib/src/shared/color-strategies/rte-field-pairs.js +10 -0
  72. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +10 -0
  73. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +11 -1
  74. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItem.mixin.d.ts +10 -0
  75. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +10 -0
  76. package/lib/src/shared/content-settings/components/focused-content-item-settings/FocusedContentItemSettings.mixin.d.ts +10 -0
  77. package/lib/src/shared/content-settings/components/items-field/ItemsField.mixin.d.ts +14 -1
  78. package/lib/src/shared/content-settings/components/items-field/ItemsField.mixin.js +11 -1
  79. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +0 -42
  80. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +12 -0
  81. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.js +5 -0
  82. package/lib/src/shared/content-settings/models.d.ts +6 -0
  83. package/lib/src/shared/content-settings/store/content-settings-store.d.ts +14 -1
  84. package/lib/src/shared/content-settings/store/content-settings-store.js +24 -2
  85. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +0 -42
  86. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +10 -42
  87. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +10 -42
  88. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +0 -56
  89. package/lib/src/shared/side-bar/components/SideBar.mixin.d.ts +0 -42
  90. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +0 -42
  91. package/package.json +2 -2
  92. package/src/brand-page/components/faq-section/FaqSection.test.ts +292 -0
  93. package/src/brand-page/components/faq-section/FaqSection.vue +299 -0
  94. package/src/brand-page/components/page/PageComponent.vue +2 -0
  95. package/src/brand-page/components/section-heading/SectionHeading.vue +52 -0
  96. package/src/brand-page/components/text-wrapper/TextWrapper.test.ts +33 -0
  97. package/src/brand-page/constants/misc.constants.ts +12 -0
  98. package/src/brand-page/constants/styles.constants.ts +2 -0
  99. package/src/brand-page/models/brand-page-context.model.ts +0 -23
  100. package/src/brand-page/models/content.model.ts +1 -0
  101. package/src/brand-page/models/index.ts +21 -0
  102. package/src/brand-page/utils/content-callback-util.ts +5 -0
  103. package/src/brand-page/utils/model.util.ts +7 -0
  104. package/src/brand-page/utils/view-model.util.test.ts +96 -0
  105. package/src/brand-page/utils/view-model.util.ts +49 -0
  106. package/src/brand-page/view-models.ts +12 -1
  107. package/src/brand-page-maker/components/action-bar/ActionBar.mixin.ts +0 -10
  108. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.test.ts +8 -49
  109. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.vue +1 -1
  110. package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.test.ts +165 -0
  111. package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.vue +37 -49
  112. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +87 -30
  113. package/src/brand-page-maker/constants/config-settings-constant.ts +108 -0
  114. package/src/brand-page-maker/constants/faq-section-config.test.ts +151 -0
  115. package/src/brand-page-maker/convertors/faq-display-style.convertor.ts +59 -0
  116. package/src/brand-page-maker/convertors/i18n/converter.json +4 -1
  117. package/src/brand-page-maker/models/content-settings-configs.ts +24 -0
  118. package/src/brand-page-maker/models/content-settings.ts +57 -0
  119. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +2 -0
  120. package/src/brand-page-maker/utils/__snapshots__/apply-color-strategy.golden.test.ts.snap +4063 -895
  121. package/src/brand-page-maker/utils/apply-color-strategy.test.ts +104 -2
  122. package/src/brand-page-maker/utils/apply-color-strategy.ts +77 -19
  123. package/src/brand-page-maker/utils/rich-text-editor.util.test.ts +110 -0
  124. package/src/brand-page-maker/utils/rich-text-editor.util.ts +66 -0
  125. package/src/brand-page-maker/validators/field.validator.ts +2 -0
  126. package/src/bundles/bundled-translations.json +20 -2
  127. package/src/composables/useTranslate.ts +2 -1
  128. package/src/metadata-builder/builders/item-id-utils.test.ts +24 -0
  129. package/src/metadata-builder/parsers/section-tool-arguments.ts +2 -1
  130. package/src/metadata-builder/parsers/section-tool-map.test.ts +12 -0
  131. package/src/shared/color-strategies/color-strategies.ts +4 -0
  132. package/src/shared/color-strategies/rte-field-pairs.ts +23 -0
  133. package/src/shared/content-settings/components/content-settings/i18n/content-settings.json +17 -1
  134. package/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.vue +3 -0
  135. package/src/shared/content-settings/components/items-field/ItemsField.mixin.ts +11 -1
  136. package/src/shared/content-settings/components/items-field/ItemsField.vue +8 -2
  137. package/src/shared/content-settings/components/list-items-field/ListItemsField.test.ts +429 -0
  138. package/src/shared/content-settings/components/list-items-field/ListItemsField.vue +262 -0
  139. package/src/shared/content-settings/components/textarea-field/TextareaField.mixin.ts +6 -0
  140. package/src/shared/content-settings/components/textarea-field/TextareaField.vue +1 -0
  141. package/src/shared/content-settings/models.ts +7 -0
  142. package/src/shared/content-settings/store/content-settings-store.test.ts +70 -0
  143. package/src/shared/content-settings/store/content-settings-store.ts +37 -3
  144. package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.test.ts +19 -0
  145. package/src/shared/page-settings/store/page-settings-store.test.ts +0 -71
  146. package/src/utils/minify-brand-page-metadata.test.ts +35 -0
  147. package/lib/src/bundled-translations-BYUfWBT3.js +0 -179
@@ -0,0 +1,292 @@
1
+ import { vi } from 'vitest';
2
+ import { createSSRApp, h } from 'vue';
3
+ import { renderToString } from 'vue/server-renderer';
4
+ import { mount } from '@vue/test-utils';
5
+ import { createPinia, setActivePinia } from 'pinia';
6
+ import FaqSection from './FaqSection.vue';
7
+ import {
8
+ BRAND_PAGE_TYPES,
9
+ MEDIA_SIZES,
10
+ type BrandPageContext,
11
+ } from '@brand-page/models';
12
+ import { FAQ_DISPLAY_STYLES } from '@brand-page/constants';
13
+ import type { FaqSectionViewModel } from '@brand-page/view-models';
14
+ import type { RichTextValue } from '@brand-page/models';
15
+
16
+ global.ResizeObserver = vi.fn(function () {
17
+ return {
18
+ observe: vi.fn(),
19
+ unobserve: vi.fn(),
20
+ disconnect: vi.fn(),
21
+ } as unknown as ResizeObserver;
22
+ });
23
+
24
+ const context = (): BrandPageContext => ({
25
+ brandPageSlug: 'faq-test',
26
+ brandPageToken: '',
27
+ isMobile: false,
28
+ isPreview: false,
29
+ isProduction: false,
30
+ isServerRender: false,
31
+ options: { mediaSizes: MEDIA_SIZES.sizeThumb },
32
+ isTemplate: false,
33
+ isDesignCom: false,
34
+ pageTitle: 'FAQ',
35
+ runtimeEnvironment: '',
36
+ stripePublishableKey: undefined,
37
+ customDomain: undefined,
38
+ brandPageType: BRAND_PAGE_TYPES.brandSite,
39
+ isBrandPage: false,
40
+ isBrandPageOrIsBrandContact: false,
41
+ isBrandSite: true,
42
+ isBrandContact: false,
43
+ pages: [],
44
+ isCapturingPreview: false,
45
+ recaptchaSiteKey: undefined,
46
+ nav: { navItems: [] },
47
+ });
48
+
49
+ const faqItem = (
50
+ id: string,
51
+ question: RichTextValue,
52
+ answer: RichTextValue,
53
+ ) => ({
54
+ id,
55
+ question,
56
+ questionStyle: {},
57
+ answer,
58
+ answerStyle: {},
59
+ });
60
+
61
+ const items = [
62
+ faqItem('q1', 'What services do you offer?', 'Many.'),
63
+ faqItem('q2', 'What are your hours?', '9 to 6.'),
64
+ faqItem('q3', 'How can I get in touch?', 'Email us.'),
65
+ ];
66
+
67
+ const viewModel = (
68
+ overrides: Partial<FaqSectionViewModel> = {},
69
+ ): FaqSectionViewModel => ({
70
+ id: 'faq-1',
71
+ type: 'faq-section',
72
+ role: 'faq-section',
73
+ title: 'Frequently Asked Questions',
74
+ description: 'Find answers',
75
+ displayStyle: FAQ_DISPLAY_STYLES.centered,
76
+ items: items.map((item) => ({ ...item })),
77
+ titleStyle: {},
78
+ descriptionStyle: {},
79
+ backgroundStyle: {},
80
+ topPadding: 32,
81
+ bottomPadding: 32,
82
+ isFullWidth: true,
83
+ isStacked: false,
84
+ ...overrides,
85
+ });
86
+
87
+ const mountFaq = (overrides: Partial<FaqSectionViewModel> = {}) =>
88
+ mount(FaqSection, {
89
+ props: { viewModel: viewModel(overrides), context: context() },
90
+ global: { stubs: { Icon: true } },
91
+ });
92
+
93
+ const openItemIds = (wrapper: ReturnType<typeof mountFaq>): string[] =>
94
+ wrapper
95
+ .findAll('[data-test-faq-item]')
96
+ .filter(
97
+ (item) =>
98
+ item.find('[data-test-faq-question]').attributes('aria-expanded') ===
99
+ 'true',
100
+ )
101
+ .map((item) => item.attributes('data-test-faq-item') as string);
102
+
103
+ describe('FaqSection', () => {
104
+ beforeEach(() => {
105
+ setActivePinia(createPinia());
106
+ });
107
+
108
+ test('opens the first item on load', () => {
109
+ expect(openItemIds(mountFaq())).toEqual(['q1']);
110
+ });
111
+
112
+ test('opening another item closes the open one; clicking the open item closes it', async () => {
113
+ const wrapper = mountFaq();
114
+ const question = (id: string) =>
115
+ wrapper.find(`[data-test-faq-item="${id}"] [data-test-faq-question]`);
116
+
117
+ await question('q3').trigger('click');
118
+ expect(openItemIds(wrapper)).toEqual(['q3']);
119
+
120
+ await question('q3').trigger('click');
121
+ expect(openItemIds(wrapper)).toEqual([]);
122
+ });
123
+
124
+ test('split style is an accordion too', () => {
125
+ expect(
126
+ openItemIds(mountFaq({ displayStyle: FAQ_DISPLAY_STYLES.split })),
127
+ ).toEqual(['q1']);
128
+ });
129
+
130
+ test('grid style shows every answer and has no toggles', () => {
131
+ const wrapper = mountFaq({ displayStyle: FAQ_DISPLAY_STYLES.grid });
132
+
133
+ expect(wrapper.findAll('[data-test-faq-question]')).toHaveLength(0);
134
+ expect(wrapper.findAll('[data-test-faq-answer]')).toHaveLength(3);
135
+ expect(wrapper.text()).toContain('Email us.');
136
+ });
137
+
138
+ test('the title is the section h2 and each question sits in an h3', () => {
139
+ const wrapper = mountFaq();
140
+
141
+ expect(wrapper.find('h2').text()).toBe('Frequently Asked Questions');
142
+ expect(wrapper.findAll('h3 > [data-test-faq-question]')).toHaveLength(3);
143
+ });
144
+
145
+ test('no items renders the heading only', () => {
146
+ const wrapper = mountFaq({ items: [] });
147
+
148
+ expect(wrapper.find('h2').exists()).toBe(true);
149
+ expect(wrapper.findAll('[data-test-faq-item]')).toHaveLength(0);
150
+ // No empty list container, or the heading→items gap would still show.
151
+ expect(wrapper.find('.c_faq__items').exists()).toBe(false);
152
+ expect(
153
+ mountFaq({ items: [], displayStyle: FAQ_DISPLAY_STYLES.grid })
154
+ .find('.c_faq__grid')
155
+ .exists(),
156
+ ).toBe(false);
157
+ });
158
+
159
+ test('an empty question or answer still renders the item', () => {
160
+ const wrapper = mountFaq({
161
+ items: [faqItem('e1', '', '')],
162
+ });
163
+
164
+ expect(wrapper.findAll('[data-test-faq-item]')).toHaveLength(1);
165
+ expect(openItemIds(wrapper)).toEqual(['e1']);
166
+ });
167
+
168
+ test('follows the item the maker panel opened', async () => {
169
+ const wrapper = mountFaq();
170
+
171
+ await wrapper.setProps({ viewModel: viewModel({ activeItemId: 'q2' }) });
172
+ expect(openItemIds(wrapper)).toEqual(['q2']);
173
+ });
174
+
175
+ test('starts on the maker panel item when one is already open', () => {
176
+ expect(openItemIds(mountFaq({ activeItemId: 'q3' }))).toEqual(['q3']);
177
+ });
178
+
179
+ test('deleting the open item opens the first remaining one', async () => {
180
+ const wrapper = mountFaq();
181
+
182
+ await wrapper.setProps({
183
+ viewModel: viewModel({ items: items.slice(1).map((i) => ({ ...i })) }),
184
+ });
185
+ expect(openItemIds(wrapper)).toEqual(['q2']);
186
+ });
187
+
188
+ test('applies stored padding and a transparent background by default', () => {
189
+ const band = mountFaq({ topPadding: 120, bottomPadding: 8 }).find(
190
+ '[data-component="faq-section"] > div',
191
+ );
192
+
193
+ expect(band.attributes('style')).toContain('padding-top: 120px');
194
+ expect(band.attributes('style')).toContain('padding-bottom: 8px');
195
+ expect(band.attributes('style')).not.toContain('background');
196
+ });
197
+
198
+ test('marks split and grid stacked on brand.page / brand.contact', () => {
199
+ const wrapper = mountFaq({
200
+ displayStyle: FAQ_DISPLAY_STYLES.split,
201
+ isStacked: true,
202
+ });
203
+
204
+ expect(wrapper.find('.c_faq').classes()).toContain('c_faq--stacked');
205
+ });
206
+
207
+ test('server render opens the same first item the client does', async () => {
208
+ const app = createSSRApp({
209
+ render: () =>
210
+ h(FaqSection, { viewModel: viewModel(), context: context() }),
211
+ });
212
+
213
+ app.use(createPinia());
214
+ app.component('Icon', { render: () => null });
215
+
216
+ const html = await renderToString(app);
217
+
218
+ expect(html.match(/aria-expanded="true"/g)).toHaveLength(1);
219
+ expect(html.match(/aria-expanded="false"/g)).toHaveLength(2);
220
+ });
221
+
222
+ test('renders rich-text marks in the question and answer', () => {
223
+ const doc = (text: string, mark: string) => ({
224
+ type: 'doc',
225
+ content: [
226
+ {
227
+ type: 'paragraph',
228
+ content: [{ type: 'text', text, marks: [{ type: mark }] }],
229
+ },
230
+ ],
231
+ });
232
+ const wrapper = mountFaq({
233
+ items: [
234
+ faqItem(
235
+ 'r1',
236
+ doc('Bold question', 'bold'),
237
+ doc('Italic answer', 'italic'),
238
+ ),
239
+ ],
240
+ });
241
+
242
+ expect(wrapper.find('[data-test-faq-question] strong').text()).toBe(
243
+ 'Bold question',
244
+ );
245
+ expect(wrapper.find('[data-test-faq-answer] em').text()).toBe(
246
+ 'Italic answer',
247
+ );
248
+ });
249
+
250
+ test("each item's plain text uses its own style", () => {
251
+ const wrapper = mountFaq({
252
+ items: [
253
+ { ...faqItem('s1', 'Big', 'a'), questionStyle: { fontSize: '2em' } },
254
+ { ...faqItem('s2', 'Small', 'b'), questionStyle: { fontSize: '1em' } },
255
+ ],
256
+ });
257
+ const sizes = wrapper
258
+ .findAll('[data-test-faq-question] .empty-para')
259
+ .map((el) => (el.element as HTMLElement).style.fontSize);
260
+
261
+ expect(sizes).toEqual(['2em', '1em']);
262
+ });
263
+
264
+ test('clicking a link inside a question follows it without toggling the item', async () => {
265
+ const linkDoc = {
266
+ type: 'doc',
267
+ content: [
268
+ {
269
+ type: 'paragraph',
270
+ content: [
271
+ {
272
+ type: 'text',
273
+ text: 'See pricing',
274
+ marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
275
+ },
276
+ ],
277
+ },
278
+ ],
279
+ };
280
+ const wrapper = mountFaq({
281
+ items: [faqItem('l1', 'First', 'a'), faqItem('l2', linkDoc, 'b')],
282
+ });
283
+
284
+ await wrapper.find('[data-test-faq-item="l2"] a').trigger('click');
285
+ expect(openItemIds(wrapper)).toEqual(['l1']);
286
+
287
+ await wrapper
288
+ .find('[data-test-faq-item="l2"] [data-test-faq-question]')
289
+ .trigger('click');
290
+ expect(openItemIds(wrapper)).toEqual(['l2']);
291
+ });
292
+ });
@@ -0,0 +1,299 @@
1
+ <template>
2
+ <section data-component="faq-section">
3
+ <div
4
+ class="tw-relative tw-px-6"
5
+ :style="[viewModel.backgroundStyle, contentPaddingStyle]"
6
+ style="container-type: inline-size"
7
+ >
8
+ <div
9
+ class="c_faq"
10
+ :class="[`c_faq--${layout}`, { 'c_faq--stacked': viewModel.isStacked }]"
11
+ >
12
+ <SectionHeading
13
+ class="c_faq__heading"
14
+ :title="viewModel.title"
15
+ :title-style="viewModel.titleStyle"
16
+ :description="viewModel.description"
17
+ :description-style="viewModel.descriptionStyle"
18
+ :slug="context.brandPageSlug"
19
+ />
20
+
21
+ <!-- No items (D1): no list at all, so the heading gap doesn't linger. -->
22
+ <div v-if="hasItems && isAccordion" class="c_faq__items">
23
+ <div
24
+ v-for="item in viewModel.items"
25
+ :key="item.id"
26
+ class="c_faq__item"
27
+ :style="itemStyle(item)"
28
+ :data-test-faq-item="item.id"
29
+ >
30
+ <h3 class="tw-m-0">
31
+ <button
32
+ type="button"
33
+ class="c_faq__question"
34
+ :aria-expanded="isOpen(item.id)"
35
+ :aria-controls="answerId(item.id)"
36
+ data-test-faq-question
37
+ @click="onQuestionClick($event, item.id)"
38
+ >
39
+ <TextWrapper
40
+ :text-style="item.questionStyle"
41
+ :text="renderText(item.question)"
42
+ :slug="context.brandPageSlug"
43
+ element="span"
44
+ />
45
+ <Icon
46
+ class="c_faq__toggle"
47
+ :name="isOpen(item.id) ? 'minus' : 'plus-light'"
48
+ size="sm"
49
+ />
50
+ </button>
51
+ </h3>
52
+ <div
53
+ v-show="isOpen(item.id)"
54
+ :id="answerId(item.id)"
55
+ class="c_faq__answer"
56
+ data-test-faq-answer
57
+ >
58
+ <TextWrapper
59
+ :text-style="item.answerStyle"
60
+ :text="renderText(item.answer)"
61
+ :slug="context.brandPageSlug"
62
+ element="div"
63
+ />
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <div v-else-if="hasItems" class="c_faq__grid">
69
+ <div
70
+ v-for="item in viewModel.items"
71
+ :key="item.id"
72
+ :data-test-faq-item="item.id"
73
+ >
74
+ <TextWrapper
75
+ :text-style="item.questionStyle"
76
+ :text="renderText(item.question)"
77
+ :slug="context.brandPageSlug"
78
+ element="h3"
79
+ />
80
+ <TextWrapper
81
+ class="tw-mt-2"
82
+ :text-style="item.answerStyle"
83
+ :text="renderText(item.answer)"
84
+ :slug="context.brandPageSlug"
85
+ element="div"
86
+ data-test-faq-answer
87
+ />
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </section>
93
+ </template>
94
+ <script setup lang="ts">
95
+ import type { PropType } from 'vue';
96
+ import { computed, ref, watch } from 'vue';
97
+ import { Icon } from '@designcrowd/fe-shared-lib';
98
+ import TextWrapper from '../text-wrapper/TextWrapper.vue';
99
+ import SectionHeading from '../section-heading/SectionHeading.vue';
100
+ import type { FaqSectionViewModel } from '@brand-page/view-models';
101
+ import type {
102
+ BrandPageContext,
103
+ FaqItem,
104
+ RichTextValue,
105
+ } from '@brand-page/models';
106
+ import { FAQ_DISPLAY_STYLES } from '@brand-page/constants';
107
+ import {
108
+ getRenderText,
109
+ contentPaddingStyle as toContentPaddingStyle,
110
+ } from '@brand-page/utils';
111
+
112
+ const props = defineProps({
113
+ viewModel: {
114
+ type: Object as PropType<FaqSectionViewModel>,
115
+ required: true,
116
+ },
117
+ context: {
118
+ type: Object as PropType<BrandPageContext>,
119
+ required: true,
120
+ },
121
+ containerClientWidth: {
122
+ type: Number as PropType<number | undefined>,
123
+ required: false,
124
+ default: undefined,
125
+ },
126
+ containerClientHeight: {
127
+ type: Number as PropType<number | undefined>,
128
+ required: false,
129
+ default: undefined,
130
+ },
131
+ });
132
+
133
+ const contentPaddingStyle = computed(() =>
134
+ toContentPaddingStyle(props.viewModel),
135
+ );
136
+
137
+ const layout = computed((): string => {
138
+ switch (props.viewModel.displayStyle) {
139
+ case FAQ_DISPLAY_STYLES.split:
140
+ return 'split';
141
+ case FAQ_DISPLAY_STYLES.grid:
142
+ return 'grid';
143
+ default:
144
+ return 'centered';
145
+ }
146
+ });
147
+
148
+ const isAccordion = computed((): boolean => layout.value !== 'grid');
149
+
150
+ const hasItems = computed((): boolean => props.viewModel.items.length > 0);
151
+
152
+ // The row divider and +/- icon follow the item's question colour.
153
+ const itemStyle = (item: FaqItem): { color?: string } => ({
154
+ color: item.questionStyle?.color,
155
+ });
156
+
157
+ // `null` = the visitor closed every item. Seeded from data (not `mounted`) so
158
+ // SSR and hydration render the same open item.
159
+ const openItemId = ref<string | null>(
160
+ props.viewModel.activeItemId ?? props.viewModel.items[0]?.id ?? null,
161
+ );
162
+
163
+ const isOpen = (itemId: string): boolean => openItemId.value === itemId;
164
+
165
+ const toggle = (itemId: string): void => {
166
+ openItemId.value = isOpen(itemId) ? null : itemId;
167
+ };
168
+
169
+ // A link inside a rich-text question follows the link; only the rest of the row toggles.
170
+ const onQuestionClick = (event: MouseEvent, itemId: string): void => {
171
+ if ((event.target as Element | null)?.closest?.('a')) {
172
+ return;
173
+ }
174
+
175
+ toggle(itemId);
176
+ };
177
+
178
+ const answerId = (itemId: string): string =>
179
+ `faq-answer-${props.viewModel.id}-${itemId}`;
180
+
181
+ // Plain-text items keep their escaped newlines; a rich-text document renders as-is.
182
+ const renderText = (text: RichTextValue): RichTextValue =>
183
+ typeof text === 'string' || text == null ? getRenderText(text ?? '') : text;
184
+
185
+ // Maker only: follow the item the settings panel opened.
186
+ watch(
187
+ () => props.viewModel.activeItemId,
188
+ (activeItemId) => {
189
+ if (activeItemId) {
190
+ openItemId.value = activeItemId;
191
+ }
192
+ },
193
+ );
194
+
195
+ // The open item was deleted → open the first remaining one.
196
+ watch(
197
+ () => props.viewModel.items.map((item) => item.id),
198
+ (itemIds) => {
199
+ if (openItemId.value !== null && !itemIds.includes(openItemId.value)) {
200
+ openItemId.value = itemIds[0] ?? null;
201
+ }
202
+ },
203
+ );
204
+ </script>
205
+
206
+ <style scoped>
207
+ /* Spacing follows the Figma redlines ("Website — Features & Widgets", FAQ): 32 heading→items,
208
+ 16 above and below each question, dividers only between items. */
209
+ .c_faq {
210
+ margin: 0 auto;
211
+ max-width: var(--brandsite-max-width);
212
+ display: flex;
213
+ flex-direction: column;
214
+ gap: 32px;
215
+ }
216
+
217
+ .c_faq--centered .c_faq__items {
218
+ width: 100%;
219
+ max-width: 48rem;
220
+ margin: 0 auto;
221
+ }
222
+
223
+ .c_faq__item {
224
+ padding: 16px 0;
225
+ }
226
+
227
+ .c_faq__item + .c_faq__item {
228
+ /* ADR 0009: color-mix without a fallback is accepted. */
229
+ border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
230
+ }
231
+
232
+ .c_faq__question {
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: space-between;
236
+ gap: 16px;
237
+ width: 100%;
238
+ padding: 0;
239
+ border: none;
240
+ background: none;
241
+ /* Buttons don't inherit font by default; the question's em sizes need the h3's. */
242
+ font: inherit;
243
+ color: inherit;
244
+ text-align: left;
245
+ cursor: pointer;
246
+ }
247
+
248
+ .c_faq__toggle {
249
+ flex: 0 0 auto;
250
+ /* Stays on the right when the question is empty and renders nothing (D9). */
251
+ margin-left: auto;
252
+ }
253
+
254
+ /* A long unbroken word wraps instead of pushing the +/- out of the row. */
255
+ .c_faq__question > [data-component='text-wrapper'] {
256
+ flex: 1 1 auto;
257
+ min-width: 0;
258
+ overflow-wrap: anywhere;
259
+ }
260
+
261
+ .c_faq__answer {
262
+ margin-top: 8px;
263
+ }
264
+
265
+ .c_faq__grid {
266
+ display: grid;
267
+ grid-template-columns: 1fr 1fr;
268
+ gap: 32px;
269
+ }
270
+
271
+ @container (min-width: 640px) {
272
+ .c_faq--split:not(.c_faq--stacked) {
273
+ flex-direction: row;
274
+ align-items: flex-start;
275
+ }
276
+
277
+ /* 1/3 heading · 2/3 items, either side of the 32px gap. */
278
+ .c_faq--split:not(.c_faq--stacked) .c_faq__heading {
279
+ flex: 1 1 0;
280
+ min-width: 0;
281
+ }
282
+
283
+ .c_faq--split:not(.c_faq--stacked) .c_faq__items {
284
+ flex: 2 1 0;
285
+ min-width: 0;
286
+ }
287
+ }
288
+
289
+ @container (max-width: 640px) {
290
+ .c_faq__grid {
291
+ grid-template-columns: 1fr;
292
+ }
293
+ }
294
+
295
+ /* brand.page / brand.contact always stack split and grid. */
296
+ .c_faq--stacked .c_faq__grid {
297
+ grid-template-columns: 1fr;
298
+ }
299
+ </style>
@@ -35,6 +35,7 @@ import ShapesSection from '../shapes-section/ShapesSection.vue';
35
35
  import PageComponentMixin from './PageComponent.mixin.ts';
36
36
  import Shop from '../shop/Shop.vue';
37
37
  import BookingSection from '../booking-section/BookingSection.vue';
38
+ import FaqSection from '../faq-section/FaqSection.vue';
38
39
 
39
40
  export default {
40
41
  components: {
@@ -60,6 +61,7 @@ export default {
60
61
  'bp-shapes-section': ShapesSection,
61
62
  'bp-shop-section': Shop,
62
63
  'bp-booking-section': BookingSection,
64
+ 'bp-faq-section': FaqSection,
63
65
  },
64
66
  mixins: [PageComponentMixin],
65
67
  };
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <div v-if="hasTitle || hasDescription" data-component="section-heading">
3
+ <TextWrapper
4
+ v-if="hasTitle"
5
+ :text-style="titleStyle"
6
+ :text="title"
7
+ :slug="slug"
8
+ element="h2"
9
+ />
10
+ <TextWrapper
11
+ v-if="hasDescription"
12
+ class="tw-mt-4"
13
+ :text-style="descriptionStyle"
14
+ :text="renderDescription"
15
+ :slug="slug"
16
+ element="div"
17
+ />
18
+ </div>
19
+ </template>
20
+ <script setup lang="ts">
21
+ import { computed } from 'vue';
22
+ import TextWrapper from '../text-wrapper/TextWrapper.vue';
23
+ import type { RichTextValue, TextStyleDeclaration } from '@brand-page/models';
24
+ import { escapeContentText, getRenderText } from '@brand-page/utils';
25
+
26
+ // Section title (the h2 — ADR 0004) + optional description, shared by the
27
+ // item-list sections (see content-sections.md, near-term requirement).
28
+ const props = withDefaults(
29
+ defineProps<{
30
+ title?: RichTextValue;
31
+ titleStyle?: TextStyleDeclaration;
32
+ description?: RichTextValue;
33
+ descriptionStyle?: TextStyleDeclaration;
34
+ slug?: string;
35
+ }>(),
36
+ {
37
+ title: undefined,
38
+ titleStyle: undefined,
39
+ description: undefined,
40
+ descriptionStyle: undefined,
41
+ slug: undefined,
42
+ },
43
+ );
44
+
45
+ const hasTitle = computed((): boolean => !!escapeContentText(props.title));
46
+ const hasDescription = computed(
47
+ (): boolean => !!escapeContentText(props.description),
48
+ );
49
+ const renderDescription = computed(
50
+ (): RichTextValue => getRenderText(props.description as string),
51
+ );
52
+ </script>
@@ -0,0 +1,33 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import TextWrapper from './TextWrapper.vue';
3
+
4
+ const boldDoc = {
5
+ type: 'doc',
6
+ content: [
7
+ {
8
+ type: 'paragraph',
9
+ content: [{ type: 'text', text: 'Hello', marks: [{ type: 'bold' }] }],
10
+ },
11
+ ],
12
+ };
13
+
14
+ const textStyle = { fontSize: '1.5em', color: 'rgb(255, 0, 0)' };
15
+
16
+ const container = (wrapper: ReturnType<typeof mount>) =>
17
+ wrapper.find('.empty-para').element as HTMLElement;
18
+
19
+ describe('TextWrapper', () => {
20
+ test('rich text ignores the text style by default — its marks own the look', () => {
21
+ const wrapper = mount(TextWrapper, { props: { text: boldDoc, textStyle } });
22
+
23
+ expect(wrapper.find('strong').text()).toBe('Hello');
24
+ expect(container(wrapper).style.fontSize).toBe('');
25
+ expect(container(wrapper).style.color).toBe('');
26
+ });
27
+
28
+ test('plain text is styled by the text style', () => {
29
+ const wrapper = mount(TextWrapper, { props: { text: 'Plain', textStyle } });
30
+
31
+ expect(container(wrapper).style.fontSize).toBe('1.5em');
32
+ });
33
+ });