Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,32 @@ jobs:
- run: npm install
- run: npm run build
- run: npm test

e2e:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6

- name: Clone NoJS Core
run: git clone --depth 1 https://github.com/no-js-dev/nojs.git "$GITHUB_WORKSPACE/../NoJS"

- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm

- name: Build NoJS Core
run: cd "$GITHUB_WORKSPACE/../NoJS" && npm ci && node build.js

- name: Install Elements dependencies
run: npm ci

- name: Build Elements
run: npm run build

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run e2e tests
run: cd e2e && npx playwright test --project=chromium
34 changes: 17 additions & 17 deletions dist/cjs/nojs-elements.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/cjs/nojs-elements.js.map

Large diffs are not rendered by default.

34 changes: 17 additions & 17 deletions dist/esm/nojs-elements.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/esm/nojs-elements.js.map

Large diffs are not rendered by default.

34 changes: 17 additions & 17 deletions dist/iife/nojs-elements.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/iife/nojs-elements.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/examples/dnd/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@
drag-list="evtA" template="item-tpl" drag-list-key="id"
drag-type="evt" drop-accept="evt" drop-sort="vertical"
drag-list-remove
on:reorder="reorderLog = 'reordered:' + $event.detail.from + '->' + $event.detail.to"
on:remove="removeLog = 'removed:' + $event.detail.index">
on:nojs:dnd-reorder="reorderLog = 'reordered:' + $event.detail.from + '->' + $event.detail.to"
on:nojs:dnd-remove="removeLog = 'removed:' + $event.detail.index">
</div>
<span class="value" data-test="reorder-log" bind="reorderLog"></span>
<span class="value" data-test="remove-log" bind="removeLog"></span>
Expand All @@ -186,7 +186,7 @@
<div class="list" data-test="evt-b"
drag-list="evtB" template="item-tpl" drag-list-key="id"
drag-type="evt" drop-accept="evt" drop-sort="vertical"
on:receive="receiveLog = 'received:' + $event.detail.item.name">
on:nojs:dnd-receive="receiveLog = 'received:' + $event.detail.item.name">
</div>
<span class="value" data-test="receive-log" bind="receiveLog"></span>
</div>
Expand Down
13 changes: 5 additions & 8 deletions docs/examples/table/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,11 @@
</tr>
</thead>
<tbody>
<tr each="user in users">
<tr each="user in users" else="noUsersTpl">
<td bind="user.name"></td>
<td bind="user.age"></td>
<td bind="user.email"></td>
</tr>
<tr else>
<td colspan="3">No users found</td>
</tr>
</tbody>
</table>
</div>
Expand All @@ -62,18 +59,18 @@
</tr>
</thead>
<tbody>
<tr each="person in people">
<tr each="person in people" else="noDataTpl">
<td bind="person.name"></td>
<td bind="person.age"></td>
</tr>
<tr else>
<td colspan="2">No data</td>
</tr>
</tbody>
</table>
</div>
</section>

<template id="noUsersTpl"><tr><td colspan="3">No users found</td></tr></template>
<template id="noDataTpl"><tr><td colspan="2">No data</td></tr></template>

<script src="../../../NoJS/dist/iife/no.js"></script>
<script src="../../../dist/iife/nojs-elements.js"></script>
<script>
Expand Down
24 changes: 17 additions & 7 deletions e2e/tests/dnd.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ test.describe('Drag and Drop', () => {

await expect(firstItem).toHaveAttribute('draggable', 'true');
await expect(firstItem).toHaveAttribute('role', 'option');
await expect(firstItem).toHaveAttribute('aria-grabbed', 'false');
await expect(firstItem).toHaveAttribute('aria-roledescription', 'draggable item');
});

