@xelqun/forms 0.1.1 → 0.2.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.
Files changed (76) hide show
  1. package/README.md +46 -61
  2. package/package.json +32 -2
  3. package/resources/css/xelqun.css +121 -0
  4. package/resources/js/components/XqAction.vue +143 -0
  5. package/resources/js/components/XqFieldSlot.vue +20 -0
  6. package/resources/js/components/XqFieldWrapper.vue +74 -30
  7. package/resources/js/components/XqForm.vue +36 -9
  8. package/resources/js/components/XqNode.vue +8 -1
  9. package/resources/js/components/XqUnknownNode.vue +2 -1
  10. package/resources/js/components/defaults.ts +36 -0
  11. package/resources/js/components/fields/XqBlockPicker.vue +68 -0
  12. package/resources/js/components/fields/XqCheckboxField.vue +35 -9
  13. package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
  14. package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
  15. package/resources/js/components/fields/XqColorPickerField.vue +156 -0
  16. package/resources/js/components/fields/XqDatePickerField.vue +149 -53
  17. package/resources/js/components/fields/XqFileUploadField.vue +524 -165
  18. package/resources/js/components/fields/XqImageEditor.vue +165 -0
  19. package/resources/js/components/fields/XqKeyValueField.vue +168 -0
  20. package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
  21. package/resources/js/components/fields/XqRadioField.vue +9 -5
  22. package/resources/js/components/fields/XqRepeaterField.vue +314 -79
  23. package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
  24. package/resources/js/components/fields/XqRichEditorField.vue +280 -0
  25. package/resources/js/components/fields/XqSelectField.vue +29 -12
  26. package/resources/js/components/fields/XqSliderField.vue +47 -0
  27. package/resources/js/components/fields/XqTagsInputField.vue +127 -0
  28. package/resources/js/components/fields/XqTextInputField.vue +121 -12
  29. package/resources/js/components/fields/XqTextareaField.vue +39 -16
  30. package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
  31. package/resources/js/components/fields/XqToggleField.vue +33 -9
  32. package/resources/js/components/layouts/XqActions.vue +19 -0
  33. package/resources/js/components/layouts/XqBlock.vue +52 -0
  34. package/resources/js/components/layouts/XqCallout.vue +46 -0
  35. package/resources/js/components/layouts/XqEmptyState.vue +37 -0
  36. package/resources/js/components/layouts/XqFlex.vue +23 -0
  37. package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
  38. package/resources/js/components/layouts/XqIconNode.vue +32 -0
  39. package/resources/js/components/layouts/XqImageNode.vue +30 -0
  40. package/resources/js/components/layouts/XqSection.vue +3 -1
  41. package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
  42. package/resources/js/components/layouts/XqTextNode.vue +49 -0
  43. package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
  44. package/resources/js/components/layouts/XqWizard.vue +168 -0
  45. package/resources/js/composables/context.ts +14 -0
  46. package/resources/js/composables/useField.ts +21 -3
  47. package/resources/js/composables/usePanelErrors.ts +37 -0
  48. package/resources/js/composables/useSortable.ts +70 -0
  49. package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
  50. package/resources/js/core/dates.ts +38 -0
  51. package/resources/js/core/images.ts +110 -0
  52. package/resources/js/core/log.ts +1 -1
  53. package/resources/js/core/markdown.ts +61 -0
  54. package/resources/js/core/mask.ts +42 -0
  55. package/resources/js/core/patch.ts +6 -3
  56. package/resources/js/core/paths.ts +11 -14
  57. package/resources/js/core/store.ts +515 -25
  58. package/resources/js/core/translate.ts +17 -0
  59. package/resources/js/core/validation.ts +126 -0
  60. package/resources/js/index.ts +4 -3
  61. package/resources/js/lib/utils.ts +15 -0
  62. package/resources/js/plugin.ts +103 -6
  63. package/resources/js/schema/types.ts +102 -0
  64. package/resources/js/testing.ts +50 -0
  65. package/resources/js/ui/XqCalendar.vue +4 -0
  66. package/resources/js/ui/XqCombobox.vue +89 -32
  67. package/resources/js/ui/XqConfirmDialog.vue +58 -0
  68. package/resources/js/ui/XqDialog.vue +46 -0
  69. package/resources/js/ui/XqIcon.vue +28 -0
  70. package/resources/js/ui/XqSelect.vue +38 -18
  71. package/resources/js/ui/XqTabs.vue +1 -1
  72. package/resources/js/ui/XqTimeField.vue +66 -0
  73. package/resources/js/ui/classes.ts +33 -0
  74. package/resources/js/ui/icons.ts +95 -0
  75. package/resources/js/ui/index.ts +5 -0
  76. package/resources/js/ui/options.ts +46 -0
