@luxfi/ui 7.4.10 → 7.4.12

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 (137) 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/alert.cjs +68 -46
  6. package/dist/alert.js +69 -46
  7. package/dist/avatar.cjs +49 -46
  8. package/dist/avatar.js +52 -48
  9. package/dist/badge.cjs +82 -49
  10. package/dist/badge.js +83 -50
  11. package/dist/checkbox.cjs +42 -75
  12. package/dist/checkbox.js +43 -76
  13. package/dist/chrome.cjs +196 -386
  14. package/dist/chrome.js +198 -386
  15. package/dist/close-button.cjs +3 -7
  16. package/dist/close-button.js +3 -7
  17. package/dist/collapsible.cjs +35 -26
  18. package/dist/collapsible.d.cts +2 -1
  19. package/dist/collapsible.d.ts +2 -1
  20. package/dist/collapsible.js +34 -24
  21. package/dist/dialog.cjs +3 -7
  22. package/dist/dialog.d.cts +1 -1
  23. package/dist/dialog.d.ts +1 -1
  24. package/dist/dialog.js +3 -7
  25. package/dist/drawer.cjs +95 -122
  26. package/dist/drawer.d.cts +43 -10
  27. package/dist/drawer.d.ts +43 -10
  28. package/dist/drawer.js +97 -122
  29. package/dist/empty-state.cjs +13 -13
  30. package/dist/empty-state.js +13 -13
  31. package/dist/expiration-selector.cjs +58 -63
  32. package/dist/expiration-selector.js +58 -63
  33. package/dist/field.cjs +60 -109
  34. package/dist/field.d.cts +0 -1
  35. package/dist/field.d.ts +0 -1
  36. package/dist/field.js +61 -109
  37. package/dist/greeks-display.cjs +48 -39
  38. package/dist/greeks-display.js +48 -39
  39. package/dist/heading.cjs +10 -17
  40. package/dist/heading.d.cts +0 -2
  41. package/dist/heading.d.ts +0 -2
  42. package/dist/heading.js +10 -17
  43. package/dist/icon-button.cjs +82 -82
  44. package/dist/icon-button.d.cts +16 -12
  45. package/dist/icon-button.d.ts +16 -12
  46. package/dist/icon-button.js +82 -82
  47. package/dist/index.cjs +1812 -2105
  48. package/dist/index.d.cts +1 -5
  49. package/dist/index.d.ts +1 -5
  50. package/dist/index.js +1814 -2100
  51. package/dist/input-group.cjs +11 -12
  52. package/dist/input-group.js +11 -12
  53. package/dist/input.cjs +27 -27
  54. package/dist/input.d.cts +21 -1
  55. package/dist/input.d.ts +21 -1
  56. package/dist/input.js +25 -28
  57. package/dist/menu.cjs +160 -271
  58. package/dist/menu.d.cts +41 -57
  59. package/dist/menu.d.ts +41 -57
  60. package/dist/menu.js +161 -251
  61. package/dist/option-chain.cjs +89 -67
  62. package/dist/option-chain.js +89 -67
  63. package/dist/option-position.cjs +88 -85
  64. package/dist/option-position.js +88 -85
  65. package/dist/pnl-diagram.cjs +20 -20
  66. package/dist/pnl-diagram.js +20 -20
  67. package/dist/popover.cjs +4 -8
  68. package/dist/popover.js +4 -8
  69. package/dist/progress.cjs +15 -22
  70. package/dist/progress.d.cts +6 -6
  71. package/dist/progress.d.ts +6 -6
  72. package/dist/progress.js +15 -22
  73. package/dist/radio.cjs +35 -67
  74. package/dist/radio.d.cts +18 -18
  75. package/dist/radio.d.ts +18 -18
  76. package/dist/radio.js +36 -68
  77. package/dist/select.cjs +40 -39
  78. package/dist/select.d.cts +35 -4
  79. package/dist/select.d.ts +35 -4
  80. package/dist/select.js +40 -38
  81. package/dist/separator.cjs +8 -33
  82. package/dist/separator.js +8 -33
  83. package/dist/slider.cjs +33 -49
  84. package/dist/slider.js +34 -50
  85. package/dist/switch.cjs +48 -55
  86. package/dist/switch.js +49 -56
  87. package/dist/tabs.cjs +108 -197
  88. package/dist/tabs.d.cts +16 -60
  89. package/dist/tabs.d.ts +16 -60
  90. package/dist/tabs.js +109 -196
  91. package/dist/tag.cjs +112 -69
  92. package/dist/tag.js +113 -69
  93. package/dist/textarea.cjs +56 -27
  94. package/dist/textarea.js +57 -28
  95. package/dist/toaster.cjs +97 -79
  96. package/dist/toaster.d.cts +7 -8
  97. package/dist/toaster.d.ts +7 -8
  98. package/dist/toaster.js +78 -80
  99. package/dist/tooltip.cjs +28 -17
  100. package/dist/tooltip.js +30 -18
  101. package/package.json +2 -16
  102. package/src/accordion.tsx +173 -227
  103. package/src/alert.tsx +78 -45
  104. package/src/avatar.tsx +54 -37
  105. package/src/badge.tsx +65 -44
  106. package/src/checkbox.tsx +70 -89
  107. package/src/close-button.tsx +3 -8
  108. package/src/collapsible.tsx +55 -52
  109. package/src/drawer.tsx +123 -115
  110. package/src/empty-state.tsx +21 -17
  111. package/src/expiration-selector.tsx +84 -66
  112. package/src/field.tsx +68 -124
  113. package/src/greeks-display.tsx +59 -51
  114. package/src/heading.tsx +22 -26
  115. package/src/icon-button.tsx +134 -141
  116. package/src/ink.tsx +37 -0
  117. package/src/input-group.tsx +59 -36
  118. package/src/input.tsx +41 -32
  119. package/src/menu.tsx +224 -397
  120. package/src/option-chain.tsx +154 -104
  121. package/src/option-position.tsx +136 -114
  122. package/src/pin-input.tsx +37 -29
  123. package/src/pnl-diagram.tsx +36 -28
  124. package/src/popover.tsx +1 -1
  125. package/src/progress.tsx +17 -21
  126. package/src/radio.tsx +56 -75
  127. package/src/select.tsx +80 -59
  128. package/src/separator.tsx +13 -34
  129. package/src/slider.tsx +43 -44
  130. package/src/strategy-builder.tsx +245 -156
  131. package/src/switch.tsx +63 -64
  132. package/src/tabs.tsx +166 -296
  133. package/src/tag.tsx +89 -51
  134. package/src/textarea.tsx +19 -29
  135. package/src/toaster.tsx +118 -86
  136. package/src/tooltip.tsx +21 -13
  137. package/tokens.css +79 -0
