diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx
index 4fee489e00f..325cd71dee2 100644
--- a/packages/@react-spectrum/ai/src/PromptField.tsx
+++ b/packages/@react-spectrum/ai/src/PromptField.tsx
@@ -141,7 +141,7 @@ function tokenizeURLs(text: string): TokenFieldSegment[] {
if (match.index > start) {
segments.push({type: 'text', text: text.slice(start, match.index)});
}
- segments.push({type: 'token', text: match[3], value: {type: 'url', url: match[0]}});
+ segments.push({type: 'token', text: match[0], value: {type: 'url', url: match[0]}});
start = match.index + match[0].length;
}
diff --git a/packages/@react-spectrum/ai/test/PromptField.test.tsx b/packages/@react-spectrum/ai/test/PromptField.test.tsx
index 61bd1ddc362..5dea956dbef 100644
--- a/packages/@react-spectrum/ai/test/PromptField.test.tsx
+++ b/packages/@react-spectrum/ai/test/PromptField.test.tsx
@@ -171,6 +171,21 @@ describeOrSkip('PromptField', () => {
let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url');
expect(urlToken?.text).toBe('test.com');
});
+
+ it('keeps the scheme in the token text and outgoing text when typed', async () => {
+ let {user, textbox, getValue} = renderPromptField();
+ await user.click(textbox);
+ await user.keyboard('visit https://www.test.com now');
+
+ await waitFor(() =>
+ expect(getValue().segments.some(s => s.type === 'token' && s.value?.type === 'url')).toBe(
+ true
+ )
+ );
+ let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url');
+ expect(urlToken?.text).toBe('https://www.test.com');
+ expect(getValue().toString()).toContain('https://www.test.com');
+ });
});
describe('replacing an existing token', () => {
diff --git a/packages/react-aria-components/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx
index cab0f819203..2f0cf1e4987 100644
--- a/packages/react-aria-components/test/TokenField.browser.test.tsx
+++ b/packages/react-aria-components/test/TokenField.browser.test.tsx
@@ -1055,6 +1055,56 @@ describeOrSkip('TokenField browser interactions', () => {
}
expect(getValue().toString()).toBe('hi');
});
+
+ // Real OS clipboard round trips can't carry text/html reliably across engines in CI, and
+ // useTokenField reads the pasted HTML/plain-text pair off the `beforeinput` event's own
+ // `dataTransfer` (not off a native clipboard read), so these dispatch that event directly
+ // with a synthetic DataTransfer to exercise the same code path a real paste would use.
+ let pasteHtml = (el: Element, html: string, plainText: string) => {
+ let dt = new DataTransfer();
+ dt.setData('text/html', html);
+ dt.setData('text/plain', plainText);
+ el.dispatchEvent(
+ new InputEvent('beforeinput', {
+ inputType: 'insertFromPaste',
+ dataTransfer: dt,
+ bubbles: true,
+ cancelable: true
+ })
+ );
+ };
+
+ it('recovers a scheme dropped from a pasted rendered link', async () => {
+ // WebKit doesn't honor a synthetic `dataTransfer` passed to the InputEvent constructor,
+ // so this can't be exercised without a real (unavailable in CI) OS clipboard round trip.
+ if (isWebKit()) {
+ return;
+ }
+ let {textbox, getValue} = await renderControlledTokenField(segments(text('')));
+ await focusField(textbox);
+ pasteHtml(textbox.element(), 'example.com', 'example.com');
+ await waitForFieldText(getValue, 'https://example.com');
+ });
+
+ it('leaves a pasted link label alone when it is not a scheme-less rendering of its href', async () => {
+ if (isWebKit()) {
+ return;
+ }
+ let {textbox, getValue} = await renderControlledTokenField(segments(text('')));
+ await focusField(textbox);
+ pasteHtml(textbox.element(), 'click here', 'click here');
+ await waitForFieldText(getValue, 'click here');
+ });
+
+ it('pastes non-link HTML unchanged', async () => {
+ if (isWebKit()) {
+ return;
+ }
+ let {textbox, getValue} = await renderControlledTokenField(segments(text('')));
+ await focusField(textbox);
+ pasteHtml(textbox.element(), 'bold text', 'bold text');
+ await waitForFieldText(getValue, 'bold text');
+ });
});
describe('undo and redo', () => {
diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts
index 1c0cac2ef1e..be37f63fba3 100644
--- a/packages/react-aria/src/tokenfield/useTokenField.ts
+++ b/packages/react-aria/src/tokenfield/useTokenField.ts
@@ -90,6 +90,46 @@ export interface TokenFieldAria {
const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json';
+/**
+ * When pasting a rendered link (e.g. copied from Slack, a doc, etc.), the browser's
+ * plain-text clipboard representation uses the anchor's visible label, which may omit
+ * the scheme (e.g. "https://") that's only present in its `href`. This inspects the
+ * pasted HTML and substitutes an anchor's `href` for its display text when the two
+ * represent the same URL, so the scheme isn't silently dropped. In every other case
+ * (no anchors, anchor text doesn't match its href, parsing fails), this returns
+ * `plainText` unchanged, preserving the existing plain-text paste behavior.
+ */
+function preferLinkHrefs(html: string, plainText: string): string {
+ try {
+ let doc = new DOMParser().parseFromString(html, 'text/html');
+ let anchors = doc.body.querySelectorAll('a[href]');
+ let result = plainText;
+ for (let anchor of anchors) {
+ let displayText = anchor.textContent?.trim();
+ let href = anchor.getAttribute('href');
+ if (!displayText || !href || !result.includes(displayText)) {
+ continue;
+ }
+
+ let hrefWithoutSchemeAndWWW = href
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
+ .replace(/^www\./i, '')
+ .replace(/\/$/, '');
+ let displayWithoutWWW = displayText.replace(/^www\./i, '').replace(/\/$/, '');
+
+ // Only substitute when the display text is a scheme/www-less rendering of the
+ // href itself (i.e. the link's label wasn't custom text unrelated to its target).
+ if (hrefWithoutSchemeAndWWW === displayWithoutWWW) {
+ result = result.replace(displayText, href);
+ }
+ }
+
+ return result;
+ } catch {
+ return plainText;
+ }
+}
+
/**
* Provides the behavior and accessibility implementation for a token field.
* A token field allows users to enter text with inline tokens.
@@ -224,6 +264,11 @@ export function useTokenField(
: null;
if (parsed) {
data = parsed;
+ } else if (e.dataTransfer.types.includes('text/html')) {
+ let html = e.dataTransfer.getData('text/html');
+ data[0].text = e.dataTransfer.types.includes('text/plain')
+ ? preferLinkHrefs(html, e.dataTransfer.getData('text/plain'))
+ : preferLinkHrefs(html, data[0].text);
} else if (e.dataTransfer.types.includes('text/plain')) {
data[0].text = e.dataTransfer.getData('text/plain');
}