@skewedaspect/sleekspace-ui 0.7.1 → 0.8.1

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 (84) hide show
  1. package/dist/components/Button/SkButton.vue.d.ts +8 -0
  2. package/dist/components/NavBar/SkNavBar.vue.d.ts +1 -0
  3. package/dist/components/NavBar/context.d.ts +3 -0
  4. package/dist/components/Page/SkPage.vue.d.ts +127 -30
  5. package/dist/components/Page/SkPageSidebarToggle.vue.d.ts +41 -0
  6. package/dist/components/Page/index.d.ts +1 -0
  7. package/dist/components/Page/types.d.ts +28 -5
  8. package/dist/components/ScrollArea/SkScrollArea.vue.d.ts +9 -0
  9. package/dist/components/Select/SkSelectItem.vue.d.ts +6 -18
  10. package/dist/components/Sidebar/SkSidebar.vue.d.ts +9 -1
  11. package/dist/components/Skeleton/SkSkeleton.vue.d.ts +2 -2
  12. package/dist/components/Tabs/SkTabs.vue.d.ts +1 -1
  13. package/dist/components/TreeView/SkTreeView.vue.d.ts +5 -5
  14. package/dist/composables/useFocusTrap.d.ts +17 -0
  15. package/dist/composables/useFocusTrap.test.d.ts +1 -0
  16. package/dist/composables/usePageDrawer.d.ts +35 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/sleekspace-ui.css +984 -291
  19. package/dist/sleekspace-ui.es.js +31559 -29868
  20. package/dist/sleekspace-ui.umd.js +32210 -30438
  21. package/dist/styles/mixins/fluidSize.test.d.ts +1 -0
  22. package/dist/tokens.css +60 -0
  23. package/llms-full.txt +6349 -0
  24. package/llms.txt +46 -0
  25. package/package.json +16 -11
  26. package/src/components/Button/SkButton.vue +25 -13
  27. package/src/components/NavBar/SkNavBar.vue +12 -1
  28. package/src/components/NavBar/context.ts +16 -0
  29. package/src/components/Page/SkPage.vue +460 -72
  30. package/src/components/Page/SkPageSidebarToggle.vue +148 -0
  31. package/src/components/Page/index.ts +1 -0
  32. package/src/components/Page/types.ts +30 -5
  33. package/src/components/ScrollArea/SkScrollArea.vue +12 -0
  34. package/src/components/Select/SkSelectItem.vue +2 -2
  35. package/src/components/Sidebar/SkSidebar.vue +10 -0
  36. package/src/components/TreeView/SkTreeView.vue +6 -6
  37. package/src/composables/useFocusTrap.test.ts +184 -0
  38. package/src/composables/useFocusTrap.ts +141 -0
  39. package/src/composables/usePageDrawer.ts +96 -0
  40. package/src/global.d.ts +1 -0
  41. package/src/index.ts +5 -0
  42. package/src/styles/components/_accordion.scss +15 -0
  43. package/src/styles/components/_alert.scss +1 -0
  44. package/src/styles/components/_avatar.scss +1 -0
  45. package/src/styles/components/_breadcrumbs.scss +7 -0
  46. package/src/styles/components/_button.scss +291 -214
  47. package/src/styles/components/_checkbox.scss +9 -1
  48. package/src/styles/components/_collapsible.scss +15 -0
  49. package/src/styles/components/_color-picker.scss +4 -1
  50. package/src/styles/components/_input.scss +1 -0
  51. package/src/styles/components/_listbox.scss +8 -2
  52. package/src/styles/components/_menu.scss +9 -2
  53. package/src/styles/components/_modal.scss +18 -2
  54. package/src/styles/components/_navbar.scss +22 -6
  55. package/src/styles/components/_number-input.scss +1 -0
  56. package/src/styles/components/_page.scss +220 -12
  57. package/src/styles/components/_pagination.scss +10 -1
  58. package/src/styles/components/_panel.scss +8 -3
  59. package/src/styles/components/_popover.scss +15 -2
  60. package/src/styles/components/_progress.scss +14 -0
  61. package/src/styles/components/_radio.scss +8 -1
  62. package/src/styles/components/_scroll-area.scss +56 -0
  63. package/src/styles/components/_select.scss +3 -1
  64. package/src/styles/components/_sidebar.scss +78 -38
  65. package/src/styles/components/_skeleton.scss +18 -0
  66. package/src/styles/components/_slider.scss +1 -0
  67. package/src/styles/components/_spinner.scss +15 -0
  68. package/src/styles/components/_switch.scss +5 -0
  69. package/src/styles/components/_table.scss +1 -0
  70. package/src/styles/components/_tabs.scss +6 -0
  71. package/src/styles/components/_tag.scss +2 -0
  72. package/src/styles/components/_tags-input.scss +1 -0
  73. package/src/styles/components/_textarea.scss +1 -0
  74. package/src/styles/components/_toast.scss +16 -1
  75. package/src/styles/components/_toolbar.scss +2 -0
  76. package/src/styles/components/_tooltip.scss +14 -1
  77. package/src/styles/components/_tree-view.scss +6 -1
  78. package/src/styles/mixins/_index.scss +1 -0
  79. package/src/styles/mixins/_responsive.scss +184 -0
  80. package/src/styles/mixins/fluidSize.test.ts +149 -0
  81. package/src/styles/tokens/_foundation-breakpoints.scss +26 -0
  82. package/src/styles/tokens/_foundation-z-index.scss +38 -0
  83. package/src/styles/tokens/index.scss +2 -0
  84. package/web-types.json +194 -14
