addon-ui 0.9.1 → 0.10.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.
@@ -5,16 +5,14 @@ import React, {
5
5
  memo,
6
6
  useImperativeHandle,
7
7
  useLayoutEffect,
8
- useMemo,
9
8
  useRef,
10
9
  useState,
11
10
  } from "react";
12
- import debounce from "debounce";
13
11
  import classnames from "classnames";
14
12
 
15
13
  import {useComponentProps} from "../../providers";
16
14
 
17
- import {Highlight, HighlightProps} from "../Highlight";
15
+ import {calculateMiddleTruncate} from "./utils";
18
16
 
19
17
  import styles from "./truncate.module.scss";
20
18
 
@@ -22,81 +20,57 @@ export interface TruncateProps extends ComponentProps<"span"> {
22
20
  text?: string;
23
21
  middle?: boolean;
24
22
  separator?: string;
25
- highlight?: Omit<HighlightProps, "textToHighlight">;
23
+ contentClassname?: string;
24
+ render?: (text: string) => React.ReactNode;
26
25
  }
27
26
 
28
- const trimMiddle = (el: HTMLElement, text: string, separator: string) => {
29
- const measure = (txt: string) => {
30
- el.textContent = txt;
31
- return el.scrollWidth <= el.clientWidth;
32
- };
33
-
34
- if (measure(text)) return text;
35
-
36
- let low = 0;
37
- let high = text.length - 2;
38
- let result = "";
39
-
40
- while (low <= high) {
41
- const size = Math.floor((low + high) / 2);
42
- const left = text.slice(0, Math.ceil(size / 2));
43
- const right = text.slice(text.length - Math.floor(size / 2));
44
- const trimmed = left + separator + right;
45
-
46
- if (measure(trimmed)) {
47
- result = trimmed;
48
- low = size + 1;
49
- } else {
50
- high = size - 1;
51
- }
52
- }
53
-
54
- return result || text.charAt(0) + separator + text.charAt(text.length - 1);
55
- };
56
-
57
27
  const Truncate: ForwardRefRenderFunction<HTMLSpanElement, TruncateProps> = (props, ref) => {
58
28
  const {
59
29
  text = "",
60
30
  middle,
61
31
  separator = "...",
62
32
  className,
63
- highlight,
33
+ contentClassname,
34
+ render,
64
35
  ...other
65
36
  } = {...useComponentProps("truncate"), ...props};
66
37
 
67
- const innerRef = useRef<HTMLSpanElement | null>(null);
38
+ const containerRef = useRef<HTMLSpanElement>(null);
68
39
  const [displayedText, setDisplayedText] = useState(text);
69
40
 
70
- const finalText = useMemo(() => {
71
- return middle ? displayedText : text;
72
- }, [displayedText, text, middle]);
73
-
74
- useImperativeHandle(ref, () => innerRef.current!, []);
41
+ useImperativeHandle(ref, () => containerRef.current!, []);
75
42
 
76
43
  useLayoutEffect(() => {
77
- const el = innerRef.current;
78
- if (!el || !middle) return;
44
+ const el = containerRef.current;
79
45
 
80
- let observer: ResizeObserver | null = null;
46
+ if (!middle || !el) {
47
+ setDisplayedText(text);
48
+ return;
49
+ }
50
+
51
+ const observer = new ResizeObserver(entries => {
52
+ const entry = entries[0];
53
+ if (!entry) return;
54
+
55
+ const maxWidth = entry.contentRect.width;
56
+ const {fontWeight, fontSize, fontFamily, letterSpacing} = window.getComputedStyle(el);
81
57
 
82
- const measureAndTrim = debounce(() => {
83
- setDisplayedText(trimMiddle(el, text, separator));
84
- }, 150);
58
+ const font = [fontWeight, fontSize, fontFamily].join(" ");
85
59
 
86
- measureAndTrim();
60
+ const truncated = calculateMiddleTruncate(text, maxWidth, font, letterSpacing, separator);
87
61
 
88
- observer = new ResizeObserver(() => measureAndTrim());
62
+ setDisplayedText(prev => (prev !== truncated ? truncated : prev));
63
+ });
89
64
 
90
65
  observer.observe(el);
66
+ return () => observer.disconnect();
67
+ }, [text, middle, separator]);
91
68
 
92
- return () => {
93
- measureAndTrim.clear();
94
- observer?.disconnect();
95
- };
96
- }, [text, separator, middle]);
69
+ const content = render ? render(displayedText) : displayedText;
97
70
 
