@cahyo-dimas/freeday 3.13.0 → 3.14.1

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 (45) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/COMPONENTS.md +45 -0
  3. package/README.id.md +1 -1
  4. package/README.md +1 -1
  5. package/adapters/react/components/FdyDrawer.tsx +4 -0
  6. package/adapters/react/index.d.ts +2 -0
  7. package/adapters/vue/components/FdyDrawer.vue +4 -0
  8. package/adapters/vue/index.d.ts +2 -0
  9. package/dist/freeday-app-shell.js +1 -0
  10. package/dist/freeday-autocomplete.js +1 -0
  11. package/dist/freeday-carousel.js +1 -0
  12. package/dist/freeday-cascade.js +1 -0
  13. package/dist/freeday-cfl.js +2 -0
  14. package/dist/freeday-chart.js +1 -0
  15. package/dist/freeday-chip.js +2 -0
  16. package/dist/freeday-datepicker.js +2 -0
  17. package/dist/freeday-datetime.js +1 -0
  18. package/dist/freeday-drawer.js +2 -0
  19. package/dist/freeday-form.js +1 -0
  20. package/dist/freeday-mask.js +2 -0
  21. package/dist/freeday-menu.js +1 -0
  22. package/dist/freeday-number.js +1 -0
  23. package/dist/freeday-rating.js +1 -0
  24. package/dist/freeday-search.js +1 -0
  25. package/dist/freeday-select.js +1 -0
  26. package/dist/freeday-slider.js +1 -0
  27. package/dist/freeday-sortable.js +222 -0
  28. package/dist/freeday-stepper.js +1 -0
  29. package/dist/freeday-table.js +1 -0
  30. package/dist/freeday-tabs.js +1 -0
  31. package/dist/freeday-timepicker.js +1 -0
  32. package/dist/freeday-tree.js +1 -0
  33. package/dist/freeday-unhydrated.js +9 -2
  34. package/dist/freeday-upload.js +1 -0
  35. package/dist/freeday.bundle.css +33 -4
  36. package/dist/freeday.css +33 -4
  37. package/dist/freeday.js +261 -2
  38. package/docs/agent-onboarding.md +2 -0
  39. package/docs/getting-started.md +1 -1
  40. package/docs/integrations.md +1 -0
  41. package/package.json +2 -2
  42. package/src/components/app-shell.css +2 -1
  43. package/src/components/drawer.css +2 -1
  44. package/src/components/modal.css +11 -2
  45. package/src/components/sortable.css +17 -0
