@svadmin/lite 0.3.15 → 0.3.16

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 (31) hide show
  1. package/README.md +52 -8
  2. package/dist/components/LiteConfirmDialog.svelte +29 -19
  3. package/dist/components/LiteConfirmDialog.svelte.d.ts +1 -5
  4. package/dist/components/LiteLayout.svelte +21 -55
  5. package/dist/components/LiteTable.svelte +18 -7
  6. package/dist/components/advanced/LiteAutoSaveIndicator.svelte +1 -1
  7. package/dist/components/advanced/LiteInlineEdit.svelte +1 -1
  8. package/dist/components/buttons/LiteDeleteButton.svelte +18 -13
  9. package/dist/components/buttons/LiteImportButton.svelte +19 -14
  10. package/dist/components/fields/LiteFileField.svelte +1 -1
  11. package/dist/components/fields/LiteImageField.svelte +2 -2
  12. package/dist/components/fields/LiteMultiSelectField.svelte +1 -1
  13. package/dist/components/fields/LiteRichTextField.svelte +1 -1
  14. package/dist/components/fields/LiteTagField.svelte +1 -1
  15. package/dist/components/layout/LiteHeader.svelte +1 -1
  16. package/dist/components/layout/LiteMenuList.svelte +44 -0
  17. package/dist/components/layout/LiteMenuList.svelte.d.ts +11 -0
  18. package/dist/components/layout/LiteSidebar.svelte +2 -45
  19. package/dist/components/pages/LiteForgotPasswordPage.svelte +1 -2
  20. package/dist/components/pages/LiteProfilePage.svelte +1 -1
  21. package/dist/components/pages/LiteRegisterPage.svelte +1 -2
  22. package/dist/components/pages/LiteShowPage.svelte +1 -1
  23. package/dist/components/pages/LiteUpdatePasswordPage.svelte +1 -2
  24. package/dist/components/widgets/LiteBarChart.svelte +1 -1
  25. package/dist/components/widgets/LiteInsightCard.svelte +1 -1
  26. package/dist/components/widgets/LitePieChart.svelte +2 -2
  27. package/dist/enhance.js +17 -8
  28. package/dist/fragment-id.d.ts +1 -0
  29. package/dist/fragment-id.js +11 -0
  30. package/dist/lite.css +81 -33
  31. package/package.json +2 -2
package/README.md CHANGED
@@ -19,14 +19,14 @@ The main `@svadmin/ui` package delivers a premium SPA experience using Svelte 5,
19
19
  | **List page** | Server-rendered `<table>` with `<a>` sort links |
20
20
  | **Detail page** | Key-value layout with type-aware formatting |
21
21
  | **Create/Edit** | Native `<form method="POST">` with server-side validation |
22
- | **Delete** | `<details>` confirmation, no JS needed |
22
+ | **Delete** | Fragment-target confirmation + native POST form, no JS needed |
23
23
  | **Search** | `<form method="GET">` with `?q=` parameter |
24
24
  | **Pagination** | Pure `<a>` page links |
25
25
  | **Authentication pages** | Login/logout plus optional provider-delegating account actions |
26
26
  | **Auth Guard** | Server hook redirects unauthenticated users |
27
27
  | **UA Detection** | Optional legacy-browser detection hook redirects users to `/lite/` routes |
28
28
  | **i18n** | Uses `@svadmin/core` `t()` translations |
29
- | **Multi-level Menu** | Config-driven 2/3 level menus via `MenuItem[]` |
29
+ | **Multi-level Menu** | Always-expanded, config-driven nested links via `MenuItem[]` |
30
30
  | **Print** | `@media print` optimized styles |
31
31
 
32
32
  ## Quick Start
@@ -54,10 +54,16 @@ export const actions = createCrudActions(dataProvider, postsResource);
54
54
  ```
55
55
 
56
56
  ```typescript
57
- // src/routes/lite/posts/+page.ts
57
+ // src/routes/lite/+layout.ts
58
+ export const ssr = true;
58
59
  export const csr = false;
59
60
  ```
60
61
 
62
+ Put these options on the `/lite` layout so every child route returns server-rendered
63
+ HTML without shipping or executing the Svelte runtime in IE11. See the runnable
64
+ [SSR example](https://github.com/zuohuadong/svadmin/tree/main/packages/lite/example)
65
+ for the complete contract and its real-response check.
66
+
61
67
  ```svelte
62
68
  <!-- src/routes/lite/posts/+page.svelte -->
63
69
  <script lang="ts">
@@ -130,7 +136,44 @@ Pass a `menu` prop to `LiteLayout` to replace the auto-generated flat resource l
130
136
 
131
137
  `meta.hidden` is honored recursively. For `meta.resource` / `meta.action`, pass a
132
138
  server-computed synchronous `canAccess(resource, action)` callback; permission checks
133
- that fail or throw are hidden. API and server actions must still enforce authorization.
139
+ that fail or throw are hidden. Nested groups stay expanded so their links remain usable
140
+ without JavaScript or native disclosure-element support. API and server actions must
141
+ still enforce authorization.
142
+
143
+ ### 2c. Request-scoped providers and tenants
144
+
145
+ Lite's server utilities consume the same `DataProvider` contract as Core, but they do
146
+ not use client hooks. Build tenant-aware providers inside each SvelteKit request so SSR
147
+ requests never share tenant state through a module-level mutable variable:
148
+
149
+ ```typescript
150
+ // src/routes/lite/posts/+page.server.ts
151
+ import { withTenantDataProvider } from '@svadmin/core';
152
+ import { createListLoader, createCrudActions } from '@svadmin/lite';
153
+ import { dataProvider, postsResource } from '$lib/admin';
154
+ import type { Actions, PageServerLoad } from './$types';
155
+
156
+ function scopedProvider(tenantId: string) {
157
+ return withTenantDataProvider(dataProvider, { tenantId });
158
+ }
159
+
160
+ export const load = ((event) =>
161
+ createListLoader(scopedProvider(event.locals.tenantId), postsResource)(event)
162
+ ) satisfies PageServerLoad;
163
+
164
+ function scopedActions(tenantId: string) {
165
+ return createCrudActions(scopedProvider(tenantId), postsResource);
166
+ }
167
+
168
+ export const actions = {
169
+ create: (event) => scopedActions(event.locals.tenantId).create(event),
170
+ update: (event) => scopedActions(event.locals.tenantId).update(event),
171
+ delete: (event) => scopedActions(event.locals.tenantId).delete(event),
172
+ } satisfies Actions;
173
+ ```
174
+
175
+ Authorization and tenant isolation must still be enforced by the provider/backend; UI
176
+ visibility is not an authorization boundary.
134
177
 
