vueless 1.3.9-beta.9 → 1.4.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.
@@ -1,5 +1,4 @@
1
1
  import { ref, watch, getCurrentInstance, toValue, useAttrs, computed } from "vue";
2
- import { isEqual } from "lodash-es";
3
2
 
4
3
  import { cx, cva, setColor, vuelessConfig, getMergedConfig } from "../utils/ui";
5
4
  import {
@@ -9,7 +8,7 @@ import {
9
8
  NESTED_COMPONENT_PATTERN_REG_EXP,
10
9
  } from "../constants";
11
10
 
12
- import type { Ref, ComputedRef } from "vue";
11
+ import type { Ref } from "vue";
13
12
  import type {
14
13
  CVA,
15
14
  UseUI,
@@ -20,11 +19,17 @@ import type {
20
19
  UnknownObject,
21
20
  ComponentNames,
22
21
  NestedComponent,
22
+ ConfigDerivedData,
23
23
  ComponentDefaults,
24
24
  ComponentConfigFull,
25
25
  VuelessComponentInstance,
26
26
  } from "../types";
27
27
 
28
+ /* Pre-computed Set for O(1) system key lookups instead of O(n) array scan. */
29
+ const CVA_KEY_SET = new Set(Object.values(CVA_CONFIG_KEY));
30
+ const SYSTEM_KEY_SET = new Set(Object.values(SYSTEM_CONFIG_KEY));
31
+ const TRANSITION_KEY = SYSTEM_CONFIG_KEY.transition;
32
+
28
33
  /**
29
34
  * Merging component configs in a given sequence (bigger number = bigger priority):
30
35
  * 1. Default component config
@@ -43,11 +48,26 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
43
48
 
44
49
  const firstClassKey = Object.keys(defaultConfig || {})[0];
45
50
  const config = ref({}) as Ref<ComponentConfigFull<T>>;
51
+ const isDev = import.meta.env?.DEV;
52
+ const isUnstyled = Boolean(vuelessConfig.unstyled);
53
+
54
+ /* Hoist shared reactive primitives — create once, share across all keys. */
55
+ const attrs = useAttrs() as KeyAttrs;
56
+ const reactiveAttrsClass = computed(() => attrs.class);
57
+
58
+ /**
59
+ * Reactive wrapper for props — created once per component instead of once per key.
60
+ * Spreads props to create a shallow copy that triggers reactivity on any prop change.
61
+ */
62
+ const reactiveProps = computed(() => ({ ...props }));
63
+
64
+ /* Cache for CVA resolver functions — only recreated when config changes. */
65
+ const cvaCache = new Map<string, ReturnType<typeof cva>>();
46
66
 
47
67
  watch(
48
68
  () => props.config,
49
69
  (newVal, oldVal) => {
50
- if (isEqual(newVal, oldVal)) return;
70
+ if (newVal === oldVal) return;
51
71
 
52
72
  const propsConfig = props.config as ComponentConfigFull<T>;
53
73
 
@@ -55,195 +75,209 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
55
75
  defaultConfig,
56
76
  globalConfig,
57
77
  propsConfig,
58
- unstyled: Boolean(vuelessConfig.unstyled),
78
+ unstyled: isUnstyled,
59
79
  }) as ComponentConfigFull<T>;
80
+
81
+ /* Invalidate CVA cache when config changes. */
82
+ cvaCache.clear();
60
83
  },
61
84
  { deep: true, immediate: true },
62
85
  );
63
86
 
64
87
  /**
65
- * Get classes by a given key (including CVA if config set).
88
+ * Pre-computed data per config key that only changes when config changes.
89
+ * Avoids recomputing extends configs, nested component info, and merged configs on every prop change.
66
90
  */
