gbs-add-block 1.2.16 → 1.2.17
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/README.md +2 -2
- package/index.cjs +5 -0
- package/package.json +1 -1
- package/source/beta-components/card/README.md +60 -0
- package/source/beta-components/card/__tests__/core.test.ts +57 -0
- package/source/beta-components/card/core/index.ts +4 -0
- package/source/beta-components/card/core/trend.ts +32 -0
- package/source/beta-components/card/core/types.ts +5 -0
- package/source/beta-components/card/index.ts +5 -0
- package/source/beta-components/card/react/Card.tsx +133 -0
- package/source/beta-components/card/react/Stat.tsx +127 -0
- package/source/beta-components/card/react/props.ts +4 -0
- package/source/beta-components/card/styles.css +231 -0
- package/source/beta-components/menu/README.md +68 -0
- package/source/beta-components/menu/__tests__/core.test.ts +120 -0
- package/source/beta-components/menu/core/index.ts +11 -0
- package/source/beta-components/menu/core/navigation.ts +84 -0
- package/source/beta-components/menu/core/types.ts +32 -0
- package/source/beta-components/menu/index.ts +19 -0
- package/source/beta-components/menu/react/Menu.tsx +371 -0
- package/source/beta-components/menu/react/context.ts +31 -0
- package/source/beta-components/menu/react/items.tsx +268 -0
- package/source/beta-components/menu/react/locale.ts +6 -0
- package/source/beta-components/menu/react/props.ts +4 -0
- package/source/beta-components/menu/react/useMenuKeys.ts +124 -0
- package/source/beta-components/menu/styles.css +222 -0
- package/source/beta-components/popover/README.md +44 -0
- package/source/beta-components/popover/core/index.ts +2 -0
- package/source/beta-components/popover/core/types.ts +12 -0
- package/source/beta-components/popover/index.ts +4 -0
- package/source/beta-components/popover/react/Popover.tsx +202 -0
- package/source/beta-components/popover/react/locale.ts +5 -0
- package/source/beta-components/popover/react/props.ts +4 -0
- package/source/beta-components/popover/styles.css +146 -0
- package/source/beta-components/shared/__tests__/position.test.ts +143 -0
- package/source/beta-components/shared/core/position.ts +124 -0
- package/source/beta-components/shared/index.ts +8 -7
- package/source/beta-components/shared/react/Popover.tsx +189 -147
- package/source/beta-components/shared/version.json +4 -4
- package/source/beta-components/shared/version.ts +6 -6
- package/source/beta-components/skeleton/README.md +58 -0
- package/source/beta-components/skeleton/__tests__/core.test.ts +41 -0
- package/source/beta-components/skeleton/core/index.ts +3 -0
- package/source/beta-components/skeleton/core/lines.ts +25 -0
- package/source/beta-components/skeleton/core/types.ts +5 -0
- package/source/beta-components/skeleton/index.ts +5 -0
- package/source/beta-components/skeleton/react/Empty.tsx +51 -0
- package/source/beta-components/skeleton/react/Skeleton.tsx +92 -0
- package/source/beta-components/skeleton/react/props.ts +4 -0
- package/source/beta-components/skeleton/styles.css +186 -0
- package/source/beta-components/spinner/styles.css +174 -167
- package/source/beta-components/tabs/react/Tabs.tsx +65 -17
- package/source/beta-components/tabs/styles.css +264 -257
- package/source/beta-components/tooltip/README.md +43 -0
- package/source/beta-components/tooltip/__tests__/core.test.ts +42 -0
- package/source/beta-components/tooltip/core/index.ts +4 -0
- package/source/beta-components/tooltip/core/types.ts +4 -0
- package/source/beta-components/tooltip/core/visibility.ts +34 -0
- package/source/beta-components/tooltip/index.ts +3 -0
- package/source/beta-components/tooltip/react/Tooltip.tsx +196 -0
- package/source/beta-components/tooltip/react/props.ts +4 -0
- package/source/beta-components/tooltip/styles.css +72 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Card and Stat styles.
|
|
3
|
+
*
|
|
4
|
+
* Tokens read the shared --gbs-* variables when they are set on an ancestor
|
|
5
|
+
* (set them on :root to theme every component at once) and fall back to the
|
|
6
|
+
* same default palette otherwise. Rules live in the `components` layer, so utility classes passed
|
|
7
|
+
* through `className` / `classNames` override them.
|
|
8
|
+
*/
|
|
9
|
+
@layer theme, base, components, utilities;
|
|
10
|
+
|
|
11
|
+
@layer components {
|
|
12
|
+
.cd-root,
|
|
13
|
+
.st-root {
|
|
14
|
+
--cd-bg: var(--gbs-bg, var(--dg-bg, light-dark(#ffffff, #0b0b0e)));
|
|
15
|
+
--cd-fg: var(--gbs-fg, var(--dg-fg, light-dark(#18181b, #f4f4f5)));
|
|
16
|
+
--cd-muted: var(--gbs-muted, var(--dg-muted, light-dark(#71717a, #a1a1aa)));
|
|
17
|
+
--cd-border: var(--gbs-border, var(--dg-border, light-dark(#e4e4e7, #27272a)));
|
|
18
|
+
--cd-hover: var(--gbs-hover, var(--dg-hover, light-dark(#f4f4f5, #1f1f23)));
|
|
19
|
+
--cd-focus: var(--gbs-focus, var(--dg-focus, light-dark(#2563eb, #60a5fa)));
|
|
20
|
+
--cd-success: var(--gbs-success, light-dark(#15803d, #4ade80));
|
|
21
|
+
--cd-danger: var(--gbs-danger, var(--dg-danger, light-dark(#dc2626, #f87171)));
|
|
22
|
+
--cd-shadow: var(--gbs-shadow, var(--dg-shadow, 0 10px 30px -8px light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.6))));
|
|
23
|
+
--cd-radius: var(--gbs-radius, var(--dg-radius, 8px));
|
|
24
|
+
--cd-font-size: var(--gbs-font-size, var(--dg-font-size, 13px));
|
|
25
|
+
--cd-px: 16px;
|
|
26
|
+
|
|
27
|
+
color-scheme: inherit;
|
|
28
|
+
font-size: var(--cd-font-size);
|
|
29
|
+
line-height: 1.5;
|
|
30
|
+
color: var(--cd-fg);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
:where(.dark, [data-theme="dark"]) :is(.cd-root, .st-root) {
|
|
34
|
+
color-scheme: dark;
|
|
35
|
+
}
|
|
36
|
+
:where(.light, [data-theme="light"]) :is(.cd-root, .st-root) {
|
|
37
|
+
color-scheme: light;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
:where(.cd-root, .st-root) *,
|
|
41
|
+
:where(.cd-root, .st-root) *::before,
|
|
42
|
+
:where(.cd-root, .st-root) *::after {
|
|
43
|
+
box-sizing: border-box;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ---------------------------------------------------------------- card */
|
|
47
|
+
|
|
48
|
+
.cd-root {
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-direction: column;
|
|
51
|
+
min-width: 0;
|
|
52
|
+
border-radius: var(--cd-radius);
|
|
53
|
+
background: var(--cd-bg);
|
|
54
|
+
text-decoration: none;
|
|
55
|
+
}
|
|
56
|
+
.cd-root[data-variant="outlined"] {
|
|
57
|
+
border: 1px solid var(--cd-border);
|
|
58
|
+
}
|
|
59
|
+
.cd-root[data-variant="elevated"] {
|
|
60
|
+
border: 1px solid transparent;
|
|
61
|
+
box-shadow: var(--cd-shadow);
|
|
62
|
+
}
|
|
63
|
+
.cd-root[data-variant="plain"] {
|
|
64
|
+
background: transparent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.cd-root[data-padding="none"] {
|
|
68
|
+
--cd-px: 0px;
|
|
69
|
+
}
|
|
70
|
+
.cd-root[data-padding="sm"] {
|
|
71
|
+
--cd-px: 12px;
|
|
72
|
+
}
|
|
73
|
+
.cd-root[data-padding="lg"] {
|
|
74
|
+
--cd-px: 24px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.cd-root[data-interactive] {
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
transition:
|
|
80
|
+
background-color 120ms ease,
|
|
81
|
+
box-shadow 120ms ease,
|
|
82
|
+
translate 120ms ease;
|
|
83
|
+
}
|
|
84
|
+
.cd-root[data-interactive]:hover {
|
|
85
|
+
background: var(--cd-hover);
|
|
86
|
+
}
|
|
87
|
+
.cd-root[data-variant="elevated"][data-interactive]:hover {
|
|
88
|
+
translate: 0 -1px;
|
|
89
|
+
}
|
|
90
|
+
.cd-root[data-interactive]:focus-visible {
|
|
91
|
+
outline: 2px solid var(--cd-focus);
|
|
92
|
+
outline-offset: 2px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.cd-header {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: flex-start;
|
|
98
|
+
gap: 12px;
|
|
99
|
+
padding: var(--cd-px) var(--cd-px) 0;
|
|
100
|
+
}
|
|
101
|
+
.cd-heading {
|
|
102
|
+
flex: 1 1 auto;
|
|
103
|
+
min-width: 0;
|
|
104
|
+
}
|
|
105
|
+
.cd-title {
|
|
106
|
+
font-size: 1.05em;
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
line-height: 1.35;
|
|
109
|
+
}
|
|
110
|
+
.cd-description {
|
|
111
|
+
margin-top: 2px;
|
|
112
|
+
color: var(--cd-muted);
|
|
113
|
+
}
|
|
114
|
+
.cd-actions {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex: none;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 4px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.cd-body {
|
|
122
|
+
flex: 1 1 auto;
|
|
123
|
+
min-width: 0;
|
|
124
|
+
padding: var(--cd-px);
|
|
125
|
+
}
|
|
126
|
+
/* The header already spaces the top, so the body should not double it. */
|
|
127
|
+
.cd-header + .cd-body {
|
|
128
|
+
padding-top: 12px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.cd-footer {
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-wrap: wrap;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 8px;
|
|
136
|
+
padding: 12px var(--cd-px);
|
|
137
|
+
border-top: 1px solid var(--cd-border);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ---------------------------------------------------------------- stat */
|
|
141
|
+
|
|
142
|
+
.st-root {
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-direction: column;
|
|
145
|
+
gap: 4px;
|
|
146
|
+
min-width: 0;
|
|
147
|
+
}
|
|
148
|
+
.st-head {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 8px;
|
|
152
|
+
}
|
|
153
|
+
.st-label {
|
|
154
|
+
flex: 1 1 auto;
|
|
155
|
+
min-width: 0;
|
|
156
|
+
color: var(--cd-muted);
|
|
157
|
+
font-weight: 500;
|
|
158
|
+
overflow: hidden;
|
|
159
|
+
text-overflow: ellipsis;
|
|
160
|
+
white-space: nowrap;
|
|
161
|
+
}
|
|
162
|
+
.st-icon {
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
flex: none;
|
|
165
|
+
color: var(--cd-muted);
|
|
166
|
+
}
|
|
167
|
+
.st-value {
|
|
168
|
+
font-size: 1.9em;
|
|
169
|
+
font-weight: 650;
|
|
170
|
+
font-variant-numeric: tabular-nums;
|
|
171
|
+
line-height: 1.15;
|
|
172
|
+
letter-spacing: -0.01em;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.st-trend {
|
|
176
|
+
display: flex;
|
|
177
|
+
align-items: center;
|
|
178
|
+
gap: 4px;
|
|
179
|
+
color: var(--cd-muted);
|
|
180
|
+
}
|
|
181
|
+
/* Colour is a second signal: the arrow and the visually hidden word carry
|
|
182
|
+
the direction on their own. */
|
|
183
|
+
.st-trend[data-positive="true"] {
|
|
184
|
+
color: var(--cd-success);
|
|
185
|
+
}
|
|
186
|
+
.st-trend[data-positive="false"] {
|
|
187
|
+
color: var(--cd-danger);
|
|
188
|
+
}
|
|
189
|
+
.st-trend-label {
|
|
190
|
+
font-variant-numeric: tabular-nums;
|
|
191
|
+
font-weight: 500;
|
|
192
|
+
}
|
|
193
|
+
.st-trend-description {
|
|
194
|
+
color: var(--cd-muted);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.st-help {
|
|
198
|
+
color: var(--cd-muted);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.st-placeholder {
|
|
202
|
+
border-radius: 4px;
|
|
203
|
+
background: var(--cd-hover);
|
|
204
|
+
}
|
|
205
|
+
.st-placeholder-value {
|
|
206
|
+
width: 5ch;
|
|
207
|
+
height: 1.9em;
|
|
208
|
+
}
|
|
209
|
+
.st-placeholder-help {
|
|
210
|
+
width: 10ch;
|
|
211
|
+
height: 1em;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.st-sr-only {
|
|
215
|
+
position: absolute;
|
|
216
|
+
width: 1px;
|
|
217
|
+
height: 1px;
|
|
218
|
+
margin: -1px;
|
|
219
|
+
padding: 0;
|
|
220
|
+
overflow: hidden;
|
|
221
|
+
border: 0;
|
|
222
|
+
clip-path: inset(50%);
|
|
223
|
+
white-space: nowrap;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@media (forced-colors: active) {
|
|
227
|
+
.cd-root[data-variant="elevated"] {
|
|
228
|
+
border-color: CanvasText;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Menu
|
|
2
|
+
|
|
3
|
+
A menu of commands for React 19, following the WAI-ARIA menu button pattern. No
|
|
4
|
+
runtime dependencies besides React.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import {
|
|
8
|
+
Menu, MenuItem, MenuCheckboxItem, MenuRadioGroup, MenuRadioItem,
|
|
9
|
+
MenuGroup, MenuSeparator, MenuSub,
|
|
10
|
+
} from "@/components/menu";
|
|
11
|
+
import "@/components/menu/styles.css";
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
<Menu trigger={<Button variant="outline">Actions</Button>} label="Row actions">
|
|
16
|
+
<MenuItem icon={<EditIcon />} shortcut="⌘E" onSelect={edit}>Edit</MenuItem>
|
|
17
|
+
<MenuSub label="Export">
|
|
18
|
+
<MenuItem onSelect={() => download("csv")}>CSV</MenuItem>
|
|
19
|
+
<MenuItem onSelect={() => download("pdf")}>PDF</MenuItem>
|
|
20
|
+
</MenuSub>
|
|
21
|
+
<MenuSeparator />
|
|
22
|
+
<MenuCheckboxItem checked={compact} onCheckedChange={setCompact}>Compact rows</MenuCheckboxItem>
|
|
23
|
+
<MenuSeparator />
|
|
24
|
+
<MenuItem destructive onSelect={remove}>Delete</MenuItem>
|
|
25
|
+
</Menu>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Prop | Default | Description |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `trigger` | — | A single element, cloned with the ARIA wiring. |
|
|
31
|
+
| `open` / `defaultOpen` / `onOpenChange` | — | Controlled or uncontrolled. Reasons: `escape`, `outside`, `trigger`, `select`, `api`. |
|
|
32
|
+
| `label` | "Menu" | Names the menu for screen readers. |
|
|
33
|
+
| `side` / `align` / `gap` | `bottom` / `start` / `4` | Flips and clamps to stay on screen. |
|
|
34
|
+
| `closeOnSelect` | `true` | Whether choosing an item closes the menu. |
|
|
35
|
+
| `className`, `classNames`, `style`, `localeText` | — | Slots: `root`, `list`, `item`, `icon`, `label`, `shortcut`, `separator`, `group`, `groupLabel`, `indicator`, `submenu`. |
|
|
36
|
+
|
|
37
|
+
## Items
|
|
38
|
+
|
|
39
|
+
| Component | Notes |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `MenuItem` | `onSelect`, `icon`, `shortcut`, `disabled`, `destructive`. Closes the menu by default. |
|
|
42
|
+
| `MenuCheckboxItem` | `checked` / `onCheckedChange`. Stays open by default, so several can be toggled. |
|
|
43
|
+
| `MenuRadioGroup` + `MenuRadioItem` | `value` / `onValueChange` on the group, `value` on each item. |
|
|
44
|
+
| `MenuGroup` | A titled section; the title is a label, never focused. |
|
|
45
|
+
| `MenuSeparator` | A rule between sections. |
|
|
46
|
+
| `MenuSub` | A nested menu, opened by hover, Enter or the arrow pointing into it. |
|
|
47
|
+
|
|
48
|
+
`shortcut` only draws the hint — binding the key is yours, because only the page
|
|
49
|
+
knows what else is listening.
|
|
50
|
+
|
|
51
|
+
## Keyboard
|
|
52
|
+
|
|
53
|
+
| Key | Does |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `ArrowDown` / `ArrowUp` on the trigger | Opens, starting at the first or last item. |
|
|
56
|
+
| `ArrowDown` / `ArrowUp` | Moves through items, wrapping, skipping disabled ones. |
|
|
57
|
+
| `Home` / `End` | First / last item. |
|
|
58
|
+
| Letters | Typeahead. Repeating a letter cycles through items starting with it. |
|
|
59
|
+
| `Enter` / `Space` | Chooses the focused item. |
|
|
60
|
+
| `ArrowRight` / `ArrowLeft` | Opens a submenu / goes back to its parent (mirrored in RTL). |
|
|
61
|
+
| `Escape` | Closes and returns focus to the trigger. |
|
|
62
|
+
| `Tab` | Closes and carries on through the page. |
|
|
63
|
+
|
|
64
|
+
Disabled items stay focusable so that someone navigating by keyboard can find
|
|
65
|
+
out an action exists but is unavailable; arrow keys skip past them.
|
|
66
|
+
|
|
67
|
+
The navigation and typeahead rules are framework-free in `core/navigation.ts`
|
|
68
|
+
and unit-tested there. Placement comes from `shared/core/position.ts`.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
edgeIndex,
|
|
4
|
+
isTypeaheadKey,
|
|
5
|
+
nextIndex,
|
|
6
|
+
typeaheadBuffer,
|
|
7
|
+
typeaheadIndex,
|
|
8
|
+
} from "../core/navigation";
|
|
9
|
+
|
|
10
|
+
const items = (...text: string[]) => text.map((label) => ({ text: label }));
|
|
11
|
+
|
|
12
|
+
const withDisabled = [
|
|
13
|
+
{ text: "Cut" },
|
|
14
|
+
{ text: "Copy", disabled: true },
|
|
15
|
+
{ text: "Paste" },
|
|
16
|
+
{ text: "Paste special", disabled: true },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
describe("nextIndex", () => {
|
|
20
|
+
it("steps forwards and backwards", () => {
|
|
21
|
+
const list = items("a", "b", "c");
|
|
22
|
+
expect(nextIndex(list, 0, 1)).toBe(1);
|
|
23
|
+
expect(nextIndex(list, 2, -1)).toBe(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("wraps around, which is what a menu does", () => {
|
|
27
|
+
const list = items("a", "b", "c");
|
|
28
|
+
expect(nextIndex(list, 2, 1)).toBe(0);
|
|
29
|
+
expect(nextIndex(list, 0, -1)).toBe(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("skips disabled items in both directions", () => {
|
|
33
|
+
expect(nextIndex(withDisabled, 0, 1)).toBe(2);
|
|
34
|
+
expect(nextIndex(withDisabled, 2, -1)).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("starts at the near end when nothing is focused", () => {
|
|
38
|
+
const list = items("a", "b", "c");
|
|
39
|
+
expect(nextIndex(list, -1, 1)).toBe(0);
|
|
40
|
+
expect(nextIndex(list, -1, -1)).toBe(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("gives up rather than landing on a disabled item", () => {
|
|
44
|
+
expect(nextIndex([{ text: "only", disabled: true }], 0, 1)).toBe(-1);
|
|
45
|
+
expect(nextIndex([], 0, 1)).toBe(-1);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("edgeIndex", () => {
|
|
50
|
+
it("finds the first and last item that can be focused", () => {
|
|
51
|
+
expect(edgeIndex(withDisabled, "first")).toBe(0);
|
|
52
|
+
expect(edgeIndex(withDisabled, "last")).toBe(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("is -1 when nothing can be focused", () => {
|
|
56
|
+
expect(edgeIndex([{ text: "x", disabled: true }], "first")).toBe(-1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("typeaheadIndex", () => {
|
|
61
|
+
const list = items("Save", "Save as", "Send", "Print");
|
|
62
|
+
|
|
63
|
+
it("jumps to the first item starting with the letter", () => {
|
|
64
|
+
expect(typeaheadIndex(list, "p", 0)).toBe(3);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("ignores case and leading space", () => {
|
|
68
|
+
expect(typeaheadIndex(list, " SE", 0)).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("cycles through items sharing a letter when it is repeated", () => {
|
|
72
|
+
expect(typeaheadIndex(list, "s", -1)).toBe(0);
|
|
73
|
+
expect(typeaheadIndex(list, "ss", 0)).toBe(1);
|
|
74
|
+
expect(typeaheadIndex(list, "sss", 1)).toBe(2);
|
|
75
|
+
// …and back round to the first.
|
|
76
|
+
expect(typeaheadIndex(list, "ssss", 2)).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("treats a longer string as a prefix, refining from where it is", () => {
|
|
80
|
+
expect(typeaheadIndex(list, "save a", 0)).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stays put when nothing matches", () => {
|
|
84
|
+
expect(typeaheadIndex(list, "z", 0)).toBe(-1);
|
|
85
|
+
expect(typeaheadIndex(list, "", 0)).toBe(-1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("never lands on a disabled item", () => {
|
|
89
|
+
// Only the disabled "Copy" starts with "co", so focus stays where it is.
|
|
90
|
+
expect(typeaheadIndex(withDisabled, "co", 0)).toBe(-1);
|
|
91
|
+
// "c" also matches the enabled "Cut", which it wraps round to find.
|
|
92
|
+
expect(typeaheadIndex(withDisabled, "c", 0)).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("typeaheadBuffer", () => {
|
|
97
|
+
it("builds a word from keys typed in quick succession", () => {
|
|
98
|
+
expect(typeaheadBuffer("s", "a", 120)).toBe("sa");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("starts again after a pause", () => {
|
|
102
|
+
expect(typeaheadBuffer("sa", "p", 900)).toBe("p");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("isTypeaheadKey", () => {
|
|
107
|
+
it("takes printable characters", () => {
|
|
108
|
+
expect(isTypeaheadKey("a", false)).toBe(true);
|
|
109
|
+
expect(isTypeaheadKey("7", false)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("leaves Space alone, since it chooses an item", () => {
|
|
113
|
+
expect(isTypeaheadKey(" ", false)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("ignores named keys and anything with a modifier", () => {
|
|
117
|
+
expect(isTypeaheadKey("ArrowDown", false)).toBe(false);
|
|
118
|
+
expect(isTypeaheadKey("a", true)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Framework-free: roving focus and typeahead, with no DOM involved.
|
|
2
|
+
export {
|
|
3
|
+
edgeIndex,
|
|
4
|
+
isTypeaheadKey,
|
|
5
|
+
nextIndex,
|
|
6
|
+
typeaheadBuffer,
|
|
7
|
+
typeaheadIndex,
|
|
8
|
+
TYPEAHEAD_RESET_MS,
|
|
9
|
+
} from "./navigation";
|
|
10
|
+
export type { NavigableItem } from "./navigation";
|
|
11
|
+
export type * from "./types";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** How long a run of keystrokes counts as one word for typeahead. */
|
|
2
|
+
export const TYPEAHEAD_RESET_MS = 700;
|
|
3
|
+
|
|
4
|
+
export interface NavigableItem {
|
|
5
|
+
/** Disabled items are skipped by the keyboard, as WAI-ARIA's menu pattern allows. */
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
/** The visible text, lowercased by the caller or not — matching ignores case. */
|
|
8
|
+
text?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The next item to focus from `from`, stepping by `step` and skipping disabled
|
|
13
|
+
* items. Wraps around, which is what a menu does and a listbox does not.
|
|
14
|
+
*
|
|
15
|
+
* Returns -1 when every item is disabled, so callers can leave focus alone
|
|
16
|
+
* rather than moving it somewhere meaningless.
|
|
17
|
+
*/
|
|
18
|
+
export function nextIndex(items: readonly NavigableItem[], from: number, step: number): number {
|
|
19
|
+
if (items.length === 0) return -1;
|
|
20
|
+
// Nothing focused yet: Down starts at the top, Up at the bottom.
|
|
21
|
+
if (from < 0) return edgeIndex(items, step > 0 ? "first" : "last");
|
|
22
|
+
for (let moved = 1; moved <= items.length; moved += 1) {
|
|
23
|
+
const index = (from + step * moved + items.length * moved) % items.length;
|
|
24
|
+
if (!items[index]?.disabled) return index;
|
|
25
|
+
}
|
|
26
|
+
return -1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The first or last item that can be focused. */
|
|
30
|
+
export function edgeIndex(items: readonly NavigableItem[], edge: "first" | "last"): number {
|
|
31
|
+
const order = edge === "first" ? items.keys() : [...items.keys()].reverse();
|
|
32
|
+
for (const index of order) {
|
|
33
|
+
if (!items[index]?.disabled) return index;
|
|
34
|
+
}
|
|
35
|
+
return -1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The item a typed string should jump to, searching from just after `from` and
|
|
40
|
+
* wrapping, so repeating a letter cycles through the items starting with it.
|
|
41
|
+
*
|
|
42
|
+
* Returns -1 when nothing matches, leaving focus where it is rather than
|
|
43
|
+
* jumping somewhere unrelated.
|
|
44
|
+
*/
|
|
45
|
+
export function typeaheadIndex(
|
|
46
|
+
items: readonly NavigableItem[],
|
|
47
|
+
query: string,
|
|
48
|
+
from: number,
|
|
49
|
+
): number {
|
|
50
|
+
const needle = query.trim().toLowerCase();
|
|
51
|
+
if (!needle) return -1;
|
|
52
|
+
|
|
53
|
+
// A repeated single letter cycles; anything longer is a prefix search from
|
|
54
|
+
// the current item, so "sa" after "s" refines instead of jumping on.
|
|
55
|
+
const repeated = needle.length > 1 && [...needle].every((character) => character === needle[0]);
|
|
56
|
+
const search = repeated ? needle[0] : needle;
|
|
57
|
+
const start = repeated || needle.length === 1 ? from + 1 : from;
|
|
58
|
+
|
|
59
|
+
for (let offset = 0; offset < items.length; offset += 1) {
|
|
60
|
+
const index = (start + offset + items.length) % items.length;
|
|
61
|
+
const item = items[index];
|
|
62
|
+
if (item?.disabled) continue;
|
|
63
|
+
if ((item?.text ?? "").trim().toLowerCase().startsWith(search)) return index;
|
|
64
|
+
}
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Collects the buffer for typeahead: keystrokes within `TYPEAHEAD_RESET_MS` of
|
|
70
|
+
* each other build a word, and a longer pause starts a new one.
|
|
71
|
+
*/
|
|
72
|
+
export function typeaheadBuffer(
|
|
73
|
+
previous: string,
|
|
74
|
+
key: string,
|
|
75
|
+
elapsedMs: number,
|
|
76
|
+
resetMs: number = TYPEAHEAD_RESET_MS,
|
|
77
|
+
): string {
|
|
78
|
+
return elapsedMs > resetMs ? key : previous + key;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a key should be added to the typeahead buffer rather than handled. */
|
|
82
|
+
export function isTypeaheadKey(key: string, modified: boolean): boolean {
|
|
83
|
+
return !modified && key.length === 1 && key !== " " && /\S/.test(key);
|
|
84
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type MenuSide = "top" | "bottom" | "left" | "right";
|
|
4
|
+
export type MenuAlign = "start" | "end";
|
|
5
|
+
|
|
6
|
+
/** What closed the menu, so callers can tell a choice from a dismissal. */
|
|
7
|
+
export type MenuCloseReason = "escape" | "outside" | "trigger" | "select" | "api";
|
|
8
|
+
|
|
9
|
+
export type MenuSlot =
|
|
10
|
+
| "root"
|
|
11
|
+
| "list"
|
|
12
|
+
| "item"
|
|
13
|
+
| "icon"
|
|
14
|
+
| "label"
|
|
15
|
+
| "shortcut"
|
|
16
|
+
| "separator"
|
|
17
|
+
| "group"
|
|
18
|
+
| "groupLabel"
|
|
19
|
+
| "indicator"
|
|
20
|
+
| "submenu";
|
|
21
|
+
|
|
22
|
+
export interface MenuLocaleText {
|
|
23
|
+
/** Fallback name for a menu with no label. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** Announced on a submenu trigger. */
|
|
26
|
+
submenu(label: string): string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A shortcut shown beside an item. Purely a hint: the menu does not bind keys. */
|
|
30
|
+
export interface MenuShortcut {
|
|
31
|
+
keys: ReactNode;
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { Menu, MenuSub } from "./react/Menu";
|
|
2
|
+
export type { MenuHandle, MenuProps, MenuSubProps } from "./react/Menu";
|
|
3
|
+
export {
|
|
4
|
+
MenuCheckboxItem,
|
|
5
|
+
MenuGroup,
|
|
6
|
+
MenuItem,
|
|
7
|
+
MenuRadioGroup,
|
|
8
|
+
MenuRadioItem,
|
|
9
|
+
MenuSeparator,
|
|
10
|
+
} from "./react/items";
|
|
11
|
+
export type {
|
|
12
|
+
MenuCheckboxItemProps,
|
|
13
|
+
MenuGroupProps,
|
|
14
|
+
MenuItemProps,
|
|
15
|
+
MenuRadioGroupProps,
|
|
16
|
+
MenuRadioItemProps,
|
|
17
|
+
} from "./react/items";
|
|
18
|
+
export { defaultMenuText } from "./react/locale";
|
|
19
|
+
export * from "./core";
|