@poveste/app 0.14.0 → 0.16.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 (126) hide show
  1. package/dist/app/style/main.pcss +18 -44
  2. package/dist/bundled/App.vue_vue_type_script_setup_true_lang.js +9 -7
  3. package/dist/bundled/app.css +91 -28
  4. package/dist/bundled/components/app/AppActions.vue_vue_type_script_setup_true_lang.js +44 -27
  5. package/dist/bundled/components/app/ShortcutTooltip.js +5 -0
  6. package/dist/bundled/components/app/ShortcutTooltip.vue_vue_type_script_setup_true_lang.js +27 -0
  7. package/dist/bundled/components/app/TopBarChipItem.js +1 -1
  8. package/dist/bundled/components/base/BaseDropdown.js +6 -0
  9. package/dist/bundled/components/base/BaseDropdown.vue_vue_type_script_setup_true_lang.js +68 -0
  10. package/dist/bundled/components/base/BaseEmpty.js +2 -2
  11. package/dist/bundled/components/base/BaseOverflowMenu.vue_vue_type_script_setup_true_lang.js +19 -19
  12. package/dist/bundled/components/base/BaseSelect.js +1 -0
  13. package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +194 -33
  14. package/dist/bundled/components/base/BaseSplitPane.js +1 -1
  15. package/dist/bundled/components/base/BaseSplitPane.vue_vue_type_script_setup_true_lang.js +65 -4
  16. package/dist/bundled/components/command/PromptSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  17. package/dist/bundled/components/panel/ControlsComponentPropItem.vue_vue_type_script_setup_true_lang.js +24 -16
  18. package/dist/bundled/components/panel/ControlsComponentProps.vue_vue_type_script_setup_true_lang.js +9 -6
  19. package/dist/bundled/components/panel/ControlsComponentState.vue_vue_type_script_setup_true_lang.js +9 -6
  20. package/dist/bundled/components/panel/PaneTabs.vue_vue_type_script_setup_true_lang.js +6 -6
  21. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +96 -52
  22. package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +5 -5
  23. package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +2 -1
  24. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  25. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +24 -18
  26. package/dist/bundled/components/search/SearchItemContent.vue_vue_type_script_setup_true_lang.js +2 -1
  27. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +1 -2
  28. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  29. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +52 -45
  30. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  31. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +22 -20
  32. package/dist/bundled/components/story/StoryVariantSingle.js +1 -1
  33. package/dist/bundled/components/story/StoryVariantSingle.vue_vue_type_script_setup_true_lang.js +2 -1
  34. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +9 -6
  35. package/dist/bundled/components/story/StoryView.vue_vue_type_script_setup_true_lang.js +5 -1
  36. package/dist/bundled/components/toolbar/DevOnlyToolbarOpenInEditor.vue_vue_type_script_setup_true_lang.js +15 -12
  37. package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +20 -16
  38. package/dist/bundled/components/toolbar/ToolbarNewTab.vue_vue_type_script_setup_true_lang.js +16 -13
  39. package/dist/bundled/components/toolbar/ToolbarResponsiveSize.vue_vue_type_script_setup_true_lang.js +35 -24
  40. package/dist/bundled/components/toolbar/ToolbarTextDirection.vue_vue_type_script_setup_true_lang.js +15 -12
  41. package/dist/bundled/components/toolbar/ToolbarTitle.js +1 -1
  42. package/dist/bundled/components/toolbar/ToolbarTitle.vue_vue_type_script_setup_true_lang.js +1 -1
  43. package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +2 -2
  44. package/dist/bundled/components/tree/StoryList.vue_vue_type_script_setup_true_lang.js +3 -3
  45. package/dist/bundled/components/tree/StoryListFolder.js +1 -1
  46. package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +3 -3
  47. package/dist/bundled/components/tree/StoryListItem.js +1 -1
  48. package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
  49. package/dist/bundled/index.js +0 -13
  50. package/dist/bundled/sandbox.js +5 -3
  51. package/dist/bundled/stores/story.js +2 -2
  52. package/dist/bundled/util/commands.js +1 -1
  53. package/dist/bundled/util/dark.js +1 -1
  54. package/dist/bundled/util/events.js +1 -1
  55. package/dist/bundled/util/grid-slots.js +1 -1
  56. package/dist/bundled/util/icons.generated.js +4 -2
  57. package/dist/bundled/util/keyboard.js +8 -14
  58. package/dist/bundled/util/mapping.js +2 -2
  59. package/dist/bundled/util/render-tracker.js +33 -0
  60. package/dist/bundled/util/scroll.js +26 -5
  61. package/dist/bundled/util/state.js +105 -5
  62. package/dist/bundled/util/variant.js +12 -2
  63. package/dist/style.css +753 -357
  64. package/package.json +28 -28
  65. package/src/__tests__/base-select.spec.ts +25 -0
  66. package/src/__tests__/render-tracker.spec.ts +38 -0
  67. package/src/__tests__/reset.spec.ts +53 -0
  68. package/src/__tests__/state-clone.spec.ts +55 -1
  69. package/src/__tests__/state-types.spec.ts +175 -0
  70. package/src/app/App.vue +44 -35
  71. package/src/app/components/app/AppActions.vue +42 -30
  72. package/src/app/components/app/ShortcutTooltip.vue +29 -0
  73. package/src/app/components/app/TopBarChipItem.vue +1 -1
  74. package/src/app/components/base/BaseDropdown.vue +142 -0
  75. package/src/app/components/base/BaseEmpty.vue +1 -1
  76. package/src/app/components/base/BaseListItemLink.vue +4 -2
  77. package/src/app/components/base/BaseOverflowMenu.vue +27 -21
  78. package/src/app/components/base/BaseSelect.vue +270 -41
  79. package/src/app/components/base/BaseSplitPane.vue +75 -4
  80. package/src/app/components/command/PromptSelect.vue +1 -1
  81. package/src/app/components/panel/ControlsComponentPropItem.vue +29 -17
  82. package/src/app/components/panel/ControlsComponentProps.vue +7 -5
  83. package/src/app/components/panel/ControlsComponentState.vue +7 -5
  84. package/src/app/components/panel/PaneTabs.vue +6 -6
  85. package/src/app/components/panel/StatePresets.vue +116 -74
  86. package/src/app/components/panel/StoryEvent.vue +10 -9
  87. package/src/app/components/panel/StorySidePanel.vue +2 -1
  88. package/src/app/components/panel/StorySourceCode.vue +51 -44
  89. package/src/app/components/search/SearchItemContent.vue +2 -1
  90. package/src/app/components/search/SearchPane.vue +2 -3
  91. package/src/app/components/story/StoryResponsivePreview.vue +61 -29
  92. package/src/app/components/story/StoryVariantGridItem.vue +20 -19
  93. package/src/app/components/story/StoryVariantSingle.vue +1 -0
  94. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +14 -8
  95. package/src/app/components/story/StoryView.vue +4 -1
  96. package/src/app/components/toolbar/DevOnlyToolbarOpenInEditor.vue +14 -12
  97. package/src/app/components/toolbar/ToolbarBackground.vue +22 -23
  98. package/src/app/components/toolbar/ToolbarNewTab.vue +14 -12
  99. package/src/app/components/toolbar/ToolbarResponsiveSize.vue +31 -25
  100. package/src/app/components/toolbar/ToolbarTextDirection.vue +14 -10
  101. package/src/app/components/toolbar/ToolbarTitle.vue +2 -2
  102. package/src/app/components/tree/StoryGroup.vue +2 -2
  103. package/src/app/components/tree/StoryList.vue +2 -2
  104. package/src/app/components/tree/StoryListFolder.vue +2 -2
  105. package/src/app/components/tree/StoryListItem.vue +1 -1
  106. package/src/app/index.ts +0 -17
  107. package/src/app/sandbox.ts +16 -3
  108. package/src/app/stores/story.ts +2 -2
  109. package/src/app/style/main.pcss +18 -44
  110. package/src/app/types.ts +4 -4
  111. package/src/app/util/commands.ts +1 -1
  112. package/src/app/util/dark.ts +1 -1
  113. package/src/app/util/env.ts +0 -1
  114. package/src/app/util/events.ts +6 -5
  115. package/src/app/util/grid-slots.ts +1 -1
  116. package/src/app/util/icons.generated.ts +2 -2
  117. package/src/app/util/keyboard.ts +7 -11
  118. package/src/app/util/mapping.ts +2 -2
  119. package/src/app/util/render-tracker.ts +31 -0
  120. package/src/app/util/scroll.ts +47 -9
  121. package/src/app/util/state.ts +141 -8
  122. package/src/app/util/variant.ts +12 -2
  123. package/dist/app/style/floating.pcss +0 -40
  124. package/dist/bundled/util/tooltip.js +0 -14
  125. package/src/app/style/floating.pcss +0 -40
  126. package/src/app/util/tooltip.ts +0 -13
