@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
JavaScript
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