Repository: civiform/civiform
Pull request #10535: [External Program Cards] Show external program modal when triggered from HTMX card
Author: login: EmiliaPaz
Labels: name: under-development
description: Features that are hidden behind a feature flag and not yet ready for staging or production testing
Created: 2025-05-12T18:10:51Z | Merged: 2025-05-19T17:53:40Z

### Description

External programs are displayed in the applicant home page as program cards. Currently
- A program card is added directly on [ProgramIndexTemplate](https://sourcegraph.com/github.com/civiform/civiform/-/blob/server/app/views/applicant/ProgramIndexTemplate.html?L75-79) 
- [Clicking](https://sourcegraph.com/github.com/civiform/civiform/-/blob/server/app/views/applicant/ProgramCardsFragment.html?L202-212) on an external program card shows the 'external program' modal.
- When a filter is selected, the [NorthStarFilteredProgramsViewPartial](https://sourcegraph.com/github.com/civiform/civiform/-/blob/server/app/views/applicant/NorthStarFilteredProgramsViewPartial.java?L125-127) renders the HTMX partial view containing the program cards and clicking the button doesn't show the modal

This PR fixes the issue of not showing the modal triggered from a HTMX partial view. 

The modal trigger is added after initialization via HTMX and doesn't have an event listener. Therefore, we add a 'htmx:afterSwap' listener that adds a click listener to the modal trigger buittons to:
- show the modal
- listen for close actions to hide the modal

This implementation is based on [packages/usa-modal/src/index.js](https://github.com/uswds/uswds/blob/3c37abfe597941775a897e4f6491684a22a8feff/packages/usa-modal/src/index.js#L370). We cannot copy `toggleModal()` because a) function doesn't have listener for the close actions and b) couldn't find a way to access the uswds package from civiform

##### Screencast
https://github.com/user-attachments/assets/22a02f09-1f3c-484c-a3bd-a271abcd4c92
1. Close button
2. Go back
3. Esc
4. Click outside modal (for some reason takes two clicks to close)
5. Continue

### Checklist

#### General

Read the full guidelines for PRs [here](https://github.com/civiform/civiform/wiki/Technical-contribution-guide#creating-a-pull-request)

- [x] Added the correct label: < feature | enhancement | bug | under-development | dependencies | infrastructure | ignore-for-release | database >
- [x] Assigned to a specific person, `civiform/developers`, or a [more specific round-robin list](https://github.com/civiform/civiform/wiki/Technical-contribution-guide#adding-reviewers)
- [ ] Added an additional reviewer from outside your organization as FYI (if the primary reviewer is in the same organization as you)
- [x] Removed the release notes section if the title is sufficient for the release notes description, or put more details in that section.
- [x] Created unit and/or browser tests which fail without the change (if possible)
- [x] Performed manual testing (Chrome and Firefox if it includes front-end changes)
- [ ] Extended the README / documentation, if necessary. For user-facing features, consider updating [the user docs](https://github.com/civiform/docs). For "under-the-hood" changes or things more relevant to developers, consider updating [the dev wiki](https://github.com/civiform/civiform/wiki).
- [ ] Ensured PII wasn't added to any new logs, unless it was guarded by `isDevOrStaging`
- [ ] Noted in the PR description which, if any, code in this PR was generated by AI.

#### User visible changes

- [ ] Followed steps to [internationalize](https://github.com/civiform/civiform/wiki/Internationalization-%28i18n%29#application-strings) any new strings
  - [ ] Added context strings to new [messages](https://github.com/civiform/civiform/blob/main/server/conf/i18n/messages)
  - [ ] Didn't use a message in applicant facing code that isn't translated yet (unless behind a flag)
- [ ] Wrote browser tests using the [validateAccessibility](https://sourcegraph.com/github.com/civiform/civiform/-/blob/browser-test/src/support/index.ts?L437:14&subtree=true) method
- [ ] Made equivalent changes in Thymeleaf for applicant-facing features and changes (or created an issue in the [Northstar epic](https://github.com/orgs/civiform/projects/1/views/94) to track that it's missing from Thymeleaf)
- [x] Tested on mobile view. See [mobile device mode](https://developer.chrome.com/docs/devtools/device-mode/)
- [x] Manually tested at 200% size
- [x] Manually evaluated tab order
- [ ] Manually tested with a screen reader if the feature is applicant-facing. See [screen reader testing](https://github.com/civiform/civiform/wiki/Testing#screen-reader-testing)

#### New Features

- [ ] Add new FeatureFlag env vars to `server/conf/helper/feature-flags.conf`
- [x] Conditioned new functionality on a [FeatureFlag](https://github.com/civiform/civiform/wiki/Feature-Flags)
- [x] Wrote browser tests with the feature flag off and on, etc.

### Instructions for manual testing

1. Log in as an admin
2. Enable EXTERNAL_PROGRAM_CARDS_ENABLED feature flag
3. Create and publish an external program with a filter
4. Logout
5. In the applicant home, verify the external program has a card on the 'Programs and Services' section
6. Apply the filter, verify the external program has a card on the 'Programs based on your selections ' section
7. Click on 'View on a new tab' which opens a modal
8. Click on 'Continue' in the modal. External program link is opened in a new tab

### Issue(s) this completes

Part of #10184


Diff:
diff --git a/browser-test/src/applicant/northstar_applicant_application_index.test.ts b/browser-test/src/applicant/northstar_applicant_application_index.test.ts
index 5cb0da30ff2..42e5b8c7aaa 100644
--- a/browser-test/src/applicant/northstar_applicant_application_index.test.ts
+++ b/browser-test/src/applicant/northstar_applicant_application_index.test.ts
@@ -1466,6 +1466,109 @@ test.describe(
       })
     })
 
+    test('External program cards are included in program filters', async ({
+      page,
+      adminPrograms,
+      applicantQuestions,
+      seeding,
+    }) => {
+      const externalProgramName = 'External Program'
+      const externalProgramLink = 'https://civiform.us'
+
+      await test.step('enable required features', async () => {
+        await enableFeatureFlag(page, 'program_filtering_enabled')
+        await enableFeatureFlag(page, 'external_program_cards_enabled')
+      })
+
+      await test.step('seed categories', async () => {
+        // The program filtering feature requires seeding the categories. This
+        // will add two programs: "Comprehensive Sample Program" and "Minimal
+        // Sample Program".
+        await seeding.seedProgramsAndCategories()
+        await page.goto('/')
+      })
+
+      await test.step("add an external program with 'Education' category", async () => {
+        await loginAsAdmin(page)
+        await adminPrograms.addProgram(
+          externalProgramName,
+          /* description= */ '',
+          /* shortDescription= */ 'description',
+          externalProgramLink,
+          ProgramVisibility.PUBLIC,
+          /* adminDescription= */ 'admin description',
+          ProgramType.EXTERNAL,
+        )
+
+        await adminPrograms.selectProgramCategories(
+          externalProgramName,
+          [ProgramCategories.EDUCATION],
+          /* isActive= */ false,
+        )
+
+        await adminPrograms.publishAllDrafts()
+        await logout(page)
+      })
+
+      await test.step("filter programs by 'Education' category", async () => {
+        await loginAsTestUser(page)
+        await applicantQuestions.filterProgramsByCategory('Education')
+      })
+
+      await test.step("external program card appeasrs in the 'Recommended' section", async () => {
+        await applicantQuestions.expectProgramsinCorrectSections(
+          {
+            expectedProgramsInMyApplicationsSection: [],
+            expectedProgramsInProgramsAndServicesSection: [],
+            expectedProgramsInRecommendedSection: [externalProgramName],
+            expectedProgramsInOtherProgramsSection: [
+              'Comprehensive Sample Program',
+              'Minimal Sample Program',
+            ],
+          },
+          /* filtersOn= */ true,
+          /* northStarEnabled= */ true,
+        )
+      })
+
+      await test.step('clicking on external program card opens a modal', async () => {
+        await applicantQuestions.clickApplyProgramButton(externalProgramName)
+
+        const modal = page.getByRole('dialog', {state: 'visible'})
+        const continueButton = modal.getByRole('link', {name: 'Continue'})
+        await expect(continueButton).toBeVisible()
+      })
+
+      await test.step("selecting 'go back' closes the modal", async () => {
+        const modal = page.getByRole('dialog', {state: 'visible'})
+        const goBackButton = modal.getByRole('button', {name: 'Go back'})
+        await expect(goBackButton).toBeVisible()
+        await goBackButton.click()
+        await expect(modal).toBeHidden()
+      })
+
+      await test.step('trigger the external program modal again', async () => {
+        await applicantQuestions.clickApplyProgramButton(externalProgramName)
+
+        const modal = page.getByRole('dialog', {state: 'visible'})
+        const continueButton = modal.getByRole('link', {name: 'Continue'})
+        await expect(continueButton).toBeVisible()
+      })
+
+      await test.step("selecting 'continue' redirects to the program's external site", async () => {
+        const modal = page.getByRole('dialog', {state: 'visible'})
+        const continueButton = modal.getByRole('link', {name: 'Continue'})
+
+        const pagePromise = page.context().waitForEvent('page')
+        await continueButton.click()
+        const newPage = await pagePromise
+        await newPage.waitForLoadState()
+        expect(newPage.url()).toMatch(externalProgramLink)
+
+        await newPage.close()
+      })
+    })
+
     async function submitApplicationAndApplyStatus(
       page: Page,
       programName: string,
diff --git a/server/app/assets/javascripts/north_star_modal.ts b/server/app/assets/javascripts/north_star_modal.ts
index 497ffef2c7c..0a148372c5e 100644
--- a/server/app/assets/javascripts/north_star_modal.ts
+++ b/server/app/assets/javascripts/north_star_modal.ts
@@ -1,3 +1,32 @@
+/**
+ * Toggles the visibility of the modal
+ * @param {Element} modalWrapper The wrapper holding the modal
+ * @param {boolean} showModal Whether the modal should be visible
+ **/
+function toggleModalVisibility(modalWrapper: Element, showModal: boolean) {
+  modalWrapper.classList.toggle('is-visible', showModal)
+  modalWrapper.classList.toggle('is-hidden', !showModal)
+}
+
+/**
+ * Shows the modal
+ * @param {Element} modalWrapper The wrapper holding the modal
+ **/
+function showModal(modalWrapper: Element) {
+  toggleModalVisibility(modalWrapper, true)
+}
+
+/**
+ * Hides the modal
+ * @param {Element} modalWrapper The wrapper holding the modal
+ * @param {HTMLElement} modalTrigger The button that triggered the modal
+ **/
+function hideModal(modalWrapper: Element, triggerButton: HTMLElement) {
+  toggleModalVisibility(modalWrapper, false)
+  // Return focus to the button that opened the modal
+  triggerButton.focus()
+}
+
 export class NorthStarModalController {
   /**
    * Find the modals, and add on-click listeners on their respective buttons to toggle them.
@@ -55,6 +84,73 @@ export class NorthStarModalController {
     })
   }
 
+  /**
+   * Attaches an event listener to a button element rendered by HTMX after
+   * initialization that triggers a USWDS modal dialog
+   *
+   * This method configures the button to show the modal when clicked, and to
+   * hide the modal when a close action is triggered.
+   *
+   * @param {HTMLElement} triggerButton  The button element that will trigger
+   *                                     the modal. Must have an 'aria-controls'
+   *                                     attribute containing the ID of the
+   *                                     target modal.
+   */
+  static attachHTMXModalListener(triggerButton: HTMLElement) {
+    triggerButton.addEventListener('click', (e) => {
+      // Default anchor navigation would focus on the href target. However, the
+      // modal we want to display is a wrapper element added by USWDS and not
+      // the one directly referenced by the href. Therefore, we need to remove
+      // the default behavior and manually focus on the modal element (in a
+      // later step).
+      e.preventDefault()
+
+      // Get the target modal ID
+      const modalId = triggerButton.getAttribute('aria-controls')
+      if (!modalId) {
+        return
+      }
+
+      // Find the modal wrapper added to the DOM by the USWDS package
+      const modalWrapper = document.querySelector(
+        `.usa-modal-wrapper#${modalId}`,
+      )
+      if (!modalWrapper) {
+        return
+      }
+
+      showModal(modalWrapper)
+
+      // Focus on an element in the modal wrapper when it opens
+      const openFocusEl: HTMLElement | null = modalWrapper.querySelector(
+        `usa-modal *[data-focus]`,
+      )
+        ? modalWrapper.querySelector(`.usa-modal *[data-focus]`)
+        : modalWrapper.querySelector(`.usa-modal`)
+      if (openFocusEl) {
+        openFocusEl.focus()
+      }
+
+      // Add listener to hide modal when a close attribute is clicked
+      const closeButtons = modalWrapper.querySelectorAll('[data-close-modal]')
+      closeButtons.forEach((closeButton) => {
+        closeButton.addEventListener('click', () => {
+          hideModal(modalWrapper, triggerButton)
+        })
+      })
+
+      // Add listener to hide modal when the escape key is pressed
+      const escapeKeyHandler = (event: KeyboardEvent) => {
+        if (event.key === 'Escape') {
+          hideModal(modalWrapper, triggerButton)
+          // Remove listener after being triggered
+          document.removeEventListener('keydown', escapeKeyHandler)
+        }
+      }
+      document.addEventListener('keydown', escapeKeyHandler)
+    })
+  }
+
   constructor() {
     const modals = document.querySelectorAll('.cf-ns-modal')
 
@@ -73,6 +169,26 @@ export class NorthStarModalController {
       }
     })
 
+    // USWDS modal trigger buttons added dynamically by HTMX after initial page
+    // load don't automatically receive the event listeners that USWDS normally
+    // attaches during its initialization. This is because USWDS only processes
+    // elements that exist when it initializes.
+    //
+    // This event listener detects when HTMX adds new content to the page and
+    // finds any external program modal triggers that were added. We then
+    // manually attach our custom modal event listeners to these buttons to
+    // ensure they can properly open their target modals.
+    document.body.addEventListener('htmx:afterSwap', () => {
+      const modalTrigger = document.querySelectorAll(
+        'a[data-open-modal][href*="external-program-modal"]',
+      )
+
+      modalTrigger.forEach((trigger) => {
+        const triggerElement = trigger as HTMLElement
+        NorthStarModalController.attachHTMXModalListener(triggerElement)
+      })
+    })
+
     // Advertise (e.g., for browser tests) that modal.ts initialization is done
     document.body.dataset.loadModal = 'true'
   }
diff --git a/server/app/views/applicant/ProgramCardsFragment.html b/server/app/views/applicant/ProgramCardsFragment.html
index 60730cb1770..a8c2f743f2a 100644
--- a/server/app/views/applicant/ProgramCardsFragment.html
+++ b/server/app/views/applicant/ProgramCardsFragment.html
@@ -203,6 +203,7 @@
       class="usa-button usa-button--outline cf-apply-button"
       th:href="${'#external-program-modal-' + card.programId()}"
       th:aria-controls="${'external-program-modal-' + card.programId()}"
+      role="button"
       data-open-modal
     >
       <svg


Reviews:
user: login: EmiliaPaz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T18:23:00Z
user: login: melanie-exygy
body: I think this is a good solution to the tough problem of USWDS modal listeners not being set after HTMX loading.  The code is clear and will be easy to maintain if ever we need to make changes.
If possible, it would be great to figure out why it takes two clicks outside the modal to close it, but I don't think it's crucial and that could be handled in a separate GH issue.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T22:04:20Z
user: login: EmiliaPaz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:47:50Z
user: login: melanie-exygy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T00:28:39Z
user: login: EmiliaPaz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T17:12:53Z
user: login: melanie-exygy
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T17:24:22Z

Review comments:
diff_hunk: @@ -202,6 +202,8 @@
     <a
       class="usa-button usa-button--outline cf-apply-button"
       th:href="${'#external-program-modal-' + card.programId()}"
+      hx-target="${'#external-program-modal-' + card.programId()}"
path: server/app/views/applicant/ProgramCardsFragment.html
user: login: EmiliaPaz
body: Trial #3: using hx controls. I don't think I have these configured correctly..
created_at: 2025-05-12T18:23:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +84,62 @@ export class NorthStarModalController {
     })
   }
 
+  /**
+   * Add a click listener to the modal trigger button to manually show the modal, and listener to hide the modal when close actions are triggered.
+   * @param {HTMLElement} triggerButton The button that triggers the modal
+   **/
+  static attachHTMXModalListener(triggerButton: HTMLElement) {
+    // Add click listener to manually toggle the modal
+    triggerButton.addEventListener('click', (e) => {
+      e.preventDefault()
path: server/app/assets/javascripts/north_star_modal.ts
user: login: melanie-exygy
body: **question (non-blocking):** Would you mind adding a comment explaining why we need `preventDefault()?`. In testing, I noticed that without it focus won't go to the modal.
created_at: 2025-05-14T21:37:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +84,62 @@ export class NorthStarModalController {
     })
   }
 
+  /**
+   * Add a click listener to the modal trigger button to manually show the modal, and listener to hide the modal when close actions are triggered.
+   * @param {HTMLElement} triggerButton The button that triggers the modal
+   **/
+  static attachHTMXModalListener(triggerButton: HTMLElement) {
+    // Add click listener to manually toggle the modal
+    triggerButton.addEventListener('click', (e) => {
+      e.preventDefault()
+
+      // Get the target modal ID
+      const modalId = triggerButton.getAttribute('aria-controls')
+      if (!modalId) {
+        return
+      }
+
+      // Find the modal wrapper added to the DOM by the USWDS package
+      const modalWrapper = document.querySelector(
+        `.usa-modal-wrapper#${modalId}`,
+      )
+      if (!modalWrapper) {
+        return
+      }
+
+      // Show the modal
+      showModal(modalWrapper)
+
+      // Focus on element when the modal opens
+      const openFocusEl: HTMLElement | null = modalWrapper.querySelector(
+        `usa-modal *[data-focus]`,
+      )
+        ? modalWrapper.querySelector(`.usa-modal *[data-focus]`)
+        : modalWrapper.querySelector(`.usa-modal`)
+      if (openFocusEl) {
+        openFocusEl.focus()
+      }
+
+      // Add listener to hide modal when a close attribute is clicked
+      const closeButtons = modalWrapper.querySelectorAll('[data-close-modal]')
+      closeButtons.forEach((closeButton) => {
+        closeButton.addEventListener('click', () => {
+          hideModal(modalWrapper, triggerButton)
+        })
+      })
+
+      // Add listener to hide modal when the escape key is pressed
+      const escapeKeyHandler = (event: KeyboardEvent) => {
+        if (event.key === 'Escape') {
+          hideModal(modalWrapper, triggerButton)
+          // Remove listener after being triggered
+          document.removeEventListener('keydown', escapeKeyHandler)
path: server/app/assets/javascripts/north_star_modal.ts
user: login: melanie-exygy
body: **praise:** Good idea to remove the event listener!
created_at: 2025-05-14T21:53:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,6 +158,19 @@ export class NorthStarModalController {
       }
     })
 
+    // Add listener for modal triggers added after initialization
+    document.body.addEventListener('htmx:afterSwap', () => {
+      // Only external program modal triggers are added after initialization
+      const modalTrigger = document.querySelectorAll(
+        'a[data-open-modal][href*="external-program-modal"]',
+      )
+
path: server/app/assets/javascripts/north_star_modal.ts
user: login: melanie-exygy
body: **thought (non-blocking):** We could give the anchor tag an aria role of button here, like USWDS does.  That way, we would be consistent with the modal trigger when it isn't in an HTMX partial.  The only reason that I'm not sure of this suggestion is that the other "View and apply" buttons all have an aria role of link and, also, even though this leads to a modal, it eventually leads outside the application, which is definitely link behavior.  What do you think?
```
            // If modalTrigger is an anchor...
            if (modalTrigger.nodeName === "A") {
              // Turn anchor links into buttons for screen readers
              modalTrigger.setAttribute("role", "button");
created_at: 2025-05-14T22:00:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +84,62 @@ export class NorthStarModalController {
     })
   }
 
+  /**
+   * Add a click listener to the modal trigger button to manually show the modal, and listener to hide the modal when close actions are triggered.
+   * @param {HTMLElement} triggerButton The button that triggers the modal
+   **/
+  static attachHTMXModalListener(triggerButton: HTMLElement) {
+    // Add click listener to manually toggle the modal
+    triggerButton.addEventListener('click', (e) => {
+      e.preventDefault()
path: server/app/assets/javascripts/north_star_modal.ts
user: login: EmiliaPaz
body: Great call! Writing the comment helped me understand better how focus was being fixed :)
created_at: 2025-05-15T00:35:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,6 +158,19 @@ export class NorthStarModalController {
       }
     })
 
+    // Add listener for modal triggers added after initialization
+    document.body.addEventListener('htmx:afterSwap', () => {
+      // Only external program modal triggers are added after initialization
+      const modalTrigger = document.querySelectorAll(
+        'a[data-open-modal][href*="external-program-modal"]',
+      )
+
path: server/app/assets/javascripts/north_star_modal.ts
user: login: EmiliaPaz
body: I don't fully follow this comment. The current button for the default card is:
```
<a href="/programs/program-a" class="usa-button usa-button--outline cf-apply-button" aria-label="View and apply to Program A">View and apply</a>
```

The button for the external program card is:
```
<a class="usa-button usa-button--outline cf-apply-button" href="#external-program-modal-6" aria-controls="external-program-modal-6" data-open-modal="" role="button">
...
</a>
```

Where to the 'view and apply' buttons have an aria role of link?

Button on external program card already has `role="button"`. We didn't add that on the [code](https://sourcegraph.com/github.com/civiform/civiform/-/blob/server/app/views/applicant/ProgramCardsFragment.html?L202-207), so my guess is that USWDS added it?
created_at: 2025-05-15T00:55:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,6 +158,19 @@ export class NorthStarModalController {
       }
     })
 
+    // Add listener for modal triggers added after initialization
+    document.body.addEventListener('htmx:afterSwap', () => {
+      // Only external program modal triggers are added after initialization
+      const modalTrigger = document.querySelectorAll(
+        'a[data-open-modal][href*="external-program-modal"]',
+      )
+
path: server/app/assets/javascripts/north_star_modal.ts
user: login: melanie-exygy
body: Sorry if the comment was confusing.  Let me try to explain better.  

The default card is an anchor element.  Anchor elements have a default aria role of "link". 
 
On the external program card, "View in new window" is an anchor but USWDS adds `role="button"` (https://github.com/uswds/uswds/blob/3c37abfe597941775a897e4f6491684a22a8feff/packages/usa-modal/src/index.js#L358).  In terms of aria, this makes the anchor behave like a button.  Buttons and links behave differently for screen reader users.  Screen reader users will often get a list of all the links on the page so that they can get a mental outline of the page and how it connects to other pages.  By adding `role="button"`, "View in a new window" will no longer appear in the screen reader list of links.  

Also, screen reader users make assumptions about an element based on what role it has.  The purpose of a link would be to take the user to a different location, whereas the purpose of a button is for the user to take action, usually on the same page.

Anyway, all this to say, if we want to be consistent with the "View in a new window" in the external cards before filtering is applied, we can add the `role="button"` like USWDS does.
created_at: 2025-05-16T00:20:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1432,6 +1432,93 @@ test.describe(
       })
     })
 
+    test('External program cards are included in program filters', async ({
+      page,
+      adminPrograms,
+      applicantQuestions,
+      seeding,
+    }) => {
+      const externalProgramName = 'External Program'
+      const externalProgramLink = 'https://civiform.us'
+
+      await test.step('enable required features', async () => {
+        await enableFeatureFlag(page, 'program_filtering_enabled')
+        await enableFeatureFlag(page, 'external_program_cards_enabled')
+      })
+
+      await test.step('seed categories', async () => {
+        // The program filtering feature requires seeding the categories. This
+        // will add two programs: "Comprehensive Sample Program" and "Minimal
+        // Sample Program".
+        await seeding.seedProgramsAndCategories()
+        await page.goto('/')
+      })
+
+      await test.step("add an external program with 'Education' category", async () => {
+        await loginAsAdmin(page)
+        await adminPrograms.addProgram(
+          externalProgramName,
+          /* description= */ '',
+          /* shortDescription= */ 'description',
+          externalProgramLink,
+          ProgramVisibility.PUBLIC,
+          /* adminDescription= */ 'admin description',
+          ProgramType.EXTERNAL,
+        )
+
+        await adminPrograms.selectProgramCategories(
+          externalProgramName,
+          [ProgramCategories.EDUCATION],
+          /* isActive= */ false,
+        )
+
+        await adminPrograms.publishAllDrafts()
+        await logout(page)
+      })
+
+      await test.step("filter programs by 'Education' category", async () => {
+        await loginAsTestUser(page)
+        await applicantQuestions.filterProgramsByCategory('Education')
+      })
+
+      await test.step("external program card in the 'Recommended' section", async () => {
+        await applicantQuestions.expectProgramsinCorrectSections(
+          {
+            expectedProgramsInMyApplicationsSection: [],
+            expectedProgramsInProgramsAndServicesSection: [],
+            expectedProgramsInRecommendedSection: [externalProgramName],
+            expectedProgramsInOtherProgramsSection: [
+              'Comprehensive Sample Program',
+              'Minimal Sample Program',
+            ],
+          },
+          /* filtersOn= */ true,
+          /* northStarEnabled= */ true,
+        )
+      })
+
+      await test.step('clicking on external program card opens a modal', async () => {
+        await applicantQuestions.clickApplyProgramButton(externalProgramName)
+
+        const modal = page.getByRole('dialog', {state: 'visible'})
+        const continueButton = modal.getByRole('link', {name: 'Continue'})
+        await expect(continueButton).toBeVisible()
+      })
+
path: browser-test/src/applicant/northstar_applicant_application_index.test.ts
user: login: melanie-exygy
body: **suggestion (non-blocking):** It might be good to test at least one of the modal close buttons since we had to add that listener too.
created_at: 2025-05-16T00:28:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1432,6 +1432,93 @@ test.describe(
       })
     })
 
+    test('External program cards are included in program filters', async ({
+      page,
+      adminPrograms,
+      applicantQuestions,
+      seeding,
+    }) => {
+      const externalProgramName = 'External Program'
+      const externalProgramLink = 'https://civiform.us'
+
+      await test.step('enable required features', async () => {
+        await enableFeatureFlag(page, 'program_filtering_enabled')
+        await enableFeatureFlag(page, 'external_program_cards_enabled')
+      })
+
+      await test.step('seed categories', async () => {
+        // The program filtering feature requires seeding the categories. This
+        // will add two programs: "Comprehensive Sample Program" and "Minimal
+        // Sample Program".
+        await seeding.seedProgramsAndCategories()
+        await page.goto('/')
+      })
+
+      await test.step("add an external program with 'Education' category", async () => {
+        await loginAsAdmin(page)
+        await adminPrograms.addProgram(
+          externalProgramName,
+          /* description= */ '',
+          /* shortDescription= */ 'description',
+          externalProgramLink,
+          ProgramVisibility.PUBLIC,
+          /* adminDescription= */ 'admin description',
+          ProgramType.EXTERNAL,
+        )
+
+        await adminPrograms.selectProgramCategories(
+          externalProgramName,
+          [ProgramCategories.EDUCATION],
+          /* isActive= */ false,
+        )
+
+        await adminPrograms.publishAllDrafts()
+        await logout(page)
+      })
+
+      await test.step("filter programs by 'Education' category", async () => {
+        await loginAsTestUser(page)
+        await applicantQuestions.filterProgramsByCategory('Education')
+      })
+
+      await test.step("external program card in the 'Recommended' section", async () => {
+        await applicantQuestions.expectProgramsinCorrectSections(
+          {
+            expectedProgramsInMyApplicationsSection: [],
+            expectedProgramsInProgramsAndServicesSection: [],
+            expectedProgramsInRecommendedSection: [externalProgramName],
+            expectedProgramsInOtherProgramsSection: [
+              'Comprehensive Sample Program',
+              'Minimal Sample Program',
+            ],
+          },
+          /* filtersOn= */ true,
+          /* northStarEnabled= */ true,
+        )
+      })
+
+      await test.step('clicking on external program card opens a modal', async () => {
+        await applicantQuestions.clickApplyProgramButton(externalProgramName)
+
+        const modal = page.getByRole('dialog', {state: 'visible'})
+        const continueButton = modal.getByRole('link', {name: 'Continue'})
+        await expect(continueButton).toBeVisible()
+      })
+
path: browser-test/src/applicant/northstar_applicant_application_index.test.ts
user: login: EmiliaPaz
body: Good call, done
created_at: 2025-05-16T21:50:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,6 +158,19 @@ export class NorthStarModalController {
       }
     })
 
+    // Add listener for modal triggers added after initialization
+    document.body.addEventListener('htmx:afterSwap', () => {
+      // Only external program modal triggers are added after initialization
+      const modalTrigger = document.querySelectorAll(
+        'a[data-open-modal][href*="external-program-modal"]',
+      )
+
path: server/app/assets/javascripts/north_star_modal.ts
user: login: EmiliaPaz
body: Thanks for the explanation. Makes sense having `role=button` for the button in the external program card for both scenarios (no filter, filters). Added
created_at: 2025-05-19T17:03:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: EmiliaPaz
created_at: 2025-05-12T18:11:05Z
body: * **#10535** <a href="https://app.graphite.dev/github/pr/civiform/civiform/10535?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/civiform/civiform/10535?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-12T18:33:24Z
body: ## [Codecov](https://app.codecov.io/gh/civiform/civiform/pull/10535?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=civiform) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 83.22%. Comparing base [(`b856911`)](https://app.codecov.io/gh/civiform/civiform/commit/b856911d2b01b975aa256040b8509b385c3e5076?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=civiform) to head [(`9aa046b`)](https://app.codecov.io/gh/civiform/civiform/commit/9aa046b9c84e7b90737d3e405bb8f9d5e6ea1b12?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=civiform).
> Report is 5 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##               main   #10535   +/-   ##
=========================================
  Coverage     83.22%   83.22%           
  Complexity     4503     4503           
=========================================
  Files           521      521           
  Lines         19753    19753           
  Branches       1295     1295           
=========================================
  Hits          16440    16440           
  Misses         2811     2811           
  Partials        502      502           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/civiform/civiform/pull/10535?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=civiform).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=civiform).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE