@luxfi/ui 7.0.0 → 7.1.0

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 (60) hide show
  1. package/dist/alert.cjs +162 -285
  2. package/dist/alert.js +162 -285
  3. package/dist/badge.cjs +162 -321
  4. package/dist/badge.d.cts +3 -7
  5. package/dist/badge.d.ts +3 -7
  6. package/dist/badge.js +162 -320
  7. package/dist/button.cjs +373 -484
  8. package/dist/button.d.cts +10 -7
  9. package/dist/button.d.ts +10 -7
  10. package/dist/button.js +373 -484
  11. package/dist/close-button.cjs +37 -18
  12. package/dist/close-button.d.cts +1 -1
  13. package/dist/close-button.d.ts +1 -1
  14. package/dist/close-button.js +37 -18
  15. package/dist/collapsible.cjs +125 -272
  16. package/dist/collapsible.js +125 -272
  17. package/dist/dialog.cjs +60 -44
  18. package/dist/dialog.d.cts +7 -8
  19. package/dist/dialog.d.ts +7 -8
  20. package/dist/dialog.js +60 -43
  21. package/dist/drawer.cjs +37 -13
  22. package/dist/drawer.js +37 -13
  23. package/dist/heading.cjs +3 -8
  24. package/dist/heading.js +3 -8
  25. package/dist/icon-button.cjs +213 -337
  26. package/dist/icon-button.d.cts +4 -4
  27. package/dist/icon-button.d.ts +4 -4
  28. package/dist/icon-button.js +213 -338
  29. package/dist/image.cjs +125 -272
  30. package/dist/image.js +125 -272
  31. package/dist/index.cjs +604 -700
  32. package/dist/index.d.cts +0 -3
  33. package/dist/index.d.ts +0 -3
  34. package/dist/index.js +604 -698
  35. package/dist/link.cjs +125 -272
  36. package/dist/link.js +125 -272
  37. package/dist/popover.cjs +55 -40
  38. package/dist/popover.js +55 -39
  39. package/dist/select.cjs +125 -272
  40. package/dist/select.js +125 -272
  41. package/dist/skeleton.cjs +125 -272
  42. package/dist/skeleton.js +125 -272
  43. package/dist/table.cjs +127 -274
  44. package/dist/table.js +127 -274
  45. package/dist/tag.cjs +184 -306
  46. package/dist/tag.js +184 -305
  47. package/dist/tooltip.cjs +22 -21
  48. package/dist/tooltip.d.cts +2 -3
  49. package/dist/tooltip.d.ts +2 -3
  50. package/dist/tooltip.js +22 -20
  51. package/package.json +9 -7
  52. package/src/badge.tsx +20 -31
  53. package/src/button.tsx +304 -238
  54. package/src/close-button.tsx +48 -22
  55. package/src/dialog.tsx +37 -47
  56. package/src/heading.tsx +8 -19
  57. package/src/icon-button.tsx +129 -104
  58. package/src/popover.tsx +30 -44
  59. package/src/skeleton.tsx +96 -144
  60. package/src/tooltip.tsx +54 -47
@@ -1,11 +1,48 @@
1
+ import { styled, View } from '@hanzogui/core';
1
2
  import * as React from 'react';
2
3
 
3
- import { cn } from './utils';
4
-
5
4
  // eslint-disable-next-line max-len
6
5
  const CLOSE_ICON_PATH = 'M9.44 8.035a.791.791 0 0 0 1.12 0l3.802-3.803a.791.791 0 0 1 1.119 0l.287.287a.79.79 0 0 1 0 1.119L11.965 9.44a.79.79 0 0 0 0 1.118l3.803 3.803a.791.791 0 0 1 0 1.119l-.287.287a.791.791 0 0 1-1.119 0l-3.803-3.803a.79.79 0 0 0-1.118 0l-3.803 3.803a.79.79 0 0 1-1.119 0l-.287-.287a.791.791 0 0 1 0-1.119l3.803-3.803a.791.791 0 0 0 0-1.118L4.232 5.638a.791.791 0 0 1 0-1.119l.287-.287a.791.791 0 0 1 1.119 0L9.44 8.035Z';
7
6
 
