itube-specs 0.0.705 → 0.0.707

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.
@@ -4,37 +4,77 @@
4
4
  <h2
5
5
  v-if="title"
6
6
  class="s-filter-page__title"
7
- >{{ title }}</h2>
7
+ >{{ title }}
8
+ </h2>
8
9
  <div
9
10
  v-for="(group, index) in groups"
10
11
  :key="`filter-group-${index}`"
11
12
  class="s-filter-page__group"
13
+ :class="{'--short': group.name === 'tags'}"
12
14
  >
13
- <span
15
+ <div
14
16
  v-if="filters.length > 0"
15
- class="s-filter-page__group-title"
17
+ class="s-filter-page__group-header"
16
18
  >
17
- {{ group?.title }}
18
- </span>
19
+ <span class="s-filter-page__group-title">
20
+ {{ group?.title }}
21
+ </span>
22
+ <FSegmentedControl
23
+ v-if="group?.name === 'physical'"
24
+ :items="unitItems"
25
+ :model-value="units"
26
+ small
27
+ @update:model-value="units = $event"
28
+ />
29
+ </div>
30
+ <div
31
+ v-if="getGroupSliders(group?.name).length > 1"
32
+ class="s-filter-page__sliders"
33
+ :style="{ '--slider-count': getGroupSliders(group?.name).length }"
34
+ >
35
+ <SFilterSlider
36
+ v-for="(item, subIndex) in getGroupSliders(group?.name)"
37
+ :key="`slider-${subIndex}`"
38
+ :item="item"
39
+ :group-name="item.group.name"
40
+ :index="subIndex"
41
+ />
42
+ </div>
19
43
  <div class="s-filter-page__items">
20
44
  <template v-for="(item, subIndex) in filters">
21
45
  <SFilterSlider
22
- v-if="(item.kind === 'range') && (item.group.name === group?.name)"
23
- class="f-filters-main__slider"
46
+ v-if="(item.kind === 'range') && (item.group.name === group?.name) && !hiddenByUnits.has(item.name) && getGroupSliders(group?.name).length === 1"
47
+ class="s-filter-page__slider"
24
48
  :item="item"
25
49
  :group-name="item.group.name"
26
50
  :index="subIndex"
27
51
  />
28
52
  <SSelect
29
- v-if="(item.kind === 'select') && (item.group.name === group?.name)"
53
+ v-if="(item.kind === 'select') && (item.group.name === group?.name) && !hiddenByUnits.has(item.name)"
54
+ class="s-filter-page__select"
30
55
  :key="`model-filter-select-${subIndex}`"
31
56
  :name="item.name"
32
- :model-value="getSelectValue(item.name)"
57
+ :model-value="getValue(item.name)"
33
58
  :items="selectItems(item)"
34
59
  size="s"
35
60
  :active="isActiveSelect(item.name)"
36
61
  :label="item.title"
37
- @update:model-value="val => updateSelectFilter(item.name, val)"
62
+ @update:model-value="val => updateFilter(item.name, val)"
63
+ />
64
+ <FSegmentedControl
65
+ v-if="(item.kind === 'radio') && (item.group.name === group?.name)"
66
+ class="s-filter-page__radio"
67
+ :items="getRadioItems(item.options)"
68
+ :title="item.title"
69
+ :model-value="getValue(item.name)"
70
+ @update:model-value="val => updateFilter(item.name, val)"
71
+ />
72
+ <FFilterByChips
73
+ v-if="(item.kind === 'chips') && (item.group.name === group?.name)"
74
+ class="f-filters-main__chips"
75
+ :items="item.options"
76
+ :title="item.title"
77
+ :filter-name="item.name"
38
78
  />
39
79
  </template>
40
80
  </div>
@@ -44,7 +84,7 @@
44
84
  </template>
45
85
 
46
86
  <script setup lang="ts">
47
- import type { IModelFilter } from '../../types';
87
+ import type { IModelFilter, IModelFilterOptions } from '../../types';
48
88
 
49
89
  import { useRoute, useRouter } from 'vue-router';
50
90
 
