diff --git a/.changeset/product-card-image-alt-tests.md b/.changeset/product-card-image-alt-tests.md new file mode 100644 index 00000000000..558e0787983 --- /dev/null +++ b/.changeset/product-card-image-alt-tests.md @@ -0,0 +1,6 @@ +--- +"@tryghost/kg-html-to-lexical": none +"@tryghost/kg-converters": none +--- + +Added test coverage for product card image alt text; no runtime change diff --git a/.changeset/product-card-image-alt.md b/.changeset/product-card-image-alt.md new file mode 100644 index 00000000000..47e2db869a9 --- /dev/null +++ b/.changeset/product-card-image-alt.md @@ -0,0 +1,6 @@ +--- +"@tryghost/kg-default-nodes": minor +"@tryghost/koenig-lexical": minor +--- + +Added alt text support for the product card image: a productImageAlt property on the product node, rendered as the image's alt attribute on web and email, parsed from HTML on import, and editable from an Alt toggle on the card in the editor diff --git a/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap b/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap index b8dd2f65667..fce07aae195 100644 --- a/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap +++ b/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap @@ -8444,7 +8444,7 @@ Ghost: Independent technology for modern publishingBeautiful, modern publishing - + \\"Ghost @@ -10030,7 +10030,7 @@ Beautiful, modern publishing with newsletters and premium subscriptions built-in - + \\"Ghost diff --git a/ghost/core/test/unit/server/services/content-import/import/media.test.ts b/ghost/core/test/unit/server/services/content-import/import/media.test.ts index a5000c17440..d8cc5ac8d62 100644 --- a/ghost/core/test/unit/server/services/content-import/import/media.test.ts +++ b/ghost/core/test/unit/server/services/content-import/import/media.test.ts @@ -83,6 +83,7 @@ describe('PostMediaInliner', function () { { type: 'product', productImageSrc: 'https://assets.test/product.jpg', + productImageAlt: 'Product photo', productUrl: 'https://example.com/product', }, { @@ -156,6 +157,7 @@ describe('PostMediaInliner', function () { assert.equal(children[4].src, '__GHOST_URL__/content/files/guide.pdf'); assert.equal(children[5].productImageSrc, '__GHOST_URL__/content/files/product.jpg'); assert.equal(children[5].productUrl, 'https://example.com/product'); + assert.equal(children[5].productImageAlt, 'Product photo'); assert.equal(children[6].backgroundImageSrc, '__GHOST_URL__/content/files/header.jpg'); assert.equal(children[6].buttonUrl, 'https://example.com/header-button'); assert.equal(children[7].backgroundImageSrc, '__GHOST_URL__/content/files/signup.jpg'); diff --git a/ghost/core/test/utils/fixtures/email-service/golden-post.json b/ghost/core/test/utils/fixtures/email-service/golden-post.json index 6e5d08749b0..690c7a34f3c 100644 --- a/ghost/core/test/utils/fixtures/email-service/golden-post.json +++ b/ghost/core/test/utils/fixtures/email-service/golden-post.json @@ -239,6 +239,7 @@ "productImageSrc": "https://main.ghost.org/content/images/2023/12/ghost-logo.png", "productImageWidth": 800, "productImageHeight": 257, + "productImageAlt": "Ghost logo", "productTitle": "Make a blog!", "productDescription": "

with Ghost

