@alfakit/react 0.1.7 → 0.1.9

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 (54) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/components/actions/action-modals.js +1 -1
  3. package/dist/components/actions/action-modals.js.map +1 -1
  4. package/dist/components/form/form-block.d.ts.map +1 -1
  5. package/dist/components/form/form-block.js +6 -3
  6. package/dist/components/form/form-block.js.map +1 -1
  7. package/dist/components/panel/app-sidebar.js +3 -3
  8. package/dist/components/panel/app-sidebar.js.map +1 -1
  9. package/dist/components/panel/bottom-nav.js +2 -2
  10. package/dist/components/panel/bottom-nav.js.map +1 -1
  11. package/dist/components/panel/keyboard-shortcuts.js +1 -1
  12. package/dist/components/panel/keyboard-shortcuts.js.map +1 -1
  13. package/dist/components/panel/locale-switcher.d.ts.map +1 -1
  14. package/dist/components/panel/locale-switcher.js +2 -1
  15. package/dist/components/panel/locale-switcher.js.map +1 -1
  16. package/dist/components/ui/tooltip.d.ts.map +1 -1
  17. package/dist/components/ui/tooltip.js +1 -1
  18. package/dist/components/ui/tooltip.js.map +1 -1
  19. package/dist/eject-manifest.json +4 -2
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -2
  22. package/dist/layouts/top-nav-layout.js +3 -3
  23. package/dist/layouts/top-nav-layout.js.map +1 -1
  24. package/dist/standalone/alfakit-component.js +17 -7
  25. package/dist/standalone/alfakit-component.js.map +1 -1
  26. package/dist/standalone.css +10 -6
  27. package/dist/starter-kit/index.d.ts +2 -2
  28. package/dist/starter-kit/index.js +2 -2
  29. package/dist/starter-kit/starter-kit.d.ts.map +1 -1
  30. package/dist/starter-kit/starter-kit.js +7 -2
  31. package/dist/starter-kit/starter-kit.js.map +1 -1
  32. package/dist/starter-kit/widgets-loaded.js +74 -13
  33. package/dist/starter-kit/widgets-loaded.js.map +1 -1
  34. package/dist/starter-kit/widgets-triggers.js +34 -3
  35. package/dist/starter-kit/widgets-triggers.js.map +1 -1
  36. package/dist/starter-kit/widgets.d.ts +11 -2
  37. package/dist/starter-kit/widgets.d.ts.map +1 -1
  38. package/dist/starter-kit/widgets.js +20 -2
  39. package/dist/starter-kit/widgets.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/components/actions/action-modals.tsx +2 -0
  42. package/src/components/form/form-block.tsx +15 -2
  43. package/src/components/panel/app-sidebar.tsx +3 -3
  44. package/src/components/panel/bottom-nav.tsx +2 -2
  45. package/src/components/panel/keyboard-shortcuts.tsx +1 -1
  46. package/src/components/panel/locale-switcher.tsx +2 -1
  47. package/src/components/ui/tooltip.tsx +10 -1
  48. package/src/layouts/top-nav-layout.tsx +3 -3
  49. package/src/standalone/alfakit-component.tsx +11 -1
  50. package/src/starter-kit/index.ts +1 -0
  51. package/src/starter-kit/starter-kit.tsx +9 -1
  52. package/src/starter-kit/widgets-loaded.tsx +86 -10
  53. package/src/starter-kit/widgets-triggers.tsx +41 -1
  54. package/src/starter-kit/widgets.tsx +33 -2
@@ -7,22 +7,26 @@ import {
7
7
  loadStarterKitPanel,
8
8
  loadedStarterKitPanel,
9
9
  openRequestedStarterKitSearch,
10
+ showsLanguageSwitcher,
10
11
  starterKitCopilot,
12
+ starterKitPanelLocale,
11
13
  starterKitWidgetsPage,
12
14
  starterKitWidgetsPanel,
13
15
  takeRequestedStarterKitCopilot,
16
+ takeRequestedStarterKitLanguages,
14
17
  tenantNameText,
15
18
  type PanelSchema,
16
19
  type StarterKitWidgets,
17
20
  } from '@alfakit/core'
18
21
  import { router, usePage } from '@inertiajs/react'
