@r2digisolutions/components 0.11.0 → 0.12.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.
@@ -4,6 +4,8 @@ declare const meta: {
4
4
  component: import("svelte").Component<{
5
5
  fullHeight?: boolean;
6
6
  framed?: boolean;
7
+ showRail?: boolean;
8
+ showContextual?: boolean;
7
9
  }, {}, "">;
8
10
  tags: string[];
9
11
  parameters: {
@@ -16,13 +18,23 @@ declare const meta: {
16
18
  framed: {
17
19
  control: "boolean";
18
20
  };
21
+ showRail: {
22
+ control: "boolean";
23
+ };
24
+ showContextual: {
25
+ control: "boolean";
26
+ };
19
27
  };
20
28
  args: {
21
29
  fullHeight: true;
22
30
  framed: false;
31
+ showRail: false;
32
+ showContextual: false;
23
33
  };
24
34
  };
25
35
  export default meta;
26
36
  type Story = StoryObj<typeof meta>;
27
37
  export declare const Fullscreen: Story;
38
+ export declare const WithRail: Story;
39
+ export declare const Coolify: Story;
28
40
  export declare const FramedPreview: Story;
@@ -8,15 +8,27 @@ const meta = {
8
8
  },
9
9
  argTypes: {
10
10
  fullHeight: { control: 'boolean' },
11
- framed: { control: 'boolean' }
11
+ framed: { control: 'boolean' },
12
+ showRail: { control: 'boolean' },
13
+ showContextual: { control: 'boolean' }
12
14
  },
13
15
  args: {
14
16
  fullHeight: true,
15
- framed: false
17
+ framed: false,
18
+ showRail: false,
19
+ showContextual: false
16
20
  }
17
21
  };
18
22
  export default meta;
19
23
  export const Fullscreen = {};
