@cahyo-dimas/freeday 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/freeday.css CHANGED
@@ -273,6 +273,8 @@ a { color: var(--color-primary); }
273
273
  .fdy-avatar--xs{width:1.5rem;height:1.5rem;font-size:var(--text-xs);}
274
274
  .fdy-avatar--sm{width:2rem;height:2rem;font-size:var(--text-xs);}
275
275
  .fdy-avatar--lg{width:3.5rem;height:3.5rem;font-size:var(--text-lg);}
276
+ /* Square: a mark that is not a person (a company logo, a card's icon chip, #070). */
277
+ .fdy-avatar--square{border-radius:var(--radius-md);}
276
278
  .fdy-avatar-group{display:inline-flex;}
277
279
  .fdy-avatar-group .fdy-avatar{border:2px solid var(--color-surface);margin-left:calc(var(--space-2)*-1);}
278
280
  .fdy-avatar-group .fdy-avatar:first-child{margin-left:0;}
@@ -609,6 +611,13 @@ a { color: var(--color-primary); }
609
611
  absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
610
612
  :where(:is(.fdy-card,.fdy-list__row):has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
611
613
  .fdy-card__body{padding:var(--space-5);}
614
+ /* Header band (#070): an identity mark, the title, and the card's way out pinned right. Beside the
615
+ title the action sits in one place on every card; under the content it moved each time the
616
+ content grew. Outside __body's padding, so its rule spans the card edge to edge. The mark is an
617
+ .fdy-avatar (--sm --square --tone-N), so its tints are the ones the contrast gate already covers. */
618
+ .fdy-card__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
619
+ .fdy-card__head .fdy-card__title{flex:1 1 auto;min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
620
+ .fdy-card__action{flex:none;display:inline-flex;align-items:center;gap:var(--space-2);}
612
621
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
613
622
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
614
623
  .fdy-card__footer{padding:var(--space-3) var(--space-5);border-top:var(--bw) solid var(--color-border-muted);background:linear-gradient(180deg,var(--color-surface),var(--color-surface-2));display:flex;gap:var(--space-2);justify-content:flex-end;}
@@ -991,6 +1000,42 @@ a { color: var(--color-primary); }
991
1000
  .fdy-text-success{color:var(--color-success-strong);}
992
1001
  .fdy-text-warning{color:var(--color-warning-strong);}
993
1002
  .fdy-text-danger{color:var(--color-danger-strong);}
1003
+ /* Document columns (#065): a form or record page with an optional identity rail on the left
1004
+ * (__lead) and a summary rail on the right (__aside). The widths follow which rails are PRESENT,
1005
+ * so the layout is decided once here and not again by every screen:
1006
+ * __main alone one column
1007
+ * __main + __aside 8 / 4 (from md)
1008
+ * __lead + __main 3 / 9 (from md)
1009
+ * all three 3 / 6 / 3 (from lg; between md and lg both rails share the left)
1010
+ * Stacked, the summary rail comes FIRST: last, it sits under every section, and its button with it.
1011
+ * From md each rail is sticky and scrolls on its own, so the total and the status stay answerable
1012
+ * while the main column scrolls with the page, and a rail taller than the screen never hangs its
1013
+ * end off it. Screen only: a printed page is not held to a window. */
1014
+ .fdy-page__columns{--_fdy-rail-top:var(--fdy-page-sticky-top,var(--space-4));display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-6);align-items:start;}
1015
+ /* Under the shell the sticky topbar is in the way; --fdy-page-sticky-top overrides either default. */
1016
+ .fdy-app .fdy-page__columns{--_fdy-rail-top:var(--fdy-page-sticky-top,calc(var(--space-16) + var(--space-4)));}
1017
+ .fdy-page__columns > *{min-width:0;}
1018
+ @media (max-width:959.98px){.fdy-page__aside{order:-1;}}
1019
+ @media (min-width:960px){
1020
+ .fdy-page__columns:has(> .fdy-page__aside){grid-template-columns:minmax(0,2fr) minmax(0,1fr);}
1021
+ .fdy-page__columns:has(> .fdy-page__lead){grid-template-columns:minmax(0,1fr) minmax(0,3fr);}
1022
+ /* Both rails, two columns: they stack on the left while __main spans both rows on the right. */
1023
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > .fdy-page__main{grid-row:span 2;}
1024
+ }
1025
+ @media (min-width:1280px){
1026
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside){grid-template-columns:minmax(0,1fr) minmax(0,2fr) minmax(0,1fr);}
1027
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > .fdy-page__main{grid-row:auto;}
1028
+ }
1029
+ /* The padding, taken back by the margin, is room for a focus ring at a card's edge: a scrolling box
1030
+ clips whatever draws outside it. */
1031
+ @media screen and (min-width:960px){
1032
+ .fdy-page__lead,.fdy-page__aside{position:sticky;top:var(--_fdy-rail-top);max-height:calc(100dvh - var(--_fdy-rail-top) - var(--space-4));overflow-y:auto;overscroll-behavior:contain;padding:var(--space-1);margin:calc(var(--space-1) * -1);}
1033
+ }
1034
+ /* Two sticky rails in ONE column would slide over each other: between md and lg, with both rails
1035
+ present, they scroll with the page instead. */
1036
+ @media screen and (min-width:960px) and (max-width:1279.98px){
1037
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > :is(.fdy-page__lead,.fdy-page__aside){position:static;max-height:none;overflow:visible;}
1038
+ }
994
1039
 
