@indielayer/ui 1.14.4 → 1.15.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 (59) hide show
  1. package/docs/pages/component/avatar/usage.vue +1 -1
  2. package/docs/pages/component/input/usage.vue +22 -8
  3. package/docs/pages/component/table/selectable.vue +1 -1
  4. package/docs/pages/component/table/virtual.vue +2 -1
  5. package/docs/pages/component/tag/usage.vue +1 -1
  6. package/docs/pages/component/textarea/usage.vue +22 -8
  7. package/lib/components/avatar/Avatar.vue2.js +20 -19
  8. package/lib/components/avatar/theme/Avatar.base.theme.js +9 -12
  9. package/lib/components/datepicker/Datepicker.vue.js +4 -4
  10. package/lib/components/drawer/Drawer.vue.js +66 -60
  11. package/lib/components/input/Input.vue.d.ts +8 -0
  12. package/lib/components/input/Input.vue.js +84 -69
  13. package/lib/components/inputFooter/InputFooter.vue.d.ts +13 -2
  14. package/lib/components/inputFooter/InputFooter.vue.js +35 -19
  15. package/lib/components/inputFooter/theme/InputFooter.base.theme.js +3 -1
  16. package/lib/components/inputFooter/theme/InputFooter.carbon.theme.js +3 -1
  17. package/lib/components/menu/MenuItem.vue.js +2 -2
  18. package/lib/components/menu/MenuItem.vue2.js +1 -0
  19. package/lib/components/popover/Popover.vue.d.ts +1 -1
  20. package/lib/components/select/Select.vue.d.ts +38 -10
  21. package/lib/components/select/Select.vue.js +210 -200
  22. package/lib/components/table/Table.vue.d.ts +55 -19
  23. package/lib/components/table/Table.vue.js +256 -214
  24. package/lib/components/table/TableCell.vue.d.ts +9 -0
  25. package/lib/components/table/TableCell.vue.js +45 -21
  26. package/lib/components/table/TableHeader.vue.js +14 -14
  27. package/lib/components/table/theme/TableCell.base.theme.js +3 -3
  28. package/lib/components/tag/Tag.vue.d.ts +3 -0
  29. package/lib/components/tag/Tag.vue.js +37 -35
  30. package/lib/components/textarea/Textarea.vue.d.ts +19 -3
  31. package/lib/components/textarea/Textarea.vue.js +98 -76
  32. package/lib/components/textarea/theme/Textarea.base.theme.js +2 -1
  33. package/lib/components/textarea/theme/Textarea.carbon.theme.js +2 -1
  34. package/lib/components/upload/Upload.vue.js +91 -86
  35. package/lib/index.js +1 -1
  36. package/lib/index.umd.js +4 -4
  37. package/lib/version.d.ts +1 -1
  38. package/lib/version.js +1 -1
  39. package/package.json +1 -1
  40. package/src/components/avatar/Avatar.vue +2 -2
  41. package/src/components/avatar/theme/Avatar.base.theme.ts +0 -5
  42. package/src/components/datepicker/Datepicker.vue +7 -2
  43. package/src/components/drawer/Drawer.vue +13 -2
  44. package/src/components/input/Input.vue +27 -2
  45. package/src/components/inputFooter/InputFooter.vue +35 -3
  46. package/src/components/inputFooter/theme/InputFooter.base.theme.ts +2 -0
  47. package/src/components/inputFooter/theme/InputFooter.carbon.theme.ts +2 -0
  48. package/src/components/menu/MenuItem.vue +1 -0
  49. package/src/components/select/Select.vue +21 -8
  50. package/src/components/table/Table.vue +170 -48
  51. package/src/components/table/TableCell.vue +23 -0
  52. package/src/components/table/TableHeader.vue +2 -2
  53. package/src/components/table/theme/TableCell.base.theme.ts +20 -11
  54. package/src/components/tag/Tag.vue +8 -3
  55. package/src/components/textarea/Textarea.vue +63 -30
  56. package/src/components/textarea/theme/Textarea.base.theme.ts +2 -0
  57. package/src/components/textarea/theme/Textarea.carbon.theme.ts +2 -0
  58. package/src/components/upload/Upload.vue +12 -2
  59. package/src/version.ts +1 -1
