mikuru 1.0.27 → 1.0.28

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,153 @@
1
+ <template>
2
+ <div class="mikuru-dropdown" ref="rootEl" @keydown="handleKeydown">
3
+ <button
4
+ class="dropdown-trigger"
5
+ type="button"
6
+ :aria-expanded="isOpen ? 'true' : 'false'"
7
+ aria-haspopup="menu"
8
+ @click="toggle"
9
+ >
10
+ <span>{{ label }}</span>
11
+ <span aria-hidden="true">⌄</span>
12
+ </button>
13
+
14
+ <div m-if="isOpen" class="dropdown-menu" role="menu">
15
+ <button
16
+ m-for="item in normalizedItems"
17
+ :key="item.value"
18
+ class="dropdown-item"
19
+ type="button"
20
+ role="menuitem"
21
+ :disabled="item.disabled"
22
+ @click="selectItem(item)"
23
+ >
24
+ <span>{{ item.label }}</span>
25
+ <small>{{ item.description }}</small>
26
+ </button>
27
+ </div>
28
+ </div>
29
+ </template>
30
+
31
+ <script>
32
+ import { computed, onMounted, onUnmounted, ref } from "mikuru";
33
+
34
+ const {
35
+ label = "Menu",
36
+ items = []
37
+ } = defineProps({
38
+ label: String,
39
+ items: Array
40
+ });
41
+
42
+ const emit = defineEmits(["select"]);
43
+ const rootEl = ref(null);
44
+ const isOpen = ref(false);
45
+ const normalizedItems = computed(() => {
46
+ const source = Array.isArray(items.value) ? items.value : [];
47
+ return source.map((item, index) => {
48
+ if (typeof item === "string") {
49
+ return { label: item, value: item, description: "", disabled: false };
50
+ }
51
+ return {
52
+ label: item.label || `Item ${index + 1}`,
53
+ value: item.value ?? item.label ?? index,
54
+ description: item.description || "",
55
+ disabled: Boolean(item.disabled)
56
+ };
57
+ });
58
+ });
59
+
60
+ onMounted(() => {
61
+ document.addEventListener("pointerdown", handleDocumentPointer);
62
+ });
63
+
64
+ onUnmounted(() => {
65
+ document.removeEventListener("pointerdown", handleDocumentPointer);
66
+ });
67
+
68
+ function toggle() {
69
+ isOpen.value = !isOpen.value;
70
+ }
71
+
72
+ function close() {
73
+ isOpen.value = false;
74
+ }
75
+
76
+ function selectItem(item) {
77
+ if (item.disabled) return;
78
+ emit("select", item.value);
79
+ close();
80
+ }
81
+
82
+ function handleDocumentPointer(event) {
83
+ const root = rootEl.value;
84
+ if (!root || root.contains(event.target)) return;
85
+ close();
86
+ }
87
+
88
+ function handleKeydown(event) {
89
+ if (event.key === "Escape") {
90
+ close();
91
+ }
92
+ }
93
+ </script>
94
+
95
+ <style scoped>
96
+ .mikuru-dropdown {
97
+ position: relative;
98
+ display: inline-block;
99
+ }
100
+
101
+ .dropdown-trigger {
102
+ display: inline-flex;
103
+ align-items: center;
104
+ gap: 8px;
105
+ border: 1px solid #cbd5e1;
106
+ border-radius: 8px;
107
+ padding: 9px 12px;
108
+ color: #111827;
109
+ background: #ffffff;
110
+ font: inherit;
111
+ cursor: pointer;
112
+ }
113
+
114
+ .dropdown-menu {
115
+ position: absolute;
116
+ top: calc(100% + 6px);
117
+ left: 0;
118
+ z-index: 40;
119
+ display: grid;
120
+ min-width: 220px;
121
+ overflow: hidden;
122
+ border: 1px solid #e5e7eb;
123
+ border-radius: 8px;
124
+ background: #ffffff;
125
+ box-shadow: 0 18px 48px rgb(15 23 42 / 16%);
126
+ }
127
+
128
+ .dropdown-item {
129
+ display: grid;
130
+ gap: 2px;
131
+ border: 0;
132
+ padding: 10px 12px;
133
+ color: #111827;
134
+ background: transparent;
135
+ text-align: left;
136
+ font: inherit;
137
+ cursor: pointer;
138
+ }
139
+
140
+ .dropdown-item:hover,
141
+ .dropdown-item:focus-visible {
142
+ background: #f1f5f9;
143
+ }
144
+
145
+ .dropdown-item:disabled {
146
+ color: #94a3b8;
147
+ cursor: not-allowed;
148
+ }
149
+
150
+ .dropdown-item small {
151
+ color: #64748b;
152
+ }
153
+ </style>
@@ -0,0 +1,269 @@
1
+ <template>
2
+ <figure
3
+ class="mikuru-image-viewer"
4
+ :class="{ 'is-dragging': isDragging, 'is-fullscreen': isFullscreen }"
5
+ :aria-label="label"
6
+ >
7
+ <div
8
+ class="viewer-frame"
9
+ ref="frameEl"
10
+ tabindex="0"
11
+ role="img"
12
+ :aria-label="alt"
13
+ @dblclick="toggleZoom"
14
+ @keydown="handleKeydown"
15
+ @pointerdown="startPan"
16
+ @pointermove="panImage"
17
+ @pointerup="endPan"
18
+ @pointercancel="endPan"
19
+ @pointerleave="endPan"
20
+ >
21
+ <img
22
+ ref="imageEl"
23
+ :src="src"
24
+ :alt="alt"
25
+ :style="imageStyle"
26
+ draggable="false"
27
+ />
28
+ </div>
29
+
30
+ <figcaption class="viewer-caption">
31
+ <span>{{ captionText }}</span>
32
+ <strong>{{ zoomLabel }}</strong>
33
+ </figcaption>
34
+
35
+ <div class="viewer-controls" aria-label="Image controls">
36
+ <button type="button" @click="zoomOut" aria-label="Zoom out">-</button>
37
+ <button type="button" @click="resetView" aria-label="Reset view">Reset</button>
38
+ <button type="button" @click="zoomIn" aria-label="Zoom in">+</button>
39
+ <button type="button" @click="rotateLeft" aria-label="Rotate left">Left</button>
40
+ <button type="button" @click="rotateRight" aria-label="Rotate right">Right</button>
41
+ <button type="button" @click="toggleFullscreen" :aria-label="fullscreenLabel">
42
+ <span m-if="isFullscreen">Exit</span>
43
+ <span m-else>Full</span>
44
+ </button>
45
+ </div>
46
+ </figure>
47
+ </template>
48
+
49
+ <script>
50
+ import { computed, onMounted, onUnmounted, ref } from "mikuru";
51
+
52
+ const {
53
+ src,
54
+ alt = "Mikuru image",
55
+ caption = "",
56
+ minZoom = 1,
57
+ maxZoom = 4,
58
+ zoomStep = 0.25
59
+ } = defineProps({
60
+ src: String,
61
+ alt: String,
62
+ caption: String,
63
+ minZoom: Number,
64
+ maxZoom: Number,
65
+ zoomStep: Number
66
+ });
67
+
68
+ const frameEl = ref(null);
69
+ const imageEl = ref(null);
70
+ const zoom = ref(1);
71
+ const rotation = ref(0);
72
+ const offsetX = ref(0);
73
+ const offsetY = ref(0);
74
+ const startX = ref(0);
75
+ const startY = ref(0);
76
+ const startOffsetX = ref(0);
77
+ const startOffsetY = ref(0);
78
+ const isDragging = ref(false);
79
+ const isFullscreen = ref(false);
80
+
81
+ const label = computed(() => caption.value || alt.value);
82
+ const captionText = computed(() => caption.value || alt.value);
83
+ const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
84
+ const fullscreenLabel = computed(() => isFullscreen.value ? "Exit fullscreen" : "Enter fullscreen");
85
+ const imageStyle = computed(() => ({
86
+ transform: `translate(${offsetX.value}px, ${offsetY.value}px) scale(${zoom.value}) rotate(${rotation.value}deg)`
87
+ }));
88
+
89
+ onMounted(() => {
90
+ document.addEventListener("fullscreenchange", syncFullscreen);
91
+ });
92
+
93
+ onUnmounted(() => {
94
+ document.removeEventListener("fullscreenchange", syncFullscreen);
95
+ });
96
+
97
+ function clamp(value, min, max) {
98
+ return Math.min(Math.max(value, min), max);
99
+ }
100
+
101
+ function setZoom(nextZoom) {
102
+ zoom.value = clamp(nextZoom, minZoom.value, maxZoom.value);
103
+ if (zoom.value === minZoom.value) {
104
+ offsetX.value = 0;
105
+ offsetY.value = 0;
106
+ }
107
+ }
108
+
109
+ function zoomIn() {
110
+ setZoom(zoom.value + zoomStep.value);
111
+ }
112
+
113
+ function zoomOut() {
114
+ setZoom(zoom.value - zoomStep.value);
115
+ }
116
+
117
+ function toggleZoom() {
118
+ if (zoom.value > minZoom.value) {
119
+ resetView();
120
+ return;
121
+ }
122
+ setZoom(Math.min(2, maxZoom.value));
123
+ }
124
+
125
+ function resetView() {
126
+ zoom.value = minZoom.value;
127
+ rotation.value = 0;
128
+ offsetX.value = 0;
129
+ offsetY.value = 0;
130
+ }
131
+
132
+ function rotateLeft() {
133
+ rotation.value -= 90;
134
+ }
135
+
136
+ function rotateRight() {
137
+ rotation.value += 90;
138
+ }
139
+
140
+ function startPan(event) {
141
+ if (zoom.value <= minZoom.value) return;
142
+ isDragging.value = true;
143
+ startX.value = event.clientX;
144
+ startY.value = event.clientY;
145
+ startOffsetX.value = offsetX.value;
146
+ startOffsetY.value = offsetY.value;
147
+ event.currentTarget.setPointerCapture(event.pointerId);
148
+ }
149
+
150
+ function panImage(event) {
151
+ if (!isDragging.value) return;
152
+ offsetX.value = startOffsetX.value + event.clientX - startX.value;
153
+ offsetY.value = startOffsetY.value + event.clientY - startY.value;
154
+ }
155
+
156
+ function endPan(event) {
157
+ if (!isDragging.value) return;
158
+ isDragging.value = false;
159
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
160
+ event.currentTarget.releasePointerCapture(event.pointerId);
161
+ }
162
+ }
163
+
164
+ async function toggleFullscreen() {
165
+ const frame = frameEl.value;
166
+ if (!frame) return;
167
+ if (document.fullscreenElement) {
168
+ await document.exitFullscreen();
169
+ return;
170
+ }
171
+ await frame.requestFullscreen();
172
+ }
173
+
174
+ function syncFullscreen() {
175
+ isFullscreen.value = document.fullscreenElement === frameEl.value;
176
+ }
177
+
178
+ function handleKeydown(event) {
179
+ if (event.key === "+" || event.key === "=") {
180
+ zoomIn();
181
+ event.preventDefault();
182
+ } else if (event.key === "-") {
183
+ zoomOut();
184
+ event.preventDefault();
185
+ } else if (event.key === "0") {
186
+ resetView();
187
+ event.preventDefault();
188
+ } else if (event.key === "ArrowLeft") {
189
+ rotateLeft();
190
+ event.preventDefault();
191
+ } else if (event.key === "ArrowRight") {
192
+ rotateRight();
193
+ event.preventDefault();
194
+ }
195
+ }
196
+ </script>
197
+
198
+ <style scoped>
199
+ .mikuru-image-viewer {
200
+ display: grid;
201
+ gap: 10px;
202
+ margin: 0;
203
+ color: #f8fafc;
204
+ }
205
+
206
+ .viewer-frame {
207
+ position: relative;
208
+ display: grid;
209
+ min-height: 280px;
210
+ overflow: hidden;
211
+ place-items: center;
212
+ border-radius: 8px;
213
+ background: #111827;
214
+ outline: none;
215
+ touch-action: none;
216
+ }
217
+
218
+ .viewer-frame:focus-visible {
219
+ box-shadow: 0 0 0 3px #38bdf8;
220
+ }
221
+
222
+ .viewer-frame img {
223
+ max-width: 100%;
224
+ max-height: 72vh;
225
+ user-select: none;
226
+ transition: transform 160ms ease;
227
+ transform-origin: center;
228
+ }
229
+
230
+ .is-dragging .viewer-frame img {
231
+ transition: none;
232
+ cursor: grabbing;
233
+ }
234
+
235
+ .viewer-caption,
236
+ .viewer-controls {
237
+ display: flex;
238
+ flex-wrap: wrap;
239
+ align-items: center;
240
+ gap: 8px;
241
+ }
242
+
243
+ .viewer-caption {
244
+ justify-content: space-between;
245
+ color: #334155;
246
+ font-size: 0.9rem;
247
+ }
248
+
249
+ .viewer-controls button {
250
+ min-width: 44px;
251
+ border: 0;
252
+ border-radius: 6px;
253
+ padding: 8px 10px;
254
+ color: #f8fafc;
255
+ background: #1f2937;
256
+ font: inherit;
257
+ cursor: pointer;
258
+ }
259
+
260
+ .viewer-controls button:hover,
261
+ .viewer-controls button:focus-visible {
262
+ background: #0f172a;
263
+ }
264
+
265
+ .is-fullscreen {
266
+ padding: 18px;
267
+ background: #020617;
268
+ }
269
+ </style>
@@ -0,0 +1,159 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ m-if="open"
5
+ class="mikuru-modal-backdrop"
6
+ role="presentation"
7
+ @click="handleBackdrop"
8
+ >
9
+ <section
10
+ ref="dialogEl"
11
+ class="mikuru-modal"
12
+ role="dialog"
13
+ aria-modal="true"
14
+ :aria-label="title"
15
+ tabindex="-1"
16
+ @click="stopEvent"
17
+ >
18
+ <header class="modal-header">
19
+ <h2>{{ title }}</h2>
20
+ <button type="button" @click="requestClose" aria-label="Close modal">Close</button>
21
+ </header>
22
+
23
+ <div class="modal-body">
24
+ <slot>{{ body }}</slot>
25
+ </div>
26
+
27
+ <footer m-if="footer" class="modal-footer">
28
+ <span>{{ footer }}</span>
29
+ </footer>
30
+ </section>
31
+ </div>
32
+ </Teleport>
33
+ </template>
34
+
35
+ <script>
36
+ import { onMounted, onUnmounted, ref, watch } from "mikuru";
37
+
38
+ const {
39
+ open = false,
40
+ title = "Mikuru Modal",
41
+ body = "",
42
+ footer = "",
43
+ closeOnBackdrop = true,
44
+ closeOnEscape = true
45
+ } = defineProps({
46
+ open: Boolean,
47
+ title: String,
48
+ body: String,
49
+ footer: String,
50
+ closeOnBackdrop: Boolean,
51
+ closeOnEscape: Boolean
52
+ });
53
+
54
+ const emit = defineEmits(["close"]);
55
+ const dialogEl = ref(null);
56
+
57
+ onMounted(() => {
58
+ document.addEventListener("keydown", handleDocumentKeydown);
59
+ focusDialog();
60
+ });
61
+
62
+ onUnmounted(() => {
63
+ document.removeEventListener("keydown", handleDocumentKeydown);
64
+ });
65
+
66
+ watch(open, () => {
67
+ focusDialog();
68
+ });
69
+
70
+ function focusDialog() {
71
+ requestAnimationFrame(() => {
72
+ if (open.value && dialogEl.value) {
73
+ dialogEl.value.focus();
74
+ }
75
+ });
76
+ }
77
+
78
+ function requestClose() {
79
+ emit("close");
80
+ }
81
+
82
+ function handleBackdrop() {
83
+ if (closeOnBackdrop.value) {
84
+ requestClose();
85
+ }
86
+ }
87
+
88
+ function handleDocumentKeydown(event) {
89
+ if (!open.value || !closeOnEscape.value) return;
90
+ if (event.key === "Escape") {
91
+ requestClose();
92
+ }
93
+ }
94
+
95
+ function stopEvent(event) {
96
+ event.stopPropagation();
97
+ }
98
+ </script>
99
+
100
+ <style scoped>
101
+ .mikuru-modal-backdrop {
102
+ position: fixed;
103
+ inset: 0;
104
+ z-index: 50;
105
+ display: grid;
106
+ place-items: center;
107
+ padding: 20px;
108
+ background: rgb(15 23 42 / 72%);
109
+ }
110
+
111
+ .mikuru-modal {
112
+ display: grid;
113
+ width: min(560px, 100%);
114
+ max-height: min(720px, 92vh);
115
+ overflow: hidden;
116
+ border-radius: 8px;
117
+ color: #111827;
118
+ background: #ffffff;
119
+ box-shadow: 0 24px 80px rgb(15 23 42 / 34%);
120
+ outline: none;
121
+ }
122
+
123
+ .modal-header,
124
+ .modal-footer {
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: space-between;
128
+ gap: 12px;
129
+ padding: 16px 18px;
130
+ border-bottom: 1px solid #e5e7eb;
131
+ }
132
+
133
+ .modal-footer {
134
+ border-top: 1px solid #e5e7eb;
135
+ border-bottom: 0;
136
+ color: #475569;
137
+ }
138
+
139
+ .modal-header h2 {
140
+ margin: 0;
141
+ font-size: 1.05rem;
142
+ }
143
+
144
+ .modal-header button {
145
+ border: 0;
146
+ border-radius: 6px;
147
+ padding: 8px 10px;
148
+ color: #f8fafc;
149
+ background: #111827;
150
+ font: inherit;
151
+ cursor: pointer;
152
+ }
153
+
154
+ .modal-body {
155
+ overflow: auto;
156
+ padding: 18px;
157
+ color: #334155;
158
+ }
159
+ </style>
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <div class="mikuru-progress" :class="{ indeterminate }">
3
+ <div class="progress-label">
4
+ <span>{{ label }}</span>
5
+ <strong m-if="!indeterminate">{{ percentLabel }}</strong>
6
+ </div>
7
+ <div
8
+ class="progress-track"
9
+ role="progressbar"
10
+ :aria-label="label"
11
+ aria-valuemin="0"
12
+ :aria-valuemax="max"
13
+ :aria-valuenow="indeterminate ? undefined : clampedValue"
14
+ >
15
+ <span class="progress-fill" :style="fillStyle"></span>
16
+ </div>
17
+ </div>
18
+ </template>
19
+
20
+ <script>
21
+ import { computed } from "mikuru";
22
+
23
+ const {
24
+ value = 0,
25
+ max = 100,
26
+ label = "Progress",
27
+ indeterminate = false
28
+ } = defineProps({
29
+ value: Number,
30
+ max: Number,
31
+ label: String,
32
+ indeterminate: Boolean
33
+ });
34
+
35
+ const safeMax = computed(() => max.value > 0 ? max.value : 100);
36
+ const clampedValue = computed(() => Math.min(Math.max(value.value, 0), safeMax.value));
37
+ const percent = computed(() => Math.round((clampedValue.value / safeMax.value) * 100));
38
+ const percentLabel = computed(() => `${percent.value}%`);
39
+ const fillStyle = computed(() => ({
40
+ width: indeterminate.value ? "45%" : `${percent.value}%`
41
+ }));
42
+ </script>
43
+
44
+ <style scoped>
45
+ .mikuru-progress {
46
+ display: grid;
47
+ gap: 7px;
48
+ color: #111827;
49
+ }
50
+
51
+ .progress-label {
52
+ display: flex;
53
+ justify-content: space-between;
54
+ gap: 12px;
55
+ font-size: 0.9rem;
56
+ }
57
+
58
+ .progress-track {
59
+ height: 10px;
60
+ overflow: hidden;
61
+ border-radius: 999px;
62
+ background: #e2e8f0;
63
+ }
64
+
65
+ .progress-fill {
66
+ display: block;
67
+ height: 100%;
68
+ border-radius: inherit;
69
+ background: #2563eb;
70
+ transition: width 180ms ease;
71
+ }
72
+
73
+ .indeterminate .progress-fill {
74
+ animation: mikuru-progress-slide 1.2s ease-in-out infinite;
75
+ }
76
+
77
+ @keyframes mikuru-progress-slide {
78
+ 0% {
79
+ transform: translateX(-120%);
80
+ }
81
+ 100% {
82
+ transform: translateX(240%);
83
+ }
84
+ }
85
+ </style>