Skip to content

Commit 66d140f

Browse files
authored
Merge pull request #77 from bholmesdev/feat/root-element
Feat/root element
2 parents 252bd06 + d1aa157 commit 66d140f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+15472
-3627
lines changed

.changeset/rare-trainers-kiss.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"simple-stack-query": minor
3+
---
4+
5+
Revamps APIs to fix bugs and unlock a new suite of features.
6+
7+
```astro
8+
<RootElement>
9+
<button data-target="btn">Click me</button>
10+
</RootElement>
11+
12+
<script>
13+
RootElement.ready(($) => {
14+
$('btn').addEventListener('click', () => {
15+
console.log("It's like JQuery but not!");
16+
});
17+
});
18+
</script>
19+
```
20+
21+
- Support multiple instances of the same component. Before, only the first instance would become interactive.
22+
- Enable data passing from the server to your client script using the `data` property.
23+
- Add an `effect()` utility to interact with the [Signal polyfill](https://github.com/proposal-signals/signal-polyfill?tab=readme-ov-file#creating-a-simple-effect) for state management.
24+
25+
[Visit revamped documentation page](https://simple-stack.dev/query) to learn how to use the new features.
26+
27+
## Migration for v0.1
28+
29+
If you were an early adopter of v0.1, thank you! You'll a few small updates to use the new APIs:
30+
31+
- Wrap any HTML you want to target with the global `RootElement` component.
32+
- Remove the `$` from your `data-target` selector (`data-target={$('btn')}` -> `data-target="btn"`). Scoping is now handled automatically.
33+
- Change `$.ready()` to `RootElement.ready()`, and retrieve the `$` selector from the first function argument. The `$` selector is no longer a global.
34+
35+
```diff
36+
+ <RootElement>
37+
- <button data=target={$('btn')}>
38+
+ <button data-target="btn">
39+
Click me
40+
</button>
41+
+ </RootElement>
42+
43+
<script>
44+
- $.ready(() => {
45+
+ RootElement.ready(($) => {
46+
$('btn').addEventListener('click', () => {
47+
console.log("It's like JQuery but not!");
48+
});
49+
});
50+
</script>
51+
```
52+
53+
Since the syntax for `data-target` is now simpler, we have also **removed the VS Code snippets prompt.** We recommend deleting the snippets file created by v0.1: `.vscode/simple-query.code-snippets`.

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,24 @@ jobs:
5151

5252
- name: Test packages
5353
run: pnpm test
54+
e2e:
55+
timeout-minutes: 60
56+
runs-on: ubuntu-latest
57+
steps:
58+
- uses: actions/checkout@v4
59+
- uses: actions/setup-node@v4
60+
with:
61+
node-version: lts/*
62+
- name: Install dependencies
63+
run: npm install -g pnpm && pnpm install
64+
- name: Install Playwright Browsers
65+
run: pnpm exec playwright install --with-deps
66+
- name: Run Playwright tests
67+
run: pnpm e2e
68+
- uses: actions/upload-artifact@v4
69+
if: always()
70+
with:
71+
name: playwright-report
72+
path: playwright-report/
73+
retention-days: 30
74+

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,8 @@
22
"editor.defaultFormatter": "biomejs.biome",
33
"[astro]": {
44
"editor.defaultFormatter": "astro-build.astro-vscode"
5+
},
6+
"[json]": {
7+
"editor.defaultFormatter": "biomejs.biome"
58
}
69
}

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"build": "turbo build --filter='./packages/*'",
99
"build:all": "turbo build",
1010
"test": "turbo test --filter='./packages/*'",
11+
"e2e": "turbo e2e",
1112
"check": "biome check packages/",
1213
"check:apply": "biome check packages/ --apply",
1314
"lint": "biome lint packages/",
@@ -16,13 +17,16 @@
1617
"format:write": "biome format --write",
1718
"version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run check:apply"
1819
},
19-
"keywords": ["withastro"],
20+
"keywords": [
21+
"withastro"
22+
],
2023
"author": "bholmesdev",
2124
"license": "MIT",
2225
"devDependencies": {
2326
"@biomejs/biome": "^1.8.3",
2427
"@changesets/changelog-github": "^0.5.0",
2528
"@changesets/cli": "^2.27.1",
29+
"@playwright/test": "^1.45.3",
2630
"@types/node": "^20.14.11",
2731
"turbo": "^1.11.2",
2832
"typescript": "^5.5.3"

packages/query/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules/
2+
/test-results/
3+
/playwright-report/
4+
/blob-report/
5+
/playwright/.cache/

packages/query/README.md

Lines changed: 5 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -3,115 +3,17 @@
33
A simple library to query the DOM from your Astro components.
44

55
```astro
6-
<button data-target={$('btn')}>Click me</button>
6+
<RootElement>
7+
<button data-target="btn">Click me</button>
8+
</RootElement>
79
810
<script>
9-
$.ready(() => {
11+
RootElement.ready(($) => {
1012
$('btn').addEventListener('click', () => {
1113
console.log("It's like JQuery but not!");
1214
});
1315
});
1416
</script>
1517
```
1618

17-
## Installation
18-
19-
Simple stack query is an Astro integration. You can install using the `astro add` CLI:
20-
21-
```bash
22-
astro add simple-stack-query
23-
```
24-
25-
To install this integration manually, follow the [manual installation instructions](https://docs.astro.build/en/guides/integrations-guide/#manual-installation)
26-
27-
## Global `$` selector
28-
29-
The `$` is globally available to define targets from your server template, and to query those targets from your client script.
30-
31-
Selectors should be applied to the `data-target` attribute. All selectors are scoped based on the component you're in, so we recommend the simplest name you can use:
32-
33-
```astro
34-
<button data-target={$('btn')}>
35-
<!--data-target="btn-4SzN_OBB"-->
36-
```
37-
38-
Then, use the same `$()` function from your client script to select that element. The query result will be a plain [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement). No, it's not a JQuery object. We just used `$` for the nostalgia 😉
39-
40-
```ts
41-
$('btn').addEventListener(() => { /* ... */ });
42-
```
43-
44-
You can also pass an `HTMLElement` or `SVGElement` type to access specific properties. For example, use `$<HTMLInputElement>()` to access `.value`:
45-
46-
```ts
47-
$<HTMLInputElement>('input').value = '';
48-
```
49-
50-
### `$.optional()` selector
51-
52-
`$()` throws when no matching element is found. To handle undefined values, use `$.optional()`:
53-
54-
```astro
55-
---
56-
const promoActive = Astro.url.searchParams.has('promo');
57-
---
58-
59-
{promoActive && <p data-target={$('banner')}>Buy my thing</p>}
60-
61-
<script>
62-
$.ready(() => {
63-
$.optional('banner')?.addEventListener('mouseover', () => {
64-
console.log("They're about to buy it omg");
65-
});
66-
});
67-
</script>
68-
```
69-
70-
### `$.all()` selector
71-
72-
You may want to select multiple targets with the same name. Use `$.all()` to query for an array of results:
73-
74-
```astro
75-
---
76-
const links = ["wtw.dev", "bholmes.dev"];
77-
---
78-
79-
{links.map(link => (
80-
<a href={link} data-target={$('link')}>{link}</a>
81-
))}
82-
83-
<script>
84-
$.ready(() => {
85-
$.all('link').forEach(linkElement => { /* ... */ });
86-
});
87-
</script>
88-
```
89-
90-
## `$.ready()` function
91-
92-
All `$` queries should be nested in a `$.ready()` block. `$.ready()` will rerun on every page [when view transitions are enabled.](https://docs.astro.build/en/guides/view-transitions/)
93-
94-
```astro
95-
<script>
96-
$.ready(() => {
97-
// ✅ Query code that should run on every navigation
98-
$('element').textContent = 'hey';
99-
})
100-
101-
// ✅ Global code that should only run once
102-
class MyElement extends HTMLElement { /* ... */}
103-
customElements.define('my-element', MyElement);
104-
</script>
105-
```
106-
107-
### 🙋‍♂️ `$.ready()` isn't running for me
108-
109-
`$.ready()` runs when `data-target` is used by your component. This heuristic keeps simple query performant and ensures scripts run at the right time when view transitions are applied.
110-
111-
If `data-target` is applied conditionally, or not at all, the `$.ready()` block may not run. You can apply a `data-target` selector anywhere in your component to resolve the issue:
112-
113-
```astro
114-
<div data-target={$('container')}>
115-
<!--...-->
116-
</div>
117-
```
19+
📚 Visit [the docs](https://simple-stack.dev/query) for more information and usage examples.

packages/query/ambient.d.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1-
declare function $<T extends Element = HTMLElement>(selector: string): T;
2-
3-
declare namespace $ {
4-
function all<T extends Element = HTMLElement>(selector: string): Array<T>;
5-
function optional<T extends Element = HTMLElement>(
6-
selector: string,
7-
): T | undefined;
8-
function ready(callback: () => void): void;
1+
declare namespace RootElement {
2+
function ready<T extends Record<string, any>>(
3+
callback: (
4+
$: {
5+
<T extends Element = HTMLElement>(selector: string): T;
6+
self: HTMLElement;
7+
all<T extends Element = HTMLElement>(selector: string): Array<T>;
8+
optional<T extends Element = HTMLElement>(
9+
selector: string,
10+
): T | undefined;
11+
},
12+
context: {
13+
effect: (callback: () => void | Promise<void>) => void;
14+
data: T;
15+
abortSignal: AbortSignal;
16+
},
17+
) => void,
18+
);
919
}
20+
21+
declare function RootElement<T extends Record<string, any>>(
22+
props: import("astro/types").HTMLAttributes<"div"> & { data?: T },
23+
): any | Promise<any>;

packages/query/e2e/basic.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, test } from "@playwright/test";
2+
import { type PreviewServer, preview } from "astro";
3+
import { generatePort, getPath } from "./utils";
4+
5+
const fixtureRoot = new URL("../fixtures/basic", import.meta.url).pathname;
6+
let previewServer: PreviewServer;
7+
8+
test.beforeAll(async () => {
9+
previewServer = await preview({
10+
root: fixtureRoot,
11+
server: { port: await generatePort() },
12+
});
13+
});
14+
15+
test.afterAll(async () => {
16+
await previewServer.stop();
17+
});
18+
19+
test("loads client JS for heading", async ({ page }) => {
20+
await page.goto(getPath("", previewServer));
21+
22+
const h1 = page.getByTestId("heading");
23+
await expect(h1).toContainText("Heading JS loaded");
24+
});
25+
26+
test("reacts to button click", async ({ page }) => {
27+
await page.goto(getPath("button", previewServer));
28+
29+
const btn = page.getByRole("button");
30+
31+
await expect(btn).toHaveAttribute("data-ready");
32+
await btn.click();
33+
await expect(btn).toContainText("1");
34+
});
35+
36+
test("reacts to button effect", async ({ page }) => {
37+
await page.goto(getPath("effect", previewServer));
38+
39+
const btn = page.getByRole("button");
40+
41+
await expect(btn).toHaveAttribute("data-ready");
42+
await btn.click();
43+
await expect(btn).toContainText("1");
44+
const p = page.getByRole("paragraph");
45+
await expect(p).toContainText("1");
46+
});
47+
48+
test("respects server data", async ({ page }) => {
49+
await page.goto(getPath("server-data", previewServer));
50+
51+
const h1 = page.getByTestId("heading");
52+
await expect(h1).toContainText("Server data");
53+
});
54+
55+
test("reacts to multiple instances of button counter", async ({ page }) => {
56+
await page.goto(getPath("multi-counter", previewServer));
57+
58+
for (const testId of ["counter-1", "counter-2"]) {
59+
const counter = page.getByTestId(testId);
60+
const btn = counter.getByRole("button");
61+
62+
await expect(btn).toHaveAttribute("data-ready");
63+
await expect(btn).toContainText("0");
64+
await btn.click();
65+
await expect(btn).toContainText("1");
66+
}
67+
});

packages/query/e2e/utils.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import net from "node:net";
2+
import { type PreviewServer } from "astro";
3+
4+
export function isPortAvailable(port) {
5+
return new Promise((resolve) => {
6+
const server = net.createServer();
7+
8+
server.once("error", (err) => {
9+
if ("code" in err && err.code === "EADDRINUSE") {
10+
resolve(false);
11+
}
12+
});
13+
14+
server.once("listening", () => {
15+
server.close();
16+
resolve(true);
17+
});
18+
19+
server.listen(port);
20+
});
21+
}
22+
23+
export async function generatePort() {
24+
const port = Math.floor(Math.random() * 1000) + 9000;
25+
if (await isPortAvailable(port)) return port;
26+
27+
return generatePort();
28+
}
29+
30+
export function getPath(path: string, previewServer: PreviewServer) {
31+
return new URL(path, `http://localhost:${previewServer.port}/`).href;
32+
}

0 commit comments

Comments
 (0)