@proteos/ui 0.57.1 → 0.59.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/index.cjs +552 -192
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +333 -22
- package/dist/index.d.ts +333 -22
- package/dist/index.js +540 -194
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/badge-multi-select/badge-multi-select.tsx +1 -1
- package/src/components/checkbox/checkbox-group.tsx +4 -1
- package/src/components/color-input/color-input.tsx +1 -1
- package/src/components/combobox/combobox.tsx +1 -1
- package/src/components/command/command-hint.tsx +1 -4
- package/src/components/command/command.stories.tsx +2 -2
- package/src/components/command/command.test.tsx +65 -3
- package/src/components/command/command.tsx +52 -7
- package/src/components/command/index.ts +16 -8
- package/src/components/currency-input/currency-input.tsx +1 -1
- package/src/components/date-picker/date-picker.tsx +1 -1
- package/src/components/date-time-picker/date-time-picker.tsx +1 -1
- package/src/components/field/field.tsx +6 -0
- package/src/components/icon-picker/icon-picker.tsx +1 -1
- package/src/components/input/input.tsx +1 -1
- package/src/components/multi-select/multi-select.tsx +1 -1
- package/src/components/progress-bar/progress-bar.stories.tsx +3 -1
- package/src/components/progress-bar/progress-bar.tsx +2 -0
- package/src/components/progress-ring/index.ts +2 -0
- package/src/components/progress-ring/progress-ring.stories.tsx +82 -0
- package/src/components/progress-ring/progress-ring.test.tsx +47 -0
- package/src/components/progress-ring/progress-ring.tsx +112 -0
- package/src/components/radio-group/radio-group.tsx +4 -1
- package/src/components/record-update/index.ts +17 -0
- package/src/components/record-update/record-update.stories.tsx +141 -0
- package/src/components/record-update/record-update.test.tsx +114 -0
- package/src/components/record-update/record-update.tsx +259 -0
- package/src/components/select/select.tsx +1 -1
- package/src/components/switch/switch.stories.tsx +22 -0
- package/src/components/switch/switch.test.tsx +6 -0
- package/src/components/switch/switch.tsx +40 -3
- package/src/components/tag-input/tag-input.tsx +1 -1
- package/src/components/text-shimmer/index.ts +2 -0
- package/src/components/text-shimmer/text-shimmer.stories.tsx +35 -0
- package/src/components/text-shimmer/text-shimmer.test.tsx +16 -0
- package/src/components/text-shimmer/text-shimmer.tsx +43 -0
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/time-field/time-field.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/lib/commands/index.ts +6 -2
- package/src/lib/commands/registry.test.ts +54 -1
- package/src/lib/commands/registry.ts +89 -4
- package/src/lib/commands/use-active-commands.test.tsx +56 -0
- package/src/lib/commands/use-active-commands.ts +16 -0
- package/src/lib/commands/use-command.ts +16 -5
- package/src/styles.css +14 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { TextShimmer } from './text-shimmer'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof TextShimmer> = {
|
|
6
|
+
title: 'Components/TextShimmer',
|
|
7
|
+
component: TextShimmer,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
tone: { control: 'radio', options: ['muted', 'accent'] },
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default meta
|
|
15
|
+
type Story = StoryObj<typeof TextShimmer>
|
|
16
|
+
|
|
17
|
+
export const Playground: Story = { args: { children: 'Searching records…', tone: 'muted' } }
|
|
18
|
+
|
|
19
|
+
export const LoadingRow: Story = {
|
|
20
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
21
|
+
render: () => (
|
|
22
|
+
<div className="text-[13px]">
|
|
23
|
+
<TextShimmer>Searching records…</TextShimmer>
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const GlowingHeading: Story = {
|
|
29
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
30
|
+
render: () => (
|
|
31
|
+
<h2 className="text-2xl font-semibold">
|
|
32
|
+
<TextShimmer tone="accent">Composing your brief</TextShimmer>
|
|
33
|
+
</h2>
|
|
34
|
+
),
|
|
35
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { TextShimmer } from './text-shimmer'
|
|
5
|
+
|
|
6
|
+
describe('TextShimmer', () => {
|
|
7
|
+
it('keeps the text readable in the DOM', () => {
|
|
8
|
+
render(<TextShimmer>Searching records…</TextShimmer>)
|
|
9
|
+
expect(screen.getByText('Searching records…')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('passes through native span attributes', () => {
|
|
13
|
+
render(<TextShimmer data-testid="shimmer">Thinking…</TextShimmer>)
|
|
14
|
+
expect(screen.getByTestId('shimmer').tagName).toBe('SPAN')
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/cn'
|
|
5
|
+
|
|
6
|
+
/* =========================================================================
|
|
7
|
+
TextShimmer — a bright band sweeping across text. The text is clipped to
|
|
8
|
+
a wide gradient (muted → ink → muted) whose position animates, so only the
|
|
9
|
+
glyphs light up. For loading / streaming states ("Searching…", "Thinking…")
|
|
10
|
+
or a glowing heading. Pure cosmetics; the text itself stays in the DOM and
|
|
11
|
+
is read normally. Honors `prefers-reduced-motion` (static, muted text).
|
|
12
|
+
========================================================================= */
|
|
13
|
+
|
|
14
|
+
export const textShimmerStyles = tv({
|
|
15
|
+
base: [
|
|
16
|
+
'inline-block bg-clip-text text-transparent',
|
|
17
|
+
'bg-[length:200%_auto] animate-text-shimmer',
|
|
18
|
+
'motion-reduce:animate-none motion-reduce:bg-none motion-reduce:text-ink-4',
|
|
19
|
+
],
|
|
20
|
+
variants: {
|
|
21
|
+
tone: {
|
|
22
|
+
// Muted band on a muted base — a quiet loading row.
|
|
23
|
+
muted:
|
|
24
|
+
'bg-[linear-gradient(90deg,var(--color-ink-4)_0%,var(--color-ink-4)_40%,var(--color-ink)_50%,var(--color-ink-4)_60%,var(--color-ink-4)_100%)]',
|
|
25
|
+
// Accent band on ink — a glowing heading.
|
|
26
|
+
accent:
|
|
27
|
+
'bg-[linear-gradient(90deg,var(--color-ink)_0%,var(--color-ink)_40%,var(--color-accent)_50%,var(--color-ink)_60%,var(--color-ink)_100%)] motion-reduce:text-ink',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: { tone: 'muted' },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export type TextShimmerVariants = VariantProps<typeof textShimmerStyles>
|
|
34
|
+
|
|
35
|
+
export type TextShimmerProps = HTMLAttributes<HTMLSpanElement> & TextShimmerVariants
|
|
36
|
+
|
|
37
|
+
export function TextShimmer({ tone, className, children, ...props }: TextShimmerProps) {
|
|
38
|
+
return (
|
|
39
|
+
<span {...props} className={cn(textShimmerStyles({ tone }), className)}>
|
|
40
|
+
{children}
|
|
41
|
+
</span>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -52,7 +52,7 @@ export function TimeField<T extends TimeValue = TimeValue>({
|
|
|
52
52
|
{...props}
|
|
53
53
|
className={(renderProps) =>
|
|
54
54
|
cn(
|
|
55
|
-
'grid grid-cols-1 gap-1.5',
|
|
55
|
+
'grid grid-cols-1 gap-1.5 content-start',
|
|
56
56
|
typeof className === 'function' ? className(renderProps) : className,
|
|
57
57
|
)
|
|
58
58
|
}
|
package/src/index.ts
CHANGED
|
@@ -38,9 +38,11 @@ export * from './components/nav'
|
|
|
38
38
|
export * from './components/orb'
|
|
39
39
|
export * from './components/popover'
|
|
40
40
|
export * from './components/progress-bar'
|
|
41
|
+
export * from './components/progress-ring'
|
|
41
42
|
export * from './components/radio-group'
|
|
42
43
|
export * from './components/recipient-select'
|
|
43
44
|
export * from './components/record-filter'
|
|
45
|
+
export * from './components/record-update'
|
|
44
46
|
export * from './components/segmented-control'
|
|
45
47
|
export * from './components/select'
|
|
46
48
|
export * from './components/separator'
|
|
@@ -50,6 +52,7 @@ export * from './components/switch'
|
|
|
50
52
|
export * from './components/table'
|
|
51
53
|
export * from './components/tabs'
|
|
52
54
|
export * from './components/tag-input'
|
|
55
|
+
export * from './components/text-shimmer'
|
|
53
56
|
export * from './components/textarea'
|
|
54
57
|
export * from './components/time-field'
|
|
55
58
|
export * from './components/toast'
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
export { useDiscoveryActive } from './discovery-store'
|
|
2
|
+
export { isEditableTarget } from './match-event'
|
|
1
3
|
export type { KeyCombo } from './parse-combo'
|
|
2
4
|
export { parseCombo } from './parse-combo'
|
|
5
|
+
export type { Platform } from './platform'
|
|
6
|
+
export { getPlatform } from './platform'
|
|
3
7
|
export { PROTECTED_COMBOS } from './protected-combos'
|
|
4
|
-
export {
|
|
8
|
+
export type { ActiveCommand, CommandLabel } from './registry'
|
|
9
|
+
export { useActiveCommands } from './use-active-commands'
|
|
5
10
|
export type { UseCommandOptions } from './use-command'
|
|
6
11
|
export { useCommand } from './use-command'
|
|
7
12
|
export type { UseEscapeKeyOptions } from './use-escape-key'
|
|
8
13
|
export { useEscapeKey } from './use-escape-key'
|
|
9
|
-
export { useDiscoveryActive } from './discovery-store'
|
|
@@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
2
2
|
|
|
3
3
|
import { parseCombo } from './parse-combo'
|
|
4
4
|
import { _setPlatformForTests } from './platform'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
_resetRegistry,
|
|
7
|
+
getActiveCommandsSnapshot,
|
|
8
|
+
listActiveCommands,
|
|
9
|
+
register,
|
|
10
|
+
subscribe,
|
|
11
|
+
} from './registry'
|
|
6
12
|
|
|
7
13
|
function dispatchKey(init: KeyboardEventInit & { cancelable?: boolean }): KeyboardEvent {
|
|
8
14
|
const ev = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
|
|
@@ -95,3 +101,50 @@ describe('registry', () => {
|
|
|
95
101
|
expect(fn).not.toHaveBeenCalled()
|
|
96
102
|
})
|
|
97
103
|
})
|
|
104
|
+
|
|
105
|
+
describe('registry — active commands', () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
_setPlatformForTests('mac')
|
|
108
|
+
_resetRegistry()
|
|
109
|
+
})
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
_setPlatformForTests(undefined)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('lists labeled top-of-stack bindings and invokes them', () => {
|
|
115
|
+
const save = vi.fn()
|
|
116
|
+
const older = vi.fn()
|
|
117
|
+
register(parseCombo('mod+s'), older, { label: 'Old save' })
|
|
118
|
+
register(parseCombo('mod+s'), save, { label: 'Save' })
|
|
119
|
+
register(parseCombo('mod+k'), () => {}) // unlabeled → not an action
|
|
120
|
+
|
|
121
|
+
const active = listActiveCommands()
|
|
122
|
+
expect(active.map((a) => [a.combo.raw, a.label])).toEqual([['mod+s', 'Save']])
|
|
123
|
+
|
|
124
|
+
active[0]?.invoke()
|
|
125
|
+
expect(save).toHaveBeenCalledTimes(1)
|
|
126
|
+
expect(older).not.toHaveBeenCalled()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('resolves label getters when the list is built and drops empty ones', () => {
|
|
130
|
+
let name: string | undefined = 'Filter'
|
|
131
|
+
register(parseCombo('mod+f'), () => {}, { label: () => name })
|
|
132
|
+
register(parseCombo('mod+g'), () => {}, { label: () => ' ' })
|
|
133
|
+
expect(listActiveCommands().map((a) => a.label)).toEqual(['Filter'])
|
|
134
|
+
name = undefined
|
|
135
|
+
expect(listActiveCommands()).toEqual([])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('notifies subscribers on register and unregister, with a stable snapshot in between', () => {
|
|
139
|
+
const listener = vi.fn()
|
|
140
|
+
subscribe(listener)
|
|
141
|
+
const unregister = register(parseCombo('mod+s'), () => {}, { label: 'Save' })
|
|
142
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
143
|
+
const first = getActiveCommandsSnapshot()
|
|
144
|
+
expect(getActiveCommandsSnapshot()).toBe(first)
|
|
145
|
+
expect(first.map((a) => a.label)).toEqual(['Save'])
|
|
146
|
+
unregister()
|
|
147
|
+
expect(listener).toHaveBeenCalledTimes(2)
|
|
148
|
+
expect(getActiveCommandsSnapshot()).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -7,16 +7,40 @@
|
|
|
7
7
|
Editable targets are NOT skipped here — `mod+<key>` combos use a real
|
|
8
8
|
modifier, so they can't be confused with text input the way bare keys
|
|
9
9
|
can. Cmd+S in a form field should still save. (The `useEscapeKey` path
|
|
10
|
-
keeps its editable-target skip because Escape is bare.)
|
|
10
|
+
keeps its editable-target skip because Escape is bare.)
|
|
11
|
+
|
|
12
|
+
Entries may carry a label — the name of the thing the combo does ("Save",
|
|
13
|
+
"Cancel", "Filter"). Labeled, top-of-stack entries are what
|
|
14
|
+
`listActiveCommands()` exposes, so a palette can list "what can I do on
|
|
15
|
+
this page right now" and invoke it without the keyboard. Subscribers
|
|
16
|
+
(`subscribe`) are told whenever the stacks change. */
|
|
11
17
|
|
|
12
18
|
import { matches } from './match-event'
|
|
13
19
|
import type { KeyCombo } from './parse-combo'
|
|
14
20
|
|
|
15
21
|
type Handler = (event: KeyboardEvent) => void
|
|
16
|
-
|
|
22
|
+
/** A fixed name, or a getter resolved when the list is built (so a wrapper
|
|
23
|
+
* can read its control's accessible name from the DOM). */
|
|
24
|
+
export type CommandLabel = string | (() => string | undefined)
|
|
25
|
+
type Entry = { combo: KeyCombo; handler: Handler; label?: CommandLabel }
|
|
26
|
+
|
|
27
|
+
export type RegisterOptions = { label?: CommandLabel }
|
|
28
|
+
|
|
29
|
+
/** A labeled binding that will fire right now if its combo is pressed. */
|
|
30
|
+
export type ActiveCommand = {
|
|
31
|
+
combo: KeyCombo
|
|
32
|
+
label: string
|
|
33
|
+
/** Run the binding as if its combo had been pressed. */
|
|
34
|
+
invoke: () => void
|
|
35
|
+
}
|
|
17
36
|
|
|
18
37
|
const stacks = new Map<string, Entry[]>()
|
|
38
|
+
const listeners = new Set<() => void>()
|
|
19
39
|
let listenerInstalled = false
|
|
40
|
+
let version = 0
|
|
41
|
+
let snapshotVersion = -1
|
|
42
|
+
let snapshot: ActiveCommand[] = []
|
|
43
|
+
const EMPTY: ActiveCommand[] = []
|
|
20
44
|
|
|
21
45
|
function onWindowKeyDown(event: KeyboardEvent): void {
|
|
22
46
|
for (const entries of stacks.values()) {
|
|
@@ -38,22 +62,83 @@ function ensureListener(): void {
|
|
|
38
62
|
listenerInstalled = true
|
|
39
63
|
}
|
|
40
64
|
|
|
41
|
-
|
|
65
|
+
function emit(): void {
|
|
66
|
+
version++
|
|
67
|
+
for (const listener of listeners) listener()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function register(combo: KeyCombo, handler: Handler, options?: RegisterOptions): () => void {
|
|
42
71
|
ensureListener()
|
|
43
72
|
const list = stacks.get(combo.raw) ?? []
|
|
44
|
-
const entry: Entry = {
|
|
73
|
+
const entry: Entry = {
|
|
74
|
+
combo,
|
|
75
|
+
handler,
|
|
76
|
+
...(options?.label !== undefined ? { label: options.label } : {}),
|
|
77
|
+
}
|
|
45
78
|
list.push(entry)
|
|
46
79
|
stacks.set(combo.raw, list)
|
|
80
|
+
emit()
|
|
47
81
|
return () => {
|
|
48
82
|
const current = stacks.get(combo.raw)
|
|
49
83
|
if (!current) return
|
|
50
84
|
const idx = current.lastIndexOf(entry)
|
|
51
85
|
if (idx >= 0) current.splice(idx, 1)
|
|
52
86
|
if (current.length === 0) stacks.delete(combo.raw)
|
|
87
|
+
emit()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Notified after every register / unregister. */
|
|
92
|
+
export function subscribe(listener: () => void): () => void {
|
|
93
|
+
listeners.add(listener)
|
|
94
|
+
return () => {
|
|
95
|
+
listeners.delete(listener)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveLabel(label: CommandLabel | undefined): string | undefined {
|
|
100
|
+
const value = typeof label === 'function' ? label() : label
|
|
101
|
+
const trimmed = value?.trim()
|
|
102
|
+
return trimmed ? trimmed : undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invokeEntry(entry: Entry): void {
|
|
106
|
+
entry.handler(new KeyboardEvent('keydown', { key: entry.combo.key, cancelable: true }))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The labeled, top-of-stack bindings, in registration order of their combos.
|
|
110
|
+
* Unlabeled bindings (a palette toggle, an undo hook) are not actions. */
|
|
111
|
+
export function listActiveCommands(): ActiveCommand[] {
|
|
112
|
+
const out: ActiveCommand[] = []
|
|
113
|
+
for (const entries of stacks.values()) {
|
|
114
|
+
const top = entries[entries.length - 1]
|
|
115
|
+
if (!top) continue
|
|
116
|
+
const label = resolveLabel(top.label)
|
|
117
|
+
if (!label) continue
|
|
118
|
+
out.push({ combo: top.combo, label, invoke: () => invokeEntry(top) })
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `listActiveCommands()` memoised per registry change — referentially stable
|
|
124
|
+
* between changes, as useSyncExternalStore requires. */
|
|
125
|
+
export function getActiveCommandsSnapshot(): ActiveCommand[] {
|
|
126
|
+
if (snapshotVersion !== version) {
|
|
127
|
+
snapshot = listActiveCommands()
|
|
128
|
+
snapshotVersion = version
|
|
53
129
|
}
|
|
130
|
+
return snapshot
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getActiveCommandsServerSnapshot(): ActiveCommand[] {
|
|
134
|
+
return EMPTY
|
|
54
135
|
}
|
|
55
136
|
|
|
56
137
|
// Test-only: clear the registry between cases.
|
|
57
138
|
export function _resetRegistry(): void {
|
|
58
139
|
stacks.clear()
|
|
140
|
+
listeners.clear()
|
|
141
|
+
version = 0
|
|
142
|
+
snapshotVersion = -1
|
|
143
|
+
snapshot = []
|
|
59
144
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { _setPlatformForTests } from './platform'
|
|
5
|
+
import { _resetRegistry } from './registry'
|
|
6
|
+
import { useActiveCommands } from './use-active-commands'
|
|
7
|
+
import { useCommand } from './use-command'
|
|
8
|
+
|
|
9
|
+
function Bind({ combo, label, handler }: { combo: string; label: string; handler: () => void }) {
|
|
10
|
+
useCommand(combo, handler, { label })
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ActionList() {
|
|
15
|
+
const actions = useActiveCommands()
|
|
16
|
+
return (
|
|
17
|
+
<ul>
|
|
18
|
+
{actions.map((action) => (
|
|
19
|
+
<li key={action.combo.raw}>
|
|
20
|
+
<button type="button" onClick={action.invoke}>
|
|
21
|
+
{action.label}
|
|
22
|
+
</button>
|
|
23
|
+
</li>
|
|
24
|
+
))}
|
|
25
|
+
</ul>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useActiveCommands', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
_setPlatformForTests('mac')
|
|
32
|
+
_resetRegistry()
|
|
33
|
+
})
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
_setPlatformForTests(undefined)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('tracks labeled bindings as they mount and unmount', () => {
|
|
39
|
+
const save = vi.fn()
|
|
40
|
+
const { rerender } = render(
|
|
41
|
+
<>
|
|
42
|
+
<ActionList />
|
|
43
|
+
<Bind combo="mod+s" label="Save" handler={save} />
|
|
44
|
+
</>,
|
|
45
|
+
)
|
|
46
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
|
47
|
+
|
|
48
|
+
act(() => {
|
|
49
|
+
screen.getByRole('button', { name: 'Save' }).click()
|
|
50
|
+
})
|
|
51
|
+
expect(save).toHaveBeenCalledTimes(1)
|
|
52
|
+
|
|
53
|
+
rerender(<ActionList />)
|
|
54
|
+
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* The labeled bindings that would fire right now — what a command palette
|
|
2
|
+
lists as "Actions". Re-renders whenever a binding mounts, unmounts, or is
|
|
3
|
+
shadowed by a newer one for the same combo. */
|
|
4
|
+
|
|
5
|
+
import { useSyncExternalStore } from 'react'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type ActiveCommand,
|
|
9
|
+
getActiveCommandsServerSnapshot,
|
|
10
|
+
getActiveCommandsSnapshot,
|
|
11
|
+
subscribe,
|
|
12
|
+
} from './registry'
|
|
13
|
+
|
|
14
|
+
export function useActiveCommands(): ActiveCommand[] {
|
|
15
|
+
return useSyncExternalStore(subscribe, getActiveCommandsSnapshot, getActiveCommandsServerSnapshot)
|
|
16
|
+
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
/* Bind a `mod+<key>` command. The handler
|
|
2
|
-
|
|
3
|
-
`enabled` are real dependencies. */
|
|
1
|
+
/* Bind a `mod+<key>` command. The handler (and label) are wrapped in refs so
|
|
2
|
+
changing their identity each render does not re-register; only `combo`
|
|
3
|
+
and `enabled` are real dependencies. */
|
|
4
4
|
|
|
5
5
|
import { useEffect, useRef } from 'react'
|
|
6
6
|
|
|
7
7
|
import { parseCombo } from './parse-combo'
|
|
8
8
|
import { assertNotProtected } from './protected-combos'
|
|
9
|
-
import { register } from './registry'
|
|
9
|
+
import { type CommandLabel, register } from './registry'
|
|
10
10
|
|
|
11
11
|
export type UseCommandOptions = {
|
|
12
12
|
/** Defaults to `true`. Set to `false` to temporarily disable without unmounting. */
|
|
13
13
|
enabled?: boolean
|
|
14
|
+
/** What the binding does ("Save", "Filter"). Labeled bindings are listed by
|
|
15
|
+
* `useActiveCommands()` — the command palette's "Actions" — and can be
|
|
16
|
+
* invoked from there. Unlabeled bindings stay keyboard-only. */
|
|
17
|
+
label?: CommandLabel
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export function useCommand(
|
|
@@ -20,13 +24,20 @@ export function useCommand(
|
|
|
20
24
|
): void {
|
|
21
25
|
const enabled = options?.enabled ?? true
|
|
22
26
|
const handlerRef = useRef(handler)
|
|
27
|
+
const labelRef = useRef(options?.label)
|
|
23
28
|
useEffect(() => {
|
|
24
29
|
handlerRef.current = handler
|
|
30
|
+
labelRef.current = options?.label
|
|
25
31
|
})
|
|
26
32
|
useEffect(() => {
|
|
27
33
|
if (!enabled) return undefined
|
|
28
34
|
const parsed = parseCombo(combo)
|
|
29
35
|
assertNotProtected(parsed)
|
|
30
|
-
return register(parsed, (event) => handlerRef.current(event)
|
|
36
|
+
return register(parsed, (event) => handlerRef.current(event), {
|
|
37
|
+
label: () => {
|
|
38
|
+
const label = labelRef.current
|
|
39
|
+
return typeof label === 'function' ? label() : label
|
|
40
|
+
},
|
|
41
|
+
})
|
|
31
42
|
}, [combo, enabled])
|
|
32
43
|
}
|
package/src/styles.css
CHANGED
|
@@ -149,6 +149,20 @@
|
|
|
149
149
|
--animate-exit-slide-up: exit-slide-up 100ms ease both;
|
|
150
150
|
--animate-exit-zoom: exit-zoom 100ms ease both;
|
|
151
151
|
|
|
152
|
+
/* Text shimmer — a bright band sweeping across text that is clipped to a
|
|
153
|
+
gradient background (see the TextShimmer component). Loading / streaming
|
|
154
|
+
states; linear so the sweep reads as continuous. */
|
|
155
|
+
--animate-text-shimmer: text-shimmer 2s linear infinite;
|
|
156
|
+
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@keyframes text-shimmer {
|
|
160
|
+
from {
|
|
161
|
+
background-position: 200% center;
|
|
162
|
+
}
|
|
163
|
+
to {
|
|
164
|
+
background-position: -200% center;
|
|
165
|
+
}
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
@keyframes indeterminate-progress {
|