Skip to content

Commit

Permalink
feat: added affix component (#352)
Browse files Browse the repository at this point in the history
* feat: create affix component

* chore: complete basic function

* test: added affix component unit test

* docs: added affix component document

---------

Co-authored-by: baiwusanyu-c <740132583@qq.com>
  • Loading branch information
vtrbo and baiwusanyu-c committed Dec 22, 2023
1 parent e7f5c61 commit 36469b3
Show file tree
Hide file tree
Showing 18 changed files with 550 additions and 55 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ Thanks to everyone who has already contributed to ikun-ui !
- [svelte](https://github.com/sveltejs/svelte)
- [unocss](https://github.com/unocss/unocss)
- [onu-ui](https://github.com/onu-ui/onu-ui)
- [naive-ui](https://github.com/tusen-ai/naive-ui)
- [element-plus](https://github.com/element-plus/element-plus)
7 changes: 7 additions & 0 deletions components/Affix/__test__/__snapshots__/affix.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Test: KAffix > props: cls 1`] = `"<head></head><body style=\\"height: 100px; overflow: auto;\\"><div class=\\"k-affix k-affix--test\\"></div></body>"`;
exports[`Test: KAffix > should work with \`position\` prop 1`] = `"<head></head><body style=\\"height: 100px; overflow: auto;\\"><div class=\\"k-affix k-affix--absolute-positioned\\"></div></body>"`;
exports[`Test: KAffix > should work with \`top\` prop 1`] = `"<head></head><body style=\\"height: 100px; overflow: auto;\\"><div class=\\"k-affix k-affix--affixed k-affix--test\\" style=\\"top: 120px;\\"></div></body>"`;
76 changes: 76 additions & 0 deletions components/Affix/__test__/affix.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, expect, test, describe, beforeEach, vi } from 'vitest';
import KAffix from '../src';
import { tick } from 'svelte';

const initHost = () => {
document.body.style.height = '100px';
document.body.style.overflow = 'auto';
};

beforeEach(() => {
initHost();
vi.useFakeTimers();
});
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});

describe('Test: KAffix', () => {
vi.mock('svelte', async () => {
const actual = (await vi.importActual('svelte')) as object;
return {
...actual,
// @ts-ignore
onMount: (await import('svelte/internal')).onMount
};
});

test('props: cls', async () => {
const instance = new KAffix({
target: document.body,
props: {
cls: 'k-affix--test'
}
});
expect(instance).toBeTruthy();
expect(
(document.documentElement as HTMLElement)!.innerHTML.includes('k-affix--test')
).toBeTruthy();
expect(document.documentElement.innerHTML).matchSnapshot();
});

test('should work with `top` prop', async () => {
const instance = new KAffix({
target: document.body,
props: {
cls: 'k-affix--test',
top: 120
}
});
expect(instance).toBeTruthy();
expect(
(document.documentElement as HTMLElement)!.innerHTML.includes('k-affix--affixed')
).not.toBeTruthy();
document.documentElement.scrollTop = 200;
document.documentElement.dispatchEvent(new Event('scroll', { bubbles: true }));
await tick();
await vi.advanceTimersByTimeAsync(300);
expect(document.body.innerHTML).toContain('top: 120px;');
expect(document.documentElement.innerHTML).matchSnapshot();
});

test('should work with `position` prop', async () => {
const instance = new KAffix({
target: document.body,
props: {
positionOption: 'absolute'
}
});
expect(instance).toBeTruthy();
expect(
(document.documentElement as HTMLElement)!.innerHTML.includes('k-affix--absolute-positioned')
).toBeTruthy();
expect(document.documentElement.innerHTML).matchSnapshot();
});
});
48 changes: 48 additions & 0 deletions components/Affix/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "@ikun-ui/affix",
"version": "0.0.16",
"type": "module",
"main": "src/index.ts",
"types": "src/index.d.ts",
"svelte": "src/index.ts",
"keywords": [
"svelte",
"svelte3",
"web component",
"component",
"react",
"vue",
"svelte-kit",
"dx"
],
"files": [
"dist",
"package.json"
],
"scripts": {
"build": "npm run build:js && npm run build:svelte",
"build:js": "tsc -p . --outDir dist/ --rootDir src/",
"build:svelte": "svelte-strip strip src/ dist",
"publish:pre": "node ../../scripts/pre-publish.js",
"publish:npm": "pnpm run publish:pre && pnpm publish --no-git-checks --access public"
},
"publishConfig": {
"access": "public",
"main": "dist/index.js",
"module": "dist/index.js",
"svelte": "dist/index.js",
"types": "dist/index.d.ts"
},
"dependencies": {
"@ikun-ui/icon": "workspace:*",
"@ikun-ui/utils": "workspace:*",
"baiwusanyu-utils": "^1.0.16",
"clsx": "^2.0.0"
},
"devDependencies": {
"@tsconfig/svelte": "^5.0.2",
"svelte-strip": "^2.0.0",
"tslib": "^2.6.2",
"typescript": "^5.3.2"
}
}
123 changes: 123 additions & 0 deletions components/Affix/src/index.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<script lang="ts">
import { getPrefixCls } from '@ikun-ui/utils';
import { clsx } from 'clsx';
import type { KAffixProps } from './types';
import type { ScrollTarget } from './utils';
import { unwrapElement, getScrollTop, getRect, beforeNextFrameOnce } from './utils';
import { onDestroy, onMount } from 'svelte';
export let cls: KAffixProps['cls'] = undefined;
export let attrs: KAffixProps['attrs'] = {};
export let listenTo: KAffixProps['listenTo'] = undefined;
export let top: KAffixProps['top'] = undefined;
export let bottom: KAffixProps['bottom'] = undefined;
export let triggerTop: KAffixProps['triggerTop'] = undefined;
export let triggerBottom: KAffixProps['triggerBottom'] = undefined;
export let positionOption: KAffixProps['positionOption'] = 'fixed';
let scrollTarget: ScrollTarget | null = null;
let stickToTopRef = false;
let stickToBottomRef = false;
let bottomAffixedTriggerScrollTopRef: number | null = null;
let topAffixedTriggerScrollTopRef: number | null = null;
$: affixedRef = stickToBottomRef || stickToTopRef;
$: mergedOffsetTopRef = triggerTop ?? top;
$: mergedTopRef = top ?? triggerTop;
$: mergedBottomRef = bottom ?? triggerBottom;
$: mergedOffsetBottomRef = triggerBottom ?? bottom;
let selfRef: Element | null = null;
const init = (): void => {
if (listenTo) {
scrollTarget = unwrapElement(listenTo);
} else {
scrollTarget = document;
}
if (scrollTarget) {
scrollTarget.addEventListener('scroll', handleScroll);
handleScroll();
}
};
function handleScroll(): void {
beforeNextFrameOnce(doHandleScroll);
}
function doHandleScroll(): void {
const selfEl = selfRef;
if (!scrollTarget || !selfEl) return;
const scrollTop = getScrollTop(scrollTarget);
if (affixedRef) {
if (topAffixedTriggerScrollTopRef !== null && scrollTop < topAffixedTriggerScrollTopRef) {
stickToTopRef = false;
topAffixedTriggerScrollTopRef = null;
}
if (
bottomAffixedTriggerScrollTopRef !== null &&
scrollTop > bottomAffixedTriggerScrollTopRef
) {
stickToBottomRef = false;
bottomAffixedTriggerScrollTopRef = null;
}
return;
}
const containerRect = getRect(scrollTarget);
const affixRect = selfEl.getBoundingClientRect();
const pxToTop = affixRect.top - containerRect.top;
const pxToBottom = containerRect.bottom - affixRect.bottom;
const mergedOffsetTop = mergedOffsetTopRef;
const mergedOffsetBottom = mergedOffsetBottomRef;
if (mergedOffsetTop !== undefined && pxToTop <= mergedOffsetTop) {
stickToTopRef = true;
topAffixedTriggerScrollTopRef = scrollTop - (mergedOffsetTop - pxToTop);
} else {
stickToTopRef = false;
topAffixedTriggerScrollTopRef = null;
}
if (mergedOffsetBottom !== undefined && pxToBottom <= mergedOffsetBottom) {
stickToBottomRef = true;
bottomAffixedTriggerScrollTopRef = scrollTop + mergedOffsetBottom - pxToBottom;
} else {
stickToBottomRef = false;
bottomAffixedTriggerScrollTopRef = null;
}
}
onMount(init);
onDestroy(() => {
if (!scrollTarget) return;
scrollTarget.removeEventListener('scroll', handleScroll);
});
let styleTop: string = '';
$: {
if (stickToTopRef && mergedOffsetTopRef !== undefined && mergedTopRef !== undefined) {
styleTop = `${mergedTopRef}px`;
}
}
let styleBottom: string = '';
$: {
if (stickToBottomRef && mergedOffsetBottomRef !== undefined && mergedBottomRef !== undefined) {
styleBottom = `${mergedBottomRef}px`;
}
}
const prefixCls = getPrefixCls('affix');
$: cnames = clsx(
prefixCls,
{
[`${prefixCls}--affixed`]: affixedRef,
[`${prefixCls}--absolute-positioned`]: positionOption === 'absolute'
},
cls
);
</script>

<div
class={cnames}
style:top={styleTop}
style:bottom={styleBottom}
bind:this={selfRef}
{...$$restProps}
{...attrs}
>
<slot />
</div>
5 changes: 5 additions & 0 deletions components/Affix/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="./types" />
import Affix from './index.svelte';
export { Affix as KAffix };

export default Affix;
13 changes: 13 additions & 0 deletions components/Affix/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/// <reference types="svelte" />
import type { ClassValue } from 'clsx';
import type { ScrollTarget } from './utils';
export type KAffixProps = {
listenTo: string | ScrollTarget | (() => HTMLElement) | undefined;
top: number | undefined;
bottom: number | undefined;
triggerTop: number | undefined;
triggerBottom: number | undefined;
positionOption: 'fixed' | 'absolute';
cls: ClassValue;
attrs: Record<string, string>;
};
42 changes: 42 additions & 0 deletions components/Affix/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { IKunUncertainFunction } from '@ikun-ui/utils';

export type ScrollTarget = Window | Document | HTMLElement;

export function getScrollTop(target: ScrollTarget): number {
return target instanceof HTMLElement ? target.scrollTop : window.scrollY;
}

export function getRect(target: ScrollTarget): { top: number; bottom: number } {
return target instanceof HTMLElement
? target.getBoundingClientRect()
: { top: 0, bottom: window.innerHeight };
}

type GetElement = () => HTMLElement;

function unwrapElement<T>(
target: T | string | GetElement
): T extends HTMLElement ? HTMLElement : HTMLElement | null;
function unwrapElement(target: HTMLElement | string | GetElement) {
if (typeof target === 'string') return document.querySelector(target);
if (typeof target === 'function') return target();
return target;
}

export { unwrapElement };

let onceCbs: IKunUncertainFunction[] = [];
const paramsMap: WeakMap<IKunUncertainFunction, any[]> = new WeakMap();

function flushOnceCallbacks(): void {
// @ts-ignore
onceCbs.forEach((cb) => cb(...paramsMap.get(cb)!));
onceCbs = [];
}

function beforeNextFrameOnce(cb: IKunUncertainFunction, ...params: any[]): void {
paramsMap.set(cb, params);
if (onceCbs.includes(cb)) return;
onceCbs.push(cb) === 1 && requestAnimationFrame(flushOnceCallbacks);
}
export { beforeNextFrameOnce };
11 changes: 11 additions & 0 deletions components/Affix/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",

"compilerOptions": {
"noImplicitAny": true,
"strict": true,
"declaration": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.svelte"],
"exclude": ["node_modules/*", "**/*.spec.ts"]
}
1 change: 1 addition & 0 deletions components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ export * from '@ikun-ui/tabs';
export * from '@ikun-ui/descriptions';
export * from '@ikun-ui/descriptions-item';
export * from '@ikun-ui/carousel';
export * from '@ikun-ui/affix';
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ const components = [
text: 'Navigation',
collapsed: false,
items: [
{
text: 'Affix',
link: '/components/KAffix'
},
{
text: 'Breadcrumb',
link: '/components/KBreadcrumb'
Expand Down

0 comments on commit 36469b3

Please sign in to comment.