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
4 changes: 4 additions & 0 deletions .github/workflows/deploy-docs-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- 'web_examples/**'
- 'packages/axoloth-behavior/src/**'
- 'packages/axoloth-style/src/**'
- 'packages/axoloth-style/metadata/**'
- '.github/workflows/deploy-docs-pages.yml'
Expand Down Expand Up @@ -53,11 +54,14 @@ jobs:
set -euo pipefail
rm -rf dist-pages
mkdir -p dist-pages/packages/axoloth-style
mkdir -p dist-pages/packages/axoloth-behavior
cp -R web_examples/. dist-pages/
cp -R packages/axoloth-behavior/src dist-pages/packages/axoloth-behavior/src
cp -R packages/axoloth-style/src dist-pages/packages/axoloth-style/src
sed -i 's#../packages/axoloth-style/src/axoloth.css#./packages/axoloth-style/src/axoloth.css#g' dist-pages/index.html
find dist-pages/examples -name index.html -print0 | xargs -0 sed -i 's#../../../packages/axoloth-style/src/axoloth.css#../../packages/axoloth-style/src/axoloth.css#g'
find dist-pages/docs -name index.html -print0 | xargs -0 sed -i 's#../../../packages/axoloth-style/src/axoloth.css#../../packages/axoloth-style/src/axoloth.css#g'
find dist-pages/recipes -name index.html -print0 | xargs -0 sed -i 's#../../../packages/axoloth-style/src/axoloth.css#../../packages/axoloth-style/src/axoloth.css#g'

- name: Configure GitHub Pages
uses: actions/configure-pages@v5
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ For browser-native Vanilla JavaScript, import a pinned behavior module:
```

Behavior is never initialized by the CSS package. Import and initialize only
the components the page uses.
the components the page uses. See the
[Behavior Guide](https://amilliondriver.github.io/MotionStyleLibrary/docs/behavior/)
for installation, initialize-all and per-component patterns, cleanup,
troubleshooting, and runnable Vanilla examples.

### Bundler And Modular CSS

Expand Down
103 changes: 94 additions & 9 deletions packages/axoloth-behavior/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Optional zero-dependency JavaScript behaviors for `@quertys/axoloth-style`.

Axoloth Behavior keeps interactive state separate from the CSS-first package. Install it only when a layout needs tabs, accordions, dropdowns, toasts, drawers, an off-canvas sidebar, or a dialog. The package is framework-neutral and works with plain HTML, React, Vue, Svelte, Angular, or any DOM-based application.

> Axoloth Style provides layout and presentation. Axoloth Behavior attaches interaction to
> `data-axo-*` attributes. Importing the CSS alone never initializes JavaScript behavior.

Read the [Behavior Guide and live Vanilla demos](https://amilliondriver.github.io/MotionStyleLibrary/docs/behavior/)
for runnable tabs, accordion, dialog, and drawer examples.

## API Stability

Version `0.6.0` validates package exports, initializers, declarative attributes,
Expand All @@ -17,7 +23,47 @@ and custom events against the reviewed `0.4.0` baseline. Read
npm install @quertys/axoloth-style @quertys/axoloth-behavior
```

Import the Axoloth CSS once, then initialize only the behavior you use:
## Initialize Everything

Import the Axoloth CSS once, then initialize the behavior package after the DOM is available:

```js
import '@quertys/axoloth-style/axoloth.css';
import { initAxolothBehaviors } from '@quertys/axoloth-behavior';

const axoloth = initAxolothBehaviors();

// Remove every listener when the page or application is disposed.
window.addEventListener('pagehide', () => axoloth.destroy(), { once: true });
```

`initAxolothBehaviors()` initializes every exported behavior and returns their controllers under
`accordion`, `dialog`, `drawer`, `dropdown`, `offcanvas`, `tabs`, and `toast`.

## CDN / Native ES Modules

No bundler is required. Load the CSS with a stylesheet link and import the JavaScript from an ES
module script:

```html
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@quertys/axoloth-style@0.9.0/src/axoloth.css"
/>

<script type="module">
import { initAxolothBehaviors } from 'https://cdn.jsdelivr.net/npm/@quertys/axoloth-behavior@0.6.0/src/index.js';

const axoloth = initAxolothBehaviors();
window.addEventListener('pagehide', () => axoloth.destroy(), { once: true });
</script>
```

