@luxfi/ui 7.4.10 → 7.4.11

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 (78) hide show
  1. package/dist/accordion.cjs +107 -166
  2. package/dist/accordion.d.cts +18 -51
  3. package/dist/accordion.d.ts +18 -51
  4. package/dist/accordion.js +109 -166
  5. package/dist/chrome.cjs +193 -379
  6. package/dist/chrome.js +195 -379
  7. package/dist/collapsible.cjs +35 -26
  8. package/dist/collapsible.d.cts +2 -1
  9. package/dist/collapsible.d.ts +2 -1
  10. package/dist/collapsible.js +34 -24
  11. package/dist/dialog.d.cts +1 -1
  12. package/dist/dialog.d.ts +1 -1
  13. package/dist/drawer.cjs +92 -115
  14. package/dist/drawer.d.cts +43 -10
  15. package/dist/drawer.d.ts +43 -10
  16. package/dist/drawer.js +94 -115
  17. package/dist/field.cjs +60 -109
  18. package/dist/field.d.cts +0 -1
  19. package/dist/field.d.ts +0 -1
  20. package/dist/field.js +61 -109
  21. package/dist/heading.cjs +10 -17
  22. package/dist/heading.d.cts +0 -2
  23. package/dist/heading.d.ts +0 -2
  24. package/dist/heading.js +10 -17
  25. package/dist/icon-button.cjs +82 -82
  26. package/dist/icon-button.d.cts +16 -12
  27. package/dist/icon-button.d.ts +16 -12
  28. package/dist/icon-button.js +82 -82
  29. package/dist/index.cjs +1073 -1419
  30. package/dist/index.d.cts +1 -5
  31. package/dist/index.d.ts +1 -5
  32. package/dist/index.js +1075 -1414
  33. package/dist/input-group.cjs +11 -12
  34. package/dist/input-group.js +11 -12
  35. package/dist/input.cjs +27 -27
  36. package/dist/input.d.cts +21 -1
  37. package/dist/input.d.ts +21 -1
  38. package/dist/input.js +25 -28
  39. package/dist/menu.cjs +160 -271
  40. package/dist/menu.d.cts +41 -57
  41. package/dist/menu.d.ts +41 -57
  42. package/dist/menu.js +161 -251
  43. package/dist/popover.cjs +1 -1
  44. package/dist/popover.js +1 -1
  45. package/dist/select.cjs +40 -39
  46. package/dist/select.d.cts +35 -4
  47. package/dist/select.d.ts +35 -4
  48. package/dist/select.js +40 -38
  49. package/dist/separator.cjs +8 -33
  50. package/dist/separator.js +8 -33
  51. package/dist/tabs.cjs +108 -197
  52. package/dist/tabs.d.cts +16 -60
  53. package/dist/tabs.d.ts +16 -60
  54. package/dist/tabs.js +109 -196
  55. package/dist/textarea.cjs +56 -27
  56. package/dist/textarea.js +57 -28
  57. package/dist/toaster.cjs +97 -79
  58. package/dist/toaster.d.cts +7 -8
  59. package/dist/toaster.d.ts +7 -8
  60. package/dist/toaster.js +78 -80
  61. package/package.json +2 -16
  62. package/src/accordion.tsx +173 -227
  63. package/src/collapsible.tsx +55 -52
  64. package/src/drawer.tsx +123 -115
  65. package/src/field.tsx +68 -124
  66. package/src/heading.tsx +22 -26
  67. package/src/icon-button.tsx +134 -141
  68. package/src/ink.tsx +37 -0
  69. package/src/input-group.tsx +21 -12
  70. package/src/input.tsx +41 -32
  71. package/src/menu.tsx +224 -397
  72. package/src/popover.tsx +1 -1
  73. package/src/select.tsx +80 -59
  74. package/src/separator.tsx +13 -34
  75. package/src/tabs.tsx +166 -296
  76. package/src/textarea.tsx +19 -29
  77. package/src/toaster.tsx +118 -86
  78. package/tokens.css +61 -0
package/src/tabs.tsx CHANGED
@@ -1,20 +1,103 @@
1
- import * as RadixTabs from '@radix-ui/react-tabs';
1
+ import { Tabs, Text, useTabsContext } from '@hanzo/gui';
2
2
  import * as React from 'react';
3
3
 
