@poveste/app 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/style/main.pcss +2 -3
- package/dist/bundled/App.vue_vue_type_script_setup_true_lang.js +14 -11
- package/dist/bundled/app.css +9 -9
- package/dist/bundled/components/app/AppActions.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/bundled/components/app/AppHeader.vue_vue_type_script_setup_true_lang.js +6 -5
- package/dist/bundled/components/app/TopBarChipItem.js +5 -2
- package/dist/bundled/components/base/BaseCheckbox.vue_vue_type_script_setup_true_lang.js +8 -6
- package/dist/bundled/components/base/BaseListItemLink.js +2 -0
- package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +4 -2
- package/dist/bundled/components/panel/StoryEvents.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StorySourceCode.js +1 -1
- package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/search/SearchItem.js +1 -1
- package/dist/bundled/components/search/SearchItem.vue_vue_type_script_setup_true_lang.js +4 -2
- package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +69 -33
- package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
- package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +2 -1
- package/dist/bundled/components/toolbar/DevOnlyToolbarOpenInEditor.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/bundled/components/toolbar/ToolbarNewTab.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/bundled/components/toolbar/ToolbarTextDirection.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/bundled/components/tree/StoryListItem.js +1 -1
- package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +9 -7
- package/dist/bundled/sandbox.js +4 -1
- package/dist/bundled/util/uncaught.js +14 -0
- package/dist/style.css +1 -1
- package/package.json +9 -8
- package/src/__tests__/uncaught.spec.ts +36 -0
- package/src/app/App.vue +37 -18
- package/src/app/components/app/AppActions.vue +2 -0
- package/src/app/components/app/AppHeader.vue +7 -2
- package/src/app/components/app/TopBarChipItem.vue +3 -0
- package/src/app/components/base/BaseCheckbox.vue +4 -1
- package/src/app/components/base/BaseListItemLink.vue +1 -0
- package/src/app/components/panel/StatePresets.vue +12 -0
- package/src/app/components/panel/StoryEvents.vue +2 -2
- package/src/app/components/panel/StorySourceCode.vue +5 -2
- package/src/app/components/search/SearchItem.vue +2 -0
- package/src/app/components/search/SearchPane.vue +42 -0
- package/src/app/components/story/StoryResponsivePreview.vue +2 -1
- package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +1 -0
- package/src/app/components/toolbar/DevOnlyToolbarOpenInEditor.vue +1 -0
- package/src/app/components/toolbar/ToolbarNewTab.vue +1 -0
- package/src/app/components/toolbar/ToolbarTextDirection.vue +1 -0
- package/src/app/components/tree/StoryListItem.vue +2 -0
- package/src/app/sandbox.ts +6 -1
- package/src/app/style/main.pcss +2 -3
- package/src/app/util/uncaught.ts +19 -0
|
@@ -7,10 +7,7 @@ import { useRoute } from "@poveste/vendors/vue-router";
|
|
|
7
7
|
//#region src/app/components/tree/StoryListItem.vue?vue&type=script&setup=true&lang.ts
|
|
8
8
|
var _hoisted_1 = { class: "bind-tree-margin flex items-center gap-2 pl-4 min-w-0" };
|
|
9
9
|
var _hoisted_2 = { class: "truncate" };
|
|
10
|
-
var _hoisted_3 =
|
|
11
|
-
key: 1,
|
|
12
|
-
class: "opacity-40 text-sm"
|
|
13
|
-
};
|
|
10
|
+
var _hoisted_3 = ["aria-label"];
|
|
14
11
|
var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
|
|
15
12
|
__name: "StoryListItem",
|
|
16
13
|
props: {
|
|
@@ -19,8 +16,8 @@ var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ de
|
|
|
19
16
|
},
|
|
20
17
|
setup(__props) {
|
|
21
18
|
useCssVars((_ctx) => ({
|
|
22
|
-
"
|
|
23
|
-
"
|
|
19
|
+
"v516f2a64": filePadding.value,
|
|
20
|
+
"v2fa7200a": _ctx.story.iconColor
|
|
24
21
|
}));
|
|
25
22
|
const props = __props;
|
|
26
23
|
const errorStore = useStoryErrorStore();
|
|
@@ -60,7 +57,12 @@ var StoryListItem_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ de
|
|
|
60
57
|
"data-testid": "story-error-marker",
|
|
61
58
|
"aria-label": "A variant of this story threw while rendering"
|
|
62
59
|
})) : createCommentVNode("", true),
|
|
63
|
-
!__props.story.docsOnly ? (openBlock(), createElementBlock("span",
|
|
60
|
+
!__props.story.docsOnly ? (openBlock(), createElementBlock("span", {
|
|
61
|
+
key: 1,
|
|
62
|
+
class: "opacity-40 text-sm",
|
|
63
|
+
"aria-label": `${__props.story.variants.length} ${__props.story.variants.length === 1 ? "variant" : "variants"}`,
|
|
64
|
+
role: "note"
|
|
65
|
+
}, toDisplayString(__props.story.variants.length), 9, _hoisted_3)) : createCommentVNode("", true)
|
|
64
66
|
]),
|
|
65
67
|
_: 1
|
|
66
68
|
}, 8, ["to"])], 512);
|
package/dist/bundled/sandbox.js
CHANGED
|
@@ -7,6 +7,7 @@ import { mapFile } from "./util/mapping.js";
|
|
|
7
7
|
import GenericRenderStory_default from "./components/story/GenericRenderStory.js";
|
|
8
8
|
import { createStateBridge } from "./util/state-bridge.js";
|
|
9
9
|
import { toRawDeep } from "./util/state.js";
|
|
10
|
+
import { isBenignResizeObserverEvent } from "./util/uncaught.js";
|
|
10
11
|
import { usePreferredDark } from "@poveste/vendors/vue-use";
|
|
11
12
|
import { computed, createApp, h, onMounted, ref, watch } from "@poveste/vendors/vue";
|
|
12
13
|
import { createPinia } from "@poveste/vendors/pinia";
|
|
@@ -36,7 +37,9 @@ function reportUncaught(error) {
|
|
|
36
37
|
variantId: variantId.value
|
|
37
38
|
});
|
|
38
39
|
}
|
|
39
|
-
window.addEventListener("error", (event) =>
|
|
40
|
+
window.addEventListener("error", (event) => {
|
|
41
|
+
if (!isBenignResizeObserverEvent(event)) reportUncaught(event.error ?? event.message);
|
|
42
|
+
});
|
|
40
43
|
window.addEventListener("unhandledrejection", (event) => reportUncaught(event.reason));
|
|
41
44
|
var prefersDark = usePreferredDark();
|
|
42
45
|
var previewDark = computed(() => resolvePreviewDark(receivedSettings.colorScheme, prefersDark.value, isDark.value));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/app/util/uncaught.ts
|
|
2
|
+
var RESIZE_OBSERVER_LOOP = ["ResizeObserver loop limit exceeded", "ResizeObserver loop completed with undelivered notifications"];
|
|
3
|
+
/**
|
|
4
|
+
* A ResizeObserver loop notification is the browser saying it deferred a resize
|
|
5
|
+
* callback to the next frame, not a component throwing. It reaches the `error`
|
|
6
|
+
* event with a null `error`, so reporting `event.error ?? event.message` blames
|
|
7
|
+
* the story that happens to be rendering for it (#468). Requiring the null keeps
|
|
8
|
+
* a genuine `throw` of the same text reportable.
|
|
9
|
+
*/
|
|
10
|
+
function isBenignResizeObserverEvent({ error, message }) {
|
|
11
|
+
return error == null && typeof message === "string" && RESIZE_OBSERVER_LOOP.some((known) => message.startsWith(known));
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
export { isBenignResizeObserverEvent };
|
package/dist/style.css
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poveste/app",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"description": "Pre-bundled UI for Poveste",
|
|
6
6
|
"author": {
|
|
7
|
-
"name": "Sorin Gitlan"
|
|
7
|
+
"name": "Sorin Gitlan",
|
|
8
|
+
"url": "https://x.com/50rayn"
|
|
8
9
|
},
|
|
9
10
|
"license": "MIT",
|
|
10
11
|
"homepage": "https://poveste.dev",
|
|
@@ -38,11 +39,11 @@
|
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"@fontsource/noto-sans-display": "^5.3.0",
|
|
42
|
+
"@poveste/controls": "^0.12.0",
|
|
43
|
+
"@poveste/shared": "^0.12.0",
|
|
44
|
+
"@poveste/vendors": "^0.12.0",
|
|
41
45
|
"fuse.js": "^7.0.0",
|
|
42
|
-
"shiki": "^3.0.0"
|
|
43
|
-
"@poveste/controls": "^0.10.0",
|
|
44
|
-
"@poveste/shared": "^0.10.0",
|
|
45
|
-
"@poveste/vendors": "^0.10.0"
|
|
46
|
+
"shiki": "^3.0.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@iconify-json/carbon": "^1.2.25",
|
|
@@ -54,13 +55,13 @@
|
|
|
54
55
|
"@tailwindcss/typography": "^0.5.15",
|
|
55
56
|
"@types/fs-extra": "^11.0.4",
|
|
56
57
|
"@types/node": "^22.10.1",
|
|
57
|
-
"@vitejs/plugin-vue": "^
|
|
58
|
+
"@vitejs/plugin-vue": "^6.0.0",
|
|
58
59
|
"chokidar": "^4.0.1",
|
|
59
60
|
"concurrently": "^9.1.0",
|
|
60
61
|
"fs-extra": "^11.2.0",
|
|
61
62
|
"globby": "^14.0.2",
|
|
62
63
|
"pathe": "^1.1.2",
|
|
63
|
-
"postcss": "^8.
|
|
64
|
+
"postcss": "^8.5.23",
|
|
64
65
|
"postcss-cli": "^11.0.1",
|
|
65
66
|
"tailwindcss": "^4.3.3",
|
|
66
67
|
"typescript": "5.6.3",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isBenignResizeObserverEvent } from '../app/util/uncaught'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The shape the sandbox's `error` listener sees. A browser-emitted
|
|
6
|
+
* ResizeObserver notification carries a null `error`; a real throw carries the
|
|
7
|
+
* `Error` that was thrown.
|
|
8
|
+
*/
|
|
9
|
+
const notification = (message: string) => ({ error: null, message })
|
|
10
|
+
const thrown = (message: string) => ({ error: new Error(message), message: `Uncaught Error: ${message}` })
|
|
11
|
+
|
|
12
|
+
describe('a ResizeObserver loop notification', () => {
|
|
13
|
+
// Chrome's exact wording, trailing period included, as captured from a live
|
|
14
|
+
// observer loop.
|
|
15
|
+
it('is recognised as benign with the period Chrome sends', () => {
|
|
16
|
+
expect(isBenignResizeObserverEvent(notification('ResizeObserver loop completed with undelivered notifications.'))).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('is recognised as benign without the period Firefox omits', () => {
|
|
20
|
+
expect(isBenignResizeObserverEvent(notification('ResizeObserver loop completed with undelivered notifications'))).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('is recognised as benign in the older loop limit wording', () => {
|
|
24
|
+
expect(isBenignResizeObserverEvent(notification('ResizeObserver loop limit exceeded'))).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('an error the sandbox exists to report', () => {
|
|
29
|
+
it('is not treated as benign when a component throws the same text', () => {
|
|
30
|
+
expect(isBenignResizeObserverEvent(thrown('ResizeObserver loop completed with undelivered notifications'))).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('is not treated as benign when an unrelated script fails with no error object', () => {
|
|
34
|
+
expect(isBenignResizeObserverEvent(notification('Script error.'))).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
})
|
package/src/app/App.vue
CHANGED
|
@@ -111,6 +111,9 @@ const layoutStore = useLayoutStore()
|
|
|
111
111
|
|
|
112
112
|
<template>
|
|
113
113
|
<div class="poveste-app-root h-full">
|
|
114
|
+
<!-- The book is a page and axe's page-level rules apply to it, unlike the
|
|
115
|
+
sandbox, which renders one isolated component (#310). -->
|
|
116
|
+
|
|
114
117
|
<div
|
|
115
118
|
v-if="storyStore.currentStory"
|
|
116
119
|
class="poveste-app hidden"
|
|
@@ -144,7 +147,9 @@ const layoutStore = useLayoutStore()
|
|
|
144
147
|
:tree="tree"
|
|
145
148
|
:stories="stories"
|
|
146
149
|
/>
|
|
147
|
-
<
|
|
150
|
+
<main class="grow flex flex-col min-h-0">
|
|
151
|
+
<RouterView class="grow" />
|
|
152
|
+
</main>
|
|
148
153
|
</div>
|
|
149
154
|
|
|
150
155
|
<BaseSplitPane
|
|
@@ -158,34 +163,48 @@ const layoutStore = useLayoutStore()
|
|
|
158
163
|
<template #first>
|
|
159
164
|
<div class="flex flex-col h-full bg-gray-100 dark:bg-gray-750 __poveste-pane-shadow-from-right">
|
|
160
165
|
<AppHeader class="flex-none" />
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
<nav
|
|
167
|
+
aria-label="Stories"
|
|
168
|
+
class="flex-1 flex flex-col min-h-0"
|
|
169
|
+
>
|
|
170
|
+
<StoryList
|
|
171
|
+
:tree="tree"
|
|
172
|
+
:stories="stories"
|
|
173
|
+
class="flex-1"
|
|
174
|
+
/>
|
|
175
|
+
</nav>
|
|
166
176
|
</div>
|
|
167
177
|
</template>
|
|
168
178
|
|
|
169
179
|
<template #last>
|
|
170
|
-
<
|
|
180
|
+
<main class="flex flex-col h-full">
|
|
171
181
|
<TopBar
|
|
172
182
|
@layout="isLayoutOpen = true"
|
|
173
183
|
@search="isSearchOpen = true"
|
|
174
184
|
/>
|
|
175
185
|
<RouterView class="flex-1 min-h-0" />
|
|
176
|
-
</
|
|
186
|
+
</main>
|
|
177
187
|
</template>
|
|
178
188
|
</BaseSplitPane>
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
<template v-else>
|
|
190
|
+
<!--
|
|
191
|
+
The story list is hidden in this layout, and `AppHeader` went with it —
|
|
192
|
+
taking the page's only `h1` and its banner. A heading has to exist in
|
|
193
|
+
every layout, and it has to sit inside a landmark, so this branch
|
|
194
|
+
carries its own (#310).
|
|
195
|
+
-->
|
|
196
|
+
<header class="sr-only">
|
|
197
|
+
<h1>{{ povesteConfig.theme?.title ?? 'Poveste' }}</h1>
|
|
198
|
+
</header>
|
|
199
|
+
|
|
200
|
+
<main class="h-full flex flex-col">
|
|
201
|
+
<TopBar
|
|
202
|
+
@layout="isLayoutOpen = true"
|
|
203
|
+
@search="isSearchOpen = true"
|
|
204
|
+
/>
|
|
205
|
+
<RouterView class="flex-1 min-h-0" />
|
|
206
|
+
</main>
|
|
207
|
+
</template>
|
|
189
208
|
|
|
190
209
|
<LayoutModal
|
|
191
210
|
v-if="!isMobile"
|
|
@@ -35,6 +35,7 @@ const themeIcon = computed(() => {
|
|
|
35
35
|
|
|
36
36
|
<TopBarChipItem
|
|
37
37
|
v-tooltip="makeTooltip('Search', ({ isMac }) => isMac ? 'meta+k' : 'ctrl+k')"
|
|
38
|
+
aria-label="Search stories"
|
|
38
39
|
data-testid="search-btn"
|
|
39
40
|
@click="$emit('search')"
|
|
40
41
|
>
|
|
@@ -47,6 +48,7 @@ const themeIcon = computed(() => {
|
|
|
47
48
|
<TopBarChipItem
|
|
48
49
|
v-if="!povesteConfig.theme.hideColorSchemeSwitch"
|
|
49
50
|
v-tooltip="makeTooltip('Toggle dark mode', ({ isMac }) => isMac ? 'meta+shift+d' : 'ctrl+shift+d')"
|
|
51
|
+
aria-label="Toggle dark mode"
|
|
50
52
|
@click="toggleDark()"
|
|
51
53
|
>
|
|
52
54
|
<Icon
|
|
@@ -4,9 +4,14 @@ import AppLogo from './AppLogo.vue'
|
|
|
4
4
|
</script>
|
|
5
5
|
|
|
6
6
|
<template>
|
|
7
|
-
<
|
|
7
|
+
<header
|
|
8
8
|
class="poveste-app-header px-4 h-14 flex items-center gap-2"
|
|
9
9
|
>
|
|
10
|
+
<!-- A page needs exactly one h1, and it has to sit inside a landmark or it
|
|
11
|
+
is content outside any region itself (#310). -->
|
|
12
|
+
<h1 class="sr-only">
|
|
13
|
+
{{ povesteConfig.theme?.title ?? 'Poveste' }}
|
|
14
|
+
</h1>
|
|
10
15
|
<div class="py-3 sm:py-4 flex-1 h-full flex items-center pr-2">
|
|
11
16
|
<a
|
|
12
17
|
:href="povesteConfig.theme?.logoHref"
|
|
@@ -18,5 +23,5 @@ import AppLogo from './AppLogo.vue'
|
|
|
18
23
|
/>
|
|
19
24
|
</a>
|
|
20
25
|
</div>
|
|
21
|
-
</
|
|
26
|
+
</header>
|
|
22
27
|
</template>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
|
+
<!-- Without an href this is not a link, so `aria-label` on it is prohibited and
|
|
3
|
+
it has no accessible role. Chip items are used both ways (#310). -->
|
|
2
4
|
<a
|
|
5
|
+
:role="$attrs.href ? undefined : 'button'"
|
|
3
6
|
class="poveste-top-bar-chip-item flex items-center gap-1 px-2.5 py-1.5 hover:bg-white/50 active:bg-white/70 dark:hover:bg-white/10 dark:active:bg-white/15 hover:text-primary-500 dark:hover:text-primary-400 cursor-pointer text-gray-900 dark:text-gray-100 transition-colors"
|
|
4
7
|
>
|
|
5
8
|
<slot />
|
|
@@ -12,6 +12,9 @@ const emit = defineEmits({
|
|
|
12
12
|
'update:modelValue': (_newValue: boolean) => true,
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
+
// Off until the first toggle, so the tick does not animate itself in on mount.
|
|
16
|
+
const animationEnabled = ref(false)
|
|
17
|
+
|
|
15
18
|
function toggle() {
|
|
16
19
|
emit('update:modelValue', !props.modelValue)
|
|
17
20
|
animationEnabled.value = true
|
|
@@ -23,7 +26,6 @@ const path = ref<SVGPathElement>()
|
|
|
23
26
|
const dasharray = ref(0)
|
|
24
27
|
const progress = computed(() => props.modelValue ? 1 : 0)
|
|
25
28
|
const dashoffset = computed(() => (1 - progress.value) * dasharray.value)
|
|
26
|
-
const animationEnabled = ref(false)
|
|
27
29
|
|
|
28
30
|
watch(path, () => {
|
|
29
31
|
dasharray.value = path.value.getTotalLength?.() ?? 21.21
|
|
@@ -34,6 +36,7 @@ watch(path, () => {
|
|
|
34
36
|
<div
|
|
35
37
|
role="checkbox"
|
|
36
38
|
tabindex="0"
|
|
39
|
+
:aria-checked="modelValue"
|
|
37
40
|
class="poveste-base-checkbox flex items-center gap-2 select-none px-4 py-3 cursor-pointer hover:bg-primary-100 dark:hover:bg-primary-700"
|
|
38
41
|
@click="toggle()"
|
|
39
42
|
@keydown.enter.prevent="toggle()"
|
|
@@ -20,14 +20,24 @@ const omitKeys = ['_hPropDefs']
|
|
|
20
20
|
|
|
21
21
|
const defaultState = toPresetState(props.variant.state, omitKeys)
|
|
22
22
|
|
|
23
|
+
/*
|
|
24
|
+
* `writeDefaults: false`: these are keyed per variant, so writing the default on
|
|
25
|
+
* mount persisted "no presets, default selected" for every variant merely
|
|
26
|
+
* browsed. Nothing prunes them, and a renamed variant orphans its keys forever
|
|
27
|
+
* because no code path can match them again (#326).
|
|
28
|
+
*/
|
|
23
29
|
const selectedOption = useStorage<string>(
|
|
24
30
|
`_poveste-presets/${saveId.value}/selected`,
|
|
25
31
|
DEFAULT_ID,
|
|
32
|
+
undefined,
|
|
33
|
+
{ writeDefaults: false },
|
|
26
34
|
)
|
|
27
35
|
|
|
28
36
|
const presetStates = useStorage<Map<string, { state: Record<string, unknown>, label: string }>>(
|
|
29
37
|
`_poveste-presets/${saveId.value}/states`,
|
|
30
38
|
new Map(),
|
|
39
|
+
undefined,
|
|
40
|
+
{ writeDefaults: false },
|
|
31
41
|
)
|
|
32
42
|
|
|
33
43
|
const presetsOptions = computed(() => {
|
|
@@ -176,6 +186,7 @@ onClickOutside(select, stopEditing)
|
|
|
176
186
|
</div>
|
|
177
187
|
<Icon
|
|
178
188
|
v-tooltip="savedNotif ? 'Saved!' : canEdit ? 'Save to preset' : null"
|
|
189
|
+
data-testid="preset-save"
|
|
179
190
|
:icon="savedNotif ? 'carbon:checkmark' : 'carbon:save'"
|
|
180
191
|
class="cursor-pointer w-4 h-4 hover:text-primary-500 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
|
|
181
192
|
:class="[
|
|
@@ -185,6 +196,7 @@ onClickOutside(select, stopEditing)
|
|
|
185
196
|
/>
|
|
186
197
|
<Icon
|
|
187
198
|
v-tooltip="'Create new preset'"
|
|
199
|
+
data-testid="preset-create"
|
|
188
200
|
icon="carbon:add-alt"
|
|
189
201
|
class="cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
|
|
190
202
|
@click="createPreset()"
|
|
@@ -12,6 +12,8 @@ const hasEvents = computed(() => eventsStore.events.length)
|
|
|
12
12
|
onMounted(resetUnseen)
|
|
13
13
|
watch(() => eventsStore.unseen, resetUnseen)
|
|
14
14
|
|
|
15
|
+
const eventsElement = ref<HTMLDivElement>()
|
|
16
|
+
|
|
15
17
|
async function resetUnseen() {
|
|
16
18
|
if (eventsStore.unseen > 0) {
|
|
17
19
|
eventsStore.unseen = 0
|
|
@@ -19,8 +21,6 @@ async function resetUnseen() {
|
|
|
19
21
|
await nextTick()
|
|
20
22
|
eventsElement.value.scrollTo({ top: eventsElement.value.scrollHeight })
|
|
21
23
|
}
|
|
22
|
-
|
|
23
|
-
const eventsElement = ref<HTMLDivElement>()
|
|
24
24
|
</script>
|
|
25
25
|
|
|
26
26
|
<template>
|
|
@@ -30,6 +30,11 @@ const highlighter = shallowRef<Highlighter>()
|
|
|
30
30
|
const dynamicSourceCode = ref('')
|
|
31
31
|
const error = ref<string>(null)
|
|
32
32
|
|
|
33
|
+
// Above the watch that assigns it: the callback is async but runs immediately,
|
|
34
|
+
// and a story whose `slots()` throws reaches the auto-switch below before its
|
|
35
|
+
// first `await` — with the declaration underneath, that path hits the TDZ.
|
|
36
|
+
const displayedSource = ref<'dynamic' | 'static'>('dynamic')
|
|
37
|
+
|
|
33
38
|
watch(() => [props.variant, generateSourceCodeFn.value], async () => {
|
|
34
39
|
if (!generateSourceCodeFn.value) return
|
|
35
40
|
error.value = null
|
|
@@ -75,8 +80,6 @@ watch(() => [props.story, props.story?.file?.source], async () => {
|
|
|
75
80
|
immediate: true,
|
|
76
81
|
})
|
|
77
82
|
|
|
78
|
-
const displayedSource = ref<'dynamic' | 'static'>('dynamic')
|
|
79
|
-
|
|
80
83
|
const displayedSourceCode = computed(() => {
|
|
81
84
|
if (displayedSource.value === 'dynamic') {
|
|
82
85
|
return dynamicSourceCode.value
|
|
@@ -279,6 +279,23 @@ const {
|
|
|
279
279
|
selectPrevious,
|
|
280
280
|
} = useSelection(results)
|
|
281
281
|
|
|
282
|
+
// Announced to a screen reader on every change: the count when a search returns
|
|
283
|
+
// something, and which result is focused as the arrows move. Both are silent
|
|
284
|
+
// otherwise — arrowing only changes styling.
|
|
285
|
+
const resultsAnnouncement = computed(() => {
|
|
286
|
+
if (!rateLimitedSearch.value) {
|
|
287
|
+
return ''
|
|
288
|
+
}
|
|
289
|
+
if (!results.value.length) {
|
|
290
|
+
return 'No results'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const count = `${results.value.length} result${results.value.length === 1 ? '' : 's'}`
|
|
294
|
+
const current = results.value[selectedIndex.value]
|
|
295
|
+
|
|
296
|
+
return current ? `${count}. ${current.title}, ${selectedIndex.value + 1} of ${results.value.length}` : count
|
|
297
|
+
})
|
|
298
|
+
|
|
282
299
|
// Activation
|
|
283
300
|
|
|
284
301
|
const router = useRouter()
|
|
@@ -321,6 +338,13 @@ onKeyboardShortcut(['enter'], () => {
|
|
|
321
338
|
<input
|
|
322
339
|
ref="input"
|
|
323
340
|
v-model="searchInputText"
|
|
341
|
+
type="search"
|
|
342
|
+
role="combobox"
|
|
343
|
+
aria-label="Search for stories and variants"
|
|
344
|
+
aria-autocomplete="list"
|
|
345
|
+
:aria-expanded="results.length > 0"
|
|
346
|
+
:aria-controls="results.length ? 'poveste-search-results' : undefined"
|
|
347
|
+
:aria-activedescendant="results.length ? `poveste-search-option-${selectedIndex}` : undefined"
|
|
324
348
|
placeholder="Search for stories, variants..."
|
|
325
349
|
class="bg-transparent w-full flex-1 pl-0 pr-6 py-4 outline-none"
|
|
326
350
|
@keydown.down.prevent="selectNext()"
|
|
@@ -338,14 +362,32 @@ onKeyboardShortcut(['enter'], () => {
|
|
|
338
362
|
|
|
339
363
|
<div
|
|
340
364
|
v-else-if="results.length"
|
|
365
|
+
id="poveste-search-results"
|
|
366
|
+
role="listbox"
|
|
367
|
+
aria-label="Search results"
|
|
341
368
|
class="max-h-[400px] overflow-y-auto rounded-b-lg"
|
|
342
369
|
>
|
|
343
370
|
<SearchItem
|
|
344
371
|
v-for="(result, index) of results"
|
|
372
|
+
:id="`poveste-search-option-${index}`"
|
|
345
373
|
:key="result.id"
|
|
346
374
|
:result="result"
|
|
347
375
|
:selected="index === selectedIndex"
|
|
348
376
|
@close="close()"
|
|
349
377
|
/>
|
|
350
378
|
</div>
|
|
379
|
+
|
|
380
|
+
<!--
|
|
381
|
+
Arrowing through results was a purely visual state change: `selectedIndex`
|
|
382
|
+
drove styling and nothing else, so a screen reader announced nothing on
|
|
383
|
+
typing, nothing on arrowing, and nothing on selecting (#311). The count is
|
|
384
|
+
what tells you the search did anything at all.
|
|
385
|
+
-->
|
|
386
|
+
<div
|
|
387
|
+
class="sr-only"
|
|
388
|
+
aria-live="polite"
|
|
389
|
+
role="status"
|
|
390
|
+
>
|
|
391
|
+
{{ resultsAnnouncement }}
|
|
392
|
+
</div>
|
|
351
393
|
</template>
|
|
@@ -41,6 +41,8 @@ function addWindowListener(event: string, listener: (event: any) => unknown) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const previewWrapper = ref<HTMLDivElement>()
|
|
45
|
+
|
|
44
46
|
function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, max: number, axis: 'x' | 'y') {
|
|
45
47
|
function onMouseDown(event: MouseEvent) {
|
|
46
48
|
event.preventDefault()
|
|
@@ -109,7 +111,6 @@ const responsiveHeight = computed({
|
|
|
109
111
|
const horizontalDragger = ref<HTMLDivElement>()
|
|
110
112
|
const verticalDragger = ref<HTMLDivElement>()
|
|
111
113
|
const cornerDragger = ref<HTMLDivElement>()
|
|
112
|
-
const previewWrapper = ref<HTMLDivElement>()
|
|
113
114
|
|
|
114
115
|
useDragger(horizontalDragger, responsiveWidth, 32, 20000, 'x')
|
|
115
116
|
useDragger(verticalDragger, responsiveHeight, 32, 20000, 'y')
|
|
@@ -9,6 +9,7 @@ const settings = usePreviewSettingsStore().currentSettings
|
|
|
9
9
|
<template>
|
|
10
10
|
<TopBarChipItem
|
|
11
11
|
v-tooltip="`Switch to text direction ${settings.textDirection === 'ltr' ? 'Right to Left' : 'Left to Right'}`"
|
|
12
|
+
aria-label="Toggle text direction"
|
|
12
13
|
class="poveste-toolbar-text-direction"
|
|
13
14
|
@click="settings.textDirection = settings.textDirection === 'ltr' ? 'rtl' : 'ltr'"
|
|
14
15
|
>
|
package/src/app/sandbox.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { mapFile } from './util/mapping'
|
|
|
14
14
|
import { applyPreviewSettings, loadStoredPreviewSettings, receivedSettings } from './util/preview-settings.js'
|
|
15
15
|
import { createStateBridge } from './util/state-bridge.js'
|
|
16
16
|
import { toRawDeep } from './util/state.js'
|
|
17
|
+
import { isBenignResizeObserverEvent } from './util/uncaught.js'
|
|
17
18
|
|
|
18
19
|
const query = parseQuery(window.location.search)
|
|
19
20
|
|
|
@@ -65,7 +66,11 @@ if (window.parent && window.parent !== window) {
|
|
|
65
66
|
function reportUncaught(error: unknown) {
|
|
66
67
|
reportStoryError(error, { storyId: storyId.value, variantId: variantId.value })
|
|
67
68
|
}
|
|
68
|
-
window.addEventListener('error', event =>
|
|
69
|
+
window.addEventListener('error', (event) => {
|
|
70
|
+
if (!isBenignResizeObserverEvent(event)) {
|
|
71
|
+
reportUncaught(event.error ?? event.message)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
69
74
|
window.addEventListener('unhandledrejection', event => reportUncaught(event.reason))
|
|
70
75
|
|
|
71
76
|
// The story preview has its own color scheme, independent from the app chrome.
|
package/src/app/style/main.pcss
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
its utility classes live in a package v4 never scans. */
|
|
26
26
|
@source "../../../../poveste/src/node/markdown.ts";
|
|
27
27
|
|
|
28
|
-
/* TODO(#36): port the v3 typography theme() customization to v4 CSS. */
|
|
29
28
|
@plugin '@tailwindcss/typography';
|
|
30
29
|
|
|
31
30
|
/* Chrome dark mode. Deliberately a distinct class from the story-side
|
|
@@ -181,7 +180,7 @@
|
|
|
181
180
|
/*
|
|
182
181
|
* Typography (@tailwindcss/typography) customization — ported from the v3
|
|
183
182
|
* `theme.extend.typography` JS config to CSS. Unlayered so it overrides the
|
|
184
|
-
* plugin's layered defaults.
|
|
183
|
+
* plugin's layered defaults.
|
|
185
184
|
*/
|
|
186
185
|
.prose {
|
|
187
186
|
--tw-prose-invert-quote-borders: var(--color-gray-800);
|
|
@@ -207,7 +206,7 @@
|
|
|
207
206
|
}
|
|
208
207
|
}
|
|
209
208
|
|
|
210
|
-
&:is(h1, h2, h3, h4
|
|
209
|
+
&:is(h1, h2, h3, h4) {
|
|
211
210
|
margin-bottom: 0.75rem;
|
|
212
211
|
|
|
213
212
|
&:not(:first-child) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Chrome ends the sentence with a period and Firefox does not, so these are
|
|
2
|
+
// prefixes rather than whole messages.
|
|
3
|
+
const RESIZE_OBSERVER_LOOP = [
|
|
4
|
+
'ResizeObserver loop limit exceeded',
|
|
5
|
+
'ResizeObserver loop completed with undelivered notifications',
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A ResizeObserver loop notification is the browser saying it deferred a resize
|
|
10
|
+
* callback to the next frame, not a component throwing. It reaches the `error`
|
|
11
|
+
* event with a null `error`, so reporting `event.error ?? event.message` blames
|
|
12
|
+
* the story that happens to be rendering for it (#468). Requiring the null keeps
|
|
13
|
+
* a genuine `throw` of the same text reportable.
|
|
14
|
+
*/
|
|
15
|
+
export function isBenignResizeObserverEvent({ error, message }: { error?: unknown, message?: unknown }): boolean {
|
|
16
|
+
return error == null
|
|
17
|
+
&& typeof message === 'string'
|
|
18
|
+
&& RESIZE_OBSERVER_LOOP.some(known => message.startsWith(known))
|
|
19
|
+
}
|