feat: redesign status hover popup with per-tag groups and accurate data feed

This commit is contained in:
Aleix Soler 2025-11-21 19:11:48 +01:00
parent 298deddf9d
commit 45e2049f7b
9 changed files with 170 additions and 56 deletions

View file

@ -10,8 +10,12 @@ import {
type Props = {
statuses: GroupedStatuses;
onMouseEnter: (statuses: GroupedStatuses) => void;
onMouseLeave: (statuses: GroupedStatuses) => void;
defaultTagName: string;
onMouseEnter: (payload: {
statuses: GroupedStatuses;
defaultTagName: string;
}) => void;
onMouseLeave: () => void;
hideUnknownStatus?: boolean;
unknownStatusConfig?: {
icon: string;
@ -25,6 +29,7 @@ type Props = {
export const FileExplorerIcon: FC<Props> = memo(
({
statuses,
defaultTagName,
onMouseLeave,
onMouseEnter,
hideUnknownStatus,
@ -60,8 +65,10 @@ export const FileExplorerIcon: FC<Props> = memo(
color={color}
iconColorMode={iconColorMode}
iconFrameMode={iconFrameMode}
onMouseEnter={() => onMouseEnter({})}
onMouseLeave={() => onMouseLeave({})}
onMouseEnter={() =>
onMouseEnter({ statuses: {}, defaultTagName })
}
onMouseLeave={onMouseLeave}
/>
);
}
@ -85,8 +92,8 @@ export const FileExplorerIcon: FC<Props> = memo(
count={totalStatuses}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
onMouseEnter={() => onMouseEnter(statuses)}
onMouseLeave={() => onMouseLeave(statuses)}
onMouseEnter={() => onMouseEnter({ statuses, defaultTagName })}
onMouseLeave={onMouseLeave}
/>
);
},

View file

@ -4,11 +4,17 @@ import { StatusDisplay } from "../atoms/StatusDisplay";
export interface Props {
statuses: GroupedStatuses;
defaultTagName: string;
onClose?: () => void;
}
export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => {
const statusEntries = Object.entries(statuses);
export const StatusFileInfoPopup: React.FC<Props> = ({
statuses,
defaultTagName,
}) => {
const statusEntries = Object.entries(statuses).filter(
([, statusList]) => statusList.length > 0,
);
if (statusEntries.length === 0) {
return (
@ -27,40 +33,39 @@ export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => {
</div>
<div className="status-popup-content">
{statusEntries.map(([groupName, statusList]) => (
<div key={groupName} className="status-group">
<div className="status-group__header">
<span className="status-group__name">
{groupName.toLowerCase()}
</span>
<span className="status-group__count">
{statusList.length}
</span>
</div>
<div className="status-group__items">
{statusList.map((status, index) => (
<div
key={`${groupName}-${index}`}
className="status-item"
>
<StatusDisplay
status={status}
variant="badge"
/>
{status.description && (
<div
className="status-description"
title={status.description}
>
{status.description}
</div>
)}
<div className="status-tag-grid">
{statusEntries.map(([groupName, statusList]) => {
const isDefault = groupName === defaultTagName;
return (
<div key={groupName} className="status-tag-card">
<div className="status-tag-card__header">
<div className="status-tag-card__title">
<span className="status-tag-card__label">
{groupName}
</span>
{isDefault && (
<span className="status-tag-card__badge">
Default tag
</span>
)}
</div>
<span className="status-tag-card__count">
{statusList.length}
</span>
</div>
))}
</div>
</div>
))}
<div className="status-tag-card__statuses">
{statusList.map((status, index) => (
<StatusDisplay
key={`${groupName}-${index}`}
status={status}
variant="badge"
/>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);

View file

@ -5,6 +5,7 @@ import { StatusIcon } from "@/components/atoms/StatusIcon";
interface EditorToolbarButtonProps {
statuses: GroupedStatuses;
defaultTagName: string;
onClick: () => void;
unknownStatusConfig: {
icon: string;
@ -14,7 +15,7 @@ interface EditorToolbarButtonProps {
}
export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
({ statuses, onClick, unknownStatusConfig }) => {
({ statuses, defaultTagName, onClick, unknownStatusConfig }) => {
const statusEntries = Object.entries(statuses);
const allStatuses = statusEntries.flatMap(
([_, statusList]) => statusList,
@ -22,7 +23,7 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
const totalStatuses = allStatuses.length;
const handleMouseEnter = () => {
StatusesInfoPopup.open(statuses);
StatusesInfoPopup.open({ statuses, defaultTagName });
};
const handleMouseLeave = () => {

View file

@ -318,6 +318,28 @@ export class NoteStatusService extends BaseNoteStatusService {
return this.buildStatusesArray(identifiers);
}
public getStatusesByAllKeys(): GroupedStatuses {
const keys = getFrontmatterKeysForFile(this.file);
const grouped: GroupedStatuses = {};
let hasStatuses = false;
keys.forEach((key) => {
const statuses = this.getStatusesForKey(key);
if (!statuses.length) {
return;
}
grouped[key] = statuses;
hasStatuses = true;
});
if (!hasStatuses) {
grouped[settingsService.settings.tagPrefix] =
this.statuses[settingsService.settings.tagPrefix] ?? [];
}
return grouped;
}
async removeStatus(
frontmatterTagName: string,
status: NoteStatus,

View file

@ -121,7 +121,7 @@ export class FileExplorerIntegration implements IElementProcessor {
}
const noteStatusService = this.getFileNoteStatusService(dataPath);
const statuses = noteStatusService?.statuses ?? null;
const statuses = noteStatusService?.getStatusesByAllKeys() ?? {};
const primaryStatus = getPrimaryStatus(statuses);
const hasStatus = Boolean(primaryStatus);
const fallbackColor = getUnknownStatusColor();
@ -138,7 +138,11 @@ export class FileExplorerIntegration implements IElementProcessor {
this.applyLeftBorder(navItem, statusColor, hasStatus);
if (noteStatusService) {
this.render(textEl, noteStatusService.statuses);
this.render(
textEl,
statuses,
settingsService.settings.tagPrefix,
);
}
}
}
@ -151,7 +155,11 @@ export class FileExplorerIntegration implements IElementProcessor {
};
}
render(element: Element, statuses: GroupedStatuses): void {
render(
element: Element,
statuses: GroupedStatuses,
defaultTagName: string,
): void {
// Remove existing icon
const existingIcon = element.querySelector(`.${this.ICON_CLASS}`);
if (existingIcon) {
@ -174,7 +182,8 @@ export class FileExplorerIntegration implements IElementProcessor {
root.render(
<FileExplorerIcon
statuses={statuses}
onMouseEnter={(s) => this.openModalInfo(s)}
defaultTagName={defaultTagName}
onMouseEnter={this.openModalInfo}
onMouseLeave={this.closeModalInfo}
hideUnknownStatus={
settingsService.settings.hideUnknownStatusInExplorer
@ -200,15 +209,21 @@ export class FileExplorerIntegration implements IElementProcessor {
}
}
private openModalInfo(statuses: GroupedStatuses) {
private openModalInfo = ({
statuses,
defaultTagName,
}: {
statuses: GroupedStatuses;
defaultTagName: string;
}) => {
if (!this.plugin) {
return;
}
StatusesInfoPopup.open(statuses);
}
private closeModalInfo() {
StatusesInfoPopup.open({ statuses, defaultTagName });
};
private closeModalInfo = () => {
StatusesInfoPopup.close();
}
};
/**
* Cleanup integration and unsubscribe from events

View file

@ -8,10 +8,17 @@ export class StatusesInfoPopup {
private static instance: StatusesInfoPopup | null = null;
private element: HTMLElement | null = null;
private statuses: GroupedStatuses;
private defaultTagName: string;
private constructor() {}
static open(statuses: GroupedStatuses) {
static open({
statuses,
defaultTagName,
}: {
statuses: GroupedStatuses;
defaultTagName: string;
}) {
// Always ensure previous instance is cleaned up before showing or when disabled
StatusesInfoPopup.close();
@ -22,6 +29,7 @@ export class StatusesInfoPopup {
}
StatusesInfoPopup.instance = new StatusesInfoPopup();
StatusesInfoPopup.instance.statuses = statuses;
StatusesInfoPopup.instance.defaultTagName = defaultTagName;
StatusesInfoPopup.instance.show();
}
@ -38,7 +46,12 @@ export class StatusesInfoPopup {
document.body.appendChild(this.element);
this.root = createRoot(this.element);
this.root.render(<StatusFileInfoPopup statuses={this.statuses} />);
this.root.render(
<StatusFileInfoPopup
statuses={this.statuses}
defaultTagName={this.defaultTagName}
/>,
);
}
private destroy() {

View file

@ -338,9 +338,12 @@ export class EditorToolbarIntegration {
}
private renderButtonForLeaf(leafButton: LeafButton): void {
const statuses =
leafButton.noteStatusService?.getStatusesByAllKeys() || {};
leafButton.root.render(
<EditorToolbarButton
statuses={leafButton.noteStatusService?.statuses || {}}
statuses={statuses}
defaultTagName={settingsService.settings.tagPrefix}
onClick={() => this.openStatusModal(leafButton.leaf)}
unknownStatusConfig={this.getUnknownStatusConfig()}
/>,

File diff suppressed because one or more lines are too long

View file

@ -172,6 +172,54 @@
color: var(--text-muted);
font-style: italic;
}
.status-tag-grid {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.status-tag-card {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-2);
background: var(--background-primary-alt);
box-shadow: var(--shadow-xs);
}
.status-tag-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-2-2);
}
.status-tag-card__title {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
}
.status-tag-card__label {
font-weight: var(--font-semibold);
text-transform: none;
}
.status-tag-card__badge {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
background: var(--background-modifier-border);
padding: 2px 6px;
border-radius: var(--radius-s);
width: fit-content;
}
.status-tag-card__count {
font-size: var(--font-ui-small);
color: var(--text-muted);
background: var(--background-modifier-border);
padding: 2px 10px;
border-radius: var(--radius-s);
font-weight: var(--font-medium);
}
.status-tag-card__statuses {
display: flex;
flex-wrap: wrap;
gap: var(--size-2-2);
}
/* Status Groups in Popup */
.status-group {