@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.
Files changed (53) hide show
  1. package/dist/index.cjs +552 -192
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +333 -22
  4. package/dist/index.d.ts +333 -22
  5. package/dist/index.js +540 -194
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/components/badge-multi-select/badge-multi-select.tsx +1 -1
  9. package/src/components/checkbox/checkbox-group.tsx +4 -1
  10. package/src/components/color-input/color-input.tsx +1 -1
  11. package/src/components/combobox/combobox.tsx +1 -1
  12. package/src/components/command/command-hint.tsx +1 -4
  13. package/src/components/command/command.stories.tsx +2 -2
  14. package/src/components/command/command.test.tsx +65 -3
  15. package/src/components/command/command.tsx +52 -7
  16. package/src/components/command/index.ts +16 -8
  17. package/src/components/currency-input/currency-input.tsx +1 -1
  18. package/src/components/date-picker/date-picker.tsx +1 -1
  19. package/src/components/date-time-picker/date-time-picker.tsx +1 -1
  20. package/src/components/field/field.tsx +6 -0
  21. package/src/components/icon-picker/icon-picker.tsx +1 -1
  22. package/src/components/input/input.tsx +1 -1
  23. package/src/components/multi-select/multi-select.tsx +1 -1
  24. package/src/components/progress-bar/progress-bar.stories.tsx +3 -1
  25. package/src/components/progress-bar/progress-bar.tsx +2 -0
  26. package/src/components/progress-ring/index.ts +2 -0
  27. package/src/components/progress-ring/progress-ring.stories.tsx +82 -0
  28. package/src/components/progress-ring/progress-ring.test.tsx +47 -0
  29. package/src/components/progress-ring/progress-ring.tsx +112 -0
  30. package/src/components/radio-group/radio-group.tsx +4 -1
  31. package/src/components/record-update/index.ts +17 -0
  32. package/src/components/record-update/record-update.stories.tsx +141 -0
  33. package/src/components/record-update/record-update.test.tsx +114 -0
  34. package/src/components/record-update/record-update.tsx +259 -0
  35. package/src/components/select/select.tsx +1 -1
  36. package/src/components/switch/switch.stories.tsx +22 -0
  37. package/src/components/switch/switch.test.tsx +6 -0
  38. package/src/components/switch/switch.tsx +40 -3
  39. package/src/components/tag-input/tag-input.tsx +1 -1
  40. package/src/components/text-shimmer/index.ts +2 -0
  41. package/src/components/text-shimmer/text-shimmer.stories.tsx +35 -0
  42. package/src/components/text-shimmer/text-shimmer.test.tsx +16 -0
  43. package/src/components/text-shimmer/text-shimmer.tsx +43 -0
  44. package/src/components/textarea/textarea.tsx +1 -1
  45. package/src/components/time-field/time-field.tsx +1 -1
  46. package/src/index.ts +3 -0
  47. package/src/lib/commands/index.ts +6 -2
  48. package/src/lib/commands/registry.test.ts +54 -1
  49. package/src/lib/commands/registry.ts +89 -4
  50. package/src/lib/commands/use-active-commands.test.tsx +56 -0
  51. package/src/lib/commands/use-active-commands.ts +16 -0
  52. package/src/lib/commands/use-command.ts +16 -5
  53. 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
+ }
@@ -54,7 +54,7 @@ export function Textarea({
54
54
  {...props}
55
55
  className={(renderProps) =>
56
56
  cn(
57
- 'grid grid-cols-1 gap-1.5',
57
+ 'grid grid-cols-1 gap-1.5 content-start',
58
58
  typeof className === 'function' ? className(renderProps) : className,
59
59
  )
60
60
  }
@@ -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 { isEditableTarget } from './match-event'
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 { _resetRegistry, register } from './registry'
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
- type Entry = { combo: KeyCombo; handler: Handler }
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
- export function register(combo: KeyCombo, handler: Handler): () => void {
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 = { combo, handler }
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 is wrapped in a ref so changing
2
- handler identity each render does not re-register; only `combo` and
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 {