@djangocfg/ui-core 2.1.561 → 2.1.562

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.561",
3
+ "version": "2.1.562",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -126,11 +126,11 @@
126
126
  ],
127
127
  "scripts": {
128
128
  "lint": "eslint .",
129
- "check": "tsc --noEmit",
129
+ "check": "tsc --noEmit && node scripts/check-preset-contrast.mjs",
130
130
  "check:contrast": "node scripts/check-preset-contrast.mjs"
131
131
  },
132
132
  "peerDependencies": {
133
- "@djangocfg/i18n": "^2.1.561",
133
+ "@djangocfg/i18n": "^2.1.562",
134
134
  "consola": "^3.4.2",
135
135
  "lucide-react": "^0.545.0",
136
136
  "moment": "^2.30.1",
@@ -206,9 +206,9 @@
206
206
  "vaul": "1.1.2"
207
207
  },
208
208
  "devDependencies": {
209
- "@djangocfg/eslint-config": "^2.1.561",
210
- "@djangocfg/i18n": "^2.1.561",
211
- "@djangocfg/typescript-config": "^2.1.561",
209
+ "@djangocfg/eslint-config": "^2.1.562",
210
+ "@djangocfg/i18n": "^2.1.562",
211
+ "@djangocfg/typescript-config": "^2.1.562",
212
212
  "@storybook/react-vite": "^10.5.0",
213
213
  "@types/node": "^24.13.3",
214
214
  "@types/react": "19.2.15",
@@ -10,7 +10,9 @@
10
10
  import { ChevronLeft, ChevronRight } from 'lucide-react';
11
11
  import React from 'react';
12
12
 
13
- import { cn } from '../../../lib';
13
+ import { useAppT, useI18n } from '@djangocfg/i18n';
14
+
15
+ import { cn, formatNumber } from '../../../lib';
14
16
  import { Button } from '../../forms/button';
15
17
  import { useIsMobile } from '../../../hooks';
16
18
 
@@ -46,6 +48,8 @@ export const StaticPagination: React.FC<StaticPaginationProps> = ({
46
48
  maxVisiblePages = 7,
47
49
  }) => {
48
50
  const isMobile = useIsMobile();
51
+ const t = useAppT();
52
+ const { locale } = useI18n();
49
53
 
50
54
  if (!data || !('count' in data) || !('page' in data) || !('pages' in data)) {
51
55
  return null;
@@ -129,11 +133,17 @@ export const StaticPagination: React.FC<StaticPaginationProps> = ({
129
133
  <div className={cn("space-y-4", className)}>
130
134
  {showInfo && (
131
135
  <div className="text-sm text-muted-foreground text-center">
132
- {isMobile ? (
133
- `Page ${currentPage} of ${totalPages}`
134
- ) : (
135
- `Showing ${startItem.toLocaleString()} to ${endItem.toLocaleString()} of ${totalItems.toLocaleString()} results`
136
- )}
136
+ {/* Through `ui.pagination.*`, which ships translated in every
137
+ locale this package supports. The figures are grouped with
138
+ `toLocaleString(locale)` rather than the host's default, so a
139
+ German reader sees `1.234` and a French one `1 234`. */}
140
+ {isMobile
141
+ ? `${t('ui.pagination.page', { page: currentPage })} ${t('ui.pagination.of', { total: totalPages })}`
142
+ : t('ui.pagination.showing', {
143
+ from: formatNumber(startItem, locale),
144
+ to: formatNumber(endItem, locale),
145
+ total: formatNumber(totalItems, locale),
146
+ })}
137
147
  </div>
138
148
  )}
139
149
 
@@ -149,10 +159,9 @@ export const StaticPagination: React.FC<StaticPaginationProps> = ({
149
159
  "gap-1 pl-2.5",
150
160
  !hasPreviousPage && "pointer-events-none opacity-50"
151
161
  )}
152
- aria-label="Go to previous page"
162
+ aria-label={t('ui.pagination.previous')}
153
163
  >
154
164
  <ChevronLeft className="h-4 w-4" />
155
- <span>Previous</span>
156
165
  </Button>
157
166
  </PaginationItem>
158
167
 
@@ -170,7 +179,12 @@ export const StaticPagination: React.FC<StaticPaginationProps> = ({
170
179
  page === currentPage && "pointer-events-none"
171
180
  )}
172
181
  >
173
- {page}
182
+ {/* GROUPED, not compacted. `34 781` reads; `34781` is
183
+ scanned digit by digit. But never `34,8 тыс.` — this is
184
+ a destination the reader clicks, and a rounded address
185
+ is not an address. Compaction belongs on counts, never
186
+ on page numbers. */}
187
+ {formatNumber(page, locale)}
174
188
  </Button>
175
189
  )}
176
190
  </PaginationItem>
@@ -186,9 +200,8 @@ export const StaticPagination: React.FC<StaticPaginationProps> = ({
186
200
  "gap-1 pr-2.5",
187
201
  !hasNextPage && "pointer-events-none opacity-50"
188
202
  )}
189
- aria-label="Go to next page"
203
+ aria-label={t('ui.pagination.next')}
190
204
  >
191
- <span>Next</span>
192
205
  <ChevronRight className="h-4 w-4" />
193
206
  </Button>
194
207
  </PaginationItem>
@@ -1,6 +1,10 @@
1
+ "use client";
2
+
1
3
  import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
2
4
  import * as React from 'react';
3
5
 
6
+ import { useAppT } from '@djangocfg/i18n';
7
+
4
8
  import { cn } from '../../../lib';
5
9
  import { type ButtonProps, buttonVariants } from '../../forms/button';
6
10
  import { Link } from '../link';
@@ -90,36 +94,50 @@ const PaginationLink = ({
90
94
  }
91
95
  PaginationLink.displayName = "PaginationLink"
92
96
 
97
+ /* `ui.pagination.*` has shipped translated in all seventeen locales since
98
+ this package gained an i18n layer; only these components never read it, so
99
+ every app using them paginated in English. `useAppT` falls back to the
100
+ English defaults when no provider is mounted, which is what makes this safe
101
+ in a 404 or any tree rendered outside the app shell. */
93
102
  const PaginationPrevious = ({
94
103
  className,
95
104
  ...props
96
- }: React.ComponentProps<typeof PaginationLink>) => (
97
- <PaginationLink
98
- aria-label="Go to previous page"
99
- size="default"
100
- className={cn("gap-1 pl-2.5", className)}
101
- {...props}
102
- >
103
- <ChevronLeft className="h-4 w-4" />
104
- <span>Previous</span>
105
- </PaginationLink>
106
- )
105
+ }: React.ComponentProps<typeof PaginationLink>) => {
106
+ const t = useAppT();
107
+ return (
108
+ <PaginationLink
109
+ aria-label={t('ui.pagination.previous')}
110
+ size="default"
111
+ className={cn("gap-1", className)}
112
+ {...props}
113
+ >
114
+ {/* ICON ONLY — a chevron pointing back is the settled convention for
115
+ "previous page"; a word beside it adds nothing a reader needs while
116
+ adding a string every locale must carry and every layout must fit.
117
+ The `aria-label` above keeps the name, because a button without one
118
+ is announced as "button". */}
119
+ <ChevronLeft className="h-4 w-4" />
120
+ </PaginationLink>
121
+ )
122
+ }
107
123
  PaginationPrevious.displayName = "PaginationPrevious"
108
124
 
109
125
  const PaginationNext = ({
110
126
  className,
111
127
  ...props
112
- }: React.ComponentProps<typeof PaginationLink>) => (
113
- <PaginationLink
114
- aria-label="Go to next page"
115
- size="default"
116
- className={cn("gap-1 pr-2.5", className)}
117
- {...props}
118
- >
119
- <span>Next</span>
120
- <ChevronRight className="h-4 w-4" />
121
- </PaginationLink>
122
- )
128
+ }: React.ComponentProps<typeof PaginationLink>) => {
129
+ const t = useAppT();
130
+ return (
131
+ <PaginationLink
132
+ aria-label={t('ui.pagination.next')}
133
+ size="default"
134
+ className={cn("gap-1", className)}
135
+ {...props}
136
+ >
137
+ <ChevronRight className="h-4 w-4" />
138
+ </PaginationLink>
139
+ )
140
+ }
123
141
  PaginationNext.displayName = "PaginationNext"
