sqlite-hub 1.1.1 → 1.2.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.
Files changed (60) hide show
  1. package/README.md +17 -196
  2. package/bin/sqlite-hub.js +7 -2
  3. package/frontend/js/api.js +60 -0
  4. package/frontend/js/app.js +201 -5
  5. package/frontend/js/components/dropdownButton.js +92 -0
  6. package/frontend/js/components/modal.js +991 -876
  7. package/frontend/js/components/queryEditor.js +24 -15
  8. package/frontend/js/components/queryHistoryDetail.js +20 -1
  9. package/frontend/js/components/queryHistoryHeader.js +3 -2
  10. package/frontend/js/components/queryResults.js +1 -1
  11. package/frontend/js/components/rowEditorPanel.js +53 -13
  12. package/frontend/js/components/sidebar.js +1 -0
  13. package/frontend/js/components/topNav.js +1 -4
  14. package/frontend/js/components/workspaceOpenDropdown.js +52 -0
  15. package/frontend/js/router.js +2 -0
  16. package/frontend/js/store.js +450 -38
  17. package/frontend/js/utils/emailPreview.js +28 -0
  18. package/frontend/js/utils/markdownDocuments.js +17 -1
  19. package/frontend/js/utils/riskySql.js +165 -0
  20. package/frontend/js/views/backups.js +204 -0
  21. package/frontend/js/views/charts.js +1 -1
  22. package/frontend/js/views/data.js +42 -16
  23. package/frontend/js/views/documents.js +184 -154
  24. package/frontend/js/views/settings.js +1 -0
  25. package/frontend/js/views/structure.js +47 -33
  26. package/frontend/js/views/tableDesigner.js +23 -0
  27. package/frontend/styles/components.css +133 -0
  28. package/frontend/styles/tailwind.generated.css +1 -1
  29. package/frontend/styles/tokens.css +1 -0
  30. package/frontend/styles/views.css +33 -21
  31. package/package.json +2 -1
  32. package/server/routes/backups.js +120 -0
  33. package/server/routes/connections.js +26 -3
  34. package/server/routes/externalApi.js +6 -2
  35. package/server/server.js +3 -1
  36. package/server/services/databaseCommandService.js +4 -3
  37. package/server/services/sqlite/backupService.js +497 -66
  38. package/server/services/sqlite/connectionManager.js +2 -2
  39. package/server/services/sqlite/importService.js +25 -0
  40. package/server/services/sqlite/sqlExecutor.js +2 -0
  41. package/server/services/storage/appStateStore.js +379 -88
  42. package/tests/api-token-auth.test.js +45 -2
  43. package/tests/backup-manager.test.js +140 -0
  44. package/tests/backups-view.test.js +70 -0
  45. package/tests/charts-height-preset-storage.test.js +60 -0
  46. package/tests/charts-route-state.test.js +144 -0
  47. package/tests/cli-service-delegation.test.js +97 -0
  48. package/tests/connection-removal.test.js +52 -0
  49. package/tests/database-command-service.test.js +37 -2
  50. package/tests/documents-view.test.js +132 -0
  51. package/tests/dropdown-button.test.js +101 -0
  52. package/tests/email-preview.test.js +89 -0
  53. package/tests/markdown-documents.test.js +24 -0
  54. package/tests/query-editor.test.js +28 -0
  55. package/tests/query-history-detail.test.js +37 -0
  56. package/tests/query-history-header.test.js +30 -0
  57. package/tests/risky-sql.test.js +30 -0
  58. package/tests/row-editor-null-values.test.js +53 -0
  59. package/tests/settings-view.test.js +1 -0
  60. package/tests/structure-view.test.js +60 -0
@@ -24,6 +24,7 @@ import { renderTopNav } from './components/topNav.js';
24
24
  import { createRouter } from './router.js';
