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,48 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import * as SwitchPrimitive from '@radix-ui/react-switch'
6
+
7
+ import { cn } from '../../lib/utils'
8
+
9
+ // Switch (тумблер вкл/выкл) на Radix: роль switch, клавиатура (Space/Enter), aria,
10
+ // controlled/uncontrolled — из примитива. Размер — через responsiveSize (CSS-адаптив
11
+ // без JS-флэша). on → bg-primary, off → bg-secondary; бегунок едет вправо на checked.
12
+ const SWITCH_BASE =
13
+ 'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-transparent outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-secondary'
14
+ // FLUID, соразмерно ThemeSwitch: высота трека = высоте ThemeSwitch (28/32→36/40→44/48),
15
+ // воздух border-4, ширина ~1.8:1. Всё клампится 320→1280px.
16
+ const SWITCH_TRACK: Record<'sm' | 'md' | 'lg', string> = {
17
+ sm: 'h-fl-28/32 w-fl-48/56 border-4',
18
+ md: 'h-fl-36/40 w-fl-64/72 border-4',
19
+ lg: 'h-fl-44/48 w-fl-80/88 border-4',
20
+ }
21
+
22
+ const THUMB_BASE =
23
+ 'pointer-events-none block rounded-full bg-cloud shadow-sm transition-transform data-[state=unchecked]:translate-x-0'
24
+ // Бегунок = высота_трека − 2·border (= слот ThemeSwitch); travel = ширина − высота. Всё fluid.
25
+ const SWITCH_THUMB: Record<'sm' | 'md' | 'lg', string> = {
26
+ sm: 'size-fl-20/24 data-[state=checked]:translate-x-fl-20/24',
27
+ md: 'size-fl-28/32 data-[state=checked]:translate-x-fl-28/32',
28
+ lg: 'size-fl-36/40 data-[state=checked]:translate-x-fl-36/40',
29
+ }
30
+
31
+ export interface SwitchProps
32
+ extends React.ComponentProps<typeof SwitchPrimitive.Root> {
33
+ /** Размер: `sm | md | lg`. По умолчанию md. */
34
+ size?: 'sm' | 'md' | 'lg'
35
+ }
36
+
37
+ export function Switch({ className, size, ...props }: SwitchProps) {
38
+ return (
39
+ <SwitchPrimitive.Root
40
+ className={cn(SWITCH_BASE, SWITCH_TRACK[size ?? 'md'], className)}
41
+ {...props}
42
+ >
43
+ <SwitchPrimitive.Thumb
44
+ className={cn(THUMB_BASE, SWITCH_THUMB[size ?? 'md'])}
45
+ />
46
+ </SwitchPrimitive.Root>
47
+ )
48
+ }
@@ -0,0 +1 @@
1
+ export * from './tabs'
@@ -0,0 +1,190 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import { Slottable } from '@radix-ui/react-slot'
6
+ import * as TabsPrimitive from '@radix-ui/react-tabs'
7
+
8
+ import { cn } from '../../lib/utils'
9
+
10
+ // Размер триггеров: по умолчанию на <TabsList>, раздаётся всем <TabsTrigger> через
11
+ // контекст. Отдельный таб может переопределить своим `size`. Каждый размер — fluid.
12
+ type TabsSize = 'sm' | 'md' | 'lg'
13
+ const TabsSizeContext = React.createContext<TabsSize>('md')
14
+
15
+ // Скругление скользящей рамки по размеру (совпадает с rounded триггеров в TABS_SIZE).
16
+ const INDICATOR_ROUNDED: Record<TabsSize, string> = {
17
+ sm: 'rounded-sm',
18
+ md: 'rounded-md',
19
+ lg: 'rounded-lg',
20
+ }
21
+
22
+ // Поведение, клавиатура (стрелки/Home/End), aria и связь триггер↔панель — всё на Radix.
23
+ export function Tabs({
24
+ className,
25
+ ...props
26
+ }: React.ComponentProps<typeof TabsPrimitive.Root>) {
27
+ return (
28
+ <TabsPrimitive.Root
29
+ className={cn('flex flex-col gap-fl-16/20', className)}
30
+ {...props}
31
+ />
32
+ )
33
+ }
34
+
35
+ export interface TabsListProps extends React.ComponentProps<
36
+ typeof TabsPrimitive.List
37
+ > {
38
+ /** Размер триггеров: `sm | md | lg`. По умолчанию `md`. */
39
+ size?: TabsSize
40
+ /** Растянуть на всю ширину: список `w-full`, табы равномерно делят её (`flex-1`). */
41
+ fullWidth?: boolean
42
+ }
43
+
44
+ type Rect = { left: number; top: number; width: number; height: number }
45
+
46
+ export function TabsList({
47
+ className,
48
+ size = 'md',
49
+ fullWidth = false,
50
+ children,
51
+ ...props
52
+ }: TabsListProps) {
53
+ const listRef = React.useRef<HTMLDivElement>(null)
54
+ const [rect, setRect] = React.useState<Rect | null>(null)
55
+
56
+ // Рамка активного таба «переезжает»: замеряем его позицию (offset* относительно списка)
57
+ // на смену активного (MutationObserver по data-state) и на ресайз (ResizeObserver —
58
+ // fluid-размеры/перенос). Первый замер — через rAF, чтобы не дёргать setState синхронно
59
+ // в эффекте. Сам стиль табов не меняется — рамку лишь выносим в отдельный слой, чтобы
60
+ // она скользила, а не мгновенно перескакивала.
61
+ React.useEffect(() => {
62
+ const list = listRef.current
63
+ if (!list) return
64
+
65
+ const measure = () => {
66
+ const active = list.querySelector<HTMLElement>(
67
+ '[role="tab"][data-state="active"]',
68
+ )
69
+ if (!active) return
70
+ setRect({
71
+ left: active.offsetLeft,
72
+ top: active.offsetTop,
73
+ width: active.offsetWidth,
74
+ height: active.offsetHeight,
75
+ })
76
+ }
77
+
78
+ const raf = requestAnimationFrame(measure)
79
+ const mo = new MutationObserver(measure)
80
+ mo.observe(list, {
81
+ attributes: true,
82
+ subtree: true,
83
+ attributeFilter: ['data-state'],
84
+ })
85
+ const ro = new ResizeObserver(measure)
86
+ ro.observe(list)
87
+
88
+ return () => {
89
+ cancelAnimationFrame(raf)
90
+ mo.disconnect()
91
+ ro.disconnect()
92
+ }
93
+ }, [])
94
+
95
+ return (
96
+ <TabsSizeContext.Provider value={size}>
97
+ <TabsPrimitive.List
98
+ ref={listRef}
99
+ className={cn(
100
+ 'relative inline-flex items-center gap-2',
101
+ // w-full + равные доли: каждый таб (прямой потомок) тянется поровну
102
+ fullWidth && 'flex w-full *:flex-1',
103
+ className,
104
+ )}
105
+ {...props}
106
+ >
107
+ {/* Рамка активного таба: только border поверх (центр прозрачный — текст и фон
108
+ таба видны сквозь). bg/подсветку по-прежнему даёт сам таб.
109
+
110
+ Переезда нет намеренно: анимация left/top/width/height на коротком
111
+ переключении из двух вкладок читается как дёрганье, особенно на телефоне.
112
+ Рамка просто оказывается на новом табе. */}
113
+ {rect && (
114
+ <span
115
+ aria-hidden
116
+ className={cn(
117
+ 'pointer-events-none absolute z-10 border-2 border-primary bg-transparent',
118
+ INDICATOR_ROUNDED[size],
119
+ )}
120
+ style={{
121
+ left: rect.left,
122
+ top: rect.top,
123
+ width: rect.width,
124
+ height: rect.height,
125
+ }}
126
+ />
127
+ )}
128
+ {children}
129
+ </TabsPrimitive.List>
130
+ </TabsSizeContext.Provider>
131
+ )
132
+ }
133
+
134
+ // border-transparent у ВСЕХ (высота не прыгает) — border-primary активного теперь рисует
135
+ // скользящая рамка из TabsList, а не сам триггер. Размер — FLUID (клампится 320→1280px).
136
+ const TABS_TRIGGER_BASE =
137
+ 'inline-flex items-center justify-center gap-2 whitespace-nowrap border-2 border-transparent bg-accent text-muted-foreground transition-[color,background-color] cursor-pointer select-none outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-secondary data-[state=active]:text-primary [&_svg]:pointer-events-none [&_svg]:shrink-0'
138
+ const TABS_SIZE: Record<TabsSize, string> = {
139
+ sm: 'min-h-fl-32/36 px-fl-14/16 py-fl-6/8 text-button-s rounded-sm',
140
+ md: 'min-h-fl-40/44 px-fl-18/20 py-fl-10/12 text-button-m rounded-md',
141
+ lg: 'min-h-fl-48/52 px-fl-22/24 py-fl-14/16 text-button-l rounded-lg',
142
+ }
143
+
144
+ export interface TabsTriggerProps extends React.ComponentProps<
145
+ typeof TabsPrimitive.Trigger
146
+ > {
147
+ /** Размер: `sm | md | lg`. Иначе берётся из TabsList. */
148
+ size?: TabsSize
149
+ /** Иконка/картинка перед текстом таба. */
150
+ startContent?: React.ReactNode
151
+ }
152
+
153
+ export function TabsTrigger({
154
+ className,
155
+ size,
156
+ startContent,
157
+ children,
158
+ ...props
159
+ }: TabsTriggerProps) {
160
+ // Явный size на табе важнее размера из <TabsList>; деструктуризация заодно не
161
+ // даёт `size` утечь на нативный <button> как невалидный атрибут.
162
+ const contextSize = React.useContext(TabsSizeContext)
163
+ const resolvedSize = size ?? contextSize
164
+ return (
165
+ <TabsPrimitive.Trigger
166
+ className={cn(TABS_TRIGGER_BASE, TABS_SIZE[resolvedSize], className)}
167
+ {...props}
168
+ >
169
+ {startContent}
170
+ {/* Slottable указывает Slot (asChild), какой ребёнок «настоящий», чтобы
171
+ startContent не ломал слот и оставался слева (как в Badge). */}
172
+ <Slottable>{children}</Slottable>
173
+ </TabsPrimitive.Trigger>
174
+ )
175
+ }
176
+
177
+ export function TabsContent({
178
+ className,
179
+ ...props
180
+ }: React.ComponentProps<typeof TabsPrimitive.Content>) {
181
+ return (
182
+ <TabsPrimitive.Content
183
+ className={cn(
184
+ 'outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
185
+ className,
186
+ )}
187
+ {...props}
188
+ />
189
+ )
190
+ }
@@ -0,0 +1 @@
1
+ export * from './textarea'
@@ -0,0 +1,151 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import { type VariantProps, cva } from 'class-variance-authority'
6
+
7
+ import { cn } from '../../lib/utils'
8
+
9
+ // Многострочное поле по образцу Input: стили несёт ОБЁРТКА (заливка/рамка, скругление,
10
+ // кольцо фокуса через focus-within), а сам <textarea> — прозрачный и без рамок. Слоты
11
+ // start/endContent живут внутри «коробки» и попадают в общую подсветку фокуса. В отличие
12
+ // от Input обёртка выравнивает по НИЗУ (items-end) — при росте поля слоты остаются у
13
+ // последней строки.
14
+ //
15
+ // autoResize: поле само растёт под контент (до maxHeight, дальше внутренний скролл). Форм-
16
+ // агностичен — работает и с react-hook-form `{...register('x')}` (неконтролируемо, ресайз
17
+ // по вводу), и контролируемо через `value` (ресайз по изменению value, ловит программный
18
+ // reset формы).
19
+ const textareaVariants = cva(
20
+ 'flex w-full items-end gap-2.5 text-foreground transition-shadow outline-none focus-within:ring-2 focus-within:ring-ring has-[textarea:disabled]:pointer-events-none has-[textarea:disabled]:opacity-50',
21
+ {
22
+ variants: {
23
+ variant: {
24
+ filled: 'bg-secondary',
25
+ bordered: 'border border-disabled bg-card',
26
+ },
27
+ },
28
+ defaultVariants: {
29
+ variant: 'filled',
30
+ },
31
+ },
32
+ )
33
+
34
+ // Размер — FLUID (padding/шрифт/скругление клампятся 320→1280px), один size = «тир».
35
+ const TEXTAREA_SIZE: Record<'sm' | 'md' | 'lg', string> = {
36
+ sm: 'px-fl-12/14 py-fl-8/10 text-body-s rounded-md',
37
+ md: 'px-fl-14/16 py-fl-10/12 text-body-m rounded-lg',
38
+ lg: 'px-fl-16/20 py-fl-12/16 text-body-l rounded-lg',
39
+ }
40
+
41
+ export interface TextareaProps
42
+ extends
43
+ Omit<React.ComponentProps<'textarea'>, 'size'>,
44
+ VariantProps<typeof textareaVariants> {
45
+ /** Размер: `sm | md | lg`. По умолчанию md. Каждый размер сам fluid-клампится. */
46
+ size?: 'sm' | 'md' | 'lg'
47
+ /** Внешний вид: `filled` (деф., заливка bg-secondary) | `bordered` (рамка на bg-card). */
48
+ variant?: 'filled' | 'bordered'
49
+ /** Класс на внешнюю обёртку (заливка/скругление). `className` идёт на сам <textarea>. */
50
+ containerClassName?: string
51
+ /** Контент перед полем (обычно иконка/кнопка) — прижат к нижней строке. */
52
+ startContent?: React.ReactNode
53
+ /** Контент после поля (обычно иконка/кнопка) — прижат к нижней строке. */
54
+ endContent?: React.ReactNode
55
+ /** Авторост высоты под контент. По умолчанию выкл. */
56
+ autoResize?: boolean
57
+ /** Потолок высоты при autoResize (px). Дальше — внутренний скролл. */
58
+ maxHeight?: number
59
+ }
60
+
61
+ export function Textarea({
62
+ ref,
63
+ className,
64
+ containerClassName,
65
+ size = 'md',
66
+ variant,
67
+ startContent,
68
+ endContent,
69
+ autoResize = false,
70
+ maxHeight,
71
+ rows = 1,
72
+ value,
73
+ onChange,
74
+ ...props
75
+ }: TextareaProps) {
76
+ // Внутренний ref нужен авторо́сту (читает scrollHeight, правит height); сливаем его с
77
+ // внешним ref (напр. от register), чтобы оба указывали на один <textarea>.
78
+ const innerRef = React.useRef<HTMLTextAreaElement>(null)
79
+ const setRefs = React.useCallback(
80
+ (node: HTMLTextAreaElement | null) => {
81
+ innerRef.current = node
82
+ if (typeof ref === 'function') ref(node)
83
+ else if (ref) ref.current = node
84
+ },
85
+ [ref],
86
+ )
87
+
88
+ // Сбрасываем высоту в auto (чтобы scrollHeight мерил реальный контент при удалении
89
+ // строк), затем тянем до контента, но не выше потолка.
90
+ const resize = React.useCallback(() => {
91
+ const el = innerRef.current
92
+ if (!el || !autoResize) return
93
+ el.style.height = 'auto'
94
+ el.style.height = `${
95
+ maxHeight ? Math.min(el.scrollHeight, maxHeight) : el.scrollHeight
96
+ }px`
97
+ }, [autoResize, maxHeight])
98
+
99
+ // Пересчёт на маунт и при изменении value (в т.ч. программный reset формы у
100
+ // контролируемого поля). Неконтролируемый ввод ловим в handleChange ниже.
101
+ React.useEffect(() => {
102
+ resize()
103
+ }, [resize, value])
104
+
105
+ const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
106
+ onChange?.(event)
107
+ // Контролируемое поле ресайзит эффект по [value]; в неконтролируемом (value не
108
+ // задан) эффект на ввод не срабатывает — пересчитываем прямо здесь.
109
+ if (value === undefined) resize()
110
+ }
111
+
112
+ return (
113
+ <div
114
+ className={cn(
115
+ textareaVariants({ variant }),
116
+ TEXTAREA_SIZE[size],
117
+ containerClassName,
118
+ )}
119
+ >
120
+ {startContent ? (
121
+ <span className='inline-flex shrink-0 items-center text-muted-foreground'>
122
+ {startContent}
123
+ </span>
124
+ ) : null}
125
+
126
+ <textarea
127
+ ref={setRefs}
128
+ rows={rows}
129
+ value={value}
130
+ onChange={handleChange}
131
+ // resize-none — рост ведём сами; min-w-0 — чтобы поле ужималось в flex и не
132
+ // выталкивало слоты; overflow-y-auto — скролл при упоре в потолок высоты.
133
+ className={cn(
134
+ 'min-w-0 flex-1 resize-none border-0 bg-transparent p-0 leading-relaxed text-current outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',
135
+ autoResize && 'overflow-y-auto',
136
+ className,
137
+ )}
138
+ style={autoResize && maxHeight ? { maxHeight } : undefined}
139
+ {...props}
140
+ />
141
+
142
+ {endContent ? (
143
+ <span className='inline-flex shrink-0 items-center text-muted-foreground'>
144
+ {endContent}
145
+ </span>
146
+ ) : null}
147
+ </div>
148
+ )
149
+ }
150
+
151
+ export { textareaVariants }
@@ -0,0 +1 @@
1
+ export * from './theme-switch'
@@ -0,0 +1,161 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import { useTheme } from 'next-themes'
6
+
7
+ import { Icon } from '../Icon'
8
+ import { cn } from '../../lib/utils'
9
+
10
+ type ThemeSwitchSize = 'sm' | 'md' | 'lg'
11
+
12
+ // Геометрия по размеру — через responsiveSize (CSS-адаптив без JS-флэша). slot несёт и
13
+ // размер, и font-size (иконка внутри — в em, масштабируется за него). travel = слот
14
+ // (100%) + зазор между слотами; p-1 (отступ) одинаков на всех размерах.
15
+ const TS_GAP: Record<ThemeSwitchSize, string> = {
16
+ sm: 'gap-2',
17
+ md: 'gap-2.5',
18
+ lg: 'gap-3',
19
+ }
20
+ // Слот = fluid-квадрат; высота ThemeSwitch = слот + 2·p-1 (8px) совпадает с min-h-fl
21
+ // контролов: 20/24+8=28/32 (sm), 28/32+8=36/40 (md), 36/40+8=44/48 (lg) — как у Select.
22
+ const TS_SLOT: Record<ThemeSwitchSize, string> = {
23
+ sm: 'size-fl-20/24 text-button-s',
24
+ md: 'size-fl-28/32 text-button-m',
25
+ lg: 'size-fl-36/40 text-button-l',
26
+ }
27
+ const TS_TRAVEL: Record<ThemeSwitchSize, string> = {
28
+ sm: 'translate-x-[calc(100%+0.5rem)]',
29
+ md: 'translate-x-[calc(100%+0.625rem)]',
30
+ lg: 'translate-x-[calc(100%+0.75rem)]',
31
+ }
32
+
33
+ // «Смонтировано ли» через useSyncExternalStore: сервер/гидрация → false, после
34
+ // гидрации → true. Идиоматичнее setState-в-эффекте и не плодит каскадные ререндеры.
35
+ const subscribeNoop = () => () => {}
36
+ const getMountedSnapshot = () => true
37
+ const getMountedServerSnapshot = () => false
38
+
39
+ export interface ThemeSwitchProps {
40
+ className?: string
41
+ /** Размер: `sm | md | lg`. По умолчанию `md`. */
42
+ size?: ThemeSwitchSize
43
+ }
44
+
45
+ // Переключатель light/dark: пилюля с солнцем и луной, тёмный «бегунок» (thumb) стоит
46
+ // под активной иконкой. Тема — через next-themes.
47
+ export function ThemeSwitch({ className, size = 'md' }: ThemeSwitchProps) {
48
+ const { resolvedTheme, setTheme } = useTheme()
49
+
50
+ // next-themes знает тему только на клиенте. На сервере/при гидрации mounted=false
51
+ // → держим light (без hydration mismatch); после гидрации берём реальную тему.
52
+ const mounted = React.useSyncExternalStore(
53
+ subscribeNoop,
54
+ getMountedSnapshot,
55
+ getMountedServerSnapshot,
56
+ )
57
+ const isDark = mounted && resolvedTheme === 'dark'
58
+
59
+ const gap = TS_GAP[size]
60
+ const slot = TS_SLOT[size]
61
+ const travel = TS_TRAVEL[size]
62
+
63
+ // Переключение темы с круговым «раскрытием» новой из точки клика (View Transitions API).
64
+ // Браузер снимает старый кадр, а новая тема «разливается» кругом через clip-path — без
65
+ // глобальных transition (их гасит disableTransitionOnChange), значит без лагов. Фолбэк:
66
+ // нет VT-API или системная «меньше движения» → мгновенное переключение, как раньше.
67
+ const toggleTheme = (event: React.MouseEvent<HTMLButtonElement>) => {
68
+ const next = isDark ? 'light' : 'dark'
69
+ const doc = document as Document & {
70
+ startViewTransition?: (cb: () => void) => { ready: Promise<void> }
71
+ }
72
+ const reduced = window.matchMedia(
73
+ '(prefers-reduced-motion: reduce)',
74
+ ).matches
75
+
76
+ if (!doc.startViewTransition || reduced) {
77
+ setTheme(next)
78
+ return
79
+ }
80
+
81
+ const x = event.clientX
82
+ const y = event.clientY
83
+ // Радиус до самого дальнего угла — чтобы круг гарантированно накрыл весь экран.
84
+ const endRadius = Math.hypot(
85
+ Math.max(x, window.innerWidth - x),
86
+ Math.max(y, window.innerHeight - y),
87
+ )
88
+
89
+ const transition = doc.startViewTransition(() => setTheme(next))
90
+ void transition.ready.then(() => {
91
+ document.documentElement.animate(
92
+ {
93
+ clipPath: [
94
+ `circle(0px at ${x}px ${y}px)`,
95
+ `circle(${endRadius}px at ${x}px ${y}px)`,
96
+ ],
97
+ },
98
+ {
99
+ duration: 450,
100
+ easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
101
+ pseudoElement: '::view-transition-new(root)',
102
+ },
103
+ )
104
+ })
105
+ }
106
+
107
+ const motion =
108
+ 'transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none'
109
+ const iconMotion =
110
+ 'transition-[transform,color] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none'
111
+
112
+ return (
113
+ <button
114
+ type='button'
115
+ role='switch'
116
+ aria-checked={isDark}
117
+ aria-label='Переключить тему'
118
+ onClick={toggleTheme}
119
+ className={cn(
120
+ 'relative inline-flex cursor-pointer items-center rounded-full bg-secondary p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
121
+ gap,
122
+ className,
123
+ )}
124
+ >
125
+ {/* «бегунок»: позиция = padding (left-1 top-1), размер = слот, проезд = слот + зазор */}
126
+ <span
127
+ aria-hidden
128
+ className={cn(
129
+ 'absolute top-1 left-1 rounded-full bg-navy',
130
+ slot,
131
+ motion,
132
+ isDark && travel,
133
+ )}
134
+ />
135
+ {/* Солнце = активно в light (на тёмном бегунке → белое). */}
136
+ <span
137
+ className={cn(
138
+ 'relative z-10 flex items-center justify-center',
139
+ slot,
140
+ iconMotion,
141
+ isDark
142
+ ? '-rotate-90 scale-75 text-muted-foreground'
143
+ : 'rotate-0 scale-100 text-cloud',
144
+ )}
145
+ >
146
+ <Icon type='sun' isCurrentColor size='1.2em' />
147
+ </span>
148
+ {/* Луна = активна в dark; неактивная просто уменьшается. */}
149
+ <span
150
+ className={cn(
151
+ 'relative z-10 flex items-center justify-center',
152
+ slot,
153
+ iconMotion,
154
+ isDark ? 'scale-100 text-cloud' : 'scale-75 text-muted-foreground',
155
+ )}
156
+ >
157
+ <Icon type='moon' isCurrentColor size='1.2em' />
158
+ </span>
159
+ </button>
160
+ )
161
+ }
@@ -0,0 +1 @@
1
+ export * from './toast'