adata-ui 2.1.41-beta.5 → 2.1.41-beta.7

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,237 @@
1
+ <script generic="T" lang="ts" setup>
2
+ import type { CascadeKeys } from '../types'
3
+ import { isDisabled, isLeaf, nodeKey, nodeLabel } from '../cascade-utils'
4
+
5
+ /**
6
+ * Одна колонка каскадного селекта. Компонент намеренно НЕ рекурсивен: панели
7
+ * приходят плоским массивом из ACascadeSelectV2, поэтому здесь нет самоссылки
8
+ * (и нет TS7022, на который натыкается рекурсивный ATreeSelectNode).
9
+ */
10
+ defineOptions({ name: 'CascadePanel' })
11
+
12
+ const props = withDefaults(defineProps<{
13
+ options: T[]
14
+ keys: CascadeKeys
15
+ level: number
16
+ /** Ключ раскрытого из этой колонки родителя. */
17
+ activeKey?: unknown
18
+ /** Ключ выбранного листа. */
19
+ selectedKey?: unknown
20
+ /** Ключ строки с roving-фокусом. */
21
+ focusedKey?: unknown
22
+ title?: string
23
+ showBack?: boolean
24
+ backLabel?: string
25
+ width?: string
26
+ minWidth?: string
27
+ maxHeight?: string
28
+ emptyLabel?: string
29
+ /**
30
+ * Колонки растут влево. Зеркалятся только строки-ветки: их шеврон разворачивается
31
+ * и уезжает влево, к своей будущей колонке. Листья (и заголовок, и «Назад») всегда
32
+ * читаются слева направо — у них никуда не ведёт, зеркалить нечего.
33
+ */
34
+ mirrored?: boolean
35
+ /** Без карточной обвязки (рамка/тень/фон) — колонка внутри дровера. */
36
+ bare?: boolean
37
+ }>(), {
38
+ activeKey: undefined,
39
+ selectedKey: undefined,
40
+ focusedKey: undefined,
41
+ title: '',
42
+ showBack: false,
43
+ backLabel: 'Назад',
44
+ width: undefined,
45
+ minWidth: undefined,
46
+ maxHeight: undefined,
47
+ emptyLabel: 'Ничего не найдено',
48
+ mirrored: false,
49
+ bare: false,
50
+ })
51
+
52
+ const emits = defineEmits<{
53
+ /** Клик/Enter по строке — родитель решает, раскрыть ветку или выбрать лист. */
54
+ activate: [node: T]
55
+ hover: [node: T]
56
+ back: []
57
+ }>()
58
+
59
+ defineSlots<{
60
+ option?: (props: { option: T, level: number, selected: boolean, hasChildren: boolean, active: boolean }) => unknown
61
+ empty?: () => unknown
62
+ }>()
63
+
64
+ const root = ref<HTMLElement | null>(null)
65
+ const listEl = ref<HTMLElement | null>(null)
66
+
67
+ const rootClass = computed(() => {
68
+ if (props.bare) return 'w-full text-start'
69
+ const card = 'rounded-lg border border-default bg-main p-1.5 text-start shadow-lg shadow-surface-dark/10 dark:shadow-surface-dark/30'
70
+ /* min-w-45 — фолбэк на первый кадр, пока ширина триггера ещё не измерена. */
71
+ return props.width ? card : `${card} min-w-45`
72
+ })
73
+
74
+ const rootStyle = computed(() => ({
75
+ width: props.bare ? undefined : props.width,
76
+ minWidth: props.bare ? undefined : props.minWidth,
77
+ maxHeight: props.maxHeight,
78
+ }))
79
+
80
+ function keyOf(node: T) {
81
+ return nodeKey(node, props.keys)
82
+ }
83
+
84
+ function hasChildren(node: T) {
85
+ return !isLeaf(node, props.keys)
86
+ }
87
+
88
+ function optionId(node: T) {
89
+ return `cascade-${props.level}-${String(keyOf(node))}`
90
+ }
91
+
92
+ /** Ставит фокус на строку по ключу — используется клавиатурной навигацией родителя. */
93
+ function focusItem(key: unknown) {
94
+ const el = listEl.value?.querySelector<HTMLElement>(`[data-cascade-key="${CSS.escape(String(key))}"]`)
95
+ if (!el) return
96
+ el.focus()
97
+ el.scrollIntoView({ block: 'nearest' })
98
+ }
99
+
100
+ defineExpose({ root, focusItem })
101
+ </script>
102
+
103
+ <template>
104
+ <div
105
+ ref="root"
106
+ class="cascade-panel flex min-w-0 flex-col"
107
+ :class="rootClass"
108
+ :style="rootStyle"
109
+ >
110
+ <button
111
+ v-if="showBack"
112
+ type="button"
113
+ class="mb-0.5 flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-body-sm text-secondary transition-colors hover:bg-action-secondary-hover hover:text-primary"
114
+ @click="emits('back')"
115
+ >
116
+ <!-- Шеврон-левый, развёрнутый на 180°, — точное зеркало правого: пути SVG симметричны. -->
117
+ <a-icon-chevron-left class="size-3.5 shrink-0" :class="{ 'rotate-180': mirrored }" />
118
+ <span>{{ backLabel }}</span>
119
+ </button>
120
+
121
+ <p
122
+ v-else-if="title"
123
+ class="shrink-0 px-3 py-1.5 text-body-sm text-secondary"
124
+ >
125
+ {{ title }}
126
+ </p>
127
+
128
+ <ul
129
+ ref="listEl"
130
+ role="listbox"
131
+ class="cascade-panel__list flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto"
132
+ >
133
+ <li v-if="!options.length" class="px-3 py-2 text-body-sm text-secondary">
134
+ <slot name="empty">
135
+ {{ emptyLabel }}
136
+ </slot>
137
+ </li>
138
+
139
+ <li
140
+ v-for="node in options"
141
+ :key="String(keyOf(node))"
142
+ role="none"
143
+ >
144
+ <button
145
+ :id="optionId(node)"
146
+ type="button"
147
+ role="option"
148
+ :data-cascade-key="String(keyOf(node))"
149
+ :aria-selected="keyOf(node) === selectedKey"
150
+ :aria-haspopup="hasChildren(node) ? 'listbox' : undefined"
151
+ :aria-expanded="hasChildren(node) ? keyOf(node) === activeKey : undefined"
152
+ :disabled="isDisabled(node, keys)"
153
+ :tabindex="keyOf(node) === focusedKey ? 0 : -1"
154
+ :class="{
155
+ 'cascade-panel__option--active': keyOf(node) === activeKey,
156
+ 'cascade-panel__option--selected': keyOf(node) === selectedKey,
157
+ 'cascade-panel__option--branch': hasChildren(node),
158
+ 'hover:bg-action-secondary-hover': !hasChildren(node),
159
+ 'flex-row-reverse text-end': mirrored && hasChildren(node),
160
+ }"
161
+ class="cascade-panel__option flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-body-md transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-brand-500/30 focus-visible:outline-hidden"
162
+ @click="emits('activate', node)"
163
+ @pointerenter="emits('hover', node)"
164
+ >
165
+ <span class="truncate">
166
+ <slot
167
+ name="option"
168
+ :option="node"
169
+ :level="level"
170
+ :selected="keyOf(node) === selectedKey"
171
+ :has-children="hasChildren(node)"
172
+ :active="keyOf(node) === activeKey"
173
+ >{{ nodeLabel(node, keys) }}</slot>
174
+ </span>
175
+
176
+ <a-icon-chevron-right
177
+ v-if="hasChildren(node)"
178
+ class="size-4 shrink-0"
179
+ :class="[
180
+ keyOf(node) === activeKey ? 'text-icon-brand' : 'text-icon-secondary',
181
+ { 'rotate-180': mirrored },
182
+ ]"
183
+ />
184
+ <a-icon-check
185
+ v-else-if="keyOf(node) === selectedKey"
186
+ class="size-4 shrink-0 text-brand"
187
+ />
188
+ </button>
189
+ </li>
190
+ </ul>
191
+ </div>
192
+ </template>
193
+
194
+ <style scoped>
195
+ .cascade-panel__list {
196
+ scrollbar-width: none;
197
+ -ms-overflow-style: none;
198
+ }
199
+
200
+ .cascade-panel__list::-webkit-scrollbar {
201
+ width: 0;
202
+ height: 0;
203
+ display: none;
204
+ }
205
+
206
+ .cascade-panel__option {
207
+ cursor: pointer;
208
+ }
209
+
210
+ .cascade-panel__option:disabled {
211
+ cursor: not-allowed;
212
+ color: var(--text-color-disabled);
213
+ pointer-events: none;
214
+ }
215
+
216
+ /* Раскрытая ветка и выбранный лист — одна и та же brand-подсветка по макету ДС. */
217
+ .cascade-panel__option--active,
218
+ .cascade-panel__option--selected {
219
+ background-color: var(--background-color-brand-subtle);
220
+ color: var(--text-color-brand);
221
+ font-weight: 500;
222
+ }
223
+
224
+ /*
225
+ * Под курсором brand-подсветка обязана остаться brand-подсветкой:
226
+ * • строка с детьми берёт этот фон сразу — иначе на hoverOpenDelay виден серый ховер,
227
+ * который через 120 мс сменяется синим (то самое мигание);
228
+ * • у раскрытой ветки и выбранного листа серый ховер имеет ту же специфичность (0,2,0),
229
+ * так что без явного правила исход зависел бы от порядка стилей в бандле.
230
+ * Цвет текста и вес не трогаем: раскрытая ветка должна оставаться заметнее наведённой.
231
+ */
232
+ .cascade-panel__option--branch:hover,
233
+ .cascade-panel__option--active:hover,
234
+ .cascade-panel__option--selected:hover {
235
+ background-color: var(--background-color-brand-subtle);
236
+ }
237
+ </style>
@@ -0,0 +1,167 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Общий «хром» триггера селектов нового ДС: рамка, плавающий лейбл, крестик очистки
4
+ * и шеврон. Область значения приходит слотом, поэтому одна и та же кнопка обслуживает
5
+ * и одиночный текст (ASelectV2, ACascadeSelectV2), и строку чипов (ASelectV2 multiple).
6
+ *
7
+ * `el` / `valueEl` отдаются наружу через defineExpose: первый нужен как reference-элемент
8
+ * для floating-ui, второй — как контейнер измерения в useChipOverflow.
9
+ */
10
+ defineOptions({ name: 'SelectTrigger' })
11
+
12
+ const props = withDefaults(defineProps<{
13
+ label?: string
14
+ required?: boolean
15
+ size?: 'sm' | 'md'
16
+ disabled?: boolean
17
+ error?: string | string[]
18
+ isOpen?: boolean
19
+ startIcon?: object
20
+ buttonBg?: string
21
+ /** Резервирует место под крестик (pr-16 вместо pr-9) — совпадает с прежним `clearable`. */
22
+ clearable?: boolean
23
+ /** Крестик показывается только когда есть что чистить. */
24
+ showClear?: boolean
25
+ /** Лейбл ведёт себя как floating label (иначе за плейсхолдер отвечает слот). */
26
+ floatingLabel?: boolean
27
+ /** Значение выбрано — лейбл поднят, иначе стоит на месте плейсхолдера. */
28
+ hasValue?: boolean
29
+ }>(), {
30
+ label: '',
31
+ required: false,
32
+ size: 'sm',
33
+ disabled: false,
34
+ error: undefined,
35
+ isOpen: false,
36
+ startIcon: undefined,
37
+ buttonBg: '',
38
+ clearable: true,
39
+ showClear: false,
40
+ floatingLabel: false,
41
+ hasValue: false,
42
+ })
43
+
44
+ const emits = defineEmits<{
45
+ toggle: []
46
+ clear: []
47
+ }>()
48
+
49
+ const el = ref<HTMLButtonElement | null>(null)
50
+ const valueEl = ref<HTMLElement | null>(null)
51
+
52
+ /* Без floating label кнопка центрирует содержимое, с ним — прижимает значение к низу. */
53
+ const layoutClass = computed(() => {
54
+ if (!props.floatingLabel) {
55
+ return props.size === 'md' ? 'h-10 py-1.5' : 'h-9 py-1.5'
56
+ }
57
+ return props.size === 'md' ? 'h-10 pt-3 pb-0.5' : 'h-9 pt-3.5 pb-0.5'
58
+ })
59
+
60
+ defineExpose({ el, valueEl })
61
+ </script>
62
+
63
+ <template>
64
+ <button
65
+ ref="el"
66
+ :disabled="disabled"
67
+ aria-haspopup="listbox"
68
+ :aria-expanded="isOpen"
69
+ :class="[
70
+ layoutClass,
71
+ clearable ? 'pr-16' : 'pr-9',
72
+ { 'select-trigger--error': error },
73
+ disabled
74
+ ? 'border-default bg-badge/70'
75
+ : (buttonBg || 'border-default bg-main'),
76
+ ]"
77
+ class="select-trigger relative flex w-full items-center gap-2 rounded-10 border border-solid pl-4 text-primary transition-colors duration-200 hover:border-brand focus-visible:border-focus focus-visible:ring-2 focus-visible:ring-brand-500/20 focus-visible:outline-hidden"
78
+ @click="emits('toggle')"
79
+ >
80
+ <span
81
+ v-if="floatingLabel && label"
82
+ :data-size="size"
83
+ class="select-trigger__label pointer-events-none absolute top-1.5 left-4 text-[10px] leading-none text-secondary"
84
+ :class="hasValue ? 'select-trigger__label--floated' : 'select-trigger__label--placeholder'"
85
+ >
86
+ {{ label }}<span v-if="required" class="text-danger">&nbsp;*</span>
87
+ </span>
88
+
89
+ <component
90
+ :is="startIcon"
91
+ v-if="startIcon"
92
+ class="shrink-0"
93
+ />
94
+
95
+ <span ref="valueEl" class="relative w-full min-w-0 overflow-hidden text-start leading-tight">
96
+ <slot />
97
+ </span>
98
+
99
+ <!-- span, а не вложенная <button>: вложенные кнопки ломают SSR-разметку при гидрации -->
100
+ <span
101
+ v-if="showClear"
102
+ role="button"
103
+ tabindex="-1"
104
+ aria-label="Очистить"
105
+ class="select-trigger__clear absolute top-1/2 right-9 flex -translate-y-1/2 items-center justify-center rounded-full p-0.5 transition-colors hover:bg-badge"
106
+ @click.stop="emits('clear')"
107
+ @keydown.enter.stop.prevent="emits('clear')"
108
+ @keydown.space.stop.prevent="emits('clear')"
109
+ >
110
+ <a-icon-x-mark class="m-0! size-3.5" />
111
+ </span>
112
+
113
+ <span
114
+ :class="{ 'rotate-180': isOpen }"
115
+ class="absolute top-1/2 right-3 -translate-y-1/2 transition-transform duration-200"
116
+ >
117
+ <a-icon-chevron-down class="m-0! size-4 text-icon-disabled" />
118
+ </span>
119
+ </button>
120
+ </template>
121
+
122
+ <style scoped>
123
+ .select-trigger {
124
+ cursor: pointer;
125
+ }
126
+
127
+ .select-trigger:disabled {
128
+ pointer-events: none;
129
+ cursor: not-allowed;
130
+ opacity: 0.7;
131
+ }
132
+
133
+ .select-trigger--error {
134
+ border-color: var(--border-color-danger);
135
+ }
136
+
137
+ .select-trigger__clear {
138
+ cursor: pointer;
139
+ }
140
+
141
+ .select-trigger__label {
142
+ transform-origin: 0 0;
143
+ transform: translateY(0) scale(1);
144
+ transition:
145
+ transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
146
+ color 200ms ease-out;
147
+ will-change: transform;
148
+ }
149
+
150
+ .select-trigger__label--floated {
151
+ transform: translateY(0) scale(1);
152
+ }
153
+
154
+ .select-trigger__label--placeholder {
155
+ transform: translateY(7px) scale(1.4);
156
+ }
157
+
158
+ .select-trigger__label--placeholder[data-size="sm"] {
159
+ transform: translateY(5px) scale(1.4);
160
+ }
161
+
162
+ @media (prefers-reduced-motion: reduce) {
163
+ .select-trigger__label {
164
+ transition: none;
165
+ }
166
+ }
167
+ </style>
@@ -14,7 +14,10 @@ const props = withDefaults(defineProps<{
14
14
  swipeToClose?: boolean
15
15
  swipeThreshold?: number
16
16
  showDragHandle?: boolean
17
+ /** Фиксированная общая высота листа («70dvh»). По умолчанию высота по контенту. */
18
+ height?: string
17
19
  maxHeight?: string
20
+ zIndex?: number | string
18
21
  }>(), {
19
22
  closeOnOverlay: true,
20
23
  closeOnEsc: true,
@@ -26,7 +29,9 @@ const props = withDefaults(defineProps<{
26
29
  swipeToClose: true,
27
30
  swipeThreshold: 65,
28
31
  showDragHandle: true,
32
+ height: undefined,
29
33
  maxHeight: '100dvh',
34
+ zIndex: 10000,
30
35
  })
31
36
 
32
37
  const emit = defineEmits<{
@@ -54,12 +59,13 @@ const blurClass = computed(() => {
54
59
  })
55
60
 
56
61
  const overlayClass = computed(() => {
57
- const base = ['drawer-v2-overlay fixed inset-0 z-[10000] flex items-end justify-stretch bg-overlay']
62
+ const base = ['drawer-v2-overlay fixed inset-0 flex items-end justify-stretch bg-overlay']
58
63
  if (blurClass.value) base.push(blurClass.value)
59
64
  return base.join(' ')
60
65
  })
61
66
 
62
- const contentStyle = computed(() => ({ maxHeight: props.maxHeight }))
67
+ const overlayStyle = computed(() => ({ zIndex: props.zIndex }))
68
+ const contentStyle = computed(() => ({ height: props.height, maxHeight: props.maxHeight }))
63
69
 
64
70
  const overlayTransitionName = computed(() => {
65
71
  if (swipeClosing.value) return ''
@@ -226,6 +232,7 @@ function onTouchEnd() {
226
232
  v-if="open"
227
233
  ref="overlayRef"
228
234
  :class="overlayClass"
235
+ :style="overlayStyle"
229
236
  @mousedown="onOverlayClick"
230
237
  >
231
238
  <transition :name="contentTransitionName" appear>
@@ -7,6 +7,7 @@ const props = withDefaults(defineProps<{
7
7
  title?: string
8
8
  size?: ModalSize
9
9
  width?: string
10
+ rounded?: string
10
11
  closeOnOverlay?: boolean
11
12
  closeOnEsc?: boolean
12
13
  lockScroll?: boolean
@@ -0,0 +1,6 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-left-icon lucide-arrow-down-left">
3
+ <path d="M17 7 7 17"/>
4
+ <path d="M17 17H7V7"/>
5
+ </svg>
6
+ </template>
@@ -0,0 +1,6 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right">
3
+ <path d="M7 7h10v10"/>
4
+ <path d="M7 17 17 7"/>
5
+ </svg>
6
+ </template>
@@ -0,0 +1,6 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-crown-icon lucide-crown">
3
+ <path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/>
4
+ <path d="M5 21h14"/>
5
+ </svg>
6
+ </template>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw-icon lucide-refresh-cw">
3
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
4
+ <path d="M21 3v5h-5"/>
5
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
6
+ <path d="M8 16H3v5"/>
7
+ </svg>
8
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-plus-icon lucide-user-plus"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M19 8v6"/><path d="M22 11h-6"/><circle cx="9" cy="7" r="4"/></svg>
3
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <svg
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ width="24"
8
+ height="24"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ stroke-width="2"
13
+ stroke-linecap="round"
14
+ stroke-linejoin="round"
15
+ class="lucide lucide-info-icon lucide-info"
16
+ >
17
+ <circle cx="12" cy="12" r="10"
18
+ /><path d="M12 16v-4" />
19
+ <path d="M12 8h.01"/>
20
+ </svg>
21
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <svg
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ width="24"
8
+ height="24"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ stroke-width="2"
13
+ stroke-linecap="round"
14
+ stroke-linejoin="round"
15
+ class="lucide lucide-waypoints-icon lucide-waypoints"
16
+ >
17
+ <path d="m10.586 5.414-5.172 5.172" />
18
+ <path d="m18.586 13.414-5.172 5.172" />
19
+ <path d="M6 12h12" />
20
+ <circle cx="12" cy="20" r="2" />
21
+ <circle cx="12" cy="4" r="2" />
22
+ <circle cx="20" cy="12" r="2" />
23
+ <circle cx="4" cy="12" r="2" />
24
+ </svg>
25
+ </template>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "adata-ui",
3
3
  "type": "module",
4
- "version": "2.1.41-beta.5",
4
+ "version": "2.1.41-beta.7",
5
5
  "main": "./nuxt.config.ts",
6
6
  "scripts": {
7
7
  "dev": "nuxi dev .playground",
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Нормализует размер из props в CSS-значение: число трактуем как пиксели,
3
+ * строку отдаём как есть (`70dvh`, `min(320px, 50vw)`), пустое — как «не задано».
4
+ *
5
+ * Общая для селектов и дроверов: и `ASelectV2`, и `ACascadeSelectV2` принимают
6
+ * размеры выпадашки в виде `number | string`.
7
+ */
8
+ export function toCssSize(value?: number | string | null): string | undefined {
9
+ if (value == null || value === '') return undefined
10
+ return typeof value === 'number' ? `${value}px` : value
11
+ }