@m3ui-vue/m3ui-vue 0.2.7 → 0.3.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.
@@ -0,0 +1,223 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, useId } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+ import { useFieldBg } from '../composables/useFieldBg'
5
+
6
+ const props = withDefaults(
7
+ defineProps<{
8
+ modelValue: string[]
9
+ label?: string
10
+ placeholder?: string
11
+ variant?: 'filled' | 'outlined'
12
+ disabled?: boolean
13
+ error?: string
14
+ hint?: string
15
+ required?: boolean
16
+ leadingIcon?: string
17
+ fieldBg?: string
18
+ maxTags?: number
19
+ duplicates?: boolean
20
+ clearable?: boolean
21
+ }>(),
22
+ {
23
+ modelValue: () => [],
24
+ variant: 'filled',
25
+ disabled: false,
26
+ required: false,
27
+ duplicates: false,
28
+ clearable: false,
29
+ },
30
+ )
31
+
32
+ const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
33
+
34
+ const id = useId()
35
+ const focused = ref(false)
36
+ const inputValue = ref('')
37
+ const fieldEl = ref<HTMLElement | null>(null)
38
+ const inputEl = ref<HTMLInputElement | null>(null)
39
+ const { resolvedFieldBg } = useFieldBg(fieldEl, () => props.fieldBg)
40
+
41
+ const hasValue = computed(() => props.modelValue.length > 0)
42
+ const canAddMore = computed(() => props.maxTags == null || props.modelValue.length < props.maxTags)
43
+
44
+ function addTag(raw: string) {
45
+ const tag = raw.trim()
46
+ if (!tag) return
47
+ if (!props.duplicates && props.modelValue.includes(tag)) return
48
+ if (!canAddMore.value) return
49
+ emit('update:modelValue', [...props.modelValue, tag])
50
+ }
51
+
52
+ function removeTag(index: number, e?: Event) {
53
+ e?.stopPropagation()
54
+ const next = [...props.modelValue]
55
+ next.splice(index, 1)
56
+ emit('update:modelValue', next)
57
+ }
58
+
59
+ function onKeydown(e: KeyboardEvent) {
60
+ if (e.key === 'Enter' || e.key === ',') {
61
+ e.preventDefault()
62
+ addTag(inputValue.value)
63
+ inputValue.value = ''
64
+ } else if (e.key === 'Backspace' && inputValue.value === '' && props.modelValue.length > 0) {
65
+ removeTag(props.modelValue.length - 1)
66
+ }
67
+ }
68
+
69
+ function onInput(e: Event) {
70
+ const value = (e.target as HTMLInputElement).value
71
+ // Handle comma typed via input event (covers paste with commas too)
72
+ if (value.includes(',')) {
73
+ const parts = value.split(',')
74
+ for (const part of parts.slice(0, -1)) {
75
+ addTag(part)
76
+ }
77
+ inputValue.value = parts[parts.length - 1] ?? ''
78
+ } else {
79
+ inputValue.value = value
80
+ }
81
+ }
82
+
83
+ function onPaste(e: ClipboardEvent) {
84
+ const pasted = e.clipboardData?.getData('text') ?? ''
85
+ if (pasted.includes(',')) {
86
+ e.preventDefault()
87
+ const parts = pasted.split(',')
88
+ for (const part of parts) {
89
+ addTag(part)
90
+ }
91
+ }
92
+ }
93
+
94
+ function focusInput() {
95
+ if (!props.disabled) {
96
+ inputEl.value?.focus()
97
+ }
98
+ }
99
+
100
+ const triggerClasses = computed(() => {
101
+ const base = [
102
+ 'flex min-h-[56px] w-full cursor-text items-center gap-1.5 flex-wrap',
103
+ 'transition-[border-color,border-width] duration-150',
104
+ props.leadingIcon ? 'pl-12 pr-10' : 'pl-4 pr-10',
105
+ ]
106
+
107
+ if (props.variant === 'outlined') {
108
+ return [
109
+ ...base,
110
+ 'rounded-sm border bg-transparent py-2',
111
+ focused.value
112
+ ? (props.error ? 'border-2 border-error' : 'border-2 border-primary')
113
+ : (props.error ? 'border-error' : 'border-outline hover:border-on-surface'),
114
+ ].join(' ')
115
+ }
116
+
117
+ return [
118
+ ...base,
119
+ 'rounded-t-sm bg-surface-container-highest border-b pb-2',
120
+ hasValue.value || focused.value ? 'pt-7' : 'pt-4',
121
+ focused.value
122
+ ? (props.error ? 'border-b-2 border-error' : 'border-b-2 border-primary')
123
+ : (props.error ? 'border-error' : 'border-on-surface-variant hover:border-on-surface'),
124
+ ].join(' ')
125
+ })
126
+
127
+ const labelClasses = computed(() => {
128
+ const left = props.leadingIcon
129
+ ? (props.variant === 'outlined' ? 'left-11' : 'left-12')
130
+ : (props.variant === 'outlined' ? 'left-3' : 'left-4')
131
+
132
+ const floated = props.variant === 'outlined'
133
+ ? '-top-2.5 translate-y-0 text-label-small bg-[var(--field-bg)] px-1 right-auto max-w-[calc(100%-1.5rem)]'
134
+ : 'top-2 translate-y-0 text-label-small'
135
+
136
+ const unFloated = props.variant === 'filled'
137
+ ? 'top-[53%] -translate-y-1/2 text-body-large'
138
+ : 'top-1/2 -translate-y-1/2 text-body-large'
139
+ const active = focused.value || hasValue.value
140
+
141
+ return [
142
+ 'pointer-events-none absolute right-10 truncate transition-all duration-200',
143
+ left,
144
+ active ? floated : unFloated,
145
+ focused.value
146
+ ? (props.error ? 'text-error' : 'text-primary')
147
+ : (props.error ? 'text-error' : 'text-on-surface-variant'),
148
+ ].join(' ')
149
+ })
150
+ </script>
151
+
152
+ <template>
153
+ <div class="flex flex-col gap-1">
154
+ <div
155
+ ref="fieldEl"
156
+ class="relative"
157
+ :class="variant === 'outlined' ? 'mt-2' : ''"
158
+ :style="variant === 'outlined' ? { '--field-bg': resolvedFieldBg } : undefined"
159
+ >
160
+ <div
161
+ v-if="leadingIcon"
162
+ class="pointer-events-none absolute left-3.5 text-on-surface-variant"
163
+ :class="variant === 'filled' ? 'top-5' : 'top-4.5'"
164
+ >
165
+ <MIcon :name="leadingIcon" :size="20" />
166
+ </div>
167
+
168
+ <!-- Trigger field -->
169
+ <div
170
+ :id="id"
171
+ :class="triggerClasses"
172
+ @click="focusInput"
173
+ >
174
+ <span
175
+ v-for="(tag, i) in modelValue"
176
+ :key="i"
177
+ class="inline-flex items-center gap-1 rounded-full bg-secondary-container px-2 py-0.5 text-label-small text-on-secondary-container"
178
+ >
179
+ {{ tag }}
180
+ <button
181
+ v-if="!disabled"
182
+ type="button"
183
+ class="flex h-4 w-4 items-center justify-center rounded-full hover:bg-on-secondary-container/20"
184
+ @click="removeTag(i, $event)"
185
+ >
186
+ <MIcon name="close" :size="12" />
187
+ </button>
188
+ </span>
189
+
190
+ <input
191
+ ref="inputEl"
192
+ :value="inputValue"
193
+ type="text"
194
+ class="min-w-[60px] flex-1 bg-transparent text-body-large text-on-surface outline-none placeholder:text-on-surface-variant"
195
+ :placeholder="!hasValue && (!label || focused) ? placeholder : ''"
196
+ :disabled="disabled"
197
+ :readonly="!canAddMore"
198
+ @input="onInput"
199
+ @keydown="onKeydown"
200
+ @paste="onPaste"
201
+ @focus="focused = true"
202
+ @blur="focused = false"
203
+ />
204
+ </div>
205
+
206
+ <label :class="labelClasses">
207
+ {{ label }}<span v-if="required" class="text-error">&nbsp;*</span>
208
+ </label>
209
+
210
+ <button
211
+ v-if="clearable && hasValue && !disabled"
212
+ type="button"
213
+ class="absolute right-2 top-4 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
214
+ @click.stop="emit('update:modelValue', []); inputValue = ''"
215
+ >
216
+ <MIcon name="close" :size="18" />
217
+ </button>
218
+ </div>
219
+
220
+ <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
221
+ <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
222
+ </div>
223
+ </template>
@@ -136,7 +136,7 @@ function onInput(event: Event) {
136
136
  <div
137
137
  v-if="leadingIcon"
138
138
  class="pointer-events-none absolute left-3.5 text-on-surface-variant"
139
- :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
139
+ :class="multiline ? 'top-[55%] -translate-y-1/2' : variant === 'filled' ? 'top-5' : 'top-4.5'"
140
140
  >
141
141
  <MIcon :name="leadingIcon" :size="20" />
142
142
  </div>
@@ -169,14 +169,13 @@ function onInput(event: Event) {
169
169
  {{ label }}<span v-if="required" class="text-error">&nbsp;*</span>
170
170
  </label>
171
171
 
172
- <div v-if="$slots.trailing" class="absolute right-2" :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'">
172
+ <div v-if="$slots.trailing" class="absolute right-2 top-1/2 -translate-y-1/2">
173
173
  <slot name="trailing" />
174
174
  </div>
175
175
  <button
176
176
  v-else-if="showClear"
177
177
  type="button"
178
- class="absolute right-3 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
179
- :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
178
+ class="absolute right-3 top-1/2 -translate-y-1/2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
180
179
  @click="emit('update:modelValue', '')"
181
180
  >
182
181
  <MIcon name="close" :size="18" />
@@ -0,0 +1,295 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ modelValue: boolean
8
+ title?: string
9
+ icon?: string
10
+ width?: string
11
+ height?: string
12
+ minWidth?: number
13
+ minHeight?: number
14
+ resizable?: boolean
15
+ draggable?: boolean
16
+ closable?: boolean
17
+ minimizable?: boolean
18
+ x?: number
19
+ y?: number
20
+ resetPosition?: boolean
21
+ }>(),
22
+ {
23
+ width: '400px',
24
+ height: '300px',
25
+ minWidth: 200,
26
+ minHeight: 150,
27
+ resizable: true,
28
+ draggable: true,
29
+ closable: true,
30
+ minimizable: true,
31
+ x: 50,
32
+ y: 50,
33
+ resetPosition: false,
34
+ },
35
+ )
36
+
37
+ const emit = defineEmits<{
38
+ 'update:modelValue': [value: boolean]
39
+ close: []
40
+ minimize: []
41
+ }>()
42
+
43
+ // ---- State ----
44
+ const posX = ref(props.x)
45
+ const posY = ref(props.y)
46
+ const winWidth = ref(parseInt(props.width) || 400)
47
+ const winHeight = ref(parseInt(props.height) || 300)
48
+ const minimized = ref(false)
49
+ const zIndex = ref(100)
50
+
51
+ // Global z-index counter shared across instances
52
+ let globalZIndex = 100
53
+
54
+ const windowRef = ref<HTMLElement | null>(null)
55
+
56
+ // ---- Drag state ----
57
+ let isDragging = false
58
+ let dragStartX = 0
59
+ let dragStartY = 0
60
+ let dragStartPosX = 0
61
+ let dragStartPosY = 0
62
+
63
+ // ---- Resize state ----
64
+ type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
65
+ let isResizing = false
66
+ let resizeDir: ResizeDirection = 'se'
67
+ let resizeStartX = 0
68
+ let resizeStartY = 0
69
+ let resizeStartW = 0
70
+ let resizeStartH = 0
71
+ let resizeStartPosX = 0
72
+ let resizeStartPosY = 0
73
+
74
+ // ---- Computed ----
75
+ const windowStyle = computed(() => ({
76
+ left: `${posX.value}px`,
77
+ top: `${posY.value}px`,
78
+ width: `${winWidth.value}px`,
79
+ height: minimized.value ? 'auto' : `${winHeight.value}px`,
80
+ zIndex: zIndex.value,
81
+ }))
82
+
83
+ // ---- Bring to front ----
84
+ function bringToFront() {
85
+ globalZIndex++
86
+ zIndex.value = globalZIndex
87
+ }
88
+
89
+ // ---- Drag ----
90
+ function onTitleBarMouseDown(e: MouseEvent) {
91
+ if (!props.draggable) return
92
+ // Ignore clicks on buttons inside the title bar
93
+ if ((e.target as HTMLElement).closest('button')) return
94
+ e.preventDefault()
95
+ isDragging = true
96
+ dragStartX = e.clientX
97
+ dragStartY = e.clientY
98
+ dragStartPosX = posX.value
99
+ dragStartPosY = posY.value
100
+ bringToFront()
101
+ document.addEventListener('mousemove', onMouseMove)
102
+ document.addEventListener('mouseup', onMouseUp)
103
+ }
104
+
105
+ // ---- Resize ----
106
+ function onResizeMouseDown(e: MouseEvent, direction: ResizeDirection) {
107
+ if (!props.resizable) return
108
+ e.preventDefault()
109
+ e.stopPropagation()
110
+ isResizing = true
111
+ resizeDir = direction
112
+ resizeStartX = e.clientX
113
+ resizeStartY = e.clientY
114
+ resizeStartW = winWidth.value
115
+ resizeStartH = winHeight.value
116
+ resizeStartPosX = posX.value
117
+ resizeStartPosY = posY.value
118
+ bringToFront()
119
+ document.addEventListener('mousemove', onMouseMove)
120
+ document.addEventListener('mouseup', onMouseUp)
121
+ }
122
+
123
+ function onMouseMove(e: MouseEvent) {
124
+ e.preventDefault()
125
+
126
+ if (isDragging) {
127
+ const dx = e.clientX - dragStartX
128
+ const dy = e.clientY - dragStartY
129
+ const parent = windowRef.value?.parentElement
130
+ const maxX = parent ? parent.clientWidth - winWidth.value : Infinity
131
+ const maxY = parent ? parent.clientHeight - (minimized.value ? 40 : winHeight.value) : Infinity
132
+ posX.value = Math.max(0, Math.min(maxX, dragStartPosX + dx))
133
+ posY.value = Math.max(0, Math.min(maxY, dragStartPosY + dy))
134
+ }
135
+
136
+ if (isResizing) {
137
+ const dx = e.clientX - resizeStartX
138
+ const dy = e.clientY - resizeStartY
139
+ const dir = resizeDir
140
+ const parent = windowRef.value?.parentElement
141
+ const parentW = parent ? parent.clientWidth : Infinity
142
+ const parentH = parent ? parent.clientHeight : Infinity
143
+
144
+ // East
145
+ if (dir.includes('e')) {
146
+ const maxW = parentW - resizeStartPosX
147
+ winWidth.value = Math.min(maxW, Math.max(props.minWidth, resizeStartW + dx))
148
+ }
149
+ // West
150
+ if (dir.includes('w')) {
151
+ const maxW = resizeStartPosX + resizeStartW
152
+ const newW = Math.min(maxW, Math.max(props.minWidth, resizeStartW - dx))
153
+ posX.value = Math.max(0, resizeStartPosX + (resizeStartW - newW))
154
+ winWidth.value = newW
155
+ }
156
+ // South
157
+ if (dir.includes('s')) {
158
+ const maxH = parentH - resizeStartPosY
159
+ winHeight.value = Math.min(maxH, Math.max(props.minHeight, resizeStartH + dy))
160
+ }
161
+ // North
162
+ if (dir === 'n' || dir === 'ne' || dir === 'nw') {
163
+ const maxH = resizeStartPosY + resizeStartH
164
+ const newH = Math.min(maxH, Math.max(props.minHeight, resizeStartH - dy))
165
+ posY.value = Math.max(0, resizeStartPosY + (resizeStartH - newH))
166
+ winHeight.value = newH
167
+ }
168
+ }
169
+ }
170
+
171
+ function onMouseUp() {
172
+ isDragging = false
173
+ isResizing = false
174
+ document.removeEventListener('mousemove', onMouseMove)
175
+ document.removeEventListener('mouseup', onMouseUp)
176
+ }
177
+
178
+ // ---- Actions ----
179
+ function close() {
180
+ emit('update:modelValue', false)
181
+ emit('close')
182
+ if (props.resetPosition) {
183
+ posX.value = props.x
184
+ posY.value = props.y
185
+ winWidth.value = parseInt(props.width) || 400
186
+ winHeight.value = parseInt(props.height) || 300
187
+ minimized.value = false
188
+ }
189
+ }
190
+
191
+ function toggleMinimize() {
192
+ minimized.value = !minimized.value
193
+ emit('minimize')
194
+ }
195
+
196
+ // ---- Focus on click ----
197
+ function onWindowMouseDown() {
198
+ bringToFront()
199
+ }
200
+
201
+ // ---- Sync initial props ----
202
+ watch(
203
+ () => props.x,
204
+ (v) => { posX.value = v },
205
+ )
206
+ watch(
207
+ () => props.y,
208
+ (v) => { posY.value = v },
209
+ )
210
+
211
+ // ---- Cleanup ----
212
+ onUnmounted(() => {
213
+ document.removeEventListener('mousemove', onMouseMove)
214
+ document.removeEventListener('mouseup', onMouseUp)
215
+ })
216
+
217
+ // Resize handle definitions
218
+ const resizeHandles: { dir: ResizeDirection; class: string; cursor: string }[] = [
219
+ { dir: 'n', class: 'top-0 left-[10px] right-[10px] h-[5px]', cursor: 'cursor-ns-resize' },
220
+ { dir: 's', class: 'bottom-0 left-[10px] right-[10px] h-[5px]', cursor: 'cursor-ns-resize' },
221
+ { dir: 'e', class: 'top-[10px] right-0 bottom-[10px] w-[5px]', cursor: 'cursor-ew-resize' },
222
+ { dir: 'w', class: 'top-[10px] left-0 bottom-[10px] w-[5px]', cursor: 'cursor-ew-resize' },
223
+ { dir: 'ne', class: 'top-0 right-0 w-[12px] h-[12px]', cursor: 'cursor-nesw-resize' },
224
+ { dir: 'nw', class: 'top-0 left-0 w-[12px] h-[12px]', cursor: 'cursor-nwse-resize' },
225
+ { dir: 'se', class: 'bottom-0 right-0 w-[12px] h-[12px]', cursor: 'cursor-nwse-resize' },
226
+ { dir: 'sw', class: 'bottom-0 left-0 w-[12px] h-[12px]', cursor: 'cursor-nesw-resize' },
227
+ ]
228
+ </script>
229
+
230
+ <template>
231
+ <div
232
+ v-if="modelValue"
233
+ ref="windowRef"
234
+ class="absolute flex flex-col rounded-xl bg-surface shadow-elevation-3 overflow-hidden"
235
+ :style="windowStyle"
236
+ @mousedown="onWindowMouseDown"
237
+ >
238
+ <!-- Title bar -->
239
+ <div
240
+ class="flex items-center gap-2 px-4 h-10 bg-surface-container select-none shrink-0"
241
+ :class="{ 'cursor-move': draggable }"
242
+ @mousedown="onTitleBarMouseDown"
243
+ >
244
+ <slot name="header">
245
+ <MIcon v-if="icon" :name="icon" :size="18" class="text-on-surface-variant" />
246
+ <span class="text-title-small font-medium text-on-surface truncate flex-1">
247
+ {{ title }}
248
+ </span>
249
+ </slot>
250
+
251
+ <slot name="actions" />
252
+
253
+ <button
254
+ v-if="minimizable"
255
+ class="text-on-surface-variant hover:bg-on-surface/8 rounded-full w-7 h-7 flex items-center justify-center transition-colors"
256
+ @click="toggleMinimize"
257
+ >
258
+ <MIcon :name="minimized ? 'open_in_full' : 'minimize'" :size="16" />
259
+ </button>
260
+
261
+ <button
262
+ v-if="closable"
263
+ class="text-on-surface-variant hover:bg-error/12 hover:text-error rounded-full w-7 h-7 flex items-center justify-center transition-colors"
264
+ @click="close"
265
+ >
266
+ <MIcon name="close" :size="16" />
267
+ </button>
268
+ </div>
269
+
270
+ <!-- Body -->
271
+ <div
272
+ v-show="!minimized"
273
+ class="flex-1 overflow-auto"
274
+ >
275
+ <slot />
276
+ </div>
277
+
278
+ <!-- Resize handles -->
279
+ <template v-if="resizable && !minimized">
280
+ <div
281
+ v-for="handle in resizeHandles"
282
+ :key="handle.dir"
283
+ class="absolute"
284
+ :class="[handle.class, handle.cursor]"
285
+ @mousedown="onResizeMouseDown($event, handle.dir)"
286
+ />
287
+ </template>
288
+ </div>
289
+ </template>
290
+
291
+ <style scoped>
292
+ .flex-col {
293
+ transition: height 0.2s ease;
294
+ }
295
+ </style>
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export type { M3Locale } from './composables/useLocale'
16
16
  // Components
