@inwink-mcp/backoffice-extension-helpers 1.0.0 → 1.0.2

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 (26) hide show
  1. package/dist/cli.js +2 -1
  2. package/package.json +1 -1
  3. package/templates/test-app/src/components/Header.tsx +36 -0
  4. package/templates/test-app/src/pages/HomePage.tsx.tpl +16 -14
  5. package/templates/vite-extension/AGENTS.md.tpl +61 -3
  6. package/templates/vite-extension/extension.manifest.json.tpl +1 -1
  7. package/templates/vite-extension/index.html.tpl +2 -0
  8. package/templates/vite-extension/src/App.tsx.tpl +14 -8
  9. package/templates/vite-extension/src/auth/AuthProvider.tsx +36 -18
  10. package/templates/vite-extension/src/components/AudiencePicker.tsx +25 -0
  11. package/templates/vite-extension/src/components/CommunityPicker.tsx +25 -0
  12. package/templates/vite-extension/src/components/ContextCombobox.css +110 -0
  13. package/templates/vite-extension/src/components/ContextCombobox.tsx +167 -0
  14. package/templates/vite-extension/src/components/EventPicker.tsx +25 -0
  15. package/templates/vite-extension/src/components/Header.css +25 -0
  16. package/templates/vite-extension/src/components/Header.tsx +37 -0
  17. package/templates/vite-extension/src/components/Layout.css +13 -0
  18. package/templates/vite-extension/src/components/Layout.tsx +19 -0
  19. package/templates/vite-extension/src/context/InwinkContextProvider.tsx +170 -0
  20. package/templates/vite-extension/src/hooks/useContextOptions.ts +106 -0
  21. package/templates/vite-extension/src/hooks/useInwinkApi.ts +53 -50
  22. package/templates/vite-extension/src/hooks/useInwinkContext.ts +13 -0
  23. package/templates/vite-extension/src/lib/inwinkContextParams.ts +80 -0
  24. package/templates/vite-extension/src/main.tsx +4 -1
  25. package/templates/vite-extension/src/pages/HomePage.tsx.tpl +22 -18
  26. package/templates/vite-extension/src/styles.css +68 -9
package/dist/cli.js CHANGED
@@ -252,7 +252,8 @@ function bootstrap(opts) {
252
252
  error(`La cible existe et n'est pas un dossier : ${destDir}`);
253
253
  return 1;
254
254
  }