@@ -0,0 +1,222 @@
1
+ /* Freeday, sortable list enhancer (optional, zero-dependency).
2
+ * Reorder the children of a list by dragging a handle, or by focusing it and pressing ↑ / ↓. The
3
+ * keyboard path is not an extra: drag-only reordering locks out every keyboard and screen-reader
4
+ * user from a settings screen, and that path is the part each app used to write for itself
5
+ * (CATATAN-FREEDAY §15).
6
+ *
7
+ * Markup contract:
8
+ * <ul class="fdy-sortable" data-fdy-sortable> (or a <tbody>, or a <div>)
9
+ * <li data-key="ketua" data-label="Ketua">
10
+ * <button class="fdy-sortable__handle" type="button">…icon…</button> Ketua
11
+ * </li>
12
+ * <li data-key="sistem" data-fdy-sort-fixed>…</li> (never moves, and nothing passes it)
13
+ * </ul>
14
+ * The items are the container's element children. `data-key` names each in the event; `data-label`
15
+ * is what the handle's default aria-label and the announcement call it (the item text otherwise).
16
+ * `data-disabled` on the container switches reordering off, e.g. while the list is filtered, and
17
+ * the handles follow it through aria-disabled.
18
+ *
19
+ * Emits a bubbling, CANCELABLE "fdy-sort-change" on the container, detail {key, from, to, order},
20
+ * `order` being every item's key in the new order. Not cancelled, the kit moves the element, which
21
+ * is the whole job on a static page. A framework that renders the list from state cancels it and
22
+ * reorders the state instead, because the DOM is the framework's and an element moved behind its
23
+ * back leaves the two disagreeing about where each row is. Either way the kit announces the move
24
+ * and, after a keyboard step, puts focus back on the moved item's handle (looked up by data-key,
25
+ * since a framework re-render may have replaced the element).
26
+ *
27
+ * The drag shows where the item will land (is-drop-before / is-drop-after on the item it lands
28
+ * against) and moves nothing until the pointer is released; Escape cancels it. Exposes
29
+ * window.FreedaySortable.
30
+ */
31
+ (function () {
32
+ 'use strict';
33
+
34
+ var TEXT = {
35
+ handle: 'Move {label}. Drag, or press the up and down arrow keys.',
36
+ moved: '{label} moved to position {position} of {total}.'
37
+ };
38
+ /* Kebab-cased for the lookup, see the note in freeday-upload.js. */
39
+ function textAttr(root, key) {
40
+ if (!root || !root.getAttribute) return null;
41
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
42
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
43
+ }
44
+ function textOf(root, key, vars) {
45
+ var custom = textAttr(root, key);
46
+ var s = custom != null && custom !== '' ? custom : TEXT[key];
47
+ for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
48
+ return s;
49
+ }
50
+
51
+ var HANDLE = '.fdy-sortable__handle';
52
+
53
+ var live = null;
54
+ function announce(message) {
55
+ if (live === null) {
56
+ live = document.createElement('div');
57
+ live.className = 'fdy-visually-hidden';
58
+ live.setAttribute('aria-live', 'polite');
59
+ document.body.appendChild(live);
60
+ }
61
+ /* The same sentence twice in a row is no change, and a live region announces changes. */
62
+ live.textContent = live.textContent === message ? message + ' ' : message;
63
+ }
64
+
65
+ function items(root) {
66
+ return Array.prototype.slice.call(root.children);
67
+ }
68
+ function keyOf(item, index) {
69
+ var key = item.getAttribute('data-key');
70
+ return key !== null ? key : String(index);
71
+ }
72
+ function labelOf(item) {
73
+ var label = item.getAttribute('data-label');
74
+ return label !== null ? label : (item.textContent || '').replace(/\s+/g, ' ').trim();
75
+ }
76
+ function disabled(root) {
77
+ return root.hasAttribute('data-disabled');
78
+ }
79
+ function itemOf(root, node) {
80
+ for (var n = node; n && n !== root; n = n.parentElement) {
81
+ if (n.parentElement === root) return n;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /* A move is allowed only if it shifts no fixed item, which is the same as saying the item does
87
+ not start fixed and does not cross one on its way. */
88
+ function allowed(list, from, to) {
89
+ if (from === to || to < 0 || to >= list.length) return false;
90
+ var lo = Math.min(from, to), hi = Math.max(from, to);
91
+ for (var i = lo; i <= hi; i++) if (list[i].hasAttribute('data-fdy-sort-fixed')) return false;
92
+ return true;
93
+ }
94
+
95
+ function syncHandles(root) {
96
+ var off = disabled(root) ? 'true' : 'false';
97
+ items(root).forEach(function (item) {
98
+ var handle = item.querySelector(HANDLE);
99
+ if (!handle) return;
100
+ if (handle.getAttribute('aria-disabled') !== off) handle.setAttribute('aria-disabled', off);
101
+ if (!handle.hasAttribute('aria-label')) handle.setAttribute('aria-label', textOf(root, 'handle', { label: labelOf(item) }));
102
+ });
103
+ }
104
+
105
+ function commit(root, from, to, refocus) {
106
+ var list = items(root);
107
+ var item = list[from];
108
+ var key = keyOf(item, from);
109
+ var keys = list.map(keyOf);
110
+ keys.splice(to, 0, keys.splice(from, 1)[0]);
111
+ var ev = new CustomEvent('fdy-sort-change', {
112
+ bubbles: true,
113
+ cancelable: true,
114
+ detail: { key: key, from: from, to: to, order: keys }
115
+ });
116
+ root.dispatchEvent(ev);
117
+ if (!ev.defaultPrevented) root.insertBefore(item, to > from ? list[to].nextSibling : list[to]);
118
+ announce(textOf(root, 'moved', { label: labelOf(item), position: String(to + 1), total: String(list.length) }));
119
+ if (!refocus) return;
120
+ /* After the app's own render, which a framework runs after this handler returns. Moving a focused
121
+ element drops its focus, and a keyed re-render may have replaced the element outright. */
122
+ setTimeout(function () {
123
+ var now = items(root);
124
+ var moved = item.parentElement === root ? item : now.filter(function (el, i) { return keyOf(el, i) === key; })[0];
125
+ var handle = moved ? moved.querySelector(HANDLE) : null;
126
+ if (handle && document.activeElement !== handle) handle.focus();
127
+ }, 0);
128
+ }
129
+
130
+ function initSortable(root) {
131
+ if (root.dataset.fdySortableReady === '1') return;
132
+ if (root.closest('[data-fdy-ignore]')) return;
133
+ root.dataset.fdySortableReady = '1';
134
+ syncHandles(root);
135
+ /* Rows a framework adds later get their aria-label, and data-disabled reaches every handle. */
136
+ new MutationObserver(function () { syncHandles(root); })
137
+ .observe(root, { childList: true, attributes: true, attributeFilter: ['data-disabled'] });
138
+
139
+ root.addEventListener('keydown', function (e) {
140
+ var handle = e.target.closest ? e.target.closest(HANDLE) : null;
141
+ if (!handle || !root.contains(handle) || disabled(root)) return;
142
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
143
+ e.preventDefault();
144
+ var list = items(root);
145
+ var from = list.indexOf(itemOf(root, handle));
146
+ var to = from + (e.key === 'ArrowUp' ? -1 : 1);
147
+ if (from !== -1 && allowed(list, from, to)) commit(root, from, to, true);
148
+ });
149
+
150
+ var drag = null;
151
+
152
+ function clearMarks() {
153
+ items(root).forEach(function (el) { el.classList.remove('is-drop-before', 'is-drop-after'); });
154
+ }
155
+ function end(apply) {
156
+ if (drag === null) return;
157
+ var d = drag;
158
+ drag = null;
159
+ document.removeEventListener('keydown', onEscape, true);
160
+ clearMarks();
161
+ d.item.classList.remove('is-dragging');
162
+ root.classList.remove('is-sorting');
163
+ if (apply && d.started && d.to !== null) commit(root, d.from, d.to, false);
164
+ }
165
+ function onEscape(e) {
166
+ if (e.key === 'Escape') { e.preventDefault(); end(false); }
167
+ }
168
+
169
+ root.addEventListener('pointerdown', function (e) {
170
+ var handle = e.target.closest ? e.target.closest(HANDLE) : null;
171
+ if (!handle || !root.contains(handle) || disabled(root) || e.button !== 0) return;
172
+ var item = itemOf(root, handle);
173
+ if (!item) return;
174
+ e.preventDefault();
175
+ handle.focus();
176
+ handle.setPointerCapture(e.pointerId);
177
+ drag = { item: item, from: items(root).indexOf(item), started: false, to: null };
178
+ document.addEventListener('keydown', onEscape, true);
179
+ });
180
+
181
+ root.addEventListener('pointermove', function (e) {
182
+ if (drag === null) return;
183
+ /* No travel threshold: a press that never leaves its own row resolves to to === from, which
184
+ allowed() refuses, so a click on the handle cannot reorder anything. */
185
+ if (!drag.started) {
186
+ drag.started = true;
187
+ drag.item.classList.add('is-dragging');
188
+ root.classList.add('is-sorting');
189
+ }
190
+ clearMarks();
191
+ drag.to = null;
192
+ var list = items(root);
193
+ var over = itemOf(root, document.elementFromPoint(e.clientX, e.clientY));
194
+ if (!over) return;
195
+ var rect = over.getBoundingClientRect();
196
+ var after = e.clientY > rect.top + rect.height / 2;
197
+ var slot = list.indexOf(over) + (after ? 1 : 0);
198
+ var to = slot > drag.from ? slot - 1 : slot;
199
+ if (!allowed(list, drag.from, to)) return;
200
+ drag.to = to;
201
+ over.classList.add(after ? 'is-drop-after' : 'is-drop-before');
202
+ });
203
+
204
+ root.addEventListener('pointerup', function () { end(true); });
205
+ root.addEventListener('pointercancel', function () { end(false); });
206
+ }
207
+
208
+ function initAll(context) {
209
+ var root = context || document;
210
+ /* root included: querySelectorAll never matches its own root, and a framework ref often sits ON the widget. */
211
+ if (root.matches && root.matches('[data-fdy-sortable]')) initSortable(root);
212
+ Array.prototype.forEach.call(root.querySelectorAll('[data-fdy-sortable]'), initSortable);
213
+ }
214
+
215
+ if (document.readyState === 'loading') {
216
+ document.addEventListener('DOMContentLoaded', function () { initAll(); });
217
+ } else {
218
+ initAll();
219
+ }
220
+
221
+ window.FreedaySortable = { init: initSortable, initAll: initAll };
222
+ })();
@@ -49,6 +49,7 @@
49
49
 
50
50
  function initStepper(root) {
51
51
  if (root.dataset.fdyStepperReady === '1') return;
52
+ if (root.closest('[data-fdy-ignore]')) return;
52
53
  root.dataset.fdyStepperReady = '1';
53
54
 
54
55
  var steps = Array.prototype.slice.call(root.querySelectorAll('.fdy-step'));
@@ -77,6 +77,7 @@
77
77
 
78
78
  function initTable(root) {
79
79
  if (root.dataset.fdyTableReady === '1') return;
80
+ if (root.closest('[data-fdy-ignore]')) return;
80
81
  var table = root.querySelector('table.fdy-table');
81
82
  var tbody = table && table.querySelector('tbody');
82
83
  if (!table || !tbody) return;
@@ -16,6 +16,7 @@
16
16
 
17
17
  function initTabs(root) {
18
18
  if (root.dataset.fdyTabsReady === '1') return;
19
+ if (root.closest('[data-fdy-ignore]')) return;
19
20
  var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
20
21
  if (tabs.length === 0) return;
21
22
  root.dataset.fdyTabsReady = '1';
@@ -43,6 +43,7 @@
43
43
 
44
44
  function initTime(wrap) {
45
45
  if (wrap.dataset.fdyTpReady === '1') return;
46
+ if (wrap.closest('[data-fdy-ignore]')) return;
46
47
  wrap.dataset.fdyTpReady = '1';
47
48
  wrap.classList.add('fdy-timepicker');
48
49
 
@@ -66,6 +66,7 @@
66
66
 
67
67
  function initTree(tree) {
68
68
  if (tree.dataset.fdyTreeReady === '1') return;
69
+ if (tree.closest('[data-fdy-ignore]')) return;
69
70
  tree.dataset.fdyTreeReady = '1';
70
71
  Array.prototype.forEach.call(tree.querySelectorAll('input[type="checkbox"]'), function (box) {
71
72
  // A branch checkbox lives inside <summary>; keep its click from toggling the branch.
@@ -15,6 +15,11 @@
15
15
  * namespace that owns it. A hook whose enhancer is not loaded is skipped: that is a different
16
16
  * mistake, and this message would misname it. test/docs.test.mjs fails if an enhancer gains a
17
17
  * ready flag this table does not list.
18
+ *
19
+ * `data-fdy-ignore` on an element or any ancestor is the sanctioned way out: every enhancer skips
20
+ * that subtree, and this stays quiet about it. It is for markup whose behaviour something else
21
+ * owns: the kit's own Vue/React FdyDrawer, and kit classes an app uses for their look
22
+ * alone (CATATAN-FREEDAY §14: a chip matrix the app drives itself).
18
23
  */
19
24
  (function () {
20
25
  'use strict';
@@ -43,6 +48,7 @@
43
48
  ['[data-fdy-search]', 'fdySearchReady', 'FreedaySearch'],
44
49
  ['[data-fdy-combo]', 'fdyComboReady', 'FreedayCombo'],
45
50
  ['[data-fdy-slider]', 'fdySliderReady', 'FreedaySlider'],
51
+ ['[data-fdy-sortable]', 'fdySortableReady', 'FreedaySortable'],
46
52
  ['[data-fdy-stepper]', 'fdyStepperReady', 'FreedayStepper'],
47
53
  ['[data-fdy-table]', 'fdyTableReady', 'FreedayTable'],
48
54
  ['[data-fdy-tabs]', 'fdyTabsReady', 'FreedayTabs'],
@@ -59,12 +65,13 @@
59
65
  var hook = HOOKS[i];
60
66
  if (!window[hook[2]]) continue;
61
67
  var el = e.target.closest(hook[0]);
62
- if (!el || el.dataset[hook[1]] === '1' || warned.has(el)) continue;
68
+ if (!el || el.dataset[hook[1]] === '1' || warned.has(el) || el.closest('[data-fdy-ignore]')) continue;
63
69
  warned.add(el);
64
70
  console.warn('Freeday: this ' + hook[0] + ' was never enhanced, so it does nothing. Either it is '
65
71
  + 'outside every initAll() scope (Vue/React: inside the element given to useFreeday(); Blazor: '
66
72
  + 'FreedayBlazor.initAll after render; anything else: ' + hook[2] + '.initAll(el) once it is in '
67
- + 'the DOM), or its markup lacks a part the enhancer needs (see COMPONENTS.md).', el);
73
+ + 'the DOM), or its markup lacks a part the enhancer needs (see COMPONENTS.md). If the app '
74
+ + 'drives this element itself on purpose, mark it or an ancestor data-fdy-ignore.', el);
68
75
  }
69
76
  }
70
77
 
@@ -228,6 +228,7 @@
228
228
 
229
229
  function initDropzone(zone) {
230
230
  if (zone.dataset.fdyUploadReady === '1') return;
231
+ if (zone.closest('[data-fdy-ignore]')) return;
231
232
  zone.dataset.fdyUploadReady = '1';
232
233
 
233
234
  var input = zone.querySelector('input[type="file"]');
@@ -1285,7 +1285,8 @@ a { color: var(--color-primary-strong); }
1285
1285
  * > (.fdy-app__topbar, .fdy-app__main)). The __content wrapper gives the sticky
1286
1286
  * topbar a tall containing block so it can travel, a bare grid cell can't. */
1287
1287
  .fdy-app{display:flex;align-items:stretch;min-height:100vh;background:var(--color-surface-2);}
1288
- .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;overflow:hidden;flex:none;width:var(--fdy-app-sidebar-w,15.5rem);box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
1288
+ /* 100vh then 100dvh: on iOS Safari 100vh is the LARGE viewport, as if the toolbar were gone, so a box sized by it runs under the toolbar. An engine without dvh keeps the first value. */
1289
+ .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;height:100dvh;overflow:hidden;flex:none;width:var(--fdy-app-sidebar-w,15.5rem);box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
1289
1290
  /* `min-height:0` is the height-axis twin of `min-width:0`, and its absence was invisible for as long
1290
1291
  * as the shell scrolled the PAGE (#055 §2). A consumer that pins the shell to the viewport and
1291
1292
  * scrolls .fdy-app__main instead hits it immediately: a column flex item's automatic minimum size is
@@ -2472,7 +2473,8 @@ a { color: var(--color-primary-strong); }
2472
2473
  /* display is gated behind [open] so the native <dialog> stays hidden until opened.
2473
2474
  * An unconditional display here would beat the UA `dialog:not([open]){display:none}`
2474
2475
  * rule (author origin wins over user-agent) and leave the drawer visible on load. */
2475
- .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition-property:transform,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
2476
+ /* 100vh then 100dvh: on iOS Safari 100vh is the LARGE viewport, as if the toolbar were gone, so a box sized by it runs under the toolbar. An engine without dvh keeps the first value. */
2477
+ .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;height:100dvh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition-property:transform,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
2476
2478
  .fdy-drawer[open]{display:flex;transform:none;}
2477
2479
  .fdy-drawer--right{inset:0 0 0 auto;transform:translateX(100%);}
2478
2480
  .fdy-drawer::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition-property:opacity,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
@@ -2779,7 +2781,8 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2779
2781
  .fdy-btn-split__toggle svg{display:block;width:1rem;height:1rem;}
2780
2782
 
2781
2783
  /* Freeday. Modal (native <dialog>) */
2782
- .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);/* Longhands, not the `transition` shorthand. A shorthand carrying one keyword the engine does not know is INVALID AT PARSE, so the whole declaration is thrown away -- and `allow-discrete` is unknown below Safari 17.4, which is under this kit's floor. The result was a modal and a drawer that opened and closed with no animation at all on an iPhone 7. Split this way only `transition-behavior` is dropped there, and the opacity/transform half survives. The lists align BY INDEX and reproduce the shorthand exactly, including `ease` for the discrete pair and `normal` for the continuous one -- verified by comparing computed styles before and after (#097). */transition-property:opacity,transform,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-spring),var(--ease-spring),ease,ease;transition-behavior:normal,normal,allow-discrete,allow-discrete;}
2784
+ /* 100vh then 100dvh: on iOS Safari 100vh is the LARGE viewport, as if the toolbar were gone, so a box sized by it runs under the toolbar. An engine without dvh keeps the first value. */
2785
+ .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));max-height:calc(100dvh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);/* Longhands, not the `transition` shorthand. A shorthand carrying one keyword the engine does not know is INVALID AT PARSE, so the whole declaration is thrown away -- and `allow-discrete` is unknown below Safari 17.4, which is under this kit's floor. The result was a modal and a drawer that opened and closed with no animation at all on an iPhone 7. Split this way only `transition-behavior` is dropped there, and the opacity/transform half survives. The lists align BY INDEX and reproduce the shorthand exactly, including `ease` for the discrete pair and `normal` for the continuous one -- verified by comparing computed styles before and after (#097). */transition-property:opacity,transform,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-spring),var(--ease-spring),ease,ease;transition-behavior:normal,normal,allow-discrete,allow-discrete;}
2783
2786
  .fdy-modal--sm{width:min(24rem,calc(100vw - var(--space-8)));}
2784
2787
  .fdy-modal--md{width:min(32rem,calc(100vw - var(--space-8)));}
2785
2788
  .fdy-modal--lg{width:min(48rem,calc(100vw - var(--space-8)));}
@@ -2789,7 +2792,15 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2789
2792
  .fdy-modal__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text);margin:0 auto 0 0;}
