diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4d66fe6..a524bab 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,5 +1,5 @@ { - "name": "harmonia", + "name": "codbex", "owner": { "name": "codbex" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b87eece..4e6635c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "harmonia", "description": "Usage docs for the Harmonia Alpine.js UI component library (@codbex/harmonia): how to add, wire, and style x-h-* components.", - "version": "2.14.2", + "version": "3.0.0", "author": { "name": "codbex" }, diff --git a/.claude/settings.json b/.claude/settings.json index b9907ca..6fefa4a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,5 @@ { "enabledPlugins": { - "harmonia@harmonia": true + "harmonia@codbex": true } } diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..d7e39c8 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,70 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Unit tests + run: npm run test + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build dist + run: node scripts/build.cjs && npm run tailwind + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: E2E tests + run: npx playwright test + + - name: Upload report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report + test-results + retention-days: 7 diff --git a/.gitignore b/.gitignore index 3401ee7..51e6a74 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ docs/public/theming/sections.json # VitePress **/.vitepress/cache + +# Playwright +playwright-report +test-results diff --git a/.prettierignore b/.prettierignore index c1f0878..c3dd653 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,7 @@ src/common/icon-data.js # Generated from docs/** by scripts/generate-agent-docs.cjs skills/ + +# Playwright output +playwright-report +test-results diff --git a/AGENTS.md b/AGENTS.md index 58a76e1..9385e35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,15 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - **happy-dom** environment; `tests/setup.js` patches `innerText`. - Directives are tested **without real Alpine**. `tests/test-utils.js` provides `mountDirective(plugin, 'h-name', el, bindings, ctxOverrides)` plus `createMockAlpine` / `createMockContext`, a minimal Proxy-based `reactive`/`effect` implementation. Use these helpers and assert on the DOM, attributes, and `el._h_*` state the directive produces. Mirror the `src/` layout under `tests/` (`tests/components`, `tests/common`, `tests/utils`). +### End-to-end tests (Playwright) + +- `npm run test:e2e` runs `tests/e2e/**/*.spec.js` (Chromium locally; CI adds Firefox and WebKit). `npm run test:e2e:ui` opens the Playwright UI. +- Build first: `node scripts/build.cjs && npm run tailwind`. The suite tests `dist/harmonia.js` and `dist/harmonia.css`, not `src/`. The fixture server (`scripts/e2e-server.cjs`) exits with instructions when `dist/` is missing. +- Write an e2e test only for what the unit suite cannot see: real Alpine init and `x-model`, real CSS and layout, floating-ui positioning, focus and Tab order, CSS transitions, pointer drag, theme switching. Everything else stays a vitest unit test. +- Fixtures are plain HTML files in `tests/e2e/fixtures/`, served over http from the repo root (never `file://` - the bundle reads localStorage at eval time and an opaque origin aborts it). Load `/dist/harmonia.css`, `/dist/harmonia.js`, then `/node_modules/alpinejs/dist/cdn.min.js` with `defer`. `` must carry `x-data` or Alpine initializes nothing, silently. +- Use `tests/e2e/helpers.js`: `gotoFixture` waits for Alpine and a rendered directive; `settle(page)` waits out the 100-200ms transitions before reading styles or visibility; seed a theme with `seedColorScheme` before navigation; `drag` for real pointer drags. +- Popover-like content is attached but hidden until opened, so locate it with `state: 'attached'`. Use `page.clock.setFixedTime()` for date components, never `clock.install()` (it freezes the setTimeout-driven transitions). Assert on `data-slot`, `data-state`, `aria-*` and settled computed styles; `el.matches(':hover')` is false under automation even when hover styles apply. No screenshot baselines. + ## Conventions - ESLint flat config (`eslint.config.js`): browser globals + `Alpine` readonly for `src`; unused vars allowed only with a `_` prefix; `console` limited to `warn`/`error`. Prettier enforces formatting (with organize-imports + tailwindcss plugins). diff --git a/CHANGELOG.md b/CHANGELOG.md index b15066f..499dbfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,128 @@ # Changelog +## v3.0.0 + +A release that adds the Combobox component and brings drag and drop to the Calendar and the Slot Picker. Calendar events can be rescheduled by dragging them to another time or day, and slot picker slots can be reordered within a day or moved to another day, in both cases with the change proposed through an event and applied by the consumer. Notifications can now play a sound, a built-in chime or an audio file, per notification or as an overlay-wide default, and the new Native Notifications utility, also available as `$notifications.native`, wraps the browser's Notification API for messages shown by the operating system. The Dialog gains a fullscreen mode that fills the viewport, together with a content slot that scrolls the body while the header and the footer stay in place, and moves its padding onto that slot and its neighbours, which is a breaking change for dialog bodies that are not wrapped in the new slot. It also keeps focus inside itself while it is open, like the Backdrop now does. The Card moves its padding the same way, onto its header, content and footer, which is a breaking change for content placed straight in a card, and gains a modifier that lets a table or a list span the card from edge to edge. The Button Group can now hold a single choice, which turns a row of buttons into a segmented control that is announced as a set of options and navigated with the arrow keys. It also draws the dividers between its buttons itself rather than relying on the `outline` variant's border, which retires `x-h-button-group-separator` as a breaking change, gains a borderless mode for a group that fills a card, and no longer overwrites a `role` set on the group. It also completes the viewport height utilities, repairs keyboard navigation in a listbox whose options change, and stops a long line of code from escaping its block. The Sidebar now separates navigation from everything else it can hold, a breaking change for navigation sidebars. A destination is written as `x-h-sidebar-menu-nav`, which announces the active entry with `aria-current="page"`, while `x-h-sidebar-menu-button` makes no navigation claim and leaves `aria-current` to the author. The Chip becomes a container holding its own buttons, a breaking change that retires the button it used to be applied to: a dismissible chip was a button with another control inside it, which is invalid markup and left its close button reachable by `Tab` but impossible to activate. The Tabs take the same shape as a breaking change for every tab. Each tab now lives in an `x-h-tab-item` wrapper that draws its surface, and a tab action is a real button beside the tab instead of a span inside it, which also lifts the one-action limit, so a tab can pair a close button with a menu trigger. The Avatar becomes a control on an `a` element as well as on a `button`, and a button avatar no longer submits the form around it. The Backdrop now keeps focus inside itself while it is open, and the Expansion Panel's generated triggers no longer share a single id. The release fixes single mode in the Accordion and makes accordion item ids dynamic: items written without an explicit id all shared the same empty id, so an accordion in single mode never collapsed the previously open section. The item id is now evaluated as an Alpine expression, which is a breaking change for hard-coded ids but lets items rendered with `x-for` take their id from the iterated data. The Range's `input` and `change` events now carry their value in `event.detail.value` rather than as the whole detail, a breaking change that aligns them with every other component's change event. And components that hold their bound value themselves, among them the Rating, the single choice Button Group, the Inline Calendar and the Menu's checkbox and radio items, now reject `x-model`'s event modifiers with a console error, since `.lazy` used to silently corrupt the bound value. And the date grid shared by the Date Picker, the Datetime Picker, the Inline Calendar and the Slot Picker now starts keyboard navigation from the visibly focused day instead of the 1st of the month. + +### Combobox + +- **New component.** A list of options belonging to a text field, filtered as the user types. It is the pattern behind a search field, an autocomplete and a command palette. + +### Calendar + +- **New: events can be rescheduled by drag and drop.** Opt in to let users drag a timed event to a new time or day in the week and day views (the start time snaps to a configurable minute step and the duration is kept), move all-day pills between days, and change an event's day in the month view. Dropping never changes the calendar's data directly: the proposed change is dispatched as an event for the consumer to apply. Individual events can opt out, and every event stays reachable by keyboard, since dragging is a pointer-only convenience. +- **Fixed: `x-model.lazy` corrupted the bound value of an inline calendar.** The modifier makes Alpine listen for `change`, and the calendar's own `change` event made that listener write the event's detail object over the date string the calendar had just stored. The modifier also silently broke the model-to-view sync, since the model expression was read from the literal `x-model` attribute name. The event modifiers (`.lazy`, `.change`, `.blur`, `.enter`) are now rejected with a console error, the model always updates immediately, and a model bound with any other modifier stays in sync. + +### Slot Picker + +- **New: slots can be reordered and moved between days by drag and drop.** Opt in to let users drag a slot within its day to reorder it or onto another visible day to move it. While dragging, a half-transparent copy of the slot follows the pointer and the other slots part to show where it will land. Dropping never changes the picker's data directly: the proposed change is dispatched as an event for the consumer to apply. Individual slots can opt out, and unavailable slots never drag. + +### Notifications + +- **New: a notification can play a sound.** The `sound` argument on `$notifications.add` plays a built-in chime, a custom audio file or nothing, and the overlay's new `data-sound` attribute sets the default for every notification. +- **New: native notifications.** New utility functions, also exposed as `$notifications.native`, wrap the browser's Notification API with an availability check, permission inspection and request, and a show function that passes the standard options through. + +### Dialog + +- **New: fullscreen mode.** A dialog can now fill the entire viewport instead of sitting centered at a capped width, which suits long forms and multi-step tasks, especially on small screens. It is switched on with `data-fullscreen` and can be bound to an expression, so the same dialog can change modes at runtime. The new `x-h-dialog-content` slot marks the body of a dialog as the only scrolling part, keeping the header and the footer in place while the content scrolls between them. +- **New: focus stays inside an open dialog and returns to the opener when it closes.** `Tab` and `Shift+Tab` used to walk straight out of the dialog into the page behind it, which is invisible to the eye but fully reachable by keyboard, and closing dropped focus at the top of the document instead of returning it to the control that opened it. Where focus lands when a dialog opens is unchanged, and dismissal, including `Esc`, is still wired up by the consumer. +- **Breaking: the dialog surface no longer has padding of its own.** The header, the body and the footer now pad themselves, so a body that is not wrapped in `x-h-dialog-content` reaches the edges of the dialog. To migrate, wrap it: `
` around whatever sits between the header and the footer, carrying over the classes it already had. A dialog laid out only from a header and a footer needs no change and looks exactly as before. Two things get easier in return. A focused control inside a scrolling body no longer has its focus ring clipped at the left and right edges, because the padding is now inside the scrolling area instead of outside it. And content that should span the full width, a calendar, a table or a list, is now a matter of the new `flush` modifier on the body (`x-h-dialog-content.flush`) instead of cancelling the surface padding with `p-0!` and re-adding it to every other part by hand. + +### Card + +- **Breaking: the card surface no longer has padding of its own.** The header, the content and the footer now pad themselves, so anything placed straight in a card reaches its edges. To migrate, wrap what should stay inset in `x-h-card-content`, carrying over the classes it already had. A card built from the header, content and footer slots needs no change and looks exactly as before, in every combination of the three. What gets easier is the case the card was worst at: a table, a list or a calendar that should span the full width of the card is now the new `flush` modifier on the content (`x-h-card-content.flush`), or simply a direct child of the card, instead of cancelling the surface padding with `p-0!` and putting it back on every other part by hand. + +### Button Group + +- **New: a button group can hold a single choice.** Binding an `x-model` turns the buttons into mutually exclusive options, the segmented control used for something like a view mode or a color scheme. The group is announced as a set of options, is a single tab stop, and is navigated with the arrow keys. A group without an `x-model` is unchanged. +- **New: `data-borderless`.** Removes the border around the group and squares its corners, keeping the dividers between the buttons. For a group that fills a card, so the card's own border and radius are the only ones on show. +- **New: the group draws the dividers between its buttons.** They used to be a side effect of the `outline` variant's border, so a group of `transparent`, `default` or `primary` buttons had none and the buttons ran together. Groups of `outline` buttons look exactly as before. +- **Breaking: `x-h-button-group-separator` is removed.** The group now draws the dividers itself, so the directive has nothing left to do. To migrate, delete the `
` elements from your button groups. The divider appears in their place on its own. +- **Fixed: a `role` set on the group was overwritten.** The group wrote `role="group"` over whatever the author had put there, unlike `x-h-tile-group`, which keeps a role you set. A role set by the author is now left alone. +- **`x-model`'s event modifiers are rejected with a console error.** `.lazy` made Alpine's own listener write the `change` event's `{ value }` detail object over the value the group had just stored. The event modifiers (`.lazy`, `.change`, `.blur`, `.enter`) have nothing to defer on a single choice, the model always updates immediately. + +### Range + +- **Breaking: the `input` and `change` events now carry their value in `event.detail.value`.** They used to put the raw value in `event.detail`, unlike every other component, whose change events report `event.detail.value`. To migrate, read `$event.detail.value` instead of `$event.detail` in `@input` and `@change` handlers. +- **`x-model`'s event modifiers are rejected with a console error.** `.lazy`, `.change`, `.blur` and `.enter` have nothing to defer on a slider, the model always updates immediately. + +### Rating + +- **Fixed: `x-model.lazy` corrupted the bound value.** The modifier makes Alpine listen for `change`, and the rating's own `change` event made that listener write the event's `{ value }` detail object over the number the rating had just stored. The event modifiers (`.lazy`, `.change`, `.blur`, `.enter`) have nothing to defer on a rating, so they are now rejected with a console error, the model always updates immediately. + +### Menu + +- **Fixed: `x-model.lazy` corrupted the bound state of a checkbox or radio item.** The modifier makes Alpine listen for `change`, and the item's own `change` event made that listener write `undefined` over the value the item had just stored. The event modifiers (`.lazy`, `.change`, `.blur`, `.enter`) are now rejected with a console error, the model always updates immediately. + +### Date Picker + +- **Fixed: a modifier on the popup's `x-model` silently broke the model-to-view sync.** The model expression was read from the literal `x-model` attribute, which does not exist when the attribute name carries a modifier such as `.fill`. The expression is now found whatever the modifiers. +- **Fixed: the first key press in a freshly opened calendar acted from the 1st of the month, not from today.** When the calendar opened with nothing selected, the visible focus landed on today's cell, but the keyboard handler based itself on the 1st of the month, so the first arrow press jumped focus there instead of moving one day from today, and pressing `Enter` straight away selected the 1st rather than the day that looked focused. Navigation now starts from the cell that actually holds focus. The date grid is shared, so the fix applies equally to the datetime picker, the inline calendar and the slot picker. + +### Dark Mode + +- **New: a `light` area can be nested inside a `dark` page or container, not just the other way around.** The `dark` class already let you scope dark mode to part of an otherwise light page by adding it to a container element. The `light` class now does the same for a light area inside a dark page, so either scheme can sit inside the other. + +### Theme + +- **New: color scheme listeners also receive the selected mode.** A listener registered with `addColorSchemeListener` used to be told only the scheme being applied, `light` or `dark`, so an `auto` selection was indistinguishable from whichever scheme the system resolved it to. The selected mode now arrives as a second argument, `light`, `dark` or `auto`, which is what a light/dark/auto control needs in order to show the right option. Listeners that take a single argument are unaffected. +- **Fixed: the system color scheme listener was never detached.** `window.matchMedia()` returns a new object on every call, so the code meant to remove the auto-mode handler was removing it from a freshly created object rather than from the one that held it. An explicit `light` or `dark` choice was therefore overridden the next time the system scheme flipped, even though the saved mode still said otherwise, and selecting `auto` repeatedly stacked handlers so a single flip notified every listener once per selection. +- **Fixed: `auto` was saved after the listeners ran.** A listener that called `getColorScheme()` while handling a switch to `auto` saw the mode being replaced rather than the new one, and only in the document that made the change, since every other frame saw the new value. The `light` and `dark` paths already saved before notifying, and `auto` now matches them. + +### New utility classes + +- **`min-h-screen`, `min-h-dvh`, `min-h-lvh` and `min-h-svh`** are now shipped and documented, setting a minimum height of the screen size or of the dynamic, large or small viewport height. They complete the `min-h` family, which previously stopped at `min-h-0` and the fixed sizes `min-h-1` to `min-h-12`. +- **`z-20`** joins the shipped z-index utilities, filling the gap between `z-10` and `z-50`. + +### Accordion + +- **Breaking: the accordion item id is now evaluated as an Alpine expression.** `x-h-accordion-item` and the optional default-expanded id on `x-h-accordion.single` used to take their value as a literal string. Both are now evaluated, matching `x-h-accordion-trigger`. To migrate, quote hard-coded ids, so `x-h-accordion-item="itemId1"` becomes `x-h-accordion-item="'itemId1'"` and `x-h-accordion.single="itemId2"` becomes `x-h-accordion.single="'itemId2'"`. This enables `x-h-accordion-item="entry.id"` inside an `x-for`, which previously gave every row the same literal id and broke single mode. +- **Fixed: single mode never collapsed the previously open item.** Items written without an id all received the empty string as their id instead of a generated one, so the single-mode bookkeeping could not tell them apart and every clicked section stayed open. The same empty id also produced an empty `id` on every trigger button and an empty `aria-labelledby` on every content region, leaving the panels without an accessible name. Items without an id now each get a unique generated one. + +### Expansion Panel + +- **Fixed: every trigger button had the literal id `undefined`.** The generated buttons all shared that duplicate id instead of getting one of their own. Each button now gets a unique id, derived as `-trigger` when the panel item has an `id` attribute and generated otherwise. +- **Fixed: the trigger's `aria-controls` did not point at the content.** It referenced the panel item wrapper when the item had an `id` attribute, and a nonexistent id otherwise. The content region now carries its own id (`-content` when the item has an `id` attribute, generated otherwise), `aria-controls` points at it, and the content names itself after its trigger with `aria-labelledby`, which it previously lacked entirely. + +### List + +- **New: `x-h-list-secondary`.** A slot for the supporting text in a list item, the preview under a subject or the timestamp beside a name. It plays the text down the way a muted foreground class does, and follows the row into its selected state, where it switches to the selected foreground at a lower opacity so it stays readable while remaining quieter than the rest of the item. + +### Listbox + +- **Fixed: a listbox whose options changed fell out of the tab order for good.** The tab stop was handed to an option once, when the listbox first mounted, and every option is otherwise unreachable by design. Rendering the options from a filtered list therefore broke the component the first time the filter ran: the replacement options all arrived unreachable, no stop was ever restored, and because the key handling hangs off that stop the arrow keys, `Home`, `End`, typeahead and `Enter` all went dead with no visible sign. Clearing the filter did not bring it back. A listbox that starts out empty, which is what a search result list does, was never reachable at all. The stop is now re-established whenever the options change. + +### Sidebar + +- **Breaking: a navigation destination is now `x-h-sidebar-menu-nav`.** A menu button used to receive `aria-current="page"` whenever it was marked `data-active="true"`, on the assumption that the sidebar is the page's navigation. That is wrong for an active button that marks a selected filter, a menu trigger or anything else that is not the current page. The sidebar now has two menu button directives sharing one look and behaviour. `x-h-sidebar-menu-nav` announces the active destination with `aria-current="page"`, while `x-h-sidebar-menu-button` never touches `aria-current` and leaves it entirely to the author. The `data-slot` now says which one an element is, too. A nav carries `sidebar-menu-nav`, and a button or a nav inside a `x-h-sidebar-menu-sub` carries `sidebar-menu-sub-button` or `sidebar-menu-sub-nav` instead of the `sidebar-menu-button` every variant used to get. To migrate, write `x-h-sidebar-menu-nav` on the buttons and links that mark the current destination, keep every other menu button as it is, and update tests or CSS selecting on `data-slot=sidebar-menu-button` for the renamed elements. +- **New: a header item can be interactive.** Writing `x-h-sidebar-header-item` on a `