@djangocfg/ui-core 2.1.537 → 2.1.540
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 +4 -4
- package/src/components/forms/input-affix/index.tsx +65 -0
- package/src/components/forms/input-group/index.tsx +34 -7
- package/src/components/index.ts +1 -0
- package/src/components/navigation/pagination/pagination.tsx +9 -0
- package/src/components/navigation/pagination/ssr-pagination.tsx +3 -0
- package/src/components/overlay/dialog/index.tsx +17 -1
- package/src/components/overlay/responsive-sheet/index.tsx +36 -2
- package/src/hooks/router/useNavigate.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.540",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
"check:contrast": "node scripts/check-preset-contrast.mjs"
|
|
129
129
|
},
|
|
130
130
|
"peerDependencies": {
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
131
|
+
"@djangocfg/i18n": "^2.1.540",
|
|
132
132
|
"consola": "^3.4.2",
|
|
133
133
|
"lucide-react": "^0.545.0",
|
|
134
134
|
"moment": "^2.30.1",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"@chenglou/pretext": "^0.0.8"
|
|
207
207
|
},
|
|
208
208
|
"devDependencies": {
|
|
209
|
-
"@djangocfg/i18n": "^2.1.
|
|
210
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
209
|
+
"@djangocfg/i18n": "^2.1.540",
|
|
210
|
+
"@djangocfg/typescript-config": "^2.1.540",
|
|
211
211
|
"@types/node": "^24.13.3",
|
|
212
212
|
"@types/react": "19.2.15",
|
|
213
213
|
"@types/react-dom": "19.2.3",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../../lib/utils';
|
|
6
|
+
import {
|
|
7
|
+
InputGroup,
|
|
8
|
+
InputGroupAddon,
|
|
9
|
+
InputGroupInput,
|
|
10
|
+
} from '../input-group';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An input with a fixed symbol before and/or after the value.
|
|
14
|
+
*
|
|
15
|
+
* `InputGroup` already does this, but it takes four components and the right
|
|
16
|
+
* `align` on each — enough ceremony that a currency field gets written as a
|
|
17
|
+
* bare `Input` with the unit in the label instead, which is how a form ends up
|
|
18
|
+
* not saying what unit it is in.
|
|
19
|
+
*
|
|
20
|
+
* This is the same thing in one component:
|
|
21
|
+
*
|
|
22
|
+
* <InputAffix prefix="$" value={price} onChange={onPrice} />
|
|
23
|
+
* <InputAffix suffix="%" value={rate} onChange={onRate} />
|
|
24
|
+
* <InputAffix prefix="$" suffix="/ yr" value={rent} onChange={onRent} />
|
|
25
|
+
*
|
|
26
|
+
* The affixes are **static marks, not content**: a currency symbol, a unit, a
|
|
27
|
+
* per-period suffix. They carry `aria-hidden` because the accessible name
|
|
28
|
+
* belongs on the field's label — a screen reader announcing "dollar sign" as
|
|
29
|
+
* it enters the field is noise, and a label reading "Purchase price in US
|
|
30
|
+
* dollars" is the thing that actually helps. Where an affix carries meaning
|
|
31
|
+
* the label does not, put it in the label or in a `FieldDescription`.
|
|
32
|
+
*
|
|
33
|
+
* Not for a button, a select, or anything interactive. Reach for `InputGroup`
|
|
34
|
+
* directly there — that is what `InputGroupButton` is for.
|
|
35
|
+
*/
|
|
36
|
+
export interface InputAffixProps
|
|
37
|
+
extends Omit<React.ComponentProps<typeof InputGroupInput>, 'prefix'> {
|
|
38
|
+
/** Shown before the value — a currency symbol, a unit. */
|
|
39
|
+
prefix?: React.ReactNode;
|
|
40
|
+
/** Shown after the value — a unit, a period. */
|
|
41
|
+
suffix?: React.ReactNode;
|
|
42
|
+
/** Applied to the group, so callers can size the whole control. */
|
|
43
|
+
groupClassName?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const InputAffix = React.forwardRef<HTMLInputElement, InputAffixProps>(
|
|
47
|
+
({ prefix, suffix, className, groupClassName, ...props }, ref) => (
|
|
48
|
+
<InputGroup className={groupClassName}>
|
|
49
|
+
{prefix ? (
|
|
50
|
+
<InputGroupAddon align="inline-start" aria-hidden="true">
|
|
51
|
+
{prefix}
|
|
52
|
+
</InputGroupAddon>
|
|
53
|
+
) : null}
|
|
54
|
+
<InputGroupInput ref={ref} className={cn(className)} {...props} />
|
|
55
|
+
{suffix ? (
|
|
56
|
+
<InputGroupAddon align="inline-end" aria-hidden="true">
|
|
57
|
+
{suffix}
|
|
58
|
+
</InputGroupAddon>
|
|
59
|
+
) : null}
|
|
60
|
+
</InputGroup>
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
InputAffix.displayName = 'InputAffix';
|
|
64
|
+
|
|
65
|
+
export { InputAffix };
|
|
@@ -15,7 +15,18 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
15
15
|
role="group"
|
|
16
16
|
className={cn(
|
|
17
17
|
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-[var(--radius)] border outline-none transition-[color,box-shadow]",
|
|
18
|
-
|
|
18
|
+
// Matches `Input`'s default size (h-10), not its `sm` one.
|
|
19
|
+
//
|
|
20
|
+
// This was `h-9`, which made a grouped input a row shorter than a bare
|
|
21
|
+
// `Input` or a `SelectTrigger` beside it — visible the moment a form
|
|
22
|
+
// puts a prefixed money field next to a plain select, and not
|
|
23
|
+
// attributable to either component from the outside. A wrapper that
|
|
24
|
+
// changes a control's height is a wrapper that cannot be dropped into
|
|
25
|
+
// an existing row.
|
|
26
|
+
//
|
|
27
|
+
// Use `className="h-9"` at the call site for the compact size, the
|
|
28
|
+
// same way `inputSize="sm"` is opted into rather than inherited.
|
|
29
|
+
"h-10 has-[>textarea]:h-auto",
|
|
19
30
|
|
|
20
31
|
// Variants based on alignment.
|
|
21
32
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
|
@@ -128,21 +139,37 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
|
|
128
139
|
)
|
|
129
140
|
}
|
|
130
141
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Forwards its ref, unlike the other parts of this group.
|
|
144
|
+
*
|
|
145
|
+
* `Input` is a `forwardRef`, and a plain function wrapper around it swallows
|
|
146
|
+
* the ref silently — no warning, no type error, just a null ref at the call
|
|
147
|
+
* site. Anything wrapping this (see `InputAffix`) needs the node to focus or
|
|
148
|
+
* measure it.
|
|
149
|
+
*/
|
|
150
|
+
const InputGroupInput = React.forwardRef<
|
|
151
|
+
HTMLInputElement,
|
|
152
|
+
React.ComponentProps<"input">
|
|
153
|
+
>(({ className, ...props }, ref) => {
|
|
135
154
|
return (
|
|
136
155
|
<Input
|
|
156
|
+
ref={ref}
|
|
137
157
|
data-slot="input-group-control"
|
|
138
158
|
className={cn(
|
|
139
|
-
|
|
159
|
+
// `focus-visible:border-transparent` alongside `ring-0`: the group
|
|
160
|
+
// draws the focus ring for the whole control, and `INPUT_BASE` also
|
|
161
|
+
// turns the input's own border to `--ring` on focus. Suppressing only
|
|
162
|
+
// the ring left that border visible, so a focused money field showed a
|
|
163
|
+
// rectangle inside a rectangle — the addon sitting outside the inner
|
|
164
|
+
// one, which is exactly what the group exists to prevent.
|
|
165
|
+
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:border-transparent focus-visible:ring-0 dark:bg-transparent",
|
|
140
166
|
className
|
|
141
167
|
)}
|
|
142
168
|
{...props}
|
|
143
169
|
/>
|
|
144
170
|
)
|
|
145
|
-
}
|
|
171
|
+
})
|
|
172
|
+
InputGroupInput.displayName = "InputGroupInput"
|
|
146
173
|
|
|
147
174
|
function InputGroupTextarea({
|
|
148
175
|
className,
|
package/src/components/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export { Slider } from './forms/slider';
|
|
|
19
19
|
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants } from './forms/button-group';
|
|
20
20
|
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField } from './forms/form';
|
|
21
21
|
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea } from './forms/input-group';
|
|
22
|
+
export { InputAffix, type InputAffixProps } from './forms/input-affix';
|
|
22
23
|
export { InputOTP, InputOTPGroup, InputOTPSlot } from './forms/input-otp';
|
|
23
24
|
export { PhoneInput } from './forms/phone-input';
|
|
24
25
|
export type { PhoneInputProps } from './forms/phone-input';
|
|
@@ -38,6 +38,13 @@ PaginationItem.displayName = "PaginationItem"
|
|
|
38
38
|
type PaginationLinkProps = {
|
|
39
39
|
isActive?: boolean
|
|
40
40
|
href?: string
|
|
41
|
+
/**
|
|
42
|
+
* Scroll to the top after navigating. Forwarded to `<Link>`, which defaults
|
|
43
|
+
* it to `false` — so a paginator that omits it leaves the visitor at the
|
|
44
|
+
* scroll offset they had on the previous page, looking at row 40 of a list
|
|
45
|
+
* that now starts again at row 1.
|
|
46
|
+
*/
|
|
47
|
+
scroll?: boolean
|
|
41
48
|
} & Pick<ButtonProps, "size"> &
|
|
42
49
|
React.ComponentProps<"a">
|
|
43
50
|
|
|
@@ -46,6 +53,7 @@ const PaginationLink = ({
|
|
|
46
53
|
isActive,
|
|
47
54
|
size = "icon",
|
|
48
55
|
href,
|
|
56
|
+
scroll,
|
|
49
57
|
children,
|
|
50
58
|
...props
|
|
51
59
|
}: PaginationLinkProps) => {
|
|
@@ -63,6 +71,7 @@ const PaginationLink = ({
|
|
|
63
71
|
href={href}
|
|
64
72
|
aria-current={isActive ? "page" : undefined}
|
|
65
73
|
className={classes}
|
|
74
|
+
scroll={scroll}
|
|
66
75
|
>
|
|
67
76
|
{children}
|
|
68
77
|
</Link>
|
|
@@ -143,6 +143,7 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
|
|
|
143
143
|
<PaginationPrevious
|
|
144
144
|
href={actualHasPreviousPage ? getPageUrl(actualCurrentPage - 1) : undefined}
|
|
145
145
|
className={!actualHasPreviousPage ? "pointer-events-none opacity-50" : undefined}
|
|
146
|
+
scroll
|
|
146
147
|
/>
|
|
147
148
|
</PaginationItem>
|
|
148
149
|
|
|
@@ -154,6 +155,7 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
|
|
|
154
155
|
<PaginationLink
|
|
155
156
|
href={getPageUrl(page)}
|
|
156
157
|
isActive={page === actualCurrentPage}
|
|
158
|
+
scroll
|
|
157
159
|
>
|
|
158
160
|
{page}
|
|
159
161
|
</PaginationLink>
|
|
@@ -165,6 +167,7 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
|
|
|
165
167
|
<PaginationNext
|
|
166
168
|
href={hasNextPage ? getPageUrl(actualCurrentPage + 1) : undefined}
|
|
167
169
|
className={!hasNextPage ? "pointer-events-none opacity-50" : undefined}
|
|
170
|
+
scroll
|
|
168
171
|
/>
|
|
169
172
|
</PaginationItem>
|
|
170
173
|
</PaginationContent>
|
|
@@ -101,7 +101,23 @@ const DialogContent = React.forwardRef<
|
|
|
101
101
|
// hard-coded, which made the width unoverridable in practice — a
|
|
102
102
|
// `max-width` beside a declared `width` is inert, so callers that
|
|
103
103
|
// needed a wider dialog silently got 32rem.
|
|
104
|
-
|
|
104
|
+
//
|
|
105
|
+
// `grid-cols-[minmax(0,1fr)]` is the same fix as the fullscreen
|
|
106
|
+
// branch's rows, on the other axis: a grid column also defaults to
|
|
107
|
+
// `min-width:auto`, so one unbreakable child — a long description, a
|
|
108
|
+
// path, a token — refuses to shrink and pushes the card past the
|
|
109
|
+
// width declared right here. The clamp reads as inert when that
|
|
110
|
+
// happens, and the overflow lands on whichever child is widest
|
|
111
|
+
// rather than on the one at fault.
|
|
112
|
+
//
|
|
113
|
+
// HEIGHT is capped for the same reason width is clamped. The card is
|
|
114
|
+
// `fixed` and centred by `-translate-y-1/2`, so content taller than
|
|
115
|
+
// the viewport grows the box and then centres it on its own overflow:
|
|
116
|
+
// BOTH ends leave the screen, and because nothing clips, nothing
|
|
117
|
+
// scrolls. The cap is only a ceiling — a card that must scroll adds
|
|
118
|
+
// `overflow-y-auto`, and one laying out its own regions overrides
|
|
119
|
+
// `max-h` as before.
|
|
120
|
+
: "left-1/2 top-1/2 grid max-h-[calc(100dvh-2rem)] grid-cols-[minmax(0,1fr)] w-[min(var(--width-dialog),calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-dialog)] border p-6 shadow-lg data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
105
121
|
className
|
|
106
122
|
)}
|
|
107
123
|
{...props}
|
|
@@ -90,6 +90,14 @@ interface ResponsiveSheetContentProps {
|
|
|
90
90
|
className?: string;
|
|
91
91
|
/** Panel width on a wide viewport. A phone always takes the full width. */
|
|
92
92
|
width?: ResponsiveSheetWidth;
|
|
93
|
+
/**
|
|
94
|
+
* Cap the panel at the viewport and scroll its body. Default.
|
|
95
|
+
*
|
|
96
|
+
* `false` for a panel that lays out its own scrolling regions — a header and
|
|
97
|
+
* footer that must stay put around a scrolling middle. It then owns the cap
|
|
98
|
+
* too; without one the overflow returns.
|
|
99
|
+
*/
|
|
100
|
+
scroll?: boolean;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
/**
|
|
@@ -103,11 +111,20 @@ interface ResponsiveSheetContentProps {
|
|
|
103
111
|
* The phone case is where this matters. `DialogContent` carries its own
|
|
104
112
|
* padding and centring; `DrawerContent` does not, so anything that renders as
|
|
105
113
|
* both lost its layout at the breakpoint and nowhere else.
|
|
114
|
+
*
|
|
115
|
+
* HEIGHT is a default here for the same reason width is. The desktop
|
|
116
|
+
* `DialogContent` is `fixed` and centred by `-translate-y-1/2` with no cap, so
|
|
117
|
+
* content taller than the viewport overflows BOTH edges — the top scrolls out
|
|
118
|
+
* of reach, and nothing clips, so nothing scrolls. Every caller that got this
|
|
119
|
+
* right passed the same `max-h-[85vh]` by hand; the one that forgot lost its
|
|
120
|
+
* header off the top. A caller that wants its own scrolling regions passes
|
|
121
|
+
* `scroll={false}` and owns the cap.
|
|
106
122
|
*/
|
|
107
123
|
function ResponsiveSheetContent({
|
|
108
124
|
children,
|
|
109
125
|
className,
|
|
110
126
|
width = "md",
|
|
127
|
+
scroll = true,
|
|
111
128
|
}: ResponsiveSheetContentProps) {
|
|
112
129
|
const { isMobile } = React.useContext(ResponsiveSheetContext);
|
|
113
130
|
|
|
@@ -122,9 +139,26 @@ function ResponsiveSheetContent({
|
|
|
122
139
|
);
|
|
123
140
|
}
|
|
124
141
|
|
|
142
|
+
// The cap sits on the panel and the scroll on an inner box, never both on
|
|
143
|
+
// one element: `DialogContent` is a grid, and a scrollport that is also the
|
|
144
|
+
// grid would clip the close button pinned to its corner.
|
|
145
|
+
if (!scroll) {
|
|
146
|
+
return (
|
|
147
|
+
<DialogContent className={cn("w-[calc(100%-1rem)]", WIDTH[width], className)}>
|
|
148
|
+
{children}
|
|
149
|
+
</DialogContent>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
return (
|
|
126
|
-
<DialogContent
|
|
127
|
-
{
|
|
154
|
+
<DialogContent
|
|
155
|
+
className={cn(
|
|
156
|
+
"max-h-[85dvh] w-[calc(100%-1rem)] grid-rows-[minmax(0,1fr)] overflow-hidden",
|
|
157
|
+
WIDTH[width],
|
|
158
|
+
className,
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
<div className="min-h-0 overflow-y-auto overscroll-contain">{children}</div>
|
|
128
162
|
</DialogContent>
|
|
129
163
|
);
|
|
130
164
|
}
|
|
@@ -38,7 +38,8 @@ export interface NavigateOptions {
|
|
|
38
38
|
export interface UseNavigateReturn {
|
|
39
39
|
/**
|
|
40
40
|
* SPA navigation through the active adapter.
|
|
41
|
-
* Default: pushState
|
|
41
|
+
* Default: pushState, and NO scroll — pass `scroll: true` for a transition
|
|
42
|
+
* that should land the visitor at the top.
|
|
42
43
|
*/
|
|
43
44
|
navigate: (href: string, opts?: NavigateOptions) => void;
|
|
44
45
|
/**
|