Pin both versions in production so a future release cannot change a deployed page unexpectedly.

## Initialize One Behavior

Import only the behavior used by the page when you want a smaller, explicit setup:

```js
import '@quertys/axoloth-style/axoloth.css';
Expand All @@ -42,6 +88,21 @@ All initializers accept an optional root as their first argument and options as
their second argument. For example, configure Toast with
`initToast(document, { duration: 4500, limit: 3 })`.

Scope an initializer to one part of a page and clean it up independently:

```js
import { initTabs } from '@quertys/axoloth-behavior/tabs';

const accountSection = document.querySelector('#account-section');
const tabs = initTabs(accountSection);

// Re-scan after adding matching markup dynamically.
tabs.refresh();

// Remove listeners before replacing or unmounting the section.
tabs.destroy();
```

## Tabs

```html
Expand Down Expand Up @@ -308,18 +369,42 @@ dialogElement.addEventListener('axo:dialog-close', () => {

Off-canvas controllers dispatch `axo:offcanvas-open` and `axo:offcanvas-close`.

## Initialize All Available Behaviors
## Troubleshooting

```js
import { initAxolothBehaviors } from '@quertys/axoloth-behavior';
### `data-axo-*` attributes do nothing

const axoloth = initAxolothBehaviors();
The package intentionally does not auto-initialize. Confirm that the behavior package is imported
from a `<script type="module">` or your bundler entry and that an initializer runs after the markup
exists.

// Remove all listeners during app cleanup.
axoloth.destroy();
```
### The component is styled but not interactive

`@quertys/axoloth-style` only supplies CSS. Install or load `@quertys/axoloth-behavior`, then call
`initAxolothBehaviors()` or the matching component initializer.

### A trigger cannot find its panel

Target values must match exactly. For example, `data-axo-drawer-toggle="menu"` controls
`data-axo-drawer-id="menu"`; a tab value must match its `data-axo-tab-panel` value.

### Dynamically added markup is ignored

Call the controller's `refresh()` method after inserting new tabs, accordion items, dropdowns, or
toast regions. Dialog, drawer, and off-canvas controllers use delegated events and can discover
matching targets when they are triggered.

### An interaction fires twice

The same root was probably initialized more than once. Keep the returned controller and call
`destroy()` before initializing it again.

### Server-rendered code cannot access `document`

Run initializers only on the client after the DOM exists. Calling an initializer without a DOM
returns a safe empty controller, but your own selectors must also stay inside the client lifecycle.

The package does not auto-initialize and does not run during server-side rendering. You keep control over when behavior is attached and destroyed.
The package never auto-initializes and never owns your application lifecycle. You decide when
behavior is attached, refreshed, and destroyed.

## License

Expand Down
84 changes: 84 additions & 0 deletions tests/accessibility/axoloth.accessibility.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@ import { readFileSync } from 'node:fs';
const examples = JSON.parse(
readFileSync(new URL('../../web_examples/data/examples.json', import.meta.url), 'utf8')
);
const recipes = JSON.parse(
readFileSync(new URL('../../web_examples/data/recipes.json', import.meta.url), 'utf8')
);
const auditTargets = [
{ id: 'docs-hub', path: '/web_examples/' },
{ id: 'behavior-guide', path: '/web_examples/docs/behavior/' },
...examples.map((example) => ({
id: example.id,
path: `/web_examples/${example.previewUrl.replace(/^\.\//, '')}`,
})),
...recipes.map((recipe) => ({
id: `recipe-${recipe.id}`,
path: `/web_examples/${recipe.previewUrl.replace(/^\.\//, '')}`,
})),
];
const viewports = [
{ name: 'mobile', width: 375, height: 900 },
Expand Down Expand Up @@ -82,3 +90,79 @@ auditTargets.forEach((target) => {
});
});
});

test.describe('behavior guide interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/web_examples/docs/behavior/', { waitUntil: 'networkidle' });
await expect(page.locator('[data-behavior-status]')).toContainText('Initialized');
});

