@stacksjs/defaults 0.70.365 → 0.70.366

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,416 @@
1
+ <script client>
2
+ import { kanbanStore } from '~/storage/framework/defaults/views/dashboard/stores/kanban'
3
+
4
+ const kanban = kanbanStore
5
+ const openCard = derived(() => kanban.openCard())
6
+ const loadingCard = derived(() => kanban.loadingCard())
7
+ const errorCard = derived(() => kanban.errorCard())
8
+ const allLabels = derived(() => kanban.currentLabels())
9
+ const cardComments = derived(() => openCard()?.comments ?? [])
10
+ const cardLabels = derived(() => openCard()?.labels ?? [])
11
+ const cardAssignees = derived(() => openCard()?.assignees ?? [])
12
+ const titleDraft = state('')
13
+ const descDraft = state('')
14
+ const commentDraft = state('')
15
+ const editingCommentId = state<number | null>(null)
16
+ const commentEditDraft = state('')
17
+ const savingCommentId = state<number | null>(null)
18
+ const showLabelPicker = state(false)
19
+ const showAssigneePicker = state(false)
20
+ const showNewLabelComposer = state(false)
21
+ const newLabelName = state('')
22
+ const newLabelColor = state('slate')
23
+ const dueDateDraft = state('')
24
+ const pendingDelete = state<{ kind: 'comment' | 'label', id: number } | null>(null)
25
+ const deleting = state(false)
26
+ const labelColors = ['slate', 'red', 'amber', 'emerald', 'blue', 'violet', 'rose']
27
+ const { start: finishCommentEditCancel } = useTimeoutFn(cancelCommentEdit, 50, { immediate: false })
28
+ let activeCardId: number | null = null
29
+ let wasLoading = false
30
+
31
+ function syncDraftsFromCard(): void {
32
+ const card = openCard()
33
+ titleDraft.set(card?.title || '')
34
+ descDraft.set(card?.description || '')
35
+ dueDateDraft.set(card?.dueDate ? card.dueDate.slice(0, 10) : '')
36
+ commentDraft.set('')
37
+ editingCommentId.set(null)
38
+ commentEditDraft.set('')
39
+ savingCommentId.set(null)
40
+ showLabelPicker.set(false)
41
+ showAssigneePicker.set(false)
42
+ showNewLabelComposer.set(false)
43
+ newLabelName.set('')
44
+ newLabelColor.set('slate')
45
+ pendingDelete.set(null)
46
+ }
47
+
48
+ effect(() => {
49
+ const card = openCard()
50
+ const isLoading = loadingCard()
51
+ const cardId = card?.id ?? null
52
+ if (cardId !== activeCardId || (wasLoading && !isLoading)) {
53
+ activeCardId = cardId
54
+ syncDraftsFromCard()
55
+ }
56
+ wasLoading = isLoading
57
+ })
58
+
59
+ function close(): void {
60
+ if (!deleting())
61
+ kanban.closeCardDetail()
62
+ }
63
+
64
+ async function commitTitle(): Promise<void> {
65
+ const card = openCard()
66
+ if (!card)
67
+ return
68
+ const next = titleDraft().trim()
69
+ if (!next) {
70
+ titleDraft.set(card.title)
71
+ return
72
+ }
73
+ if (next !== card.title)
74
+ await kanban.updateCard({ title: next })
75
+ }
76
+
77
+ async function commitDescription(): Promise<void> {
78
+ const card = openCard()
79
+ if (!card)
80
+ return
81
+ const next = descDraft()
82
+ if (next !== (card.description || ''))
83
+ await kanban.updateCard({ description: next || null })
84
+ }
85
+
86
+ async function commitDueDate(): Promise<void> {
87
+ await kanban.updateCard({ dueDate: dueDateDraft() || null })
88
+ }
89
+
90
+ async function toggleLabel(labelId: number): Promise<void> {
91
+ const card = openCard()
92
+ if (!card)
93
+ return
94
+ const hasLabel = card.labels.some(label => label.id === labelId)
95
+ const labelIds = hasLabel
96
+ ? card.labels.filter(label => label.id !== labelId).map(label => label.id)
97
+ : [...card.labels.map(label => label.id), labelId]
98
+ await kanban.syncCardLabels(card.id, labelIds)
99
+ }
100
+
101
+ async function toggleAssignee(userId: number): Promise<void> {
102
+ const card = openCard()
103
+ if (!card)
104
+ return
105
+ const hasAssignee = card.assignees.some(assignee => assignee.userId === userId)
106
+ const userIds = hasAssignee
107
+ ? card.assignees.filter(assignee => assignee.userId !== userId).map(assignee => assignee.userId)
108
+ : [...card.assignees.map(assignee => assignee.userId), userId]
109
+ await kanban.syncCardAssignees(card.id, userIds)
110
+ }
111
+
112
+ async function submitComment(): Promise<void> {
113
+ const body = commentDraft().trim()
114
+ if (!body)
115
+ return
116
+ if (await kanban.addComment(body))
117
+ commentDraft.set('')
118
+ }
119
+
120
+ function startCommentEdit(id: number, body: string): void {
121
+ editingCommentId.set(id)
122
+ commentEditDraft.set(body)
123
+ }
124
+
125
+ function cancelCommentEdit(): void {
126
+ editingCommentId.set(null)
127
+ commentEditDraft.set('')
128
+ }
129
+
130
+ function cancelCommentEditWithoutClosing(): void {
131
+ finishCommentEditCancel()
132
+ }
133
+
134
+ async function saveCommentEdit(id: number): Promise<void> {
135
+ const body = commentEditDraft().trim()
136
+ if (!body || savingCommentId())
137
+ return
138
+ savingCommentId.set(id)
139
+ try {
140
+ if (await kanban.updateComment(id, body))
141
+ cancelCommentEdit()
142
+ }
143
+ finally {
144
+ savingCommentId.set(null)
145
+ }
146
+ }
147
+
148
+ async function submitNewLabel(): Promise<void> {
149
+ const name = newLabelName().trim()
150
+ const card = openCard()
151
+ if (!name || !card)
152
+ return
153
+ const created = await kanban.createLabel({ boardId: card.boardId, name, color: newLabelColor() })
154
+ if (!created)
155
+ return
156
+ await kanban.syncCardLabels(card.id, [...card.labels.map(label => label.id), created.id])
157
+ newLabelName.set('')
158
+ showNewLabelComposer.set(false)
159
+ }
160
+
161
+ const deleteTitle = derived(() => pendingDelete()?.kind === 'label'
162
+ ? 'Delete this label?'
163
+ : 'Delete this comment?')
164
+
165
+ const deleteDescription = derived(() => pendingDelete()?.kind === 'label'
166
+ ? 'The label will be removed from every card that currently uses it.'
167
+ : 'This action cannot be undone.')
168
+
169
+ function requestDelete(kind: 'comment' | 'label', id: number): void {
170
+ pendingDelete.set({ kind, id })
171
+ }
172
+
173
+ function closeDelete(): void {
174
+ if (!deleting())
175
+ pendingDelete.set(null)
176
+ }
177
+
178
+ async function confirmDelete(): Promise<void> {
179
+ const pending = pendingDelete()
180
+ if (!pending || deleting())
181
+ return
182
+ deleting.set(true)
183
+ try {
184
+ const deleted = pending.kind === 'comment'
185
+ ? await kanban.deleteComment(pending.id)
186
+ : await kanban.deleteLabel(pending.id)
187
+ if (deleted)
188
+ pendingDelete.set(null)
189
+ }
190
+ finally {
191
+ deleting.set(false)
192
+ }
193
+ }
194
+
195
+ function labelChipClass(color: string): string {
196
+ return ({
197
+ slate: 'bg-slate-100 text-slate-700 dark:bg-slate-800/60 dark:text-slate-200',
198
+ amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200',
199
+ blue: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200',
200
+ emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200',
201
+ red: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200',
202
+ violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-200',
203
+ rose: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-200',
204
+ })[color] || 'bg-slate-100 text-slate-700 dark:bg-slate-800/60 dark:text-slate-200'
205
+ }
206
+
207
+ function initialsOf(name: string | null, email: string | null): string {
208
+ const source = (name || email || '?').trim()
209
+ const parts = source.split(/\s+/).filter(Boolean)
210
+ if (parts.length === 0)
211
+ return '?'
212
+ if (parts.length === 1)
213
+ return parts[0].slice(0, 2).toUpperCase()
214
+ return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
215
+ }
216
+
217
+ function formatTimestamp(iso: string | null): string {
218
+ if (!iso)
219
+ return ''
220
+ const date = new Date(iso)
221
+ if (Number.isNaN(date.getTime()))
222
+ return ''
223
+ return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
224
+ }
225
+ </script>
226
+
227
+ <Modal
228
+ :isOpen="Boolean(openCard())"
229
+ title="Card details"
230
+ description="Edit card content, ownership, labels, and activity."
231
+ size="xl"
232
+ :closable="!deleting && editingCommentId === null"
233
+ @close="close"
234
+ >
235
+ <div :if="openCard()" class="grid gap-6">
236
+ <label class="grid gap-2">
237
+ <span class="font-medium text-zinc-500 text-xs tracking-wide uppercase">Title</span>
238
+ <input
239
+ x-model="titleDraft"
240
+ name="card_title"
241
+ required
242
+ autofocus
243
+ @blur="commitTitle()"
244
+ @keydown.enter.prevent="$event.currentTarget.blur()"
245
+ class="px-3 py-2 font-semibold text-lg text-zinc-900 dark:text-zinc-100 bg-white dark:bg-zinc-950 border border-zinc-200 rounded-md dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600"
246
+ />
247
+ </label>
248
+
249
+ <div :if="loadingCard()" role="status" class="flex gap-2 items-center text-zinc-500 text-sm">
250
+ <span class="h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
251
+ Refreshing card details
252
+ </div>
253
+
254
+ <div :if="errorCard()" role="alert" class="px-3 py-2 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-md dark:border-red-900/40">
255
+ {{ errorCard() }}
256
+ </div>
257
+
258
+ <div class="grid gap-6 grid-cols-1 md:grid-cols-3">
259
+ <div class="md:col-span-2 space-y-6">
260
+ <section>
261
+ <h4 class="mb-2 font-medium text-zinc-500 text-xs tracking-wide uppercase">Description</h4>
262
+ <textarea
263
+ x-model="descDraft"
264
+ name="card_description"
265
+ @blur="commitDescription()"
266
+ rows="4"
267
+ placeholder="Add a more detailed description"
268
+ class="px-3 py-2 w-full text-zinc-800 text-sm dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-y"
269
+ ></textarea>
270
+ </section>
271
+
272
+ <section>
273
+ <h4 class="mb-2 font-medium text-zinc-500 text-xs tracking-wide uppercase">Activity</h4>
274
+ <div class="mb-4">
275
+ <textarea
276
+ x-model="commentDraft"
277
+ name="card_comment"
278
+ @keydown.meta.enter.prevent="submitComment()"
279
+ @keydown.ctrl.enter.prevent="submitComment()"
280
+ rows="2"
281
+ placeholder="Add a comment, then press Command+Enter to send"
282
+ class="px-3 py-2 w-full text-zinc-800 text-sm dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-none"
283
+ ></textarea>
284
+ <div class="flex justify-end mt-2">
285
+ <Button size="sm" :disabled="!commentDraft().trim()" @click="submitComment()">Comment</Button>
286
+ </div>
287
+ </div>
288
+
289
+ <div :if="openCard().comments.length === 0 && !loadingCard()" class="italic text-zinc-400 text-xs">No comments yet.</div>
290
+ <ul class="space-y-3">
291
+ <li :for="comment in cardComments" class="flex gap-3">
292
+ <span class="inline-flex flex-shrink-0 items-center justify-center h-7 w-7 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(comment.authorName, comment.authorEmail)"></span>
293
+ <div class="flex-1 min-w-0">
294
+ <div class="flex flex-wrap gap-2 items-baseline">
295
+ <span class="font-medium text-zinc-700 text-xs dark:text-zinc-200" :text="comment.authorName || comment.authorEmail || 'Unknown'"></span>
296
+ <span class="text-[10px] text-zinc-400" :text="formatTimestamp(comment.createdAt)"></span>
297
+ <template :if="editingCommentId() !== comment.id">
298
+ <Button variant="ghost" size="xs" @click="startCommentEdit(comment.id, comment.body)">Edit</Button>
299
+ <Button variant="ghost" size="xs" @click="requestDelete('comment', comment.id)"><span class="text-red-500">Delete</span></Button>
300
+ </template>
301
+ </div>
302
+ <p :if="editingCommentId() !== comment.id" class="mt-0.5 text-zinc-700 text-xs whitespace-pre-wrap dark:text-zinc-300" :text="comment.body"></p>
303
+ <div :if="editingCommentId() === comment.id" class="mt-2">
304
+ <textarea
305
+ x-model="commentEditDraft"
306
+ @keydown.escape.stop.prevent="cancelCommentEditWithoutClosing()"
307
+ @keydown.meta.enter.prevent="saveCommentEdit(comment.id)"
308
+ @keydown.ctrl.enter.prevent="saveCommentEdit(comment.id)"
309
+ rows="3"
310
+ maxlength="10000"
311
+ :aria-label="'Edit comment by ' + (comment.authorName || comment.authorEmail || 'Unknown')"
312
+ class="px-3 py-2 w-full text-zinc-800 text-sm dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-y"
313
+ ></textarea>
314
+ <div class="flex gap-2 justify-end mt-2">
315
+ <Button variant="secondary" size="xs" :disabled="savingCommentId() === comment.id" @click="cancelCommentEdit()">Cancel</Button>
316
+ <Button size="xs" :loading="savingCommentId() === comment.id" :disabled="!commentEditDraft().trim()" @click="saveCommentEdit(comment.id)">Save comment</Button>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </li>
321
+ </ul>
322
+ </section>
323
+ </div>
324
+
325
+ <aside class="space-y-5">
326
+ <section>
327
+ <div class="flex items-center justify-between mb-2">
328
+ <h4 class="font-medium text-zinc-500 text-xs tracking-wide uppercase">Labels</h4>
329
+ <Button variant="ghost" size="xs" interaction="toggle" :pressed="showLabelPicker()" @click="showLabelPicker.set(!showLabelPicker())">
330
+ <span :if="!showLabelPicker()">Edit</span>
331
+ <span :if="showLabelPicker()">Done</span>
332
+ </Button>
333
+ </div>
334
+
335
+ <div :if="!showLabelPicker()" class="flex flex-wrap gap-1">
336
+ <span :for="label in cardLabels" :class="`px-2 py-0.5 text-xs font-medium rounded ${labelChipClass(label.color)}`" :text="label.name"></span>
337
+ <span :if="openCard().labels.length === 0" class="italic text-zinc-400 text-xs">None</span>
338
+ </div>
339
+
340
+ <div :if="showLabelPicker()" class="space-y-2">
341
+ <div :for="label in allLabels" :class="`flex items-center gap-1 px-1 py-0.5 w-full rounded ${labelChipClass(label.color)}`">
342
+ <button type="button" class="flex flex-1 items-center justify-between px-1 py-0.5 min-w-0 text-left text-xs rounded hover:opacity-90" :aria-pressed="String(openCard().labels.some(cardLabel => cardLabel.id === label.id))" @click="toggleLabel(label.id)">
343
+ <span class="truncate" :text="label.name"></span>
344
+ <span :if="openCard().labels.some(cardLabel => cardLabel.id === label.id)" class="h-3 w-3 i-hugeicons-tick-02"></span>
345
+ </button>
346
+ <Button variant="ghost" size="xs" iconOnly :ariaLabel="'Delete label ' + label.name" @click="requestDelete('label', label.id)">
347
+ <span class="block h-3 w-3 text-red-500 i-hugeicons-cancel-01"></span>
348
+ </Button>
349
+ </div>
350
+
351
+ <div :if="showNewLabelComposer()" class="pt-2 space-y-2 border-t border-zinc-200 dark:border-zinc-800">
352
+ <input x-model="newLabelName" name="label_name" @keydown.enter.prevent="submitNewLabel()" placeholder="Label name" class="px-2 py-1 w-full text-xs bg-white dark:bg-zinc-950 border border-zinc-300 rounded dark:border-zinc-700 focus:border-violet-500 focus:outline-none" />
353
+ <div class="flex gap-1 items-center" role="group" aria-label="Label color">
354
+ <button :for="color in labelColors" type="button" @click="newLabelColor.set(color)" :class="`w-5 h-5 rounded ${labelChipClass(color)} ${newLabelColor() === color ? 'ring-2 ring-offset-1 ring-violet-500 dark:ring-offset-zinc-900' : ''}`" :aria-label="'Use ' + color + ' label color'" :aria-pressed="String(newLabelColor() === color)"></button>
355
+ </div>
356
+ <div class="flex gap-1">
357
+ <Button size="xs" @click="submitNewLabel()">Create</Button>
358
+ <Button variant="ghost" size="xs" @click="showNewLabelComposer.set(false)">Cancel</Button>
359
+ </div>
360
+ </div>
361
+ <Button :if="!showNewLabelComposer()" variant="outline" size="sm" fullWidth @click="showNewLabelComposer.set(true)">+ New label</Button>
362
+ </div>
363
+ </section>
364
+
365
+ <section>
366
+ <div class="flex items-center justify-between mb-2">
367
+ <h4 class="font-medium text-zinc-500 text-xs tracking-wide uppercase">Assignees</h4>
368
+ <Button variant="ghost" size="xs" interaction="toggle" :pressed="showAssigneePicker()" @click="showAssigneePicker.set(!showAssigneePicker())">
369
+ <span :if="!showAssigneePicker()">Edit</span>
370
+ <span :if="showAssigneePicker()">Done</span>
371
+ </Button>
372
+ </div>
373
+
374
+ <div :if="!showAssigneePicker()" class="flex flex-wrap gap-2">
375
+ <div :for="assignee in cardAssignees" class="flex gap-1.5 items-center text-zinc-700 text-xs dark:text-zinc-200">
376
+ <span class="inline-flex items-center justify-center h-6 w-6 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(assignee.name, assignee.email)"></span>
377
+ <span :text="assignee.name || assignee.email || 'Unknown'"></span>
378
+ </div>
379
+ <span :if="openCard().assignees.length === 0" class="italic text-zinc-400 text-xs">Unassigned</span>
380
+ </div>
381
+
382
+ <div :if="showAssigneePicker()" class="overflow-y-auto space-y-1 max-h-48">
383
+ <p :if="kanban.users().length === 0" class="italic text-zinc-400 text-xs">No users to assign.</p>
384
+ <button :for="user in kanban.users" type="button" :aria-pressed="String(openCard().assignees.some(assignee => assignee.userId === user.id))" @click="toggleAssignee(user.id)" class="flex gap-2 items-center px-2 py-1.5 w-full text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded">
385
+ <span class="inline-flex flex-shrink-0 items-center justify-center h-6 w-6 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(user.name, user.email)"></span>
386
+ <span class="flex-1 text-zinc-700 truncate dark:text-zinc-200" :text="user.name || user.email"></span>
387
+ <span :if="openCard().assignees.some(assignee => assignee.userId === user.id)" class="h-3.5 w-3.5 text-violet-500 i-hugeicons-tick-02"></span>
388
+ </button>
389
+ </div>
390
+ </section>
391
+
392
+ <section>
393
+ <h4 class="mb-2 font-medium text-zinc-500 text-xs tracking-wide uppercase">Due date</h4>
394
+ <input type="date" x-model="dueDateDraft" name="due_date" @change="commitDueDate()" class="px-2 py-1 w-full text-zinc-800 text-xs dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none" />
395
+ </section>
396
+
397
+ <section class="pt-3 border-t border-zinc-200 dark:border-zinc-800">
398
+ <p class="text-[10px] text-zinc-400">Created <span :text="formatTimestamp(openCard().createdAt)"></span></p>
399
+ <p :if="openCard().updatedAt && openCard().updatedAt !== openCard().createdAt" class="text-[10px] text-zinc-400">Updated <span :text="formatTimestamp(openCard().updatedAt)"></span></p>
400
+ </section>
401
+ </aside>
402
+ </div>
403
+ </div>
404
+
405
+ </Modal>
406
+
407
+ <ConfirmDialog
408
+ :isOpen="Boolean(pendingDelete())"
409
+ :title="deleteTitle()"
410
+ :description="deleteDescription()"
411
+ :busy="deleting()"
412
+ :error="errorCard() || ''"
413
+ confirmLabel="Delete"
414
+ @close="closeDelete"
415
+ @confirm="confirmDelete"
416
+ />
@@ -1,5 +1,3 @@
1
- @import('../UI/Button')
2
-
3
1
  <script client>