@@ -57,6 +97,25 @@ const props = defineProps<{
57
97
  const route = useRoute();
58
98
  const router = useRouter();
59
99
 
100
+ const { t } = useI18n();
101
+
102
+ const { units, hiddenByUnits } = useUnits();
103
+
104
+ watch(units, () => {
105
+ const query = { ...route.query };
106
+ const unitFields = ['height_cm', 'height_in', 'weight_kg', 'weight_lb'];
107
+ for (const field of unitFields) {
108
+ delete query[`filter_${field}_from`];
109
+ delete query[`filter_${field}_to`];
110
+ }
111
+ router.replace({ query });
112
+ });
113
+
114
+ const unitItems = computed(() => [
115
+ { name: 'imperial', title: t('units_imperial'), quantity: 0 },
116
+ { name: 'metric', title: t('units_metric'), quantity: 0 },
117
+ ]);
118
+
60
119
  const groups = computed(() => {
61
120
  const uniqueNames = [...new Set(props.filters.map(item => item.group.title))];
62
121
  return uniqueNames.map(name => {
@@ -64,6 +123,12 @@ const groups = computed(() => {
64
123
  }).sort((a, b) => a?.order - b?.order)
65
124
  });
66
125
 
126
+ function getGroupSliders(groupName: string) {
127
+ return props.filters.filter(
128
+ (item) => item.kind === 'range' && item.group.name === groupName && !hiddenByUnits.value.has(item.name)
129
+ );
130
+ }
131
+
67
132
  function selectItems(item: IModelFilter) {
68
133
  return [
69
134
  ...item.options.map(item => ({
@@ -73,7 +138,7 @@ function selectItems(item: IModelFilter) {
73
138
  ]
74
139
  }
75
140
 
76
- function updateSelectFilter(name: string, val: any) {
141
+ function updateFilter(name: string, val: any) {
77
142
  const query = { ...route.query };
78
143
 
79
144
  if (!val || val === 'all') {
@@ -90,7 +155,7 @@ function isActiveSelect(name: string) {
90
155
  return Object.keys(route.query).some(key => key.startsWith(`filter_${baseName}`));
91
156
  }
92
157
 
93
- function getSelectValue(name: string) {
158
+ function getValue(name: string) {
94
159
  const value = route.query[ `filter_${name}` ];
95
160
  if (value) {
96
161
  return value;
@@ -102,10 +167,17 @@ function getSelectValue(name: string) {
102
167
  return null;
103
168
  }
104
169
 
105
- function getCount(item: IModelFilter) {
106
- const selectedValue = getSelectValue(item.name);
107
- const targetValue = selectedValue !== null && selectedValue !== undefined && selectedValue !== '' ? selectedValue : 'all';
108
- return props.filters.find(subitem => subitem.name === item.name)?.options.find(subitem => subitem.name === targetValue)?.quantity;
170
+ function getRadioItems(items: IModelFilterOptions[]) {
171
+ const filteredItems = items.filter((item) => item.name !== 'unspecified');
172
+ const order = ['all', 'yes'];
173
+ return filteredItems.sort((a, b) => {
174
+ const aIndex = order.indexOf(a.name);
175
+ const bIndex = order.indexOf(b.name);
176
+ if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
177
+ if (aIndex !== -1) return -1;
178
+ if (bIndex !== -1) return 1;
179
+ return 0;
180
+ });
109
181
  }
110
182
  </script>
111
183
 
@@ -7,11 +7,12 @@
7
7
  >
8
8
  <h3 class="s-info-grid__group-title">{{ group.title }}</h3>
9
9
  <div class="s-info-grid__group-items">
10
- <NuxtLink
11
- v-for="(item, index) in group.items"
10
+ <component
11
+ :is="item.isFilter ? NuxtLink : 'div'"
12
+ v-for="(item, index) in group.items.filter(i => !hiddenByUnits.has(i.name))"
12
13
  :key="`s-info-grid-item-${gIndex}-${index}`"
13
14
  class="s-info-grid__item"
14
- :to="generateLink(link(item, item.values[0]))"
15
+ :to="item.isFilter ? generateLink(link(item, item.values[0].name)) : undefined"
15
16
  >
16
17
  <SIcon class="s-info-grid__item-icon" name="tattoos" size="12" />
17
18
  <span class="s-info-grid__item-title">{{ item.title }}</span>
@@ -24,9 +25,9 @@
24
25
  {'--success': isSuccess(value)},
25
26
  {'--warning': isWarning(value)}
26
27
  ]"
27
- >{{ value }}{{ vIndex < item.values.length - 1 ? ', ' : '' }}</span>
28
+ >{{ value.title }}{{ vIndex < item.values.length - 1 ? ', ' : '' }}</span>
28
29
  </p>
29
- </NuxtLink>
30
+ </component>
30
31
  </div>
31
32
  </div>
32
33
  </div>
@@ -39,7 +40,9 @@ defineProps<{
39
40
  groups: IGroupedParameter[]
40
41
  }>();
41
42
 
42
- const {generateLink} = useGenerateLink();
43
+ const NuxtLink = resolveComponent('NuxtLink');
44
+ const { hiddenByUnits } = useUnits();
45
+ const { generateLink } = useGenerateLink();
43
46
 
44
47
  function link(item: IGroupedParameterItem, value: string) {
45
48
  const formattedValue = value.toLowerCase().replace(/\s+/g, '+');
@@ -8,6 +8,7 @@
8
8
  {'s-input--error': error},
9
9
  {'s-input--textarea': isTextArea},
10
10
  {'s-input--icon': isPassword || icon},
11
+ {'s-input--pre-icon': preIcon},
11
12
  `s-input--${size}`,
12
13
  ]"
13
14
  >
@@ -30,6 +31,12 @@
30
31
  :for="name"
31
32
  >{{ placeholder }}</label>
32
33
  <div class="s-input__wrapper">
34
+ <SIcon
35
+ v-if="preIcon"
36
+ class="s-input__pre-icon"
37
+ :name="preIcon"
38
+ :size="preIconSize"
39
+ />
33
40
  <textarea
34
41
  v-if="isTextArea"
35
42
  :id="name"
@@ -59,7 +66,6 @@
59
66
  autocapitalize="off"
60
67
  :placeholder="placeholder"
61
68
  spellcheck="false"
62
- v-bind="$attrs"
63
69
  @input="onInput"
64
70
  @focus="onFocus"
65
71
  @blur="onBlur"
@@ -110,6 +116,8 @@ const props = withDefaults(defineProps<{
110
116
  placeholder?: string
111
117
  icon?: string
112
118
  labelIcon?: string
119
+ preIcon?: string
120
+ preIconSize?: string
113
121
  }>(), {
114
122
  type: 'text',
115
123
  inputmode: 'text',
@@ -0,0 +1,51 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+ import { useUnits } from './use-units';
4
+
5
+ describe('useUnits', () => {
6
+ beforeEach(() => {
7
+ localStorage.clear();
8
+ useNuxtApp().payload.state = {};
9
+ });
10
+
11
+ it('по умолчанию metric', () => {
12
+ const { units } = useUnits();
13
+ expect(units.value).toBe('metric');
14
+ });
15
+
16
+ it('imperial для US', () => {
17
+ useState<string>('clientCountryCode', () => 'us');
18
+ const { units } = useUnits();
19
+ expect(units.value).toBe('imperial');
20
+ });
21
+
22
+ it('imperial для Liberia', () => {
23
+ useState<string>('clientCountryCode', () => 'lr');
24
+ const { units } = useUnits();
25
+ expect(units.value).toBe('imperial');
26
+ });
27
+
28
+ it('берёт сохранённое значение из localStorage', () => {
29
+ localStorage.setItem('units', 'imperial');
30
+ const { units } = useUnits();
31
+ expect(units.value).toBe('imperial');
32
+ });
33
+
34
+ it('localStorage приоритетнее country code', () => {
35
+ useState<string>('clientCountryCode', () => 'us');
36
+ localStorage.setItem('units', 'metric');
37
+ const { units } = useUnits();
38
+ expect(units.value).toBe('metric');
39
+ });
40
+
41
+ it('hiddenByUnits для metric — скрывает дюймы и фунты', () => {
42
+ const { hiddenByUnits } = useUnits();
43
+ expect(hiddenByUnits.value).toEqual(new Set(['height_in', 'weight_lb']));
44
+ });
45
+
46
+ it('hiddenByUnits для imperial — скрывает см и кг', () => {
47
+ localStorage.setItem('units', 'imperial');
48
+ const { hiddenByUnits } = useUnits();
49
+ expect(hiddenByUnits.value).toEqual(new Set(['height_cm', 'weight_kg']));
50
+ });
51
+ });
@@ -0,0 +1,21 @@
1
+ const imperialCountries = new Set(['us', 'lr', 'mm']);
2
+
3
+ export function useUnits() {
4
+ const clientCountryCode = useState<string>('clientCountryCode', () => '');
5
+ const savedUnits = import.meta.client ? localStorage.getItem('units') : null;
6
+ const defaultUnits = imperialCountries.has(clientCountryCode.value) ? 'imperial' : 'metric';
7
+ const units = useState('units', () => savedUnits || defaultUnits);
8
+
9
+ const hiddenByUnits = computed(() => {
10
+ if (units.value === 'metric') {
11
+ return new Set(['height_in', 'weight_lb']);
12
+ }
13
+ return new Set(['height_cm', 'weight_kg']);
14
+ });
15
+
16
+ watch(units, (val) => {
17
+ localStorage.setItem('units', val);
18
+ });
19
+
20
+ return { units, hiddenByUnits };
21
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.705",
4
+ "version": "0.0.707",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
8
8
  "prepublishOnly": "npm install && npx nuxi prepare",
9
9
  "patch": "npm version patch",
10
- "eslint fix": "npx eslint . --ext .ts,.vue,.js --fix"
10
+ "eslint fix": "npx eslint . --ext .ts,.vue,.js --fix",
11
+ "test": "NODE_OPTIONS='--no-warnings' vitest"
11
12
  },
12
13
  "exports": {
13
14
  ".": {
@@ -40,11 +41,15 @@
40
41
  "devDependencies": {
41
42
  "@nuxt/eslint": "latest",
42
43
  "@nuxt/icon": "1.15.0",
44
+ "@nuxt/test-utils": "^3.23.0",
43
45
  "@nuxtjs/i18n": "9.5.6",
44
46
  "@types/node": "^20.19.19",
47
+ "@vue/test-utils": "^2.4.6",
45
48
  "eslint": "^9.37.0",
49
+ "happy-dom": "^18.0.1",
46
50
  "nuxt": "3.17.6",
47
51
  "typescript": "^5.9.3",
52
+ "vitest": "^3.2.4",
48
53
  "vue": "3.5.17"
49
54
  },
50
55
  "dependencies": {
@@ -10,6 +10,6 @@ export interface IParameterModel {
10
10
  title: string
11
11
  values: IParameterModelValue[]
12
12
  group: IModelGroup
13
- isFilter: boolean
13
+ is_filter: boolean
14
14
  kind: 'range' | 'select' | 'radio' | 'chips'
15
15
  }