@classic-homes/theme-svelte 0.1.55 → 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.
Files changed (47) hide show
  1. package/dist/__tests__/nav-access.test.js +145 -0
  2. package/dist/__tests__/sidebar-expansion.test.js +59 -0
  3. package/dist/lib/components/Button.svelte +17 -1
  4. package/dist/lib/components/Checkbox.svelte +3 -2
  5. package/dist/lib/components/Combobox.svelte +4 -4
  6. package/dist/lib/components/DataPanel.svelte +11 -5
  7. package/dist/lib/components/DateTimePicker.svelte +3 -2
  8. package/dist/lib/components/FileUpload.svelte +4 -3
  9. package/dist/lib/components/HeaderSearch.svelte +5 -0
  10. package/dist/lib/components/RadioGroup.svelte +3 -2
  11. package/dist/lib/components/Select.svelte +3 -2
  12. package/dist/lib/components/Signature.svelte +3 -2
  13. package/dist/lib/components/Tabs.svelte +3 -2
  14. package/dist/lib/components/data-table/DataTableColumnFilter.svelte +2 -2
  15. package/dist/lib/components/data-table/DataTableColumnFilter.svelte.d.ts +33 -13
  16. package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +2 -2
  17. package/dist/lib/components/data-table/DataTableColumnVisibility.svelte.d.ts +27 -7
  18. package/dist/lib/components/data-table/DataTableFacetedFilter.svelte +2 -2
  19. package/dist/lib/components/data-table/DataTableFacetedFilter.svelte.d.ts +36 -17
  20. package/dist/lib/components/data-table/DataTablePagination.svelte +2 -2
  21. package/dist/lib/components/data-table/DataTablePagination.svelte.d.ts +31 -11
  22. package/dist/lib/components/data-table/DataTableSearch.svelte +2 -2
  23. package/dist/lib/components/data-table/DataTableSearch.svelte.d.ts +31 -11
  24. package/dist/lib/components/data-table/DataTableToolbar.svelte +2 -2
  25. package/dist/lib/components/data-table/DataTableToolbar.svelte.d.ts +37 -17
  26. package/dist/lib/components/layout/DashboardLayout.svelte +1 -0
  27. package/dist/lib/components/layout/Sidebar.svelte +22 -17
  28. package/dist/lib/components/layout/Sidebar.svelte.d.ts +10 -1
  29. package/dist/lib/composables/useAnnounce.svelte.js +9 -9
  30. package/dist/lib/composables/useArrowNavigation.svelte.js +5 -4
  31. package/dist/lib/composables/useEscapeKey.svelte.d.ts +4 -2
  32. package/dist/lib/composables/useEscapeKey.svelte.js +3 -2
  33. package/dist/lib/composables/useFocusRef.svelte.js +4 -3
  34. package/dist/lib/composables/useFocusTrap.svelte.d.ts +9 -3
  35. package/dist/lib/composables/useFocusTrap.svelte.js +34 -28
  36. package/dist/lib/composables/useRovingTabindex.svelte.js +4 -3
  37. package/dist/lib/schemas/auth.d.ts +18 -8
  38. package/dist/lib/schemas/auth.js +4 -1
  39. package/dist/lib/schemas/index.d.ts +1 -1
  40. package/dist/lib/stores/sidebar.svelte.d.ts +7 -2
  41. package/dist/lib/stores/sidebar.svelte.js +9 -3
  42. package/dist/lib/types/layout.d.ts +52 -4
  43. package/dist/lib/utils/nav-access.d.ts +61 -0
  44. package/dist/lib/utils/nav-access.js +74 -0
  45. package/dist/lib/utils.d.ts +0 -10
  46. package/dist/lib/utils.js +0 -13
  47. package/package.json +1 -1
@@ -243,7 +243,10 @@ export declare const emailChangeSchema: z.ZodObject<{
243
243
  }>;
