@djangocfg/ui-core 2.1.560 → 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.560",
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.560",
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.560",
210
- "@djangocfg/i18n": "^2.1.560",
211
- "@djangocfg/typescript-config": "^2.1.560",
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>
@@ -66,6 +66,8 @@ export {
66
66
  parseAsJson,
67
67
  // useRouter (composite facade)
68
68
  useRouter,
69
+ // useScrollToTop (covers the routes Next's own scroll handler skips)
70
+ useScrollToTop,
69
71
  } from './router';
70
72
  export type {
71
73
  RouterAdapter,
@@ -93,4 +95,5 @@ export type {
93
95
  QueryParser,
94
96
  QueryParserBuilder,
95
97
  UseRouterReturn,
98
+ UseScrollToTopOptions,
96
99
  } from './router';
@@ -22,12 +22,17 @@ export type {
22
22
  export {
23
23
  useLocation,
24
24
  useLocationProperty,
25
+ patchHistoryOnce,
25
26
  NAVIGATE_EVENT,
26
27
  PUSH_STATE_EVENT,
27
28
  REPLACE_STATE_EVENT,
28
29
  } from './useLocation';
29
30
  export type { LocationSnapshot } from './useLocation';
30
31
 
32
+ // useScrollToTop (fills the gaps in Next's own scroll restoration)
33
+ export { useScrollToTop } from './useScrollToTop';
34
+ export type { UseScrollToTopOptions } from './useScrollToTop';
35
+
31
36
  // useNavigate
32
37
  export { useNavigate } from './useNavigate';
33
38
  export type { NavigateOptions, UseNavigateReturn } from './useNavigate';
@@ -54,7 +54,16 @@ const SSR_SNAPSHOT: LocationSnapshot = Object.freeze({
54
54
  // (us or them) has installed a patch, the marker stays until full reload.
55
55
  const PATCH_KEY = Symbol.for('djc.router.historyPatched');
56
56
 
57
- function patchHistoryOnce(): void {
57
+ /**
58
+ * Installs the `pushState`/`replaceState` patch that makes SPA navigations
59
+ * observable, exactly once per document.
60
+ *
61
+ * Exported because `useLocation` is no longer the only subscriber: a hook may
62
+ * listen for `NAVIGATE_EVENT` without reading the location through
63
+ * `useSyncExternalStore` (see `useScrollToTop`), and without this call it
64
+ * would subscribe to an event nothing dispatches — silently doing nothing.
65
+ */
66
+ export function patchHistoryOnce(): void {
58
67
  if (typeof window === 'undefined') return;
59
68
  const w = window as Window & { [PATCH_KEY]?: true };
60
69
  if (w[PATCH_KEY]) return;
@@ -0,0 +1,120 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useScrollToTop — land at the top of the page after a forward navigation.
5
+ *
6
+ * WHY THIS EXISTS AT ALL — Next.js App Router already scrolls, sometimes
7
+ *
8
+ * `layout-router.tsx` walks the navigated segment looking for an element to
9
+ * scroll to, and **gives up silently** in two cases that are ordinary in our
10
+ * apps:
11
+ *
12
+ * 1. It takes the segment's first DOM node and skips it when the node is
13
+ * `position: sticky|fixed` OR when `getBoundingClientRect()` is all
14
+ * zeroes — then moves to `nextElementSibling`, and `return`s outright
15
+ * when there is none. A page whose root is `display: contents` (a width
16
+ * wrapper), or whose first child renders `null`, `<script type=
17
+ * "application/ld+json">`, or any other zero-box node, therefore gets no
18
+ * scroll at all.
19
+ * 2. Having found a node, it exits early when that node's top edge is
20
+ * ALREADY within the viewport. Land mid-page on a long route whose next
21
+ * route starts with a tall block, and the check passes while the visitor
22
+ * is looking at the middle of the new page.
23
+ *
24
+ * Both are position-dependent, which is why the bug reads as intermittent:
25
+ * clicking from the top of a page (what an automated run usually does) hits
26
+ * neither branch, so the symptom only shows up when a person scrolls first.
27
+ *
28
+ * WHAT IT DELIBERATELY DOES NOT DO
29
+ *
30
+ * - **Back/forward are left alone.** The browser restores the previous scroll
31
+ * position on a `popstate`, and overriding that is a worse bug than the one
32
+ * being fixed. Only `pushState`/`replaceState` navigations scroll.
33
+ * - **`#hash` links are left alone.** A URL naming an anchor has already said
34
+ * where it wants to land.
35
+ * - **Query-only changes are left alone.** Filters, pagination and tab state
36
+ * write the query string; yanking the visitor to the top as they tick a
37
+ * checkbox is the complaint this hook would otherwise cause. Only a
38
+ * `pathname` change scrolls.
39
+ *
40
+ * It is idempotent with Next's own handler: when Next succeeds we are already
41
+ * at the top and the write is a no-op.
42
+ *
43
+ * @example
44
+ * // Once, at the app root — `BaseApp` already does this for Next.js hosts.
45
+ * useScrollToTop();
46
+ */
47
+
48
+ import { useEffect, useRef } from 'react';
49
+
50
+ import { NAVIGATE_EVENT, patchHistoryOnce } from './useLocation';
51
+
52
+ export interface UseScrollToTopOptions {
53
+ /** Turn the behaviour off without unmounting the host. Default: true. */
54
+ enabled?: boolean;
55
+ /**
56
+ * Scroll animation. Default `'auto'` (instant), matching a hard navigation.
57
+ * `'smooth'` animates a full page height on every route change, which reads
58
+ * as lag rather than polish.
59
+ */
60
+ behavior?: ScrollBehavior;
61
+ }
62
+
63
+ /**
64
+ * Scrolls the window to the top after each forward navigation to a new path.
65
+ * Mount ONCE per app — a second mount just writes `scrollTo(0, 0)` twice.
66
+ */
67
+ export function useScrollToTop({
68
+ enabled = true,
69
+ behavior = 'auto',
70
+ }: UseScrollToTopOptions = {}): void {
71
+ /**
72
+ * The path we last saw. Seeded on mount rather than from `''`, so the first
73
+ * paint does not count as a navigation — that would fight the browser's own
74
+ * restoration on a reload.
75
+ */
76
+ const lastPathRef = useRef<string | null>(null);
77
+
78
+ useEffect(() => {
79
+ if (!enabled || typeof window === 'undefined') return;
80
+
81
+ // `pushState`/`replaceState` do not emit an event on their own. Without
82
+ // this the listener below is subscribed to something nothing dispatches.
83
+ patchHistoryOnce();
84
+ lastPathRef.current = window.location.pathname;
85
+
86
+ /*
87
+ * NAVIGATE_EVENT only, and that is what keeps back/forward working: the
88
+ * patch in `useLocation` dispatches it from `pushState`/`replaceState`
89
+ * exclusively, so a `popstate` never reaches this handler and the
90
+ * browser's restored position is never overwritten. Subscribing to
91
+ * `popstate` here — or to `hashchange` — would undo exactly that.
92
+ */
93
+ const onNavigate = () => {
94
+ const { pathname, hash } = window.location;
95
+ const changedPath = pathname !== lastPathRef.current;
96
+ lastPathRef.current = pathname;
97
+
98
+ // Query-only or state-only writes: the visitor stays where they are.
99
+ if (!changedPath) return;
100
+ // The URL names an anchor; it has already said where to land.
101
+ if (hash) return;
102
+
103
+ /*
104
+ * Deferred by one frame, and this is load-bearing. Our navigate event
105
+ * fires from a microtask right after `pushState`, while the destination
106
+ * is still the OLD document — React has not committed the new segment
107
+ * yet. Scrolling then is correct but immediately undone: Next's own
108
+ * handler runs on the commit that follows and may scroll back down to
109
+ * an element it found mid-page. Running after paint means we write
110
+ * last, over a document whose real height is known.
111
+ */
112
+ requestAnimationFrame(() => {
113
+ window.scrollTo({ top: 0, left: 0, behavior });
114
+ });
115
+ };
116
+
117
+ window.addEventListener(NAVIGATE_EVENT, onNavigate);
118
+ return () => window.removeEventListener(NAVIGATE_EVENT, onNavigate);
119
+ }, [enabled, behavior]);
120
+ }
@@ -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.