@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.
- package/dist/accordion.cjs +107 -166
- package/dist/accordion.d.cts +18 -51
- package/dist/accordion.d.ts +18 -51
- package/dist/accordion.js +109 -166
- package/dist/chrome.cjs +193 -379
- package/dist/chrome.js +195 -379
- package/dist/collapsible.cjs +35 -26
- package/dist/collapsible.d.cts +2 -1
- package/dist/collapsible.d.ts +2 -1
- package/dist/collapsible.js +34 -24
- package/dist/dialog.d.cts +1 -1
- package/dist/dialog.d.ts +1 -1
- package/dist/drawer.cjs +92 -115
- package/dist/drawer.d.cts +43 -10
- package/dist/drawer.d.ts +43 -10
- package/dist/drawer.js +94 -115
- package/dist/field.cjs +60 -109
- package/dist/field.d.cts +0 -1
- package/dist/field.d.ts +0 -1
- package/dist/field.js +61 -109
- package/dist/heading.cjs +10 -17
- package/dist/heading.d.cts +0 -2
- package/dist/heading.d.ts +0 -2
- package/dist/heading.js +10 -17
- package/dist/icon-button.cjs +82 -82
- package/dist/icon-button.d.cts +16 -12
- package/dist/icon-button.d.ts +16 -12
- package/dist/icon-button.js +82 -82
- package/dist/index.cjs +1073 -1419
- package/dist/index.d.cts +1 -5
- package/dist/index.d.ts +1 -5
- package/dist/index.js +1075 -1414
- package/dist/input-group.cjs +11 -12
- package/dist/input-group.js +11 -12
- package/dist/input.cjs +27 -27
- package/dist/input.d.cts +21 -1
- package/dist/input.d.ts +21 -1
- package/dist/input.js +25 -28
- package/dist/menu.cjs +160 -271
- package/dist/menu.d.cts +41 -57
- package/dist/menu.d.ts +41 -57
- package/dist/menu.js +161 -251
- package/dist/next.cjs +6 -10
- package/dist/next.js +5 -9
- package/dist/popover.cjs +1 -1
- package/dist/popover.js +1 -1
- package/dist/select.cjs +40 -39
- package/dist/select.d.cts +35 -4
- package/dist/select.d.ts +35 -4
- package/dist/select.js +40 -38
- package/dist/separator.cjs +8 -33
- package/dist/separator.js +8 -33
- package/dist/tabs.cjs +108 -197
- package/dist/tabs.d.cts +16 -60
- package/dist/tabs.d.ts +16 -60
- package/dist/tabs.js +109 -196
- package/dist/textarea.cjs +56 -27
- package/dist/textarea.js +57 -28
- package/dist/toaster.cjs +97 -79
- package/dist/toaster.d.cts +7 -8
- package/dist/toaster.d.ts +7 -8
- package/dist/toaster.js +78 -80
- package/package.json +2 -16
- package/src/accordion.tsx +173 -227
- package/src/collapsible.tsx +55 -52
- package/src/drawer.tsx +123 -115
- package/src/engine.ts +0 -25
- package/src/field.tsx +68 -124
- package/src/heading.tsx +22 -26
- package/src/icon-button.tsx +134 -141
- package/src/ink.tsx +37 -0
- package/src/input-group.tsx +21 -12
- package/src/input.tsx +41 -32
- package/src/menu.tsx +224 -397
- package/src/next.ts +32 -1
- package/src/popover.tsx +1 -1
- package/src/select.tsx +80 -59
- package/src/separator.tsx +13 -34
- package/src/tabs.tsx +166 -296
- package/src/textarea.tsx +19 -29
- package/src/toaster.tsx +118 -86
- 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?:
|
|
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?:
|
|
37
|
+
size?: Size;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
125
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
) }
|
|
194
|
-
|
|
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
|
-
</
|
|
208
|
+
</Dialog.Content>
|
|
198
209
|
</>
|
|
199
210
|
);
|
|
200
211
|
|
|
201
|
-
|
|
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
|
-
// ───
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
{ ...props }
|
|
225
|
-
|
|
226
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 =
|
|
246
|
-
|
|
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 =
|
|
252
|
-
|
|
253
|
-
|
|
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 =
|
|
258
|
-
export const DrawerDescription =
|
|
259
|
-
export const DrawerActionTrigger =
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
118
|
-
|
|
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
|
-
</
|
|
109
|
+
</YStack>
|
|
129
110
|
);
|
|
130
111
|
}
|
|
131
112
|
|
|
132
|
-
const inputElement = React.cloneElement(child, injectedProps);
|
|
133
|
-
|
|
134
113
|
return (
|
|
135
|
-
<
|
|
136
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|