mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
1 parent
7a4f30ab61
commit
41798b718a
149 files changed
+375
-201
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
Reference in new issue
Block a user