@lincyaw/host 1.0.0-rc.0 → 1.0.0-rc.2
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 +14 -15
- package/package.json +16 -7
- package/src/actions.ts +22 -0
- package/src/approvals.ts +9 -55
- package/src/blocks.ts +9 -10
- package/src/claim-first.ts +13 -0
- package/src/code-lines-box.ts +11 -0
- package/src/{types.ts → contracts.ts} +62 -12
- package/src/control.ts +117 -0
- package/src/design/appearance.ts +16 -0
- package/src/design/caption.ts +31 -0
- package/src/design/components.ts +132 -316
- package/src/design/design.css +8 -0
- package/src/design/layout.ts +28 -0
- package/src/error-boundary.ts +18 -0
- package/src/host-key.ts +4 -0
- package/src/host.ts +42 -77
- package/src/index.ts +55 -25
- package/src/locale.ts +15 -37
- package/src/messages.ts +25 -10
- package/src/operations.ts +6 -22
- package/src/panels.ts +25 -57
- package/src/playback-control.ts +67 -0
- package/src/registries.ts +86 -0
- package/src/resident-worker.ts +84 -0
- package/src/runtime.ts +187 -0
- package/src/server.ts +27 -164
- package/src/session.ts +5 -29
- package/src/shared-state.ts +30 -0
- package/src/site.ts +14 -0
- package/src/start-report-panel.ts +8 -0
- package/src/step-clock.ts +49 -0
- package/src/step-scope.ts +38 -0
- package/src/store.ts +33 -0
- package/src/styles.d.ts +1 -0
- package/src/windows.ts +4 -2
- package/src/PresenterPanels.vue +0 -91
- package/src/StartReportPanel.vue +0 -45
- package/src/Toolbar.vue +0 -49
- package/src/boundary.ts +0 -56
- package/src/design/theme.ts +0 -32
- package/src/kernel.ts +0 -131
- package/src/key.ts +0 -6
- package/src/scope.ts +0 -29
- package/src/shared.ts +0 -85
- package/src/vue-shim.d.ts +0 -6
package/src/design/components.ts
CHANGED
|
@@ -1,327 +1,143 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
1
|
+
import { createContext, createElement as h, forwardRef, useContext, useEffect, useId, useRef } from 'react'
|
|
2
|
+
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
|
+
import * as Popover from '@radix-ui/react-popover'
|
|
5
|
+
import * as Dropdown from '@radix-ui/react-dropdown-menu'
|
|
6
|
+
import * as Tabs from '@radix-ui/react-tabs'
|
|
7
|
+
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
8
|
+
import * as RadioGroup from '@radix-ui/react-radio-group'
|
|
9
|
+
import { useLocale } from '../locale.ts'
|
|
10
|
+
import { HOST_MESSAGES } from '../messages.ts'
|
|
5
11
|
import { ICONS } from '@lincyaw/design'
|
|
6
12
|
import type { IconName } from '@lincyaw/design'
|
|
7
|
-
|
|
8
|
-
const PLACE: InjectionKey<ToolbarPlace> = Symbol('presmium.toolbarPlace')
|
|
9
|
-
const OVERLAY_Z: InjectionKey<string> = Symbol('presmium.overlayZ')
|
|
10
|
-
|
|
11
|
-
function overlayStyle(kind: 'popover' | 'dialog') {
|
|
12
|
-
const parent = inject(OVERLAY_Z, undefined)
|
|
13
|
-
const zIndex = parent ? `calc(${parent} + 1)` : `var(--pm-z-${kind})`
|
|
14
|
-
provide(OVERLAY_Z, zIndex)
|
|
15
|
-
return { zIndex }
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/** @experimental */
|
|
20
|
-
export function useToolbarPlace(): ToolbarPlace {
|
|
21
|
-
return inject(PLACE, 'bar')
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** @experimental */
|
|
25
|
-
export function provideToolbarPlace(place: ToolbarPlace): void {
|
|
26
|
-
provide(PLACE, place)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const PmIcon = defineComponent({
|
|
30
|
-
name: 'PmIcon',
|
|
31
|
-
props: {
|
|
32
|
-
name: { type: String as PropType<IconName>, required: true },
|
|
33
|
-
size: { type: [Number, String], default: 20 },
|
|
34
|
-
strokeWidth: { type: Number, default: 1.7 },
|
|
35
|
-
},
|
|
36
|
-
setup(props) {
|
|
37
|
-
return () => h('svg', {
|
|
38
|
-
'class': 'pm-ui-icon',
|
|
39
|
-
'width': props.size,
|
|
40
|
-
'height': props.size,
|
|
41
|
-
'viewBox': '0 0 24 24',
|
|
42
|
-
'fill': 'none',
|
|
43
|
-
'stroke': 'currentColor',
|
|
44
|
-
'stroke-width': props.strokeWidth,
|
|
45
|
-
'stroke-linecap': 'round',
|
|
46
|
-
'stroke-linejoin': 'round',
|
|
47
|
-
'aria-hidden': 'true',
|
|
48
|
-
'focusable': 'false',
|
|
49
|
-
}, (ICONS[props.name] ?? []).map(([tag, attrs]) => h(tag, attrs)))
|
|
50
|
-
},
|
|
51
|
-
})
|
|
52
|
-
|
|
13
|
+
import type { ToolbarPlacement } from '../contracts.ts'
|
|
53
14
|
export type ButtonVariant = 'default' | 'primary' | 'accent' | 'ghost' | 'danger'
|
|
54
|
-
|
|
55
|
-
export const PmButton = defineComponent({
|
|
56
|
-
name: 'PmButton',
|
|
57
|
-
props: {
|
|
58
|
-
variant: { type: String as PropType<ButtonVariant>, default: 'default' },
|
|
59
|
-
small: Boolean,
|
|
60
|
-
icon: String as PropType<IconName>,
|
|
61
|
-
type: { type: String as PropType<'button' | 'submit' | 'reset'>, default: 'button' },
|
|
62
|
-
},
|
|
63
|
-
setup(props, { slots }) {
|
|
64
|
-
return () => h('button', {
|
|
65
|
-
type: props.type,
|
|
66
|
-
class: ['pm-ui-button', props.variant !== 'default' && `pm-ui-button--${props.variant}`, props.small && 'pm-ui-button--small'],
|
|
67
|
-
}, [props.icon ? h(PmIcon, { name: props.icon, size: props.small ? 14 : 16, strokeWidth: 2 }) : null, slots.default?.()])
|
|
68
|
-
},
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
export const PmIconButton = defineComponent({
|
|
72
|
-
name: 'PmIconButton',
|
|
73
|
-
props: {
|
|
74
|
-
icon: { type: String as PropType<IconName>, required: true },
|
|
75
|
-
label: { type: String, required: true },
|
|
76
|
-
small: Boolean,
|
|
77
|
-
},
|
|
78
|
-
setup(props) {
|
|
79
|
-
return () => h('button', {
|
|
80
|
-
'type': 'button',
|
|
81
|
-
'class': ['pm-ui-icon-button', props.small && 'pm-ui-icon-button--small'],
|
|
82
|
-
'aria-label': props.label,
|
|
83
|
-
'title': props.label,
|
|
84
|
-
}, [h(PmIcon, { name: props.icon, size: props.small ? 16 : 20 })])
|
|
85
|
-
},
|
|
86
|
-
})
|
|
87
|
-
|
|
15
|
+
export type ControlDensity = 'compact' | 'regular' | 'touch'
|
|
88
16
|
export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'outline'
|
|
89
|
-
|
|
90
|
-
export
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
},
|
|
96
|
-
setup(props, { slots }) {
|
|
97
|
-
return () => h('span', { class: ['pm-ui-badge', props.tone !== 'neutral' && `pm-ui-badge--${props.tone}`, props.pill && 'pm-ui-badge--pill'] }, slots.default?.())
|
|
98
|
-
},
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
export const PmCard = defineComponent({
|
|
102
|
-
name: 'PmCard',
|
|
103
|
-
props: {
|
|
104
|
-
variant: { type: String as PropType<'default' | 'interactive' | 'dashed'>, default: 'default' },
|
|
105
|
-
tag: { type: String, default: 'div' },
|
|
106
|
-
},
|
|
107
|
-
setup(props, { slots }) {
|
|
108
|
-
return () => h(props.tag, { class: ['pm-ui-card', props.variant !== 'default' && `pm-ui-card--${props.variant}`] }, slots.default?.())
|
|
109
|
-
},
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
export const PmField = defineComponent({
|
|
113
|
-
name: 'PmField',
|
|
114
|
-
props: {
|
|
115
|
-
label: { type: String, required: true },
|
|
116
|
-
inline: Boolean,
|
|
117
|
-
},
|
|
118
|
-
setup(props, { slots }) {
|
|
119
|
-
return () => h('label', { class: ['pm-ui-field', props.inline && 'pm-ui-field--inline'] }, props.inline
|
|
120
|
-
? [slots.default?.(), h('span', { class: 'pm-ui-field__label' }, props.label)]
|
|
121
|
-
: [h('span', { class: 'pm-ui-field__label' }, props.label), slots.default?.()])
|
|
122
|
-
},
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
export interface Choice {
|
|
126
|
-
value: string
|
|
127
|
-
label: string
|
|
17
|
+
export type PopoverPlacement = 'below-end' | 'below-start' | 'above-end' | 'above-start' | 'above' | 'below'
|
|
18
|
+
export interface Choice { value: string, label: string, icon?: IconName, disabled?: boolean }
|
|
19
|
+
export interface MenuItem { id: string, label: string, icon?: IconName, danger?: boolean, disabled?: boolean, shortcut?: string, checked?: boolean, separator?: boolean }
|
|
20
|
+
const cls = (...values: (string | false | undefined)[]) => values.filter(Boolean).join(' ')
|
|
21
|
+
export function PmIcon({ name, size: _size, strokeWidth = 1.7, ...attrs }: { name: IconName, size?: number | string, strokeWidth?: number } & HTMLAttributes<SVGElement>) {
|
|
22
|
+
return h('svg', { ...attrs, className: cls('pm-ui-icon', attrs.className), viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: false }, ICONS[name]?.map(([tag, attributes], key) => h(tag, { ...attributes, key })))
|
|
128
23
|
}
|
|
129
|
-
|
|
130
|
-
export const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
label: { type: String, required: true },
|
|
136
|
-
},
|
|
137
|
-
emits: ['update:modelValue'],
|
|
138
|
-
setup(props, { emit }) {
|
|
139
|
-
return () => h(ToggleGroupRoot, {
|
|
140
|
-
'type': 'single', 'class': 'pm-ui-segmented', 'aria-label': props.label, 'modelValue': props.modelValue,
|
|
141
|
-
'onUpdate:modelValue': value => { if (typeof value === 'string' && value) emit('update:modelValue', value) },
|
|
142
|
-
}, () => props.options.map(option => h(ToggleGroupItem, {
|
|
143
|
-
key: option.value, value: option.value, class: 'pm-ui-segmented__option',
|
|
144
|
-
}, () => option.label)))
|
|
145
|
-
},
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
export const PmTabs = defineComponent({
|
|
149
|
-
name: 'PmTabs',
|
|
150
|
-
props: {
|
|
151
|
-
modelValue: { type: String, required: true },
|
|
152
|
-
tabs: { type: Array as PropType<Choice[]>, required: true },
|
|
153
|
-
label: { type: String, required: true },
|
|
154
|
-
},
|
|
155
|
-
emits: ['update:modelValue'],
|
|
156
|
-
setup(props, { emit, slots }) {
|
|
157
|
-
return () => h(TabsRoot, {
|
|
158
|
-
'class': 'pm-ui-tabset', 'modelValue': props.modelValue,
|
|
159
|
-
'onUpdate:modelValue': value => emit('update:modelValue', String(value)),
|
|
160
|
-
}, () => [
|
|
161
|
-
h(TabsList, { class: 'pm-ui-tabs', 'aria-label': props.label }, () => props.tabs.map(tab =>
|
|
162
|
-
h(TabsTrigger, { key: tab.value, value: tab.value, class: 'pm-ui-tab' }, () => tab.label))),
|
|
163
|
-
slots.default ? h(TabsContent, { key: props.modelValue, value: props.modelValue, class: 'pm-ui-tabpanel', tabindex: 0 }, () => slots.default?.({ value: props.modelValue })) : null,
|
|
164
|
-
])
|
|
165
|
-
},
|
|
24
|
+
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant, small?: boolean, icon?: IconName, density?: ControlDensity, loading?: boolean }
|
|
25
|
+
export const PmButton = forwardRef<HTMLButtonElement, ButtonProps>(function PmButton({ variant = 'default', small, density = small ? 'compact' : 'regular', loading, icon, className, children, disabled, ...rest }, ref) {
|
|
26
|
+
return h('button', { ...rest, ref, type: rest.type ?? 'button', 'data-density': density, 'aria-busy': loading || undefined, disabled: disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true', className: cls('pm-ui-button', variant !== 'default' && `pm-ui-button--${variant}`, className) },
|
|
27
|
+
h('span', { className: cls('pm-ui-button__content', !icon && loading !== undefined && 'pm-ui-button__content--loading-text') },
|
|
28
|
+
icon || loading !== undefined ? h('span', { className: 'pm-ui-button__icon', 'aria-hidden': true }, loading ? h(PmIcon, { name: 'loading', className: 'pm-ui-loading' }) : icon ? h(PmIcon, { name: icon }) : null) : null,
|
|
29
|
+
children))
|
|
166
30
|
})
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return { side: value.startsWith('above') ? 'top' as const : 'bottom' as const, align: value.endsWith('start') ? 'start' as const : 'end' as const, sideOffset: 6, collisionPadding: 16 }
|
|
31
|
+
export function PmTooltip({ label, shortcut, children }: { label: string, shortcut?: string, children: ReactNode }) {
|
|
32
|
+
return h(Tooltip.Provider, { delayDuration: 300, skipDelayDuration: 0, children:
|
|
33
|
+
h(Tooltip.Root, {}, h(Tooltip.Trigger, { asChild: true }, children), h(Tooltip.Portal, {},
|
|
34
|
+
h(Tooltip.Content, { className: 'pm-ui-tooltip pm-ui', side: 'top', sideOffset: 6, collisionPadding: 8 }, label, shortcut ? h('kbd', { className: 'pm-ui-shortcut' }, shortcut) : null))) })
|
|
172
35
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
props: {
|
|
177
|
-
open: Boolean,
|
|
178
|
-
label: { type: String, required: true },
|
|
179
|
-
placement: { type: String as PropType<PopoverPlacement>, default: 'below-end' },
|
|
180
|
-
name: String,
|
|
181
|
-
},
|
|
182
|
-
emits: ['update:open'],
|
|
183
|
-
setup(props, { emit, slots }) {
|
|
184
|
-
const style = overlayStyle('popover')
|
|
185
|
-
const anchor = ref<HTMLElement>()
|
|
186
|
-
const id = useId()
|
|
187
|
-
let outside = false
|
|
188
|
-
return () => h(PopoverRoot, { 'open': props.open, 'onUpdate:open': value => emit('update:open', value) }, () => [
|
|
189
|
-
h(PopoverAnchor, { asChild: true }, () => h('span', { ref: anchor, class: ['pm-ui-anchor', props.name] }, slots.trigger?.().map(node => cloneVNode(node, {
|
|
190
|
-
'aria-haspopup': 'dialog', 'aria-expanded': props.open, 'aria-controls': props.open ? id : undefined,
|
|
191
|
-
})))),
|
|
192
|
-
h(PopoverPortal, {}, () => h(PopoverContent, {
|
|
193
|
-
...placement(props.placement), 'id': id, style,
|
|
194
|
-
onOpenAutoFocus: () => { outside = false },
|
|
195
|
-
onInteractOutside: () => { outside = true },
|
|
196
|
-
onCloseAutoFocus: (event: Event) => { event.preventDefault(); if (!outside) anchor.value?.querySelector<HTMLElement>('button, [href]')?.focus() },
|
|
197
|
-
'class': ['pm-ui-popover', 'pm-ui-floating', 'pm-ui', props.name && `${props.name}__panel`], 'aria-label': props.label,
|
|
198
|
-
}, slots.default)),
|
|
199
|
-
])
|
|
200
|
-
},
|
|
36
|
+
export const PmIconButton = forwardRef<HTMLButtonElement, ButtonProps & { icon: IconName, label: string, hint?: string, shortcut?: string }>(function PmIconButton({ icon, label, hint, shortcut, small, density = small ? 'compact' : 'regular', loading, className, disabled, ...rest }, ref) {
|
|
37
|
+
const button = h('button', { ...rest, ref, type: 'button', 'data-density': density, 'aria-label': label, 'aria-busy': loading || undefined, disabled: disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true', className: cls('pm-ui-icon-button', className) }, h(PmIcon, { name: loading ? 'loading' : icon, className: loading ? 'pm-ui-loading' : undefined }))
|
|
38
|
+
return h(PmTooltip, { label: hint ?? label, shortcut, children: button })
|
|
201
39
|
})
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
name: String,
|
|
208
|
-
} as const
|
|
209
|
-
|
|
210
|
-
function dialogComponent(drawer: boolean) {
|
|
211
|
-
return defineComponent({
|
|
212
|
-
name: drawer ? 'PmDrawer' : 'PmDialog',
|
|
213
|
-
props: dialogProps,
|
|
214
|
-
emits: ['update:open'],
|
|
215
|
-
setup(props, { emit, slots }) {
|
|
216
|
-
const style = overlayStyle(drawer ? 'popover' : 'dialog')
|
|
217
|
-
const kind = drawer ? 'pm-ui-drawer' : 'pm-ui-dialog'
|
|
218
|
-
let opener: HTMLElement | undefined
|
|
219
|
-
return () => h(DialogRoot, { 'open': props.open, 'onUpdate:open': value => emit('update:open', value) }, () => [
|
|
220
|
-
slots.trigger ? h(DialogTrigger, { asChild: true }, slots.trigger) : null,
|
|
221
|
-
h(DialogPortal, {}, () => h(DialogContent, {
|
|
222
|
-
'class': [kind, 'pm-ui', props.name && `${props.name}__panel`], 'style': style, 'aria-describedby': undefined,
|
|
223
|
-
onOpenAutoFocus: () => { opener = document.activeElement instanceof HTMLElement ? document.activeElement : undefined },
|
|
224
|
-
onCloseAutoFocus: (event: Event) => {
|
|
225
|
-
if (slots.trigger || !opener?.isConnected) return
|
|
226
|
-
event.preventDefault()
|
|
227
|
-
opener.focus()
|
|
228
|
-
},
|
|
229
|
-
}, () => [
|
|
230
|
-
h('div', { class: `${kind}__header` }, [
|
|
231
|
-
h(DialogTitle, { as: 'h2', class: `${kind}__title` }, () => props.label),
|
|
232
|
-
slots.actions?.(),
|
|
233
|
-
h(DialogClose, { asChild: true }, () => h(PmIconButton, { icon: 'close', label: props.closeLabel, small: true })),
|
|
234
|
-
]),
|
|
235
|
-
h('div', { class: `${kind}__body` }, slots.default?.()),
|
|
236
|
-
])),
|
|
237
|
-
])
|
|
238
|
-
},
|
|
239
|
-
})
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export const PmDrawer = dialogComponent(true)
|
|
243
|
-
export const PmDialog = dialogComponent(false)
|
|
244
|
-
|
|
245
|
-
export interface MenuItem {
|
|
246
|
-
id: string
|
|
40
|
+
export function PmBadge({ tone = 'neutral', pill, children, className, ...rest }: HTMLAttributes<HTMLSpanElement> & { tone?: Tone, pill?: boolean }) { return h('span', { ...rest, className: cls('pm-ui-badge', tone !== 'neutral' && `pm-ui-badge--${tone}`, pill && 'pm-ui-badge--pill', className) }, children) }
|
|
41
|
+
export function PmCard({ variant = 'default', className, children, ...rest }: HTMLAttributes<HTMLDivElement> & { variant?: string }) { return h('div', { ...rest, className: cls('pm-ui-card', variant !== 'default' && `pm-ui-card--${variant}`, className) }, children) }
|
|
42
|
+
export function PmField({ label, inline, group, children }: { label: string, inline?: boolean, group?: boolean, children?: ReactNode }) { return h(group ? 'div' : 'label', { className: cls('pm-ui-field', inline && 'pm-ui-field--inline'), ...(group ? { role: 'group', 'aria-label': label } : {}) }, h('span', { className: 'pm-ui-field__label' }, label), children) }
|
|
43
|
+
interface OverlayProps {
|
|
44
|
+
open: boolean
|
|
247
45
|
label: string
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
46
|
+
closeLabel?: string
|
|
47
|
+
name?: string
|
|
48
|
+
children?: ReactNode
|
|
49
|
+
trigger?: ReactNode
|
|
50
|
+
actions?: ReactNode
|
|
51
|
+
description?: string
|
|
52
|
+
footer?: ReactNode
|
|
53
|
+
busy?: boolean
|
|
54
|
+
onOpenChange?(open: boolean): void
|
|
55
|
+
onClose?(): void
|
|
56
|
+
onOpenAutoFocus?(event: Event): void
|
|
57
|
+
onCloseAutoFocus?(event: Event): void
|
|
251
58
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
emits: ['select'],
|
|
262
|
-
setup(props, { emit }) {
|
|
263
|
-
const style = overlayStyle('popover')
|
|
264
|
-
return () => h(DropdownMenuRoot, {}, () => [
|
|
265
|
-
h(DropdownMenuTrigger, { asChild: true }, () => h(PmIconButton, { icon: props.icon, label: props.label, small: true })),
|
|
266
|
-
h(DropdownMenuPortal, {}, () => h(DropdownMenuContent, { ...placement(props.placement), style, class: ['pm-ui-menu', 'pm-ui-floating', 'pm-ui'], 'aria-label': props.label }, () => props.items.map(item =>
|
|
267
|
-
h(DropdownMenuItem, {
|
|
268
|
-
key: item.id, disabled: item.disabled, textValue: item.label,
|
|
269
|
-
class: ['pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger'],
|
|
270
|
-
onSelect: () => emit('select', item.id),
|
|
271
|
-
}, () => [item.icon ? h(PmIcon, { name: item.icon, size: 16 }) : null, item.label])))),
|
|
272
|
-
])
|
|
273
|
-
},
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
export const PmEmpty = defineComponent({
|
|
277
|
-
name: 'PmEmpty',
|
|
278
|
-
props: {
|
|
279
|
-
title: String,
|
|
280
|
-
icon: String as PropType<IconName>,
|
|
281
|
-
},
|
|
282
|
-
setup(props, { slots }) {
|
|
283
|
-
return () => h('div', { class: 'pm-ui-empty' }, [
|
|
284
|
-
props.icon ? h(PmIcon, { name: props.icon, size: 28 }) : null,
|
|
285
|
-
props.title ? h('p', { class: 'pm-ui-empty__title' }, props.title) : null,
|
|
286
|
-
slots.default ? h('p', { style: { margin: 0 } }, slots.default()) : null,
|
|
287
|
-
slots.action?.(),
|
|
288
|
-
])
|
|
289
|
-
},
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
export const PmNotice = defineComponent({
|
|
293
|
-
name: 'PmNotice',
|
|
294
|
-
props: {
|
|
295
|
-
tone: { type: String as PropType<'neutral' | 'accent' | 'ok' | 'warn' | 'danger'>, default: 'neutral' },
|
|
296
|
-
},
|
|
297
|
-
setup(props, { slots }) {
|
|
298
|
-
return (): VNode => h('p', { class: ['pm-ui-notice', props.tone !== 'neutral' && `pm-ui-notice--${props.tone}`], role: props.tone === 'danger' ? 'alert' : 'status' }, slots.default?.())
|
|
299
|
-
},
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
export const PmToolbarEntry = defineComponent({
|
|
303
|
-
name: 'PmToolbarEntry',
|
|
304
|
-
props: {
|
|
305
|
-
name: { type: String, required: true },
|
|
306
|
-
label: { type: String, required: true },
|
|
307
|
-
closeLabel: { type: String, required: true },
|
|
308
|
-
icon: String as PropType<IconName>,
|
|
309
|
-
open: Boolean,
|
|
310
|
-
},
|
|
311
|
-
emits: ['update:open'],
|
|
312
|
-
setup(props, { emit, slots }) {
|
|
313
|
-
const place = useToolbarPlace()
|
|
314
|
-
const update = (open: boolean) => emit('update:open', open)
|
|
315
|
-
const toggleAttrs = () => ({
|
|
316
|
-
'class': `${props.name}__toggle`,
|
|
317
|
-
'aria-expanded': props.open ? 'true' : 'false',
|
|
318
|
-
'onClick': () => update(!props.open),
|
|
319
|
-
})
|
|
320
|
-
return () => {
|
|
321
|
-
return h(PmPopover, { 'open': props.open, 'label': props.label, 'name': props.name, 'placement': place === 'status' ? 'above-start' : 'below-end', 'onUpdate:open': update }, {
|
|
322
|
-
trigger: () => h(PmButton, { variant: 'ghost', small: true, icon: props.icon, ...toggleAttrs() }, () => props.label),
|
|
323
|
-
default: () => slots.default?.(),
|
|
324
|
-
})
|
|
59
|
+
function DialogSurface({ open, label, closeLabel, name, children, actions, trigger, description, footer, busy, onOpenChange, onClose, onOpenAutoFocus, onCloseAutoFocus, drawer = false }: OverlayProps & { drawer?: boolean }) {
|
|
60
|
+
const kind = drawer ? 'pm-ui-drawer' : 'pm-ui-dialog'
|
|
61
|
+
const locale = useLocale()
|
|
62
|
+
const id = useId()
|
|
63
|
+
const focusTarget = useRef<HTMLElement | null>(typeof document === 'undefined' ? null : document.activeElement as HTMLElement)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (open) return
|
|
66
|
+
const remember = (event: FocusEvent) => {
|
|
67
|
+
if (event.target instanceof HTMLElement && event.target !== document.body && event.target.closest('[data-pm-dialog]')?.getAttribute('data-pm-dialog') !== id) focusTarget.current = event.target
|
|
325
68
|
}
|
|
326
|
-
|
|
327
|
-
|
|
69
|
+
document.addEventListener('focusin', remember, true)
|
|
70
|
+
return () => document.removeEventListener('focusin', remember, true)
|
|
71
|
+
}, [id, open])
|
|
72
|
+
return h(Dialog.Root, { open, modal: true, onOpenChange: next => { if (!next && busy) return; onOpenChange?.(next); if (!next) onClose?.() } },
|
|
73
|
+
trigger ? h(Dialog.Trigger, { asChild: true }, trigger) : null,
|
|
74
|
+
h(Dialog.Portal, {}, h(Dialog.Overlay, { className: 'pm-ui-dialog-overlay' }),
|
|
75
|
+
h(Dialog.Content, {
|
|
76
|
+
className: cls(kind, 'pm-ui', name && `${name}__panel`), ...{ 'data-density': 'regular', 'data-pm-dialog': id }, 'aria-busy': busy || undefined,
|
|
77
|
+
...description ? {} : { 'aria-describedby': undefined },
|
|
78
|
+
onInteractOutside: event => event.preventDefault(),
|
|
79
|
+
onEscapeKeyDown: event => { event.stopPropagation(); if (busy) event.preventDefault() },
|
|
80
|
+
onOpenAutoFocus: event => {
|
|
81
|
+
onOpenAutoFocus?.(event)
|
|
82
|
+
if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return
|
|
83
|
+
const panel = event.target
|
|
84
|
+
if (panel.contains(document.activeElement)) { event.preventDefault(); return }
|
|
85
|
+
const target = [...panel.querySelectorAll<HTMLElement>(`.${kind}__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')
|
|
86
|
+
if (target) { event.preventDefault(); target.focus() }
|
|
87
|
+
},
|
|
88
|
+
onCloseAutoFocus: event => {
|
|
89
|
+
onCloseAutoFocus?.(event)
|
|
90
|
+
if (event.defaultPrevented) return
|
|
91
|
+
event.preventDefault()
|
|
92
|
+
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')
|
|
93
|
+
const target = available(focusTarget.current) ? focusTarget.current : [...document.querySelectorAll<HTMLElement>('[role="toolbar"] button, main button')].find(available)
|
|
94
|
+
target?.focus()
|
|
95
|
+
},
|
|
96
|
+
}, h('div', { className: `${kind}__header` },
|
|
97
|
+
h(Dialog.Title, { className: `${kind}__title` }, label), actions,
|
|
98
|
+
h(Dialog.Close, { asChild: true }, h(PmIconButton, { icon: 'close', label: closeLabel ?? HOST_MESSAGES[locale].close, small: true, disabled: busy }))),
|
|
99
|
+
description ? h(Dialog.Description, { className: 'pm-ui-dialog__description' }, description) : null,
|
|
100
|
+
h('div', { className: `${kind}__body` }, children),
|
|
101
|
+
footer ? h('div', { className: 'pm-ui-dialog__footer' }, footer) : null)))
|
|
102
|
+
}
|
|
103
|
+
export function PmDialog(props: OverlayProps) { return h(DialogSurface, props) }
|
|
104
|
+
export function PmDrawer(props: OverlayProps) { return h(DialogSurface, { ...props, drawer: true }) }
|
|
105
|
+
const placement = (value: PopoverPlacement) => ({ side: value.startsWith('above') ? 'top' as const : 'bottom' as const, align: value.endsWith('start') ? 'start' as const : 'end' as const })
|
|
106
|
+
export function PmPopover({ open, label, name, children, trigger, placement: place = 'below-end', onOpenChange }: OverlayProps & { placement?: PopoverPlacement }) { return h(Popover.Root, { open, onOpenChange }, trigger ? h(Popover.Trigger, { asChild: true }, trigger) : null, h(Popover.Portal, {}, h(Popover.Content, { ...placement(place), className: cls('pm-ui-popover pm-ui-floating pm-ui', name), 'aria-label': label, onEscapeKeyDown: event => event.stopPropagation() }, children))) }
|
|
107
|
+
export function PmMenu({ label, items, icon = 'more', placement: place = 'below-end', onSelect }: { label: string, items: MenuItem[], icon?: IconName, placement?: PopoverPlacement, onSelect?(id: string): void }) {
|
|
108
|
+
return h(Dropdown.Root, {},
|
|
109
|
+
h(Dropdown.Trigger, { asChild: true }, h(PmIconButton, { icon, label, small: true })),
|
|
110
|
+
h(Dropdown.Portal, {}, h(Dropdown.Content, { ...placement(place), className: 'pm-ui-menu pm-ui-floating pm-ui', ...{ 'data-density': 'compact' }, 'aria-label': label, onEscapeKeyDown: event => event.stopPropagation() },
|
|
111
|
+
items.map(item => h(Dropdown.Group, { key: item.id }, item.separator ? h(Dropdown.Separator, { className: 'pm-ui-menu__separator' }) : null,
|
|
112
|
+
item.checked === undefined ? h(Dropdown.Item, { disabled: item.disabled, textValue: item.label, className: cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger'), onSelect: () => onSelect?.(item.id) },
|
|
113
|
+
item.icon ? h(PmIcon, { name: item.icon }) : null, item.label, item.shortcut ? h('kbd', { className: 'pm-ui-shortcut' }, item.shortcut) : null)
|
|
114
|
+
: h(Dropdown.CheckboxItem, { checked: item.checked, disabled: item.disabled, textValue: item.label, className: cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger'), onSelect: () => onSelect?.(item.id) },
|
|
115
|
+
h(Dropdown.ItemIndicator, {}, h(PmIcon, { name: 'check' })), item.label))))))
|
|
116
|
+
}
|
|
117
|
+
export function PmTabs({ value, modelValue, tabs, label, children, onValueChange, iconOnly, density = 'compact' }: { value?: string, modelValue?: string, tabs: Choice[], label: string, children?: ReactNode, iconOnly?: boolean, density?: ControlDensity, onValueChange?(value: string): void }) {
|
|
118
|
+
return h(Tabs.Root, { value: value ?? modelValue, onValueChange, activationMode: 'manual', className: 'pm-ui-tabs-root', ...{ 'data-density': density } },
|
|
119
|
+
h(Tabs.List, { className: 'pm-ui-tabs', 'aria-label': label }, tabs.map(tab => {
|
|
120
|
+
const trigger = h(Tabs.Trigger, { key: tab.value, value: tab.value, className: 'pm-ui-tab', disabled: tab.disabled, 'aria-label': tab.label, ...{ 'data-icon-only': iconOnly || undefined } }, tab.icon ? h(PmIcon, { name: tab.icon }) : null, iconOnly ? null : tab.label)
|
|
121
|
+
return iconOnly ? h(PmTooltip, { key: tab.value, label: tab.label, children: trigger }) : trigger
|
|
122
|
+
})), children)
|
|
123
|
+
}
|
|
124
|
+
export function PmTabPanel({ value, children, forceMount, hidden }: { value: string, children?: ReactNode, forceMount?: true, hidden?: boolean }) { return h(Tabs.Content, { value, forceMount, hidden, className: 'pm-ui-tab-panel' }, children) }
|
|
125
|
+
export function PmSegmented({ value, modelValue, choices, items, label, onValueChange, density = 'compact', disabled }: { value?: string, modelValue?: string, choices?: Choice[], items?: Choice[], label: string, density?: ControlDensity, disabled?: boolean, onValueChange?(value: string): void }) {
|
|
126
|
+
return h(RadioGroup.Root, { className: 'pm-ui-segmented', 'aria-label': label, value: value ?? modelValue, onValueChange, orientation: 'horizontal', ...{ 'data-density': density }, disabled },
|
|
127
|
+
(choices ?? items ?? []).map(item => h(RadioGroup.Item, { key: item.value, value: item.value, className: 'pm-ui-segmented__option', disabled: item.disabled }, item.icon ? h(PmIcon, { name: item.icon }) : null, item.label)))
|
|
128
|
+
}
|
|
129
|
+
export function PmNotice({ tone = 'neutral', children, className, ...rest }: HTMLAttributes<HTMLDivElement> & { tone?: Tone }) { return h('div', { ...rest, className: cls('pm-ui-notice', tone !== 'neutral' && `pm-ui-notice--${tone}`, className), role: tone === 'danger' ? 'alert' : 'status' }, children) }
|
|
130
|
+
export function PmEmpty({ title, icon, action, children }: { title?: string, icon?: IconName, action?: ReactNode, children?: ReactNode }) { return h('div', { className: 'pm-ui-empty' }, icon ? h(PmIcon, { name: icon }) : null, title ? h('p', { className: 'pm-ui-empty__title' }, title) : null, children, action) }
|
|
131
|
+
/** Loading in the shape of the content: `document` headings and paragraphs, `list` rows, `preview` a slide-shaped area. */
|
|
132
|
+
export function PmSkeleton({ label, shape = 'document' }: { label: string, shape?: 'document' | 'list' | 'preview' }) {
|
|
133
|
+
const bar = (kind?: string, key?: number) => h('span', { key, className: 'pm-ui-skeleton__bar', 'data-kind': kind })
|
|
134
|
+
const paragraph = (lines: number, key: number) => h('span', { key, className: 'pm-ui-skeleton__group' }, Array.from({ length: lines }, (_, index) => bar(undefined, index)))
|
|
135
|
+
const bars = shape === 'list' ? Array.from({ length: 5 }, (_, index) => bar('row', index))
|
|
136
|
+
: shape === 'preview' ? [bar('media', 0), paragraph(2, 1)]
|
|
137
|
+
: [bar('heading', 0), paragraph(4, 1), paragraph(3, 2), bar('heading', 3), paragraph(4, 4)]
|
|
138
|
+
return h('div', { className: 'pm-ui-skeleton', 'data-shape': shape, role: 'status', 'aria-busy': 'true' }, h('span', { className: 'pm-ui-sr-only' }, label), h('span', { className: 'pm-ui-skeleton', 'aria-hidden': 'true' }, bars))
|
|
139
|
+
}
|
|
140
|
+
export const ToolbarPlacementContext = createContext<ToolbarPlacement>('bar')
|
|
141
|
+
export const provideToolbarPlacement = ToolbarPlacementContext.Provider
|
|
142
|
+
export function useToolbarPlacement() { return useContext(ToolbarPlacementContext) }
|
|
143
|
+
export function PmToolbarEntry({ title, label = title ?? '', name, icon = 'more', open = false, onOpenChange, children }: { title?: string, name?: string, label?: string, closeLabel?: string, icon?: IconName, open?: boolean, onOpenChange?(open: boolean): void, children?: ReactNode }) { const where = useToolbarPlacement(); return h(PmPopover, { open, onOpenChange, name, label, placement: where === 'status' ? 'above-start' : 'below-end', trigger: h(PmIconButton, { icon, label, small: true, className: name && `${name}__toggle`, 'aria-expanded': open }) }, children) }
|
package/src/design/design.css
CHANGED
|
@@ -1 +1,9 @@
|
|
|
1
1
|
@import '@lincyaw/design/design.css';
|
|
2
|
+
|
|
3
|
+
.pm-toolbar { display: inline-flex; align-items: center; gap: var(--pm-space-2); }
|
|
4
|
+
.pm-toolbar[data-placement='status'] { gap: var(--pm-space-4); }
|
|
5
|
+
.pm-presenter-panels { position: fixed; right: var(--pm-space-2); bottom: var(--pm-overlay-bar, var(--pm-space-12)); z-index: var(--pm-z-overlay-view); max-width: var(--pm-aside-width); max-height: 50vh; overflow: auto; padding: var(--pm-space-1) var(--pm-space-3); border: var(--pm-size-hairline) solid var(--pm-color-line); border-radius: var(--pm-radius-small); background: var(--pm-color-surface); color: var(--pm-color-ink); font-size: var(--pm-text-sm); box-shadow: var(--pm-shadow-float); }
|
|
6
|
+
.pm-presenter-panels--docked { position: static; max-width: none; max-height: none; margin-top: var(--pm-space-4); box-shadow: none; }
|
|
7
|
+
.pm-presenter-panels summary { cursor: pointer; font-weight: 600; }
|
|
8
|
+
.pm-presenter-panels__badge { display: inline-block; padding: 0 var(--pm-space-1); border-radius: var(--pm-radius-pill); background: var(--pm-color-danger); color: var(--pm-color-surface); text-align: center; }
|
|
9
|
+
.pm-presenter-panels h3 { margin: var(--pm-space-2) 0 var(--pm-space-1); font-size: var(--pm-text-md); }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Children, createElement as h, useState } from 'react'
|
|
2
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
3
|
+
import { PmTabs, PmTabPanel } from './components.ts'
|
|
4
|
+
|
|
5
|
+
export interface ContentLayoutProps { title?: string, children?: ReactNode }
|
|
6
|
+
export interface ColumnsLayoutProps extends ContentLayoutProps { columnWidths?: number[] }
|
|
7
|
+
const heading = (title?: string) => title ? h('h3', { className: 'pm-layout__title' }, title) : null
|
|
8
|
+
|
|
9
|
+
/** Authored column proportions are content geometry; narrow surfaces stack the same children. */
|
|
10
|
+
export function PmColumns({ title, columnWidths, children }: ColumnsLayoutProps) {
|
|
11
|
+
const items = Children.toArray(children)
|
|
12
|
+
const columns = items.map((_, index) => `minmax(0, ${Number.isFinite(columnWidths?.[index]) && columnWidths![index]! > 0 ? columnWidths![index] : 1}fr)`).join(' ')
|
|
13
|
+
const style: CSSProperties & { '--pm-layout-columns': string } = { '--pm-layout-columns': columns || 'minmax(0, 1fr)' }
|
|
14
|
+
return h('div', { className: 'pm-layout-columns', style }, heading(title), items.map((item, index) => h('div', { className: 'pm-layout__item', key: index }, item)))
|
|
15
|
+
}
|
|
16
|
+
export function PmStack({ title, children }: ContentLayoutProps) {
|
|
17
|
+
return h('div', { className: 'pm-layout-stack' }, heading(title), children)
|
|
18
|
+
}
|
|
19
|
+
export interface TabLayoutEntry { id: string, title: string, content: ReactNode, visible?: boolean }
|
|
20
|
+
export function PmTabGroup({ title, label, tabs, staticView = false }: { title?: string, label: string, tabs: TabLayoutEntry[], staticView?: boolean }) {
|
|
21
|
+
const [chosen, setChosen] = useState<string>()
|
|
22
|
+
if (staticView) return h('div', { className: 'pm-layout-stack', 'data-layout-static': 'tabs' }, heading(title), tabs.map(tab => h('section', { key: tab.id, className: 'pm-layout-stack' }, h('h4', { className: 'pm-layout__title' }, tab.title), tab.content)))
|
|
23
|
+
const visible = tabs.filter(tab => tab.visible !== false)
|
|
24
|
+
const active = visible.find(tab => tab.id === chosen)?.id ?? visible[0]?.id ?? ''
|
|
25
|
+
return h('div', { className: 'pm-layout-stack' }, heading(title), h(PmTabs, {
|
|
26
|
+
label: title || label, value: active, tabs: visible.map(tab => ({ value: tab.id, label: tab.title })), onValueChange: setChosen,
|
|
27
|
+
}, tabs.map(tab => h(PmTabPanel, { key: tab.id, value: tab.id, forceMount: true, hidden: tab.id !== active || tab.visible === false }, tab.content))))
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Component, createElement as h } from 'react'
|
|
2
|
+
import type { ReactNode, ErrorInfo } from 'react'
|
|
3
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
4
|
+
import { createStore } from './store.ts'
|
|
5
|
+
import { HOST_MESSAGES } from './messages.ts'
|
|
6
|
+
import { currentLocale } from './locale.ts'
|
|
7
|
+
export interface ComponentError { component: string, message: string }
|
|
8
|
+
export const componentErrorRevision = createStore(0)
|
|
9
|
+
const errors = new Map<object, ComponentError>()
|
|
10
|
+
export function componentErrors(): readonly ComponentError[] { return [...errors.values()] }
|
|
11
|
+
export class PmBoundary extends Component<{ name?: string, resetKey?: unknown, children?: ReactNode }, { error?: ComponentError }> {
|
|
12
|
+
state: { error?: ComponentError } = {}
|
|
13
|
+
static getDerivedStateFromError(error: unknown) { return { error: { component: 'component', message: errorMessage(error) } } }
|
|
14
|
+
componentDidCatch(error: unknown, _info: ErrorInfo) { const problem = { component: this.props.name ?? 'component', message: errorMessage(error) }; errors.set(this, problem); componentErrorRevision.set(n => n + 1); this.setState({ error: problem }); console.error(`[presmium] ${problem.component} failed`, error) }
|
|
15
|
+
componentDidUpdate(before: Readonly<{ name?: string, resetKey?: unknown }>) { if (before.resetKey !== this.props.resetKey && this.state.error) { errors.delete(this); componentErrorRevision.set(n => n + 1); this.setState({ error: undefined }) } }
|
|
16
|
+
componentWillUnmount() { if (errors.delete(this)) componentErrorRevision.set(n => n + 1) }
|
|
17
|
+
render() { const error = this.state.error; return error ? h('div', { className: 'pm-boundary-error', role: 'alert', 'data-pm-error': error.component }, HOST_MESSAGES[currentLocale()].componentError(error.component, error.message)) : this.props.children }
|
|
18
|
+
}
|
package/src/host-key.ts
ADDED