@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
@@ -3,26 +3,58 @@ import { watch, onBeforeUnmount, type Ref } from 'vue';
3
3
  // Vue equivalent of the vanilla freeday-popover.js helper. Lifts a dropdown panel into the top
4
4
  // layer via the native Popover API so it escapes any ancestor overflow clip (a card, a scroll
5
5
  // container), then positions it `fixed` against its trigger (flip above when there's no room
6
- // below; match the trigger width). The panel stays in the component's DOM, so focus / outside-
7
- // click / ARIA are untouched. Degrades to the panel's own [hidden]/absolute CSS where the
8
- // Popover API is unavailable. Drive it with the component's existing `open` ref.
6
+ // below; match the trigger width; line either edge up with the trigger's). The panel stays in
7
+ // the component's DOM, so focus / outside-click / ARIA are untouched. Degrades to the panel's own
8
+ // [hidden]/absolute CSS where the Popover API is unavailable. Drive it with the component's
9
+ // existing `open` ref.
9
10
 
10
11
  interface PopoverElement extends HTMLElement {
11
12
  showPopover(): void;
12
13
  hidePopover(): void;
13
14
  }
14
15
 
16
+ /**
17
+ * Which of the panel's edges lines up with the trigger's. `'start'` (the default everywhere it is
18
+ * omitted) puts the panel's LEFT edge on the trigger's left edge, right for an input-shaped
19
+ * dropdown. `'end'` puts its RIGHT edge on the trigger's right edge, which is what a panel much
20
+ * wider than its trigger needs when the trigger sits in a right-hand gutter or a table header.
21
+ * Same two names as the raw path's `place()`, so one concept does not grow a second vocabulary.
22
+ */
23
+ export type FdyPopoverAlign = 'start' | 'end';
24
+
15
25
  const GAP = 4; // ~ --space-1
16
26
  const supported: boolean =
17
27
  typeof HTMLElement !== 'undefined'
18
28
  && typeof (HTMLElement.prototype as Partial<PopoverElement>).showPopover === 'function';
19
29
 