995
1040
  /* Freeday. Date picker (input-styled trigger + calendar popover).
996
1041
  * Built by freeday-datepicker.js from an empty [data-fdy-datepicker] wrapper. */
@@ -1086,9 +1131,19 @@ a { color: var(--color-primary); }
1086
1131
  /* Freeday. Description list (key-value detail view) */
1087
1132
  .fdy-dl{display:grid;grid-template-columns:auto 1fr;gap:var(--space-2) var(--space-5);margin:0;}
1088
1133
  .fdy-dl dt{color:var(--color-text-muted);font-size:var(--text-sm);}
1089
- .fdy-dl dd{margin:0;color:var(--color-text);font-size:var(--text-sm);font-weight:var(--weight-medium);}
1134
+ /* overflow-wrap: an email, an order code or a 13-digit phone number is one unbreakable token, and
1135
+ without it the value prints over whatever sits beside it (#066). */
1136
+ .fdy-dl dd{margin:0;min-width:0;color:var(--color-text);font-size:var(--text-sm);font-weight:var(--weight-medium);overflow-wrap:anywhere;}
1090
1137
  .fdy-dl--rows{grid-template-columns:1fr;gap:var(--space-1);}
1091
1138
  .fdy-dl--rows dt{margin-top:var(--space-2);}
1139
+ /* Facts grid (#066): each pair a <div> (allowed inside <dl>), caption above value, as many columns
1140
+ as the list's OWN width holds, never more than three. Sized by the element rather than the
1141
+ window, so a grid in a narrow side rail stays one column with no modifier: a window breakpoint
1142
+ gave a rail two columns of about 110px. min(100%, …) keeps a single column from overflowing a
1143
+ rail narrower than the column floor. */
1144
+ .fdy-dl--grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,max(var(--fdy-dl-col-min,14rem),calc((100% - 2 * var(--space-6)) / 3))),1fr));gap:var(--space-5) var(--space-6);}
1145
+ .fdy-dl--grid > div{min-width:0;}
1146
+ .fdy-dl--grid dd{margin-top:var(--space-1);}
1092
1147
 
1093
1148
  /* Freeday. Divider */
1094
1149
  .fdy-divider{border:0;border-top:var(--bw) solid var(--color-border);margin:var(--space-4) 0;}
@@ -1480,6 +1535,12 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1480
1535
  .fdy-switch input::before{content:"";position:absolute;top:2px;left:2px;width:calc(var(--control-switch-h) - 4px);height:calc(var(--control-switch-h) - 4px);border-radius:50%;background:var(--color-switch-thumb);box-shadow:var(--shadow-1);transition:transform var(--dur-base) var(--ease-spring);}
1481
1536
  .fdy-switch input:checked{background:var(--color-primary);}
1482
1537
  .fdy-switch input:checked::before{transform:translateX(calc(var(--control-switch-w) - var(--control-switch-h)));}
1538
+ /* State hint (#068): one line saying what the CURRENT state means, never "on means X, off means Y",
1539
+ which leaves the reader to work out which half applies. The control's aria-describedby points at
1540
+ the .fdy-help WRAPPING both lines: a hidden child of a referenced node is skipped, so a screen
1541
+ reader hears only the line on screen. Point it at the two children and both are read. */
1542
+ .fdy-field:has(:is(.fdy-switch,.fdy-check) input:checked) [data-fdy-when="off"],
1543
+ .fdy-field:not(:has(:is(.fdy-switch,.fdy-check) input:checked)) [data-fdy-when="on"]{display:none;}
1483
1544
 
1484
1545
  /* Standalone checkbox (e.g. table row/select-all), supports :indeterminate */
1485
1546
  .fdy-checkbox{appearance:none;-webkit-appearance:none;flex:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);border-radius:var(--radius-sm);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
@@ -1531,6 +1592,13 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1531
1592
  .fdy-state__text{font-size:var(--text-sm);color:var(--color-text-muted);max-width:24rem;line-height:var(--leading-normal);}
1532
1593
  .fdy-state__actions{margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:center;}
1533
1594
  .fdy-state--danger .fdy-state__icon{color:var(--color-danger-strong);}
1595
+ /* Compact (#069): the same state inside a small card, one line of icon and text instead of a
1596
+ page-sized block. Danger matters most here: a widget that renders zero for a failed request
1597
+ reads as "nothing to do", the opposite of what happened. */
1598
+ .fdy-state--compact{flex-direction:row;align-items:flex-start;text-align:left;gap:var(--space-2);padding:var(--space-2) 0;}
1599
+ .fdy-state--compact .fdy-state__icon{width:1rem;height:1rem;flex:none;}
1600
+ .fdy-state--compact .fdy-state__text{font-size:var(--text-xs);max-width:none;}
1601
+ .fdy-state--compact .fdy-state__actions{margin:0 0 0 auto;}
1534
1602
 
1535
1603
  /* Freeday. Stepper / Wizard (progress header + optional panels).
1536
1604
  * Enhanced by freeday-stepper.js for prev/next navigation. */
@@ -1587,8 +1655,17 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1587
1655
  itself, for the same reason: hover paints the full step, so the two must not be the same colour.