@@ -1,22 +1,136 @@
1
+ <!----------------------------------------------------------------------------------------------------------------------
2
+ - Page Component
3
+ --------------------------------------------------------------------------------------------------------------------->
4
+
1
5
  <template>
2
- <div :class="classes" :style="customStyles" :data-scheme="theme">
6
+ <div ref="rootRef" :class="classes" :style="customStyles" :data-scheme="theme">
3
7
  <header v-if="$slots.header" class="sk-page-header">
4
8
  <slot name="header" />
5
9
  </header>
6
10
 
11
+ <div v-if="$slots.subheader" class="sk-page-subheader">
12
+ <slot name="subheader" />
13
+ </div>
14
+
7
15
  <div class="sk-page-main">
8
- <aside v-if="$slots.sidebar" class="sk-page-sidebar">
9
- <slot name="sidebar" />
16
+ <!-- Persistent (inline) sidebar on the left -->
17
+ <aside
18
+ v-if="hasSidebar && !isSidebarDrawerActive"
19
+ class="sk-page-sidebar"
20
+ >
21
+ <div v-if="$slots['sidebar-header']" class="sk-page-sidebar-header">
22
+ <slot name="sidebar-header" :is-drawer="false" />
23
+ </div>
24
+ <div class="sk-page-sidebar-body">
25
+ <slot name="sidebar" :is-drawer="false" />
26
+ </div>
27
+ <div v-if="$slots['sidebar-footer']" class="sk-page-sidebar-footer">
28
+ <slot name="sidebar-footer" :is-drawer="false" />
29
+ </div>
10
30
  </aside>
11
31
 
12
- <main class="sk-page-content">
13
- <slot />
14
- </main>
32
+ <div class="sk-page-center">
33
+ <div v-if="$slots['main-header']" class="sk-page-main-header">
34
+ <slot name="main-header" />
35
+ </div>
36
+ <main class="sk-page-content">
37
+ <slot />
38
+ </main>
39
+ <div v-if="$slots['main-footer']" class="sk-page-main-footer">
40
+ <slot name="main-footer" />
41
+ </div>
42
+ </div>
43
+
44
+ <!-- Persistent (inline) aside on the right -->
45
+ <aside
46
+ v-if="hasAside && !isAsideDrawerActive"
47
+ class="sk-page-aside"
48
+ >
49
+ <div v-if="$slots['aside-header']" class="sk-page-aside-header">
50
+ <slot name="aside-header" :is-drawer="false" />
51
+ </div>
52
+ <div class="sk-page-aside-body">
53
+ <slot name="aside" :is-drawer="false" />
54
+ </div>
55
+ <div v-if="$slots['aside-footer']" class="sk-page-aside-footer">
56
+ <slot name="aside-footer" :is-drawer="false" />
57
+ </div>
58
+ </aside>
15
59
  </div>
16
60
 
17
61
  <footer v-if="$slots.footer" class="sk-page-footer">