19
- import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
22
+ import { ChevronsUpDownIcon, LanguagesIcon, PlusIcon } from 'lucide-react'
20
23
  import { Direction } from 'radix-ui'
21
- import { useEffect, useMemo, useState, type ReactNode } from 'react'
24
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
22
25
  import { AiCopilot } from '../components/ai/copilot'
23
26
  import { NotificationsMenu } from '../components/notifications/notifications-menu'
24
27
  import { RealtimeNotifications } from '../components/notifications/realtime-notifications'
25
28
  import { GlobalSearch } from '../components/panel/global-search'
29
+ import { LocaleOptions } from '../components/panel/locale-switcher'
26
30
  import { TenantName } from '../components/panel/nav-label'
27
31
  import { TenantMenu } from '../components/panel/tenant-menu'
28
32
  import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar'
@@ -38,6 +42,7 @@ import { TooltipProvider } from '../components/ui/tooltip'
38
42
  import {
39
43
  AlfakitProvider,
40
44
  StandaloneProvider,
45
+ useLocalization,
41
46
  useT,
42
47
  type AlfakitContextValue,
43
48
  type AnyComponent,
@@ -45,16 +50,18 @@ import {
45
50
  import { Icon } from '../lib/icons'
46
51
  import {
47
52
  CopilotTrigger,
53
+ LanguageSwitcherTrigger,
48
54
  NotificationsTrigger,
49
55
  SearchTrigger,
50
56
  TenantSwitcherTrigger,
57
+ languageTriggerClasses,
51
58
  type WidgetProps,
52
59
  } from './widgets-triggers'
53
60
 
54
61
  /**
55
62
  * The loaded header components of starter kit mode: the panel's own search palette,
56
- * notifications menu and tenant menu, rendered outside Alfakit pages with the panel data
57
- * from `widgets.endpoint`. This module only loads in the browser.
63
+ * notifications menu, tenant menu and language menu, rendered outside Alfakit pages with
64
+ * the panel data from `widgets.endpoint`. This module only loads in the browser.
58
65
  */
59
66
 
60
67
  // One context for the browser session: its own translator (the panel's strings), an empty
@@ -75,17 +82,24 @@ function useWidgetsContext(panel: PanelSchema): AlfakitContextValue {
75
82
  return widgetsContext
76
83
  }
77
84
 
78
- /** The panel the components render with, once its data has loaded (null until then). */
85
+ /**
86
+ * The panel the components render with, once its data has loaded (null until then). It is
87
+ * loaded per endpoint (a tenant switch) and language (a language switch), so the components
88
+ * follow a switch instead of keeping the old language's strings.
89
+ */
79
90
  function useWidgetsPanel(source: StarterKitWidgets): PanelSchema | null {
80
91
  const endpoint = source.widgets.endpoint
81
- const [loaded, setLoaded] = useState(() => loadedStarterKitPanel(endpoint))
82
- const base = loadedStarterKitPanel(endpoint) ?? loaded
92
+ const locale = starterKitPanelLocale(source)
93
+ const [loaded, setLoaded] = useState(() => loadedStarterKitPanel(endpoint, locale))
94
+ // Until the new language's data arrives, not the old language's.
95
+ const current =
96
+ loaded && (locale === undefined || loaded.localization?.code === locale) ? loaded : null
97
+ const base = loadedStarterKitPanel(endpoint, locale) ?? current
83
98
 
84
99
  useEffect(() => {
85
100
  let active = true
86
101
 
87
- // Another endpoint (a tenant switch) loads its own panel data.
88
- void loadStarterKitPanel(endpoint).then(
102
+ void loadStarterKitPanel(endpoint, locale).then(
89
103
  (panel) => active && setLoaded(panel),
90
104
  () => {},
91
105
  )
@@ -93,7 +107,7 @@ function useWidgetsPanel(source: StarterKitWidgets): PanelSchema | null {
93
107
  return () => {
94
108
  active = false
95
109
  }
96
- }, [endpoint])
110
+ }, [endpoint, locale])
97
111
 
98
112
  const { navigation, widgets } = source
99
113
 
@@ -184,6 +198,68 @@ export function LoadedCopilot({ source }: WidgetProps) {
184
198
  )
185
199
  }
186
200
 
201
+ /**
202
+ * The panel's language menu (its languages in their own names, `LocaleOptions`): picking
203
+ * one posts to the panel's locale endpoint and reloads the page in it. Nothing without a
204
+ * language switcher with two or more languages.
205
+ */
206
+ export function LoadedLanguageSwitcher({ source }: WidgetProps) {
207
+ const panel = useWidgetsPanel(source)
208
+
209
+ if (!panel) return <LanguageSwitcherTrigger source={source} />
210
+
211
+ if (!showsLanguageSwitcher(panel.localization, 'auth')) return null
212
+
213
+ return (
214
+ <WidgetShell panel={panel}>
215
+ <LanguageMenu />
216
+ </WidgetShell>
217
+ )
218
+ }
219
+
220
+ function LanguageMenu() {
221
+ const t = useT()
222
+ const localization = useLocalization()
223
+ const [open, setOpen] = useState(false)
224
+ // Taken once per mount (effects run twice in StrictMode).
225
+ const requested = useRef<boolean | null>(null)
226
+
227
+ // A click on the trigger before the menu loaded opens it once mounted: a frame later, as
228
+ // the menu closes again when it opens while focus is still on the replaced button.
229
+ useEffect(() => {
230
+ requested.current ??= takeRequestedStarterKitLanguages()
231
+
232
+ if (!requested.current) return
233
+
234
+ const frame = requestAnimationFrame(() => setOpen(true))
235
+
236
+ return () => cancelAnimationFrame(frame)
237
+ }, [])
238
+ const label = t('change_language', {}, 'Change language')
239
+
240
+ return (
241
+ <DropdownMenu open={open} onOpenChange={setOpen}>
242
+ <DropdownMenuTrigger asChild>
243
+ <button
244
+ type="button"
245
+ data-slot="language-switcher-trigger"
246
+ aria-label={label}
247
+ title={label}
248
+ className={languageTriggerClasses}
249
+ >
250
+ <LanguagesIcon className="size-4" aria-hidden="true" />
251
+ <span lang={localization.tag} dir={localization.direction} className="hidden sm:inline">
252
+ {localization.label}
253
+ </span>
254
+ </button>
255
+ </DropdownMenuTrigger>
256
+ <DropdownMenuContent align="end" className="min-w-40">
257
+ <LocaleOptions />
258
+ </DropdownMenuContent>
259
+ </DropdownMenu>
260
+ )
261
+ }
262
+
187
263
  /** The user's tenants on a page outside the panel (no current tenant). */
188
264
  function TenantChooser({ panel }: { panel: PanelSchema }) {
189
265
  const t = useT()
@@ -4,11 +4,12 @@ import {
4
4
  registerStarterKitCopilotShortcut,
5
5
  registerStarterKitSearchShortcut,
6
6
  requestStarterKitCopilot,
7
+ requestStarterKitLanguages,
7
8
  requestStarterKitSearch,
8
9
  tenantNameText,
9
10
  type StarterKitWidgets,
10
11
  } from '@alfakit/core'
11
- import { BellIcon, ChevronsUpDownIcon, SearchIcon, SparklesIcon } from 'lucide-react'
12
+ import { BellIcon, ChevronsUpDownIcon, LanguagesIcon, SearchIcon, SparklesIcon } from 'lucide-react'
12
13
  import { useEffect } from 'react'
13
14
  import { useHydrated } from '../hooks/use-hydrated'
14
15
 
@@ -136,3 +137,42 @@ export function CopilotTrigger({ source, onLoad }: WidgetProps & { onLoad?: () =
136
137
  </button>
137
138
  )
138
139
  }
140
+
141
+ /** The language button's look, before and after its menu has loaded. */
142
+ export const languageTriggerClasses =
143
+ 'hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent inline-flex h-9 min-w-9 shrink-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50'
144
+
145
+ /**
146
+ * The language button until its menu has loaded: the languages icon and the current
147
+ * language in its own name (from `sm`). A click is remembered and opens the menu once loaded.
148
+ */
149
+ export function LanguageSwitcherTrigger({ source, onLoad }: WidgetProps & { onLoad?: () => void }) {
150
+ const language = source.widgets.language
151
+
152
+ return (
153
+ <button
154
+ type="button"
155
+ data-slot="language-switcher-trigger"
156
+ aria-label={language?.label}
157
+ aria-haspopup="menu"
158
+ title={language?.label}
159
+ className={languageTriggerClasses}
160
+ onPointerEnter={() => onLoad?.()}
161
+ onClick={() => {
162
+ requestStarterKitLanguages()
163
+ onLoad?.()
164
+ }}
165
+ >
166
+ <LanguagesIcon className="size-4" aria-hidden="true" />
167
+ {language && (
168
+ <span
169
+ lang={language.current.tag}
170
+ dir={language.current.direction}
171
+ className="hidden sm:inline"
172
+ >
173
+ {language.current.label}
174
+ </span>
175
+ )}
176
+ </button>
177
+ )
178
+ }
@@ -9,6 +9,7 @@ import { lazy, Suspense } from 'react'
9
9
  import { lazyComponent } from '../lib/lazy'
10
10
  import {
11
11
  CopilotTrigger,
12
+ LanguageSwitcherTrigger,
12
13
  NotificationsTrigger,
13
14
  SearchTrigger,
14
15
  TenantSwitcherTrigger,
@@ -17,17 +18,18 @@ import {
17
18
 
18
19
  /**
19
20
  * Drop-in header components of starter kit mode (`Panel::starterKit()`): AlfaKit's search,
20
- * notifications bell and tenant switcher in the host's own header, on every page of the
21
+ * notifications bell, tenant switcher and language switcher in the host's own header, on every page of the
21
22
  * application. They read the shared `alfakitNavigation` prop, render a lightweight trigger
22
23
  * straight away, and load their code and the panel data the first time they render
23
24
  * (in the browser, never during SSR).
24
25
  *
25
26
  * ```tsx
26
- * import { AlfakitCopilot, AlfakitNotifications, AlfakitSearch } from '@alfakit/react'
27
+ * import { AlfakitCopilot, AlfakitLanguageSwitcher, AlfakitNotifications, AlfakitSearch } from '@alfakit/react'
27
28
  *
28
29
  * <AlfakitSearch />
29
30
  * <AlfakitNotifications />
30
31
  * <AlfakitCopilot />
32
+ * <AlfakitLanguageSwitcher />
31
33
  * ```
32
34
  */
33
35
  export interface AlfakitHeaderWidgetProps {
@@ -65,6 +67,11 @@ function preloadCopilot() {
65
67
  void LoadedCopilot.preload()
66
68
  }
67
69
 
70
+ const LoadedLanguageSwitcher = lazyComponent<WidgetProps>(
71
+ () => load().then((module) => module.LoadedLanguageSwitcher),
72
+ (props) => <LanguageSwitcherTrigger {...props} />,
73
+ )
74
+
68
75
  // Only needed while impersonating: its code loads then (and never on AlfaKit pages).
69
76
  const LazyImpersonationBanner = lazy(() => import('./impersonation-banner'))
70
77
 
@@ -176,3 +183,27 @@ export function AlfakitCopilot({ panel }: AlfakitHeaderWidgetProps = {}) {
176
183
  </>
177
184
  )
178
185
  }
186
+
187
+ /**
188
+ * The panel's language menu (`locales()`): the current language straight away, and each
189
+ * language in its own name once loaded. Picking one switches the panel's language (the
190
+ * session, the cookie and `persistLocaleUsing()`) and reloads the page, so AlfaKit pages and
191
+ * the header components show it; the host's own pages and `<html lang dir>` are the app's.
192
+ * Renders nothing for guests or with fewer than two languages.
193
+ */
194
+ export function AlfakitLanguageSwitcher({ panel }: AlfakitHeaderWidgetProps = {}) {
195
+ const source = useWidgets(panel, 'language')
196
+
197
+ const banner = <StarterKitImpersonationBanner source={source} />
198
+
199
+ if (!source?.widgets.language) return banner
200
+
201
+ return (
202
+ <>
203
+ {banner}
204
+ <span data-slot="starter-kit-language-switcher" className="inline-flex">
205
+ <LoadedLanguageSwitcher source={source} />
206
+ </span>
207
+ </>
208
+ )
209
+ }