1588
1656
  Exposed as a Tier-3 token through the var() FALLBACK, never as a declaration — a custom property
1589
1657
  set here would outrank a host's own :root override. */
1590
- .fdy-table--striped tbody tr:nth-child(even){background:var(--fdy-table-stripe,color-mix(in srgb,var(--color-surface-2) 55%,var(--color-surface)));}
1591
- .fdy-table tbody tr:hover{background:var(--color-surface-2);}
1658
+ /* The resting colour is also published as --_fdy-row-rest, so a row that must not answer the
1659
+ pointer can hover back to exactly what it was (see --hover-actionable). */
1660
+ .fdy-table--striped tbody tr:nth-child(even){--_fdy-row-rest:var(--fdy-table-stripe,color-mix(in srgb,var(--color-surface-2) 55%,var(--color-surface)));background:var(--_fdy-row-rest);}
1661
+ .fdy-table tbody tr:hover{background:var(--_fdy-row-hover,var(--color-surface-2));}
1662
+ /* Opt-in (#064): only a row that DOES something answers the pointer. A tint on a row with no click
1663
+ handler promises a target that is not there: people click the row, nothing happens, and the
1664
+ button they had to aim at was elsewhere in it. Striping, not hover, is what keeps the eye on a
1665
+ line. The inert row's hover resolves to its own resting colour, so a stripe survives it; a frozen
1666
+ cell falls back to its opaque surface, because it scrolls over its neighbours. */
1667
+ .fdy-table--hover-actionable tbody tr:not(.fdy-table__row--activatable){--_fdy-row-hover:var(--_fdy-row-rest,transparent);}
1668
+ .fdy-table--hover-actionable tbody tr:not(.fdy-table__row--activatable) td.fdy-table__freeze{--_fdy-row-hover:var(--_fdy-row-rest,var(--color-surface));}
1592
1669
  /* Monospace data, ids, codes, IPs, timestamps. Alignment-neutral so it works in any cell
1593
1670
  or inline (`<span class="fdy-mono">`), not just numeric columns. */
1594
1671
  .fdy-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;}
@@ -1597,8 +1674,8 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1597
1674
  .fdy-table__num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--font-mono);}
1598
1675
  /* Loading / empty state cell (FdyTable spans it across all columns). */
1599
1676
  .fdy-table__state{text-align:center;color:var(--color-text-muted);padding:var(--space-6) var(--space-4);}
1600
- /* Activatable row (FdyTable rowActivatable): pointer + keyboard focus ring. The hover tint already
1601
- comes from the base `.fdy-table tbody tr:hover` rule, so this only adds the affordance + focus. */
1677
+ /* Activatable row (FdyTable rowActivatable): pointer + keyboard focus ring. The hover tint comes from
1678
+ the base `.fdy-table tbody tr:hover` rule, which --hover-actionable keeps for these rows only. */
1602
1679
  .fdy-table__row--activatable{cursor:pointer;}
1603
1680
  .fdy-table__row--activatable:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px;}
1604
1681
  /* Expandable detail row (FdyTable row-detail slot): a muted panel under its row, no hover tint. */
@@ -1658,7 +1735,7 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1658
1735
  (0,4,3) and beat the hover rule below. At (0,3,3) it ties, and the later rule wins, so a hovered
1659
1736
  row still bands unbroken across the freeze line. */
1660
1737
  .fdy-table--striped tbody tr:nth-child(even) td.fdy-table__freeze{background:var(--fdy-table-stripe,color-mix(in srgb,var(--color-surface-2) 55%,var(--color-surface)));}
1661
- .fdy-table--sticky tbody tr:hover td.fdy-table__freeze{background:var(--color-surface-2);}
1738
+ .fdy-table--sticky tbody tr:hover td.fdy-table__freeze{background:var(--_fdy-row-hover,var(--color-surface-2));}
1662
1739
  /* A frozen header cell is stuck on both axes wherever the head is frozen too, so
1663
1740
  it has to outrank both. */
1664
1741
  .fdy-table--sticky thead .fdy-table__freeze{z-index:3;}
package/dist/freeday.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Type surface for the root side-effect import `@cahyo-dimas/freeday`, which loads every
2
- // enhancer and registers the `window.Freeday` global. Authored from src/freeday-toast.js
3
- // Keep it in sync with that file. (The per-component window.Freeday<Name> enhancer namespaces are
2
+ // enhancer and registers the `window.Freeday` global. Authored from src/freeday-toast.js and
3
+ // src/freeday-confirm.js. Keep it in sync with those files. (The per-component window.Freeday<Name> enhancer namespaces are
4
4
  // intentionally not typed here; no consumer reconstructs those.)
5
5
 