255
- const entries = fs3.readdirSync(destDir);
255
+ const IGNORED_DIRS = /* @__PURE__ */ new Set([".claude", ".github", ".git", ".vscode", ".idea", ".cursor"]);
256
+ const entries = fs3.readdirSync(destDir).filter((e) => !IGNORED_DIRS.has(e));
256
257
  if (entries.length > 0 && !opts.force) {
257
258
  error(`Le dossier cible n'est pas vide : ${destDir}`);
258
259
  info("Utilisez --force pour \xE9crire malgr\xE9 tout, ou --dir vers un dossier vide.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inwink-mcp/backoffice-extension-helpers",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "author": "inwink",
5
5
  "license": "ISC",
6
6
  "description": "CLI pour bootstrapper, packager et vérifier les extensions backoffice inwink.",
@@ -0,0 +1,36 @@
1
+ import './Header.css';
2
+ import { FiLogIn, FiLogOut } from 'react-icons/fi';
3
+ import { useAuth } from '../hooks/useAuth';
4
+ import EventPicker from './EventPicker';
5
+
6
+ interface HeaderProps {
7
+ title: string;
8
+ }
9
+
10
+ // Version surchargée du Header par l'overlay "test-app".
11
+ // Démontre qu'on peut personnaliser le Header (ici : montage d'un EventPicker)
12
+ // sans toucher au Layout ni à App — et force la compilation des pickers + du
13
+ // contexte inwink dans le build de test.
14
+ export default function Header({ title }: HeaderProps) {
15
+ const { isAuthenticated, isLoading, login, logout } = useAuth();
16
+
17
+ return (
18
+ <header className="app-header">
19
+ <div className="app-header__brand">{title}</div>
20
+ <div className="app-header__actions">
21
+ {isAuthenticated && <EventPicker />}
22
+ {isAuthenticated ? (
23
+ <button onClick={() => void logout()}>
24
+ <FiLogOut aria-hidden />
25
+ Se déconnecter
26
+ </button>
27
+ ) : (
28
+ <button className="btn-primary" disabled={isLoading} onClick={() => void login()}>
29
+ <FiLogIn aria-hidden />
30
+ {isLoading ? 'Initialisation…' : 'Se connecter'}
31
+ </button>
32
+ )}
33
+ </div>
34
+ </header>
35
+ );
36
+ }
@@ -1,13 +1,16 @@
1
1
  import { useState } from 'react';
2
- import { FiLogIn, FiLogOut, FiRefreshCw } from 'react-icons/fi';
2
+ import { FiRefreshCw } from 'react-icons/fi';
3
3
  import { useAuth } from '../hooks/useAuth';
4
+ import { useInwinkContext } from '../hooks/useInwinkContext';
4
5
  import { useInwinkEventApi } from '../hooks/useInwinkEventApi';
5
6
 
6
7
  // Version de HomePage générée par le template overlay "test-app".
7
- // Sert à vérifier que le squelette bootstrappé compile et que les hooks API
8
- // (ici useInwinkEventApi) sont utilisables.
8
+ // Sert à vérifier que le squelette bootstrappé compile et que les hooks
9
+ // (useInwinkEventApi, useInwinkContext) sont utilisables. Le login/logout vit
10
+ // désormais dans le Header (lui-même surchargé par cet overlay).
9
11
  export default function HomePage() {
10
- const { isAuthenticated, isLoading, login, logout, user } = useAuth();
12
+ const { isAuthenticated, user } = useAuth();
13
+ const { scope, eventId, communityId, tenantId } = useInwinkContext();
11
14
  const eventApi = useInwinkEventApi();
12
15
  const [result, setResult] = useState<string>('');
13
16
  const [error, setError] = useState<string>('');
@@ -28,28 +31,27 @@ export default function HomePage() {
28
31
  }
29
32
 
30
33
  return (
31
- <main>
34
+ <section>
32
35
  <h1>__DISPLAY_NAME__ — test API Event</h1>
33
36
  {!isAuthenticated ? (
34
- <button disabled={isLoading} onClick={() => void login()}>
35
- <FiLogIn aria-hidden />
36
- {isLoading ? 'Initialisation...' : 'Se connecter'}
37
- </button>
37
+ <p>Connectez-vous via le bouton « Se connecter » en haut à droite.</p>
38
38
  ) : (
39
39
  <>
40
40
  <p>Connecté en tant que : {user?.profile.email ?? 'Inconnu'}</p>
41
+ <p>
42
+ Contexte courant : {scope ?? 'aucun'}
43
+ {eventId ? ` — événement ${eventId}` : ''}
44
+ {communityId ? ` — communauté ${communityId}` : ''}
45
+ {tenantId ? ` — audience ${tenantId}` : ''}
46
+ </p>
41
47
  <button onClick={() => void callEventApi()}>
42
48
  <FiRefreshCw aria-hidden />
43
49
  Appeler l'API Event
44
50
  </button>
45
- <button onClick={() => void logout()}>
46
- <FiLogOut aria-hidden />
47
- Se déconnecter
48
- </button>
49
51
  {result && <pre>{result}</pre>}
50
52
  {error && <p role="alert">Erreur : {error}</p>}
51
53
  </>
52
54
  )}
53
- </main>
55
+ </section>
54
56
  );
55
57
  }
@@ -25,9 +25,20 @@ __NAME__/
25
25
  ├── vite.config.ts # Config Vite (base: './' + copie du manifest)
26
26
  ├── scripts/zip-build.mjs # Packaging zip {name}-{version}.zip
27
27
  └── src/
28
- ├── main.tsx # Bootstrap React (HashRouter + AuthProvider)
29
- ├── App.tsx # Routes
30
- ├── styles.css # Styles
28
+ ├── main.tsx # Bootstrap React (HashRouter + AuthProvider + InwinkContextProvider)
29
+ ├── App.tsx # Routes (via Layout + Outlet)
30
+ ├── styles.css # Styles globaux : polices, tokens (couleurs/accent), base
31
+ ├── components/
32
+ │ ├── Layout.tsx + .css # Gabarit : Header + zone de contenu (Outlet)
33
+ │ ├── Header.tsx + .css # En-tête (marque + login/logout) — surchargeable
34
+ │ ├── ContextCombobox.tsx + .css # Combobox ergonomique réutilisable (recherche + clavier)
35
+ │ ├── EventPicker.tsx # Sélecteur d'événement (autonome, non monté par défaut)
36
+ │ ├── CommunityPicker.tsx # Sélecteur de communauté (idem)
37
+ │ └── AudiencePicker.tsx # Sélecteur de tenant/audience (idem)
38
+ ├── context/
39
+ │ └── InwinkContextProvider.tsx # Contexte event/community/tenant + loaders de listes
40
+ ├── lib/
41
+ │ └── inwinkContextParams.ts # Lecture/persistance des params URL (eventid/communityid/tenantid)
31
42
  ├── pages/
32
43
  │ └── HomePage.tsx # Page d'accueil (logique métier / UI)
33
44
  ├── api/
@@ -38,6 +49,8 @@ __NAME__/
38
49
  │ └── ProtectedRoute.tsx
39
50
  └── hooks/
40
51
  ├── useAuth.ts # Hook d'accès au contexte auth
52
+ ├── useInwinkContext.ts # Hook d'accès au contexte inwink (event/community/tenant)
53
+ ├── useContextOptions.ts # Chargement (1×) des listes pour les pickers
41
54
  ├── useInwinkApi.ts # Hook générique d'appel API (Bearer + renouvellement 401)
42
55
  ├── useInwinkEventApi.ts # Hook API Event
43
56
  ├── useInwinkCommunityApi.ts # Hook API Community
@@ -85,6 +98,50 @@ Bonnes pratiques :
85
98
  - Ne déclarer dans `permissions.platform` que les scopes réellement utilisés.
86
99
  - Déclarer les hôtes externes dans `permissions.externalHosts` (HTTPS uniquement).
87
100
 
101
+ ## Structure de pages & Header
102
+
103
+ L'app est rendue dans un `Layout` (`components/Layout.tsx`) = `Header` + zone de contenu (`<Outlet />`). Le `Header` (`components/Header.tsx`) est **minimal par défaut** (marque + bouton login/logout) et **conçu pour être surchargé** : c'est l'endroit naturel où monter un picker de contexte si l'extension en a besoin.
104
+
105
+ Pour **ajouter une page** : créer le composant dans `src/pages/`, puis ajouter une `<Route>` enfant du `<Route element={<Layout .../>}>` dans `App.tsx` (elle s'affichera dans l'`Outlet`).
106
+
107
+ ## Contexte inwink (event / community / tenant)
108
+
109
+ Quand l'extension est ouverte depuis le back-office sur un objet précis, le BO passe `eventid`, `communityid` ou `tenantid` dans l'URL. Le template gère ça nativement :
110
+
111
+ - `lib/inwinkContextParams.ts` lit ces paramètres (query classique **et** query du hash), insensible à la casse, et les **persiste en sessionStorage** — indispensable car le callback OIDC nettoie l'URL et le `redirect_uri` ne réémet pas la query.
112
+ - `context/InwinkContextProvider.tsx` expose le contexte courant et les setters via le hook **`useInwinkContext`** :
113
+
114
+ ```typescript
115
+ const { scope, eventId, communityId, tenantId, setEvent, setCommunity, setTenant } = useInwinkContext();
116
+ // scope = 'event' | 'community' | 'tenant' | null (tenant = audience)
117
+ ```
118
+
119
+ Le contexte issu de l'URL est **pré-rempli mais modifiable** (l'utilisateur peut changer d'objet via un picker).
120
+
121
+ ## Pickers de contexte (event / community / audience)
122
+
123
+ Trois composants autonomes existent dans `components/` : **`EventPicker`**, **`CommunityPicker`**, **`AudiencePicker`**. Ils consomment `useInwinkContext`, listent les objets de l'utilisateur via l'API Customer (`me/events/query`, `me/communities/query`, `me/tenants/query`) et appliquent la sélection. L'UI est mutualisée dans **`ContextCombobox`** (bouton + popover avec recherche et navigation clavier) — réutilise-le pour tout sélecteur similaire.
124
+
125
+ Ces listes sont **paginées côté serveur** (1ʳᵉ page seulement). La recherche du combobox est donc déléguée au serveur via `useContextOptions` (loaders `loadEvents/loadCommunities/loadTenants`, paramètre `IInwinkContextQuery = { search?, id? }`) : la saisie est debouncée et filtre par nom **ou** id (`{ filters: { $or: [{ id }, { <label>: { $contains } }] } }`). Les événements sont triés par date décroissante (`orders`). Comme l'objet pré-sélectionné via l'URL peut ne pas figurer dans la page courante, `useContextOptions` **fetch son détail par id** et le fusionne dans les options pour afficher son libellé.
126
+
127
+ **Ils ne sont PAS montés par défaut.** À toi de décider, selon l'usage de l'extension, d'en monter un — typiquement dans le `Header` (cf. ci-dessus) ou dans une page :
128
+
129
+ ```tsx
130
+ import EventPicker from '../components/EventPicker';
131
+ // ...
132
+ {isAuthenticated && <EventPicker />}
133
+ ```
134
+
135
+ ## Branding inwink
136
+
137
+ - Polices : **Lato** (texte) + **Poppins** (titres), chargées via Google Fonts dans `styles.css`. Les hôtes `fonts.googleapis.com` / `fonts.gstatic.com` sont déjà déclarés dans `permissions.externalHosts`.
138
+ - Couleur d'accentuation : **`#e94c1e`** (variable CSS `--color-accent`).
139
+ - Variables/tokens globaux dans `styles.css` ; classe `.btn-primary` pour l'action principale.
140
+
141
+ ## Convention CSS (obligatoire)
142
+
143
+ **Tout nouveau composant doit avoir un fichier `.css` co-localisé**, importé en tête de son `.tsx` (`import './MonComposant.css';`). Les styles globaux et les tokens (`--font-*`, `--color-*`) restent dans `styles.css` ; les styles spécifiques d'un composant vivent dans son `.css`. Vite bundle automatiquement ces CSS dans `dist/assets/` (packagé dans le zip) — ne pas les oublier.
144
+
88
145
  ## Règles et contraintes
89
146
 
90
147
  | Règle | Détail |
@@ -96,6 +153,7 @@ Bonnes pratiques :
96
153
  | Pas d'imports Node.js | `fs`, `path`, `http`, `child_process`, etc. interdits dans le code source |
97
154
  | Pas de `fetch` direct | Toujours passer par `useInwinkEventApi` / `useInwinkCommunityApi` / `useInwinkCustomerApi` (ou `useInwinkApi` pour un hôte externe déclaré) |
98
155
  | Icônes via `react-icons` | Ne pas embarquer de SVG inline ni d'images d'icônes |
156
+ | CSS co-localisé par composant | Chaque composant a un `.css` importé en tête de son `.tsx` ; tokens/global dans `styles.css` |
99
157
  | `HashRouter` obligatoire | Pour compatibilité avec l'hébergement versionné |
100
158
  | `base: './'` obligatoire | Dans `vite.config.ts`, pour les chemins d'assets relatifs |
101
159
  | `response_type: 'code'` | PKCE obligatoire, pas de flux implicit |
@@ -6,7 +6,7 @@
6
6
  "author": "__AUTHOR__",
7
7
  "permissions": {
8
8
  "platform": [],
9
- "externalHosts": []
9
+ "externalHosts": ["https://fonts.googleapis.com", "https://fonts.gstatic.com"]
10
10
  },
11
11
  "auth": {
12
12
  "scopes": ["openid", "profile", "email"]
@@ -3,6 +3,8 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
7
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
6
8
  <title>__DISPLAY_NAME__</title>
7
9
  </head>
8
10
  <body>
@@ -1,22 +1,28 @@
1
1
  import { Navigate, Route, Routes } from 'react-router-dom';
2
+ import Layout from './components/Layout';
2
3
  import ProtectedRoute from './auth/ProtectedRoute';
3
4
  import HomePage from './pages/HomePage';
4
5
  // Pour appeler une API inwink, utilisez un hook dédié (jamais fetch directement) :
5
6
  // import { useInwinkEventApi } from './hooks/useInwinkEventApi';
6
7
  // const eventApi = useInwinkEventApi();
7
8
  // const data = await eventApi.get('/v1/...');
9
+ //
10
+ // Pour ajouter une page : créez-la dans src/pages/ puis ajoutez une <Route> enfant
11
+ // du <Route element={<Layout .../>}> ci-dessous (elle s'affichera dans l'<Outlet> du Layout).
8
12
 
9
13
  export default function App() {
10
14
  return (
11
15
  <Routes>
12
- <Route
13
- path="/"
14
- element={
15
- <ProtectedRoute>
16
- <HomePage />
17
- </ProtectedRoute>
18
- }
19
- />
16
+ <Route element={<Layout title="__DISPLAY_NAME__" />}>
17
+ <Route
18
+ path="/"
19
+ element={
20
+ <ProtectedRoute>
21
+ <HomePage />
22
+ </ProtectedRoute>
23
+ }
24
+ />
25
+ </Route>
20
26
  <Route path="*" element={<Navigate to="/" replace />} />
21
27
  </Routes>
22
28
  );
@@ -1,5 +1,5 @@
1
1
  // src/auth/AuthProvider.tsx
2
- import { createContext, useEffect, useState, type PropsWithChildren } from 'react';
2
+ import { createContext, useCallback, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react';
3
3
  import type { User } from 'oidc-client-ts';
4
4
  import { userManager } from './config';
5
5
 
@@ -24,6 +24,8 @@ function isOidcCallback(): boolean {
24
24
  export function AuthProvider({ children }: PropsWithChildren) {
25
25
  const [user, setUser] = useState<User | null>(null);
26
26
  const [isLoading, setIsLoading] = useState(true);
27
+ const userRef = useRef(user);
28
+ useEffect(() => { userRef.current = user; }, [user]);
27
29
 
28
30
  useEffect(() => {
29
31
  let isMounted = true;
@@ -56,24 +58,40 @@ export function AuthProvider({ children }: PropsWithChildren) {
56
58
  };
57
59
  }, []);
58
60
 
61
+ const login = useCallback(async () => {
62
+ setIsLoading(true);
63
+ await userManager.signinRedirect();
64
+ }, []);
65
+
66
+ const logout = useCallback(async () => {
67
+ await userManager.removeUser();
68
+ setUser(null);
69
+ }, []);
70
+
71
+ const getAccessToken = useCallback(() => userRef.current?.access_token ?? null, []);
72
+
73
+ const renewToken = useCallback(async () => {
74
+ try {
75
+ const renewed = await userManager.signinSilent();
76
+ setUser(renewed);
77
+ return renewed?.access_token ?? null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }, []);
82
+
83
+ const value = useMemo(() => ({
84
+ isAuthenticated: Boolean(user && !user.expired),
85
+ isLoading,
86
+ login,
87
+ logout,
88
+ user,
89
+ getAccessToken,
90
+ renewToken,
91
+ }), [user, isLoading]);
92
+
59
93
  return (
60
- <AuthContext.Provider value={{
61
- isAuthenticated: Boolean(user && !user.expired),
62
- isLoading,
63
- login: async () => { setIsLoading(true); await userManager.signinRedirect(); },
64
- logout: async () => { await userManager.removeUser(); setUser(null); },
65
- user,
66
- getAccessToken: () => user?.access_token ?? null,
67
- renewToken: async () => {
68
- try {
69
- const renewed = await userManager.signinSilent();
70
- setUser(renewed);
71
- return renewed?.access_token ?? null;
72
- } catch {
73
- return null;
74
- }
75
- },
76
- }}>
94
+ <AuthContext.Provider value={value}>
77
95
  {children}
78
96
  </AuthContext.Provider>
79
97
  );
@@ -0,0 +1,25 @@
1
+ import ContextCombobox from './ContextCombobox';
2
+ import { useContextOptions } from '../hooks/useContextOptions';
3
+ import { useInwinkContext } from '../hooks/useInwinkContext';
4
+
5
+ /**
6
+ * Sélecteur de tenant / audience courant. Pré-rempli depuis le contexte (URL) et modifiable.
7
+ * Composant autonome, montable où l'on veut (header, page…). Non monté par défaut.
8
+ */
9
+ export default function AudiencePicker() {
10
+ const { tenantId, setTenant, loadTenants } = useInwinkContext();
11
+ const { options, isLoading, error, search } = useContextOptions(loadTenants, tenantId);
12
+
13
+ return (
14
+ <ContextCombobox
15
+ label="Audience"
16
+ placeholder="Choisir une audience"
17
+ value={tenantId}
18
+ options={options}
19
+ isLoading={isLoading}
20
+ error={error}
21
+ onChange={setTenant}
22
+ onSearch={search}
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,25 @@
1
+ import ContextCombobox from './ContextCombobox';
2
+ import { useContextOptions } from '../hooks/useContextOptions';
3
+ import { useInwinkContext } from '../hooks/useInwinkContext';
4
+
5
+ /**
6
+ * Sélecteur de communauté courante. Pré-rempli depuis le contexte (URL) et modifiable.
7
+ * Composant autonome, montable où l'on veut (header, page…). Non monté par défaut.
8
+ */
9
+ export default function CommunityPicker() {
10
+ const { communityId, setCommunity, loadCommunities } = useInwinkContext();
11
+ const { options, isLoading, error, search } = useContextOptions(loadCommunities, communityId);
12
+
13
+ return (
14
+ <ContextCombobox
15
+ label="Communauté"
16
+ placeholder="Choisir une communauté"
17
+ value={communityId}
18
+ options={options}
19
+ isLoading={isLoading}
20
+ error={error}
21
+ onChange={setCommunity}
22
+ onSearch={search}
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,110 @@
1
+ .context-combobox {
2
+ position: relative;
3
+ display: inline-flex;
4
+ flex-direction: column;
5
+ gap: 0.2rem;
6
+ font-size: 0.85rem;
7
+ }
8
+
9
+ .context-combobox__label {
10
+ color: var(--color-text-muted);
11
+ font-weight: 600;
12
+ }
13
+
14
+ .context-combobox__trigger {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ justify-content: space-between;
18
+ gap: 0.5rem;
19
+ min-width: 14rem;
20
+ max-width: 20rem;
21
+ padding: 0.4rem 0.6rem;
22
+ border-radius: var(--radius);
23
+ border: 1px solid var(--color-border);
24
+ background: var(--color-bg);
25
+ color: var(--color-text);
26
+ font: inherit;
27
+ }
28
+
29
+ .context-combobox__trigger:hover:not(:disabled) {
30
+ border-color: var(--color-accent);
31
+ }
32
+
33
+ .context-combobox__value {
34
+ overflow: hidden;
35
+ text-overflow: ellipsis;
36
+ white-space: nowrap;
37
+ }
38
+
39
+ .context-combobox__chevron {
40
+ flex-shrink: 0;
41
+ color: var(--color-text-muted);
42
+ }
43
+
44
+ .context-combobox__panel {
45
+ position: absolute;
46
+ top: calc(100% + 0.25rem);
47
+ left: 0;
48
+ z-index: 20;
49
+ width: max(100%, 16rem);
50
+ background: var(--color-bg);
51
+ border: 1px solid var(--color-border);
52
+ border-radius: var(--radius);
53
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
54
+ overflow: hidden;
55
+ }
56
+
57
+ .context-combobox__search {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 0.4rem;
61
+ padding: 0.5rem 0.6rem;
62
+ border-bottom: 1px solid var(--color-border);
63
+ color: var(--color-text-muted);
64
+ }
65
+
66
+ .context-combobox__search input {
67
+ flex: 1;
68
+ border: none;
69
+ outline: none;
70
+ font: inherit;
71
+ background: transparent;
72
+ color: var(--color-text);
73
+ }
74
+
75
+ .context-combobox__list {
76
+ list-style: none;
77
+ margin: 0;
78
+ padding: 0.25rem;
79
+ max-height: 16rem;
80
+ overflow-y: auto;
81
+ }
82
+
83
+ .context-combobox__empty {
84
+ padding: 0.5rem 0.6rem;
85
+ color: var(--color-text-muted);
86
+ }
87
+
88
+ .context-combobox__option {
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ gap: 0.5rem;
93
+ width: 100%;
94
+ padding: 0.45rem 0.6rem;
95
+ border: none;
96
+ border-radius: calc(var(--radius) - 2px);
97
+ background: transparent;
98
+ color: var(--color-text);
99
+ font: inherit;
100
+ text-align: left;
101
+ }
102
+
103
+ .context-combobox__option.is-highlighted {
104
+ background: var(--color-surface);
105
+ }
106
+
107
+ .context-combobox__option.is-selected {
108
+ color: var(--color-accent);
109
+ font-weight: 600;
110
+ }
@@ -0,0 +1,167 @@
1
+ import './ContextCombobox.css';
2
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { FiCheck, FiChevronDown, FiSearch } from 'react-icons/fi';
4
+ import type { InwinkContextOption } from '../context/InwinkContextProvider';
5
+
6
+ interface ContextComboboxProps {
7
+ label: string;
8
+ placeholder: string;
9
+ value?: string;
10
+ options: InwinkContextOption[];
11
+ isLoading: boolean;
12
+ error: string | null;
13
+ onChange: (id: string) => void;
14
+ /**
15
+ * Si fourni, la recherche est déléguée au serveur (le terme saisi est transmis)
16
+ * et la liste n'est PAS refiltrée côté client. Sans ce prop, le filtrage se fait
17
+ * côté client sur le libellé.
18
+ */
19
+ onSearch?: (query: string) => void;
20
+ }
21
+
22
+ /**
23
+ * Sélecteur ergonomique réutilisable : bouton déclencheur + popover avec champ de
24
+ * recherche, liste filtrable et navigation clavier. Mutualise l'UI des pickers de
25
+ * contexte (Event / Community / Audience).
26
+ */
27
+ export default function ContextCombobox({
28
+ label,
29
+ placeholder,
30
+ value,
31
+ options,
32
+ isLoading,
33
+ error,
34
+ onChange,
35
+ onSearch,
36
+ }: ContextComboboxProps) {
37
+ const [open, setOpen] = useState(false);
38
+ const [query, setQuery] = useState('');
39
+ const [highlight, setHighlight] = useState(0);
40
+ const containerRef = useRef<HTMLDivElement>(null);
41
+ const inputRef = useRef<HTMLInputElement>(null);
42
+ const listId = useId();
43
+
44
+ const selected = options.find((option) => option.id === value);
45
+ const triggerLabel = selected?.label ?? (value && isLoading ? 'Chargement…' : placeholder);
46
+
47
+ // En mode serveur (`onSearch`), la liste est déjà filtrée par l'API : on ne refiltre
48
+ // pas côté client (une recherche par id n'apparaît pas dans le libellé).
49
+ const filtered = useMemo(() => {
50
+ if (onSearch) return options;
51
+ const q = query.trim().toLowerCase();
52
+ return q ? options.filter((option) => option.label.toLowerCase().includes(q)) : options;
53
+ }, [options, query, onSearch]);
54
+
55
+ // Fermeture au clic extérieur.
56
+ useEffect(() => {
57
+ if (!open) return;
58
+ const onPointerDown = (event: MouseEvent) => {
59
+ if (!containerRef.current?.contains(event.target as Node)) {
60
+ setOpen(false);
61
+ }
62
+ };
63
+ document.addEventListener('mousedown', onPointerDown);
64
+ return () => document.removeEventListener('mousedown', onPointerDown);
65
+ }, [open]);
66
+
67
+ // Focus du champ de recherche à l'ouverture, et réinitialisation de la liste
68
+ // (en mode serveur, on recharge la liste complète non filtrée).
69
+ useEffect(() => {
70
+ if (open) {
71
+ setQuery('');
72
+ setHighlight(0);
73
+ inputRef.current?.focus();
74
+ onSearch?.('');
75
+ }
76
+ }, [open, onSearch]);
77
+
78
+ const select = (id: string) => {
79
+ onChange(id);
80
+ setOpen(false);
81
+ };
82
+
83
+ const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
84
+ if (event.key === 'ArrowDown') {
85
+ event.preventDefault();
86
+ setHighlight((h) => Math.min(h + 1, filtered.length - 1));
87
+ } else if (event.key === 'ArrowUp') {
88
+ event.preventDefault();
89
+ setHighlight((h) => Math.max(h - 1, 0));
90
+ } else if (event.key === 'Enter') {
91
+ event.preventDefault();
92
+ const option = filtered[highlight];
93
+ if (option) select(option.id);
94
+ } else if (event.key === 'Escape') {
95
+ setOpen(false);
96
+ }
97
+ };
98
+
99
+ return (
100
+ <div className="context-combobox" ref={containerRef}>
101
+ <span className="context-combobox__label">{label}</span>
102
+ <button
103
+ type="button"
104
+ className="context-combobox__trigger"
105
+ disabled={Boolean(error)}
106
+ aria-haspopup="listbox"
107
+ aria-expanded={open}
108
+ title={error ?? undefined}
109
+ onClick={() => setOpen((o) => !o)}
110
+ >
111
+ <span className="context-combobox__value">
112
+ {error ? 'Erreur de chargement' : triggerLabel}
113
+ </span>
114
+ <FiChevronDown aria-hidden className="context-combobox__chevron" />
115
+ </button>
116
+
117
+ {open && (
118
+ <div className="context-combobox__panel">
119
+ <div className="context-combobox__search">
120
+ <FiSearch aria-hidden />
121
+ <input
122
+ ref={inputRef}
123
+ type="text"
124
+ value={query}
125
+ placeholder="Rechercher…"
126
+ aria-label={`Rechercher ${label.toLowerCase()}`}
127
+ onChange={(e) => {
128
+ setQuery(e.target.value);
129
+ setHighlight(0);
130
+ onSearch?.(e.target.value);
131
+ }}
132
+ onKeyDown={onInputKeyDown}
133
+ />
134
+ </div>
135
+ <ul className="context-combobox__list" role="listbox" id={listId}>
136
+ {isLoading && <li className="context-combobox__empty">Chargement…</li>}
137
+ {!isLoading && filtered.length === 0 && (
138
+ <li className="context-combobox__empty">Aucun résultat</li>
139
+ )}
140
+ {!isLoading &&
141
+ filtered.map((option, index) => {
142
+ const isSelected = option.id === value;
143
+ const isHighlighted = index === highlight;
144
+ return (
145
+ <li key={option.id} role="option" aria-selected={isSelected}>
146
+ <button
147
+ type="button"
148
+ className={
149
+ 'context-combobox__option' +
150
+ (isHighlighted ? ' is-highlighted' : '') +
151
+ (isSelected ? ' is-selected' : '')
152
+ }
153
+ onMouseEnter={() => setHighlight(index)}
154
+ onClick={() => select(option.id)}
155
+ >
156
+ <span>{option.label}</span>
157
+ {isSelected && <FiCheck aria-hidden />}
158
+ </button>
159
+ </li>
160
+ );
161
+ })}
162
+ </ul>
163
+ </div>
164
+ )}
165
+ </div>
166
+ );
167
+ }