From 6e67b9dd8979458d6ed7b0acc38bafc22812dad9 Mon Sep 17 00:00:00 2001 From: StanZGenchev Date: Wed, 26 Aug 2026 21:26:59 +0300 Subject: [PATCH 1/9] #78 - Drag and drop for calendar and slot picker components Signed-off-by: StanZGenchev --- docs/components/calendar.md | 64 ++- docs/components/slot-picker.md | 95 +++- docs/utility-classes/z-index.md | 3 +- skills/harmonia/references/calendar.md | 60 ++- skills/harmonia/references/slot-picker.md | 91 +++- skills/harmonia/references/utility-classes.md | 2 +- src/common/drag.js | 90 ++++ src/components/calendar.js | 196 ++++++- src/components/slot-picker.js | 267 +++++++++- src/styles/harmonia.css | 2 +- tests/components/calendar.test.js | 329 ++++++++++++ tests/components/slot-picker.test.js | 489 ++++++++++++++++++ 12 files changed, 1636 insertions(+), 52 deletions(-) create mode 100644 src/common/drag.js diff --git a/docs/components/calendar.md b/docs/components/calendar.md index eb8d5c1..747b6ce 100644 --- a/docs/components/calendar.md +++ b/docs/components/calendar.md @@ -10,6 +10,17 @@ For a compact calendar that selects a single date or a date range, see [Inline C Use `x-h-calendar` when users need to view and navigate a schedule - appointments, team calendars, project timelines, and so on. +## Behavior + +Set `draggable: true` in the configuration to let users reschedule events by dragging them: + +- **Week and day views** - dragging a timed event vertically moves its start time in steps of `dragStep` minutes (`15` by default), and dragging it onto another day column (week view) moves it to that day. The duration is kept. Events that continue from an earlier day can only be moved between days. +- **Week view all-day strip** - all-day pills can be dragged onto another day column. +- **Month view** - dragging an event pill onto another day cell changes only its day and keeps its time. The hovered target cell is highlighted while dragging. +- **Year view** - no drag and drop. + +Dropping never changes the calendar's data directly. The event snaps back and an `event-drop` event is dispatched with the proposed new `start` and `end` values. Apply them to your event object to accept the move, or ignore the event to reject it. Individual events can opt out with `draggable: false`. Dragging is a mouse or pen interaction, and a plain click still fires `event-click`. + ## Keyboard Handling In the month view (and within each year-view mini-month) the day cells form an ARIA grid with roving focus: @@ -24,7 +35,7 @@ Events are buttons in the tab order. Activate them to fire `event-click`. In the ## Accessibility -The calendar is a labeled `group` (default name "Calendar", overridable with an `aria-label` attribute). The toolbar period heading is an `aria-live` region. The month grid uses `role="grid"`/`row`/`gridcell` with `aria-current="date"` on today and full keyboard navigation. Events are `button`s whose accessible label includes the title, time (or "all day"), and status (e.g. "unconfirmed"). The week/day time grid's empty-slot "click to pick a time" is a pointer-only convenience. +The calendar is a labeled `group` (default name "Calendar", overridable with an `aria-label` attribute). The toolbar period heading is an `aria-live` region. The month grid uses `role="grid"`/`row`/`gridcell` with `aria-current="date"` on today and full keyboard navigation. Events are `button`s whose accessible label includes the title, time (or "all day"), and status (e.g. "unconfirmed"). The week/day time grid's empty-slot "click to pick a time" is a pointer-only convenience. Drag-and-drop rescheduling is a pointer-only convenience as well, and every event stays reachable through its button and `event-click`. ## API Reference @@ -50,10 +61,11 @@ x-h-calendar ### Events -| Event | Description | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| event-click | Fired when the user clicks an event. The original event object is passed in `$event.detail.event`. | -| date-click | Fired when the user clicks an empty date cell or time slot. The clicked `Date` is in `$event.detail.date`. For time-grid views the slot time string (`"HH:MM"`) is also in `$event.detail.time`. | +| Event | Description | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| event-click | Fired when the user clicks an event. The original event object is passed in `$event.detail.event`. | +| date-click | Fired when the user clicks an empty date cell or time slot. The clicked `Date` is in `$event.detail.date`. For time-grid views the slot time string (`"HH:MM"`) is also in `$event.detail.time`. | +| event-drop | Fired when a dragged event is dropped on a new day or time (requires the `draggable` option). `$event.detail.event` is the event object. `$event.detail.start` and `$event.detail.end` hold the proposed new values in the same string shape as the event's own fields (`"YYYY-MM-DDTHH:MM"`, or `"YYYY-MM-DD"` when the original value was date-only and the time of day is unchanged). `detail.end` is `undefined` when the event has no `end`. Assign the values to your event to apply the move. | ### Configuration @@ -73,6 +85,8 @@ Pass a configuration object to the directive as an expression. | showNowIndicator | Show the current-time indicator in week and day views. Defaults to `true`. Set to `false` to hide it. | | views | Show the view-switcher button group in the toolbar. Defaults to `true`. Set to `false` to lock the calendar to the view set in `view` and hide the switcher. | | scrollTo | Where week and day views scroll to on load - `"now"` anchors on the current time, `"first-event"` anchors on the earliest event in view. Falls back to `"now"` when the view has no timed events. | +| draggable | Enable drag-and-drop rescheduling of events in the month, week, and day views. Defaults to `false`. See [Behavior](#behavior). | +| dragStep | Minutes value, used as a step when a timed event is dragged vertically in the week and day views. Defaults to `15`. | ### Event object @@ -88,6 +102,7 @@ Each item in the `events` array supports the following fields: | color | `blue`
`red`
`green`
`yellow`
`purple`
`pink`
`indigo`
`orange`
`gray`
`teal` | false | Color key. | | status | string | false | Pill style. `confirmed` (default) renders a filled pill, `unconfirmed` renders an outlined pill, and `rejected` renders an outlined pill with a dashed border. | | description | string | false | Shown as a tooltip on event pills. | +| draggable | boolean | false | Set to `false` to exclude the event from drag and drop when the calendar has `draggable: true`. | ## Examples @@ -213,3 +228,42 @@ Each item in the `events` array supports the following fields: ``` + +### Drag and drop + +Enable rescheduling with `draggable: true` and apply the change in an `@event-drop` handler. The "Public Holiday" event opts out with `draggable: false`. + + + +```html +
+``` + +
diff --git a/docs/components/slot-picker.md b/docs/components/slot-picker.md index d1aef50..8d02641 100644 --- a/docs/components/slot-picker.md +++ b/docs/components/slot-picker.md @@ -8,9 +8,20 @@ Use the Slot Picker when users need to book or choose one or more time slots fro Set `days` to control how many day columns are shown (1 to 7). The picker renders only the day grid, so you build the toolbar yourself from the control directives (every example below includes one). The previous/next controls move by that number of days, and the calendar control jumps straight to any date. The chosen date becomes the first of the visible days, which avoids paging far ahead one step at a time. Set `showNowIndicator: true` to mark the current time in today's column with a red line that moves as time passes. By default every day column stays visible at every width, so a narrow container simply shows narrower columns. Add the `responsive` modifier (`x-h-slot-picker.responsive`) to make the columns stack into a single column on narrow screens instead. +## Behavior + +Set `draggable: true` in the configuration to let users reorder slots within a day and move them to another visible day by dragging them. Dragging requires explicit `slots` (there must be an array to reorder), so generated slots (shorthand mode and `fillEmptyDays` fillers) never drag: + +- While a slot is dragged, a half-transparent copy of it follows the pointer, and the slot itself (dimmed) moves through the day lists live. The surrounding slots part around it by exactly its own space, always showing where the drop will land. +- A pointer just past the grid's edge still targets the nearest day. Disabled and out-of-range days are never drop targets, and releasing the slot over one snaps it back. Days whose slots are generated still accept drops, but note that applying such a drop makes the target day explicit, replacing its generated schedule. +- A slot with tiles drags as a whole. A press on a tile stays a tile interaction (click to select), so tiles cannot be dragged individually. +- Dropping never changes the picker's data directly. The slot snaps back and a `slot-drop` event is dispatched with the proposed change, including a ready-to-use `slots` array. Assign `$event.detail.slots` to your `slots` config to accept the move, or ignore the event to reject it. +- Individual slots can opt out with `draggable: false`, and unavailable slots never drag. +- Dragging is a mouse or pen interaction, and a plain click still selects the slot (or fires `slot-click`). + ## Accessibility -The picker is a labeled `group` (default name "Time slot picker", overridable with an `aria-label` attribute). Each day is its own `group` labeled by its header, so the day is announced for the slots inside it. When selection is enabled (an `x-model` is bound), available slots are toggle buttons with a day + time `aria-label` and `aria-pressed` reflecting selection. Without an `x-model` they are plain action buttons with the same label and no `aria-pressed`. Unavailable slots are marked `aria-disabled` with a hidden "Not available" note. Selecting a slot updates the cell in place rather than re-rendering, so keyboard focus stays on the chosen slot. The `x-h-slot-picker-calendar` control opens a `dialog` containing a fully keyboard-navigable date grid, and the dialog takes its accessible name from that control. The default month and year navigation buttons labels can be overridden using the `data-aria-*` attributes. Picking a date moves the visible range and returns focus to the control, and `Esc` closes it. Because you supply the toolbar, give each control button an accessible name (an `aria-label` on an icon-only button, or visible text). +The picker is a labeled `group` (default name "Time slot picker", overridable with an `aria-label` attribute). Each day is its own `group` labeled by its header, so the day is announced for the slots inside it. When selection is enabled (an `x-model` is bound), available slots are toggle buttons with a day + time `aria-label` and `aria-pressed` reflecting selection. Without an `x-model` they are plain action buttons with the same label and no `aria-pressed`. Unavailable slots are marked `aria-disabled` with a hidden "Not available" note. Selecting a slot updates the cell in place rather than re-rendering, so keyboard focus stays on the chosen slot. The `x-h-slot-picker-calendar` control opens a `dialog` containing a fully keyboard-navigable date grid, and the dialog takes its accessible name from that control. The default month and year navigation buttons labels can be overridden using the `data-aria-*` attributes. Picking a date moves the visible range and returns focus to the control, and `Esc` closes it. Because you supply the toolbar, give each control button an accessible name (an `aria-label` on an icon-only button, or visible text). Drag-and-drop moving is a pointer-only convenience, and every slot stays reachable through its button and `slot-click`. ## API Reference @@ -77,6 +88,7 @@ Pass a configuration object as an Alpine expression. | minDate | - | Start day. When set, the user cannot page to any day before it. Accepts a `YYYY-MM-DD` string or a `Date`. Independent of `maxDate`. | | maxDate | - | End day. When set, the user cannot page to any day after it. Accepts a `YYYY-MM-DD` string or a `Date`. Independent of `minDate`. | | showNowIndicator | `false` | When `true`, a current-time indicator is shown in today's column and moves on its own as time passes. | +| draggable | `false` | Enable reordering slots within a day and moving them to another day by drag and drop. Requires explicit `slots`. See [Behavior](#behavior). | #### Slot object (explicit mode) @@ -92,6 +104,7 @@ Pass a configuration object as an Alpine expression. | status | string | For a colored slot, `confirmed` (default) renders it filled, `unconfirmed` renders it as an outline, and `rejected` renders it as an outline with a dashed border. Ignored when no `color` is set. | | icons | `{ left, right }` | Badge images rendered in the cell's top corners. `left` and `right` are optional arrays of `{ url, alt }` objects, where `url` is the image path and `alt` is the alt text (defaults to `''`). | | tiles | Tile[] | Sub-slots (see below). When present and non-empty, the slot renders as a labeled group and only its tiles are selectable. The slot's own `start` labels the group. | +| draggable | boolean | Set to `false` to exclude the slot from drag and drop when the picker has `draggable: true`. | #### Tile object (sub-slots) @@ -121,9 +134,10 @@ A selected sub-slot tile uses a composite key of the form `'YYYY-MM-DDTHH:MM#ind ### Events -| Event | Description | -| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| slot-click | Dispatched on every slot click, including deselection and when no `x-model` is bound (in which case `selected` is always `false`). `event.detail.slot` contains `date`, `start`, `end`, `available`, `selected` (the new state after the click), `description`, `note`, `color`, `status`, `key`, and `tileIndex` (a number for a tile, `null` for a plain slot). | +| Event | Description | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| slot-click | Dispatched on every slot click, including deselection and when no `x-model` is bound (in which case `selected` is always `false`). `event.detail.slot` contains `date`, `start`, `end`, `available`, `selected` (the new state after the click), `description`, `note`, `color`, `status`, `key`, and `tileIndex` (a number for a tile, `null` for a plain slot). | +| slot-drop | Dispatched when a dragged slot is dropped at a new position (requires the `draggable` option, dropping at the unchanged position dispatches nothing). `event.detail.slot` carries the same fields as `slot-click`'s detail without `selected`. `event.detail.date` is the target day as `YYYY-MM-DD` and `event.detail.index` the slot's new position within that day's slot list. `event.detail.slots` is a new array with the move applied, built without mutating yours - assign it to your `slots` config to accept the move. | ## Examples @@ -677,3 +691,76 @@ Selection is enabled by binding `x-model`. Leave it off to use the picker purely ``` + +### Drag and drop + +Enable `draggable: true` and handle `slot-drop` to let users rearrange the schedule. While dragging, a half-transparent copy of the slot follows the pointer and the other slots part to show where it will land - within the same day (reorder) or on another day. The dragged slot snaps back until your handler applies the change. `$event.detail.slots` has the move applied but the slot keeps its original time, so a real handler adjusts it to the new position before assigning - that is the place for your own scheduling rules. Here `onDrop` preserves the slot's duration and starts it where its new predecessor ends (dropped at the top of a day, it ends where the next slot starts), so dragging the 11:00 Consultation after the 14:00 slot makes it start at 14:30. The gray "Fixed" slot opts out with `draggable: false`. + + + +```html +
+
+
+ + + +
+
+ +
+
+``` + +
diff --git a/docs/utility-classes/z-index.md b/docs/utility-classes/z-index.md index 2c72072..0943e1f 100644 --- a/docs/utility-classes/z-index.md +++ b/docs/utility-classes/z-index.md @@ -7,7 +7,8 @@ Utilities for controlling the stacking order of positioned elements. A higher `z | Class | Description | | ----- | ------------------------------------------------------------------------- | | z-1 | `z-index: 1;` | -| z-10 | `z-index: 10;` Raised elements above normal flow. | +| z-10 | `z-index: 10;` | +| z-20 | `z-index: 20;` | | z-50 | `z-index: 50;` Overlays such as popovers, dropdowns, and dialogs. | | z-60 | `z-index: 60;` The topmost layer, above overlays (used by notifications). | diff --git a/skills/harmonia/references/calendar.md b/skills/harmonia/references/calendar.md index 255ac79..2179521 100644 --- a/skills/harmonia/references/calendar.md +++ b/skills/harmonia/references/calendar.md @@ -8,6 +8,17 @@ Part of the Harmonia Alpine.js component library. Every directive uses the `x-h- Use `x-h-calendar` when users need to view and navigate a schedule - appointments, team calendars, project timelines, and so on. +## Behavior + +Set `draggable: true` in the configuration to let users reschedule events by dragging them: + +- **Week and day views** - dragging a timed event vertically moves its start time in steps of `dragStep` minutes (`15` by default), and dragging it onto another day column (week view) moves it to that day. The duration is kept. Events that continue from an earlier day can only be moved between days. +- **Week view all-day strip** - all-day pills can be dragged onto another day column. +- **Month view** - dragging an event pill onto another day cell changes only its day and keeps its time. The hovered target cell is highlighted while dragging. +- **Year view** - no drag and drop. + +Dropping never changes the calendar's data directly. The event snaps back and an `event-drop` event is dispatched with the proposed new `start` and `end` values. Apply them to your event object to accept the move, or ignore the event to reject it. Individual events can opt out with `draggable: false`. Dragging is a mouse or pen interaction, and a plain click still fires `event-click`. + ## Directive - `x-h-calendar` @@ -30,10 +41,11 @@ Use `x-h-calendar` when users need to view and navigate a schedule - appointment ### Events -| Event | Description | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| event-click | Fired when the user clicks an event. The original event object is passed in `$event.detail.event`. | -| date-click | Fired when the user clicks an empty date cell or time slot. The clicked `Date` is in `$event.detail.date`. For time-grid views the slot time string (`"HH:MM"`) is also in `$event.detail.time`. | +| Event | Description | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| event-click | Fired when the user clicks an event. The original event object is passed in `$event.detail.event`. | +| date-click | Fired when the user clicks an empty date cell or time slot. The clicked `Date` is in `$event.detail.date`. For time-grid views the slot time string (`"HH:MM"`) is also in `$event.detail.time`. | +| event-drop | Fired when a dragged event is dropped on a new day or time (requires the `draggable` option). `$event.detail.event` is the event object. `$event.detail.start` and `$event.detail.end` hold the proposed new values in the same string shape as the event's own fields (`"YYYY-MM-DDTHH:MM"`, or `"YYYY-MM-DD"` when the original value was date-only and the time of day is unchanged). `detail.end` is `undefined` when the event has no `end`. Assign the values to your event to apply the move. | ### Configuration @@ -53,6 +65,8 @@ Pass a configuration object to the directive as an expression. | showNowIndicator | Show the current-time indicator in week and day views. Defaults to `true`. Set to `false` to hide it. | | views | Show the view-switcher button group in the toolbar. Defaults to `true`. Set to `false` to lock the calendar to the view set in `view` and hide the switcher. | | scrollTo | Where week and day views scroll to on load - `"now"` anchors on the current time, `"first-event"` anchors on the earliest event in view. Falls back to `"now"` when the view has no timed events. | +| draggable | Enable drag-and-drop rescheduling of events in the month, week, and day views. Defaults to `false`. See Behavior. | +| dragStep | Minutes value, used as the step when a timed event is dragged vertically in the week and day views. Defaults to `15`. | ### Event object @@ -68,6 +82,7 @@ Each item in the `events` array supports the following fields: | color | `blue`
`red`
`green`
`yellow`
`purple`
`pink`
`indigo`
`orange`
`gray`
`teal` | false | Color key. | | status | string | false | Pill style. `confirmed` (default) renders a filled pill, `unconfirmed` renders an outlined pill, and `rejected` renders an outlined pill with a dashed border. | | description | string | false | Shown as a tooltip on event pills. | +| draggable | boolean | false | Set to `false` to exclude the event from drag and drop when the calendar has `draggable: true`. | ## Keyboard Handling @@ -83,7 +98,7 @@ Events are buttons in the tab order. Activate them to fire `event-click`. In the ## Accessibility -The calendar is a labeled `group` (default name "Calendar", overridable with an `aria-label` attribute). The toolbar period heading is an `aria-live` region. The month grid uses `role="grid"`/`row`/`gridcell` with `aria-current="date"` on today and full keyboard navigation. Events are `button`s whose accessible label includes the title, time (or "all day"), and status (e.g. "unconfirmed"). The week/day time grid's empty-slot "click to pick a time" is a pointer-only convenience. +The calendar is a labeled `group` (default name "Calendar", overridable with an `aria-label` attribute). The toolbar period heading is an `aria-live` region. The month grid uses `role="grid"`/`row`/`gridcell` with `aria-current="date"` on today and full keyboard navigation. Events are `button`s whose accessible label includes the title, time (or "all day"), and status (e.g. "unconfirmed"). The week/day time grid's empty-slot "click to pick a time" is a pointer-only convenience. Drag-and-drop rescheduling is a pointer-only convenience as well, and every event stays reachable through its button and `event-click`. ## Examples @@ -194,6 +209,41 @@ The calendar is a labeled `group` (default name "Calendar", overridable with an > ``` +### Drag and drop + +Enable rescheduling with `draggable: true` and apply the change in an `@event-drop` handler. The "Public Holiday" event opts out with `draggable: false`. + +```html +
+``` + Full docs: https://www.codbex.com/harmonia/components/calendar.html ## Notes diff --git a/skills/harmonia/references/slot-picker.md b/skills/harmonia/references/slot-picker.md index fad4ab2..b47d169 100644 --- a/skills/harmonia/references/slot-picker.md +++ b/skills/harmonia/references/slot-picker.md @@ -10,6 +10,17 @@ Use the Slot Picker when users need to book or choose one or more time slots fro Set `days` to control how many day columns are shown (1 to 7). The picker renders only the day grid, so you build the toolbar yourself from the control directives (every example below includes one). The previous/next controls move by that number of days, and the calendar control jumps straight to any date. The chosen date becomes the first of the visible days, which avoids paging far ahead one step at a time. Set `showNowIndicator: true` to mark the current time in today's column with a red line that moves as time passes. By default every day column stays visible at every width, so a narrow container simply shows narrower columns. Add the `responsive` modifier (`x-h-slot-picker.responsive`) to make the columns stack into a single column on narrow screens instead. +## Behavior + +Set `draggable: true` in the configuration to let users reorder slots within a day and move them to another visible day by dragging them. Dragging requires explicit `slots` (there must be an array to reorder), so generated slots (shorthand mode and `fillEmptyDays` fillers) never drag: + +- While a slot is dragged, a half-transparent copy of it follows the pointer, and the slot itself (dimmed) moves through the day lists live. The surrounding slots part around it by exactly its own space, always showing where the drop will land. +- A pointer just past the grid's edge still targets the nearest day. Disabled and out-of-range days are never drop targets, and releasing the slot over one snaps it back. Days whose slots are generated still accept drops, but note that applying such a drop makes the target day explicit, replacing its generated schedule. +- A slot with tiles drags as a whole. A press on a tile stays a tile interaction (click to select), so tiles cannot be dragged individually. +- Dropping never changes the picker's data directly. The slot snaps back and a `slot-drop` event is dispatched with the proposed change, including a ready-to-use `slots` array. Assign `$event.detail.slots` to your `slots` config to accept the move, or ignore the event to reject it. +- Individual slots can opt out with `draggable: false`, and unavailable slots never drag. +- Dragging is a mouse or pen interaction, and a plain click still selects the slot (or fires `slot-click`). + ## Directives `x-h-slot-picker` is the root. The directives compose one component and must be nested as shown in the Examples below (the library throws at runtime when a required ancestor is missing): @@ -73,6 +84,7 @@ Pass a configuration object as an Alpine expression. | minDate | - | Start day. When set, the user cannot page to any day before it. Accepts a `YYYY-MM-DD` string or a `Date`. Independent of `maxDate`. | | maxDate | - | End day. When set, the user cannot page to any day after it. Accepts a `YYYY-MM-DD` string or a `Date`. Independent of `minDate`. | | showNowIndicator | `false` | When `true`, a current-time indicator is shown in today's column and moves on its own as time passes. | +| draggable | `false` | Enable reordering slots within a day and moving them to another day by drag and drop. Requires explicit `slots`. See Behavior. | #### Slot object (explicit mode) @@ -88,6 +100,7 @@ Pass a configuration object as an Alpine expression. | status | string | For a colored slot, `confirmed` (default) renders it filled, `unconfirmed` renders it as an outline, and `rejected` renders it as an outline with a dashed border. Ignored when no `color` is set. | | icons | `{ left, right }` | Badge images rendered in the cell's top corners. `left` and `right` are optional arrays of `{ url, alt }` objects, where `url` is the image path and `alt` is the alt text (defaults to `''`). | | tiles | Tile[] | Sub-slots (see below). When present and non-empty, the slot renders as a labeled group and only its tiles are selectable. The slot's own `start` labels the group. | +| draggable | boolean | Set to `false` to exclude the slot from drag and drop when the picker has `draggable: true`. | #### Tile object (sub-slots) @@ -117,13 +130,14 @@ A selected sub-slot tile uses a composite key of the form `'YYYY-MM-DDTHH:MM#ind ### Events -| Event | Description | -| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| slot-click | Dispatched on every slot click, including deselection and when no `x-model` is bound (in which case `selected` is always `false`). `event.detail.slot` contains `date`, `start`, `end`, `available`, `selected` (the new state after the click), `description`, `note`, `color`, `status`, `key`, and `tileIndex` (a number for a tile, `null` for a plain slot). | +| Event | Description | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| slot-click | Dispatched on every slot click, including deselection and when no `x-model` is bound (in which case `selected` is always `false`). `event.detail.slot` contains `date`, `start`, `end`, `available`, `selected` (the new state after the click), `description`, `note`, `color`, `status`, `key`, and `tileIndex` (a number for a tile, `null` for a plain slot). | +| slot-drop | Dispatched when a dragged slot is dropped at a new position (requires the `draggable` option, dropping at the unchanged position dispatches nothing). `event.detail.slot` carries the same fields as `slot-click`'s detail without `selected`. `event.detail.date` is the target day as `YYYY-MM-DD` and `event.detail.index` the slot's new position within that day's slot list. `event.detail.slots` is a new array with the move applied, built without mutating yours - assign it to your `slots` config to accept the move. | ## Accessibility -The picker is a labeled `group` (default name "Time slot picker", overridable with an `aria-label` attribute). Each day is its own `group` labeled by its header, so the day is announced for the slots inside it. When selection is enabled (an `x-model` is bound), available slots are toggle buttons with a day + time `aria-label` and `aria-pressed` reflecting selection. Without an `x-model` they are plain action buttons with the same label and no `aria-pressed`. Unavailable slots are marked `aria-disabled` with a hidden "Not available" note. Selecting a slot updates the cell in place rather than re-rendering, so keyboard focus stays on the chosen slot. The `x-h-slot-picker-calendar` control opens a `dialog` containing a fully keyboard-navigable date grid, and the dialog takes its accessible name from that control. The default month and year navigation buttons labels can be overridden using the `data-aria-*` attributes. Picking a date moves the visible range and returns focus to the control, and `Esc` closes it. Because you supply the toolbar, give each control button an accessible name (an `aria-label` on an icon-only button, or visible text). +The picker is a labeled `group` (default name "Time slot picker", overridable with an `aria-label` attribute). Each day is its own `group` labeled by its header, so the day is announced for the slots inside it. When selection is enabled (an `x-model` is bound), available slots are toggle buttons with a day + time `aria-label` and `aria-pressed` reflecting selection. Without an `x-model` they are plain action buttons with the same label and no `aria-pressed`. Unavailable slots are marked `aria-disabled` with a hidden "Not available" note. Selecting a slot updates the cell in place rather than re-rendering, so keyboard focus stays on the chosen slot. The `x-h-slot-picker-calendar` control opens a `dialog` containing a fully keyboard-navigable date grid, and the dialog takes its accessible name from that control. The default month and year navigation buttons labels can be overridden using the `data-aria-*` attributes. Picking a date moves the visible range and returns focus to the control, and `Esc` closes it. Because you supply the toolbar, give each control button an accessible name (an `aria-label` on an icon-only button, or visible text). Drag-and-drop moving is a pointer-only convenience, and every slot stays reachable through its button and `slot-click`. ## Binding @@ -638,6 +652,75 @@ Selection is enabled by binding `x-model`. Leave it off to use the picker purely ``` +### Drag and drop + +Enable `draggable: true` and handle `slot-drop` to let users rearrange the schedule. While dragging, a half-transparent copy of the slot follows the pointer and the other slots part to show where it will land - within the same day (reorder) or on another day. The dragged slot snaps back until your handler applies the change. `$event.detail.slots` has the move applied but the slot keeps its original time, so a real handler adjusts it to the new position before assigning - that is the place for your own scheduling rules. Here `onDrop` preserves the slot's duration and starts it where its new predecessor ends (dropped at the top of a day, it ends where the next slot starts), so dragging the 11:00 Consultation after the 14:00 slot makes it start at 14:30. The gray "Fixed" slot opts out with `draggable: false`. + +```html +
+
+
+ + + +
+
+ +
+
+``` + Full docs: https://www.codbex.com/harmonia/components/slot-picker.html ## Notes diff --git a/skills/harmonia/references/utility-classes.md b/skills/harmonia/references/utility-classes.md index fb4843d..b4d69c5 100644 --- a/skills/harmonia/references/utility-classes.md +++ b/skills/harmonia/references/utility-classes.md @@ -43,5 +43,5 @@ fade-b-2 fade-b-4 fade-b-8 fade-l-2 fade-l-4 fade-l-8 fade-r-2 fade-r-4 fade-r-8 ## Tailwind utility subset ``` -*:scale-95 -translate-x-4 -translate-x-full -translate-y-4 -translate-y-full absolute align-middle animate-ping animate-pulse animate-spin aspect-3/2 aspect-square aspect-video backdrop-blur-md backdrop-blur-sm backdrop-blur-xs bg-auto bg-background bg-black bg-blue-500 bg-card bg-contain bg-cover bg-gray-500 bg-green-500 bg-indigo-500 bg-information bg-information-foreground bg-muted bg-muted-foreground bg-negative bg-negative-foreground bg-no-repeat bg-orange-500 bg-pink-500 bg-positive bg-positive-foreground bg-primary bg-primary-foreground bg-purple-500 bg-red-500 bg-repeat bg-repeat-x bg-repeat-y bg-secondary bg-secondary-foreground bg-sidebar bg-teal-500 bg-warning bg-warning-foreground bg-white bg-yellow-500 block border border-0 border-10 border-11 border-12 border-2 border-3 border-4 border-5 border-6 border-7 border-8 border-9 border-b border-background border-border border-dashed border-dotted border-foreground border-information border-information-foreground border-l border-muted border-muted-foreground border-negative border-negative-foreground border-positive border-positive-foreground border-primary border-primary-foreground border-primary/50 border-r border-secondary border-secondary-foreground border-solid border-t border-warning border-warning-foreground border-x border-y bottom-0 box-border capitalize col-end-1 col-end-10 col-end-11 col-end-12 col-end-2 col-end-3 col-end-4 col-end-5 col-end-6 col-end-7 col-end-8 col-end-9 col-span-1 col-span-10 col-span-11 col-span-12 col-span-2 col-span-3 col-span-4 col-span-5 col-span-6 col-span-7 col-span-8 col-span-9 col-start-1 col-start-10 col-start-11 col-start-12 col-start-2 col-start-3 col-start-4 col-start-5 col-start-6 col-start-7 col-start-8 col-start-9 content-around content-between content-center content-end content-evenly content-start content-stretch cursor-crosshair cursor-grab cursor-grabbing cursor-none cursor-not-allowed cursor-pointer cursor-text cursor-wait divide-dashed divide-dotted divide-solid divide-x divide-x-0 divide-x-10 divide-x-11 divide-x-12 divide-x-2 divide-x-3 divide-x-4 divide-x-5 divide-x-6 divide-x-7 divide-x-8 divide-x-9 divide-x-reverse divide-y divide-y-0 divide-y-10 divide-y-11 divide-y-12 divide-y-2 divide-y-3 divide-y-4 divide-y-5 divide-y-6 divide-y-7 divide-y-8 divide-y-9 divide-y-reverse duration-100 duration-200 duration-300 ease-linear ease-out fill-black fill-blue-500 fill-current fill-gray-500 fill-green-500 fill-indigo-500 fill-information fill-information-foreground fill-muted fill-muted-foreground fill-negative fill-negative-foreground fill-none fill-orange-500 fill-pink-500 fill-positive fill-positive-foreground fill-primary fill-primary-foreground fill-purple-500 fill-red-500 fill-secondary fill-secondary-foreground fill-teal-500 fill-warning fill-warning-foreground fill-white fill-yellow-500 fixed flex flex-0 flex-1 flex-auto flex-col flex-col-reverse flex-none flex-row flex-row-reverse flex-wrap float-right font-bold font-extrabold font-light font-medium font-mono font-normal font-sans font-semibold font-serif gap-1 gap-10 gap-11 gap-12 gap-2 gap-3 gap-4 gap-5 gap-6 gap-7 gap-8 gap-9 gap-x-1 gap-x-10 gap-x-11 gap-x-12 gap-x-2 gap-x-3 gap-x-4 gap-x-5 gap-x-6 gap-x-7 gap-x-8 gap-x-9 gap-y-1 gap-y-10 gap-y-11 gap-y-12 gap-y-2 gap-y-3 gap-y-4 gap-y-5 gap-y-6 gap-y-7 gap-y-8 gap-y-9 grid grid-cols-1 grid-cols-10 grid-cols-11 grid-cols-12 grid-cols-2 grid-cols-3 grid-cols-4 grid-cols-5 grid-cols-6 grid-cols-7 grid-cols-8 grid-cols-9 group-focus-within:opacity-100 group-hover:opacity-100 h-1 h-1/2 h-10 h-11 h-12 h-2 h-3 h-4 h-5 h-6 h-7 h-8 h-9 h-auto h-dvh h-fit h-full h-lvh h-max h-min h-screen h-svh hidden hidden! hover:bg-muted inline inline-block inline-flex inset-0 italic items-baseline items-center items-end items-start items-stretch justify-around justify-between justify-center justify-end justify-end-safe justify-evenly justify-items-center justify-items-end justify-items-start justify-items-stretch justify-start justify-stretch leading-4 leading-5 leading-6 leading-7 leading-8 leading-none leading-normal leading-relaxed leading-snug left-0 line-clamp-1 line-clamp-2 line-clamp-3 line-clamp-4 line-clamp-5 line-clamp-6 line-through lowercase m-0 m-1 m-10 m-11 m-12 m-2 m-3 m-4 m-5 m-6 m-7 m-8 m-9 max-h-1 max-h-10 max-h-11 max-h-12 max-h-2 max-h-3 max-h-4 max-h-5 max-h-6 max-h-7 max-h-8 max-h-9 max-w-1 max-w-10 max-w-10xl max-w-11 max-w-12 max-w-2 max-w-2xl max-w-2xs max-w-3 max-w-3xl max-w-3xs max-w-4 max-w-4xl max-w-5 max-w-5xl max-w-6 max-w-6xl max-w-7 max-w-7xl max-w-8 max-w-8xl max-w-9 max-w-9xl max-w-auto max-w-dvw max-w-lg max-w-md max-w-screen max-w-sm max-w-xl max-w-xs mb-0 mb-1 mb-10 mb-11 mb-12 mb-2 mb-3 mb-4 mb-5 mb-6 mb-7 mb-8 mb-9 min-h-0 min-h-1 min-h-10 min-h-11 min-h-12 min-h-2 min-h-3 min-h-4 min-h-5 min-h-6 min-h-7 min-h-8 min-h-9 min-w-0 min-w-1 min-w-10 min-w-10xl min-w-11 min-w-12 min-w-2 min-w-2xl min-w-2xs min-w-3 min-w-3xl min-w-3xs min-w-4 min-w-4xl min-w-5 min-w-5xl min-w-6 min-w-6xl min-w-7 min-w-7xl min-w-8 min-w-8xl min-w-9 min-w-9xl min-w-auto min-w-lg min-w-md min-w-sm min-w-xl min-w-xs ml-0 ml-1 ml-10 ml-11 ml-12 ml-2 ml-3 ml-4 ml-5 ml-6 ml-7 ml-8 ml-9 ml-auto motion-reduce:transition-none mr-0 mr-1 mr-10 mr-11 mr-12 mr-2 mr-3 mr-4 mr-5 mr-6 mr-7 mr-8 mr-9 mr-auto mt-0 mt-1 mt-10 mt-11 mt-12 mt-2 mt-3 mt-4 mt-5 mt-6 mt-7 mt-8 mt-9 mx-0 mx-1 mx-10 mx-11 mx-12 mx-2 mx-3 mx-4 mx-5 mx-6 mx-7 mx-8 mx-9 mx-auto my-0 my-1 my-10 my-11 my-12 my-2 my-3 my-4 my-5 my-6 my-7 my-8 my-9 no-underline object-contain object-cover object-fill opacity-0 opacity-100 opacity-25 opacity-50 opacity-75 overflow-auto overflow-hidden overflow-scroll overflow-visible overflow-x-auto overflow-x-hidden overflow-x-scroll overflow-x-visible overflow-y-auto overflow-y-hidden overflow-y-scroll overflow-y-visible p-0 p-1 p-10 p-11 p-12 p-2 p-3 p-4 p-5 p-6 p-7 p-8 p-9 pb-0 pb-1 pb-10 pb-11 pb-12 pb-2 pb-3 pb-4 pb-5 pb-6 pb-7 pb-8 pb-9 pl-0 pl-1 pl-10 pl-11 pl-12 pl-2 pl-3 pl-4 pl-5 pl-6 pl-7 pl-8 pl-9 pl-auto place-content-around place-content-between place-content-center place-content-end place-content-evenly place-content-start place-content-stretch place-items-center place-items-end place-items-start place-items-stretch pr-0 pr-1 pr-10 pr-11 pr-12 pr-2 pr-3 pr-4 pr-5 pr-6 pr-7 pr-8 pr-9 pr-auto pt-0 pt-1 pt-10 pt-11 pt-12 pt-2 pt-3 pt-4 pt-5 pt-6 pt-7 pt-8 pt-9 px-0 px-1 px-10 px-11 px-12 px-2 px-3 px-4 px-5 px-6 px-7 px-8 px-9 py-0 py-1 py-10 py-11 py-12 py-2 py-3 py-4 py-5 py-6 py-7 py-8 py-9 relative resize-none right-0 ring-0 ring-1 ring-2 ring-4 rotate-180 rotate-270 rotate-90 rounded-2xl rounded-3xl rounded-4xl rounded-b-2xl rounded-b-3xl rounded-b-4xl rounded-b-full rounded-b-lg rounded-b-md rounded-b-none rounded-b-sm rounded-b-xl rounded-b-xs rounded-bl-2xl rounded-bl-3xl rounded-bl-4xl rounded-bl-full rounded-bl-lg rounded-bl-md rounded-bl-none rounded-bl-sm rounded-bl-xl rounded-bl-xs rounded-br-2xl rounded-br-3xl rounded-br-4xl rounded-br-full rounded-br-lg rounded-br-md rounded-br-none rounded-br-sm rounded-br-xl rounded-br-xs rounded-control rounded-e-2xl rounded-e-3xl rounded-e-4xl rounded-e-full rounded-e-lg rounded-e-md rounded-e-none rounded-e-sm rounded-e-xl rounded-e-xs rounded-full rounded-lg rounded-md rounded-none rounded-none! rounded-s-2xl rounded-s-3xl rounded-s-4xl rounded-s-full rounded-s-lg rounded-s-md rounded-s-none rounded-s-sm rounded-s-xl rounded-s-xs rounded-sm rounded-t-2xl rounded-t-3xl rounded-t-4xl rounded-t-full rounded-t-lg rounded-t-md rounded-t-none rounded-t-sm rounded-t-xl rounded-t-xs rounded-tl-2xl rounded-tl-3xl rounded-tl-4xl rounded-tl-full rounded-tl-lg rounded-tl-md rounded-tl-none rounded-tl-sm rounded-tl-xl rounded-tl-xs rounded-tr-2xl rounded-tr-3xl rounded-tr-4xl rounded-tr-full rounded-tr-lg rounded-tr-md rounded-tr-none rounded-tr-sm rounded-tr-xl rounded-tr-xs rounded-xl rounded-xs row-span-1 row-span-10 row-span-11 row-span-12 row-span-2 row-span-3 row-span-4 row-span-5 row-span-6 row-span-7 row-span-8 row-span-9 scale-105 scale-95 select-none self-center self-end self-start self-stretch shadow-lg shadow-md shadow-none shadow-none! shadow-sm shadow-xl shadow-xs shrink-0 size-1 size-10 size-11 size-12 size-2 size-3 size-4 size-5 size-6 size-7 size-8 size-9 size-fit size-full sr-only sticky stroke-black stroke-blue-500 stroke-gray-500 stroke-green-500 stroke-indigo-500 stroke-orange-500 stroke-pink-500 stroke-purple-500 stroke-red-500 stroke-teal-500 stroke-white stroke-yellow-500 tabular-nums text-2xl text-2xs text-3xl text-4xl text-5xl text-6xl text-7xl text-8xl text-9xl text-background text-base text-black text-blue-500 text-center text-ellipsis text-foreground text-gray-500 text-green-500 text-indigo-500 text-information text-information-foreground text-justify text-left text-lg text-muted text-muted-foreground text-negative text-negative-foreground text-nowrap text-orange-500 text-pink-500 text-positive text-positive-foreground text-primary text-primary-foreground text-purple-500 text-red-500 text-right text-secondary text-secondary-foreground text-sidebar-foreground text-sm text-teal-500 text-warning text-warning-foreground text-white text-wrap text-xl text-xs text-yellow-500 top-0 tracking-tight transition-[opacity,scale] transition-all transition-colors transition-opacity transition-shadow transition-transform translate-x-4 translate-x-full translate-y-4 translate-y-full truncate underline uppercase w-1 w-1/10 w-1/2 w-1/3 w-1/4 w-1/5 w-10 w-10xl w-11 w-12 w-2 w-2/3 w-2/5 w-2xl w-2xs w-3 w-3/4 w-3/5 w-3xl w-3xs w-4 w-4/5 w-4xl w-5 w-5xl w-6 w-6xl w-7 w-7xl w-8 w-8xl w-9 w-9/10 w-9xl w-auto w-dvw w-fit w-full w-lg w-lvw w-max w-md w-min w-screen w-sm w-svw w-xl w-xs whitespace-nowrap whitespace-pre whitespace-pre-line whitespace-pre-wrap wrap-anywhere wrap-break-word z-1 z-10 z-50 z-60 +*:scale-95 -translate-x-4 -translate-x-full -translate-y-4 -translate-y-full absolute align-middle animate-ping animate-pulse animate-spin aspect-3/2 aspect-square aspect-video backdrop-blur-md backdrop-blur-sm backdrop-blur-xs bg-auto bg-background bg-black bg-blue-500 bg-card bg-contain bg-cover bg-gray-500 bg-green-500 bg-indigo-500 bg-information bg-information-foreground bg-muted bg-muted-foreground bg-negative bg-negative-foreground bg-no-repeat bg-orange-500 bg-pink-500 bg-positive bg-positive-foreground bg-primary bg-primary-foreground bg-purple-500 bg-red-500 bg-repeat bg-repeat-x bg-repeat-y bg-secondary bg-secondary-foreground bg-sidebar bg-teal-500 bg-warning bg-warning-foreground bg-white bg-yellow-500 block border border-0 border-10 border-11 border-12 border-2 border-3 border-4 border-5 border-6 border-7 border-8 border-9 border-b border-background border-border border-dashed border-dotted border-foreground border-information border-information-foreground border-l border-muted border-muted-foreground border-negative border-negative-foreground border-positive border-positive-foreground border-primary border-primary-foreground border-primary/50 border-r border-secondary border-secondary-foreground border-solid border-t border-warning border-warning-foreground border-x border-y bottom-0 box-border capitalize col-end-1 col-end-10 col-end-11 col-end-12 col-end-2 col-end-3 col-end-4 col-end-5 col-end-6 col-end-7 col-end-8 col-end-9 col-span-1 col-span-10 col-span-11 col-span-12 col-span-2 col-span-3 col-span-4 col-span-5 col-span-6 col-span-7 col-span-8 col-span-9 col-start-1 col-start-10 col-start-11 col-start-12 col-start-2 col-start-3 col-start-4 col-start-5 col-start-6 col-start-7 col-start-8 col-start-9 content-around content-between content-center content-end content-evenly content-start content-stretch cursor-crosshair cursor-grab cursor-grabbing cursor-none cursor-not-allowed cursor-pointer cursor-text cursor-wait divide-dashed divide-dotted divide-solid divide-x divide-x-0 divide-x-10 divide-x-11 divide-x-12 divide-x-2 divide-x-3 divide-x-4 divide-x-5 divide-x-6 divide-x-7 divide-x-8 divide-x-9 divide-x-reverse divide-y divide-y-0 divide-y-10 divide-y-11 divide-y-12 divide-y-2 divide-y-3 divide-y-4 divide-y-5 divide-y-6 divide-y-7 divide-y-8 divide-y-9 divide-y-reverse duration-100 duration-200 duration-300 ease-linear ease-out fill-black fill-blue-500 fill-current fill-gray-500 fill-green-500 fill-indigo-500 fill-information fill-information-foreground fill-muted fill-muted-foreground fill-negative fill-negative-foreground fill-none fill-orange-500 fill-pink-500 fill-positive fill-positive-foreground fill-primary fill-primary-foreground fill-purple-500 fill-red-500 fill-secondary fill-secondary-foreground fill-teal-500 fill-warning fill-warning-foreground fill-white fill-yellow-500 fixed flex flex-0 flex-1 flex-auto flex-col flex-col-reverse flex-none flex-row flex-row-reverse flex-wrap float-right font-bold font-extrabold font-light font-medium font-mono font-normal font-sans font-semibold font-serif gap-1 gap-10 gap-11 gap-12 gap-2 gap-3 gap-4 gap-5 gap-6 gap-7 gap-8 gap-9 gap-x-1 gap-x-10 gap-x-11 gap-x-12 gap-x-2 gap-x-3 gap-x-4 gap-x-5 gap-x-6 gap-x-7 gap-x-8 gap-x-9 gap-y-1 gap-y-10 gap-y-11 gap-y-12 gap-y-2 gap-y-3 gap-y-4 gap-y-5 gap-y-6 gap-y-7 gap-y-8 gap-y-9 grid grid-cols-1 grid-cols-10 grid-cols-11 grid-cols-12 grid-cols-2 grid-cols-3 grid-cols-4 grid-cols-5 grid-cols-6 grid-cols-7 grid-cols-8 grid-cols-9 group-focus-within:opacity-100 group-hover:opacity-100 h-1 h-1/2 h-10 h-11 h-12 h-2 h-3 h-4 h-5 h-6 h-7 h-8 h-9 h-auto h-dvh h-fit h-full h-lvh h-max h-min h-screen h-svh hidden hidden! hover:bg-muted inline inline-block inline-flex inset-0 italic items-baseline items-center items-end items-start items-stretch justify-around justify-between justify-center justify-end justify-end-safe justify-evenly justify-items-center justify-items-end justify-items-start justify-items-stretch justify-start justify-stretch leading-4 leading-5 leading-6 leading-7 leading-8 leading-none leading-normal leading-relaxed leading-snug left-0 line-clamp-1 line-clamp-2 line-clamp-3 line-clamp-4 line-clamp-5 line-clamp-6 line-through lowercase m-0 m-1 m-10 m-11 m-12 m-2 m-3 m-4 m-5 m-6 m-7 m-8 m-9 max-h-1 max-h-10 max-h-11 max-h-12 max-h-2 max-h-3 max-h-4 max-h-5 max-h-6 max-h-7 max-h-8 max-h-9 max-w-1 max-w-10 max-w-10xl max-w-11 max-w-12 max-w-2 max-w-2xl max-w-2xs max-w-3 max-w-3xl max-w-3xs max-w-4 max-w-4xl max-w-5 max-w-5xl max-w-6 max-w-6xl max-w-7 max-w-7xl max-w-8 max-w-8xl max-w-9 max-w-9xl max-w-auto max-w-dvw max-w-lg max-w-md max-w-screen max-w-sm max-w-xl max-w-xs mb-0 mb-1 mb-10 mb-11 mb-12 mb-2 mb-3 mb-4 mb-5 mb-6 mb-7 mb-8 mb-9 min-h-0 min-h-1 min-h-10 min-h-11 min-h-12 min-h-2 min-h-3 min-h-4 min-h-5 min-h-6 min-h-7 min-h-8 min-h-9 min-w-0 min-w-1 min-w-10 min-w-10xl min-w-11 min-w-12 min-w-2 min-w-2xl min-w-2xs min-w-3 min-w-3xl min-w-3xs min-w-4 min-w-4xl min-w-5 min-w-5xl min-w-6 min-w-6xl min-w-7 min-w-7xl min-w-8 min-w-8xl min-w-9 min-w-9xl min-w-auto min-w-lg min-w-md min-w-sm min-w-xl min-w-xs ml-0 ml-1 ml-10 ml-11 ml-12 ml-2 ml-3 ml-4 ml-5 ml-6 ml-7 ml-8 ml-9 ml-auto motion-reduce:transition-none mr-0 mr-1 mr-10 mr-11 mr-12 mr-2 mr-3 mr-4 mr-5 mr-6 mr-7 mr-8 mr-9 mr-auto mt-0 mt-1 mt-10 mt-11 mt-12 mt-2 mt-3 mt-4 mt-5 mt-6 mt-7 mt-8 mt-9 mx-0 mx-1 mx-10 mx-11 mx-12 mx-2 mx-3 mx-4 mx-5 mx-6 mx-7 mx-8 mx-9 mx-auto my-0 my-1 my-10 my-11 my-12 my-2 my-3 my-4 my-5 my-6 my-7 my-8 my-9 no-underline object-contain object-cover object-fill opacity-0 opacity-100 opacity-25 opacity-50 opacity-75 overflow-auto overflow-hidden overflow-scroll overflow-visible overflow-x-auto overflow-x-hidden overflow-x-scroll overflow-x-visible overflow-y-auto overflow-y-hidden overflow-y-scroll overflow-y-visible p-0 p-1 p-10 p-11 p-12 p-2 p-3 p-4 p-5 p-6 p-7 p-8 p-9 pb-0 pb-1 pb-10 pb-11 pb-12 pb-2 pb-3 pb-4 pb-5 pb-6 pb-7 pb-8 pb-9 pl-0 pl-1 pl-10 pl-11 pl-12 pl-2 pl-3 pl-4 pl-5 pl-6 pl-7 pl-8 pl-9 pl-auto place-content-around place-content-between place-content-center place-content-end place-content-evenly place-content-start place-content-stretch place-items-center place-items-end place-items-start place-items-stretch pr-0 pr-1 pr-10 pr-11 pr-12 pr-2 pr-3 pr-4 pr-5 pr-6 pr-7 pr-8 pr-9 pr-auto pt-0 pt-1 pt-10 pt-11 pt-12 pt-2 pt-3 pt-4 pt-5 pt-6 pt-7 pt-8 pt-9 px-0 px-1 px-10 px-11 px-12 px-2 px-3 px-4 px-5 px-6 px-7 px-8 px-9 py-0 py-1 py-10 py-11 py-12 py-2 py-3 py-4 py-5 py-6 py-7 py-8 py-9 relative resize-none right-0 ring-0 ring-1 ring-2 ring-4 rotate-180 rotate-270 rotate-90 rounded-2xl rounded-3xl rounded-4xl rounded-b-2xl rounded-b-3xl rounded-b-4xl rounded-b-full rounded-b-lg rounded-b-md rounded-b-none rounded-b-sm rounded-b-xl rounded-b-xs rounded-bl-2xl rounded-bl-3xl rounded-bl-4xl rounded-bl-full rounded-bl-lg rounded-bl-md rounded-bl-none rounded-bl-sm rounded-bl-xl rounded-bl-xs rounded-br-2xl rounded-br-3xl rounded-br-4xl rounded-br-full rounded-br-lg rounded-br-md rounded-br-none rounded-br-sm rounded-br-xl rounded-br-xs rounded-control rounded-e-2xl rounded-e-3xl rounded-e-4xl rounded-e-full rounded-e-lg rounded-e-md rounded-e-none rounded-e-sm rounded-e-xl rounded-e-xs rounded-full rounded-lg rounded-md rounded-none rounded-none! rounded-s-2xl rounded-s-3xl rounded-s-4xl rounded-s-full rounded-s-lg rounded-s-md rounded-s-none rounded-s-sm rounded-s-xl rounded-s-xs rounded-sm rounded-t-2xl rounded-t-3xl rounded-t-4xl rounded-t-full rounded-t-lg rounded-t-md rounded-t-none rounded-t-sm rounded-t-xl rounded-t-xs rounded-tl-2xl rounded-tl-3xl rounded-tl-4xl rounded-tl-full rounded-tl-lg rounded-tl-md rounded-tl-none rounded-tl-sm rounded-tl-xl rounded-tl-xs rounded-tr-2xl rounded-tr-3xl rounded-tr-4xl rounded-tr-full rounded-tr-lg rounded-tr-md rounded-tr-none rounded-tr-sm rounded-tr-xl rounded-tr-xs rounded-xl rounded-xs row-span-1 row-span-10 row-span-11 row-span-12 row-span-2 row-span-3 row-span-4 row-span-5 row-span-6 row-span-7 row-span-8 row-span-9 scale-105 scale-95 select-none self-center self-end self-start self-stretch shadow-lg shadow-md shadow-none shadow-none! shadow-sm shadow-xl shadow-xs shrink-0 size-1 size-10 size-11 size-12 size-2 size-3 size-4 size-5 size-6 size-7 size-8 size-9 size-fit size-full sr-only sticky stroke-black stroke-blue-500 stroke-gray-500 stroke-green-500 stroke-indigo-500 stroke-orange-500 stroke-pink-500 stroke-purple-500 stroke-red-500 stroke-teal-500 stroke-white stroke-yellow-500 tabular-nums text-2xl text-2xs text-3xl text-4xl text-5xl text-6xl text-7xl text-8xl text-9xl text-background text-base text-black text-blue-500 text-center text-ellipsis text-foreground text-gray-500 text-green-500 text-indigo-500 text-information text-information-foreground text-justify text-left text-lg text-muted text-muted-foreground text-negative text-negative-foreground text-nowrap text-orange-500 text-pink-500 text-positive text-positive-foreground text-primary text-primary-foreground text-purple-500 text-red-500 text-right text-secondary text-secondary-foreground text-sidebar-foreground text-sm text-teal-500 text-warning text-warning-foreground text-white text-wrap text-xl text-xs text-yellow-500 top-0 tracking-tight transition-[opacity,scale] transition-all transition-colors transition-opacity transition-shadow transition-transform translate-x-4 translate-x-full translate-y-4 translate-y-full truncate underline uppercase w-1 w-1/10 w-1/2 w-1/3 w-1/4 w-1/5 w-10 w-10xl w-11 w-12 w-2 w-2/3 w-2/5 w-2xl w-2xs w-3 w-3/4 w-3/5 w-3xl w-3xs w-4 w-4/5 w-4xl w-5 w-5xl w-6 w-6xl w-7 w-7xl w-8 w-8xl w-9 w-9/10 w-9xl w-auto w-dvw w-fit w-full w-lg w-lvw w-max w-md w-min w-screen w-sm w-svw w-xl w-xs whitespace-nowrap whitespace-pre whitespace-pre-line whitespace-pre-wrap wrap-anywhere wrap-break-word z-1 z-10 z-20 z-50 z-60 ``` diff --git a/src/common/drag.js b/src/common/drag.js new file mode 100644 index 0000000..7486276 --- /dev/null +++ b/src/common/drag.js @@ -0,0 +1,90 @@ +// Shared pointer plumbing for drag interactions (calendar events, slot picker slots). + +// Pointer travel in px before a press becomes a drag. +export const DRAG_THRESHOLD = 4; + +export function capturePointer(target, e) { + if (target.setPointerCapture) { + try { + target.setPointerCapture(e.pointerId); + } catch { + // Pointer capture is best-effort. Ignore environments that lack it. + } + } +} + +export function releasePointer(target, e) { + if (target.releasePointerCapture) { + try { + target.releasePointerCapture(e.pointerId); + } catch { + // Pointer capture is best-effort. Ignore environments that lack it. + } + } +} + +// Day-target drag: the source element stays in place (dimmed) while the cell or +// column under the pointer is highlighted as the drop target. The caller maps +// pointer coordinates to targets via resolveTarget(x, y) -> { el, ... } | null +// and receives the final target (or null when the drop landed on none) in +// onDrop, which fires only after a real drag (past the threshold) ends with a +// pointerup, never on pointercancel. canStart(e) can veto a press (e.g. one +// that begins on a nested interactive element), and onPointerDown runs for +// every accepted press. Listeners live on the source element itself (pointer +// capture routes moves there), so they die with the render and nothing +// outlives the directive. +export function attachDayDrag(source, { canStart, onPointerDown, resolveTarget, onDrop }) { + source.addEventListener('pointerdown', (e) => { + if (e.button > 0) return; + if (canStart && !canStart(e)) return; + onPointerDown?.(); + const startX = e.clientX; + const startY = e.clientY; + let dragging = false; + let target = null; + + const setTarget = (next) => { + if (next?.el === target?.el) return; + if (target) { + target.el.removeAttribute('data-drop-target'); + target.el.classList.remove('bg-muted/50'); + } + target = next; + if (target) { + target.el.setAttribute('data-drop-target', 'true'); + target.el.classList.add('bg-muted/50'); + } + }; + + const move = (me) => { + if (!source.isConnected) return finish(false); + if (!dragging) { + if (Math.abs(me.clientX - startX) < DRAG_THRESHOLD && Math.abs(me.clientY - startY) < DRAG_THRESHOLD) return; + dragging = true; + source.setAttribute('data-dragging', 'true'); + source.classList.add('opacity-50'); + } + setTarget(resolveTarget(me.clientX, me.clientY)); + }; + + const finish = (commit) => { + source.removeEventListener('pointermove', move); + source.removeEventListener('pointerup', up); + source.removeEventListener('pointercancel', cancel); + releasePointer(source, e); + if (!dragging) return; + const drop = target; + setTarget(null); + source.removeAttribute('data-dragging'); + source.classList.remove('opacity-50'); + if (commit) onDrop(drop); + }; + const up = () => finish(true); + const cancel = () => finish(false); + + capturePointer(source, e); + source.addEventListener('pointermove', move); + source.addEventListener('pointerup', up); + source.addEventListener('pointercancel', cancel); + }); +} diff --git a/src/components/calendar.js b/src/components/calendar.js index a7bdf48..fd8d498 100644 --- a/src/components/calendar.js +++ b/src/components/calendar.js @@ -1,5 +1,6 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; import { createCalendarWidget, isToday, nextFocusDate, parseDateValue, sameDay, toDateString } from '../common/calendar'; +import { attachDayDrag, capturePointer, DRAG_THRESHOLD, releasePointer } from '../common/drag'; import { colorClasses } from '../common/event-colors'; import { ChevronDown, ChevronLeft, ChevronRight, createSvg } from '../common/icons'; import { createDateTimeFormatCache } from '../common/intl'; @@ -59,6 +60,10 @@ export default function (Alpine) { let showViewSwitcher = true; let scrollTo = 'now'; let currentTrimAll = null; + let draggable = false; + let dragStep = 15; + // Set when a drag just completed so the click that follows it does not fire event-click. + let suppressClick = false; el.classList.add('flex', 'flex-col', 'h-full', 'overflow-hidden'); el.setAttribute('role', 'group'); @@ -307,6 +312,10 @@ export default function (Alpine) { if (ev.description) pill.title = ev.description; pill.addEventListener('click', (e) => { e.stopPropagation(); + if (suppressClick) { + suppressClick = false; + return; + } el.dispatchEvent(new CustomEvent('event-click', { detail: { event: ev }, bubbles: true })); }); return pill; @@ -355,11 +364,153 @@ export default function (Alpine) { } } + // === Drag and drop === + // Rescheduling by drag never mutates the calendar's own data: a completed + // drag restores the element and dispatches `event-drop` with the proposed + // new start/end strings, and the consumer applies them to its events array + // (which re-renders the calendar). Time-grid math relies on renderTimeGrid's + // scale of 1px per minute (HOUR_H = 60). + + const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; + + function canDrag(ev) { + return draggable && ev.draggable !== false; + } + + // Local YYYY-MM-DDTHH:MM string, the minute-precision form of an event's start/end. + function toDateTimeString(d) { + return `${toDateString(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + + // Shift by whole days, then minutes, with wall-clock semantics across DST. + function shiftDate(date, dayDelta, minsDelta) { + const d = new Date(date); + d.setDate(d.getDate() + dayDelta); + if (minsDelta) d.setMinutes(d.getMinutes() + minsDelta); + return d; + } + + // Keep the consumer's string shape: a date-only field stays date-only while + // the time of day is unchanged (month and all-day drags), otherwise emit a + // datetime string. + function dropValue(original, shifted, timeChanged) { + return !timeChanged && typeof original === 'string' && DATE_ONLY_RE.test(original) ? toDateString(shifted) : toDateTimeString(shifted); + } + + function dispatchEventDrop(ev, dayDelta, minsDelta) { + el.dispatchEvent( + new CustomEvent('event-drop', { + detail: { + event: ev, + start: dropValue(ev.start, shiftDate(ev.startDate, dayDelta, minsDelta), minsDelta !== 0), + end: ev.end ? dropValue(ev.end, shiftDate(ev.endDate, dayDelta, minsDelta), minsDelta !== 0) : undefined, + }, + bubbles: true, + }) + ); + } + + // Timed events in the week/day grid: vertical moves snap to dragStep minutes + // (15 by default), horizontal moves follow the pointer across day columns. Listeners live on + // the event element itself (pointer capture routes moves there), so they die + // with the render and nothing outlives the directive. + function attachTimedDrag(evEl, ev, { colsGrid, scrollArea, days, dayIdx, startMins, durMins, lockVertical, timeEl }) { + evEl.addEventListener('pointerdown', (e) => { + if (e.button > 0) return; + suppressClick = false; + const startX = e.clientX; + const startY = e.clientY; + const startScroll = scrollArea.scrollTop; + const orig = { top: evEl.style.top, left: evEl.style.left, width: evEl.style.width, time: timeEl ? timeEl.textContent : '' }; + // The 30-minute visual floor can render past the day's end. Never force + // the event upward because of it. + const maxDelta = Math.max(24 * 60 - startMins - durMins, 0); + let dragging = false; + let minsDelta = 0; + let dayDelta = 0; + + const move = (me) => { + if (!evEl.isConnected) return finish(false); + if (!dragging) { + if (Math.abs(me.clientX - startX) < DRAG_THRESHOLD && Math.abs(me.clientY - startY) < DRAG_THRESHOLD) return; + dragging = true; + evEl.setAttribute('data-dragging', 'true'); + evEl.classList.add('z-20', 'shadow-lg'); + evEl.style.left = '0.125rem'; + evEl.style.width = 'calc(100% - 0.25rem)'; + } + // Nudge the scroll area when the pointer nears its edges, before the + // delta math so the same move lands consistently. + const sRect = scrollArea.getBoundingClientRect(); + if (sRect.height > 0) { + if (me.clientY < sRect.top + 40) scrollArea.scrollTop -= 15; + else if (me.clientY > sRect.bottom - 40) scrollArea.scrollTop += 15; + } + if (!lockVertical) { + const dy = me.clientY - startY + (scrollArea.scrollTop - startScroll); + minsDelta = Math.min(Math.max(Math.round(dy / dragStep) * dragStep, -startMins), maxDelta); + evEl.style.top = `${startMins + minsDelta}px`; + if (timeEl) timeEl.textContent = dtf(locale, { hour: 'numeric', minute: '2-digit' }).format(shiftDate(ev.startDate, 0, minsDelta)); + } + const gRect = colsGrid.getBoundingClientRect(); + const colW = gRect.width / days.length; + if (colW > 0) { + const targetCol = Math.min(Math.max(Math.floor((me.clientX - gRect.left) / colW), 0), days.length - 1); + dayDelta = targetCol - dayIdx; + evEl.style.transform = dayDelta ? `translateX(${dayDelta * colW}px)` : ''; + } + }; + + const finish = (commit) => { + evEl.removeEventListener('pointermove', move); + evEl.removeEventListener('pointerup', up); + evEl.removeEventListener('pointercancel', cancel); + releasePointer(evEl, e); + if (!dragging) return; + evEl.removeAttribute('data-dragging'); + evEl.classList.remove('z-20', 'shadow-lg'); + evEl.style.top = orig.top; + evEl.style.left = orig.left; + evEl.style.width = orig.width; + evEl.style.transform = ''; + if (timeEl) timeEl.textContent = orig.time; + if (commit) { + suppressClick = true; + if ((dayDelta !== 0 || minsDelta !== 0) && evEl.isConnected) dispatchEventDrop(ev, dayDelta, minsDelta); + } + }; + const up = () => finish(true); + const cancel = () => finish(false); + + capturePointer(evEl, e); + evEl.addEventListener('pointermove', move); + evEl.addEventListener('pointerup', up); + evEl.addEventListener('pointercancel', cancel); + }); + } + + // Day-only drag for month cells and the week all-day strip: the pill stays + // in place while the hovered day cell is highlighted. Dropping shifts the + // event by whole days and keeps its time of day. + function attachEventDayDrag(pill, ev, originDay, resolveCell) { + attachDayDrag(pill, { + onPointerDown: () => (suppressClick = false), + resolveTarget: resolveCell, + onDrop: (target) => { + suppressClick = true; + const dayDelta = target ? Math.round((target.date - originDay) / 86400000) : 0; + if (dayDelta !== 0 && pill.isConnected) dispatchEventDrop(ev, dayDelta, 0); + }, + }); + } + function setConfig(config) { locale = resolveLocale(config.locale); if (config.firstDay !== undefined) firstDay = config.firstDay; if (config.showNowIndicator !== undefined) showNowIndicator = config.showNowIndicator; if (config.scrollTo !== undefined && ['now', 'first-event'].includes(config.scrollTo)) scrollTo = config.scrollTo; + if (config.draggable !== undefined) draggable = !!config.draggable; + if (config.dragStep > 0) dragStep = +config.dragStep; if (config.views !== undefined) { showViewSwitcher = config.views; viewSwitcher.classList.toggle('hidden', !showViewSwitcher); @@ -450,6 +601,18 @@ export default function (Alpine) { const dayLabelFmt = dtf(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); const dayCells = []; + // Map a pointer position to a day cell: the 6 rows and 7 columns divide + // the grid evenly, so one rect suffices. Out-of-grid pointers clamp to + // the nearest cell. + const resolveMonthCell = (x, y) => { + const rect = grid.getBoundingClientRect(); + if (!(rect.width > 0) || !(rect.height > 0)) return null; + const c = Math.min(Math.max(Math.floor(((x - rect.left) / rect.width) * 7), 0), 6); + const r = Math.min(Math.max(Math.floor(((y - rect.top) / rect.height) * 6), 0), 5); + const cell = dayCells[r * 7 + c]; + return cell ? { el: cell, date: cell._date } : null; + }; + for (let r = 0; r < 6; r++) { const rowEl = document.createElement('div'); rowEl.classList.add('grid', 'grid-cols-7', 'flex-1', 'min-h-0'); @@ -497,6 +660,7 @@ export default function (Alpine) { dayEvs.forEach((ev) => { const pill = makeEventPill(ev); pill.classList.add('event-pill'); + if (canDrag(ev)) attachEventDayDrag(pill, ev, capturedDay, resolveMonthCell); cell.appendChild(pill); }); cell._trimFn = () => trimMonthCell(cell, capturedDay, dayEvs); @@ -587,10 +751,25 @@ export default function (Alpine) { const allDayGrid = document.createElement('div'); allDayGrid.classList.add('grid', 'flex-1'); allDayGrid.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; + const adCells = []; + const resolveAllDayCell = (x) => { + const rect = allDayGrid.getBoundingClientRect(); + if (!(rect.width > 0)) return null; + const c = Math.min(Math.max(Math.floor(((x - rect.left) / rect.width) * cols), 0), cols - 1); + return { el: adCells[c], date: days[c] }; + }; days.forEach((day) => { const adCell = document.createElement('div'); adCell.classList.add('border-r', 'last:border-r-0', 'p-0.5', 'space-y-0.5', 'min-h-[28px]'); - events.filter((ev) => ev.allDay && eventSpansDay(ev, day)).forEach((ev) => adCell.appendChild(makeEventPill(ev))); + events + .filter((ev) => ev.allDay && eventSpansDay(ev, day)) + .forEach((ev) => { + const pill = makeEventPill(ev); + // A day-only drag is meaningful only when there is another day column. + if (cols > 1 && canDrag(ev)) attachEventDayDrag(pill, ev, day, resolveAllDayCell); + adCell.appendChild(pill); + }); + adCells.push(adCell); allDayGrid.appendChild(adCell); }); allDayRow.appendChild(allDayGrid); @@ -626,7 +805,7 @@ export default function (Alpine) { colsGrid.style.height = `${HOURS * HOUR_H}px`; const colDayFmt = dtf(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); - days.forEach((day) => { + days.forEach((day, dayIdx) => { const col = document.createElement('div'); col.classList.add('border-r', 'last:border-r-0', 'relative'); col.style.height = `${HOURS * HOUR_H}px`; @@ -704,8 +883,9 @@ export default function (Alpine) { titleEl.textContent = ev.title; evEl.appendChild(titleEl); + let timeEl = null; if (durMins >= 45) { - const timeEl = document.createElement('div'); + timeEl = document.createElement('div'); timeEl.classList.add('opacity-80', 'leading-tight'); timeEl.textContent = dtf(locale, { hour: 'numeric', minute: '2-digit' }).format(ev.startDate); evEl.appendChild(timeEl); @@ -713,8 +893,18 @@ export default function (Alpine) { evEl.addEventListener('click', (e) => { e.stopPropagation(); + if (suppressClick) { + suppressClick = false; + return; + } el.dispatchEvent(new CustomEvent('event-click', { detail: { event: ev }, bubbles: true })); }); + if (canDrag(ev)) { + // Segments continuing from an earlier day render clamped to the top, + // so a vertical move would disagree with the applied result. Lock + // them to day changes. + attachTimedDrag(evEl, ev, { colsGrid, scrollArea, days, dayIdx, startMins, durMins, lockVertical: ev.startDate < startOfDay, timeEl }); + } col.appendChild(evEl); }); diff --git a/src/components/slot-picker.js b/src/components/slot-picker.js index 7b577d5..3c06911 100644 --- a/src/components/slot-picker.js +++ b/src/components/slot-picker.js @@ -1,5 +1,6 @@ import { findAncestorState } from '../common/ancestor'; import { createCalendarWidget, forwardCalendarNavAria, isToday, toDateString } from '../common/calendar'; +import { capturePointer, DRAG_THRESHOLD, releasePointer } from '../common/drag'; import { colorClasses, EVENT_COLORS, ringClass } from '../common/event-colors'; import { createDateTimeFormatCache } from '../common/intl'; import { eventInsidePicker, setupPopover } from '../common/picker-popover'; @@ -12,7 +13,7 @@ export default function (Alpine) { Alpine.directive('h-slot-picker', (el, { expression, modifiers }, { effect, evaluateLater, cleanup, Alpine }) => { el.classList.add('relative', 'flex', 'flex-col', 'bg-background', 'text-foreground'); el.setAttribute('data-slot', 'slot-picker'); - // Expose the picker as a labeled group; respect an author-provided aria-label. + // Expose the picker as a labeled group. Respect an author-provided aria-label. el.setAttribute('role', 'group'); if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Time slot picker'); @@ -36,6 +37,11 @@ export default function (Alpine) { let minDate = null; let maxDate = null; let showNowIndicator = false; + let draggable = false; + // Set when a drag just completed so the click that follows it does not select. + let suppressClick = false; + // Ends any in-flight drag gesture (its move/up listeners live on window). + let abortDrag = null; // The picker renders no toolbar of its own. Consumers compose one from an // x-h-toolbar wrapping the x-h-slot-picker-* control directives, which reach @@ -103,7 +109,7 @@ export default function (Alpine) { if (currentDate > maxStart) currentDate = maxStart; } // A range narrower than the visible window can push the start below the start - // day; anchor at minDate and let render disable the overflowing days. + // day. Anchor at minDate and let render disable the overflowing days. if (minDate && currentDate < minDate) currentDate = new Date(minDate); } @@ -133,7 +139,7 @@ export default function (Alpine) { function getSlotsForDay(dateStr) { if (explicitSlots) { const daySlots = explicitSlots.filter((s) => s.date === dateStr); - // Explicit slots override a day; days without any fall back to the + // Explicit slots override a day. Days without any fall back to the // generated start/end/step schedule only when `fillEmptyDays` is set. if (daySlots.length || !fillEmptyDays) return daySlots; } @@ -178,7 +184,7 @@ export default function (Alpine) { // A colored slot is "filled" (solid background) for any status other than the // two outline statuses - the same split colorClasses() uses. Only filled - // colored slots get the selected border; outlined ones (unconfirmed/rejected) + // colored slots get the selected border while outlined ones (unconfirmed/rejected) // keep the ring alone. function isFilledStatus(status) { return status !== 'unconfirmed' && status !== 'rejected'; @@ -206,7 +212,7 @@ export default function (Alpine) { // Filled colored cells also gain a contrasting border on selection // (like the step indicator's active marker: ring = fill, border = text). // The transparent border is already on the cell (buildCell) so recoloring - // it causes no layout shift; outlined statuses keep their own border. + // it causes no layout shift. Outlined statuses keep their own border. if (isFilledStatus(cell.getAttribute('data-status'))) { cell.classList.toggle('border-transparent', !isSelected); cell.classList.toggle('border-background', isSelected); @@ -269,8 +275,8 @@ export default function (Alpine) { } // Build a selectable cell shared by top-level slots and sub-slot tiles. `item` - // supplies the look (color, description, note, icons, availability); `payload` - // is the data dispatched on `slot-click`. + // supplies the look (color, description, note, icons, availability). + // `payload` is the data dispatched on `slot-click`. function buildCell({ key, ariaLabel, visibleTime, item, dataSlot, isTile, payload }) { const available = item.available !== false; const color = resolveColor(item.color); @@ -288,7 +294,7 @@ export default function (Alpine) { cell.setAttribute('data-colored', 'true'); cell.setAttribute('data-color', color); cell.setAttribute('data-status', item.status || ''); - // A filled colored cell has no border of its own; carry a transparent one + // A filled colored cell has no border of its own. Carry a transparent one // so selection can recolor it (to border-background) without a layout // shift. Outlined statuses already have their own border from colorClasses. if (isFilledStatus(item.status)) cell.classList.add('border', 'border-transparent'); @@ -358,14 +364,211 @@ export default function (Alpine) { if (rightBadge) cell.appendChild(rightBadge); if (available) { - cell.addEventListener('click', () => selectSlot(key, payload)); + cell.addEventListener('click', () => { + if (suppressClick) { + suppressClick = false; + return; + } + selectSlot(key, payload); + }); } return cell; } + // === Drag and drop === + // Sortable rescheduling: while a slot is dragged, a half-opacity ghost clone + // follows the pointer and the slot itself (dimmed) relocates live through + // the day lists as the placeholder, so the surrounding slots part exactly + // where the drop would land. The picker's own data never changes: a + // completed drag dispatches `slot-drop` with the proposed day, position, + // and a ready-to-assign `slots` array, and the consumer applies it (or + // ignores the event to reject the move). + + const SLOT_SELECTOR = '[data-slot="slot-picker-cell"], [data-slot="slot-picker-slot"]'; + + // Dragging needs an array to reorder, so only slots taken directly from the + // consumer's `slots` config qualify (generated slots are fresh objects each + // render and never match). + function canDrag(item) { + return draggable && item.available !== false && item.draggable !== false && !!explicitSlots && explicitSlots.includes(item); + } + + // The slot nodes of a day list in display order: cells and tile groups only, + // skipping the now indicator (and the dragged node when `except` is given). + function slotChildren(list, except) { + return Array.from(list.children).filter((c) => c !== except && c.matches(SLOT_SELECTOR)); + } + + // The proposed new slots array for a drop: the consumer's array with the + // dragged raw slot removed, shallow-copied onto the target date, and spliced + // in before the target day's index-th slot (after the day's last slot when + // index runs past it, at the array end when the day has no explicit slots). + // Never mutates the consumer's array or objects. + function buildProposedSlots(raw, date, index) { + const next = explicitSlots.filter((s) => s !== raw); + const dayPositions = []; + next.forEach((s, i) => { + if (s.date === date) dayPositions.push(i); + }); + const at = !dayPositions.length ? next.length : index >= dayPositions.length ? dayPositions[dayPositions.length - 1] + 1 : dayPositions[index]; + next.splice(at, 0, { ...raw, date }); + return next; + } + + // The slot/tile payload dispatched on slot-click and slot-drop. + function slotPayload(dateStr, slot) { + return { + date: dateStr, + start: slot.start, + end: slot.end, + available: slot.available !== false, + description: slot.description ?? null, + note: slot.note ?? null, + color: slot.color ?? null, + status: slot.status ?? null, + tileIndex: null, + }; + } + + // Enabled day columns of the current render, hit-tested by rect so day + // targeting works for both the side-by-side and the stacked responsive + // layout. Disabled and out-of-range days are never listed, so they can + // never become drop targets. + let dayCols = []; + + function resolveDayColumn(x, y) { + // Clamp the pointer into the day grid so a drag just past an edge (for + // example below a short column) still targets the nearest day, like the + // calendar's cell resolution. Skipped without layout (test environments). + const grid = dayGrid.getBoundingClientRect(); + if (grid.width > 0 && grid.height > 0) { + x = Math.min(Math.max(x, grid.left), grid.right - 1); + y = Math.min(Math.max(y, grid.top), grid.bottom - 1); + } + for (const c of dayCols) { + const r = c.el.getBoundingClientRect(); + if (r.width > 0 && r.height > 0 && x >= r.left && x < r.right && y >= r.top && y < r.bottom) return c; + } + return null; + } + + function attachSlotDrag(node, { raw, payload, key, suppress = false, canStart }) { + node.addEventListener('pointerdown', (e) => { + if (e.button > 0) return; + if (canStart && !canStart(e)) return; + suppressClick = false; + const startX = e.clientX; + const startY = e.clientY; + const srcRect = node.getBoundingClientRect(); + const homeList = node.parentNode; + // The restore anchor must be a slot, not the now indicator, which can + // move on its own while the drag is in flight. + let homeNext = node.nextElementSibling; + while (homeNext && !homeNext.matches(SLOT_SELECTOR)) homeNext = homeNext.nextElementSibling; + const origIdx = slotChildren(homeList).indexOf(node); + let dragging = false; + let ghost = null; + let toRem = null; + let pending = null; + + const place = (list, ref) => { + if (node.parentNode === list && node.nextSibling === ref) return; + list.insertBefore(node, ref); + }; + const restoreHome = () => place(homeList, homeNext && homeNext.parentNode === homeList ? homeNext : null); + + const move = (me) => { + if (!node.isConnected) return finish(false); + if (!dragging) { + if (Math.abs(me.clientX - startX) < DRAG_THRESHOLD && Math.abs(me.clientY - startY) < DRAG_THRESHOLD) return; + dragging = true; + const base = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + toRem = (px) => `${px / base}rem`; + // Clone before dimming the source so the ghost keeps the slot's + // resting look. + ghost = node.cloneNode(true); + ghost.setAttribute('data-slot', 'slot-picker-ghost'); + ghost.setAttribute('aria-hidden', 'true'); + ghost.setAttribute('inert', ''); + // Cells carry `relative`, which would win over `absolute` and leave + // the ghost in the flow. + ghost.classList.remove('relative'); + ghost.classList.add('absolute', 'opacity-50', 'pointer-events-none', 'z-50', 'shadow-lg'); + ghost.style.width = toRem(srcRect.width); + ghost.style.height = toRem(srcRect.height); + el.appendChild(ghost); + node.setAttribute('data-dragging', 'true'); + node.classList.add('opacity-50'); + } + // Nudge the scroll body when the pointer nears its edges, before any + // rect reads so the same move lands consistently. + const sRect = scrollBody.getBoundingClientRect(); + if (sRect.height > 0) { + if (me.clientY < sRect.top + 40) scrollBody.scrollTop -= 15; + else if (me.clientY > sRect.bottom - 40) scrollBody.scrollTop += 15; + } + const elRect = el.getBoundingClientRect(); + ghost.style.left = toRem(me.clientX - elRect.left - (startX - srcRect.left)); + ghost.style.top = toRem(me.clientY - elRect.top - (startY - srcRect.top)); + const target = resolveDayColumn(me.clientX, me.clientY); + if (!target) { + restoreHome(); + pending = null; + return; + } + const kids = slotChildren(target.list, node); + const ref = + kids.find((k) => { + const r = k.getBoundingClientRect(); + return r.top + r.height / 2 > me.clientY; + }) ?? null; + place(target.list, ref); + pending = { date: target.date, index: ref ? kids.indexOf(ref) : kids.length }; + }; + + const finish = (commit) => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + window.removeEventListener('pointercancel', cancel); + abortDrag = null; + releasePointer(node, e); + if (!dragging) return; + ghost.remove(); + restoreHome(); + node.removeAttribute('data-dragging'); + node.classList.remove('opacity-50'); + // The drag may have parked the node past the now indicator. Re-seat it. + if (nowIndicatorEl && todaySlotList) positionNowIndicator(); + if (!commit) return; + if (suppress) suppressClick = true; + if (!pending || !node.isConnected) return; + // Reinserting at the original position leaves the order unchanged. + if (pending.date === payload.date && pending.index === origIdx) return; + if (!explicitSlots || !explicitSlots.includes(raw)) return; + el.dispatchEvent( + new CustomEvent('slot-drop', { + bubbles: true, + detail: { slot: { ...payload, key }, date: pending.date, index: pending.index, slots: buildProposedSlots(raw, pending.date, pending.index) }, + }) + ); + }; + const up = () => finish(true); + const cancel = () => finish(false); + + // Relocating the node clears pointer capture (removal from the tree), + // so the gesture listeners must live on window. Capture stays on as a + // best effort against text selection and stray hovers. + capturePointer(node, e); + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + window.addEventListener('pointercancel', cancel); + abortDrag = () => finish(false); + }); + } + // Build a group container for a slot that holds sub-slot tiles. The slot's own - // time labels the group; each tile is an individually selectable cell. + // time labels the group. Each tile is an individually selectable cell. function buildGroup({ dateStr, dayLabel, slot }) { const groupTime = slot.end ? `${slot.start} to ${slot.end}` : slot.start; @@ -457,7 +660,9 @@ export default function (Alpine) { function positionNowIndicator() { const now = new Date(); const nowMins = now.getHours() * 60 + now.getMinutes(); - const next = todayEntries.find((e) => e.startMins > nowMins); + // A dragged slot can be parked in another column mid-drag, so anchor only + // on entries still in today's list. + const next = todayEntries.find((e) => e.startMins > nowMins && e.el.parentNode === todaySlotList); todaySlotList.insertBefore(nowIndicatorEl, next ? next.el : null); return next ? next.startMins : 24 * 60; } @@ -472,7 +677,7 @@ export default function (Alpine) { } function nowTick() { - // Crossing midnight changes which column is today, so re-render; otherwise + // Crossing midnight changes which column is today, so re-render. Otherwise // move only the indicator element, which never disturbs keyboard focus. if (!todaySlotList || toDateString(new Date()) !== renderedTodayStr) render(); else scheduleNowTick(positionNowIndicator()); @@ -485,7 +690,7 @@ export default function (Alpine) { function render() { const days = Array.from({ length: dayCount }, (_, i) => addDays(currentDate, i)); - // Reset the now-indicator bookkeeping; today's column repopulates it below. + // Reset the now-indicator bookkeeping. Today's column repopulates it below. const now = new Date(); clearTimeout(nowTimer); nowIndicatorEl = null; @@ -493,7 +698,7 @@ export default function (Alpine) { todayEntries = []; renderedTodayStr = toDateString(now); - // Heading: exposed as reactive state; an x-h-slot-picker-title control renders it. + // Heading: exposed as reactive state. `x-h-slot-picker-title` control renders it. const shortFmt = dtf(locale, { day: 'numeric', month: 'short' }); const longFmt = dtf(locale, { day: 'numeric', month: 'short', year: 'numeric' }); state.title = days.length === 1 ? longFmt.format(days[0]) : `${shortFmt.format(days[0])} - ${longFmt.format(days[days.length - 1])}`; @@ -511,6 +716,7 @@ export default function (Alpine) { dayGrid.innerHTML = ''; cellByKey.clear(); + dayCols = []; const dayNameFmt = dtf(locale, { weekday: 'long' }); const dateFmt = dtf(locale, { day: 'numeric', month: 'long' }); @@ -555,10 +761,10 @@ export default function (Alpine) { dayGrid.appendChild(col); return; } - // Slot list: a chronological vertical stack per day. const slotList = document.createElement('div'); slotList.classList.add('flex', 'flex-col', 'gap-1', 'p-2'); + dayCols.push({ el: col, date: dateStr, list: slotList }); const slots = getSlotsForDay(dateStr); const trackNow = showNowIndicator && today; @@ -567,10 +773,22 @@ export default function (Alpine) { let node; if (Array.isArray(slot.tiles) && slot.tiles.length) { node = buildGroup({ dateStr, dayLabel, slot }); + if (canDrag(slot)) { + // The group drags as a whole. A press that starts on a tile stays + // a tile interaction, and no click suppression is needed because + // the container has no click handler of its own. + attachSlotDrag(node, { + raw: slot, + payload: slotPayload(dateStr, slot), + key: slotKey(dateStr, slot.start), + canStart: (e) => !e.target.closest('[data-slot="slot-picker-tile"]'), + }); + } } else { const key = slotKey(dateStr, slot.start); const timeLabel = slot.end ? `${slot.start} to ${slot.end}` : slot.start; const descPart = slot.description ? `, ${slot.description}` : ''; + const payload = slotPayload(dateStr, slot); node = buildCell({ key, ariaLabel: `${dayLabel}, ${timeLabel}${descPart}`, @@ -578,21 +796,12 @@ export default function (Alpine) { item: slot, dataSlot: 'slot-picker-cell', isTile: false, - payload: { - date: dateStr, - start: slot.start, - end: slot.end, - available: slot.available !== false, - description: slot.description ?? null, - note: slot.note ?? null, - color: slot.color ?? null, - status: slot.status ?? null, - tileIndex: null, - }, + payload, }); + if (canDrag(slot)) attachSlotDrag(node, { raw: slot, payload, key, suppress: true }); } slotList.appendChild(node); - // The now indicator is positioned against these start times; a start-less + // The now indicator is positioned against these start times. A start-less // slot counts as already started (timeToMins would throw on it). if (trackNow) todayEntries.push({ el: node, startMins: slot.start ? timeToMins(slot.start) : -1 }); }); @@ -744,6 +953,7 @@ export default function (Alpine) { if (config.fillEmptyDays !== undefined) fillEmptyDays = !!config.fillEmptyDays; if (config.multiple !== undefined) multiple = !!config.multiple; if (config.showNowIndicator !== undefined) showNowIndicator = !!config.showNowIndicator; + if (config.draggable !== undefined) draggable = !!config.draggable; if (config.locale !== undefined) { locale = resolveLocale(config.locale); if (calWidget) calWidget.setConfig({ locale }); @@ -788,6 +998,7 @@ export default function (Alpine) { }); cleanup(() => { + abortDrag?.(); clearTimeout(nowTimer); if (calPopover) { calWidget.cleanup(); @@ -839,7 +1050,7 @@ export default function (Alpine) { if (!host) throw new Error(`${original} must be inside a slot picker`); const api = host._h_slot_picker; // `.text-only` suppresses all built-in styling so the consumer can style the - // title (or its wrapper) themselves; text, data-slot, and aria-live remain. + // title (or its wrapper) themselves. The text, data-slot, and aria-live remain. if (!modifiers.includes('text-only')) el.classList.add('flex-1', 'text-sm', 'font-semibold', 'text-center', 'leading-tight', 'line-clamp-3'); if (!el.hasAttribute('aria-live')) el.setAttribute('aria-live', 'polite'); el.setAttribute('data-slot', 'slot-picker-title'); diff --git a/src/styles/harmonia.css b/src/styles/harmonia.css index bb51b11..2432210 100644 --- a/src/styles/harmonia.css +++ b/src/styles/harmonia.css @@ -202,7 +202,7 @@ @source inline("animate-{pulse,ping,spin}"); @source inline("motion-reduce:transition-none"); @source inline("ease-{out,linear}"); -@source inline("z-{1,10,50,60}"); +@source inline("z-{1,10,20,50,60}"); @source inline("fill-none"); /* Ring width for the slot-picker's color-matched selection ring (the ring diff --git a/tests/components/calendar.test.js b/tests/components/calendar.test.js index 5236eb6..cb6dce1 100644 --- a/tests/components/calendar.test.js +++ b/tests/components/calendar.test.js @@ -272,6 +272,335 @@ describe('h-calendar', () => { }); }); + describe('drag and drop', () => { + const pointer = (target, type, coords = {}) => target.dispatchEvent(new MouseEvent(type, { bubbles: true, ...coords })); + + function stubRect(node, rect) { + node.getBoundingClientRect = () => ({ left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, ...rect }); + } + + // The initial-scroll rAF is stubbed to run synchronously so scrollTop is + // stable before a drag starts. + function mountDrag(config) { + const raf = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => cb()); + try { + mount('calConfig', { evaluateLater: () => (cb) => cb(config) }); + } finally { + raf.mockRestore(); + } + } + + function timedEl(title) { + return Array.from(el.querySelectorAll('.absolute')).find((p) => p.querySelector('.font-medium')?.textContent.trim() === title); + } + + function scrollArea() { + return el.querySelector('.overflow-y-auto.flex-1'); + } + + const standup = { id: 'e1', title: 'Standup', start: '2026-06-18T09:00:00', end: '2026-06-18T10:00:00', color: 'blue' }; + + it('is inert when draggable is not enabled', () => { + mountDrag({ view: 'day', date: '2026-06-18', events: [standup] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 400 }); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 400 }); + expect(evEl.hasAttribute('data-dragging')).toBe(false); + expect(evEl.style.top).toBe('540px'); + expect(drops).not.toHaveBeenCalled(); + }); + + it('keeps sub-threshold moves as plain clicks', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [standup] }); + const drops = vi.fn(); + const clicks = vi.fn(); + el.addEventListener('event-drop', drops); + el.addEventListener('event-click', clicks); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 51, clientY: 302 }); + pointer(evEl, 'pointerup', { clientX: 51, clientY: 302 }); + expect(evEl.hasAttribute('data-dragging')).toBe(false); + expect(drops).not.toHaveBeenCalled(); + evEl.click(); + expect(clicks).toHaveBeenCalledOnce(); + }); + + it('moves a day-view event vertically in 15-minute steps and dispatches event-drop', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [standup] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 365 }); + // 65px rounds to the 60-minute step and the preview follows. + expect(evEl.getAttribute('data-dragging')).toBe('true'); + expect(evEl.classList.contains('z-20')).toBe(true); + expect(evEl.style.top).toBe('600px'); + expect(evEl.style.left).toBe('0.125rem'); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 365 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.event.id).toBe('e1'); + expect(detail.start).toBe('2026-06-18T10:00'); + expect(detail.end).toBe('2026-06-18T11:00'); + // Snap-back: the drop never mutates the calendar's own rendering. + expect(evEl.hasAttribute('data-dragging')).toBe(false); + expect(evEl.classList.contains('z-20')).toBe(false); + expect(evEl.style.top).toBe('540px'); + }); + + it('snaps vertical moves to a configured dragStep', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, dragStep: 60, events: [standup] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 350 }); + // 50px rounds to one 60-minute step (the default 15 would give 45 minutes). + expect(evEl.style.top).toBe('600px'); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 350 }); + expect(drops.mock.calls[0][0].detail.start).toBe('2026-06-18T10:00'); + }); + + it('ignores a non-positive dragStep and keeps the 15-minute default', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, dragStep: 0, events: [standup] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 350 }); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 350 }); + expect(drops.mock.calls[0][0].detail.start).toBe('2026-06-18T09:45'); + }); + + it('suppresses the trailing click after a completed drag', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [standup] }); + const clicks = vi.fn(); + el.addEventListener('event-click', clicks); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 365 }); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 365 }); + evEl.click(); + expect(clicks).not.toHaveBeenCalled(); + evEl.click(); + expect(clicks).toHaveBeenCalledOnce(); + }); + + it('moves an event across week columns and keeps its time', () => { + mountDrag({ view: 'week', date: '2026-06-18', draggable: true, events: [standup] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Standup'); + const colsGrid = scrollArea().lastElementChild; + stubRect(colsGrid, { left: 0, width: 700 }); + // Thu Jun 18 is column 4 of the Sun-Sat week; x 650 is column 6 (Sat). + pointer(evEl, 'pointerdown', { clientX: 450, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 650, clientY: 300 }); + expect(evEl.style.transform).toBe('translateX(200px)'); + pointer(evEl, 'pointerup', { clientX: 650, clientY: 300 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.start).toBe('2026-06-20T09:00'); + expect(detail.end).toBe('2026-06-20T10:00'); + expect(evEl.style.transform).toBe(''); + }); + + it('does not dispatch when the event is dropped where it started', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [standup] }); + const drops = vi.fn(); + const clicks = vi.fn(); + el.addEventListener('event-drop', drops); + el.addEventListener('event-click', clicks); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 305 }); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 305 }); + expect(drops).not.toHaveBeenCalled(); + expect(evEl.style.top).toBe('540px'); + // The gesture was still a drag, not a click. + evEl.click(); + expect(clicks).not.toHaveBeenCalled(); + }); + + it('clamps vertical moves to the day', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [standup] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: -400 }); + expect(evEl.style.top).toBe('0px'); + pointer(evEl, 'pointerup', { clientX: 50, clientY: -400 }); + expect(drops.mock.calls[0][0].detail.start).toBe('2026-06-18T00:00'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 2300 }); + expect(evEl.style.top).toBe('1380px'); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 2300 }); + expect(drops.mock.calls[1][0].detail.start).toBe('2026-06-18T23:00'); + expect(drops.mock.calls[1][0].detail.end).toBe('2026-06-19T00:00'); + }); + + it('never forces an event upward when its 30-minute visual floor passes midnight', () => { + const late = { id: 'e9', title: 'Late', start: '2026-06-18T23:55:00', end: '2026-06-18T23:59:00' }; + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [late] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Late'); + expect(evEl.style.top).toBe('1435px'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 1437 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 1537 }); + expect(evEl.style.top).toBe('1435px'); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 1537 }); + expect(drops).not.toHaveBeenCalled(); + }); + + it('locks segments continuing from an earlier day to day changes', () => { + const overnight = { id: 'e2', title: 'Overnight', start: '2026-06-17T22:00:00', end: '2026-06-18T02:00:00' }; + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [overnight] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Overnight'); + expect(evEl.style.top).toBe('0px'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 180 }); + expect(evEl.getAttribute('data-dragging')).toBe('true'); + expect(evEl.style.top).toBe('0px'); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 180 }); + expect(drops).not.toHaveBeenCalled(); + }); + + it('moves a month-view event to the hovered day and keeps its time', () => { + mountDrag({ view: 'month', date: '2026-06-18', draggable: true, events: [standup] }); + const drops = vi.fn(); + const clicks = vi.fn(); + el.addEventListener('event-drop', drops); + el.addEventListener('event-click', clicks); + const grid = el.querySelector('[role="grid"]'); + stubRect(grid, { left: 0, top: 0, width: 700, height: 600 }); + const cells = el.querySelectorAll('[role="gridcell"]'); + const pill = el.querySelector('.event-pill'); + pointer(pill, 'pointerdown', { clientX: 450, clientY: 250 }); + // Hover Jun 30 (row 4, col 2), then Jun 29 (row 4, col 1). + pointer(pill, 'pointermove', { clientX: 250, clientY: 450 }); + expect(pill.getAttribute('data-dragging')).toBe('true'); + expect(pill.classList.contains('opacity-50')).toBe(true); + expect(cells[30].getAttribute('data-drop-target')).toBe('true'); + expect(cells[30].classList.contains('bg-muted/50')).toBe(true); + pointer(pill, 'pointermove', { clientX: 150, clientY: 450 }); + expect(cells[30].hasAttribute('data-drop-target')).toBe(false); + expect(cells[29].getAttribute('data-drop-target')).toBe('true'); + pointer(pill, 'pointerup', { clientX: 150, clientY: 450 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.start).toBe('2026-06-29T09:00'); + expect(detail.end).toBe('2026-06-29T10:00'); + expect(cells[29].hasAttribute('data-drop-target')).toBe(false); + expect(pill.classList.contains('opacity-50')).toBe(false); + pill.click(); + expect(clicks).not.toHaveBeenCalled(); + }); + + it('keeps date-only events date-only on a month drag', () => { + const holiday = { id: 'e3', title: 'Holiday', start: '2026-06-18', allDay: true }; + mountDrag({ view: 'month', date: '2026-06-18', draggable: true, events: [holiday] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + stubRect(el.querySelector('[role="grid"]'), { left: 0, top: 0, width: 700, height: 600 }); + const pill = el.querySelector('.event-pill'); + pointer(pill, 'pointerdown', { clientX: 450, clientY: 250 }); + pointer(pill, 'pointermove', { clientX: 550, clientY: 250 }); + pointer(pill, 'pointerup', { clientX: 550, clientY: 250 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.start).toBe('2026-06-19'); + expect(detail.end).toBeUndefined(); + }); + + it('moves all-day events across days in the week strip', () => { + const holiday = { id: 'e4', title: 'Holiday', start: '2026-06-18', allDay: true }; + mountDrag({ view: 'week', date: '2026-06-18', draggable: true, events: [holiday] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const allDayGrid = el.querySelector('.max-h-18').lastElementChild; + stubRect(allDayGrid, { left: 0, width: 700 }); + const pill = allDayGrid.querySelector('button'); + pointer(pill, 'pointerdown', { clientX: 450, clientY: 10 }); + pointer(pill, 'pointermove', { clientX: 550, clientY: 10 }); + expect(allDayGrid.children[5].getAttribute('data-drop-target')).toBe('true'); + pointer(pill, 'pointerup', { clientX: 550, clientY: 10 }); + expect(drops).toHaveBeenCalledOnce(); + expect(drops.mock.calls[0][0].detail.start).toBe('2026-06-19'); + }); + + it('respects a per-event draggable: false', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [{ ...standup, draggable: false }] }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 400 }); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 400 }); + expect(evEl.hasAttribute('data-dragging')).toBe(false); + expect(drops).not.toHaveBeenCalled(); + }); + + it('aborts on pointercancel without dispatching or suppressing clicks', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [standup] }); + const drops = vi.fn(); + const clicks = vi.fn(); + el.addEventListener('event-drop', drops); + el.addEventListener('event-click', clicks); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 300 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 365 }); + expect(evEl.style.top).toBe('600px'); + pointer(evEl, 'pointercancel', {}); + expect(evEl.style.top).toBe('540px'); + expect(drops).not.toHaveBeenCalled(); + evEl.click(); + expect(clicks).toHaveBeenCalledOnce(); + }); + + it('does not attach dragging to overflow-popover pills', () => { + const events = Array.from({ length: 5 }, (_, i) => ({ + id: String(i), + title: `Event ${i}`, + start: '2026-06-18T10:00:00', + end: '2026-06-18T11:00:00', + })); + mountDrag({ view: 'month', date: '2026-06-18', draggable: true, events }); + const drops = vi.fn(); + el.addEventListener('event-drop', drops); + el.querySelector('[data-slot="overflow-more-btn"]').click(); + const popover = Array.from(document.querySelectorAll('[role="dialog"]')).find((d) => !d.classList.contains('hidden')); + const pill = popover.querySelector('button'); + pointer(pill, 'pointerdown', { clientX: 100, clientY: 100 }); + pointer(pill, 'pointermove', { clientX: 300, clientY: 300 }); + pointer(pill, 'pointerup', { clientX: 300, clientY: 300 }); + expect(pill.hasAttribute('data-dragging')).toBe(false); + expect(drops).not.toHaveBeenCalled(); + }); + + it('nudges the scroll area when dragging near its edge', () => { + mountDrag({ view: 'day', date: '2026-06-18', draggable: true, events: [standup] }); + const area = scrollArea(); + const startScroll = area.scrollTop; + stubRect(area, { top: 0, bottom: 200, height: 200 }); + const evEl = timedEl('Standup'); + pointer(evEl, 'pointerdown', { clientX: 50, clientY: 100 }); + pointer(evEl, 'pointermove', { clientX: 50, clientY: 190 }); + expect(area.scrollTop).toBe(startScroll + 15); + // The scrolled distance feeds the vertical delta: 90px pointer + 15px scroll. + expect(evEl.style.top).toBe('645px'); + pointer(evEl, 'pointerup', { clientX: 50, clientY: 190 }); + }); + }); + describe('accessibility', () => { it('exposes the calendar as a labeled group', () => { mount('calConfig', { evaluateLater: () => (cb) => cb({ view: 'month', date: '2026-06-18' }) }); diff --git a/tests/components/slot-picker.test.js b/tests/components/slot-picker.test.js index 710f01b..a7d1323 100644 --- a/tests/components/slot-picker.test.js +++ b/tests/components/slot-picker.test.js @@ -1013,6 +1013,495 @@ describe('h-slot-picker', () => { }); }); + describe('drag and drop', () => { + const pointer = (target, type, coords = {}) => target.dispatchEvent(new MouseEvent(type, { bubbles: true, ...coords })); + + function stubRect(node, rect) { + node.getBoundingClientRect = () => ({ left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, ...rect }); + } + + function dayColumns() { + return Array.from(el.querySelector('.overflow-auto').firstElementChild.children); + } + + // Three 100px-wide side-by-side day columns: 22, 23, 24 June 2026. + function stubColumns() { + const cols = dayColumns(); + cols.forEach((col, i) => stubRect(col, { left: i * 100, right: (i + 1) * 100, top: 0, bottom: 500, width: 100, height: 500 })); + return cols; + } + + function slotList(colIdx) { + return dayColumns()[colIdx].querySelector('[data-slot="slot-picker-header"]').nextElementSibling; + } + + function slotNodes(colIdx) { + return Array.from(slotList(colIdx).children).filter((n) => n.matches('[data-slot="slot-picker-cell"], [data-slot="slot-picker-slot"]')); + } + + // Stub each slot node of a column as a 40px-spaced band (midpoints at 68, + // 108, 148, ...). Stubs are own-property functions on the original nodes: + // they do not move when a node is relocated and do not survive cloning, so + // all midpoints stay static for a whole drag and expected indices derive + // from the original geometry. + function stubSlots(colIdx) { + slotNodes(colIdx).forEach((n, j) => stubRect(n, { left: colIdx * 100, right: colIdx * 100 + 100, top: 50 + j * 40, bottom: 86 + j * 40, width: 100, height: 36 })); + } + + function ghost() { + return el.querySelector('[data-slot="slot-picker-ghost"]'); + } + + const baseSlots = () => [ + { date: FIXED_DATE, start: '09:00', end: '09:30' }, + { date: FIXED_DATE, start: '10:00', end: '10:30' }, + ]; + + function firstCell() { + return el.querySelector('button[data-slot="slot-picker-cell"]'); + } + + it('is inert when draggable is not enabled', () => { + mount('config', withConfig({ date: FIXED_DATE, slots: baseSlots() })); + stubColumns(); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 50 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 50 }); + pointer(cell, 'pointerup', { clientX: 150, clientY: 50 }); + expect(cell.hasAttribute('data-dragging')).toBe(false); + expect(ghost()).toBeNull(); + expect(drops).not.toHaveBeenCalled(); + }); + + it('moves a slot to another day with a ghost, a live placeholder, and index and slots in the detail', () => { + const slots = baseSlots(); + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots })); + stubColumns(); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 250, clientY: 60 }); + // The ghost follows the pointer while the dimmed original is parked in + // the hovered day's list. + const g = ghost(); + expect(g).toBeTruthy(); + expect(g.parentNode).toBe(el); + expect(g.getAttribute('aria-hidden')).toBe('true'); + ['absolute', 'opacity-50', 'pointer-events-none', 'z-50', 'shadow-lg'].forEach((c) => expect(g.classList.contains(c)).toBe(true)); + expect(cell.getAttribute('data-dragging')).toBe('true'); + expect(cell.classList.contains('opacity-50')).toBe(true); + expect(cell.parentNode).toBe(slotList(2)); + pointer(cell, 'pointerup', { clientX: 250, clientY: 60 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.slot).toMatchObject({ date: FIXED_DATE, start: '09:00', end: '09:30', tileIndex: null }); + expect(detail.slot.key).toBe('2026-06-22T09:00'); + expect(detail.date).toBe('2026-06-24'); + expect(detail.index).toBe(0); + expect(detail.slots).toEqual([slots[1], { ...slots[0], date: '2026-06-24' }]); + expect(detail.slots).not.toBe(slots); + expect(slots[0].date).toBe(FIXED_DATE); + // Snap-back: ghost gone, the slot restored to its home position and look. + expect(ghost()).toBeNull(); + expect(cell.parentNode).toBe(slotList(0)); + expect(slotNodes(0)[0]).toBe(cell); + expect(cell.hasAttribute('data-dragging')).toBe(false); + expect(cell.classList.contains('opacity-50')).toBe(false); + }); + + it('reorders a slot down within its day', () => { + const slots = [...baseSlots(), { date: FIXED_DATE, start: '11:00', end: '11:30' }]; + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots })); + stubColumns(); + stubSlots(0); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const [c0, c1, c2] = slotNodes(0); + pointer(c0, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(c0, 'pointermove', { clientX: 50, clientY: 120 }); + // Past cell 1's midpoint (108): the placeholder parks between 10:00 and 11:00. + expect(slotNodes(0)).toEqual([c1, c0, c2]); + pointer(c0, 'pointerup', { clientX: 50, clientY: 120 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.date).toBe(FIXED_DATE); + expect(detail.index).toBe(1); + expect(detail.slots).toEqual([slots[1], { ...slots[0] }, slots[2]]); + expect(slotNodes(0)).toEqual([c0, c1, c2]); + }); + + it('reorders a slot up within its day', () => { + const slots = [...baseSlots(), { date: FIXED_DATE, start: '11:00', end: '11:30' }]; + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots })); + stubColumns(); + stubSlots(0); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const [c0, c1, c2] = slotNodes(0); + pointer(c2, 'pointerdown', { clientX: 50, clientY: 140 }); + pointer(c2, 'pointermove', { clientX: 50, clientY: 60 }); + // Above cell 0's midpoint (68): the placeholder parks first. + expect(slotNodes(0)).toEqual([c2, c0, c1]); + pointer(c2, 'pointerup', { clientX: 50, clientY: 60 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.date).toBe(FIXED_DATE); + expect(detail.index).toBe(0); + expect(detail.slots).toEqual([{ ...slots[2] }, slots[0], slots[1]]); + }); + + it('inserts between existing slots on a cross-day move', () => { + const slots = [ + { date: FIXED_DATE, start: '09:00', end: '09:30' }, + { date: '2026-06-23', start: '09:00', end: '09:30' }, + { date: '2026-06-23', start: '10:00', end: '10:30' }, + ]; + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots })); + stubColumns(); + stubSlots(1); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 100 }); + expect(cell.parentNode).toBe(slotList(1)); + expect(slotNodes(1)[1]).toBe(cell); + pointer(cell, 'pointerup', { clientX: 150, clientY: 100 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.date).toBe('2026-06-23'); + expect(detail.index).toBe(1); + expect(detail.slots).toEqual([slots[1], { ...slots[0], date: '2026-06-23' }, slots[2]]); + }); + + it('does not dispatch on a same-position drop and suppresses the trailing click', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + stubSlots(0); + const drops = vi.fn(); + const clicks = vi.fn(); + el.addEventListener('slot-drop', drops); + el.addEventListener('slot-click', clicks); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 60, clientY: 60 }); + pointer(cell, 'pointerup', { clientX: 60, clientY: 60 }); + expect(drops).not.toHaveBeenCalled(); + cell.click(); + expect(clicks).not.toHaveBeenCalled(); + cell.click(); + expect(clicks).toHaveBeenCalledOnce(); + }); + + it('dispatches nothing after a round trip back to the original position', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + stubSlots(0); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const [c0, c1] = slotNodes(0); + pointer(c0, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(c0, 'pointermove', { clientX: 50, clientY: 120 }); + expect(slotNodes(0)).toEqual([c1, c0]); + pointer(c0, 'pointermove', { clientX: 50, clientY: 60 }); + expect(slotNodes(0)).toEqual([c0, c1]); + pointer(c0, 'pointerup', { clientX: 50, clientY: 60 }); + expect(drops).not.toHaveBeenCalled(); + }); + + it('keeps sub-threshold moves as plain clicks', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + const clicks = vi.fn(); + el.addEventListener('slot-click', clicks); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 50 }); + pointer(cell, 'pointermove', { clientX: 52, clientY: 51 }); + pointer(cell, 'pointerup', { clientX: 52, clientY: 51 }); + expect(cell.hasAttribute('data-dragging')).toBe(false); + expect(ghost()).toBeNull(); + cell.click(); + expect(clicks).toHaveBeenCalledOnce(); + }); + + it('leaves the selection untouched by a drag', () => { + withModel(); + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + const cell = firstCell(); + cell.click(); + expect(cell.getAttribute('aria-pressed')).toBe('true'); + expect(el._x_model.get()).toBe('2026-06-22T09:00'); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 50 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 50 }); + pointer(cell, 'pointerup', { clientX: 150, clientY: 50 }); + expect(cell.getAttribute('aria-pressed')).toBe('true'); + expect(el._x_model.get()).toBe('2026-06-22T09:00'); + }); + + it('respects a per-slot draggable: false', () => { + const slots = baseSlots(); + slots[0].draggable = false; + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots })); + stubColumns(); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 50 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 50 }); + pointer(cell, 'pointerup', { clientX: 150, clientY: 50 }); + expect(cell.hasAttribute('data-dragging')).toBe(false); + expect(drops).not.toHaveBeenCalled(); + }); + + it('never drags unavailable slots', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: [{ date: FIXED_DATE, start: '09:00', end: '09:30', available: false }] })); + stubColumns(); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = el.querySelector('[data-slot="slot-picker-cell"]'); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 50 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 50 }); + pointer(cell, 'pointerup', { clientX: 150, clientY: 50 }); + expect(cell.hasAttribute('data-dragging')).toBe(false); + expect(drops).not.toHaveBeenCalled(); + }); + + it('never targets a disabled day column', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots(), disabledDates: ['2026-06-23'] })); + stubColumns(); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 60 }); + // The disabled middle day is no target: the placeholder stays home. + expect(cell.parentNode).toBe(slotList(0)); + expect(slotNodes(0)[0]).toBe(cell); + pointer(cell, 'pointerup', { clientX: 150, clientY: 60 }); + expect(drops).not.toHaveBeenCalled(); + }); + + it('restores the placeholder and dispatches nothing on a drop outside every column', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 60 }); + expect(cell.parentNode).toBe(slotList(1)); + pointer(cell, 'pointermove', { clientX: -50, clientY: 60 }); + expect(cell.parentNode).toBe(slotList(0)); + expect(slotNodes(0)[0]).toBe(cell); + pointer(cell, 'pointerup', { clientX: -50, clientY: 60 }); + expect(drops).not.toHaveBeenCalled(); + expect(ghost()).toBeNull(); + }); + + it('clamps the pointer into the day grid so edges still target the nearest day', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + stubRect(el.querySelector('.overflow-auto').firstElementChild, { left: 0, right: 300, top: 0, bottom: 500, width: 300, height: 500 }); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + // Far below the grid over column 1's x range: clamps to column 1. + pointer(cell, 'pointermove', { clientX: 150, clientY: 700 }); + expect(cell.parentNode).toBe(slotList(1)); + pointer(cell, 'pointerup', { clientX: 150, clientY: 700 }); + expect(drops).toHaveBeenCalledOnce(); + expect(drops.mock.calls[0][0].detail.date).toBe('2026-06-23'); + }); + + it('never drags generated slots', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, start: '09:00', end: '11:00', step: 60 })); + stubColumns(); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 60 }); + pointer(cell, 'pointerup', { clientX: 150, clientY: 60 }); + expect(cell.hasAttribute('data-dragging')).toBe(false); + expect(ghost()).toBeNull(); + expect(drops).not.toHaveBeenCalled(); + }); + + it('keeps fillEmptyDays fillers inert but accepts drops on their day', () => { + const raw = { date: FIXED_DATE, start: '08:00', end: '08:30' }; + mount('config', withConfig({ date: FIXED_DATE, draggable: true, fillEmptyDays: true, start: '09:00', end: '11:00', step: 60, slots: [raw] })); + stubColumns(); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + // Generated fillers on other days do not drag. + const filler = slotNodes(1)[0]; + pointer(filler, 'pointerdown', { clientX: 150, clientY: 60 }); + pointer(filler, 'pointermove', { clientX: 250, clientY: 60 }); + pointer(filler, 'pointerup', { clientX: 250, clientY: 60 }); + expect(filler.hasAttribute('data-dragging')).toBe(false); + expect(drops).not.toHaveBeenCalled(); + // The explicit slot can drop on a generated day: the visual position + // appends past the fillers while the proposed array simply gains the + // day's only explicit slot. + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 60 }); + pointer(cell, 'pointerup', { clientX: 150, clientY: 60 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.date).toBe('2026-06-23'); + expect(detail.index).toBe(2); + expect(detail.slots).toEqual([{ ...raw, date: '2026-06-23' }]); + }); + + it('drags a tiled slot as a whole from its header, never from a tile', () => { + const slots = [{ date: FIXED_DATE, start: '09:00', end: '10:00', tiles: [{ description: 'Room A' }, { description: 'Room B' }] }]; + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots })); + stubColumns(); + const drops = vi.fn(); + const clicks = vi.fn(); + el.addEventListener('slot-drop', drops); + el.addEventListener('slot-click', clicks); + const group = el.querySelector('[data-slot="slot-picker-slot"]'); + const header = group.querySelector('[data-slot="slot-picker-slot-header"]'); + const tile = group.querySelector('[data-slot="slot-picker-tile"]'); + + // A press on a tile never starts a group drag. + pointer(tile, 'pointerdown', { clientX: 50, clientY: 50 }); + pointer(tile, 'pointermove', { clientX: 150, clientY: 50 }); + pointer(tile, 'pointerup', { clientX: 150, clientY: 50 }); + expect(group.hasAttribute('data-dragging')).toBe(false); + expect(drops).not.toHaveBeenCalled(); + tile.click(); + expect(clicks).toHaveBeenCalledOnce(); + + // A press on the header drags the whole group. + pointer(header, 'pointerdown', { clientX: 50, clientY: 50 }); + pointer(group, 'pointermove', { clientX: 150, clientY: 50 }); + expect(group.getAttribute('data-dragging')).toBe('true'); + expect(group.parentNode).toBe(slotList(1)); + pointer(group, 'pointerup', { clientX: 150, clientY: 50 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.slot).toMatchObject({ date: FIXED_DATE, start: '09:00', end: '10:00', tileIndex: null }); + expect(detail.date).toBe('2026-06-23'); + expect(detail.index).toBe(0); + expect(detail.slots).toEqual([{ ...slots[0], date: '2026-06-23' }]); + // Group drags never suppress the next tile click. + tile.click(); + expect(clicks).toHaveBeenCalledTimes(2); + }); + + it('aborts on pointercancel without dispatching or suppressing clicks', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + const drops = vi.fn(); + const clicks = vi.fn(); + el.addEventListener('slot-drop', drops); + el.addEventListener('slot-click', clicks); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 50 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 50 }); + expect(ghost()).toBeTruthy(); + expect(cell.parentNode).toBe(slotList(1)); + pointer(cell, 'pointercancel', {}); + expect(ghost()).toBeNull(); + expect(cell.parentNode).toBe(slotList(0)); + expect(cell.hasAttribute('data-dragging')).toBe(false); + expect(drops).not.toHaveBeenCalled(); + cell.click(); + expect(clicks).toHaveBeenCalledOnce(); + }); + + it('positions the ghost from the pointer and the grab offset in rem', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + stubSlots(0); + stubRect(el, { left: 0, top: 0, width: 300, height: 600 }); + const cell = firstCell(); + // Grab at (50, 60) on the 100x36 cell at (0, 50): offset (50, 10). + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 200 }); + const g = ghost(); + expect(g.style.width).toBe('6.25rem'); + expect(g.style.height).toBe('2.25rem'); + expect(g.style.left).toBe('6.25rem'); + expect(g.style.top).toBe('11.875rem'); + // The cell's own `relative` must not survive on the clone, where it + // would win over `absolute` and leave the ghost in the flow. + expect(g.classList.contains('relative')).toBe(false); + pointer(cell, 'pointerup', { clientX: 150, clientY: 200 }); + }); + + it('nudges the scroll body when dragging near its edges', () => { + mount('config', withConfig({ date: FIXED_DATE, draggable: true, slots: baseSlots() })); + stubColumns(); + const scrollBody = el.querySelector('.overflow-auto'); + stubRect(scrollBody, { top: 0, bottom: 500, height: 500, width: 300 }); + scrollBody.scrollTop = 100; + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 50, clientY: 490 }); + expect(scrollBody.scrollTop).toBe(115); + pointer(cell, 'pointermove', { clientX: 50, clientY: 10 }); + expect(scrollBody.scrollTop).toBe(100); + pointer(cell, 'pointerup', { clientX: 50, clientY: 10 }); + }); + + it('keeps the now indicator safe while a drag parks a slot elsewhere', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 5, 22, 10, 30, 0)); + try { + const slots = [ + { date: FIXED_DATE, start: '09:00', end: '09:30' }, + { date: FIXED_DATE, start: '12:00', end: '12:30' }, + { date: FIXED_DATE, start: '23:00', end: '23:30' }, + ]; + mount('config', withConfig({ date: FIXED_DATE, draggable: true, showNowIndicator: true, slots })); + stubColumns(); + const cell = slotNodes(0)[2]; + pointer(cell, 'pointerdown', { clientX: 50, clientY: 60 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 60 }); + expect(cell.parentNode).toBe(slotList(1)); + // The 12:00 boundary tick fires while the 23:00 slot is parked in + // another column. It must not throw and must keep the indicator in + // today's list. + expect(() => vi.advanceTimersByTime(90 * 60000)).not.toThrow(); + expect(el.querySelector('[data-slot="slot-picker-now"]').parentNode).toBe(slotList(0)); + pointer(cell, 'pointerup', { clientX: 150, clientY: 60 }); + } finally { + vi.useRealTimers(); + } + }); + + it('targets stacked responsive columns by their vertical rects', () => { + const slots = [ + { date: FIXED_DATE, start: '09:00', end: '09:30' }, + { date: '2026-06-23', start: '09:30', end: '10:00' }, + ]; + mountResponsive('config', withConfig({ date: FIXED_DATE, draggable: true, slots })); + const cols = dayColumns(); + cols.forEach((col, i) => stubRect(col, { left: 0, right: 300, top: i * 200, bottom: (i + 1) * 200, width: 300, height: 200 })); + const drops = vi.fn(); + el.addEventListener('slot-drop', drops); + const cell = firstCell(); + pointer(cell, 'pointerdown', { clientX: 150, clientY: 100 }); + pointer(cell, 'pointermove', { clientX: 150, clientY: 300 }); + expect(cell.parentNode).toBe(slotList(1)); + pointer(cell, 'pointerup', { clientX: 150, clientY: 300 }); + expect(drops).toHaveBeenCalledOnce(); + const detail = drops.mock.calls[0][0].detail; + expect(detail.date).toBe('2026-06-23'); + expect(detail.index).toBe(1); + expect(detail.slots).toEqual([slots[1], { ...slots[0], date: '2026-06-23' }]); + }); + }); + describe('now indicator', () => { // FIXED_DATE (2026-06-22) is "today" and the first visible column. const setNow = (h, m) => vi.setSystemTime(new Date(2026, 5, 22, h, m, 0)); From a63a6e2d746d4140238d3886dd7f9e2a24011aa6 Mon Sep 17 00:00:00 2001 From: StanZGenchev Date: Thu, 27 Aug 2026 20:17:06 +0300 Subject: [PATCH 2/9] Fixed #77 Signed-off-by: StanZGenchev --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 26 ++++++++++ docs/components/accordion.md | 46 +++++++++++++---- package.json | 2 +- skills/harmonia/references/accordion.md | 42 ++++++++++++---- skills/harmonia/references/calendar.md | 2 +- skills/harmonia/references/migration.md | 4 ++ src/components/accordion.js | 15 +++--- src/components/expansion-panel.js | 16 ++++-- tests/components/accordion.test.js | 63 ++++++++++++++++++++++-- tests/components/expansion-panel.test.js | 35 ++++++++++++- tests/test-utils.js | 2 + 12 files changed, 219 insertions(+), 36 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 840993b..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.0", + "version": "3.0.0", "author": { "name": "codbex" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc1c76..b35c578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v3.0.0 + +A release that 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. The release also 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. + +### 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. + +### 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. + +### Z-Index + +- **New: `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. + ## v2.14.1 A bugfix release that makes the Time Picker follow programmatic model writes, completing the v2.14.0 fix that only covered clearing. There are no breaking changes. diff --git a/docs/components/accordion.md b/docs/components/accordion.md index 0a962d3..3d6a857 100644 --- a/docs/components/accordion.md +++ b/docs/components/accordion.md @@ -27,9 +27,9 @@ x-h-accordion-content #### x-h-accordion-item -| Attribute | Type | Required | Description | -| --------- | ------ | -------- | ----------------------------------------------------------------------- | -| `self` | string | false | Sets the ID of the item. Useful when setting the default expanded item. | +| Attribute | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `self` | string | false | Sets the ID of the item. Expects a string literal or a reference to a variable. Useful when setting the default expanded item. | #### x-h-accordion-trigger @@ -41,9 +41,9 @@ x-h-accordion-content #### x-h-accordion -| Modifier | Type | Required | Description | -| -------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| single | string | false | Used when the accordion must show only one section at a time. Optionally, the id of the item that should be expanded by default can be set. | +| Modifier | Type | Required | Description | +| -------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| single | string | false | Used when the accordion must show only one section at a time. Optionally, the id of the item that should be expanded by default can be set. Expects a string literal or a reference to a variable. | #### x-h-accordion-item @@ -58,15 +58,15 @@ x-h-accordion-content ```html -
-
+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. @@ -78,6 +78,34 @@ x-h-accordion-content +### Dynamic items + +Because the item id is evaluated, items rendered with `x-for` can take their id from the iterated data, which keeps single mode working. + + + +```html +
+ +
+``` + +
+ ### Default section diff --git a/package.json b/package.json index 70df1dc..9d7263d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codbex/harmonia", - "version": "2.14.1", + "version": "3.0.0", "description": "A Modern UI Component Library for Alpine.js", "repository": { "url": "git+https://github.com/codbex/harmonia.git", diff --git a/skills/harmonia/references/accordion.md b/skills/harmonia/references/accordion.md index dc8c19c..a31c67a 100644 --- a/skills/harmonia/references/accordion.md +++ b/skills/harmonia/references/accordion.md @@ -29,9 +29,9 @@ Use accordions to group related content that doesn’t need to be visible all at #### x-h-accordion-item -| Attribute | Type | Required | Description | -| --------- | ------ | -------- | ----------------------------------------------------------------------- | -| `self` | string | false | Sets the ID of the item. Useful when setting the default expanded item. | +| Attribute | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `self` | string | false | Sets the ID of the item. Expects a string literal or a reference to a variable. Useful when setting the default expanded item. | #### x-h-accordion-trigger @@ -43,9 +43,9 @@ Use accordions to group related content that doesn’t need to be visible all at #### x-h-accordion -| Modifier | Type | Required | Description | -| -------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| single | string | false | Used when the accordion must show only one section at a time. Optionally, the id of the item that should be expanded by default can be set. | +| Modifier | Type | Required | Description | +| -------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| single | string | false | Used when the accordion must show only one section at a time. Optionally, the id of the item that should be expanded by default can be set. Expects a string literal or a reference to a variable. | #### x-h-accordion-item @@ -58,15 +58,15 @@ Use accordions to group related content that doesn’t need to be visible all at ### Show only one section at a time ```html -
-
+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. @@ -76,6 +76,30 @@ Use accordions to group related content that doesn’t need to be visible all at
``` +### Dynamic items + +Because the item id is evaluated, items rendered with `x-for` can take their id from the iterated data, which keeps single mode working. + +```html +
+ +
+``` + ### Default section ```html diff --git a/skills/harmonia/references/calendar.md b/skills/harmonia/references/calendar.md index 2179521..5a24d9a 100644 --- a/skills/harmonia/references/calendar.md +++ b/skills/harmonia/references/calendar.md @@ -66,7 +66,7 @@ Pass a configuration object to the directive as an expression. | views | Show the view-switcher button group in the toolbar. Defaults to `true`. Set to `false` to lock the calendar to the view set in `view` and hide the switcher. | | scrollTo | Where week and day views scroll to on load - `"now"` anchors on the current time, `"first-event"` anchors on the earliest event in view. Falls back to `"now"` when the view has no timed events. | | draggable | Enable drag-and-drop rescheduling of events in the month, week, and day views. Defaults to `false`. See Behavior. | -| dragStep | Minutes value, used as the step when a timed event is dragged vertically in the week and day views. Defaults to `15`. | +| dragStep | Minutes value, used as a step when a timed event is dragged vertically in the week and day views. Defaults to `15`. | ### Event object diff --git a/skills/harmonia/references/migration.md b/skills/harmonia/references/migration.md index 7ab81f3..f8c6134 100644 --- a/skills/harmonia/references/migration.md +++ b/skills/harmonia/references/migration.md @@ -2,6 +2,10 @@ Breaking changes only, grouped by version (newest first). For the full history including features and fixes, see [CHANGELOG.md](https://github.com/codbex/harmonia/blob/main/CHANGELOG.md). +## v3.0.0 + +- **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. + ## v2.13.0 - **Breaking: the fade utilities take a size, and the bare names are gone.** `fade-x`, `fade-y`, `fade-t`, `fade-b`, `fade-l` and `fade-r` no longer exist. Each class now ends with a size on the spacing scale, shipped in `2`, `4` and `8` (a 0.5rem, 1rem or 2rem fade). To migrate, append `-2` to the old name, so `fade-x` becomes `fade-x-2` with the exact same 0.5rem fade. diff --git a/src/components/accordion.js b/src/components/accordion.js index 357c972..c7f93ef 100644 --- a/src/components/accordion.js +++ b/src/components/accordion.js @@ -3,17 +3,17 @@ import { transitionClose } from '../common/transition-close'; import uuidv4 from '../utils/uuid'; import { ChevronDown, createSvg } from './../common/icons'; export default function (Alpine) { - Alpine.directive('h-accordion', (el, { expression, modifiers }, { Alpine }) => { + Alpine.directive('h-accordion', (el, { expression, modifiers }, { evaluate, Alpine }) => { el._h_accordion = modifiers.includes('single') ? Alpine.reactive({ single: true, - expandedId: expression ?? '', + expandedId: expression ? String(evaluate(expression) ?? '') : '', }) : { single: false }; el.setAttribute('data-slot', 'accordion'); }); - Alpine.directive('h-accordion-item', (el, { original, expression, modifiers }, { Alpine }) => { + Alpine.directive('h-accordion-item', (el, { original, expression, modifiers }, { evaluate, Alpine }) => { const accordion = findAncestorState(Alpine, el, '_h_accordion'); if (!accordion) { @@ -23,7 +23,8 @@ export default function (Alpine) { el.classList.add('border-b', 'last:border-b-0'); el.setAttribute('data-slot', 'accordion-item'); - const itemId = expression ?? `ha${uuidv4()}`; + const idValue = expression ? evaluate(expression) : null; + const itemId = idValue == null || idValue === '' ? `ha${uuidv4()}` : String(idValue); function getIsExpanded() { if (accordion._h_accordion.single) { @@ -108,11 +109,13 @@ export default function (Alpine) { }; const handler = () => { - accordionItem._h_accordionItem.expanded = !accordionItem._h_accordionItem.expanded; - setAttributes(); + // Claim the expanded slot before toggling, so the collapse effect never + // sees the new expanded state paired with the previous item's id. if (accordion._h_accordion.single) { accordion._h_accordion.expandedId = accordionItem._h_accordionItem.id; } + accordionItem._h_accordionItem.expanded = !accordionItem._h_accordionItem.expanded; + setAttributes(); }; setAttributes(); diff --git a/src/components/expansion-panel.js b/src/components/expansion-panel.js index 7aae04a..14574b0 100644 --- a/src/components/expansion-panel.js +++ b/src/components/expansion-panel.js @@ -28,11 +28,13 @@ export default function (Alpine) { ); el.setAttribute('data-slot', 'exp-panel-item'); - let itemId; + let itemId, controlsId; if (el.hasAttribute('id')) { - itemId = el.getAttribute('id'); + itemId = `${el.getAttribute('id')}-trigger`; + controlsId = `${el.getAttribute('id')}-content`; } else { itemId = `epi${uuidv4()}`; + controlsId = `epi${uuidv4()}`; } function setExpanded(expanded) { @@ -46,7 +48,8 @@ export default function (Alpine) { } el._h_expPanelItem = Alpine.reactive({ - controls: itemId, + id: itemId, + controls: controlsId, expanded: evaluate(expression || 'false'), }); @@ -177,8 +180,13 @@ export default function (Alpine) { }); }); - Alpine.directive('h-exp-panel-content', (el) => { + Alpine.directive('h-exp-panel-content', (el, _, { Alpine }) => { el.classList.add('flex-1', 'overflow-scroll'); el.setAttribute('data-slot', 'exp-panel-content'); + const parent = findAncestorState(Alpine, el, '_h_expPanelItem'); + if (parent) { + el.setAttribute('id', parent._h_expPanelItem.controls); + el.setAttribute('aria-labelledby', parent._h_expPanelItem.id); + } }); } diff --git a/tests/components/accordion.test.js b/tests/components/accordion.test.js index 3d603ad..b529c27 100644 --- a/tests/components/accordion.test.js +++ b/tests/components/accordion.test.js @@ -36,10 +36,15 @@ describe('h-accordion', () => { expect(el._h_accordion.expandedId).toBe(''); }); - it('uses expression as initial expandedId when single', () => { - mountDirective(accordionPlugin, 'h-accordion', el, { modifiers: ['single'], expression: 'item-1' }); + it('uses the evaluated expression as initial expandedId when single', () => { + mountDirective(accordionPlugin, 'h-accordion', el, { modifiers: ['single'], expression: 'ids.first' }, { evaluate: () => 'item-1' }); expect(el._h_accordion.expandedId).toBe('item-1'); }); + + it('falls back to an empty expandedId when the expression evaluates to null', () => { + mountDirective(accordionPlugin, 'h-accordion', el, { modifiers: ['single'], expression: 'missing' }, { evaluate: () => null }); + expect(el._h_accordion.expandedId).toBe(''); + }); }); describe('h-accordion-item', () => { @@ -65,12 +70,32 @@ describe('h-accordion-item', () => { }); it('creates reactive _h_accordionItem with id and controls', () => { - mountDirective(accordionPlugin, 'h-accordion-item', el, { expression: 'test-id' }); + mountDirective(accordionPlugin, 'h-accordion-item', el, { expression: "'test-id'" }, { evaluate: () => 'test-id' }); expect(el._h_accordionItem.id).toBe('test-id'); expect(el._h_accordionItem.controls).toBeTruthy(); expect(typeof el._h_accordionItem.expanded).toBe('boolean'); }); + it('uses the evaluated expression as the id', () => { + mountDirective(accordionPlugin, 'h-accordion-item', el, { expression: 'item.id' }, { evaluate: () => 'row-1' }); + expect(el._h_accordionItem.id).toBe('row-1'); + }); + + it('generates distinct non-empty ids for items without an expression', () => { + const sibling = document.createElement('div'); + parentEl.appendChild(sibling); + mountDirective(accordionPlugin, 'h-accordion-item', el); + mountDirective(accordionPlugin, 'h-accordion-item', sibling); + expect(el._h_accordionItem.id).toBeTruthy(); + expect(sibling._h_accordionItem.id).toBeTruthy(); + expect(el._h_accordionItem.id).not.toBe(sibling._h_accordionItem.id); + }); + + it('falls back to a generated id when the expression evaluates to null', () => { + mountDirective(accordionPlugin, 'h-accordion-item', el, { expression: 'missing' }, { evaluate: () => null }); + expect(el._h_accordionItem.id).toBeTruthy(); + }); + it('throws if no accordion parent', () => { const orphan = document.createElement('div'); document.body.appendChild(orphan); @@ -142,6 +167,38 @@ describe('h-accordion-trigger', () => { }); }); +describe('single mode', () => { + // Regression for #77: items without an expression all got the empty string + // as id, so opening a second item never collapsed the first. + it('collapses the previously open item when items have no explicit id', () => { + const rootEl = document.createElement('div'); + document.body.appendChild(rootEl); + mountDirective(accordionPlugin, 'h-accordion', rootEl, { modifiers: ['single'] }); + + const items = []; + const triggers = []; + for (let i = 0; i < 2; i++) { + const itemEl = document.createElement('div'); + rootEl.appendChild(itemEl); + mountDirective(accordionPlugin, 'h-accordion-item', itemEl); + const triggerEl = document.createElement('h3'); + itemEl.appendChild(triggerEl); + mountDirective(accordionPlugin, 'h-accordion-trigger', triggerEl, { original: 'h-accordion-trigger', expression: '' }); + items.push(itemEl); + triggers.push(triggerEl); + } + + triggers[0].dispatchEvent(new Event('click')); + expect(items[0]._h_accordionItem.expanded).toBe(true); + + triggers[1].dispatchEvent(new Event('click')); + expect(items[1]._h_accordionItem.expanded).toBe(true); + expect(items[0]._h_accordionItem.expanded).toBe(false); + expect(triggers[0].querySelector('button').getAttribute('aria-expanded')).toBe('false'); + expect(triggers[1].querySelector('button').getAttribute('aria-expanded')).toBe('true'); + }); +}); + describe('h-accordion-content', () => { let rootEl, itemEl, contentEl; diff --git a/tests/components/expansion-panel.test.js b/tests/components/expansion-panel.test.js index 4a69d39..50f97b3 100644 --- a/tests/components/expansion-panel.test.js +++ b/tests/components/expansion-panel.test.js @@ -56,13 +56,27 @@ describe('h-exp-panel-item', () => { it('creates reactive _h_expPanelItem', () => { mountDirective(expansionPanelPlugin, 'h-exp-panel-item', el, { expression: 'false' }, { evaluate: vi.fn().mockReturnValue(false) }); expect(el._h_expPanelItem).toBeDefined(); + expect(typeof el._h_expPanelItem.id).toBe('string'); expect(typeof el._h_expPanelItem.controls).toBe('string'); + expect(el._h_expPanelItem.id).not.toBe(el._h_expPanelItem.controls); }); - it('uses element id when present', () => { + it('derives the trigger and content ids from the element id when present', () => { el.setAttribute('id', 'custom-id'); mountDirective(expansionPanelPlugin, 'h-exp-panel-item', el, { expression: 'false' }, { evaluate: vi.fn().mockReturnValue(false) }); - expect(el._h_expPanelItem.controls).toBe('custom-id'); + expect(el._h_expPanelItem.id).toBe('custom-id-trigger'); + expect(el._h_expPanelItem.controls).toBe('custom-id-content'); + }); + + it('generates distinct ids for items without an element id', () => { + const sibling = document.createElement('div'); + document.body.appendChild(sibling); + mountDirective(expansionPanelPlugin, 'h-exp-panel-item', el, { expression: 'false' }, { evaluate: vi.fn().mockReturnValue(false) }); + mountDirective(expansionPanelPlugin, 'h-exp-panel-item', sibling, { expression: 'false' }, { evaluate: vi.fn().mockReturnValue(false) }); + expect(el._h_expPanelItem.id).toBeTruthy(); + expect(el._h_expPanelItem.controls).toBeTruthy(); + expect(el._h_expPanelItem.id).not.toBe(sibling._h_expPanelItem.id); + expect(el._h_expPanelItem.controls).not.toBe(sibling._h_expPanelItem.controls); }); it('starts collapsed when expression is false', () => { @@ -173,6 +187,13 @@ describe('h-exp-panel-trigger', () => { expect(btn.getAttribute('aria-controls')).toBe('content-1'); }); + // Regression: the state had no id property, so every button got id="undefined". + it('sets the item id on the button', () => { + mountDirective(expansionPanelPlugin, 'h-exp-panel-trigger', triggerEl, { original: 'h-exp-panel-trigger', expression: '' }); + const btn = triggerEl.querySelector('button'); + expect(btn.getAttribute('id')).toBe('item-1'); + }); + it('calls cleanup', () => { const { ctx } = mountDirective(expansionPanelPlugin, 'h-exp-panel-trigger', triggerEl, { original: 'h-exp-panel-trigger', expression: '' }); expect(ctx.cleanup).toHaveBeenCalled(); @@ -209,4 +230,14 @@ describe('h-exp-panel-content', () => { mountDirective(expansionPanelPlugin, 'h-exp-panel-content', el); expect(el.getAttribute('data-slot')).toBe('exp-panel-content'); }); + + it('sets id and aria-labelledby from the parent item', () => { + const itemEl = document.createElement('div'); + itemEl._h_expPanelItem = { id: 'item-1', controls: 'content-1', expanded: false }; + itemEl.appendChild(el); + document.body.appendChild(itemEl); + mountDirective(expansionPanelPlugin, 'h-exp-panel-content', el); + expect(el.getAttribute('id')).toBe('content-1'); + expect(el.getAttribute('aria-labelledby')).toBe('item-1'); + }); }); diff --git a/tests/test-utils.js b/tests/test-utils.js index 78d651f..e935abf 100644 --- a/tests/test-utils.js +++ b/tests/test-utils.js @@ -13,6 +13,8 @@ function reactive(obj) { return Reflect.get(target, key); }, set(target, key, value) { + // Alpine's reactivity does not trigger effects when the value is unchanged. + if (Reflect.get(target, key) === value) return true; const result = Reflect.set(target, key, value); if (deps[key]) { for (const fn of [...deps[key]]) fn(); From c83ccec698191647bea4bbd5bc528d127e4e59a1 Mon Sep 17 00:00:00 2001 From: StanZGenchev Date: Fri, 28 Aug 2026 16:06:30 +0300 Subject: [PATCH 3/9] Updated Alpine to 3.16.3 Signed-off-by: StanZGenchev --- package-lock.json | 37 +++++++++++++++---------------------- package.json | 6 +++--- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5628195..fb58573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codbex/harmonia", - "version": "2.14.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codbex/harmonia", - "version": "2.14.1", + "version": "3.0.0", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.8.0" @@ -15,7 +15,7 @@ "@eslint/js": "^10.0.1", "@tailwindcss/cli": "^4.3.3", "@vitest/coverage-v8": "^4.1.9", - "alpinejs": "^3.15.12", + "alpinejs": "^3.16.3", "autoprefixer": "^10.5.0", "esbuild": "^0.28.1", "eslint": "^10.5.0", @@ -30,7 +30,7 @@ "vitest": "^4.1.9" }, "peerDependencies": { - "alpinejs": "^3.15.12" + "alpinejs": "^3.16.3" } }, "node_modules/@babel/helper-string-parser": { @@ -2344,13 +2344,13 @@ "license": "MIT" }, "node_modules/@vue/reactivity": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", - "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/shared": "3.1.5" + "@vue/shared": "3.5.42" } }, "node_modules/@vue/runtime-core": { @@ -2431,9 +2431,9 @@ "license": "MIT" }, "node_modules/@vue/shared": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", - "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", "dev": true, "license": "MIT" }, @@ -2586,13 +2586,13 @@ } }, "node_modules/alpinejs": { - "version": "3.15.12", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", - "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", + "version": "3.16.3", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.3.tgz", + "integrity": "sha512-kLIO2JOPs5ZY3mHPH+CHHPzp89Y6Gb2luLRy+RDIb1oG9y6N5Vfc75kkEoaSOPhJqvakuPiAsnd9paNPNSZyVg==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "~3.1.1" + "@vue/reactivity": "~3.5.40" } }, "node_modules/assertion-error": { @@ -5404,13 +5404,6 @@ } } }, - "node_modules/vitepress/node_modules/@vue/shared": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", - "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", - "dev": true, - "license": "MIT" - }, "node_modules/vitest": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", diff --git a/package.json b/package.json index 9d7263d..b3275ac 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "docs:preview": "vitepress preview docs" }, "docDependencies": { - "alpinejs": "^3.15.12", + "alpinejs": "^3.16.3", "i18next": "^26.3.4", "lucide": "^1.20.0", "culori": "^4.0.2", @@ -66,7 +66,7 @@ "@eslint/js": "^10.0.1", "@tailwindcss/cli": "^4.3.3", "@vitest/coverage-v8": "^4.1.9", - "alpinejs": "^3.15.12", + "alpinejs": "^3.16.3", "autoprefixer": "^10.5.0", "esbuild": "^0.28.1", "eslint": "^10.5.0", @@ -81,7 +81,7 @@ "vitest": "^4.1.9" }, "peerDependencies": { - "alpinejs": "^3.15.12" + "alpinejs": "^3.16.3" }, "dependencies": { "@floating-ui/dom": "^1.8.0" From 0927194df4e83eb41aa59be03ea3dc257ab0cb60 Mon Sep 17 00:00:00 2001 From: StanZGenchev Date: Thu, 3 Sep 2026 02:42:23 +0300 Subject: [PATCH 4/9] Resolved #82 #83 #84 #85 #86 #87 #88 #89 #90 #91 #92 #93 #94 #95 #96 #97 #98 #99 #100 #101 #102 #103 and #104 Signed-off-by: StanZGenchev --- CHANGELOG.md | 88 +++- README.md | 2 + docs/.vitepress/config.js | 1 + docs/.vitepress/theme/TemplateShowcase.vue | 3 +- docs/.vitepress/theme/custom.css | 10 + docs/components.md | 14 + docs/components/avatar.md | 12 +- docs/components/backdrop.md | 35 +- docs/components/button-group.md | 151 ++++++- docs/components/button.md | 4 +- docs/components/card.md | 58 +++ docs/components/chip.md | 140 +++++-- docs/components/combobox.md | 156 +++++++ docs/components/dialog.md | 151 ++++++- docs/components/file-upload.md | 4 + docs/components/inline-calendar.md | 2 +- docs/components/list.md | 20 + docs/components/menu.md | 4 + docs/components/progress.md | 8 + docs/components/range.md | 12 +- docs/components/rating.md | 2 +- docs/components/sidebar.md | 44 +- docs/components/table.md | 107 ++++- docs/components/text.md | 22 +- docs/custom-themes.md | 7 +- docs/dark-mode.md | 27 ++ docs/index.md | 58 ++- docs/public/images/backdrop.svg | 3 + docs/public/images/combobox.svg | 3 + docs/public/templates/ember/ember-habits.html | 161 ++++---- docs/public/templates/granite-erp/index.html | 64 +-- docs/public/templates/granite-erp/js/app.js | 56 ++- docs/public/templates/granite-erp/js/data.js | 39 +- .../granite-erp/pages/approvals.html | 126 +++--- .../templates/granite-erp/pages/bills.html | 12 +- .../granite-erp/pages/customers.html | 35 +- .../granite-erp/pages/dashboard.html | 39 +- .../granite-erp/pages/documents.html | 13 +- .../templates/granite-erp/pages/inbox.html | 26 +- .../granite-erp/pages/inventory.html | 39 +- .../granite-erp/pages/invoice-detail.html | 46 ++- .../templates/granite-erp/pages/invoices.html | 25 +- .../granite-erp/pages/not-found.html | 2 +- .../templates/granite-erp/pages/settings.html | 46 +-- .../templates/granite-erp/pages/vendors.html | 12 +- docs/public/templates/onyx-chat/index.html | 256 ++++++------ docs/public/templates/onyx-chat/js/app.js | 22 +- .../templates/onyx-chat/pages/activity.html | 28 +- .../templates/onyx-chat/pages/channels.html | 8 +- .../templates/onyx-chat/pages/chat.html | 57 ++- .../templates/onyx-chat/pages/not-found.html | 2 +- .../templates/onyx-chat/pages/people.html | 6 +- .../templates/onyx-chat/pages/settings.html | 36 +- docs/public/templates/quartz-docs/index.html | 299 ++++++++++++++ docs/public/templates/quartz-docs/js/app.js | 196 +++++++++ docs/public/templates/quartz-docs/js/data.js | 62 +++ .../templates/quartz-docs/pages/api.html | 136 +++++++ .../pages/blog-announcing-quartz-1-0.html | 65 +++ .../quartz-docs/pages/blog-quartz-1-1.html | 67 +++ .../templates/quartz-docs/pages/blog.html | 45 +++ .../quartz-docs/pages/guide-caching.html | 70 ++++ .../pages/guide-getting-started.html | 82 ++++ .../quartz-docs/pages/guide-queries.html | 98 +++++ .../pages/guide-what-is-quartz.html | 64 +++ .../templates/quartz-docs/pages/home.html | 42 ++ .../quartz-docs/pages/not-found.html | 16 + .../templates/slate/slate-dashboard.html | 288 +++++++------ docs/public/theming/generator.html | 19 +- docs/public/theming/theme-sync-frame.html | 65 ++- docs/utilities/theme.md | 21 +- docs/utility-classes/width-height.md | 12 +- skills/harmonia/SKILL.md | 3 +- skills/harmonia/llms.txt | 3 +- skills/harmonia/references/avatar.md | 10 +- skills/harmonia/references/backdrop.md | 35 +- skills/harmonia/references/button-group.md | 139 ++++++- skills/harmonia/references/button.md | 4 +- skills/harmonia/references/card.md | 56 +++ skills/harmonia/references/chip.md | 130 ++++-- skills/harmonia/references/combobox.md | 151 +++++++ skills/harmonia/references/dialog.md | 149 ++++++- skills/harmonia/references/file-upload.md | 4 + skills/harmonia/references/inline-calendar.md | 2 +- skills/harmonia/references/list.md | 16 + skills/harmonia/references/menu.md | 4 + skills/harmonia/references/migration.md | 10 + skills/harmonia/references/progress.md | 8 + skills/harmonia/references/range.md | 12 +- skills/harmonia/references/rating.md | 2 +- skills/harmonia/references/sidebar.md | 40 +- skills/harmonia/references/table.md | 98 +++++ skills/harmonia/references/text.md | 22 +- skills/harmonia/references/theme.md | 20 +- skills/harmonia/references/utility-classes.md | 2 +- src/common/focus-trap.js | 78 ++++ src/common/focusable.js | 32 ++ src/common/model.js | 29 ++ src/common/shared-classes.js | 13 + src/components/avatar.js | 11 +- src/components/backdrop.js | 8 + src/components/button.js | 199 ++++++++- src/components/calendar.js | 6 +- src/components/card.js | 15 +- src/components/checkbox.js | 2 - src/components/chip.js | 242 +++++------ src/components/combobox.js | 157 ++++++++ src/components/date-picker.js | 3 +- src/components/dialog.js | 82 ++-- src/components/file-upload.js | 20 + src/components/list.js | 73 ++-- src/components/menu.js | 6 +- src/components/notifications.js | 2 +- src/components/progress.js | 49 ++- src/components/radio.js | 2 - src/components/range.js | 11 +- src/components/rating.js | 4 +- src/components/sidebar.js | 72 +++- src/components/switch.js | 3 +- src/components/table.js | 114 +++++- src/components/text.js | 2 +- src/index.js | 2 + src/module.js | 3 + src/styles/common.css | 18 +- src/styles/globals.css | 5 +- src/styles/harmonia.css | 1 + src/utils/theme.js | 50 ++- tests/common/focus-trap.test.js | 194 +++++++++ tests/common/model.test.js | 52 +++ tests/components/avatar.test.js | 44 +- tests/components/backdrop.test.js | 128 +++++- tests/components/button.test.js | 381 +++++++++++++++++- tests/components/calendar.test.js | 32 ++ tests/components/card.test.js | 44 ++ tests/components/checkbox.test.js | 2 - tests/components/chip.test.js | 230 +++++++++-- tests/components/combobox.test.js | 363 +++++++++++++++++ tests/components/dialog.test.js | 298 +++++++++++++- tests/components/file-upload.test.js | 34 ++ tests/components/list.test.js | 95 ++++- tests/components/menu.test.js | 27 ++ tests/components/notifications.test.js | 3 +- tests/components/progress.test.js | 54 +++ tests/components/radio.test.js | 2 - tests/components/range.test.js | 31 +- tests/components/rating.test.js | 19 + tests/components/sidebar.test.js | 225 ++++++++++- tests/components/switch.test.js | 2 - tests/components/table.test.js | 210 +++++++++- tests/components/text.test.js | 1 + tests/utils/theme.test.js | 142 ++++++- 150 files changed, 7577 insertions(+), 1221 deletions(-) create mode 100644 docs/components/combobox.md create mode 100644 docs/public/images/backdrop.svg create mode 100644 docs/public/images/combobox.svg create mode 100644 docs/public/templates/quartz-docs/index.html create mode 100644 docs/public/templates/quartz-docs/js/app.js create mode 100644 docs/public/templates/quartz-docs/js/data.js create mode 100644 docs/public/templates/quartz-docs/pages/api.html create mode 100644 docs/public/templates/quartz-docs/pages/blog-announcing-quartz-1-0.html create mode 100644 docs/public/templates/quartz-docs/pages/blog-quartz-1-1.html create mode 100644 docs/public/templates/quartz-docs/pages/blog.html create mode 100644 docs/public/templates/quartz-docs/pages/guide-caching.html create mode 100644 docs/public/templates/quartz-docs/pages/guide-getting-started.html create mode 100644 docs/public/templates/quartz-docs/pages/guide-queries.html create mode 100644 docs/public/templates/quartz-docs/pages/guide-what-is-quartz.html create mode 100644 docs/public/templates/quartz-docs/pages/home.html create mode 100644 docs/public/templates/quartz-docs/pages/not-found.html create mode 100644 skills/harmonia/references/combobox.md create mode 100644 src/common/focus-trap.js create mode 100644 src/common/focusable.js create mode 100644 src/common/model.js create mode 100644 src/components/combobox.js create mode 100644 tests/common/focus-trap.test.js create mode 100644 tests/common/model.test.js create mode 100644 tests/components/combobox.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c9dbe11..4fc524f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,71 @@ ## v3.0.0 -A release that 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. The release also 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. +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. 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 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 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. + +### 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. -### Z-Index +### 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. + +### 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. -- **New: `z-20` joins the shipped z-index utilities**, filling the gap between `z-10` and `z-50`. +### 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 @@ -26,6 +78,36 @@ A release that brings drag and drop to the Calendar and the Slot Picker. Calenda - **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 + +- **New: a header item can be interactive.** Writing `x-h-sidebar-header-item` on a `
-
+
diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 9d89367..b9d5877 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -165,6 +165,16 @@ h6:has(+ component-container) { gap: 2rem; } +.template-card > .template-mobile { + display: flex; + flex-direction: column; + align-items: center; +} + +.template-mobile > iframe { + width: 377px; +} + .template-card { overflow: hidden; } diff --git a/docs/components.md b/docs/components.md index cca1619..cc2e950 100644 --- a/docs/components.md +++ b/docs/components.md @@ -28,6 +28,13 @@ outline: deep

Visual representation of a user or entity, displayed as an image, icon, or fallback initials.

+ + +
+

Backdrop

+

Full-screen scrim that dims the page and animates transient surfaces like command palettes and custom modals in and out.

+
+
@@ -98,6 +105,13 @@ outline: deep

Compact, interactive element representing a filter or selection, with an optional dismiss button.

+ + +
+

Combobox

+

A list of options belonging to a text field, filtered as the user types.

+
+
diff --git a/docs/components/avatar.md b/docs/components/avatar.md index 9617386..c02da59 100644 --- a/docs/components/avatar.md +++ b/docs/components/avatar.md @@ -6,6 +6,10 @@ Represents a person, entity, or object using an image, icon, or text, such as a Use avatars to visually identify users or related entities in lists, profiles, or collaborative features. Choose the appropriate variant based on available data - images for personal recognition, initials or icons as fallbacks. +## Behavior + +An avatar is a control only when it is written as a `button` or an `a` element, so the element itself carries the role, the tab stop and the keyboard behavior. On any other tag it stays a plain avatar and is never given a role or a `tabindex` it cannot honor. + ## API Reference ### Component attribute(s) @@ -107,6 +111,8 @@ You can change the avatar shape by using the `rounded-` classes. ### Variants +Inside an active [sidebar menu button](/components/sidebar), a variant avatar switches to the button's own foreground color so it stays legible. + ```html @@ -168,12 +174,14 @@ Use `data-color` to fill the avatar with one of Harmonia's standard palette colo ### Interactive -To make an avatar interactive, use the `button` HTML element instead of a `span`. +To make an avatar interactive, write it as a `button` or an `a` element instead of a `div`. Use a `button` when the avatar acts on the page, and an `a` with an `href` when it leads somewhere. - + ```html + +HM ``` diff --git a/docs/components/backdrop.md b/docs/components/backdrop.md index ed40ede..18e4f3a 100644 --- a/docs/components/backdrop.md +++ b/docs/components/backdrop.md @@ -12,6 +12,10 @@ Mark each direct child that should animate with `x-h-backdrop-item`. The backdro The backdrop is focusable through `tabindex="-1"`, and its show and hide transitions respect the user's `prefers-reduced-motion` setting. Because the surrounding component owns the open state, wire up your own dismissal (for example closing on a click of the scrim or on `Esc`) to match your use case. +While it is open the backdrop keeps focus inside itself. `Tab` and `Shift+Tab` cycle through its own focusable content instead of reaching the page behind, and if focus is somewhere else when the user presses `Tab` it is brought in. Closing hands focus back to whatever had it when the backdrop opened, so a keyboard user returns to the button they came from. A backdrop with nothing focusable inside holds focus on itself. + +The backdrop stays a scrim rather than a dialog, so it sets no `role` or `aria-modal` of its own. If the content you put inside is a modal dialog, give that content the dialog semantics. + ## API Reference ### Component attribute(s) @@ -23,19 +27,38 @@ x-h-backdrop-item ## Examples -A command palette where a button opens the backdrop, the scrim closes it, and picking a command closes it too. +A command palette where a button opens the backdrop and the scrim closes it. The results come from a [Combobox](/components/combobox), so typing filters them, the arrow keys move through them, and picking one closes the palette. `Enter` before any arrow key runs the first match. ```html -
+
-
- -
+
+ +
    -