nexa-ui-kit 0.6.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.
Files changed (114) hide show
  1. package/dist/NBadge.nexa +40 -0
  2. package/dist/NBottomSheet.nexa +124 -0
  3. package/dist/NButton.nexa +123 -0
  4. package/dist/NCard.nexa +74 -0
  5. package/dist/NInput.nexa +116 -0
  6. package/dist/NModal.nexa +165 -0
  7. package/dist/NSelect.nexa +169 -0
  8. package/dist/NToastContainer.nexa +86 -0
  9. package/dist/NTooltip.nexa +115 -0
  10. package/dist/components/NAlert.js +134 -0
  11. package/dist/components/NAlert.nexa +115 -0
  12. package/dist/components/NAutocomplete.js +94 -0
  13. package/dist/components/NAutocomplete.nexa +58 -0
  14. package/dist/components/NAvatar.js +75 -0
  15. package/dist/components/NAvatar.nexa +67 -0
  16. package/dist/components/NBadge.js +74 -0
  17. package/dist/components/NBadge.nexa +61 -0
  18. package/dist/components/NBottomSheet.js +149 -0
  19. package/dist/components/NBottomSheet.nexa +145 -0
  20. package/dist/components/NButton.js +284 -0
  21. package/dist/components/NButton.nexa +275 -0
  22. package/dist/components/NCard.js +117 -0
  23. package/dist/components/NCard.nexa +100 -0
  24. package/dist/components/NCheckbox.js +108 -0
  25. package/dist/components/NCheckbox.nexa +90 -0
  26. package/dist/components/NChips.js +72 -0
  27. package/dist/components/NChips.nexa +57 -0
  28. package/dist/components/NDataTable.js +252 -0
  29. package/dist/components/NDataTable.nexa +186 -0
  30. package/dist/components/NDatepicker.js +379 -0
  31. package/dist/components/NDatepicker.nexa +367 -0
  32. package/dist/components/NForm.js +132 -0
  33. package/dist/components/NForm.nexa +133 -0
  34. package/dist/components/NFormField.js +173 -0
  35. package/dist/components/NFormField.nexa +171 -0
  36. package/dist/components/NInput.js +311 -0
  37. package/dist/components/NInput.nexa +311 -0
  38. package/dist/components/NInputNumber.js +202 -0
  39. package/dist/components/NInputNumber.nexa +199 -0
  40. package/dist/components/NModal.js +221 -0
  41. package/dist/components/NModal.nexa +221 -0
  42. package/dist/components/NMultiSelect.js +156 -0
  43. package/dist/components/NMultiSelect.nexa +77 -0
  44. package/dist/components/NPaginator.js +117 -0
  45. package/dist/components/NPaginator.nexa +77 -0
  46. package/dist/components/NPassword.js +193 -0
  47. package/dist/components/NPassword.nexa +178 -0
  48. package/dist/components/NProgressBar.js +127 -0
  49. package/dist/components/NProgressBar.nexa +111 -0
  50. package/dist/components/NRadio.js +96 -0
  51. package/dist/components/NRadio.nexa +81 -0
  52. package/dist/components/NSelect.js +468 -0
  53. package/dist/components/NSelect.nexa +452 -0
  54. package/dist/components/NSkeleton.js +98 -0
  55. package/dist/components/NSkeleton.nexa +74 -0
  56. package/dist/components/NSwitch.js +92 -0
  57. package/dist/components/NSwitch.nexa +76 -0
  58. package/dist/components/NTabs.js +129 -0
  59. package/dist/components/NTabs.nexa +113 -0
  60. package/dist/components/NTag.js +108 -0
  61. package/dist/components/NTag.nexa +93 -0
  62. package/dist/components/NToastContainer.js +242 -0
  63. package/dist/components/NToastContainer.nexa +221 -0
  64. package/dist/components/NTooltip.js +163 -0
  65. package/dist/components/NTooltip.nexa +166 -0
  66. package/dist/components/NTreeMenu.js +151 -0
  67. package/dist/components/NTreeMenu.nexa +142 -0
  68. package/dist/index.d.ts +32 -0
  69. package/dist/index.js +34 -0
  70. package/dist/services/FloatingOverlay.d.ts +27 -0
  71. package/dist/services/FloatingOverlay.js +98 -0
  72. package/dist/services/FormValidation.d.ts +8 -0
  73. package/dist/services/FormValidation.js +46 -0
  74. package/dist/services/ToastService.d.ts +16 -0
  75. package/dist/services/ToastService.js +26 -0
  76. package/dist/styles/theme.d.ts +1 -0
  77. package/dist/styles/theme.js +144 -0
  78. package/package.json +32 -0
  79. package/src/components/NAlert.nexa +115 -0
  80. package/src/components/NAutocomplete.nexa +58 -0
  81. package/src/components/NAvatar.nexa +67 -0
  82. package/src/components/NBadge.nexa +61 -0
  83. package/src/components/NBottomSheet.nexa +145 -0
  84. package/src/components/NButton.nexa +275 -0
  85. package/src/components/NCard.nexa +100 -0
  86. package/src/components/NCheckbox.nexa +90 -0
  87. package/src/components/NChips.nexa +57 -0
  88. package/src/components/NDataTable.nexa +186 -0
  89. package/src/components/NDatepicker.nexa +367 -0
  90. package/src/components/NForm.nexa +133 -0
  91. package/src/components/NFormField.nexa +171 -0
  92. package/src/components/NInput.nexa +311 -0
  93. package/src/components/NInputNumber.nexa +199 -0
  94. package/src/components/NModal.nexa +221 -0
  95. package/src/components/NMultiSelect.nexa +77 -0
  96. package/src/components/NPaginator.nexa +77 -0
  97. package/src/components/NPassword.nexa +178 -0
  98. package/src/components/NProgressBar.nexa +111 -0
  99. package/src/components/NRadio.nexa +81 -0
  100. package/src/components/NSelect.nexa +452 -0
  101. package/src/components/NSkeleton.nexa +74 -0
  102. package/src/components/NSwitch.nexa +76 -0
  103. package/src/components/NTabs.nexa +113 -0
  104. package/src/components/NTag.nexa +93 -0
  105. package/src/components/NToastContainer.nexa +221 -0
  106. package/src/components/NTooltip.nexa +166 -0
  107. package/src/components/NTreeMenu.nexa +142 -0
  108. package/src/index.ts +36 -0
  109. package/src/services/FloatingOverlay.ts +133 -0
  110. package/src/services/FormValidation.ts +44 -0
  111. package/src/services/ToastService.ts +41 -0
  112. package/src/shims.d.ts +5 -0
  113. package/src/styles/theme.ts +146 -0
  114. package/src/styles/tokens.css +170 -0
