@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 +6 -6
- package/src/components/navigation/pagination/pagination-static.tsx +24 -11
- package/src/components/navigation/pagination/pagination.tsx +40 -22
- package/src/components/navigation/pagination/ssr-pagination.tsx +36 -7
- package/src/lib/format-number.ts +51 -0
- package/src/lib/index.ts +1 -0
- package/src/styles/css/presets/macos.css +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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.
|
|
210
|
-
"@djangocfg/i18n": "^2.1.
|
|
211
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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 {
|
|
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
|
-
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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=
|
|
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
|
-
{
|
|
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=
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 {
|
|
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
|
-
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
{
|
|
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
|
@@ -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.
|