diff --git a/package.json b/package.json index 87e19960fb..887f79738a 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "stop": "node server/server.stop.js", "prepare": "husky install", "commit": "cz", - "check-element-changes": "node ./scripts/check-element-changes.js -d" + "check-element-changes": "node ./packages/unity-react-core/scripts/check-element-changes.js" }, "jest": { "preset": "jest-puppeteer", @@ -74,6 +74,7 @@ "copy-webpack-plugin": "^8.1.1", "css-minimizer-webpack-plugin": "^2.0.0", "cz-conventional-changelog": "^3.3.0", + "diff": "^8.0.2", "dompurify": "^3.2.4", "eslint": "^9", "eslint-config-prettier": "^8.2.0", @@ -102,6 +103,7 @@ "semantic-release": "^22", "semantic-release-monorepo": "^8.0.2", "semver": "^7.5.2", + "tsx": "^4.19.3", "vite": "^5.3.5", "webpack": "^5.32.0", "webpack-bundle-analyzer": "^4.4.1", diff --git a/packages/app-degree-pages/__mocks__/unity-react-core-mock.jsx b/packages/app-degree-pages/__mocks__/unity-react-core-mock.jsx index 21e91e79e0..f2625abc75 100644 --- a/packages/app-degree-pages/__mocks__/unity-react-core-mock.jsx +++ b/packages/app-degree-pages/__mocks__/unity-react-core-mock.jsx @@ -1,20 +1,8 @@ /* eslint-disable react/jsx-props-no-spreading */ // @ts-check -import * as asuCore from "@asu/unity-react-core"; import React from "react"; import { vi } from "vitest" -const { - Accordion: _C1, - AnchorMenu: _C2, - Button: _C3, - Card: _C4, - Hero: _C5, - Pagination: _C6, - Video: _C7, - ...rest -} = asuCore; - const mockComponent = vi.fn(props => <>{props?.children}>); const Accordion = mockComponent; @@ -28,7 +16,11 @@ const Card = mockComponent; const Hero = mockComponent; const Pagination = mockComponent; const Video = mockComponent; +const Divider = mockComponent; +const Image = mockComponent; +const List = mockComponent; +// Mock all the components we might need vi.doMock("@asu/unity-react-core", () => ({ Accordion, AnchorMenu, @@ -37,5 +29,7 @@ vi.doMock("@asu/unity-react-core", () => ({ Hero, Pagination, Video, - ...rest, + Divider, + Image, + List, })); diff --git a/packages/app-degree-pages/vitest.setup.ts b/packages/app-degree-pages/vitest.setup.ts index 2da0972886..b4b8c7f92d 100644 --- a/packages/app-degree-pages/vitest.setup.ts +++ b/packages/app-degree-pages/vitest.setup.ts @@ -4,6 +4,8 @@ import { afterEach } from 'vitest' import "./__mocks__/fetch-mock"; import "./__mocks__/window-mock"; +import "./__mocks__/unity-react-core-mock"; + afterEach(() => { cleanup() }); diff --git a/packages/component-header-footer/setupTests.js b/packages/component-header-footer/setupTests.js index c64a1227a3..73ff7f8471 100644 --- a/packages/component-header-footer/setupTests.js +++ b/packages/component-header-footer/setupTests.js @@ -6,4 +6,9 @@ import "@testing-library/jest-dom"; import "./__mocks__/window-mock"; +if (typeof global.TextEncoder === 'undefined') { + const util = require('util'); + global.TextEncoder = util.TextEncoder; +} + expect.extend({ toMatchImageSnapshot }); diff --git a/packages/shared/utils/html-utils.d.ts b/packages/shared/utils/html-utils.d.ts index 50b312108d..df4034a350 100644 --- a/packages/shared/utils/html-utils.d.ts +++ b/packages/shared/utils/html-utils.d.ts @@ -9,4 +9,4 @@ export type FocusableElement = { * @param {string} targetSelector * @returns {FocusableElement} */ -export function queryFirstFocusable(targetSelector: string): FocusableElement; +export function queryFirstFocusable(targetSelector: string): FocusableElement | null; diff --git a/packages/shared/utils/html-utils.js b/packages/shared/utils/html-utils.js index 62de3cf062..9a63c4d1b8 100644 --- a/packages/shared/utils/html-utils.js +++ b/packages/shared/utils/html-utils.js @@ -1,19 +1,37 @@ // @ts-check import DOMPurify from "dompurify"; +// Lazy initialization of DOMPurify to support server-side rendering +let DOMPurifyInstanceServerCompatible = null; + +function getDOMPurifyInstance() { + if (!DOMPurifyInstanceServerCompatible) { + // Initialize DOMPurify with the current window (browser or JSDOM) + if (typeof window !== "undefined") { + DOMPurifyInstanceServerCompatible = DOMPurify(window); + } else { + // Fallback for environments without window + DOMPurifyInstanceServerCompatible = DOMPurify; + } + } + return DOMPurifyInstanceServerCompatible; +} + /** * @typedef {{ * focus: () => void * } & Element } FocusableElement * @param {string} targetSelector - * @returns {FocusableElement} + * @returns {FocusableElement | null} */ function queryFirstFocusable(targetSelector) { const target = targetSelector ? document.querySelector(targetSelector) : document; - /** @type {FocusableElement} */ + if (!target) return null; + + /** @type {FocusableElement | null} */ const focusable = target.querySelector( 'button, [href], input, select, textarea, [tabIndex]:not([tabIndex="-1"])' ); @@ -25,7 +43,8 @@ function queryFirstFocusable(targetSelector) { * @returns {Object} */ export const sanitizeDangerousMarkup = content => { - return { __html: DOMPurify.sanitize(content) }; + const purify = getDOMPurifyInstance(); + return { __html: purify.sanitize(content) }; }; export { queryFirstFocusable }; diff --git a/packages/static-site/src/pages/Home.tsx b/packages/static-site/src/pages/Home.tsx index 39a42efcda..ea8f3f2105 100644 --- a/packages/static-site/src/pages/Home.tsx +++ b/packages/static-site/src/pages/Home.tsx @@ -27,7 +27,7 @@ const Home = () => {
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
", + }, + }, + { + content: { + header: "Accordion Card 2", + body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
", + }, + }, + { + content: { + header: "Accordion Card 3, opened card", + body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
", + }, + }, + ], + openedCard: 3, + }, + }, + AnchorMenu: { + reactComponent: AnchorMenu, + props: { + items: [ + { + text: "Title Case is Required", + targetIdName: "first-container", + icon: ["fas", "link"], + }, + { text: "Second Container", targetIdName: "second-container" }, + { text: "Third Container", targetIdName: "third-container" }, + { + text: "This is the Last", + targetIdName: "fourth-container", + icon: ["fas", "link"], + }, + ], + }, + }, + Article: { + reactComponent: Article, + props: { + defaultArgs: { + type: "news", + articleUrl: "https://example.com", + headerImageUrl: "", + title: + "Clarisse Machanguana takes her skill set to the next level at ASU Thunderbird", + publicationDate: "March 18, 2021", + authorName: "Jimena Garrison", + authorTitle: "Media Relations and Strategic Communications", + authorEmail: "jgarris6@asu.edu", + authorPhone: "480-727-4058", + breadcrumbs: [ + { + title: "Home", + url: "/", + }, + { + title: "Second nav item", + url: "/events", + }, + { + title: "Current page", + url: "/events/current-article", + active: true, + }, + ], + body: "After 34 years in the game of basketball, Clarisse Machanguana retired. Her eponymous philanthropic foundation remains her only connection to the sport, although the effect of the global game has left imprints in many aspects of her life.
Playing basketball took her to Portugal and then the U.S., where she attended Old Dominion University in Virginia to study criminal justice. When she realized that sports could be a microcosm of life and values, she decided to create a way to coach sports while teaching life skills in her home country of Mozambique. She started the Clarisse Machanguana Foundation in 2014 with the goal of empowering Mozambican youth through health, education and sports programs.
Now she’s taking her leadership game to the next level at ASU’s Thunderbird School of Global Management, earning her Master of Global Management degree with a nonprofit management concentration. Machanguana is honing her skills as a global professional in and out of the classroom to propel her foundation even further.
Here she reflects on the experiences that brought her to Thunderbird and ASU.
Question: Why basketball?
Answer: I started at age 6, and because it was popular in my area and I was tall — now 6-feet-5-inches tall, to be exact — people kept telling me I should play. Basketball took me everywhere. I had a scholarship to play in Portugal and the U.S., and later on in Spain, France, Brazil, South Korea and Italy. Basketball became a passport and a school for me, and a source of amazing friendships. I played from age 6 to 40. My foundation now partners with the Department of Education. We collaborate with teachers and teach them to coach life skills and basketball.
I advocate social causes that are challenging for youth and transform them into opportunities, giving them tools to lift themselves out of the poverty they see. They use the skills like respecting your opponent and perseverance that can be applied in life as well as sports. When you wake up and all you see is poverty, you start to believe that mindset of limitations and scarcity. We give young people something else to believe in, a vision of a better life.
", + }, + }, + }, + Breadcrumbs: { + reactComponent: Breadcrumbs, + props: { + linkItems: [ + { + label: "Home", + active: false, + href: "#", + }, + { + label: "Library", + active: false, + href: "#", + }, + { + label: "Data", + active: true, + href: "#", + }, + ], + }, + }, + Card: { + reactComponent: Card, + props: { + type: "default", + horizontal: false, + clickable: false, + image: "https://picsum.photos/300/200", + imageAltText: "An example image", + title: "Default title", + body: "(Bold!) Body copy goes here. Limit to 5 lines max. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua eiusmod tempo.", + buttons: [ + { + color: "maroon", + size: "default", + label: "CTA button", + }, + { + color: "gold", + size: "small", + label: "Link Button", + href: "/", + target: "_top", + }, + ], + tags: [ + { color: "gray", label: "tag1", href: "/#example-link" }, + { color: "gray", label: "tag2", href: "/#example-link" }, + { color: "gray", label: "tag3", href: "/#example-link" }, + ], + linkUrl: "/#", + linkLabel: "Link", + }, + }, + Pagination: { + reactComponent: Pagination, + props: { + type: "default", + background: "white", + totalPages: 4, + }, + }, + Button: { + reactComponent: Button, + props: { + label: "Default Button", + onClick: () => {}, + }, + }, + ButtonIconOnly: { + reactComponent: ButtonIconOnly, + props: { + color: "white", + icon: ["fas", "times"], + onClick: () => {}, + }, + }, + ButtonTag: { + reactComponent: ButtonTag, + props: { + label: "Tag Button", + color: "gray", + onClick: () => {}, + }, + }, + Checkboxes: { + reactComponent: Checkboxes, + props: {}, + }, + Divider: { + reactComponent: Divider, + props: {}, + }, + Form: { + reactComponent: Form, + props: { + children: "Form content", + background: "uds-form-gray1", + }, + }, + GridLinks: { + reactComponent: GridLinks, + props: { + numColumns: 2, + textColor: "none", + gridLinkItems: [ + { + label: "First-year student", + icon: "fa-university", + href: "https://example.com", + }, + { + label: "Online student", + icon: "fa-desktop", + href: "https://example.com", + }, + { + label: "Transfer student", + icon: "fa-lightbulb", + href: "https://example.com", + }, + { + label: "Veteran student", + icon: "fa-user-graduate", + href: "https://example.com", + }, + ], + }, + }, + Hero: { + reactComponent: Hero, + props: { + image: { + url: "https://picsum.photos/800/400", + altText: "Hero image", + size: "medium", + }, + title: { + text: "Hero Heading", + highlightColor: "gold", + }, + contentsColor: "white", + contents: [ + { + text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + }, + ], + }, + }, + Image: { + reactComponent: Image, + props: { + src: "https://picsum.photos/300/200", + alt: "Placeholder image", + border: true, + }, + }, + ImageBasedCard: { + reactComponent: ImageBasedCard, + props: { + image: "https://picsum.photos/300/200", + title: "Example Card Title", + buttonText: "Learn More", + buttonHref: "https://example.com", + orientation: "portrait", + size: "md", + }, + }, + List: { + reactComponent: List, + props: { + listType: "unordered", + items: [ + { + content: "First list item", + }, + { + content: "Second list item", + }, + { + content: "Third list item", + }, + ], + }, + }, + Modal: { + reactComponent: Modal, + props: { + open: false, + }, + }, + NotificationBanner: { + reactComponent: NotificationBanner, + props: { + title: "Stay up-to-date on what's new at ASU", + color: "orange", + children: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + buttons: [ + { + href: "https://provost.asu.edu/sync/students", + label: "Info. on teaching and learning remotely", + }, + ], + }, + }, + TextInput: { + reactComponent: TextInput, + props: { + id: "input-id", + label: "Input Label", + placeholder: "Input Placeholder", + required: false, + }, + }, + Textarea: { + reactComponent: Textarea, + props: { + id: "textarea-id", + label: "Textarea Label", + placeholder: "Textarea Placeholder", + required: false, + }, + }, + TabbedPanels: { + reactComponent: TabbedPanels, + props: { + children: [ + React.createElement(Tab, { id: "home", title: "Home", key: "home" } as any, "Home content"), + React.createElement(Tab, { id: "profile", title: "Profile", key: "profile" } as any, "Profile content"), + React.createElement(Tab, { id: "contact", title: "Contact", key: "contact" } as any, "Contact content"), + ], + }, + }, + Select: { + reactComponent: Select, + props: { + id: "select-id", + label: "Select Label", + options: [ + { value: "1", label: "Option 1" }, + { value: "2", label: "Option 2" }, + { value: "3", label: "Option 3" }, + ], + required: false, + }, + }, + Testimonial: { + reactComponent: Testimonial, + props: { + quote: { + content: "We hold these truths to be self-evident, that all men are created equal, that they are endowed by their Creator with certain unalienable Rights, that among these are Life, Liberty and the pursuit of Happiness.", + cite: { + name: "Thomas Jefferson", + description: "The Declaration of Independence", + }, + }, + }, + }, + RankingCard: { + reactComponent: RankingCard, + props: { + imageSize: "large", + image: "https://picsum.photos/300/200", + imageAlt: "Image alt text", + heading: "Ranking title goes here, under the photo", + body: "ASU has topped U.S. News & World Report's Most Innovative Schools list since the inception of the category in 2016. ASU again placed ahead of Stanford and MIT on the list, based on a survey of peers.", + readMoreLink: "https://www.asu.edu/", + }, + }, + Radios: { + reactComponent: Radios, + props: {}, + }, + Tooltip: { + reactComponent: Tooltip, + props: { + title: "Header", + content: "Content", + triggerElement: React.createElement("span", {}, "Tooltip trigger"), + }, + }, + Video: { + reactComponent: Video, + props: { + type: "youtube", + url: "https://www.youtube.com/embed/YW2p0ctzK9c", + caption: "Sample video", + }, + }, + SidebarMenu: { + reactComponent: SidebarMenu, + props: { + title: "Header", + links: [ + { + href: "https://example.com", + text: "Active Link", + isActive: true, + }, + { + text: "Link 2 dropdown", + items: [ + { + href: "https://example.com", + text: "Link 2.1", + }, + { + href: "https://example.com", + text: "Link 2.2", + }, + ], + }, + { + href: "https://example.com", + text: "Link 4", + }, + ], + }, + }, + SystemAlert: { + reactComponent: SystemAlert, + props: { + type: "warning", + children: "Warning: This is a warning alert to alert, confirm or notify.", + dismissable: true, + }, + }, + Table: { + reactComponent: Table, + props: { + columns: 5, + fixed: false, + }, + }, + Loader: { + reactComponent: Loader, + props: {}, + }, + }; + + function generateAllComponents(): Record