@nexxtmove/ui 1.12.0 → 1.14.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 (39) hide show
  1. package/dist/components/molecules/TemplateCard/TemplateCard.vue.d.ts +39 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +267 -197
  4. package/dist/ui.css +1 -1
  5. package/package.json +2 -1
  6. package/src/components/atoms/Avatar/Avatar.vue +3 -3
  7. package/src/components/atoms/Button/Button.vue +11 -1
  8. package/src/components/atoms/Card/Card.vue +1 -1
  9. package/src/components/atoms/Checkbox/Checkbox.vue +1 -1
  10. package/src/components/atoms/EnergyLabel/EnergyLabel.vue +1 -1
  11. package/src/components/atoms/PresenceIndicator/PresenceIndicator.vue +3 -3
  12. package/src/components/atoms/PropertyListing/PropertyListing.vue +1 -1
  13. package/src/components/atoms/SelectableOption/SelectableOption.vue +1 -1
  14. package/src/components/atoms/Skeleton/Skeleton.vue +1 -1
  15. package/src/components/atoms/Squircle/Squircle.vue +1 -1
  16. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.vue +1 -1
  17. package/src/components/molecules/DoughnutChart/DoughnutChart.vue +1 -4
  18. package/src/components/molecules/InputField/InputField.vue +1 -1
  19. package/src/components/molecules/InputSelect/InputSelect.vue +1 -1
  20. package/src/components/molecules/Pagination/Pagination.vue +5 -5
  21. package/src/components/molecules/PropertyCard/PropertyCard.vue +4 -4
  22. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.vue +2 -2
  23. package/src/components/molecules/SocialIcons/SocialIcons.vue +1 -1
  24. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.vue +1 -1
  25. package/src/components/molecules/TemplateCard/TemplateCard.vue +156 -30
  26. package/src/components/molecules/TimelineEvent/TimelineEvent.vue +3 -3
  27. package/src/components/molecules/TimelineLanes/TimelineLanes.vue +2 -2
  28. package/src/components/molecules/Toast/Toast.vue +11 -3
  29. package/src/components/organisms/Calendar/_CalendarDayView.vue +2 -2
  30. package/src/components/organisms/Calendar/_CalendarHeader.vue +7 -7
  31. package/src/components/organisms/Calendar/_CalendarTimePicker.vue +4 -4
  32. package/src/components/organisms/ColorPicker/ColorPicker.vue +12 -12
  33. package/src/components/organisms/DropdownButton/DropdownButton.vue +1 -1
  34. package/src/components/organisms/Modal/Modal.vue +2 -2
  35. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.vue +4 -4
  36. package/src/components/organisms/Table/Table.vue +1 -1
  37. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.vue +3 -6
  38. package/src/components/organisms/VideoPlayer/VideoPlayer.vue +5 -5
  39. package/src/index.ts +5 -0
