@karibsen/eponyme 0.7.0 → 0.8.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 (46) hide show
  1. package/README.md +59 -7
  2. package/dist/module.d.mts +15 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +5 -0
  5. package/dist/runtime/components/fields/FieldRenderer.vue +11 -0
  6. package/dist/runtime/components/fields/RichTextField.vue +37 -1
  7. package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
  8. package/dist/runtime/components/fields/TagsField.vue +55 -0
  9. package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
  10. package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
  11. package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
  12. package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
  13. package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
  14. package/dist/runtime/components/ui/EPDialog.vue +6 -2
  15. package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
  16. package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
  17. package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
  18. package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
  19. package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
  20. package/dist/runtime/composables/useEponymeCollection.js +29 -4
  21. package/dist/runtime/fields/index.d.ts +2 -0
  22. package/dist/runtime/fields/index.js +2 -1
  23. package/dist/runtime/fields/tags.d.ts +6 -0
  24. package/dist/runtime/fields/tags.js +3 -0
  25. package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
  26. package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
  27. package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
  28. package/dist/runtime/server/services/eponyme-form-store.js +2 -2
  29. package/dist/runtime/server/services/eponyme-store.d.ts +278 -30
  30. package/dist/runtime/server/services/eponyme-store.js +373 -71
  31. package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
  32. package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
  33. package/dist/runtime/types/eponyme.d.ts +53 -2
  34. package/dist/runtime/types/field.d.ts +35 -2
  35. package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
  36. package/dist/runtime/utils/eponyme-entry-index.js +51 -0
  37. package/dist/runtime/utils/get-field-default-value.js +2 -0
  38. package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
  39. package/dist/runtime/utils/{normalize-eponyme-phones.js → normalize-eponyme-values.js} +4 -2
  40. package/dist/runtime/utils/normalize-tags.d.ts +10 -0
  41. package/dist/runtime/utils/normalize-tags.js +17 -0
  42. package/dist/runtime/utils/validate-eponyme-data.js +16 -0
  43. package/dist/runtime/utils/variables.d.ts +4 -0
  44. package/dist/runtime/utils/variables.js +8 -1
  45. package/package.json +3 -2
  46. 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 String @id
228
- data Json @db.JsonB
229
- createdAt DateTime @default(now())
230
- updatedAt DateTime @updatedAt
231
- deletedAt DateTime?
232
- versions EponymeVersion[]
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.7.0",
4
+ "version": "0.8.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
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
+ };
@@ -2,6 +2,7 @@ type __VLS_Props = {
2
2
  open?: boolean;
3
3
  title: string;
4
4
  description?: string;
5
+ role?: 'dialog' | 'alertdialog';
5
6
  };
6
7
  declare var __VLS_16: {}, __VLS_47: {};
7
8
  type __VLS_Slots = {} & {
@@ -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 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">
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>
@@ -2,6 +2,7 @@ type __VLS_Props = {
2
2
  open?: boolean;
3
3
  title: string;
4
4
  description?: string;
5
+ role?: 'dialog' | 'alertdialog';
5
6
  };
6
7
  declare var __VLS_16: {}, __VLS_47: {};
7
8
  type __VLS_Slots = {} & {
@@ -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>