@luxfi/ui 7.4.9 → 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 (82) 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/next.cjs +6 -10
  44. package/dist/next.js +5 -9
  45. package/dist/popover.cjs +1 -1
  46. package/dist/popover.js +1 -1
  47. package/dist/select.cjs +40 -39
  48. package/dist/select.d.cts +35 -4
  49. package/dist/select.d.ts +35 -4
  50. package/dist/select.js +40 -38
  51. package/dist/separator.cjs +8 -33
  52. package/dist/separator.js +8 -33
  53. package/dist/tabs.cjs +108 -197
  54. package/dist/tabs.d.cts +16 -60
  55. package/dist/tabs.d.ts +16 -60
  56. package/dist/tabs.js +109 -196
  57. package/dist/textarea.cjs +56 -27
  58. package/dist/textarea.js +57 -28
  59. package/dist/toaster.cjs +97 -79
  60. package/dist/toaster.d.cts +7 -8
  61. package/dist/toaster.d.ts +7 -8
  62. package/dist/toaster.js +78 -80
  63. package/package.json +2 -16
  64. package/src/accordion.tsx +173 -227
  65. package/src/collapsible.tsx +55 -52
  66. package/src/drawer.tsx +123 -115
  67. package/src/engine.ts +0 -25
  68. package/src/field.tsx +68 -124
  69. package/src/heading.tsx +22 -26
  70. package/src/icon-button.tsx +134 -141
  71. package/src/ink.tsx +37 -0
  72. package/src/input-group.tsx +21 -12
  73. package/src/input.tsx +41 -32
  74. package/src/menu.tsx +224 -397
  75. package/src/next.ts +32 -1
  76. package/src/popover.tsx +1 -1
  77. package/src/select.tsx +80 -59
  78. package/src/separator.tsx +13 -34
  79. package/src/tabs.tsx +166 -296
  80. package/src/textarea.tsx +19 -29
  81. package/src/toaster.tsx +118 -86
  82. package/tokens.css +61 -0
package/src/drawer.tsx CHANGED
@@ -1,11 +1,9 @@
1
- import { Sheet as GuiSheet } from '@hanzo/gui';
2
- import * as RadixDialog from '@radix-ui/react-dialog';
1
+ import { Dialog, Sheet as GuiSheet, View, XStack, YStack } from '@hanzo/gui';
3
2
  import * as React from 'react';
4
3
 
5
- import { useIsNarrow } from './use-narrow';
6
- import { cn } from './utils';
7
-
8
4
  import { CloseButton } from './close-button';
5
+ import { ink } from './ink';
6
+ import { useIsNarrow } from './use-narrow';
9
7
 
10
8
  // A drawer is a side panel on a wide screen and a bottom sheet on a phone. Below
11
9
  // `NARROW_PX` the content is presented by gui's Sheet — draggable, with
@@ -13,6 +11,16 @@ import { CloseButton } from './close-button';
13
11
  // `position: fixed` panel with a slide-in keyframe cannot fake. Same components,
14
12
  // same props, at every call site. The breakpoint itself lives in ./use-narrow,
15
13
  // so "is this phone-sized?" has one answer across the package.
14
+ //
15
+ // Both presentations now come from the same engine: the wide panel is gui's
16
+ // Dialog where it used to be Radix's. That is the whole reason the root has to
17
+ // own its open state — it did already, because the Sheet lives outside the
18
+ // dialog tree and needs the same state, and now both sides read it.
19
+ //
20
+ // The slide is `enterStyle` / `exitStyle` on the content, driven by the CSS
21
+ // animation driver that lux.config feeds the engine. It is not decoration: the
22
+ // panel is `position: fixed` at the edge it flies in from, so without the
23
+ // transform it appears instantly, fully formed, which reads as a repaint bug.
16
24
 
17
25
  // ─── DrawerRoot ─────────────────────────────────────────────────
18
26
 
