diff --git a/src/amp-react-carousel-hooks.js b/src/amp-react-carousel-hooks.js index 3415edb..8a7e6a0 100644 --- a/src/amp-react-carousel-hooks.js +++ b/src/amp-react-carousel-hooks.js @@ -169,8 +169,14 @@ const AmpReactCarouselHooks = ReactCompatibleBaseElement(AmpCarouselHooks, { }, }, children: { - 'arrowNext': '[arrow-next]', - 'arrowPrev': '[arrow-prev]', + 'arrowNext': { + selector: '[arrow-next]', + props: {retarget: true}, + }, + 'arrowPrev': { + selector: '[arrow-prev]', + props: {retarget: true}, + }, 'children': '*', }, }); diff --git a/src/amp-react-carousel.js b/src/amp-react-carousel.js index edf7bd1..9400a0a 100644 --- a/src/amp-react-carousel.js +++ b/src/amp-react-carousel.js @@ -217,8 +217,14 @@ const AmpReactCarousel = ReactCompatibleBaseElement(AmpCarousel, { }, }, children: { - 'arrowNext': '[arrow-next]', - 'arrowPrev': '[arrow-prev]', + 'arrowNext': { + selector: '[arrow-next]', + props: {retarget: true}, + }, + 'arrowPrev': { + selector: '[arrow-prev]', + props: {retarget: true}, + }, 'children': '*', }, }); diff --git a/src/react-compat-base-element.js b/src/react-compat-base-element.js index 11280d8..45939b0 100644 --- a/src/react-compat-base-element.js +++ b/src/react-compat-base-element.js @@ -17,6 +17,11 @@ import AmpElementFactory from './amp-element.js'; import devAssert from './dev-assert.js'; +const { + useEffect, + useRef, +} = React; + /** * ReactCompatibleBaseElement is a compatibility wrapper around AMP's * BaseElement. It takes a Component to compose, and calls renders the @@ -486,8 +491,12 @@ function collectProps(element, opts) { (props[match] || (props[match] = [])); const slot = `i-amphtml-${match}-${list.length}`; childElement.setAttribute('slot', slot); - const child = React.createElement('slot', {name: slot}); - list.push(child); + const def = opts.children[match]; + const slotProps = Object.assign( + {name: slot}, + typeof def == 'object' && def.props || {} + ); + list.push(React.createElement(Slot, slotProps)); } props.children = children; } @@ -509,8 +518,9 @@ function matchChild(element, defs) { } // TBD: a little slow to do this repeatedly. for (const match in defs) { - const expr = defs[match]; - if (element.matches(expr)) { + const def = defs[match]; + const selector = typeof def == 'string' ? def : def.selector; + if (element.matches(selector)) { return match; } } @@ -540,3 +550,54 @@ function toUpperCase(_match, character) { function dashToCamelCase(name) { return name.replace(/-([a-z])/g, toUpperCase); } + +function Slot(props) { + const ref = useRef(); + const slotProps = Object.assign({}, props, {ref}); + useEffect(() => { + const slot = ref.current; + + // Retarget slots and content. + if (props.retarget) { + // TBD: retargetting here is for: + // 1. `disabled` doesn't apply inside subtrees. This makes it more like + // `hidden`. Similarly do other attributes. + // 2. Re-propagate click events to slots since React stops propagation. + // See https://github.com/facebook/react/issues/9242. + slot.assignedNodes().forEach(node => { + // Basic attributes: + const { attributes } = slot; + for (let i = 0, l = attributes.length; i < l; i++) { + const { name, value } = attributes[i]; + if (name == 'name') { + // This is the slot's name. + } else if (!node.hasAttribute(name)) { + // TBD: this means that attributes can be rendered only once? + // TBD: what do we do with style and class? + node.setAttribute(name, value); + } + } + // Boolean attributes: + node.disabled = slot.hasAttribute('disabled'); + node.hidden = slot.hasAttribute('hidden'); + if (!node['i-amphtml-event-distr']) { + node['i-amphtml-event-distr'] = true; + node.addEventListener('click', e => { + // Stop propagation on the original event to avoid deliving this + // event twice with frameworks that correctly work with composed + // boundaries. + e.stopPropagation(); + e.preventDefault(); + const event = new Event('click', { + bubbles: true, + cancelable: true, + composed: false, + }); + slot.dispatchEvent(event); + }); + } + }); + } + }); + return React.createElement('slot', slotProps); +}