Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Carousel container portal navigation #13162

Merged
merged 8 commits into from
Jan 22, 2025
Merged

Conversation

abeddow91
Copy link
Contributor

@abeddow91 abeddow91 commented Jan 16, 2025

What does this change?

Refactors the carousel navigation buttons to use a React portal.

A portal allows rendering the button elements outside of the normal React component hierarchy, enabling flexibility in their placement within the DOM. This is particularly useful when the buttons need to be positioned outside the visual boundaries of the carousel component itself, such as on the fronts containers.

Why?

Depending on the breakpoint, the carousel front containers have their buttons placed outside of the component, in line with either the section heading or the left column. This had previously been achieved by using negative margins and absolute positioning to move the buttons outside of the container. This however, was quite brittle solution as it had to be calculate using the font size and line height of the container title and it also caused overlapping with the show/hide buttons as they occupied the same space.

By using portals, we can achieve a distinct position in the dom between the containers and there navigation buttons whilst still coupling the code in the carousel component.

The portal dynamically identifies a DOM element by constructing its ID using the sectionId prop and appends the suffix -carousel-navigation. This allows us to create distinct navigation portals per carousel.

If the target DOM element is not found, a warning is logged in the console. The buttons will not be rendered if the portal target is unavailable.

As well as refactoring the nav buttons, this pr also hooks up show/hide with the beta containers and add a hidden class to the nav buttons so that they can also be hidden when a containers is closed.

Why Portals?

As highlighted above, the design requirements necessitate that the navigation buttons for the carousel be placed outside the boundaries of the carousel component itself. This positioning ensures alignment with external elements such as section headings or adjacent columns, depending on the screen breakpoint. To implement this, we considered three potential approaches:

1. Negative margins
Initially, negative margins were used to manually adjust the position of the buttons relative to the carousel container. However, this solution was brittle and prone to issues:

  • It tightly coupled the layout of the carousel to the font size, line height, and other styling of external elements like the container title.
  • It introduced maintenance overhead, as layout changes in related components (e.g., title styling) required recalibrating the margins.
  • It caused overlapping issues with show/hide buttons, as both occupied the same space.

Overall, this approach was fragile and difficult to scale or adapt to new requirements.

2. Lifting State to a Shared Parent Component
Another option was to lift the state to a parent component shared by both the carousel and its navigation buttons. This approach offered some advantages, such as:

  • Allowing the buttons to be rendered server-side, reducing potential layout shifts.
  • Decoupling the carousel’s visual structure from its button placement.

However, this approach came with significant trade-offs:

  • It required hydrating the entire parent layout, significantly increasing the size of the client-side bundle.
  • It introduced unnecessary complexity by coupling unrelated components through a shared state.
  • This approach was less modular, as it required managing state across a broader scope than necessary.

3. Portals
React Portals emerged as the ideal solution because they allow rendering the navigation buttons in a separate part of the DOM while maintaining their state and logic within the carousel component. This approach offers several benefits:

  • Flexibility in DOM Placement: Portals enable precise control over where the buttons are rendered, such as aligning them with section headings or external elements without relying on fragile CSS hacks.
  • Scoped State Management: The buttons remain logically tied to the carousel component, ensuring the state and behavior are encapsulated and modular.
  • Dynamic Targeting: The portal dynamically identifies the target DOM element using the sectionId prop, allowing distinct navigation portals for each carousel instance. If the target element is missing, the system gracefully handles the error by logging a warning and skipping the rendering of the buttons.
  • Compatibility with Show/Hide Logic: Refactoring to use portals also facilitated integrating the navigation buttons with the "show/hide" functionality of beta containers. Buttons can now dynamically inherit a hidden class, ensuring they remain hidden when a container is closed.

Unfortunately, portals are inherently rendered on the client side and cannot be server-side rendered, which can result in some layout shifting. However, this issue has been largely mitigated by introducing a fixed minimum height placeholder to reserve space for the navigation buttons during initial rendering.

Screenshots

Screen.Recording.2025-01-16.at.09.40.41.mov

Screenshot 2025-01-21 at 10 17 04

Screenshot 2025-01-21 at 10 17 11

@abeddow91 abeddow91 requested a review from a team as a code owner January 16, 2025 09:19
Copy link