67
- function getClasses(key: string, mutatedProps?: MutatedProps) {
68
- return computed(() => {
69
- const mutatedPropsValue = toValue(mutatedProps);
70
- const value = (config.value as ComponentConfigFull<T>)[key];
71
- const color = (toValue(mutatedProps || {}).color || props.color) as StateColors;
72
-
73
- const isNestedComponent = Boolean(getNestedComponent(value));
74
-
75
- let classes = "";
76
-
77
- if (typeof value === "object" && isCVA(value)) {
78
- classes = cva(value)({
79
- ...props,
80
- ...mutatedPropsValue,
81
- ...(color ? { color } : {}),
82
- });
83
- }
91
+ const configDerivedData = computed(() => {
92
+ const data: ConfigDerivedData = {};
84
93
 
85
- if (typeof value === "string") {
86
- classes = value;
87
- }
94
+ for (const key in config.value) {
95
+ if (isSystemKey(key)) continue;
88
96
 
89
- classes = classes
90
- .replaceAll(EXTENDS_PATTERN_REG_EXP, "")
91
- .replace(NESTED_COMPONENT_PATTERN_REG_EXP, "");
97
+ const keyConfig: NestedComponent =
98
+ typeof config.value[key] === "object" ? (config.value[key] as NestedComponent) : {};
92
99
 
93
- return color && !isNestedComponent ? setColor(classes, color) : classes;
94
- });
95
- }
100
+ const extendsKeyConfig = computeExtendsKeyConfig(key);
101
+ const extendsKeyNestedComponent = getNestedComponent(extendsKeyConfig);
102
+ const keyNestedComponent = getNestedComponent(config.value[key]);
103
+ const nestedComponent = extendsKeyNestedComponent || keyNestedComponent || componentName;
96
104
 
97
- /**
98
- * Returns an object where:
99
- * – key: elementKey
100
- * – value: reactive object of string element attributes (with classes).
101
- */
102
- function getKeysAttrs(mutatedProps?: MutatedProps) {
103
- const keysAttrs: KeysAttrs<T> = {};
105
+ const mergedNestedConfig = getMergedConfig({
106
+ defaultConfig: extendsKeyConfig,
107
+ globalConfig: keyConfig,
108
+ propsConfig: attrs["config"] || {},
109
+ unstyled: isUnstyled,
110
+ });
104
111
 
105
- for (const key in config.value) {
106
- if (isSystemKey(key)) continue;
112
+ const mergedDefaults: ComponentDefaults = {};
113
+
114
+ const defaultAttrs = {
115
+ ...(extendsKeyConfig.defaults || {}),
116
+ ...(keyConfig.defaults || {}),
117
+ };
107
118
 
108
- keysAttrs[`${key}Attrs`] = getAttrs(key, getClasses(key, mutatedProps));
119
+ for (const defaultKey in defaultAttrs) {
120
+ mergedDefaults[defaultKey] =
121
+ typeof defaultAttrs[defaultKey] === "object"
122
+ ? defaultAttrs[defaultKey][String(props[defaultKey])]
123
+ : defaultAttrs[defaultKey];
124
+ }
125
+
126
+ data[key] = {
127
+ keyConfig,
128
+ extendsClasses: computeExtendsClasses(key),
129
+ extendsKeyConfig,
130
+ nestedComponent,
131
+ mergedNestedConfig,
132
+ mergedDefaults,
133
+ };
109
134
  }
110
135
 
111
- return keysAttrs;
112
- }
136
+ return data;
137
+ });
113
138
 
114
139
  /**
115
- * Get element attributes for a given key.
140
+ * Compute classes for a given key directly (not as a computed ref).
141
+ * Used inside watchers to avoid creating orphaned computed properties.
116
142
  */
117
- function getAttrs(configKey: string, classes: ComputedRef<string>) {
118
- const vuelessAttrs = ref({} as KeyAttrs);
143
+ function computeClassesForKey(key: string) {
144
+ const mutatedPropsValue = toValue(mutatedProps);
145
+ const value = (config.value as ComponentConfigFull<T>)[key];
146
+ const color = (toValue(mutatedProps || {}).color || props.color) as StateColors;
119
147
 
120
- const attrs = useAttrs() as KeyAttrs;
148
+ const isNestedComponent = Boolean(getNestedComponent(value));
121
149
 
122
- const reactiveProps = computed(() => ({ ...props }));
123
- const reactiveClass = computed(() => attrs.class);
150
+ let classes = "";
124
151
 
125
- watch([config, reactiveProps, classes, reactiveClass], updateVuelessAttrs, { immediate: true });
152
+ if (typeof value === "object" && isCVA(value)) {
153
+ let cvaFn = cvaCache.get(key);
126
154
 
127
- /**
128
- * Updating Vueless attributes.
129
- */
130
- function updateVuelessAttrs(newVal: unknown, oldVal: unknown) {
131
- if (isEqual(newVal, oldVal)) return;
155
+ if (!cvaFn) {
156
+ cvaFn = cva(value);
157
+ cvaCache.set(key, cvaFn);
158
+ }
132
159
 
133
- let keyConfig: NestedComponent = {};
160
+ classes = cvaFn({
161
+ ...props,
162
+ ...mutatedPropsValue,
163
+ ...(color ? { color } : {}),
164
+ });
165
+ }
134
166
 
135
- if (typeof config.value[configKey] === "object") {
136
- keyConfig = config.value[configKey] as NestedComponent;
137
- }
167
+ if (typeof value === "string") {
168
+ classes = value;
169
+ }
138
170
 
139
- const isDev = import.meta.env?.DEV;
140
- const isTopLevelKey = (topLevelClassKey || firstClassKey) === configKey;
171
+ classes = classes
172
+ .replaceAll(EXTENDS_PATTERN_REG_EXP, "")
173
+ .replace(NESTED_COMPONENT_PATTERN_REG_EXP, "");
141
174
 
142
- const extendsClasses = getExtendsClasses(configKey);
143
- const extendsKeyConfig = getExtendsKeyConfig(configKey);
144
- const extendsKeyNestedComponent = getNestedComponent(extendsKeyConfig);
145
- const keyNestedComponent = getNestedComponent(config.value[configKey]);
146
- const nestedComponent = extendsKeyNestedComponent || keyNestedComponent || componentName;
175
+ return color && !isNestedComponent ? setColor(classes, color) : classes;
176
+ }
147
177
 
148
- const commonAttrs: KeyAttrs = {
149
- ...(isTopLevelKey ? attrs : {}),
150
- "vl-component": isDev ? attrs["vl-component"] || componentName || null : null,
151
- "vl-key": isDev ? attrs["vl-key"] || configKey || null : null,
152
- "vl-child-component": isDev && attrs["vl-component"] ? nestedComponent : null,
153
- "vl-child-key": isDev && attrs["vl-component"] ? configKey : null,
154
- };
178
+ /**
179
+ * Recursively compute extends classes directly (no orphaned computed refs).
180
+ */
181
+ function computeExtendsClasses(configKey: string): string[] {
182
+ const extendsKeys = getExtendsKeys(config.value[configKey]);
155
183
 
156
- /* Delete value key to prevent v-model overwrite. */
157
- delete commonAttrs.value;
158
-
159
- vuelessAttrs.value = {
160
- ...commonAttrs,
161
- class: cx([...extendsClasses, toValue(classes), commonAttrs.class]),
162
- config: getMergedConfig({
163
- defaultConfig: extendsKeyConfig,
164
- globalConfig: keyConfig,
165
- propsConfig: attrs["config"] || {},
166
- unstyled: Boolean(vuelessConfig.unstyled),
167
- }),
168
- ...getDefaults({
169
- ...(extendsKeyConfig.defaults || {}),
170
- ...(keyConfig.defaults || {}),
171
- }),
172
- };
184
+ if (!extendsKeys.length) return [];
185
+
186
+ const result: string[] = [];
187
+
188
+ for (const key of extendsKeys) {
189
+ if (key === configKey) continue;
190
+
191
+ result.push(...computeExtendsClasses(key), computeClassesForKey(key));
173
192
  }
174
193
 
175
- /**
176
- * Recursively get extends classes.
177
- */
178
- function getExtendsClasses(configKey: string) {
179
- let extendsClasses: string[] = [];
194
+ return result;
195
+ }
180
196
 
181
- const extendsKeys = getExtendsKeys(config.value[configKey]);
197
+ /**
198
+ * Merge extends nested component configs.
199
+ * TODO: Add ability to merge multiple keys in one (now works for merging only 1 first key).
200
+ */
201
+ function computeExtendsKeyConfig(configKey: string): NestedComponent {
202
+ const propsConfig = props.config as ComponentConfigFull<T>;
203
+ const extendsKeys = getExtendsKeys(config.value[configKey]);
182
204
 
183
- if (extendsKeys.length) {
184
- extendsKeys.forEach((key) => {
185
- if (key === configKey) return;
205
+ if (!extendsKeys.length) return {};
186
206
 
187
- extendsClasses = [
188
- ...extendsClasses,
189
- ...getExtendsClasses(key),
190
- toValue(getClasses(key, mutatedProps)),
191
- ];
192
- });
193
- }
207
+ const [firstKey] = extendsKeys;
194
208
 
195
- return extendsClasses;
209
+ if (config.value[firstKey] === undefined) {
210
+ // eslint-disable-next-line no-console
211
+ console.warn(`[vueless] Missing ${firstKey} extend key.`);
196
212
  }
197
213
 
198
- /**
199
- * Merge extends nested component configs.
200
- * TODO: Add ability to merge multiple keys in one (now works for merging only 1 first key).
201
- */
202
- function getExtendsKeyConfig(configKey: string) {
203
- let extendsKeyConfig: NestedComponent = {};
204
-
205
- const propsConfig = props.config as ComponentConfigFull<T>;
206
- const extendsKeys = getExtendsKeys(config.value[configKey]);
214
+ return getMergedConfig({
215
+ defaultConfig: config.value[firstKey] || {},
216
+ globalConfig: globalConfig[firstKey],
217
+ propsConfig: propsConfig[firstKey],
218
+ unstyled: isUnstyled,
219
+ }) as NestedComponent;
220
+ }
207
221
 
208
- if (extendsKeys.length) {
209
- const [firstKey] = extendsKeys;
222
+ /**
223
+ * Returns an object where:
224
+ * – key: elementKey
225
+ * – value: reactive object of string element attributes (with classes).
226
+ */
227
+ function getKeysAttrs(mutatedProps?: MutatedProps) {
228
+ const keysAttrs: KeysAttrs<T> = {};
229
+ const attrsRefs: Record<string, Ref<KeyAttrs>> = {};
210
230
 
211
- if (config.value[firstKey] === undefined) {
212
- // eslint-disable-next-line no-console
213
- console.warn(`[vueless] Missing ${firstKey} extend key.`);
214
- }
231
+ for (const key in config.value) {
232
+ if (isSystemKey(key)) continue;
215
233
 
216
- extendsKeyConfig = getMergedConfig({
217
- defaultConfig: config.value[firstKey] || {},
218
- globalConfig: globalConfig[firstKey],
219
- propsConfig: propsConfig[firstKey],
220
- unstyled: Boolean(vuelessConfig.unstyled),
221
- }) as NestedComponent;
222
- }
234
+ const vuelessAttrs = ref({} as KeyAttrs);
223
235
 
224
- return extendsKeyConfig;
236
+ attrsRefs[key] = vuelessAttrs;
237
+ keysAttrs[`${key}Attrs`] = vuelessAttrs;
225
238
  }
226
239
 
227
240
  /**
228
- * Get component prop default value.
229
- * Conditionally set props default value for nested components based on parent component prop value.
230
- * For example, set icon size for the nested component based on the size of the parent component.
231
- * Use an object where key = parent component prop value, value = nested component prop value.
232
- * */
233
- function getDefaults(defaultAttrs: NestedComponent["defaults"]) {
234
- const defaults: ComponentDefaults = {};
235
-
236
- for (const key in defaultAttrs) {
237
- defaults[key] =
238
- typeof defaultAttrs[key] === "object"
239
- ? defaultAttrs[key][String(props[key])]
240
- : defaultAttrs[key];
241
- }
242
-
243
- return defaults;
244
- }
241
+ * Single consolidated watcher instead of N per-key watchers.
242
+ * Watches: config (for config changes), reactiveProps (for prop changes),
243
+ * mutatedProps (for slot/computed prop changes), reactiveAttrsClass (for class attr changes).
244
+ */
245
+ watch(
246
+ [config, reactiveProps, mutatedProps || (() => undefined), reactiveAttrsClass],
247
+ () => {
248
+ const derived = configDerivedData.value;
249
+
250
+ for (const key in attrsRefs) {
251
+ const data = derived[key];
252
+
253
+ if (!data) continue;
254
+
255
+ const isTopLevelKey = (topLevelClassKey || firstClassKey) === key;
256
+ const classes = computeClassesForKey(key);
257
+
258
+ const commonAttrs: KeyAttrs = {
259
+ ...(isTopLevelKey ? attrs : {}),
260
+ "vl-component": isDev ? attrs["vl-component"] || componentName || null : null,
261
+ "vl-key": isDev ? attrs["vl-key"] || key || null : null,
262
+ "vl-child-component": isDev && attrs["vl-component"] ? data.nestedComponent : null,
263
+ "vl-child-key": isDev && attrs["vl-component"] ? key : null,
264
+ };
265
+
266
+ /* Delete value key to prevent v-model overwrite. */
267
+ delete commonAttrs.value;
268
+
269
+ attrsRefs[key].value = {
270
+ ...commonAttrs,
271
+ class: cx([...data.extendsClasses, classes, commonAttrs.class]),
272
+ config: data.mergedNestedConfig,
273
+ ...data.mergedDefaults,
274
+ };
275
+ }
276
+ },
277
+ { immediate: true },
278
+ );
245
279
 
246
- return vuelessAttrs;
280
+ return keysAttrs;
247
281
  }
248
282
 
249
283
  /**
@@ -275,7 +309,7 @@ function getExtendsKeys(configItemValue?: string | CVA | NestedComponent): strin
275
309
  const values = getBaseClasses(configItemValue);
276
310
  const matches = values.match(EXTENDS_PATTERN_REG_EXP);
277
311
 
278
- return matches ? matches?.map((pattern) => pattern.slice(2, -1)) : [];
312
+ return matches ? matches.map((pattern) => pattern.slice(2, -1)) : [];
279
313
  }
280
314
 
281
315
  /**
@@ -292,9 +326,7 @@ function getNestedComponent(value?: string | CVA | NestedComponent) {
292
326
  * Check is config key not contains classes or CVA config object.
293
327
  */
294
328
  function isSystemKey(key: string): boolean {
295
- const isExactKey = Object.values(SYSTEM_CONFIG_KEY).some((value) => value === key);
296
-
297
- return isExactKey || key.toLowerCase().includes(SYSTEM_CONFIG_KEY.transition.toLowerCase());
329
+ return SYSTEM_KEY_SET.has(key) || key.toLowerCase().includes(TRANSITION_KEY);
298
330
  }
299
331
 
300
332
  /**
@@ -305,7 +337,7 @@ function isCVA(config?: UnknownObject | string): boolean {
305
337
  return false;
306
338
  }
307
339
 
308
- return Object.values(CVA_CONFIG_KEY).some((value) =>
309
- Object.keys(config).some((key) => key === value),
310
- );
340
+ const keys = Object.keys(config);
341
+
342
+ return keys.some((key) => CVA_KEY_SET.has(key));
311
343
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vueless",
3
- "version": "1.3.9-beta.9",
3
+ "version": "1.4.0",
4
4
  "description": "Vue Styleless UI Component Library, powered by Tailwind CSS.",
5
5
  "author": "Johnny Grid <hello@vueless.com> (https://vueless.com)",
6
6
  "homepage": "https://vueless.com",
@@ -57,7 +57,7 @@
57
57
  "@vue/eslint-config-typescript": "^14.6.0",
58
58
  "@vue/test-utils": "^2.4.6",
59
59
  "@vue/tsconfig": "^0.7.0",
60
- "@vueless/storybook": "^1.4.8",
60
+ "@vueless/storybook": "^1.4.10",
61
61
  "eslint": "^9.32.0",
62
62
  "eslint-plugin-storybook": "^10.0.2",
63
63
  "eslint-plugin-vue": "^10.3.0",
package/types.ts CHANGED
@@ -385,6 +385,17 @@ export type UseUI<T> = {
385
385
  getDataTest: (suffix?: string) => string | null;
386
386
  } & KeysAttrs<T>;
387
387
 
388
+ export interface ConfigDerivedData {
389
+ [key: string]: {
390
+ keyConfig: NestedComponent;
391
+ extendsClasses: string[];
392
+ extendsKeyConfig: NestedComponent;
393
+ nestedComponent: string;
394
+ mergedNestedConfig: unknown;
395
+ mergedDefaults: ComponentDefaults;
396
+ };
397
+ }
398
+
388
399
  export type KeysAttrs<T> = Record<
389
400
  string,
390
401
  Ref<KeyAttrsWithConfig<T>> | ComputedRef<KeyAttrsWithConfig<T>>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, useSlots, useTemplateRef } from "vue";
3
- import { RouterLink } from "vue-router";
3
+ import { RouterLink, useRoute, useRouter } from "vue-router";
4
4
 
5
5
  import { useUI } from "../composables/useUI";
6
6
  import { hasSlotContent } from "../utils/helper";
@@ -49,6 +49,8 @@ const emit = defineEmits([
49
49
  const slots = useSlots();
50
50
 
51
51
  const linkRef = useTemplateRef<HTMLLinkElement>("link");
52
+ const route = useRoute();
53
+ const router = useRouter();
52
54
 
53
55
  const isPresentRoute = computed(() => {
54
56
  return typeof props.to === "string" || typeof props.to === "object";
@@ -58,6 +60,20 @@ const safeTo = computed(() => {
58
60
  return props.to || "/";
59
61
  });
60
62
 
63
+ const isActive = computed(() => {
64
+ if (!isPresentRoute.value) return false;
65
+
66
+ try {
67
+ const resolved = router.resolve(safeTo.value);
68
+ const currentPath = route.path;
69
+ const targetPath = resolved.path;
70
+
71
+ return currentPath === targetPath || currentPath.startsWith(targetPath + "/");
72
+ } catch {
73
+ return false;
74
+ }
75
+ });
76
+
61
77
  const prefixedHref = computed(() => {
62
78
  const types = {
63
79
  phone: "tel:",
@@ -142,7 +158,7 @@ const { getDataTest, linkAttrs } = useUI<Config>(defaultConfig, mutatedProps);
142
158
  @binding {boolean} is-active
143
159
  @binding {boolean} is-exact-active
144
160
  -->
145
- <slot :is-active="slotProps.isActive" :is-exact-active="slotProps.isExactActive">
161
+ <slot :is-active="isActive" :is-exact-active="slotProps.isExactActive">
146
162
  {{ label }}
147
163
  </slot>
148
164
  </router-link>