ketekny-ui-kit 1.0.16 → 1.0.18

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": "ketekny-ui-kit",
3
3
  "type": "module",
4
- "version": "1.0.16",
4
+ "version": "1.0.18",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -40,13 +40,14 @@
40
40
  "dependencies": {
41
41
  "@primeuix/themes": "^1.1.1",
42
42
  "@vuepic/vue-datepicker": "^11.0.2",
43
- "json-editor-vue": "^0.18.1",
44
43
  "he-tree-vue": "^3.1.2",
44
+ "json-editor-vue": "^0.18.1",
45
45
  "lucide-vue-next": "^0.511.0",
46
46
  "moment": "^2.30.1",
47
47
  "primeicons": "^7.0.0",
48
48
  "primevue": "^4.3.4",
49
49
  "quill": "^2.0.3",
50
+ "reka-ui": "^2.8.2",
50
51
  "simple-code-editor": "^2.0.9",
51
52
  "vue-router": "^4.6.4",
52
53
  "vue3-easy-data-table": "^1.5.47"
@@ -2,24 +2,32 @@
2
2
  <div
3
3
  v-if="visible"
4
4
  :style="{ zIndex: zIndex }"
5
- class="fixed inset-0 flex items-center justify-center overflow-hidden bg-black bg-opacity-50 backdrop-blur-sm"
5
+ class="fixed inset-0 flex items-center justify-center overflow-hidden bg-black/50 backdrop-blur-sm"
6
+ @click.self="onBackdropClick"
6
7
  >
7
8
  <transition name="dialog">
8
9
  <div
9
- class="relative bg-white shadow-lg overflow-hidden rounded-2xl flex flex-col max-h-[90vh] w-full m-4 sm:min-w-[600px] sm:w-auto"
10
+ ref="dialogPanel"
11
+ class="relative bg-white shadow-lg overflow-hidden rounded-2xl flex flex-col max-h-[90vh] m-4"
10
12
  :class="dialogClasses"
13
+ :role="'dialog'"
14
+ :aria-modal="'true'"
15
+ :aria-labelledby="titleId"
16
+ tabindex="-1"
11
17
  v-show="visible"
12
18
  >
13
19
  <!-- Header -->
14
- <div class="flex flex-row items-center p-4 text-white bg-primary shrink-0">
15
- <div class="text-xl font-semibold !text-white">{{ title }}</div>
20
+ <div class="flex flex-row items-center p-4 text-white bg-sky-800 shrink-0">
21
+ <div :id="titleId" class="text-xl font-semibold !text-white">{{ title }}</div>
16
22
  <div class="flex-1" />
17
- <div
18
- class="p-1 text-black transition duration-100 ease-in-out rounded-full cursor-pointer text-secondary hover:bg-white hover:text-primary"
23
+ <button
24
+ type="button"
25
+ class="p-1 text-black transition duration-100 ease-in-out rounded-full cursor-pointer text-secondary hover:bg-white hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
26
+ aria-label="Close dialog"
19
27
  @click="close"
20
28
  >
21
- <x />
22
- </div>
29
+ <X />
30
+ </button>
23
31
  </div>
24
32
 
25
33
  <!-- Fixed content (optional) -->
@@ -46,6 +54,7 @@ import { X } from 'lucide-vue-next'
46
54
 
47
55
  // 🔥 Global z-index tracker
48
56
  let dialogZIndexCounter = 1000
57
+ let bodyScrollLockCounter = 0
49
58
 
50
59
  export default {
51
60
  name: 'kDialog',
@@ -54,6 +63,14 @@ export default {
54
63
  visible: Boolean,
55
64
  title: String,
56
65
  maximized: Boolean,
66
+ closeOnBackdrop: {
67
+ type: Boolean,
68
+ default: true,
69
+ },
70
+ closeOnEsc: {
71
+ type: Boolean,
72
+ default: true,
73
+ },
57
74
  width: {
58
75
  type: String,
59
76
  default: '',
@@ -62,39 +79,77 @@ export default {
62
79
  data() {
63
80
  return {
64
81
  zIndex: 1000, // Default fallback
82
+ titleId: `dialog-title-${Math.random().toString(36).slice(2, 10)}`,
83
+ previousActiveElement: null,
65
84
  }
66
85
  },
67
86
  watch: {
68
- visible(val) {
69
- if (val) {
70
- dialogZIndexCounter += 1
71
- this.zIndex = dialogZIndexCounter
72
- }
87
+ visible: {
88
+ immediate: true,
89
+ handler(val) {
90
+ if (val) {
91
+ dialogZIndexCounter += 1
92
+ this.zIndex = dialogZIndexCounter
93
+ this.previousActiveElement = document.activeElement
94
+ this.lockBodyScroll()
95
+ document.addEventListener('keydown', this.handleKeydown)
96
+ this.$nextTick(() => {
97
+ this.$refs.dialogPanel?.focus()
98
+ })
99
+ } else {
100
+ this.unlockBodyScroll()
101
+ document.removeEventListener('keydown', this.handleKeydown)
102
+ this.$nextTick(() => {
103
+ this.previousActiveElement?.focus?.()
104
+ this.previousActiveElement = null
105
+ })
106
+ }
107
+ },
73
108
  },
74
109
  },
75
- // mounted() {
76
- // // ✅ Assign a new z-index when component is created
77
- // dialogZIndexCounter += 1
78
- // this.zIndex = dialogZIndexCounter
79
- // },
80
110
  computed: {
81
111
  computedWidth() {
82
- return this.width ? this.width : 'sm:w-[600px]'
112
+ return 'w-[600px] max-w-[calc(100vw-2rem)]'
83
113
  },
84
114
  dialogClasses() {
85
115
  if (this.width) {
86
- return this.computedWidth
116
+ return this.width
87
117
  } else if (this.maximized) {
88
- return 'max-w-none h-[90vh]'
118
+ return 'w-[calc(100vw-2rem)] max-w-none h-[90vh]'
89
119
  } else {
90
120
  return this.computedWidth + ' max-h-[90vh]'
91
121
  }
92
122
  },
93
123
  },
94
124
  methods: {
125
+ onBackdropClick() {
126
+ if (!this.closeOnBackdrop) return
127
+ this.close()
128
+ },
129
+ handleKeydown(event) {
130
+ if (!this.visible || !this.closeOnEsc) return
131
+ if (event.key === 'Escape') {
132
+ event.preventDefault()
133
+ this.close()
134
+ }
135
+ },
136
+ lockBodyScroll() {
137
+ bodyScrollLockCounter += 1
138
+ if (bodyScrollLockCounter > 1) return
139
+ document.body.style.overflow = 'hidden'
140
+ },
141
+ unlockBodyScroll() {
142
+ bodyScrollLockCounter = Math.max(0, bodyScrollLockCounter - 1)
143
+ if (bodyScrollLockCounter > 0) return
144
+ document.body.style.overflow = ''
145
+ },
95
146
  close() {
96
147
  this.$emit('update:visible', false)
97
148
  },
98
149
  },
150
+ beforeUnmount() {
151
+ this.unlockBodyScroll()
152
+ document.removeEventListener('keydown', this.handleKeydown)
153
+ },
99
154
  }
100
155
  </script>
package/src/ui/kInput.vue CHANGED
@@ -1,6 +1,10 @@
1
1
  <template>
2
2
  <div class="w-full text-primary/90">
3
- <label v-if="label != null" :for="inputId" class="inputLabel" :class="hasError ? theme.labelError : theme.label">
3
+ <label
4
+ v-if="label != null"
5
+ :for="inputId"
6
+ class="inputLabel"
7
+ :class="hasError ? theme.labelError : disabled ? theme.labelDisabled : theme.label">
4
8
  {{ label }}
5
9
  </label>
6
10
  <div class="relative">
@@ -22,11 +26,12 @@
22
26
  :aria-invalid="hasError ? 'true' : 'false'"
23
27
  :aria-describedby="describedById"
24
28
  />
25
- <component v-if="iconComponent" :is="iconComponent" :class="theme.trailingIcon" />
29
+ <component v-if="iconComponent" :is="iconComponent" :class="[theme.trailingIcon, disabled ? theme.trailingIconDisabled : '']" />
26
30
  <div v-if="isPassword" :class="theme.passwordToggle">
27
31
  <button
28
32
  type="button"
29
- :class="theme.passwordToggleButton"
33
+ :disabled="disabled"
34
+ :class="[theme.passwordToggleButton, disabled ? theme.passwordToggleButtonDisabled : '']"
30
35
  :aria-label="showPassword ? 'Hide password' : 'Show password'"
31
36
  :aria-pressed="showPassword ? 'true' : 'false'"
32
37
  @click="showPassword = !showPassword">
@@ -1,95 +1,101 @@
1
1
  <template>
2
2
  <div class="relative w-full">
3
- <!-- Label -->
4
3
  <label v-if="label != null" :for="computedId" class="inputLabel" :class="hasError ? 'text-red-500' : 'text-gray-700'">
5
4
  {{ label }}
6
5
  </label>
7
6
 
8
- <!-- Input Trigger -->
9
- <div
10
- ref="trigger"
11
- @click="toggleDropdown"
12
- @keydown="handleTriggerKeydown"
13
- class="relative w-full px-3 py-2 text-gray-700 bg-white border rounded-lg cursor-pointer trigger-box"
14
- :class="[defaultStyle, hasError ? errorStyle : '', disabled ? disabledStyle : '']"
15
- :aria-haspopup="'listbox'"
16
- :aria-expanded="isOpen.toString()"
17
- :aria-disabled="disabled.toString()"
18
- :aria-controls="dropdownId"
19
- :role="'combobox'"
20
- :tabindex="disabled ? -1 : 0"
21
- >
22
- <span class="block pr-10 truncate">{{ selectedLabel || placeholder }}</span>
7
+ <SelectRoot v-model="internalValue" v-model:open="open" :disabled="disabled">
8
+ <SelectTrigger as-child>
9
+ <div
10
+ :id="computedId"
11
+ class="relative w-full px-3 py-2 text-gray-700 bg-white border rounded-lg cursor-pointer trigger-box"
12
+ :class="[defaultStyle, hasError ? errorStyle : '', disabled ? disabledStyle : '']"
13
+ :aria-label="label || 'Select option'"
14
+ >
15
+ <SelectValue :placeholder="placeholder" class="block pr-10 truncate" />
23
16
 
24
- <!-- Clear button -->
25
- <button
26
- v-if="clearable && isSelectionSet && !disabled"
27
- type="button"
28
- class="absolute text-gray-400 -translate-y-1/2 right-8 top-1/2 hover:text-red-500"
29
- @click.stop="clearSelection"
30
- aria-label="Clear selection">
31
- <X class="w-5 h-5 mr-2" />
32
- </button>
33
-
34
- <!-- Dropdown icon -->
35
- <span class="absolute text-gray-400 -translate-y-1/2 pointer-events-none right-3 top-1/2">
36
- <ChevronDown class="w-5 h-5" v-if="!isOpen" />
37
- <ChevronUp class="w-5 h-5" v-else />
38
- </span>
39
- </div>
17
+ <button
18
+ v-if="clearable && isSelectionSet && !disabled"
19
+ type="button"
20
+ class="absolute text-gray-400 -translate-y-1/2 right-8 top-1/2 hover:text-red-500"
21
+ aria-label="Clear selection"
22
+ @pointerdown.stop.prevent="clearSelection"
23
+ @click.stop.prevent="clearSelection"
24
+ >
25
+ <X class="w-5 h-5 mr-2" />
26
+ </button>
40
27
 
41
- <!-- Dropdown Menu -->
42
- <Teleport to="body">
43
- <div
44
- v-if="isOpen"
45
- ref="dropdown"
46
- :id="dropdownId"
47
- class="absolute z-[9999] mt-1 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
48
- :class="dropdownHeight"
49
- :style="dropdownPositionStyle"
50
- role="listbox"
51
- :aria-label="label || 'Select options'"
52
- >
53
- <!-- Search -->
54
- <div class="sticky top-0 z-10 pt-2 pb-2 mx-2 bg-white">
55
- <input
56
- ref="searchInput"
57
- type="text"
58
- v-model="searchQuery"
59
- placeholder="Αναζήτηση..."
60
- class="w-full px-3 py-2 border border-gray-200 rounded-md focus:outline-none"
61
- role="searchbox"
62
- aria-label="Search options"
63
- @keydown.esc.stop.prevent="closeDropdown"
64
- />
28
+ <span class="absolute text-gray-400 -translate-y-1/2 pointer-events-none right-3 top-1/2">
29
+ <ChevronDown class="w-5 h-5" v-if="!open" />
30
+ <ChevronUp class="w-5 h-5" v-else />
31
+ </span>
65
32
  </div>
33
+ </SelectTrigger>
66
34
 
67
- <!-- Filtered Options -->
68
- <div class="flex flex-col gap-[2px] p-2">
69
- <div
70
- v-for="option in filteredOptions"
71
- :id="`${dropdownId}-opt-${option[optionValue]}`"
72
- :key="option[optionValue]"
73
- @click="selectOption(option)"
74
- class="px-3 py-2 rounded cursor-pointer"
75
- :class="option[optionValue] === selectedValue ? 'bg-primary text-white' : 'hover:bg-blue-100'"
76
- role="option"
77
- :aria-selected="(option[optionValue] === selectedValue).toString()"
78
- >
79
- {{ option[optionLabel] }}
35
+ <SelectPortal>
36
+ <SelectContent
37
+ :id="dropdownId"
38
+ position="popper"
39
+ :side-offset="6"
40
+ class="z-[9999] overflow-hidden bg-white border border-gray-200 rounded-lg shadow-[0_20px_45px_-15px_rgba(15,23,42,0.35),0_8px_18px_-10px_rgba(15,23,42,0.25)] ring-1 ring-slate-900/10"
41
+ >
42
+ <div v-if="searchable" data-select-search-header class="sticky top-0 z-10 pt-2 pb-2 mx-2 bg-white">
43
+ <input
44
+ ref="searchInput"
45
+ type="text"
46
+ v-model="searchQuery"
47
+ placeholder="Αναζήτηση..."
48
+ class="w-full px-3 py-2 border border-gray-200 rounded-md focus:outline-none"
49
+ role="searchbox"
50
+ aria-label="Search options"
51
+ @keydown.stop="handleSearchKeydown"
52
+ />
80
53
  </div>
81
- </div>
82
54
 
83
- <div v-if="filteredOptions.length === 0" class="px-3 py-2 text-gray-400">Δεν βρέθηκαν αποτελέσματα</div>
84
- </div>
85
- </Teleport>
55
+ <div class="relative">
56
+ <SelectViewport
57
+ class="p-2 overflow-y-auto"
58
+ :class="canScrollAny ? 'pt-8 pb-8' : ''"
59
+ :style="{ maxHeight: viewportMaxHeight }"
60
+ @scroll="updateScrollState"
61
+ >
62
+ <div class="flex flex-col gap-0">
63
+ <SelectItem
64
+ v-for="(option, index) in filteredOptions"
65
+ :key="`${option[optionValue]}-${index}`"
66
+ :value="option[optionValue]"
67
+ class="px-3 py-2 rounded cursor-pointer outline-none text-slate-700 hover:bg-primary/20 hover:text-primary data-[highlighted]:bg-primary data-[highlighted]:text-white data-[state=checked]:text-primary data-[state=checked]:data-[highlighted]:text-white"
68
+ >
69
+ <div class="flex items-center justify-between gap-2">
70
+ <SelectItemText class="truncate">{{ option[optionLabel] }}</SelectItemText>
71
+ <span class="inline-flex items-center justify-center w-4 h-4 shrink-0">
72
+ <SelectItemIndicator>
73
+ <Check class="w-4 h-4" />
74
+ </SelectItemIndicator>
75
+ </span>
76
+ </div>
77
+ </SelectItem>
78
+ </div>
79
+
80
+ <div v-if="filteredOptions.length === 0" class="px-3 py-2 text-gray-400">Δεν βρέθηκαν επιλογές</div>
81
+ </SelectViewport>
82
+
83
+ <SelectScrollUpButton class="absolute top-0 left-0 right-0 z-10 flex items-center justify-center h-6 text-gray-500 bg-white/95">
84
+ <ChevronUp class="w-4 h-4" />
85
+ </SelectScrollUpButton>
86
+
87
+ <SelectScrollDownButton class="absolute bottom-0 left-0 right-0 z-10 flex items-center justify-center h-6 text-gray-500 bg-white/95">
88
+ <ChevronDown class="w-4 h-4" />
89
+ </SelectScrollDownButton>
90
+ </div>
91
+ </SelectContent>
92
+ </SelectPortal>
93
+ </SelectRoot>
86
94
 
87
- <!-- Error -->
88
95
  <div class="mt-1 text-red-500" v-if="hasError && error !== true && error !== ''">
89
96
  {{ error }}
90
97
  </div>
91
98
 
92
- <!-- Info -->
93
99
  <div class="mt-1 text-gray-500" v-if="info">
94
100
  {{ info }}
95
101
  </div>
@@ -97,163 +103,168 @@
97
103
  </template>
98
104
 
99
105
  <script setup>
100
- import { X, ChevronDown, ChevronUp } from "lucide-vue-next";
101
- </script>
106
+ import { computed, nextTick, ref, watch } from "vue";
107
+ import {
108
+ SelectContent,
109
+ SelectItem,
110
+ SelectItemIndicator,
111
+ SelectItemText,
112
+ SelectPortal,
113
+ SelectRoot,
114
+ SelectScrollDownButton,
115
+ SelectScrollUpButton,
116
+ SelectTrigger,
117
+ SelectValue,
118
+ SelectViewport,
119
+ } from "reka-ui";
120
+ import { X, ChevronDown, ChevronUp, Check } from "lucide-vue-next";
102
121
 
103
- <script>
104
- export default {
105
- name: "kSelect",
106
- props: {
107
- options: { type: Array, required: true },
108
- modelValue: [String, Number],
109
- optionValue: { type: String, default: "value" },
110
- optionLabel: { type: String, default: "label" },
111
- label: String,
112
- info: String,
113
- error: [String, Boolean],
114
- disabled: { type: Boolean, default: false },
115
- placeholder: { type: String, default: "Επιλέξτε μία τιμή" },
116
- id: { type: String, default: null },
117
- dropdownHeight: { type: String, default: "max-h-80" },
118
- clearable: { type: Boolean, default: true },
119
- },
120
- data() {
121
- return {
122
- isOpen: false,
123
- searchQuery: "",
124
- dropdownPositionStyle: {},
125
- generatedId: `select-${Math.random().toString(36).substr(2, 9)}`,
126
- defaultStyle: "w-full px-3 py-2 border rounded-lg transition shadow-sm focus:outline-none text-gray-700 focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white placeholder-gray-400",
127
- errorStyle: "border-red-500 focus:ring focus:ring-red-300",
128
- disabledStyle: "!bg-gray-100 !text-gray-400 !cursor-not-allowed",
129
- };
122
+ const props = defineProps({
123
+ options: { type: Array, required: true },
124
+ modelValue: [String, Number],
125
+ optionValue: { type: String, default: "value" },
126
+ optionLabel: { type: String, default: "label" },
127
+ label: String,
128
+ info: String,
129
+ error: [String, Boolean],
130
+ disabled: { type: Boolean, default: false },
131
+ placeholder: { type: String, default: "Επιλέξτε μία τιμή" },
132
+ id: { type: String, default: null },
133
+ clearable: { type: Boolean, default: true },
134
+ searchable: { type: Boolean, default: true },
135
+ });
136
+
137
+ const emit = defineEmits(["update:modelValue"]);
138
+
139
+ const open = ref(false);
140
+ const searchQuery = ref("");
141
+ const searchInput = ref(null);
142
+ const canScrollAny = ref(false);
143
+ const generatedId = `select-${Math.random().toString(36).substr(2, 9)}`;
144
+ const viewportMaxHeight = "400px";
145
+ const defaultStyle = "w-full px-3 py-2 border rounded-lg transition shadow-sm focus:outline-none text-gray-700 focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white placeholder-gray-400";
146
+ const errorStyle = "border-red-500 focus:ring focus:ring-red-300";
147
+ const disabledStyle = "!bg-gray-100 !text-gray-400 !cursor-not-allowed";
148
+
149
+ const computedId = computed(() => (props.id != null && props.id !== "" ? props.id : generatedId));
150
+ const dropdownId = computed(() => `${computedId.value}-listbox`);
151
+
152
+ const hasError = computed(() => props.error != null && props.error !== false);
153
+ const isSelectionSet = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
154
+
155
+ const internalValue = computed({
156
+ get() {
157
+ return props.modelValue === null ? undefined : props.modelValue;
130
158
  },
131
- computed: {
132
- computedId() {
133
- return this.id != null && this.id !== "" ? this.id : this.generatedId;
134
- },
135
- dropdownId() {
136
- return `${this.computedId}-listbox`;
137
- },
138
- selectedValue() {
139
- return this.modelValue;
140
- },
141
- isSelectionSet() {
142
- return this.selectedValue !== null && this.selectedValue !== undefined && this.selectedValue !== "";
143
- },
144
- selectedLabel() {
145
- const match = this.options.find((o) => o[this.optionValue] === this.modelValue);
146
- return match ? match[this.optionLabel] : "";
147
- },
148
- filteredOptions() {
149
- const q = this.searchQuery.trim().toLowerCase();
150
- return this.options.filter((o) => String(o[this.optionLabel] ?? "").toLowerCase().includes(q));
151
- },
152
- hasError() {
153
- return this.error != null && this.error !== false;
154
- },
159
+ set(val) {
160
+ emit("update:modelValue", val ?? null);
155
161
  },
156
- methods: {
157
- toggleDropdown() {
158
- if (this.disabled) return;
159
- this.isOpen = !this.isOpen;
160
- if (this.isOpen) {
161
- this.$nextTick(() => {
162
- this.getDropdownPosition();
163
- this.$refs.searchInput?.focus();
164
- });
165
- } else {
166
- this.searchQuery = "";
167
- }
168
- },
169
- closeDropdown() {
170
- this.isOpen = false;
171
- this.searchQuery = "";
172
- },
173
- selectOption(option) {
174
- this.$emit("update:modelValue", option[this.optionValue]);
175
- this.closeDropdown();
176
- },
177
- clearSelection() {
178
- this.$emit("update:modelValue", null);
179
- this.closeDropdown();
180
- },
181
- handleTriggerKeydown(event) {
182
- if (this.disabled) return;
183
- if (event.key === "Enter" || event.key === " ") {
184
- event.preventDefault();
185
- this.toggleDropdown();
186
- } else if (event.key === "ArrowDown" && !this.isOpen) {
187
- event.preventDefault();
188
- this.toggleDropdown();
189
- } else if (event.key === "Escape" && this.isOpen) {
190
- event.preventDefault();
191
- this.closeDropdown();
192
- }
193
- },
194
- closeOnClickOutside(e) {
195
- const clickedOutside = !this.$el.contains(e.target) && !(this.$refs.dropdown && this.$refs.dropdown.contains(e.target));
162
+ });
196
163
 
197
- if (clickedOutside) {
198
- this.closeDropdown();
199
- }
200
- },
201
- handleViewportChange() {
202
- if (!this.isOpen) return;
203
- this.getDropdownPosition();
204
- },
164
+ const filteredOptions = computed(() => {
165
+ if (!props.searchable) return props.options;
166
+ const q = searchQuery.value.trim().toLowerCase();
167
+ if (!q) return props.options;
168
+ return props.options.filter((o) => String(o?.[props.optionLabel] ?? "").toLowerCase().includes(q));
169
+ });
205
170
 
206
- getDropdownPosition() {
207
- const trigger = this.$refs.trigger;
208
- if (!trigger) return;
209
- const dropdown = this.$refs.dropdown;
171
+ watch(open, (val) => {
172
+ if (val) {
173
+ nextTick(() => {
174
+ if (props.searchable) searchInput.value?.focus();
175
+ updateScrollState();
176
+ });
177
+ return;
178
+ }
179
+ searchQuery.value = "";
180
+ canScrollAny.value = false;
181
+ });
210
182
 
211
- const rect = trigger.getBoundingClientRect();
212
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
213
- const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
214
- const viewportHeight = window.innerHeight;
215
- const viewportWidth = window.innerWidth;
216
- const pad = 8;
183
+ watch(filteredOptions, () => {
184
+ if (!open.value) return;
185
+ nextTick(() => updateScrollState());
186
+ });
217
187
 
218
- const spaceBelow = viewportHeight - rect.bottom;
219
- const spaceAbove = rect.top;
220
- const contentHeight = dropdown ? dropdown.scrollHeight : 320;
221
- const maxViewportHeight = Math.max(120, viewportHeight - pad * 2);
222
- const desiredHeight = Math.min(contentHeight, maxViewportHeight);
188
+ function clearSelection() {
189
+ emit("update:modelValue", null);
190
+ open.value = false;
191
+ }
223
192
 
224
- const shouldOpenAbove = spaceBelow < Math.min(240, desiredHeight) && spaceAbove > spaceBelow;
225
- const availableHeight = Math.max(120, (shouldOpenAbove ? spaceAbove : spaceBelow) - pad);
226
- const finalHeight = Math.min(desiredHeight, availableHeight);
193
+ function focusFirstOrLastOption(last = false) {
194
+ const container = document.getElementById(dropdownId.value);
195
+ if (!container) return;
196
+ const nodes = Array.from(container.querySelectorAll('[role="option"]:not([data-disabled])'));
197
+ if (!nodes.length) return;
198
+ const target = last ? nodes[nodes.length - 1] : nodes[0];
199
+ target.focus();
200
+ }
227
201
 
228
- const rawTop = shouldOpenAbove ? rect.top + scrollTop - finalHeight : rect.bottom + scrollTop;
229
- const minTop = scrollTop + pad;
230
- const maxTop = scrollTop + viewportHeight - finalHeight - pad;
231
- const top = Math.min(Math.max(rawTop, minTop), maxTop);
202
+ function moveOptionFocus(step) {
203
+ const container = document.getElementById(dropdownId.value);
204
+ if (!container) return;
205
+ const nodes = Array.from(container.querySelectorAll('[role="option"]:not([data-disabled])'));
206
+ if (!nodes.length) return;
207
+ const activeIndex = nodes.findIndex((node) => node === document.activeElement);
208
+ const baseIndex = activeIndex >= 0 ? activeIndex : step > 0 ? -1 : 0;
209
+ const nextIndex = Math.min(Math.max(baseIndex + step, 0), nodes.length - 1);
210
+ nodes[nextIndex].focus();
211
+ }
232
212
 
233
- const rawLeft = rect.left + scrollLeft;
234
- const minLeft = scrollLeft + pad;
235
- const maxLeft = scrollLeft + viewportWidth - rect.width - pad;
236
- const left = Math.min(Math.max(rawLeft, minLeft), Math.max(minLeft, maxLeft));
213
+ function handleSearchKeydown(event) {
214
+ if (event.key === "Escape") {
215
+ event.preventDefault();
216
+ event.stopPropagation();
217
+ open.value = false;
218
+ return;
219
+ }
237
220
 
238
- this.dropdownPositionStyle = {
239
- position: "absolute",
240
- top: `${top}px`,
241
- left: `${left}px`,
242
- minWidth: `${rect.width}px`,
243
- maxWidth: `500px`,
244
- maxHeight: `${finalHeight}px`,
245
- };
246
- },
247
- },
248
- mounted() {
249
- document.addEventListener("click", this.closeOnClickOutside);
250
- window.addEventListener("resize", this.handleViewportChange);
251
- window.addEventListener("scroll", this.handleViewportChange, true);
252
- },
253
- beforeUnmount() {
254
- document.removeEventListener("click", this.closeOnClickOutside);
255
- window.removeEventListener("resize", this.handleViewportChange);
256
- window.removeEventListener("scroll", this.handleViewportChange, true);
257
- },
258
- };
221
+ if (event.key === "ArrowDown") {
222
+ event.preventDefault();
223
+ event.stopPropagation();
224
+ moveOptionFocus(1);
225
+ return;
226
+ }
227
+
228
+ if (event.key === "ArrowUp") {
229
+ event.preventDefault();
230
+ event.stopPropagation();
231
+ moveOptionFocus(-1);
232
+ return;
233
+ }
234
+
235
+ if (event.key === "Home") {
236
+ event.preventDefault();
237
+ event.stopPropagation();
238
+ focusFirstOrLastOption(false);
239
+ return;
240
+ }
241
+
242
+ if (event.key === "End") {
243
+ event.preventDefault();
244
+ event.stopPropagation();
245
+ focusFirstOrLastOption(true);
246
+ return;
247
+ }
248
+
249
+ if (event.key === "Enter") {
250
+ const container = document.getElementById(dropdownId.value);
251
+ if (!container) return;
252
+ const active = document.activeElement;
253
+ if (active && container.contains(active) && active.getAttribute("role") === "option") {
254
+ event.preventDefault();
255
+ event.stopPropagation();
256
+ active.click();
257
+ }
258
+ }
259
+ }
260
+
261
+ function updateScrollState() {
262
+ const container = document.getElementById(dropdownId.value);
263
+ const viewport = container?.querySelector("[data-reka-select-viewport]");
264
+ if (!viewport) {
265
+ canScrollAny.value = false;
266
+ return;
267
+ }
268
+ canScrollAny.value = viewport.scrollHeight > viewport.clientHeight + 1;
269
+ }
259
270
  </script>
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div :class="labelStyle === 'inline' ? 'flex items-center' : ''">
3
3
  <!-- Block-style label -->
4
- <div v-if="showLabel && labelStyle !== 'inline'" class="block mb-1 text-sm font-bold text-primary/90" for="toggle">
4
+ <div v-if="showLabel && labelStyle !== 'inline'" :class="['block mb-1 text-sm font-bold', disabled ? 'text-slate-500' : 'text-primary/90']" for="toggle">
5
5
  {{ label }}
6
6
  </div>
7
7
 
@@ -13,15 +13,27 @@
13
13
  :aria-disabled="disabled.toString()"
14
14
  :disabled="disabled"
15
15
  :class="[
16
- 'w-16 h-8 flex items-center rounded-full p-1 transition duration-300',
17
- modelValue ? 'bg-primary' : 'bg-gray-400',
18
- disabled ? 'opacity-50 cursor-not-allowed' : '',
16
+ 'w-16 h-8 flex items-center rounded-full p-1 transition duration-300 border',
17
+ disabled
18
+ ? 'bg-slate-200 border-slate-300 cursor-not-allowed'
19
+ : modelValue
20
+ ? 'bg-primary border-primary shadow-sm shadow-primary/30'
21
+ : 'bg-gray-400 border-gray-400',
19
22
  ]">
20
- <div :class="['w-6 h-6 bg-white rounded-full shadow-md transform transition duration-300', modelValue ? 'translate-x-8' : 'translate-x-0']"></div>
23
+ <div
24
+ :class="[
25
+ 'w-6 h-6 rounded-full shadow-md transform transition duration-300',
26
+ disabled ? 'bg-slate-100' : modelValue ? 'bg-white ring-2 ring-primary/20' : 'bg-white',
27
+ modelValue ? 'translate-x-8' : 'translate-x-0',
28
+ ]"></div>
21
29
  </button>
22
30
 
23
31
  <!-- Inline-style label -->
24
- <div v-if="showLabel && labelStyle === 'inline'" class="ml-2 text-sm font-bold cursor-pointer text-primary/90" :for="computedId" @click="toggle">
32
+ <div
33
+ v-if="showLabel && labelStyle === 'inline'"
34
+ :class="['ml-2 text-sm font-bold', disabled ? 'text-slate-500 cursor-not-allowed' : 'text-primary/90 cursor-pointer']"
35
+ :for="computedId"
36
+ @click="toggle">
25
37
  {{ label }}
26
38
  </div>
27
39
  </div>
@@ -1,16 +1,19 @@
1
1
  export const K_INPUT_THEME = {
2
2
  label: "text-primary/90",
3
+ labelDisabled: "text-slate-500",
3
4
  labelError: "text-rose-800",
4
5
  baseInput:
5
6
  "w-full px-3 py-2 border rounded-lg transition shadow-sm focus:outline-none text-slate-900 bg-white placeholder-gray-400 focus:ring-2 focus:ring-primary/25 focus:border-primary",
6
7
  withRightAdornment: "pr-10",
7
8
  withPasswordToggle: "pr-14",
8
- disabled: "bg-slate-100 text-slate-500 cursor-not-allowed border-slate-300",
9
+ disabled: "!bg-slate-50 !text-slate-400 !border-slate-200 !shadow-none cursor-not-allowed placeholder-slate-400",
9
10
  errorInput: "border-rose-500 bg-rose-50/40 focus:border-rose-600 focus:ring-rose-500/20",
10
11
  infoText: "text-sm text-slate-600",
11
12
  errorText: "text-sm text-rose-700",
12
13
  trailingIcon: "absolute w-4 h-4 text-primary/70 -translate-y-1/2 pointer-events-none right-3 top-1/2",
14
+ trailingIconDisabled: "text-slate-400",
13
15
  passwordToggle: "absolute inset-y-0 right-0 flex items-center pr-3 text-slate-700",
14
16
  passwordToggleButton:
15
17
  "text-xs font-medium select-none rounded px-1 py-0.5 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
18
+ passwordToggleButtonDisabled: "text-slate-400 cursor-not-allowed hover:bg-transparent",
16
19
  };
@@ -8,7 +8,7 @@ const config = {
8
8
  header: '#256D96',
9
9
  background: '#FEFCF6',
10
10
  accent: '#8C1F1F',
11
- primary: '#1E5F7C',
11
+ primary: '#0369A1',
12
12
  secondary: '#f5f7fa',
13
13
  success: '#228B22',
14
14
  danger: '#A32626',
@@ -50,4 +50,4 @@ const config = {
50
50
  plugins: [],
51
51
  }
52
52
 
53
- export default config
53
+ export default config
@@ -22,7 +22,7 @@ export default {
22
22
  header: "#256D96",
23
23
  background: "#FEFCF6",
24
24
  accent: "#8C1F1F",
25
- primary: "#1E5F7C",
25
+ primary: "#0369A1",
26
26
  secondary: "#f5f7fa",
27
27
  success: "#228B22",
28
28
  danger: "#A32626",