@nexxtmove/ui 0.1.38 → 0.1.39

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "0.1.38",
4
+ "version": "0.1.39",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -20,6 +20,7 @@
20
20
  "tailwind"
21
21
  ],
22
22
  "dependencies": {
23
+ "@number-flow/vue": "^0.5.0",
23
24
  "date-fns": "^4.1.0",
24
25
  "date-fns-tz": "^3.2.0",
25
26
  "reka-ui": "^2.9.2"
@@ -1,65 +1,21 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
2
+ import NumberFlow from '@number-flow/vue'
3
3
 
4
4
  defineOptions({
5
5
  name: 'NexxtAnimatedNumber',
6
6
  })
7
7
 
8
- type EasingFunction = 'ease-linear' | 'ease-in' | 'ease-out' | 'ease-in-out'
9
-
10
8
  const {
11
9
  value,
12
10
  duration = 500,
13
- easing = 'ease-out',
11
+ delay = 0,
14
12
  } = defineProps<{
15
13
  value: number
16
14
  duration?: number
17
- easing?: EasingFunction
15
+ delay?: number
18
16
  }>()
19
-
20
- const direction = ref<'up' | 'down'>('up')
21
- const digits = computed(() => String(value).split(''))
22
- const transitionDuration = computed(() => `duration-${duration}`)
23
-
24
- watch(
25
- () => value,
26
- (newVal, oldVal) => {
27
- direction.value = newVal > oldVal ? 'up' : 'down'
28
- },
29
- )
30
17
  </script>
31
18
 
32
19
  <template>
33
- <TransitionGroup
34
- tag="div"
35
- class="relative flex shrink-0 items-center tabular-nums"
36
- :aria-label="value"
37
- :enter-active-class="`transition-all ${transitionDuration} ${easing}`"
38
- :leave-active-class="`absolute transition-all ${transitionDuration} ${easing}`"
39
- :enter-from-class="
40
- direction === 'up' ? 'translate-y-full opacity-0' : '-translate-y-full opacity-0'
41
- "
42
- :leave-to-class="
43
- direction === 'up' ? '-translate-y-full opacity-0' : 'translate-y-full opacity-0'
44
- "
45
- move-class="transition-all duration-500 ease-in-out"
46
- >
47
- <div
48
- v-for="(digit, i) in digits"
49
- :key="i"
50
- class="relative inline-flex h-[1.5em] w-[1ch] items-center justify-center overflow-hidden"
51
- aria-hidden="true"
52
- >
53
- <Transition
54
- :enter-active-class="`transition-transform ${transitionDuration} ${easing}`"
55
- :leave-active-class="`absolute inset-0 transition-transform ${transitionDuration} ${easing}`"
56
- :enter-from-class="direction === 'up' ? 'translate-y-full' : '-translate-y-full'"
57
- :leave-to-class="direction === 'up' ? '-translate-y-full' : 'translate-y-full'"
58
- >
59
- <span :key="digit" class="flex h-full w-full items-center justify-center">
60
- {{ digit }}
61
- </span>
62
- </Transition>
63
- </div>
64
- </TransitionGroup>
20
+ <NumberFlow :value="value" :transform-timing="{ duration, delay }" />
65
21
  </template>
@@ -1,17 +1,22 @@
1
1
  <script lang="ts" setup>
2
2
  import NexxtIcon from '../Icon/Icon.vue'
3
- import { computed } from 'vue'
3
+ import NexxtAnimatedNumber from '../AnimatedNumber/AnimatedNumber.vue'
4
+ import { computed, onMounted, ref } from 'vue'
5
+ import { PHASE_MAP } from '../TimelineEvent/TimelineTypes'
4
6
 
5
- type Phase = 'Latent' | 'Oriëntatie' | 'Actief zoeken' | 'Selectie' | 'Transactie' | 'Wonen'
7
+ type Phase = (typeof PHASE_MAP)[keyof typeof PHASE_MAP]['label']
6
8
 
7
9
  interface NexxtCustomerJourneyTileProps {
10
+ as?: keyof HTMLElementTagNameMap
8
11
  title?: string
9
12
  amount?: number
13
+ animated?: boolean
14
+ delay?: number
10
15
  percentage?: number
11
16
  percentageExplanaition?: string
12
17
  }
13
18
 
14
- const PHASE_ICON_MAP: Record<Phase, string> = {
19
+ const PHASE_ICON_MAP: Partial<Record<Phase, string>> = {
15
20
  Latent: 'eye-closed',
16
21
  Oriëntatie: 'building-magnifying-glass',
17
22
  'Actief zoeken': 'house-person-return',
@@ -24,8 +29,21 @@ defineOptions({
24
29
  name: 'NexxtCustomerJourneyTile',
25
30
  })
26
31
 
27
- const { title, amount, percentage, percentageExplanaition } =
28
- defineProps<NexxtCustomerJourneyTileProps>()
32
+ const {
33
+ as = 'div',
34
+ title,
35
+ amount,
36
+ animated = false,
37
+ delay,
38
+ percentage,
39
+ percentageExplanaition,
40
+ } = defineProps<NexxtCustomerJourneyTileProps>()
41
+
42
+ const displayAmount = ref(animated ? 0 : (amount ?? 0))
43
+
44
+ onMounted(() => {
45
+ if (animated && amount !== undefined) displayAmount.value = amount
46
+ })
29
47
 
30
48
  const icon = computed(() => {
31
49
  if (!title) return null
@@ -46,10 +64,10 @@ const ariaLabel = computed(() => {
46
64
  </script>
47
65
 
48
66
  <template>
49
- <button
50
- type="button"
67
+ <component
68
+ :is="as"
51
69
  :aria-label="ariaLabel"
52
- class="flex cursor-pointer rounded-xl bg-white outline outline-gray-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-500"
70
+ class="flex rounded-xl bg-white outline outline-gray-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-500"
53
71
  :class="icon ? 'gap-6 p-1.5' : 'p-4'"
54
72
  >
55
73
  <div
@@ -63,19 +81,26 @@ const ariaLabel = computed(() => {
63
81
  <span
64
82
  class="heading-1"
65
83
  :class="icon ? 'text-purple-500' : 'text-sapphire-900'"
66
- v-if="amount"
67
- >{{ amount }}</span
84
+ v-if="amount !== undefined"
68
85
  >
69
- <div v-if="percentage" class="flex gap-0.5">
86
+ <NexxtAnimatedNumber :value="displayAmount" :delay="delay" />
87
+ </span>
88
+ <div v-if="percentage !== undefined" class="flex gap-0.5">
70
89
  <span
71
90
  class="extra-small-semibold"
72
- :class="percentage > 0 ? 'text-green-500' : 'text-brick-500'"
91
+ :class="
92
+ percentage > 0
93
+ ? 'text-green-500'
94
+ : percentage === 0
95
+ ? 'text-orange-500'
96
+ : 'text-brick-500'
97
+ "
73
98
  ><template v-if="percentage > 0">+</template>{{ percentage }}%</span
74
99
  >
75
- <span class="extra-small-normal" v-if="percentageExplanaition">{{
100
+ <span class="ml-0.5 text-left extra-small-normal" v-if="percentageExplanaition">{{
76
101
  percentageExplanaition
77
102
  }}</span>
78
103
  </div>
79
104
  </div>
80
- </button>
105
+ </component>
81
106
  </template>
@@ -6,14 +6,21 @@ interface NexxtDropdownButton {
6
6
  label: string
7
7
  action: string | (() => void)
8
8
  icon?: InstanceType<typeof NexxtIcon>['name']
9
+ selected?: boolean
9
10
  }
10
11
 
11
- export interface NexxtDropdownButtonProps extends NexxtDropdownButton {
12
+ type NexxtDropdownButtonBase = {
12
13
  variant?: 'primary' | 'secondary'
13
14
  options: NexxtDropdownButton[]
14
15
  placement?: 'left' | 'right'
16
+ triggerIcon?: InstanceType<typeof NexxtIcon>['name']
17
+ icon?: InstanceType<typeof NexxtIcon>['name']
15
18
  }
16
19
 
20
+ export type NexxtDropdownButtonProps =
21
+ | (NexxtDropdownButtonBase & { iconOnly?: false; label: string; action: string | (() => void) })
22
+ | (NexxtDropdownButtonBase & { iconOnly: true; label?: string; action?: string | (() => void) })
23
+
17
24
  const {
18
25
  variant = 'primary',
19
26
  options,
@@ -21,6 +28,8 @@ const {
21
28
  action,
22
29
  icon,
23
30
  placement = 'right',
31
+ iconOnly = false,
32
+ triggerIcon = 'chevron-down',
24
33
  } = defineProps<NexxtDropdownButtonProps>()
25
34
 
26
35
  const emit = defineEmits<{
@@ -106,31 +115,33 @@ const transitionClasses = computed(() => {
106
115
  :class="placement === 'right' ? 'items-end' : 'items-start'"
107
116
  >
108
117
  <div class="inline-flex items-center overflow-hidden rounded-md" :class="componentClasses">
118
+ <template v-if="!iconOnly">
119
+ <button
120
+ class="flex cursor-pointer items-center gap-2 px-4 py-2 transition-colors duration-200"
121
+ :class="buttonClasses"
122
+ @click="action && handleAction(action)"
123
+ >
124
+ <NexxtIcon v-if="icon" :name="icon" aria-hidden="true" />
125
+ {{ label }}
126
+ </button>
127
+ <span
128
+ class="h-7 w-px rounded-sm"
129
+ :class="variant === 'primary' ? 'bg-white' : 'bg-gray-200'"
130
+ aria-hidden="true"
131
+ ></span>
132
+ </template>
109
133
  <button
110
- class="flex cursor-pointer items-center gap-2 px-4 py-2 transition-colors duration-200"
111
- :class="buttonClasses"
112
- @click="handleAction(action)"
113
- >
114
- <NexxtIcon v-if="icon" :name="icon" aria-hidden="true" />
115
- {{ label }}
116
- </button>
117
- <span
118
- class="h-7 w-px rounded-sm"
119
- :class="variant === 'primary' ? 'bg-white' : 'bg-gray-200'"
120
- aria-hidden="true"
121
- ></span>
122
- <button
123
- class="cursor-pointer px-2 py-2 transition-colors duration-200"
124
- :class="buttonClasses"
125
- @click="toggleDropdown"
134
+ class="w-10 shrink-0 cursor-pointer transition-colors duration-200"
135
+ :class="[buttonClasses, iconOnly ? 'p-2' : 'px-2 py-2']"
136
+ @click="iconOnly ? toggleDropdown() : toggleDropdown()"
126
137
  aria-haspopup="menu"
127
138
  :aria-expanded="isOpen"
128
139
  :aria-label="`Open ${label} menu`"
129
140
  >
130
141
  <NexxtIcon
131
- name="chevron-down"
142
+ :name="triggerIcon"
132
143
  class="transition-transform duration-200"
133
- :class="{ 'rotate-180': isOpen }"
144
+ :class="{ 'rotate-180': isOpen && triggerIcon === 'chevron-down' }"
134
145
  aria-hidden="true"
135
146
  />
136
147
  </button>
@@ -148,6 +159,7 @@ const transitionClasses = computed(() => {
148
159
  :key="option.label"
149
160
  role="menuitem"
150
161
  class="flex w-full min-w-42 cursor-pointer items-center gap-2 px-4 py-2 text-left text-sm whitespace-nowrap text-gray-700 transition-colors duration-200 hover:bg-gray-100 hover:text-purple-600 focus:bg-gray-100 focus:outline-none"
162
+ :class="option.selected ? 'bg-cornflower-blue-100' : ''"
151
163
  @click="handleOptionClick(option.action)"
152
164
  >
153
165
  <NexxtIcon v-if="option.icon" :name="option.icon" aria-hidden="true" />
@@ -3,6 +3,7 @@ import Button from '../Button/Button.vue'
3
3
 
4
4
  interface NexxtHeaderProps {
5
5
  title: string
6
+ headingType?: 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4'
6
7
  backButtonText?: string
7
8
  backButtonAction?: () => void
8
9
  }
@@ -12,6 +13,7 @@ defineOptions({
12
13
  })
13
14
 
14
15
  const {
16
+ headingType = 'heading-4',
15
17
  backButtonText,
16
18
  backButtonAction = () => {
17
19
  window.history.back()
@@ -29,7 +31,7 @@ const {
29
31
  @click="backButtonAction"
30
32
  >{{ backButtonText }}</Button
31
33
  >
32
- <span class="block truncate heading-4 text-sapphire-500">
34
+ <span class="block truncate text-sapphire-500" :class="headingType">
33
35
  {{ title }}
34
36
  </span>
35
37
  </div>
@@ -33,7 +33,7 @@ const modelValue = defineModel<string>()
33
33
  </label>
34
34
 
35
35
  <div
36
- class="flex items-center gap-2 rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
36
+ class="flex items-center gap-2 rounded-sm px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
37
37
  :class="error ? 'ring-brick-400' : 'ring-gray-200'"
38
38
  >
39
39
  <span v-if="leftIcon" class="flex items-center">
@@ -47,10 +47,11 @@ const modelValue = defineModel<string>()
47
47
  v-bind="$attrs"
48
48
  class="h-10 min-w-60 bg-transparent text-sm text-gray-900 placeholder-gray-400 focus:outline-none"
49
49
  />
50
-
51
- <span v-if="rightIcon" class="flex items-center">
52
- <Icon :name="rightIcon" />
53
- </span>
50
+ <slot name="right">
51
+ <span v-if="rightIcon" class="flex items-center">
52
+ <Icon :name="rightIcon" />
53
+ </span>
54
+ </slot>
54
55
  </div>
55
56
 
56
57
  <Transition
@@ -8,6 +8,7 @@ export interface TableColumn<K extends string = string> {
8
8
  label: string
9
9
  sortable?: boolean
10
10
  width?: string
11
+ minCellWidth?: number
11
12
  }
12
13
 
13
14
  export interface NexxtTableProps<
@@ -17,12 +18,19 @@ export interface NexxtTableProps<
17
18
  columns: TableColumn<TKey>[]
18
19
  rows: TRow[]
19
20
  selectable?: boolean
21
+ loading?: boolean
20
22
  }
21
23
 
22
24
  const INTERACTIVE = ['a', 'button', 'input', 'select', 'textarea', 'label']
23
25
 
24
26
  defineOptions({ name: 'NexxtTable' })
25
27
 
28
+ defineSlots<
29
+ { [K in TKey as `cell-${K}`]?: (props: { row: TRow; value: TRow[K] }) => unknown } & {
30
+ [key: string]: ((props: Record<string, unknown>) => unknown) | undefined
31
+ }
32
+ >()
33
+
26
34
  const props = defineProps<NexxtTableProps<TKey, TRow>>()
27
35
  const emit = defineEmits<{
28
36
  'row-click': [row: TRow]
@@ -54,7 +62,9 @@ const toggleRow = (row: TRow) => {
54
62
  }
55
63
 
56
64
  const columnTemplate = computed(() =>
57
- props.columns.map((col) => `minmax(100px, ${col.width ?? '1fr'})`).join(' '),
65
+ props.columns
66
+ .map((col) => `minmax(${col.minCellWidth ?? 150}px, ${col.width ?? '1fr'})`)
67
+ .join(' '),
58
68
  )
59
69
 
60
70
  const gridTemplate = computed(() =>
@@ -87,7 +97,8 @@ const getRowRoundedClass = (rowIndex: number) => {
87
97
  return ''
88
98
  }
89
99
 
90
- const rowValue = (row: TRow, key: TKey) => (row as Record<TKey, unknown>)[key]
100
+ const rowValue = (row: TRow, key: TKey): TRow[TKey] => row[key]
101
+ const cellSlotName = (key: TKey): string => `cell-${key}`
91
102
 
92
103
  const handleRowKeydown = (event: KeyboardEvent, rowIndex: number) => {
93
104
  if (event.key === 'ArrowDown') {
@@ -102,12 +113,12 @@ const handleRowKeydown = (event: KeyboardEvent, rowIndex: number) => {
102
113
 
103
114
  <template>
104
115
  <div class="flex flex-col gap-3">
105
- <div class="overflow-auto">
116
+ <div class="overflow-x-auto">
106
117
  <div
107
118
  role="grid"
108
119
  :aria-colcount="columns.length + (selectable ? 1 : 0)"
109
120
  :aria-rowcount="rows.length + 1"
110
- class="w-full min-w-max"
121
+ class="min-w-full"
111
122
  >
112
123
  <div class="sticky top-0 z-10 mb-0.5">
113
124
  <div v-if="$slots.toolbar">
@@ -156,7 +167,9 @@ const handleRowKeydown = (event: KeyboardEvent, rowIndex: number) => {
156
167
  @keydown.enter.prevent="handleSort(column)"
157
168
  @keydown.space.prevent="handleSort(column)"
158
169
  >
159
- {{ column.label }}
170
+ <span :class="sortKey === column.key ? 'text-cornflower-blue-600' : ''">{{
171
+ column.label
172
+ }}</span>
160
173
  <Icon
161
174
  v-if="column.sortable"
162
175
  :name="
@@ -173,7 +186,7 @@ const handleRowKeydown = (event: KeyboardEvent, rowIndex: number) => {
173
186
  </div>
174
187
  </div>
175
188
 
176
- <div role="rowgroup">
189
+ <div role="rowgroup" class="relative">
177
190
  <div
178
191
  v-for="(row, rowIndex) in rows"
179
192
  :key="rowIndex"
@@ -215,7 +228,7 @@ const handleRowKeydown = (event: KeyboardEvent, rowIndex: number) => {
215
228
  role="gridcell"
216
229
  :aria-colindex="colIndex + (selectable ? 2 : 1)"
217
230
  >
218
- <slot :name="`cell-${column.key}`" :row="row" :value="rowValue(row, column.key)">
231
+ <slot :name="cellSlotName(column.key)" :row="row" :value="rowValue(row, column.key)">
219
232
  {{ rowValue(row, column.key) }}
220
233
  </slot>
221
234
  </div>
@@ -224,6 +237,23 @@ const handleRowKeydown = (event: KeyboardEvent, rowIndex: number) => {
224
237
  <div v-if="!rows.length" class="px-4 py-8 text-center text-sm text-slate-400" role="row">
225
238
  <slot name="empty">Geen data beschikbaar</slot>
226
239
  </div>
240
+
241
+ <Transition
242
+ enter-active-class="transition-opacity duration-200"
243
+ leave-active-class="transition-opacity duration-200"
244
+ enter-from-class="opacity-0"
245
+ leave-to-class="opacity-0"
246
+ >
247
+ <div
248
+ v-if="loading"
249
+ data-testid="loading-overlay"
250
+ class="absolute inset-0 flex items-center justify-center rounded-md bg-white/40 backdrop-blur-sm"
251
+ >
252
+ <slot name="loading"
253
+ ><Icon name="spinner-third" class="fa-spin text-3xl text-purple-500"
254
+ /></slot>
255
+ </div>
256
+ </Transition>
227
257
  </div>
228
258
  </div>
229
259
  </div>
@@ -186,7 +186,7 @@ export const PHASE_MAP: Record<number, PhaseConfig> = {
186
186
  textColor: 'text-gray-700',
187
187
  },
188
188
  1: {
189
- label: 'Latente woonfase',
189
+ label: 'Latent',
190
190
  color: 'border-amber-500',
191
191
  bgColor: 'bg-amber-500',
192
192
  fromColor: 'from-amber-500',
@@ -202,7 +202,7 @@ export const PHASE_MAP: Record<number, PhaseConfig> = {
202
202
  textColor: 'text-white',
203
203
  },
204
204
  3: {
205
- label: 'Actieve zoektocht',
205
+ label: 'Actief zoeken',
206
206
  color: 'border-yellow-500',
207
207
  bgColor: 'bg-yellow-500',
208
208
  fromColor: 'from-yellow-500',