@threadlabs/looma 0.1.7 → 0.1.8

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/layout/index.cjs CHANGED
@@ -176,7 +176,24 @@ if (typeof HTMLElement !== "undefined") {
176
176
  }
177
177
  class UISidebarElement extends UILayoutElement {
178
178
  static get observedAttributes() {
179
- return ["gap", "side", "width", "align"];
179
+ return ["gap", "side", "width", "align", "resizable", "storage-key", "min-width", "max-width", "resize-step", "resize-label"];
180
+ }
181
+ resizeHandle = null;
182
+ resizeAbort = null;
183
+ pointerAbort = null;
184
+ connectedCallback() {
185
+ this.syncResizable();
186
+ }
187
+ disconnectedCallback() {
188
+ this.resizeAbort?.abort();
189
+ this.resizeAbort = null;
190
+ this.pointerAbort?.abort();
191
+ this.pointerAbort = null;
192
+ }
193
+ attributeChangedCallback(name) {
194
+ if (["resizable", "storage-key", "min-width", "max-width", "resize-step", "resize-label", "side", "width"].includes(name)) {
195
+ this.syncResizable();
196
+ }
180
197
  }
181
198
  get gap() {
182
199
  return this.getPrimitiveAttribute("gap");
@@ -202,6 +219,128 @@ if (typeof HTMLElement !== "undefined") {
202
219
  set align(value) {
203
220
  this.setPrimitiveAttribute("align", value);
204
221
  }
222
+ get resizable() {
223
+ return this.hasAttribute("resizable");
224
+ }
225
+ set resizable(value) {
226
+ this.toggleAttribute("resizable", value);
227
+ }
228
+ numericAttribute(name, fallback) {
229
+ const parsed = Number(this.getAttribute(name));
230
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
231
+ }
232
+ bounds() {
233
+ const min = this.numericAttribute("min-width", 176);
234
+ const max = Math.max(min, this.numericAttribute("max-width", 480));
235
+ return { min, max, step: this.numericAttribute("resize-step", 16) };
236
+ }
237
+ defaultWidth() {
238
+ if (this.width === "narrow") return 224;
239
+ if (this.width === "wide") return 384;
240
+ return 288;
241
+ }
242
+ storageId() {
243
+ const key = this.getAttribute("storage-key")?.trim();
244
+ return key ? `looma:sidebar-width:${key}` : null;
245
+ }
246
+ readStoredWidth() {
247
+ const key = this.storageId();
248
+ if (!key || typeof localStorage === "undefined") return null;
249
+ try {
250
+ const parsed = Number(localStorage.getItem(key));
251
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+ persistWidth(width) {
257
+ const key = this.storageId();
258
+ if (!key || typeof localStorage === "undefined") return;
259
+ try {
260
+ localStorage.setItem(key, String(width));
261
+ } catch {
262
+ }
263
+ }
264
+ currentWidth() {
265
+ const inline = Number.parseFloat(this.style.getPropertyValue("--ui-sidebar-width"));
266
+ if (Number.isFinite(inline)) return inline;
267
+ const sideRegion = this.getAttribute("side") === "end" ? this.querySelector(":scope > :last-child:not([data-ui-sidebar-resizer])") : this.querySelector(":scope > :first-child:not([data-ui-sidebar-resizer])");
268
+ return sideRegion?.getBoundingClientRect().width || this.defaultWidth();
269
+ }
270
+ setWidth(value, persist = false, trigger = "programmatic") {
271
+ const { min, max } = this.bounds();
272
+ const width = Math.round(Math.max(min, Math.min(max, value)));
273
+ const previous = Number.parseFloat(this.style.getPropertyValue("--ui-sidebar-width"));
274
+ const changed = !Number.isFinite(previous) || previous !== width;
275
+ this.style.setProperty("--ui-sidebar-width", `${width}px`);
276
+ this.resizeHandle?.setAttribute("aria-valuemin", String(min));
277
+ this.resizeHandle?.setAttribute("aria-valuemax", String(max));
278
+ this.resizeHandle?.setAttribute("aria-valuenow", String(width));
279
+ if (persist && changed) this.persistWidth(width);
280
+ if (changed) {
281
+ this.dispatchEvent(new CustomEvent("resize", { detail: { width, trigger }, bubbles: true }));
282
+ }
283
+ }
284
+ syncResizable() {
285
+ if (!this.isConnected) return;
286
+ if (!this.resizable) {
287
+ this.resizeAbort?.abort();
288
+ this.resizeAbort = null;
289
+ this.pointerAbort?.abort();
290
+ this.pointerAbort = null;
291
+ this.resizeHandle?.remove();
292
+ this.resizeHandle = null;
293
+ return;
294
+ }
295
+ if (!this.resizeHandle) {
296
+ this.resizeHandle = document.createElement("div");
297
+ this.resizeHandle.setAttribute("data-ui-sidebar-resizer", "");
298
+ this.resizeHandle.setAttribute("role", "separator");
299
+ this.resizeHandle.setAttribute("aria-orientation", "vertical");
300
+ this.resizeHandle.setAttribute("tabindex", "0");
301
+ this.append(this.resizeHandle);
302
+ }
303
+ this.resizeHandle.setAttribute("aria-label", this.getAttribute("resize-label") || "Resize sidebar");
304
+ this.resizeAbort?.abort();
305
+ this.resizeAbort = new AbortController();
306
+ const signal = this.resizeAbort.signal;
307
+ this.resizeHandle.addEventListener("keydown", (event) => this.onResizeKey(event), { signal });
308
+ this.resizeHandle.addEventListener("pointerdown", (event) => this.onResizePointer(event), { signal });
309
+ this.resizeHandle.addEventListener("dblclick", () => this.setWidth(this.defaultWidth(), true, "pointer"), { signal });
310
+ this.setWidth(this.readStoredWidth() ?? this.currentWidth());
311
+ }
312
+ onResizeKey(event) {
313
+ const { min, max, step } = this.bounds();
314
+ let next = null;
315
+ if (event.key === "Home") next = min;
316
+ if (event.key === "End") next = max;
317
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
318
+ const physicalDelta = event.key === "ArrowRight" ? step : -step;
319
+ next = this.currentWidth() + (this.side === "end" ? -physicalDelta : physicalDelta);
320
+ }
321
+ if (next === null) return;
322
+ event.preventDefault();
323
+ this.setWidth(next, true, "keyboard");
324
+ }
325
+ onResizePointer(event) {
326
+ if (event.button !== 0 || this.pointerAbort) return;
327
+ event.preventDefault();
328
+ const startX = event.clientX;
329
+ const startWidth = this.currentWidth();
330
+ const multiplier = this.side === "end" ? -1 : 1;
331
+ const controller = new AbortController();
332
+ this.pointerAbort = controller;
333
+ const finish = () => {
334
+ this.persistWidth(this.currentWidth());
335
+ controller.abort();
336
+ this.pointerAbort = null;
337
+ };
338
+ window.addEventListener("pointermove", (moveEvent) => {
339
+ this.setWidth(startWidth + (moveEvent.clientX - startX) * multiplier, false, "pointer");
340
+ }, { signal: controller.signal });
341
+ window.addEventListener("pointerup", finish, { once: true, signal: controller.signal });
342
+ window.addEventListener("pointercancel", finish, { once: true, signal: controller.signal });
343
+ }
205
344
  }
206
345
  class UIReelElement extends UILayoutElement {
207
346
  static get observedAttributes() {
package/layout/index.css CHANGED
@@ -210,14 +210,15 @@
210
210
  gap: var(--ui-sidebar-gap);
211
211
  align-items: var(--ui-sidebar-align);
212
212
  }
213
- ui-sidebar > * {
213
+ ui-sidebar > :not([data-ui-sidebar-resizer]) {
214
214
  min-inline-size: 0;
215
215
  }
216
216
  ui-sidebar > :first-child {
217
217
  flex-basis: var(--ui-sidebar-width);
218
218
  flex-grow: 1;
219
219
  }
220
- ui-sidebar > :last-child {
220
+ ui-sidebar > :last-child:not([data-ui-sidebar-resizer]),
221
+ ui-sidebar[resizable] > :nth-last-child(2):not([data-ui-sidebar-resizer]) {
221
222
  flex-basis: 0;
222
223
  flex-grow: 999;
223
224
  min-inline-size: min(var(--ui-sidebar-content-min), 100%);
@@ -227,7 +228,8 @@
227
228
  flex-grow: 999;
228
229
  min-inline-size: min(var(--ui-sidebar-content-min), 100%);
229
230
  }
230
- ui-sidebar[side=end] > :last-child {
231
+ ui-sidebar[side=end] > :last-child:not([data-ui-sidebar-resizer]),
232
+ ui-sidebar[side=end][resizable] > :nth-last-child(2):not([data-ui-sidebar-resizer]) {
231
233
  flex-basis: var(--ui-sidebar-width);
232
234
  flex-grow: 1;
233
235
  min-inline-size: 0;
@@ -259,6 +261,49 @@
259
261
  ui-sidebar[align=end] {
260
262
  --ui-sidebar-align: flex-end;
261
263
  }
264
+ ui-sidebar[resizable] {
265
+ position: relative;
266
+ flex-wrap: nowrap;
267
+ }
268
+ ui-sidebar[resizable] > :first-child:not([data-ui-sidebar-resizer]) {
269
+ flex: 0 0 var(--ui-sidebar-width);
270
+ }
271
+ ui-sidebar[side=end][resizable] > :first-child:not([data-ui-sidebar-resizer]) {
272
+ flex: 1 1 0;
273
+ }
274
+ ui-sidebar[side=end][resizable] > :nth-last-child(2):not([data-ui-sidebar-resizer]) {
275
+ flex: 0 0 var(--ui-sidebar-width);
276
+ }
277
+ ui-sidebar > [data-ui-sidebar-resizer] {
278
+ position: absolute;
279
+ z-index: 1;
280
+ inset-block: 0;
281
+ inset-inline-start: var(--ui-sidebar-width);
282
+ inline-size: 8px;
283
+ min-inline-size: 8px;
284
+ transform: translateX(-50%);
285
+ cursor: col-resize;
286
+ touch-action: none;
287
+ outline: none;
288
+ }
289
+ ui-sidebar[side=end] > [data-ui-sidebar-resizer] {
290
+ inset-inline-start: auto;
291
+ inset-inline-end: var(--ui-sidebar-width);
292
+ transform: translateX(50%);
293
+ }
294
+ ui-sidebar > [data-ui-sidebar-resizer]::after {
295
+ content: "";
296
+ position: absolute;
297
+ inset-block: 0;
298
+ inset-inline-start: 3px;
299
+ inline-size: 2px;
300
+ background: transparent;
301
+ transition: background-color 120ms ease;
302
+ }
303
+ ui-sidebar > [data-ui-sidebar-resizer]:hover::after,
304
+ ui-sidebar > [data-ui-sidebar-resizer]:focus-visible::after {
305
+ background: var(--ui-color-focus, Highlight);
306
+ }
262
307
  ui-reel {
263
308
  --ui-reel-gap: var(--ui-space-4, 1rem);
264
309
  --ui-reel-item-width: auto;
@@ -1,3 +1,8 @@
1
+ type SidebarResizeTrigger = "keyboard" | "pointer" | "programmatic";
2
+ interface SidebarResizeDetail {
3
+ width: number;
4
+ trigger: SidebarResizeTrigger;
5
+ }
1
6
  declare function registerLayoutPrimitives(): void;
2
7
 
3
- export { registerLayoutPrimitives };
8
+ export { type SidebarResizeDetail, type SidebarResizeTrigger, registerLayoutPrimitives };
package/layout/index.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ type SidebarResizeTrigger = "keyboard" | "pointer" | "programmatic";
2
+ interface SidebarResizeDetail {
3
+ width: number;
4
+ trigger: SidebarResizeTrigger;
5
+ }
1
6
  declare function registerLayoutPrimitives(): void;
2
7
 
3
- export { registerLayoutPrimitives };
8
+ export { type SidebarResizeDetail, type SidebarResizeTrigger, registerLayoutPrimitives };
package/layout/index.js CHANGED
@@ -152,7 +152,24 @@ if (typeof HTMLElement !== "undefined") {
152
152
  }
153
153
  class UISidebarElement extends UILayoutElement {
154
154
  static get observedAttributes() {
155
- return ["gap", "side", "width", "align"];
155
+ return ["gap", "side", "width", "align", "resizable", "storage-key", "min-width", "max-width", "resize-step", "resize-label"];
156
+ }
157
+ resizeHandle = null;
158
+ resizeAbort = null;
159
+ pointerAbort = null;
160
+ connectedCallback() {
161
+ this.syncResizable();
162
+ }
163
+ disconnectedCallback() {
164
+ this.resizeAbort?.abort();
165
+ this.resizeAbort = null;
166
+ this.pointerAbort?.abort();
167
+ this.pointerAbort = null;
168
+ }
169
+ attributeChangedCallback(name) {
170
+ if (["resizable", "storage-key", "min-width", "max-width", "resize-step", "resize-label", "side", "width"].includes(name)) {
171
+ this.syncResizable();
172
+ }
156
173
  }
157
174
  get gap() {
158
175
  return this.getPrimitiveAttribute("gap");
@@ -178,6 +195,128 @@ if (typeof HTMLElement !== "undefined") {
178
195
  set align(value) {
179
196
  this.setPrimitiveAttribute("align", value);
180
197
  }
198
+ get resizable() {
199
+ return this.hasAttribute("resizable");
200
+ }
201
+ set resizable(value) {
202
+ this.toggleAttribute("resizable", value);
203
+ }
204
+ numericAttribute(name, fallback) {
205
+ const parsed = Number(this.getAttribute(name));
206
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
207
+ }
208
+ bounds() {
209
+ const min = this.numericAttribute("min-width", 176);
210
+ const max = Math.max(min, this.numericAttribute("max-width", 480));
211
+ return { min, max, step: this.numericAttribute("resize-step", 16) };
212
+ }
213
+ defaultWidth() {
214
+ if (this.width === "narrow") return 224;
215
+ if (this.width === "wide") return 384;
216
+ return 288;
217
+ }
218
+ storageId() {
219
+ const key = this.getAttribute("storage-key")?.trim();
220
+ return key ? `looma:sidebar-width:${key}` : null;
221
+ }
222
+ readStoredWidth() {
223
+ const key = this.storageId();
224
+ if (!key || typeof localStorage === "undefined") return null;
225
+ try {
226
+ const parsed = Number(localStorage.getItem(key));
227
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+ persistWidth(width) {
233
+ const key = this.storageId();
234
+ if (!key || typeof localStorage === "undefined") return;
235
+ try {
236
+ localStorage.setItem(key, String(width));
237
+ } catch {
238
+ }
239
+ }
240
+ currentWidth() {
241
+ const inline = Number.parseFloat(this.style.getPropertyValue("--ui-sidebar-width"));
242
+ if (Number.isFinite(inline)) return inline;
243
+ const sideRegion = this.getAttribute("side") === "end" ? this.querySelector(":scope > :last-child:not([data-ui-sidebar-resizer])") : this.querySelector(":scope > :first-child:not([data-ui-sidebar-resizer])");
244
+ return sideRegion?.getBoundingClientRect().width || this.defaultWidth();
245
+ }
246
+ setWidth(value, persist = false, trigger = "programmatic") {
247
+ const { min, max } = this.bounds();
248
+ const width = Math.round(Math.max(min, Math.min(max, value)));
249
+ const previous = Number.parseFloat(this.style.getPropertyValue("--ui-sidebar-width"));
250
+ const changed = !Number.isFinite(previous) || previous !== width;
251
+ this.style.setProperty("--ui-sidebar-width", `${width}px`);
252
+ this.resizeHandle?.setAttribute("aria-valuemin", String(min));
253
+ this.resizeHandle?.setAttribute("aria-valuemax", String(max));
254
+ this.resizeHandle?.setAttribute("aria-valuenow", String(width));
255
+ if (persist && changed) this.persistWidth(width);
256
+ if (changed) {
257
+ this.dispatchEvent(new CustomEvent("resize", { detail: { width, trigger }, bubbles: true }));
258
+ }
259
+ }
260
+ syncResizable() {
261
+ if (!this.isConnected) return;
262
+ if (!this.resizable) {
263
+ this.resizeAbort?.abort();
264
+ this.resizeAbort = null;
265
+ this.pointerAbort?.abort();
266
+ this.pointerAbort = null;
267
+ this.resizeHandle?.remove();
268
+ this.resizeHandle = null;
269
+ return;
270
+ }
271
+ if (!this.resizeHandle) {
272
+ this.resizeHandle = document.createElement("div");
273
+ this.resizeHandle.setAttribute("data-ui-sidebar-resizer", "");
274
+ this.resizeHandle.setAttribute("role", "separator");
275
+ this.resizeHandle.setAttribute("aria-orientation", "vertical");
276
+ this.resizeHandle.setAttribute("tabindex", "0");
277
+ this.append(this.resizeHandle);
278
+ }
279
+ this.resizeHandle.setAttribute("aria-label", this.getAttribute("resize-label") || "Resize sidebar");
280
+ this.resizeAbort?.abort();
281
+ this.resizeAbort = new AbortController();
282
+ const signal = this.resizeAbort.signal;
283
+ this.resizeHandle.addEventListener("keydown", (event) => this.onResizeKey(event), { signal });
284
+ this.resizeHandle.addEventListener("pointerdown", (event) => this.onResizePointer(event), { signal });
285
+ this.resizeHandle.addEventListener("dblclick", () => this.setWidth(this.defaultWidth(), true, "pointer"), { signal });
286
+ this.setWidth(this.readStoredWidth() ?? this.currentWidth());
287
+ }
288
+ onResizeKey(event) {
289
+ const { min, max, step } = this.bounds();
290
+ let next = null;
291
+ if (event.key === "Home") next = min;
292
+ if (event.key === "End") next = max;
293
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
294
+ const physicalDelta = event.key === "ArrowRight" ? step : -step;
295
+ next = this.currentWidth() + (this.side === "end" ? -physicalDelta : physicalDelta);
296
+ }
297
+ if (next === null) return;
298
+ event.preventDefault();
299
+ this.setWidth(next, true, "keyboard");
300
+ }
301
+ onResizePointer(event) {
302
+ if (event.button !== 0 || this.pointerAbort) return;
303
+ event.preventDefault();
304
+ const startX = event.clientX;
305
+ const startWidth = this.currentWidth();
306
+ const multiplier = this.side === "end" ? -1 : 1;
307
+ const controller = new AbortController();
308
+ this.pointerAbort = controller;
309
+ const finish = () => {
310
+ this.persistWidth(this.currentWidth());
311
+ controller.abort();
312
+ this.pointerAbort = null;
313
+ };
314
+ window.addEventListener("pointermove", (moveEvent) => {
315
+ this.setWidth(startWidth + (moveEvent.clientX - startX) * multiplier, false, "pointer");
316
+ }, { signal: controller.signal });
317
+ window.addEventListener("pointerup", finish, { once: true, signal: controller.signal });
318
+ window.addEventListener("pointercancel", finish, { once: true, signal: controller.signal });
319
+ }
181
320
  }
182
321
  class UIReelElement extends UILayoutElement {
183
322
  static get observedAttributes() {
package/layout.css CHANGED
@@ -151,12 +151,13 @@
151
151
  align-items: var(--ui-sidebar-align);
152
152
  }
153
153
 
154
- ui-sidebar > * { min-inline-size: 0; }
154
+ ui-sidebar > :not([data-ui-sidebar-resizer]) { min-inline-size: 0; }
155
155
  ui-sidebar > :first-child {
156
156
  flex-basis: var(--ui-sidebar-width);
157
157
  flex-grow: 1;
158
158
  }
159
- ui-sidebar > :last-child {
159
+ ui-sidebar > :last-child:not([data-ui-sidebar-resizer]),
160
+ ui-sidebar[resizable] > :nth-last-child(2):not([data-ui-sidebar-resizer]) {
160
161
  flex-basis: 0;
161
162
  flex-grow: 999;
162
163
  min-inline-size: min(var(--ui-sidebar-content-min), 100%);
@@ -166,7 +167,8 @@
166
167
  flex-grow: 999;
167
168
  min-inline-size: min(var(--ui-sidebar-content-min), 100%);
168
169
  }
169
- ui-sidebar[side="end"] > :last-child {
170
+ ui-sidebar[side="end"] > :last-child:not([data-ui-sidebar-resizer]),
171
+ ui-sidebar[side="end"][resizable] > :nth-last-child(2):not([data-ui-sidebar-resizer]) {
170
172
  flex-basis: var(--ui-sidebar-width);
171
173
  flex-grow: 1;
172
174
  min-inline-size: 0;
@@ -180,6 +182,49 @@
180
182
  ui-sidebar[align="start"] { --ui-sidebar-align: flex-start; }
181
183
  ui-sidebar[align="center"] { --ui-sidebar-align: center; }
182
184
  ui-sidebar[align="end"] { --ui-sidebar-align: flex-end; }
185
+ ui-sidebar[resizable] {
186
+ position: relative;
187
+ flex-wrap: nowrap;
188
+ }
189
+ ui-sidebar[resizable] > :first-child:not([data-ui-sidebar-resizer]) {
190
+ flex: 0 0 var(--ui-sidebar-width);
191
+ }
192
+ ui-sidebar[side="end"][resizable] > :first-child:not([data-ui-sidebar-resizer]) {
193
+ flex: 1 1 0;
194
+ }
195
+ ui-sidebar[side="end"][resizable] > :nth-last-child(2):not([data-ui-sidebar-resizer]) {
196
+ flex: 0 0 var(--ui-sidebar-width);
197
+ }
198
+ ui-sidebar > [data-ui-sidebar-resizer] {
199
+ position: absolute;
200
+ z-index: 1;
201
+ inset-block: 0;
202
+ inset-inline-start: var(--ui-sidebar-width);
203
+ inline-size: 8px;
204
+ min-inline-size: 8px;
205
+ transform: translateX(-50%);
206
+ cursor: col-resize;
207
+ touch-action: none;
208
+ outline: none;
209
+ }
210
+ ui-sidebar[side="end"] > [data-ui-sidebar-resizer] {
211
+ inset-inline-start: auto;
212
+ inset-inline-end: var(--ui-sidebar-width);
213
+ transform: translateX(50%);
214
+ }
215
+ ui-sidebar > [data-ui-sidebar-resizer]::after {
216
+ content: "";
217
+ position: absolute;
218
+ inset-block: 0;
219
+ inset-inline-start: 3px;
220
+ inline-size: 2px;
221
+ background: transparent;
222
+ transition: background-color 120ms ease;
223
+ }
224
+ ui-sidebar > [data-ui-sidebar-resizer]:hover::after,
225
+ ui-sidebar > [data-ui-sidebar-resizer]:focus-visible::after {
226
+ background: var(--ui-color-focus, Highlight);
227
+ }
183
228
 
184
229
  ui-reel {
185
230
  --ui-reel-gap: var(--ui-space-4, 1rem);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "Looma web components, responsive layout primitives, a complete editor, and supported Vue adapters.",
6
6
  "license": "MIT",