@cahyo-dimas/freeday 3.7.0 → 3.8.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.
@@ -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
  }
@@ -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;
@@ -3056,6 +3056,37 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
3056
3056
  /* Reveal the newly-shown panel on tab switch (fade + rise), reduced-motion gated. */
3057
3057
  @media (prefers-reduced-motion:no-preference){.fdy-tabs__panel:not([hidden]){animation:fdy-combo-in var(--dur-base) var(--ease-standard);}}
3058
3058
 
3059
+ /* VERTICAL VARIANT: the same tabs turned down the page, list on the leading side, panel beside it.
3060
+ *
3061
+ * Two markers, one each, and they are not interchangeable. The CLASS decides the look, because a
3062
+ * stylesheet cannot reach a parent from a child and the two-column box belongs to `.fdy-tabs`
3063
+ * itself. The ATTRIBUTE `aria-orientation="vertical"` on the tablist decides the keyboard, because
3064
+ * that is the thing an assistive technology announces and APG requires it on a vertical tablist
3065
+ * anyway. `freeday-tabs.js` writes the attribute when only the class is present, so an app states
3066
+ * the axis once and the two can never disagree.
3067
+ *
3068
+ * Selection is a SOFT FILL here, not a leading edge bar. Sideways, the marker is an underline under
3069
+ * the tab it belongs to; turned 90 degrees that line lands between two tabs and points at the wrong
3070
+ * one. The kit already has an answer for "one item in a vertical list is where you are" and spends
3071
+ * it in four places (`.fdy-nav__item[aria-current]`, the combo, cascade and autocomplete options):
3072
+ * `--color-primary-soft` behind `--color-primary-strong`. Both are precomputed tone tokens, so this
3073
+ * needs no color-mix() and no alpha of its own.
3074
+ *
3075
+ * What it deliberately does NOT do: pick a column width (set `--fdy-tabs-list-w`, default `auto`),
3076
+ * and collapse to one column on a narrow screen. Whether these tabs are a whole page, a card or a
3077
+ * drawer is the page's knowledge, not the component's, so the breakpoint is the page's to write. */
3078
+ .fdy-tabs--vertical{display:flex;align-items:flex-start;gap:var(--space-5);}
3079
+ /* `overflow:visible` on both axes, because the base strip scrolls sideways: a scroll container
3080
+ clips to its padding box, and a column has no sideways overflow to scroll, only a focus ring to
3081
+ lose. `box-shadow:none` drops the strip's own bottom line with it. */
3082
+ .fdy-tabs--vertical .fdy-tabs__list{flex:none;flex-direction:column;width:var(--fdy-tabs-list-w,auto);overflow:visible;box-shadow:none;}
3083
+ /* `white-space:normal` and `text-align:start` are corrections, not taste: a tab in a column of
3084
+ fixed width that may not wrap will overflow it, and a button centres its label by default. */
3085
+ .fdy-tabs--vertical .fdy-tabs__tab{border-bottom:0;border-radius:var(--radius-md);text-align:start;white-space:normal;}
3086
+ .fdy-tabs--vertical .fdy-tabs__tab:focus-visible{border-radius:var(--radius-md);}
3087
+ .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);}
3088
+ .fdy-tabs--vertical .fdy-tabs__panel{flex:1;min-width:0;padding:0;}
3089
+
3059
3090
  /* Freeday. Timeline (vertical event feed: dot markers on a connector line). Pure CSS. */
3060
3091
  .fdy-timeline{list-style:none;margin:0;padding:0;}
3061
3092
  .fdy-timeline__item{position:relative;padding:0 0 var(--space-5) var(--space-6);border-left:2px solid var(--color-border);}
package/dist/freeday.css CHANGED
@@ -1879,6 +1879,37 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1879
1879
  /* Reveal the newly-shown panel on tab switch (fade + rise), reduced-motion gated. */
1880
1880
  @media (prefers-reduced-motion:no-preference){.fdy-tabs__panel:not([hidden]){animation:fdy-combo-in var(--dur-base) var(--ease-standard);}}
1881
1881
 
1882
+ /* VERTICAL VARIANT: the same tabs turned down the page, list on the leading side, panel beside it.
1883
+ *
1884
+ * Two markers, one each, and they are not interchangeable. The CLASS decides the look, because a
1885
+ * stylesheet cannot reach a parent from a child and the two-column box belongs to `.fdy-tabs`
1886
+ * itself. The ATTRIBUTE `aria-orientation="vertical"` on the tablist decides the keyboard, because
1887
+ * that is the thing an assistive technology announces and APG requires it on a vertical tablist
1888
+ * anyway. `freeday-tabs.js` writes the attribute when only the class is present, so an app states
1889
+ * the axis once and the two can never disagree.
1890
+ *
1891
+ * Selection is a SOFT FILL here, not a leading edge bar. Sideways, the marker is an underline under
1892
+ * the tab it belongs to; turned 90 degrees that line lands between two tabs and points at the wrong
1893
+ * one. The kit already has an answer for "one item in a vertical list is where you are" and spends
1894
+ * it in four places (`.fdy-nav__item[aria-current]`, the combo, cascade and autocomplete options):
1895
+ * `--color-primary-soft` behind `--color-primary-strong`. Both are precomputed tone tokens, so this
1896
+ * needs no color-mix() and no alpha of its own.
1897
+ *
1898
+ * What it deliberately does NOT do: pick a column width (set `--fdy-tabs-list-w`, default `auto`),
1899
+ * and collapse to one column on a narrow screen. Whether these tabs are a whole page, a card or a
1900
+ * drawer is the page's knowledge, not the component's, so the breakpoint is the page's to write. */
1901
+ .fdy-tabs--vertical{display:flex;align-items:flex-start;gap:var(--space-5);}
1902
+ /* `overflow:visible` on both axes, because the base strip scrolls sideways: a scroll container
1903
+ clips to its padding box, and a column has no sideways overflow to scroll, only a focus ring to
1904
+ lose. `box-shadow:none` drops the strip's own bottom line with it. */
1905
+ .fdy-tabs--vertical .fdy-tabs__list{flex:none;flex-direction:column;width:var(--fdy-tabs-list-w,auto);overflow:visible;box-shadow:none;}
1906
+ /* `white-space:normal` and `text-align:start` are corrections, not taste: a tab in a column of
1907
+ fixed width that may not wrap will overflow it, and a button centres its label by default. */
1908
+ .fdy-tabs--vertical .fdy-tabs__tab{border-bottom:0;border-radius:var(--radius-md);text-align:start;white-space:normal;}
1909
+ .fdy-tabs--vertical .fdy-tabs__tab:focus-visible{border-radius:var(--radius-md);}
1910
+ .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);}
1911
+ .fdy-tabs--vertical .fdy-tabs__panel{flex:1;min-width:0;padding:0;}
1912
+
1882
1913
  /* Freeday. Timeline (vertical event feed: dot markers on a connector line). Pure CSS. */
1883
1914
  .fdy-timeline{list-style:none;margin:0;padding:0;}
1884
1915
  .fdy-timeline__item{position:relative;padding:0 0 var(--space-5) var(--space-6);border-left:2px solid var(--color-border);}
package/dist/freeday.js CHANGED
@@ -2098,6 +2098,8 @@
2098
2098
  * <div data-fdy-datepicker data-role="to" data-placeholder="To"></div>
2099
2099
  * </div>
2100
2100
  * The range links the two: the end can never precede the start (out-of-range days disable).
2101
+ * - Month: <div data-fdy-datepicker data-mode="month" data-value="2026-10"></div>
2102
+ * The calendar opens ON the month grid and stops there; the value is "YYYY-MM".
2101
2103
  *
2102
2104
  * Emits a bubbling "fdy-datepicker-change" CustomEvent on the wrapper: detail {value, date}.
2103
2105
  * Keyboard in the grid: ←/→ ±1 day, ↑/↓ ±1 week, Home/End week edge, PageUp/Down ±month,