4
- import { cn } from './utils';
5
-
6
- // ---------------------------------------------------------------------------
7
- // Variant / size / fitted types (match the old Chakra recipe)
8
- // ---------------------------------------------------------------------------
4
+ import { ink } from './ink';
5
+
6
+ // The Lux tab skin, on the gui engine.
7
+ //
8
+ // gui's Tabs already owns everything Radix was imported for: roving focus,
9
+ // `activationMode`, the `data-state="active" | "inactive"` contract, and an
10
+ // `activeStyle` prop that applies while a tab is selected. So this file is a
11
+ // SKIN — a variant table and three wrappers — and nothing else.
12
+ //
13
+ // The styles are inline props rather than `styled()` wrappers, because
14
+ // `Tabs.Tab` is `TabFrame.styleable(...)`: an HOC, not a frame. `styled()` over
15
+ // one does not compile style props to classes — they leak onto the element as
16
+ // lowercase HTML attributes (`backgroundcolor="…"`) and nothing is styled.
17
+ // Extending `Tabs.Tab` would look right, typecheck, and render unstyled.
18
+ //
19
+ // What went with Radix: a `useTabsContext` that read `variant` and `size` off a
20
+ // `data-*` attribute of the nearest ancestor DOM node, inside a `useEffect`. It
21
+ // could only answer AFTER paint, so every trigger rendered one frame in the
22
+ // wrong variant and then corrected itself, and during SSR it could not answer at
23
+ // all. Variant and size are props of the root, so they travel as React context.
9
24
 
10
25
  type TabsVariant = 'solid' | 'secondary' | 'segmented' | 'unstyled';
11
26
  type TabsSize = 'sm' | 'md' | 'free';
12
27
 
28
+ type Style = Record<string, unknown>;
29
+
30
+ /** Variant and size, declared on the root and read by every trigger. */
31
+ const Shape = React.createContext<{ variant: TabsVariant; size: TabsSize; fitted: boolean }>({
32
+ variant: 'solid',
33
+ size: 'md',
34
+ fitted: false,
35
+ });
36
+
37
+ const SIZE: Record<TabsSize, Style> = {
38
+ sm: { height: 32, minWidth: 32, paddingVertical: 4, paddingHorizontal: 12, fontSize: 14 },
39
+ md: { height: 40, minWidth: 40, paddingVertical: 8, paddingHorizontal: 16, fontSize: 16 },
40
+ free: {},
41
+ };
42
+
43
+ const SELECTED_BG = 'var(--color-selected-control-bg)';
44
+ const SELECTED_FG = 'var(--color-selected-control-text)';
45
+
46
+ // Resting look and selected look, per variant, as two plain objects that get
47
+ // MERGED here rather than handed to gui's `activeStyle`.
48
+ //
49
+ // `activeStyle` is gui's own prop for "while this tab is selected", and it does
50
+ // not survive contact with a variant that also styles the resting state: the
51
+ // trigger spreads `activeStyle` and only THEN spreads the caller's remaining
52
+ // props, so any key set in both — `backgroundColor` for solid, `borderColor`
53
+ // for secondary — resolves to the resting value even while selected. Measured:
54
+ // the selected tab's computed background stayed `rgba(0, 0, 0, 0)`, identical
55
+ // to an unselected one, with every behavioural test still passing.
56
+ //
57
+ // Selection is readable instead (`useTabsContext`), so the style is a function
58
+ // of state and one object reaches gui with nothing left to override it.
59
+ const VARIANT: Record<TabsVariant, { rest: Style; active: Style }> = {
60
+ solid: {
61
+ rest: {
62
+ fontWeight: '600',
63
+ borderRadius: 6,
64
+ color: 'var(--color-tabs-solid-fg)',
65
+ hoverStyle: { color: 'var(--color-hover)' },
66
+ },
67
+ active: { backgroundColor: SELECTED_BG, color: SELECTED_FG },
68
+ },
69
+ secondary: {
70
+ rest: {
71
+ fontWeight: '500',
72
+ borderRadius: 6,
73
+ borderWidth: 2,
74
+ borderColor: 'var(--color-tabs-secondary-border)',
75
+ color: 'var(--color-tabs-secondary-fg)',
76
+ hoverStyle: { color: 'var(--color-hover)', borderColor: 'var(--color-hover)' },
77
+ },
78
+ active: { backgroundColor: SELECTED_BG, color: SELECTED_FG, borderColor: 'transparent' },
79
+ },
80
+ segmented: {
81
+ rest: {
82
+ borderWidth: 2,
83
+ borderColor: SELECTED_BG,
84
+ color: 'var(--color-text-primary)',
85
+ hoverStyle: { color: 'var(--color-hover)' },
86
+ },
87
+ active: { backgroundColor: SELECTED_BG, borderColor: SELECTED_BG, color: SELECTED_FG },
88
+ },
89
+ unstyled: { rest: {}, active: {} },
90
+ };
91
+
92
+ const LIST_HEIGHT: Record<TabsSize, number | undefined> = { sm: 32, md: 40, free: undefined };
93
+
13
94
  // ---------------------------------------------------------------------------
