diff --git a/frontend/src/desktop/createDesktopSurface.js b/frontend/src/desktop/createDesktopSurface.js
index 0ca4a33..2b1dd9a 100644
--- a/frontend/src/desktop/createDesktopSurface.js
+++ b/frontend/src/desktop/createDesktopSurface.js
@@ -84,12 +84,13 @@ const createDesktopSurface = ({
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps
? (() => {
- const { onClose, onOpenPreview, ...restDetailProps } = detailProps;
+ const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailProps;
return (
);
diff --git a/frontend/src/documents/DocumentSummarySection.jsx b/frontend/src/documents/DocumentSummarySection.jsx
index b040c32..5793167 100644
--- a/frontend/src/documents/DocumentSummarySection.jsx
+++ b/frontend/src/documents/DocumentSummarySection.jsx
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
-import QuickAddMenu from '../ui/QuickAddMenu';
+import SelectionAssignmentMenu from './SelectionAssignmentMenu';
import { getTagColorStyle } from '../utils/colors';
import {
formatDate,
@@ -34,6 +34,29 @@ export const buildCorrespondentOptions = (entries = []) => {
const normalizeOptions = (options) => (Array.isArray(options) ? options : []);
+const normalizeQuickAddOption = (option) => {
+ if (option == null) {
+ return null;
+ }
+ if (typeof option === 'string') {
+ const label = option.trim();
+ return label ? { id: label, label, original: option } : null;
+ }
+ const label = typeof option.label === 'string'
+ ? option.label.trim()
+ : typeof option.name === 'string'
+ ? option.name.trim()
+ : '';
+ if (!label) {
+ return null;
+ }
+ return {
+ id: option.id ?? label,
+ label,
+ original: option,
+ };
+};
+
export const TagSection = ({
tags = [],
onRemove,
@@ -63,10 +86,71 @@ export const TagSection = ({
[onAdd],
);
- const normalizedOptions = useMemo(() => normalizeOptions(datalistOptions), [datalistOptions]);
+ const normalizedOptions = useMemo(
+ () =>
+ normalizeOptions(datalistOptions)
+ .map((option) => normalizeQuickAddOption(option))
+ .filter(Boolean),
+ [datalistOptions],
+ );
const containerClass = className ? `tag-list ${className}` : 'tag-list';
const showQuickAdd = Boolean(onAdd);
+ const assignmentItems = useMemo(() => {
+ const map = new Map();
+
+ normalizedOptions.forEach((option) => {
+ const label = option?.label?.trim();
+ if (!label) {
+ return;
+ }
+ const key = label.toLowerCase();
+ if (map.has(key)) {
+ return;
+ }
+ map.set(key, {
+ id: option.id ?? label,
+ label,
+ state: 'none',
+ payload: option.original ?? { label },
+ });
+ });
+
+ tags.forEach((tag) => {
+ const label = typeof tag?.label === 'string' ? tag.label.trim() : '';
+ if (!label) {
+ return;
+ }
+ const key = label.toLowerCase();
+ const payload = { id: tag.id, label, color: tag.color ?? null };
+ if (map.has(key)) {
+ const entry = map.get(key);
+ entry.state = 'all';
+ entry.payload = payload;
+ return;
+ }
+ map.set(key, {
+ id: tag.id ?? label,
+ label,
+ state: 'all',
+ payload,
+ });
+ });
+
+ return Array.from(map.values());
+ }, [normalizedOptions, tags]);
+
+ const handleAssignmentSelect = useCallback(
+ (item) => {
+ if (!item) {
+ return;
+ }
+ const payload = item.payload ?? { label: item.label };
+ handleSelect(payload);
+ },
+ [handleSelect],
+ );
+
return (
{tags.map((tag) => {
@@ -89,14 +173,17 @@ export const TagSection = ({
);
})}
{showQuickAdd ? (
-
handleSelect(normalized || original)}
+
@@ -125,31 +212,82 @@ export const CorrespondentSection = ({
[onAdd],
);
- const handleSelect = useCallback(
- (original, normalized) => {
- if (!onAdd) return;
- const source = normalized && typeof normalized === 'object' ? normalized : original;
+ const normalizedOptions = useMemo(
+ () =>
+ normalizeOptions(datalistOptions)
+ .map((option) => normalizeQuickAddOption(option))
+ .filter(Boolean),
+ [datalistOptions],
+ );
+ const hasEntries = entries && entries.length > 0;
+ const showQuickAdd = Boolean(onAdd);
+ const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
+
+ const assignmentItems = useMemo(() => {
+ const map = new Map();
+
+ normalizedOptions.forEach((option) => {
+ const label = option?.label?.trim();
+ if (!label) {
+ return;
+ }
+ const key = label.toLowerCase();
+ if (map.has(key)) {
+ return;
+ }
+ map.set(key, {
+ id: option.id ?? label,
+ label,
+ state: 'none',
+ payload: option.original ?? { name: label },
+ });
+ });
+
+ entries.forEach((entry) => {
+ const label = typeof entry?.name === 'string' ? entry.name.trim() : '';
+ if (!label) {
+ return;
+ }
+ const key = label.toLowerCase();
+ const payload = { id: entry.id, name: label };
+ if (map.has(key)) {
+ const item = map.get(key);
+ item.state = 'all';
+ item.payload = payload;
+ return;
+ }
+ map.set(key, {
+ id: entry.id ?? label,
+ label,
+ state: 'all',
+ payload,
+ });
+ });
+
+ return Array.from(map.values());
+ }, [normalizedOptions, entries]);
+
+ const handleAssignmentSelect = useCallback(
+ (item) => {
+ if (!onAdd || !item) {
+ return;
+ }
+ const source = item.payload ?? item;
const resolvedName =
- (source && typeof source.name === 'string' && source.name.trim()) ||
- (typeof source === 'string' ? source.trim() : '') ||
- (source && typeof source.label === 'string' ? source.label.trim() : '');
+ (source && typeof source.name === 'string' && source.name.trim())
+ || (typeof source === 'string' ? source.trim() : '')
+ || (source && typeof source.label === 'string' ? source.label.trim() : '');
if (!resolvedName) {
return;
}
- const payload =
- source && typeof source === 'object'
- ? { ...source, name: resolvedName }
- : { id: null, name: resolvedName };
+ const payload = typeof source === 'object'
+ ? { ...source, name: resolvedName }
+ : { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null });
},
[onAdd],
);
- const normalizedOptions = useMemo(() => normalizeOptions(datalistOptions), [datalistOptions]);
- const hasEntries = entries && entries.length > 0;
- const showQuickAdd = Boolean(onAdd);
- const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
-
return (
{hasEntries
@@ -176,14 +314,17 @@ export const CorrespondentSection = ({
})
: !showQuickAdd &&
No correspondents yet.}
{showQuickAdd ? (
-
handleSelect(original, normalized)}
+
diff --git a/frontend/src/documents/SelectionAssignmentMenu.jsx b/frontend/src/documents/SelectionAssignmentMenu.jsx
index e5ff17b..ca75ed8 100644
--- a/frontend/src/documents/SelectionAssignmentMenu.jsx
+++ b/frontend/src/documents/SelectionAssignmentMenu.jsx
@@ -1,10 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useFloatingMenu from '../ui/useFloatingMenu';
-import {
- PlusIcon,
- CheckIcon,
- CircleDashedCheckIcon,
-} from '../ui/icons';
+import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
const STATE_ORDER = {
all: 0,
@@ -35,10 +31,12 @@ const SelectionAssignmentMenu = ({
disabled = false,
className,
triggerContent = null,
+ triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
showStateIndicators = true,
showCounts = true,
onOpenMenu = null,
renderItemLabel = null,
+ positionStrategy = 'absolute',
}) => {
const anchorRef = useRef(null);
const inputRef = useRef(null);
@@ -55,7 +53,7 @@ const SelectionAssignmentMenu = ({
} = useFloatingMenu({
anchorRef,
align: 'center',
- positionStrategy: 'absolute',
+ positionStrategy,
minWidth: 220,
});
@@ -117,7 +115,8 @@ const SelectionAssignmentMenu = ({
);
const handleCreate = useCallback(
- async () => {
+ async (event) => {
+ event?.preventDefault?.();
if (typeof onCreate !== 'function') {
return;
}
@@ -144,9 +143,12 @@ const SelectionAssignmentMenu = ({
);
const canCreate = Boolean(onCreate);
- const showCreateOption = canCreate
- && query.trim().length > 0
- && !existingLabels.has(query.trim().toLowerCase());
+ const trimmedQuery = query.trim();
+ const queryKey = trimmedQuery.toLowerCase();
+ const canSubmitCreate = canCreate
+ && trimmedQuery.length > 0
+ && !existingLabels.has(queryKey)
+ && !pending;
const handleTriggerClick = useCallback(() => {
if (disabled) {
@@ -163,7 +165,7 @@ const SelectionAssignmentMenu = ({
) : null}
diff --git a/frontend/src/documents/panel/createDocumentsSurface.js b/frontend/src/documents/panel/createDocumentsSurface.js
index 794e2f4..a0ee776 100644
--- a/frontend/src/documents/panel/createDocumentsSurface.js
+++ b/frontend/src/documents/panel/createDocumentsSurface.js
@@ -93,12 +93,13 @@ const createDocumentsSurface = ({
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps
? (() => {
- const { onClose, onOpenPreview, ...restDetailProps } = detailProps;
+ const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailProps;
return (
);
diff --git a/frontend/src/styles/detail/detail-panels.css b/frontend/src/styles/detail/detail-panels.css
index c30b43a..f5584c6 100644
--- a/frontend/src/styles/detail/detail-panels.css
+++ b/frontend/src/styles/detail/detail-panels.css
@@ -74,10 +74,6 @@
flex: 1;
}
-.detail-panel__content .document-viewer-panel__body {
- padding: 0.5rem 0.5rem 0;
-}
-
.detail-section__header {
display: flex;
align-items: center;
@@ -195,7 +191,14 @@
border-bottom: 1px solid var(--border-subtle);
}
-.selection-assignment__header input {
+.selection-assignment__form {
+ display: flex;
+ gap: 0.4rem;
+ align-items: stretch;
+}
+
+.selection-assignment__form input {
+ flex: 1 1 auto;
width: 100%;
padding: 0.35rem 0.6rem;
border: 1px solid var(--border-subtle);
@@ -205,12 +208,20 @@
font-size: 0.95rem;
}
-.selection-assignment__header input:focus-visible {
+.selection-assignment__form input:focus-visible {
outline: none;
border-color: color-mix(in oklch, var(--accent) 60%, transparent);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 35%, transparent);
}
+.selection-assignment__add {
+ align-self: stretch;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 2rem;
+}
+
.selection-assignment__list {
max-height: max(240px, 50vh);
overflow-y: auto;
diff --git a/frontend/src/styles/sidebar/sidebar.css b/frontend/src/styles/sidebar/sidebar.css
index a1a8479..2fcba18 100644
--- a/frontend/src/styles/sidebar/sidebar.css
+++ b/frontend/src/styles/sidebar/sidebar.css
@@ -236,7 +236,7 @@
box-shadow: 0 12px 28px var(--shadow-strong);
min-width: 220px;
z-index: 2500000;
- overflow: hidden;
+ overflow: visible;
}
.menu[data-floating-position] {
@@ -247,6 +247,7 @@
.menu__list {
overflow-y: auto;
+ max-height: min(20rem, 60vh);
padding: 0.25rem;
display: flex;
flex-direction: column;