Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .sync/log/c257686a254906f7ffd333ed5da29c112924f7dd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Port: test(composables): add specs for defineLocale, useKbd and useFormField (#6701)

**Upstream:** `c257686a254906f7ffd333ed5da29c112924f7dd` (nuxt/ui)
**Decision:** port — test-only

## Upstream change
Adds three composable specs (test-only, no runtime changes):
`defineLocale.spec.ts`, `useKbd.spec.ts`, `useFormField.spec.ts`.

## b24ui port
All three composables exist in b24ui with matching APIs. Created the three specs;
two needed small b24ui adaptations:

- **`useKbd.spec.ts`** — verbatim. b24ui's `kbdKeysMap` and the macOS/non-macOS
meta/ctrl/alt resolution match upstream exactly (`⌘`/`⌃`/`⌥` vs `Ctrl`/`Ctrl`/`Alt`).
- **`defineLocale.spec.ts`** — added `locale: 'en'` to `baseOptions`. b24ui's
`DefineLocaleOptions` requires a `locale` field (fork divergence — every b24ui
locale definition passes it); upstream's fixture omits it, which would fail
typecheck here.
- **`useFormField.spec.ts`** — changed the error-state assertion from
`'error'` to `'air-primary-alert'`. b24ui's `useFormField` returns the
`air-primary-alert` color token on field error (fork divergence); the rest of
the API (id/name/size/highlight/disabled fallbacks, `ariaAttrs`, form events,
debounced/deferred input) matches upstream.

## Tests
No snapshots. +6 test files (231→237); +28 tests × (nuxt/vue). Suite 5411 passed
/ 6 skipped (was 5355). Targeted run of the three specs: 56 passed.

## Verify (CI=true)
`dev:prepare` · `lint` · `typecheck` · `test` · `build` — all green.
10 changes: 8 additions & 2 deletions .sync/nuxt-ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"upstream": "nuxt/ui",
"branch": "v4",
"sync_enabled": false,
"cursor": "59440671dd20196ca3a7cc613dc13438251e706d",
"cursor": "c257686a254906f7ffd333ed5da29c112924f7dd",
"_cursor_note": "cursor = last upstream commit ported into b24ui (oldest-first, manual cadence). sync_enabled stays false until Phase 2 (porter workflow #67 + CLAUDE_CODE_OAUTH_TOKEN) is wired and trusted. `processed` is maintained per port from now on (backfilled #68-#72 on 2026-06-09).",
"stats": {
"queue_depth": 0,
Expand Down Expand Up @@ -971,10 +971,16 @@
"summary": "fix(useFileUpload): keep dropzone type filter reactive to accept (#6699) — useDropZone got dataTypes.value (static snapshot); pass computed ref dataTypes instead. parseAcceptToDataTypes returns [] not undefined for unrestricted; dataTypes typed readonly string[]. b24ui composable matched, applied verbatim. Created test/composables/useFileUpload.spec.ts verbatim (composable-only). +2 test files (229), tests 5341."
},
"59440671dd20196ca3a7cc613dc13438251e706d": {
"pr": 261,
"b24ui_sha": "652b37237103cb1eab9024851e2ef50462eb1e89",
"decision": "port",
"summary": "fix(useScrollspy): unobserve previous headings on update (#6700) — observerCallback batches entry changes into a Set + single write (watcher runs once per callback); updateHeadings disconnect()s observer and resets visibleHeadings before re-observing so navigations don't accumulate stale targets. b24ui composable matched, applied verbatim. Created test/composables/useScrollspy.spec.ts verbatim (mock IntersectionObserver). +2 test files (231), tests 5355."
},
"c257686a254906f7ffd333ed5da29c112924f7dd": {
"pr": null,
"b24ui_sha": "pending-merge",
"decision": "port",
"summary": "fix(useScrollspy): unobserve previous headings on update (#6700) — observerCallback batches entry changes into a Set + single write (watcher runs once per callback); updateHeadings disconnect()s observer and resets visibleHeadings before re-observing so navigations don't accumulate stale targets. b24ui composable matched, applied verbatim. Created test/composables/useScrollspy.spec.ts verbatim (mock IntersectionObserver). +2 test files (231), tests 5355."
"summary": "test(composables): add specs for defineLocale, useKbd and useFormField (#6701) — test-only, 3 new specs. b24ui adaptations: defineLocale baseOptions +locale:'en' (b24ui requires locale field); useFormField error color assertion 'error'->'air-primary-alert' (b24ui token). useKbd verbatim. +6 test files (237), tests 5411."
}
}
}
86 changes: 86 additions & 0 deletions test/composables/defineLocale.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { defineLocale, extendLocale } from '../../src/runtime/composables/defineLocale'

interface TestMessages {
greeting: string
nested: {
hello: string
bye: string
}
}

const baseOptions = {
name: 'English',
code: 'en',
locale: 'en',
messages: {
greeting: 'Hello',
nested: {
hello: 'Hello',
bye: 'Bye'
}
}
}

describe('defineLocale', () => {
it('returns the provided options untouched', () => {
const locale = defineLocale<TestMessages>(baseOptions)

expect(locale.name).toBe('English')
expect(locale.code).toBe('en')
expect(locale.messages).toEqual(baseOptions.messages)
})

it('defaults dir to ltr when not provided', () => {
const locale = defineLocale<TestMessages>(baseOptions)

expect(locale.dir).toBe('ltr')
})

it('keeps an explicit dir', () => {
const locale = defineLocale<TestMessages>({ ...baseOptions, dir: 'rtl' })

expect(locale.dir).toBe('rtl')
})
})

describe('extendLocale', () => {
it('overrides top-level fields', () => {
const base = defineLocale<TestMessages>(baseOptions)
const extended = extendLocale<TestMessages>(base, { name: 'British English', code: 'en-GB' })

expect(extended.name).toBe('British English')
expect(extended.code).toBe('en-GB')
})

it('deep-merges messages, keeping untouched keys from the base', () => {
const base = defineLocale<TestMessages>(baseOptions)
const extended = extendLocale<TestMessages>(base, {
messages: {
nested: {
hello: 'Hi'
}
}
})

expect(extended.messages.nested.hello).toBe('Hi')
// Untouched keys fall back to the base locale.
expect(extended.messages.nested.bye).toBe('Bye')
expect(extended.messages.greeting).toBe('Hello')
})

it('inherits dir from the base locale when not overridden', () => {
const base = defineLocale<TestMessages>({ ...baseOptions, dir: 'rtl' })
const extended = extendLocale<TestMessages>(base, { name: 'Variant' })

expect(extended.dir).toBe('rtl')
})

it('does not mutate the base locale', () => {
const base = defineLocale<TestMessages>(baseOptions)
extendLocale<TestMessages>(base, { name: 'Variant', messages: { greeting: 'Yo' } })

expect(base.name).toBe('English')
expect(base.messages.greeting).toBe('Hello')
})
})
219 changes: 219 additions & 0 deletions test/composables/useFormField.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { defineComponent, h, provide, computed, ref } from 'vue'
import { useEventBus } from '@vueuse/core'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import {
useFormField,
formOptionsInjectionKey,
formBusInjectionKey,
formFieldInjectionKey,
inputIdInjectionKey
} from '../../src/runtime/composables/useFormField'
import type { FormEvent, FormFieldInjectedOptions, FormInjectedOptions } from '../../src/runtime/types/form'

// Concrete stand-in for a component's props type so `size`/`color` accept strings.
type TestProps = { size: string, color: string }

interface FieldProps {
id?: string
name?: string
size?: string
color?: string
highlight?: boolean
disabled?: boolean
}
type FieldOpts = Parameters<typeof useFormField>[1]

interface Context {
formOptions?: FormInjectedOptions
formField?: Partial<FormFieldInjectedOptions<any>>
inputId?: string
}

const teardowns: Array<() => void> = []

afterEach(() => {
teardowns.splice(0).forEach(fn => fn())
})

async function mountField(props?: FieldProps, opts?: FieldOpts, ctx: Context = {}) {
const events: FormEvent<any>[] = []
const bus = useEventBus<FormEvent<any>>(Symbol('test-form'))
const off = bus.on(event => events.push(event))

const inputIdRef = ref<string | undefined>(ctx.inputId)

let api!: ReturnType<typeof useFormField<TestProps>>
const Child = defineComponent({
setup() {
api = useFormField<TestProps>(props, opts)
return () => null
}
})

const Parent = defineComponent({
setup() {
provide(formBusInjectionKey, bus)
provide(inputIdInjectionKey, inputIdRef)
if (ctx.formOptions) {
provide(formOptionsInjectionKey, computed(() => ctx.formOptions as FormInjectedOptions))
}
if (ctx.formField) {
provide(formFieldInjectionKey, computed(() => ctx.formField as FormFieldInjectedOptions<any>))
}
return () => h(Child)
}
})

const wrapper = await mountSuspended(Parent)
teardowns.push(off, () => wrapper.unmount())
return { api, events, inputIdRef, wrapper }
}

describe('useFormField', () => {
describe('without a wrapping form field', () => {
it('derives its values from the props', async () => {
const { api } = await mountField({ id: 'my-id', name: 'my-name', size: 'lg', color: 'primary', highlight: true, disabled: true })

expect(api.id.value).toBe('my-id')
expect(api.name.value).toBe('my-name')
expect(api.size.value).toBe('lg')
expect(api.color.value).toBe('primary')
expect(api.highlight.value).toBe(true)
expect(api.disabled.value).toBe(true)
})

it('has no aria attributes', async () => {
const { api } = await mountField({ name: 'x' })

expect(api.ariaAttrs.value).toBeUndefined()
})

it('does not emit form events without an injected form field', async () => {
const { api, events } = await mountField({ name: 'x' })

api.emitFormBlur()
api.emitFormChange()
api.emitFormFocus()

expect(events).toHaveLength(0)
})
})

describe('with a wrapping form field', () => {
it('falls back to the field name, size and id', async () => {
const { api } = await mountField(
{},
undefined,
{ formField: { name: 'field-name', size: 'sm', ariaId: 'aria-1' }, inputId: 'input-1' }
)

expect(api.name.value).toBe('field-name')
expect(api.size.value).toBe('sm')
expect(api.id.value).toBe('input-1')
})

it('lets explicit props win over the field', async () => {
const { api } = await mountField(
{ name: 'prop-name', size: 'xl' },
undefined,
{ formField: { name: 'field-name', size: 'sm', ariaId: 'aria-1' } }
)

expect(api.name.value).toBe('prop-name')
expect(api.size.value).toBe('xl')
})

it('forces error color and highlight when the field has an error', async () => {
const { api } = await mountField(
{ color: 'primary', highlight: false },
undefined,
{ formField: { ariaId: 'aria-1', error: 'Required' } }
)

// b24ui uses the `air-primary-alert` token for the error state.
expect(api.color.value).toBe('air-primary-alert')
expect(api.highlight.value).toBe(true)
})

it('builds aria attributes from the descriptive field slots', async () => {
const { api } = await mountField(
{},
undefined,
{ formField: { ariaId: 'aria-1', error: 'Required', hint: 'Hint', description: 'Desc' } }
)

const attrs = api.ariaAttrs.value!
expect(attrs['aria-invalid']).toBe(true)
expect(attrs['aria-describedby']).toBe('aria-1-error aria-1-hint aria-1-description')
})

it('marks aria-invalid false and omits describedby when there is no error or description', async () => {
const { api } = await mountField({}, undefined, { formField: { ariaId: 'aria-1' } })

const attrs = api.ariaAttrs.value!
expect(attrs['aria-invalid']).toBe(false)
expect(attrs['aria-describedby']).toBeUndefined()
})

it('reflects the form-level disabled option', async () => {
const { api } = await mountField({}, undefined, { formField: { ariaId: 'aria-1' }, formOptions: { disabled: true } })

expect(api.disabled.value).toBe(true)
})
})

describe('input id binding', () => {
it('sets the input id from props.id by default', async () => {
const { inputIdRef } = await mountField({ id: 'bound-id' }, undefined, { formField: { ariaId: 'aria-1' }, inputId: 'initial' })

expect(inputIdRef.value).toBe('bound-id')
})

it('clears the input id when bind is false', async () => {
const { inputIdRef } = await mountField({ id: 'bound-id' }, { bind: false }, { formField: { ariaId: 'aria-1' }, inputId: 'initial' })

expect(inputIdRef.value).toBeUndefined()
})
})

describe('form events', () => {
it('emits blur, focus and change with the field name', async () => {
const { api, events } = await mountField({}, undefined, { formField: { name: 'email', ariaId: 'aria-1' } })

api.emitFormBlur()
api.emitFormFocus()
api.emitFormChange()

expect(events).toEqual([
{ type: 'blur', name: 'email', eager: undefined },
{ type: 'focus', name: 'email', eager: undefined },
{ type: 'change', name: 'email', eager: undefined }
])
})

it('emits a debounced eager input event', async () => {
const { api, events } = await mountField({}, undefined, { formField: { name: 'email', ariaId: 'aria-1' } })

api.emitFormInput()

await vi.waitFor(() => expect(events).toContainEqual({ type: 'input', name: 'email', eager: true }))
})

it('defers input validation when requested and the field is not eager', async () => {
const { api, events } = await mountField({}, { deferInputValidation: true }, { formField: { name: 'email', ariaId: 'aria-1', eagerValidation: false } })

api.emitFormInput()

await vi.waitFor(() => expect(events).toContainEqual({ type: 'input', name: 'email', eager: false }))
})

it('stays eager when the field opts into eager validation, even with defer', async () => {
const { api, events } = await mountField({}, { deferInputValidation: true }, { formField: { name: 'email', ariaId: 'aria-1', eagerValidation: true } })

api.emitFormInput()

await vi.waitFor(() => expect(events).toContainEqual({ type: 'input', name: 'email', eager: true }))
})
})
})
Loading