Skip to content
Open
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
57 changes: 51 additions & 6 deletions resources/js/components/AppHeader.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { index as developersIndex } from '@/actions/App/Http/Controllers/DeveloperController';
import { show } from '@/actions/App/Http/Controllers/PluginController';
import { scoreSearchResult } from '@/utils/formatting';
import { pluginSearchKey } from '@/lib/pluginSearch';
import type { Plugin } from '@/types';
import { router, usePage } from '@inertiajs/vue3';
import { computed, nextTick, ref, watch } from 'vue';
import { scoreSearchResult } from '@/utils/formatting';

const page = usePage<{ plugins?: Plugin[]; apiUrl: string }>();
const plugins = computed(() => page.props.plugins ?? []);

const searchInput = ref('');
const searchInput = inject(pluginSearchKey, ref(''));
const searchVisible = ref(false);
const menuOpen = ref(false);
const searchInputRef = ref<HTMLInputElement | null>(null);
const desktopSearchInputRef = ref<HTMLInputElement | null>(null);

const links = [
{ href: '/', label: 'All Plugins', exact: true },
Expand All @@ -29,9 +31,15 @@ function isActive(link: { href: string; exact: boolean }): boolean {
return currentPath.value.startsWith(link.href);
}

/**
* The homepage renders the whole plugin list itself, so there the query filters
* that list in place instead of opening a duplicate dropdown over it.
*/
const filtersPageInPlace = computed(() => currentPath.value === '/');

const searchResults = computed(() => {
const q = searchInput.value.trim();
if (!q) return [];
if (!q || filtersPageInPlace.value) return [];
return plugins.value
.map((p) => ({ ...p, score: scoreSearchResult(p, q) }))
.filter((p) => p.score > 0)
Expand Down Expand Up @@ -73,6 +81,42 @@ async function openMenuForSearch(): Promise<void> {
await nextTick();
searchInputRef.value?.focus();
}

/** The drawer holds the only search box below the `sm` breakpoint. */
async function focusSearch(): Promise<void> {
if (window.matchMedia('(min-width: 640px)').matches) {
searchVisible.value = true;
await nextTick();
desktopSearchInputRef.value?.focus();
desktopSearchInputRef.value?.select();

return;
}

await openMenuForSearch();
}

/**
* Take over find-in-page. Browser find is of limited use here: it only matches
* the plugin name and description text that happens to be rendered, whereas the
* search box also matches author and tags across every plugin.
*/
function onKeydown(event: KeyboardEvent): void {
if (event.altKey || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'f') {
return;
}

event.preventDefault();
void focusSearch();
}

onMounted(() => window.addEventListener('keydown', onKeydown));
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));

watch(currentPath, () => {
searchInput.value = '';
searchVisible.value = false;
});
</script>

<template>
Expand Down Expand Up @@ -105,9 +149,10 @@ async function openMenuForSearch(): Promise<void> {
<path fill="currentColor" d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z" />
</svg>
<input
ref="desktopSearchInputRef"
v-model="searchInput"
class="app-header__search-input"
placeholder="Search plugins by name, author, tags..."
:placeholder="filtersPageInPlace ? 'Filter plugins by name, author, tags...' : 'Search plugins by name, author, tags...'"
type="text"
autocomplete="off"
spellcheck="false"
Expand Down
4 changes: 4 additions & 0 deletions resources/js/layouts/AppLayout.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { provide, ref } from 'vue';
import AppFooter from '@/components/AppFooter.vue';
import AppHeader from '@/components/AppHeader.vue';
import AppNav from '@/components/AppNav.vue';
import { pluginSearchKey } from '@/lib/pluginSearch';

provide(pluginSearchKey, ref(''));
</script>

<template>
Expand Down
36 changes: 36 additions & 0 deletions resources/js/lib/pluginSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { InjectionKey, Ref } from 'vue';
import type { Plugin } from '@/types';
import { scoreSearchResult } from '@/utils/formatting';

/**
* The header search box and the homepage plugin list share one query string.
*
* On the homepage the query filters the list in place; everywhere else the
* header keeps showing its own results dropdown. The ref is provided by
* AppLayout rather than living at module scope so that it is created per app
* instance — module state would be shared across requests under SSR.
*/
export const pluginSearchKey: InjectionKey<Ref<string>> =
Symbol('pluginSearch');

/**
* Whether a plugin matches the query, using the same scoring the header
* dropdown uses so that filtering and searching never disagree.
*/
export function matchesQuery(plugin: Plugin, query: string): boolean {
return scoreSearchResult(plugin, query) > 0;
}

/**
* Filters while preserving the caller's ordering — the homepage sorts by the
* column the user picked, which must survive filtering.
*/
export function filterPlugins(plugins: Plugin[], query: string): Plugin[] {
const trimmed = query.trim();

if (!trimmed) {
return plugins;
}

return plugins.filter((plugin) => matchesQuery(plugin, trimmed));
}
130 changes: 104 additions & 26 deletions resources/js/pages/Index.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, inject, onMounted, onUnmounted, ref } from 'vue';
import { show } from '@/actions/App/Http/Controllers/PluginController';
import AppLayout from '@/layouts/AppLayout.vue';
import { filterPlugins, pluginSearchKey } from '@/lib/pluginSearch';
import type { Plugin } from '@/types';
import { formatDate, truncateString } from '@/utils/formatting';
import { Head } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref } from 'vue';

