hallo-kit 0.3.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.
Files changed (128) hide show
  1. package/README.md +168 -0
  2. package/bin/cli.mjs +136 -0
  3. package/fonts/Manrope/Manrope-VariableFont_wght.woff2 +0 -0
  4. package/fonts/Manrope/OFL.txt +93 -0
  5. package/fonts/index.ts +30 -0
  6. package/package.json +77 -0
  7. package/src/config.ts +11 -0
  8. package/src/hooks/index.ts +14 -0
  9. package/src/hooks/useAutoHideOnScroll.ts +26 -0
  10. package/src/hooks/useCountdown.ts +44 -0
  11. package/src/hooks/useDevice.ts +63 -0
  12. package/src/hooks/useIsTelegramMiniApp.ts +17 -0
  13. package/src/hooks/useIsTouch.ts +23 -0
  14. package/src/hooks/useKeyboardOpen.ts +35 -0
  15. package/src/hooks/useMediaQuery.ts +24 -0
  16. package/src/hooks/usePlatform.ts +102 -0
  17. package/src/hooks/useQueryParams.tsx +70 -0
  18. package/src/hooks/useRevealOnce.ts +21 -0
  19. package/src/hooks/useScrolledPast.ts +19 -0
  20. package/src/hooks/useSettleOnce.ts +26 -0
  21. package/src/hooks/useTelegram.tsx +57 -0
  22. package/src/i18n/index.tsx +92 -0
  23. package/src/index.ts +35 -0
  24. package/src/lib/device.ts +28 -0
  25. package/src/lib/platform.ts +19 -0
  26. package/src/lib/utils.ts +36 -0
  27. package/src/logo/LogoFull.tsx +59 -0
  28. package/src/logo/LogoMark.tsx +31 -0
  29. package/src/logo/index.ts +2 -0
  30. package/src/next.ts +13 -0
  31. package/src/server.ts +4 -0
  32. package/src/theme-color-sync.tsx +43 -0
  33. package/src/theme-provider.tsx +31 -0
  34. package/src/types/css.d.ts +6 -0
  35. package/src/types/globals.d.ts +47 -0
  36. package/src/ui/Icon/Icon.module.css +25 -0
  37. package/src/ui/Icon/Icon.tsx +45 -0
  38. package/src/ui/Icon/Icon.types.ts +60 -0
  39. package/src/ui/Icon/data/brand.data.tsx +75 -0
  40. package/src/ui/Icon/data/index.ts +2 -0
  41. package/src/ui/Icon/data/ui.data.tsx +780 -0
  42. package/src/ui/Icon/index.ts +3 -0
  43. package/src/ui/badge/badge.tsx +185 -0
  44. package/src/ui/badge/index.ts +1 -0
  45. package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -0
  46. package/src/ui/breadcrumbs/index.ts +1 -0
  47. package/src/ui/burger-icon/burger-icon.tsx +22 -0
  48. package/src/ui/burger-icon/index.ts +1 -0
  49. package/src/ui/button/button.tsx +179 -0
  50. package/src/ui/button/index.ts +1 -0
  51. package/src/ui/card/card.tsx +100 -0
  52. package/src/ui/card/index.ts +1 -0
  53. package/src/ui/confirm/confirm-dialog-host.tsx +89 -0
  54. package/src/ui/confirm/index.ts +1 -0
  55. package/src/ui/count-up/count-up.tsx +96 -0
  56. package/src/ui/count-up/index.ts +1 -0
  57. package/src/ui/data-list/data-list.tsx +331 -0
  58. package/src/ui/data-list/index.ts +1 -0
  59. package/src/ui/drawer/drawer.tsx +166 -0
  60. package/src/ui/drawer/index.ts +1 -0
  61. package/src/ui/flag/flag.data.ts +240 -0
  62. package/src/ui/flag/flag.tsx +57 -0
  63. package/src/ui/flag/index.ts +1 -0
  64. package/src/ui/image/image.tsx +67 -0
  65. package/src/ui/image/index.ts +1 -0
  66. package/src/ui/input/amount-input.tsx +55 -0
  67. package/src/ui/input/index.ts +2 -0
  68. package/src/ui/input/input.tsx +391 -0
  69. package/src/ui/label/index.ts +1 -0
  70. package/src/ui/label/label.tsx +34 -0
  71. package/src/ui/method-card/index.ts +1 -0
  72. package/src/ui/method-card/method-card.tsx +101 -0
  73. package/src/ui/modal/index.ts +1 -0
  74. package/src/ui/modal/modal.tsx +128 -0
  75. package/src/ui/otp-input/index.ts +1 -0
  76. package/src/ui/otp-input/otp-input.tsx +78 -0
  77. package/src/ui/pagination/index.ts +1 -0
  78. package/src/ui/pagination/pagination.tsx +138 -0
  79. package/src/ui/radio-group/index.ts +1 -0
  80. package/src/ui/radio-group/radio-group.tsx +81 -0
  81. package/src/ui/select/index.ts +1 -0
  82. package/src/ui/select/select.tsx +215 -0
  83. package/src/ui/separator/index.ts +1 -0
  84. package/src/ui/separator/separator.tsx +54 -0
  85. package/src/ui/skeleton/index.ts +1 -0
  86. package/src/ui/skeleton/skeleton.tsx +44 -0
  87. package/src/ui/spinner/index.ts +1 -0
  88. package/src/ui/spinner/spinner.tsx +33 -0
  89. package/src/ui/switch/index.ts +1 -0
  90. package/src/ui/switch/switch.tsx +48 -0
  91. package/src/ui/tabs/index.ts +1 -0
  92. package/src/ui/tabs/tabs.tsx +190 -0
  93. package/src/ui/textarea/index.ts +1 -0
  94. package/src/ui/textarea/textarea.tsx +151 -0
  95. package/src/ui/theme-switch/index.ts +1 -0
  96. package/src/ui/theme-switch/theme-switch.tsx +161 -0
  97. package/src/ui/toast/index.ts +1 -0
  98. package/src/ui/toast/toast.tsx +159 -0
  99. package/src/utils/api/stringFormat.ts +5 -0
  100. package/src/utils/clearAllCookies.ts +7 -0
  101. package/src/utils/clsx.ts +18 -0
  102. package/src/utils/confirm.ts +48 -0
  103. package/src/utils/copy-to-clipboard.ts +12 -0
  104. package/src/utils/date/convertTimestamp.ts +44 -0
  105. package/src/utils/date/formatCountdown.ts +9 -0
  106. package/src/utils/date/formatDayLabel.ts +50 -0
  107. package/src/utils/date/formatRelativeDateTime.ts +34 -0
  108. package/src/utils/date/locales.ts +9 -0
  109. package/src/utils/form/validationSchema.ts +46 -0
  110. package/src/utils/getByKeyString.ts +15 -0
  111. package/src/utils/getCssVar.ts +2 -0
  112. package/src/utils/getDeepType.ts +3 -0
  113. package/src/utils/getQueryParams.ts +21 -0
  114. package/src/utils/inAppBrowser.ts +151 -0
  115. package/src/utils/index.ts +28 -0
  116. package/src/utils/isRTL.ts +29 -0
  117. package/src/utils/notify.ts +46 -0
  118. package/src/utils/openExternal.ts +27 -0
  119. package/src/utils/system/getMillisecondsPeriod.ts +31 -0
  120. package/src/utils/validation/authValidators.ts +23 -0
  121. package/src/utils/validation/refCode.ts +30 -0
  122. package/styles/base.css +97 -0
  123. package/styles/brand.css +36 -0
  124. package/styles/index.css +19 -0
  125. package/styles/motion.css +207 -0
  126. package/styles/semantic.css +151 -0
  127. package/styles/theme.css +143 -0
  128. package/styles/utilities.css +289 -0