2790
2793
  .fdy-modal__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
2791
2794
  .fdy-modal__close:hover{background:var(--color-surface-2);color:var(--color-text);}
2792
- .fdy-modal__body{flex:1;min-height:0;padding:var(--space-5);overflow:auto;}
2795
+ /* `1 1 auto`, NOT `flex:1`. The shorthand's basis is 0%, and `.fdy-modal` has only a max-height, so
2796
+ * the percentage resolves against an indefinite height. Current engines read that as `content`;
2797
+ * older WebKit read it as 0, sized the dialog to header + footer, and left the body its 40px of
2798
+ * padding, with the form overflowing inside it. That was every modal on an iPhone 7 (iOS 15),
2799
+ * reported from a real device (CATATAN-FREEDAY §16). A 0px basis reproduces it in Chrome to the
2800
+ * pixel. `auto` lays out identically in current engines (measured: 498.7px dialog, 355.7px body,
2801
+ * both readings) and leaves no indefinite percentage for any engine to read. min-height:0 is
2802
+ * still what lets a long body shrink and scroll once the dialog reaches its max-height. */
2803
+ .fdy-modal__body{flex:1 1 auto;min-height:0;padding:var(--space-5);overflow:auto;}
2793
2804
  .fdy-modal__footer{flex:none;display:flex;gap:var(--space-2);justify-content:flex-end;padding:var(--space-4) var(--space-5);border-top:var(--bw) solid var(--color-border-muted);background:var(--color-surface-2);}
