Skip to content

Commit 12d31bd

Browse files
authored
Merge branch 'main' into phase-2.2-modal-migration
2 parents bde54d4 + 470fb66 commit 12d31bd

23 files changed

Lines changed: 2633 additions & 3343 deletions

File tree

src/app/components/DotMenu.css

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@
2222
outline: none;
2323
}
2424

25-
.dot-menu-dropdown .dot-menu-dropdown-list.dot-menu-right-align {
25+
/* Positioning for dropdown-menu container */
26+
.dropdown-menu.dot-menu-right-align {
2627
right: 0;
2728
left: unset;
2829
}
2930

30-
.dot-menu-dropdown .dot-menu-dropdown-list:not(.dot-menu-right-align) {
31+
.dropdown-menu.dot-menu-left-align {
3132
left: 0;
3233
right: unset;
3334
}

src/app/components/DotMenu.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,15 @@ export type DotMenuDropdownProps = Omit<DropdownProps, 'children' | 'toggle'> &
7979
};
8080

8181
export function DotMenuDropdown({ className, children, toggle, ...props }: DotMenuDropdownProps) {
82+
// Extract rightAlign from children if it's a DotMenuDropdownList
83+
const rightAlign = React.Children.toArray(children).some(
84+
(child) => React.isValidElement(child) && child.props.rightAlign
85+
);
86+
8287
return (
8388
<Dropdown
8489
className={classNames('dot-menu-dropdown', className)}
90+
menuClassName={rightAlign ? 'dot-menu-right-align' : 'dot-menu-left-align'}
8591
toggle={toggle || <DotMenuToggle />}
8692
{...props}
8793
>

src/app/components/Dropdown.css

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/* Dropdown component styles */
2+
3+
/* Keyframe animation for dropdown fade-in */
4+
@keyframes dropdown-fade-in {
5+
0% {
6+
opacity: 0;
7+
}
8+
9+
100% {
10+
opacity: 1;
11+
}
12+
}
13+
14+
/* DropdownToggle styles */
15+
.dropdown-toggle {
16+
cursor: pointer;
17+
}
18+
19+
/* TabHiddenDropDown menu styles - applies to children that are not the toggle */
20+
.dropdown-menu {
21+
animation: 100ms dropdown-fade-in ease-out;
22+
position: absolute;
23+
box-shadow: 0 0.5rem 0.5rem 0 rgba(0, 0, 0, 0.1);
24+
border: 1px solid var(--dropdown-form-border, #d5d5d5); /* theme.color.neutral.formBorder */
25+
top: calc(100% + 0.4rem);
26+
left: 0;
27+
}
28+
29+
/* DropdownFocusWrapper styles */
30+
.dropdown-focus-wrapper {
31+
overflow: visible;
32+
}
33+
34+
/* DropdownList styles */
35+
.dropdown-list {
36+
list-style: none;
37+
margin: 0;
38+
padding: 0.6rem 0;
39+
background: var(--dropdown-form-background, #f5f5f5); /* theme.color.neutral.formBackground */
40+
z-index: 1;
41+
}
42+
43+
.dropdown-list li {
44+
padding: 0.2rem;
45+
}
46+
47+
.dropdown-list li button,
48+
.dropdown-list li a {
49+
white-space: nowrap;
50+
text-decoration: none;
51+
display: flex;
52+
align-items: center;
53+
text-align: left;
54+
cursor: pointer;
55+
outline: none;
56+
border: none;
57+
padding-left: 0.8rem;
58+
margin: 0;
59+
height: 3rem;
60+
background: none;
61+
min-width: 7rem;
62+
63+
/* textStyle from Typography - using CSS variables */
64+
font-family: var(--text-font-family, Helvetica, Arial, sans-serif);
65+
font-weight: var(--text-font-weight, normal);
66+
color: var(--dropdown-text-color, #424242); /* theme.color.text.default */
67+
font-size: 1.4rem;
68+
line-height: 2rem;
69+
}
70+
71+
.dropdown-list li button:focus,
72+
.dropdown-list li a:focus {
73+
background: var(--dropdown-form-border, #d5d5d5); /* theme.color.neutral.formBorder */
74+
outline: 0.2rem auto Highlight;
75+
outline: 0.2rem auto -webkit-focus-ring-color;
76+
}
77+
78+
/* TabTransparentDropdown focus-within behavior */
79+
80+
/* Hide the second toggle by default */
81+
.dropdown-transparent .dropdown-toggle-second {
82+
height: 0;
83+
width: 0;
84+
overflow: hidden;
85+
clip: rect(1px, 1px, 1px, 1px);
86+
}
87+
88+
/* Show the second toggle when focus is within the wrapper */
89+
.dropdown-transparent .dropdown-focus-wrapper.focus-within + .dropdown-toggle-second,
90+
.dropdown-transparent .dropdown-focus-wrapper:focus-within + .dropdown-toggle-second {
91+
height: unset;
92+
width: unset;
93+
clip: unset;
94+
overflow: visible;
95+
}
96+
97+
/* Show the first toggle by default */
98+
.dropdown-transparent .dropdown-focus-wrapper > .dropdown-toggle {
99+
height: unset;
100+
width: unset;
101+
clip: unset;
102+
overflow: visible;
103+
}
104+
105+
/* Hide the first toggle when focus is within the wrapper */
106+
.dropdown-transparent .dropdown-focus-wrapper.focus-within > .dropdown-toggle,
107+
.dropdown-transparent .dropdown-focus-wrapper:focus-within > .dropdown-toggle {
108+
height: 0;
109+
width: 0;
110+
overflow: hidden;
111+
clip: rect(1px, 1px, 1px, 1px);
112+
}
113+
114+
/* Menu positioning and animation for TabTransparentDropdown */
115+
.dropdown-transparent .dropdown-focus-wrapper > .dropdown-menu {
116+
animation: 100ms dropdown-fade-in ease-out;
117+
position: absolute;
118+
box-shadow: 0 0.5rem 0.5rem 0 rgba(0, 0, 0, 0.1);
119+
border: 1px solid var(--dropdown-form-border, #d5d5d5); /* theme.color.neutral.formBorder */
120+
top: calc(100% + 0.4rem);
121+
left: 0;
122+
}
123+
124+
/* Hide the menu by default */
125+
:where(.dropdown-transparent .dropdown-focus-wrapper > .dropdown-menu) {
126+
height: 0;
127+
width: 0;
128+
overflow: hidden;
129+
clip: rect(1px, 1px, 1px, 1px);
130+
}
131+
132+
/* Show the menu when focus is within the wrapper */
133+
.dropdown-transparent .dropdown-focus-wrapper.focus-within > .dropdown-menu,
134+
.dropdown-transparent .dropdown-focus-wrapper:focus-within > .dropdown-menu {
135+
height: unset;
136+
width: unset;
137+
clip: unset;
138+
overflow: visible;
139+
}
140+
141+
/* TextResizerMenu customization for dropdown-menu */
142+
.dropdown-menu.text-resizer-menu {
143+
background: #fff;
144+
right: 0;
145+
left: auto;
146+
top: calc(100% - 1px);
147+
}
148+
149+
/* ContextMenu customization for dropdown-menu */
150+
.dropdown-menu.context-menu {
151+
z-index: 2;
152+
border: 1px solid var(--dropdown-form-border, #d5d5d5); /* theme.color.neutral.formBorder */
153+
background-color: var(--dropdown-form-background, #f5f5f5); /* theme.color.neutral.formBackground */
154+
margin-bottom: 1rem; /* for last context menu to show with more space */
155+
156+
menu {
157+
padding: 0;
158+
}
159+
}
160+
161+
/* DisplayNote menu customization for dropdown-menu */
162+
.dropdown-menu.display-note-menu {
163+
left: -4rem;
164+
}

src/app/components/Dropdown.spec.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,161 @@ describe('Dropdown', () => {
177177

178178
expect(mockEv.preventDefault).toHaveBeenCalledTimes(2);
179179
});
180+
181+
it('TabTransparentDropdown handles focus in', () => {
182+
const component = renderer.create(<TestContainer>
183+
<Dropdown toggle={<button>show more</button>}>
184+
<DropdownList>
185+
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
186+
</DropdownList>
187+
</Dropdown>
188+
</TestContainer>);
189+
190+
// Find the dropdown focus wrapper div
191+
const focusWrapper = component.root.findAll(
192+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
193+
)[0];
194+
195+
// Initially, focus-within should not be present
196+
expect(focusWrapper.props.className).not.toContain('focus-within');
197+
198+
// Simulate focus in
199+
renderer.act(() => {
200+
focusWrapper.props.onFocus();
201+
});
202+
203+
// After focus in, focus-within should be present
204+
const updatedFocusWrapper = component.root.findAll(
205+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
206+
)[0];
207+
expect(updatedFocusWrapper.props.className).toContain('focus-within');
208+
});
209+
210+
it('TabTransparentDropdown handles focus out when focus moves outside', () => {
211+
const component = renderer.create(<TestContainer>
212+
<Dropdown toggle={<button>show more</button>}>
213+
<DropdownList>
214+
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
215+
</DropdownList>
216+
</Dropdown>
217+
</TestContainer>);
218+
219+
// Find the dropdown focus wrapper div
220+
const focusWrapper = component.root.findAll(
221+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
222+
)[0];
223+
224+
// Simulate focus in first
225+
renderer.act(() => {
226+
focusWrapper.props.onFocus();
227+
});
228+
229+
// Verify focus-within is present
230+
let updatedFocusWrapper = component.root.findAll(
231+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
232+
)[0];
233+
expect(updatedFocusWrapper.props.className).toContain('focus-within');
234+
235+
// Simulate focus out to an element outside the dropdown (relatedTarget is outside)
236+
renderer.act(() => {
237+
const outsideElement = document?.createElement('div');
238+
focusWrapper.props.onBlur({
239+
currentTarget: {
240+
contains: jest.fn().mockReturnValue(false),
241+
},
242+
relatedTarget: outsideElement,
243+
});
244+
});
245+
246+
// After focus out, focus-within should be removed
247+
updatedFocusWrapper = component.root.findAll(
248+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
249+
)[0];
250+
expect(updatedFocusWrapper.props.className).not.toContain('focus-within');
251+
});
252+
253+
it('TabTransparentDropdown handles focus out when relatedTarget is null', () => {
254+
const component = renderer.create(<TestContainer>
255+
<Dropdown toggle={<button>show more</button>}>
256+
<DropdownList>
257+
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
258+
</DropdownList>
259+
</Dropdown>
260+
</TestContainer>);
261+
262+
// Find the dropdown focus wrapper div
263+
const focusWrapper = component.root.findAll(
264+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
265+
)[0];
266+
267+
// Simulate focus in first
268+
renderer.act(() => {
269+
focusWrapper.props.onFocus();
270+
});
271+
272+
// Verify focus-within is present
273+
let updatedFocusWrapper = component.root.findAll(
274+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
275+
)[0];
276+
expect(updatedFocusWrapper.props.className).toContain('focus-within');
277+
278+
// Simulate focus out with null relatedTarget (focus moved to browser UI or another app)
279+
renderer.act(() => {
280+
focusWrapper.props.onBlur({
281+
currentTarget: {
282+
contains: jest.fn(),
283+
},
284+
relatedTarget: null,
285+
});
286+
});
287+
288+
// After focus out with null relatedTarget, focus-within should be removed
289+
updatedFocusWrapper = component.root.findAll(
290+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
291+
)[0];
292+
expect(updatedFocusWrapper.props.className).not.toContain('focus-within');
293+
});
294+
295+
it('TabTransparentDropdown maintains focus when moving within the dropdown', () => {
296+
const component = renderer.create(<TestContainer>
297+
<Dropdown toggle={<button>show more</button>}>
298+
<DropdownList>
299+
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
300+
</DropdownList>
301+
</Dropdown>
302+
</TestContainer>);
303+
304+
// Find the dropdown focus wrapper div
305+
const focusWrapper = component.root.findAll(
306+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
307+
)[0];
308+
309+
// Simulate focus in first
310+
renderer.act(() => {
311+
focusWrapper.props.onFocus();
312+
});
313+
314+
// Verify focus-within is present
315+
let updatedFocusWrapper = component.root.findAll(
316+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
317+
)[0];
318+
expect(updatedFocusWrapper.props.className).toContain('focus-within');
319+
320+
// Simulate focus moving to another element within the dropdown (relatedTarget is inside)
321+
renderer.act(() => {
322+
const insideElement = document?.createElement('div');
323+
focusWrapper.props.onBlur({
324+
currentTarget: {
325+
contains: jest.fn().mockReturnValue(true),
326+
},
327+
relatedTarget: insideElement,
328+
});
329+
});
330+
331+
// Focus-within should still be present because focus is still within the dropdown
332+
updatedFocusWrapper = component.root.findAll(
333+
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
334+
)[0];
335+
expect(updatedFocusWrapper.props.className).toContain('focus-within');
336+
});
180337
});

0 commit comments

Comments
 (0)