@kodaris/krubble-app-components 1.0.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.
@@ -0,0 +1,1853 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { LitElement, html, css, nothing } from 'lit';
8
+ import { customElement, property, state } from 'lit/decorators.js';
9
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
10
+ import { classMap } from 'lit/directives/class-map.js';
11
+ import { KRContextMenu, KRDialog } from '@kodaris/krubble-components';
12
+ import { KRClient } from '@krubble/data';
13
+ import { KRNavItemEdit } from './scaffold/nav-item-edit.js';
14
+ /**
15
+ * Application shell component with nav drawer and main content area.
16
+ *
17
+ * ## Features
18
+ * - Collapsible navigation drawer with hierarchical items (groups and items)
19
+ * - User profile display in footer with customization menu
20
+ * - Edit mode for customizing navigation (rename, reorder, hide items, add custom items)
21
+ * - Drag and drop reordering in edit mode
22
+ * - Persistence of customizations via preference API (global/organization level)
23
+ *
24
+ * ## Nav Customization
25
+ * Users can customize the navigation by clicking their profile and selecting
26
+ * "Customize Navigation". This enters edit mode where users can:
27
+ * - Right-click items to edit label, icon, or visibility
28
+ * - Right-click to add new items above/below existing items
29
+ * - Drag and drop to reorder items or move them between groups
30
+ *
31
+ * Customizations are stored globally and apply to all users.
32
+ *
33
+ * ## Preference Storage
34
+ * Preferences are stored as JSON with the following structure:
35
+ * ```json
36
+ * {
37
+ * "nav": {
38
+ * "itemId": { "label": "Custom Label", "icon": "...", "active": true, "order": 0 },
39
+ * "custom-123": { "type": "item", "label": "New Item", "url": "/path", ... }
40
+ * }
41
+ * }
42
+ * ```
43
+ *
44
+ * The `nav` object is a delta - it only contains overrides for existing items
45
+ * or definitions for custom items (prefixed with "custom-").
46
+ *
47
+ * ## API Endpoints Used
48
+ * - GET `/api/system/preference/json/scaffold?global=true` - load preferences
49
+ * - POST `/api/system/preference/json/scaffold?global=true` - create new preference
50
+ * - PUT `/api/system/preference/json/scaffold/{uuid}?global=true` - update existing preference
51
+ *
52
+ * @slot - The main content
53
+ *
54
+ * @property {string} logo - URL for the logo image
55
+ * @property {string} title - Title text to display instead of logo
56
+ * @property {'light'|'dark'} scheme - Color scheme: 'light' (default) or 'dark'
57
+ * @property {KRNavItem[]} nav - Navigation items as JSON array
58
+ * @property {KRUser} user - User profile data
59
+ *
60
+ *
61
+ * TODO
62
+ * - Don't hardcode breadcrumbs
63
+ * - Look at renderNormalFooter and renderEditFooter and see if that is what we want to do
64
+ */
65
+ let KRScaffold = class KRScaffold extends LitElement {
66
+ constructor() {
67
+ super();
68
+ /**
69
+ * HTTP client for API calls
70
+ */
71
+ this.http = KRClient.getInstance();
72
+ /**
73
+ * Default icon for nav items without an icon (shown at top level)
74
+ */
75
+ this.defaultNavItemIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>';
76
+ this.navItemsExpanded = new Set();
77
+ this.navQuery = '';
78
+ this.activeNavItemId = null;
79
+ this.isNavScrolled = false;
80
+ this.isNavOpened = true;
81
+ this.isEditing = false;
82
+ this.isUserMenuOpen = false;
83
+ this.pref = { nav: {} };
84
+ this.draggedNavItemId = null;
85
+ this.navItemDropTargetId = null;
86
+ this.navItemDropPosition = 'above';
87
+ this.pendingRequests = 0;
88
+ this.originalFetch = null;
89
+ this.originalXhrOpen = null;
90
+ this.navItemDragPreview = null;
91
+ this.navItemDragStartY = 0;
92
+ this.isNavItemDragging = false;
93
+ this.navItemDragExpandTimeout = null;
94
+ this.navInitialized = false;
95
+ this.logo = '';
96
+ this.title = '';
97
+ this.scheme = 'light';
98
+ this.nav = [];
99
+ this.navIconsDisplayed = false;
100
+ this.navExpanded = false;
101
+ this.user = null;
102
+ /**
103
+ * Whether to show the subbar with menu toggle
104
+ */
105
+ this.subbar = false;
106
+ /**
107
+ * Breadcrumbs to display in the subbar
108
+ */
109
+ this.breadcrumbs = [];
110
+ this.boundHandleMouseMove = this.handleMouseMove.bind(this);
111
+ this.boundHandleMouseUp = this.handleMouseUp.bind(this);
112
+ }
113
+ connectedCallback() {
114
+ super.connectedCallback();
115
+ this.loadPref();
116
+ this.installFetchInterceptor();
117
+ }
118
+ updated(changedProperties) {
119
+ super.updated(changedProperties);
120
+ if (changedProperties.has('nav') && this.nav.length > 0) {
121
+ this.updateActiveNavItem();
122
+ if (this.navExpanded && !this.navInitialized) {
123
+ this.navInitialized = true;
124
+ this.getComputedNav()
125
+ .filter(item => item.type === 'group')
126
+ .forEach(group => this.navItemsExpanded.add(group.id));
127
+ }
128
+ }
129
+ }
130
+ disconnectedCallback() {
131
+ super.disconnectedCallback();
132
+ this.uninstallFetchInterceptor();
133
+ }
134
+ /**
135
+ * Installs global interceptors for fetch and XMLHttpRequest to track pending HTTP requests.
136
+ * Updates `pendingRequests` state when requests start/complete.
137
+ */
138
+ installFetchInterceptor() {
139
+ if (this.originalFetch)
140
+ return;
141
+ const scaffold = this;
142
+ // Intercept fetch
143
+ this.originalFetch = window.fetch.bind(window);
144
+ const originalFetch = this.originalFetch; // Capture in local scope to avoid null reference after uninstall
145
+ window.fetch = function (...args) {
146
+ scaffold.pendingRequests++;
147
+ return originalFetch(...args).finally(() => {
148
+ scaffold.pendingRequests--;
149
+ });
150
+ };
151
+ // Intercept XMLHttpRequest
152
+ this.originalXhrOpen = XMLHttpRequest.prototype.open;
153
+ const originalXhrOpen = this.originalXhrOpen; // Capture in local scope to avoid null reference after uninstall
154
+ XMLHttpRequest.prototype.open = function (method, url, async = true, username, password) {
155
+ scaffold.pendingRequests++;
156
+ this.addEventListener('loadend', () => {
157
+ scaffold.pendingRequests--;
158
+ }, { once: true });
159
+ return originalXhrOpen.call(this, method, url, async, username, password);
160
+ };
161
+ }
162
+ /**
163
+ * Restores the original fetch and XMLHttpRequest functions.
164
+ */
165
+ uninstallFetchInterceptor() {
166
+ if (this.originalFetch) {
167
+ window.fetch = this.originalFetch;
168
+ this.originalFetch = null;
169
+ }
170
+ if (this.originalXhrOpen) {
171
+ XMLHttpRequest.prototype.open = this.originalXhrOpen;
172
+ this.originalXhrOpen = null;
173
+ }
174
+ }
175
+ // =========================================================================
176
+ // Navigation Data
177
+ // =========================================================================
178
+ /**
179
+ * Returns the computed navigation as a flat array with preferences applied.
180
+ *
181
+ * Transforms the hierarchical `nav` property into a flat list where each item
182
+ * has a `parentId` reference instead of nested `children`. Merges user
183
+ * preference overrides (label, icon, url, active, order, parentId) and
184
+ * includes any custom items added via the edit dialog.
185
+ *
186
+ * @returns Flat array of nav items with preferences applied
187
+ */
188
+ getComputedNav() {
189
+ const result = [];
190
+ this.nav.forEach((item, index) => {
191
+ const override = this.pref.nav[item.id];
192
+ result.push({
193
+ ...item,
194
+ ...override,
195
+ order: override?.order ?? item.order ?? index,
196
+ parentId: override?.parentId ?? null,
197
+ });
198
+ // Add children if group
199
+ if (item.type === 'group' && item.children) {
200
+ item.children.forEach((child, childIndex) => {
201
+ const childOverride = this.pref.nav[child.id];
202
+ result.push({
203
+ ...child,
204
+ ...childOverride,
205
+ order: childOverride?.order ?? child.order ?? childIndex,
206
+ parentId: childOverride?.parentId !== undefined ? childOverride.parentId : item.id,
207
+ });
208
+ });
209
+ }
210
+ });
211
+ // Add custom items from pref
212
+ Object.entries(this.pref.nav).forEach(([id, item]) => {
213
+ if (id.startsWith('custom') && item.type && item.label) {
214
+ result.push({
215
+ id,
216
+ type: item.type,
217
+ label: item.label,
218
+ icon: item.icon,
219
+ url: item.url,
220
+ active: item.active,
221
+ order: item.order ?? result.length,
222
+ parentId: item.parentId ?? null,
223
+ });
224
+ }
225
+ });
226
+ return result;
227
+ }
228
+ /**
229
+ * Returns nav items for a given parent, filtered and sorted for rendering.
230
+ *
231
+ * Filters the computed nav to items matching the parentId, sorts by order,
232
+ * hides inactive items in normal mode, and applies the search query filter.
233
+ * Groups are shown if their label matches or if they have matching children.
234
+ *
235
+ * @param parentId - The parent ID to filter by (null for top-level items)
236
+ * @returns Filtered and sorted nav items ready for rendering
237
+ */
238
+ getNavItemChildren(parentId) {
239
+ let items = this.getComputedNav()
240
+ .filter(item => item.parentId === parentId)
241
+ .sort((a, b) => a.order - b.order);
242
+ // In normal mode, filter out hidden items
243
+ if (!this.isEditing) {
244
+ items = items.filter(item => item.active !== false);
245
+ }
246
+ // Apply search filter
247
+ if (this.navQuery) {
248
+ const query = this.navQuery.toLowerCase();
249
+ const allItems = this.getComputedNav();
250
+ items = items.filter(item => {
251
+ if (item.type === 'group') {
252
+ // Show group if its label matches OR if it has matching children
253
+ return item.label.toLowerCase().includes(query) || allItems.some(child => child.parentId === item.id &&
254
+ child.active !== false &&
255
+ child.label.toLowerCase().includes(query));
256
+ }
257
+ // Show item if its label matches
258
+ return item.label.toLowerCase().includes(query);
259
+ });
260
+ }
261
+ return items;
262
+ }
263
+ // =========================================================================
264
+ // Navigation Search
265
+ // =========================================================================
266
+ /**
267
+ * Handles search input changes for filtering nav items.
268
+ *
269
+ * When a search query is entered, automatically expands any groups that
270
+ * contain matching children so users can see the results. When the search
271
+ * is cleared, collapses all groups back to their default state.
272
+ *
273
+ * @param e - The input event from the search field
274
+ */
275
+ handleNavQueryChange(e) {
276
+ this.navQuery = e.target.value;
277
+ // Expand all groups when searching so matching children are visible
278
+ // (groups without matching children won't be rendered anyway)
279
+ if (this.navQuery) {
280
+ this.getComputedNav().forEach(item => {
281
+ if (item.type === 'group') {
282
+ this.navItemsExpanded.add(item.id);
283
+ }
284
+ });
285
+ }
286
+ else {
287
+ // Collapse all when search is cleared
288
+ this.navItemsExpanded.clear();
289
+ }
290
+ }
291
+ /**
292
+ * Clears the search query.
293
+ */
294
+ handleNavQueryClear() {
295
+ this.navQuery = '';
296
+ this.navItemsExpanded.clear();
297
+ }
298
+ // =========================================================================
299
+ // Navigation Interaction
300
+ // =========================================================================
301
+ handleMenuClick() {
302
+ this.isNavOpened = !this.isNavOpened;
303
+ }
304
+ /**
305
+ * Handles scroll on nav content to show/hide header shadow.
306
+ */
307
+ handleNavScroll(e) {
308
+ this.isNavScrolled = e.target.scrollTop > 0;
309
+ }
310
+ /**
311
+ * Toggles the expanded/collapsed state of a nav group with animation.
312
+ * Uses the Web Animations API to animate height transitions.
313
+ * When collapsing, the `nav-group--expanded` class is removed after animation completes.
314
+ * When expanding, the class is added immediately before animation starts.
315
+ *
316
+ * @param itemId - The unique identifier of the nav group to toggle
317
+ */
318
+ toggleNavItem(itemId) {
319
+ const navItem = this.shadowRoot?.querySelector(`.nav-item[data-id="${itemId}"]`);
320
+ const groupEl = navItem?.nextElementSibling;
321
+ if (!groupEl)
322
+ return;
323
+ if (this.navItemsExpanded.has(itemId)) {
324
+ this.navItemsExpanded.delete(itemId);
325
+ groupEl.animate([
326
+ { height: `${groupEl.scrollHeight}px` },
327
+ { height: '0px' }
328
+ ], { duration: 300, easing: 'ease-in-out' }).onfinish = () => {
329
+ groupEl.classList.remove('nav-group--expanded');
330
+ };
331
+ }
332
+ else {
333
+ this.navItemsExpanded.add(itemId);
334
+ groupEl.classList.add('nav-group--expanded');
335
+ groupEl.animate([
336
+ { height: '0px' },
337
+ { height: `${groupEl.scrollHeight}px` }
338
+ ], { duration: 300, easing: 'ease-in-out' });
339
+ }
340
+ this.requestUpdate();
341
+ }
342
+ /**
343
+ * Handles click events on navigation items.
344
+ *
345
+ * For groups, toggles the expanded/collapsed state.
346
+ * For items, dispatches a cancelable `nav-item-click` custom event.
347
+ *
348
+ * If a listener calls `preventDefault()` on the custom event, native browser
349
+ * navigation is prevented. Otherwise, the default `<a href>` navigation
350
+ * proceeds normally.
351
+ *
352
+ * The `@krubble/angular` package provides `KRScaffoldDirective` which
353
+ * automatically listens for this event and handles navigation using
354
+ * Angular's router. Import `KrubbleModule` in your Angular app to enable
355
+ * SPA navigation without page reloads.
356
+ *
357
+ * @param e - The original click event
358
+ * @param item - The nav item that was clicked
359
+ */
360
+ handleNavItemClick(e, item) {
361
+ if (item.type === 'group') {
362
+ this.toggleNavItem(item.id);
363
+ }
364
+ else {
365
+ const navEvent = new CustomEvent('nav-item-click', {
366
+ detail: { item },
367
+ bubbles: true,
368
+ composed: true,
369
+ cancelable: true,
370
+ });
371
+ this.dispatchEvent(navEvent);
372
+ // If a listener called preventDefault(), prevent the native navigation
373
+ if (navEvent.defaultPrevented) {
374
+ e.preventDefault();
375
+ }
376
+ }
377
+ }
378
+ /**
379
+ * Updates the active nav item highlight based on the current URL.
380
+ *
381
+ * Compares the first path segment of `window.location.pathname` with each
382
+ * nav item's URL to find a match. When found, highlights that item and
383
+ * expands its parent group if nested.
384
+ *
385
+ * Called automatically by `KRScaffoldDirective` on Angular route changes.
386
+ */
387
+ updateActiveNavItem() {
388
+ const currentPath = window.location.pathname;
389
+ const allItems = this.getComputedNav().filter(item => item.type === 'item' && item.url);
390
+ // Find the best match - prefer longer/more specific URL matches
391
+ let activeItem = null;
392
+ let longestMatch = 0;
393
+ for (const item of allItems) {
394
+ const url = item.url; // We filtered for items with url above
395
+ if (currentPath.startsWith(url) && url.length > longestMatch) {
396
+ activeItem = item;
397
+ longestMatch = url.length;
398
+ }
399
+ }
400
+ if (activeItem) {
401
+ this.activeNavItemId = activeItem.id;
402
+ if (activeItem.parentId) {
403
+ this.navItemsExpanded.add(activeItem.parentId);
404
+ }
405
+ }
406
+ else {
407
+ this.activeNavItemId = null;
408
+ }
409
+ }
410
+ // =========================================================================
411
+ // Navigation Customization
412
+ // =========================================================================
413
+ startEditing() {
414
+ this.isEditing = true;
415
+ }
416
+ cancelEditing() {
417
+ this.isEditing = false;
418
+ // Reload to revert changes
419
+ this.loadPref();
420
+ }
421
+ resetPref() {
422
+ this.pref.nav = {};
423
+ }
424
+ savePref() {
425
+ const url = this.pref.uuid
426
+ ? `/api/system/preference/json/scaffold/${this.pref.uuid}?global=true`
427
+ : `/api/system/preference/json/scaffold?global=true`;
428
+ this.http.fetch({
429
+ url,
430
+ method: this.pref.uuid ? 'PUT' : 'POST',
431
+ body: JSON.stringify({ nav: this.pref.nav }),
432
+ })
433
+ .then((response) => {
434
+ this.pref = response.data;
435
+ this.isEditing = false;
436
+ })
437
+ .catch((error) => {
438
+ console.error('Failed to save nav customizations:', error);
439
+ });
440
+ }
441
+ loadPref() {
442
+ this.http.fetch({
443
+ url: '/api/system/preference/json/scaffold?global=true',
444
+ method: 'GET',
445
+ })
446
+ .then((response) => {
447
+ const pref = response?.data?.[0];
448
+ if (pref) {
449
+ this.pref = pref;
450
+ }
451
+ })
452
+ .catch(() => {
453
+ // No preferences saved yet
454
+ });
455
+ }
456
+ handleNavItemContextMenu(e, item) {
457
+ if (!this.isEditing)
458
+ return;
459
+ e.preventDefault();
460
+ KRContextMenu.open({
461
+ x: e.clientX,
462
+ y: e.clientY,
463
+ items: [
464
+ { id: 'edit', label: 'Edit Item' },
465
+ { id: 'divider-1', label: '', divider: true },
466
+ { id: 'add-above', label: 'Add Item Above' },
467
+ { id: 'add-below', label: 'Add Item Below' },
468
+ ],
469
+ }).then((result) => {
470
+ if (!result)
471
+ return;
472
+ switch (result.id) {
473
+ case 'edit':
474
+ this.openNavItemEdit(item);
475
+ break;
476
+ case 'add-above':
477
+ this.addNavItem(item, 'above');
478
+ break;
479
+ case 'add-below':
480
+ this.addNavItem(item, 'below');
481
+ break;
482
+ }
483
+ });
484
+ }
485
+ /**
486
+ * Opens the nav item edit dialog for customizing an item's properties.
487
+ *
488
+ * Opens `KRNavItemEdit` dialog pre-populated with the item's current values.
489
+ * When the user saves, merges the result into the nav delta to persist
490
+ * customizations like label, icon, URL, and visibility.
491
+ *
492
+ * @param item - The nav item to edit
493
+ */
494
+ openNavItemEdit(item) {
495
+ KRDialog.open(KRNavItemEdit, { data: item }).afterClosed().then((res) => {
496
+ const result = res;
497
+ if (!result)
498
+ return;
499
+ if (!this.pref.nav[item.id]) {
500
+ this.pref.nav[item.id] = {};
501
+ }
502
+ this.pref.nav[item.id] = {
503
+ ...this.pref.nav[item.id],
504
+ ...result
505
+ };
506
+ this.requestUpdate();
507
+ });
508
+ }
509
+ /**
510
+ * Adds a new custom nav item relative to an existing item.
511
+ *
512
+ * Creates a placeholder item in the nav delta, shifts existing siblings
513
+ * to make room, then opens the edit dialog so the user can configure
514
+ * the new item's label, icon, and URL.
515
+ *
516
+ * @param targetItem - The existing item to position relative to
517
+ * @param position - Whether to insert 'above' or 'below' the target item
518
+ */
519
+ addNavItem(targetItem, position) {
520
+ const customId = `custom-${Date.now()}`;
521
+ const siblings = this.getComputedNav()
522
+ .filter(i => i.parentId === targetItem.parentId)
523
+ .sort((a, b) => a.order - b.order);
524
+ const targetIndex = siblings.findIndex(i => i.id === targetItem.id);
525
+ const newOrder = position === 'above' ? targetIndex : targetIndex + 1;
526
+ // Shift existing items to make room for the new item
527
+ siblings.forEach((sibling, index) => {
528
+ if (index >= newOrder) {
529
+ if (!this.pref.nav[sibling.id]) {
530
+ this.pref.nav[sibling.id] = {};
531
+ }
532
+ this.pref.nav[sibling.id].order = index + 1;
533
+ }
534
+ });
535
+ // Add the new item and open edit dialog
536
+ const newItem = {
537
+ id: customId,
538
+ type: 'item',
539
+ label: 'New Item',
540
+ order: newOrder,
541
+ parentId: targetItem.parentId,
542
+ active: true,
543
+ };
544
+ this.pref.nav[customId] = newItem;
545
+ this.requestUpdate();
546
+ this.openNavItemEdit(newItem);
547
+ }
548
+ // =========================================================================
549
+ // Navigation Drag & Drop
550
+ // =========================================================================
551
+ /**
552
+ * Initiates a potential drag operation when mouse is pressed on a nav item.
553
+ *
554
+ * Only active in edit mode. Records the starting position and sets up
555
+ * document-level listeners for mouse move and mouse up events. The actual
556
+ * drag doesn't start until the mouse moves more than 5 pixels.
557
+ *
558
+ * @param e - The mouse event
559
+ * @param item - The nav item being pressed
560
+ */
561
+ handleNavItemMouseDown(e, item) {
562
+ if (!this.isEditing)
563
+ return;
564
+ e.preventDefault();
565
+ this.draggedNavItemId = item.id;
566
+ this.navItemDragStartY = e.clientY;
567
+ this.isNavItemDragging = false;
568
+ document.addEventListener('mousemove', this.boundHandleMouseMove);
569
+ document.addEventListener('mouseup', this.boundHandleMouseUp);
570
+ }
571
+ /**
572
+ * Handles mouse movement during a nav item drag operation.
573
+ *
574
+ * Initiates dragging after the mouse moves more than 5 pixels (to distinguish
575
+ * from clicks), creates a floating preview element that follows the cursor,
576
+ * and updates the drop target indicator based on mouse position.
577
+ *
578
+ * @param e - The mouse event
579
+ */
580
+ handleMouseMove(e) {
581
+ if (!this.draggedNavItemId)
582
+ return;
583
+ // Start dragging after moving a few pixels (to distinguish from clicks)
584
+ if (!this.isNavItemDragging && Math.abs(e.clientY - this.navItemDragStartY) > 5) {
585
+ this.isNavItemDragging = true;
586
+ this.classList.add('kr-scaffold--dragging');
587
+ // Create drag preview
588
+ const draggedItem = this.getComputedNav().find(i => i.id === this.draggedNavItemId);
589
+ if (draggedItem) {
590
+ this.navItemDragPreview = document.createElement('div');
591
+ this.navItemDragPreview.className = 'nav-item-drag-preview';
592
+ this.navItemDragPreview.textContent = draggedItem.label;
593
+ this.navItemDragPreview.style.left = `${e.clientX + 10}px`;
594
+ this.navItemDragPreview.style.top = `${e.clientY - 20}px`;
595
+ this.shadowRoot?.appendChild(this.navItemDragPreview);
596
+ }
597
+ }
598
+ if (!this.isNavItemDragging)
599
+ return;
600
+ // Update preview position
601
+ if (this.navItemDragPreview) {
602
+ this.navItemDragPreview.style.left = `${e.clientX + 10}px`;
603
+ this.navItemDragPreview.style.top = `${e.clientY - 20}px`;
604
+ }
605
+ // Find drop target based on mouse position
606
+ this.updateNavItemDropTarget(e);
607
+ }
608
+ /**
609
+ * Handles the end of a nav item drag operation.
610
+ *
611
+ * Removes document-level mouse listeners, executes the drop if there's a valid
612
+ * target, and cleans up all drag-related state.
613
+ */
614
+ handleMouseUp() {
615
+ document.removeEventListener('mousemove', this.boundHandleMouseMove);
616
+ document.removeEventListener('mouseup', this.boundHandleMouseUp);
617
+ if (this.isNavItemDragging && this.navItemDropTargetId) {
618
+ this.executeNavItemDrop();
619
+ }
620
+ // Reset all drag state
621
+ this.draggedNavItemId = null;
622
+ this.navItemDropTargetId = null;
623
+ this.isNavItemDragging = false;
624
+ this.classList.remove('kr-scaffold--dragging');
625
+ if (this.navItemDragPreview) {
626
+ this.navItemDragPreview.remove();
627
+ this.navItemDragPreview = null;
628
+ }
629
+ this.clearNavItemDragExpandTimeout();
630
+ this.requestUpdate();
631
+ }
632
+ /**
633
+ * Determines where a dragged nav item would be dropped based on mouse position.
634
+ *
635
+ * Loops through all nav items and checks if the mouse is hovering over one.
636
+ * For groups, the item is divided into thirds (above/center/below) where
637
+ * "center" means drop into the group. For regular items, it's halves (above/below).
638
+ *
639
+ * Also handles auto-expanding collapsed groups when hovering over their center,
640
+ * and validates drops (e.g., prevents dropping a group into another group).
641
+ *
642
+ * Updates `navItemDropTargetId` and `navItemDropPosition` state which are used to render
643
+ * visual drop indicators in the UI.
644
+ *
645
+ * @param e - The mouse event from dragging
646
+ */
647
+ updateNavItemDropTarget(e) {
648
+ // Get all nav items in shadow DOM
649
+ const navItems = this.shadowRoot?.querySelectorAll('.nav-item[data-id]');
650
+ if (!navItems)
651
+ return;
652
+ let foundTarget = false;
653
+ navItems.forEach((el) => {
654
+ const rect = el.getBoundingClientRect();
655
+ const itemId = el.getAttribute('data-id');
656
+ if (!itemId || itemId === this.draggedNavItemId)
657
+ return;
658
+ // Skip if mouse is outside this element
659
+ if (e.clientX < rect.left || e.clientX > rect.right ||
660
+ e.clientY < rect.top || e.clientY > rect.bottom) {
661
+ return;
662
+ }
663
+ const item = this.getComputedNav().find(i => i.id === itemId);
664
+ if (!item) {
665
+ return;
666
+ }
667
+ const y = e.clientY - rect.top;
668
+ let position;
669
+ // Determine drop position based on mouse Y position within the item.
670
+ // Groups are divided into thirds: top=above, middle=center (drop into), bottom=below.
671
+ // Regular items are divided in half: top=above, bottom=below.
672
+ if (item.type === 'group') {
673
+ if (y < rect.height / 3) {
674
+ // Top third - drop above the group
675
+ position = 'above';
676
+ this.clearNavItemDragExpandTimeout();
677
+ }
678
+ else if (y > (rect.height * 2) / 3) {
679
+ // Bottom third - drop below the group
680
+ position = 'below';
681
+ this.clearNavItemDragExpandTimeout();
682
+ }
683
+ else {
684
+ // Middle third - drop into the group (as a child)
685
+ position = 'center';
686
+ // Auto-expand collapsed groups after a delay
687
+ if (!this.navItemsExpanded.has(item.id) && !this.navItemDragExpandTimeout) {
688
+ this.expandNavGroupOnDrag(item.id);
689
+ }
690
+ }
691
+ }
692
+ else {
693
+ // Regular items only support above/below, not center
694
+ position = y < rect.height / 2 ? 'above' : 'below';
695
+ this.clearNavItemDragExpandTimeout();
696
+ }
697
+ // Determine what the new parent would be
698
+ const newParentId = position === 'center' ? item.id : item.parentId;
699
+ const draggedItem = this.getComputedNav().find(i => i.id === this.draggedNavItemId);
700
+ // Don't show drop indicator if invalid drop:
701
+ // 1. Can't drop an item into itself (newParentId === draggedNavItemId)
702
+ // 2. Groups can only exist at the top level, so prevent dropping a group into another group (newParentId !== null means it would be nested)
703
+ if (newParentId === this.draggedNavItemId || (draggedItem?.type === 'group' && newParentId !== null)) {
704
+ this.navItemDropTargetId = null;
705
+ this.clearNavItemDragExpandTimeout();
706
+ return;
707
+ }
708
+ // Valid drop target found - store the target item and position (above/below/center)
709
+ // so the UI can render the drop indicator in the correct location
710
+ this.navItemDropTargetId = item.id;
711
+ this.navItemDropPosition = position;
712
+ foundTarget = true;
713
+ });
714
+ // Mouse is not over any valid nav item - clear the drop indicator
715
+ // (e.g., user dragged outside the nav area or over empty space)
716
+ if (!foundTarget) {
717
+ this.navItemDropTargetId = null;
718
+ this.clearNavItemDragExpandTimeout();
719
+ }
720
+ this.requestUpdate();
721
+ }
722
+ /**
723
+ * Cancels the pending auto-expand timeout for nav groups during drag.
724
+ * Called when the user moves away from a group's center or stops dragging.
725
+ */
726
+ clearNavItemDragExpandTimeout() {
727
+ if (this.navItemDragExpandTimeout) {
728
+ clearTimeout(this.navItemDragExpandTimeout);
729
+ this.navItemDragExpandTimeout = null;
730
+ }
731
+ }
732
+ /**
733
+ * Auto-expands a collapsed nav group after a delay while dragging over it.
734
+ *
735
+ * Uses a 500ms timeout to prevent groups from expanding too eagerly when the
736
+ * user is just passing over them. Only expands if the user is still hovering
737
+ * over the group's center when the timeout fires.
738
+ *
739
+ * @param itemId - The ID of the group to expand
740
+ */
741
+ expandNavGroupOnDrag(itemId) {
742
+ this.navItemDragExpandTimeout = window.setTimeout(() => {
743
+ if (this.navItemDropTargetId === itemId && this.navItemDropPosition === 'center') {
744
+ this.navItemsExpanded.add(itemId);
745
+ this.requestUpdate();
746
+ }
747
+ this.navItemDragExpandTimeout = null;
748
+ }, 500);
749
+ }
750
+ /**
751
+ * Executes the drop operation after a nav item drag completes.
752
+ *
753
+ * Calculates the new position and parent for the dragged item based on
754
+ * the drop target and position (above/below/center). Updates the navDelta
755
+ * with new order values for the dragged item and shifts siblings as needed.
756
+ */
757
+ executeNavItemDrop() {
758
+ if (!this.draggedNavItemId || !this.navItemDropTargetId)
759
+ return;
760
+ const allItems = this.getComputedNav();
761
+ const draggedItem = allItems.find(i => i.id === this.draggedNavItemId);
762
+ const targetItem = allItems.find(i => i.id === this.navItemDropTargetId);
763
+ if (!draggedItem || !targetItem)
764
+ return;
765
+ // Determine the new parentId for the dragged item
766
+ let newParentId;
767
+ if (this.navItemDropPosition === 'center' && targetItem.type === 'group') {
768
+ if (targetItem.id === this.draggedNavItemId) {
769
+ return;
770
+ }
771
+ newParentId = targetItem.id;
772
+ }
773
+ else {
774
+ newParentId = targetItem.parentId;
775
+ }
776
+ if (newParentId === this.draggedNavItemId)
777
+ return;
778
+ // Get siblings in the destination parent
779
+ const siblings = allItems
780
+ .filter(i => i.parentId === newParentId && i.id !== this.draggedNavItemId)
781
+ .sort((a, b) => a.order - b.order);
782
+ const navDelta = this.pref.nav;
783
+ // Calculate the new order
784
+ let newOrder;
785
+ if (this.navItemDropPosition === 'center') {
786
+ // Dropping into a group - place at the end of its children
787
+ if (siblings.length > 0) {
788
+ newOrder = Math.max(...siblings.map(s => s.order)) + 1;
789
+ }
790
+ else {
791
+ newOrder = 0;
792
+ }
793
+ }
794
+ else {
795
+ // Find where the target item is in the sorted siblings list
796
+ const targetIndex = siblings.findIndex(i => i.id === targetItem.id);
797
+ // Determine insert position based on drop position (above/below target)
798
+ if (targetIndex === -1) {
799
+ newOrder = 0;
800
+ }
801
+ else if (this.navItemDropPosition === 'above') {
802
+ newOrder = targetIndex;
803
+ }
804
+ else {
805
+ newOrder = targetIndex + 1;
806
+ }
807
+ // Shift existing siblings to make room for the dropped item.
808
+ // Items at or after the insert position get their order bumped up by 1.
809
+ siblings.forEach((sibling, index) => {
810
+ if (!navDelta[sibling.id]) {
811
+ navDelta[sibling.id] = {};
812
+ }
813
+ if (index >= newOrder) {
814
+ navDelta[sibling.id].order = index + 1;
815
+ }
816
+ else {
817
+ navDelta[sibling.id].order = index;
818
+ }
819
+ });
820
+ }
821
+ // Update the dragged item's position and parent in the delta
822
+ if (!navDelta[this.draggedNavItemId]) {
823
+ navDelta[this.draggedNavItemId] = {};
824
+ }
825
+ navDelta[this.draggedNavItemId].order = newOrder;
826
+ navDelta[this.draggedNavItemId].parentId = newParentId;
827
+ // Persist changes and trigger re-render
828
+ this.pref.nav = navDelta;
829
+ this.requestUpdate();
830
+ }
831
+ // =========================================================================
832
+ // User Menu
833
+ // =========================================================================
834
+ toggleUserMenu() {
835
+ this.isUserMenuOpen = !this.isUserMenuOpen;
836
+ }
837
+ closeUserMenu() {
838
+ this.isUserMenuOpen = false;
839
+ }
840
+ handleCustomize() {
841
+ this.closeUserMenu();
842
+ this.startEditing();
843
+ }
844
+ // =========================================================================
845
+ // Rendering
846
+ // =========================================================================
847
+ renderNormalFooter() {
848
+ if (!this.user)
849
+ return nothing;
850
+ const initials = this.user.name
851
+ .split(' ')
852
+ .map((part) => part[0])
853
+ .join('')
854
+ .toUpperCase()
855
+ .slice(0, 2);
856
+ // todo - use a reusable menu component
857
+ return html `
858
+ <div class="user-menu-container">
859
+ ${this.isUserMenuOpen ? html `
860
+ <div class="user-menu">
861
+ <button class="user-menu__item" @click=${this.handleCustomize}>
862
+ Customize Navigation
863
+ </button>
864
+ </div>
865
+ ` : nothing}
866
+ <div class="user" @click=${this.toggleUserMenu}>
867
+ <div class="user__avatar">
868
+ ${this.user.avatar
869
+ ? html `<img src=${this.user.avatar} alt=${this.user.name} />`
870
+ : initials}
871
+ </div>
872
+ <div class="user__info">
873
+ <div class="user__name">${this.user.name}</div>
874
+ ${this.user.email ? html `<div class="user__email">${this.user.email}</div>` : nothing}
875
+ </div>
876
+ </div>
877
+ </div>
878
+ `;
879
+ }
880
+ // todo - review
881
+ renderEditFooter() {
882
+ return html `
883
+ <div class="edit-actions">
884
+ <button class="edit-actions__btn edit-actions__btn--primary" @click=${this.savePref}>
885
+ Save
886
+ </button>
887
+ <div class="edit-actions__secondary">
888
+ <button class="edit-actions__btn" @click=${this.resetPref}>
889
+ Reset
890
+ </button>
891
+ <button class="edit-actions__btn" @click=${this.cancelEditing}>
892
+ Cancel
893
+ </button>
894
+ </div>
895
+ </div>
896
+ `;
897
+ }
898
+ /**
899
+ * Renders a navigation item as either a group or a link.
900
+ *
901
+ * Groups render as expandable `<button>` elements with a chevron indicator and
902
+ * nested children. Items render as `<a>` anchor links with href navigation.
903
+ *
904
+ * Both types support drag-and-drop reordering in edit mode, context menus for
905
+ * customization, and visual indicators for active/hidden/drop-target states.
906
+ *
907
+ * Icons use SVG strings and fall back to `defaultNavItemIcon` when not provided.
908
+ * Icons are only rendered for top-level items, not for children nested within groups.
909
+ *
910
+ * @param item - The flat nav item data to render
911
+ * @param isChild - Whether this item is nested within a group (affects icon rendering)
912
+ * @returns Lit HTML template for the nav item
913
+ */
914
+ renderNavItem(item, isChild = false) {
915
+ if (item.type === 'section') {
916
+ return html `
917
+ <div class="nav-section">${item.label}</div>
918
+ `;
919
+ }
920
+ if (item.type === 'group') {
921
+ const children = this.getNavItemChildren(item.id);
922
+ return html `
923
+ <button
924
+ class=${classMap({
925
+ 'nav-item': true,
926
+ 'nav-item--expanded': this.navItemsExpanded.has(item.id),
927
+ 'nav-item--hidden': this.isEditing && item.active === false,
928
+ 'nav-item--drop-above': this.navItemDropTargetId === item.id && this.navItemDropPosition === 'above',
929
+ 'nav-item--drop-below': this.navItemDropTargetId === item.id && this.navItemDropPosition === 'below',
930
+ 'nav-item--drop-center': this.navItemDropTargetId === item.id && this.navItemDropPosition === 'center',
931
+ })}
932
+ data-id="${item.id}"
933
+ @mousedown=${(e) => this.handleNavItemMouseDown(e, item)}
934
+ @click=${(e) => this.handleNavItemClick(e, item)}
935
+ @contextmenu=${(e) => this.handleNavItemContextMenu(e, item)}
936
+ >
937
+ ${this.navIconsDisplayed ? html `<span class="nav-item__icon">${unsafeHTML(item.icon || this.defaultNavItemIcon)}</span>` : nothing}
938
+ <span class="nav-item__label">${item.label}</span>
939
+ <svg
940
+ class="nav-item__chevron"
941
+ viewBox="0 0 24 24"
942
+ fill="none"
943
+ stroke="currentColor"
944
+ stroke-width="2"
945
+ >
946
+ <path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round" />
947
+ </svg>
948
+ </button>
949
+ <div class=${classMap({
950
+ 'nav-group': true,
951
+ 'nav-group--expanded': this.navItemsExpanded.has(item.id),
952
+ })}>
953
+ ${children.map((child) => this.renderNavItem(child, true))}
954
+ </div>
955
+ `;
956
+ }
957
+ return html `
958
+ <a
959
+ class=${classMap({
960
+ 'nav-item': true,
961
+ 'nav-item--active': this.activeNavItemId === item.id,
962
+ 'nav-item--hidden': this.isEditing && item.active === false,
963
+ 'nav-item--drop-above': this.navItemDropTargetId === item.id && this.navItemDropPosition === 'above',
964
+ 'nav-item--drop-below': this.navItemDropTargetId === item.id && this.navItemDropPosition === 'below',
965
+ })}
966
+ data-id="${item.id}"
967
+ href=${item.url || '#'}
968
+ @mousedown=${(e) => this.handleNavItemMouseDown(e, item)}
969
+ @click=${(e) => this.handleNavItemClick(e, item)}
970
+ @contextmenu=${(e) => this.handleNavItemContextMenu(e, item)}
971
+ >
972
+ ${this.navIconsDisplayed && !isChild ? html `<span class="nav-item__icon">${unsafeHTML(item.icon || this.defaultNavItemIcon)}</span>` : nothing}
973
+ <span class="nav-item__label">${item.label}</span>
974
+ </a>
975
+ `;
976
+ }
977
+ // todo - don't hardcode breadcrumbs
978
+ render() {
979
+ const topLevelItems = this.getNavItemChildren(null);
980
+ // Use unfiltered count to determine if search should show (so it doesn't disappear while typing)
981
+ const totalTopLevelItems = this.getComputedNav().filter(item => item.parentId === null).length;
982
+ return html `
983
+ <div class=${classMap({
984
+ 'progress': true,
985
+ 'progress--loading': this.pendingRequests > 0,
986
+ })}>
987
+ <div class="progress__track"></div>
988
+ <div class="progress__bar progress__bar--primary">
989
+ <span class="progress__bar-inner"></span>
990
+ </div>
991
+ <div class="progress__bar progress__bar--secondary">
992
+ <span class="progress__bar-inner"></span>
993
+ </div>
994
+ </div>
995
+
996
+ ${this.subbar ? html `
997
+ <kr-subbar menu .breadcrumbs=${this.breadcrumbs} @menu-click=${this.handleMenuClick}>
998
+ <slot name="subbar"></slot>
999
+ </kr-subbar>
1000
+ ` : nothing}
1001
+
1002
+ <div class="wrapper">
1003
+ <nav class=${classMap({
1004
+ 'nav': true,
1005
+ 'nav--scrolled': this.isNavScrolled,
1006
+ 'nav--opened': !this.subbar || this.isNavOpened,
1007
+ })}>
1008
+ <div class="nav-header">
1009
+ ${this.title
1010
+ ? html `<span class="nav-title">${this.title}</span>`
1011
+ : this.logo
1012
+ ? html `<img class="nav-logo" src=${this.logo} alt="Logo" />`
1013
+ : nothing}
1014
+ </div>
1015
+ <div class="nav-content" @scroll=${this.handleNavScroll}>
1016
+ ${totalTopLevelItems > 20 ? html `
1017
+ <div class="nav-search">
1018
+ <div class="nav-search__wrapper">
1019
+ <span class="nav-search__icon">
1020
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
1021
+ <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
1022
+ </svg>
1023
+ </span>
1024
+ <input
1025
+ class="nav-search__input"
1026
+ type="text"
1027
+ placeholder="Search..."
1028
+ .value=${this.navQuery}
1029
+ @input=${this.handleNavQueryChange}
1030
+ />
1031
+ ${this.navQuery ? html `
1032
+ <button class="nav-search__clear" @click=${this.handleNavQueryClear}>
1033
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
1034
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
1035
+ </svg>
1036
+ </button>
1037
+ ` : nothing}
1038
+ </div>
1039
+ </div>
1040
+ ` : nothing}
1041
+ <div class="nav-items">
1042
+ ${topLevelItems.map((item) => this.renderNavItem(item))}
1043
+ </div>
1044
+ </div>
1045
+ <!-- <div class="nav-footer">
1046
+ ${this.isEditing ? this.renderEditFooter() : this.renderNormalFooter()}
1047
+ </div> -->
1048
+ </nav>
1049
+
1050
+ <main class="main">
1051
+ <slot></slot>
1052
+ </main>
1053
+ </div>
1054
+
1055
+ `;
1056
+ }
1057
+ };
1058
+ KRScaffold.styles = css `
1059
+ :host {
1060
+ display: flex;
1061
+ flex-direction: column;
1062
+ width: 100%;
1063
+ height: 100%;
1064
+ font-size: 14px;
1065
+ --kr-scaffold-nav-width: 210px;
1066
+
1067
+ /* Default to light scheme */
1068
+ --kr-scaffold-nav-bg: #ffffff;
1069
+ --kr-scaffold-nav-text: #000000;
1070
+ --kr-scaffold-nav-text-active: #000000;
1071
+ --kr-scaffold-nav-hover: #f3f4f6;
1072
+ --kr-scaffold-nav-active-bg: #e5e7eb;
1073
+ --kr-scaffold-nav-border: rgb(229, 229, 228);
1074
+ --kr-scaffold-nav-divider: #e5e7eb;
1075
+ --kr-scaffold-nav-search-bg: #f3f4f6;
1076
+ --kr-scaffold-nav-search-focus-bg: #ffffff;
1077
+ --kr-scaffold-nav-search-focus-border: #d1d5db;
1078
+ }
1079
+
1080
+ /* Dark scheme */
1081
+ :host([scheme="dark"]) {
1082
+ --kr-scaffold-nav-bg: #10172a;
1083
+ --kr-scaffold-nav-text: rgb(255 255 255 / 80%);
1084
+ --kr-scaffold-nav-text-active: #ffffff;
1085
+ --kr-scaffold-nav-hover: rgba(255, 255, 255, 0.05);
1086
+ --kr-scaffold-nav-active-bg: rgb(255 255 255 / 12%);
1087
+ --kr-scaffold-nav-border: transparent;
1088
+ --kr-scaffold-nav-divider: rgba(255, 255, 255, 0.06);
1089
+ --kr-scaffold-nav-search-bg: rgba(255, 255, 255, 0.15);
1090
+ --kr-scaffold-nav-search-focus-bg: rgba(255, 255, 255, 0.2);
1091
+ --kr-scaffold-nav-search-focus-border: rgba(255, 255, 255, 0.3);
1092
+ }
1093
+
1094
+ *,
1095
+ *::before,
1096
+ *::after {
1097
+ box-sizing: border-box;
1098
+ }
1099
+
1100
+ /* Nav */
1101
+ .nav {
1102
+ width: var(--kr-scaffold-nav-width);
1103
+ background: var(--kr-scaffold-nav-bg);
1104
+ color: var(--kr-scaffold-nav-text);
1105
+ display: flex;
1106
+ flex-direction: column;
1107
+ flex-shrink: 0;
1108
+ overflow: hidden;
1109
+ border-right: 1px solid var(--kr-scaffold-nav-border);
1110
+ transition: margin-left 0.3s ease;
1111
+ margin-left: calc(-1 * var(--kr-scaffold-nav-width));
1112
+ }
1113
+
1114
+ .nav--opened {
1115
+ margin-left: 0;
1116
+ }
1117
+
1118
+ .wrapper {
1119
+ display: flex;
1120
+ flex: 1;
1121
+ min-height: 0;
1122
+ }
1123
+
1124
+ .nav-header {
1125
+ height: 64px;
1126
+ padding: 0 20px;
1127
+ display: flex;
1128
+ align-items: center;
1129
+ position: relative;
1130
+ z-index: 1;
1131
+ transition: box-shadow 0.2s ease;
1132
+ }
1133
+
1134
+ .nav--scrolled .nav-header {
1135
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
1136
+ }
1137
+
1138
+ .nav-logo {
1139
+ max-width: 100%;
1140
+ height: 32px;
1141
+ }
1142
+
1143
+ .nav-title {
1144
+ font-size: 18px;
1145
+ font-weight: 600;
1146
+ color: var(--kr-scaffold-nav-text-active);
1147
+ letter-spacing: .2px;
1148
+ white-space: nowrap;
1149
+ overflow: hidden;
1150
+ text-overflow: ellipsis;
1151
+ }
1152
+
1153
+ .nav-search {
1154
+ padding: 4px 0 8px;
1155
+ }
1156
+
1157
+ .nav-search__wrapper {
1158
+ display: flex;
1159
+ align-items: center;
1160
+ background: var(--kr-scaffold-nav-search-bg);
1161
+ border-radius: 8px;
1162
+ padding: 0 12px;
1163
+ height: 40px;
1164
+ gap: 8px;
1165
+ border: 1px solid transparent;
1166
+ transition: all 0.15s ease;
1167
+ }
1168
+
1169
+ .nav-search__wrapper:focus-within {
1170
+ background: var(--kr-scaffold-nav-search-focus-bg);
1171
+ border-color: var(--kr-scaffold-nav-search-focus-border);
1172
+ }
1173
+
1174
+ .nav-search__icon {
1175
+ width: 18px;
1176
+ height: 18px;
1177
+ flex-shrink: 0;
1178
+ opacity: 0.6;
1179
+ }
1180
+
1181
+ .nav-search__icon svg {
1182
+ width: 100%;
1183
+ height: 100%;
1184
+ fill: currentColor;
1185
+ }
1186
+
1187
+ .nav-search__input {
1188
+ flex: 1;
1189
+ background: none;
1190
+ border: none;
1191
+ outline: none;
1192
+ color: var(--kr-scaffold-nav-text-active);
1193
+ font-size: 13px;
1194
+ font-family: inherit;
1195
+ min-width: 0;
1196
+ }
1197
+
1198
+ .nav-search__input::placeholder {
1199
+ color: var(--kr-scaffold-nav-text);
1200
+ opacity: 0.7;
1201
+ }
1202
+
1203
+ .nav-search__clear {
1204
+ width: 18px;
1205
+ height: 18px;
1206
+ padding: 0;
1207
+ background: none;
1208
+ border: none;
1209
+ color: var(--kr-scaffold-nav-text);
1210
+ cursor: pointer;
1211
+ display: flex;
1212
+ align-items: center;
1213
+ justify-content: center;
1214
+ opacity: 0.6;
1215
+ transition: opacity 0.15s ease;
1216
+ }
1217
+
1218
+ .nav-search__clear:hover {
1219
+ opacity: 1;
1220
+ }
1221
+
1222
+ .nav-search__clear svg {
1223
+ width: 100%;
1224
+ height: 100%;
1225
+ fill: currentColor;
1226
+ }
1227
+
1228
+ .nav-content {
1229
+ flex: 1;
1230
+ overflow-y: auto;
1231
+ padding: 0 9px 0.75rem 8px;
1232
+ }
1233
+
1234
+ .nav-footer {
1235
+ padding: 12px;
1236
+ border-top: 1px solid var(--kr-scaffold-nav-divider);
1237
+ }
1238
+
1239
+ .user {
1240
+ display: flex;
1241
+ align-items: center;
1242
+ gap: 12px;
1243
+ padding: 8px;
1244
+ border-radius: 8px;
1245
+ cursor: pointer;
1246
+ transition: background 0.15s ease;
1247
+ }
1248
+
1249
+ .user:hover {
1250
+ background: var(--kr-scaffold-nav-hover);
1251
+ }
1252
+
1253
+ .user__avatar {
1254
+ width: 36px;
1255
+ height: 36px;
1256
+ border-radius: 50%;
1257
+ background: var(--kr-scaffold-avatar-bg, #beea4e);
1258
+ color: var(--kr-scaffold-avatar-text, #10172a);
1259
+ display: flex;
1260
+ align-items: center;
1261
+ justify-content: center;
1262
+ font-size: 14px;
1263
+ font-weight: 600;
1264
+ flex-shrink: 0;
1265
+ overflow: hidden;
1266
+ }
1267
+
1268
+ .user__avatar img {
1269
+ width: 100%;
1270
+ height: 100%;
1271
+ object-fit: cover;
1272
+ }
1273
+
1274
+ .user__info {
1275
+ flex: 1;
1276
+ min-width: 0;
1277
+ }
1278
+
1279
+ .user__name {
1280
+ font-size: 13px;
1281
+ font-weight: 500;
1282
+ color: var(--kr-scaffold-nav-text-active);
1283
+ white-space: nowrap;
1284
+ overflow: hidden;
1285
+ text-overflow: ellipsis;
1286
+ }
1287
+
1288
+ .user__email {
1289
+ font-size: 12px;
1290
+ color: var(--kr-scaffold-nav-text);
1291
+ white-space: nowrap;
1292
+ overflow: hidden;
1293
+ text-overflow: ellipsis;
1294
+ }
1295
+
1296
+ .user-menu-container {
1297
+ position: relative;
1298
+ }
1299
+
1300
+ .user-menu {
1301
+ position: absolute;
1302
+ bottom: 100%;
1303
+ left: 0;
1304
+ right: 0;
1305
+ margin-bottom: 8px;
1306
+ background: var(--kr-scaffold-nav-bg);
1307
+ border: 1px solid var(--kr-scaffold-nav-divider);
1308
+ border-radius: 8px;
1309
+ padding: 4px;
1310
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1311
+ }
1312
+
1313
+ .user-menu__item {
1314
+ display: block;
1315
+ width: 100%;
1316
+ padding: 10px 12px;
1317
+ background: none;
1318
+ border: none;
1319
+ color: var(--kr-scaffold-nav-text);
1320
+ font-size: 13px;
1321
+ font-family: inherit;
1322
+ text-align: left;
1323
+ cursor: pointer;
1324
+ border-radius: 6px;
1325
+ transition: background 0.15s ease;
1326
+ }
1327
+
1328
+ .user-menu__item:hover {
1329
+ background: var(--kr-scaffold-nav-hover);
1330
+ color: var(--kr-scaffold-nav-text-active);
1331
+ }
1332
+
1333
+ .user-menu__divider {
1334
+ height: 1px;
1335
+ background: var(--kr-scaffold-nav-divider);
1336
+ margin: 4px 0;
1337
+ }
1338
+
1339
+ /* Main */
1340
+ .main {
1341
+ flex: 1;
1342
+ min-width: 0;
1343
+ overflow-y: auto;
1344
+ background: #ffffff;
1345
+ display: flex;
1346
+ flex-direction: column;
1347
+ }
1348
+
1349
+ .breadcrumbs {
1350
+ height: 32px;
1351
+ padding: 0 1rem;
1352
+ display: flex;
1353
+ align-items: center;
1354
+ gap: 6px;
1355
+ font-size: 12px;
1356
+ color: #6b7280;
1357
+ background: #f9fafb;
1358
+ border-bottom: 1px solid #e5e7eb;
1359
+ flex-shrink: 0;
1360
+ }
1361
+
1362
+ .breadcrumbs a {
1363
+ color: #6b7280;
1364
+ text-decoration: none;
1365
+ }
1366
+
1367
+ .breadcrumbs a:hover {
1368
+ color: #111827;
1369
+ text-decoration: underline;
1370
+ }
1371
+
1372
+ .breadcrumbs__separator {
1373
+ color: #d1d5db;
1374
+ }
1375
+
1376
+ .breadcrumbs__current {
1377
+ color: #111827;
1378
+ font-weight: 500;
1379
+ }
1380
+
1381
+ .main__content {
1382
+ flex: 1;
1383
+ overflow-y: auto;
1384
+ }
1385
+
1386
+ /* Nav Items */
1387
+ .nav-items {
1388
+ display: flex;
1389
+ flex-direction: column;
1390
+ gap: 2px;
1391
+ }
1392
+
1393
+ .nav-item {
1394
+ display: flex;
1395
+ align-items: center;
1396
+ gap: 16px;
1397
+ padding: 0 12px;
1398
+ height: 32px;
1399
+ border-radius: 8px;
1400
+ color: var(--kr-scaffold-nav-text);
1401
+ text-decoration: none;
1402
+ cursor: pointer;
1403
+ border: none;
1404
+ background: none;
1405
+ width: 100%;
1406
+ text-align: left;
1407
+ font-size: 13px;
1408
+ font-weight: 400;
1409
+ font-family: inherit;
1410
+ transition: all 0.15s ease;
1411
+ }
1412
+
1413
+ .nav-item:hover {
1414
+ background: #F3F7FC;
1415
+ color: var(--kr-scaffold-nav-text-active);
1416
+ }
1417
+
1418
+ .nav-item--active,
1419
+ .nav-item--active:hover {
1420
+ background: #e1e9f6;
1421
+ color: #1a2332;
1422
+ font-weight: 500;
1423
+ }
1424
+
1425
+ .nav-item__icon {
1426
+ width: 16px;
1427
+ height: 16px;
1428
+ display: flex;
1429
+ align-items: center;
1430
+ justify-content: center;
1431
+ flex-shrink: 0;
1432
+ }
1433
+
1434
+ .nav-item__icon svg {
1435
+ width: 16px;
1436
+ height: 16px;
1437
+ fill: currentColor;
1438
+ }
1439
+
1440
+ .nav-item__label {
1441
+ flex: 1;
1442
+ white-space: nowrap;
1443
+ overflow: hidden;
1444
+ text-overflow: ellipsis;
1445
+ letter-spacing: 0.01em;
1446
+ }
1447
+
1448
+ .nav-item__chevron {
1449
+ width: 16px;
1450
+ height: 16px;
1451
+ transition: transform 0.2s ease;
1452
+ transform: rotate(-90deg);
1453
+ stroke: currentColor;
1454
+ }
1455
+
1456
+ .nav-item--expanded .nav-item__chevron {
1457
+ transform: rotate(0deg);
1458
+ }
1459
+
1460
+ .nav-group {
1461
+ display: flex;
1462
+ flex-direction: column;
1463
+ overflow: hidden;
1464
+ height: 0;
1465
+ }
1466
+
1467
+ .nav-group--expanded {
1468
+ height: auto;
1469
+ }
1470
+
1471
+ .nav-group .nav-item {
1472
+ padding: 0 12px 0 44px;
1473
+ height: 32px;
1474
+ font-size: 13px;
1475
+ font-weight: 400;
1476
+ margin-top: 2px;
1477
+ flex-shrink: 0;
1478
+ }
1479
+
1480
+ .nav-group .nav-item:first-child {
1481
+ margin-top: 0;
1482
+ }
1483
+
1484
+ /* Nav items in groups when icons are hidden */
1485
+ :host(:not([nav-icons-displayed])) .nav-group .nav-item {
1486
+ padding-left: 24px;
1487
+ }
1488
+
1489
+ /* Sections */
1490
+ .nav-section {
1491
+ padding: 16px 12px 8px;
1492
+ font-size: 11px;
1493
+ font-weight: 600;
1494
+ text-transform: uppercase;
1495
+ letter-spacing: 0.05em;
1496
+ color: var(--kr-scaffold-nav-text);
1497
+ opacity: 0.6;
1498
+ }
1499
+
1500
+ .nav-section:first-child {
1501
+ padding-top: 0;
1502
+ }
1503
+
1504
+ /* Edit mode actions */
1505
+ .edit-actions {
1506
+ display: flex;
1507
+ flex-direction: column;
1508
+ gap: 8px;
1509
+ }
1510
+
1511
+ .edit-actions__btn {
1512
+ display: block;
1513
+ width: 100%;
1514
+ padding: 10px 12px;
1515
+ background: var(--kr-scaffold-nav-hover);
1516
+ border: none;
1517
+ color: var(--kr-scaffold-nav-text);
1518
+ font-size: 13px;
1519
+ font-weight: 500;
1520
+ font-family: inherit;
1521
+ cursor: pointer;
1522
+ border-radius: 6px;
1523
+ transition: background 0.15s ease;
1524
+ }
1525
+
1526
+ .edit-actions__btn:hover {
1527
+ background: var(--kr-scaffold-nav-active-bg);
1528
+ color: var(--kr-scaffold-nav-text-active);
1529
+ }
1530
+
1531
+ .edit-actions__btn--primary {
1532
+ background: #beea4e;
1533
+ color: #10172a;
1534
+ }
1535
+
1536
+ .edit-actions__btn--primary:hover {
1537
+ background: #d4f472;
1538
+ color: #10172a;
1539
+ }
1540
+
1541
+ .edit-actions__secondary {
1542
+ display: flex;
1543
+ gap: 8px;
1544
+ }
1545
+
1546
+ .edit-actions__secondary .edit-actions__btn {
1547
+ flex: 1;
1548
+ }
1549
+
1550
+ /* Hidden/inactive items in edit mode */
1551
+ .nav-item--hidden {
1552
+ opacity: 0.4;
1553
+ font-style: italic;
1554
+ }
1555
+
1556
+ .nav-item--hidden:hover {
1557
+ opacity: 0.6;
1558
+ }
1559
+
1560
+ /* Dragging state */
1561
+ :host(.kr-scaffold--dragging),
1562
+ :host(.kr-scaffold--dragging) * {
1563
+ cursor: grabbing;
1564
+ user-select: none;
1565
+ }
1566
+
1567
+ .nav-item-drag-preview {
1568
+ position: fixed;
1569
+ pointer-events: none;
1570
+ z-index: 1000;
1571
+ opacity: 0.9;
1572
+ background: var(--kr-scaffold-nav-bg);
1573
+ border: 1px solid var(--kr-scaffold-nav-divider);
1574
+ border-radius: 8px;
1575
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
1576
+ padding: 0 12px;
1577
+ height: 40px;
1578
+ min-width: 200px;
1579
+ display: flex;
1580
+ align-items: center;
1581
+ gap: 16px;
1582
+ color: var(--kr-scaffold-nav-text-active);
1583
+ font-size: 13px;
1584
+ font-weight: 500;
1585
+ }
1586
+
1587
+ /* Drop indicator */
1588
+ .nav-item--drop-above,
1589
+ .nav-item--drop-below,
1590
+ .nav-item--drop-center {
1591
+ position: relative;
1592
+ }
1593
+
1594
+ .nav-item--drop-above::before,
1595
+ .nav-item--drop-below::after {
1596
+ content: '';
1597
+ position: absolute;
1598
+ left: 0;
1599
+ right: 0;
1600
+ height: 2px;
1601
+ background: #beea4e;
1602
+ }
1603
+
1604
+ .nav-item--drop-above::before {
1605
+ top: 0;
1606
+ }
1607
+
1608
+ .nav-item--drop-below::after {
1609
+ bottom: 0;
1610
+ }
1611
+
1612
+ .nav-item--drop-center {
1613
+ background: var(--kr-scaffold-nav-active-bg);
1614
+ }
1615
+
1616
+ .breadcrumbs {
1617
+ height: 32px;
1618
+ padding: 0 1rem;
1619
+ display: flex;
1620
+ align-items: center;
1621
+ gap: 6px;
1622
+ font-size: 12px;
1623
+ color: #6b7280;
1624
+ //background: #f9fafb;
1625
+ background: #beea4e;
1626
+ border-top: 1px solid #e5e7eb;
1627
+ flex-shrink: 0;
1628
+ }
1629
+
1630
+ .breadcrumbs a {
1631
+ //color: #6b7280;
1632
+ color: #10172a;
1633
+ font-size: 13px;
1634
+ font-weight: 600;
1635
+ text-decoration: none;
1636
+ }
1637
+
1638
+ .breadcrumbs a:hover {
1639
+ color: #111827;
1640
+ text-decoration: underline;
1641
+ }
1642
+
1643
+ .breadcrumbs__separator {
1644
+ color: #d1d5db;
1645
+ }
1646
+
1647
+ .breadcrumbs__current {
1648
+ color: #111827;
1649
+ font-weight: 500;
1650
+ }
1651
+
1652
+ /* Progress bar */
1653
+ .progress {
1654
+ position: fixed;
1655
+ top: 0;
1656
+ left: 0;
1657
+ right: 0;
1658
+ height: 4px;
1659
+ overflow-x: hidden;
1660
+ z-index: 1000;
1661
+ display: none;
1662
+ }
1663
+
1664
+ .progress--loading {
1665
+ display: block;
1666
+ }
1667
+
1668
+ .progress__track {
1669
+ position: absolute;
1670
+ top: 0;
1671
+ bottom: 0;
1672
+ width: 100%;
1673
+ background: rgba(190, 234, 78, 0.3);
1674
+ }
1675
+
1676
+ .progress__bar {
1677
+ position: absolute;
1678
+ top: 0;
1679
+ bottom: 0;
1680
+ width: 100%;
1681
+ transform-origin: left center;
1682
+ }
1683
+
1684
+ .progress__bar-inner {
1685
+ display: inline-block;
1686
+ position: absolute;
1687
+ width: 100%;
1688
+ height: 100%;
1689
+ background: #beea4e;
1690
+ }
1691
+
1692
+ .progress__bar--primary {
1693
+ left: -145.166611%;
1694
+ }
1695
+
1696
+ .progress--loading .progress__bar--primary {
1697
+ animation: progress-primary-translate 2s infinite linear;
1698
+ }
1699
+
1700
+ .progress--loading .progress__bar--primary .progress__bar-inner {
1701
+ animation: progress-primary-scale 2s infinite linear;
1702
+ }
1703
+
1704
+ .progress__bar--secondary {
1705
+ left: -54.888891%;
1706
+ }
1707
+
1708
+ .progress--loading .progress__bar--secondary {
1709
+ animation: progress-secondary-translate 2s infinite linear;
1710
+ }
1711
+
1712
+ .progress--loading .progress__bar--secondary .progress__bar-inner {
1713
+ animation: progress-secondary-scale 2s infinite linear;
1714
+ }
1715
+
1716
+ @keyframes progress-primary-translate {
1717
+ 0% {
1718
+ transform: translateX(0);
1719
+ }
1720
+ 20% {
1721
+ animation-timing-function: cubic-bezier(0.5, 0, 0.701732, 0.495819);
1722
+ transform: translateX(0);
1723
+ }
1724
+ 59.15% {
1725
+ animation-timing-function: cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);
1726
+ transform: translateX(83.67142%);
1727
+ }
1728
+ 100% {
1729
+ transform: translateX(200.611057%);
1730
+ }
1731
+ }
1732
+
1733
+ @keyframes progress-primary-scale {
1734
+ 0% {
1735
+ transform: scaleX(0.08);
1736
+ }
1737
+ 36.65% {
1738
+ animation-timing-function: cubic-bezier(0.334731, 0.12482, 0.785844, 1);
1739
+ transform: scaleX(0.08);
1740
+ }
1741
+ 69.15% {
1742
+ animation-timing-function: cubic-bezier(0.06, 0.11, 0.6, 1);
1743
+ transform: scaleX(0.661479);
1744
+ }
1745
+ 100% {
1746
+ transform: scaleX(0.08);
1747
+ }
1748
+ }
1749
+
1750
+ @keyframes progress-secondary-translate {
1751
+ 0% {
1752
+ animation-timing-function: cubic-bezier(0.15, 0, 0.515058, 0.409685);
1753
+ transform: translateX(0);
1754
+ }
1755
+ 25% {
1756
+ animation-timing-function: cubic-bezier(0.31033, 0.284058, 0.8, 0.733712);
1757
+ transform: translateX(37.651913%);
1758
+ }
1759
+ 48.35% {
1760
+ animation-timing-function: cubic-bezier(0.4, 0.627035, 0.6, 0.902026);
1761
+ transform: translateX(84.386165%);
1762
+ }
1763
+ 100% {
1764
+ transform: translateX(160.277782%);
1765
+ }
1766
+ }
1767
+
1768
+ @keyframes progress-secondary-scale {
1769
+ 0% {
1770
+ animation-timing-function: cubic-bezier(0.205028, 0.057051, 0.57661, 0.453971);
1771
+ transform: scaleX(0.08);
1772
+ }
1773
+ 19.15% {
1774
+ animation-timing-function: cubic-bezier(0.152313, 0.196432, 0.648374, 1.004315);
1775
+ transform: scaleX(0.457104);
1776
+ }
1777
+ 44.15% {
1778
+ animation-timing-function: cubic-bezier(0.257759, -0.003163, 0.211762, 1.38179);
1779
+ transform: scaleX(0.72796);
1780
+ }
1781
+ 100% {
1782
+ transform: scaleX(0.08);
1783
+ }
1784
+ }
1785
+ `;
1786
+ __decorate([
1787
+ state()
1788
+ ], KRScaffold.prototype, "navItemsExpanded", void 0);
1789
+ __decorate([
1790
+ state()
1791
+ ], KRScaffold.prototype, "navQuery", void 0);
1792
+ __decorate([
1793
+ state()
1794
+ ], KRScaffold.prototype, "activeNavItemId", void 0);
1795
+ __decorate([
1796
+ state()
1797
+ ], KRScaffold.prototype, "isNavScrolled", void 0);
1798
+ __decorate([
1799
+ state()
1800
+ ], KRScaffold.prototype, "isNavOpened", void 0);
1801
+ __decorate([
1802
+ state()
1803
+ ], KRScaffold.prototype, "isEditing", void 0);
1804
+ __decorate([
1805
+ state()
1806
+ ], KRScaffold.prototype, "isUserMenuOpen", void 0);
1807
+ __decorate([
1808
+ state()
1809
+ ], KRScaffold.prototype, "pref", void 0);
1810
+ __decorate([
1811
+ state()
1812
+ ], KRScaffold.prototype, "draggedNavItemId", void 0);
1813
+ __decorate([
1814
+ state()
1815
+ ], KRScaffold.prototype, "navItemDropTargetId", void 0);
1816
+ __decorate([
1817
+ state()
1818
+ ], KRScaffold.prototype, "navItemDropPosition", void 0);
1819
+ __decorate([
1820
+ state()
1821
+ ], KRScaffold.prototype, "pendingRequests", void 0);
1822
+ __decorate([
1823
+ property({ type: String })
1824
+ ], KRScaffold.prototype, "logo", void 0);
1825
+ __decorate([
1826
+ property({ type: String })
1827
+ ], KRScaffold.prototype, "title", void 0);
1828
+ __decorate([
1829
+ property({ type: String, reflect: true })
1830
+ ], KRScaffold.prototype, "scheme", void 0);
1831
+ __decorate([
1832
+ property({ type: Array })
1833
+ ], KRScaffold.prototype, "nav", void 0);
1834
+ __decorate([
1835
+ property({ type: Boolean, attribute: 'nav-icons-displayed', reflect: true })
1836
+ ], KRScaffold.prototype, "navIconsDisplayed", void 0);
1837
+ __decorate([
1838
+ property({ type: Boolean, attribute: 'nav-expanded' })
1839
+ ], KRScaffold.prototype, "navExpanded", void 0);
1840
+ __decorate([
1841
+ property({ type: Object })
1842
+ ], KRScaffold.prototype, "user", void 0);
1843
+ __decorate([
1844
+ property({ type: Boolean })
1845
+ ], KRScaffold.prototype, "subbar", void 0);
1846
+ __decorate([
1847
+ property({ type: Array })
1848
+ ], KRScaffold.prototype, "breadcrumbs", void 0);
1849
+ KRScaffold = __decorate([
1850
+ customElement('kr-scaffold')
1851
+ ], KRScaffold);
1852
+ export { KRScaffold };
1853
+ //# sourceMappingURL=scaffold.js.map