2794
2805
  /* Symmetric enter AND exit (fade + rise + subtle scale) on the native <dialog>. @starting-style
2795
2806
  * animates the entry; overlay/display allow-discrete keep it painted through the exit so it
@@ -2900,6 +2911,24 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2900
2911
  .fdy-slider__value{font-family:var(--font-mono);font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--color-primary-strong);font-variant-numeric:tabular-nums;}
2901
2912
  .fdy-slider__scale{display:flex;justify-content:space-between;font-size:var(--text-xs);color:var(--color-text-muted);font-variant-numeric:tabular-nums;}
2902
2913
 
2914
+ /* Freeday. Sortable list (freeday-sortable.js). The kit styles the handle and the drag states only;
2915
+ * the list itself is whatever it already was: .fdy-list, a table body, plain rows.
2916
+ *
2917
+ * The handle is a control-height square so a thumb can hold it on a phone, and `touch-action:none`
2918
+ * keeps that drag from scrolling the page. Its glyph is the only thing marking it as a control, so
2919
+ * it takes --color-text-muted, not --color-text-subtle: a non-text control still owes 3:1. */
2920
+ .fdy-sortable__handle{display:inline-flex;flex:none;align-items:center;justify-content:center;width:var(--control-h);height:var(--control-h);padding:0;border:0;border-radius:var(--radius-md);background:transparent;color:var(--color-text-muted);cursor:grab;touch-action:none;}
2921
+ .fdy-sortable__handle:hover{background:var(--color-surface-2);color:var(--color-text);}
2922
+ .fdy-sortable__handle:active{cursor:grabbing;}
2923
+ .fdy-sortable__handle[aria-disabled="true"]{cursor:not-allowed;opacity:.4;}
2924
+ .fdy-sortable__handle[aria-disabled="true"]:hover{background:transparent;color:var(--color-text-muted);}
2925
+ .fdy-sortable.is-sorting{-webkit-user-select:none;user-select:none;}
2926
+ .fdy-sortable > .is-dragging{opacity:.45;}
2927
+ /* The landing line. On a table row it is drawn on the cells: a box-shadow on a <tr> is not painted
2928
+ * by every engine the kit supports. */
2929
+ .fdy-sortable > :not(tr).is-drop-before,.fdy-sortable > tr.is-drop-before > *{box-shadow:inset 0 var(--focus-ring-width) 0 var(--color-primary);}
2930
+ .fdy-sortable > :not(tr).is-drop-after,.fdy-sortable > tr.is-drop-after > *{box-shadow:inset 0 calc(var(--focus-ring-width) * -1) 0 var(--color-primary);}
2931
+
2903
2932
  /* Freeday. Spinner */
