@jupyterlab/ui-components
Version:
JupyterLab - UI components written in React
657 lines • 25.9 kB
JavaScript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { UUID } from '@lumino/coreutils';
import { Signal } from '@lumino/signaling';
import React from 'react';
import { createRoot } from 'react-dom/client';
import badSvgstr from '../../style/debug/bad.svg';
import blankSvgstr from '../../style/debug/blank.svg';
import refreshSvgstr from '../../style/icons/toolbar/refresh.svg';
import { LabIconStyle } from '../style';
import { classes, getReactAttrs } from '../utils';
export class LabIcon {
/** *********
* statics *
***********/
/**
* Remove any rendered icon from the element that contains it
*
* @param container - a DOM node into which an icon was
* previously rendered
*
* @returns the cleaned container
*/
static remove(container) {
// clean up all children
while (container.firstChild) {
container.firstChild.remove();
}
// remove all classes
container.className = '';
return container;
}
/**
* Resolve an icon name or a \{name, svgstr\} pair into an
* actual LabIcon.
*
* @param options - icon: either a string with the name of an existing icon
* or an object with \{name: string, svgstr: string\} fields.
*
* @returns a LabIcon instance
*/
static resolve({ icon }) {
if (icon instanceof LabIcon) {
// icon already is a LabIcon; nothing to do here
return icon;
}
if (typeof icon === 'string') {
// do a dynamic lookup of existing icon by name
const resolved = LabIcon._instances.get(icon);
if (resolved) {
return resolved;
}
// lookup failed
if (LabIcon._debug) {
// fail noisily
console.warn(`Lookup failed for icon, creating loading icon. icon: ${icon}`);
}
// no matching icon currently registered, create a new loading icon
// TODO: find better icon (maybe animate?) for loading icon
return new LabIcon({ name: icon, svgstr: refreshSvgstr, _loading: true });
}
// icon was provided as a non-LabIcon \{name, svgstr\} pair, communicating
// an intention to create a new icon
return new LabIcon(icon);
}
/**
* Resolve an icon name or a \{name, svgstr\} pair into a DOM element.
* If icon arg is undefined, the function will fall back to trying to render
* the icon as a CSS background image, via the iconClass arg.
* If both icon and iconClass are undefined, this function will return
* an empty div.
*
* @param icon - optional, either a string with the name of an existing icon
* or an object with \{name: string, svgstr: string\} fields
*
* @param iconClass - optional, if the icon arg is not set, the iconClass arg
* should be a CSS class associated with an existing CSS background-image
*
* @param fallback - DEPRECATED, optional, a LabIcon instance that will
* be used if neither icon nor iconClass are defined
*
* @param props - any additional args are passed though to the element method
* of the resolved icon on render
*
* @returns a DOM node with the resolved icon rendered into it
*/
static resolveElement({ icon, iconClass, fallback, ...props }) {
if (!Private.isResolvable(icon)) {
if (!iconClass && fallback) {
// if neither icon nor iconClass are defined/resolvable, use fallback
return fallback.element(props);
}
// set the icon's class to iconClass plus props.className
props.className = classes(iconClass, props.className);
// render icon as css background image, assuming one is set on iconClass
return Private.blankElement(props);
}
return LabIcon.resolve({ icon }).element(props);
}
/**
* Resolve an icon name or a \{name, svgstr\} pair into a React component.
* If icon arg is undefined, the function will fall back to trying to render
* the icon as a CSS background image, via the iconClass arg.
* If both icon and iconClass are undefined, the returned component
* will simply render an empty div.
*
* @param icon - optional, either a string with the name of an existing icon
* or an object with \{name: string, svgstr: string\} fields
*
* @param iconClass - optional, if the icon arg is not set, the iconClass arg
* should be a CSS class associated with an existing CSS background-image
*
* @param fallback - DEPRECATED, optional, a LabIcon instance that will
* be used if neither icon nor iconClass are defined
*
* @param props - any additional args are passed though to the React component
* of the resolved icon on render
*
* @returns a React component that will render the resolved icon
*/
static resolveReact({ icon, iconClass, fallback, ...props }) {
if (!Private.isResolvable(icon)) {
if (!iconClass && fallback) {
// if neither icon nor iconClass are defined/resolvable, use fallback
return React.createElement(fallback.react, { ...props });
}
// set the icon's class to iconClass plus props.className
props.className = classes(iconClass, props.className);
// render icon as css background image, assuming one is set on iconClass
return React.createElement(Private.blankReact, { ...props });
}
const resolved = LabIcon.resolve({ icon });
return React.createElement(resolved.react, { ...props });
}
/**
* Resolve a \{name, svgstr\} pair into an actual svg node.
*/
static resolveSvg({ name, svgstr }) {
const svgDoc = new DOMParser().parseFromString(Private.svgstrShim(svgstr), 'image/svg+xml');
const svgError = svgDoc.querySelector('parsererror');
// structure of error element varies by browser, search at top level
if (svgError) {
// parse failed, svgElement will be an error box
const errmsg = `SVG HTML was malformed for LabIcon instance.\nname: ${name}, svgstr: ${svgstr}`;
if (LabIcon._debug) {
// fail noisily, render the error box
console.error(errmsg);
return svgError;
}
else {
// bad svg is always a real error, fail silently but warn
console.warn(errmsg);
return null;
}
}
else {
// parse succeeded
return svgDoc.documentElement;
}
}
/**
* Toggle icon debug from off-to-on, or vice-versa.
*
* @param debug - optional boolean to force debug on or off
*/
static toggleDebug(debug) {
LabIcon._debug = debug !== null && debug !== void 0 ? debug : !LabIcon._debug;
}
/** *********
* members *
***********/
constructor({ name, svgstr, render, unrender, _loading = false }) {
this._props = {};
this._svgReplaced = new Signal(this);
/**
* Cache for svg parsing intermediates
* - undefined: the cache has not yet been populated
* - null: a valid, but empty, value
*/
this._svgElement = undefined;
this._svgInnerHTML = undefined;
this._svgReactAttrs = undefined;
if (!(name && svgstr)) {
// sanity check failed
console.error(`When defining a new LabIcon, name and svgstr must both be non-empty strings. name: ${name}, svgstr: ${svgstr}`);
return badIcon;
}
// currently this needs to be set early, before checks for existing icons
this._loading = _loading;
// check to see if this is a redefinition of an existing icon
if (LabIcon._instances.has(name)) {
// fetch the existing icon, replace its svg, then return it
const icon = LabIcon._instances.get(name);
if (this._loading) {
// replace the placeholder svg in icon
icon.svgstr = svgstr;
this._loading = false;
return icon;
}
else {
// already loaded icon svg exists; replace it and warn
if (LabIcon._debug) {
console.warn(`Redefining previously loaded icon svgstr. name: ${name}, svgstrOld: ${icon.svgstr}, svgstr: ${svgstr}`);
}
icon.svgstr = svgstr;
return icon;
}
}
this.name = name;
this.react = this._initReact(name);
this.svgstr = svgstr;
// setup custom render/unrender methods, if passed in
this._initRender({ render, unrender });
LabIcon._instances.set(this.name, this);
}
/**
* Get a view of this icon that is bound to the specified icon/style props
*
* @param optional icon/style props (same as args for .element
* and .react methods). These will be bound to the resulting view
*
* @returns a view of this LabIcon instance
*/
bindprops(props) {
const view = Object.create(this);
view._props = props;
view.react = view._initReact(view.name + '_bind');
return view;
}
/**
* Create an icon as a DOM element
*
* @param className - a string that will be used as the class
* of the container element. Overrides any existing class
*
* @param container - a preexisting DOM element that
* will be used as the container for the svg element
*
* @param label - text that will be displayed adjacent
* to the icon
*
* @param title - a tooltip for the icon
*
* @param tag - if container is not explicitly
* provided, this tag will be used when creating the container
*
* @param stylesheet - optional string naming a builtin icon
* stylesheet, for example 'menuItem' or `statusBar`. Can also be an
* object defining a custom icon stylesheet, or a list of builtin
* stylesheet names and/or custom stylesheet objects. If array,
* the given stylesheets will be merged.
*
* See @jupyterlab/ui-components/src/style/icon.ts for details
*
* @param elementPosition - optional position for the inner svg element
*
* @param elementSize - optional size for the inner svg element.
* Set to 'normal' to get a standard 16px x 16px icon
*
* @param ...elementCSS - all additional args are treated as
* overrides for the CSS props applied to the inner svg element
*
* @returns A DOM element that contains an (inline) svg element
* that displays an icon
*/
element(props = {}) {
var _a;
let { className, container, label, title, tag = 'div', ...styleProps } = { ...this._props, ...props };
// check if icon element is already set
const maybeSvgElement = container === null || container === void 0 ? void 0 : container.firstChild;
if (((_a = maybeSvgElement === null || maybeSvgElement === void 0 ? void 0 : maybeSvgElement.dataset) === null || _a === void 0 ? void 0 : _a.iconId) === this._uuid) {
// return the existing icon element
return maybeSvgElement;
}
// ensure that svg html is valid
if (!this.svgElement) {
// bail if failing silently, return blank element
return document.createElement('div');
}
if (container) {
// take ownership by removing any existing children
while (container.firstChild) {
container.firstChild.remove();
}
}
else if (tag) {
// create a container if needed
container = document.createElement(tag);
}
const svgElement = this.svgElement.cloneNode(true);
if (!container) {
if (label) {
console.warn();
}
return svgElement;
}
if (label != null) {
container.textContent = label;
}
Private.initContainer({
container: container,
className,
styleProps,
title
});
// add the svg node to the container
container.appendChild(svgElement);
return container;
}
render(container, options) {
var _a;
let label = (_a = options === null || options === void 0 ? void 0 : options.children) === null || _a === void 0 ? void 0 : _a[0];
// narrow type of label
if (typeof label !== 'string') {
label = undefined;
}
this.element({
container,
label,
...options === null || options === void 0 ? void 0 : options.props
});
}
get svgElement() {
if (this._svgElement === undefined) {
this._svgElement = this._initSvg({ uuid: this._uuid });
}
return this._svgElement;
}
get svgInnerHTML() {
if (this._svgInnerHTML === undefined) {
if (this.svgElement === null) {
// the svg element resolved to null, mark this null too
this._svgInnerHTML = null;
}
else {
this._svgInnerHTML = this.svgElement.innerHTML;
}
}
return this._svgInnerHTML;
}
get svgReactAttrs() {
if (this._svgReactAttrs === undefined) {
if (this.svgElement === null) {
// the svg element resolved to null, mark this null too
this._svgReactAttrs = null;
}
else {
this._svgReactAttrs = getReactAttrs(this.svgElement, {
ignore: ['data-icon-id']
});
}
}
return this._svgReactAttrs;
}
get svgstr() {
return this._svgstr;
}
set svgstr(svgstr) {
this._svgstr = svgstr;
// associate a new unique id with this particular svgstr
const uuid = UUID.uuid4();
const uuidOld = this._uuid;
this._uuid = uuid;
// empty the svg parsing intermediates cache
this._svgElement = undefined;
this._svgInnerHTML = undefined;
this._svgReactAttrs = undefined;
// update icon elements created using .element method
document
.querySelectorAll(`[data-icon-id="${uuidOld}"]`)
.forEach(oldSvgElement => {
if (this.svgElement) {
oldSvgElement.replaceWith(this.svgElement.cloneNode(true));
}
});
// trigger update of icon elements created using other methods
this._svgReplaced.emit();
}
_initReact(displayName) {
const component = React.forwardRef((props = {}, ref) => {
const { className, container, label, title, slot, tag = 'div', ...styleProps } = { ...this._props, ...props };
// set up component state via useState hook
const [, setId] = React.useState(this._uuid);
// subscribe to svg replacement via useEffect hook
React.useEffect(() => {
const onSvgReplaced = () => {
setId(this._uuid);
};
this._svgReplaced.connect(onSvgReplaced);
// specify cleanup callback as hook return
return () => {
this._svgReplaced.disconnect(onSvgReplaced);
};
});
// make it so that tag can be used as a jsx component
const Tag = tag !== null && tag !== void 0 ? tag : React.Fragment;
// ensure that svg html is valid
if (!(this.svgInnerHTML && this.svgReactAttrs)) {
// bail if failing silently
return React.createElement(React.Fragment, null);
}
const svgProps = { ...this.svgReactAttrs };
if (!tag) {
Object.assign(svgProps, {
className: className || styleProps
? classes(className, LabIconStyle.styleClass(styleProps))
: undefined,
title: title,
slot: slot
});
}
const svgComponent = (React.createElement("svg", { ...svgProps, ...this.svgReactAttrs, dangerouslySetInnerHTML: { __html: this.svgInnerHTML }, ref: ref }));
if (container) {
Private.initContainer({ container, className, styleProps, title });
return (React.createElement(React.Fragment, null,
svgComponent,
label));
}
else {
let attributes = {};
if (Tag !== React.Fragment) {
attributes = {
className: className || styleProps
? classes(className, LabIconStyle.styleClass(styleProps))
: undefined,
title: title,
slot: slot
};
}
return (React.createElement(Tag, { ...attributes },
svgComponent,
label));
}
});
component.displayName = `LabIcon_${displayName}`;
return component;
}
_initRender({ render, unrender }) {
if (render) {
this.render = render;
if (unrender) {
this.unrender = unrender;
}
}
else if (unrender) {
console.warn('In _initRender, ignoring unrender arg since render is undefined');
}
}
_initSvg({ title, uuid } = {}) {
const svgElement = LabIcon.resolveSvg(this);
if (!svgElement) {
// bail on null svg element
return svgElement;
}
if (svgElement.tagName !== 'parsererror') {
// svgElement is an actual svg node, augment it
svgElement.dataset.icon = this.name;
if (uuid) {
svgElement.dataset.iconId = uuid;
}
if (title) {
Private.setTitleSvg(svgElement, title);
}
else {
// mark as a decorative
svgElement.setAttribute('aria-hidden', 'true');
}
}
return svgElement;
}
}
LabIcon._debug = false;
LabIcon._instances = new Map();
var Private;
(function (Private) {
function blankElement({ className = '', container, label, title, tag = 'div', slot, ...styleProps }) {
if ((container === null || container === void 0 ? void 0 : container.className) === className) {
// nothing needs doing, return the icon node
return container;
}
if (container) {
// take ownership by removing any existing children
while (container.firstChild) {
container.firstChild.remove();
}
}
else {
// create a container if needed
container = document.createElement(tag !== null && tag !== void 0 ? tag : 'div');
}
if (label != null) {
container.textContent = label;
}
Private.initContainer({ container, className, styleProps, title });
return container;
}
Private.blankElement = blankElement;
Private.blankReact = React.forwardRef(({ className = '', container, label, title, tag = 'div', ...styleProps }, ref) => {
// make it so that tag can be used as a jsx component
const Tag = tag !== null && tag !== void 0 ? tag : 'div';
if (container) {
initContainer({ container, className, styleProps, title });
return React.createElement(React.Fragment, null);
}
else {
// if ref is defined, we create a blank svg node and point ref to it
return (React.createElement(Tag, { className: classes(className, LabIconStyle.styleClass(styleProps)) },
ref && blankIcon.react({ ref }),
label));
}
});
Private.blankReact.displayName = 'BlankReact';
function initContainer({ container, className, styleProps, title }) {
if (title != null) {
container.title = title;
}
const styleClass = LabIconStyle.styleClass(styleProps);
if (className != null) {
// override the container class with explicitly passed-in class + style class
const classResolved = classes(className, styleClass);
container.className = classResolved;
return classResolved;
}
else if (styleClass) {
// add the style class to the container class
container.classList.add(styleClass);
return styleClass;
}
else {
return '';
}
}
Private.initContainer = initContainer;
function isResolvable(icon) {
return !!(icon &&
(typeof icon === 'string' ||
(icon.name && icon.svgstr)));
}
Private.isResolvable = isResolvable;
function setTitleSvg(svgNode, title) {
// add a title node to the top level svg node
const titleNodes = svgNode.getElementsByTagName('title');
if (titleNodes.length) {
titleNodes[0].textContent = title;
}
else {
const titleNode = document.createElement('title');
titleNode.textContent = title;
svgNode.appendChild(titleNode);
}
}
Private.setTitleSvg = setTitleSvg;
/**
* A shim for svgstrs loaded using any loader other than raw-loader.
* This function assumes that svgstr will look like one of:
*
* - the raw contents of an .svg file:
* <svg...</svg>
*
* - a data URL:
* data:[<mediatype>][;base64],<svg...</svg>
*
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
*/
function svgstrShim(svgstr, strict = true) {
// decode any uri escaping, condense leading/lagging whitespace,
// then match to raw svg string
const [, base64, raw] = decodeURIComponent(svgstr)
.replace(/>\s*\n\s*</g, '><')
.replace(/\s*\n\s*/g, ' ')
.match(strict
? // match based on data url schema
/^(?:data:.*?(;base64)?,)?(.*)/
: // match based on open of svg tag
/(?:(base64).*)?(<svg.*)/);
// decode from base64, if needed
return base64 ? atob(raw) : raw;
}
Private.svgstrShim = svgstrShim;
/**
* TODO: figure out story for independent Renderers.
* Base implementation of IRenderer.
*/
class Renderer {
constructor(_icon, _rendererOptions) {
this._icon = _icon;
this._rendererOptions = _rendererOptions;
}
// eslint-disable-next-line
render(container, options) { }
}
Private.Renderer = Renderer;
/**
* TODO: figure out story for independent Renderers.
* Implementation of IRenderer that creates the icon svg node
* as a DOM element.
*/
class ElementRenderer extends Renderer {
render(container, options) {
var _a, _b;
let label = (_a = options === null || options === void 0 ? void 0 : options.children) === null || _a === void 0 ? void 0 : _a[0];
// narrow type of label
if (typeof label !== 'string') {
label = undefined;
}
this._icon.element({
container,
label,
...(_b = this._rendererOptions) === null || _b === void 0 ? void 0 : _b.props,
...options === null || options === void 0 ? void 0 : options.props
});
}
}
Private.ElementRenderer = ElementRenderer;
/**
* TODO: figure out story for independent Renderers.
* Implementation of IRenderer that creates the icon svg node
* as a React component.
*/
class ReactRenderer extends Renderer {
constructor() {
super(...arguments);
this._rootDOM = null;
}
render(container, options) {
var _a, _b;
let label = (_a = options === null || options === void 0 ? void 0 : options.children) === null || _a === void 0 ? void 0 : _a[0];
// narrow type of label
if (typeof label !== 'string') {
label = undefined;
}
const icon = this._icon;
if (this._rootDOM !== null) {
this._rootDOM.unmount();
}
this._rootDOM = createRoot(container);
this._rootDOM.render(React.createElement(icon.react, { container: container, label: label, ...(_b = this._rendererOptions) === null || _b === void 0 ? void 0 : _b.props, ...options === null || options === void 0 ? void 0 : options.props }));
}
unrender(container) {
if (this._rootDOM !== null) {
this._rootDOM.unmount();
this._rootDOM = null;
}
}
}
Private.ReactRenderer = ReactRenderer;
})(Private || (Private = {}));
// need to be at the bottom since constructor depends on Private
export const badIcon = new LabIcon({
name: 'ui-components:bad',
svgstr: badSvgstr
});
export const blankIcon = new LabIcon({
name: 'ui-components:blank',
svgstr: blankSvgstr
});
//# sourceMappingURL=labicon.js.map