@@ -2126,6 +2128,11 @@
2126
2128
  var TEXT = {
2127
2129
  label: 'Date',
2128
2130
  placeholder: 'Choose a date',
2131
+ /* The month mode's own pair. Reusing `label` and `placeholder` would name a month field "Date"
2132
+ and ask the reader to "Choose a date" on a control that cannot express a day, so every caller
2133
+ would have to pass both attributes to undo a default that was simply wrong for the mode. */
2134
+ labelMonth: 'Month',
2135
+ placeholderMonth: 'Choose a month',
2129
2136
  prevMonth: 'Previous month',
2130
2137
  nextMonth: 'Next month',
2131
2138
  prevYear: 'Previous year',
@@ -2156,6 +2163,7 @@
2156
2163
  function uid(p) { uidSeq += 1; return p + '-' + uidSeq; }
2157
2164
  function pad(n) { return n < 10 ? '0' + n : '' + n; }
2158
2165
  function toISO(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
2166
+ function toISOMonth(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1); }
2159
2167
  function parseISO(s) {
2160
2168
  if (!s) return null;
2161
2169
  var p = String(s).split('-');
@@ -2163,6 +2171,17 @@
2163
2171
  var d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
2164
2172
  return isNaN(d.getTime()) ? null : d;
2165
2173
  }
2174
+ /* Month mode reads BOTH shapes, and the day it drops is the point: a bound of "2026-03" lands on
2175
+ March 1st, and monthDisabled then compares a whole month against it correctly at either end,
2176
+ so an app can pass the same strings it stores. A full date still works for a host that keeps
2177
+ its bounds in one format across a form. */
2178
+ function parseISOMonth(s) {
2179
+ if (!s) return null;
2180
+ var p = String(s).split('-');
2181
+ if (p.length < 2) return null;
2182
+ var d = new Date(Number(p[0]), Number(p[1]) - 1, p.length > 2 ? Number(p[2]) : 1);
2183
+ return isNaN(d.getTime()) ? null : d;
2184
+ }
2166
2185
  function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
2167
2186
  function sameDay(a, b) { return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
2168
2187
  function addDays(d, n) { var x = new Date(d); x.setDate(x.getDate() + n); return x; }
@@ -2189,11 +2208,16 @@
2189
2208
  wrap.dataset.fdyDpReady = '1';
2190
2209
  wrap.classList.add('fdy-datepicker');
2191
2210
 
2192
- var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, 'placeholder');
2193
- var label = wrap.getAttribute('data-label') || textOf(wrap, 'label');
2194
- var selected = parseISO(wrap.getAttribute('data-value'));
2195
- var minDate = parseISO(wrap.getAttribute('data-min'));
2196
- var maxDate = parseISO(wrap.getAttribute('data-max'));
2211
+ /* `data-mode="month"` makes the month grid the DESTINATION rather than a waypoint: the panel
2212
+ opens on it, Enter and a click commit from it, the day grid is never built, and the value is
2213
+ "YYYY-MM". Anything else, including the attribute's absence, is the day picker unchanged. */
2214
+ var monthOnly = wrap.getAttribute('data-mode') === 'month';
2215
+ var parseVal = monthOnly ? parseISOMonth : parseISO;
2216
+ var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, monthOnly ? 'placeholderMonth' : 'placeholder');
2217
+ var label = wrap.getAttribute('data-label') || textOf(wrap, monthOnly ? 'labelMonth' : 'label');
2218
+ var selected = parseVal(wrap.getAttribute('data-value'));
2219
+ var minDate = parseVal(wrap.getAttribute('data-min'));
2220
+ var maxDate = parseVal(wrap.getAttribute('data-max'));
2197
2221
  var rangeStart = null, rangeEnd = null; // for in-range shading
2198
2222
  var view = startOfDay(selected || new Date());
2199
2223
  view = new Date(view.getFullYear(), view.getMonth(), 1);
@@ -2203,7 +2227,7 @@
2203
2227
  August 2026 to March 2022 is fifty-three of them. Shift+PageUp already jumped a year, but a
2204
2228
  shortcut nobody can see is not an affordance, the repo's own test helper clicked "previous
2205
2229
  month" twenty-four times to reach a date two years back. */
2206
- var mode = 'days';
2230
+ var mode = monthOnly ? 'months' : 'days';
2207
2231
  var focusMonth = null;
2208
2232
  var focusYear = null;
2209
2233
 
@@ -2239,7 +2263,10 @@
2239
2263
 
2240
2264
  function updateDisplay() {
2241
2265
  if (selected) {
2242
- valueSpan.textContent = valueFmt.format(selected);
2266
+ /* Through the same `Intl` formatter the month CELLS and the day grid's title already use,
2267
+ which is the whole reason this mode exists: <input type="month"> names the month in the
2268
+ browser's language, this names it in the page's. */
2269
+ valueSpan.textContent = (monthOnly ? monthNameFmt : valueFmt).format(selected);
2243
2270
  valueSpan.classList.remove('fdy-datepicker__value--placeholder');
2244
2271
  } else {
2245
2272
  valueSpan.textContent = placeholder;
@@ -2283,6 +2310,10 @@
2283
2310
  }
2284
2311
 
2285
2312
  function render() {
2313
+ /* The floor of the drill, stated once here rather than at every caller that could lower it:
2314
+ in month mode there is no level below months, so the day grid is unreachable by
2315
+ construction instead of by every path remembering not to ask for it. */
2316
+ if (monthOnly && mode === 'days') mode = 'months';
2286
2317
  if (mode === 'years') { renderYears(); return; }
2287
2318
  if (mode === 'months') { renderMonths(); return; }
2288
2319
  panel.innerHTML = '';
@@ -2387,7 +2418,7 @@
2387
2418
  }
2388
2419
  if (monthDisabled(year, m)) btn.disabled = true;
2389
2420
  btn.tabIndex = m === focusMonth ? 0 : -1;
2390
- btn.addEventListener('click', (function (mm) { return function () { openMonth(mm); }; })(m));
2421
+ btn.addEventListener('click', (function (mm) { return function () { chooseMonth(mm); }; })(m));
2391
2422
  grid.appendChild(btn);
2392
2423
  }
2393
2424
  grid.addEventListener('keydown', onMonthKey);
@@ -2501,6 +2532,28 @@
2501
2532
  if (cell) cell.focus();
2502
2533
  }
2503
2534
 
2535
+ /* What a month cell DOES, decided in one place for the click handler and the Enter key alike.
2536
+ In day mode a month is a step on the way down; in month mode it is the answer. */
2537
+ function chooseMonth(m) {
2538
+ if (monthOnly) pickMonth(m);
2539
+ else openMonth(m);
2540
+ }
2541
+
2542
+ function pickMonth(m) {
2543
+ var year = view.getFullYear();
2544
+ if (monthDisabled(year, m)) return;
2545
+ selected = new Date(year, m, 1);
2546
+ focusMonth = m;
2547
+ focusDate = selected;
2548
+ updateDisplay();
2549
+ /* The same event name, so a host already listening keeps listening, and `date` is still a
2550
+ real Date (the first of the month) rather than a second shape to parse. */
2551
+ wrap.dispatchEvent(new CustomEvent('fdy-datepicker-change', {
2552
+ bubbles: true, detail: { value: toISOMonth(selected), date: new Date(selected) }
2553
+ }));
2554
+ close(true);
2555
+ }
2556
+
2504
2557
  /* Picking a month is navigation, not selection: it drops back to the day grid with the roving
2505
2558
  cell clamped into the new month, so nothing is committed until a DAY is chosen. */
2506
2559
  function openMonth(m) {
@@ -2532,7 +2585,7 @@
2532
2585
  case 'PageUp': moveMonthFocus(focusMonth, -1); break;
2533
2586
  case 'PageDown': moveMonthFocus(focusMonth, 1); break;
2534
2587
  case 'Enter':
2535
- case ' ': openMonth(focusMonth); break;
2588
+ case ' ': chooseMonth(focusMonth); break;
2536
2589
  case 'Escape': close(true); break;
2537
2590
  default: handled = false;
2538
2591
  }
@@ -2626,14 +2679,15 @@
2626
2679
  function open() {
2627
2680
  if (stateDisabled || stateReadonly) return;
2628
2681
  if (!panel.hidden) return;
2629
- mode = 'days';
2682
+ mode = monthOnly ? 'months' : 'days';
2630
2683
  focusDate = selected || focusDate || new Date();
2631
2684
  view = new Date(focusDate.getFullYear(), focusDate.getMonth(), 1);
2685
+ if (monthOnly) focusMonth = view.getMonth();
2632
2686
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
2633
2687
  trigger.setAttribute('aria-expanded', 'true');
2634
2688
  trigger.classList.add('is-open');
2635
2689
  render();
2636
- focusFocusDate();
2690
+ if (monthOnly) focusMonthCell(); else focusFocusDate();
2637
2691
  document.addEventListener('click', onDocClick, true);
2638
2692
  }
