From b47e9b48be785f67cbfaa0f70f7f01c2ea054a21 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 26 May 2026 18:33:22 -0700 Subject: [PATCH] feat(web): always-visible checkboxes and select-all on playlist multi-select Builds on #14393. While the playlist page is in edit mode: - Each track row shows a Harmony Checkbox at the leading edge, always visible (not hover-gated). Clicking either the row or the checkbox toggles selection. - The TRACK column header gains a select-all Checkbox that mirrors row state (indeterminate when some are selected) and toggles between select-all and clear-all. - Selected rows render with a light purple tint mixed from the Harmony secondary token (no raw hex), so the selection state is obvious in both light and dark themes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tracks/EditAwareTracksTable.module.css | 18 +++- .../edit-mode/tracks/EditAwareTracksTable.tsx | 86 ++++++++++++++++++- .../components/tracks-table/TracksTable.tsx | 25 +++++- 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.module.css b/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.module.css index 00fb5bab21e..75568ec7ab8 100644 --- a/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.module.css +++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.module.css @@ -1,5 +1,21 @@ .selected, .selected td { - background-color: var(--harmony-bg-surface-2) !important; + /* Light purple tint derived from the Harmony secondary token. Mixed with + * white so the row stays readable in both light and dark themes — there's + * no Harmony secondary-50 primitive to reach for directly. */ + background-color: color-mix( + in srgb, + var(--harmony-secondary) 8%, + var(--harmony-white) + ) !important; box-shadow: inset 3px 0 0 0 var(--harmony-secondary, var(--harmony-accent)); } + +.selected:hover:not(.skeletonRow), +.selected:hover:not(.skeletonRow) > td { + background-color: color-mix( + in srgb, + var(--harmony-secondary) 12%, + var(--harmony-white) + ) !important; +} diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.tsx b/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.tsx index f80f732550e..dea1ced9f98 100644 --- a/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.tsx +++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.tsx @@ -1,6 +1,14 @@ -import { ComponentProps, useCallback, useEffect, useRef } from 'react' +import { + ComponentProps, + MouseEvent, + useCallback, + useEffect, + useMemo, + useRef +} from 'react' import { ID } from '@audius/common/models' +import { Checkbox, Flex } from '@audius/harmony' import { TracksTable } from 'components/tracks-table' @@ -22,12 +30,83 @@ type EditAwareTracksTableProps = ComponentProps & { * behavior is identical to the underlying TracksTable. */ export const EditAwareTracksTable = (props: EditAwareTracksTableProps) => { - const { collectionId, onClickRow, ...rest } = props + const { collectionId, onClickRow, data, ...rest } = props const editMode = usePlaylistEditMode() const selection = useTrackSelection() const isEditingThis = editMode.isEditMode && editMode.collectionId === collectionId + const selectableTrackIds = useMemo(() => { + if (!isEditingThis) return [] as ID[] + const ids: ID[] = [] + for (const t of data) { + if (typeof t.track_id === 'number') ids.push(t.track_id) + } + return ids + }, [data, isEditingThis]) + + const selectableCount = selectableTrackIds.length + const selectedCount = selection.count + const allSelected = selectableCount > 0 && selectedCount >= selectableCount + const someSelected = selectedCount > 0 && !allSelected + + const handleHeaderCheckboxClick = useCallback( + (e: MouseEvent) => { + // Stop the click from bubbling to the column header's sort handler. + e.stopPropagation() + if (allSelected) { + selection.clear() + } else { + selection.selectAll(selectableTrackIds) + } + }, + [allSelected, selectableTrackIds, selection] + ) + + const trackNameHeader = useMemo(() => { + if (!isEditingThis) return undefined + return ( + + 0} + indeterminate={someSelected} + onClick={handleHeaderCheckboxClick} + onChange={() => {}} + /> + Track + + ) + }, [ + allSelected, + handleHeaderCheckboxClick, + isEditingThis, + selectedCount, + someSelected + ]) + + const renderTrackPrefix = useCallback( + (track: TrackLike, index: number) => { + const id = track.track_id + if (typeof id !== 'number') return null + const checked = selection.isSelected(id) + return ( + { + // Avoid double-toggling: the row's onClick handler would also + // call selection.toggle if the click bubbled up. + e.stopPropagation() + selection.toggle(id, index) + }} + onChange={() => {}} + /> + ) + }, + [selection] + ) + // Capture shift modifier state from keyboard so we can extend the selection // even though TracksTable's onClickRow does not pass the MouseEvent. const shiftRef = useRef(false) @@ -83,8 +162,11 @@ export const EditAwareTracksTable = (props: EditAwareTracksTableProps) => { return ( ) } diff --git a/packages/web/src/components/tracks-table/TracksTable.tsx b/packages/web/src/components/tracks-table/TracksTable.tsx index dae2c233625..599fa70b82f 100644 --- a/packages/web/src/components/tracks-table/TracksTable.tsx +++ b/packages/web/src/components/tracks-table/TracksTable.tsx @@ -184,6 +184,17 @@ type TracksTableProps = { showArtistInTrackNameColumn?: boolean onClickRow?: (track: any, index: number) => void trackActionsHeader?: ReactNode + /** + * Optional content for the Track column header, replacing the default + * 'Track' label. Used by selection mode to render a select-all checkbox. + */ + trackNameHeader?: ReactNode + /** + * Optional content rendered at the leading edge of the Track-name cell + * (before the inline artwork). Used by selection mode to render the + * per-row selection checkbox. + */ + renderTrackPrefix?: (track: any, rowIndex: number) => ReactNode /** * Optional additional className applied per row. The result is appended * to the table's own per-row className. Use this for things like a @@ -220,6 +231,8 @@ export const TracksTable = ({ data, activeIndex, trackActionsHeader, + trackNameHeader, + renderTrackPrefix, rowClassNameAddition, ...tableProps }: TracksTableProps) => { @@ -245,6 +258,8 @@ export const TracksTable = ({ onClickRepostRef.current = onClickRepost const onClickRemoveRef = useRef(onClickRemove) onClickRemoveRef.current = onClickRemove + const renderTrackPrefixRef = useRef(renderTrackPrefix) + renderTrackPrefixRef.current = renderTrackPrefix const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() const [, setGatedModalVisibility] = useModalState('LockedContent') @@ -364,8 +379,11 @@ export const TracksTable = ({ ) ) : null + const prefix = renderTrackPrefixRef.current?.(track, index) + return ( + {prefix} {showArtistInTrackNameColumn && track.track_id ? (