UNPKG

@stories-js/react

Version:

React Stories renderer and wrapper for custom elements of Stories to be used as first-class React components

334 lines (324 loc) 15 kB
import { defineCustomElements } from '@stories-js/core/loader'; import { __rest } from 'tslib'; import React, { createElement } from 'react'; const dashToPascalCase = (str) => str .toLowerCase() .split('-') .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(''); const camelToDashCase = (str) => str.replace(/([A-Z])/g, (m) => `-${m[0].toLowerCase()}`); const attachProps = (node, newProps, oldProps = {}) => { // some test frameworks don't render DOM elements, so we test here to make sure we are dealing with DOM first if (node instanceof Element) { // add any classes in className to the class list const className = getClassName(node.classList, newProps, oldProps); if (className !== '') { node.className = className; } Object.keys(newProps).forEach((name) => { if (name === 'children' || name === 'style' || name === 'ref' || name === 'class' || name === 'className' || name === 'forwardedRef') { return; } if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) { const eventName = name.substring(2); const eventNameLc = eventName[0].toLowerCase() + eventName.substring(1); if (!isCoveredByReact(eventNameLc)) { syncEvent(node, eventNameLc, newProps[name]); } } else { node[name] = newProps[name]; const propType = typeof newProps[name]; if (propType === 'string') { node.setAttribute(camelToDashCase(name), newProps[name]); } } }); } }; const getClassName = (classList, newProps, oldProps) => { const newClassProp = newProps.className || newProps.class; const oldClassProp = oldProps.className || oldProps.class; // map the classes to Maps for performance const currentClasses = arrayToMap(classList); const incomingPropClasses = arrayToMap(newClassProp ? newClassProp.split(' ') : []); const oldPropClasses = arrayToMap(oldClassProp ? oldClassProp.split(' ') : []); const finalClassNames = []; // loop through each of the current classes on the component // to see if it should be a part of the classNames added currentClasses.forEach((currentClass) => { if (incomingPropClasses.has(currentClass)) { // add it as its already included in classnames coming in from newProps finalClassNames.push(currentClass); incomingPropClasses.delete(currentClass); } else if (!oldPropClasses.has(currentClass)) { // add it as it has NOT been removed by user finalClassNames.push(currentClass); } }); incomingPropClasses.forEach((s) => finalClassNames.push(s)); return finalClassNames.join(' '); }; /** * Checks if an event is supported in the current execution environment. * @license Modernizr 3.0.0pre (Custom Build) | MIT */ const isCoveredByReact = (eventNameSuffix) => { if (typeof document === 'undefined') { return true; } else { const eventName = 'on' + eventNameSuffix; let isSupported = eventName in document; if (!isSupported) { const element = document.createElement('div'); element.setAttribute(eventName, 'return;'); isSupported = typeof element[eventName] === 'function'; } return isSupported; } }; const syncEvent = (node, eventName, newEventHandler) => { const eventStore = node.__events || (node.__events = {}); const oldEventHandler = eventStore[eventName]; // Remove old listener so they don't double up. if (oldEventHandler) { node.removeEventListener(eventName, oldEventHandler); } // Bind new listener. node.addEventListener(eventName, (eventStore[eventName] = function handler(e) { if (newEventHandler) { newEventHandler.call(this, e); } })); }; const arrayToMap = (arr) => { const map = new Map(); arr.forEach((s) => map.set(s, s)); return map; }; const setRef = (ref, value) => { if (typeof ref === 'function') { ref(value); } else if (ref != null) { // Cast as a MutableRef so we can assign current ref.current = value; } }; const mergeRefs = (...refs) => { return (value) => { refs.forEach(ref => { setRef(ref, value); }); }; }; const createForwardRef = (ReactComponent, displayName) => { const forwardRef = (props, ref) => { return React.createElement(ReactComponent, Object.assign({}, props, { forwardedRef: ref })); }; forwardRef.displayName = displayName; return React.forwardRef(forwardRef); }; const createReactComponent = (tagName, ReactComponentContext, manipulatePropsFunction, defineCustomElement) => { if (defineCustomElement !== undefined) { defineCustomElement(); } const displayName = dashToPascalCase(tagName); const ReactComponent = class extends React.Component { constructor(props) { super(props); this.setComponentElRef = (element) => { this.componentEl = element; }; } componentDidMount() { this.componentDidUpdate(this.props); } componentDidUpdate(prevProps) { attachProps(this.componentEl, this.props, prevProps); } render() { const _a = this.props, { children, forwardedRef, style, className, ref } = _a, cProps = __rest(_a, ["children", "forwardedRef", "style", "className", "ref"]); let propsToPass = Object.keys(cProps).reduce((acc, name) => { const value = cProps[name]; if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) { const eventName = name.substring(2).toLowerCase(); if (typeof document !== 'undefined' && isCoveredByReact(eventName)) { acc[name] = value; } } else { // we should only render strings, booleans, and numbers as attrs in html. // objects, functions, arrays etc get synced via properties on mount. const type = typeof value; if (type === 'string' || type === 'boolean' || type === 'number') { acc[camelToDashCase(name)] = value; } } return acc; }, {}); if (manipulatePropsFunction) { propsToPass = manipulatePropsFunction(this.props, propsToPass); } const newProps = Object.assign(Object.assign({}, propsToPass), { ref: mergeRefs(forwardedRef, this.setComponentElRef), style }); /** * We use createElement here instead of * React.createElement to work around a * bug in Vite (https://github.com/vitejs/vite/issues/6104). * React.createElement causes all elements to be rendered * as <tagname> instead of the actual Web Component. */ return createElement(tagName, newProps, children); } static get displayName() { return displayName; } }; // If context was passed to createReactComponent then conditionally add it to the Component Class if (ReactComponentContext) { ReactComponent.contextType = ReactComponentContext; } return createForwardRef(ReactComponent, displayName); }; /* eslint-disable */ defineCustomElements(); const StoriesAddonActions = /*@__PURE__*/ createReactComponent('stories-addon-actions'); const StoriesAddonControls = /*@__PURE__*/ createReactComponent('stories-addon-controls'); const StoriesAddons = /*@__PURE__*/ createReactComponent('stories-addons'); const StoriesApp = /*@__PURE__*/ createReactComponent('stories-app'); const StoriesBadge = /*@__PURE__*/ createReactComponent('stories-badge'); const StoriesButton = /*@__PURE__*/ createReactComponent('stories-button'); const StoriesButtons = /*@__PURE__*/ createReactComponent('stories-buttons'); const StoriesCheckbox = /*@__PURE__*/ createReactComponent('stories-checkbox'); const StoriesCol = /*@__PURE__*/ createReactComponent('stories-col'); const StoriesFooter = /*@__PURE__*/ createReactComponent('stories-footer'); const StoriesGrid = /*@__PURE__*/ createReactComponent('stories-grid'); const StoriesIcon = /*@__PURE__*/ createReactComponent('stories-icon'); const StoriesInput = /*@__PURE__*/ createReactComponent('stories-input'); const StoriesLabel = /*@__PURE__*/ createReactComponent('stories-label'); const StoriesPreview = /*@__PURE__*/ createReactComponent('stories-preview'); const StoriesRouter = /*@__PURE__*/ createReactComponent('stories-router'); const StoriesRow = /*@__PURE__*/ createReactComponent('stories-row'); const StoriesSearchbar = /*@__PURE__*/ createReactComponent('stories-searchbar'); const StoriesSidebar = /*@__PURE__*/ createReactComponent('stories-sidebar'); const StoriesSplitPane = /*@__PURE__*/ createReactComponent('stories-split-pane'); const StoriesTab = /*@__PURE__*/ createReactComponent('stories-tab'); const StoriesTabBar = /*@__PURE__*/ createReactComponent('stories-tab-bar'); const StoriesTabButton = /*@__PURE__*/ createReactComponent('stories-tab-button'); const StoriesTabs = /*@__PURE__*/ createReactComponent('stories-tabs'); const StoriesToolBar = /*@__PURE__*/ createReactComponent('stories-tool-bar'); const StoriesToolButton = /*@__PURE__*/ createReactComponent('stories-tool-button'); const StoriesToolZoom = /*@__PURE__*/ createReactComponent('stories-tool-zoom'); const StoriesZoom = /*@__PURE__*/ createReactComponent('stories-zoom'); /* eslint-disable @typescript-eslint/array-type */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ /** * Performs a rest spread on an object. * * @param source The source value. * @param propertyNames The property names excluded from the rest spread. */ function rest(source, propertyNames) { const result = {}; for (const p in source) if (Object.prototype.hasOwnProperty.call(source, p) && propertyNames.indexOf(p) < 0) result[p] = source[p]; if (source != null && typeof Object.getOwnPropertySymbols === "function") for (let i = 0, p = Object.getOwnPropertySymbols(source); i < p.length; i++) { if (propertyNames.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(source, p[i])) result[p[i]] = source[p[i]]; } return result; } /** * Currently StoryContextUpdates are allowed to have any key in the type. * However, you cannot overwrite any of the build-it "static" keys. * * @param inputContextUpdate StoryContextUpdate * @returns StoryContextUpdate */ function sanitizeStoryContextUpdate(inputContextUpdate) { return rest(inputContextUpdate, ["storyId", "kinds", "storyName", "storyFn", "component", "subcomponents", "decorators", "args", "argTypes"]); } function decorateStory(storyFn, decorator, bindWithContext) { // Bind the partially decorated storyFn so that when it is called it always knows about the story context, // no matter what it is passed directly. This is because we cannot guarantee a decorator will // pass the context down to the next decorated story in the chain. const boundStoryFunction = bindWithContext(storyFn); return (context) => decorator(boundStoryFunction, context); } function applyDecorators(storyFn, decorators) { // We use a trick to avoid recreating the bound story function inside `decorateStory`. // Instead we pass it a context "getter", which is defined once (at "decoration time") // The getter reads a variable which is scoped to this call of `decorateStory` // (ie to this story), so there is no possibility of overlap. // This will break if you call the same story twice interleaved // (React might do it if you rendered the same story twice in the one ReactDom.render call, for instance) const contextStore = {}; /** * When you call the story function inside a decorator, e.g.: * * ```jsx * <div>{storyFn({ foo: 'bar' })}</div> * ``` * * This will override the `foo` property on the `innerContext`, which gets * merged in with the default context */ const bindWithContext = (decoratedStoryFn) => (context) => { // console.log('*** bindWithContext', context) contextStore.value = Object.assign(Object.assign({}, contextStore.value), sanitizeStoryContextUpdate(context)); // console.log('*** contextStore.value', contextStore.value) return decoratedStoryFn(contextStore.value); }; const decoratedWithContextStore = decorators.reduce((story, decorator) => decorateStory(story, decorator, bindWithContext), storyFn); return (context) => { contextStore.value = context; return decoratedWithContextStore(context); // Pass the context directly into the first decorator }; } function executeFnStore(fn) { return (conext) => { const result = fn(conext.args); // console.log('*** executeFnStore', conext.args, result); return result; }; } function executeFnDecor(fn) { return (comp, conext) => { const result = fn(comp, conext); // console.log('*** executeFnDecor', comp, conext, result); return result; }; } function prepareToRender(storyFn, decorators) { const decorated = applyDecorators(executeFnStore(storyFn), decorators.map((decorator) => executeFnDecor(decorator))); return (context) => { // console.log('*** prepareToRender', context); return decorated(context); }; } function prepareStory(story) { const decorators = story.decorators || []; const storyFn = story.storyFn; return prepareToRender(storyFn, decorators); } /* eslint-disable @typescript-eslint/no-explicit-any */ const EMPTY = React.createElement(React.Fragment, null); const StoriesReactRenderer = ({ story, context }) => { if (story && context) { const decoratedStory = prepareStory(story); return decoratedStory(context || {}); } return EMPTY; }; export { StoriesAddonActions, StoriesAddonControls, StoriesAddons, StoriesApp, StoriesBadge, StoriesButton, StoriesButtons, StoriesCheckbox, StoriesCol, StoriesFooter, StoriesGrid, StoriesIcon, StoriesInput, StoriesLabel, StoriesPreview, StoriesReactRenderer, StoriesRouter, StoriesRow, StoriesSearchbar, StoriesSidebar, StoriesSplitPane, StoriesTab, StoriesTabBar, StoriesTabButton, StoriesTabs, StoriesToolBar, StoriesToolButton, StoriesToolZoom, StoriesZoom }; //# sourceMappingURL=index.esm.js.map