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.
@@ -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
+ }