metaowl 0.1.3 → 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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * @module Forms
3
+ *
4
+ * Form handling and validation for MetaOwl applications.
5
+ *
6
+ * Features:
7
+ * - useForm() hook for form state
8
+ * - Field-level validation
9
+ * - Schema validation (Zod-compatible)
10
+ * - Error display
11
+ * - Field dirty/touched tracking
12
+ * - Async validation support
13
+ *
14
+ * @example
15
+ * import { useForm } from 'metaowl'
16
+ *
17
+ * const form = useForm({
18
+ * name: { default: '', validation: (v) => v.length > 2 || 'Min 3 chars' },
19
+ * email: { default: '', validation: (v) => /^[^\s@]+@[^\s@]+$/.test(v) || 'Invalid' }
20
+ * })
21
+ *
22
+ * form.handleSubmit((values) => {
23
+ * console.log(values.name, values.email)
24
+ * })
25
+ */
26
+
27
+ import { reactive } from '@odoo/owl'
28
+
29
+ /**
30
+ * Create a form controller.
31
+ *
32
+ * @param {object} fieldsConfig - Field configurations
33
+ * @returns {FormController}
34
+ *
35
+ * @example
36
+ * const form = useForm({
37
+ * name: { default: '', validation: (v) => v.length > 0 || 'Required' },
38
+ * email: {
39
+ * default: '',
40
+ * validation: [
41
+ * (v) => !!v || 'Required',
42
+ * (v) => /.+@.+/.test(v) || 'Invalid email'
43
+ * ],
44
+ * asyncValidation: async (v) => await checkEmail(v) || 'Taken'
45
+ * }
46
+ * })
47
+ */
48
+ export function useForm(fieldsConfig = {}) {
49
+ const fields = {}
50
+ const errors = {}
51
+ const touched = {}
52
+ const dirty = {}
53
+ const validating = {}
54
+
55
+ // Initialize field states
56
+ for (const [name, config] of Object.entries(fieldsConfig)) {
57
+ const initialValue = config?.default ?? ''
58
+ fields[name] = initialValue
59
+ errors[name] = null
60
+ touched[name] = false
61
+ dirty[name] = false
62
+ validating[name] = false
63
+ }
64
+
65
+ const state = reactive({
66
+ fields,
67
+ errors,
68
+ touched,
69
+ dirty,
70
+ validating,
71
+ isSubmitting: false,
72
+ isValidating: false,
73
+ submitCount: 0
74
+ })
75
+
76
+ /**
77
+ * Validate a single field.
78
+ *
79
+ * @param {string} name
80
+ * @returns {Promise<boolean>}
81
+ */
82
+ async function validateField(name) {
83
+ const config = fieldsConfig[name]
84
+ if (!config?.validation) {
85
+ state.errors[name] = null
86
+ return true
87
+ }
88
+
89
+ const value = state.fields[name]
90
+ const validators = Array.isArray(config.validation)
91
+ ? config.validation
92
+ : [config.validation]
93
+
94
+ // Sync validation
95
+ for (const validator of validators) {
96
+ const result = validator(value, state.fields)
97
+ if (result !== true) {
98
+ state.errors[name] = result || 'Invalid'
99
+ return false
100
+ }
101
+ }
102
+
103
+ // Async validation
104
+ if (config.asyncValidation) {
105
+ state.validating[name] = true
106
+ state.isValidating = true
107
+ try {
108
+ const result = await config.asyncValidation(value, state.fields)
109
+ if (result !== true) {
110
+ state.errors[name] = result || 'Invalid'
111
+ return false
112
+ }
113
+ } finally {
114
+ state.validating[name] = false
115
+ state.isValidating = Object.values(state.validating).some(Boolean)
116
+ }
117
+ }
118
+
119
+ state.errors[name] = null
120
+ return true
121
+ }
122
+
123
+ /**
124
+ * Validate all fields.
125
+ *
126
+ * @returns {Promise<boolean>}
127
+ */
128
+ async function validateAll() {
129
+ const results = await Promise.all(
130
+ Object.keys(fieldsConfig).map(name => validateField(name))
131
+ )
132
+ return results.every(Boolean)
133
+ }
134
+
135
+ return {
136
+ // State (reactive)
137
+ fields: state.fields,
138
+ errors: state.errors,
139
+ touched: state.touched,
140
+ dirty: state.dirty,
141
+ validating: state.validating,
142
+ isSubmitting: state.isSubmitting,
143
+ isValidating: state.isValidating,
144
+ submitCount: state.submitCount,
145
+
146
+ // Computed
147
+ get isValid() {
148
+ return Object.values(state.errors).every(e => e === null)
149
+ },
150
+
151
+ get isDirty() {
152
+ return Object.values(state.dirty).some(Boolean)
153
+ },
154
+
155
+ get isTouched() {
156
+ return Object.values(state.touched).some(Boolean)
157
+ },
158
+
159
+ // Methods
160
+ /**
161
+ * Update a field value.
162
+ *
163
+ * @param {string} name
164
+ * @param {*} value
165
+ */
166
+ setValue(name, value) {
167
+ state.fields[name] = value
168
+ state.dirty[name] = value !== (fieldsConfig[name]?.default ?? '')
169
+ },
170
+
171
+ /**
172
+ * Mark field as touched.
173
+ *
174
+ * @param {string} name
175
+ */
176
+ setTouched(name) {
177
+ state.touched[name] = true
178
+ },
179
+
180
+ /**
181
+ * Mark all fields as touched.
182
+ */
183
+ setAllTouched() {
184
+ for (const name of Object.keys(fieldsConfig)) {
185
+ state.touched[name] = true
186
+ }
187
+ },
188
+
189
+ /**
190
+ * Validate a field.
191
+ *
192
+ * @param {string} name
193
+ * @returns {Promise<boolean>}
194
+ */
195
+ validateField,
196
+
197
+ /**
198
+ * Validate all fields.
199
+ *
200
+ * @returns {Promise<boolean>}
201
+ */
202
+ validate: validateAll,
203
+
204
+ /**
205
+ * Reset form to initial state.
206
+ */
207
+ reset() {
208
+ for (const [name, config] of Object.entries(fieldsConfig)) {
209
+ state.fields[name] = config?.default ?? ''
210
+ state.errors[name] = null
211
+ state.touched[name] = false
212
+ state.dirty[name] = false
213
+ }
214
+ state.isSubmitting = false
215
+ state.submitCount = 0
216
+ },
217
+
218
+ /**
219
+ * Create submit handler.
220
+ *
221
+ * @param {Function} onSubmit - Callback with form values
222
+ * @param {object} [options]
223
+ * @param {boolean} [options.validate=true] - Validate before submit
224
+ * @returns {Function} Submit handler
225
+ */
226
+ handleSubmit(onSubmit, options = {}) {
227
+ const { validate = true } = options
228
+
229
+ return async (...args) => {
230
+ state.isSubmitting = true
231
+ state.submitCount++
232
+
233
+ try {
234
+ if (validate) {
235
+ this.setAllTouched()
236
+ const isValid = await this.validate()
237
+ if (!isValid) {
238
+ state.isSubmitting = false
239
+ return
240
+ }
241
+ }
242
+
243
+ await onSubmit({ ...state.fields }, ...args)
244
+ } finally {
245
+ state.isSubmitting = false
246
+ }
247
+ }
248
+ },
249
+
250
+ /**
251
+ * Get field props for binding.
252
+ *
253
+ * @param {string} name
254
+ * @returns {object} Bindable props
255
+ */
256
+ register(name) {
257
+ return {
258
+ value: state.fields[name],
259
+ onChange: (e) => this.setValue(name, e.target?.value ?? e),
260
+ onBlur: () => {
261
+ this.setTouched(name)
262
+ this.validateField(name)
263
+ },
264
+ error: state.touched[name] ? state.errors[name] : null
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Common validators.
272
+ */
273
+ export const validators = {
274
+ required: (message = 'Required') => (v) => !!v || message,
275
+
276
+ minLength: (min, message) => (v) =>
277
+ (v?.length ?? 0) >= min || message || `Min ${min} characters`,
278
+
279
+ maxLength: (max, message) => (v) =>
280
+ (v?.length ?? 0) <= max || message || `Max ${max} characters`,
281
+
282
+ min: (min, message) => (v) =>
283
+ Number(v) >= min || message || `Min ${min}`,
284
+
285
+ max: (max, message) => (v) =>
286
+ Number(v) <= max || message || `Max ${max}`,
287
+
288
+ email: (message = 'Invalid email') => (v) =>
289
+ /^[^\s@]+@[^\s@]+$/.test(v) || message,
290
+
291
+ url: (message = 'Invalid URL') => (v) =>
292
+ /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})[/\w .-]*\/?$/.test(v) || message,
293
+
294
+ pattern: (regex, message = 'Invalid format') => (v) =>
295
+ regex.test(v) || message,
296
+
297
+ match: (field, message) => (v, values) =>
298
+ v === values[field] || message || 'Fields do not match'
299
+ }
300
+
301
+ /**
302
+ * Create validation schema from config.
303
+ *
304
+ * @param {object} schema
305
+ * @returns {object}
306
+ *
307
+ * @example
308
+ * const schema = createSchema({
309
+ * name: [validators.required(), validators.minLength(3)],
310
+ * email: [validators.required(), validators.email()]
311
+ * })
312
+ *
313
+ * const form = useForm(schema)
314
+ */
315
+ export function createSchema(schema) {
316
+ const fieldsConfig = {}
317
+
318
+ for (const [name, validators] of Object.entries(schema)) {
319
+ const validatorArray = Array.isArray(validators) ? validators : [validators]
320
+
321
+ fieldsConfig[name] = {
322
+ default: '',
323
+ validation: (v, values) => {
324
+ for (const validator of validatorArray) {
325
+ const result = validator(v, values)
326
+ if (result !== true) return result
327
+ }
328
+ return true
329
+ }
330
+ }
331
+ }
332
+
333
+ return fieldsConfig
334
+ }
335
+
336
+ /**
337
+ * Field component props helper.
338
+ *
339
+ * @param {object} form - Form controller from useForm
340
+ * @param {string} name - Field name
341
+ * @returns {object} Props for input component
342
+ */
343
+ export function fieldProps(form, name) {
344
+ return {
345
+ value: form.fields[name],
346
+ error: form.touched[name] ? form.errors[name] : null,
347
+ onChange: (value) => form.setValue(name, value),
348
+ onBlur: () => {
349
+ form.setTouched(name)
350
+ form.validateField(name)
351
+ }
352
+ }
353
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * @module i18n
3
+ *
4
+ * Internationalization (i18n) support for MetaOwl applications.
5
+ *
6
+ * Features:
7
+ * - Translation function t()
8
+ * - Locale switching
9
+ * - Pluralization support
10
+ * - Interpolation: {{variable}}
11
+ * - Named formats
12
+ * - Async locale loading
13
+ * - Fallback locales
14
+ *
15
+ * @example
16
+ * import { i18n, t } from 'metaowl'
17
+ *
18
+ * // Configure
19
+ * i18n.configure({
20
+ * locale: 'de',
21
+ * fallbackLocale: 'en',
22
+ * messages: {
23
+ * en: { hello: 'Hello {{name}}' },
24
+ * de: { hello: 'Hallo {{name}}' }
25
+ * }
26
+ * })
27
+ *
28
+ * // Use in templates
29
+ * t('hello', { name: 'World' }) // 'Hallo World'
30
+ *
31
+ * // Use in OWL templates
32
+ * xml`<span t-esc="t('hello', { name: state.user })" />`
33
+ */
34
+
35
+ import { reactive } from '@odoo/owl'
36
+
37
+ /**
38
+ * Reactive i18n state.
39
+ */
40
+ const _state = reactive({
41
+ locale: 'en',
42
+ fallbackLocale: 'en',
43
+ messages: {},
44
+ loading: false
45
+ })
46
+
47
+ /**
48
+ * Default pluralization rules.
49
+ */
50
+ const _pluralRules = new Map()
51
+
52
+ /**
53
+ * Configure pluralization for a locale.
54
+ *
55
+ * @param {string} locale
56
+ * @param {Function} rule - (count) => form ('zero'|'one'|'two'|'few'|'many'|'other')
57
+ */
58
+ export function setPluralizationRule(locale, rule) {
59
+ _pluralRules.set(locale, rule)
60
+ }
61
+
62
+ /**
63
+ * Default pluralization (English-like).
64
+ *
65
+ * @param {number} count
66
+ * @returns {string}
67
+ */
68
+ function defaultPluralRule(count) {
69
+ if (count === 0) return 'zero'
70
+ if (count === 1) return 'one'
71
+ return 'other'
72
+ }
73
+
74
+ /**
75
+ * Get plural form for count and locale.
76
+ *
77
+ * @param {number} count
78
+ * @param {string} locale
79
+ * @returns {string}
80
+ */
81
+ function getPluralForm(count, locale) {
82
+ const rule = _pluralRules.get(locale) || defaultPluralRule
83
+ return rule(count)
84
+ }
85
+
86
+ /**
87
+ * Configure i18n settings.
88
+ *
89
+ * @param {object} config
90
+ * @param {string} [config.locale='en']
91
+ * @param {string} [config.fallbackLocale='en']
92
+ * @param {object} [config.messages={}]
93
+ * @param {boolean} [config.warnOnMissing=true]
94
+ */
95
+ export function configureI18n(config) {
96
+ if (config.locale) {
97
+ _state.locale = config.locale
98
+ document.documentElement.lang = config.locale
99
+ }
100
+ if (config.fallbackLocale) {
101
+ _state.fallbackLocale = config.fallbackLocale
102
+ }
103
+ if (config.messages) {
104
+ _state.messages = config.messages
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get current locale.
110
+ *
111
+ * @returns {string}
112
+ */
113
+ export function getLocale() {
114
+ return _state.locale
115
+ }
116
+
117
+ /**
118
+ * Set current locale.
119
+ *
120
+ * @param {string} locale
121
+ * @returns {Promise<void>}
122
+ */
123
+ export async function setLocale(locale) {
124
+ _state.locale = locale
125
+ document.documentElement.lang = locale
126
+ }
127
+
128
+ /**
129
+ * Load locale messages asynchronously.
130
+ *
131
+ * @param {string} locale
132
+ * @param {object|Promise<object>} messages
133
+ */
134
+ export async function loadLocaleMessages(locale, messages) {
135
+ _state.loading = true
136
+ try {
137
+ const loaded = await messages
138
+ if (!_state.messages[locale]) {
139
+ _state.messages[locale] = {}
140
+ }
141
+ Object.assign(_state.messages[locale], loaded)
142
+ } finally {
143
+ _state.loading = false
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Translate a key with optional interpolation.
149
+ *
150
+ * @param {string} key - Dot-notation key (e.g., 'messages.hello')
151
+ * @param {object} [values] - Interpolation values
152
+ * @param {string} [defaultMessage] - Fallback message
153
+ * @returns {string} Translated string
154
+ *
155
+ * @example
156
+ * t('hello') // 'Hello'
157
+ * t('hello', { name: 'World' }) // 'Hello World'
158
+ * t('count', { n: 5 }) // Uses plural forms
159
+ */
160
+ export function t(key, values = {}, defaultMessage) {
161
+ const locale = _state.locale
162
+ const fallbackLocale = _state.fallbackLocale
163
+
164
+ // Get message
165
+ let message = getMessage(key, locale)
166
+
167
+ // Fallback
168
+ if (!message && locale !== fallbackLocale) {
169
+ message = getMessage(key, fallbackLocale)
170
+ }
171
+
172
+ // Still not found
173
+ if (!message) {
174
+ return defaultMessage || key
175
+ }
176
+
177
+ // Handle pluralization
178
+ if (typeof message === 'object') {
179
+ const count = values.n || values.count || 0
180
+ const form = getPluralForm(count, locale)
181
+ message = message[form] || message.other || message.one || key
182
+ }
183
+
184
+ // Interpolate
185
+ return interpolate(message, values)
186
+ }
187
+
188
+ /**
189
+ * Get message by dot-notation key.
190
+ *
191
+ * @param {string} key
192
+ * @param {string} locale
193
+ * @returns {string|object|undefined}
194
+ */
195
+ function getMessage(key, locale) {
196
+ const parts = key.split('.')
197
+ let current = _state.messages[locale]
198
+
199
+ for (const part of parts) {
200
+ if (!current || typeof current !== 'object') {
201
+ return undefined
202
+ }
203
+ current = current[part]
204
+ }
205
+
206
+ return current
207
+ }
208
+
209
+ /**
210
+ * Interpolate values into message.
211
+ *
212
+ * @param {string} message
213
+ * @param {object} values
214
+ * @returns {string}
215
+ */
216
+ function interpolate(message, values) {
217
+ return message.replace(/\{\{(\w+)\}\}/g, (match, key) => {
218
+ return values[key] !== undefined ? values[key] : match
219
+ })
220
+ }
221
+
222
+ /**
223
+ * Format a date according to locale.
224
+ *
225
+ * @param {Date|number|string} date
226
+ * @param {object} [options] - Intl.DateTimeFormat options
227
+ * @returns {string}
228
+ */
229
+ export function formatDate(date, options = {}) {
230
+ const d = new Date(date)
231
+ return new Intl.DateTimeFormat(_state.locale, options).format(d)
232
+ }
233
+
234
+ /**
235
+ * Format a number according to locale.
236
+ *
237
+ * @param {number} number
238
+ * @param {object} [options] - Intl.NumberFormat options
239
+ * @returns {string}
240
+ */
241
+ export function formatNumber(number, options = {}) {
242
+ return new Intl.NumberFormat(_state.locale, options).format(number)
243
+ }
244
+
245
+ /**
246
+ * Format currency according to locale.
247
+ *
248
+ * @param {number} amount
249
+ * @param {string} currency - Currency code (e.g., 'USD', 'EUR')
250
+ * @param {object} [options]
251
+ * @returns {string}
252
+ */
253
+ export function formatCurrency(amount, currency, options = {}) {
254
+ return new Intl.NumberFormat(_state.locale, {
255
+ style: 'currency',
256
+ currency,
257
+ ...options
258
+ }).format(amount)
259
+ }
260
+
261
+ /**
262
+ * Format relative time (e.g., "2 hours ago").
263
+ *
264
+ * @param {Date|number|string} date
265
+ * @param {string} [style='long'] - 'long'|'short'|'narrow'
266
+ * @returns {string}
267
+ */
268
+ export function formatRelativeTime(date, style = 'long') {
269
+ const d = new Date(date)
270
+ const now = new Date()
271
+ const diff = d.getTime() - now.getTime()
272
+
273
+ const seconds = Math.round(diff / 1000)
274
+ const minutes = Math.round(seconds / 60)
275
+ const hours = Math.round(minutes / 60)
276
+ const days = Math.round(hours / 24)
277
+
278
+ const rtf = new Intl.RelativeTimeFormat(_state.locale, { style })
279
+
280
+ if (Math.abs(seconds) < 60) return rtf.format(seconds, 'second')
281
+ if (Math.abs(minutes) < 60) return rtf.format(minutes, 'minute')
282
+ if (Math.abs(hours) < 24) return rtf.format(hours, 'hour')
283
+ return rtf.format(days, 'day')
284
+ }
285
+
286
+ /**
287
+ * i18n helper for OWL templates.
288
+ * Use as: t-esc="i18n.t('key', values)"
289
+ */
290
+ export const i18n = {
291
+ get locale() { return _state.locale },
292
+ get fallbackLocale() { return _state.fallbackLocale },
293
+ get loading() { return _state.loading },
294
+ get messages() { return _state.messages },
295
+ configure: configureI18n,
296
+ setLocale,
297
+ t,
298
+ formatDate,
299
+ formatNumber,
300
+ formatCurrency,
301
+ formatRelativeTime
302
+ }
303
+
304
+ /**
305
+ * Create a namespaced translation function.
306
+ *
307
+ * @param {string} namespace - e.g., 'forms', 'errors'
308
+ * @returns {Function} - (key, values) => string
309
+ *
310
+ * @example
311
+ * const tf = createNamespacedT('forms')
312
+ * tf('name.label') // Same as t('forms.name.label')
313
+ */
314
+ export function createNamespacedT(namespace) {
315
+ return (key, values) => t(`${namespace}.${key}`, values)
316
+ }
317
+
318
+ // Set up German pluralization
319
+ setPluralizationRule('de', (count) => {
320
+ if (count === 0) return 'zero'
321
+ if (count === 1) return 'one'
322
+ return 'other'
323
+ })
324
+
325
+ // Set up Russian pluralization (complex)
326
+ setPluralizationRule('ru', (count) => {
327
+ const mod10 = count % 10
328
+ const mod100 = count % 100
329
+ if (mod10 === 1 && mod100 !== 11) return 'one'
330
+ if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) return 'few'
331
+ if (mod10 === 0 || [5, 6, 7, 8, 9].includes(mod10) || [11, 12, 13, 14].includes(mod100)) return 'many'
332
+ return 'other'
333
+ })