2904
2933
  .fdy-spinner{display:inline-block;width:1.5rem;height:1.5rem;border:2.5px solid var(--color-border-strong);border-top-color:var(--color-primary);border-radius:var(--radius-full);animation:fdy-spin .7s linear infinite;}
2905
2934
  .fdy-spinner--sm{width:1rem;height:1rem;border-width:2px;}
package/dist/freeday.css CHANGED
@@ -108,7 +108,8 @@ a { color: var(--color-primary-strong); }
108
108
  * > (.fdy-app__topbar, .fdy-app__main)). The __content wrapper gives the sticky
109
109
  * topbar a tall containing block so it can travel, a bare grid cell can't. */
110
110
  .fdy-app{display:flex;align-items:stretch;min-height:100vh;background:var(--color-surface-2);}
111
- .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;overflow:hidden;flex:none;width:var(--fdy-app-sidebar-w,15.5rem);box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
111
+ /* 100vh then 100dvh: on iOS Safari 100vh is the LARGE viewport, as if the toolbar were gone, so a box sized by it runs under the toolbar. An engine without dvh keeps the first value. */
112
+ .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;height:100dvh;overflow:hidden;flex:none;width:var(--fdy-app-sidebar-w,15.5rem);box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
112
113
  /* `min-height:0` is the height-axis twin of `min-width:0`, and its absence was invisible for as long
113
114
  * as the shell scrolled the PAGE (#055 §2). A consumer that pins the shell to the viewport and
114
115
  * scrolls .fdy-app__main instead hits it immediately: a column flex item's automatic minimum size is
@@ -1295,7 +1296,8 @@ a { color: var(--color-primary-strong); }
1295
1296
  /* display is gated behind [open] so the native <dialog> stays hidden until opened.
1296
1297
  * An unconditional display here would beat the UA `dialog:not([open]){display:none}`
1297
1298
  * rule (author origin wins over user-agent) and leave the drawer visible on load. */
