feat: add lucid icons support

This commit is contained in:
Aleix Soler 2025-11-21 08:04:30 +01:00
parent e4eb51108b
commit 1b26ba6d80
24 changed files with 477 additions and 50 deletions

View file

@ -65,6 +65,7 @@ export const FileExplorerIcon: FC<Props> = memo(
return (
<StatusIconPreview
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
color={iconColor}
count={totalStatuses}
iconFrameMode={iconFrameMode}

View file

@ -18,6 +18,7 @@ export type StatusItem = {
name: string;
color: string;
icon?: string;
lucideIcon?: string;
};
export type FilesByStatus = {
@ -69,6 +70,7 @@ const GroupedStatusViewContent = () => {
name: noteStatus.name,
color: noteStatus.color || "white",
icon: noteStatus.icon,
lucideIcon: noteStatus.lucideIcon,
};
const scopedIdentifier = noteStatus.templateId

View file

@ -54,7 +54,11 @@ export const StatusGroup = ({
<div className="grouped-status-group__status">
<StatusDisplay
status={
{ ...status, icon: status.icon || "" } as NoteStatus
{
...status,
icon: status.icon || "",
lucideIcon: status.lucideIcon,
} as NoteStatus
}
variant="badge"
/>

View file

@ -1,13 +1,15 @@
import { NoteStatus } from "@/types/noteStatus";
import React from "react";
import { Input } from "@/components/atoms/Input";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import { LucideIconPicker } from "./LucideIconPicker";
export type Props = {
status: NoteStatus;
index: number;
onCustomStatusChange: (
index: number,
column: "name" | "icon" | "color" | "description",
column: "name" | "icon" | "color" | "description" | "lucideIcon",
value: string,
) => void;
onCustomStatusRemove: (index: number) => void;
@ -28,26 +30,47 @@ export const CustomStatusItem: React.FC<Props> = ({
canMoveDown = false,
}) => {
const isValid = status.name.trim().length > 0;
const displayIcon = status.icon.trim() || "📝";
return (
<div className="custom-status-item">
{/* Simple horizontal layout with all inputs in a row */}
<div className="custom-status-item__row">
{/* Icon field - simple text input */}
<div className="custom-status-item__field">
<label className="custom-status-item__label">Icon</label>
<div className="custom-status-item__row custom-status-item__row--icons">
<div className="custom-status-item__field custom-status-item__field--icon">
<label className="custom-status-item__label">
Emoji icon
</label>
<Input
variant="text"
value={status.icon}
onChange={(value) =>
onCustomStatusChange(index, "icon", value || "")
}
placeholder="📝"
placeholder="Example: ✅ or 🚧"
className="custom-status-item__input custom-status-item__input--icon"
/>
<p className="custom-status-item__hint">
This fallback icon is shown anywhere Lucide is not
available.
</p>
</div>
<div className="custom-status-item__field custom-status-item__field--lucide">
<label className="custom-status-item__label">
Lucide icon (optional)
</label>
<LucideIconPicker
value={status.lucideIcon || ""}
onChange={(value) =>
onCustomStatusChange(index, "lucideIcon", value)
}
/>
<p className="custom-status-item__hint">
Matches Obsidian&apos;s toolbar icons so your status
button blends in.
</p>
</div>
</div>
{/* Simple horizontal layout with remaining inputs */}
<div className="custom-status-item__row">
{/* Name field */}
<div className="custom-status-item__field custom-status-item__field--name">
<label className="custom-status-item__label">
@ -139,12 +162,13 @@ export const CustomStatusItem: React.FC<Props> = ({
{/* Preview row - shows how the status will look */}
<div className="custom-status-item__preview">
<span
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
className="custom-status-item__preview-icon"
size={18}
style={{ color: status.color }}
>
{displayIcon}
</span>
/>
<span
className="custom-status-item__preview-text"
style={{ color: status.color }}

View file

@ -20,6 +20,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
currentStatuses.push({
name: "",
icon: "",
lucideIcon: "",
});
onChange("customStatuses", currentStatuses);
};
@ -84,7 +85,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
<SettingItem
name="Custom statuses"
description="Create custom statuses with icons, names, and colors. All statuses require a name."
description="Create custom statuses with emoji icons and optional Lucide icon names plus colors. All statuses require a name."
vertical
>
<div className="custom-status-list">

View file

@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from "react";
import { IconName, getIconIds } from "obsidian";
import { Input } from "@/components/atoms/Input";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export interface LucideIconPickerProps {
value?: string;
onChange: (value: string) => void;
maxResults?: number;
}
const DEFAULT_MAX_RESULTS = 36;
export const LucideIconPicker: React.FC<LucideIconPickerProps> = ({
value = "",
onChange,
maxResults = DEFAULT_MAX_RESULTS,
}) => {
const [query, setQuery] = useState(value);
useEffect(() => {
setQuery(value || "");
}, [value]);
const iconIds = useMemo(() => {
try {
return getIconIds()
.slice()
.sort((a, b) => a.localeCompare(b));
} catch (error) {
console.error("Failed to load Lucide icon list", error);
return [] as IconName[];
}
}, []);
const filteredIcons = useMemo(() => {
if (!iconIds.length) {
return [];
}
const normalizedQuery = query.trim().toLowerCase();
const source = normalizedQuery.length
? iconIds.filter((name) =>
name.toLowerCase().includes(normalizedQuery),
)
: iconIds;
return source.slice(0, maxResults);
}, [iconIds, query, maxResults]);
const handleSelect = (iconName: IconName) => {
onChange(iconName);
setQuery(iconName);
};
const handleClear = () => {
onChange("");
setQuery("");
};
return (
<div className="lucide-icon-picker">
<div className="lucide-icon-picker__control">
<Input
variant="text"
value={query}
onChange={setQuery}
placeholder="Search by name (e.g. check-circle)"
className="lucide-icon-picker__input"
/>
{value && (
<button
type="button"
className="lucide-icon-picker__clear"
onClick={handleClear}
>
Clear
</button>
)}
</div>
<div className="lucide-icon-picker__selected">
<span className="lucide-icon-picker__selected-preview">
<StatusIcon
icon={value}
lucideIcon={value}
size={18}
className="lucide-icon-picker__selected-icon"
/>
</span>
<span className="lucide-icon-picker__selected-label">
{value || "No Lucide icon selected"}
</span>
</div>
{!iconIds.length && (
<div className="lucide-icon-picker__empty">
Lucide icons are not available yet. Try reloading the
plugin.
</div>
)}
{iconIds.length > 0 && (
<div className="lucide-icon-picker__grid">
{filteredIcons.map((iconName) => {
const isSelected = iconName === value;
return (
<button
type="button"
key={iconName}
className={`lucide-icon-picker__option ${isSelected ? "lucide-icon-picker__option--selected" : ""}`}
onClick={() => handleSelect(iconName)}
title={iconName}
>
<StatusIcon
icon={iconName}
lucideIcon={iconName}
size={16}
/>
<span>{iconName}</span>
</button>
);
})}
{filteredIcons.length === 0 && (
<div className="lucide-icon-picker__empty">
No icons match "{query.trim()}"
</div>
)}
</div>
)}
</div>
);
};

View file

@ -1,5 +1,6 @@
import React from "react";
import { PluginSettings } from "@/types/pluginSettings";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export type StatusGroupProps = {
statuses: PluginSettings["customStatuses"];
@ -39,9 +40,12 @@ export const StatusGroup: React.FC<StatusGroupProps> = ({
>
<div className="status-selector__content">
<div className="status-selector__status">
<span className="status-selector__icon">
{status.icon}
</span>
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
className="status-selector__icon"
size={16}
/>
<span className="status-selector__name">
{status.name}
</span>

View file

@ -23,6 +23,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
{
name: "",
icon: "",
lucideIcon: "",
color: "#888888",
templateId: template?.id || "",
},
@ -63,6 +64,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
{
name: "",
icon: "",
lucideIcon: "",
color: "#888888",
templateId: template?.id || "",
},
@ -72,7 +74,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
const handleStatusChange = useCallback(
(
index: number,
column: "name" | "icon" | "color" | "description",
column: "name" | "icon" | "color" | "description" | "lucideIcon",
value: string,
) => {
setStatuses((prev) =>
@ -145,7 +147,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
<SettingItem
name="Statuses"
description="Define the statuses included in this template"
description="Define the statuses included in this template, including emojis and optional Lucide icons"
vertical
>
<div className="template-editor-statuses">

View file

@ -268,7 +268,7 @@ export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
<SettingItem
name="Icon for unknown status"
description="Icon displayed whenever a note does not have a status."
description="Emoji or Lucide icon name displayed whenever a note does not have a status."
>
<Input
variant="text"

View file

@ -5,6 +5,7 @@ import {
Props as StatusBarContextProps,
} from "./StatusBarContext";
import { StatusBarGroup } from "./StatusBarGroup";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export type Props = {
statuses: GroupedStatuses;
@ -51,13 +52,14 @@ export const StatusBar: FC<Props> = ({
}}
>
{noStatusConfig.showIcon && (
<span
<StatusIcon
icon={noStatusConfig.icon}
size={14}
className="status-bar-no-status-icon"
style={{
marginRight: noStatusConfig.showText ? "4px" : "0",
}}
>
{noStatusConfig.icon}
</span>
/>
)}
{noStatusConfig.showText && noStatusConfig.text}
</span>

View file

@ -1,6 +1,7 @@
import React, { FC, memo } from "react";
import { GroupedStatuses } from "@/types/noteStatus";
import { StatusesInfoPopup } from "@/integrations/popups/statusesInfoPopupIntegration";
import { StatusIcon } from "@/components/atoms/StatusIcon";
interface EditorToolbarButtonProps {
statuses: GroupedStatuses;
@ -40,12 +41,12 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
onMouseLeave={handleMouseLeave}
aria-label="Add status to note"
>
<span
<StatusIcon
icon={unknownStatusConfig.icon}
size={16}
className="editor-toolbar-button__icon"
style={{ color: unknownStatusConfig.color }}
>
{unknownStatusConfig.icon}
</span>
/>
</button>
);
}
@ -63,16 +64,17 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
onMouseLeave={handleMouseLeave}
aria-label={`Current status: ${primaryStatus.name}. Click to change.`}
>
<span
<StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{
color:
primaryStatus.color ||
"var(--interactive-accent)",
}}
>
{primaryStatus.icon || "📝"}
</span>
/>
</button>
);
}
@ -88,16 +90,17 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
aria-label={`${totalStatuses} statuses assigned. Click to change.`}
>
<div className="editor-toolbar-button__icon-container">
<span
<StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{
color:
primaryStatus.color ||
"var(--interactive-accent)",
}}
>
{primaryStatus.icon || "📝"}
</span>
/>
<span className="editor-toolbar-button__counter">
{totalStatuses}
</span>

View file

@ -2,6 +2,7 @@ import { NoteStatus } from "@/types/noteStatus";
import { FC, memo, useState } from "react";
import { getStatusTooltip } from "@/utils/statusUtils";
import { ObsidianIcon } from "./ObsidianIcon";
import { StatusIcon } from "./StatusIcon";
export type StatusDisplayVariant = "chip" | "badge" | "template";
@ -82,9 +83,12 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
}}
onClick={handleClick}
>
<span className="note-status-chip-icon">
{status.icon ? status.icon : "📝"}
</span>
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
className="note-status-chip-icon"
size={16}
/>
<span className="note-status-chip-text">
{getDisplayName()}
</span>
@ -121,9 +125,12 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
onClick={handleClick}
>
<div className="status-badge-item">
<span className="status-badge-icon">
{status.icon ? status.icon : "📝"}
</span>
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
className="status-badge-icon"
size={14}
/>
<span className="status-badge-text">
{getDisplayName()}
</span>
@ -144,7 +151,13 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
}
/>
<span>
{status.icon ? status.icon : "📝"} {getDisplayName()}
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
size={14}
style={{ marginRight: "4px" }}
/>
{getDisplayName()}
</span>
</div>
);

View file

@ -0,0 +1,52 @@
import React, { CSSProperties, FC } from "react";
import { ObsidianIcon } from "./ObsidianIcon";
import { resolveLucideIconName } from "@/utils/iconUtils";
interface StatusIconProps {
icon?: string;
lucideIcon?: string;
fallbackIcon?: string;
className?: string;
size?: number;
style?: CSSProperties;
}
export const StatusIcon: FC<StatusIconProps> = ({
icon,
lucideIcon,
fallbackIcon = "📝",
className = "",
size,
style = {},
}) => {
const lucideName = resolveLucideIconName(lucideIcon, icon);
const resolvedSize = size ?? 16;
const combinedClassName = ["status-icon", className]
.filter(Boolean)
.join(" ");
const wrapperStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
...style,
};
if (size) {
wrapperStyle.fontSize = `${size}px`;
}
const fallbackValue =
icon && icon.trim().length ? icon : (fallbackIcon ?? "");
return (
<span className={combinedClassName} style={wrapperStyle}>
{lucideName ? (
<ObsidianIcon name={lucideName} size={resolvedSize} />
) : (
fallbackValue
)}
</span>
);
};

View file

@ -1,10 +1,12 @@
import React, { CSSProperties, FC, memo } from "react";
import { StatusIcon } from "./StatusIcon";
type IconFrameMode = "always" | "never";
type IconColorMode = "status" | "theme";
export interface StatusIconPreviewProps {
icon?: string;
lucideIcon?: string;
color?: string;
count?: number;
iconFrameMode?: IconFrameMode;
@ -14,6 +16,7 @@ export interface StatusIconPreviewProps {
iconClassName?: string;
wrapperClassName?: string;
style?: CSSProperties;
iconSize?: number;
onMouseEnter?: (event: React.MouseEvent<HTMLDivElement>) => void;
onMouseLeave?: (event: React.MouseEvent<HTMLDivElement>) => void;
}
@ -21,6 +24,7 @@ export interface StatusIconPreviewProps {
export const StatusIconPreview: FC<StatusIconPreviewProps> = memo(
({
icon,
lucideIcon,
color,
count,
iconFrameMode = "never",
@ -30,10 +34,10 @@ export const StatusIconPreview: FC<StatusIconPreviewProps> = memo(
iconClassName = "",
wrapperClassName = "",
style,
iconSize,
onMouseEnter,
onMouseLeave,
}) => {
const iconDisplay = icon?.trim().length ? icon : "📝";
const useStatusColor = iconColorMode === "status";
const appliedColor = useStatusColor && color ? color.trim() : undefined;
@ -58,7 +62,12 @@ export const StatusIconPreview: FC<StatusIconPreviewProps> = memo(
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<span className="status-minimal__icon">{iconDisplay}</span>
<StatusIcon
icon={icon}
lucideIcon={lucideIcon}
size={iconSize ?? (compact ? 13 : 16)}
className="status-minimal__icon"
/>
{count && count > 1 && (
<span
className="status-minimal__count"

View file

@ -34,6 +34,7 @@ export const StatusModalOption: React.FC<StatusOptionProps> = memo(
<div className="note-status-option__icon-wrapper">
<StatusIconPreview
icon={status.icon}
lucideIcon={status.lucideIcon}
color={status.color}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
@ -101,7 +102,7 @@ export const StatusSelector: React.FC<Props> = ({
>
{availableStatuses.map((status, index) => (
<StatusModalOption
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}`}
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}:${status.lucideIcon ?? ""}`}
status={status}
isSelected={isStatusSelected(status, currentStatuses)}
isFocused={index === focusedIndex}

View file

@ -18,6 +18,7 @@ interface StatusItem {
name: string;
color: string;
icon?: string;
lucideIcon?: string;
}
interface FilesByStatus {
@ -57,6 +58,7 @@ export class GroupedDashboardView extends ItemView {
name: status.name,
color: status.color || "",
icon: status.icon,
lucideIcon: status.lucideIcon,
});
private getAllFiles = (): FileItem[] => {

View file

@ -42,6 +42,7 @@ export class GroupedStatusView extends ItemView {
name: status.name,
color: status.color || "white",
icon: status.icon,
lucideIcon: status.lucideIcon,
});
private getAllFiles = (): FileItem[] => {

View file

@ -53,6 +53,9 @@
.status-minimal__icon {
font-size: 11px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
filter: grayscale(0.2);
transition: filter var(--anim-duration-fast) ease;
}

View file

@ -36,12 +36,23 @@
margin-bottom: var(--size-4-2);
}
.custom-status-item__row--icons {
align-items: stretch;
flex-wrap: wrap;
}
.custom-status-item__field {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
}
.custom-status-item__field--icon,
.custom-status-item__field--lucide {
flex: 1;
min-width: 240px;
}
.custom-status-item__field--name {
flex: 2;
min-width: 120px;
@ -82,9 +93,15 @@
}
.custom-status-item__input--icon {
width: 50px !important;
text-align: center;
font-size: 1.2em;
width: 100% !important;
text-align: left;
font-size: 1em;
}
.custom-status-item__hint {
margin: 0;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.custom-status-item__input--name {
@ -203,6 +220,97 @@
border-left: 3px solid var(--text-error);
}
.lucide-icon-picker {
display: flex;
flex-direction: column;
gap: var(--size-2-2);
}
.lucide-icon-picker__control {
display: flex;
gap: var(--size-2-1);
align-items: center;
}
.lucide-icon-picker__input {
flex: 1;
}
.lucide-icon-picker__clear {
background: var(--background-modifier-border);
border: none;
padding: 4px 8px;
border-radius: var(--radius-s);
cursor: pointer;
font-size: var(--font-ui-smaller);
}
.lucide-icon-picker__clear:hover {
background: var(--interactive-hover);
color: var(--text-normal);
}
.lucide-icon-picker__selected {
display: flex;
align-items: center;
gap: var(--size-2-1);
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.lucide-icon-picker__selected-preview {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--background-secondary);
border-radius: var(--radius-s);
}
.lucide-icon-picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--size-2-1);
max-height: 220px;
overflow-y: auto;
padding: var(--size-2-1);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
background: var(--background-primary-alt);
}
.lucide-icon-picker__option {
display: flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-1) var(--size-2-2);
border-radius: var(--radius-s);
border: 1px solid transparent;
background: transparent;
cursor: pointer;
font-size: var(--font-ui-smaller);
transition:
border var(--anim-duration-fast) ease,
background var(--anim-duration-fast) ease;
}
.lucide-icon-picker__option:hover {
background: var(--background-modifier-hover);
}
.lucide-icon-picker__option--selected {
border-color: var(--interactive-accent);
background: var(--background-modifier-success);
color: var(--text-normal);
}
.lucide-icon-picker__empty {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
padding: var(--size-2-1);
}
/* Custom Status List - BEM */
.custom-status-list {
/* Container for multiple custom status items */

View file

@ -0,0 +1,6 @@
.status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}

View file

@ -21,6 +21,9 @@
align-items: center;
justify-content: center;
color: var(--text-muted);
width: 18px;
height: 18px;
line-height: 1;
}
/* Icon with status color */

View file

@ -15,5 +15,6 @@
@import "components/group-label.css";
@import "components/collapsible-counter.css";
@import "components/status-display.css";
@import "components/status-icon.css";
@import "components/obsidian-icon.css";
@import "components/toolbar-button.css";

View file

@ -1,6 +1,7 @@
export type NoteStatus = {
name: string;
icon: string;
lucideIcon?: string;
color?: string; // Optional color property
description?: string; // Optional description property
templateId?: string; // Optional template scope for namespacing

51
utils/iconUtils.ts Normal file
View file

@ -0,0 +1,51 @@
import { IconName, getIcon, getIconIds } from "obsidian";
let cachedIconSet: Set<IconName> | null = null;
const getLucideIconSet = () => {
if (!cachedIconSet || cachedIconSet.size === 0) {
try {
const ids = getIconIds();
if (ids?.length) {
cachedIconSet = new Set(ids);
}
} catch (error) {
// Ignore fallback detection will rely on getIcon
cachedIconSet = null;
}
}
return cachedIconSet;
};
const iconExists = (iconName: string): boolean => {
const iconSet = getLucideIconSet();
if (iconSet?.size) {
return iconSet.has(iconName as IconName);
}
try {
return !!getIcon(iconName as IconName);
} catch {
return false;
}
};
export const resolveLucideIconName = (
...candidates: Array<string | undefined | null>
): IconName | null => {
for (const candidate of candidates) {
const trimmed = candidate?.trim();
if (trimmed && iconExists(trimmed)) {
return trimmed as IconName;
}
}
return null;
};
export const isLucideIconName = (name?: string | null) => {
const trimmed = name?.trim();
if (!trimmed) {
return false;
}
return iconExists(trimmed);
};