1
0
Эх сурвалжийг харах

Add id magic (#2400)

* wip

* wip

* Update implementation and tests

* wip

* wip

* Add docs
Caleb Porzio 3 жил өмнө
parent
commit
e5ed0c2f5f

+ 1 - 0
packages/alpinejs/src/directives.js

@@ -160,6 +160,7 @@ let directiveOrder = [
     'ignore',
     'ref',
     'data',
+    'id',
     'bind',
     'init',
     'for',

+ 1 - 0
packages/alpinejs/src/directives/index.js

@@ -12,4 +12,5 @@ import './x-show'
 import './x-for'
 import './x-ref'
 import './x-if'
+import './x-id'
 import './x-on'

+ 8 - 0
packages/alpinejs/src/directives/x-id.js

@@ -0,0 +1,8 @@
+import { directive } from "../directives"
+import { setIdRoot } from '../ids'
+
+directive('id', (el, { expression }, { evaluate }) => {
+    let names = evaluate(expression)
+    
+    names.forEach(name => setIdRoot(el, name))
+})

+ 20 - 0
packages/alpinejs/src/ids.js

@@ -0,0 +1,20 @@
+import { findClosest } from './lifecycle'
+
+let globalIdMemo = {}
+
+export function findAndIncrementId(name) {
+    if (! globalIdMemo[name]) globalIdMemo[name] = 0
+
+    return ++globalIdMemo[name]
+}
+
+export function closestIdRoot(el, name) {
+    return findClosest(el, element => {
+        if (element._x_ids && element._x_ids[name]) return true
+    })
+}
+
+export function setIdRoot(el, name) {
+    if (! el._x_ids) el._x_ids = {}
+    if (! el._x_ids[name]) el._x_ids[name] = findAndIncrementId(name) 
+}

+ 10 - 4
packages/alpinejs/src/lifecycle.js

@@ -45,15 +45,21 @@ export function addRootSelector(selectorCallback) { rootSelectorCallbacks.push(s
 export function addInitSelector(selectorCallback) { initSelectorCallbacks.push(selectorCallback) }
 
 export function closestRoot(el, includeInitSelectors = false) {
-    if (!el) return
+    return findClosest(el, element => {
+        const selectors = includeInitSelectors ? allSelectors() : rootSelectors()
 
-    const selectors = includeInitSelectors ? allSelectors() : rootSelectors()
+        if (selectors.some(selector => element.matches(selector))) return true
+    }) 
+}
+
+export function findClosest(el, callback) {
+    if (! el) return
 
-    if (selectors.some(selector => el.matches(selector))) return el
+    if (callback(el)) return el
 
     if (! el.parentElement) return
 
-    return closestRoot(el.parentElement, includeInitSelectors)
+    return findClosest(el.parentElement, callback)
 }
 
 export function isRoot(el) {

+ 25 - 0
packages/alpinejs/src/magics/$id.js

@@ -0,0 +1,25 @@
+import { magic } from '../magics'
+import { closestIdRoot, findAndIncrementId } from '../ids'
+
+magic('id', el => (name, key = null) => {
+    let root = closestIdRoot(el, name)
+
+    let id = root
+        ? root._x_ids[name]
+        : findAndIncrementId(name)
+
+    return key
+        ? new AlpineId(`${name}-${id}-${key}`)
+        : new AlpineId(`${name}-${id}`)
+})
+
+class AlpineId {
+    constructor(id) {
+        this.id = id
+    }
+
+    toString() {
+        return this.id
+    }
+}
+

+ 1 - 0
packages/alpinejs/src/magics/index.js

@@ -5,4 +5,5 @@ import './$store'
 import './$data'
 import './$root'
 import './$refs'
+import './$id'
 import './$el'

+ 33 - 0
packages/docs/src/en/directives/id.md

@@ -0,0 +1,33 @@
+---
+order: 17
+title: id
+---
+
+# x-id
+`x-id` allows you to declare a new "scope" for any new IDs generated using `$id()`. It accepts an array of strings (ID names) and adds a suffix to each `$id('...')` generated within it that is unique to other IDs on the page.
+
+`x-id` is meant to be used in conjunction with the `$id(...)` magic.
+
+[Visit the $id documentation](/magics/id) for a better understanding of this feature.
+
+Here's a brief example of this directive in use:
+
+```alpine
+<div x-id="['text-input']">
+    <label :for="$id('text-input')">Username</label>
+    <!-- for="text-input-1" -->
+
+    <input type="text" :id="$id('text-input')">
+    <!-- id="text-input-1" -->
+</div>
+
+<div x-id="['text-input']">
+    <label :for="$id('text-input')">Username</label>
+    <!-- for="text-input-2" -->
+
+    <input type="text" :id="$id('text-input')">
+    <!-- id="text-input-2" -->
+</div>
+```
+
+

+ 106 - 0
packages/docs/src/en/magics/id.md

@@ -0,0 +1,106 @@
+---
+order: 9
+prefix: $
+title: id
+---
+
+# $id
+
+`$id` is a magic property that can be used to generate an element's ID and ensure that it is within on the page and won't conflict with other IDs of the same name.
+
+This utility is extremely helpful when building re-usable components (presumably in a back-end template) that might occur multiple times on a page, and make use of ID attributes.
+
+Things like input components, modals, listboxes, etc. will all benefit from this utility.
+
+<a name="basic-usage"></a>
+## Basic usage
+
+Suppose you have two input elements on a page, and you want them to have a unique ID from each other, you can do the following:
+
+```alpine
+<input type="text" :id="$id('text-input')">
+<!-- id="text-input-1" -->
+
+<input type="text" :id="$id('text-input')">
+<!-- id="text-input-2" -->
+```
+
+As you can see, `$id` takes in a string and spits out an appended suffix that is unique on the page.
+
+<a name="groups-with-x-id"></a>
+## Grouping with x-id
+
+Now let's say you want to have those same two input elements, but this time you want `<label>` elements for each of them.
+
+This presents a problem, you now need to be able to reference the same ID twice. One for the `<label>`'s `for` attribute, and the other for the `id` on the input.
+
+Here's is a way that you might think to accomplish this and is totally valid:
+
+```alpine
+<div x-data="{ id: $id('text-input') }">
+    <label :for="id"> <!-- "text-input-1" -->
+    <input type="text" :id="id"> <!-- "text-input-1" -->
+</div>
+
+<div x-data="{ id: $id('text-input') }">
+    <label :for="id"> <!-- "text-input-2" -->
+    <input type="text" :id="id"> <!-- "text-input-2" -->
+</div>
+```
+
+This approach is fine, however, having to name and store the ID in your component scope feels cumbersome.
+
+To accomplish this same task in a more flexible way, you can use Alpine's `x-id` directive to declare an "id scope" for a set of IDs:
+
+```alpine
+<div x-id="['text-input']">
+    <label :for="$id('text-input')"> <!-- "text-input-1" -->
+    <input type="text" :id="$id('text-input')"> <!-- "text-input-1" -->
+</div>
+
+<div x-id="['text-input']">
+    <label :for="$id('text-input')"> <!-- "text-input-2" -->
+    <input type="text" :id="$id('text-input')"> <!-- "text-input-2" -->
+</div>
+```
+
+As you can see, `x-id` accepts an array of ID names. Now any usages of `$id()` within that scope, will all use the same ID. Think of them as "id groups".
+
+<a name="nesting"></a>
+## Nesting
+
+As you might have intuited, you can freely nest these `x-id` groups, like so:
+
+```alpine
+<div x-id="['text-input']">
+    <label :for="$id('text-input')"> <!-- "text-input-1" -->
+    <input type="text" :id="$id('text-input')"> <!-- "text-input-1" -->
+
+    <div x-id="['text-input']">
+        <label :for="$id('text-input')"> <!-- "text-input-2" -->
+        <input type="text" :id="$id('text-input')"> <!-- "text-input-2" -->
+    </div>
+</div>
+```
+
+<a name="keyed-ids"></a>
+## Keyed IDs (For Looping)
+
+Sometimes, it is helpful to specify an additional suffix on the end of an ID for the purpose of identifying it within a loop.
+
+For this, `$id()` accepts an optional second parameter that will be added as a suffix on the end of the generated ID.
+
+A common example of this need is something like a listbox component that uses the `aria-activedescendant` attribute to tell assistive technologies which element is "active" in the list:
+
+```alpine
+<ul
+    x-id="['list-item']"
+    :aria-activedescendant="$id('list-item', activeItem.id)"
+>
+    <template x-for="item in items" :key="item.id">
+        <li :id="$id('list-item', item.id)">...</li>
+    </template>
+</ul>
+```
+
+This is an incomplete example of a listbox, but it should still be helpful to demonstrate a scenario where you might need each ID in a group to still be unique to the page, but also be keyed within a loop so that you can reference individual IDs within that group.

+ 129 - 0
tests/cypress/integration/magics/$id.spec.js

@@ -0,0 +1,129 @@
+import { haveAttribute, haveText, html, test } from '../../utils'
+
+test('$id generates a unique id',
+    html`
+        <div x-data x-id="['foo']" id="1">
+            <div>
+                <h1 :id="$id('foo')"></h1>
+            </div>
+
+            <span :aria-labelledby="$id('foo')"></span>
+        </div>
+
+        <div x-data x-id="['foo']" id="2">
+            <div>
+                <h1 :id="$id('foo')"></h1>
+            </div>
+
+            <span :aria-labelledby="$id('foo')"></span>
+        </div>
+    `,
+    ({ get }) => {
+        get('#1 h1').should(haveAttribute('id', 'foo-1'))
+        get('#1 span').should(haveAttribute('aria-labelledby', 'foo-1'))
+        get('#2 h1').should(haveAttribute('id', 'foo-2'))
+        get('#2 span').should(haveAttribute('aria-labelledby', 'foo-2'))
+    }
+)
+
+test('$id works with keys and nested data scopes',
+    html`
+        <div x-data x-id="['foo']" id="1">
+            <!-- foo-1-3 -->
+            <span :aria-activedescendant="$id('foo', 3)"></span>
+
+            <ul>
+                <li x-data :id="$id('foo', 1)"></li> <!-- foo-1-1 -->
+                <li x-data :id="$id('foo', 2)"></li> <!-- foo-1-2 -->
+                <li x-data :id="$id('foo', 3)"></li> <!-- foo-1-3 -->
+            </ul>
+        </div>
+
+        <div x-data x-id="['foo']" id="2">
+            <!-- foo-2-3 -->
+            <span :aria-activedescendant="$id('foo', 3)"></span>
+
+            <ul>
+                <li x-data :id="$id('foo', 1)"></li> <!-- foo-2-1 -->
+                <li x-data :id="$id('foo', 2)"></li> <!-- foo-2-2 -->
+                <li x-data :id="$id('foo', 3)"></li> <!-- foo-2-3 -->
+            </ul>
+        </div>
+    `,
+    ({ get }) => {
+        get('#1 span').should(haveAttribute('aria-activedescendant', 'foo-1-3'))
+        get('#1 li:nth-child(1)').should(haveAttribute('id', 'foo-1-1'))
+        get('#1 li:nth-child(2)').should(haveAttribute('id', 'foo-1-2'))
+        get('#1 li:nth-child(3)').should(haveAttribute('id', 'foo-1-3'))
+        get('#2 span').should(haveAttribute('aria-activedescendant', 'foo-2-3'))
+        get('#2 li:nth-child(1)').should(haveAttribute('id', 'foo-2-1'))
+        get('#2 li:nth-child(2)').should(haveAttribute('id', 'foo-2-2'))
+        get('#2 li:nth-child(3)').should(haveAttribute('id', 'foo-2-3'))
+    }
+)
+
+test('$id scopes are grouped by name',
+    html`
+        <div x-data x-id="['foo']">
+            <!-- foo-1 -->
+            <span :aria-activedescendant="$id('foo')"></span>
+
+            <ul>
+                <li x-data x-id="['bar']" :id="$id('bar')"></li> <!-- bar-1 -->
+                <li x-data x-id="['bar']" :id="$id('bar')"></li> <!-- bar-2 -->
+            </ul>
+        </div>
+    `,
+    ({ get }) => {
+        get('span').should(haveAttribute('aria-activedescendant', 'foo-1'))
+        get('li:nth-child(1)').should(haveAttribute('id', 'bar-1'))
+        get('li:nth-child(2)').should(haveAttribute('id', 'bar-2'))
+    }
+)
+
+test('$ids are globally unique when outside x-id',
+    html`
+        <div x-data>
+            <h1 :id="$id('foo')"></h1>
+            <h2 :id="$id('foo')"></h2>
+        </div>
+    `,
+    ({ get }) => {
+        get('h1').should(haveAttribute('id', 'foo-1'))
+        get('h2').should(haveAttribute('id', 'foo-2'))
+    }
+)
+
+test('$id scopes can be reset',
+    html`
+        <div x-data x-id="['foo', 'bar']">
+            <!-- foo-1 -->
+            <span :aria-labelledby="$id('foo')"></span>
+
+            <div x-data>
+                <h1 :id="$id('foo')"></h1>
+                <h5 :id="$id('bar')"></h5>
+                
+                <div x-id="['foo']">
+                    <h2 :aria-labelledby="$id('foo')"></h2>
+                    <h6 :aria-labelledby="$id('bar')"></h6>
+
+                    <div x-data>
+                        <h3 :id="$id('foo')"></h3>
+                    </div>
+                </div>
+
+                <h4 :id="$id('foo')"></h4>
+            </div>
+        </div>
+    `,
+    ({ get }) => {
+        get('span').should(haveAttribute('aria-labelledby', 'foo-1'))
+        get('h1').should(haveAttribute('id', 'foo-1'))
+        get('h2').should(haveAttribute('aria-labelledby', 'foo-2'))
+        get('h3').should(haveAttribute('id', 'foo-2'))
+        get('h4').should(haveAttribute('id', 'foo-1'))
+        get('h5').should(haveAttribute('id', 'bar-1'))
+        get('h6').should(haveAttribute('aria-labelledby', 'bar-1'))
+    }
+)