18
62
  <slot name="footer" />
19
63
  </footer>
64
+
65
+ <!-- Sidebar drawer (off-canvas, slides in from the left) -->
66
+ <template v-if="hasSidebar && isSidebarDrawerActive">
67
+ <Transition name="sk-page-drawer-backdrop">
68
+ <div
69
+ v-if="sidebarDrawerOpen"
70
+ class="sk-page-drawer-backdrop"
71
+ :data-scheme="theme"
72
+ @click="closeSidebarDrawer"
73
+ />
74
+ </Transition>
75
+
76
+ <Transition name="sk-page-drawer-left">
77
+ <aside
78
+ v-if="sidebarDrawerOpen"
79
+ ref="sidebarDrawerRef"
80
+ class="sk-page-drawer sk-page-drawer-left"
81
+ :data-scheme="theme"
82
+ role="dialog"
83
+ aria-modal="true"
84
+ tabindex="-1"
85
+ @click="onSidebarDrawerClick"
86
+ >
87
+ <div v-if="$slots['sidebar-header']" class="sk-page-sidebar-header">
88
+ <slot name="sidebar-header" :is-drawer="true" />
89
+ </div>
90
+ <div class="sk-page-sidebar-body">
91
+ <slot name="sidebar" :is-drawer="true" />
92
+ </div>
93
+ <div v-if="$slots['sidebar-footer']" class="sk-page-sidebar-footer">
94
+ <slot name="sidebar-footer" :is-drawer="true" />
95
+ </div>
96
+ </aside>
97
+ </Transition>
98
+ </template>
99
+
100
+ <!-- Aside drawer (off-canvas, slides in from the right) -->
101
+ <template v-if="hasAside && isAsideDrawerActive">
102
+ <Transition name="sk-page-drawer-backdrop">
103
+ <div
104
+ v-if="asideDrawerOpen"
105
+ class="sk-page-drawer-backdrop"
106
+ :data-scheme="theme"
107
+ @click="closeAsideDrawer"
108
+ />
109
+ </Transition>
110
+
111
+ <Transition name="sk-page-drawer-right">
112
+ <aside
113
+ v-if="asideDrawerOpen"
114
+ ref="asideDrawerRef"
115
+ class="sk-page-drawer sk-page-drawer-right"
116
+ :data-scheme="theme"
117
+ role="dialog"
118
+ aria-modal="true"
119
+ tabindex="-1"
120
+ @click="onAsideDrawerClick"
121
+ >
122
+ <div v-if="$slots['aside-header']" class="sk-page-aside-header">
123
+ <slot name="aside-header" :is-drawer="true" />
124
+ </div>
125
+ <div class="sk-page-aside-body">
126
+ <slot name="aside" :is-drawer="true" />
127
+ </div>
128
+ <div v-if="$slots['aside-footer']" class="sk-page-aside-footer">
129
+ <slot name="aside-footer" :is-drawer="true" />
130
+ </div>
131
+ </aside>
132
+ </Transition>
133
+ </template>
20
134
  </div>
21
135
  </template>
22
136
 
@@ -31,125 +145,404 @@
31
145
  <script setup lang="ts">
