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.
- package/README.md +168 -0
- package/bin/cli.mjs +136 -0
- package/fonts/Manrope/Manrope-VariableFont_wght.woff2 +0 -0
- package/fonts/Manrope/OFL.txt +93 -0
- package/fonts/index.ts +30 -0
- package/package.json +77 -0
- package/src/config.ts +11 -0
- package/src/hooks/index.ts +14 -0
- package/src/hooks/useAutoHideOnScroll.ts +26 -0
- package/src/hooks/useCountdown.ts +44 -0
- package/src/hooks/useDevice.ts +63 -0
- package/src/hooks/useIsTelegramMiniApp.ts +17 -0
- package/src/hooks/useIsTouch.ts +23 -0
- package/src/hooks/useKeyboardOpen.ts +35 -0
- package/src/hooks/useMediaQuery.ts +24 -0
- package/src/hooks/usePlatform.ts +102 -0
- package/src/hooks/useQueryParams.tsx +70 -0
- package/src/hooks/useRevealOnce.ts +21 -0
- package/src/hooks/useScrolledPast.ts +19 -0
- package/src/hooks/useSettleOnce.ts +26 -0
- package/src/hooks/useTelegram.tsx +57 -0
- package/src/i18n/index.tsx +92 -0
- package/src/index.ts +35 -0
- package/src/lib/device.ts +28 -0
- package/src/lib/platform.ts +19 -0
- package/src/lib/utils.ts +36 -0
- package/src/logo/LogoFull.tsx +59 -0
- package/src/logo/LogoMark.tsx +31 -0
- package/src/logo/index.ts +2 -0
- package/src/next.ts +13 -0
- package/src/server.ts +4 -0
- package/src/theme-color-sync.tsx +43 -0
- package/src/theme-provider.tsx +31 -0
- package/src/types/css.d.ts +6 -0
- package/src/types/globals.d.ts +47 -0
- package/src/ui/Icon/Icon.module.css +25 -0
- package/src/ui/Icon/Icon.tsx +45 -0
- package/src/ui/Icon/Icon.types.ts +60 -0
- package/src/ui/Icon/data/brand.data.tsx +75 -0
- package/src/ui/Icon/data/index.ts +2 -0
- package/src/ui/Icon/data/ui.data.tsx +780 -0
- package/src/ui/Icon/index.ts +3 -0
- package/src/ui/badge/badge.tsx +185 -0
- package/src/ui/badge/index.ts +1 -0
- package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -0
- package/src/ui/breadcrumbs/index.ts +1 -0
- package/src/ui/burger-icon/burger-icon.tsx +22 -0
- package/src/ui/burger-icon/index.ts +1 -0
- package/src/ui/button/button.tsx +179 -0
- package/src/ui/button/index.ts +1 -0
- package/src/ui/card/card.tsx +100 -0
- package/src/ui/card/index.ts +1 -0
- package/src/ui/confirm/confirm-dialog-host.tsx +89 -0
- package/src/ui/confirm/index.ts +1 -0
- package/src/ui/count-up/count-up.tsx +96 -0
- package/src/ui/count-up/index.ts +1 -0
- package/src/ui/data-list/data-list.tsx +331 -0
- package/src/ui/data-list/index.ts +1 -0
- package/src/ui/drawer/drawer.tsx +166 -0
- package/src/ui/drawer/index.ts +1 -0
- package/src/ui/flag/flag.data.ts +240 -0
- package/src/ui/flag/flag.tsx +57 -0
- package/src/ui/flag/index.ts +1 -0
- package/src/ui/image/image.tsx +67 -0
- package/src/ui/image/index.ts +1 -0
- package/src/ui/input/amount-input.tsx +55 -0
- package/src/ui/input/index.ts +2 -0
- package/src/ui/input/input.tsx +391 -0
- package/src/ui/label/index.ts +1 -0
- package/src/ui/label/label.tsx +34 -0
- package/src/ui/method-card/index.ts +1 -0
- package/src/ui/method-card/method-card.tsx +101 -0
- package/src/ui/modal/index.ts +1 -0
- package/src/ui/modal/modal.tsx +128 -0
- package/src/ui/otp-input/index.ts +1 -0
- package/src/ui/otp-input/otp-input.tsx +78 -0
- package/src/ui/pagination/index.ts +1 -0
- package/src/ui/pagination/pagination.tsx +138 -0
- package/src/ui/radio-group/index.ts +1 -0
- package/src/ui/radio-group/radio-group.tsx +81 -0
- package/src/ui/select/index.ts +1 -0
- package/src/ui/select/select.tsx +215 -0
- package/src/ui/separator/index.ts +1 -0
- package/src/ui/separator/separator.tsx +54 -0
- package/src/ui/skeleton/index.ts +1 -0
- package/src/ui/skeleton/skeleton.tsx +44 -0
- package/src/ui/spinner/index.ts +1 -0
- package/src/ui/spinner/spinner.tsx +33 -0
- package/src/ui/switch/index.ts +1 -0
- package/src/ui/switch/switch.tsx +48 -0
- package/src/ui/tabs/index.ts +1 -0
- package/src/ui/tabs/tabs.tsx +190 -0
- package/src/ui/textarea/index.ts +1 -0
- package/src/ui/textarea/textarea.tsx +151 -0
- package/src/ui/theme-switch/index.ts +1 -0
- package/src/ui/theme-switch/theme-switch.tsx +161 -0
- package/src/ui/toast/index.ts +1 -0
- package/src/ui/toast/toast.tsx +159 -0
- package/src/utils/api/stringFormat.ts +5 -0
- package/src/utils/clearAllCookies.ts +7 -0
- package/src/utils/clsx.ts +18 -0
- package/src/utils/confirm.ts +48 -0
- package/src/utils/copy-to-clipboard.ts +12 -0
- package/src/utils/date/convertTimestamp.ts +44 -0
- package/src/utils/date/formatCountdown.ts +9 -0
- package/src/utils/date/formatDayLabel.ts +50 -0
- package/src/utils/date/formatRelativeDateTime.ts +34 -0
- package/src/utils/date/locales.ts +9 -0
- package/src/utils/form/validationSchema.ts +46 -0
- package/src/utils/getByKeyString.ts +15 -0
- package/src/utils/getCssVar.ts +2 -0
- package/src/utils/getDeepType.ts +3 -0
- package/src/utils/getQueryParams.ts +21 -0
- package/src/utils/inAppBrowser.ts +151 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/isRTL.ts +29 -0
- package/src/utils/notify.ts +46 -0
- package/src/utils/openExternal.ts +27 -0
- package/src/utils/system/getMillisecondsPeriod.ts +31 -0
- package/src/utils/validation/authValidators.ts +23 -0
- package/src/utils/validation/refCode.ts +30 -0
- package/styles/base.css +97 -0
- package/styles/brand.css +36 -0
- package/styles/index.css +19 -0
- package/styles/motion.css +207 -0
- package/styles/semantic.css +151 -0
- package/styles/theme.css +143 -0
- 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'
|