addon-ui 0.9.2 → 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.
- package/README.md +51 -12
- package/dist-types/components/Select/Select.d.ts +1 -1
- package/dist-types/components/Select/SelectIcon.d.ts +1 -1
- package/dist-types/components/Select/SelectValue.d.ts +1 -1
- package/dist-types/components/TextField/TextField.d.ts +2 -1
- package/dist-types/components/TextField/utils.d.ts +8 -0
- package/dist-types/components/Truncate/Truncate.d.ts +2 -2
- package/dist-types/components/Truncate/utils.d.ts +2 -0
- package/dist-types/plugin/index.d.ts +28 -2
- package/dist-types/providers/theme/ThemeProvider.d.ts +88 -0
- package/dist-types/providers/ui/UIProvider.d.ts +22 -1
- package/package.json +3 -8
- package/src/components/Select/Select.tsx +1 -1
- package/src/components/Select/SelectIcon.tsx +1 -1
- package/src/components/Select/SelectValue.tsx +1 -1
- package/src/components/TextField/TextField.tsx +66 -13
- package/src/components/TextField/text-field.module.scss +3 -1
- package/src/components/TextField/utils.ts +56 -0
- package/src/components/Truncate/Truncate.tsx +38 -56
- package/src/components/Truncate/truncate.module.scss +14 -15
- package/src/components/Truncate/utils.ts +62 -0
- package/src/plugin/index.ts +223 -9
- package/src/providers/theme/ThemeProvider.tsx +121 -15
- package/src/providers/ui/UIProvider.tsx +42 -15
- package/src/providers/ui/styles/default.scss +1 -1
- package/src/styles/mixins.scss +19 -6
- /package/src/providers/theme/{ThemeStorage.tsx → ThemeStorage.ts} +0 -0
package/README.md
CHANGED
|
@@ -67,12 +67,12 @@ This library now ships with dedicated documentation files for each component in
|
|
|
67
67
|
- [IconButton](docs/IconButton.md)
|
|
68
68
|
- [List](docs/List.md) (covers List and ListItem)
|
|
69
69
|
- [Modal](docs/Modal.md)
|
|
70
|
-
- [Odometer](docs/Odometer.md) (component + useOdometer hook)
|
|
70
|
+
- [Odometer](docs/Odometer.md) (component + `useOdometer` hook)
|
|
71
71
|
- [ScrollArea](docs/ScrollArea.md)
|
|
72
72
|
- [Select](docs/Select.md)
|
|
73
73
|
- [SvgSprite](docs/SvgSprite.md)
|
|
74
74
|
- [Switch](docs/Switch.md)
|
|
75
|
-
- [Tabs](docs/Tabs.md) (includes Tabs
|
|
75
|
+
- [Tabs](docs/Tabs.md) (includes `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`)
|
|
76
76
|
- [Tag](docs/Tag.md)
|
|
77
77
|
- [TextArea](docs/TextArea.md)
|
|
78
78
|
- [TextField](docs/TextField.md)
|
|
@@ -83,7 +83,7 @@ This library now ships with dedicated documentation files for each component in
|
|
|
83
83
|
- [View](docs/View.md)
|
|
84
84
|
- [ViewDrawer](docs/ViewDrawer.md)
|
|
85
85
|
- [ViewModal](docs/ViewModal.md)
|
|
86
|
-
- [Viewport](docs/Viewport.md) (ViewportProvider + useViewport)
|
|
86
|
+
- [Viewport](docs/Viewport.md) (`ViewportProvider` + `useViewport`)
|
|
87
87
|
|
|
88
88
|
Notes:
|
|
89
89
|
|
|
@@ -134,15 +134,40 @@ export default defineConfig({
|
|
|
134
134
|
plugins: [
|
|
135
135
|
ui({
|
|
136
136
|
themeDir: "./theme", // Directory for theme files
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
configName: "ui.config", // Name of config files
|
|
138
|
+
styleName: "ui.style", // Name of style files
|
|
139
139
|
mergeConfig: true, // Merge configs from different directories
|
|
140
140
|
mergeStyles: true, // Merge styles from different directories
|
|
141
|
+
splitChunks: true, // Enable automatic chunk splitting for components
|
|
141
142
|
}),
|
|
142
143
|
],
|
|
143
144
|
});
|
|
144
145
|
```
|
|
145
146
|
|
|
147
|
+
### Plugin Options
|
|
148
|
+
|
|
149
|
+
| Option | Type | Default | Description |
|
|
150
|
+
| :------------ | :------------------------------------------------- | :------------ | :----------------------------------------------------------------------------------------------------- |
|
|
151
|
+
| `themeDir` | `string` | `"."` | Directory path where plugin configuration and style files are located. |
|
|
152
|
+
| `configName` | `string` | `"config.ui"` | Name of the configuration file. |
|
|
153
|
+
| `styleName` | `string` | `"style.ui"` | Name of the SCSS style file. |
|
|
154
|
+
| `mergeConfig` | `boolean` | `true` | Whether to merge configuration files from different directories. |
|
|
155
|
+
| `mergeStyles` | `boolean` | `true` | Whether to merge style files from different directories. |
|
|
156
|
+
| `splitChunks` | `boolean \| (name: string) => string \| undefined` | `true` | Enables automatic chunk splitting. If a function is provided, it can be used to customize chunk names. |
|
|
157
|
+
|
|
158
|
+
#### Customizing Chunk Names
|
|
159
|
+
|
|
160
|
+
You can pass a callback function to `splitChunks` to customize the generated chunk names:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
ui({
|
|
164
|
+
splitChunks: name => {
|
|
165
|
+
if (name === "button") return "ui-core-button";
|
|
166
|
+
return `ui-${name}`;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
146
171
|
### Configuration Files
|
|
147
172
|
|
|
148
173
|
The `addon-ui` configuration is designed to retrieve configuration from each extension separately, allowing for
|
|
@@ -271,7 +296,7 @@ extensions with the same functionality but different visual appearances.
|
|
|
271
296
|
|
|
272
297
|
### Global Theme Customization
|
|
273
298
|
|
|
274
|
-
You can customize the theme globally by passing props to the UIProvider
|
|
299
|
+
You can customize the theme globally by passing props to the `UIProvider`:
|
|
275
300
|
|
|
276
301
|
```jsx
|
|
277
302
|
import {UIProvider} from "addon-ui";
|
|
@@ -289,6 +314,8 @@ const customTheme = {
|
|
|
289
314
|
icons: {
|
|
290
315
|
// Custom icons
|
|
291
316
|
},
|
|
317
|
+
// Specify the DOM element to set theme/view/browser attributes on
|
|
318
|
+
container: "#app-root",
|
|
292
319
|
};
|
|
293
320
|
|
|
294
321
|
function App() {
|
|
@@ -296,6 +323,17 @@ function App() {
|
|
|
296
323
|
}
|
|
297
324
|
```
|
|
298
325
|
|
|
326
|
+
### UIProvider Props
|
|
327
|
+
|
|
328
|
+
| Prop | Type | Default | Description |
|
|
329
|
+
| :----------- | :----------------------------- | :---------- | :-------------------------------------------------------- |
|
|
330
|
+
| `components` | `ComponentsProps` | `{}` | Component-specific configuration overrides. |
|
|
331
|
+
| `icons` | `Icons` | `{}` | Custom SVG icons registration. |
|
|
332
|
+
| `extra` | `ExtraProps` | `{}` | App-wide extra properties. |
|
|
333
|
+
| `storage` | `ThemeStorageContract \| true` | `undefined` | Persistence storage for theme settings. |
|
|
334
|
+
| `container` | `string \| Element \| false` | `"html"` | Target element for attributes. Set to `false` to disable. |
|
|
335
|
+
| `view` | `string` | `undefined` | Custom view identifier for specific styling. |
|
|
336
|
+
|
|
299
337
|
### Using Extra Props
|
|
300
338
|
|
|
301
339
|
Extra Props is a powerful feature that allows you to extend component props with custom properties. This is particularly
|
|
@@ -438,12 +476,13 @@ function App() {
|
|
|
438
476
|
|
|
439
477
|
## Theming and style reuse
|
|
440
478
|
|
|
441
|
-
- Global theme tokens (colors, typography, spacing, transitions) live in your ui.style.scss
|
|
442
|
-
|
|
443
|
-
-
|
|
444
|
-
|
|
445
|
-
-
|
|
446
|
-
|
|
479
|
+
- Global theme tokens (colors, typography, spacing, transitions) live in your `ui.style.scss`.
|
|
480
|
+
- Each component also exposes its own `--component-*` variables. See the CSS variables tables in the docs to know exactly what you can override.
|
|
481
|
+
- **Theme Mixins**: Use `@import "addon-ui/theme";` to access `@include light { ... }` and `@include dark { ... }` mixins.
|
|
482
|
+
- **Universal Targeting**: These mixins are container-agnostic. They work correctly whether the `theme` attribute is on a parent element or directly on the component itself.
|
|
483
|
+
- **Context-Aware**:
|
|
484
|
+
- When used at the top level, they generate global selectors: `[theme="dark"] { ... }`.
|
|
485
|
+
- When used inside a component, they generate scoped selectors: `[theme="dark"] .my-comp, .my-comp[theme="dark"] { ... }`.
|
|
447
486
|
|
|
448
487
|
## Radix UI and third-party integrations
|
|
449
488
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { SelectProps } from "@radix-ui/react-select";
|
|
3
|
-
export { SelectProps };
|
|
3
|
+
export { type SelectProps };
|
|
4
4
|
declare const _default: React.NamedExoticComponent<SelectProps>;
|
|
5
5
|
export default _default;
|
|
6
6
|
//# sourceMappingURL=Select.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { SelectIconProps } from "@radix-ui/react-select";
|
|
3
|
-
export { SelectIconProps };
|
|
3
|
+
export { type SelectIconProps };
|
|
4
4
|
declare const _default: React.NamedExoticComponent<SelectIconProps & React.RefAttributes<HTMLSpanElement>>;
|
|
5
5
|
export default _default;
|
|
6
6
|
//# sourceMappingURL=SelectIcon.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { SelectValueProps } from "@radix-ui/react-select";
|
|
3
|
-
export { SelectValueProps };
|
|
3
|
+
export { type SelectValueProps };
|
|
4
4
|
declare const _default: React.NamedExoticComponent<SelectValueProps & React.RefAttributes<HTMLSpanElement>>;
|
|
5
5
|
export default _default;
|
|
6
6
|
//# sourceMappingURL=SelectValue.d.ts.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { ComponentProps, ReactNode } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { TextFieldAccent, TextFieldRadius, TextFieldSize, TextFieldVariant } from "./types";
|
|
3
3
|
export interface TextFieldActions {
|
|
4
4
|
select(): void;
|
|
5
5
|
focus(): void;
|
|
@@ -20,6 +20,7 @@ export interface TextFieldProps extends ComponentProps<"input"> {
|
|
|
20
20
|
inputClassName?: string;
|
|
21
21
|
afterClassName?: string;
|
|
22
22
|
beforeClassName?: string;
|
|
23
|
+
strict?: boolean;
|
|
23
24
|
}
|
|
24
25
|
declare const _default: React.NamedExoticComponent<Omit<TextFieldProps, "ref"> & React.RefAttributes<TextFieldActions>>;
|
|
25
26
|
export default _default;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import React, { ComponentProps } from "react";
|
|
2
|
-
import { HighlightProps } from "../Highlight";
|
|
3
2
|
export interface TruncateProps extends ComponentProps<"span"> {
|
|
4
3
|
text?: string;
|
|
5
4
|
middle?: boolean;
|
|
6
5
|
separator?: string;
|
|
7
|
-
|
|
6
|
+
contentClassname?: string;
|
|
7
|
+
render?: (text: string) => React.ReactNode;
|
|
8
8
|
}
|
|
9
9
|
declare const _default: React.NamedExoticComponent<Omit<TruncateProps, "ref"> & React.RefAttributes<HTMLSpanElement>>;
|
|
10
10
|
export default _default;
|
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
export interface PluginOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Directory path where plugin configuration and style files are located, relative to the project root.
|
|
4
|
+
* @default "."
|
|
5
|
+
*/
|
|
2
6
|
themeDir?: string;
|
|
3
|
-
|
|
4
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Name of the configuration file.
|
|
9
|
+
* @default "config.ui"
|
|
10
|
+
*/
|
|
11
|
+
configName?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Name of the style file.
|
|
14
|
+
* @default "style.ui"
|
|
15
|
+
*/
|
|
16
|
+
styleName?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Whether to merge configuration files from different app directories.
|
|
19
|
+
* @default true
|
|
20
|
+
*/
|
|
5
21
|
mergeConfig?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether to merge style files from different app directories.
|
|
24
|
+
* @default true
|
|
25
|
+
*/
|
|
6
26
|
mergeStyles?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Configuration for splitting chunks.
|
|
29
|
+
* Can be a boolean to enable/disable or a callback to customize chunk names.
|
|
30
|
+
* @default true
|
|
31
|
+
*/
|
|
32
|
+
splitChunks?: boolean | ((name: string) => string | undefined);
|
|
7
33
|
}
|
|
8
34
|
declare const _default: import("adnbn").PluginDefinition<[options?: PluginOptions | undefined]>;
|
|
9
35
|
export default _default;
|
|
@@ -2,7 +2,95 @@ import { FC, PropsWithChildren } from "react";
|
|
|
2
2
|
import { ThemeStorageContract } from "../../types/theme";
|
|
3
3
|
import { Config } from "../../types/config";
|
|
4
4
|
export interface ThemeProviderProps extends Pick<Config, "components"> {
|
|
5
|
+
/**
|
|
6
|
+
* Theme persistence storage configuration.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* - When `undefined`, theme changes are stored only in component state (memory) and reset on page reload.
|
|
10
|
+
* - When `true`, uses the default `ThemeStorage` implementation (typically localStorage).
|
|
11
|
+
* - When a custom `ThemeStorageContract` object is provided, uses that implementation for theme persistence.
|
|
12
|
+
*
|
|
13
|
+
* The storage is used to save, retrieve, and watch for theme changes across sessions or tabs.
|
|
14
|
+
*
|
|
15
|
+
* @default undefined
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* // No persistence (memory only)
|
|
20
|
+
* <ThemeProvider>
|
|
21
|
+
* <App />
|
|
22
|
+
* </ThemeProvider>
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* // Use default storage (localStorage)
|
|
28
|
+
* <ThemeProvider storage={true}>
|
|
29
|
+
* <App />
|
|
30
|
+
* </ThemeProvider>
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* // Use custom storage implementation
|
|
36
|
+
* const customStorage: ThemeStorageContract = {
|
|
37
|
+
* get: async () => { ... },
|
|
38
|
+
* change: async (theme) => { ... },
|
|
39
|
+
* toggle: async () => { ... },
|
|
40
|
+
* watch: (callback) => { ... }
|
|
41
|
+
* };
|
|
42
|
+
*
|
|
43
|
+
* <ThemeProvider storage={customStorage}>
|
|
44
|
+
* <App />
|
|
45
|
+
* </ThemeProvider>
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
5
48
|
storage?: ThemeStorageContract | true;
|
|
49
|
+
/**
|
|
50
|
+
* The DOM element where the provider will set attributes "browser"
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* - When a string is provided, it's used as a CSS selector to find the element via `document.querySelector`.
|
|
54
|
+
* - When an Element is provided, attributes are set directly on that element.
|
|
55
|
+
* - When `false`, no element attributes are set.
|
|
56
|
+
*
|
|
57
|
+
* Attributes are automatically cleaned up when the component unmounts.
|
|
58
|
+
*
|
|
59
|
+
* @default "html"
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* // Use default html element
|
|
64
|
+
* <ThemeProviderProps>
|
|
65
|
+
* <App />
|
|
66
|
+
* </ThemeProviderProps>
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```tsx
|
|
71
|
+
* // Use custom selector
|
|
72
|
+
* <ThemeProviderProps container="#app-root">
|
|
73
|
+
* <App />
|
|
74
|
+
* </ThemeProviderProps>
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```tsx
|
|
79
|
+
* // Use direct element reference
|
|
80
|
+
* <ThemeProviderProps container={document.body}>
|
|
81
|
+
* <App />
|
|
82
|
+
* </ThemeProviderProps>
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```tsx
|
|
87
|
+
* // Disable container attributes
|
|
88
|
+
* <ThemeProviderProps container={false}>
|
|
89
|
+
* <App />
|
|
90
|
+
* </ThemeProviderProps>
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
container?: string | Element | false;
|
|
6
94
|
}
|
|
7
95
|
declare const ThemeProvider: FC<PropsWithChildren<ThemeProviderProps>>;
|
|
8
96
|
export default ThemeProvider;
|
|
@@ -4,7 +4,28 @@ import { Config } from "../../types/config";
|
|
|
4
4
|
import "./styles/default.scss";
|
|
5
5
|
import "./styles/reset.scss";
|
|
6
6
|
import "addon-ui-style.scss";
|
|
7
|
-
export interface UIProviderProps extends Partial<Config>, Pick<ThemeProviderProps, "storage"> {
|
|
7
|
+
export interface UIProviderProps extends Partial<Config>, Pick<ThemeProviderProps, "storage" | "container"> {
|
|
8
|
+
/**
|
|
9
|
+
* A custom view identifier that allows developers to specify a unique name for styling customization.
|
|
10
|
+
* This value is set as a "view" attribute on the container element and can be targeted through SCSS mixins
|
|
11
|
+
* to apply view-specific styles and behavior.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <UIProvider view="dashboard">
|
|
16
|
+
* <App />
|
|
17
|
+
* </UIProvider>
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```scss
|
|
22
|
+
* @include view("dashboard") {
|
|
23
|
+
* .some-class {
|
|
24
|
+
* // Custom styles for dashboard view
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
8
29
|
view?: string;
|
|
9
30
|
}
|
|
10
31
|
declare const UIProvider: FC<PropsWithChildren<UIProviderProps>>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "addon-ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"description": "A comprehensive React UI component library designed exclusively for the AddonBone browser extension framework with customizable theming and consistent design patterns",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"react",
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"@types/node": "^22.13.10",
|
|
107
107
|
"@types/react": "^19.0.10",
|
|
108
108
|
"@types/react-dom": "^19.0.4",
|
|
109
|
-
"adnbn": "^0.5.
|
|
109
|
+
"adnbn": "^0.5.7",
|
|
110
110
|
"depcheck": "^1.4.7",
|
|
111
111
|
"eslint": "^9.21.0",
|
|
112
112
|
"eslint-plugin-react-hooks": "^5.1.0",
|
|
@@ -141,12 +141,7 @@
|
|
|
141
141
|
},
|
|
142
142
|
"overrides": {
|
|
143
143
|
"flat-cache": "^6.1.18",
|
|
144
|
-
"
|
|
145
|
-
"glob": "^10.4.5"
|
|
146
|
-
},
|
|
147
|
-
"test-exclude": {
|
|
148
|
-
"glob": "^10.4.5"
|
|
149
|
-
}
|
|
144
|
+
"glob": "^13.0.0"
|
|
150
145
|
},
|
|
151
146
|
"eslintConfig": {
|
|
152
147
|
"extends": [
|
|
@@ -6,7 +6,7 @@ import {Root, SelectProps} from "@radix-ui/react-select";
|
|
|
6
6
|
|
|
7
7
|
import {useComponentProps} from "../../providers";
|
|
8
8
|
|
|
9
|
-
export {SelectProps};
|
|
9
|
+
export {type SelectProps};
|
|
10
10
|
|
|
11
11
|
const Select: FC<SelectProps> = props => {
|
|
12
12
|
const {...other} = {...useComponentProps("select"), ...props};
|
|
@@ -8,7 +8,7 @@ import {useComponentProps} from "../../providers";
|
|
|
8
8
|
|
|
9
9
|
import styles from "./select.module.scss";
|
|
10
10
|
|
|
11
|
-
export {SelectIconProps};
|
|
11
|
+
export {type SelectIconProps};
|
|
12
12
|
|
|
13
13
|
const SelectIcon: ForwardRefRenderFunction<HTMLSpanElement, SelectIconProps> = (props, ref) => {
|
|
14
14
|
const {className, ...other} = {...useComponentProps("selectIcon"), ...props};
|
|
@@ -8,7 +8,7 @@ import {useComponentProps} from "../../providers";
|
|
|
8
8
|
|
|
9
9
|
import styles from "./select.module.scss";
|
|
10
10
|
|
|
11
|
-
export {SelectValueProps};
|
|
11
|
+
export {type SelectValueProps};
|
|
12
12
|
|
|
13
13
|
const SelectValue: ForwardRefRenderFunction<HTMLSpanElement, SelectValueProps> = (props, ref) => {
|
|
14
14
|
const {className, ...other} = {...useComponentProps("selectValue"), ...props};
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import React, {
|
|
2
|
-
|
|
2
|
+
ChangeEvent,
|
|
3
3
|
ComponentProps,
|
|
4
4
|
forwardRef,
|
|
5
|
+
KeyboardEvent,
|
|
5
6
|
memo,
|
|
6
7
|
ReactNode,
|
|
7
8
|
useCallback,
|
|
8
9
|
useEffect,
|
|
9
10
|
useImperativeHandle,
|
|
11
|
+
useMemo,
|
|
10
12
|
useRef,
|
|
11
13
|
useState,
|
|
12
14
|
} from "react";
|
|
@@ -16,7 +18,8 @@ import classnames from "classnames";
|
|
|
16
18
|
import {cloneOrCreateElement} from "../../utils";
|
|
17
19
|
import {useComponentProps} from "../../providers";
|
|
18
20
|
|
|
19
|
-
import {
|
|
21
|
+
import {normalizeNumberInput} from "./utils";
|
|
22
|
+
import {TextFieldAccent, TextFieldRadius, TextFieldSize, TextFieldVariant} from "./types";
|
|
20
23
|
|
|
21
24
|
import styles from "./text-field.module.scss";
|
|
22
25
|
|
|
@@ -44,6 +47,7 @@ export interface TextFieldProps extends ComponentProps<"input"> {
|
|
|
44
47
|
inputClassName?: string;
|
|
45
48
|
afterClassName?: string;
|
|
46
49
|
beforeClassName?: string;
|
|
50
|
+
strict?: boolean;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
|
|
@@ -55,7 +59,8 @@ const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
|
|
|
55
59
|
label,
|
|
56
60
|
fullWidth,
|
|
57
61
|
type = "text",
|
|
58
|
-
|
|
62
|
+
strict,
|
|
63
|
+
value: propValue,
|
|
59
64
|
defaultValue,
|
|
60
65
|
before,
|
|
61
66
|
after,
|
|
@@ -64,12 +69,20 @@ const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
|
|
|
64
69
|
afterClassName,
|
|
65
70
|
beforeClassName,
|
|
66
71
|
onChange,
|
|
72
|
+
onKeyDown,
|
|
67
73
|
...other
|
|
68
74
|
} = {...useComponentProps("textField"), ...props};
|
|
69
75
|
|
|
70
|
-
const [value, setValue] = useState<string
|
|
76
|
+
const [value, setValue] = useState<string>(() => {
|
|
77
|
+
if (propValue != null) return String(propValue);
|
|
78
|
+
if (defaultValue != null) return String(defaultValue);
|
|
79
|
+
return "";
|
|
80
|
+
});
|
|
81
|
+
|
|
71
82
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
72
83
|
|
|
84
|
+
const strictNumberType = useMemo(() => type === "number" && !!strict, [type, strict]);
|
|
85
|
+
|
|
73
86
|
useImperativeHandle(
|
|
74
87
|
ref,
|
|
75
88
|
() => ({
|
|
@@ -83,22 +96,61 @@ const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
|
|
|
83
96
|
return inputRef.current?.value;
|
|
84
97
|
},
|
|
85
98
|
setValue(value: string | number | undefined) {
|
|
86
|
-
setValue(value);
|
|
99
|
+
setValue(value == null ? "" : String(value));
|
|
87
100
|
},
|
|
88
101
|
}),
|
|
89
102
|
[]
|
|
90
103
|
);
|
|
91
104
|
|
|
92
|
-
const handleChange = useCallback
|
|
93
|
-
event => {
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
const handleChange = useCallback(
|
|
106
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
107
|
+
let newValue = event.currentTarget.value ?? "";
|
|
108
|
+
|
|
109
|
+
if (strictNumberType) {
|
|
110
|
+
newValue = normalizeNumberInput(newValue);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setValue(newValue);
|
|
114
|
+
|
|
115
|
+
onChange?.({
|
|
116
|
+
...event,
|
|
117
|
+
currentTarget: {
|
|
118
|
+
...event.currentTarget,
|
|
119
|
+
value: newValue,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
[onChange, strictNumberType]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const handleKeyDown = useCallback(
|
|
127
|
+
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
128
|
+
if (strictNumberType && event.key.length === 1) {
|
|
129
|
+
// Only handle single-character printable keys here
|
|
130
|
+
// composition and paste handled in onChange
|
|
131
|
+
const {selectionStart, selectionEnd, value} = event.currentTarget;
|
|
132
|
+
|
|
133
|
+
const start = selectionStart ?? value.length;
|
|
134
|
+
const end = selectionEnd ?? start;
|
|
135
|
+
|
|
136
|
+
const next = value.slice(0, start) + event.key + value.slice(end);
|
|
137
|
+
const normalized = normalizeNumberInput(next);
|
|
138
|
+
|
|
139
|
+
if (normalized !== next) {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onKeyDown?.(event);
|
|
96
145
|
},
|
|
97
|
-
[
|
|
146
|
+
[onKeyDown, strictNumberType]
|
|
98
147
|
);
|
|
99
148
|
|
|
100
149
|
useEffect(() => {
|
|
101
|
-
|
|
150
|
+
const text = propValue == null ? "" : String(propValue);
|
|
151
|
+
|
|
152
|
+
setValue(strictNumberType ? normalizeNumberInput(text) : text);
|
|
153
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
154
|
}, [propValue]);
|
|
103
155
|
|
|
104
156
|
return (
|
|
@@ -124,12 +176,13 @@ const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
|
|
|
124
176
|
<input
|
|
125
177
|
{...other}
|
|
126
178
|
ref={inputRef}
|
|
127
|
-
type={type}
|
|
179
|
+
type={strictNumberType ? "text" : type}
|
|
180
|
+
inputMode={strictNumberType ? "decimal" : other.inputMode}
|
|
128
181
|
value={value}
|
|
129
|
-
defaultValue={defaultValue}
|
|
130
182
|
aria-label={label}
|
|
131
183
|
className={classnames(styles["text-field__input"], inputClassName)}
|
|
132
184
|
onChange={handleChange}
|
|
185
|
+
onKeyDown={handleKeyDown}
|
|
133
186
|
/>
|
|
134
187
|
{cloneOrCreateElement(after, {className: classnames(styles["text-field__after"], afterClassName)}, "span")}
|
|
135
188
|
</div>
|
|
@@ -10,7 +10,7 @@ $root: text-field;
|
|
|
10
10
|
font-weight: var(--text-field-font-weight, 400);
|
|
11
11
|
font-size: var(--text-field-font-size, 14px);
|
|
12
12
|
letter-spacing: var(--text-field-letter-spacing, 0.5px);
|
|
13
|
-
line-height: var(--text-field-line-height, var(--line-height,
|
|
13
|
+
line-height: var(--text-field-line-height, var(--line-height, 1rem));
|
|
14
14
|
padding: var(--text-field-padding, 8px 12px);
|
|
15
15
|
border-radius: var(--text-field-border-radius, 8px);
|
|
16
16
|
transition:
|
|
@@ -43,10 +43,12 @@ $root: text-field;
|
|
|
43
43
|
outline: none;
|
|
44
44
|
background: transparent;
|
|
45
45
|
transition: color var(--text-field-speed-color, var(--speed-color));
|
|
46
|
+
appearance: textfield;
|
|
46
47
|
|
|
47
48
|
&:focus {
|
|
48
49
|
outline: none;
|
|
49
50
|
}
|
|
51
|
+
|
|
50
52
|
&:disabled {
|
|
51
53
|
cursor: not-allowed;
|
|
52
54
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes user-typed numeric input.
|
|
3
|
+
*
|
|
4
|
+
* - Allows partial values ("-", ".", "1e", "1e-")
|
|
5
|
+
* - Supports decimals and scientific notation
|
|
6
|
+
*/
|
|
7
|
+
export const normalizeNumberInput = (raw: string): string => {
|
|
8
|
+
if (!raw) return "";
|
|
9
|
+
|
|
10
|
+
const filtered = raw.replace(/[^0-9eE+\-.]/g, "");
|
|
11
|
+
|
|
12
|
+
let result = "";
|
|
13
|
+
let hasExponent = false;
|
|
14
|
+
let hasDot = false;
|
|
15
|
+
let isInExponent = false;
|
|
16
|
+
let canUseSign = true;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
19
|
+
const ch = filtered[i];
|
|
20
|
+
|
|
21
|
+
if (ch >= "0" && ch <= "9") {
|
|
22
|
+
result += ch;
|
|
23
|
+
canUseSign = false;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (ch === ".") {
|
|
28
|
+
if (!isInExponent && !hasDot) {
|
|
29
|
+
result += ch;
|
|
30
|
+
hasDot = true;
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (ch === "e" || ch === "E") {
|
|
36
|
+
if (!hasExponent) {
|
|
37
|
+
if (/\d/.test(result)) {
|
|
38
|
+
result += ch;
|
|
39
|
+
hasExponent = true;
|
|
40
|
+
isInExponent = true;
|
|
41
|
+
canUseSign = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (ch === "+" || ch === "-") {
|
|
48
|
+
if (canUseSign) {
|
|
49
|
+
result += ch;
|
|
50
|
+
canUseSign = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
};
|