@s3pweb/shell-ui 0.1.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.
package/README.md ADDED
@@ -0,0 +1,337 @@
1
+ # @s3pweb/shell-ui
2
+
3
+ Stateless, white-label-ready application shell for React. A single `<Shell>`
4
+ component renders either a **vertical sidebar** or a **horizontal top-nav**
5
+ based on a `navMode` prop. The consumer owns router, i18n, auth, and state —
6
+ Shell owns the chrome.
7
+
8
+ ```
9
+ ┌──────────────┬────────────────────────┐ ┌────────────────────────┐
10
+ │ ▢ Logo │ │ │ Logo ▢ ▢ ▢ ▢ 👤 🌙 │
11
+ │ │ │ ├────────────────────────┤
12
+ │ ▢ Home │ <Outlet /> │ or │ │
13
+ │ ▢ Inbox ⓿ │ │ │ <Outlet /> │
14
+ │ ▼ Reports │ │ │ │
15
+ │ ▢ Settings │ │ │ │
16
+ └──────────────┴────────────────────────┘ └────────────────────────┘
17
+ navMode='sidebar' navMode='top'
18
+ ```
19
+
20
+ ## Design
21
+
22
+ - **No router, no i18n provider, no state library.** Pass `items` with
23
+ `active: boolean` flags and an `onItemSelect` callback. Wire to your router.
24
+ - **Hybrid controlled / uncontrolled.** `collapsed`, `navMode`, and per-group
25
+ `expanded` work zero-config; pass props + callbacks to persist them.
26
+ - **Theming via Tailwind v4 tokens.** All colors live in CSS variables
27
+ (`--color-primary`, `--color-sidebar`, …). Override them per brand without
28
+ forking the component.
29
+ - **No `@s3pweb/*` imports.** Externally consumable as-is.
30
+
31
+ Runtime deps: `react`, `radix-ui`, `lucide-react`, `class-variance-authority`,
32
+ `clsx`, `tailwind-merge`, `tw-animate-css`.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pnpm add @s3pweb/shell-ui
38
+ ```
39
+
40
+ Requires **React 19** and **Tailwind CSS v4** in the consumer app.
41
+
42
+ ## Public API
43
+
44
+ ```ts
45
+ import {
46
+ Shell, // <Shell> — sidebar OR top-nav, switched by navMode prop
47
+ Sidebar, // <Sidebar> — vertical-only, exported for advanced layouts
48
+ SidebarItems, // The nav-items list alone (used for mobile Sheet drawers)
49
+ isNavGroup, // Type guard: NavEntry → NavGroup
50
+ type NavItem,
51
+ type NavGroup,
52
+ type NavEntry, // NavItem | NavGroup
53
+ type SidebarUser, // { name, subtitle?, initials? }
54
+ type ShellLocale, // 'fr' | 'en'
55
+ type ShellProps,
56
+ } from '@s3pweb/shell-ui';
57
+ ```
58
+
59
+ ## Minimal example (outside the monorepo)
60
+
61
+ **`src/index.css`** — Tailwind v4, scan shell-ui sources, import the preset:
62
+
63
+ ```css
64
+ @import 'tailwindcss';
65
+
66
+ /* So Tailwind generates the utility classes shell-ui references. */
67
+ @source "../node_modules/@s3pweb/shell-ui/src/**/*.{ts,tsx}";
68
+
69
+ /* Neutral tokens + dark-variant binding + tw-animate-css.
70
+ Skip if your app already declares --color-primary, --color-sidebar, etc. */
71
+ @import '@s3pweb/shell-ui/preset.css';
72
+
73
+ /* Optional brand theme. */
74
+ @import '@s3pweb/shell-ui/themes/s3pweb.css';
75
+ ```
76
+
77
+ **`src/App.tsx`** — render the shell:
78
+
79
+ ```tsx
80
+ import { useState } from 'react';
81
+ import { BarChart3, FileText, Home, Inbox, Settings } from 'lucide-react';
82
+ import { Shell, type NavEntry } from '@s3pweb/shell-ui';
83
+
84
+ export function App() {
85
+ const [activeId, setActiveId] = useState('inbox');
86
+
87
+ const items: NavEntry[] = [
88
+ { id: 'home', label: 'Home', icon: <Home />, active: activeId === 'home' },
89
+ {
90
+ id: 'inbox', label: 'Inbox', icon: <Inbox />, active: activeId === 'inbox',
91
+ badge: <span className="rounded-full bg-primary px-1.5 text-xs text-primary-foreground">12</span>,
92
+ },
93
+ {
94
+ id: 'reports', label: 'Reports', icon: <BarChart3 />,
95
+ active: activeId.startsWith('reports-'),
96
+ items: [
97
+ { id: 'reports-monthly', label: 'Monthly', icon: <FileText />, active: activeId === 'reports-monthly' },
98
+ { id: 'reports-quarterly', label: 'Quarterly', icon: <FileText />, active: activeId === 'reports-quarterly' },
99
+ ],
100
+ },
101
+ { id: 'settings', label: 'Settings', icon: <Settings />, active: activeId === 'settings' },
102
+ ];
103
+
104
+ return (
105
+ <Shell
106
+ items={items}
107
+ locale="en"
108
+ logo={<span className="text-lg font-semibold">ACME</span>}
109
+ user={{ name: 'Alex Dev', subtitle: 'alex@acme.com' }}
110
+ onItemSelect={(item) => setActiveId(item.id)}
111
+ onLogout={() => signOut()}
112
+ onThemeToggle={() => document.documentElement.classList.toggle('dark')}
113
+ >
114
+ <main className="p-8">
115
+ <h1 className="text-2xl font-semibold">Active: {activeId}</h1>
116
+ </main>
117
+ </Shell>
118
+ );
119
+ }
120
+ ```
121
+
122
+ No provider stack, no router required. Pass `navMode='top'` (or wire
123
+ `onNavModeChange`) to swap chrome layout without touching the rest of the code.
124
+
125
+ ## Wiring inside the s3pweb monorepo
126
+
127
+ Apps don't render `<Shell>` directly — they go through `MainLayout` in
128
+ `@s3pweb/shared-layouts`, which bridges Zustand (`useUIStore`) → Shell props:
129
+
130
+ ```tsx
131
+ // apps/s3pweb/src/app/main-layout.tsx
132
+ <SharedMainLayout
133
+ logo={<S3pwebLogo />}
134
+ navigationItems={navigationItems}
135
+ bgClassName="bg-white"
136
+ variant="corporate"
137
+ enableNavModeToggle
138
+ user={user ? { name: user.fullName, subtitle: user.email } : undefined}
139
+ onLogout={logout}
140
+ />
141
+ ```
142
+
143
+ ```css
144
+ /* apps/s3pweb/src/index.css */
145
+ @import 'tailwindcss';
146
+ @import 'tw-animate-css';
147
+
148
+ @source "../../../packages/shell-ui/src/**/*.{ts,tsx}";
149
+ /* ...other @source dirs... */
150
+
151
+ @import '@s3pweb/shared-ui/themes/base.css';
152
+ @import './theme/s3pweb.css';
153
+ @import '@s3pweb/shell-ui/themes/s3pweb.css';
154
+ ```
155
+
156
+ `MainLayout` reads `collapsed`, `navMode`, `theme`, `expandedNavGroups` from
157
+ `useUIStore`, maps the app's `NavEntry` (with React Router metadata) into
158
+ shell-ui's `NavEntry`, and renders `<Shell>` with a router-aware
159
+ `onItemSelect={(item) => navigate(item.href)}`.
160
+
161
+ ## Brand themes
162
+
163
+ Two pre-built brand themes ship with the package. They redefine the neutral
164
+ tokens and add per-module decoration (icon chips, active-item accent line/dot).
165
+
166
+ ```css
167
+ @import '@s3pweb/shell-ui/themes/s3pweb.css';
168
+ /* or */
169
+ @import '@s3pweb/shell-ui/themes/aftral.css';
170
+ ```
171
+
172
+ Themes are scoped under `[data-shell-theme="s3pweb"]` (or `"aftral"`), so
173
+ multiple brands can coexist in the same document. Activate one by setting the
174
+ attribute on `<html>` or any ancestor:
175
+
176
+ ```html
177
+ <html data-shell-theme="s3pweb">
178
+ ...
179
+ </html>
180
+ ```
181
+
182
+ Combine with the standard `.dark` class for dark mode:
183
+
184
+ ```html
185
+ <html class="dark" data-shell-theme="s3pweb">
186
+ ```
187
+
188
+ To roll your own theme, override the tokens in `:root` (or a scoped selector):
189
+
190
+ ```css
191
+ :root {
192
+ --color-primary: oklch(0.3 0.1 240);
193
+ --color-primary-foreground: oklch(0.98 0 0);
194
+ --color-cta: oklch(0.85 0.15 90);
195
+ --color-sidebar: oklch(0.97 0 0);
196
+ }
197
+ ```
198
+
199
+ ## Per-module decoration (`slug`)
200
+
201
+ Every `NavEntry` is rendered with `data-shell-ui-nav-slug="..."` on three
202
+ surfaces — the sidebar group, the sidebar flat leaf, and the top-bar button —
203
+ so brand CSS can paint a module-specific chip in all three layouts with a
204
+ single rule.
205
+
206
+ **`slug` defaults to `id`** — only set it explicitly when you need a
207
+ CSS-friendly theme key that's different from your id (e.g. your `id` is a
208
+ route path like `/incidents` or a UUID like `inc-1234`):
209
+
210
+ ```tsx
211
+ { id: '/incidents', slug: 'incidents', label: 'Incidents', icon: <Bell />, items: [...] }
212
+ ```
213
+
214
+ ### Canonical palette rule
215
+
216
+ To paint one slug, list all three selectors in a single rule (they share
217
+ declarations). The `:not([data-shell-ui-nav-children] *)` clause excludes
218
+ sub-items inside an expanded group so only top-level items get the chip.
219
+
220
+ ```css
221
+ [data-shell-theme='s3pweb'] [data-shell-ui-nav-slug='foo'] [data-shell-ui-nav-group-button] [data-shell-ui-nav-icon],
222
+ [data-shell-theme='s3pweb'] [data-shell-ui-nav-item][data-shell-ui-nav-slug='foo']:not([data-shell-ui-nav-children] *) [data-shell-ui-nav-icon],
223
+ [data-shell-theme='s3pweb'] [data-shell-ui-top-bar-button][data-shell-ui-nav-slug='foo'] {
224
+ background: var(--color-emerald-100);
225
+ color: var(--color-emerald-700);
226
+ }
227
+ .dark [data-shell-theme='s3pweb'] [data-shell-ui-nav-slug='foo'] [data-shell-ui-nav-group-button] [data-shell-ui-nav-icon],
228
+ .dark [data-shell-theme='s3pweb'] [data-shell-ui-nav-item][data-shell-ui-nav-slug='foo']:not([data-shell-ui-nav-children] *) [data-shell-ui-nav-icon],
229
+ .dark [data-shell-theme='s3pweb'] [data-shell-ui-top-bar-button][data-shell-ui-nav-slug='foo'] {
230
+ background: color-mix(in oklab, var(--color-emerald-700) 28%, transparent);
231
+ color: var(--color-emerald-300);
232
+ }
233
+ ```
234
+
235
+ ### Shade tiers
236
+
237
+ Tailwind 4.2.1 ships every family as `--color-{family}-{shade}`. Pick the tier
238
+ that matches the family's chroma:
239
+
240
+ - **Saturated** (red, orange, amber, yellow, lime, green, emerald, teal, cyan,
241
+ sky, blue, indigo, violet, purple, fuchsia, pink, rose) —
242
+ light: `bg=*-100 / fg=*-700`; dark: `bg=color-mix(*-700 28%, transparent) / fg=*-300`.
243
+ - **Desaturated** (slate, gray, zinc, neutral, stone, olive) —
244
+ light: `bg=*-200 / fg=*-500`; dark: `bg=color-mix(*-500 30%, transparent) / fg=*-300`.
245
+ Their `*-700` reads almost black against a `*-100` bg, so the tier shifts.
246
+ - **One-step darker** (when two slugs share a family and you want them distinct) —
247
+ light: `bg=*-300 / fg=*-800`; dark: `bg=color-mix(*-800 36%, transparent) / fg=*-300`.
248
+
249
+ The shipped `themes/s3pweb.css` keeps the per-slug palette block empty by
250
+ default (every chip neutral) — see the long comment in that file for a
251
+ copy-paste-ready template; uncomment and add rules per module as needed.
252
+
253
+ ## Slots & extras
254
+
255
+ | Prop | Where | Use case |
256
+ |--------------------------|-----------------------------|---------------------------------------|
257
+ | `logo` / `collapsedLogo` | Sidebar top / top-bar left | Brand mark |
258
+ | `topSlot` | Sidebar, above the nav list | Entity selector, search box |
259
+ | `userSection` | Sidebar footer | Custom user widget (replaces default) |
260
+ | `footer` / `footerExtra` | Sidebar footer | Status pill, secondary CTA |
261
+ | `bgClassName` | Sidebar `<aside>` / `<header>` | `bg-white`, `bg-sidebar`, etc. |
262
+ | `mainClassName` | The `<Outlet />` wrapper | Padding, scroll behavior |
263
+ | `renderCollapsedTooltip` | Collapsed sidebar tooltip | Override default tooltip content |
264
+
265
+ ## Locale
266
+
267
+ Built-in button labels (Collapse / Expand / Logout / Dark mode / etc.) are
268
+ embedded in the package. Pass `locale="fr"` (default) or `locale="en"` — no
269
+ i18n provider needed. The strings are exposed via `getShellStrings(locale)`
270
+ for advanced use.
271
+
272
+ ## Storybook
273
+
274
+ ```bash
275
+ pnpm --filter @s3pweb/shell-ui storybook # dev server on :6006
276
+ pnpm --filter @s3pweb/shell-ui build-storybook # static build to ./storybook-static
277
+ ```
278
+
279
+ Three stories ship out of the box:
280
+
281
+ - **Shells / S3pweb** — replicates the s3pweb SaaS nav (Carte, Messagerie,
282
+ Éco-conduite, Maintenance, Social, Incidents, Prise de poste, Km/Carburant,
283
+ Nibelis).
284
+ - **Shells / Aftral** — replicates the AFTRAL nav (Eco-driving + Coaching, plus
285
+ the Administration group for non-AFTRAL users).
286
+ - **Playground / Configurable** — generic shell with Storybook Controls for
287
+ theme, locale, badges, groups, user/logo/toggles visibility. Events stream
288
+ to the Actions panel.
289
+
290
+ Switch between light/dark mode via the Storybook toolbar.
291
+
292
+ ## Data types
293
+
294
+ ```ts
295
+ interface NavItem {
296
+ id: string;
297
+ label: string;
298
+ icon?: ReactNode;
299
+ active?: boolean;
300
+ badge?: ReactNode;
301
+ href?: string; // Renders as <a href> for native browser semantics
302
+ meta?: unknown; // Free-form payload, passed back via onItemSelect
303
+ }
304
+
305
+ interface NavGroup {
306
+ id: string;
307
+ label: string;
308
+ icon?: ReactNode;
309
+ items: NavItem[];
310
+ active?: boolean;
311
+ expanded?: boolean;
312
+ slug?: string; // data-shell-ui-nav-slug for per-module theming
313
+ }
314
+
315
+ type NavEntry = NavItem | NavGroup;
316
+ ```
317
+
318
+ ## Publishing externally
319
+
320
+ This package is `"private": true` today. To publish to a registry:
321
+
322
+ 1. Build to `dist/` (e.g. `tsup --dts --format esm`)
323
+ 2. Set `"private": false` + bump `"version"`
324
+ 3. Update `"exports"` to point at `./dist/*` instead of `./src/*`
325
+ 4. Configure `publishConfig` for your registry
326
+ 5. `pnpm publish`
327
+
328
+ The current `"exports"` entries:
329
+
330
+ ```jsonc
331
+ {
332
+ ".": "./src/index.ts",
333
+ "./preset.css": "./src/preset.css",
334
+ "./themes/s3pweb.css": "./src/themes/s3pweb.css",
335
+ "./themes/aftral.css": "./src/themes/aftral.css"
336
+ }
337
+ ```
@@ -0,0 +1,45 @@
1
+ import { ReactNode } from 'react';
2
+ import { ShellAction } from '../types';
3
+ export interface UseActionsMenuOptions {
4
+ /** The full set of actions the user can pin / unpin. */
5
+ actions: ShellAction[];
6
+ /** localStorage key for the pinned id set. Default `'shell-ui:pinnedActions'`. */
7
+ storageKey?: string;
8
+ /**
9
+ * Action IDs pinned by default — used the first time the hook mounts
10
+ * (no localStorage entry yet). Default: every action ID (all pinned).
11
+ */
12
+ defaultPinned?: string[];
13
+ /** Menu trigger label / panel heading. Default `'Actions'`. */
14
+ menuLabel?: string;
15
+ /** Menu trigger placement in the chrome cluster. Default `'trailing'`. */
16
+ menuPlacement?: 'leading' | 'trailing';
17
+ /** Draw a divider after the menu trigger. Default false. */
18
+ menuDividerAfter?: boolean;
19
+ /** Override the menu trigger icon. Default lucide `Puzzle`. */
20
+ menuIcon?: ReactNode;
21
+ /** Override the menu trigger's action id. Default `'__actions-menu__'`. */
22
+ menuId?: string;
23
+ /** Labels for the pin toggle button (i18n). */
24
+ pinLabel?: string;
25
+ unpinLabel?: string;
26
+ }
27
+ export interface UseActionsMenuReturn {
28
+ /** Pass straight to `<Shell actions={...}>`: pinned subset + the menu trigger appended. */
29
+ visibleActions: ShellAction[];
30
+ /** Set of currently-pinned action IDs. */
31
+ pinnedIds: Set<string>;
32
+ /** Pin / unpin imperatively (alongside the panel's pin buttons). */
33
+ togglePin: (id: string) => void;
34
+ }
35
+ export declare function useActionsMenu({ actions, storageKey, defaultPinned, menuLabel, menuPlacement, menuDividerAfter, menuIcon, menuId, pinLabel, unpinLabel, }: UseActionsMenuOptions): UseActionsMenuReturn;
36
+ export interface ActionsMenuPanelProps {
37
+ actions: ShellAction[];
38
+ pinnedIds: Set<string>;
39
+ onTogglePin: (id: string) => void;
40
+ title?: string;
41
+ pinLabel?: string;
42
+ unpinLabel?: string;
43
+ className?: string;
44
+ }
45
+ export declare function ActionsMenuPanel({ actions, pinnedIds, onTogglePin, title, pinLabel, unpinLabel, className }: ActionsMenuPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { VariantProps } from 'class-variance-authority';
2
+ import * as React from 'react';
3
+ /**
4
+ * Button — shadcn/ui Button installed via `npx shadcn@latest add button`,
5
+ * customized with the s3pweb design-system values (rounded-sm, font-semibold,
6
+ * 38px icon size, brand-tuned ghost hover) so the shell's top-bar / footer
7
+ * buttons render identically to the live SaaS shell.
8
+ */
9
+ declare const buttonVariants: (props?: ({
10
+ variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | null | undefined;
11
+ size?: "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
12
+ } & import('class-variance-authority/types').ClassProp) | undefined) => string;
13
+ declare function Button({ className, variant, size, asChild, ...props }: React.ComponentProps<'button'> & VariantProps<typeof buttonVariants> & {
14
+ asChild?: boolean;
15
+ }): import("react/jsx-runtime").JSX.Element;
16
+ export { Button, buttonVariants };
@@ -0,0 +1,37 @@
1
+ import { Partner } from './partner-cluster';
2
+ /**
3
+ * EcosystemMegaPanel — the "L'écosystème …" popover panel from the partners
4
+ * maquette, ported to React. Designed to live inside a Radix Popover (passed
5
+ * via `PartnerCluster.popoverContent` or `ShellAction.clickContent`), with
6
+ * panel-internal links wrapped in `<PopoverClose asChild>` so a click both
7
+ * dismisses the popover and opens the partner site in a new tab.
8
+ *
9
+ * Built-in fr/en strings, override any of them via the explicit props. Any
10
+ * Partner with an `href` opens that URL in a new tab; partners without `href`
11
+ * still render but their link is non-functional (href='#').
12
+ */
13
+ export interface EcosystemMegaPanelProps {
14
+ /** Partners to render. */
15
+ partners: Partner[];
16
+ /** Heading line. Default: 'L'écosystème' / 'The ecosystem'. */
17
+ title?: string;
18
+ /** Sub-heading paragraph. Default: localised generic. */
19
+ subtitle?: string;
20
+ /** Footer-left label. Default: '{N} services connectés à votre compte'. */
21
+ connectedLabel?: string;
22
+ /** Footer-right action label. Default: 'Tout découvrir →' / 'Discover all →'. */
23
+ seeAllLabel?: string;
24
+ /** When set, the "see all" footer item renders as an `<a target='_blank' href>`. */
25
+ seeAllHref?: string;
26
+ /** Click callback for the "see all" footer (fires alongside href navigation if both are set). */
27
+ onSeeAllClick?: () => void;
28
+ /** Click callback when a partner row is clicked. Fires alongside href navigation. */
29
+ onPartnerClick?: (partner: Partner) => void;
30
+ /** Show the 'Nous' / 'Part.' kind tag next to each name. Default true. */
31
+ showKindTag?: boolean;
32
+ /** Locale for built-in strings. Default 'fr'. */
33
+ locale?: 'fr' | 'en';
34
+ /** Extra Tailwind classes on the panel root (override width / padding). */
35
+ className?: string;
36
+ }
37
+ export declare function EcosystemMegaPanel({ partners, title, subtitle, connectedLabel, seeAllLabel, seeAllHref, onSeeAllClick, onPartnerClick, showKindTag, locale, className, }: EcosystemMegaPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { HoverCard as HoverCardPrimitive } from 'radix-ui';
2
+ import * as React from 'react';
3
+ /**
4
+ * HoverCard — shadcn-style wrapper around Radix HoverCard, mirroring the
5
+ * brand-themed Tooltip in `./tooltip.tsx`. Use it for any rich-content popup
6
+ * that should open on hover (e.g. a partners list, user-profile preview,
7
+ * avatar tooltip with metadata). When you need keyboard-equivalent click
8
+ * semantics or a focus-trapped menu, reach for DropdownMenu instead.
9
+ *
10
+ * Defaults: openDelay 200ms / closeDelay 100ms — felt right against the
11
+ * existing Tooltip's instant open. Override per-instance when needed.
12
+ */
13
+ declare function HoverCard({ openDelay, closeDelay, ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>): import("react/jsx-runtime").JSX.Element;
14
+ declare function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>): import("react/jsx-runtime").JSX.Element;
15
+ declare function HoverCardContent({ className, align, sideOffset, children, ...props }: React.ComponentProps<typeof HoverCardPrimitive.Content>): import("react/jsx-runtime").JSX.Element;
16
+ export { HoverCard, HoverCardTrigger, HoverCardContent };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * LayoutSwitcher — ports the `s3p-app-header--layoutButton` from the s3pweb
3
+ * Stencil app into shell-ui as a **stateless, click-emitting** button. The
4
+ * consumer owns the layout state (typically a `useState` persisted to
5
+ * `localStorage`), and is responsible for opening whatever picker modal /
6
+ * drawer wires up to the click.
7
+ *
8
+ * If the consumer wants the keyboard-shortcut behaviour from the original
9
+ * (pressing 1-9 / AZERTY `& é " ' ( - è _ ç` to jump to a layout), they wire
10
+ * a `window.addEventListener('keydown')` themselves — the component does NOT
11
+ * attach any global listener.
12
+ *
13
+ * Use the `variant` prop to render:
14
+ * - **compact** (default, top-bar): 38×38 ghost-button with the
15
+ * `LayoutGrid` icon + a small primary-coloured badge in the
16
+ * bottom-right corner showing the current value.
17
+ * - **row** (expanded sidebar): full-width footer row matching the other
18
+ * sidebar toggles — icon + label + badge on the right.
19
+ *
20
+ * For collapsed-sidebar mode (where shell-ui falls back to the standard
21
+ * icon button), use `<LayoutNumberIcon>` as the action's `icon` so the
22
+ * number badge is still visible at 16px.
23
+ */
24
+ export interface LayoutSwitcherProps {
25
+ /** Current layout value (typically 1-9). Rendered as the badge. */
26
+ value: number;
27
+ /** Fires when the trigger is clicked. Consumer opens its picker / modal here. */
28
+ onClick?: () => void;
29
+ /**
30
+ * Visual variant:
31
+ * - 'compact' (default): 38×38 ghost-button, icon + corner badge.
32
+ * - 'row': h-8 full-width row, icon + label + trailing badge.
33
+ */
34
+ variant?: 'compact' | 'row';
35
+ /** Title (native tooltip + aria-label prefix). Default 'Gérer les dispositions'. */
36
+ title?: string;
37
+ /** Label shown in the `row` variant. Default = `title`. */
38
+ label?: string;
39
+ /** Extra Tailwind classes on the root button. */
40
+ className?: string;
41
+ }
42
+ export declare function LayoutSwitcher({ value, onClick, variant, title, label, className }: LayoutSwitcherProps): import("react/jsx-runtime").JSX.Element;
43
+ /**
44
+ * Icon-slot variant of LayoutSwitcher: a 16×16 `LayoutGrid` with a small
45
+ * primary-coloured number badge overflowing the bottom-right corner. Use
46
+ * as `ShellAction.icon` when the action's `customTrigger` is skipped (e.g.
47
+ * in the collapsed-sidebar fallback) but you still want the badge visible.
48
+ */
49
+ export declare function LayoutNumberIcon({ value, className }: {
50
+ value: number;
51
+ className?: string;
52
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,111 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * Partner-cluster widget — replicates the "Nos solutions" header pattern from
4
+ * the s3pweb partners maquette (claude-maquette-partners). Use it as the
5
+ * trigger of a `ShellAction` with `clickContent` / `hoverContent` set, e.g.:
6
+ *
7
+ * { id: 'solutions', label: 'Nos solutions',
8
+ * icon: <Layers />, // sidebar fallback
9
+ * customTrigger: <PartnerCluster partners={ECOSYSTEM} chipBorderColor='white' />,
10
+ * clickContent: <YourMegaPanel /> }
11
+ *
12
+ * The cluster paints a small label, the first N partner tiles overlapped
13
+ * (-7px stride, ringed with `chipBorderColor` to mask the overlap against the
14
+ * chrome bg), and a "+M" overflow chip when there are more partners than
15
+ * `maxVisible`. Each tile uses a 16% tint of the partner's brand colour for
16
+ * its bg and the full brand colour for the mark stroke.
17
+ */
18
+ export interface Partner {
19
+ id: string;
20
+ name: string;
21
+ /** Brand hex colour (e.g. '#2E6BE6'). */
22
+ color: string;
23
+ /**
24
+ * Brand mark — rendered inside the tile as-is. Pass any ReactNode:
25
+ * - SVG paths wrapped via `svgMark(...)` (or your own `<svg>` element)
26
+ * - `<img src='/favicons/x.png' alt='X' />` for raster brand icons
27
+ * - any custom component
28
+ *
29
+ * The tile constrains the mark to ~60% of its width/height; any inner
30
+ * `<svg>` or `<img>` automatically fills that slot.
31
+ */
32
+ mark: React.ReactNode;
33
+ /** Optional one-line tagline shown in detail / popover layouts. */
34
+ tagline?: string;
35
+ /** Optional category — free-form (e.g. 'Financement', 'Optimisation'). */
36
+ category?: string;
37
+ /** Optional taxonomy tag — 'solution' = ours, 'partner' = external. */
38
+ kind?: 'solution' | 'partner';
39
+ /** Optional URL — handy for popover content that links to the partner site. */
40
+ href?: string;
41
+ }
42
+ /**
43
+ * Mix a hex colour toward white by `ratio` (0 = white, 1 = full colour).
44
+ * Mirrors `ECO.tint()` in the maquette's ecosystem.js. Returns an rgb() string.
45
+ */
46
+ export declare function tintColor(hex: string, ratio?: number): string;
47
+ /**
48
+ * Convenience helper for the most common Partner.mark case: raw SVG path
49
+ * content lifted from a design source. Wraps the paths in a 24×24 viewBox
50
+ * with `stroke=currentColor` so the brand colour cascades from the tile.
51
+ *
52
+ * mark: svgMark(<><circle cx='12' cy='12' r='8'/><path d='...'/></>)
53
+ */
54
+ export declare function svgMark(paths: React.ReactNode, opts?: {
55
+ strokeWidth?: number;
56
+ }): React.ReactNode;
57
+ export interface PartnerTileProps extends React.HTMLAttributes<HTMLSpanElement> {
58
+ partner: Partner;
59
+ /** Outer square edge in px. Default 30. */
60
+ size?: number;
61
+ /** Solid mode: bg=brand colour, glyph=white. Default tinted bg + brand glyph. */
62
+ solid?: boolean;
63
+ /** Border-radius in px. Defaults to ~28% of `size`. */
64
+ radius?: number;
65
+ /** Optional 2px ring colour painted around the tile (used inside PartnerCluster). */
66
+ ringColor?: string;
67
+ }
68
+ export declare function PartnerTile({ partner, size, solid, radius, ringColor, className, style, title, ...props }: PartnerTileProps): import("react/jsx-runtime").JSX.Element;
69
+ export interface PartnerClusterProps extends React.HTMLAttributes<HTMLDivElement> {
70
+ partners: Partner[];
71
+ /** Tiles shown before the +N overflow chip. Default 4. */
72
+ maxVisible?: number;
73
+ /**
74
+ * Small label shown to the left of the tiles. Default: no label.
75
+ * Pass a string (e.g. 'Nos solutions') to display one.
76
+ */
77
+ label?: string | null;
78
+ /** Tile edge in px. Default 30. */
79
+ tileSize?: number;
80
+ /**
81
+ * Colour painted on the 2px ring around each tile / the +N chip to mask the
82
+ * -7px overlap against the chrome's background. Should match the
83
+ * surrounding bg — defaults to `var(--color-sidebar)`. Pass `'white'` (or
84
+ * a Tailwind colour variable) when the chrome uses a different bg.
85
+ */
86
+ chipBorderColor?: string;
87
+ /**
88
+ * Content rendered inside the click-Popover anchored to the +N overflow
89
+ * chip. When the cluster has no overflow (maxVisible >= partners.length)
90
+ * the popover won't have a trigger — keep `maxVisible` below
91
+ * `partners.length` if you rely on this.
92
+ */
93
+ popoverContent?: React.ReactNode;
94
+ /** Popover side. Default 'bottom' (top-bar context). Use 'right' when the cluster sits in a sidebar. */
95
+ popoverSide?: 'top' | 'right' | 'bottom' | 'left';
96
+ /** Popover align. Default 'end'. */
97
+ popoverAlign?: 'start' | 'center' | 'end';
98
+ /** Popover offset from the trigger in px. Default 8. */
99
+ popoverSideOffset?: number;
100
+ /** Locale for built-in labels (the overflow chip title). Default 'fr'. */
101
+ locale?: 'fr' | 'en';
102
+ }
103
+ /**
104
+ * The cluster is NOT a single button. Each visible tile is an independent
105
+ * `<a target='_blank'>` when its partner has an `href` (otherwise a
106
+ * non-interactive `<span>`), and the `+N` chip is a separate `<button>` that
107
+ * opens `popoverContent` in a Popover. Effect: clicking a tile goes straight
108
+ * to that partner's site, clicking `+N` opens the full panel — matching the
109
+ * maquette's UX.
110
+ */
111
+ export declare function PartnerCluster({ partners, maxVisible, label, tileSize, chipBorderColor, popoverContent, popoverSide, popoverAlign, popoverSideOffset, locale, className, ...props }: PartnerClusterProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { Popover as PopoverPrimitive } from 'radix-ui';
2
+ import * as React from 'react';
3
+ /**
4
+ * Popover — shadcn-style wrapper around Radix Popover, mirroring the
5
+ * brand-themed Tooltip + HoverCard primitives. Use it for click-triggered
6
+ * rich-content popups (vs. HoverCard's hover semantics).
7
+ */
8
+ declare function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>): import("react/jsx-runtime").JSX.Element;
9
+ declare function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>): import("react/jsx-runtime").JSX.Element;
10
+ /**
11
+ * Use to decouple the popover's positioning anchor from its click trigger.
12
+ * Typical case: a row of items where ONE small button opens the popover, but
13
+ * the popover should anchor to the whole row so it doesn't move when the
14
+ * trigger's `transform` changes on hover.
15
+ */
16
+ declare function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>): import("react/jsx-runtime").JSX.Element;
17
+ /**
18
+ * Wraps a child element so clicking it closes the popover (Radix forwards the
19
+ * click + close to the underlying element via `asChild`). Use to make
20
+ * panel-internal links / buttons dismiss the popover before navigating.
21
+ */
22
+ declare function PopoverClose({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Close>): import("react/jsx-runtime").JSX.Element;
23
+ declare function PopoverContent({ className, align, sideOffset, children, ...props }: React.ComponentProps<typeof PopoverPrimitive.Content>): import("react/jsx-runtime").JSX.Element;
24
+ export { Popover, PopoverTrigger, PopoverAnchor, PopoverClose, PopoverContent };