6
6
  export interface FreedayToastOptions {
@@ -15,11 +15,26 @@ export interface FreedayToastOptions {
15
15
  key?: string;
16
16
  }
17
17
 
18
+ export interface FreedayConfirmOptions {
19
+ title?: string;
20
+ message?: string;
21
+ /** Findings shown as a list under the message. */
22
+ details?: string[];
23
+ /** Accept label. Default 'Confirm'; name the action instead ('Delete order'). */
24
+ acceptText?: string;
25
+ /** Reject label. Default 'Cancel'. */
26
+ rejectText?: string;
27
+ /** The accept button's variant. Default 'primary'. */
28
+ tone?: 'primary' | 'danger';
29
+ }
30
+
18
31
  export interface FreedayGlobal {
19
32
  /** Show a transient toast in a live region (created on first use). Returns the toast element. */
20
33
  toast(opts?: FreedayToastOptions): HTMLElement;
21
34
  /** Dismiss a toast early: pass the element returned by toast(), or a key string. */
22
35
  dismiss(target: HTMLElement | string): void;
36
+ /** Ask a yes/no question in a modal. true on accept; false on reject, Escape or a backdrop click. */
37
+ confirm(opts?: FreedayConfirmOptions): Promise<boolean>;
23
38
  }
24
39
 
25
40
  declare global {
package/dist/freeday.js CHANGED
@@ -1924,6 +1924,167 @@
1924
1924
  window.FreedayChip = { initGroup: initGroup, initRemove: initRemove, initAll: initAll };
1925
1925
  })();
1926
1926
 
