diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionCase.js new file mode 100644 index 000000000000..aada4e33fa4e --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionCase.js @@ -0,0 +1,53 @@ +import TestCase from '../../TestCase'; +import Fixture from '../../Fixture'; +import CompareDocumentPositionFragmentContainer from './CompareDocumentPositionFragmentContainer'; + +const React = window.React; + +export default function CompareDocumentPositionCase() { + return ( + + +
  • Click the "Compare All Positions" button
  • +
    + + The compareDocumentPosition method compares the position of the fragment + relative to other elements in the DOM. The "Before Element" should be + PRECEDING the fragment, and the "After Element" should be FOLLOWING. + Elements inside the fragment should be CONTAINED_BY. + + + + +
    + First child element +
    +
    + Second child element +
    +
    + Third child element +
    +
    +
    +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionFragmentContainer.js b/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionFragmentContainer.js new file mode 100644 index 000000000000..380bed49cb41 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionFragmentContainer.js @@ -0,0 +1,246 @@ +const React = window.React; +const {Fragment, useRef, useState} = React; + +const POSITION_FLAGS = { + DISCONNECTED: 0x01, + PRECEDING: 0x02, + FOLLOWING: 0x04, + CONTAINS: 0x08, + CONTAINED_BY: 0x10, + IMPLEMENTATION_SPECIFIC: 0x20, +}; + +function getPositionDescription(bitmask) { + const flags = []; + if (bitmask & POSITION_FLAGS.DISCONNECTED) flags.push('DISCONNECTED'); + if (bitmask & POSITION_FLAGS.PRECEDING) flags.push('PRECEDING'); + if (bitmask & POSITION_FLAGS.FOLLOWING) flags.push('FOLLOWING'); + if (bitmask & POSITION_FLAGS.CONTAINS) flags.push('CONTAINS'); + if (bitmask & POSITION_FLAGS.CONTAINED_BY) flags.push('CONTAINED_BY'); + if (bitmask & POSITION_FLAGS.IMPLEMENTATION_SPECIFIC) + flags.push('IMPLEMENTATION_SPECIFIC'); + return flags.length > 0 ? flags.join(' | ') : 'SAME'; +} + +function ResultRow({label, result, color}) { + if (!result) return null; + + return ( +
    +
    + {label} +
    +
    + Raw value: + {result.raw} + Flags: + + {getPositionDescription(result.raw)} + +
    +
    + ); +} + +export default function CompareDocumentPositionFragmentContainer({children}) { + const fragmentRef = useRef(null); + const beforeRef = useRef(null); + const afterRef = useRef(null); + const insideRef = useRef(null); + const [results, setResults] = useState(null); + + const compareAll = () => { + const fragment = fragmentRef.current; + const beforePos = fragment.compareDocumentPosition(beforeRef.current); + const afterPos = fragment.compareDocumentPosition(afterRef.current); + const insidePos = insideRef.current + ? fragment.compareDocumentPosition(insideRef.current) + : null; + + setResults({ + before: {raw: beforePos}, + after: {raw: afterPos}, + inside: insidePos !== null ? {raw: insidePos} : null, + }); + }; + + return ( + +
    + + {results && ( + + Comparison complete + + )} +
    + +
    +
    +
    +
    + Before Element +
    + +
    +
    + FRAGMENT +
    +
    + {children} +
    +
    + +
    + After Element +
    +
    +
    + +
    +
    + Comparison Results +
    + + {!results && ( +
    + Click "Compare All Positions" to see results +
    + )} + + {results && ( + + + + {results.inside && ( + + )} + +
    + Flag Reference: +
    + 0x01 + DISCONNECTED + 0x02 + PRECEDING (other is before fragment) + 0x04 + FOLLOWING (other is after fragment) + 0x08 + CONTAINS (other contains fragment) + 0x10 + CONTAINED_BY (other is inside fragment) +
    +
    +
    + )} +
    +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/EventFragmentContainer.js b/fixtures/dom/src/components/fixtures/fragment-refs/EventFragmentContainer.js new file mode 100644 index 000000000000..33c99390a4da --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/EventFragmentContainer.js @@ -0,0 +1,112 @@ +const React = window.React; +const {Fragment, useRef, useState} = React; + +export default function EventFragmentContainer({children}) { + const fragmentRef = useRef(null); + const [eventLog, setEventLog] = useState([]); + const [listenerAdded, setListenerAdded] = useState(false); + const [bubblesState, setBubblesState] = useState(true); + + const logEvent = message => { + setEventLog(prev => [...prev, message]); + }; + + const fragmentClickHandler = () => { + logEvent('Fragment event listener fired'); + }; + + const addListener = () => { + fragmentRef.current.addEventListener('click', fragmentClickHandler); + setListenerAdded(true); + logEvent('Added click listener to fragment'); + }; + + const removeListener = () => { + fragmentRef.current.removeEventListener('click', fragmentClickHandler); + setListenerAdded(false); + logEvent('Removed click listener from fragment'); + }; + + const dispatchClick = () => { + fragmentRef.current.dispatchEvent( + new MouseEvent('click', {bubbles: bubblesState}) + ); + logEvent(`Dispatched click event (bubbles: ${bubblesState})`); + }; + + const clearLog = () => { + setEventLog([]); + }; + + return ( + +
    + + + + + +
    + +
    logEvent('Parent div clicked')} + style={{ + padding: '12px', + border: '1px dashed #ccc', + borderRadius: '4px', + backgroundColor: '#fff', + }}> + {children} +
    + + {eventLog.length > 0 && ( +
    + Event Log: + +
    + )} +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js index 125b67cf39a9..a6e32422bc25 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js @@ -1,46 +1,35 @@ import TestCase from '../../TestCase'; import Fixture from '../../Fixture'; +import EventFragmentContainer from './EventFragmentContainer'; const React = window.React; -const {Fragment, useEffect, useRef, useState} = React; +const {useState} = React; function WrapperComponent(props) { return props.children; } -function handler(e) { - const text = e.currentTarget.innerText; - alert('You clicked: ' + text); -} - export default function EventListenerCase() { - const fragmentRef = useRef(null); const [extraChildCount, setExtraChildCount] = useState(0); - useEffect(() => { - fragmentRef.current.addEventListener('click', handler); - - const lastFragmentRefValue = fragmentRef.current; - return () => { - lastFragmentRefValue.removeEventListener('click', handler); - }; - }); - return ( -
  • Click one of the children, observe the alert
  • -
  • Add a new child, click it, observe the alert
  • -
  • Remove the event listeners, click a child, observe no alert
  • -
  • Add the event listeners back, click a child, observe the alert
  • +
  • + Click "Add event listener" to attach a click handler to the fragment +
  • +
  • Click "Dispatch click event" to dispatch a click event
  • +
  • Observe the event log showing the event fired
  • +
  • Add a new child, dispatch again to see it still works
  • +
  • + Click "Remove event listener" and dispatch again to see no event fires +
  • Fragment refs can manage event listeners on the first level of host - children. This page loads with an effect that sets up click event - hanndlers on each child card. Clicking on a card will show an alert - with the card's text. + children. The event log shows when events are dispatched and handled.

    New child nodes will also have event listeners applied. Removed nodes @@ -50,28 +39,17 @@ export default function EventListenerCase() { -

    Target count: {extraChildCount + 3}
    - - - - -
    - +
    + Target count: {extraChildCount + 3} + +
    +
    Child A
    @@ -88,8 +66,8 @@ export default function EventListenerCase() {
    ))} - - + +
    ); diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/GetRootNodeFragmentContainer.js b/fixtures/dom/src/components/fixtures/fragment-refs/GetRootNodeFragmentContainer.js new file mode 100644 index 000000000000..01244b3c87a2 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/GetRootNodeFragmentContainer.js @@ -0,0 +1,79 @@ +const React = window.React; +const {Fragment, useRef, useState} = React; + +export default function GetRootNodeFragmentContainer({children}) { + const fragmentRef = useRef(null); + const [rootNodeInfo, setRootNodeInfo] = useState(null); + + const getRootNodeInfo = () => { + const rootNode = fragmentRef.current.getRootNode(); + setRootNodeInfo({ + nodeName: rootNode.nodeName, + nodeType: rootNode.nodeType, + nodeTypeLabel: getNodeTypeLabel(rootNode.nodeType), + isDocument: rootNode === document, + }); + }; + + const getNodeTypeLabel = nodeType => { + const types = { + 1: 'ELEMENT_NODE', + 3: 'TEXT_NODE', + 9: 'DOCUMENT_NODE', + 11: 'DOCUMENT_FRAGMENT_NODE', + }; + return types[nodeType] || `UNKNOWN (${nodeType})`; + }; + + return ( + +
    + +
    + + {rootNodeInfo && ( +
    +
    + Node Name: {rootNodeInfo.nodeName} +
    +
    + Node Type: {rootNodeInfo.nodeType} ( + {rootNodeInfo.nodeTypeLabel}) +
    +
    + Is Document:{' '} + {rootNodeInfo.isDocument ? 'Yes' : 'No'} +
    +
    + )} + +
    + {children} +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/TextNodesCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/TextNodesCase.js index 4141e069eec1..c6a359ac8878 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/TextNodesCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/TextNodesCase.js @@ -1,6 +1,9 @@ import TestCase from '../../TestCase'; import Fixture from '../../Fixture'; import PrintRectsFragmentContainer from './PrintRectsFragmentContainer'; +import CompareDocumentPositionFragmentContainer from './CompareDocumentPositionFragmentContainer'; +import EventFragmentContainer from './EventFragmentContainer'; +import GetRootNodeFragmentContainer from './GetRootNodeFragmentContainer'; const React = window.React; const {Fragment, useRef, useState} = React; @@ -242,6 +245,28 @@ function ScrollIntoViewMixed() { ); } +function CompareDocumentPositionTextNodes() { + return ( + + +
  • Click the "Compare All Positions" button
  • +
    + + compareDocumentPosition should work correctly even when the fragment + contains only text nodes. The "Before" element should be PRECEDING the + fragment, and the "After" element should be FOLLOWING. + + + + + This is text-only content inside the fragment. + + + +
    + ); +} + function ObserveTextOnlyWarning() { const fragmentRef = useRef(null); const [message, setMessage] = useState(''); @@ -287,6 +312,126 @@ function ObserveTextOnlyWarning() { ); } +function EventTextOnly() { + return ( + + +
  • + Click "Add event listener" to attach a click handler to the fragment +
  • +
  • Click "Dispatch click event" to dispatch a click event
  • +
  • Observe that the fragment's event listener fires
  • +
  • Click "Remove event listener" and dispatch again
  • +
    + + Event operations (addEventListener, removeEventListener, dispatchEvent) + work on fragments with text-only content. The event is dispatched on the + fragment's parent element since text nodes cannot be event targets. + + + + + This fragment contains only text. Events are handled via the parent. + + + +
    + ); +} + +function EventMixed() { + return ( + + +
  • + Click "Add event listener" to attach a click handler to the fragment +
  • +
  • Click "Dispatch click event" to dispatch a click event
  • +
  • Observe that the fragment's event listener fires
  • +
  • Click directly on the element or text content to see bubbling
  • +
    + + Event operations work on fragments with mixed text and element content. + dispatchEvent forwards to the parent element. Clicks on child elements + or text bubble up through the DOM as normal. + + + + + Text node before element. + + Element + + Text node after element. + + + +
    + ); +} + +function GetRootNodeTextOnly() { + return ( + + +
  • Click the "Get Root Node" button
  • +
    + + getRootNode should return the root of the DOM tree containing the + fragment's text content. For a fragment in the main document, this + should return the Document node. + + + + + This fragment contains only text. getRootNode returns the document. + + + +
    + ); +} + +function GetRootNodeMixed() { + return ( + + +
  • Click the "Get Root Node" button
  • +
    + + getRootNode should return the root of the DOM tree for fragments with + mixed text and element content. The result is the same whether checking + from text nodes or element nodes within the fragment. + + + + + Text before element. + + Element + + Text after element. + + + +
    + ); +} + export default function TextNodesCase() { return ( @@ -297,7 +442,8 @@ export default function TextNodesCase() {

    Supported: getClientRects, compareDocumentPosition, - scrollIntoView + scrollIntoView, getRootNode, addEventListener, removeEventListener, + dispatchEvent

    No-op (silent): focus, focusLast (text nodes cannot @@ -310,10 +456,15 @@ export default function TextNodesCase() { + + + + + ); } diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/index.js b/fixtures/dom/src/components/fixtures/fragment-refs/index.js index ca6e8185116c..91160618c808 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/index.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/index.js @@ -5,6 +5,7 @@ import IntersectionObserverCase from './IntersectionObserverCase'; import ResizeObserverCase from './ResizeObserverCase'; import FocusCase from './FocusCase'; import GetClientRectsCase from './GetClientRectsCase'; +import CompareDocumentPositionCase from './CompareDocumentPositionCase'; import ScrollIntoViewCase from './ScrollIntoViewCase'; import TextNodesCase from './TextNodesCase'; @@ -19,6 +20,7 @@ export default function FragmentRefsPage() { +