1298
- .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition-property:transform,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
1299
+ /* 100vh then 100dvh: on iOS Safari 100vh is the LARGE viewport, as if the toolbar were gone, so a box sized by it runs under the toolbar. An engine without dvh keeps the first value. */
1300
+ .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;height:100dvh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition-property:transform,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
1299
1301
  .fdy-drawer[open]{display:flex;transform:none;}
1300
1302
  .fdy-drawer--right{inset:0 0 0 auto;transform:translateX(100%);}
1301
1303
  .fdy-drawer::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition-property:opacity,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
@@ -1602,7 +1604,8 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1602
1604
  .fdy-btn-split__toggle svg{display:block;width:1rem;height:1rem;}
1603
1605
 
1604
1606
  /* Freeday. Modal (native <dialog>) */
1605
- .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);/* Longhands, not the `transition` shorthand. A shorthand carrying one keyword the engine does not know is INVALID AT PARSE, so the whole declaration is thrown away -- and `allow-discrete` is unknown below Safari 17.4, which is under this kit's floor. The result was a modal and a drawer that opened and closed with no animation at all on an iPhone 7. Split this way only `transition-behavior` is dropped there, and the opacity/transform half survives. The lists align BY INDEX and reproduce the shorthand exactly, including `ease` for the discrete pair and `normal` for the continuous one -- verified by comparing computed styles before and after (#097). */transition-property:opacity,transform,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-spring),var(--ease-spring),ease,ease;transition-behavior:normal,normal,allow-discrete,allow-discrete;}
1607
+ /* 100vh then 100dvh: on iOS Safari 100vh is the LARGE viewport, as if the toolbar were gone, so a box sized by it runs under the toolbar. An engine without dvh keeps the first value. */
1608
+ .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));max-height:calc(100dvh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);/* Longhands, not the `transition` shorthand. A shorthand carrying one keyword the engine does not know is INVALID AT PARSE, so the whole declaration is thrown away -- and `allow-discrete` is unknown below Safari 17.4, which is under this kit's floor. The result was a modal and a drawer that opened and closed with no animation at all on an iPhone 7. Split this way only `transition-behavior` is dropped there, and the opacity/transform half survives. The lists align BY INDEX and reproduce the shorthand exactly, including `ease` for the discrete pair and `normal` for the continuous one -- verified by comparing computed styles before and after (#097). */transition-property:opacity,transform,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-spring),var(--ease-spring),ease,ease;transition-behavior:normal,normal,allow-discrete,allow-discrete;}
1606
1609
  .fdy-modal--sm{width:min(24rem,calc(100vw - var(--space-8)));}