1927
+ /* Freeday, confirm API (optional, zero-dependency). Note #060.
1928
+ * Freeday.confirm({ title, message, details, acceptText, rejectText, tone }) opens a .fdy-modal
1929
+ * and resolves a Promise<boolean>: true on accept; false on reject, Escape or a backdrop click.
1930
+ * details: string[] shown as a list under the message, for a confirm that has to NAME its
1931
+ * findings ("these three fields will be refused") instead of folding them into one
1932
+ * long sentence
1933
+ * tone: 'primary' (default) | 'danger', the accept button's variant
1934
+ *
1935
+ * One dialog, one answer: every way out resolves the same Promise, and a Promise settles once, so
1936
+ * a caller never hears "no" after "yes" (a framework dialog that reports reject and then hide, or
1937
+ * hide after accept, does exactly that). Text is set with textContent, never parsed as HTML.
1938
+ * Focus starts on the reject button: Enter on a dialog that just opened must not delete anything.
1939
+ * The dialog is removed when it closes; the native <dialog> returns focus to where it was.
1940
+ * Buttons carry data-fdy-confirm-accept / data-fdy-confirm-reject, stable hooks for tests, because
1941
+ * an accept labelled "Delete" collides by accessible name with every row's own Delete button.
1942
+ */
1943
+ (function () {
1944
+ 'use strict';
1945
+
1946
+ /* User-facing strings, English by default. Per call, acceptText / rejectText override them; a
1947
+ * label that names the action ("Delete order") is better than either default. */
1948
+ var TEXT = {
1949
+ accept: 'Confirm',
1950
+ reject: 'Cancel'
1951
+ };
1952
+
1953
+ var seq = 0;
1954
+
1955
+ function el(tag, className, text) {
1956
+ var node = document.createElement(tag);
1957
+ if (className) node.className = className;
1958
+ if (text != null) node.textContent = text;
1959
+ return node;
1960
+ }
1961
+
1962
+ function confirm(opts) {
1963
+ opts = opts || {};
1964
+ return new Promise(function (resolve) {
1965
+ seq += 1;
1966
+ var titleId = 'fdy-confirm-title-' + seq;
1967
+ var dialog = el('dialog', 'fdy-modal fdy-modal--sm');
1968
+ dialog.setAttribute('aria-labelledby', titleId);
1969
+ dialog.setAttribute('data-fdy-confirm', '');
1970
+
1971
+ var header = el('header', 'fdy-modal__header');
1972
+ var title = el('h2', 'fdy-modal__title', opts.title || '');
1973
+ title.id = titleId;
1974
+ header.appendChild(title);
1975
+
1976
+ var body = el('div', 'fdy-modal__body');
1977
+ if (opts.message) {
1978
+ var message = el('p', null, opts.message);
1979
+ message.id = titleId + '-desc';
1980
+ dialog.setAttribute('aria-describedby', message.id);
1981
+ body.appendChild(message);
1982
+ }
1983
+ var details = Array.isArray(opts.details) ? opts.details : [];
1984
+ if (details.length > 0) {
1985
+ var list = el('ul');
1986
+ details.forEach(function (line) { list.appendChild(el('li', null, line)); });
1987
+ body.appendChild(list);
1988
+ }
1989
+
1990
+ var footer = el('footer', 'fdy-modal__footer');
1991
+ var reject = el('button', 'fdy-btn fdy-btn--ghost', opts.rejectText || TEXT.reject);
1992
+ reject.type = 'button';
1993
+ reject.setAttribute('data-fdy-confirm-reject', '');
1994
+ var accept = el('button', opts.tone === 'danger' ? 'fdy-btn fdy-btn--danger' : 'fdy-btn', opts.acceptText || TEXT.accept);
1995
+ accept.type = 'button';
1996
+ accept.setAttribute('data-fdy-confirm-accept', '');
1997
+ footer.appendChild(reject);
1998
+ footer.appendChild(accept);
1999
+
2000
+ dialog.appendChild(header);
2001
+ dialog.appendChild(body);
2002
+ dialog.appendChild(footer);
2003
+
2004
+ function settle(answer) {
2005
+ resolve(answer);
2006
+ if (dialog.open) dialog.close();
2007
+ }
2008
+
2009
+ accept.addEventListener('click', function () { settle(true); });
2010
+ reject.addEventListener('click', function () { settle(false); });
2011
+ // Escape fires `cancel` then `close`; a backdrop click lands on the <dialog> itself, since its
2012
+ // children fill the box edge to edge. Both mean "no".
2013
+ dialog.addEventListener('close', function () {
2014
+ settle(false);
2015
+ if (dialog.parentNode) dialog.parentNode.removeChild(dialog);
2016
+ });
2017
+ dialog.addEventListener('click', function (e) { if (e.target === dialog) settle(false); });
2018
+
2019
+ document.body.appendChild(dialog);
2020
+ dialog.showModal();
2021
+ reject.focus();
2022
+ });
2023
+ }
2024
+
2025
+ window.Freeday = window.Freeday || {};
2026
+ window.Freeday.confirm = confirm;
2027
+ })();
2028
+
2029
+ /* Freeday, copy-to-clipboard enhancer (optional, zero-dependency). Note #062.
2030
+ * A button carrying data-fdy-copy="<value>" copies that value and says so with a toast. For
2031
+ * reference values a reader takes elsewhere: an external document number, an event ID.
2032
+ *
2033
+ * <dd><span class="fdy-mono">INV-2026-00042</span>
2034
+ * <button type="button" class="fdy-btn fdy-btn--ghost fdy-btn--icon fdy-btn--sm"
2035
+ * data-fdy-copy="INV-2026-00042" aria-label="Copy invoice number">…svg…</button></dd>
2036
+ *
2037
+ * The value is the attribute, not a lookup of some neighbour's text: a framework writes it from
2038
+ * the same variable as the visible text, and a row of a table needs no ids to find its own cell.
2039
+ * One delegated listener on the document, so buttons rendered later need no hydration.
2040
+ *
2041
+ * The Clipboard API exists only in a secure context. Over plain http writeText is missing, and a
2042
+ * copy that silently did nothing is the failure this refuses: the reader is told to copy by hand.
2043
+ * Emits bubbling "fdy-copy" {value, ok} on the button. A click inside a clickable table row still
2044
+ * reaches the row's own handler; stop it there if the row should not open. Exposes window.FreedayCopy.
2045
+ */
2046
+ (function () {
2047
+ 'use strict';
2048
+
2049
+ /* User-facing strings, English by default and overridable per button with data-fdy-text-<key>,
2050
+ * like every other enhancer's. */
2051
+ var TEXT = {
2052
+ copied: 'Copied.',
2053
+ failed: 'Could not copy. Select the text and copy it manually.'
2054
+ };
2055
+ function textOf(el, key) {
2056
+ var custom = el.getAttribute('data-fdy-text-' + key);
2057
+ return custom != null && custom !== '' ? custom : TEXT[key];
2058
+ }
2059
+
2060
+ function report(btn, value, ok) {
2061
+ if (window.Freeday && typeof window.Freeday.toast === 'function') {
2062
+ window.Freeday.toast(ok
2063
+ ? { variant: 'success', message: textOf(btn, 'copied'), key: 'fdy-copy' }
2064
+ : { variant: 'danger', message: textOf(btn, 'failed'), key: 'fdy-copy', timeout: 0 });
2065
+ }
2066
+ btn.dispatchEvent(new CustomEvent('fdy-copy', { bubbles: true, detail: { value: value, ok: ok } }));
2067
+ }
2068
+
2069
+ function copy(btn) {
2070
+ var value = btn.getAttribute('data-fdy-copy') || '';
2071
+ if (value === '') return;
2072
+ var clip = navigator.clipboard;
2073
+ if (!clip || typeof clip.writeText !== 'function') { report(btn, value, false); return; }
2074
+ clip.writeText(value).then(
2075
+ function () { report(btn, value, true); },
2076
+ function () { report(btn, value, false); }
2077
+ );
2078
+ }
2079
+
2080
+ document.addEventListener('click', function (e) {
2081
+ var btn = e.target.closest && e.target.closest('[data-fdy-copy]');
2082
+ if (btn && !btn.disabled) copy(btn);
2083
+ });
2084
+
2085
+ window.FreedayCopy = { copy: copy };
2086
+ })();
2087
+
1927
2088
  /* Freeday, date picker enhancer (optional, zero-dependency).
1928
2089
  * Builds an input-styled trigger + calendar popover from an empty wrapper.
1929
2090
  * Locale comes from <html lang> (via Intl), month/weekday/value formatting is not hardcoded.
@@ -2529,15 +2690,30 @@
2529
2690
  fromApi.setRange(f, t); // start stays free, picking a later start clears the end below
2530
2691
  toApi.setRange(f, t);
2531
2692
  }
2693
+ /* An end the new start overtook is cleared, and the clear is ANNOUNCED like a pick: a binding
2694
+ * that only hears picks (Blazor's To) otherwise keeps the end the range no longer has. */
2695
+ function clearEnd(el, api) {
2696
+ api.clear();
2697
+ el.dispatchEvent(new CustomEvent('fdy-datepicker-change', { bubbles: true, detail: { value: '' } }));
2698
+ }
2699
+ /* "Settled" = both ends chosen, or both empty: the only states worth a reload (#063). A pick of
2700
+ * the first end, or a start that overtook the end, is half a range. */
2701
+ function settle() {
2702
+ var f = fromApi.getValue(), t = toApi.getValue();
2703
+ if ((f === '') !== (t === '')) return;
2704
+ root.dispatchEvent(new CustomEvent('fdy-daterange-settled', { bubbles: true, detail: { from: f, to: t } }));
2705
+ }
2532
2706
  fromEl.addEventListener('fdy-datepicker-change', function () {
2533
2707
  var f = fromApi.getValue(), t = toApi.getValue();
2534
- if (f && t && t < f) toApi.clear();
2708
+ if (f && t && t < f) clearEnd(toEl, toApi);
2535
2709
  sync();
2710
+ settle();
2536
2711
  });