32
146
  /**
33
147
  * @component SkPage
34
- * @description A full-page layout component that provides a structured application shell with header, sidebar,
35
- * main content, and footer regions. Designed for building complete application pages with optional fixed
36
- * positioning for header/footer and flexible sidebar placement. The layout automatically handles scrolling
37
- * behavior and responsive spacing.
148
+ * @description Full-page layout shell with symmetric panels: a left sidebar and a right aside, each with
149
+ * its own header/body/footer slot trio, and each independently persistent, drawer-based, or responsive.
150
+ * Wraps header / subheader / main content (with optional `main-header` and `main-footer` strips) / footer.
151
+ * Sidebar slides in from the left; aside slides in from the right, with backdrop, focus trap, and
152
+ * ESC-to-close. Drop `<SkPageSidebarToggle />` into the navbar's `#leading` for the sidebar and
153
+ * `<SkPageSidebarToggle side="aside" />` into `#actions` for the aside.
38
154
  *
39
- * @example
155
+ * @example App shell with both panels
40
156
  * ```vue
41
- * <SkPage fixed-header sidebar-position="left" sidebar-width="280px">
157
+ * <SkPage fixed-header>
42
158
  * <template #header>
43
- * <AppNavbar />
44
- * </template>
45
- * <template #sidebar>
46
- * <AppMenu />
159
+ * <SkNavBar>
160
+ * <template #leading><SkPageSidebarToggle /></template>
161
+ * <template #brand>AppName</template>
162
+ * <template #actions><SkPageSidebarToggle side="aside" /></template>
163
+ * </SkNavBar>
47
164
  * </template>
165
+ * <template #sidebar><SkSidebar>...</SkSidebar></template>
166
+ * <template #aside>...</template>
48
167
  * <MainContent />
49
- * <template #footer>
50
- * <AppFooter />
51
- * </template>
52
168
  * </SkPage>
53
169
  * ```
54
170
  *
55
- * @slot default - The main content area of the page. This is where your primary page content goes. Automatically
56
- * handles scrolling when header/footer are fixed.
57
- * @slot header - The page header region, typically used for navigation bars, branding, or page titles. Spans the
58
- * full width above the sidebar and content areas.
59
- * @slot sidebar - Optional sidebar region for navigation menus, filters, or secondary content. Position is
60
- * controlled by the `sidebarPosition` prop.
61
- * @slot footer - The page footer region for copyright notices, links, or secondary navigation. Spans the full
62
- * width below the sidebar and content areas.
171
+ * @slot default - Main content. Scrolls inside the content area and receives the default content padding.
172
+ * @slot header - Top-of-page header. Typically a SkNavBar.
173
+ * @slot subheader - Full-width strip between the header and the main row. Good for breadcrumbs or sub-tabs.
174
+ * @slot sidebar - Left sidebar body. Renders inline when persistent, inside a drawer when in drawer mode.
175
+ * Slot props: `{ isDrawer: boolean }` true when this invocation is the off-canvas drawer.
176
+ * @slot sidebar-header - Pinned top of the sidebar (e.g. logo, search). Renders in the drawer too.
177
+ * Slot props: `{ isDrawer: boolean }`.
178
+ * @slot sidebar-footer - Pinned bottom of the sidebar (e.g. user menu). Renders in the drawer too.
179
+ * Slot props: `{ isDrawer: boolean }`.
180
+ * @slot main-header - Pinned top of the center column (e.g. breadcrumbs, sub-tabs). Does not scroll.
181
+ * @slot main-footer - Pinned bottom of the center column (e.g. status bar). Does not scroll.
182
+ * @slot aside - Right aside body. Persistent or drawer, mirror of the sidebar on the opposite side.
183
+ * Slot props: `{ isDrawer: boolean }` — use to add chrome only when rendering in the drawer.
184
+ * @slot aside-header - Pinned top of the aside (e.g. panel title). Renders in the drawer too.
185
+ * Slot props: `{ isDrawer: boolean }`.
186
+ * @slot aside-footer - Pinned bottom of the aside (e.g. action buttons). Renders in the drawer too.
187
+ * Slot props: `{ isDrawer: boolean }`.
188
+ * @slot footer - Bottom-of-page footer.
63
189
  */
64
190
 
