Skip to content

Commit 2873afa

Browse files
authored
:sparkes: Creates Alpine Scope (#21)
* ✨ Adds $scope * 🔧 Adjusts configs * 🏷️ Fixes Types * 📝 Updates Documentation
1 parent dd40353 commit 2873afa

13 files changed

+1602
-687
lines changed

package.json

+18-18
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,26 @@
2626
"@milahu/patch-package": "6.4.14",
2727
"@trivago/prettier-plugin-sort-imports": "4.3.0",
2828
"@types/alpinejs": "3.13.6",
29-
"@types/node": "20.11.1",
30-
"@typescript-eslint/eslint-plugin": "6.18.1",
31-
"@typescript-eslint/parser": "6.18.1",
32-
"@vitest/ui": "1.2.0",
33-
"alpinejs": "3.13.3",
34-
"esbuild": "0.19.11",
35-
"eslint": "8.56.0",
36-
"happy-dom": "13.1.4",
37-
"husky": "8.0.3",
38-
"lint-staged": "15.2.0",
29+
"@types/node": "20.11.21",
30+
"@typescript-eslint/eslint-plugin": "7.1.0",
31+
"@typescript-eslint/parser": "7.1.0",
32+
"@vitest/ui": "1.3.1",
33+
"alpinejs": "3.13.5",
34+
"esbuild": "0.20.1",
35+
"eslint": "8.57.0",
36+
"husky": "9.0.11",
37+
"lint-staged": "15.2.2",
3938
"npm-run-all": "4.1.5",
40-
"prettier": "3.2.2",
39+
"prettier": "3.2.5",
4140
"pretty-bytes": "6.1.1",
41+
"testing-library-alpine": "0.0.1-alpha.0",
4242
"typescript": "5.3.3",
43-
"vite": "5.0.11",
44-
"vite-plugin-dts": "3.7.0",
45-
"vite-tsconfig-paths": "4.2.3",
46-
"vitest": "1.2.0",
47-
"vitest-dom": "0.1.1"
43+
"vite": "5.1.4",
44+
"vite-plugin-dts": "3.7.3",
45+
"vite-tsconfig-paths": "4.3.1",
46+
"vitest": "1.3.1",
47+
"vitest-dom": "0.1.1",
48+
"vitest-environment-alpine": "0.0.2-alpha.1"
4849
},
4950
"lint-staged": {
5051
"*.{js,ts,mjs}": [
@@ -67,13 +68,12 @@
6768
},
6869
"pnpm": {
6970
"overrides": {
70-
"happy-dom@>9.1.9": "9.1.9",
7171
"typescript@<5.1.6": "5.1.6",
7272
"semver@<7.5.2": ">=7.5.2"
7373
}
7474
},
7575
"dependencies": {
76-
"@vue/reactivity": "^3.4.13",
76+
"@vue/reactivity": "^3.4.20",
7777
"alpinets": "link:../alpinets/packages/alpinets"
7878
}
7979
}

packages/params/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Import to Build (Simple Version):
1919
import Alpine from 'alpinejs';
2020
import Params from '@ekwoka/alpine-history';
2121

22-
Alpine.plugin(Params); // key used for your Cloudinary with Fetch API
22+
Alpine.plugin(Params);
2323

2424
window.Alpine = Alpine;
2525
Alpine.start();

packages/scope/LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2021 Eric Kwoka
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