135
178
  ### 3. Optionally route legacy browsers to Lite pages
136
179
 
@@ -180,7 +223,7 @@ export const handle = createLegacyRedirectHook('/lite');
180
223
  ## CSS
181
224
 
182
225
  Import `@svadmin/lite/lite.css` in your layout. It's fully self-contained:
183
- - IE11-oriented CSS baseline (standard flexbox, no CSS variables)
226
+ - IE11-oriented CSS baseline (standard flexbox, no CSS variables, Grid, or `gap`)
184
227
  - Custom-styled checkboxes, radios, and selects (no `appearance: none` needed)
185
228
  - Indigo/Slate color system aligned with `@svadmin/ui`
186
229
  - Modern focus rings (`box-shadow` based)
@@ -194,7 +237,7 @@ This CSS baseline helps older browsers, but the generated Svelte/SvelteKit appli
194
237
  ## Optional: Progressive Enhancement
195
238
 
196
239
  Copy the exported `@svadmin/lite/enhance.js` asset to your application's static folder. This ES5 script adds:
197
- - Auto-close delete confirmation when clicking outside
240
+ - Auto-close fragment-target confirmations when clicking outside
198
241
  - Auto-focus first form input
199
242
  - Unsaved changes warning
200
243
 
@@ -215,8 +258,9 @@ The asset is optional for core server-rendered navigation and form submission; t
215
258
  - Buttons such as import/export/chat provide SSR UI and route contracts only. Consumers
216
259
  must implement the corresponding loader or form action for their backend.
217
260
  - IE11 can consume server-rendered HTML and the optional ES5 enhancement layer, with
218
- graceful CSS/native-element fallbacks. Svelte 5 hydration/runtime execution in IE11
219
- is not supported or promised by this package.
261
+ native links/forms and the IE11-safe Lite CSS contract. Keep the entire Lite route
262
+ subtree on `ssr = true` and `csr = false`; Svelte 5 hydration/runtime execution in
263
+ IE11 is not supported or promised by this package.
220
264
 
221
265
  ## License
222
266
 
@@ -1,11 +1,8 @@
1
1
  <script lang="ts">
2
- /**
3
- * LiteConfirmDialog — SSR-compatible confirmation dialog.
4
- * Utilizes pure HTML <details> and <summary> to show a popup.
5
- * No client-side JS required.
6
- */
2
+ import { liteFragmentId } from '../fragment-id';
7
3
 
8
4
  interface Props {
5
+ confirmationId?: string;
9
6
  title?: string;
10
7
  description?: string;
11
8
  confirmLabel?: string;
@@ -21,6 +18,7 @@
21
18
  }
22
19
 
23
20
  let {
21
+ confirmationId,
24
22
  title = 'Are you sure?',
25
23
  description = 'This action cannot be undone.',
26
24
  confirmLabel = 'Confirm',
@@ -31,30 +29,42 @@
31
29
  hiddenInputs = {},
32
30
  align = 'right'
33
31
  }: Props = $props();
32
+
33
+ const componentId = $props.id();
34
+ const fragmentId = $derived(liteFragmentId(
35
+ 'confirm',
36
+ confirmationId ?? componentId,
37
+ ));
38
+ const titleId = $derived(`${fragmentId}-title`);
39
+ const descriptionId = $derived(`${fragmentId}-description`);
34
40
  </script>
35
41
 
