@ziix/calendar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/dist/index.d.ts +433 -0
- package/dist/ziix-calendar.css +1 -0
- package/dist/ziix-calendar.js +1083 -0
- package/dist/ziix-calendar.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ziix
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# @ziix/calendar
|
|
2
|
+
|
|
3
|
+
A framework-agnostic resource & time-grid calendar — a from-scratch, **license-free**
|
|
4
|
+
replacement for the FullCalendar views DMS uses (`timeGridDay`, `resourceTimeGridDay`,
|
|
5
|
+
`resourceTimeline`). No premium scheduler licence, no React/Vue/Preact dependency: a
|
|
6
|
+
plain imperative class you drive through a ref from any framework.
|
|
7
|
+
|
|
8
|
+
> **Status.** All three views (`day`, `resource-day`, `timeline`) and the full interaction
|
|
9
|
+
> engine (drag/resize/select) are implemented and tested. Remaining work is polish, the DMS
|
|
10
|
+
> migration and the npm release — see the roadmap below.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @ziix/calendar dayjs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`dayjs` is a peer dependency (the calendar uses it for timezone-correct date math).
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { Calendar } from '@ziix/calendar'
|
|
24
|
+
import '@ziix/calendar/styles.css'
|
|
25
|
+
|
|
26
|
+
const cal = new Calendar(document.getElementById('calendar'), {
|
|
27
|
+
view: 'day',
|
|
28
|
+
timezone: 'Europe/Copenhagen', // events are placed in the shop's clock, not the browser's
|
|
29
|
+
date: '2026-06-09',
|
|
30
|
+
height: 780,
|
|
31
|
+
slot: { duration: 15, min: '06:00', max: '19:00', labelInterval: 60 },
|
|
32
|
+
nowIndicator: true,
|
|
33
|
+
locale: { code: 'da', intl: 'da-DK', firstDay: 1 },
|
|
34
|
+
events: [
|
|
35
|
+
{ id: 1, title: 'Service', start: '2026-06-09T08:00:00+02:00', end: '2026-06-09T09:00:00+02:00' },
|
|
36
|
+
],
|
|
37
|
+
onEventClick: ({ event }) => console.log('clicked', event.title),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
cal.render()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The host element gets a `.zc` class and owns its own height (set `height`, or give the
|
|
44
|
+
element a height in CSS). Call `cal.destroy()` when you tear the component down.
|
|
45
|
+
|
|
46
|
+
### Loading events from a server
|
|
47
|
+
|
|
48
|
+
`events` may be an array (above) **or** a function that receives the visible range and
|
|
49
|
+
returns events — it re-runs automatically on navigation:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
events: async ({ start, end }) => {
|
|
53
|
+
// start / end are Dayjs objects in the calendar timezone
|
|
54
|
+
const res = await fetch(`/calendar/events?from=${start.toISOString()}&to=${end.toISOString()}`)
|
|
55
|
+
return (await res.json()).events
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Timeline view (resources as rows)
|
|
60
|
+
|
|
61
|
+
This is the `resourceTimeline` replacement: a sticky resource area on the left and a
|
|
62
|
+
horizontally-scrolling time grid on the right. Pass `resources` (array or function) and
|
|
63
|
+
set `view: 'timeline'`.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
const cal = new Calendar(el, {
|
|
67
|
+
view: 'timeline',
|
|
68
|
+
timezone: 'Europe/Copenhagen',
|
|
69
|
+
height: 780,
|
|
70
|
+
slot: { duration: 15, min: '06:00', max: '19:00' },
|
|
71
|
+
nowIndicator: true,
|
|
72
|
+
|
|
73
|
+
// group resources into header bands by this field
|
|
74
|
+
resourceGroupField: 'group',
|
|
75
|
+
|
|
76
|
+
// the sticky left area: one or more columns
|
|
77
|
+
resourceArea: {
|
|
78
|
+
width: '25%',
|
|
79
|
+
columns: [
|
|
80
|
+
{ field: 'title', header: 'Afdelinger' }, // plain text from resource.title
|
|
81
|
+
{ header: 'Tider', render: (r) => renderHoursCell(r) }, // custom HTML / DOM node per resource
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
resources: [
|
|
86
|
+
{ id: 'E1', title: 'Anders', group: 'Mekanik' },
|
|
87
|
+
{ id: 'E2', title: 'Bo', group: 'Mekanik' },
|
|
88
|
+
{ id: 'C1', title: 'Lånebil 1', group: 'Biler' },
|
|
89
|
+
],
|
|
90
|
+
|
|
91
|
+
events: [
|
|
92
|
+
{ id: 1, title: 'Service', start: '...', end: '...', resourceId: 'E1' },
|
|
93
|
+
],
|
|
94
|
+
|
|
95
|
+
// custom label for the default resource column (FullCalendar's resourceLabelContent)
|
|
96
|
+
renderResource: (resource) => `<strong>${resource.title}</strong>`,
|
|
97
|
+
|
|
98
|
+
onEventClick: ({ event }) => openOrder(event.extendedProps.orderId),
|
|
99
|
+
})
|
|
100
|
+
cal.render()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Events on the same resource that overlap in time stack into vertical levels and the row
|
|
104
|
+
grows to fit them. An event without a matching `resourceId` is not shown in the timeline.
|
|
105
|
+
|
|
106
|
+
### Resource-day view (resources as columns)
|
|
107
|
+
|
|
108
|
+
`view: 'resource-day'` is the `resourceTimeGridDay` equivalent: the same vertical time axis
|
|
109
|
+
as the day view, but with one column per resource under a sticky, grouped header. Takes the
|
|
110
|
+
same `resources` / `resourceGroupField` / `renderResource` options as the timeline. With
|
|
111
|
+
`editable`, dragging an event sideways moves it to another resource column.
|
|
112
|
+
|
|
113
|
+
## Data shapes
|
|
114
|
+
|
|
115
|
+
**Event input** (`color`/`textColor`/`extendedProps` optional; extra keys are preserved on
|
|
116
|
+
`event.raw`):
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
{ id, title?, start, end?, resourceId?, allDay?, color?, textColor?, extendedProps? }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`start` / `end` accept anything dayjs parses (ISO string with offset is safest). Inside the
|
|
123
|
+
calendar they become `event.start` / `event.end` Dayjs objects in the configured timezone.
|
|
124
|
+
|
|
125
|
+
**Resource input:**
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
{ id, title?, group?, order?, ...customFields }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Custom fields (e.g. `make`, `workHours`) are available as `resource.raw.<field>` in
|
|
132
|
+
`renderResource` / column `render`.
|
|
133
|
+
|
|
134
|
+
## Options reference
|
|
135
|
+
|
|
136
|
+
| Option | Type | Notes |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `view` | `'day' \| 'resource-day' \| 'timeline'` | default `'day'` |
|
|
139
|
+
| `date` | `string \| Date` | initial date; default today |
|
|
140
|
+
| `timezone` | `string` | IANA tz; events are placed in this clock |
|
|
141
|
+
| `locale` | `string \| { code, intl?, firstDay?, buttons? }` | `intl` is the BCP-47 tag for formatting |
|
|
142
|
+
| `firstDay` | `number` | 0 = Sunday; default 1 |
|
|
143
|
+
| `slot` | `{ duration?, min?, max?, labelInterval? }` | minutes / `'HH:mm'` / minutes |
|
|
144
|
+
| `height` | `number \| string` | applied to the host element |
|
|
145
|
+
| `eventMinHeight` | `number` | min height (px) of a stacked event in the timeline; row grows to fit. Default 48 |
|
|
146
|
+
| `nowIndicator` | `boolean` | current-time line |
|
|
147
|
+
| `dayClosed` | `boolean \| ((date) => boolean)` | tint the day as closed/non-business (`--zc-nonbusiness`) |
|
|
148
|
+
| `toolbar` | `{ start?, center?, end? } \| false` | space-separated tokens: `today prev next title <customKey>` |
|
|
149
|
+
| `buttons` | `{ [key]: { text?, icon?, onClick } }` | custom toolbar buttons |
|
|
150
|
+
| `events` | array \| `(range) => events \| Promise<events>` | function re-runs on navigation |
|
|
151
|
+
| `resources` | array \| `(range) => resources \| Promise<…>` | timeline / resource-day |
|
|
152
|
+
| `resourceArea` | `{ width?, columns? }` | sticky left area (timeline) |
|
|
153
|
+
| `resourceGroupField` | `string` | field to group rows by; default `'group'` |
|
|
154
|
+
| `resourceOrder` | `string` | field to sort by; default `'order'` (use `'id'` for id sort) |
|
|
155
|
+
| `renderEvent` | `(event) => string \| HTMLElement` | custom event body |
|
|
156
|
+
| `renderResource` | `(resource) => string \| HTMLElement` | custom resource label |
|
|
157
|
+
| `timeFormat` | `Intl.DateTimeFormatOptions` | default event time format |
|
|
158
|
+
| `editable` | `boolean` | enable drag-move + resize |
|
|
159
|
+
| `selectable` | `boolean` | enable drag-select of empty ranges |
|
|
160
|
+
| `eventOverlap` | `boolean \| (() => boolean)` | allow overlapping events on drop; default `true` |
|
|
161
|
+
| `selectAllow` | `({ start, end, resource }) => boolean` | gate which ranges can be selected |
|
|
162
|
+
| `onEventClick` | `({ event, el, jsEvent }) => void` | |
|
|
163
|
+
| `onEventContextMenu` | `({ event, el, jsEvent }) => void` | right-click; native menu suppressed |
|
|
164
|
+
| `onEventMount` | `({ event, el }) => void` | bind deep-link highlight / extra listeners here |
|
|
165
|
+
| `onEventChange` | `({ event, oldEvent }) => void` | after a drag/resize commit |
|
|
166
|
+
| `onSelect` | `({ start, end, resource, jsEvent }) => void` | after a drag-select |
|
|
167
|
+
| `onDatesSet` | `({ start, end, view }) => void` | fires on navigation / view change |
|
|
168
|
+
| `onEventsSet` | `(events) => void` | after each event load |
|
|
169
|
+
|
|
170
|
+
## Editing — drag, resize, select
|
|
171
|
+
|
|
172
|
+
Set `editable: true` to let users drag events to a new time (and, in the timeline, a new
|
|
173
|
+
resource row) and resize them by their edges. Set `selectable: true` to let users
|
|
174
|
+
drag-select an empty range. Everything snaps to `slot.duration`.
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
const cal = new Calendar(el, {
|
|
178
|
+
view: 'timeline',
|
|
179
|
+
editable: true,
|
|
180
|
+
selectable: true,
|
|
181
|
+
|
|
182
|
+
// false ⇒ a drop/resize that would overlap another event on the same resource is
|
|
183
|
+
// rejected and snaps back. May be a function evaluated per drop.
|
|
184
|
+
eventOverlap: () => shop.settings.calendar_overlap,
|
|
185
|
+
|
|
186
|
+
// gate which ranges may be selected (e.g. only employee or rental-car rows)
|
|
187
|
+
selectAllow: ({ resource }) => !!resource && /^[EC]/.test(resource.id),
|
|
188
|
+
|
|
189
|
+
// fired after a successful drag/resize — persist it to your backend here
|
|
190
|
+
onEventChange: ({ event, oldEvent, revert }) => {
|
|
191
|
+
api.patch(`/calendar/event/${event.id}`, {
|
|
192
|
+
from: event.start.toISOString(),
|
|
193
|
+
to: event.end.toISOString(),
|
|
194
|
+
resource: event.resourceId,
|
|
195
|
+
}).catch(() => revert()) // server rejected the move → snap the event back
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// fired after a drag-select — open a "new event" menu, etc.
|
|
199
|
+
onSelect: ({ start, end, resource }) => openNewEventMenu(start, end, resource),
|
|
200
|
+
})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Behaviour notes:
|
|
204
|
+
|
|
205
|
+
- **Day view:** drag moves vertically (time only); resize from the bottom edge.
|
|
206
|
+
- **Timeline:** drag moves horizontally (time) and vertically (across resource rows);
|
|
207
|
+
resize from either edge.
|
|
208
|
+
- A rejected move (overlap / out of bounds) reverts automatically — `onEventChange` does
|
|
209
|
+
**not** fire.
|
|
210
|
+
- A plain click on an editable event still fires `onEventClick` (distinguished from a drag
|
|
211
|
+
by a movement threshold).
|
|
212
|
+
|
|
213
|
+
## Context menus
|
|
214
|
+
|
|
215
|
+
Right-click on an event fires `onEventContextMenu` (the native browser menu is suppressed
|
|
216
|
+
first). The calendar deliberately does **not** ship a menu UI — you render your own from the
|
|
217
|
+
hook, so it matches your app. Works in all three views, on read-only and editable events.
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
const cal = new Calendar(el, {
|
|
221
|
+
onEventContextMenu: ({ event, jsEvent }) => {
|
|
222
|
+
myMenu.open(jsEvent.clientX, jsEvent.clientY, [
|
|
223
|
+
{ label: 'Open order', run: () => openOrder(event.extendedProps.orderId) },
|
|
224
|
+
{ label: 'Delete', run: () => cal.getEventById(event.id)?.remove() },
|
|
225
|
+
])
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
For a context menu on **empty** space (e.g. "create here"), use `onSelect` — a drag-select
|
|
231
|
+
gives you `{ start, end, resource }` to anchor the menu. See `examples/index.html` for a
|
|
232
|
+
working menu implementation.
|
|
233
|
+
|
|
234
|
+
## Imperative API
|
|
235
|
+
|
|
236
|
+
The calendar is a plain class you drive through a ref — mirrors the surface FullCalendar
|
|
237
|
+
consumers rely on:
|
|
238
|
+
|
|
239
|
+
| Method | Purpose |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `render()` / `destroy()` | mount / tear down |
|
|
242
|
+
| `refetchEvents()` | re-run the events source for the current range |
|
|
243
|
+
| `reload()` | re-fetch resources **and** events |
|
|
244
|
+
| `addEvent(input)` | add one event, returns an `EventHandle` |
|
|
245
|
+
| `getEventById(id)` | returns `{ event, remove(), setExtendedProp() }` or `null` |
|
|
246
|
+
| `getEvents()` / `getResources()` | read stores |
|
|
247
|
+
| `getResourceById(id)` | returns a `ResourceHandle` — `{ resource, setExtendedProp(), setProp() }` for pushing live data (work hours, punch-ins) into a resource column |
|
|
248
|
+
| `gotoDate(date)` / `today()` / `prev()` / `next()` | navigation |
|
|
249
|
+
| `changeView('day' \| 'resource-day' \| 'timeline')` | switch view |
|
|
250
|
+
| `getView()` | `{ type, activeStart, activeEnd }` for the current range |
|
|
251
|
+
| `view` / `date` / `activeStart` / `activeEnd` (getters) | current state |
|
|
252
|
+
|
|
253
|
+
### Real-time updates
|
|
254
|
+
|
|
255
|
+
Drive incremental updates from a websocket without a full refetch (DMS uses Laravel Echo):
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
echo.private(`shop.${id}`)
|
|
259
|
+
.listen('.eventCreated', (e) => cal.addEvent(e.event))
|
|
260
|
+
.listen('.eventUpdated', () => cal.refetchEvents())
|
|
261
|
+
.listen('.eventRemoved', (e) => cal.getEventById(e.id)?.remove())
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Using it from Preact / React
|
|
265
|
+
|
|
266
|
+
Because the calendar is framework-agnostic, you mount it on a ref and drive it
|
|
267
|
+
imperatively — no wrapper component needed:
|
|
268
|
+
|
|
269
|
+
```jsx
|
|
270
|
+
import { useEffect, useRef } from 'preact/hooks'
|
|
271
|
+
import { Calendar } from '@ziix/calendar'
|
|
272
|
+
import '@ziix/calendar/styles.css'
|
|
273
|
+
|
|
274
|
+
export function CalendarView({ shopId }) {
|
|
275
|
+
const elRef = useRef(null)
|
|
276
|
+
const calRef = useRef(null)
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
const cal = new Calendar(elRef.current, {
|
|
280
|
+
view: 'timeline',
|
|
281
|
+
timezone: window.ziix.timezone,
|
|
282
|
+
events: ({ start, end }) => fetchEvents(shopId, start, end),
|
|
283
|
+
resources: () => fetchResources(shopId),
|
|
284
|
+
onEventClick: ({ event }) => openOrder(event),
|
|
285
|
+
})
|
|
286
|
+
cal.render()
|
|
287
|
+
calRef.current = cal
|
|
288
|
+
return () => cal.destroy()
|
|
289
|
+
}, [shopId])
|
|
290
|
+
|
|
291
|
+
return <div ref={elRef} style={{ height: 780 }} />
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Translations & locale
|
|
296
|
+
|
|
297
|
+
The calendar renders almost no text of its own — column headers, resource labels and
|
|
298
|
+
event content all come from **your** render hooks, so they're already in your language.
|
|
299
|
+
The only built-in strings are the toolbar buttons, and the date in the title. Both are
|
|
300
|
+
driven by the `locale` option — pass your app's translations there (DMS feeds its
|
|
301
|
+
`trans()` values straight in):
|
|
302
|
+
|
|
303
|
+
```js
|
|
304
|
+
const cal = new Calendar(el, {
|
|
305
|
+
locale: {
|
|
306
|
+
code: 'da',
|
|
307
|
+
intl: 'da-DK', // BCP-47 tag used by Intl to format the title date
|
|
308
|
+
firstDay: 1,
|
|
309
|
+
buttons: { today: trans('e.today'), prev: '‹', next: '›' },
|
|
310
|
+
ariaLabels: { today: trans('e.today'), prev: trans('e.prev'), next: trans('e.next') },
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
There are no hardcoded user-facing strings in the library — anything visible is either
|
|
316
|
+
supplied by you (hooks) or overridable here. The title date is localised automatically
|
|
317
|
+
via `Intl` using `intl` (falling back to `code`).
|
|
318
|
+
|
|
319
|
+
## Theming
|
|
320
|
+
|
|
321
|
+
Every colour is a `--zc-*` custom property on `.zc`. Remap them to your design tokens —
|
|
322
|
+
no need to touch internals:
|
|
323
|
+
|
|
324
|
+
```css
|
|
325
|
+
.zc {
|
|
326
|
+
--zc-border: var(--color-border);
|
|
327
|
+
--zc-today-bg: var(--color-primary-50);
|
|
328
|
+
--zc-event-bg: var(--color-primary-200);
|
|
329
|
+
--zc-event-border: var(--color-primary-300);
|
|
330
|
+
--zc-event-fg: var(--color-primary-700);
|
|
331
|
+
--zc-now: var(--color-danger-500);
|
|
332
|
+
--zc-nonbusiness: var(--color-muted); /* closed-day tint */
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Develop
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
npm install
|
|
340
|
+
npm run dev # Vite playground (examples/index.html)
|
|
341
|
+
npm test # vitest (datelib, overlap logic + day/timeline DOM render)
|
|
342
|
+
npm run typecheck # tsc --noEmit
|
|
343
|
+
npm run build # dist/ziix-calendar.js + .css + index.d.ts
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Roadmap
|
|
347
|
+
|
|
348
|
+
| Fase | Scope | State |
|
|
349
|
+
| --- | --- | --- |
|
|
350
|
+
| 0 | Core: Calendar class, stores, datelib, toolbar, navigation, theming | ✅ |
|
|
351
|
+
| 1 | `day` view: time axis, overlap packing, now indicator, event hooks | ✅ |
|
|
352
|
+
| 3 | `timeline` view (resources as rows, horizontal axis, grouping, custom resource columns, event stacking) | ✅ |
|
|
353
|
+
| 4 | Interaction engine: drag-move, resize, drag-select, overlap/allow gating | ✅ |
|
|
354
|
+
| 2 | `resource-day` view (resources as columns, grouped header, cross-column move) | ✅ |
|
|
355
|
+
| 5 | Locale pack, deep-link highlight, a11y polish | ⏳ |
|
|
356
|
+
|
|
357
|
+
## License
|
|
358
|
+
|
|
359
|
+
MIT
|