@uni-design-system/uni-angular 1.0.0 → 2.0.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.
@@ -0,0 +1,15 @@
1
+ export function memoize<T extends (...args: any[]) => any>(fn: T): T {
2
+ const cache = new Map<string, ReturnType<T>>();
3
+
4
+ return ((...args: Parameters<T>): ReturnType<T> => {
5
+ const key = JSON.stringify(args);
6
+
7
+ if (cache.has(key)) {
8
+ return cache.get(key)!;
9
+ }
10
+
11
+ const result = fn(...args);
12
+ cache.set(key, result);
13
+ return result;
14
+ }) as T;
15
+ }
@@ -0,0 +1,2 @@
1
+ export const safeParseInt = (n: string | number) =>
2
+ typeof n === 'number' ? n : parseInt(n);
@@ -0,0 +1,3 @@
1
+ export * from './local-storage/local-storage.service';
2
+ export * from './option/option.model';
3
+ export * from './timer/timer';
@@ -0,0 +1,124 @@
1
+ import { Injectable } from '@angular/core';
2
+ import { memoize } from '../helpers/memoize.helper';
3
+
4
+ @Injectable({
5
+ providedIn: 'root',
6
+ })
7
+ export class LocalStorageService {
8
+ private isLocalStorageAvailable = memoize((): boolean => {
9
+ try {
10
+ const test = '__localStorage_test__';
11
+ localStorage.setItem(test, test);
12
+ localStorage.removeItem(test);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ });
18
+
19
+ setItem<T>(key: string, value: T): boolean {
20
+ if (!this.isLocalStorageAvailable()) {
21
+ console.warn('LocalStorage is not available');
22
+ return false;
23
+ }
24
+
25
+ try {
26
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
27
+ localStorage.setItem(key, stringValue);
28
+ return true;
29
+ } catch (error) {
30
+ console.error('Error saving to localStorage:', error);
31
+ return false;
32
+ }
33
+ }
34
+
35
+ getItem<T>(key: string): T | null {
36
+ if (!this.isLocalStorageAvailable()) {
37
+ return null;
38
+ }
39
+
40
+ try {
41
+ const item = localStorage.getItem(key);
42
+ if (item === null) {
43
+ return null;
44
+ }
45
+
46
+ try {
47
+ return JSON.parse(item) as T;
48
+ } catch {
49
+ return item as T;
50
+ }
51
+ } catch (error) {
52
+ console.error('Error reading from localStorage:', error);
53
+ return null;
54
+ }
55
+ }
56
+
57
+ removeItem(key: string): boolean {
58
+ if (!this.isLocalStorageAvailable()) {
59
+ return false;
60
+ }
61
+
62
+ try {
63
+ localStorage.removeItem(key);
64
+ return true;
65
+ } catch (error) {
66
+ console.error('Error removing from localStorage:', error);
67
+ return false;
68
+ }
69
+ }
70
+
71
+ clear(): boolean {
72
+ if (!this.isLocalStorageAvailable()) {
73
+ return false;
74
+ }
75
+
76
+ try {
77
+ localStorage.clear();
78
+ return true;
79
+ } catch (error) {
80
+ console.error('Error clearing localStorage:', error);
81
+ return false;
82
+ }
83
+ }
84
+
85
+ hasKey(key: string): boolean {
86
+ if (!this.isLocalStorageAvailable()) {
87
+ return false;
88
+ }
89
+
90
+ return localStorage.getItem(key) !== null;
91
+ }
92
+
93
+ getAllKeys(): string[] {
94
+ if (!this.isLocalStorageAvailable()) {
95
+ return [];
96
+ }
97
+
98
+ try {
99
+ return Object.keys(localStorage);
100
+ } catch (error) {
101
+ console.error('Error getting localStorage keys:', error);
102
+ return [];
103
+ }
104
+ }
105
+
106
+ getSize(): number {
107
+ if (!this.isLocalStorageAvailable()) {
108
+ return 0;
109
+ }
110
+
111
+ try {
112
+ let total = 0;
113
+ for (const key in localStorage) {
114
+ if (Object.prototype.hasOwnProperty.call(localStorage, key)) {
115
+ total += localStorage[key].length + key.length;
116
+ }
117
+ }
118
+ return total;
119
+ } catch (error) {
120
+ console.error('Error calculating localStorage size:', error);
121
+ return 0;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,6 @@
1
+ export interface Option<T = unknown> {
2
+ label: string;
3
+ value: T;
4
+ }
5
+
6
+ export type Options<T = unknown> = Option<T>[];
@@ -0,0 +1,66 @@
1
+ import { computed, DestroyRef, inject, signal } from '@angular/core';
2
+
3
+ export function useTimer() {
4
+ const destroyRef = inject(DestroyRef);
5
+
6
+ const msRemaining = signal<number>(0);
7
+ const isPaused = signal<boolean>(false);
8
+ const isActive = computed(() => msRemaining() > 0);
9
+
10
+ let intervalId: any = null;
11
+ let endTime = 0;
12
+ let onCompleteCallback: (() => void) | undefined;
13
+
14
+ const stop = () => {
15
+ if (intervalId) clearInterval(intervalId);
16
+ intervalId = null;
17
+ };
18
+
19
+ const tick = () => {
20
+ const remaining = Math.max(0, endTime - Date.now());
21
+ msRemaining.set(remaining);
22
+
23
+ if (remaining <= 0) {
24
+ stop();
25
+ if (onCompleteCallback) onCompleteCallback();
26
+ }
27
+ };
28
+
29
+ const start = (durationMs: number, onComplete?: () => void) => {
30
+ stop();
31
+ onCompleteCallback = onComplete;
32
+ isPaused.set(false);
33
+ msRemaining.set(durationMs);
34
+ endTime = Date.now() + durationMs;
35
+ intervalId = setInterval(tick, 100);
36
+ };
37
+
38
+ const pause = () => {
39
+ if (!isActive() || isPaused()) return;
40
+ stop();
41
+ isPaused.set(true);
42
+ };
43
+
44
+ const resume = () => {
45
+ if (!isActive() || !isPaused()) return;
46
+ isPaused.set(false);
47
+ endTime = Date.now() + msRemaining();
48
+ intervalId = setInterval(tick, 100);
49
+ };
50
+
51
+ destroyRef.onDestroy(() => stop());
52
+
53
+ return {
54
+ start,
55
+ pause,
56
+ resume,
57
+ stop: () => {
58
+ stop();
59
+ msRemaining.set(0);
60
+ },
61
+ msRemaining,
62
+ isPaused,
63
+ isActive,
64
+ secondsRemaining: computed(() => Math.ceil(msRemaining() / 1000)),
65
+ };
66
+ }
@@ -0,0 +1,57 @@
1
+ import { Component, HostBinding, inject, input } from '@angular/core';
2
+ import { css } from '@emotion/css';
3
+
4
+ import type {
5
+ ColorKey,
6
+ OptionalDisplay,
7
+ OptionalTextAlign,
8
+ Typeface,
9
+ } from '@uni-design-system/uni-core';
10
+ import { ThemeService } from '../../theming/theme.service';
11
+
12
+ @Component({
13
+ selector: 'uni-text, Text',
14
+ standalone: true,
15
+ imports: [],
16
+ template: '<ng-content></ng-content>',
17
+ })
18
+ export class UniTextComponent {
19
+ theme = inject(ThemeService);
20
+
21
+ typeface = input<Typeface>('title-small');
22
+ color = input<ColorKey>();
23
+ display = input<OptionalDisplay>();
24
+ align = input<OptionalTextAlign>();
25
+ nowrap = input<boolean>();
26
+ maxWidth = input<number>();
27
+ ellipsis = input<boolean>(false);
28
+
29
+ @HostBinding('class') get className() {
30
+ return css([
31
+ {
32
+ ...this.theme.typeface(this.typeface()),
33
+ ...this.theme.color(this.color()),
34
+ display: this.display(),
35
+ },
36
+ this.align() && {
37
+ textAlign: this.align(),
38
+ },
39
+ this.nowrap() && {
40
+ whiteSpace: 'nowrap',
41
+ },
42
+ this.maxWidth() && {
43
+ maxWidth: this.maxWidth(),
44
+ overflow: 'hidden',
45
+ whiteSpace: 'nowrap',
46
+ textOverflow: 'ellipsis',
47
+ display: 'inline-block',
48
+ },
49
+ this.ellipsis() && {
50
+ whiteSpace: 'nowrap',
51
+ overflow: 'hidden',
52
+ textOverflow: 'ellipsis',
53
+ minWidth: 0,
54
+ },
55
+ ]);
56
+ }
57
+ }
@@ -0,0 +1,15 @@
1
+ import { Meta, Title } from '@storybook/addon-docs/blocks';
2
+ import * as Stories from './text.stories';
3
+ import { StoryUsage } from '../../../stories/blocks/StoryUsage';
4
+
5
+ <Meta of={Stories} name="Overview" />
6
+ <Title />
7
+ `import { UniTextComponent } from '@uni-design-system/uni-angular';`
8
+
9
+ ## Overview
10
+
11
+ The Text Component is used to format text using typefaces and colors defined in the loaded theme.
12
+
13
+ ## Usage
14
+
15
+ <StoryUsage of={Stories.DisplayLarge} />
@@ -0,0 +1,39 @@
1
+ import { argsToTemplate, Meta, StoryObj } from '@storybook/angular';
2
+ import { UniTextComponent } from './text.component';
3
+ import type { Typeface, ColorKey } from '@uni-design-system/uni-core';
4
+
5
+ // 1. Create a unified argument type for the story file
6
+ type StoryArgs = UniTextComponent & { ngContent?: string };
7
+
8
+ // 2. Pass the combined type directly to Meta so it recognizes 'ngContent' in argTypes
9
+ const meta: Meta<StoryArgs> = {
10
+ title: 'Components/Text',
11
+ component: UniTextComponent as any, // Cast to any prevents the mapper from breaking on the intersection
12
+ render: (args) => {
13
+ const { ngContent, ...componentProps } = args;
14
+ return {
15
+ props: componentProps,
16
+ template: `<uni-text ${argsToTemplate(componentProps)}>${ngContent || ''}</uni-text>`,
17
+ };
18
+ },
19
+ argTypes: {
20
+ ngContent: {
21
+ control: 'text',
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+
28
+ // 3. Keep the matching type structure on your StoryObj
29
+ type Story = StoryObj<StoryArgs>;
30
+
31
+ export const DisplayLarge: Story = {
32
+ args: {
33
+ ngContent: 'The quick brown fox jumps over the lazy dog.',
34
+ color: 'primary' as ColorKey,
35
+ typeface: 'title-large' as Typeface,
36
+ display: 'block',
37
+ align: 'center',
38
+ },
39
+ };
@@ -0,0 +1,270 @@
1
+ // noinspection JSUnusedGlobalSymbols
2
+
3
+ import { computed, inject, Injectable, linkedSignal, Signal, signal } from '@angular/core';
4
+ import { css } from '@emotion/css';
5
+ import { LocalStorageService, Options } from '../cdk';
6
+ import {
7
+ LightTheme,
8
+ type NullableSize,
9
+ type ThemeName,
10
+ type Typeface,
11
+ type UniTheme,
12
+ Z_INDEX,
13
+ type ZIndexableElements,
14
+ ColorToken,
15
+ ComponentName,
16
+ ComponentTheme,
17
+ ContainerColorToken,
18
+ ContentColorToken,
19
+ NullableStyleExpression,
20
+ OptionalSize,
21
+ Size,
22
+ TextRole,
23
+ Thickness,
24
+ Variant,
25
+ type ColorKey,
26
+ type Radius,
27
+ type Border,
28
+ type Shadow,
29
+ } from '@uni-design-system/uni-core';
30
+
31
+ import { UNI_THEMES } from './theme.token';
32
+ import { safeParseInt } from '../cdk/helpers/number.helper';
33
+
34
+ @Injectable({
35
+ providedIn: 'root',
36
+ })
37
+ export class ThemeService {
38
+ private themes = inject(UNI_THEMES);
39
+ private localStorage = inject(LocalStorageService);
40
+
41
+ theme = signal<UniTheme>(LightTheme);
42
+ themeOptions = signal<Options<ThemeName>>([]);
43
+
44
+ components = computed(() => this.theme().components);
45
+ component = <T>(componentName: ComponentName): Signal<ComponentTheme<T>> =>
46
+ computed(() => (this.components()[componentName] as ComponentTheme<T>) || {});
47
+ colors = computed(() => this.theme().colors);
48
+ typeFaces = computed(() => this.theme().typefaces);
49
+ spacing = computed(() => this.theme().spacing);
50
+ thicknesses = computed(() => this.theme().thicknesses);
51
+ radii = computed(() => this.theme().radii);
52
+ borders = computed(() => this.theme().borders);
53
+ shadows = computed(() => this.theme().shadows);
54
+ icons = computed(() => this.theme().icons);
55
+
56
+ constructor() {
57
+ this.themeOptions.set(
58
+ Object.keys(this.themes).map((key) => {
59
+ return { label: this.themes[key].name, value: key };
60
+ })
61
+ );
62
+
63
+ this.selectTheme(this.localStorage.getItem('theme') || Object.keys(this.themes)[0] || 'base');
64
+ }
65
+
66
+ public selectTheme(themeName: ThemeName): void {
67
+ this.selectedThemeKey.set(themeName);
68
+ if (this.themes[themeName]) this.theme.set(this.themes[themeName]);
69
+ this.localStorage.setItem('theme', themeName);
70
+ }
71
+
72
+ public selectedThemeName = computed(() => this.theme().name);
73
+ public selectedThemeKey = signal<string>('');
74
+
75
+ textClass = (textRole: TextRole, textColor?: ContentColorToken) => {
76
+ return css([
77
+ {
78
+ ...this.typeFaces()[textRole],
79
+ },
80
+ textColor && {
81
+ color: this.colors()[textColor],
82
+ },
83
+ ]);
84
+ };
85
+
86
+ componentStyle = (componentName: ComponentName, variant: Variant, size: Size) =>
87
+ computed(() => {
88
+ const component = this.component(componentName)();
89
+ const { fixed, colors, sizes } = component;
90
+ const colorStyle = colors && colors[variant];
91
+ const sizeStyle = sizes && sizes[size];
92
+ return { ...fixed, ...colorStyle, ...sizeStyle };
93
+ });
94
+
95
+ getSpacing = (size: NullableSize) => {
96
+ return size === 'none' ? 'none' : this.spacing()[size];
97
+ };
98
+
99
+ getThickness = (thickness: Thickness) => this.theme().thicknesses[thickness];
100
+
101
+ getContentColor = (token: ContainerColorToken, useVariant?: boolean) =>
102
+ useVariant
103
+ ? this.colors()[`on-${token}-variant` as ColorToken]
104
+ : this.colors()[`on-${token}` as ColorToken];
105
+
106
+ colorPair = (token?: ContainerColorToken, colorVariant?: boolean): NullableStyleExpression => {
107
+ if (!token) return;
108
+ const backgroundColor = this.colors()[token];
109
+ const color = this.getContentColor(token, colorVariant);
110
+ return { color, backgroundColor };
111
+ };
112
+
113
+ backgroundColor = (token?: ColorKey): NullableStyleExpression => {
114
+ return !token ? undefined : { backgroundColor: this.colors()[token] };
115
+ };
116
+
117
+ backgroundImage = (url?: string): NullableStyleExpression => {
118
+ return !url ? undefined : { backgroundImage: `url(${url})` };
119
+ };
120
+
121
+ getContainerColors = (color: Variant, useVariant?: boolean) => {
122
+ const token = (color + '-container') as ContainerColorToken;
123
+ return this.colorPair(token, useVariant);
124
+ };
125
+
126
+ typeface = (typeface?: Typeface) => typeface && this.typeFaces()[typeface];
127
+
128
+ colorPalette = () => this.colors();
129
+
130
+ color(color?: ColorKey): NullableStyleExpression {
131
+ return !color ? undefined : { color: this.colors()[color] };
132
+ }
133
+
134
+ getDashedBorder(
135
+ color: ColorKey | undefined,
136
+ radius: Radius | undefined
137
+ ): NullableStyleExpression {
138
+ if (!color) return;
139
+
140
+ const r = radius && this.radii()[radius];
141
+ const borderRadius = r ? safeParseInt(r) : 0;
142
+ const colors = this.colors()[color];
143
+ const strokeColor = colors?.replace('#', '%23');
144
+
145
+ return {
146
+ backgroundImage: `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='${borderRadius}' ry='${borderRadius}' stroke='${strokeColor}' stroke-width='4' stroke-dasharray='6%2c 14' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e")`,
147
+ borderRadius,
148
+ };
149
+ }
150
+
151
+ radius(size: Radius | undefined): NullableStyleExpression {
152
+ return !size ? undefined : { borderRadius: this.radii()[size] };
153
+ }
154
+
155
+ getRadiusLeft(size: Radius | undefined): NullableStyleExpression {
156
+ if (!size) return;
157
+ return {
158
+ borderBottomLeftRadius: this.radii()[size],
159
+ borderTopLeftRadius: this.radii()[size],
160
+ };
161
+ }
162
+
163
+ getRadiusRight(size: Radius | undefined): NullableStyleExpression {
164
+ if (!size) return;
165
+ return {
166
+ borderBottomRightRadius: this.radii()[size],
167
+ borderTopRightRadius: this.radii()[size],
168
+ };
169
+ }
170
+
171
+ getRadiusTop(size: Radius | undefined): NullableStyleExpression {
172
+ if (!size) return;
173
+ return {
174
+ borderTopLeftRadius: this.radii()[size],
175
+ borderTopRightRadius: this.radii()[size],
176
+ };
177
+ }
178
+
179
+ getRadiusBottom(size: Radius | undefined): NullableStyleExpression {
180
+ if (!size) return;
181
+ return {
182
+ borderBottomLeftRadius: this.radii()[size],
183
+ borderBottomRightRadius: this.radii()[size],
184
+ };
185
+ }
186
+
187
+ padding(size: OptionalSize): NullableStyleExpression {
188
+ return !size ? undefined : { padding: this.spacing()[size] };
189
+ }
190
+
191
+ horizontalPadding(size: OptionalSize): NullableStyleExpression {
192
+ return !size ? undefined : { paddingInline: this.spacing()[size] };
193
+ }
194
+
195
+ verticalPadding(size: OptionalSize): NullableStyleExpression {
196
+ return !size ? undefined : { paddingBlock: this.spacing()[size] };
197
+ }
198
+
199
+ paddingLeft(size: OptionalSize): NullableStyleExpression {
200
+ return !size ? undefined : { paddingLeft: this.spacing()[size] };
201
+ }
202
+
203
+ paddingRight(size: OptionalSize): NullableStyleExpression {
204
+ return !size ? undefined : { paddingRight: this.spacing()[size] };
205
+ }
206
+
207
+ paddingTop(size: OptionalSize): NullableStyleExpression {
208
+ return !size ? undefined : { paddingTop: this.spacing()[size] };
209
+ }
210
+
211
+ paddingBottom(size: OptionalSize): NullableStyleExpression {
212
+ return !size ? undefined : { paddingBottom: this.spacing()[size] };
213
+ }
214
+
215
+ border(border: Border | undefined): NullableStyleExpression {
216
+ return !border ? undefined : { border: this.borders()[border] };
217
+ }
218
+
219
+ borderTop(border: Border | undefined): NullableStyleExpression {
220
+ return !border ? undefined : { borderTop: this.borders()[border] };
221
+ }
222
+
223
+ borderBottom(border: Border | undefined): NullableStyleExpression {
224
+ return !border ? undefined : { borderBottom: this.borders()[border] };
225
+ }
226
+
227
+ borderLeft(border: Border | undefined): NullableStyleExpression {
228
+ return !border ? undefined : { borderLeft: this.borders()[border] };
229
+ }
230
+
231
+ borderRight(border: Border | undefined): NullableStyleExpression {
232
+ return !border ? undefined : { borderRight: this.borders()[border] };
233
+ }
234
+
235
+ boxShadow(shadow: Shadow | undefined) {
236
+ return !shadow ? undefined : { boxShadow: this.shadows()[shadow] };
237
+ }
238
+
239
+ gap(gap: OptionalSize): NullableStyleExpression {
240
+ return !gap || gap === 'none' ? undefined : { gap: this.spacing()[gap] };
241
+ }
242
+
243
+ zIndex(element: ZIndexableElements | undefined): NullableStyleExpression {
244
+ return !element ? undefined : { zIndex: Z_INDEX[element] };
245
+ }
246
+
247
+ borderColor(borderColor: ColorToken): NullableStyleExpression {
248
+ return { borderColor: this.colors()[borderColor] };
249
+ }
250
+
251
+ getComponentTheme<T>(componentName: ComponentName) {
252
+ return this.component<T>(componentName);
253
+ }
254
+
255
+ // Used to get an "always-defined" options object from a component theme.
256
+ getComponentOptions = <T>(componentName: ComponentName) =>
257
+ linkedSignal({
258
+ source: this.getComponentTheme<T>(componentName),
259
+ computation: () => {
260
+ return this.getComponentTheme<T>(componentName)().options || ({} as T);
261
+ },
262
+ });
263
+
264
+ componentOptions = (componentName: ComponentName) =>
265
+ computed(() => this.component(componentName)().options || {});
266
+
267
+ style(prop: string, value: string | number | undefined): NullableStyleExpression {
268
+ return !value ? undefined : { [prop]: value };
269
+ }
270
+ }
@@ -0,0 +1,7 @@
1
+ import { InjectionToken } from '@angular/core';
2
+ import { type UniTheme, UniThemes } from '@uni-design-system/uni-core';
3
+
4
+ export const UNI_THEMES = new InjectionToken<Record<string, UniTheme>>('', {
5
+ providedIn: 'root',
6
+ factory: () => UniThemes,
7
+ });
@@ -0,0 +1,21 @@
1
+ import { Controls, Source, Story, StoryProps } from '@storybook/addon-docs/blocks';
2
+ import { ReactElement } from 'react';
3
+
4
+ export function StoryUsage({
5
+ of,
6
+ exclude,
7
+ }: StoryProps & { exclude?: string | string[] }): ReactElement {
8
+ const commonControlExcludes = ['children', 'className', 'style'];
9
+ const excludedControls = [
10
+ ...commonControlExcludes,
11
+ ...(Array.isArray(exclude) ? exclude : exclude ? [exclude] : []),
12
+ ];
13
+
14
+ return (
15
+ <div>
16
+ <Story of={of} />
17
+ <Source of={of} dark={true} />
18
+ <Controls of={of} exclude={excludedControls} />
19
+ </div>
20
+ );
21
+ }
package/tsconfig.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "extends": "@uni-design-system/tsconfig/base.json",
3
3
  "compilerOptions": {
4
+ "moduleResolution": "bundler",
4
5
  "outDir": "./dist",
5
- "rootDir": "./src",
6
+ "rootDir": "..",
6
7
  "module": "ESNext",
7
8
  "target": "ES2022",
8
9
  "esModuleInterop": true,
9
10
  "types": ["node", "vite/client"],
11
+ "jsx": "react-jsx",
10
12
  "emitDecoratorMetadata": true,
11
13
  "experimentalDecorators": true,
12
14
  "useDefineForClassFields": false,
13
- "ignoreDeprecations": "6.0",
15
+ "ignoreDeprecations": "5.0"
14
16
  },
15
- "include": ["src/**/*", "vite.config.ts"],
16
- "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.stories.ts"]
17
+ "include": ["src/**/*", "**/*"],
18
+ "exclude": ["../node_modules", "../dist", "**/*.spec.ts", "**/*.stories.ts"]
17
19
  }