98
71
  return (
99
72
  <span
73
+ ref={containerRef}
100
74
  className={classnames(
101
75
  styles["truncate"],
102
76
  {
@@ -104,11 +78,19 @@ const Truncate: ForwardRefRenderFunction<HTMLSpanElement, TruncateProps> = (prop
104
78
  },
105
79
  className
106
80
  )}
81
+ title={text}
107
82
  {...other}
108
83
  >
109
- <span ref={innerRef} className={styles["truncate__hidden"]} />
110
-
111
- {highlight ? <Highlight {...highlight} textToHighlight={finalText} /> : finalText}
84
+ {middle ? (
85
+ <>
86
+ <span className={styles["truncate__hidden"]} aria-hidden="true">
87
+ {text}
88
+ </span>
89
+ <span className={classnames(styles["truncate__content"], contentClassname)}>{content}</span>
90
+ </>
91
+ ) : (
92
+ content
93
+ )}
112
94
  </span>
113
95
  );
114
96
  };
@@ -2,30 +2,29 @@
2
2
 
3
3
  .truncate {
4
4
  position: relative;
5
- display: block;
6
- width: 100%;
5
+ display: inline-block;
6
+ vertical-align: middle;
7
+ width: auto;
8
+ max-width: 100%;
9
+ min-width: 0;
7
10
  white-space: nowrap;
8
11
  overflow: hidden;
9
12
  text-overflow: ellipsis;
10
13
  transition: color var(--truncate-speed-color, var(--speed-color));
11
14
 
15
+ &--middle {
16
+ text-overflow: clip;
17
+ }
18
+
12
19
  &__hidden {
13
- opacity: 0;
14
- position: absolute;
15
- width: 100%;
20
+ display: block;
21
+ height: 0;
22
+ visibility: hidden;
16
23
  white-space: nowrap;
17
24
  overflow: hidden;
18
- text-overflow: ellipsis;
19
25
  }
20
26
 
21
- &--middle {
22
- text-overflow: clip;
23
-
24
- padding-right: var(--truncate-around-space, 8px);
25
-
26
- @include theme.rtl() {
27
- padding-right: 0;
28
- padding-left: var(--truncate-around-space, 8px);
29
- }
27
+ &__content {
28
+ display: block;
30
29
  }
31
30
  }
@@ -0,0 +1,62 @@
1
+ const MAX_CACHE_SIZE = 1000;
2
+ const cache = new Map<string, string>();
3
+ let canvas: HTMLCanvasElement | null = null;
4
+
5
+ const addToCache = (key: string, value: string) => {
6
+ if (cache.size >= MAX_CACHE_SIZE) {
7
+ const oldestKey = cache.keys().next().value;
8
+ if (oldestKey !== undefined) {
9
+ cache.delete(oldestKey);
10
+ }
11
+ }
12
+ cache.set(key, value);
13
+ };
14
+
15
+ export const calculateMiddleTruncate = (
16
+ text: string,
17
+ maxWidth: number,
18
+ font: string,
19
+ letterSpacing: string,
20
+ separator: string
21
+ ) => {
22
+ const cacheKey = `${text}-${maxWidth}-${font}-${letterSpacing}-${separator}`;
23
+ if (cache.has(cacheKey)) return cache.get(cacheKey)!;
24
+
25
+ if (!canvas) {
26
+ canvas = document.createElement("canvas");
27
+ }
28
+ const context = canvas.getContext("2d");
29
+ if (!context) return text;
30
+ context.font = font;
31
+ context.letterSpacing = letterSpacing;
32
+
33
+ const measure = (txt: string) => context.measureText(txt).width;
34
+
35
+ if (measure(text) <= maxWidth) {
36
+ addToCache(cacheKey, text);
37
+ return text;
38
+ }
39
+
40
+ let low = 0;
41
+ let high = text.length;
42
+ let result = "";
43
+
44
+ while (low <= high) {
45
+ const mid = Math.floor((low + high) / 2);
46
+ const leftHalf = Math.ceil(mid / 2);
47
+ const rightHalf = Math.floor(mid / 2);
48
+
49
+ const trimmed = text.slice(0, leftHalf) + separator + text.slice(text.length - rightHalf);
50
+
51
+ if (measure(trimmed) <= maxWidth) {
52
+ result = trimmed;
53
+ low = mid + 1;
54
+ } else {
55
+ high = mid - 1;
56
+ }
57
+ }
58
+
59
+ const finalResult = result || text[0] + separator + text.slice(-1);
60
+ addToCache(cacheKey, finalResult);
61
+ return finalResult;
62
+ };
@@ -1,7 +1,8 @@
1
1
  import path from "path";
2
2
  import {definePlugin} from "adnbn";
3
- import {Configuration as Rspack} from "@rspack/core";
3
+ import {Configuration as Rspack, NormalModule} from "@rspack/core";
4
4
  import {RspackVirtualModulePlugin} from "rspack-plugin-virtual-module";
5
+ import kebabCase from "lodash/kebabCase";
5
6
 
6
7
  import StyleBuilder from "./builder/StyleBuilder";
7
8
  import ConfigBuilder from "./builder/ConfigBuilder";
@@ -13,20 +14,52 @@ import ConfigFinder from "./finder/ConfigFinder";
13
14
  import type {BuilderContract} from "./types";
14
15
 
15
16
  export interface PluginOptions {
17
+ /**
18
+ * Directory path where plugin configuration and style files are located, relative to the project root.
19
+ * @default "."
20
+ */
16
21
  themeDir?: string;
17
- configFileName?: string;
18
- styleFileName?: string;
22
+
23
+ /**
24
+ * Name of the configuration file.
25
+ * @default "config.ui"
26
+ */
27
+ configName?: string;
28
+
29
+ /**
30
+ * Name of the style file.
31
+ * @default "style.ui"
32
+ */
33
+ styleName?: string;
34
+
35
+ /**
36
+ * Whether to merge configuration files from different app directories.
37
+ * @default true
38
+ */
19
39
  mergeConfig?: boolean;
40
+
41
+ /**
42
+ * Whether to merge style files from different app directories.
43
+ * @default true
44
+ */
20
45
  mergeStyles?: boolean;
46
+
47
+ /**
48
+ * Configuration for splitting chunks.
49
+ * Can be a boolean to enable/disable or a callback to customize chunk names.
50
+ * @default true
51
+ */
52
+ splitChunks?: boolean | ((name: string) => string | undefined);
21
53
  }
22
54
 
23
55
  export default definePlugin((options: PluginOptions = {}) => {
24
56
  const {
25
57
  themeDir = ".",
26
- configFileName = "ui.config",
27
- styleFileName = "ui.style",
58
+ configName = "ui.config",
59
+ styleName = "ui.style",
28
60
  mergeConfig = true,
29
61
  mergeStyles = true,
62
+ splitChunks = false,
30
63
  } = options;
31
64
 
32
65
  let configFinder: Finder;
@@ -47,14 +80,14 @@ export default definePlugin((options: PluginOptions = {}) => {
47
80
  path.join(srcDir, sharedDir, ...normalizeThemeDir),
48
81
  ];
49
82
 
50
- configFinder = new ConfigFinder(configFileName, config).setCanMerge(mergeConfig).setSearchDirs(searchDirs);
51
- styleFinder = new StyleFinder(styleFileName, config).setCanMerge(mergeStyles).setSearchDirs(searchDirs);
83
+ configFinder = new ConfigFinder(configName, config).setCanMerge(mergeConfig).setSearchDirs(searchDirs);
84
+ styleFinder = new StyleFinder(styleName, config).setCanMerge(mergeStyles).setSearchDirs(searchDirs);
52
85
 
53
86
  configBuilder = new ConfigBuilder(configFinder);
54
87
  styleBuilder = new StyleBuilder(styleFinder);
55
88
  },
56
89
  bundler: () => {
57
- return {
90
+ const config: Rspack = {
58
91
  plugins: [
59
92
  new RspackVirtualModulePlugin(
60
93
  {
@@ -64,7 +97,188 @@ export default definePlugin((options: PluginOptions = {}) => {
64
97
  "addon-ui-virtual"
65
98
  ),
66
99
  ],
67
- } satisfies Rspack;
100
+ };
101
+
102
+ if (splitChunks) {
103
+ const splitChunksNameCallback = typeof splitChunks === "function" ? splitChunks : undefined;
104
+
105
+ const toUIChunk = (name: string) => {
106
+ if (splitChunksNameCallback) {
107
+ const finalName = splitChunksNameCallback(name);
108
+
109
+ if (finalName) {
110
+ return finalName;
111
+ }
112
+ }
113
+
114
+ return `${kebabCase(name)}.ui`;
115
+ };
116
+
117
+ const extractName = (res: string): string | null => {
118
+ if (!res) {
119
+ return null;
120
+ }
121
+
122
+ const match = res.match(/src[\\/]components[\\/]([^\\/]+)/);
123
+
124
+ if (match && match[1] && !match[1].includes(".") && match[1] !== "index") {
125
+ const componentName = match[1];
126
+ const normalized = componentName.toLowerCase();
127
+
128
+ if (["button", "basebutton", "iconbutton"].includes(normalized)) {
129
+ return "button";
130
+ }
131
+
132
+ if (["list", "listitem"].includes(normalized)) {
133
+ return "list";
134
+ }
135
+
136
+ if (["view", "viewdrawer", "viewmodal", "viewport"].includes(normalized)) {
137
+ return "view";
138
+ }
139
+
140
+ if (["svgsprite", "icon"].includes(normalized)) {
141
+ return "svg";
142
+ }
143
+
144
+ return componentName;
145
+ }
146
+
147
+ return null;
148
+ };
149
+
150
+ config.optimization = {
151
+ splitChunks: {
152
+ cacheGroups: {
153
+ addonUI: {
154
+ test: module => {
155
+ const resource =
156
+ (module as NormalModule).resource ||
157
+ (typeof module.nameForCondition === "function"
158
+ ? module.nameForCondition()
159
+ : "");
160
+
161
+ if (!resource) {
162
+ return false;
163
+ }
164
+
165
+ if (
166
+ resource.includes("addon-ui-virtual") ||
167
+ resource.includes("addon-ui-style.scss") ||
168
+ resource.includes("addon-ui-config") ||
169
+ /providers[\\/]ui[\\/]styles/.test(resource)
170
+ ) {
171
+ return true;
172
+ }
173
+
174
+ const isComponent = /src[\\/]components[\\/]/.test(resource);
175
+
176
+ if (isComponent) {
177
+ if (resource.includes("node_modules")) {
178
+ return resource.includes("addon-ui");
179
+ }
180
+
181
+ return true;
182
+ }
183
+
184
+ return /node_modules[\\/](@radix-ui|radix-ui|autosize|odometer|react-highlight-words|react-responsive-overflow-list)/.test(
185
+ resource
186
+ );
187
+ },
188
+ name(module) {
189
+ const resource =
190
+ (module as NormalModule).resource ||
191
+ ((typeof module.nameForCondition === "function"
192
+ ? module.nameForCondition()
193
+ : "") as string);
194
+
195
+ const directName = extractName(resource);
196
+
197
+ if (directName) {
198
+ return toUIChunk(directName);
199
+ }
200
+
201
+ if (
202
+ resource.includes("addon-ui-virtual") ||
203
+ resource.includes("addon-ui-style.scss") ||
204
+ resource.includes("addon-ui-config") ||
205
+ /providers[\\/]ui[\\/]styles/.test(resource)
206
+ ) {
207
+ return toUIChunk("common");
208
+ }
209
+
210
+ if (resource.includes("node_modules")) {
211
+ if (resource.includes("radix-ui")) {
212
+ const match = resource.match(/@radix-ui[\\/]react-([^\\/]+)/);
213
+
214
+ if (match) {
215
+ const radixName = match[1];
216
+
217
+ const mainRadixComponents = [
218
+ "accordion",
219
+ "avatar",
220
+ "checkbox",
221
+ "dialog",
222
+ "dropdown-menu",
223
+ "popover",
224
+ "scroll-area",
225
+ "select",
226
+ "switch",
227
+ "tabs",
228
+ "toast",
229
+ "tooltip",
230
+ ];
231
+
232
+ if (mainRadixComponents.includes(radixName)) {
233
+ return toUIChunk(radixName);
234
+ }
235
+ }
236
+
237
+ return toUIChunk("common");
238
+ }
239
+
240
+ if (resource.includes("odometer")) {
241
+ return toUIChunk("odometer");
242
+ }
243
+
244
+ if (resource.includes("autosize")) {
245
+ return toUIChunk("text-area");
246
+ }
247
+
248
+ if (resource.includes("react-highlight-words")) {
249
+ return toUIChunk("highlight");
250
+ }
251
+
252
+ if (resource.includes("react-responsive-overflow-list")) {
253
+ return toUIChunk("truncate-list");
254
+ }
255
+ }
256
+
257
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
+ let issuer = (module as any).issuer;
259
+
260
+ while (issuer) {
261
+ const nameFromIssuer = extractName(issuer.resource || "");
262
+
263
+ if (nameFromIssuer) {
264
+ return toUIChunk(nameFromIssuer);
265
+ }
266
+
267
+ issuer = issuer.issuer;
268
+ }
269
+
270
+ return toUIChunk("common");
271
+ },
272
+ chunks: "all",
273
+ enforce: true,
274
+ priority: 30,
275
+ },
276
+ },
277
+ },
278
+ };
279
+ }
280
+
281
+ return config satisfies Rspack;
68
282
  },
69
283
  manifest: ({manifest}) => {
70
284
  manifest.addPermission("storage");
@@ -14,30 +14,124 @@ const isValid = (theme: Theme | undefined): theme is Theme => {
14
14
  };
15
15
 
16
16
  export interface ThemeProviderProps extends Pick<Config, "components"> {
17
+ /**
18
+ * Theme persistence storage configuration.
19
+ *
20
+ * @remarks
21
+ * - When `undefined`, theme changes are stored only in component state (memory) and reset on page reload.
22
+ * - When `true`, uses the default `ThemeStorage` implementation (typically localStorage).
23
+ * - When a custom `ThemeStorageContract` object is provided, uses that implementation for theme persistence.
24
+ *
25
+ * The storage is used to save, retrieve, and watch for theme changes across sessions or tabs.
26
+ *
27
+ * @default undefined
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * // No persistence (memory only)
32
+ * <ThemeProvider>
33
+ * <App />
34
+ * </ThemeProvider>
35
+ * ```
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * // Use default storage (localStorage)
40
+ * <ThemeProvider storage={true}>
41
+ * <App />
42
+ * </ThemeProvider>
43
+ * ```
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * // Use custom storage implementation
48
+ * const customStorage: ThemeStorageContract = {
49
+ * get: async () => { ... },
50
+ * change: async (theme) => { ... },
51
+ * toggle: async () => { ... },
52
+ * watch: (callback) => { ... }
53
+ * };
54
+ *
55
+ * <ThemeProvider storage={customStorage}>
56
+ * <App />
57
+ * </ThemeProvider>
58
+ * ```
59
+ */
17
60
  storage?: ThemeStorageContract | true;
61
+ /**
62
+ * The DOM element where the provider will set attributes "browser"
63
+ *
64
+ * @remarks
65
+ * - When a string is provided, it's used as a CSS selector to find the element via `document.querySelector`.
66
+ * - When an Element is provided, attributes are set directly on that element.
67
+ * - When `false`, no element attributes are set.
68
+ *
69
+ * Attributes are automatically cleaned up when the component unmounts.
70
+ *
71
+ * @default "html"
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * // Use default html element
76
+ * <ThemeProviderProps>
77
+ * <App />
78
+ * </ThemeProviderProps>
79
+ * ```
80
+ *
81
+ * @example
82
+ * ```tsx
83
+ * // Use custom selector
84
+ * <ThemeProviderProps container="#app-root">
85
+ * <App />
86
+ * </ThemeProviderProps>
87
+ * ```
88
+ *
89
+ * @example
90
+ * ```tsx
91
+ * // Use direct element reference
92
+ * <ThemeProviderProps container={document.body}>
93
+ * <App />
94
+ * </ThemeProviderProps>
95
+ * ```
96
+ *
97
+ * @example
98
+ * ```tsx
99
+ * // Disable container attributes
100
+ * <ThemeProviderProps container={false}>
101
+ * <App />
102
+ * </ThemeProviderProps>
103
+ * ```
104
+ */
105
+ container?: string | Element | false;
18
106
  }
19
107
 
20
- const ThemeProvider: FC<PropsWithChildren<ThemeProviderProps>> = ({children, components, storage}) => {
108
+ const ThemeProvider: FC<PropsWithChildren<ThemeProviderProps>> = props => {
109
+ const {children, components, storage, container} = props;
110
+
21
111
  const [theme, setTheme] = useState<Theme>(() => (isDarkMedia() ? Theme.Dark : Theme.Light));
22
112
 
23
113
  const currentStorage: ThemeStorageContract | undefined = useMemo(() => {
24
- if (!storage) return;
114
+ if (!storage) {
115
+ return;
116
+ }
25
117
 
26
- if (storage === true) return new ThemeStorage();
118
+ if (storage === true) {
119
+ return new ThemeStorage();
120
+ }
27
121
 
28
122
  return storage;
29
123
  }, [storage]);
30
124
 
31
- const changeTheme = useCallback(
32
- (theme: Theme) => {
33
- if (currentStorage) {
34
- currentStorage.change(theme).catch(e => console.error("ThemeProvider: set theme to storage error", e));
35
- } else {
36
- setTheme(theme);
37
- }
38
- },
39
- [currentStorage]
40
- );
125
+ // prettier-ignore
126
+ const changeTheme = useCallback((theme: Theme) => {
127
+ setTheme(theme);
128
+
129
+ if (currentStorage) {
130
+ currentStorage
131
+ .change(theme)
132
+ .catch(e => console.error("ThemeProvider: set theme to storage error", e));
133
+ }
134
+ }, [currentStorage]);
41
135
 
42
136
  const toggleTheme = useCallback(() => {
43
137
  changeTheme(theme === Theme.Dark ? Theme.Light : Theme.Dark);
@@ -57,8 +151,20 @@ const ThemeProvider: FC<PropsWithChildren<ThemeProviderProps>> = ({children, com
57
151
  }, [currentStorage]);
58
152
 
59
153
  useEffect(() => {
60
- document.querySelector("html")?.setAttribute("theme", theme);
61
- }, [theme]);
154
+ if (container === false) {
155
+ return;
156
+ }
157
+
158
+ const element = typeof container === "string" ? document.querySelector(container) : container;
159
+
160
+ if (element) {
161
+ element.setAttribute("theme", theme);
162
+
163
+ return () => {
164
+ element.removeAttribute("theme");
165
+ };
166
+ }
167
+ }, [theme, container]);
62
168
 
63
169
  return (
64
170
  <ThemeContext.Provider value={{theme, changeTheme, toggleTheme, components}}>{children}</ThemeContext.Provider>