diff --git a/src/lib/components/segmented-control/segmented-control.svelte b/src/lib/components/segmented-control/segmented-control.svelte index 07f863085..86ff817aa 100644 --- a/src/lib/components/segmented-control/segmented-control.svelte +++ b/src/lib/components/segmented-control/segmented-control.svelte @@ -9,6 +9,7 @@ interface Props { options: Option; active: keyof T; + defaultValue?: keyof T | undefined; disabled?: boolean; containerRole?: string; itemRole?: string; @@ -19,6 +20,7 @@ let { options, active = $bindable(), + defaultValue = undefined, disabled = false, containerRole = 'radiogroup', itemRole = 'radio', @@ -67,7 +69,12 @@ } -
+
{#each options as option (option.value)} @@ -196,4 +203,12 @@ opacity: 0.5; pointer-events: none; } + + .segmented-control.default-active .selector { + background-color: var(--color-foreground-level-5); + } + + .segmented-control.default-active .option.selected { + color: var(--color-background); + } diff --git a/src/lib/components/wave/issues-page/components/filter-config/components/dropdown-filter-item.svelte b/src/lib/components/wave/issues-page/components/filter-config/components/dropdown-filter-item.svelte index f080aca59..c289f384b 100644 --- a/src/lib/components/wave/issues-page/components/filter-config/components/dropdown-filter-item.svelte +++ b/src/lib/components/wave/issues-page/components/filter-config/components/dropdown-filter-item.svelte @@ -24,10 +24,18 @@ const popoverEl = document.getElementById(`dropdown-${uid}`); if (popoverEl) { - popoverEl.style.position = 'absolute'; - popoverEl.style.top = `${rect.bottom + window.scrollY + 8}px`; - popoverEl.style.left = `${rect.left + window.scrollX}px`; + popoverEl.style.position = 'fixed'; + popoverEl.style.left = `${rect.left}px`; popoverEl.style.width = `${rect.width}px`; + + const spaceBelow = window.innerHeight - rect.bottom - 16; + const maxHeight = Math.max(spaceBelow, 160); + + popoverEl.style.maxHeight = `${maxHeight}px`; + popoverEl.style.bottom = ''; + + const top = Math.min(rect.bottom + 8, window.innerHeight - maxHeight - 8); + popoverEl.style.top = `${top}px`; } } @@ -78,7 +86,7 @@ > {#if !selectedOption} - Any + In any repo {:else} {#await optionsPromise} Loading... @@ -124,7 +132,9 @@
- + {#each options.filter((option) => option.label .toLowerCase() @@ -142,7 +152,8 @@ .dropdown-trigger { cursor: pointer; padding: 0.25rem 0.5rem; - border-radius: 0.5rem; + min-height: 42px; + border-radius: 0.5rem 0 0.5rem 0.5rem; border: 1px solid var(--color-foreground-level-3); user-select: none; display: flex; @@ -157,6 +168,7 @@ .dropdown-trigger .name { flex-grow: 1; + min-width: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; @@ -164,8 +176,8 @@ } .dropdown-trigger.has-selection { - background-color: var(--color-primary-level-1); - border-color: var(--color-primary-level-3); + background-color: var(--color-background); + border-color: var(--color-foreground-level-3); } .spinner { @@ -181,6 +193,9 @@ box-shadow: var(--shadow-elevation-4); padding: 0; z-index: 5; + overflow: hidden; + display: flex; + flex-direction: column; transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out, @@ -189,6 +204,7 @@ opacity: 0; transform: translateY(0.25rem); + pointer-events: none; } .dropdown-content .search-bar { @@ -212,6 +228,7 @@ .dropdown-content:popover-open { opacity: 1; transform: translateY(0); + pointer-events: auto; } @starting-style { @@ -222,21 +239,32 @@ } .options-list { - max-height: 15rem; + flex: 1 1 auto; overflow-y: auto; + overflow-x: hidden; + display: block; + min-width: 0; + } + + .dropdown-options { display: flex; flex-direction: column; - min-width: 0; + max-height: 100%; + min-height: 0; + flex: 1; } .dropdown-options button { + width: 100%; min-width: 0; - padding: 0.5rem 0.5rem; + padding: 0.3rem 0.7rem; background: none; border: none; text-align: left; cursor: pointer; transition: background-color 0.2s; + box-sizing: border-box; + max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/lib/components/wave/issues-page/components/filter-config/filter-config.svelte b/src/lib/components/wave/issues-page/components/filter-config/filter-config.svelte index 398695f52..e9cc78cb0 100644 --- a/src/lib/components/wave/issues-page/components/filter-config/filter-config.svelte +++ b/src/lib/components/wave/issues-page/components/filter-config/filter-config.svelte @@ -21,16 +21,25 @@ ], }, - ...(ownUserId && mode === 'contributor' + hasPr: { + type: 'single-select', + label: 'Linked PR', + options: [ + { label: 'All', value: 'all' }, + { label: 'Linked', value: 'true' }, + { label: 'None', value: 'false' }, + ], + }, + + ...(mode === 'maintainer' || mode === 'wave' ? { - assignedToUser: { + hasApplications: { type: 'single-select', - label: 'Assignment', + label: 'Applications', options: [ - { - label: 'Assigned to me', - value: ownUserId, - }, + { label: 'All', value: 'all' }, + { label: 'Has', value: 'true' }, + { label: 'None', value: 'false' }, ], }, } @@ -40,60 +49,45 @@ ? { applicantAssigned: { type: 'single-select', - label: 'Applicant assignment', + label: 'Assigned', options: [ - { - label: 'Assigned to an applicant', - value: 'true', - }, - { - label: 'Not assigned', - value: 'false', - }, + { label: 'All', value: 'all' }, + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, ], }, } : {}), - ...(mode === 'maintainer' || mode === 'wave' + ...(ownUserId && mode === 'contributor' ? { - hasApplications: { + assignedToUser: { + type: 'toggle', + label: 'Assigned to me', + onValue: ownUserId, + }, + } + : {}), + + ...(mode === 'maintainer' + ? { + isInWaveProgram: { type: 'single-select', - label: 'Applications', + label: 'Part of Wave', options: [ - { - label: 'Has applications', - value: 'true', - }, - { - label: 'No applications', - value: 'false', - }, + { label: 'All', value: 'all' }, + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, ], }, } : {}), - hasPr: { - type: 'single-select', - label: 'Pull Requests', - options: [ - { - label: 'Has linked PR', - value: 'true', - }, - { - label: 'No linked PR', - value: 'false', - }, - ], - }, - ...(mode === 'maintainer' || mode === 'wave' ? { repoId: { type: 'dropdown', - label: 'Repository', + label: 'Repo', optionsPromise: (async () => { if (mode === 'wave') { if (!currentWaveProgramId) { @@ -127,26 +121,13 @@ }, } : {}), - - ...(mode === 'maintainer' - ? { - isInWaveProgram: { - type: 'single-select', - label: 'Wave Membership', - options: [ - { - label: 'Part of a Wave', - value: 'true', - }, - ], - }, - } - : {}), }) as const;
- {#each Object.entries(AVAILABLE_FILTERS(ownUserId, mode, currentWaveProgramId)) as [filterKey, filterConfig], i (filterKey)} + {#each filterEntries as [filterKey, filterConfig], i (filterKey)}
-
{filterConfig.label}
{#if filterConfig.type === 'single-select'} - handleSelectFilter(filterKey as keyof IssueFilters, value)} - /> + {#if filterKey === 'state'} +
+ Status +
+ + handleSelectFilter( + 'state', + (value as StatusValue) === 'all' ? null : (value as StatusValue), + )} + /> +
+
+ {:else} +
+ {filterConfig.label} +
+ ({ + title: option.label, + value: option.value, + }))} + active={getSegmentedValue(filters[filterKey as keyof IssueFilters])} + defaultValue="all" + onTabChange={(value) => + handleSelectFilter( + filterKey as keyof IssueFilters, + value === 'all' ? null : (value as IssueFilters[keyof IssueFilters]), + )} + /> +
+
+ {/if} + {:else if filterConfig.type === 'toggle'} +
+ {filterConfig.label} +
+ + handleSelectFilter( + filterKey as keyof IssueFilters, + checked ? (filterConfig.onValue as IssueFilters[keyof IssueFilters]) : null, + )} + /> +
+
{:else if filterConfig.type === 'dropdown'} - handleSelectFilter(filterKey as keyof IssueFilters, value)} - /> +
+ {filterConfig.label} +
+ handleSelectFilter(filterKey as keyof IssueFilters, value)} + /> +
+
{/if}
{/each}
-
- - +
+ +
@@ -247,6 +338,45 @@ gap: 0.5rem; } + .filter-config-item--row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-width: 0; + } + + .filter-row-label { + font-weight: 400; + font-family: var(--typeface-regular); + font-size: 1rem; + flex-shrink: 0; + } + + .filter-row-control :global(.segmented-control) { + font-size: 1rem; + } + + .filter-row-control { + display: flex; + justify-content: flex-end; + flex: 1; + min-width: 0; + } + + .filter-row-control :global(.single-select-filter-item) { + justify-content: flex-end; + } + + .filter-config-item :global(.segmented-control .option) { + font-weight: 400; + } + + .filter-config-item :global(.dropdown-trigger) { + width: 100%; + max-width: none; + } + .actions { position: sticky; bottom: 0.25rem; @@ -256,5 +386,10 @@ display: flex; justify-content: flex-end; gap: 0.5rem; + transition: background-color 0.3s; + } + + .actions.actions-flash { + background-color: var(--color-primary-level-1); } diff --git a/src/lib/components/wave/issues-page/components/filter-config/types.ts b/src/lib/components/wave/issues-page/components/filter-config/types.ts index 9d0fcff44..d1e5615ea 100644 --- a/src/lib/components/wave/issues-page/components/filter-config/types.ts +++ b/src/lib/components/wave/issues-page/components/filter-config/types.ts @@ -10,6 +10,13 @@ export type DropdownConfig = { optionsPromise: Promise; }; +export type ToggleConfig = { + type: 'toggle'; + label: string; + onValue: string; +}; + export type FilterConfig = | SingleSelectConfig<{ label: string; value: string }[]> - | DropdownConfig<{ label: string; value: string }[]>; + | DropdownConfig<{ label: string; value: string }[]> + | ToggleConfig; diff --git a/src/lib/components/wave/issues-page/issues-page.svelte b/src/lib/components/wave/issues-page/issues-page.svelte index 41bd03457..ef1570e22 100644 --- a/src/lib/components/wave/issues-page/issues-page.svelte +++ b/src/lib/components/wave/issues-page/issues-page.svelte @@ -108,6 +108,11 @@ } let filtersOpen = $state(false); + let filterFlash = $state(false); + let filterFlashTimeout: ReturnType | null = null; + let filterDropdownEl = $state(null); + let filterButtonEl = $state(null); + let ignoreFilterOutsideClick = $state(false); // svelte-ignore non_reactive_update let filterConfigInstance: FilterConfig; @@ -120,6 +125,61 @@ } } + function triggerFilterFlash() { + if (filterFlashTimeout) { + clearTimeout(filterFlashTimeout); + } + + filterFlash = true; + filterFlashTimeout = setTimeout(() => { + filterFlash = false; + }, 450); + } + + function handleFilterOutsideClick(event: MouseEvent) { + if (!filtersOpen) return; + + if (ignoreFilterOutsideClick) { + ignoreFilterOutsideClick = false; + return; + } + + const target = event.target as Node | null; + if (!target) return; + + const path = event.composedPath?.() ?? []; + const clickedInPopover = path.some( + (node) => node instanceof HTMLElement && node.classList.contains('dropdown-content'), + ); + if (clickedInPopover) { + return; + } + + if (filterDropdownEl?.contains(target) || filterButtonEl?.contains(target)) { + return; + } + + if (filterConfigInstance?.hasChangesInFilters()) { + triggerFilterFlash(); + return; + } + + filtersOpen = false; + filterConfigInstance?.reset(); + } + + function handleFilterPointerDown(event: PointerEvent) { + if (!filtersOpen) return; + + const target = event.target as Node | null; + if (!target) return; + + const openPopover = document.querySelector('.dropdown-content:popover-open'); + if (openPopover && !openPopover.contains(target)) { + ignoreFilterOutsideClick = true; + } + } + let applyingFilters = $state(false); async function handleApplyFilters(filters: IssueFilters) { @@ -222,9 +282,16 @@ } }); - let noOfFilters = $derived( - Object.keys(appliedFilters).filter((key) => key !== 'search').length - noOfPreappliedFilters, - ); + let noOfFilters = $derived(() => { + const filterKeys = Object.keys(appliedFilters).filter((key) => key !== 'search'); + let count = filterKeys.length - noOfPreappliedFilters; + + if (filtersMode === 'maintainer' && appliedFilters.state === 'open') { + count -= 1; + } + + return Math.max(0, count); + }); let searchOpen = $state(false); @@ -280,6 +347,8 @@ } + +
@@ -328,20 +397,22 @@ onclick={handleSearchOpen}>Search -
- +
+
+ +