extract @k8slens/icon

Signed-off-by: Gabriel <gaccettola@mirantis.com>
This commit is contained in:
Gabriel authored and Gabriel Accettola committed 2023-05-30 21:35:33 +02:00
1 parent 7a4f30ab61
commit 41798b718a
149 files changed
+375 -201

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.Icon {
--size: 21px;
--small-size: 18px;
--smallest-size: 16px;
--big-size: 32px;
--color-active: var(--iconActiveColor);
--bgc-active: var(--iconActiveBackground);
--focus-color: var(--icon-focus-color, var(--blue));
display: inline-flex;
flex-shrink: 0;
font-style: normal;
vertical-align: middle;
align-items: center;
justify-content: center;
text-decoration: none;
user-select: none;
box-sizing: content-box; // allow to use padding for outer spacing
-webkit-user-select: none; /* safari */
-moz-user-select: none; /* firefox */
font-size: var(--size);
width: var(--size);
height: var(--size);
&.smallest {
font-size: var(--smallest-size);
width: var(--smallest-size);
height: var(--smallest-size);
}
&.small {
font-size: var(--small-size);
width: var(--small-size);
height: var(--small-size);
}
&.big {
font-size: var(--big-size);
width: var(--big-size);
height: var(--big-size);
}
// material-icon
&.material {
> .icon {
font-family: "Material Icons";
font-size: inherit;
font-weight: inherit;
font-style: inherit;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Support for IE. */
font-feature-settings: 'liga';
}
}
// inline svg icon
&.svg {
box-sizing: content-box;
> .icon {
width: 100%;
height: 100%;
}
svg {
pointer-events: none;
width: 100%;
height: 100%;
* {
fill: currentColor;
}
line {
stroke: currentColor;
}
}
}
&.disabled {
opacity: .5;
color: inherit !important;
cursor: not-allowed !important;
}
&.sticker {
background: var(--colorOk);
color: var(--textColorAccent);
border-radius: 50%;
box-sizing: content-box;
padding: 8px * 0.5;
}
&.active {
color: var(--color-active);
box-shadow: 0 0 0 2px var(--iconActiveBackground);
background-color: var(--iconActiveBackground);
}
&.interactive {
cursor: pointer;
transition: 250ms color, 250ms opacity, 150ms background-color, 150ms box-shadow;
border-radius: var(--border-radius);
&.focusable:focus-visible {
box-shadow: 0 0 0 2px var(--focus-color);
}
&:hover {
@extend .active;
}
}
}
@@ -0,0 +1,94 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import type { Logger } from "@k8slens/logger";
import { loggerFeature, loggerInjectionToken } from "@k8slens/logger";
import { createContainer } from "@ogre-tools/injectable";
import type { DiRender } from "@k8slens/test-utils";
import { renderFor } from "@k8slens/test-utils";
import { Icon } from "./icon";
import { runInAction } from "mobx";
import { registerFeature } from "@k8slens/feature-core";
import { registerInjectableReact } from "@ogre-tools/injectable-react";
import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx";
import { setLegacyGlobalDiForExtensionApi } from "@k8slens/legacy-global-di";
describe("<Icon> href technical tests", () => {
let render: DiRender;
let logger: jest.MockedObject<Logger>;
beforeEach(() => {
const environment = "renderer";
const di = createContainer(environment, {
detectCycles: false,
});
registerMobX(di);
registerInjectableReact(di);
setLegacyGlobalDiForExtensionApi(di, environment);
runInAction(() => {
registerFeature(di,
loggerFeature,
);
});
logger = {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
silly: jest.fn(),
warn: jest.fn(),
};
di.override(loggerInjectionToken, () => logger);
render = renderFor(di);
});
it("should render an <Icon> with http href", () => {
const result = render(
<Icon data-testid="my-icon" href="http://localhost" />
);
const icon = result.queryByTestId("my-icon");
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute("href", "http://localhost");
expect(logger.warn).not.toBeCalled();
});
it("should render an <Icon> with https href", () => {
const result = render(
<Icon data-testid="my-icon" href="https://localhost" />
);
const icon = result.queryByTestId("my-icon");
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute("href", "https://localhost");
expect(logger.warn).not.toBeCalled();
});
it("should warn about ws hrefs", () => {
const result = render(<Icon data-testid="my-icon" href="ws://localhost" />);
const icon = result.queryByTestId("my-icon");
expect(icon).toBeInTheDocument();
expect(icon).not.toHaveAttribute("href", "ws://localhost");
expect(logger.warn).toBeCalled();
});
it("should warn about javascript: hrefs", () => {
const result = render(<Icon data-testid="my-icon" href="#" />);
const icon = result.queryByTestId("my-icon");
expect(icon).toBeInTheDocument();
expect(icon).not.toHaveAttribute("href", "#");
expect(logger.warn).toBeCalled();
});
});
+309
View File
@@ -0,0 +1,309 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./icon.scss";
import React, { createRef } from "react";
import { NavLink } from "react-router-dom";
import type { LocationDescriptor } from "history";
import { cssNames } from "@k8slens/utilities";
import { withTooltip } from "@k8slens/tooltip";
import isNumber from "lodash/isNumber";
import Configuration from "../assets/configuration.svg";
import Crane from "../assets/crane.svg";
import Group from "../assets/group.svg";
import Helm from "../assets/helm.svg";
import Install from "../assets/install.svg";
import Kube from "../assets/kube.svg";
import LensLogo from "../assets/lens-logo.svg";
import License from "../assets/license.svg";
import LogoLens from "../assets/logo-lens.svg";
import Logout from "../assets/logout.svg";
import Nodes from "../assets/nodes.svg";
import PushOff from "../assets/push_off.svg";
import PushPin from "../assets/push_pin.svg";
import Spinner from "../assets/spinner.svg";
import Ssh from "../assets/ssh.svg";
import Storage from "../assets/storage.svg";
import Terminal from "../assets/terminal.svg";
import Notice from "../assets/notice.svg";
import User from "../assets/user.svg";
import Users from "../assets/users.svg";
import Wheel from "../assets/wheel.svg";
import Workloads from "../assets/workloads.svg";
import type { Logger } from "@k8slens/logger";
import { withInjectables } from "@ogre-tools/injectable-react";
import { loggerInjectionToken } from "@k8slens/logger";
import type { StrictReactNode } from "@k8slens/utilities";
const hrefValidation = /https?:\/\//;
const hrefIsSafe = (href: string) => Boolean(href.match(hrefValidation));
/**
* Mapping between the local file names and the svgs
*
* Because we only really want a fixed list of bundled icons, this is safer so that consumers of
* `<Icon>` cannot pass in a `../some/path`.
*/
const localSvgIcons = new Map([
["configuration", Configuration],
["crane", Crane],
["group", Group],
["helm", Helm],
["install", Install],
["kube", Kube],
["lens-logo", LensLogo],
["license", License],
["logo-lens", LogoLens],
["logout", Logout],
["nodes", Nodes],
["push_off", PushOff],
["push_pin", PushPin],
["spinner", Spinner],
["ssh", Ssh],
["storage", Storage],
["terminal", Terminal],
["notice", Notice],
["user", User],
["users", Users],
["wheel", Wheel],
["workloads", Workloads],
]);
export type NamedSvg =
| "configuration"
| "crane"
| "group"
| "helm"
| "install"
| "kube"
| "lens-logo"
| "license"
| "logo-lens"
| "logout"
| "nodes"
| "push_off"
| "push_pin"
| "spinner"
| "ssh"
| "storage"
| "terminal"
| "user"
| "users"
| "wheel"
| "workloads";
export interface BaseIconProps {
/**
* One of the names from https://material.io/icons/
*/
material?: string;
/**
* Either an SVG XML or one of {@link NamedSvg}
*/
svg?: NamedSvg | string;
/**
* render icon as NavLink from react-router-dom
*/
link?: LocationDescriptor;
/**
* render icon as hyperlink
*/
href?: string;
/**
* The icon size (css units)
*/
size?: string | number;
/**
* A pre-defined icon-size
*/
small?: boolean;
/**
* A pre-defined icon-size
*/
smallest?: boolean;
/**
* A pre-defined icon-size
*/
big?: boolean;
/**
* apply active-state styles
*/
active?: boolean;
/**
* indicates that icon is interactive and highlight it on focus/hover
*/
interactive?: boolean;
/**
* Allow focus to the icon to show `.active` styles. Only applicable if {@link IconProps.interactive} is `true`.
*
* @default true
*/
focusable?: boolean;
sticker?: boolean;
disabled?: boolean;
"data-testid"?: string;
}
export interface IconProps extends React.HTMLAttributes<any>, BaseIconProps {
children?: StrictReactNode;
}
export function isSvg(content: string): boolean {
// source code of the asset
// eslint-disable-next-line xss/no-mixed-html
return String(content).includes("<svg");
}
interface Dependencies {
logger: Logger;
}
const RawIcon = (props: IconProps & Dependencies) => {
const ref = createRef<HTMLAnchorElement>();
const {
// skip passing props to icon's html element
className,
href,
link,
material,
svg,
size,
smallest,
small,
big,
disabled,
sticker,
active,
focusable = true,
children,
interactive,
onClick,
onKeyDown,
logger,
...elemProps
} = props;
const isInteractive = interactive ?? !!(onClick || href || link);
const boundOnClick = (event: React.MouseEvent) => {
if (!disabled) {
onClick?.(event);
}
};
const boundOnKeyDown = (event: React.KeyboardEvent<any>) => {
switch (event.nativeEvent.code) {
case "Space":
// fallthrough
case "Enter": {
ref.current?.click();
event.preventDefault();
break;
}
}
onKeyDown?.(event);
};
let iconContent: StrictReactNode;
const iconProps: Partial<IconProps> = {
className: cssNames(
"Icon",
className,
{
svg,
material,
interactive: isInteractive,
disabled,
sticker,
active,
focusable,
},
!size ? { smallest, small, big } : {}
),
onClick: isInteractive ? boundOnClick : undefined,
onKeyDown: isInteractive ? boundOnKeyDown : undefined,
tabIndex: isInteractive && focusable && !disabled ? 0 : undefined,
style: size
? ({
"--size": size + (isNumber(size) ? "px" : ""),
} as React.CSSProperties)
: undefined,
...elemProps,
};
// render as inline svg-icon
if (typeof svg === "string") {
const svgIconText = isSvg(svg) ? svg : localSvgIcons.get(svg) ?? "";
iconContent = (
<span
className="icon"
// eslint-disable-next-line xss/no-mixed-html
dangerouslySetInnerHTML={{ __html: svgIconText }}
/>
);
}
// render as material-icon
if (typeof material === "string") {
iconContent = (
<span className="icon" data-icon-name={material}>
{material}
</span>
);
}
// wrap icon's content passed from decorator
iconProps.children = (
<>
{iconContent}
{children}
</>
);
// render icon type
if (link) {
const { className, children } = iconProps;
return (
<NavLink className={className} to={link} ref={ref}>
{children}
</NavLink>
);
}
if (href) {
if (hrefIsSafe(href)) {
// eslint-disable-next-line jsx-a11y/anchor-has-content
return <a {...iconProps} href={href} ref={ref} />;
}
logger.warn("[ICON]: href prop is unsafe, blocking", { href });
}
return <i {...iconProps} ref={ref} />;
};
const InjectedIcon = withInjectables<Dependencies, IconProps>(RawIcon, {
getProps: (di, props) => ({
...props,
logger: di.inject(loggerInjectionToken),
}),
});
export const Icon = Object.assign(withTooltip(InjectedIcon), { isSvg });