@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.
- 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/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/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/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/accordion.tsx
CHANGED
|
@@ -1,285 +1,231 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Accordion, View, XStack } from '@hanzo/gui';
|
|
2
2
|
import * as React from 'react';
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
import { ink } from './ink';
|
|
5
|
+
|
|
6
|
+
// The Lux accordion, on gui's Accordion.
|
|
7
|
+
//
|
|
8
|
+
// Everything behavioural belongs to the primitive and none of it is
|
|
9
|
+
// re-implemented here: roving Arrow/Home/End focus on the root, `aria-expanded`
|
|
10
|
+
// + `aria-controls` on the trigger, `role="region"` + `aria-labelledby` on the
|
|
11
|
+
// content. This file names the four parts and dresses them.
|
|
12
|
+
//
|
|
13
|
+
// Three corrections the primitive needs, each of which is a real bug if skipped:
|
|
14
|
+
//
|
|
15
|
+
// 1. gui's `Accordion.Header` renders an `<h1>` at the display type scale. An
|
|
16
|
+
// accordion is a list of sections inside a page that already has a title, so
|
|
17
|
+
// unqualified every row publishes a competing document heading and a screen
|
|
18
|
+
// reader's heading list comes out as N level-1s. `headingLevel` re-hosts it
|
|
19
|
+
// and sets the matching `aria-level`, which the inherited `role="heading"`
|
|
20
|
+
// requires to be valid.
|
|
21
|
+
// 2. `unstyled` means gui applies nothing — which on the web leaves the USER
|
|
22
|
+
// AGENT's chrome, and a bare `<button>` is #efefef with an outset border.
|
|
23
|
+
// Unstyled has to mean "no chrome", so the reset is explicit.
|
|
24
|
+
// 3. That trigger is a bare `<button>`, and a bare button inside a form
|
|
25
|
+
// SUBMITS it. `type="button"` or opening a section posts the form.
|
|
26
|
+
//
|
|
27
|
+
// The indicator's open state comes from the trigger's function child — gui
|
|
28
|
+
// exports no per-item context, and that is the only seam. It replaces a
|
|
29
|
+
// `group-data-[state=open]/trigger:` Tailwind chain that could not have worked
|
|
30
|
+
// here anyway: gui compiles style props to atomic classes and has no
|
|
31
|
+
// parent-state selector.
|
|
32
|
+
|
|
33
|
+
const IndicatorIcon = () => (
|
|
34
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
9
35
|
<path d="M7.5 15L12.5 10L7.5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
10
36
|
</svg>
|
|
11
37
|
);
|
|
12
38
|
|
|
39
|
+
const BAR = 'var(--color-text-primary)' as never;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The open/closed mark. The FAQ variant is a plus that becomes a minus: two
|
|
43
|
+
* bars, and the upright one turns a quarter turn onto the flat one.
|
|
44
|
+
*/
|
|
45
|
+
const Indicator = ({ open, variant }: { open: boolean; variant?: 'outline' | 'faq' }) => (
|
|
46
|
+
variant === 'faq' ? (
|
|
47
|
+
<XStack width={ 12 } height={ 12 } flexShrink={ 0 } alignItems="center" justifyContent="center" position="relative">
|
|
48
|
+
<View position="absolute" width="100%" height={ 2 } borderRadius={ 2 } backgroundColor={ BAR }/>
|
|
49
|
+
<View
|
|
50
|
+
position="absolute"
|
|
51
|
+
width={ 2 }
|
|
52
|
+
height="100%"
|
|
53
|
+
borderRadius={ 2 }
|
|
54
|
+
backgroundColor={ BAR }
|
|
55
|
+
rotate={ open ? '90deg' : '0deg' }
|
|
56
|
+
/>
|
|
57
|
+
</XStack>
|
|
58
|
+
) : (
|
|
59
|
+
<XStack flexShrink={ 0 } alignItems="center" rotate={ open ? '270deg' : '180deg' }>
|
|
60
|
+
<IndicatorIcon/>
|
|
61
|
+
</XStack>
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
|
|
13
65
|
// ---------------------------------------------------------------------------
|
|
14
|
-
//
|
|
66
|
+
// Root
|
|
15
67
|
// ---------------------------------------------------------------------------
|
|
16
68
|
|
|
17
|
-
interface AccordionRootProps
|
|
69
|
+
export interface AccordionRootProps
|
|
70
|
+
extends Omit<React.ComponentProps<typeof Accordion>, 'type' | 'value' | 'defaultValue' | 'onValueChange' | 'size'> {
|
|
18
71
|
|
|
19
|
-
/**
|
|
20
|
-
readonly multiple?: boolean;
|
|
21
|
-
|
|
22
|
-
/** Controlled open items (array of `value` strings). */
|
|
72
|
+
/** Controlled open items. */
|
|
23
73
|
readonly value?: Array<string>;
|
|
24
74
|
|
|
25
75
|
/** Uncontrolled initial open items. */
|
|
26
76
|
readonly defaultValue?: Array<string>;
|
|
27
77
|
|
|
28
|
-
/**
|
|
29
|
-
* Called when open items change.
|
|
30
|
-
* Wraps the value in `{ value }` to stay compatible with the Chakra callback shape
|
|
31
|
-
* that consumers already rely on.
|
|
32
|
-
*/
|
|
78
|
+
/** Fires with the full set of open items. */
|
|
33
79
|
readonly onValueChange?: (details: { value: Array<string> }) => void;
|
|
80
|
+
|
|
34
81
|
readonly variant?: 'outline' | 'faq';
|
|
35
82
|
readonly size?: 'sm' | 'md';
|
|
36
|
-
|
|
37
|
-
/** Accepted for compatibility; not applied visually. */
|
|
38
|
-
readonly noAnimation?: boolean;
|
|
39
|
-
|
|
40
|
-
/** Accepted for compatibility; Radix defers rendering internally. */
|
|
41
|
-
readonly lazyMount?: boolean;
|
|
42
|
-
readonly children?: React.ReactNode;
|
|
43
|
-
// Legacy Chakra style-prop shims
|
|
44
|
-
readonly position?: string;
|
|
45
|
-
readonly w?: string;
|
|
46
|
-
readonly bgColor?: string | Record<string, string>;
|
|
47
|
-
readonly borderRadius?: string;
|
|
48
83
|
}
|
|
49
84
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
[ onValueChange ],
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<AccordionPrimitive.Root
|
|
81
|
-
ref={ ref }
|
|
82
|
-
type="multiple"
|
|
83
|
-
value={ value }
|
|
84
|
-
defaultValue={ defaultValue }
|
|
85
|
-
onValueChange={ handleValueChange }
|
|
86
|
-
className={ cn(
|
|
87
|
-
'w-full',
|
|
88
|
-
variant === 'faq' && 'accordion-faq',
|
|
89
|
-
size === 'sm' && 'accordion-sm',
|
|
90
|
-
className,
|
|
91
|
-
) }
|
|
92
|
-
data-variant={ variant }
|
|
93
|
-
data-size={ size }
|
|
94
|
-
{ ...rest }
|
|
95
|
-
>
|
|
96
|
-
{ children }
|
|
97
|
-
</AccordionPrimitive.Root>
|
|
98
|
-
);
|
|
99
|
-
},
|
|
85
|
+
const Variant = React.createContext<'outline' | 'faq' | undefined>(undefined);
|
|
86
|
+
|
|
87
|
+
export const AccordionRoot = ({
|
|
88
|
+
value,
|
|
89
|
+
defaultValue,
|
|
90
|
+
onValueChange,
|
|
91
|
+
variant,
|
|
92
|
+
size,
|
|
93
|
+
children,
|
|
94
|
+
...rest
|
|
95
|
+
}: AccordionRootProps): React.ReactElement => (
|
|
96
|
+
<Variant.Provider value={ variant }>
|
|
97
|
+
<Accordion
|
|
98
|
+
unstyled
|
|
99
|
+
type="multiple"
|
|
100
|
+
width="100%"
|
|
101
|
+
value={ value }
|
|
102
|
+
defaultValue={ defaultValue }
|
|
103
|
+
onValueChange={ onValueChange ? (next: Array<string>) => onValueChange({ value: next }) : undefined }
|
|
104
|
+
data-variant={ variant }
|
|
105
|
+
data-size={ size }
|
|
106
|
+
{ ...rest }
|
|
107
|
+
>
|
|
108
|
+
{ children }
|
|
109
|
+
</Accordion>
|
|
110
|
+
</Variant.Provider>
|
|
100
111
|
);
|
|
101
112
|
|
|
102
113
|
// ---------------------------------------------------------------------------
|
|
103
|
-
//
|
|
114
|
+
// Item
|
|
104
115
|
// ---------------------------------------------------------------------------
|
|
105
116
|
|
|
106
|
-
|
|
107
|
-
// Legacy Chakra style-prop shims
|
|
108
|
-
as?: string;
|
|
109
|
-
_first?: Record<string, unknown>;
|
|
110
|
-
_last?: Record<string, unknown>;
|
|
111
|
-
display?: string;
|
|
112
|
-
}
|
|
117
|
+
export type AccordionItemProps = React.ComponentProps<typeof Accordion.Item>;
|
|
113
118
|
|
|
114
|
-
export const AccordionItem = React.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
'border-b border-[var(--color-border-divider)]',
|
|
122
|
-
className,
|
|
123
|
-
) }
|
|
124
|
-
{ ...rest }
|
|
125
|
-
/>
|
|
126
|
-
);
|
|
127
|
-
},
|
|
119
|
+
export const AccordionItem = (props: AccordionItemProps): React.ReactElement => (
|
|
120
|
+
<Accordion.Item
|
|
121
|
+
unstyled
|
|
122
|
+
borderBottomWidth={ 1 }
|
|
123
|
+
borderColor={ 'var(--color-border-divider)' as never }
|
|
124
|
+
{ ...props }
|
|
125
|
+
/>
|
|
128
126
|
);
|
|
129
127
|
|
|
130
128
|
// ---------------------------------------------------------------------------
|
|
131
|
-
//
|
|
129
|
+
// Trigger
|
|
132
130
|
// ---------------------------------------------------------------------------
|
|
133
131
|
|
|
134
|
-
|
|
132
|
+
export type AccordionItemTriggerProps = React.ComponentProps<typeof Accordion.Trigger> & {
|
|
135
133
|
readonly indicatorPlacement?: 'start' | 'end';
|
|
136
134
|
readonly noIndicator?: boolean;
|
|
137
135
|
readonly variant?: 'outline' | 'faq';
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const {
|
|
155
|
-
children, indicatorPlacement: indicatorPlacementProp, variant, noIndicator, className,
|
|
156
|
-
// Strip Chakra style props
|
|
157
|
-
px: _px, py: _py, _hover, wordBreak: _wordBreak, textAlign: _textAlign,
|
|
158
|
-
cursor: _cursor, display: _display, alignItems: _alignItems, columnGap: _columnGap,
|
|
159
|
-
...rest
|
|
160
|
-
} = props;
|
|
161
|
-
|
|
162
|
-
const indicatorPlacement = variant === 'faq' ? 'start' : (indicatorPlacementProp ?? 'end');
|
|
163
|
-
|
|
164
|
-
const indicator = variant === 'faq' ? (
|
|
165
|
-
<span
|
|
166
|
-
className={ cn(
|
|
167
|
-
'relative inline-block h-3 w-3 shrink-0',
|
|
168
|
-
// horizontal bar (always visible)
|
|
169
|
-
'before:absolute before:left-0 before:top-1/2 before:block before:h-[2px] before:w-full before:-translate-y-1/2 before:rounded-[2px] before:bg-current',
|
|
170
|
-
// vertical bar (rotates to 0 when open)
|
|
171
|
-
'after:absolute after:left-1/2 after:top-0 after:block after:h-full after:w-[2px]',
|
|
172
|
-
'after:-translate-x-1/2 after:rounded-[2px] after:bg-current',
|
|
173
|
-
'after:transition-transform after:duration-200 after:ease-in-out',
|
|
174
|
-
// When parent trigger has data-state="open", rotate vertical bar
|
|
175
|
-
'group-data-[state=open]/trigger:after:rotate-90',
|
|
176
|
-
) }
|
|
177
|
-
/>
|
|
178
|
-
) : (
|
|
179
|
-
<span
|
|
180
|
-
className={ cn(
|
|
181
|
-
'inline-flex shrink-0 transition-transform duration-200',
|
|
182
|
-
'rotate-180',
|
|
183
|
-
'group-data-[state=open]/trigger:rotate-[270deg]',
|
|
184
|
-
) }
|
|
185
|
-
>
|
|
186
|
-
<IndicatorIcon className="h-5 w-5"/>
|
|
187
|
-
</span>
|
|
188
|
-
);
|
|
136
|
+
readonly headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
137
|
+
|
|
138
|
+
/** The DOM button type — see (3) above. Defaults to `button`, not submit. */
|
|
139
|
+
readonly type?: 'button' | 'submit' | 'reset';
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const AccordionItemTrigger = ({
|
|
143
|
+
children,
|
|
144
|
+
indicatorPlacement: placementProp,
|
|
145
|
+
variant: variantProp,
|
|
146
|
+
noIndicator,
|
|
147
|
+
headingLevel = 3,
|
|
148
|
+
...rest
|
|
149
|
+
}: AccordionItemTriggerProps): React.ReactElement => {
|
|
150
|
+
const variant = variantProp ?? React.useContext(Variant);
|
|
151
|
+
const placement = variant === 'faq' ? 'start' : (placementProp ?? 'end');
|
|
189
152
|
|
|
190
153
|
return (
|
|
191
|
-
<
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
154
|
+
<Accordion.Header unstyled render={ `h${ headingLevel }` } aria-level={ headingLevel } display="flex" width="100%">
|
|
155
|
+
<Accordion.Trigger
|
|
156
|
+
unstyled
|
|
157
|
+
type="button"
|
|
158
|
+
width="100%"
|
|
159
|
+
flexDirection="row"
|
|
160
|
+
alignItems="center"
|
|
161
|
+
gap={ 8 }
|
|
162
|
+
paddingVertical={ 12 }
|
|
163
|
+
paddingHorizontal={ 0 }
|
|
164
|
+
borderWidth={ 0 }
|
|
165
|
+
backgroundColor="transparent"
|
|
166
|
+
cursor="pointer"
|
|
167
|
+
// Ink colour and its hover live in one CSS rule rather than on this
|
|
168
|
+
// frame: `color` is a Text prop, not a Stack prop, and "the label
|
|
169
|
+
// brightens while the ROW is hovered" is a pseudo-class on an ancestor
|
|
170
|
+
// — a selector, which no prop expresses. The children inherit it.
|
|
171
|
+
className="lux-accordion-trigger"
|
|
172
|
+
{ ...rest }
|
|
173
|
+
>
|
|
174
|
+
{ (({ open }: { open: boolean }) => (
|
|
175
|
+
<>
|
|
176
|
+
{ placement === 'start' && !noIndicator && <Indicator open={ open } variant={ variant }/> }
|
|
177
|
+
{ ink(children, undefined, { color: 'inherit', textAlign: 'left' }) }
|
|
178
|
+
{ placement === 'end' && !noIndicator && <Indicator open={ open } variant={ variant }/> }
|
|
179
|
+
</>
|
|
180
|
+
)) as unknown as React.ReactNode }
|
|
181
|
+
</Accordion.Trigger>
|
|
182
|
+
</Accordion.Header>
|
|
209
183
|
);
|
|
210
|
-
}
|
|
184
|
+
};
|
|
211
185
|
|
|
212
186
|
// ---------------------------------------------------------------------------
|
|
213
|
-
//
|
|
187
|
+
// Content
|
|
214
188
|
// ---------------------------------------------------------------------------
|
|
215
189
|
|
|
216
|
-
export
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
AccordionItemContentProps
|
|
230
|
-
>(function AccordionItemContent(props, ref) {
|
|
231
|
-
const { className, children, pb: _pb, pr: _pr, pl: _pl, w: _w, display: _display, flexDir: _flexDir, rowGap: _rowGap, ...rest } = props;
|
|
232
|
-
return (
|
|
233
|
-
<AccordionPrimitive.Content
|
|
234
|
-
ref={ ref }
|
|
235
|
-
className={ cn(
|
|
236
|
-
'overflow-hidden text-[var(--color-text-primary)]',
|
|
237
|
-
'data-[state=open]:animate-accordion-down',
|
|
238
|
-
'data-[state=closed]:animate-accordion-up',
|
|
239
|
-
className,
|
|
240
|
-
) }
|
|
241
|
-
{ ...rest }
|
|
242
|
-
>
|
|
243
|
-
<div className="pb-3">
|
|
244
|
-
{ children }
|
|
245
|
-
</div>
|
|
246
|
-
</AccordionPrimitive.Content>
|
|
247
|
-
);
|
|
248
|
-
});
|
|
190
|
+
export type AccordionItemContentProps = React.ComponentProps<typeof Accordion.Content>;
|
|
191
|
+
|
|
192
|
+
export const AccordionItemContent = ({ children, ...rest }: AccordionItemContentProps): React.ReactElement => (
|
|
193
|
+
<Accordion.Content
|
|
194
|
+
unstyled
|
|
195
|
+
overflow="hidden"
|
|
196
|
+
paddingBottom={ 12 }
|
|
197
|
+
backgroundColor="transparent"
|
|
198
|
+
{ ...rest }
|
|
199
|
+
>
|
|
200
|
+
{ ink(children, undefined, { color: 'var(--color-text-primary)' }) }
|
|
201
|
+
</Accordion.Content>
|
|
202
|
+
);
|
|
249
203
|
|
|
250
204
|
// ---------------------------------------------------------------------------
|
|
251
205
|
// useAccordion
|
|
252
206
|
// ---------------------------------------------------------------------------
|
|
253
207
|
|
|
208
|
+
/** Open state for an accordion whose items are addressable by URL fragment. */
|
|
254
209
|
export function useAccordion(items: Array<{ id: string }>) {
|
|
255
210
|
const [ value, setValue ] = React.useState<Array<string>>([]);
|
|
256
211
|
|
|
257
|
-
const onValueChange = React.useCallback(({ value }: { value: Array<string> }) => {
|
|
258
|
-
setValue(
|
|
212
|
+
const onValueChange = React.useCallback(({ value: next }: { value: Array<string> }) => {
|
|
213
|
+
setValue(next);
|
|
259
214
|
}, []);
|
|
260
215
|
|
|
261
216
|
const scrollToItemFromUrl = React.useCallback(() => {
|
|
262
217
|
const hash = window.location.hash.replace('#', '');
|
|
263
|
-
|
|
264
|
-
if (!hash) {
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
218
|
+
if (!hash) return;
|
|
267
219
|
|
|
268
220
|
const itemToScroll = items.find((item) => item.id === hash);
|
|
269
|
-
if (itemToScroll)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
setValue([ itemToScroll.id ]);
|
|
275
|
-
}
|
|
221
|
+
if (!itemToScroll) return;
|
|
222
|
+
|
|
223
|
+
document.getElementById(itemToScroll.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
224
|
+
setValue([ itemToScroll.id ]);
|
|
276
225
|
}, [ items ]);
|
|
277
226
|
|
|
278
|
-
return React.useMemo(
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
scrollToItemFromUrl,
|
|
283
|
-
};
|
|
284
|
-
}, [ value, onValueChange, scrollToItemFromUrl ]);
|
|
227
|
+
return React.useMemo(
|
|
228
|
+
() => ({ value, onValueChange, scrollToItemFromUrl }),
|
|
229
|
+
[ value, onValueChange, scrollToItemFromUrl ],
|
|
230
|
+
);
|
|
285
231
|
}
|
package/src/collapsible.tsx
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Collapsible, YStack } from '@hanzo/gui';
|
|
2
2
|
import React from 'react';
|
|
3
3
|
|
|
4
|
-
import { cn } from './utils';
|
|
5
|
-
|
|
6
4
|
import type { LinkProps } from './link';
|
|
7
5
|
import { Link } from './link';
|
|
8
6
|
|
|
9
|
-
//
|
|
7
|
+
// Two disclosure shapes on gui's Collapsible: one that hides a block of detail
|
|
8
|
+
// behind a "View details" link, one that cuts a long list down to its first few
|
|
9
|
+
// rows.
|
|
10
|
+
//
|
|
11
|
+
// The trigger is the `Link` itself rather than a wrapping `Collapsible.Trigger`.
|
|
12
|
+
// gui's Trigger renders its OWN element — it has no `asChild` — so wrapping
|
|
13
|
+
// would nest a button around an anchor, which is invalid HTML and swallows the
|
|
14
|
+
// link's own keyboard behaviour. The state is already controlled here, so the
|
|
15
|
+
// only thing the wrapper contributed was `aria-expanded`, and that is a prop.
|
|
16
|
+
|
|
17
|
+
/** An effect that skips the first mount. */
|
|
10
18
|
function useUpdateEffect(effect: React.EffectCallback, deps: React.DependencyList): void {
|
|
11
19
|
const isFirstMount = React.useRef(true);
|
|
12
20
|
React.useEffect(() => {
|
|
@@ -19,12 +27,8 @@ function useUpdateEffect(effect: React.EffectCallback, deps: React.DependencyLis
|
|
|
19
27
|
}, deps);
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
// Inline scroll-to helper (replaces react-scroll dependency)
|
|
23
30
|
function scrollToElement(id: string): void {
|
|
24
|
-
|
|
25
|
-
if (el) {
|
|
26
|
-
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
27
|
-
}
|
|
31
|
+
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export interface CollapsibleDetailsProps extends LinkProps {
|
|
@@ -38,48 +42,49 @@ export interface CollapsibleDetailsProps extends LinkProps {
|
|
|
38
42
|
const CUT_ID = 'CollapsibleDetails';
|
|
39
43
|
|
|
40
44
|
export const CollapsibleDetails = (props: CollapsibleDetailsProps) => {
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
const {
|
|
46
|
+
children, id = CUT_ID, onClick, isExpanded: isExpandedProp = false,
|
|
47
|
+
text: textProp, loading, noScroll, ...rest
|
|
48
|
+
} = props;
|
|
43
49
|
|
|
44
50
|
const [ isExpanded, setIsExpanded ] = React.useState(isExpandedProp);
|
|
45
51
|
|
|
46
52
|
const handleClick = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
47
53
|
setIsExpanded((flag) => !flag);
|
|
48
|
-
if (!noScroll)
|
|
49
|
-
scrollToElement(id);
|
|
50
|
-
}
|
|
54
|
+
if (!noScroll) scrollToElement(id);
|
|
51
55
|
onClick?.(event);
|
|
52
56
|
}, [ id, noScroll, onClick ]);
|
|
53
57
|
|
|
54
58
|
useUpdateEffect(() => {
|
|
55
59
|
setIsExpanded(isExpandedProp);
|
|
56
|
-
if (isExpandedProp && !noScroll)
|
|
57
|
-
scrollToElement(id);
|
|
58
|
-
}
|
|
60
|
+
if (isExpandedProp && !noScroll) scrollToElement(id);
|
|
59
61
|
}, [ isExpandedProp, id, noScroll ]);
|
|
60
62
|
|
|
61
63
|
const text = isExpanded ? (textProp?.[1] ?? 'Hide details') : (textProp?.[0] ?? 'View details');
|
|
62
64
|
|
|
63
65
|
return (
|
|
64
|
-
<Collapsible
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
</Collapsible.Trigger>
|
|
66
|
+
<Collapsible open={ isExpanded } onOpenChange={ setIsExpanded }>
|
|
67
|
+
<Link
|
|
68
|
+
className="text-sm underline decoration-dashed w-fit"
|
|
69
|
+
aria-expanded={ isExpanded }
|
|
70
|
+
onClick={ handleClick }
|
|
71
|
+
loading={ loading }
|
|
72
|
+
{ ...rest }
|
|
73
|
+
>
|
|
74
|
+
<span id={ id }>{ text }</span>
|
|
75
|
+
</Link>
|
|
75
76
|
<Collapsible.Content>
|
|
76
77
|
{ children }
|
|
77
78
|
</Collapsible.Content>
|
|
78
|
-
</Collapsible
|
|
79
|
+
</Collapsible>
|
|
79
80
|
);
|
|
80
81
|
};
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
// `items` and `text` are this component's own nouns — the rows, and the pair of
|
|
84
|
+
// trigger labels. On gui they are the `alignItems` / `textAlign` shorthands.
|
|
85
|
+
// Ours win; the shorthands stay reachable under their full names.
|
|
86
|
+
interface CollapsibleListProps<T>
|
|
87
|
+
extends Omit<React.ComponentProps<typeof YStack>, 'children' | 'text' | 'items'> {
|
|
83
88
|
items: Array<T>;
|
|
84
89
|
renderItem: (item: T, index: number) => React.ReactNode;
|
|
85
90
|
triggerProps?: LinkProps;
|
|
@@ -88,10 +93,13 @@ interface CollapsibleListProps<T> extends React.ComponentPropsWithoutRef<'div'>
|
|
|
88
93
|
defaultExpanded?: boolean;
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
|
|
92
|
-
const CUT_LENGTH = 3;
|
|
96
|
+
const CUT_LENGTH = 3;
|
|
93
97
|
|
|
94
|
-
|
|
98
|
+
export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
|
|
99
|
+
const {
|
|
100
|
+
items, renderItem, triggerProps, cutLength = CUT_LENGTH,
|
|
101
|
+
text: textProp, defaultExpanded = false, ...rest
|
|
102
|
+
} = props;
|
|
95
103
|
|
|
96
104
|
const [ isExpanded, setIsExpanded ] = React.useState(defaultExpanded);
|
|
97
105
|
|
|
@@ -99,28 +107,23 @@ export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
|
|
|
99
107
|
setIsExpanded(defaultExpanded);
|
|
100
108
|
}, [ defaultExpanded ]);
|
|
101
109
|
|
|
102
|
-
const handleToggle = React.useCallback(() =>
|
|
103
|
-
setIsExpanded((flag) => !flag);
|
|
104
|
-
}, []);
|
|
110
|
+
const handleToggle = React.useCallback(() => setIsExpanded((flag) => !flag), []);
|
|
105
111
|
|
|
106
112
|
const text = isExpanded ? (textProp?.[1] ?? 'Hide') : (textProp?.[0] ?? 'Show all');
|
|
107
113
|
|
|
108
114
|
return (
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
) }
|
|
123
|
-
</div>
|
|
124
|
-
</Collapsible.Root>
|
|
115
|
+
<YStack width="100%" { ...rest }>
|
|
116
|
+
{ items.slice(0, isExpanded ? undefined : cutLength).map(renderItem) }
|
|
117
|
+
{ items.length > cutLength && (
|
|
118
|
+
<Link
|
|
119
|
+
className="text-sm underline decoration-dashed w-fit min-w-0"
|
|
120
|
+
aria-expanded={ isExpanded }
|
|
121
|
+
onClick={ handleToggle }
|
|
122
|
+
{ ...triggerProps }
|
|
123
|
+
>
|
|
124
|
+
{ text }
|
|
125
|
+
</Link>
|
|
126
|
+
) }
|
|
127
|
+
</YStack>
|
|
125
128
|
);
|
|
126
129
|
};
|