2537
2712
  toEl.addEventListener('fdy-datepicker-change', function () {
2538
2713
  var f = fromApi.getValue(), t = toApi.getValue();
2539
- if (f && t && t < f) fromApi.clear();
2714
+ if (f && t && t < f) clearEnd(fromEl, fromApi);
2540
2715
  sync();
2716
+ settle();
2541
2717
  });
2542
2718
  sync();
2543
2719
  }
@@ -2720,7 +2896,7 @@
2720
2896
  * data-fdy-msg-min | -max | -step | -mismatch, then generic data-fdy-msg.
2721
2897
  * Cross-field match (e.g. confirm password): data-fdy-match="#otherId".
2722
2898
  *
2723
- * Timing: validates on submit (blocks + focuses the first invalid control); after
2899
+ * Timing: validates on submit (blocks, reveals + focuses the first invalid control); after
2724
2900
  * the first submit attempt each field re-validates on input/blur so errors clear
2725
2901
  * live. Add data-fdy-eager to validate on blur from the start.
2726
2902
  *
@@ -2833,6 +3009,34 @@
2833
3009
  }
2834
3010
  }
2835
3011
 
3012
+ /* focus() on a control that is not rendered does nothing and reports nothing: the field turns red
3013
+ * where nobody can see it, and the submit reads as a dead button (#059). So whatever hides the
3014
+ * control is undone first, each through that component's own control, so its state agrees with
3015
+ * what is shown:
3016
+ * closed <details> opened (accordion, tree, nav group)
3017
+ * hidden [role=tabpanel] its tab is clicked; freeday-tabs selects on click
3018
+ * hidden .fdy-step-panel its step button is clicked. A step not yet reached has a disabled
3019
+ * button, so this only ever goes BACK and never skips a step guard.
3020
+ * Anything else that hides it belongs to the app, and is left alone. */
3021
+ function reveal(el) {
3022
+ for (var node = el.parentElement; node; node = node.parentElement) {
3023
+ if (node.localName === 'details' && !node.open) {
3024
+ node.open = true;
3025
+ } else if (node.hidden && node.getAttribute('role') === 'tabpanel' && node.id) {
3026
+ var tab = Array.prototype.filter.call(document.querySelectorAll('[role="tab"]'), function (t) {
3027
+ return t.getAttribute('aria-controls') === node.id;
3028
+ })[0];
3029
+ if (tab) tab.click();
3030
+ } else if (node.hidden && node.classList.contains('fdy-step-panel')) {
3031
+ var stepper = node.closest('[data-fdy-stepper]');
3032
+ if (!stepper) continue;
3033
+ var index = Array.prototype.indexOf.call(stepper.querySelectorAll('.fdy-step-panel'), node);
3034
+ var btn = stepper.querySelectorAll('.fdy-step__btn')[index];
3035
+ if (btn && !btn.disabled) btn.click();
3036
+ }
3037
+ }
3038
+ }
3039
+
2836
3040
  function initForm(form) {
2837
3041
  if (form.dataset.fdyFormReady === '1') return;
2838
3042
  form.dataset.fdyFormReady = '1';
@@ -2867,6 +3071,7 @@
2867
3071
  });
2868
3072
  if (invalid.length) {
2869
3073
  e.preventDefault();
3074
+ reveal(invalid[0]);
2870
3075
  invalid[0].focus();
2871
3076
  form.dispatchEvent(new CustomEvent('fdy-form-invalid', { bubbles: true, detail: { invalid: invalid } }));
2872
3077
  } else {
@@ -3378,6 +3583,84 @@
3378
3583
  window.FreedayRating = { init: initRating, initAll: initAll };
3379
3584
  })();
3380
3585
 
