next-language-selector 0.2.8 β†’ 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,18 +2,20 @@
2
2
 
3
3
  ![npm version](https://img.shields.io/npm/v/next-language-selector?color=3178C6&style=flat-square)
4
4
  ![bundle size](https://img.shields.io/bundlephobia/minzip/next-language-selector?color=black&style=flat-square)
5
+ [![codecov](https://codecov.io/gh/kirilinsky/next-language-selector/graph/badge.svg)](https://codecov.io/gh/kirilinsky/next-language-selector)
5
6
  ![security score](https://socket.dev/api/badge/npm/package/next-language-selector?style=flat-square)
6
- ![license](https://img.shields.io/npm/l/next-language-selector?color=gray&style=flat-square)
7
+ ![license](https://img.shields.io/npm/l/next-language-selector?color=2E7D32&style=flat-square)
7
8
 
8
- A lightweight, configurable language selector for Next.js (App Router & Pages Router).
9
- Efficiently manages the `NEXT_LOCALE` cookie and works seamlessly with `next-intl` or any other i18n solution.
9
+ A lightweight, unstyled language selector for Next.js (App Router & Pages Router).
10
+ Manages the `NEXT_LOCALE` cookie and works with `next-intl` or any i18n solution.
10
11
 
11
12
  ## Key Features
12
13
 
13
- - πŸš€ **Next.js Native**: Built specifically for the Next.js ecosystem.
14
- - πŸͺΆ **Zero Dependencies**: Keeps your bundle size minimal.
15
- - 🎨 **Fully Customizable**: Use built-in styles or your own UI (Shadcn UI, Radix, etc.) via render props.
16
- - πŸͺ **Cookie-based**: Automatically syncs with the `NEXT_LOCALE` cookie.
14
+ - **Next.js Native**: Built for the Next.js ecosystem (App Router & Pages Router).
15
+ - **Zero Dependencies**: No runtime deps β€” just React.
16
+ - **Unstyled by default**: Bring your own CSS, Tailwind, Shadcn, Radix β€” no style conflicts.
17
+ - **Cookie-based**: Reads and writes `NEXT_LOCALE` automatically.
18
+ - **Secure**: Cookie injection-safe, `SameSite=Lax` out of the box.
17
19
 
18
20
  ## Installation
19
21
 
@@ -25,7 +27,7 @@ npm install next-language-selector
25
27
 
26
28
  ## Basic Usage
27
29
 
28
- Just drop the component into your Footer or Navbar. It handles cookie updates and local state out of the box.
30
+ Drop the component into your Footer or Navbar. It handles cookie sync and state out of the box.
29
31
 
30
32
  ```tsx
31
33
  import { LanguageSelector } from "next-language-selector";
@@ -41,17 +43,55 @@ export default function Footer() {
41
43
  <LanguageSelector
42
44
  locales={locales}
43
45
  defaultLocale="en"
44
- activeColor="#3b82f6" // optional
45
- isDropdown={false} // Renders as a list of buttons
46
46
  />
47
47
  </footer>
48
48
  );
49
49
  }
50
50
  ```
51
51
 
52
- ## Custom UI (e.g., Shadcn UI / Headless)
52
+ ## Styling
53
53
 
54
- Use the `renderCustom` prop to take full control over the rendering while keeping the cookie management logic.
54
+ The component is **unstyled by default**. Use `className` / `itemClassName` props or target the `data-active` attribute:
55
+
56
+ ```css
57
+ /* Plain CSS */
58
+ .lang-btn {
59
+ background: none;
60
+ border: none;
61
+ cursor: pointer;
62
+ opacity: 0.5;
63
+ }
64
+
65
+ .lang-btn[data-active="true"] {
66
+ opacity: 1;
67
+ font-weight: 600;
68
+ border-bottom: 2px solid currentColor;
69
+ }
70
+ ```
71
+
72
+ ```tsx
73
+ <LanguageSelector
74
+ locales={locales}
75
+ defaultLocale="en"
76
+ className="flex gap-2"
77
+ itemClassName="lang-btn"
78
+ />
79
+ ```
80
+
81
+ With Tailwind:
82
+
83
+ ```tsx
84
+ <LanguageSelector
85
+ locales={locales}
86
+ defaultLocale="en"
87
+ className="flex items-center gap-3"
88
+ itemClassName="text-sm text-gray-400 data-[active=true]:text-black data-[active=true]:font-semibold"
89
+ />
90
+ ```
91
+
92
+ ## Custom UI
93
+
94
+ Use the `renderCustom` prop to take full control over rendering while keeping the cookie logic.
55
95
 
56
96
  ```tsx
57
97
  <LanguageSelector
@@ -63,11 +103,7 @@ Use the `renderCustom` prop to take full control over the rendering while keepin
63
103
  <button
64
104
  key={lang.code}
65
105
  onClick={() => onChange(lang.code)}
66
- className={
67
- currentLocale === lang.code
68
- ? "text-blue-600 font-bold"
69
- : "text-gray-500"
70
- }
106
+ className={currentLocale === lang.code ? "font-bold" : "opacity-50"}
71
107
  >
72
108
  {lang.flag} {lang.name}
73
109
  </button>
@@ -77,9 +113,20 @@ Use the `renderCustom` prop to take full control over the rendering while keepin
77
113
  />
78
114
  ```
79
115
 
80
- ## Setup with next-intl (Middleware)
116
+ ## Dropdown mode
117
+
118
+ ```tsx
119
+ <LanguageSelector
120
+ locales={locales}
121
+ defaultLocale="en"
122
+ isDropdown
123
+ className="border rounded px-2 py-1"
124
+ />
125
+ ```
126
+
127
+ ## Setup with next-intl
81
128
 
82
- To make sure Next.js detects the language from the cookie set by this component, update your `middleware.ts` or `proxy.ts`:
129
+ Update your `middleware.ts` to read the cookie set by this component:
83
130
 
84
131
  ```typescript
85
132
  import createMiddleware from "next-intl/middleware";
@@ -90,23 +137,33 @@ export default createMiddleware({
90
137
  localeCookie: {
91
138
  name: "NEXT_LOCALE",
92
139
  path: "/",
93
- maxAge: 31536000, // 1 year
140
+ maxAge: 31536000,
94
141
  },
95
142
  });
96
143
  ```
97
144
 
98
145
  ## Props
99
146
 
100
- | Prop | Type | Default | Description |
101
- | :-------------- | :--------------- | :------------ | :---------------------------------------------------------- |
102
- | `locales` | `LocaleConfig[]` | **Required** | Array of `{ name, code, flag }` objects |
103
- | `defaultLocale` | `string` | **Required** | Initial language code |
104
- | `isDropdown` | `boolean` | `false` | Toggle between `<select>` and a list of buttons |
105
- | `autoReload` | `boolean` | `true` | Trigger reload on cookie change |
106
- | `cookieName` | `string` | `NEXT_LOCALE` | Name of the cookie to store the selected language |
107
- | `activeColor` | `string` | `red` | Underline color for the active language (non-dropdown mode) |
108
- | `className` | `string` | - | CSS class for the wrapper element |
109
- | `renderCustom` | `Function` | - | Render prop for custom UI logic |
147
+ | Prop | Type | Default | Description |
148
+ | :-------------- | :--------------- | :------------- | :--------------------------------------------------- |
149
+ | `locales` | `LocaleConfig[]` | **Required** | Array of `{ name, code, flag? }` objects |
150
+ | `defaultLocale` | `string` | **Required** | Fallback locale code |
151
+ | `isDropdown` | `boolean` | `false` | Render as `<select>` instead of buttons |
152
+ | `autoReload` | `boolean` | `true` | Reload page after cookie change |
153
+ | `cookieName` | `string` | `NEXT_LOCALE` | Cookie name to store the selected locale |
154
+ | `className` | `string` | - | CSS class for the wrapper `<div>` or `<select>` |
155
+ | `itemClassName` | `string` | - | CSS class for each `<button>` or `<option>` |
156
+ | `renderCustom` | `Function` | - | Render prop for fully custom UI |
157
+
158
+ ### `LocaleConfig`
159
+
160
+ ```ts
161
+ interface LocaleConfig {
162
+ name: string; // Display name, e.g. "English"
163
+ code: string; // Locale code, e.g. "en"
164
+ flag?: string; // Optional emoji flag, e.g. "πŸ‡ΊπŸ‡Έ"
165
+ }
166
+ ```
110
167
 
111
168
  ## License
112
169
 
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";var u=Object.defineProperty;var x=Object.getOwnPropertyDescriptor;var k=Object.getOwnPropertyNames;var y=Object.prototype.hasOwnProperty;var v=(t,o)=>{for(var a in o)u(t,a,{get:o[a],enumerable:!0})},E=(t,o,a,c)=>{if(o&&typeof o=="object"||typeof o=="function")for(let n of k(o))!y.call(t,n)&&n!==a&&u(t,n,{get:()=>o[n],enumerable:!(c=x(o,n))||c.enumerable});return t};var b=t=>E(u({},"__esModule",{value:!0}),t);var N={};v(N,{LanguageSelector:()=>h,setLocaleCookie:()=>i});module.exports=b(N);var p=require("react");var i=(t,o="NEXT_LOCALE",a=!0)=>{typeof document>"u"||(document.cookie=`${o}=${t}; max-age=31536000; path=/`,a&&window.location.reload())};var r=require("react/jsx-runtime");function f(t){let{locales:o,defaultLocale:a,cookieName:c="NEXT_LOCALE",isDropdown:n=!1,autoReload:L=!0,renderCustom:g,className:m,activeColor:C="red"}=t,[s,d]=(0,p.useState)(a);(0,p.useEffect)(()=>{let e=document.cookie.split("; ").find(S=>S.startsWith(`${c}=`))?.split("=")[1];e&&d(e)},[c]);let l=e=>{d(e),i(e,c,L)};return g?(0,r.jsx)(r.Fragment,{children:g({locales:o,currentLocale:s,onChange:l})}):n?(0,r.jsx)("select",{value:s,onChange:e=>l(e.target.value),className:m,children:o.map(e=>(0,r.jsxs)("option",{value:e.code,children:[e.flag," ",e.name]},e.code))}):(0,r.jsx)("div",{style:{display:"flex",gap:"8px",alignItems:"center"},className:m,children:o.map(e=>(0,r.jsxs)("button",{onClick:()=>l(e.code),style:{cursor:"pointer",background:"none",border:"none",borderBottom:s===e.code?`1px solid ${C}`:"1px solid transparent",color:s===e.code?"inherit":"gray",fontSize:"12px",padding:"2px 4px"},children:[e.flag," ",e.name]},e.code))})}var h=f;0&&(module.exports={LanguageSelector,setLocaleCookie});
1
+ "use strict";var f=Object.defineProperty;var v=Object.getOwnPropertyDescriptor;var E=Object.getOwnPropertyNames;var x=Object.prototype.hasOwnProperty;var h=(t,o)=>{for(var a in o)f(t,a,{get:o[a],enumerable:!0})},P=(t,o,a,n)=>{if(o&&typeof o=="object"||typeof o=="function")for(let c of E(o))!x.call(t,c)&&c!==a&&f(t,c,{get:()=>o[c],enumerable:!(n=v(o,c))||n.enumerable});return t};var b=t=>P(f({},"__esModule",{value:!0}),t);var I={};h(I,{LanguageSelector:()=>w,setLocaleCookie:()=>i});module.exports=b(I);var s=require("react");var i=(t,o="NEXT_LOCALE",a=!0)=>{if(typeof document>"u")return;let n=encodeURIComponent(o),c=encodeURIComponent(t);document.cookie=`${n}=${c}; max-age=31536000; path=/; SameSite=Lax`,a&&window.location.reload()};var r=require("react/jsx-runtime");function N(t){let{locales:o,defaultLocale:a,cookieName:n="NEXT_LOCALE",isDropdown:c=!1,autoReload:g=!0,renderCustom:L,className:C,itemClassName:S}=t,[R,y]=(0,s.useState)(!1),[l,u]=(0,s.useState)(a);(0,s.useEffect)(()=>{let e=encodeURIComponent(n),k=document.cookie.split("; ").find(d=>d.startsWith(`${e}=`)),p=k?decodeURIComponent(k.split("=").slice(1).join("=")):null;p&&o.some(d=>d.code===p)?u(p):u(a),y(!0)},[n,a,o]);let m=(0,s.useCallback)(e=>{u(e),i(e,n,g)},[n,g]);return R?L?(0,r.jsx)(r.Fragment,{children:L({locales:o,currentLocale:l,onChange:m})}):c?(0,r.jsx)("select",{value:l,onChange:e=>m(e.target.value),className:C,children:o.map(e=>(0,r.jsxs)("option",{value:e.code,className:S,children:[e.flag," ",e.name]},e.code))}):(0,r.jsx)("div",{className:C,children:o.map(e=>(0,r.jsxs)("button",{onClick:()=>m(e.code),"data-active":l===e.code,"aria-pressed":l===e.code,className:S,children:[e.flag," ",e.name]},e.code))}):null}var w=N;0&&(module.exports={LanguageSelector,setLocaleCookie});
package/dist/index.d.mts CHANGED
@@ -12,8 +12,8 @@ interface LanguageSelectorProps {
12
12
  defaultLocale: string;
13
13
  isDropdown?: boolean;
14
14
  cookieName?: string;
15
- activeColor?: string;
16
15
  className?: string;
16
+ itemClassName?: string;
17
17
  autoReload?: boolean;
18
18
  renderCustom?: (props: {
19
19
  locales: LocaleConfig[];
@@ -22,6 +22,6 @@ interface LanguageSelectorProps {
22
22
  }) => ReactNode;
23
23
  }
24
24
 
25
- declare const LanguageSelector: (props: LanguageSelectorProps) => React.JSX.Element;
25
+ declare const LanguageSelector: (props: LanguageSelectorProps) => React.JSX.Element | null;
26
26
 
27
27
  export { LanguageSelector, type LanguageSelectorProps, type LocaleConfig, setLocaleCookie };
package/dist/index.d.ts CHANGED
@@ -12,8 +12,8 @@ interface LanguageSelectorProps {
12
12
  defaultLocale: string;
13
13
  isDropdown?: boolean;
14
14
  cookieName?: string;
15
- activeColor?: string;
16
15
  className?: string;
16
+ itemClassName?: string;
17
17
  autoReload?: boolean;
18
18
  renderCustom?: (props: {
19
19
  locales: LocaleConfig[];
@@ -22,6 +22,6 @@ interface LanguageSelectorProps {
22
22
  }) => ReactNode;
23
23
  }
24
24
 
25
- declare const LanguageSelector: (props: LanguageSelectorProps) => React.JSX.Element;
25
+ declare const LanguageSelector: (props: LanguageSelectorProps) => React.JSX.Element | null;
26
26
 
27
27
  export { LanguageSelector, type LanguageSelectorProps, type LocaleConfig, setLocaleCookie };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{useEffect as S,useState as x}from"react";var s=(r,o="NEXT_LOCALE",a=!0)=>{typeof document>"u"||(document.cookie=`${o}=${r}; max-age=31536000; path=/`,a&&window.location.reload())};import{Fragment as k,jsx as i,jsxs as g}from"react/jsx-runtime";function m(r){let{locales:o,defaultLocale:a,cookieName:n="NEXT_LOCALE",isDropdown:d=!1,autoReload:f=!0,renderCustom:p,className:l,activeColor:L="red"}=r,[t,u]=x(a);S(()=>{let e=document.cookie.split("; ").find(C=>C.startsWith(`${n}=`))?.split("=")[1];e&&u(e)},[n]);let c=e=>{u(e),s(e,n,f)};return p?i(k,{children:p({locales:o,currentLocale:t,onChange:c})}):d?i("select",{value:t,onChange:e=>c(e.target.value),className:l,children:o.map(e=>g("option",{value:e.code,children:[e.flag," ",e.name]},e.code))}):i("div",{style:{display:"flex",gap:"8px",alignItems:"center"},className:l,children:o.map(e=>g("button",{onClick:()=>c(e.code),style:{cursor:"pointer",background:"none",border:"none",borderBottom:t===e.code?`1px solid ${L}`:"1px solid transparent",color:t===e.code?"inherit":"gray",fontSize:"12px",padding:"2px 4px"},children:[e.flag," ",e.name]},e.code))})}var X=m;export{X as LanguageSelector,s as setLocaleCookie};
1
+ import{useCallback as v,useEffect as E,useState as S}from"react";var m=(c,o="NEXT_LOCALE",a=!0)=>{if(typeof document>"u")return;let t=encodeURIComponent(o),r=encodeURIComponent(c);document.cookie=`${t}=${r}; max-age=31536000; path=/; SameSite=Lax`,a&&window.location.reload()};import{Fragment as x,jsx as p,jsxs as k}from"react/jsx-runtime";function N(c){let{locales:o,defaultLocale:a,cookieName:t="NEXT_LOCALE",isDropdown:r=!1,autoReload:d=!0,renderCustom:f,className:g,itemClassName:L}=c,[R,y]=S(!1),[n,s]=S(a);E(()=>{let e=encodeURIComponent(t),C=document.cookie.split("; ").find(u=>u.startsWith(`${e}=`)),i=C?decodeURIComponent(C.split("=").slice(1).join("=")):null;i&&o.some(u=>u.code===i)?s(i):s(a),y(!0)},[t,a,o]);let l=v(e=>{s(e),m(e,t,d)},[t,d]);return R?f?p(x,{children:f({locales:o,currentLocale:n,onChange:l})}):r?p("select",{value:n,onChange:e=>l(e.target.value),className:g,children:o.map(e=>k("option",{value:e.code,className:L,children:[e.flag," ",e.name]},e.code))}):p("div",{className:g,children:o.map(e=>k("button",{onClick:()=>l(e.code),"data-active":n===e.code,"aria-pressed":n===e.code,className:L,children:[e.flag," ",e.name]},e.code))}):null}var A=N;export{A as LanguageSelector,m as setLocaleCookie};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-language-selector",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "description": "Configurable language selector for Next.js",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.mjs",
@@ -10,23 +10,21 @@
10
10
  "types": "./dist/index.d.ts",
11
11
  "import": "./dist/index.mjs",
12
12
  "require": "./dist/index.cjs"
13
- },
14
- "./selector": {
15
- "types": "./dist/selector.d.ts",
16
- "import": "./dist/selector.mjs",
17
- "require": "./dist/selector.cjs"
18
13
  }
19
14
  },
20
15
  "scripts": {
21
16
  "build": "tsup",
22
17
  "dev": "tsup --watch",
23
- "lint": "tsc --noEmit"
18
+ "lint": "tsc --noEmit",
19
+ "test": "vitest",
20
+ "test:run": "vitest run",
21
+ "test:coverage": "npx vitest run --coverage"
24
22
  },
25
23
  "files": [
26
24
  "dist"
27
25
  ],
28
26
  "peerDependencies": {
29
- "next": "^14.2.35 || ^15.0.0 || ^16.0.0",
27
+ "next": "^15.0.0 || ^16.0.0",
30
28
  "react": "^18.0.0 || ^19.0.0"
31
29
  },
32
30
  "keywords": [
@@ -50,10 +48,17 @@
50
48
  "url": "git+https://github.com/kirilinsky/next-language-selector.git"
51
49
  },
52
50
  "devDependencies": {
51
+ "@testing-library/jest-dom": "^6.9.1",
52
+ "@testing-library/react": "^16.3.2",
53
+ "@testing-library/user-event": "^14.6.1",
53
54
  "@types/react": "^19.2.14",
55
+ "@vitest/coverage-v8": "^4.1.2",
56
+ "happy-dom": "^20.8.9",
54
57
  "tslib": "^2.8.1",
55
58
  "tsup": "^8.5.1",
56
- "typescript": "^5.9.3"
59
+ "typescript": "^5.9.3",
60
+ "vitest": "^4.1.2"
57
61
  },
58
- "sideEffects": false
59
- }
62
+ "sideEffects": false,
63
+ "packageManager": "pnpm@10.32.1"
64
+ }