mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-14 13:58:21 +00:00
Adjust category rendering
This commit is contained in:
+14
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-18
@@ -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>
|
||||
@@ -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 "${category.title}"`">​</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 "${category.title}"`">​</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
html:has(* .outline-link:target) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
} */
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user