Skip to content

Commit 36469b3

Browse files
vtrbobaiwusanyu-c
andauthored
feat: added affix component (#352)
* feat: create affix component * chore: complete basic function * test: added affix component unit test * docs: added affix component document --------- Co-authored-by: baiwusanyu-c <[email protected]>
1 parent e7f5c61 commit 36469b3

File tree

18 files changed

+550
-55
lines changed

18 files changed

+550
-55
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,5 @@ Thanks to everyone who has already contributed to ikun-ui !
3131
- [svelte](https://github.com/sveltejs/svelte)
3232
- [unocss](https://github.com/unocss/unocss)
3333
- [onu-ui](https://github.com/onu-ui/onu-ui)
34+
- [naive-ui](https://github.com/tusen-ai/naive-ui)
35+
- [element-plus](https://github.com/element-plus/element-plus)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`Test: KAffix > props: cls 1`] = `"<head></head><body style=\\"height: 100px; overflow: auto;\\"><div class=\\"k-affix k-affix--test\\"></div></body>"`;
4+
5+
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>"`;
6+
7+
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, expect, test, describe, beforeEach, vi } from 'vitest';
2+
import KAffix from '../src';
3+
import { tick } from 'svelte';
4+
5+
const initHost = () => {
6+
document.body.style.height = '100px';
7+
document.body.style.overflow = 'auto';
8+
};
9+
10+
beforeEach(() => {
11+
initHost();
12+
vi.useFakeTimers();
13+
});
14+
afterEach(() => {
15+
document.body.innerHTML = '';
16+
vi.restoreAllMocks();
17+
});
18+
19+
describe('Test: KAffix', () => {
20+
vi.mock('svelte', async () => {
21+
const actual = (await vi.importActual('svelte')) as object;
22+
return {
23+
...actual,
24+
// @ts-ignore
25+
onMount: (await import('svelte/internal')).onMount
26+
};
27+
});
28+
29+
test('props: cls', async () => {
30+
const instance = new KAffix({
31+
target: document.body,
32+
props: {
33+
cls: 'k-affix--test'
34+
}
35+
});
36+
expect(instance).toBeTruthy();
37+
expect(
38+
(document.documentElement as HTMLElement)!.innerHTML.includes('k-affix--test')
39+
).toBeTruthy();
40+
expect(document.documentElement.innerHTML).matchSnapshot();
41+
});
42+
43+
test('should work with `top` prop', async () => {
44+
const instance = new KAffix({
45+
target: document.body,
46+
props: {
47+
cls: 'k-affix--test',
48+
top: 120
49+
}
50+
});
51+
expect(instance).toBeTruthy();
52+
expect(
53+
(document.documentElement as HTMLElement)!.innerHTML.includes('k-affix--affixed')
54+
).not.toBeTruthy();
55+
document.documentElement.scrollTop = 200;
56+
document.documentElement.dispatchEvent(new Event('scroll', { bubbles: true }));
57+
await tick();
58+
await vi.advanceTimersByTimeAsync(300);
59+
expect(document.body.innerHTML).toContain('top: 120px;');
60+
expect(document.documentElement.innerHTML).matchSnapshot();
61+
});
62+
63+
test('should work with `position` prop', async () => {
64+
const instance = new KAffix({
65+
target: document.body,
66+
props: {
67+
positionOption: 'absolute'
68+
}
69+
});
70+
expect(instance).toBeTruthy();
71+
expect(
72+
(document.documentElement as HTMLElement)!.innerHTML.includes('k-affix--absolute-positioned')
73+
).toBeTruthy();
74+
expect(document.documentElement.innerHTML).matchSnapshot();
75+
});
76+
});

components/Affix/package.json

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@ikun-ui/affix",
3+
"version": "0.0.16",
4+
"type": "module",
5+
"main": "src/index.ts",
6+
"types": "src/index.d.ts",
7+
"svelte": "src/index.ts",
8+
"keywords": [
9+
"svelte",
10+
"svelte3",
11+
"web component",
12+
"component",
13+
"react",
14+
"vue",
15+
"svelte-kit",
16+
"dx"
17+
],
18+
"files": [
19+
"dist",
20+
"package.json"
21+
],
22+
"scripts": {
23+
"build": "npm run build:js && npm run build:svelte",
24+
"build:js": "tsc -p . --outDir dist/ --rootDir src/",
25+
"build:svelte": "svelte-strip strip src/ dist",
26+
"publish:pre": "node ../../scripts/pre-publish.js",
27+
"publish:npm": "pnpm run publish:pre && pnpm publish --no-git-checks --access public"
28+
},
29+
"publishConfig": {
30+
"access": "public",
31+
"main": "dist/index.js",
32+
"module": "dist/index.js",
33+
"svelte": "dist/index.js",
34+
"types": "dist/index.d.ts"
35+
},
36+
"dependencies": {
37+
"@ikun-ui/icon": "workspace:*",
38+
"@ikun-ui/utils": "workspace:*",
39+
"baiwusanyu-utils": "^1.0.16",
40+
"clsx": "^2.0.0"
41+
},
42+
"devDependencies": {
43+
"@tsconfig/svelte": "^5.0.2",
44+
"svelte-strip": "^2.0.0",
45+
"tslib": "^2.6.2",
46+
"typescript": "^5.3.2"
47+
}
48+
}

components/Affix/src/index.svelte

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<script lang="ts">
2+
import { getPrefixCls } from '@ikun-ui/utils';
3+
import { clsx } from 'clsx';
4+
import type { KAffixProps } from './types';
5+
import type { ScrollTarget } from './utils';
6+
import { unwrapElement, getScrollTop, getRect, beforeNextFrameOnce } from './utils';
7+
import { onDestroy, onMount } from 'svelte';
8+
export let cls: KAffixProps['cls'] = undefined;
9+
export let attrs: KAffixProps['attrs'] = {};
10+
export let listenTo: KAffixProps['listenTo'] = undefined;
11+
export let top: KAffixProps['top'] = undefined;
12+
export let bottom: KAffixProps['bottom'] = undefined;
13+
export let triggerTop: KAffixProps['triggerTop'] = undefined;
14+
export let triggerBottom: KAffixProps['triggerBottom'] = undefined;
15+
export let positionOption: KAffixProps['positionOption'] = 'fixed';
16+
17+
let scrollTarget: ScrollTarget | null = null;
18+
let stickToTopRef = false;
19+
let stickToBottomRef = false;
20+
let bottomAffixedTriggerScrollTopRef: number | null = null;
21+
let topAffixedTriggerScrollTopRef: number | null = null;
22+
$: affixedRef = stickToBottomRef || stickToTopRef;
23+
$: mergedOffsetTopRef = triggerTop ?? top;
24+
$: mergedTopRef = top ?? triggerTop;
25+
$: mergedBottomRef = bottom ?? triggerBottom;
26+
$: mergedOffsetBottomRef = triggerBottom ?? bottom;
27+
let selfRef: Element | null = null;
28+
29+
const init = (): void => {
30+
if (listenTo) {
31+
scrollTarget = unwrapElement(listenTo);
32+
} else {
33+
scrollTarget = document;
34+
}
35+
if (scrollTarget) {
36+
scrollTarget.addEventListener('scroll', handleScroll);
37+
handleScroll();
38+
}
39+
};
40+
function handleScroll(): void {
41+
beforeNextFrameOnce(doHandleScroll);
42+
}
43+
44+
function doHandleScroll(): void {
45+
const selfEl = selfRef;
46+
if (!scrollTarget || !selfEl) return;
47+
const scrollTop = getScrollTop(scrollTarget);
48+
if (affixedRef) {
49+
if (topAffixedTriggerScrollTopRef !== null && scrollTop < topAffixedTriggerScrollTopRef) {
50+
stickToTopRef = false;
51+
topAffixedTriggerScrollTopRef = null;
52+
}
53+
if (
54+
bottomAffixedTriggerScrollTopRef !== null &&
55+
scrollTop > bottomAffixedTriggerScrollTopRef
56+
) {
57+
stickToBottomRef = false;
58+
bottomAffixedTriggerScrollTopRef = null;
59+
}
60+
return;
61+
}
62+
const containerRect = getRect(scrollTarget);
63+
const affixRect = selfEl.getBoundingClientRect();
64+
const pxToTop = affixRect.top - containerRect.top;
65+
const pxToBottom = containerRect.bottom - affixRect.bottom;
66+
const mergedOffsetTop = mergedOffsetTopRef;
67+
const mergedOffsetBottom = mergedOffsetBottomRef;
68+
if (mergedOffsetTop !== undefined && pxToTop <= mergedOffsetTop) {
69+
stickToTopRef = true;
70+
topAffixedTriggerScrollTopRef = scrollTop - (mergedOffsetTop - pxToTop);
71+
} else {
72+
stickToTopRef = false;
73+
topAffixedTriggerScrollTopRef = null;
74+
}
75+
if (mergedOffsetBottom !== undefined && pxToBottom <= mergedOffsetBottom) {
76+
stickToBottomRef = true;
77+
bottomAffixedTriggerScrollTopRef = scrollTop + mergedOffsetBottom - pxToBottom;
78+
} else {
79+
stickToBottomRef = false;
80+
bottomAffixedTriggerScrollTopRef = null;
81+
}
82+
}
83+
onMount(init);
84+
onDestroy(() => {
85+
if (!scrollTarget) return;
86+
scrollTarget.removeEventListener('scroll', handleScroll);
87+
});
88+
89+
let styleTop: string = '';
90+
$: {
91+
if (stickToTopRef && mergedOffsetTopRef !== undefined && mergedTopRef !== undefined) {
92+
styleTop = `${mergedTopRef}px`;
93+
}
94+
}
95+
96+
let styleBottom: string = '';
97+
$: {
98+
if (stickToBottomRef && mergedOffsetBottomRef !== undefined && mergedBottomRef !== undefined) {
99+
styleBottom = `${mergedBottomRef}px`;
100+
}
101+
}
102+
103+
const prefixCls = getPrefixCls('affix');
104+
$: cnames = clsx(
105+
prefixCls,
106+
{
107+
[`${prefixCls}--affixed`]: affixedRef,
108+
[`${prefixCls}--absolute-positioned`]: positionOption === 'absolute'
109+
},
110+
cls
111+
);
112+
</script>
113+
114+
<div
115+
class={cnames}
116+
style:top={styleTop}
117+
style:bottom={styleBottom}
118+
bind:this={selfRef}
119+
{...$$restProps}
120+
{...attrs}
121+
>
122+
<slot />
123+
</div>

components/Affix/src/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// <reference types="./types" />
2+
import Affix from './index.svelte';
3+
export { Affix as KAffix };
4+
5+
export default Affix;

components/Affix/src/types.d.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/// <reference types="svelte" />
2+
import type { ClassValue } from 'clsx';
3+
import type { ScrollTarget } from './utils';
4+
export type KAffixProps = {
5+
listenTo: string | ScrollTarget | (() => HTMLElement) | undefined;
6+
top: number | undefined;
7+
bottom: number | undefined;
8+
triggerTop: number | undefined;
9+
triggerBottom: number | undefined;
10+
positionOption: 'fixed' | 'absolute';
11+
cls: ClassValue;
12+
attrs: Record<string, string>;
13+
};

components/Affix/src/utils.ts

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { IKunUncertainFunction } from '@ikun-ui/utils';
2+
3+
export type ScrollTarget = Window | Document | HTMLElement;
4+
5+
export function getScrollTop(target: ScrollTarget): number {
6+
return target instanceof HTMLElement ? target.scrollTop : window.scrollY;
7+
}
8+
9+
export function getRect(target: ScrollTarget): { top: number; bottom: number } {
10+
return target instanceof HTMLElement
11+
? target.getBoundingClientRect()
12+
: { top: 0, bottom: window.innerHeight };
13+
}
14+
15+
type GetElement = () => HTMLElement;
16+
17+
function unwrapElement<T>(
18+
target: T | string | GetElement
19+
): T extends HTMLElement ? HTMLElement : HTMLElement | null;
20+
function unwrapElement(target: HTMLElement | string | GetElement) {
21+
if (typeof target === 'string') return document.querySelector(target);
22+
if (typeof target === 'function') return target();
23+
return target;
24+
}
25+
26+
export { unwrapElement };
27+
28+
let onceCbs: IKunUncertainFunction[] = [];
29+
const paramsMap: WeakMap<IKunUncertainFunction, any[]> = new WeakMap();
30+
31+
function flushOnceCallbacks(): void {
32+
// @ts-ignore
33+
onceCbs.forEach((cb) => cb(...paramsMap.get(cb)!));
34+
onceCbs = [];
35+
}
36+
37+
function beforeNextFrameOnce(cb: IKunUncertainFunction, ...params: any[]): void {
38+
paramsMap.set(cb, params);
39+
if (onceCbs.includes(cb)) return;
40+
onceCbs.push(cb) === 1 && requestAnimationFrame(flushOnceCallbacks);
41+
}
42+
export { beforeNextFrameOnce };

components/Affix/tsconfig.json

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"extends": "@tsconfig/svelte/tsconfig.json",
3+
4+
"compilerOptions": {
5+
"noImplicitAny": true,
6+
"strict": true,
7+
"declaration": true
8+
},
9+
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.svelte"],
10+
"exclude": ["node_modules/*", "**/*.spec.ts"]
11+
}

components/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,4 @@ export * from '@ikun-ui/tabs';
5050
export * from '@ikun-ui/descriptions';
5151
export * from '@ikun-ui/descriptions-item';
5252
export * from '@ikun-ui/carousel';
53+
export * from '@ikun-ui/affix';

docs/.vitepress/config.ts

+4
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ const components = [
156156
text: 'Navigation',
157157
collapsed: false,
158158
items: [
159+
{
160+
text: 'Affix',
161+
link: '/components/KAffix'
162+
},
159163
{
160164
text: 'Breadcrumb',
161165
link: '/components/KBreadcrumb'

0 commit comments

Comments
 (0)