package/dist/ui.css CHANGED
@@ -1,2 +1,2 @@
1
- rect[data-v-0a5d9b21]{animation-duration:4s;animation-timing-function:cubic-bezier(.77,0,.175,1);animation-iteration-count:infinite}rect[data-v-0a5d9b21]:first-child{animation-name:load-1-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(2){animation-name:load-2-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(3){animation-name:load-3-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(4){animation-name:load-4-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(5){animation-name:load-5-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(6){animation-name:load-6-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(7){animation-name:load-7-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(8){animation-name:load-8-0a5d9b21}@media (prefers-reduced-motion:reduce){rect[data-v-0a5d9b21]{animation:none}svg[data-v-0a5d9b21]{animation:2.5s ease-in-out infinite pulse-0a5d9b21}}@keyframes pulse-0a5d9b21{0%,to{opacity:1}50%{opacity:.65}}@keyframes load-1-0a5d9b21{0%,80%{transform:translate(0)}5%,20%,60%,75%{transform:translate(-25px,25px)}30%,50%{transform:translate(-75px,75px)}}@keyframes load-2-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(50px)}30%,50%{transform:translateY(50px)}}@keyframes load-3-0a5d9b21{10%,70%{transform:translate(0%)}15%,65%{transform:translateY(-50px)}20%,60%{fill:var(--color-sapphire-500);transform:translateY(-50px)}30%,50%{fill:var(--color-purple-500);transform:translate(-50px)}}@keyframes load-4-0a5d9b21{20%,60%{transform:translate(0%)}30%,50%{transform:translate(50px,-50px)}}@keyframes load-5-0a5d9b21{0%,80%{transform:translate(0%)}5%,20%,60%,75%{transform:translateY(50px)}30%,50%{transform:translate(50px)}}@keyframes load-6-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(-50px)}30%,50%{transform:translateY(-50px)}}@keyframes load-7-0a5d9b21{10%,70%{transform:translate(0%)}15%,20%,60%,65%{transform:translate(50px)}30%,50%{transform:translate(100px,-50px)}}@keyframes load-8-0a5d9b21{15%,65%{transform:translate(0%)}20%,60%{transform:translateY(-50px)}30%,50%{transform:translate(50px,-100px)}}.timer-circle[data-v-d13bb383]{stroke-dashoffset:0;animation:linear forwards toast-timer-d13bb383}@keyframes toast-timer-d13bb383{to{stroke-dashoffset:var(--b622135e)}}
1
+ rect[data-v-0a5d9b21]{animation-duration:4s;animation-timing-function:cubic-bezier(.77,0,.175,1);animation-iteration-count:infinite}rect[data-v-0a5d9b21]:first-child{animation-name:load-1-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(2){animation-name:load-2-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(3){animation-name:load-3-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(4){animation-name:load-4-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(5){animation-name:load-5-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(6){animation-name:load-6-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(7){animation-name:load-7-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(8){animation-name:load-8-0a5d9b21}@media (prefers-reduced-motion:reduce){rect[data-v-0a5d9b21]{animation:none}svg[data-v-0a5d9b21]{animation:2.5s ease-in-out infinite pulse-0a5d9b21}}@keyframes pulse-0a5d9b21{0%,to{opacity:1}50%{opacity:.65}}@keyframes load-1-0a5d9b21{0%,80%{transform:translate(0)}5%,20%,60%,75%{transform:translate(-25px,25px)}30%,50%{transform:translate(-75px,75px)}}@keyframes load-2-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(50px)}30%,50%{transform:translateY(50px)}}@keyframes load-3-0a5d9b21{10%,70%{transform:translate(0%)}15%,65%{transform:translateY(-50px)}20%,60%{fill:var(--color-sapphire-500);transform:translateY(-50px)}30%,50%{fill:var(--color-purple-500);transform:translate(-50px)}}@keyframes load-4-0a5d9b21{20%,60%{transform:translate(0%)}30%,50%{transform:translate(50px,-50px)}}@keyframes load-5-0a5d9b21{0%,80%{transform:translate(0%)}5%,20%,60%,75%{transform:translateY(50px)}30%,50%{transform:translate(50px)}}@keyframes load-6-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(-50px)}30%,50%{transform:translateY(-50px)}}@keyframes load-7-0a5d9b21{10%,70%{transform:translate(0%)}15%,20%,60%,65%{transform:translate(50px)}30%,50%{transform:translate(100px,-50px)}}@keyframes load-8-0a5d9b21{15%,65%{transform:translate(0%)}20%,60%{transform:translateY(-50px)}30%,50%{transform:translate(50px,-100px)}}.timer-circle[data-v-7e3c5bda]{stroke-dashoffset:0;animation:linear forwards toast-timer-7e3c5bda}@keyframes toast-timer-7e3c5bda{to{stroke-dashoffset:var(--fa0d20de)}}
2
2
  /*$vite$:1*/
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.12.0",
4
+ "version": "1.14.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -57,6 +57,7 @@
57
57
  "consola": "^3.4.2",
58
58
  "eslint": "^10.8.1",
59
59
  "eslint-config-prettier": "^10.1.8",
60
+ "eslint-plugin-better-tailwindcss": "4.7.0",
60
61
  "eslint-plugin-prettier": "^5.5.6",
61
62
  "eslint-plugin-storybook": "10.5.10",
62
63
  "eslint-plugin-vue": "^10.10.0",
@@ -63,14 +63,14 @@ const fontSizeClasses = {
63
63
  <template>
64
64
  <NexxtSquircle v-if="squircle" :size="size">
65
65
  <div
66
- class="flex h-full w-full items-center justify-center bg-cornflower-blue-50 font-semibold text-cornflower-blue-500"
66
+ class="flex size-full items-center justify-center bg-cornflower-blue-50 font-semibold text-cornflower-blue-500"
67
67
  :class="[fontSizeClasses[size]]"
68
68
  >
69
69
  <img
70
70
  v-if="showImage"
71
71
  :src="src"
72
72
  :alt="name"
73
- class="h-full w-full object-cover"
73
+ class="size-full object-cover"
74
74
  @error="imageFailed = true"
75
75
  />
76
76
  <template v-else>{{ computedInitials }}</template>
@@ -88,7 +88,7 @@ const fontSizeClasses = {
88
88
  v-if="showImage"
89
89
  :src="src"
90
90
  :alt="name"
91
- class="h-full w-full object-cover"
91
+ class="size-full object-cover"
92
92
  @error="imageFailed = true"
93
93
  />
94
94
  <template v-else>{{ computedInitials }}</template>
@@ -23,6 +23,16 @@ const {
23
23
  rounded = false,
24
24
  fullWidth = false,
25
25
  } = defineProps<NexxtButtonProps>()
26
+
27
+ // Full class names so Tailwind and the no-unknown-classes lint rule see them.
28
+ const VARIANT_CLASSES = {
29
+ primary: 'button-primary',
30
+ secondary: 'button-secondary',
31
+ success: 'button-success',
32
+ danger: 'button-danger',
33
+ warning: 'button-warning',
34
+ link: 'button-link',
35
+ } as const
26
36
  </script>
27
37
 
28
38
  <template>
@@ -33,7 +43,7 @@ const {
33
43
  'button relative flex h-10 cursor-pointer items-center overflow-hidden transition-colors duration-200 focus-visible:outline-3 disabled:cursor-not-allowed',
34
44
  fullWidth ? 'w-full justify-start' : 'justify-center',
35
45
  rounded ? 'rounded-full' : 'rounded-lg',
36
- `button-${variant}`,
46
+ VARIANT_CLASSES[variant],
37
47
  $slots.default ?? 'w-10',
38
48
  !$slots.default && icon ? 'aspect-square px-0' : '',
39
49
  loading ? 'pointer-events-none' : '',
@@ -14,7 +14,7 @@ defineProps<Props>()
14
14
  v-if="image"
15
15
  :src="image"
16
16
  :alt="imageAlt ?? 'Card afbeelding'"
17
- class="block h-40 max-h-40 min-h-40 w-full rounded-tl-lg rounded-tr-lg object-cover"
17
+ class="block h-40 max-h-40 min-h-40 w-full rounded-t-lg object-cover"
18
18
  />
19
19
  </slot>
20
20
 
@@ -33,7 +33,7 @@ watchEffect(() => {
33
33
  <span
34
34
  aria-hidden="true"
35
35
  :class="[
36
- 'flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors duration-200',
36
+ 'flex size-5 shrink-0 items-center justify-center rounded-sm border transition-colors duration-200',
37
37
  'peer-focus-visible:ring-2 peer-focus-visible:ring-cornflower-blue-400 peer-focus-visible:ring-offset-2',
38
38
  model || indeterminate
39
39
  ? 'border-cornflower-blue-600 text-cornflower-blue-600'
@@ -37,7 +37,7 @@ const color = computed(() => COLORS[normalized.value])
37
37
  <span
38
38
  role="img"
39
39
  :aria-label="`Energielabel ${normalized}`"
40
- class="relative isolate mr-3 inline-flex h-6 items-center justify-center rounded-l px-2 extra-small-semibold text-white after:absolute after:top-1/2 after:left-full after:size-4.25 after:-translate-x-1/2 after:-translate-y-1/2 after:rotate-45 after:rounded-tr after:bg-(--energy-label-color) after:content-['']"
40
+ class="relative isolate mr-3 inline-flex h-6 items-center justify-center rounded-l px-2 extra-small-semibold text-white after:absolute after:top-1/2 after:left-full after:size-4.25 after:-translate-1/2 after:rotate-45 after:rounded-tr after:bg-(--energy-label-color) after:content-['']"
41
41
  :style="{ backgroundColor: color, '--energy-label-color': color }"
42
42
  >
43
43
  <span aria-hidden="true" class="relative z-10">{{ normalized }}</span>
@@ -17,13 +17,13 @@ const { online = false } = defineProps<NexxtPresenceIndicatorProps>()
17
17
  :aria-label="online ? 'Online' : 'Offline'"
18
18
  role="status"
19
19
  >
20
- <span class="relative flex h-1.5 w-1.5">
20
+ <span class="relative flex size-1.5">
21
21
  <span
22
22
  v-if="online"
23
- class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
23
+ class="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75"
24
24
  />
25
25
  <i
26
- class="relative block h-1.5 w-1.5 rounded-full"
26
+ class="relative block size-1.5 rounded-full"
27
27
  :class="online ? 'bg-green-500' : 'bg-gray-700'"
28
28
  />
29
29
  </span>
@@ -20,7 +20,7 @@ defineProps<Props>()
20
20
  </div>
21
21
  </div>
22
22
 
23
- <div v-if="text" class="wrap-break-words w-full body-normal text-gray-950">
23
+ <div v-if="text" class="w-full body-normal wrap-break-word text-gray-950">
24
24
  {{ text }}
25
25
  </div>
26
26
  </div>
@@ -56,7 +56,7 @@ const RADIUS = {
56
56
  <slot :selected="selected" />
57
57
  <span
58
58
  v-if="checkmark && selected"
59
- class="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-cornflower-blue-500 text-white shadow"
59
+ class="absolute -top-2 -right-2 flex size-6 items-center justify-center rounded-full bg-cornflower-blue-500 text-white shadow-sm"
60
60
  >
61
61
  <NexxtIcon name="check" type="solid" class="text-xs" />
62
62
  </span>
@@ -3,5 +3,5 @@ defineOptions({ name: 'NexxtSkeleton' })
3
3
  </script>
4
4
 
5
5
  <template>
6
- <div class="animate-pulse rounded bg-gray-200" />
6
+ <div class="animate-pulse rounded-sm bg-gray-200" />
7
7
  </template>
@@ -43,7 +43,7 @@ const style = computed(() => ({
43
43
  <template>
44
44
  <div class="relative block shrink-0 overflow-hidden" :style="style">
45
45
  <svg
46
- class="absolute h-0 w-0 overflow-hidden"
46
+ class="absolute size-0 overflow-hidden"
47
47
  aria-hidden="true"
48
48
  focusable="false"
49
49
  width="0"
@@ -102,7 +102,7 @@ const iconColors = computed(() => {
102
102
  >
103
103
  <div
104
104
  v-if="icon"
105
- class="flex min-h-10 w-14 items-center justify-center overflow-hidden rounded-tl-md rounded-bl-md"
105
+ class="flex min-h-10 w-14 items-center justify-center overflow-hidden rounded-l-md"
106
106
  :class="iconColors"
107
107
  >
108
108
  <NexxtIcon :name="icon" class="text-2xl" aria-hidden="true" />
@@ -107,10 +107,7 @@ const resolvedBackgroundColor = computed(() =>
107
107
  :width="size - thickness * 2"
108
108
  :height="size - thickness * 2"
109
109
  >
110
- <div
111
- xmlns="http://www.w3.org/1999/xhtml"
112
- class="flex h-full w-full items-center justify-center"
113
- >
110
+ <div xmlns="http://www.w3.org/1999/xhtml" class="flex size-full items-center justify-center">
114
111
  <slot>
115
112
  <span class="heading-1">{{ value }}</span>
116
113
  </slot>
@@ -97,7 +97,7 @@ const decrement = () => {
97
97
  v-model="modelValue"
98
98
  :placeholder="placeholder"
99
99
  v-bind="inputAttrs"
100
- class="bg-transparant h-10 w-full min-w-60 text-sm text-gray-900 placeholder-gray-400 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
100
+ class="h-10 w-full min-w-60 bg-transparent text-sm text-gray-900 placeholder-gray-400 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
101
101
  :class="{ 'pl-3': !leftIcon, 'pr-3': !rightIcon }"
102
102
  />
103
103
  <slot name="right">
@@ -141,7 +141,7 @@ const select = (opt: SelectOption, close: () => void) => {
141
141
  v-model="inputModel"
142
142
  :placeholder="placeholder"
143
143
  v-bind="inputAttrs"
144
- class="bg-transparant h-10 w-full min-w-40 px-3 text-sm text-gray-900 placeholder-gray-400 focus:outline-none"
144
+ class="h-10 w-full min-w-40 bg-transparent px-3 text-sm text-gray-900 placeholder-gray-400 focus:outline-none"
145
145
  />
146
146
  </div>
147
147
 
@@ -47,13 +47,13 @@ const {
47
47
  >
48
48
  <PaginationFirst
49
49
  v-if="items.some((item) => item.type === 'ellipsis')"
50
- class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-transparent transition hover:bg-cornflower-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
50
+ class="flex size-8 cursor-pointer items-center justify-center rounded-lg bg-transparent transition hover:bg-cornflower-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
51
51
  :disabled="disabled"
52
52
  >
53
53
  <NexxtIcon name="angles-left" />
54
54
  </PaginationFirst>
55
55
  <PaginationPrev
56
- class="mr-4 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-transparent transition hover:bg-cornflower-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
56
+ class="mr-4 flex size-8 cursor-pointer items-center justify-center rounded-lg bg-transparent transition hover:bg-cornflower-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
57
57
  :disabled="disabled"
58
58
  >
59
59
  <NexxtIcon name="chevron-left" />
@@ -72,20 +72,20 @@ const {
72
72
  v-else
73
73
  :key="page.type"
74
74
  :index="index"
75
- class="flex h-8 w-8 items-center justify-center"
75
+ class="flex size-8 items-center justify-center"
76
76
  >
77
77
  &#8230;
78
78
  </PaginationEllipsis>
79
79
  </template>
80
80
  <PaginationNext
81
- class="ml-4 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-transparent transition hover:bg-cornflower-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
81
+ class="ml-4 flex size-8 cursor-pointer items-center justify-center rounded-lg bg-transparent transition hover:bg-cornflower-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
82
82
  :disabled="disabled"
83
83
  >
84
84
  <NexxtIcon name="chevron-right" />
85
85
  </PaginationNext>
86
86
  <PaginationLast
87
87
  v-if="items.some((item) => item.type === 'ellipsis')"
88
- class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-transparent transition hover:bg-cornflower-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
88
+ class="flex size-8 cursor-pointer items-center justify-center rounded-lg bg-transparent transition hover:bg-cornflower-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
89
89
  :disabled="disabled"
90
90
  >
91
91
  <NexxtIcon name="angles-right" />
@@ -20,17 +20,17 @@ defineProps<NexxtPropertyCardProps>()
20
20
  <template>
21
21
  <NexxtCard :image="image" :image-alt="imageAlt">
22
22
  <template v-if="!image" #image>
23
- <div class="relative isolate h-40 w-full overflow-hidden rounded-tl-lg rounded-tr-lg">
23
+ <div class="relative isolate h-40 w-full overflow-hidden rounded-t-lg">
24
24
  <img
25
25
  :src="PropertyPlaceholder"
26
26
  alt="Woning placeholder"
27
27
  loading="lazy"
28
- class="h-full w-full object-cover"
28
+ class="size-full object-cover"
29
29
  />
30
30
  <div
31
- class="absolute inset-0 flex items-center justify-center rounded-tl-lg rounded-tr-lg bg-white/20 backdrop-blur-sm"
31
+ class="absolute inset-0 flex items-center justify-center rounded-t-lg bg-white/20 backdrop-blur-sm"
32
32
  >
33
- <NexxtIcon name="house" class="text-6xl text-white drop-shadow" />
33
+ <NexxtIcon name="house" class="text-6xl text-white drop-shadow-sm" />
34
34
  </div>
35
35
  </div>
36
36
  </template>
@@ -18,14 +18,14 @@ withDefaults(defineProps<NexxtSkeletonPropertyCardProps>(), {
18
18
  role="status"
19
19
  :aria-label="ariaLabel"
20
20
  >
21
- <NexxtSkeleton class="h-40 w-full rounded-tl-lg rounded-tr-lg rounded-b-none" />
21
+ <NexxtSkeleton class="h-40 w-full rounded-t-lg rounded-b-none" />
22
22
 
23
23
  <div
24
24
  class="box-border flex w-full flex-col gap-2 rounded-b-lg border border-gray-200 px-4 py-2"
25
25
  >
26
26
  <div class="flex items-center gap-2">
27
27
  <NexxtSkeleton class="h-4 w-24" />
28
- <NexxtSkeleton class="h-4 w-4" />
28
+ <NexxtSkeleton class="size-4" />
29
29
  <NexxtSkeleton class="h-4 w-28" />
30
30
  </div>
31
31
 
@@ -48,7 +48,7 @@ const activeIcons = computed(() =>
48
48
  <li
49
49
  v-for="platform in activeIcons"
50
50
  :key="platform.name"
51
- class="inline-flex h-5 w-5 items-center justify-center rounded-full outline-1 outline-white"
51
+ class="inline-flex size-5 items-center justify-center rounded-full outline-1 outline-white"
52
52
  :class="brandColors[platform.name]"
53
53
  :aria-label="platform.label"
54
54
  >
@@ -47,7 +47,7 @@ onMounted(() => {
47
47
  muted
48
48
  playsinline
49
49
  :loop="hasPlayedOnce"
50
- class="h-full w-full object-cover"
50
+ class="size-full object-cover"
51
51
  poster="../../../assets/images/Template_aanvragen.jpg"
52
52
  @ended.once="handleEnded"
53
53
  />
@@ -7,11 +7,31 @@ import NexxtTooltip from '../../atoms/Tooltip/Tooltip.vue'
7
7
  export type NexxtTemplateCardContentFeature = 'references' | 'valuation' | 'reviews' | 'packages'
8
8
 
9
9
  export interface NexxtTemplateCardProps {
10
+ /** Preview image of the template. Falls back to a placeholder of the same height when omitted. */
10
11
  image?: string
12
+ /** Template name, shown as the card heading. Truncated on one line. */
11
13
  title: string
14
+ /** Secondary line under the title, e.g. when the template was last changed. Truncated on one line. */
12
15
  subtitle?: string
16
+ /** Content blocks the template contains; each renders an icon with a tooltip. */
13
17
  contentFeatures?: NexxtTemplateCardContentFeature[]
18
+ /** Overrides the default Dutch tooltip text per content feature. */
14
19
  messages?: Partial<Record<NexxtTemplateCardContentFeature, string>>
20
+ /** Lays a full-card button over the content that emits `select`. Off keeps the card inert, so a read-only grid adds no empty tabstop per card. */
21
+ selectable?: boolean
22
+ /** Whether this card is currently chosen: draws the blue border and, with `checkmark`, the badge. Only does something with `selectable`. A prop plus the `select` emit rather than a `defineModel`, because the card owns no state: the parent grid decides which single card may be chosen. */
23
+ selected?: boolean
24
+ /** Shows a check badge in the top-right corner while selected. Only does something with `selectable`. */
25
+ checkmark?: boolean
26
+ /** Disables the select button, dims the card content and drops the overlay. Only does something with `selectable`. */
27
+ disabled?: boolean
28
+ }
29
+
30
+ export type NexxtTemplateCardSlots = {
31
+ /** Covers the whole card and fades in on hover or keyboard focus; the content underneath dims. Not rendered while the card is selectable and disabled. On a coarse pointer it shrinks to the image band, without veil or blur, so the title stays readable and the feature icons stay tappable. The layer is click-through, but `*:pointer-events-auto` only reaches its direct children: an own `absolute inset-0` wrapper inside the slot swallows the select click. */
32
+ overlay?(props: { selected: boolean }): unknown
33
+ /** Top-right corner of the card, for a preview or menu button. Stays interactive while `disabled`, which makes it the place for e.g. a preview button: the overlay is not rendered then. */
34
+ actions?(props: { selected: boolean }): unknown
15
35
  }
16
36
 
17
37
  type TemplateCardIcon = {
@@ -45,46 +65,152 @@ const props = withDefaults(defineProps<NexxtTemplateCardProps>(), {
45
65
  subtitle: undefined,
46
66
  contentFeatures: () => [],
47
67
  messages: () => ({}),
68
+ selectable: false,
69
+ selected: false,
70
+ checkmark: false,
71
+ disabled: false,
48
72
  })
49
73
 
74
+ const emit = defineEmits<{
75
+ /** The card was chosen. No payload: the consumer knows which template it rendered. */
76
+ select: []
77
+ }>()
78
+
79
+ // The root of the template stays a plain `<div>` and must stay single-root: the heading, the
80
+ // paragraph and the tooltip triggers are flow content, which is invalid inside a `<button>`, and
81
+ // wrapping them also dropped the heading role and turned the accessible name of the button into
82
+ // the whole card as one long string. A leading comment in the template would make it a fragment,
83
+ // after which class and attributes no longer reach the card, so the notes live here.
84
+ // Selection, hover and the dim are driven from the props and from the root's own `:hover`, not
85
+ // from the button. The slot checks are read inline rather than through a computed -- slot presence
86
+ // is not reactive, so a consumer toggling `<template v-if>` on a slot would never re-evaluate a
87
+ // cached computed.
88
+ //
89
+ // The overlay and the actions are siblings of the select button, never children: a button inside a
90
+ // button is invalid markup and swallows the inner click, and both layers stack above it so their
91
+ // own buttons get the click. They are handed the selection because their content often styles
92
+ // itself by it.
93
+ defineSlots<NexxtTemplateCardSlots>()
94
+
50
95
  const featureMessages = computed<Record<NexxtTemplateCardContentFeature, string>>(() => ({
51
96
  ...DEFAULT_MESSAGES,
52
97
  ...props.messages,
53
98
  }))
99
+
100
+ const onSelect = () => {
101
+ if (props.selectable && !props.disabled) emit('select')
102
+ }
54
103
  </script>
55
104
 
56
105
  <template>
57
- <div class="w-full max-w-105 rounded-xl border border-gray-200">
58
- <picture class="h-38 w-full">
59
- <img
60
- v-if="image"
61
- :src="image"
62
- :alt="title"
63
- loading="lazy"
64
- class="w-full rounded-t-xl object-cover"
65
- />
66
- <div v-else class="flex h-38 w-full items-center justify-center rounded-t-xl bg-gray-100">
67
- <NexxtIcon name="image" class="text-4xl text-gray-400" />
106
+ <div
107
+ class="group relative size-full max-w-105 rounded-xl border border-gray-200 transition-colors duration-200 ease-out"
108
+ :class="[
109
+ selectable && selected && 'border-cornflower-blue-500',
110
+ selectable && !selected && !disabled && 'hover:border-gray-300',
111
+ ]"
112
+ >
113
+ <div
114
+ class="relative z-10 transition-opacity duration-200 ease-out"
115
+ :class="[
116
+ // Lets a click anywhere on the card reach the stretched button underneath; only the
117
+ // tooltip triggers claim their own pointer events back.
118
+ selectable && 'pointer-events-none',
119
+ selectable && disabled && 'opacity-50',
120
+ $slots.overlay &&
121
+ !(selectable && disabled) &&
122
+ 'group-focus-within:opacity-40 group-hover:opacity-40 pointer-coarse:opacity-100!',
123
+ ]"
124
+ >
125
+ <picture class="block h-38 w-full">
126
+ <!-- Decorative: the template name is right below it as text. -->
127
+ <img
128
+ v-if="image"
129
+ :src="image"
130
+ alt=""
131
+ loading="lazy"
132
+ class="h-38 w-full rounded-t-xl object-cover"
133
+ />
134
+ <div v-else class="flex h-38 w-full items-center justify-center rounded-t-xl bg-gray-100">
135
+ <NexxtIcon name="image" class="text-4xl text-gray-400" />
136
+ </div>
137
+ </picture>
138
+ <div class="rounded-b-xl px-8 pt-3.5 pb-4">
139
+ <h3 class="truncate heading-4 text-gray-900">{{ title }}</h3>
140
+ <p v-if="subtitle" class="truncate body-normal text-gray-700">{{ subtitle }}</p>
141
+ <ul v-if="contentFeatures.length" class="mt-3 flex flex-wrap gap-2">
142
+ <li v-for="feature in contentFeatures" :key="feature">
143
+ <NexxtTooltip>
144
+ <!--
145
+ Claims its pointer events back from the content layer so the tooltip still opens on
146
+ hover, and forwards the click to the card so the icon is no dead zone on a
147
+ selectable card. `onSelect` guards on selectable and disabled itself.
148
+ NexxtIcon is aria-hidden and the trigger is not focusable, so the label is only
149
+ there for screen readers.
150
+ -->
151
+ <div
152
+ :class="[ICON_MAP[feature].backgroundColor, ICON_MAP[feature].textColor]"
153
+ class="pointer-events-auto flex size-8 items-center justify-center rounded-full"
154
+ @click="onSelect"
155
+ >
156
+ <NexxtIcon :name="ICON_MAP[feature].icon" />
157
+ <span class="sr-only">{{ featureMessages[feature] }}</span>
158
+ </div>
159
+ <template #tooltip>
160
+ <span class="small-normal text-gray-700">{{ featureMessages[feature] }}</span>
161
+ </template>
162
+ </NexxtTooltip>
163
+ </li>
164
+ </ul>
68
165
  </div>
69
- </picture>
70
- <div class="rounded-b-xl px-8 pt-3.5 pb-4">
71
- <h3 class="truncate heading-4 text-gray-900">{{ title }}</h3>
72
- <p v-if="subtitle" class="truncate body-normal text-gray-700">{{ subtitle }}</p>
73
- <ul v-if="contentFeatures.length" class="mt-3 flex flex-wrap gap-2">
74
- <li v-for="feature in contentFeatures" :key="feature">
75
- <NexxtTooltip>
76
- <div
77
- :class="[ICON_MAP[feature].backgroundColor, ICON_MAP[feature].textColor]"
78
- class="flex size-8 items-center justify-center rounded-full"
79
- >
80
- <NexxtIcon :name="ICON_MAP[feature].icon" />
81
- </div>
82
- <template #tooltip>
83
- <span class="body-small text-gray-700">{{ featureMessages[feature] }}</span>
84
- </template>
85
- </NexxtTooltip>
86
- </li>
87
- </ul>
88
166
  </div>
167
+
168
+ <!--
169
+ A stretched button instead of a wrapper: it carries the click, the pressed state and the
170
+ focus ring, while the card content above it stays real document structure. It holds the
171
+ lowest stacking level of the layers, so a click on an actions or overlay button lands there
172
+ and not here. The focus ring follows Button.vue (`outline-3` plus offset), so a selected card
173
+ with keyboard focus shows two distinguishable indicators: blue border and ring.
174
+ -->
175
+ <button
176
+ v-if="selectable"
177
+ type="button"
178
+ :aria-label="title"
179
+ :aria-pressed="selected"
180
+ :disabled="disabled || undefined"
181
+ class="absolute inset-0 cursor-pointer rounded-xl focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-500 disabled:cursor-not-allowed"
182
+ @click="onSelect"
183
+ />
184
+
185
+ <div v-if="$slots.actions" class="absolute top-4 right-4 z-20">
186
+ <slot name="actions" :selected="selected" />
187
+ </div>
188
+
189
+ <!--
190
+ Dropped while selectable and disabled: an invisible layer would still hold tabbable buttons,
191
+ and a card that cannot be chosen reveals nothing. A read-only card (no `selectable`) keeps
192
+ its overlay, disabled or not.
193
+ `pointer-events-none` on the layer with `*:pointer-events-auto` on its children lets a click
194
+ on the veil fall through to the select button while the overlay's own buttons keep theirs.
195
+ On a coarse pointer there is no hover to reveal it, so it is always there -- but then without
196
+ veil or blur and shrunk to the image band at the top, because a permanent veil over the title
197
+ and the subtitle drops their contrast below 4.5:1 and a full-height layer would swallow the
198
+ taps on the feature icons.
199
+ jsdom resolves no Tailwind and the storybook project cannot hit-test, so the layering itself
200
+ is asserted by neither suite; only the DOM order is.
201
+ -->
202
+ <div
203
+ v-if="$slots.overlay && !(selectable && disabled)"
204
+ class="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-xl bg-white/60 opacity-0 backdrop-blur-xs transition-opacity duration-200 ease-out *:pointer-events-auto group-focus-within:opacity-100 group-hover:opacity-100 pointer-coarse:inset-x-0 pointer-coarse:top-0 pointer-coarse:bottom-auto pointer-coarse:h-38 pointer-coarse:justify-end pointer-coarse:bg-transparent pointer-coarse:p-4 pointer-coarse:opacity-100 pointer-coarse:backdrop-blur-none"
205
+ >
206
+ <slot name="overlay" :selected="selected" />
207
+ </div>
208
+
209
+ <span
210
+ v-if="selectable && selected && checkmark"
211
+ class="absolute -top-2 -right-2 z-30 flex size-6 items-center justify-center rounded-full bg-cornflower-blue-500 text-white shadow-sm"
212
+ >
213
+ <NexxtIcon name="check" type="solid" class="text-xs" />
214
+ </span>
89
215
  </div>
90
216
  </template>
@@ -87,10 +87,10 @@ const isContactEvent = computed(() => {
87
87
  }"
88
88
  >
89
89
  <div
90
- class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full"
90
+ class="flex size-10 shrink-0 items-center justify-center rounded-full"
91
91
  :class="[eventConfig.color]"
92
92
  >
93
- <NexxtIcon :name="eventConfig.icon as any" class="flex h-6 w-6 text-xl text-white" />
93
+ <NexxtIcon :name="eventConfig.icon as any" class="flex size-6 text-xl text-white" />
94
94
  </div>
95
95
  <div
96
96
  class="flex w-full flex-col gap-2 rounded-xl border border-gray-200 p-8"
@@ -108,7 +108,7 @@ const isContactEvent = computed(() => {
108
108
  <span class="truncate group-hover:underline">
109
109
  {{ event.description || event.url }}
110
110
  </span>
111
- <NexxtIcon name="external-link" class="ml-1 h-4 w-4 text-gray-950" />
111
+ <NexxtIcon name="external-link" class="ml-1 size-4 text-gray-950" />
112
112
  </a>
113
113
  <span v-else-if="event.description" class="mt-1 text-sm text-gray-500">
114
114
  {{ event.description }}
@@ -30,7 +30,7 @@ withDefaults(
30
30
  <span class="flex gap-1">
31
31
  {{ leftLabel }}
32
32
  <NexxtTooltip v-if="leftTooltip">
33
- <NexxtIcon name="circle-info" class="h-4 w-4 text-xs text-gray-600" />
33
+ <NexxtIcon name="circle-info" class="size-4 text-xs text-gray-600" />
34
34
  <template #tooltip
35
35
  ><span class="text-sm">{{ leftTooltip }}</span></template
36
36
  >
@@ -39,7 +39,7 @@ withDefaults(
39
39
  <span class="flex gap-1">
40
40
  {{ rightLabel }}
41
41
  <NexxtTooltip v-if="rightTooltip">
42
- <NexxtIcon name="circle-info" class="h-4 w-4 text-xs text-gray-600" />
42
+ <NexxtIcon name="circle-info" class="size-4 text-xs text-gray-600" />
43
43
  <template #tooltip
44
44
  ><span class="text-sm">{{ rightTooltip }}</span></template
45
45
  >
@@ -48,6 +48,14 @@ const VARIANT_CONFIG: Record<
48
48
  },
49
49
  }
50
50
 
51
+ // Full class names so Tailwind and the no-unknown-classes lint rule see them.
52
+ const VARIANT_CLASSES: Record<ToastVariant, string> = {
53
+ info: 'toast-info',
54
+ success: 'toast-success',
55
+ warning: 'toast-warning',
56
+ error: 'toast-error',
57
+ }
58
+
51
59
  const { variant = 'info', dismissible = true, autoClose = 0 } = defineProps<NexxtToastProps>()
52
60
  const emit = defineEmits<{ dismiss: [] }>()
53
61
 
@@ -94,13 +102,13 @@ onUnmounted(() => {
94
102
  :aria-live="variant === 'error' ? 'assertive' : 'polite'"
95
103
  aria-atomic="true"
96
104
  class="toast flex w-100 gap-4 rounded-xl bg-white shadow-md"
97
- :class="`toast-${variant}`"
105
+ :class="VARIANT_CLASSES[variant]"
98
106
  @mouseenter="autoClose ? pause() : undefined"
99
107
  @mouseleave="autoClose ? resume() : undefined"
100
108
  >
101
109
  <div
102
110
  aria-hidden="true"
103
- class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white"
111
+ class="flex size-8 shrink-0 items-center justify-center rounded-full text-white"
104
112
  :class="selectedVariant.iconBg"
105
113
  >
106
114
  <NexxtIcon :name="selectedVariant.icon" class="text-xl" />
@@ -115,7 +123,7 @@ onUnmounted(() => {
115
123
  <button
116
124
  v-if="dismissible"
117
125
  type="button"
118
- class="relative flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center focus:outline-none"
126
+ class="relative flex size-8 shrink-0 cursor-pointer items-center justify-center focus:outline-none"
119
127
  :aria-label="
120
128
  autoClose ? `Sluiten (verdwijnt automatisch na ${autoClose / 1000} seconden)` : 'Sluiten'
121
129
  "
@@ -137,7 +137,7 @@ defineExpose({
137
137
  :aria-label="format(day, 'd MMMM yyyy', { locale })"
138
138
  :tabindex="isSameMonth(day, viewDate) ? 0 : -1"
139
139
  :class="[
140
- 'relative flex h-8 w-8 items-center justify-center rounded-xl text-xs transition-colors duration-150 outline-none focus:ring-2 focus:ring-cornflower-blue-500 focus:ring-offset-1',
140
+ 'relative flex size-8 items-center justify-center rounded-xl text-xs transition-colors duration-150 outline-none focus:ring-2 focus:ring-cornflower-blue-500 focus:ring-offset-1',
141
141
  !isSameMonth(day, viewDate) ? 'text-gray-300' : 'text-gray-900',
142
142
  isSelected(day) ? 'bg-cornflower-blue-500 font-medium text-white!' : '',
143
143
  isTodayByTimezone(day) && !isSelected(day)
@@ -158,7 +158,7 @@ defineExpose({
158
158
  <span
159
159
  v-if="isMarked(day)"
160
160
  :class="[
161
- 'absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full',
161
+ 'absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full',
162
162
  isSelected(day) ? 'bg-white' : 'bg-cornflower-blue-500',
163
163
  ]"
164
164
  />