@lincyaw/workbench 0.1.0 → 0.2.0-rc.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/README.md +40 -8
- package/package.json +27 -22
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +547 -90
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +33 -43
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +751 -203
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +456 -44
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +757 -361
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- package/src/store.ts +0 -599
package/src/components.tsx
CHANGED
|
@@ -1,43 +1,154 @@
|
|
|
1
|
-
import { ICONS } from '@lincyaw/design'
|
|
2
1
|
import type { IconName } from '@lincyaw/design'
|
|
2
|
+
import { mountMascot } from '@lincyaw/design/mascot'
|
|
3
|
+
import type { MascotAction } from '@lincyaw/design/mascot'
|
|
3
4
|
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
5
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
5
6
|
import * as Popover from '@radix-ui/react-popover'
|
|
6
7
|
import * as Tabs from '@radix-ui/react-tabs'
|
|
7
|
-
import * as
|
|
8
|
-
import
|
|
9
|
-
import
|
|
8
|
+
import * as Toolbar from '@radix-ui/react-toolbar'
|
|
9
|
+
import * as RadioGroup from '@radix-ui/react-radio-group'
|
|
10
|
+
import { forwardRef, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
11
|
+
import type { ComponentProps, CSSProperties, InputHTMLAttributes, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
|
12
|
+
import { Group as ResizableGroup, Panel as ResizablePanel, Separator as ResizableSeparator } from 'react-resizable-panels'
|
|
13
|
+
import { PmButton, PmIcon, PmIconButton, PmTooltip } from './base-controls.tsx'
|
|
14
|
+
import { useMascotEnabled } from './mascot-preference.ts'
|
|
15
|
+
import type { ControlDensity } from './base-controls.tsx'
|
|
16
|
+
export { PmButton, PmIcon, PmIconButton, PmTooltip } from './base-controls.tsx'
|
|
17
|
+
export type { ButtonVariant, ControlDensity } from './base-controls.tsx'
|
|
18
|
+
export { PmSortableList } from './sortable-list.tsx'
|
|
19
|
+
export type { SortableListItem, SortableListLabels, SortableListProps } from './sortable-list.tsx'
|
|
10
20
|
|
|
11
21
|
const cls = (...names: (string | false | undefined | null)[]) => names.filter(Boolean).join(' ')
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
/** The dependency owns pointer capture, iframe shielding, constraints and keyboard resizing. */
|
|
24
|
+
export function PmSplitGroup({ className, orientation = 'horizontal', ...props }: ComponentProps<typeof ResizableGroup>) {
|
|
25
|
+
return <ResizableGroup {...props} orientation={orientation} data-pm-orientation={orientation} className={cls('pm-ui-split-group', className)} />
|
|
26
|
+
}
|
|
27
|
+
export function PmSplitPanel({ className, hidden, background, scroll, style, ...props }: ComponentProps<typeof ResizablePanel> & { background?: boolean, scroll?: boolean }) {
|
|
28
|
+
// Panel assigns className/style to its content and HTML attributes to its
|
|
29
|
+
// sizing element. Mark that outer element explicitly for hiding and retention.
|
|
30
|
+
return <ResizablePanel {...props} hidden={hidden} data-pm-split-panel="" data-pm-background={background || undefined} aria-hidden={background || props['aria-hidden']} {...background ? { inert: '' } : {}}
|
|
31
|
+
style={{ overflow: scroll ? 'auto' : 'hidden', ...style }} className={cls('pm-ui-split-panel', className)} />
|
|
32
|
+
}
|
|
33
|
+
export function PmSplitHandle({ label, className, ...props }: ComponentProps<typeof ResizableSeparator> & { label: string }) {
|
|
34
|
+
return <ResizableSeparator {...props} aria-label={label} className={cls('pm-ui-split-handle', className)} />
|
|
19
35
|
}
|
|
20
36
|
|
|
21
|
-
|
|
37
|
+
/** Shared chrome for docked editors; content and actions remain owned by the caller. */
|
|
38
|
+
export function PmPanelHeader({ title, meta, leading, actions }: { title: string, meta?: ReactNode, leading?: ReactNode, actions?: ReactNode }) {
|
|
39
|
+
return <header className="pm-ui-panel-header">
|
|
40
|
+
{leading}<h2 className="pm-ui-panel-header__title">{title}</h2>
|
|
41
|
+
{meta && <span className="pm-ui-panel-header__meta">{meta}</span>}
|
|
42
|
+
{actions && <div className="pm-ui-panel-header__actions">{actions}</div>}
|
|
43
|
+
</header>
|
|
44
|
+
}
|
|
22
45
|
|
|
23
|
-
|
|
46
|
+
/** One tab stop, with arrow/Home/End navigation supplied by Radix. */
|
|
47
|
+
export function PmToolbar({ label, children, className, scroll }: { label: string, children: ReactNode, className?: string, scroll?: boolean }) {
|
|
48
|
+
return <Toolbar.Root className={cls(scroll && 'pm-ui-toolbar-scroll', className)} aria-label={label} loop>{children}</Toolbar.Root>
|
|
49
|
+
}
|
|
50
|
+
export const PmToolbarButton = Toolbar.Button
|
|
24
51
|
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
export interface Choice { value: string, label: string, icon?: IconName, disabled?: boolean }
|
|
53
|
+
export function PmTabs({ value, tabs, label, iconOnly, density = 'compact', variant = 'default', idPrefix, onValueChange, children }: { value: string, tabs: Choice[], label: string, iconOnly?: boolean, density?: ControlDensity, variant?: 'default' | 'dock', idPrefix?: string, onValueChange(value: string): void, children?: ReactNode }) {
|
|
54
|
+
return <Tabs.Root value={value} onValueChange={onValueChange} activationMode="manual" className="pm-ui-tabs-root" data-density={density} data-variant={variant}>
|
|
55
|
+
<Tabs.List className="pm-ui-tabs" aria-label={label}>{tabs.map(tab => {
|
|
56
|
+
const trigger = <Tabs.Trigger className="pm-ui-tab" value={tab.value} id={idPrefix ? `${idPrefix}-${tab.value}-tab` : undefined} aria-controls={idPrefix ? `${idPrefix}-${tab.value}-panel` : undefined} disabled={tab.disabled} aria-label={tab.label} data-icon-only={iconOnly || undefined}>{tab.icon && <PmIcon name={tab.icon} />}{!iconOnly && tab.label}</Tabs.Trigger>
|
|
57
|
+
return iconOnly ? <PmTooltip key={tab.value} label={tab.label}>{trigger}</PmTooltip> : <span key={tab.value} className="pm-ui-tab-slot">{trigger}</span>
|
|
58
|
+
})}</Tabs.List>{children}
|
|
59
|
+
</Tabs.Root>
|
|
60
|
+
}
|
|
61
|
+
export function PmTabPanel({ value, keepMounted, children }: { value: string, keepMounted?: boolean, children: ReactNode }) {
|
|
62
|
+
return <Tabs.Content value={value} forceMount={keepMounted || undefined} data-keep-mounted={keepMounted || undefined} className="pm-ui-tab-panel">{children}</Tabs.Content>
|
|
63
|
+
}
|
|
64
|
+
export function PmSegmented({ label, value, options, density = 'compact', disabled, onChange }: { label: string, value: string, options: Choice[], density?: ControlDensity, disabled?: boolean, onChange(value: string): void }) {
|
|
65
|
+
return <RadioGroup.Root className="pm-ui-segmented" aria-label={label} value={value} onValueChange={onChange} orientation="horizontal" data-density={density} disabled={disabled}>
|
|
66
|
+
{options.map(option => <RadioGroup.Item key={option.value} className="pm-ui-segmented__option" value={option.value} disabled={option.disabled}>{option.icon && <PmIcon name={option.icon} />}{option.label}</RadioGroup.Item>)}
|
|
67
|
+
</RadioGroup.Root>
|
|
68
|
+
}
|
|
33
69
|
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
70
|
+
export interface SearchListItem {
|
|
71
|
+
value: string
|
|
72
|
+
label: string
|
|
73
|
+
/** Non-interactive supporting text; actions belong outside the choice. */
|
|
74
|
+
description?: ReactNode
|
|
75
|
+
keywords?: readonly string[]
|
|
76
|
+
icon?: IconName
|
|
77
|
+
disabled?: boolean
|
|
78
|
+
}
|
|
79
|
+
export interface SearchListProps {
|
|
80
|
+
label: string
|
|
81
|
+
searchLabel: string
|
|
82
|
+
searchPlaceholder?: string
|
|
83
|
+
items: readonly SearchListItem[]
|
|
84
|
+
value?: string
|
|
85
|
+
onValueChange(value: string): void
|
|
86
|
+
/** Explicit activation is separate from moving a candidate with arrow keys. */
|
|
87
|
+
onActivate?(value: string): void
|
|
88
|
+
query?: string
|
|
89
|
+
onQueryChange?(query: string): void
|
|
90
|
+
resultsLabel(count: number): string
|
|
91
|
+
emptyLabel: string
|
|
92
|
+
loadingLabel: string
|
|
93
|
+
loading?: boolean
|
|
94
|
+
error?: ReactNode
|
|
95
|
+
disabled?: boolean
|
|
96
|
+
density?: ControlDensity
|
|
97
|
+
autoFocus?: boolean
|
|
98
|
+
'aria-describedby'?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Search and a single choice compose with Dialog; Radix owns radio navigation and selection. */
|
|
102
|
+
export function PmSearchList({ label, searchLabel, searchPlaceholder, items, value, onValueChange, onActivate, query, onQueryChange, resultsLabel, emptyLabel, loadingLabel, loading, error, disabled, density = 'compact', autoFocus, 'aria-describedby': describedBy }: SearchListProps) {
|
|
103
|
+
const id = useId()
|
|
104
|
+
const [localQuery, setLocalQuery] = useState('')
|
|
105
|
+
const list = useRef<HTMLDivElement>(null)
|
|
106
|
+
const navigating = useRef(false)
|
|
107
|
+
const navigationRelease = useRef<ReturnType<typeof setTimeout>>()
|
|
108
|
+
const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
|
|
109
|
+
const clearNavigation = () => { if (navigationRelease.current) clearTimeout(navigationRelease.current); navigationRelease.current = undefined; navigating.current = false }
|
|
110
|
+
useEffect(() => () => { if (navigationRelease.current) clearTimeout(navigationRelease.current) }, [])
|
|
111
|
+
const search = query ?? localQuery
|
|
112
|
+
const matches = useMemo(() => {
|
|
113
|
+
const terms = search.normalize('NFKC').toLowerCase().trim().split(/\s+/).filter(Boolean)
|
|
114
|
+
return items.filter(item => {
|
|
115
|
+
const text = [item.label, ...item.keywords ?? []].join(' ').normalize('NFKC').toLowerCase()
|
|
116
|
+
return terms.every(term => text.includes(term))
|
|
117
|
+
})
|
|
118
|
+
}, [items, search])
|
|
119
|
+
const blocked = Boolean(disabled || loading || error)
|
|
120
|
+
const errorId = error ? `${id}-error` : undefined
|
|
121
|
+
return <div className="pm-ui-search-list" data-density={density} onKeyDownCapture={event => {
|
|
122
|
+
if (navigationKeys.includes(event.key)) { clearNavigation(); navigating.current = true }
|
|
123
|
+
else if (event.key === 'Enter' || event.key === ' ') clearNavigation()
|
|
124
|
+
}} onKeyUpCapture={event => {
|
|
125
|
+
// Radix can defer arrow-key focus and its corresponding synthetic click until the next task.
|
|
126
|
+
if (navigationKeys.includes(event.key)) {
|
|
127
|
+
if (navigationRelease.current) clearTimeout(navigationRelease.current)
|
|
128
|
+
navigationRelease.current = setTimeout(() => { navigating.current = false; navigationRelease.current = undefined }, 0)
|
|
129
|
+
}
|
|
130
|
+
}} onPointerDownCapture={clearNavigation} onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) clearNavigation() }}>
|
|
131
|
+
<PmInput label={searchLabel} value={search} placeholder={searchPlaceholder} prefixIcon="search" density={density} disabled={disabled} autoFocus={autoFocus} aria-controls={`${id}-results`} onChange={event => {
|
|
132
|
+
if (query === undefined) setLocalQuery(event.target.value)
|
|
133
|
+
onQueryChange?.(event.target.value)
|
|
134
|
+
}} onKeyDown={event => {
|
|
135
|
+
if (event.key !== 'ArrowDown' || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
|
|
136
|
+
const selected = list.current?.querySelector<HTMLElement>('[role="radio"][data-state="checked"]:not(:disabled)')
|
|
137
|
+
const first = selected ?? list.current?.querySelector<HTMLElement>('[role="radio"]:not(:disabled)')
|
|
138
|
+
if (first) { event.preventDefault(); first.focus() }
|
|
139
|
+
}} />
|
|
140
|
+
<div className="pm-ui-search-list__heading"><span id={`${id}-label`}>{label}</span><span className="pm-ui-search-list__count" role="status" aria-live="polite" aria-atomic="true">{loading && <span className="pm-ui-loading" aria-hidden="true"><PmIcon name="loading" /></span>}{loading ? loadingLabel : resultsLabel(matches.length)}</span></div>
|
|
141
|
+
{error && <p id={errorId} className="pm-ui-field__error" role="alert">{error}</p>}
|
|
142
|
+
<RadioGroup.Root ref={list} id={`${id}-results`} className="pm-ui-search-list__results" aria-labelledby={`${id}-label`} aria-describedby={[describedBy, errorId].filter(Boolean).join(' ') || undefined} aria-invalid={error ? true : undefined} aria-busy={loading || undefined} value={value ?? ''} disabled={blocked} orientation="vertical" loop onValueChange={next => { if (!blocked) onValueChange(next) }}>
|
|
143
|
+
{matches.map((item, index) => <RadioGroup.Item key={item.value} value={item.value} disabled={item.disabled} className="pm-ui-search-list__option" aria-labelledby={`${id}-option-${index}`} aria-describedby={item.description ? `${id}-description-${index}` : undefined} onClick={() => { if (!blocked && !item.disabled && !navigating.current) onActivate?.(item.value) }} onKeyDown={event => { if (event.key === 'Enter' && onActivate && !blocked && !item.disabled) { event.preventDefault(); onActivate(item.value) } }}>
|
|
144
|
+
{item.icon && <PmIcon name={item.icon} />}
|
|
145
|
+
<span className="pm-ui-search-list__text"><span id={`${id}-option-${index}`} className="pm-ui-search-list__label" title={item.label}>{item.label}</span>{item.description && <span id={`${id}-description-${index}`} className="pm-ui-search-list__description">{item.description}</span>}</span>
|
|
146
|
+
<span className="pm-ui-search-list__check" aria-hidden="true">{item.value === value && <PmIcon name="check" />}</span>
|
|
147
|
+
</RadioGroup.Item>)}
|
|
148
|
+
{!matches.length && !loading && !error && <p className="pm-ui-search-list__empty">{emptyLabel}</p>}
|
|
149
|
+
</RadioGroup.Root>
|
|
150
|
+
</div>
|
|
151
|
+
}
|
|
41
152
|
|
|
42
153
|
export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'outline'
|
|
43
154
|
|
|
@@ -49,108 +160,220 @@ export function PmCard({ variant = 'default', className, children, ...rest }: {
|
|
|
49
160
|
return <div className={cls('pm-ui-card', variant !== 'default' && `pm-ui-card--${variant}`, className)} {...rest}>{children}</div>
|
|
50
161
|
}
|
|
51
162
|
|
|
52
|
-
export function PmField({ label, inline, children }: { label: string, inline?: boolean, children?: ReactNode }) {
|
|
163
|
+
export function PmField({ label, labelHidden, inline, group, hint, error, htmlFor, hintId, errorId, density, children }: { label: string, labelHidden?: boolean, inline?: boolean, group?: boolean, hint?: ReactNode, error?: ReactNode, htmlFor?: string, hintId?: string, errorId?: string, density?: ControlDensity, children?: ReactNode }) {
|
|
164
|
+
const feedback = <>{hint && <span id={hintId} className="pm-ui-field__hint">{hint}</span>}{error && <span id={errorId} className="pm-ui-field__error" role="alert">{error}</span>}</>
|
|
165
|
+
if (group) return <div className="pm-ui-field" data-density={density} role="group" aria-label={label}><span className="pm-ui-field__label">{label}</span>{children}{feedback}</div>
|
|
53
166
|
return (
|
|
54
|
-
<label className={cls('pm-ui-field', inline && 'pm-ui-field--inline')}>
|
|
55
|
-
{inline ? <>{children}<span className="pm-ui-field__label">{label}</span></> : <><span className=
|
|
167
|
+
<label className={cls('pm-ui-field', inline && 'pm-ui-field--inline')} data-density={density} htmlFor={htmlFor}>
|
|
168
|
+
{inline ? <>{children}<span id={htmlFor ? `${htmlFor}-label` : undefined} className="pm-ui-field__label">{label}</span></> : <><span id={htmlFor ? `${htmlFor}-label` : undefined} className={labelHidden ? 'pm-ui-sr-only' : 'pm-ui-field__label'}>{label}</span>{children}</>}
|
|
169
|
+
{feedback}
|
|
56
170
|
</label>
|
|
57
171
|
)
|
|
58
172
|
}
|
|
59
173
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
174
|
+
type FieldProps = { label: string, hint?: ReactNode, error?: ReactNode, density?: ControlDensity }
|
|
175
|
+
function useField({ id, hint, error, 'aria-describedby': describedBy }: { id?: string, hint?: ReactNode, error?: ReactNode, 'aria-describedby'?: string }) {
|
|
176
|
+
const generated = useId()
|
|
177
|
+
const controlId = id ?? generated
|
|
178
|
+
return { id: controlId, hintId: hint ? `${controlId}-hint` : undefined, errorId: error ? `${controlId}-error` : undefined,
|
|
179
|
+
describedBy: [describedBy, hint && `${controlId}-hint`, error && `${controlId}-error`].filter(Boolean).join(' ') || undefined }
|
|
63
180
|
}
|
|
181
|
+
export const PmInput = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & FieldProps & { labelHidden?: boolean, prefixIcon?: IconName, suffix?: ReactNode, width?: 'number' | 'time' }>(function PmInput({ label, labelHidden, hint, error, density = 'compact', prefixIcon, suffix, width, className, ...rest }, ref) {
|
|
182
|
+
const field = useField({ ...rest, hint, error })
|
|
183
|
+
const input = <input {...rest} ref={ref} id={field.id} data-density={density} data-width={width} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-input', className)} />
|
|
184
|
+
return <PmField label={label} labelHidden={labelHidden} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
|
|
185
|
+
{prefixIcon || suffix ? <span className="pm-ui-control" data-width={width} data-density={density}>{prefixIcon && <span className="pm-ui-control__prefix"><PmIcon name={prefixIcon} /></span>}{input}{suffix && <span className="pm-ui-control__suffix">{suffix}</span>}</span> : input}
|
|
186
|
+
</PmField>
|
|
187
|
+
})
|
|
188
|
+
export const PmTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & FieldProps & { autoGrow?: boolean }>(function PmTextarea({ label, hint, error, density = 'compact', autoGrow, className, ...rest }, ref) {
|
|
189
|
+
const field = useField({ ...rest, hint, error })
|
|
190
|
+
const node = useRef<HTMLTextAreaElement | null>(null)
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!autoGrow || !node.current) return
|
|
193
|
+
node.current.style.height = 'auto'
|
|
194
|
+
node.current.style.height = `${node.current.scrollHeight}px`
|
|
195
|
+
}, [autoGrow, rest.value])
|
|
196
|
+
return <PmField label={label} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
|
|
197
|
+
<textarea {...rest} ref={element => { node.current = element; if (typeof ref === 'function') ref(element); else if (ref) ref.current = element }} id={field.id} data-density={density} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-textarea', className)} />
|
|
198
|
+
</PmField>
|
|
199
|
+
})
|
|
200
|
+
export const PmSelect = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement> & FieldProps & { labelHidden?: boolean }>(function PmSelect({ label, labelHidden, hint, error, density = 'compact', className, children, ...rest }, ref) {
|
|
201
|
+
const field = useField({ ...rest, hint, error })
|
|
202
|
+
return <PmField label={label} labelHidden={labelHidden} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
|
|
203
|
+
<select {...rest} ref={ref} id={field.id} data-density={density} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-select', className)}>{children}</select>
|
|
204
|
+
</PmField>
|
|
205
|
+
})
|
|
64
206
|
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
207
|
+
export interface ChoiceSelectItem extends Choice { description?: ReactNode }
|
|
208
|
+
export interface ChoiceSelectProps {
|
|
209
|
+
label: string
|
|
210
|
+
value: string
|
|
211
|
+
choices: readonly ChoiceSelectItem[]
|
|
212
|
+
onValueChange(value: string): void
|
|
213
|
+
placeholder?: string
|
|
214
|
+
hint?: ReactNode
|
|
215
|
+
error?: ReactNode
|
|
216
|
+
disabled?: boolean
|
|
217
|
+
required?: boolean
|
|
218
|
+
requiredMessage?: string
|
|
219
|
+
name?: string
|
|
220
|
+
id?: string
|
|
221
|
+
density?: ControlDensity
|
|
222
|
+
'aria-describedby'?: string
|
|
71
223
|
}
|
|
72
224
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
225
|
+
/** A consistent choice menu; Radix owns typeahead, navigation, dismissal and focus return. */
|
|
226
|
+
export function PmChoiceSelect({ label, value, choices, onValueChange, placeholder, hint, error, disabled, required, requiredMessage, name, id, density = 'regular', 'aria-describedby': describedBy }: ChoiceSelectProps) {
|
|
227
|
+
const [invalid, setInvalid] = useState(false)
|
|
228
|
+
const trigger = useRef<HTMLButtonElement>(null)
|
|
229
|
+
const feedback = error ?? (invalid ? requiredMessage : undefined)
|
|
230
|
+
const field = useField({ id, hint, error: feedback, 'aria-describedby': describedBy })
|
|
231
|
+
const selected = choices.find(choice => choice.value === value)
|
|
232
|
+
useEffect(() => { setInvalid(false) }, [value])
|
|
233
|
+
const change = (next: string) => { setInvalid(false); onValueChange(next) }
|
|
234
|
+
return <PmField label={required ? `${label} *` : label} htmlFor={field.id} hint={hint} error={feedback} hintId={field.hintId} errorId={field.errorId} density={density}>
|
|
235
|
+
<DropdownMenu.Root>
|
|
236
|
+
<DropdownMenu.Trigger asChild><button ref={trigger} id={field.id} type="button" className="pm-ui-choice-select__trigger" data-density={density} data-placeholder={!selected || undefined} disabled={disabled} aria-labelledby={`${field.id}-label ${field.id}-value`} aria-describedby={field.describedBy} aria-invalid={Boolean(error || invalid) || undefined}>
|
|
237
|
+
{selected?.icon && <PmIcon name={selected.icon} />}<span id={`${field.id}-value`} className="pm-ui-choice-select__value">{selected?.label ?? placeholder ?? label}</span><PmIcon name="chevron-down" />
|
|
238
|
+
</button></DropdownMenu.Trigger>
|
|
239
|
+
<DropdownMenu.Portal><DropdownMenu.Content className="pm-ui-menu pm-ui-floating pm-ui pm-ui-choice-select__menu" data-density={density} aria-label={label} side="bottom" align="start" sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
|
|
240
|
+
<DropdownMenu.RadioGroup value={value} onValueChange={change}>
|
|
241
|
+
{choices.filter(choice => choice.value !== '').map(choice => <DropdownMenu.RadioItem key={choice.value} value={choice.value} textValue={choice.label} disabled={choice.disabled} className="pm-ui-menu__item pm-ui-choice-select__option">
|
|
242
|
+
{choice.icon && <PmIcon name={choice.icon} />}<span className="pm-ui-choice-select__text"><span>{choice.label}</span>{choice.description && <span className="pm-ui-choice-select__description">{choice.description}</span>}</span><span className="pm-ui-choice-select__check"><DropdownMenu.ItemIndicator><PmIcon name="check" /></DropdownMenu.ItemIndicator></span>
|
|
243
|
+
</DropdownMenu.RadioItem>)}
|
|
244
|
+
</DropdownMenu.RadioGroup>
|
|
245
|
+
</DropdownMenu.Content></DropdownMenu.Portal>
|
|
246
|
+
</DropdownMenu.Root>
|
|
247
|
+
{/* The hidden native proxy participates only in HTML form submission and required validation. */}
|
|
248
|
+
{(name || required) && <select className="pm-ui-choice-select__native" aria-hidden="true" tabIndex={-1} name={name} value={selected?.value ?? ''} required={required} disabled={disabled} onChange={event => change(event.target.value)} onInvalid={event => { event.preventDefault(); setInvalid(true); trigger.current?.focus() }}><option value="" />{choices.filter(choice => choice.value !== '').map(choice => <option key={choice.value} value={choice.value} disabled={choice.disabled}>{choice.label}</option>)}</select>}
|
|
249
|
+
</PmField>
|
|
82
250
|
}
|
|
83
251
|
|
|
84
|
-
export
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
252
|
+
export interface WorkspaceSwitcherProps {
|
|
253
|
+
label: string
|
|
254
|
+
value?: string
|
|
255
|
+
items: readonly SearchListItem[]
|
|
256
|
+
onValueChange(value: string): void
|
|
257
|
+
placeholder: string
|
|
258
|
+
searchLabel: string
|
|
259
|
+
searchPlaceholder?: string
|
|
260
|
+
emptyLabel: string
|
|
261
|
+
resultsLabel(count: number): string
|
|
262
|
+
loadingLabel: string
|
|
263
|
+
loading?: boolean
|
|
264
|
+
disabled?: boolean
|
|
265
|
+
createLabel: string
|
|
266
|
+
onCreate(): void
|
|
98
267
|
}
|
|
99
268
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
269
|
+
/** Search candidate choices without navigating; activate by click, Space or Enter to switch. */
|
|
270
|
+
export function PmWorkspaceSwitcher({ label, value, items, onValueChange, placeholder, searchLabel, searchPlaceholder, emptyLabel, resultsLabel, loadingLabel, loading, disabled, createLabel, onCreate }: WorkspaceSwitcherProps) {
|
|
271
|
+
const [open, setOpen] = useState(false)
|
|
272
|
+
const [candidate, setCandidate] = useState(value)
|
|
273
|
+
const panel = useRef<HTMLDivElement>(null)
|
|
274
|
+
const trigger = useRef<HTMLButtonElement>(null)
|
|
275
|
+
const creating = useRef(false)
|
|
276
|
+
const selected = items.find(item => item.value === value)
|
|
277
|
+
return <Popover.Root open={open} onOpenChange={next => { setOpen(next); if (next) { setCandidate(value); creating.current = false } }}>
|
|
278
|
+
<Popover.Trigger asChild><button ref={trigger} type="button" className="pm-ui-workspace-switcher" disabled={disabled || loading} aria-label={`${label}: ${selected?.label ?? placeholder}`} aria-busy={loading || undefined}>
|
|
279
|
+
<span className="pm-ui-workspace-switcher__mark" aria-hidden="true">{loading ? <span className="pm-ui-loading"><PmIcon name="loading" /></span> : selected ? Array.from(selected.label)[0] : <PmIcon name="folder" />}</span>
|
|
280
|
+
<span className="pm-ui-workspace-switcher__text"><span className="pm-ui-workspace-switcher__name">{selected?.label ?? placeholder}</span>{selected?.description && <span className="pm-ui-workspace-switcher__description">{selected.description}</span>}</span><PmIcon name="chevron-down" />
|
|
281
|
+
</button></Popover.Trigger>
|
|
282
|
+
<Popover.Portal><Popover.Content ref={panel} className="pm-ui-popover pm-ui-floating pm-ui pm-ui-workspace-switcher__panel" side="bottom" align="start" sideOffset={6} collisionPadding={16} aria-label={label} onOpenAutoFocus={event => { event.preventDefault(); panel.current?.querySelector('input')?.focus() }} onCloseAutoFocus={event => { if (creating.current) event.preventDefault() }} onEscapeKeyDown={event => event.stopPropagation()}>
|
|
283
|
+
<PmSearchList label={label} searchLabel={searchLabel} searchPlaceholder={searchPlaceholder} items={items} value={candidate} onValueChange={setCandidate} onActivate={next => { setOpen(false); onValueChange(next) }} resultsLabel={resultsLabel} emptyLabel={emptyLabel} loadingLabel={loadingLabel} loading={loading} autoFocus />
|
|
284
|
+
<div className="pm-ui-workspace-switcher__footer"><PmButton variant="ghost" icon="plus" onClick={() => { creating.current = true; setOpen(false); trigger.current?.focus(); onCreate() }}>{createLabel}</PmButton></div>
|
|
285
|
+
</Popover.Content></Popover.Portal>
|
|
286
|
+
</Popover.Root>
|
|
116
287
|
}
|
|
117
288
|
|
|
118
|
-
export
|
|
289
|
+
export type PopoverPlacement = 'below-end' | 'below-start' | 'above-end' | 'above-start'
|
|
290
|
+
const placementOf = (placement: PopoverPlacement) => ({ side: placement.startsWith('above') ? 'top' as const : 'bottom' as const, align: placement.endsWith('start') ? 'start' as const : 'end' as const })
|
|
291
|
+
|
|
292
|
+
export function PmDialog({ open, label, closeLabel, name, actions, description, footer, busy, onClose, onOpenAutoFocus, onCloseAutoFocus, children }: { open: boolean, label: string, closeLabel: string, name?: string, actions?: ReactNode, description?: string, footer?: ReactNode, busy?: boolean, onClose(): void, onOpenAutoFocus?(event: Event): void, onCloseAutoFocus?(event: Event): void, children?: ReactNode }) {
|
|
293
|
+
const id = useId()
|
|
294
|
+
// A descendant's React autoFocus runs before Radix's open-focus event.
|
|
295
|
+
const trigger = useRef<HTMLElement | null>(typeof document === 'undefined' ? null : document.activeElement as HTMLElement)
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (open) return
|
|
298
|
+
const remember = (event: FocusEvent) => {
|
|
299
|
+
const element = event.target
|
|
300
|
+
if (element instanceof HTMLElement && element !== document.body && element.closest('[data-pm-dialog]')?.getAttribute('data-pm-dialog') !== id) trigger.current = element
|
|
301
|
+
}
|
|
302
|
+
document.addEventListener('focusin', remember, true)
|
|
303
|
+
return () => document.removeEventListener('focusin', remember, true)
|
|
304
|
+
}, [id, open])
|
|
119
305
|
return (
|
|
120
|
-
<Dialog.Root open={open} onOpenChange={next => { if (!next) onClose() }}>
|
|
306
|
+
<Dialog.Root open={open} modal onOpenChange={next => { if (!next && !busy) onClose() }}>
|
|
121
307
|
<Dialog.Portal>
|
|
122
|
-
<Dialog.Overlay className="
|
|
123
|
-
<Dialog.Content className={cls('pm-ui-dialog', 'pm-ui', name && `${name}__panel`)} aria-
|
|
308
|
+
<Dialog.Overlay className="pm-ui-dialog-overlay" />
|
|
309
|
+
<Dialog.Content className={cls('pm-ui-dialog', 'pm-ui', name && `${name}__panel`)} data-pm-dialog={id} data-density="regular" aria-modal="true" aria-busy={busy || undefined} {...description ? {} : { 'aria-describedby': undefined }} onInteractOutside={event => event.preventDefault()} onEscapeKeyDown={event => { event.stopPropagation(); if (busy) event.preventDefault() }} onOpenAutoFocus={event => {
|
|
310
|
+
onOpenAutoFocus?.(event)
|
|
311
|
+
if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return
|
|
312
|
+
const panel = event.target
|
|
313
|
+
if (panel.contains(document.activeElement)) { event.preventDefault(); return }
|
|
314
|
+
const target = [...panel.querySelectorAll<HTMLElement>('.pm-ui-dialog__body :is(button, input, textarea, select, a[href], [tabindex]), .pm-ui-dialog__footer :is(button, input, textarea, select, a[href], [tabindex])')].find(element => element.tabIndex >= 0 && !element.closest('[inert], [hidden]') && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length && getComputedStyle(element).visibility === 'visible')
|
|
315
|
+
if (target) { event.preventDefault(); target.focus() }
|
|
316
|
+
}} onCloseAutoFocus={event => {
|
|
317
|
+
onCloseAutoFocus?.(event)
|
|
318
|
+
if (event.defaultPrevented) return
|
|
319
|
+
event.preventDefault()
|
|
320
|
+
const available = (element: HTMLElement | null): element is HTMLElement => Boolean(element?.isConnected && element !== document.body && !element.closest(`[data-pm-dialog="${id}"], [inert], [hidden]`) && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length && getComputedStyle(element).visibility === 'visible')
|
|
321
|
+
const target = available(trigger.current) ? trigger.current : [...document.querySelectorAll<HTMLElement>('[role="toolbar"] button, main button')].find(available)
|
|
322
|
+
target?.focus()
|
|
323
|
+
}}>
|
|
124
324
|
<div className="pm-ui-dialog__header">
|
|
125
325
|
<Dialog.Title className="pm-ui-dialog__title">{label}</Dialog.Title>
|
|
126
326
|
{actions}
|
|
127
|
-
<Dialog.Close asChild><PmIconButton icon="close" label={closeLabel} small /></Dialog.Close>
|
|
327
|
+
<Dialog.Close asChild><PmIconButton icon="close" label={closeLabel} small disabled={busy} /></Dialog.Close>
|
|
128
328
|
</div>
|
|
329
|
+
{description && <Dialog.Description className="pm-ui-dialog__description">{description}</Dialog.Description>}
|
|
129
330
|
<div className="pm-ui-dialog__body">{children}</div>
|
|
331
|
+
{footer && <div className="pm-ui-dialog__footer">{footer}</div>}
|
|
130
332
|
</Dialog.Content>
|
|
131
333
|
</Dialog.Portal>
|
|
132
334
|
</Dialog.Root>
|
|
133
335
|
)
|
|
134
336
|
}
|
|
135
337
|
|
|
338
|
+
/** A non-modal floating panel on its trigger, with the shared `pm-ui-popover` surface (host's PmPopover for React pages). */
|
|
339
|
+
export function PmPopover({ open, label, name, placement = 'below-end', trigger, onOpenChange, children }: { open: boolean, label: string, name?: string, placement?: PopoverPlacement, trigger: ReactNode, onOpenChange(open: boolean): void, children?: ReactNode }) {
|
|
340
|
+
return (
|
|
341
|
+
<Popover.Root open={open} onOpenChange={onOpenChange}>
|
|
342
|
+
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
|
|
343
|
+
<Popover.Portal>
|
|
344
|
+
<Popover.Content className={cls('pm-ui-popover', 'pm-ui-floating', 'pm-ui', name)} aria-label={label} {...placementOf(placement)} sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
|
|
345
|
+
{children}
|
|
346
|
+
</Popover.Content>
|
|
347
|
+
</Popover.Portal>
|
|
348
|
+
</Popover.Root>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
136
352
|
export interface MenuItem {
|
|
137
353
|
id: string
|
|
138
354
|
label: string
|
|
139
355
|
icon?: IconName
|
|
140
356
|
danger?: boolean
|
|
141
357
|
disabled?: boolean
|
|
358
|
+
shortcut?: string
|
|
359
|
+
checked?: boolean
|
|
360
|
+
separator?: boolean
|
|
142
361
|
}
|
|
143
362
|
|
|
144
|
-
|
|
363
|
+
/** `toolbarItem`: the trigger joins the surrounding `PmToolbar`'s arrow-key navigation, e.g. as its overflow ("More") menu. */
|
|
364
|
+
export function PmMenu({ label, description, items, icon = 'more', placement = 'below-end', triggerRef, toolbarItem, onSelect }: { label: string, description?: string, items: MenuItem[], icon?: IconName, placement?: PopoverPlacement, triggerRef?: Ref<HTMLButtonElement>, toolbarItem?: boolean, onSelect(id: string): void }) {
|
|
365
|
+
const descriptionId = useId()
|
|
366
|
+
const trigger = <PmIconButton ref={triggerRef} icon={icon} label={label} small />
|
|
145
367
|
return (
|
|
146
368
|
<DropdownMenu.Root>
|
|
147
|
-
<DropdownMenu.Trigger asChild
|
|
369
|
+
<DropdownMenu.Trigger asChild>{toolbarItem ? <Toolbar.Button asChild>{trigger}</Toolbar.Button> : trigger}</DropdownMenu.Trigger>
|
|
148
370
|
<DropdownMenu.Portal>
|
|
149
|
-
<DropdownMenu.Content className="pm-ui-menu
|
|
371
|
+
<DropdownMenu.Content className="pm-ui-menu pm-ui-floating pm-ui" data-density="compact" data-description={description ? '' : undefined} aria-label={label} aria-describedby={description ? descriptionId : undefined} {...placementOf(placement)} sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
|
|
372
|
+
{description && <p className="pm-ui-menu__description" id={descriptionId}>{description}</p>}
|
|
150
373
|
{items.map(item => (
|
|
151
|
-
<DropdownMenu.
|
|
152
|
-
{item.icon && <PmIcon name={item.icon}
|
|
153
|
-
</DropdownMenu.Item>
|
|
374
|
+
<DropdownMenu.Group key={item.id}>{item.separator && <DropdownMenu.Separator className="pm-ui-menu__separator" />}{item.checked === undefined ? <DropdownMenu.Item textValue={item.label} className={cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger')} disabled={item.disabled} onSelect={() => onSelect(item.id)}>
|
|
375
|
+
{item.icon && <PmIcon name={item.icon} />}{item.label}{item.shortcut && <kbd className="pm-ui-shortcut">{item.shortcut}</kbd>}
|
|
376
|
+
</DropdownMenu.Item> : <DropdownMenu.CheckboxItem className="pm-ui-menu__item" checked={item.checked} disabled={item.disabled} textValue={item.label} onSelect={() => onSelect(item.id)}><DropdownMenu.ItemIndicator><PmIcon name="check" /></DropdownMenu.ItemIndicator>{item.label}</DropdownMenu.CheckboxItem>}</DropdownMenu.Group>
|
|
154
377
|
))}
|
|
155
378
|
</DropdownMenu.Content>
|
|
156
379
|
</DropdownMenu.Portal>
|
|
@@ -163,12 +386,246 @@ export function PmEmpty({ title, icon, action, children }: { title?: string, ico
|
|
|
163
386
|
<div className="pm-ui-empty">
|
|
164
387
|
{icon && <PmIcon name={icon} size={28} />}
|
|
165
388
|
{title && <p className="pm-ui-empty__title">{title}</p>}
|
|
166
|
-
{children && <p
|
|
389
|
+
{children && <p className="pm-ui-empty__body">{children}</p>}
|
|
167
390
|
{action}
|
|
168
391
|
</div>
|
|
169
392
|
)
|
|
170
393
|
}
|
|
171
394
|
|
|
395
|
+
/** Loading in the shape of the content: `document` is a page of headings and paragraphs, `list` a column of rows and
|
|
396
|
+
* `preview` a slide-shaped area. The label is the status read to assistive technology. */
|
|
397
|
+
export function PmSkeleton({ label, shape = 'document' }: { label: string, shape?: 'document' | 'list' | 'preview' }) {
|
|
398
|
+
const bar = (kind?: 'heading' | 'row' | 'media', key?: number) => <span key={key} className="pm-ui-skeleton__bar" data-kind={kind} />
|
|
399
|
+
const paragraph = (lines: number, key: number) => <span key={key} className="pm-ui-skeleton__group">{Array.from({ length: lines }, (_, index) => bar(undefined, index))}</span>
|
|
400
|
+
return (
|
|
401
|
+
<div className="pm-ui-skeleton" data-shape={shape} role="status" aria-busy="true">
|
|
402
|
+
<span className="pm-ui-sr-only">{label}</span>
|
|
403
|
+
<span className="pm-ui-skeleton" aria-hidden="true">
|
|
404
|
+
{shape === 'list' ? Array.from({ length: 5 }, (_, index) => bar('row', index))
|
|
405
|
+
: shape === 'preview' ? <>{bar('media')}{paragraph(2, 0)}</>
|
|
406
|
+
: <>{bar('heading')}{paragraph(4, 0)}{paragraph(3, 1)}{bar('heading')}{paragraph(4, 2)}</>}
|
|
407
|
+
</span>
|
|
408
|
+
</div>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
172
412
|
export function PmNotice({ tone = 'neutral', children }: { tone?: 'neutral' | 'accent' | 'ok' | 'warn' | 'danger', children?: ReactNode }) {
|
|
173
413
|
return <div className={cls('pm-ui-notice', tone !== 'neutral' && `pm-ui-notice--${tone}`)} role={tone === 'danger' ? 'alert' : 'status'}>{children}</div>
|
|
174
414
|
}
|
|
415
|
+
|
|
416
|
+
/** The Presmium kitten acting out what the assistant or a long task is doing. It is decoration: the text beside it
|
|
417
|
+
* says the same thing for everyone, so it is hidden from assistive technology. Nothing shows when the author turned
|
|
418
|
+
* the kitten off in Settings. */
|
|
419
|
+
export function PmMascot(props: { action: MascotAction, size?: 'small' | 'regular' }) {
|
|
420
|
+
return useMascotEnabled() ? <MascotPlayer {...props} /> : null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function MascotPlayer({ action, size = 'regular' }: { action: MascotAction, size?: 'small' | 'regular' }) {
|
|
424
|
+
const ref = useRef<HTMLSpanElement>(null)
|
|
425
|
+
const player = useRef<ReturnType<typeof mountMascot>>(undefined)
|
|
426
|
+
const first = useRef(action)
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
const mounted = mountMascot(ref.current!, first.current)
|
|
429
|
+
player.current = mounted
|
|
430
|
+
return () => mounted.stop()
|
|
431
|
+
}, [])
|
|
432
|
+
useEffect(() => { player.current?.set(action) }, [action])
|
|
433
|
+
return <span ref={ref} className={cls('pm-ui-mascot', size === 'small' && 'pm-ui-mascot--sm')} data-pm-mascot={action} aria-hidden="true" />
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export interface RangeProps {
|
|
437
|
+
label: string
|
|
438
|
+
value: number
|
|
439
|
+
min?: number
|
|
440
|
+
max: number
|
|
441
|
+
step?: number
|
|
442
|
+
disabled?: boolean
|
|
443
|
+
density?: ControlDensity
|
|
444
|
+
valueText?(value: number): string
|
|
445
|
+
onValueCommit(value: number): void
|
|
446
|
+
onPreviewChange?(value: number | undefined): void
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Native range owns pointer/keyboard mechanics; only a committed gesture requests adoption. */
|
|
450
|
+
export function PmRange({ label, value, min = 0, max, step = 1, disabled, density = 'compact', valueText, onValueCommit, onPreviewChange }: RangeProps) {
|
|
451
|
+
const [draft, setDraft] = useState<number>()
|
|
452
|
+
const pointer = useRef<'active' | 'cancelled'>(), latest = useRef<number>(), preview = useRef(onPreviewChange)
|
|
453
|
+
preview.current = onPreviewChange
|
|
454
|
+
const blocked = Boolean(disabled || !Number.isFinite(min) || !Number.isFinite(max) || max <= min)
|
|
455
|
+
const minimum = Number.isFinite(min) ? min : 0, maximum = Number.isFinite(max) ? Math.max(minimum, max) : minimum
|
|
456
|
+
const bounded = (next: number) => Math.max(minimum, Math.min(maximum, Number.isFinite(next) ? next : minimum))
|
|
457
|
+
const current = bounded(draft ?? value), progress = max > min ? (current - min) / (max - min) * 100 : 0
|
|
458
|
+
const cancel = () => { if (pointer.current) pointer.current = 'cancelled'; latest.current = undefined; setDraft(undefined); preview.current?.(undefined) }
|
|
459
|
+
useEffect(() => { cancel() }, [blocked, min, max])
|
|
460
|
+
useEffect(() => () => { pointer.current = undefined; latest.current = undefined; preview.current?.(undefined) }, [])
|
|
461
|
+
return <input type="range" className="pm-ui-range" data-density={density} aria-label={label} aria-valuetext={valueText?.(current)} min={minimum} max={maximum} step={step} value={current} disabled={blocked}
|
|
462
|
+
style={{ '--pm-range-progress': `${progress}%` } as CSSProperties}
|
|
463
|
+
onPointerDown={() => { if (!blocked) pointer.current = 'active' }}
|
|
464
|
+
onPointerCancel={() => { cancel(); pointer.current = undefined }} onBlur={cancel}
|
|
465
|
+
onPointerUp={() => { const active = pointer.current === 'active', next = latest.current; cancel(); pointer.current = undefined; if (active && !blocked && next !== undefined) onValueCommit(next) }}
|
|
466
|
+
onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); cancel() } }}
|
|
467
|
+
onChange={event => {
|
|
468
|
+
if (blocked || pointer.current === 'cancelled') return
|
|
469
|
+
const next = bounded(event.currentTarget.valueAsNumber)
|
|
470
|
+
if (pointer.current === 'active') { latest.current = next; setDraft(next); preview.current?.(next) }
|
|
471
|
+
else onValueCommit(next)
|
|
472
|
+
}} />
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export type TimelineTiming = 'confirmed' | 'estimated' | 'unknown'
|
|
476
|
+
export interface TimelineSegment {
|
|
477
|
+
id: string
|
|
478
|
+
stepId?: string
|
|
479
|
+
label: string
|
|
480
|
+
startMs?: number
|
|
481
|
+
endMs?: number
|
|
482
|
+
timing: TimelineTiming
|
|
483
|
+
statusLabel?: string
|
|
484
|
+
disabled?: boolean
|
|
485
|
+
/** Real normalized peak amplitudes; absence never produces a decorative waveform. */
|
|
486
|
+
waveform?: readonly number[]
|
|
487
|
+
}
|
|
488
|
+
export interface TimelineTrack { id: 'visual' | 'voice' | 'caption', label: string, segments: readonly TimelineSegment[] }
|
|
489
|
+
export interface TimelineLabels {
|
|
490
|
+
position: string
|
|
491
|
+
seconds: string
|
|
492
|
+
seek: string
|
|
493
|
+
zoomIn: string
|
|
494
|
+
zoomOut: string
|
|
495
|
+
fit: string
|
|
496
|
+
estimated: string
|
|
497
|
+
unknown: string
|
|
498
|
+
loading: string
|
|
499
|
+
empty: string
|
|
500
|
+
seekHint: string
|
|
501
|
+
invalidPosition: string
|
|
502
|
+
}
|
|
503
|
+
export interface TimelineProps {
|
|
504
|
+
label: string
|
|
505
|
+
tracks: readonly TimelineTrack[]
|
|
506
|
+
labels: TimelineLabels
|
|
507
|
+
durationMs?: number
|
|
508
|
+
durationStatus?: TimelineTiming
|
|
509
|
+
currentTimeMs?: number
|
|
510
|
+
selectedStepId?: string
|
|
511
|
+
zoom?: number
|
|
512
|
+
onZoomChange?(zoom: number): void
|
|
513
|
+
onSelectStep(stepId: string): void
|
|
514
|
+
/** Optional segment activation; the caller owns whether its confirmed time may be sought. */
|
|
515
|
+
onSelectSegment?(track: TimelineTrack['id'], segment: TimelineSegment): void
|
|
516
|
+
/** Full-size narrow/coarse alternative to short proportional segments; no timing is inferred here. */
|
|
517
|
+
segmentPicker?: { track: TimelineTrack['id'], ids: readonly string[], label: string, placeholder: string }
|
|
518
|
+
/** Caller pauses and confirms its source/clock before updating controlled currentTimeMs. */
|
|
519
|
+
onSeek(timeMs: number): void
|
|
520
|
+
seekDisabled?: boolean
|
|
521
|
+
disabled?: boolean
|
|
522
|
+
loading?: boolean
|
|
523
|
+
seeking?: boolean
|
|
524
|
+
density?: ControlDensity
|
|
525
|
+
/** Omit the fallback step strip when the surrounding editor already provides step navigation. */
|
|
526
|
+
showSteps?: boolean
|
|
527
|
+
/** A flush docked editor, with status and zoom in the same tool row. */
|
|
528
|
+
variant?: 'default' | 'editor'
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const timelineTime = (ms: number) => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${(ms % 60000 / 1000).toFixed(3).padStart(6, '0')}`
|
|
532
|
+
function waveformPath(samples: readonly number[] | undefined): string | undefined {
|
|
533
|
+
if (!samples?.length || !samples.every(value => Number.isFinite(value) && value >= 0 && value <= 1)) return undefined
|
|
534
|
+
const length = Math.min(160, samples.length)
|
|
535
|
+
return Array.from({ length }, (_, index) => {
|
|
536
|
+
let peak = 0
|
|
537
|
+
for (let at = Math.floor(index * samples.length / length); at < Math.floor((index + 1) * samples.length / length); at++) peak = Math.max(peak, samples[at]!)
|
|
538
|
+
return `M${(index + 0.5) / length * 160},${10 - peak * 10}V${10 + peak * 10}`
|
|
539
|
+
}).join(' ')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Data-only timeline; source, playback, seeking authority and all labels remain with its caller. */
|
|
543
|
+
export function PmTimeline({ label, tracks, labels, durationMs, durationStatus, currentTimeMs, selectedStepId, zoom, onZoomChange, onSelectStep, onSelectSegment, segmentPicker, onSeek, seekDisabled, disabled, loading, seeking, density = 'compact', showSteps = true, variant = 'default' }: TimelineProps) {
|
|
544
|
+
const hintId = useId()
|
|
545
|
+
const viewport = useRef<HTMLDivElement>(null), tick = useRef<HTMLSpanElement>(null), composing = useRef(false)
|
|
546
|
+
const [width, setWidth] = useState(0), [tickWidth, setTickWidth] = useState(1), [localZoom, setLocalZoom] = useState(1), [raw, setRaw] = useState<string>(), [previewTime, setPreviewTime] = useState<number>()
|
|
547
|
+
const span = Number.isFinite(durationMs) && durationMs! >= 0 ? durationMs : undefined
|
|
548
|
+
const timing = durationStatus ?? (span === undefined ? 'unknown' : 'confirmed')
|
|
549
|
+
const timed = span !== undefined && span > 0 && timing !== 'unknown'
|
|
550
|
+
const blocked = Boolean(disabled || loading), cannotSeek = Boolean(blocked || seeking || seekDisabled || !timed || timing !== 'confirmed')
|
|
551
|
+
const scale = Math.max(1, Math.min(16, Number.isFinite(zoom ?? localZoom) ? zoom ?? localZoom : 1))
|
|
552
|
+
const position = Number.isFinite(currentTimeMs) && currentTimeMs! >= 0 ? currentTimeMs : undefined
|
|
553
|
+
const shownPosition = previewTime ?? position
|
|
554
|
+
const zoomTo = (value: number) => { const next = Math.max(1, Math.min(16, value)); if (zoom === undefined) setLocalZoom(next); onZoomChange?.(next) }
|
|
555
|
+
useEffect(() => {
|
|
556
|
+
const element = viewport.current
|
|
557
|
+
if (!element) return
|
|
558
|
+
const update = () => {
|
|
559
|
+
const style = getComputedStyle(element)
|
|
560
|
+
setWidth(Math.max(0, element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)))
|
|
561
|
+
if (tick.current) setTickWidth(tick.current.getBoundingClientRect().width)
|
|
562
|
+
}
|
|
563
|
+
const observer = new ResizeObserver(update); observer.observe(element); update()
|
|
564
|
+
return () => observer.disconnect()
|
|
565
|
+
}, [timed])
|
|
566
|
+
useEffect(() => { if (cannotSeek) { setPreviewTime(undefined); setRaw(undefined) } }, [cannotSeek, span])
|
|
567
|
+
const requestSeek = (next: number) => { if (!cannotSeek && span !== undefined && Number.isFinite(next)) onSeek(Math.max(0, Math.min(span, Math.round(next)))) }
|
|
568
|
+
const number = raw?.normalize('NFKC').trim(), milliseconds = number !== undefined && /^\d+(?:\.\d+)?$/.test(number) ? Number(number) * 1000 : NaN
|
|
569
|
+
const invalid = raw !== undefined && (!Number.isFinite(milliseconds) || milliseconds < 0 || span === undefined || milliseconds > span)
|
|
570
|
+
const commitPosition = () => { if (composing.current || raw === undefined || invalid || cannotSeek) return; setRaw(undefined); requestSeek(milliseconds) }
|
|
571
|
+
const rows = useMemo(() => tracks.map(track => ({ ...track, segments: track.segments.map(segment => ({ ...segment, wave: waveformPath(segment.waveform), positioned: timed && segment.timing !== 'unknown' && Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs) && segment.startMs! >= 0 && segment.endMs! >= segment.startMs! && segment.endMs! <= span! })) })), [tracks, timed, span])
|
|
572
|
+
const pickerSegments = useMemo(() => {
|
|
573
|
+
const ids = new Set(segmentPicker?.ids)
|
|
574
|
+
return rows.find(track => track.id === segmentPicker?.track)?.segments.filter(segment => ids.has(segment.id) && segment.positioned && segment.timing === 'confirmed') ?? []
|
|
575
|
+
}, [rows, segmentPicker])
|
|
576
|
+
const steps = useMemo(() => {
|
|
577
|
+
const choices = new Map<string, TimelineSegment>()
|
|
578
|
+
for (const track of tracks) for (const segment of track.segments) if (segment.stepId && !choices.has(segment.stepId)) choices.set(segment.stepId, segment)
|
|
579
|
+
return [...choices.values()]
|
|
580
|
+
}, [tracks])
|
|
581
|
+
const ticks = useMemo(() => {
|
|
582
|
+
if (!timed || span === undefined) return []
|
|
583
|
+
const interval = span / Math.max(1, width * scale / tickWidth), power = 10 ** Math.floor(Math.log10(Math.max(1, interval)))
|
|
584
|
+
const unit = [1, 2, 5, 10].find(value => value * power >= interval)! * power
|
|
585
|
+
const marks = Array.from({ length: Math.min(200, Math.floor(span / unit) + 1) }, (_, index) => index * unit)
|
|
586
|
+
return [0, ...marks.filter(time => time > 0 && time < span && Math.min(time, span - time) / span * width * scale >= tickWidth), span]
|
|
587
|
+
}, [span, timed, width, scale, tickWidth])
|
|
588
|
+
const status = <div className="pm-ui-timeline__status" role="status">{loading ? labels.loading : timing === 'unknown' || span === undefined ? labels.unknown : `${timing === 'estimated' ? `${labels.estimated} · ` : ''}${timelineTime(span)}`}</div>
|
|
589
|
+
return <section className="pm-ui-timeline" data-variant={variant} data-density={density} data-disabled={blocked || undefined} data-segment-picker={segmentPicker?.track} aria-label={label} aria-busy={loading || seeking || undefined}>
|
|
590
|
+
<div className="pm-ui-timeline__tools">
|
|
591
|
+
<PmInput label={`${labels.position} (${labels.seconds})`} labelHidden={variant === 'editor'} width="time" density={density} inputMode="decimal" maxLength={32} value={raw ?? (position === undefined ? '' : (position / 1000).toFixed(3))} placeholder={labels.unknown} error={invalid ? labels.invalidPosition : undefined} disabled={cannotSeek}
|
|
592
|
+
onChange={event => setRaw(event.target.value)} onBlur={commitPosition}
|
|
593
|
+
onCompositionStart={() => { composing.current = true }} onCompositionEnd={() => { composing.current = false }}
|
|
594
|
+
onKeyDown={event => { if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; if (event.key === 'Enter') { event.preventDefault(); commitPosition() } else if (event.key === 'Escape') setRaw(undefined) }} />
|
|
595
|
+
{variant === 'editor' && status}
|
|
596
|
+
<div className="pm-ui-timeline__zoom">
|
|
597
|
+
<PmIconButton icon="minus" label={labels.zoomOut} density={density} disabled={blocked || scale <= 1} onClick={() => zoomTo(scale / 2)} />
|
|
598
|
+
<output className="pm-ui-timeline__scale">{Math.round(scale * 100)}%</output>
|
|
599
|
+
<PmIconButton icon="plus" label={labels.zoomIn} density={density} disabled={blocked || scale >= 16} onClick={() => zoomTo(scale * 2)} />
|
|
600
|
+
<PmIconButton icon="fit" label={labels.fit} density={density} disabled={blocked} onClick={() => { zoomTo(1); viewport.current?.scrollTo({ left: 0 }) }} />
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
{variant !== 'editor' && status}
|
|
604
|
+
<div className="pm-ui-timeline__tracks">
|
|
605
|
+
<div className="pm-ui-timeline__labels" aria-hidden="true">{timed && <><span /><span /></>}{tracks.map(track => <span key={track.id} data-track-label="">{track.label}</span>)}</div>
|
|
606
|
+
<div ref={viewport} className="pm-ui-timeline__viewport" tabIndex={0} aria-label={label} aria-describedby={hintId}>
|
|
607
|
+
<div className="pm-ui-timeline__canvas" style={{ width: timed ? `${width * scale}px` : '100%' }}>
|
|
608
|
+
{timed && <><div className="pm-ui-timeline__ruler" aria-hidden="true" onClick={event => { const rect = event.currentTarget.getBoundingClientRect(); requestSeek((event.clientX - rect.left) / rect.width * span!) }}>
|
|
609
|
+
{ticks.map((time, index) => <span key={time} ref={index === 0 ? tick : undefined} data-end={time === span || undefined} style={{ left: `${time / span! * 100}%` }}>{timelineTime(time).replace(/\.000$/, '')}</span>)}
|
|
610
|
+
</div><PmRange label={labels.seek} value={position ?? 0} max={span!} disabled={cannotSeek} density={density} valueText={timelineTime} onPreviewChange={setPreviewTime} onValueCommit={requestSeek} /></>}
|
|
611
|
+
{rows.map(track => <div key={track.id} className="pm-ui-timeline__track" data-track={track.id} role="group" aria-label={track.label}>
|
|
612
|
+
{track.segments.map(segment => {
|
|
613
|
+
const description = `${segment.label}${segment.statusLabel ? ` · ${segment.statusLabel}` : ''} · ${segment.positioned ? `${segment.timing === 'estimated' ? `${labels.estimated} · ` : ''}${timelineTime(segment.startMs!)}–${timelineTime(segment.endMs!)}` : labels.unknown}`
|
|
614
|
+
const content = <>{segment.wave && segment.positioned && <svg className="pm-ui-timeline__wave" viewBox="0 0 160 20" preserveAspectRatio="none" aria-hidden="true"><path d={segment.wave} /></svg>}<span>{segment.label}</span></>
|
|
615
|
+
return segment.positioned ? <button key={segment.id} type="button" className="pm-ui-timeline__segment" data-timing={segment.timing} aria-label={description} title={description} aria-pressed={segment.stepId ? segment.stepId === selectedStepId : undefined} disabled={blocked || segment.disabled || !segment.stepId} onClick={() => { if (onSelectSegment) onSelectSegment(track.id, segment); else if (segment.stepId) onSelectStep(segment.stepId) }} style={{ left: `${segment.startMs! / span! * 100}%`, width: `${(segment.endMs! - segment.startMs!) / span! * 100}%` }}>{content}</button>
|
|
616
|
+
: <span className="pm-ui-timeline__unknown" key={segment.id} title={description}>{segment.label} · {labels.unknown}</span>
|
|
617
|
+
})}
|
|
618
|
+
</div>)}
|
|
619
|
+
{timed && shownPosition !== undefined && <span className="pm-ui-timeline__cursor" data-preview={previewTime !== undefined || undefined} style={{ left: `${Math.max(0, Math.min(1, shownPosition / span!)) * 100}%` }} aria-hidden="true" />}
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
{segmentPicker && pickerSegments.length > 0 && <div className="pm-ui-timeline__segment-picker"><PmSelect density="touch" label={segmentPicker.label} value="" disabled={cannotSeek || !onSelectSegment}
|
|
624
|
+
onChange={event => { const segment = pickerSegments.find(segment => segment.id === event.target.value); if (segment && !cannotSeek) onSelectSegment?.(segmentPicker.track, segment) }}>
|
|
625
|
+
<option value="">{segmentPicker.placeholder}</option>{pickerSegments.map(segment => <option key={segment.id} value={segment.id} disabled={segment.disabled}>{timelineTime(segment.startMs!)} · {segment.label}</option>)}
|
|
626
|
+
</PmSelect></div>}
|
|
627
|
+
{showSteps && <div className="pm-ui-timeline__steps">{steps.map(segment => <PmButton key={segment.stepId} density={density} disabled={blocked || segment.disabled} aria-pressed={segment.stepId === selectedStepId} variant={segment.stepId === selectedStepId ? 'accent' : 'default'} onClick={() => onSelectStep(segment.stepId!)}>{segment.label}</PmButton>)}</div>}
|
|
628
|
+
{!tracks.some(track => track.segments.length) && <PmNotice>{labels.empty}</PmNotice>}
|
|
629
|
+
<p id={hintId} className="pm-ui-sr-only">{labels.seekHint}</p>
|
|
630
|
+
</section>
|
|
631
|
+
}
|