Hello 👋! When you're ready to run Chromatic, please apply the run_chromatic label to this PR.

You will need to reapply the label each time you want to run Chromatic.

Click here to see the Chromatic project.

Copy link

github-actions bot commented Jan 16, 2025

Size Change: -541 B (-0.06%)

Total Size: 884 kB

Filename Size Change
dotcom-rendering/dist/Accessibility-importable.client.web.********************.js 6.95 kB +247 B (+3.68%)
dotcom-rendering/dist/ScrollableFeature-importable.client.web.********************.js 6.85 kB -248 B (-3.49%)
dotcom-rendering/dist/ScrollableMedium-importable.client.web.********************.js 4.15 kB -250 B (-5.68%)
dotcom-rendering/dist/ScrollableSmall-importable.client.web.********************.js 4.13 kB -246 B (-5.62%)
ℹ️ View Unchanged
Filename Size Change
dotcom-rendering/dist/1076.client.web.********************.js 3.41 kB 0 B
dotcom-rendering/dist/1101.client.web.********************.js 4.81 kB 0 B
dotcom-rendering/dist/1262.client.web.********************.js 4.48 kB -3 B (-0.07%)
dotcom-rendering/dist/1401.client.web.********************.js 441 B 0 B
dotcom-rendering/dist/1427.client.web.********************.js 4.94 kB 0 B
dotcom-rendering/dist/1477.client.web.********************.js 3.52 kB +1 B (+0.03%)
dotcom-rendering/dist/1669.client.web.********************.js 12.3 kB +3 B (+0.02%)
dotcom-rendering/dist/1714.client.web.********************.js 2.87 kB -1 B (-0.03%)
dotcom-rendering/dist/2188.client.web.********************.js 6.52 kB 0 B
dotcom-rendering/dist/2444.client.web.********************.js 2.67 kB 0 B
dotcom-rendering/dist/2482.client.web.********************.js 44.8 kB 0 B
dotcom-rendering/dist/280.client.web.********************.js 531 B 0 B
dotcom-rendering/dist/3213.client.web.********************.js 5.42 kB 0 B
dotcom-rendering/dist/342.client.web.********************.js 4.18 kB 0 B
dotcom-rendering/dist/3524.client.web.********************.js 3.51 kB +1 B (+0.03%)
dotcom-rendering/dist/3769.client.web.********************.js 22.7 kB +1 B (0%)
dotcom-rendering/dist/3789.client.web.********************.js 3.58 kB +1 B (+0.03%)
dotcom-rendering/dist/39.client.web.********************.js 3.05 kB -5 B (-0.16%)
dotcom-rendering/dist/3937.client.web.********************.js 3.85 kB -1 B (-0.03%)
dotcom-rendering/dist/4170.client.web.********************.js 16.3 kB 0 B
dotcom-rendering/dist/4285.client.web.********************.js 6.54 kB -1 B (-0.02%)
dotcom-rendering/dist/4501.client.web.********************.js 4.29 kB 0 B
dotcom-rendering/dist/4684.client.web.********************.js 3.17 kB -1 B (-0.03%)
dotcom-rendering/dist/4878.client.web.********************.js 8.08 kB +1 B (+0.01%)
dotcom-rendering/dist/4943.client.web.********************.js 3.69 kB 0 B
dotcom-rendering/dist/4982.client.web.********************.js 13.9 kB +2 B (+0.01%)
dotcom-rendering/dist/5095.client.web.********************.js 4.17 kB 0 B
dotcom-rendering/dist/5223.client.web.********************.js 3.27 kB 0 B
dotcom-rendering/dist/5598.client.web.********************.js 4.49 kB 0 B
dotcom-rendering/dist/6021.client.web.********************.js 11.1 kB 0 B
dotcom-rendering/dist/6061.client.web.********************.js 3.63 kB 0 B
dotcom-rendering/dist/6073.client.web.********************.js 3.53 kB 0 B
dotcom-rendering/dist/6163.client.web.********************.js 3.83 kB 0 B
dotcom-rendering/dist/6577.client.web.********************.js 5.41 kB 0 B
dotcom-rendering/dist/6627.client.web.********************.js 10.4 kB +2 B (+0.02%)
dotcom-rendering/dist/6876.client.web.********************.js 2.67 kB 0 B
dotcom-rendering/dist/6882.client.web.********************.js 12.6 kB 0 B
dotcom-rendering/dist/6903.client.web.********************.js 3.21 kB -1 B (-0.03%)
dotcom-rendering/dist/6931.client.web.********************.js 2.63 kB 0 B
dotcom-rendering/dist/6940.client.web.********************.js 526 B 0 B
dotcom-rendering/dist/7116.client.web.********************.js 23 kB 0 B
dotcom-rendering/dist/719.client.web.********************.js 3.49 kB 0 B
dotcom-rendering/dist/7350.client.web.********************.js 3.32 kB 0 B
dotcom-rendering/dist/7364.client.web.********************.js 3.22 kB 0 B
dotcom-rendering/dist/7540.client.web.********************.js 2.72 kB 0 B
dotcom-rendering/dist/7546.client.web.********************.js 7.36 kB -1 B (-0.01%)
dotcom-rendering/dist/7861.client.web.********************.js 619 B 0 B
dotcom-rendering/dist/8030.client.web.********************.js 4.18 kB 0 B
dotcom-rendering/dist/8067.client.web.********************.js 3.39 kB 0 B
dotcom-rendering/dist/8209.client.web.********************.js 3.64 kB 0 B
dotcom-rendering/dist/8592.client.web.********************.js 157 B 0 B
dotcom-rendering/dist/895.client.web.********************.js 5.14 kB -1 B (-0.02%)
dotcom-rendering/dist/9072.client.web.********************.js 2.61 kB 0 B
dotcom-rendering/dist/9242.client.web.********************.js 3.76 kB 0 B
dotcom-rendering/dist/9288.client.web.********************.js 2.51 kB 0 B
dotcom-rendering/dist/9362.client.web.********************.js 20.3 kB 0 B
dotcom-rendering/dist/9558.client.web.********************.js 3.53 kB 0 B
dotcom-rendering/dist/9665.client.web.********************.js 4.04 kB 0 B
dotcom-rendering/dist/9735.client.web.********************.js 4.46 kB 0 B
dotcom-rendering/dist/9766.client.web.********************.js 3.41 kB +1 B (+0.03%)
dotcom-rendering/dist/9790.client.web.********************.js 3.58 kB +1 B (+0.03%)
dotcom-rendering/dist/AdBlockAsk-importable.client.web.********************.js 2.85 kB -1 B (-0.04%)
dotcom-rendering/dist/AdPortals-importable.client.web.********************.js 4.85 kB 0 B
dotcom-rendering/dist/AlreadyVisited-importable.client.web.********************.js 424 B 0 B
dotcom-rendering/dist/AppsEpic-importable.client.web.********************.js 3.63 kB -1 B (-0.03%)
dotcom-rendering/dist/AppsFooter-importable.client.web.********************.js 2.7 kB -1 B (-0.04%)
dotcom-rendering/dist/AppsLightboxImage-importable.client.web.********************.js 2.66 kB 0 B
dotcom-rendering/dist/AppsLightboxImageStore-importable.client.web.********************.js 2.55 kB 0 B
dotcom-rendering/dist/AudioAtomWrapper-importable.client.web.********************.js 2.6 kB +1 B (+0.04%)
dotcom-rendering/dist/AudioPlayerWrapper-importable.client.web.********************.js 6.33 kB 0 B
dotcom-rendering/dist/AustralianTerritorySwitcher-importable.client.web.********************.js 2 kB -1 B (-0.05%)
dotcom-rendering/dist/Branding-importable.client.web.********************.js 2.88 kB -2 B (-0.07%)
dotcom-rendering/dist/braze-web-sdk-core.client.web.********************.js 37.2 kB 0 B
dotcom-rendering/dist/BrazeMessaging-importable.client.web.********************.js 1.97 kB 0 B
dotcom-rendering/dist/CalloutBlockComponent-importable.client.web.********************.js 6.74 kB 0 B
dotcom-rendering/dist/CalloutEmbedBlockComponent-importable.client.web.********************.js 5.77 kB +2 B (+0.03%)
dotcom-rendering/dist/CardCommentCount-importable.client.web.********************.js 2.66 kB 0 B
dotcom-rendering/dist/Carousel-importable.client.web.********************.js 7.03 kB +1 B (+0.01%)
dotcom-rendering/dist/CarouselForNewsletters-importable.client.web.********************.js 5.14 kB -3 B (-0.06%)
dotcom-rendering/dist/ChartAtom-importable.client.web.********************.js 538 B 0 B
dotcom-rendering/dist/CommentCount-importable.client.web.********************.js 2.29 kB -2 B (-0.09%)
dotcom-rendering/dist/Crossword-importable.client.web.********************.js 277 B 0 B
dotcom-rendering/dist/DiscussionApps-importable.client.web.********************.js 1.93 kB 0 B
dotcom-rendering/dist/DiscussionMeta-importable.client.web.********************.js 2.44 kB 0 B
dotcom-rendering/dist/DiscussionWeb-importable.client.web.********************.js 1.74 kB 0 B
dotcom-rendering/dist/DocumentBlockComponent-importable.client.web.********************.js 2.82 kB 0 B
dotcom-rendering/dist/Dropdown-importable.client.web.********************.js 1.72 kB 0 B
dotcom-rendering/dist/EditionSwitcherBanner-importable.client.web.********************.js 3.5 kB +1 B (+0.03%)
dotcom-rendering/dist/EmbedBlockComponent-importable.client.web.********************.js 3.94 kB 0 B
dotcom-rendering/dist/EnhancePinnedPost-importable.client.web.********************.js 2.02 kB 0 B
dotcom-rendering/dist/FetchOnwardsData-importable.client.web.********************.js 1.94 kB 0 B
dotcom-rendering/dist/FilterKeyEventsToggle-importable.client.web.********************.js 3.8 kB +1 B (+0.03%)
dotcom-rendering/dist/FocusStyles-importable.client.web.********************.js 617 B 0 B
dotcom-rendering/dist/FollowWrapper-importable.client.web.********************.js 2.52 kB 0 B
dotcom-rendering/dist/FooterLabel-importable.client.web.********************.js 343 B 0 B
dotcom-rendering/dist/FooterReaderRevenueLinks-importable.client.web.********************.js 3.5 kB 0 B
dotcom-rendering/dist/frameworks.client.web.********************.js 20.9 kB 0 B
dotcom-rendering/dist/FrontSubNav-importable.client.web.********************.js 7.37 kB -1 B (-0.01%)
dotcom-rendering/dist/GetCricketScoreboard-importable.client.web.********************.js 6.26 kB +1 B (+0.02%)
dotcom-rendering/dist/GetMatchNav-importable.client.web.********************.js 11.4 kB -1 B (-0.01%)
dotcom-rendering/dist/GetMatchStats-importable.client.web.********************.js 7.97 kB 0 B
dotcom-rendering/dist/GetMatchTabs-importable.client.web.********************.js 2.58 kB 0 B
dotcom-rendering/dist/guardian-braze-components-banner.client.web.********************.js 15.8 kB 0 B
dotcom-rendering/dist/guardian-braze-components-end-of-article.client.web.********************.js 10.2 kB 0 B
dotcom-rendering/dist/GuideAtomWrapper-importable.client.web.********************.js 783 B 0 B
dotcom-rendering/dist/index.client.web.********************.js 45 kB -79 B (-0.18%)
dotcom-rendering/dist/InstagramBlockComponent-importable.client.web.********************.js 2.9 kB +2 B (+0.07%)
dotcom-rendering/dist/InteractiveAtomMessenger-importable.client.web.********************.js 853 B 0 B
dotcom-rendering/dist/InteractiveBlockComponent-importable.client.web.********************.js 8.49 kB +2 B (+0.02%)
dotcom-rendering/dist/InteractiveContentsBlockComponent-importable.client.web.********************.js 3.74 kB 0 B
dotcom-rendering/dist/KeyEventsCarousel-importable.client.web.********************.js 5.68 kB +1 B (+0.02%)
dotcom-rendering/dist/KnowledgeQuizAtom-importable.client.web.********************.js 3.55 kB 0 B
dotcom-rendering/dist/LatestLinks-importable.client.web.********************.js 6.38 kB -1 B (-0.02%)
dotcom-rendering/dist/LightboxHash-importable.client.web.********************.js 436 B 0 B
dotcom-rendering/dist/LightboxLayout-importable.client.web.********************.js 6.53 kB +3 B (+0.05%)
dotcom-rendering/dist/LiveBlogEpic-importable.client.web.********************.js 3.55 kB 0 B
dotcom-rendering/dist/LiveblogNotifications-importable.client.web.********************.js 4.81 kB -1 B (-0.02%)
dotcom-rendering/dist/Liveness-importable.client.web.********************.js 4.72 kB 0 B
dotcom-rendering/dist/ManyNewsletterSignUp-importable.client.web.********************.js 7.6 kB +1 B (+0.01%)
dotcom-rendering/dist/MapEmbedBlockComponent-importable.client.web.********************.js 5.89 kB 0 B
dotcom-rendering/dist/Metrics-importable.client.web.********************.js 2.69 kB 0 B
dotcom-rendering/dist/MostViewedFooter-importable.client.web.********************.js 3.85 kB +1 B (+0.03%)
dotcom-rendering/dist/MostViewedFooterData-importable.client.web.********************.js 5.94 kB -2 B (-0.03%)
dotcom-rendering/dist/MostViewedRightWithAd-importable.client.web.********************.js 5.12 kB +1 B (+0.02%)
dotcom-rendering/dist/OnwardsUpper-importable.client.web.********************.js 5.32 kB 0 B
dotcom-rendering/dist/PersonalityQuizAtom-importable.client.web.********************.js 3.72 kB -1 B (-0.03%)
dotcom-rendering/dist/ProfileAtom-importable.client.web.********************.js 543 B 0 B
dotcom-rendering/dist/ProfileAtomWrapper-importable.client.web.********************.js 802 B 0 B
dotcom-rendering/dist/PulsingDot-importable.client.web.********************.js 749 B 0 B
dotcom-rendering/dist/QandaAtom-importable.client.web.********************.js 543 B 0 B
dotcom-rendering/dist/ReaderRevenueDev-importable.client.web.********************.js 468 B 0 B
dotcom-rendering/dist/readerRevenueDevUtils.client.web.********************.js 1.75 kB 0 B
dotcom-rendering/dist/RelativeTime-importable.client.web.********************.js 2.53 kB 0 B
dotcom-rendering/dist/RichLinkComponent-importable.client.web.********************.js 6.11 kB 0 B
dotcom-rendering/dist/ScrollableHighlights-importable.client.web.********************.js 6.12 kB 0 B
dotcom-rendering/dist/SecureSignup-importable.client.web.********************.js 4.1 kB 0 B
dotcom-rendering/dist/SendTargetingParams-importable.client.web.********************.js 2.22 kB 0 B
dotcom-rendering/dist/sentry.client.web.********************.js 794 B 0 B
dotcom-rendering/dist/SetABTests-importable.client.web.********************.js 3.8 kB 0 B
dotcom-rendering/dist/SetAdTargeting-importable.client.web.********************.js 485 B 0 B
dotcom-rendering/dist/ShareButton-importable.client.web.********************.js 919 B 0 B
dotcom-rendering/dist/shimport.client.web.********************.js 2.8 kB 0 B
dotcom-rendering/dist/ShowHideContainers-importable.client.web.********************.js 698 B +40 B (+6.08%) 🔍
dotcom-rendering/dist/ShowMore-importable.client.web.********************.js 2.1 kB +2 B (+0.1%)
dotcom-rendering/dist/SignInGateMain.client.web.********************.js 4.46 kB +1 B (+0.02%)
dotcom-rendering/dist/SignInGateMainCheckoutComplete.client.web.********************.js 5.56 kB -1 B (-0.02%)
dotcom-rendering/dist/SignInGateSelector-importable.client.web.********************.js 5.83 kB 0 B
dotcom-rendering/dist/SlideshowCarousel-importable.client.web.********************.js 4.37 kB -1 B (-0.02%)
dotcom-rendering/dist/SlotBodyEnd-importable.client.web.********************.js 4.84 kB 0 B
dotcom-rendering/dist/SpotifyBlockComponent-importable.client.web.********************.js 5.71 kB 0 B
dotcom-rendering/dist/StickyBottomBanner-importable.client.web.********************.js 6.15 kB 0 B
dotcom-rendering/dist/StickyLiveblogAskWrapper-importable.client.web.********************.js 8.14 kB 0 B
dotcom-rendering/dist/SubNav-importable.client.web.********************.js 2.41 kB +2 B (+0.08%)
dotcom-rendering/dist/TableOfContents-importable.client.web.********************.js 3.48 kB 0 B
dotcom-rendering/dist/TimelineAtom-importable.client.web.********************.js 1.23 kB -2 B (-0.16%)
dotcom-rendering/dist/Titlepiece-importable.client.web.********************.js 13.5 kB 0 B
dotcom-rendering/dist/TopBar-importable.client.web.********************.js 9.29 kB 0 B
dotcom-rendering/dist/TopBarSupport-importable.client.web.********************.js 2.5 kB 0 B
dotcom-rendering/dist/TweetBlockComponent-importable.client.web.********************.js 1.13 kB -1 B (-0.09%)
dotcom-rendering/dist/UnsafeEmbedBlockComponent-importable.client.web.********************.js 2.91 kB -1 B (-0.03%)
dotcom-rendering/dist/VideoFacebookBlockComponent-importable.client.web.********************.js 5.9 kB 0 B
dotcom-rendering/dist/VineBlockComponent-importable.client.web.********************.js 2.78 kB -2 B (-0.07%)
dotcom-rendering/dist/YoutubeBlockComponent-importable.client.web.********************.js 4.38 kB -1 B (-0.02%)

