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:
- beta
tags:
- 'v*-beta.*'
- "v*-beta.*"
permissions:
contents: write
@@ -23,7 +23,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.1'
bun-version: "1.3.1"
- name: Install dependencies
run: bun install
@@ -45,7 +45,7 @@ jobs:
run: |
# Get the latest beta or production tag
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
echo "No previous tag found, using all commits"
COMMITS=$(git log --pretty=format:"- %s (%h)" HEAD)
@@ -53,13 +53,13 @@ jobs:
echo "Generating changelog from $PREVIOUS_TAG to HEAD"
COMMITS=$(git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..HEAD)
fi
# Create changelog with categorization
FEATURES=$(echo "$COMMITS" | grep -i "^- feat" || echo "")
FIXES=$(echo "$COMMITS" | grep -i "^- fix" || echo "")
CHORES=$(echo "$COMMITS" | grep -i "^- chore\|^- docs\|^- style\|^- refactor" || echo "")
OTHER=$(echo "$COMMITS" | grep -v -i "^- feat\|^- fix\|^- chore\|^- docs\|^- style\|^- refactor" || echo "")
{
echo "changelog<<EOF"
if [ -n "$FEATURES" ]; then
@@ -98,35 +98,35 @@ jobs:
- name: Create or Update GitHub Pre-Release
run: |
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.**
### Testing Instructions
1. Download the appropriate ZIP file below
2. For Chrome: Load as unpacked extension or install from [unlisted link](https://chromewebstore.google.com/detail/mue/bngmbednanpcfochchhgbkookpiaiaid) (dev team only)
3. For Firefox: Install via about:debugging → Load Temporary Add-on
4. Report issues at https://github.com/mue/mue/issues
${{ steps.changelog.outputs.changelog }}
### Installation Files
- **Chrome/Edge**: \`chrome-${{ steps.version.outputs.version }}.zip\`
- **Firefox**: \`firefox-${{ steps.version.outputs.version }}.zip\`
---
**🔗 Demo**: [demo.muetab.com](https://demo.muetab.com)
**📱 Beta Branch Demo**: [mue-git-beta-mue.vercel.app](https://mue-git-beta-mue.vercel.app)
EOF
)
if [ "${{ steps.check_release.outputs.exists }}" = "true" ]; then
echo "Updating existing release..."
gh release edit "v${{ steps.version.outputs.version }}" \
--notes "$RELEASE_NOTES" \
--prerelease
# Upload new files (will replace if they exist)
gh release upload "v${{ steps.version.outputs.version }}" \
"build/chrome-${{ steps.version.outputs.version }}.zip" \
@@ -137,7 +137,7 @@ jobs:
gh release create "v${{ steps.version.outputs.version }}" \
"build/chrome-${{ 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" \
--prerelease
fi

View File

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

View File

@@ -31,6 +31,14 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
}
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 handleCustomQuote = (e, text, index, type) => {