Skip to content

Commit d9393b3

Browse files
committed
Add Menu, MenuBar, MenuItem, and TagInput framework components
New components: - Menu/MenuBar/MenuItem: popover-based menu system replacing Dropdown for action menus (used in ScoreLibrary) - TagInput: inline tag rendering with text input and autocomplete (completions filtered by owner-supplied list) Fixes: - Input: default padding 3px so caret doesn't sit at the edge - ScoreLibrary: use closure-captured data in menu handlers instead of shared instance state (fixed "Import Score" and new folder menus) - Add Folder dialog: use placeholder instead of pre-filled content - Menu: render popover container always (native popover API needs it in DOM), guard hidePopover for jsdom Tests: 27 new unit tests (MenuItem, Menu, MenuBar, TagInput) Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent f4056a0 commit d9393b3

13 files changed

Lines changed: 1074 additions & 58 deletions

File tree

src/App.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,9 +633,10 @@ export class App extends UIComponent<{}, IAppState> {
633633
{
634634
type: ValueEditorEntryType.Value,
635635
id: "folderName",
636-
content: "Name of the new folder",
636+
content: "",
637+
placeholder: "Name of the new folder",
637638
displayWidth: 6,
638-
}],
639+
} as IValueEditorValueEntry],
639640
);
640641