compressed-size-action

@Georges-GNM Georges-GNM added the run_chromatic Runs chromatic when label is applied label Jan 16, 2025
@github-actions github-actions bot removed the run_chromatic Runs chromatic when label is applied label Jan 16, 2025
@domlander domlander self-requested a review January 16, 2025 12:05
@abeddow91 abeddow91 self-assigned this Jan 16, 2025
@abeddow91 abeddow91 added the run_chromatic Runs chromatic when label is applied label Jan 16, 2025
@github-actions github-actions bot removed the run_chromatic Runs chromatic when label is applied label Jan 16, 2025
@Georges-GNM Georges-GNM self-assigned this Jan 16, 2025
@abeddow91 abeddow91 force-pushed the ab/carousel-portal-navigation branch from 61b622f to 5c72902 Compare January 16, 2025 12:29
@abeddow91 abeddow91 added the run_chromatic Runs chromatic when label is applied label Jan 16, 2025
@github-actions github-actions bot removed the run_chromatic Runs chromatic when label is applied label Jan 16, 2025
@abeddow91 abeddow91 force-pushed the ab/carousel-portal-navigation branch from e015978 to 5c72902 Compare January 16, 2025 16:10
@domlander
Copy link
Contributor

The show/hide should render in the bottom-right in Primary containers: Figma

@abeddow91 abeddow91 force-pushed the ab/carousel-portal-navigation branch from 5c72902 to 761702e Compare January 21, 2025 10:20
@abeddow91 abeddow91 requested a review from Georges-GNM January 21, 2025 10:47
@Georges-GNM Georges-GNM removed their assignment Jan 21, 2025
@abeddow91 abeddow91 added the run_chromatic Runs chromatic when label is applied label Jan 22, 2025
@github-actions github-actions bot removed the run_chromatic Runs chromatic when label is applied label Jan 22, 2025
@Fweddi Fweddi self-assigned this Jan 22, 2025
Copy link
Contributor

@Fweddi Fweddi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On scrollable containers with a small height the Show/Hide controls are quite close to the navigation arrows. I just wanted to check if this was specified in the designs?
image

Copy link
Contributor

@Fweddi Fweddi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! Tested locally and all LGTM. Portals are fun.

@abeddow91 abeddow91 merged commit 6744bed into main Jan 22, 2025
31 checks passed
@abeddow91 abeddow91 deleted the ab/carousel-portal-navigation branch January 22, 2025 13:45
@prout-bot
Copy link

Seen on PROD (merged by @abeddow91 1 hour, 13 minutes and 30 seconds ago) Please check your changes!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants