hallo-kit 0.3.0 → 0.3.1
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 +2 -0
- package/fonts/Manrope/OFL.txt +93 -93
- package/fonts/index.ts +30 -30
- package/package.json +4 -1
- package/src/hooks/useCountdown.ts +44 -44
- package/src/hooks/usePlatform.ts +102 -102
- package/src/ui/Icon/Icon.types.ts +60 -60
- package/src/ui/Icon/data/ui.data.tsx +780 -780
- package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -63
- package/src/ui/data-list/data-list.tsx +331 -331
- package/src/ui/modal/modal.tsx +128 -128
- package/src/ui/select/select.tsx +215 -215
- package/src/ui/skeleton/skeleton.tsx +44 -44
- package/src/ui/tabs/tabs.tsx +190 -190
- package/src/utils/copy-to-clipboard.ts +12 -12
package/src/ui/select/select.tsx
CHANGED
|
@@ -1,215 +1,215 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import * as React from 'react'
|
|
4
|
-
|
|
5
|
-
import * as SelectPrimitive from '@radix-ui/react-select'
|
|
6
|
-
|
|
7
|
-
import { Icon } from '../Icon'
|
|
8
|
-
import { cn } from '../../lib/utils'
|
|
9
|
-
|
|
10
|
-
export interface SelectOption {
|
|
11
|
-
value: string
|
|
12
|
-
label: string
|
|
13
|
-
/** Контент в начале пункта (флаг/иконка/что угодно). Показывается и в списке, и в значении триггера. */
|
|
14
|
-
startContent?: React.ReactNode
|
|
15
|
-
disabled?: boolean
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// group — чтобы шеврон в триггере доворачивался по data-state. Размер — через
|
|
19
|
-
// responsiveSize (CSS-адаптив без флэша), а не cva. Иконки — в em (масштаб за font-size).
|
|
20
|
-
const SELECT_TRIGGER_BASE =
|
|
21
|
-
'group inline-flex w-full cursor-pointer items-center bg-card text-foreground shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-disabled:pointer-events-none data-disabled:opacity-50 data-placeholder:text-muted-foreground rounded-full'
|
|
22
|
-
const SELECT_TRIGGER_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
23
|
-
sm: 'gap-fl-10/12 min-h-fl-28/32 px-fl-10/12 py-fl-2/4 text-button-s',
|
|
24
|
-
md: 'gap-fl-14/16 min-h-fl-36/40 px-fl-14/16 py-fl-6/8 text-button-m',
|
|
25
|
-
lg: 'gap-fl-18/20 min-h-fl-44/48 px-fl-18/20 py-fl-10/12 text-button-l',
|
|
26
|
-
}
|
|
27
|
-
// Пункт = носитель подсветки; текст инсетит px ВНУТРИ пункта (выравнивание с триггером).
|
|
28
|
-
const SELECT_ITEM_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
29
|
-
sm: 'rounded-sm px-fl-6/8 py-fl-6/8 text-button-s',
|
|
30
|
-
md: 'rounded-md px-fl-10/12 py-fl-8/10 text-button-m',
|
|
31
|
-
lg: 'rounded-lg px-fl-14/16 py-fl-10/12 text-button-l',
|
|
32
|
-
}
|
|
33
|
-
// Боковой инсет разделителя = px пункта → линия выровнена с текстом и уже подсветки.
|
|
34
|
-
const SELECT_DIVIDER_INSET: Record<'sm' | 'md' | 'lg', string> = {
|
|
35
|
-
sm: 'mx-2',
|
|
36
|
-
md: 'mx-3',
|
|
37
|
-
lg: 'mx-4',
|
|
38
|
-
}
|
|
39
|
-
const SELECT_ROUNDED_CONTENT: Record<'sm' | 'md' | 'lg', string> = {
|
|
40
|
-
sm: 'rounded-sm',
|
|
41
|
-
md: 'rounded-md',
|
|
42
|
-
lg: 'rounded-lg',
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface SelectProps {
|
|
46
|
-
options: SelectOption[]
|
|
47
|
-
/** Размер: `sm | md | lg`. По умолчанию md. Каждый размер сам fluid-клампится. */
|
|
48
|
-
size?: 'sm' | 'md' | 'lg'
|
|
49
|
-
value?: string
|
|
50
|
-
defaultValue?: string
|
|
51
|
-
onValueChange?: (value: string) => void
|
|
52
|
-
placeholder?: string
|
|
53
|
-
disabled?: boolean
|
|
54
|
-
/** Фиксированный контент в начале триггера (помимо иконки выбранного пункта): лейбл, иконка и т.п. */
|
|
55
|
-
startContent?: React.ReactNode
|
|
56
|
-
/** Класс на триггер. */
|
|
57
|
-
className?: string
|
|
58
|
-
/** Класс на выпадающую панель. */
|
|
59
|
-
contentClassName?: string
|
|
60
|
-
/**
|
|
61
|
-
* В триггере показывать только `startContent` выбранного пункта (напр. флаг),
|
|
62
|
-
* без текста; в списке пункты остаются с текстом. Для компактных переключателей
|
|
63
|
-
* (язык). По умолчанию false.
|
|
64
|
-
*/
|
|
65
|
-
compactValue?: boolean
|
|
66
|
-
/**
|
|
67
|
-
* Доступное имя контрола. Заведено явно: у компонента нет rest-спреда, а TypeScript
|
|
68
|
-
* не проверяет лишние JSX-атрибуты с дефисом — `aria-label` на <Select> молча
|
|
69
|
-
* компилировался и никуда не попадал, оставляя контрол без доступного имени.
|
|
70
|
-
*/
|
|
71
|
-
'aria-label'?: string
|
|
72
|
-
'aria-labelledby'?: string
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Флаг/иконка + подпись. Кладём ВНУТРЬ ItemText, чтобы триггер (Select.Value)
|
|
76
|
-
// отрисовал ровно то же — иконку слева и текст, как в макете.
|
|
77
|
-
function OptionContent({ option }: { option: SelectOption }) {
|
|
78
|
-
return (
|
|
79
|
-
// leading-normal: токены text-button-* имеют line-height:1, из-за чего нижние
|
|
80
|
-
// выносные элементы букв (g/y/р) обрезаются строчным боксом. Локально возвращаем запас.
|
|
81
|
-
<span className='flex items-center gap-2.5 leading-normal'>
|
|
82
|
-
{option.startContent ? (
|
|
83
|
-
<span className='inline-flex shrink-0 items-center'>
|
|
84
|
-
{option.startContent}
|
|
85
|
-
</span>
|
|
86
|
-
) : null}
|
|
87
|
-
{option.label}
|
|
88
|
-
</span>
|
|
89
|
-
)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function Select({
|
|
93
|
-
options,
|
|
94
|
-
value,
|
|
95
|
-
defaultValue,
|
|
96
|
-
onValueChange,
|
|
97
|
-
placeholder,
|
|
98
|
-
disabled,
|
|
99
|
-
startContent,
|
|
100
|
-
size,
|
|
101
|
-
className,
|
|
102
|
-
contentClassName,
|
|
103
|
-
compactValue = false,
|
|
104
|
-
'aria-label': ariaLabel,
|
|
105
|
-
'aria-labelledby': ariaLabelledBy,
|
|
106
|
-
}: SelectProps) {
|
|
107
|
-
// Только для контролируемого значения: в неконтролируемом режиме выбранный пункт
|
|
108
|
-
// знает лишь Radix, и подменять его нечем.
|
|
109
|
-
const selectedOption = value != null ? options.find((o) => o.value === value) : undefined
|
|
110
|
-
|
|
111
|
-
const triggerSize = SELECT_TRIGGER_SIZE[size ?? 'md']
|
|
112
|
-
const itemSize = SELECT_ITEM_SIZE[size ?? 'md']
|
|
113
|
-
const dividerInset = SELECT_DIVIDER_INSET[size ?? 'md']
|
|
114
|
-
const roundedContent = SELECT_ROUNDED_CONTENT[size ?? 'md']
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
<SelectPrimitive.Root
|
|
118
|
-
value={value}
|
|
119
|
-
defaultValue={defaultValue}
|
|
120
|
-
onValueChange={onValueChange}
|
|
121
|
-
disabled={disabled}
|
|
122
|
-
>
|
|
123
|
-
<SelectPrimitive.Trigger
|
|
124
|
-
aria-label={ariaLabel}
|
|
125
|
-
aria-labelledby={ariaLabelledBy}
|
|
126
|
-
className={cn(SELECT_TRIGGER_BASE, triggerSize, className)}
|
|
127
|
-
>
|
|
128
|
-
{startContent ? (
|
|
129
|
-
<span className='flex shrink-0 items-center text-muted-foreground'>
|
|
130
|
-
{startContent}
|
|
131
|
-
</span>
|
|
132
|
-
) : null}
|
|
133
|
-
{/* Значение рендерим САМИ, а не отдаём на откуп SelectPrimitive.Value.
|
|
134
|
-
Radix берёт текст из смонтированных Item'ов, а они живут в портале и
|
|
135
|
-
на сервере не рендерятся — из-за чего триггер приезжает ПУСТЫМ и
|
|
136
|
-
остаётся таким до гидрации. На медленной сети это сотни миллисекунд
|
|
137
|
-
пустого поля там, где уже должно стоять выбранное значение.
|
|
138
|
-
Когда значение контролируемое, лейбл известен сразу — печатаем его. */}
|
|
139
|
-
<SelectPrimitive.Value placeholder={placeholder}>
|
|
140
|
-
{selectedOption ? (
|
|
141
|
-
compactValue ? (
|
|
142
|
-
selectedOption.startContent
|
|
143
|
-
) : (
|
|
144
|
-
<OptionContent option={selectedOption} />
|
|
145
|
-
)
|
|
146
|
-
) : undefined}
|
|
147
|
-
</SelectPrimitive.Value>
|
|
148
|
-
<SelectPrimitive.Icon asChild>
|
|
149
|
-
<span className='ml-auto flex shrink-0 items-center text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180'>
|
|
150
|
-
<Icon type='chevron_down' isCurrentColor size='0.72em' />
|
|
151
|
-
</span>
|
|
152
|
-
</SelectPrimitive.Icon>
|
|
153
|
-
</SelectPrimitive.Trigger>
|
|
154
|
-
|
|
155
|
-
<SelectPrimitive.Portal>
|
|
156
|
-
<SelectPrimitive.Content
|
|
157
|
-
position='popper'
|
|
158
|
-
sideOffset={8}
|
|
159
|
-
className={cn(
|
|
160
|
-
'z-50 max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] overflow-hidden border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95',
|
|
161
|
-
contentClassName,
|
|
162
|
-
roundedContent,
|
|
163
|
-
)}
|
|
164
|
-
>
|
|
165
|
-
<SelectPrimitive.Viewport className='flex flex-col'>
|
|
166
|
-
{options.map((option, index) => (
|
|
167
|
-
<React.Fragment key={option.value}>
|
|
168
|
-
<SelectPrimitive.Item
|
|
169
|
-
value={option.value}
|
|
170
|
-
disabled={option.disabled}
|
|
171
|
-
textValue={option.label}
|
|
172
|
-
className={cn(
|
|
173
|
-
'flex w-full cursor-pointer items-center gap-2.5 text-foreground outline-none select-none data-highlighted:bg-secondary data-disabled:pointer-events-none data-disabled:opacity-50',
|
|
174
|
-
itemSize,
|
|
175
|
-
)}
|
|
176
|
-
>
|
|
177
|
-
<SelectPrimitive.ItemText>
|
|
178
|
-
{compactValue && option.startContent ? (
|
|
179
|
-
<span className='inline-flex shrink-0 items-center'>
|
|
180
|
-
{option.startContent}
|
|
181
|
-
</span>
|
|
182
|
-
) : (
|
|
183
|
-
<OptionContent option={option} />
|
|
184
|
-
)}
|
|
185
|
-
</SelectPrimitive.ItemText>
|
|
186
|
-
{/* compactValue: название живёт ВНЕ ItemText → видно в списке, но НЕ в триггере.
|
|
187
|
-
leading-normal — иначе truncate (overflow-hidden) при line-height:1 режет
|
|
188
|
-
нижние выносы букв («g» в English). */}
|
|
189
|
-
{compactValue ? (
|
|
190
|
-
<span className='truncate leading-normal'>
|
|
191
|
-
{option.label}
|
|
192
|
-
</span>
|
|
193
|
-
) : null}
|
|
194
|
-
{/* галочка прижата вправо; рендерится только у выбранного */}
|
|
195
|
-
<SelectPrimitive.ItemIndicator className='ml-auto flex items-center text-primary'>
|
|
196
|
-
<Icon type='check' isCurrentColor size='0.85em' />
|
|
197
|
-
</SelectPrimitive.ItemIndicator>
|
|
198
|
-
</SelectPrimitive.Item>
|
|
199
|
-
{/* разделитель между пунктами — тонкая линия */}
|
|
200
|
-
{index < options.length - 1 ? (
|
|
201
|
-
<SelectPrimitive.Separator
|
|
202
|
-
className={cn(
|
|
203
|
-
'my-1 h-px bg-border rounded-full',
|
|
204
|
-
dividerInset,
|
|
205
|
-
)}
|
|
206
|
-
/>
|
|
207
|
-
) : null}
|
|
208
|
-
</React.Fragment>
|
|
209
|
-
))}
|
|
210
|
-
</SelectPrimitive.Viewport>
|
|
211
|
-
</SelectPrimitive.Content>
|
|
212
|
-
</SelectPrimitive.Portal>
|
|
213
|
-
</SelectPrimitive.Root>
|
|
214
|
-
)
|
|
215
|
-
}
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import * as SelectPrimitive from '@radix-ui/react-select'
|
|
6
|
+
|
|
7
|
+
import { Icon } from '../Icon'
|
|
8
|
+
import { cn } from '../../lib/utils'
|
|
9
|
+
|
|
10
|
+
export interface SelectOption {
|
|
11
|
+
value: string
|
|
12
|
+
label: string
|
|
13
|
+
/** Контент в начале пункта (флаг/иконка/что угодно). Показывается и в списке, и в значении триггера. */
|
|
14
|
+
startContent?: React.ReactNode
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// group — чтобы шеврон в триггере доворачивался по data-state. Размер — через
|
|
19
|
+
// responsiveSize (CSS-адаптив без флэша), а не cva. Иконки — в em (масштаб за font-size).
|
|
20
|
+
const SELECT_TRIGGER_BASE =
|
|
21
|
+
'group inline-flex w-full cursor-pointer items-center bg-card text-foreground shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-disabled:pointer-events-none data-disabled:opacity-50 data-placeholder:text-muted-foreground rounded-full'
|
|
22
|
+
const SELECT_TRIGGER_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
23
|
+
sm: 'gap-fl-10/12 min-h-fl-28/32 px-fl-10/12 py-fl-2/4 text-button-s',
|
|
24
|
+
md: 'gap-fl-14/16 min-h-fl-36/40 px-fl-14/16 py-fl-6/8 text-button-m',
|
|
25
|
+
lg: 'gap-fl-18/20 min-h-fl-44/48 px-fl-18/20 py-fl-10/12 text-button-l',
|
|
26
|
+
}
|
|
27
|
+
// Пункт = носитель подсветки; текст инсетит px ВНУТРИ пункта (выравнивание с триггером).
|
|
28
|
+
const SELECT_ITEM_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
29
|
+
sm: 'rounded-sm px-fl-6/8 py-fl-6/8 text-button-s',
|
|
30
|
+
md: 'rounded-md px-fl-10/12 py-fl-8/10 text-button-m',
|
|
31
|
+
lg: 'rounded-lg px-fl-14/16 py-fl-10/12 text-button-l',
|
|
32
|
+
}
|
|
33
|
+
// Боковой инсет разделителя = px пункта → линия выровнена с текстом и уже подсветки.
|
|
34
|
+
const SELECT_DIVIDER_INSET: Record<'sm' | 'md' | 'lg', string> = {
|
|
35
|
+
sm: 'mx-2',
|
|
36
|
+
md: 'mx-3',
|
|
37
|
+
lg: 'mx-4',
|
|
38
|
+
}
|
|
39
|
+
const SELECT_ROUNDED_CONTENT: Record<'sm' | 'md' | 'lg', string> = {
|
|
40
|
+
sm: 'rounded-sm',
|
|
41
|
+
md: 'rounded-md',
|
|
42
|
+
lg: 'rounded-lg',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SelectProps {
|
|
46
|
+
options: SelectOption[]
|
|
47
|
+
/** Размер: `sm | md | lg`. По умолчанию md. Каждый размер сам fluid-клампится. */
|
|
48
|
+
size?: 'sm' | 'md' | 'lg'
|
|
49
|
+
value?: string
|
|
50
|
+
defaultValue?: string
|
|
51
|
+
onValueChange?: (value: string) => void
|
|
52
|
+
placeholder?: string
|
|
53
|
+
disabled?: boolean
|
|
54
|
+
/** Фиксированный контент в начале триггера (помимо иконки выбранного пункта): лейбл, иконка и т.п. */
|
|
55
|
+
startContent?: React.ReactNode
|
|
56
|
+
/** Класс на триггер. */
|
|
57
|
+
className?: string
|
|
58
|
+
/** Класс на выпадающую панель. */
|
|
59
|
+
contentClassName?: string
|
|
60
|
+
/**
|
|
61
|
+
* В триггере показывать только `startContent` выбранного пункта (напр. флаг),
|
|
62
|
+
* без текста; в списке пункты остаются с текстом. Для компактных переключателей
|
|
63
|
+
* (язык). По умолчанию false.
|
|
64
|
+
*/
|
|
65
|
+
compactValue?: boolean
|
|
66
|
+
/**
|
|
67
|
+
* Доступное имя контрола. Заведено явно: у компонента нет rest-спреда, а TypeScript
|
|
68
|
+
* не проверяет лишние JSX-атрибуты с дефисом — `aria-label` на <Select> молча
|
|
69
|
+
* компилировался и никуда не попадал, оставляя контрол без доступного имени.
|
|
70
|
+
*/
|
|
71
|
+
'aria-label'?: string
|
|
72
|
+
'aria-labelledby'?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Флаг/иконка + подпись. Кладём ВНУТРЬ ItemText, чтобы триггер (Select.Value)
|
|
76
|
+
// отрисовал ровно то же — иконку слева и текст, как в макете.
|
|
77
|
+
function OptionContent({ option }: { option: SelectOption }) {
|
|
78
|
+
return (
|
|
79
|
+
// leading-normal: токены text-button-* имеют line-height:1, из-за чего нижние
|
|
80
|
+
// выносные элементы букв (g/y/р) обрезаются строчным боксом. Локально возвращаем запас.
|
|
81
|
+
<span className='flex items-center gap-2.5 leading-normal'>
|
|
82
|
+
{option.startContent ? (
|
|
83
|
+
<span className='inline-flex shrink-0 items-center'>
|
|
84
|
+
{option.startContent}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
87
|
+
{option.label}
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function Select({
|
|
93
|
+
options,
|
|
94
|
+
value,
|
|
95
|
+
defaultValue,
|
|
96
|
+
onValueChange,
|
|
97
|
+
placeholder,
|
|
98
|
+
disabled,
|
|
99
|
+
startContent,
|
|
100
|
+
size,
|
|
101
|
+
className,
|
|
102
|
+
contentClassName,
|
|
103
|
+
compactValue = false,
|
|
104
|
+
'aria-label': ariaLabel,
|
|
105
|
+
'aria-labelledby': ariaLabelledBy,
|
|
106
|
+
}: SelectProps) {
|
|
107
|
+
// Только для контролируемого значения: в неконтролируемом режиме выбранный пункт
|
|
108
|
+
// знает лишь Radix, и подменять его нечем.
|
|
109
|
+
const selectedOption = value != null ? options.find((o) => o.value === value) : undefined
|
|
110
|
+
|
|
111
|
+
const triggerSize = SELECT_TRIGGER_SIZE[size ?? 'md']
|
|
112
|
+
const itemSize = SELECT_ITEM_SIZE[size ?? 'md']
|
|
113
|
+
const dividerInset = SELECT_DIVIDER_INSET[size ?? 'md']
|
|
114
|
+
const roundedContent = SELECT_ROUNDED_CONTENT[size ?? 'md']
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<SelectPrimitive.Root
|
|
118
|
+
value={value}
|
|
119
|
+
defaultValue={defaultValue}
|
|
120
|
+
onValueChange={onValueChange}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
>
|
|
123
|
+
<SelectPrimitive.Trigger
|
|
124
|
+
aria-label={ariaLabel}
|
|
125
|
+
aria-labelledby={ariaLabelledBy}
|
|
126
|
+
className={cn(SELECT_TRIGGER_BASE, triggerSize, className)}
|
|
127
|
+
>
|
|
128
|
+
{startContent ? (
|
|
129
|
+
<span className='flex shrink-0 items-center text-muted-foreground'>
|
|
130
|
+
{startContent}
|
|
131
|
+
</span>
|
|
132
|
+
) : null}
|
|
133
|
+
{/* Значение рендерим САМИ, а не отдаём на откуп SelectPrimitive.Value.
|
|
134
|
+
Radix берёт текст из смонтированных Item'ов, а они живут в портале и
|
|
135
|
+
на сервере не рендерятся — из-за чего триггер приезжает ПУСТЫМ и
|
|
136
|
+
остаётся таким до гидрации. На медленной сети это сотни миллисекунд
|
|
137
|
+
пустого поля там, где уже должно стоять выбранное значение.
|
|
138
|
+
Когда значение контролируемое, лейбл известен сразу — печатаем его. */}
|
|
139
|
+
<SelectPrimitive.Value placeholder={placeholder}>
|
|
140
|
+
{selectedOption ? (
|
|
141
|
+
compactValue ? (
|
|
142
|
+
selectedOption.startContent
|
|
143
|
+
) : (
|
|
144
|
+
<OptionContent option={selectedOption} />
|
|
145
|
+
)
|
|
146
|
+
) : undefined}
|
|
147
|
+
</SelectPrimitive.Value>
|
|
148
|
+
<SelectPrimitive.Icon asChild>
|
|
149
|
+
<span className='ml-auto flex shrink-0 items-center text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180'>
|
|
150
|
+
<Icon type='chevron_down' isCurrentColor size='0.72em' />
|
|
151
|
+
</span>
|
|
152
|
+
</SelectPrimitive.Icon>
|
|
153
|
+
</SelectPrimitive.Trigger>
|
|
154
|
+
|
|
155
|
+
<SelectPrimitive.Portal>
|
|
156
|
+
<SelectPrimitive.Content
|
|
157
|
+
position='popper'
|
|
158
|
+
sideOffset={8}
|
|
159
|
+
className={cn(
|
|
160
|
+
'z-50 max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] overflow-hidden border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95',
|
|
161
|
+
contentClassName,
|
|
162
|
+
roundedContent,
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<SelectPrimitive.Viewport className='flex flex-col'>
|
|
166
|
+
{options.map((option, index) => (
|
|
167
|
+
<React.Fragment key={option.value}>
|
|
168
|
+
<SelectPrimitive.Item
|
|
169
|
+
value={option.value}
|
|
170
|
+
disabled={option.disabled}
|
|
171
|
+
textValue={option.label}
|
|
172
|
+
className={cn(
|
|
173
|
+
'flex w-full cursor-pointer items-center gap-2.5 text-foreground outline-none select-none data-highlighted:bg-secondary data-disabled:pointer-events-none data-disabled:opacity-50',
|
|
174
|
+
itemSize,
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
<SelectPrimitive.ItemText>
|
|
178
|
+
{compactValue && option.startContent ? (
|
|
179
|
+
<span className='inline-flex shrink-0 items-center'>
|
|
180
|
+
{option.startContent}
|
|
181
|
+
</span>
|
|
182
|
+
) : (
|
|
183
|
+
<OptionContent option={option} />
|
|
184
|
+
)}
|
|
185
|
+
</SelectPrimitive.ItemText>
|
|
186
|
+
{/* compactValue: название живёт ВНЕ ItemText → видно в списке, но НЕ в триггере.
|
|
187
|
+
leading-normal — иначе truncate (overflow-hidden) при line-height:1 режет
|
|
188
|
+
нижние выносы букв («g» в English). */}
|
|
189
|
+
{compactValue ? (
|
|
190
|
+
<span className='truncate leading-normal'>
|
|
191
|
+
{option.label}
|
|
192
|
+
</span>
|
|
193
|
+
) : null}
|
|
194
|
+
{/* галочка прижата вправо; рендерится только у выбранного */}
|
|
195
|
+
<SelectPrimitive.ItemIndicator className='ml-auto flex items-center text-primary'>
|
|
196
|
+
<Icon type='check' isCurrentColor size='0.85em' />
|
|
197
|
+
</SelectPrimitive.ItemIndicator>
|
|
198
|
+
</SelectPrimitive.Item>
|
|
199
|
+
{/* разделитель между пунктами — тонкая линия */}
|
|
200
|
+
{index < options.length - 1 ? (
|
|
201
|
+
<SelectPrimitive.Separator
|
|
202
|
+
className={cn(
|
|
203
|
+
'my-1 h-px bg-border rounded-full',
|
|
204
|
+
dividerInset,
|
|
205
|
+
)}
|
|
206
|
+
/>
|
|
207
|
+
) : null}
|
|
208
|
+
</React.Fragment>
|
|
209
|
+
))}
|
|
210
|
+
</SelectPrimitive.Viewport>
|
|
211
|
+
</SelectPrimitive.Content>
|
|
212
|
+
</SelectPrimitive.Portal>
|
|
213
|
+
</SelectPrimitive.Root>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
|
|
3
|
-
import { cn } from '../../lib/utils'
|
|
4
|
-
|
|
5
|
-
export interface SkeletonProps extends React.ComponentProps<'div'> {
|
|
6
|
-
/**
|
|
7
|
-
* Идёт ли загрузка. По умолчанию `true`. Если `false` и переданы `children` —
|
|
8
|
-
* рендерим реальный контент без заглушки (wrapper-режим).
|
|
9
|
-
*/
|
|
10
|
-
loading?: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Skeleton — заглушка-плейсхолдер на время загрузки. Два режима:
|
|
14
|
-
// - без children: пустой блок, форму задаёшь className (w-*/h-*/size-*/rounded-*);
|
|
15
|
-
// - с children (wrapper): children держат размер (invisible), а заглушка ложится
|
|
16
|
-
// поверх — удобно, когда контент уже есть, но «не готов».
|
|
17
|
-
// Анимация — shimmer-блик пробегает слева-направо (keyframes shimmer в globals.css).
|
|
18
|
-
export function Skeleton({
|
|
19
|
-
className,
|
|
20
|
-
loading = true,
|
|
21
|
-
children,
|
|
22
|
-
...props
|
|
23
|
-
}: SkeletonProps) {
|
|
24
|
-
// Загрузка завершена и есть что показать — отдаём реальный контент как есть.
|
|
25
|
-
if (!loading && children !== undefined) return <>{children}</>
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<div
|
|
29
|
-
aria-hidden
|
|
30
|
-
className={cn(
|
|
31
|
-
'relative overflow-hidden rounded-md bg-accent',
|
|
32
|
-
className,
|
|
33
|
-
)}
|
|
34
|
-
{...props}
|
|
35
|
-
>
|
|
36
|
-
{/* блик-полоса: едет translateX(-100% → 100%), overflow-hidden её обрезает */}
|
|
37
|
-
<span className='pointer-events-none absolute inset-0 animate-shimmer bg-linear-to-r from-transparent via-skeleton-sheen to-transparent' />
|
|
38
|
-
{/* children задают размер обёртки, но скрыты на время загрузки */}
|
|
39
|
-
{children !== undefined ? (
|
|
40
|
-
<div className='invisible'>{children}</div>
|
|
41
|
-
) : null}
|
|
42
|
-
</div>
|
|
43
|
-
)
|
|
44
|
-
}
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/utils'
|
|
4
|
+
|
|
5
|
+
export interface SkeletonProps extends React.ComponentProps<'div'> {
|
|
6
|
+
/**
|
|
7
|
+
* Идёт ли загрузка. По умолчанию `true`. Если `false` и переданы `children` —
|
|
8
|
+
* рендерим реальный контент без заглушки (wrapper-режим).
|
|
9
|
+
*/
|
|
10
|
+
loading?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Skeleton — заглушка-плейсхолдер на время загрузки. Два режима:
|
|
14
|
+
// - без children: пустой блок, форму задаёшь className (w-*/h-*/size-*/rounded-*);
|
|
15
|
+
// - с children (wrapper): children держат размер (invisible), а заглушка ложится
|
|
16
|
+
// поверх — удобно, когда контент уже есть, но «не готов».
|
|
17
|
+
// Анимация — shimmer-блик пробегает слева-направо (keyframes shimmer в globals.css).
|
|
18
|
+
export function Skeleton({
|
|
19
|
+
className,
|
|
20
|
+
loading = true,
|
|
21
|
+
children,
|
|
22
|
+
...props
|
|
23
|
+
}: SkeletonProps) {
|
|
24
|
+
// Загрузка завершена и есть что показать — отдаём реальный контент как есть.
|
|
25
|
+
if (!loading && children !== undefined) return <>{children}</>
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
aria-hidden
|
|
30
|
+
className={cn(
|
|
31
|
+
'relative overflow-hidden rounded-md bg-accent',
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{/* блик-полоса: едет translateX(-100% → 100%), overflow-hidden её обрезает */}
|
|
37
|
+
<span className='pointer-events-none absolute inset-0 animate-shimmer bg-linear-to-r from-transparent via-skeleton-sheen to-transparent' />
|
|
38
|
+
{/* children задают размер обёртки, но скрыты на время загрузки */}
|
|
39
|
+
{children !== undefined ? (
|
|
40
|
+
<div className='invisible'>{children}</div>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|