package/lib/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "1.14.4";
1
+ declare const _default: "1.15.0";
2
2
  export default _default;
package/lib/version.js CHANGED
@@ -1,4 +1,4 @@
1
- const e = "1.14.4";
1
+ const e = "1.15.0";
2
2
  export {
3
3
  e as default
4
4
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indielayer/ui",
3
- "version": "1.14.4",
3
+ "version": "1.15.0",
4
4
  "description": "Indielayer UI Components with Tailwind CSS build for Vue 3",
5
5
  "author": {
6
6
  "name": "João Teixeira",
@@ -78,11 +78,11 @@ const { styles, classes, className } = useTheme('Avatar', {}, props, { source })
78
78
  v-if="source"
79
79
  :alt="alt"
80
80
  :src="image"
81
- class="h-full w-full"
81
+ class="absolute top-0 left-0 h-full w-full"
82
82
  />
83
83
 
84
84
  <span
85
- v-else-if="name"
85
+ v-if="name"
86
86
  class="leading-none"
87
87
  >{{ initials }}</span>
88
88
 
@@ -22,11 +22,6 @@ const theme: AvatarTheme = {
22
22
  styles({ props, colors, css, data }) {
23
23
  const color = colors.getPalette(props.color)
24
24
 
25
- if (data.source) return css.variables({
26
- bg: 'transparent',
27
- border: props.outlined ? color[500] : 'transparent',
28
- })
29
-
30
25
  return css.variables({
31
26
  bg: color[100],
32
27
  text: color[500],
@@ -190,7 +190,7 @@ const { styles, classes, className } = useTheme('Datepicker', {}, props)
190
190
 
191
191
  <template>
192
192
  <div
193
- :style="styles"
193
+ :style="[styles, { '--dp-clear-btn-top': !!label ? '2.75rem' : '1.2rem' }]"
194
194
  :class="[
195
195
  className,
196
196
  classes.wrapper,
@@ -336,7 +336,12 @@ const { styles, classes, className } = useTheme('Datepicker', {}, props)
336
336
  }
337
337
 
338
338
  .dp--clear-btn {
339
- top: 2.75rem !important;
339
+ top: var(--dp-clear-btn-top, 2.75rem) !important;
340
+ }
341
+
342
+ .dp--clear-btn svg {
343
+ height: 1rem;
344
+ width: 1rem;
340
345
  }
341
346
 
342
347
  .dp__theme_dark {
@@ -137,7 +137,13 @@ function onEnter(el: Element, done: () => void) {
137
137
 
138
138
  return
139
139
  }
140
- el.addEventListener('transitionend', done)
140
+
141
+ const handler = () => {
142
+ el.removeEventListener('transitionend', handler)
143
+ done()
144
+ }
145
+
146
+ el.addEventListener('transitionend', handler)
141
147
  setTimeout(() => {
142
148
  if (props.backdrop) el.classList.add('bg-slate-500/30')
143
149
  if (props.position === 'top') (el as HTMLElement).style.top = '0'
@@ -150,7 +156,12 @@ function onEnter(el: Element, done: () => void) {
150
156
  function onBeforeLeave(el: Element) {}
151
157
 
152
158
  function onLeave(el: Element, done: () => void) {
153
- el.addEventListener('transitionend', done)
159
+ const handler = () => {
160
+ el.removeEventListener('transitionend', handler)
161
+ done()
162
+ }
163
+
164
+ el.addEventListener('transitionend', handler)
154
165
  setTimeout(() => {
155
166
  if (props.backdrop) el.classList.remove('bg-slate-500/30')
156
167
  if (props.position === 'top') (el as HTMLElement).style.top = `-${props.height}px`
@@ -26,6 +26,8 @@ const inputProps = {
26
26
  },
27
27
  step: [Number, String],
28
28
  block: Boolean,
29
+ showCounter: Boolean,
30
+ clearable: Boolean,
29
31
  }
30
32
 
31
33
  export type InputProps = ExtractPublicPropTypes<typeof inputProps>
@@ -49,7 +51,7 @@ import { useColors } from '../../composables/useColors'
49
51
  import { useCommon } from '../../composables/useCommon'
50
52
  import { useInputtable } from '../../composables/useInputtable'
51
53
  import { useInteractive } from '../../composables/useInteractive'
52
- import { eyeIcon, eyeVisibleIcon } from '../../common/icons'
54
+ import { closeIcon, eyeIcon, eyeVisibleIcon } from '../../common/icons'
53
55
 
54
56
  import XLabel from '../label/Label.vue'
55
57
  import XIcon from '../icon/Icon.vue'
@@ -95,6 +97,8 @@ function togglePasswordVisibility() {
95
97
  currentType.value = currentType.value === 'password' ? 'text' : 'password'
96
98
  }
97
99
 
100
+ const showClearIcon = computed(() => props.clearable && props.modelValue !== '')
101
+
98
102
  const { focus, blur } = useInteractive(elRef)
99
103
 
100
104
  const {
@@ -107,6 +111,12 @@ const {
107
111
  setError,
108
112
  } = useInputtable(props, { focus, emit })
109
113
 
114
+ const currentLength = computed(() => {
115
+ const value = props.modelValue
116
+
117
+ return value ? String(value).length : 0
118
+ })
119
+
110
120
  const { styles, classes, className } = useTheme('Input', {}, props, { errorInternal })
111
121
 
112
122
  defineExpose({ focus, blur, reset, validate, setError })
@@ -170,6 +180,14 @@ defineExpose({ focus, blur, reset, validate, setError })
170
180
  />
171
181
 
172
182
  <slot name="suffix">
183
+ <x-icon
184
+ v-if="showClearIcon"
185
+ :size="size"
186
+ :icon="closeIcon"
187
+ class="mr-2 right-1 cursor-pointer"
188
+ :class="classes.icon"
189
+ @click="reset()"
190
+ />
173
191
  <x-icon
174
192
  v-if="iconRight"
175
193
  :size="size"
@@ -188,6 +206,13 @@ defineExpose({ focus, blur, reset, validate, setError })
188
206
  </slot>
189
207
  </div>
190
208
 
191
- <x-input-footer v-if="!hideFooterInternal" :error="errorInternal" :helper="helper"/>
209
+ <x-input-footer
210
+ v-if="!hideFooterInternal"
211
+ :error="errorInternal"
212
+ :helper="helper"
213
+ :character-count="currentLength"
214
+ :max-characters="maxlength"
215
+ :show-counter="showCounter"
216
+ />
192
217
  </x-label>
193
218
  </template>
@@ -2,28 +2,60 @@
2
2
  const inputFooterProps = {
3
3
  helper: String,
4
4
  error: String,
5
+ characterCount: Number,
6
+ maxCharacters: [Number, String],
7
+ showCounter: Boolean,
5
8
  }
6
9
 
7
10
  export type InputFooterProps = ExtractPublicPropTypes<typeof inputFooterProps>
8
11
 
9
- type InternalClasses = 'wrapper' | 'helperText' | 'errorText'
12
+ type InternalClasses = 'wrapper' | 'helperText' | 'errorText' | 'container' | 'counter'
10
13
  export interface InputFooterTheme extends ThemeComponent<InputFooterProps, InternalClasses> {}
11
14
 
12
15
  export default { name: 'XInputFooter' }
13
16
  </script>
14
17
 
15
18
  <script setup lang="ts">
19
+ import { computed } from 'vue'
16
20
  import type { ExtractPublicPropTypes } from 'vue'
17
21
  import { useTheme, type ThemeComponent } from '../../composables/useTheme'
18
22
 
19
23
  const props = defineProps(inputFooterProps)
20
24
 
21
25
  const { styles, classes, className } = useTheme('InputFooter', {}, props)
26
+
27
+ const maxChars = computed(() => {
28
+ return props.maxCharacters ? Number(props.maxCharacters) : undefined
29
+ })
30
+
31
+ const counterText = computed(() => {
32
+ if (props.characterCount === undefined) return ''
33
+
34
+ if (maxChars.value) {
35
+ return `${props.characterCount}/${maxChars.value}`
36
+ }
37
+
38
+ return `${props.characterCount}`
39
+ })
40
+
41
+ const hasMessage = computed(() => props.error || props.helper)
22
42
  </script>
23
43
 
24
44
  <template>
25
45
  <div :class="[className, classes.wrapper]" :style="styles">
26
- <p v-if="error" :class="classes.errorText">{{ error }}</p>
27
- <p v-else-if="helper" :class="classes.helperText">{{ helper }}</p>
46
+ <div v-if="hasMessage || showCounter" :class="classes.container">
47
+ <div>
48
+ <p v-if="error" :class="classes.errorText">{{ error }}</p>
49
+ <p v-else-if="helper" :class="classes.helperText">{{ helper }}</p>
50
+ </div>
51
+ <p
52
+ v-if="showCounter"
53
+ :class="classes.counter"
54
+ role="status"
55
+ aria-live="polite"
56
+ >
57
+ {{ counterText }}
58
+ </p>
59
+ </div>
28
60
  </div>
29
61
  </template>
@@ -3,8 +3,10 @@ import type { InputFooterTheme } from '../InputFooter.vue'
3
3
  const theme: InputFooterTheme = {
4
4
  classes: {
5
5
  wrapper: 'text-xs mt-1',
6
+ container: 'flex justify-between items-start gap-2',
6
7
  helperText: 'text-secondary-500 dark:text-secondary-400',
7
8
  errorText: 'text-error-500 dark:text-error-400',
9
+ counter: 'text-secondary-500 dark:text-secondary-400 whitespace-nowrap',
8
10
  },
9
11
  }
10
12
 
@@ -3,8 +3,10 @@ import type { InputFooterTheme } from '../InputFooter.vue'
3
3
  const theme: InputFooterTheme = {
4
4
  classes: {
5
5
  wrapper: 'text-xs mt-1',
6
+ container: 'flex justify-between items-start gap-2',
6
7
  helperText: 'text-secondary-500 dark:text-secondary-400',
7
8
  errorText: 'text-error-500 dark:text-error-400',
9
+ counter: 'text-secondary-500 dark:text-secondary-400 whitespace-nowrap',
8
10
  },
9
11
  }
10
12
 
@@ -156,6 +156,7 @@ const { styles, classes, className } = useTheme('MenuItem', {}, computedProps, {
156
156
  v-if="checkbox"
157
157
  :model-value="computedProps.active"
158
158
  hide-footer
159
+ class="mr-1.5"
159
160
  skip-form-registry
160
161
  @click.stop.prevent="onItemClick($event, 'checkbox-click')"
161
162
  />
@@ -110,19 +110,32 @@ const selected = computed<any | any[]>({
110
110
  },
111
111
  })
112
112
 
113
+ const labelCache = computed(() => {
114
+ if (!props.options) return new Map<SelectOption, string>()
115
+
116
+ return new Map(props.options.map((option) => [option, option.label.toLowerCase()]))
117
+ })
118
+
113
119
  const internalOptions = computed(() => {
114
120
  if (!props.options || props.options.length === 0) return []
115
121
 
122
+ const filterLower = filter.value.toLowerCase()
123
+ const hasFilter = filter.value !== ''
124
+
125
+ const selectedSet = new Set(
126
+ internalMultiple.value && Array.isArray(selected.value)
127
+ ? selected.value
128
+ : [],
129
+ )
130
+ const singleSelectedValue = !internalMultiple.value ? selected.value : null
131
+ const cache = labelCache.value
132
+
116
133
  return props.options
117
- .filter((option) => filter.value === '' || option.label.toLowerCase().includes(filter.value.toLowerCase()))
134
+ .filter((option) => !hasFilter || cache.get(option)?.includes(filterLower))
118
135
  .map((option) => {
119
- let isActive = false
120
-
121
- if (internalMultiple.value && Array.isArray(selected.value)) {
122
- isActive = selected.value.includes(option.value)
123
- } else {
124
- isActive = option.value === selected.value
125
- }
136
+ const isActive = internalMultiple.value
137
+ ? selectedSet.has(option.value)
138
+ : option.value === singleSelectedValue
126
139
 
127
140
  return {
128
141
  value: option.value,
@@ -50,6 +50,9 @@ const tableProps = {
50
50
  type: Boolean,
51
51
  default: true,
52
52
  },
53
+ toFn: Function as PropType<(item: unknown) => string | Record<string, unknown> | undefined>,
54
+ hrefFn: Function as PropType<(item: unknown) => string>,
55
+ hrefTarget: String as PropType<'_blank' | '_self' | '_parent' | '_top'>,
53
56
  }
54
57
 
55
58
  export type TableHeader = {
@@ -97,21 +100,15 @@ const props = defineProps({
97
100
  type: Array as PropType<T[]>,
98
101
  default: () => [],
99
102
  },
103
+ toFn: Function as PropType<(item: T) => string | Record<string, unknown> | undefined>,
104
+ hrefFn: Function as PropType<(item: T) => string>,
105
+ hrefTarget: String as PropType<'_blank' | '_self' | '_parent' | '_top'>,
100
106
  })
101
107
 
102
108
  const selected = defineModel<(number | string) | (number | string)[]>('selected')
103
109
 
104
- type internalT = T & {
105
- __expanded?: boolean;
106
- }
107
-
108
- function clone<T>(source: T[]): T[] {
109
- try {
110
- return JSON.parse(JSON.stringify(source))
111
- } catch (e) {
112
- return []
113
- }
114
- }
110
+ // Use Map for expandable state to handle virtual list correctly
111
+ const expandedState = ref(new Map<number | string, boolean>())
115
112
 
116
113
  const items = computed(() => props.items)
117
114
 
@@ -126,9 +123,40 @@ const { list, containerProps, wrapperProps } = useVirtualList(
126
123
  },
127
124
  )
128
125
 
129
- const internalItems = ref<internalT[]>([])
126
+ // Helper function to get item key with validation
127
+ function getItemKey(item: T, index: number): number | string {
128
+ if (!props.keyProp || !item || typeof item !== 'object' || item === null) {
129
+ return index
130
+ }
131
+
132
+ const keyValue = (item as Record<string, unknown>)[props.keyProp]
133
+
134
+ // Validate that the key exists and is a valid type
135
+ if (keyValue === undefined || keyValue === null) {
136
+ console.warn(`[XTable] keyProp "${props.keyProp}" is undefined/null for item at index ${index}. Falling back to index.`)
137
+
138
+ return index
139
+ }
140
+
141
+ if (typeof keyValue !== 'string' && typeof keyValue !== 'number') {
142
+ console.warn(`[XTable] keyProp "${props.keyProp}" must be a string or number, got ${typeof keyValue}. Falling back to index.`)
143
+
144
+ return index
145
+ }
146
+
147
+ return keyValue as number | string
148
+ }
149
+
150
+ // Helper function to get original index from virtual list item
151
+ // Note: useVirtualList always preserves the original index in item.index
152
+ function getOriginalIndex(virtualItem: { data: T; index: number; }): number {
153
+ return virtualItem.index
154
+ }
130
155
 
131
- const emit = defineEmits(['update:sort', 'click-row'])
156
+ const emit = defineEmits<{
157
+ (e: 'update:sort', sortValues: string[]): void;
158
+ (e: 'click-row', item: T, index: number): void;
159
+ }>()
132
160
 
133
161
  function getSort(headerValue: string | undefined, sort: string[]): TableHeaderSort {
134
162
  if (!headerValue) return undefined
@@ -173,10 +201,37 @@ function sortHeader(header: TableHeader) {
173
201
  emit('update:sort', sort)
174
202
  }
175
203
 
176
- function getValue(item: any, path: string | string[] | undefined) {
204
+ const pathCache = new Map<string, string[]>()
205
+
206
+ function getValue(item: T, path: string | string[] | undefined): unknown {
177
207
  if (!path) return ''
178
- const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g)
179
- const result = pathArray?.reduce((prevObj: any, key: string) => prevObj && prevObj[key], item)
208
+ if (!item) return ''
209
+
210
+ let pathArray: string[] | null
211
+
212
+ if (Array.isArray(path)) {
213
+ pathArray = path
214
+ } else {
215
+ // Check cache first
216
+ if (pathCache.has(path)) {
217
+ pathArray = pathCache.get(path)!
218
+ } else {
219
+ // Parse and cache the result
220
+ pathArray = path.match(/([^[.\]])+/g)
221
+ if (pathArray) {
222
+ pathCache.set(path, pathArray)
223
+ }
224
+ }
225
+ }
226
+
227
+ if (!pathArray || pathArray.length === 0) return ''
228
+
229
+ const result = pathArray.reduce((prevObj: unknown, key: string) => {
230
+ if (prevObj === null || prevObj === undefined) return null
231
+ if (typeof prevObj !== 'object') return null
232
+
233
+ return (prevObj as Record<string, unknown>)[key]
234
+ }, item)
180
235
 
181
236
  return result ?? ''
182
237
  }
@@ -184,38 +239,59 @@ function getValue(item: any, path: string | string[] | undefined) {
184
239
  const allKeys = computed<(number | string)[]>(() => {
185
240
  if (!props.selectable) return []
186
241
 
187
- return items.value.map((item, index) => props.keyProp ? (item as Record<string, unknown>)[props.keyProp] : index) as (number | string)[]
242
+ return items.value.map((item, index) => getItemKey(item, index))
243
+ })
244
+
245
+ const selectedSet = computed(() => {
246
+ if (!props.selectable || props.singleSelect) return new Set<number | string>()
247
+ if (!Array.isArray(selected.value)) return new Set<number | string>()
248
+
249
+ return new Set(selected.value)
188
250
  })
189
251
 
190
252
  const allRowsSelected = computed(() => {
191
253
  if (!props.selectable || props.singleSelect) return false
254
+ if (!Array.isArray(selected.value) || selected.value.length === 0) return false
255
+
256
+ const keysLength = allKeys.value.length
257
+
258
+ if (keysLength === 0) return false
192
259
 
193
- return Array.isArray(selected.value) && selected.value.length > 0 && allKeys.value.length > 0 && selected.value.length === allKeys.value.length
260
+ return selected.value.length === keysLength
194
261
  })
195
262
 
196
263
  const someRowsSelected = computed(() => {
197
264
  if (!props.selectable || props.singleSelect) return false
265
+ if (!Array.isArray(selected.value) || selected.value.length === 0) return false
198
266
 
199
- return Array.isArray(selected.value) && selected.value.length > 0 && allKeys.value.length > 0 && selected.value.length !== allKeys.value.length
267
+ const keysLength = allKeys.value.length
268
+
269
+ if (keysLength === 0) return false
270
+
271
+ return selected.value.length > 0 && selected.value.length !== keysLength
200
272
  })
201
273
 
202
- function isRowSelected(rowKey: any) {
274
+ function isRowSelected(rowKey: number | string): boolean {
203
275
  if (!props.selectable) return false
276
+
204
277
  if (props.singleSelect) {
205
278
  return selected.value === rowKey
206
- } else {
207
- return Array.isArray(selected.value) && selected.value.includes(rowKey)
208
279
  }
280
+
281
+ return selectedSet.value.has(rowKey)
209
282
  }
210
283
 
211
- function toggleRowSelection(rowKey: any) {
284
+ function toggleRowSelection(rowKey: number | string) {
212
285
  if (!props.selectable) return
286
+
213
287
  if (props.singleSelect) {
214
288
  selected.value = selected.value === rowKey ? undefined : rowKey
215
289
  } else {
216
290
  if (!Array.isArray(selected.value)) selected.value = []
217
- if (selected.value.includes(rowKey)) {
218
- selected.value = selected.value.filter((k: any) => k !== rowKey)
291
+
292
+ // Use Set for O(1) lookup instead of includes O(n)
293
+ if (selectedSet.value.has(rowKey)) {
294
+ selected.value = selected.value.filter((k: number | string) => k !== rowKey)
219
295
  } else {
220
296
  selected.value = [...selected.value, rowKey]
221
297
  }
@@ -232,25 +308,68 @@ function toggleSelectAll() {
232
308
  }
233
309
  }
234
310
 
235
- function onTableRowClick(item: any, index: number) {
311
+ function toggleExpanded(virtualItem: { data: T; index: number; }) {
312
+ if (!props.expandable) return
313
+ const itemKey = getItemKey(virtualItem.data, getOriginalIndex(virtualItem))
314
+
315
+ expandedState.value.set(itemKey, !expandedState.value.get(itemKey))
316
+ }
317
+
318
+ function isExpanded(virtualItem: { data: T; index: number; }): boolean {
319
+ if (!props.expandable) return false
320
+ const itemKey = getItemKey(virtualItem.data, getOriginalIndex(virtualItem))
321
+
322
+ return expandedState.value.get(itemKey) ?? false
323
+ }
324
+
325
+ function onTableRowClick(item: T, virtualItem: { data: T; index: number; }) {
326
+ // Get the original index from the items array
327
+ const originalIndex = getOriginalIndex(virtualItem)
328
+
236
329
  if (props.selectable && props.singleSelect) {
237
- toggleRowSelection(props.keyProp ? (item as Record<string, unknown>)[props.keyProp] : index)
330
+ const itemKey = getItemKey(item, originalIndex)
331
+
332
+ toggleRowSelection(itemKey)
238
333
  }
239
334
 
240
- emit('click-row', item, index)
335
+ emit('click-row', item, originalIndex)
241
336
  }
242
337
 
243
- watch(items, (newValue) => {
244
- if (props.expandable) internalItems.value = clone(newValue as any) as internalT[]
338
+ // Compute column count for colspan
339
+ const columnCount = computed(() => {
340
+ let count = props.headers.length
341
+
342
+ if (props.selectable && !props.singleSelect) count++
343
+ if (props.expandable) count++
344
+
345
+ return count
346
+ })
347
+
348
+ watch(items, (newValue: T[]) => {
349
+ const currentKeys = new Set<number | string>()
350
+
351
+ newValue.forEach((item, index) => {
352
+ currentKeys.add(getItemKey(item, index))
353
+ })
354
+
355
+ // Clear expanded state for items that no longer exist
356
+ if (props.expandable) {
357
+ expandedState.value.forEach((_, key) => {
358
+ if (!currentKeys.has(key)) {
359
+ expandedState.value.delete(key)
360
+ }
361
+ })
362
+ }
245
363
 
364
+ // Clear selected items that no longer exist
246
365
  if (props.selectable && props.autoClearSelected) {
247
366
  if (props.singleSelect) {
248
- if (!allKeys.value.includes(selected.value as any)) {
367
+ if (!currentKeys.has(selected.value as number | string)) {
249
368
  selected.value = undefined
250
369
  }
251
370
  } else {
252
- if (Array.isArray(selected.value)) {
253
- selected.value = selected.value.filter((k: any) => allKeys.value.includes(k))
371
+ if (Array.isArray(selected.value) && selected.value.length > 0) {
372
+ selected.value = selected.value.filter((k: number | string) => currentKeys.has(k))
254
373
  }
255
374
  }
256
375
  }
@@ -335,33 +454,33 @@ const { styles, classes, className } = useTheme('Table', {}, props)
335
454
  </template>
336
455
  <template v-else-if="error">
337
456
  <tr>
338
- <td colspan="999">
457
+ <td :colspan="columnCount">
339
458
  <slot name="error"></slot>
340
459
  </td>
341
460
  </tr>
342
461
  </template>
343
462
  <template v-else-if="!items || items.length === 0">
344
463
  <tr>
345
- <td colspan="999">
464
+ <td :colspan="columnCount">
346
465
  <slot name="empty"></slot>
347
466
  </td>
348
467
  </tr>
349
468
  </template>
350
- <template v-for="(item, index) in list" v-else :key="keyProp ? (item.data as Record<string, unknown>)[keyProp] : item.index">
469
+ <template v-for="item in list" v-else :key="getItemKey(item.data, item.index)">
351
470
  <x-table-row
352
- :pointer="pointer"
471
+ :pointer="pointer || (!!toFn || !!hrefFn)"
353
472
  :striped="striped"
354
- :selected="isRowSelected(keyProp ? (item.data as Record<string, unknown>)[keyProp] : item.index)"
473
+ :selected="isRowSelected(getItemKey(item.data, item.index))"
355
474
  :single-select="singleSelect"
356
- @click="onTableRowClick(item.data, item.index)"
475
+ @click="onTableRowClick(item.data, item)"
357
476
  >
358
- <x-table-cell v-if="props.selectable && !singleSelect" width="40" class="!pl-3.5 !pr-0.5 cursor-pointer" @click.stop="toggleRowSelection(keyProp ? (item.data as Record<string, unknown>)[keyProp] : item.index)">
477
+ <x-table-cell v-if="props.selectable && !singleSelect" width="40" class="!pl-3.5 !pr-0.5 cursor-pointer" @click.stop="toggleRowSelection(getItemKey(item.data, item.index))">
359
478
  <x-checkbox
360
- :model-value="isRowSelected(keyProp ? (item.data as Record<string, unknown>)[keyProp] : item.index)"
479
+ :model-value="isRowSelected(getItemKey(item.data, item.index))"
361
480
  hide-footer
362
- :aria-label="`Select row ${index + 1}`"
481
+ :aria-label="`Select row ${getOriginalIndex(item) + 1}`"
363
482
  skip-form-registry
364
- @click.prevent.stop="toggleRowSelection(keyProp ? (item.data as Record<string, unknown>)[keyProp] : item.index)"
483
+ @click.prevent.stop="toggleRowSelection(getItemKey(item.data, item.index))"
365
484
  />
366
485
  </x-table-cell>
367
486
  <x-table-cell v-if="expandable" width="48" class="!p-1">
@@ -369,14 +488,14 @@ const { styles, classes, className } = useTheme('Table', {}, props)
369
488
  type="button"
370
489
  class="px-3 p-2"
371
490
  :class="[dense ? 'p-0.5' : 'px-3 py-2']"
372
- @click="internalItems[item.index].__expanded = !internalItems[item.index].__expanded"
491
+ @click.stop="toggleExpanded(item)"
373
492
  >
374
493
  <x-icon
375
494
  :icon="chevronDownIcon"
376
495
  :size="dense ? 'xs' : 'md'"
377
496
  class="transition-transform"
378
497
  :class="{
379
- 'rotate-180': internalItems[item.index]?.__expanded,
498
+ 'rotate-180': isExpanded(item),
380
499
  }"
381
500
  />
382
501
  </button>
@@ -394,15 +513,18 @@ const { styles, classes, className } = useTheme('Table', {}, props)
394
513
  overflow: 'hidden',
395
514
  whiteSpace: 'nowrap',
396
515
  } : {}]"
516
+ :href="hrefFn ? hrefFn(item.data) : undefined"
517
+ :to="toFn ? toFn(item.data) : undefined"
518
+ :target="hrefFn ? hrefTarget : undefined"
397
519
  >
398
520
  <slot :name="`item-${header.value}`" :item="item.data">
399
521
  {{ getValue(item.data, header.value) }}
400
522
  </slot>
401
523
  </x-table-cell>
402
524
  </x-table-row>
403
- <tr v-if="expandable" :class="{ 'hidden': !internalItems[item.index]?.__expanded }">
404
- <td colspan="999">
405
- <div class="overflow-hidden transition-opacity" :class="[internalItems[item.index]?.__expanded ? '' : 'opacity-0 max-h-0']">
525
+ <tr v-if="expandable" :class="{ 'hidden': !isExpanded(item) }">
526
+ <td :colspan="columnCount">
527
+ <div class="overflow-hidden transition-opacity" :class="[isExpanded(item) ? '' : 'opacity-0 max-h-0']">
406
528
  <slot name="expanded-row" :item="item.data"></slot>
407
529
  </div>
408
530
  </td>