14
- // TabsRoot
95
+ // Root
15
96
  // ---------------------------------------------------------------------------
16
97
 
17
- export interface TabsProps extends Omit<React.ComponentPropsWithoutRef<typeof RadixTabs.Root>, 'onValueChange'> {
98
+ // `size` is narrowed away from gui's own token-valued `size` on purpose: a Lux
99
+ // tab bar has three sizes, not the whole size ramp.
100
+ export interface TabsProps extends Omit<React.ComponentProps<typeof Tabs>, 'onValueChange' | 'size'> {
18
101
 
19
102
  /** Visual variant. Default: "solid". */
20
103
  variant?: TabsVariant;
@@ -25,256 +108,99 @@ export interface TabsProps extends Omit<React.ComponentPropsWithoutRef<typeof Ra
25
108
  /** Stretch triggers to fill the list width. */
26
109
  fitted?: boolean;
27
110
 
28
- /**
29
- * @deprecated Chakra-compat. Radix mounts lazily by default (content only
30
- * renders when active). Kept so call-sites don't break at compile time.
31
- */
32
- lazyMount?: boolean;
33
-
34
- /**
35
- * @deprecated Chakra-compat. Radix unmounts inactive content by default.
36
- * Kept so call-sites don't break at compile time.
37
- */
38
- unmountOnExit?: boolean;
39
-
40
- /**
41
- * Chakra-compatible value-change handler.
42
- * Accepts both `(details: { value: string }) => void` (Chakra convention)
43
- * and `(value: string) => void` (Radix convention).
44
- */
45
- onValueChange?: ((details: { value: string }) => void) | ((value: string) => void);
111
+ /** Fires with the newly selected value. */
112
+ onValueChange?: (details: { value: string }) => void;
46
113
  }
47
114
 
48
- const ROOT_SIZE_CLASSES: Record<TabsSize, string> = {
49
- sm: '[--tabs-height:2rem] [--tabs-content-padding:1.5rem]',
50
- md: '[--tabs-height:2.5rem] [--tabs-content-padding:1.5rem]',
51
- free: '',
52
- };
115
+ export const TabsRoot = ({
116
+ variant = 'solid',
117
+ size = 'md',
118
+ fitted = false,
119
+ onValueChange,
120
+ ...rest
121
+ }: TabsProps): React.ReactElement => {
122
+ const shape = React.useMemo(() => ({ variant, size, fitted }), [ variant, size, fitted ]);
53
123
 
54
- export const TabsRoot = React.forwardRef<HTMLDivElement, TabsProps>(
55
- function TabsRoot(props, ref) {
56
- const {
57
- variant = 'solid',
58
- size = 'md',
59
- fitted,
60
- lazyMount: _lazyMount,
61
- unmountOnExit: _unmountOnExit,
62
- onValueChange,
63
- className,
64
- ...rest
65
- } = props;
66
-
67
- // Bridge Chakra-style `onValueChange({value})` to Radix `onValueChange(value)`
68
- const handleValueChange = React.useCallback(
69
- (value: string) => {
70
- if (!onValueChange) return;
71
- // Detect which signature the caller expects by trying the Chakra shape.
72
- // Both signatures are valid TS, so we call with the object form since
73
- // all existing consumers use `({ value })`.
74
- (onValueChange as (details: { value: string }) => void)({ value });
75
- },
76
- [ onValueChange ],
77
- );
78
-
79
- return (
80
- <RadixTabs.Root
81
- ref={ ref }
82
- data-variant={ variant }
83
- data-size={ size }
84
- data-fitted={ fitted ? '' : undefined }
85
- className={ cn(
86
- 'relative',
87
- ROOT_SIZE_CLASSES[size],
88
- className,
89
- ) }
90
- onValueChange={ onValueChange ? handleValueChange : undefined }
124
+ return (
125
+ <Shape.Provider value={ shape }>
126
+ <Tabs
127
+ unstyled
128
+ position="relative"
129
+ orientation="horizontal"
130
+ onValueChange={ onValueChange ? (value: string) => onValueChange({ value }) : undefined }
91
131
  { ...rest }
92
132
  />
93
- );
94
- },
95
- );
133
+ </Shape.Provider>
134
+ );
135
+ };
96
136
 
97
137
  // ---------------------------------------------------------------------------
98
- // TabsList
138
+ // List
99
139
  // ---------------------------------------------------------------------------
100
140
 
101
- export interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
102
- // Radix pass-through
103
- asChild?: boolean;
104
- loop?: boolean;
105
- // Legacy Chakra style-prop shims
106
- flexWrap?: string;
107
- alignItems?: string;
108
- whiteSpace?: string;
109
- bgColor?: string;
110
- marginBottom?: number | string;
111
- mx?: string | Record<string, string>;
112
- px?: string | Record<string, string>;
113
- w?: string | Record<string, string>;
114
- overflowX?: string | Record<string, string>;
115
- overscrollBehaviorX?: string;
116
- css?: Record<string, unknown>;
117
- position?: string;
118
- boxShadow?: string | Record<string, string>;
119
- top?: number | string;
120
- zIndex?: string | Record<string, string>;
121
- }
141
+ export type TabsListProps = React.ComponentProps<typeof Tabs.List>;
122
142
 
