Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions frontend/src/components/ParticipationChip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<template>
<div
ref="toolbar"
class="flex flex-wrap gap-3 my-4 items-center"
role="toolbar"
aria-label="Participation status filters"
@keydown="onToolbarKeydown"
>
<!-- All chip -->
<button
:ref="(el) => setButtonRef(el, 0)"
type="button"
:aria-pressed="selected === null"
title="Show all participations"
:tabindex="focusedIndex === 0 ? 0 : -1"
:class="[
'inline-flex items-center gap-2 px-4 py-1 rounded-full text-sm transition duration-150 ease-out cursor-pointer select-none transform-gpu',
selected === null
? 'bg-black/5 text-slate-900 font-semibold ring-2 ring-offset-1 ring-slate-300'
: 'bg-white text-slate-700 hover:bg-slate-50',
// make focus with keyboard very visible
'focus-visible:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-slate-400',
]"
@click="setSelected(null)"
@focus="focusedIndex = 0"
@keydown="onButtonKeydown"
>
<Check
v-if="selected === null"
class="w-4 h-4 text-slate-900 stroke-current"
aria-hidden="true"
/>
<span>All</span>
</button>

<!-- Status chips -->
<button
v-for="(status, idx) in statusesOrdered"
:key="status"
:ref="(el) => setButtonRef(el, idx + 1)"
type="button"
:aria-pressed="selected === status"
:title="humanReadableParticipationStatus[status]"
:tabindex="focusedIndex === idx + 1 ? 0 : -1"
:class="[
'inline-flex items-center gap-3 px-4 py-1 rounded-full text-sm transition-colors duration-150 ease-out cursor-pointer select-none transform-gpu',
chipClass(status),
selected === status ? 'font-semibold shadow' : 'font-medium',
// selected gets a persistent ring; otherwise show a visible focus ring for keyboard users
selected === status
? ['ring-2', 'ring-offset-1', participationStatusColor[status].ring]
: [
'focus-visible:outline-none',
'focus-visible:scale-105',
'focus-visible:ring-2',
'focus-visible:ring-offset-2',
participationStatusColor[status].ring,
],
]"
@click="setSelected(status)"
@focus="focusedIndex = idx + 1"
@keydown="onButtonKeydown"
>
<!-- show an icon only when this status is selected to avoid extra left space -->
<Check
v-if="selected === status"
:class="[
'w-4 h-4 stroke-current',
status === 'GIVEN_UP' ? 'text-slate-900' : 'text-white',
]"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{
humanReadableParticipationStatus[status]
}}</span>
</button>
</div>
</template>

<script setup lang="ts">
import { computed, ref, nextTick, type ComponentPublicInstance } from "vue";
import { Check } from "lucide-vue-next";
import type { ParticipationStatus } from "@/dto";
import {
humanReadableParticipationStatus,
participationStatusColor,
} from "@/dto";

const props = defineProps<{
selected?: ParticipationStatus | null;
}>();

const emit = defineEmits<{
(e: "update:selected", value: ParticipationStatus | null): void;
}>();

const statusesOrdered: ParticipationStatus[] = [
"ANNOUNCED",
"ACCEPTED",
"CONTACTED",
"GIVEN_UP",
"IN_CONVERSATIONS",
"ON_HOLD",
"REJECTED",
"SELECTED",
"SUGGESTED",
];

const selected = computed<ParticipationStatus | null>({
get: () => props.selected ?? null,
set: (val) => emit("update:selected", val),
});

const chipClass = (status: ParticipationStatus) => {
const color = participationStatusColor[status];
return `${color.background} border border-transparent`;
};

function setSelected(val: ParticipationStatus | null) {
selected.value = val;
}

// Keyboard navigation support
const focusedIndex = ref(0);
const chipButtons = ref<(HTMLElement | undefined)[]>([]);
const toolbar = ref<HTMLElement | null>(null);

function focusButton(idx: number) {
nextTick(() => {
const buttons = chipButtons.value || [];
const button = buttons[idx];
if (button) {
focusedIndex.value = idx;
button.focus();
}
});
}