36
- <details class="lite-confirm-details">
37
- <summary class={triggerClass}>{triggerLabel}</summary>
38
- <div class="lite-confirm-panel" style="text-align:left; {align === 'right' ? 'right:0;' : 'left:0;'}">
39
- <div style="font-weight:600;margin-bottom:8px;color:#0f172a;">{title}</div>
42
+ <div class="lite-confirm">
43
+ <span id={`${fragmentId}-closed`} class="lite-confirm-cancel-target" aria-hidden="true"></span>
44
+ <a href={`#${fragmentId}`} class={triggerClass} aria-controls={fragmentId} aria-haspopup="dialog">{triggerLabel}</a>
45
+ <div
46
+ id={fragmentId}
47
+ class="lite-confirm-panel lite-confirm-target"
48
+ style="text-align:left; {align === 'right' ? 'right:0;' : 'left:0;'}"
49
+ role="dialog"
50
+ aria-labelledby={titleId}
51
+ aria-describedby={description ? descriptionId : undefined}
52
+ tabindex="-1"
53
+ >
54
+ <div id={titleId} style="font-weight:600;margin-bottom:8px;color:#0f172a;">{title}</div>
40
55
  {#if description}
41
- <div style="font-size:12px;color:#64748b;margin-bottom:16px;">{description}</div>
56
+ <div id={descriptionId} style="font-size:12px;color:#64748b;margin-bottom:16px;">{description}</div>
42
57
  {/if}
43
- <form method="POST" {action} style="display:flex;margin:0;justify-content:flex-end;">
58
+ <form method="POST" {action} class="lite-inline-actions lite-justify-end">
44
59
  {#each Object.entries(hiddenInputs) as [key, val] (key)}
45
60
  <input type="hidden" name={key} value={val} />
46
61
  {/each}
47
- <button
48
- type="button"
49
- class="lite-btn lite-btn-sm"
50
- style="margin-right:8px;"
51
- onclick={(e) => (e.currentTarget as HTMLElement).closest('details')?.removeAttribute('open')}
52
- >
62
+ <a href={`#${fragmentId}-closed`} class="lite-btn lite-btn-sm">
53
63
  {cancelLabel}
54
- </button>
64
+ </a>
55
65
  <button type="submit" class="lite-btn lite-btn-primary lite-btn-sm lite-btn-danger">
56
66
  {confirmLabel}
57
67
  </button>
58
68
  </form>
59
69
  </div>
60
- </details>
70
+ </div>
@@ -1,9 +1,5 @@
1
- /**
2
- * LiteConfirmDialog — SSR-compatible confirmation dialog.
3
- * Utilizes pure HTML <details> and <summary> to show a popup.
4
- * No client-side JS required.
5
- */
6
1
  interface Props {
2
+ confirmationId?: string;
7
3
  title?: string;
8
4
  description?: string;
9
5
  confirmLabel?: string;
@@ -6,6 +6,7 @@
6
6
  import type { ResourceDefinition, MenuItem } from '@svadmin/core';
7
7
  import type { Snippet } from 'svelte';
8
8
  import { filterVisibleMenu, filterVisibleResources } from '../menu-visibility';
9
+ import LiteMenuList from './layout/LiteMenuList.svelte';
9
10
 
10
11
  interface Props {
11
12
  resources: ResourceDefinition[];
@@ -67,41 +68,7 @@
67
68
  <nav class="lite-sidebar">
68
69
  <div class="lite-sidebar-brand">{brandName}</div>
69
70
  {#if hasCustomMenu}
70
- {#each visibleMenu as item, _i (_i)}
71
- {#if item.children && item.children.length > 0}
72
- <details class="lite-menu-group">
73
- <summary class="lite-menu-parent">{item.label ?? item.name}</summary>
74
- {#each item.children as child, _i (_i)}
75
- {#if child.children && child.children.length > 0}
76
- <details class="lite-menu-group" style="margin-left:12px">
77
- <summary class="lite-menu-parent">{child.label ?? child.name}</summary>
78
- {#each child.children as grandchild, _i (_i)}
79
- <a
80
- href={grandchild.href ?? `${basePath}/${grandchild.name}`}
81
- class={isActive(grandchild.href ?? `${basePath}/${grandchild.name}`) ? 'active' : ''}
82
- target={grandchild.target === '_blank' ? '_blank' : undefined}
83
- style="padding-left:40px"
84
- >{grandchild.label ?? grandchild.name}</a>
85
- {/each}
86
- </details>
87
- {:else}
88
- <a
89
- href={child.href ?? `${basePath}/${child.name}`}
90
- class={isActive(child.href ?? `${basePath}/${child.name}`) ? 'active' : ''}
91
- target={child.target === '_blank' ? '_blank' : undefined}
92
- style="padding-left:28px"
93
- >{child.label ?? child.name}</a>
94
- {/if}
95
- {/each}
96
- </details>
97
- {:else}
98
- <a
99
- href={item.href ?? `${basePath}/${item.name}`}
100
- class={isActive(item.href ?? `${basePath}/${item.name}`) ? 'active' : ''}
101
- target={item.target === '_blank' ? '_blank' : undefined}
102
- >{item.label ?? item.name}</a>
103
- {/if}
104
- {/each}
71
+ <LiteMenuList items={visibleMenu} {basePath} {currentResource} />
105
72
  {:else}
106
73
  {#each menuResources as res, _i (_i)}
107
74
  <a
@@ -123,26 +90,25 @@
123
90
  </nav>
124
91
 
125
92
  <nav class="lite-mobile-nav" aria-label="Mobile navigation">
126
- <details>
127
- <summary>{brandName}</summary>
128
- <div class="lite-mobile-nav-links">
129
- {#each mobileItems as item (item.name)}
130
- <a
131
- href={item.href ?? `${basePath}/${item.name}`}
132
- class={isActive(item.href ?? `${basePath}/${item.name}`) ? 'active' : ''}
133
- target={item.target === '_blank' ? '_blank' : undefined}
134
- >{item.label ?? item.name}</a>
135
- {/each}
136
- {#if userName}
137
- <div class="lite-mobile-user">
138
- <span>{userName}</span>
139
- <form method="POST" action={`${basePath}/login?/logout`}>
140
- <button type="submit" class="lite-btn lite-btn-sm">Logout</button>
141
- </form>
142
- </div>
143
- {/if}
144
- </div>
145
- </details>
93
+ <div class="lite-mobile-nav-brand">{brandName}</div>
94
+ <div class="lite-mobile-nav-links">
95
+ {#each mobileItems as item (item.name)}
96
+ <a
97
+ href={item.href ?? `${basePath}/${item.name}`}
98
+ class={isActive(item.href ?? `${basePath}/${item.name}`) ? 'active' : ''}
99
+ target={item.target === '_blank' ? '_blank' : undefined}
100
+ rel={item.target === '_blank' ? 'noreferrer' : undefined}
101
+ >{item.label ?? item.name}</a>
102
+ {/each}
103
+ {#if userName}
104
+ <div class="lite-mobile-user">
105
+ <span>{userName}</span>
106
+ <form method="POST" action={`${basePath}/login?/logout`}>
107
+ <button type="submit" class="lite-btn lite-btn-sm">Logout</button>
108
+ </form>
109
+ </div>
110
+ {/if}
111
+ </div>
146
112
  </nav>
147
113
 
148
114
  <!-- Main Content -->
@@ -6,6 +6,7 @@
6
6
  import type { ResourceDefinition, FieldDefinition } from '@svadmin/core';
7
7
  import { t } from '@svadmin/core/i18n';
8
8
  import { isExplicitBooleanTrue } from '../value-normalization';
9
+ import { liteFragmentId } from '../fragment-id';
9
10
 
10
11
  interface Props {
11
12
  records: Record<string, unknown>[];
@@ -32,6 +33,8 @@
32
33
  canDelete,
33
34
  }: Props = $props();
34
35
 
36
+ const tableId = $props.id();
37
+
35
38
  let pk = $derived(resource.primaryKey ?? 'id');
36
39
  const showView = $derived(canShow ?? resource.canShow !== false);
37
40
  const showEdit = $derived(canEdit ?? resource.canEdit !== false);
@@ -116,17 +119,25 @@
116
119
  <a href={`${basePath}/${resource.name}/edit/${id}`} class="lite-btn lite-btn-sm">{t('common.edit') || 'Edit'}</a>
117
120
  {/if}
118
121
  {#if showDelete}
119
- <!-- Delete uses <details> for no-JS confirmation -->
120
- <details class="lite-confirm-details">
121
- <summary class="lite-btn lite-btn-sm lite-btn-danger">{t('common.delete') || 'Delete'}</summary>
122
- <div class="lite-confirm-panel">
123
- <p style="margin:0 0 8px;font-size:13px;">{t('common.areYouSure') || 'Are you sure?'}</p>
124
- <form method="POST" action="?/delete" style="display:inline;">
122
+ {@const confirmationId = liteFragmentId('delete', tableId, resource.name, String(id))}
123
+ {@const confirmationTitleId = `${confirmationId}-title`}
124
+ <div class="lite-confirm">
125
+ <span id={`${confirmationId}-closed`} class="lite-confirm-cancel-target" aria-hidden="true"></span>
126
+ <a
127
+ href={`#${confirmationId}`}
128
+ class="lite-btn lite-btn-sm lite-btn-danger"
129
+ aria-controls={confirmationId}
130
+ aria-haspopup="dialog"
131
+ >{t('common.delete') || 'Delete'}</a>
132
+ <div id={confirmationId} class="lite-confirm-panel lite-confirm-target" role="dialog" aria-labelledby={confirmationTitleId} tabindex="-1">
133
+ <p id={confirmationTitleId} style="margin:0 0 8px;font-size:13px;">{t('common.areYouSure') || 'Are you sure?'}</p>
134
+ <form method="POST" action="?/delete" class="lite-inline-actions">
125
135
  <input type="hidden" name="id" value={String(id)} />
136
+ <a href={`#${confirmationId}-closed`} class="lite-btn lite-btn-sm">{t('common.cancel') || 'Cancel'}</a>
126
137
  <button type="submit" class="lite-btn lite-btn-sm lite-btn-danger">{t('common.confirm') || 'Confirm'}</button>
127
138
  </form>
128
139
  </div>
129
- </details>
140
+ </div>
130
141
  {/if}
131
142
  </td>
132
143
  {/if}
@@ -20,7 +20,7 @@
20
20
  const cfg = $derived(statusConfig[status] ?? statusConfig.idle);
21
21
  </script>
22
22
 
23
- <span style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: {cfg.color};">
23
+ <span class="lite-inline-sm" style="display: inline-flex; align-items: center; font-size: 13px; color: {cfg.color};">
24
24
  <span>{cfg.icon}</span>
25
25
  <span>{message ?? cfg.text}</span>
26
26
  </span>
@@ -20,7 +20,7 @@
20
20
  );
21
21
  </script>
22
22
 
23
- <span style="display: inline-flex; align-items: center; gap: 6px;">
23
+ <span class="lite-inline-sm" style="display: inline-flex; align-items: center;">
24
24
  <span>{displayValue}</span>
25
25
  {#if editUrl}
26
26
  <a
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { t } from '@svadmin/core/i18n';
3
3
  import { Trash2 } from '@lucide/svelte';
4
+ import { liteFragmentId } from '../../fragment-id';
4
5
 
5
6
  interface Props {
6
7
  resource: string;
@@ -21,37 +22,41 @@
21
22
  size = 'default',
22
23
  redirectUrl = ''
23
24
  }: Props = $props();
25
+
26
+ const componentId = $props.id();
27
+ const confirmationId = $derived(liteFragmentId('delete', componentId, resource, recordItemId));
28
+ const confirmationTitleId = $derived(`${confirmationId}-title`);
24
29
  </script>
25
30
 
26
- <details class="lite-confirm-details {className}">
27
- <summary
31
+ <div class="lite-confirm {className}">
32
+ <span id={`${confirmationId}-closed`} class="lite-confirm-cancel-target" aria-hidden="true"></span>
33
+ <a
34
+ href={`#${confirmationId}`}
28
35
  class="lite-btn lite-btn-danger {size === 'sm' ? 'lite-btn-sm' : ''}"
29
36
  title={t('common.delete') || 'Delete'}
37
+ aria-controls={confirmationId}
38
+ aria-haspopup="dialog"
30
39
  >
31
40
  <Trash2 size={16} />
32
41
  {#if !hideText}
33
42
  <span style="margin-left: 4px">{t('common.delete') || 'Delete'}</span>
34
43
  {/if}
35
- </summary>
36
- <div class="lite-confirm-panel" style="right: 0; left: auto;">
37
- <p style="margin: 0 0 8px; font-size: 13px; color: #111827;">
44
+ </a>
45
+ <div id={confirmationId} class="lite-confirm-panel lite-confirm-target" style="right: 0; left: auto;" role="dialog" aria-labelledby={confirmationTitleId} tabindex="-1">
46
+ <p id={confirmationTitleId} style="margin: 0 0 8px; font-size: 13px; color: #111827;">
38
47
  {t('common.areYouSure') || 'Are you sure?'}
39
48
  </p>
40
- <form method="POST" action={`${basePath}/${resource}?/delete`} style="display:inline-flex; gap: 8px;">
49
+ <form method="POST" action={`${basePath}/${resource}?/delete`} class="lite-inline-actions">
41
50
  <input type="hidden" name="id" value={String(recordItemId)} />
42
51
  {#if redirectUrl}
43
52
  <input type="hidden" name="redirect" value={redirectUrl} />
44
53
  {/if}
45
- <button
46
- type="button"
47
- class="lite-btn lite-btn-sm"
48
- onclick={(e) => (e.currentTarget as HTMLElement).closest('details')?.removeAttribute('open')}
49
- >
54
+ <a href={`#${confirmationId}-closed`} class="lite-btn lite-btn-sm">
50
55
  {t('common.cancel') || 'Cancel'}
51
- </button>
56
+ </a>
52
57
  <button type="submit" class="lite-btn lite-btn-sm lite-btn-danger">
53
58
  {t('common.confirm') || 'Confirm'}
54
59
  </button>
55
60
  </form>
56
61
  </div>
57
- </details>
62
+ </div>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { t } from '@svadmin/core/i18n';
3
3
  import { Upload } from '@lucide/svelte';
4
+ import { liteFragmentId } from '../../fragment-id';
4
5
 
5
6
  interface Props {
6
7
  resource: string;
@@ -17,34 +18,38 @@
17
18
  class: className = '',
18
19
  size = 'default'
19
20
  }: Props = $props();
21
+
22
+ const componentId = $props.id();
23
+ const importPanelId = $derived(liteFragmentId('import', componentId, resource));
24
+ const importPanelTitleId = $derived(`${importPanelId}-title`);
20
25
  </script>
21
26
 
22
- <details class="lite-confirm-details {className}">
23
- <summary
27
+ <div class="lite-confirm {className}">
28
+ <span id={`${importPanelId}-closed`} class="lite-confirm-cancel-target" aria-hidden="true"></span>
29
+ <a
30
+ href={`#${importPanelId}`}
24
31
  class="lite-btn {size === 'sm' ? 'lite-btn-sm' : ''}"
25
32
  title={t('common.import') || 'Import'}
33
+ aria-controls={importPanelId}
34
+ aria-haspopup="dialog"
26
35
  >
27
36
  <Upload size={16} />
28
37
  {#if !hideText}
29
38
  <span style="margin-left: 4px">{t('common.import') || 'Import'}</span>
30
39
  {/if}
31
- </summary>
32
- <div class="lite-confirm-panel">
33
- <p style="margin: 0 0 8px; font-size: 13px;">{t('common.importData') || 'Import data (CSV/JSON)'}</p>
34
- <form method="POST" action={`${basePath}/${resource}?/${resource}_import`} enctype="multipart/form-data" style="display:flex; flex-direction: column; gap: 8px;">
40
+ </a>
41
+ <div id={importPanelId} class="lite-confirm-panel lite-confirm-target" role="dialog" aria-labelledby={importPanelTitleId} tabindex="-1">
42
+ <p id={importPanelTitleId} style="margin: 0 0 8px; font-size: 13px;">{t('common.importData') || 'Import data (CSV/JSON)'}</p>
43
+ <form method="POST" action={`${basePath}/${resource}?/${resource}_import`} enctype="multipart/form-data" class="lite-stack-sm">
35
44
  <input type="file" name="file" accept=".csv,.json" required style="font-size: 13px;" />
36
- <div style="display:flex; gap: 8px; justify-content: flex-end;">
37
- <button
38
- type="button"
39
- class="lite-btn lite-btn-sm"
40
- onclick={(e) => (e.currentTarget as HTMLElement).closest('details')?.removeAttribute('open')}
41
- >
45
+ <div class="lite-inline-actions lite-justify-end">
46
+ <a href={`#${importPanelId}-closed`} class="lite-btn lite-btn-sm">
42
47
  {t('common.cancel') || 'Cancel'}
43
- </button>
48
+ </a>
44
49
  <button type="submit" class="lite-btn lite-btn-sm lite-btn-primary">
45
50
  {t('common.upload') || 'Upload'}
46
51
  </button>
47
52
  </div>
48
53
  </form>
49
54
  </div>
50
- </details>
55
+ </div>
@@ -22,7 +22,7 @@
22
22
 
23
23
  {#if mode === 'show'}
24
24
  {@const files = getFiles(value)}
25
- <div style="display:flex; flex-direction: column; gap: 4px;">
25
+ <div class="lite-stack-xs">
26
26
  {#each files as f, _i (_i)}
27
27
  {@const href = toSafeHref(f)}
28
28
  {#if href}
@@ -21,7 +21,7 @@
21
21
 
22
22
  {#if mode === 'show'}
23
23
  {@const urls = getUrls(value)}
24
- <div style="display:flex; gap: 8px; flex-wrap: wrap;">
24
+ <div class="lite-inline-md lite-flex-wrap">
25
25
  {#each urls as url, _i (_i)}
26
26
  <img src={url} alt={field.label} style="max-height: 100px; border-radius: 4px; border: 1px solid #e5e7eb;" />
27
27
  {:else}
@@ -32,7 +32,7 @@
32
32
  {@const urls = getUrls(value)}
33
33
  <div>
34
34
  {#if urls.length > 0}
35
- <div style="margin-bottom: 8px; display:flex; gap: 8px;">
35
+ <div class="lite-inline-md lite-flex-wrap" style="margin-bottom: 8px;">
36
36
  {#each urls as url, _i (_i)}
37
37
  <img src={url} alt="Current" style="height: 60px; border-radius: 4px; border: 1px solid #e5e7eb; opacity: 0.6;" />
38
38
  {/each}
@@ -23,7 +23,7 @@
23
23
  </script>
24
24
 
25
25
  {#if mode === 'show'}
26
- <div style="display:flex; gap: 4px; flex-wrap: wrap;">
26
+ <div class="lite-inline-xs lite-flex-wrap">
27
27
  {#each displayOptions(value) as opt, _i (_i)}
28
28
  <span class="lite-badge">{opt}</span>
29
29
  {:else}
@@ -21,7 +21,7 @@
21
21
  </div>
22
22
  {:else}
23
23
  <div class="lite-markdown-editor">
24
- <div style="padding: 8px 12px; border: 1px solid #cbd5e1; border-bottom: none; border-radius: 6px 6px 0 0; background: #f8fafc; color: #64748b; font-size: 12px; display: flex; gap: 12px; font-weight: 500;">
24
+ <div class="lite-inline-lg lite-flex-wrap" style="padding: 8px 12px; border: 1px solid #cbd5e1; border-bottom: none; border-radius: 6px 6px 0 0; background: #f8fafc; color: #64748b; font-size: 12px; font-weight: 500;">
25
25
  <span>Markdown is supported</span>
26
26
  <span style="color: #94a3b8;">**bold**</span>
27
27
  <span style="color: #94a3b8; font-style: italic;">*italic*</span>
@@ -14,7 +14,7 @@
14
14
  </script>
15
15
 
16
16
  {#if mode === 'show'}
17
- <div style="display:flex; gap: 4px; flex-wrap: wrap;">
17
+ <div class="lite-inline-xs lite-flex-wrap">
18
18
  {#each tags as tag, _i (_i)}
19
19
  <span class="lite-badge">{tag}</span>
20
20
  {:else}
@@ -11,7 +11,7 @@
11
11
 
12
12
  <header class="lite-header" style="padding: 16px 24px; background: white; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center;">
13
13
  <h2 style="margin: 0; font-size: 18px; font-weight: 600; color: #0f172a;">{title}</h2>
14
- <div style="display: flex; gap: 12px; align-items: center;">
14
+ <div class="lite-inline-lg" style="align-items: center;">
15
15
  {#if children}
16
16
  {@render children()}
17
17
  {/if}
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import type { MenuItem } from '@svadmin/core';
3
+ import LiteMenuList from './LiteMenuList.svelte';
4
+
5
+ interface Props {
6
+ items: MenuItem[];
7
+ basePath: string;
8
+ currentResource: string;
9
+ depth?: number;
10
+ }
11
+
12
+ let { items, basePath, currentResource, depth = 0 }: Props = $props();
13
+
14
+ function itemHref(menuItem: MenuItem): string {
15
+ return menuItem.href ?? `${basePath}/${menuItem.name}`;
16
+ }
17
+
18
+ function isActive(menuItem: MenuItem): boolean {
19
+ return currentResource === itemHref(menuItem).replace(basePath + '/', '');
20
+ }
21
+ </script>
22
+
23
+ <ul class="lite-menu-list lite-menu-depth-{depth}">
24
+ {#each items as menuItem (menuItem.name)}
25
+ <li>
26
+ {#if menuItem.children && menuItem.children.length > 0}
27
+ <span class="lite-menu-parent">{menuItem.label ?? menuItem.name}</span>
28
+ <LiteMenuList
29
+ items={menuItem.children}
30
+ {basePath}
31
+ {currentResource}
32
+ depth={depth + 1}
33
+ />
34
+ {:else}
35
+ <a
36
+ href={itemHref(menuItem)}
37
+ class={isActive(menuItem) ? 'active' : ''}
38
+ target={menuItem.target === '_blank' ? '_blank' : undefined}
39
+ rel={menuItem.target === '_blank' ? 'noreferrer' : undefined}
40
+ >{menuItem.label ?? menuItem.name}</a>
41
+ {/if}
42
+ </li>
43
+ {/each}
44
+ </ul>
@@ -0,0 +1,11 @@
1
+ import type { MenuItem } from '@svadmin/core';
2
+ import LiteMenuList from './LiteMenuList.svelte';
3
+ interface Props {
4
+ items: MenuItem[];
5
+ basePath: string;
6
+ currentResource: string;
7
+ depth?: number;
8
+ }
9
+ declare const LiteMenuList: import("svelte").Component<Props, {}, "">;
10
+ type LiteMenuList = ReturnType<typeof LiteMenuList>;
11
+ export default LiteMenuList;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { ResourceDefinition, MenuItem } from '@svadmin/core';
3
3
  import { filterVisibleMenu, filterVisibleResources } from '../../menu-visibility';
4
+ import LiteMenuList from './LiteMenuList.svelte';
4
5
 
5
6
  interface Props {
6
7
  resources: ResourceDefinition[];
@@ -26,56 +27,12 @@
26
27
 
27
28
  const hasCustomMenu = $derived(menu !== undefined);
28
29
  const visibleMenu = $derived(menu ? filterVisibleMenu(menu, canAccess) : []);
29
-
30
- function isActive(href: string | undefined): boolean {
31
- if (!href) return false;
32
- return currentResource === href.replace(basePath + '/', '');
33
- }
34
-
35
- function hasActiveChild(children?: MenuItem[]): boolean {
36
- if (!children) return false;
37
- return children.some(c => isActive(c.href ?? `${basePath}/${c.name}`));
38
- }
39
30
  </script>
40
31
 
41
32
  <nav class="lite-sidebar">
42
33
  <div class="lite-sidebar-brand">{brandName}</div>
43
34
  {#if hasCustomMenu}
44
- {#each visibleMenu as item, _i (_i)}
45
- {#if item.children && item.children.length > 0}
46
- <details class="lite-menu-group" open={hasActiveChild(item.children)}>
47
- <summary class="lite-menu-parent">{item.label ?? item.name}</summary>
48
- {#each item.children as child, _i (_i)}
49
- {#if child.children && child.children.length > 0}
50
- <details class="lite-menu-group" style="margin-left:12px" open={hasActiveChild(child.children)}>
51
- <summary class="lite-menu-parent">{child.label ?? child.name}</summary>
52
- {#each child.children as grandchild, _i (_i)}
53
- <a
54
- href={grandchild.href ?? `${basePath}/${grandchild.name}`}
55
- class={isActive(grandchild.href ?? `${basePath}/${grandchild.name}`) ? 'active' : ''}
56
- target={grandchild.target === '_blank' ? '_blank' : undefined}
57
- style="padding-left:40px"
58
- >{grandchild.label ?? grandchild.name}</a>
59
- {/each}
60
- </details>
61
- {:else}
62
- <a
63
- href={child.href ?? `${basePath}/${child.name}`}
64
- class={isActive(child.href ?? `${basePath}/${child.name}`) ? 'active' : ''}
65
- target={child.target === '_blank' ? '_blank' : undefined}
66
- style="padding-left:28px"
67
- >{child.label ?? child.name}</a>
68
- {/if}
69
- {/each}
70
- </details>
71
- {:else}
72
- <a
73
- href={item.href ?? `${basePath}/${item.name}`}
74
- class={isActive(item.href ?? `${basePath}/${item.name}`) ? 'active' : ''}
75
- target={item.target === '_blank' ? '_blank' : undefined}
76
- >{item.label ?? item.name}</a>
77
- {/if}
78
- {/each}
35
+ <LiteMenuList items={visibleMenu} {basePath} {currentResource} />
79
36
  {:else}
80
37
  {#each menuResources as res, _i (_i)}
81
38
  <a
@@ -25,8 +25,7 @@
25
25
  <p style="font-size: 14px; color: #64748b; margin: 0;">{description}</p>
26
26
  </div>
27
27
 
28
- <!-- Native form action -->
29
- <form method="POST" {action} style="display: flex; flex-direction: column; gap: 16px;">
28
+ <form method="POST" {action} class="lite-stack-lg">
30
29
  <div class="lite-form-group">
31
30
  <label for="email">Email</label>
32
31
  <input type="email" id="email" name="email" class="lite-input" required />
@@ -22,7 +22,7 @@
22
22
  </div>
23
23
 
24
24
  <div class="lite-card" style="max-width: 600px; margin: 0 auto; padding: 24px;">
25
- <form method="POST" {action} style="display: flex; flex-direction: column; gap: 16px;">
25
+ <form method="POST" {action} class="lite-stack-lg">
26
26
  <div class="lite-form-group">
27
27
  <label for="name">Name</label>
28
28
  <input type="text" id="name" name="name" class="lite-input" value={user?.name ?? ''} />
@@ -24,8 +24,7 @@
24
24
  <p style="font-size: 14px; color: #64748b; margin: 0;">{description}</p>
25
25
  </div>
26
26
 
27
- <!-- We use native form action -->
28
- <form method="POST" {action} style="display: flex; flex-direction: column; gap: 16px;">
27
+ <form method="POST" {action} class="lite-stack-lg">
29
28
  <div class="lite-form-group">
30
29
  <label for="email">Email</label>
31
30
  <input type="email" id="email" name="email" class="lite-input" required />
@@ -47,7 +47,7 @@
47
47
  </div>
48
48
 
49
49
  <div class="lite-card" style="padding: 24px;">
50
- <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 24px;">
50
+ <div class="lite-stack-lg">
51
51
  {#each showFields as field, _i (_i)}
52
52
  <div>
53
53
  <div style="display: block; font-size: 13px; font-weight: 500; color: #64748b; margin-bottom: 8px;">
@@ -21,8 +21,7 @@
21
21
  <p style="font-size: 14px; color: #64748b; margin: 0;">{description}</p>
22
22
  </div>
23
23
 
24
- <!-- Native form action -->
25
- <form method="POST" {action} style="display: flex; flex-direction: column; gap: 16px;">
24
+ <form method="POST" {action} class="lite-stack-lg">
26
25
  <div class="lite-form-group">
27
26
  <label for="password">New Password</label>
28
27
  <input type="password" id="password" name="password" class="lite-input" required minlength="6" />
@@ -26,7 +26,7 @@
26
26
  {title}
27
27
  </div>
28
28
  {/if}
29
- <div style="padding: 16px; display: flex; flex-direction: column; gap: 12px;">
29
+ <div class="lite-stack-md" style="padding: 16px;">
30
30
  {#each data as point, _i (_i)}
31
31
  <div>
32
32
  <div style="display: flex; justify-content: space-between; margin-bottom: 4px; font-size: 13px;">
@@ -17,7 +17,7 @@
17
17
  </script>
18
18
 
19
19
  <div class="lite-card" style="margin-bottom: 16px;">
20
- <div style="padding: 12px 16px; border-bottom: 1px solid #e2e8f0; font-weight: 600; font-size: 14px; color: #0f172a; display: flex; align-items: center; gap: 8px;">
20
+ <div class="lite-inline-md" style="padding: 12px 16px; border-bottom: 1px solid #e2e8f0; font-weight: 600; font-size: 14px; color: #0f172a; align-items: center;">
21
21
  <span style="color: #f59e0b;">✦</span>
22
22
  {title}
23
23
  </div>
@@ -27,10 +27,10 @@
27
27
  {title}
28
28
  </div>
29
29
  {/if}
30
- <div style="padding: 16px; display: flex; flex-direction: column; gap: 10px;">
30
+ <div class="lite-stack-md" style="padding: 16px;">
31
31
  {#each data as point, i (i)}
32
32
  {@const pct = total > 0 ? ((point.value / total) * 100).toFixed(1) : '0.0'}
33
- <div style="display: flex; align-items: center; gap: 10px; font-size: 13px;">
33
+ <div class="lite-inline-lg" style="align-items: center; font-size: 13px;">
34
34
  <span style="width: 12px; height: 12px; border-radius: 50%; background: {point.color ?? defaultColors[i % defaultColors.length]}; flex-shrink: 0;"></span>
35
35
  <span style="flex: 1; color: #334155;">{point.label}</span>
36
36
  <span style="color: #64748b; font-weight: 500;">{point.value}</span>
package/dist/enhance.js CHANGED
@@ -54,20 +54,29 @@
54
54
  });
55
55
  }
56
56
 
57
+ function hasClass(element, className) {
58
+ return (' ' + element.className + ' ').indexOf(' ' + className + ' ') !== -1;
59
+ }
60
+
61
+ function closeActiveConfirmation(clickedNode) {
62
+ var targetId = window.location.hash.slice(1);
63
+ if (!targetId) return;
64
+
65
+ var confirmation = document.getElementById(targetId);
66
+ if (!confirmation || !hasClass(confirmation, 'lite-confirm-target')) return;
67
+ if (clickedNode && confirmation.contains(clickedNode)) return;
68
+
69
+ var closedTarget = document.getElementById(targetId + '-closed');
70
+ if (closedTarget) window.location.hash = closedTarget.id;
71
+ }
72
+
57
73
  function init() {
58
74
  document.documentElement.className += ' lite-js';
59
75
 
60
- // 1. Delete confirmation — close <details> when clicking outside
61
76
  document.addEventListener('click', function(e) {
62
- var details = document.querySelectorAll('details.lite-confirm-details');
63
- for (var i = 0; i < details.length; i++) {
64
- if (!details[i].contains(e.target)) {
65
- details[i].removeAttribute('open');
66
- }
67
- }
77
+ closeActiveConfirmation(e.target);
68
78
  });
69
79
 
70
- // 2. Highlight current table row on hover (for older browsers without :hover)
71
80
  var rows = document.querySelectorAll('.lite-table tbody tr');
72
81
  for (var j = 0; j < rows.length; j++) {
73
82
  rows[j].addEventListener('mouseenter', function() {
@@ -0,0 +1 @@
1
+ export declare function liteFragmentId(scope: string, ...tokens: Array<string | number>): string;
@@ -0,0 +1,11 @@
1
+ function fragmentToken(rawToken) {
2
+ const encodedToken = Array.from(String(rawToken), (character) => {
3
+ if (/^[a-z0-9]$/iu.test(character))
4
+ return character;
5
+ return `_${character.codePointAt(0)?.toString(16)}_`;
6
+ }).join('');
7
+ return `${encodedToken.length}_${encodedToken}`;
8
+ }
9
+ export function liteFragmentId(scope, ...tokens) {
10
+ return `lite-${[scope, ...tokens].map(fragmentToken).join('-')}`;
11
+ }
package/dist/lite.css CHANGED
@@ -62,7 +62,7 @@ a:hover {
62
62
  border-bottom: 1px solid #e2e8f0;
63
63
  }
64
64
 
65
- .lite-mobile-nav summary {
65
+ .lite-mobile-nav-brand {
66
66
  cursor: pointer;
67
67
  padding: 14px 16px;
68
68
  font-weight: 700;
@@ -137,37 +137,34 @@ a:hover {
137
137
  background: #4f46e5;
138
138
  }
139
139
 
140
- /* ─── Multi-Level Menu (details/summary) ──────────────────── */
141
- .lite-menu-group {
140
+ /* ─── Multi-Level Menu ───────────────────────────────────── */
141
+ .lite-menu-list {
142
+ list-style: none;
142
143
  margin: 0;
143
- border: none;
144
+ padding: 0;
144
145
  }
145
- .lite-menu-group summary {
146
- list-style: none;
147
- cursor: pointer;
146
+ .lite-menu-list .lite-menu-list {
147
+ margin-left: 12px;
148
+ }
149
+ .lite-menu-parent {
150
+ display: block;
148
151
  padding: 8px 16px;
149
152
  color: #64748b;
150
153
  font-size: 11px;
151
154
  font-weight: 700;
152
155
  text-transform: uppercase;
153
156
  letter-spacing: 0.06em;
154
- transition: color 0.15s ease, background 0.15s ease;
155
- }
156
- .lite-menu-group summary::-webkit-details-marker {
157
- display: none;
158
- }
159
- .lite-menu-group summary:hover {
160
- color: #0f172a;
161
- background: #f8fafc;
162
- }
163
- .lite-menu-group[open] > summary {
164
- color: #0f172a;
165
157
  }
166
- .lite-menu-group[open] > summary::after {
158
+ .lite-menu-parent::after {
167
159
  content: " \25BE";
168
160
  }
169
- .lite-menu-group:not([open]) > summary::after {
170
- content: " \25B8";
161
+ .lite-menu-depth-1 > li > a,
162
+ .lite-menu-depth-1 > li > .lite-menu-parent {
163
+ padding-left: 28px;
164
+ }
165
+ .lite-menu-depth-2 > li > a,
166
+ .lite-menu-depth-2 > li > .lite-menu-parent {
167
+ padding-left: 40px;
171
168
  }
172
169
 
173
170
  .lite-main {
@@ -669,19 +666,9 @@ textarea.lite-input {
669
666
 
670
667
  /* ─── Delete Confirmation ──────────────────────────────────── */
671
668
  .lite-confirm {
672
- display: inline;
673
- }
674
- .lite-confirm-details {
675
669
  position: relative;
676
670
  display: inline-block;
677
671
  }
678
- .lite-confirm-details summary {
679
- cursor: pointer;
680
- list-style: none;
681
- }
682
- .lite-confirm-details summary::-webkit-details-marker {
683
- display: none;
684
- }
685
672
  .lite-confirm-panel {
686
673
  position: absolute;
687
674
  right: 0;
@@ -697,6 +684,68 @@ textarea.lite-input {
697
684
  white-space: nowrap;
698
685
  margin-top: 4px;
699
686
  }
687
+ .lite-confirm-target {
688
+ display: none;
689
+ }
690
+ .lite-confirm-target:target {
691
+ display: block;
692
+ }
693
+ .lite-confirm-cancel-target {
694
+ display: none;
695
+ }
696
+ .lite-inline-actions {
697
+ display: inline-flex;
698
+ margin: 0;
699
+ }
700
+ .lite-inline-actions > * + * {
701
+ margin-left: 8px;
702
+ }
703
+ .lite-stack-sm {
704
+ display: flex;
705
+ flex-direction: column;
706
+ }
707
+ .lite-stack-sm > * + * {
708
+ margin-top: 8px;
709
+ }
710
+ .lite-stack-xs,
711
+ .lite-stack-md,
712
+ .lite-stack-lg {
713
+ display: flex;
714
+ flex-direction: column;
715
+ }
716
+ .lite-stack-xs > * + * {
717
+ margin-top: 4px;
718
+ }
719
+ .lite-stack-md > * + * {
720
+ margin-top: 12px;
721
+ }
722
+ .lite-stack-lg > * + * {
723
+ margin-top: 16px;
724
+ }
725
+ .lite-inline-xs,
726
+ .lite-inline-sm,
727
+ .lite-inline-md,
728
+ .lite-inline-lg {
729
+ display: flex;
730
+ }
731
+ .lite-inline-xs > * + * {
732
+ margin-left: 4px;
733
+ }
734
+ .lite-inline-sm > * + * {
735
+ margin-left: 6px;
736
+ }
737
+ .lite-inline-md > * + * {
738
+ margin-left: 8px;
739
+ }
740
+ .lite-inline-lg > * + * {
741
+ margin-left: 12px;
742
+ }
743
+ .lite-flex-wrap {
744
+ flex-wrap: wrap;
745
+ }
746
+ .lite-justify-end {
747
+ justify-content: flex-end;
748
+ }
700
749
 
701
750
  /* ─── Login Page ───────────────────────────────────────────── */
702
751
  .lite-login-wrapper {
@@ -802,8 +851,7 @@ textarea.lite-input {
802
851
  .lite-search,
803
852
  .lite-pagination,
804
853
  .lite-btn,
805
- .lite-confirm,
806
- .lite-confirm-details {
854
+ .lite-confirm {
807
855
  display: none !important;
808
856
  }
809
857
  .lite-main {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/lite",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "description": "SSR-first lightweight admin UI for @svadmin with optional progressive enhancement",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -69,7 +69,7 @@
69
69
  "build": "node ../../node_modules/@sveltejs/package/svelte-package.js -i src -o dist && bun ../../scripts/copy-package-assets.ts static/enhance.js dist/enhance.js",
70
70
  "test": "bun run test:components && bun run test:server && bun run test:security",
71
71
  "test:components": "vitest run --config ./vitest.config.ts",
72
- "test:server": "bun test ./src/schema-generator.test.ts ./src/server-adapter.test.ts",
72
+ "test:server": "bun test ./src/schema-generator.test.ts ./src/server-adapter.test.ts ./src/fragment-id.test.ts ./src/ie11-ssr-contract.test.ts",
73
73
  "test:security": "vitest run --config ./src/security.test.config.ts"
74
74
  },
75
75
  "license": "MIT",