123
- export const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
124
- function TabsList(props, ref) {
125
- const {
126
- className,
127
- // Strip Chakra style props
128
- flexWrap: _flexWrap, alignItems: _alignItems, whiteSpace: _whiteSpace,
129
- bgColor: _bgColor, marginBottom: _marginBottom, mx: _mx, px: _px,
130
- w: _w, overflowX: _overflowX, overscrollBehaviorX: _overscrollBehaviorX,
131
- css: _css, position: _position, boxShadow: _boxShadow, top: _top,
132
- zIndex: _zIndex,
133
- style: styleProp,
134
- asChild, loop,
135
- ...rest
136
- } = props;
137
-
138
- return (
139
- <RadixTabs.List
140
- ref={ ref }
141
- asChild={ asChild }
142
- loop={ loop }
143
- className={ cn(
144
- 'inline-flex w-full relative isolate flex-row',
145
- 'min-h-[var(--tabs-height,2.5rem)]',
146
- className,
147
- ) }
148
- style={ styleProp }
149
- { ...rest }
150
- />
151
- );
152
- },
153
- );
143
+ export const TabsList = (props: TabsListProps): React.ReactElement => {
144
+ const { size } = React.useContext(Shape);
145
+ return (
146
+ <Tabs.List
147
+ unstyled
148
+ width="100%"
149
+ position="relative"
150
+ flexDirection="row"
151
+ backgroundColor="transparent"
152
+ minHeight={ LIST_HEIGHT[size] }
153
+ { ...props }
154
+ />
155
+ );
156
+ };
154
157
 
155
158
  // ---------------------------------------------------------------------------
156
- // TabsTrigger
159
+ // Trigger
157
160
  // ---------------------------------------------------------------------------
158
161
 
159
- export interface TabsTriggerProps extends Omit<React.ComponentPropsWithoutRef<typeof RadixTabs.Trigger>, 'dir'> {
160
- // Legacy Chakra style-prop shims
161
- scrollSnapAlign?: string;
162
- flexShrink?: number;
163
- bgColor?: string | Record<string, string>;
164
- w?: string;
165
- py?: string;
166
- borderRadius?: string;
167
- fontWeight?: string;
168
- color?: string;
169
- _hover?: Record<string, string>;
170
- position?: string;
171
- top?: string;
172
- left?: string;
173
- visibility?: string;
174
- }
162
+ export type TabsTriggerProps = React.ComponentProps<typeof Tabs.Tab>;
175
163
 