4
2
  const liveId = useReactiveProp('id', '')
5
3
  const liveShow = useReactiveProp('show', true)
@@ -9,49 +7,28 @@ const generatedId = useId('modal')
9
7
 
10
8
  const modalId = derived(() => liveId() || generatedId)
11
9
 
12
- function closeModal(reason: 'backdrop' | 'button'): void {
10
+ function closeModal(reason: 'backdrop' | 'button' | 'escape'): void {
13
11
  liveShow.set(false)
14
12
  emit('update:show', false)
15
13
  emit('close', reason)
16
14
  }
17
15
  </script>
18
16
 
19
- <div
20
- :if="liveShow()"
21
- :data-modal="modalId()"
22
- class="flex fixed inset-0 z-50 items-end justify-center sm:items-center pb-6 px-4 sm:p-0 dashboard-modal-layer"
23
- >
24
- <button
25
- type="button"
26
- class="fixed inset-0 bg-gray-500/75 transition-opacity"
27
- aria-label="Close dialog"
28
- @click="closeModal('backdrop')"
29
- ></button>
30
-
31
- <div
32
- class="overflow-y-auto relative pb-4 pt-5 px-4 sm:p-6 max-h-[32rem] max-w-md w-full md:max-w-xl bg-white dark:bg-neutral-700 rounded-lg shadow-xl transition-all"
33
- role="dialog"
34
- aria-modal="true"
35
- :aria-label="liveAriaLabel()"
17
+ <div :data-modal="modalId()">
18
+ <Modal
19
+ :isOpen="liveShow()"
20
+ :ariaLabel="liveAriaLabel()"
21
+ size="md"
22
+ @close="closeModal"
36
23
  >
37
- <Button
38
- type="button"
39
- variant="ghost"
40
- size="sm"
41
- iconOnly
42
- class="absolute right-4 top-4"
43
- ariaLabel="Close dialog"
44
- @click="closeModal('button')"
45
- >
46
- <span aria-hidden="true" class="h-6 w-6 i-hugeicons-cancel-01"></span>
47
- </Button>
48
-
49
24
  <div class="flex flex-col">
50
25
  <slot name="body" />
51
26
  </div>
52
27
 
53
- <div class="sm:flex sm:flex-row-reverse mt-5 space-y-4 sm:mt-4 md:space-y-0">
54
- <slot name="actions" />
55
- </div>
56
- </div>
28
+ <template #footer>
29
+ <div class="flex flex-row-reverse flex-wrap gap-3 items-center justify-end w-full">
30
+ <slot name="actions" />
31
+ </div>
32
+ </template>
33
+ </Modal>
57
34
  </div>
@@ -1,5 +1,3 @@
1
- @import('../../UI/Button')
2
-
3
1
  <script client>
4
2
  type AlertType = 'info' | 'warning' | 'danger' | 'success' | 'error'
5
3
 
@@ -10,7 +8,6 @@ const liveConfirmationText = useReactiveProp('confirmationText', 'Confirm')
10
8
  const liveAbortText = useReactiveProp('abortText', 'Cancel')
11
9
  const liveShow = useReactiveProp('show', true)
12
10
  const emit = defineEmits()
13
- const titleId = useId('alert-title')
14
11
 
15
12
  const normalizedType = derived(() => liveType() === 'danger' ? 'error' : liveType())
16
13
  const iconClass = derived(() => ({
@@ -32,47 +29,25 @@ function dismiss(event: 'close' | 'cancel' | 'confirm'): void {
32
29
  }
33
30
  </script>
34
31
 
35
- <div :if="liveShow()" class="flex fixed inset-0 z-50 items-end justify-center sm:items-center pb-6 px-4 sm:p-0 dashboard-modal-layer">
36
- <button
37
- type="button"
38
- class="fixed inset-0 bg-gray-500/75 transition-opacity"
39
- aria-label="Close alert"
40
- @click="dismiss('close')"
41
- ></button>
42
-
43
- <div
44
- class="overflow-y-auto relative pb-4 pt-5 px-4 sm:p-6 max-h-[32rem] max-w-md w-full md:max-w-xl bg-white dark:bg-neutral-700 rounded-lg shadow-xl transition-all"
45
- role="alertdialog"
46
- aria-modal="true"
47
- :aria-labelledby="titleId"
48
- >
49
- <Button
50
- type="button"
51
- variant="ghost"
52
- size="sm"
53
- iconOnly
54
- class="absolute right-4 top-4"
55
- ariaLabel="Close alert"
56
- @click="dismiss('close')"
32
+ <Modal
33
+ :isOpen="liveShow()"
34
+ :title="liveTitle()"
35
+ :description="liveDescription()"
36
+ role="alertdialog"
37
+ size="md"
38
+ @close="dismiss('close')"
39
+ >
40
+ <template #header-leading>
41
+ <div
42
+ class="flex items-center justify-center shrink-0 h-10 w-10 rounded-full"
43
+ :class="iconWrapperClass()"
57
44
  >
58
- <span aria-hidden="true" class="h-6 w-6 i-hugeicons-cancel-01"></span>
59
- </Button>
60
-
61
- <div class="sm:flex sm:items-start">
62
- <div
63
- class="flex items-center justify-center shrink-0 mx-auto sm:mx-0 h-12 w-12 sm:h-10 sm:w-10 rounded-full"
64
- :class="iconWrapperClass()"
65
- >
66
- <span aria-hidden="true" class="h-6 w-6" :class="iconClass()"></span>
67
- </div>
68
-
69
- <div class="mt-3 sm:ml-4 sm:mt-0 text-center sm:text-left">
70
- <h3 :id="titleId" class="font-medium leading-6 text-gray-900 text-lg dark:text-neutral-100" :text="liveTitle()"></h3>
71
- <p :if="liveDescription()" class="mt-2 text-gray-500 text-sm dark:text-neutral-400" :text="liveDescription()"></p>
72
- </div>
45
+ <span aria-hidden="true" class="h-6 w-6" :class="iconClass()"></span>
73
46
  </div>
47
+ </template>
74
48
 
75
- <div class="sm:flex sm:flex-row-reverse mt-5 space-y-4 sm:mt-4 md:space-y-0">
49
+ <template #footer>
50
+ <div class="flex flex-row-reverse flex-wrap gap-3 items-center justify-end w-full">
76
51
  <Button
77
52
  :if="normalizedType() === 'info'"
78
53
  type="button"
@@ -113,11 +88,10 @@ function dismiss(event: 'close' | 'cancel' | 'confirm'): void {
113
88
  :if="liveAbortText()"
114
89
  type="button"
115
90
  variant="secondary"
116
- class="mr-4"
117
91
  @click="dismiss('cancel')"
118
92
  >
119
93
  {{ liveAbortText() }}
120
94
  </Button>
121
95
  </div>
122
- </div>
123
- </div>
96
+ </template>
97
+ </Modal>
@@ -3,12 +3,13 @@ interface ModalProps {
3
3
  isOpen?: boolean
4
4
  title?: string
5
5
  description?: string
6
+ ariaLabel?: string
6
7
  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
7
8
  closable?: boolean
8
9
  role?: 'dialog' | 'alertdialog'
9
10
  }
10
11
 
11
- const { isOpen = false, title = '', description = '', size = 'md', closable = true, role = 'dialog' } = defineProps<ModalProps>()
12
+ const { isOpen = false, title = '', description = '', ariaLabel = '', size = 'md', closable = true, role = 'dialog' } = defineProps<ModalProps>()
12
13
 
13
14
  const sizeClasses = {
14
15
  sm: 'max-w-sm',
@@ -23,6 +24,7 @@ const open = useReactiveProp('isOpen', false)
23
24
  const liveClosable = useReactiveProp('closable', true)
24
25
  const liveTitle = useReactiveProp('title', '')
25
26
  const liveDescription = useReactiveProp('description', '')
27
+ const liveAriaLabel = useReactiveProp('ariaLabel', '')
26
28
  const liveRole = useReactiveProp('role', 'dialog')
27
29
  const dialog = useRef<HTMLDialogElement>('dashboardModalDialog')
28
30
  const activeElement = useActiveElement()
@@ -30,19 +32,21 @@ const scrollLocked = useScrollLock()
30
32
  const emit = defineEmits()
31
33
  let restoreFocus: HTMLElement | null = null
32
34
 
33
- function closeModal(): void {
35
+ type ModalCloseReason = 'backdrop' | 'button' | 'escape'
36
+
37
+ function closeModal(reason: ModalCloseReason): void {
34
38
  if (!liveClosable()) return
35
- emit('close')
39
+ emit('close', reason)
36
40
  }
37
41
 
38
42
  function handleCancel(event: Event): void {
39
43
  event.preventDefault()
40
- closeModal()
44
+ closeModal('escape')
41
45
  }
42
46
 
43
47
  function handleBackdropClick(event: MouseEvent): void {
44
48
  if (event.target === event.currentTarget)
45
- closeModal()
49
+ closeModal('backdrop')
46
50
  }
47
51
 
48
52
  effect(() => {
@@ -80,7 +84,7 @@ effect(() => {
80
84
  ref="dashboardModalDialog"
81
85
  :role="liveRole()"
82
86
  aria-modal="true"
83
- :aria-label="liveTitle() || 'Dialog'"
87
+ :aria-label="liveAriaLabel() || liveTitle() || 'Dialog'"
84
88
  class="fixed inset-0 overflow-y-auto z-[55] m-0 p-0 h-dvh max-h-none max-w-none w-screen bg-transparent dashboard-modal-dialog dashboard-modal-layer"
85
89
  @cancel="handleCancel"
86
90
  >
@@ -111,7 +115,7 @@ effect(() => {
111
115
  size="sm"
112
116
  iconOnly
113
117
  ariaLabel="Close modal"
114
- @click="closeModal"
118
+ @click="closeModal('button')"
115
119
  >
116
120
  <div class="h-5 w-5 i-hugeicons-cancel-01"></div>
117
121
  </Button>