Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shadow root support #521

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/drag-drop-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface Props extends Responders {
- `sensors`: Used to pass in your own `sensor`s for a `<DragDropContext />`. 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 `<DragDropContext />`. 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

Expand Down
34 changes: 30 additions & 4 deletions src/query-selector-all.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion src/view/drag-drop-context/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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 => {
Expand Down
3 changes: 3 additions & 0 deletions src/view/drag-drop-context/drag-drop-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,6 +68,7 @@ export default function DragDropContext(props: DragDropContextProps) {
onDragUpdate={props.onDragUpdate}
onDragEnd={props.onDragEnd}
autoScrollerOptions={props.autoScrollerOptions}
stylesInsertionPoint={props.stylesInsertionPoint}
>
{props.children}
</App>
Expand Down
2 changes: 1 addition & 1 deletion src/view/draggable/get-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/view/draggable/use-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
}
Expand Down
20 changes: 9 additions & 11 deletions src/view/get-elements/find-drag-handle.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
15 changes: 9 additions & 6 deletions src/view/get-elements/find-draggable.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
18 changes: 17 additions & 1 deletion src/view/use-sensor-marshal/closest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,27 @@ 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);
}
// 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/view/use-sensor-marshal/use-sensor-marshal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion src/view/use-style-marshal/get-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 11 additions & 9 deletions src/view/use-style-marshal/use-style-marshal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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<HTMLStyleElement | null>(null);
const dynamicRef = useRef<HTMLStyleElement | null>(null);
Expand Down Expand Up @@ -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);
Expand All @@ -76,7 +77,7 @@ export default function useStyleMarshal(contextId: ContextId, nonce?: string) {
const remove = (ref: MutableRefObject<HTMLStyleElement | null>) => {
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;
};

Expand All @@ -90,6 +91,7 @@ export default function useStyleMarshal(contextId: ContextId, nonce?: string) {
styles.always,
styles.resting,
contextId,
stylesInsertionPoint
]);

const dragging = useCallback(
Expand Down
50 changes: 50 additions & 0 deletions stories/examples/70-shadow-roots.stories.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ShadowRootContext.Consumer>
{(stylesRoot) => (<Simple stylesRoot={stylesRoot}/>)}
</ShadowRootContext.Consumer>))
.add('Super Simple - vertical list (nested shadow root)', () => inNestedShadowRoot(
<ShadowRootContext.Consumer>
{(stylesRoot) => (<Simple stylesRoot={stylesRoot}/>)}
</ShadowRootContext.Consumer>)
)
.add('Super Simple - vertical list with scroll (overflow: auto)', () => inShadowRoot(
<ShadowRootContext.Consumer>
{(stylesRoot) => (<SimpleWithScroll overflow="auto" stylesRoot={stylesRoot}/>)}
</ShadowRootContext.Consumer>)
)
.add('Super Simple - vertical list with scroll (overflow: scroll)', () => inShadowRoot(
<ShadowRootContext.Consumer>
{(stylesRoot) => (<SimpleWithScroll overflow="scroll" stylesRoot={stylesRoot}/>)}
</ShadowRootContext.Consumer>)
)
.add('Super Simple - with mixed spacing', () => inShadowRoot(
<ShadowRootContext.Consumer>
{(stylesRoot) => (<WithMixedSpacing stylesRoot={stylesRoot}/>)}
</ShadowRootContext.Consumer>)
)
.add('nested interactive elements - stress test (without styles)', () => inShadowRoot(
<ShadowRootContext.Consumer>
{(stylesRoot) => (<InteractiveElementsApp stylesRoot={stylesRoot}/>)}
</ShadowRootContext.Consumer>)
)
.add(
'Super Simple - vertical list (with draggables containing shadowRoots)', () =>
<ShadowRootContext.Consumer>
{(stylesRoot) => (<SimpleWithShadowRoot stylesRoot={stylesRoot} />)}
</ShadowRootContext.Consumer>
);
8 changes: 6 additions & 2 deletions stories/src/interactive-elements/interactive-elements-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,17 @@ const Status = styled.strong<StatusProps>`
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 = {
Expand Down Expand Up @@ -209,7 +213,7 @@ export default class InteractiveElementsApp extends React.Component<
const { canDragInteractiveElements } = this.state;

return (
<DragDropContext onDragEnd={this.onDragEnd}>
<DragDropContext onDragEnd={this.onDragEnd} stylesInsertionPoint={this.props.stylesRoot}>
<Container>
<Droppable droppableId="droppable">
{(droppableProvided: DroppableProvided) => (
Expand Down
Loading