1607
1610
  .fdy-modal--md{width:min(32rem,calc(100vw - var(--space-8)));}
1608
1611
  .fdy-modal--lg{width:min(48rem,calc(100vw - var(--space-8)));}
@@ -1612,7 +1615,15 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1612
1615
  .fdy-modal__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text);margin:0 auto 0 0;}
1613
1616
  .fdy-modal__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
1614
1617
  .fdy-modal__close:hover{background:var(--color-surface-2);color:var(--color-text);}
1615
- .fdy-modal__body{flex:1;min-height:0;padding:var(--space-5);overflow:auto;}
1618
+ /* `1 1 auto`, NOT `flex:1`. The shorthand's basis is 0%, and `.fdy-modal` has only a max-height, so
1619
+ * the percentage resolves against an indefinite height. Current engines read that as `content`;
1620
+ * older WebKit read it as 0, sized the dialog to header + footer, and left the body its 40px of
1621
+ * padding, with the form overflowing inside it. That was every modal on an iPhone 7 (iOS 15),
1622
+ * reported from a real device (CATATAN-FREEDAY §16). A 0px basis reproduces it in Chrome to the
1623
+ * pixel. `auto` lays out identically in current engines (measured: 498.7px dialog, 355.7px body,
1624
+ * both readings) and leaves no indefinite percentage for any engine to read. min-height:0 is
1625
+ * still what lets a long body shrink and scroll once the dialog reaches its max-height. */
1626
+ .fdy-modal__body{flex:1 1 auto;min-height:0;padding:var(--space-5);overflow:auto;}
1616
1627
  .fdy-modal__footer{flex:none;display:flex;gap:var(--space-2);justify-content:flex-end;padding:var(--space-4) var(--space-5);border-top:var(--bw) solid var(--color-border-muted);background:var(--color-surface-2);}
1617
1628
  /* Symmetric enter AND exit (fade + rise + subtle scale) on the native <dialog>. @starting-style
1618
1629
  * animates the entry; overlay/display allow-discrete keep it painted through the exit so it
@@ -1723,6 +1734,24 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1723
1734
  .fdy-slider__value{font-family:var(--font-mono);font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--color-primary-strong);font-variant-numeric:tabular-nums;}
1724
1735
  .fdy-slider__scale{display:flex;justify-content:space-between;font-size:var(--text-xs);color:var(--color-text-muted);font-variant-numeric:tabular-nums;}
1725
1736
 
1737
+ /* Freeday. Sortable list (freeday-sortable.js). The kit styles the handle and the drag states only;
1738
+ * the list itself is whatever it already was: .fdy-list, a table body, plain rows.
1739
+ *
1740
+ * The handle is a control-height square so a thumb can hold it on a phone, and `touch-action:none`
1741
+ * keeps that drag from scrolling the page. Its glyph is the only thing marking it as a control, so
1742
+ * it takes --color-text-muted, not --color-text-subtle: a non-text control still owes 3:1. */
1743
+ .fdy-sortable__handle{display:inline-flex;flex:none;align-items:center;justify-content:center;width:var(--control-h);height:var(--control-h);padding:0;border:0;border-radius:var(--radius-md);background:transparent;color:var(--color-text-muted);cursor:grab;touch-action:none;}
1744
+ .fdy-sortable__handle:hover{background:var(--color-surface-2);color:var(--color-text);}
1745
+ .fdy-sortable__handle:active{cursor:grabbing;}
1746
+ .fdy-sortable__handle[aria-disabled="true"]{cursor:not-allowed;opacity:.4;}
1747
+ .fdy-sortable__handle[aria-disabled="true"]:hover{background:transparent;color:var(--color-text-muted);}
1748
+ .fdy-sortable.is-sorting{-webkit-user-select:none;user-select:none;}
1749
+ .fdy-sortable > .is-dragging{opacity:.45;}
1750
+ /* The landing line. On a table row it is drawn on the cells: a box-shadow on a <tr> is not painted
1751
+ * by every engine the kit supports. */
1752
+ .fdy-sortable > :not(tr).is-drop-before,.fdy-sortable > tr.is-drop-before > *{box-shadow:inset 0 var(--focus-ring-width) 0 var(--color-primary);}
1753
+ .fdy-sortable > :not(tr).is-drop-after,.fdy-sortable > tr.is-drop-after > *{box-shadow:inset 0 calc(var(--focus-ring-width) * -1) 0 var(--color-primary);}
1754
+
1726
1755
  /* Freeday. Spinner */
1727
1756
  .fdy-spinner{display:inline-block;width:1.5rem;height:1.5rem;border:2.5px solid var(--color-border-strong);border-top-color:var(--color-primary);border-radius:var(--radius-full);animation:fdy-spin .7s linear infinite;}
1728
1757
  .fdy-spinner--sm{width:1rem;height:1rem;border-width:2px;}