Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/root element #77

Merged
merged 27 commits into from
Jul 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7f01189
wip: rootelement convention
bholmesdev Jul 23, 2024
47e3ee8
feat: brave new API with signal polyfill
bholmesdev Jul 23, 2024
5907afc
feat: support styling with display: contents default
bholmesdev Jul 24, 2024
fc93999
feat: effect primitive
bholmesdev Jul 24, 2024
f8d44b3
fix: rerun ready functions on navigation
bholmesdev Jul 26, 2024
230901c
docs: new root element queries and client
bholmesdev Jul 26, 2024
c25a67d
fix: support server islands with connectedCallback
bholmesdev Jul 26, 2024
24dfdc0
feat: data-target auto scoping
bholmesdev Jul 27, 2024
7d1dcc9
refactor: move to unique custom elements
bholmesdev Jul 27, 2024
56b1ece
docs: update for new data-target API
bholmesdev Jul 27, 2024
6cd0be5
docs: passing server data section
bholmesdev Jul 27, 2024
9a3d702
docs: edit signal line highlight
bholmesdev Jul 27, 2024
a940cac
docs: cleanup logic
bholmesdev Jul 27, 2024
c7a0bc3
docs: note -> caution
bholmesdev Jul 27, 2024
5833986
edit: . -> :
bholmesdev Jul 27, 2024
4c9c0b5
docs: passing data-target as a component prop
bholmesdev Jul 27, 2024
7dcad40
docs: reorder sections
bholmesdev Jul 27, 2024
b9170f3
chore: remove old snippets
bholmesdev Jul 27, 2024
c5db74e
deps: playwright
bholmesdev Jul 28, 2024
f395abe
deps: playwright at workspace root
bholmesdev Jul 28, 2024
6ec1a7f
feat: playwright ci job
bholmesdev Jul 28, 2024
ee56cf0
feat(query): playwright tests
bholmesdev Jul 28, 2024
06f373c
fix: lint
bholmesdev Jul 28, 2024
731ef98
feat(e2e): test on view transition navigation
bholmesdev Jul 28, 2024
f1431d5
chore: changeset
bholmesdev Jul 28, 2024
36f709c
docs: linnk to docs page from README
bholmesdev Jul 28, 2024
d1aa157
chore: lint
bholmesdev Jul 28, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading