@deplois/template 0.0.0-stage → 0.0.1

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.
Files changed (47) hide show
  1. package/README.md +113 -2
  2. package/app/app.config.ts +52 -0
  3. package/app/components/action/button.vue +35 -0
  4. package/app/components/content/badge.vue +9 -0
  5. package/app/components/content/card.vue +9 -0
  6. package/app/components/content/chart.vue +94 -0
  7. package/app/components/content/formatted-text.vue +127 -0
  8. package/app/components/content/table.vue +13 -0
  9. package/app/components/content/timeline.vue +9 -0
  10. package/app/components/feedback/alert.vue +9 -0
  11. package/app/components/feedback/loader.vue +53 -0
  12. package/app/components/feedback/loading-view.vue +21 -0
  13. package/app/components/feedback/modal.vue +93 -0
  14. package/app/components/feedback/toast.vue +79 -0
  15. package/app/components/feedback/tooltip.vue +9 -0
  16. package/app/components/file/list.vue +36 -0
  17. package/app/components/file/upload.vue +114 -0
  18. package/app/components/form/calendar.vue +10 -0
  19. package/app/components/form/check-box.vue +64 -0
  20. package/app/components/form/editor.vue +124 -0
  21. package/app/components/form/input-error.vue +59 -0
  22. package/app/components/form/input.vue +43 -0
  23. package/app/components/form/select.vue +10 -0
  24. package/app/components/form/terms.vue +54 -0
  25. package/app/components/form/toggle.vue +29 -0
  26. package/app/components/navigation/breadcrumb.vue +10 -0
  27. package/app/components/navigation/tabs.vue +9 -0
  28. package/app/components/page/dashboard.vue +84 -0
  29. package/app/components/page/error.vue +45 -0
  30. package/app/components/page/frame.vue +53 -0
  31. package/app/error.vue +10 -0
  32. package/app/languages/en-us.ts +87 -0
  33. package/app/languages/index.ts +15 -0
  34. package/app/languages/ja-jp.ts +87 -0
  35. package/app/languages/ko-kr.ts +87 -0
  36. package/app/plugins/modal.plugin.ts +103 -0
  37. package/app/plugins/module.ts +49 -0
  38. package/app/plugins/toast.plugin.ts +44 -0
  39. package/app/plugins/variables.plugin.ts +28 -0
  40. package/app/types/file.ts +8 -0
  41. package/assets/css/colors.css +61 -0
  42. package/assets/css/main.css +164 -0
  43. package/nuxt.config.ts +19 -0
  44. package/package.json +59 -4
  45. package/public/_deplois/font/LICENSE +104 -0
  46. package/public/_deplois/font/PretendardVariable.woff2 +0 -0
  47. package/public/_deplois/logo.svg +3 -0
