= {
},
};
+interface PublishGuidelinesProps {
+ variant: ConfirmPublishVariant;
+ className?: string;
+ /** The icons' colour; the notebook's indigo unless told otherwise. */
+ iconClassName?: string;
+}
+
+/** The guidelines an author agrees to, as a gray box: a heading and the list. */
+export function PublishGuidelines({
+ variant,
+ className,
+ iconClassName = 'text-indigo-600',
+}: PublishGuidelinesProps) {
+ const guidelines = PUBLISH_GUIDELINES[variant];
+ return (
+
+
{guidelines.heading}
+
+ {guidelines.items.map((item) => (
+ -
+
+ {item.text}
+
+ ))}
+
+
+ );
+}
+
export function ConfirmPublishModal({
isOpen,
onClose,
@@ -80,10 +106,9 @@ export function ConfirmPublishModal({
const [title, setTitle] = useState(initialTitle);
const [hasAgreed, setHasAgreed] = useState(false);
- const isTitleValid = title.trim().length >= 20;
+ const isTitleValid = title.trim().length >= MIN_PUBLISH_TITLE_LENGTH;
const isPublishEnabled = isTitleValid && hasAgreed;
- const guidelines = GUIDELINES[variant];
const resolvedDocumentLabel =
documentLabel ?? (variant === 'rfp' ? 'request for proposal' : 'research proposal');
@@ -137,20 +162,7 @@ export function ConfirmPublishModal({
placeholder="Enter title..."
/>
-
-
{guidelines.heading}
-
- {guidelines.items.map((item) => (
- -
-
- {item.text}
-
- ))}
-
-
+
React.ReactElement;
+ /** Seated inside the field, before the chosen options: an icon, say. */
+ leading?: React.ReactNode;
+ /** Shown in the search input once something is chosen; nothing by default. */
+ placeholderWhenFilled?: string;
+ /** Put the caret in the search input when the field mounts. */
+ autoFocus?: boolean;
+ /** Restyles the panel the results drop in. */
+ optionsClassName?: string;
}
export function SearchableMultiSelect({
@@ -59,6 +67,10 @@ export function SearchableMultiSelect({
sortable = false,
minSearchLength = 2,
renderOption,
+ leading,
+ placeholderWhenFilled = '',
+ autoFocus = false,
+ optionsClassName,
}: SearchableMultiSelectProps) {
const id = useId();
const [query, setQuery] = useState('');
@@ -206,6 +218,7 @@ export function SearchableMultiSelect({
className
)}
>
+ {leading}
{sortable ? (
setQuery(e.target.value.trim())}
onKeyDown={handleKeyDown}
displayValue={() => query}
@@ -260,7 +274,8 @@ export function SearchableMultiSelect({
diff --git a/components/ui/form/SearchableUserSelect.tsx b/components/ui/form/SearchableUserSelect.tsx
index 81d20eb07..0cc113836 100644
--- a/components/ui/form/SearchableUserSelect.tsx
+++ b/components/ui/form/SearchableUserSelect.tsx
@@ -1,5 +1,9 @@
import { useCallback } from 'react';
-import { SearchableMultiSelect, MultiSelectOption } from './SearchableMultiSelect';
+import {
+ SearchableMultiSelect,
+ MultiSelectOption,
+ type SearchableMultiSelectProps,
+} from './SearchableMultiSelect';
import { SearchService } from '@/services/search.service';
import { UserSuggestion } from '@/types/search';
import { Avatar } from '@/components/ui/Avatar';
@@ -63,6 +67,14 @@ export interface SearchableUserSelectProps {
debounceMs?: number;
sortable?: boolean;
getOptionValue?: (user: UserSuggestion) => string;
+ /** How a result row looks; the avatar, name, headline and profile link by default. */
+ renderOption?: SearchableMultiSelectProps['renderOption'];
+ /** Restyles the field itself. */
+ className?: string;
+ leading?: SearchableMultiSelectProps['leading'];
+ placeholderWhenFilled?: string;
+ autoFocus?: boolean;
+ optionsClassName?: string;
}
export function SearchableUserSelect({
@@ -74,6 +86,12 @@ export function SearchableUserSelect({
debounceMs = 300,
sortable = false,
getOptionValue = defaultGetOptionValue,
+ renderOption = renderUserOption,
+ className,
+ leading,
+ placeholderWhenFilled,
+ autoFocus,
+ optionsClassName,
}: Readonly) {
const handleAsyncSearch = useCallback(
async (query: string) => {
@@ -106,7 +124,12 @@ export function SearchableUserSelect({
helperText={helperText}
debounceMs={debounceMs}
sortable={sortable}
- renderOption={renderUserOption}
+ renderOption={renderOption}
+ className={className}
+ leading={leading}
+ placeholderWhenFilled={placeholderWhenFilled}
+ autoFocus={autoFocus}
+ optionsClassName={optionsClassName}
/>
);
}
diff --git a/contexts/TopBarSlotContext.tsx b/contexts/TopBarSlotContext.tsx
index 7fb33d0de..340a81a61 100644
--- a/contexts/TopBarSlotContext.tsx
+++ b/contexts/TopBarSlotContext.tsx
@@ -6,6 +6,15 @@ interface TopBarSlotContextValue {
/** Custom node rendered in the TopBar's top-left, replacing the breadcrumb. */
leftSlot: ReactNode;
setLeftSlot: (node: ReactNode) => void;
+ /**
+ * Shown in place of the page's own title, beside the back arrow, which
+ * stays: a page that names what is open rather than itself.
+ */
+ title: string | null;
+ setTitle: (title: string | null) => void;
+ /** A control seated between the back arrow and the title. */
+ leading: ReactNode;
+ setLeading: (node: ReactNode) => void;
}
const TopBarSlotContext = createContext(null);
@@ -13,12 +22,18 @@ const TopBarSlotContext = createContext(null);
/**
* Lets a page inject a custom element into the shared TopBar's left area (where
* the breadcrumb normally renders). Used by the notebook to surface its
- * "Notebook" notes dropdown in the standard top bar. Pages that don't set a
- * slot fall back to the default breadcrumb.
+ * "Notebook" notes dropdown in the standard top bar, and by the workspace to
+ * put the open document's or conversation's title there. Pages that don't set
+ * anything fall back to the default breadcrumb.
*/
export function TopBarSlotProvider({ children }: { children: ReactNode }) {
const [leftSlot, setLeftSlot] = useState(null);
- const value = useMemo(() => ({ leftSlot, setLeftSlot }), [leftSlot]);
+ const [title, setTitle] = useState(null);
+ const [leading, setLeading] = useState(null);
+ const value = useMemo(
+ () => ({ leftSlot, setLeftSlot, title, setTitle, leading, setLeading }),
+ [leftSlot, title, leading]
+ );
return {children};
}
diff --git a/hooks/useFittingCount.ts b/hooks/useFittingCount.ts
deleted file mode 100644
index bef3cd374..000000000
--- a/hooks/useFittingCount.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
-
-export interface FitOptions {
- /** How many items would like to show. */
- readonly itemCount: number;
- /** The narrowest an item may be laid out at. */
- readonly minItemWidth: number;
- /** Space between neighbouring items. */
- readonly gap: number;
- /**
- * Width the row spends on things that are not these items — a trailing
- * control, its gap — and that must stay visible before any item does.
- */
- readonly reservedWidth: number;
-}
-
-/** How many of the items fit side by side in `width` at their minimum size. */
-export function fitCount(
- width: number,
- { itemCount, minItemWidth, gap, reservedWidth }: FitOptions
-): number {
- const available = width - reservedWidth;
- if (available <= 0) return 0;
- // n items take n widths and n - 1 gaps.
- const fits = Math.floor((available + gap) / (minItemWidth + gap));
- return Math.max(0, Math.min(itemCount, fits));
-}
-
-/**
- * How many of a row's items fit the row's current content width. The row
- * decides which items those are; this only counts. Measures on mount and
- * again whenever the element resizes, so a user dragging the pane narrower
- * sees items fold away as they stop fitting.
- */
-export function useFittingCount(options: FitOptions) {
- const [width, setWidth] = useState(null);
- const elementRef = useRef(null);
- const observerRef = useRef(null);
-
- const ref = useCallback((element: T | null) => {
- observerRef.current?.disconnect();
- observerRef.current = null;
- elementRef.current = element;
- if (!element) return;
-
- observerRef.current = new ResizeObserver(([entry]) => {
- setWidth(entry.contentRect.width);
- });
- observerRef.current.observe(element);
- }, []);
-
- // The observer reports after layout; measuring here as well means the
- // first paint already shows the right number of items.
- useLayoutEffect(() => {
- const element = elementRef.current;
- if (!element) return;
- const style = getComputedStyle(element);
- const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
- setWidth(element.clientWidth - padding);
- }, []);
-
- const { itemCount, minItemWidth, gap, reservedWidth } = options;
- const count = useMemo(
- () => (width == null ? 0 : fitCount(width, { itemCount, minItemWidth, gap, reservedWidth })),
- [width, itemCount, minItemWidth, gap, reservedWidth]
- );
-
- return { ref, count };
-}