244
244
  export type EmailChangeFormData = z.infer<typeof emailChangeSchema>;
245
245
  /**
246
- * User schema
246
+ * User schema — for validating a user payload from an API.
247
+ *
248
+ * Distinct from the layout `User` in `types/layout.ts`, which is a display
249
+ * view-model for the sidebar and avatar. This one describes data on the wire.
247
250
  */
248
251
  export declare const userSchema: z.ZodObject<{
249
252
  id: z.ZodString;
@@ -262,9 +265,9 @@ export declare const userSchema: z.ZodObject<{
262
265
  email: string;
263
266
  username?: string | undefined;
264
267
  roles?: string[] | undefined;
268
+ permissions?: string[] | undefined;
265
269
  fullName?: string | undefined;
266
270
  avatarUrl?: string | null | undefined;
267
- permissions?: string[] | undefined;
268
271
  mfaEnabled?: boolean | undefined;
269
272
  emailVerified?: boolean | undefined;
270
273
  createdAt?: string | undefined;
@@ -274,15 +277,22 @@ export declare const userSchema: z.ZodObject<{
274
277
  email: string;
275
278
  username?: string | undefined;
276
279
  roles?: string[] | undefined;
280
+ permissions?: string[] | undefined;
277
281
  fullName?: string | undefined;
278
282
  avatarUrl?: string | null | undefined;
279
- permissions?: string[] | undefined;
280
283
  mfaEnabled?: boolean | undefined;
281
284
  emailVerified?: boolean | undefined;
282
285
  createdAt?: string | undefined;
283
286
  updatedAt?: string | undefined;
284
287
  }>;
285
- export type User = z.infer<typeof userSchema>;
288
+ /**
289
+ * A user payload as validated by {@link userSchema}.
290
+ *
291
+ * Named `AuthUser` to keep the package's `User` unambiguous — that name belongs
292
+ * to the layout view-model. Previously exported as `User`, where it collided
293
+ * with the layout type and lost, making it unreachable from the package root.
294
+ */
295
+ export type AuthUser = z.infer<typeof userSchema>;
286
296
  /**
287
297
  * Auth tokens schema
288
298
  */
@@ -324,9 +334,9 @@ export declare const loginResponseSchema: z.ZodObject<{
324
334
  email: string;
325
335
  username?: string | undefined;
326
336
  roles?: string[] | undefined;
337
+ permissions?: string[] | undefined;
327
338
  fullName?: string | undefined;
328
339
  avatarUrl?: string | null | undefined;
329
- permissions?: string[] | undefined;
330
340
  mfaEnabled?: boolean | undefined;
331
341
  emailVerified?: boolean | undefined;
332
342
  createdAt?: string | undefined;
@@ -336,9 +346,9 @@ export declare const loginResponseSchema: z.ZodObject<{
336
346
  email: string;
337
347
  username?: string | undefined;
338
348
  roles?: string[] | undefined;
349
+ permissions?: string[] | undefined;
339
350
  fullName?: string | undefined;
340
351
  avatarUrl?: string | null | undefined;
341
- permissions?: string[] | undefined;
342
352
  mfaEnabled?: boolean | undefined;
343
353
  emailVerified?: boolean | undefined;
344
354
  createdAt?: string | undefined;
@@ -368,9 +378,9 @@ export declare const loginResponseSchema: z.ZodObject<{
368
378
  email: string;
369
379
  username?: string | undefined;
370
380
  roles?: string[] | undefined;
381
+ permissions?: string[] | undefined;
371
382
  fullName?: string | undefined;
372
383
  avatarUrl?: string | null | undefined;
373
- permissions?: string[] | undefined;
374
384
  mfaEnabled?: boolean | undefined;
375
385
  emailVerified?: boolean | undefined;
376
386
  createdAt?: string | undefined;
@@ -390,9 +400,9 @@ export declare const loginResponseSchema: z.ZodObject<{
390
400
  email: string;
391
401
  username?: string | undefined;
392
402
  roles?: string[] | undefined;
403
+ permissions?: string[] | undefined;
393
404
  fullName?: string | undefined;
394
405
  avatarUrl?: string | null | undefined;
395
- permissions?: string[] | undefined;
396
406
  mfaEnabled?: boolean | undefined;
397
407
  emailVerified?: boolean | undefined;
398
408
  createdAt?: string | undefined;
@@ -181,7 +181,10 @@ export const emailChangeSchema = z.object({
181
181
  // API Response Schemas (for validating API responses)
182
182
  // ============================================================================
183
183
  /**
184
- * User schema
184
+ * User schema — for validating a user payload from an API.
185
+ *
186
+ * Distinct from the layout `User` in `types/layout.ts`, which is a display
187
+ * view-model for the sidebar and avatar. This one describes data on the wire.
185
188
  */
186
189
  export const userSchema = z.object({
187
190
  id: z.string(),
@@ -9,7 +9,7 @@ export { emailSchema, passwordSchema, loginPasswordSchema, usernameSchema, fullN
9
9
  export { loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema, changePasswordSchema, mfaVerifySchema, mfaSetupSchema, profileUpdateSchema, emailChangeSchema, } from './auth.js';
10
10
  export type { LoginFormData, RegisterFormData, ForgotPasswordFormData, ResetPasswordFormData, ChangePasswordFormData, MfaVerifyFormData, MfaSetupFormData, ProfileUpdateFormData, EmailChangeFormData, } from './auth.js';
11
11
  export { userSchema, authTokensSchema, loginResponseSchema } from './auth.js';
12
- export type { User, AuthTokens, LoginResponse } from './auth.js';
12
+ export type { AuthUser, AuthTokens, LoginResponse } from './auth.js';
13
13
  export { nonEmptyString, trimmedString, optionalString, urlSchema, optionalUrlSchema, slugSchema, } from './common.js';
14
14
  export { positiveInt, nonNegativeInt, percentageSchema, priceSchema, numericString, } from './common.js';
15
15
  export { isoDateSchema, dateStringSchema, timeStringSchema, futureDateSchema, pastDateSchema, } from './common.js';
@@ -122,8 +122,13 @@ declare class SidebarStore {
122
122
  getPinnedIds(): string[];
123
123
  /**
124
124
  * Compute which sections should be initially expanded based on user roles.
125
- * Sections with expandForRoles that match user roles will be auto-expanded.
126
- * Falls back to section.expanded or default behavior.
125
+ *
126
+ * A section listing `expandForRoles` expands for a user holding ANY of them.
127
+ * Sections without it fall back to the default: expanded unless explicitly
128
+ * collapsed.
129
+ *
130
+ * This is a UX affordance, not access control — it decides what starts open,
131
+ * not what is visible. Filtering is `filterNavItems`.
127
132
  */
128
133
  computeInitialExpansions(sections: NavSection[], userRoles?: string[]): string[];
129
134
  /**
@@ -17,6 +17,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
17
17
  return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
18
18
  };
19
19
  var _SidebarStore_instances, _SidebarStore_initialized, _SidebarStore_loadedFromStorage, _SidebarStore_loadedExpansionFromStorage, _SidebarStore_loadedPinnedFromStorage, _SidebarStore_onPinnedChange, _SidebarStore_appId, _SidebarStore_getStorageKey, _SidebarStore_persist, _SidebarStore_persistExpansion, _SidebarStore_persistPinned;
20
+ import { matchesAccess } from '../utils/nav-access.js';
20
21
  const STORAGE_PREFIX = 'classic-theme-sidebar';
21
22
  class SidebarStore {
22
23
  constructor() {
@@ -281,15 +282,20 @@ class SidebarStore {
281
282
  // ============================================
282
283
  /**
283
284
  * Compute which sections should be initially expanded based on user roles.
284
- * Sections with expandForRoles that match user roles will be auto-expanded.
285
- * Falls back to section.expanded or default behavior.
285
+ *
286
+ * A section listing `expandForRoles` expands for a user holding ANY of them.
287
+ * Sections without it fall back to the default: expanded unless explicitly
288
+ * collapsed.
289
+ *
290
+ * This is a UX affordance, not access control — it decides what starts open,
291
+ * not what is visible. Filtering is `filterNavItems`.
286
292
  */
287
293
  computeInitialExpansions(sections, userRoles) {
288
294
  return sections
289
295
  .filter((section) => {
290
296
  // Check role-based expansion first
291
297
  if (section.expandForRoles?.length) {
292
- return section.expandForRoles.some((role) => userRoles?.includes(role));
298
+ return matchesAccess(section.expandForRoles, userRoles);
293
299
  }
294
300
  // Fall back to default behavior: expand unless explicitly collapsed
295
301
  return !section.collapsible || section.expanded !== false;
@@ -78,8 +78,19 @@ export interface NavItem {
78
78
  icon?: string;
79
79
  /** Optional badge (notification count, etc.) */
80
80
  badge?: string | number;
81
- /** Role-based visibility */
81
+ /**
82
+ * Roles that can see this item. The user needs ANY of them by default; set
83
+ * `requireAllRoles` on the Sidebar for ALL. Omit to show the item to everyone.
84
+ */
82
85
  roles?: string[];
86
+ /**
87
+ * Permissions that can see this item. The user needs ANY of them by default;
88
+ * set `requireAllPermissions` on the Sidebar for ALL.
89
+ *
90
+ * Combined with `roles` as AND: an item listing both is shown only to a user
91
+ * who satisfies each.
92
+ */
93
+ permissions?: string[];
83
94
  /** Opens in new tab */
84
95
  external?: boolean;
85
96
  /** Whether this item is currently active */
@@ -125,7 +136,27 @@ export interface SidebarPreferences {
125
136
  expandedItems: string[];
126
137
  }
127
138
  /**
128
- * User data for layout components
139
+ * User data for layout components.
140
+ *
141
+ * A display view-model, deliberately not an identity: the layout only needs
142
+ * enough to render an avatar and decide what navigation to show, so this stays
143
+ * independent of any auth package. Apps map their own user into it.
144
+ *
145
+ * With `@classic-homes/auth`, derive the display fields rather than reaching for
146
+ * a matching shape — its `User` has no `name` or `initials`:
147
+ *
148
+ * ```typescript
149
+ * import { getDisplayName, getUserInitials } from '@classic-homes/auth/core';
150
+ *
151
+ * const layoutUser = {
152
+ * id: user.id,
153
+ * name: getDisplayName(user),
154
+ * initials: getUserInitials(user),
155
+ * email: user.email,
156
+ * roles: user.roles,
157
+ * permissions: user.permissions,
158
+ * };
159
+ * ```
129
160
  */
130
161
  export interface User {
131
162
  /** Unique identifier */
@@ -136,8 +167,16 @@ export interface User {
136
167
  email?: string;
137
168
  /** Avatar URL */
138
169
  avatar?: string;
139
- /** User roles for authorization */
170
+ /**
171
+ * Every role the user holds, used to filter navigation.
172
+ *
173
+ * A user may hold several; items are matched against the whole set, so a
174
+ * manager who is also an agent sees both. Not an access control boundary —
175
+ * this only decides what is rendered.
176
+ */
140
177
  roles?: string[];
178
+ /** Every permission the user holds, used to filter navigation. */
179
+ permissions?: string[];
141
180
  /** User initials (fallback for avatar) */
142
181
  initials?: string;
143
182
  }
@@ -286,8 +325,17 @@ export interface PublicLayoutProps {
286
325
  export interface SidebarProps {
287
326
  /** Navigation sections */
288
327
  navigation: NavSection[];
289
- /** User roles for filtering navigation items */
328
+ /**
329
+ * Every role the current user holds. Items are matched against the whole set,
330
+ * so a user with several roles sees the union of what they allow.
331
+ */
290
332
  userRoles?: string[];
333
+ /** Every permission the current user holds, for permission-gated items. */
334
+ userPermissions?: string[];
335
+ /** Require ALL of an item's `roles` rather than any (default: any). */
336
+ requireAllRoles?: boolean;
337
+ /** Require ALL of an item's `permissions` rather than any (default: any). */
338
+ requireAllPermissions?: boolean;
291
339
  /** Visual variant - light (default) or dark */
292
340
  variant?: 'light' | 'dark';
293
341
  /** Whether sidebar is collapsed */
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Navigation Access Filtering
3
+ *
4
+ * Decides which navigation items a user should see, based on the roles and
5
+ * permissions they hold.
6
+ *
7
+ * A user holds a *set* of roles and a *set* of permissions, and an item is
8
+ * matched against the whole set — someone who is both a manager and an agent
9
+ * sees the union of what each allows.
10
+ *
11
+ * This decides what is **rendered**. It is not an access control boundary:
12
+ * the server enforces access, and hiding a link is a courtesy to the user, not
13
+ * a security measure.
14
+ */
15
+ import type { NavItem } from '../types/layout.js';
16
+ /**
17
+ * Who is looking, and how strictly to match.
18
+ */
19
+ export interface NavAccessContext {
20
+ /** Every role the user holds. */
21
+ userRoles?: string[];
22
+ /** Every permission the user holds. */
23
+ userPermissions?: string[];
24
+ /** Require ALL of an item's `roles` rather than any (default: any). */
25
+ requireAllRoles?: boolean;
26
+ /** Require ALL of an item's `permissions` rather than any (default: any). */
27
+ requireAllPermissions?: boolean;
28
+ }
29
+ /**
30
+ * Whether `held` satisfies `required`.
31
+ *
32
+ * An empty or absent `required` is no restriction at all, so it matches. An
33
+ * empty `held` against a real restriction never matches — a user with no roles
34
+ * cannot satisfy a role requirement.
35
+ */
36
+ export declare function matchesAccess(required: string[] | undefined, held: string[] | undefined, requireAll?: boolean): boolean;
37
+ /**
38
+ * Whether a single item should be shown.
39
+ *
40
+ * `roles` and `permissions` combine as AND: an item declaring both is shown
41
+ * only to a user who satisfies each.
42
+ */
43
+ export declare function isNavItemVisible(item: NavItem, context?: NavAccessContext): boolean;
44
+ /**
45
+ * Filter a navigation tree.
46
+ *
47
+ * Recurses into `children`: a restricted child under an unrestricted parent is
48
+ * still checked, rather than inheriting the parent's visibility.
49
+ *
50
+ * A parent left with no children is dropped, since it would render as an empty
51
+ * expandable — unless it has its own `href`, which makes it a destination in
52
+ * its own right.
53
+ *
54
+ * Does not mutate `items`.
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * filterNavItems(items, { userRoles: ['manager', 'agent'] });
59
+ * ```
60
+ */
61
+ export declare function filterNavItems(items: NavItem[], context?: NavAccessContext): NavItem[];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Navigation Access Filtering
3
+ *
4
+ * Decides which navigation items a user should see, based on the roles and
5
+ * permissions they hold.
6
+ *
7
+ * A user holds a *set* of roles and a *set* of permissions, and an item is
8
+ * matched against the whole set — someone who is both a manager and an agent
9
+ * sees the union of what each allows.
10
+ *
11
+ * This decides what is **rendered**. It is not an access control boundary:
12
+ * the server enforces access, and hiding a link is a courtesy to the user, not
13
+ * a security measure.
14
+ */
15
+ /**
16
+ * Whether `held` satisfies `required`.
17
+ *
18
+ * An empty or absent `required` is no restriction at all, so it matches. An
19
+ * empty `held` against a real restriction never matches — a user with no roles
20
+ * cannot satisfy a role requirement.
21
+ */
22
+ export function matchesAccess(required, held, requireAll = false) {
23
+ if (!required || required.length === 0)
24
+ return true;
25
+ if (!held || held.length === 0)
26
+ return false;
27
+ return requireAll
28
+ ? required.every((value) => held.includes(value))
29
+ : required.some((value) => held.includes(value));
30
+ }
31
+ /**
32
+ * Whether a single item should be shown.
33
+ *
34
+ * `roles` and `permissions` combine as AND: an item declaring both is shown
35
+ * only to a user who satisfies each.
36
+ */
37
+ export function isNavItemVisible(item, context = {}) {
38
+ return (matchesAccess(item.roles, context.userRoles, context.requireAllRoles) &&
39
+ matchesAccess(item.permissions, context.userPermissions, context.requireAllPermissions));
40
+ }
41
+ /**
42
+ * Filter a navigation tree.
43
+ *
44
+ * Recurses into `children`: a restricted child under an unrestricted parent is
45
+ * still checked, rather than inheriting the parent's visibility.
46
+ *
47
+ * A parent left with no children is dropped, since it would render as an empty
48
+ * expandable — unless it has its own `href`, which makes it a destination in
49
+ * its own right.
50
+ *
51
+ * Does not mutate `items`.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * filterNavItems(items, { userRoles: ['manager', 'agent'] });
56
+ * ```
57
+ */
58
+ export function filterNavItems(items, context = {}) {
59
+ const result = [];
60
+ for (const item of items) {
61
+ if (!isNavItemVisible(item, context))
62
+ continue;
63
+ if (!item.children) {
64
+ result.push(item);
65
+ continue;
66
+ }
67
+ const children = filterNavItems(item.children, context);
68
+ if (item.children.length > 0 && children.length === 0 && !item.href) {
69
+ continue;
70
+ }
71
+ result.push({ ...item, children });
72
+ }
73
+ return result;
74
+ }
@@ -4,13 +4,3 @@ import { type ClassValue } from 'clsx';
4
4
  * Uses clsx for conditional classes and tailwind-merge to handle conflicts
5
5
  */
6
6
  export declare function cn(...inputs: ClassValue[]): string;
7
- /**
8
- * Generate a unique ID with an optional prefix.
9
- * Uses crypto.randomUUID() to avoid SSR hydration mismatches caused by
10
- * sequential counters (where server and client counts can diverge).
11
- * The short suffix is derived from the UUID for compact, readable IDs.
12
- *
13
- * @param prefix - Optional prefix for the ID (e.g., 'checkbox', 'tabs')
14
- * @returns A unique ID string like 'checkbox-a1b2c3d4' or 'ct-a1b2c3d4'
15
- */
16
- export declare function useId(prefix?: string): string;
package/dist/lib/utils.js CHANGED
@@ -7,16 +7,3 @@ import { twMerge } from 'tailwind-merge';
7
7
  export function cn(...inputs) {
8
8
  return twMerge(clsx(inputs));
9
9
  }
10
- /**
11
- * Generate a unique ID with an optional prefix.
12
- * Uses crypto.randomUUID() to avoid SSR hydration mismatches caused by
13
- * sequential counters (where server and client counts can diverge).
14
- * The short suffix is derived from the UUID for compact, readable IDs.
15
- *
16
- * @param prefix - Optional prefix for the ID (e.g., 'checkbox', 'tabs')
17
- * @returns A unique ID string like 'checkbox-a1b2c3d4' or 'ct-a1b2c3d4'
18
- */
19
- export function useId(prefix) {
20
- const id = crypto.randomUUID().slice(0, 8);
21
- return prefix ? `${prefix}-${id}` : `ct-${id}`;
22
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "0.1.55",
3
+ "version": "1.0.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",