20
- function place(panel: HTMLElement, trigger: HTMLElement): void {
21
- const r: DOMRect = trigger.getBoundingClientRect();
30
+ function place(
31
+ panel: HTMLElement,
32
+ trigger: HTMLElement,
33
+ align: FdyPopoverAlign | undefined,
34
+ matchWidth: boolean | undefined,
35
+ ): void {
36
+ /* Take the panel out of the flow BEFORE measuring anything. Until this runs the panel is still
37
+ * `position:absolute` with `top:calc(100% + …)` from its component's stylesheet, an author rule
38
+ * outranks the popover UA style, and in the top layer that `100%` resolves against the viewport.
39
+ * So a panel that has just been shown sits one viewport height down the page and drags the
40
+ * document's scrollable overflow with it. Where scrollbars take layout space (every Windows and
41
+ * Linux browser; macOS overlay scrollbars hide it) the document gains a classic scrollbar for the
42
+ * duration, documentElement.clientWidth drops by its width, and the page relays out that much
43
+ * narrower. A trigger rect read in that instant belongs to a layout the next three lines undo:
44
+ * measured 741 instead of 756 for a combo in a 24px gutter, a scrollbar's width to the left. */
22
45
  panel.style.position = 'fixed';
23
46
  panel.style.margin = '0';
24
47
  panel.style.inset = 'auto';
25
- panel.style.minWidth = `${r.width}px`;
48
+ const r: DOMRect = trigger.getBoundingClientRect();
49
+ /* The trigger's width as a FLOOR, and `matchWidth !== false` rather than a truthiness test, the
50
+ same spelling the raw `place()` uses. It is what keeps the four input-shaped panels looking
51
+ like one control with its list hanging off it, and it is not cosmetic: `.fdy-combo__listbox`,
52
+ `.fdy-autocomplete__listbox` and `.fdy-cascade__panel` are `min-width:100%`, and once the panel
53
+ is `fixed` in the top layer that 100% is the VIEWPORT, so without this line a 220px combo opens
54
+ a 1000px listbox. A panel whose stylesheet asks for a width of its own opts out instead:
55
+ `.fdy-filter` wants `min-width:14rem` and hangs off a 24px funnel, and an inline floor of 24px
56
+ outranks the rule, which left the typed filter panel 206px wide against the 224 it asked for. */
57
+ if (matchWidth !== false) panel.style.minWidth = `${r.width}px`;
26
58
  const ph: number = panel.offsetHeight;
27
59
  const pw: number = panel.offsetWidth;
28
60
  const vw: number = document.documentElement.clientWidth;
@@ -30,8 +62,13 @@ function place(panel: HTMLElement, trigger: HTMLElement): void {
30
62
  const below: number = vh - r.bottom - GAP;
31
63
  const above: number = r.top - GAP;
32
64
  const top: number = ph <= below || below >= above ? r.bottom + GAP : Math.max(GAP, r.top - GAP - ph);
33
- let left: number = r.left;
34
- if (left + pw > vw - GAP) left = Math.max(GAP, vw - GAP - pw);
65
+ let left: number = align === 'end' ? r.right - pw : r.left;
66
+ /* Clamped on BOTH sides, and that is not belt-and-braces: the first line keeps a start-aligned
67
+ panel on screen, the second keeps an end-aligned one on screen when the panel is wider than the
68
+ room to the left of its trigger. Keeping only one of them trades an overflow on one edge for an
69
+ overflow on the other. */
70
+ if (left + pw > vw - GAP) left = vw - GAP - pw;
71
+ if (left < GAP) left = GAP;
35
72
  panel.style.top = `${Math.round(top)}px`;
36
73
  panel.style.left = `${Math.round(left)}px`;
37
74
  }
@@ -39,16 +76,22 @@ function place(panel: HTMLElement, trigger: HTMLElement): void {
39
76
  /**
40
77
  * Anchor a dropdown `panelRef` to `triggerRef` in the top layer while `open` is true.
41
78
  * The panel element must carry `popover="manual"` in the template (ignored where unsupported).
79
+ * `align` and `matchWidth` are captured once, at setup; omitting them means `'start'` and `true`,
80
+ * which is what nine of the kit's ten typed dropdowns want. Both are plain values and not an
81
+ * options object, so the React port can list them in an effect's dependencies without a caller
82
+ * having to memoise anything, and the two ports keep one signature.
42
83
  */
43
84
  export function usePopover(
44
85
  panelRef: Ref<HTMLElement | null>,
45
86
  triggerRef: Ref<HTMLElement | null>,
46
87
  open: Ref<boolean>,
88
+ align?: FdyPopoverAlign,
89
+ matchWidth?: boolean,
47
90
  ): void {
48
91
  function reposition(): void {
49
92
  const panel: PopoverElement | null = panelRef.value as PopoverElement | null;
50
93
  const trigger: HTMLElement | null = triggerRef.value;
51
- if (supported && panel !== null && trigger !== null && panel.matches(':popover-open')) place(panel, trigger);
94
+ if (supported && panel !== null && trigger !== null && panel.matches(':popover-open')) place(panel, trigger, align, matchWidth);
52
95
  }
53
96
 
54
97
  watch(
@@ -59,7 +102,7 @@ export function usePopover(
59
102
  if (!supported || panel === null || trigger === null) return;
60
103
  if (isOpen) {
61
104
  if (!panel.matches(':popover-open')) panel.showPopover();
62
- place(panel, trigger);
105
+ place(panel, trigger, align, matchWidth);
63
106
  window.addEventListener('scroll', reposition, true);
64
107
  window.addEventListener('resize', reposition);
65
108
  } else {
@@ -11,6 +11,8 @@
11
11
  * <div data-fdy-datepicker data-role="to" data-placeholder="To"></div>
12
12
  * </div>
13
13
  * The range links the two: the end can never precede the start (out-of-range days disable).
14
+ * - Month: <div data-fdy-datepicker data-mode="month" data-value="2026-10"></div>
15
+ * The calendar opens ON the month grid and stops there; the value is "YYYY-MM".
14
16
  *
15
17
  * Emits a bubbling "fdy-datepicker-change" CustomEvent on the wrapper: detail {value, date}.
16
18
  * Keyboard in the grid: ←/→ ±1 day, ↑/↓ ±1 week, Home/End week edge, PageUp/Down ±month,
@@ -39,6 +41,11 @@
39
41
  var TEXT = {
40
42
  label: 'Date',
41
43
  placeholder: 'Choose a date',
44
+ /* The month mode's own pair. Reusing `label` and `placeholder` would name a month field "Date"
45
+ and ask the reader to "Choose a date" on a control that cannot express a day, so every caller
46
+ would have to pass both attributes to undo a default that was simply wrong for the mode. */
47
+ labelMonth: 'Month',
48
+ placeholderMonth: 'Choose a month',
42
49
  prevMonth: 'Previous month',
43
50
  nextMonth: 'Next month',
44
51
  prevYear: 'Previous year',
@@ -69,6 +76,7 @@
69
76
  function uid(p) { uidSeq += 1; return p + '-' + uidSeq; }
70
77
  function pad(n) { return n < 10 ? '0' + n : '' + n; }
71
78
  function toISO(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
79
+ function toISOMonth(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1); }
72
80
  function parseISO(s) {
73
81
  if (!s) return null;
74
82
  var p = String(s).split('-');
@@ -76,6 +84,17 @@
76
84
  var d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
77
85
  return isNaN(d.getTime()) ? null : d;
78
86
  }
87
+ /* Month mode reads BOTH shapes, and the day it drops is the point: a bound of "2026-03" lands on
88
+ March 1st, and monthDisabled then compares a whole month against it correctly at either end,
89
+ so an app can pass the same strings it stores. A full date still works for a host that keeps
90
+ its bounds in one format across a form. */
91
+ function parseISOMonth(s) {
92
+ if (!s) return null;
93
+ var p = String(s).split('-');
94
+ if (p.length < 2) return null;
95
+ var d = new Date(Number(p[0]), Number(p[1]) - 1, p.length > 2 ? Number(p[2]) : 1);
96
+ return isNaN(d.getTime()) ? null : d;
97
+ }
79
98
  function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
80
99
  function sameDay(a, b) { return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
81
100
  function addDays(d, n) { var x = new Date(d); x.setDate(x.getDate() + n); return x; }
@@ -102,11 +121,16 @@
102
121
  wrap.dataset.fdyDpReady = '1';
103
122
  wrap.classList.add('fdy-datepicker');
104
123
 
105
- var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, 'placeholder');
106
- var label = wrap.getAttribute('data-label') || textOf(wrap, 'label');
107
- var selected = parseISO(wrap.getAttribute('data-value'));
108
- var minDate = parseISO(wrap.getAttribute('data-min'));
109
- var maxDate = parseISO(wrap.getAttribute('data-max'));
124
+ /* `data-mode="month"` makes the month grid the DESTINATION rather than a waypoint: the panel
125
+ opens on it, Enter and a click commit from it, the day grid is never built, and the value is
126
+ "YYYY-MM". Anything else, including the attribute's absence, is the day picker unchanged. */
127
+ var monthOnly = wrap.getAttribute('data-mode') === 'month';
128
+ var parseVal = monthOnly ? parseISOMonth : parseISO;
129
+ var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, monthOnly ? 'placeholderMonth' : 'placeholder');
130
+ var label = wrap.getAttribute('data-label') || textOf(wrap, monthOnly ? 'labelMonth' : 'label');
131
+ var selected = parseVal(wrap.getAttribute('data-value'));
132
+ var minDate = parseVal(wrap.getAttribute('data-min'));
133
+ var maxDate = parseVal(wrap.getAttribute('data-max'));
110
134
  var rangeStart = null, rangeEnd = null; // for in-range shading
111
135
  var view = startOfDay(selected || new Date());
112
136
  view = new Date(view.getFullYear(), view.getMonth(), 1);
@@ -116,7 +140,7 @@
116
140
  August 2026 to March 2022 is fifty-three of them. Shift+PageUp already jumped a year, but a
117
141
  shortcut nobody can see is not an affordance, the repo's own test helper clicked "previous
118
142
  month" twenty-four times to reach a date two years back. */
119
- var mode = 'days';
143
+ var mode = monthOnly ? 'months' : 'days';
120
144
  var focusMonth = null;
121
145
  var focusYear = null;
122
146
 
@@ -152,7 +176,10 @@
152
176
 
153
177
  function updateDisplay() {
154
178
  if (selected) {
155
- valueSpan.textContent = valueFmt.format(selected);
179
+ /* Through the same `Intl` formatter the month CELLS and the day grid's title already use,
180
+ which is the whole reason this mode exists: <input type="month"> names the month in the
181
+ browser's language, this names it in the page's. */
182
+ valueSpan.textContent = (monthOnly ? monthNameFmt : valueFmt).format(selected);
156
183
  valueSpan.classList.remove('fdy-datepicker__value--placeholder');
157
184
  } else {
158
185
  valueSpan.textContent = placeholder;
@@ -196,6 +223,10 @@
196
223
  }
197
224
 
198
225
  function render() {
226
+ /* The floor of the drill, stated once here rather than at every caller that could lower it:
227
+ in month mode there is no level below months, so the day grid is unreachable by
228
+ construction instead of by every path remembering not to ask for it. */
229
+ if (monthOnly && mode === 'days') mode = 'months';
199
230
  if (mode === 'years') { renderYears(); return; }
200
231
  if (mode === 'months') { renderMonths(); return; }
201
232
  panel.innerHTML = '';
@@ -300,7 +331,7 @@
300
331
  }
301
332
  if (monthDisabled(year, m)) btn.disabled = true;
302
333
  btn.tabIndex = m === focusMonth ? 0 : -1;
303
- btn.addEventListener('click', (function (mm) { return function () { openMonth(mm); }; })(m));
334
+ btn.addEventListener('click', (function (mm) { return function () { chooseMonth(mm); }; })(m));
304
335
  grid.appendChild(btn);
305
336
  }
306
337
  grid.addEventListener('keydown', onMonthKey);
@@ -414,6 +445,28 @@
414
445
  if (cell) cell.focus();
415
446
  }
416
447
 
448
+ /* What a month cell DOES, decided in one place for the click handler and the Enter key alike.
449
+ In day mode a month is a step on the way down; in month mode it is the answer. */
450
+ function chooseMonth(m) {
451
+ if (monthOnly) pickMonth(m);
452
+ else openMonth(m);
453
+ }
454
+
455
+ function pickMonth(m) {
456
+ var year = view.getFullYear();
457
+ if (monthDisabled(year, m)) return;
458
+ selected = new Date(year, m, 1);
459
+ focusMonth = m;
460
+ focusDate = selected;
461
+ updateDisplay();
462
+ /* The same event name, so a host already listening keeps listening, and `date` is still a
463
+ real Date (the first of the month) rather than a second shape to parse. */
464
+ wrap.dispatchEvent(new CustomEvent('fdy-datepicker-change', {
465
+ bubbles: true, detail: { value: toISOMonth(selected), date: new Date(selected) }
466
+ }));
467
+ close(true);
468
+ }
469
+
417
470
  /* Picking a month is navigation, not selection: it drops back to the day grid with the roving
418
471
  cell clamped into the new month, so nothing is committed until a DAY is chosen. */
419
472
  function openMonth(m) {
@@ -445,7 +498,7 @@
445
498
  case 'PageUp': moveMonthFocus(focusMonth, -1); break;
446
499
  case 'PageDown': moveMonthFocus(focusMonth, 1); break;
447
500
  case 'Enter':
448
- case ' ': openMonth(focusMonth); break;
501
+ case ' ': chooseMonth(focusMonth); break;
449
502
  case 'Escape': close(true); break;
450
503
  default: handled = false;
451
504
  }
@@ -539,14 +592,15 @@
539
592
  function open() {
540
593
  if (stateDisabled || stateReadonly) return;
541
594
  if (!panel.hidden) return;
542
- mode = 'days';
595
+ mode = monthOnly ? 'months' : 'days';
543
596
  focusDate = selected || focusDate || new Date();
544
597
  view = new Date(focusDate.getFullYear(), focusDate.getMonth(), 1);
598
+ if (monthOnly) focusMonth = view.getMonth();
545
599
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
546
600
  trigger.setAttribute('aria-expanded', 'true');
547
601
  trigger.classList.add('is-open');
548
602
  render();
549
- focusFocusDate();
603
+ if (monthOnly) focusMonthCell(); else focusFocusDate();
550
604
  document.addEventListener('click', onDocClick, true);
551
605
  }
552
606
 
@@ -572,10 +626,10 @@
572
626
  var api = {
573
627
  wrap: wrap,
574
628
  setState: setState,
575
- getValue: function () { return selected ? toISO(selected) : ''; },
629
+ getValue: function () { return selected ? (monthOnly ? toISOMonth(selected) : toISO(selected)) : ''; },
576
630
  clear: function () { selected = null; updateDisplay(); if (!panel.hidden) render(); },
577
- setMin: function (iso) { minDate = parseISO(iso); if (!panel.hidden) render(); },
578
- setMax: function (iso) { maxDate = parseISO(iso); if (!panel.hidden) render(); },
631
+ setMin: function (iso) { minDate = parseVal(iso); if (!panel.hidden) render(); },
632
+ setMax: function (iso) { maxDate = parseVal(iso); if (!panel.hidden) render(); },
579
633
  setRange: function (fromIso, toIso) {
580
634
  rangeStart = parseISO(fromIso);
581
635
  rangeEnd = parseISO(toIso);
@@ -36,7 +36,21 @@
36
36
  its[(index + its.length) % its.length].focus();
37
37
  }
38
38
  var _pop = null;
39
- function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(menu, trigger, { matchWidth: false }); return _pop; }
39
+ /* `.fdy-menu--end` is the kit's existing spelling for "align this menu to the right", and the
40
+ stylesheet has always honoured it (left:auto;right:0). The popover path replaces that with
41
+ inline `fixed` coordinates, so without this line the class was silently dropped the moment
42
+ the menu went into the top layer: an account menu in a page gutter overshot its avatar and
43
+ ran to 4px off the screen edge. Reported from an app that patched it with a `translate`
44
+ hack keyed to the kit's own GAP. */
45
+ function popCtl() {
46
+ if (_pop === null && window.FreedayPopover) {
47
+ _pop = window.FreedayPopover.attach(menu, trigger, {
48
+ matchWidth: false,
49
+ align: menu.classList.contains('fdy-menu--end') ? 'end' : 'start'
50
+ });
51
+ }
52
+ return _pop;
53
+ }
40
54
  function open(index) {
41
55
  var p = popCtl(); if (p) p.show(); else menu.hidden = false;
42
56
  trigger.setAttribute('aria-expanded', 'true');
@@ -8,7 +8,8 @@
8
8
  * Used by the select/combo, datepicker, timepicker, cascade, autocomplete and menu enhancers;
9
9
  * the freeday/vue adapter ships an equivalent `usePopover` composable.
10
10
  *
11
- * attach(panel, trigger) -> { show(), hide() }. show()/hide() also toggle the panel's [hidden]
11
+ * attach(panel, trigger, opts) -> { show(), hide() }, where opts is { matchWidth, align } and
12
+ * both are described on place() below. show()/hide() also toggle the panel's [hidden]
12
13
  * attribute so callers keep using `!panel.hidden` for open-state and the `:not([hidden])`
13
14
  * entrance animation still fires. On browsers without the Popover API it degrades to that
14
15
  * [hidden] toggle over the panel's existing absolute-positioned CSS (clipped, but functional).
@@ -21,35 +22,56 @@
21
22
  && typeof HTMLElement.prototype.showPopover === 'function';
22
23
 
23
24
  // Position `panel` (already shown, so it has a measurable box) against `trigger`'s rect.
24
- // matchWidth (default true) floors the panel to the trigger width, right for input-shaped
25
- // dropdowns (combo/cascade/date/time), skipped for menus that keep their own min-width.
26
- function place(panel, trigger, matchWidth) {
27
- var r = trigger.getBoundingClientRect();
25
+ // opts is the object attach() takes, and both keys are optional:
26
+ // matchWidth (default true) floors the panel to the trigger width, right for input-shaped
27
+ // dropdowns (combo/cascade/date/time), skipped for menus that keep their own min-width.
28
+ // align 'start' (default) lines the panel's LEFT edge up with the trigger's left edge;
29
+ // 'end' lines its RIGHT edge up with the trigger's right edge, which is what a
30
+ // control parked in a right-hand gutter needs. The panel is kept inside the
31
+ // viewport either way, so 'end' near the left edge slides right instead of
32
+ // hanging off it. freeday-menu.js reads it off `.fdy-menu--end`.
33
+ function place(panel, trigger, opts) {
34
+ var o = opts || {};
35
+ /* Take the panel out of the flow BEFORE measuring anything, because until this runs the panel
36
+ * is still `position:absolute` with `top:calc(100% + …)` from its component's stylesheet. An
37
+ * author rule outranks the popover UA style, and in the top layer that `100%` resolves against
38
+ * the viewport, so a panel that has just been shown sits one viewport height down the page and
39
+ * drags the document's scrollable overflow with it. Where scrollbars take layout space (every
40
+ * Windows and Linux browser; macOS overlay scrollbars hide it completely) the document gains a
41
+ * classic scrollbar for the duration, documentElement.clientWidth drops by its width, and the
42
+ * page relays out 15px narrower. A trigger rect read in that instant is a rect from a layout
43
+ * that is about to be undone by the next three lines: measured 961 instead of 976 for an avatar
44
+ * in a 24px gutter, so the menu landed a scrollbar's width to the left of it. */
28
45
  panel.style.position = 'fixed';
29
46
  panel.style.margin = '0';
30
47
  panel.style.inset = 'auto'; // clear the UA popover centering
31
- if (matchWidth !== false) panel.style.minWidth = r.width + 'px'; // at least as wide as the trigger
48
+ var r = trigger.getBoundingClientRect();
49
+ if (o.matchWidth !== false) panel.style.minWidth = r.width + 'px'; // at least as wide as the trigger
32
50
  var ph = panel.offsetHeight, pw = panel.offsetWidth;
33
51
  var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
34
52
  var below = vh - r.bottom - GAP, above = r.top - GAP;
35
53
  // Prefer below; flip above only when it doesn't fit below and above has more room.
36
54
  var top = (ph <= below || below >= above) ? (r.bottom + GAP) : Math.max(GAP, r.top - GAP - ph);
37
- var left = r.left;
38
- if (left + pw > vw - GAP) left = Math.max(GAP, vw - GAP - pw); // keep within the viewport
55
+ var left = (o.align === 'end') ? (r.right - pw) : r.left;
56
+ /* Clamped on BOTH sides, and that is not belt-and-braces: the first line is what keeps a
57
+ start-aligned panel on screen, the second is what keeps an end-aligned one on screen when
58
+ the panel is wider than the room to the left of its trigger. Keeping only one of them
59
+ trades an overflow on one edge for an overflow on the other. */
60
+ if (left + pw > vw - GAP) left = vw - GAP - pw;
61
+ if (left < GAP) left = GAP;
39
62
  panel.style.top = Math.round(top) + 'px';
40
63
  panel.style.left = Math.round(left) + 'px';
41
64
  }
42
65
 
43
66
  function attach(panel, trigger, opts) {
44
- var matchWidth = !(opts && opts.matchWidth === false);
45
67
  if (supported) panel.setAttribute('popover', 'manual');
46
- function reposition() { if (supported && panel.matches(':popover-open')) place(panel, trigger, matchWidth); }
68
+ function reposition() { if (supported && panel.matches(':popover-open')) place(panel, trigger, opts); }
47
69
  return {
48
70
  show: function () {
49
71
  panel.hidden = false;
50
72
  if (supported) {
51
73
  if (!panel.matches(':popover-open')) panel.showPopover();
52
- place(panel, trigger, matchWidth);
74
+ place(panel, trigger, opts);
53
75
  window.addEventListener('scroll', reposition, true); // capture: any scrolling ancestor
54
76
  window.addEventListener('resize', reposition);
55
77
  }
@@ -14,7 +14,10 @@
14
14
  *
15
15
  * Column filters: mark a header `<th data-fdy-filter="text|enum|number">` and a funnel
16
16
  * button is injected; text filters by contains, enum by a value checklist, number by a
17
- * min/max range. All active column filters AND the global search must pass (logical AND).
17
+ * min/max range. All active column filters AND the global search must pass (logical AND). The
18
+ * panel is anchored to its funnel by freeday-popover.js, as in every other enhancer with a
19
+ * dropdown, so load that file too (dist/freeday.js already has it); without it the panel still
20
+ * opens, at whatever position its stylesheet leaves it.
18
21
  *
19
22
  * Bulk actions: an optional `[data-fdy-table-bulk]` bar (hidden by default) with a
20
23
  * `[data-fdy-table-bulk-count]` label and a `[data-fdy-table-bulk-clear]` button shows
@@ -244,6 +247,8 @@
244
247
  function closePopover(returnFocus) {
245
248
  if (!openPopover) return;
246
249
  var btn = openPopover._trigger;
250
+ // Out of the top layer first, which also drops the reposition listeners attach() added.
251
+ if (openPopover._pop) openPopover._pop.hide();
247
252
  openPopover.remove();
248
253
  openPopover = null;
249
254
  window.removeEventListener('scroll', closeOnScroll, true);
@@ -365,11 +370,65 @@
365
370
  foot.appendChild(reset); foot.appendChild(done);
366
371
  pop.appendChild(foot);
367
372
 
368
- document.body.appendChild(pop);
373
+ /* Positioned by freeday-popover.js, like every other enhancer with a dropdown. This used to
374
+ * be a fourth copy of that arithmetic, and the copies had drifted: it kept an 8px margin
375
+ * where the kit's gap is 4, measured against window.innerWidth (which counts a classic
376
+ * scrollbar the layout no longer has, so the clamp parked the panel under it), aligned the
377
+ * left edges only, and never reached the top layer. The typed FdyTableFilter asks for
378
+ * `align:'end'`, so a funnel in the rightmost column opened differently depending on which
379
+ * path rendered the table, and the two paths are supposed to be interchangeable.
380
+ * align 'end': the funnel is a 24px button in a column header and the panel is 14rem, so
381
+ * lining up their LEFT edges aims the panel at nothing anybody can see, and in the rightmost
382
+ * column it ran past the page. place() still clamps both sides, so the first column is safe.
383
+ * matchWidth false: `.fdy-filter` asks the stylesheet for min-width:14rem, and a floor
384
+ * taken from a 24px funnel is an inline style that outranks it.
385
+ * In the top layer the panel is a child of the <th>, where FdyTableFilter renders it, rather
386
+ * than of <body>. The top layer is what escapes an ancestor's overflow clip now, so the
387
+ * <body> trick has nothing left to do, and it cost something real: everything outside an open
388
+ * modal <dialog> is inert, so a filter panel on <body> painted a form that refused focus and
389
+ * every click for any table inside a modal. */
390
+ /* WHERE it comes to rest depends on whether the top layer is available, and the degraded path
391
+ * is not an edge case: `supported` is read once when freeday-popover.js loads, the Popover API
392
+ * landed in Chrome 114 / Safari 17 / Firefox 125, and the README promises Chrome 105 /
393
+ * Safari 15.4 / Firefox 121. Chrome 105..113, Safari 15.4 and 16, and Firefox 121..124 are all
394
+ * inside what the kit supports and all of them come through here.
395
+ *
396
+ * In the top layer the parent decides nothing about paint order, so the panel stays in the
397
+ * <th> and the modal fix above holds. Without it the <th> is a CAGE:
398
+ * `.fdy-table--sticky-head thead th` is `position:sticky; z-index:2`, and a positioned box
399
+ * with a z-index is a stacking context, so the panel's own 130 is sorted inside that 2 and
400
+ * every page overlay from 3 up paints over it. Measured on a phone: 224x49px of the panel,
401
+ * its Reset and Close buttons, under `.fdy-bottomnav`'s z-index 50, and the hit test at that
402
+ * point returned the bar. Raising the 130 cannot reach it, a number raised inside a stacking
403
+ * context never leaves that context; and the context has to exist, the 2 is what keeps a
404
+ * sticky header above the z-index:1 frozen body cells scrolling under it. So the panel is what
405
+ * moves.
406
+ *
407
+ * It moves to `root`, the element that carries `data-fdy-table`, and not to <body>. `root` is
408
+ * documented as `.fdy-datatable`: no position, no z-index, no transform, so no stacking
409
+ * context and no containing block for a fixed box, which is all the panel needs. And it is
410
+ * INSIDE whatever dialog the table is in, so the inert fault that sent the panel here in the
411
+ * first place cannot come back, with no `closest('dialog')` special case to get wrong.
412
+ *
413
+ * Re-parenting alone would trade one fault for another, so it comes with place(). A fixed box
414
+ * with auto offsets takes its STATIC position, and in a sticky cell inside a scrolled
415
+ * scrollport that spot is nowhere in particular: measured 99px below the funnel it belongs to,
416
+ * with the panel's foot 12px off the bottom of a 740px screen, and at the end of the datatable
417
+ * once it is parented to `root`. place() is the kit's one copy of that arithmetic and it needs
418
+ * nothing from the Popover API, it only writes inline coordinates. The panel closes on any
419
+ * scroll (closeOnScroll, below), so a fixed panel that does not follow the page is not a
420
+ * second fault.
421
+ *
422
+ * Where the helper is absent ENTIRELY nothing moves: there would be nothing to place the panel
423
+ * with, and the static position inside the <th> at least lands near its own funnel. */
424
+ var popover = window.FreedayPopover || null;
425
+ var degraded = popover !== null && popover.supported !== true;
426
+ var opts = { matchWidth: false, align: 'end' };
427
+ (degraded ? root : btn.parentNode).appendChild(pop);
369
428
  pop._trigger = btn;
370
- var rect = btn.getBoundingClientRect();
371
- pop.style.top = (rect.bottom + 4) + 'px';
372
- pop.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - pop.offsetWidth - 8)) + 'px';
429
+ pop._pop = popover === null ? null : popover.attach(pop, btn, opts);
430
+ if (pop._pop) pop._pop.show();
431
+ if (degraded) popover.place(pop, btn, opts);
373
432
  openPopover = pop;
374
433
  window.addEventListener('scroll', closeOnScroll, true);
375
434
  window.addEventListener('resize', closeOnScroll);
@@ -4,6 +4,12 @@
4
4
  * Markup: a [data-fdy-tabs] with a [role="tablist"] of
5
5
  * <button role="tab" aria-controls="PANEL" aria-selected> and matching
6
6
  * <div role="tabpanel" id="PANEL" aria-labelledby="TAB" [hidden]>.
7
+ *
8
+ * The arrow AXIS comes from the tablist's `aria-orientation`: `vertical` answers to Up and
9
+ * Down, anything else to Left and Right. One axis per tablist, which is what APG maps, and the
10
+ * other pair is left entirely alone rather than bound as a second axis: Left and Right over a
11
+ * column of tabs belong to whatever else is on the page, and a widget that preventDefaults a key
12
+ * it has no meaning for has taken it from the page silently. Home/End answer on both.
7
13
  */
8
14
  (function () {
9
15
  'use strict';
@@ -14,6 +20,19 @@
14
20
  if (tabs.length === 0) return;
15
21
  root.dataset.fdyTabsReady = '1';
16
22
 
23
+ /* The look follows `.fdy-tabs--vertical`, the behaviour follows `aria-orientation`, and this
24
+ makes the second agree with the first so an app states the axis once. The attribute always
25
+ wins when it is there: it is what an assistive technology announces, so nothing here may
26
+ overwrite one an author wrote. */
27
+ var list = root.querySelector('[role="tablist"]');
28
+ if (list !== null && list.getAttribute('aria-orientation') === null
29
+ && root.classList.contains('fdy-tabs--vertical')) {
30
+ list.setAttribute('aria-orientation', 'vertical');
31
+ }
32
+ var vertical = list !== null && list.getAttribute('aria-orientation') === 'vertical';
33
+ var NEXT_KEY = vertical ? 'ArrowDown' : 'ArrowRight';
34
+ var PREV_KEY = vertical ? 'ArrowUp' : 'ArrowLeft';
35
+
17
36
  function panelFor(tab) {
18
37
  var id = tab.getAttribute('aria-controls');
19
38
  return id ? document.getElementById(id) : null;
@@ -33,9 +52,9 @@
33
52
  tab.addEventListener('click', function () { select(tab, false); });
34
53
  tab.addEventListener('keydown', function (e) {
35
54
  switch (e.key) {
36
- case 'ArrowRight':
55
+ case NEXT_KEY:
37
56
  e.preventDefault(); select(tabs[(i + 1) % tabs.length], true); break;
38
- case 'ArrowLeft':
57
+ case PREV_KEY:
39
58
  e.preventDefault(); select(tabs[(i - 1 + tabs.length) % tabs.length], true); break;
40
59
  case 'Home':
41
60
  e.preventDefault(); select(tabs[0], true); break;
@@ -2998,8 +2998,25 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2998
2998
  .fdy-table__filterbtn.is-active{color:var(--color-primary-strong);}
2999
2999
  .fdy-table__filterbtn svg{width:.85rem;height:.85rem;}
3000
3000
 
3001
- /* Filter popover (position:fixed, appended to <body> so overflow can't clip it) */
3002
- .fdy-filter{position:fixed;z-index:130;min-width:14rem;max-width:18rem;padding:var(--space-3);background:var(--color-surface);border:var(--bw) solid var(--color-border-strong);border-radius:var(--radius-md);box-shadow:var(--shadow-3);text-transform:none;letter-spacing:normal;font-weight:var(--weight-regular);color:var(--color-text);}
3001
+ /* Filter popover. `position:fixed` because the panel is positioned by freeday-popover.js (and by
3002
+ usePopover on the typed path) with inline viewport coordinates, in the top layer, which is what
3003
+ escapes a card's or a scroll container's clip; the panel itself is a child of its own <th> on
3004
+ both paths, so an open modal <dialog> does not make it inert. It is the one kit panel that is
3005
+ fixed rather than absolute, and that is load-bearing: #079 cannot reach a panel which never
3006
+ resolves a `top:calc(100% + …)` against the viewport, which browser/filter-parity.mjs measures.
3007
+ `min-width` is the panel's own, so neither path may floor it to the width of a 24px funnel.
3008
+
3009
+ The typography block is a RESET of what the panel inherits from that <th>, and it has to name
3010
+ every inherited property the header rule sets, not the ones that looked worst. A header is
3011
+ uppercase, tracked, semibold, muted, 12px and `white-space:nowrap`; a numeric column adds mono
3012
+ and, on the typed path, an inline `text-align:right` that no ancestor rule could outrank anyway.
3013
+ None of that belongs on a panel of labels and inputs, and the four it reset from the start were
3014
+ the four that SHOWED: an 18rem cap hides a nowrap panel's damage, because the box stays 18rem
3015
+ wide while the text runs out of the list, which then scrolls sideways to reach a value nobody
3016
+ can read. `font-size` and `font-family` reach only the text that declares neither, the range
3017
+ separator and the title, which is exactly why they went unnoticed. Measured as geometry in
3018
+ browser/filter-wrap.mjs rather than asserted as declarations. */
3019
+ .fdy-filter{position:fixed;z-index:130;min-width:14rem;max-width:18rem;padding:var(--space-3);background:var(--color-surface);border:var(--bw) solid var(--color-border-strong);border-radius:var(--radius-md);box-shadow:var(--shadow-3);text-align:left;white-space:normal;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-regular);text-transform:none;letter-spacing:normal;color:var(--color-text);}
3003
3020
  .fdy-filter__title{font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-wide);margin-bottom:var(--space-2);}
3004
3021
  .fdy-filter__list{display:flex;flex-direction:column;gap:var(--space-1);max-height:12rem;overflow:auto;}
3005
3022
  .fdy-filter__check{display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm);color:var(--color-text);cursor:pointer;}
@@ -3056,6 +3073,37 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
3056
3073
  /* Reveal the newly-shown panel on tab switch (fade + rise), reduced-motion gated. */
3057
3074
  @media (prefers-reduced-motion:no-preference){.fdy-tabs__panel:not([hidden]){animation:fdy-combo-in var(--dur-base) var(--ease-standard);}}
3058
3075
 
3076
+ /* VERTICAL VARIANT: the same tabs turned down the page, list on the leading side, panel beside it.
3077
+ *
3078
+ * Two markers, one each, and they are not interchangeable. The CLASS decides the look, because a
3079
+ * stylesheet cannot reach a parent from a child and the two-column box belongs to `.fdy-tabs`
3080
+ * itself. The ATTRIBUTE `aria-orientation="vertical"` on the tablist decides the keyboard, because
3081
+ * that is the thing an assistive technology announces and APG requires it on a vertical tablist
3082
+ * anyway. `freeday-tabs.js` writes the attribute when only the class is present, so an app states
3083
+ * the axis once and the two can never disagree.
3084
+ *
3085
+ * Selection is a SOFT FILL here, not a leading edge bar. Sideways, the marker is an underline under
3086
+ * the tab it belongs to; turned 90 degrees that line lands between two tabs and points at the wrong
3087
+ * one. The kit already has an answer for "one item in a vertical list is where you are" and spends
3088
+ * it in four places (`.fdy-nav__item[aria-current]`, the combo, cascade and autocomplete options):
3089
+ * `--color-primary-soft` behind `--color-primary-strong`. Both are precomputed tone tokens, so this
3090
+ * needs no color-mix() and no alpha of its own.
3091
+ *
3092
+ * What it deliberately does NOT do: pick a column width (set `--fdy-tabs-list-w`, default `auto`),
3093
+ * and collapse to one column on a narrow screen. Whether these tabs are a whole page, a card or a
3094
+ * drawer is the page's knowledge, not the component's, so the breakpoint is the page's to write. */
3095
+ .fdy-tabs--vertical{display:flex;align-items:flex-start;gap:var(--space-5);}
3096
+ /* `overflow:visible` on both axes, because the base strip scrolls sideways: a scroll container
3097
+ clips to its padding box, and a column has no sideways overflow to scroll, only a focus ring to
3098
+ lose. `box-shadow:none` drops the strip's own bottom line with it. */
3099
+ .fdy-tabs--vertical .fdy-tabs__list{flex:none;flex-direction:column;width:var(--fdy-tabs-list-w,auto);overflow:visible;box-shadow:none;}
3100
+ /* `white-space:normal` and `text-align:start` are corrections, not taste: a tab in a column of
3101
+ fixed width that may not wrap will overflow it, and a button centres its label by default. */
3102
+ .fdy-tabs--vertical .fdy-tabs__tab{border-bottom:0;border-radius:var(--radius-md);text-align:start;white-space:normal;}
3103
+ .fdy-tabs--vertical .fdy-tabs__tab:focus-visible{border-radius:var(--radius-md);}
3104
+ .fdy-tabs--vertical .fdy-tabs__tab[aria-selected="true"],.fdy-tabs--vertical .fdy-tabs__tab[aria-current="page"]{background:var(--color-primary-soft);color:var(--color-primary-strong);font-weight:var(--weight-semibold);}
3105
+ .fdy-tabs--vertical .fdy-tabs__panel{flex:1;min-width:0;padding:0;}
3106
+
3059
3107
  /* Freeday. Timeline (vertical event feed: dot markers on a connector line). Pure CSS. */
3060
3108
  .fdy-timeline{list-style:none;margin:0;padding:0;}
3061
3109
  .fdy-timeline__item{position:relative;padding:0 0 var(--space-5) var(--space-6);border-left:2px solid var(--color-border);}