diff --git a/config/fileMock.js b/config/fileMock.js
new file mode 100644
index 00000000..86059f36
--- /dev/null
+++ b/config/fileMock.js
@@ -0,0 +1 @@
+module.exports = 'test-file-stub';
diff --git a/cypress/component/ProductNudgeDataModal.cy.tsx b/cypress/component/ProductNudgeDataModal.cy.tsx
new file mode 100644
index 00000000..fc6834f2
--- /dev/null
+++ b/cypress/component/ProductNudgeDataModal.cy.tsx
@@ -0,0 +1,74 @@
+import { createElement } from 'react';
+import { ProductNudgeDataModal } from '../../packages/module/dist/dynamic/ProductNudge';
+
+const props = {
+ isOpen: true,
+ onClose: () => undefined,
+ brand: 'lightwell' as const,
+ titleText: 'Lightwell Lens',
+ matchData: { exact: 118, partial: 195, noMatch: 534 },
+ ecosystemData: [
+ { name: 'Java', exact: 200, partial: 120, noMatch: 180 },
+ { name: 'Python', exact: 50, partial: 80, noMatch: 170 },
+ ],
+};
+
+const expectTextInsideChart = (tooltipText: string) => {
+ cy.contains('text', tooltipText).should('exist').then(($text) => {
+ const textBounds = $text[0].getBoundingClientRect();
+ const svgBounds = $text[0].ownerSVGElement?.getBoundingClientRect();
+
+ if (!svgBounds) {
+ throw new Error('Expected the tooltip label to belong to an SVG chart');
+ }
+
+ expect(textBounds.left).to.be.at.least(svgBounds.left - 1);
+ expect(textBounds.right).to.be.at.most(svgBounds.right + 1);
+ expect(textBounds.top).to.be.at.least(svgBounds.top - 1);
+ expect(textBounds.bottom).to.be.at.most(svgBounds.bottom + 1);
+ });
+};
+
+describe('ProductNudgeDataModal chart theming and tooltips', () => {
+ afterEach(() => {
+ cy.get('html').invoke('removeClass', 'pf-v6-theme-dark');
+ });
+
+ it('resolves chart fills and legend text through PatternFly light/dark tokens', () => {
+ cy.mount(createElement(ProductNudgeDataModal, props));
+
+ const noMatchFill = 'svg path[style*="lightwell-chart-color-no-match"]';
+ const legendLabel = '[aria-label="By ecosystem chart"] svg text tspan';
+ const donutTitle = 'svg text tspan';
+ let lightDonutTitleFill: string;
+
+ cy.get(noMatchFill).first().should('have.css', 'fill', 'rgb(224, 224, 224)');
+ cy.get(legendLabel).filter((_index, element) => element.textContent === 'Exact match')
+ .should('have.css', 'fill', 'rgb(31, 31, 31)');
+ cy.get(donutTitle).filter((_index, element) => element.textContent === '313').then(($title) => {
+ lightDonutTitleFill = getComputedStyle($title[0]).fill;
+ });
+
+ cy.get('html').invoke('addClass', 'pf-v6-theme-dark');
+
+ cy.get(noMatchFill).first().should('have.css', 'fill', 'rgb(112, 112, 112)');
+ cy.get(legendLabel).filter((_index, element) => element.textContent === 'Exact match')
+ .should('have.css', 'fill', 'rgb(255, 255, 255)');
+ cy.get(donutTitle).filter((_index, element) => element.textContent === '313').should(($title) => {
+ expect(getComputedStyle($title[0]).fill).not.to.equal(lightDonutTitleFill);
+ });
+ });
+
+ it('keeps donut and tall-bar tooltips inside their chart viewports', () => {
+ cy.mount(createElement(ProductNudgeDataModal, props));
+
+ cy.get('svg path[style*="lightwell-chart-color-partial"]')
+ .first()
+ .trigger('mouseover');
+ expectTextInsideChart('partial matches: 195');
+
+ cy.get('[aria-label="By ecosystem chart"] svg path[style*="lightwell-chart-color-exact"][index="0"]')
+ .trigger('mouseover');
+ expectTextInsideChart('Exact match: 200');
+ });
+});
diff --git a/cypress/support/component.ts b/cypress/support/component.ts
index 2f8db4c5..81c27b48 100644
--- a/cypress/support/component.ts
+++ b/cypress/support/component.ts
@@ -26,6 +26,8 @@ import { mount } from 'cypress/react18'
import '@patternfly/patternfly/patternfly.css';
// Patternfly utilities
import '@patternfly/patternfly/patternfly-addons.css';
+// PatternFly chart tokens, including dark-theme chart colors
+import '@patternfly/patternfly/patternfly-charts.css';
// Global theme CSS
import '@patternfly/documentation-framework/global.css';
@@ -46,4 +48,4 @@ Cypress.Commands.add('mount', mount)
Cypress.on('uncaught:exception', (_err, _runnable) => false)
// Example use:
-// cy.mount()
\ No newline at end of file
+// cy.mount()
diff --git a/jest.config.js b/jest.config.js
index f5f860bc..8241d439 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -33,6 +33,7 @@ module.exports = {
],
moduleNameMapper: {
'\\.(css|scss)$': 'identity-obj-proxy',
+ '\\.(svg|png)$': '/config/fileMock.js',
'^lodash-es$': 'lodash',
customReact: 'react',
reactRedux: 'react-redux',
diff --git a/package-lock.json b/package-lock.json
index 91c141b4..802aa47e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"packages/*"
],
"dependencies": {
+ "@patternfly/patternfly": "^6.6.1",
"@patternfly/react-drag-drop": "^6.6.2",
"@patternfly/react-tokens": "^6.0.0"
},
@@ -68,6 +69,12 @@
"ts-jest": "29.1.5",
"wait-on": "^7.2.0",
"whatwg-fetch": "^3.6.20"
+ },
+ "peerDependencies": {
+ "victory-area": "^37.3.6",
+ "victory-axis": "^37.3.6",
+ "victory-box-plot": "^37.3.6",
+ "victory-chart": "^37.3.6"
}
},
"node_modules/@adobe/css-tools": {
@@ -5529,10 +5536,9 @@
}
},
"node_modules/@patternfly/patternfly": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-6.2.3.tgz",
- "integrity": "sha512-FR027W7JygcQpvlRU/Iom936Vm0apzfi2o5lvtlcWW6IaeZCCTtTaDxehoYuELHlemzkLziQAgu6LuCJEVayjw==",
- "dev": true,
+ "version": "6.6.1",
+ "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-6.6.1.tgz",
+ "integrity": "sha512-8P9C1TmFk5y5s2/1XVjmP9JmSyDXDiNBwPZewhPizULtqlaRu+Evljx/X1HTSFrCNY0qgI1uDKy+NXiorIniyA==",
"license": "MIT"
},
"node_modules/@patternfly/patternfly-a11y": {
@@ -5579,6 +5585,98 @@
"node": ">=18"
}
},
+ "node_modules/@patternfly/react-charts": {
+ "version": "8.6.1",
+ "resolved": "https://registry.npmjs.org/@patternfly/react-charts/-/react-charts-8.6.1.tgz",
+ "integrity": "sha512-HkYmBanA3KmP/+dw3sbCGITjCif+r1EDzYcfjzLUUshllp55zBMv4UraEpV+pSwSv3fz1SfMQm7Dt5UXL0CWzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@patternfly/react-styles": "^6.6.1",
+ "@patternfly/react-tokens": "^6.6.1",
+ "hoist-non-react-statics": "^3.3.2",
+ "lodash": "^4.17.23",
+ "tslib": "^2.8.1"
+ },
+ "peerDependencies": {
+ "echarts": "^5.6.0 || ^6.0.0",
+ "react": "^17 || ^18 || ^19",
+ "react-dom": "^17 || ^18 || ^19",
+ "victory-area": "^37.3.6",
+ "victory-axis": "^37.3.6",
+ "victory-bar": "^37.3.6",
+ "victory-box-plot": "^37.3.6",
+ "victory-chart": "^37.3.6",
+ "victory-core": "^37.3.6",
+ "victory-create-container": "^37.3.6",
+ "victory-cursor-container": "^37.3.6",
+ "victory-group": "^37.3.6",
+ "victory-legend": "^37.3.6",
+ "victory-line": "^37.3.6",
+ "victory-pie": "^37.3.6",
+ "victory-scatter": "^37.3.6",
+ "victory-stack": "^37.3.6",
+ "victory-tooltip": "^37.3.6",
+ "victory-voronoi-container": "^37.3.6",
+ "victory-zoom-container": "^37.3.6"
+ },
+ "peerDependenciesMeta": {
+ "echarts": {
+ "optional": true
+ },
+ "victory-area": {
+ "optional": true
+ },
+ "victory-axis": {
+ "optional": true
+ },
+ "victory-bar": {
+ "optional": true
+ },
+ "victory-box-plot": {
+ "optional": true
+ },
+ "victory-chart": {
+ "optional": true
+ },
+ "victory-core": {
+ "optional": true
+ },
+ "victory-create-container": {
+ "optional": true
+ },
+ "victory-cursor-container": {
+ "optional": true
+ },
+ "victory-group": {
+ "optional": true
+ },
+ "victory-legend": {
+ "optional": true
+ },
+ "victory-line": {
+ "optional": true
+ },
+ "victory-pie": {
+ "optional": true
+ },
+ "victory-scatter": {
+ "optional": true
+ },
+ "victory-stack": {
+ "optional": true
+ },
+ "victory-tooltip": {
+ "optional": true
+ },
+ "victory-voronoi-container": {
+ "optional": true
+ },
+ "victory-zoom-container": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@patternfly/react-code-editor": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/@patternfly/react-code-editor/-/react-code-editor-6.6.2.tgz",
@@ -6343,27 +6441,19 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
- "dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
- "dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
- "dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
- "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/d3-color": "*"
}
@@ -6372,17 +6462,13 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
- "dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
- "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/d3-time": "*"
}
@@ -6391,9 +6477,7 @@
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
- "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/d3-path": "*"
}
@@ -6402,15 +6486,11 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
- "dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
- "dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/eslint": {
"version": "9.6.0",
@@ -11390,9 +11470,7 @@
},
"node_modules/d3-array": {
"version": "3.2.4",
- "dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"internmap": "1 - 2"
},
@@ -11402,36 +11480,28 @@
},
"node_modules/d3-color": {
"version": "3.1.0",
- "dev": true,
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
- "dev": true,
"license": "BSD-3-Clause",
- "peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
- "dev": true,
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
- "dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"d3-color": "1 - 3"
},
@@ -11441,18 +11511,14 @@
},
"node_modules/d3-path": {
"version": "3.1.0",
- "dev": true,
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
- "dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
@@ -11466,9 +11532,7 @@
},
"node_modules/d3-shape": {
"version": "3.2.0",
- "dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"d3-path": "^3.1.0"
},
@@ -11478,9 +11542,7 @@
},
"node_modules/d3-time": {
"version": "3.1.0",
- "dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"d3-array": "2 - 3"
},
@@ -11490,9 +11552,7 @@
},
"node_modules/d3-time-format": {
"version": "4.1.0",
- "dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"d3-time": "1 - 3"
},
@@ -11502,13 +11562,18 @@
},
"node_modules/d3-timer": {
"version": "3.0.1",
- "dev": true,
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
},
+ "node_modules/d3-voronoi": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz",
+ "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -11832,14 +11897,12 @@
"node_modules/delaunator": {
"version": "4.0.1",
"dev": true,
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/delaunay-find": {
"version": "0.0.6",
"dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"delaunator": "^4.0.0"
}
@@ -15709,9 +15772,7 @@
},
"node_modules/internmap": {
"version": "2.0.3",
- "dev": true,
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -19347,7 +19408,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "dev": true,
"license": "ISC"
},
"node_modules/json5": {
@@ -23001,9 +23061,7 @@
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
- "dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-is": {
"version": "16.13.1",
@@ -27651,13 +27709,104 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/victory": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory/-/victory-37.3.6.tgz",
+ "integrity": "sha512-CZ1vjvra0R1U3T2dMI4EsjI8Ng+JmQ2ox/EweSzjkTnHfW/Vn5ylryadawDiYjDMcBvABjO3uODsIlSEm4d/Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "victory-area": "37.3.6",
+ "victory-axis": "37.3.6",
+ "victory-bar": "37.3.6",
+ "victory-box-plot": "37.3.6",
+ "victory-brush-container": "37.3.6",
+ "victory-brush-line": "37.3.6",
+ "victory-candlestick": "37.3.6",
+ "victory-canvas": "37.3.6",
+ "victory-chart": "37.3.6",
+ "victory-core": "37.3.6",
+ "victory-create-container": "37.3.6",
+ "victory-cursor-container": "37.3.6",
+ "victory-errorbar": "37.3.6",
+ "victory-group": "37.3.6",
+ "victory-histogram": "37.3.6",
+ "victory-legend": "37.3.6",
+ "victory-line": "37.3.6",
+ "victory-pie": "37.3.6",
+ "victory-polar-axis": "37.3.6",
+ "victory-scatter": "37.3.6",
+ "victory-selection-container": "37.3.6",
+ "victory-shared-events": "37.3.6",
+ "victory-stack": "37.3.6",
+ "victory-tooltip": "37.3.6",
+ "victory-voronoi": "37.3.6",
+ "victory-voronoi-container": "37.3.6",
+ "victory-zoom-container": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
+ "node_modules/victory-area": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-area/-/victory-area-37.3.6.tgz",
+ "integrity": "sha512-wVC8LKrZJLiSySNuJLRCB449qZTsPiRyzLlNoJwe21y+XA/a2HJbmJSeywmo8P153aX8viKe1H8ygDsTFXQhHw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "victory-core": "37.3.6",
+ "victory-vendor": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
+ "node_modules/victory-axis": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-axis/-/victory-axis-37.3.6.tgz",
+ "integrity": "sha512-Vi0dZvgmXmnCdoqc49WckeG5cMXnl7FTtqVhXu9JweA9cgCnkZabBd5mRvAjblb3Lo4j0HZCSPKHYWUPW70qZg==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "victory-core": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
"node_modules/victory-bar": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-bar/-/victory-bar-37.3.6.tgz",
"integrity": "sha512-jdATFRWL1LUW/yEpKWx/aId2BiU2o1pPF9+Kh1TFISBduJoI4ZqvZD90H1QK4f/z50PikqiqiDECaKoKM1jfOQ==",
"dev": true,
"license": "MIT",
- "peer": true,
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "victory-core": "37.3.6",
+ "victory-vendor": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
+ "node_modules/victory-box-plot": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-box-plot/-/victory-box-plot-37.3.6.tgz",
+ "integrity": "sha512-GOucnD63h14ScBuISC/nd1GBTEx6gIZfLE+0P0gyeH1poBKq0trTTvpQDvAMuGR8zICfEETG3ltmUMCwRrFyUg==",
+ "license": "MIT",
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6",
@@ -27676,7 +27825,6 @@
"integrity": "sha512-LfZ2CgX1cYAqCtYxcSB68OfZS2v0T2VLXoEArd0lCXfRBY1Gya7GacCUcuo7GoK9XOXeslx7S/U95aVutt1VLg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"react-fast-compare": "^3.2.0",
@@ -27689,13 +27837,84 @@
"react": ">=16.6.0"
}
},
+ "node_modules/victory-brush-line": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-brush-line/-/victory-brush-line-37.3.6.tgz",
+ "integrity": "sha512-zsZJfF1fUj4F7mUoIMV+h73qoTClPA4bKM1terlYrDBD8l/c/f0KBbEotu3E1X+n4QMmDRruswaB/YUdqK5QLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "react-fast-compare": "^3.2.0",
+ "victory-core": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
+ "node_modules/victory-candlestick": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-candlestick/-/victory-candlestick-37.3.6.tgz",
+ "integrity": "sha512-h/mOmkCrsWrirn4dFnpLxJPXpxT+uHxuYxnXGrAyH+YUOrVj3iKaDJlEiVlz5vy30syE5j5hzTQCMsZ/hzHNdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "victory-core": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
+ "node_modules/victory-canvas": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-canvas/-/victory-canvas-37.3.6.tgz",
+ "integrity": "sha512-1CD4S0uZ92sUGGSIEQferEfSqd/z9EXw9G6zkzPIoJeTKFshpfqCjUkNRx9Iu9Upxt3fUpId8Qwl1YfchmbrFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "victory-bar": "37.3.6",
+ "victory-core": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
+ "node_modules/victory-chart": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-chart/-/victory-chart-37.3.6.tgz",
+ "integrity": "sha512-IkPo/W4AJ7bPu902TGER09OseR9ODm+FQAKfOBw4JsdEhZZ7BiG9zgd/25+x0r5EsTLu81CYGQVkBa+ZazcOlA==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "react-fast-compare": "^3.2.0",
+ "victory-axis": "37.3.6",
+ "victory-core": "37.3.6",
+ "victory-polar-axis": "37.3.6",
+ "victory-shared-events": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
"node_modules/victory-core": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-core/-/victory-core-37.3.6.tgz",
"integrity": "sha512-aFgO6KokxPbUCPznZP5UPhOdI22pMuwDXKDt6eoQOnkVim66Ia+K95TQar2nwVKGYV5j26aKVf/n9blwphGJRw==",
- "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.21",
"react-fast-compare": "^3.2.0",
@@ -27714,7 +27933,6 @@
"integrity": "sha512-Uf5bFQvqUsXCjqpvBW4LhrdrHkM6dBqxYgub6FCsBb86f84xZQ3vY7jFkg/JfvF0oGKMoWXYYrYLC1sk+fcWVA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"victory-brush-container": "37.3.6",
@@ -27737,7 +27955,23 @@
"integrity": "sha512-+Oiw57d5nE+iq8As8RvepknzmNtKq1Gsc50u1X3IRd4jXtX8zqZrgXGlVZ+BP/tkLsWnGYVjKulwKBf2oaEUuw==",
"dev": true,
"license": "MIT",
- "peer": true,
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "victory-core": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
+ "node_modules/victory-errorbar": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-errorbar/-/victory-errorbar-37.3.6.tgz",
+ "integrity": "sha512-WGAv/qizOlfmwKv+Yfxr4q6pDgTfloNQwi3Z3M0h8povjMZt74tHYkvi/TASSRYr3zv5kjUqUJ28qAyGMWwryQ==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6"
@@ -27755,7 +27989,6 @@
"integrity": "sha512-kgy/Azl5BxwlJAV0KDPGypv35TMrOD1J2ZxnJW2Wyyq+e8i0GGBIv5MoBzou64BRsDlS9V0CYRIjnkHgrBpB5w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"react-fast-compare": "^3.2.0",
@@ -27769,13 +28002,32 @@
"react": ">=16.6.0"
}
},
+ "node_modules/victory-histogram": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-histogram/-/victory-histogram-37.3.6.tgz",
+ "integrity": "sha512-K4d43MpXHYnGCLEMzfRpJ+lCRRDKALPi/juxfMGVzBPzSMgjC8h9x6hKdxaejiTd/E04UdzNO7J24plL3Uz8rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "react-fast-compare": "^3.2.0",
+ "victory-bar": "37.3.6",
+ "victory-core": "37.3.6",
+ "victory-vendor": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
"node_modules/victory-legend": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-legend/-/victory-legend-37.3.6.tgz",
"integrity": "sha512-vRRrhj3/ENqKVLdaBMzEmR83N6BOjox1bthYT1eJjN2H5SIK35bxn30IkiV/Pz3y627EqZe4TAWaxc0jiJlCiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6"
@@ -27793,7 +28045,6 @@
"integrity": "sha512-Ke817uf/qFbN9jU7Dba7CrcHXYO5wAZuKKnyeHJmLDeQeFST0773xejnIuC+dBgZipjFr4KIbSd+VcUafFNE1g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6",
@@ -27812,7 +28063,6 @@
"integrity": "sha512-tvdgAZ/HQWlo3KDDe0XAVbizHuaNMbgkkiF7zfA7Ww+3bHSs+0P9dsDtK2xP365D8gBCOv8pWmuzvKRhzNbqeA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6",
@@ -27825,13 +28075,28 @@
"react": ">=16.6.0"
}
},
+ "node_modules/victory-polar-axis": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-polar-axis/-/victory-polar-axis-37.3.6.tgz",
+ "integrity": "sha512-RpFsCkzHezJq5P+C/wtVdjEHX25JIFsSgs6qYSnfr/hayaFbWgK5HhRFpriQm5hg61cx47WxAOLyHvzf0nasvw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.19",
+ "victory-core": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
"node_modules/victory-scatter": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-scatter/-/victory-scatter-37.3.6.tgz",
"integrity": "sha512-fp95zMTPXgW1cmTowzDXhn+KxePMVDrzU0lotsHQMdBV7eB+ioXdu9hORlx4VHmMYg2ihsGwRTF+VAZ7rGxphA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6"
@@ -27849,7 +28114,6 @@
"integrity": "sha512-gd3qODDlBtLEJM7+2jCXk2YcLBUmIpYEEHswytMhwc6zihxXipGBUHRulhLj/I05mKay2gaOAg5ewiJHd4Awgw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6"
@@ -27865,9 +28129,7 @@
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-shared-events/-/victory-shared-events-37.3.6.tgz",
"integrity": "sha512-ygrbOtzLUTbtKebacZKyQRekhSAROnAvMkVI/PKsAGsz0ClY9P7qDEJG7eTUUygjO6ax0tI6WNE6JogQzeD1gw==",
- "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"json-stringify-safe": "^5.0.1",
"lodash": "^4.17.19",
@@ -27887,7 +28149,6 @@
"integrity": "sha512-ldod04RdqGJGH5p5eWXCofdTkbhZqIp3iwW7NpxSbMDLs8zPQIVvDFVtuJgMwQiC5vnIpbhMmxVeFbr8m64ZKA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"react-fast-compare": "^3.2.0",
@@ -27907,7 +28168,6 @@
"integrity": "sha512-vqaJS9noauOqDDBBAV9Ln9duOY/i17h1DCfCPAqhwPFyvFbwKvAub9zPTeYWAm/14VvWX5O/0yekFCVbcC7hjg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6"
@@ -27923,9 +28183,7 @@
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
- "dev": true,
"license": "MIT AND ISC",
- "peer": true,
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
@@ -27943,13 +28201,30 @@
"d3-timer": "^3.0.1"
}
},
+ "node_modules/victory-voronoi": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-voronoi/-/victory-voronoi-37.3.6.tgz",
+ "integrity": "sha512-Q+1FWHp8IAbmDL9pGWS0y0N4Cb5qmD9OOgxoxCfIDsLlhGvd6LddhRoknWsN7WnreaK+XiwjSfQkdMTCZ4hdhQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "d3-voronoi": "^1.1.4",
+ "lodash": "^4.17.19",
+ "victory-core": "37.3.6"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0"
+ }
+ },
"node_modules/victory-voronoi-container": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-voronoi-container/-/victory-voronoi-container-37.3.6.tgz",
"integrity": "sha512-qAAG0rMuK7A4EoJ4cyUk5wNdOW+HuCXNKPOko+hYK6wWOYXJvFhiglYyA85a695YyAXECc6JyJS/crm4IOEFag==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"delaunay-find": "0.0.6",
"lodash": "^4.17.19",
@@ -27970,7 +28245,6 @@
"integrity": "sha512-AGL+k20mI44OL5b0VgIxlmnNSefIoFmbbim5NraPmIxbtns9qQW/56ivIncJcYomBungIx99gUpsEpcQaMNHgQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lodash": "^4.17.19",
"victory-core": "37.3.6"
@@ -29207,16 +29481,19 @@
},
"devDependencies": {
"@patternfly/documentation-framework": "^6.5.16",
- "@patternfly/patternfly": "^6.0.0",
+ "@patternfly/patternfly": "^6.6.1",
"@patternfly/patternfly-a11y": "^5.1.0",
+ "@patternfly/react-charts": "^8.2.1",
"@patternfly/react-code-editor": "^6.6.2",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "typescript": "^5.8.3"
+ "typescript": "^5.8.3",
+ "victory": "^37.3.6"
},
"peerDependencies": {
+ "@patternfly/react-charts": "^8.0.0",
"@patternfly/react-drag-drop": "^6.6.2",
"react": "^17 || ^18 || ^19",
"react-dom": "^17 || ^18 || ^19"
diff --git a/package.json b/package.json
index 33c0df0e..cfcc45f2 100644
--- a/package.json
+++ b/package.json
@@ -86,7 +86,14 @@
"sharp": "^0.35.5"
},
"dependencies": {
+ "@patternfly/patternfly": "^6.6.1",
"@patternfly/react-drag-drop": "^6.6.2",
"@patternfly/react-tokens": "^6.0.0"
+ },
+ "peerDependencies": {
+ "victory-area": "^37.3.6",
+ "victory-axis": "^37.3.6",
+ "victory-box-plot": "^37.3.6",
+ "victory-chart": "^37.3.6"
}
}
diff --git a/packages/module/package.json b/packages/module/package.json
index adc780b0..5a403514 100644
--- a/packages/module/package.json
+++ b/packages/module/package.json
@@ -5,7 +5,8 @@
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"scripts": {
- "build": "npm run build:index && npm run build:js && npm run build:esm && npm run build:fed:packages",
+ "build": "npm run build:index && npm run build:js && npm run build:esm && npm run build:fed:packages && npm run build:assets",
+ "build:assets": "mkdir -p dist/esm/ProductNudge/assets dist/cjs/ProductNudge/assets dist/dynamic/ProductNudge/assets && cp src/ProductNudge/assets/* dist/esm/ProductNudge/assets/ && cp src/ProductNudge/assets/* dist/cjs/ProductNudge/assets/ && cp src/ProductNudge/assets/* dist/dynamic/ProductNudge/assets/",
"build:watch": "npm run build:js && npm run build:esm -- --watch && npm run build:fed:packages -- --watch",
"build:esm": "tsc --build --verbose ./tsconfig.json",
"build:fed:packages": "node generate-fed-package-json.js",
@@ -38,19 +39,22 @@
"react-jss": "^10.10.0"
},
"peerDependencies": {
+ "@patternfly/react-charts": "^8.0.0",
"@patternfly/react-drag-drop": "^6.6.2",
"react": "^17 || ^18 || ^19",
"react-dom": "^17 || ^18 || ^19"
},
"devDependencies": {
"@patternfly/documentation-framework": "^6.5.16",
- "@patternfly/patternfly": "^6.0.0",
+ "@patternfly/patternfly": "^6.6.1",
"@patternfly/patternfly-a11y": "^5.1.0",
+ "@patternfly/react-charts": "^8.2.1",
"@patternfly/react-code-editor": "^6.6.2",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "typescript": "^5.8.3"
+ "typescript": "^5.8.3",
+ "victory": "^37.3.6"
}
}
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx
index a02f549d..df4a09e2 100644
--- a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx
@@ -100,5 +100,3 @@ export const FieldBuilderExample: React.FunctionComponent = () => {
);
};
-
-export default FieldBuilderExample;
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx
index d7e55f17..8e733651 100644
--- a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx
@@ -240,5 +240,3 @@ export const FieldBuilderSelectExample: React.FunctionComponent = () => {
);
};
-
-export default FieldBuilderSelectExample;
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudge.md b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudge.md
new file mode 100644
index 00000000..f4fa3170
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudge.md
@@ -0,0 +1,129 @@
+---
+section: extensions
+subsection: component-groups
+id: Product nudge
+source: react
+propComponents: [
+ 'ProductNudge',
+ 'ProductNudgeContactModal',
+ 'ProductNudgeStack',
+ 'ProductNudgeDescriptionItem',
+ 'ProductNudgeDataModal',
+ 'ProductNudgeModalFooter',
+ 'ProductNudgeBrandLogo',
+ 'NudgeContent',
+ 'NudgeContact',
+ 'NudgeMetric',
+ 'ProductNudgeImage',
+ 'ProductNudgeBrandAssets',
+ 'ProductNudgeContactFormField',
+ 'ProductNudgeAction',
+ 'ProductNudgeMatchData',
+ 'ProductNudgeEcosystemData'
+]
+sourceLink: https://github.com/patternfly/react-component-groups/blob/main/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudge.md
+---
+
+import ProductNudge from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+import { ProductNudgeContactModal, ProductNudgeStack, ProductNudgeDescriptionItem, ProductNudgeDataModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+import { lightwellBackgroundAssets } from '@patternfly/react-component-groups/dist/esm/ProductNudge/productNudgeLightwellBackgrounds';
+import { useState } from 'react';
+import { DescriptionList } from '@patternfly/react-core';
+
+A **product nudge** surfaces a product offer or upsell in context.
+
+To render a nudge, provide:
+
+- its `variant`
+- an `isEligible` value
+- `content` with a unique `id`, headline, body, and CTA
+
+The `variant` property selects the layout: `full-width`, `inline`, or `compact`. Each variant is described in its own example below. A link CTA uses an `href` property, whereas a contact CTA calls your `onAction` callback (not needed for link CTAs). The `behavior` property controls whether the nudge is persistent (the default), dismissible, or collapsible.
+
+Optional callbacks let your application respond to impressions, dismissals, and CTA actions: `onImpression` fires once when at least half of the nudge is visible, while the `onDismiss` and `onAction` callbacks run for their corresponding user actions. These callbacks do not persist dismissal state or implement a contact workflow; your application owns that behavior.
+
+Product copy is always supplied by the caller. Components that support `brand="lightwell"` use the included Lightwell visual assets and CTA styling. You can omit the `brand` property for neutral PatternFly styling, or provide image props for custom branding. Explicit image props take precedence over Lightwell assets, and the brand option never supplies product copy. Some examples on this page include a "Use Lightwell branding" checkbox so you can preview each component with and without the Lightwell preset.
+
+## Examples
+
+### Full-width variant
+
+The `full-width` variant spans the available width and uses a large CTA.
+
+The Lightwell preset's small SVG logo, logomark, and partner-logo assets ship with the package by default. Its hero background images (~2 MB combined) are published separately and are not included in the default import. To use the Lightwell hero background, import `lightwellBackgroundAssets` from `@patternfly/react-component-groups/dist/esm/ProductNudge/productNudgeLightwellBackgrounds` and spread it into `content.assets`, as shown below. Brand image sizing (`logoSize`/`logoOffset` and `logoMarkSize`/`logoMarkOffset`) and the hero background color (`backgroundColor`) can be overridden regardless of brand. To define a custom CTA color scheme, set `ctaColorScheme="custom"` and provide `ctaStyle`.
+
+```js file="./ProductNudgeHeroExample.tsx"
+
+```
+
+### Inline variant
+
+The `inline` variant uses an inline PatternFly [alert](/components/Alert), making it suitable for a sidebar or below a page heading.
+
+```js file="./ProductNudgeAlertExample.tsx"
+
+```
+
+### Contact modal
+
+Use `ProductNudgeContactModal` to provide a contact or lead-capture form. Your application controls when the modal opens and what happens after submission.
+
+Set `titleText`, `descriptionText`, and `submitText` for the modal content. By default, the form has name, email, and phone fields. You can pass the `fields` property to configure custom fields, including their names, labels, input types, placeholders, required state, autocomplete, and help text. The `onSubmit` callback receives an object keyed by each field's `name`.
+
+Uncheck "Use Lightwell branding and default fields" to see a custom field configuration with generic placeholder copy instead of the Lightwell preset.
+
+```js file="./ProductNudgeContactModalExample.tsx"
+
+```
+
+### Contact modal with a submission error
+
+When `onSubmit` rejects, the modal shows an inline danger alert at the top of the form and moves focus to it. Provide a specific `errorMessage` that lists each field with an error, states the issue, and, where possible, how to resolve it. For example: "Work email format is invalid. Please ensure your email follows a format similar to `john@mail.com`." The `errorTitle` can stay more generic.
+
+In this example, `onSubmit` validates the values, stores one message per invalid field in state, and then throws. Submit with empty or invalid values to see the alert. The work email field uses `type: 'text'` so the browser's built-in validation doesn't intercept the submission.
+
+```js file="./ProductNudgeContactModalErrorExample.tsx"
+
+```
+
+### Data modal
+
+`ProductNudgeDataModal` shows built-in match charts. Pass `matchData` to show the match analysis chart, `ecosystemData` to show the by-ecosystem chart, or both to show both charts. The ecosystem chart's axis scales to fit your data. The chart data, colors, brand, icon, and partner logo are customizable.
+
+When using the built-in charts, load `@patternfly/patternfly/patternfly-charts.css` alongside the standard PatternFly styles. It provides chart design tokens, including theme-aware colors for dark mode.
+
+```js file="./ProductNudgeDataModalExample.tsx"
+
+```
+
+### Data modal with custom content
+
+Pass `customContent` to `` in order to fully replace the built-in chart region with any caller-supplied content.
+
+```js file="./ProductNudgeCustomContentExample.tsx"
+
+```
+
+### Compact variant
+
+Use `variant="compact"` for a compact nudge near related content or data within `ProductNudge`. The first item in `metrics` is shown as the nudge's value, followed by its label (for example, "6 clusters threatened").
+
+```js file="./ProductNudgeCompactExample.tsx"
+
+```
+
+### In-context stack
+
+Use `ProductNudgeStack` directly for a compact, self-contained block with an optional title icon, heading, value or headline, body note, and inline CTA. It renders the same compact treatment as `ProductNudge` with `variant="compact"`.
+
+```js file="./ProductNudgeStackExample.tsx"
+
+```
+
+### Description list item
+
+`ProductNudgeDescriptionItem` renders a PatternFly `DescriptionListGroup`, and must be placed inside an existing [description list component](/components/description-list) alongside other items. Set `termText` and optionally `termIcon`; the description can include a headline/value, body, and optional link.
+
+```js file="./ProductNudgeDescriptionItemExample.tsx"
+
+```
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeAlertExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeAlertExample.tsx
new file mode 100644
index 00000000..dd2d2290
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeAlertExample.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import ProductNudge from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+
+export const ProductNudgeAlertExample: React.FunctionComponent = () => (
+ alert('CTA clicked')}
+ content={{
+ id: 'example.alert',
+ headline: 'Application dependency risk can increase remediation effort across your OpenShift estate',
+ body: 'Lightwell, a joint effort between Red Hat and IBM, provides another remediation approach for qualifying application dependencies.',
+ cta: {
+ label: 'Learn more about Lightwell',
+ action: 'link',
+ href: 'https://www.redhat.com/en/lightwell',
+ },
+ }}
+ />
+);
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCompactExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCompactExample.tsx
new file mode 100644
index 00000000..3ccfbe1b
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCompactExample.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import ProductNudge from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+
+export const ProductNudgeCompactExample: React.FunctionComponent = () => (
+ alert('Nudge dismissed')}
+ content={{
+ id: 'example.compact',
+ headline: 'Lightwell remediation',
+ body: 'When a vulnerability requires upgrading a dependency your environment already relies on, Lightwell may provide a backported security fix for the existing version.',
+ cta: {
+ label: 'Learn more about Lightwell',
+ action: 'link',
+ href: 'https://www.redhat.com/en/lightwell',
+ },
+ }}
+ />
+);
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalErrorExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalErrorExample.tsx
new file mode 100644
index 00000000..db115303
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalErrorExample.tsx
@@ -0,0 +1,49 @@
+import React, { useState } from 'react';
+import { Button, List, ListItem } from '@patternfly/react-core';
+import { ProductNudgeContactModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+
+export const ProductNudgeContactModalErrorExample: React.FunctionComponent = () => {
+ const [ isModalOpen, setIsModalOpen ] = useState(false);
+ const [ errors, setErrors ] = useState([]);
+
+ return (
+ <>
+
+ setIsModalOpen(false)}
+ titleText="Contact us"
+ descriptionText="Tell us a little about your organization and we'll be in touch."
+ submitText="Submit"
+ fields={[
+ { name: 'company', label: 'Company', placeholder: 'Your company', isRequired: true, autoComplete: 'organization' },
+ { name: 'workEmail', label: 'Work email', type: 'text', placeholder: 'you@example.com', isRequired: true, autoComplete: 'email' },
+ ]}
+ errorTitle="Unable to submit your request"
+ errorMessage={(
+
+ {errors.map((error) => {error})}
+
+ )}
+ // Validate in onSubmit, describe every invalid field, then reject. The modal shows the
+ // error alert and moves focus to it.
+ onSubmit={async (values) => {
+ const nextErrors: string[] = [];
+ if (!values.company?.trim()) {
+ nextErrors.push('Company is required. Enter the name of your organization.');
+ }
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(values.workEmail ?? '')) {
+ nextErrors.push('Work email format is invalid. Please ensure your email follows a format similar to john@mail.com.');
+ }
+ if (nextErrors.length > 0) {
+ setErrors(nextErrors);
+ throw new Error('Validation failed');
+ }
+ setIsModalOpen(false);
+ }}
+ />
+ >
+ );
+};
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalExample.tsx
new file mode 100644
index 00000000..d2260abf
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalExample.tsx
@@ -0,0 +1,68 @@
+import React, { useState } from 'react';
+import { Button, Checkbox } from '@patternfly/react-core';
+import { ProductNudgeContactModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+
+export const ProductNudgeContactModalExample: React.FunctionComponent = () => {
+ const [ isModalOpen, setIsModalOpen ] = useState(false);
+ const [ useLightwell, setUseLightwell ] = useState(true);
+
+ return (
+ <>
+ setUseLightwell(checked)}
+ />
+
+ setIsModalOpen(false)}
+ brand={useLightwell ? 'lightwell' : undefined}
+ onSubmit={async (values) => {
+ // eslint-disable-next-line no-console
+ console.log('Contact form submitted:', values);
+ setIsModalOpen(false);
+ alert(`Contact form submitted:\n${JSON.stringify(values, null, 2)}`);
+ }}
+ {...(useLightwell ? {
+ titleText: 'Contact us',
+ descriptionText: 'Leave your details and a Red Hat representative will get in touch about how Lightwell can help secure open source dependencies in your environment.',
+ submitText: 'Submit',
+ successMessage: 'Thanks — a Red Hat representative will be in touch.',
+ } : {
+ titleText: 'Title text',
+ descriptionText: 'Tell us a little about your organization and we\'ll be in touch.',
+ submitText: 'CTA text',
+ successMessage: 'Thanks — our team will be in touch.',
+ fields: [
+ {
+ name: 'company',
+ label: 'Company',
+ placeholder: 'Your company',
+ isRequired: true,
+ autoComplete: 'organization',
+ },
+ {
+ name: 'workEmail',
+ label: 'Work email',
+ type: 'email',
+ placeholder: 'you@example.com',
+ isRequired: true,
+ autoComplete: 'email',
+ },
+ {
+ name: 'packageCount',
+ label: 'Number of packages',
+ type: 'number',
+ placeholder: '100',
+ helpText: 'An estimate is fine.',
+ },
+ ],
+ })}
+ />
+ >
+ );
+};
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomContentExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomContentExample.tsx
new file mode 100644
index 00000000..dddd4033
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomContentExample.tsx
@@ -0,0 +1,35 @@
+import React, { useState } from 'react';
+import { Button, Content, List, ListItem } from '@patternfly/react-core';
+import { ProductNudgeDataModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+
+// `customContent` replaces the built-in match/ecosystem charts entirely — it accepts any
+// caller-supplied content, not just a different chart.
+export const ProductNudgeCustomContentExample: React.FunctionComponent = () => {
+ const [ isOpen, setIsOpen ] = useState(false);
+
+ return (
+ <>
+
+ setIsOpen(false)}
+ titleText="Title text"
+ descriptionText="This modal renders whatever content you pass in — no built-in chart required."
+ contentAriaLabel="Feature list"
+ customContent={(
+
+ This plan includes:
+
+ Unlimited dashboards
+ Priority support
+ Single sign-on
+
+
+ )}
+ primaryAction={{ label: 'CTA text', onClick: () => alert('Upgraded!') }}
+ />
+ >
+ );
+};
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDataModalExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDataModalExample.tsx
new file mode 100644
index 00000000..f5a7cb0c
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDataModalExample.tsx
@@ -0,0 +1,45 @@
+import React, { useState } from 'react';
+import { Button, Checkbox } from '@patternfly/react-core';
+import { ProductNudgeDataModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+
+export const ProductNudgeDataModalExample: React.FunctionComponent = () => {
+ const [ isOpen, setIsOpen ] = useState(false);
+ const [ useLightwell, setUseLightwell ] = useState(true);
+
+ return (
+ <>
+ setUseLightwell(checked)}
+ />
+
+ setIsOpen(false)}
+ brand={useLightwell ? 'lightwell' : undefined}
+ matchData={{ exact: 118, partial: 195, noMatch: 534 }}
+ ecosystemData={[
+ { name: 'Java', exact: 70, partial: 120, noMatch: 180 },
+ { name: 'Python', exact: 50, partial: 80, noMatch: 170 },
+ ]}
+ {...(useLightwell ? {
+ titleText: 'Lightwell Lens',
+ descriptionText: 'Lightwell helps secure open source dependencies at scale with validated fixes for the versions you already run.',
+ footerText: 'Download a shareable report with match results and remediation guidance.',
+ primaryAction: { label: 'Download report', onClick: () => alert('Report downloaded') },
+ secondaryAction: { label: 'Learn more about Lightwell', href: 'https://www.redhat.com/en/lightwell' },
+ } : {
+ titleText: 'Title text',
+ descriptionText: 'Review package matches with a palette supplied by your application.',
+ footerText: 'Download a shareable report with match results.',
+ primaryAction: { label: 'CTA text', onClick: () => alert('Report downloaded') },
+ chartColors: [ '#0066cc', '#4cb140', '#c9190b' ],
+ })}
+ />
+ >
+ );
+};
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDescriptionItemExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDescriptionItemExample.tsx
new file mode 100644
index 00000000..dda001a8
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDescriptionItemExample.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import {
+ DescriptionList
+} from '@patternfly/react-core';
+import { ProductNudgeDescriptionItem } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+
+export const ProductNudgeDescriptionItemExample: React.FunctionComponent = () => (
+
+
+
+);
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeHeroExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeHeroExample.tsx
new file mode 100644
index 00000000..8a1a040e
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeHeroExample.tsx
@@ -0,0 +1,51 @@
+import React, { useState } from 'react';
+import { Checkbox } from '@patternfly/react-core';
+import ProductNudge from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+// The Lightwell hero background images are ~2MB and are intentionally excluded from the
+// default `ProductNudge` import graph so non-Lightwell consumers never pay for them. Hero
+// nudges that want the Lightwell background opt in with this one extra import. See the
+// "Bundle size" note on this page.
+import { lightwellBackgroundAssets } from '@patternfly/react-component-groups/dist/esm/ProductNudge/productNudgeLightwellBackgrounds';
+
+// A consumer would point this at their own hosted logo asset; this example uses an inline
+// SVG data URI so the demo has no external dependency.
+const brandLogo = `data:image/svg+xml;utf8,${encodeURIComponent(
+ '',
+)}`;
+
+export const ProductNudgeHeroExample: React.FunctionComponent = () => {
+ const [ useLightwell, setUseLightwell ] = useState(true);
+
+ return (
+ <>
+ setUseLightwell(checked)}
+ />
+ alert('CTA clicked')}
+ content={useLightwell ? {
+ id: 'example.hero',
+ headline: 'Accelerate open-source adoption with Lightwell',
+ body: 'Lightwell maps your package catalog to the Red Hat ecosystem, so your teams can modernize faster with supported, enterprise-grade alternatives.',
+ cta: { label: 'Get in touch', action: 'contact' },
+ assets: lightwellBackgroundAssets,
+ } : {
+ id: 'example.hero',
+ headline: 'Title text',
+ body: 'Supporting body text for the nudge.',
+ cta: { label: 'CTA text', action: 'link', href: 'https://example.com' },
+ assets: {
+ logo: { src: brandLogo, alt: 'Brand' },
+ },
+ }}
+ />
+ >
+ );
+};
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeStackExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeStackExample.tsx
new file mode 100644
index 00000000..439d212d
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeStackExample.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { ProductNudgeStack } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge';
+
+export const ProductNudgeStackExample: React.FunctionComponent = () => (
+
+);
diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/gallery-data.json b/packages/module/patternfly-docs/content/extensions/component-groups/gallery-data.json
index ba193e6d..c62bf581 100644
--- a/packages/module/patternfly-docs/content/extensions/component-groups/gallery-data.json
+++ b/packages/module/patternfly-docs/content/extensions/component-groups/gallery-data.json
@@ -41,6 +41,9 @@
"page-header": {
"summary": "A page header with a title, subtitle and other optional content."
},
+ "product-nudge": {
+ "summary": "A promotional nudge component for surfacing product offers in context."
+ },
"responsive-actions": {
"summary": "Displays actions in a responsive layout."
},
diff --git a/packages/module/patternfly-docs/patternfly-docs.css.js b/packages/module/patternfly-docs/patternfly-docs.css.js
index 240ac3b2..41897d99 100644
--- a/packages/module/patternfly-docs/patternfly-docs.css.js
+++ b/packages/module/patternfly-docs/patternfly-docs.css.js
@@ -2,6 +2,8 @@
import '@patternfly/patternfly/patternfly.css';
// Patternfly utilities
import '@patternfly/patternfly/patternfly-addons.css';
+// PatternFly chart tokens, including dark-theme chart colors
+import '@patternfly/patternfly/patternfly-charts.css';
// Global theme CSS
import '@patternfly/documentation-framework/global.css';
diff --git a/packages/module/patternfly-docs/patternfly-docs.source.js b/packages/module/patternfly-docs/patternfly-docs.source.js
index f0127391..6dab0a88 100644
--- a/packages/module/patternfly-docs/patternfly-docs.source.js
+++ b/packages/module/patternfly-docs/patternfly-docs.source.js
@@ -7,10 +7,14 @@ module.exports = (sourceMD, sourceProps) => {
const extensionPath = path.join(__dirname, '../src');
sourceProps(toPosixPath(path.join(extensionPath, '/**/*.tsx')), propsIgnore);
+ // Parse types before markdown pages so their propComponents entries can resolve.
+ sourceProps(toPosixPath(path.join(extensionPath, '/**/*.types.ts')), propsIgnore);
+
// Parse md files
const contentBase = path.join(__dirname, './content');
sourceMD(toPosixPath(path.join(contentBase, 'extensions/**/*.md')), 'extensions');
+
/**
If you want to parse content from node_modules instead of providing a relative/absolute path,
you can do something similar to this:
diff --git a/packages/module/src/ProductNudge/ProductNudge.test.tsx b/packages/module/src/ProductNudge/ProductNudge.test.tsx
new file mode 100644
index 00000000..14f976be
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudge.test.tsx
@@ -0,0 +1,452 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import ProductNudge from './ProductNudge';
+import { NudgeContent } from './ProductNudge.types';
+
+const content: NudgeContent = {
+ id: 'test.nudge',
+ headline: 'Test headline',
+ body: 'Test body copy.',
+ cta: { label: 'Get in touch', action: 'contact' },
+};
+
+const linkContent: NudgeContent = {
+ ...content,
+ cta: { label: 'Learn more', action: 'link', href: 'https://example.com' },
+};
+
+describe('ProductNudge component', () => {
+ it('renders nothing when not eligible', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders full-width variant', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('.pf-v6-product-nudge--full-width')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Test headline' })).toBeInTheDocument();
+ expect(screen.getByText('Test body copy.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Get in touch' })).toHaveClass('pf-m-display-lg');
+ });
+
+ it('applies the Lightwell CTA color when the brand is selected', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: 'Get in touch' })).toHaveStyle({
+ '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)',
+ });
+ });
+
+ it('uses neutral CTA styling and consumer assets without a brand preset', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Get in touch' })).not.toHaveStyle({
+ '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)',
+ });
+ expect(screen.getByRole('img', { name: 'Custom' })).toHaveAttribute('src', 'custom-logo.svg');
+ });
+
+ it('renders inline variant with link CTA', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('.pf-v6-c-alert.pf-m-inline')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Learn more' })).toHaveAttribute('href', 'https://example.com');
+ });
+
+ it('renders inline variant dismissible', () => {
+ const onDismiss = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /Test headline/ }));
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText('Test headline')).not.toBeInTheDocument();
+ });
+
+ it('renders a caller-provided alert icon', () => {
+ render(
+ Icon }}
+ isEligible
+ onAction={jest.fn()}
+ />,
+ );
+
+ expect(screen.getByTestId('custom-alert-icon')).toBeInTheDocument();
+ });
+
+ it('renders with metrics', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('42')).toBeInTheDocument();
+ expect(screen.getByText('packages')).toBeInTheDocument();
+ });
+
+ it('renders with default CTA color scheme', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Get in touch' })).not.toHaveStyle({
+ '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)',
+ });
+ });
+
+ it('keeps dismissible field nudges dismissible', () => {
+ const onDismiss = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss Test headline' }));
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText('Test headline')).not.toBeInTheDocument();
+ });
+
+ it('routes contact CTAs in compact variant to the action callback', () => {
+ const onAction = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Get in touch' }));
+
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves variant styling when content is collapsible', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('.pf-v6-c-hero')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Test headline' })).toBeInTheDocument();
+ });
+
+ it('lets consumers choose a heading level for the page context', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('heading', { level: 2, name: 'Test headline' })).toBeInTheDocument();
+ });
+
+ it('renders the Lightwell alert mark at 28px', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('.pf-v6-c-alert__icon img')).toHaveStyle({ width: '1.75rem', height: '1.75rem' });
+ });
+
+ it('allows overriding the alert mark size and offset', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('.pf-v6-c-alert__icon img')).toHaveStyle({
+ width: '2rem',
+ height: '2rem',
+ marginInlineStart: '0',
+ });
+ });
+
+ it('allows overriding the hero logo size and offset', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('img', { name: 'Lightwell' })).toHaveStyle({
+ width: '4rem',
+ marginInlineStart: '0',
+ });
+ });
+
+ it('allows overriding the hero background color', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('.pf-v6-c-hero')).toHaveStyle({ '--pn-nudge-background-color': '#123456' });
+ });
+
+ it('allows overriding chrome copy for dismiss, show-less, and show-details labels', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Close Test headline' })).toBeInTheDocument();
+ });
+
+ it('applies ctaColorScheme="custom" using the caller-supplied ctaStyle', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Get in touch' })).toHaveStyle({
+ '--pf-v6-c-button--BackgroundColor': '#00ff00',
+ });
+ });
+
+ it('places the full-width dismiss button before the nudge content in the tab order', () => {
+ render(
+ ,
+ );
+
+ const dismiss = screen.getByRole('button', { name: 'Dismiss Test headline' });
+ const heading = screen.getByRole('heading', { name: 'Test headline' });
+ const link = screen.getByRole('link', { name: 'Learn more' });
+
+ expect(dismiss.compareDocumentPosition(heading)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+ expect(dismiss.compareDocumentPosition(link)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+ });
+
+ it('shows a dismiss tooltip that defaults to the aria-label text and can be customized', async () => {
+ const { rerender } = render(
+ ,
+ );
+
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Dismiss Test headline' }));
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Dismiss Test headline');
+
+ fireEvent.mouseLeave(screen.getByRole('button', { name: 'Dismiss Test headline' }));
+ rerender(
+ ,
+ );
+
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Dismiss Test headline' }));
+ expect(await screen.findByText('Close this offer')).toBeInTheDocument();
+ });
+
+ it('lets consumers set the headline size independently from the heading level', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('heading', { level: 3, name: 'Test headline' })).toHaveClass('pf-m-lg');
+ });
+
+ it('does not require onAction for link CTAs', () => {
+ render();
+
+ expect(screen.getByRole('link', { name: 'Learn more' })).toHaveAttribute('href', 'https://example.com');
+ });
+
+ it('applies dismissAriaLabelPrefix to the inline variant dismiss button and calls onDismiss', () => {
+ const onDismiss = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close Test headline' }));
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows the dismiss tooltip on the inline variant', async () => {
+ render(
+ ,
+ );
+
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Dismiss Test headline' }));
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Dismiss Test headline');
+ });
+
+ it('does not duplicate the dismiss label for assistive technology with the default tooltip', async () => {
+ render(
+ ,
+ );
+ const dismiss = screen.getByRole('button', { name: 'Dismiss Test headline' });
+
+ fireEvent.mouseEnter(dismiss);
+ await screen.findByRole('tooltip');
+
+ expect(dismiss).not.toHaveAttribute('aria-describedby');
+ });
+
+ it('describes the dismiss button with a custom tooltip', async () => {
+ render(
+ ,
+ );
+ const dismiss = screen.getByRole('button', { name: 'Dismiss Test headline' });
+
+ fireEvent.mouseEnter(dismiss);
+ const tooltip = await screen.findByRole('tooltip');
+
+ expect(tooltip).toHaveTextContent('Close this offer');
+ expect(dismiss).toHaveAttribute('aria-describedby', tooltip.id);
+ });
+
+ it.each([ 'full-width', 'inline', 'compact' ] as const)(
+ 'does not render a contact CTA without onAction in the %s variant',
+ (variant) => {
+ render();
+
+ expect(screen.getByText('Test headline')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Get in touch' })).not.toBeInTheDocument();
+ },
+ );
+
+ it('shows the first metric value with its label in the compact variant', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('6 clusters threatened')).toBeInTheDocument();
+ expect(screen.queryByText(/ignored/)).not.toBeInTheDocument();
+ });
+
+ it('formats percentage metrics with their label in the compact variant', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('42% match rate')).toBeInTheDocument();
+ });
+
+});
diff --git a/packages/module/src/ProductNudge/ProductNudge.tsx b/packages/module/src/ProductNudge/ProductNudge.tsx
new file mode 100644
index 00000000..ef394514
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudge.tsx
@@ -0,0 +1,499 @@
+import type { FunctionComponent, ReactNode } from 'react';
+import { useState } from 'react';
+import type { TitleProps } from '@patternfly/react-core';
+
+import {
+ Alert,
+ AlertActionLink,
+ Button,
+ Content,
+ ExpandableSection,
+ Flex,
+ FlexItem,
+ Hero,
+ Stack,
+ StackItem,
+ Title,
+ Tooltip,
+} from '@patternfly/react-core';
+import { css } from '@patternfly/react-styles';
+import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon.js';
+import { createUseStyles } from 'react-jss';
+
+import ErrorBoundary from '../ErrorBoundary';
+import { useImpressionTracking } from './useImpressionTracking';
+import { ProductNudgeStack } from './ProductNudgeStack';
+import {
+ createImageSizeStyle,
+ createSquareImageSizeStyle,
+ LIGHTWELL_BACKGROUND_COLOR,
+ lightwellCtaStyle,
+ nudgeModeStyles,
+} from './nudgeStyles';
+import { ProductNudgeBrandLogo } from './ProductNudgeBrandLogo';
+import { lightwellBrandAssets } from './productNudgeDefaults';
+import {
+ ProductNudgeBrand,
+ ProductNudgeVariant,
+ ProductNudgeBehavior,
+ NudgeContent,
+ NudgeMetric,
+ ProductNudgeCtaColorScheme,
+ ProductNudgeHeadingLevel,
+} from './ProductNudge.types';
+
+export interface ProductNudgeProps {
+ /** Visual layout variant */
+ variant: ProductNudgeVariant;
+ /** Interaction behavior; defaults to 'persistent' */
+ behavior?: ProductNudgeBehavior;
+ /** All displayable content */
+ content: NudgeContent;
+ /** Metrics displayed in a row below the body text */
+ metrics?: NudgeMetric[];
+ /** false renders null and suppresses impression tracking */
+ isEligible: boolean;
+ /** Shows a loading spinner and disables a non-link CTA while its action is in progress. */
+ isLoading?: boolean;
+ /** Called when a non-link (contact) CTA is clicked. Not needed when `content.cta.action` is `'link'`. A contact CTA is not rendered without it. */
+ onAction?: () => void;
+ /** Called when the user dismisses (behavior='dismissible') */
+ onDismiss?: () => void;
+ /** Called once when the component is 50% visible in the viewport */
+ onImpression?: () => void;
+ /** Applies a named brand preset; copy remains entirely caller-supplied. */
+ brand?: ProductNudgeBrand;
+ /** CTA color scheme; defaults to the selected brand or PatternFly styling. */
+ ctaColorScheme?: ProductNudgeCtaColorScheme;
+ /** CTA inline style applied when `ctaColorScheme="custom"`. */
+ ctaStyle?: React.CSSProperties;
+ /** Heading level for the headline; defaults to the existing level for the selected variant. */
+ headingLevel?: ProductNudgeHeadingLevel;
+ /** Size of the headline; defaults to '2xl' for `full-width`, 'lg' otherwise. Set this to keep the headline visually consistent with a non-default `headingLevel`. */
+ headingSize?: TitleProps['size'];
+ /** Width of the full logo (hero/alert); defaults to '6rem'. */
+ logoSize?: string;
+ /** Inline-start margin of the full logo, used to compensate for an asset's internal padding; defaults to '-10px' for the Lightwell logo, '0' for a caller-supplied logo. */
+ logoOffset?: string;
+ /** Width/height of the small logo mark (alert icon); defaults to '1.75rem'. */
+ logoMarkSize?: string;
+ /** Inline-start margin of the small logo mark; defaults to '-3px' for the Lightwell mark, '0' for a caller-supplied mark. */
+ logoMarkOffset?: string;
+ /** Hero background color; defaults to the Lightwell tint when `brand="lightwell"`, otherwise unset. */
+ backgroundColor?: string;
+ /** Additional CSS class forwarded to the root element */
+ className?: string;
+ /** OUIA component ID */
+ ouiaId?: string;
+ /** Prefix used in the dismiss button's aria-label, followed by the headline. */
+ dismissAriaLabelPrefix?: string;
+ /** Tooltip content shown on the dismiss button; defaults to the same text as the dismiss aria-label. */
+ dismissTooltipContent?: ReactNode;
+ /** Text for the "show less" toggle when `behavior="collapsible"`. */
+ showLessText?: string;
+ /** Prefix used in the alert's expand/collapse toggle aria-label, followed by the headline. */
+ showDetailsAriaLabelPrefix?: string;
+}
+
+const useStyles = createUseStyles({
+ nudge: {
+ padding: 'var(--pf-t--global--spacer--lg)',
+ },
+ alertIcon: {
+ display: 'block',
+ },
+ logo: {
+ display: 'block',
+ },
+ ...nudgeModeStyles,
+ heroBg: {
+ '--pf-v6-c-hero--BackgroundColor': 'var(--pn-nudge-background-color)',
+ '.pf-v6-theme-dark &': {
+ '--pf-v6-c-hero--BackgroundColor': 'var(--pf-t--color--black)',
+ },
+ },
+ heroContent: {
+ maxWidth: '75%',
+ },
+ heroDismiss: {
+ position: 'absolute',
+ insetBlockStart: 'var(--pf-t--global--spacer--md)',
+ insetInlineEnd: 'var(--pf-t--global--spacer--md)',
+ },
+ disclosure: {
+ display: 'block',
+ marginBlockStart: 'var(--pf-t--global--spacer--xs)',
+ },
+ metricsRow: {
+ alignItems: 'flex-start',
+ },
+});
+
+const formatMetricValue = (value: string | number, format: 'percentage' | 'count' | 'text') => {
+ if (format === 'percentage') {
+ return `${value}%`;
+ }
+ return value;
+};
+
+const ProductNudgeContent: FunctionComponent = ({
+ variant,
+ behavior = 'persistent',
+ content,
+ metrics = [],
+ isEligible,
+ isLoading = false,
+ onAction,
+ onDismiss,
+ onImpression,
+ ctaColorScheme,
+ ctaStyle: customCtaStyle,
+ headingLevel,
+ headingSize,
+ logoSize,
+ logoOffset,
+ logoMarkSize,
+ logoMarkOffset,
+ backgroundColor,
+ brand,
+ className,
+ ouiaId = 'ProductNudge',
+ dismissAriaLabelPrefix = 'Dismiss ',
+ dismissTooltipContent,
+ showLessText = 'Show less',
+ showDetailsAriaLabelPrefix = 'Show details for ',
+}: ProductNudgeProps) => {
+ const classes = useStyles();
+ const [ isDismissed, setIsDismissed ] = useState(false);
+ const [ isExpanded, setIsExpanded ] = useState(false);
+ const impressionRef = useImpressionTracking(onImpression, isEligible && !isLoading);
+
+ if (!isEligible || isDismissed) {
+ return null;
+ }
+
+ const handleDismiss = () => {
+ setIsDismissed(true);
+ onDismiss?.();
+ };
+
+ const assets = {
+ ...(brand === 'lightwell' ? lightwellBrandAssets : {}),
+ ...content.assets,
+ };
+ const hasCustomLogo = Boolean(content.assets?.logo);
+ const resolvedLogoStyle = createImageSizeStyle(
+ logoSize ?? '6rem',
+ logoOffset ?? (hasCustomLogo ? '0' : '-10px'),
+ );
+ const hasCustomLogoMark = Boolean(content.assets?.logo || content.assets?.logomark);
+ const resolvedLogoMarkStyle = createSquareImageSizeStyle(
+ logoMarkSize ?? '1.75rem',
+ logoMarkOffset ?? (hasCustomLogoMark ? '0' : '-3px'),
+ );
+ const resolvedBackgroundColor = backgroundColor ?? (brand === 'lightwell' ? LIGHTWELL_BACKGROUND_COLOR : undefined);
+ const ctaHref = content.cta.action === 'link' ? content.cta.href : undefined;
+ const resolvedCtaColorScheme = ctaColorScheme ?? (brand === 'lightwell' ? 'lightwell' : 'default');
+ const ctaStyleByScheme: Record = {
+ lightwell: lightwellCtaStyle,
+ custom: customCtaStyle,
+ default: undefined,
+ };
+ const ctaStyle = ctaStyleByScheme[resolvedCtaColorScheme];
+
+ const metricsRow = metrics.length > 0 && (
+
+ {metrics.map((metric) => (
+
+
+ {formatMetricValue(metric.value, metric.format)} {metric.label}
+
+
+ ))}
+
+ );
+
+ const dismissControl = behavior === 'dismissible' && (
+
+ }
+ ouiaId={`${ouiaId}-dismiss`}
+ />
+
+ );
+
+ const sharedCtaProps = {
+ variant: 'primary' as const,
+ size: variant === 'full-width' ? ('lg' as const) : undefined,
+ ouiaId: `${ouiaId}-cta`,
+ style: ctaStyle,
+ };
+
+ // A contact CTA needs an `onAction` handler; without one there is nothing for the button to do.
+ const showCta = content.cta.action === 'link' || Boolean(onAction);
+
+ const renderCtaButton = () => {
+ if (!showCta) {
+ return null;
+ }
+ if (content.cta.action === 'link') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ };
+ const ctaButton = renderCtaButton();
+
+ const hasPartnerLogo = Boolean(assets.partnerLogo || assets.partnerLogoDark);
+ const cta = (
+
+ {ctaButton && {ctaButton}}
+ {hasPartnerLogo && (
+
+
+
+ )}
+
+ );
+
+ const body = (
+
+ {assets.logo && (
+
+
+ {assets.logoDark && (
+
+ )}
+
+ )}
+
+
+ {content.headline}
+
+
+
+
+ {content.body}
+
+ {content.secondaryBody && {content.secondaryBody}}
+ {content.disclosure && (
+
+ {content.disclosure}
+
+ )}
+
+ {metricsRow && {metricsRow}}
+ {(showCta || hasPartnerLogo) && {cta}}
+
+ );
+
+ const nudgePaddingClass = variant === 'full-width' || variant === 'inline' ? undefined : classes.nudge;
+
+ const rootClassName = css(
+ 'pf-v6-product-nudge',
+ `pf-v6-product-nudge--${variant}`,
+ nudgePaddingClass,
+ className,
+ );
+
+ const withExpandableContent = (children: React.ReactNode) => behavior === 'collapsible' ? (
+ setIsExpanded(expanded)}
+ isExpanded={isExpanded}
+ >
+ {children}
+
+ ) : children;
+
+ if (variant === 'full-width') {
+ return (
+
+
+ {dismissControl && (
+ {dismissControl}
+ )}
+ {withExpandableContent(body)}
+
+
+ );
+ }
+
+ if (variant === 'inline') {
+ const alertIconImage = content.assets?.logo ?? assets.logomark ?? assets.logo;
+ const alertIconDark = content.assets?.logoDark ?? assets.logomarkDark ?? assets.logoDark;
+ const alertIcon = content.icon ?? (alertIconImage ? (
+ <>
+
+ {alertIconDark && (
+
+ )}
+ >
+ ) : undefined);
+
+ const renderAlertCta = () => {
+ if (!showCta) {
+ return undefined;
+ }
+ if (content.cta.action === 'link') {
+ return (
+
+ {content.cta.label}
+
+ );
+ }
+ return (
+
+ {content.cta.label}
+
+ );
+ };
+ const alertCta = renderAlertCta();
+
+ return (
+
+
+ {content.body}
+ {content.secondaryBody && {content.secondaryBody}}
+ {content.disclosure && {content.disclosure}}
+
+
+ );
+ }
+
+ if (variant === 'compact') {
+ const fieldValue = metrics[0]
+ ? `${formatMetricValue(metrics[0].value, metrics[0].format)} ${metrics[0].label}`
+ : undefined;
+ const fieldContent = (
+
+ );
+
+ return (
+
+
+ {withExpandableContent(fieldContent)}
+ {dismissControl && {dismissControl}}
+
+
+ );
+ }
+
+ return null;
+};
+
+const ProductNudge: FunctionComponent = (props: ProductNudgeProps) => (
+
+
+
+);
+
+export default ProductNudge;
diff --git a/packages/module/src/ProductNudge/ProductNudge.types.ts b/packages/module/src/ProductNudge/ProductNudge.types.ts
new file mode 100644
index 00000000..b88913a7
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudge.types.ts
@@ -0,0 +1,147 @@
+import React from 'react';
+
+export type ProductNudgeBrand = 'lightwell';
+
+export interface ProductNudgeImage {
+ /** Image URL. */
+ src: string;
+ /** Accessible alt text; pass an empty string for a purely decorative image. */
+ alt: string;
+}
+
+/** Brand images that can be applied by a named brand preset or overridden by consumers. */
+export interface ProductNudgeBrandAssets {
+ /** Full logo shown in light mode (and in dark mode when `logoDark` is not provided). */
+ logo?: ProductNudgeImage;
+ /** Full logo shown in dark mode; falls back to `logo` when not provided. */
+ logoDark?: ProductNudgeImage;
+ /** Compact logomark shown in light mode (for example in the inline variant's alert icon). */
+ logomark?: ProductNudgeImage;
+ /** Compact logomark shown in dark mode; falls back to `logomark` when not provided. */
+ logomarkDark?: ProductNudgeImage;
+ /** Background image URL for the full-width variant in light mode. */
+ backgroundImageLight?: string;
+ /** Background image URL for the full-width variant in dark mode. */
+ backgroundImageDark?: string;
+ /** Partner logo shown in modal footers in light mode. */
+ partnerLogo?: ProductNudgeImage;
+ /** Partner logo shown in modal footers in dark mode; falls back to `partnerLogo` when not provided. */
+ partnerLogoDark?: ProductNudgeImage;
+}
+
+export interface NudgeContact {
+ /** Modal title text (used for aria-label) */
+ title: string;
+ /** Introductory paragraph shown above the form */
+ intro: string | React.ReactNode;
+ /** Message shown in place of the form after a successful submit */
+ successMessage: string | React.ReactNode;
+}
+
+export interface NudgeContent {
+ /** Unique content identifier, e.g. 'product.overview' */
+ id: string;
+ /** Primary heading text */
+ headline: string;
+ /** Primary body paragraph */
+ body: string | React.ReactNode;
+ /** Optional secondary body paragraph */
+ secondaryBody?: string | React.ReactNode;
+ /** Optional hedged legal/disclaimer text rendered in a small element */
+ disclosure?: string | React.ReactNode;
+ /** Call-to-action configuration */
+ cta:
+ | {
+ /** Button label */
+ label: string;
+ /** 'contact' fires onAction */
+ action: 'contact';
+ href?: never;
+ }
+ | {
+ /** Link label */
+ label: string;
+ /** 'link' renders an anchor to href */
+ action: 'link';
+ /** Required when action is 'link' */
+ href: string;
+ };
+ /** Contact modal content; required when cta.action is 'contact' */
+ contact?: NudgeContact;
+ /** Optional React icon rendered for icon-bearing variant modes. */
+ icon?: React.ReactNode;
+ /** Visual severity when variant is `inline`; defaults to `info`. */
+ alertVariant?: 'success' | 'warning' | 'danger' | 'info';
+ /** Optional brand imagery */
+ assets?: ProductNudgeBrandAssets;
+}
+
+/** Format applied to a metric value for display */
+export type NudgeMetricFormat = 'percentage' | 'count' | 'text';
+
+export interface NudgeMetric {
+ /** Display label, e.g. 'covered packages' */
+ label: string;
+ /** Numeric or string value */
+ value: string | number;
+ /** How to format the value for display */
+ format: NudgeMetricFormat;
+
+}
+
+/** Visual layout variant: `full-width` for a prominent hero placement, `inline` for an alert-style message, `compact` for an in-context field placement. */
+export type ProductNudgeVariant = 'full-width' | 'inline' | 'compact';
+
+/** Interaction pattern */
+export type ProductNudgeBehavior = 'persistent' | 'dismissible' | 'collapsible';
+
+/**
+ * CTA color scheme; 'lightwell' applies the Lightwell red accent, 'default' uses PatternFly
+ * styling, 'custom' applies the caller-supplied `ctaStyle` prop.
+ */
+export type ProductNudgeCtaColorScheme = 'lightwell' | 'default' | 'custom';
+
+/** Heading element used for the nudge headline. */
+export type ProductNudgeHeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+
+export interface ProductNudgeAction {
+ /** Button or link label. */
+ label: React.ReactNode;
+ /** Renders the action as a link when provided. */
+ href?: string;
+ /** Click handler, used when `href` is not provided. */
+ onClick?: () => void;
+}
+
+export interface ProductNudgeContactFormField {
+ /** Stable key used in the submitted values object. */
+ name: string;
+ /** Accessible field label. */
+ label: React.ReactNode;
+ /** Native input type. */
+ type?: 'text' | 'date' | 'datetime-local' | 'email' | 'month' | 'number' | 'password' | 'search' | 'tel' | 'time' | 'url';
+ /** Placeholder text shown inside the empty field. */
+ placeholder?: string;
+ /** Marks the field as required for submission. */
+ isRequired?: boolean;
+ /** Native `autocomplete` attribute value. */
+ autoComplete?: string;
+ /** Supplementary help text rendered below the field. */
+ helpText?: React.ReactNode;
+}
+
+export type ContactFormValues = Record;
+
+export interface ProductNudgeMatchData {
+ /** Count of exact matches. */
+ exact: number;
+ /** Count of partial matches. */
+ partial: number;
+ /** Count of items with no match. */
+ noMatch: number;
+}
+
+export interface ProductNudgeEcosystemData extends ProductNudgeMatchData {
+ /** Ecosystem display name. */
+ name: string | React.ReactNode;
+}
diff --git a/packages/module/src/ProductNudge/ProductNudgeBrandLogo.tsx b/packages/module/src/ProductNudge/ProductNudgeBrandLogo.tsx
new file mode 100644
index 00000000..754f9f7b
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeBrandLogo.tsx
@@ -0,0 +1,54 @@
+import { FunctionComponent } from 'react';
+import { css } from '@patternfly/react-styles';
+import { createUseStyles } from 'react-jss';
+
+import { ProductNudgeImage } from './ProductNudge.types';
+import { nudgeModeStyles, partnerLogoStyles } from './nudgeStyles';
+
+const useStyles = createUseStyles({
+ partnerLogo: partnerLogoStyles,
+ ...nudgeModeStyles,
+});
+
+export interface ProductNudgeBrandLogoProps {
+ /** Logo shown in light mode (and in dark mode when `dark` is not provided). */
+ light?: ProductNudgeImage;
+ /** Logo shown in dark mode; falls back to `light` when not provided. */
+ dark?: ProductNudgeImage;
+ /** Additional CSS class applied to each rendered image. */
+ className?: string;
+}
+
+/** Shared light/dark-mode rendering for an optional partner logo. */
+export const ProductNudgeBrandLogo: FunctionComponent = ({
+ light,
+ dark,
+ className,
+}) => {
+ const classes = useStyles();
+
+ if (!light && !dark) {
+ return null;
+ }
+
+ return (
+ <>
+ {light && (
+
+ )}
+ {dark && (
+
+ )}
+ >
+ );
+};
+
+export default ProductNudgeBrandLogo;
diff --git a/packages/module/src/ProductNudge/ProductNudgeContactModal.test.tsx b/packages/module/src/ProductNudge/ProductNudgeContactModal.test.tsx
new file mode 100644
index 00000000..d6a1f1aa
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeContactModal.test.tsx
@@ -0,0 +1,236 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import ProductNudgeContactModal from './ProductNudgeContactModal';
+
+describe('ProductNudgeContactModal component', () => {
+ it('renders when open', () => {
+ render(
+ ,
+ );
+ const dialog = screen.getByRole('dialog', { name: 'Get in touch' });
+ expect(dialog).toBeInTheDocument();
+
+ const logomarkImages = dialog.querySelectorAll('img[alt=""]');
+ expect(logomarkImages).toHaveLength(2);
+ expect(logomarkImages[0].className).toContain('lightModeOnly');
+ expect(logomarkImages[1].className).toContain('darkModeOnly');
+ expect(logomarkImages[0].parentElement).toHaveStyle({ width: '1.5rem', height: '1.5rem' });
+ expect(logomarkImages[0]).toHaveStyle({ width: '100%', height: '100%' });
+ const partnerLogoImages = dialog.querySelectorAll('img[alt="Red Hat and IBM"]');
+ expect(partnerLogoImages).toHaveLength(2);
+ expect(partnerLogoImages[0].className).toContain('lightModeOnly');
+ expect(partnerLogoImages[1].className).toContain('darkModeOnly');
+ const footer = dialog.querySelector('.pf-v6-c-modal-box__footer');
+ expect(footer).toContainElement(screen.getByRole('button', { name: 'Send request' }));
+ expect(footer).toContainElement(partnerLogoImages[0]);
+ expect(screen.getByRole('button', { name: 'Send request' })).toHaveStyle({
+ '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)'
+ });
+ });
+
+ it('renders closed (nothing visible)', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('renders supplied field placeholders and submits ContactFormValues', async () => {
+ const onSubmit = jest.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('Enter your name'), { target: { value: 'Jane Doe' } });
+ fireEvent.change(screen.getByPlaceholderText('Enter your email'), { target: { value: 'jane@example.com' } });
+ fireEvent.change(screen.getByPlaceholderText('Enter your phone'), { target: { value: '555-0100' } });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Send request' }));
+ });
+
+ expect(onSubmit).toHaveBeenCalledWith({ name: 'Jane Doe', email: 'jane@example.com', phone: '555-0100' });
+ });
+
+ it('renders configured fields and submits values using their names', async () => {
+ const onSubmit = jest.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'jane@example.com' } });
+ fireEvent.change(screen.getByPlaceholderText('10+'), { target: { value: '12' } });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
+ });
+
+ expect(onSubmit).toHaveBeenCalledWith({ workEmail: 'jane@example.com', teamSize: '12' });
+ });
+
+ it('disables repeat submission and reports a failed submission', async () => {
+ let rejectSubmission: (error: Error) => void = () => undefined;
+ const onSubmit = jest.fn(() => new Promise((_resolve, reject) => {
+ rejectSubmission = reject;
+ }));
+
+ render(
+ ,
+ );
+
+ const submitButton = screen.getByRole('button', { name: 'Send request' });
+ fireEvent.click(submitButton);
+ expect(submitButton).toBeDisabled();
+
+ fireEvent.click(submitButton);
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ rejectSubmission(new Error('Request failed'));
+ });
+ expect(await screen.findByText('Please try again. If the problem continues, contact support.')).toBeInTheDocument();
+ expect(submitButton).toBeEnabled();
+ });
+
+ it('allows overriding the title icon size, error copy, and content aria-label', async () => {
+ let rejectSubmission: (error: Error) => void = () => undefined;
+ const onSubmit = jest.fn(() => new Promise((_resolve, reject) => {
+ rejectSubmission = reject;
+ }));
+
+ render(
+ ,
+ );
+
+ const dialog = screen.getByRole('dialog', { name: 'Get in touch' });
+ const logomarkImages = dialog.querySelectorAll('img[alt=""]');
+ expect(logomarkImages[0].parentElement).toHaveStyle({ width: '2rem', height: '2rem' });
+ expect(screen.getByRole('region', { name: 'Custom region label' })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Send request' }));
+ await act(async () => {
+ rejectSubmission(new Error('Request failed'));
+ });
+
+ expect(await screen.findByText('Custom error message')).toBeInTheDocument();
+ expect(screen.getByText('Custom error title')).toBeInTheDocument();
+ });
+
+ it('moves focus to the error alert after a failed submission', async () => {
+ const onSubmit = jest.fn().mockRejectedValue(new Error('Request failed'));
+ render(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Send request' }));
+ });
+
+ const message = await screen.findByText(/Email format is invalid/);
+ expect(message.closest('[tabindex="-1"]')).toHaveFocus();
+ });
+
+ it('does not render a default error message', async () => {
+ const onSubmit = jest.fn().mockRejectedValue(new Error('Request failed'));
+ render(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Send request' }));
+ });
+
+ expect(await screen.findByText('Unable to submit request')).toBeInTheDocument();
+ expect(screen.queryByText(/contact support/)).not.toBeInTheDocument();
+ });
+
+ it('keeps unique, stable ids across multiple modals', () => {
+ render(
+ <>
+
+
+ >,
+ );
+
+ const [ first, second ] = screen.getAllByRole('dialog', { hidden: true });
+ expect(first.getAttribute('aria-labelledby')).not.toEqual(second.getAttribute('aria-labelledby'));
+ });
+
+ it('keeps a consumer header icon mounted and up to date across rerenders', () => {
+ const props = { isOpen: true, onClose: jest.fn(), titleText: 'Get in touch', submitText: 'Send', onSubmit: jest.fn() };
+ const { rerender } = render(
+ First} />,
+ );
+ const icon = screen.getByTestId('header-icon');
+
+ rerender(Second} />);
+
+ expect(screen.getByTestId('header-icon')).toBe(icon);
+ expect(icon).toHaveTextContent('Second');
+ });
+});
diff --git a/packages/module/src/ProductNudge/ProductNudgeContactModal.tsx b/packages/module/src/ProductNudge/ProductNudgeContactModal.tsx
new file mode 100644
index 00000000..8f2d118d
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeContactModal.tsx
@@ -0,0 +1,309 @@
+import { useEffect, useId, useMemo, useRef, useState, type ComponentType, type CSSProperties, type FormEvent, type FunctionComponent, type ReactNode } from 'react';
+import {
+ Button,
+ Alert,
+ FlexItem,
+ Form,
+ FormGroup,
+ HelperText,
+ HelperTextItem,
+ Modal,
+ ModalBody,
+ ModalHeader,
+ ModalVariant,
+ TextInput
+} from '@patternfly/react-core';
+import { createUseStyles } from 'react-jss';
+import {
+ type ContactFormValues,
+ type ProductNudgeBrand,
+ type ProductNudgeContactFormField,
+ type ProductNudgeCtaColorScheme,
+ type ProductNudgeImage,
+} from './ProductNudge.types';
+import { createSquareImageSizeStyle, lightwellCtaStyle, nudgeModeStyles } from './nudgeStyles';
+import { lightwellBrandAssets, LightwellLogomark, LightwellLogomarkDark } from './productNudgeDefaults';
+import { ProductNudgeModalFooter } from './ProductNudgeModalFooter';
+
+export interface ProductNudgeContactModalProps {
+ /** Whether the modal is open */
+ isOpen: boolean;
+ /** Callback to close the modal */
+ onClose: () => void;
+ /** Modal title */
+ titleText: ReactNode;
+ /** Optional icon or component shown beside the modal title */
+ titleIcon?: ComponentType;
+ /** Optional icon node shown beside the modal title; takes precedence over titleIcon. */
+ headerIcon?: ReactNode;
+ /** Optional description shown below the modal title */
+ descriptionText?: ReactNode;
+ /** Submit button label */
+ submitText: ReactNode;
+ /** Placeholder for the name field */
+ namePlaceholder?: string;
+ /** Placeholder for the email field */
+ emailPlaceholder?: string;
+ /** Placeholder for the phone field */
+ phonePlaceholder?: string;
+ /** Configurable fields. Defaults to the legacy name, email, and phone fields. */
+ fields?: ProductNudgeContactFormField[];
+ /** Optional message displayed after a successful submission. */
+ successMessage?: ReactNode;
+ /** Receives values keyed by each configured field's name. */
+ onSubmit: (values: ContactFormValues) => Promise;
+ /** Applies a named brand preset; copy remains entirely caller-supplied. */
+ brand?: ProductNudgeBrand;
+ /** Footer partner logo override. */
+ partnerLogo?: ProductNudgeImage;
+ /** Dark-mode footer partner logo override. */
+ partnerLogoDark?: ProductNudgeImage;
+ /** Override the primary CTA visual scheme. */
+ ctaColorScheme?: ProductNudgeCtaColorScheme;
+ /** CTA inline style applied when `ctaColorScheme="custom"`. */
+ ctaStyle?: CSSProperties;
+ /** Width/height of the title icon container; defaults to '1.5rem'. */
+ titleIconSize?: string;
+ /** Error alert title shown after a failed submission. */
+ errorTitle?: ReactNode;
+ /**
+ * Error alert message shown after a failed submission. Supply context-specific copy that
+ * names the problem and, where possible, how to resolve it (e.g. "Email format is invalid.
+ * Please ensure your email follows a similar format as john@mail.com."). Focus moves to the
+ * error alert after a failed submit so that assistive technology announces the error.
+ */
+ errorMessage?: ReactNode;
+ /** aria-label applied to the scrollable modal body region. */
+ contentAriaLabel?: string;
+ /** Prefix for generated form and accessibility IDs. */
+ id?: string;
+}
+
+const useStyles = createUseStyles({
+ modal: {
+ '--pf-v6-c-modal-box__header--PaddingBlockStart': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__header--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__header--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__body--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__body--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__footer--PaddingBlockEnd': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__footer--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__footer--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)'
+ },
+ modalBody: {
+ marginInlineEnd: 'var(--pf-v6-c-modal-box__close--sibling--MarginInlineEnd)'
+ },
+ modalFooter: {
+ marginInlineEnd: 'var(--pf-v6-c-modal-box__close--sibling--MarginInlineEnd)'
+ },
+ titleIcon: {
+ '& img': {
+ width: '100%',
+ height: '100%'
+ }
+ },
+ ...nudgeModeStyles
+});
+
+interface LightwellContactTitleIconProps {
+ /** Width/height of the icon container. */
+ size: string;
+}
+
+const LightwellContactTitleIcon: FunctionComponent = ({ size }) => {
+ const classes = useStyles();
+
+ return (
+
+

+

+
+ );
+};
+
+export const ProductNudgeContactModal = ({
+ isOpen,
+ namePlaceholder = '',
+ emailPlaceholder = '',
+ phonePlaceholder = '',
+ onClose,
+ onSubmit,
+ titleText,
+ titleIcon,
+ headerIcon,
+ descriptionText,
+ submitText,
+ fields,
+ successMessage,
+ brand,
+ partnerLogo,
+ partnerLogoDark,
+ ctaColorScheme,
+ ctaStyle: customCtaStyle,
+ titleIconSize = '1.5rem',
+ errorTitle = 'Unable to submit request',
+ errorMessage,
+ contentAriaLabel = 'Contact request details',
+ id,
+}: ProductNudgeContactModalProps) => {
+ const classes = useStyles();
+ const generatedId = useId();
+ const idPrefix = id ?? `product-nudge-contact-${generatedId.replace(/:/g, '')}`;
+ const errorRef = useRef(null);
+ const resolvedFields: ProductNudgeContactFormField[] = fields ?? [
+ { name: 'name', label: 'Name', type: 'text', placeholder: namePlaceholder, isRequired: true, autoComplete: 'name' },
+ { name: 'email', label: 'E-mail', type: 'email', placeholder: emailPlaceholder, isRequired: true, autoComplete: 'email' },
+ { name: 'phone', label: 'Phone', type: 'tel', placeholder: phonePlaceholder, autoComplete: 'tel' },
+ ];
+ const [ values, setValues ] = useState({});
+ const [ isSubmitted, setIsSubmitted ] = useState(false);
+ const [ isSubmitting, setIsSubmitting ] = useState(false);
+ const [ hasSubmitError, setHasSubmitError ] = useState(false);
+ const resolvedLogo = partnerLogo ?? (brand === 'lightwell' ? lightwellBrandAssets.partnerLogo : undefined);
+ const resolvedLogoDark = partnerLogoDark ?? (brand === 'lightwell' && !partnerLogo ? lightwellBrandAssets.partnerLogoDark : undefined);
+ const resolvedCtaColorScheme = ctaColorScheme ?? (brand === 'lightwell' ? 'lightwell' : 'default');
+ const ctaStyleByScheme: Record = {
+ lightwell: lightwellCtaStyle,
+ custom: customCtaStyle,
+ default: undefined,
+ };
+ const ctaStyle = ctaStyleByScheme[resolvedCtaColorScheme];
+
+ // ModalHeader renders titleIconVariant as a component type. Keep that type stable and read the
+ // latest `headerIcon` node from a ref, so an inline element doesn't remount on every render.
+ const headerIconNodeRef = useRef(headerIcon);
+ headerIconNodeRef.current = headerIcon;
+ const hasHeaderIcon = Boolean(headerIcon);
+ const titleIconComponent = useMemo(() => {
+ if (hasHeaderIcon) {
+ return () => <>{headerIconNodeRef.current}>;
+ }
+ if (titleIcon) {
+ return titleIcon;
+ }
+ if (brand === 'lightwell') {
+ return () => ;
+ }
+ return undefined;
+ }, [ hasHeaderIcon, titleIcon, brand, titleIconSize ]);
+
+ useEffect(() => {
+ if (isOpen) {
+ setValues({});
+ setIsSubmitted(false);
+ setHasSubmitError(false);
+ }
+ }, [ isOpen ]);
+
+ useEffect(() => {
+ if (hasSubmitError) {
+ errorRef.current?.focus();
+ }
+ }, [ hasSubmitError ]);
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ if (isSubmitting) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ setHasSubmitError(false);
+ try {
+ await onSubmit(values);
+ if (successMessage) {
+ setIsSubmitted(true);
+ }
+ } catch {
+ setHasSubmitError(true);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ {isSubmitted ? successMessage : (
+
+ )}
+
+
+
+
+ )}
+ />
+
+ );
+};
+
+export default ProductNudgeContactModal;
diff --git a/packages/module/src/ProductNudge/ProductNudgeDataModal.test.tsx b/packages/module/src/ProductNudge/ProductNudgeDataModal.test.tsx
new file mode 100644
index 00000000..3cabf82b
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeDataModal.test.tsx
@@ -0,0 +1,337 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { getResizeObserver } from '@patternfly/react-core';
+import ProductNudgeDataModal from './ProductNudgeDataModal';
+
+jest.mock('@patternfly/react-core', () => {
+ const actual = jest.requireActual('@patternfly/react-core');
+ return { ...actual, getResizeObserver: jest.fn(actual.getResizeObserver) };
+});
+
+const lightwellProps = {
+ brand: 'lightwell' as const,
+ matchData: { exact: 118, partial: 195, noMatch: 534 },
+ ecosystemData: [
+ { name: 'Java', exact: 70, partial: 120, noMatch: 180 },
+ { name: 'Python', exact: 50, partial: 80, noMatch: 170 },
+ ],
+ titleText: 'Lightwell Lens',
+ descriptionText: 'Lightwell match analysis.',
+ footerText: 'Download a full, shareable report with detailed match results and remediation guidance.',
+ primaryAction: { label: 'Download report', onClick: jest.fn() },
+ secondaryAction: { label: 'Learn more about Lightwell', href: 'https://www.redhat.com/en/lightwell' },
+};
+
+describe('ProductNudgeDataModal component', () => {
+ it('renders when open', () => {
+ render();
+
+ expect(screen.getByRole('dialog', { name: 'Lightwell Lens' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Match analysis' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'By ecosystem' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Learn more about Lightwell' })).toHaveAttribute(
+ 'href',
+ 'https://www.redhat.com/en/lightwell',
+ );
+ });
+
+ it('associates the dialog description and labels its scrollable body', () => {
+ render();
+
+ const dialog = screen.getByRole('dialog', { name: 'Lightwell Lens' });
+ const descriptionId = dialog.getAttribute('aria-describedby');
+
+ expect(descriptionId).toBeTruthy();
+ expect(document.getElementById(descriptionId ?? '')).toHaveTextContent('Lightwell match analysis.');
+ expect(dialog.querySelector('[aria-label="Additional details"]')).toBeInTheDocument();
+ });
+
+ it('renders closed', () => {
+ render();
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('renders supplied match analysis data', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('50%')).toBeInTheDocument();
+ expect(screen.getByText('10', { selector: 'strong' })).toBeInTheDocument();
+ });
+
+ it('renders the redesigned two-section analysis content', () => {
+ render();
+
+ const dialog = screen.getByRole('dialog', { name: 'Lightwell Lens' });
+ const productTitle = screen.getByRole('heading', { name: 'Lightwell Lens' });
+ expect(dialog).toContainElement(productTitle);
+ const titleLogo = productTitle.parentElement?.querySelector('img');
+ expect(titleLogo).toBeInTheDocument();
+ expect(titleLogo?.parentElement).toHaveStyle({ width: '1.5rem', height: '1.5rem' });
+ expect(titleLogo).toHaveStyle({ width: '100%', height: '100%' });
+
+ const sectionsFlex = dialog.querySelector('.pf-v6-l-flex.pf-m-column.pf-m-row-on-md.pf-m-gap-2xl');
+ expect(sectionsFlex?.children).toHaveLength(2);
+ expect(sectionsFlex?.children[0]).toHaveClass('pf-m-flex-1');
+ expect(sectionsFlex?.children[1]).toHaveClass('pf-m-flex-1');
+ const sectionStack = sectionsFlex?.children[0].firstElementChild;
+ expect(sectionStack).toHaveClass('pf-v6-l-stack', 'pf-m-gutter');
+ expect(sectionStack?.firstElementChild?.getAttribute('class')).toBe('');
+ expect(sectionStack?.firstElementChild?.firstElementChild).toHaveClass('pf-v6-l-stack', 'pf-m-gutter');
+
+ expect(screen.getByRole('heading', { name: 'Match analysis' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'By ecosystem' })).toBeInTheDocument();
+ expect(screen.getByText('See how packages map to supported ecosystems.')).toBeInTheDocument();
+ expect(screen.getByText(/Download a full, shareable report/)).toBeInTheDocument();
+ });
+
+ it('renders responsive chart dimensions, series legend, and ecosystem labels', () => {
+ render(
+
+ );
+
+ const chartViewport = screen.getByRole('region', { name: 'By ecosystem chart' });
+ const chart = chartViewport.querySelector('svg');
+ expect(chart).toHaveAttribute('viewBox');
+ expect(chart).toHaveAttribute('height', '158');
+ expect(screen.getByText('Exact match')).toBeInTheDocument();
+ expect(screen.getByText('Partial match')).toBeInTheDocument();
+ expect(screen.getByText('No match')).toBeInTheDocument();
+ expect(screen.getByText('Java')).toBeInTheDocument();
+ expect(screen.getByText('Python')).toBeInTheDocument();
+
+ expect(chart?.querySelector('desc')).toHaveTextContent(
+ 'Java: 5 exact matches, 10 partial matches, 15 with no match',
+ );
+
+ expect(chart?.querySelector('path[style*="lightwell-chart-color-exact"]')).toBeInTheDocument();
+ expect(chart?.querySelector('path[style*="lightwell-chart-color-partial"]')).toBeInTheDocument();
+ const legendSwatches = Array.from(chart?.querySelectorAll('path') ?? []).slice(-3);
+ expect(legendSwatches[0]).toHaveAttribute('style', expect.stringContaining('lightwell-chart-color-exact'));
+ expect(legendSwatches[1]).toHaveAttribute('style', expect.stringContaining('lightwell-chart-color-partial'));
+ expect(legendSwatches[2]).toHaveAttribute(
+ 'style',
+ expect.stringContaining(
+ 'lightwell-chart-color-no-match, var(--pf-t--chart--global--fill--color--200, #e0e0e0)',
+ ),
+ );
+ });
+
+ it('uses the caller-provided chart palette for ecosystem legend swatches', () => {
+ const chartColors = [ '#123456', '#234567', '#345678' ];
+ render(
+ ,
+ );
+
+ const chart = screen.getByRole('region', { name: 'By ecosystem chart' }).querySelector('svg');
+ const legendSwatches = Array.from(chart?.querySelectorAll('path') ?? []).slice(-3);
+ expect(legendSwatches.map((swatch) => swatch.style.fill)).toEqual(chartColors);
+ });
+
+ it('shows match type and value in ecosystem bar tooltips', async () => {
+ render();
+
+ const chart = screen.getByRole('region', { name: 'By ecosystem chart' }).querySelector('svg');
+ const exactBar = chart?.querySelector('path[style*="lightwell-chart-color-exact"]');
+
+ if (!exactBar) {
+ throw new Error('Expected to find the exact-match bar');
+ }
+ fireEvent.mouseOver(exactBar);
+
+ expect(await screen.findByText('Exact match: 70')).toBeInTheDocument();
+ });
+
+ it('applies the Lightwell CTA color inline', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: 'Download report' })).toHaveStyle({
+ '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)',
+ });
+ });
+
+ it('supports caller content and actions without adding Lightwell copy', () => {
+ const onClick = jest.fn();
+ render(
+ Custom analysis placement}
+ footerText="Your custom report is ready."
+ primaryAction={{ label: 'Open report', onClick }}
+ />,
+ );
+
+ expect(screen.getByRole('dialog', { name: 'Coverage overview' })).toBeInTheDocument();
+ expect(screen.getByText('Custom analysis placement')).toBeInTheDocument();
+ expect(screen.queryByRole('region', { name: 'By ecosystem chart' })).not.toBeInTheDocument();
+ expect(screen.queryByText('Learn more about Lightwell')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Open report' }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not supply product-specific chart data implicitly', () => {
+ render();
+
+ expect(screen.queryByRole('region', { name: 'By ecosystem chart' })).not.toBeInTheDocument();
+ expect(screen.queryByText('Java')).not.toBeInTheDocument();
+ });
+
+ it('allows overriding the title icon size and content aria-label', () => {
+ render(
+ ,
+ );
+
+ const productTitle = screen.getByRole('heading', { name: 'Lightwell Lens' });
+ const titleLogo = productTitle.parentElement?.querySelector('img');
+ expect(titleLogo?.parentElement).toHaveStyle({ width: '2rem', height: '2rem' });
+ expect(screen.getByRole('region', { name: 'Custom region label' })).toBeInTheDocument();
+ });
+
+ it('renders only the match analysis when ecosystemData is omitted', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: 'Match analysis' })).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'By ecosystem' })).not.toBeInTheDocument();
+ });
+
+ it('renders only the ecosystem chart when matchData is omitted', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: 'By ecosystem' })).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Match analysis' })).not.toBeInTheDocument();
+ });
+
+ it('renders no charts when neither matchData nor ecosystemData is provided', () => {
+ render();
+
+ expect(screen.queryByRole('heading', { name: 'Match analysis' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'By ecosystem' })).not.toBeInTheDocument();
+ });
+
+ it('prefers customContent over both built-in charts', () => {
+ render(Custom body
} isOpen onClose={jest.fn()} />);
+
+ expect(screen.getByText('Custom body')).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Match analysis' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'By ecosystem' })).not.toBeInTheDocument();
+ });
+
+ it('scales the ecosystem chart axis to data larger than 200', () => {
+ render(
+ ,
+ );
+
+ const chart = screen.getByRole('region', { name: 'By ecosystem chart' }).querySelector('svg');
+ expect(chart?.textContent).toContain('400');
+ });
+
+ it('keeps the default axis ticks for small ecosystem data', () => {
+ render();
+
+ const chart = screen.getByRole('region', { name: 'By ecosystem chart' }).querySelector('svg');
+ expect(chart?.textContent).toContain('200');
+ expect(chart?.textContent).not.toContain('400');
+ });
+
+ it('observes the ecosystem chart when the modal starts closed and is opened later', () => {
+ const resizeObserverMock = getResizeObserver as jest.Mock;
+ resizeObserverMock.mockClear();
+ const { rerender } = render();
+ expect(resizeObserverMock).not.toHaveBeenCalled();
+
+ rerender();
+
+ expect(resizeObserverMock).toHaveBeenCalledWith(
+ screen.getByRole('region', { name: 'By ecosystem chart' }),
+ expect.any(Function),
+ );
+ });
+
+ it('renders the secondary link action with a trailing icon in the button icon slot', () => {
+ render();
+
+ const link = screen.getByRole('link', { name: 'Learn more about Lightwell' });
+ expect(link.querySelector('.pf-v6-c-button__icon.pf-m-end svg')).toBeInTheDocument();
+ });
+
+ it('does not remount the title icon when the modal rerenders with the same props', () => {
+ const { rerender } = render();
+ const icon = screen.getByRole('dialog').querySelector('.pf-v6-c-modal-box__title-icon img');
+
+ rerender();
+
+ expect(screen.getByRole('dialog').querySelector('.pf-v6-c-modal-box__title-icon img')).toBe(icon);
+ });
+
+ it('keeps the ecosystem axis close to the data instead of overshooting', () => {
+ render(
+ ,
+ );
+
+ const chart = screen.getByRole('region', { name: 'By ecosystem chart' }).querySelector('svg');
+ expect(chart?.textContent).toContain('1K');
+ expect(chart?.textContent).not.toContain('2K');
+ });
+
+ it('does not render the ecosystem section for an empty ecosystemData array', () => {
+ render();
+
+ expect(screen.queryByRole('heading', { name: 'By ecosystem' })).not.toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Match analysis' })).toBeInTheDocument();
+ });
+
+ it('keeps a consumer title icon mounted and up to date across rerenders', () => {
+ const { rerender } = render(
+ First} isOpen onClose={jest.fn()} />,
+ );
+ const icon = screen.getByTestId('title-icon');
+
+ rerender(
+ Second} isOpen onClose={jest.fn()} />,
+ );
+
+ expect(screen.getByTestId('title-icon')).toBe(icon);
+ expect(icon).toHaveTextContent('Second');
+ });
+});
diff --git a/packages/module/src/ProductNudge/ProductNudgeDataModal.tsx b/packages/module/src/ProductNudge/ProductNudgeDataModal.tsx
new file mode 100644
index 00000000..05ff1ab5
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeDataModal.tsx
@@ -0,0 +1,440 @@
+import type { ComponentType, FunctionComponent, ReactNode } from 'react';
+import { useEffect, useId, useMemo, useRef, useState } from 'react';
+
+import {
+ Button,
+ Content,
+ Flex,
+ FlexItem,
+ getResizeObserver,
+ Modal,
+ ModalBody,
+ ModalHeader,
+ Popover,
+ Stack,
+ Title,
+} from '@patternfly/react-core';
+import {
+ Chart,
+ ChartAxis,
+ ChartBar,
+ ChartContainer,
+ ChartDonut,
+ ChartGroup,
+ ChartTooltip,
+} from '@patternfly/react-charts/victory';
+import ArrowRightIcon from '@patternfly/react-icons/dist/esm/icons/arrow-right-icon.js';
+import RhUiQuestionMarkCircleIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-question-mark-circle-icon.js';
+import { createUseStyles } from 'react-jss';
+
+import {
+ ProductNudgeAction,
+ ProductNudgeBrand,
+ ProductNudgeCtaColorScheme,
+ ProductNudgeEcosystemData,
+ ProductNudgeImage,
+ ProductNudgeMatchData,
+} from './ProductNudge.types';
+import { createSquareImageSizeStyle, lightwellCtaStyle, nudgeModeStyles } from './nudgeStyles';
+import { lightwellBrandAssets, LightwellLogomark, LightwellLogomarkDark } from './productNudgeDefaults';
+import { ProductNudgeModalFooter } from './ProductNudgeModalFooter';
+import { formatAxisTick, getYAxisTicks } from './productNudgeChartUtils';
+
+export interface ProductNudgeDataModalProps {
+ /** Whether the modal is open */
+ isOpen: boolean;
+ /** Callback to close the modal */
+ onClose: () => void;
+ /** Summary counts for the built-in match charts; omitted when using custom analysis content. */
+ matchData?: ProductNudgeMatchData;
+ /** Per-ecosystem counts for the built-in charts; omitted when using custom analysis content. */
+ ecosystemData?: ProductNudgeEcosystemData[];
+ /** Applies a named brand preset; all copy remains caller-supplied. */
+ brand?: ProductNudgeBrand;
+ /** Modal title. */
+ titleText: React.ReactNode;
+ /** Optional icon displayed beside the modal title. */
+ titleIcon?: React.ReactNode;
+ /** Optional description displayed below the modal title. */
+ descriptionText?: React.ReactNode;
+ /** Replaces the default chart region with caller-provided content of any kind (not limited to analysis/charts). */
+ customContent?: React.ReactNode;
+ /** Optional text displayed in the modal footer. */
+ footerText?: React.ReactNode;
+ /** Optional primary action displayed in the modal footer. */
+ primaryAction?: ProductNudgeAction;
+ /** Optional secondary action displayed in the modal footer. */
+ secondaryAction?: ProductNudgeAction;
+ /** Footer partner logo override. */
+ partnerLogo?: ProductNudgeImage;
+ /** Dark-mode footer partner logo override. */
+ partnerLogoDark?: ProductNudgeImage;
+ /** Override the primary CTA visual scheme. */
+ ctaColorScheme?: ProductNudgeCtaColorScheme;
+ /** CTA inline style applied when `ctaColorScheme="custom"`. */
+ ctaStyle?: React.CSSProperties;
+ /** Width/height of the title icon container; defaults to '1.5rem'. */
+ titleIconSize?: string;
+ /** aria-label applied to the scrollable modal body region (wraps both the built-in chart and `customContent`). */
+ contentAriaLabel?: string;
+ /** Optional palette for the built-in charts. */
+ chartColors?: string[];
+ /** Prefix for generated modal and accessibility IDs. */
+ id?: string;
+}
+
+const useStyles = createUseStyles({
+ modal: {
+ '--pf-v6-c-modal-box__header--PaddingBlockStart': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__header--PaddingBlockEnd': 'var(--pf-t--global--spacer--md)',
+ '--pf-v6-c-modal-box__header--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__header--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__body--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__body--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__footer--PaddingBlockEnd': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__footer--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-modal-box__footer--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)',
+ '--pf-v6-c-content--small--MarginBlockEnd': 0,
+ },
+ ecosystemChartViewport: {
+ width: '100%',
+ minWidth: 0,
+ height: '158px',
+ overflowX: 'auto',
+ },
+ modalTitleIcon: {
+ '& img': {
+ width: '100%',
+ height: '100%',
+ },
+ },
+ ...nudgeModeStyles,
+});
+
+const DEFAULT_ECOSYSTEM_CHART_WIDTH = 600;
+const ECOSYSTEM_CHART_HEIGHT = 158;
+
+// Lightwell's chart palette is intentionally separate from PatternFly's palette;
+// custom properties allow consumers to theme it without changing the defaults.
+const LIGHTWELL_CHART_COLORS = [
+ 'var(--lightwell-chart-color-exact, #f56e6e)',
+ 'var(--lightwell-chart-color-partial, #f8ae54)',
+ 'var(--lightwell-chart-color-no-match, var(--pf-t--chart--global--fill--color--200, #e0e0e0))',
+];
+
+const ECOSYSTEM_LEGEND_LABELS = [ 'Exact match', 'Partial match', 'No match' ];
+
+const describeEcosystemData = (data: ProductNudgeEcosystemData[]) => data.map(({ name, exact, partial, noMatch }) => {
+ const ecosystemName = typeof name === 'string' || typeof name === 'number' ? String(name) : 'Ecosystem';
+ return `${ecosystemName}: ${exact} exact matches, ${partial} partial matches, ${noMatch} with no match`;
+}).join('. ');
+
+interface LightwellTitleIconProps {
+ /** Width/height of the icon container. */
+ size: string;
+}
+
+const LightwellTitleIcon: FunctionComponent = ({ size }) => {
+ const classes = useStyles();
+
+ return (
+
+

+

+
+ );
+};
+
+const renderFooterAction = (
+ action: ProductNudgeAction | undefined,
+ variant: 'primary' | 'link',
+ style?: React.CSSProperties,
+) => {
+ if (!action) {
+ return null;
+ }
+
+ const linkIconProps = variant === 'link' ? { icon: , iconPosition: 'end' as const } : {};
+
+ return action.href ? (
+
+ ) : (
+
+ );
+};
+
+export const ProductNudgeDataModal: FunctionComponent = ({
+ isOpen,
+ onClose,
+ matchData,
+ ecosystemData,
+ brand,
+ titleText,
+ titleIcon,
+ descriptionText,
+ customContent,
+ footerText,
+ primaryAction,
+ secondaryAction,
+ partnerLogo,
+ partnerLogoDark,
+ ctaColorScheme,
+ ctaStyle: customCtaStyle,
+ titleIconSize = '1.5rem',
+ contentAriaLabel = 'Additional details',
+ chartColors: providedChartColors,
+ id,
+}: ProductNudgeDataModalProps) => {
+ const classes = useStyles();
+ const [ ecosystemChartViewport, setEcosystemChartViewport ] = useState(null);
+ const [ ecosystemChartWidth, setEcosystemChartWidth ] = useState(DEFAULT_ECOSYSTEM_CHART_WIDTH);
+ const generatedId = useId();
+ const idPrefix = id ?? `product-nudge-analysis-${generatedId.replace(/:/g, '')}`;
+ const resolvedLogo = partnerLogo ?? (brand === 'lightwell' ? lightwellBrandAssets.partnerLogo : undefined);
+ const resolvedLogoDark = partnerLogoDark ?? (brand === 'lightwell' && !partnerLogo ? lightwellBrandAssets.partnerLogoDark : undefined);
+ const resolvedCtaColorScheme = ctaColorScheme ?? (brand === 'lightwell' ? 'lightwell' : 'default');
+ const ctaStyleByScheme: Record = {
+ lightwell: lightwellCtaStyle,
+ custom: customCtaStyle,
+ default: undefined,
+ };
+ const ctaStyle = ctaStyleByScheme[resolvedCtaColorScheme];
+ const chartColors = providedChartColors ?? (brand === 'lightwell' ? LIGHTWELL_CHART_COLORS : undefined);
+ const legendData = ECOSYSTEM_LEGEND_LABELS.map((name, index) => ({
+ name,
+ ...(chartColors?.length && {
+ symbol: { fill: chartColors[index % chartColors.length] },
+ }),
+ }));
+ // ModalHeader renders titleIconVariant as a component type. Keep that type stable and read the
+ // latest icon node from a ref, so an inline `titleIcon` element doesn't remount on every render.
+ const titleIconNodeRef = useRef(titleIcon);
+ titleIconNodeRef.current = titleIcon;
+ const hasTitleIcon = Boolean(titleIcon);
+ const titleIconVariant = useMemo(() => {
+ if (hasTitleIcon) {
+ return () => <>{titleIconNodeRef.current}>;
+ }
+ if (brand === 'lightwell') {
+ return () => ;
+ }
+ return undefined;
+ }, [ hasTitleIcon, brand, titleIconSize ]);
+ const chartMatchData = matchData ?? { exact: 0, partial: 0, noMatch: 0 };
+ const chartEcosystemData = ecosystemData ?? [];
+ const hasEcosystemData = chartEcosystemData.length > 0;
+ const maxEcosystemValue = Math.max(0, ...chartEcosystemData.flatMap(({ exact, partial, noMatch }) => [ exact, partial, noMatch ]));
+ const yAxisTicks = getYAxisTicks(maxEcosystemValue);
+ const totalPackages = chartMatchData.exact + chartMatchData.partial + chartMatchData.noMatch;
+ const totalMatches = chartMatchData.exact + chartMatchData.partial;
+ const matchPercentage = totalPackages ? Math.round((totalMatches / totalPackages) * 100) : 0;
+ const matchItems = [
+ {
+ label: 'exact matches',
+ value: chartMatchData.exact,
+ helpText: 'Packages with a direct version-matched equivalent in the catalog.',
+ },
+ {
+ label: 'partial matches',
+ value: chartMatchData.partial,
+ helpText: 'Packages with a near-match or alternative available in the catalog.',
+ },
+ {
+ label: 'no match',
+ value: chartMatchData.noMatch,
+ helpText: 'Packages with no equivalent found in the catalog.',
+ },
+ ];
+
+ // The viewport only exists while the modal is open and the built-in ecosystem chart is rendered,
+ // so observe it through a callback-ref'd element instead of a one-time mount effect.
+ useEffect(() => {
+ if (!ecosystemChartViewport) {
+ return;
+ }
+
+ const updateChartWidth = () => {
+ const width = ecosystemChartViewport.clientWidth;
+ if (width > 0) {
+ setEcosystemChartWidth(width);
+ }
+ };
+
+ const unobserve = getResizeObserver(ecosystemChartViewport, updateChartWidth);
+ updateChartWidth();
+
+ return unobserve;
+ }, [ ecosystemChartViewport ]);
+
+ return (
+
+
+
+
+ {customContent ?? ((matchData || hasEcosystemData) && (
+
+ {matchData && (
+
+
+
+
+ Match analysis
+
+ {matchPercentage}% of packages match the catalog.
+
+
+
+
+ ({ x: label, y: value }))}
+ labels={({ datum }) => `${datum.x}: ${datum.y}`}
+ labelComponent={}
+ title={`${totalMatches}`}
+ subTitle="matches"
+ colorScale={chartColors}
+ constrainToVisibleArea
+ height={160}
+ width={160}
+ padding={{ bottom: 0, left: 0, right: 0, top: 0 }}
+ radius={70}
+ innerRadius={52}
+ padAngle={1}
+ />
+
+
+
+ {matchItems.map(({ label, value, helpText }) => (
+
+ {value}
+
+ }
+ iconPosition="end"
+ >
+ {label}
+
+
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
+ {hasEcosystemData && (
+
+
+
+
+ By ecosystem
+ See how packages map to supported ecosystems.
+
+
+ }
+ >
+
+ name)} />
+
+ ({ x: name, y: exact, label: `Exact match: ${exact}` }))}
+ labels={({ datum }) => datum.label}
+ labelComponent={}
+ />
+ ({ x: name, y: partial, label: `Partial match: ${partial}` }))}
+ labels={({ datum }) => datum.label}
+ labelComponent={}
+ />
+ ({ x: name, y: noMatch, label: `No match: ${noMatch}` }))}
+ labels={({ datum }) => datum.label}
+ labelComponent={}
+ />
+
+
+
+
+
+
+
+ )}
+
+ ))}
+
+
+ {primaryAction && {renderFooterAction(primaryAction, 'primary', ctaStyle)}}
+ {secondaryAction && {renderFooterAction(secondaryAction, 'link')}}
+ >
+ )}
+ />
+
+ );
+};
+
+export default ProductNudgeDataModal;
diff --git a/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.test.tsx b/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.test.tsx
new file mode 100644
index 00000000..1b6b58fe
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.test.tsx
@@ -0,0 +1,158 @@
+import { act, render, screen } from '@testing-library/react';
+
+import ProductNudgeDescriptionItem from './ProductNudgeDescriptionItem';
+
+let restoreIntersectionObserver: () => void = jest.fn();
+
+const installIntersectionObserverMock = () => {
+ let callback: IntersectionObserverCallback = () => undefined;
+ const observe = jest.fn();
+ const disconnect = jest.fn();
+ const originalDescriptor = Object.getOwnPropertyDescriptor(global, 'IntersectionObserver');
+ const observerConstructor = jest.fn((nextCallback: IntersectionObserverCallback) => {
+ callback = nextCallback;
+ return { observe, disconnect } as unknown as IntersectionObserver;
+ });
+
+ Object.defineProperty(global, 'IntersectionObserver', {
+ configurable: true,
+ value: observerConstructor
+ });
+
+ const restore = () => {
+ if (originalDescriptor) {
+ Object.defineProperty(global, 'IntersectionObserver', originalDescriptor);
+ } else {
+ Reflect.deleteProperty(global, 'IntersectionObserver');
+ }
+ };
+ restoreIntersectionObserver = restore;
+
+ return {
+ observe,
+ disconnect,
+ observerConstructor,
+ notify: (intersectionRatio: number) =>
+ act(() =>
+ callback([ { isIntersecting: true, intersectionRatio } as IntersectionObserverEntry ], {} as IntersectionObserver)
+ ),
+ };
+};
+
+afterEach(() => restoreIntersectionObserver());
+
+describe('ProductNudgeDescriptionItem component', () => {
+ it('calls onImpression once when at least half visible', () => {
+ const intersectionObserver = installIntersectionObserverMock();
+ const onImpression = jest.fn();
+
+ render(
+
+
+
,
+ );
+
+ expect(intersectionObserver.observe).toHaveBeenCalledTimes(1);
+ expect(intersectionObserver.observerConstructor).toHaveBeenCalledWith(expect.any(Function), { threshold: 0.5 });
+ intersectionObserver.notify(0.25);
+ expect(onImpression).not.toHaveBeenCalled();
+ intersectionObserver.notify(0.5);
+ intersectionObserver.notify(0.75);
+
+ expect(onImpression).toHaveBeenCalledTimes(1);
+ expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not observe when ineligible or when onImpression is omitted', () => {
+ const intersectionObserver = installIntersectionObserverMock();
+
+ const { rerender } = render(
+
+
+
,
+ );
+ expect(intersectionObserver.observerConstructor).not.toHaveBeenCalled();
+
+ rerender(
+
+
+
,
+ );
+ expect(intersectionObserver.observerConstructor).not.toHaveBeenCalled();
+ });
+
+ it('renders caller-provided term, icon, description, and link content', () => {
+ render(
+
+ Icon}
+ headline="12 issues addressed"
+ bodyText="A product-specific description."
+ ctaText="View the report"
+ ctaUrl="https://example.com/report"
+ />
+
,
+ );
+
+ expect(screen.getByText('Acme Security')).toBeInTheDocument();
+ expect(screen.getByTestId('term-icon')).toBeInTheDocument();
+ expect(screen.getByText('12 issues addressed')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'View the report' })).toHaveAttribute('href', 'https://example.com/report');
+ });
+
+ it('renders neutral term content when no brand or term text is supplied', () => {
+ render(
+
+
+
,
+ );
+
+ expect(screen.queryByText('Lightwell')).not.toBeInTheDocument();
+ });
+
+ it('renders the Lightwell inline mark at 16px', () => {
+ const { container } = render(
+
+
+
,
+ );
+
+ expect(container.querySelector('img')).toHaveStyle({ width: '1rem' });
+ });
+
+ it('allows overriding the term logo size', () => {
+ const { container } = render(
+
+
+
,
+ );
+
+ expect(container.querySelector('img')).toHaveStyle({ width: '2rem' });
+ });
+});
diff --git a/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.tsx b/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.tsx
new file mode 100644
index 00000000..8a9a105e
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.tsx
@@ -0,0 +1,161 @@
+import type { FunctionComponent, ReactNode } from 'react';
+
+import {
+ Button,
+ DescriptionListDescription,
+ DescriptionListTerm,
+ Stack,
+ StackItem,
+} from '@patternfly/react-core';
+import { css } from '@patternfly/react-styles';
+import descriptionListStyles from '@patternfly/react-styles/css/components/DescriptionList/description-list';
+import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon.js';
+import { createUseStyles } from 'react-jss';
+
+import { ProductNudgeBrand } from './ProductNudge.types';
+import { createImageSizeStyle, nudgeModeStyles } from './nudgeStyles';
+import { lightwellBrandAssets } from './productNudgeDefaults';
+import { useImpressionTracking } from './useImpressionTracking';
+
+export interface ProductNudgeDescriptionItemProps {
+ /** false renders null */
+ isEligible: boolean;
+ /** Called once when the component is at least 50% visible. */
+ onImpression?: () => void;
+ /** Applies a named brand preset; copy remains entirely caller-supplied. */
+ brand?: ProductNudgeBrand;
+ /** Visible text/content rendered in the description-list term. */
+ termText?: string | ReactNode;
+ /** Icon displayed in the description-list term. */
+ termIcon?: ReactNode;
+ /** Bold headline in the description */
+ headline: string | ReactNode;
+ /** Body paragraph in the description */
+ bodyText: string | ReactNode;
+ /** CTA link label */
+ ctaText?: string;
+ /** CTA link href */
+ ctaUrl?: string;
+ /** Custom term logo/icon image for light mode. */
+ logo?: { src: string; alt: string };
+ /** Custom term logo/icon image for dark mode. */
+ logoDark?: { src: string; alt: string };
+ /** Width of the term logo/icon; defaults to '1rem'. */
+ logoMarkSize?: string;
+ /** Inline-start margin of the term logo/icon; defaults to unset. */
+ logoMarkOffset?: string;
+ /** OUIA component ID */
+ ouiaId?: string;
+ /** Additional CSS class on the DescriptionListGroup */
+ className?: string;
+ /** data-testid forwarded to root */
+ 'data-testid'?: string;
+}
+
+const useStyles = createUseStyles({
+ term: {
+ alignSelf: 'start',
+ },
+ termIcon: {
+ display: 'block',
+ height: 'auto',
+ },
+ ...nudgeModeStyles,
+});
+
+/**
+ * A DescriptionList item variant of ProductNudge. Renders as a DescriptionListGroup
+ * so it can be dropped directly inside an existing DescriptionList alongside other items.
+ * Term content and icon are caller supplied; the description renders headline, body, and link content.
+ */
+export const ProductNudgeDescriptionItem: FunctionComponent = ({
+ isEligible,
+ onImpression,
+ brand,
+ termText,
+ termIcon,
+ headline,
+ bodyText,
+ ctaText,
+ ctaUrl,
+ logo,
+ logoDark,
+ logoMarkSize = '1rem',
+ logoMarkOffset,
+ ouiaId = 'ProductNudgeDescriptionItem',
+ className,
+ 'data-testid': dataTestId,
+}: ProductNudgeDescriptionItemProps) => {
+ const classes = useStyles();
+ const impressionRef = useImpressionTracking(onImpression, isEligible);
+ const lightTermLogo = logo ?? (brand === 'lightwell' ? lightwellBrandAssets.logomark : undefined);
+ const darkTermLogo = logoDark ?? (brand === 'lightwell' && !logo ? lightwellBrandAssets.logomarkDark : undefined);
+ const resolvedTermIconStyle = createImageSizeStyle(logoMarkSize, logoMarkOffset);
+ const resolvedTermIcon = termIcon ?? ((lightTermLogo || darkTermLogo) ? (
+ <>
+ {lightTermLogo && (
+
+ )}
+ {darkTermLogo && (
+
+ )}
+ >
+ ) : undefined);
+
+ if (!isEligible) {
+ return null;
+ }
+
+ return (
+
+
+ {termText}
+
+
+
+ {headline}
+ {bodyText && {bodyText}}
+ {ctaUrl && (
+
+ }
+ iconPosition="end"
+ ouiaId={`${ouiaId}-cta`}
+ >
+ {ctaText ?? 'Learn more'}
+
+
+ )}
+
+
+
+ );
+};
+
+export default ProductNudgeDescriptionItem;
diff --git a/packages/module/src/ProductNudge/ProductNudgeModalFooter.test.tsx b/packages/module/src/ProductNudge/ProductNudgeModalFooter.test.tsx
new file mode 100644
index 00000000..6d1d3e79
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeModalFooter.test.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from '@testing-library/react';
+
+import ProductNudgeModalFooter from './ProductNudgeModalFooter';
+
+describe('ProductNudgeModalFooter component', () => {
+ it('renders nothing when no content is provided', () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders footer text and actions', () => {
+ render(
+ Submit}
+ />,
+ );
+
+ expect(screen.getByText('Supporting copy')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
+ });
+
+ it('renders a partner logo when only light mode is provided', () => {
+ render();
+
+ expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', 'logo.svg');
+ });
+
+ it('renders both light and dark partner logos when both are provided', () => {
+ const { container } = render(
+ ,
+ );
+
+ const images = container.querySelectorAll('img');
+ expect(images).toHaveLength(2);
+ expect(images[0]).toHaveAttribute('src', 'logo-light.svg');
+ expect(images[1]).toHaveAttribute('src', 'logo-dark.svg');
+ });
+
+ it('centers the actions row when isCentered is set', () => {
+ const { container } = render(
+ Submit} isCentered />,
+ );
+
+ expect(container.querySelector('.pf-m-justify-content-center')).toBeInTheDocument();
+ });
+});
diff --git a/packages/module/src/ProductNudge/ProductNudgeModalFooter.tsx b/packages/module/src/ProductNudge/ProductNudgeModalFooter.tsx
new file mode 100644
index 00000000..903e5cc7
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeModalFooter.tsx
@@ -0,0 +1,56 @@
+import { FunctionComponent, ReactNode } from 'react';
+import { Content, ContentVariants, Flex, FlexItem, ModalFooter, Stack } from '@patternfly/react-core';
+
+import { ProductNudgeImage } from './ProductNudge.types';
+import { ProductNudgeBrandLogo } from './ProductNudgeBrandLogo';
+
+export interface ProductNudgeModalFooterProps {
+ /** Action buttons/links rendered alongside the partner logo. */
+ actions?: ReactNode;
+ /** Supporting copy rendered above the actions row. */
+ footerText?: ReactNode;
+ /** Partner logo shown in light mode (and in dark mode when `partnerLogoDark` is not provided). */
+ partnerLogo?: ProductNudgeImage;
+ /** Partner logo shown in dark mode; falls back to `partnerLogo` when not provided. */
+ partnerLogoDark?: ProductNudgeImage;
+ /** Additional CSS class forwarded to the footer. */
+ className?: string;
+ /** Centers the actions/logo row instead of the default space-between layout. */
+ isCentered?: boolean;
+}
+
+/** Shared modal footer for actions, optional supporting copy, and brand logo. */
+export const ProductNudgeModalFooter: FunctionComponent = ({
+ actions,
+ footerText,
+ partnerLogo,
+ partnerLogoDark,
+ className,
+ isCentered = false,
+}) => {
+ if (!actions && !footerText && !partnerLogo && !partnerLogoDark) {
+ return null;
+ }
+
+ return (
+
+
+ {footerText && {footerText}}
+
+ {actions}
+ {(partnerLogo || partnerLogoDark) && (
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default ProductNudgeModalFooter;
diff --git a/packages/module/src/ProductNudge/ProductNudgeStack.test.tsx b/packages/module/src/ProductNudge/ProductNudgeStack.test.tsx
new file mode 100644
index 00000000..bc542528
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeStack.test.tsx
@@ -0,0 +1,147 @@
+import { act, render, screen } from '@testing-library/react';
+import ProductNudgeStack from './ProductNudgeStack';
+
+let restoreIntersectionObserver: () => void = jest.fn();
+
+const installIntersectionObserverMock = () => {
+ let callback: IntersectionObserverCallback = () => undefined;
+ const observe = jest.fn();
+ const disconnect = jest.fn();
+ const originalDescriptor = Object.getOwnPropertyDescriptor(global, 'IntersectionObserver');
+ const observerConstructor = jest.fn((nextCallback: IntersectionObserverCallback) => {
+ callback = nextCallback;
+ return { observe, disconnect } as unknown as IntersectionObserver;
+ });
+
+ Object.defineProperty(global, 'IntersectionObserver', {
+ configurable: true,
+ value: observerConstructor
+ });
+
+ const restore = () => {
+ if (originalDescriptor) {
+ Object.defineProperty(global, 'IntersectionObserver', originalDescriptor);
+ } else {
+ Reflect.deleteProperty(global, 'IntersectionObserver');
+ }
+ };
+ restoreIntersectionObserver = restore;
+
+ return {
+ observe,
+ disconnect,
+ observerConstructor,
+ notify: (intersectionRatio: number) =>
+ act(() =>
+ callback([ { isIntersecting: true, intersectionRatio } as IntersectionObserverEntry ], {} as IntersectionObserver)
+ ),
+ };
+};
+
+afterEach(() => restoreIntersectionObserver());
+
+describe('ProductNudgeStack component', () => {
+ it('renders nothing when not eligible', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders when eligible', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.getByText('Headline')).toBeInTheDocument();
+ expect(screen.getByText('Body text.')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Learn more' })).toHaveAttribute('href', 'https://example.com');
+ expect(container.querySelector('img')).toHaveAttribute('src', 'logo.png');
+ });
+
+ it('renders without logo or CTA link', () => {
+ const { container } = render();
+
+ expect(screen.getByText('Headline')).toBeInTheDocument();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ expect(container.querySelector('img')).not.toBeInTheDocument();
+ });
+
+ it('calls onImpression once when at least half visible', () => {
+ const intersectionObserver = installIntersectionObserverMock();
+ const onImpression = jest.fn();
+
+ render();
+
+ expect(intersectionObserver.observe).toHaveBeenCalledTimes(1);
+ expect(intersectionObserver.observerConstructor).toHaveBeenCalledWith(expect.any(Function), { threshold: 0.5 });
+ intersectionObserver.notify(0.25);
+ expect(onImpression).not.toHaveBeenCalled();
+ intersectionObserver.notify(0.5);
+ intersectionObserver.notify(0.75);
+
+ expect(onImpression).toHaveBeenCalledTimes(1);
+ expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not observe when ineligible or when onImpression is omitted', () => {
+ const intersectionObserver = installIntersectionObserverMock();
+
+ const { rerender } = render(
+
+ );
+ expect(intersectionObserver.observerConstructor).not.toHaveBeenCalled();
+
+ rerender();
+ expect(intersectionObserver.observerConstructor).not.toHaveBeenCalled();
+ });
+
+ it('renders the Lightwell inline mark at 16px', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('img')).toHaveStyle({ width: '1rem' });
+ });
+
+ it('allows overriding the logomark size', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('img')).toHaveStyle({ width: '2rem' });
+ });
+
+ it('uses the Lightwell mark only when the brand is selected and allows an icon override', () => {
+ render(
+ Custom icon}
+ bodyText="Custom body"
+ />,
+ );
+
+ expect(screen.getByTestId('field-icon')).toBeInTheDocument();
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/module/src/ProductNudge/ProductNudgeStack.tsx b/packages/module/src/ProductNudge/ProductNudgeStack.tsx
new file mode 100644
index 00000000..75f39f2e
--- /dev/null
+++ b/packages/module/src/ProductNudge/ProductNudgeStack.tsx
@@ -0,0 +1,174 @@
+import type { FunctionComponent, ReactNode } from 'react';
+
+import {
+ Button,
+ Flex,
+ FlexItem,
+ StackItem,
+} from '@patternfly/react-core';
+import { css } from '@patternfly/react-styles';
+import stackStyles from '@patternfly/react-styles/css/layouts/Stack/stack';
+import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon.js';
+import { createUseStyles } from 'react-jss';
+
+import { ProductNudgeBrand } from './ProductNudge.types';
+import { createImageSizeStyle, nudgeModeStyles } from './nudgeStyles';
+import { lightwellBrandAssets } from './productNudgeDefaults';
+import { useImpressionTracking } from './useImpressionTracking';
+
+export interface ProductNudgeStackProps {
+ /** false renders null */
+ isEligible: boolean;
+ /** Called once when the component is at least 50% visible. */
+ onImpression?: () => void;
+ /** Applies a named brand preset; copy remains entirely caller-supplied. */
+ brand?: ProductNudgeBrand;
+ /** Icon shown beside the title. */
+ titleIcon?: ReactNode;
+ /** Heading label rendered beside the logo */
+ titleText: string | ReactNode;
+ /** Primary value or metric rendered below the heading */
+ value?: string | ReactNode;
+ /** Body / note text */
+ bodyText: string | ReactNode;
+ /** CTA link label */
+ ctaText?: string;
+ /** CTA link href; when omitted, onAction renders a button CTA instead. */
+ ctaUrl?: string;
+ /** Action callback used instead of a link CTA. */
+ onAction?: () => void;
+ /** Loading state for an action CTA. */
+ isLoading?: boolean;
+ /** Logomark shown in the heading row (light mode) */
+ logo?: { src: string; alt: string };
+ /** Logomark shown in dark mode */
+ logoDark?: { src: string; alt: string };
+ /** Width of the logomark; defaults to '1rem'. */
+ logoMarkSize?: string;
+ /** Inline-start margin of the logomark; defaults to unset. */
+ logoMarkOffset?: string;
+ /** OUIA component ID */
+ ouiaId?: string;
+ /** Additional CSS class */
+ className?: string;
+ /** data-testid forwarded to the root element */
+ 'data-testid'?: string;
+}
+
+const useStyles = createUseStyles({
+ logomark: {
+ display: 'block',
+ height: 'auto',
+ },
+ ...nudgeModeStyles,
+ valueText: {
+ fontSize: 'var(--pf-t--global--font--size--lg)',
+ }
+});
+
+/**
+ * An in-context detail variant of ProductNudge. Renders a branded stack block
+ * with logomark, heading label, optional value, body note, and an inline CTA.
+ * Drop it anywhere — no DescriptionList wrapper required.
+ */
+export const ProductNudgeStack: FunctionComponent = ({
+ isEligible,
+ onImpression,
+ brand,
+ titleIcon,
+ titleText,
+ value,
+ bodyText,
+ ctaText,
+ ctaUrl,
+ onAction,
+ isLoading = false,
+ logo,
+ logoDark,
+ logoMarkSize = '1rem',
+ logoMarkOffset,
+ ouiaId = 'ProductNudgeStack',
+ className,
+ 'data-testid': dataTestId,
+}: ProductNudgeStackProps) => {
+ const classes = useStyles();
+ const impressionRef = useImpressionTracking(onImpression, isEligible);
+ const resolvedLogo = logo ?? (brand === 'lightwell' ? lightwellBrandAssets.logomark : undefined);
+ const resolvedLogoDark = logoDark ?? (brand === 'lightwell' && !logo ? lightwellBrandAssets.logomarkDark : undefined);
+ const resolvedLogoMarkStyle = createImageSizeStyle(logoMarkSize, logoMarkOffset);
+
+ if (!isEligible) {
+ return null;
+ }
+
+ return (
+
+
+
+ {titleIcon ? (
+ {titleIcon}
+ ) : resolvedLogo && (
+
+
+ {resolvedLogoDark && (
+
+ )}
+
+ )}
+ {titleText}
+
+
+ {value &&
{value}}
+ {bodyText &&
{bodyText}}
+ {(ctaUrl || onAction) && (
+
+ {ctaUrl ? (
+ }
+ iconPosition="end"
+ ouiaId={`${ouiaId}-cta`}
+ >
+ {ctaText ?? 'Learn more'}
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+};
+
+export default ProductNudgeStack;
diff --git a/packages/module/src/ProductNudge/assets/RedHatIBMLogo.svg b/packages/module/src/ProductNudge/assets/RedHatIBMLogo.svg
new file mode 100644
index 00000000..a6476f53
--- /dev/null
+++ b/packages/module/src/ProductNudge/assets/RedHatIBMLogo.svg
@@ -0,0 +1,62 @@
+
diff --git a/packages/module/src/ProductNudge/assets/RedHatIBMLogoDark.svg b/packages/module/src/ProductNudge/assets/RedHatIBMLogoDark.svg
new file mode 100644
index 00000000..be6ea37d
--- /dev/null
+++ b/packages/module/src/ProductNudge/assets/RedHatIBMLogoDark.svg
@@ -0,0 +1,62 @@
+
diff --git a/packages/module/src/ProductNudge/assets/lightwell-bg-dark.png b/packages/module/src/ProductNudge/assets/lightwell-bg-dark.png
new file mode 100644
index 00000000..dd1bbd80
Binary files /dev/null and b/packages/module/src/ProductNudge/assets/lightwell-bg-dark.png differ
diff --git a/packages/module/src/ProductNudge/assets/lightwell-bg-light.png b/packages/module/src/ProductNudge/assets/lightwell-bg-light.png
new file mode 100644
index 00000000..81982704
Binary files /dev/null and b/packages/module/src/ProductNudge/assets/lightwell-bg-light.png differ
diff --git a/packages/module/src/ProductNudge/assets/lightwell-logo-dark.svg b/packages/module/src/ProductNudge/assets/lightwell-logo-dark.svg
new file mode 100644
index 00000000..a02d71bd
--- /dev/null
+++ b/packages/module/src/ProductNudge/assets/lightwell-logo-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/module/src/ProductNudge/assets/lightwell-logo.svg b/packages/module/src/ProductNudge/assets/lightwell-logo.svg
new file mode 100644
index 00000000..833ae3ab
--- /dev/null
+++ b/packages/module/src/ProductNudge/assets/lightwell-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/module/src/ProductNudge/assets/lightwell-logomark-dark.svg b/packages/module/src/ProductNudge/assets/lightwell-logomark-dark.svg
new file mode 100644
index 00000000..24586272
--- /dev/null
+++ b/packages/module/src/ProductNudge/assets/lightwell-logomark-dark.svg
@@ -0,0 +1,15 @@
+
diff --git a/packages/module/src/ProductNudge/assets/lightwell-logomark-light.svg b/packages/module/src/ProductNudge/assets/lightwell-logomark-light.svg
new file mode 100644
index 00000000..b55d1c21
--- /dev/null
+++ b/packages/module/src/ProductNudge/assets/lightwell-logomark-light.svg
@@ -0,0 +1,35 @@
+
\ No newline at end of file
diff --git a/packages/module/src/ProductNudge/index.ts b/packages/module/src/ProductNudge/index.ts
new file mode 100644
index 00000000..0a79b2b1
--- /dev/null
+++ b/packages/module/src/ProductNudge/index.ts
@@ -0,0 +1,25 @@
+export { default } from './ProductNudge';
+export * from './ProductNudge';
+export * from './ProductNudge.types';
+
+export { default as ProductNudgeContactModal } from './ProductNudgeContactModal';
+export * from './ProductNudgeContactModal';
+
+
+export { default as ProductNudgeStack } from './ProductNudgeStack';
+export * from './ProductNudgeStack';
+
+export { default as ProductNudgeDescriptionItem } from './ProductNudgeDescriptionItem';
+export * from './ProductNudgeDescriptionItem';
+
+export { default as ProductNudgeDataModal } from './ProductNudgeDataModal';
+export * from './ProductNudgeDataModal';
+
+export { default as ProductNudgeModalFooter } from './ProductNudgeModalFooter';
+export * from './ProductNudgeModalFooter';
+
+export { default as ProductNudgeBrandLogo } from './ProductNudgeBrandLogo';
+export * from './ProductNudgeBrandLogo';
+
+export * from './productNudgeDefaults';
+export { useImpressionTracking } from './useImpressionTracking';
diff --git a/packages/module/src/ProductNudge/nudgeStyles.ts b/packages/module/src/ProductNudge/nudgeStyles.ts
new file mode 100644
index 00000000..5b2f60c5
--- /dev/null
+++ b/packages/module/src/ProductNudge/nudgeStyles.ts
@@ -0,0 +1,42 @@
+import type { CSSProperties } from 'react';
+
+export const nudgeModeStyles = {
+ lightModeOnly: {
+ '.pf-v6-theme-dark &': { display: 'none' },
+ },
+ darkModeOnly: {
+ display: 'none',
+ '.pf-v6-theme-dark &': { display: 'block' },
+ },
+};
+
+export const partnerLogoStyles = {
+ display: 'block',
+ width: 'min(120px, 30vw)',
+};
+
+/**
+ * Shared sizing for a rectangular brand image (e.g. a full wordmark logo), built from
+ * caller-supplied size/offset values rather than hardcoded per asset.
+ */
+export const createImageSizeStyle = (size: string, offset?: string): CSSProperties => (
+ offset === undefined ? { width: size } : { width: size, marginInlineStart: offset }
+);
+
+/**
+ * Shared sizing for a square brand mark (icon-style logomark or title icon container).
+ */
+export const createSquareImageSizeStyle = (size: string, offset?: string): CSSProperties => ({
+ ...createImageSizeStyle(size, offset),
+ height: size,
+});
+
+/** Default background tint applied for `brand="lightwell"`; overridable via the `backgroundColor` prop. */
+export const LIGHTWELL_BACKGROUND_COLOR = '#e5e0df';
+
+export const lightwellCtaStyle = {
+ '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)',
+ '--pf-v6-c-button--hover--BackgroundColor': 'var(--pf-t--color--red--60)',
+ '--pf-v6-c-button--m-clicked--BackgroundColor': 'var(--pf-t--color--red--60)',
+ '--pf-v6-c-button--Color': 'var(--pf-t--color--white)'
+} as CSSProperties;
diff --git a/packages/module/src/ProductNudge/productNudgeChartUtils.test.ts b/packages/module/src/ProductNudge/productNudgeChartUtils.test.ts
new file mode 100644
index 00000000..834138d6
--- /dev/null
+++ b/packages/module/src/ProductNudge/productNudgeChartUtils.test.ts
@@ -0,0 +1,43 @@
+import { formatAxisTick, getYAxisTicks } from './productNudgeChartUtils';
+
+describe('getYAxisTicks', () => {
+ it.each([
+ [ 804, [ 250, 500, 750, 1000 ] ],
+ [ 180, [ 50, 100, 150, 200 ] ],
+ [ 200, [ 50, 100, 150, 200 ] ],
+ [ 350, [ 100, 200, 300, 400 ] ],
+ [ 1001, [ 500, 1000, 1500 ] ],
+ [ 3, [ 1, 2, 3 ] ],
+ [ 9, [ 5, 10 ] ],
+ [ 12500, [ 5000, 10000, 15000 ] ],
+ ])('covers a max of %s without overshooting', (max, expected) => {
+ expect(getYAxisTicks(max)).toEqual(expected);
+ });
+
+ it.each([ 0, -5, NaN, Infinity ])('falls back to the default ticks for %s', (max) => {
+ expect(getYAxisTicks(max)).toEqual([ 50, 100, 150, 200 ]);
+ });
+
+ it('always ends at the first tick that covers the max and uses whole numbers', () => {
+ for (let max = 1; max <= 5000; max += 7) {
+ const ticks = getYAxisTicks(max);
+ const step = ticks[0];
+ expect(ticks[ticks.length - 1]).toBeGreaterThanOrEqual(max);
+ expect(ticks[ticks.length - 1] - step).toBeLessThan(max);
+ expect(ticks.every(Number.isInteger)).toBe(true);
+ }
+ });
+
+ it('returns a new array each call so callers cannot mutate the defaults', () => {
+ getYAxisTicks(0).push(999);
+ expect(getYAxisTicks(0)).toEqual([ 50, 100, 150, 200 ]);
+ });
+});
+
+describe('formatAxisTick', () => {
+ it('uses compact notation for large values', () => {
+ expect(formatAxisTick(250)).toBe('250');
+ expect(formatAxisTick(1000)).toBe('1K');
+ expect(formatAxisTick(1500)).toBe('1.5K');
+ });
+});
diff --git a/packages/module/src/ProductNudge/productNudgeChartUtils.ts b/packages/module/src/ProductNudge/productNudgeChartUtils.ts
new file mode 100644
index 00000000..8a1b7ba3
--- /dev/null
+++ b/packages/module/src/ProductNudge/productNudgeChartUtils.ts
@@ -0,0 +1,28 @@
+const TARGET_TICK_COUNT = 4;
+const DEFAULT_TICKS = [ 50, 100, 150, 200 ];
+const NICE_MULTIPLIERS = [ 1, 2, 2.5, 5, 10 ];
+
+/**
+ * Returns evenly spaced whole-number tick values for a chart's dependent axis.
+ * The last tick is the first "nice" step that covers `maxValue`, so the axis never extends
+ * much beyond the data. Falls back to 50-200 when there is no positive data.
+ */
+export const getYAxisTicks = (maxValue: number): number[] => {
+ if (!Number.isFinite(maxValue) || maxValue <= 0) {
+ return [ ...DEFAULT_TICKS ];
+ }
+
+ // Counts are whole numbers, so never use a step smaller than 1.
+ const rawStep = Math.max(1, maxValue / TARGET_TICK_COUNT);
+ const magnitude = 10 ** Math.floor(Math.log10(rawStep));
+ const step = NICE_MULTIPLIERS
+ .map((multiplier) => multiplier * magnitude)
+ .find((candidate) => candidate >= rawStep && Number.isInteger(candidate)) ?? 10 * magnitude;
+ const count = Math.ceil(maxValue / step);
+
+ return Array.from({ length: count }, (_, index) => step * (index + 1));
+};
+
+/** Compact axis label (for example 1500 becomes "1.5K") so large values fit the axis padding. */
+export const formatAxisTick = (value: number): string =>
+ new Intl.NumberFormat(undefined, { notation: 'compact' }).format(value);
diff --git a/packages/module/src/ProductNudge/productNudgeDefaults.ts b/packages/module/src/ProductNudge/productNudgeDefaults.ts
new file mode 100644
index 00000000..ed65747f
--- /dev/null
+++ b/packages/module/src/ProductNudge/productNudgeDefaults.ts
@@ -0,0 +1,41 @@
+import LightwellLogo from './assets/lightwell-logo.svg';
+import LightwellLogoDark from './assets/lightwell-logo-dark.svg';
+import LightwellLogomark from './assets/lightwell-logomark-light.svg';
+import LightwellLogomarkDark from './assets/lightwell-logomark-dark.svg';
+import RedHatIBMLogo from './assets/RedHatIBMLogo.svg';
+import RedHatIBMLogoDark from './assets/RedHatIBMLogoDark.svg';
+
+import { NudgeContent, ProductNudgeBrandAssets } from './ProductNudge.types';
+
+/** Individual asset exports for consumers that need direct access. */
+export { LightwellLogo, LightwellLogoDark, LightwellLogomark, LightwellLogomarkDark, RedHatIBMLogo, RedHatIBMLogoDark };
+
+/**
+ * Visual defaults for `brand="lightwell"`. Product copy is intentionally not included.
+ * Alt text below ("Lightwell", "Red Hat and IBM") is part of the Lightwell preset itself,
+ * not generic component chrome — it travels with its asset, so a consumer overriding any
+ * of these images (via `content.assets` or a component's `logo`/`logoDark`/`partnerLogo`
+ * props) supplies their own matching alt text in the same call.
+ *
+ * Background images are intentionally NOT included here — see `productNudgeLightwellBackgrounds.ts`.
+ */
+export const lightwellBrandAssets: ProductNudgeBrandAssets = {
+ logo: { src: LightwellLogo, alt: 'Lightwell' },
+ logoDark: { src: LightwellLogoDark, alt: 'Lightwell' },
+ logomark: { src: LightwellLogomark, alt: 'Lightwell' },
+ logomarkDark: { src: LightwellLogomarkDark, alt: 'Lightwell' },
+ partnerLogo: { src: RedHatIBMLogo, alt: 'Red Hat and IBM' },
+ partnerLogoDark: { src: RedHatIBMLogoDark, alt: 'Red Hat and IBM' },
+};
+
+/** Assembled assets for a full hero nudge (logo + partner logo). Add the Lightwell background
+ * images from `productNudgeLightwellBackgrounds.ts` for a hero nudge that needs one. */
+export const lightwellHeroAssets: NudgeContent['assets'] = {
+ ...lightwellBrandAssets,
+};
+
+/** Assembled assets for an alert or field nudge (logomark icon). */
+export const lightwellAlertAssets: NudgeContent['assets'] = {
+ logo: lightwellBrandAssets.logomark,
+ logoDark: lightwellBrandAssets.logomarkDark,
+};
diff --git a/packages/module/src/ProductNudge/productNudgeLightwellBackgrounds.ts b/packages/module/src/ProductNudge/productNudgeLightwellBackgrounds.ts
new file mode 100644
index 00000000..ebb0d852
--- /dev/null
+++ b/packages/module/src/ProductNudge/productNudgeLightwellBackgrounds.ts
@@ -0,0 +1,27 @@
+import LightwellBgLight from './assets/lightwell-bg-light.png';
+import LightwellBgDark from './assets/lightwell-bg-dark.png';
+
+import { ProductNudgeBrandAssets } from './ProductNudge.types';
+
+/** Individual asset exports for consumers that need direct access. */
+export { LightwellBgLight, LightwellBgDark };
+
+/**
+ * Lightwell's hero background images (~2MB combined). Deliberately NOT re-exported from
+ * `index.ts` so non-Lightwell consumers never pay for these bytes regardless of bundler
+ * tree-shaking configuration. Lightwell hero nudges that need a background opt in with one
+ * extra import, spreading this into `content.assets` alongside `lightwellBrandAssets`:
+ *
+ * ```ts
+ * import { lightwellBrandAssets } from '@patternfly/react-component-groups/dist/esm/ProductNudge/productNudgeDefaults';
+ * import { lightwellBackgroundAssets } from '@patternfly/react-component-groups/dist/esm/ProductNudge/productNudgeLightwellBackgrounds';
+ *
+ * content.assets = { ...lightwellBrandAssets, ...lightwellBackgroundAssets };
+ * ```
+ */
+export const lightwellBackgroundAssets: Pick = {
+ backgroundImageLight: LightwellBgLight,
+ backgroundImageDark: LightwellBgDark,
+};
+
+export default lightwellBackgroundAssets;
diff --git a/packages/module/src/ProductNudge/useImpressionTracking.ts b/packages/module/src/ProductNudge/useImpressionTracking.ts
new file mode 100644
index 00000000..8e60adcb
--- /dev/null
+++ b/packages/module/src/ProductNudge/useImpressionTracking.ts
@@ -0,0 +1,42 @@
+import { useEffect, useRef } from 'react';
+
+// Fires onImpression once per mounted instance, the first time the element
+// is at least 50% visible. Mount-based counting inflates the denominator
+// with never-seen renders, so this is IntersectionObserver-based rather
+// than fired on mount.
+export const useImpressionTracking = (
+ onImpression: (() => void) | undefined,
+ isEligible: boolean,
+) => {
+ const ref = useRef(null);
+ const hasFiredRef = useRef(false);
+
+ useEffect(() => {
+ if (!isEligible || !onImpression || hasFiredRef.current) {
+ return undefined;
+ }
+
+ const node = ref.current;
+ if (!node || typeof IntersectionObserver === 'undefined') {
+ return undefined;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5 && !hasFiredRef.current) {
+ hasFiredRef.current = true;
+ onImpression();
+ observer.disconnect();
+ }
+ });
+ },
+ { threshold: 0.5 },
+ );
+
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [ isEligible, onImpression ]);
+
+ return ref;
+};
diff --git a/packages/module/src/declarations.d.ts b/packages/module/src/declarations.d.ts
new file mode 100644
index 00000000..14461e25
--- /dev/null
+++ b/packages/module/src/declarations.d.ts
@@ -0,0 +1,9 @@
+declare module '*.png' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.svg' {
+ const src: string;
+ export default src;
+}
diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts
index c1ff0b47..bdc50eca 100644
--- a/packages/module/src/index.ts
+++ b/packages/module/src/index.ts
@@ -57,6 +57,9 @@ export * from './ResponsiveActions';
export { default as ResponsiveAction } from './ResponsiveAction';
export * from './ResponsiveAction';
+export { default as ProductNudge } from './ProductNudge';
+export * from './ProductNudge';
+
export { default as PageHeader } from './PageHeader';
export * from './PageHeader';