65
- import { computed, provide, watch } from 'vue';
191
+ import { type Ref, computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
66
192
 
67
193
  // Types
68
194
  import type { SkThemeName } from '../Theme/types';
69
- import type { SkPageSidebarPosition } from './types';
195
+ import type { SkPagePanelMode } from './types';
70
196
 
71
197
  // Composables
72
198
  import { provideTheme } from '../Theme/useTheme';
199
+ import { useFocusTrap } from '@/composables/useFocusTrap';
200
+ import { providePageDrawers } from '@/composables/usePageDrawer';
73
201
 
74
202
  //------------------------------------------------------------------------------------------------------------------
75
203
 
76
204
  export interface SkPageComponentProps
77
205
  {
78
206
  /**
79
- * Controls which side of the page the sidebar appears on. The sidebar slot content
80
- * will be positioned on the specified side, with the main content area filling the
81
- * remaining space. Has no effect if the sidebar slot is not provided.
82
- * @default 'left'
83
- */
84
- sidebarPosition ?: SkPageSidebarPosition;
85
-
86
- /**
87
- * When true, the header remains fixed at the top of the viewport while the main
88
- * content scrolls beneath it. Useful for keeping navigation always accessible.
89
- * The content area adjusts its top padding to prevent overlap with the fixed header.
207
+ * When true, the header remains sticky at the top of the viewport while the main content scrolls.
90
208
  * @default false
91
209
  */
92
210
  fixedHeader ?: boolean;
93
211
 
94
212
  /**
95
- * When true, the footer remains fixed at the bottom of the viewport while the main
96
- * content scrolls above it. Useful for persistent action bars or important links.
97
- * The content area adjusts its bottom padding to prevent overlap with the fixed footer.
213
+ * When true, the footer remains sticky at the bottom of the viewport while the main content scrolls.
98
214
  * @default false
99
215
  */
100
216
  fixedFooter ?: boolean;
101
217
 
102
218
  /**
103
- * Custom width for the sidebar region. Accepts any valid CSS width value (px, rem, %, etc.).
104
- * When not specified, the sidebar uses the default width defined in the design tokens.
105
- * Only applies when the sidebar slot is provided.
219
+ * Custom width for the sidebar (persistent and drawer). Accepts any CSS length. When not specified,
220
+ * uses the `--sk-page-sidebar-width` token default (16rem).
106
221
  */
107
222
  sidebarWidth ?: string;
108
223
 
109
224
  /**
110
- * Optional theme name. When provided, SkPage acts as a theme provider setting
111
- * `data-scheme` on the root element and providing theme context for descendant
112
- * components (including portal components like dropdowns and modals).
113
- * When omitted, SkPage has no theme behavior.
225
+ * Custom width for the aside (persistent and drawer). Accepts any CSS length. When not specified,
226
+ * uses the `--sk-page-aside-width` token default (16rem).
227
+ */
228
+ asideWidth ?: string;
229
+
230
+ /**
231
+ * Sidebar rendering mode.
232
+ * - `auto`: persistent above `sidebarBreakpoint`, drawer below. Default.
233
+ * - `persistent`: always inline.
234
+ * - `drawer`: always off-canvas.
235
+ * @default 'auto'
236
+ */
237
+ sidebarMode ?: SkPagePanelMode;
238
+
239
+ /**
240
+ * Aside rendering mode. Mirror of `sidebarMode` for the right-side panel.
241
+ * @default 'auto'
242
+ */
243
+ asideMode ?: SkPagePanelMode;
244
+
245
+ /**
246
+ * Viewport width below which the sidebar's `auto` mode switches to drawer. Accepts any CSS length.
247
+ * @default '1024px'
248
+ */
249
+ sidebarBreakpoint ?: string;
250
+
251
+ /**
252
+ * Viewport width below which the aside's `auto` mode switches to drawer. Accepts any CSS length.
253
+ * @default '1024px'
254
+ */
255
+ asideBreakpoint ?: string;
256
+
257
+ /**
258
+ * Controlled sidebar drawer open state. Bind via `v-model:sidebarOpen` to control programmatically.
259
+ * When omitted, SkPage manages state internally.
260
+ */
261
+ sidebarOpen ?: boolean;
262
+
263
+ /**
264
+ * Controlled aside drawer open state. Bind via `v-model:asideOpen` to control programmatically.
265
+ * When omitted, SkPage manages state internally.
266
+ */
267
+ asideOpen ?: boolean;
268
+
269
+ /**
270
+ * Optional theme name. When provided, SkPage acts as a theme provider -- setting `data-scheme`
271
+ * on the root element and providing theme context for descendant components (including portal
272
+ * components like dropdowns and modals).
114
273
  */
115
274
  theme ?: SkThemeName;
275
+
276
+ /**
277
+ * When true, zeroes out `--sk-page-gap` and `--sk-page-content-padding` so panels and content
278
+ * sit flush against the header, footer, edges, and each other.
279
+ * @default false
280
+ */
281
+ flush ?: boolean;
116
282
  }
117
283
 
118
284
  //------------------------------------------------------------------------------------------------------------------
119
285
 
120
286
  const props = withDefaults(defineProps<SkPageComponentProps>(), {
121
- sidebarPosition: 'left',
122
287
  fixedHeader: false,
123
288
  fixedFooter: false,
124
289
  sidebarWidth: undefined,
290
+ asideWidth: undefined,
291
+ sidebarMode: 'auto',
292
+ asideMode: 'auto',
293
+ sidebarBreakpoint: '1024px',
294
+ asideBreakpoint: '1024px',
295
+ sidebarOpen: undefined,
296
+ asideOpen: undefined,
125
297
  theme: undefined,
298
+ flush: false,
126
299
  });
127
300
 
301
+ const emit = defineEmits<{
302
+ 'update:sidebarOpen' : [ value : boolean ];
303
+ 'update:asideOpen' : [ value : boolean ];
304
+ }>();
305
+
306
+ const slots = defineSlots<{
307
+ 'default' ?: () => unknown;
308
+ 'header' ?: () => unknown;
309
+ 'subheader' ?: () => unknown;
310
+ 'sidebar' ?: (props : { isDrawer : boolean }) => unknown;
311
+ 'sidebar-header' ?: (props : { isDrawer : boolean }) => unknown;
312
+ 'sidebar-footer' ?: (props : { isDrawer : boolean }) => unknown;
313
+ 'main-header' ?: () => unknown;
314
+ 'main-footer' ?: () => unknown;
315
+ 'aside' ?: (props : { isDrawer : boolean }) => unknown;
316
+ 'aside-header' ?: (props : { isDrawer : boolean }) => unknown;
317
+ 'aside-footer' ?: (props : { isDrawer : boolean }) => unknown;
318
+ 'footer' ?: () => unknown;
319
+ }>();
320
+
128
321
  //------------------------------------------------------------------------------------------------------------------
129
- // Classes
322
+ // Slot-derived state
130
323
  //------------------------------------------------------------------------------------------------------------------
131
324
 
132
- const classes = computed(() => ({
133
- 'sk-page': true,
134
- [`sk-sidebar-${ props.sidebarPosition }`]: true,
135
- 'sk-fixed-header': props.fixedHeader,
136
- 'sk-fixed-footer': props.fixedFooter,
137
- }));
325
+ const hasSidebar = computed<boolean>(() => Boolean(slots.sidebar));
326
+ const hasAside = computed<boolean>(() => Boolean(slots.aside));
138
327
 
139
328
  //------------------------------------------------------------------------------------------------------------------
140
- // Custom Styles
329
+ // Responsive mode detection — driven by SkPage's own width, not the viewport, so embedded
330
+ // demos, split panes, and modal shells collapse based on the space they're actually given.
141
331
  //------------------------------------------------------------------------------------------------------------------
142
332
 
143
- const customStyles = computed(() =>
333
+ const rootRef = ref<HTMLElement | null>(null);
334
+ const rootWidth = ref<number>(Number.POSITIVE_INFINITY);
335
+
336
+ function resolvePx(value : string, context : HTMLElement | null) : number
337
+ {
338
+ // Fast path for plain pixel values.
339
+ if(/^\d+(\.\d+)?px$/.test(value)) { return parseFloat(value); }
340
+
341
+ if(typeof document === 'undefined') { return parseFloat(value) || 1024; }
342
+
343
+ // Measure by writing the length into a detached element and reading its computed width.
344
+ // Handles rem, em, vw, ch, etc. in a single mechanism.
345
+ const probe = document.createElement('div');
346
+ probe.style.position = 'absolute';
347
+ probe.style.visibility = 'hidden';
348
+ probe.style.pointerEvents = 'none';
349
+ probe.style.width = value;
350
+ (context ?? document.body).appendChild(probe);
351
+ const px = probe.offsetWidth;
352
+ probe.remove();
353
+ return px;
354
+ }
355
+
356
+ const sidebarBreakpointPx = computed<number>(() => resolvePx(props.sidebarBreakpoint, rootRef.value));
357
+ const asideBreakpointPx = computed<number>(() => resolvePx(props.asideBreakpoint, rootRef.value));
358
+
359
+ const sidebarBelowBreakpoint = computed<boolean>(() => rootWidth.value < sidebarBreakpointPx.value);
360
+ const asideBelowBreakpoint = computed<boolean>(() => rootWidth.value < asideBreakpointPx.value);
361
+
362
+ let observer : ResizeObserver | null = null;
363
+
364
+ onMounted(() =>
365
+ {
366
+ if(!rootRef.value || typeof ResizeObserver === 'undefined') { return; }
367
+
368
+ rootWidth.value = rootRef.value.clientWidth;
369
+
370
+ observer = new ResizeObserver((entries) =>
371
+ {
372
+ for(const entry of entries)
373
+ {
374
+ // borderBoxSize is the standards-aligned readout; fall back to contentRect for older engines.
375
+ const size = entry.borderBoxSize?.[0];
376
+ rootWidth.value = size ? size.inlineSize : entry.contentRect.width;
377
+ }
378
+ });
379
+ observer.observe(rootRef.value);
380
+ });
381
+
382
+ onBeforeUnmount(() =>
383
+ {
384
+ observer?.disconnect();
385
+ observer = null;
386
+ });
387
+
388
+ const isSidebarDrawerActive = computed<boolean>(() =>
389
+ {
390
+ if(!hasSidebar.value) { return false; }
391
+ if(props.sidebarMode === 'drawer') { return true; }
392
+ if(props.sidebarMode === 'persistent') { return false; }
393
+ return sidebarBelowBreakpoint.value;
394
+ });
395
+
396
+ const isAsideDrawerActive = computed<boolean>(() =>
397
+ {
398
+ if(!hasAside.value) { return false; }
399
+ if(props.asideMode === 'drawer') { return true; }
400
+ if(props.asideMode === 'persistent') { return false; }
401
+ return asideBelowBreakpoint.value;
402
+ });
403
+
404
+ //------------------------------------------------------------------------------------------------------------------
405
+ // Drawer open state (uncontrolled by default, controlled when the corresponding v-model is bound)
406
+ //------------------------------------------------------------------------------------------------------------------
407
+
408
+ interface DrawerState
409
+ {
410
+ open : Ref<boolean>;
411
+ setOpen : (value : boolean) => void;
412
+ }
413
+
414
+ function useDrawerState(
415
+ controlledProp : () => boolean | undefined,
416
+ setControlled : (value : boolean) => void,
417
+ isActive : Ref<boolean>
418
+ ) : DrawerState
144
419
  {
145
- if(!props.sidebarWidth)
420
+ const internal = ref<boolean>(false);
421
+ const isControlled = computed<boolean>(() => controlledProp() !== undefined);
422
+
423
+ const open = computed<boolean>(() =>
146
424
  {
147
- return {};
148
- }
425
+ if(!isActive.value) { return false; }
426
+ if(isControlled.value) { return controlledProp() === true; }
427
+ return internal.value;
428
+ });
149
429
 
150
- return {
151
- '--sk-page-sidebar-width': props.sidebarWidth,
430
+ const setOpen = (value : boolean) : void =>
431
+ {
432
+ if(!isControlled.value) { internal.value = value; }
433
+ setControlled(value);
152
434
  };
435
+
436
+ // Collapse open state if layout becomes persistent (e.g. user widens the viewport).
437
+ watch(isActive, (active) =>
438
+ {
439
+ if(!active && open.value) { setOpen(false); }
440
+ });
441
+
442
+ return { open, setOpen };
443
+ }
444
+
445
+ const sidebar = useDrawerState(
446
+ () => props.sidebarOpen,
447
+ (value) => emit('update:sidebarOpen', value),
448
+ isSidebarDrawerActive
449
+ );
450
+ const aside = useDrawerState(
451
+ () => props.asideOpen,
452
+ (value) => emit('update:asideOpen', value),
453
+ isAsideDrawerActive
454
+ );
455
+
456
+ const sidebarDrawerOpen = sidebar.open;
457
+ const asideDrawerOpen = aside.open;
458
+
459
+ const closeSidebarDrawer = () : void => sidebar.setOpen(false);
460
+ const closeAsideDrawer = () : void => aside.setOpen(false);
461
+
462
+ //------------------------------------------------------------------------------------------------------------------
463
+ // Click-to-dismiss on empty drawer chrome. We don't use `pointer-events: none` for this
464
+ // because that also blocks wheel/touch scrolling inside the drawer body. Instead we listen
465
+ // for clicks on the drawer and close when the target wasn't something interactive.
466
+ //------------------------------------------------------------------------------------------------------------------
467
+
468
+ const INTERACTIVE_SELECTOR = [
469
+ 'a[href]',
470
+ 'button:not([disabled])',
471
+ 'input:not([disabled]):not([type="hidden"])',
472
+ 'select:not([disabled])',
473
+ 'textarea:not([disabled])',
474
+ 'label',
475
+ 'summary',
476
+ '[role="button"]',
477
+ '[role="link"]',
478
+ '[role="menuitem"]',
479
+ '[role="option"]',
480
+ '[role="tab"]',
481
+ '[role="checkbox"]',
482
+ '[role="radio"]',
483
+ '[role="switch"]',
484
+ '[tabindex]:not([tabindex="-1"])',
485
+ '.sk-sidebar-item',
486
+ ].join(',');
487
+
488
+ function onSidebarDrawerClick(event : MouseEvent) : void
489
+ {
490
+ const target = event.target as HTMLElement | null;
491
+ if(!target || !target.closest(INTERACTIVE_SELECTOR)) { closeSidebarDrawer(); }
492
+ }
493
+
494
+ function onAsideDrawerClick(event : MouseEvent) : void
495
+ {
496
+ const target = event.target as HTMLElement | null;
497
+ if(!target || !target.closest(INTERACTIVE_SELECTOR)) { closeAsideDrawer(); }
498
+ }
499
+
500
+ //------------------------------------------------------------------------------------------------------------------
501
+ // Provide drawer context for toggle components
502
+ //------------------------------------------------------------------------------------------------------------------
503
+
504
+ providePageDrawers(
505
+ { isOpen: sidebarDrawerOpen, isAvailable: isSidebarDrawerActive, setOpen: sidebar.setOpen },
506
+ { isOpen: asideDrawerOpen, isAvailable: isAsideDrawerActive, setOpen: aside.setOpen }
507
+ );
508
+
509
+ //------------------------------------------------------------------------------------------------------------------
510
+ // Focus trap
511
+ //------------------------------------------------------------------------------------------------------------------
512
+
513
+ const sidebarDrawerRef = ref<HTMLElement | null>(null);
514
+ const asideDrawerRef = ref<HTMLElement | null>(null);
515
+
516
+ useFocusTrap({
517
+ active: sidebarDrawerOpen,
518
+ container: sidebarDrawerRef,
519
+ onEscape: closeSidebarDrawer,
520
+ });
521
+ useFocusTrap({
522
+ active: asideDrawerOpen,
523
+ container: asideDrawerRef,
524
+ onEscape: closeAsideDrawer,
525
+ });
526
+
527
+ //------------------------------------------------------------------------------------------------------------------
528
+ // Classes & styles
529
+ //------------------------------------------------------------------------------------------------------------------
530
+
531
+ const classes = computed(() => ({
532
+ 'sk-page': true,
533
+ 'sk-fixed-header': props.fixedHeader,
534
+ 'sk-fixed-footer': props.fixedFooter,
535
+ 'sk-sidebar-drawer-active': isSidebarDrawerActive.value,
536
+ 'sk-aside-drawer-active': isAsideDrawerActive.value,
537
+ 'sk-flush': props.flush,
538
+ }));
539
+
540
+ const customStyles = computed(() =>
541
+ {
542
+ const styles : Record<string, string> = {};
543
+ if(props.sidebarWidth) { styles['--sk-page-sidebar-width'] = props.sidebarWidth; }
544
+ if(props.asideWidth) { styles['--sk-page-aside-width'] = props.asideWidth; }
545
+ return styles;
153
546
  });
154
547
 
155
548
  //------------------------------------------------------------------------------------------------------------------
@@ -159,19 +552,14 @@
159
552
  if(props.theme)
160
553
  {
161
554
  const { currentTheme, setTheme } = provideTheme(props.theme);
162
-
163
- // Provide theme for portal components (dropdown, modal, tooltip, etc.)
164
555
  provide('sk-theme', currentTheme);
165
556
 
166
- // Watch for external theme prop changes
167
557
  watch(() => props.theme, (newTheme) =>
168
558
  {
169
- if(newTheme)
170
- {
171
- setTheme(newTheme);
172
- }
559
+ if(newTheme) { setTheme(newTheme); }
173
560
  });
174
561
  }
562
+
175
563
  </script>
176
564
 
177
565
  <!--------------------------------------------------------------------------------------------------------------------->