@@ -21,46 +29,44 @@ export interface DrawerRootProps {
21
29
  open?: boolean;
22
30
  defaultOpen?: boolean;
23
31
  onOpenChange?: (details: { open: boolean }) => void;
24
- placement?: 'left' | 'right' | 'top' | 'bottom';
32
+ placement?: Placement;
25
33
  initialFocusEl?: (() => HTMLElement | null) | React.RefObject<HTMLElement>;
26
34
  lazyMount?: boolean;
27
35
  unmountOnExit?: boolean;
28
36
  modal?: boolean;
29
- size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
37
+ size?: Size;
30
38
  }
31
39
 
32
- const DrawerPlacementContext = React.createContext<string>('right');
33
- const DrawerSizeContext = React.createContext<string>('md');
40
+ type Placement = 'left' | 'right' | 'top' | 'bottom';
41
+ type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
42
+
43
+ const DrawerPlacementContext = React.createContext<Placement>('right');
44
+ const DrawerSizeContext = React.createContext<Size>('md');
34
45
 
35
46
  interface DrawerStateValue {
36
47
  readonly open: boolean;
37
48
  readonly setOpen: (next: boolean) => void;
38
49
  readonly modal: boolean;
50
+ readonly narrow: boolean;
39
51
  }
40
52
 
41
53
  const DrawerStateContext = React.createContext<DrawerStateValue>({
42
54
  open: false,
43
55
  setOpen: () => { /* noop */ },
44
56
  modal: true,
57
+ narrow: false,
45
58
  });
46
59
 
