Skip to content

Commit

Permalink
feat(client): add components package
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Apr 20, 2024
1 parent 583de0f commit 3aa55b2
Show file tree
Hide file tree
Showing 13 changed files with 679 additions and 4 deletions.
10 changes: 10 additions & 0 deletions packages/components/client/form/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { App } from 'vue'
import form from 'schemastery-vue'

export { form as SchemaBase }

export * from 'schemastery-vue'

export default function (app: App) {
app.use(form)
}
8 changes: 8 additions & 0 deletions packages/components/client/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.k-link {
cursor: pointer;
text-decoration: underline;

&:hover {
text-decoration: underline;
}
}
16 changes: 16 additions & 0 deletions packages/components/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { App } from 'vue'
import form from './form'
import virtual from './virtual'
import Comment from './k-comment.vue'

import './index.scss'

export * from 'cosmokit'
export * from './form'
export * from './virtual'

export default function (app: App) {
app.use(form)
app.use(virtual)
app.component('k-comment', Comment)
}
81 changes: 81 additions & 0 deletions packages/components/client/k-comment.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<template>
<div class="k-comment" :class="type === 'error' ? 'danger' : type">
<k-icon :name="icon"></k-icon>
<slot>
<p>{{ title }}</p>
</slot>
</div>
</template>

<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps({
type: { type: String, default: 'primary' },
title: { type: String, required: false },
})
const icon = computed(() => {
switch (props.type) {
case 'success': return 'check-full'
case 'error': return 'times-full'
case 'warning': return 'exclamation-full'
default: return 'info-full'
}
})
</script>

<style lang="scss" scoped>
@mixin apply-color($name) {
&.#{$name} {
border-left-color: var(--k-color-#{$name});
background-color: var(--k-color-#{$name}-fade);
.k-icon {
color: var(--k-color-#{$name});
}
}
}
.k-comment {
padding: 1px 1.5rem;
margin: 2rem 0;
border-left-width: 4px;
border-left-style: solid;
position: relative;
line-height: 1.7;
transition: 0.3s ease;
font-weight: 500;
> .k-icon {
position: absolute;
top: 1.25em;
left: -12px;
width: 20px;
height: 19px;
border-radius: 100%;
background-color: var(--k-card-bg);
}
@include apply-color(primary);
@include apply-color(secondary);
@include apply-color(warning);
@include apply-color(success);
@include apply-color(danger);
& + & {
margin-top: -1rem;
}
:deep(a) {
text-decoration: underline;
&:hover {
text-decoration: underline;
}
}
}
</style>
22 changes: 22 additions & 0 deletions packages/components/client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"rootDir": ".",
"target": "es2022",
"module": "esnext",
"declaration": true,
"jsx": "preserve",
"noEmit": true,
"composite": true,
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "node",
"strictBindCallApply": true,
"types": [
"@cordisjs/client/global",
],
},
"include": [
".",
],
}
8 changes: 8 additions & 0 deletions packages/components/client/virtual/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { App } from 'vue'
import VirtualList from './list.vue'

export { VirtualList }

export default function (app: App) {
app.component('virtual-list', VirtualList)
}
69 changes: 69 additions & 0 deletions packages/components/client/virtual/item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Comment, defineComponent, Directive, Fragment, h, Ref, ref, Text, VNode, watch, withDirectives } from 'vue'

export const useRefDirective = (ref: Ref): Directive<Element> => ({
mounted(el) {
ref.value = el
},
updated(el) {
ref.value = el
},
beforeUnmount() {
ref.value = null
},
})

function findFirstLegitChild(node: VNode[]): VNode {
if (!node) return null
for (const child of node) {
if (typeof child === 'object') {
switch (child.type) {
case Comment:
continue
case Text:
break
case Fragment:
return findFirstLegitChild(child.children as VNode[])
default:
if (typeof child.type === 'string') return child
return child
}
}
return h('span', child)
}
}

const VirtualItem = defineComponent({
props: {
class: {},
},

emits: ['resize'],

setup(props, { attrs, slots, emit }) {
let resizeObserver: ResizeObserver
const root = ref<HTMLElement>()

watch(root, (value) => {
resizeObserver?.disconnect()
if (!value) return

resizeObserver = new ResizeObserver(dispatchSizeChange)
resizeObserver.observe(value)
})

function dispatchSizeChange() {
if (!root.value) return
const marginTop = +(getComputedStyle(root.value).marginTop.slice(0, -2))
emit('resize', root.value.offsetHeight + marginTop)
}

const directive = useRefDirective(root)

return () => {
const head = findFirstLegitChild(slots.default?.(attrs))
return withDirectives(head, [[directive]])
}
},
})

export default VirtualItem
161 changes: 161 additions & 0 deletions packages/components/client/virtual/list.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<template>
<el-scrollbar ref="root" @scroll="onScroll" :max-height="maxHeight">
<virtual-item v-if="$slots.header" @resize="virtual.saveSize('header', $event)">
<div><slot name="header"></slot></div>
</virtual-item>
<component :is="tag" class="virtual-list-wrapper" :style="wrapperStyle">
<virtual-item v-for="(item, index) in dataShown"
@resize="virtual.saveSize(getKey(item), $event)">
<slot v-bind="item" :index="index + range.start"></slot>
</virtual-item>
</component>
<virtual-item v-if="$slots.footer" @resize="virtual.saveSize('footer', $event)">
<div><slot name="footer"></slot></div>
</virtual-item>
<div ref="shepherd"></div>
</el-scrollbar>
</template>

<script lang="ts" setup>
import { ref, computed, watch, nextTick, onActivated, onMounted, PropType } from 'vue'
import type { ElScrollbar } from 'element-plus'
import Virtual from './virtual'
import VirtualItem from './item'
const emit = defineEmits(['item-click', 'scroll', 'top', 'bottom', 'update:activeKey'])
const props = defineProps({
keyName: { type: String, default: 'id' },
data: { type: Array, required: true },
count: { default: 50 },
estimated: { default: 50 },
tag: { default: 'div' },
pinned: Boolean,
activeKey: { default: '' },
threshold: { default: 0 },
maxHeight: String,
activate: {
type: String as PropType<'top' | 'bottom' | 'current'>,
default: 'bottom',
},
})
const dataShown = computed<any[]>(() => props.data.slice(range.start, range.end))
const root = ref<typeof ElScrollbar>()
watch(() => props.data.length, () => {
const { scrollTop, clientHeight, scrollHeight } = root.value.wrapRef
if (!props.pinned || Math.abs(scrollTop + clientHeight - scrollHeight) < 1) {
nextTick(scrollToBottom)
}
virtual.updateUids(getUids())
virtual.handleDataChange()
})
watch(() => props.activeKey, (value) => {
if (!value) return
emit('update:activeKey', null)
scrollToUid(value, true)
})
const shepherd = ref<HTMLElement>()
const wrapperStyle = computed(() => {
const { padFront, padBehind } = range
return { padding: `${padFront}px 0px ${padBehind}px` }
})
const virtual = new Virtual({
count: props.count,
estimated: props.estimated,
buffer: Math.floor(props.count / 3),
uids: getUids(),
})
const range = virtual.range
function getUids() {
return props.data.map(getKey)
}
function getKey(item: string) {
const keys = props.keyName.split('.')
return keys.reduce((obj, key) => obj[key], item)
}
onMounted(() => {
if (props.activeKey) {
scrollToUid(props.activeKey)
} else {
scrollToBottom()
}
})
function scrollToOffset(offset: number, smooth = false) {
if (smooth) {
root.value.wrapRef.scrollTo({ top: offset, behavior: 'smooth' })
} else {
root.value.wrapRef.scrollTop = offset
}
}
// set current scroll position to a expectant index
function scrollToUid(uid: string, smooth = false) {
scrollToOffset(virtual.getUidOffset(uid), smooth)
}
function scrollToBottom() {
if (shepherd.value) {
const offset = shepherd.value.offsetTop
scrollToOffset(offset)
// check if it's really scrolled to the bottom
// maybe list doesn't render and calculate to last range
// so we need retry in next event loop until it really at bottom
setTimeout(() => {
const offset = Math.ceil(root.value.wrapRef.scrollTop)
const clientLength = Math.ceil(root.value.wrapRef.clientHeight)
const scrollLength = Math.ceil(root.value.wrapRef.scrollHeight)
if (offset + clientLength < scrollLength) {
scrollToBottom()
}
}, 3)
}
}
let scrollTop = 0
onActivated(() => {
if (props.activate === 'bottom') {
scrollToBottom()
} else if (props.activate === 'current') {
root.value.setScrollTop(scrollTop)
}
})
function onScroll(ev: MouseEvent) {
const offset = Math.ceil(scrollTop = root.value.wrapRef.scrollTop)
const clientLength = Math.ceil(root.value.wrapRef.clientHeight)
const scrollLength = Math.ceil(root.value.wrapRef.scrollHeight)
// iOS scroll-spring-back behavior will make direction mistake
if (offset < 0 || (offset + clientLength > scrollLength + 1) || !scrollLength) {
return
}
virtual.handleScroll(offset)
emitEvent(offset, clientLength, scrollLength, ev)
}
function emitEvent(offset: number, clientLength: number, scrollLength: number, ev: MouseEvent) {
emit('scroll', ev, virtual.range)
if (virtual.direction < 0 && !!props.data.length && (offset - props.threshold <= 0)) {
emit('top')
} else if (virtual.direction > 0 && (offset + clientLength + props.threshold >= scrollLength)) {
emit('bottom')
}
}
</script>
Loading

0 comments on commit 3aa55b2

Please sign in to comment.