3586
+ /* Freeday, list search enhancer (optional, zero-dependency). Note #061.
3587
+ * Turns an .fdy-input-group into a search box that searches while the reader pauses, at once on
3588
+ * Enter, and has a clear button. All three paths end in ONE bubbling event on the group,
3589
+ * "fdy-search" {term}, so an app listens once instead of wiring keyup, a timer and a click.
3590
+ *
3591
+ * Markup (the group, not a new block; the clear button is yours, so is its aria-label):
3592
+ * <div class="fdy-input-group" data-fdy-search [data-fdy-search-delay="400"]>
3593
+ * <span class="fdy-input-group__addon fdy-input-group__addon--icon">…svg…</span>
3594
+ * <input class="fdy-input" type="text" aria-label="Search orders">
3595
+ * <button type="button" class="fdy-input-group__btn" data-fdy-search-clear aria-label="Clear search">×</button>
3596
+ * </div>
3597
+ *
3598
+ * type="text", not "search": WebKit's own × is unthemed and fires no Enter, which is what made
3599
+ * clearing a field leave the old rows on screen until somebody pressed Enter on an empty box.
3600
+ * The clear button is hidden while the box is empty. Clearing writes through the native value
3601
+ * setter and fires a native `input`, so v-model, a React onChange and Blazor @bind all follow.
3602
+ * `term` is trimmed; "" means "show everything again". Exposes window.FreedaySearch.
3603
+ */
3604
+ (function () {
3605
+ 'use strict';
3606
+
3607
+ var DEFAULT_DELAY = 400;
3608
+ var nativeValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
3609
+
3610
+ function initSearch(root) {
3611
+ if (root.dataset.fdySearchReady === '1') return;
3612
+ var input = root.querySelector('input');
3613
+ if (!input) return;
3614
+ root.dataset.fdySearchReady = '1';
3615
+ var clearBtn = root.querySelector('[data-fdy-search-clear]');
3616
+ var delayAttr = parseInt(root.getAttribute('data-fdy-search-delay'), 10);
3617
+ var delay = isNaN(delayAttr) ? DEFAULT_DELAY : delayAttr;
3618
+ var timer = null;
3619
+
3620
+ function cancel() { if (timer !== null) { clearTimeout(timer); timer = null; } }
3621
+ function emit() {
3622
+ cancel();
3623
+ root.dispatchEvent(new CustomEvent('fdy-search', { bubbles: true, detail: { term: input.value.trim() } }));
3624
+ }
3625
+ function syncClear() { if (clearBtn) clearBtn.hidden = input.value === ''; }
3626
+
3627
+ input.addEventListener('input', function () {
3628
+ syncClear();
3629
+ cancel();
3630
+ timer = setTimeout(emit, delay);
3631
+ });
3632
+ input.addEventListener('keydown', function (e) {
3633
+ if (e.key !== 'Enter') return;
3634
+ e.preventDefault(); // inside a <form>, Enter would submit it as well
3635
+ emit();
3636
+ });
3637
+ if (clearBtn) {
3638
+ clearBtn.addEventListener('click', function () {
3639
+ nativeValue.call(input, '');
3640
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3641
+ emit(); // at once, and it cancels the pause the input event just started
3642
+ input.focus();
3643
+ });
3644
+ }
3645
+ syncClear();
3646
+ }
3647
+
3648
+ function initAll(context) {
3649
+ var root = context || document;
3650
+ /* root included: querySelectorAll never matches its own root, and a framework ref often sits ON the widget. */
3651
+ if (root.matches && root.matches('[data-fdy-search]')) initSearch(root);
3652
+ Array.prototype.forEach.call(root.querySelectorAll('[data-fdy-search]'), initSearch);
3653
+ }
3654
+
3655
+ if (document.readyState === 'loading') {
3656
+ document.addEventListener('DOMContentLoaded', function () { initAll(); });
3657
+ } else {
3658
+ initAll();
3659
+ }
3660
+
3661
+ window.FreedaySearch = { init: initSearch, initAll: initAll };
3662
+ })();
3663
+
3381
3664
  /* Freeday, combobox enhancer (optional, zero-dependency).
3382
3665
  * Implements the WAI-ARIA APG "select-only combobox" pattern for every
3383
3666
  * [data-fdy-combo] on the page. Progressive enhancement: the markup is the
@@ -137,6 +137,14 @@ Recent additions most likely to replace something an app hand-rolled (all detail
137
137
  | `size="cfl"` on `FdyModal` | writing `.fdy-modal--cfl` by hand to reach the 46rem width |
138
138
  | `dialogOnly` + `open()` on `FdyCfl` | a hand-rolled picker, because the trigger is a chip or a table cell and not a 22rem field |
139
139
  | `.fdy-field--full` | `max-width: none` on the form container, once per form, because the fields are a flex column and not a `.fdy-form-grid` |
140
+ | `.fdy-page__columns` | a hand-rolled 8/4 or 3/6/3 grid per screen, with a summary rail that falls under every section on a phone |
141
+ | `.fdy-dl--grid` | a facts grid whose columns follow the window, so a side rail gets two 110px columns |
142
+ | `.fdy-card__head` | a card title row with its "View all" link at the bottom, moving every time the content grows |
143
+ | `settled` on `FdyDateRange` | reloading a list on each end of a range, the first time with the start alone |
144
+ | `{ filtered, clearFilters }` on `FdyTable`'s empty state | one "No data" sentence for both "nothing matches" and "nothing yet" |
145
+ | `Freeday.confirm()` | a confirm dialog assembled from a modal, two buttons and a flag so it answers only once |
146
+ | `[data-fdy-search]` | a search box that only searches on Enter, so emptying it leaves the old rows on screen |
147
+ | `[data-fdy-copy]` | selecting an ID by hand, or a copy button that silently does nothing over http |
140
148
  | `.fdy-nav--flat` | undoing the caret, the pointer cursor and the group divider yourself, on a sidebar whose group labels never collapse |
141
149
  | `--fdy-app-sidebar-w` | overriding `width` on `.fdy-app__sidebar`, which is also the rule that animates the collapse |
142
150
  | `@media (--fdy-filterbar-stacked)` / `(--fdy-nav-drawer)`, from `@cahyo-dimas/freeday/media` | a literal `640px` or `720px` retyped into your stylesheet, which no longer matches the kit's rule the day the kit retunes it — and which you could only have found by reading `filterbar.css` |
@@ -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.5.0"` (public npm package). `dist/` is
194
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.6.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.5.0",
3
+ "version": "3.6.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/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/harness-contract.mjs",
99
+ "test:browser": "node --test --test-concurrency=3 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",
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",
@@ -8,6 +8,8 @@
8
8
  .fdy-avatar--xs{width:1.5rem;height:1.5rem;font-size:var(--text-xs);}
9
9
  .fdy-avatar--sm{width:2rem;height:2rem;font-size:var(--text-xs);}
10
10
  .fdy-avatar--lg{width:3.5rem;height:3.5rem;font-size:var(--text-lg);}
11
+ /* Square: a mark that is not a person (a company logo, a card's icon chip, #070). */
12
+ .fdy-avatar--square{border-radius:var(--radius-md);}
11
13
  .fdy-avatar-group{display:inline-flex;}
