@m3ui-vue/m3ui-vue 0.1.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.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/components/MAlert.vue.d.ts +27 -0
- package/dist/components/MAppBar.vue.d.ts +24 -0
- package/dist/components/MAvatar.vue.d.ts +9 -0
- package/dist/components/MBadge.vue.d.ts +22 -0
- package/dist/components/MBottomSheet.vue.d.ts +26 -0
- package/dist/components/MBreadcrumbs.vue.d.ts +19 -0
- package/dist/components/MButton.vue.d.ts +32 -0
- package/dist/components/MCalendar.vue.d.ts +23 -0
- package/dist/components/MCard.vue.d.ts +28 -0
- package/dist/components/MChart.vue.d.ts +13 -0
- package/dist/components/MCheckbox.vue.d.ts +26 -0
- package/dist/components/MChip.vue.d.ts +33 -0
- package/dist/components/MCodeEditor.vue.d.ts +35 -0
- package/dist/components/MColorPicker.vue.d.ts +18 -0
- package/dist/components/MCommandPalette.vue.d.ts +29 -0
- package/dist/components/MConfirmDialog.vue.d.ts +23 -0
- package/dist/components/MContainer.vue.d.ts +24 -0
- package/dist/components/MContextMenu.vue.d.ts +35 -0
- package/dist/components/MDataTable.vue.d.ts +83 -0
- package/dist/components/MDatePicker.vue.d.ts +21 -0
- package/dist/components/MDateRangePicker.vue.d.ts +24 -0
- package/dist/components/MDialog.vue.d.ts +30 -0
- package/dist/components/MDivider.vue.d.ts +11 -0
- package/dist/components/MDragDropList.vue.d.ts +40 -0
- package/dist/components/MEmptyState.vue.d.ts +21 -0
- package/dist/components/MExpansionPanel.vue.d.ts +28 -0
- package/dist/components/MFab.vue.d.ts +28 -0
- package/dist/components/MFileUpload.vue.d.ts +25 -0
- package/dist/components/MGrid.vue.d.ts +26 -0
- package/dist/components/MHotkeys.vue.d.ts +16 -0
- package/dist/components/MIcon.vue.d.ts +9 -0
- package/dist/components/MIconButton.vue.d.ts +14 -0
- package/dist/components/MInfiniteScroll.vue.d.ts +34 -0
- package/dist/components/MJsonEditor.vue.d.ts +17 -0
- package/dist/components/MJsonViewer.vue.d.ts +14 -0
- package/dist/components/MKanban.vue.d.ts +53 -0
- package/dist/components/MLoadingOverlay.vue.d.ts +28 -0
- package/dist/components/MMarkdown.vue.d.ts +11 -0
- package/dist/components/MMasonry.vue.d.ts +23 -0
- package/dist/components/MMenu.vue.d.ts +27 -0
- package/dist/components/MMenuItem.vue.d.ts +16 -0
- package/dist/components/MMultiSelect.vue.d.ts +34 -0
- package/dist/components/MNavigationBar.vue.d.ts +18 -0
- package/dist/components/MNavigationDrawer.vue.d.ts +41 -0
- package/dist/components/MNavigationRail.vue.d.ts +32 -0
- package/dist/components/MPagination.vue.d.ts +12 -0
- package/dist/components/MProgressBar.vue.d.ts +13 -0
- package/dist/components/MRadio.vue.d.ts +17 -0
- package/dist/components/MRadioGroup.vue.d.ts +24 -0
- package/dist/components/MRating.vue.d.ts +23 -0
- package/dist/components/MResult.vue.d.ts +20 -0
- package/dist/components/MRichTextEditor.vue.d.ts +17 -0
- package/dist/components/MScheduler.vue.d.ts +35 -0
- package/dist/components/MSegmentedButton.vue.d.ts +24 -0
- package/dist/components/MSelect.vue.d.ts +29 -0
- package/dist/components/MSideSheet.vue.d.ts +28 -0
- package/dist/components/MSkeleton.vue.d.ts +14 -0
- package/dist/components/MSlider.vue.d.ts +24 -0
- package/dist/components/MSnackbar.vue.d.ts +3 -0
- package/dist/components/MSpinner.vue.d.ts +10 -0
- package/dist/components/MSplitter.vue.d.ts +26 -0
- package/dist/components/MSpotlightSearch.vue.d.ts +34 -0
- package/dist/components/MStack.vue.d.ts +30 -0
- package/dist/components/MStatCard.vue.d.ts +24 -0
- package/dist/components/MStepper.vue.d.ts +33 -0
- package/dist/components/MSwitch.vue.d.ts +14 -0
- package/dist/components/MTable.vue.d.ts +73 -0
- package/dist/components/MTabs.vue.d.ts +20 -0
- package/dist/components/MTerminal.vue.d.ts +25 -0
- package/dist/components/MTextField.vue.d.ts +41 -0
- package/dist/components/MTimePicker.vue.d.ts +20 -0
- package/dist/components/MTimeline.vue.d.ts +31 -0
- package/dist/components/MTooltip.vue.d.ts +21 -0
- package/dist/components/MTopAppBar.vue.d.ts +29 -0
- package/dist/components/MTour.vue.d.ts +19 -0
- package/dist/components/MTransferList.vue.d.ts +23 -0
- package/dist/components/MTree.vue.d.ts +68 -0
- package/dist/components/MTreeTable.vue.d.ts +57 -0
- package/dist/components/MVirtualTable.vue.d.ts +40 -0
- package/dist/components/_MContextMenuPanel.vue.d.ts +13 -0
- package/dist/components/_MTreeNode.vue.d.ts +26 -0
- package/dist/composables/useColorPalette.d.ts +11 -0
- package/dist/composables/useFieldBg.d.ts +13 -0
- package/dist/composables/useTheme.d.ts +5 -0
- package/dist/composables/useToast.d.ts +59 -0
- package/dist/index.d.ts +112 -0
- package/dist/m3ui.css +2 -0
- package/dist/m3ui.js +7432 -0
- package/dist/m3ui.js.map +1 -0
- package/dist/plugin.d.ts +9 -0
- package/dist/styles/palettes.css +1253 -0
- package/dist/styles/theme.css +249 -0
- package/package.json +166 -0
- package/src/components/MAlert.vue +69 -0
- package/src/components/MAppBar.vue +40 -0
- package/src/components/MAvatar.vue +21 -0
- package/src/components/MBadge.vue +46 -0
- package/src/components/MBottomSheet.vue +113 -0
- package/src/components/MBreadcrumbs.vue +52 -0
- package/src/components/MButton.vue +111 -0
- package/src/components/MCalendar.vue +173 -0
- package/src/components/MCard.vue +56 -0
- package/src/components/MChart.vue +158 -0
- package/src/components/MCheckbox.vue +48 -0
- package/src/components/MChip.vue +87 -0
- package/src/components/MCodeEditor.vue +179 -0
- package/src/components/MColorPicker.vue +305 -0
- package/src/components/MCommandPalette.vue +213 -0
- package/src/components/MConfirmDialog.vue +43 -0
- package/src/components/MContainer.vue +36 -0
- package/src/components/MContextMenu.vue +66 -0
- package/src/components/MDataTable.vue +376 -0
- package/src/components/MDatePicker.vue +253 -0
- package/src/components/MDateRangePicker.vue +265 -0
- package/src/components/MDialog.vue +90 -0
- package/src/components/MDivider.vue +26 -0
- package/src/components/MDragDropList.vue +111 -0
- package/src/components/MEmptyState.vue +40 -0
- package/src/components/MExpansionPanel.vue +112 -0
- package/src/components/MFab.vue +220 -0
- package/src/components/MFileUpload.vue +206 -0
- package/src/components/MGrid.vue +99 -0
- package/src/components/MHotkeys.vue +122 -0
- package/src/components/MIcon.vue +9 -0
- package/src/components/MIconButton.vue +49 -0
- package/src/components/MInfiniteScroll.vue +68 -0
- package/src/components/MJsonEditor.vue +118 -0
- package/src/components/MJsonViewer.vue +106 -0
- package/src/components/MKanban.vue +147 -0
- package/src/components/MLoadingOverlay.vue +52 -0
- package/src/components/MMarkdown.vue +123 -0
- package/src/components/MMasonry.vue +87 -0
- package/src/components/MMenu.vue +113 -0
- package/src/components/MMenuItem.vue +15 -0
- package/src/components/MMultiSelect.vue +306 -0
- package/src/components/MNavigationBar.vue +62 -0
- package/src/components/MNavigationDrawer.vue +157 -0
- package/src/components/MNavigationRail.vue +80 -0
- package/src/components/MPagination.vue +37 -0
- package/src/components/MProgressBar.vue +200 -0
- package/src/components/MRadio.vue +89 -0
- package/src/components/MRadioGroup.vue +41 -0
- package/src/components/MRating.vue +108 -0
- package/src/components/MResult.vue +62 -0
- package/src/components/MRichTextEditor.vue +199 -0
- package/src/components/MScheduler.vue +225 -0
- package/src/components/MSegmentedButton.vue +75 -0
- package/src/components/MSelect.vue +259 -0
- package/src/components/MSideSheet.vue +112 -0
- package/src/components/MSkeleton.vue +60 -0
- package/src/components/MSlider.vue +188 -0
- package/src/components/MSnackbar.vue +244 -0
- package/src/components/MSpinner.vue +122 -0
- package/src/components/MSplitter.vue +97 -0
- package/src/components/MSpotlightSearch.vue +244 -0
- package/src/components/MStack.vue +67 -0
- package/src/components/MStatCard.vue +56 -0
- package/src/components/MStepper.vue +161 -0
- package/src/components/MSwitch.vue +63 -0
- package/src/components/MTable.vue +404 -0
- package/src/components/MTabs.vue +97 -0
- package/src/components/MTerminal.vue +146 -0
- package/src/components/MTextField.vue +180 -0
- package/src/components/MTimePicker.vue +227 -0
- package/src/components/MTimeline.vue +117 -0
- package/src/components/MTooltip.vue +82 -0
- package/src/components/MTopAppBar.vue +62 -0
- package/src/components/MTour.vue +226 -0
- package/src/components/MTransferList.vue +181 -0
- package/src/components/MTree.vue +164 -0
- package/src/components/MTreeTable.vue +159 -0
- package/src/components/MVirtualTable.vue +155 -0
- package/src/components/_MContextMenuPanel.vue +129 -0
- package/src/components/_MTreeNode.vue +171 -0
- package/src/composables/useColorPalette.ts +60 -0
- package/src/composables/useFieldBg.ts +91 -0
- package/src/composables/useTheme.ts +55 -0
- package/src/composables/useToast.ts +51 -0
- package/src/env.d.ts +1 -0
- package/src/index.ts +119 -0
- package/src/plugin.ts +18 -0
- package/src/styles/palettes.css +1253 -0
- package/src/styles/theme.css +249 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { watch } from 'vue'
|
|
3
|
+
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
5
|
+
import Underline from '@tiptap/extension-underline'
|
|
6
|
+
import TextAlign from '@tiptap/extension-text-align'
|
|
7
|
+
import Link from '@tiptap/extension-link'
|
|
8
|
+
import Image from '@tiptap/extension-image'
|
|
9
|
+
import Highlight from '@tiptap/extension-highlight'
|
|
10
|
+
import Placeholder from '@tiptap/extension-placeholder'
|
|
11
|
+
import { TextStyle } from '@tiptap/extension-text-style'
|
|
12
|
+
import Color from '@tiptap/extension-color'
|
|
13
|
+
import MIcon from './MIcon.vue'
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(
|
|
16
|
+
defineProps<{
|
|
17
|
+
modelValue: string
|
|
18
|
+
placeholder?: string
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
minHeight?: string
|
|
21
|
+
}>(),
|
|
22
|
+
{ placeholder: 'Escribe aquí...', disabled: false, minHeight: '200px' },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
|
26
|
+
|
|
27
|
+
const editor = useEditor({
|
|
28
|
+
content: props.modelValue,
|
|
29
|
+
editable: !props.disabled,
|
|
30
|
+
extensions: [
|
|
31
|
+
StarterKit,
|
|
32
|
+
Underline,
|
|
33
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
34
|
+
Link.configure({ openOnClick: false }),
|
|
35
|
+
Image,
|
|
36
|
+
Highlight.configure({ multicolor: true }),
|
|
37
|
+
Placeholder.configure({ placeholder: props.placeholder }),
|
|
38
|
+
TextStyle,
|
|
39
|
+
Color,
|
|
40
|
+
],
|
|
41
|
+
onUpdate: ({ editor: e }) => emit('update:modelValue', e.getHTML()),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
watch(() => props.modelValue, (val) => {
|
|
45
|
+
if (editor.value && editor.value.getHTML() !== val) editor.value.commands.setContent(val)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
watch(() => props.disabled, (v) => editor.value?.setEditable(!v))
|
|
49
|
+
|
|
50
|
+
interface ToolBtn {
|
|
51
|
+
icon: string
|
|
52
|
+
label: string
|
|
53
|
+
action: () => void
|
|
54
|
+
active?: () => boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const toolGroups: ToolBtn[][] = [
|
|
58
|
+
[
|
|
59
|
+
{ icon: 'format_bold', label: 'Negrita', action: () => editor.value?.chain().focus().toggleBold().run(), active: () => !!editor.value?.isActive('bold') },
|
|
60
|
+
{ icon: 'format_italic', label: 'Cursiva', action: () => editor.value?.chain().focus().toggleItalic().run(), active: () => !!editor.value?.isActive('italic') },
|
|
61
|
+
{ icon: 'format_underlined', label: 'Subrayado', action: () => editor.value?.chain().focus().toggleUnderline().run(), active: () => !!editor.value?.isActive('underline') },
|
|
62
|
+
{ icon: 'format_strikethrough', label: 'Tachado', action: () => editor.value?.chain().focus().toggleStrike().run(), active: () => !!editor.value?.isActive('strike') },
|
|
63
|
+
{ icon: 'ink_highlighter', label: 'Resaltar', action: () => editor.value?.chain().focus().toggleHighlight().run(), active: () => !!editor.value?.isActive('highlight') },
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
{ icon: 'format_list_bulleted', label: 'Lista', action: () => editor.value?.chain().focus().toggleBulletList().run(), active: () => !!editor.value?.isActive('bulletList') },
|
|
67
|
+
{ icon: 'format_list_numbered', label: 'Lista numerada', action: () => editor.value?.chain().focus().toggleOrderedList().run(), active: () => !!editor.value?.isActive('orderedList') },
|
|
68
|
+
{ icon: 'format_quote', label: 'Cita', action: () => editor.value?.chain().focus().toggleBlockquote().run(), active: () => !!editor.value?.isActive('blockquote') },
|
|
69
|
+
{ icon: 'code', label: 'Código', action: () => editor.value?.chain().focus().toggleCode().run(), active: () => !!editor.value?.isActive('code') },
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
{ icon: 'format_align_left', label: 'Izquierda', action: () => editor.value?.chain().focus().setTextAlign('left').run(), active: () => !!editor.value?.isActive({ textAlign: 'left' }) },
|
|
73
|
+
{ icon: 'format_align_center', label: 'Centro', action: () => editor.value?.chain().focus().setTextAlign('center').run(), active: () => !!editor.value?.isActive({ textAlign: 'center' }) },
|
|
74
|
+
{ icon: 'format_align_right', label: 'Derecha', action: () => editor.value?.chain().focus().setTextAlign('right').run(), active: () => !!editor.value?.isActive({ textAlign: 'right' }) },
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
{ icon: 'undo', label: 'Deshacer', action: () => editor.value?.chain().focus().undo().run() },
|
|
78
|
+
{ icon: 'redo', label: 'Rehacer', action: () => editor.value?.chain().focus().redo().run() },
|
|
79
|
+
],
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
function insertLink() {
|
|
83
|
+
const url = window.prompt('URL del enlace:')
|
|
84
|
+
if (url) editor.value?.chain().focus().setLink({ href: url }).run()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function insertImage() {
|
|
88
|
+
const url = window.prompt('URL de la imagen:')
|
|
89
|
+
if (url) editor.value?.chain().focus().setImage({ src: url }).run()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setHeading(level: 1 | 2 | 3) {
|
|
93
|
+
editor.value?.chain().focus().toggleHeading({ level }).run()
|
|
94
|
+
}
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<template>
|
|
98
|
+
<div
|
|
99
|
+
class="overflow-hidden rounded-lg border transition-colors duration-150"
|
|
100
|
+
:class="disabled ? 'border-outline-variant/50 opacity-60' : 'border-outline-variant focus-within:border-primary'"
|
|
101
|
+
>
|
|
102
|
+
<!-- Toolbar -->
|
|
103
|
+
<div class="flex flex-wrap items-center gap-0.5 border-b border-outline-variant bg-surface-container px-2 py-1.5">
|
|
104
|
+
<!-- Heading select -->
|
|
105
|
+
<select
|
|
106
|
+
class="h-8 cursor-pointer rounded bg-transparent px-2 text-label-large text-on-surface-variant outline-none hover:bg-on-surface/8"
|
|
107
|
+
:value="
|
|
108
|
+
editor?.isActive('heading', { level: 1 }) ? '1'
|
|
109
|
+
: editor?.isActive('heading', { level: 2 }) ? '2'
|
|
110
|
+
: editor?.isActive('heading', { level: 3 }) ? '3'
|
|
111
|
+
: '0'
|
|
112
|
+
"
|
|
113
|
+
@change="(e: Event) => {
|
|
114
|
+
const v = (e.target as HTMLSelectElement).value
|
|
115
|
+
if (v === '0') editor?.chain().focus().setParagraph().run()
|
|
116
|
+
else setHeading(Number(v) as 1 | 2 | 3)
|
|
117
|
+
}"
|
|
118
|
+
>
|
|
119
|
+
<option value="0">Párrafo</option>
|
|
120
|
+
<option value="1">Título 1</option>
|
|
121
|
+
<option value="2">Título 2</option>
|
|
122
|
+
<option value="3">Título 3</option>
|
|
123
|
+
</select>
|
|
124
|
+
|
|
125
|
+
<div class="mx-1 h-6 w-px bg-outline-variant" />
|
|
126
|
+
|
|
127
|
+
<template v-for="(group, gi) in toolGroups" :key="gi">
|
|
128
|
+
<button
|
|
129
|
+
v-for="btn in group"
|
|
130
|
+
:key="btn.icon"
|
|
131
|
+
type="button"
|
|
132
|
+
:title="btn.label"
|
|
133
|
+
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded transition-colors duration-100"
|
|
134
|
+
:class="btn.active?.() ? 'bg-secondary-container text-on-secondary-container' : 'text-on-surface-variant hover:bg-on-surface/8'"
|
|
135
|
+
@click="btn.action"
|
|
136
|
+
>
|
|
137
|
+
<MIcon :name="btn.icon" :size="20" />
|
|
138
|
+
</button>
|
|
139
|
+
<div v-if="gi < toolGroups.length - 1" class="mx-1 h-6 w-px bg-outline-variant" />
|
|
140
|
+
</template>
|
|
141
|
+
|
|
142
|
+
<div class="mx-1 h-6 w-px bg-outline-variant" />
|
|
143
|
+
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
title="Enlace"
|
|
147
|
+
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-on-surface-variant transition-colors hover:bg-on-surface/8"
|
|
148
|
+
@click="insertLink"
|
|
149
|
+
>
|
|
150
|
+
<MIcon name="link" :size="20" />
|
|
151
|
+
</button>
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
title="Imagen"
|
|
155
|
+
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-on-surface-variant transition-colors hover:bg-on-surface/8"
|
|
156
|
+
@click="insertImage"
|
|
157
|
+
>
|
|
158
|
+
<MIcon name="image" :size="20" />
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<!-- Editor content -->
|
|
163
|
+
<EditorContent
|
|
164
|
+
:editor="editor"
|
|
165
|
+
class="rte-content bg-surface px-4 py-3 text-body-large text-on-surface"
|
|
166
|
+
:style="{ minHeight: minHeight }"
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
</template>
|
|
170
|
+
|
|
171
|
+
<style scoped>
|
|
172
|
+
.rte-content :deep(.tiptap) {
|
|
173
|
+
outline: none;
|
|
174
|
+
min-height: inherit;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.rte-content :deep(.tiptap p.is-editor-empty:first-child::before) {
|
|
178
|
+
content: attr(data-placeholder);
|
|
179
|
+
float: left;
|
|
180
|
+
color: var(--color-on-surface-variant);
|
|
181
|
+
opacity: 0.5;
|
|
182
|
+
pointer-events: none;
|
|
183
|
+
height: 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.rte-content :deep(h1) { font-size: var(--text-headline-large); line-height: var(--text-headline-large--line-height); font-weight: 600; margin: 0.75em 0 0.25em; }
|
|
187
|
+
.rte-content :deep(h2) { font-size: var(--text-headline-medium); line-height: var(--text-headline-medium--line-height); font-weight: 600; margin: 0.75em 0 0.25em; }
|
|
188
|
+
.rte-content :deep(h3) { font-size: var(--text-headline-small); line-height: var(--text-headline-small--line-height); font-weight: 600; margin: 0.75em 0 0.25em; }
|
|
189
|
+
.rte-content :deep(p) { margin: 0.5em 0; }
|
|
190
|
+
.rte-content :deep(ul),
|
|
191
|
+
.rte-content :deep(ol) { padding-left: 1.5em; margin: 0.5em 0; }
|
|
192
|
+
.rte-content :deep(blockquote) { border-left: 3px solid var(--color-primary); padding-left: 1em; margin: 0.5em 0; color: var(--color-on-surface-variant); }
|
|
193
|
+
.rte-content :deep(code) { background: var(--color-surface-container-highest); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.875em; }
|
|
194
|
+
.rte-content :deep(pre) { background: var(--color-surface-container-highest); padding: 1em; border-radius: 8px; overflow-x: auto; margin: 0.5em 0; }
|
|
195
|
+
.rte-content :deep(pre code) { background: none; padding: 0; }
|
|
196
|
+
.rte-content :deep(a) { color: var(--color-primary); text-decoration: underline; }
|
|
197
|
+
.rte-content :deep(mark) { background: var(--color-tertiary-container); color: var(--color-on-tertiary-container); padding: 0.1em 0.2em; border-radius: 2px; }
|
|
198
|
+
.rte-content :deep(img) { max-width: 100%; height: auto; border-radius: 8px; margin: 0.5em 0; }
|
|
199
|
+
</style>
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import MIconButton from './MIconButton.vue'
|
|
4
|
+
|
|
5
|
+
export interface SchedulerEvent {
|
|
6
|
+
id: string | number
|
|
7
|
+
title: string
|
|
8
|
+
start: string
|
|
9
|
+
end: string
|
|
10
|
+
color?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'success'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(
|
|
14
|
+
defineProps<{
|
|
15
|
+
events?: SchedulerEvent[]
|
|
16
|
+
view?: 'week' | 'day'
|
|
17
|
+
startHour?: number
|
|
18
|
+
endHour?: number
|
|
19
|
+
locale?: string
|
|
20
|
+
}>(),
|
|
21
|
+
{
|
|
22
|
+
events: () => [],
|
|
23
|
+
view: 'week',
|
|
24
|
+
startHour: 7,
|
|
25
|
+
endHour: 22,
|
|
26
|
+
locale: 'es-ES',
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
eventClick: [SchedulerEvent]
|
|
32
|
+
slotClick: [{ date: string; hour: number }]
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const currentDate = ref(new Date())
|
|
36
|
+
const currentView = ref(props.view)
|
|
37
|
+
|
|
38
|
+
const hours = computed(() =>
|
|
39
|
+
Array.from({ length: props.endHour - props.startHour }, (_, i) => props.startHour + i),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
function startOfWeek(d: Date) {
|
|
43
|
+
const dt = new Date(d)
|
|
44
|
+
const day = dt.getDay()
|
|
45
|
+
const diff = day === 0 ? -6 : 1 - day
|
|
46
|
+
dt.setDate(dt.getDate() + diff)
|
|
47
|
+
dt.setHours(0, 0, 0, 0)
|
|
48
|
+
return dt
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const weekDays = computed(() => {
|
|
52
|
+
const start = startOfWeek(currentDate.value)
|
|
53
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
54
|
+
const d = new Date(start)
|
|
55
|
+
d.setDate(d.getDate() + i)
|
|
56
|
+
return d
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const visibleDays = computed(() =>
|
|
61
|
+
currentView.value === 'day' ? [currentDate.value] : weekDays.value,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const todayIso = (() => {
|
|
65
|
+
const d = new Date()
|
|
66
|
+
return fmt(d)
|
|
67
|
+
})()
|
|
68
|
+
|
|
69
|
+
function fmt(d: Date) {
|
|
70
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const dayFormat = new Intl.DateTimeFormat(props.locale, { weekday: 'short' })
|
|
74
|
+
const dateFormat = new Intl.DateTimeFormat(props.locale, { day: 'numeric' })
|
|
75
|
+
|
|
76
|
+
const headerLabel = computed(() => {
|
|
77
|
+
if (currentView.value === 'day') {
|
|
78
|
+
return new Intl.DateTimeFormat(props.locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
|
79
|
+
.format(currentDate.value)
|
|
80
|
+
}
|
|
81
|
+
const start = weekDays.value[0]!
|
|
82
|
+
const end = weekDays.value[6]!
|
|
83
|
+
const f = new Intl.DateTimeFormat(props.locale, { day: 'numeric', month: 'short' })
|
|
84
|
+
return `${f.format(start)} – ${f.format(end)}, ${end.getFullYear()}`
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
function navigate(delta: number) {
|
|
88
|
+
const d = new Date(currentDate.value)
|
|
89
|
+
if (currentView.value === 'week') d.setDate(d.getDate() + delta * 7)
|
|
90
|
+
else d.setDate(d.getDate() + delta)
|
|
91
|
+
currentDate.value = d
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function goToday() { currentDate.value = new Date() }
|
|
95
|
+
|
|
96
|
+
function eventsForDayHour(day: Date, hour: number) {
|
|
97
|
+
const dayStr = fmt(day)
|
|
98
|
+
return props.events.filter((ev) => {
|
|
99
|
+
const start = new Date(ev.start)
|
|
100
|
+
const end = new Date(ev.end)
|
|
101
|
+
const evDay = fmt(start)
|
|
102
|
+
if (evDay !== dayStr) return false
|
|
103
|
+
const evStartHour = start.getHours()
|
|
104
|
+
const evEndHour = end.getHours() + (end.getMinutes() > 0 ? 1 : 0)
|
|
105
|
+
return hour >= evStartHour && hour < evEndHour
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isEventStart(ev: SchedulerEvent, hour: number) {
|
|
110
|
+
return new Date(ev.start).getHours() === hour
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function eventDuration(ev: SchedulerEvent) {
|
|
114
|
+
const start = new Date(ev.start)
|
|
115
|
+
const end = new Date(ev.end)
|
|
116
|
+
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 3600000))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function timeLabel(ev: SchedulerEvent) {
|
|
120
|
+
const f = new Intl.DateTimeFormat(props.locale, { hour: '2-digit', minute: '2-digit' })
|
|
121
|
+
return `${f.format(new Date(ev.start))} – ${f.format(new Date(ev.end))}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const eventColors: Record<string, string> = {
|
|
125
|
+
primary: 'bg-primary-container text-on-primary-container border-primary/30',
|
|
126
|
+
secondary: 'bg-secondary-container text-on-secondary-container border-secondary/30',
|
|
127
|
+
tertiary: 'bg-tertiary-container text-on-tertiary-container border-tertiary/30',
|
|
128
|
+
error: 'bg-error-container text-on-error-container border-error/30',
|
|
129
|
+
success: 'bg-success-container text-on-success-container border-success/30',
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template>
|
|
134
|
+
<div class="flex flex-col overflow-hidden rounded-lg border border-outline-variant">
|
|
135
|
+
<!-- Header -->
|
|
136
|
+
<div class="flex items-center justify-between border-b border-outline-variant bg-surface-container px-4 py-3">
|
|
137
|
+
<div class="flex items-center gap-1">
|
|
138
|
+
<MIconButton icon="chevron_left" label="Anterior" :size="36" @click="navigate(-1)" />
|
|
139
|
+
<MIconButton icon="chevron_right" label="Siguiente" :size="36" @click="navigate(1)" />
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
class="ml-2 cursor-pointer rounded-full border border-outline px-3 py-1 text-label-medium text-on-surface transition-colors hover:bg-on-surface/8"
|
|
143
|
+
@click="goToday"
|
|
144
|
+
>
|
|
145
|
+
Hoy
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<h3 class="text-title-medium font-medium capitalize text-on-surface">{{ headerLabel }}</h3>
|
|
150
|
+
|
|
151
|
+
<div class="flex rounded-full bg-surface-container-high p-0.5">
|
|
152
|
+
<button
|
|
153
|
+
v-for="v in (['day', 'week'] as const)"
|
|
154
|
+
:key="v"
|
|
155
|
+
type="button"
|
|
156
|
+
class="cursor-pointer rounded-full px-3 py-1 text-label-medium transition-all duration-150"
|
|
157
|
+
:class="currentView === v ? 'bg-secondary-container text-on-secondary-container shadow-elevation-1' : 'text-on-surface-variant hover:bg-on-surface/8'"
|
|
158
|
+
@click="currentView = v"
|
|
159
|
+
>
|
|
160
|
+
{{ v === 'day' ? 'Día' : 'Semana' }}
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<!-- Grid -->
|
|
166
|
+
<div class="overflow-auto">
|
|
167
|
+
<table class="w-full border-collapse">
|
|
168
|
+
<!-- Day headers -->
|
|
169
|
+
<thead>
|
|
170
|
+
<tr>
|
|
171
|
+
<th class="sticky top-0 z-10 w-16 border-b border-r border-outline-variant bg-surface-container p-2" />
|
|
172
|
+
<th
|
|
173
|
+
v-for="day in visibleDays"
|
|
174
|
+
:key="fmt(day)"
|
|
175
|
+
class="sticky top-0 z-10 border-b border-r border-outline-variant bg-surface-container px-2 py-2 text-center last:border-r-0"
|
|
176
|
+
:class="fmt(day) === todayIso ? 'bg-primary-container/30' : ''"
|
|
177
|
+
>
|
|
178
|
+
<div class="text-label-small uppercase text-on-surface-variant">{{ dayFormat.format(day) }}</div>
|
|
179
|
+
<div
|
|
180
|
+
class="mx-auto mt-0.5 flex h-8 w-8 items-center justify-center rounded-full text-title-medium"
|
|
181
|
+
:class="fmt(day) === todayIso ? 'bg-primary text-on-primary font-medium' : 'text-on-surface'"
|
|
182
|
+
>
|
|
183
|
+
{{ dateFormat.format(day) }}
|
|
184
|
+
</div>
|
|
185
|
+
</th>
|
|
186
|
+
</tr>
|
|
187
|
+
</thead>
|
|
188
|
+
|
|
189
|
+
<tbody>
|
|
190
|
+
<tr v-for="hour in hours" :key="hour">
|
|
191
|
+
<!-- Hour label -->
|
|
192
|
+
<td class="w-16 border-r border-b border-outline-variant/50 p-0 pr-2 text-right align-top">
|
|
193
|
+
<span class="relative -top-2.5 text-label-small text-on-surface-variant">
|
|
194
|
+
{{ String(hour).padStart(2, '0') }}:00
|
|
195
|
+
</span>
|
|
196
|
+
</td>
|
|
197
|
+
|
|
198
|
+
<!-- Day cells -->
|
|
199
|
+
<td
|
|
200
|
+
v-for="day in visibleDays"
|
|
201
|
+
:key="fmt(day)"
|
|
202
|
+
class="relative h-14 border-r border-b border-outline-variant/50 p-0 last:border-r-0"
|
|
203
|
+
:class="fmt(day) === todayIso ? 'bg-primary-container/[0.05]' : ''"
|
|
204
|
+
@click="emit('slotClick', { date: fmt(day), hour })"
|
|
205
|
+
>
|
|
206
|
+
<template v-for="ev in eventsForDayHour(day, hour)" :key="ev.id">
|
|
207
|
+
<button
|
|
208
|
+
v-if="isEventStart(ev, hour)"
|
|
209
|
+
type="button"
|
|
210
|
+
class="absolute inset-x-0.5 top-0.5 z-[5] cursor-pointer overflow-hidden rounded border-l-[3px] px-2 py-1 text-left transition-opacity hover:opacity-90"
|
|
211
|
+
:class="eventColors[ev.color ?? 'primary']"
|
|
212
|
+
:style="{ height: `calc(${eventDuration(ev) * 100}% - 4px)` }"
|
|
213
|
+
@click.stop="emit('eventClick', ev)"
|
|
214
|
+
>
|
|
215
|
+
<p class="truncate text-label-small font-medium">{{ ev.title }}</p>
|
|
216
|
+
<p class="truncate text-label-small opacity-70">{{ timeLabel(ev) }}</p>
|
|
217
|
+
</button>
|
|
218
|
+
</template>
|
|
219
|
+
</td>
|
|
220
|
+
</tr>
|
|
221
|
+
</tbody>
|
|
222
|
+
</table>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</template>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import MIcon from './MIcon.vue'
|
|
3
|
+
|
|
4
|
+
export interface SegmentedOption {
|
|
5
|
+
value: string | number
|
|
6
|
+
label: string
|
|
7
|
+
icon?: string
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
withDefaults(defineProps<{
|
|
12
|
+
modelValue: string | number | (string | number)[]
|
|
13
|
+
options: SegmentedOption[]
|
|
14
|
+
multiSelect?: boolean
|
|
15
|
+
density?: 'default' | 'comfortable' | 'compact'
|
|
16
|
+
color?: 'primary' | 'secondary' | 'tertiary'
|
|
17
|
+
}>(), { multiSelect: false, density: 'default', color: 'primary' })
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{ 'update:modelValue': [string | number | (string | number)[]] }>()
|
|
20
|
+
|
|
21
|
+
function isSelected(value: string | number, modelValue: string | number | (string | number)[]) {
|
|
22
|
+
return Array.isArray(modelValue) ? modelValue.includes(value) : modelValue === value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toggle(opt: SegmentedOption, modelValue: string | number | (string | number)[], multi: boolean) {
|
|
26
|
+
if (opt.disabled) return
|
|
27
|
+
if (multi) {
|
|
28
|
+
const arr = Array.isArray(modelValue) ? [...modelValue] : [modelValue]
|
|
29
|
+
const idx = arr.indexOf(opt.value)
|
|
30
|
+
if (idx >= 0) arr.splice(idx, 1)
|
|
31
|
+
else arr.push(opt.value)
|
|
32
|
+
emit('update:modelValue', arr)
|
|
33
|
+
} else {
|
|
34
|
+
emit('update:modelValue', opt.value)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div
|
|
41
|
+
class="inline-flex overflow-hidden rounded-full border border-outline"
|
|
42
|
+
role="group"
|
|
43
|
+
>
|
|
44
|
+
<button
|
|
45
|
+
v-for="(opt, i) in options"
|
|
46
|
+
:key="opt.value"
|
|
47
|
+
type="button"
|
|
48
|
+
class="relative inline-flex items-center justify-center gap-2 text-label-large font-medium transition-[background-color,color] duration-150 outline-none before:pointer-events-none before:absolute before:inset-0 before:bg-current before:opacity-0 before:transition-opacity before:duration-150 enabled:hover:before:opacity-[0.08] enabled:active:before:opacity-[0.12]"
|
|
49
|
+
:class="[
|
|
50
|
+
density === 'compact' ? 'h-8 px-3' : density === 'comfortable' ? 'h-10 px-4' : 'h-10 px-6',
|
|
51
|
+
i > 0 ? 'border-l border-outline' : '',
|
|
52
|
+
opt.disabled ? 'cursor-not-allowed opacity-[0.38]' : 'cursor-pointer',
|
|
53
|
+
isSelected(opt.value, modelValue)
|
|
54
|
+
? color === 'secondary'
|
|
55
|
+
? 'bg-secondary-container text-on-secondary-container'
|
|
56
|
+
: color === 'tertiary'
|
|
57
|
+
? 'bg-tertiary-container text-on-tertiary-container'
|
|
58
|
+
: 'bg-secondary-container text-on-secondary-container'
|
|
59
|
+
: 'text-on-surface',
|
|
60
|
+
]"
|
|
61
|
+
:disabled="opt.disabled"
|
|
62
|
+
:aria-pressed="isSelected(opt.value, modelValue)"
|
|
63
|
+
@click="toggle(opt, modelValue, multiSelect)"
|
|
64
|
+
>
|
|
65
|
+
<MIcon
|
|
66
|
+
v-if="isSelected(opt.value, modelValue)"
|
|
67
|
+
name="check"
|
|
68
|
+
:size="18"
|
|
69
|
+
class="transition-transform duration-150"
|
|
70
|
+
/>
|
|
71
|
+
<MIcon v-else-if="opt.icon" :name="opt.icon" :size="18" />
|
|
72
|
+
<span>{{ opt.label }}</span>
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|