25
25
  import {
26
26
  createActiveConnectionBackup,
27
+ downloadBackup,
27
28
  createSettingsApiToken,
28
29
  chooseOpenDatabasePath,
29
30
  chooseCreateDatabasePath,
@@ -45,6 +46,7 @@ import {
45
46
  exportCurrentQueryFormat,
46
47
  getState,
47
48
  initializeApp,
49
+ insertMarkdownIntoLastOpenDocument,
48
50
  loadMoreQueryHistory,
49
51
  openModal,
50
52
  openOverviewInFinder,
@@ -53,6 +55,10 @@ import {
53
55
  openDeleteEditorRowModal,
54
56
  openDeleteQueryHistoryModal,
55
57
  openDeleteSettingsApiTokenModal,
58
+ openCreateBackupModal,
59
+ openEditBackupNotesModal,
60
+ openDeleteBackupModal,
61
+ openRestoreBackupModal,
56
62
  openDeleteQueryChartModal,
57
63
  openDataExportModal,
58
64
  openDeleteDocumentModal,
@@ -68,6 +74,7 @@ import {
68
74
  openDataRowUpdatePreview,
69
75
  openEditorRowUpdatePreview,
70
76
  refreshCurrentRoute,
77
+ refreshBackups,
71
78
  refreshMediaTaggingPreview,
72
79
  removeConnection,
73
80
  removeCurrentMediaTag,
@@ -105,6 +112,7 @@ import {
105
112
  setEditorPanelVisibility,
106
113
  setEditorTab,
107
114
  submitDeleteChartConfirmation,
115
+ submitDeleteBackupConfirmation,
108
116
  submitDeleteDocumentConfirmation,
109
117
  submitDocumentInsertNote,
110
118
  submitDocumentInsertTable,
@@ -112,6 +120,9 @@ import {
112
120
  submitCreateMediaTaggingMappingTable,
113
121
  submitDeleteQueryHistoryConfirmation,
114
122
  submitDeleteSettingsApiTokenConfirmation,
123
+ submitBackupSafetyChoice,
124
+ submitCreateBackupConfirmation,
125
+ submitEditBackupNotesConfirmation,
115
126
  submitRowUpdatePreviewConfirmation,
116
127
  setQueryHistoryPanelVisibility,
117
128
  sortDataTableByColumn,
@@ -129,6 +140,7 @@ import {
129
140
  toggleChartsResultsPanel,
130
141
  toggleChartsSqlPanel,
131
142
  toggleCurrentDocumentTodo,
143
+ toggleDocumentsPanel,
132
144
  toggleDocumentsPane,
133
145
  setMediaTaggingWorkflowMediaDetailsVisible,
134
146
  setMediaTaggingWorkflowMediaRotationDegrees,
@@ -159,6 +171,7 @@ import {
159
171
  removeCurrentTableDesignerColumn,
160
172
  } from './store.js';
161
173
  import { renderChartsDetail, renderChartsView } from './views/charts.js';
174
+ import { renderBackupsView } from './views/backups.js';
162
175
  import { renderConnectionsView } from './views/connections.js';
163
176
  import { renderDataRowEditorPanel, renderDataView } from './views/data.js';
164
177
  import { renderDocumentsView } from './views/documents.js';
@@ -227,6 +240,7 @@ let lastRenderedPanelMarkup = '';
227
240
  let lastRenderedModalMarkup = '';
228
241
  let lastRenderedToastMarkup = '';
229
242
  let lastRenderedChartsHistorySignature = '';
243
+ let lastRenderedChartsDetailSignature = '';
230
244
  let lastRenderedChartsCardSignature = '';
231
245
  let lastRenderedPanelOpen = false;
232
246
  let lastRenderedLockedRoute = false;
@@ -241,6 +255,7 @@ const DOCUMENT_AUTOSAVE_DELAY_MS = 5000;
241
255
  const APP_TITLE = 'SQLite Hub';
242
256
  const ROUTE_TITLE_SEGMENTS = {
243
257
  connections: 'Connections',
258
+ backups: 'Backups',
244
259
  overview: 'Overview',
245
260
  data: 'Data',
246
261
  structure: 'Structure',
@@ -773,6 +788,27 @@ function buildChartsCardSignature(state) {
773
788
  });
774
789
  }
775
790
 
791
+ function buildChartsDetailSignature(state) {
792
+ if (state.route.name !== 'charts') {
793
+ return '';
794
+ }
795
+
796
+ const detail = state.charts.detail;
797
+ const historyVisible = state.charts.historyPanelVisible !== false || !state.charts.selectedHistoryId;
798
+
799
+ return JSON.stringify({
800
+ selectedHistoryId: state.charts.selectedHistoryId ?? null,
801
+ historyVisible,
802
+ detailLoading: Boolean(state.charts.detailLoading),
803
+ detailError: state.charts.detailError
804
+ ? [state.charts.detailError.code ?? '', state.charts.detailError.message ?? '']
805
+ : null,
806
+ hasDetailItem: Boolean(detail?.item),
807
+ displayTitle: detail?.item?.displayTitle ?? '',
808
+ cards: buildChartsCardSignature(state),
809
+ });
810
+ }
811
+
776
812
  function syncChartsHistorySelectionUi(state) {
777
813
  const selectedHistoryId = String(state.charts.selectedHistoryId ?? '');
778
814
  const historyButtons = shellRefs.view.querySelectorAll(
@@ -1053,6 +1089,8 @@ function resolveView(state) {
1053
1089
  return renderLandingView(state);
1054
1090
  case 'connections':
1055
1091
  return renderConnectionsView(state);
1092
+ case 'backups':
1093
+ return renderBackupsView(state);
1056
1094
  case 'overview':
1057
1095
  return renderOverviewView(state);
1058
1096
  case 'charts':
@@ -1353,6 +1391,7 @@ function renderApp(state) {
1353
1391
  const modalMarkup = renderModal(state);
1354
1392
  const toastMarkup = renderToasts(state.toasts);
1355
1393
  const chartsHistorySignature = buildChartsHistorySignature(state);
1394
+ const chartsDetailSignature = buildChartsDetailSignature(state);
1356
1395
  const chartsCardSignature = buildChartsCardSignature(state);
1357
1396
  const isLockedRoute = [
1358
1397
  'editor',
@@ -1373,6 +1412,7 @@ function renderApp(state) {
1373
1412
  const panelChanged = panel !== lastRenderedPanelMarkup;
1374
1413
  const modalChanged = modalMarkup !== lastRenderedModalMarkup;
1375
1414
  const chartsHistoryChanged = chartsHistorySignature !== lastRenderedChartsHistorySignature;
1415
+ const chartsDetailChanged = chartsDetailSignature !== lastRenderedChartsDetailSignature;
1376
1416
  const chartsCardsChanged = chartsCardSignature !== lastRenderedChartsCardSignature;
1377
1417
  const panelOpenChanged = panelOpen !== lastRenderedPanelOpen;
1378
1418
  const lockedRouteChanged = isLockedRoute !== lastRenderedLockedRoute;
@@ -1421,12 +1461,17 @@ function renderApp(state) {
1421
1461
  const historyPatched = !chartsHistoryChanged || patchChartsHistoryUi(state);
1422
1462
 
1423
1463
  if (historyPatched) {
1424
- if (chartsCardsChanged) {
1425
- teardownQueryChartRenderer();
1426
- }
1464
+ if (chartsDetailChanged) {
1465
+ if (chartsCardsChanged) {
1466
+ teardownQueryChartRenderer();
1467
+ }
1427
1468
 
1428
- preservedChartsDom = !chartsCardsChanged;
1429
- mainPatched = patchChartsDetailUi(state, { preserveCharts: preservedChartsDom });
1469
+ preservedChartsDom = !chartsCardsChanged;
1470
+ mainPatched = patchChartsDetailUi(state, { preserveCharts: preservedChartsDom });
1471
+ } else {
1472
+ preservedChartsDom = true;
1473
+ mainPatched = syncChartsHistorySelectionUi(state);
1474
+ }
1430
1475
  }
1431
1476
 
1432
1477
  if (!mainPatched) {
@@ -1527,6 +1572,7 @@ function renderApp(state) {
1527
1572
  lastRenderedModalMarkup = modalMarkup;
1528
1573
  lastRenderedToastMarkup = toastMarkup;
1529
1574
  lastRenderedChartsHistorySignature = chartsHistorySignature;
1575
+ lastRenderedChartsDetailSignature = chartsDetailSignature;
1530
1576
  lastRenderedChartsCardSignature = chartsCardSignature;
1531
1577
  lastRenderedPanelOpen = panelOpen;
1532
1578
  lastRenderedLockedRoute = isLockedRoute;
@@ -1552,6 +1598,22 @@ async function executeEditorQueryAndNavigate() {
1552
1598
  router.navigate(success && activeTab === 'results' ? '/editor/results' : '/editor');
1553
1599
  }
1554
1600
 
1601
+ function quoteSqlIdentifier(identifier) {
1602
+ return `"${String(identifier ?? '').replace(/"/g, '""')}"`;
1603
+ }
1604
+
1605
+ async function openTableInSqlEditor(tableName) {
1606
+ const normalizedTableName = String(tableName ?? '').trim();
1607
+
1608
+ if (!normalizedTableName) {
1609
+ showToast('No table selected for SQL Editor.', 'alert');
1610
+ return;
1611
+ }
1612
+
1613
+ setCurrentQuery(`SELECT * FROM ${quoteSqlIdentifier(normalizedTableName)};`);
1614
+ router.navigate('/editor');
1615
+ }
1616
+
1555
1617
  function formatCurrentQuery() {
1556
1618
  const currentQuery = getState().editor.sqlText ?? '';
1557
1619
  const formattedQuery = formatSqlQuery(currentQuery);
@@ -1585,6 +1647,27 @@ function formatCurrentQuery() {
1585
1647
  showToast('SQL query formatted.', 'success');
1586
1648
  }
1587
1649
 
1650
+ async function copyCurrentQueryToClipboard() {
1651
+ const currentQuery = getState().editor.sqlText ?? '';
1652
+
1653
+ if (!currentQuery.trim()) {
1654
+ showToast('No SQL query to copy.', 'alert');
1655
+ return;
1656
+ }
1657
+
1658
+ if (!navigator.clipboard?.writeText) {
1659
+ showToast('Clipboard API is not available.', 'alert');
1660
+ return;
1661
+ }
1662
+
1663
+ try {
1664
+ await navigator.clipboard.writeText(currentQuery);
1665
+ showToast('SQL query copied.', 'success');
1666
+ } catch (error) {
1667
+ showToast('SQL query could not be copied.', 'alert');
1668
+ }
1669
+ }
1670
+
1588
1671
  const OPENABLE_URL_PATTERN = /^https?:\/\/[^\s<>"']+$/i;
1589
1672
 
1590
1673
  function getOpenableUrl(value) {
@@ -1795,6 +1878,14 @@ function closeCopyColumnMenus(exceptMenu = null) {
1795
1878
  });
1796
1879
  }
1797
1880
 
1881
+ function closeDropdownButtons(exceptDropdown = null) {
1882
+ document.querySelectorAll('[data-dropdown-button][open]').forEach(dropdown => {
1883
+ if (dropdown !== exceptDropdown && dropdown instanceof HTMLDetailsElement) {
1884
+ dropdown.open = false;
1885
+ }
1886
+ });
1887
+ }
1888
+
1798
1889
  function closeSidebarDatabasePickers(exceptPicker = null) {
1799
1890
  document.querySelectorAll('.sidebar-db-picker[open]').forEach(picker => {
1800
1891
  if (picker !== exceptPicker && picker instanceof HTMLDetailsElement) {
@@ -2059,6 +2150,39 @@ function exportRowEditorJson() {
2059
2150
  showToast(`Row from "${payload.label}" exported as JSON.`, 'success');
2060
2151
  }
2061
2152
 
2153
+ async function insertRowEditorJsonIntoDocument() {
2154
+ const payload = buildRowEditorJsonPayload(getState());
2155
+
2156
+ if (!payload) {
2157
+ showToast('No row is selected.', 'alert');
2158
+ return;
2159
+ }
2160
+
2161
+ const markdown = ['```json', payload.text, '```'].join('\n');
2162
+
2163
+ try {
2164
+ const result = await insertMarkdownIntoLastOpenDocument(markdown);
2165
+
2166
+ if (!result.documentId) {
2167
+ showToast('Open a document before inserting row JSON.', 'alert');
2168
+ return;
2169
+ }
2170
+
2171
+ if (!result.inserted) {
2172
+ showToast('Row JSON could not be inserted.', 'alert');
2173
+ return;
2174
+ }
2175
+
2176
+ if (!result.saved) {
2177
+ scheduleDocumentAutosave(result.documentId);
2178
+ }
2179
+
2180
+ showToast(`Row from "${payload.label}" inserted into document.`, 'success');
2181
+ } catch (error) {
2182
+ showToast('Open a document before inserting row JSON.', 'alert');
2183
+ }
2184
+ }
2185
+
2062
2186
  function exportCurrentDocumentMarkdown() {
2063
2187
  const documents = getState().documents;
2064
2188
 
@@ -2275,6 +2399,9 @@ async function handleAction(actionNode) {
2275
2399
  case 'export-row-editor-json':
2276
2400
  exportRowEditorJson();
2277
2401
  return;
2402
+ case 'insert-row-editor-json-into-document':
2403
+ await insertRowEditorJsonIntoDocument();
2404
+ return;
2278
2405
  case 'open-modal':
2279
2406
  openModal(actionNode.dataset.modal, {
2280
2407
  columnId: actionNode.dataset.columnId,
@@ -2446,6 +2573,36 @@ async function handleAction(actionNode) {
2446
2573
  case 'create-backup':
2447
2574
  await createActiveConnectionBackup();
2448
2575
  return;
2576
+ case 'open-create-backup-modal':
2577
+ openCreateBackupModal();
2578
+ return;
2579
+ case 'refresh-backups':
2580
+ await refreshBackups();
2581
+ return;
2582
+ case 'open-table-in-sql-editor':
2583
+ await openTableInSqlEditor(actionNode.dataset.tableName);
2584
+ return;
2585
+ case 'open-restore-backup-modal':
2586
+ openRestoreBackupModal(actionNode.dataset.backupId);
2587
+ return;
2588
+ case 'open-edit-backup-notes-modal':
2589
+ openEditBackupNotesModal(actionNode.dataset.backupId);
2590
+ return;
2591
+ case 'open-delete-backup-modal':
2592
+ openDeleteBackupModal(actionNode.dataset.backupId);
2593
+ return;
2594
+ case 'download-backup':
2595
+ await downloadBackup(actionNode.dataset.backupId);
2596
+ return;
2597
+ case 'backup-safety-create':
2598
+ await submitBackupSafetyChoice('create');
2599
+ return;
2600
+ case 'backup-safety-continue':
2601
+ await submitBackupSafetyChoice('continue');
2602
+ return;
2603
+ case 'backup-safety-cancel':
2604
+ await submitBackupSafetyChoice('cancel');
2605
+ return;
2449
2606
  case 'copy-media-tags': {
2450
2607
  const tags = getState().mediaTagging.tags ?? [];
2451
2608
  const formattedTags = tags
@@ -2476,6 +2633,9 @@ async function handleAction(actionNode) {
2476
2633
  case 'format-current-query':
2477
2634
  formatCurrentQuery();
2478
2635
  return;
2636
+ case 'copy-current-query':
2637
+ await copyCurrentQueryToClipboard();
2638
+ return;
2479
2639
  case 'delete-data-row':
2480
2640
  openDeleteDataRowModal(actionNode.dataset.rowIndex);
2481
2641
  return;
@@ -2659,6 +2819,9 @@ async function handleAction(actionNode) {
2659
2819
  case 'toggle-table-designer-tables':
2660
2820
  toggleTableDesignerTablesPanel();
2661
2821
  return;
2822
+ case 'toggle-documents-panel':
2823
+ toggleDocumentsPanel();
2824
+ return;
2662
2825
  case 'select-structure-entry':
2663
2826
  if (actionNode.dataset.entryName) {
2664
2827
  await selectStructureEntry(actionNode.dataset.entryName);
@@ -2867,6 +3030,20 @@ document.addEventListener('click', event => {
2867
3030
  });
2868
3031
  }
2869
3032
 
3033
+ const dropdownButton = target.closest('[data-dropdown-button]');
3034
+
3035
+ if (!dropdownButton) {
3036
+ closeDropdownButtons();
3037
+ } else if (target.closest('.dropdown-button__toggle')) {
3038
+ window.requestAnimationFrame(() => {
3039
+ if (dropdownButton instanceof HTMLDetailsElement && dropdownButton.open) {
3040
+ closeDropdownButtons(dropdownButton);
3041
+ }
3042
+ });
3043
+ } else if (target.closest('.dropdown-button__item')) {
3044
+ closeDropdownButtons();
3045
+ }
3046
+
2870
3047
  const sidebarDatabasePicker = target.closest('.sidebar-db-picker');
2871
3048
 
2872
3049
  if (!sidebarDatabasePicker) {
@@ -2986,6 +3163,12 @@ document.addEventListener('keydown', event => {
2986
3163
  return;
2987
3164
  }
2988
3165
 
3166
+ if (document.querySelector('[data-dropdown-button][open]')) {
3167
+ event.preventDefault();
3168
+ closeDropdownButtons();
3169
+ return;
3170
+ }
3171
+
2989
3172
  if (document.querySelector('.sidebar-db-picker[open]')) {
2990
3173
  event.preventDefault();
2991
3174
  closeSidebarDatabasePickers();
@@ -3433,6 +3616,19 @@ document.addEventListener('submit', async event => {
3433
3616
  case 'delete-row-confirm':
3434
3617
  await submitDeleteRowConfirmation();
3435
3618
  return;
3619
+ case 'create-backup':
3620
+ await submitCreateBackupConfirmation({
3621
+ name: String(formData.get('name') ?? ''),
3622
+ notes: String(formData.get('notes') ?? ''),
3623
+ type: String(formData.get('type') ?? 'manual'),
3624
+ });
3625
+ return;
3626
+ case 'edit-backup-notes':
3627
+ await submitEditBackupNotesConfirmation(String(formData.get('notes') ?? ''));
3628
+ return;
3629
+ case 'delete-backup-confirm':
3630
+ await submitDeleteBackupConfirmation();
3631
+ return;
3436
3632
  case 'save-query-chart':
3437
3633
  await saveCurrentQueryChartDraft();
3438
3634
  return;
@@ -0,0 +1,92 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ function renderDataAttributes(attributes = {}) {
4
+ return Object.entries(attributes)
5
+ .filter(([, value]) => value !== undefined && value !== null && value !== false)
6
+ .map(([key, value]) => {
7
+ const attributeName = key
8
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
9
+ .replace(/_/g, "-")
10
+ .toLowerCase();
11
+
12
+ return value === true
13
+ ? `data-${attributeName}`
14
+ : `data-${attributeName}="${escapeHtml(value)}"`;
15
+ })
16
+ .join(" ");
17
+ }
18
+
19
+ function renderDropdownItem(item = {}) {
20
+ const dataAttributes = renderDataAttributes(item.dataAttributes);
21
+ const actionAttribute = item.actionAttribute || "data-action";
22
+ const actionMarkup =
23
+ item.action === undefined || item.action === null
24
+ ? ""
25
+ : `${escapeHtml(actionAttribute)}="${escapeHtml(item.action)}"`;
26
+ const disabled = item.disabled ? 'disabled aria-disabled="true"' : "";
27
+ const iconMarkup = item.icon
28
+ ? `<span class="material-symbols-outlined">${escapeHtml(item.icon)}</span>`
29
+ : "";
30
+
31
+ return `
32
+ <button
33
+ class="dropdown-button__item ${item.danger ? "dropdown-button__item--danger" : ""}"
34
+ ${actionMarkup}
35
+ ${dataAttributes}
36
+ ${disabled}
37
+ role="menuitem"
38
+ type="button"
39
+ >
40
+ ${iconMarkup}
41
+ <span>${escapeHtml(item.label)}</span>
42
+ </button>
43
+ `;
44
+ }
45
+
46
+ export function renderDropdownButton({
47
+ align = "right",
48
+ disabled = false,
49
+ icon = "more_horiz",
50
+ items = [],
51
+ label,
52
+ title = label,
53
+ } = {}) {
54
+ const iconMarkup = icon
55
+ ? `<span class="material-symbols-outlined">${escapeHtml(icon)}</span>`
56
+ : "";
57
+ const buttonLabel = escapeHtml(label);
58
+ const chevron = '<span class="material-symbols-outlined dropdown-button__chevron">expand_more</span>';
59
+
60
+ if (disabled) {
61
+ return `
62
+ <button
63
+ aria-disabled="true"
64
+ class="standard-button dropdown-button__toggle"
65
+ disabled
66
+ title="${escapeHtml(title)}"
67
+ type="button"
68
+ >
69
+ ${iconMarkup}
70
+ ${buttonLabel}
71
+ ${chevron}
72
+ </button>
73
+ `;
74
+ }
75
+
76
+ return `
77
+ <details class="dropdown-button dropdown-button--align-${escapeHtml(align)}" data-dropdown-button>
78
+ <summary
79
+ aria-label="${escapeHtml(title)}"
80
+ class="standard-button dropdown-button__toggle"
81
+ title="${escapeHtml(title)}"
82
+ >
83
+ ${iconMarkup}
84
+ ${buttonLabel}
85
+ ${chevron}
86
+ </summary>
87
+ <div class="dropdown-button__panel" role="menu">
88
+ ${items.map((item) => renderDropdownItem(item)).join("")}
89
+ </div>
90
+ </details>
91
+ `;
92
+ }