@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,387 @@
1
+ <script lang="ts">
2
+ /* The one format this component speaks, re-exported from the shared core so the name has a single
3
+ home. `FdyDatepicker` cannot use it: its `modelValue` holds `YYYY-MM-DD` or `YYYY-MM` depending
4
+ on `mode`, and a `defineProps<{...}>` cannot carry a union discriminated by another prop. That
5
+ is the whole reason this component exists. */
6
+ export type { FdyMonth } from '../../core/month-grid';
7
+ </script>
8
+
9
+ <script setup lang="ts">
10
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, type ComputedRef, type Ref } from 'vue';
11
+ import { usePopover } from '../usePopover';
12
+ import {
13
+ YEARS_PER_PAGE,
14
+ addMonths,
15
+ monthCells as buildMonthCells,
16
+ monthFormatters,
17
+ monthKeyIntent,
18
+ pageLocale,
19
+ parseISOMonth,
20
+ startOfMonth,
21
+ toISOMonth,
22
+ yearCells as buildYearCells,
23
+ yearKeyIntent,
24
+ yearPageStart,
25
+ yearRangeLabel,
26
+ type FdyMonthCell,
27
+ type FdyMonthFormatters,
28
+ type FdyYearCell,
29
+ } from '../../core/month-grid.js';
30
+
31
+ // A controlled month picker over freeday's `.fdy-datepicker` / `.fdy-cal__*` classes (see
32
+ // src/components/datepicker.css). It renders the same trigger and the same popover the date picker
33
+ // does, and reuses `FdyDatepicker`'s own month and year grids through
34
+ // adapters/core/month-grid.js, so there is one copy of that logic rather than a fifth.
35
+ //
36
+ // It exists for its SIGNATURE. 3.8.0 answered "we need a month" with `mode="month"` on
37
+ // FdyDatepicker, which left `modelValue: string | null` carrying `YYYY-MM-DD` or `YYYY-MM`
38
+ // depending on a different prop. Here the value has one format, there is no mode to read, and the
39
+ // day grid is absent rather than merely unvisited.
40
+ //
41
+ // Month names come from `<html lang>` through Intl, which is the capability `<input type="month">`
42
+ // cannot offer: it names the month in the BROWSER's language.
43
+
44
+ /** Which grid is on screen. There is no level below months here: that is the component. */
45
+ type CalendarLevel = 'months' | 'years';
46
+
47
+ const props = defineProps<{
48
+ /** The chosen month, ISO `YYYY-MM` (`'2026-10'`). `''`/`null` for empty. One format, always. */
49
+ modelValue: string | null;
50
+ /** Earliest selectable month, `YYYY-MM` (a full `YYYY-MM-DD` is read too, as its own month). */
51
+ min?: string;
52
+ /** Latest selectable month. A bound that falls inside a month leaves that month pickable. */
53
+ max?: string;
54
+ /** BCP-47 tag for the month names. Defaults to the page's `<html lang>`, then `en`. */
55
+ locale?: string;
56
+ placeholder?: string;
57
+ disabled?: boolean;
58
+ /** Locked/view mode: stays focusable and shows its month, but can't be opened, cleared, or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
59
+ readonly?: boolean;
60
+ invalid?: boolean;
61
+ describedby?: string;
62
+ id?: string;
63
+ ariaLabelledby?: string;
64
+ /** Show a clear (×) button in the trigger when a month is set, so an optional month can be unset. Emits `''` via update:modelValue + change. Off by default. */
65
+ clearable?: boolean;
66
+ /** aria-label for the clear button (when `clearable`). Default 'Clear month'. */
67
+ clearLabel?: string;
68
+ /** aria-labels for the year arrows shown in the month grid. Defaults 'Previous year' / 'Next year'. */
69
+ prevYearLabel?: string;
70
+ nextYearLabel?: string;
71
+ /** aria-label for the title button that drills from the month grid to the year grid. Default 'Choose year'. */
72
+ chooseYearLabel?: string;
73
+ /** aria-labels for the page arrows shown in the year grid. Defaults 'Previous years' / 'Next years'. */
74
+ prevYearsLabel?: string;
75
+ nextYearsLabel?: string;
76
+ }>();
77
+
78
+ const emit = defineEmits<{
79
+ 'update:modelValue': [value: string];
80
+ change: [value: string];
81
+ }>();
82
+
83
+ const baseId: string = useId();
84
+ const triggerId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-trigger`);
85
+ const titleId: string = `${baseId}-title`;
86
+
87
+ const rootEl: Ref<HTMLDivElement | null> = ref(null);
88
+ const triggerEl: Ref<HTMLButtonElement | null> = ref(null);
89
+ const panelEl: Ref<HTMLDivElement | null> = ref(null);
90
+
91
+ const open: Ref<boolean> = ref(false);
92
+
93
+ // Render the panel in the top layer (popover) so it never gets clipped by a card or scroll
94
+ // container; positions against the trigger. Same plumbing every kit dropdown uses.
95
+ usePopover(panelEl, triggerEl, open);
96
+
97
+ /** Resolved once here so every formatter below reads the same tag. */
98
+ const activeLocale: ComputedRef<string> = computed((): string => props.locale ?? pageLocale());
99
+ const formatters: ComputedRef<FdyMonthFormatters> = computed(
100
+ (): FdyMonthFormatters => monthFormatters(activeLocale.value),
101
+ );
102
+
103
+ const selectedDate: ComputedRef<Date | null> = computed((): Date | null => parseISOMonth(props.modelValue));
104
+ const minDate: ComputedRef<Date | null> = computed((): Date | null => parseISOMonth(props.min));
105
+ const maxDate: ComputedRef<Date | null> = computed((): Date | null => parseISOMonth(props.max));
106
+ const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
107
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
108
+ const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
109
+ const displayPlaceholder: ComputedRef<string> = computed((): string => props.placeholder ?? 'Select month');
110
+ const showClear: ComputedRef<boolean> = computed(
111
+ (): boolean => props.clearable === true && selectedDate.value !== null && isDisabled.value === false && isReadonly.value === false,
112
+ );
113
+ const clearLabelText: ComputedRef<string> = computed((): string => props.clearLabel ?? 'Clear month');
114
+ const chooseYearLabelText: ComputedRef<string> = computed((): string => props.chooseYearLabel ?? 'Choose year');
115
+ const prevYearLabelText: ComputedRef<string> = computed((): string => props.prevYearLabel ?? 'Previous year');
116
+ const nextYearLabelText: ComputedRef<string> = computed((): string => props.nextYearLabel ?? 'Next year');
117
+ const prevYearsLabelText: ComputedRef<string> = computed((): string => props.prevYearsLabel ?? 'Previous years');
118
+ const nextYearsLabelText: ComputedRef<string> = computed((): string => props.nextYearsLabel ?? 'Next years');
119
+
120
+ /** The year the month grid shows, held as its January so the shared helpers can take a Date. */
121
+ const view: Ref<Date> = ref(startOfMonth(selectedDate.value ?? new Date()));
122
+ const level: Ref<CalendarLevel> = ref('months');
123
+ const focusMonth: Ref<number> = ref(0);
124
+ const focusYear: Ref<number> = ref(0);
125
+
126
+ /* The month named in the page's language, through the same formatter the cells use. This is the
127
+ capability `<input type="month">` cannot be pushed into: it reads the browser. */
128
+ const displayValue: ComputedRef<string> = computed((): string => {
129
+ if (selectedDate.value === null) return displayPlaceholder.value;
130
+ return formatters.value.name.format(selectedDate.value);
131
+ });
132
+
133
+ const monthCells: ComputedRef<FdyMonthCell[]> = computed((): FdyMonthCell[] => buildMonthCells({
134
+ year: view.value.getFullYear(),
135
+ selected: selectedDate.value,
136
+ min: minDate.value,
137
+ max: maxDate.value,
138
+ focusMonth: focusMonth.value,
139
+ formatters: formatters.value,
140
+ }));
141
+
142
+ const yearPage: ComputedRef<number> = computed((): number => yearPageStart(focusYear.value));
143
+
144
+ const yearCells: ComputedRef<FdyYearCell[]> = computed((): FdyYearCell[] => buildYearCells({
145
+ pageStart: yearPage.value,
146
+ selected: selectedDate.value,
147
+ min: minDate.value,
148
+ max: maxDate.value,
149
+ focusYear: focusYear.value,
150
+ }));
151
+
152
+ const yearRangeText: ComputedRef<string> = computed((): string => yearRangeLabel(yearPage.value));
153
+
154
+ function focusMonthCell(): void {
155
+ const cell: HTMLElement | null = panelEl.value?.querySelector('.fdy-cal__month[tabindex="0"]') ?? null;
156
+ cell?.focus();
157
+ }
158
+
159
+ function focusYearCell(): void {
160
+ const cell: HTMLElement | null = panelEl.value?.querySelector('.fdy-cal__year[tabindex="0"]') ?? null;
161
+ cell?.focus();
162
+ }
163
+
164
+ /** `index` is already 0-11: the wrap lives in `monthKeyIntent`, so it is applied in one place. */
165
+ function moveMonthFocus(index: number, yearDelta: number): void {
166
+ if (yearDelta !== 0) view.value = addMonths(view.value, yearDelta * 12);
167
+ focusMonth.value = index;
168
+ void nextTick((): void => focusMonthCell());
169
+ }
170
+
171
+ /* A month is the ANSWER here. In FdyDatepicker the same click walks down into the month's days,
172
+ which is the one thing the two components do differently with the shared grid. */
173
+ function pickMonth(index: number): void {
174
+ const year: number = view.value.getFullYear();
175
+ if (monthCells.value[index].disabled) return;
176
+ const first: Date = new Date(year, index, 1);
177
+ focusMonth.value = index;
178
+ view.value = first;
179
+ const iso: string = toISOMonth(first);
180
+ emit('update:modelValue', iso);
181
+ emit('change', iso);
182
+ closePanel(true);
183
+ }
184
+
185
+ function openYearGrid(): void {
186
+ focusYear.value = view.value.getFullYear();
187
+ level.value = 'years';
188
+ void nextTick((): void => focusYearCell());
189
+ }
190
+
191
+ function openMonthGrid(): void {
192
+ focusMonth.value = view.value.getMonth();
193
+ level.value = 'months';
194
+ void nextTick((): void => focusMonthCell());
195
+ }
196
+
197
+ /* Picking a year is NAVIGATION and commits nothing: it drops to that year's months, where the
198
+ answer is given. Without this level, reaching a month four years back is four arrow presses per
199
+ year and no pointer route at all. */
200
+ function openYear(year: number): void {
201
+ view.value = new Date(year, view.value.getMonth(), 1);
202
+ openMonthGrid();
203
+ }
204
+
205
+ function moveYearFocus(year: number): void {
206
+ focusYear.value = year;
207
+ view.value = new Date(year, view.value.getMonth(), 1);
208
+ void nextTick((): void => focusYearCell());
209
+ }
210
+
211
+ function onMonthKeydown(e: KeyboardEvent): void {
212
+ const intent = monthKeyIntent(e.key, focusMonth.value);
213
+ if (intent.action === 'none') return;
214
+ e.preventDefault();
215
+ if (intent.action === 'focus') moveMonthFocus(intent.index, intent.yearDelta);
216
+ else if (intent.action === 'choose') pickMonth(intent.index);
217
+ else closePanel(true);
218
+ }
219
+
220
+ function onYearKeydown(e: KeyboardEvent): void {
221
+ const intent = yearKeyIntent(e.key, focusYear.value);
222
+ if (intent.action === 'none') return;
223
+ /* stopPropagation as well, matching FdyDatepicker's year grid: the panel is in the top layer and
224
+ a bubbling Escape would otherwise be read a second time by whatever is behind it. */
225
+ e.preventDefault();
226
+ e.stopPropagation();
227
+ if (intent.action === 'focus') moveYearFocus(intent.year);
228
+ else if (intent.action === 'choose') openYear(intent.year);
229
+ else closePanel(true);
230
+ }
231
+
232
+ // Clear (reset to empty). Emits '', parseISOMonth('') is null, so the placeholder shows again.
233
+ function clearValue(): void {
234
+ emit('update:modelValue', '');
235
+ emit('change', '');
236
+ open.value = false;
237
+ triggerEl.value?.focus();
238
+ }
239
+
240
+ function openPanel(): void {
241
+ if (isDisabled.value || isReadonly.value || open.value) return;
242
+ view.value = startOfMonth(selectedDate.value ?? view.value);
243
+ /* Reopening starts on months. Without this a picker that drilled to the year grid and was
244
+ dismissed would open on years next time, and a keyboard user would land on a grid that cannot
245
+ answer. */
246
+ level.value = 'months';
247
+ focusMonth.value = view.value.getMonth();
248
+ open.value = true;
249
+ void nextTick((): void => focusMonthCell());
250
+ }
251
+
252
+ function closePanel(returnFocus: boolean): void {
253
+ if (!open.value) return;
254
+ open.value = false;
255
+ if (returnFocus) triggerEl.value?.focus();
256
+ }
257
+
258
+ function toggle(): void {
259
+ if (open.value) closePanel(false);
260
+ else openPanel();
261
+ }
262
+
263
+ // Close when a pointer lands outside the whole control.
264
+ function onDocPointerDown(e: MouseEvent): void {
265
+ const target: EventTarget | null = e.target;
266
+ if (rootEl.value !== null && target instanceof Node && !rootEl.value.contains(target)) {
267
+ closePanel(false);
268
+ }
269
+ }
270
+
271
+ /* A null relatedTarget means focus fell to <body>, which is what happens when the element the user
272
+ * just pressed is REMOVED by the click it triggered (drilling into the year grid replaces the grid,
273
+ * and with it the cell that had focus). That is not focus leaving the control, and closing on it
274
+ * made the panel vanish mid-navigation. A pointer that really lands outside is handled by the
275
+ * mousedown listener, which is the reliable path for that case. */
276
+ function onFocusout(e: FocusEvent): void {
277
+ const next: EventTarget | null = e.relatedTarget;
278
+ if (next === null) return;
279
+ if (rootEl.value !== null && !(next instanceof Node && rootEl.value.contains(next))) {
280
+ closePanel(false);
281
+ }
282
+ }
283
+
284
+ onMounted((): void => {
285
+ document.addEventListener('mousedown', onDocPointerDown);
286
+ });
287
+ onBeforeUnmount((): void => {
288
+ document.removeEventListener('mousedown', onDocPointerDown);
289
+ });
290
+ </script>
291
+
292
+ <template>
293
+ <div ref="rootEl" class="fdy-datepicker" :class="{ 'fdy-datepicker--error': isInvalid }" @focusout="onFocusout">
294
+ <button
295
+ :id="triggerId"
296
+ ref="triggerEl"
297
+ type="button"
298
+ class="fdy-datepicker__trigger"
299
+ :class="{ 'is-open': open }"
300
+ aria-haspopup="dialog"
301
+ :aria-expanded="open"
302
+ :aria-labelledby="ariaLabelledby"
303
+ :aria-invalid="isInvalid ? 'true' : undefined"
304
+ :aria-readonly="isReadonly ? 'true' : undefined"
305
+ :aria-describedby="describedby"
306
+ :disabled="isDisabled"
307
+ @click="toggle"
308
+ >
309
+ <span class="fdy-datepicker__value" :class="{ 'fdy-datepicker__value--placeholder': selectedDate === null }">{{ displayValue }}</span>
310
+ <span class="fdy-datepicker__icon" :class="{ 'is-hidden': showClear }" aria-hidden="true">
311
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
312
+ <rect x="3" y="4" width="18" height="18" rx="2"></rect>
313
+ <path d="M16 2v4M8 2v4M3 10h18"></path>
314
+ </svg>
315
+ </span>
316
+ </button>
317
+ <button
318
+ v-if="showClear"
319
+ type="button"
320
+ class="fdy-datepicker__clear"
321
+ :aria-label="clearLabelText"
322
+ @mousedown.prevent
323
+ @click="clearValue"
324
+ >
325
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
326
+ <path d="M18 6 6 18M6 6l12 12"></path>
327
+ </svg>
328
+ </button>
329
+
330
+ <div ref="panelEl" class="fdy-datepicker__panel" role="dialog" aria-modal="false" popover="manual" :aria-labelledby="titleId" :hidden="!open">
331
+ <!-- ONE head for both levels on purpose: swapping it with v-if destroys the very button the
332
+ user just pressed, focus falls to <body>, and the panel's own focusout handler closes it.
333
+ The labels and handlers switch; the elements do not. -->
334
+ <div class="fdy-cal__head">
335
+ <button
336
+ type="button"
337
+ class="fdy-cal__nav"
338
+ :aria-label="level === 'years' ? prevYearsLabelText : prevYearLabelText"
339
+ @click="level === 'years' ? moveYearFocus(focusYear - YEARS_PER_PAGE) : moveMonthFocus(focusMonth, -1)"
340
+ >‹</button>
341
+ <button
342
+ :id="titleId"
343
+ type="button"
344
+ class="fdy-cal__title"
345
+ :aria-label="level === 'years' ? undefined : chooseYearLabelText"
346
+ @click="level === 'years' ? openMonthGrid() : openYearGrid()"
347
+ >{{ level === 'years' ? yearRangeText : String(view.getFullYear()) }}</button>
348
+ <button
349
+ type="button"
350
+ class="fdy-cal__nav"
351
+ :aria-label="level === 'years' ? nextYearsLabelText : nextYearLabelText"
352
+ @click="level === 'years' ? moveYearFocus(focusYear + YEARS_PER_PAGE) : moveMonthFocus(focusMonth, 1)"
353
+ >›</button>
354
+ </div>
355
+ <div v-if="level === 'years'" class="fdy-cal__grid fdy-cal__grid--years" role="grid" :aria-labelledby="titleId" @keydown="onYearKeydown">
356
+ <button
357
+ v-for="cell in yearCells"
358
+ :key="cell.year"
359
+ type="button"
360
+ class="fdy-cal__year"
361
+ :class="{ 'is-today': cell.today, 'is-selected': cell.selected }"
362
+ role="gridcell"
363
+ :aria-label="String(cell.year)"
364
+ :aria-selected="cell.selected ? 'true' : undefined"
365
+ :disabled="cell.disabled"
366
+ :tabindex="cell.focusable ? 0 : -1"
367
+ @click="openYear(cell.year)"
368
+ >{{ cell.year }}</button>
369
+ </div>
370
+ <div v-else class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
371
+ <button
372
+ v-for="cell in monthCells"
373
+ :key="cell.index"
374
+ type="button"
375
+ class="fdy-cal__month"
376
+ :class="{ 'is-today': cell.today, 'is-selected': cell.selected }"
377
+ role="gridcell"
378
+ :aria-label="cell.ariaLabel"
379
+ :aria-selected="cell.selected ? 'true' : undefined"
380
+ :disabled="cell.disabled"
381
+ :tabindex="cell.focusable ? 0 : -1"
382
+ @click="pickMonth(cell.index)"
383
+ >{{ cell.label }}</button>
384
+ </div>
385
+ </div>
386
+ </div>
387
+ </template>
@@ -69,6 +69,13 @@ const props = withDefaults(defineProps<{
69
69
  pageSizes?: readonly number[];
70
70
  pageIndex?: number;
71
71
  loading?: boolean;
72
+ /** The loading row's text, rendered by this table rather than by the footer. Default `Loading…`.
73
+ * Blazor has carried `LoadingText` all along while the two script adapters wrote the word into
74
+ * the template, so a table in any other language could translate its empty row and not the row
75
+ * right beside it. Defaulted here, like `emptyText` and the four selection strings, and
76
+ * unlike the six footer strings further down, which the footer defaults because the footer is
77
+ * what renders them. */
78
+ loadingText?: string;
72
79
  emptyText?: string;
73
80
  ariaLabel?: string;
74
81
  /** Opt in to row activation: rows become focusable and emit `row-activate` on click/Enter/Space. */
@@ -96,6 +103,23 @@ const props = withDefaults(defineProps<{
96
103
  selectRowLabel?: string;
97
104
  /** Accessible name of the bulk bar region. Default `Bulk actions`. */
98
105
  bulkLabel?: string;
106
+ /* The footer's six strings, forwarded to the `FdyTableFooter` this table draws itself. 3.8.0 gave
107
+ * the footer those props and this table passed none of them on, so the hatch reached every footer
108
+ * except the one almost every screen uses, and the documented way out was to switch the footer off
109
+ * and render it yourself: a door that opens only if you stop using the table. Flat props, with the
110
+ * footer's own names, so moving between the two routes moves a prop rather than reshaping it. */
111
+ /** Footer range line, `{from}` `{to}` `{total}` substituted. Default `Showing {from}–{to} of {total}`. */
112
+ infoText?: string;
113
+ /** Footer: the visible word beside the rows-per-page control, which also names it. Default `Rows`. */
114
+ pageSizeLabel?: string;
115
+ /** Footer: accessible name of the pager landmark. Default `Pagination`. */
116
+ paginationLabel?: string;
117
+ /** Footer: accessible name of the previous-page arrow. Default `Previous page`. */
118
+ prevLabel?: string;
119
+ /** Footer: accessible name of the next-page arrow. Default `Next page`. */
120
+ nextLabel?: string;
121
+ /** Footer: accessible name of a numbered page button. `{n}` substituted. Default `Go to page {n}`. */
122
+ pageLabel?: string;
99
123
  }>(), { pager: true });
100
124
 
101
125
  const emit = defineEmits<{
@@ -418,7 +442,7 @@ const colSpan: ComputedRef<number> = computed((): number =>
418
442
  </thead>
419
443
  <tbody>
420
444
  <tr v-if="loading">
421
- <td :colspan="colSpan" class="fdy-table__state" role="status">Loading…</td>
445
+ <td :colspan="colSpan" class="fdy-table__state" role="status">{{ loadingText ?? 'Loading…' }}</td>
422
446
  </tr>
423
447
  <tr v-else-if="displayRows.length === 0">
424
448
  <td :colspan="colSpan" class="fdy-table__state">
@@ -461,10 +485,18 @@ const colSpan: ComputedRef<number> = computed((): number =>
461
485
  </table>
462
486
  </div>
463
487
 
488
+ <!-- The six strings are passed as given, `undefined` included: every default lives in the
489
+ footer, so an omitted prop here is the footer's English and not a second copy of it. -->
464
490
  <FdyTableFooter
465
491
  v-if="pager !== false"
466
492
  :page="footerPage"
467
493
  :page-sizes="pageSizes"
494
+ :info-text="infoText"
495
+ :page-size-label="pageSizeLabel"
496
+ :pagination-label="paginationLabel"
497
+ :prev-label="prevLabel"
498
+ :next-label="nextLabel"
499
+ :page-label="pageLabel"
468
500
  @update:page="onFooterPage"
469
501
  />
470
502
  </div>
@@ -28,7 +28,19 @@ const triggerEl: Ref<HTMLButtonElement | null> = ref(null);
28
28
  const panelEl: Ref<HTMLDivElement | null> = ref(null);
29
29
  const open: Ref<boolean> = ref(false);
30
30
 
31
- usePopover(panelEl, triggerEl, open);
31
+ /* Always end-aligned, and not an option the parent can set: the funnel is a 24px button in a
32
+ column header while the panel runs past 200px, so lining up their LEFT edges aligns the panel
33
+ with nothing anybody can see. In the rightmost column, the one this was reported from, the panel
34
+ ran past the page and the viewport clamp parked it 4px off the screen edge. A prop no caller
35
+ would ever set to 'start' is a prop that should not exist. The clamp still catches the leftmost
36
+ column, where an end-aligned panel would hang off the other edge.
37
+ `matchWidth:false` for the other half of the same thought. Every other typed dropdown wants a
38
+ panel at least as wide as the control it hangs off; this one hangs off a 24px icon, and that
39
+ floor is written inline, where it outranks the `min-width:14rem` `.fdy-filter` asks for: the
40
+ panel measured 224px on the raw path and 206 here, and an enum column with short labels would
41
+ have gone narrower still. The choice sits in this component rather than in the hook, because a
42
+ hook that guessed it from the trigger's size would be guessing for the other nine callers too. */
43
+ usePopover(panelEl, triggerEl, open, 'end', false);
32
44
 
33
45
  const active: ComputedRef<boolean> = computed((): boolean => isFilterActive(props.filter));
34
46
 
@@ -25,6 +25,28 @@ const props = defineProps<{
25
25
  * holds the row you were looking at.
26
26
  */
27
27
  pageSizes?: readonly number[];
28
+ /* The strings, six of them, one per thing this band says. The raw path has had a hatch since
29
+ * 1.39.0 (`data-fdy-text-<key>` read through `textOf()`) and this path had none, so an app that
30
+ * does not speak English could only rewrite the whole band by hand; one shipped eighty one lines
31
+ * of its own markup to change five words. Names follow the enhancer's `TEXT` keys where a key
32
+ * exists, `info`, `prev`, `next`, so one string is not two names across the two paths. The other
33
+ * three have no twin because on the raw path that markup is the app's own. Defaults stay English
34
+ * on every path, which COMPONENTS.md promises and the suite enforces. */
35
+ /**
36
+ * The range line. `{from}`, `{to}` and `{total}` are substituted, every occurrence, as `textOf()`
37
+ * does. Default `Showing {from}–{to} of {total}`, the enhancer's `info`.
38
+ */
39
+ infoText?: string;
40
+ /** The visible word beside the rows-per-page control, which also names it. Default `Rows`. */
41
+ pageSizeLabel?: string;
42
+ /** Accessible name of the pager landmark. Default `Pagination`. */
43
+ paginationLabel?: string;
44
+ /** Accessible name of the previous-page arrow. Default `Previous page`. */
45
+ prevLabel?: string;
46
+ /** Accessible name of the next-page arrow. Default `Next page`. */
47
+ nextLabel?: string;
48
+ /** Accessible name of a numbered page button. `{n}` substituted. Default `Go to page {n}`. */
49
+ pageLabel?: string;
28
50
  }>();
29
51
 
30
52
  const emit = defineEmits<{ 'update:page': [page: FdyPageState] }>();
@@ -47,6 +69,13 @@ const pages: ComputedRef<Array<number | 'ellipsis'>> = computed((): Array<number
47
69
  pageWindow(currentPage1.value, totalPages.value),
48
70
  );
49
71
 
72
+ const infoLine: ComputedRef<string> = computed((): string =>
73
+ (props.infoText ?? 'Showing {from}–{to} of {total}')
74
+ .replace(/\{from\}/g, String(rangeFrom.value))
75
+ .replace(/\{to\}/g, String(rangeTo.value))
76
+ .replace(/\{total\}/g, String(props.page.total)),
77
+ );
78
+
50
79
  const hasPager: ComputedRef<boolean> = computed((): boolean => props.page.size > 0 && totalPages.value > 1);
51
80
  const sizes: ComputedRef<readonly number[]> = computed((): readonly number[] => props.pageSizes ?? []);
52
81
  /* Just the number. The control sits beside a label reading "Rows", so "20 per page" states the same
@@ -58,6 +87,10 @@ const sizeOptions: ComputedRef<Array<{ value: string; label: string }>> = comput
58
87
  * the whole band in that case, and this is where that decision now lives. */
59
88
  const visible: ComputedRef<boolean> = computed((): boolean => hasPager.value || sizes.value.length > 0);
60
89
 
90
+ function pageAriaLabel(page1: number): string {
91
+ return (props.pageLabel ?? 'Go to page {n}').replace(/\{n\}/g, String(page1));
92
+ }
93
+
61
94
  function goTo(page1: number): void {
62
95
  const clamped: number = Math.min(Math.max(1, page1), totalPages.value);
63
96
  emit('update:page', { index: clamped - 1, size: props.page.size, total: props.page.total });
@@ -76,10 +109,10 @@ function onSize(value: string): void {
76
109
 
77
110
  <template>
78
111
  <div v-if="visible" class="fdy-table-footer">
79
- <span class="fdy-table-footer__info">Showing {{ rangeFrom }}–{{ rangeTo }} of {{ page.total }}</span>
112
+ <span class="fdy-table-footer__info">{{ infoLine }}</span>
80
113
 
81
114
  <div v-if="sizes.length > 0" class="fdy-table-footer__size">
82
- <span :id="sizeLabelId">Rows</span>
115
+ <span :id="sizeLabelId">{{ pageSizeLabel ?? 'Rows' }}</span>
83
116
  <!-- The kit's own listbox, not a native <select>: an OS menu is unthemeable, and on macOS it
84
117
  drops a dark panel into a light page. FdyCombo's own header says exactly that. -->
85
118
  <FdyCombo
@@ -90,13 +123,13 @@ function onSize(value: string): void {
90
123
  />
91
124
  </div>
92
125
 
93
- <nav v-if="hasPager" class="fdy-pagination" aria-label="Pagination">
126
+ <nav v-if="hasPager" class="fdy-pagination" :aria-label="paginationLabel ?? 'Pagination'">
94
127
  <ul class="fdy-pagination__list">
95
128
  <li>
96
129
  <button
97
130
  type="button"
98
131
  class="fdy-pagination__link"
99
- aria-label="Previous page"
132
+ :aria-label="prevLabel ?? 'Previous page'"
100
133
  :disabled="currentPage1 === 1"
101
134
  @click="goTo(currentPage1 - 1)"
102
135
  >‹</button>
@@ -104,13 +137,13 @@ function onSize(value: string): void {
104
137
  <li v-for="(p, i) in pages" :key="typeof p === 'number' ? p : `gap-${i}`">
105
138
  <span v-if="p === 'ellipsis'" class="fdy-pagination__ellipsis">…</span>
106
139
  <span v-else-if="p === currentPage1" class="fdy-pagination__link" aria-current="page">{{ p }}</span>
107
- <button v-else type="button" class="fdy-pagination__link" :aria-label="`Go to page ${p}`" @click="goTo(p)">{{ p }}</button>
140
+ <button v-else type="button" class="fdy-pagination__link" :aria-label="pageAriaLabel(p)" @click="goTo(p)">{{ p }}</button>
108
141
  </li>
109
142
  <li>
110
143
  <button
111
144
  type="button"
112
145
  class="fdy-pagination__link"
113
- aria-label="Next page"
146
+ :aria-label="nextLabel ?? 'Next page'"
114
147
  :disabled="currentPage1 === totalPages"
115
148
  @click="goTo(currentPage1 + 1)"
116
149
  >›</button>
@@ -12,6 +12,16 @@ export interface UseFreedayReturn {
12
12
  */
13
13
  export declare function useFreeday(rootRef?: Ref<HTMLElement | null>): UseFreedayReturn;
14
14
 
15
+ /**
16
+ * Which of the panel's edges lines up with the trigger's. `'start'` (the default) puts the panel's
17
+ * LEFT edge on the trigger's left edge, right for an input-shaped dropdown; `'end'` puts its RIGHT
18
+ * edge on the trigger's right edge, which is what a panel much wider than its trigger needs when
19
+ * the trigger sits in a right-hand gutter or a table header. Either way the panel is kept inside
20
+ * the viewport, so `'end'` near the left edge slides right instead of hanging off it. Same two
21
+ * names as the raw `FreedayPopover.place()`.
22
+ */
23
+ export type FdyPopoverAlign = 'start' | 'end';
24
+
15
25
  /**
16
26
  * Anchor a dropdown panel to its trigger in the top layer, so it escapes any ancestor overflow clip
17
27
  *, `.fdy-card` is `overflow:hidden`, so a panel positioned inside one is otherwise cut at the card's
@@ -23,10 +33,16 @@ export declare function usePopover(
23
33
  panelRef: Ref<HTMLElement | null>,
24
34
  triggerRef: Ref<HTMLElement | null>,
25
35
  open: Ref<boolean>,
36
+ align?: FdyPopoverAlign,
37
+ matchWidth?: boolean,
26
38
  ): void;
27
39
 
28
40
  export { default as FdyCombo } from './components/FdyCombo.vue';
29
41
  export { default as FdyDatepicker } from './components/FdyDatepicker.vue';
42
+ /** A picker that answers with a whole month, `YYYY-MM`. Replaces `FdyDatepicker`'s deprecated
43
+ * `mode="month"`, where one prop carried two formats and another prop chose between them. */
44
+ export { default as FdyMonthpicker } from './components/FdyMonthpicker.vue';
45
+ export type { FdyMonth } from '../core/month-grid';
30
46
  export { default as FdyDateRange } from './components/FdyDateRange.vue';
31
47
  export type { DateRangeValue } from './components/FdyDateRange.vue';
32
48
  export { default as FdyAutocomplete } from './components/FdyAutocomplete.vue';
@@ -2,6 +2,7 @@ export { useFreeday } from './useFreeday.js';
2
2
  export { usePopover } from './usePopover.ts';
3
3
  export { default as FdyCombo } from './components/FdyCombo.vue';
4
4
  export { default as FdyDatepicker } from './components/FdyDatepicker.vue';
5
+ export { default as FdyMonthpicker } from './components/FdyMonthpicker.vue';
5
6
  export { default as FdyDateRange } from './components/FdyDateRange.vue';
6
7
  export { default as FdyAutocomplete } from './components/FdyAutocomplete.vue';
7
8
  export { default as FdyCascade } from './components/FdyCascade.vue';