Skip to content

Commit

Permalink
🎉 Add color picker popover
Browse files Browse the repository at this point in the history
  • Loading branch information
Schneegans committed Feb 1, 2025
1 parent aec45d3 commit ebe5a36
Show file tree
Hide file tree
Showing 10 changed files with 193 additions and 11 deletions.
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@
"@jaames/iro": "^5.5.2",
"@popperjs/core": "^2.11.8",
"@types/bootstrap": "^5.2.10",
"@types/chroma-js": "^3.1.1",
"@types/lodash.isequal": "^4.5.8",
"@types/mime-types": "^2.1.4",
"@types/react-transition-group": "^4.4.12",
"bootstrap": "^5.3.1",
"chokidar": "^4.0.3",
"chroma-js": "^3.1.2",
"commander": "^13.0.0",
"dbus-final": "github:Jelmerro/dbus-final",
"electron-squirrel-startup": "^1.0.0",
Expand Down
4 changes: 4 additions & 0 deletions src/settings-renderer/components/App.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@
background-blend-mode: overlay;
}
}

:global(.react-tooltip) {
z-index: 1000;
}
2 changes: 1 addition & 1 deletion src/settings-renderer/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export default () => {
visible={themesVisible}
onClose={() => setThemesVisible(false)}
/>
<Tooltip id="main-tooltip" delayShow={500} />
</div>
<Tooltip id="main-tooltip" delayShow={500} />
</>
);
};
1 change: 0 additions & 1 deletion src/settings-renderer/components/MenuThemesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ export default (props: IProps) => {
window.commonAPI.appSettings.getKey('enableDarkModeForMenuThemes'),
]).then(
([themes, darkMode, menuTheme, darkMenuTheme, colors, darkColors, useDarkMode]) => {
console.log('Got themes!');
setThemes(themes);
setDarkMode(darkMode);
setCurrentThemeID(menuTheme);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,11 @@
}
}
}

:global(.react-colorful__saturation) {
border-radius: 4px 4px 0 0 !important;
}

:global(.react-colorful__last-control) {
border-radius: 0 0 4px 4px !important;
}
30 changes: 21 additions & 9 deletions src/settings-renderer/components/widgets/ColorButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

import React from 'react';
import { RgbaStringColorPicker } from 'react-colorful';
import chroma from 'chroma-js';

import * as classes from './ColorButton.module.scss';
import Popover from './Popover';

interface IProps {
/** Function to call when the color is changed. */
Expand All @@ -32,19 +34,29 @@ interface IProps {
*/
export default (props: IProps) => {
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [color, setColor] = React.useState(props.color);
const [color, setColor] = React.useState(chroma(props.color).css());

React.useEffect(() => {
setColor(props.color);
setColor(chroma(props.color).css());
}, [props.color]);

return (
<div
className={classes.colorButton}
data-tooltip-id="main-tooltip"
data-tooltip-content={props.name}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
<div className={classes.color} style={{ backgroundColor: color }} />
</div>
<Popover
visible={isPopoverOpen}
onClickOutside={() => setIsPopoverOpen(false)}
content={
<>
{props.name}
<RgbaStringColorPicker color={color} onChange={setColor} />
</>
}>
<div
className={classes.colorButton}
data-tooltip-id="main-tooltip"
data-tooltip-content={props.name}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
<div className={classes.color} style={{ backgroundColor: color }} />
</div>
</Popover>
);
};
38 changes: 38 additions & 0 deletions src/settings-renderer/components/widgets/Popover.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//////////////////////////////////////////////////////////////////////////////////////////
// _ _ ____ _ _ ___ ____ //
// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
// //
//////////////////////////////////////////////////////////////////////////////////////////

// SPDX-FileCopyrightText: Simon Schneegans <code@simonschneegans.de>
// SPDX-License-Identifier: MIT

@import '../../variables.scss';

.popover {
position: absolute;
padding: 5px;
border-radius: 6px;
background-color: black;

transition: opacity 200ms;
opacity: 0;
}

.popoverTriangle {
position: absolute;
pointer-events: none;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid black;
}

:global(.popover-enter),
:global(.popover-enter-done) {
&.popover {
opacity: 1;
}
}
106 changes: 106 additions & 0 deletions src/settings-renderer/components/widgets/Popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//////////////////////////////////////////////////////////////////////////////////////////
// _ _ ____ _ _ ___ ____ //
// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
// //
//////////////////////////////////////////////////////////////////////////////////////////

// SPDX-FileCopyrightText: Simon Schneegans <code@simonschneegans.de>
// SPDX-License-Identifier: MIT

import React from 'react';
import { createPortal } from 'react-dom';
import { CSSTransition } from 'react-transition-group';

import * as classes from './Popover.module.scss';

interface IProps {
/** Whether the modal is visible. */
visible: boolean;

/** Called when the user clicks outside the popover area. */
onClickOutside: () => void;

/** Content to display inside the popover. */
content: React.ReactNode;

/** The popover target. It will be used to position the popover relative to it. */
children: React.ReactNode;
}

/**
* A simple popover component. When props.visible becomes true, the popover will be faded
* in with a CSS transition. When it becomes false, the popover will be faded out and its
* content will be unmounted. The popover will be mounted to the body element and
* positioned relative to the children of the Popover component.
*
* @param props - The properties for the modal component.
* @returns A popover element.
*/
export default (props: IProps) => {
const popoverContent = React.useRef(null);
const popoverTriangle = React.useRef(null);
const popoverTarget = React.useRef(null);

React.useEffect(() => {
if (!props.visible) {
return;
}

// Position the popover above the target element.
const triangleSize = 10;
const targetRect = popoverTarget.current.getBoundingClientRect();
const contentRect = popoverContent.current.getBoundingClientRect();
const yDiff = targetRect.top - contentRect.height - triangleSize;
const xDiff = targetRect.left - contentRect.width / 2 + targetRect.width / 2;

// Clamp to window bounds left and right. Vertical position is not clamped yet,
// ideally we would position the popover below the target element if there is not
// // enough space above it.
const windowPadding = 10;
const clampedXDiff = Math.max(
windowPadding,
Math.min(window.innerWidth - contentRect.width - windowPadding, xDiff)
);

popoverContent.current.style.top = `${yDiff}px`;
popoverContent.current.style.left = `${clampedXDiff}px`;
popoverTriangle.current.style.top = `${contentRect.height - 1}px`;
popoverTriangle.current.style.left = `${contentRect.width / 2 - triangleSize + xDiff - clampedXDiff}px`;

// Dismiss the popover when the user clicks outside of it.
const handleClick = (event: MouseEvent) => {
if (
popoverContent.current &&
!popoverContent.current.contains(event.target as Node)
) {
props.onClickOutside();
}
};

document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [props.onClickOutside, props.visible]);

return (
<>
<div ref={popoverTarget}>{props.children}</div>
{createPortal(
<CSSTransition
in={props.visible}
nodeRef={popoverContent}
// The modal CSS class uses a 200ms transition when fading in and out, so we set the
// timeout to 200ms to match this.
timeout={200}
classNames="popover"
unmountOnExit>
<div ref={popoverContent} className={classes.popover}>
<div ref={popoverTriangle} className={classes.popoverTriangle} />
{props.content}
</div>
</CSSTransition>,
document.body
)}
</>
);
};
1 change: 1 addition & 0 deletions src/settings-renderer/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ body {
overflow: hidden;
user-select: none;
background: transparent;
margin: 0;
font-family:
system-ui,
-apple-system,
Expand Down

0 comments on commit ebe5a36

Please sign in to comment.