17
17
  export { default as MAbsolute } from './components/MAbsolute.vue'
18
18
  export { default as MAlert } from './components/MAlert.vue'
19
+ export { default as MAutocomplete } from './components/MAutocomplete.vue'
19
20
  export { default as MAppBar } from './components/MAppBar.vue'
20
21
  export { default as MAppLayout } from './components/MAppLayout.vue'
21
22
  export { default as MAspectRatio } from './components/MAspectRatio.vue'
@@ -73,6 +74,7 @@ export { default as MMenu } from './components/MMenu.vue'
73
74
  export { default as MMenuItem } from './components/MMenuItem.vue'
74
75
  export { default as MMaskField } from './components/MMaskField.vue'
75
76
  export type { MaskPreset } from './components/MMaskField.vue'
77
+ export { default as MMultiAutocomplete } from './components/MMultiAutocomplete.vue'
76
78
  export { default as MMultiSelect } from './components/MMultiSelect.vue'
77
79
  export type { MultiSelectOption } from './components/MMultiSelect.vue'
78
80
  export { default as MNavigationBar } from './components/MNavigationBar.vue'
@@ -110,6 +112,7 @@ export { default as MSticky } from './components/MSticky.vue'
110
112
  export { default as MSubtitle } from './components/MSubtitle.vue'
111
113
  export { default as MSwitch } from './components/MSwitch.vue'
112
114
  export { default as MTable } from './components/MTable.vue'
115
+ export { default as MTagInput } from './components/MTagInput.vue'
113
116
  export { default as MTabs } from './components/MTabs.vue'
114
117
  // MTerminal — import from '@m3ui-vue/m3ui-vue/terminal'
115
118
  export { default as MText } from './components/MText.vue'
@@ -124,6 +127,7 @@ export { default as MTransferList } from './components/MTransferList.vue'
124
127
  export { default as MTree } from './components/MTree.vue'
125
128
  export { default as MTreeTable } from './components/MTreeTable.vue'
126
129
  export { default as MVirtualTable } from './components/MVirtualTable.vue'
130
+ export { default as MWindow } from './components/MWindow.vue'
127
131
 
128
132
  // Re-export types from components that define them
129
133
  export type { EmojiCategory } from './data/emojis'