@@ -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 />
@@ -0,0 +1,142 @@
1
+ <script lang="ts" setup>
2
+ import { HstTooltip, PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger, portalTarget } from '@poveste/controls'
3
+ import { onMounted, ref, shallowRef } from 'vue'
4
+
5
+ // The wrapper below is what a caller's class and `data-testid` are for, the way
6
+ // they were on `VDropdown` itself.
7
+ defineOptions({ inheritAttrs: false })
8
+
9
+ const props = withDefaults(defineProps<{
10
+ side?: 'top' | 'right' | 'bottom' | 'left'
11
+ align?: 'start' | 'center' | 'end'
12
+ /** floating-vue's `skidding`: a shift along the trigger's own edge. */
13
+ alignOffset?: number
14
+ /**
15
+ * Refuses to open, rather than disabling the button. A disabled button takes
16
+ * no pointer events, and the one site that sets this still wants its tooltip.
17
+ */
18
+ disabled?: boolean
19
+ /**
20
+ * Rendered here rather than around the trigger at the call site, because
21
+ * where the tooltip sits relative to the anchor below decides whether this
22
+ * opens in the right place at all.
23
+ */
24
+ tooltip?: string | null | undefined
25
+ }>(), {
26
+ side: 'bottom',
27
+ align: 'center',
28
+ alignOffset: 0,
29
+ disabled: false,
30
+ tooltip: undefined,
31
+ })
32
+
33
+ const open = ref(false)
34
+
35
+ function setOpen(value: boolean) {
36
+ open.value = value && !props.disabled
37
+ }
38
+
39
+ // After mount, not during setup: the chrome's root is only in the document once
40
+ // the tree it wraps has been inserted.
41
+ const target = shallowRef<HTMLElement>()
42
+ onMounted(() => {
43
+ target.value = portalTarget()
44
+ })
45
+ </script>
46
+
47
+ <template>
48
+ <PopoverRoot
49
+ :open="open"
50
+ @update:open="setOpen"
51
+ >
52
+ <!-- The anchor is the wrapper, not the trigger, and it has to sit outside
53
+ the tooltip. A tooltip is a popper too, so a trigger rendered inside one
54
+ registers itself as *its* anchor, and the panel then has none: it opens at
55
+ the top-left corner, off-screen, which a test asking only whether it is
56
+ visible still passes. -->
57
+ <PopoverAnchor as-child>
58
+ <div
59
+ v-bind="$attrs"
60
+ class="poveste-dropdown-trigger"
61
+ >
62
+ <!-- No tooltip while the panel is up: it would sit on top of the first
63
+ row. -->
64
+ <HstTooltip :content="open ? null : tooltip">
65
+ <PopoverTrigger as-child>
66
+ <slot :open="open" />
67
+ </PopoverTrigger>
68
+ </HstTooltip>
69
+ </div>
70
+ </PopoverAnchor>
71
+
72
+ <PopoverPortal
73
+ v-if="target"
74
+ :to="target"
75
+ >
76
+ <PopoverContent
77
+ class="poveste-dropdown"
78
+ :side="side"
79
+ :align="align"
80
+ :side-offset="8"
81
+ :align-offset="alignOffset"
82
+ :collision-padding="4"
83
+ >
84
+ <slot
85
+ name="popper"
86
+ :close="() => setOpen(false)"
87
+ />
88
+ </PopoverContent>
89
+ </PopoverPortal>
90
+ </PopoverRoot>
91
+ </template>
92
+
93
+ <style lang="pcss">
94
+ /* floating-vue put the same zero on its own wrapper, and the triggers that
95
+ wanted text to sit normally say `leading-normal` because of it. */
96
+ .poveste-dropdown-trigger {
97
+ line-height: 0;
98
+ }
99
+
100
+ .poveste-dropdown {
101
+ background: #fff;
102
+ color: #000;
103
+ border: 1px solid #ddd;
104
+ border-radius: 6px;
105
+ box-shadow: 0 6px 30px rgb(0 0 0 / .1);
106
+ /* A row that paints to the panel's edge has to stop at the rounded corner,
107
+ which the `overflow-y: auto` on floating-vue's inner element did. */
108
+ overflow: hidden;
109
+ z-index: 100;
110
+
111
+ /*
112
+ * What Tailwind's `dark:` compiles to, spelled out because `@apply` in a
113
+ * single-file component needs a `@reference` and no component here has one.
114
+ * Not `.ptw-dark &`: the chrome CSS is wrapped in `@scope` and `.ptw-dark`
115
+ * sits on `<html>`, above the scope root, so a descendant rule keyed on it
116
+ * never matches and the panel falls back to the light colours (#101). This
117
+ * puts the condition on the subject instead, and adds no specificity — so it
118
+ * has to come after the light values to win.
119
+ */
120
+ &:where(.ptw-dark, .ptw-dark *) {
121
+ background: var(--color-gray-700);
122
+ border-color: var(--color-gray-850);
123
+ color: var(--color-gray-100);
124
+ }
125
+
126
+ &:focus-visible {
127
+ outline: none;
128
+ }
129
+
130
+ &[data-state='open'] {
131
+ transform-origin: var(--reka-popover-content-transform-origin);
132
+ animation: poveste-dropdown-open .15s cubic-bezier(0, 1, .5, 1);
133
+ }
134
+ }
135
+
136
+ @keyframes poveste-dropdown-open {
137
+ from {
138
+ opacity: 0;
139
+ transform: scale(.75);
140
+ }
141
+ }
142
+ </style>
@@ -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,7 +1,9 @@
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'
6
+ import BaseDropdown from './BaseDropdown.vue'
5
7
 