test('tabs and accordion update their accessible state', async ({ page }) => {
const securityTab = page.getByRole('tab', { name: 'Security' });
const profilePanel = page.getByRole('tabpanel', { name: 'Profile' });
const securityPanel = page.getByRole('tabpanel', { name: 'Security' });

await securityTab.click();
await expect(securityTab).toHaveAttribute('aria-selected', 'true');
await expect(profilePanel).toBeHidden();
await expect(securityPanel).toBeVisible();

const cleanupTrigger = page.getByRole('button', { name: /When should I clean up/ });
await cleanupTrigger.click();
await expect(cleanupTrigger).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('region', { name: /When should I clean up/ })).toBeVisible();
});

test('dialog and drawer open, dismiss, and restore focus', async ({ page }) => {
const dialogTrigger = page.getByRole('button', { name: 'Open dialog' });
const dialog = page.locator('#behavior-confirm-dialog');

await dialogTrigger.click();
await expect(dialog).toHaveClass(/axo-dialog-open/);
await page.keyboard.press('Escape');
await expect(dialog).not.toHaveClass(/axo-dialog-open/);
await expect(dialogTrigger).toBeFocused();

const drawerTrigger = page.getByRole('button', { name: 'Open drawer' });
const drawer = page.locator('#behavior-main-drawer');

await drawerTrigger.click();
await expect(drawer).toHaveClass(/axo-drawer-open/);
await page.keyboard.press('Escape');
await expect(drawer).not.toHaveClass(/axo-drawer-open/);
await expect(drawerTrigger).toBeFocused();
});
});

test.describe('gallery dialog recipe interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/web_examples/recipes/gallery-dialog/', { waitUntil: 'networkidle' });
await expect(page.locator('[data-gallery-status]')).toHaveText('Dialog behavior initialized.');
});

test('updates selected content, dismisses, and restores focus', async ({ page }) => {
const chairTrigger = page.getByRole('button', { name: 'Open details for Low reading chair' });
const dialog = page.locator('#gallery-object-dialog');

await chairTrigger.click();
await expect(dialog).toHaveClass(/axo-dialog-open/);
await expect(dialog).toHaveAttribute('aria-hidden', 'false');
await expect(page.locator('[data-gallery-dialog-title]')).toHaveText('Low reading chair');
await expect(page.locator('[data-gallery-dialog-art]')).toHaveAttribute(
'aria-label',
'Low reading chair'
);

await page.keyboard.press('Escape');
await expect(dialog).not.toHaveClass(/axo-dialog-open/);
await expect(chairTrigger).toBeFocused();

const vesselTrigger = page.getByRole('button', {
name: 'Open details for Folded ceramic vessel',
});
await vesselTrigger.click();
await page.locator('.axo-dialog-backdrop').click({ position: { x: 4, y: 4 } });
await expect(dialog).not.toHaveClass(/axo-dialog-open/);
await expect(vesselTrigger).toBeFocused();
});
});
33 changes: 21 additions & 12 deletions tests/visual/axoloth.visual.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,20 @@ examples.forEach((example) => {
});

if (example.id === 'kitchen-sink') {
await page.locator('#utility-reference').evaluate((element) => {
const topbarHeight =
document.querySelector('.docs-topbar')?.getBoundingClientRect().height ?? 0;
window.scrollTo({
behavior: 'instant',
top: Math.max(0, element.offsetTop - topbarHeight - 16),
});
await expect(page.locator('#utility-count')).not.toHaveText('Loading utilities...');
await expect(page.locator('#utilities-table-body tr')).not.toHaveCount(1);
await page.locator('.docs-topbar').evaluate((element) => element.remove());
await page.addStyleTag({
content: `
.docs-sidebar {
visibility: hidden !important;
}

#utility-reference {
block-size: 900px !important;
overflow: hidden !important;
}
`,
});
}

Expand All @@ -60,14 +67,16 @@ examples.forEach((example) => {

expect(pageWidth.scrollWidth).toBeLessThanOrEqual(pageWidth.clientWidth + 1);
expect(consoleErrors).toEqual([]);
const screenshotOptions = {
fullPage: example.fullPage !== false,
};
if (example.id === 'kitchen-sink') {
screenshotOptions.maxDiffPixelRatio = 0.04;
await expect(page.locator('#utility-reference')).toHaveScreenshot(
`${example.id}-${width}.png`
);
return;
}

await expect(page).toHaveScreenshot(`${example.id}-${width}.png`, screenshotOptions);
await expect(page).toHaveScreenshot(`${example.id}-${width}.png`, {
fullPage: example.fullPage !== false,
});
});
});
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading