aplance-ui 1.2.1 → 1.3.0
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 +1 -1
- package/registry/components/primitives/modal/modal.tsx +52 -13
- package/registry/components/primitives/popover/popover-context.ts +1 -0
- package/registry/components/primitives/popover/popover.tsx +13 -2
- package/registry/components/primitives/slider/slider.module.css +62 -5
- package/registry/components/primitives/slider/slider.tsx +192 -34
- package/registry/components/primitives/slider/types.ts +56 -3
- package/registry/components/primitives/slider/utils.ts +9 -0
- package/registry/components/ui/data-table/data-table.module.css +124 -6
- package/registry/components/ui/data-table/data-table.tsx +176 -88
- package/registry/components/ui/data-table/pagination-bar.tsx +174 -71
- package/registry/components/ui/data-table/types.ts +12 -3
- package/registry/components/ui/input-fields/common/input-field-native.module.css +52 -42
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +21 -1
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +90 -9
- package/registry/components/ui/input-fields/input/input.tsx +58 -29
- package/registry/components/ui/input-fields/media-input/media-input.module.css +29 -0
- package/registry/components/ui/input-fields/media-input/media-preview.tsx +25 -7
- package/registry/components/ui/tabs/tabs.module.css +22 -8
- package/registry/components/ui/tabs/tabs.tsx +5 -1
- package/registry/hooks/useFocusOutside.tsx +21 -6
- package/registry/hooks/useHasMounted.ts +22 -0
- package/registry/registry.json +21 -3
|
@@ -24,9 +24,13 @@ import type { BaseInputFieldProps } from "../common/types";
|
|
|
24
24
|
|
|
25
25
|
export type InputType =
|
|
26
26
|
| "text"
|
|
27
|
+
| "email"
|
|
28
|
+
| "tel"
|
|
29
|
+
| "url"
|
|
27
30
|
| "password"
|
|
28
31
|
| "search"
|
|
29
32
|
| "number"
|
|
33
|
+
| "file"
|
|
30
34
|
| "radio"
|
|
31
35
|
| "date"
|
|
32
36
|
| "month"
|
|
@@ -37,6 +41,45 @@ export type InputType =
|
|
|
37
41
|
| "color"
|
|
38
42
|
| "range";
|
|
39
43
|
|
|
44
|
+
const styledInputTypes: readonly InputType[] = [
|
|
45
|
+
"text",
|
|
46
|
+
"email",
|
|
47
|
+
"tel",
|
|
48
|
+
"url",
|
|
49
|
+
"password",
|
|
50
|
+
"search",
|
|
51
|
+
"number",
|
|
52
|
+
"file",
|
|
53
|
+
"date",
|
|
54
|
+
"month",
|
|
55
|
+
"week",
|
|
56
|
+
"time",
|
|
57
|
+
"datetime-local",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
const clearableInputTypes: readonly InputType[] = [
|
|
61
|
+
"text",
|
|
62
|
+
"email",
|
|
63
|
+
"tel",
|
|
64
|
+
"url",
|
|
65
|
+
"password",
|
|
66
|
+
"search",
|
|
67
|
+
"number",
|
|
68
|
+
"file",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
const fitWidthInputTypes: readonly InputType[] = [
|
|
72
|
+
"checkbox",
|
|
73
|
+
"radio",
|
|
74
|
+
"color",
|
|
75
|
+
] as const;
|
|
76
|
+
const dateInputTypes: readonly InputType[] = [
|
|
77
|
+
"date",
|
|
78
|
+
"month",
|
|
79
|
+
"week",
|
|
80
|
+
"datetime-local",
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
40
83
|
type BaseProps = BaseInputFieldProps & {
|
|
41
84
|
type?: InputType;
|
|
42
85
|
onClear?: () => void;
|
|
@@ -98,41 +141,16 @@ export const Input = ({
|
|
|
98
141
|
|
|
99
142
|
const showPasswordToggle = isPassword && hasValue;
|
|
100
143
|
const showClearButton =
|
|
101
|
-
showClear &&
|
|
102
|
-
hasValue &&
|
|
103
|
-
![
|
|
104
|
-
"radio",
|
|
105
|
-
"range",
|
|
106
|
-
"color",
|
|
107
|
-
"date",
|
|
108
|
-
"month",
|
|
109
|
-
"week",
|
|
110
|
-
"time",
|
|
111
|
-
"datetime-local",
|
|
112
|
-
"checkbox",
|
|
113
|
-
].includes(type);
|
|
144
|
+
showClear && hasValue && clearableInputTypes.includes(type);
|
|
114
145
|
|
|
115
|
-
const stylesToApplyFields =
|
|
116
|
-
|
|
117
|
-
"password",
|
|
118
|
-
"search",
|
|
119
|
-
"number",
|
|
120
|
-
"date",
|
|
121
|
-
"month",
|
|
122
|
-
"week",
|
|
123
|
-
"time",
|
|
124
|
-
"datetime-local",
|
|
125
|
-
"file",
|
|
126
|
-
].includes(type);
|
|
127
|
-
const stylesFitWidth = ["checkbox", "radio", "color"].includes(type);
|
|
146
|
+
const stylesToApplyFields = styledInputTypes.includes(type);
|
|
147
|
+
const stylesFitWidth = fitWidthInputTypes.includes(type);
|
|
128
148
|
const hasButtonStyle = buttonStyle !== "none";
|
|
129
149
|
const buttonToneClass = hasButtonStyle
|
|
130
150
|
? sButton[`tone_${buttonStyle}`]
|
|
131
151
|
: undefined;
|
|
132
152
|
|
|
133
|
-
const isDateFamily =
|
|
134
|
-
type,
|
|
135
|
-
);
|
|
153
|
+
const isDateFamily = dateInputTypes.includes(type);
|
|
136
154
|
const isTimeFamily = type === "time";
|
|
137
155
|
|
|
138
156
|
const showDateCalendarIcon = isDateFamily && !iconEnd;
|
|
@@ -156,6 +174,16 @@ export const Input = ({
|
|
|
156
174
|
onChange?.(e);
|
|
157
175
|
};
|
|
158
176
|
|
|
177
|
+
// A programmatic value set (e.g. react-hook-form populating a field on reset)
|
|
178
|
+
// sets the DOM value via the ref WITHOUT firing onChange, so `hasValue` can lag
|
|
179
|
+
// the real value and the reveal/clear actions stay hidden on a pre-filled
|
|
180
|
+
// field. Re-sync from the actual value on focus so they appear as soon as the
|
|
181
|
+
// field is interacted with.
|
|
182
|
+
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
183
|
+
setHasValue(e.target.value.length > 0);
|
|
184
|
+
props.onFocus?.(e);
|
|
185
|
+
};
|
|
186
|
+
|
|
159
187
|
const handleClear = () => {
|
|
160
188
|
const element = inputRef.current;
|
|
161
189
|
if (!element) return;
|
|
@@ -261,6 +289,7 @@ export const Input = ({
|
|
|
261
289
|
style={dynamicInputStyle}
|
|
262
290
|
{...props}
|
|
263
291
|
onChange={handleInputChange}
|
|
292
|
+
onFocus={handleFocus}
|
|
264
293
|
/>
|
|
265
294
|
|
|
266
295
|
<div className={clsx(sCommon.action_end_wrapper)}>
|
|
@@ -204,6 +204,35 @@
|
|
|
204
204
|
&[data-status="error"] {
|
|
205
205
|
border-color: var(--color-error-foreground);
|
|
206
206
|
}
|
|
207
|
+
|
|
208
|
+
&[data-status="uploaded"] {
|
|
209
|
+
border-color: var(--color-success-foreground);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Progress bar shown along the bottom edge of a tile during upload. The
|
|
214
|
+
width comes from `style={{ width: <pct>% }}` set inline on .progress_fill. */
|
|
215
|
+
.preview_progress_track {
|
|
216
|
+
width: 100%;
|
|
217
|
+
height: var(--spacing-2);
|
|
218
|
+
border-radius: var(--radius-full);
|
|
219
|
+
background-color: var(--color-muted);
|
|
220
|
+
overflow: hidden;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.preview_progress_fill {
|
|
224
|
+
height: 100%;
|
|
225
|
+
border-radius: var(--radius-full);
|
|
226
|
+
background-color: var(--color-primary);
|
|
227
|
+
transition: width var(--transition-base);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.preview_done_badge {
|
|
231
|
+
display: inline-flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
gap: var(--spacing-2);
|
|
234
|
+
color: var(--color-success-foreground);
|
|
235
|
+
font-size: var(--font-size-xs);
|
|
207
236
|
}
|
|
208
237
|
|
|
209
238
|
/* List layout - single row with thumb on the left and metadata fills the rest.
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { type FC, type MouseEvent } from "react";
|
|
2
2
|
import clsx from "clsx";
|
|
3
|
-
import { IconX } from "@tabler/icons-react";
|
|
3
|
+
import { IconCircleCheck, IconX } from "@tabler/icons-react";
|
|
4
4
|
import { Icon } from "../../../primitives/icon/icon";
|
|
5
5
|
import { Button } from "../../button/button";
|
|
6
|
-
import type {
|
|
7
|
-
MediaFile,
|
|
8
|
-
MediaPreviewLayout,
|
|
9
|
-
MediaPreviewSize,
|
|
10
|
-
} from "./types";
|
|
6
|
+
import type { MediaFile, MediaPreviewLayout, MediaPreviewSize } from "./types";
|
|
11
7
|
import { formatBytes, previewKindOf } from "./utils";
|
|
12
8
|
import { KIND_LABEL, iconForKind } from "./kind-icons";
|
|
13
9
|
import s from "./media-input.module.css";
|
|
@@ -72,9 +68,31 @@ export const MediaPreview: FC<MediaPreviewProps> = ({
|
|
|
72
68
|
</span>
|
|
73
69
|
<span className={s.preview_sub}>
|
|
74
70
|
<span>{KIND_LABEL[kind]}</span>
|
|
75
|
-
<span aria-hidden="true"
|
|
71
|
+
<span aria-hidden="true">-</span>
|
|
76
72
|
<span>{formatBytes(file.file.size)}</span>
|
|
77
73
|
</span>
|
|
74
|
+
{file.status === "uploading" && typeof file.progress === "number" ? (
|
|
75
|
+
<div
|
|
76
|
+
className={s.preview_progress_track}
|
|
77
|
+
role="progressbar"
|
|
78
|
+
aria-valuemin={0}
|
|
79
|
+
aria-valuemax={100}
|
|
80
|
+
aria-valuenow={file.progress}
|
|
81
|
+
>
|
|
82
|
+
<div
|
|
83
|
+
className={s.preview_progress_fill}
|
|
84
|
+
style={{ width: `${String(file.progress)}%` }}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
88
|
+
{file.status === "uploaded" ? (
|
|
89
|
+
<span className={s.preview_done_badge}>
|
|
90
|
+
<Icon size="sm" aria-hidden="true">
|
|
91
|
+
<IconCircleCheck />
|
|
92
|
+
</Icon>
|
|
93
|
+
Uploaded
|
|
94
|
+
</span>
|
|
95
|
+
) : null}
|
|
78
96
|
{file.error && <span className={s.preview_error}>{file.error}</span>}
|
|
79
97
|
</div>
|
|
80
98
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* ─────────────────────────────────────────────
|
|
2
|
-
Tabs
|
|
2
|
+
Tabs - root
|
|
3
3
|
───────────────────────────────────────────── */
|
|
4
4
|
.root {
|
|
5
5
|
display: flex;
|
|
@@ -15,8 +15,20 @@
|
|
|
15
15
|
position: relative;
|
|
16
16
|
display: inline-flex;
|
|
17
17
|
align-items: center;
|
|
18
|
+
/* Scroll horizontally when the tabs don't fit (e.g. many status tabs on a
|
|
19
|
+
phone) instead of clipping/overflowing the page. The active indicator is
|
|
20
|
+
absolutely positioned inside, so it scrolls with the tabs and stays aligned.
|
|
21
|
+
Scrollbar hidden for a clean strip - the partially-shown edge tab signals
|
|
22
|
+
scrollability. */
|
|
23
|
+
max-width: 100%;
|
|
24
|
+
overflow-x: auto;
|
|
25
|
+
scrollbar-width: none;
|
|
26
|
+
|
|
27
|
+
&::-webkit-scrollbar {
|
|
28
|
+
display: none;
|
|
29
|
+
}
|
|
18
30
|
|
|
19
|
-
/* style1
|
|
31
|
+
/* style1 - pill container */
|
|
20
32
|
&.list_style1 {
|
|
21
33
|
background: var(--color-muted);
|
|
22
34
|
border-radius: var(--radius-md);
|
|
@@ -24,7 +36,7 @@
|
|
|
24
36
|
gap: var(--spacing-1);
|
|
25
37
|
}
|
|
26
38
|
|
|
27
|
-
/* style2
|
|
39
|
+
/* style2 - underline container */
|
|
28
40
|
&.list_style2 {
|
|
29
41
|
border-bottom: var(--border-1) solid var(--color-border);
|
|
30
42
|
gap: var(--spacing-6);
|
|
@@ -45,7 +57,7 @@
|
|
|
45
57
|
width var(--transition-base);
|
|
46
58
|
will-change: left, width;
|
|
47
59
|
|
|
48
|
-
/* style1
|
|
60
|
+
/* style1 - filled pill */
|
|
49
61
|
&.indicator_style1 {
|
|
50
62
|
top: var(--spacing-1);
|
|
51
63
|
height: calc(100% - var(--spacing-1) * 2);
|
|
@@ -54,7 +66,7 @@
|
|
|
54
66
|
box-shadow: var(--shadow-sm);
|
|
55
67
|
}
|
|
56
68
|
|
|
57
|
-
/* style2
|
|
69
|
+
/* style2 - bottom line */
|
|
58
70
|
&.indicator_style2 {
|
|
59
71
|
top: auto;
|
|
60
72
|
bottom: -1px;
|
|
@@ -65,7 +77,7 @@
|
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
/* ─────────────────────────────────────────────
|
|
68
|
-
Tab button
|
|
80
|
+
Tab button - shared base
|
|
69
81
|
───────────────────────────────────────────── */
|
|
70
82
|
.tab {
|
|
71
83
|
position: relative;
|
|
@@ -73,6 +85,8 @@
|
|
|
73
85
|
display: inline-flex;
|
|
74
86
|
align-items: center;
|
|
75
87
|
justify-content: center;
|
|
88
|
+
/* Never shrink in the scroller - keep the label on one line and scroll. */
|
|
89
|
+
flex-shrink: 0;
|
|
76
90
|
font-family: var(--font-family-1);
|
|
77
91
|
font-size: var(--font-size-sm);
|
|
78
92
|
line-height: var(--line-height-sm);
|
|
@@ -91,7 +105,7 @@
|
|
|
91
105
|
cursor: not-allowed;
|
|
92
106
|
}
|
|
93
107
|
|
|
94
|
-
/* style1
|
|
108
|
+
/* style1 - pill tab */
|
|
95
109
|
&.tab_style1 {
|
|
96
110
|
padding: var(--spacing-3) var(--spacing-6);
|
|
97
111
|
border-radius: calc(var(--radius-md) - var(--spacing-1));
|
|
@@ -110,7 +124,7 @@
|
|
|
110
124
|
}
|
|
111
125
|
}
|
|
112
126
|
|
|
113
|
-
/* style2
|
|
127
|
+
/* style2 - underline tab */
|
|
114
128
|
&.tab_style2 {
|
|
115
129
|
padding: var(--spacing-3) var(--spacing-1);
|
|
116
130
|
min-height: var(--spacing-11);
|
|
@@ -61,7 +61,11 @@ export const Tabs = ({
|
|
|
61
61
|
const listRect = list.getBoundingClientRect();
|
|
62
62
|
const btnRect = btn.getBoundingClientRect();
|
|
63
63
|
setIndicator({
|
|
64
|
-
|
|
64
|
+
// `list` is a horizontal scroller and the indicator is absolutely
|
|
65
|
+
// positioned inside it, so its `left` is measured in content coordinates.
|
|
66
|
+
// Add scrollLeft, otherwise the indicator drifts away from the active tab
|
|
67
|
+
// once the strip is scrolled.
|
|
68
|
+
left: btnRect.left - listRect.left + list.scrollLeft,
|
|
65
69
|
width: btnRect.width,
|
|
66
70
|
});
|
|
67
71
|
}, []);
|
|
@@ -3,14 +3,23 @@ import { type RefObject, useEffect } from "react";
|
|
|
3
3
|
type UseFocusOutsideOptions = {
|
|
4
4
|
enabled?: boolean;
|
|
5
5
|
event?: "mousedown" | "mouseup" | "click";
|
|
6
|
+
/**
|
|
7
|
+
* CSS selector for elements that count as "inside" even when they aren't
|
|
8
|
+
* contained by any `ref`. Needed for portaled descendant layers - a
|
|
9
|
+
* `Popover` menu opened from within a `Modal` renders under `document.body`
|
|
10
|
+
* via `createPortal`, so it's outside the modal's `contentRef` even though
|
|
11
|
+
* it's logically nested. Without this, the modal's outside-click handler
|
|
12
|
+
* fires when you click a dropdown option and dismisses the dialog.
|
|
13
|
+
*/
|
|
14
|
+
ignoreSelector?: string;
|
|
6
15
|
};
|
|
7
16
|
|
|
8
17
|
export function useFocusOutside(
|
|
9
18
|
refs: RefObject<HTMLElement | null>[],
|
|
10
19
|
handler: (event: MouseEvent) => void,
|
|
11
|
-
options: UseFocusOutsideOptions = {}
|
|
20
|
+
options: UseFocusOutsideOptions = {},
|
|
12
21
|
) {
|
|
13
|
-
const { enabled = true, event = "mousedown" } = options;
|
|
22
|
+
const { enabled = true, event = "mousedown", ignoreSelector } = options;
|
|
14
23
|
|
|
15
24
|
useEffect(() => {
|
|
16
25
|
if (!enabled) return;
|
|
@@ -19,15 +28,21 @@ export function useFocusOutside(
|
|
|
19
28
|
const target = e.target as Node;
|
|
20
29
|
|
|
21
30
|
const clickedInside = refs.some(
|
|
22
|
-
(ref) => ref.current && ref.current.contains(target)
|
|
31
|
+
(ref) => ref.current && ref.current.contains(target),
|
|
23
32
|
);
|
|
33
|
+
if (clickedInside) return;
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
// A click inside a portaled layer (e.g. a popover menu) counts as
|
|
36
|
+
// "inside" so the host (modal/drawer) doesn't dismiss itself.
|
|
37
|
+
if (ignoreSelector) {
|
|
38
|
+
const el = target instanceof Element ? target : target.parentElement;
|
|
39
|
+
if (el?.closest(ignoreSelector)) return;
|
|
27
40
|
}
|
|
41
|
+
|
|
42
|
+
handler(e);
|
|
28
43
|
};
|
|
29
44
|
|
|
30
45
|
document.addEventListener(event, listener);
|
|
31
46
|
return () => document.removeEventListener(event, listener);
|
|
32
|
-
}, [refs, handler, enabled, event]);
|
|
47
|
+
}, [refs, handler, enabled, event, ignoreSelector]);
|
|
33
48
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
// The value never changes after the initial client commit, so nothing to
|
|
4
|
+
// subscribe to - a no-op unsubscribe.
|
|
5
|
+
const noopSubscribe = (): (() => void) => () => {};
|
|
6
|
+
const getClientSnapshot = (): boolean => true;
|
|
7
|
+
const getServerSnapshot = (): boolean => false;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `false` during SSR and on the FIRST client render, then `true` after mount.
|
|
11
|
+
*
|
|
12
|
+
* Gate any UI that depends on client-only state the server can't know (RTK Query
|
|
13
|
+
* caches, cookies, localStorage) behind this so the hydrated markup matches the
|
|
14
|
+
* server HTML - otherwise React throws the "tree hydrated but some attributes
|
|
15
|
+
* didn't match" warning (and won't patch the difference). The real client state
|
|
16
|
+
* reveals on the tick right after hydration.
|
|
17
|
+
*
|
|
18
|
+
* Built on `useSyncExternalStore` (React's hydration-safe server/client split)
|
|
19
|
+
* rather than a `useEffect` mount flag, so there's no setState-in-effect.
|
|
20
|
+
*/
|
|
21
|
+
export const useHasMounted = (): boolean =>
|
|
22
|
+
useSyncExternalStore(noopSubscribe, getClientSnapshot, getServerSnapshot);
|
package/registry/registry.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://ui.aplance.com/registry.schema.json",
|
|
3
3
|
"version": 4,
|
|
4
|
-
"generated": "2026-
|
|
4
|
+
"generated": "2026-06-15T07:36:30.820Z",
|
|
5
5
|
"units": [
|
|
6
6
|
{
|
|
7
7
|
"name": "accordion",
|
|
@@ -199,6 +199,7 @@
|
|
|
199
199
|
"select-button"
|
|
200
200
|
],
|
|
201
201
|
"externalDeps": [
|
|
202
|
+
"@tabler/icons-react",
|
|
202
203
|
"clsx",
|
|
203
204
|
"react"
|
|
204
205
|
]
|
|
@@ -505,7 +506,8 @@
|
|
|
505
506
|
"deps": [
|
|
506
507
|
"button",
|
|
507
508
|
"icon",
|
|
508
|
-
"useFocusOutside"
|
|
509
|
+
"useFocusOutside",
|
|
510
|
+
"useHasMounted"
|
|
509
511
|
],
|
|
510
512
|
"externalDeps": [
|
|
511
513
|
"@tabler/icons-react",
|
|
@@ -550,7 +552,8 @@
|
|
|
550
552
|
"components/primitives/popover/popover.tsx"
|
|
551
553
|
],
|
|
552
554
|
"deps": [
|
|
553
|
-
"useFocusOutside"
|
|
555
|
+
"useFocusOutside",
|
|
556
|
+
"useHasMounted"
|
|
554
557
|
],
|
|
555
558
|
"externalDeps": [
|
|
556
559
|
"clsx",
|
|
@@ -782,6 +785,21 @@
|
|
|
782
785
|
"react"
|
|
783
786
|
]
|
|
784
787
|
},
|
|
788
|
+
{
|
|
789
|
+
"name": "useHasMounted",
|
|
790
|
+
"kind": "hook",
|
|
791
|
+
"group": null,
|
|
792
|
+
"files": [
|
|
793
|
+
"hooks/useHasMounted.ts"
|
|
794
|
+
],
|
|
795
|
+
"clientFiles": [
|
|
796
|
+
"hooks/useHasMounted.ts"
|
|
797
|
+
],
|
|
798
|
+
"deps": [],
|
|
799
|
+
"externalDeps": [
|
|
800
|
+
"react"
|
|
801
|
+
]
|
|
802
|
+
},
|
|
785
803
|
{
|
|
786
804
|
"name": "useResizeObserver",
|
|
787
805
|
"kind": "hook",
|