From a96dbbd2353ec80d8d5f11c3349da14071f1cd76 Mon Sep 17 00:00:00 2001 From: Matej Lednicky Date: Tue, 18 Jul 2023 17:01:29 +0200 Subject: [PATCH] feat(REACH-574): Allow passing any attributes to the button for popover and sidetab Since the button is generated by the SDK we need to allow passing any attributes to the button for further customization, eg. to add aria-label attribute. --- docs/configuration.md | 1 + packages/demo-html/public/sidetab-html.html | 1 + packages/demo-nextjs/pages/popover.js | 1 + packages/embed/README.md | 1 + packages/embed/src/base/button-options.ts | 7 ++++ packages/embed/src/base/iframe-options.ts | 11 +++--- packages/embed/src/base/index.ts | 1 + ...tial-element-with-additional-attributes.ts | 10 ++++++ .../create-popover/create-popover.spec.ts | 9 +++++ .../create-popover/create-popover.ts | 8 +++-- .../create-popover/popover-options.ts | 4 ++- .../create-sidetab/create-sidetab.spec.ts | 9 +++++ .../create-sidetab/create-sidetab.ts | 8 +++-- .../create-sidetab/sidetab-options.ts | 4 ++- .../build-options-from-attributes.spec.ts | 8 +++++ .../build-options-from-attributes.ts | 1 + .../utils/add-attributes-to-element.spec.ts | 35 +++++++++++++++++++ .../src/utils/add-attributes-to-element.ts | 7 ++++ .../utils/create-iframe/create-iframe.spec.ts | 14 +++++++- .../src/utils/create-iframe/create-iframe.ts | 7 ++-- packages/embed/src/utils/index.ts | 1 + 21 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 packages/embed/src/base/button-options.ts create mode 100644 packages/embed/src/base/partial-element-with-additional-attributes.ts create mode 100644 packages/embed/src/utils/add-attributes-to-element.spec.ts create mode 100644 packages/embed/src/utils/add-attributes-to-element.ts diff --git a/docs/configuration.md b/docs/configuration.md index d970541d..ec68cc05 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,7 @@ If you embed via HTML, you need to pass optinos as attributes with `data-tf-` pr | shareGaInstance | string / boolean | shares Google Analytics instance of the host page with embedded typeform, you can provide your Google Analytics ID to specify which instance to share (if you have more than one in your page) | `false` | | inlineOnMobile | boolean | removes placeholder welcome screen in mobile and makes form show inline instead of fullscreen | `false` | | iframeProps | object | HTML attributes to be passed directly to the iframe with typeform | `undefined` | +| buttonProps | object | HTML attributes to be passed directly to the button created by embed SDK (only for popover and sidetab) | `undefined` | | lazy | boolean | enable lazy loading (for widget only), typeform starts loading when user scrolls to it, [see demo](https://github.com/Typeform/embed-demo/blob/main/demo-html/widget-lazy-html/index.html) | `false` | | keepSession | boolean | preserve form state when modal window is closed (and re-opened) | `false` | | redirectTarget | string | target for [typeforms with redirect](https://www.typeform.com/help/a/redirect-on-completion-or-redirect-through-endings-360060589532/), valid values are `_self`, `_top`, `_blank` or `_parent` ([see docs on anchor target](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target)) | `_parent` | diff --git a/packages/demo-html/public/sidetab-html.html b/packages/demo-html/public/sidetab-html.html index 4404d404..b7b09299 100644 --- a/packages/demo-html/public/sidetab-html.html +++ b/packages/demo-html/public/sidetab-html.html @@ -18,6 +18,7 @@ data-tf-medium="demo-test" data-tf-button-text="open sidetab" data-tf-hidden="foo=foo value,bar=bar value" + data-tf-button-props="ariaLabel=Sidetab Button,dataTestid=my-form-button" > diff --git a/packages/demo-nextjs/pages/popover.js b/packages/demo-nextjs/pages/popover.js index 6297dd3d..0bfeca79 100644 --- a/packages/demo-nextjs/pages/popover.js +++ b/packages/demo-nextjs/pages/popover.js @@ -30,6 +30,7 @@ export default function PopoverPage({ id }) { onReady={handleOnReady} medium="demo-test" hidden={{ foo: 'foo value', bar: 'bar value' }} + buttonProps={{ ariaLabel: 'Typeform Button', dataTestid: 'demo-button' }} /> diff --git a/packages/embed/README.md b/packages/embed/README.md index c769b099..c223c837 100644 --- a/packages/embed/README.md +++ b/packages/embed/README.md @@ -153,6 +153,7 @@ Closing and opening a typeform in modal window will restart the progress from th | [shareGaInstance](https://codesandbox.io/s/github/Typeform/embed-demo/tree/main/demo-html/widget-inline) | string / boolean | shares Google Analytics instance of the host page with embedded typeform, you can provide your Google Analytics ID to specify which instance to share (if you have more than one in your page) | `false` | | [inlineOnMobile](https://codesandbox.io/s/github/Typeform/embed-demo/tree/main/demo-html/widget-inline) | boolean | removes placeholder welcome screen in mobile and makes form show inline instead of fullscreen | `false` | | [iframeProps](https://codesandbox.io/s/github/Typeform/embed-demo/tree/main/demo-html/widget-js) | object | HTML attributes to be passed directly to the iframe with typeform | `undefined` | +| [buttonProps](https://codesandbox.io/s/github/Typeform/embed-demo/tree/main/demo-html/sidetab-html) | object | HTML attributes to be passed directly to the button created by embed SDK (only for popover and sidetab) | `undefined` | | [lazy](https://codesandbox.io/s/github/Typeform/embed-demo/tree/main/demo-html/widget-lazy-html) | boolean | enable lazy loading (for widget only), typeform starts loading when user scrolls to it, [see demo](https://github.com/Typeform/embed-demo/blob/main/demo-html/widget-lazy-html/index.html) | `false` | | [keepSession](https://codesandbox.io/s/github/Typeform/embed-demo/tree/main/demo-html/keep-session-html) | boolean | preserve form state when modal window is closed (and re-opened) | `false` | | redirectTarget | string | target for [typeforms with redirect](https://www.typeform.com/help/a/redirect-on-completion-or-redirect-through-endings-360060589532/), valid values are `_self`, `_top`, `_blank` or `_parent` ([see docs on anchor target](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target)) | `_parent` | diff --git a/packages/embed/src/base/button-options.ts b/packages/embed/src/base/button-options.ts new file mode 100644 index 00000000..7d2bf275 --- /dev/null +++ b/packages/embed/src/base/button-options.ts @@ -0,0 +1,7 @@ +import { PartialElementWithAdditionalAttributes } from './partial-element-with-additional-attributes' + +export type ButtonProps = PartialElementWithAdditionalAttributes + +export type ButtonOptions = { + buttonProps?: ButtonProps +} diff --git a/packages/embed/src/base/iframe-options.ts b/packages/embed/src/base/iframe-options.ts index 1526a23b..3645e811 100644 --- a/packages/embed/src/base/iframe-options.ts +++ b/packages/embed/src/base/iframe-options.ts @@ -1,10 +1,7 @@ -// per docs: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style -// setting style as string is not advised, but perfectly valid approach -interface StyleString { - style: string -} -type HTMLIFrameElementWithStyleString = Omit & StyleString +import { PartialElementWithAdditionalAttributes } from './partial-element-with-additional-attributes' + +export type IframeProps = PartialElementWithAdditionalAttributes export type IframeOptions = { - iframeProps?: Partial + iframeProps?: IframeProps } diff --git a/packages/embed/src/base/index.ts b/packages/embed/src/base/index.ts index 27570533..d3a77639 100644 --- a/packages/embed/src/base/index.ts +++ b/packages/embed/src/base/index.ts @@ -7,3 +7,4 @@ export * from './behavioral-options' export * from './sizeable-options' export * from './iframe-options' export * from './modal-window-options' +export * from './button-options' diff --git a/packages/embed/src/base/partial-element-with-additional-attributes.ts b/packages/embed/src/base/partial-element-with-additional-attributes.ts new file mode 100644 index 00000000..15964f9b --- /dev/null +++ b/packages/embed/src/base/partial-element-with-additional-attributes.ts @@ -0,0 +1,10 @@ +interface AdditionalAttributes { + // per docs: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style + // setting style as string is not advised, but perfectly valid approach + style: string + + // data attributes + [key: `data${string}`]: string +} + +export type PartialElementWithAdditionalAttributes = Partial & AdditionalAttributes> diff --git a/packages/embed/src/factories/create-popover/create-popover.spec.ts b/packages/embed/src/factories/create-popover/create-popover.spec.ts index 4811fa76..d3eeebe4 100644 --- a/packages/embed/src/factories/create-popover/create-popover.spec.ts +++ b/packages/embed/src/factories/create-popover/create-popover.spec.ts @@ -176,5 +176,14 @@ describe('#createSidetab', () => { ) }) }) + + describe('#buttonProps', () => { + it('should render popover with custom button attributes', async () => { + popover = createPopover('formId', { buttonProps: { ariaLabel: 'foo', title: 'pop' } }) + popover.open() + jest.runAllTimers() + expect(screen.getByTitle('pop')).toHaveAttribute('aria-label', 'foo') + }) + }) }) }) diff --git a/packages/embed/src/factories/create-popover/create-popover.ts b/packages/embed/src/factories/create-popover/create-popover.ts index dcc7aaa3..0416e7de 100644 --- a/packages/embed/src/factories/create-popover/create-popover.ts +++ b/packages/embed/src/factories/create-popover/create-popover.ts @@ -10,9 +10,10 @@ import { isInPage, makeAutoResize, invokeWithoutDefault, + addAttributesToElement, } from '../../utils' import type { RemoveHandler } from '../../utils' -import { EmbedPopup } from '../../base' +import { ButtonProps, EmbedPopup } from '../../base' import { PopoverOptions } from './popover-options' import { buildNotificationDot, canBuildNotificationDot, saveNotificationDotHideUntilTime } from './notification-days' @@ -81,13 +82,14 @@ const buildIcon = (customIcon?: string, color?: string) => { return triggerIcon } -const buildTriggerButton = (color: string) => { +const buildTriggerButton = (color: string, buttonProps: ButtonProps = {}) => { const textColor = getTextColor(color) const button = document.createElement('button') button.className = 'tf-v1-popover-button' button.dataset.testid = 'tf-v1-popover-button' button.style.backgroundColor = color button.style.color = textColor + addAttributesToElement(button, buttonProps) return button } @@ -126,7 +128,7 @@ export const createPopover = (formId: string, userOptions: PopoverOptions = {}): const spinner = buildSpinner() const closeIcon = buildCloseIcon() const closeModal = buildCloseIcon('button', 'tf-v1-popover-close') - const button = buildTriggerButton(options.buttonColor || defaultOptions.buttonColor) + const button = buildTriggerButton(options.buttonColor || defaultOptions.buttonColor, options.buttonProps) const container = options.container || document.body diff --git a/packages/embed/src/factories/create-popover/popover-options.ts b/packages/embed/src/factories/create-popover/popover-options.ts index 3e777d0f..551a4993 100644 --- a/packages/embed/src/factories/create-popover/popover-options.ts +++ b/packages/embed/src/factories/create-popover/popover-options.ts @@ -6,6 +6,7 @@ import { SizeableOptions, IframeOptions, ModalWindowOptions, + ButtonOptions, } from '../../base' export type PopoverOptions = BaseOptions & @@ -14,7 +15,8 @@ export type PopoverOptions = BaseOptions & ActionableOptions & BehavioralOptions & SizeableOptions & - IframeOptions & { + IframeOptions & + ButtonOptions & { /** * Specify the size of the popover (only applies if using mode "popover"). * diff --git a/packages/embed/src/factories/create-sidetab/create-sidetab.spec.ts b/packages/embed/src/factories/create-sidetab/create-sidetab.spec.ts index 49ebce50..21c2cb97 100644 --- a/packages/embed/src/factories/create-sidetab/create-sidetab.spec.ts +++ b/packages/embed/src/factories/create-sidetab/create-sidetab.spec.ts @@ -89,4 +89,13 @@ describe('#createSidetab', () => { expect(screen.getByTestId('tf-v1-sidetab')).toHaveStyle({ width: '400px', height: '600px' }) }) }) + + describe('#buttonProps', () => { + it('should render sidetab with custom button attributes', async () => { + sidetab = createSidetab('formId', { buttonProps: { ariaLabel: 'foo', title: 'button' } }) + sidetab.open() + jest.runAllTimers() + expect(screen.getByTitle('button')).toHaveAttribute('aria-label', 'foo') + }) + }) }) diff --git a/packages/embed/src/factories/create-sidetab/create-sidetab.ts b/packages/embed/src/factories/create-sidetab/create-sidetab.ts index dd3bae2e..8bf3bb08 100644 --- a/packages/embed/src/factories/create-sidetab/create-sidetab.ts +++ b/packages/embed/src/factories/create-sidetab/create-sidetab.ts @@ -10,9 +10,10 @@ import { isInPage, makeAutoResize, invokeWithoutDefault, + addAttributesToElement, } from '../../utils' import type { RemoveHandler } from '../../utils' -import { EmbedPopup } from '../../base' +import { ButtonProps, EmbedPopup } from '../../base' import { SidetabOptions } from './sidetab-options' @@ -47,12 +48,13 @@ const buildSpinner = () => { return icon } -const buildTriggerButton = (color: string) => { +const buildTriggerButton = (color: string, buttonProps: ButtonProps = {}) => { const textColor = getTextColor(color) const button = document.createElement('button') button.className = 'tf-v1-sidetab-button' button.style.backgroundColor = color button.style.color = textColor + addAttributesToElement(button, buttonProps) return button } @@ -103,7 +105,7 @@ export const createSidetab = (formId: string, userOptions: SidetabOptions = {}): const sidetab = buildSidetab(options.width, options.height) const wrapper = buildWrapper() const spinner = buildSpinner() - const button = buildTriggerButton(options.buttonColor || defaultOptions.buttonColor) + const button = buildTriggerButton(options.buttonColor || defaultOptions.buttonColor, options.buttonProps) const buttonText = buildTriggerButtonText(options.buttonText || defaultOptions.buttonText) const icon = buildIcon(options.customIcon, options.buttonColor || defaultOptions.buttonColor) const closeIcon = buildCloseIcon() diff --git a/packages/embed/src/factories/create-sidetab/sidetab-options.ts b/packages/embed/src/factories/create-sidetab/sidetab-options.ts index 0399e9aa..f4bda9aa 100644 --- a/packages/embed/src/factories/create-sidetab/sidetab-options.ts +++ b/packages/embed/src/factories/create-sidetab/sidetab-options.ts @@ -6,6 +6,7 @@ import { SizeableOptions, IframeOptions, ModalWindowOptions, + ButtonOptions, } from '../../base' export type SidetabOptions = BaseOptions & @@ -14,7 +15,8 @@ export type SidetabOptions = BaseOptions & ActionableOptions & BehavioralOptions & SizeableOptions & - IframeOptions & { + IframeOptions & + ButtonOptions & { /** * Specify the button text * diff --git a/packages/embed/src/initializers/build-options-from-attributes.spec.ts b/packages/embed/src/initializers/build-options-from-attributes.spec.ts index 3d3a8d17..fda6f2a8 100644 --- a/packages/embed/src/initializers/build-options-from-attributes.spec.ts +++ b/packages/embed/src/initializers/build-options-from-attributes.spec.ts @@ -30,6 +30,8 @@ describe('build-options-from-attributes', () => { data-tf-disable-scroll data-tf-full-screen data-tf-no-heading + data-tf-iframe-props="title=foo" + data-tf-button-props="aria-label=bar" >` it('should load correct options', () => { @@ -75,6 +77,12 @@ describe('build-options-from-attributes', () => { disableScroll: true, fullScreen: true, noHeading: true, + iframeProps: { + title: 'foo', + }, + buttonProps: { + 'aria-label': 'bar', + }, }) }) }) diff --git a/packages/embed/src/initializers/build-options-from-attributes.ts b/packages/embed/src/initializers/build-options-from-attributes.ts index 27059ea1..aa7c3f88 100644 --- a/packages/embed/src/initializers/build-options-from-attributes.ts +++ b/packages/embed/src/initializers/build-options-from-attributes.ts @@ -40,6 +40,7 @@ export const buildOptionsFromAttributes = (element: HTMLElement) => { tracking: 'record', redirectTarget: 'string', iframeProps: 'record', + buttonProps: 'record', lazy: 'boolean', keepSession: 'boolean', hubspot: 'boolean', diff --git a/packages/embed/src/utils/add-attributes-to-element.spec.ts b/packages/embed/src/utils/add-attributes-to-element.spec.ts new file mode 100644 index 00000000..99fbdf2a --- /dev/null +++ b/packages/embed/src/utils/add-attributes-to-element.spec.ts @@ -0,0 +1,35 @@ +import { addAttributesToElement } from './add-attributes-to-element' + +describe('#addAttributesToElement', () => { + it('should add attributes to an element', () => { + const element = document.createElement('button') + addAttributesToElement(element, { + title: 'test', + }) + expect(element.getAttribute('title')).toBe('test') + }) + + it('should add attributes with dashes to an element', () => { + const element = document.createElement('button') + addAttributesToElement(element, { + ariaLabel: 'foo', + dataCustomValue: 'bar', + }) + expect(element.getAttribute('aria-label')).toBe('foo') + expect(element.getAttribute('data-custom-value')).toBe('bar') + }) + + it('should set element style from a string', () => { + const element = document.createElement('button') + addAttributesToElement(element, { + style: 'color:blue; background:none; text-decoration:underline; padding:20px; margin:10px;', + }) + expect(element).toHaveStyle({ + color: 'blue', + background: 'none', + textDecoration: 'underline', + padding: '20px', + margin: '10px', + }) + }) +}) diff --git a/packages/embed/src/utils/add-attributes-to-element.ts b/packages/embed/src/utils/add-attributes-to-element.ts new file mode 100644 index 00000000..b8ee6aaa --- /dev/null +++ b/packages/embed/src/utils/add-attributes-to-element.ts @@ -0,0 +1,7 @@ +import { camelCaseToKebabCase } from './load-options-from-attributes' + +export const addAttributesToElement = (element: HTMLElement, props = {}) => { + Object.keys(props).forEach((key) => { + element.setAttribute(camelCaseToKebabCase(key), props[key]) + }) +} diff --git a/packages/embed/src/utils/create-iframe/create-iframe.spec.ts b/packages/embed/src/utils/create-iframe/create-iframe.spec.ts index 359a8186..8dc1d4b4 100644 --- a/packages/embed/src/utils/create-iframe/create-iframe.spec.ts +++ b/packages/embed/src/utils/create-iframe/create-iframe.spec.ts @@ -19,7 +19,11 @@ describe('create-iframe', () => { onQuestionChanged: jest.fn(), onHeightChanged: jest.fn(), domain: 'custom.example.com', - iframeProps: { title: 'hello' }, + iframeProps: { + title: 'hello', + ariaLabel: 'foo bar', + style: 'border:1px red solid;margin:10px;', // not recommended, but valid + }, } beforeEach(() => { @@ -53,6 +57,14 @@ describe('create-iframe', () => { expect(iframe.getAttribute('title')).toBe('hello') }) + it('should set correct iframe aria-label', () => { + expect(iframe.getAttribute('aria-label')).toBe('foo bar') + }) + + it('should set correct iframe style', () => { + expect(iframe).toHaveStyle({ border: '1px red solid', margin: '10px' }) + }) + it('should set correct iframe permissions', () => { expect(iframe.allow).toBe('microphone; camera') }) diff --git a/packages/embed/src/utils/create-iframe/create-iframe.ts b/packages/embed/src/utils/create-iframe/create-iframe.ts index 5d25b4d1..9c43008b 100644 --- a/packages/embed/src/utils/create-iframe/create-iframe.ts +++ b/packages/embed/src/utils/create-iframe/create-iframe.ts @@ -1,6 +1,6 @@ import { EmbedType, UrlOptions, ActionableOptions, IframeOptions } from '../../base' import { buildIframeSrc } from '../build-iframe-src' -import { setupGaInstance } from '../' +import { addAttributesToElement, setupGaInstance } from '../' import { generateEmbedId } from './generate-embed-id' import { @@ -42,10 +42,7 @@ export const createIframe = (type: EmbedType, { formId, domain, options }: Creat iframe.dataset.testid = 'iframe' iframe.style.border = '0px' iframe.allow = 'microphone; camera' - - Object.keys(iframeProps).forEach((key) => { - iframe.setAttribute(key, iframeProps[key]) - }) + addAttributesToElement(iframe, iframeProps) iframe.addEventListener('load', triggerIframeRedraw, { once: true }) diff --git a/packages/embed/src/utils/index.ts b/packages/embed/src/utils/index.ts index 04223674..0ec68c74 100644 --- a/packages/embed/src/utils/index.ts +++ b/packages/embed/src/utils/index.ts @@ -18,3 +18,4 @@ export * from './make-auto-resize' export * from './change-color-opacity' export * from './hubspot' export * from './invoke-without-default' +export * from './add-attributes-to-element'