diff --git a/src/entries/content.ts b/src/entries/content.ts index e438129..68af7ee 100644 --- a/src/entries/content.ts +++ b/src/entries/content.ts @@ -1,5 +1,8 @@ import { setupContentUi } from '@/features/content-ui' -import { createSprintHeaderInjector, injectStatusBarSprintButton } from '@/features/sprint-injections' +import { + createSprintHeaderInjector, + injectStatusBarSprintButton, +} from '@/features/sprint-injections' import { injectTableEnhancementStyles, initDragAndDrop, @@ -49,7 +52,7 @@ export default defineContentScript({ projectContext.isOrg, ) - const injectSprintHeaders = createSprintHeaderInjector(projectContext, getFields) + const injectSprintHeaders = createSprintHeaderInjector(ctx, projectContext, getFields) const injectHierarchyChips = createHierarchyChipInjector(projectContext) const cleanupIssueDetail = setupIssueDetailInjector(projectContext) const cleanupTableEnhancements = setupTableEnhancements([ diff --git a/src/features/__tests__/bulk-edit-flyout.test.tsx b/src/features/__tests__/bulk-edit-flyout.test.tsx index ae561d8..d1a5736 100644 --- a/src/features/__tests__/bulk-edit-flyout.test.tsx +++ b/src/features/__tests__/bulk-edit-flyout.test.tsx @@ -218,6 +218,20 @@ describe('bulk-edit — partitionFieldList', () => { if (partition.mode !== 'search') return expect(partition.matches.filter((f) => f.id === relationshipFieldId('parent'))).toHaveLength(1) }) + + it('browse mode with no recents yields empty Recent section data', () => { + const partition = partitionFieldList({ + fields: baseFields, + recentIds: [], + query: '', + }) + expect(partition.mode).toBe('browse') + if (partition.mode !== 'browse') return + expect(partition.recent).toEqual([]) + expect(partition.issueProperties.map((f) => f.name)).toEqual(['Comment', 'Description']) + expect(partition.projectFields.map((f) => f.name)).toEqual(['Alpha', 'Status', 'Zeta']) + expect(partition.relationships.map((f) => f.name)).toEqual(['Blocked by', 'Blocking', 'Parent']) + }) }) describe('bulk-edit — buildRelationshipsPayload', () => { diff --git a/src/features/bulk-actions-bar.tsx b/src/features/bulk-actions-bar.tsx index e1de38e..f4de56f 100644 --- a/src/features/bulk-actions-bar.tsx +++ b/src/features/bulk-actions-bar.tsx @@ -13,6 +13,7 @@ import { exportSelectedToCSV } from '@/features/bulk-utils' import type { ProjectData, ProjectField } from '@/features/bulk-edit-utils' import type { ReorderOp } from '@/features/bulk-move-utils' import { ListCheckIcon, TagIcon, XIcon } from '@/ui/icons' +import { primerCss } from '@/lib/primer-css-helper' import { Z_OVERLAY } from '@/lib/z-index' import { BulkActionsMenu } from '@/features/bulk-actions-menu' import { BulkActionsModals } from '@/features/bulk-actions-modals' @@ -28,18 +29,7 @@ export type { ReorderOp } from '@/features/bulk-move-utils' const RECENT_FIELDS_CAP = 3 /** Shared chip styling for the three top-level inline actions on the bar. */ -const chipSx = { - boxShadow: 'none', - display: 'inline-flex', - alignItems: 'center', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, -} as const +const chipSx = primerCss.chipButton() interface Props { projectId: string @@ -72,27 +62,12 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P const [randomAssignOpen, setRandomAssignOpen] = useState(false) const [showCloseModal, setShowCloseModal] = useState(false) const [closeReason, setCloseReason] = useState<'COMPLETED' | 'NOT_PLANNED'>('COMPLETED') - const [showOpenModal, setShowOpenModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const [deleteItemTitles, setDeleteItemTitles] = useState([]) - const [showLockModal, setShowLockModal] = useState(false) - const [lockReason, setLockReason] = useState< - 'OFF_TOPIC' | 'TOO_HEATED' | 'RESOLVED' | 'SPAM' | null - >(null) - const [showPinModal, setShowPinModal] = useState(false) - const [showUnpinModal, setShowUnpinModal] = useState(false) const [showTransferModal, setShowTransferModal] = useState(false) const [showHelp, setShowHelp] = useState(false) const anyModalOpen = - showCloseModal || - showOpenModal || - showDeleteModal || - showLockModal || - showPinModal || - showUnpinModal || - showTransferModal || - showDupModal || - showHelp + showCloseModal || showDeleteModal || showTransferModal || showDupModal || showHelp const anyFlyoutOpen = editFieldsOpen || renameOpen || reorderOpen || randomAssignOpen || markOpen const resolvedProjectId = projectData?.id || projectId @@ -106,11 +81,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P setMenuOpen(false) setShowDupModal(false) setShowCloseModal(false) - setShowOpenModal(false) setShowDeleteModal(false) - setShowLockModal(false) - setShowPinModal(false) - setShowUnpinModal(false) setShowTransferModal(false) setShowHelp(false) setMarkOpen(false) @@ -163,11 +134,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P if (anyModalOpen || anyFlyoutOpen) { setShowDupModal(false) setShowCloseModal(false) - setShowOpenModal(false) setShowDeleteModal(false) - setShowLockModal(false) - setShowPinModal(false) - setShowUnpinModal(false) setShowTransferModal(false) setShowHelp(false) setEditFieldsOpen(false) @@ -246,7 +213,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P modifiers: { meta: true, shift: true }, context: 'Table Selection', label: 'Close Issues', - action: handleBulkClose, + action: () => handleBulkClose(), }) reg({ @@ -255,7 +222,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P modifiers: { meta: true, shift: true }, context: 'Table Selection', label: 'Reopen Issues', - action: handleBulkOpen, + action: () => runMarkVerb('reopen'), }) reg({ @@ -264,7 +231,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P modifiers: { meta: true, shift: true }, context: 'Table Selection', label: 'Lock Conversations', - action: handleLock, + action: () => runMarkVerb('lock'), }) reg({ @@ -273,7 +240,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P modifiers: { meta: true, shift: true }, context: 'Table Selection', label: 'Pin Issues', - action: handlePin, + action: () => runMarkVerb('pin'), }) reg({ @@ -493,66 +460,10 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P // §3 selection policy — Random Assign preserves selection. } - async function handleBulkClose() { - setMenuOpen(false) - if (!(await checkToken())) return - setShowCloseModal(true) - } - - function handleConfirmClose() { - const itemIds = selectionStore.getAll() - setShowCloseModal(false) - sendMessage('bulkClose', { itemIds, projectId: resolvedProjectId, reason: closeReason }) - selectionStore.clear() - } - - async function handleBulkOpen() { + async function runMarkVerb(verb: MarkVerb) { setMenuOpen(false) if (!(await checkToken())) return - setShowOpenModal(true) - } - - function handleConfirmOpen() { - const itemIds = selectionStore.getAll() - setShowOpenModal(false) - sendMessage('bulkOpen', { itemIds, projectId: resolvedProjectId }) - } - - async function handleLock() { - setMenuOpen(false) - if (!(await checkToken())) return - setShowLockModal(true) - } - - function handleConfirmLock() { - const itemIds = selectionStore.getAll() - setShowLockModal(false) - sendMessage('bulkLock', { itemIds, projectId: resolvedProjectId, lockReason }) - selectionStore.clear() - } - - async function handlePin() { - setMenuOpen(false) - if (!(await checkToken())) return - setShowPinModal(true) - } - - function handleConfirmPin() { - const itemIds = selectionStore.getAll() - setShowPinModal(false) - sendMessage('bulkPin', { itemIds, projectId: resolvedProjectId }) - } - - async function _handleUnpin() { - setMenuOpen(false) - if (!(await checkToken())) return - setShowUnpinModal(true) - } - - function handleConfirmUnpin() { - const itemIds = selectionStore.getAll() - setShowUnpinModal(false) - sendMessage('bulkUnpin', { itemIds, projectId: resolvedProjectId }) + handleMarkVerb(verb) } async function handleTransfer() { @@ -625,6 +536,20 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P // §3 selection policy — Reorder preserves selection. } + async function handleBulkClose() { + setMenuOpen(false) + if (!(await checkToken())) return + if (selectionStore.count() === 0) return + setShowCloseModal(true) + } + + function handleConfirmClose() { + const itemIds = selectionStore.getAll() + setShowCloseModal(false) + sendMessage('bulkClose', { itemIds, projectId: resolvedProjectId, reason: closeReason }) + selectionStore.clear() + } + function handleMarkVerb(verb: MarkVerb) { setMarkOpen(false) const itemIds = selectionStore.getAll() @@ -633,7 +558,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P sendMessage('bulkClose', { itemIds, projectId: resolvedProjectId, - reason: closeReason, + reason: 'COMPLETED', }) selectionStore.clear() return @@ -663,8 +588,8 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P A: { action: () => handleRandomAssign() }, M: { action: () => setMarkOpen((open) => !open) }, C: { action: () => handleBulkClose() }, - P: { action: () => handlePin() }, - L: { action: () => handleLock() }, + P: { action: () => runMarkVerb('pin') }, + L: { action: () => runMarkVerb('lock') }, T: { action: () => handleTransfer() }, D: { action: () => { @@ -689,30 +614,15 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P owner={owner} isOrg={isOrg} number={number} - firstRepoName={firstRepoName} showCloseModal={showCloseModal} closeReason={closeReason} onChangeCloseReason={setCloseReason} onCloseCloseModal={() => setShowCloseModal(false)} onConfirmClose={handleConfirmClose} - showOpenModal={showOpenModal} - onCloseOpenModal={() => setShowOpenModal(false)} - onConfirmOpen={handleConfirmOpen} showDeleteModal={showDeleteModal} deleteItemTitles={deleteItemTitles} onCloseDeleteModal={() => setShowDeleteModal(false)} onConfirmDelete={handleConfirmDelete} - showLockModal={showLockModal} - lockReason={lockReason} - onChangeLockReason={setLockReason} - onCloseLockModal={() => setShowLockModal(false)} - onConfirmLock={handleConfirmLock} - showPinModal={showPinModal} - onClosePinModal={() => setShowPinModal(false)} - onConfirmPin={handleConfirmPin} - showUnpinModal={showUnpinModal} - onCloseUnpinModal={() => setShowUnpinModal(false)} - onConfirmUnpin={handleConfirmUnpin} showTransferModal={showTransferModal} onCloseTransferModal={() => setShowTransferModal(false)} onConfirmTransfer={handleConfirmTransfer} diff --git a/src/features/bulk-actions-modals.tsx b/src/features/bulk-actions-modals.tsx index d5cff76..536f84c 100644 --- a/src/features/bulk-actions-modals.tsx +++ b/src/features/bulk-actions-modals.tsx @@ -1,17 +1,10 @@ -// Portals modal-keeper bulk-action modals — close / reopen / delete / lock / -// pin / unpin / transfer / duplicate plus the keyboard help overlay. The -// retired modal verbs (edit fields, rename, reorder, random assign) now -// live in their respective anchored flyouts mounted by the bar. +// Modal-keeper bulk-action modals — delete / transfer / duplicate plus keyboard help. import React, { Suspense } from 'react' import { selectionStore } from '@/lib/selection-store' import { BulkCloseModal } from '@/features/bulk-close-modal' import { BulkDeleteModal } from '@/features/bulk-delete-modal' -import { BulkOpenModal } from '@/features/bulk-open-modal' import { BulkTransferModal } from '@/features/bulk-transfer-modal' -import { BulkLockModal } from '@/features/bulk-lock-modal' -import { BulkPinModal } from '@/features/bulk-pin-modal' -import { BulkUnpinModal } from '@/features/bulk-unpin-modal' import { KeyboardHelpOverlay } from '@/features/keyboard-help-overlay' import { ModalLoadingFallback } from '@/features/bulk-actions-utils' @@ -25,40 +18,18 @@ interface Props { owner: string isOrg: boolean number: number - firstRepoName: string - // close showCloseModal: boolean closeReason: 'COMPLETED' | 'NOT_PLANNED' onChangeCloseReason: (r: 'COMPLETED' | 'NOT_PLANNED') => void onCloseCloseModal: () => void onConfirmClose: () => void - // open / delete - showOpenModal: boolean - onCloseOpenModal: () => void - onConfirmOpen: () => void showDeleteModal: boolean deleteItemTitles?: string[] onCloseDeleteModal: () => void onConfirmDelete: () => void - // lock - showLockModal: boolean - lockReason: 'OFF_TOPIC' | 'TOO_HEATED' | 'RESOLVED' | 'SPAM' | null - onChangeLockReason: (r: 'OFF_TOPIC' | 'TOO_HEATED' | 'RESOLVED' | 'SPAM' | null) => void - onCloseLockModal: () => void - onConfirmLock: () => void - - // pin - showPinModal: boolean - onClosePinModal: () => void - onConfirmPin: () => void - showUnpinModal: boolean - onCloseUnpinModal: () => void - onConfirmUnpin: () => void - - // transfer showTransferModal: boolean onCloseTransferModal: () => void onConfirmTransfer: ( @@ -67,11 +38,9 @@ interface Props { eligibleItemIds?: readonly string[], ) => void - // duplicate showDupModal: boolean onCloseDupModal: () => void - // help showHelp: boolean onCloseHelp: () => void } @@ -89,14 +58,6 @@ export function BulkActionsModals(props: Props) { /> )} - {props.showOpenModal && ( - - )} - {props.showDeleteModal && ( )} - {props.showLockModal && ( - - )} - - {props.showPinModal && ( - - )} - - {props.showUnpinModal && ( - - )} - {props.showTransferModal && ( - + } onClose={onClose} /> - - {/* Body */} - - - Choose how to close {count} issue{count !== 1 ? 's' : ''}: - - {REASONS.map(({ id, label, sublabel }) => { - const isSelected = closeReason === id - const SelectionIcon = isSelected ? CheckCircleFillIcon : CircleIcon - return ( - - onChangeReason(id)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 3, - width: '100%', - p: 3, - border: '1px solid', - borderColor: isSelected ? 'accent.emphasis' : 'border.default', - borderRadius: 2, - bg: isSelected ? 'accent.subtle' : 'transparent', - cursor: 'pointer', - textAlign: 'left', - transition: 'all 150ms ease', - '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, - }} - > - - - - - - {label} - - - {sublabel} - - - - - ) - })} - - - {/* Footer */} - + } + footer={ + <> - + + } + > + + + Choose how to close {count} issue{count !== 1 ? 's' : ''}: + + {REASONS.map(({ id, label, sublabel }) => { + const isSelected = closeReason === id + const SelectionIcon = isSelected ? CheckCircleFillIcon : CircleIcon + return ( + + onChangeReason(id)} + data-testid={`rgp-bulk-close-reason-${id.toLowerCase()}`} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 3, + width: '100%', + p: 3, + border: '1px solid', + borderColor: isSelected ? 'accent.emphasis' : 'border.default', + borderRadius: 2, + bg: isSelected ? 'accent.subtle' : 'transparent', + cursor: 'pointer', + textAlign: 'left', + boxShadow: 'none', + transition: 'all 150ms ease', + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + }} + > + + + + + + {label} + + + {sublabel} + + + + + ) + })} - + ) } diff --git a/src/features/bulk-delete-modal.tsx b/src/features/bulk-delete-modal.tsx index 4ecf640..c10feb3 100644 --- a/src/features/bulk-delete-modal.tsx +++ b/src/features/bulk-delete-modal.tsx @@ -3,7 +3,8 @@ import { Box, Button, Flash, FormControl, Text, TextInput } from '@primer/react' import { TrashIcon } from '@/ui/icons' import { ModalStepHeader } from '@/ui/modal-step-header' -import { Z_MODAL } from '@/lib/z-index' +import { ModalShell } from '@/ui/modal-shell' +import { primerCss } from '@/lib/primer-css-helper' interface Props { count: number @@ -32,8 +33,6 @@ export function BulkDeleteModal({ count, itemTitles, onClose, onConfirm }: Props const inputRef = useRef(null) useEffect(() => { - // Below threshold: keep Cancel as default focus (cancel-focused confirm). - // At/above threshold: focus the typed-confirm input so the user can comply. const target = needsTypedConfirm ? inputRef.current : cancelRef.current target?.focus() }, [needsTypedConfirm]) @@ -49,121 +48,21 @@ export function BulkDeleteModal({ count, itemTitles, onClose, onConfirm }: Props onConfirm() } - return ( - - - } - onClose={onClose} - /> - - - - Removes {count} item{count !== 1 ? 's' : ''} from the project board. Underlying issues - are not deleted. - - - {titles.length > 0 && ( - - - {visibleTitles.map((title, i) => ( - - {title} - - ))} - - {hiddenCount > 0 && ( - - )} - - )} + const title = `Delete ${count} item${count !== 1 ? 's' : ''}?` - {needsTypedConfirm && ( - - - Type {DELETE_TYPED_CONFIRM_PHRASE} to confirm - - setConfirmInput(e.currentTarget.value)} - placeholder={`Type "${DELETE_TYPED_CONFIRM_PHRASE}" to confirm`} - aria-label={`Type "${DELETE_TYPED_CONFIRM_PHRASE}" to confirm deletion`} - data-testid="rgp-bulk-delete-typed-confirm" - sx={{ width: '100%' }} - /> - - )} - - - This cannot be undone. - - - - Requires project admin access. - - - - + return ( + } onClose={onClose} />} + footer={ + <> - + + } + > + + + Removes {count} item{count !== 1 ? 's' : ''} from the project board. Underlying issues are + not deleted. + + + {titles.length > 0 && ( + + + {visibleTitles.map((title, i) => ( + + {title} + + ))} + + {hiddenCount > 0 && ( + + )} + + )} + + {needsTypedConfirm && ( + + + Type {DELETE_TYPED_CONFIRM_PHRASE} to confirm + + setConfirmInput(e.currentTarget.value)} + placeholder={`Type "${DELETE_TYPED_CONFIRM_PHRASE}" to confirm`} + aria-label={`Type "${DELETE_TYPED_CONFIRM_PHRASE}" to confirm deletion`} + data-testid="rgp-bulk-delete-typed-confirm" + sx={{ width: '100%' }} + /> + + )} + + + This cannot be undone. + + + + Requires project admin access. + - + ) } diff --git a/src/features/bulk-edit-flyout.tsx b/src/features/bulk-edit-flyout.tsx index fa2772d..e783512 100644 --- a/src/features/bulk-edit-flyout.tsx +++ b/src/features/bulk-edit-flyout.tsx @@ -260,7 +260,14 @@ export function BulkEditFlyout({ {partition.mode === 'search' ? ( {partition.matches.length === 0 ? ( - No fields match. + + {query.trim() + ? `No fields match "${query.trim()}". Try a different search.` + : 'No fields match.'} + ) : ( partition.matches.map((field) => ( @@ -269,6 +276,21 @@ export function BulkEditFlyout({ ) : ( <> + {partition.recent.length === 0 && ( + + Fields you edit appear under Recent for quick access. + + )} {partition.recent.length > 0 && ( Recent @@ -281,6 +303,7 @@ export function BulkEditFlyout({ )} {partition.issueProperties.length > 0 && ( + {partition.recent.length > 0 && } Issue properties {partition.issueProperties.map((field) => ( @@ -291,6 +314,9 @@ export function BulkEditFlyout({ )} {partition.projectFields.length > 0 && ( + {(partition.recent.length > 0 || partition.issueProperties.length > 0) && ( + + )} Project fields {partition.projectFields.map((field) => ( @@ -301,6 +327,9 @@ export function BulkEditFlyout({ )} {partition.relationships.length > 0 && ( + {(partition.recent.length > 0 || + partition.issueProperties.length > 0 || + partition.projectFields.length > 0) && } Relationships {partition.relationships.map((field) => ( diff --git a/src/features/bulk-edit-relationship-pane.tsx b/src/features/bulk-edit-relationship-pane.tsx index 6f5a820..5736069 100644 --- a/src/features/bulk-edit-relationship-pane.tsx +++ b/src/features/bulk-edit-relationship-pane.tsx @@ -202,9 +202,9 @@ export const BulkEditRelationshipPane = forwardRef< )} {prSkipCount > 0 && validationErrors.length === 0 && ( - - {prSkipCount} pull request{prSkipCount === 1 ? '' : 's'} will be skipped — relationships - apply to issues only. + + {prSkipCount} pull request{prSkipCount === 1 ? '' : 's'} in the selection will be skipped. + Relationship updates apply to issues only; you can proceed with the issue subset. )} diff --git a/src/features/bulk-lock-modal.tsx b/src/features/bulk-lock-modal.tsx deleted file mode 100644 index c5ffe9a..0000000 --- a/src/features/bulk-lock-modal.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import React, { useEffect } from 'react' -import Tippy from '@/ui/tooltip' -import { CheckCircleFillIcon, CircleIcon } from '@primer/octicons-react' -import { Box, Button, Text } from '@primer/react' -import { LockIcon } from '@/ui/icons' -import { ModalStepHeader } from '@/ui/modal-step-header' -import { ensureTippyCss } from '@/lib/tippy-utils' -import { Z_MODAL, Z_TOOLTIP } from '@/lib/z-index' - -type LockReason = 'OFF_TOPIC' | 'TOO_HEATED' | 'RESOLVED' | 'SPAM' | null - -const REASONS: { id: LockReason; label: string; sublabel: string }[] = [ - { id: null, label: 'No reason', sublabel: 'Lock without specifying a reason' }, - { id: 'OFF_TOPIC', label: 'Off-topic', sublabel: 'Conversation went off-topic' }, - { id: 'TOO_HEATED', label: 'Too heated', sublabel: 'Discussion became too heated' }, - { id: 'RESOLVED', label: 'Resolved', sublabel: 'Conversation is resolved' }, - { id: 'SPAM', label: 'Spam', sublabel: 'Contains spam' }, -] - -interface Props { - count: number - lockReason: LockReason - onChangeReason: (r: LockReason) => void - onClose: () => void - onConfirm: () => void -} - -export function BulkLockModal({ count, lockReason, onChangeReason, onClose, onConfirm }: Props) { - useEffect(() => { - ensureTippyCss() - }, []) - - return ( - - - } - onClose={onClose} - /> - - - - Lock conversations on {count} issue{count !== 1 ? 's' : ''}: - - {REASONS.map(({ id, label, sublabel }) => { - const isSelected = lockReason === id - const SelectionIcon = isSelected ? CheckCircleFillIcon : CircleIcon - return ( - - onChangeReason(id)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 3, - width: '100%', - p: 3, - border: '1px solid', - borderColor: isSelected ? 'accent.emphasis' : 'border.default', - borderRadius: 2, - bg: isSelected ? 'accent.subtle' : 'transparent', - cursor: 'pointer', - textAlign: 'left', - transition: 'all 150ms ease', - '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, - }} - > - - - - - - {label} - - - {sublabel} - - - - - ) - })} - - - - - - - - - ) -} diff --git a/src/features/bulk-open-modal.tsx b/src/features/bulk-open-modal.tsx deleted file mode 100644 index ff67ca8..0000000 --- a/src/features/bulk-open-modal.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import { Text } from '@primer/react' -import { IssueReopenedIcon } from '@/ui/icons' -import { createModal } from '@/lib/modal-factory' - -export const BulkOpenModal = createModal<{ count: number; onConfirm: () => void }>({ - name: 'Reopen Issues', - icon: , - renderContent: ({ count }) => ( - <> - - Reopen {count} issue{count !== 1 ? 's' : ''}? - - - Only closed issues will be affected. Open issues are left unchanged. - - - ), - onSubmit: async ({ onConfirm }) => { - onConfirm() - }, - confirmLabel: 'Reopen Issues', -}) diff --git a/src/features/bulk-pin-modal.tsx b/src/features/bulk-pin-modal.tsx deleted file mode 100644 index 6e25fb8..0000000 --- a/src/features/bulk-pin-modal.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import { Text } from '@primer/react' -import { PinIcon } from '@/ui/icons' -import { createModal } from '@/lib/modal-factory' - -export const BulkPinModal = createModal<{ count: number; onConfirm: () => void }>({ - name: 'Pin Issues', - icon: , - renderContent: ({ count }) => ( - <> - - Pin {count} issue{count !== 1 ? 's' : ''} to their repository? - - - GitHub allows a maximum of 3 pinned issues per repository. - - - ), - onSubmit: async ({ onConfirm }) => { - onConfirm() - }, - confirmLabel: 'Pin Issues', -}) diff --git a/src/features/bulk-unpin-modal.tsx b/src/features/bulk-unpin-modal.tsx deleted file mode 100644 index 49a161b..0000000 --- a/src/features/bulk-unpin-modal.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' -import { Text } from '@primer/react' -import { UnpinIcon } from '@/ui/icons' -import { createModal } from '@/lib/modal-factory' - -export const BulkUnpinModal = createModal<{ count: number; onConfirm: () => void }>({ - name: 'Unpin Issues', - icon: , - renderContent: ({ count }) => ( - - Unpin {count} issue{count !== 1 ? 's' : ''} from their repository? - - ), - onSubmit: async ({ onConfirm }) => { - onConfirm() - }, - confirmLabel: 'Unpin Issues', -}) diff --git a/src/features/sprint-injections.tsx b/src/features/sprint-injections.tsx index bce5692..d5f6a97 100644 --- a/src/features/sprint-injections.tsx +++ b/src/features/sprint-injections.tsx @@ -1,9 +1,8 @@ import React from 'react' -import ReactDOM from 'react-dom/client' -import { StyleSheetManager } from 'styled-components' -import isPropValid from '@emotion/is-prop-valid' +import type { ContentScriptContext } from 'wxt/utils/content-script-context' import tippy, { type Instance } from 'tippy.js' import { SprintGroupHeaderWidget } from '@/features/sprint-table-widget' +import { createLightDomUi, type FeatureUi } from '@/lib/shadow-ui-factory' import { sprintPanelStore } from '@/lib/sprint-store' import { ensureTippyCss, getTippyDelayValue } from '@/lib/tippy-utils' import type { ProjectContext, ProjectData } from '@/lib/github-project' @@ -20,9 +19,17 @@ let showTooltipTimeout: number | null = null let hideTooltipTimeout: number | null = null export function createSprintHeaderInjector( + ctx: ContentScriptContext, projectContext: ProjectContext, getFields: () => Promise, ) { + const mountedWidgets = new Map() + + ctx.onInvalidated(() => { + for (const ui of mountedWidgets.values()) ui.destroy() + mountedWidgets.clear() + }) + return () => { const currentLabels = document.querySelectorAll( 'span[class*="iteration-group-header-label-module__CurrentIterationLabel"]', @@ -38,8 +45,10 @@ export function createSprintHeaderInjector( 'display:inline-flex;align-items:center;gap:4px;margin-left:4px;vertical-align:middle' label.after(hostSpan) - ReactDOM.createRoot(hostSpan).render( - + const ui = createLightDomUi(ctx, { + name: 'sprint-header-widget', + anchor: hostSpan, + component: ( - , - ) + ), + }) + ui.mount() + mountedWidgets.set(hostSpan, ui) + } + + for (const [host, ui] of mountedWidgets) { + if (!host.isConnected) { + ui.destroy() + mountedWidgets.delete(host) + } } } } @@ -119,10 +137,15 @@ function injectSprintButtonStyles() { border: none !important; background: transparent !important; box-shadow: none !important; - transition: background-color 120ms ease !important; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) !important; } [data-rgp-sprint-btn] button:hover { - background-color: var(--color-canvas-subtle, rgba(175,184,193,.2)) !important; + background-color: var(--color-canvas-subtle) !important; + } + @media (prefers-reduced-motion: reduce) { + [data-rgp-sprint-btn] button { + transition: none !important; + } } ` document.head.appendChild(style) diff --git a/src/features/sprint-modal.tsx b/src/features/sprint-modal.tsx index 827b2e7..21f5c6a 100644 --- a/src/features/sprint-modal.tsx +++ b/src/features/sprint-modal.tsx @@ -2,7 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react' import Tippy from '@/ui/tooltip' import { ensureTippyCss } from '@/lib/tippy-utils' import { Box, Button, Flash, Heading, Label, Spinner, Text } from '@primer/react' -import { Z_MODAL, Z_TOOLTIP } from '@/lib/z-index' +import { ModalShell } from '@/ui/modal-shell' +import { Z_TOOLTIP } from '@/lib/z-index' import { GearIcon, SlidersIcon, SprintIcon, XIcon } from '@/ui/icons' import { ModalStepHeader } from '@/ui/modal-step-header' import { sendMessage } from '@/lib/messages' @@ -72,7 +73,8 @@ export function SprintPanel({ }, [projectId, owner, number, isOrg]) useEffect(() => { - fetchStatus() + // eslint-disable-next-line react-hooks/set-state-in-effect -- load sprint status when panel deps change + void fetchStatus() }, [fetchStatus]) useEffect(() => { @@ -114,281 +116,199 @@ export function SprintPanel({ const currentSprint = status?.activeSprint ?? status?.acknowledgedSprint ?? null - return ( + const sprintHeader = showSettings ? ( + } + onBack={() => setShowSettings(false)} + onClose={onClose} + /> + ) : confirmingEnd && status?.activeSprint ? ( + } + subtitle={`${status.activeSprint.title} · ${fmt(status.activeSprint.startDate)} – ${fmt(status.activeSprint.endDate)}`} + onBack={() => setConfirmingEnd(false)} + onClose={onClose} + /> + ) : ( - e.stopPropagation()} - onKeyDown={(e: React.KeyboardEvent) => { - e.stopPropagation() - if (e.key === 'Escape') onClose() - }} - onKeyUp={(e: React.KeyboardEvent) => e.stopPropagation()} - > - {/* header — switches between ModalStepHeader (sub-views) and custom (main) */} - {showSettings ? ( - } - onBack={() => setShowSettings(false)} - onClose={onClose} - /> - ) : confirmingEnd && status?.activeSprint ? ( - } - subtitle={`${status.activeSprint.title} · ${fmt(status.activeSprint.startDate)} – ${fmt(status.activeSprint.endDate)}`} - onBack={() => setConfirmingEnd(false)} - onClose={onClose} - /> - ) : ( - + + + Sprint + + + + + - - - - - - - )} + + + + + + + + + ) - {/* body */} - - {showSettings ? ( - { - setShowSettings(false) - await fetchStatus() - }} - /> - ) : confirmingEnd && status?.activeSprint && status?.settings ? ( - { - setConfirmingEnd(false) - await fetchStatus() - }} - /> - ) : ( - <> - {state === 'loading' && ( - - - - )} + return ( + + + {showSettings ? ( + { + setShowSettings(false) + await fetchStatus() + }} + /> + ) : confirmingEnd && status?.activeSprint && status?.settings ? ( + { + setConfirmingEnd(false) + await fetchStatus() + }} + /> + ) : ( + <> + {state === 'loading' && ( + + + + )} - {state === 'error' && ( - - {error ?? 'Failed to load sprint status.'} - - )} + {state === 'error' && ( + + {error ?? 'Failed to load sprint status.'} + + )} - {state === 'not-configured' && ( - - - Sprint tracking isn't set up for this project yet. - - - Each GitHub project has its own sprint configuration. - - + + Sprint tracking isn't set up for this project yet. + + + Each GitHub project has its own sprint configuration. + + + - - - )} - - {state === 'no-active' && ( - - - {status?.settings?.sprintFieldName ?? 'Sprint'} - - No active sprint - {status?.nearestUpcoming && ( - <> - - Next: {status.nearestUpcoming.title} — starts{' '} - {fmt(status.nearestUpcoming.startDate)} - - - - - - )} - - )} + Set Up Sprint + + + + )} - {state === 'acknowledged' && currentSprint && ( - - - - {currentSprint.title} + {state === 'no-active' && ( + + + {status?.settings?.sprintFieldName ?? 'Sprint'} + + No active sprint + {status?.nearestUpcoming && ( + <> + + Next: {status.nearestUpcoming.title} — starts{' '} + {fmt(status.nearestUpcoming.startDate)} - - - - {fmt(currentSprint.startDate)} – {fmt(currentSprint.endDate)} - - - Filter{' '} - - {SPRINT_FILTER} - {' '} - is applied automatically on save. - - - + + )} + + )} + + {state === 'acknowledged' && currentSprint && ( + + + + {currentSprint.title} + + + + + {fmt(currentSprint.startDate)} – {fmt(currentSprint.endDate)} + + + Filter{' '} + + {SPRINT_FILTER} + {' '} + is applied automatically on save. + + + + + - )} + + )} - {state === 'active' && status?.activeSprint && status?.settings && ( - setConfirmingEnd(true)} - onOpenSettings={() => setShowSettings(true)} - /> - )} - - )} - + {state === 'active' && status?.activeSprint && status?.settings && ( + setConfirmingEnd(true)} + onOpenSettings={() => setShowSettings(true)} + /> + )} + + )} - + ) } diff --git a/src/features/sprint-progress-view.tsx b/src/features/sprint-progress-view.tsx index 4e48e98..b6134de 100644 --- a/src/features/sprint-progress-view.tsx +++ b/src/features/sprint-progress-view.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react' -import { Avatar, Box, Button, Flash, Label, Spinner, Text } from '@primer/react' +import { Avatar, Box, Button, Flash, Label, ProgressBar, Spinner, Text } from '@primer/react' +import { primerCss } from '@/lib/primer-css-helper' import Tippy from '@/ui/tooltip' import { Z_TOOLTIP } from '@/lib/z-index' import { sendMessage, type SprintInfo, type SprintProgressData } from '@/lib/messages' @@ -38,10 +39,10 @@ interface MetricBarProps { label: string done: number total: number - color?: string + barSx?: Record } -function MetricBar({ label, done, total, color = 'accent.emphasis' }: MetricBarProps) { +function MetricBar({ label, done, total, barSx }: MetricBarProps) { const percent = pct(done, total) return ( @@ -51,18 +52,7 @@ function MetricBar({ label, done, total, color = 'accent.emphasis' }: MetricBarP {done} of {total} ({percent}%) - - - + ) } @@ -101,6 +91,7 @@ export function SprintProgressView({ useEffect(() => { let cancelled = false + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset fetch state when sprint inputs change setLoadState('loading') setErrorMsg(null) sendMessage('getSprintProgress', { @@ -177,7 +168,10 @@ export function SprintProgressView({ label={progress.pointsFieldName || 'Points'} done={progress.donePoints} total={progress.totalPoints} - color="success.emphasis" + barSx={{ + '& > span': { bg: 'success.emphasis' }, + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + }} /> )} @@ -312,37 +306,13 @@ export function SprintProgressView({ variant="invisible" size="small" onClick={onOpenSettings} - sx={{ - color: 'fg.muted', - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, - }} + sx={{ ...primerCss.buttonMotion(), color: 'fg.muted' }} > Settings - diff --git a/src/features/sprint-settings-utils.test.ts b/src/features/sprint-settings-utils.test.ts new file mode 100644 index 0000000..c05f2e5 --- /dev/null +++ b/src/features/sprint-settings-utils.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' + +import { formatAdvancedSettingsHint, hasAdvancedSettings } from '@/features/sprint-settings-utils' +import type { SprintSettings } from '@/lib/storage' + +function settings(over: Partial = {}): SprintSettings { + return { + sprintFieldId: 'sf', + sprintFieldName: 'Sprint', + doneFieldId: 'df', + doneFieldName: 'Status', + doneFieldType: 'SINGLE_SELECT', + doneOptionId: 'opt-done', + doneOptionName: 'Done', + ...over, + } +} + +describe('hasAdvancedSettings', () => { + it('returns false for null', () => { + expect(hasAdvancedSettings(null)).toBe(false) + }) + + it('returns false when only required fields are set', () => { + expect(hasAdvancedSettings(settings())).toBe(false) + }) + + it('returns true when a not-started option is set', () => { + expect(hasAdvancedSettings(settings({ notStartedOptionId: 'opt-todo' }))).toBe(true) + }) + + it('returns true when exclusion conditions exist', () => { + expect( + hasAdvancedSettings( + settings({ + excludeConditions: [ + { + fieldId: 'f1', + fieldName: 'F1', + fieldType: 'SINGLE_SELECT', + optionId: 'o1', + optionName: 'O1', + }, + ], + }), + ), + ).toBe(true) + }) + + it('returns true when a points field is set', () => { + expect(hasAdvancedSettings(settings({ pointsFieldId: 'pf' }))).toBe(true) + }) + + it('returns false for an empty exclusion array', () => { + expect(hasAdvancedSettings(settings({ excludeConditions: [] }))).toBe(false) + }) +}) + +describe('formatAdvancedSettingsHint', () => { + it('returns null when nothing is configured', () => { + expect(formatAdvancedSettingsHint({ excludeCount: 0 })).toBeNull() + }) + + it('formats a single not-started option', () => { + expect(formatAdvancedSettingsHint({ notStartedOptionName: 'Todo', excludeCount: 0 })).toBe( + 'Not started: Todo', + ) + }) + + it('singularizes one exclusion', () => { + expect(formatAdvancedSettingsHint({ excludeCount: 1 })).toBe('1 exclusion') + }) + + it('pluralizes multiple exclusions', () => { + expect(formatAdvancedSettingsHint({ excludeCount: 3 })).toBe('3 exclusions') + }) + + it('joins all configured parts with a middot', () => { + expect( + formatAdvancedSettingsHint({ + notStartedOptionName: 'Todo', + excludeCount: 2, + pointsFieldName: 'Estimate', + }), + ).toBe('Not started: Todo · 2 exclusions · Estimate') + }) +}) diff --git a/src/features/sprint-settings-utils.ts b/src/features/sprint-settings-utils.ts new file mode 100644 index 0000000..65b41cb --- /dev/null +++ b/src/features/sprint-settings-utils.ts @@ -0,0 +1,31 @@ +// pure helpers for the sprint settings advanced disclosure (no React, unit-tested). + +import type { SprintSettings } from '@/lib/storage' + +/** Whether saved settings already configure any advanced (optional) option. */ +export function hasAdvancedSettings(settings: SprintSettings | null): boolean { + if (!settings) return false + return Boolean( + settings.notStartedOptionId || + (settings.excludeConditions?.length ?? 0) > 0 || + settings.pointsFieldId, + ) +} + +/** + * Short muted summary of configured advanced options for the collapsed disclosure. + * Returns null when nothing advanced is set. + */ +export function formatAdvancedSettingsHint(args: { + notStartedOptionName?: string + excludeCount: number + pointsFieldName?: string +}): string | null { + const parts: string[] = [] + if (args.notStartedOptionName) parts.push(`Not started: ${args.notStartedOptionName}`) + if (args.excludeCount > 0) { + parts.push(`${args.excludeCount} exclusion${args.excludeCount === 1 ? '' : 's'}`) + } + if (args.pointsFieldName) parts.push(args.pointsFieldName) + return parts.length > 0 ? parts.join(' · ') : null +} diff --git a/src/features/sprint-settings-view.tsx b/src/features/sprint-settings-view.tsx index e90ca66..5e458af 100644 --- a/src/features/sprint-settings-view.tsx +++ b/src/features/sprint-settings-view.tsx @@ -5,8 +5,10 @@ import Tippy from '@/ui/tooltip' import { Box, Button, + Details, Flash, FormControl, + Label, Radio, RadioGroup, Select, @@ -14,8 +16,10 @@ import { Text, TextInput, } from '@primer/react' +import { primerCss } from '@/lib/primer-css-helper' import { Z_TOOLTIP } from '@/lib/z-index' import { + ChevronDownIcon, FilterIcon, IssueClosedIcon, IterationsIcon, @@ -28,6 +32,7 @@ import { import { sendMessage } from '@/lib/messages' import type { ExcludeCondition, SprintSettings } from '@/lib/storage' import { injectSprintFilter, SPRINT_FILTER, type FieldNode } from '@/lib/sprint-utils' +import { formatAdvancedSettingsHint, hasAdvancedSettings } from '@/features/sprint-settings-utils' import type { ProjectData } from '@/lib/github-project' const labelIconBoxSx = { @@ -37,6 +42,38 @@ const labelIconBoxSx = { flexShrink: 0, } as const +const fieldLabelSx = { + display: 'flex', + alignItems: 'center', + gap: 2, + fontSize: 1, + fontWeight: 'semibold', + color: 'fg.muted', +} as const + +type FieldLabelIcon = React.ComponentType<{ size?: number }> + +/** Shared icon + text content for FormControl/RadioGroup labels (keeps ` + +
setAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)} + data-testid="rgp-sprint-advanced-settings" + sx={{ + ...primerCss.flatPanel(), + p: 0, + boxShadow: 'none', + '& > summary': { display: 'flex', alignItems: 'center', gap: 2, - fontSize: 1, + px: 3, + py: 2, + listStyle: 'none', + cursor: 'pointer', fontWeight: 'semibold', + color: 'fg.default', + borderRadius: 2, + transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', + '&:hover': { bg: 'canvas.subtle' }, + '&:focus-visible': { outline: '2px solid', outlineColor: 'accent.emphasis' }, + }, + '& > summary::-webkit-details-marker': { display: 'none' }, + '&[open] > summary .rgp-advanced-chevron': { transform: 'rotate(180deg)' }, + '& .rgp-advanced-chevron': { + display: 'inline-flex', color: 'fg.muted', - }} - > + transition: 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)', + }, + '@media (prefers-reduced-motion: reduce)': { + '& > summary': { transition: 'none' }, + '& .rgp-advanced-chevron': { transition: 'none' }, + }, + }} + > + + + + - + - Done condition field - - - + {/* not started option — items in this state are excluded from "done" count */} + {selectedDoneField?.dataType === 'SINGLE_SELECT' && selectedDoneField.options && ( + + + + + + + When set, all statuses except this one count toward sprint progress — not just the + done option. + + + )} - {/* radio options for SINGLE_SELECT done field */} - {selectedDoneField?.dataType === 'SINGLE_SELECT' && selectedDoneField.options && ( - setDoneOptionId(v ?? '')} sx={{ pl: 2 }}> - - - + {/* exclude from migration */} + + + - Done option - - {selectedDoneField.options.map((opt) => ( + {excludeConditions.length === 0 && ( + + No exclusion rules yet. Matching items are skipped during sprint migration. + + )} + {excludeConditions.map((cond, idx) => { + const selectedField = excludableFields.find((f) => f.id === cond.fieldId) + return ( + + + + + + {selectedField?.dataType === 'SINGLE_SELECT' && selectedField.options && ( + + + + )} + + {selectedField?.dataType === 'TEXT' && ( + + + updateExcludeCondition(idx, { optionName: e.target.value, optionId: '' }) + } + /> + + )} + + + + + + ) + })} - - - setDoneOptionId(opt.id)} - /> - {opt.name} - - + - ))} - - )} - - {/* not started option — items in this state are excluded from "done" count */} - {selectedDoneField?.dataType === 'SINGLE_SELECT' && selectedDoneField.options && ( - - - - - - Not started option{' '} - (optional) - - - - When set, all statuses except this one count toward sprint progress — not just the done - option. - - - )} - - {/* text input for TEXT done field */} - {selectedDoneField?.dataType === 'TEXT' && ( - - - - - - Done value - - setDoneTextValue(e.target.value)} - /> - - )} - - {/* exclude from migration */} - - - - - - Exclude from migration{' '} - (optional) - - - {excludeConditions.map((cond, idx) => { - const selectedField = excludableFields.find((f) => f.id === cond.fieldId) - return ( - - - - - - {selectedField?.dataType === 'SINGLE_SELECT' && selectedField.options && ( - - - - )} - {selectedField?.dataType === 'TEXT' && ( - - - updateExcludeCondition(idx, { optionName: e.target.value, optionId: '' }) - } - /> - - )} - - 0 && ( + + + + + setPointsFieldId(e.target.value)} block> - None - {numberFields.map((f) => ( - - {f.name} - - ))} - - - Used to display point totals in the sprint progress view. - - - )} + None + {numberFields.map((f) => ( + + {f.name} + + ))} + + + Used to display point totals in the sprint progress view. + + + )} + +
{/* footer */} @@ -551,16 +576,7 @@ export function SettingsView({ variant="primary" disabled={!canSave || saving} onClick={handleSave} - sx={{ - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, - }} + sx={primerCss.buttonMotion()} > {saving ? : 'Save Settings →'} diff --git a/src/features/sprint-table-widget.tsx b/src/features/sprint-table-widget.tsx index f92d1c2..8731f7b 100644 --- a/src/features/sprint-table-widget.tsx +++ b/src/features/sprint-table-widget.tsx @@ -7,6 +7,7 @@ import { sendMessage } from '@/lib/messages' import type { SprintInfo } from '@/lib/messages' import type { SprintSettings } from '@/lib/storage' import type { ProjectData } from '@/lib/github-project' +import { primerCss } from '@/lib/primer-css-helper' import { sprintConfirmEndStore, sprintPanelStore } from '@/lib/sprint-store' interface Props { @@ -19,6 +20,24 @@ interface Props { type WidgetState = 'loading' | 'not-configured' | 'no-active' | 'acknowledged' | 'active' | 'error' +const accentTextButtonSx = { + ...primerCss.buttonMotion(), + color: 'accent.fg', + fontWeight: 500, + fontSize: 0, + px: '8px', + py: '3px', + height: 'auto', + lineHeight: 1.5, + border: 'none', + borderRadius: 2, + '&:hover:not(:disabled)': { transform: 'translateY(-1px)', bg: 'accent.subtle' }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + '&:hover:not(:disabled)': { transform: 'none' }, + }, +} + interface SprintStatus { hasSettings: boolean activeSprint: SprintInfo | null @@ -28,7 +47,13 @@ interface SprintStatus { settings: SprintSettings | null } -export function SprintGroupHeaderWidget({ projectId, owner, isOrg, number, getFields }: Props) { +export function SprintGroupHeaderWidget({ + projectId, + owner, + isOrg, + number, + getFields: _getFields, +}: Props) { ensureTippyCss() const [state, setState] = useState('loading') const [status, setStatus] = useState(null) @@ -57,7 +82,8 @@ export function SprintGroupHeaderWidget({ projectId, owner, isOrg, number, getFi }, [projectId, owner, number, isOrg]) useEffect(() => { - fetchStatus() + // eslint-disable-next-line react-hooks/set-state-in-effect -- load widget status when project context changes + void fetchStatus() }, [fetchStatus]) const handleAcknowledge = async () => { @@ -138,25 +164,7 @@ export function SprintGroupHeaderWidget({ projectId, owner, isOrg, number, getFi variant="invisible" onClick={handleAcknowledge} disabled={acknowledging} - sx={{ - color: 'accent.fg', - fontWeight: 500, - fontSize: 0, - px: '8px', - py: '3px', - height: 'auto', - lineHeight: 1.5, - border: 'none', - borderRadius: 2, - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)', bg: 'accent.subtle' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, - }} + sx={accentTextButtonSx} > {acknowledging ? : 'Start Sprint'} diff --git a/src/lib/modal-factory.tsx b/src/lib/modal-factory.tsx index 04f59f7..7ef77e5 100644 --- a/src/lib/modal-factory.tsx +++ b/src/lib/modal-factory.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Box, Button, Flash, Spinner } from '@primer/react' +import { Button, Flash, Spinner } from '@primer/react' import { ModalStepHeader } from '@/ui/modal-step-header' +import { ModalShell } from '@/ui/modal-shell' import { primerCss } from '@/lib/primer-css-helper' import { toastStore } from '@/lib/toast-store' import { ensureTippyCss } from '@/lib/tippy-utils' @@ -67,14 +68,6 @@ export function createModal(opts: CreateModalOptions): React.FC { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') handleRequestClose() - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [handleRequestClose]) - const handleSubmit = async () => { if (submitInFlightRef.current) return submitInFlightRef.current = true @@ -103,62 +96,54 @@ export function createModal(opts: CreateModalOptions): React.FC + + + + ) + return ( - } + footer={ + footer + ? footer({ onClose: handleRequestClose, onSubmit: handleSubmit, loading }) + : defaultFooter + } > - e.stopPropagation()} - onKeyDown={(e: React.KeyboardEvent) => { if (e.key !== 'Escape') e.stopPropagation() }} - onKeyUp={(e: React.KeyboardEvent) => e.stopPropagation()} - > - - - {error && ( - - {error} - - )} - {renderContent( - { ...dataProps, onClose: handleRequestClose } as T & { onClose: () => void }, - { - error, - stage, - }, - )} - - - {footer ? ( - footer({ onClose: handleRequestClose, onSubmit: handleSubmit, loading }) - ) : ( - <> - - - - )} - - - + {error && ( + + {error} + + )} + {renderContent( + { ...dataProps, onClose: handleRequestClose } as T & { onClose: () => void }, + { + error, + stage, + }, + )} + ) } diff --git a/src/lib/primer-css-helper.ts b/src/lib/primer-css-helper.ts index 03a197d..68fed74 100644 --- a/src/lib/primer-css-helper.ts +++ b/src/lib/primer-css-helper.ts @@ -101,4 +101,17 @@ export const primerCss = { px: 4, py: 3, }), + + chipButton: makePreset({ + boxShadow: 'none', + display: 'inline-flex', + alignItems: 'center', + transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', + '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, + '&:active': { transform: 'translateY(0)', transition: '100ms' }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + '&:hover:not(:disabled)': { transform: 'none' }, + }, + }), } diff --git a/src/ui/__tests__/modal-shell.test.tsx b/src/ui/__tests__/modal-shell.test.tsx new file mode 100644 index 0000000..95cc27f --- /dev/null +++ b/src/ui/__tests__/modal-shell.test.tsx @@ -0,0 +1,79 @@ +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { BaseStyles, Box, ThemeProvider } from '@primer/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ModalShell } from '@/ui/modal-shell' +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let mounted: Array<{ container: HTMLDivElement; root: Root }> = [] + +function renderModal(onClose: () => void, closeOnEscape = true) { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + + + + Body + + + , + ) + }) + mounted.push({ container, root }) + return container +} + +beforeEach(() => { + mounted = [] +}) + +afterEach(() => { + for (const { container, root } of mounted) { + act(() => root.unmount()) + container.remove() + } +}) + +describe('ModalShell Escape handling', () => { + it('closes the modal and blocks capture-phase document listeners on Escape', () => { + const onClose = vi.fn() + renderModal(onClose) + + const globalSpy = vi.fn() + document.addEventListener('keydown', globalSpy, true) + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), + ) + }) + + document.removeEventListener('keydown', globalSpy, true) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(globalSpy).not.toHaveBeenCalled() + }) + + it('consumes Escape without closing when closeOnEscape is false', () => { + const onClose = vi.fn() + renderModal(onClose, false) + + const globalSpy = vi.fn() + document.addEventListener('keydown', globalSpy, true) + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), + ) + }) + + document.removeEventListener('keydown', globalSpy, true) + + expect(onClose).not.toHaveBeenCalled() + expect(globalSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/ui/modal-shell.tsx b/src/ui/modal-shell.tsx new file mode 100644 index 0000000..018d585 --- /dev/null +++ b/src/ui/modal-shell.tsx @@ -0,0 +1,82 @@ +import React, { useCallback, useEffect } from 'react' +import type { BetterSystemStyleObject } from '@primer/react' +import { Box } from '@primer/react' +import { primerCss } from '@/lib/primer-css-helper' + +export interface ModalShellProps { + /** Accessible name for the dialog (aria-label). */ + ariaLabel: string + onClose: () => void + /** Header slot — ModalStepHeader or custom chrome. */ + header?: React.ReactNode + /** Scrollable body content. */ + children: React.ReactNode + /** Optional footer row (buttons). */ + footer?: React.ReactNode + /** When false, backdrop clicks do not close. Default true. */ + closeOnBackdrop?: boolean + /** Extra sx merged into the panel container. */ + panelSx?: BetterSystemStyleObject + /** Optional data-testid on the panel. */ + panelTestId?: string + /** When false, Escape does not close. Default true. */ + closeOnEscape?: boolean +} + +export function ModalShell({ + ariaLabel, + onClose, + header, + children, + footer, + closeOnBackdrop = true, + panelSx, + panelTestId, + closeOnEscape = true, +}: ModalShellProps) { + const handleRequestClose = useCallback(() => { + onClose() + }, [onClose]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + e.preventDefault() + e.stopImmediatePropagation() + if (closeOnEscape) handleRequestClose() + } + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [closeOnEscape, handleRequestClose]) + + return ( + + e.stopPropagation()} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key !== 'Escape') e.stopPropagation() + }} + onKeyUp={(e: React.KeyboardEvent) => e.stopPropagation()} + > + {header} + {children} + {footer && ( + {footer} + )} + + + ) +}