47
60
  export const DrawerRoot = (props: DrawerRootProps) => {
48
61
  const {
49
- children,
50
- open,
51
- defaultOpen,
52
- onOpenChange,
53
- placement = 'right',
54
- modal = true,
55
- size = 'md',
62
+ children, open, defaultOpen, onOpenChange,
63
+ placement = 'right', modal = true, size = 'md',
56
64
  } = props;
57
65
 
58
- // The root owns the open state (controllable) rather than leaving it inside
59
- // Radix, because the phone presentation is a gui Sheet that lives outside the
60
- // dialog tree and needs the same state.
61
66
  const [ uncontrolled, setUncontrolled ] = React.useState(defaultOpen ?? false);
62
67
  const isControlled = open !== undefined;
63
68
  const isOpen = isControlled ? open : uncontrolled;
69
+ const narrow = useIsNarrow();
64
70
 
65
71
  const setOpen = React.useCallback((nextOpen: boolean) => {
66
72
  if (!isControlled) setUncontrolled(nextOpen);
@@ -68,21 +74,26 @@ export const DrawerRoot = (props: DrawerRootProps) => {
68
74
  }, [ isControlled, onOpenChange ]);
69
75
 
70
76
  const state = React.useMemo(
71
- () => ({ open: isOpen, setOpen, modal }),
72
- [ isOpen, setOpen, modal ],
77
+ () => ({ open: isOpen, setOpen, modal, narrow }),
78
+ [ isOpen, setOpen, modal, narrow ],
73
79
  );
74
80
 
75
81
  return (
76
82
  <DrawerPlacementContext.Provider value={ placement }>
77
83
  <DrawerSizeContext.Provider value={ size }>
78
84
  <DrawerStateContext.Provider value={ state }>
79
- <RadixDialog.Root
80
- open={ isOpen }
81
- onOpenChange={ setOpen }
82
- modal={ modal }
83
- >
84
- { children }
85
- </RadixDialog.Root>
85
+ { /* WHICH presentation is one decision, and it is made here.
86
+ A gui Sheet inside a gui Dialog is not a sheet in a dialog: the
87
+ Dialog publishes a sheet controller and adopts any Sheet beneath
88
+ it, so the phone panel stopped rendering entirely the moment the
89
+ wide path became a gui Dialog. On a phone there is no dialog —
90
+ the Sheet IS the presentation — so no Dialog is mounted at all,
91
+ and the trigger below reads the same flag. */ }
92
+ { narrow ? children : (
93
+ <Dialog open={ isOpen } onOpenChange={ setOpen } modal={ modal }>
94
+ { children }
95
+ </Dialog>
96
+ ) }
86
97
  </DrawerStateContext.Provider>
87
98
  </DrawerSizeContext.Provider>
88
99
  </DrawerPlacementContext.Provider>
@@ -98,45 +109,35 @@ export interface DrawerContentProps extends React.ComponentPropsWithoutRef<'div'
98
109
  backdrop?: boolean;
99
110
  }
100
111
 
101
- const PLACEMENT_CLASSES: Record<string, string> = {
102
- right: [
103
- 'inset-y-0 right-0',
104
- 'data-[state=open]:animate-in data-[state=open]:slide-in-from-right',
105
- 'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right',
106
- ].join(' '),
107
- left: [
108
- 'inset-y-0 left-0',
109
- 'data-[state=open]:animate-in data-[state=open]:slide-in-from-left',
110
- 'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left',
111
- ].join(' '),
112
- top: [
113
- 'inset-x-0 top-0',
114
- 'data-[state=open]:animate-in data-[state=open]:slide-in-from-top',
115
- 'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top',
116
- ].join(' '),
117
- bottom: [
118
- 'inset-x-0 bottom-0',
119
- 'data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom',
120
- 'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom',
121
- ].join(' '),
112
+ /** Where the panel is pinned, and which way it flies in from. */
113
+ const ANCHOR: Record<Placement, Record<string, unknown>> = {
114
+ right: { top: 0, bottom: 0, right: 0, enterStyle: { x: 24, opacity: 0 }, exitStyle: { x: 24, opacity: 0 } },
115
+ left: { top: 0, bottom: 0, left: 0, enterStyle: { x: -24, opacity: 0 }, exitStyle: { x: -24, opacity: 0 } },
116
+ top: { left: 0, right: 0, top: 0, enterStyle: { y: -24, opacity: 0 }, exitStyle: { y: -24, opacity: 0 } },
117
+ bottom: { left: 0, right: 0, bottom: 0, enterStyle: { y: 24, opacity: 0 }, exitStyle: { y: 24, opacity: 0 } },
122
118
  };
123
119
 
124
- const SIZE_CLASSES: Record<string, Record<string, string>> = {
125
- right: { xs: 'w-60', sm: 'w-80', md: 'w-96', lg: 'w-[480px]', xl: 'w-[640px]', full: 'w-screen' },
126
- left: { xs: 'w-60', sm: 'w-80', md: 'w-96', lg: 'w-[480px]', xl: 'w-[640px]', full: 'w-screen' },
127
- top: { xs: 'h-40', sm: 'h-60', md: 'h-80', lg: 'h-96', xl: 'h-[480px]', full: 'h-screen' },
128
- bottom: { xs: 'h-40', sm: 'h-60', md: 'h-80', lg: 'h-96', xl: 'h-[480px]', full: 'h-screen' },
120
+ const ACROSS: Record<Size, number | string> = {
121
+ xs: 240, sm: 320, md: 384, lg: 480, xl: 640, full: '100%',
129
122
  };
130
123
 
124
+ /** The panel's measured axis: width for a side panel, height for a top/bottom one. */
125
+ const extent = (placement: Placement, size: Size): Record<string, unknown> => (
126
+ placement === 'left' || placement === 'right' ?
127
+ { width: ACROSS[size], maxWidth: '100%' } :
128
+ { height: ACROSS[size], maxHeight: '100%' }
129
+ );
130
+
131
+ const SCRIM = 'rgba(0,0,0,0.5)';
132
+
131
133
  export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(
132
134
  function DrawerContent(props, ref) {
133
- const { children, portalled = true, portalRef, offset: _offset, backdrop = true, className, ...rest } = props;
135
+ const { children, portalled = true, portalRef: _portalRef, offset: _offset, backdrop = true, ...rest } = props;
134
136
  const placement = React.useContext(DrawerPlacementContext);
135
137
  const size = React.useContext(DrawerSizeContext);
136
- const { open, setOpen, modal } = React.useContext(DrawerStateContext);
137
- const isNarrow = useIsNarrow();
138
+ const { open, setOpen, modal, narrow } = React.useContext(DrawerStateContext);
138
139
 
139
- if (isNarrow) {
140
+ if (narrow) {
140
141
  return (
141
142
  <GuiSheet
142
143
  open={ open }
@@ -152,7 +153,7 @@ export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps
152
153
  transition="lazy"
153
154
  enterStyle={{ opacity: 0 }}
154
155
  exitStyle={{ opacity: 0 }}
155
- className="bg-black/50"
156
+ backgroundColor={ SCRIM as never }
156
157
  />
157
158
  ) }
158
159
  <GuiSheet.Handle/>
@@ -160,10 +161,11 @@ export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps
160
161
  Chakra contract hands us has nothing to attach to here. */ }
161
162
  <GuiSheet.Frame
162
163
  unstyled
163
- className={ cn(
164
- 'flex flex-col rounded-t-2xl bg-[var(--color-drawer-bg)] shadow-[var(--shadow-drawer)]',
165
- className,
166
- ) }
164
+ flexDirection="column"
165
+ borderTopLeftRadius={ 16 }
166
+ borderTopRightRadius={ 16 }
167
+ backgroundColor={ 'var(--color-drawer-bg)' as never }
168
+ shadowColor={ 'var(--shadow-drawer)' as never }
167
169
  { ...(rest as object) }
168
170
  >
169
171
  { children }
@@ -175,85 +177,91 @@ export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps
175
177
  const content = (
176
178
  <>
177
179
  { backdrop && (
178
- <RadixDialog.Overlay
179
- className={ cn(
180
- 'fixed inset-0 z-50 bg-black/50',
181
- 'data-[state=open]:animate-in data-[state=open]:fade-in-0',
182
- 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
183
- ) }
180
+ <Dialog.Overlay
181
+ key="overlay"
182
+ position="fixed"
183
+ top={ 0 }
184
+ right={ 0 }
185
+ bottom={ 0 }
186
+ left={ 0 }
187
+ zIndex={ 50 }
188
+ backgroundColor={ SCRIM as never }
189
+ transition="lazy"
190
+ enterStyle={{ opacity: 0 }}
191
+ exitStyle={{ opacity: 0 }}
184
192
  />
185
193
  ) }
186
- <RadixDialog.Content
187
- ref={ ref }
188
- className={ cn(
189
- 'fixed z-50 flex flex-col bg-[var(--color-drawer-bg)] shadow-[var(--shadow-drawer)] duration-300',
190
- PLACEMENT_CLASSES[placement] ?? PLACEMENT_CLASSES.right,
191
- SIZE_CLASSES[placement]?.[size] ?? SIZE_CLASSES.right?.md,
192
- className,
193
- ) }
194
- { ...rest }
194
+ <Dialog.Content
195
+ key="content"
196
+ ref={ ref as never }
197
+ unstyled
198
+ position="fixed"
199
+ zIndex={ 50 }
200
+ flexDirection="column"
201
+ backgroundColor={ 'var(--color-drawer-bg)' as never }
202
+ transition="medium"
203
+ { ...ANCHOR[placement] }
204
+ { ...extent(placement, size) }
205
+ { ...(rest as object) }
195
206
  >
196
207
  { children }
197
- </RadixDialog.Content>
208
+ </Dialog.Content>
198
209
  </>
199
210
  );
200
211
 
201
- if (portalled) {
202
- return (
203
- <RadixDialog.Portal container={ portalRef?.current ?? undefined }>
204
- { content }
205
- </RadixDialog.Portal>
206
- );
207
- }
208
-
209
- return content;
212
+ return portalled ? <Dialog.Portal>{ content }</Dialog.Portal> : content;
210
213
  },
211
214
  );
212
215
 
213
- // ─── DrawerCloseTrigger ─────────────────────────────────────────
216
+ // ─── Triggers ───────────────────────────────────────────────────
214
217
 
215
218
  export const DrawerCloseTrigger = React.forwardRef<
216
219
  HTMLButtonElement,
217
220
  React.ComponentPropsWithoutRef<'button'>
218
221
  >(function DrawerCloseTrigger(props, ref) {
219
222
  return (
220
- <RadixDialog.Close asChild>
221
- <CloseButton
222
- ref={ ref }
223
- className={ cn('absolute top-7 right-5', props.className) }
224
- { ...props }
225
- />
226
- </RadixDialog.Close>
223
+ // Placement belongs to the surrounding box, not to the button: CloseButton
224
+ // is a fixed 20px control and has no opinion about where it sits.
225
+ <View position="absolute" top={ 28 } right={ 20 } zIndex={ 1 }>
226
+ <Dialog.Close asChild>
227
+ <CloseButton ref={ ref } { ...props }/>
228
+ </Dialog.Close>
229
+ </View>
227
230
  );
228
231
  });
229
232
 
230
- // ─── DrawerTrigger ──────────────────────────────────────────────
233
+ export const DrawerTrigger = ({ asChild = true, children, ...rest }: React.ComponentProps<typeof Dialog.Trigger>) => {
234
+ const { setOpen, narrow } = React.useContext(DrawerStateContext);
235
+
236
+ // On a phone there is no Dialog above this, so there is no Dialog.Trigger to
237
+ // be: the child opens the Sheet directly.
238
+ if (narrow) {
239
+ return React.isValidElement(children) ?
240
+ React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, { onClick: () => setOpen(true) }) :
241
+ <>{ children }</>;
242
+ }
231
243
 
232
- export const DrawerTrigger = (props: React.ComponentPropsWithoutRef<typeof RadixDialog.Trigger>) => {
233
- const { asChild = true, ...rest } = props;
234
- return <RadixDialog.Trigger asChild={ asChild } { ...rest }/>;
244
+ return <Dialog.Trigger asChild={ asChild } { ...rest }>{ children }</Dialog.Trigger>;
235
245
  };
236
246
 
237
247
  // ─── Sub-components ─────────────────────────────────────────────
238
248
 
239
- export const DrawerHeader = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
240
- function DrawerHeader({ className, ...props }, ref) {
241
- return <div ref={ ref } className={ cn('flex flex-col gap-1.5 p-6 pb-0', className) } { ...props }/>;
242
- },
249
+ // These three take caller-authored content, so a bare string is the normal
250
+ // case and has to be given a host — a View cannot lay one out.
251
+ export const DrawerHeader = ({ children, ...props }: React.ComponentProps<typeof YStack>) => (
252
+ <YStack gap={ 6 } padding={ 24 } paddingBottom={ 0 } { ...props }>{ ink(children) }</YStack>
243
253
  );
244
254
 
245
- export const DrawerBody = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
246
- function DrawerBody({ className, ...props }, ref) {
247
- return <div ref={ ref } className={ cn('flex-1 overflow-auto p-6', className) } { ...props }/>;
248
- },
255
+ export const DrawerBody = ({ children, ...props }: React.ComponentProps<typeof YStack>) => (
256
+ <YStack flex={ 1 } overflow="scroll" padding={ 24 } { ...props }>{ ink(children) }</YStack>
249
257
  );
250
258
 
251
- export const DrawerFooter = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
252
- function DrawerFooter({ className, ...props }, ref) {
253
- return <div ref={ ref } className={ cn('flex items-center justify-end gap-2 p-6 pt-0', className) } { ...props }/>;
254
- },
259
+ export const DrawerFooter = ({ children, ...props }: React.ComponentProps<typeof XStack>) => (
260
+ <XStack alignItems="center" justifyContent="flex-end" gap={ 8 } padding={ 24 } paddingTop={ 0 } { ...props }>
261
+ { ink(children) }
262
+ </XStack>
255
263
  );
256
264
 
257
- export const DrawerTitle = RadixDialog.Title;
258
- export const DrawerDescription = RadixDialog.Description;
259
- export const DrawerActionTrigger = RadixDialog.Close;
265
+ export const DrawerTitle = Dialog.Title;
266
+ export const DrawerDescription = Dialog.Description;
267
+ export const DrawerActionTrigger = Dialog.Close;
package/src/engine.ts CHANGED
@@ -140,28 +140,3 @@ export const ALIASABLE: ReadonlyArray<string> = [
140
140
  '@tanstack/react-query',
141
141
  ...GUI_PACKAGES,
142
142
  ];
143
-
144
- /**
145
- * Exact-match aliases pinning every aliasable package to ONE entry file, for
146
- * bundlers that have no `dedupe` of their own (webpack, turbopack).
147
- *
148
- * Keys carry webpack's `$` exact-match suffix so only the bare specifier is
149
- * redirected — a deep import keeps resolving normally. Packages that are not
150
- * physically installed are skipped rather than throwing: the list is the
151
- * engine's full surface and no single app pulls all of it.
152
- */
153
- export function guiAliases(from: string): Record<string, string> {
154
- // Deferred so this module stays importable from a browser bundle (the
155
- // wrappers are build-time only, but `engine.ts` is plain values).
156
- const { createRequire } = require('node:module') as typeof import('node:module');
157
- const require_ = createRequire(`${ from.replace(/\/?$/, '/') }noop.js`);
158
- const alias: Record<string, string> = {};
159
- for (const pkg of ALIASABLE) {
160
- try {
161
- alias[`${ pkg }$`] = require_.resolve(pkg);
162
- } catch {
163
- // Not installed in this app — nothing to dedupe.
164
- }
165
- }
166
- return alias;
167
- }
package/src/field.tsx CHANGED
@@ -1,12 +1,20 @@
1
- import * as LabelPrimitive from '@radix-ui/react-label';
1
+ import { Label, Text, YStack } from '@hanzo/gui';
2
2
  import * as React from 'react';
3
3
 
4
- import { cn } from './utils';
5
-
6
4
  import type { InputProps } from './input';
7
5
  import type { InputGroupProps } from './input-group';
8
6
 
9
- // Inline constants
7
+ // A labelled form control: label, the control itself, and whichever of helper /
8
+ // error / optional text applies.
9
+ //
10
+ // The label is gui's `Label` — a real `<label htmlFor>`, which is the entire
11
+ // reason Radix's was imported. Everything else was Tailwind, and a good part of
12
+ // it was dead: the colours were written `text-[var(--color-x,theme(colors.gray.400))]`,
13
+ // and `theme()` is a Tailwind 3 function that v4 does not resolve inside an
14
+ // arbitrary value. So the fallback half of every one of those declarations was
15
+ // discarded and the class contributed nothing whenever the custom property was
16
+ // unset. The tokens are referenced directly now, and they are all defined.
17
+
10
18
  const space = String.fromCharCode(32);
11
19
 
12
20
  function getComponentDisplayName(type: string | React.JSXElementConstructor<unknown>): string | undefined {
@@ -27,75 +35,62 @@ export interface FieldProps {
27
35
  readonly readOnly?: boolean;
28
36
  readonly invalid?: boolean;
29
37
  readonly floating?: boolean;
30
- readonly bgColor?: string | Record<string, string>;
31
38
  readonly id?: string;
32
39
  readonly className?: string;
33
40
  readonly style?: React.CSSProperties;
34
41
  }
35
42
 
43
+ const ERROR = 'var(--color-text-error)' as never;
44
+ const MUTED = 'var(--color-text-secondary)' as never;
45
+
46
+ /** The `*`, the "(optional)", and the inline error that ride alongside a label. */
47
+ const Marks = ({ required, optionalText, errorText }: Pick<FieldProps, 'required' | 'optionalText' | 'errorText'>) => (
48
+ <>
49
+ { required && <Text color={ ERROR } aria-hidden>*</Text> }
50
+ { !required && optionalText && <Text fontSize={ 14 } color={ MUTED }>{ optionalText }</Text> }
51
+ { errorText && <Text fontSize={ 14 } color={ ERROR }>-{ space }{ errorText }</Text> }
52
+ </>
53
+ );
54
+
36
55
  export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
37
56
  function Field(props, ref) {
38
57
  const {
39
- label,
40
- children,
41
- helperText,
42
- errorText,
43
- optionalText,
44
- size,
45
- required,
46
- disabled,
47
- readOnly,
48
- invalid,
49
- floating,
50
- id,
51
- className,
52
- style,
58
+ label, children, helperText, errorText, optionalText, size,
59
+ required, disabled, readOnly, invalid, floating, id, className, style,
53
60
  } = props;
54
61
 
62
+ const flags = {
63
+ 'data-disabled': disabled || undefined,
64
+ 'data-readonly': readOnly || undefined,
65
+ 'data-invalid': invalid || undefined,
66
+ };
67
+
55
68
  // A floating field cannot be without a label.
56
69
  if (floating && label) {
57
- const injectedProps = {
58
- className: 'peer',
59
- placeholder: ' ',
60
- size,
61
- floating,
62
- disabled,
63
- readOnly,
64
- };
70
+ const injectedProps = { className: 'lux-field-control', placeholder: ' ', size, floating, disabled, readOnly };
65
71
 
66
72
  const labelElement = (
67
- <LabelPrimitive.Root
68
- className={ cn(
69
- 'absolute left-3 top-1/2 -translate-y-1/2',
70
- 'text-[var(--color-input-placeholder,theme(colors.gray.400))]',
71
- 'pointer-events-none select-none',
72
- 'origin-top-left transition-all duration-150',
73
- 'peer-focus:top-1 peer-focus:translate-y-0 peer-focus:scale-75',
74
- 'peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:translate-y-0 peer-[:not(:placeholder-shown)]:scale-75',
75
- ) }
73
+ <Label
76
74
  htmlFor={ id }
75
+ position="absolute"
76
+ left={ 12 }
77
+ top="50%"
78
+ pointerEvents="none"
79
+ userSelect="none"
80
+ flexDirection="row"
81
+ gap={ 4 }
82
+ color={ 'var(--color-input-placeholder)' as never }
83
+ // The float itself stays in CSS: it keys off `:placeholder-shown` and
84
+ // `:focus` of a SIBLING, which is a selector, not a prop.
85
+ className="lux-field-float"
77
86
  >
78
87
  { label }
79
- { required && (
80
- <span className="ml-0.5 text-[var(--color-fg-error,theme(colors.red.500))]" aria-hidden="true">*</span>
81
- ) }
82
- { !required && optionalText && (
83
- <span className="ml-1 text-[var(--color-text-secondary,theme(colors.gray.400))] text-sm">
84
- { optionalText }
85
- </span>
86
- ) }
87
- { errorText && (
88
- <span className="ml-0.5 text-[var(--color-fg-error,theme(colors.red.500))] text-sm">
89
- -{ space }{ errorText }
90
- </span>
91
- ) }
92
- </LabelPrimitive.Root>
88
+ <Marks required={ required } optionalText={ optionalText } errorText={ errorText }/>
89
+ </Label>
93
90
  );
94
91
 
95
92
  const helperTextElement = helperText ? (
96
- <p className="mt-1.5 text-xs text-[var(--color-text-secondary,theme(colors.gray.400))]">
97
- { helperText }
98
- </p>
93
+ <Text marginTop={ 6 } fontSize={ 12 } color={ MUTED }>{ helperText }</Text>
99
94
  ) : null;
100
95
 
101
96
  const child = React.Children.only<React.ReactElement<InputProps | InputGroupProps>>(children);
@@ -107,96 +102,45 @@ export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
107
102
  injectedProps,
108
103
  );
109
104
 
110
- const groupInputElement = React.cloneElement(child,
111
- {},
112
- inputElement,
113
- labelElement,
114
- );
115
-
116
105
  return (
117
- <div
118
- ref={ ref }
119
- id={ id }
120
- className={ cn('relative w-full', className) }
121
- style={ style }
122
- data-disabled={ disabled || undefined }
123
- data-readonly={ readOnly || undefined }
124
- data-invalid={ invalid || undefined }
125
- >
126
- { groupInputElement }
106
+ <YStack ref={ ref as never } id={ id } position="relative" width="100%" className={ className } style={ style } { ...flags }>
107
+ { React.cloneElement(child, {}, inputElement, labelElement) }
127
108
  { helperTextElement }
128
- </div>
109
+ </YStack>
129
110
  );
130
111
  }
131
112
 
132
- const inputElement = React.cloneElement(child, injectedProps);
133
-
134
113
  return (
135
- <div
136
- ref={ ref }
137
- id={ id }
138
- className={ cn('relative w-full', className) }
139
- style={ style }
140
- data-disabled={ disabled || undefined }
141
- data-readonly={ readOnly || undefined }
142
- data-invalid={ invalid || undefined }
143
- >
144
- { inputElement }
114
+ <YStack ref={ ref as never } id={ id } position="relative" width="100%" className={ className } style={ style } { ...flags }>
115
+ { React.cloneElement(child, injectedProps) }
145
116
  { labelElement }
146
117
  { helperTextElement }
147
- </div>
118
+ </YStack>
148
119
  );
149
120
  }
150
121
 
151
- // Non-floating: standard field layout
152
- const injectedProps = {
153
- size,
154
- };
155
122
  const child = React.Children.only<React.ReactElement<InputProps | InputGroupProps>>(children);
156
- const clonedChild = React.cloneElement(child, injectedProps);
157
123
 
158
124
  return (
159
- <div
160
- ref={ ref }
161
- id={ id }
162
- className={ cn('flex flex-col gap-1', className) }
163
- style={ style }
164
- data-disabled={ disabled || undefined }
165
- data-readonly={ readOnly || undefined }
166
- data-invalid={ invalid || undefined }
167
- >
125
+ <YStack ref={ ref as never } id={ id } gap={ 4 } className={ className } style={ style } { ...flags }>
168
126
  { label && (
169
- <LabelPrimitive.Root
170
- className={ cn(
171
- 'text-sm font-medium text-[var(--color-field-label,theme(colors.gray.700))]',
172
- 'dark:text-[var(--color-field-label,theme(colors.gray.300))]',
173
- disabled && 'opacity-40',
174
- ) }
127
+ <Label
175
128
  htmlFor={ id }
129
+ flexDirection="row"
130
+ gap={ 4 }
131
+ fontSize={ 14 }
132
+ fontWeight="500"
133
+ color={ 'var(--color-text-primary)' as never }
134
+ opacity={ disabled ? 0.4 : 1 }
176
135
  >
177
136
  { label }
178
- { required && (
179
- <span className="ml-0.5 text-[var(--color-fg-error,theme(colors.red.500))]" aria-hidden="true">*</span>
180
- ) }
181
- { !required && optionalText && (
182
- <span className="ml-1 text-[var(--color-text-secondary,theme(colors.gray.400))] text-sm font-normal">
183
- { optionalText }
184
- </span>
185
- ) }
186
- </LabelPrimitive.Root>
187
- ) }
188
- { clonedChild }
189
- { helperText && (
190
- <p className="text-xs text-[var(--color-text-secondary,theme(colors.gray.400))]">
191
- { helperText }
192
- </p>
193
- ) }
194
- { errorText && (
195
- <p className="text-xs text-[var(--color-fg-error,theme(colors.red.500))]">
196
- { errorText }
197
- </p>
137
+ <Marks required={ required } optionalText={ optionalText }/>
138
+ </Label>
198
139
  ) }
199
- </div>
140
+ { React.cloneElement(child, { size }) }
141
+ { helperText && <Text fontSize={ 12 } color={ MUTED }>{ helperText }</Text> }
142
+ { errorText && <Text fontSize={ 12 } color={ ERROR }>{ errorText }</Text> }
143
+ </YStack>
200
144
  );
201
145
  },
202
146
  );