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/tabs/tabs.tsx
CHANGED
|
@@ -1,190 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Копирует текст без необработанного Promise rejection, если браузер запретил clipboard.
|
|
3
|
-
* Возвращаем результат, чтобы вызывающий UI при необходимости мог показать уведомление.
|
|
4
|
-
*/
|
|
5
|
-
export async function copyToClipboard(value: string): Promise<boolean> {
|
|
6
|
-
try {
|
|
7
|
-
await navigator.clipboard.writeText(value)
|
|
8
|
-
return true
|
|
9
|
-
} catch {
|
|
10
|
-
return false
|
|
11
|
-
}
|
|
12
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Копирует текст без необработанного Promise rejection, если браузер запретил clipboard.
|
|
3
|
+
* Возвращаем результат, чтобы вызывающий UI при необходимости мог показать уведомление.
|
|
4
|
+
*/
|
|
5
|
+
export async function copyToClipboard(value: string): Promise<boolean> {
|
|
6
|
+
try {
|
|
7
|
+
await navigator.clipboard.writeText(value)
|
|
8
|
+
return true
|
|
9
|
+
} catch {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
}
|