function handleNavigationKeydown(e: KeyboardEvent) {
const key = e.key;
if (
key === "ArrowRight" ||
key === "ArrowDown" ||
key === "Right" ||
key === "Down" ||
key === "39"
Comment on lines +142 to +146
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string '39' is not a valid value for e.key. Modern browsers use 'ArrowRight' for arrow keys, and the fallback 'Right' handles older browsers. Numeric keycodes like 39 should be checked using e.keyCode (though it's deprecated). Remove the string '39' check or use e.keyCode === 39 instead.

Copilot uses AI. Check for mistakes.
) {
e.preventDefault();
focusedIndex.value =
(focusedIndex.value + 1) % (statusesOrdered.length + 1);
focusButton(focusedIndex.value);
return true;
} else if (
key === "ArrowLeft" ||
key === "ArrowUp" ||
key === "Left" ||
key === "Up" ||
key === "37"
Comment on lines +154 to +158
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string '37' is not a valid value for e.key. Modern browsers use 'ArrowLeft' for arrow keys, and the fallback 'Left' handles older browsers. Numeric keycodes like 37 should be checked using e.keyCode (though it's deprecated). Remove the string '37' check or use e.keyCode === 37 instead.

Copilot uses AI. Check for mistakes.
) {
e.preventDefault();
focusedIndex.value =
(focusedIndex.value - 1 + statusesOrdered.length + 1) %
(statusesOrdered.length + 1);
focusButton(focusedIndex.value);
return true;
}
return false;
}

function onToolbarKeydown(e: KeyboardEvent) {
if (e.target && (e.target as HTMLElement).tagName === "BUTTON") return;
handleNavigationKeydown(e);
}

function onButtonKeydown(e: KeyboardEvent) {
handleNavigationKeydown(e);
}

function setButtonRef(
el: Element | ComponentPublicInstance | null,
idx: number,
) {
if (!chipButtons.value) chipButtons.value = [];
if (el) {
chipButtons.value[idx] = (el as HTMLElement) ?? undefined;
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nullish coalescing operator ?? undefined is redundant. If el as HTMLElement is nullish, it would already be null or undefined. Simplify to chipButtons.value[idx] = el as HTMLElement | undefined;

Suggested change
chipButtons.value[idx] = (el as HTMLElement) ?? undefined;
chipButtons.value[idx] = el as HTMLElement | undefined;

Copilot uses AI. Check for mistakes.
} else {
chipButtons.value[idx] = undefined;
}
}
</script>
16 changes: 14 additions & 2 deletions frontend/src/components/companies/MembersCompanies.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<CreateCompanyDialogTrigger />
</div>

<ParticipationChip v-model:selected="selectedStatus" />

<div
v-if="!membersSorted.length && companiesLoading"
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4 my-4"
Expand All @@ -28,7 +30,7 @@
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4 my-4"
>
<CompanyWorkflowCard
v-for="company in participations?.get(item.id) || []"
v-for="company in participationsFiltered?.get(item.id) || []"
:key="company.id"
:company="company"
/>
Expand All @@ -38,7 +40,6 @@
</template>

<script setup lang="ts">
import { computed } from "vue";
import type { Company, CompanyParticipation } from "@/dto/companies";
import type { Member } from "@/dto/members";
import MemberWithAvatar from "@/components/members/MemberWithAvatar.vue";
Expand All @@ -47,6 +48,10 @@ import { useInsertionSort, useSortByParticipationStatus } from "@/lib/utils";
import { Skeleton } from "../ui/skeleton";
import CompanyWorkflowCard from "../cards/CompanyWorkflowCard.vue";
import CreateCompanyDialogTrigger from "./CreateCompanyDialogTrigger.vue";
import ParticipationChip from "@/components/ParticipationChip.vue";
import { ref, computed, type ComputedRef } from "vue";
import { useParticipationFilter } from "@/composables/useParticipationFilter";
import type { ParticipationStatus } from "@/dto";

const props = defineProps<{
companies: Company[];
Expand Down Expand Up @@ -100,4 +105,11 @@ const participations = computed(() =>
return acc;
}, new Map<string, CompanyWithParticipation[]>()),
);

const selectedStatus = ref<ParticipationStatus | null>(null);

const participationsFiltered = useParticipationFilter<CompanyWithParticipation>(
participations as ComputedRef<Map<string, CompanyWithParticipation[]>>,
selectedStatus,
);
</script>
16 changes: 14 additions & 2 deletions frontend/src/components/speakers/MembersSpeakers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<CreateSpeakerDialogTrigger />
</div>

<ParticipationChip v-model:selected="selectedStatus" />

<div
v-if="!membersSorted.length && speakersLoading"
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4 my-4"
Expand All @@ -28,7 +30,7 @@
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4 my-4"
>
<SpeakerWorkflowCard
v-for="speaker in participations?.get(item.id) || []"
v-for="speaker in participationsFiltered?.get(item.id) || []"
:key="speaker.id"
:speaker="speaker"
/>
Expand All @@ -38,7 +40,6 @@
</template>

<script setup lang="ts">
import { computed } from "vue";
import type { Member } from "@/dto/members";
import MemberWithAvatar from "@/components/members/MemberWithAvatar.vue";
import { DynamicScroller } from "vue-virtual-scroller";
Expand All @@ -47,6 +48,10 @@ import { useInsertionSort, useSortByParticipationStatus } from "@/lib/utils";
import { Skeleton } from "../ui/skeleton";
import SpeakerWorkflowCard from "../cards/SpeakerWorkflowCard.vue";
import CreateSpeakerDialogTrigger from "./CreateSpeakerDialogTrigger.vue";
import ParticipationChip from "@/components/ParticipationChip.vue";
import { ref, computed, type ComputedRef } from "vue";
import { useParticipationFilter } from "@/composables/useParticipationFilter";
import type { ParticipationStatus } from "@/dto";

const props = defineProps<{
speakers: Speaker[];
Expand Down Expand Up @@ -96,4 +101,11 @@ const participations = computed(() =>
return acc;
}, new Map<string, SpeakerWithParticipation[]>()),
);

const selectedStatus = ref<ParticipationStatus | null>(null);

const participationsFiltered = useParticipationFilter<SpeakerWithParticipation>(
participations as ComputedRef<Map<string, SpeakerWithParticipation[]>>,
selectedStatus,
);
</script>
27 changes: 27 additions & 0 deletions frontend/src/composables/useParticipationFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { computed } from "vue";
import type { Ref } from "vue";
import type { ParticipationStatus } from "@/dto";

// Generic composable to filter a Map<string, T[]> by participation.status
export function useParticipationFilter<
T extends { participation?: { status?: ParticipationStatus } },
>(
participations: Ref<Map<string, T[]> | undefined>,
selectedStatus: Ref<ParticipationStatus | null>,
) {
return computed(() => {
if (!participations.value) return new Map<string, T[]>();

if (!selectedStatus.value) return participations.value;

const map = new Map<string, T[]>();
for (const [memberId, items] of participations.value.entries()) {
const filtered = items.filter(
(it) => it.participation?.status === selectedStatus.value,
);
if (filtered.length) map.set(memberId, filtered);
}

return map;
});
}