Accessibility? Fine, we can hack it.
Making Portal more accessible, one way or another.
I've got some work for Peter Torres Fremlin's Disability Debrief on my docket. Given the subject matter, Peter obviously cares a lot about accessibility. He recently sent me an accessibility audit for his site. About half the items are under theme control. Great. I can fix those, and will tomorrow.
The other half? Yeah, that's where we get a blog post. The other half is baked into Ghost's Portal and Comments apps, which aren't under theme control.
I asked the core team if I could submit some patches for them, and was told that while it was fine to submit PRs for the missing roles and tabindexes, anything more complicated or that impacted visual styling (i.e. the contrast fixes) wasn't going to be a priority/no capacity right now.
I was aggravated. I don't like no, and I especially don't like no when it's funded work offered for free to Ghost that would solve an access issue. (Yes, ok, so it isn't really free, because someone would need to review it. But still.)
I thought about forking the apps. I didn't love that option. It's not that it's hard to fork them, and it's not that it's hard to pull new changes into a fork, but there's a risk there for clients on Ghost Pro that the interface between the app and the api gets changed, and you end up with a situation where they're out of sync and things are broken. [Ghost Pro does a staggered roll-out on Monday/Tuesday of the previous Friday release, except sometimes there's a mid-week patch for something and so an extra roll-out.] I was pretty sure I could set up some automation to automatically patch and release these hypothetical forks, but I was also pretty sure that eventually there'd be a week when the interface changed and my automatic patching failed at the same time, and it'd coincide with a day that I wasn't at my desk. I didn't love it.
So. I've seen code posted occasionally that modifies some part of portal. It's a little bit of a hack. OK, no, it's a lot of a hack. You run a mutation listener and watch for the portal to change, and rewrite it. It does actually get around the issues with iframes, interestingly enough. So, that's where I went.
I have a lot more to do, but try tabbing through the Portal now. You should see that the buttons get a focus highlight, and the close button is focusable and can be triggered with the keyboard.
And for comparison, here's an unmodded version:
OK, so how'd we do that? Like this! 👇
<script>
window.addEventListener('load', ()=>{
let ghostPortal = document.getElementById('ghost-portal-root');
let ghostPortalWrapper = null;
let portalRewriter = null;
const rewritePortal = () => {
const iframeDoc = ghostPortalWrapper.firstChild.contentDocument;
if(iframeDoc && iframeDoc.head){
const closeIcon = iframeDoc.querySelector('.gh-portal-closeicon');
// Check if stylesheet already exists and element is ready
if(!iframeDoc.getElementById('injected-stylesheet') && closeIcon){
// Inject stylesheet
const styleSheet = iframeDoc.createElement('style');
styleSheet.id = 'injected-stylesheet';
styleSheet.textContent = `
.gh-portal-closeicon {
color: var(--grey1);
}
.gh-portal-closeicon:hover {
color: var(--black);
}
.gh-portal-closeicon-container {
cursor: pointer;
}
.gh-portal-closeicon:focus {
outline: 2px solid var(--grey1);
outline-offset: 2px;
}
.gh-portal-closeicon:focus-visible {
outline: 2px solid var(--black);
outline-offset: 2px;
border-radius: 50%
}
.gh-portal-btn.gh-portal-btn-list:focus-visible, .gh-portal-btn:focus-visible {
outline: 2px solid var(--black);
outline-offset: 2px;
}
`;
iframeDoc.head.appendChild(styleSheet);
// Make close icon keyboard focusable
if(!closeIcon.hasAttribute('tabindex')){
closeIcon.setAttribute('tabindex', '0');
closeIcon.setAttribute('role', 'button');
closeIcon.setAttribute('aria-label', 'Close popup');
// Add keyboard event handler for Enter and Space
closeIcon.addEventListener('keydown', (e) => {
if(e.key === 'Enter' || e.key === ' '){
e.preventDefault();
// Dispatch a click event since SVG elements may not support .click()
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: iframeDoc.defaultView
});
closeIcon.dispatchEvent(clickEvent);
}
});
}
console.log('Portal rewritten successfully');
clearInterval(portalRewriter);
}
}
}
let portalObserver = new MutationObserver((mutations)=> {
mutations.forEach((mutation) => {
if(mutation.addedNodes[0]?.nodeName === 'DIV'){
ghostPortalWrapper = mutation.addedNodes[0];
portalRewriter = setInterval(rewritePortal, 50);
}
});
});
portalObserver.observe(ghostPortal, {
attributes: false,
characterData: true,
childList: true,
subtree: true,
attributeOldValue: false,
characterDataOldValue: true
})
})
</script>Thanks to this site, for the code that got me started:

I haven't worked it out yet for the comments app, but I'm thinking I'm headed for something similar, at least for the contrast fixes...
Accessibility, here we come!
p.s. I'm pretty sure there's a portal dark mode hidden in here, too...