Thanks to CorxitSun for his answer; also thanks to bits and pieces from these answers:
I was able to boil it down to a more concise JavaScript solution that doesn't require accessing hidden APIs, and works both in a native iOS webview and in iOS Safari. This is still a little hacky, but hopefully a little less hacky than previous attempts.
There is one remaining issue – although the selection handles and selection background now show, the text selection menu (with copy, share, etc.) doesn't show unless a user taps a second time.
const selectParagraph = (event) => {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(event.currentTarget);
selection.removeAllRanges();
selection.addRange(range);
}
const paragraphs = document.querySelectorAll('p');
for (paragraph of paragraphs) {
paragraph.addEventListener('click', selectParagraph);
}
// WORKAROUND CODE
// 1. Determine the browser, and keep track of whether selection has been initialized
const isIosSafari = /^((?!Chrome|Firefox|Android|Samsung).)*AppleWebKit/i.test(navigator.userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 1;
let selectionIsReady = false;
// 2. Allow focus to be set programmatically (in addition to fixing iOS Safari, this also prevents "tap to search" in Android Chrome from interfering with text selection)
document.body.tabIndex = -1;
// 3. Initialize selection by temporarily moving focus to a hidden input element, the first time a user taps anywhere on the page. When the browser refocuses the page as part of its normal tap handling, selection UI (selection handles and colored background) will be visible. Known issue: Even with this workaround, the user has to tap the selected text again to show the text selection menu
window.addEventListener('pointerdown', (event) => respondToPointerDown(event));
const respondToPointerDown = (event) => {
if (isIosSafari && !selectionIsReady) {
const tempInput = document.createElement('input');
tempInput.style.position = 'fixed';
tempInput.style.opacity = 0;
tempInput.style.height = 0;
tempInput.style.fontSize = '16px'; // Prevent zoom on focus
tempInput.inputMode = 'none'; // Don't show keyboard
document.body.prepend(tempInput);
tempInput.focus();
setTimeout(() => {
tempInput.remove();
selectionIsReady = true;
}, 200);
}
}
// 4. If the window or tab goes in the background, selection needs to be initialized again
window.addEventListener('blur', (event) => selectionIsReady = false);
<p>Tap to select this paragraph.</p>
<p>Or this paragraph.</p>