diff --git a/docs/api/drag-drop-context.md b/docs/api/drag-drop-context.md index 09d7a3db9..4425c4acc 100644 --- a/docs/api/drag-drop-context.md +++ b/docs/api/drag-drop-context.md @@ -39,6 +39,7 @@ interface Props extends Responders { - `sensors`: Used to pass in your own `sensor`s for a ``. See our [sensor api documentation](/docs/sensors/sensor-api.md) - `enableDefaultSensors`: Whether or not the default sensors ([mouse](/docs/sensors/mouse.md), [keyboard](/docs/sensors/keyboard.md), and [touch](/docs/sensors/touch.md)) are enabled. You can also import them separately as `useMouseSensor`, `useKeyboardSensor`, or `useTouchSensor` and reuse just some of them via `sensors` prop. See our [sensor api documentation](/docs/sensors/sensor-api.md) - `autoScrollerOptions`: An object whose several (optional) properties allow the user to configure the auto-scroll behavior. A simple example is `{ disabled: true }`, which turns off auto scrolling entirely for that ``. See our [Auto scrolling documentation](/docs/guides/auto-scrolling.md) +- `stylesInsertionPoint`: Specify the DOM node where to append styles. This is useful when used inside shadowRoots like web components. If not specified it will use document's head. > See our [type guide](/docs/guides/types.md) for more details diff --git a/src/query-selector-all.ts b/src/query-selector-all.ts index 80f0aea5c..b9701eae7 100644 --- a/src/query-selector-all.ts +++ b/src/query-selector-all.ts @@ -1,6 +1,32 @@ -export function querySelectorAll( - parentNode: ParentNode, +export function getEventTarget(event: Event): EventTarget { + const target = event.composedPath && event.composedPath()[0]; + return target || event.target; +} + +export function getEventTargetRoot(event: Event | null): Node { + const source = event && event.composedPath && event.composedPath()[0]; + const root = source && (source as Element).getRootNode(); + return root || document; +} + +export function queryElements( + ref: Node | null, selector: string, -): HTMLElement[] { - return Array.from(parentNode.querySelectorAll(selector)); + filterFn: (el:Element) => boolean, +): Element | undefined { + const rootNode: any = ref && ref.getRootNode(); + const documentOrShadowRoot: ShadowRoot | Document = rootNode && rootNode.querySelectorAll ? rootNode : document; + const possible = Array.from(documentOrShadowRoot.querySelectorAll(selector)); + const filtered = possible.find(filterFn); + + // in case nothing was found in this document/shadowRoot we recursievly try the parent document(Fragment) given + // by the host property. This is needed in case the the draggable/droppable itself contains a shadow root + if (!filtered && (documentOrShadowRoot as ShadowRoot).host) { + return queryElements( + (documentOrShadowRoot as ShadowRoot).host, + selector, + filterFn, + ); + } + return filtered; } diff --git a/src/view/drag-drop-context/app.tsx b/src/view/drag-drop-context/app.tsx index 617427b88..ab67bf66e 100644 --- a/src/view/drag-drop-context/app.tsx +++ b/src/view/drag-drop-context/app.tsx @@ -70,6 +70,7 @@ export interface Props extends Responders { // options to exert more control over autoScroll // eslint-disable-next-line react/no-unused-prop-types autoScrollerOptions?: PartialAutoScrollerOptions; + stylesInsertionPoint?: HTMLElement|null; } const createResponders = (props: Props): Responders => ({ @@ -141,7 +142,7 @@ export default function App(props: Props) { contextId, text: dragHandleUsageInstructions, }); - const styleMarshal: StyleMarshal = useStyleMarshal(contextId, nonce); + const styleMarshal: StyleMarshal = useStyleMarshal(contextId, nonce, props.stylesInsertionPoint); const lazyDispatch: (a: Action) => void = useCallback( (action: Action): void => { diff --git a/src/view/drag-drop-context/drag-drop-context.tsx b/src/view/drag-drop-context/drag-drop-context.tsx index e1b7a1829..4e7f24f5a 100644 --- a/src/view/drag-drop-context/drag-drop-context.tsx +++ b/src/view/drag-drop-context/drag-drop-context.tsx @@ -26,6 +26,8 @@ export interface DragDropContextProps extends Responders { * Customize auto scroller */ autoScrollerOptions?: PartialAutoScrollerOptions; + // Allows customizing the element to add the stylesheets to e.g. when being used in a ShadowRoot + stylesInsertionPoint?: HTMLElement | null, } // Reset any context that gets persisted across server side renders @@ -66,6 +68,7 @@ export default function DragDropContext(props: DragDropContextProps) { onDragUpdate={props.onDragUpdate} onDragEnd={props.onDragEnd} autoScrollerOptions={props.autoScrollerOptions} + stylesInsertionPoint={props.stylesInsertionPoint} > {props.children} diff --git a/src/view/draggable/get-style.ts b/src/view/draggable/get-style.ts index 5e0b1b751..c7eafa0f7 100644 --- a/src/view/draggable/get-style.ts +++ b/src/view/draggable/get-style.ts @@ -96,7 +96,7 @@ function getDraggingStyle(dragging: DraggingMapProps): DraggingStyle { function getSecondaryStyle(secondary: SecondaryMapProps): NotDraggingStyle { return { transform: transforms.moveTo(secondary.offset), - // transition style is applied in the head + // transition style is applied in the head or stylesInsertionPoint transition: secondary.shouldAnimateDisplacement ? undefined : 'none', }; } diff --git a/src/view/draggable/use-validation.ts b/src/view/draggable/use-validation.ts index a946f5158..5176d5677 100644 --- a/src/view/draggable/use-validation.ts +++ b/src/view/draggable/use-validation.ts @@ -44,7 +44,7 @@ export function useValidation( // When not enabled there is no drag handle props if (props.isEnabled) { invariant( - findDragHandle(contextId, id), + findDragHandle(contextId, id, getRef()), `${prefix(id)} Unable to find drag handle`, ); } diff --git a/src/view/get-elements/find-drag-handle.ts b/src/view/get-elements/find-drag-handle.ts index de81d43a3..7a5f79941 100644 --- a/src/view/get-elements/find-drag-handle.ts +++ b/src/view/get-elements/find-drag-handle.ts @@ -1,25 +1,23 @@ import type { DraggableId, ContextId } from '../../types'; import { dragHandle as dragHandleAttr } from '../data-attributes'; import { warning } from '../../dev-warning'; -import { querySelectorAll } from '../../query-selector-all'; +import { queryElements } from '../../query-selector-all'; import isHtmlElement from '../is-type-of-element/is-html-element'; export default function findDragHandle( contextId: ContextId, draggableId: DraggableId, + ref: HTMLElement | null, ): HTMLElement | null { // cannot create a selector with the draggable id as it might not be a valid attribute selector const selector = `[${dragHandleAttr.contextId}="${contextId}"]`; - const possible = querySelectorAll(document, selector); - - if (!possible.length) { - warning(`Unable to find any drag handles in the context "${contextId}"`); - return null; - } - - const handle = possible.find((el): boolean => { - return el.getAttribute(dragHandleAttr.draggableId) === draggableId; - }); + const handle = queryElements( + ref, + selector, + (el: Element): boolean => { + return el.getAttribute(dragHandleAttr.draggableId) === draggableId; + }, + ); if (!handle) { warning( diff --git a/src/view/get-elements/find-draggable.ts b/src/view/get-elements/find-draggable.ts index d1df0aad2..5acc1a999 100644 --- a/src/view/get-elements/find-draggable.ts +++ b/src/view/get-elements/find-draggable.ts @@ -1,20 +1,23 @@ import type { DraggableId, ContextId } from '../../types'; import * as attributes from '../data-attributes'; -import { querySelectorAll } from '../../query-selector-all'; +import { queryElements } from '../../query-selector-all'; import { warning } from '../../dev-warning'; import isHtmlElement from '../is-type-of-element/is-html-element'; export default function findDraggable( contextId: ContextId, draggableId: DraggableId, + ref: Node ): HTMLElement | null { // cannot create a selector with the draggable id as it might not be a valid attribute selector const selector = `[${attributes.draggable.contextId}="${contextId}"]`; - const possible = querySelectorAll(document, selector); - - const draggable = possible.find((el): boolean => { - return el.getAttribute(attributes.draggable.id) === draggableId; - }); + const draggable = queryElements( + ref, + selector, + (el: Element): boolean => { + return el.getAttribute(attributes.draggable.id) === draggableId; + }, + ); if (!draggable) { return null; diff --git a/src/view/use-sensor-marshal/closest.ts b/src/view/use-sensor-marshal/closest.ts index bea6ae370..bb46ed29a 100644 --- a/src/view/use-sensor-marshal/closest.ts +++ b/src/view/use-sensor-marshal/closest.ts @@ -36,7 +36,7 @@ function closestPonyfill(el: Element | null, selector: string): null | Element { return closestPonyfill(el.parentElement, selector); } -export default function closest(el: Element, selector: string): Element | null { +function closestImpl(el: Element, selector: string): Element | null { // Using native closest for maximum speed where we can if (el.closest) { return el.closest(selector); @@ -44,3 +44,19 @@ export default function closest(el: Element, selector: string): Element | null { // ie11: damn you! return closestPonyfill(el, selector); } + +export default function closest(el: Element, selector: string): Element | null { + // TODO... + // @ts-ignore:next-line + if (!el || el === document || el === window) { + return null; + } + const found = closestImpl(el, selector); + + if (found) { + return found; + } + + const root = el.getRootNode(); + return closest((root as ShadowRoot).host, selector); +} \ No newline at end of file diff --git a/src/view/use-sensor-marshal/find-closest-draggable-id-from-event.ts b/src/view/use-sensor-marshal/find-closest-draggable-id-from-event.ts index a8c7c9b9a..2d696cdae 100644 --- a/src/view/use-sensor-marshal/find-closest-draggable-id-from-event.ts +++ b/src/view/use-sensor-marshal/find-closest-draggable-id-from-event.ts @@ -3,6 +3,7 @@ import * as attributes from '../data-attributes'; import isElement from '../is-type-of-element/is-element'; import isHtmlElement from '../is-type-of-element/is-html-element'; import closest from './closest'; +import { getEventTarget } from '../../query-selector-all'; import { warning } from '../../dev-warning'; function getSelector(contextId: ContextId): string { @@ -13,7 +14,7 @@ function findClosestDragHandleFromEvent( contextId: ContextId, event: Event, ): Element | null { - const target = event.target; + const target = getEventTarget(event); if (!isElement(target)) { warning('event.target must be a Element'); diff --git a/src/view/use-sensor-marshal/is-event-in-interactive-element.ts b/src/view/use-sensor-marshal/is-event-in-interactive-element.ts index db488108d..53c34c81d 100644 --- a/src/view/use-sensor-marshal/is-event-in-interactive-element.ts +++ b/src/view/use-sensor-marshal/is-event-in-interactive-element.ts @@ -1,3 +1,4 @@ +import { getEventTarget } from '../../query-selector-all'; import isHtmlElement from '../is-type-of-element/is-html-element'; export type InteractiveTagNames = typeof interactiveTagNames; @@ -57,7 +58,7 @@ export default function isEventInInteractiveElement( draggable: Element, event: Event, ): boolean { - const target = event.target; + const target = getEventTarget(event); if (!isHtmlElement(target)) { return false; diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.ts b/src/view/use-sensor-marshal/use-sensor-marshal.ts index 3341d134a..211588774 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.ts +++ b/src/view/use-sensor-marshal/use-sensor-marshal.ts @@ -49,6 +49,7 @@ import { noop } from '../../empty'; import findClosestDraggableIdFromEvent from './find-closest-draggable-id-from-event'; import findDraggable from '../get-elements/find-draggable'; import bindEvents from '../event-bindings/bind-events'; +import { getEventTargetRoot } from '../../query-selector-all'; function preventDefault(event: Event) { event.preventDefault(); @@ -173,7 +174,7 @@ function tryStart({ } const entry: DraggableEntry = registry.draggable.getById(draggableId); - const el: HTMLElement | null = findDraggable(contextId, entry.descriptor.id); + const el: HTMLElement | null = findDraggable(contextId, entry.descriptor.id, getEventTargetRoot(sourceEvent)); if (!el) { warning(`Unable to find draggable element with id: ${draggableId}`); diff --git a/src/view/use-style-marshal/get-styles.ts b/src/view/use-style-marshal/get-styles.ts index efa4c714c..d045508d0 100644 --- a/src/view/use-style-marshal/get-styles.ts +++ b/src/view/use-style-marshal/get-styles.ts @@ -143,7 +143,7 @@ export default (contextId: ContextId): Styles => { // we do not want the browser to have behaviors we do not expect const body: Rule = { - selector: 'body', + selector: 'body, :host', styles: { dragging: ` cursor: grabbing; diff --git a/src/view/use-style-marshal/use-style-marshal.ts b/src/view/use-style-marshal/use-style-marshal.ts index 7b2e64703..c32e4056c 100644 --- a/src/view/use-style-marshal/use-style-marshal.ts +++ b/src/view/use-style-marshal/use-style-marshal.ts @@ -9,10 +9,10 @@ import type { Styles } from './get-styles'; import { prefix } from '../data-attributes'; import useLayoutEffect from '../use-isomorphic-layout-effect'; -const getHead = (): HTMLHeadElement => { - const head: HTMLHeadElement | null = document.querySelector('head'); - invariant(head, 'Cannot find the head to append a style to'); - return head; +const getStylesRoot = (stylesInsertionPoint?: HTMLElement|null): HTMLElement => { + const stylesRoot = stylesInsertionPoint || document.querySelector('head'); + invariant(stylesRoot, 'Cannot find the head or root to append a style to'); + return stylesRoot; }; const createStyleEl = (nonce?: string): HTMLStyleElement => { @@ -24,7 +24,7 @@ const createStyleEl = (nonce?: string): HTMLStyleElement => { return el; }; -export default function useStyleMarshal(contextId: ContextId, nonce?: string) { +export default function useStyleMarshal(contextId: ContextId, nonce?: string, stylesInsertionPoint?: HTMLElement|null) { const styles: Styles = useMemo(() => getStyles(contextId), [contextId]); const alwaysRef = useRef(null); const dynamicRef = useRef(null); @@ -64,9 +64,10 @@ export default function useStyleMarshal(contextId: ContextId, nonce?: string) { always.setAttribute(`${prefix}-always`, contextId); dynamic.setAttribute(`${prefix}-dynamic`, contextId); - // add style tags to head - getHead().appendChild(always); - getHead().appendChild(dynamic); + // add style tags to styles root + const stylesRoot = getStylesRoot(stylesInsertionPoint); + stylesRoot.appendChild(always); + stylesRoot.appendChild(dynamic); // set initial style setAlwaysStyle(styles.always); @@ -76,7 +77,7 @@ export default function useStyleMarshal(contextId: ContextId, nonce?: string) { const remove = (ref: MutableRefObject) => { const current: HTMLStyleElement | null = ref.current; invariant(current, 'Cannot unmount ref as it is not set'); - getHead().removeChild(current); + stylesRoot.removeChild(current); ref.current = null; }; @@ -90,6 +91,7 @@ export default function useStyleMarshal(contextId: ContextId, nonce?: string) { styles.always, styles.resting, contextId, + stylesInsertionPoint ]); const dragging = useCallback( diff --git a/stories/examples/70-shadow-roots.stories.tsx b/stories/examples/70-shadow-roots.stories.tsx new file mode 100644 index 000000000..0a8d3cd0f --- /dev/null +++ b/stories/examples/70-shadow-roots.stories.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; + +import Simple from '../src/simple/simple'; +import SimpleWithScroll from '../src/simple/simple-scrollable'; +import WithMixedSpacing from '../src/simple/simple-mixed-spacing'; +import { + inShadowRoot, + inNestedShadowRoot, + ShadowRootContext +} from '../src/shadow-root/inside-shadow-root'; +import SimpleWithShadowRoot from '../src/shadow-root/simple-with-shadow-root'; +import InteractiveElementsApp from '../src/interactive-elements/interactive-elements-app'; + +storiesOf('Examples/Shadow Root', module) + .add('Super Simple - vertical list', () => inShadowRoot( + + {(stylesRoot) => ()} + )) + .add('Super Simple - vertical list (nested shadow root)', () => inNestedShadowRoot( + + {(stylesRoot) => ()} + ) + ) + .add('Super Simple - vertical list with scroll (overflow: auto)', () => inShadowRoot( + + {(stylesRoot) => ()} + ) + ) + .add('Super Simple - vertical list with scroll (overflow: scroll)', () => inShadowRoot( + + {(stylesRoot) => ()} + ) + ) + .add('Super Simple - with mixed spacing', () => inShadowRoot( + + {(stylesRoot) => ()} + ) + ) + .add('nested interactive elements - stress test (without styles)', () => inShadowRoot( + + {(stylesRoot) => ()} + ) + ) + .add( + 'Super Simple - vertical list (with draggables containing shadowRoots)', () => + + {(stylesRoot) => ()} + + ); \ No newline at end of file diff --git a/stories/src/interactive-elements/interactive-elements-app.tsx b/stories/src/interactive-elements/interactive-elements-app.tsx index 9459f70fa..ecf9fbb24 100644 --- a/stories/src/interactive-elements/interactive-elements-app.tsx +++ b/stories/src/interactive-elements/interactive-elements-app.tsx @@ -165,13 +165,17 @@ const Status = styled.strong` color: ${({ isEnabled }) => (isEnabled ? colors.B200 : colors.P100)}; `; +interface Props { + stylesRoot?: HTMLElement | null; +} + interface State { canDragInteractiveElements: boolean; items: ItemType[]; } export default class InteractiveElementsApp extends React.Component< - unknown, + Props, State > { state: State = { @@ -209,7 +213,7 @@ export default class InteractiveElementsApp extends React.Component< const { canDragInteractiveElements } = this.state; return ( - + {(droppableProvided: DroppableProvided) => ( diff --git a/stories/src/shadow-root/inside-shadow-root.tsx b/stories/src/shadow-root/inside-shadow-root.tsx new file mode 100644 index 000000000..98c6db9ad --- /dev/null +++ b/stories/src/shadow-root/inside-shadow-root.tsx @@ -0,0 +1,108 @@ +import React, { ReactNode } from 'react'; +import ReactDOM from 'react-dom'; + +// TODO... +// import retargetEvents from 'react-shadow-dom-retarget-events'; + +export const ShadowRootContext = React.createContext(null); + +class MyCustomElement extends HTMLElement { + content: ReactNode; + root: ShadowRoot; + appContainer: HTMLElement; + + mountComponent() { + if (!this.appContainer) { + this.root = this.attachShadow({ mode: 'open' }); + this.appContainer = document.createElement('div'); + this.root.appendChild(this.appContainer); + } + + if (this.content) { + ReactDOM.render( + + {this.content} + , + this.appContainer, + ); + + // needed for React versions before 17 + // TODO... + // retargetEvents(this.root); + } + } + + unmountComponent() { + if (this.appContainer) { + ReactDOM.unmountComponentAtNode(this.appContainer); + } + } + + setContent(content: ReactNode) { + this.content = content; + this.updateComponent(); + } + + updateComponent() { + this.unmountComponent(); + this.mountComponent(); + } + + connectedCallback() { + this.mountComponent(); + } + + disconnectedCallback() { + this.unmountComponent(); + } +} + +customElements.define('my-custom-element', MyCustomElement); + +class CompoundCustomElement extends HTMLElement { + childComponent: MyCustomElement; + root: ShadowRoot; + + constructor() { + super(); + this.root = this.attachShadow({ mode: 'open' }); + this.childComponent = document.createElement('my-custom-element') as MyCustomElement; + this.root.appendChild(this.childComponent); + } +} + +customElements.define('compound-custom-element', CompoundCustomElement); + +declare global { + module JSX { + interface IntrinsicElements { + // TODO... any + "my-custom-element": any, + "compound-custom-element": any + } + } +} + +export function inShadowRoot(child: ReactNode) { + return ( + { + if (node) { + node.setContent(child); + } + }} + /> + ); +} + +export function inNestedShadowRoot(child: ReactNode) { + return ( + { + if (node) { + node.childComponent.setContent(child); + } + }} + /> + ); +} \ No newline at end of file diff --git a/stories/src/shadow-root/simple-with-shadow-root.tsx b/stories/src/shadow-root/simple-with-shadow-root.tsx new file mode 100644 index 000000000..d1555cc9c --- /dev/null +++ b/stories/src/shadow-root/simple-with-shadow-root.tsx @@ -0,0 +1,155 @@ +import React, { Component } from 'react'; +import { + DragDropContext, + Droppable, + Draggable, + DraggableStyle, + DropResult, +} from '@hello-pangea/dnd'; + +// fake data generator +const getItems = (count: number) => + Array.from({ length: count }, (v, k) => k).map((k) => ({ + id: `item-${k}`, + content: `item ${k}`, + })); + +// a little function to help us with reordering the result +const reorder = ( + list: TList, + startIndex: number, + endIndex: number, +): TList => { + const result = Array.from(list) as TList; + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +}; + +const grid = 8; + +const getItemStyle = ( + isDragging: boolean, + draggableStyle: DraggableStyle = {}, +) => ({ + display: 'block', + // some basic styles to make the items look a bit nicer + userSelect: 'none' as const, + padding: grid * 2, + margin: `0 0 ${grid}px 0`, + + // change background colour if dragging + background: isDragging ? 'lightgreen' : 'red', + + // styles we need to apply on draggables + ...draggableStyle, +}); + +const getListStyle = (isDraggingOver: boolean) => ({ + background: isDraggingOver ? 'lightblue' : 'grey', + padding: grid, + width: 250, +}); + +class SomeCustomElement extends HTMLElement { + childComponent; + + constructor() { + super(); + const root = this.attachShadow({ mode: 'open' }); + this.childComponent = document.createElement('div'); + root.appendChild(this.childComponent); + } + + connectedCallback() { + this.childComponent.textContent = this.getAttribute('content'); + } +} + +customElements.define('some-custom-element', SomeCustomElement); + +declare global { + module JSX { + interface IntrinsicElements { + // TODO... any + "some-custom-element": any + } + } +} + +interface AppProps { + stylesRoot?: HTMLElement | null; +} + +interface Item { + id: string; + content: string; +} + +interface AppState { + items: Item[]; +} + +export default class App extends Component { + constructor(props: AppProps) { + super(props); + this.state = { + items: getItems(10), + }; + this.onDragEnd = this.onDragEnd.bind(this); + } + + onDragEnd(result: DropResult) { + // dropped outside the list + if (!result.destination) { + return; + } + + const items = reorder( + this.state.items, + result.source.index, + result.destination.index, + ); + + this.setState({ + items, + }); + } + + // Normally you would want to split things out into separate components. + // But in this example everything is just done in one place for simplicity + render() { + return ( + + + {(droppableProvided, droppableSnapshot) => ( +
+ {this.state.items.map((item, index) => ( + + {(draggableProvided, draggableSnapshot) => ( + + {item.content} + + )} + + ))} + {droppableProvided.placeholder} +
+ )} +
+
+ ); + } +} diff --git a/stories/src/simple/simple-mixed-spacing.tsx b/stories/src/simple/simple-mixed-spacing.tsx index 135afc14e..30656cfad 100644 --- a/stories/src/simple/simple-mixed-spacing.tsx +++ b/stories/src/simple/simple-mixed-spacing.tsx @@ -6,6 +6,7 @@ import { DraggableStyle, DropResult, } from '@hello-pangea/dnd'; +import { ShadowRootContext } from '../shadow-root/inside-shadow-root'; // fake data generator const getItems = (count: number) => @@ -63,7 +64,9 @@ const getItemStyle = ( ...draggableStyle, }); -interface AppProps {} +interface AppProps { + stylesRoot?: HTMLElement | null; +} interface Item { id: string; @@ -104,7 +107,7 @@ export default class App extends Component { // But in this example everything is just done in one place for simplicity render() { return ( - + {(droppableProvided) => (
({ interface AppProps { overflow?: string; + stylesRoot?: HTMLElement | null; } interface Item { @@ -103,7 +104,7 @@ export default class App extends Component { // But in this example everything is just done in one place for simplicity render() { return ( - + {(droppableProvided, droppableSnapshot) => (
({ width: 250, }); -interface AppProps {} +interface AppProps { + stylesRoot?: HTMLElement | null; +} interface Item { id: string; @@ -92,7 +94,7 @@ export default class App extends Component { // But in this example everything is just done in one place for simplicity render() { return ( - + {(droppableProvided, droppableSnapshot) => (