Skip to content

Commit c84e2a2

Browse files
committed
Fix tests and fix new popover positioning
1 parent 290c7e6 commit c84e2a2

32 files changed

+446
-444
lines changed

browser/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ This changelog covers all five packages, as they are (for now) updated as a whol
4141
- BREAKING CHANGE: `useCanWrite` now only returns a boolean. There is no longer a message returned.
4242
- BREAKING CHANGE: `useCanWrite` does not take an agent as argument any more and only checks the agent set in the store. If you need to explicitly check a different agent, use `await resource.canWrite(agent)`.
4343
- BREAKING CHANGE: `useDebounce` and `useDebouncedCallback` are no longer exported.
44+
- BREAKING CHANGE: @tomic/react now requires React 19.2.0 or above.
4445
- Added `useDebouncedSave` hook.
4546
- Add a cjs build.
4647

browser/create-template/templates/nextjs-site/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
"gray-matter": "^4.0.3",
1818
"modern-css-reset": "^1.4.0",
1919
"next": "15.0.4",
20-
"react": "19.0.0",
21-
"react-dom": "19.0.0",
20+
"react": "19.2.0",
21+
"react-dom": "19.2.0",
2222
"remark": "^15.0.1",
2323
"remark-html": "^16.0.1",
2424
"zod": "^3.23.8"

browser/data-browser/src/chunks/RTE/CollaborativeEditor.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,23 +57,19 @@ import { addIf } from '@helpers/addIf';
5757
export type CollaborativeEditorProps = {
5858
placeholder?: string;
5959
doc: Y.Doc;
60-
autoFocus?: boolean;
6160
resource: Resource;
6261
property: string;
6362
id?: string;
64-
labelId?: string;
6563
onBlur?: () => void;
6664
};
6765

6866
const COLORS = ['#70d6ff', '#ff70a6', '#ff9770', '#ffd670', '#e9ff70'];
6967