", "productRatingEnabled": true, diff --git a/koenig/kg-converters/test/lexical-to-mobiledoc.test.ts b/koenig/kg-converters/test/lexical-to-mobiledoc.test.ts index c77011bbf2f..c428e49502e 100644 --- a/koenig/kg-converters/test/lexical-to-mobiledoc.test.ts +++ b/koenig/kg-converters/test/lexical-to-mobiledoc.test.ts @@ -3066,6 +3066,67 @@ describe('lexicalToMobiledoc', function () { ); }); + it('keeps product card image alt text', function () { + const result = lexicalToMobiledoc( + JSON.stringify({ + root: { + children: [ + { + type: 'product', + version: 1, + productImageSrc: 'https://example.com/images/camera.jpg', + productImageWidth: 724, + productImageHeight: 76, + productImageAlt: 'A camera on a wooden table', + productTitle: 'Camera', + productDescription: 'A nice camera', + productRatingEnabled: false, + productStarRating: 5, + productButtonEnabled: false, + productButton: '', + productUrl: '', + }, + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }), + ); + + assert.equal( + result, + JSON.stringify({ + version: MOBILEDOC_VERSION, + ghostVersion: GHOST_VERSION, + atoms: [], + cards: [ + [ + 'product', + { + version: 1, + productImageSrc: 'https://example.com/images/camera.jpg', + productImageWidth: 724, + productImageHeight: 76, + productImageAlt: 'A camera on a wooden table', + productTitle: 'Camera', + productDescription: 'A nice camera', + productRatingEnabled: false, + productStarRating: 5, + productButtonEnabled: false, + productButton: '', + productUrl: '', + }, + ], + ], + markups: [], + sections: [[10, 0]], + }), + ); + }); + it('renames cards', function () { const result = lexicalToMobiledoc( JSON.stringify({ diff --git a/koenig/kg-converters/test/mobiledoc-to-lexical.test.ts b/koenig/kg-converters/test/mobiledoc-to-lexical.test.ts index 16003f24f08..fdd77b8e800 100644 --- a/koenig/kg-converters/test/mobiledoc-to-lexical.test.ts +++ b/koenig/kg-converters/test/mobiledoc-to-lexical.test.ts @@ -2438,6 +2438,67 @@ describe('mobiledocToLexical', function () { ); }); + it('keeps product card image alt text', function () { + const result = mobiledocToLexical( + JSON.stringify({ + version: MOBILEDOC_VERSION, + ghostVersion: GHOST_VERSION, + atoms: [], + cards: [ + [ + 'product', + { + version: 1, + productImageSrc: 'https://example.com/images/camera.jpg', + productImageWidth: 724, + productImageHeight: 76, + productImageAlt: 'A camera on a wooden table', + productTitle: 'Camera', + productDescription: 'A nice camera', + productRatingEnabled: false, + productStarRating: 5, + productButtonEnabled: false, + productButton: '', + productUrl: '', + }, + ], + ], + markups: [], + sections: [[10, 0]], + }), + ); + + assert.equal( + result, + JSON.stringify({ + root: { + children: [ + { + type: 'product', + version: 1, + productImageSrc: 'https://example.com/images/camera.jpg', + productImageWidth: 724, + productImageHeight: 76, + productImageAlt: 'A camera on a wooden table', + productTitle: 'Camera', + productDescription: 'A nice camera', + productRatingEnabled: false, + productStarRating: 5, + productButtonEnabled: false, + productButton: '', + productUrl: '', + }, + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }), + ); + }); + it('renames cards', function () { const result = mobiledocToLexical( JSON.stringify({ diff --git a/koenig/kg-default-nodes/src/nodes/product/ProductNode.ts b/koenig/kg-default-nodes/src/nodes/product/ProductNode.ts index 618e7eb860f..c0f1c7b6e45 100644 --- a/koenig/kg-default-nodes/src/nodes/product/ProductNode.ts +++ b/koenig/kg-default-nodes/src/nodes/product/ProductNode.ts @@ -6,6 +6,7 @@ const productProperties = { productImageSrc: {default: '', urlType: 'url'}, productImageWidth: {default: null as number | null}, productImageHeight: {default: null as number | null}, + productImageAlt: {default: ''}, productTitle: {default: '', urlType: 'html', wordCount: true}, productDescription: {default: '', urlType: 'html', wordCount: true}, productRatingEnabled: {default: false}, @@ -25,7 +26,7 @@ export class ProductNode extends generateDecoratorNode({ /* override */ exportJSON() { // checks if src is a data string - const {productImageSrc, productImageWidth, productImageHeight, productTitle, productDescription, productRatingEnabled, productStarRating, productButtonEnabled, productButton, productUrl} = this; + const {productImageSrc, productImageWidth, productImageHeight, productImageAlt, productTitle, productDescription, productRatingEnabled, productStarRating, productButtonEnabled, productButton, productUrl} = this; const isBlob = productImageSrc && productImageSrc.startsWith('data:'); const dataset = { @@ -34,6 +35,7 @@ export class ProductNode extends generateDecoratorNode({ productImageSrc: isBlob ? '' : productImageSrc, productImageWidth, productImageHeight, + productImageAlt, productTitle, productDescription, productRatingEnabled, diff --git a/koenig/kg-default-nodes/src/nodes/product/product-parser.ts b/koenig/kg-default-nodes/src/nodes/product/product-parser.ts index 03eaf2a3637..3fc7e9e5db8 100644 --- a/koenig/kg-default-nodes/src/nodes/product/product-parser.ts +++ b/koenig/kg-default-nodes/src/nodes/product/product-parser.ts @@ -22,6 +22,10 @@ export function parseProductNode(ProductNode: new (data: Record if (img && img.getAttribute('src')) { payload.productImageSrc = img.getAttribute('src'); + if (img.getAttribute('alt')) { + payload.productImageAlt = img.getAttribute('alt'); + } + if (img.getAttribute('width')) { const productImageWidth = Number(img.getAttribute('width')); diff --git a/koenig/kg-default-nodes/src/nodes/product/product-renderer.ts b/koenig/kg-default-nodes/src/nodes/product/product-renderer.ts index d66050e9a0d..16834229366 100644 --- a/koenig/kg-default-nodes/src/nodes/product/product-renderer.ts +++ b/koenig/kg-default-nodes/src/nodes/product/product-renderer.ts @@ -4,6 +4,7 @@ import {renderEmptyContainer} from '../../utils/render-empty-container.js'; import {getFirstHtmlElement} from '../../utils/get-first-html-element.js'; import {getResizedImageDimensions} from '../../utils/get-resized-image-dimensions.js'; import {renderEmailButton} from '../../utils/render-helpers/email-button.js'; +import {escapeHtml} from '../../utils/escape-html.js'; interface ProductNodeData { productStarRating: number; @@ -19,6 +20,7 @@ interface ProductTemplateBaseData { productImageSrc: string; productImageWidth: number | null; productImageHeight: number | null; + productImageAlt: string; productTitle: string; productDescription: string; productRatingEnabled: boolean; @@ -86,7 +88,7 @@ export function cardTemplate({data}: {data: ProductTemplateData; feature?: Expor `
- ${data.productImageSrc ? `` : ''} + ${data.productImageSrc ? `${escapeHtml(data.productImageAlt)}` : ''}