defineOptions({ layout: AppLayout });

const props = defineProps<{
plugins: Plugin[];
}>();

const searchQuery = inject(pluginSearchKey, ref(''));

type SortField = keyof Plugin;
type SortDirection = 'asc' | 'desc';
Expand All @@ -33,6 +35,11 @@ function sortIndicator(field: SortField): string {
return sortDirection.value === 'asc' ? '↑' : '↓';
}

function ariaSort(field: SortField): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none';
return sortDirection.value === 'asc' ? 'ascending' : 'descending';
}

const sortedPlugins = computed(() => {
return [...props.plugins].sort((a, b) => {
const valueA = a[sortField.value];
Expand All @@ -46,6 +53,8 @@ const sortedPlugins = computed(() => {
});
});

const visiblePlugins = computed(() => filterPlugins(sortedPlugins.value, searchQuery.value));

const tableWrapper = ref<HTMLElement>();
const stickyScrollbar = ref<HTMLElement>();
const scrollWidth = ref(0);
Expand Down Expand Up @@ -97,51 +106,68 @@ const columns: { field: SortField; label: string }[] = [
<Head title="RuneLite Plugin Stats" />

<div ref="tableWrapper" class="plugin-table__wrapper">
<table class="plugin-table">
<thead class="plugin-table__head">
<tr>
<th
<div class="plugin-table" role="table" aria-label="RuneLite plugins">
<div class="plugin-table__head" role="rowgroup">
<div class="plugin-table__head-row" role="row">
<div
v-for="col in columns"
:key="col.field"
scope="col"
role="columnheader"
tabindex="0"
class="plugin-table__head-cell plugin-table__head-cell--sortable"
:aria-sort="ariaSort(col.field)"
@click="handleSort(col.field)"
@keydown.enter.prevent="handleSort(col.field)"
@keydown.space.prevent="handleSort(col.field)"
>
{{ col.label }}
<span :class="sortField === col.field ? 'plugin-table__sort--active' : 'plugin-table__sort--inactive'">{{ sortIndicator(col.field) }}</span>
</th>
<th scope="col" class="plugin-table__head-cell"></th>
</tr>
</thead>
<tbody>
<tr
v-for="(plugin, index) in sortedPlugins"
</div>
<div role="columnheader" class="plugin-table__head-cell"><span class="plugin-table__head-cell-label">Stats</span></div>
</div>
</div>
<div class="plugin-table__body" role="rowgroup">
<div
v-for="(plugin, index) in visiblePlugins"
:key="plugin.id"
role="row"
:title="plugin.warning"
class="plugin-table__row"
:class="index % 2 === 0 ? 'plugin-table__row--even' : 'plugin-table__row--odd'"
>
<td class="plugin-table__cell">
<div role="cell" class="plugin-table__cell">
<a
class="plugin-table__name-link"
:href="`https://runelite.net/plugin-hub/show/${plugin.name}`"
target="_blank"
rel="noopener noreferrer"
>{{ plugin.display || plugin.name }}</a>
<span v-if="plugin.author" class="plugin-table__author">by {{ plugin.author }}</span>
</td>
<td class="plugin-table__cell plugin-table__cell--num">{{ plugin.current_installs.toLocaleString('en-US') }}</td>
<td class="plugin-table__cell plugin-table__cell--num plugin-table__cell--secondary">{{ plugin.all_time_high.toLocaleString('en-US') }}</td>
<td class="plugin-table__cell plugin-table__cell--desc">{{ truncateString(plugin.description, 100) }}</td>
<td class="plugin-table__cell plugin-table__cell--secondary plugin-table__cell--date">{{ formatDate(plugin.updated_on) }}</td>
<td class="plugin-table__cell plugin-table__cell--action">
</div>
<div role="cell" class="plugin-table__cell plugin-table__cell--num">{{ plugin.current_installs.toLocaleString('en-US') }}</div>
<div role="cell" class="plugin-table__cell plugin-table__cell--num plugin-table__cell--secondary">{{ plugin.all_time_high.toLocaleString('en-US') }}</div>
<div role="cell" class="plugin-table__cell plugin-table__cell--desc">{{ truncateString(plugin.description, 100) }}</div>
<div role="cell" class="plugin-table__cell plugin-table__cell--secondary plugin-table__cell--date">{{ formatDate(plugin.updated_on) }}</div>
<div role="cell" class="plugin-table__cell plugin-table__cell--action">
<a :href="show.url(plugin.name)" class="plugin-table__stats-link">Stats</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

<p v-if="visiblePlugins.length === 0" class="plugin-table__empty">
No plugins match “{{ searchQuery.trim() }}”.
</p>
</div>

<p class="plugin-table__count" aria-live="polite">
<template v-if="searchQuery.trim()">
Showing {{ visiblePlugins.length.toLocaleString('en-US') }} of
{{ sortedPlugins.length.toLocaleString('en-US') }} plugins
</template>
<template v-else>{{ sortedPlugins.length.toLocaleString('en-US') }} plugins</template>
</p>

<div ref="stickyScrollbar" class="plugin-table__scrollbar">
<div :style="{ width: scrollWidth + 'px' }" class="plugin-table__scrollbar-spacer" />
</div>
Expand All @@ -159,24 +185,52 @@ const columns: { field: SortField; label: string }[] = [
display: none;
}

/*
* Grid rather than <table>: content-visibility has no effect on internal table
* elements, so rows have to be ordinary grid boxes for the browser to be able
* to skip laying out the ones that are off screen.
*
* Each row carries the column track list itself rather than using `subgrid`.
* content-visibility implies layout containment, and containment forces a
* subgrid to become an independent grid - the rows would silently lose the
* parent's tracks and every cell would take a full row. Repeating an explicit
* template keeps the columns aligned precisely because it does not depend on
* what any sibling row contains.
*/
.plugin-table {
@apply w-full text-left text-sm;
min-width: 57rem;
}

.plugin-table__head {
background: #0d0d0d;
border-bottom: 1px solid #2a2a2a;
}

.plugin-table__head-row,
.plugin-table__row {
@apply grid items-center;
grid-template-columns: minmax(12rem, 2fr) 7rem 9rem minmax(14rem, 3fr) 9.5rem 5.5rem;
}

.plugin-table__head-cell {
@apply px-4 py-3 text-xs font-medium uppercase tracking-wider text-gray-500;
@apply px-4 py-3 text-xs font-medium uppercase tracking-wider text-gray-400;
white-space: nowrap;
}

.plugin-table__head-cell--sortable {
@apply cursor-pointer select-none;
}

.plugin-table__head-cell--sortable:focus-visible {
@apply outline-none;
box-shadow: inset 0 0 0 2px #ff6c21;
}

.plugin-table__head-cell-label {
@apply sr-only;
}

.plugin-table__head-cell--sortable:hover {
color: #ff6c21;
}
Expand All @@ -189,9 +243,25 @@ const columns: { field: SortField; label: string }[] = [
@apply text-gray-700;
}

/*
* The list runs to ~2000 rows. `content-visibility: auto` lets the browser skip
* style, layout and paint for rows outside the viewport; `contain-intrinsic-size`
* supplies the placeholder height so the scrollbar stays honest. The `auto`
* keyword means the real height is remembered once a row has been rendered, so
* the estimate only has to be close for rows that have never been on screen.
*/
.plugin-table__row {
@apply transition-colors duration-75;
border-bottom: 1px solid #1e1e1e;
content-visibility: auto;
contain-intrinsic-size: auto 41px;
}

/* Rows wrap to three description lines once the grid is horizontally scrolled. */
@media (max-width: 639px) {
.plugin-table__row {
contain-intrinsic-size: auto 61px;
}
}

.plugin-table__row--even {
Expand Down Expand Up @@ -254,6 +324,14 @@ const columns: { field: SortField; label: string }[] = [
color: #fff;
}

.plugin-table__empty {
@apply px-4 py-10 text-center text-sm text-gray-400;
}

.plugin-table__count {
@apply px-4 py-3 text-xs text-gray-400;
}

.plugin-table__scrollbar {
@apply sticky bottom-0 overflow-x-auto overflow-y-hidden;
scrollbar-width: thin;
Expand Down