641642
if (result?.closure !== DialogResponseClosure.Accept) {

src/components/ui/framework/Button.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class Button extends UIComponent<IButtonProperties> {
5353
id, children, caption, style, orientation, round, imageOnly, disabled, isDefault, title, role,
5454
name, value, popoverTarget, onClick
5555
} = this.props;
56+
5657
const className = this.generateFinalClassName([
5758
"btn",
5859
"du-btn",

src/components/ui/framework/Input.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export class Input extends UIComponent<IInputProperties> {
7272
const className = this.generateFinalClassName(["input"]);
7373

7474
const newStyle = {
75+
padding: "3px",
7576
...style,
7677
textAlign: textAlignment,
7778
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright (c) Mike Lischke. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*/
5+
6+
.menuItem {
7+
display: flex;
8+
align-items: center;
9+
10+
padding: 6px 12px;
11+
gap: 8px;
12+
13+
cursor: default;
14+
user-select: none;
15+
16+
border: 1px solid transparent;
17+
border-radius: 4px;
18+
19+
white-space: nowrap;
20+
}
21+
22+
.menuItem .icon {
23+
width: 16px;
24+
height: 16px;
25+
}
26+
27+
.menuItem:not(.disabled):hover {
28+
background-color: var(--color-base-200);
29+
}
30+
31+
.menuItem.disabled {
32+
opacity: 0.4;
33+
cursor: default;
34+
}
35+
36+
.menuItem.separator {
37+
height: 1px;
38+
margin: 4px 0;
39+
padding: 0;
40+
41+
background-color: var(--color-base-300);
42+
border: none;
43+
}
44+
45+
.menuBar {
46+
display: flex;
47+
align-items: center;
48+
gap: 0;
49+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright (c) Mike Lischke. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*/
5+
6+
import "./Menu.css";
7+
8+
import { ComponentChild } from "preact";
9+
10+
import { Button } from "../Button.js";
11+
import { Icon } from "../Icon.js";
12+
import { UIComponent, type ICommonUIProperties } from "../UIComponent.js";
13+
import { getNewId } from "../../../../core/utils.js";
14+
import { Codicon } from "../Codicon.js";
15+
import { type IMenuItem, MenuItem } from "./MenuItem.js";
16+
17+
interface IMenuProperties extends ICommonUIProperties {
18+
/** The menu items (including separators). */
19+
items: IMenuItem[];
20+
21+
/** Shown as the trigger button caption when used standalone. */
22+
caption?: string;
23+
24+
/** Shown as the trigger button icon when used standalone. */
25+
icon?: Codicon;
26+
27+
/** Called when an item is clicked. The item id is passed. */
28+
onItemClick?: (id: string) => void;
29+
}
30+
31+
interface IMenuState {
32+
open: boolean;
33+
}
34+
35+
/**
36+
* A vertical popup menu. Uses the native popover API for positioning.
37+
*
38+
* Can be used standalone (renders a trigger button) or embedded
39+
* in a MenuBar (the MenuBar controls open/close via ref).
40+
*/
41+
export class Menu extends UIComponent<IMenuProperties, IMenuState> {
42+
private popoverId = `menu-popover-${getNewId()}`;
43+
private anchorName = `--menu-anchor-${getNewId()}`;
44+
45+
public constructor(props: IMenuProperties) {
46+
super(props);
47+
48+
this.state = {
49+
open: false,
50+
};
51+
}
52+
53+
public render(): ComponentChild {
54+
const { id, caption, icon, items, style } = this.props;
55+
const triggerShown = (caption ?? icon) !== undefined;
56+
const className = this.generateFinalClassName(["menuHost"]);
57+
58+
return (
59+
<div id={id} className={className} style={style}>
60+
{triggerShown && (
61+
<div style={{ anchorName: this.anchorName }}>
62+
<Button
63+
className="du-btn-ghost"
64+
popoverTarget={this.popoverId}
65+
imageOnly={!caption && icon !== undefined}
66+
onClick={(e) => {
67+
e.stopPropagation();
68+
}}
69+
>
70+
{icon && <Icon src={icon} />}
71+
{caption}
72+
</Button>
73+
</div>
74+
)}
75+
76+
<div
77+
className="menu du-dropdown du-menu rounded-box bg-base-100 shadow-sm"
78+
id={this.popoverId}
79+
popover="auto"
80+
style={{ positionAnchor: this.anchorName }}
81+
onKeyDown={this.handleKeyDown}
82+
onToggle={this.handlePopoverToggle}
83+
>
84+
{items.map((item) => {
85+
return (
86+
<div
87+
key={item.id}
88+
onClick={() => {
89+
if (!item.disabled && item.label !== "-") {
90+
this.props.onItemClick?.(item.id);
91+
this.close();
92+
}
93+
}}
94+
>
95+
<MenuItem item={item} />
96+
</div>
97+
);
98+
})}
99+
</div>
100+
</div>
101+
);
102+
}
103+
104+
public open(): void {
105+
this.setState({ open: true });
106+
}
107+
108+
public close(): void {
109+
const popover = document.getElementById(this.popoverId);
110+
111+
if (popover && typeof (popover as HTMLElement & { hidePopover?: () => void; }).hidePopover === "function") {
112+
(popover as HTMLElement & { hidePopover: () => void; }).hidePopover();
113+
}
114+
115+
this.setState({ open: false });
116+
}
117+
118+
private handleKeyDown = (e: KeyboardEvent): void => {
119+
if (e.key === "Escape") {
120+
this.close();
121+
}
122+
};
123+
124+
private handlePopoverToggle = (e: Event): void => {
125+
const popover = e.target as HTMLElement;
126+
127+
this.setState({ open: popover.matches(":popover-open") });
128+
};
129+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright (c) Mike Lischke. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*/
5+
6+
import { ComponentChild, createRef } from "preact";
7+
8+
import { Button } from "../Button.js";
9+
import { Icon } from "../Icon.js";
10+
import { UIComponent, type ICommonUIProperties } from "../UIComponent.js";
11+
import { type IMenuItem } from "./MenuItem.js";
12+
import { Menu } from "./Menu.js";
13+
14+
interface IMenuBarItem extends IMenuItem {
15+
/** The nested menu items shown when this top-level item is clicked. */
16+
children?: IMenuItem[];
17+
}
18+
19+
interface IMenuBarProperties extends ICommonUIProperties {
20+
items: IMenuBarItem[];
21+
22+
/** Called when a menu item is clicked (any level). */
23+
onItemClick?: (id: string) => void;
24+
}
25+
26+
interface IMenuBarState {
27+
openMenuId: string;
28+
}
29+
30+
/**
31+
* A horizontal bar of top-level items that each open a dropdown Menu.
32+
*/
33+
export class MenuBar extends UIComponent<IMenuBarProperties, IMenuBarState> {
34+
private menuRefs = new Map<string, preact.RefObject<Menu>>();
35+
36+
public constructor(props: IMenuBarProperties) {
37+
super(props);
38+
39+
this.state = {
40+
openMenuId: "",
41+
};
42+
}
43+
44+
public render(): ComponentChild {
45+
const { id, items, style } = this.props;
46+
const { openMenuId } = this.state;
47+
const className = this.generateFinalClassName(["menuBar"]);
48+
49+
return (
50+
<div
51+
id={id}
52+
className={className}
53+
style={{ ...style, display: "flex", alignItems: "center" }}
54+
onMouseLeave={() => {
55+
this.setState({ openMenuId: "" });
56+
}}
57+
>
58+
{items.map((item) => {
59+
const refKey = item.id;
60+
let menuRef = this.menuRefs.get(refKey);
61+
62+
if (!menuRef) {
63+
menuRef = createRef<Menu>();
64+
this.menuRefs.set(refKey, menuRef);
65+
}
66+
67+
const isOpen = openMenuId === item.id;
68+
69+
return (
70+
<div
71+
key={item.id}
72+
onMouseEnter={() => {
73+
if (openMenuId !== "") {
74+
this.setState({ openMenuId: item.id });
75+
}
76+
}}
77+
>
78+
<Button
79+
imageOnly={!item.label && item.icon !== undefined}
80+
onClick={() => {
81+
if (item.children) {
82+
this.setState({
83+
openMenuId: isOpen ? "" : item.id,
84+
}, () => {
85+
if (!isOpen) {
86+
menuRef.current?.open();
87+
}
88+
});
89+
}
90+
91+
this.props.onItemClick?.(item.id);
92+
}}
93+
>
94+
{item.icon && <Icon src={item.icon} />}
95+
{item.label}
96+
</Button>
97+
{item.children && (
98+
<Menu
99+
ref={menuRef}
100+
items={item.children}
101+
onItemClick={(childId) => {
102+
this.setState({ openMenuId: "" });
103+
this.props.onItemClick?.(childId);
104+
}}
105+
/>
106+
)}
107+
</div>
108+
);
109+
})}
110+
</div>
111+
);
112+
}
113+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright (c) Mike Lischke. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*/
5+
6+
import { ComponentChild } from "preact";
7+
8+
import { Icon } from "../Icon.js";
9+
import { UIComponent, type ICommonUIProperties } from "../UIComponent.js";
10+
import { Codicon } from "../Codicon.js";
11+
12+
export interface IMenuItem {
13+
id: string;
14+
label: string;
15+
icon?: Codicon;
16+
17+
/** If true, shows a right-pointing arrow indicating a submenu. */
18+
hasSubMenu?: boolean;
19+
20+
/** Use "-" as label for a separator line. */
21+
disabled?: boolean;
22+
}
23+
24+
interface IMenuItemViewProperties extends ICommonUIProperties {
25+
item: IMenuItem;
26+
}
27+
28+
/**
29+
* Renders a single menu item: label, optional icon, optional submenu arrow.
30+
* Set `item.label = "-"` and `item.disabled` for a separator.
31+
*/
32+
export class MenuItem extends UIComponent<IMenuItemViewProperties> {
33+
34+
public render(): ComponentChild {
35+
const { id, item, style } = this.props;
36+
const { label, icon, hasSubMenu, disabled } = item;
37+
const isSeparator = label === "-";
38+
const className = this.generateFinalClassName([
39+
"menuItem",
40+
this.classFromProperty(disabled ?? isSeparator, "disabled"),
41+
this.classFromProperty(hasSubMenu ?? false, "submenu"),
42+
isSeparator ? "separator" : "",
43+
]);
44+
45+
if (isSeparator) {
46+
return (
47+
<div id={id} className={className} style={style} />
48+
);
49+
}
50+
51+
return (
52+
<div id={id} className={className} style={style}>
53+
{icon && <Icon src={icon} />}
54+
<span>{label}</span>
55+
{hasSubMenu && (
56+
<Icon src={Codicon.ChevronRight} style={{ marginLeft: "auto", fontSize: "12px" }} />
57+
)}
58+
</div>
59+
);
60+
}
61+
}

0 commit comments

Comments
 (0)