nexa-ui-kit 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ <script setup>
2
+ import { signal, computed } from 'nexa-framework'
3
+
4
+ const props = defineProps({
5
+ onRefresh: { type: Function, default: null },
6
+ onEndReached: { type: Function, default: null },
7
+ endReachedThreshold: { type: Number, default: 80 },
8
+ horizontal: { type: Boolean, default: false }
9
+ })
10
+
11
+ const isRefreshing = signal(false)
12
+ const pullDelta = signal(0)
13
+ const refreshThreshold = 60
14
+ let touchStartY = 0
15
+ let startScrollTop = 0
16
+
17
+ const pullProgress = computed(() =>
18
+ Math.min(1, pullDelta.value / refreshThreshold)
19
+ )
20
+
21
+ const spinnerScale = computed(() =>
22
+ `scale(${pullProgress.value})`
23
+ )
24
+
25
+ const onTouchStart = (e) => {
26
+ const container = e.currentTarget
27
+ startScrollTop = container.scrollTop
28
+ touchStartY = e.touches[0].clientY
29
+ }
30
+
31
+ const onTouchMove = (e) => {
32
+ if (isRefreshing.value || !props.onRefresh) return
33
+ const container = e.currentTarget
34
+ const dy = e.touches[0].clientY - touchStartY
35
+ if (container.scrollTop === 0 && dy > 0 && startScrollTop === 0) {
36
+ pullDelta.value = Math.min(dy * 0.5, refreshThreshold * 1.5)
37
+ }
38
+ }
39
+
40
+ const onTouchEnd = async () => {
41
+ if (!props.onRefresh || pullDelta.value < refreshThreshold) {
42
+ pullDelta.value = 0
43
+ return
44
+ }
45
+ isRefreshing.value = true
46
+ pullDelta.value = 0
47
+ try {
48
+ await props.onRefresh()
49
+ } finally {
50
+ isRefreshing.value = false
51
+ }
52
+ }
53
+
54
+ const onScroll = (e) => {
55
+ if (!props.onEndReached) return
56
+ const el = e.target
57
+ const distanceFromBottom = props.horizontal
58
+ ? el.scrollWidth - el.scrollLeft - el.clientWidth
59
+ : el.scrollHeight - el.scrollTop - el.clientHeight
60
+ if (distanceFromBottom <= props.endReachedThreshold) {
61
+ props.onEndReached()
62
+ }
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <div
68
+ class="n-scroll-view"
69
+ :class="{ 'n-scroll-view--horizontal': horizontal }"
70
+ @scroll="onScroll"
71
+ @touchstart="onTouchStart"
72
+ @touchmove="onTouchMove"
73
+ @touchend="onTouchEnd"
74
+ >
75
+ <div
76
+ v-if="onRefresh"
77
+ class="n-scroll-view-ptr"
78
+ :class="{ 'is-refreshing': isRefreshing.value }"
79
+ :style="{ height: pullDelta.value + 'px' }"
80
+ >
81
+ <div
82
+ class="n-scroll-view-spinner"
83
+ :style="{ transform: isRefreshing.value ? 'scale(1)' : spinnerScale.value }"
84
+ >
85
+
86
+ </div>
87
+ </div>
88
+ <slot />
89
+ </div>
90
+ </template>
91
+
92
+ <style scoped>
93
+ .n-scroll-view {
94
+ overflow-y: auto;
95
+ overflow-x: hidden;
96
+ width: 100%;
97
+ height: 100%;
98
+ -webkit-overflow-scrolling: touch;
99
+ overscroll-behavior: contain;
100
+ position: relative;
101
+ }
102
+
103
+ .n-scroll-view--horizontal {
104
+ overflow-y: hidden;
105
+ overflow-x: auto;
106
+ display: flex;
107
+ }
108
+
109
+ .n-scroll-view-ptr {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ overflow: hidden;
114
+ transition: height 0.1s;
115
+ }
116
+
117
+ .n-scroll-view-ptr.is-refreshing {
118
+ height: 52px !important;
119
+ }
120
+
121
+ .n-scroll-view-spinner {
122
+ font-size: 24px;
123
+ color: var(--n-color-primary, #6366f1);
124
+ transition: transform 0.1s;
125
+ line-height: 1;
126
+ transform-origin: center;
127
+ }
128
+
129
+ .n-scroll-view-ptr.is-refreshing .n-scroll-view-spinner {
130
+ animation: n-spin 0.8s linear infinite;
131
+ }
132
+
133
+ @keyframes n-spin {
134
+ to { transform: rotate(360deg) scale(1); }
135
+ }
136
+ </style>
@@ -49,7 +49,7 @@ _sfc_main.render = function(ctx) {
49
49
  "\n ",
50
50
  h('div', { class: "n-tabs-content", "data-v-339e5ee5": "" }, [
51
51
  "\n ",
52
- ctx.$slots['tab-' + activeKey.value] ? ctx.$slots['tab-' + activeKey.value]() : null,
52
+ ctx.$slots['tab-' + activeKey.value] ? ctx.$slots['tab-' + activeKey.value]() : ["\n ", ctx.$slots.default ? ctx.$slots.default() : null, "\n "],
53
53
  "\n "
54
54
  ]),
55
55
  "\n "
@@ -0,0 +1,91 @@
1
+ import { signal, computed, onMounted, onUnmounted, effect, h, hText, defineComponent, registerComponent, reloadComponent, injectStyle } from 'nexa-framework'
2
+
3
+ const _sfc_main = defineComponent({
4
+ __scopeId: 'data-v-2d5f09ed',
5
+ __hmrId: 'NVirtualList_nexa',
6
+ props: {
7
+ items: { type: Array, default: () => [] },
8
+ itemHeight: { type: Number, default: 48 },
9
+ overscan: { type: Number, default: 3 },
10
+ renderItem: { type: Function, required: true },
11
+ keyFn: { type: Function, default: null }
12
+ },
13
+ setup(props, setupContext) {
14
+ const { emit, slots, slots: $slots } = setupContext
15
+ const containerRef = signal(null)
16
+ const scrollTop = signal(0)
17
+ const containerHeight = signal(400)
18
+ const totalHeight = computed(() => props.items.length * props.itemHeight)
19
+ const visibleRange = computed(() => {
20
+ const start = Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan)
21
+ const visibleCount = Math.ceil(containerHeight.value / props.itemHeight) + props.overscan * 2
22
+ const end = Math.min(props.items.length, start + visibleCount)
23
+ return { start, end }
24
+ })
25
+ const visibleItems = computed(() => {
26
+ const { start, end } = visibleRange.value
27
+ return props.items.slice(start, end).map((item, i) => ({
28
+ item,
29
+ index: start + i,
30
+ top: (start + i) * props.itemHeight
31
+ }))
32
+ })
33
+ let resizeObserver = null
34
+ onMounted(() => {
35
+ const el = containerRef.value
36
+ if (!el) return
37
+ containerHeight.value = el.clientHeight
38
+ resizeObserver = new ResizeObserver(entries => {
39
+ containerHeight.value = entries[0].contentRect.height
40
+ })
41
+ resizeObserver.observe(el)
42
+ })
43
+ onUnmounted(() => {
44
+ resizeObserver?.disconnect()
45
+ })
46
+ const onScroll = (e) => {
47
+ scrollTop.value = e.target.scrollTop
48
+ }
49
+ return { containerRef, scrollTop, containerHeight, totalHeight, visibleRange, visibleItems, resizeObserver, onScroll, $slots }
50
+ }
51
+ })
52
+ // Injected render function
53
+ _sfc_main.render = function(ctx) {
54
+ const { containerRef, scrollTop, containerHeight, totalHeight, visibleRange, visibleItems, resizeObserver, onScroll, $slots, items, itemHeight, overscan, renderItem, keyFn, Fragment: _ntc_Fragment } = ctx
55
+ return h('div', { class: "n-virtual-list", onScroll: onScroll, ref: el => containerRef.value = el, "data-v-2d5f09ed": "" }, [
56
+ "\n ",
57
+ h('div', { class: "n-virtual-list-spacer", style: { height: totalHeight.value + 'px', position: 'relative' }, "data-v-2d5f09ed": "" }, [
58
+ "\n ",
59
+ visibleItems.value.map((row, index) =>
60
+ h('div', { class: "n-virtual-list-row", key: keyFn ? keyFn(row.item, row.index) : row.index, style: { position: 'absolute', top: row.top + 'px', width: '100%', height: itemHeight + 'px' }, "data-v-2d5f09ed": "" }, [
61
+ "\n ",
62
+ renderItem(row.item, row.index),
63
+ "\n "
64
+ ])
65
+ ),
66
+ "\n "
67
+ ]),
68
+ "\n "
69
+ ])
70
+ }
71
+ _sfc_main.__scopeId = 'data-v-2d5f09ed'
72
+ _sfc_main.__hmrId = 'NVirtualList_nexa'
73
+
74
+ export default _sfc_main
75
+
76
+ const __style = `.n-virtual-list[data-v-2d5f09ed]{
77
+ overflow-y: auto;
78
+ width: 100%;
79
+ height: 100%;
80
+ -webkit-overflow-scrolling: touch;
81
+ overscroll-behavior: contain;
82
+ }
83
+
84
+ .n-virtual-list-spacer[data-v-2d5f09ed]{
85
+ position: relative;
86
+ }
87
+
88
+ .n-virtual-list-row[data-v-2d5f09ed]{
89
+ box-sizing: border-box;
90
+ }`
91
+ injectStyle('data-v-2d5f09ed', __style)
@@ -0,0 +1,94 @@
1
+ <script setup>
2
+ import { signal, computed, onMounted, onUnmounted, effect } from 'nexa-framework'
3
+
4
+ const props = defineProps({
5
+ items: { type: Array, default: () => [] },
6
+ itemHeight: { type: Number, default: 48 },
7
+ overscan: { type: Number, default: 3 },
8
+ renderItem: { type: Function, required: true },
9
+ keyFn: { type: Function, default: null }
10
+ })
11
+
12
+ const containerRef = signal(null)
13
+ const scrollTop = signal(0)
14
+ const containerHeight = signal(400)
15
+
16
+ const totalHeight = computed(() => props.items.length * props.itemHeight)
17
+
18
+ const visibleRange = computed(() => {
19
+ const start = Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan)
20
+ const visibleCount = Math.ceil(containerHeight.value / props.itemHeight) + props.overscan * 2
21
+ const end = Math.min(props.items.length, start + visibleCount)
22
+ return { start, end }
23
+ })
24
+
25
+ const visibleItems = computed(() => {
26
+ const { start, end } = visibleRange.value
27
+ return props.items.slice(start, end).map((item, i) => ({
28
+ item,
29
+ index: start + i,
30
+ top: (start + i) * props.itemHeight
31
+ }))
32
+ })
33
+
34
+ let resizeObserver = null
35
+
36
+ onMounted(() => {
37
+ const el = containerRef.value
38
+ if (!el) return
39
+ containerHeight.value = el.clientHeight
40
+
41
+ resizeObserver = new ResizeObserver(entries => {
42
+ containerHeight.value = entries[0].contentRect.height
43
+ })
44
+ resizeObserver.observe(el)
45
+ })
46
+
47
+ onUnmounted(() => {
48
+ resizeObserver?.disconnect()
49
+ })
50
+
51
+ const onScroll = (e) => {
52
+ scrollTop.value = e.target.scrollTop
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <div
58
+ class="n-virtual-list"
59
+ @scroll="onScroll"
60
+ :ref="el => containerRef.value = el"
61
+ >
62
+ <div
63
+ class="n-virtual-list-spacer"
64
+ :style="{ height: totalHeight.value + 'px', position: 'relative' }"
65
+ >
66
+ <div
67
+ v-for="row in visibleItems.value"
68
+ :key="keyFn ? keyFn(row.item, row.index) : row.index"
69
+ class="n-virtual-list-row"
70
+ :style="{ position: 'absolute', top: row.top + 'px', width: '100%', height: itemHeight + 'px' }"
71
+ >
72
+ {{ renderItem(row.item, row.index) }}
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped>
79
+ .n-virtual-list {
80
+ overflow-y: auto;
81
+ width: 100%;
82
+ height: 100%;
83
+ -webkit-overflow-scrolling: touch;
84
+ overscroll-behavior: contain;
85
+ }
86
+
87
+ .n-virtual-list-spacer {
88
+ position: relative;
89
+ }
90
+
91
+ .n-virtual-list-row {
92
+ box-sizing: border-box;
93
+ }
94
+ </style>
package/dist/index.d.ts CHANGED
@@ -27,6 +27,9 @@ export { default as NInputNumber } from './components/NInputNumber.nexa';
27
27
  export { default as NMultiSelect } from './components/NMultiSelect.nexa';
28
28
  export { default as NDataTable } from './components/NDataTable.nexa';
29
29
  export { default as NPaginator } from './components/NPaginator.nexa';
30
+ export { default as NVirtualList } from './components/NVirtualList.nexa';
31
+ export { default as NScrollView } from './components/NScrollView.nexa';
32
+ export { default as NImage } from './components/NImage.nexa';
30
33
  export * from './services/ToastService.js';
31
34
  export * from './services/FormValidation.js';
32
35
  export { installTheme } from './styles/theme.js';
package/dist/index.js CHANGED
@@ -29,6 +29,9 @@ export { default as NInputNumber } from './components/NInputNumber.js';
29
29
  export { default as NMultiSelect } from './components/NMultiSelect.js';
30
30
  export { default as NDataTable } from './components/NDataTable.js';
31
31
  export { default as NPaginator } from './components/NPaginator.js';
32
+ export { default as NVirtualList } from './components/NVirtualList.js';
33
+ export { default as NScrollView } from './components/NScrollView.js';
34
+ export { default as NImage } from './components/NImage.js';
32
35
  export * from './services/ToastService.js';
33
36
  export * from './services/FormValidation.js';
34
37
  export { installTheme } from './styles/theme.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexa-ui-kit",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Premium component library for Nexa Framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,12 +18,12 @@
18
18
  "src"
19
19
  ],
20
20
  "dependencies": {
21
- "nexa-framework": "0.6.4",
22
- "nexa-mobile": "0.6.4"
21
+ "nexa-framework": "0.7.0",
22
+ "nexa-mobile": "0.7.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "cpx": "^1.5.0",
26
- "nexa-compiler": "0.6.4"
26
+ "nexa-compiler": "0.7.0"
27
27
  },
28
28
  "scripts": {
29
29
  "build": "tsc && node scripts/compile-nexa.js && node scripts/patch-imports.js && cpx \"src/**/*.nexa\" dist",
@@ -168,11 +168,28 @@ const startResize = (e) => { if (!props.resizableColumns) return; const field =
168
168
  <div class="n-dt-wrapper">
169
169
  <table class="n-dt-table" :class="{ 'is-striped': stripedRows, 'is-hover': hoverRows }">
170
170
  <thead class="n-dt-thead">
171
- <tr class="n-dt-head-row"><th v-if="selectionMode" class="n-dt-th is-selection"><input v-if="selectionMode === 'multiple'" class="n-dt-selectbox" type="checkbox" :checked="allVisibleSelected.value" @click.stop="toggleAllVisible" /></th><th v-for="col in normalizeColumns.value" :key="col.field" class="n-dt-th" :class="[`is-${col.align}`, col.sortable ? 'is-sortable' : '']" :style="{ width: getWidth(col) || undefined, minWidth: col.minWidth }" :data-field="col.field" @click="onSortClick"><div class="n-dt-th-content"><span class="n-dt-th-text">{{ getHeaderContent(col) }}</span><span v-if="col.sortable" class="n-dt-sort" :class="{ 'is-active': internalSortField.value === col.field }"><span v-if="internalSortField.value !== col.field" class="n-dt-sort-icon">↕</span><span v-else class="n-dt-sort-icon">{{ internalSortOrder.value === 1 ? '↑' : '↓' }}</span></span></div><span v-if="resizableColumns" class="n-dt-resizer" :data-field="col.field" @mousedown="startResize"></span></th></tr>
171
+ <tr class="n-dt-head-row">
172
+ <th v-if="selectionMode" class="n-dt-th is-selection"><input v-if="selectionMode === 'multiple'" class="n-dt-selectbox" type="checkbox" :checked="allVisibleSelected.value" @click.stop="toggleAllVisible" /></th>
173
+ <th v-for="col in normalizeColumns.value" :key="col.field" class="n-dt-th" :class="[`is-${col.align}`, col.sortable ? 'is-sortable' : '']" :style="{ width: getWidth(col) || undefined, minWidth: col.minWidth }" :data-field="col.field" @click="onSortClick">
174
+ <div class="n-dt-th-content">
175
+ <span class="n-dt-th-text">{{ $slots.value && $slots.value[`header-${col.field}`] ? $slots[`header-${col.field}`]({ column: col }) : getHeaderContent(col) }}</span>
176
+ <span v-if="col.sortable" class="n-dt-sort" :class="{ 'is-active': internalSortField.value === col.field }">
177
+ <span v-if="internalSortField.value !== col.field" class="n-dt-sort-icon">↕</span>
178
+ <span v-else class="n-dt-sort-icon">{{ internalSortOrder.value === 1 ? '↑' : '↓' }}</span>
179
+ </span>
180
+ </div>
181
+ <span v-if="resizableColumns" class="n-dt-resizer" :data-field="col.field" @mousedown="startResize"></span>
182
+ </th>
183
+ </tr>
172
184
  <tr v-if="filterDisplay === 'row'" class="n-dt-filter-row"><th v-if="selectionMode" class="n-dt-th is-selection"></th><th v-for="col in normalizeColumns.value" :key="col.field" class="n-dt-th" :class="`is-${col.align}`" :style="{ width: getWidth(col) || undefined, minWidth: col.minWidth }"><input v-if="col.filterable" class="n-dt-filter" :data-field="col.field" :value="(effectiveFilters.value[col.field]?.value) || ''" placeholder="Filter" @input="onColumnFilterInput" /></th></tr>
173
185
  </thead>
174
186
  <tbody class="n-dt-tbody">
175
- <tr v-for="(row, i) in visibleRows.value" :key="getRowKey(row, i + internalFirst.value)" class="n-dt-row" :class="{ 'is-selected': isRowSelected(row, i + internalFirst.value) }" @click="toggleRowSelection(row, i + internalFirst.value)"><td v-if="selectionMode" class="n-dt-td is-selection"><input class="n-dt-selectbox" type="checkbox" :checked="isRowSelected(row, i + internalFirst.value)" @click.stop="toggleRowSelection(row, i + internalFirst.value)" /></td><td v-for="col in normalizeColumns.value" :key="col.field" class="n-dt-td" :class="`is-${col.align}`" :style="{ width: getWidth(col) || undefined, minWidth: col.minWidth }">{{ getCellContent(row, col, i + internalFirst.value) }}</td></tr>
187
+ <tr v-for="(row, i) in visibleRows.value" :key="getRowKey(row, i + internalFirst.value)" class="n-dt-row" :class="{ 'is-selected': isRowSelected(row, i + internalFirst.value) }" @click="toggleRowSelection(row, i + internalFirst.value)">
188
+ <td v-if="selectionMode" class="n-dt-td is-selection"><input class="n-dt-selectbox" type="checkbox" :checked="isRowSelected(row, i + internalFirst.value)" @click.stop="toggleRowSelection(row, i + internalFirst.value)" /></td>
189
+ <td v-for="col in normalizeColumns.value" :key="col.field" class="n-dt-td" :class="`is-${col.align}`" :style="{ width: getWidth(col) || undefined, minWidth: col.minWidth }">
190
+ {{ $slots.value && $slots.value[`body-${col.field}`] ? $slots[`body-${col.field}`]({ data: row, column: col, index: i + internalFirst.value }) : getCellContent(row, col, i + internalFirst.value) }}
191
+ </td>
192
+ </tr>
176
193
  <tr v-if="visibleRows.value.length === 0" class="n-dt-empty-row"><td :colspan="normalizeColumns.value.length + (selectionMode ? 1 : 0)" class="n-dt-empty">{{ emptyMessage }}</td></tr>
177
194
  </tbody>
178
195
  </table>
@@ -0,0 +1,112 @@
1
+ <script setup>
2
+ import { signal, computed, onMounted, onUnmounted } from 'nexa-framework'
3
+
4
+ const props = defineProps({
5
+ src: { type: String, required: true },
6
+ alt: { type: String, default: '' },
7
+ fallback: { type: String, default: '' },
8
+ fit: { type: String, default: 'cover' },
9
+ lazy: { type: Boolean, default: true },
10
+ width: { type: String, default: null },
11
+ height: { type: String, default: null },
12
+ rounded: { type: Boolean, default: false },
13
+ blur: { type: Boolean, default: true }
14
+ })
15
+
16
+ const loaded = signal(false)
17
+ const error = signal(false)
18
+ const visible = signal(!props.lazy)
19
+ let observer = null
20
+ let imgRef = null
21
+
22
+ const currentSrc = computed(() => {
23
+ if (error.value && props.fallback) return props.fallback
24
+ return props.src
25
+ })
26
+
27
+ const wrapperStyle = computed(() => ({
28
+ width: props.width ?? '100%',
29
+ height: props.height ?? 'auto',
30
+ position: 'relative',
31
+ overflow: 'hidden',
32
+ display: 'block',
33
+ borderRadius: props.rounded ? '50%' : undefined
34
+ }))
35
+
36
+ const imgStyle = computed(() => ({
37
+ width: '100%',
38
+ height: '100%',
39
+ objectFit: props.fit,
40
+ transition: 'opacity 0.3s ease, filter 0.4s ease',
41
+ opacity: loaded.value ? '1' : '0',
42
+ filter: (props.blur && !loaded.value) ? 'blur(8px)' : 'none'
43
+ }))
44
+
45
+ onMounted(() => {
46
+ if (!props.lazy) return
47
+ observer = new IntersectionObserver(entries => {
48
+ if (entries[0].isIntersecting) {
49
+ visible.value = true
50
+ observer?.disconnect()
51
+ }
52
+ }, { rootMargin: '200px' })
53
+ if (imgRef) observer.observe(imgRef)
54
+ })
55
+
56
+ onUnmounted(() => {
57
+ observer?.disconnect()
58
+ })
59
+
60
+ const onLoad = () => { console.log('ON_LOAD_CALLED'); loaded.value = true }
61
+ const onError = () => { console.log('ON_ERROR_CALLED'); error.value = true; loaded.value = true }
62
+ </script>
63
+
64
+ <template>
65
+ <span
66
+ class="n-image-wrapper"
67
+ :style="wrapperStyle.value"
68
+ :ref="el => imgRef = el"
69
+ >
70
+ <span v-if="!loaded.value" class="n-image-skeleton" />
71
+ <img
72
+ v-if="visible.value"
73
+ :src="currentSrc.value"
74
+ :alt="alt"
75
+ :style="{ width: '100%', height: '100%', objectFit: fit, transition: 'opacity 0.3s ease, filter 0.4s ease', opacity: loaded.value ? '1' : '0', filter: (blur && !loaded.value) ? 'blur(8px)' : 'none' }"
76
+ class="n-image"
77
+ @load="onLoad"
78
+ @error="onError"
79
+ />
80
+ </span>
81
+ </template>
82
+
83
+ <style scoped>
84
+ .n-image-wrapper {
85
+ background: var(--n-color-surface-hover, #1a1a2e);
86
+ display: inline-block;
87
+ }
88
+
89
+ .n-image-skeleton {
90
+ position: absolute;
91
+ inset: 0;
92
+ background: linear-gradient(
93
+ 90deg,
94
+ var(--n-color-surface, #111827) 25%,
95
+ var(--n-color-surface-hover, #1f2937) 50%,
96
+ var(--n-color-surface, #111827) 75%
97
+ );
98
+ background-size: 200% 100%;
99
+ animation: n-img-shimmer 1.5s infinite;
100
+ }
101
+
102
+ @keyframes n-img-shimmer {
103
+ 0% { background-position: 200% 0; }
104
+ 100% { background-position: -200% 0; }
105
+ }
106
+
107
+ .n-image {
108
+ display: block;
109
+ position: relative;
110
+ z-index: 1;
111
+ }
112
+ </style>
@@ -0,0 +1,136 @@
1
+ <script setup>
2
+ import { signal, computed } from 'nexa-framework'
3
+
4
+ const props = defineProps({
5
+ onRefresh: { type: Function, default: null },
6
+ onEndReached: { type: Function, default: null },
7
+ endReachedThreshold: { type: Number, default: 80 },
8
+ horizontal: { type: Boolean, default: false }
9
+ })
10
+
11
+ const isRefreshing = signal(false)
12
+ const pullDelta = signal(0)
13
+ const refreshThreshold = 60
14
+ let touchStartY = 0
15
+ let startScrollTop = 0
16
+
17
+ const pullProgress = computed(() =>
18
+ Math.min(1, pullDelta.value / refreshThreshold)
19
+ )
20
+
21
+ const spinnerScale = computed(() =>
22
+ `scale(${pullProgress.value})`
23
+ )
24
+
25
+ const onTouchStart = (e) => {
26
+ const container = e.currentTarget
27
+ startScrollTop = container.scrollTop
28
+ touchStartY = e.touches[0].clientY
29
+ }
30
+
31
+ const onTouchMove = (e) => {
32
+ if (isRefreshing.value || !props.onRefresh) return
33
+ const container = e.currentTarget
34
+ const dy = e.touches[0].clientY - touchStartY
35
+ if (container.scrollTop === 0 && dy > 0 && startScrollTop === 0) {
36
+ pullDelta.value = Math.min(dy * 0.5, refreshThreshold * 1.5)
37
+ }
38
+ }
39
+
40
+ const onTouchEnd = async () => {
41
+ if (!props.onRefresh || pullDelta.value < refreshThreshold) {
42
+ pullDelta.value = 0
43
+ return
44
+ }
45
+ isRefreshing.value = true
46
+ pullDelta.value = 0
47
+ try {
48
+ await props.onRefresh()
49
+ } finally {
50
+ isRefreshing.value = false
51
+ }
52
+ }
53
+
54
+ const onScroll = (e) => {
55
+ if (!props.onEndReached) return
56
+ const el = e.target
57
+ const distanceFromBottom = props.horizontal
58
+ ? el.scrollWidth - el.scrollLeft - el.clientWidth
59
+ : el.scrollHeight - el.scrollTop - el.clientHeight
60
+ if (distanceFromBottom <= props.endReachedThreshold) {
61
+ props.onEndReached()
62
+ }
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <div
68
+ class="n-scroll-view"
69
+ :class="{ 'n-scroll-view--horizontal': horizontal }"
70
+ @scroll="onScroll"
71
+ @touchstart="onTouchStart"
72
+ @touchmove="onTouchMove"
73
+ @touchend="onTouchEnd"
74
+ >
75
+ <div
76
+ v-if="onRefresh"
77
+ class="n-scroll-view-ptr"
78
+ :class="{ 'is-refreshing': isRefreshing.value }"
79
+ :style="{ height: pullDelta.value + 'px' }"
80
+ >
81
+ <div
82
+ class="n-scroll-view-spinner"
83
+ :style="{ transform: isRefreshing.value ? 'scale(1)' : spinnerScale.value }"
84
+ >
85
+
86
+ </div>
87
+ </div>
88
+ <slot />
89
+ </div>
90
+ </template>
91
+
92
+ <style scoped>
93
+ .n-scroll-view {
94
+ overflow-y: auto;
95
+ overflow-x: hidden;
96
+ width: 100%;
97
+ height: 100%;
98
+ -webkit-overflow-scrolling: touch;
99
+ overscroll-behavior: contain;
100
+ position: relative;
101
+ }
102
+
103
+ .n-scroll-view--horizontal {
104
+ overflow-y: hidden;
105
+ overflow-x: auto;
106
+ display: flex;
107
+ }
108
+
109
+ .n-scroll-view-ptr {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ overflow: hidden;
114
+ transition: height 0.1s;
115
+ }
116
+
117
+ .n-scroll-view-ptr.is-refreshing {
118
+ height: 52px !important;
119
+ }
120
+
121
+ .n-scroll-view-spinner {
122
+ font-size: 24px;
123
+ color: var(--n-color-primary, #6366f1);
124
+ transition: transform 0.1s;
125
+ line-height: 1;
126
+ transform-origin: center;
127
+ }
128
+
129
+ .n-scroll-view-ptr.is-refreshing .n-scroll-view-spinner {
130
+ animation: n-spin 0.8s linear infinite;
131
+ }
132
+
133
+ @keyframes n-spin {
134
+ to { transform: rotate(360deg) scale(1); }
135
+ }
136
+ </style>