Merge remote-tracking branch 'ui/ui' into dev

This commit is contained in:
2025-11-11 18:03:50 +01:00
6 changed files with 229 additions and 72 deletions
+2 -1
View File
@@ -84,12 +84,13 @@ const createDesktopSurface = ({
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps const detail = detailOpen && detailProps
? (() => { ? (() => {
const { onClose, onOpenPreview, ...restDetailProps } = detailProps; const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailProps;
return ( return (
<DocumentViewerPanel <DocumentViewerPanel
variant="sidebar" variant="sidebar"
onCollapsePanel={onClose} onCollapsePanel={onClose}
onMaximizePanel={onOpenPreview} onMaximizePanel={onOpenPreview}
tagOptions={tagOptions}
{...restDetailProps} {...restDetailProps}
/> />
); );
+169 -28
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EditIcon, IconX, PlusIcon } from '../ui/icons'; import { EditIcon, IconX, PlusIcon } from '../ui/icons';
import QuickAddMenu from '../ui/QuickAddMenu'; import SelectionAssignmentMenu from './SelectionAssignmentMenu';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { import {
formatDate, formatDate,
@@ -34,6 +34,29 @@ export const buildCorrespondentOptions = (entries = []) => {
const normalizeOptions = (options) => (Array.isArray(options) ? options : []); 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 = ({ export const TagSection = ({
tags = [], tags = [],
onRemove, onRemove,
@@ -63,10 +86,71 @@ export const TagSection = ({
[onAdd], [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 containerClass = className ? `tag-list ${className}` : 'tag-list';
const showQuickAdd = Boolean(onAdd); 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 ( return (
<div className={containerClass}> <div className={containerClass}>
{tags.map((tag) => { {tags.map((tag) => {
@@ -89,14 +173,17 @@ export const TagSection = ({
); );
})} })}
{showQuickAdd ? ( {showQuickAdd ? (
<QuickAddMenu <SelectionAssignmentMenu
options={normalizedOptions} label="Add tag"
onCreate={handleCreate} items={assignmentItems}
onSelectOption={(original, normalized) => handleSelect(normalized || original)}
placeholder={addPlaceholder} placeholder={addPlaceholder}
emptyMessage="No tags"
createLabel={addButtonLabel} createLabel={addButtonLabel}
triggerAriaLabel="Add tag" onToggle={handleAssignmentSelect}
triggerTitle={addButtonLabel} onCreate={handleCreate}
showStateIndicators
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger" triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={( triggerContent={(
<span className="quick-add__chip-label"> <span className="quick-add__chip-label">
@@ -125,19 +212,75 @@ export const CorrespondentSection = ({
[onAdd], [onAdd],
); );
const handleSelect = useCallback( const normalizedOptions = useMemo(
(original, normalized) => { () =>
if (!onAdd) return; normalizeOptions(datalistOptions)
const source = normalized && typeof normalized === 'object' ? normalized : original; .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 = const resolvedName =
(source && typeof source.name === 'string' && source.name.trim()) || (source && typeof source.name === 'string' && source.name.trim())
(typeof source === 'string' ? source.trim() : '') || || (typeof source === 'string' ? source.trim() : '')
(source && typeof source.label === 'string' ? source.label.trim() : ''); || (source && typeof source.label === 'string' ? source.label.trim() : '');
if (!resolvedName) { if (!resolvedName) {
return; return;
} }
const payload = const payload = typeof source === 'object'
source && typeof source === 'object'
? { ...source, name: resolvedName } ? { ...source, name: resolvedName }
: { id: null, name: resolvedName }; : { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null }); onAdd({ name: resolvedName, option: payload, input: null });
@@ -145,11 +288,6 @@ export const CorrespondentSection = ({
[onAdd], [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 ( return (
<div className={containerClass}> <div className={containerClass}>
{hasEntries {hasEntries
@@ -176,14 +314,17 @@ export const CorrespondentSection = ({
}) })
: !showQuickAdd && <span className="meta">No correspondents yet.</span>} : !showQuickAdd && <span className="meta">No correspondents yet.</span>}
{showQuickAdd ? ( {showQuickAdd ? (
<QuickAddMenu <SelectionAssignmentMenu
options={normalizedOptions} label="Add correspondent"
onCreate={handleCreate} items={assignmentItems}
onSelectOption={(original, normalized) => handleSelect(original, normalized)}
placeholder={addPlaceholder} placeholder={addPlaceholder}
emptyMessage="No correspondents"
createLabel={addButtonLabel} createLabel={addButtonLabel}
triggerAriaLabel="Add correspondent" onToggle={handleAssignmentSelect}
triggerTitle={addButtonLabel} onCreate={handleCreate}
showStateIndicators
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger" triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={( triggerContent={(
<span className="quick-add__chip-label"> <span className="quick-add__chip-label">
@@ -1,10 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useFloatingMenu from '../ui/useFloatingMenu'; import useFloatingMenu from '../ui/useFloatingMenu';
import { import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
PlusIcon,
CheckIcon,
CircleDashedCheckIcon,
} from '../ui/icons';
const STATE_ORDER = { const STATE_ORDER = {
all: 0, all: 0,
@@ -35,10 +31,12 @@ const SelectionAssignmentMenu = ({
disabled = false, disabled = false,
className, className,
triggerContent = null, triggerContent = null,
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
showStateIndicators = true, showStateIndicators = true,
showCounts = true, showCounts = true,
onOpenMenu = null, onOpenMenu = null,
renderItemLabel = null, renderItemLabel = null,
positionStrategy = 'absolute',
}) => { }) => {
const anchorRef = useRef(null); const anchorRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
@@ -55,7 +53,7 @@ const SelectionAssignmentMenu = ({
} = useFloatingMenu({ } = useFloatingMenu({
anchorRef, anchorRef,
align: 'center', align: 'center',
positionStrategy: 'absolute', positionStrategy,
minWidth: 220, minWidth: 220,
}); });
@@ -117,7 +115,8 @@ const SelectionAssignmentMenu = ({
); );
const handleCreate = useCallback( const handleCreate = useCallback(
async () => { async (event) => {
event?.preventDefault?.();
if (typeof onCreate !== 'function') { if (typeof onCreate !== 'function') {
return; return;
} }
@@ -144,9 +143,12 @@ const SelectionAssignmentMenu = ({
); );
const canCreate = Boolean(onCreate); const canCreate = Boolean(onCreate);
const showCreateOption = canCreate const trimmedQuery = query.trim();
&& query.trim().length > 0 const queryKey = trimmedQuery.toLowerCase();
&& !existingLabels.has(query.trim().toLowerCase()); const canSubmitCreate = canCreate
&& trimmedQuery.length > 0
&& !existingLabels.has(queryKey)
&& !pending;
const handleTriggerClick = useCallback(() => { const handleTriggerClick = useCallback(() => {
if (disabled) { if (disabled) {
@@ -163,7 +165,7 @@ const SelectionAssignmentMenu = ({
<button <button
type="button" type="button"
ref={anchorRef} ref={anchorRef}
className="quick-add__chip quick-add__trigger panel-floating-actions__trigger" className={triggerClassName}
onClick={handleTriggerClick} onClick={handleTriggerClick}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={isOpen} aria-expanded={isOpen}
@@ -184,6 +186,7 @@ const SelectionAssignmentMenu = ({
data-floating-position data-floating-position
> >
<div className="selection-assignment__header"> <div className="selection-assignment__header">
<form className="selection-assignment__form" onSubmit={handleCreate}>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@@ -193,6 +196,18 @@ const SelectionAssignmentMenu = ({
aria-label={placeholder} aria-label={placeholder}
disabled={pending} disabled={pending}
/> />
{canCreate ? (
<button
type="submit"
className="icon-button selection-assignment__add"
disabled={!canSubmitCreate}
aria-label={createLabel || 'Add'}
title={createLabel || 'Add'}
>
<PlusIcon aria-hidden="true" />
</button>
) : null}
</form>
</div> </div>
<div className="selection-assignment__list" role="presentation"> <div className="selection-assignment__list" role="presentation">
{filteredItems.length ? ( {filteredItems.length ? (
@@ -237,19 +252,6 @@ const SelectionAssignmentMenu = ({
<div className="menu__empty selection-assignment__empty">{emptyMessage}</div> <div className="menu__empty selection-assignment__empty">{emptyMessage}</div>
)} )}
</div> </div>
{showCreateOption ? (
<button
type="button"
className="menu__item selection-assignment__create"
onClick={handleCreate}
disabled={pending}
>
<PlusIcon className="selection-assignment__icon" aria-hidden="true" />
<span className="selection-assignment__label">
{createLabel ? `${createLabel}${query.trim()}` : `Create “${query.trim()}`}
</span>
</button>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -93,12 +93,13 @@ const createDocumentsSurface = ({
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps const detail = detailOpen && detailProps
? (() => { ? (() => {
const { onClose, onOpenPreview, ...restDetailProps } = detailProps; const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailProps;
return ( return (
<DocumentViewerPanel <DocumentViewerPanel
variant="sidebar" variant="sidebar"
onCollapsePanel={onClose} onCollapsePanel={onClose}
onMaximizePanel={onOpenPreview} onMaximizePanel={onOpenPreview}
tagOptions={tagOptions}
{...restDetailProps} {...restDetailProps}
/> />
); );
+17 -6
View File
@@ -74,10 +74,6 @@
flex: 1; flex: 1;
} }
.detail-panel__content .document-viewer-panel__body {
padding: 0.5rem 0.5rem 0;
}
.detail-section__header { .detail-section__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -195,7 +191,14 @@
border-bottom: 1px solid var(--border-subtle); 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%; width: 100%;
padding: 0.35rem 0.6rem; padding: 0.35rem 0.6rem;
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
@@ -205,12 +208,20 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
.selection-assignment__header input:focus-visible { .selection-assignment__form input:focus-visible {
outline: none; outline: none;
border-color: color-mix(in oklch, var(--accent) 60%, transparent); border-color: color-mix(in oklch, var(--accent) 60%, transparent);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 35%, 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 { .selection-assignment__list {
max-height: max(240px, 50vh); max-height: max(240px, 50vh);
overflow-y: auto; overflow-y: auto;
+2 -1
View File
@@ -236,7 +236,7 @@
box-shadow: 0 12px 28px var(--shadow-strong); box-shadow: 0 12px 28px var(--shadow-strong);
min-width: 220px; min-width: 220px;
z-index: 2500000; z-index: 2500000;
overflow: hidden; overflow: visible;
} }
.menu[data-floating-position] { .menu[data-floating-position] {
@@ -247,6 +247,7 @@
.menu__list { .menu__list {
overflow-y: auto; overflow-y: auto;
max-height: min(20rem, 60vh);
padding: 0.25rem; padding: 0.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;