@@ -0,0 +1,79 @@
1
+ <template lang='pug'>
2
+ div.fixed.z-50.flex.flex-col.justify-end.overflow-y-auto.p-1(:class="props.ui?.root ?? 'bottom-3 left-3 right-3 max-h-[calc(100svh-1.5rem)] md:left-auto md:right-5 md:w-96'" aria-live='polite' aria-relevant='additions')
3
+ TransitionGroup.relative.flex.flex-col.gap-2(name='toast' tag='div')
4
+ div.flex.w-full.items-start.gap-3.rounded-md.p-4.shadow-md(
5
+ v-for='(toast, index) in $toast.toasts'
6
+ :key='toast.srl'
7
+ :class="(typeof props.ui?.item == 'string' ? props.ui.item : props.ui?.item?.[toast.type]) ?? ['border bg-default text-default shadow-black/10 dark:shadow-black/30', borders[toast.type]]"
8
+ :style='{ zIndex: index + 1 }'
9
+ :role="toast.type == 'danger' ? 'alert' : 'status'"
10
+ )
11
+ slot(:toast='toast' :close='() => $toast.remove(toast.srl)')
12
+ slot(name='icon' :toast='toast')
13
+ Icon.shrink-0(:name='icons[toast.type]' :class="props.ui?.icon ?? 'mt-0.5 size-5 text-muted'")
14
+ slot(name='message' :toast='toast')
15
+ p.min-w-0.flex-1.whitespace-pre-wrap.break-words.text-sm.leading-5(:class='props.ui?.message') {{ toast.message }}
16
+ slot(name='close' :toast='toast' :close='() => $toast.remove(toast.srl)')
17
+ button.flex.size-6.shrink-0.cursor-pointer.items-center.justify-center.rounded-md.transition-colors(type='button' :aria-label='$languages.items[$languages.value].components.toast.dismiss' :class="props.ui?.close ?? 'text-muted hover:bg-muted hover:text-default'" @click='$toast.remove(toast.srl)')
18
+ Icon.size-4(name='i-lucide-x')
19
+ </template>
20
+
21
+ <script setup lang='ts'>
22
+ import type { ToastType } from '../../plugins/toast.plugin'
23
+
24
+ const props = defineProps<{
25
+ ui?: Partial<Record<'root' | 'icon' | 'message' | 'close', string>> & {
26
+ item?: string | Partial<Record<ToastType, string>>
27
+ }
28
+ }>()
29
+ const { $languages, $toast } = useNuxtApp()
30
+
31
+ const borders: Record<ToastType, string> = {
32
+ info: 'border-info',
33
+ success: 'border-success',
34
+ warning: 'border-warning',
35
+ danger: 'border-error'
36
+ }
37
+
38
+ const icons: Record<ToastType, string> = {
39
+ info: 'i-lucide-info',
40
+ success: 'i-lucide-circle-check',
41
+ warning: 'i-lucide-triangle-alert',
42
+ danger: 'i-lucide-circle-x'
43
+ }
44
+
45
+ onBeforeUnmount(() => $toast.clear())
46
+ </script>
47
+
48
+ <style scoped>
49
+ .toast-enter-active,
50
+ .toast-leave-active,
51
+ .toast-move {
52
+ transition: opacity 180ms ease, transform 300ms cubic-bezier(.22, 1, .36, 1);
53
+ }
54
+
55
+ .toast-enter-from {
56
+ opacity: 0;
57
+ transform: translateY(1rem) scale(.98);
58
+ }
59
+
60
+ .toast-leave-to {
61
+ opacity: 0;
62
+ transform: translateY(.5rem) scale(.98);
63
+ }
64
+
65
+ .toast-leave-active {
66
+ position: absolute;
67
+ right: 0;
68
+ left: 0;
69
+ }
70
+
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .toast-enter-active,
73
+ .toast-leave-active,
74
+ .toast-move {
75
+ transition: none;
76
+ }
77
+ }
78
+ </style>
79
+
@@ -0,0 +1,9 @@
1
+ <template lang='pug'>
2
+ UTooltip(:delay-duration='200' v-bind='$attrs')
3
+ template(v-for='(_, name) in $slots' #[name]='scope')
4
+ slot(:name='name' v-bind='scope || {}')
5
+ </template>
6
+
7
+ <script setup lang='ts'>
8
+ defineOptions({ inheritAttrs: false })
9
+ </script>
@@ -0,0 +1,36 @@
1
+ <template lang='pug'>
2
+ div.grid.w-full.gap-2
3
+ slot(v-if='!props.files.length' name='empty')
4
+ div.flex.min-w-0.items-center.gap-3.rounded-md.p-3(v-for='(file, index) in props.files' :key='file.id ?? index' :class="props.ui?.item ?? 'border border-accented bg-default'")
5
+ slot(:file='file' :index='index' :remove='() => remove(index)')
6
+ slot(name='preview' :file='file' :index='index')
7
+ div.flex.size-10.shrink-0.items-center.justify-center.overflow-hidden.rounded-md(:class="props.ui?.preview ?? 'bg-muted text-muted'")
8
+ img.size-full.object-cover(v-if="file.type.startsWith('image/') && (file.data || file.url)" :src='file.data || file.url' :alt='file.name')
9
+ Icon.size-5(v-else name='i-lucide-file-text')
10
+ div.min-w-0.flex-1
11
+ slot(name='name' :file='file' :index='index')
12
+ p.truncate.text-sm.font-medium(:class='props.ui?.name') {{ file.name }}
13
+ slot(name='description' :file='file' :index='index')
14
+ p.mt-1.text-xs(:class="props.ui?.description ?? 'text-muted'") {{ typeof file.size == 'number' ? `${(file.size / 1024).toLocaleString(undefined, { maximumFractionDigits: 1 })} KB` : file.size }}
15
+ slot(name='actions' :file='file' :index='index' :remove='() => remove(index)')
16
+ button.flex.size-8.shrink-0.cursor-pointer.items-center.justify-center.rounded-md.transition-colors(v-if='props.removable' type='button' :disabled='props.disabled' :aria-label="$languages.items[$languages.value].components.file.remove.replace('${name}', file.name)" class='disabled:cursor-not-allowed disabled:opacity-50' :class="props.ui?.remove ?? 'text-muted hover:bg-muted hover:text-default'" @click='remove(index)')
17
+ Icon.size-4(name='i-lucide-x')
18
+ </template>
19
+
20
+ <script setup lang='ts'>
21
+ import type { FileItem } from '../../types/file'
22
+
23
+ const { $languages } = useNuxtApp()
24
+
25
+ const props = withDefaults(defineProps<{
26
+ files: Array<FileItem>,
27
+ removable?: boolean,
28
+ disabled?: boolean,
29
+ ui?: Partial<Record<'item' | 'preview' | 'name' | 'description' | 'remove', string>>
30
+ }>(), { removable: true, disabled: false })
31
+ const emit = defineEmits<{ remove: [index: number] }>()
32
+
33
+ function remove (index: number) {
34
+ if(!props.disabled && props.removable) emit('remove', index)
35
+ }
36
+ </script>
@@ -0,0 +1,114 @@
1
+ <template lang='pug'>
2
+ div.w-full
3
+ div.relative.flex.w-full.flex-col.items-center.justify-center.gap-2.rounded-md.border.border-dashed.p-4.text-center.transition-colors(
4
+ :class="[props.ui?.dropzone ?? 'min-h-32 border-accented bg-default', { 'cursor-not-allowed opacity-50': props.disabled, 'ring-2 ring-current': dragCount > 0 && !props.disabled }]"
5
+ @dragenter.prevent='dragCount++'
6
+ @dragover.prevent
7
+ @dragleave.prevent='dragCount = Math.max(0, dragCount - 1)'
8
+ @drop.prevent='drop'
9
+ )
10
+ input.sr-only(:id='inputId' ref='fileInput' type='file' :accept='props.accept' :multiple='props.multiple' :disabled='props.disabled' :aria-label='(props.label ?? $languages.items[$languages.value].components.file.select)' tabindex='-1' @change='select')
11
+ slot(:open='open' :disabled='props.disabled' :is-reading='isReading' :is-dragging='dragCount > 0')
12
+ Icon.size-6(:name="isReading ? 'i-lucide-loader-circle' : 'i-lucide-upload'" :class="[props.ui?.icon ?? 'text-muted', { 'animate-spin': isReading }]")
13
+ button.cursor-pointer.text-sm.font-medium(type='button' :disabled='props.disabled' class='disabled:cursor-not-allowed' :class="props.ui?.button ?? 'text-default hover:text-muted'" @click='open') {{ (props.label ?? $languages.items[$languages.value].components.file.select) }}
14
+ p.text-xs(:class="props.ui?.description ?? 'text-muted'") {{ (props.description ?? $languages.items[$languages.value].components.file.description) }}
15
+ slot(name='error' :error='error')
16
+ p.mt-1.text-xs(v-if='error' role='alert' :class="props.ui?.error ?? 'text-error'") {{ error }}
17
+ </template>
18
+
19
+ <script setup lang='ts'>
20
+ import type { FileItem } from '../../types/file'
21
+
22
+ const { $languages } = useNuxtApp()
23
+
24
+ const props = withDefaults(defineProps<{
25
+ accept?: string,
26
+ multiple?: boolean,
27
+ maxSize?: number,
28
+ disabled?: boolean,
29
+ label?: string,
30
+ description?: string,
31
+ ui?: Partial<Record<'dropzone' | 'icon' | 'button' | 'description' | 'error', string>>
32
+ }>(), {
33
+ accept: 'image/*,.pdf,.doc,.docx,.hwp',
34
+ multiple: true,
35
+ maxSize: 10 * 1024 * 1024,
36
+ disabled: false
37
+ })
38
+ const emit = defineEmits<{ upload: [file: FileItem & { data: string, size: number }], error: [error: string] }>()
39
+ const inputId = useId()
40
+ const fileInput = ref<HTMLInputElement | null>(null)
41
+ const error = ref<string | null>(null)
42
+ const dragCount = ref(0)
43
+ const isReading = ref(false)
44
+ const readers = new Set<FileReader>()
45
+ const acceptedTypes = computed(() => props.accept.split(',').map(type => type.trim().toLowerCase()).filter(Boolean))
46
+ const fileTypes: Record<string, string> = {
47
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
48
+ webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', heic: 'image/heic',
49
+ pdf: 'application/pdf', doc: 'application/msword',
50
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', hwp: 'application/x-hwp'
51
+ }
52
+
53
+ function open () {
54
+ if(!props.disabled) fileInput.value?.click()
55
+ }
56
+
57
+ function select (event: Event) {
58
+ const input = event.target as HTMLInputElement
59
+ readFiles(input.files)
60
+ input.value = ''
61
+ }
62
+
63
+ function drop (event: DragEvent) {
64
+ dragCount.value = 0
65
+ readFiles(event.dataTransfer?.files)
66
+ }
67
+
68
+ function readFiles (files?: FileList | null) {
69
+ if(props.disabled || !files) return
70
+ error.value = null
71
+ for(const file of Array.from(files).slice(0, props.multiple ? undefined : 1)) {
72
+ const name = file.name.toLowerCase()
73
+ const type = file.type && file.type !== 'application/octet-stream'
74
+ ? file.type.toLowerCase() : fileTypes[name.split('.').pop() ?? ''] ?? 'application/octet-stream'
75
+ if(acceptedTypes.value.length && !acceptedTypes.value.some(accept => accept.startsWith('.')
76
+ ? name.endsWith(accept) : accept.endsWith('/*') ? type.startsWith(accept.slice(0, -1)) : type === accept)) {
77
+ error.value = $languages.items[$languages.value].components.file.unsupported.replace('${name}', file.name).replace('${size}', String(props.maxSize))
78
+ emit('error', error.value)
79
+ continue
80
+ }
81
+ if(file.size > props.maxSize) {
82
+ error.value = $languages.items[$languages.value].components.file.tooLarge.replace('${name}', file.name).replace('${size}', String(props.maxSize))
83
+ emit('error', error.value)
84
+ continue
85
+ }
86
+
87
+ const reader = new FileReader()
88
+ readers.add(reader)
89
+ isReading.value = true
90
+ reader.onload = () => {
91
+ if(typeof reader.result === 'string') emit('upload', { name: file.name, size: file.size, type: type, data: reader.result })
92
+ }
93
+ reader.onerror = () => {
94
+ error.value = $languages.items[$languages.value].components.file.readError.replace('${name}', file.name).replace('${size}', String(props.maxSize))
95
+ emit('error', error.value)
96
+ }
97
+ reader.onloadend = () => {
98
+ readers.delete(reader)
99
+ isReading.value = readers.size > 0
100
+ }
101
+ reader.readAsDataURL(file)
102
+ }
103
+ }
104
+
105
+ onBeforeUnmount(() => {
106
+ for(const reader of readers) {
107
+ reader.onload = null
108
+ reader.onerror = null
109
+ reader.onloadend = null
110
+ reader.abort()
111
+ }
112
+ readers.clear()
113
+ })
114
+ </script>
@@ -0,0 +1,10 @@
1
+ <template lang='pug'>
2
+ UCalendar(:locale='$languages.locale.code' :calendar-label='$languages.items[$languages.value].components.calendar.label' v-bind='$attrs')
3
+ template(v-for='(_, name) in $slots' #[name]='scope')
4
+ slot(:name='name' v-bind='scope || {}')
5
+ </template>
6
+
7
+ <script setup lang='ts'>
8
+ const { $languages } = useNuxtApp()
9
+ defineOptions({ inheritAttrs: false })
10
+ </script>
@@ -0,0 +1,64 @@
1
+ <template lang='pug'>
2
+ button.relative.flex.size-4.shrink-0.cursor-pointer.items-center.justify-center.rounded.border.transition-colors(
3
+ type='button'
4
+ role='checkbox'
5
+ :aria-checked='modelValue'
6
+ :disabled='props.disabled'
7
+ :class="modelValue ? (props.ui?.checked ?? 'border-primary bg-primary text-white') : (props.ui?.unchecked ?? 'border-accented bg-default text-transparent')"
8
+ class='focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:opacity-50'
9
+ @click='toggle'
10
+ )
11
+ slot(:checked='modelValue')
12
+ i.check-mark.pointer-events-none.absolute.block.origin-left(:class="[props.ui?.icon, { active: modelValue }]" class='left-[5.83px] top-[4.14px] size-[14px] -rotate-45 scale-[0.8]' aria-hidden='true')
13
+ </template>
14
+
15
+ <script setup lang='ts'>
16
+ const props = defineProps<{ disabled?: boolean, ui?: Partial<Record<'checked' | 'unchecked' | 'icon', string>> }>()
17
+ const modelValue = defineModel<boolean>({ default: false })
18
+ const emit = defineEmits<{ update: [value: boolean] }>()
19
+
20
+ function toggle () {
21
+ if(props.disabled) return
22
+ modelValue.value = !modelValue.value
23
+ emit('update', modelValue.value)
24
+ }
25
+ </script>
26
+
27
+ <style scoped>
28
+ .check-mark::before,
29
+ .check-mark::after {
30
+ content: '';
31
+ display: block;
32
+ background: currentColor;
33
+ }
34
+
35
+ .check-mark::before {
36
+ width: 2.1px;
37
+ height: 0;
38
+ }
39
+
40
+ .check-mark::after {
41
+ width: 0;
42
+ height: 2.1px;
43
+ }
44
+
45
+ .check-mark.active::before {
46
+ height: 35%;
47
+ margin-bottom: -.1px;
48
+ border-radius: 4px 4px 0 0;
49
+ transition: height 300ms cubic-bezier(.895, .03, .685, .22);
50
+ }
51
+
52
+ .check-mark.active::after {
53
+ width: 80%;
54
+ border-radius: 0 4px 4px 0;
55
+ transition: width 300ms 300ms cubic-bezier(.165, .84, .44, 1);
56
+ }
57
+
58
+ @media (prefers-reduced-motion: reduce) {
59
+ .check-mark.active::before,
60
+ .check-mark.active::after {
61
+ transition: none;
62
+ }
63
+ }
64
+ </style>
@@ -0,0 +1,124 @@
1
+ <template lang='pug'>
2
+ ClientOnly(fallback-tag='div' :fallback='$languages.items[$languages.value].components.editor.loading')
3
+ div.overflow-hidden.rounded-md.border.border-accented.bg-default.text-default(v-bind='$attrs' :class="[{ 'opacity-50': props.disabled }, props.ui?.root]" class='focus-within:border-primary focus-within:ring-1 focus-within:ring-primary')
4
+ div.flex.flex-wrap.gap-1.border-b.border-default.bg-muted.p-2(v-if='!props.readonly' role='group' :aria-label='(props.toolbarLabel ?? $languages.items[$languages.value].components.editor.toolbar)' :class='props.ui?.toolbar')
5
+ Tooltip(v-for='action in actions' :key='action.name' :text='action.label')
6
+ Button.w-8.px-0(:auto-loading='false' :disabled='props.disabled || !editor || action.disabled' :aria-label='action.label' :aria-pressed='action.active' :class="action.active ? 'bg-primary/10 text-primary' : 'text-muted hover:bg-elevated hover:text-default'" @click='action.run')
7
+ Icon.size-4.shrink-0(:name='action.icon')
8
+ slot(name='toolbar' :editor='editor')
9
+ form.flex.flex-wrap.items-start.gap-2.border-b.border-default.p-2(v-if='showLink && !props.readonly' @submit.prevent='setLink')
10
+ div.min-w-0.flex-1
11
+ Input(v-model='link' type='text' inputmode='url' placeholder='https://example.com' :aria-label='$languages.items[$languages.value].components.editor.linkAddress' :disabled='props.disabled' :aria-invalid='!!linkError')
12
+ p.mt-1.text-xs.text-error(v-if='linkError' role='alert') {{ $languages.items[$languages.value].components.editor.invalidLink }}
13
+ Button.bg-primary.text-white(:auto-loading='false' type='submit' :disabled='props.disabled' class='hover:bg-primary-700') {{ $languages.items[$languages.value].components.common.apply }}
14
+ Button.text-muted(:auto-loading='false' :disabled='props.disabled' @click="link = ''; setLink()") {{ $languages.items[$languages.value].components.editor.unlink }}
15
+ Button.text-muted(:auto-loading='false' @click='showLink = false') {{ $languages.items[$languages.value].components.common.cancel }}
16
+ div.relative(:class='props.ui?.content')
17
+ p.pointer-events-none.absolute.left-4.top-4.text-sm.text-dimmed(v-if='editor?.isEmpty && (props.placeholder ?? $languages.items[$languages.value].components.editor.placeholder)' aria-hidden='true') {{ (props.placeholder ?? $languages.items[$languages.value].components.editor.placeholder) }}
18
+ EditorContent(:editor='editor')
19
+ div.min-h-40.p-4(v-if='!editor' aria-busy='true')
20
+ Loader.size-4.text-primary
21
+ div.border-t.border-default.px-4.py-2.text-xs.text-muted(v-if='$slots.footer')
22
+ slot(name='footer' :editor='editor')
23
+ </template>
24
+
25
+ <script setup lang='ts'>
26
+ import { useEditor, EditorContent } from '@tiptap/vue-3'
27
+ import StarterKit from '@tiptap/starter-kit'
28
+ import TextAlign from '@tiptap/extension-text-align'
29
+
30
+ const { $languages } = useNuxtApp()
31
+
32
+ defineOptions({ inheritAttrs: false })
33
+
34
+ const props = withDefaults(defineProps<{
35
+ mode?: 'simple' | 'detailed',
36
+ disabled?: boolean,
37
+ readonly?: boolean,
38
+ placeholder?: string,
39
+ label?: string,
40
+ toolbarLabel?: string,
41
+ ui?: Partial<Record<'root' | 'toolbar' | 'content', string>>
42
+ }>(), { mode: 'simple' })
43
+ const modelValue = defineModel<string>({ default: '' })
44
+ const revision = ref(0)
45
+ const showLink = ref(false)
46
+ const link = ref('')
47
+ const linkError = ref(false)
48
+ let selection = { from: 0, to: 0 }
49
+ const editor = useEditor({
50
+ content: modelValue.value,
51
+ editable: !props.disabled && !props.readonly,
52
+ extensions: [StarterKit.configure({ link: { openOnClick: false, protocols: ['http', 'https', 'mailto'] } }), TextAlign.configure({ types: ['heading', 'paragraph'] })],
53
+ editorProps: {
54
+ attributes: {
55
+ role: 'textbox',
56
+ 'aria-multiline': 'true',
57
+ 'aria-label': (props.label ?? $languages.items[$languages.value].components.editor.label),
58
+ 'aria-disabled': String(!!props.disabled),
59
+ 'aria-readonly': String(!!props.readonly),
60
+ class: 'min-h-40 p-4 text-sm leading-6 outline-none break-words [&_p]:min-h-6 [&_p+p]:mt-2 [&_h1]:text-2xl [&_h2]:text-xl [&_h3]:text-lg [&_h1]:font-bold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:text-muted [&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:overflow-x-auto [&_a]:text-primary [&_a]:underline [&_hr]:border-default [&_hr]:my-4'
61
+ }
62
+ },
63
+ onUpdate: ({ editor }) => { modelValue.value = editor.getHTML() },
64
+ onTransaction: () => { revision.value++ },
65
+ onCreate: ({ editor }) => {
66
+ if(editor.getHTML() != modelValue.value) editor.commands.setContent(modelValue.value, { emitUpdate: false })
67
+ }
68
+ })
69
+ watch(modelValue, value => {
70
+ if(editor.value && value != editor.value.getHTML()) editor.value.commands.setContent(value, { emitUpdate: false })
71
+ })
72
+ watch(() => [props.disabled, props.readonly, (props.label ?? $languages.items[$languages.value].components.editor.label)], () => {
73
+ editor.value?.setEditable(!props.disabled && !props.readonly)
74
+ editor.value?.setOptions({ editorProps: { attributes: { ...editor.value.options.editorProps.attributes, 'aria-label': (props.label ?? $languages.items[$languages.value].components.editor.label), 'aria-disabled': String(!!props.disabled), 'aria-readonly': String(!!props.readonly) } } })
75
+ if(props.disabled || props.readonly) showLink.value = false
76
+ })
77
+ const actions = computed(() => {
78
+ revision.value
79
+ const instance = editor.value
80
+ if(!instance) return []
81
+ const items = [
82
+ { name: 'bold', label: $languages.items[$languages.value].components.editor.bold, icon: 'i-lucide-bold', active: instance.isActive('bold'), disabled: false, run: () => instance.chain().focus().toggleBold().run() },
83
+ { name: 'italic', label: $languages.items[$languages.value].components.editor.italic, icon: 'i-lucide-italic', active: instance.isActive('italic'), disabled: false, run: () => instance.chain().focus().toggleItalic().run() },
84
+ { name: 'underline', label: $languages.items[$languages.value].components.editor.underline, icon: 'i-lucide-underline', active: instance.isActive('underline'), disabled: false, run: () => instance.chain().focus().toggleUnderline().run() },
85
+ { name: 'bulletList', label: $languages.items[$languages.value].components.editor.bulletList, icon: 'i-lucide-list', active: instance.isActive('bulletList'), disabled: false, run: () => instance.chain().focus().toggleBulletList().run() }
86
+ ]
87
+ if(props.mode == 'detailed') items.push(
88
+ { name: 'heading', label: $languages.items[$languages.value].components.editor.heading, icon: 'i-lucide-heading-2', active: instance.isActive('heading', { level: 2 }), disabled: false, run: () => instance.chain().focus().toggleHeading({ level: 2 }).run() },
89
+ { name: 'strike', label: $languages.items[$languages.value].components.editor.strike, icon: 'i-lucide-strikethrough', active: instance.isActive('strike'), disabled: false, run: () => instance.chain().focus().toggleStrike().run() },
90
+ { name: 'orderedList', label: $languages.items[$languages.value].components.editor.orderedList, icon: 'i-lucide-list-ordered', active: instance.isActive('orderedList'), disabled: false, run: () => instance.chain().focus().toggleOrderedList().run() },
91
+ { name: 'blockquote', label: $languages.items[$languages.value].components.editor.blockquote, icon: 'i-lucide-quote', active: instance.isActive('blockquote'), disabled: false, run: () => instance.chain().focus().toggleBlockquote().run() },
92
+ { name: 'codeBlock', label: $languages.items[$languages.value].components.editor.codeBlock, icon: 'i-lucide-code', active: instance.isActive('codeBlock'), disabled: false, run: () => instance.chain().focus().toggleCodeBlock().run() },
93
+ { name: 'left', label: $languages.items[$languages.value].components.editor.left, icon: 'i-lucide-align-left', active: instance.isActive({ textAlign: 'left' }), disabled: false, run: () => instance.chain().focus().setTextAlign('left').run() },
94
+ { name: 'center', label: $languages.items[$languages.value].components.editor.center, icon: 'i-lucide-align-center', active: instance.isActive({ textAlign: 'center' }), disabled: false, run: () => instance.chain().focus().setTextAlign('center').run() },
95
+ { name: 'right', label: $languages.items[$languages.value].components.editor.right, icon: 'i-lucide-align-right', active: instance.isActive({ textAlign: 'right' }), disabled: false, run: () => instance.chain().focus().setTextAlign('right').run() },
96
+ { name: 'link', label: $languages.items[$languages.value].components.editor.link, icon: 'i-lucide-link', active: instance.isActive('link'), disabled: false, run: () => {
97
+ selection = { from: instance.state.selection.from, to: instance.state.selection.to }
98
+ link.value = instance.getAttributes('link').href || ''
99
+ linkError.value = false
100
+ showLink.value = !showLink.value
101
+ return true
102
+ } },
103
+ { name: 'clear', label: $languages.items[$languages.value].components.editor.clear, icon: 'i-lucide-remove-formatting', active: false, disabled: false, run: () => instance.chain().focus().unsetAllMarks().clearNodes().run() }
104
+ )
105
+ items.push(
106
+ { name: 'undo', label: $languages.items[$languages.value].components.editor.undo, icon: 'i-lucide-undo', active: false, disabled: !instance.can().undo(), run: () => instance.chain().focus().undo().run() },
107
+ { name: 'redo', label: $languages.items[$languages.value].components.editor.redo, icon: 'i-lucide-redo', active: false, disabled: !instance.can().redo(), run: () => instance.chain().focus().redo().run() }
108
+ )
109
+ return items
110
+ })
111
+ function setLink () {
112
+ if(props.disabled || props.readonly || !editor.value) return
113
+ const href = link.value.trim()
114
+ if(href && !/^(https?:\/\/|mailto:)/i.test(href)) {
115
+ linkError.value = true
116
+ return
117
+ }
118
+ const command = editor.value.chain().focus().setTextSelection(selection).extendMarkRange('link')
119
+ if(href) command.setLink({ href }).run()
120
+ else command.unsetLink().run()
121
+ showLink.value = false
122
+ }
123
+ defineExpose({ editor })
124
+ </script>
@@ -0,0 +1,59 @@
1
+ <template lang='pug'>
2
+ div(:class="[{ 'input-error': hasError }, props.ui?.root]" @input="error = null" @change="error = null")
3
+ slot(:error='error' :clear='() => error = null')
4
+ Transition(name='input-error-message')
5
+ div(v-if='hasError')
6
+ slot(name='message' :error='error')
7
+ p.mt-1.text-xs(:id='messageId' role='alert' :class="props.ui?.message ?? 'text-error'") {{ error }}
8
+ </template>
9
+
10
+ <script setup lang='ts'>
11
+ const props = defineProps<{
12
+ ui?: Partial<Record<'root' | 'message', string>>,
13
+ messageId?: string
14
+ }>()
15
+
16
+ const error = defineModel<string | null>('error', { default: null })
17
+ const hasError = computed(() => typeof error.value === 'string' && error.value.length > 0)
18
+ </script>
19
+
20
+ <style scoped>
21
+ .input-error :deep(input) {
22
+ border-color: var(--ui-error) !important;
23
+ border-width: 1px !important;
24
+ box-shadow: 0 0 0 1px var(--ui-error);
25
+ }
26
+
27
+ .input-error :deep([data-input-root]),
28
+ .input-error :deep(input:not([data-input-root] input)) {
29
+ animation: input-error-shake 280ms cubic-bezier(.36, .07, .19, .97);
30
+ }
31
+
32
+ .input-error-message-enter-active,
33
+ .input-error-message-leave-active {
34
+ transition: opacity 160ms ease, transform 160ms ease;
35
+ }
36
+
37
+ .input-error-message-enter-from,
38
+ .input-error-message-leave-to {
39
+ opacity: 0;
40
+ transform: translateY(-.25rem);
41
+ }
42
+
43
+ @keyframes input-error-shake {
44
+ 15%, 85% { transform: translateX(-.2rem); }
45
+ 30%, 70% { transform: translateX(.2rem); }
46
+ 45%, 55% { transform: translateX(-.1rem); }
47
+ }
48
+
49
+ @media (prefers-reduced-motion: reduce) {
50
+ .input-error :deep([data-input-root]),
51
+ .input-error :deep(input),
52
+ .input-error-message-enter-active,
53
+ .input-error-message-leave-active {
54
+ animation: none;
55
+ transition: none;
56
+ }
57
+ }
58
+ </style>
59
+
@@ -0,0 +1,43 @@
1
+ <template lang='pug'>
2
+ div.relative.w-full(data-input-root :class='props.ui?.root')
3
+ div.absolute.inset-y-0.left-3.flex.items-center(v-if='props.icon || $slots.leading' :class="props.ui?.leading ?? 'text-muted'")
4
+ slot(name='leading')
5
+ Icon.pointer-events-none.size-4(v-if='props.icon' :name='props.icon')
6
+ input.h-8.w-full.rounded-md.border.px-3.text-sm.outline-none.transition-colors(
7
+ v-bind='mergeProps(languageAttributes, $attrs)'
8
+ v-model='modelValue'
9
+ v-maska
10
+ :data-maska='props.vMask'
11
+ :class="[props.ui?.input ?? 'border-accented bg-default text-default placeholder:text-dimmed focus:border-current focus:ring-1 focus:ring-current', { 'pl-9': props.icon || $slots.leading, 'pr-9': $slots.trailing }]"
12
+ class='disabled:cursor-not-allowed disabled:opacity-50'
13
+ )
14
+ div.absolute.inset-y-0.right-3.flex.items-center(v-if='$slots.trailing' :class="props.ui?.trailing ?? 'text-muted'")
15
+ slot(name='trailing')
16
+ </template>
17
+
18
+ <script setup lang='ts'>
19
+ import { mergeProps } from 'vue'
20
+ import { vMaska } from 'maska/vue'
21
+
22
+ const { $languages } = useNuxtApp()
23
+
24
+ defineOptions({ inheritAttrs: false })
25
+ const props = defineProps<{
26
+ icon?: string,
27
+ vMask?: string,
28
+ languageKey?: string,
29
+ ui?: Partial<Record<'root' | 'input' | 'leading' | 'trailing', string>>
30
+ }>()
31
+ const modelValue = defineModel<string>({ default: '' })
32
+
33
+ const languageAttributes = computed<Record<string, unknown>>(() => {
34
+ if(!props.languageKey) return {}
35
+ let attributes: unknown = $languages.items[$languages.value]?.input
36
+ for(const key of props.languageKey.split('.').filter(Boolean)) {
37
+ if(attributes == null || typeof attributes !== 'object' || Array.isArray(attributes)) return {}
38
+ attributes = (attributes as Record<string, unknown>)[key]
39
+ }
40
+ return attributes != null && typeof attributes === 'object' && !Array.isArray(attributes)
41
+ ? attributes as Record<string, unknown> : {}
42
+ })
43
+ </script>
@@ -0,0 +1,10 @@
1
+ <template lang='pug'>
2
+ USelect(size='md' :placeholder='$languages.items[$languages.value].components.common.select' v-bind='$attrs')
3
+ template(v-for='(_, name) in $slots' #[name]='scope')
4
+ slot(:name='name' v-bind='scope || {}')
5
+ </template>
6
+
7
+ <script setup lang='ts'>
8
+ const { $languages } = useNuxtApp()
9
+ defineOptions({ inheritAttrs: false })
10
+ </script>
@@ -0,0 +1,54 @@
1
+ <template lang='pug'>
2
+ div.w-full
3
+ div.flex.w-full.items-center.justify-between.gap-3(:class='props.ui?.header')
4
+ div.flex.min-w-0.items-center.gap-2
5
+ CheckBox(v-model='isAllAgreed' :disabled='props.disabled' :aria-label='(props.label ?? $languages.items[$languages.value].components.terms.label)')
6
+ button.cursor-pointer.text-left.text-sm.font-medium.paperlogy(type='button' :disabled='props.disabled' class='disabled:cursor-not-allowed disabled:opacity-50' @click='toggleAll') {{ (props.label ?? $languages.items[$languages.value].components.terms.label) }}
7
+ button.flex.size-8.shrink-0.cursor-pointer.items-center.justify-center.rounded-md(type='button' :aria-expanded='expanded' :aria-controls='listId' :aria-label='$languages.items[$languages.value].components.terms.toggle' :class="props.ui?.toggle ?? 'text-muted hover:bg-muted'" @click='expanded = !expanded')
8
+ Icon.size-4.transition-transform(name='i-lucide-chevron-down' :class="{ 'rotate-180': expanded }")
9
+ div.grid.transition-all.duration-300(:class="expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'" class='motion-reduce:transition-none' :inert='!expanded')
10
+ div.min-h-0.overflow-hidden(:id='listId')
11
+ div.mt-2.flex.w-full.flex-col.gap-2.pl-6
12
+ div.flex.w-full.items-center.justify-between.gap-3(v-for='term in items' :key='term.id' :class='props.ui?.item')
13
+ slot(:term='term' :toggle='() => toggleTerm(term)' :disabled='props.disabled')
14
+ div.flex.min-w-0.items-center.gap-2
15
+ CheckBox(:aria-label='term.name' :disabled='props.disabled' :model-value='term.isAgreed' @update:model-value='toggleTerm(term)')
16
+ button.cursor-pointer.text-left.text-sm.paperlogy(type='button' :disabled='props.disabled' class='disabled:cursor-not-allowed disabled:opacity-50' @click='toggleTerm(term)')
17
+ span.mr-1.font-medium(:class="term.isRequired ? (props.ui?.required ?? 'text-default') : (props.ui?.optional ?? 'text-muted')") [{{ term.isRequired ? (props.requiredLabel ?? $languages.items[$languages.value].components.terms.required) : (props.optionalLabel ?? $languages.items[$languages.value].components.terms.optional) }}]
18
+ | {{ term.name }}
19
+ slot(name='link' :term='term')
20
+ a.shrink-0.cursor-pointer.text-xs.transition-colors(v-if='term.uri' :href='term.uri' target='_blank' rel='noopener noreferrer' :class="props.ui?.link ?? 'text-muted hover:text-default'") {{ (props.linkLabel ?? $languages.items[$languages.value].components.terms.link) }}
21
+ </template>
22
+
23
+ <script setup lang='ts'>
24
+ const { $languages } = useNuxtApp()
25
+ const props = withDefaults(defineProps<{
26
+ label?: string,
27
+ linkLabel?: string,
28
+ requiredLabel?: string,
29
+ optionalLabel?: string,
30
+ disabled?: boolean,
31
+ ui?: Partial<Record<'header' | 'toggle' | 'item' | 'required' | 'optional' | 'link', string>>
32
+ }>(), { disabled: false })
33
+ const items = defineModel<Array<{ id: string, name: string, uri?: string, isRequired: boolean, isAgreed: boolean }>>('items', { required: true })
34
+ const expanded = ref(true)
35
+ const listId = useId()
36
+ const isAllAgreed = computed({
37
+ get: () => items.value.length > 0 && items.value.every(term => term.isAgreed),
38
+ set: (value: boolean) => {
39
+ if(props.disabled) return
40
+ items.value = items.value.map(term => ({ ...term, isAgreed: value }))
41
+ }
42
+ })
43
+
44
+ watch(isAllAgreed, value => { expanded.value = !value })
45
+
46
+ function toggleAll () {
47
+ isAllAgreed.value = !isAllAgreed.value
48
+ }
49
+
50
+ function toggleTerm (target: typeof items.value[number]) {
51
+ if(props.disabled) return
52
+ items.value = items.value.map(term => term.id == target.id ? { ...term, isAgreed: !term.isAgreed } : term)
53
+ }
54
+ </script>
@@ -0,0 +1,29 @@
1
+ <template lang='pug'>
2
+ button.relative.inline-flex.shrink-0.cursor-pointer.items-center.rounded-full.transition-colors(
3
+ type='button'
4
+ role='switch'
5
+ :aria-checked='modelValue'
6
+ :disabled='props.disabled'
7
+ :class="[modelValue ? (props.ui?.checked ?? 'bg-primary') : (props.ui?.unchecked ?? 'bg-accented'), props.size == 'sm' ? 'h-5 w-9' : 'h-6 w-11']"
8
+ class='p-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:opacity-50'
9
+ @click='toggle'
10
+ )
11
+ span.rounded-full.shadow-sm.transition-transform(:class="[props.ui?.thumb ?? 'bg-white', props.size == 'sm' ? 'size-4' : 'size-5', modelValue ? (props.size == 'sm' ? 'translate-x-4' : 'translate-x-5') : 'translate-x-0']" class='motion-reduce:transition-none')
12
+ </template>
13
+
14
+ <script setup lang='ts'>
15
+ const props = withDefaults(defineProps<{
16
+ size?: 'sm' | 'md',
17
+ disabled?: boolean,
18
+ ui?: Partial<Record<'checked' | 'unchecked' | 'thumb', string>>
19
+ }>(), { size: 'md', disabled: false })
20
+ const modelValue = defineModel<boolean>({ default: false })
21
+ const emit = defineEmits<{ change: [change: { before: boolean, after: boolean }] }>()
22
+
23
+ function toggle () {
24
+ if(props.disabled) return
25
+ const before = modelValue.value
26
+ modelValue.value = !before
27
+ emit('change', { before: before, after: modelValue.value })
28
+ }
29
+ </script>