package/src/tag.tsx CHANGED
@@ -1,7 +1,6 @@
1
+ import { Text, XStack } from '@hanzo/gui';
1
2
  import * as React from 'react';
2
3
 
3
- import { cn } from './utils';
4
-
5
4
  import { CloseButton } from './close-button';
6
5
  import { Skeleton } from './skeleton';
7
6
  import { Tooltip } from './tooltip';
@@ -21,29 +20,29 @@ function TruncatedTextTooltip({ label, children }: { readonly label: React.React
21
20
  type TagVariant = 'subtle' | 'clickable' | 'filter' | 'select';
22
21
  type TagSize = 'sm' | 'md' | 'lg';
23
22
 
24
- const TAG_BASE = 'inline-flex items-center align-top max-w-full select-text rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500';
25
-
26
- const TAG_VARIANT_CLASSES: Record<TagVariant, string> = {
27
- subtle: 'bg-[var(--color-tag-subtle-bg)] text-[var(--color-tag-subtle-fg)]',
28
- clickable: 'cursor-pointer bg-[var(--color-tag-clickable-bg)] text-[var(--color-tag-clickable-fg)] hover:opacity-76',
29
- filter: 'bg-[var(--color-tag-filter-bg)]',
30
- select: 'cursor-pointer bg-[var(--color-tag-select-bg)] text-[var(--color-tag-select-fg)] hover:text-[var(--color-hover)] hover:opacity-76',
23
+ /** The chip background, cursor, and the hover opacity `clickable`/`select` get. */
24
+ const TAG_VARIANT: Record<TagVariant, { backgroundColor: string; cursor?: string; hoverStyle?: Record<string, unknown> }> = {
25
+ subtle: { backgroundColor: 'var(--color-tag-subtle-bg)' },
26
+ clickable: { backgroundColor: 'var(--color-tag-clickable-bg)', cursor: 'pointer', hoverStyle: { opacity: 0.76 } },
27
+ filter: { backgroundColor: 'var(--color-tag-filter-bg)' },
28
+ select: { backgroundColor: 'var(--color-tag-select-bg)', cursor: 'pointer', hoverStyle: { opacity: 0.76 } },
31
29
  };
32
30
 
33
- const TAG_SIZE_CLASSES: Record<TagSize, string> = {
34
- sm: 'px-1 py-0.5 min-h-5 gap-1 text-xs',
35
- md: 'px-1 py-0.5 min-h-6 gap-1 text-sm',
36
- lg: 'px-1.5 py-1.5 min-h-8 min-w-8 gap-1 text-sm',
31
+ /** The label inside it — same four variants, the colour half (`filter` has none). */
32
+ const TAG_VARIANT_FG: Record<TagVariant, string | undefined> = {
33
+ subtle: 'var(--color-tag-subtle-fg)',
34
+ clickable: 'var(--color-tag-clickable-fg)',
35
+ filter: undefined,
36
+ select: 'var(--color-tag-select-fg)',
37
37
  };
38
38
 
39
- const TAG_SELECTED_CLASSES = [
40
- 'bg-[var(--color-tag-select-selected-bg)]',
41
- 'text-[var(--color-tag-select-selected-fg)]',
42
- 'hover:text-[var(--color-tag-select-selected-fg)]',
43
- 'hover:opacity-76',
44
- ].join(' ');
39
+ const TAG_SIZE: Record<TagSize, Record<string, number>> = {
40
+ sm: { paddingHorizontal: 4, paddingVertical: 2, minHeight: 20, gap: 4 },
41
+ md: { paddingHorizontal: 4, paddingVertical: 2, minHeight: 24, gap: 4 },
42
+ lg: { paddingHorizontal: 6, paddingVertical: 6, minHeight: 32, minWidth: 32, gap: 4 },
43
+ };
45
44
 
46
- const TAG_DISABLED_CLASSES = 'opacity-40 pointer-events-none cursor-not-allowed';
45
+ const TAG_SIZE_FONT: Record<TagSize, number> = { sm: 12, md: 14, lg: 14 };
47
46
 
48
47
  export interface TagProps extends Omit<React.ComponentPropsWithRef<'span'>, 'size'> {
49
48
  readonly variant?: TagVariant;
@@ -80,14 +79,39 @@ export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
80
79
  ...rest
81
80
  } = props;
82
81
 
82
+ const resolvedVariant = variant ?? 'subtle';
83
+ const resolvedSize = size ?? 'md';
84
+ const isSelected = Boolean(selected) && !loading;
85
+
86
+ // `selected` overrides the variant's own colours; `disabled` only dims —
87
+ // neither is known until render, so resolve the final tokens here rather
88
+ // than lean on a class-merge order that no longer exists.
89
+ const backgroundColor = isSelected ? 'var(--color-tag-select-selected-bg)' : TAG_VARIANT[resolvedVariant].backgroundColor;
90
+ const color = isSelected ? 'var(--color-tag-select-selected-fg)' : TAG_VARIANT_FG[resolvedVariant];
91
+ const hoverStyle = isSelected ? { opacity: 0.76 } : TAG_VARIANT[resolvedVariant].hoverStyle;
92
+ // Unselected `select` rows preview the selected look on hover; once
93
+ // selected the hover colour equals the resting colour, i.e. no change.
94
+ const textHoverColor = !isSelected && resolvedVariant === 'select' ? 'var(--color-hover)' : undefined;
95
+
83
96
  const labelElement = label ? (
84
- <span className="text-[var(--color-text-secondary)]">{ label }:{ nbsp }</span>
97
+ <Text fontSize={ TAG_SIZE_FONT[resolvedSize] } fontWeight="500" color={ 'var(--color-text-secondary)' as never }>
98
+ { label }:{ nbsp }
99
+ </Text>
85
100
  ) : null;
86
101
 
87
102
  const labelSpan = (
88
- <span className="line-clamp-1 whitespace-nowrap text-ellipsis font-medium inline">
103
+ // `ellipsis` is Text's own built-in single-line-truncation variant —
104
+ // the same rule Tailwind's `line-clamp-1`/`text-ellipsis` drew, through
105
+ // the component's variants rather than three separate style props.
106
+ <Text
107
+ ellipsis
108
+ fontWeight="500"
109
+ fontSize={ TAG_SIZE_FONT[resolvedSize] }
110
+ color={ color as never }
111
+ hoverStyle={ textHoverColor ? { color: textHoverColor as never } : undefined }
112
+ >
89
113
  { labelElement }{ children }
90
- </span>
114
+ </Text>
91
115
  );
92
116
 
93
117
  const contentElement = truncated ? (
@@ -98,43 +122,57 @@ export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
98
122
 
99
123
  return (
100
124
  <Skeleton loading={ loading } asChild>
101
- <span
102
- ref={ ref }
103
- className={ cn(
104
- TAG_BASE,
105
- TAG_VARIANT_CLASSES[variant ?? 'subtle'],
106
- TAG_SIZE_CLASSES[size ?? 'md'],
107
- selected && !loading && TAG_SELECTED_CLASSES,
108
- disabled && TAG_DISABLED_CLASSES,
109
- className,
110
- ) }
111
- { ...(selected && !loading && { 'data-selected': true }) }
125
+ <XStack
126
+ ref={ ref as never }
127
+ render={ <span/> }
128
+ display="inline-flex"
129
+ alignItems="center"
130
+ verticalAlign="top"
131
+ maxWidth="100%"
132
+ userSelect="text"
133
+ { ...TAG_SIZE[resolvedSize] }
134
+ backgroundColor={ backgroundColor as never }
135
+ hoverStyle={ hoverStyle }
136
+ focusVisibleStyle={{
137
+ outlineWidth: 2,
138
+ outlineOffset: 2,
139
+ outlineColor: 'var(--color-gray-500)' as never,
140
+ outlineStyle: 'solid',
141
+ }}
142
+ // One cursor, decided once: disabled outranks the variant's.
143
+ cursor={ disabled ? 'not-allowed' : TAG_VARIANT[resolvedVariant].cursor }
144
+ { ...(disabled ? { opacity: 0.4, pointerEvents: 'none' } : {}) }
145
+ { ...(isSelected && { 'data-selected': true }) }
112
146
  { ...(disabled && { 'data-disabled': true }) }
113
- { ...rest }
147
+ className={ className }
148
+ { ...(rest as object) }
114
149
  >
115
150
  { startElement && (
116
- <span className="shrink-0 inline-flex items-center justify-center empty:hidden">
151
+ <XStack flexShrink={ 0 } alignItems="center" justifyContent="center">
117
152
  { startElement }
118
- </span>
153
+ </XStack>
119
154
  ) }
120
155
  { contentElement }
121
- { endElement && (
122
- <span
123
- { ...endElementProps }
124
- className={ cn(
125
- 'shrink-0 inline-flex items-center justify-center',
126
- endElementProps?.className,
127
- ) }
128
- >
129
- { endElement }
130
- </span>
131
- ) }
156
+ { endElement && (() => {
157
+ const { className: endClassName, ...endElementRest } = endElementProps ?? {};
158
+ return (
159
+ <XStack
160
+ { ...(endElementRest as object) }
161
+ flexShrink={ 0 }
162
+ alignItems="center"
163
+ justifyContent="center"
164
+ className={ endClassName }
165
+ >
166
+ { endElement }
167
+ </XStack>
168
+ );
169
+ })() }
132
170
  { closable && (
133
- <span className="shrink-0 inline-flex items-center justify-center">
171
+ <XStack flexShrink={ 0 } alignItems="center" justifyContent="center">
134
172
  <CloseButton onClick={ onClose }/>
135
- </span>
173
+ </XStack>
136
174
  ) }
137
- </span>
175
+ </XStack>
138
176
  </Skeleton>
139
177
  );
140
178
  },
package/src/textarea.tsx CHANGED
@@ -1,33 +1,11 @@
1
1
  import { TextArea as GuiTextArea } from '@hanzo/gui';
2
2
  import React from 'react';
3
3
 
4
- import { cn } from './utils';
4
+ import { CONTROL, VARIANT } from './input';
5
5
 
6
- const TEXTAREA_BASE = [
7
- 'w-full appearance-none outline-none transition-colors resize-y',
8
- 'bg-[var(--color-input-bg)] text-[var(--color-input-fg)] border-[var(--color-input-border)]',
9
- 'placeholder:text-[var(--color-input-placeholder,theme(colors.gray.400))]',
10
- 'hover:border-[var(--color-input-border-hover,theme(colors.gray.400))]',
11
- 'focus:border-[var(--color-input-border-focus,theme(colors.blue.500))]',
12
- 'focus:placeholder:text-[var(--color-input-placeholder-focus,theme(colors.gray.300))]',
13
- 'disabled:opacity-40 disabled:cursor-not-allowed',
14
- 'read-only:opacity-70',
15
- 'data-[invalid]:border-[var(--color-input-border-invalid,theme(colors.red.500))]',
16
- ].join(' ');
17
-
18
- const TEXTAREA_SIZE_CLASSES: Record<string, string> = {
19
- xs: 'px-2 py-1 text-xs rounded',
20
- sm: 'px-3 py-2 text-sm rounded-md',
21
- md: 'px-4 py-2 text-base rounded-md',
22
- lg: 'px-4 py-3 text-lg rounded-lg',
23
- '2xl': 'px-4 py-3 text-xl rounded-lg',
24
- };
25
-
26
- const TEXTAREA_VARIANT_CLASSES: Record<string, string> = {
27
- outline: 'border border-solid',
28
- filled: 'border-0 bg-[var(--color-input-filled-bg,theme(colors.gray.100))]',
29
- unstyled: 'border-0 bg-transparent p-0',
30
- };
6
+ // gui's TextArea — multiline TextInput on native, `<textarea>` on web. The
7
+ // frame, the variants and the placeholder rule are the Input's: a textarea is a
8
+ // text control that wraps, not a second design.
31
9
 
32
10
  type TextareaSize = 'xs' | 'sm' | 'md' | 'lg' | '2xl';
33
11
  type TextareaVariant = 'outline' | 'filled' | 'unstyled';
@@ -35,12 +13,20 @@ type TextareaVariant = 'outline' | 'filled' | 'unstyled';
35
13
  export interface TextareaProps extends Omit<React.ComponentPropsWithRef<'textarea'>, 'size'> {
36
14
  readonly size?: TextareaSize;
37
15
  readonly variant?: TextareaVariant;
16
+
38
17
  /** Tamagui / React-Native convention — bridged to native `onChange` (see Input). */
39
18
  readonly onChangeText?: (text: string) => void;
40
19
  }
41
20
 
42
- // gui's TextArea multiline TextInput on native, `<textarea>` on web — wearing
43
- // the Lux size/variant classes.
21
+ /** Same ramp as Input, but a textarea is sized by padding, not by height. */
22
+ const SIZE: Record<TextareaSize, Record<string, number>> = {
23
+ xs: { paddingHorizontal: 8, paddingVertical: 4, fontSize: 12, borderRadius: 4 },
24
+ sm: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 14, borderRadius: 6 },
25
+ md: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 16, borderRadius: 6 },
26
+ lg: { paddingHorizontal: 16, paddingVertical: 12, fontSize: 18, borderRadius: 8 },
27
+ '2xl': { paddingHorizontal: 16, paddingVertical: 12, fontSize: 20, borderRadius: 8 },
28
+ };
29
+
44
30
  export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
45
31
  ({ size = 'md', variant = 'outline', className, onChange, onChangeText, ...rest }, ref) => {
46
32
  const handleChange = React.useCallback(
@@ -50,11 +36,15 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
50
36
  },
51
37
  [ onChange, onChangeText ],
52
38
  );
39
+
53
40
  return (
54
41
  <GuiTextArea
55
42
  ref={ ref as never }
56
43
  unstyled
57
- className={ cn(TEXTAREA_BASE, TEXTAREA_SIZE_CLASSES[size], TEXTAREA_VARIANT_CLASSES[variant], className) }
44
+ className={ className ? `lux-control ${ className }` : 'lux-control' }
45
+ { ...CONTROL }
46
+ { ...SIZE[size] }
47
+ { ...VARIANT[variant] }
58
48
  { ...(rest as object) }
59
49
  onChange={ handleChange as never }
60
50
  />
package/src/toaster.tsx CHANGED
@@ -1,89 +1,73 @@
1
1
  'use client';
2
2
 
3
- import type React from 'react';
4
- import { Toaster as SonnerToaster, toast } from 'sonner';
3
+ import { Text, Toast, ToastProvider, ToastViewport, XStack, YStack } from '@hanzo/gui';
4
+ import * as React from 'react';
5
+
6
+ // Toasts, on the gui engine.
7
+ //
8
+ // The published surface is imperative — `toaster.error({ title })` from
9
+ // anywhere, including code that is not a component — while gui's toast is a
10
+ // hook (`useToastController`) that only a descendant of a provider can call.
11
+ // The bridge is a module-level store: callers push onto it, `<Toaster/>`
12
+ // subscribes with `useSyncExternalStore` and renders one gui `Toast` per entry.
13
+ // That is the whole adaptation, and it is why `sonner` is gone rather than
14
+ // wrapped — a second toast implementation would have been a second way to do
15
+ // the same thing.
5
16
 
6
17
  const SECOND = 1_000;
7
18
  const DURATION = 10 * SECOND;
8
19
 
20
+ type ToastType = 'info' | 'warning' | 'success' | 'error' | 'loading';
21
+
9
22
  interface ToastOptions {
10
23
  readonly id?: string | number;
11
24
  readonly title?: string;
12
25
  readonly description?: string;
13
- readonly type?: 'info' | 'warning' | 'success' | 'error' | 'loading';
26
+ readonly type?: ToastType;
14
27
  readonly duration?: number;
15
28
  readonly action?: { readonly label: string; readonly onClick: () => void };
16
29
  readonly onStatusChange?: (details: { readonly status: string }) => void;
17
30
  }
18
31
 
19
- function mapOptions(opts: ToastOptions): Parameters<typeof toast>[1] {
20
- const external: Parameters<typeof toast>[1] = {
21
- id: opts.id,
22
- description: opts.description,
23
- duration: opts.duration,
24
- };
25
-
26
- if (opts.action) {
27
- external.action = {
28
- label: opts.action.label,
29
- onClick: opts.action.onClick,
30
- };
31
- }
32
-
33
- if (opts.onStatusChange) {
34
- const cb = opts.onStatusChange;
35
- external.onDismiss = () => cb({ status: 'unmounted' });
36
- external.onAutoClose = () => cb({ status: 'unmounted' });
37
- }
38
-
39
- return external;
32
+ interface Entry extends ToastOptions {
33
+ readonly id: string | number;
40
34
  }
41
35
 
42
- function create(opts: ToastOptions): string | number {
43
- const external = mapOptions(opts);
44
- const title = opts.title ?? '';
45
-
46
- switch (opts.type) {
47
- case 'success':
48
- return toast.success(title, external);
49
- case 'error':
50
- return toast.error(title, external);
51
- case 'warning':
52
- return toast.warning(title, external);
53
- case 'loading':
54
- return toast.loading(title, external);
55
- case 'info':
56
- default:
57
- return toast.info(title, external);
58
- }
59
- }
36
+ // ── the store ──────────────────────────────────────────────────────────────
60
37
 
61
- function update(id: string | number, opts: ToastOptions): void {
62
- const title = opts.title ?? '';
63
-
64
- // Sonner does not have a dedicated update method.
65
- // Re-calling a typed toast with the same id replaces it in-place.
66
- const external = mapOptions({ ...opts, id });
67
-
68
- switch (opts.type) {
69
- case 'success':
70
- toast.success(title, external);
71
- break;
72
- case 'error':
73
- toast.error(title, external);
74
- break;
75
- case 'warning':
76
- toast.warning(title, external);
77
- break;
78
- case 'loading':
79
- toast.loading(title, external);
80
- break;
81
- case 'info':
82
- default:
83
- toast.info(title, external);
84
- break;
85
- }
86
- }
38
+ let entries: ReadonlyArray<Entry> = [];
39
+ const listeners = new Set<() => void>();
40
+ let counter = 0;
41
+
42
+ const emit = () => listeners.forEach((l) => l());
43
+
44
+ const subscribe = (l: () => void) => {
45
+ listeners.add(l);
46
+ return () => { listeners.delete(l); };
47
+ };
48
+
49
+ /** `useSyncExternalStore` compares by identity, so the array is replaced, never mutated. */
50
+ const snapshot = () => entries;
51
+
52
+ const remove = (id: string | number): void => {
53
+ const found = entries.find((e) => e.id === id);
54
+ entries = entries.filter((e) => e.id !== id);
55
+ emit();
56
+ found?.onStatusChange?.({ status: 'unmounted' });
57
+ };
58
+
59
+ const create = (opts: ToastOptions): string | number => {
60
+ const id = opts.id ?? `toast-${ ++counter }`;
61
+ const entry: Entry = { ...opts, id };
62
+ entries = [ ...entries.filter((e) => e.id !== id), entry ];
63
+ emit();
64
+ return id;
65
+ };
66
+
67
+ const update = (id: string | number, opts: ToastOptions): void => {
68
+ entries = entries.map((e) => (e.id === id ? { ...e, ...opts, id } : e));
69
+ emit();
70
+ };
87
71
 
88
72
  export const toaster = {
89
73
  create,
@@ -91,27 +75,75 @@ export const toaster = {
91
75
  error: (opts: ToastOptions) => create({ ...opts, type: 'error' }),
92
76
  loading: (opts: ToastOptions) => create({ ...opts, type: 'loading' }),
93
77
  update,
94
- remove: (id: string | number) => toast.dismiss(id),
78
+ remove,
95
79
  } as const;
96
80
 
81
+ // ── presentation ───────────────────────────────────────────────────────────
82
+
83
+ /** Border colour carries the severity; the surface stays the popover surface. */
84
+ const EDGE: Record<ToastType, string> = {
85
+ success: 'var(--color-status-good)',
86
+ error: 'var(--color-status-bad)',
87
+ warning: 'var(--color-status-warn)',
88
+ loading: 'var(--color-border-divider)',
89
+ info: 'var(--color-border-divider)',
90
+ };
91
+
92
+ const One = ({ entry }: { entry: Entry }) => (
93
+ <Toast
94
+ key={ entry.id }
95
+ duration={ entry.duration ?? DURATION }
96
+ onOpenChange={ (open: boolean) => { if (!open) remove(entry.id); } }
97
+ unstyled
98
+ flexDirection="column"
99
+ gap={ 4 }
100
+ padding={ 12 }
101
+ borderRadius={ 8 }
102
+ borderWidth={ 1 }
103
+ borderColor={ EDGE[entry.type ?? 'info'] as never }
104
+ backgroundColor={ 'var(--color-popover-bg)' as never }
105
+ transition="quick"
106
+ enterStyle={{ opacity: 0, y: -8 }}
107
+ exitStyle={{ opacity: 0, y: -8 }}
108
+ >
109
+ { entry.title ? (
110
+ <Toast.Title unstyled fontSize={ 14 } fontWeight="600" color={ 'var(--color-text-primary)' as never }>
111
+ { entry.title }
112
+ </Toast.Title>
113
+ ) : null }
114
+ { entry.description ? (
115
+ <Toast.Description unstyled fontSize={ 13 } color={ 'var(--color-text-secondary)' as never }>
116
+ { entry.description }
117
+ </Toast.Description>
118
+ ) : null }
119
+ { /* Toast.Action is the accessible affordance and takes no styling of its
120
+ own; the look belongs to the Text it wraps. */ }
121
+ { entry.action ? (
122
+ <XStack marginTop={ 4 }>
123
+ <Toast.Action altText={ entry.action.label } onPress={ entry.action.onClick }>
124
+ <Text
125
+ cursor="pointer"
126
+ fontSize={ 13 }
127
+ fontWeight="500"
128
+ color={ 'var(--color-link-primary)' as never }
129
+ >
130
+ { entry.action.label }
131
+ </Text>
132
+ </Toast.Action>
133
+ </XStack>
134
+ ) : null }
135
+ </Toast>
136
+ );
137
+
97
138
  export const Toaster: React.FC = () => {
139
+ const list = React.useSyncExternalStore(subscribe, snapshot, snapshot);
140
+
98
141
  return (
99
- <SonnerToaster
100
- position="top-right"
101
- richColors
102
- closeButton
103
- duration={ DURATION }
104
- offset={{ top: 12, right: 12, bottom: 12, left: 12 }}
105
- mobileOffset={{ top: 16, right: 16, bottom: 16, left: 16 }}
106
- toastOptions={{
107
- className: 'font-sans',
108
- style: {
109
- fontFamily: 'var(--font-sans, "Geist", sans-serif)',
110
- color: 'var(--color-text-primary)',
111
- backgroundColor: 'var(--color-bg-primary)',
112
- borderColor: 'var(--color-border-divider)',
113
- },
114
- }}
115
- />
142
+ <ToastProvider>
143
+ <YStack>
144
+ { list.map((entry) => <One key={ entry.id } entry={ entry }/>) }
145
+ </YStack>
146
+ <ToastViewport position="absolute" top={ 12 } right={ 12 }/>
147
+ </ToastProvider>
116
148
  );
117
149
  };
package/src/tooltip.tsx CHANGED
@@ -5,8 +5,8 @@ import {
5
5
  import { useClickAway } from '@uidotdev/usehooks';
6
6
  import * as React from 'react';
7
7
 
8
+ import { ink } from './ink';
8
9
  import { useIsTouch } from './use-narrow';
9
- import { cn } from './utils';
10
10
 
11
11
  /** Map placement string to @hanzogui Popper placement prop. */
12
12
  function mapPlacement(p: string | undefined): string | undefined {
@@ -161,14 +161,21 @@ export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
161
161
  <GuiTooltip.Content
162
162
  ref={ ref }
163
163
  unstyled
164
- className={ cn(
165
- 'z-[9999] overflow-hidden rounded-lg px-3 py-2 text-sm',
166
- 'animate-in fade-in-0 zoom-in-95',
167
- isPopover && 'bg-[var(--color-popover-bg)] text-[var(--color-text-primary)]',
168
- isPopover && 'shadow-[var(--shadow-popover)] border border-[var(--color-border-divider)] max-w-sm',
169
- !isPopover && 'bg-[var(--color-tooltip-bg)] text-[var(--color-tooltip-fg)] max-w-xs',
170
- contentProps?.className,
171
- ) }
164
+ zIndex={ 9999 }
165
+ overflow="hidden"
166
+ borderRadius={ 8 }
167
+ paddingHorizontal={ 12 }
168
+ paddingVertical={ 8 }
169
+ maxWidth={ isPopover ? 384 : 320 }
170
+ backgroundColor={ (isPopover ? 'var(--color-popover-bg)' : 'var(--color-tooltip-bg)') as never }
171
+ transition="quick"
172
+ enterStyle={{ opacity: 0, scale: 0.95 }}
173
+ exitStyle={{ opacity: 0, scale: 0.95 }}
174
+ { ...(isPopover ? {
175
+ borderWidth: 1,
176
+ borderColor: 'var(--color-border-divider)' as never,
177
+ shadowColor: 'var(--shadow-popover)' as never,
178
+ } : {}) }
172
179
  onClick={ interactive ? handleContentClick : undefined }
173
180
  { ...(selected ? { 'data-selected': true } : {}) }
174
181
  { ...contentProps as any }
@@ -176,12 +183,13 @@ export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
176
183
  { showArrow && (
177
184
  <GuiTooltip.Arrow
178
185
  unstyled
179
- className={ cn(
180
- isPopover ? 'fill-[var(--color-popover-bg)]' : 'fill-[var(--color-tooltip-bg)]',
181
- ) }
186
+ backgroundColor={ (isPopover ? 'var(--color-popover-bg)' : 'var(--color-tooltip-bg)') as never }
182
187
  />
183
188
  ) }
184
- { content }
189
+ { ink(content, undefined, {
190
+ fontSize: 14,
191
+ color: (isPopover ? 'var(--color-text-primary)' : 'var(--color-tooltip-fg)') as never,
192
+ }) }
185
193
  </GuiTooltip.Content>
186
194
  </GuiTooltip>
187
195
  </GuiTooltipGroup>