Skip to content

Commit

Permalink
Merge pull request #77 from bholmesdev/feat/root-element
Browse files Browse the repository at this point in the history
Feat/root element
  • Loading branch information
bholmesdev authored Jul 28, 2024
2 parents 252bd06 + d1aa157 commit 66d140f
Show file tree
Hide file tree
Showing 43 changed files with 15,472 additions and 3,627 deletions.
53 changes: 53 additions & 0 deletions .changeset/rare-trainers-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"simple-stack-query": minor
---

Revamps APIs to fix bugs and unlock a new suite of features.

```astro
<RootElement>
<button data-target="btn">Click me</button>
</RootElement>
<script>
RootElement.ready(($) => {
$('btn').addEventListener('click', () => {
console.log("It's like JQuery but not!");
});
});
</script>
```

- Support multiple instances of the same component. Before, only the first instance would become interactive.
- Enable data passing from the server to your client script using the `data` property.
- 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.

[Visit revamped documentation page](https://simple-stack.dev/query) to learn how to use the new features.

## Migration for v0.1

If you were an early adopter of v0.1, thank you! You'll a few small updates to use the new APIs:

- Wrap any HTML you want to target with the global `RootElement` component.
- Remove the `$` from your `data-target` selector (`data-target={$('btn')}` -> `data-target="btn"`). Scoping is now handled automatically.
- Change `$.ready()` to `RootElement.ready()`, and retrieve the `$` selector from the first function argument. The `$` selector is no longer a global.

```diff
+ <RootElement>
- <button data=target={$('btn')}>
+ <button data-target="btn">
Click me
</button>
+ </RootElement>

<script>
- $.ready(() => {
+ RootElement.ready(($) => {
$('btn').addEventListener('click', () => {
console.log("It's like JQuery but not!");
});
});
</script>
```

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`.
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,24 @@ jobs:

- name: Test packages
run: pnpm test
e2e:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm install -g pnpm && pnpm install
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"editor.defaultFormatter": "biomejs.biome",
"[astro]": {
"editor.defaultFormatter": "astro-build.astro-vscode"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "turbo build --filter='./packages/*'",
"build:all": "turbo build",
"test": "turbo test --filter='./packages/*'",
"e2e": "turbo e2e",
"check": "biome check packages/",
"check:apply": "biome check packages/ --apply",
"lint": "biome lint packages/",
Expand All @@ -16,13 +17,16 @@
"format:write": "biome format --write",
"version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run check:apply"
},
"keywords": ["withastro"],
"keywords": [
"withastro"
],
"author": "bholmesdev",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "^1.8.3",
"@changesets/changelog-github": "^0.5.0",
"@changesets/cli": "^2.27.1",
"@playwright/test": "^1.45.3",
"@types/node": "^20.14.11",
"turbo": "^1.11.2",
"typescript": "^5.5.3"
Expand Down
5 changes: 5 additions & 0 deletions packages/query/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
108 changes: 5 additions & 103 deletions packages/query/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,115 +3,17 @@
A simple library to query the DOM from your Astro components.

```astro
<button data-target={$('btn')}>Click me</button>
<RootElement>
<button data-target="btn">Click me</button>
</RootElement>
<script>
$.ready(() => {
RootElement.ready(($) => {
$('btn').addEventListener('click', () => {
console.log("It's like JQuery but not!");
});
});
</script>
```

## Installation

Simple stack query is an Astro integration. You can install using the `astro add` CLI:

```bash
astro add simple-stack-query
```

To install this integration manually, follow the [manual installation instructions](https://docs.astro.build/en/guides/integrations-guide/#manual-installation)

## Global `$` selector

The `$` is globally available to define targets from your server template, and to query those targets from your client script.

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:

```astro
<button data-target={$('btn')}>
<!--data-target="btn-4SzN_OBB"-->
```

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 😉

```ts
$('btn').addEventListener(() => { /* ... */ });
```

You can also pass an `HTMLElement` or `SVGElement` type to access specific properties. For example, use `$<HTMLInputElement>()` to access `.value`:

```ts
$<HTMLInputElement>('input').value = '';
```

### `$.optional()` selector

`$()` throws when no matching element is found. To handle undefined values, use `$.optional()`:

```astro
---
const promoActive = Astro.url.searchParams.has('promo');
---
{promoActive && <p data-target={$('banner')}>Buy my thing</p>}
<script>
$.ready(() => {
$.optional('banner')?.addEventListener('mouseover', () => {
console.log("They're about to buy it omg");
});
});
</script>
```

### `$.all()` selector

You may want to select multiple targets with the same name. Use `$.all()` to query for an array of results:

```astro
---
const links = ["wtw.dev", "bholmes.dev"];
---
{links.map(link => (
<a href={link} data-target={$('link')}>{link}</a>
))}
<script>
$.ready(() => {
$.all('link').forEach(linkElement => { /* ... */ });
});
</script>
```

## `$.ready()` function

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/)

```astro
<script>
$.ready(() => {
// ✅ Query code that should run on every navigation
$('element').textContent = 'hey';
})
// ✅ Global code that should only run once
class MyElement extends HTMLElement { /* ... */}
customElements.define('my-element', MyElement);
</script>
```

### 🙋‍♂️ `$.ready()` isn't running for me

`$.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.

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:

```astro
<div data-target={$('container')}>
<!--...-->
</div>
```
📚 Visit [the docs](https://simple-stack.dev/query) for more information and usage examples.
30 changes: 22 additions & 8 deletions packages/query/ambient.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
declare function $<T extends Element = HTMLElement>(selector: string): T;

declare namespace $ {
function all<T extends Element = HTMLElement>(selector: string): Array<T>;
function optional<T extends Element = HTMLElement>(
selector: string,
): T | undefined;
function ready(callback: () => void): void;
declare namespace RootElement {
function ready<T extends Record<string, any>>(
callback: (
$: {
<T extends Element = HTMLElement>(selector: string): T;
self: HTMLElement;
all<T extends Element = HTMLElement>(selector: string): Array<T>;
optional<T extends Element = HTMLElement>(
selector: string,
): T | undefined;
},
context: {
effect: (callback: () => void | Promise<void>) => void;
data: T;
abortSignal: AbortSignal;
},
) => void,
);
}

declare function RootElement<T extends Record<string, any>>(
props: import("astro/types").HTMLAttributes<"div"> & { data?: T },
): any | Promise<any>;
67 changes: 67 additions & 0 deletions packages/query/e2e/basic.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, test } from "@playwright/test";
import { type PreviewServer, preview } from "astro";
import { generatePort, getPath } from "./utils";

const fixtureRoot = new URL("../fixtures/basic", import.meta.url).pathname;
let previewServer: PreviewServer;

test.beforeAll(async () => {
previewServer = await preview({
root: fixtureRoot,
server: { port: await generatePort() },
});
});

test.afterAll(async () => {
await previewServer.stop();
});

test("loads client JS for heading", async ({ page }) => {
await page.goto(getPath("", previewServer));

const h1 = page.getByTestId("heading");
await expect(h1).toContainText("Heading JS loaded");
});

test("reacts to button click", async ({ page }) => {
await page.goto(getPath("button", previewServer));

const btn = page.getByRole("button");

await expect(btn).toHaveAttribute("data-ready");
await btn.click();
await expect(btn).toContainText("1");
});

test("reacts to button effect", async ({ page }) => {
await page.goto(getPath("effect", previewServer));

const btn = page.getByRole("button");

await expect(btn).toHaveAttribute("data-ready");
await btn.click();
await expect(btn).toContainText("1");
const p = page.getByRole("paragraph");
await expect(p).toContainText("1");
});

test("respects server data", async ({ page }) => {
await page.goto(getPath("server-data", previewServer));

const h1 = page.getByTestId("heading");
await expect(h1).toContainText("Server data");
});

test("reacts to multiple instances of button counter", async ({ page }) => {
await page.goto(getPath("multi-counter", previewServer));

for (const testId of ["counter-1", "counter-2"]) {
const counter = page.getByTestId(testId);
const btn = counter.getByRole("button");

await expect(btn).toHaveAttribute("data-ready");
await expect(btn).toContainText("0");
await btn.click();
await expect(btn).toContainText("1");
}
});
32 changes: 32 additions & 0 deletions packages/query/e2e/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import net from "node:net";
import { type PreviewServer } from "astro";

export function isPortAvailable(port) {
return new Promise((resolve) => {
const server = net.createServer();

server.once("error", (err) => {
if ("code" in err && err.code === "EADDRINUSE") {
resolve(false);
}
});

server.once("listening", () => {
server.close();
resolve(true);
});

server.listen(port);
});
}

export async function generatePort() {
const port = Math.floor(Math.random() * 1000) + 9000;
if (await isPortAvailable(port)) return port;

return generatePort();
}

export function getPath(path: string, previewServer: PreviewServer) {
return new URL(path, `http://localhost:${previewServer.port}/`).href;
}
Loading

0 comments on commit 66d140f

Please sign in to comment.