8
- export interface CloseButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
7
+ const CloseButtonFrame = styled(View, {
8
+ name: 'LuxCloseButton',
9
+ render: <button type="button" aria-label="Close" />,
10
+ role: 'button',
11
+ cursor: 'pointer',
12
+ display: 'inline-flex',
13
+ alignItems: 'center',
14
+ justifyContent: 'center',
15
+ width: 20,
16
+ height: 20,
17
+ minWidth: 0,
18
+ flexShrink: 1,
19
+ padding: 0,
20
+ borderRadius: 4,
21
+ borderWidth: 0,
22
+ overflow: 'hidden',
23
+ backgroundColor: 'transparent',
24
+
25
+ hoverStyle: {
26
+ backgroundColor: 'transparent',
27
+ },
28
+
29
+ variants: {
30
+ disabled: {
31
+ true: {
32
+ opacity: 0.4,
33
+ pointerEvents: 'none',
34
+ },
35
+ },
36
+ } as const,
37
+ });
38
+
39
+ const CLOSE_BUTTON_CLASSES = [
40
+ 'text-[var(--closeButton-fg,currentColor)]',
41
+ 'hover:text-[var(--hover-color,currentColor)]',
42
+ 'disabled:opacity-40',
43
+ ].join(' ');
44
+
45
+ export interface CloseButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'size'> {
9
46
  readonly variant?: 'plain';
10
47
  readonly size?: 'md';
11
48
  }
@@ -17,23 +54,12 @@ export const CloseButton = React.forwardRef<
17
54
  const { variant: _variant, size: _size, className, children, ...rest } = props;
18
55
 
19
56
  return (
20
- <button
21
- type="button"
22
- aria-label="Close"
23
- ref={ ref }
24
- className={ cn(
25
- 'inline-flex items-center justify-center',
26
- 'size-5 min-w-0 shrink-0 p-0',
27
- 'rounded-sm border-0 overflow-hidden',
28
- 'bg-transparent text-[var(--closeButton-fg,currentColor)]',
29
- 'hover:bg-transparent hover:text-[var(--hover-color,currentColor)]',
30
- 'disabled:opacity-40',
31
- 'cursor-pointer',
32
- className,
33
- ) }
34
- { ...rest }
57
+ <CloseButtonFrame
58
+ ref={ref as any}
59
+ className={[CLOSE_BUTTON_CLASSES, className].filter(Boolean).join(' ')}
60
+ {...(rest as any)}
35
61
  >
36
- { children ?? (
62
+ {children ?? (
37
63
  <svg
38
64
  className="size-5"
39
65
  viewBox="0 0 20 20"
@@ -41,11 +67,11 @@ export const CloseButton = React.forwardRef<
41
67
  aria-hidden="true"
42
68
  >
43
69
  <path
44
- d={ CLOSE_ICON_PATH }
70
+ d={CLOSE_ICON_PATH}
45
71
  fill="currentColor"
46
72
  />
47
73
  </svg>
48
- ) }
49
- </button>
74
+ )}
75
+ </CloseButtonFrame>
50
76
  );
51
77
  });
package/src/dialog.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import * as RadixDialog from '@radix-ui/react-dialog';
1
+ import { Dialog as GuiDialog } from '@hanzogui/dialog';
2
2
  import * as React from 'react';
3
3
 
4
4
  import { cn } from './utils';
@@ -35,14 +35,14 @@ type DialogSize = 'sm' | 'md' | 'full' | 'cover';
35
35
  */
36
36
  type ResponsiveSize = DialogSize | { base?: DialogSize; lgDown?: DialogSize; lg?: DialogSize };
37
37
 
