@poveste/app 0.14.0 → 0.15.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/bundled/app.css +26 -26
- package/dist/bundled/components/app/TopBarChipItem.js +1 -1
- package/dist/bundled/components/base/BaseEmpty.js +2 -2
- package/dist/bundled/components/base/BaseOverflowMenu.vue_vue_type_script_setup_true_lang.js +14 -14
- package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +158 -26
- package/dist/bundled/components/base/BaseSplitPane.js +1 -1
- package/dist/bundled/components/command/PromptSelect.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/ControlsComponentPropItem.vue_vue_type_script_setup_true_lang.js +20 -14
- package/dist/bundled/components/panel/PaneTabs.vue_vue_type_script_setup_true_lang.js +6 -6
- package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +78 -49
- package/dist/bundled/components/panel/StorySidePanel.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 +5 -4
- package/dist/bundled/components/search/SearchItemContent.vue_vue_type_script_setup_true_lang.js +2 -1
- package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +1 -2
- package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
- package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +8 -4
- package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
- package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +9 -6
- package/dist/bundled/components/story/StoryView.vue_vue_type_script_setup_true_lang.js +4 -1
- package/dist/bundled/components/toolbar/ToolbarTitle.js +1 -1
- package/dist/bundled/components/toolbar/ToolbarTitle.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/tree/StoryList.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/bundled/components/tree/StoryListFolder.js +1 -1
- package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/bundled/components/tree/StoryListItem.js +1 -1
- package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/bundled/sandbox.js +3 -2
- package/dist/bundled/stores/story.js +2 -2
- package/dist/bundled/util/commands.js +1 -1
- package/dist/bundled/util/dark.js +1 -1
- package/dist/bundled/util/events.js +1 -1
- package/dist/bundled/util/grid-slots.js +1 -1
- package/dist/bundled/util/keyboard.js +8 -14
- package/dist/bundled/util/mapping.js +2 -2
- package/dist/bundled/util/render-tracker.js +33 -0
- package/dist/bundled/util/variant.js +12 -2
- package/dist/style.css +23 -1
- package/package.json +13 -17
- package/src/__tests__/render-tracker.spec.ts +38 -0
- package/src/app/App.vue +1 -1
- package/src/app/components/app/TopBarChipItem.vue +1 -1
- package/src/app/components/base/BaseEmpty.vue +1 -1
- package/src/app/components/base/BaseListItemLink.vue +4 -2
- package/src/app/components/base/BaseOverflowMenu.vue +17 -16
- package/src/app/components/base/BaseSelect.vue +171 -15
- package/src/app/components/base/BaseSplitPane.vue +2 -2
- package/src/app/components/command/PromptSelect.vue +1 -1
- package/src/app/components/panel/ControlsComponentPropItem.vue +26 -15
- package/src/app/components/panel/PaneTabs.vue +6 -6
- package/src/app/components/panel/StatePresets.vue +100 -64
- package/src/app/components/panel/StorySidePanel.vue +1 -1
- package/src/app/components/panel/StorySourceCode.vue +6 -5
- package/src/app/components/search/SearchItemContent.vue +2 -1
- package/src/app/components/search/SearchPane.vue +2 -3
- package/src/app/components/story/StoryResponsivePreview.vue +6 -2
- package/src/app/components/story/StoryVariantGridItem.vue +1 -1
- package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +14 -8
- package/src/app/components/story/StoryView.vue +3 -1
- package/src/app/components/toolbar/ToolbarTitle.vue +2 -2
- package/src/app/components/tree/StoryGroup.vue +2 -2
- package/src/app/components/tree/StoryList.vue +2 -2
- package/src/app/components/tree/StoryListFolder.vue +2 -2
- package/src/app/components/tree/StoryListItem.vue +1 -1
- package/src/app/sandbox.ts +8 -2
- package/src/app/stores/story.ts +2 -2
- package/src/app/types.ts +4 -4
- package/src/app/util/commands.ts +1 -1
- package/src/app/util/dark.ts +1 -1
- package/src/app/util/env.ts +0 -1
- package/src/app/util/events.ts +6 -5
- package/src/app/util/grid-slots.ts +1 -1
- package/src/app/util/keyboard.ts +7 -11
- package/src/app/util/mapping.ts +2 -2
- package/src/app/util/render-tracker.ts +31 -0
- package/src/app/util/state.ts +3 -3
- package/src/app/util/variant.ts +12 -2
|
@@ -24,30 +24,24 @@ var pressedKeys = /* @__PURE__ */ new Set();
|
|
|
24
24
|
var trackedWindows = shallowRef([]);
|
|
25
25
|
function bindTracking(target) {
|
|
26
26
|
const onKeydown = (event) => {
|
|
27
|
-
for (const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
mod.pressed = true;
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
27
|
+
for (const mod of Object.values(modifiers)) if (mod.key === event.key) {
|
|
28
|
+
mod.pressed = true;
|
|
29
|
+
return;
|
|
33
30
|
}
|
|
34
31
|
pressedKeys.add(event.key.toLocaleLowerCase());
|
|
35
32
|
};
|
|
36
33
|
const onKeyup = (event) => {
|
|
37
34
|
requestAnimationFrame(() => {
|
|
38
35
|
pressedKeys.clear();
|
|
39
|
-
for (const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
mod.pressed = false;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
36
|
+
for (const mod of Object.values(modifiers)) if (mod.key === event.key) {
|
|
37
|
+
mod.pressed = false;
|
|
38
|
+
break;
|
|
45
39
|
}
|
|
46
40
|
});
|
|
47
41
|
};
|
|
48
42
|
const onBlur = () => {
|
|
49
43
|
pressedKeys.clear();
|
|
50
|
-
for (const
|
|
44
|
+
for (const mod of Object.values(modifiers)) mod.pressed = false;
|
|
51
45
|
};
|
|
52
46
|
target.addEventListener("keydown", onKeydown);
|
|
53
47
|
target.addEventListener("keyup", onKeyup);
|
|
@@ -89,7 +83,7 @@ function isMatchingCombination(combination) {
|
|
|
89
83
|
const splitted = combination.split("+").map((key) => key.trim());
|
|
90
84
|
const targetKey = splitted.pop();
|
|
91
85
|
if (!targetKey) return false;
|
|
92
|
-
for (const mod
|
|
86
|
+
for (const [name, mod] of Object.entries(modifiers)) if (splitted.includes(name) !== mod.pressed) return false;
|
|
93
87
|
return pressedKeys.has(targetKey);
|
|
94
88
|
}
|
|
95
89
|
function formatKey(key) {
|
|
@@ -20,7 +20,7 @@ function mapFile(file, existingFile) {
|
|
|
20
20
|
file: markRaw(result),
|
|
21
21
|
variants: file.story.variants.map((v) => mapVariant(v, existingFile.story.variants.find((item) => item.id === v.id)))
|
|
22
22
|
};
|
|
23
|
-
else if (key !== "component") result
|
|
23
|
+
else if (key !== "component") Reflect.set(result, key, Reflect.get(file, key));
|
|
24
24
|
} else result = {
|
|
25
25
|
...file,
|
|
26
26
|
component: markRaw(file.component),
|
|
@@ -38,7 +38,7 @@ function mapVariant(variant, existingVariant) {
|
|
|
38
38
|
let result;
|
|
39
39
|
if (existingVariant) {
|
|
40
40
|
result = existingVariant;
|
|
41
|
-
for (const key in variant) if (!copiedFromExistingVariant.includes(key)) result
|
|
41
|
+
for (const key in variant) if (!copiedFromExistingVariant.includes(key)) Reflect.set(result, key, Reflect.get(variant, key));
|
|
42
42
|
} else result = {
|
|
43
43
|
...variant,
|
|
44
44
|
state: reactive({
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/app/util/render-tracker.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether the render now under way inside a realm reported a throw.
|
|
4
|
+
*
|
|
5
|
+
* The host cannot see inside the realm, so "did this render succeed" has to be
|
|
6
|
+
* read off the two named messages it does see. A throw reaches
|
|
7
|
+
* `app.config.errorHandler` during mount and `SANDBOX_READY` is emitted after
|
|
8
|
+
* `await host.mount()` returns, so the order is always error-then-ready — which
|
|
9
|
+
* makes a ready with nothing recorded since the render began a clean render.
|
|
10
|
+
*
|
|
11
|
+
* Clearing on the story and variant ids changing instead cleared the pair being
|
|
12
|
+
* navigated *to*, so arriving at a broken story wiped its marker, and the fixed
|
|
13
|
+
* story the comment was about was never reached at all: a file edited in place
|
|
14
|
+
* keeps its ids, so that watcher did not fire on the one path it named (#597).
|
|
15
|
+
*/
|
|
16
|
+
function createRenderTracker() {
|
|
17
|
+
let threw = false;
|
|
18
|
+
return {
|
|
19
|
+
/** A new render is starting: what the previous one reported no longer holds. */
|
|
20
|
+
begin() {
|
|
21
|
+
threw = false;
|
|
22
|
+
},
|
|
23
|
+
threw() {
|
|
24
|
+
threw = true;
|
|
25
|
+
},
|
|
26
|
+
/** True when the render that has just reported ready said nothing before it. */
|
|
27
|
+
wasClean() {
|
|
28
|
+
return !threw;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
export { createRenderTracker };
|
|
@@ -8,14 +8,24 @@ import { useRoute } from "@poveste/vendors/vue-router";
|
|
|
8
8
|
* has to tell "a variant is on its way" from "this story is waiting for the
|
|
9
9
|
* reader to pick one" needs the same answer it will give — hence one predicate
|
|
10
10
|
* rather than two copies of the condition (#328).
|
|
11
|
+
*
|
|
12
|
+
* Any story with variants, deliberately. It used to require a remembered choice
|
|
13
|
+
* or exactly one variant, which made `StoryView`'s `variants[0]` fallback
|
|
14
|
+
* unreachable: a story with several variants and no history satisfied neither
|
|
15
|
+
* clause, so the first visit of a session showed an empty panel and every visit
|
|
16
|
+
* after it worked, because the first one recorded the history (#724).
|
|
17
|
+
*
|
|
18
|
+
* No layout clause. Grid stories already auto-selected on revisit through the
|
|
19
|
+
* remembered choice, so excluding them here would auto-open them on the second
|
|
20
|
+
* visit and never the first — this defect, made permanent for one layout.
|
|
11
21
|
*/
|
|
12
22
|
function autoSelectsVariant(story) {
|
|
13
|
-
return !!story &&
|
|
23
|
+
return !!story && story.variants.length > 0;
|
|
14
24
|
}
|
|
15
25
|
function useCurrentVariantRoute(variant) {
|
|
16
26
|
const route = useRoute();
|
|
17
27
|
return {
|
|
18
|
-
isActive: computed(() => route.query
|
|
28
|
+
isActive: computed(() => route.query["variantId"] === variant.value.id),
|
|
19
29
|
targetRoute: computed(() => ({
|
|
20
30
|
...route,
|
|
21
31
|
query: {
|
package/dist/style.css
CHANGED
|
@@ -274,7 +274,7 @@
|
|
|
274
274
|
inherits: false;
|
|
275
275
|
initial-value: solid;
|
|
276
276
|
}
|
|
277
|
-
.__poveste-json-code[data-v-
|
|
277
|
+
.__poveste-json-code[data-v-68225145] .cm-editor {
|
|
278
278
|
min-width: 280px;
|
|
279
279
|
height: 100%;
|
|
280
280
|
}
|
|
@@ -1012,6 +1012,9 @@
|
|
|
1012
1012
|
.mb-8 {
|
|
1013
1013
|
margin-bottom: calc(var(--spacing) * 8);
|
|
1014
1014
|
}
|
|
1015
|
+
.mb-\[10px\] {
|
|
1016
|
+
margin-bottom: 10px;
|
|
1017
|
+
}
|
|
1015
1018
|
.ml-4 {
|
|
1016
1019
|
margin-left: calc(var(--spacing) * 4);
|
|
1017
1020
|
}
|
|
@@ -1670,6 +1673,9 @@
|
|
|
1670
1673
|
.stroke-2 {
|
|
1671
1674
|
stroke-width: 2;
|
|
1672
1675
|
}
|
|
1676
|
+
.p-0 {
|
|
1677
|
+
padding: 0px;
|
|
1678
|
+
}
|
|
1673
1679
|
.p-1 {
|
|
1674
1680
|
padding: var(--spacing);
|
|
1675
1681
|
}
|
|
@@ -1927,6 +1933,16 @@
|
|
|
1927
1933
|
outline-style: var(--tw-outline-style);
|
|
1928
1934
|
outline-width: 1px;
|
|
1929
1935
|
}
|
|
1936
|
+
.outline-2 {
|
|
1937
|
+
outline-style: var(--tw-outline-style);
|
|
1938
|
+
outline-width: 2px;
|
|
1939
|
+
}
|
|
1940
|
+
.-outline-offset-2 {
|
|
1941
|
+
outline-offset: calc(2px * -1);
|
|
1942
|
+
}
|
|
1943
|
+
.outline-primary-500 {
|
|
1944
|
+
outline-color: var(--color-primary-500);
|
|
1945
|
+
}
|
|
1930
1946
|
.blur {
|
|
1931
1947
|
--tw-blur: blur(8px);
|
|
1932
1948
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
|
@@ -2022,6 +2038,9 @@
|
|
|
2022
2038
|
.group-focus-within\:visible:is(:where(.group):focus-within *) {
|
|
2023
2039
|
visibility: visible;
|
|
2024
2040
|
}
|
|
2041
|
+
.group-focus-within\:flex:is(:where(.group):focus-within *) {
|
|
2042
|
+
display: flex;
|
|
2043
|
+
}
|
|
2025
2044
|
@media (hover: hover) {
|
|
2026
2045
|
.group-hover\:flex:is(:where(.group):hover *) {
|
|
2027
2046
|
display: flex;
|
|
@@ -2166,6 +2185,9 @@
|
|
|
2166
2185
|
.focus-visible\:border-primary-500:focus-visible {
|
|
2167
2186
|
border-color: var(--color-primary-500);
|
|
2168
2187
|
}
|
|
2188
|
+
.focus-visible\:opacity-100:focus-visible {
|
|
2189
|
+
opacity: 100%;
|
|
2190
|
+
}
|
|
2169
2191
|
.active\:bg-gray-600\/50:active {
|
|
2170
2192
|
background-color: color-mix(in srgb, rgb(var(--_poveste-color-gray-600)) 50%, transparent);
|
|
2171
2193
|
@supports (color: color-mix(in lab, red, red)) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poveste/app",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"description": "Pre-bundled UI for Poveste",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Sorin Gitlan",
|
|
@@ -23,13 +23,9 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"exports": {
|
|
26
|
-
".":
|
|
27
|
-
"types": "./dist/index.d.ts",
|
|
28
|
-
"default": "./dist/bundle-index.js"
|
|
29
|
-
},
|
|
26
|
+
".": "./dist/bundle-index.js",
|
|
30
27
|
"./*": "./*"
|
|
31
28
|
},
|
|
32
|
-
"types": "./src/index.ts",
|
|
33
29
|
"files": [
|
|
34
30
|
"dist",
|
|
35
31
|
"src"
|
|
@@ -38,11 +34,11 @@
|
|
|
38
34
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
|
39
35
|
},
|
|
40
36
|
"dependencies": {
|
|
41
|
-
"@poveste/controls": "^0.
|
|
42
|
-
"@poveste/shared": "^0.
|
|
43
|
-
"@poveste/vendors": "^0.
|
|
37
|
+
"@poveste/controls": "^0.15.0",
|
|
38
|
+
"@poveste/shared": "^0.15.0",
|
|
39
|
+
"@poveste/vendors": "^0.15.0",
|
|
44
40
|
"fuse.js": "^7.0.0",
|
|
45
|
-
"shiki": "^
|
|
41
|
+
"shiki": "^4.4.3"
|
|
46
42
|
},
|
|
47
43
|
"devDependencies": {
|
|
48
44
|
"@fontsource/noto-sans-display": "^5.3.0",
|
|
@@ -56,19 +52,19 @@
|
|
|
56
52
|
"@types/fs-extra": "^11.0.4",
|
|
57
53
|
"@types/node": "^22.10.1",
|
|
58
54
|
"@vitejs/plugin-vue": "^6.0.0",
|
|
59
|
-
"chokidar": "^
|
|
60
|
-
"concurrently": "^
|
|
55
|
+
"chokidar": "^5.0.0",
|
|
56
|
+
"concurrently": "^10.0.5",
|
|
61
57
|
"fs-extra": "^11.2.0",
|
|
62
|
-
"globby": "^
|
|
63
|
-
"pathe": "^
|
|
58
|
+
"globby": "^16.2.4",
|
|
59
|
+
"pathe": "^2.0.3",
|
|
64
60
|
"postcss": "^8.5.23",
|
|
65
|
-
"postcss-cli": "^
|
|
61
|
+
"postcss-cli": "^12.0.0",
|
|
66
62
|
"tailwindcss": "^4.3.3",
|
|
67
|
-
"typescript": "
|
|
63
|
+
"typescript": "6.0.3",
|
|
68
64
|
"vite": "^8.2.0",
|
|
69
65
|
"vitest": "^5.0.0",
|
|
70
66
|
"vue": "^3.5.26",
|
|
71
|
-
"vue-tsc": "^
|
|
67
|
+
"vue-tsc": "^3.3.11"
|
|
72
68
|
},
|
|
73
69
|
"scripts": {
|
|
74
70
|
"build": "pnpm run icons && rimraf dist && vite build && pnpm run build:css && node ./scripts/copy.mjs",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createRenderTracker } from '../app/util/render-tracker.js'
|
|
3
|
+
|
|
4
|
+
describe('createRenderTracker', () => {
|
|
5
|
+
it('calls a render clean when nothing was reported during it', () => {
|
|
6
|
+
const render = createRenderTracker()
|
|
7
|
+
|
|
8
|
+
render.begin()
|
|
9
|
+
|
|
10
|
+
expect(render.wasClean()).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('does not call a render clean when a throw was reported during it', () => {
|
|
14
|
+
const render = createRenderTracker()
|
|
15
|
+
render.begin()
|
|
16
|
+
|
|
17
|
+
render.threw()
|
|
18
|
+
|
|
19
|
+
expect(render.wasClean()).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// The half #597 is about. Without it a story that threw once stays marked for
|
|
23
|
+
// the life of the tab: every later render inherits the first one's verdict,
|
|
24
|
+
// so the marker the old comment promised to lift on a fix never lifts.
|
|
25
|
+
it('forgets the previous render when a new one begins', () => {
|
|
26
|
+
const render = createRenderTracker()
|
|
27
|
+
render.begin()
|
|
28
|
+
render.threw()
|
|
29
|
+
|
|
30
|
+
render.begin()
|
|
31
|
+
|
|
32
|
+
expect(render.wasClean()).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('treats a render that has reported nothing yet as clean', () => {
|
|
36
|
+
expect(createRenderTracker().wasClean()).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
})
|
package/src/app/App.vue
CHANGED
|
@@ -29,7 +29,7 @@ import { onKeyboardShortcut } from './util/keyboard'
|
|
|
29
29
|
import { mapFile } from './util/mapping'
|
|
30
30
|
import { isMobile } from './util/responsive'
|
|
31
31
|
|
|
32
|
-
const files = ref<StoryFile[]>(rawFiles.map(file => mapFile(file)))
|
|
32
|
+
const files = ref<StoryFile[]>(rawFiles.map((file: StoryFile) => mapFile(file)))
|
|
33
33
|
const tree = ref<Tree>(rawTree)
|
|
34
34
|
|
|
35
35
|
onUpdate((newFiles: StoryFile[], newTree: Tree) => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<!-- Without an href this is not a link, so `aria-label` on it is prohibited and
|
|
3
3
|
it has no accessible role. Chip items are used both ways (#310). -->
|
|
4
4
|
<a
|
|
5
|
-
:role="$attrs
|
|
5
|
+
:role="$attrs['href'] ? undefined : 'button'"
|
|
6
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"
|
|
7
7
|
>
|
|
8
8
|
<slot />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="poveste-base-empty base-empty flex flex-col items-center justify-center space-y-4 py-12 h-full text-center text-gray-400
|
|
2
|
+
<div class="poveste-base-empty base-empty flex flex-col items-center justify-center space-y-4 py-12 h-full text-center text-gray-500 dark:text-gray-400 text-lg">
|
|
3
3
|
<slot />
|
|
4
4
|
</div>
|
|
5
5
|
</template>
|
|
@@ -26,9 +26,11 @@ export default defineComponent({
|
|
|
26
26
|
},
|
|
27
27
|
|
|
28
28
|
setup(props, { emit }) {
|
|
29
|
-
function handleNavigate(event, navigate: (event) => unknown) {
|
|
29
|
+
function handleNavigate(event: MouseEvent | KeyboardEvent, navigate: (event?: MouseEvent) => unknown) {
|
|
30
30
|
emit('navigate')
|
|
31
|
-
|
|
31
|
+
// vue-router types this as a click, but its guard reads only the modifier
|
|
32
|
+
// keys and `defaultPrevented`, which a key press carries too.
|
|
33
|
+
navigate(event as MouseEvent)
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
+
import type { SetupContext, VNode } from 'vue'
|
|
2
3
|
import { Icon } from '@iconify/vue'
|
|
3
4
|
import { useResizeObserver } from '@vueuse/core'
|
|
4
5
|
import { computed, h, onBeforeUnmount, reactive, ref } from 'vue'
|
|
@@ -11,9 +12,9 @@ const el = ref<HTMLDivElement>()
|
|
|
11
12
|
|
|
12
13
|
const availableWidth = ref(0)
|
|
13
14
|
|
|
14
|
-
useResizeObserver(el, (
|
|
15
|
-
|
|
16
|
-
availableWidth.value =
|
|
15
|
+
useResizeObserver(el, ([entry]) => {
|
|
16
|
+
if (!entry) return
|
|
17
|
+
availableWidth.value = entry.contentRect.width - overflowButtonWidth
|
|
17
18
|
})
|
|
18
19
|
|
|
19
20
|
// Children
|
|
@@ -28,8 +29,8 @@ const children = ref(new Map<HTMLElement, ChildState>())
|
|
|
28
29
|
const visibleChildrenCount = computed(() => {
|
|
29
30
|
let width = 0
|
|
30
31
|
const c = [...children.value.values()].sort((a, b) => a.index - b.index)
|
|
31
|
-
for (
|
|
32
|
-
width +=
|
|
32
|
+
for (const [i, child] of c.entries()) {
|
|
33
|
+
width += child.width
|
|
33
34
|
if (width > availableWidth.value) {
|
|
34
35
|
return i
|
|
35
36
|
}
|
|
@@ -43,18 +44,18 @@ const visibleChildrenCount = computed(() => {
|
|
|
43
44
|
const ChildWrapper = {
|
|
44
45
|
name: 'ChildWrapper',
|
|
45
46
|
props: ['index'],
|
|
46
|
-
setup(props, { slots }) {
|
|
47
|
+
setup(props: { index: number }, { slots }: SetupContext) {
|
|
47
48
|
const el = ref<HTMLDivElement>()
|
|
48
49
|
|
|
49
50
|
const state = reactive({ width: 0, index: props.index })
|
|
50
51
|
|
|
51
|
-
useResizeObserver(el, (
|
|
52
|
+
useResizeObserver(el, ([entry]) => {
|
|
52
53
|
// The observer only fires for an element it is observing, so this is
|
|
53
54
|
// never null here — but `children` is keyed by the element, and an
|
|
54
55
|
// undefined key would silently make a second entry for the same child.
|
|
55
56
|
const element = el.value
|
|
56
|
-
if (!element) return
|
|
57
|
-
const width =
|
|
57
|
+
if (!element || !entry) return
|
|
58
|
+
const width = entry.contentRect.width
|
|
58
59
|
if (!children.value.has(element)) {
|
|
59
60
|
children.value.set(element, state)
|
|
60
61
|
}
|
|
@@ -68,24 +69,24 @@ const ChildWrapper = {
|
|
|
68
69
|
|
|
69
70
|
const visible = computed(() => visibleChildrenCount.value > state.index)
|
|
70
71
|
|
|
71
|
-
return () => h('div', { ref: el, style: { visibility: visible.value ? 'visible' : 'hidden' } }, slots
|
|
72
|
+
return () => h('div', { ref: el, style: { visibility: visible.value ? 'visible' : 'hidden' } }, slots['default']?.())
|
|
72
73
|
},
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* Wraps each child with a <ChildWrapper>
|
|
77
78
|
*/
|
|
78
|
-
function ChildrenRender(
|
|
79
|
-
const
|
|
80
|
-
return
|
|
79
|
+
function ChildrenRender(_props: unknown, { slots }: SetupContext) {
|
|
80
|
+
const children = slots['default']?.()[0]?.children
|
|
81
|
+
return Array.isArray(children) ? children.map((vnode, index) => h(ChildWrapper, { index }, () => [vnode])) : []
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
85
|
* Only renders a part of a children list
|
|
85
86
|
*/
|
|
86
|
-
function ChildrenSlice(props, { slots }) {
|
|
87
|
-
const
|
|
88
|
-
return
|
|
87
|
+
function ChildrenSlice(props: { start?: number, end?: number }, { slots }: SetupContext) {
|
|
88
|
+
const children = slots['default']?.()[0]?.children
|
|
89
|
+
return Array.isArray(children) ? children.slice(props.start, props.end) as VNode[] : []
|
|
89
90
|
}
|
|
90
91
|
</script>
|
|
91
92
|
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
2
|
import type { ComputedRef } from 'vue'
|
|
3
3
|
import { Icon } from '@iconify/vue'
|
|
4
|
+
import { refAutoReset } from '@vueuse/core'
|
|
4
5
|
import { Dropdown as VDropdown } from 'floating-vue'
|
|
5
|
-
import { computed } from 'vue'
|
|
6
|
+
import { computed, ref, useId } from 'vue'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* The WAI-ARIA select-only combobox: the trigger keeps focus and names the active
|
|
10
|
+
* option through `aria-activedescendant`, so the popup needs no focus of its own.
|
|
11
|
+
* It was a click-only `<div>` with `<div>` options, which a keyboard could not open
|
|
12
|
+
* and a screen reader did not list (#828).
|
|
13
|
+
*/
|
|
6
14
|
|
|
7
15
|
const props = defineProps<{
|
|
8
16
|
modelValue: string
|
|
9
17
|
options: Record<string, string> | Array<string>
|
|
18
|
+
/** The accessible name of the picker and its list. */
|
|
19
|
+
label: string
|
|
10
20
|
}>()
|
|
11
21
|
|
|
12
22
|
const emit = defineEmits<{
|
|
@@ -21,23 +31,159 @@ const formattedOptions: ComputedRef<Record<string, string>> = computed(() => {
|
|
|
21
31
|
return props.options
|
|
22
32
|
})
|
|
23
33
|
|
|
34
|
+
const entries = computed(() => Object.entries(formattedOptions.value))
|
|
35
|
+
|
|
24
36
|
const selectedLabel = computed(() => formattedOptions.value[props.modelValue])
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
const id = useId()
|
|
39
|
+
const listboxId = `${id}-listbox`
|
|
40
|
+
function optionId(index: number) {
|
|
41
|
+
return `${id}-option-${index}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const combobox = ref<HTMLElement>()
|
|
45
|
+
const open = ref(false)
|
|
46
|
+
const activeIndex = ref(-1)
|
|
47
|
+
|
|
48
|
+
function openList(index?: number) {
|
|
49
|
+
const selected = entries.value.findIndex(([value]) => value === props.modelValue)
|
|
50
|
+
activeIndex.value = index ?? Math.max(selected, 0)
|
|
51
|
+
open.value = true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Type-to-select, as the pattern recommends: typed characters within half a second
|
|
56
|
+
* form one search, and repeating a single character steps through the options
|
|
57
|
+
* that start with it.
|
|
58
|
+
*/
|
|
59
|
+
const typed = refAutoReset('', 500)
|
|
60
|
+
|
|
61
|
+
function isTypeaheadKey(event: KeyboardEvent) {
|
|
62
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
// Space chooses, unless it continues a search already under way.
|
|
66
|
+
return event.key !== ' ' || typed.value !== ''
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function typeahead(char: string) {
|
|
70
|
+
const previous = typed.value
|
|
71
|
+
typed.value = previous + char.toLowerCase()
|
|
72
|
+
const repeated = [...typed.value].every(c => c === typed.value[0])
|
|
73
|
+
const search = repeated ? typed.value.charAt(0) : typed.value
|
|
74
|
+
// A new or repeated character looks past the active option; a longer search may
|
|
75
|
+
// still match it.
|
|
76
|
+
const start = repeated ? activeIndex.value + 1 : Math.max(activeIndex.value, 0)
|
|
77
|
+
const count = entries.value.length
|
|
78
|
+
for (let offset = 0; offset < count; offset++) {
|
|
79
|
+
const index = (start + offset) % count
|
|
80
|
+
if (entries.value[index]?.[1].toLowerCase().startsWith(search)) {
|
|
81
|
+
activeIndex.value = index
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function close() {
|
|
88
|
+
open.value = false
|
|
30
89
|
}
|
|
90
|
+
|
|
91
|
+
function choose(index: number) {
|
|
92
|
+
const entry = entries.value[index]
|
|
93
|
+
if (entry) {
|
|
94
|
+
emit('update:modelValue', entry[0])
|
|
95
|
+
emit('select', entry[0])
|
|
96
|
+
}
|
|
97
|
+
close()
|
|
98
|
+
combobox.value?.focus()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onKeydown(event: KeyboardEvent) {
|
|
102
|
+
const last = entries.value.length - 1
|
|
103
|
+
|
|
104
|
+
if (isTypeaheadKey(event)) {
|
|
105
|
+
event.preventDefault()
|
|
106
|
+
if (!open.value) {
|
|
107
|
+
openList()
|
|
108
|
+
}
|
|
109
|
+
typeahead(event.key)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!open.value) {
|
|
114
|
+
if (['ArrowDown', 'ArrowUp', 'Enter', ' ', 'Home', 'End'].includes(event.key)) {
|
|
115
|
+
event.preventDefault()
|
|
116
|
+
openList(event.key === 'Home' ? 0 : event.key === 'End' ? last : undefined)
|
|
117
|
+
}
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
switch (event.key) {
|
|
122
|
+
case 'ArrowDown':
|
|
123
|
+
event.preventDefault()
|
|
124
|
+
activeIndex.value = Math.min(activeIndex.value + 1, last)
|
|
125
|
+
break
|
|
126
|
+
case 'ArrowUp':
|
|
127
|
+
event.preventDefault()
|
|
128
|
+
if (event.altKey) {
|
|
129
|
+
choose(activeIndex.value)
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
|
133
|
+
}
|
|
134
|
+
break
|
|
135
|
+
case 'Home':
|
|
136
|
+
event.preventDefault()
|
|
137
|
+
activeIndex.value = 0
|
|
138
|
+
break
|
|
139
|
+
case 'End':
|
|
140
|
+
event.preventDefault()
|
|
141
|
+
activeIndex.value = last
|
|
142
|
+
break
|
|
143
|
+
case 'Enter':
|
|
144
|
+
case ' ':
|
|
145
|
+
event.preventDefault()
|
|
146
|
+
choose(activeIndex.value)
|
|
147
|
+
break
|
|
148
|
+
case 'Escape':
|
|
149
|
+
event.preventDefault()
|
|
150
|
+
close()
|
|
151
|
+
break
|
|
152
|
+
case 'Tab':
|
|
153
|
+
// Keeps the default: focus moves on, with the active option chosen.
|
|
154
|
+
choose(activeIndex.value)
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
defineExpose({
|
|
160
|
+
focus: () => combobox.value?.focus(),
|
|
161
|
+
})
|
|
31
162
|
</script>
|
|
32
163
|
|
|
33
164
|
<template>
|
|
165
|
+
<!-- `no-auto-focus`: floating-vue otherwise moves focus into the popup once it
|
|
166
|
+
shows, and the combobox pattern keeps it on the trigger, which owns the keys. -->
|
|
34
167
|
<VDropdown
|
|
168
|
+
v-model:shown="open"
|
|
35
169
|
class="poveste-base-select"
|
|
170
|
+
:triggers="[]"
|
|
171
|
+
no-auto-focus
|
|
36
172
|
auto-size
|
|
37
173
|
auto-boundary-max-size
|
|
38
174
|
>
|
|
39
175
|
<div
|
|
40
|
-
|
|
176
|
+
ref="combobox"
|
|
177
|
+
role="combobox"
|
|
178
|
+
tabindex="0"
|
|
179
|
+
:aria-label="label"
|
|
180
|
+
aria-haspopup="listbox"
|
|
181
|
+
:aria-expanded="open"
|
|
182
|
+
:aria-controls="open ? listboxId : undefined"
|
|
183
|
+
:aria-activedescendant="open && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
|
184
|
+
class="cursor-pointer w-full outline-none focus-visible:border-primary-500 px-2 h-[27px] -my-1 border border-solid border-black/25 dark:border-white/25 hover:border-primary-500 dark:hover:border-primary-500 rounded-sm flex gap-2 items-center leading-normal"
|
|
185
|
+
@click="open ? close() : openList()"
|
|
186
|
+
@keydown="onKeydown"
|
|
41
187
|
>
|
|
42
188
|
<div class="flex-1 truncate">
|
|
43
189
|
<slot :label="selectedLabel">
|
|
@@ -49,24 +195,34 @@ function selectValue(value: string, hide: () => void) {
|
|
|
49
195
|
class="w-4 h-4 flex-none ml-auto"
|
|
50
196
|
/>
|
|
51
197
|
</div>
|
|
52
|
-
<template #popper
|
|
53
|
-
<div
|
|
198
|
+
<template #popper>
|
|
199
|
+
<div
|
|
200
|
+
:id="listboxId"
|
|
201
|
+
role="listbox"
|
|
202
|
+
:aria-label="label"
|
|
203
|
+
class="flex flex-col bg-gray-50 dark:bg-gray-700"
|
|
204
|
+
>
|
|
54
205
|
<div
|
|
55
|
-
v-for="(
|
|
56
|
-
|
|
57
|
-
:key="
|
|
206
|
+
v-for="([value, optionLabel], index) of entries"
|
|
207
|
+
:id="optionId(index)"
|
|
208
|
+
:key="value"
|
|
209
|
+
role="option"
|
|
210
|
+
:aria-selected="value === modelValue"
|
|
58
211
|
class="px-2 py-1 cursor-pointer hover:bg-primary-100 dark:hover:bg-primary-700"
|
|
59
212
|
:class="{
|
|
60
|
-
'bg-primary-200 dark:bg-primary-800':
|
|
213
|
+
'bg-primary-200 dark:bg-primary-800': value === modelValue,
|
|
214
|
+
'outline outline-2 -outline-offset-2 outline-primary-500': index === activeIndex,
|
|
61
215
|
}"
|
|
62
|
-
@
|
|
216
|
+
@mousedown.prevent
|
|
217
|
+
@mousemove="activeIndex = index"
|
|
218
|
+
@click="choose(index)"
|
|
63
219
|
>
|
|
64
220
|
<slot
|
|
65
221
|
name="option"
|
|
66
|
-
:label="
|
|
222
|
+
:label="optionLabel"
|
|
67
223
|
:value="value"
|
|
68
224
|
>
|
|
69
|
-
{{
|
|
225
|
+
{{ optionLabel }}
|
|
70
226
|
</slot>
|
|
71
227
|
</div>
|
|
72
228
|
</div>
|