Skip to content

Commit 1d98e21

Browse files
committed
feat: add sort button in icon-resources
1 parent 8a4d7b2 commit 1d98e21

4 files changed

Lines changed: 247 additions & 8 deletions

File tree

‎docs-source/src/.vitepress/components/IconResources.vue‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,20 @@ import { isLocalizedRouteSwitch } from '../configs/i18n';
1111
import { useComponentMessages } from '../theme/i18n';
1212
import CategoryNavigation from './icon-resources/CategoryNavigation.vue';
1313
import ResourceResults from './icon-resources/ResourceResults.vue';
14+
import ResourceSorting from './icon-resources/ResourceSorting.vue';
1415
import {
1516
categorySpecs,
1617
loadIconResources,
18+
resolveLabel,
1719
type CategoryId,
1820
type ManifestLabel
1921
} from '../data/icon-resources';
2022
import { iconResourcesState, resetIconResourcesState } from './icon-resources/utils/state';
2123
22-
const { text } = useComponentMessages('iconResources');
24+
const { locale, text } = useComponentMessages('iconResources');
2325
const route = useRoute();
2426
const mountedRoutePath = route.path;
25-
const { activeCategoryId, categories, fatalError, loading, query } = iconResourcesState;
27+
const { activeCategoryId, categories, fatalError, loading, query, sortMode } = iconResourcesState;
2628
const labelSearchValues = (label: ManifestLabel | undefined) =>
2729
typeof label === 'string' ? [label] : Object.values(label ?? {});
2830
const systemMenuOpen = ref(false);
@@ -49,6 +51,23 @@ const filteredEntries = computed(() => {
4951
entry.effectiveRule.contributors
5052
].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery)));
5153
});
54+
const sortedEntries = computed(() => {
55+
const entries = filteredEntries.value;
56+
if (sortMode.value === 'addedAsc') return entries;
57+
if (sortMode.value === 'addedDesc') return [...entries].reverse();
58+
const collator = new Intl.Collator(locale.value, {
59+
numeric: true,
60+
sensitivity: 'base',
61+
usage: 'sort'
62+
});
63+
const direction = sortMode.value === 'nameAsc' ? 1 : -1;
64+
return [...entries].sort((left, right) => direction * (
65+
collator.compare(
66+
resolveLabel(left.effectiveRule.label, locale.value) ?? left.key,
67+
resolveLabel(right.effectiveRule.label, locale.value) ?? right.key
68+
) || collator.compare(left.key, right.key)
69+
));
70+
});
5271
5372
const resolveCategoryId = (value: string | null) =>
5473
categorySpecs.find((category) => category.path === value)?.id;
@@ -74,7 +93,7 @@ const refreshImageViewer = () => nextTick(() => {
7493
});
7594
});
7695
77-
watch([filteredEntries, loading], refreshImageViewer, { flush: 'post' });
96+
watch([sortedEntries, loading], refreshImageViewer, { flush: 'post' });
7897
onBeforeUnmount(() => {
7998
imageZoom?.detach();
8099
if (!isLocalizedRouteSwitch(mountedRoutePath, window.location.pathname)) resetIconResourcesState();
@@ -101,14 +120,17 @@ onMounted(async () => {
101120

102121
<template>
103122
<section ref="iconResourcesShell" class="icon-resources-shell">
104-
<CategoryNavigation v-model:system-menu-open="systemMenuOpen" :active-category-id="activeCategoryId"
105-
:category-count="categoryCount" :system-categories="systemCategories"
106-
:system-button-label="systemButtonLabel" :text="text" @select="selectCategory" />
123+
<div class="resource-toolbar">
124+
<CategoryNavigation v-model:system-menu-open="systemMenuOpen" :active-category-id="activeCategoryId"
125+
:category-count="categoryCount" :system-categories="systemCategories"
126+
:system-button-label="systemButtonLabel" :text="text" @select="selectCategory" />
127+
<ResourceSorting v-model="sortMode" :text="text" />
128+
</div>
107129
<label class="search-box">
108130
<span aria-hidden="true">⌕</span>
109131
<input v-model="query" type="search" :placeholder="text.search">
110132
</label>
111-
<ResourceResults :fatal-error="fatalError" :filtered-entries="filteredEntries" :loading="loading"
133+
<ResourceResults :fatal-error="fatalError" :filtered-entries="sortedEntries" :loading="loading"
112134
:selected-category="selectedCategory" :text="text" />
113135
</section>
114136
</template>
@@ -118,6 +140,17 @@ onMounted(async () => {
118140
margin-top: 24px;
119141
}
120142
143+
.resource-toolbar {
144+
display: flex;
145+
align-items: flex-start;
146+
gap: 8px;
147+
148+
:deep(.category-tabs) {
149+
flex: 1 1 auto;
150+
min-width: 0;
151+
}
152+
}
153+
121154
.search-box {
122155
display: flex;
123156
align-items: center;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<script setup lang="ts">
2+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
3+
import { DownOutlined, SortAscendingOutlined, SortDescendingOutlined } from '@ant-design/icons-vue';
4+
import type { IconResourceSortMode } from './utils/state';
5+
6+
type SortGroup = 'name' | 'added';
7+
8+
interface SortingText {
9+
sort: string;
10+
sortOptions: {
11+
added: string;
12+
ascending: string;
13+
descending: string;
14+
name: string;
15+
};
16+
}
17+
18+
const props = defineProps<{
19+
modelValue: IconResourceSortMode;
20+
text: SortingText;
21+
}>();
22+
23+
const emit = defineEmits<{
24+
'update:modelValue': [mode: IconResourceSortMode];
25+
}>();
26+
27+
const sortGroups: SortGroup[] = ['name', 'added'];
28+
const sortRoot = ref<HTMLElement>();
29+
const menuOpen = ref(false);
30+
const activeGroup = computed(() => props.modelValue.startsWith('name') ? 'name' : 'added');
31+
const descending = computed(() => props.modelValue.endsWith('Desc'));
32+
const resolveSortMode = (group: SortGroup, useDescending: boolean) => group === 'name'
33+
? useDescending ? 'nameDesc' : 'nameAsc'
34+
: useDescending ? 'addedDesc' : 'addedAsc';
35+
const toggleDirection = () => {
36+
emit('update:modelValue', resolveSortMode(activeGroup.value, !descending.value));
37+
menuOpen.value = false;
38+
};
39+
const selectSortGroup = (group: SortGroup) => {
40+
emit('update:modelValue', resolveSortMode(group, descending.value));
41+
menuOpen.value = false;
42+
};
43+
const closeMenuOnDocumentClick = (event: MouseEvent) => {
44+
if (!sortRoot.value?.contains(event.target as Node)) menuOpen.value = false;
45+
};
46+
const closeMenuOnEscape = (event: KeyboardEvent) => {
47+
if (event.key === 'Escape') menuOpen.value = false;
48+
};
49+
50+
onMounted(() => {
51+
document.addEventListener('click', closeMenuOnDocumentClick);
52+
document.addEventListener('keydown', closeMenuOnEscape);
53+
});
54+
onBeforeUnmount(() => {
55+
document.removeEventListener('click', closeMenuOnDocumentClick);
56+
document.removeEventListener('keydown', closeMenuOnEscape);
57+
});
58+
</script>
59+
60+
<template>
61+
<div ref="sortRoot" class="sort-control">
62+
<button type="button" class="sort-trigger sort-direction-trigger"
63+
:title="`${text.sort}: ${text.sortOptions[descending ? 'descending' : 'ascending']}`"
64+
:aria-label="`${text.sort}: ${text.sortOptions[descending ? 'descending' : 'ascending']}`"
65+
@click="toggleDirection">
66+
<SortDescendingOutlined v-if="descending" aria-hidden="true" />
67+
<SortAscendingOutlined v-else aria-hidden="true" />
68+
</button>
69+
<button type="button" class="sort-trigger sort-field-trigger" :class="{ active: menuOpen }" aria-haspopup="menu"
70+
:aria-expanded="menuOpen" :title="`${text.sort}: ${text.sortOptions[activeGroup]}`"
71+
:aria-label="`${text.sort}: ${text.sortOptions[activeGroup]}`" @click="menuOpen = !menuOpen">
72+
<DownOutlined aria-hidden="true" />
73+
</button>
74+
<Transition name="sort-menu">
75+
<div v-if="menuOpen" class="sort-menu" role="menu">
76+
<button v-for="group in sortGroups" :key="group" type="button" role="menuitemradio"
77+
:aria-checked="activeGroup === group" :class="{ active: activeGroup === group }"
78+
@click="selectSortGroup(group)">
79+
{{ text.sortOptions[group] }}
80+
</button>
81+
</div>
82+
</Transition>
83+
</div>
84+
</template>
85+
86+
<style scoped lang="scss">
87+
.sort-control {
88+
position: relative;
89+
display: flex;
90+
flex: 0 0 auto;
91+
}
92+
93+
.sort-trigger {
94+
display: grid;
95+
height: 38px;
96+
margin: 0;
97+
padding: 0;
98+
border: 1px solid var(--vp-c-divider);
99+
place-items: center;
100+
background: var(--vp-c-bg-soft);
101+
color: var(--vp-c-text-2);
102+
cursor: pointer;
103+
104+
:deep(.anticon) {
105+
font-size: 18px;
106+
}
107+
108+
&:hover,
109+
&.active {
110+
position: relative;
111+
z-index: 1;
112+
border-color: var(--vp-c-brand-1);
113+
color: var(--vp-c-brand-1);
114+
}
115+
116+
&.active {
117+
background: var(--vp-c-brand-soft);
118+
}
119+
}
120+
121+
.sort-direction-trigger {
122+
width: 38px;
123+
border-radius: 9px 0 0 9px;
124+
}
125+
126+
.sort-field-trigger {
127+
width: 30px;
128+
margin-left: -1px;
129+
border-radius: 0 9px 9px 0;
130+
131+
:deep(.anticon) {
132+
font-size: 12px;
133+
transition: transform 0.15s ease;
134+
}
135+
136+
&[aria-expanded='true'] :deep(.anticon) {
137+
transform: rotate(180deg);
138+
}
139+
}
140+
141+
.sort-menu {
142+
position: absolute;
143+
z-index: 20;
144+
top: calc(100% + 8px);
145+
right: 0;
146+
display: grid;
147+
min-width: 130px;
148+
padding: 8px;
149+
border: 1px solid var(--vp-c-divider);
150+
border-radius: 10px;
151+
background: var(--vp-c-bg-elv);
152+
box-shadow: var(--vp-shadow-3);
153+
154+
button {
155+
width: 100%;
156+
padding: 7px 10px;
157+
border: 0;
158+
border-radius: 6px;
159+
background: transparent;
160+
color: var(--vp-c-text-2);
161+
font: inherit;
162+
text-align: left;
163+
cursor: pointer;
164+
165+
&:hover {
166+
background: var(--vp-c-bg-soft);
167+
color: var(--vp-c-text-1);
168+
}
169+
170+
&.active {
171+
background: var(--vp-c-brand-soft);
172+
color: var(--vp-c-brand-1);
173+
}
174+
}
175+
}
176+
177+
.sort-menu-enter-active,
178+
.sort-menu-leave-active {
179+
transition: opacity 0.15s ease, transform 0.15s ease;
180+
}
181+
182+
.sort-menu-enter-from,
183+
.sort-menu-leave-to {
184+
opacity: 0;
185+
transform: translateY(-6px);
186+
}
187+
</style>

‎docs-source/src/.vitepress/components/icon-resources/utils/state.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { ref } from 'vue';
22
import type { CategoryId, LoadedCategory } from '../../../data/icon-resources';
33

4+
/** Sort modes supported by the icon resources view. */
5+
export type IconResourceSortMode = 'nameAsc' | 'nameDesc' | 'addedAsc' | 'addedDesc';
6+
47
/** Keeps the icon resources view state while VitePress swaps a localized page. */
58
export const iconResourcesState = {
69
activeCategoryId: ref<CategoryId>('app'),
710
categories: ref<LoadedCategory[]>([]),
811
fatalError: ref(''),
912
initialized: ref(false),
1013
loading: ref(true),
11-
query: ref('')
14+
query: ref(''),
15+
sortMode: ref<IconResourceSortMode>('nameAsc')
1216
};
1317

1418
/** Clears view state when the visitor leaves the icon resources page. */
@@ -19,4 +23,5 @@ export const resetIconResourcesState = () => {
1923
iconResourcesState.initialized.value = false;
2024
iconResourcesState.loading.value = true;
2125
iconResourcesState.query.value = '';
26+
iconResourcesState.sortMode.value = 'nameAsc';
2227
};

‎docs-source/src/.vitepress/locales/components.json‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121
"resourceDirectory": "Resource Directory",
2222
"result": "rules",
2323
"search": "Search by label, package name, or contributor",
24+
"sort": "Sort",
25+
"sortOptions": {
26+
"name": "By Name",
27+
"added": "By Addition",
28+
"ascending": "Ascending",
29+
"descending": "Descending"
30+
},
2431
"system": "System"
2532
},
2633
"submitAdaption": {
@@ -143,6 +150,13 @@
143150
"resourceDirectory": "资源目录",
144151
"result": "条规则",
145152
"search": "搜索名称、包名或贡献者",
153+
"sort": "排序",
154+
"sortOptions": {
155+
"name": "按名称",
156+
"added": "按添加",
157+
"ascending": "正序",
158+
"descending": "倒序"
159+
},
146160
"system": "系统"
147161
},
148162
"submitAdaption": {

0 commit comments

Comments
 (0)