12
14
  .fdy-avatar-group .fdy-avatar{border:2px solid var(--color-surface);margin-left:calc(var(--space-2)*-1);}
13
15
  .fdy-avatar-group .fdy-avatar:first-child{margin-left:0;}
@@ -32,6 +32,13 @@
32
32
  absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
33
33
  :where(:is(.fdy-card,.fdy-list__row):has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
34
34
  .fdy-card__body{padding:var(--space-5);}
35
+ /* Header band (#070): an identity mark, the title, and the card's way out pinned right. Beside the
36
+ title the action sits in one place on every card; under the content it moved each time the
37
+ content grew. Outside __body's padding, so its rule spans the card edge to edge. The mark is an
38
+ .fdy-avatar (--sm --square --tone-N), so its tints are the ones the contrast gate already covers. */
39
+ .fdy-card__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
40
+ .fdy-card__head .fdy-card__title{flex:1 1 auto;min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
41
+ .fdy-card__action{flex:none;display:inline-flex;align-items:center;gap:var(--space-2);}
35
42
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
36
43
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
37
44
  .fdy-card__footer{padding:var(--space-3) var(--space-5);border-top:var(--bw) solid var(--color-border-muted);background:linear-gradient(180deg,var(--color-surface),var(--color-surface-2));display:flex;gap:var(--space-2);justify-content:flex-end;}
@@ -95,3 +95,39 @@
95
95
  .fdy-text-success{color:var(--color-success-strong);}
96
96
  .fdy-text-warning{color:var(--color-warning-strong);}
97
97
  .fdy-text-danger{color:var(--color-danger-strong);}
98
+ /* Document columns (#065): a form or record page with an optional identity rail on the left
99
+ * (__lead) and a summary rail on the right (__aside). The widths follow which rails are PRESENT,
100
+ * so the layout is decided once here and not again by every screen:
101
+ * __main alone one column
102
+ * __main + __aside 8 / 4 (from md)
103
+ * __lead + __main 3 / 9 (from md)
104
+ * all three 3 / 6 / 3 (from lg; between md and lg both rails share the left)
105
+ * Stacked, the summary rail comes FIRST: last, it sits under every section, and its button with it.
106
+ * From md each rail is sticky and scrolls on its own, so the total and the status stay answerable
107
+ * while the main column scrolls with the page, and a rail taller than the screen never hangs its
108
+ * end off it. Screen only: a printed page is not held to a window. */
109
+ .fdy-page__columns{--_fdy-rail-top:var(--fdy-page-sticky-top,var(--space-4));display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-6);align-items:start;}
110
+ /* Under the shell the sticky topbar is in the way; --fdy-page-sticky-top overrides either default. */
111
+ .fdy-app .fdy-page__columns{--_fdy-rail-top:var(--fdy-page-sticky-top,calc(var(--space-16) + var(--space-4)));}
112
+ .fdy-page__columns > *{min-width:0;}
113
+ @media (max-width:959.98px){.fdy-page__aside{order:-1;}}
114
+ @media (min-width:960px){
115
+ .fdy-page__columns:has(> .fdy-page__aside){grid-template-columns:minmax(0,2fr) minmax(0,1fr);}
116
+ .fdy-page__columns:has(> .fdy-page__lead){grid-template-columns:minmax(0,1fr) minmax(0,3fr);}
117
+ /* Both rails, two columns: they stack on the left while __main spans both rows on the right. */
118
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > .fdy-page__main{grid-row:span 2;}
119
+ }
120
+ @media (min-width:1280px){
121
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside){grid-template-columns:minmax(0,1fr) minmax(0,2fr) minmax(0,1fr);}
122
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > .fdy-page__main{grid-row:auto;}
123
+ }
124
+ /* The padding, taken back by the margin, is room for a focus ring at a card's edge: a scrolling box
125
+ clips whatever draws outside it. */
126
+ @media screen and (min-width:960px){
127
+ .fdy-page__lead,.fdy-page__aside{position:sticky;top:var(--_fdy-rail-top);max-height:calc(100dvh - var(--_fdy-rail-top) - var(--space-4));overflow-y:auto;overscroll-behavior:contain;padding:var(--space-1);margin:calc(var(--space-1) * -1);}
128
+ }
129
+ /* Two sticky rails in ONE column would slide over each other: between md and lg, with both rails
130
+ present, they scroll with the page instead. */
131
+ @media screen and (min-width:960px) and (max-width:1279.98px){
132
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > :is(.fdy-page__lead,.fdy-page__aside){position:static;max-height:none;overflow:visible;}
133
+ }