7068
export default function CollaborativeEditor({
7169
placeholder,
72-
autoFocus,
7370
doc,
7471
property,
7572
id,
76-
labelId,
7773
resource,
7874
onBlur,
7975
}: CollaborativeEditorProps): React.JSX.Element {
@@ -249,11 +245,12 @@ export default function CollaborativeEditor({
249245
],
250246
editable: canWrite,
251247
onBlur,
252-
autofocus: !!autoFocus,
253248
editorProps: {
254249
attributes: {
255250
...(id && { id }),
256-
...(labelId && { 'aria-labelledby': labelId }),
251+
'aria-label': 'Rich Text Editor',
252+
'aria-multiline': 'true',
253+
'aria-readonly': canWrite ? 'true' : 'false',
257254
spellcheck: 'true',
258255
},
259256
},
Lines changed: 115 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,144 @@
11
import {
2-
useEffectEvent,
2+
useEffect,
33
useId,
4-
useLayoutEffect,
54
useRef,
5+
useState,
66
type ReactNode,
7+
type RefObject,
78
} from 'react';
89
import { styled } from 'styled-components';
910
import { transparentize } from 'polished';
1011
import { fadeIn } from '@helpers/commonAnimations';
1112
import { useControlLock } from '@hooks/useControlLock';
1213
import { useDialogTreeInfo } from './Dialog/dialogContext';
13-
import { useControllable } from '@hooks/useControlable';
14+
import { useOnValueChange } from '@helpers/useOnValueChange';
1415

1516
export interface TriggerProps {
1617
onClick: () => void;
1718
'data-popover-target': string;
1819
}
1920

20-
export interface PopoverProps {
21-
Trigger: (props: TriggerProps) => ReactNode;
22-
open?: boolean;
23-
defaultOpen?: boolean;
24-
onOpenChange: (open: boolean) => void;
21+
export interface PopoverPropsFromHook {
22+
isOpen: boolean;
23+
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
24+
anchorName: string;
25+
}
26+
export interface PopoverProps extends PopoverPropsFromHook {
27+
Trigger: ReactNode;
2528
className?: string;
26-
noArrow?: boolean;
2729
noLock?: boolean;
28-
modal?: boolean;
29-
side?: 'top' | 'bottom' | 'left' | 'right';
3030
}
3131

32+
export interface UsePopoverProps {
33+
defaultOpen?: boolean;
34+
autoFocusElement?: RefObject<HTMLElement | null>;
35+
}
36+
37+
export interface UsePopoverReturn {
38+
triggerProps: {
39+
onClick: () => void;
40+
'data-popover-target': string;
41+
};
42+
popoverProps: PopoverPropsFromHook;
43+
openPopover: () => void;
44+
closePopover: () => void;
45+
isOpen: boolean;
46+
}
47+
48+
export const usePopover = ({
49+
defaultOpen = false,
50+
autoFocusElement,
51+
}: UsePopoverProps): UsePopoverReturn => {
52+
const id = useId();
53+
const [isOpen, setIsOpen] = useState(defaultOpen);
54+
const { setHasOpenInnerPopup } = useDialogTreeInfo();
55+
56+
const openPopover = () => {
57+
setIsOpen(true);
58+
};
59+
60+
const closePopover = () => {
61+
setIsOpen(false);
62+
};
63+
64+
const triggerProps = {
65+
onClick: () => setIsOpen(prev => !prev),
66+
'data-popover-target': id,
67+
};
68+
69+
const popoverProps = {
70+
anchorName: id,
71+
isOpen,
72+
setIsOpen,
73+
};
74+
75+
useOnValueChange(() => {
76+
setHasOpenInnerPopup(isOpen);
77+
}, [isOpen]);
78+
79+
useEffect(() => {
80+
if (isOpen && autoFocusElement && autoFocusElement.current) {
81+
autoFocusElement.current.focus();
82+
}
83+
}, [isOpen, autoFocusElement]);
84+
85+
return { triggerProps, popoverProps, openPopover, closePopover, isOpen };
86+
};
87+
3288
/**
3389
* Popover component, consists of an outer dialog element and an inner content div.
3490
* To style the content div use `${CustomPopover.Content}: { ... }`
3591
*/
3692
export function CustomPopover({
3793
Trigger,
38-
open: parentOpen,
39-
defaultOpen,
40-
onOpenChange,
94+
anchorName,
95+
isOpen,
96+
setIsOpen,
4197
className,
4298
noLock,
43-
side = 'top',
44-
modal,
4599
children,
46100
}: React.PropsWithChildren<PopoverProps>) {
47-
const popoverRef = useRef<HTMLDialogElement>(null);
101+
const popoverRef = useRef<HTMLDivElement>(null);
48102
const contentRef = useRef<HTMLDivElement>(null);
49-
const id = useId();
50103

51-
const setElementState = (state: boolean) => {
52-
if (state && !popoverRef.current?.hasAttribute('open')) {
53-
if (modal) {
54-
popoverRef.current?.showModal();
55-
} else {
56-
popoverRef.current?.show();
57-
}
58-
} else if (!state && popoverRef.current?.hasAttribute('open')) {
59-
popoverRef.current?.close();
104+
useEffect(() => {
105+
if (isOpen && !popoverRef.current?.matches(':popover-open')) {
106+
popoverRef.current?.showPopover();
107+
} else if (!isOpen && popoverRef.current?.matches(':popover-open')) {
108+
popoverRef.current?.hidePopover();
60109
}
61-
};
62-
63-
const onStateChange = (state: boolean) => {
64-
setElementState(state);
65-
setHasOpenInnerPopup(state);
66-
67-
onOpenChange?.(state);
68-
};
69-
70-
const [open, setOpen] = useControllable({
71-
controlledValue: parentOpen,
72-
defaultValue: defaultOpen,
73-
onChange: onStateChange,
74-
});
110+
}, [isOpen]);
75111

76-
const { setHasOpenInnerPopup } = useDialogTreeInfo();
112+
useEffect(() => {
113+
const handleToggle = (e: ToggleEvent) => {
114+
if (e.newState === 'closed') {
115+
setIsOpen(false);
116+
}
117+
};
77118

78-
const handleOutsideClick = (
79-
e: React.MouseEvent<HTMLDialogElement, MouseEvent>,
80-
) => {
81-
if (
82-
!contentRef.current?.contains(e.target as HTMLElement) &&
83-
contentRef.current !== e.target
84-
) {
85-
setOpen(false);
86-
}
87-
};
119+
if (!popoverRef.current) return;
88120

89-
const setElementStateEffect = useEffectEvent((state: boolean) => {
90-
setElementState(state);
91-
});
121+
const popover = popoverRef.current;
122+
popover.addEventListener('toggle', handleToggle);
92123

93-
useLayoutEffect(() => {
94-
setElementStateEffect(!!open);
95-
}, [open]);
124+
return () => {
125+
popover.removeEventListener('toggle', handleToggle);
126+
};
127+
}, [setIsOpen]);
96128

97-
useControlLock(!noLock && !!open);
129+
useControlLock(!noLock && !!isOpen);
98130

99131
return (
100-
<Wrapper anchorName={id}>
101-
<Trigger
102-
onClick={() => setOpen(prev => !prev)}
103-
data-popover-target={id}
104-
/>
132+
<Wrapper anchorName={anchorName}>
133+
{Trigger}
105134
<Popover
106-
anchorName={id}
135+
anchorName={anchorName}
107136
popover='auto'
108137
ref={popoverRef}
109-
id={id}
110-
side={side}
111-
onMouseDown={e => handleOutsideClick(e)}
138+
id={anchorName}
112139
className={className}
113140
>
114-
<PopoverContent ref={contentRef}>{open && children}</PopoverContent>
141+
<PopoverContent ref={contentRef}>{isOpen && children}</PopoverContent>
115142
</Popover>
116143
</Wrapper>
117144
);
@@ -124,12 +151,25 @@ CustomPopover.Content = PopoverContent;
124151
const Wrapper = styled.div<{ anchorName: string }>`
125152
display: contents;
126153
127-
& button[data-popover-target='${p => p.anchorName}'] {
154+
& *[data-popover-target='${p => p.anchorName}'] {
128155
anchor-name: --${p => p.anchorName};
129156
}
130157
`;
131158

132-
const Popover = styled.dialog<{ anchorName: string; side: string }>`
159+
const Popover = styled.div<{ anchorName: string }>`
160+
@position-try --top-right {
161+
position-area: top span-right;
162+
}
163+
@position-try --top-left {
164+
position-area: top span-left;
165+
}
166+
@position-try --bottom-right {
167+
position-area: bottom span-right;
168+
}
169+
@position-try --bottom-left {
170+
position-area: bottom span-left;
171+
}
172+
133173
border: none;
134174
background-color: ${p => transparentize(0.2, p.theme.colors.bgBody)};
135175
backdrop-filter: blur(10px);
@@ -139,11 +179,10 @@ const Popover = styled.dialog<{ anchorName: string; side: string }>`
139179
margin: 0;
140180
padding: 0;
141181
inset: auto;
182+
position: fixed;
142183
position-anchor: --${p => p.anchorName};
143-
position-area: ${p => p.side};
144-
position-try-fallbacks: flip-block;
184+
position-area: top center;
185+
position-try: --top-right, --top-left, --bottom-right, --bottom-left;
145186
max-height: unset;
146-
&::backdrop {
147-
background-color: transparent;
148-
}
187+
min-width: max-content;
149188
`;

browser/data-browser/src/components/Searchbar/Searchbar.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
base64StringToFilter,
1818
filterToBase64String,
1919
} from '../../routes/Search/searchUtils';
20+
import { addFieldsIf } from '@helpers/addIf';
2021

2122
function addTagsToFilter(
2223
base64Filter: string | undefined,
@@ -52,10 +53,10 @@ export function Searchbar(): JSX.Element {
5253
to: paths.search,
5354
search: prev => ({
5455
query: q,
55-
...(scope ? { queryscope: scope } : {}),
56-
...(tags.length > 0
57-
? { filters: addTagsToFilter(prev.filters, tags) }
58-
: {}),
56+
...addFieldsIf(!!scope, { queryscope: scope }),
57+
...addFieldsIf(tags.length > 0, {
58+
filters: addTagsToFilter(prev.filters, tags),
59+
}),
5960
}),
6061
replace: true,
6162
});

browser/data-browser/src/components/Tag/Tag.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function TagButton({
104104
e.stopPropagation();
105105
onClick(subject);
106106
},
107-
[onClick],
107+
[onClick, subject],
108108
);
109109

110110
const className = selected ? 'selected-tag' : '';
@@ -171,14 +171,15 @@ const TagWrapperButton = styled(TagWrapper)`
171171
cursor: pointer;
172172
user-select: none;
173173
174-
transition: ${transition('filter', 'box-shadow')};
174+
transition: ${transition('filter', 'box-shadow', 'transform')};
175175
animation: ${fadeIn} 0.2s ease-in-out;
176176
&:hover,
177177
&:focus,
178178
&.selected-tag {
179179
--shadow-color: ${({ theme }) =>
180180
theme.darkMode ? 'var(--dark-color)' : 'var(--light-color)'};
181181
filter: brightness(1.05);
182+
transform: scale(1.1);
182183
box-shadow: 0 1px 20px 0px var(--shadow-color);
183184
}
184185
`;

browser/data-browser/src/components/forms/InputResourceArray.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export default function InputResourceArray({
9090
setError(newArray.length === 0 ? 'Required' : undefined);
9191
}
9292
},
93-
[property.datatype, setArray, setError, required, addingNewItem, array],
93+
[property.datatype, setArray, setError, required, array],
9494
);
9595

9696
const handleSetSubjectMemos = useMemo(() => {
@@ -198,7 +198,7 @@ export default function InputResourceArray({
198198
>
199199
<FaPlus />
200200
</AddButton>
201-
{array.length > 1 && (
201+
{array.length > 0 && (
202202
<StyledButton
203203
title='Remove all items from this list'
204204
data-testid={`input-${property.shortname}-clear`}

browser/data-browser/src/helpers/useOnValueChange.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { useState } from 'react';
22

3-
export function useOnValueChange(callback: () => void, dependants: unknown[]) {
4-
const [deps, setDeps] = useState(dependants);
3+
const initialUnique = [Symbol('uniqueValue')];
4+
5+
export function useOnValueChange(
6+
callback: () => void,
7+
dependants: unknown[],
8+
runOnMount: boolean = false,
9+
) {
10+
const [deps, setDeps] = useState(runOnMount ? initialUnique : dependants);
511

612
if (deps.some((d, i) => d !== dependants[i])) {
713
setDeps(dependants);

0 commit comments

Comments
 (0)