2639
2693
 
@@ -2659,10 +2713,10 @@
2659
2713
  var api = {
2660
2714
  wrap: wrap,
2661
2715
  setState: setState,
2662
- getValue: function () { return selected ? toISO(selected) : ''; },
2716
+ getValue: function () { return selected ? (monthOnly ? toISOMonth(selected) : toISO(selected)) : ''; },
2663
2717
  clear: function () { selected = null; updateDisplay(); if (!panel.hidden) render(); },
2664
- setMin: function (iso) { minDate = parseISO(iso); if (!panel.hidden) render(); },
2665
- setMax: function (iso) { maxDate = parseISO(iso); if (!panel.hidden) render(); },
2718
+ setMin: function (iso) { minDate = parseVal(iso); if (!panel.hidden) render(); },
2719
+ setMax: function (iso) { maxDate = parseVal(iso); if (!panel.hidden) render(); },
2666
2720
  setRange: function (fromIso, toIso) {
2667
2721
  rangeStart = parseISO(fromIso);
2668
2722
  rangeEnd = parseISO(toIso);
@@ -3295,7 +3349,21 @@
3295
3349
  its[(index + its.length) % its.length].focus();
3296
3350
  }
3297
3351
  var _pop = null;
3298
- function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(menu, trigger, { matchWidth: false }); return _pop; }
3352
+ /* `.fdy-menu--end` is the kit's existing spelling for "align this menu to the right", and the
3353
+ stylesheet has always honoured it (left:auto;right:0). The popover path replaces that with
3354
+ inline `fixed` coordinates, so without this line the class was silently dropped the moment
3355
+ the menu went into the top layer: an account menu in a page gutter overshot its avatar and
3356
+ ran to 4px off the screen edge. Reported from an app that patched it with a `translate`
3357
+ hack keyed to the kit's own GAP. */
3358
+ function popCtl() {
3359
+ if (_pop === null && window.FreedayPopover) {
3360
+ _pop = window.FreedayPopover.attach(menu, trigger, {
3361
+ matchWidth: false,
3362
+ align: menu.classList.contains('fdy-menu--end') ? 'end' : 'start'
3363
+ });
3364
+ }
3365
+ return _pop;
3366
+ }
3299
3367
  function open(index) {
3300
3368
  var p = popCtl(); if (p) p.show(); else menu.hidden = false;
3301
3369
  trigger.setAttribute('aria-expanded', 'true');
@@ -3469,7 +3537,8 @@
3469
3537
  * Used by the select/combo, datepicker, timepicker, cascade, autocomplete and menu enhancers;
3470
3538
  * the freeday/vue adapter ships an equivalent `usePopover` composable.
3471
3539
  *
3472
- * attach(panel, trigger) -> { show(), hide() }. show()/hide() also toggle the panel's [hidden]
3540
+ * attach(panel, trigger, opts) -> { show(), hide() }, where opts is { matchWidth, align } and
3541
+ * both are described on place() below. show()/hide() also toggle the panel's [hidden]
3473
3542
  * attribute so callers keep using `!panel.hidden` for open-state and the `:not([hidden])`
3474
3543
  * entrance animation still fires. On browsers without the Popover API it degrades to that
3475
3544
  * [hidden] toggle over the panel's existing absolute-positioned CSS (clipped, but functional).
@@ -3482,35 +3551,56 @@
3482
3551
  && typeof HTMLElement.prototype.showPopover === 'function';
3483
3552
 
3484
3553
  // Position `panel` (already shown, so it has a measurable box) against `trigger`'s rect.
3485
- // matchWidth (default true) floors the panel to the trigger width, right for input-shaped
3486
- // dropdowns (combo/cascade/date/time), skipped for menus that keep their own min-width.
3487
- function place(panel, trigger, matchWidth) {
3488
- var r = trigger.getBoundingClientRect();
3554
+ // opts is the object attach() takes, and both keys are optional:
3555
+ // matchWidth (default true) floors the panel to the trigger width, right for input-shaped
3556
+ // dropdowns (combo/cascade/date/time), skipped for menus that keep their own min-width.
3557
+ // align 'start' (default) lines the panel's LEFT edge up with the trigger's left edge;
3558
+ // 'end' lines its RIGHT edge up with the trigger's right edge, which is what a
3559
+ // control parked in a right-hand gutter needs. The panel is kept inside the
3560
+ // viewport either way, so 'end' near the left edge slides right instead of
3561
+ // hanging off it. freeday-menu.js reads it off `.fdy-menu--end`.
3562
+ function place(panel, trigger, opts) {
3563
+ var o = opts || {};
3564
+ /* Take the panel out of the flow BEFORE measuring anything, because until this runs the panel
3565
+ * is still `position:absolute` with `top:calc(100% + …)` from its component's stylesheet. An
3566
+ * author rule outranks the popover UA style, and in the top layer that `100%` resolves against
3567
+ * the viewport, so a panel that has just been shown sits one viewport height down the page and
3568
+ * drags the document's scrollable overflow with it. Where scrollbars take layout space (every
3569
+ * Windows and Linux browser; macOS overlay scrollbars hide it completely) the document gains a
3570
+ * classic scrollbar for the duration, documentElement.clientWidth drops by its width, and the
3571
+ * page relays out 15px narrower. A trigger rect read in that instant is a rect from a layout
3572
+ * that is about to be undone by the next three lines: measured 961 instead of 976 for an avatar
3573
+ * in a 24px gutter, so the menu landed a scrollbar's width to the left of it. */
3489
3574
  panel.style.position = 'fixed';
3490
3575
  panel.style.margin = '0';
3491
3576
  panel.style.inset = 'auto'; // clear the UA popover centering
3492
- if (matchWidth !== false) panel.style.minWidth = r.width + 'px'; // at least as wide as the trigger
3577
+ var r = trigger.getBoundingClientRect();
3578
+ if (o.matchWidth !== false) panel.style.minWidth = r.width + 'px'; // at least as wide as the trigger
3493
3579
  var ph = panel.offsetHeight, pw = panel.offsetWidth;
3494
3580
  var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
3495
3581
  var below = vh - r.bottom - GAP, above = r.top - GAP;
3496
3582
  // Prefer below; flip above only when it doesn't fit below and above has more room.
3497
3583
  var top = (ph <= below || below >= above) ? (r.bottom + GAP) : Math.max(GAP, r.top - GAP - ph);
3498
- var left = r.left;
3499
- if (left + pw > vw - GAP) left = Math.max(GAP, vw - GAP - pw); // keep within the viewport
3584
+ var left = (o.align === 'end') ? (r.right - pw) : r.left;
3585
+ /* Clamped on BOTH sides, and that is not belt-and-braces: the first line is what keeps a
3586
+ start-aligned panel on screen, the second is what keeps an end-aligned one on screen when
3587
+ the panel is wider than the room to the left of its trigger. Keeping only one of them
3588
+ trades an overflow on one edge for an overflow on the other. */
3589
+ if (left + pw > vw - GAP) left = vw - GAP - pw;
3590
+ if (left < GAP) left = GAP;
3500
3591
  panel.style.top = Math.round(top) + 'px';
3501
3592
  panel.style.left = Math.round(left) + 'px';
3502
3593
  }
3503
3594
 
3504
3595
  function attach(panel, trigger, opts) {
3505
- var matchWidth = !(opts && opts.matchWidth === false);
3506
3596
  if (supported) panel.setAttribute('popover', 'manual');
3507
- function reposition() { if (supported && panel.matches(':popover-open')) place(panel, trigger, matchWidth); }
3597
+ function reposition() { if (supported && panel.matches(':popover-open')) place(panel, trigger, opts); }
3508
3598
  return {
3509
3599
  show: function () {
3510
3600
  panel.hidden = false;
3511
3601
  if (supported) {
3512
3602
  if (!panel.matches(':popover-open')) panel.showPopover();
3513
- place(panel, trigger, matchWidth);
3603
+ place(panel, trigger, opts);
3514
3604
  window.addEventListener('scroll', reposition, true); // capture: any scrolling ancestor
3515
3605
  window.addEventListener('resize', reposition);
3516
3606
  }
@@ -4653,6 +4743,12 @@
4653
4743
  * Markup: a [data-fdy-tabs] with a [role="tablist"] of
4654
4744
  * <button role="tab" aria-controls="PANEL" aria-selected> and matching
4655
4745
  * <div role="tabpanel" id="PANEL" aria-labelledby="TAB" [hidden]>.
4746
+ *
4747
+ * The arrow AXIS comes from the tablist's `aria-orientation`: `vertical` answers to Up and
4748
+ * Down, anything else to Left and Right. One axis per tablist, which is what APG maps, and the
4749
+ * other pair is left entirely alone rather than bound as a second axis: Left and Right over a
4750
+ * column of tabs belong to whatever else is on the page, and a widget that preventDefaults a key
4751
+ * it has no meaning for has taken it from the page silently. Home/End answer on both.
4656
4752
  */
4657
4753
  (function () {
4658
4754
  'use strict';
@@ -4663,6 +4759,19 @@
4663
4759
  if (tabs.length === 0) return;
4664
4760
  root.dataset.fdyTabsReady = '1';
4665
4761
 
4762
+ /* The look follows `.fdy-tabs--vertical`, the behaviour follows `aria-orientation`, and this
4763
+ makes the second agree with the first so an app states the axis once. The attribute always
4764
+ wins when it is there: it is what an assistive technology announces, so nothing here may
4765
+ overwrite one an author wrote. */
4766
+ var list = root.querySelector('[role="tablist"]');
4767
+ if (list !== null && list.getAttribute('aria-orientation') === null
4768
+ && root.classList.contains('fdy-tabs--vertical')) {
4769
+ list.setAttribute('aria-orientation', 'vertical');
4770
+ }
4771
+ var vertical = list !== null && list.getAttribute('aria-orientation') === 'vertical';
4772
+ var NEXT_KEY = vertical ? 'ArrowDown' : 'ArrowRight';
4773
+ var PREV_KEY = vertical ? 'ArrowUp' : 'ArrowLeft';
4774
+
4666
4775
  function panelFor(tab) {
4667
4776
  var id = tab.getAttribute('aria-controls');
4668
4777
  return id ? document.getElementById(id) : null;
@@ -4682,9 +4791,9 @@
4682
4791
  tab.addEventListener('click', function () { select(tab, false); });
4683
4792
  tab.addEventListener('keydown', function (e) {
4684
4793
  switch (e.key) {
4685
- case 'ArrowRight':
4794
+ case NEXT_KEY:
4686
4795
  e.preventDefault(); select(tabs[(i + 1) % tabs.length], true); break;
4687
- case 'ArrowLeft':
4796
+ case PREV_KEY:
4688
4797
  e.preventDefault(); select(tabs[(i - 1 + tabs.length) % tabs.length], true); break;
4689
4798
  case 'Home':
4690
4799
  e.preventDefault(); select(tabs[0], true); break;
@@ -191,7 +191,7 @@ live docs also have a copy button per component.
191
191
  ```bash
192
192
  npm i @cahyo-dimas/freeday
193
193
  ```
194
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.7.0"` (public npm package). `dist/` is
194
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.8.0"` (public npm package). `dist/` is
195
195
  committed and published → no build step; `npm ci` runs without auth.
196
196
 
197
197
  ### 2. Import the CSS + enhancers **once** in your entry (`src/main.ts`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "Freeday: token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -96,7 +96,7 @@
96
96
  "scripts": {
97
97
  "build": "node tokens/build.mjs",
98
98
  "test": "node --test",
99
- "test:browser": "node --test --test-concurrency=3 browser/color-mix.mjs browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/field-width.mjs browser/cfl-multi.mjs browser/cfl-dialog-only.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs browser/overlay-stack.mjs browser/chart-a11y.mjs browser/app-shell.mjs browser/bottom-nav.mjs browser/picker-states.mjs browser/tabs-strip.mjs browser/cfl-narrow.mjs browser/form-reveal.mjs browser/confirm-search-copy.mjs browser/daterange-settled.mjs browser/display-parts.mjs browser/table-empty.mjs browser/page-columns.mjs browser/table-hover.mjs browser/harness-contract.mjs",
99
+ "test:browser": "node --test --test-concurrency=3 browser/color-mix.mjs browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/field-width.mjs browser/cfl-multi.mjs browser/cfl-dialog-only.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs browser/overlay-stack.mjs browser/chart-a11y.mjs browser/app-shell.mjs browser/bottom-nav.mjs browser/picker-states.mjs browser/tabs-strip.mjs browser/tabs-vertical.mjs browser/menu-align.mjs browser/cfl-narrow.mjs browser/form-reveal.mjs browser/confirm-search-copy.mjs browser/daterange-settled.mjs browser/display-parts.mjs browser/table-empty.mjs browser/table-footer-text.mjs browser/month-mode.mjs browser/page-columns.mjs browser/table-hover.mjs browser/harness-contract.mjs",
100
100
  "prepack": "node tokens/build.mjs",
101
101
  "version": "node tokens/build.mjs && git add dist",
102
102
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit",
@@ -32,3 +32,34 @@
32
32
  .fdy-tabs__panel[hidden]{display:none;}
33
33
  /* Reveal the newly-shown panel on tab switch (fade + rise), reduced-motion gated. */
34
34
  @media (prefers-reduced-motion:no-preference){.fdy-tabs__panel:not([hidden]){animation:fdy-combo-in var(--dur-base) var(--ease-standard);}}
35
+
36
+ /* VERTICAL VARIANT: the same tabs turned down the page, list on the leading side, panel beside it.
37
+ *
38
+ * Two markers, one each, and they are not interchangeable. The CLASS decides the look, because a
39
+ * stylesheet cannot reach a parent from a child and the two-column box belongs to `.fdy-tabs`
40
+ * itself. The ATTRIBUTE `aria-orientation="vertical"` on the tablist decides the keyboard, because
41
+ * that is the thing an assistive technology announces and APG requires it on a vertical tablist
42
+ * anyway. `freeday-tabs.js` writes the attribute when only the class is present, so an app states
43
+ * the axis once and the two can never disagree.
44
+ *
45
+ * Selection is a SOFT FILL here, not a leading edge bar. Sideways, the marker is an underline under
46
+ * the tab it belongs to; turned 90 degrees that line lands between two tabs and points at the wrong
47
+ * one. The kit already has an answer for "one item in a vertical list is where you are" and spends
48
+ * it in four places (`.fdy-nav__item[aria-current]`, the combo, cascade and autocomplete options):
49
+ * `--color-primary-soft` behind `--color-primary-strong`. Both are precomputed tone tokens, so this
50
+ * needs no color-mix() and no alpha of its own.
51
+ *
52
+ * What it deliberately does NOT do: pick a column width (set `--fdy-tabs-list-w`, default `auto`),
53
+ * and collapse to one column on a narrow screen. Whether these tabs are a whole page, a card or a
54
+ * drawer is the page's knowledge, not the component's, so the breakpoint is the page's to write. */
55
+ .fdy-tabs--vertical{display:flex;align-items:flex-start;gap:var(--space-5);}
56
+ /* `overflow:visible` on both axes, because the base strip scrolls sideways: a scroll container
57
+ clips to its padding box, and a column has no sideways overflow to scroll, only a focus ring to
58
+ lose. `box-shadow:none` drops the strip's own bottom line with it. */
59
+ .fdy-tabs--vertical .fdy-tabs__list{flex:none;flex-direction:column;width:var(--fdy-tabs-list-w,auto);overflow:visible;box-shadow:none;}
60
+ /* `white-space:normal` and `text-align:start` are corrections, not taste: a tab in a column of
61
+ fixed width that may not wrap will overflow it, and a button centres its label by default. */
62
+ .fdy-tabs--vertical .fdy-tabs__tab{border-bottom:0;border-radius:var(--radius-md);text-align:start;white-space:normal;}
63
+ .fdy-tabs--vertical .fdy-tabs__tab:focus-visible{border-radius:var(--radius-md);}
64
+ .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);}
65
+ .fdy-tabs--vertical .fdy-tabs__panel{flex:1;min-width:0;padding:0;}