diff --git a/examples/activityLog/activityHistory.tsx b/examples/activityLog/activityHistory.tsx new file mode 100644 index 000000000..05bbce1de --- /dev/null +++ b/examples/activityLog/activityHistory.tsx @@ -0,0 +1,240 @@ +import type { ActivityLogSegmentProps } from '../../lib'; +import { TransmissionList } from '../../lib'; + +export type ActivitySource = 'activity' | 'notification' | 'label' | 'transmission'; + +export interface ActivityHistoryEntry { + id: string; + source: ActivitySource; + /** ISO timestamp, also used as the `datetime` of the rendered segment */ + createdAt: string; + /** `activity` only: rendered in bold ahead of the text */ + actor?: string; + /** `activity` | `notification` | `label`: the plain text body */ + text?: string; + /** `transmission` only */ + sender?: string; + senderType?: 'company' | 'person'; + title?: string; + summary?: string; +} + +export const activitySourceLabels: Record = { + activity: 'Hendelser', + notification: 'Varslinger', + label: 'Merking', + transmission: 'Forsendelser', +}; + +const monthsNb = [ + 'januar', + 'februar', + 'mars', + 'april', + 'mai', + 'juni', + 'juli', + 'august', + 'september', + 'oktober', + 'november', + 'desember', +]; + +const osloParts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Europe/Oslo', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}); + +/** "22. august 2024 kl. 18.45" */ +export const formatActivityDate = (isoDate: string): string => { + const parts = Object.fromEntries(osloParts.formatToParts(new Date(isoDate)).map((part) => [part.type, part.value])); + return `${Number(parts.day)}. ${monthsNb[Number(parts.month) - 1]} ${parts.year} kl. ${parts.hour}.${parts.minute}`; +}; + +export const activityHistoryEntries: ActivityHistoryEntry[] = [ + { + id: 'l1', + source: 'label', + createdAt: '2024-08-22T16:45:00Z', + text: 'Fantasifull 2024 Søster markerte meldingen som lest.', + }, + { + id: 'l2', + source: 'label', + createdAt: '2024-08-22T11:00:00Z', + text: 'Fantasifull 2024 Søster markerte meldingen som ulest.', + }, + { + id: 'l3', + source: 'label', + createdAt: '2024-08-21T13:30:00Z', + text: 'Ola Nordmann flyttet meldingen til innboksen.', + }, + { + id: 'l4', + source: 'label', + createdAt: '2024-08-20T12:15:00Z', + text: 'Fantasifull 2024 Søster flyttet meldingen til arkivet.', + }, + { + id: 'a1', + source: 'activity', + createdAt: '2024-08-19T09:00:00Z', + actor: 'Skatteetaten', + text: 'Meldingen ble sendt.', + }, + { + id: 'n1', + source: 'notification', + createdAt: '2024-08-18T11:03:00Z', + text: 'Påminnelse på e-post til post@firma-as.no, regnskap@firma-as.no, daglig.leder@firma-as.no og 1 annen mottaker ble levert.', + }, + { + id: 't1', + source: 'transmission', + createdAt: '2024-08-16T09:00:00Z', + sender: 'Oslo kommune', + senderType: 'company', + title: 'Språktest: innhold per språk', + }, + { + id: 't2', + source: 'transmission', + createdAt: '2024-08-15T09:00:00Z', + sender: 'Oslo kommune', + senderType: 'company', + title: 'Sak 7: vises (isAuthorized=true, summary og GUI-vedlegg)', + }, + { + id: 't3', + source: 'transmission', + createdAt: '2024-08-15T08:00:00Z', + sender: 'Oslo kommune', + senderType: 'company', + title: 'Sak 6: deaktivert lenke (isAuthorized=true, uautorisert lenke)', + }, + { + id: 't4', + source: 'transmission', + createdAt: '2024-08-15T06:00:00Z', + sender: 'Oslo kommune', + senderType: 'company', + title: 'Sak 4: vises (isAuthorized=true, innhold finnes)', + }, + { + id: 'a2', + source: 'activity', + createdAt: '2024-08-14T10:30:00Z', + actor: 'Skatteetaten', + text: 'Meldingen ble åpnet.', + }, + { + id: 't5', + source: 'transmission', + createdAt: '2024-08-13T14:12:00Z', + sender: 'Per Nordmann', + senderType: 'person', + title: 'Tittel 4', + }, + { + id: 'n2', + source: 'notification', + createdAt: '2024-08-13T14:06:00Z', + text: 'Varsel på e-post til kari.nordmann@example.com ble levert.', + }, + { + id: 'n3', + source: 'notification', + createdAt: '2024-08-07T11:02:00Z', + text: 'Påminnelse på e-post til kari.nordmann@example.com ble levert.', + }, + { + id: 'a3', + source: 'activity', + createdAt: '2024-08-02T08:00:00Z', + actor: 'Skatteetaten', + text: 'Denne meldingen er utløpt.', + }, + { + id: 't6', + source: 'transmission', + createdAt: '2024-07-31T20:12:00Z', + sender: 'Kari Nordmann', + senderType: 'person', + title: 'Tittel 2', + }, + { + id: 'n4', + source: 'notification', + createdAt: '2024-07-30T20:16:00Z', + text: 'Varsel på e-post til kari.nordmann@example.com og post@firma-as.no ble levert.', + }, + { + id: 'n5', + source: 'notification', + createdAt: '2024-07-30T20:15:00Z', + text: 'Varsel på SMS til +4799887766 ble levert.', + }, + { + id: 't7', + source: 'transmission', + createdAt: '2024-07-30T20:12:00Z', + sender: 'Oslo kommune', + senderType: 'company', + title: 'Tittel', + }, +]; + +/** Build a log segment from a raw entry, the way a consuming app would. */ +export const toActivityLogSegment = (entry: ActivityHistoryEntry): ActivityLogSegmentProps => { + const date = formatActivityDate(entry.createdAt); + + if (entry.source === 'transmission') { + return { + id: entry.id, + kind: entry.source, + datetime: entry.createdAt, + /** A transmission renders its own card, so nothing readable is left in the + * props — it has to say what its text is. */ + searchText: [entry.title, entry.summary].filter(Boolean).join(' '), + /** ...and take the words back in, to mark them inside that card. */ + children: (highlightWords) => ( + + ), + }; + } + + return { + id: entry.id, + kind: entry.source, + datetime: entry.createdAt, + byline: date, + summary: entry.actor ? ( + <> + {entry.actor}: {entry.text} + + ) : ( + entry.text + ), + }; +}; + +export const activityHistorySegments: ActivityLogSegmentProps[] = activityHistoryEntries.map(toActivityLogSegment); diff --git a/examples/activityLog/index.ts b/examples/activityLog/index.ts index f471abfe8..b83f33b1f 100644 --- a/examples/activityLog/index.ts +++ b/examples/activityLog/index.ts @@ -1,3 +1,5 @@ export * from './activityLogItems'; export * from './useActivityLog'; export * from './useActivityLogToolbar'; +export * from './activityHistory'; +export * from './useActivityLogFilter'; diff --git a/examples/activityLog/useActivityLogFilter.tsx b/examples/activityLog/useActivityLogFilter.tsx new file mode 100644 index 000000000..ea3e2a409 --- /dev/null +++ b/examples/activityLog/useActivityLogFilter.tsx @@ -0,0 +1,69 @@ +import { useMemo, useState } from 'react'; +import type { ActivityLogSegmentProps, FilterProps, FilterState } from '../../lib'; +import { activityLogKinds } from '../../lib'; +import { type ActivitySource, activitySourceLabels } from './activityHistory'; + +/** The radio option that stands for "no filter". */ +export const ALL_KINDS = 'all'; + +const labelFor = (kind: string) => activitySourceLabels[kind as ActivitySource] ?? kind; + +/** + * A type filter over an activity log: one radio for "all", plus a checkbox per + * kind present. Checking every kind is the same as no filter, so it collapses + * back onto the radio. + */ +export const useActivityLogFilter = (segments: ActivityLogSegmentProps[]) => { + const [filterState, setFilterState] = useState({}); + + const kinds = useMemo(() => activityLogKinds(segments), [segments]); + + const selected = useMemo( + () => (filterState.kind ?? []).map(String).filter((value) => value !== ALL_KINDS), + [filterState], + ); + + const filters: FilterProps[] = useMemo( + () => [ + { + id: 'kind', + name: 'kind', + label: 'Alle typer', + title: 'Type ...', + groups: { 1: { title: 'Velg type' } }, + items: [ + { id: ALL_KINDS, groupId: '1', name: 'kind', role: 'radio', value: ALL_KINDS, title: 'Alle typer' }, + ...kinds.map((kind) => ({ + id: kind, + groupId: '2', + name: 'kind', + role: 'checkbox', + value: kind, + title: labelFor(kind), + count: segments.filter((segment) => segment.kind === kind).length, + })), + ], + }, + ], + [kinds, segments], + ); + + const onFilterStateChange = (next: FilterState) => { + const picked = (next.kind ?? []).map(String).filter((value) => value !== ALL_KINDS); + const partial = picked.length > 0 && picked.length < kinds.length; + setFilterState(partial ? { ...next, kind: picked } : { kind: [ALL_KINDS] }); + }; + + const getFilterLabel = () => { + if (selected.length === 0) return 'Alle typer'; + if (selected.length === 1) return labelFor(selected[0]); + return `${selected.length} typer`; + }; + + return { + /** Pass straight to `ActivityLog`. An empty array means every kind. */ + kind: selected, + filter: { filters, filterState, onFilterStateChange, getFilterLabel }, + reset: () => setFilterState({}), + }; +}; diff --git a/examples/activityLog/useActivityLogToolbar.tsx b/examples/activityLog/useActivityLogToolbar.tsx index 988c8c916..d6943b1b7 100644 --- a/examples/activityLog/useActivityLogToolbar.tsx +++ b/examples/activityLog/useActivityLogToolbar.tsx @@ -36,7 +36,7 @@ export const useActivityLogToolbar = (): ToolbarProps => { const search = { name: 'q', - placeholder: 'Søk i aktivitetsloggen', + placeholder: 'Søk ...', value: q, onChange, onClear, diff --git a/lib/components/ActivityLog/ActivityLog.stories.tsx b/lib/components/ActivityLog/ActivityLog.stories.tsx index 9f223a3fa..84c62aefd 100644 --- a/lib/components/ActivityLog/ActivityLog.stories.tsx +++ b/lib/components/ActivityLog/ActivityLog.stories.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react'; import { ActivityLog, Button, @@ -10,8 +10,16 @@ import { PageBase, Section, Toolbar, + Typography, } from '..'; -import { useActivityLog, useProfile } from '../../../examples'; +import { + type ActivityHistoryEntry, + activityHistoryEntries, + toActivityLogSegment, + useActivityLog, + useActivityLogFilter, + useProfile, +} from '../../../examples'; const meta = { title: 'Timeline/ActivityLog', @@ -98,3 +106,85 @@ export const ActivityLogModalAdvanced = () => { ); }; + +const resultsMaxHeight = '60vh'; + +const ActivityLogEmptyState = ({ children }: { children: ReactNode }) => ( + +

{children}

+
+); + +interface DialogActivityLogModalProps { + entries: ActivityHistoryEntry[]; + defaultQuery?: string; +} + +const DialogActivityLogModal = ({ entries, defaultQuery = '' }: DialogActivityLogModalProps) => { + const [open, setOpen] = useState(true); + const [q, setQ] = useState(defaultQuery); + + /** Built once. The query is not baked into them, so typing does not rebuild the log. */ + const segments = useMemo(() => entries.map(toActivityLogSegment), [entries]); + + const { kind, filter } = useActivityLogFilter(segments); + + const onClose = () => setOpen(false); + + return ( + <> + + + + + {/* No sticky positioning needed: the log scrolls in its own box below. */} + {segments.length > 0 && ( + ) => setQ(event.target.value), + onClear: () => setQ(''), + }} + /> + )} + Det er ikke registrert noen aktiviteter på denne meldingen. + } + noResultsState={Ingen treff} + style={{ maxHeight: resultsMaxHeight, overflowY: 'auto' }} + /> + + + + ); +}; + +export const DialogActivityLogFiltered = () => ; + +export const DialogActivityLogPartialSources = () => ( + entry.source === 'notification' || entry.source === 'transmission', + )} + /> +); + +export const DialogActivityLogShortLog = () => ( + entry.source === 'label').slice(0, 3)} /> +); + +/** Empty state 1: the dialog has no activity at all, so there is nothing to filter. */ +export const DialogActivityLogNoActivity = () => ; + +/** Empty state 2: there is activity, but every term has to match and these two never co-occur. */ +export const DialogActivityLogNoMatches = () => ( + +); diff --git a/lib/components/ActivityLog/ActivityLog.tsx b/lib/components/ActivityLog/ActivityLog.tsx index 2b63f91c0..7cc814556 100644 --- a/lib/components/ActivityLog/ActivityLog.tsx +++ b/lib/components/ActivityLog/ActivityLog.tsx @@ -1,42 +1,91 @@ 'use client'; -import { useState } from 'react'; +import { type CSSProperties, type ReactNode, useMemo, useState } from 'react'; import { ActivityLogSegment, type ActivityLogSegmentProps, Button, Section, Timeline } from '..'; +import { activityLogQueryTerms, matchesActivityLogSegment } from './activityLogSearch'; +import { useReservedHeight } from './useReservedHeight'; export interface ActivityLogProps { items: ActivityLogSegmentProps[]; maxItems?: number; + /** Allow expanding past `maxItems`. Default is true. */ collapsible?: boolean; + /** Initially expanded. Default is false. */ expanded?: boolean; expandLabel?: string; collapseLabel?: string; + /** Show only segments with these kinds. Omit, or pass an empty array, for all. */ + kind?: string | string[]; + /** Free text; every term has to match. Matches are highlighted. */ + query?: string; + /** Rendered when `items` is empty. */ + emptyState?: ReactNode; + /** Rendered when nothing matches `kind` / `query`. */ + noResultsState?: ReactNode; + /** Keep the unfiltered height while filtering. Default is true. */ + reserveHeight?: boolean; + className?: string; + style?: CSSProperties; } export const ActivityLog = ({ items, maxItems, + collapsible = true, + expanded: defaultExpanded = false, expandLabel = 'Expand history', collapseLabel = 'Collapse history', + kind, + query, + emptyState, + noResultsState, + reserveHeight = true, + className, + style, }: ActivityLogProps) => { - if (!items?.length) { - return null; - } + const [expanded, setExpanded] = useState(defaultExpanded); + + const terms = useMemo(() => activityLogQueryTerms(query), [query]); + + const filterable = kind !== undefined || query !== undefined; + const managed = + filterable || + emptyState !== undefined || + noResultsState !== undefined || + className !== undefined || + style !== undefined; - const [expanded, setExpanded] = useState(false); + const visible = useMemo( + () => (filterable ? (items ?? []).filter((item) => matchesActivityLogSegment(item, kind, terms)) : (items ?? [])), + [items, filterable, kind, terms], + ); + + const filtering = (Array.isArray(kind) ? kind.length > 0 : Boolean(kind)) || terms.length > 0; + const { ref, minHeight } = useReservedHeight(filtering, reserveHeight && filterable); const onToggle = () => { setExpanded((prev) => !prev); }; - const collapsedItems = maxItems ? items.slice(0, maxItems) : items; - const displayedItems = expanded ? items : collapsedItems; - const toggle = maxItems && items.length > maxItems; + const collapsedItems = maxItems ? visible.slice(0, maxItems) : visible; + const displayedItems = expanded ? visible : collapsedItems; + const toggle = collapsible && maxItems && visible.length > maxItems; - return ( + const log = !items?.length ? ( + (emptyState ?? null) + ) : !visible.length ? ( + (noResultsState ?? null) + ) : (
{displayedItems.map((item) => { - return ; + return ( + + ); })} {toggle && ( @@ -46,4 +95,14 @@ export const ActivityLog = ({ )}
); + + if (!managed) { + return log; + } + + return ( +
+ {log} +
+ ); }; diff --git a/lib/components/ActivityLog/ActivityLogItem.tsx b/lib/components/ActivityLog/ActivityLogItem.tsx index 6f315aa58..01cd53332 100644 --- a/lib/components/ActivityLog/ActivityLogItem.tsx +++ b/lib/components/ActivityLog/ActivityLogItem.tsx @@ -8,8 +8,16 @@ export interface ActivityLogItemProps { datetime?: string; children?: ReactNode; summary?: ReactNode; + highlightWords?: string[]; } -export const ActivityLogItem = ({ byline, datetime, children, summary, ...props }: ActivityLogItemProps) => { +export const ActivityLogItem = ({ + byline, + datetime, + children, + summary, + highlightWords, + ...props +}: ActivityLogItemProps) => { if (children) { return ( @@ -20,7 +28,7 @@ export const ActivityLogItem = ({ byline, datetime, children, summary, ...props return ( - {summary} + {summary} ); }; diff --git a/lib/components/ActivityLog/ActivityLogSegment.tsx b/lib/components/ActivityLog/ActivityLogSegment.tsx index 729934c90..cf3ba4ac9 100644 --- a/lib/components/ActivityLog/ActivityLogSegment.tsx +++ b/lib/components/ActivityLog/ActivityLogSegment.tsx @@ -6,18 +6,33 @@ export interface ActivityLogSegmentProps { id?: string; items?: ActivityLogItemProps[]; datetime?: string; - byline?: string; - children?: ReactNode; + byline?: ReactNode; summary?: ReactNode; + /** May be a function of the words to highlight. */ + children?: ReactNode | ((highlightWords: string[]) => ReactNode); + /** Which source this segment came from. */ + kind?: string; + /** Text to match on. Falls back to the text inside `summary`. */ + searchText?: string; + /** Words to mark in `summary`. Set by `ActivityLog` from its `query`. */ + highlightWords?: string[]; } -export const ActivityLogSegment = ({ id, items, datetime, byline, children, summary }: ActivityLogSegmentProps) => { +export const ActivityLogSegment = ({ + id, + items, + datetime, + byline, + children, + summary, + highlightWords, +}: ActivityLogSegmentProps) => { return ( - {summary && {summary}} - {children} + {summary && {summary}} + {typeof children === 'function' ? children(highlightWords ?? []) : children} {items?.map((item) => ( - + ))} ); diff --git a/lib/components/ActivityLog/__screenshots__/ActivityLog/ActivityLogModalAdvanced.png b/lib/components/ActivityLog/__screenshots__/ActivityLog/ActivityLogModalAdvanced.png index 850d1d8db..4b059aafe 100644 Binary files a/lib/components/ActivityLog/__screenshots__/ActivityLog/ActivityLogModalAdvanced.png and b/lib/components/ActivityLog/__screenshots__/ActivityLog/ActivityLogModalAdvanced.png differ diff --git a/lib/components/ActivityLog/__screenshots__/ActivityLog/Controlled.png b/lib/components/ActivityLog/__screenshots__/ActivityLog/Controlled.png index d0a2587b8..29a430167 100644 Binary files a/lib/components/ActivityLog/__screenshots__/ActivityLog/Controlled.png and b/lib/components/ActivityLog/__screenshots__/ActivityLog/Controlled.png differ diff --git a/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogFiltered.png b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogFiltered.png new file mode 100644 index 000000000..9d27b8066 Binary files /dev/null and b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogFiltered.png differ diff --git a/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogNoActivity.png b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogNoActivity.png new file mode 100644 index 000000000..afaa7e347 Binary files /dev/null and b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogNoActivity.png differ diff --git a/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogNoMatches.png b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogNoMatches.png new file mode 100644 index 000000000..b49df88f0 Binary files /dev/null and b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogNoMatches.png differ diff --git a/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogPartialSources.png b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogPartialSources.png new file mode 100644 index 000000000..0e21e5eb6 Binary files /dev/null and b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogPartialSources.png differ diff --git a/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogShortLog.png b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogShortLog.png new file mode 100644 index 000000000..36444c090 Binary files /dev/null and b/lib/components/ActivityLog/__screenshots__/ActivityLog/DialogActivityLogShortLog.png differ diff --git a/lib/components/ActivityLog/__screenshots__/ActivityLog/ProfileActivityLog.png b/lib/components/ActivityLog/__screenshots__/ActivityLog/ProfileActivityLog.png index 8eb44b0e3..6882d0c80 100644 Binary files a/lib/components/ActivityLog/__screenshots__/ActivityLog/ProfileActivityLog.png and b/lib/components/ActivityLog/__screenshots__/ActivityLog/ProfileActivityLog.png differ diff --git a/lib/components/ActivityLog/activityLogSearch.ts b/lib/components/ActivityLog/activityLogSearch.ts new file mode 100644 index 000000000..245a18354 --- /dev/null +++ b/lib/components/ActivityLog/activityLogSearch.ts @@ -0,0 +1,32 @@ +import { type ReactNode, isValidElement } from 'react'; +import type { ActivityLogSegmentProps } from './ActivityLogSegment'; + +/** Concatenate the text out of a React node, so a JSX `summary` is searchable. */ +export const activityLogSegmentText = (node: ReactNode): string => { + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(activityLogSegmentText).join(''); + if (isValidElement(node)) return activityLogSegmentText((node.props as { children?: ReactNode }).children); + return ''; +}; + +/** Split a query into lower-cased terms. */ +export const activityLogQueryTerms = (query?: string): string[] => + (query ?? '').toLowerCase().split(/\s+/).filter(Boolean); + +/** The kinds present in a log, in first-seen order. */ +export const activityLogKinds = (items: ActivityLogSegmentProps[] = []): string[] => [ + ...new Set(items.map((item) => item.kind).filter((kind): kind is string => Boolean(kind))), +]; + +/** Matches when `kind` is one of those asked for and every term appears in the text. */ +export const matchesActivityLogSegment = ( + segment: ActivityLogSegmentProps, + kind: string | string[] | undefined, + terms: string[], +): boolean => { + const kinds = kind === undefined ? [] : Array.isArray(kind) ? kind : [kind]; + if (kinds.length && (segment.kind === undefined || !kinds.includes(segment.kind))) return false; + if (!terms.length) return true; + const haystack = (segment.searchText ?? activityLogSegmentText(segment.summary)).toLowerCase(); + return terms.every((term) => haystack.includes(term)); +}; diff --git a/lib/components/ActivityLog/index.ts b/lib/components/ActivityLog/index.ts index 52bb2eab3..94672d394 100644 --- a/lib/components/ActivityLog/index.ts +++ b/lib/components/ActivityLog/index.ts @@ -1,3 +1,5 @@ export * from './ActivityLog'; export * from './ActivityLogSegment'; export * from './ActivityLogItem'; +export * from './activityLogSearch'; +export * from './useReservedHeight'; diff --git a/lib/components/ActivityLog/useReservedHeight.tsx b/lib/components/ActivityLog/useReservedHeight.tsx new file mode 100644 index 000000000..54978147a --- /dev/null +++ b/lib/components/ActivityLog/useReservedHeight.tsx @@ -0,0 +1,25 @@ +'use client'; +import { useLayoutEffect, useRef, useState } from 'react'; + +/** Hold the height an element had while `hold` was false, so filtering cannot shrink it. */ +export const useReservedHeight = (hold: boolean, enabled = true) => { + const ref = useRef(null); + const [reserved, setReserved] = useState(); + + useLayoutEffect(() => { + const element = ref.current; + if (!element || !enabled || hold) return; + + const update = () => { + const { height } = element.getBoundingClientRect(); + setReserved((prev) => (prev !== undefined && Math.abs(prev - height) < 1 ? prev : height)); + }; + + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [enabled, hold]); + + return { ref, minHeight: enabled ? reserved : undefined }; +}; diff --git a/lib/components/Dialog/DialogActivityLog.stories.ts b/lib/components/Dialog/DialogActivityLog.stories.ts deleted file mode 100644 index 343675d40..000000000 --- a/lib/components/Dialog/DialogActivityLog.stories.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { dialog, dialogActivityLog } from '../../../examples'; -import { DialogActivityLog } from './DialogActivityLog.tsx'; - -const meta = { - title: 'Inbox/Dialog/DialogActivityLog', - component: DialogActivityLog, - tags: [], - args: { - ...dialogActivityLog, - title: dialog.title, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - open: true, - onClose: () => { - alert('Close activityLog'); - }, - }, -}; diff --git a/lib/components/Dialog/DialogActivityLog.stories.tsx b/lib/components/Dialog/DialogActivityLog.stories.tsx new file mode 100644 index 000000000..47dfd3a32 --- /dev/null +++ b/lib/components/Dialog/DialogActivityLog.stories.tsx @@ -0,0 +1,71 @@ +import { type ChangeEvent, type ReactNode, useState } from 'react'; +import { Button, Typography } from '..'; +import { activityHistorySegments, dialog, dialogActivityLog, useActivityLogFilter } from '../../../examples'; +import { DialogActivityLog } from './DialogActivityLog.tsx'; + +const meta = { + title: 'Inbox/Dialog/DialogActivityLog', + component: DialogActivityLog, + tags: [], + args: { + ...dialogActivityLog, + title: dialog.title, + }, +}; + +export default meta; + +export const Default = { + args: { + open: true, + onClose: () => { + alert('Close activityLog'); + }, + }, +}; + +const EmptyState = ({ children }: { children: ReactNode }) => ( + +

{children}

+
+); + +/** + * The wrapper forwards every `ActivityLog` prop and renders `toolbar` above the + * log. The type filter is multi-select: check any number of types, and checking + * every one of them collapses back onto "Alle typer". + */ +export const WithFilterAndSearch = () => { + const [open, setOpen] = useState(true); + const [q, setQ] = useState(''); + + const { kind, filter } = useActivityLogFilter(activityHistorySegments); + + return ( + <> + + setOpen(false)} + items={activityHistorySegments} + kind={kind} + query={q} + toolbar={{ + filter, + search: { + name: 'q', + label: 'Søk i aktivitetsloggen', + placeholder: 'Søk ...', + value: q, + onChange: (event: ChangeEvent) => setQ(event.target.value), + onClear: () => setQ(''), + }, + }} + emptyState={Det er ikke registrert noen aktiviteter på denne meldingen.} + noResultsState={Ingen treff} + style={{ maxHeight: '60vh', overflowY: 'auto' }} + /> + + ); +}; diff --git a/lib/components/Dialog/DialogActivityLog.tsx b/lib/components/Dialog/DialogActivityLog.tsx index a8cb56d19..f626a2ef7 100644 --- a/lib/components/Dialog/DialogActivityLog.tsx +++ b/lib/components/Dialog/DialogActivityLog.tsx @@ -1,18 +1,20 @@ -import { ActivityLog, type ActivityLogProps, ModalBase, ModalBody, ModalHeader, type ToolbarProps } from '../'; +import { ActivityLog, type ActivityLogProps, ModalBase, ModalBody, ModalHeader, Toolbar, type ToolbarProps } from '../'; export interface DialogActivityLogProps extends ActivityLogProps { title: string; + /** Rendered above the log, for a filter and a search over it. */ toolbar?: ToolbarProps; open: boolean; onClose: () => void; } -export const DialogActivityLog = ({ title, items, open, onClose }: DialogActivityLogProps) => { +export const DialogActivityLog = ({ title, toolbar, open, onClose, ...activityLog }: DialogActivityLogProps) => { return ( - + {toolbar && } + ); diff --git a/lib/components/Dialog/__screenshots__/DialogActivityLog/WithFilterAndSearch.png b/lib/components/Dialog/__screenshots__/DialogActivityLog/WithFilterAndSearch.png new file mode 100644 index 000000000..959fda63a Binary files /dev/null and b/lib/components/Dialog/__screenshots__/DialogActivityLog/WithFilterAndSearch.png differ diff --git a/lib/components/Dropdown/Dropdown.tsx b/lib/components/Dropdown/Dropdown.tsx index 71cabc0ce..db5277bed 100644 --- a/lib/components/Dropdown/Dropdown.tsx +++ b/lib/components/Dropdown/Dropdown.tsx @@ -92,12 +92,23 @@ export const Dropdown = ({ expanded = false, }: DropdownProps) => { const containerRef = useRef(null); + const [portalTarget, setPortalTarget] = useState(null); const dropdownRef = useRef(null); const openRef = useRef(open); useEffect(() => { openRef.current = open; }, [open]); + /** A paints in the top layer, so portalling to document.body would put + * the dropdown behind the modal. Stay inside the dialog when there is one. */ + useEffect(() => { + if (!useFixedPosition) { + setPortalTarget(null); + return; + } + setPortalTarget(containerRef.current?.closest('dialog') ?? document.body); + }, [useFixedPosition]); + const [coords, setCoords] = useState<{ yDir: string; xDir: string; @@ -322,7 +333,7 @@ export const Dropdown = ({ {backdrop &&
}
{trigger} - {useFixedPosition ? createPortal(dropdownEl, document.body) : dropdownEl} + {useFixedPosition && portalTarget ? createPortal(dropdownEl, portalTarget) : dropdownEl}
); diff --git a/lib/components/Toolbar/ToolbarFilterMenu.tsx b/lib/components/Toolbar/ToolbarFilterMenu.tsx index d5159fb61..3f14a81bf 100644 --- a/lib/components/Toolbar/ToolbarFilterMenu.tsx +++ b/lib/components/Toolbar/ToolbarFilterMenu.tsx @@ -115,6 +115,7 @@ export const ToolbarFilterMenu = ({ if (removable) { return ( + new Set( + filters + .flatMap((filter) => filter.items ?? []) + .filter((option) => option.name === name && option.role === 'radio' && option.value !== undefined) + .map((option) => String(option.value)), + ); + const onFilterChange = (type: string, name: string, value: string) => { const values = [value]; @@ -81,16 +90,19 @@ export const useFilter = ({ filters = [], filterState, onFilterStateChange }: Us ...applicableFilterState, [name]: values, }); - } else { - changeFilterState({ - ...applicableFilterState, - [name]: applicableFilterState[name] - ? applicableFilterState[name].some((v) => values.includes(String(v))) - ? applicableFilterState[name].filter((v) => !(values || []).includes(String(v))) - : [...applicableFilterState[name], ...(values || [])] - : values, - }); + return; } + + /** A radio and a checkbox under one name are alternatives, never both at once. */ + const radioValues = radioValuesFor(name); + const current = (applicableFilterState[name] ?? []).filter((v) => !radioValues.has(String(v))); + + changeFilterState({ + ...applicableFilterState, + [name]: current.some((v) => values.includes(String(v))) + ? current.filter((v) => !values.includes(String(v))) + : [...current, ...values], + }); }; const onFilterRemove = (name: string) => {