Adjust category rendering

This commit is contained in:
Eric Fennis
2023-04-28 20:52:39 +02:00
parent 9a2fc75a9a
commit 5dd183fc92
14 changed files with 288 additions and 57 deletions
@@ -1,5 +1,6 @@
import fs from 'fs'
import path from 'path'
import { getAllData } from '../lib/icons';
export function getAllCategoryFiles() {
const fileNames = fs.readdirSync(path.join('../categories')).filter((file) => path.extname(file) === '.json');
@@ -15,8 +16,20 @@ export function getAllCategoryFiles() {
export default {
async load() {
const icons = await getAllData()
const categories = getAllCategoryFiles()
const categoryIconCounts = Object.fromEntries(
categories.map(({ name }) => {
const categoryIcons = icons.filter(({ categories }) => categories.includes(name))
return [name, categoryIcons.length];
})
)
return {
categories: getAllCategoryFiles(),
categories,
categoryIconCounts,
}
}
}
@@ -6,13 +6,10 @@ import VPLink from 'vitepress/dist/client/theme-default/components/VPLink.vue'
import { isActive } from 'vitepress/dist/client/theme-default/support/utils'
import { onContentUpdated } from 'vitepress'
import { useActiveAnchor } from '../composables/useActiveAnchor'
import { data } from './CategoriesList.data'
import { useRouter } from 'vitepress';
import { data } from './CategoryList.data'
import CategoryListItem from './CategoryListItem.vue'
const { go } = useRouter()
const { frontmatter, theme, params, page } = useData()
const { page } = useData()
const categoriesIsActive = computed(() => {
return isActive(page.value.relativePath, '/icons/categories');
@@ -22,17 +19,6 @@ const overviewIsActive = computed(() => {
return isActive(page.value.relativePath, '/icons/');
});
interface Header {
level: number
title: string
slug: string
link: string
children: Header[]
}
type MenuItem = Omit<Header, 'slug' | 'children'> & {
children?: MenuItem[]
}
const headers = computed(() => {
const linkPrefix = page.value.relativePath.startsWith('icons/categories')
? '' : '/icons/categories'
@@ -61,7 +47,7 @@ useActiveAnchor(container, marker)
<div class="content">
<div class="outline-marker" ref="marker" />
<nav aria-labelledby="doc-outline-aria-label">
<VPDocOutlineItem :headers="headers" :root="true" />
<CategoryListItem :headers="headers" :root="true" />
</nav>
</div>
</div>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { useCategoryView } from '../composables/useCategoryView'
interface Header {
level: number
title: string
slug: string
link: string
children: Header[]
}
type MenuItem = Omit<Header, 'slug' | 'children'> & {
children?: MenuItem[]
}
defineProps<{
headers: MenuItem[]
root?: boolean
}>()
const { selectedCategory } = useCategoryView()
function onClick(event: Event) {
const id = '#' + (event.target as HTMLAnchorElement).href!.split('#')[1]
const decodedId = decodeURIComponent(id)
selectedCategory.value = decodedId.replace('#', '')
const heading = document.querySelector<HTMLAnchorElement>(decodedId)
heading?.focus()
}
</script>
<template>
<ul :class="root ? 'root' : 'nested'">
<li v-for="{ children, link, title } in headers">
<a
class="outline-link"
:href="link"
@click="onClick"
:title="title"
>
{{ title }}
</a>
</li>
</ul>
</template>
<style scoped>
.root {
position: relative;
z-index: 1;
}
.nested {
padding-left: 13px;
}
.outline-link {
display: block;
line-height: 28px;
color: var(--vp-c-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.5s;
font-weight: 500;
}
.outline-link:hover,
.outline-link.active {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.outline-link.nested {
padding-left: 13px;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { vIntersectionObserver } from '@vueuse/components'
const emit = defineEmits(['end-of-page'])
const onIntersectionObserver: IntersectionObserverCallback = ([{ isIntersecting }]) => {
if (isIntersecting) {
emit('end-of-page')
}
}
</script>
<template>
<div v-intersection-observer="onIntersectionObserver" />
</template>
@@ -1,58 +1,97 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import type { IconEntity, Category } from '../types'
import IconItem from './IconItem.vue'
import IconDetailOverlay from './IconDetailOverlay.vue'
import IconGrid from './IconGrid.vue'
import useSearch from '../lib/useSearch'
import Input from './Input.vue'
import EndOfPage from './EndOfPage.vue'
import { useCategoryView } from '../composables/useCategoryView'
const props = defineProps<{
icons: IconEntity[]
categories: Category[]
}>()
const { selectedCategory } = useCategoryView()
const activeIconName = ref('')
const searchQuery = ref(
typeof window === 'undefined'
? ''
: (
new URLSearchParams(window.location.search).get('search')
|| ''
)
)
const searchInput = ref()
const isSearching = computed(() => !!searchQuery.value)
function setActiveIconName(name: string) {
console.log('activeIcon', name);
activeIconName.value = name
}
const searchResults = useSearch(searchQuery, props.icons, [
{ name: 'name', weight: 2 },
{ name: 'tags', weight: 1 },
])
const categories = computed(() => {
if( !props.categories?.length || !props.icons?.length ) return []
return props.categories.map(({ name, title }) => {
const categoryIcons = props.icons.filter(({ categories }) => categories.includes(name))
// const isSearching = icons.length !== data.length;
// const searchResults = isSearching
// ? categoryIcons.filter(icon => icons.some((item) => item?.name === icon?.name))
// : categoryIcons;
return props.categories.map(({ name, title }) => {
const categoryIcons = props.icons.filter(
({ categories }) => categories.includes(name)
)
const searchedCategoryIcons = isSearching
? categoryIcons.filter(icon => searchResults.value.some((item) => item?.name === icon?.name))
: categoryIcons;
return {
title,
name,
icons: categoryIcons,
// isActive: name === activeCategory,
icons: searchedCategoryIcons,
};
})
.filter(({ icons }) => icons.length)
})
watch(searchQuery, (searchString) => {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('search', searchString);
nextTick(() => {
window.history.replaceState({}, '', newUrl)
})
})
onMounted(() => {
const searchParams = new URLSearchParams(window.location.search);
if(searchParams.has('search')) {
searchInput.value.focus()
}
})
const activeIcon = computed(() =>
props.icons?.find((icon) => icon.name === activeIconName.value)
)
</script>
<template>
<Input type="search" placeholder="Search icons..." v-model="searchQuery" class="input-wrapper" ref="searchInput"/>
<section class="category" v-for="category in categories" :key="category.name">
<h2 class="title" :id="category.name">
<a class="header-anchor" :href="`#${category.name}`" :aria-label="`Permalink to &quot;${category.title}&quot;`">&ZeroWidthSpace;</a>
{{ category.title }}
</h2>
<IconGrid :activeIcon="activeIconName" :icons="category.icons" @setActiveIcon="setActiveIconName"/>
</section>
<h2 class="title" :id="category.name">
<a class="header-anchor" :href="`#${category.name}`" :aria-label="`Permalink to &quot;${category.title}&quot;`">&ZeroWidthSpace;</a>
{{ category.title }}
</h2>
<IconGrid
:activeIcon="activeIconName"
:icons="category.icons"
@setActiveIcon="setActiveIconName"
/>
</section>
<IconDetailOverlay :icon="activeIcon" @close="setActiveIconName('')"/>
</template>
@@ -60,7 +99,6 @@ const activeIcon = computed(() =>
.icons {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
/* padding: 32px 32px 96px; */
gap: 8px;
width: 100%;
}
@@ -77,4 +115,24 @@ const activeIcon = computed(() =>
.category {
margin-bottom: 32px;
}
.input-wrapper:deep(.input) {
padding: 12px 24px;
font-size: 14px;
height: 48px;
}
.input-wrapper {
margin-bottom: 16px;
}
.hidden-category {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
</style>
+39 -2
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import type { IconEntity } from '../types'
import { useMediaQuery, useOffsetPagination } from '@vueuse/core'
import IconItem from './IconItem.vue'
import IconDetailOverlay from './IconDetailOverlay.vue'
import IconGrid from './IconGrid.vue'
import Input from './Input.vue'
import useSearch from '../lib/useSearch'
import EndOfPage from './EndOfPage.vue'
const props = defineProps<{
icons: IconEntity[]
@@ -20,12 +22,45 @@ const searchQuery = ref(
|| ''
)
)
const isExtraLargeScreen = useMediaQuery('(min-width: 1440px)');
const isLargeScreen = useMediaQuery('(min-width: 1280px)');
const isMediumScreen = useMediaQuery('(min-width: 960px)');
const isSmallScreen = useMediaQuery('(min-width: 640px)');
const searchInput = ref()
const pageSize = computed(() => {
if(isExtraLargeScreen.value) {
return 16 * 16;
}
if(isLargeScreen.value) {
return 16 * 12;
}
if(isMediumScreen.value) {
return 13 * 12;
}
if(isSmallScreen.value) {
return 10 * 10;
}
return 10 * 5;
})
const searchResults = useSearch(searchQuery, props.icons, [
{ name: 'name', weight: 2 },
{ name: 'tags', weight: 1 },
])
const { next, currentPage } = useOffsetPagination( { pageSize })
const paginatedIcons = computed(() => {
const end = pageSize.value * currentPage.value
return searchResults.value.slice(0, end)
})
function setActiveIconName(name: string) {
activeIconName.value = name
}
@@ -42,6 +77,8 @@ watch(searchQuery, (searchString) => {
nextTick(() => {
window.history.replaceState({}, '', newUrl)
})
currentPage.value = 1
})
onMounted(() => {
@@ -49,13 +86,13 @@ onMounted(() => {
if(searchParams.has('search')) {
searchInput.value.focus()
}
})
</script>
<template>
<Input type="search" placeholder="Search icons..." v-model="searchQuery" class="input-wrapper" ref="searchInput"/>
<IconGrid :activeIcon="activeIconName" :icons="searchResults" @setActiveIcon="setActiveIconName"/>
<IconGrid :activeIcon="activeIconName" :icons="paginatedIcons" @setActiveIcon="setActiveIconName"/>
<EndOfPage @end-of-page="next"/>
<IconDetailOverlay :icon="activeIcon" @close="setActiveIconName('')"/>
</template>
@@ -1,6 +1,10 @@
import { onMounted, onUpdated, onUnmounted } from 'vue';
import { throttleAndDebounce } from 'vitepress/dist/client/theme-default/support/utils'
/*
* This file is compied and adjusted from vitepress/dist/client/theme-default/composables/useActiveAnchor.ts
*/
export function useActiveAnchor(container, marker) {
const onScroll = throttleAndDebounce(setActiveLink, 100);
let prevActiveLink = null;
@@ -0,0 +1,27 @@
/* eslint-disable no-console */
import {
ref, inject, Ref
} from 'vue';
export const CATEGORY_VIEW_CONTEXT = Symbol('categoryView');
interface CategoryViewContext {
selectedCategory: Ref<string>
categoryCounts: Ref<Record<string, number>>
}
export const categoryViewContext = {
selectedCategory: ref(''),
categoryCounts: ref({}),
};
export function useCategoryView(): CategoryViewContext {
const context = inject<CategoryViewContext>(CATEGORY_VIEW_CONTEXT);
if (!context) {
throw new Error('useCategoryView must be used with categoryView context');
}
return context;
}
+2 -8
View File
@@ -1,7 +1,7 @@
/* eslint-disable no-console */
import {
ref, provide, inject, Ref
ref, inject, Ref
} from 'vue';
export const ICON_STYLE_CONTEXT = Symbol('size');
@@ -12,18 +12,12 @@ interface IconSizeContext {
color: Ref<string>
}
export const context = {
export const iconStyleContext = {
size: ref(24),
strokeWidth: ref(2),
color: ref('currentColor'),
};
export function useIconStyleProvider(): IconSizeContext {
provide(ICON_STYLE_CONTEXT, context);
return context;
}
export function useIconStyleContext(): IconSizeContext{
const context = inject<IconSizeContext>(ICON_STYLE_CONTEXT);
+4 -2
View File
@@ -4,7 +4,8 @@ import './style.css'
import { Theme } from 'vitepress'
import IconsSidebarNavAfter from './layouts/IconsSidebarNavAfter.vue'
import HomeHeroIconsCard from '../components/HomeHeroIconsCard.vue'
import { ICON_STYLE_CONTEXT, context } from '../composables/useIconStyle'
import { ICON_STYLE_CONTEXT, iconStyleContext } from '../composables/useIconStyle'
import { CATEGORY_VIEW_CONTEXT, categoryViewContext } from '../composables/useCategoryView'
const theme: Partial<Theme> = {
extends: DefaultTheme,
@@ -15,7 +16,8 @@ const theme: Partial<Theme> = {
})
},
enhanceApp({ app }) {
app.provide(ICON_STYLE_CONTEXT, context)
app.provide(ICON_STYLE_CONTEXT, iconStyleContext)
app.provide(CATEGORY_VIEW_CONTEXT, categoryViewContext)
}
}
@@ -3,7 +3,7 @@ import { onMounted } from 'vue'
import { useData } from 'vitepress'
import { useSidebar } from 'vitepress/dist/client/theme-default/composables/sidebar.js'
import CategoriesList from '../../components/CategoriesList.vue'
import CategoryList from '../../components/CategoryList.vue'
import IconCustomizer from '../../components/IconCustomizer.vue'
const { frontmatter, page } = useData()
@@ -19,6 +19,6 @@ onMounted(() => {
<template>
<div>
<IconCustomizer/>
<CategoriesList v-if="page?.relativePath?.startsWith?.('icons')"/>
<CategoryList v-if="page?.relativePath?.startsWith?.('icons')"/>
</div>
</template>
+2 -2
View File
@@ -83,7 +83,7 @@
position: relative;
}
}
/*
html:has(* .outline-link:target) {
scroll-behavior: smooth;
}
} */
+2 -1
View File
@@ -26,14 +26,15 @@
},
"dependencies": {
"@headlessui/vue": "^1.7.13",
"@vueuse/components": "^10.1.0",
"@vueuse/core": "^10.1.0",
"element-to-path": "^1.2.1",
"fuse.js": "^6.5.3",
"js-yaml": "^4.1.0",
"jszip": "^3.7.0",
"lodash-es": "^4.17.21",
"lucide-vue-next": "workspace:*",
"lucide-react": "workspace:*",
"lucide-vue-next": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"svg-pathdata": "^6.0.3",
+14
View File
@@ -46,6 +46,9 @@ importers:
'@headlessui/vue':
specifier: ^1.7.13
version: 1.7.13(vue@3.2.47)
'@vueuse/components':
specifier: ^10.1.0
version: 10.1.0(vue@3.2.47)
'@vueuse/core':
specifier: ^10.1.0
version: 10.1.0(vue@3.2.47)
@@ -10265,6 +10268,17 @@ packages:
vue: 3.2.45
dev: true
/@vueuse/components@10.1.0(vue@3.2.47):
resolution: {integrity: sha512-U3c5tPUAxfwWoBSpFbCNFMEM8/jSZ0+5M1n9YWcahyb+uRNo4UmsofX/Hzs6K56PSm0g+uuhZVwSnMq8ttD8Lw==}
dependencies:
'@vueuse/core': 10.1.0(vue@3.2.47)
'@vueuse/shared': 10.1.0(vue@3.2.47)
vue-demi: 0.14.0(vue@3.2.47)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/@vueuse/core@10.1.0(vue@3.2.47):
resolution: {integrity: sha512-3Znoa5m5RO+z4/C9w6DRaKTR3wCVJvD5rav8HTDGsr+7rOZRHtcgFJ8NcCs0ZvIpmev2kExTa311ns5j2RbzDQ==}
dependencies: