mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
feat(nuxt): useId
composable (#23368)
Co-authored-by: Daniel Roe <daniel@roe.dev> Co-authored-by: Sébastien Chopin <seb@nuxt.com>
This commit is contained in:
parent
46b5336718
commit
658a0ffed7
29
docs/3.api/2.composables/use-id.md
Normal file
29
docs/3.api/2.composables/use-id.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: "useId"
|
||||||
|
description: Generate an SSR-friendly unique identifier that can be passed to accessibility attributes.
|
||||||
|
---
|
||||||
|
|
||||||
|
`useId` generates an SSR-friendly unique identifier that can be passed to accessibility attributes.
|
||||||
|
|
||||||
|
Call `useId` at the top level of your component to generate a unique string identifier:
|
||||||
|
|
||||||
|
```vue [components/EmailField.vue]
|
||||||
|
<script setup lang="ts">
|
||||||
|
const id = useId()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label :for="id">Email</label>
|
||||||
|
<input :id="id" name="email" type="email"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
`useId` does not take any parameters.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
`useId` returns a unique string associated with this particular `useId` call in this particular component.
|
@ -1,7 +1,9 @@
|
|||||||
import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, ref } from 'vue'
|
import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue'
|
||||||
import type { ComponentInternalInstance, ComponentOptions } from 'vue'
|
import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue'
|
||||||
import { getFragmentHTML } from './utils'
|
import { getFragmentHTML } from './utils'
|
||||||
|
|
||||||
|
export const clientOnlySymbol: InjectionKey<boolean> = Symbol.for('nuxt:client-only')
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'ClientOnly',
|
name: 'ClientOnly',
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
@ -10,6 +12,7 @@ export default defineComponent({
|
|||||||
setup (_, { slots, attrs }) {
|
setup (_, { slots, attrs }) {
|
||||||
const mounted = ref(false)
|
const mounted = ref(false)
|
||||||
onMounted(() => { mounted.value = true })
|
onMounted(() => { mounted.value = true })
|
||||||
|
provide(clientOnlySymbol, true)
|
||||||
return (props: any) => {
|
return (props: any) => {
|
||||||
if (mounted.value) { return slots.default?.() }
|
if (mounted.value) { return slots.default?.() }
|
||||||
const slot = slots.fallback || slots.placeholder
|
const slot = slots.fallback || slots.placeholder
|
||||||
|
49
packages/nuxt/src/app/composables/id.ts
Normal file
49
packages/nuxt/src/app/composables/id.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { getCurrentInstance, inject } from 'vue'
|
||||||
|
import { useNuxtApp } from '../nuxt'
|
||||||
|
import { clientOnlySymbol } from '#app/components/client-only'
|
||||||
|
|
||||||
|
const ATTR_KEY = 'data-n-ids'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an SSR-friendly unique identifier that can be passed to accessibility attributes.
|
||||||
|
*/
|
||||||
|
export function useId (key?: string): string {
|
||||||
|
if (typeof key !== 'string') {
|
||||||
|
throw new TypeError('[nuxt] [useId] key must be a string.')
|
||||||
|
}
|
||||||
|
const nuxtApp = useNuxtApp()
|
||||||
|
const instance = getCurrentInstance()
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
// TODO: support auto-incrementing ID for plugins if there is need?
|
||||||
|
throw new TypeError('[nuxt] `useId` must be called within a component.')
|
||||||
|
}
|
||||||
|
|
||||||
|
nuxtApp._id ||= 0
|
||||||
|
instance._nuxtIdIndex ||= {}
|
||||||
|
instance._nuxtIdIndex[key] ||= 0
|
||||||
|
|
||||||
|
const instanceIndex = key + ':' + instance._nuxtIdIndex[key]++
|
||||||
|
|
||||||
|
if (import.meta.server) {
|
||||||
|
const ids = JSON.parse(instance.attrs[ATTR_KEY] as string | undefined || '{}')
|
||||||
|
ids[instanceIndex] = key + ':' + nuxtApp._id++
|
||||||
|
instance.attrs[ATTR_KEY] = JSON.stringify(ids)
|
||||||
|
return ids[instanceIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nuxtApp.payload.serverRendered && nuxtApp.isHydrating && !inject(clientOnlySymbol, false)) {
|
||||||
|
// Access data attribute from sibling if root is a comment node and sibling is an element
|
||||||
|
const el = instance.vnode.el?.nodeType === 8 && instance.vnode.el?.nextElementSibling?.getAttribute
|
||||||
|
? instance.vnode.el?.nextElementSibling
|
||||||
|
: instance.vnode.el
|
||||||
|
|
||||||
|
const ids = JSON.parse(el?.getAttribute?.(ATTR_KEY) || '{}')
|
||||||
|
if (ids[instanceIndex]) {
|
||||||
|
return ids[instanceIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pure client-side ids, avoiding potential collision with server-side ids
|
||||||
|
return key + '_' + nuxtApp._id++
|
||||||
|
}
|
@ -35,3 +35,4 @@ export type { NuxtAppManifest, NuxtAppManifestMeta } from './manifest'
|
|||||||
export type { ReloadNuxtAppOptions } from './chunk'
|
export type { ReloadNuxtAppOptions } from './chunk'
|
||||||
export { reloadNuxtApp } from './chunk'
|
export { reloadNuxtApp } from './chunk'
|
||||||
export { useRequestURL } from './url'
|
export { useRequestURL } from './url'
|
||||||
|
export { useId } from './id'
|
||||||
|
@ -102,6 +102,8 @@ interface _NuxtApp {
|
|||||||
|
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_id?: number
|
||||||
/** @internal */
|
/** @internal */
|
||||||
_scope: EffectScope
|
_scope: EffectScope
|
||||||
/** @internal */
|
/** @internal */
|
||||||
|
1
packages/nuxt/src/app/types/augments.d.ts
vendored
1
packages/nuxt/src/app/types/augments.d.ts
vendored
@ -31,5 +31,6 @@ declare module 'vue' {
|
|||||||
}
|
}
|
||||||
interface ComponentInternalInstance {
|
interface ComponentInternalInstance {
|
||||||
_nuxtOnBeforeMountCbs: Function[]
|
_nuxtOnBeforeMountCbs: Function[]
|
||||||
|
_nuxtIdIndex?: Record<string, number>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,10 @@ const granularAppPresets: InlinePreset[] = [
|
|||||||
{
|
{
|
||||||
imports: ['useRequestURL'],
|
imports: ['useRequestURL'],
|
||||||
from: '#app/composables/url'
|
from: '#app/composables/url'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imports: ['useId'],
|
||||||
|
from: '#app/composables/id'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -136,6 +136,7 @@ export default defineUntypedSchema({
|
|||||||
*/
|
*/
|
||||||
keyedComposables: {
|
keyedComposables: {
|
||||||
$resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [
|
$resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [
|
||||||
|
{ name: 'useId', argumentLength: 1 },
|
||||||
{ name: 'callOnce', argumentLength: 2 },
|
{ name: 'callOnce', argumentLength: 2 },
|
||||||
{ name: 'defineNuxtComponent', argumentLength: 2 },
|
{ name: 'defineNuxtComponent', argumentLength: 2 },
|
||||||
{ name: 'useState', argumentLength: 2 },
|
{ name: 'useState', argumentLength: 2 },
|
||||||
|
@ -872,7 +872,7 @@ describe('navigate external', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('composables', () => {
|
describe('composables', () => {
|
||||||
it('should run code once', async () => {
|
it('`callOnce` should run code once', async () => {
|
||||||
const html = await $fetch('/once')
|
const html = await $fetch('/once')
|
||||||
|
|
||||||
expect(html).toContain('once.vue')
|
expect(html).toContain('once.vue')
|
||||||
@ -881,6 +881,31 @@ describe('composables', () => {
|
|||||||
const { page } = await renderPage('/once')
|
const { page } = await renderPage('/once')
|
||||||
expect(await page.getByText('once:').textContent()).toContain('once: 2')
|
expect(await page.getByText('once:').textContent()).toContain('once: 2')
|
||||||
})
|
})
|
||||||
|
it('`useId` should generate unique ids', async () => {
|
||||||
|
// TODO: work around interesting Vue bug where async components are loaded in a different order on first import
|
||||||
|
await $fetch('/use-id')
|
||||||
|
|
||||||
|
const sanitiseHTML = (html: string) => html.replace(/ data-[^= ]+="[^"]+"/g, '').replace(/<!--[[\]]-->/, '')
|
||||||
|
|
||||||
|
const serverHTML = await $fetch('/use-id').then(html => sanitiseHTML(html.match(/<form.*<\/form>/)![0]))
|
||||||
|
const ids = serverHTML.match(/id="[^"]*"/g)?.map(id => id.replace(/id="([^"]*)"/, '$1')) as string[]
|
||||||
|
const renderedForm = [
|
||||||
|
`<h2 id="${ids[0]}"> id: ${ids[0]}</h2><div><label for="${ids[1]}">Email</label><input id="${ids[1]}" name="email" type="email"><label for="${ids[2]}">Password</label><input id="${ids[2]}" name="password" type="password"></div>`,
|
||||||
|
`<div><label for="${ids[3]}">Email</label><input id="${ids[3]}" name="email" type="email"><label for="${ids[4]}">Password</label><input id="${ids[4]}" name="password" type="password"></div>`
|
||||||
|
]
|
||||||
|
const clientOnlyServer = '<span></span>'
|
||||||
|
expect(serverHTML).toEqual(`<form>${renderedForm.join(clientOnlyServer)}</form>`)
|
||||||
|
|
||||||
|
const { page, pageErrors } = await renderPage('/use-id')
|
||||||
|
const clientHTML = await page.innerHTML('form')
|
||||||
|
const clientIds = clientHTML
|
||||||
|
.match(/id="[^"]*"/g)?.map(id => id.replace(/id="([^"]*)"/, '$1'))
|
||||||
|
.filter(i => !ids.includes(i)) as string[]
|
||||||
|
const clientOnlyClient = `<div><label for="${clientIds[0]}">Email</label><input id="${clientIds[0]}" name="email" type="email"><label for="${clientIds[1]}">Password</label><input id="${clientIds[1]}" name="password" type="password"></div>`
|
||||||
|
expect(sanitiseHTML(clientHTML)).toEqual(`${renderedForm.join(clientOnlyClient)}`)
|
||||||
|
expect(pageErrors).toEqual([])
|
||||||
|
await page.close()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('middlewares', () => {
|
describe('middlewares', () => {
|
||||||
|
21
test/fixtures/basic/components/ComponentWithIds.vue
vendored
Normal file
21
test/fixtures/basic/components/ComponentWithIds.vue
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
const emailId = useId()
|
||||||
|
const passwordId = useId()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label :for="emailId">Email</label>
|
||||||
|
<input
|
||||||
|
:id="emailId"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
>
|
||||||
|
<label :for="passwordId">Password</label>
|
||||||
|
<input
|
||||||
|
:id="passwordId"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
15
test/fixtures/basic/pages/use-id.vue
vendored
Normal file
15
test/fixtures/basic/pages/use-id.vue
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup>
|
||||||
|
const id = useId()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form>
|
||||||
|
<h2 :id="id">
|
||||||
|
id: {{ id }}
|
||||||
|
</h2>
|
||||||
|
<LazyComponentWithIds />
|
||||||
|
<ClientOnly><ComponentWithIds /></ClientOnly>
|
||||||
|
<ComponentWithIds />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
@ -3,6 +3,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { defineEventHandler } from 'h3'
|
import { defineEventHandler } from 'h3'
|
||||||
|
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
|
|
||||||
import * as composables from '#app/composables'
|
import * as composables from '#app/composables'
|
||||||
@ -14,6 +15,7 @@ import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders
|
|||||||
import { clearNuxtState, useState } from '#app/composables/state'
|
import { clearNuxtState, useState } from '#app/composables/state'
|
||||||
import { useRequestURL } from '#app/composables/url'
|
import { useRequestURL } from '#app/composables/url'
|
||||||
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
|
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
|
||||||
|
import { useId } from '#app/composables/id'
|
||||||
import { callOnce } from '#app/composables/once'
|
import { callOnce } from '#app/composables/once'
|
||||||
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
||||||
|
|
||||||
@ -98,6 +100,7 @@ describe('composables', () => {
|
|||||||
'clearNuxtState',
|
'clearNuxtState',
|
||||||
'useState',
|
'useState',
|
||||||
'useRequestURL',
|
'useRequestURL',
|
||||||
|
'useId',
|
||||||
'useRoute',
|
'useRoute',
|
||||||
'navigateTo',
|
'navigateTo',
|
||||||
'abortNavigation',
|
'abortNavigation',
|
||||||
@ -431,6 +434,33 @@ describe('clearNuxtState', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useId', () => {
|
||||||
|
it('default', () => {
|
||||||
|
const vals = new Set<string>()
|
||||||
|
for (let index = 0; index < 100; index++) {
|
||||||
|
mount(defineComponent({
|
||||||
|
setup () {
|
||||||
|
const id = useId()
|
||||||
|
vals.add(id)
|
||||||
|
return () => h('div', id)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
expect(vals.size).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates unique ids per-component', () => {
|
||||||
|
const component = defineComponent({
|
||||||
|
setup () {
|
||||||
|
const id = useId()
|
||||||
|
return () => h('div', id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mount(component).html()).not.toBe(mount(component).html())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('url', () => {
|
describe('url', () => {
|
||||||
it('useRequestURL', () => {
|
it('useRequestURL', () => {
|
||||||
const url = useRequestURL()
|
const url = useRequestURL()
|
||||||
|
Loading…
Reference in New Issue
Block a user