Skip to content
5 changes: 3 additions & 2 deletions src/app/components/DotMenu.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@
outline: none;
}

.dot-menu-dropdown .dot-menu-dropdown-list.dot-menu-right-align {
/* Positioning for dropdown-menu container */
.dropdown-menu.dot-menu-right-align {
right: 0;
left: unset;
}

.dot-menu-dropdown .dot-menu-dropdown-list:not(.dot-menu-right-align) {
.dropdown-menu.dot-menu-left-align {
left: 0;
right: unset;
}
Expand Down
6 changes: 6 additions & 0 deletions src/app/components/DotMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,15 @@ export type DotMenuDropdownProps = Omit<DropdownProps, 'children' | 'toggle'> &
};

export function DotMenuDropdown({ className, children, toggle, ...props }: DotMenuDropdownProps) {
// Extract rightAlign from children if it's a DotMenuDropdownList
const rightAlign = React.Children.toArray(children).some(
(child) => React.isValidElement(child) && child.props.rightAlign
);

return (
<Dropdown
className={classNames('dot-menu-dropdown', className)}
menuClassName={rightAlign ? 'dot-menu-right-align' : 'dot-menu-left-align'}
toggle={toggle || <DotMenuToggle />}
{...props}
>
Expand Down
163 changes: 163 additions & 0 deletions src/app/components/Dropdown.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/* Dropdown component styles */

/* Keyframe animation for dropdown fade-in */
@keyframes dropdown-fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

/* DropdownToggle styles */
.dropdown-toggle {
cursor: pointer;
}

/* TabHiddenDropDown menu styles - applies to children that are not the toggle */
.dropdown-menu {
animation: 100ms dropdown-fade-in ease-out;
position: absolute;
box-shadow: 0 0.5rem 0.5rem 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--dropdown-form-border, #d5d5d5); /* theme.color.neutral.formBorder */
top: calc(100% + 0.4rem);
left: 0;
}

/* DropdownFocusWrapper styles */
.dropdown-focus-wrapper {
overflow: visible;
}

/* DropdownList styles */
.dropdown-list {
list-style: none;
margin: 0;
padding: 0.6rem 0;
background: var(--dropdown-form-background, #f5f5f5); /* theme.color.neutral.formBackground */
z-index: 1;
}

.dropdown-list li {
padding: 0.2rem;
}

.dropdown-list li button,
.dropdown-list li a {
white-space: nowrap;
text-decoration: none;
display: flex;
align-items: center;
text-align: left;
cursor: pointer;
outline: none;
border: none;
padding-left: 0.8rem;
margin: 0;
height: 3rem;
background: none;
min-width: 7rem;
/* textStyle from Typography - using CSS variables */
font-family: var(--text-font-family, Helvetica, Arial, sans-serif);
font-weight: var(--text-font-weight, normal);
color: var(--dropdown-text-color, #424242); /* theme.color.text.default */
font-size: 1.4rem;
line-height: 2rem;
}

.dropdown-list li button:focus,
.dropdown-list li a:focus {
background: var(--dropdown-form-border, #d5d5d5); /* theme.color.neutral.formBorder */
outline: 0.2rem auto Highlight;
outline: 0.2rem auto -webkit-focus-ring-color;
}

/* TabTransparentDropdown focus-within behavior */
/* Hide the second toggle by default */
.dropdown-transparent .dropdown-toggle-second {
height: 0;
width: 0;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
}

/* Show the second toggle when focus is within the wrapper */
.dropdown-transparent .dropdown-focus-wrapper.focus-within + .dropdown-toggle-second,
.dropdown-transparent .dropdown-focus-wrapper:focus-within + .dropdown-toggle-second {
height: unset;
width: unset;
clip: unset;
overflow: visible;
}

/* Show the first toggle by default */
.dropdown-transparent .dropdown-focus-wrapper > .dropdown-toggle {
height: unset;
width: unset;
clip: unset;
overflow: visible;
}

/* Hide the first toggle when focus is within the wrapper */
.dropdown-transparent .dropdown-focus-wrapper.focus-within > .dropdown-toggle,
.dropdown-transparent .dropdown-focus-wrapper:focus-within > .dropdown-toggle {
height: 0;
width: 0;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
}

/* Menu positioning and animation for TabTransparentDropdown */
.dropdown-transparent .dropdown-focus-wrapper > .dropdown-menu {
animation: 100ms dropdown-fade-in ease-out;
position: absolute;
box-shadow: 0 0.5rem 0.5rem 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--dropdown-form-border, #d5d5d5); /* theme.color.neutral.formBorder */
top: calc(100% + 0.4rem);
left: 0;
}

/* Hide the menu by default */
.dropdown-transparent .dropdown-focus-wrapper > .dropdown-menu {
Comment thread
RoyEJohnson marked this conversation as resolved.
Outdated
height: 0;
width: 0;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
}

/* Show the menu when focus is within the wrapper */
.dropdown-transparent .dropdown-focus-wrapper.focus-within > .dropdown-menu,
.dropdown-transparent .dropdown-focus-wrapper:focus-within > .dropdown-menu {
height: unset;
width: unset;
clip: unset;
overflow: visible;
}

/* TextResizerMenu customization for dropdown-menu */
.dropdown-menu.text-resizer-menu {
background: #fff;
right: 0;
left: auto;
top: calc(100% - 1px);
}

/* ContextMenu customization for dropdown-menu */
.dropdown-menu.context-menu {
z-index: 2;
border: 1px solid var(--dropdown-form-border, #d5d5d5); /* theme.color.neutral.formBorder */
background-color: var(--dropdown-form-background, #f5f5f5); /* theme.color.neutral.formBackground */
margin-bottom: 1rem; /* for last context menu to show with more space */
}

.dropdown-menu.context-menu {
menu {
padding: 0;
}
}

/* DisplayNote menu customization for dropdown-menu */
.dropdown-menu.display-note-menu {
left: -4rem;
}
157 changes: 157 additions & 0 deletions src/app/components/Dropdown.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,161 @@ describe('Dropdown', () => {

expect(mockEv.preventDefault).toHaveBeenCalledTimes(2);
});

it('TabTransparentDropdown handles focus in', () => {
const component = renderer.create(<TestContainer>
<Dropdown toggle={<button>show more</button>}>
<DropdownList>
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
</DropdownList>
</Dropdown>
</TestContainer>);

// Find the dropdown focus wrapper div
const focusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];

// Initially, focus-within should not be present
expect(focusWrapper.props.className).not.toContain('focus-within');

// Simulate focus in
renderer.act(() => {
focusWrapper.props.onFocus();
});

// After focus in, focus-within should be present
const updatedFocusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];
expect(updatedFocusWrapper.props.className).toContain('focus-within');
});

it('TabTransparentDropdown handles focus out when focus moves outside', () => {
const component = renderer.create(<TestContainer>
<Dropdown toggle={<button>show more</button>}>
<DropdownList>
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
</DropdownList>
</Dropdown>
</TestContainer>);

// Find the dropdown focus wrapper div
const focusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];

// Simulate focus in first
renderer.act(() => {
focusWrapper.props.onFocus();
});

// Verify focus-within is present
let updatedFocusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];
expect(updatedFocusWrapper.props.className).toContain('focus-within');

// Simulate focus out to an element outside the dropdown (relatedTarget is outside)
renderer.act(() => {
const outsideElement = document?.createElement('div');
focusWrapper.props.onBlur({
currentTarget: {
contains: jest.fn().mockReturnValue(false),
},
relatedTarget: outsideElement,
});
});

// After focus out, focus-within should be removed
updatedFocusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];
expect(updatedFocusWrapper.props.className).not.toContain('focus-within');
});

it('TabTransparentDropdown handles focus out when relatedTarget is null', () => {
const component = renderer.create(<TestContainer>
<Dropdown toggle={<button>show more</button>}>
<DropdownList>
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
</DropdownList>
</Dropdown>
</TestContainer>);

// Find the dropdown focus wrapper div
const focusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];

// Simulate focus in first
renderer.act(() => {
focusWrapper.props.onFocus();
});

// Verify focus-within is present
let updatedFocusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];
expect(updatedFocusWrapper.props.className).toContain('focus-within');

// Simulate focus out with null relatedTarget (focus moved to browser UI or another app)
renderer.act(() => {
focusWrapper.props.onBlur({
currentTarget: {
contains: jest.fn(),
},
relatedTarget: null,
});
});

// After focus out with null relatedTarget, focus-within should be removed
updatedFocusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];
expect(updatedFocusWrapper.props.className).not.toContain('focus-within');
});

it('TabTransparentDropdown maintains focus when moving within the dropdown', () => {
const component = renderer.create(<TestContainer>
<Dropdown toggle={<button>show more</button>}>
<DropdownList>
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
</DropdownList>
</Dropdown>
</TestContainer>);

// Find the dropdown focus wrapper div
const focusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];

// Simulate focus in first
renderer.act(() => {
focusWrapper.props.onFocus();
});

// Verify focus-within is present
let updatedFocusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];
expect(updatedFocusWrapper.props.className).toContain('focus-within');

// Simulate focus moving to another element within the dropdown (relatedTarget is inside)
renderer.act(() => {
const insideElement = document?.createElement('div');
focusWrapper.props.onBlur({
currentTarget: {
contains: jest.fn().mockReturnValue(true),
},
relatedTarget: insideElement,
});
});

// Focus-within should still be present because focus is still within the dropdown
updatedFocusWrapper = component.root.findAll(
(el) => el.props.className && el.props.className.includes('dropdown-focus-wrapper')
)[0];
expect(updatedFocusWrapper.props.className).toContain('focus-within');
});
});
Loading
Loading