@cahyo-dimas/freeday 3.7.0 → 3.9.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/COMPONENTS.md +214 -18
  3. package/README.id.md +4 -4
  4. package/README.md +4 -3
  5. package/adapters/blazor/FdyDatepicker.razor +3 -1
  6. package/adapters/blazor/FdyDatepicker.razor.cs +36 -1
  7. package/adapters/blazor/FdyMonthpicker.razor +29 -0
  8. package/adapters/blazor/FdyMonthpicker.razor.cs +102 -0
  9. package/adapters/blazor/FdyTable.razor +6 -1
  10. package/adapters/blazor/FdyTable.razor.cs +33 -0
  11. package/adapters/blazor/FdyTableFooter.razor +6 -6
  12. package/adapters/blazor/FdyTableFooter.razor.cs +36 -0
  13. package/adapters/core/month-grid.d.ts +116 -0
  14. package/adapters/core/month-grid.js +234 -0
  15. package/adapters/react/components/FdyDatepicker.tsx +164 -166
  16. package/adapters/react/components/FdyMonthpicker.tsx +400 -0
  17. package/adapters/react/components/FdyTable.tsx +38 -2
  18. package/adapters/react/components/FdyTableFilter.tsx +13 -1
  19. package/adapters/react/components/FdyTableFooter.tsx +37 -6
  20. package/adapters/react/index.d.ts +6 -2
  21. package/adapters/react/index.js +1 -0
  22. package/adapters/react/usePopover.ts +54 -9
  23. package/adapters/vue/components/FdyDatepicker.vue +158 -160
  24. package/adapters/vue/components/FdyMonthpicker.vue +387 -0
  25. package/adapters/vue/components/FdyTable.vue +33 -1
  26. package/adapters/vue/components/FdyTableFilter.vue +13 -1
  27. package/adapters/vue/components/FdyTableFooter.vue +39 -6
  28. package/adapters/vue/index.d.ts +16 -0
  29. package/adapters/vue/index.js +1 -0
  30. package/adapters/vue/usePopover.ts +53 -10
  31. package/dist/freeday-datepicker.js +68 -14
  32. package/dist/freeday-menu.js +15 -1
  33. package/dist/freeday-popover.js +33 -11
  34. package/dist/freeday-table.js +64 -5
  35. package/dist/freeday-tabs.js +21 -2
  36. package/dist/freeday.bundle.css +50 -2
  37. package/dist/freeday.css +50 -2
  38. package/dist/freeday.js +201 -33
  39. package/docs/agent-onboarding.md +3 -3
  40. package/docs/getting-started.md +6 -6
  41. package/docs/integrations.md +22 -4
  42. package/package.json +2 -2
  43. package/src/components/table.css +19 -2
  44. package/src/components/tabs.css +31 -0
