1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Compute the gradient color from the DOM for NamespaceSelect

- Once on initial load, and the subsequently only when theme changes
  have applied

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-05-11 08:35:26 -04:00
parent 204dfdc202
commit aeb750de1b
5 changed files with 133 additions and 38 deletions

View File

@ -9,16 +9,6 @@
height: var(--font-size); height: var(--font-size);
position: absolute; position: absolute;
z-index: 20; z-index: 20;
&.front {
left: 0px;
background: linear-gradient(to right, var(--contentColor) 0px, transparent);
}
&.back {
right: 0px;
background: linear-gradient(to left, var(--contentColor) 0px, transparent);
}
} }
.NamespaceSelect { .NamespaceSelect {

View File

@ -1,14 +1,19 @@
import "./namespace-select.scss"; import "./namespace-select.scss";
import React from "react"; import Color from "color";
import { computed } from "mobx"; import { computed, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { Select, SelectOption, SelectProps } from "../select"; import React from "react";
import { cssNames } from "../../utils"; import { findDOMNode } from "react-dom";
import { Icon } from "../icon"; import { components, SelectComponentsConfig, ValueContainerProps } from "react-select";
import { namespaceStore } from "./namespace.store";
import { kubeWatchApi } from "../../api/kube-watch-api"; import { kubeWatchApi } from "../../api/kube-watch-api";
import { components, ValueContainerProps } from "react-select"; import { ThemeStore } from "../../theme.store";
import { cssNames } from "../../utils";
import { computeStackingColor } from "../../utils/color";
import { Icon } from "../icon";
import { Select, SelectOption, SelectProps } from "../select";
import { namespaceStore } from "./namespace.store";
interface Props extends SelectProps { interface Props extends SelectProps {
showIcons?: boolean; showIcons?: boolean;
@ -19,35 +24,70 @@ interface Props extends SelectProps {
const defaultProps: Partial<Props> = { const defaultProps: Partial<Props> = {
showIcons: true, showIcons: true,
showClusterOption: false, components: {},
customizeOptions: (options) => options,
}; };
function GradientValueContainer<T>({children, ...rest}: ValueContainerProps<T>) { function getGVCStyle(position: "front" | "back", backgroundColor: Color): React.CSSProperties {
return ( const placement = position === "front" ? "left" : "right";
<components.ValueContainer {...rest}> const direction = position === "front" ? "to right" : "to left";
<div className="GradientValueContainer front" />
{children} return {
<div className="GradientValueContainer back" /> [placement]: "0px",
</components.ValueContainer> background: `linear-gradient(${direction}, ${backgroundColor.rgb().toString()} 0px, transparent)`,
); };
}
function getGVC<T>(trueBackgroundColour: Color): React.FunctionComponent {
return function ({children, ...rest}: ValueContainerProps<T>) {
return (
<components.ValueContainer {...rest}>
<div className="GradientValueContainer" style={getGVCStyle("front", trueBackgroundColour)} />
{children}
<div className="GradientValueContainer" style={getGVCStyle("back", trueBackgroundColour)} />
</components.ValueContainer>
);
};
} }
@observer @observer
export class NamespaceSelect extends React.Component<Props> { export class NamespaceSelect extends React.Component<Props> {
static defaultProps = defaultProps as object; static defaultProps = defaultProps as object;
@observable ValueContainer?: React.ComponentClass<ValueContainerProps<any>> | React.FunctionComponent;
componentDidMount() { componentDidMount() {
// eslint-disable-next-line react/no-find-dom-node
const elem = findDOMNode(this) as HTMLElement;
this.ValueContainer = getGVC(computeStackingColor(elem, "backgroundColor"));
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([namespaceStore], { kubeWatchApi.subscribeStores([namespaceStore], {
preload: true, preload: true,
loadOnce: true, // skip reloading namespaces on every render / page visit loadOnce: true, // skip reloading namespaces on every render / page visit
}) }),
ThemeStore.getInstance().onThemeApplied(() => {
this.ValueContainer = getGVC(computeStackingColor(elem, "backgroundColor"));
}),
]); ]);
} }
@computed get components(): SelectComponentsConfig<SelectOption> {
if (!this.ValueContainer) {
return this.props.components;
}
const { components: { ValueContainer, ...components } } = this.props;
return {
...components,
ValueContainer: this.ValueContainer,
};
}
@computed.struct get options(): SelectOption[] { @computed.struct get options(): SelectOption[] {
const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props; const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props;
let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); const options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() }));
if (showAllNamespacesOption) { if (showAllNamespacesOption) {
options.unshift({ label: "All Namespaces", value: "" }); options.unshift({ label: "All Namespaces", value: "" });
@ -55,11 +95,7 @@ export class NamespaceSelect extends React.Component<Props> {
options.unshift({ label: "Cluster", value: "" }); options.unshift({ label: "Cluster", value: "" });
} }
if (customizeOptions) { return customizeOptions(options);
options = customizeOptions(options);
}
return options;
} }
formatOptionLabel = (option: SelectOption) => { formatOptionLabel = (option: SelectOption) => {
@ -75,9 +111,7 @@ export class NamespaceSelect extends React.Component<Props> {
}; };
render() { render() {
const { className, showIcons, customizeOptions, components = {}, ...selectProps } = this.props; const { className, showIcons, customizeOptions, components, ...selectProps } = this.props;
components.ValueContainer ??= GradientValueContainer;
return ( return (
<Select <Select
@ -85,7 +119,7 @@ export class NamespaceSelect extends React.Component<Props> {
menuClass="NamespaceSelectMenu" menuClass="NamespaceSelectMenu"
formatOptionLabel={this.formatOptionLabel} formatOptionLabel={this.formatOptionLabel}
options={this.options} options={this.options}
components={components} components={this.components}
{...selectProps} {...selectProps}
/> />
); );

View File

@ -1,7 +1,8 @@
import { computed, observable, reaction } from "mobx"; import { computed, observable, reaction } from "mobx";
import { autobind, Singleton } from "./utils";
import { UserStore } from "../common/user-store"; import { UserStore } from "../common/user-store";
import logger from "../main/logger"; import logger from "../main/logger";
import { autobind, Disposer, disposer, EventEmitter, Singleton } from "./utils";
export type ThemeId = string; export type ThemeId = string;
@ -50,6 +51,8 @@ export class ThemeStore extends Singleton {
}; };
} }
private themeApplied = new EventEmitter<[]>();
constructor() { constructor() {
super(); super();
@ -61,6 +64,7 @@ export class ThemeStore extends Singleton {
logger.error(err); logger.error(err);
UserStore.getInstance().resetTheme(); UserStore.getInstance().resetTheme();
} }
this.themeApplied.emit();
}, { }, {
fireImmediately: true, fireImmediately: true,
}); });
@ -71,6 +75,12 @@ export class ThemeStore extends Singleton {
await Promise.all(this.themeIds.map(this.loadTheme)); await Promise.all(this.themeIds.map(this.loadTheme));
} }
public onThemeApplied(handler: () => void): Disposer {
this.themeApplied.addListener(handler);
return disposer(() => this.themeApplied.removeListener(handler));
}
getThemeById(themeId: ThemeId): Theme { getThemeById(themeId: ThemeId): Theme {
return this.allThemes.get(themeId); return this.allThemes.get(themeId);
} }

View File

@ -0,0 +1,15 @@
import Color from "color";
import { blend } from "../color";
describe("color tests", () => {
describe("blend", () => {
it("should return parent if child has alpha = 0", () => {
expect(blend(Color("rgba(10, 20, 30, 1)"), Color("rgba(11, 21, 31, 0)")).toString()).toBe("rgb(10, 20, 30)");
});
it("should return child if child has alpha = 1", () => {
expect(blend(Color("rgba(1, 2, 3, 1)"), Color("rgba(10, 20, 30, 1)")).toString()).toBe("rgb(10, 20, 30)");
});
});
});

View File

@ -0,0 +1,46 @@
import Color from "color";
/**
* Some notes:
* - CSS colours use straight alpha channels:
* - https://en.wikipedia.org/wiki/Alpha_compositing
* - https://stackoverflow.com/a/45526785/5615967
* - The `getComputedStyles` function does not do this already
*/
/**
* Compute the "actual" color at an element by walking up the tree while
* @param elem The root element to up the DOM from
* @param field Which computedStyle field to work against
*/
export function computeStackingColor(elem: HTMLElement | undefined, field: "color" | "backgroundColor"): Color {
if (!elem) {
return Color.rgb(0, 0, 0).alpha(0);
}
const curColor = Color(window.getComputedStyle(elem)[field]);
if (curColor.alpha() === 1) {
return curColor;
}
return blend(computeStackingColor(elem.parentElement, field), curColor);
}
/**
* Blends the two colors where the parent color is place beneath the child color.
* And the returned color is what the browser would render.
*
* The parent color is placed beneath because child elements are rendered "above"
* their DOM parents.
* @param parent The color that is from the parent element
* @param child The color that is from the child element
*/
export function blend(parent: Color, child: Color): Color {
const alpha = (1 - child.alpha()) * parent.alpha() + child.alpha();
const red = ((1 - child.alpha()) * parent.alpha() * parent.red() + child.alpha() * child.red()) / alpha;
const green = ((1 - child.alpha()) * parent.alpha() * parent.green() + child.alpha() * child.green()) / alpha;
const blue = ((1 - child.alpha()) * parent.alpha() * parent.blue() + child.alpha() * child.blue()) / alpha;
return Color.rgb(red, green, blue).alpha(alpha);
}