@@ -0,0 +1,17 @@
1
+ export type Replacements = Record<string, string | number>;
2
+
3
+ /**
4
+ * Text is written in English and that is its key, so a missing line reads fine.
5
+ * Fill :placeholders the way Laravel does.
6
+ */
7
+ export function translate(
8
+ messages: Record<string, string> | undefined,
9
+ text: string,
10
+ replacements: Replacements = {},
11
+ ): string {
12
+ return Object.entries(replacements).reduce(
13
+ // A function, so a "$&" in a file name isn't read as a pattern.
14
+ (line, [name, value]) => line.replaceAll(`:${name}`, () => String(value)),
15
+ messages?.[text] ?? text,
16
+ );
17
+ }
@@ -0,0 +1,126 @@
1
+ import type { ClientRule, FieldValidation } from '../schema/types';
2
+ import { isPlainObject } from './values';
3
+
4
+ /**
5
+ * Laravel's rules, for the browser. Each one may let through what the server
6
+ * rejects, and must never reject what the server accepts: the server has the
7
+ * last word, and a wrong "no" here is a form nobody can submit. Keep
8
+ * tests/Fixtures/conformance/validation.json passing on both sides.
9
+ */
10
+
11
+ /** PHP's trim(). JavaScript's also takes spaces PHP treats as text. */
12
+ function phpTrim(text: string): string {
13
+ const spaces = ' \t\n\r\0\x0B';
14
+ let start = 0;
15
+ let end = text.length;
16
+
17
+ while (start < end && spaces.includes(text[start])) {
18
+ start++;
19
+ }
20
+
21
+ while (end > start && spaces.includes(text[end - 1])) {
22
+ end--;
23
+ }
24
+
25
+ return text.slice(start, end);
26
+ }
27
+
28
+ const length = (text: string) => Array.from(text).length;
29
+
30
+ function isBlank(value: unknown): boolean {
31
+ if (value === null || value === undefined) {
32
+ return true;
33
+ }
34
+
35
+ if (typeof value === 'string') {
36
+ return phpTrim(value) === '';
37
+ }
38
+
39
+ if (Array.isArray(value)) {
40
+ return value.length === 0;
41
+ }
42
+
43
+ return isPlainObject(value) && Object.keys(value).length === 0;
44
+ }
45
+
46
+ function isNumeric(text: string): boolean {
47
+ return /^[ \t\n\r\v\f]*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?[ \t\n\r\v\f]*$/.test(text);
48
+ }
49
+
50
+ /**
51
+ * An app may or may not trim strings before validating, so a length only fails
52
+ * if it fails either way: the raw text for a minimum, the trimmed text for a maximum.
53
+ */
54
+ function passesString(rule: ClientRule, text: string): boolean {
55
+ const longest = length(text);
56
+ const shortest = length(phpTrim(text));
57
+ const limit = rule.value ?? 0;
58
+
59
+ return rule.rule === 'min'
60
+ ? longest >= limit
61
+ : rule.rule === 'max'
62
+ ? shortest <= limit
63
+ : shortest <= limit && limit <= longest;
64
+ }
65
+
66
+ function passes(rule: ClientRule, value: unknown): boolean {
67
+ const text = String(value);
68
+
69
+ switch (rule.rule) {
70
+ case 'accepted':
71
+ return [true, 1, 'yes', 'on', '1', 'true'].includes(value as never);
72
+ case 'email':
73
+ // Anything else an address can look like is Laravel's call: quotes and spaces are valid.
74
+ return /.@./s.test(text);
75
+ case 'numeric':
76
+ return isNumeric(text);
77
+ case 'integer':
78
+ return /^[ \t\n\r\v\f]*[+-]?\d+[ \t\n\r\v\f]*$/.test(text);
79
+ case 'size':
80
+ return passesString(rule, text);
81
+ case 'min':
82
+ case 'max':
83
+ if (rule.type === 'string') {
84
+ return passesString(rule, text);
85
+ }
86
+
87
+ if (!isNumeric(text)) {
88
+ return true;
89
+ }
90
+
91
+ return rule.rule === 'min'
92
+ ? Number(text) >= (rule.value ?? 0)
93
+ : Number(text) <= (rule.value ?? 0);
94
+ default:
95
+ return true;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The message of the first rule this value breaks, in the order the server
101
+ * checks them, or null if none.
102
+ */
103
+ export function firstFailure(
104
+ validation: FieldValidation,
105
+ value: unknown,
106
+ required: boolean,
107
+ ): string | null {
108
+ for (const rule of validation.rules) {
109
+ const isRequiredRule = rule.rule === 'required' || rule.rule === 'accepted';
110
+
111
+ if (isRequiredRule) {
112
+ if (required && (rule.rule === 'accepted' ? !passes(rule, value) : isBlank(value))) {
113
+ return rule.message;
114
+ }
115
+
116
+ continue;
117
+ }
118
+
119
+ // Laravel skips other rules on an empty value.
120
+ if (!isBlank(value) && !passes(rule, value)) {
121
+ return rule.message;
122
+ }
123
+ }
124
+
125
+ return null;
126
+ }
@@ -1,5 +1,5 @@
1
1
  export { createXelqunForms } from './plugin';
2
- export type { XelqunFormsOptions } from './plugin';
2
+ export type { XelqunFormsOptions, XelqunPlugin, XelqunPluginContext } from './plugin';
3
3
 
4
4
  export { default as XqForm } from './components/XqForm.vue';
5
5
  export { default as XqNode } from './components/XqNode.vue';
@@ -8,13 +8,14 @@ export { createRegistry, defaultComponents } from './components/defaults';
8
8
 
9
9
  export { useXqForm } from './composables/useXqForm';
10
10
  export { useField } from './composables/useField';
11
- export { useFormStore } from './composables/context';
11
+ export { useFormStore, useTranslator } from './composables/context';
12
12
  export { gridStyle, spanStyle } from './composables/layout';
13
- export type { ComponentResolver } from './composables/registry';
13
+ export type { ComponentRegistry, ComponentResolver } from './composables/registry';
14
14
 
15
15
  export { createFormStore, defaultLiveConfig } from './core/store';
16
16
  export type { FormStore, FormStoreOptions, SubmitOptions } from './core/store';
17
17
  export type { RequestKind, RequestLogEntry } from './core/log';
18
+ export { HttpError } from './core/http';
18
19
  export { evaluateCondition } from './core/conditions';
19
20
  export { evaluateExpression } from './core/expressions';
20
21
  export { getPath, overlaps } from './core/paths';
@@ -6,6 +6,21 @@ export function cn(...inputs: ClassValue[]): string {
6
6
  return twMerge(clsx(inputs));
7
7
  }
8
8
 
9
+ /**
10
+ * Keep keyboard users in place: focus the first of these that can take it.
11
+ */
12
+ export function focusFirstEnabled(container: HTMLElement | null, selectors: string[]): void {
13
+ for (const selector of selectors) {
14
+ const control = container?.querySelector<HTMLButtonElement>(selector);
15
+
16
+ if (control && !control.disabled) {
17
+ control.focus();
18
+
19
+ return;
20
+ }
21
+ }
22
+ }
23
+
9
24
  export function formatBytes(bytes: number | null | undefined): string {
10
25
  if (!bytes) {
11
26
  return '';
@@ -1,17 +1,114 @@
1
- import type { Plugin } from 'vue';
2
- import { createRegistry } from './components/defaults';
3
- import { REGISTRY } from './composables/registry';
4
- import type { ComponentResolver } from './composables/registry';
1
+ import type { App, Component, Plugin } from 'vue';
2
+ import { createRegistry, defaultComponents } from './components/defaults';
3
+ import { REGISTRY, toComponent } from './composables/registry';
4
+ import type { ComponentRegistry, ComponentResolver } from './composables/registry';
5
+ import { registerIcons } from './ui/icons';
6
+
7
+ export interface XelqunPluginContext {
8
+ app: App;
9
+ }
10
+
11
+ /**
12
+ * The browser half of a plugin. Give it a function that returns this, so the
13
+ * host writes `plugins: [ratingPlugin()]` and options have somewhere to go.
14
+ */
15
+ export interface XelqunPlugin {
16
+ id: string;
17
+ /** Renderers keyed by vendor-prefixed schema type, e.g. { 'acme/rating': () => import('./Rating.vue') }. */
18
+ components?: Record<string, ComponentResolver>;
19
+ /** Built-in types this plugin takes over on purpose. Another plugin's type is always a collision. */
20
+ replaces?: string[];
21
+ /** Icons PHP can name, as { name: component }. */
22
+ icons?: Record<string, Component>;
23
+ /** Runs once, after every plugin has registered its components. */
24
+ setup?: (context: XelqunPluginContext) => void;
25
+ }
5
26
 
6
27
  export interface XelqunFormsOptions {
7
- /** Extra or replacement renderers keyed by schema type, e.g. { rating: () => import('./Rating.vue') }. */
28
+ plugins?: XelqunPlugin[];
29
+ /** The app's own renderers, keyed by schema type. These win over plugins and built-ins. */
8
30
  components?: Record<string, ComponentResolver>;
31
+ /** Icons PHP can name, as { name: component }, on top of the built-in set. */
32
+ icons?: Record<string, Component>;
33
+ }
34
+
35
+ const VENDOR_PREFIXED_TYPE = /^[a-z0-9-]+\/[a-z0-9-]+$/;
36
+
37
+ const BUILT_IN = 'built-in';
38
+
39
+ /**
40
+ * Add each plugin's components to the registry. A collision is a bug in the app,
41
+ * so it throws in development. In production the page should still work: the
42
+ * error is logged and the type that was registered first stays.
43
+ */
44
+ export function registerPlugins(
45
+ registry: ComponentRegistry,
46
+ plugins: XelqunPlugin[],
47
+ strict: boolean,
48
+ ): void {
49
+ const owners = new Map(Object.keys(defaultComponents()).map((type) => [type, BUILT_IN]));
50
+ const ids = new Set<string>();
51
+
52
+ const refuse = (message: string): void => {
53
+ if (strict) {
54
+ throw new Error(`[xelqun] ${message}`);
55
+ }
56
+
57
+ console.error(`[xelqun] ${message}`);
58
+ };
59
+
60
+ for (const plugin of plugins) {
61
+ if (ids.has(plugin.id)) {
62
+ refuse(`The plugin "${plugin.id}" is registered twice.`);
63
+
64
+ continue;
65
+ }
66
+
67
+ ids.add(plugin.id);
68
+
69
+ for (const [type, resolver] of Object.entries(plugin.components ?? {})) {
70
+ const owner = owners.get(type);
71
+ const isBuiltIn = owner === BUILT_IN;
72
+ const mayReplace = isBuiltIn && (plugin.replaces?.includes(type) ?? false);
73
+
74
+ if (owner && !isBuiltIn) {
75
+ refuse(
76
+ `The plugins "${owner}" and "${plugin.id}" both register the type "${type}".`,
77
+ );
78
+
79
+ continue;
80
+ }
81
+
82
+ if (!VENDOR_PREFIXED_TYPE.test(type) && !mayReplace) {
83
+ refuse(
84
+ `The plugin "${plugin.id}" registers the type "${type}". Plugin types need a vendor prefix such as "acme/rating". Names without one are reserved for built-in fields. To replace a built-in on purpose, list it in "replaces".`,
85
+ );
86
+
87
+ continue;
88
+ }
89
+
90
+ registry.set(type, toComponent(resolver));
91
+ owners.set(type, plugin.id);
92
+ }
93
+ }
9
94
  }
10
95
 
11
96
  export function createXelqunForms(options: XelqunFormsOptions = {}): Plugin {
12
97
  return {
13
98
  install(app) {
14
- app.provide(REGISTRY, createRegistry(options.components));
99
+ const plugins = options.plugins ?? [];
100
+ const registry = createRegistry();
101
+
102
+ registerPlugins(registry, plugins, import.meta.env?.DEV ?? false);
103
+ plugins.forEach((plugin) => registerIcons(plugin.icons ?? {}));
104
+ registerIcons(options.icons ?? {});
105
+
106
+ for (const [type, resolver] of Object.entries(options.components ?? {})) {
107
+ registry.set(type, toComponent(resolver));
108
+ }
109
+
110
+ plugins.forEach((plugin) => plugin.setup?.({ app }));
111
+ app.provide(REGISTRY, registry);
15
112
  },
16
113
  };
17
114
  }
@@ -13,6 +13,14 @@ export type ConditionNode =
13
13
  }
14
14
  | { op: 'filled' | 'blank'; path: string };
15
15
 
16
+ export interface ConditionalText {
17
+ when: ConditionNode;
18
+ text: string;
19
+ otherwise: string | null;
20
+ }
21
+
22
+ export type TextKey = 'label' | 'hint' | 'placeholder';
23
+
16
24
  export type ExpressionNode =
17
25
  | { lit: unknown }
18
26
  | { ref: string }
@@ -40,23 +48,81 @@ export interface SchemaNode {
40
48
  placeholder?: string | null;
41
49
  required?: boolean;
42
50
  disabled?: boolean;
51
+ /** The server ignores this field's value. Text controls stay readable, others render disabled. */
52
+ readOnly?: boolean;
53
+ autofocus?: boolean;
54
+ hiddenLabel?: boolean;
55
+ inlineLabel?: boolean;
56
+ extraAttributes?: Record<string, unknown>;
43
57
  rules?: {
44
58
  visible?: ConditionNode | null;
45
59
  required?: ConditionNode | null;
46
60
  disabled?: ConditionNode | null;
61
+ text?: Partial<Record<TextKey, ConditionalText>>;
47
62
  };
48
63
  computed?: { src: string; ast: ExpressionNode } | null;
49
64
  live?: LiveConfig | null;
65
+ /** Set by fields with rows. They need props.template, rowPlaceholder, rows and newRow. */
66
+ containsRows?: boolean;
67
+ /** Set by fields that hold a file, which a copied row can't share. */
68
+ acceptsUploads?: boolean;
50
69
  span?: ColumnSpan;
70
+ /** False keeps a child of a Flex at its own width. */
71
+ grow?: boolean;
72
+ /** Rules the browser can check, on forms with frontendValidation(). */
73
+ validation?: FieldValidation | null;
74
+ /** The place around its field, or in its repeater row's header, this child renders in. */
75
+ slot?: FieldSlot | 'itemAction';
51
76
  props?: Record<string, any>;
52
77
  children?: SchemaNode[];
53
78
  deps?: string[];
54
79
  }
55
80
 
81
+ /** One kind of row in a builder. */
82
+ export interface BlockOption {
83
+ name: string;
84
+ label: string;
85
+ icon: string | null;
86
+ group: string | null;
87
+ maxItems: number | null;
88
+ template: SchemaNode[];
89
+ newRow: Record<string, unknown>;
90
+ }
91
+
92
+ export interface ClientRule {
93
+ rule: 'required' | 'accepted' | 'email' | 'numeric' | 'integer' | 'min' | 'max' | 'size';
94
+ type?: 'string' | 'numeric';
95
+ value?: number;
96
+ message: string;
97
+ }
98
+
99
+ export interface FieldValidation {
100
+ attribute: string;
101
+ rules: ClientRule[];
102
+ }
103
+
104
+ export type FieldSlot =
105
+ | 'aboveLabel'
106
+ | 'beforeLabel'
107
+ | 'afterLabel'
108
+ | 'belowLabel'
109
+ | 'aboveContent'
110
+ | 'beforeContent'
111
+ | 'afterContent'
112
+ | 'belowContent'
113
+ | 'aboveErrorMessage'
114
+ | 'belowErrorMessage'
115
+ | 'prefixAction'
116
+ | 'suffixAction'
117
+ | 'optionAction'
118
+ | 'markdownPreview';
119
+
56
120
  export interface FormEndpoints {
57
121
  update: string;
58
122
  upload: string;
59
123
  search: string;
124
+ validate: string;
125
+ action: string;
60
126
  }
61
127
 
62
128
  export interface FormSchema {
@@ -65,6 +131,10 @@ export interface FormSchema {
65
131
  id: string;
66
132
  token: string | null;
67
133
  endpoints: FormEndpoints;
134
+ locale?: string;
135
+ /** Text in this locale, by its English original. Anything missing stays English. */
136
+ messages?: Record<string, string>;
137
+ unsavedChangesAlert?: boolean;
68
138
  state: { data: Record<string, any> };
69
139
  components: SchemaNode[];
70
140
  }
@@ -77,6 +147,22 @@ export interface UpdateRequest {
77
147
  deps: Record<string, string[]>;
78
148
  }
79
149
 
150
+ export interface ValidateRequest {
151
+ token: string;
152
+ step: string;
153
+ state: { data: Record<string, any> };
154
+ deps: Record<string, string[]>;
155
+ }
156
+
157
+ export interface ActionRequest {
158
+ token: string;
159
+ action: string;
160
+ phase: 'mount' | 'run';
161
+ state: { data: Record<string, any> };
162
+ data?: Record<string, any>;
163
+ deps: Record<string, string[]>;
164
+ }
165
+
80
166
  export interface UpdatePatch {
81
167
  $xq: number;
82
168
  seq: number;
@@ -85,10 +171,26 @@ export interface UpdatePatch {
85
171
  errors: Record<string, string | string[]>;
86
172
  }
87
173
 
174
+ export interface ActionResult extends UpdatePatch {
175
+ result?: unknown;
176
+ }
177
+
88
178
  export interface SelectOption {
89
179
  value: string | number;
90
180
  label: string;
91
181
  description?: string | null;
182
+ disabled?: boolean;
183
+ /** The group this option is listed under. */
184
+ group?: string;
185
+ }
186
+
187
+ /** Text a select shows in place of its defaults. */
188
+ export interface SelectMessages {
189
+ loading?: string;
190
+ noOptions?: string;
191
+ noResults?: string;
192
+ prompt?: string;
193
+ searching?: string;
92
194
  }
93
195
 
94
196
  export interface UploadResult {
@@ -0,0 +1,50 @@
1
+ import type { FormSchema, SchemaNode } from './schema/types';
2
+
3
+ /**
4
+ * Hand-built schema for testing a renderer without a server, from
5
+ * '@xelqun/forms/testing'. Pass it to XqForm in a mounted test.
6
+ */
7
+ export function fieldNode(
8
+ path: string,
9
+ type = 'text-input',
10
+ extra: Partial<SchemaNode> = {},
11
+ ): SchemaNode {
12
+ return {
13
+ key: path,
14
+ type,
15
+ hidden: false,
16
+ path,
17
+ label: path.split('.').pop(),
18
+ required: false,
19
+ disabled: false,
20
+ rules: { visible: null, required: null, disabled: null },
21
+ computed: null,
22
+ live: null,
23
+ props: {},
24
+ children: [],
25
+ deps: [],
26
+ ...extra,
27
+ };
28
+ }
29
+
30
+ export function formSchema(
31
+ components: SchemaNode[],
32
+ data: Record<string, any> = {},
33
+ token: string | null = 'token',
34
+ ): FormSchema {
35
+ return {
36
+ $xq: 1,
37
+ pkg: '0.1.0',
38
+ id: 'test-form',
39
+ token,
40
+ endpoints: {
41
+ update: '/_xelqun/forms/update',
42
+ upload: '/_xelqun/forms/upload',
43
+ search: '/_xelqun/forms/search',
44
+ validate: '/_xelqun/forms/validate',
45
+ action: '/_xelqun/forms/action',
46
+ },
47
+ state: { data },
48
+ components,
49
+ };
50
+ }
@@ -22,6 +22,8 @@ const props = defineProps<{
22
22
  minValue?: DateValue;
23
23
  maxValue?: DateValue;
24
24
  placeholder?: DateValue;
25
+ isDateUnavailable?: (date: DateValue) => boolean;
26
+ locale?: string;
25
27
  class?: HTMLAttributes['class'];
26
28
  }>();
27
29
  const model = defineModel<DateValue | undefined>();
@@ -37,6 +39,8 @@ const navClass =
37
39
  :min-value="minValue"
38
40
  :max-value="maxValue"
39
41
  :placeholder="placeholder"
42
+ :locale="locale"
43
+ :is-date-unavailable="isDateUnavailable"
40
44
  fixed-weeks
41
45
  weekday-format="short"
42
46
  data-slot="xq-calendar"