@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,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The select's baseline look.
|
|
3
|
+
*
|
|
4
|
+
* A string rather than a stylesheet because `<Select.Root>` injects it - see the note in
|
|
5
|
+
* root.tsx for why this one component ships a look at all. `scripts/sync-recipes.mjs`
|
|
6
|
+
* generates `recipes/select/styles.css` from here, and CI fails when the two drift, so this
|
|
7
|
+
* module is the source and the `.css` is a copy for anyone who prefers the import.
|
|
8
|
+
*
|
|
9
|
+
* Everything below is either a custom property or an attribute selector: override the
|
|
10
|
+
* properties on `:root` for a different-looking select without writing one selector, and
|
|
11
|
+
* override a selector for the rest. The sheet is PREPENDED to `<head>`, so anything the
|
|
12
|
+
* document already has wins on source order at equal specificity.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const SELECT_STYLES = `
|
|
16
|
+
:root {
|
|
17
|
+
--enigma-select-bg: #171717;
|
|
18
|
+
--enigma-select-border: #404040;
|
|
19
|
+
--enigma-select-border-focus: #a3a3a3;
|
|
20
|
+
--enigma-select-text: #f5f5f5;
|
|
21
|
+
--enigma-select-muted: #a3a3a3;
|
|
22
|
+
--enigma-select-radius: 0.5rem;
|
|
23
|
+
--enigma-select-font-size: 0.875rem;
|
|
24
|
+
--enigma-select-padding: 0.5rem 0.75rem;
|
|
25
|
+
|
|
26
|
+
--enigma-select-panel-bg: #1c1c1c;
|
|
27
|
+
--enigma-select-panel-border: #333333;
|
|
28
|
+
--enigma-select-panel-radius: 0.625rem;
|
|
29
|
+
--enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
|
30
|
+
--enigma-select-panel-width: 100%;
|
|
31
|
+
--enigma-select-list-height: 15rem;
|
|
32
|
+
|
|
33
|
+
--enigma-select-active-bg: #2a2a2a;
|
|
34
|
+
--enigma-select-accent: #fbbf24;
|
|
35
|
+
--enigma-select-tag-bg: #2a2a2a;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@media (prefers-color-scheme: light) {
|
|
39
|
+
:root {
|
|
40
|
+
--enigma-select-bg: #ffffff;
|
|
41
|
+
--enigma-select-border: #d4d4d4;
|
|
42
|
+
--enigma-select-border-focus: #737373;
|
|
43
|
+
--enigma-select-text: #171717;
|
|
44
|
+
--enigma-select-muted: #737373;
|
|
45
|
+
--enigma-select-panel-bg: #ffffff;
|
|
46
|
+
--enigma-select-panel-border: #e5e5e5;
|
|
47
|
+
--enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
|
48
|
+
--enigma-select-active-bg: #f5f5f5;
|
|
49
|
+
--enigma-select-accent: #b45309;
|
|
50
|
+
--enigma-select-tag-bg: #f0f0f0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
[data-enigma-select-root] { position: relative; display: inline-block; min-width: 0; }
|
|
55
|
+
|
|
56
|
+
[data-enigma-select-trigger] {
|
|
57
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
58
|
+
width: 100%; min-width: 0; box-sizing: border-box;
|
|
59
|
+
padding: var(--enigma-select-padding);
|
|
60
|
+
font: inherit; font-size: var(--enigma-select-font-size); text-align: left;
|
|
61
|
+
color: var(--enigma-select-text); background: var(--enigma-select-bg);
|
|
62
|
+
border: 1px solid var(--enigma-select-border); border-radius: var(--enigma-select-radius);
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
}
|
|
65
|
+
[data-enigma-select-trigger]:focus-visible { outline: 2px solid var(--enigma-select-border-focus); outline-offset: 1px; }
|
|
66
|
+
[data-enigma-select-trigger][data-open] { border-color: var(--enigma-select-border-focus); }
|
|
67
|
+
[data-enigma-select-trigger]:disabled,
|
|
68
|
+
[data-enigma-select-trigger][aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
|
|
69
|
+
/* Nothing to choose, or nothing yet: the control reads as unavailable and the caret fades,
|
|
70
|
+
because a caret at full strength promises a panel that will never open. Not the disabled
|
|
71
|
+
look, which would say the field is off rather than that the list is empty. */
|
|
72
|
+
[data-enigma-select-trigger][data-empty],
|
|
73
|
+
[data-enigma-select-trigger][data-loading] { opacity: 1; cursor: default; }
|
|
74
|
+
[data-enigma-select-trigger][data-empty] [data-enigma-select-caret],
|
|
75
|
+
[data-enigma-select-trigger][data-loading] [data-enigma-select-caret] { opacity: 0.25; }
|
|
76
|
+
|
|
77
|
+
/* The indicator: one slot, holding the caret and the × stacked in the same cell. Side by
|
|
78
|
+
side they would be two targets a pixel apart - one of which throws away your choice - and
|
|
79
|
+
the pair would change the trigger's width the moment a value appeared. */
|
|
80
|
+
[data-enigma-select-indicator] {
|
|
81
|
+
display: grid; place-items: center; flex: none;
|
|
82
|
+
margin-left: auto; width: 1rem; height: 1rem;
|
|
83
|
+
}
|
|
84
|
+
[data-enigma-select-indicator] > * { grid-area: 1 / 1; }
|
|
85
|
+
|
|
86
|
+
/* The caret, drawn rather than loaded: an icon font or an SVG file is a request, and this
|
|
87
|
+
is two borders. */
|
|
88
|
+
[data-enigma-select-caret] {
|
|
89
|
+
width: 0.4rem; height: 0.4rem;
|
|
90
|
+
/* Decoration, and it must not be the hit target: the rotation gives it a stacking
|
|
91
|
+
context, which paints it OVER the × sharing its cell and swallows the click. */
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
|
|
94
|
+
transform: translateY(-0.1rem) rotate(45deg);
|
|
95
|
+
opacity: 0.6; transition: transform 120ms ease-out, opacity 120ms ease-out;
|
|
96
|
+
}
|
|
97
|
+
[data-enigma-select-trigger][data-open] [data-enigma-select-caret] { transform: translateY(0.1rem) rotate(225deg); }
|
|
98
|
+
|
|
99
|
+
[data-enigma-select-value] {
|
|
100
|
+
display: flex; align-items: center; gap: 0.375rem;
|
|
101
|
+
/* min-width: 0 is what lets the ellipsis happen: a flex item's default minimum is its
|
|
102
|
+
content, so without it a long label pushes the caret out of the button. */
|
|
103
|
+
min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
|
104
|
+
}
|
|
105
|
+
[data-enigma-select-value][data-placeholder] { color: var(--enigma-select-muted); }
|
|
106
|
+
[data-enigma-select-value][data-tags] { flex-wrap: wrap; white-space: normal; }
|
|
107
|
+
|
|
108
|
+
[data-enigma-select-tag] {
|
|
109
|
+
display: inline-flex; align-items: center; gap: 0.25rem;
|
|
110
|
+
padding: 0.125rem 0.25rem 0.125rem 0.5rem;
|
|
111
|
+
font-size: 0.8125rem; line-height: 1.4;
|
|
112
|
+
background: var(--enigma-select-tag-bg); border-radius: 999px;
|
|
113
|
+
}
|
|
114
|
+
[data-enigma-select-tag][data-rest] { padding: 0.125rem 0.5rem; color: var(--enigma-select-muted); }
|
|
115
|
+
|
|
116
|
+
[data-enigma-select-tag-remove] {
|
|
117
|
+
display: grid; place-items: center;
|
|
118
|
+
width: 1rem; height: 1rem; border-radius: 999px;
|
|
119
|
+
color: var(--enigma-select-muted); font-size: 0.875rem; line-height: 1; cursor: pointer;
|
|
120
|
+
}
|
|
121
|
+
[data-enigma-select-tag-remove]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }
|
|
122
|
+
|
|
123
|
+
/* The ×, in the caret's place and only while the control is under the pointer or holds
|
|
124
|
+
focus. ":focus" and not ":focus-visible": a tap focuses the button, and on a touch screen
|
|
125
|
+
there is no hover to reveal it with. */
|
|
126
|
+
[data-enigma-select-clear] {
|
|
127
|
+
display: grid; place-items: center;
|
|
128
|
+
width: 1rem; height: 1rem; border-radius: 999px;
|
|
129
|
+
color: var(--enigma-select-muted); font-size: 0.875rem; line-height: 1; cursor: pointer;
|
|
130
|
+
opacity: 0; pointer-events: none; transition: opacity 120ms ease-out;
|
|
131
|
+
}
|
|
132
|
+
[data-enigma-select-trigger]:hover [data-enigma-select-clear],
|
|
133
|
+
[data-enigma-select-trigger]:focus [data-enigma-select-clear] { opacity: 1; pointer-events: auto; }
|
|
134
|
+
/* Scoped to a trigger that HAS a × - otherwise hovering a select with nothing to clear
|
|
135
|
+
would hide its caret and leave an empty slot. */
|
|
136
|
+
[data-enigma-select-trigger][data-clearable]:hover [data-enigma-select-caret],
|
|
137
|
+
[data-enigma-select-trigger][data-clearable]:focus [data-enigma-select-caret] { opacity: 0; }
|
|
138
|
+
[data-enigma-select-clear]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }
|
|
139
|
+
|
|
140
|
+
[data-enigma-select-content] {
|
|
141
|
+
position: absolute; z-index: 50;
|
|
142
|
+
top: calc(100% + 0.25rem); left: 0;
|
|
143
|
+
min-width: var(--enigma-select-panel-width); box-sizing: border-box;
|
|
144
|
+
padding: 0.25rem;
|
|
145
|
+
background: var(--enigma-select-panel-bg);
|
|
146
|
+
border: 1px solid var(--enigma-select-panel-border);
|
|
147
|
+
border-radius: var(--enigma-select-panel-radius);
|
|
148
|
+
box-shadow: var(--enigma-select-panel-shadow);
|
|
149
|
+
animation: enigma-select-in 120ms ease-out;
|
|
150
|
+
/* A bound, because the content is arbitrary: a long option label or a query typed into
|
|
151
|
+
the filter would otherwise widen the panel with it. min-width still wins for a
|
|
152
|
+
trigger wider than this, which is what it is for. */
|
|
153
|
+
max-width: min(28rem, calc(100vw - 2rem));
|
|
154
|
+
}
|
|
155
|
+
/* Opening upwards is not a variant, it is the same panel measured against the window: near
|
|
156
|
+
the bottom of the screen the list would otherwise be unreachable. */
|
|
157
|
+
[data-enigma-select-content][data-side="top"] { top: auto; bottom: calc(100% + 0.25rem); }
|
|
158
|
+
[data-enigma-select-content][data-state="closed"] { animation: enigma-select-out 120ms ease-in forwards; pointer-events: none; }
|
|
159
|
+
|
|
160
|
+
@keyframes enigma-select-in {
|
|
161
|
+
from { opacity: 0; transform: translateY(-0.25rem); }
|
|
162
|
+
to { opacity: 1; transform: none; }
|
|
163
|
+
}
|
|
164
|
+
@keyframes enigma-select-out {
|
|
165
|
+
from { opacity: 1; }
|
|
166
|
+
to { opacity: 0; transform: translateY(-0.125rem); }
|
|
167
|
+
}
|
|
168
|
+
@media (prefers-reduced-motion: reduce) {
|
|
169
|
+
[data-enigma-select-content],
|
|
170
|
+
[data-enigma-select-content][data-state="closed"] { animation: none; }
|
|
171
|
+
[data-enigma-select-trigger]::after { transition: none; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
[data-enigma-select-search] {
|
|
175
|
+
width: 100%; box-sizing: border-box;
|
|
176
|
+
padding: 0.375rem 0.5rem 0.5rem;
|
|
177
|
+
font: inherit; font-size: var(--enigma-select-font-size);
|
|
178
|
+
color: var(--enigma-select-text); background: transparent;
|
|
179
|
+
border: 0; border-bottom: 1px solid var(--enigma-select-panel-border);
|
|
180
|
+
outline: none;
|
|
181
|
+
}
|
|
182
|
+
/* WebKit's own clear cross, gone: it is drawn on no other engine, so leaving it in gives
|
|
183
|
+
half the visitors a control the other half never sees. */
|
|
184
|
+
[data-enigma-select-search]::-webkit-search-cancel-button,
|
|
185
|
+
[data-enigma-select-search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
|
|
186
|
+
|
|
187
|
+
[data-enigma-select-list] {
|
|
188
|
+
max-height: var(--enigma-select-list-height);
|
|
189
|
+
margin-top: 0.25rem;
|
|
190
|
+
overflow-y: auto; overscroll-behavior: contain;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
[data-enigma-select-group-label] {
|
|
194
|
+
margin: 0.375rem 0 0.125rem; padding: 0 0.5rem;
|
|
195
|
+
font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
|
|
196
|
+
color: var(--enigma-select-muted);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
[data-enigma-select-option] {
|
|
200
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
201
|
+
padding: 0.4375rem 0.5rem; border-radius: 0.375rem;
|
|
202
|
+
font-size: var(--enigma-select-font-size); color: var(--enigma-select-text);
|
|
203
|
+
cursor: pointer;
|
|
204
|
+
}
|
|
205
|
+
[data-enigma-select-option][data-active] { background: var(--enigma-select-active-bg); }
|
|
206
|
+
[data-enigma-select-option][data-disabled] { opacity: 0.45; cursor: not-allowed; }
|
|
207
|
+
|
|
208
|
+
[data-enigma-select-option-text] { display: grid; min-width: 0; }
|
|
209
|
+
[data-enigma-select-option-label] { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
210
|
+
[data-enigma-select-option-description] {
|
|
211
|
+
font-size: 0.75rem; color: var(--enigma-select-muted);
|
|
212
|
+
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
[data-enigma-select-icon] { display: inline-flex; align-items: center; flex: none; max-height: 1.25em; line-height: 0; }
|
|
216
|
+
|
|
217
|
+
/* The check, drawn with two borders and shown only where it belongs. Reserved space either
|
|
218
|
+
way, so choosing a row does not shift the label under the pointer. */
|
|
219
|
+
[data-enigma-select-check] {
|
|
220
|
+
flex: none; margin-left: auto;
|
|
221
|
+
width: 0.35rem; height: 0.6rem;
|
|
222
|
+
border-right: 2px solid var(--enigma-select-accent); border-bottom: 2px solid var(--enigma-select-accent);
|
|
223
|
+
transform: rotate(45deg) translate(-0.05rem, -0.05rem);
|
|
224
|
+
opacity: 0;
|
|
225
|
+
}
|
|
226
|
+
[data-enigma-select-option][data-selected] [data-enigma-select-check] { opacity: 1; }
|
|
227
|
+
|
|
228
|
+
/* The end-of-window marker: one pixel, so it has a box to be scrolled into. */
|
|
229
|
+
[data-enigma-select-more] { height: 1px; }
|
|
230
|
+
|
|
231
|
+
[data-enigma-select-empty] {
|
|
232
|
+
margin: 0; padding: 0.75rem 0.5rem;
|
|
233
|
+
font-size: var(--enigma-select-font-size); color: var(--enigma-select-muted);
|
|
234
|
+
/* The quoted query is cut in the text as well, but a pasted string with no spaces has
|
|
235
|
+
no break opportunity at all - so this is what stops it stretching the panel. */
|
|
236
|
+
overflow-wrap: anywhere;
|
|
237
|
+
}
|
|
238
|
+
`;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
4
|
+
import { useSelection, type SelectionRenderer, type UseSelectionOptions } from "@/react/selection/use-selection";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `onChange` is dropped from BOTH sides on purpose: the model's means "the state moved" and a
|
|
8
|
+
* div's means an input inside it changed, and one name cannot be both. The selection is
|
|
9
|
+
* reported through `onSelectionChange`, and the whole state through `useSelection`.
|
|
10
|
+
*/
|
|
11
|
+
export interface SelectionListProps<Item> extends Omit<UseSelectionOptions<Item>, "onChange">, Omit<ComponentPropsWithoutRef<"div">, "children" | "onSelect" | "onChange"> {
|
|
12
|
+
/** Draw one row. What it returns goes inside the row element, which this component owns. */
|
|
13
|
+
children: SelectionRenderer<Item>;
|
|
14
|
+
/** Shown instead of the rows when there are none. */
|
|
15
|
+
empty?: ReactNode;
|
|
16
|
+
/** Drag a rubber band over the empty space to select what it covers. Default: on. */
|
|
17
|
+
marquee?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A list whose rows can be selected the way a file manager's are.
|
|
22
|
+
*
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <SelectionList
|
|
25
|
+
* items={files}
|
|
26
|
+
* getId={(file) => file.path}
|
|
27
|
+
* onCommand={(event) => {
|
|
28
|
+
* if (event.command === "delete") remove(event.items);
|
|
29
|
+
* if (event.command === "rename") rename(event.cursor);
|
|
30
|
+
* }}
|
|
31
|
+
* >
|
|
32
|
+
* {({ item }) => <><FileIcon kind={item.kind} />{item.name}</>}
|
|
33
|
+
* </SelectionList>
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* It renders a container and one element per row, with the roles, the ids and the state
|
|
37
|
+
* attributes on them - and nothing else. No borders, no padding, no highlight: `[data-selected]`
|
|
38
|
+
* and `[data-cursor]` are there for the stylesheet, so the list looks like the rest of the
|
|
39
|
+
* product rather than like this package.
|
|
40
|
+
*
|
|
41
|
+
* The shape is the only thing it decides. A table, a grid of cards, a tree or a virtualized
|
|
42
|
+
* window - and anything that needs to reach the model itself, to clear the selection from a
|
|
43
|
+
* toolbar or read it into a header checkbox - uses `useSelection` directly and puts the same
|
|
44
|
+
* props on its own markup. That is the same component with the markup handed back.
|
|
45
|
+
*/
|
|
46
|
+
export function SelectionList<Item>(props: SelectionListProps<Item>): ReactNode {
|
|
47
|
+
const {
|
|
48
|
+
items,
|
|
49
|
+
getId,
|
|
50
|
+
disabled,
|
|
51
|
+
multiple,
|
|
52
|
+
columns,
|
|
53
|
+
page,
|
|
54
|
+
shortcuts,
|
|
55
|
+
onCommand,
|
|
56
|
+
onSelectionChange,
|
|
57
|
+
scrollIntoView,
|
|
58
|
+
children,
|
|
59
|
+
empty,
|
|
60
|
+
marquee = true,
|
|
61
|
+
...rest
|
|
62
|
+
} = props;
|
|
63
|
+
|
|
64
|
+
const selection = useSelection<Item>({
|
|
65
|
+
items, getId, disabled, multiple, columns, page, shortcuts, onCommand, onSelectionChange, scrollIntoView
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const listProps = selection.getListProps<HTMLDivElement>(rest);
|
|
69
|
+
const containerProps = marquee ? selection.getMarqueeProps<HTMLDivElement>(listProps) : listProps;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
// `position: relative` is the one style this component sets, and it is not a look: the
|
|
73
|
+
// rubber band is absolutely positioned inside, and without a positioned ancestor it
|
|
74
|
+
// would be drawn against the page instead of against the list.
|
|
75
|
+
<div {...containerProps} style={{ position: "relative", ...rest.style }}>
|
|
76
|
+
{items.length === 0 && empty !== undefined
|
|
77
|
+
? <div data-enigma-selection-empty="">{empty}</div>
|
|
78
|
+
: items.map((item, index) => (
|
|
79
|
+
<div key={getId ? getId(item, index) : index} {...selection.getItemProps<HTMLDivElement>(index)}>
|
|
80
|
+
{children({
|
|
81
|
+
item,
|
|
82
|
+
index,
|
|
83
|
+
selected: selection.state.selectedSet.has(getId ? getId(item, index) : String(index)),
|
|
84
|
+
cursor: selection.state.cursor === index,
|
|
85
|
+
disabled: Boolean(disabled?.(item, index))
|
|
86
|
+
})}
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
{selection.marquee && (
|
|
90
|
+
// The band is drawn, not just computed: without something on screen a drag over
|
|
91
|
+
// empty space looks like the list selecting rows at random.
|
|
92
|
+
<div
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
data-enigma-selection-marquee=""
|
|
95
|
+
style={{ position: "absolute", pointerEvents: "none", ...selection.marquee }}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { useSelection, type UseSelectionOptions, type UseSelectionResult, type SelectionRenderer, type SelectionRowRender, type MarqueeRect } from "@/react/selection/use-selection";
|
|
103
|
+
export {
|
|
104
|
+
createSelection,
|
|
105
|
+
DEFAULT_SELECTION_SHORTCUTS,
|
|
106
|
+
type SelectionCommand,
|
|
107
|
+
type SelectionCommandName,
|
|
108
|
+
type SelectionCommandEvent,
|
|
109
|
+
type SelectionShortcuts,
|
|
110
|
+
type SelectionOptions,
|
|
111
|
+
type SelectionInstance,
|
|
112
|
+
type SelectionState,
|
|
113
|
+
type SelectionClickModifiers
|
|
114
|
+
} from "@/core/selection";
|
|
115
|
+
export { shortcutTokens, shortcutText, parseShortcut, matchesShortcut, type Shortcut, type ShortcutSpec } from "@/core/keys";
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { KeyboardEvent, PointerEvent, HTMLAttributes, ReactNode } from "react";
|
|
5
|
+
import {
|
|
6
|
+
createSelection,
|
|
7
|
+
type SelectionInstance,
|
|
8
|
+
type SelectionOptions,
|
|
9
|
+
type SelectionState
|
|
10
|
+
} from "@/core/selection";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The selection model as a hook, plus the props that put it on real elements.
|
|
14
|
+
*
|
|
15
|
+
* Prop getters rather than components, because a selectable list is every shape at once - a
|
|
16
|
+
* table, a grid of cards, a tree, a virtualized window - and a component that owned the markup
|
|
17
|
+
* would fit exactly one of them. `<SelectionList>` next door IS this hook with a container and
|
|
18
|
+
* rows around it, for the case where the shape is a plain list.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface UseSelectionOptions<Item> extends Omit<SelectionOptions<Item>, "items"> {
|
|
22
|
+
items: readonly Item[];
|
|
23
|
+
/** Bring the cursor into view when the keyboard moves it. Default: on. */
|
|
24
|
+
scrollIntoView?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseSelectionResult<Item> {
|
|
28
|
+
instance: SelectionInstance<Item>;
|
|
29
|
+
state: SelectionState<Item>;
|
|
30
|
+
/** Whether a row is selected, by id. */
|
|
31
|
+
isSelected: (id: string) => boolean;
|
|
32
|
+
/** The container: the keyboard, the roles and the id the rows point at. */
|
|
33
|
+
getListProps: <T extends HTMLElement = HTMLElement>(props?: HTMLAttributes<T>) => HTMLAttributes<T> & { ref: (node: T | null) => void; };
|
|
34
|
+
/** One row: its state, its id, and the click rule. */
|
|
35
|
+
getItemProps: <T extends HTMLElement = HTMLElement>(index: number, props?: HTMLAttributes<T>) => HTMLAttributes<T> & { id: string; };
|
|
36
|
+
/**
|
|
37
|
+
* A press on the empty space of the container, for a rubber band. Composed OVER the list
|
|
38
|
+
* props rather than beside them, so the two handlers do not overwrite each other:
|
|
39
|
+
* `<div {...getMarqueeProps(getListProps())}>`.
|
|
40
|
+
*/
|
|
41
|
+
getMarqueeProps: <T extends HTMLElement = HTMLElement>(props?: HTMLAttributes<T>) => HTMLAttributes<T>;
|
|
42
|
+
/** The rubber band's rectangle while it is being dragged, in container coordinates. */
|
|
43
|
+
marquee: MarqueeRect | null;
|
|
44
|
+
ids: { list: string; };
|
|
45
|
+
itemId: (index: number) => string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A rubber band, in coordinates relative to the scrolling container's content. */
|
|
49
|
+
export interface MarqueeRect {
|
|
50
|
+
left: number;
|
|
51
|
+
top: number;
|
|
52
|
+
width: number;
|
|
53
|
+
height: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** How far the pointer must travel before a press becomes a rubber band rather than a click. */
|
|
57
|
+
const MARQUEE_SLOP = 4;
|
|
58
|
+
|
|
59
|
+
export function useSelection<Item>(options: UseSelectionOptions<Item>): UseSelectionResult<Item> {
|
|
60
|
+
const { items, scrollIntoView = true, ...rest } = options;
|
|
61
|
+
|
|
62
|
+
const id = useId();
|
|
63
|
+
const listRef = useRef<HTMLElement | null>(null);
|
|
64
|
+
const latest = useRef(options);
|
|
65
|
+
latest.current = options;
|
|
66
|
+
|
|
67
|
+
const instance = useMemo(() => createSelection<Item>({
|
|
68
|
+
items,
|
|
69
|
+
getId: (item, index) => (latest.current.getId ?? ((_: Item, position: number) => String(position)))(item, index),
|
|
70
|
+
disabled: (item, index) => Boolean(latest.current.disabled?.(item, index)),
|
|
71
|
+
onCommand: (event) => latest.current.onCommand?.(event),
|
|
72
|
+
onSelectionChange: (ids, chosen) => latest.current.onSelectionChange?.(ids, chosen)
|
|
73
|
+
// Built once: rebuilding it would drop the selection and the cursor on every render.
|
|
74
|
+
// Everything below is pushed in through update().
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}), []);
|
|
77
|
+
|
|
78
|
+
const [state, setState] = useState<SelectionState<Item>>(() => instance.state);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const unsubscribe = instance.subscribe(setState);
|
|
82
|
+
setState(instance.state);
|
|
83
|
+
return () => {
|
|
84
|
+
unsubscribe();
|
|
85
|
+
instance.destroy();
|
|
86
|
+
};
|
|
87
|
+
}, [instance]);
|
|
88
|
+
|
|
89
|
+
const { multiple, columns, page, shortcuts } = rest;
|
|
90
|
+
// The bindings as a string, because `shortcuts={{ rename: "F3" }}` is a new object on every
|
|
91
|
+
// render: depending on its identity would push it in each time and emit a state for a
|
|
92
|
+
// binding table nobody changed.
|
|
93
|
+
const shortcutSignature = JSON.stringify(shortcuts ?? null);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
instance.update({ items, multiple, columns, page, shortcuts: latest.current.shortcuts });
|
|
97
|
+
}, [instance, items, multiple, columns, page, shortcutSignature]);
|
|
98
|
+
|
|
99
|
+
// The cursor can move by key onto a row that is scrolled out of sight, and a cursor nobody
|
|
100
|
+
// can see is the same as none - every arrow press then looks like it did nothing.
|
|
101
|
+
const cursor = state.cursor;
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!scrollIntoView || cursor < 0) return;
|
|
104
|
+
const row = listRef.current?.querySelector<HTMLElement>(`[data-enigma-selection-index="${cursor}"]`);
|
|
105
|
+
row?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
106
|
+
}, [cursor, scrollIntoView]);
|
|
107
|
+
|
|
108
|
+
const isSelected = useCallback((rowId: string) => state.selectedSet.has(rowId), [state.selectedSet]);
|
|
109
|
+
|
|
110
|
+
const itemId = useCallback((index: number) => `${id}-item-${index}`, [id]);
|
|
111
|
+
|
|
112
|
+
const getListProps = useCallback(<T extends HTMLElement>(props: HTMLAttributes<T> = {}) => ({
|
|
113
|
+
...props,
|
|
114
|
+
ref: (node: T | null) => { listRef.current = node; },
|
|
115
|
+
id: `${id}-list`,
|
|
116
|
+
role: props.role ?? "listbox",
|
|
117
|
+
"aria-multiselectable": (latest.current.multiple ?? true) || undefined,
|
|
118
|
+
// A single tab stop, with the arrows moving a cursor INSIDE it: a list of two hundred
|
|
119
|
+
// rows that are each tabbable is a list nobody can tab past.
|
|
120
|
+
tabIndex: props.tabIndex ?? 0,
|
|
121
|
+
"aria-activedescendant": state.cursor >= 0 ? itemId(state.cursor) : undefined,
|
|
122
|
+
"data-enigma-selection-list": "",
|
|
123
|
+
"data-selected-count": String(state.count),
|
|
124
|
+
onKeyDown: (event: KeyboardEvent<T>) => {
|
|
125
|
+
props.onKeyDown?.(event);
|
|
126
|
+
if (event.defaultPrevented) return;
|
|
127
|
+
// The browser's own meaning is taken only when a binding matched: Ctrl+A selects
|
|
128
|
+
// the whole page, F2 does nothing, and Backspace still navigates back where the
|
|
129
|
+
// list has not claimed them.
|
|
130
|
+
if (instance.keyDown(event)) event.preventDefault();
|
|
131
|
+
}
|
|
132
|
+
}), [id, instance, itemId, state.cursor, state.count]);
|
|
133
|
+
|
|
134
|
+
const getItemProps = useCallback(<T extends HTMLElement>(index: number, props: HTMLAttributes<T> = {}) => {
|
|
135
|
+
const item = latest.current.items[index];
|
|
136
|
+
const rowId = item === undefined ? null : (latest.current.getId ?? ((_: Item, position: number) => String(position)))(item, index);
|
|
137
|
+
const disabled = item !== undefined && Boolean(latest.current.disabled?.(item, index));
|
|
138
|
+
const chosen = rowId !== null && state.selectedSet.has(rowId);
|
|
139
|
+
return {
|
|
140
|
+
...props,
|
|
141
|
+
id: itemId(index),
|
|
142
|
+
role: props.role ?? "option",
|
|
143
|
+
"aria-selected": chosen,
|
|
144
|
+
"aria-disabled": disabled || undefined,
|
|
145
|
+
"data-enigma-selection-item": "",
|
|
146
|
+
"data-enigma-selection-index": String(index),
|
|
147
|
+
"data-selected": chosen ? "" : undefined,
|
|
148
|
+
"data-cursor": state.cursor === index ? "" : undefined,
|
|
149
|
+
"data-disabled": disabled ? "" : undefined,
|
|
150
|
+
onPointerDown: (event: PointerEvent<T>) => {
|
|
151
|
+
props.onPointerDown?.(event);
|
|
152
|
+
if (event.defaultPrevented || disabled || event.button !== 0) return;
|
|
153
|
+
// Down rather than click, so a drag that starts on a row begins from a
|
|
154
|
+
// selection that already includes it - which is what makes dragging a group
|
|
155
|
+
// work instead of collapsing it to the row under the pointer.
|
|
156
|
+
instance.click(index, event);
|
|
157
|
+
},
|
|
158
|
+
onContextMenu: (event: React.MouseEvent<T>) => {
|
|
159
|
+
props.onContextMenu?.(event);
|
|
160
|
+
if (event.defaultPrevented || disabled) return;
|
|
161
|
+
// A right-click on a row OUTSIDE the selection selects it first, and one
|
|
162
|
+
// inside leaves the group alone. Same rule as `targets`, applied to the
|
|
163
|
+
// selection itself - so what the menu acts on is what is highlighted.
|
|
164
|
+
if (rowId !== null && !state.selectedSet.has(rowId)) instance.click(index);
|
|
165
|
+
}
|
|
166
|
+
} as HTMLAttributes<T> & { id: string; };
|
|
167
|
+
}, [instance, itemId, state.selectedSet, state.cursor]);
|
|
168
|
+
|
|
169
|
+
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
|
|
170
|
+
const drag = useRef<{ x: number; y: number; pointer: number; } | null>(null);
|
|
171
|
+
|
|
172
|
+
const getMarqueeProps = useCallback(<T extends HTMLElement>(props: HTMLAttributes<T> = {}) => ({
|
|
173
|
+
...props,
|
|
174
|
+
onPointerDown: (event: PointerEvent<T>) => {
|
|
175
|
+
props.onPointerDown?.(event);
|
|
176
|
+
if (event.defaultPrevented || event.button !== 0) return;
|
|
177
|
+
// Only from EMPTY space: a press that lands on a row is that row's click, and a
|
|
178
|
+
// band started there would fight the drag-and-drop the row probably has.
|
|
179
|
+
if ((event.target as HTMLElement).closest("[data-enigma-selection-item]")) return;
|
|
180
|
+
drag.current = { x: event.clientX, y: event.clientY, pointer: event.pointerId };
|
|
181
|
+
}
|
|
182
|
+
}), []);
|
|
183
|
+
|
|
184
|
+
// The band lives on the window rather than on the container: the pointer leaves the list
|
|
185
|
+
// constantly while dragging one, and a listener on the element would stop tracking there.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const list = listRef.current;
|
|
188
|
+
if (!list) return;
|
|
189
|
+
|
|
190
|
+
const move = (event: globalThis.PointerEvent) => {
|
|
191
|
+
const start = drag.current;
|
|
192
|
+
if (!start || event.pointerId !== start.pointer) return;
|
|
193
|
+
if (!marquee && Math.abs(event.clientX - start.x) < MARQUEE_SLOP && Math.abs(event.clientY - start.y) < MARQUEE_SLOP) return;
|
|
194
|
+
if (!marquee) instance.beginMarquee(event.ctrlKey || event.metaKey);
|
|
195
|
+
|
|
196
|
+
const bounds = list.getBoundingClientRect();
|
|
197
|
+
const left = Math.min(start.x, event.clientX);
|
|
198
|
+
const top = Math.min(start.y, event.clientY);
|
|
199
|
+
const rect = {
|
|
200
|
+
left: left - bounds.left + list.scrollLeft,
|
|
201
|
+
top: top - bounds.top + list.scrollTop,
|
|
202
|
+
width: Math.abs(event.clientX - start.x),
|
|
203
|
+
height: Math.abs(event.clientY - start.y)
|
|
204
|
+
};
|
|
205
|
+
setMarquee(rect);
|
|
206
|
+
|
|
207
|
+
// Measured from the rows themselves rather than from an assumed row height, so
|
|
208
|
+
// this works for a grid, a table and a list of different-sized cards alike.
|
|
209
|
+
const covered: number[] = [];
|
|
210
|
+
for (const row of list.querySelectorAll<HTMLElement>("[data-enigma-selection-item]")) {
|
|
211
|
+
const box = row.getBoundingClientRect();
|
|
212
|
+
const hits = box.right >= Math.min(start.x, event.clientX)
|
|
213
|
+
&& box.left <= Math.max(start.x, event.clientX)
|
|
214
|
+
&& box.bottom >= Math.min(start.y, event.clientY)
|
|
215
|
+
&& box.top <= Math.max(start.y, event.clientY);
|
|
216
|
+
if (hits) covered.push(Number(row.dataset.enigmaSelectionIndex));
|
|
217
|
+
}
|
|
218
|
+
instance.marqueeTo(covered);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const up = () => {
|
|
222
|
+
if (!drag.current) return;
|
|
223
|
+
drag.current = null;
|
|
224
|
+
setMarquee(null);
|
|
225
|
+
instance.endMarquee();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
window.addEventListener("pointermove", move);
|
|
229
|
+
window.addEventListener("pointerup", up);
|
|
230
|
+
window.addEventListener("pointercancel", up);
|
|
231
|
+
return () => {
|
|
232
|
+
window.removeEventListener("pointermove", move);
|
|
233
|
+
window.removeEventListener("pointerup", up);
|
|
234
|
+
window.removeEventListener("pointercancel", up);
|
|
235
|
+
};
|
|
236
|
+
}, [instance, marquee]);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
instance,
|
|
240
|
+
state,
|
|
241
|
+
isSelected,
|
|
242
|
+
getListProps,
|
|
243
|
+
getItemProps,
|
|
244
|
+
getMarqueeProps,
|
|
245
|
+
marquee,
|
|
246
|
+
ids: { list: `${id}-list` },
|
|
247
|
+
itemId
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** What a row renderer is handed. Everything it needs to draw one row and nothing else. */
|
|
252
|
+
export interface SelectionRowRender<Item> {
|
|
253
|
+
item: Item;
|
|
254
|
+
index: number;
|
|
255
|
+
selected: boolean;
|
|
256
|
+
cursor: boolean;
|
|
257
|
+
disabled: boolean;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export type SelectionRenderer<Item> = (row: SelectionRowRender<Item>) => ReactNode;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
The files in this directory are vendored, not written here.
|
|
2
|
+
|
|
3
|
+
They are the toast component from TPEOficial/dymo-saas
|
|
4
|
+
(src/components/internal/toast), itself derived from Sonner
|
|
5
|
+
(https://github.com/emilkowalski/sonner, MIT, Emil Kowalski), and they are kept
|
|
6
|
+
as close to verbatim as a library can keep them so the toast looks the same
|
|
7
|
+
everywhere it is used.
|
|
8
|
+
|
|
9
|
+
Three mechanical changes were unavoidable, and they are the only ones:
|
|
10
|
+
|
|
11
|
+
- `@/utils/cn` became `./cn`, a two-line local file: a package cannot resolve a
|
|
12
|
+
consumer's path alias.
|
|
13
|
+
- `import "./styles.css"` was removed. The stylesheet lives in `styles.ts` as a
|
|
14
|
+
string and `<Toaster />` injects it, because a package cannot assume its
|
|
15
|
+
consumer has a CSS-capable bundler - and a toast at the edge of the screen is
|
|
16
|
+
the one component a forgotten import ruins rather than merely undresses.
|
|
17
|
+
- `JSX.Element` became `React.JSX.Element` in assets.tsx, which is where React
|
|
18
|
+
19's types put it.
|
|
19
|
+
|
|
20
|
+
Refresh them from upstream rather than editing them here.
|