176
- const TRIGGER_BASE =
177
- 'outline-none min-w-[var(--tabs-height,2.5rem)] h-[var(--tabs-height,2.5rem)]' +
178
- ' flex items-center relative cursor-pointer gap-2' +
179
- ' focus-visible:z-[1] focus-visible:outline-2 focus-visible:outline-offset-0' +
180
- ' disabled:cursor-not-allowed disabled:opacity-50';
164
+ export const TabsTrigger = ({ children, ...props }: TabsTriggerProps): React.ReactElement => {
165
+ const { variant, size, fitted } = React.useContext(Shape);
166
+ const { rest, active } = VARIANT[variant];
167
+ const selected = useTabsContext().value === props.value;
181
168
 
182
- const TRIGGER_SIZE_CLASSES: Record<TabsSize, string> = {
183
- sm: 'py-1 px-3 text-sm',
184
- md: 'py-2 px-4 text-base',
185
- free: '',
186
- };
187
-
188
- const TRIGGER_VARIANT_CLASSES: Record<TabsVariant, string> = {
189
- solid:
190
- 'font-semibold gap-1 rounded-md bg-transparent' +
191
- ' text-tabs-solid-fg' +
192
- ' data-[state=active]:bg-selected-control-bg data-[state=active]:text-selected-control-text' +
193
- ' data-[state=active]:hover:text-selected-control-text' +
194
- ' hover:text-hover',
195
- secondary:
196
- 'font-medium bg-transparent' +
197
- ' text-tabs-secondary-fg' +
198
- ' border-2 border-solid border-tabs-secondary-border rounded-md' +
199
- ' data-[state=active]:bg-selected-control-bg data-[state=active]:text-selected-control-text' +
200
- ' data-[state=active]:border-transparent data-[state=active]:hover:border-transparent' +
201
- ' hover:text-hover hover:border-hover',
202
- segmented:
203
- 'bg-transparent' +
204
- ' text-text-primary' +
205
- ' border-2 border-solid border-selected-control-bg' +
206
- ' hover:text-hover' +
207
- ' data-[state=active]:text-selected-control-text data-[state=active]:bg-selected-control-bg' +
208
- ' data-[state=active]:border-selected-control-bg' +
209
- ' data-[state=active]:hover:text-selected-control-text',
210
- unstyled: '',
169
+ return (
170
+ <Tabs.Tab
171
+ unstyled
172
+ flexDirection="row"
173
+ alignItems="center"
174
+ justifyContent="center"
175
+ position="relative"
176
+ cursor="pointer"
177
+ gap={ 8 }
178
+ borderWidth={ 0 }
179
+ { ...SIZE[size] }
180
+ { ...rest }
181
+ { ...(selected ? active : null) }
182
+ { ...(fitted ? { flex: 1 } : null) }
183
+ { ...props }
184
+ >
185
+ { ink(children, undefined, { fontSize: 'inherit', color: 'inherit', fontWeight: 'inherit' }) }
186
+ </Tabs.Tab>
187
+ );
211
188
  };
212
189
 
213
- export const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
214
- function TabsTrigger(props, ref) {
215
- const {
216
- className,
217
- // Strip Chakra style props
218
- scrollSnapAlign: _scrollSnapAlign, flexShrink: _flexShrink, bgColor: _bgColor,
219
- w: _w, py: _py, borderRadius: _borderRadius, fontWeight: _fontWeight,
220
- color: _color, _hover, position: _position, top: _top, left: _left,
221
- visibility: _visibility,
222
- ...rest
223
- } = props;
224
-
225
- // Read variant / size from the closest TabsRoot via DOM data attributes.
226
- // We could use React context, but reading from data-* keeps the API surface
227
- // identical to Chakra where recipe tokens cascade automatically.
228
- const internalRef = React.useRef<HTMLButtonElement>(null);
229
- const mergedRef = useMergedRef(ref, internalRef);
230
- const { variant, size, fitted } = useTabsContext(internalRef);
231
-
232
- return (
233
- <RadixTabs.Trigger
234
- ref={ mergedRef }
235
- className={ cn(
236
- 'group',
237
- TRIGGER_BASE,
238
- TRIGGER_SIZE_CLASSES[size],
239
- TRIGGER_VARIANT_CLASSES[variant],
240
- fitted && 'flex-1 text-center justify-center',
241
- className,
242
- ) }
243
- { ...rest }
244
- />
245
- );
246
- },
247
- );
248
-
249
190
  // ---------------------------------------------------------------------------
250
- // TabsContent
191
+ // Content
251
192
  // ---------------------------------------------------------------------------
252
193
 
253
- export interface TabsContentProps extends React.ComponentPropsWithoutRef<typeof RadixTabs.Content> {
254
- // Legacy Chakra style-prop shims
255
- padding?: number | string;
256
- }
194
+ export type TabsContentProps = React.ComponentProps<typeof Tabs.Content>;
257
195
 