@@ -0,0 +1,199 @@
1
+ <script setup>
2
+ import { signal, computed, inject, effect, batch } from 'nexa-framework'
3
+
4
+ const props = defineProps({
5
+ modelValue: { type: Number, default: 0 },
6
+ min: { type: Number, default: null },
7
+ max: { type: Number, default: null },
8
+ step: { type: Number, default: 1 },
9
+ bindField: { type: Boolean, default: false },
10
+ disabled: { type: Boolean, default: false },
11
+ readonly: { type: Boolean, default: false },
12
+ label: { type: String, default: '' },
13
+ placeholder: { type: String, default: '' }
14
+ })
15
+
16
+ const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
17
+
18
+ const field = inject('nexa-ui:form-field', undefined)
19
+
20
+ const effectiveValue = computed(() => {
21
+ if (props.bindField && field?.value) {
22
+ const v = field.value.value
23
+ return typeof v === 'number' ? v : Number(v)
24
+ }
25
+ return props.modelValue
26
+ })
27
+
28
+ const effectiveDisabled = computed(() => {
29
+ if (props.disabled) return true
30
+ if (props.bindField && field?.disabled) return !!field.disabled.value
31
+ return false
32
+ })
33
+
34
+ const text = signal(String(effectiveValue.value ?? ''))
35
+
36
+ effect(() => {
37
+ const next = effectiveValue.value
38
+ const nextText = next === null || next === undefined ? '' : String(next)
39
+ if (text.value === nextText) return
40
+ text.value = nextText
41
+ })
42
+
43
+ const canEdit = computed(() => !effectiveDisabled.value && !props.readonly)
44
+
45
+ const sanitize = (raw) => {
46
+ const input = String(raw ?? '')
47
+ if (!input) return ''
48
+ const allowMinus = typeof props.min === 'number' ? props.min < 0 : true
49
+ let out = ''
50
+ let hasDot = false
51
+ let hasMinus = false
52
+ for (let i = 0; i < input.length; i++) {
53
+ const ch = input[i] === ',' ? '.' : input[i]
54
+ const isDigit = ch >= '0' && ch <= '9'
55
+ if (isDigit) {
56
+ out += ch
57
+ continue
58
+ }
59
+ if (ch === '.' && !hasDot) {
60
+ hasDot = true
61
+ out += ch
62
+ continue
63
+ }
64
+ if (ch === '-' && allowMinus && !hasMinus && out.length === 0) {
65
+ hasMinus = true
66
+ out += ch
67
+ }
68
+ }
69
+ return out
70
+ }
71
+
72
+ const clamp = (n) => {
73
+ if (n == null || Number.isNaN(n)) return null
74
+ let out = n
75
+ if (typeof props.min === 'number' && !Number.isNaN(props.min)) out = Math.max(out, props.min)
76
+ if (typeof props.max === 'number' && !Number.isNaN(props.max)) out = Math.min(out, props.max)
77
+ return out
78
+ }
79
+
80
+ const parse = (raw) => {
81
+ const s = sanitize(raw).trim()
82
+ if (!s) return null
83
+ const n = Number(s)
84
+ if (Number.isNaN(n)) return null
85
+ return n
86
+ }
87
+
88
+ const setValue = (next) => {
89
+ if (!canEdit.value) return
90
+ const clamped = clamp(next)
91
+ if (clamped == null) return
92
+ batch(() => {
93
+ if (props.bindField && field?.setValue) field.setValue(clamped)
94
+ else emit('update:modelValue', clamped)
95
+ text.value = String(clamped)
96
+ })
97
+ }
98
+
99
+ const inc = () => {
100
+ if (!canEdit.value) return
101
+ const base = typeof effectiveValue.value === 'number' && !Number.isNaN(effectiveValue.value) ? effectiveValue.value : 0
102
+ setValue(base + (props.step || 1))
103
+ }
104
+
105
+ const dec = () => {
106
+ if (!canEdit.value) return
107
+ const base = typeof effectiveValue.value === 'number' && !Number.isNaN(effectiveValue.value) ? effectiveValue.value : 0
108
+ setValue(base - (props.step || 1))
109
+ }
110
+
111
+ const onInput = (e) => {
112
+ if (!canEdit.value) return
113
+ const nextRaw = e.target.value
114
+ const nextText = sanitize(nextRaw)
115
+ if (e?.target && e.target.value !== nextText) {
116
+ e.target.value = nextText
117
+ }
118
+ batch(() => {
119
+ text.value = nextText
120
+ const n = parse(nextText)
121
+ const clamped = clamp(n)
122
+ if (clamped == null) return
123
+ if (props.bindField && field?.setValue) field.setValue(clamped)
124
+ else emit('update:modelValue', clamped)
125
+ })
126
+ }
127
+
128
+ const onBeforeInput = (e) => {
129
+ if (!canEdit.value) return
130
+ const inputType = e?.inputType
131
+ if (typeof inputType === 'string' && inputType.includes('Composition')) return
132
+ if (typeof inputType === 'string' && !inputType.startsWith('insert')) return
133
+ const data = e?.data
134
+ if (typeof data !== 'string' || !data) return
135
+ for (let i = 0; i < data.length; i++) {
136
+ const ch = data[i] === ',' ? '.' : data[i]
137
+ const isDigit = ch >= '0' && ch <= '9'
138
+ if (isDigit || ch === '.' || ch === '-') continue
139
+ e.preventDefault()
140
+ return
141
+ }
142
+ }
143
+
144
+ const onPaste = (e) => {
145
+ if (!canEdit.value) return
146
+ const pasted = e?.clipboardData?.getData?.('text')
147
+ if (typeof pasted !== 'string') return
148
+ e.preventDefault()
149
+ const nextText = sanitize(pasted)
150
+ const target = e?.target
151
+ if (!target) return
152
+ target.value = nextText
153
+ onInput({ target })
154
+ }
155
+
156
+ const onKeydown = (e) => {
157
+ if (!canEdit.value) return
158
+ if (e.ctrlKey || e.metaKey || e.altKey) return
159
+ const key = e.key
160
+ if (typeof key !== 'string' || key.length !== 1) return
161
+ const ch = key === ',' ? '.' : key
162
+ const isDigit = ch >= '0' && ch <= '9'
163
+ if (isDigit || ch === '.' || ch === '-') return
164
+ e.preventDefault()
165
+ }
166
+
167
+ const onBlur = () => {
168
+ emit('blur')
169
+ if (props.bindField && field?.onBlur) field.onBlur()
170
+ const n = parse(text.value)
171
+ const clamped = clamp(n)
172
+ if (clamped == null) {
173
+ text.value = String(effectiveValue.value ?? '')
174
+ return
175
+ }
176
+ batch(() => {
177
+ text.value = String(clamped)
178
+ if (props.bindField && field?.setValue) field.setValue(clamped)
179
+ else emit('update:modelValue', clamped)
180
+ })
181
+ }
182
+
183
+ const onFocus = () => emit('focus')
184
+ </script>
185
+
186
+ <template>
187
+ <div class="n-inum">
188
+ <label v-if="label" class="n-inum-label">{{ label }}</label>
189
+ <div class="n-inum-wrap" :class="{ 'is-disabled': effectiveDisabled.value }">
190
+ <button type="button" class="n-inum-btn n-inum-dec" :disabled="effectiveDisabled.value || readonly" aria-label="Decrement" @click="dec">−</button>
191
+ <input class="n-inum-input" type="text" :value="text.value" :placeholder="placeholder" :disabled="effectiveDisabled.value" :readonly="readonly" inputmode="decimal" autocomplete="off" @beforeinput="onBeforeInput" @keydown="onKeydown" @paste="onPaste" @input="onInput" @focus="onFocus" @blur="onBlur" />
192
+ <button type="button" class="n-inum-btn n-inum-inc" :disabled="effectiveDisabled.value || readonly" aria-label="Increment" @click="inc">+</button>
193
+ </div>
194
+ </div>
195
+ </template>
196
+
197
+ <style scoped>
198
+ .n-inum{display:flex;flex-direction:column;gap:var(--n-space-2);width:100%;font-family:var(--n-font-sans)}.n-inum-label{display:block;font-size:var(--n-text-sm);font-weight:var(--n-weight-medium);color:var(--n-color-text-secondary);margin-bottom:var(--n-space-2)}.n-inum-wrap{display:flex;align-items:stretch;min-height:44px;background:var(--n-color-surface);border:1px solid var(--n-color-border);border-radius:var(--n-radius-md);overflow:hidden;transition:all var(--n-transition-fast)}.n-inum-wrap:focus-within{border-color:var(--n-color-primary);box-shadow:0 0 0 3px var(--n-color-primary-light)}.n-inum-input{flex:1;background:transparent;border:none;outline:none;padding:0.75rem 0.75rem;color:var(--n-color-text);font-size:var(--n-text-base);font-family:inherit;text-align:center;line-height:1.2;box-sizing:border-box}.n-inum-btn{width:2.5rem;background:transparent;border:none;color:var(--n-color-text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:var(--n-text-base);transition:all var(--n-transition-fast)}.n-inum-btn:hover:not(:disabled){background:var(--n-color-glass);color:var(--n-color-text)}.n-inum-btn:disabled{opacity:0.5;cursor:not-allowed}.is-disabled{opacity:0.6;cursor:not-allowed;background:var(--n-color-surface-alt)}
199
+ </style>
@@ -0,0 +1,221 @@
1
+ <script setup>
2
+ import { signal, effect, onMounted, onUnmounted } from 'nexa-framework'
3
+
4
+ const props = defineProps({
5
+ show: { type: Boolean, default: false },
6
+ title: { type: String, default: '' },
7
+ size: { type: String, default: 'md' },
8
+ closable: { type: Boolean, default: true }
9
+ })
10
+
11
+ const emit = defineEmits(['close'])
12
+
13
+ const isVisible = signal(false)
14
+ let modalEl = null
15
+ let previousFocus = null
16
+ const bodyOverflow = signal('')
17
+
18
+ const sizeMap = { sm: '400px', md: '500px', lg: '640px', xl: '800px', full: '96%' }
19
+
20
+ effect(() => {
21
+ if (props.show) {
22
+ previousFocus = document.activeElement
23
+ isVisible.value = true
24
+ bodyOverflow.value = document.body.style.overflow
25
+ document.body.style.overflow = 'hidden'
26
+ requestAnimationFrame(() => {
27
+ if (modalEl) modalEl.focus()
28
+ })
29
+ } else {
30
+ setTimeout(() => {
31
+ isVisible.value = false
32
+ document.body.style.overflow = bodyOverflow.value
33
+ if (previousFocus && previousFocus.focus) {
34
+ try { previousFocus.focus() } catch {}
35
+ }
36
+ }, 250)
37
+ }
38
+ })
39
+
40
+ const close = () => {
41
+ if (!props.closable) return
42
+ emit('close')
43
+ }
44
+
45
+ const handleEsc = (e) => {
46
+ if (e.key === 'Escape' && props.show) close()
47
+ }
48
+
49
+ const handleOverlayClick = (e) => {
50
+ if (e.target === e.currentTarget) close()
51
+ }
52
+
53
+ const focusableSelector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
54
+
55
+ const handleKeydown = (e) => {
56
+ if (e.key !== 'Tab' || !modalEl) return
57
+ const focusable = modalEl.querySelectorAll(focusableSelector)
58
+ if (focusable.length === 0) {
59
+ e.preventDefault()
60
+ return
61
+ }
62
+ const first = focusable[0]
63
+ const last = focusable[focusable.length - 1]
64
+ if (e.shiftKey && document.activeElement === first) {
65
+ e.preventDefault()
66
+ last.focus()
67
+ } else if (!e.shiftKey && document.activeElement === last) {
68
+ e.preventDefault()
69
+ first.focus()
70
+ }
71
+ }
72
+
73
+ onMounted(() => {
74
+ window.addEventListener('keydown', handleEsc)
75
+ })
76
+
77
+ onUnmounted(() => {
78
+ window.removeEventListener('keydown', handleEsc)
79
+ document.body.style.overflow = ''
80
+ })
81
+ </script>
82
+
83
+ <template>
84
+ <Teleport to="body">
85
+ <div v-if="isVisible.value" class="n-modal-root">
86
+ <div
87
+ class="n-modal-overlay"
88
+ :class="{ 'is-active': show }"
89
+ @click="handleOverlayClick"
90
+ ></div>
91
+ <div
92
+ ref="modalEl"
93
+ class="n-modal-container"
94
+ :class="{ 'is-active': show }"
95
+ :style="{ maxWidth: sizeMap[size] || size }"
96
+ tabindex="-1"
97
+ @keydown="handleKeydown"
98
+ >
99
+ <div v-if="title" class="n-modal-header">
100
+ <slot name="header">
101
+ <h3>{{ title }}</h3>
102
+ </slot>
103
+ <button v-if="closable" class="n-modal-close" @click="close" aria-label="Close">&times;</button>
104
+ </div>
105
+ <div class="n-modal-content">
106
+ <slot />
107
+ </div>
108
+ <div v-if="$slots.footer" class="n-modal-footer">
109
+ <slot name="footer" />
110
+ </div>
111
+ </div>
112
+ </div>
113
+ </Teleport>
114
+ </template>
115
+
116
+ <style scoped>
117
+ .n-modal-root {
118
+ position: fixed;
119
+ top: 0;
120
+ left: 0;
121
+ width: 100vw;
122
+ height: 100vh;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ z-index: var(--n-z-modal);
127
+ }
128
+
129
+ .n-modal-overlay {
130
+ position: absolute;
131
+ top: 0;
132
+ left: 0;
133
+ width: 100%;
134
+ height: 100%;
135
+ background: var(--n-color-overlay);
136
+ backdrop-filter: blur(8px);
137
+ opacity: 0;
138
+ transition: opacity 0.25s ease;
139
+ }
140
+
141
+ .n-modal-overlay.is-active {
142
+ opacity: 1;
143
+ }
144
+
145
+ .n-modal-container {
146
+ position: relative;
147
+ width: 90%;
148
+ background: var(--n-color-surface);
149
+ border: 1px solid var(--n-color-border);
150
+ border-radius: var(--n-radius-2xl);
151
+ box-shadow: var(--n-shadow-xl);
152
+ transform: scale(0.9) translateY(20px);
153
+ opacity: 0;
154
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
155
+ overflow: hidden;
156
+ outline: none;
157
+ max-height: 85vh;
158
+ display: flex;
159
+ flex-direction: column;
160
+ }
161
+
162
+ .n-modal-container.is-active {
163
+ transform: scale(1) translateY(0);
164
+ opacity: 1;
165
+ }
166
+
167
+ .n-modal-header {
168
+ padding: var(--n-space-6) var(--n-space-8);
169
+ border-bottom: 1px solid var(--n-color-border);
170
+ display: flex;
171
+ justify-content: space-between;
172
+ align-items: center;
173
+ flex-shrink: 0;
174
+ }
175
+
176
+ .n-modal-header h3 {
177
+ margin: 0;
178
+ font-size: var(--n-text-xl);
179
+ font-weight: var(--n-weight-bold);
180
+ color: var(--n-color-text);
181
+ }
182
+
183
+ .n-modal-close {
184
+ background: transparent;
185
+ border: none;
186
+ color: var(--n-color-text-secondary);
187
+ font-size: 1.75rem;
188
+ cursor: pointer;
189
+ transition: color var(--n-transition-fast);
190
+ padding: 0;
191
+ line-height: 1;
192
+ width: 36px;
193
+ height: 36px;
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ border-radius: var(--n-radius-sm);
198
+ }
199
+
200
+ .n-modal-close:hover {
201
+ color: var(--n-color-text);
202
+ background: var(--n-color-glass);
203
+ }
204
+
205
+ .n-modal-content {
206
+ padding: var(--n-space-8);
207
+ color: var(--n-color-text-secondary);
208
+ overflow-y: auto;
209
+ flex: 1;
210
+ }
211
+
212
+ .n-modal-footer {
213
+ padding: var(--n-space-5) var(--n-space-8);
214
+ background: rgba(0, 0, 0, 0.15);
215
+ border-top: 1px solid var(--n-color-border);
216
+ display: flex;
217
+ justify-content: flex-end;
218
+ gap: var(--n-space-4);
219
+ flex-shrink: 0;
220
+ }
221
+ </style>
@@ -0,0 +1,77 @@
1
+ <script setup>
2
+ import { signal, computed, inject, onBeforeUnmount, effect, batch } from 'nexa-framework'
3
+ import { trackFloatingOverlay } from '../services/FloatingOverlay.js'
4
+ const props = defineProps({ modelValue: { type: Array, default: () => [] }, options: { type: Array, default: () => [] }, placeholder: { type: String, default: 'Select...' }, label: { type: String, default: '' }, disabled: { type: Boolean, default: false }, bindField: { type: Boolean, default: false }, searchable: { type: Boolean, default: false }, clearable: { type: Boolean, default: false }, placement: { type: String, default: 'auto' }, appendTo: { type: String, default: 'self' }, maxChips: { type: Number, default: 3 } })
5
+ const emit = defineEmits(['update:modelValue', 'clear'])
6
+ const field = inject('nexa-ui:form-field', undefined)
7
+ const instanceId = `n-ms-${Math.random().toString(16).slice(2)}`
8
+ const isOpen = signal(false)
9
+ const query = signal('')
10
+ const queryDraft = signal('')
11
+ const focusedIndex = signal(-1)
12
+ const popupStyle = signal({})
13
+ const resolvedPlacement = signal('bottom')
14
+ const rootEl = signal(null)
15
+ let stopTracking = null
16
+ let outsideHandler = null
17
+ const normalizeOption = (opt) => { if (opt == null) return { label: '', value: null, disabled: false }; if (typeof opt === 'string' || typeof opt === 'number') return { label: String(opt), value: opt, disabled: false }; if (typeof opt === 'object') { const label = 'label' in opt ? String(opt.label) : String(opt.value ?? ''); const value = 'value' in opt ? opt.value : label; return { label, value, disabled: !!opt.disabled } } return { label: String(opt), value: opt, disabled: false } }
18
+ const normalizedOptions = computed(() => props.options.map(normalizeOption))
19
+ const effectiveModelValue = computed(() => {
20
+ if (props.bindField && field?.value) {
21
+ const v = field.value.value
22
+ return Array.isArray(v) ? v : []
23
+ }
24
+ return props.modelValue
25
+ })
26
+
27
+ const effectiveDisabled = computed(() => {
28
+ if (props.disabled) return true
29
+ if (props.bindField && field?.disabled) return !!field.disabled.value
30
+ return false
31
+ })
32
+
33
+ const selectedSet = computed(() => new Set(Array.isArray(effectiveModelValue.value) ? effectiveModelValue.value : []))
34
+ const filteredOptions = computed(() => { const q = props.searchable ? query.value.trim().toLowerCase() : ''; if (!q) return normalizedOptions.value; return normalizedOptions.value.filter(o => o.label.toLowerCase().includes(q)) })
35
+ const selectedLabels = computed(() => { const set = selectedSet.value; const out = []; for (const opt of normalizedOptions.value) { if (set.has(opt.value)) out.push({ label: opt.label, value: opt.value }) } return out })
36
+ const close = () => { if (!isOpen.value) return; isOpen.value = false; focusedIndex.value = -1; query.value = ''; queryDraft.value = ''; if (stopTracking) { stopTracking(); stopTracking = null } if (outsideHandler) { document.removeEventListener('mousedown', outsideHandler); outsideHandler = null } if (props.bindField && field?.onBlur) field.onBlur() }
37
+ const open = (e) => { if (effectiveDisabled.value || isOpen.value) return; isOpen.value = true; focusedIndex.value = filteredOptions.value.length > 0 ? 0 : -1; const t = e?.currentTarget || e?.target; rootEl.value = t?.closest ? t.closest(`[data-ms-root="${instanceId}"]`) : null; if (props.appendTo === 'body') { stopTracking = trackFloatingOverlay({ isOpen: () => isOpen.value, getAnchor: () => { const root = rootEl.value; return root ? root.querySelector('.n-ms-trigger') : null }, getPopup: () => document.querySelector(`[data-ms-popup="${instanceId}"]`), placement: props.placement, align: 'start', matchWidth: true, minWidth: 240, gap: 8, margin: 8, zIndex: 9999, onUpdate: (r) => { popupStyle.value = r.style; resolvedPlacement.value = r.placement }, isEventInside: (ev) => { const el = ev.target; if (!el || typeof el.closest !== 'function') return false; return !!(el.closest(`[data-ms-root="${instanceId}"]`) || el.closest(`[data-ms-popup="${instanceId}"]`)) }, onOutside: () => close() }) } else { if (outsideHandler) document.removeEventListener('mousedown', outsideHandler); outsideHandler = (event) => { const el = event.target; if (!el || typeof el.closest !== 'function') return; if (el.closest(`[data-ms-root="${instanceId}"]`)) return; close() }; document.addEventListener('mousedown', outsideHandler) } requestAnimationFrame(() => { if (!props.searchable) return; const search = props.appendTo === 'body' ? document.querySelector(`[data-ms-popup="${instanceId}"] .n-ms-search-input`) : rootEl.value?.querySelector?.('.n-ms-search-input'); if (search && typeof search.focus === 'function') { try { search.focus() } catch {} } }) }
38
+ const toggleOpen = (e) => { if (effectiveDisabled.value) return; if (isOpen.value) close(); else open(e) }
39
+ const setModel = (next) => { if (props.bindField && field?.setValue) field.setValue(next); else emit('update:modelValue', next) }
40
+ const toggleValue = (value) => { if (effectiveDisabled.value) return; const cur = Array.isArray(effectiveModelValue.value) ? effectiveModelValue.value : []; const next = cur.includes(value) ? cur.filter(v => v !== value) : [...cur, value]; setModel(next) }
41
+ const clear = () => { if (effectiveDisabled.value) return; setModel([]); emit('clear'); query.value = ''; queryDraft.value = '' }
42
+ const removeChip = (value, e) => { e?.stopPropagation?.(); const cur = Array.isArray(effectiveModelValue.value) ? effectiveModelValue.value : []; setModel(cur.filter(v => v !== value)) }
43
+ const onKeydown = (e) => { if (!isOpen.value) { if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); open(e) } return } const items = filteredOptions.value; if (e.key === 'ArrowDown') { e.preventDefault(); focusedIndex.value = Math.min(focusedIndex.value + 1, items.length - 1); return } if (e.key === 'ArrowUp') { e.preventDefault(); focusedIndex.value = Math.max(focusedIndex.value - 1, 0); return } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const opt = items[focusedIndex.value]; if (!opt || opt.disabled) return; toggleValue(opt.value); return } if (e.key === 'Escape') { e.preventDefault(); close() } }
44
+ effect(() => {
45
+ if (!isOpen.value) return
46
+ if (!props.searchable) return
47
+ if (queryDraft.value === query.value) return
48
+ queryDraft.value = query.value
49
+ })
50
+
51
+ const onSearchInput = (e) => {
52
+ const next = e?.target?.value ?? ''
53
+ batch(() => {
54
+ queryDraft.value = next
55
+ query.value = next
56
+ focusedIndex.value = filteredOptions.value.length > 0 ? 0 : -1
57
+ })
58
+ }
59
+ onBeforeUnmount(() => close())
60
+ </script>
61
+
62
+ <template>
63
+ <div class="n-ms" :class="{ 'is-open': isOpen.value, 'is-disabled': effectiveDisabled.value }" :data-ms-root="instanceId">
64
+ <label v-if="label" class="n-ms-label">{{ label }}</label>
65
+ <div class="n-ms-trigger" role="combobox" tabindex="0" :aria-expanded="isOpen.value" @click="toggleOpen" @keydown="onKeydown">
66
+ <div v-if="selectedLabels.value.length > 0" class="n-ms-chips"><div v-for="c in selectedLabels.value.slice(0, maxChips)" :key="String(c.value)" class="n-ms-chip"><span class="n-ms-chip-label">{{ c.label }}</span><button type="button" class="n-ms-chip-remove" aria-label="Remove" tabindex="-1" :disabled="effectiveDisabled.value" @click="removeChip(c.value, $event)">✕</button></div><span v-if="selectedLabels.value.length > maxChips" class="n-ms-more">+{{ selectedLabels.value.length - maxChips }}</span></div>
67
+ <span v-else class="n-ms-placeholder">{{ placeholder }}</span>
68
+ <div class="n-ms-actions"><button v-if="clearable && selectedLabels.value.length > 0" type="button" class="n-ms-clear" tabindex="-1" @click.stop="clear">✕</button><span class="n-ms-arrow">▾</span></div>
69
+ </div>
70
+ <div v-if="isOpen.value && appendTo !== 'body'" class="n-ms-dropdown"><div v-if="searchable" class="n-ms-search"><input class="n-ms-search-input" :value="queryDraft.value" placeholder="Search..." @input="onSearchInput" @keydown.stop="onKeydown" /></div><div class="n-ms-options"><button v-for="(opt, i) in filteredOptions.value" :key="String(opt.value) + ':' + i" type="button" class="n-ms-option" :data-ms-opt="String(opt.value)" :class="{ 'is-selected': selectedSet.value.has(opt.value), 'is-focused': i === focusedIndex.value, 'is-disabled': opt.disabled }" :disabled="opt.disabled" @mouseenter="focusedIndex.value = i" @click="toggleValue(opt.value)"><span class="n-ms-check">{{ selectedSet.value.has(opt.value) ? '✓' : '' }}</span><span class="n-ms-option-label">{{ opt.label }}</span></button><div v-if="filteredOptions.value.length === 0" class="n-ms-empty">No options</div></div></div>
71
+ <Teleport to="body"><div v-if="isOpen.value && appendTo === 'body'" class="n-ms-dropdown" :class="{ 'is-top': resolvedPlacement.value === 'top' }" :data-ms-popup="instanceId" :style="popupStyle.value"><div v-if="searchable" class="n-ms-search"><input class="n-ms-search-input" :value="queryDraft.value" placeholder="Search..." @input="onSearchInput" @keydown.stop="onKeydown" /></div><div class="n-ms-options"><button v-for="(opt, i) in filteredOptions.value" :key="String(opt.value) + ':' + i" type="button" class="n-ms-option" :data-ms-opt="String(opt.value)" :class="{ 'is-selected': selectedSet.value.has(opt.value), 'is-focused': i === focusedIndex.value, 'is-disabled': opt.disabled }" :disabled="opt.disabled" @mouseenter="focusedIndex.value = i" @click="toggleValue(opt.value)"><span class="n-ms-check">{{ selectedSet.value.has(opt.value) ? '✓' : '' }}</span><span class="n-ms-option-label">{{ opt.label }}</span></button><div v-if="filteredOptions.value.length === 0" class="n-ms-empty">No options</div></div></div></Teleport>
72
+ </div>
73
+ </template>
74
+
75
+ <style scoped>
76
+ .n-ms{position:relative;width:100%;font-family:var(--n-font-sans)}.n-ms-label{display:block;font-size:var(--n-text-sm);font-weight:var(--n-weight-medium);color:var(--n-color-text-secondary);margin-bottom:var(--n-space-2)}.n-ms-trigger{background:var(--n-color-surface);border:1px solid var(--n-color-border);border-radius:var(--n-radius-md);padding:0.5rem 0.75rem;display:flex;justify-content:space-between;align-items:center;cursor:pointer;transition:all var(--n-transition-fast);color:var(--n-color-text);gap:var(--n-space-2);min-height:44px}.n-ms-trigger:focus-visible{border-color:var(--n-color-primary);box-shadow:0 0 0 3px var(--n-color-primary-light)}.n-ms.is-open .n-ms-trigger{border-color:var(--n-color-primary);box-shadow:0 0 0 3px var(--n-color-primary-light)}.n-ms-placeholder{color:var(--n-color-text-muted)}.n-ms-actions{display:flex;align-items:center;gap:0.25rem;flex-shrink:0}.n-ms-clear{background:transparent;border:none;color:var(--n-color-text-muted);cursor:pointer;padding:0.15rem;font-size:var(--n-text-xs);border-radius:var(--n-radius-sm);line-height:1}.n-ms-clear:hover{color:var(--n-color-text)}.n-ms-arrow{color:var(--n-color-text-muted);transition:transform var(--n-transition-fast);font-size:var(--n-text-xs)}.is-open .n-ms-arrow{transform:rotate(180deg)}.n-ms-chips{display:flex;flex-wrap:wrap;gap:var(--n-space-2);align-items:center;min-width:0}.n-ms-chip{display:inline-flex;align-items:center;gap:0.35rem;padding:0.2rem 0.45rem;border-radius:999px;background:var(--n-color-glass);border:1px solid var(--n-color-border);font-size:var(--n-text-xs);line-height:1;max-width:12rem}.n-ms-chip-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:10rem}.n-ms-chip-remove{background:transparent;border:none;color:var(--n-color-text-muted);cursor:pointer;padding:0;line-height:1;display:flex;align-items:center}.n-ms-chip-remove:hover:not(:disabled){color:var(--n-color-text)}.n-ms-chip-remove:disabled{opacity:0.5;cursor:not-allowed}.n-ms-more{font-size:var(--n-text-xs);color:var(--n-color-text-muted)}.n-ms-dropdown{position:absolute;top:calc(100% + 0.5rem);left:0;width:100%;background:var(--n-color-surface);border:1px solid var(--n-color-border);border-radius:var(--n-radius-md);box-shadow:var(--n-shadow-lg);z-index:var(--n-z-dropdown);overflow:hidden;animation:n-ms-in .2s cubic-bezier(0,1,0,1)}@keyframes n-ms-in{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}.n-ms-dropdown.is-top{animation:n-ms-in-top .2s cubic-bezier(0,1,0,1)}@keyframes n-ms-in-top{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.n-ms-search{padding:var(--n-space-2);border-bottom:1px solid var(--n-color-border)}.n-ms-search-input{width:100%;background:var(--n-color-bg);border:1px solid var(--n-color-border);border-radius:var(--n-radius-sm);padding:var(--n-space-2) var(--n-space-3);color:var(--n-color-text);font-size:var(--n-text-sm);outline:none;font-family:inherit}.n-ms-search-input:focus{border-color:var(--n-color-primary)}.n-ms-options{max-height:260px;overflow-y:auto}.n-ms-option{width:100%;display:flex;align-items:center;gap:0.5rem;padding:0.7rem 1rem;background:transparent;border:none;color:var(--n-color-text-secondary);cursor:pointer;transition:all var(--n-transition-fast);text-align:left}.n-ms-option:hover:not(:disabled),.n-ms-option.is-focused{background:var(--n-color-glass);color:var(--n-color-text)}.n-ms-option.is-selected{background:var(--n-color-primary-light);color:var(--n-color-primary);font-weight:var(--n-weight-semibold)}.n-ms-option:disabled,.n-ms-option.is-disabled{opacity:0.4;cursor:not-allowed}.n-ms-check{width:1.25rem;display:inline-flex;align-items:center;justify-content:center;font-size:var(--n-text-xs)}.n-ms-empty{padding:var(--n-space-4);color:var(--n-color-text-muted);text-align:center;font-size:var(--n-text-sm)}.is-disabled .n-ms-trigger{opacity:0.5;cursor:not-allowed}
77
+ </style>
@@ -0,0 +1,77 @@
1
+ <script setup>
2
+ import { computed } from 'nexa-framework'
3
+
4
+ const props = defineProps({
5
+ first: { type: Number, default: 0 },
6
+ rows: { type: Number, default: 10 },
7
+ totalRecords: { type: Number, default: 0 },
8
+ rowsPerPageOptions: { type: Array, default: () => [10, 20, 50] },
9
+ })
10
+
11
+ const emit = defineEmits(['update:first', 'update:rows', 'updateFirst', 'updateRows', 'page'])
12
+
13
+ const safeFirst = computed(() => Number.isFinite(props.first) ? props.first : 0)
14
+ const safeRows = computed(() => {
15
+ const r = Number.isFinite(props.rows) ? props.rows : 10
16
+ return r > 0 ? r : 10
17
+ })
18
+
19
+ const page = computed(() => Math.floor((safeFirst.value || 0) / (safeRows.value || 1)))
20
+ const pageCount = computed(() => {
21
+ const rows = safeRows.value || 1
22
+ return Math.max(1, Math.ceil((props.totalRecords || 0) / rows))
23
+ })
24
+
25
+ const canPrev = computed(() => page.value > 0)
26
+ const canNext = computed(() => page.value < pageCount.value - 1)
27
+
28
+ const goToPage = (p) => {
29
+ const clamped = Math.min(Math.max(p, 0), pageCount.value - 1)
30
+ const nextFirst = clamped * (safeRows.value || 1)
31
+ emit('update:first', nextFirst)
32
+ emit('updateFirst', nextFirst)
33
+ emit('page', { page: clamped, first: nextFirst, rows: safeRows.value })
34
+ }
35
+
36
+ const prev = () => { if (!canPrev.value) return; goToPage(page.value - 1) }
37
+ const next = () => { if (!canNext.value) return; goToPage(page.value + 1) }
38
+ const firstPage = () => goToPage(0)
39
+ const lastPage = () => goToPage(pageCount.value - 1)
40
+
41
+ const changeRows = (e) => {
42
+ const nextRows = Number(e.target.value)
43
+ if (!Number.isFinite(nextRows) || nextRows <= 0) return
44
+ emit('update:rows', nextRows)
45
+ emit('update:first', 0)
46
+ emit('updateRows', nextRows)
47
+ emit('updateFirst', 0)
48
+ emit('page', { page: 0, first: 0, rows: nextRows })
49
+ }
50
+
51
+ const start = computed(() => Math.min((safeFirst.value || 0) + 1, props.totalRecords || 0))
52
+ const end = computed(() => Math.min((safeFirst.value || 0) + (safeRows.value || 0), props.totalRecords || 0))
53
+ </script>
54
+
55
+ <template>
56
+ <div class="n-paginator">
57
+ <div class="n-paginator-left">
58
+ <span class="n-paginator-report">{{ start.value }}-{{ end.value }} of {{ totalRecords }}</span>
59
+ </div>
60
+ <div class="n-paginator-center">
61
+ <button type="button" class="n-pg-btn" :disabled="!canPrev.value" @click="firstPage">«</button>
62
+ <button type="button" class="n-pg-btn" :disabled="!canPrev.value" @click="prev">‹</button>
63
+ <span class="n-pg-page">{{ page.value + 1 }} / {{ pageCount.value }}</span>
64
+ <button type="button" class="n-pg-btn" :disabled="!canNext.value" @click="next">›</button>
65
+ <button type="button" class="n-pg-btn" :disabled="!canNext.value" @click="lastPage">»</button>
66
+ </div>
67
+ <div class="n-paginator-right">
68
+ <select class="n-pg-select" :value="rows" @change="changeRows">
69
+ <option v-for="n in rowsPerPageOptions" :key="n" :value="n">{{ n }}</option>
70
+ </select>
71
+ </div>
72
+ </div>
73
+ </template>
74
+
75
+ <style scoped>
76
+ .n-paginator{display:flex;align-items:center;justify-content:space-between;gap:var(--n-space-3);padding:var(--n-space-3) var(--n-space-4);border-top:1px solid var(--n-color-border);background:var(--n-color-surface);font-family:var(--n-font-sans)}.n-paginator-report{color:var(--n-color-text-muted);font-size:var(--n-text-xs)}.n-paginator-center{display:flex;align-items:center;gap:0.35rem}.n-pg-btn{background:transparent;border:1px solid var(--n-color-border);color:var(--n-color-text);border-radius:var(--n-radius-sm);padding:0.25rem 0.5rem;cursor:pointer;transition:all var(--n-transition-fast)}.n-pg-btn:hover:not(:disabled){background:var(--n-color-glass)}.n-pg-btn:disabled{opacity:0.5;cursor:not-allowed}.n-pg-page{min-width:5rem;text-align:center;color:var(--n-color-text-secondary);font-size:var(--n-text-xs)}.n-pg-select{background:var(--n-color-bg);border:1px solid var(--n-color-border);color:var(--n-color-text);border-radius:var(--n-radius-sm);padding:0.25rem 0.5rem;font-size:var(--n-text-xs);outline:none}
77
+ </style>