packages/scope/README.md

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Alpine Scope: Scoped Context Naming for AlpineJS
2+
3+
[<img src="https://img.shields.io/npm/v/@ekwoka/alpine-scope?label=%20&style=for-the-badge&logo=pnpm&logoColor=white">](https://www.npmjs.com/package/@ekwoka/alpine-scope)
4+
<img src="https://img.shields.io/npm/types/@ekwoka/alpine-scope?label=%20&amp;logo=typescript&amp;logoColor=white&amp;style=for-the-badge">
5+
<img src="https://img.shields.io/npm/dt/@ekwoka/alpine-scope?style=for-the-badge&logo=npm&logoColor=white" >
6+
[<img src="https://img.shields.io/bundlephobia/minzip/@ekwoka/alpine-scope?style=for-the-badge&logo=esbuild&logoColor=white">](https://bundlephobia.com/package/@ekwoka/alpine-scope)
7+
8+
> This exposes a simple magic `$scope` to allow accessing specific component scopes in the tree by name.
9+
10+
## Install
11+
12+
```sh
13+
npm i @ekwoka/alpine-scope
14+
```
15+
16+
Import to Build (Simple Version):
17+
18+
```js
19+
import Alpine from 'alpinejs';
20+
import Scope from '@ekwoka/alpine-scope';
21+
22+
Alpine.plugin(Scope);
23+
24+
window.Alpine = Alpine;
25+
Alpine.start();
26+
```
27+
28+
## Usage:
29+
30+
When using Alpine, it can sometimes be difficult to access the values you want in some component trees. While often this is a case of poor design, sometimes the best design can still run into some conflicts that require awkward workarounds.
31+
32+
With this plugin, you can use the magic `$scope` to directly access the data context of a specific component in the tree.
33+
34+
### Implicit Naming
35+
36+
```html
37+
<div x-data="foo">
38+
// { value: 'hello' }
39+
<div x-data="bar">
40+
// { value: 'world' }
41+
<span x-text="$scope.foo.value"></span> // 'hello'
42+
<span x-text="value"></span> // 'world'
43+
</div>
44+
</div>
45+
```
46+
47+
The above is an example of implicitely scoped contexts. The expression passed to `x-data` is used as the key. This works great when the contexts are defined with `Alpine.data` and referenced by name. Obviously, this would become an issue if you your expression is like
48+
49+
```html
50+
<div
51+
x-data="{ foo: { bar: [1,2,3 ]}, doStuff() { console.log(this.foo.bar) } }"></div>
52+
```
53+
54+
### Explicit Naming
55+
56+
Conveniently included is the `x-scope` directive, which allows you to explicitly name the scope. This is useful for cases where the expression may be unknown at the point of needing the scoping, and cases where the expression is unwieldly.
57+
58+
```html
59+
<div x-data="{ value: 'hello' }" x-scope="foo">
60+
<div x-data="{ value: 'world' }">
61+
<span x-text="$scope.foo.value"></span> // 'hello'
62+
<span x-text="value"></span> // 'world'
63+
</div>
64+
</div>
65+
```
66+
67+
Pretty nifty!!!
68+
69+
And don't worry, scopes won't leak into other trees. They are only accessible within the tree they are defined.
70+
71+
## How it works
72+
73+
### `x-scope="expression"`
74+
75+
`x-scope` adds a `Map` of scopes to the current elements nearest component, that contains any scopes from the parent component and then the current component. These are placed in the context under a special `Symbol` so as not to conflict with your components directly.
76+
77+
This adds the scope to the current context, not the specific elements subtree. This means that children of the `root` element can provide a name to the scope, and that all elements in the component will see the same list of scopes, even if they are not in the same subtree. This can be useful for some more dynamic use cases. The same component scope can be named multiple times from multiple `x-scope` directives in the component tree, and they will not remove the others.
78+
79+
However, the scopes are isolated to the component and its decendents, and will not leak into the parent or other components.
80+
81+
### `$scope.name`
82+
83+
`$scope` is a magic property available in expressions and component methods that exposes a `Proxy` that allows access to the Parent components.
84+
85+
When a key is access, like `$scope.foo`, the `Proxy` first looks in the current contexts `Map` of scopes (from `x-scope`) for a context. If no context is found, it will look up the tree for an element with a matching `x-data` expression to use its context.
86+
87+
This means that explicitely named scopes will always take precedence over implicitely named scopes, and that scopes will not leak to sibling or parent trees.
88+
89+
## Author
90+
91+
👤 **Eric Kwoka**
92+
93+
- Website: http://thekwoka.net
94+
- Github: [@ekwoka](https://github.com/ekwoka)
95+
96+
## Show your support
97+
98+
Give a ⭐️ if this project helped you!

packages/scope/package.json

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@ekwoka/alpine-scope",
3+
"version": "0.0.1",
4+
"description": "Access component scopes by name",
5+
"author": {
6+
"name": "Eric Kwoka",
7+
"email": "[email protected]",
8+
"url": "https://thekwoka.net/"
9+
},
10+
"license": "MIT",
11+
"keywords": [
12+
"AlpineJS"
13+
],
14+
"type": "module",
15+
"files": [
16+
"dist",
17+
"src"
18+
],
19+
"sideEffects": false,
20+
"main": "dist/",
21+
"types": "dist/",
22+
"exports": {
23+
".": {
24+
"import": "./dist/index.js",
25+
"require": "./dist/index.js"
26+
},
27+
"./dist": "./dist/",
28+
"./src": "./src/"
29+
},
30+
"scripts": {
31+
"build": "vite build",
32+
"coverage": "vitest run --coverage",
33+
"lint": "eslint --fix ./src; prettier --write ./src --loglevel error",
34+
"lint:check": "eslint --max-warnings 10 ./src && prettier --check ./src",
35+
"lint:types": "tsc --noEmit",
36+
"prebuild": "rm -rf dist",
37+
"test": "vitest"
38+
},
39+
"peerDependencies": {
40+
"alpinejs": "3.x"
41+
},
42+
"prettier": {
43+
"singleQuote": true,
44+
"bracketSameLine": true
45+
},
46+
"repository": {
47+
"type": "git",
48+
"url": "https://github.com/ekwoka/alpine-plugins"
49+
},
50+
"homepage": "https://github.com/ekwoka/alpine-plugins/blob/main/packages/scope/README.md"
51+
}

packages/scope/src/index.ts

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import type { Alpine } from 'alpinejs';
2+
import type { Assertion } from 'vitest';
3+
4+
export const $scope = Symbol('$scope');
5+
6+
/**
7+
* Alpine Scope Plugin registers `$scope` magic property and `x-scope` directive.
8+
* Allows reaching into specific parent component contexts to access their data.
9+
* @param Alpine {Alpine}
10+
*/
11+
export const Scope = (Alpine: Alpine) => {
12+
type Scopable = { [$scope]?: Map<string, HTMLElement> };
13+
Alpine.directive('scope', (el, { expression }) => {
14+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
15+
const context = Alpine.$data(el) as Scopable;
16+
17+
const rootContext = Alpine.closestDataStack(el)[0];
18+
if (!rootContext) return;
19+
rootContext[$scope] = new Map(context[$scope]).set(expression, el);
20+
});
21+
Alpine.magic('scope', (el) => {
22+
return new Proxy(
23+
{},
24+
{
25+
get(_, name: string) {
26+
const scopes = (Alpine.$data(el) as Scopable)[$scope];
27+
if (scopes?.has(name)) return Alpine.$data(scopes.get(name)!);
28+
const root = Alpine.findClosest(el, (el) =>
29+
el.matches(`[x-data="${name}"]`),
30+
) as HTMLElement | undefined;
31+
if (root) return Alpine.$data(root);
32+
return undefined;
33+
},
34+
},
35+
);
36+
});
37+
};
38+
39+
export default Scope;
40+
41+
declare module 'alpinejs' {
42+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
43+
export interface Magics<T> {
44+
/**
45+
* A `Proxy` of the parent component scopes
46+
*/
47+
$scope: Record<string, unknown>;
48+
}
49+
}
50+
51+
if (import.meta.vitest) {
52+
describe('$scope', () => {
53+
it('can access implicitely scoped context', async () => {
54+
const root = await render(
55+
`
56+
<div x-data="foo">
57+
<div x-data="bar">
58+
<span id="naked" x-text="value"></span>
59+
<span id="foo" x-text="$scope.foo?.value"></span>
60+
<span id="bar" x-text="$scope.bar?.value"></span>
61+
</div>
62+
</div>
63+
`.trim(),
64+
)
65+
.withComponent('foo', () => ({ value: 'foo' }))
66+
.withComponent('bar', () => ({ value: 'bar' }))
67+
.withPlugin(Scope);
68+
expect((root as HTMLElement).querySelector('#naked')).toHaveTextContent(
69+
'bar',
70+
);
71+
expect((root as HTMLElement).querySelector('#foo')).toHaveTextContent(
72+
'foo',
73+
);
74+
expect((root as HTMLElement).querySelector('#bar')).toHaveTextContent(
75+
'bar',
76+
);
77+
});
78+
it('can access explicitely scoped context', async () => {
79+
const root = await render(
80+
`
81+
<div x-data="{ value: 'foo' }" x-scope="foo">
82+
<div x-data="{ value: 'bar' }" x-scope="bar">
83+
<span id="naked" x-text="value"></span>
84+
<span id="foo" x-text="$scope.foo?.value"></span>
85+
<span id="bar" x-text="$scope.bar?.value"></span>
86+
</div>
87+
</div>
88+
`.trim(),
89+
).withPlugin(Scope);
90+
expect((root as HTMLElement).querySelector('#naked')).toHaveTextContent(
91+
'bar',
92+
);
93+
expect((root as HTMLElement).querySelector('#foo')).toHaveTextContent(
94+
'foo',
95+
);
96+
expect((root as HTMLElement).querySelector('#bar')).toHaveTextContent(
97+
'bar',
98+
);
99+
});
100+
it('favors explicitely scoped contexts', async () => {
101+
const root = await render(
102+
`
103+
<div x-data="foo" x-scope="bar">
104+
<div x-data="bar">
105+
<span id="naked" x-text="value"></span>
106+
<span id="bar" x-text="$scope.bar?.value"></span>
107+
</div>
108+
</div>
109+
`.trim(),
110+
)
111+
.withComponent('foo', () => ({ value: 'foo' }))
112+
.withComponent('bar', () => ({ value: 'bar' }))
113+
.withPlugin(Scope);
114+
expect((root as HTMLElement).querySelector('#naked')).toHaveTextContent(
115+
'bar',
116+
);
117+
expect((root as HTMLElement).querySelector('#bar')).toHaveTextContent(
118+
'foo',
119+
);
120+
});
121+
it('does not leak', async () => {
122+
const root = await render(
123+
`
124+
<div x-data="{ value: 'root' }">
125+
<div x-data="foo" x-scope="foo"></div>
126+
<div x-data="bar">
127+
</div>
128+
<span id="naked" x-text="value"></span>
129+
<span id="foo" x-text="$scope.foo?.value ?? 'not found'"></span>
130+
<span id="bar" x-text="$scope.bar?.value ?? 'not found'"></span>
131+
</div>
132+
`.trim(),
133+
)
134+
.withComponent('foo', () => ({ value: 'foo' }))
135+
.withComponent('bar', () => ({ value: 'bar' }))
136+
.withPlugin(Scope);
137+
expect((root as HTMLElement).querySelector('#naked')).toHaveTextContent(
138+
'root',
139+
);
140+
expect((root as HTMLElement).querySelector('#foo')).toHaveTextContent(
141+
'not found',
142+
);
143+
expect((root as HTMLElement).querySelector('#bar')).toHaveTextContent(
144+
'not found',
145+
);
146+
});
147+
});
148+
}
149+
declare module 'vitest' {
150+
interface Assertion<T> extends AlpineMatchers<T> {}
151+
}
152+
153+
interface AlpineMatchers<T> {
154+
toHaveTextContent: (expected: string) => Assertion<T>;
155+
}

0 commit comments

Comments
 (0)