@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.
Files changed (79) hide show
  1. package/dist/bundled/app.css +26 -26
  2. package/dist/bundled/components/app/TopBarChipItem.js +1 -1
  3. package/dist/bundled/components/base/BaseEmpty.js +2 -2
  4. package/dist/bundled/components/base/BaseOverflowMenu.vue_vue_type_script_setup_true_lang.js +14 -14
  5. package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +158 -26
  6. package/dist/bundled/components/base/BaseSplitPane.js +1 -1
  7. package/dist/bundled/components/command/PromptSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  8. package/dist/bundled/components/panel/ControlsComponentPropItem.vue_vue_type_script_setup_true_lang.js +20 -14
  9. package/dist/bundled/components/panel/PaneTabs.vue_vue_type_script_setup_true_lang.js +6 -6
  10. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +78 -49
  11. package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +1 -1
  12. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  13. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +5 -4
  14. package/dist/bundled/components/search/SearchItemContent.vue_vue_type_script_setup_true_lang.js +2 -1
  15. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +1 -2
  16. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  17. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +8 -4
  18. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  19. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +3 -3
  20. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +9 -6
  21. package/dist/bundled/components/story/StoryView.vue_vue_type_script_setup_true_lang.js +4 -1
  22. package/dist/bundled/components/toolbar/ToolbarTitle.js +1 -1
  23. package/dist/bundled/components/toolbar/ToolbarTitle.vue_vue_type_script_setup_true_lang.js +1 -1
  24. package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +2 -2
  25. package/dist/bundled/components/tree/StoryList.vue_vue_type_script_setup_true_lang.js +3 -3
  26. package/dist/bundled/components/tree/StoryListFolder.js +1 -1
  27. package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +3 -3
  28. package/dist/bundled/components/tree/StoryListItem.js +1 -1
  29. package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
  30. package/dist/bundled/sandbox.js +3 -2
  31. package/dist/bundled/stores/story.js +2 -2
  32. package/dist/bundled/util/commands.js +1 -1
  33. package/dist/bundled/util/dark.js +1 -1
  34. package/dist/bundled/util/events.js +1 -1
  35. package/dist/bundled/util/grid-slots.js +1 -1
  36. package/dist/bundled/util/keyboard.js +8 -14
  37. package/dist/bundled/util/mapping.js +2 -2
  38. package/dist/bundled/util/render-tracker.js +33 -0
  39. package/dist/bundled/util/variant.js +12 -2
  40. package/dist/style.css +23 -1
  41. package/package.json +13 -17
  42. package/src/__tests__/render-tracker.spec.ts +38 -0
  43. package/src/app/App.vue +1 -1
  44. package/src/app/components/app/TopBarChipItem.vue +1 -1
  45. package/src/app/components/base/BaseEmpty.vue +1 -1
  46. package/src/app/components/base/BaseListItemLink.vue +4 -2
  47. package/src/app/components/base/BaseOverflowMenu.vue +17 -16
  48. package/src/app/components/base/BaseSelect.vue +171 -15
  49. package/src/app/components/base/BaseSplitPane.vue +2 -2
  50. package/src/app/components/command/PromptSelect.vue +1 -1
  51. package/src/app/components/panel/ControlsComponentPropItem.vue +26 -15
  52. package/src/app/components/panel/PaneTabs.vue +6 -6
  53. package/src/app/components/panel/StatePresets.vue +100 -64
  54. package/src/app/components/panel/StorySidePanel.vue +1 -1
  55. package/src/app/components/panel/StorySourceCode.vue +6 -5
  56. package/src/app/components/search/SearchItemContent.vue +2 -1
  57. package/src/app/components/search/SearchPane.vue +2 -3
  58. package/src/app/components/story/StoryResponsivePreview.vue +6 -2
  59. package/src/app/components/story/StoryVariantGridItem.vue +1 -1
  60. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +14 -8
  61. package/src/app/components/story/StoryView.vue +3 -1
  62. package/src/app/components/toolbar/ToolbarTitle.vue +2 -2
  63. package/src/app/components/tree/StoryGroup.vue +2 -2
  64. package/src/app/components/tree/StoryList.vue +2 -2
  65. package/src/app/components/tree/StoryListFolder.vue +2 -2
  66. package/src/app/components/tree/StoryListItem.vue +1 -1
  67. package/src/app/sandbox.ts +8 -2
  68. package/src/app/stores/story.ts +2 -2
  69. package/src/app/types.ts +4 -4
  70. package/src/app/util/commands.ts +1 -1
  71. package/src/app/util/dark.ts +1 -1
  72. package/src/app/util/env.ts +0 -1
  73. package/src/app/util/events.ts +6 -5
  74. package/src/app/util/grid-slots.ts +1 -1
  75. package/src/app/util/keyboard.ts +7 -11
  76. package/src/app/util/mapping.ts +2 -2
  77. package/src/app/util/render-tracker.ts +31 -0
  78. package/src/app/util/state.ts +3 -3
  79. package/src/app/util/variant.ts +12 -2
