@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/accordion.tsx CHANGED
@@ -1,285 +1,231 @@
1
- import * as AccordionPrimitive from '@radix-ui/react-accordion';
1
+ import { Accordion, View, XStack } from '@hanzo/gui';
2
2
  import * as React from 'react';
3
3
 
4
- import { cn } from './utils';
5
-
6
- // Inline chevron icon (east-mini arrow)
7
- const IndicatorIcon = ({ className }: { readonly className?: string }) => (
8
- <svg className={ className } viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
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
- // AccordionRoot
66
+ // Root
15
67
  // ---------------------------------------------------------------------------
16
68
 
17
- interface AccordionRootProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'defaultValue' | 'dir'> {
69
+ export interface AccordionRootProps
70
+ extends Omit<React.ComponentProps<typeof Accordion>, 'type' | 'value' | 'defaultValue' | 'onValueChange' | 'size'> {
18
71
 
19
- /** Open multiple items simultaneously. Defaults to true. */
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
- export const AccordionRoot = React.forwardRef<HTMLDivElement, AccordionRootProps>(
51
- function AccordionRoot(props, ref) {
52
- const {
53
- multiple: _multiple,
54
- value,
55
- defaultValue,
56
- onValueChange,
57
- variant,
58
- size,
59
- noAnimation: _noAnimation,
60
- lazyMount: _lazyMount,
61
- className,
62
- children,
63
- position: _position,
64
- w: _w,
65
- bgColor: _bgColor,
66
- borderRadius: _borderRadius,
67
- ...rest
68
- } = props;
69
-
70
- // Radix requires `type` discriminant. We default to "multiple" to match
71
- // the original Chakra wrapper behavior.
72
- const handleValueChange = React.useCallback(
73
- (next: Array<string>) => {
74
- onValueChange?.({ value: next });
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
- // AccordionItem
114
+ // Item
104
115
  // ---------------------------------------------------------------------------
105
116
 
106
- interface AccordionItemProps extends React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> {
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.forwardRef<HTMLDivElement, AccordionItemProps>(
115
- function AccordionItem(props, ref) {
116
- const { className, as: _as, _first, _last, display: _display, ...rest } = props;
117
- return (
118
- <AccordionPrimitive.Item
119
- ref={ ref }
120
- className={ cn(
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
- // AccordionItemTrigger
129
+ // Trigger
132
130
  // ---------------------------------------------------------------------------
133
131
 
134
- interface AccordionItemTriggerProps extends Omit<React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>, 'dir'> {
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
- // Legacy Chakra style-prop shims
139
- px?: number | string;
140
- py?: number | string;
141
- _hover?: Record<string, string>;
142
- wordBreak?: string;
143
- textAlign?: string;
144
- cursor?: string;
145
- display?: string;
146
- alignItems?: string;
147
- columnGap?: number | string;
148
- }
149
-
150
- export const AccordionItemTrigger = React.forwardRef<
151
- HTMLButtonElement,
152
- AccordionItemTriggerProps
153
- >(function AccordionItemTrigger(props, ref) {
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
- <AccordionPrimitive.Header asChild>
192
- <div>
193
- <AccordionPrimitive.Trigger
194
- ref={ ref }
195
- className={ cn(
196
- 'group/trigger flex w-full cursor-pointer items-center gap-2 py-3 text-left text-[var(--color-text-primary)]',
197
- 'hover:text-[var(--color-hover)]',
198
- 'focus-visible:outline-none',
199
- className,
200
- ) }
201
- { ...rest }
202
- >
203
- { indicatorPlacement === 'start' && !noIndicator && indicator }
204
- { children }
205
- { indicatorPlacement === 'end' && !noIndicator && indicator }
206
- </AccordionPrimitive.Trigger>
207
- </div>
208
- </AccordionPrimitive.Header>
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
- // AccordionItemContent
187
+ // Content
214
188
  // ---------------------------------------------------------------------------
215
189
 
216
- export interface AccordionItemContentProps extends React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> {
217
- // Legacy Chakra style-prop shims
218
- pb?: number | string;
219
- pr?: number | string;
220
- pl?: string;
221
- w?: string;
222
- display?: string;
223
- flexDir?: string;
224
- rowGap?: number | string;
225
- }
226
-
227
- export const AccordionItemContent = React.forwardRef<
228
- HTMLDivElement,
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(value);
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
- const el = document.getElementById(itemToScroll.id);
271
- if (el) {
272
- el.scrollIntoView({ behavior: 'smooth', block: 'start' });
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
- return {
280
- value,
281
- onValueChange,
282
- scrollToItemFromUrl,
283
- };
284
- }, [ value, onValueChange, scrollToItemFromUrl ]);
227
+ return React.useMemo(
228
+ () => ({ value, onValueChange, scrollToItemFromUrl }),
229
+ [ value, onValueChange, scrollToItemFromUrl ],
230
+ );
285
231
  }
@@ -1,12 +1,20 @@
1
- import * as Collapsible from '@radix-ui/react-collapsible';
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
- // Inline useUpdateEffect (effect that skips first mount)
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
- const el = document.getElementById(id);
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
- const { children, id = CUT_ID, onClick, isExpanded: isExpandedProp = false, text: textProp, loading, noScroll, ...rest } = props;
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.Root open={ isExpanded }>
65
- <Collapsible.Trigger asChild>
66
- <Link
67
- className="text-sm underline decoration-dashed w-fit"
68
- onClick={ handleClick }
69
- loading={ loading }
70
- { ...rest }
71
- >
72
- <span id={ id }>{ text }</span>
73
- </Link>
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.Root>
79
+ </Collapsible>
79
80
  );
80
81
  };
81
82
 
82
- interface CollapsibleListProps<T> extends React.ComponentPropsWithoutRef<'div'> {
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
- export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
92
- const CUT_LENGTH = 3;
96
+ const CUT_LENGTH = 3;
93
97
 
94
- const { items, renderItem, triggerProps, cutLength = CUT_LENGTH, text: textProp, defaultExpanded = false, className, ...rest } = props;
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
- <Collapsible.Root open={ isExpanded }>
110
- <div className={ cn('flex flex-col w-full', className) } { ...rest }>
111
- { items.slice(0, isExpanded ? undefined : cutLength).map(renderItem) }
112
- { items.length > cutLength && (
113
- <Collapsible.Trigger asChild>
114
- <Link
115
- className="text-sm underline decoration-dashed w-fit min-w-0"
116
- onClick={ handleToggle }
117
- { ...triggerProps }
118
- >
119
- { text }
120
- </Link>
121
- </Collapsible.Trigger>
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
  };