${data.productTitle}

@@ -139,7 +141,7 @@ export function emailCardTemplate({data}: {data: ProductTemplateData; feature?: ${data.productImageSrc ? ` - + ${escapeHtml(data.productImageAlt)} ` : ''} diff --git a/koenig/kg-default-nodes/test/nodes/product.test.ts b/koenig/kg-default-nodes/test/nodes/product.test.ts index f619a7edad5..888f608db85 100644 --- a/koenig/kg-default-nodes/test/nodes/product.test.ts +++ b/koenig/kg-default-nodes/test/nodes/product.test.ts @@ -30,6 +30,7 @@ describe('ProductNode', function () { expect(productNode.productImageSrc).toBe(data.productImageSrc); expect(productNode.productImageWidth!).toBe(data.productImageWidth); expect(productNode.productImageHeight!).toBe(data.productImageHeight); + expect(productNode.productImageAlt).toBe(data.productImageAlt); expect(productNode.productTitle).toBe(data.productTitle); expect(productNode.productDescription).toBe(data.productDescription); expect(productNode.productRatingEnabled).toBe(true); @@ -46,6 +47,7 @@ describe('ProductNode', function () { productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', productImageWidth: 200, productImageHeight: 100, + productImageAlt: 'A camera on a wooden table', productTitle: 'This is a title', productDescription: 'This is a description', productRatingEnabled: true, @@ -86,6 +88,10 @@ describe('ProductNode', function () { productNode.productImageHeight = 700; expect(productNode.productImageHeight).toBe(700); + expect(productNode.productImageAlt).toBe(''); + productNode.productImageAlt = 'A camera on a wooden table'; + expect(productNode.productImageAlt).toBe('A camera on a wooden table'); + expect(productNode.productTitle).toBe(''); productNode.productTitle = 'Title'; expect(productNode.productTitle).toBe('Title'); @@ -184,6 +190,7 @@ describe('ProductNode', function () { productImageSrc: dataset.productImageSrc, productImageWidth: dataset.productImageWidth, productImageHeight: dataset.productImageHeight, + productImageAlt: dataset.productImageAlt, productTitle: dataset.productTitle, productDescription: dataset.productDescription, productRatingEnabled: dataset.productRatingEnabled, @@ -248,7 +255,7 @@ describe('ProductNode', function () { const element = result.element as HTMLElement; assertPrettifiesTo(element.outerHTML, ` -

Product title!

This product is ok
Click me
+

Product title!

This product is ok
Click me
`); })); @@ -273,9 +280,51 @@ describe('ProductNode', function () { const element = result.element as HTMLElement; assertPrettifiesTo(element.outerHTML, ` -

Product title!

This product is ok
Click me
+

Product title!

This product is ok
Click me
`); })); + + it('renders image alt text', editorTest(function () { + const productNode = $createProductNode({ + productImageSrc: 'https://example.com/images/ok.jpg', + productImageAlt: 'A camera on a wooden table', + productTitle: 'Product title!' + }); + const result = productNode.exportDOM(editor, exportOptions); + const element = result.element as HTMLElement; + const img = element.querySelector('img.kg-product-card-image')!; + + expect(img.getAttribute('alt')).toBe('A camera on a wooden table'); + })); + + it('escapes image alt text', editorTest(function () { + const productNode = $createProductNode({ + productImageSrc: 'https://example.com/images/ok.jpg', + productImageAlt: 'Fits "most" cameras & ', + productTitle: 'Product title!' + }); + const result = productNode.exportDOM(editor, exportOptions); + const element = result.element as HTMLElement; + const img = element.querySelector('img.kg-product-card-image')!; + + // unescaped quotes would truncate the attribute when the HTML is parsed + expect(img.getAttribute('alt')).toBe('Fits "most" cameras & '); + expect(element.outerHTML).toContain('alt="Fits "most" cameras & '); + })); + + it('renders escaped image alt text in email', editorTest(function () { + const productNode = $createProductNode({ + productImageSrc: 'https://example.com/images/ok.jpg', + productImageAlt: 'Fits "most" cameras & ', + productTitle: 'Product title!' + }); + const result = productNode.exportDOM(editor, {...exportOptions, target: 'email'}); + const element = result.element as HTMLElement; + const img = element.querySelector('td.kg-product-image img')!; + + expect(img.getAttribute('alt')).toBe('Fits "most" cameras & '); + expect(element.outerHTML).toContain('alt="Fits "most" cameras & '); + })); }); describe('importDOM', function () { @@ -290,6 +339,7 @@ describe('ProductNode', function () { expect($isProductNode(productNode)).toBe(true); expect(productNode.productImageSrc).toBe('https://example.com/images/ok.jpg'); + expect(productNode.productImageAlt).toBe(''); expect(productNode.productTitle).toBe('Product title!'); expect(productNode.productDescription).toBe('This product is ok'); expect(productNode.productRatingEnabled).toBe(true); @@ -369,6 +419,22 @@ describe('ProductNode', function () { expect(productNode.productImageHeight).toBe(null); })); + it('parses product card image alt text', editorTest(function () { + const document = createDocument(html` +
Fits "most" cameras & <lenses>

Product title!

This product is ok
+ `); + const nodes = $generateNodesFromDOM(editor, document); + expect(nodes.length).toBe(1); + + const productNode = nodes[0] as ProductNode; + expect($isProductNode(productNode)).toBe(true); + + expect(productNode.productImageSrc).toBe('https://example.com/images/ok.jpg'); + expect(productNode.productImageAlt).toBe('Fits "most" cameras & '); + expect(productNode.productImageWidth!).toBe(200); + expect(productNode.productImageHeight!).toBe(100); + })); + it('handles arbitrary whitespace in button content', editorTest(function () { const document = createDocument(html`
diff --git a/koenig/kg-default-nodes/test/renderers/product-renderer.test.ts b/koenig/kg-default-nodes/test/renderers/product-renderer.test.ts index d1ff9331ad7..c25757f58e7 100644 --- a/koenig/kg-default-nodes/test/renderers/product-renderer.test.ts +++ b/koenig/kg-default-nodes/test/renderers/product-renderer.test.ts @@ -35,7 +35,7 @@ describe('renderers/product-renderer', function () { assertPrettifiesTo(result.html, html`
- +

This is a title

@@ -74,6 +74,14 @@ describe('renderers/product-renderer', function () { `); }); + it('renders escaped image alt text', function () { + const result = renderForWeb(getTestData({productImageAlt: 'Fits "most" cameras & lenses'})); + + assertPrettifiedIncludes(result.html, html` + Fits "most" cameras & lenses + `); + }); + it('renders nothing with a missing data', function () { const result = renderForWeb(getTestData({isEmpty: () => true})); assert.equal(result.html, ''); @@ -176,6 +184,7 @@ describe('renderers/product-renderer', function () { src="/content/images/2022/11/koenig-lexical.jpg" width="200" height="100" + alt="" border="0" /> @@ -222,6 +231,14 @@ describe('renderers/product-renderer', function () { `); }); + it('renders escaped image alt text', function () { + const result = renderForEmail(getTestData({productImageAlt: 'Fits "most" cameras & lenses'}), {feature: {}}); + + assertPrettifiedIncludes(result.html, html` + Fits "most" cameras & lenses + `); + }); + it('renders nothing with a missing data', function () { const result = renderForEmail(getTestData({isEmpty: () => true}), {feature: {}}); assert.equal(result.html, ''); @@ -266,7 +283,7 @@ describe('renderers/product-renderer', function () { - + @@ -322,7 +339,7 @@ describe('renderers/product-renderer', function () { - + diff --git a/koenig/kg-html-to-lexical/test/html-to-lexical.test.ts b/koenig/kg-html-to-lexical/test/html-to-lexical.test.ts index 896a8ecb0e2..54ff21f2998 100644 --- a/koenig/kg-html-to-lexical/test/html-to-lexical.test.ts +++ b/koenig/kg-html-to-lexical/test/html-to-lexical.test.ts @@ -1022,6 +1022,7 @@ describe('HTMLtoLexical', function () { src="__GHOST_URL__/content/images/2023/10/CleanShot-2023-07-19-at-12.57.37@2x.png" width="724" height="76" + alt="product image" class="kg-product-card-image" loading="lazy" /> @@ -1216,6 +1217,34 @@ describe('HTMLtoLexical', function () { 'signup', ]); }); + + it('keeps product card image alt text', function () { + const html = ` +
+
+ A camera on a wooden table +
+

Product title

+
+
Product description
+
+
+ `; + + const lexical = htmlToLexical(html, options); + const [product] = lexical.root.children; + + assert.equal(product.type, 'product'); + assert.equal(product.productImageSrc, 'https://example.com/images/camera.jpg'); + assert.equal(product.productImageAlt, 'A camera on a wooden table'); + }); }); describe('BRs', function () { diff --git a/koenig/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx b/koenig/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx index b732c448266..c2220e13a94 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx @@ -143,5 +143,6 @@ Populated.args = { buttonUrl: 'https://ghost.org/', rating: 4, imgMimeTypes: ['image/*'], - imgSrc: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg' + imgSrc: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + imgAlt: 'Fujifilm X100V camera on a wooden table' }; diff --git a/koenig/koenig-lexical/src/components/ui/cards/ProductCard.tsx b/koenig/koenig-lexical/src/components/ui/cards/ProductCard.tsx index 26439243e74..89266f83fed 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/ProductCard.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/ProductCard.tsx @@ -9,6 +9,7 @@ import {isEditorEmpty} from '../../../utils/isEditorEmpty'; export function ProductCard({ isEditing, + imgAlt, imgSrc, isButtonEnabled, buttonText, @@ -20,6 +21,7 @@ export function ProductCard({ onButtonUrlChange, onRatingToggle, imgDragHandler, + onImgAltChange, onImgChange, imgMimeTypes, imgUploader, @@ -38,6 +40,7 @@ export function ProductCard({ <>
@@ -137,6 +141,7 @@ export function ProductCard({ ProductCard.propTypes = { isEditing: PropTypes.bool, + imgAlt: PropTypes.string, imgSrc: PropTypes.string, isButtonEnabled: PropTypes.bool, buttonText: PropTypes.string, @@ -147,6 +152,7 @@ ProductCard.propTypes = { onButtonTextChange: PropTypes.func, onButtonUrlChange: PropTypes.func, onRatingToggle: PropTypes.func, + onImgAltChange: PropTypes.func, onImgChange: PropTypes.func, onRemoveImage: PropTypes.func, imgDragHandler: PropTypes.object, diff --git a/koenig/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx b/koenig/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx index 94f18920d7e..9b5f48e4ea0 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx @@ -4,13 +4,16 @@ import WandIcon from '../../../../assets/icons/kg-wand.svg?react'; import {IconButton} from '../../IconButton.jsx'; import {MediaPlaceholder} from '../../MediaPlaceholder.jsx'; import {ProgressBar} from '../../ProgressBar.jsx'; +import {TextInput} from '../../TextInput'; import {openFileSelection} from '../../../../utils/openFileSelection.js'; export function ProductCardImage({ imgSrc, + imgAlt = '', imgUploader = {}, imgDragHandler = {}, onImgChange, + onImgAltChange, imgMimeTypes, onRemoveImage, isPinturaEnabled, @@ -18,95 +21,148 @@ export function ProductCardImage({ isEditing }) { const fileInputRef = React.useRef(null); + const [isEditingAlt, setIsEditingAlt] = React.useState(false); + + // always close the alt input when leaving edit mode or when the image goes away + React.useEffect(() => { + if (!isEditing || !imgSrc) { + setIsEditingAlt(false); + } + }, [isEditing, imgSrc]); const onRemove = (e) => { e.stopPropagation(); // prevents card from losing selected state onRemoveImage(); }; + const toggleIsEditingAlt = (e) => { + e.stopPropagation(); // prevents card from losing editing state + setIsEditingAlt(!isEditingAlt); + }; + + const handleAltChange = (e) => { + onImgAltChange?.(e.target.value); + }; + const showPlaceholder = imgDragHandler.isDraggedOver || !imgSrc; + const showAltToggle = isEditing && !showPlaceholder; + const showAltInput = showAltToggle && isEditingAlt; const progressStyle = { width: `${imgUploader.progress?.toFixed(0)}%` }; return ( -
- { - showPlaceholder - ? ( - <> - openFileSelection({fileInputRef})} - icon='product' - isDraggedOver={imgDragHandler.isDraggedOver} - placeholderRef={imgDragHandler.setRef} - size='small' - /> - -
- +
+ { + showPlaceholder + ? ( + <> + openFileSelection({fileInputRef})} + icon='product' + isDraggedOver={imgDragHandler.isDraggedOver} + placeholderRef={imgDragHandler.setRef} + size='small' /> - - - ) - : ( - <> - Product thumbnail - { - isEditing && ( - <> -
- - ) - } +
+ +
+ + ) + : ( + <> + {imgAlt} - { - isEditing && ( - <> -
- -
- - ) - } + { + isEditing && ( + <> +
+ + ) + } + + { + isEditing && ( + <> +
+ +
+ + ) + } - { - isEditing && isPinturaEnabled && ( - <> -
- openImageEditor({ - image: imgSrc, - handleSave: (editedImage) => { - onImgChange({ - target: { - files: [editedImage] - } - }); - } - })} /> + { + isEditing && isPinturaEnabled && ( + <> +
+ openImageEditor({ + image: imgSrc, + handleSave: (editedImage) => { + onImgChange({ + target: { + files: [editedImage] + } + }); + } + })} /> +
+ + ) + } + + { + showAltToggle && ( + + ) + } + + { + imgUploader.isLoading && ( +
+
- - ) - } + ) + } + + ) + } +
- { - imgUploader.isLoading && ( -
- -
- ) - } - - ) + { + showAltInput && ( +
+ +
+ ) } -
+ ); } diff --git a/koenig/koenig-lexical/src/nodes/ProductNode.tsx b/koenig/koenig-lexical/src/nodes/ProductNode.tsx index c5fe35dd06c..823045a140f 100644 --- a/koenig/koenig-lexical/src/nodes/ProductNode.tsx +++ b/koenig/koenig-lexical/src/nodes/ProductNode.tsx @@ -91,6 +91,7 @@ export class ProductNode extends BaseProductNode { description={this.productDescription} descriptionEditor={this.__productDescriptionEditor} descriptionEditorInitialState={this.__productDescriptionEditorInitialState} + imgAlt={this.productImageAlt} imgHeight={this.productImageHeight} imgSrc={this.productImageSrc} imgWidth={this.productImageWidth} diff --git a/koenig/koenig-lexical/src/nodes/ProductNodeComponent.tsx b/koenig/koenig-lexical/src/nodes/ProductNodeComponent.tsx index a2a58aef904..d09116857b3 100644 --- a/koenig/koenig-lexical/src/nodes/ProductNodeComponent.tsx +++ b/koenig/koenig-lexical/src/nodes/ProductNodeComponent.tsx @@ -15,6 +15,7 @@ export function ProductNodeComponent({ nodeKey, buttonText, buttonUrl, + imgAlt, imgHeight, imgSrc, imgWidth, @@ -76,6 +77,14 @@ export function ProductNodeComponent({ editor.update(() => { const node = $getNodeByKey(nodeKey); node.productImageSrc = ''; + node.productImageAlt = ''; + }); + }; + + const handleImgAltChange = (value) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + node.productImageAlt = value; }); }; @@ -132,6 +141,7 @@ export function ProductNodeComponent({ description={description} descriptionEditor={descriptionEditor} descriptionEditorInitialState={descriptionEditorInitialState} + imgAlt={imgAlt} imgDragHandler={imgDragHandler} imgHeight={imgHeight} imgMimeTypes={imgMimeTypes} @@ -150,6 +160,7 @@ export function ProductNodeComponent({ onButtonTextChange={handleButtonTextChange} onButtonToggle={handleButtonToggle} onButtonUrlChange={handleButtonUrlChange} + onImgAltChange={handleImgAltChange} onImgChange={handleImgChange} onRatingChange={handleRatingChange} onRatingToggle={handleRatingToggle} diff --git a/koenig/koenig-lexical/test/e2e/cards/product-card.test.ts b/koenig/koenig-lexical/test/e2e/cards/product-card.test.ts index 66aaf5de4a9..2fb5bbd01a9 100644 --- a/koenig/koenig-lexical/test/e2e/cards/product-card.test.ts +++ b/koenig/koenig-lexical/test/e2e/cards/product-card.test.ts @@ -1,5 +1,5 @@ import path from 'path'; -import {assertHTML, createDataTransfer, createSnippet, focusEditor, html, initialize, insertCard, isMac} from '../../utils/e2e'; +import {assertHTML, createDataTransfer, createSnippet, focusEditor, getEditorStateJSON, html, initialize, insertCard, isMac} from '../../utils/e2e'; import {expect, test} from '@playwright/test'; import {fileURLToPath} from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -54,7 +54,7 @@ test.describe('Product card', async () => {
Product thumbnail
@@ -192,6 +192,98 @@ test.describe('Product card', async () => { await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); }); + test('can set image alt text', async function () { + await withWideViewport(page, async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + await expect(page.getByTestId('product-card-image')).toBeVisible(); + + // toggle is visible while editing with an image, input stays hidden until toggled + const altToggle = page.getByTestId('product-image-alt-toggle'); + const altInput = page.getByTestId('product-image-alt-input'); + await expect(altToggle).toBeVisible(); + await expect(altInput).toBeHidden(); + + await altToggle.click(); + await expect(altInput).toBeVisible(); + await altInput.fill('A camera on a wooden table'); + + // preview image and serialized node both carry the alt text + await expect(page.getByTestId('product-card-image')).toHaveAttribute('alt', 'A camera on a wooden table'); + const editorState = JSON.parse(await getEditorStateJSON(page)); + expect(editorState.root.children[0].productImageAlt).toEqual('A camera on a wooden table'); + + // toggling again hides the input but keeps the value + await altToggle.click(); + await expect(altInput).toBeHidden(); + await expect(page.getByTestId('product-card-image')).toHaveAttribute('alt', 'A camera on a wooden table'); + }); + }); + + test('hides alt input when leaving edit mode', async function () { + await withWideViewport(page, async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + await expect(page.getByTestId('product-card-image')).toBeVisible(); + + await page.getByTestId('product-image-alt-toggle').click(); + await page.getByTestId('product-image-alt-input').fill('A camera on a wooden table'); + + await page.keyboard.press('Escape'); + await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-editing', 'false'); + await expect(page.getByTestId('product-image-alt-input')).toBeHidden(); + await expect(page.getByTestId('product-image-alt-toggle')).toBeHidden(); + + // re-entering edit mode shows the toggle but not the input + await page.getByTestId('edit-product-card').click(); + await expect(page.getByTestId('product-image-alt-toggle')).toBeVisible(); + await expect(page.getByTestId('product-image-alt-input')).toBeHidden(); + }); + }); + + test('clears alt text when image is removed', async function () { + await withWideViewport(page, async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + await expect(page.getByTestId('product-card-image')).toBeVisible(); + + await page.getByTestId('product-image-alt-toggle').click(); + await page.getByTestId('product-image-alt-input').fill('A camera on a wooden table'); + + await page.getByTestId('replace-product-image').click(); + await expect(page.getByTestId('media-placeholder')).toBeVisible(); + await expect(page.getByTestId('product-image-alt-input')).toBeHidden(); + + const editorState = JSON.parse(await getEditorStateJSON(page)); + expect(editorState.root.children[0].productImageSrc).toEqual(''); + expect(editorState.root.children[0].productImageAlt).toEqual(''); + }); + }); + + test('keeps the caret position while editing alt text', async function () { + await withWideViewport(page, async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + await expect(page.getByTestId('product-card-image')).toBeVisible(); + + await page.getByTestId('product-image-alt-toggle').click(); + const altInput = page.getByTestId('product-image-alt-input'); + await altInput.fill('camera on a table'); + + // move the caret to the start and type: each character must land at the caret + await page.keyboard.press('Home'); + await page.keyboard.type('A '); + + await expect(altInput).toHaveValue('A camera on a table'); + const editorState = JSON.parse(await getEditorStateJSON(page)); + expect(editorState.root.children[0].productImageAlt).toEqual('A camera on a table'); + }); + }); + test('can show/hide rating starts if rating enabled/disabled', async function () { await focusEditor(page); await insertCard(page, {cardName: 'product'}); @@ -445,7 +537,7 @@ test.describe('Product card', async () => {
Product thumbnail
@@ -538,7 +630,7 @@ test.describe('Product card', async () => {
Product thumbnail
@@ -713,3 +805,15 @@ async function uploadImg(page, src = 'large-image.png') { const fileChooser = await fileChooserPromise; await fileChooser.setFiles([imagePath]); } + +// the settings panel overlaps the image's bottom-right corner at the default +// test viewport; the alt tests need it to sit beside the card instead +async function withWideViewport(page, fn) { + const originalViewport = page.viewportSize(); + await page.setViewportSize({width: 1400, height: 1000}); + try { + await fn(); + } finally { + await page.setViewportSize(originalViewport); + } +} diff --git a/koenig/koenig-lexical/test/unit/productCard.test.ts b/koenig/koenig-lexical/test/unit/productCard.test.ts index 02d18f9e201..6848bdc9a5e 100644 --- a/koenig/koenig-lexical/test/unit/productCard.test.ts +++ b/koenig/koenig-lexical/test/unit/productCard.test.ts @@ -72,4 +72,19 @@ describe('ProductNode', function () { expect(title).toEqual('Hello title land baaaabeee.'); })); }); + + describe('image alt text', function () { + it('exports productImageAlt', editorTest(function () { + dataset.productImageAlt = 'A camera on a wooden table'; + const productNode = $createProductNode(dataset); + const json = productNode.exportJSON(); + expect(json.productImageAlt).toEqual('A camera on a wooden table'); + })); + + it('defaults productImageAlt to an empty string', editorTest(function () { + const productNode = $createProductNode(dataset); + const json = productNode.exportJSON(); + expect(json.productImageAlt).toEqual(''); + })); + }); }); \ No newline at end of file