feat(Dropdown): implement closing animation and portal rendering for dropdown menu

fix(QuoteOptions): ensure authorDetails is set to true for users upgrading from older versions
This commit is contained in:
alexsparkes
2026-01-27 14:22:15 +00:00
parent 9bf160094e
commit 7a589de14b
3 changed files with 109 additions and 50 deletions

View File

@@ -5,7 +5,7 @@ on:
branches: branches:
- beta - beta
tags: tags:
- 'v*-beta.*' - "v*-beta.*"
permissions: permissions:
contents: write contents: write
@@ -23,7 +23,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with: with:
bun-version: '1.3.1' bun-version: "1.3.1"
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
@@ -98,7 +98,7 @@ jobs:
- name: Create or Update GitHub Pre-Release - name: Create or Update GitHub Pre-Release
run: | run: |
RELEASE_NOTES=$(cat <<EOF RELEASE_NOTES=$(cat <<EOF
## 🧪 Mue Beta v${{ steps.version.outputs.version }} ## 🧪 Mue v${{ steps.version.outputs.version }}
**⚠️ This is a beta release for testing purposes only.** **⚠️ This is a beta release for testing purposes only.**
@@ -137,7 +137,7 @@ jobs:
gh release create "v${{ steps.version.outputs.version }}" \ gh release create "v${{ steps.version.outputs.version }}" \
"build/chrome-${{ steps.version.outputs.version }}.zip" \ "build/chrome-${{ steps.version.outputs.version }}.zip" \
"build/firefox-${{ steps.version.outputs.version }}.zip" \ "build/firefox-${{ steps.version.outputs.version }}.zip" \
--title "Beta v${{ steps.version.outputs.version }}" \ --title "v${{ steps.version.outputs.version }}" \
--notes "$RELEASE_NOTES" \ --notes "$RELEASE_NOTES" \
--prerelease --prerelease
fi fi

View File

@@ -1,5 +1,6 @@
import variables from 'config/variables'; import variables from 'config/variables';
import { memo, useState, useCallback, useRef, useEffect } from 'react'; import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { MdExpandMore, MdCheck, MdRefresh } from 'react-icons/md'; import { MdExpandMore, MdCheck, MdRefresh } from 'react-icons/md';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -8,25 +9,51 @@ import EventBus from 'utils/eventbus';
import './Dropdown.scss'; import './Dropdown.scss';
const Dropdown = memo((props) => { const Dropdown = memo((props) => {
const [value, setValue] = useState( const [value, setValue] = useState(localStorage.getItem(props.name) || props.items[0]?.value);
localStorage.getItem(props.name) || props.items[0]?.value,
);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1); const [focusedIndex, setFocusedIndex] = useState(-1);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
const containerRef = useRef(null); const containerRef = useRef(null);
const controlRef = useRef(null);
const menuRef = useRef(null);
const optionsRef = useRef([]); const optionsRef = useRef([]);
const closeDropdown = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsOpen(false);
setIsClosing(false);
setFocusedIndex(-1);
}, 200); // Match animation duration
}, []);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) { if (
setIsOpen(false); containerRef.current &&
setFocusedIndex(-1); !containerRef.current.contains(event.target) &&
menuRef.current &&
!menuRef.current.contains(event.target)
) {
closeDropdown();
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, [closeDropdown]);
useEffect(() => {
if (isOpen && controlRef.current) {
const rect = controlRef.current.getBoundingClientRect();
setMenuPosition({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}
}, [isOpen]);
const onChange = useCallback( const onChange = useCallback(
(newValue) => { (newValue) => {
@@ -37,8 +64,7 @@ const Dropdown = memo((props) => {
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`); variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
setValue(newValue); setValue(newValue);
setIsOpen(false); closeDropdown();
setFocusedIndex(-1);
if (!props.noSetting) { if (!props.noSetting) {
localStorage.setItem(props.name, newValue); localStorage.setItem(props.name, newValue);
@@ -69,18 +95,23 @@ const Dropdown = memo((props) => {
case 'Enter': case 'Enter':
case ' ': case ' ':
e.preventDefault(); e.preventDefault();
setIsOpen(!isOpen); if (isOpen) {
closeDropdown();
} else {
setIsOpen(true);
}
break; break;
case 'Escape': case 'Escape':
setIsOpen(false); closeDropdown();
setFocusedIndex(-1);
break; break;
case 'ArrowDown': case 'ArrowDown':
e.preventDefault(); e.preventDefault();
if (!isOpen) { if (!isOpen) {
setIsOpen(true); setIsOpen(true);
} else { } else {
setFocusedIndex((prev) => (prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev)); setFocusedIndex((prev) =>
prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev,
);
} }
break; break;
case 'ArrowUp': case 'ArrowUp':
@@ -126,8 +157,16 @@ const Dropdown = memo((props) => {
</div> </div>
)} )}
<div <div
ref={controlRef}
className="dropdown-control" className="dropdown-control"
onClick={() => !props.disabled && setIsOpen(!isOpen)} onClick={() => {
if (props.disabled) return;
if (isOpen) {
closeDropdown();
} else {
setIsOpen(true);
}
}}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
role="button" role="button"
aria-haspopup="listbox" aria-haspopup="listbox"
@@ -138,8 +177,19 @@ const Dropdown = memo((props) => {
<span className="dropdown-value">{selectedItem?.text || value}</span> <span className="dropdown-value">{selectedItem?.text || value}</span>
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} /> <MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
</div> </div>
{isOpen && ( {(isOpen || isClosing) &&
<div className="dropdown-menu" role="listbox"> createPortal(
<div
ref={menuRef}
className={`dropdown-menu ${isClosing ? 'closing' : ''}`}
role="listbox"
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
width: `${menuPosition.width}px`,
}}
>
{props.items.map((item, index) => {props.items.map((item, index) =>
item !== null ? ( item !== null ? (
<div <div
@@ -157,7 +207,8 @@ const Dropdown = memo((props) => {
</div> </div>
) : null, ) : null,
)} )}
</div> </div>,
document.body,
)} )}
</div> </div>
); );

View File

@@ -31,6 +31,14 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
} }
return type; return type;
}); });
// Migration: Force authorDetails on for users upgrading from older versions
useState(() => {
if (localStorage.getItem('authorDetails') === null) {
localStorage.setItem('authorDetails', 'true');
}
});
const [customQuote, setCustomQuote] = useState(getCustom()); const [customQuote, setCustomQuote] = useState(getCustom());
const handleCustomQuote = (e, text, index, type) => { const handleCustomQuote = (e, text, index, type) => {