Skip to content

Commit

Permalink
Re-designed CTA card settings panel components (#1451)
Browse files Browse the repository at this point in the history
ref https://linear.app/ghost/issue/PLG-355/improve-cta-card-settings-panel

- Updated ButtonGroup styles.
- Added a button-style image upload placeholder to MediaPlaceholder. Also updated the VideoCard to follow the same style.
- Refactored ColorOptionButtons and ColorPicker to display swatches and colorpicker inside toolbar. This also affects the callout, signup and header cards and the image-background selector.
- Added pink and purple as background colors to the CTA card.
- Changed CTA card link styles to currentColor instead of accent color – both for the label and the body text.
- Extracted modified UI components to beta copies to limit impact and allow easier migration of production cards to new settings UI components later on

---------

Co-authored-by: Sanne de Vries <[email protected]>
Co-authored-by: Kevin Ansfield <[email protected]>
  • Loading branch information
3 people authored Mar 3, 2025
1 parent 1a3874f commit 1915539
Show file tree
Hide file tree
Showing 25 changed files with 1,249 additions and 107 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ export class CallToActionNode extends generateDecoratorNode({
properties: [
{name: 'layout', default: 'minimal'},
{name: 'textValue', default: '', wordCount: true},
{name: 'showButton', default: false},
{name: 'buttonText', default: ''},
{name: 'showButton', default: true},
{name: 'buttonText', default: 'Learn more'},
{name: 'buttonUrl', default: ''},
{name: 'buttonColor', default: ''},
{name: 'buttonTextColor', default: ''},
{name: 'buttonColor', default: '#000000'}, // Where colour is customisable, we should use hex values
{name: 'buttonTextColor', default: '#ffffff'},
{name: 'hasSponsorLabel', default: true},
{name: 'sponsorLabel', default: '<p><span style="white-space: pre-wrap;">SPONSORED</span></p>'},
{name: 'backgroundColor', default: 'grey'},
{name: 'imageUrl', default: null},
{name: 'backgroundColor', default: 'grey'}, // Since this is one of a few fixed options, we stick to colour names.
{name: 'imageUrl', default: ''},
{name: 'imageWidth', default: null},
{name: 'imageHeight', default: null}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ function ctaCardTemplate(dataset) {
</div>
` : ''}
${dataset.showButton ? `
<a href="${dataset.buttonUrl}" class="kg-cta-button ${buttonAccent}"
${buttonStyle}>
<a href="${dataset.buttonUrl}" class="kg-cta-button ${buttonAccent}" ${buttonStyle}>
${dataset.buttonText}
</a>
` : ''}
Expand All @@ -46,12 +45,12 @@ function ctaCardTemplate(dataset) {
}

function emailCTATemplate(dataset, options = {}) {
const buttonStyle = dataset.buttonColor === 'accent'
? `color: ${dataset.buttonTextColor};`
const buttonStyle = dataset.buttonColor === 'accent'
? `color: ${dataset.buttonTextColor};`
: `background-color: ${dataset.buttonColor}; color: ${dataset.buttonTextColor};`;

let imageDimensions;

if (dataset.imageUrl && dataset.imageWidth && dataset.imageHeight) {
imageDimensions = {
width: dataset.imageWidth,
Expand Down Expand Up @@ -98,9 +97,10 @@ function emailCTATemplate(dataset, options = {}) {
<table border="0" cellpadding="0" cellspacing="0" class="kg-cta-button-wrapper">
<tr>
<td class="${dataset.buttonColor === 'accent' ? 'kg-style-accent' : ''}" style="${buttonStyle}">
<a href="${dataset.buttonUrl}"
<a href="${dataset.buttonUrl}"
class="kg-cta-button ${dataset.buttonColor === 'accent' ? 'kg-style-accent' : ''}"
style="${buttonStyle}">
style="${buttonStyle}"
>
${dataset.buttonText}
</a>
</td>
Expand Down Expand Up @@ -146,9 +146,10 @@ function emailCTATemplate(dataset, options = {}) {
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="kg-cta-button-wrapper ${dataset.buttonColor === 'accent' ? 'kg-style-accent' : ''}" style="${buttonStyle}">
<a href="${dataset.buttonUrl}"
class="kg-cta-button ${dataset.buttonColor === 'accent' ? 'kg-style-accent' : ''}"
style="${buttonStyle}">
<a href="${dataset.buttonUrl}"
class="kg-cta-button ${dataset.buttonColor === 'accent' ? 'kg-style-accent' : ''}"
style="${buttonStyle}"
>
${dataset.buttonText}
</a>
</td>
Expand Down
26 changes: 12 additions & 14 deletions packages/kg-default-nodes/test/nodes/call-to-action.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ describe('CallToActionNode', function () {
callToActionNode.textValue = 'This is a cool advertisement';
callToActionNode.textValue.should.equal('This is a cool advertisement');

callToActionNode.showButton.should.equal(false);
callToActionNode.showButton = true;
callToActionNode.showButton.should.equal(true);
callToActionNode.showButton = false;
callToActionNode.showButton.should.equal(false);

callToActionNode.buttonText.should.equal('');
callToActionNode.buttonText.should.equal('Learn more');
callToActionNode.buttonText = 'click me';
callToActionNode.buttonText.should.equal('click me');

Expand All @@ -97,11 +97,11 @@ describe('CallToActionNode', function () {
callToActionNode.sponsorLabel = 'This post is brought to you by our sponsors';
callToActionNode.sponsorLabel.should.equal('This post is brought to you by our sponsors');

callToActionNode.buttonColor.should.equal('');
callToActionNode.buttonColor = 'red';
callToActionNode.buttonColor.should.equal('red');
callToActionNode.buttonColor.should.equal('#000000');
callToActionNode.buttonColor = '#ffffff';
callToActionNode.buttonColor.should.equal('#ffffff');

callToActionNode.buttonTextColor.should.equal('');
callToActionNode.buttonTextColor.should.equal('#ffffff');
callToActionNode.buttonTextColor = 'black';
callToActionNode.buttonTextColor.should.equal('black');

Expand All @@ -110,10 +110,10 @@ describe('CallToActionNode', function () {
callToActionNode.hasSponsorLabel.should.equal(false);

callToActionNode.backgroundColor.should.equal('grey');
callToActionNode.backgroundColor = '#654321';
callToActionNode.backgroundColor.should.equal('#654321');
callToActionNode.backgroundColor = 'red';
callToActionNode.backgroundColor.should.equal('red');

should(callToActionNode.imageUrl).be.null();
callToActionNode.imageUrl.should.equal('');
callToActionNode.imageUrl = 'http://blog.com/image1.jpg';
callToActionNode.imageUrl.should.equal('http://blog.com/image1.jpg');

Expand Down Expand Up @@ -259,7 +259,6 @@ describe('CallToActionNode', function () {
buttonText: 'Get access now',
buttonTextColor: '#000000',
buttonUrl: 'http://someblog.com/somepost',
hasImage: true,
hasSponsorLabel: true,
sponsorLabel: '<p><span style="white-space: pre-wrap;">SPONSORED</span></p>',
imageUrl: '/content/images/2022/11/koenig-lexical.jpg',
Expand All @@ -276,7 +275,7 @@ describe('CallToActionNode', function () {
html.should.containEql('Get access now');
html.should.containEql('http://someblog.com/somepost');
html.should.containEql('<p><span style="white-space: pre-wrap;">SPONSORED</span></p>'); // because hasSponsorLabel is true
html.should.containEql('/content/images/size/w64h64/2022/11/koenig-lexical.jpg'); // because hasImage is true
html.should.containEql('/content/images/size/w64h64/2022/11/koenig-lexical.jpg');
html.should.containEql('This is a new CTA Card via email.');
}));

Expand All @@ -289,7 +288,6 @@ describe('CallToActionNode', function () {
buttonText: 'Get access now',
buttonTextColor: '#000000',
buttonUrl: 'http://someblog.com/somepost',
hasImage: true,
hasSponsorLabel: true,
sponsorLabel: '<p><span style="white-space: pre-wrap;">SPONSORED</span></p>',
imageUrl: '/content/images/2022/11/koenig-lexical.jpg',
Expand All @@ -301,7 +299,7 @@ describe('CallToActionNode', function () {
const {element} = callToActionNode.exportDOM(exportOptions);

const html = element.outerHTML.toString();
html.should.containEql('/content/images/size/w256h256/2022/11/koenig-lexical.jpg'); // because hasImage is true
html.should.containEql('/content/images/size/w256h256/2022/11/koenig-lexical.jpg');
}));

it('renders email with img width and height when immersive', editorTest(function () {
Expand Down
64 changes: 64 additions & 0 deletions packages/koenig-lexical/src/components/ui/ButtonGroupBeta.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import PropTypes from 'prop-types';
import React from 'react';

import {Tooltip} from './Tooltip';
import {usePreviousFocus} from '../../hooks/usePreviousFocus';

export function ButtonGroupBeta({buttons = [], selectedName, onClick, hasTooltip = true}) {
return (
<div className="flex">
<ul className="flex items-center justify-evenly rounded-lg bg-grey-100 font-sans text-md font-normal text-white" role="menubar">
{buttons.map(({label, name, Icon, dataTestId, ariaLabel}) => (
<ButtonGroupIconButton
key={`${name}-${label}`}
ariaLabel={ariaLabel}
dataTestId={dataTestId}
hasTooltip={hasTooltip}
Icon={Icon}
label={label}
name={name}
selectedName={selectedName}
onClick={onClick}
/>
))}
</ul>
</div>
);
}

export function ButtonGroupIconButton({dataTestId, onClick, label, ariaLabel, name, selectedName, Icon, hasTooltip}) {
const isActive = name === selectedName;

const {handleMousedown, handleClick} = usePreviousFocus(onClick, name);

return (
<li className="mb-0">
<button
aria-checked={isActive}
aria-label={ariaLabel || label}
className={`group relative flex h-7 w-8 cursor-pointer items-center justify-center rounded-lg text-black dark:text-white dark:hover:bg-grey-900 ${isActive ? 'border border-grey-300 bg-white shadow-xs dark:bg-grey-900' : '' } ${Icon ? '' : 'text-[1.3rem] font-bold'}`}
data-testid={dataTestId}
role="menuitemradio"
type="button"
onClick={handleClick}
onMouseDown={handleMousedown}
>
{Icon ? <Icon className="size-4 stroke-2" /> : label}
{(Icon && label && hasTooltip) && <Tooltip label={label} />}
</button>
</li>
);
}

ButtonGroupBeta.propTypes = {
selectedName: PropTypes.oneOf(['regular', 'wide', 'full', 'split', 'center', 'left', 'small', 'medium', 'large', 'grid', 'list', 'minimal', 'immersive']).isRequired,
hasTooltip: PropTypes.bool,
onClick: PropTypes.func.isRequired,
buttons: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
name: PropTypes.string.isRequired,
Icon: PropTypes.elementType,
dataTestId: PropTypes.string,
ariaLabel: PropTypes.string
}))
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import ImgFullIcon from '../../assets/icons/kg-img-full.svg?react';
import ImgRegularIcon from '../../assets/icons/kg-img-regular.svg?react';
import ImgWideIcon from '../../assets/icons/kg-img-wide.svg?react';
import React from 'react';
import {ButtonGroupBeta, ButtonGroupIconButton} from './ButtonGroupBeta';

const story = {
title: 'Generic/Button group (beta)',
component: ButtonGroupBeta,
subcomponents: {ButtonGroupIconButton},
parameters: {
status: {
type: 'functional'
}
},
argTypes: {
selectedName: {control: 'select', options: ['regular', 'wide', 'full']}
}
};
export default story;

const Template = (args) => {
return (
<ButtonGroupBeta {...args} />
);
};

export const CardWidth = Template.bind({});
CardWidth.args = {
selectedName: 'regular',
buttons: [
{
label: 'Regular',
name: 'regular',
Icon: ImgRegularIcon
},
{
label: 'Wide',
name: 'wide',
Icon: ImgWideIcon
},
{
label: 'Full',
name: 'full',
Icon: ImgFullIcon
}
]
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import PlusIcon from '../../assets/icons/plus.svg?react';
import React, {useState} from 'react';
import {Tooltip} from './Tooltip';
import {useClickOutside} from '../../hooks/useClickOutside';
import {usePreviousFocus} from '../../hooks/usePreviousFocus';

export function ColorOptionButtonsBeta({buttons = [], selectedName, onClick}) {
const [isOpen, setIsOpen] = useState(false);
const componentRef = React.useRef(null);

const selectedButton = buttons.find(button => button.name === selectedName);

// Close the swatch popover when clicking outside of it
useClickOutside(isOpen, componentRef, () => setIsOpen(false));

return (
<div ref={componentRef} className="relative">
<button
className={`relative size-6 cursor-pointer rounded-full ${selectedName ? 'p-[2px]' : 'border border-grey-200 dark:border-grey-800'}`}
data-testid="color-options-button"
type="button"
onClick={() => setIsOpen(!isOpen)}
>
{selectedName && (
<div className="absolute inset-0 rounded-full bg-clip-content p-[3px]" style={{
background: 'conic-gradient(hsl(360,100%,50%),hsl(315,100%,50%),hsl(270,100%,50%),hsl(225,100%,50%),hsl(180,100%,50%),hsl(135,100%,50%),hsl(90,100%,50%),hsl(45,100%,50%),hsl(0,100%,50%))',
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
maskComposite: 'exclude'
}} />
)}
<span
className={`${selectedButton?.color || ''} block size-full rounded-full border-2 border-white`}
></span>
</button>

{/* Color options popover */}
{isOpen && (
<div className="absolute -right-3 bottom-full z-10 mb-2 rounded-lg bg-white px-3 py-2 shadow" data-testid="color-options-popover">
<div className="flex">
<ul className="flex w-full items-center justify-between rounded-md font-sans text-md font-normal text-white">
{buttons.map(({label, name, color}) => (
name !== 'image' ?
<ColorButton
key={`${name}-${label}`}
color={color}
data-testid={`color-options-${name}-button`}
label={label}
name={name}
selectedName={selectedName}
onClick={(title) => {
onClick(title);
setIsOpen(false);
}}
/>
:
<li key='background-image' className={`mb-0 flex size-[3rem] cursor-pointer items-center justify-center rounded-full border-2 ${selectedName === name ? 'border-green' : 'border-transparent'}`} data-testid="background-image-color-button" type="button" onClick={() => onClick(name)}>
<span className="border-1 flex size-6 items-center justify-center rounded-full border border-black/5">
<PlusIcon className="size-3 stroke-grey-700 stroke-2 dark:stroke-grey-500 dark:group-hover:stroke-grey-100" />
</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}

export function ColorButton({onClick, label, name, color, selectedName}) {
const isActive = name === selectedName;

const {handleMousedown, handleClick} = usePreviousFocus(onClick, name);
return (
<li className="mb-0">
<button
aria-label={label}
className={`group relative flex size-6 cursor-pointer items-center justify-center rounded-full border-2 ${isActive ? 'border-green' : 'border-transparent'}`}
data-test-id={`color-picker-${name}`}
type="button"
onClick={handleClick}
onMouseDown={handleMousedown}
>
<span
className={`${color} size-[1.8rem] rounded-full border`}
></span>
<Tooltip label={label} />
</button>
</li>
);
}
Loading

0 comments on commit 1915539

Please sign in to comment.