@karibsen/eponyme 0.7.0 → 0.8.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/README.md +59 -7
- package/dist/module.d.mts +15 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +5 -0
- package/dist/runtime/components/fields/FieldRenderer.vue +11 -0
- package/dist/runtime/components/fields/RichTextField.vue +37 -1
- package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
- package/dist/runtime/components/fields/TagsField.vue +55 -0
- package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
- package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
- package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
- package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
- package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
- package/dist/runtime/components/ui/EPDialog.vue +6 -2
- package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
- package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
- package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
- package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
- package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
- package/dist/runtime/composables/useEponymeCollection.js +29 -4
- package/dist/runtime/fields/index.d.ts +2 -0
- package/dist/runtime/fields/index.js +2 -1
- package/dist/runtime/fields/tags.d.ts +6 -0
- package/dist/runtime/fields/tags.js +3 -0
- package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
- package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
- package/dist/runtime/server/services/eponyme-form-store.js +2 -2
- package/dist/runtime/server/services/eponyme-store.d.ts +278 -30
- package/dist/runtime/server/services/eponyme-store.js +373 -71
- package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
- package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
- package/dist/runtime/types/eponyme.d.ts +53 -2
- package/dist/runtime/types/field.d.ts +35 -2
- package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
- package/dist/runtime/utils/eponyme-entry-index.js +51 -0
- package/dist/runtime/utils/get-field-default-value.js +2 -0
- package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
- package/dist/runtime/utils/{normalize-eponyme-phones.js → normalize-eponyme-values.js} +4 -2
- package/dist/runtime/utils/normalize-tags.d.ts +10 -0
- package/dist/runtime/utils/normalize-tags.js +17 -0
- package/dist/runtime/utils/validate-eponyme-data.js +16 -0
- package/dist/runtime/utils/variables.d.ts +4 -0
- package/dist/runtime/utils/variables.js +8 -1
- package/package.json +3 -2
- package/dist/runtime/utils/normalize-eponyme-phones.d.ts +0 -9
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Define your content in `eponyme.config.ts`. Eponyme provides defaults, validatio
|
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
|
-
- Type-safe fields for text, slugs, rich text, numbers, booleans, images, links, phone numbers, dates, colors, sections, tabs, and arrays
|
|
16
|
+
- Type-safe fields for text, slugs, rich text, numbers, booleans, images, links, phone numbers, tags, dates, colors, sections, tabs, and arrays
|
|
17
17
|
- Declarative defaults and validation
|
|
18
18
|
- Conditional fields, character counters, and sortable arrays
|
|
19
19
|
- Private drafts with explicit publishing
|
|
@@ -169,6 +169,26 @@ field.string({
|
|
|
169
169
|
})
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
+
### Tags
|
|
173
|
+
|
|
174
|
+
`field.tags()` holds a short list of values, with suggestions and optional free entry:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
tags: field.tags({
|
|
178
|
+
suggestions: ['Nuxt', 'Vue', 'TypeScript'],
|
|
179
|
+
allowCustom: true,
|
|
180
|
+
maxItems: 5,
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The value type follows the options: closed, it is `('Nuxt' | 'Vue' | 'TypeScript')[]`; with
|
|
185
|
+
`allowCustom`, `string[]`. One array never mixes two types.
|
|
186
|
+
|
|
187
|
+
The list is normalised **on the server** before the write: trimmed, blanks dropped, duplicates
|
|
188
|
+
folded case-insensitively. A suggestion imposes its spelling, so typing `nuxt` beside a declared
|
|
189
|
+
`Nuxt` yields `Nuxt` and a listing never splits in two. `minItems` and `maxItems` count what is
|
|
190
|
+
left after that folding.
|
|
191
|
+
|
|
172
192
|
### Phone numbers
|
|
173
193
|
|
|
174
194
|
`field.phone()` stores its value in **E.164** — `+33611131143` — whatever format it was typed
|
|
@@ -224,17 +244,49 @@ Add the required models to your Prisma schema:
|
|
|
224
244
|
|
|
225
245
|
```prisma
|
|
226
246
|
model Eponyme {
|
|
227
|
-
name
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
247
|
+
name String @id
|
|
248
|
+
draft Json @db.JsonB
|
|
249
|
+
published Json @db.JsonB
|
|
250
|
+
status String
|
|
251
|
+
publishedAt DateTime?
|
|
252
|
+
createdAt DateTime @default(now())
|
|
253
|
+
updatedAt DateTime @updatedAt
|
|
254
|
+
deletedAt DateTime?
|
|
255
|
+
versions EponymeVersion[]
|
|
256
|
+
index EponymeEntryIndex[]
|
|
233
257
|
|
|
234
258
|
@@index([deletedAt])
|
|
259
|
+
@@index([publishedAt])
|
|
235
260
|
@@map("eponyme_entries")
|
|
236
261
|
}
|
|
237
262
|
|
|
263
|
+
/// Filterable values pulled out of the content columns, so a listing can look them up
|
|
264
|
+
/// instead of loading and normalizing every entry of a collection to compare them.
|
|
265
|
+
/// Rewritten for an entry inside the same transaction as the write that changed it.
|
|
266
|
+
model EponymeEntryIndex {
|
|
267
|
+
entryName String
|
|
268
|
+
/// "draft" or "published": the two disagree, and a public listing must only see one.
|
|
269
|
+
version String
|
|
270
|
+
key String
|
|
271
|
+
/// Case-folded, so a lookup never has to guess which spelling was stored first.
|
|
272
|
+
value String
|
|
273
|
+
entry Eponyme @relation(fields: [entryName], references: [name], onDelete: Cascade)
|
|
274
|
+
|
|
275
|
+
@@id([entryName, version, key, value])
|
|
276
|
+
@@index([key, version, value])
|
|
277
|
+
@@map("eponyme_entry_index")
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// Remembers which filterable schema each collection was last indexed against, so the
|
|
281
|
+
/// module can rebuild only what a config change actually invalidated.
|
|
282
|
+
model EponymeIndexState {
|
|
283
|
+
name String @id
|
|
284
|
+
fingerprint String
|
|
285
|
+
updatedAt DateTime @updatedAt
|
|
286
|
+
|
|
287
|
+
@@map("eponyme_index_state")
|
|
288
|
+
}
|
|
289
|
+
|
|
238
290
|
model EponymeVersion {
|
|
239
291
|
id Int @id @default(autoincrement())
|
|
240
292
|
entryName String
|
package/dist/module.d.mts
CHANGED
|
@@ -97,6 +97,21 @@ interface ModuleOptions {
|
|
|
97
97
|
* @default 5
|
|
98
98
|
*/
|
|
99
99
|
cacheSeconds?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Whether the filterable index is rebuilt at startup when the configuration that produced
|
|
102
|
+
* it has changed.
|
|
103
|
+
*
|
|
104
|
+
* @remarks
|
|
105
|
+
* Leave it on. Ordinary writes keep the index current, but a field that becomes filterable
|
|
106
|
+
* leaves the entries already stored without a row for it, and a filter on them then answers
|
|
107
|
+
* "none" instead of failing — a wrong listing rather than an error.
|
|
108
|
+
*
|
|
109
|
+
* Turning it off is an escape hatch: if a rebuild ever fails on every boot, this gets the
|
|
110
|
+
* application up again so `reindexEponymeEntries()` can be run by hand.
|
|
111
|
+
*
|
|
112
|
+
* @default true
|
|
113
|
+
*/
|
|
114
|
+
autoReindex?: boolean;
|
|
100
115
|
/**
|
|
101
116
|
* How long a browser may reuse published content, in seconds. `0` disables it.
|
|
102
117
|
*
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -30,6 +30,7 @@ const module$1 = defineNuxtModule({
|
|
|
30
30
|
sessionDurationDays: 7
|
|
31
31
|
},
|
|
32
32
|
cacheSeconds: 5,
|
|
33
|
+
autoReindex: true,
|
|
33
34
|
browserCacheSeconds: 30,
|
|
34
35
|
cdnCacheSeconds: 300
|
|
35
36
|
},
|
|
@@ -89,6 +90,7 @@ const module$1 = defineNuxtModule({
|
|
|
89
90
|
};
|
|
90
91
|
nuxt.options.runtimeConfig.eponymeContent = {
|
|
91
92
|
cacheSeconds: options.cacheSeconds ?? 5,
|
|
93
|
+
autoReindex: options.autoReindex ?? true,
|
|
92
94
|
browserCacheSeconds: options.browserCacheSeconds ?? 30,
|
|
93
95
|
cdnCacheSeconds: options.cdnCacheSeconds ?? 300
|
|
94
96
|
};
|
|
@@ -126,6 +128,9 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
|
|
|
126
128
|
addImportsDir(resolver.resolve("./runtime/composables"));
|
|
127
129
|
addServerImports([
|
|
128
130
|
{ name: "getEponymeSitemapEntries", from: resolver.resolve("./runtime/server/utils/eponyme-sitemap") },
|
|
131
|
+
// The backfill for the filterable index, which a migration cannot do on its own:
|
|
132
|
+
// reading a value out of the JSONB envelope needs the configured schema.
|
|
133
|
+
{ name: "reindexEponymeEntries", from: resolver.resolve("./runtime/server/utils/eponyme-reindex") },
|
|
129
134
|
// A `custom` form posts to the host application's own route, so this is what
|
|
130
135
|
// keeps server-side validation as the security boundary.
|
|
131
136
|
{ name: "validateEponymeForm", from: resolver.resolve("./runtime/server/utils/eponyme-form") },
|
|
@@ -10,6 +10,7 @@ import RadioField from "./RadioField.vue";
|
|
|
10
10
|
import RichTextField from "./RichTextField.vue";
|
|
11
11
|
import SelectField from "./SelectField.vue";
|
|
12
12
|
import SlugField from "./SlugField.vue";
|
|
13
|
+
import TagsField from "./TagsField.vue";
|
|
13
14
|
import TextareaField from "./TextareaField.vue";
|
|
14
15
|
import TextField from "./TextField.vue";
|
|
15
16
|
import UrlField from "./UrlField.vue";
|
|
@@ -62,6 +63,8 @@ const component = computed(() => {
|
|
|
62
63
|
return UrlField;
|
|
63
64
|
case "phone":
|
|
64
65
|
return PhoneField;
|
|
66
|
+
case "tags":
|
|
67
|
+
return TagsField;
|
|
65
68
|
default:
|
|
66
69
|
return TextField;
|
|
67
70
|
}
|
|
@@ -81,6 +84,14 @@ const specificProps = computed(() => {
|
|
|
81
84
|
case "radio":
|
|
82
85
|
case "checkboxGroup":
|
|
83
86
|
return { options: field.options.options };
|
|
87
|
+
case "tags":
|
|
88
|
+
return {
|
|
89
|
+
suggestions: field.options.suggestions,
|
|
90
|
+
allowCustom: field.options.allowCustom,
|
|
91
|
+
minItems: field.options.minItems,
|
|
92
|
+
maxItems: field.options.maxItems,
|
|
93
|
+
placeholder: field.options.placeholder
|
|
94
|
+
};
|
|
84
95
|
case "select":
|
|
85
96
|
return { options: field.options.options, placeholder: field.options.placeholder };
|
|
86
97
|
case "number":
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import Image from "@tiptap/extension-image";
|
|
3
3
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
4
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
5
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
4
6
|
import StarterKit from "@tiptap/starter-kit";
|
|
5
7
|
import { EditorContent, Extension, useEditor } from "@tiptap/vue-3";
|
|
6
8
|
import { computed, ref, watch } from "vue";
|
|
7
9
|
import { useEponymeVariables } from "../../composables/useEponymeVariables";
|
|
10
|
+
import { findEponymeVariableRanges } from "../../utils/variables";
|
|
8
11
|
import EPDropdownMenu from "../ui/EPDropdownMenu.vue";
|
|
9
12
|
import EPFormField from "../ui/EPFormField.vue";
|
|
10
13
|
import EPLinkDialog from "../ui/EPLinkDialog.vue";
|
|
@@ -23,6 +26,38 @@ const DownloadableLinkAttribute = Extension.create({
|
|
|
23
26
|
}];
|
|
24
27
|
}
|
|
25
28
|
});
|
|
29
|
+
const variableHighlightKey = new PluginKey("eponymeVariableHighlight");
|
|
30
|
+
function createVariableDecorations(document) {
|
|
31
|
+
const decorations = [];
|
|
32
|
+
document.descendants((node, position) => {
|
|
33
|
+
if (!node.isText || !node.text) return;
|
|
34
|
+
for (const range of findEponymeVariableRanges(node.text)) {
|
|
35
|
+
decorations.push(Decoration.inline(
|
|
36
|
+
position + range.from,
|
|
37
|
+
position + range.to,
|
|
38
|
+
{ class: "eponyme-rich-text-variable" }
|
|
39
|
+
));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return DecorationSet.create(document, decorations);
|
|
43
|
+
}
|
|
44
|
+
const EponymeVariableHighlight = Extension.create({
|
|
45
|
+
name: "eponymeVariableHighlight",
|
|
46
|
+
addProseMirrorPlugins() {
|
|
47
|
+
return [
|
|
48
|
+
new Plugin({
|
|
49
|
+
key: variableHighlightKey,
|
|
50
|
+
state: {
|
|
51
|
+
init: (_, state) => createVariableDecorations(state.doc),
|
|
52
|
+
apply: (transaction, decorations) => transaction.docChanged ? createVariableDecorations(transaction.doc) : decorations
|
|
53
|
+
},
|
|
54
|
+
props: {
|
|
55
|
+
decorations: (state) => variableHighlightKey.getState(state)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
});
|
|
26
61
|
const props = defineProps({
|
|
27
62
|
id: { type: String, required: true },
|
|
28
63
|
modelValue: { type: null, required: false },
|
|
@@ -55,6 +90,7 @@ const editor = useEditor({
|
|
|
55
90
|
}
|
|
56
91
|
}),
|
|
57
92
|
DownloadableLinkAttribute,
|
|
93
|
+
EponymeVariableHighlight,
|
|
58
94
|
Placeholder.configure({ placeholder: props.placeholder }),
|
|
59
95
|
Image.configure({ HTMLAttributes: { class: "eponyme-rich-text-image" } })
|
|
60
96
|
],
|
|
@@ -244,5 +280,5 @@ const tools = computed(() => {
|
|
|
244
280
|
</template>
|
|
245
281
|
|
|
246
282
|
<style scoped>
|
|
247
|
-
.rich-text-tool{align-items:center;background:transparent;border:0;border-radius:.45rem;color:var(--ep-color-muted-ep,#8d8d8d);cursor:pointer;display:flex;font-family:inherit;font-size:.75rem;font-weight:650;height:2rem;justify-content:center;min-width:2rem;padding:0 .55rem;transition:color .15s ease,background-color .15s ease}.rich-text-tool.is-active,.rich-text-tool:hover{background:var(--ep-color-theme-ep,#1c1c1c);color:#fff}.rich-text-tool:disabled{cursor:not-allowed;opacity:.35}.eponyme-rich-text :deep(.tiptap){color:var(--ep-color-text-ep,#e7e7e7);font-size:.925rem;line-height:1.75;min-height:18rem;outline:none;padding:1.25rem}.eponyme-rich-text :deep(.tiptap>:first-child){margin-top:0}.eponyme-rich-text :deep(.tiptap>:last-child){margin-bottom:0}.eponyme-rich-text :deep(.tiptap h2){font-size:1.5rem;line-height:1.25;margin:1.5rem 0 .75rem}.eponyme-rich-text :deep(.tiptap h3){font-size:1.2rem;line-height:1.3;margin:1.25rem 0 .625rem}.eponyme-rich-text :deep(.tiptap blockquote),.eponyme-rich-text :deep(.tiptap ol),.eponyme-rich-text :deep(.tiptap p),.eponyme-rich-text :deep(.tiptap ul){margin:.75rem 0}.eponyme-rich-text :deep(.tiptap ol),.eponyme-rich-text :deep(.tiptap ul){padding-left:1.5rem}.eponyme-rich-text :deep(.tiptap blockquote){border-left:2px solid var(--ep-color-muted-ep,#8d8d8d);color:var(--ep-color-muted-ep,#8d8d8d);padding-left:1rem}.eponyme-rich-text :deep(.tiptap img){border-radius:.75rem;height:auto;max-width:100%}.eponyme-rich-text :deep(.tiptap img.ProseMirror-selectednode){outline:2px solid #fff;outline-offset:2px}.eponyme-rich-text :deep(.tiptap a){color:#fff;text-decoration:underline;text-underline-offset:3px}.eponyme-rich-text :deep(.tiptap p.is-editor-empty:first-child:before){color:var(--ep-color-muted-ep,#8d8d8d);content:attr(data-placeholder);float:left;height:0;pointer-events:none}
|
|
283
|
+
.rich-text-tool{align-items:center;background:transparent;border:0;border-radius:.45rem;color:var(--ep-color-muted-ep,#8d8d8d);cursor:pointer;display:flex;font-family:inherit;font-size:.75rem;font-weight:650;height:2rem;justify-content:center;min-width:2rem;padding:0 .55rem;transition:color .15s ease,background-color .15s ease}.rich-text-tool.is-active,.rich-text-tool:hover{background:var(--ep-color-theme-ep,#1c1c1c);color:#fff}.rich-text-tool:disabled{cursor:not-allowed;opacity:.35}.eponyme-rich-text :deep(.tiptap){color:var(--ep-color-text-ep,#e7e7e7);font-size:.925rem;line-height:1.75;min-height:18rem;outline:none;padding:1.25rem}.eponyme-rich-text :deep(.tiptap>:first-child){margin-top:0}.eponyme-rich-text :deep(.tiptap>:last-child){margin-bottom:0}.eponyme-rich-text :deep(.tiptap h2){font-size:1.5rem;line-height:1.25;margin:1.5rem 0 .75rem}.eponyme-rich-text :deep(.tiptap h3){font-size:1.2rem;line-height:1.3;margin:1.25rem 0 .625rem}.eponyme-rich-text :deep(.tiptap blockquote),.eponyme-rich-text :deep(.tiptap ol),.eponyme-rich-text :deep(.tiptap p),.eponyme-rich-text :deep(.tiptap ul){margin:.75rem 0}.eponyme-rich-text :deep(.tiptap ol),.eponyme-rich-text :deep(.tiptap ul){padding-left:1.5rem}.eponyme-rich-text :deep(.tiptap blockquote){border-left:2px solid var(--ep-color-muted-ep,#8d8d8d);color:var(--ep-color-muted-ep,#8d8d8d);padding-left:1rem}.eponyme-rich-text :deep(.tiptap img){border-radius:.75rem;height:auto;max-width:100%}.eponyme-rich-text :deep(.tiptap img.ProseMirror-selectednode){outline:2px solid #fff;outline-offset:2px}.eponyme-rich-text :deep(.tiptap a){color:#fff;text-decoration:underline;text-underline-offset:3px}.eponyme-rich-text :deep(.eponyme-rich-text-variable){color:#ff41c5;font-weight:650}@supports (((-webkit-background-clip:text) or (background-clip:text)) or (-webkit-background-clip:text)){.eponyme-rich-text :deep(.eponyme-rich-text-variable){background-clip:text;-webkit-background-clip:text;background-image:linear-gradient(90deg,#fe7af0,#ff41c5);background-position:0 50%;background-size:200% 100%;color:transparent;-webkit-text-fill-color:transparent}}@media (prefers-reduced-motion:no-preference){@supports (((-webkit-background-clip:text) or (background-clip:text)) or (-webkit-background-clip:text)){.eponyme-rich-text :deep(.eponyme-rich-text-variable){animation:eponyme-variable-gradient 2.5s ease-in-out infinite alternate}}}@keyframes eponyme-variable-gradient{to{background-position:100% 50%}}@media (forced-colors:active){.eponyme-rich-text :deep(.eponyme-rich-text-variable){background:none;color:linktext;text-decoration:underline;-webkit-text-fill-color:currentcolor}}.eponyme-rich-text :deep(.tiptap p.is-editor-empty:first-child:before){color:var(--ep-color-muted-ep,#8d8d8d);content:attr(data-placeholder);float:left;height:0;pointer-events:none}
|
|
248
284
|
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
id: string;
|
|
3
|
+
modelValue?: unknown;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
suggestions?: readonly string[];
|
|
8
|
+
allowCustom?: boolean;
|
|
9
|
+
minItems?: number;
|
|
10
|
+
maxItems?: number;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
errors?: string[];
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
16
|
+
"update:modelValue": (value: string[]) => any;
|
|
17
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
18
|
+
"onUpdate:modelValue"?: ((value: string[]) => any) | undefined;
|
|
19
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
20
|
+
declare const _default: typeof __VLS_export;
|
|
21
|
+
export default _default;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import EPFormField from "../ui/EPFormField.vue";
|
|
4
|
+
import EPTagsInput from "../ui/EPTagsInput.vue";
|
|
5
|
+
import { normalizeEponymeTags } from "../../utils/normalize-tags";
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
id: { type: String, required: true },
|
|
8
|
+
modelValue: { type: null, required: false },
|
|
9
|
+
label: { type: String, required: true },
|
|
10
|
+
description: { type: String, required: false },
|
|
11
|
+
required: { type: Boolean, required: false },
|
|
12
|
+
suggestions: { type: Array, required: false },
|
|
13
|
+
allowCustom: { type: Boolean, required: false },
|
|
14
|
+
minItems: { type: Number, required: false },
|
|
15
|
+
maxItems: { type: Number, required: false },
|
|
16
|
+
placeholder: { type: String, required: false },
|
|
17
|
+
errors: { type: Array, required: false, default: () => [] },
|
|
18
|
+
disabled: { type: Boolean, required: false }
|
|
19
|
+
});
|
|
20
|
+
const emit = defineEmits(["update:modelValue"]);
|
|
21
|
+
const tags = computed(() => Array.isArray(props.modelValue) ? props.modelValue.filter((tag) => typeof tag === "string") : []);
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<EPFormField
|
|
26
|
+
:id="id"
|
|
27
|
+
:label="label"
|
|
28
|
+
:description="description"
|
|
29
|
+
:required="required"
|
|
30
|
+
:errors="errors"
|
|
31
|
+
>
|
|
32
|
+
<ClientOnly>
|
|
33
|
+
<EPTagsInput
|
|
34
|
+
:model-value="tags"
|
|
35
|
+
:suggestions="suggestions"
|
|
36
|
+
:allow-custom="allowCustom"
|
|
37
|
+
:placeholder="placeholder"
|
|
38
|
+
:max="maxItems"
|
|
39
|
+
:required="required"
|
|
40
|
+
:disabled="disabled"
|
|
41
|
+
:invalid="Boolean(errors.length)"
|
|
42
|
+
@update:model-value="emit('update:modelValue', normalizeEponymeTags($event, { suggestions, allowCustom }))"
|
|
43
|
+
/>
|
|
44
|
+
<template #fallback>
|
|
45
|
+
<div class="ep:min-h-12 ep:rounded-xl ep:border ep:border-border-ep ep:bg-selected-ep" />
|
|
46
|
+
</template>
|
|
47
|
+
</ClientOnly>
|
|
48
|
+
<p
|
|
49
|
+
v-if="maxItems !== void 0"
|
|
50
|
+
class="ep:mt-1.5 ep:mb-0 ep:text-right ep:text-[11px] ep:text-muted-ep"
|
|
51
|
+
>
|
|
52
|
+
{{ tags.length }} / {{ maxItems }}
|
|
53
|
+
</p>
|
|
54
|
+
</EPFormField>
|
|
55
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
id: string;
|
|
3
|
+
modelValue?: unknown;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
suggestions?: readonly string[];
|
|
8
|
+
allowCustom?: boolean;
|
|
9
|
+
minItems?: number;
|
|
10
|
+
maxItems?: number;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
errors?: string[];
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
16
|
+
"update:modelValue": (value: string[]) => any;
|
|
17
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
18
|
+
"onUpdate:modelValue"?: ((value: string[]) => any) | undefined;
|
|
19
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
20
|
+
declare const _default: typeof __VLS_export;
|
|
21
|
+
export default _default;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
open?: boolean;
|
|
3
|
+
label: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
cancelLabel?: string;
|
|
6
|
+
confirmLabel?: string;
|
|
7
|
+
confirmVariant?: 'primary' | 'secondary' | 'danger';
|
|
8
|
+
confirmLoading?: boolean;
|
|
9
|
+
confirmDisabled?: boolean;
|
|
10
|
+
closeOnConfirm?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type __VLS_Slots = {
|
|
13
|
+
trigger?: () => unknown;
|
|
14
|
+
default?: () => unknown;
|
|
15
|
+
confirm?: (props: {
|
|
16
|
+
confirm: () => void;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
}) => unknown;
|
|
20
|
+
};
|
|
21
|
+
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
22
|
+
cancel: () => any;
|
|
23
|
+
"update:open": (value: boolean) => any;
|
|
24
|
+
confirm: () => any;
|
|
25
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
26
|
+
onCancel?: (() => any) | undefined;
|
|
27
|
+
"onUpdate:open"?: ((value: boolean) => any) | undefined;
|
|
28
|
+
onConfirm?: (() => any) | undefined;
|
|
29
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
30
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
31
|
+
declare const _default: typeof __VLS_export;
|
|
32
|
+
export default _default;
|
|
33
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
34
|
+
new (): {
|
|
35
|
+
$slots: S;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EPButton from "./EPButton.vue";
|
|
3
|
+
import EPDialog from "./EPDialog.vue";
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
open: { type: Boolean, required: false },
|
|
6
|
+
label: { type: String, required: true },
|
|
7
|
+
description: { type: String, required: false },
|
|
8
|
+
cancelLabel: { type: String, required: false, default: "Cancel" },
|
|
9
|
+
confirmLabel: { type: String, required: false, default: "Confirm" },
|
|
10
|
+
confirmVariant: { type: String, required: false, default: "primary" },
|
|
11
|
+
confirmLoading: { type: Boolean, required: false },
|
|
12
|
+
confirmDisabled: { type: Boolean, required: false },
|
|
13
|
+
closeOnConfirm: { type: Boolean, required: false, default: true }
|
|
14
|
+
});
|
|
15
|
+
const emit = defineEmits(["update:open", "cancel", "confirm"]);
|
|
16
|
+
defineSlots();
|
|
17
|
+
function cancel() {
|
|
18
|
+
emit("cancel");
|
|
19
|
+
emit("update:open", false);
|
|
20
|
+
}
|
|
21
|
+
function confirm() {
|
|
22
|
+
emit("confirm");
|
|
23
|
+
if (props.closeOnConfirm) emit("update:open", false);
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<EPDialog
|
|
29
|
+
:open="open"
|
|
30
|
+
:title="label"
|
|
31
|
+
:description="description"
|
|
32
|
+
role="alertdialog"
|
|
33
|
+
@update:open="emit('update:open', $event)"
|
|
34
|
+
>
|
|
35
|
+
<template
|
|
36
|
+
v-if="$slots.trigger"
|
|
37
|
+
#trigger
|
|
38
|
+
>
|
|
39
|
+
<slot name="trigger" />
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<slot />
|
|
43
|
+
|
|
44
|
+
<div class="ep:mt-6 ep:flex ep:justify-end ep:gap-2">
|
|
45
|
+
<EPButton
|
|
46
|
+
:label="cancelLabel"
|
|
47
|
+
variant="ghost"
|
|
48
|
+
@click="cancel"
|
|
49
|
+
/>
|
|
50
|
+
<slot
|
|
51
|
+
name="confirm"
|
|
52
|
+
:confirm="confirm"
|
|
53
|
+
:loading="confirmLoading"
|
|
54
|
+
:disabled="confirmDisabled"
|
|
55
|
+
>
|
|
56
|
+
<EPButton
|
|
57
|
+
:label="confirmLabel"
|
|
58
|
+
:variant="confirmVariant"
|
|
59
|
+
:loading="confirmLoading"
|
|
60
|
+
:disabled="confirmDisabled"
|
|
61
|
+
@click="confirm"
|
|
62
|
+
/>
|
|
63
|
+
</slot>
|
|
64
|
+
</div>
|
|
65
|
+
</EPDialog>
|
|
66
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
open?: boolean;
|
|
3
|
+
label: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
cancelLabel?: string;
|
|
6
|
+
confirmLabel?: string;
|
|
7
|
+
confirmVariant?: 'primary' | 'secondary' | 'danger';
|
|
8
|
+
confirmLoading?: boolean;
|
|
9
|
+
confirmDisabled?: boolean;
|
|
10
|
+
closeOnConfirm?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type __VLS_Slots = {
|
|
13
|
+
trigger?: () => unknown;
|
|
14
|
+
default?: () => unknown;
|
|
15
|
+
confirm?: (props: {
|
|
16
|
+
confirm: () => void;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
}) => unknown;
|
|
20
|
+
};
|
|
21
|
+
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
22
|
+
cancel: () => any;
|
|
23
|
+
"update:open": (value: boolean) => any;
|
|
24
|
+
confirm: () => any;
|
|
25
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
26
|
+
onCancel?: (() => any) | undefined;
|
|
27
|
+
"onUpdate:open"?: ((value: boolean) => any) | undefined;
|
|
28
|
+
onConfirm?: (() => any) | undefined;
|
|
29
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
30
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
31
|
+
declare const _default: typeof __VLS_export;
|
|
32
|
+
export default _default;
|
|
33
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
34
|
+
new (): {
|
|
35
|
+
$slots: S;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -4,7 +4,8 @@ import EPButton from "./EPButton.vue";
|
|
|
4
4
|
defineProps({
|
|
5
5
|
open: { type: Boolean, required: false },
|
|
6
6
|
title: { type: String, required: true },
|
|
7
|
-
description: { type: String, required: false }
|
|
7
|
+
description: { type: String, required: false },
|
|
8
|
+
role: { type: String, required: false }
|
|
8
9
|
});
|
|
9
10
|
const emit = defineEmits(["update:open"]);
|
|
10
11
|
</script>
|
|
@@ -22,7 +23,10 @@ const emit = defineEmits(["update:open"]);
|
|
|
22
23
|
</DialogTrigger>
|
|
23
24
|
<DialogPortal>
|
|
24
25
|
<DialogOverlay class="eponyme-dialog-overlay ep:fixed ep:inset-0 ep:z-50 ep:bg-black/65 ep:backdrop-blur-sm" />
|
|
25
|
-
<DialogContent
|
|
26
|
+
<DialogContent
|
|
27
|
+
:role="role"
|
|
28
|
+
class="eponyme-dialog-content eponyme-portal ep:fixed ep:top-1/2 ep:left-1/2 ep:z-50 ep:w-[min(32rem,calc(100vw-2rem))] ep:rounded-2xl ep:border-0 ep:bg-theme-ep ep:p-6 ep:font-sans ep:text-text-ep ep:outline-none"
|
|
29
|
+
>
|
|
26
30
|
<DialogTitle class="ep:text-xl ep:font-semibold ep:text-white">
|
|
27
31
|
{{ title }}
|
|
28
32
|
</DialogTitle>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chips plus a row of suggestion buttons, knowing nothing about Eponyme.
|
|
3
|
+
*
|
|
4
|
+
* The suggestions the developer configured are not a filtered autocomplete list: they're
|
|
5
|
+
* rendered as standing buttons below the input so an editor can see and click them directly,
|
|
6
|
+
* with no typing or listbox navigation required. `TagsInputRoot` still owns the chips, their
|
|
7
|
+
* keyboard handling and the delete buttons.
|
|
8
|
+
*/
|
|
9
|
+
type __VLS_Props = {
|
|
10
|
+
modelValue?: string[];
|
|
11
|
+
/** Rendered as clickable buttons below the input, minus the ones already picked. */
|
|
12
|
+
suggestions?: readonly string[];
|
|
13
|
+
/** Whether a value outside `suggestions` may be added. */
|
|
14
|
+
allowCustom?: boolean;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
max?: number;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
invalid?: boolean;
|
|
20
|
+
};
|
|
21
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
22
|
+
"update:modelValue": (value: string[]) => any;
|
|
23
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
24
|
+
"onUpdate:modelValue"?: ((value: string[]) => any) | undefined;
|
|
25
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
26
|
+
declare const _default: typeof __VLS_export;
|
|
27
|
+
export default _default;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
TagsInputItem,
|
|
5
|
+
TagsInputItemDelete,
|
|
6
|
+
TagsInputItemText,
|
|
7
|
+
TagsInputRoot
|
|
8
|
+
} from "reka-ui";
|
|
9
|
+
const props = defineProps({
|
|
10
|
+
modelValue: { type: Array, required: false, default: () => [] },
|
|
11
|
+
suggestions: { type: Array, required: false, default: () => [] },
|
|
12
|
+
allowCustom: { type: Boolean, required: false },
|
|
13
|
+
placeholder: { type: String, required: false },
|
|
14
|
+
max: { type: Number, required: false },
|
|
15
|
+
required: { type: Boolean, required: false },
|
|
16
|
+
disabled: { type: Boolean, required: false },
|
|
17
|
+
invalid: { type: Boolean, required: false }
|
|
18
|
+
});
|
|
19
|
+
const emit = defineEmits(["update:modelValue"]);
|
|
20
|
+
const search = ref("");
|
|
21
|
+
const available = computed(() => props.suggestions.filter((suggestion) => !props.modelValue.some((tag) => same(tag, suggestion))));
|
|
22
|
+
const full = computed(() => props.max !== void 0 && props.modelValue.length >= props.max);
|
|
23
|
+
function same(left, right) {
|
|
24
|
+
return left.trim().toLocaleLowerCase() === right.trim().toLocaleLowerCase();
|
|
25
|
+
}
|
|
26
|
+
function add(value) {
|
|
27
|
+
const tag = value.trim();
|
|
28
|
+
if (!tag || full.value) return;
|
|
29
|
+
const suggested = props.suggestions.find((suggestion) => same(suggestion, tag));
|
|
30
|
+
if (!suggested && !props.allowCustom) return;
|
|
31
|
+
if (props.modelValue.some((existing) => same(existing, tag))) return;
|
|
32
|
+
emit("update:modelValue", [...props.modelValue, suggested ?? tag]);
|
|
33
|
+
search.value = "";
|
|
34
|
+
}
|
|
35
|
+
function remove(tag) {
|
|
36
|
+
emit("update:modelValue", props.modelValue.filter((existing) => existing !== tag));
|
|
37
|
+
}
|
|
38
|
+
function onEnter(event) {
|
|
39
|
+
if (!search.value.trim()) return;
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
add(search.value);
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<div>
|
|
47
|
+
<TagsInputRoot
|
|
48
|
+
:model-value="modelValue"
|
|
49
|
+
:max="max"
|
|
50
|
+
:required="required"
|
|
51
|
+
:disabled="disabled"
|
|
52
|
+
:add-on-paste="false"
|
|
53
|
+
class="ep:flex ep:min-h-12 ep:w-full ep:flex-wrap ep:items-center ep:gap-1.5 ep:rounded-xl ep:border ep:border-border-ep ep:bg-selected-ep ep:px-2 ep:py-2 ep:transition ep:focus-within:border-muted-ep ep:focus-within:ring-2 ep:focus-within:ring-white/10 ep:aria-invalid:border-danger-ep"
|
|
54
|
+
:aria-invalid="invalid || void 0"
|
|
55
|
+
@remove-tag="remove"
|
|
56
|
+
>
|
|
57
|
+
<TagsInputItem
|
|
58
|
+
v-for="tag in modelValue"
|
|
59
|
+
:key="tag"
|
|
60
|
+
:value="tag"
|
|
61
|
+
class="ep:flex ep:items-center ep:gap-1 ep:rounded-lg ep:bg-white/10 ep:py-1 ep:pr-1 ep:pl-2.5 ep:text-sm ep:text-white"
|
|
62
|
+
>
|
|
63
|
+
<TagsInputItemText />
|
|
64
|
+
<TagsInputItemDelete
|
|
65
|
+
:aria-label="`Remove ${tag}`"
|
|
66
|
+
class="ep:flex ep:size-5 ep:cursor-pointer ep:items-center ep:justify-center ep:rounded-md ep:border-0 ep:bg-transparent ep:text-muted-ep ep:transition ep:hover:bg-white/10 ep:hover:text-white"
|
|
67
|
+
>
|
|
68
|
+
<Icon
|
|
69
|
+
name="mingcute:close-line"
|
|
70
|
+
class="ep:size-3.5"
|
|
71
|
+
aria-hidden="true"
|
|
72
|
+
/>
|
|
73
|
+
</TagsInputItemDelete>
|
|
74
|
+
</TagsInputItem>
|
|
75
|
+
|
|
76
|
+
<input
|
|
77
|
+
v-model="search"
|
|
78
|
+
:placeholder="full ? void 0 : placeholder"
|
|
79
|
+
:disabled="disabled || full"
|
|
80
|
+
class="ep:min-w-24 ep:flex-1 ep:border-0 ep:bg-transparent ep:px-2 ep:text-sm ep:text-white ep:outline-none ep:placeholder:text-muted-ep ep:disabled:cursor-not-allowed"
|
|
81
|
+
@keydown.enter="onEnter"
|
|
82
|
+
>
|
|
83
|
+
</TagsInputRoot>
|
|
84
|
+
|
|
85
|
+
<div
|
|
86
|
+
v-if="available.length"
|
|
87
|
+
class="ep:mt-2 ep:flex ep:flex-wrap ep:gap-1.5"
|
|
88
|
+
>
|
|
89
|
+
<button
|
|
90
|
+
v-for="suggestion in available"
|
|
91
|
+
:key="suggestion"
|
|
92
|
+
type="button"
|
|
93
|
+
:disabled="disabled || full"
|
|
94
|
+
class="ep:cursor-pointer ep:rounded-lg ep:border ep:border-border-ep ep:bg-transparent ep:px-2.5 ep:py-1 ep:text-sm ep:text-muted-ep ep:transition ep:hover:border-muted-ep ep:hover:text-white ep:disabled:cursor-not-allowed ep:disabled:opacity-50"
|
|
95
|
+
@click="add(suggestion)"
|
|
96
|
+
>
|
|
97
|
+
{{ suggestion }}
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|