124
142
 
125
143
  const PaginationEllipsis = ({
@@ -2,7 +2,9 @@
2
2
 
3
3
  import React from 'react';
4
4
 
5
- import { cn } from '../../../lib';
5
+ import { useAppT, useI18n } from '@djangocfg/i18n';
6
+
7
+ import { cn, formatNumber } from '../../../lib';
6
8
  import { useIsMobile } from '../../../hooks';
7
9
  import { useLocation, useQueryParams } from '../../../hooks/router';
8
10
 
@@ -59,6 +61,8 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
59
61
  const queryParams = useQueryParams();
60
62
  const { pathname } = useLocation();
61
63
  const isMobile = useIsMobile();
64
+ const t = useAppT();
65
+ const { locale } = useI18n();
62
66
 
63
67
  const getCurrentPageFromUrl = (): number => {
64
68
  const pageParam = queryParams.get('page');
@@ -81,6 +85,20 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
81
85
  return queryString ? `${basePath}?${queryString}` : basePath;
82
86
  };
83
87
 
88
+ /* NO "first" / "last" BUTTONS, deliberately.
89
+ *
90
+ * `ui.pagination.first` and `.last` exist in the dictionary and nothing
91
+ * renders them. That is the right outcome, not an omission to finish:
92
+ *
93
+ * - The row ALREADY ENDS IN BOTH. `1` is always pushed first and
94
+ * `totalPages` always last, so a jump to either end is one click on a
95
+ * number the reader can already see. A dedicated button would be a third
96
+ * route to a place two already reach.
97
+ * - "Last" is a destination nobody wants. On a catalogue sorted by date or
98
+ * price, page 34,781 is the worst match for the query — the control is
99
+ * cheap to build and answers a question no one asks.
100
+ *
101
+ * Two chevrons and the numbers. Anything more is furniture. */
84
102
  const getVisiblePages = (): (number | 'ellipsis')[] => {
85
103
  const mobileMaxVisible = 3;
86
104
  const effectiveMaxVisible = isMobile ? mobileMaxVisible : maxVisiblePages;
@@ -129,11 +147,17 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
129
147
  <div className={cn("space-y-4", className)}>
130
148
  {showInfo && (
131
149
  <div className="text-sm text-muted-foreground text-center">
132
- {isMobile ? (
133
- `Page ${actualCurrentPage} of ${totalPages}`
134
- ) : (
135
- `Showing ${startItem.toLocaleString()} to ${endItem.toLocaleString()} of ${totalItems.toLocaleString()} results`
136
- )}
150
+ {/* Through `ui.pagination.*`, which ships translated in every
151
+ locale this package supports. The figures are grouped with
152
+ `toLocaleString(locale)` rather than the host's default, so a
153
+ German reader sees `1.234` and a French one `1 234`. */}
154
+ {isMobile
155
+ ? `${t('ui.pagination.page', { page: actualCurrentPage })} ${t('ui.pagination.of', { total: totalPages })}`
156
+ : t('ui.pagination.showing', {
157
+ from: formatNumber(startItem, locale),
158
+ to: formatNumber(endItem, locale),
159
+ total: formatNumber(totalItems, locale),
160
+ })}
137
161
  </div>
138
162
  )}
139
163
 
@@ -157,7 +181,12 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
157
181
  isActive={page === actualCurrentPage}
158
182
  scroll
159
183
  >
160
- {page}
184
+ {/* GROUPED, not compacted. `34 781` reads; `34781` is
185
+ scanned digit by digit. But never `34,8 тыс.` — this is
186
+ a destination the reader clicks, and a rounded address
187
+ is not an address. Compaction belongs on counts, never
188
+ on page numbers. */}
189
+ {formatNumber(page, locale)}
161
190
  </PaginationLink>
162
191
  )}
163
192
  </PaginationItem>
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Numbers, written the way the reader's language writes them.
3
+ *
4
+ * Two functions, because a figure does two different jobs and they want
5
+ * opposite things:
6
+ *
7
+ * - `formatNumber` — the figure IS the information. A price, a mileage, a
8
+ * result count the reader may quote or compare. Grouped, never rounded.
9
+ * - `formatCompact` — the figure is a SIZE, glanced at in chrome: a badge, a
10
+ * tab, a nav counter. Four digits is where a number stops being read and
11
+ * starts being measured, and the exact value belongs on the page it leads
12
+ * to, not on the label pointing at it.
13
+ *
14
+ * WHY `Intl` AND NOT `${Math.round(n / 1000)}k`
15
+ *
16
+ * Measured, not assumed — `834_763` through `notation: 'compact'`:
17
+ *
18
+ * en 834.8K ru 834,8 тыс.
19
+ * ko 83.5만 de 834.763
20
+ *
21
+ * Korean groups by TEN THOUSAND, not by thousands, so a hardcoded `k` is not
22
+ * merely untranslated there — it states the wrong magnitude. Russian wants a
23
+ * word and a comma decimal. Hand-rolling this means hand-rolling CLDR.
24
+ *
25
+ * AND WHY THE CALLER STILL CANNOT ASSUME IT IS SHORT
26
+ *
27
+ * Note `de` in that table: Node returns the FULL number, because compact data
28
+ * is locale-dependent and a runtime may simply not carry it. `formatCompact`
29
+ * is a request, not a guarantee — never size a container on the assumption
30
+ * that it returns four characters.
31
+ */
32
+
33
+ /** Grouped by the reader's locale. `834763` → `834,763` / `834 763`. */
34
+ export function formatNumber(value: number, locale?: string): string {
35
+ return new Intl.NumberFormat(locale).format(value);
36
+ }
37
+
38
+ /**
39
+ * Shortened for chrome, in the reader's own notation.
40
+ *
41
+ * Below `threshold` the exact figure is returned grouped — a badge reading
42
+ * `1.2K` where it could say `1,180` has traded precision for nothing.
43
+ */
44
+ export function formatCompact(value: number, locale?: string, threshold = 10_000): string {
45
+ if (Math.abs(value) < threshold) return formatNumber(value, locale);
46
+
47
+ return new Intl.NumberFormat(locale, {
48
+ notation: 'compact',
49
+ maximumFractionDigits: 1,
50
+ }).format(value);
51
+ }
package/src/lib/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./utils";
2
+ export * from "./format-number";
2
3
  export * from "./logger";
3
4
  export * from "./dialog-service";
4
5
  export * from "./env";
@@ -102,7 +102,14 @@
102
102
  --accent: hsl(211 25% 19%);
103
103
  --accent-foreground: hsl(211 100% 72%);
104
104
  --destructive: hsl(3 100% 62%);
105
+ /* Both ink tokens must flip with the fill. The dark --destructive is far
106
+ brighter than the light one, so the white label this preset sets above
107
+ drops to 3.38:1 on it; dark ink is 5.31:1. --destructive-foreground was
108
+ flipped here from the start and --on-destructive was not, which left
109
+ `text-on-destructive` inheriting white from the light block — unreadable,
110
+ and invisible to check-preset-contrast.mjs, which does not pair them. */
105
111
  --destructive-foreground: hsl(0 0% 9%);
112
+ --on-destructive: hsl(0 0% 9%);
106
113
  /* A hairline divides, it does not glow. Ten points over --background is a
107
114
  definite edge at one device pixel without becoming the loudest thing on
108
115
  the page — which it was once the fills stopped separating surfaces.