@@ -0,0 +1,78 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import * as OneTimePasswordField from '@radix-ui/react-one-time-password-field'
6
+
7
+ import { cn } from '../../lib/utils'
8
+
9
+ // Поле кода подтверждения из N ячеек (OTP) — headless Radix OneTimePasswordField +
10
+ // свои стили. Примитив сам ведёт roving-фокус, Backspace/стрелки, вставку из буфера
11
+ // и фильтрацию ввода (validationType='numeric' и autoComplete='one-time-code' —
12
+ // дефолты Radix). Значение — сплошная строка цифр.
13
+ // error — как у Input: `true` — только красная подсветка ячеек, строка — подсветка
14
+ // плюс текст под полем (role="alert").
15
+ export function OtpInput({
16
+ length = 6,
17
+ value,
18
+ onChange,
19
+ disabled,
20
+ error,
21
+ autoFocus,
22
+ ariaLabel,
23
+ }: {
24
+ length?: number
25
+ value: string
26
+ onChange: (code: string) => void
27
+ disabled?: boolean
28
+ error?: boolean | string
29
+ autoFocus?: boolean
30
+ ariaLabel?: string
31
+ }) {
32
+ const hasError = !!error
33
+ const errorMessage = typeof error === 'string' ? error : undefined
34
+
35
+ const reactId = React.useId()
36
+ const messageId = `${reactId}-msg`
37
+
38
+ const field = (
39
+ <OneTimePasswordField.Root
40
+ value={value}
41
+ onValueChange={onChange}
42
+ disabled={disabled}
43
+ autoFocus={autoFocus}
44
+ aria-label={ariaLabel}
45
+ aria-describedby={errorMessage ? messageId : undefined}
46
+ className='flex items-center justify-center gap-fl-8/12'
47
+ >
48
+ {/* Число ячеек = число Input-детей — так Radix узнаёт length. */}
49
+ {Array.from({ length }, (_, index) => (
50
+ <OneTimePasswordField.Input
51
+ key={index}
52
+ aria-invalid={hasError || undefined}
53
+ className={cn(
54
+ 'size-fl-44/52 rounded-md bg-secondary text-center text-button-l font-medium text-foreground outline-none transition-[color,box-shadow]',
55
+ 'focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50',
56
+ hasError && 'ring-1 ring-error bg-error-soft focus:ring-error',
57
+ )}
58
+ />
59
+ ))}
60
+ </OneTimePasswordField.Root>
61
+ )
62
+
63
+ // Без текста сообщения возвращаем только поле — лишнюю обёртку не плодим (как Input).
64
+ if (!errorMessage) return field
65
+
66
+ return (
67
+ <div className='flex w-full flex-col gap-1.5'>
68
+ {field}
69
+ <p
70
+ id={messageId}
71
+ role='alert'
72
+ className='text-center text-button-s text-error'
73
+ >
74
+ {errorMessage}
75
+ </p>
76
+ </div>
77
+ )
78
+ }
@@ -0,0 +1 @@
1
+ export * from './pagination'
@@ -0,0 +1,138 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+
5
+ import { cn } from '../../lib/utils'
6
+
7
+ import { Icon } from '../Icon'
8
+ import { Button } from '../button'
9
+
10
+ export interface PaginationProps {
11
+ /** Текущая страница (с 1). */
12
+ page: number
13
+ /** Всего элементов. */
14
+ total: number
15
+ /** Размер страницы. */
16
+ pageSize: number
17
+ onPageChange: (page: number) => void
18
+ /** Подпись для aria-label навигации. */
19
+ label?: string
20
+ className?: string
21
+ }
22
+
23
+ const ELLIPSIS = '…' as const
24
+
25
+ /**
26
+ * Ряд номеров страниц: 1 … (p-1) p (p+1) … N. Всегда видны первая/последняя,
27
+ * вокруг текущей — по соседу; разрывы схлопываются в многоточие.
28
+ */
29
+ const buildRange = (
30
+ page: number,
31
+ pageCount: number,
32
+ ): Array<number | typeof ELLIPSIS> => {
33
+ // До 7 страниц выводим все — многоточие не нужно.
34
+ if (pageCount <= 7) return Array.from({ length: pageCount }, (_, i) => i + 1)
35
+
36
+ const wanted = new Set([
37
+ 1,
38
+ 2,
39
+ page - 1,
40
+ page,
41
+ page + 1,
42
+ pageCount - 1,
43
+ pageCount,
44
+ ])
45
+ const result: Array<number | typeof ELLIPSIS> = []
46
+ let prev = 0
47
+ for (let i = 1; i <= pageCount; i++) {
48
+ if (!wanted.has(i)) continue
49
+ // Разрыв в один номер бессмысленно прятать за «…» — показываем сам номер.
50
+ if (i - prev === 2) result.push(i - 1)
51
+ else if (i - prev > 2) result.push(ELLIPSIS)
52
+ result.push(i)
53
+ prev = i
54
+ }
55
+ return result
56
+ }
57
+
58
+ /**
59
+ * Классическая номерная пагинация (1 2 3 … N) c prev/next. Управляемая: страница и
60
+ * обработчик приходят сверху (хуки списков и так постраничные — page/pageSize).
61
+ */
62
+ export function Pagination({
63
+ page,
64
+ total,
65
+ pageSize,
66
+ onPageChange,
67
+ label,
68
+ className,
69
+ }: PaginationProps) {
70
+ const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))
71
+ const range = useMemo(() => buildRange(page, pageCount), [page, pageCount])
72
+
73
+ if (pageCount <= 1) return null
74
+
75
+ const goTo = (next: number) => {
76
+ const clamped = Math.min(Math.max(1, next), pageCount)
77
+ if (clamped !== page) onPageChange(clamped)
78
+ }
79
+
80
+ return (
81
+ <nav
82
+ aria-label={label}
83
+ className={cn('flex items-center justify-center gap-1', className)}
84
+ >
85
+ <Button
86
+ variant='ghost'
87
+ size='sm'
88
+ isIconOnly
89
+ aria-label={`${label ?? ''} ←`.trim()}
90
+ disabled={page <= 1}
91
+ onClick={() => goTo(page - 1)}
92
+ >
93
+ <Icon
94
+ type='chevron_right'
95
+ size='1.25em'
96
+ isCurrentColor
97
+ className='rotate-180'
98
+ />
99
+ </Button>
100
+
101
+ {range.map((item, idx) =>
102
+ item === ELLIPSIS ? (
103
+ <span
104
+ key={`gap-${idx}`}
105
+ aria-hidden
106
+ className='min-w-fl-32/36 text-center text-button-s text-muted-foreground select-none'
107
+ >
108
+ {ELLIPSIS}
109
+ </span>
110
+ ) : (
111
+ <Button
112
+ key={item}
113
+ variant={item === page ? 'default' : 'ghost'}
114
+ size='sm'
115
+ isIconOnly
116
+ className='size-fl-36/42! p-0!'
117
+ aria-label={String(item)}
118
+ aria-current={item === page ? 'page' : undefined}
119
+ onClick={() => goTo(item)}
120
+ >
121
+ {item}
122
+ </Button>
123
+ ),
124
+ )}
125
+
126
+ <Button
127
+ variant='ghost'
128
+ size='sm'
129
+ isIconOnly
130
+ aria-label={`${label ?? ''} →`.trim()}
131
+ disabled={page >= pageCount}
132
+ onClick={() => goTo(page + 1)}
133
+ >
134
+ <Icon type='chevron_right' size='1.25em' isCurrentColor />
135
+ </Button>
136
+ </nav>
137
+ )
138
+ }
@@ -0,0 +1 @@
1
+ export * from './radio-group'
@@ -0,0 +1,81 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
6
+ import { type VariantProps, cva } from 'class-variance-authority'
7
+
8
+ import { cn } from '../../lib/utils'
9
+
10
+ // Радио-группа — headless Radix + свои стили. Root ведёт одиночный выбор, клавиатуру
11
+ // (стрелки) и ARIA; раскладку задаёт потребитель через className (по умолчанию —
12
+ // вертикальный список карточек с гэпом, как в макетах тарифов/способов оплаты).
13
+ export function RadioGroup({
14
+ className,
15
+ ...props
16
+ }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
17
+ return (
18
+ <RadioGroupPrimitive.Root
19
+ className={cn('flex flex-col gap-2.5', className)}
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ // Внешний вид карточки-варианта (по макетам два вида):
26
+ // - bordered (деф.) — белая карточка, у выбранной только синяя рамка (способы оплаты);
27
+ // - soft — лёгкая рамка всегда, у выбранной синяя рамка + голубая заливка (тарифы).
28
+ const radioCardVariants = cva(
29
+ 'group flex w-full cursor-pointer items-center gap-fl-12/16 rounded-lg border-2 p-5 text-left transition outline-none hover:border-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-99 disabled:pointer-events-none disabled:opacity-50',
30
+ {
31
+ variants: {
32
+ appearance: {
33
+ bordered:
34
+ 'border-transparent bg-card data-[state=checked]:border-primary',
35
+ soft: 'border-border bg-card data-[state=checked]:border-primary data-[state=checked]:bg-accent',
36
+ },
37
+ },
38
+ defaultVariants: { appearance: 'bordered' },
39
+ },
40
+ )
41
+
42
+ // Кружок-индикатор радио: пустой с серой рамкой; у выбранного (data-state=checked
43
+ // на Item-родителе с классом group) — синяя рамка и точка внутри. Размер — как в
44
+ // макете (24px). Отдельным экспортом не нужен — используется внутри RadioGroupCard.
45
+ function RadioDot() {
46
+ return (
47
+ <span
48
+ aria-hidden
49
+ className={cn(
50
+ 'relative flex size-6 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
51
+ 'border-disabled group-data-[state=checked]:border-primary',
52
+ )}
53
+ >
54
+ <RadioGroupPrimitive.Indicator asChild forceMount>
55
+ <span className='size-3 scale-0 rounded-full bg-primary transition-transform group-data-[state=checked]:scale-100' />
56
+ </RadioGroupPrimitive.Indicator>
57
+ </span>
58
+ )
59
+ }
60
+
61
+ // Карточка-вариант радио-группы: вся карточка — кнопка выбора, слева кружок-радио,
62
+ // дальше произвольный контент (children). Рамка есть всегда → выбор не сдвигает
63
+ // раскладку. Тарифы кладут сюда «название+период | цена» (appearance='soft'),
64
+ // способы оплаты — «иконка + название/описание» (bordered).
65
+ export function RadioGroupCard({
66
+ className,
67
+ appearance,
68
+ children,
69
+ ...props
70
+ }: React.ComponentProps<typeof RadioGroupPrimitive.Item> &
71
+ VariantProps<typeof radioCardVariants>) {
72
+ return (
73
+ <RadioGroupPrimitive.Item
74
+ className={cn(radioCardVariants({ appearance }), className)}
75
+ {...props}
76
+ >
77
+ <RadioDot />
78
+ {children}
79
+ </RadioGroupPrimitive.Item>
80
+ )
81
+ }
@@ -0,0 +1 @@
1
+ export * from './select'
@@ -0,0 +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
+ }
@@ -0,0 +1 @@
1
+ export * from './separator'
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import * as SeparatorPrimitive from '@radix-ui/react-separator'
6
+
7
+ import { cn } from '../../lib/utils'
8
+
9
+ // Separator — тонкая линия-разделитель на Radix (role=separator + aria-orientation;
10
+ // при decorative — чисто визуальный, скринридеру не объявляется). Стиль — как у
11
+ // разделителя пунктов в Select: 1px, цвет sky, скруглённые концы.
12
+ // horizontal → тянется по ширине (h-px w-full), vertical → по высоте (w-px h-full;
13
+ // родитель должен задавать высоту, напр. flex-строка с h-*).
14
+ // С children (только horizontal) → разделитель с подписью по центру: «──── ИЛИ ────».
15
+ export function Separator({
16
+ className,
17
+ orientation = 'horizontal',
18
+ decorative = true,
19
+ children,
20
+ ...props
21
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
22
+ // Подпись по центру: две линии по бокам + текст между ними. Radix Root тут не
23
+ // подходит (это одна линия), поэтому собираем role=separator вручную; сами линии
24
+ // декоративны (aria-hidden), а текст-метка остаётся доступной скринридеру.
25
+ if (children != null && orientation === 'horizontal') {
26
+ return (
27
+ <div
28
+ role='separator'
29
+ aria-orientation='horizontal'
30
+ className={cn('flex w-full items-center gap-fl-12/16', className)}
31
+ {...props}
32
+ >
33
+ <span aria-hidden className='h-px flex-1 rounded-full bg-border' />
34
+ <span className='text-body-s shrink-0 text-muted-foreground'>
35
+ {children}
36
+ </span>
37
+ <span aria-hidden className='h-px flex-1 rounded-full bg-border' />
38
+ </div>
39
+ )
40
+ }
41
+
42
+ return (
43
+ <SeparatorPrimitive.Root
44
+ orientation={orientation}
45
+ decorative={decorative}
46
+ className={cn(
47
+ 'shrink-0 rounded-full bg-border',
48
+ orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
49
+ className,
50
+ )}
51
+ {...props}
52
+ />
53
+ )
54
+ }
@@ -0,0 +1 @@
1
+ export * from './skeleton'
@@ -0,0 +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
+ }
@@ -0,0 +1 @@
1
+ export * from './spinner'
@@ -0,0 +1,33 @@
1
+ import * as React from 'react'
2
+
3
+ import { cn } from '../../lib/utils'
4
+
5
+ // Крутилка-индикатор загрузки. Цвет — currentColor (наследует цвет текста),
6
+ // размер — через className (по умолчанию size-5). Трек полупрозрачный, дуга крутится.
7
+ export function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
8
+ return (
9
+ <svg
10
+ role='status'
11
+ aria-label='Загрузка'
12
+ viewBox='0 0 24 24'
13
+ fill='none'
14
+ className={cn('size-5 shrink-0 animate-spin text-current', className)}
15
+ {...props}
16
+ >
17
+ <circle
18
+ cx='12'
19
+ cy='12'
20
+ r='9.5'
21
+ stroke='currentColor'
22
+ strokeWidth='2.5'
23
+ className='opacity-20'
24
+ />
25
+ <path
26
+ d='M21.5 12A9.5 9.5 0 0 0 12 2.5'
27
+ stroke='currentColor'
28
+ strokeWidth='2.5'
29
+ strokeLinecap='round'
30
+ />
31
+ </svg>
32
+ )
33
+ }
@@ -0,0 +1 @@
1
+ export * from './switch'