aplance-ui 1.2.2 → 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.
@@ -22,67 +22,67 @@ import { FieldMessage } from "../common/field-message/field-message";
22
22
  import { Icon } from "../../../primitives/icon/icon";
23
23
  import type { BaseInputFieldProps } from "../common/types";
24
24
 
25
- export type InputType =
26
- | "text"
27
- | "email"
28
- | "tel"
29
- | "url"
30
- | "password"
31
- | "search"
32
- | "number"
33
- | "file"
34
- | "radio"
35
- | "date"
36
- | "month"
25
+ export type InputType =
26
+ | "text"
27
+ | "email"
28
+ | "tel"
29
+ | "url"
30
+ | "password"
31
+ | "search"
32
+ | "number"
33
+ | "file"
34
+ | "radio"
35
+ | "date"
36
+ | "month"
37
37
  | "week"
38
38
  | "time"
39
39
  | "datetime-local"
40
40
  | "checkbox"
41
- | "color"
42
- | "range";
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
-
83
- type BaseProps = BaseInputFieldProps & {
84
- type?: InputType;
85
- onClear?: () => void;
41
+ | "color"
42
+ | "range";
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
+
83
+ type BaseProps = BaseInputFieldProps & {
84
+ type?: InputType;
85
+ onClear?: () => void;
86
86
  showClear?: boolean;
87
87
  };
88
88
 
@@ -139,18 +139,18 @@ export const Input = ({
139
139
  const generatedId = useId();
140
140
  const inputId = props.id ?? generatedId;
141
141
 
142
- const showPasswordToggle = isPassword && hasValue;
143
- const showClearButton =
144
- showClear && hasValue && clearableInputTypes.includes(type);
145
-
146
- const stylesToApplyFields = styledInputTypes.includes(type);
147
- const stylesFitWidth = fitWidthInputTypes.includes(type);
148
- const hasButtonStyle = buttonStyle !== "none";
149
- const buttonToneClass = hasButtonStyle
150
- ? sButton[`tone_${buttonStyle}`]
151
- : undefined;
152
-
153
- const isDateFamily = dateInputTypes.includes(type);
142
+ const showPasswordToggle = isPassword && hasValue;
143
+ const showClearButton =
144
+ showClear && hasValue && clearableInputTypes.includes(type);
145
+
146
+ const stylesToApplyFields = styledInputTypes.includes(type);
147
+ const stylesFitWidth = fitWidthInputTypes.includes(type);
148
+ const hasButtonStyle = buttonStyle !== "none";
149
+ const buttonToneClass = hasButtonStyle
150
+ ? sButton[`tone_${buttonStyle}`]
151
+ : undefined;
152
+
153
+ const isDateFamily = dateInputTypes.includes(type);
154
154
  const isTimeFamily = type === "time";
155
155
 
156
156
  const showDateCalendarIcon = isDateFamily && !iconEnd;
@@ -174,6 +174,16 @@ export const Input = ({
174
174
  onChange?.(e);
175
175
  };
176
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
+
177
187
  const handleClear = () => {
178
188
  const element = inputRef.current;
179
189
  if (!element) return;
@@ -279,6 +289,7 @@ export const Input = ({
279
289
  style={dynamicInputStyle}
280
290
  {...props}
281
291
  onChange={handleInputChange}
292
+ onFocus={handleFocus}
282
293
  />
283
294
 
284
295
  <div className={clsx(sCommon.action_end_wrapper)}>
@@ -1,5 +1,5 @@
1
1
  /* ─────────────────────────────────────────────
2
- Tabs – root
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 – pill container */
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 – underline container */
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 – filled pill */
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 – bottom line */
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 – shared base
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 – pill tab */
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 – underline tab */
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
- left: btnRect.left - listRect.left,
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
  }, []);
@@ -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);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://ui.aplance.com/registry.schema.json",
3
3
  "version": 4,
4
- "generated": "2026-05-26T16:15:51.026Z",
4
+ "generated": "2026-06-15T07:36:30.820Z",
5
5
  "units": [
6
6
  {
7
7
  "name": "accordion",
@@ -506,7 +506,8 @@
506
506
  "deps": [
507
507
  "button",
508
508
  "icon",
509
- "useFocusOutside"
509
+ "useFocusOutside",
510
+ "useHasMounted"
510
511
  ],
511
512
  "externalDeps": [
512
513
  "@tabler/icons-react",
@@ -551,7 +552,8 @@
551
552
  "components/primitives/popover/popover.tsx"
552
553
  ],
553
554
  "deps": [
554
- "useFocusOutside"
555
+ "useFocusOutside",
556
+ "useHasMounted"
555
557
  ],
556
558
  "externalDeps": [
557
559
  "clsx",
@@ -783,6 +785,21 @@
783
785
  "react"
784
786
  ]
785
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
+ },
786
803
  {
787
804
  "name": "useResizeObserver",
788
805
  "kind": "hook",