@enigmax/primitives 0.21.0 → 0.23.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.
- package/dist/chunk-3BQVOOAM.js +388 -0
- package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
- package/dist/chunk-4VUHQFAT.js +130 -0
- package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
- package/dist/chunk-DSBYVA7V.js +713 -0
- package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
- package/dist/{chunk-R4ZAEE7V.js → chunk-KEVZ5XQV.js} +1 -1
- package/dist/chunk-N6PDHMAX.js +213 -0
- package/dist/chunk-NONGREXC.js +248 -0
- package/dist/chunk-VKL3DEIQ.js +804 -0
- package/dist/chunk-WPTBURIC.js +1627 -0
- package/dist/chunk-WSQC3PCC.js +466 -0
- package/dist/context-menu-D3FtTn7v.d.ts +174 -0
- package/dist/{index-dTdAbOWl.d.ts → index-DQNnohoo.d.ts} +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +8 -4
- package/dist/keys-D2zJs1uB.d.ts +100 -0
- package/dist/next/index.d.ts +12 -5
- package/dist/next/index.js +21 -14
- package/dist/react/button.d.ts +2 -2
- package/dist/react/context-menu.d.ts +202 -0
- package/dist/react/context-menu.js +6 -0
- package/dist/react/index.d.ts +11 -4
- package/dist/react/index.js +20 -13
- package/dist/react/input.d.ts +2 -2
- package/dist/react/input.js +1 -1
- package/dist/react/palette.d.ts +1 -1
- package/dist/react/palette.js +3 -3
- package/dist/react/search.d.ts +1 -1
- package/dist/react/search.js +2 -2
- package/dist/react/select.d.ts +217 -0
- package/dist/react/select.js +7 -0
- package/dist/react/selection.d.ts +104 -0
- package/dist/react/selection.js +4 -0
- package/dist/react/slot.d.ts +2 -2
- package/dist/react/toast.d.ts +165 -32
- package/dist/react/toast.js +1 -1
- package/dist/react-router/index.d.ts +12 -5
- package/dist/react-router/index.js +21 -14
- package/dist/search/index.d.ts +2 -2
- package/dist/search/index.js +1 -1
- package/dist/{search-DXxY8SEH.d.ts → search-DYgqRp37.d.ts} +20 -3
- package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
- package/dist/select-ClSy-J1f.d.ts +100 -0
- package/dist/selection-B_pmzHpy.d.ts +150 -0
- package/package.json +21 -2
- package/recipes/context-menu/styles.css +177 -0
- package/recipes/input/styles.css +9 -0
- package/recipes/palette/styles.css +3 -0
- package/recipes/search/tailwind.tsx +3 -2
- package/recipes/select/styles.css +230 -0
- package/recipes/toast/styles.css +688 -192
- package/registry.json +434 -29
- package/src/core/context-menu.ts +694 -0
- package/src/core/keys.ts +264 -0
- package/src/core/search.ts +25 -1
- package/src/core/select.ts +404 -0
- package/src/core/selection.ts +648 -0
- package/src/index.ts +60 -1
- package/src/react/context-menu/context.ts +57 -0
- package/src/react/context-menu/index.tsx +94 -0
- package/src/react/context-menu/root.tsx +846 -0
- package/src/react/context-menu/styles.ts +186 -0
- package/src/react/index.ts +70 -2
- package/src/react/palette/root.tsx +2 -2
- package/src/react/select/context.ts +56 -0
- package/src/react/select/index.tsx +96 -0
- package/src/react/select/root.tsx +839 -0
- package/src/react/select/styles.ts +238 -0
- package/src/react/selection/index.tsx +115 -0
- package/src/react/selection/use-selection.ts +260 -0
- package/src/react/toast/NOTICE +20 -0
- package/src/react/toast/assets.tsx +85 -0
- package/src/react/toast/cn.ts +10 -0
- package/src/react/toast/hooks.ts +13 -0
- package/src/react/toast/index.tsx +736 -0
- package/src/react/toast/state.ts +207 -0
- package/src/react/toast/styles.ts +738 -0
- package/src/react/toast/types.ts +193 -0
- package/src/react/toaster.tsx +76 -341
- package/dist/chunk-JCCL7XKC.js +0 -465
- package/src/react/toast-styles.ts +0 -247
- /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context menu's baseline look.
|
|
3
|
+
*
|
|
4
|
+
* A string rather than a stylesheet because `<ContextMenu.Root>` injects it, for the same
|
|
5
|
+
* reason the select does: a popup with no styles is not a plain-looking control, it is
|
|
6
|
+
* transparent text lying on top of the page - and this one is drawn at the pointer, over
|
|
7
|
+
* whatever happens to be under it. `scripts/sync-recipes.mjs` generates
|
|
8
|
+
* `recipes/context-menu/styles.css` from here, and CI fails when the two drift.
|
|
9
|
+
*
|
|
10
|
+
* Everything below is either a custom property or an attribute selector: override the
|
|
11
|
+
* properties on `:root` for a different-looking menu without writing one selector. The sheet
|
|
12
|
+
* is PREPENDED to `<head>`, so anything the document already has wins on source order at
|
|
13
|
+
* equal specificity.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CONTEXT_MENU_STYLES = `
|
|
17
|
+
:root {
|
|
18
|
+
--enigma-menu-bg: #1c1c1c;
|
|
19
|
+
--enigma-menu-border: #333333;
|
|
20
|
+
--enigma-menu-radius: 0.625rem;
|
|
21
|
+
--enigma-menu-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
|
22
|
+
--enigma-menu-text: #f5f5f5;
|
|
23
|
+
--enigma-menu-muted: #a3a3a3;
|
|
24
|
+
--enigma-menu-font-size: 0.8125rem;
|
|
25
|
+
--enigma-menu-min-width: 12rem;
|
|
26
|
+
--enigma-menu-max-height: 20rem;
|
|
27
|
+
|
|
28
|
+
--enigma-menu-active-bg: #2a2a2a;
|
|
29
|
+
--enigma-menu-accent: #fbbf24;
|
|
30
|
+
--enigma-menu-danger: #f87171;
|
|
31
|
+
--enigma-menu-danger-bg: rgba(248, 113, 113, 0.12);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@media (prefers-color-scheme: light) {
|
|
35
|
+
:root {
|
|
36
|
+
--enigma-menu-bg: #ffffff;
|
|
37
|
+
--enigma-menu-border: #e5e5e5;
|
|
38
|
+
--enigma-menu-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
|
39
|
+
--enigma-menu-text: #171717;
|
|
40
|
+
--enigma-menu-muted: #737373;
|
|
41
|
+
--enigma-menu-active-bg: #f5f5f5;
|
|
42
|
+
--enigma-menu-accent: #b45309;
|
|
43
|
+
--enigma-menu-danger: #b91c1c;
|
|
44
|
+
--enigma-menu-danger-bg: rgba(185, 28, 28, 0.08);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Every panel is positioned in VIEWPORT coordinates, which is what a pointer event reports
|
|
49
|
+
and what a menu opened at the pointer needs. Fixed rather than absolute so a scrolling
|
|
50
|
+
ancestor cannot drag it away from the place it was opened. */
|
|
51
|
+
[data-enigma-menu-panel] {
|
|
52
|
+
position: fixed; z-index: 60;
|
|
53
|
+
box-sizing: border-box;
|
|
54
|
+
min-width: var(--enigma-menu-min-width);
|
|
55
|
+
max-width: min(22rem, calc(100vw - 1rem));
|
|
56
|
+
padding: 0.25rem;
|
|
57
|
+
color: var(--enigma-menu-text);
|
|
58
|
+
background: var(--enigma-menu-bg);
|
|
59
|
+
border: 1px solid var(--enigma-menu-border);
|
|
60
|
+
border-radius: var(--enigma-menu-radius);
|
|
61
|
+
box-shadow: var(--enigma-menu-shadow);
|
|
62
|
+
font-size: var(--enigma-menu-font-size);
|
|
63
|
+
animation: enigma-menu-in 90ms ease-out;
|
|
64
|
+
/* Measured before it is placed, and placed before it is seen: without this the panel is
|
|
65
|
+
painted at 0,0 for one frame and jumps to the pointer on the next. */
|
|
66
|
+
visibility: hidden;
|
|
67
|
+
}
|
|
68
|
+
[data-enigma-menu-panel][data-placed] { visibility: visible; }
|
|
69
|
+
@keyframes enigma-menu-in {
|
|
70
|
+
from { opacity: 0; transform: scale(0.98); }
|
|
71
|
+
to { opacity: 1; transform: none; }
|
|
72
|
+
}
|
|
73
|
+
@media (prefers-reduced-motion: reduce) {
|
|
74
|
+
[data-enigma-menu-panel] { animation: none; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* The heading: what the menu is acting ON. A long file name is cut rather than allowed to
|
|
78
|
+
set the panel's width, and the full one stays reachable through its title attribute. */
|
|
79
|
+
[data-enigma-menu-title] {
|
|
80
|
+
margin: 0; padding: 0.375rem 0.5rem 0.25rem;
|
|
81
|
+
font-size: 0.6875rem; font-weight: 600;
|
|
82
|
+
color: var(--enigma-menu-muted);
|
|
83
|
+
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[data-enigma-menu-search] {
|
|
87
|
+
width: 100%; box-sizing: border-box;
|
|
88
|
+
padding: 0.375rem 0.5rem 0.5rem;
|
|
89
|
+
font: inherit; font-size: var(--enigma-menu-font-size);
|
|
90
|
+
color: var(--enigma-menu-text); background: transparent;
|
|
91
|
+
border: 0; border-bottom: 1px solid var(--enigma-menu-border);
|
|
92
|
+
outline: none;
|
|
93
|
+
}
|
|
94
|
+
[data-enigma-menu-search]::-webkit-search-cancel-button,
|
|
95
|
+
[data-enigma-menu-search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
|
|
96
|
+
|
|
97
|
+
[data-enigma-menu-list] {
|
|
98
|
+
max-height: var(--enigma-menu-max-height);
|
|
99
|
+
overflow-y: auto; overscroll-behavior: contain;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
[data-enigma-menu-item] {
|
|
103
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
104
|
+
padding: 0.3125rem 0.5rem; border-radius: 0.375rem;
|
|
105
|
+
line-height: 1.4;
|
|
106
|
+
color: var(--enigma-menu-text);
|
|
107
|
+
/* The hand, not the arrow. A native menu uses the arrow because the whole surface is
|
|
108
|
+
drawn by the OS and nothing on a page looks like it; here the rows sit on a web page,
|
|
109
|
+
where the hand is what says "this is a thing you press". */
|
|
110
|
+
cursor: pointer; user-select: none;
|
|
111
|
+
}
|
|
112
|
+
[data-enigma-menu-item][data-active] { background: var(--enigma-menu-active-bg); }
|
|
113
|
+
[data-enigma-menu-item][data-disabled] { opacity: 0.45; cursor: not-allowed; }
|
|
114
|
+
[data-enigma-menu-item][data-destructive] { color: var(--enigma-menu-danger); }
|
|
115
|
+
[data-enigma-menu-item][data-destructive][data-active] { background: var(--enigma-menu-danger-bg); }
|
|
116
|
+
|
|
117
|
+
[data-enigma-menu-item-text] { display: grid; min-width: 0; }
|
|
118
|
+
[data-enigma-menu-item-label] { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
119
|
+
[data-enigma-menu-item-description] {
|
|
120
|
+
font-size: 0.6875rem; color: var(--enigma-menu-muted);
|
|
121
|
+
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
[data-enigma-menu-icon] { display: inline-flex; align-items: center; flex: none; width: 1rem; max-height: 1.25em; line-height: 0; }
|
|
125
|
+
|
|
126
|
+
/* The shortcut, on the right, the way every desktop menu prints it. margin-left: auto is
|
|
127
|
+
what pushes it there, and flex: none is what stops a long label squashing it. */
|
|
128
|
+
[data-enigma-menu-shortcut] {
|
|
129
|
+
display: flex; align-items: center; gap: 0.125rem;
|
|
130
|
+
flex: none; margin-left: auto; padding-left: 1.5rem;
|
|
131
|
+
font-size: 0.6875rem; color: var(--enigma-menu-muted);
|
|
132
|
+
font-variant-numeric: tabular-nums;
|
|
133
|
+
}
|
|
134
|
+
[data-enigma-menu-shortcut] kbd { font: inherit; }
|
|
135
|
+
|
|
136
|
+
/* The submenu arrow shares the shortcut's slot, so a row never has both pushed apart. */
|
|
137
|
+
[data-enigma-menu-arrow] {
|
|
138
|
+
flex: none; margin-left: auto;
|
|
139
|
+
width: 0.35rem; height: 0.35rem;
|
|
140
|
+
border-right: 1.5px solid currentColor; border-top: 1.5px solid currentColor;
|
|
141
|
+
transform: rotate(45deg);
|
|
142
|
+
opacity: 0.6;
|
|
143
|
+
}
|
|
144
|
+
[data-enigma-menu-shortcut] + [data-enigma-menu-arrow] { margin-left: 0.5rem; }
|
|
145
|
+
|
|
146
|
+
/* Check and radio: drawn rather than loaded, and reserving their space either way so
|
|
147
|
+
choosing a row does not shift the label under the pointer. */
|
|
148
|
+
[data-enigma-menu-check] {
|
|
149
|
+
flex: none; width: 1rem; height: 1rem;
|
|
150
|
+
display: grid; place-items: center;
|
|
151
|
+
color: var(--enigma-menu-accent);
|
|
152
|
+
}
|
|
153
|
+
[data-enigma-menu-check]::after { content: ""; width: 0.35rem; height: 0.6rem; opacity: 0; }
|
|
154
|
+
[data-enigma-menu-item][aria-checked="true"] [data-enigma-menu-check]::after {
|
|
155
|
+
opacity: 1;
|
|
156
|
+
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
|
|
157
|
+
transform: rotate(45deg) translate(-0.05rem, -0.05rem);
|
|
158
|
+
}
|
|
159
|
+
[data-enigma-menu-item][role="menuitemradio"] [data-enigma-menu-check]::after {
|
|
160
|
+
width: 0.4rem; height: 0.4rem; border-radius: 999px; background: currentColor;
|
|
161
|
+
transform: none; border: 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
[data-enigma-menu-separator] {
|
|
165
|
+
height: 1px; margin: 0.25rem 0.25rem;
|
|
166
|
+
background: var(--enigma-menu-border);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
[data-enigma-menu-label] {
|
|
170
|
+
margin: 0.375rem 0 0.125rem; padding: 0 0.5rem;
|
|
171
|
+
font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
|
|
172
|
+
color: var(--enigma-menu-muted);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
[data-enigma-menu-status] {
|
|
176
|
+
margin: 0; padding: 0.625rem 0.5rem;
|
|
177
|
+
font-size: var(--enigma-menu-font-size); color: var(--enigma-menu-muted);
|
|
178
|
+
/* The quoted query is cut in the text as well, but a pasted string with no spaces has no
|
|
179
|
+
break opportunity at all - so this is what stops it stretching the panel. */
|
|
180
|
+
overflow-wrap: anywhere;
|
|
181
|
+
}
|
|
182
|
+
[data-enigma-menu-status][data-error] { color: var(--enigma-menu-danger); }
|
|
183
|
+
|
|
184
|
+
/* The end-of-window marker: one pixel, so it has a box to be scrolled into. */
|
|
185
|
+
[data-enigma-menu-more] { height: 1px; }
|
|
186
|
+
`;
|
package/src/react/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/
|
|
|
5
5
|
export { useInput, type UseInputResult } from "@/react/use-input";
|
|
6
6
|
export { useSearch, type UseSearchResult } from "@/react/use-search";
|
|
7
7
|
export { type InputOptions, type InputAction, type InputIcon } from "@/core/input";
|
|
8
|
-
export { type SearchOptions, type SearchMatch, type FuseConstructor } from "@/core/search";
|
|
8
|
+
export { shortenQuery, type SearchOptions, type SearchMatch, type FuseConstructor } from "@/core/search";
|
|
9
9
|
export { useButton, type UseButtonResult } from "@/react/use-button";
|
|
10
10
|
export { Button, type ButtonProps } from "@/react/button";
|
|
11
11
|
export { setLinkComponent, getLinkComponent } from "@/react/link";
|
|
@@ -36,7 +36,8 @@ export {
|
|
|
36
36
|
type PasswordScore
|
|
37
37
|
} from "@/core/password";
|
|
38
38
|
export { useNotifications, createNotificationQueue, defaultQueue, type UseNotificationsResult } from "@/react/use-notifications";
|
|
39
|
-
export { Toaster,
|
|
39
|
+
export { Toaster, toast, useSonner, type ToasterProps } from "@/react/toaster";
|
|
40
|
+
export type { ToastT, ExternalToast, ToastClassnames, Action } from "@/react/toast/types";
|
|
40
41
|
export { useNetworkState } from "@/react/use-network-state";
|
|
41
42
|
export { type NetworkState } from "@/core/network";
|
|
42
43
|
export { RelativeTime, type RelativeTimeProps } from "@/react/relative-time";
|
|
@@ -90,3 +91,70 @@ export {
|
|
|
90
91
|
type RecentEntry,
|
|
91
92
|
type RecentStore
|
|
92
93
|
} from "@/core/palette";
|
|
94
|
+
export {
|
|
95
|
+
Select,
|
|
96
|
+
SelectRoot,
|
|
97
|
+
SelectTrigger,
|
|
98
|
+
SelectValue,
|
|
99
|
+
SelectContent,
|
|
100
|
+
SelectSearch,
|
|
101
|
+
SelectList,
|
|
102
|
+
SelectOptionRow,
|
|
103
|
+
useSelectContext,
|
|
104
|
+
type SelectProps,
|
|
105
|
+
type SelectRootProps,
|
|
106
|
+
type SelectSingleProps,
|
|
107
|
+
type SelectMultipleProps,
|
|
108
|
+
type SelectItem,
|
|
109
|
+
type SelectContextValue
|
|
110
|
+
} from "@/react/select";
|
|
111
|
+
export { createSelect, type SelectOption, type SelectOptions, type SelectState, type SelectInstance } from "@/core/select";
|
|
112
|
+
export {
|
|
113
|
+
ContextMenu,
|
|
114
|
+
ContextMenuRoot,
|
|
115
|
+
ContextMenuTrigger,
|
|
116
|
+
ContextMenuContent,
|
|
117
|
+
ContextMenuPanel,
|
|
118
|
+
ContextMenuSearch,
|
|
119
|
+
ContextMenuList,
|
|
120
|
+
ContextMenuRow,
|
|
121
|
+
useContextMenuContext,
|
|
122
|
+
type ContextMenuProps,
|
|
123
|
+
type ContextMenuRootProps,
|
|
124
|
+
type ContextMenuTriggerProps,
|
|
125
|
+
type ContextMenuContentProps,
|
|
126
|
+
type ContextMenuItem,
|
|
127
|
+
type ContextMenuNode,
|
|
128
|
+
type ContextMenuContextValue
|
|
129
|
+
} from "@/react/context-menu";
|
|
130
|
+
export {
|
|
131
|
+
createContextMenu,
|
|
132
|
+
isAction,
|
|
133
|
+
type ContextMenuEntry,
|
|
134
|
+
type ContextMenuAction,
|
|
135
|
+
type ContextMenuOptions,
|
|
136
|
+
type ContextMenuState,
|
|
137
|
+
type ContextMenuInstance
|
|
138
|
+
} from "@/core/context-menu";
|
|
139
|
+
export {
|
|
140
|
+
SelectionList,
|
|
141
|
+
useSelection,
|
|
142
|
+
type SelectionListProps,
|
|
143
|
+
type UseSelectionOptions,
|
|
144
|
+
type UseSelectionResult,
|
|
145
|
+
type SelectionRenderer,
|
|
146
|
+
type SelectionRowRender,
|
|
147
|
+
type MarqueeRect
|
|
148
|
+
} from "@/react/selection";
|
|
149
|
+
export {
|
|
150
|
+
createSelection,
|
|
151
|
+
DEFAULT_SELECTION_SHORTCUTS,
|
|
152
|
+
type SelectionCommand,
|
|
153
|
+
type SelectionCommandName,
|
|
154
|
+
type SelectionCommandEvent,
|
|
155
|
+
type SelectionShortcuts,
|
|
156
|
+
type SelectionOptions,
|
|
157
|
+
type SelectionState,
|
|
158
|
+
type SelectionInstance
|
|
159
|
+
} from "@/core/selection";
|
|
160
|
+
export { parseShortcut, matchesShortcut, shortcutTokens, shortcutText, isApplePlatform, type Shortcut, type ShortcutSpec } from "@/core/keys";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Slot } from "@/react/slot";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { PaletteContext, usePaletteContext, type PaletteRow } from "@/react/palette/context";
|
|
6
|
-
import { createSearch, subsequenceMatcher, type SearchInstance, type SearchMatch, type SearchOptions } from "@/core/search";
|
|
6
|
+
import { createSearch, subsequenceMatcher, shortenQuery, type SearchInstance, type SearchMatch, type SearchOptions } from "@/core/search";
|
|
7
7
|
import { createRecentStore, groupRows, moveActive, shortcutLabel, isPaletteShortcut, type RecentEntry, type PaletteKey } from "@/core/palette";
|
|
8
8
|
import {
|
|
9
9
|
useCallback, useEffect, useId, useMemo, useRef, useState,
|
|
@@ -576,7 +576,7 @@ export function PaletteList<Item>({ children, empty, heading, ...props }: Palett
|
|
|
576
576
|
data-enigma-palette-list=""
|
|
577
577
|
>
|
|
578
578
|
{palette.rows.length === 0
|
|
579
|
-
? empty ?? <p data-enigma-palette-empty="">{palette.query.trim() ? `Nothing matches "${palette.query
|
|
579
|
+
? empty ?? <p data-enigma-palette-empty="">{palette.query.trim() ? `Nothing matches "${shortenQuery(palette.query)}".` : "Type to search."}</p>
|
|
580
580
|
: groups.map((group) => (
|
|
581
581
|
<div key={group.label} role="group" aria-label={group.label} data-enigma-palette-group="">
|
|
582
582
|
{heading !== null && (
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
import type { ReactNode, RefObject } from "react";
|
|
5
|
+
import type { SelectInstance, SelectOption, SelectState } from "@/core/select";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One option, as React draws it. The core carries `icon` as `unknown` because it cannot
|
|
9
|
+
* know what a node is; here it is one.
|
|
10
|
+
*/
|
|
11
|
+
export interface SelectItem extends SelectOption {
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SelectContextValue {
|
|
16
|
+
instance: SelectInstance;
|
|
17
|
+
state: SelectState;
|
|
18
|
+
/** Open or close the panel. React owns that flag; the instance follows it. */
|
|
19
|
+
setOpen: (open: boolean) => void;
|
|
20
|
+
disabled: boolean;
|
|
21
|
+
/** Whether the trigger offers a × that empties the selection. */
|
|
22
|
+
clearable: boolean;
|
|
23
|
+
searchable: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* There is nothing to choose from at all - not a filter that matched nothing, an option
|
|
26
|
+
* list that is empty. The trigger says so and refuses to open, because a panel holding one
|
|
27
|
+
* line of apology is worse than a control that reads as unavailable.
|
|
28
|
+
*/
|
|
29
|
+
empty: boolean;
|
|
30
|
+
/** The options have not arrived yet, which is a different thing from there being none. */
|
|
31
|
+
loading: boolean;
|
|
32
|
+
emptyLabel: ReactNode;
|
|
33
|
+
loadingLabel: ReactNode;
|
|
34
|
+
/** ids the parts need to point at each other. */
|
|
35
|
+
ids: { trigger: string; list: string; field: string; };
|
|
36
|
+
optionId: (index: number) => string;
|
|
37
|
+
triggerRef: RefObject<HTMLButtonElement | null>;
|
|
38
|
+
fieldRef: RefObject<HTMLInputElement | null>;
|
|
39
|
+
/** Close and put focus back where it came from - the trigger, always. */
|
|
40
|
+
close: () => void;
|
|
41
|
+
/** The keys the list answers to, shared by the trigger and the search field. */
|
|
42
|
+
onListKeyDown: (event: React.KeyboardEvent) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Null rather than a default: a part rendered outside its Root is a mistake with an obvious
|
|
47
|
+
* fix, and a silent default would leave it half-working - a trigger that opens nothing, an
|
|
48
|
+
* option that selects into the void.
|
|
49
|
+
*/
|
|
50
|
+
export const SelectContext = createContext<SelectContextValue | null>(null);
|
|
51
|
+
|
|
52
|
+
export function useSelectContext(part: string): SelectContextValue {
|
|
53
|
+
const value = useContext(SelectContext);
|
|
54
|
+
if (!value) throw new Error(`<${part}> must be rendered inside <Select.Root>.`);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import * as parts from "@/react/select/root";
|
|
5
|
+
import { useSelectContext } from "@/react/select/context";
|
|
6
|
+
import type { SelectRootProps, SelectTriggerProps, SelectContentProps, SelectListProps } from "@/react/select/root";
|
|
7
|
+
|
|
8
|
+
export type SelectProps = SelectRootProps & {
|
|
9
|
+
/** What the trigger says with nothing chosen. */
|
|
10
|
+
placeholder?: ReactNode;
|
|
11
|
+
searchPlaceholder?: string;
|
|
12
|
+
/** Removable tags instead of a comma-separated line. Default: on when `multiple`. */
|
|
13
|
+
tags?: boolean;
|
|
14
|
+
maxTags?: number;
|
|
15
|
+
/** Shown when the filter matches nothing. */
|
|
16
|
+
empty?: ReactNode;
|
|
17
|
+
/** Draw a row yourself: the icon, label, description and check are the default. */
|
|
18
|
+
renderOption?: SelectListProps["children"];
|
|
19
|
+
/** On the root, which is the element with a size. `triggerProps` dresses the button. */
|
|
20
|
+
className?: string;
|
|
21
|
+
triggerProps?: SelectTriggerProps;
|
|
22
|
+
contentProps?: SelectContentProps;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The select, assembled.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <Select
|
|
30
|
+
* options={[{ value: "es", label: "Spain", icon: <Flag code="es" /> }]}
|
|
31
|
+
* value={country}
|
|
32
|
+
* onValueChange={setCountry}
|
|
33
|
+
* />
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* Everything it renders is a part you can render yourself instead - see `Select.Root` for
|
|
37
|
+
* the anatomy. This is those parts in the order they belong in, not a different component.
|
|
38
|
+
*
|
|
39
|
+
* `multiple` changes the types with it: `value` and what the change reports are a list, and
|
|
40
|
+
* the compiler says so at the call site. A filter appears on its own once the list is long
|
|
41
|
+
* enough to need one, fuzzy if you hand over Fuse's constructor and plain otherwise.
|
|
42
|
+
*/
|
|
43
|
+
export function Select(props: SelectProps): ReactNode {
|
|
44
|
+
const {
|
|
45
|
+
placeholder = "Select",
|
|
46
|
+
searchPlaceholder = "Search",
|
|
47
|
+
tags,
|
|
48
|
+
maxTags,
|
|
49
|
+
empty,
|
|
50
|
+
renderOption,
|
|
51
|
+
className,
|
|
52
|
+
triggerProps,
|
|
53
|
+
contentProps,
|
|
54
|
+
children,
|
|
55
|
+
...rest
|
|
56
|
+
} = props;
|
|
57
|
+
|
|
58
|
+
// The union survives as far as the props object; the rest spread flattens it, so the
|
|
59
|
+
// cast puts back what TypeScript dropped rather than widening anything.
|
|
60
|
+
const root = rest as SelectRootProps;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<parts.SelectRoot {...root} className={className}>
|
|
64
|
+
<parts.SelectTrigger {...triggerProps}>
|
|
65
|
+
<parts.SelectValue placeholder={placeholder} tags={tags} maxTags={maxTags} />
|
|
66
|
+
</parts.SelectTrigger>
|
|
67
|
+
<parts.SelectContent {...contentProps}>
|
|
68
|
+
{/* Rendered on the root's own decision: `searchable="auto"` is a count, and
|
|
69
|
+
the field has to appear on the same rule the keyboard follows. */}
|
|
70
|
+
<SearchSlot placeholder={searchPlaceholder} />
|
|
71
|
+
<parts.SelectList empty={empty}>{renderOption}</parts.SelectList>
|
|
72
|
+
</parts.SelectContent>
|
|
73
|
+
{children}
|
|
74
|
+
</parts.SelectRoot>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The filter, only where the root decided there is one. */
|
|
79
|
+
function SearchSlot({ placeholder }: { placeholder: string; }): ReactNode {
|
|
80
|
+
const { searchable } = useSelectContext("Select");
|
|
81
|
+
return searchable ? <parts.SelectSearch placeholder={placeholder} /> : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Select.Root = parts.SelectRoot;
|
|
85
|
+
Select.Trigger = parts.SelectTrigger;
|
|
86
|
+
Select.Value = parts.SelectValue;
|
|
87
|
+
Select.Content = parts.SelectContent;
|
|
88
|
+
Select.Search = parts.SelectSearch;
|
|
89
|
+
Select.List = parts.SelectList;
|
|
90
|
+
Select.Option = parts.SelectOptionRow;
|
|
91
|
+
|
|
92
|
+
export * from "@/react/select/root";
|
|
93
|
+
export { useSelectContext, type SelectItem, type SelectContextValue } from "@/react/select/context";
|
|
94
|
+
export { SELECT_STYLES } from "@/react/select/styles";
|
|
95
|
+
export { createSelect, SELECT_SEARCH_KEYS } from "@/core/select";
|
|
96
|
+
export type { SelectOption, SelectOptions, SelectState, SelectInstance, SelectMoveKey } from "@/core/select";
|