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 type { ComponentInternalInstance, ComponentOptions } from 'vue'
|
||||
import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue'
|
||||
import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue'
|
||||
import { getFragmentHTML } from './utils'
|
||||
|
||||
export const clientOnlySymbol: InjectionKey<boolean> = Symbol.for('nuxt:client-only')
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ClientOnly',
|
||||
inheritAttrs: false,
|
||||
@ -10,6 +12,7 @@ export default defineComponent({
|
||||
setup (_, { slots, attrs }) {
|
||||
const mounted = ref(false)
|
||||
onMounted(() => { mounted.value = true })
|
||||
provide(clientOnlySymbol, true)
|
||||
return (props: any) => {
|
||||
if (mounted.value) { return slots.default?.() }
|
||||
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 { reloadNuxtApp } from './chunk'
|
||||
export { useRequestURL } from './url'
|
||||
export { useId } from './id'
|
||||
|
@ -102,6 +102,8 @@ interface _NuxtApp {
|
||||
|
||||
[key: string]: unknown
|
||||
|
||||
/** @internal */
|
||||
_id?: number
|
||||
/** @internal */
|
||||
_scope: EffectScope
|
||||
/** @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 {
|
||||
_nuxtOnBeforeMountCbs: Function[]
|
||||
_nuxtIdIndex?: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
@ -100,6 +100,10 @@ const granularAppPresets: InlinePreset[] = [
|
||||
{
|
||||
imports: ['useRequestURL'],
|
||||
from: '#app/composables/url'
|
||||
},
|
||||
{
|
||||
imports: ['useId'],
|
||||
from: '#app/composables/id'
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -136,6 +136,7 @@ export default defineUntypedSchema({
|
||||
*/
|
||||
keyedComposables: {
|
||||
$resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [
|
||||
{ name: 'useId', argumentLength: 1 },
|
||||
{ name: 'callOnce', argumentLength: 2 },
|
||||
{ name: 'defineNuxtComponent', argumentLength: 2 },
|
||||
{ name: 'useState', argumentLength: 2 },
|
||||
|
@ -872,7 +872,7 @@ describe('navigate external', () => {
|
||||
})
|
||||
|
||||
describe('composables', () => {
|
||||
it('should run code once', async () => {
|
||||
it('`callOnce` should run code once', async () => {
|
||||
const html = await $fetch('/once')
|
||||
|
||||
expect(html).toContain('once.vue')
|
||||
@ -881,6 +881,31 @@ describe('composables', () => {
|
||||
const { page } = await renderPage('/once')
|
||||
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', () => {
|
||||
@ -1420,7 +1445,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
|
||||
expect(files.map(m => m.replace(/\.\w+(\.\w+)$/, '$1'))).toContain('css-only-asset.svg')
|
||||
})
|
||||
|
||||
it('should not include inlined CSS in generated CSS file', async () => {
|
||||
it('should not include inlined CSS in generated CSS file', async () => {
|
||||
const html: string = await $fetch('/styles')
|
||||
const cssFiles = new Set([...html.matchAll(/<link [^>]*href="([^"]*\.css)">/g)].map(m => m[1]))
|
||||
let css = ''
|
||||
|
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 { defineEventHandler } from 'h3'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
|
||||
import * as composables from '#app/composables'
|
||||
@ -14,6 +15,7 @@ import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders
|
||||
import { clearNuxtState, useState } from '#app/composables/state'
|
||||
import { useRequestURL } from '#app/composables/url'
|
||||
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
|
||||
import { useId } from '#app/composables/id'
|
||||
import { callOnce } from '#app/composables/once'
|
||||
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
||||
|
||||
@ -98,6 +100,7 @@ describe('composables', () => {
|
||||
'clearNuxtState',
|
||||
'useState',
|
||||
'useRequestURL',
|
||||
'useId',
|
||||
'useRoute',
|
||||
'navigateTo',
|
||||
'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', () => {
|
||||
it('useRequestURL', () => {
|
||||
const url = useRequestURL()
|
||||
|
Loading…
Reference in New Issue
Block a user