258
- export const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
259
- function TabsContent(props, ref) {
260
- const { className, padding: _padding, ...rest } = props;
261
-
262
- return (
263
- <RadixTabs.Content
264
- ref={ ref }
265
- className={ cn(
266
- 'w-full pt-[var(--tabs-content-padding,1.5rem)]',
267
- 'focus-visible:outline-2 focus-visible:outline-offset-[-2px]',
268
- className,
269
- ) }
270
- { ...rest }
271
- />
272
- );
273
- },
196
+ export const TabsContent = ({ children, ...props }: TabsContentProps): React.ReactElement => (
197
+ <Tabs.Content unstyled width="100%" paddingTop={ 24 } backgroundColor="transparent" { ...props }>
198
+ { ink(children) }
199
+ </Tabs.Content>
274
200
  );
275
201
 
276
202
  // ---------------------------------------------------------------------------
277
- // TabsCounter
203
+ // Counter
278
204
  // ---------------------------------------------------------------------------
279
205
 
280
206
  export interface TabsCounterProps {
@@ -283,72 +209,16 @@ export interface TabsCounterProps {
283
209
 
284
210
  const COUNTER_OVERLOAD = 50;
285
211
 
212
+ /**
213
+ * The count beside a tab label. Zero is deliberately dimmer than a real count —
214
+ * "nothing here" should read quieter than "three things here".
215
+ */
286
216
  export const TabsCounter = ({ count }: TabsCounterProps): React.ReactElement | null => {
287
- if (count === undefined || count === null) {
288
- return null;
289
- }
217
+ if (count === undefined || count === null) return null;
290
218
 
291
219
  return (
292
- <span
293
- className={ cn(
294
- 'group-hover:text-inherit',
295
- count > 0 ?
296
- 'text-text-secondary' :
297
- 'text-[rgba(16,17,18,0.24)] dark:text-[rgba(255,255,255,0.24)]',
298
- ) }
299
- >
220
+ <Text color={ (count > 0 ? 'var(--color-text-secondary)' : 'var(--color-text-disabled)') as never }>
300
221
  { count > COUNTER_OVERLOAD ? `${ COUNTER_OVERLOAD }+` : count }
301
- </span>
222
+ </Text>
302
223
  );
303
224
  };
304
-
305
- // ---------------------------------------------------------------------------
306
- // Helpers
307
- // ---------------------------------------------------------------------------
308
-
309
- /** Read variant / size / fitted from the nearest ancestor [data-variant] element. */
310
- function useTabsContext(triggerRef: React.RefObject<HTMLButtonElement | null>): {
311
- variant: TabsVariant;
312
- size: TabsSize;
313
- fitted: boolean;
314
- } {
315
- const [ ctx, setCtx ] = React.useState<{
316
- variant: TabsVariant;
317
- size: TabsSize;
318
- fitted: boolean;
319
- }>({ variant: 'solid', size: 'md', fitted: false });
320
-
321
- React.useEffect(() => {
322
- const el = triggerRef.current;
323
- if (!el) return;
324
- const root = el.closest('[data-variant]');
325
- if (!root) return;
326
- setCtx({
327
- variant: (root.getAttribute('data-variant') as TabsVariant) ?? 'solid',
328
- size: (root.getAttribute('data-size') as TabsSize) ?? 'md',
329
- fitted: root.hasAttribute('data-fitted'),
330
- });
331
- }, [ triggerRef ]);
332
-
333
- return ctx;
334
- }
335
-
336
- /** Merge multiple refs into one callback ref. */
337
- function useMergedRef<T>(
338
- ...refs: Array<React.Ref<T> | undefined>
339
- ): React.RefCallback<T> {
340
- return React.useCallback(
341
- (instance: T | null) => {
342
- for (const ref of refs) {
343
- if (!ref) continue;
344
- if (typeof ref === 'function') {
345
- ref(instance);
346
- } else {
347
- (ref as React.MutableRefObject<T | null>).current = instance;
348
- }
349
- }
350
- },
351
- // eslint-disable-next-line react-hooks/exhaustive-deps
352
- refs,
353
- );
354
- }
package/src/textarea.tsx CHANGED
@@ -1,33 +1,11 @@
1
1
  import { TextArea as GuiTextArea } from '@hanzo/gui';
2
2
  import React from 'react';
3
3
 
4
- import { cn } from './utils';
4
+ import { CONTROL, VARIANT } from './input';
5
5
 
6
- const TEXTAREA_BASE = [
7
- 'w-full appearance-none outline-none transition-colors resize-y',
8
- 'bg-[var(--color-input-bg)] text-[var(--color-input-fg)] border-[var(--color-input-border)]',
9
- 'placeholder:text-[var(--color-input-placeholder,theme(colors.gray.400))]',
10
- 'hover:border-[var(--color-input-border-hover,theme(colors.gray.400))]',
11
- 'focus:border-[var(--color-input-border-focus,theme(colors.blue.500))]',
12
- 'focus:placeholder:text-[var(--color-input-placeholder-focus,theme(colors.gray.300))]',
13
- 'disabled:opacity-40 disabled:cursor-not-allowed',
14
- 'read-only:opacity-70',
15
- 'data-[invalid]:border-[var(--color-input-border-invalid,theme(colors.red.500))]',
16
- ].join(' ');
17
-
18
- const TEXTAREA_SIZE_CLASSES: Record<string, string> = {
19
- xs: 'px-2 py-1 text-xs rounded',
20
- sm: 'px-3 py-2 text-sm rounded-md',
21
- md: 'px-4 py-2 text-base rounded-md',
22
- lg: 'px-4 py-3 text-lg rounded-lg',
23
- '2xl': 'px-4 py-3 text-xl rounded-lg',
24
- };
25
-
26
- const TEXTAREA_VARIANT_CLASSES: Record<string, string> = {
27
- outline: 'border border-solid',
28
- filled: 'border-0 bg-[var(--color-input-filled-bg,theme(colors.gray.100))]',
29
- unstyled: 'border-0 bg-transparent p-0',
30
- };
6
+ // gui's TextArea — multiline TextInput on native, `<textarea>` on web. The
7
+ // frame, the variants and the placeholder rule are the Input's: a textarea is a
8
+ // text control that wraps, not a second design.
31
9
 
32
10
  type TextareaSize = 'xs' | 'sm' | 'md' | 'lg' | '2xl';
33
11
  type TextareaVariant = 'outline' | 'filled' | 'unstyled';
@@ -35,12 +13,20 @@ type TextareaVariant = 'outline' | 'filled' | 'unstyled';
35
13
  export interface TextareaProps extends Omit<React.ComponentPropsWithRef<'textarea'>, 'size'> {
36
14
  readonly size?: TextareaSize;
37
15
  readonly variant?: TextareaVariant;
16
+
38
17
  /** Tamagui / React-Native convention — bridged to native `onChange` (see Input). */
39
18
  readonly onChangeText?: (text: string) => void;
40
19
  }
41
20
 
42
- // gui's TextArea multiline TextInput on native, `<textarea>` on web — wearing
43
- // the Lux size/variant classes.
21
+ /** Same ramp as Input, but a textarea is sized by padding, not by height. */
22
+ const SIZE: Record<TextareaSize, Record<string, number>> = {
23
+ xs: { paddingHorizontal: 8, paddingVertical: 4, fontSize: 12, borderRadius: 4 },
24
+ sm: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 14, borderRadius: 6 },
25
+ md: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 16, borderRadius: 6 },
26
+ lg: { paddingHorizontal: 16, paddingVertical: 12, fontSize: 18, borderRadius: 8 },
27
+ '2xl': { paddingHorizontal: 16, paddingVertical: 12, fontSize: 20, borderRadius: 8 },
28
+ };
29
+
44
30
  export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
45
31
  ({ size = 'md', variant = 'outline', className, onChange, onChangeText, ...rest }, ref) => {
46
32
  const handleChange = React.useCallback(
@@ -50,11 +36,15 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
50
36
  },
51
37
  [ onChange, onChangeText ],
52
38
  );
39
+
53
40
  return (
54
41
  <GuiTextArea
55
42
  ref={ ref as never }
56
43
  unstyled
57
- className={ cn(TEXTAREA_BASE, TEXTAREA_SIZE_CLASSES[size], TEXTAREA_VARIANT_CLASSES[variant], className) }
44
+ className={ className ? `lux-control ${ className }` : 'lux-control' }
45
+ { ...CONTROL }
46
+ { ...SIZE[size] }
47
+ { ...VARIANT[variant] }
58
48
  { ...(rest as object) }
59
49
  onChange={ handleChange as never }
60
50
  />