@nexxtmove/ui 0.1.38 → 0.1.40

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.40",
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,35 +1,43 @@
1
1
  <script lang="ts" setup>
2
2
  import NexxtIcon from '../Icon/Icon.vue'
3
- import { computed } from 'vue'
4
-
5
- type Phase = 'Latent' | 'Oriëntatie' | 'Actief zoeken' | 'Selectie' | 'Transactie' | 'Wonen'
3
+ import NexxtAnimatedNumber from '../AnimatedNumber/AnimatedNumber.vue'
4
+ import { computed, onMounted, ref } from 'vue'
5
+ import { PHASE_MAP } from '../TimelineEvent/TimelineTypes'
6
6
 
7
7
  interface NexxtCustomerJourneyTileProps {
8
+ as?: keyof HTMLElementTagNameMap
8
9
  title?: string
9
10
  amount?: number
11
+ animated?: boolean
12
+ delay?: number
10
13
  percentage?: number
11
14
  percentageExplanaition?: string
12
15
  }
13
16
 
14
- const PHASE_ICON_MAP: Record<Phase, string> = {
15
- Latent: 'eye-closed',
16
- Oriëntatie: 'building-magnifying-glass',
17
- 'Actief zoeken': 'house-person-return',
18
- Selectie: 'handshake',
19
- Transactie: 'pencil-mechanical',
20
- Wonen: 'house-heart',
21
- }
22
-
23
17
  defineOptions({
24
18
  name: 'NexxtCustomerJourneyTile',
25
19
  })
26
20
 
27
- const { title, amount, percentage, percentageExplanaition } =
28
- defineProps<NexxtCustomerJourneyTileProps>()
21
+ const {
22
+ as = 'div',
23
+ title,
24
+ amount,
25
+ animated = false,
26
+ delay,
27
+ percentage,
28
+ percentageExplanaition,
29
+ } = defineProps<NexxtCustomerJourneyTileProps>()
30
+
31
+ const displayAmount = ref(animated ? 0 : (amount ?? 0))
32
+
33
+ onMounted(() => {
34
+ if (animated && amount !== undefined) displayAmount.value = amount
35
+ })
29
36
 
30
37
  const icon = computed(() => {
31
38
  if (!title) return null
32
- return (PHASE_ICON_MAP[title as Phase] as InstanceType<typeof NexxtIcon>['name']) || null
39
+ const phase = Object.values(PHASE_MAP).find((p) => p.label === title)
40
+ return (phase?.icon as InstanceType<typeof NexxtIcon>['name']) || null
33
41
  })
34
42
 
35
43
  const ariaLabel = computed(() => {
@@ -46,10 +54,10 @@ const ariaLabel = computed(() => {
46
54
  </script>
47
55
 
48
56
  <template>
49
- <button
50
- type="button"
57
+ <component
58
+ :is="as"
51
59
  :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"
60
+ 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
61
  :class="icon ? 'gap-6 p-1.5' : 'p-4'"
54
62
  >
55
63
  <div
@@ -63,19 +71,26 @@ const ariaLabel = computed(() => {
63
71
  <span
64
72
  class="heading-1"
65
73
  :class="icon ? 'text-purple-500' : 'text-sapphire-900'"
66
- v-if="amount"
67
- >{{ amount }}</span
74
+ v-if="amount !== undefined"
68
75
  >
69
- <div v-if="percentage" class="flex gap-0.5">
76
+ <NexxtAnimatedNumber :value="displayAmount" :delay="delay" />
77
+ </span>
78
+ <div v-if="percentage !== undefined" class="flex gap-0.5">
70
79
  <span
71
80
  class="extra-small-semibold"
72
- :class="percentage > 0 ? 'text-green-500' : 'text-brick-500'"
81
+ :class="
82
+ percentage > 0
83
+ ? 'text-green-500'
84
+ : percentage === 0
85
+ ? 'text-orange-500'
86
+ : 'text-brick-500'
87
+ "
73
88
  ><template v-if="percentage > 0">+</template>{{ percentage }}%</span
74
89
  >
75
- <span class="extra-small-normal" v-if="percentageExplanaition">{{
90
+ <span class="ml-0.5 text-left extra-small-normal" v-if="percentageExplanaition">{{
76
91
  percentageExplanaition
77
92
  }}</span>
78
93
  </div>
79
94
  </div>
80
- </button>
95
+ </component>
81
96
  </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>
@@ -34,6 +34,7 @@ export interface TimelineResponse {
34
34
  export interface PhaseConfig {
35
35
  label: string
36
36
  color: string
37
+ icon: string
37
38
  bgColor: string
38
39
  fromColor: string
39
40
  toColor: string
@@ -180,14 +181,16 @@ export const PHASE_MAP: Record<number, PhaseConfig> = {
180
181
  0: {
181
182
  label: 'Inactief',
182
183
  color: 'border-slate-300',
184
+ icon: 'eye-closed',
183
185
  bgColor: 'bg-slate-300',
184
186
  fromColor: 'from-slate-300',
185
187
  toColor: 'to-slate-300',
186
188
  textColor: 'text-gray-700',
187
189
  },
188
190
  1: {
189
- label: 'Latente woonfase',
191
+ label: 'Latent',
190
192
  color: 'border-amber-500',
193
+ icon: 'eye-closed',
191
194
  bgColor: 'bg-amber-500',
192
195
  fromColor: 'from-amber-500',
193
196
  toColor: 'to-amber-500',
@@ -196,14 +199,16 @@ export const PHASE_MAP: Record<number, PhaseConfig> = {
196
199
  2: {
197
200
  label: 'Oriëntatie',
198
201
  color: 'border-purple-500',
202
+ icon: 'building-magnifying-glass',
199
203
  bgColor: 'bg-purple-500',
200
204
  fromColor: 'from-purple-500',
201
205
  toColor: 'to-purple-500',
202
206
  textColor: 'text-white',
203
207
  },
204
208
  3: {
205
- label: 'Actieve zoektocht',
209
+ label: 'Actief zoeken',
206
210
  color: 'border-yellow-500',
211
+ icon: 'house-person-return',
207
212
  bgColor: 'bg-yellow-500',
208
213
  fromColor: 'from-yellow-500',
209
214
  toColor: 'to-yellow-500',
@@ -212,6 +217,7 @@ export const PHASE_MAP: Record<number, PhaseConfig> = {
212
217
  4: {
213
218
  label: 'Selectie',
214
219
  color: 'border-blue-500',
220
+ icon: 'handshake',
215
221
  bgColor: 'bg-blue-500',
216
222
  fromColor: 'from-blue-500',
217
223
  toColor: 'to-blue-500',
@@ -220,6 +226,7 @@ export const PHASE_MAP: Record<number, PhaseConfig> = {
220
226
  5: {
221
227
  label: 'Transactie',
222
228
  color: 'border-emerald-500',
229
+ icon: 'pencil-mechanical',
223
230
  bgColor: 'bg-emerald-500',
224
231
  fromColor: 'from-emerald-500',
225
232
  toColor: 'to-emerald-500',
@@ -228,6 +235,7 @@ export const PHASE_MAP: Record<number, PhaseConfig> = {
228
235
  6: {
229
236
  label: 'Wonen',
230
237
  color: 'border-green-500',
238
+ icon: 'house-heart',
231
239
  bgColor: 'bg-green-500',
232
240
  fromColor: 'from-green-500',
233
241
  toColor: 'to-green-500',