test('drag item from list A to list B transfers the item', async ({ page }) => {
Expand Down Expand Up @@ -227,27 +227,37 @@ test.describe('Drag and Drop', () => {
await expect(firstItem).toBeFocused();
});

test('keyboard DnD: Space activates drag mode (aria-grabbed)', async ({ page }) => {
test('keyboard DnD: Space activates drag mode (nojs-dragging class + live-region announcement)', async ({ page }) => {
const kbList = page.getByTestId('kb-list');
const firstItem = kbList.getByTestId('item').first();

await firstItem.focus();
await expect(firstItem).not.toHaveClass(/nojs-dragging/);

await page.keyboard.press('Space');

// After Space, aria-grabbed should be "true"
await expect(firstItem).toHaveAttribute('aria-grabbed', 'true');
// After Space, the drag class should be applied
await expect(firstItem).toHaveClass(/nojs-dragging/);

// Live region should announce the grab
const liveRegion = page.locator('.nojs-dnd-live-region');
await expect(liveRegion).toHaveText(/Grabbed .+\. Use arrow keys to (?:move|reorder)\./);
});

test('keyboard DnD: Escape cancels drag mode', async ({ page }) => {
test('keyboard DnD: Escape cancels drag mode (class removed + cancellation announcement)', async ({ page }) => {
const kbList = page.getByTestId('kb-list');
const firstItem = kbList.getByTestId('item').first();

await firstItem.focus();
await page.keyboard.press('Space');
await expect(firstItem).toHaveAttribute('aria-grabbed', 'true');
await expect(firstItem).toHaveClass(/nojs-dragging/);

await page.keyboard.press('Escape');
await expect(firstItem).toHaveAttribute('aria-grabbed', 'false');
await expect(firstItem).not.toHaveClass(/nojs-dragging/);

// Live region should announce the cancellation
const liveRegion = page.locator('.nojs-dnd-live-region');
await expect(liveRegion).toHaveText(/(?:Drag|Reorder) cancelled\./);
});

// ── Visual feedback (CSS classes) ────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/popover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ test.describe('Popover', () => {
const trigger = page.getByTestId('basic-trigger');

// Before opening
await expect(trigger).toHaveAttribute('aria-haspopup', 'true');
await expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
await expect(trigger).toHaveAttribute('aria-expanded', 'false');

// After opening
Expand Down
62 changes: 42 additions & 20 deletions src/popover/popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ function _supportsPopover(el, method = "togglePopover") {
return !!el && typeof el[method] === "function";
}

// Safe check for `:popover-open` — jsdom and older browsers throw on the
// unsupported pseudo-class, so we catch and return false.
function _isPopoverOpen(el) {
try { return el.matches(":popover-open"); } catch { return false; }
}

// ─── Positioning helper ─────────────────────────────────────────────

const GAP = 8;
Expand Down Expand Up @@ -80,7 +86,7 @@ function _startTracking(entry, anchorEl) {
stop();
return;
}
if (typeof popoverEl.matches === "function" && !popoverEl.matches(":popover-open")) {
if (!_isPopoverOpen(popoverEl)) {
stop();
return;
}
Expand Down Expand Up @@ -152,8 +158,11 @@ export function registerPopoverDirective(NoJS) {
const isOpen = e.newState === "open";
entry.open = isOpen;
for (const t of entry.triggerEls) t.setAttribute("aria-expanded", String(isOpen));
// Stop live-position tracking once the popover closes.
if (!isOpen) _stopTracking(entry);
// Stop live-position tracking and reset visibility once the popover closes.
if (!isOpen) {
el.style.visibility = "";
_stopTracking(entry);
}
};
el.addEventListener("toggle", toggleHandler);

Expand Down Expand Up @@ -215,16 +224,21 @@ export function registerPopoverDirective(NoJS) {
return;
}
if (!_supportsPopover(entry.popoverEl)) return;
entry.popoverEl.togglePopover();
// Position after opening, then track scroll/resize while it stays open.
requestAnimationFrame(() => {
if (entry.popoverEl.matches(":popover-open")) {
// Hide before opening so the popover is never visible at (0,0).
// try/finally guarantees visibility is cleared even if positioning throws.
entry.popoverEl.style.visibility = "hidden";
try {
entry.popoverEl.togglePopover();
// Position synchronously, then reveal.
if (_isPopoverOpen(entry.popoverEl)) {
_positionPopover(entry.popoverEl, el, entry.position);
_startTracking(entry, el);
} else {
_stopTracking(entry);
}
});
} finally {
entry.popoverEl.style.visibility = "";
}
};
el.addEventListener("click", clickHandler);

Expand Down Expand Up @@ -276,13 +290,18 @@ export function registerPopoverDirective(NoJS) {
popoverApi.open = (id, anchorEl) => {
const entry = _popoverRegistry.get(id);
if (!entry || !entry.popoverEl || !_supportsPopover(entry.popoverEl, "showPopover")) return false;
try { entry.popoverEl.showPopover(); } catch { return false; }
const anchor = anchorEl || [...entry.triggerEls][0];
if (anchor) {
requestAnimationFrame(() => {
entry.popoverEl.style.visibility = "hidden";
try {
entry.popoverEl.showPopover();
const anchor = anchorEl || [...entry.triggerEls][0];
if (anchor) {
_positionPopover(entry.popoverEl, anchor, entry.position);
_startTracking(entry, anchor);
});
}
} catch {
return false;
} finally {
entry.popoverEl.style.visibility = "";
}
return true;
};
Expand All @@ -298,15 +317,18 @@ export function registerPopoverDirective(NoJS) {
popoverApi.toggle = (id, anchorEl) => {
const entry = _popoverRegistry.get(id);
if (!entry || !entry.popoverEl || !_supportsPopover(entry.popoverEl)) return false;
entry.popoverEl.togglePopover();
const anchor = anchorEl || [...entry.triggerEls][0];
if (anchor && entry.popoverEl.matches(":popover-open")) {
requestAnimationFrame(() => {
entry.popoverEl.style.visibility = "hidden";
try {
entry.popoverEl.togglePopover();
const anchor = anchorEl || [...entry.triggerEls][0];
if (anchor && _isPopoverOpen(entry.popoverEl)) {
_positionPopover(entry.popoverEl, anchor, entry.position);
_startTracking(entry, anchor);
});
} else {
_stopTracking(entry);
} else {
_stopTracking(entry);
}
} finally {
entry.popoverEl.style.visibility = "";
}
return true;
};
Expand Down