flipfolio 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,14 +1,19 @@
1
1
  # flipfolio
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/flipfolio)](https://www.npmjs.com/package/flipfolio)
4
+ [![minzipped size](https://img.shields.io/bundlephobia/minzip/flipfolio)](https://bundlephobia.com/package/flipfolio)
5
+ [![CI](https://github.com/kirthi-b/flipfolio/actions/workflows/ci.yml/badge.svg)](https://github.com/kirthi-b/flipfolio/actions/workflows/ci.yml)
6
+ [![license](https://img.shields.io/npm/l/flipfolio)](LICENSE)
7
+
3
8
  A gallery where every item is a folder: stack them, grid them, spin them.
4
9
 
5
10
  Started as a CSS 3D experiment; it now runs the [kirthi.studio](https://kirthi.studio) homepage. No framework, no dependencies.
6
11
 
7
- Live demo: [kirthi.studio/demos/flipfolio](https://kirthi.studio/demos/flipfolio/)
12
+ Live demo: [kirthi.studio/demos/flipfolio](https://kirthi.studio/demos/flipfolio/), or edit it on [StackBlitz](https://stackblitz.com/github/kirthi-b/flipfolio/tree/master/examples/vanilla?file=main.js)
8
13
 
9
- > v0.1.0, an early release.
14
+ > v0.3.0. New this release: a crisp grid view, runtime color and gradient theming, and drag hooks.
10
15
 
11
- ![flipfolio demo: stack, grid, and carousel modes](assets/demo.gif)
16
+ ![flipfolio demo: throwing folders off the stack, grid mode, and photo decals](assets/demo.gif)
12
17
 
13
18
  ## Quick start
14
19
  ```sh
@@ -72,6 +77,9 @@ Folders hold your actual content. These are live project images, loaded with `it
72
77
  ![The same five folders laid out as a stack, a grid, and a carousel](assets/showcase/modes.png)
73
78
  One items array, three layouts. `setMode()` animates between them at runtime.
74
79
 
80
+ ![The front folder flung up off the pile with a grab cursor, the next folder revealed behind it](assets/showcase/throw.png)
81
+ Grab the front folder and throw it. Stack mode, `drag: 'fling'`; a short drag springs back.
82
+
75
83
  ![Four folders holding a photo, a text document, an SVG chart, and a custom-rendered card](assets/showcase/any-content.png)
76
84
  Interiors take images, markup, DOM nodes, or a `contentRenderer` for anything else.
77
85
 
@@ -85,28 +93,47 @@ Per-folder tab colors, tokens for radius and blur, and the silhouette itself is
85
93
  |---|---|---|---|
86
94
  | `items` | `Item[]` | `[]` | `{ label?, color?, src?, content?, decal?, ...data }` |
87
95
  | `mode` | `'stack'\|'grid'\|'carousel'` | `'stack'` | 3 modes (a 4th "shelf" is planned) |
96
+ | `grid` | `{ columns?, gap? }` | auto | pin the grid's column count and/or gap (px); default is a width/count heuristic |
88
97
  | `peek` | `'hover'\|'always'\|'off'` | `'hover'` | contents slide out of the folder |
98
+ | `drag` | `'fling'\|'off'` | `'fling'` | grab the active folder and throw it (stack mode) |
89
99
  | `contentRenderer` | `(card, item, i) => void` | built-in | fills the folder interior with whatever you render |
90
100
  | `onSelect` | `(item, i) => void` | (none) | fired on click/Enter of the active folder (no built-in navigation) |
91
- | `folderPath` | `string` | manila default | SVG path `d` (viewBox `0 0 480 342`) |
101
+ | `folderPath` | `string` | manila default | SVG path `d` (viewBox `0 0 480 342`), or a `FOLDER_PATHS` preset |
92
102
  | `loop` | `boolean` | `true` | wrap-around |
93
103
  | `scrollNav` | `boolean` | `true` | wheel/trackpad cycles the stack/carousel |
94
104
  | `reducedMotion` | `'auto'\|'off'\|'force'` | `'auto'` | drops the 3D tilt when reduced |
95
105
  | `defaultActiveIndex` | `number` | `0` | |
96
106
  | `label` | `string` | `'Folder gallery'` | `aria-label` for the listbox |
97
107
 
98
- Handle: `next()`, `prev()`, `goTo(i)`, `setMode(m)`, `setPeek(p)`, `getActiveIndex()`, `getMode()`, `destroy()`.
108
+ Handle: `next()`, `prev()`, `goTo(i)`, `setMode(m)`, `setPeek(p)`, `setColor(i, hex)`, `setGradient(i, gradient)`, `getActiveIndex()`, `getMode()`, `getPeek()`, `getColor(i)`, `getGradient(i)`, `getItems()`, `destroy()`.
99
109
 
100
- Events (CustomEvent on the root, bubbling): `fg-select`, `fg-activechange`, `fg-modechange`, each with `event.detail`.
110
+ Events (CustomEvent on the root, bubbling): `fg-select`, `fg-activechange`, `fg-modechange`, `fg-peekchange` (detail `{ peek }`), `fg-flingstart` and `fg-flingend` (detail `{ index, direction }`, direction `'next' | 'prev'`), each with `event.detail`. React props: `onSelect`, `onActiveChange`, `onModeChange`, `onPeekChange`, `onFlingStart`, `onFlingEnd`. Vue emits: `@select`, `@active-change`, `@mode-change`, `@peek-change`, `@fling-start`, `@fling-end`.
101
111
 
102
112
  ## Content
103
113
  Each folder renders what you give it: `item.src` for an image, `item.content` for an HTML string or a DOM node, or a `contentRenderer(card, item, index)` for anything richer. Set `item.decal` to an image URL and the photo prints on the folder's front panel; the back and tab keep their color. Contents slide out of the folder on hover; the `peek` option makes that always-on or turns it off.
104
114
 
115
+ ## Silhouette
116
+ `folderPath` takes any SVG path `d` in the `0 0 480 342` viewBox. Three presets ship as `FOLDER_PATHS`: `left` (the default tab), `right` (tab mirrored), and `tray` (no tab). Pass one instead of a raw string:
117
+
118
+ ```js
119
+ import { createFolderGallery, FOLDER_PATHS } from 'flipfolio';
120
+ createFolderGallery(el, { items, folderPath: FOLDER_PATHS.tray });
121
+ ```
122
+
123
+ ## Runtime theming
124
+ Each folder's palette derives from `item.color`; `setColor(index, hex)` re-derives the whole thing (SVG back, frosted + solid fronts, auto-contrast label) at runtime, no rebuild. `item.gradient` paints a CSS gradient across the front over the frosted surface, and `setGradient(index, gradient)` sets or clears it live (pass a falsy value to clear). Together they drive swatch pickers and theme switchers:
125
+
126
+ ```js
127
+ gallery.setColor(0, '#3d2e42'); // recolor one folder
128
+ gallery.setGradient(0, 'linear-gradient(135deg, #236363, #6FCDCD, #8B7FB8)');
129
+ gallery.setGradient(0, null); // back to the flat front
130
+ ```
131
+
105
132
  ## Accessibility
106
133
  `role="listbox"`/`option`, roving tabindex, arrow-key + Home/End navigation, `aria-live` position announcements, `:focus-visible` rings, and a `prefers-reduced-motion` fallback that drops the 3D tilt.
107
134
 
108
135
  ## Theming
109
- Override the CSS custom properties on `.fg-root`: `--fg-folder-bg`, `--fg-radius`, `--fg-perspective`, `--fg-ease`, `--fg-front`, `--fg-front-solid`, `--fg-active-blur`, `--fg-label`, `--fg-dot`, `--fg-dot-active`. Light is the default; dark applies automatically under `prefers-color-scheme: dark`, and `data-fg-theme="light"` or `"dark"` on `.fg-root` overrides either way.
136
+ Override the CSS custom properties on `.fg-root`: `--fg-folder-bg`, `--fg-radius`, `--fg-perspective`, `--fg-ease`, `--fg-front`, `--fg-front-solid`, `--fg-active-blur`, `--fg-label`, `--fg-dot`, `--fg-dot-active`, `--fg-scene-height`, `--fg-gap`, `--fg-folder-shadow`, `--fg-gradient-opacity`, `--fg-label-size`, `--fg-label-weight`, `--fg-label-tracking`. Light is the default; dark applies automatically under `prefers-color-scheme: dark`, and `data-fg-theme="light"` or `"dark"` on `.fg-root` overrides either way.
110
137
 
111
138
  ## Limits
112
139
  Client-rendered only: folders paint after JS runs, there is no SSR of the layout itself. Every folder is a live 3D layer, so the sweet spot is 12 items or fewer, not hundreds. Uses `aspect-ratio` and `backdrop-filter`: evergreen browsers only, no IE fallback.
@@ -1,35 +1,11 @@
1
- var __defProp = Object.defineProperty;
2
- var __getOwnPropSymbols = Object.getOwnPropertySymbols;
3
- var __hasOwnProp = Object.prototype.hasOwnProperty;
4
- var __propIsEnum = Object.prototype.propertyIsEnumerable;
5
- var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
- var __spreadValues = (a, b) => {
7
- for (var prop in b || (b = {}))
8
- if (__hasOwnProp.call(b, prop))
9
- __defNormalProp(a, prop, b[prop]);
10
- if (__getOwnPropSymbols)
11
- for (var prop of __getOwnPropSymbols(b)) {
12
- if (__propIsEnum.call(b, prop))
13
- __defNormalProp(a, prop, b[prop]);
14
- }
15
- return a;
16
- };
17
- var __objRest = (source, exclude) => {
18
- var target = {};
19
- for (var prop in source)
20
- if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0)
21
- target[prop] = source[prop];
22
- if (source != null && __getOwnPropSymbols)
23
- for (var prop of __getOwnPropSymbols(source)) {
24
- if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop))
25
- target[prop] = source[prop];
26
- }
27
- return target;
28
- };
29
-
30
1
  // src/folder-gallery.js
31
2
  var SVG_NS = "http://www.w3.org/2000/svg";
32
3
  var DEFAULT_FOLDER_PATH = "M 0 22 C 0 10 10 0 22 0 L 155 0 C 168 0 172 6 176 14 C 180 24 188 32 204 32 L 458 32 C 470 32 480 42 480 54 L 480 320 C 480 332 470 342 458 342 L 22 342 C 10 342 0 332 0 320 Z";
4
+ var FOLDER_PATHS = {
5
+ left: DEFAULT_FOLDER_PATH,
6
+ right: "M 480 22 C 480 10 470 0 458 0 L 325 0 C 312 0 308 6 304 14 C 300 24 292 32 276 32 L 22 32 C 10 32 0 42 0 54 L 0 320 C 0 332 10 342 22 342 L 458 342 C 470 342 480 332 480 320 Z",
7
+ tray: "M 0 22 C 0 10 10 0 22 0 L 458 0 C 470 0 480 10 480 22 L 480 320 C 480 332 470 342 458 342 L 22 342 C 10 342 0 332 0 320 Z"
8
+ };
33
9
  var DEFAULTS = {
34
10
  mode: "stack",
35
11
  // 'stack' | 'grid' | 'carousel'
@@ -40,19 +16,27 @@ var DEFAULTS = {
40
16
  // 'auto' | 'off' | 'force'
41
17
  peek: "hover",
42
18
  // 'hover' | 'always' | 'off'
19
+ drag: "fling",
20
+ // 'fling' | 'off' - grab the active folder and throw it (stack mode)
43
21
  defaultActiveIndex: 0,
44
22
  label: "Folder gallery"
45
23
  };
46
24
  var PEEK_MODES = /* @__PURE__ */ new Set(["hover", "always", "off"]);
25
+ var DRAG_MODES = /* @__PURE__ */ new Set(["fling", "off"]);
26
+ var INSTANCES = /* @__PURE__ */ new WeakMap();
47
27
  var MODE_WIDTHS = { stack: 480, grid: 720, carousel: 720 };
48
28
  var SCROLL_THRESHOLD = 30;
49
29
  var SCROLL_COOLDOWN = { stack: 450, carousel: 300 };
50
30
  function hexToRgb(hex) {
51
- const h = String(hex).replace("#", "");
31
+ let h = String(hex).trim().replace(/^#/, "");
32
+ if (h.length === 3) h = h.replace(/./g, (c) => c + c);
33
+ if (!/^[0-9a-f]{6}$/i.test(h)) return null;
52
34
  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
53
35
  }
54
36
  function folderColors(hex) {
55
- const [r, g, b] = hexToRgb(hex);
37
+ const rgb = hexToRgb(hex);
38
+ if (!rgb) return null;
39
+ const [r, g, b] = rgb;
56
40
  const dn = (v) => Math.max(v - 30, 0);
57
41
  const up = (v, o) => Math.min(v + o, 255);
58
42
  const fr = up(r, 35);
@@ -66,6 +50,34 @@ function folderColors(hex) {
66
50
  label: yiq >= 128 ? "#1c1c1a" : "#ffffff"
67
51
  };
68
52
  }
53
+ function paintFolder(card, hex) {
54
+ const c = folderColors(hex);
55
+ if (!c) return;
56
+ card.style.setProperty("--fg-folder-bg", hex);
57
+ card.style.setProperty("--fg-front", c.front);
58
+ card.style.setProperty("--fg-front-solid", c.frontSolid);
59
+ card.style.setProperty("--fg-label-on-color", c.label);
60
+ const path = card.querySelector(".fg-folder path");
61
+ if (path) path.setAttribute("fill", c.back);
62
+ }
63
+ function paintGradient(card, gradient) {
64
+ const front = card.querySelector(".fg-front");
65
+ if (!front) return;
66
+ let layer = front.querySelector(".fg-gradient");
67
+ if (!gradient) {
68
+ if (layer) layer.remove();
69
+ card.classList.remove("fg-card--gradient");
70
+ return;
71
+ }
72
+ if (!layer) {
73
+ layer = document.createElement("div");
74
+ layer.className = "fg-gradient";
75
+ layer.setAttribute("aria-hidden", "true");
76
+ front.insertBefore(layer, front.firstChild);
77
+ }
78
+ layer.style.background = gradient;
79
+ card.classList.add("fg-card--gradient");
80
+ }
69
81
  function defaultContentRenderer(card, item) {
70
82
  const wrap = document.createElement("div");
71
83
  wrap.className = "fg-content";
@@ -85,12 +97,17 @@ function defaultContentRenderer(card, item) {
85
97
  card.appendChild(wrap);
86
98
  }
87
99
  function createFolderGallery(root, options = {}) {
88
- if (!root || !root.nodeType) throw new Error("createFolderGallery: a root element is required");
89
- const opts = __spreadValues(__spreadValues({}, DEFAULTS), options);
100
+ if (!root || root.nodeType !== 1) throw new Error("createFolderGallery: a root element is required");
101
+ const prior = INSTANCES.get(root);
102
+ if (prior) prior();
103
+ const provided = {};
104
+ for (const k in options) if (options[k] !== void 0) provided[k] = options[k];
105
+ const opts = { ...DEFAULTS, ...provided };
90
106
  const items = Array.isArray(opts.items) ? opts.items : [];
91
107
  const n = items.length;
92
108
  const renderContent = typeof opts.contentRenderer === "function" ? opts.contentRenderer : defaultContentRenderer;
93
- const reduced = opts.reducedMotion === "force" || opts.reducedMotion !== "off" && typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
109
+ const reducedQuery = opts.reducedMotion === "auto" && typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : null;
110
+ let reduced = opts.reducedMotion === "force" || (reducedQuery ? reducedQuery.matches : false);
94
111
  let mode = MODE_WIDTHS[opts.mode] ? opts.mode : "stack";
95
112
  let active = Math.min(Math.max(opts.defaultActiveIndex | 0, 0), Math.max(n - 1, 0));
96
113
  let scrollCooldown = false;
@@ -101,9 +118,29 @@ function createFolderGallery(root, options = {}) {
101
118
  cleanups.push(() => el.removeEventListener(ev, fn, o));
102
119
  };
103
120
  const emit = (name, detail) => root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
121
+ let destroyed = false;
122
+ const timers = /* @__PURE__ */ new Set();
123
+ const rafs = /* @__PURE__ */ new Set();
124
+ const later = (fn, ms) => {
125
+ const id = setTimeout(() => {
126
+ timers.delete(id);
127
+ fn();
128
+ }, ms);
129
+ timers.add(id);
130
+ return id;
131
+ };
132
+ const frame = (fn) => {
133
+ const id = requestAnimationFrame(() => {
134
+ rafs.delete(id);
135
+ fn();
136
+ });
137
+ rafs.add(id);
138
+ return id;
139
+ };
104
140
  root.classList.add("fg-root");
105
141
  root.setAttribute("data-fg-mode", mode);
106
142
  root.setAttribute("data-fg-peek", PEEK_MODES.has(opts.peek) ? opts.peek : "hover");
143
+ root.setAttribute("data-fg-drag", DRAG_MODES.has(opts.drag) ? opts.drag : "fling");
107
144
  const scene = document.createElement("div");
108
145
  scene.className = "fg-scene";
109
146
  scene.setAttribute("role", "listbox");
@@ -118,26 +155,48 @@ function createFolderGallery(root, options = {}) {
118
155
  live.setAttribute("aria-atomic", "true");
119
156
  root.append(scene, dotsEl, live);
120
157
  function teardown() {
158
+ if (destroyed) return;
159
+ destroyed = true;
160
+ timers.forEach(clearTimeout);
161
+ timers.clear();
162
+ rafs.forEach(cancelAnimationFrame);
163
+ rafs.clear();
121
164
  cleanups.forEach((fn) => fn());
122
165
  cleanups.length = 0;
123
166
  root.replaceChildren();
124
167
  root.classList.remove("fg-root");
125
168
  root.removeAttribute("data-fg-mode");
126
169
  root.removeAttribute("data-fg-peek");
170
+ root.removeAttribute("data-fg-drag");
171
+ INSTANCES.delete(root);
127
172
  }
128
173
  if (n === 0) {
129
- return { next() {
130
- }, prev() {
131
- }, goTo() {
132
- }, setMode() {
133
- }, setPeek() {
134
- }, getActiveIndex: () => -1, getMode: () => mode, destroy: teardown };
174
+ const noop = () => {
175
+ };
176
+ INSTANCES.set(root, teardown);
177
+ return {
178
+ next: noop,
179
+ prev: noop,
180
+ goTo: noop,
181
+ setMode: noop,
182
+ setPeek: noop,
183
+ setColor: noop,
184
+ setGradient: noop,
185
+ getActiveIndex: () => -1,
186
+ getMode: () => mode,
187
+ getPeek: () => root.getAttribute("data-fg-peek"),
188
+ getColor: () => void 0,
189
+ getGradient: () => void 0,
190
+ getItems: () => [],
191
+ destroy: teardown
192
+ };
135
193
  }
136
194
  function setCardTransform(card, { x, y, z, rx, ry, s, zIndex, opacity, isActive }) {
137
- card.style.transform = `translate3d(${x}px,${y}px,${z}px) rotateX(${rx}deg) rotateY(${ry}deg) scale(${s})`;
138
- card.style.transformOrigin = "center bottom";
195
+ if (!card.classList.contains("fg-card--flung")) {
196
+ card.style.transform = `translate3d(${x}px,${y}px,${z}px) rotateX(${rx}deg) rotateY(${ry}deg) scale(${s})`;
197
+ card.style.opacity = String(opacity);
198
+ }
139
199
  card.style.zIndex = String(zIndex);
140
- card.style.opacity = String(opacity);
141
200
  card.setAttribute("aria-selected", isActive ? "true" : "false");
142
201
  card.classList.toggle("is-active", isActive);
143
202
  }
@@ -147,13 +206,9 @@ function createFolderGallery(root, options = {}) {
147
206
  card.tabIndex = i === active ? 0 : -1;
148
207
  card.setAttribute("role", "option");
149
208
  card.setAttribute("aria-label", item.label || `Item ${i + 1}`);
150
- if (item.color) {
151
- const c = folderColors(item.color);
152
- card.style.setProperty("--fg-folder-bg", item.color);
153
- card.style.setProperty("--fg-front", c.front);
154
- card.style.setProperty("--fg-front-solid", c.frontSolid);
155
- card.style.setProperty("--fg-label-on-color", c.label);
156
- }
209
+ card.setAttribute("aria-setsize", String(n));
210
+ card.setAttribute("aria-posinset", String(i + 1));
211
+ card.setAttribute("data-fg-index", String(i));
157
212
  const svg = document.createElementNS(SVG_NS, "svg");
158
213
  svg.setAttribute("class", "fg-folder");
159
214
  svg.setAttribute("viewBox", "0 0 480 342");
@@ -161,7 +216,7 @@ function createFolderGallery(root, options = {}) {
161
216
  svg.setAttribute("aria-hidden", "true");
162
217
  const path = document.createElementNS(SVG_NS, "path");
163
218
  path.setAttribute("d", opts.folderPath);
164
- path.setAttribute("fill", item.color ? folderColors(item.color).back : "var(--fg-folder-bg)");
219
+ path.setAttribute("fill", "var(--fg-folder-bg)");
165
220
  svg.appendChild(path);
166
221
  card.appendChild(svg);
167
222
  renderContent(card, item, i);
@@ -184,6 +239,8 @@ function createFolderGallery(root, options = {}) {
184
239
  front.appendChild(label);
185
240
  }
186
241
  card.appendChild(front);
242
+ if (item.color) paintFolder(card, item.color);
243
+ if (item.gradient) paintGradient(card, item.gradient);
187
244
  scene.appendChild(card);
188
245
  return card;
189
246
  });
@@ -197,9 +254,13 @@ function createFolderGallery(root, options = {}) {
197
254
  dotsEl.appendChild(dot);
198
255
  return dot;
199
256
  });
257
+ const clearGridWidth = (card) => {
258
+ if (card.style.width) card.style.width = "";
259
+ };
200
260
  function layoutStack() {
201
261
  scene.style.height = "";
202
262
  cardEls.forEach((card, i) => {
263
+ clearGridWidth(card);
203
264
  const behind = (i - active + n) % n;
204
265
  if (behind === 0) {
205
266
  setCardTransform(card, { x: 0, y: 100, z: 20, rx: 0, ry: 0, s: 1, zIndex: n + 1, opacity: 1, isActive: true });
@@ -215,33 +276,31 @@ function createFolderGallery(root, options = {}) {
215
276
  }
216
277
  function layoutGrid() {
217
278
  const sceneW = scene.clientWidth || MODE_WIDTHS.grid;
218
- const cols = sceneW < 520 ? 2 : n <= 4 ? 2 : 3;
219
- const gap = 16;
220
- const cardW = sceneW;
221
- const cardH = sceneW * (2 / 3);
222
- const sc = (sceneW - (cols - 1) * gap) / (cols * cardW);
223
- const scaledW = cardW * sc;
224
- const scaledH = cardH * sc;
225
- const tabScaled = 22 * sc;
279
+ const gap = Number.isFinite(opts.grid && opts.grid.gap) ? opts.grid.gap : 16;
280
+ const cols = Math.max(1, Math.round(
281
+ opts.grid && opts.grid.columns ? opts.grid.columns : sceneW < 520 ? 2 : n <= 4 ? 2 : 3
282
+ ));
283
+ const cellW = (sceneW - (cols - 1) * gap) / cols;
284
+ const cellH = cellW * (2 / 3);
226
285
  const rows = Math.ceil(n / cols);
227
286
  cardEls.forEach((card, i) => {
228
287
  const col = i % cols;
229
288
  const row = Math.floor(i / cols);
230
289
  const itemsInRow = Math.min(cols, n - row * cols);
231
- const rowW = itemsInRow * scaledW + (itemsInRow - 1) * gap;
290
+ const rowW = itemsInRow * cellW + (itemsInRow - 1) * gap;
232
291
  const rowStartX = (sceneW - rowW) / 2;
233
- const cellLeft = rowStartX + col * (scaledW + gap);
234
- const cellTop = row * (scaledH + tabScaled + gap) + tabScaled;
235
- const tx = cellLeft + scaledW / 2 - cardW / 2;
236
- const ty = cellTop + scaledH - cardH;
237
- setCardTransform(card, { x: tx, y: ty, z: 0, rx: 0, ry: 0, s: sc, zIndex: i === active ? 2 : 1, opacity: 1, isActive: i === active });
292
+ const x = rowStartX + col * (cellW + gap);
293
+ const y = row * (cellH + gap);
294
+ card.style.width = cellW + "px";
295
+ setCardTransform(card, { x, y, z: 0, rx: 0, ry: 0, s: 1, zIndex: i === active ? 2 : 1, opacity: 1, isActive: i === active });
238
296
  });
239
- scene.style.height = rows * (scaledH + tabScaled + gap) + 20 + "px";
297
+ scene.style.height = rows * cellH + (rows - 1) * gap + "px";
240
298
  }
241
299
  function layoutCarousel() {
242
300
  const sceneW = scene.clientWidth || MODE_WIDTHS.carousel;
243
301
  const cardW = sceneW;
244
302
  const cardH = cardW * (2 / 3);
303
+ cardEls.forEach(clearGridWidth);
245
304
  const activeSc = 0.62;
246
305
  const activeW = cardW * activeSc;
247
306
  const sceneH = Math.max(cardH * activeSc + 40, 180);
@@ -276,29 +335,39 @@ function createFolderGallery(root, options = {}) {
276
335
  live.textContent = cur && cur.label ? `${active + 1} of ${n}: ${cur.label}` : `${active + 1} of ${n}`;
277
336
  }
278
337
  function goTo(i) {
279
- const next = opts.loop ? (i % n + n) % n : Math.min(Math.max(i, 0), n - 1);
338
+ if (destroyed) return;
339
+ const t = Number.isFinite(+i) ? Math.trunc(+i) : active;
340
+ const next = opts.loop ? (t % n + n) % n : Math.min(Math.max(t, 0), n - 1);
280
341
  if (next === active) return;
281
342
  active = next;
282
343
  applyLayout();
283
344
  emit("fg-activechange", { index: active, item: items[active] });
284
345
  }
285
346
  function select(i) {
347
+ if (destroyed) return;
286
348
  emit("fg-select", { index: i, item: items[i] });
287
349
  if (typeof opts.onSelect === "function") opts.onSelect(items[i], i);
288
350
  }
289
351
  function setMode(m) {
290
- if (m === mode || !MODE_WIDTHS[m]) return;
352
+ if (destroyed || m === mode || !MODE_WIDTHS[m]) return;
291
353
  mode = m;
292
354
  root.setAttribute("data-fg-mode", mode);
293
355
  applyLayout();
294
356
  emit("fg-modechange", { mode });
295
357
  }
296
358
  function setPeek(p) {
297
- if (!PEEK_MODES.has(p)) return;
359
+ if (destroyed || !PEEK_MODES.has(p) || p === root.getAttribute("data-fg-peek")) return;
298
360
  root.setAttribute("data-fg-peek", p);
361
+ emit("fg-peekchange", { peek: p });
299
362
  }
363
+ const dragEnabled = opts.drag !== "off";
364
+ let suppressClick = false;
300
365
  cardEls.forEach((card, i) => {
301
366
  on(card, "click", () => {
367
+ if (suppressClick) {
368
+ suppressClick = false;
369
+ return;
370
+ }
302
371
  i === active ? select(i) : goTo(i);
303
372
  });
304
373
  on(card, "keydown", (e) => {
@@ -364,7 +433,7 @@ function createFolderGallery(root, options = {}) {
364
433
  scrollAccum = 0;
365
434
  scrollCooldown = true;
366
435
  goTo(active + dir);
367
- setTimeout(() => {
436
+ later(() => {
368
437
  scrollCooldown = false;
369
438
  scrollAccum = 0;
370
439
  }, SCROLL_COOLDOWN[mode] || 300);
@@ -378,6 +447,7 @@ function createFolderGallery(root, options = {}) {
378
447
  touchStartY = e.touches[0].clientY;
379
448
  }, { passive: true });
380
449
  on(scene, "touchend", (e) => {
450
+ if (dragEnabled && mode === "stack") return;
381
451
  const dx = touchStartX - e.changedTouches[0].clientX;
382
452
  const dy = touchStartY - e.changedTouches[0].clientY;
383
453
  const ax = Math.abs(dx);
@@ -386,29 +456,158 @@ function createFolderGallery(root, options = {}) {
386
456
  const dir = ax > ay ? dx > 0 ? 1 : -1 : dy > 0 ? 1 : -1;
387
457
  goTo(active + dir);
388
458
  }, { passive: true });
459
+ if (dragEnabled) {
460
+ const DRAG_ROT = 0.04;
461
+ const FLING_DIST = 90;
462
+ const FLING_VEL = 0.5;
463
+ const TAP_SLOP = 6;
464
+ let dragCard = null;
465
+ let dragBase = "";
466
+ let startPX = 0, startPY = 0, dragDX = 0, dragDY = 0;
467
+ let lastX = 0, lastY = 0, lastT = 0, velX = 0, velY = 0;
468
+ on(scene, "pointerdown", (e) => {
469
+ if (mode !== "stack") return;
470
+ if (e.button !== void 0 && e.button !== 0) return;
471
+ const card = e.target && e.target.closest ? e.target.closest(".fg-card") : null;
472
+ if (!card || !card.classList.contains("is-active")) return;
473
+ dragCard = card;
474
+ dragBase = card.style.transform;
475
+ startPX = e.clientX;
476
+ startPY = e.clientY;
477
+ dragDX = 0;
478
+ dragDY = 0;
479
+ lastX = e.clientX;
480
+ lastY = e.clientY;
481
+ lastT = typeof performance !== "undefined" ? performance.now() : Date.now();
482
+ velX = 0;
483
+ velY = 0;
484
+ card.classList.add("fg-card--dragging");
485
+ if (card.setPointerCapture && e.pointerId !== void 0) {
486
+ try {
487
+ card.setPointerCapture(e.pointerId);
488
+ } catch (_) {
489
+ }
490
+ }
491
+ });
492
+ on(window, "pointermove", (e) => {
493
+ if (!dragCard) return;
494
+ dragDX = e.clientX - startPX;
495
+ dragDY = e.clientY - startPY;
496
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
497
+ const dt = Math.max(now - lastT, 1);
498
+ velX = (e.clientX - lastX) / dt;
499
+ velY = (e.clientY - lastY) / dt;
500
+ lastX = e.clientX;
501
+ lastY = e.clientY;
502
+ lastT = now;
503
+ dragCard.style.transform = `${dragBase} translate3d(${dragDX}px, ${dragDY}px, 40px) rotate(${(dragDX * DRAG_ROT).toFixed(2)}deg)`;
504
+ });
505
+ const endDrag = () => {
506
+ if (!dragCard) return;
507
+ const card = dragCard;
508
+ dragCard = null;
509
+ card.classList.remove("fg-card--dragging");
510
+ const dist = Math.hypot(dragDX, dragDY);
511
+ if (dist < TAP_SLOP) {
512
+ card.style.transform = dragBase;
513
+ return;
514
+ }
515
+ suppressClick = true;
516
+ const horizontal = Math.abs(dragDX) >= Math.abs(dragDY);
517
+ const flung = dist > FLING_DIST || Math.hypot(velX, velY) > FLING_VEL;
518
+ const dir = horizontal ? dragDX > 0 ? -1 : 1 : dragDY > 0 ? -1 : 1;
519
+ const target = active + dir;
520
+ const blocked = !opts.loop && (target < 0 || target > n - 1);
521
+ if (!flung || blocked) {
522
+ card.style.transform = dragBase;
523
+ return;
524
+ }
525
+ const semantic = dir > 0 ? "next" : "prev";
526
+ emit("fg-flingstart", { index: active, direction: semantic });
527
+ if (reduced) {
528
+ goTo(target);
529
+ emit("fg-flingend", { index: target, direction: semantic });
530
+ return;
531
+ }
532
+ const offX = horizontal ? Math.sign(dragDX) * Math.max(window.innerWidth * 0.7, 480) : dragDX * 3;
533
+ const offY = horizontal ? dragDY * 3 : Math.sign(dragDY) * Math.max(window.innerHeight * 0.7, 480);
534
+ card.classList.add("fg-card--flung");
535
+ card.style.transform = `${dragBase} translate3d(${offX}px, ${offY}px, 40px) rotate(${Math.sign(dragDX || 1) * 18}deg)`;
536
+ card.style.opacity = "0";
537
+ goTo(target);
538
+ later(() => {
539
+ card.classList.add("fg-card--snap");
540
+ card.classList.remove("fg-card--flung");
541
+ applyLayout();
542
+ const slot = card.style.transform;
543
+ const slotOpacity = card.style.opacity;
544
+ card.style.transform = `${slot} translate3d(0, -90px, 0)`;
545
+ card.style.opacity = "0";
546
+ frame(() => frame(() => {
547
+ card.classList.remove("fg-card--snap");
548
+ card.classList.add("fg-card--entering");
549
+ card.style.transform = slot;
550
+ card.style.opacity = slotOpacity;
551
+ later(() => {
552
+ card.classList.remove("fg-card--entering");
553
+ emit("fg-flingend", { index: target, direction: semantic });
554
+ }, 700);
555
+ }));
556
+ }, 400);
557
+ };
558
+ on(window, "pointerup", endDrag);
559
+ on(window, "pointercancel", endDrag);
560
+ }
389
561
  let resizeTimer;
390
562
  on(window, "resize", () => {
391
563
  clearTimeout(resizeTimer);
392
- resizeTimer = setTimeout(applyLayout, 150);
564
+ resizeTimer = later(applyLayout, 150);
393
565
  });
566
+ if (reducedQuery && reducedQuery.addEventListener) {
567
+ on(reducedQuery, "change", (e) => {
568
+ if (!destroyed) {
569
+ reduced = e.matches;
570
+ applyLayout();
571
+ }
572
+ });
573
+ }
394
574
  applyLayout();
395
- return {
575
+ const handle = {
396
576
  next: () => goTo(active + 1),
397
577
  prev: () => goTo(active - 1),
398
578
  goTo,
399
579
  setMode,
400
580
  setPeek,
581
+ setColor: (i, hex) => {
582
+ const card = cardEls[i];
583
+ if (card && !destroyed) paintFolder(card, hex);
584
+ },
585
+ setGradient: (i, gradient) => {
586
+ const card = cardEls[i];
587
+ if (card && !destroyed) paintGradient(card, gradient);
588
+ },
401
589
  getActiveIndex: () => active,
402
590
  getMode: () => mode,
591
+ getPeek: () => root.getAttribute("data-fg-peek"),
592
+ getColor: (i) => {
593
+ const card = cardEls[i];
594
+ return card ? card.style.getPropertyValue("--fg-folder-bg") || void 0 : void 0;
595
+ },
596
+ getGradient: (i) => {
597
+ const layer = cardEls[i] && cardEls[i].querySelector(".fg-gradient");
598
+ return layer ? layer.style.background || void 0 : void 0;
599
+ },
600
+ getItems: () => items.slice(),
403
601
  destroy: teardown
404
602
  };
603
+ INSTANCES.set(root, teardown);
604
+ return handle;
405
605
  }
406
606
  var folder_gallery_default = createFolderGallery;
407
607
 
408
608
  export {
409
- __spreadValues,
410
- __objRest,
609
+ FOLDER_PATHS,
411
610
  createFolderGallery,
412
611
  folder_gallery_default
413
612
  };
414
- //# sourceMappingURL=chunk-DT6YOBGV.js.map
613
+ //# sourceMappingURL=chunk-RFBZ3WPG.js.map