@@ -0,0 +1,234 @@
1
+ // Freeday, month and year grid logic, framework-agnostic (pure, zero dependencies).
2
+ //
3
+ // The twelve-cell month grid and the twelve-cell year grid behind it: which cell is selected, which
4
+ // is out of bounds, how a year page is numbered, and what a key press means. Shared by
5
+ // `FdyDatepicker` (where both grids are navigation on the way to a day) and `FdyMonthpicker` (where
6
+ // the month grid is the answer), in Vue and in React, so four components read one copy.
7
+ //
8
+ // It is here rather than in each component because of what this release cost. The panel-position
9
+ // logic existed in four copies; #079 was found in one, fixed in one, and shipped broken in the
10
+ // other three, and three items of 3.9.0 went on cleaning that up. `FdyMonthpicker` needs the month
11
+ // grid, which would have made the fifth copy of it, so the rule moved out first.
12
+ //
13
+ // The vanilla enhancer (src/freeday-datepicker.js) deliberately does NOT import this, for the
14
+ // reason adapters/core/app-shell.js records: dist/freeday.js is a plain IIFE concatenation with no
15
+ // module system. That leaves two implementations, and browser/month-mode.mjs and
16
+ // browser/monthpicker.mjs hold both to the same observable behaviour.
17
+ //
18
+ // Everything here is a pure function over its arguments. State, refs, focus scheduling and markup
19
+ // stay with the caller, because those are the parts Vue and React genuinely do differently.
20
+
21
+ /** A year page holds twelve years, so the grid is the same shape as the month grid above it. */
22
+ export const YEARS_PER_PAGE = 12;
23
+
24
+ function pad(n) {
25
+ return n < 10 ? `0${n}` : `${n}`;
26
+ }
27
+
28
+ /** A Date as the `YYYY-MM` a month picker reports. The day is dropped, which is the point. */
29
+ export function toISOMonth(d) {
30
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
31
+ }
32
+
33
+ /**
34
+ * Read `YYYY-MM` or `YYYY-MM-DD` as the first day of the month named.
35
+ *
36
+ * Both shapes on purpose, and the dropped day is why: a bound of `2026-03` lands on March 1st,
37
+ * which `monthDisabled` then compares a whole month against correctly at either end, so an app can
38
+ * pass the strings it already stores. A full date still works for a form that keeps one format
39
+ * across all of its bounds.
40
+ *
41
+ * @param {string | null | undefined} s
42
+ * @returns {Date | null} null for an empty, short or unparseable value
43
+ */
44
+ export function parseISOMonth(s) {
45
+ if (s === null || s === undefined || s === '') return null;
46
+ const parts = s.split('-');
47
+ if (parts.length < 2) return null;
48
+ const d = new Date(Number(parts[0]), Number(parts[1]) - 1, parts.length > 2 ? Number(parts[2]) : 1);
49
+ return Number.isNaN(d.getTime()) ? null : d;
50
+ }
51
+
52
+ /**
53
+ * The language the page declares, which is what COMPONENTS.md has always promised and what the
54
+ * vanilla enhancer has always done.
55
+ *
56
+ * Handing `undefined` to Intl instead falls back to the BROWSER's language, so an Indonesian reader
57
+ * saw Indonesian month names inside a kit that is English on every other path, and an Indonesian
58
+ * page read by an English browser got English ones. That second case is the symptom the month
59
+ * picker was asked for, so the fallback has to be the page.
60
+ */
61
+ export function pageLocale() {
62
+ if (typeof document === 'undefined') return 'en';
63
+ // `||`, not `??`: an empty `lang=""` is not null and Intl throws RangeError on it.
64
+ return document.documentElement.getAttribute('lang') || 'en';
65
+ }
66
+
67
+ export function startOfDay(d) {
68
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
69
+ }
70
+
71
+ export function startOfMonth(d) {
72
+ return new Date(d.getFullYear(), d.getMonth(), 1);
73
+ }
74
+
75
+ export function addMonths(d, n) {
76
+ const x = new Date(d);
77
+ x.setMonth(x.getMonth() + n);
78
+ return x;
79
+ }
80
+
81
+ /**
82
+ * Is this whole month outside the bounds?
83
+ *
84
+ * Only when the WHOLE month is, which is why it reads the bounds directly instead of asking whether
85
+ * both ends of the month are disabled days: a range that sits INSIDE one month disables both of its
86
+ * ends while the middle stays perfectly selectable.
87
+ *
88
+ * @param {number} year
89
+ * @param {number} month 0-11
90
+ * @param {Date | null} min
91
+ * @param {Date | null} max
92
+ */
93
+ export function monthDisabled(year, month, min, max) {
94
+ const first = new Date(year, month, 1);
95
+ const last = new Date(year, month + 1, 0);
96
+ if (min !== null && startOfDay(last).getTime() < startOfDay(min).getTime()) return true;
97
+ if (max !== null && startOfDay(first).getTime() > startOfDay(max).getTime()) return true;
98
+ return false;
99
+ }
100
+
101
+ /** Unreachable only when EVERY month in the year is, the same rule one level up. */
102
+ export function yearDisabled(year, min, max) {
103
+ if (min !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(min).getTime()) return true;
104
+ if (max !== null && startOfDay(new Date(year, 0, 1)).getTime() > startOfDay(max).getTime()) return true;
105
+ return false;
106
+ }
107
+
108
+ /**
109
+ * Where the page holding this year starts: 2016 for 2026, so pages tile as 2016-2027, 2028-2039.
110
+ * Anchoring the page on the year in view would make the arrows land on overlapping windows, and the
111
+ * same year would sit somewhere different every step.
112
+ */
113
+ export function yearPageStart(year) {
114
+ return Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
115
+ }
116
+
117
+ /** The year grid's title, naming the page it shows. */
118
+ export function yearRangeLabel(pageStart) {
119
+ return `${pageStart} – ${pageStart + YEARS_PER_PAGE - 1}`;
120
+ }
121
+
122
+ /**
123
+ * The two Intl formatters a month grid needs: `cell` writes what a reader sees in the cell, `name`
124
+ * writes its accessible name and the value in the trigger. Built once per locale by the caller,
125
+ * because both frameworks memoize and `new Intl.DateTimeFormat` is not cheap.
126
+ */
127
+ export function monthFormatters(locale) {
128
+ return {
129
+ cell: new Intl.DateTimeFormat(locale, { month: 'short' }),
130
+ name: new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * The twelve cells of one year's month grid.
136
+ *
137
+ * @param {{ year: number, selected: Date | null, min: Date | null, max: Date | null,
138
+ * focusMonth: number, formatters: { cell: Intl.DateTimeFormat, name: Intl.DateTimeFormat },
139
+ * today?: Date }} view
140
+ */
141
+ export function monthCells(view) {
142
+ const today = view.today ?? new Date();
143
+ return Array.from({ length: 12 }, (_unused, index) => {
144
+ const cellDate = new Date(view.year, index, 1);
145
+ return {
146
+ index,
147
+ label: view.formatters.cell.format(cellDate),
148
+ ariaLabel: view.formatters.name.format(cellDate),
149
+ selected: view.selected !== null && view.selected.getFullYear() === view.year && view.selected.getMonth() === index,
150
+ today: today.getFullYear() === view.year && today.getMonth() === index,
151
+ disabled: monthDisabled(view.year, index, view.min, view.max),
152
+ focusable: index === view.focusMonth,
153
+ };
154
+ });
155
+ }
156
+
157
+ /**
158
+ * The twelve cells of one year page.
159
+ *
160
+ * @param {{ pageStart: number, selected: Date | null, min: Date | null, max: Date | null,
161
+ * focusYear: number, today?: Date }} view
162
+ */
163
+ export function yearCells(view) {
164
+ const thisYear = (view.today ?? new Date()).getFullYear();
165
+ return Array.from({ length: YEARS_PER_PAGE }, (_unused, index) => {
166
+ const year = view.pageStart + index;
167
+ return {
168
+ year,
169
+ selected: view.selected !== null && view.selected.getFullYear() === year,
170
+ today: thisYear === year,
171
+ disabled: yearDisabled(year, view.min, view.max),
172
+ focusable: year === view.focusYear,
173
+ };
174
+ });
175
+ }
176
+
177
+ /**
178
+ * What a key press in the month grid means, with the wrap already applied.
179
+ *
180
+ * `choose` is deliberately not called `select`: whether choosing a month commits a value or walks
181
+ * down into its days is the one thing the two components disagree about, and that decision belongs
182
+ * to them. `none` means the key was not ours, so the caller leaves the event alone.
183
+ *
184
+ * Arrows move one or three months and WRAP inside the year on screen, which is why `index` can come
185
+ * back on the far side without `yearDelta` moving. PageUp and PageDown are the keys that change the
186
+ * year, holding the month still (WAI-ARIA APG).
187
+ *
188
+ * @param {string} key a KeyboardEvent.key
189
+ * @param {number} focusMonth 0-11
190
+ * @returns {{ action: 'focus' | 'choose' | 'close' | 'none', index: number, yearDelta: number }}
191
+ */
192
+ export function monthKeyIntent(key, focusMonth) {
193
+ const focus = (raw, yearDelta) => ({ action: 'focus', index: (raw + 12) % 12, yearDelta });
194
+ switch (key) {
195
+ case 'ArrowLeft': return focus(focusMonth - 1, 0);
196
+ case 'ArrowRight': return focus(focusMonth + 1, 0);
197
+ case 'ArrowUp': return focus(focusMonth - 3, 0);
198
+ case 'ArrowDown': return focus(focusMonth + 3, 0);
199
+ case 'Home': return focus(0, 0);
200
+ case 'End': return focus(11, 0);
201
+ case 'PageUp': return focus(focusMonth, -1);
202
+ case 'PageDown': return focus(focusMonth, 1);
203
+ case 'Enter':
204
+ case ' ': return { action: 'choose', index: focusMonth, yearDelta: 0 };
205
+ case 'Escape': return { action: 'close', index: focusMonth, yearDelta: 0 };
206
+ default: return { action: 'none', index: focusMonth, yearDelta: 0 };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * What a key press in the year grid means. `choose` here is always navigation, in both components:
212
+ * a year is never an answer, so nothing is committed from this grid.
213
+ *
214
+ * @param {string} key a KeyboardEvent.key
215
+ * @param {number} focusYear
216
+ * @returns {{ action: 'focus' | 'choose' | 'close' | 'none', year: number }}
217
+ */
218
+ export function yearKeyIntent(key, focusYear) {
219
+ const focus = (year) => ({ action: 'focus', year });
220
+ switch (key) {
221
+ case 'ArrowLeft': return focus(focusYear - 1);
222
+ case 'ArrowRight': return focus(focusYear + 1);
223
+ case 'ArrowUp': return focus(focusYear - 3);
224
+ case 'ArrowDown': return focus(focusYear + 3);
225
+ case 'Home': return focus(yearPageStart(focusYear));
226
+ case 'End': return focus(yearPageStart(focusYear) + YEARS_PER_PAGE - 1);
227
+ case 'PageUp': return focus(focusYear - YEARS_PER_PAGE);
228
+ case 'PageDown': return focus(focusYear + YEARS_PER_PAGE);
229
+ case 'Enter':
230
+ case ' ': return { action: 'choose', year: focusYear };
231
+ case 'Escape': return { action: 'close', year: focusYear };
232
+ default: return { action: 'none', year: focusYear };
233
+ }
234
+ }