@@ -57,7 +57,7 @@ function trimSlots(slots) {
57
57
  next = kept;
58
58
  }
59
59
  let end = next.length;
60
- while (end > 0 && !next[end - 1].visible) end--;
60
+ while (end > 0 && !next[end - 1]?.visible) end--;
61
61
  return end === next.length ? next : next.slice(0, end);
62
62
  }
63
63
  //#endregion
@@ -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 i in modifiers) {
28
- const mod = modifiers[i];
29
- if (mod.key === event.key) {
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 i in modifiers) {
40
- const mod = modifiers[i];
41
- if (mod.key === event.key) {
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 i in modifiers) modifiers[i].pressed = false;
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 in modifiers) if (splitted.includes(mod) !== modifiers[mod].pressed) return false;
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[key] = file[key];
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[key] = variant[key];
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 && (!!story.lastSelectedVariant || story.variants.length === 1);
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.variantId === variant.value.id),
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-e4c89a1b] .cm-editor {
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.14.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.14.0",
42
- "@poveste/shared": "^0.14.0",
43
- "@poveste/vendors": "^0.14.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": "^3.0.0"
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": "^4.0.1",
60
- "concurrently": "^9.1.0",
55
+ "chokidar": "^5.0.0",
56
+ "concurrently": "^10.0.5",
61
57
  "fs-extra": "^11.2.0",
62
- "globby": "^14.0.2",
63
- "pathe": "^1.1.2",
58
+ "globby": "^16.2.4",
59
+ "pathe": "^2.0.3",
64
60
  "postcss": "^8.5.23",
65
- "postcss-cli": "^11.0.1",
61
+ "postcss-cli": "^12.0.0",
66
62
  "tailwindcss": "^4.3.3",
67
- "typescript": "5.6.3",
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": "^2.1.10"
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.href ? undefined : 'button'"
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 text-lg">
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
- navigate(event)
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, (entries) => {
15
- const containerWidth = entries[0].contentRect.width
16
- availableWidth.value = containerWidth - overflowButtonWidth
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 (let i = 0; i < c.length; i++) {
32
- width += c[i].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, (entries) => {
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 = entries[0].contentRect.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.default())
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(props, { slots }) {
79
- const [fragment] = slots.default()
80
- return fragment.children.map((vnode, index) => h(ChildWrapper, { index }, () => [vnode]))
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 [fragment] = slots.default()
88
- return fragment.children.slice(props.start, props.end)
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
- function selectValue(value: string, hide: () => void) {
27
- emit('update:modelValue', value)
28
- emit('select', value)
29
- hide()
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
- class="cursor-pointer w-full outline-none 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"
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="{ hide }">
53
- <div class="flex flex-col bg-gray-50 dark:bg-gray-700">
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="(label, value) in formattedOptions"
56
- v-bind="{ ...$attrs, class: null, style: null }"
57
- :key="label"
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': props.modelValue === value,
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
- @click="selectValue(value, hide)"
216
+ @mousedown.prevent
217
+ @mousemove="activeIndex = index"
218
+ @click="choose(index)"
63
219
  >
64
220
  <slot
65
221
  name="option"
66
- :label="label"
222
+ :label="optionLabel"
67
223
  :value="value"
68
224
  >
69
- {{ label }}
225
+ {{ optionLabel }}
70
226
  </slot>
71
227
  </div>
72
228
  </div>