6
8
  // Container
7
9
 
@@ -11,9 +13,9 @@ const el = ref<HTMLDivElement>()
11
13
 
12
14
  const availableWidth = ref(0)
13
15
 
14
- useResizeObserver(el, (entries) => {
15
- const containerWidth = entries[0].contentRect.width
16
- availableWidth.value = containerWidth - overflowButtonWidth
16
+ useResizeObserver(el, ([entry]) => {
17
+ if (!entry) return
18
+ availableWidth.value = entry.contentRect.width - overflowButtonWidth
17
19
  })
18
20
 
19
21
  // Children
@@ -28,8 +30,8 @@ const children = ref(new Map<HTMLElement, ChildState>())
28
30
  const visibleChildrenCount = computed(() => {
29
31
  let width = 0
30
32
  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
33
+ for (const [i, child] of c.entries()) {
34
+ width += child.width
33
35
  if (width > availableWidth.value) {
34
36
  return i
35
37
  }
@@ -43,18 +45,18 @@ const visibleChildrenCount = computed(() => {
43
45
  const ChildWrapper = {
44
46
  name: 'ChildWrapper',
45
47
  props: ['index'],
46
- setup(props, { slots }) {
48
+ setup(props: { index: number }, { slots }: SetupContext) {
47
49
  const el = ref<HTMLDivElement>()
48
50
 
49
51
  const state = reactive({ width: 0, index: props.index })
50
52
 
51
- useResizeObserver(el, (entries) => {
53
+ useResizeObserver(el, ([entry]) => {
52
54
  // The observer only fires for an element it is observing, so this is
53
55
  // never null here — but `children` is keyed by the element, and an
54
56
  // undefined key would silently make a second entry for the same child.
55
57
  const element = el.value
56
- if (!element) return
57
- const width = entries[0].contentRect.width
58
+ if (!element || !entry) return
59
+ const width = entry.contentRect.width
58
60
  if (!children.value.has(element)) {
59
61
  children.value.set(element, state)
60
62
  }
@@ -68,24 +70,24 @@ const ChildWrapper = {
68
70
 
69
71
  const visible = computed(() => visibleChildrenCount.value > state.index)
70
72
 
71
- return () => h('div', { ref: el, style: { visibility: visible.value ? 'visible' : 'hidden' } }, slots.default())
73
+ return () => h('div', { ref: el, style: { visibility: visible.value ? 'visible' : 'hidden' } }, slots['default']?.())
72
74
  },
73
75
  }
74
76
 
75
77
  /**
76
78
  * Wraps each child with a <ChildWrapper>
77
79
  */
78
- function ChildrenRender(props, { slots }) {
79
- const [fragment] = slots.default()
80
- return fragment.children.map((vnode, index) => h(ChildWrapper, { index }, () => [vnode]))
80
+ function ChildrenRender(_props: unknown, { slots }: SetupContext) {
81
+ const children = slots['default']?.()[0]?.children
82
+ return Array.isArray(children) ? children.map((vnode, index) => h(ChildWrapper, { index }, () => [vnode])) : []
81
83
  }
82
84
 
83
85
  /**
84
86
  * Only renders a part of a children list
85
87
  */
86
- function ChildrenSlice(props, { slots }) {
87
- const [fragment] = slots.default()
88
- return fragment.children.slice(props.start, props.end)
88
+ function ChildrenSlice(props: { start?: number, end?: number }, { slots }: SetupContext) {
89
+ const children = slots['default']?.()[0]?.children
90
+ return Array.isArray(children) ? children.slice(props.start, props.end) as VNode[] : []
89
91
  }
90
92
  </script>
91
93
 
@@ -98,18 +100,22 @@ function ChildrenSlice(props, { slots }) {
98
100
  <slot />
99
101
  </ChildrenRender>
100
102
 
101
- <VDropdown
103
+ <BaseDropdown
102
104
  v-if="visibleChildrenCount < children.size"
103
105
  >
104
- <div
105
- role="button"
106
+ <!-- A `<button>`, where this was a `role="button"` div with no `tabindex`:
107
+ the popover writes `aria-expanded` onto whatever it is given, and axe
108
+ rejects that on an element with no role it can name (#924). A keyboard
109
+ reaches the hidden tabs now, which it could not before. -->
110
+ <button
111
+ aria-label="More"
106
112
  class="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900 w-8 h-full flex items-center justify-center absolute top-0 right-0"
107
113
  >
108
114
  <Icon
109
115
  icon="carbon:caret-down"
110
116
  class="w-4 h-4 opacity-50 group-hover:opacity-100"
111
117
  />
112
- </div>
118
+ </button>
113
119
 
114
120
  <template #popper>
115
121
  <div class="flex flex-col items-stretch">
@@ -120,6 +126,6 @@ function ChildrenSlice(props, { slots }) {
120
126
  </ChildrenSlice>
121
127
  </div>
122
128
  </template>
123
- </VDropdown>
129
+ </BaseDropdown>
124
130
  </div>
125
131
  </template>
@@ -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 { Dropdown as VDropdown } from 'floating-vue'
5
- import { computed } from 'vue'
4
+ import { PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot, portalTarget } from '@poveste/controls'
5
+ import { refAutoReset } from '@vueuse/core'
6
+ import { computed, nextTick, onMounted, ref, shallowRef, useId, watch } 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,55 +31,274 @@ 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
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()
30
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
+ // After mount, not during setup: the chrome's root is only in the document once
160
+ // the tree it wraps has been inserted.
161
+ const target = shallowRef<HTMLElement>()
162
+ onMounted(() => {
163
+ target.value = portalTarget()
164
+ })
165
+
166
+ /*
167
+ * Reka spares its own trigger from the dismiss it fires on any pointer or focus
168
+ * landing outside the popup — but only a `PopoverTrigger` registers as one, and
169
+ * this trigger cannot be that: `PopoverTrigger` writes its own `aria-expanded`,
170
+ * `aria-controls` and `aria-haspopup="dialog"` over the listbox wiring the
171
+ * combobox pattern is built on. So the dismiss is declined here instead.
172
+ *
173
+ * Without it the popup is impossible to close by clicking the picker: the
174
+ * dismiss lands on `pointerdown`, the handler below then reads `open` as false
175
+ * and opens it again, and the whole thing looks like a click that did nothing.
176
+ * Neither aria spec catches it — both drive this from the keyboard, on purpose.
177
+ */
178
+ function keepOpenOnTheTrigger(event: Event) {
179
+ if (combobox.value?.contains(event.target as Node)) {
180
+ event.preventDefault()
181
+ }
182
+ }
183
+
184
+ /*
185
+ * A capped list scrolls, and `aria-activedescendant` moves an option the browser
186
+ * has no reason to reveal — the focus never leaves the trigger, so nothing
187
+ * scrolls on its own.
188
+ */
189
+ watch([open, activeIndex], async ([isOpen, index]) => {
190
+ if (!isOpen || index < 0) {
191
+ return
192
+ }
193
+ await nextTick()
194
+ target.value?.ownerDocument.getElementById(optionId(index))?.scrollIntoView({ block: 'nearest' })
195
+ })
196
+
197
+ defineExpose({
198
+ focus: () => combobox.value?.focus(),
199
+ })
31
200
  </script>
32
201
 
33
202
  <template>
34
- <VDropdown
35
- class="poveste-base-select"
36
- auto-size
37
- auto-boundary-max-size
38
- >
39
- <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"
203
+ <PopoverRoot v-model:open="open">
204
+ <PopoverAnchor
205
+ as-child
206
+ class="poveste-base-select"
41
207
  >
42
- <div class="flex-1 truncate">
43
- <slot :label="selectedLabel">
44
- {{ selectedLabel }}
45
- </slot>
208
+ <div
209
+ ref="combobox"
210
+ role="combobox"
211
+ tabindex="0"
212
+ :aria-label="label"
213
+ aria-haspopup="listbox"
214
+ :aria-expanded="open"
215
+ :aria-controls="open ? listboxId : undefined"
216
+ :aria-activedescendant="open && activeIndex >= 0 ? optionId(activeIndex) : undefined"
217
+ 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"
218
+ @click="open ? close() : openList()"
219
+ @keydown="onKeydown"
220
+ >
221
+ <div class="flex-1 truncate">
222
+ <slot :label="selectedLabel">
223
+ {{ selectedLabel }}
224
+ </slot>
225
+ </div>
226
+ <Icon
227
+ icon="carbon:chevron-sort"
228
+ class="w-4 h-4 flex-none ml-auto"
229
+ />
46
230
  </div>
47
- <Icon
48
- icon="carbon:chevron-sort"
49
- class="w-4 h-4 flex-none ml-auto"
50
- />
51
- </div>
52
- <template #popper="{ hide }">
53
- <div class="flex flex-col bg-gray-50 dark:bg-gray-700">
231
+ </PopoverAnchor>
232
+
233
+ <PopoverPortal
234
+ v-if="target"
235
+ :to="target"
236
+ >
237
+ <!-- The popup takes no focus at either end: the combobox pattern keeps it on
238
+ the trigger, which owns the keys. Deliberate rather than load-bearing
239
+ today — Reka moves focus into the content on open, and only skips it
240
+ here because the options are divs with nothing focusable in them. The
241
+ aria specs cannot fail on this, so removing it would look safe. -->
242
+ <PopoverContent
243
+ class="poveste-base-select-popper"
244
+ align="start"
245
+ :side-offset="6"
246
+ :collision-padding="8"
247
+ @open-auto-focus.prevent
248
+ @close-auto-focus.prevent
249
+ @interact-outside="keepOpenOnTheTrigger"
250
+ >
54
251
  <div
55
- v-for="(label, value) in formattedOptions"
56
- v-bind="{ ...$attrs, class: null, style: null }"
57
- :key="label"
58
- class="px-2 py-1 cursor-pointer hover:bg-primary-100 dark:hover:bg-primary-700"
59
- :class="{
60
- 'bg-primary-200 dark:bg-primary-800': props.modelValue === value,
61
- }"
62
- @click="selectValue(value, hide)"
252
+ :id="listboxId"
253
+ role="listbox"
254
+ :aria-label="label"
255
+ class="poveste-base-select-options flex flex-col bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100"
63
256
  >
64
- <slot
65
- name="option"
66
- :label="label"
67
- :value="value"
257
+ <div
258
+ v-for="([value, optionLabel], index) of entries"
259
+ :id="optionId(index)"
260
+ :key="value"
261
+ role="option"
262
+ :aria-selected="value === modelValue"
263
+ class="px-2 py-1 cursor-pointer hover:bg-primary-100 dark:hover:bg-primary-700"
264
+ :class="{
265
+ 'bg-primary-200 dark:bg-primary-800': value === modelValue,
266
+ 'outline outline-2 -outline-offset-2 outline-primary-500': index === activeIndex,
267
+ }"
268
+ @mousedown.prevent
269
+ @mousemove="activeIndex = index"
270
+ @click="choose(index)"
68
271
  >
69
- {{ label }}
70
- </slot>
272
+ <slot
273
+ name="option"
274
+ :label="optionLabel"
275
+ :value="value"
276
+ >
277
+ {{ optionLabel }}
278
+ </slot>
279
+ </div>
71
280
  </div>
72
- </div>
73
- </template>
74
- </VDropdown>
281
+ </PopoverContent>
282
+ </PopoverPortal>
283
+ </PopoverRoot>
75
284
  </template>
285
+
286
+ <style lang="pcss">
287
+ .poveste-base-select-popper {
288
+ /* The trigger's own width, which is what floating-vue's `auto-size` gave. */
289
+ min-width: var(--reka-popper-anchor-width);
290
+ z-index: 100;
291
+
292
+ &:focus-visible {
293
+ outline: none;
294
+ }
295
+ }
296
+
297
+ .poveste-base-select-options {
298
+ /* The room Reka measured between the trigger and the viewport edge, which is
299
+ what `auto-boundary-max-size` gave: a story with more presets than fit below
300
+ its picker otherwise drew them off the screen, reachable by nothing. */
301
+ max-height: var(--reka-popover-content-available-height);
302
+ overflow-y: auto;
303
+ }
304
+ </style>