24
+ export const WithRail = {
25
+ name: 'With icon rail',
26
+ args: { showRail: true }
27
+ };
28
+ export const Coolify = {
29
+ name: 'Rail + contextual',
30
+ args: { showRail: true, showContextual: true }
31
+ };
20
32
  export const FramedPreview = {
21
33
  name: 'Framed preview',
22
34
  args: { fullHeight: false, framed: true },
@@ -2,6 +2,15 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import Sidebar, { type SidebarGroup } from '../Sidebar/Sidebar.svelte';
4
4
  import Navbar, { type NavbarLink } from '../Navbar/Navbar.svelte';
5
+ import NavRail, { type NavRailItem } from '../NavRail/NavRail.svelte';
6
+ import IconButton from '../../atoms/IconButton/IconButton.svelte';
7
+ import Badge from '../../atoms/Badge/Badge.svelte';
8
+ import {
9
+ AppChrome,
10
+ getAppChrome,
11
+ setAppChrome,
12
+ type AppShellContextual
13
+ } from './app-chrome.svelte.js';
5
14
 
6
15
  interface AppShellProps {
7
16
  brand?: string;
@@ -12,6 +21,20 @@
12
21
  collapsed?: boolean;
13
22
  showSidebar?: boolean;
14
23
  showNavbar?: boolean;
24
+ /** Icon rail. Shown when the list is non-empty. */
25
+ rail?: NavRailItem[];
26
+ railFooter?: NavRailItem[];
27
+ railValue?: string;
28
+ railBrand?: Snippet;
29
+ onrailchange?: (id: string) => void;
30
+ /** Storybook / explicit contextual nav. Nested layouts can also set this via `getAppChrome()`. */
31
+ contextualGroups?: SidebarGroup[];
32
+ contextualValue?: string;
33
+ contextualBrand?: string;
34
+ contextualHeader?: Snippet;
35
+ oncontextualchange?: (id: string) => void;
36
+ onsidebarchange?: (id: string) => void;
37
+ sidebarFooter?: Snippet;
15
38
  /**
16
39
  * Fill the viewport. Use `false` + `framed` for compact Storybook previews.
17
40
  */
@@ -34,6 +57,18 @@
34
57
  collapsed = $bindable(false),
35
58
  showSidebar = true,
36
59
  showNavbar = true,
60
+ rail = [],
61
+ railFooter = [],
62
+ railValue = $bindable(''),
63
+ railBrand,
64
+ onrailchange,
65
+ contextualGroups = [],
66
+ contextualValue = $bindable(''),
67
+ contextualBrand = '',
68
+ contextualHeader,
69
+ oncontextualchange,
70
+ onsidebarchange,
71
+ sidebarFooter,
37
72
  fullHeight = true,
38
73
  framed = false,
39
74
  mainClass = '',
@@ -41,44 +76,196 @@
41
76
  actions,
42
77
  children
43
78
  }: AppShellProps = $props();
79
+
80
+ const chrome = (() => {
81
+ try {
82
+ return getAppChrome();
83
+ } catch {
84
+ const created = new AppChrome();
85
+ setAppChrome(created);
86
+ return created;
87
+ }
88
+ })();
89
+
90
+ const propContextual = $derived.by((): AppShellContextual | null => {
91
+ if (contextualGroups.length === 0) return null;
92
+ return {
93
+ groups: contextualGroups,
94
+ value: contextualValue,
95
+ brand: contextualBrand,
96
+ header: contextualHeader,
97
+ onchange: oncontextualchange
98
+ };
99
+ });
100
+
101
+ const resolvedContextual = $derived(chrome.contextual ?? propContextual);
102
+ const showRail = $derived(rail.length > 0 || railFooter.length > 0);
103
+ let mobileOpen = $state(false);
104
+
105
+ const mobileShowsContextual = $derived(Boolean(resolvedContextual));
106
+
107
+ function closeMobile() {
108
+ mobileOpen = false;
109
+ }
110
+
111
+ function onPrimaryChange(id: string) {
112
+ onsidebarchange?.(id);
113
+ closeMobile();
114
+ }
115
+
116
+ function onContextualNav(id: string) {
117
+ contextualValue = id;
118
+ resolvedContextual?.onchange?.(id);
119
+ closeMobile();
120
+ }
44
121
  </script>
45
122
 
46
123
  <div
47
124
  class={[
48
- 'flex w-full overflow-hidden bg-surface',
125
+ 'bg-surface relative flex w-full overflow-hidden',
49
126
  fullHeight && 'h-dvh',
50
- !fullHeight && framed && 'h-full min-h-0',
127
+ !fullHeight && framed && 'min-h-0 h-full',
51
128
  !fullHeight && !framed && 'h-[min(36rem,80vh)]',
52
- framed && 'rounded-2xl border border-border shadow-sm',
129
+ framed && 'rounded-2xl border-border shadow-sm border',
53
130
  className
54
131
  ]}
55
132
  >
56
- {#if showSidebar}
57
- <Sidebar
58
- {brand}
59
- groups={sidebarGroups}
60
- bind:value={sidebarValue}
61
- bind:collapsed
133
+ {#snippet contextualHeaderDefault()}
134
+ {#if resolvedContextual}
135
+ <div class="min-w-0 space-y-1">
136
+ {#if resolvedContextual.parentHref && resolvedContextual.parentLabel}
137
+ <a
138
+ href={resolvedContextual.parentHref}
139
+ class="text-muted hover:text-primary block truncate text-[11px]"
140
+ >
141
+ {resolvedContextual.parentLabel}
142
+ </a>
143
+ {/if}
144
+ <p class="text-sm font-semibold text-primary truncate">{resolvedContextual.brand}</p>
145
+ <div class="gap-1.5 flex flex-wrap items-center">
146
+ {#if resolvedContextual.status}
147
+ <Badge variant="secondary" size="sm">{resolvedContextual.status}</Badge>
148
+ {/if}
149
+ {#if resolvedContextual.description}
150
+ <span class="text-muted truncate text-[11px]">{resolvedContextual.description}</span>
151
+ {/if}
152
+ </div>
153
+ </div>
154
+ {/if}
155
+ {/snippet}
156
+
157
+ {#if showRail}
158
+ <NavRail
159
+ items={rail}
160
+ footerItems={railFooter}
161
+ bind:value={railValue}
162
+ brand={railBrand}
163
+ onchange={onrailchange}
62
164
  />
63
165
  {/if}
64
- <div class="flex min-h-0 min-w-0 flex-1 flex-col">
166
+
167
+ {#if mobileOpen}
168
+ <button
169
+ type="button"
170
+ class="inset-0 bg-black/40 md:hidden fixed z-30"
171
+ aria-label="Cerrar menú"
172
+ onclick={closeMobile}
173
+ ></button>
174
+ {/if}
175
+
176
+ {#if showSidebar}
177
+ <div
178
+ class={[
179
+ 'z-40 h-full shrink-0',
180
+ mobileShowsContextual && 'max-md:hidden',
181
+ !mobileOpen && 'max-md:hidden',
182
+ mobileOpen &&
183
+ !mobileShowsContextual &&
184
+ 'max-md:fixed max-md:inset-y-0 max-md:z-40 max-md:shadow-xl',
185
+ mobileOpen && !mobileShowsContextual && showRail && 'max-md:left-14',
186
+ mobileOpen && !mobileShowsContextual && !showRail && 'max-md:left-0'
187
+ ]}
188
+ >
189
+ <Sidebar
190
+ {brand}
191
+ groups={sidebarGroups}
192
+ bind:value={sidebarValue}
193
+ bind:collapsed
194
+ footer={sidebarFooter}
195
+ onchange={onPrimaryChange}
196
+ />
197
+ </div>
198
+ {/if}
199
+
200
+ {#if resolvedContextual}
201
+ <div
202
+ class={[
203
+ 'z-40 h-full shrink-0',
204
+ !mobileOpen && 'max-md:hidden',
205
+ mobileOpen &&
206
+ mobileShowsContextual &&
207
+ 'max-md:fixed max-md:inset-y-0 max-md:z-40 max-md:shadow-xl',
208
+ mobileOpen && mobileShowsContextual && showRail && 'max-md:left-14',
209
+ mobileOpen && mobileShowsContextual && !showRail && 'max-md:left-0'
210
+ ]}
211
+ >
212
+ <Sidebar
213
+ brand={resolvedContextual.brand ?? brand}
214
+ groups={resolvedContextual.groups}
215
+ value={resolvedContextual.value}
216
+ collapsible={false}
217
+ header={resolvedContextual.header ?? contextualHeaderDefault}
218
+ onchange={onContextualNav}
219
+ />
220
+ </div>
221
+ {/if}
222
+
223
+ <div class="min-h-0 min-w-0 flex flex-1 flex-col">
65
224
  {#if showNavbar}
66
225
  <Navbar
67
226
  {brand}
68
227
  links={navLinks}
69
228
  bind:value={navValue}
70
- showBrand={!showSidebar}
229
+ showBrand={!showSidebar && !showRail}
71
230
  variant="pills"
72
231
  size="sm"
73
232
  bordered
74
233
  maxWidth="full"
75
234
  >
235
+ {#snippet leading()}
236
+ <div class="md:hidden">
237
+ <IconButton
238
+ label={mobileOpen ? 'Cerrar menú' : 'Abrir menú'}
239
+ size="sm"
240
+ onclick={() => (mobileOpen = !mobileOpen)}
241
+ >
242
+ <svg
243
+ class="h-4 w-4"
244
+ viewBox="0 0 24 24"
245
+ fill="none"
246
+ stroke="currentColor"
247
+ stroke-width="2"
248
+ aria-hidden="true"
249
+ >
250
+ {#if mobileOpen}
251
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
252
+ {:else}
253
+ <path
254
+ stroke-linecap="round"
255
+ stroke-linejoin="round"
256
+ d="M4 6h16M4 12h16M4 18h16"
257
+ />
258
+ {/if}
259
+ </svg>
260
+ </IconButton>
261
+ </div>
262
+ {/snippet}
76
263
  {#snippet actions()}
77
264
  {#if actions}{@render actions()}{/if}
78
265
  {/snippet}
79
266
  </Navbar>
80
267
  {/if}
81
- <main class={['min-h-0 flex-1 overflow-auto p-6', mainClass]}>
268
+ <main class={['min-h-0 p-6 flex-1 overflow-auto', mainClass]}>
82
269
  {#if children}{@render children()}{/if}
83
270
  </main>
84
271
  </div>
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import { type SidebarGroup } from '../Sidebar/Sidebar.svelte';
3
3
  import { type NavbarLink } from '../Navbar/Navbar.svelte';
4
+ import { type NavRailItem } from '../NavRail/NavRail.svelte';
4
5
  interface AppShellProps {
5
6
  brand?: string;
6
7
  sidebarGroups?: SidebarGroup[];
@@ -10,6 +11,20 @@ interface AppShellProps {
10
11
  collapsed?: boolean;
11
12
  showSidebar?: boolean;
12
13
  showNavbar?: boolean;
14
+ /** Icon rail. Shown when the list is non-empty. */
15
+ rail?: NavRailItem[];
16
+ railFooter?: NavRailItem[];
17
+ railValue?: string;
18
+ railBrand?: Snippet;
19
+ onrailchange?: (id: string) => void;
20
+ /** Storybook / explicit contextual nav. Nested layouts can also set this via `getAppChrome()`. */
21
+ contextualGroups?: SidebarGroup[];
22
+ contextualValue?: string;
23
+ contextualBrand?: string;
24
+ contextualHeader?: Snippet;
25
+ oncontextualchange?: (id: string) => void;
26
+ onsidebarchange?: (id: string) => void;
27
+ sidebarFooter?: Snippet;
13
28
  /**
14
29
  * Fill the viewport. Use `false` + `framed` for compact Storybook previews.
15
30
  */
@@ -22,6 +37,6 @@ interface AppShellProps {
22
37
  actions?: Snippet;
23
38
  children?: Snippet;
24
39
  }
25
- declare const AppShell: import("svelte").Component<AppShellProps, {}, "collapsed" | "sidebarValue" | "navValue">;
40
+ declare const AppShell: import("svelte").Component<AppShellProps, {}, "collapsed" | "sidebarValue" | "navValue" | "railValue" | "contextualValue">;
26
41
  type AppShell = ReturnType<typeof AppShell>;
27
42
  export default AppShell;
@@ -1,26 +1,68 @@
1
1
  <script lang="ts">
2
2
  import AppShell from './AppShell.svelte';
3
3
  import type { SidebarGroup } from '../Sidebar/Sidebar.svelte';
4
+ import type { NavRailItem } from '../NavRail/NavRail.svelte';
4
5
  import type { NavbarLink } from '../Navbar/Navbar.svelte';
5
6
  import Button from '../../atoms/Button/Button.svelte';
6
7
  import Avatar from '../../atoms/Avatar/Avatar.svelte';
8
+ import Badge from '../../atoms/Badge/Badge.svelte';
9
+ import BrandMark from '../../atoms/BrandMark/BrandMark.svelte';
7
10
 
8
11
  let {
9
12
  fullHeight = true,
10
- framed = false
13
+ framed = false,
14
+ showRail = true,
15
+ showContextual = false
11
16
  }: {
12
17
  fullHeight?: boolean;
13
18
  framed?: boolean;
19
+ showRail?: boolean;
20
+ showContextual?: boolean;
14
21
  } = $props();
15
22
 
16
23
  const sidebarGroups: SidebarGroup[] = [
17
24
  {
18
- id: 'main',
25
+ id: 'ops',
19
26
  label: 'Workspace',
20
27
  items: [
21
- { id: 'home', label: 'Home' },
22
- { id: 'projects', label: 'Projects' },
23
- { id: 'billing', label: 'Billing' }
28
+ { id: 'home', label: 'Dashboard', href: '#' },
29
+ { id: 'projects', label: 'Projects', href: '#' },
30
+ { id: 'billing', label: 'Billing', href: '#' }
31
+ ]
32
+ },
33
+ {
34
+ id: 'manage',
35
+ label: 'Manage',
36
+ items: [
37
+ { id: 'team', label: 'Team', href: '#' },
38
+ { id: 'settings', label: 'Settings', href: '#' }
39
+ ]
40
+ }
41
+ ];
42
+
43
+ const rail: NavRailItem[] = [
44
+ { id: 'home', label: 'Home', href: '#' },
45
+ { id: 'atom', label: 'Atom', href: '#' },
46
+ { id: 'crm', label: 'CRM', href: '#' }
47
+ ];
48
+
49
+ const railFooter: NavRailItem[] = [{ id: 'settings', label: 'Settings', href: '#' }];
50
+
51
+ const contextualGroups: SidebarGroup[] = [
52
+ {
53
+ id: 'general',
54
+ label: 'General',
55
+ items: [
56
+ { id: 'overview', label: 'Overview', href: '#' },
57
+ { id: 'datos', label: 'Details', href: '#' }
58
+ ]
59
+ },
60
+ {
61
+ id: 'biz',
62
+ label: 'Business',
63
+ items: [
64
+ { id: 'contracts', label: 'Clients', href: '#' },
65
+ { id: 'plans', label: 'Plans', href: '#' }
24
66
  ]
25
67
  }
26
68
  ];
@@ -33,6 +75,8 @@
33
75
 
34
76
  let sidebarValue = $state('projects');
35
77
  let navValue = $state('overview');
78
+ let railValue = $state('atom');
79
+ let contextualValue = $state('overview');
36
80
  </script>
37
81
 
38
82
  <AppShell
@@ -40,24 +84,47 @@
40
84
  {navLinks}
41
85
  {fullHeight}
42
86
  {framed}
87
+ rail={showRail ? rail : []}
88
+ railFooter={showRail ? railFooter : []}
89
+ contextualGroups={showContextual ? contextualGroups : []}
90
+ contextualBrand="Atom"
43
91
  bind:sidebarValue
44
92
  bind:navValue
93
+ bind:railValue
94
+ bind:contextualValue
45
95
  >
96
+ {#snippet railBrand()}
97
+ <BrandMark mark="R2" size="sm" />
98
+ {/snippet}
99
+ {#snippet contextualHeader()}
100
+ <div class="min-w-0">
101
+ <p class="text-sm font-semibold text-primary truncate">Atom</p>
102
+ <div class="mt-1 gap-1.5 flex items-center">
103
+ <Badge variant="success" size="sm" dot>active</Badge>
104
+ </div>
105
+ </div>
106
+ {/snippet}
46
107
  {#snippet actions()}
47
108
  <Button size="sm">New</Button>
48
109
  <Avatar name="Rafa" size="sm" />
49
110
  {/snippet}
50
111
  <div class="space-y-2">
51
- <h1 class="text-xl font-semibold capitalize text-primary">{navValue}</h1>
112
+ <h1 class="text-xl font-semibold text-primary capitalize">{navValue}</h1>
52
113
  <p class="text-sm text-secondary">
53
114
  Sidebar: <span class="font-medium text-primary">{sidebarValue}</span>
115
+ {#if showRail}
116
+ · Rail: <span class="font-medium text-primary">{railValue}</span>
117
+ {/if}
118
+ {#if showContextual}
119
+ · Contextual: <span class="font-medium text-primary">{contextualValue}</span>
120
+ {/if}
54
121
  </p>
55
122
  <p class="max-w-lg text-sm text-muted">
56
123
  Scrollable content area. The shell fills the viewport so sidebar and main column reach the
57
124
  bottom.
58
125
  </p>
59
126
  {#each Array.from({ length: 12 }, (_, i) => i + 1) as n (n)}
60
- <div class="rounded-xl border border-border bg-surface-elevated p-4 text-sm text-secondary">
127
+ <div class="rounded-xl border-border bg-surface-elevated p-4 text-sm text-secondary border">
61
128
  Content block {n}
62
129
  </div>
63
130
  {/each}
@@ -1,6 +1,8 @@
1
1
  type $$ComponentProps = {
2
2
  fullHeight?: boolean;
3
3
  framed?: boolean;
4
+ showRail?: boolean;
5
+ showContextual?: boolean;
4
6
  };
5
7
  declare const AppShellStory: import("svelte").Component<$$ComponentProps, {}, "">;
6
8
  type AppShellStory = ReturnType<typeof AppShellStory>;
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { SidebarGroup } from '../Sidebar/Sidebar.svelte';
3
+ export interface AppShellContextual {
4
+ groups: SidebarGroup[];
5
+ value: string;
6
+ brand?: string;
7
+ description?: string;
8
+ status?: string;
9
+ parentHref?: string;
10
+ parentLabel?: string;
11
+ header?: Snippet;
12
+ onchange?: (id: string) => void;
13
+ }
14
+ export declare class AppChrome {
15
+ contextual: AppShellContextual | null;
16
+ setContextual(nav: AppShellContextual | null): void;
17
+ }
18
+ export declare const getAppChrome: () => AppChrome, setAppChrome: (context: AppChrome) => AppChrome;
@@ -0,0 +1,8 @@
1
+ import { createContext } from 'svelte';
2
+ export class AppChrome {
3
+ contextual = $state.raw(null);
4
+ setContextual(nav) {
5
+ this.contextual = nav;
6
+ }
7
+ }
8
+ export const [getAppChrome, setAppChrome] = createContext();
@@ -0,0 +1,12 @@
1
+ import type { StoryObj } from '@storybook/svelte';
2
+ declare const meta: {
3
+ title: string;
4
+ component: import("svelte").Component<Record<string, never>, {}, "">;
5
+ tags: string[];
6
+ parameters: {
7
+ layout: string;
8
+ };
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+ export declare const Default: Story;
@@ -0,0 +1,11 @@
1
+ import NavRailStory from './NavRailStory.svelte';
2
+ const meta = {
3
+ title: 'Organisms/NavRail',
4
+ component: NavRailStory,
5
+ tags: ['autodocs'],
6
+ parameters: {
7
+ layout: 'centered'
8
+ }
9
+ };
10
+ export default meta;
11
+ export const Default = {};
@@ -0,0 +1,119 @@
1
+ <script lang="ts">
2
+ import type { Component, Snippet } from 'svelte';
3
+ import Tooltip from '../../atoms/Tooltip/Tooltip.svelte';
4
+ import RailMark from '../../atoms/RailMark/RailMark.svelte';
5
+ import Divider from '../../atoms/Divider/Divider.svelte';
6
+
7
+ export type NavRailIcon = Component<{
8
+ class?: string;
9
+ size?: number | string;
10
+ strokeWidth?: number | string;
11
+ }>;
12
+
13
+ export interface NavRailItem {
14
+ id: string;
15
+ label: string;
16
+ href?: string;
17
+ icon?: NavRailIcon;
18
+ disabled?: boolean;
19
+ }
20
+
21
+ interface NavRailProps {
22
+ items?: NavRailItem[];
23
+ footerItems?: NavRailItem[];
24
+ value?: string;
25
+ class?: string;
26
+ brand?: Snippet;
27
+ onchange?: (id: string) => void;
28
+ }
29
+
30
+ let {
31
+ items = [],
32
+ footerItems = [],
33
+ value = $bindable(''),
34
+ class: className = '',
35
+ brand,
36
+ onchange
37
+ }: NavRailProps = $props();
38
+
39
+ function select(id: string, disabled?: boolean) {
40
+ if (disabled) return;
41
+ value = id;
42
+ onchange?.(id);
43
+ }
44
+
45
+ const itemClass = (item: NavRailItem) => [
46
+ 'relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors',
47
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
48
+ value === item.id
49
+ ? 'bg-brand-50 text-brand-700 dark:bg-brand-950/40 dark:text-brand-300'
50
+ : 'text-secondary hover:bg-surface-overlay hover:text-primary',
51
+ item.disabled && 'pointer-events-none cursor-not-allowed opacity-40'
52
+ ];
53
+ </script>
54
+
55
+ {#snippet railButton(item: NavRailItem)}
56
+ <Tooltip content={item.label} side="right">
57
+ <span class="relative inline-flex">
58
+ <RailMark active={value === item.id} length="short" inset="sm" />
59
+ {#if item.href && !item.disabled}
60
+ <a
61
+ href={item.href}
62
+ aria-label={item.label}
63
+ aria-current={value === item.id ? 'page' : undefined}
64
+ class={itemClass(item)}
65
+ onclick={() => select(item.id, item.disabled)}
66
+ >
67
+ {#if item.icon}
68
+ <item.icon class="h-5 w-5" strokeWidth={1.75} />
69
+ {:else}
70
+ <span class="text-xs font-semibold">{item.label.slice(0, 1)}</span>
71
+ {/if}
72
+ </a>
73
+ {:else}
74
+ <button
75
+ type="button"
76
+ aria-label={item.label}
77
+ aria-current={value === item.id ? 'page' : undefined}
78
+ disabled={item.disabled}
79
+ class={itemClass(item)}
80
+ onclick={() => select(item.id, item.disabled)}
81
+ >
82
+ {#if item.icon}
83
+ <item.icon class="h-5 w-5" strokeWidth={1.75} />
84
+ {:else}
85
+ <span class="text-xs font-semibold">{item.label.slice(0, 1)}</span>
86
+ {/if}
87
+ </button>
88
+ {/if}
89
+ </span>
90
+ </Tooltip>
91
+ {/snippet}
92
+
93
+ <aside
94
+ class={[
95
+ 'w-14 border-border bg-surface-elevated py-3 flex h-full shrink-0 flex-col items-center border-r',
96
+ className
97
+ ]}
98
+ >
99
+ {#if brand}
100
+ <div class="mb-3 px-2 flex items-center justify-center">
101
+ {@render brand()}
102
+ </div>
103
+ {/if}
104
+
105
+ <nav class="gap-1 flex flex-1 flex-col items-center" aria-label="Rail">
106
+ {#each items as item (item.id)}
107
+ {@render railButton(item)}
108
+ {/each}
109
+ </nav>
110
+
111
+ {#if footerItems.length > 0}
112
+ <div class="w-8 py-2"><Divider /></div>
113
+ <nav class="gap-1 flex flex-col items-center" aria-label="Rail footer">
114
+ {#each footerItems as item (item.id)}
115
+ {@render railButton(item)}
116
+ {/each}
117
+ </nav>
118
+ {/if}
119
+ </aside>
@@ -0,0 +1,24 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+ export type NavRailIcon = Component<{
3
+ class?: string;
4
+ size?: number | string;
5
+ strokeWidth?: number | string;
6
+ }>;
7
+ export interface NavRailItem {
8
+ id: string;
9
+ label: string;
10
+ href?: string;
11
+ icon?: NavRailIcon;
12
+ disabled?: boolean;
13
+ }
14
+ interface NavRailProps {
15
+ items?: NavRailItem[];
16
+ footerItems?: NavRailItem[];
17
+ value?: string;
18
+ class?: string;
19
+ brand?: Snippet;
20
+ onchange?: (id: string) => void;
21
+ }
22
+ declare const NavRail: Component<NavRailProps, {}, "value">;
23
+ type NavRail = ReturnType<typeof NavRail>;
24
+ export default NavRail;
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import NavRail, { type NavRailItem } from './NavRail.svelte';
3
+ import BrandMark from '../../atoms/BrandMark/BrandMark.svelte';
4
+
5
+ const items: NavRailItem[] = [
6
+ { id: 'home', label: 'Dashboard', href: '#' },
7
+ { id: 'atom', label: 'Atom', href: '#' },
8
+ { id: 'crm', label: 'CRM', href: '#' }
9
+ ];
10
+
11
+ const footerItems: NavRailItem[] = [{ id: 'settings', label: 'Ajustes', href: '#' }];
12
+
13
+ let value = $state('atom');
14
+ </script>
15
+
16
+ <div class="rounded-xl border-border bg-surface h-[28rem] overflow-hidden border">
17
+ <div class="flex h-full">
18
+ <NavRail {items} {footerItems} bind:value>
19
+ {#snippet brand()}
20
+ <BrandMark mark="R2" size="sm" />
21
+ {/snippet}
22
+ </NavRail>
23
+ <div class="p-6 text-sm text-secondary flex flex-1 items-center justify-center">
24
+ Selected: <span class="ml-1 font-medium text-primary">{value}</span>
25
+ </div>
26
+ </div>
27
+ </div>
@@ -0,0 +1,3 @@
1
+ declare const NavRailStory: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type NavRailStory = ReturnType<typeof NavRailStory>;
3
+ export default NavRailStory;
@@ -68,42 +68,37 @@
68
68
 
69
69
  <header
70
70
  class={[
71
- 'relative z-40 w-full bg-surface-elevated',
72
- bordered && 'border-b border-border',
73
- sticky && 'sticky top-0',
71
+ 'bg-surface-elevated relative z-40 w-full',
72
+ bordered && 'border-border border-b',
73
+ sticky && 'top-0 sticky',
74
74
  blur && 'bg-surface-elevated/85 backdrop-blur-md',
75
75
  className
76
76
  ]}
77
77
  >
78
78
  <div
79
- class={[
80
- 'mx-auto flex w-full items-center gap-3 px-4 sm:gap-4 sm:px-6',
81
- height,
82
- maxWidthClass
83
- ]}
79
+ class={['gap-3 px-4 sm:gap-4 sm:px-6 mx-auto flex w-full items-center', height, maxWidthClass]}
84
80
  >
81
+ {#if leading}
82
+ <div class="flex shrink-0 items-center">{@render leading()}</div>
83
+ {/if}
85
84
  {#if showBrand}
86
- <div class="flex shrink-0 items-center gap-2">
87
- {#if leading}
88
- {@render leading()}
89
- {:else}
90
- <span
91
- class={[
92
- 'flex items-center justify-center rounded-lg bg-brand-500 font-bold text-white',
93
- size === 'lg' ? 'h-8 w-8 text-xs' : 'h-7 w-7 text-[11px]'
94
- ]}
95
- aria-hidden="true"
96
- >
97
- {brand.slice(0, 1).toUpperCase()}
98
- </span>
99
- {/if}
85
+ <div class="gap-2 flex shrink-0 items-center">
86
+ <span
87
+ class={[
88
+ 'rounded-lg bg-brand-500 font-bold text-white flex items-center justify-center',
89
+ size === 'lg' ? 'h-8 w-8 text-xs' : 'h-7 w-7 text-[11px]'
90
+ ]}
91
+ aria-hidden="true"
92
+ >
93
+ {brand.slice(0, 1).toUpperCase()}
94
+ </span>
100
95
  <span class={['font-semibold tracking-tight text-primary', brandSize]}>{brand}</span>
101
96
  </div>
102
97
  {/if}
103
98
 
104
99
  <nav
105
100
  class={[
106
- 'hidden h-full min-w-0 items-stretch gap-0.5 md:flex',
101
+ 'min-w-0 gap-0.5 md:flex hidden h-full items-stretch',
107
102
  centerLinks ? 'flex-1 justify-center' : 'flex-1',
108
103
  !showBrand && !centerLinks && 'flex-1'
109
104
  ]}
@@ -116,14 +111,14 @@
116
111
  disabled={link.disabled}
117
112
  onclick={() => select(link.id, link.disabled)}
118
113
  class={[
119
- 'relative flex items-center gap-1.5 px-3 font-medium transition-colors',
120
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/30',
114
+ 'gap-1.5 px-3 font-medium relative flex items-center transition-colors',
115
+ 'focus-visible:ring-brand-500/30 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset',
121
116
  linkSize,
122
117
  variant === 'underline' && 'border-b-2',
123
118
  variant === 'underline' &&
124
119
  (active
125
120
  ? 'border-brand-500 text-primary'
126
- : 'border-transparent text-secondary hover:text-primary'),
121
+ : 'text-secondary hover:text-primary border-transparent'),
127
122
  variant === 'pills' && 'my-2 rounded-lg',
128
123
  variant === 'pills' &&
129
124
  (active
@@ -137,7 +132,7 @@
137
132
  {link.label}
138
133
  {#if link.badge !== undefined && link.badge !== ''}
139
134
  <span
140
- class="rounded-full bg-brand-50 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700 dark:bg-brand-950/50 dark:text-brand-300"
135
+ class="bg-brand-50 px-1.5 py-0.5 font-semibold text-brand-700 dark:bg-brand-950/50 dark:text-brand-300 rounded-full text-[10px]"
141
136
  >
142
137
  {link.badge}
143
138
  </span>
@@ -147,7 +142,7 @@
147
142
  </nav>
148
143
 
149
144
  <nav
150
- class="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto md:hidden"
145
+ class="min-w-0 gap-0.5 md:hidden flex flex-1 items-center overflow-x-auto"
151
146
  aria-label="Primary"
152
147
  >
153
148
  {#each links as link (link.id)}
@@ -156,8 +151,8 @@
156
151
  disabled={link.disabled}
157
152
  onclick={() => select(link.id, link.disabled)}
158
153
  class={[
159
- 'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors',
160
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
154
+ 'px-3 py-1 text-xs font-medium shrink-0 rounded-full transition-colors',
155
+ 'focus-visible:ring-brand-500/30 focus-visible:ring-2 focus-visible:outline-none',
161
156
  value === link.id
162
157
  ? 'bg-brand-50 text-brand-700 dark:bg-brand-950/50 dark:text-brand-300'
163
158
  : 'text-secondary hover:bg-surface-overlay',
@@ -170,7 +165,7 @@
170
165
  </nav>
171
166
 
172
167
  {#if actions}
173
- <div class="ml-auto flex shrink-0 items-center gap-2">
168
+ <div class="gap-2 ml-auto flex shrink-0 items-center">
174
169
  {@render actions()}
175
170
  </div>
176
171
  {/if}
@@ -1,11 +1,18 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
2
+ import type { Component, Snippet } from 'svelte';
3
3
  import Divider from '../../atoms/Divider/Divider.svelte';
4
4
 
5
+ export type SidebarIcon = Component<{
6
+ class?: string;
7
+ size?: number | string;
8
+ strokeWidth?: number | string;
9
+ }>;
10
+
5
11
  export interface SidebarItem {
6
12
  id: string;
7
13
  label: string;
8
14
  href?: string;
15
+ icon?: SidebarIcon;
9
16
  disabled?: boolean;
10
17
  }
11
18
 
@@ -20,7 +27,10 @@
20
27
  groups?: SidebarGroup[];
21
28
  value?: string;
22
29
  collapsed?: boolean;
30
+ /** Show the collapse toggle. Default true. */
31
+ collapsible?: boolean;
23
32
  class?: string;
33
+ header?: Snippet;
24
34
  footer?: Snippet;
25
35
  onchange?: (id: string) => void;
26
36
  }
@@ -30,7 +40,9 @@
30
40
  groups = [],
31
41
  value = $bindable(''),
32
42
  collapsed = $bindable(false),
43
+ collapsible = true,
33
44
  class: className = '',
45
+ header,
34
46
  footer,
35
47
  onchange
36
48
  }: SidebarProps = $props();
@@ -40,76 +52,119 @@
40
52
  value = id;
41
53
  onchange?.(id);
42
54
  }
55
+
56
+ function itemClass(item: SidebarItem) {
57
+ return [
58
+ 'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-sm transition-colors',
59
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
60
+ value === item.id
61
+ ? 'bg-brand-50 font-medium text-brand-700 dark:bg-brand-950/40 dark:text-brand-300'
62
+ : 'text-secondary hover:bg-surface-overlay hover:text-primary',
63
+ item.disabled && 'pointer-events-none cursor-not-allowed opacity-40',
64
+ collapsed && 'justify-center'
65
+ ];
66
+ }
43
67
  </script>
44
68
 
69
+ {#snippet itemGlyph(item: SidebarItem)}
70
+ {#if item.icon}
71
+ <item.icon class="h-4 w-4 shrink-0" strokeWidth={1.75} />
72
+ {:else}
73
+ <span
74
+ class="h-6 w-6 rounded-md bg-surface-overlay font-semibold flex shrink-0 items-center justify-center text-[11px]"
75
+ >
76
+ {item.label.slice(0, 1)}
77
+ </span>
78
+ {/if}
79
+ {/snippet}
80
+
81
+ {#snippet navItem(item: SidebarItem)}
82
+ {#if item.href && !item.disabled}
83
+ <a
84
+ href={item.href}
85
+ title={collapsed ? item.label : undefined}
86
+ aria-current={value === item.id ? 'page' : undefined}
87
+ class={itemClass(item)}
88
+ onclick={() => select(item.id, item.disabled)}
89
+ >
90
+ {@render itemGlyph(item)}
91
+ {#if !collapsed}
92
+ <span class="truncate">{item.label}</span>
93
+ {/if}
94
+ </a>
95
+ {:else}
96
+ <button
97
+ type="button"
98
+ disabled={item.disabled}
99
+ title={collapsed ? item.label : undefined}
100
+ aria-current={value === item.id ? 'page' : undefined}
101
+ class={itemClass(item)}
102
+ onclick={() => select(item.id, item.disabled)}
103
+ >
104
+ {@render itemGlyph(item)}
105
+ {#if !collapsed}
106
+ <span class="truncate">{item.label}</span>
107
+ {/if}
108
+ </button>
109
+ {/if}
110
+ {/snippet}
111
+
45
112
  <aside
46
113
  class={[
47
- 'flex h-full flex-col border-r border-border bg-surface-elevated transition-[width] duration-200',
114
+ 'border-border bg-surface-elevated flex h-full flex-col border-r transition-[width] duration-200',
48
115
  collapsed ? 'w-[4.25rem]' : 'w-60',
49
116
  className
50
117
  ]}
51
118
  >
52
119
  <div
53
120
  class={[
54
- 'flex items-center gap-2 px-3 py-4',
55
- collapsed ? 'justify-center' : 'justify-between'
121
+ 'gap-2 px-3 py-4 flex items-center',
122
+ collapsed ? 'justify-center' : header || !collapsible ? 'justify-start' : 'justify-between'
56
123
  ]}
57
124
  >
58
- {#if !collapsed}
59
- <span class="truncate text-sm font-semibold text-primary">{brand}</span>
125
+ {#if header && !collapsed}
126
+ <div class="min-w-0 flex-1">{@render header()}</div>
127
+ {:else if !collapsed}
128
+ <span class="text-sm font-semibold text-primary truncate">{brand}</span>
129
+ {/if}
130
+ {#if collapsible}
131
+ <button
132
+ type="button"
133
+ onclick={() => (collapsed = !collapsed)}
134
+ class="rounded-lg p-2 text-secondary hover:bg-surface-overlay hover:text-primary focus-visible:ring-brand-500/30 focus-visible:ring-2 focus-visible:outline-none"
135
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
136
+ >
137
+ <svg
138
+ class="h-4 w-4"
139
+ viewBox="0 0 24 24"
140
+ fill="none"
141
+ stroke="currentColor"
142
+ stroke-width="2"
143
+ aria-hidden="true"
144
+ >
145
+ {#if collapsed}
146
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h10M4 18h16" />
147
+ {:else}
148
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
149
+ {/if}
150
+ </svg>
151
+ </button>
60
152
  {/if}
61
- <button
62
- type="button"
63
- onclick={() => (collapsed = !collapsed)}
64
- class="rounded-lg p-2 text-secondary hover:bg-surface-overlay hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
65
- aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
66
- >
67
- <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
68
- {#if collapsed}
69
- <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h10M4 18h16" />
70
- {:else}
71
- <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
72
- {/if}
73
- </svg>
74
- </button>
75
153
  </div>
76
154
 
77
- <nav class="flex-1 overflow-y-auto px-2 pb-3">
155
+ <nav class="px-2 pb-3 flex-1 overflow-y-auto">
78
156
  {#each groups as group, gi (group.id)}
79
157
  {#if group.label && !collapsed}
80
- <p class="mb-1 mt-3 px-2 text-[11px] font-semibold uppercase tracking-wide text-muted">
158
+ <p class="mb-1 mt-3 px-2 font-semibold tracking-wide text-muted text-[11px] uppercase">
81
159
  {group.label}
82
160
  </p>
83
161
  {:else if gi > 0}
84
162
  <div class="my-2 px-2"><Divider /></div>
85
163
  {/if}
86
- <ul class="flex flex-col gap-0.5">
164
+ <ul class="gap-0.5 flex flex-col">
87
165
  {#each group.items as item (item.id)}
88
166
  <li>
89
- <button
90
- type="button"
91
- disabled={item.disabled}
92
- onclick={() => select(item.id, item.disabled)}
93
- title={collapsed ? item.label : undefined}
94
- class={[
95
- 'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-sm transition-colors',
96
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
97
- value === item.id
98
- ? 'bg-brand-50 font-medium text-brand-700 dark:bg-brand-950/40 dark:text-brand-300'
99
- : 'text-secondary hover:bg-surface-overlay hover:text-primary',
100
- item.disabled && 'cursor-not-allowed opacity-40',
101
- collapsed && 'justify-center'
102
- ]}
103
- >
104
- <span
105
- class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface-overlay text-[11px] font-semibold"
106
- >
107
- {item.label.slice(0, 1)}
108
- </span>
109
- {#if !collapsed}
110
- <span class="truncate">{item.label}</span>
111
- {/if}
112
- </button>
167
+ {@render navItem(item)}
113
168
  </li>
114
169
  {/each}
115
170
  </ul>
@@ -117,7 +172,7 @@
117
172
  </nav>
118
173
 
119
174
  {#if footer}
120
- <div class={['border-t border-border p-3', collapsed && 'flex justify-center']}>
175
+ <div class={['border-border p-3 border-t', collapsed && 'flex justify-center']}>
121
176
  {@render footer()}
122
177
  </div>
123
178
  {/if}
@@ -1,8 +1,14 @@
1
- import type { Snippet } from 'svelte';
1
+ import type { Component, Snippet } from 'svelte';
2
+ export type SidebarIcon = Component<{
3
+ class?: string;
4
+ size?: number | string;
5
+ strokeWidth?: number | string;
6
+ }>;
2
7
  export interface SidebarItem {
3
8
  id: string;
4
9
  label: string;
5
10
  href?: string;
11
+ icon?: SidebarIcon;
6
12
  disabled?: boolean;
7
13
  }
8
14
  export interface SidebarGroup {
@@ -15,10 +21,13 @@ interface SidebarProps {
15
21
  groups?: SidebarGroup[];
16
22
  value?: string;
17
23
  collapsed?: boolean;
24
+ /** Show the collapse toggle. Default true. */
25
+ collapsible?: boolean;
18
26
  class?: string;
27
+ header?: Snippet;
19
28
  footer?: Snippet;
20
29
  onchange?: (id: string) => void;
21
30
  }
22
- declare const Sidebar: import("svelte").Component<SidebarProps, {}, "value" | "collapsed">;
31
+ declare const Sidebar: Component<SidebarProps, {}, "value" | "collapsed">;
23
32
  type Sidebar = ReturnType<typeof Sidebar>;
24
33
  export default Sidebar;
package/dist/index.d.ts CHANGED
@@ -656,7 +656,9 @@ export { default as ToastContainer } from './components/organisms/Toast/ToastCon
656
656
  export { toastStore, toast } from './components/organisms/Toast/toast.svelte.js';
657
657
  export type { Toast, ToastVariant, ToastPosition, ToastAppearance, ToastAction } from './components/organisms/Toast/toast.svelte.js';
658
658
  export { default as Sidebar } from './components/organisms/Sidebar/Sidebar.svelte';
659
- export type { SidebarItem, SidebarGroup } from './components/organisms/Sidebar/Sidebar.svelte';
659
+ export type { SidebarItem, SidebarGroup, SidebarIcon } from './components/organisms/Sidebar/Sidebar.svelte';
660
+ export { default as NavRail } from './components/organisms/NavRail/NavRail.svelte';
661
+ export type { NavRailItem, NavRailIcon } from './components/organisms/NavRail/NavRail.svelte';
660
662
  export { default as Navbar } from './components/organisms/Navbar/Navbar.svelte';
661
663
  export type { NavbarLink } from './components/organisms/Navbar/Navbar.svelte';
662
664
  export { default as BottomNav } from './components/organisms/BottomNav/BottomNav.svelte';
@@ -664,6 +666,8 @@ export type { BottomNavItem, BottomNavIcon } from './components/organisms/Bottom
664
666
  export { default as CommandPalette } from './components/organisms/CommandPalette/CommandPalette.svelte';
665
667
  export type { CommandItem } from './components/organisms/CommandPalette/CommandPalette.svelte';
666
668
  export { default as AppShell } from './components/organisms/AppShell/AppShell.svelte';
669
+ export { getAppChrome, setAppChrome, AppChrome } from './components/organisms/AppShell/app-chrome.svelte.js';
670
+ export type { AppShellContextual } from './components/organisms/AppShell/app-chrome.svelte.js';
667
671
  export { default as EditorShell } from './components/organisms/EditorShell/EditorShell.svelte';
668
672
  export { default as BlueprintEditor } from './components/organisms/BlueprintEditor/BlueprintEditor.svelte';
669
673
  export { default as CanvasEditor } from './components/organisms/CanvasEditor/CanvasEditor.svelte';
package/dist/index.js CHANGED
@@ -454,10 +454,12 @@ export { default as GanttChart } from './components/organisms/GanttChart/GanttCh
454
454
  export { default as ToastContainer } from './components/organisms/Toast/ToastContainer.svelte';
455
455
  export { toastStore, toast } from './components/organisms/Toast/toast.svelte.js';
456
456
  export { default as Sidebar } from './components/organisms/Sidebar/Sidebar.svelte';
457
+ export { default as NavRail } from './components/organisms/NavRail/NavRail.svelte';
457
458
  export { default as Navbar } from './components/organisms/Navbar/Navbar.svelte';
458
459
  export { default as BottomNav } from './components/organisms/BottomNav/BottomNav.svelte';
459
460
  export { default as CommandPalette } from './components/organisms/CommandPalette/CommandPalette.svelte';
460
461
  export { default as AppShell } from './components/organisms/AppShell/AppShell.svelte';
462
+ export { getAppChrome, setAppChrome, AppChrome } from './components/organisms/AppShell/app-chrome.svelte.js';
461
463
  export { default as EditorShell } from './components/organisms/EditorShell/EditorShell.svelte';
462
464
  export { default as BlueprintEditor } from './components/organisms/BlueprintEditor/BlueprintEditor.svelte';
463
465
  export { default as CanvasEditor } from './components/organisms/CanvasEditor/CanvasEditor.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",