38
- interface DialogContextValue {
38
+ interface DialogSizeContextValue {
39
39
  size: ResponsiveSize;
40
40
  }
41
41
 
42
- const DialogContext = React.createContext<DialogContextValue>({ size: 'md' });
42
+ const DialogSizeContext = React.createContext<DialogSizeContextValue>({ size: 'md' });
43
43
 
44
- function useDialogContext(): DialogContextValue {
45
- return React.useContext(DialogContext);
44
+ function useDialogSizeContext(): DialogSizeContextValue {
45
+ return React.useContext(DialogSizeContext);
46
46
  }
47
47
 
48
48
  // ---------------------------------------------------------------------------
@@ -61,7 +61,7 @@ const CONTENT_SIZE_MAP: Record<DialogSize, string> = {
61
61
  *
62
62
  * For a plain value we return the matching static class. For a breakpoint
63
63
  * object we compose mobile-first responsive classes. The Tailwind config in
64
- * this project defines `lg: 1000px` as breakpoint that aligns with Chakra's
64
+ * this project defines `lg: 1000px` as breakpoint -- that aligns with Chakra's
65
65
  * `lgDown` / `lg` pattern.
66
66
  */
67
67
  function sizeClasses(size: ResponsiveSize): string {
@@ -100,7 +100,7 @@ export interface DialogRootProps {
100
100
  onOpenChange?: (details: { open: boolean }) => void;
101
101
  size?: ResponsiveSize;
102
102
 
103
- /** Accepted for API compat but not implemented animations are CSS-only. */
103
+ /** Accepted for API compat but not implemented -- animations are CSS-only. */
104
104
  motionPreset?: string;
105
105
  modal?: boolean;
106
106
  }
@@ -112,7 +112,7 @@ export const DialogRoot: React.FC<DialogRootProps> = ({
112
112
  onOpenChange,
113
113
  size = 'md',
114
114
  modal = true,
115
- // motionPreset is intentionally unused kept for API compat
115
+ // motionPreset is intentionally unused -- kept for API compat
116
116
  }) => {
117
117
  const handleOpenChange = React.useCallback(
118
118
  (nextOpen: boolean) => {
@@ -121,19 +121,19 @@ export const DialogRoot: React.FC<DialogRootProps> = ({
121
121
  [ onOpenChange ],
122
122
  );
123
123
 
124
- const ctx = React.useMemo<DialogContextValue>(() => ({ size }), [ size ]);
124
+ const ctx = React.useMemo<DialogSizeContextValue>(() => ({ size }), [ size ]);
125
125
 
126
126
  return (
127
- <DialogContext.Provider value={ ctx }>
128
- <RadixDialog.Root
127
+ <DialogSizeContext.Provider value={ ctx }>
128
+ <GuiDialog
129
129
  open={ open }
130
130
  defaultOpen={ defaultOpen }
131
131
  onOpenChange={ handleOpenChange }
132
132
  modal={ modal }
133
133
  >
134
134
  { children }
135
- </RadixDialog.Root>
136
- </DialogContext.Provider>
135
+ </GuiDialog>
136
+ </DialogSizeContext.Provider>
137
137
  );
138
138
  };
139
139
 
@@ -155,8 +155,8 @@ export const DialogContent = React.forwardRef<
155
155
  >(function DialogContent(props, ref) {
156
156
  const {
157
157
  children,
158
- portalled = true,
159
- portalRef,
158
+ portalled: _portalled = true,
159
+ portalRef: _portalRef,
160
160
  backdrop = true,
161
161
  className,
162
162
  paddingTop: _paddingTop,
@@ -169,29 +169,17 @@ export const DialogContent = React.forwardRef<
169
169
  ...(_paddingTop !== undefined ? { paddingTop: typeof _paddingTop === 'number' ? `${ _paddingTop * 4 }px` : _paddingTop } : {}),
170
170
  };
171
171
 
172
- const { size } = useDialogContext();
173
-
174
- let portalProps: RadixDialog.DialogPortalProps;
175
- if (!portalled) {
176
- portalProps = { container: undefined };
177
- } else if (portalRef) {
178
- portalProps = { container: portalRef.current };
179
- } else {
180
- portalProps = {};
181
- }
182
-
183
- // When portalled=false we skip the Radix Portal wrapper entirely
184
- const Wrapper = portalled ? RadixDialog.Portal : React.Fragment;
185
- const wrapperProps = portalled ? portalProps : {};
172
+ const { size } = useDialogSizeContext();
186
173
 
187
174
  return (
188
- <Wrapper { ...wrapperProps }>
175
+ <GuiDialog.Portal>
189
176
  { backdrop && (
190
- <RadixDialog.Overlay
177
+ <GuiDialog.Overlay
178
+ unstyled
191
179
  className="fixed inset-0 z-[1400] bg-black/80"
192
180
  />
193
181
  ) }
194
- { /* Positioner centers the content panel */ }
182
+ { /* Positioner -- centers the content panel */ }
195
183
  <div
196
184
  className={ cn(
197
185
  'fixed inset-0 z-[1400] flex w-screen h-dvh',
@@ -199,8 +187,9 @@ export const DialogContent = React.forwardRef<
199
187
  'overflow-hidden',
200
188
  ) }
201
189
  >
202
- <RadixDialog.Content
190
+ <GuiDialog.Content
203
191
  ref={ ref }
192
+ unstyled
204
193
  className={ cn(
205
194
  // Base content styles
206
195
  'relative flex flex-col w-full p-6 outline-none text-base',
@@ -213,12 +202,12 @@ export const DialogContent = React.forwardRef<
213
202
  className,
214
203
  ) }
215
204
  style={ Object.keys(contentInlineStyle).length > 0 ? contentInlineStyle : undefined }
216
- { ...rest }
205
+ { ...rest as any }
217
206
  >
218
207
  { children }
219
- </RadixDialog.Content>
208
+ </GuiDialog.Content>
220
209
  </div>
221
- </Wrapper>
210
+ </GuiDialog.Portal>
222
211
  );
223
212
  });
224
213
 
@@ -235,11 +224,11 @@ export const DialogCloseTrigger = React.forwardRef<
235
224
  const { className, ...rest } = props;
236
225
 
237
226
  return (
238
- <RadixDialog.Close asChild>
227
+ <GuiDialog.Close asChild>
239
228
  <CloseButton ref={ ref } className={ className } { ...rest }>
240
229
  { props.children }
241
230
  </CloseButton>
242
- </RadixDialog.Close>
231
+ </GuiDialog.Close>
243
232
  );
244
233
  });
245
234
 
@@ -271,14 +260,15 @@ export const DialogHeader = React.forwardRef<
271
260
  { ...rest }
272
261
  >
273
262
  { startElement }
274
- <RadixDialog.Title
263
+ <GuiDialog.Title
264
+ unstyled
275
265
  className={ cn(
276
266
  'text-base lg:text-lg font-medium',
277
267
  'whitespace-nowrap overflow-hidden text-ellipsis',
278
268
  ) }
279
269
  >
280
270
  { children }
281
- </RadixDialog.Title>
271
+ </GuiDialog.Title>
282
272
  <DialogCloseTrigger className="ml-auto"/>
283
273
  </div>
284
274
  );
@@ -335,22 +325,22 @@ export const DialogFooter = React.forwardRef<
335
325
  });
336
326
 
337
327
  // ---------------------------------------------------------------------------
338
- // Simple wrappers that re-export Radix primitives under the old names
328
+ // Simple wrappers that re-export @hanzogui primitives under the old names
339
329
  // ---------------------------------------------------------------------------
340
330
 
341
- export const DialogBackdrop = RadixDialog.Overlay;
331
+ export const DialogBackdrop = GuiDialog.Overlay;
342
332
 
343
- export const DialogTitle = RadixDialog.Title;
333
+ export const DialogTitle = GuiDialog.Title;
344
334
 
345
- export const DialogDescription = RadixDialog.Description;
335
+ export const DialogDescription = GuiDialog.Description;
346
336
 
347
- export interface DialogTriggerProps extends React.ComponentPropsWithoutRef<typeof RadixDialog.Trigger> {}
337
+ export interface DialogTriggerProps extends React.ComponentPropsWithoutRef<'button'> {}
348
338
 
349
- export const DialogTrigger = RadixDialog.Trigger;
339
+ export const DialogTrigger = GuiDialog.Trigger;
350
340
 
351
341
  /**
352
342
  * `DialogActionTrigger` renders its child and closes the dialog on click.
353
343
  * This mirrors the Chakra `Dialog.ActionTrigger` behavior which wraps
354
344
  * children in a close action.
355
345
  */
356
- export const DialogActionTrigger = RadixDialog.Close;
346
+ export const DialogActionTrigger = GuiDialog.Close;
package/src/heading.tsx CHANGED
@@ -9,25 +9,13 @@ export interface HeadingProps extends React.ComponentPropsWithoutRef<'h1'> {
9
9
  size?: string;
10
10
  }
11
11
 
12
- const LEVEL_TAG = {
13
- '1': 'h1',
14
- '2': 'h2',
15
- '3': 'h3',
16
- } as const;
12
+ const LEVEL_TAG = { '1': 'h1', '2': 'h2', '3': 'h3' } as const;
17
13
 
18
14
  // base (mobile) + lg (desktop) responsive text styles per level
19
15
  //
20
- // Level 1: heading.md -> heading.xl
21
- // base: 18px/24px font-500
22
- // lg: 32px/40px font-500 tracking=-0.5px
23
- //
24
- // Level 2: heading.sm -> heading.lg
25
- // base: 16px/24px font-500
26
- // lg: 24px/32px font-500
27
- //
28
- // Level 3: heading.xs -> heading.md
29
- // base: 14px/20px font-600
30
- // lg: 18px/24px font-500
16
+ // Level 1: 18px/24px font-500 -> 32px/40px font-500 tracking=-0.5px
17
+ // Level 2: 16px/24px font-500 -> 24px/32px font-500
18
+ // Level 3: 14px/20px font-600 -> 18px/24px font-500
31
19
  const LEVEL_CLASSES = {
32
20
  '1': 'text-[18px] leading-[24px] font-medium lg:text-[32px] lg:leading-[40px] lg:tracking-[-0.5px]',
33
21
  '2': 'text-[16px] leading-[24px] font-medium lg:text-[24px] lg:leading-[32px]',
@@ -40,14 +28,15 @@ export const Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(
40
28
  function Heading({ level, className, mb, size: _size, style: styleProp, ...rest }, ref) {
41
29
  const Tag = level ? LEVEL_TAG[level] : 'h2';
42
30
  const levelClasses = level ? LEVEL_CLASSES[level] : undefined;
43
- const shimStyle: React.CSSProperties = { ...styleProp };
44
- if (mb !== undefined) shimStyle.marginBottom = typeof mb === 'number' ? `${ mb * 4 }px` : mb;
31
+ const mergedStyle = mb !== undefined
32
+ ? { ...styleProp, marginBottom: typeof mb === 'number' ? `${ mb * 4 }px` : mb }
33
+ : styleProp;
45
34
 
46
35
  return (
47
36
  <Tag
48
37
  ref={ ref }
49
38
  className={ cn(BASE_CLASSES, levelClasses, className) }
50
- style={ Object.keys(shimStyle).length > 0 ? shimStyle : undefined }
39
+ style={ mergedStyle }
51
40
  { ...rest }
52
41
  />
53
42
  );
@@ -1,105 +1,120 @@
1
- import { cva } from 'class-variance-authority';
2
- import React from 'react';
3
-
4
- import { cn } from './utils';
1
+ import { styled, View } from '@hanzogui/core';
2
+ import * as React from 'react';
5
3
 
6
4
  import { Skeleton } from './skeleton';
7
5
 
8
- /* -------------------------------------------------------------------------- */
9
- /* CVA variant definitions */
10
- /* -------------------------------------------------------------------------- */
11
-
12
- const iconButtonVariants = cva(
13
- // base
14
- [
15
- 'inline-flex items-center justify-center',
16
- 'p-0 min-w-auto shrink-0',
17
- 'cursor-pointer border-0 bg-transparent',
18
- 'disabled:opacity-[var(--opacity-control-disabled,0.4)] disabled:cursor-not-allowed',
19
- ],
20
- {
21
- variants: {
22
- variant: {
23
- plain: 'bg-transparent text-inherit border-none hover:bg-transparent',
24
- icon_secondary: [
25
- 'bg-transparent text-icon-secondary border-none',
26
- 'hover:text-hover',
27
- 'data-[selected]:bg-selected-control-bg data-[selected]:text-selected-control-text',
28
- 'data-[selected]:hover:text-hover',
29
- 'data-[expanded]:text-hover',
30
- ],
31
- icon_background: [
32
- 'bg-[var(--color-button-icon-background-bg)] text-icon-secondary border-none',
33
- 'hover:text-hover',
34
- 'data-[selected]:bg-selected-control-bg data-[selected]:text-selected-control-text',
35
- 'data-[selected]:hover:text-hover',
36
- 'data-[expanded]:text-hover',
37
- ],
38
- link: [
39
- 'bg-transparent text-link-primary border-none font-normal px-0 h-auto',
40
- 'hover:bg-transparent hover:text-link-primary-hover',
41
- 'disabled:text-text-secondary',
42
- ],
43
- dropdown: [
44
- 'border-2 border-solid bg-transparent',
45
- 'text-[var(--color-button-dropdown-fg)] border-[var(--color-button-dropdown-border)]',
46
- 'hover:bg-transparent hover:text-hover hover:border-hover',
47
- 'data-[expanded]:bg-transparent data-[expanded]:text-hover data-[expanded]:border-hover',
48
- 'data-[selected]:bg-selected-control-bg data-[selected]:text-selected-control-text data-[selected]:border-transparent',
49
- 'data-[selected]:hover:text-hover',
50
- ],
51
- pagination: [
52
- 'border-2 border-solid bg-transparent',
53
- 'text-[var(--color-button-pagination-fg)] border-[var(--color-button-pagination-border)]',
54
- 'hover:bg-transparent hover:text-hover hover:border-hover',
55
- 'data-[selected]:bg-selected-control-bg data-[selected]:text-selected-control-text data-[selected]:border-transparent',
56
- ],
57
- },
58
- size: {
59
- '2xs': 'size-5 rounded-sm [&_svg]:size-5',
60
- '2xs_alt': 'size-5 rounded-sm [&_svg]:size-3',
61
- md: 'size-8 rounded-[var(--radius-base,8px)] [&_svg]:size-5',
62
- },
6
+ // ---------------------------------------------------------------------------
7
+ // IconButtonFrame — styled base
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const IconButtonFrame = styled(View, {
11
+ name: 'LuxIconButton',
12
+ render: <button type="button" />,
13
+ role: 'button',
14
+ cursor: 'pointer',
15
+ display: 'inline-flex',
16
+ alignItems: 'center',
17
+ justifyContent: 'center',
18
+ padding: 0,
19
+ minWidth: 'auto' as any,
20
+ flexShrink: 1,
21
+ backgroundColor: 'transparent',
22
+ borderWidth: 0,
23
+
24
+ variants: {
25
+ size: {
26
+ '2xs': { width: 20, height: 20, borderRadius: 4 },
27
+ '2xs_alt': { width: 20, height: 20, borderRadius: 4 },
28
+ md: { width: 32, height: 32, borderRadius: 'var(--radius-base, 8px)' as any },
63
29
  },
64
- defaultVariants: {
65
- variant: 'plain',
66
- },
67
- },
68
- );
69
30
 
70
- /* -------------------------------------------------------------------------- */
71
- /* Types */
72
- /* -------------------------------------------------------------------------- */
31
+ disabled: {
32
+ true: {
33
+ opacity: 0.4,
34
+ cursor: 'not-allowed',
35
+ pointerEvents: 'none',
36
+ },
37
+ },
38
+ } as const,
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Variant class maps (CSS custom properties via Tailwind)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const VARIANT_CLASSES: Record<string, string> = {
46
+ plain: 'bg-transparent text-inherit border-none hover:bg-transparent',
47
+
48
+ icon_secondary: [
49
+ 'bg-transparent text-icon-secondary border-none',
50
+ 'hover:text-hover',
51
+ 'data-[selected]:bg-selected-control-bg data-[selected]:text-selected-control-text',
52
+ 'data-[selected]:hover:text-hover',
53
+ 'data-[expanded]:text-hover',
54
+ ].join(' '),
55
+
56
+ icon_background: [
57
+ 'bg-[var(--color-button-icon-background-bg)] text-icon-secondary border-none',
58
+ 'hover:text-hover',
59
+ 'data-[selected]:bg-selected-control-bg data-[selected]:text-selected-control-text',
60
+ 'data-[selected]:hover:text-hover',
61
+ 'data-[expanded]:text-hover',
62
+ ].join(' '),
63
+
64
+ link: [
65
+ 'bg-transparent text-link-primary border-none font-normal px-0 h-auto',
66
+ 'hover:bg-transparent hover:text-link-primary-hover',
67
+ 'disabled:text-text-secondary',
68
+ ].join(' '),
69
+
70
+ dropdown: [
71
+ 'border-2 border-solid bg-transparent',
72
+ 'text-[var(--color-button-dropdown-fg)] border-[var(--color-button-dropdown-border)]',
73
+ 'hover:bg-transparent hover:text-hover hover:border-hover',
74
+ 'data-[expanded]:bg-transparent data-[expanded]:text-hover data-[expanded]:border-hover',
75
+ 'data-[selected]:bg-selected-control-bg data-[selected]:text-selected-control-text data-[selected]:border-transparent',
76
+ 'data-[selected]:hover:text-hover',
77
+ ].join(' '),
78
+
79
+ pagination: [
80
+ 'border-2 border-solid bg-transparent',
81
+ 'text-[var(--color-button-pagination-fg)] border-[var(--color-button-pagination-border)]',
82
+ 'hover:bg-transparent hover:text-hover hover:border-hover',
83
+ 'data-[selected]:bg-selected-control-bg data-[selected]:text-selected-control-text data-[selected]:border-transparent',
84
+ ].join(' '),
85
+ };
86
+
87
+ // SVG icon size classes per size variant
88
+ const ICON_SIZE_CLASSES: Record<string, string> = {
89
+ '2xs': '[&_svg]:size-5',
90
+ '2xs_alt': '[&_svg]:size-3',
91
+ md: '[&_svg]:size-5',
92
+ };
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Types
96
+ // ---------------------------------------------------------------------------
73
97
 
74
98
  type Variant = 'plain' | 'icon_secondary' | 'icon_background' | 'link' | 'dropdown' | 'pagination';
75
99
  type Size = '2xs' | '2xs_alt' | 'md';
76
100
 
77
- export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
78
-
101
+ export interface IconButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'size'> {
79
102
  /** Visual variant */
80
103
  variant?: Variant;
81
-
82
104
  /** Icon-button size */
83
105
  size?: Size;
84
-
85
106
  /** Show a loading spinner (disables button) */
86
107
  loading?: boolean;
87
-
88
108
  /** Render a skeleton placeholder instead of the button */
89
109
  loadingSkeleton?: boolean;
90
-
91
110
  /** Maps to data-expanded for popover trigger styling */
92
111
  expanded?: boolean;
93
-
94
112
  /** Maps to data-selected for toggle styling */
95
113
  selected?: boolean;
96
-
97
114
  /** Maps to data-highlighted */
98
115
  highlighted?: boolean;
99
-
100
116
  /** Polymorphic element type (e.g. "div") */
101
117
  as?: React.ElementType;
102
-
103
118
  // Legacy Chakra style-prop shims
104
119
  boxSize?: number | string;
105
120
  color?: string;
@@ -111,9 +126,11 @@ export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonEl
111
126
  _expanded?: Record<string, string>;
112
127
  }
113
128
 
114
- /* -------------------------------------------------------------------------- */
115
- /* Component */
116
- /* -------------------------------------------------------------------------- */
129
+ // ---------------------------------------------------------------------------
130
+ // Component
131
+ // ---------------------------------------------------------------------------
132
+
133
+ const SP = 4;
117
134
 
118
135
  export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
119
136
  function IconButton(props, ref) {
@@ -128,7 +145,7 @@ export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
128
145
  disabled,
129
146
  className,
130
147
  children,
131
- as: Comp = 'button',
148
+ as: _as,
132
149
  style: styleProp,
133
150
  // Strip Chakra style props
134
151
  boxSize: _boxSize,
@@ -142,47 +159,55 @@ export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
142
159
  ...rest
143
160
  } = props;
144
161
 
145
- const SP = 4;
146
162
  const shimStyle: React.CSSProperties = { ...styleProp };
147
163
  if (_boxSize !== undefined) {
148
- const bs = typeof _boxSize === 'number' ? `${ _boxSize * SP }px` : _boxSize;
164
+ const bs = typeof _boxSize === 'number' ? `${_boxSize * SP}px` : _boxSize;
149
165
  shimStyle.width = bs;
150
166
  shimStyle.height = bs;
151
167
  }
152
168
  if (_color) shimStyle.color = _color;
153
169
  if (_px !== undefined) {
154
- const v = typeof _px === 'number' ? `${ _px * SP }px` : _px;
170
+ const v = typeof _px === 'number' ? `${_px * SP}px` : _px;
155
171
  shimStyle.paddingLeft = v;
156
172
  shimStyle.paddingRight = v;
157
173
  }
158
174
  if (_borderRadius) shimStyle.borderRadius = _borderRadius;
159
- if (_ml !== undefined) shimStyle.marginLeft = typeof _ml === 'number' ? `${ _ml * SP }px` : _ml;
160
- if (_mr !== undefined) shimStyle.marginRight = typeof _mr === 'number' ? `${ _mr * SP }px` : _mr;
161
- const mergedBtnStyle = Object.keys(shimStyle).length > 0 ? shimStyle : undefined;
175
+ if (_ml !== undefined) shimStyle.marginLeft = typeof _ml === 'number' ? `${_ml * SP}px` : _ml;
176
+ if (_mr !== undefined) shimStyle.marginRight = typeof _mr === 'number' ? `${_mr * SP}px` : _mr;
177
+ const mergedStyle = Object.keys(shimStyle).length > 0 ? shimStyle : undefined;
178
+
179
+ const variantClass = VARIANT_CLASSES[variant] ?? VARIANT_CLASSES.plain;
180
+ const iconSizeClass = size ? (ICON_SIZE_CLASSES[size] ?? '') : '';
181
+ const combinedClassName = [variantClass, iconSizeClass, className].filter(Boolean).join(' ');
162
182
 
163
183
  const button = (
164
- <Comp
165
- ref={ ref }
166
- type={ Comp === 'button' ? 'button' : undefined }
167
- disabled={ !loadingSkeleton && (loading || disabled) }
168
- className={ cn(iconButtonVariants({ variant, size }), className) }
169
- style={ mergedBtnStyle }
170
- { ...(expanded ? { 'data-expanded': true } : {}) }
171
- { ...(selected ? { 'data-selected': true } : {}) }
172
- { ...(highlighted ? { 'data-highlighted': true } : {}) }
173
- { ...(loadingSkeleton ? { 'data-loading-skeleton': true } : {}) }
174
- { ...rest }
184
+ <IconButtonFrame
185
+ ref={ref as any}
186
+ size={size as any}
187
+ disabled={(!loadingSkeleton && (loading || disabled)) || undefined}
188
+ render={_as ? (React.createElement(_as) as React.ReactElement) : undefined}
189
+ className={combinedClassName}
190
+ style={mergedStyle}
191
+ {...(expanded ? { 'data-expanded': true } : {})}
192
+ {...(selected ? { 'data-selected': true } : {})}
193
+ {...(highlighted ? { 'data-highlighted': true } : {})}
194
+ {...(loadingSkeleton ? { 'data-loading-skeleton': true } : {})}
195
+ {...(rest as any)}
175
196
  >
176
- { loading ? (
177
- <span className="inline-block size-5 animate-spin rounded-full border-2 border-current border-b-transparent border-l-transparent"/>
178
- ) : children }
179
- </Comp>
197
+ {loading ? (
198
+ <span
199
+ className="inline-block size-5 animate-spin rounded-full border-2 border-current border-b-transparent border-l-transparent"
200
+ role="status"
201
+ aria-label="Loading"
202
+ />
203
+ ) : children}
204
+ </IconButtonFrame>
180
205
  );
181
206
 
182
207
  if (loadingSkeleton) {
183
208
  return (
184
- <Skeleton loading={ loadingSkeleton } asChild ref={ ref as React.ForwardedRef<HTMLDivElement> }>
185
- { button }
209
+ <Skeleton loading={loadingSkeleton} asChild ref={ref as React.ForwardedRef<HTMLDivElement>}>
210
+ {button}
186
211
  </Skeleton>
187
212
  );
188
213
  }