@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.
- package/dist/app/style/main.pcss +18 -44
- package/dist/bundled/App.vue_vue_type_script_setup_true_lang.js +9 -7
- package/dist/bundled/app.css +91 -28
- package/dist/bundled/components/app/AppActions.vue_vue_type_script_setup_true_lang.js +44 -27
- package/dist/bundled/components/app/ShortcutTooltip.js +5 -0
- package/dist/bundled/components/app/ShortcutTooltip.vue_vue_type_script_setup_true_lang.js +27 -0
- package/dist/bundled/components/app/TopBarChipItem.js +1 -1
- package/dist/bundled/components/base/BaseDropdown.js +6 -0
- package/dist/bundled/components/base/BaseDropdown.vue_vue_type_script_setup_true_lang.js +68 -0
- package/dist/bundled/components/base/BaseEmpty.js +2 -2
- package/dist/bundled/components/base/BaseOverflowMenu.vue_vue_type_script_setup_true_lang.js +19 -19
- package/dist/bundled/components/base/BaseSelect.js +1 -0
- package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +194 -33
- package/dist/bundled/components/base/BaseSplitPane.js +1 -1
- package/dist/bundled/components/base/BaseSplitPane.vue_vue_type_script_setup_true_lang.js +65 -4
- 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 +24 -16
- package/dist/bundled/components/panel/ControlsComponentProps.vue_vue_type_script_setup_true_lang.js +9 -6
- package/dist/bundled/components/panel/ControlsComponentState.vue_vue_type_script_setup_true_lang.js +9 -6
- 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 +96 -52
- package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +5 -5
- package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +2 -1
- package/dist/bundled/components/panel/StorySourceCode.js +1 -1
- package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +24 -18
- 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 +52 -45
- package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
- package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +22 -20
- package/dist/bundled/components/story/StoryVariantSingle.js +1 -1
- package/dist/bundled/components/story/StoryVariantSingle.vue_vue_type_script_setup_true_lang.js +2 -1
- 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 +5 -1
- package/dist/bundled/components/toolbar/DevOnlyToolbarOpenInEditor.vue_vue_type_script_setup_true_lang.js +15 -12
- package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +20 -16
- package/dist/bundled/components/toolbar/ToolbarNewTab.vue_vue_type_script_setup_true_lang.js +16 -13
- package/dist/bundled/components/toolbar/ToolbarResponsiveSize.vue_vue_type_script_setup_true_lang.js +35 -24
- package/dist/bundled/components/toolbar/ToolbarTextDirection.vue_vue_type_script_setup_true_lang.js +15 -12
- 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/index.js +0 -13
- package/dist/bundled/sandbox.js +5 -3
- 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/icons.generated.js +4 -2
- 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/scroll.js +26 -5
- package/dist/bundled/util/state.js +105 -5
- package/dist/bundled/util/variant.js +12 -2
- package/dist/style.css +753 -357
- package/package.json +28 -28
- package/src/__tests__/base-select.spec.ts +25 -0
- package/src/__tests__/render-tracker.spec.ts +38 -0
- package/src/__tests__/reset.spec.ts +53 -0
- package/src/__tests__/state-clone.spec.ts +55 -1
- package/src/__tests__/state-types.spec.ts +175 -0
- package/src/app/App.vue +44 -35
- package/src/app/components/app/AppActions.vue +42 -30
- package/src/app/components/app/ShortcutTooltip.vue +29 -0
- package/src/app/components/app/TopBarChipItem.vue +1 -1
- package/src/app/components/base/BaseDropdown.vue +142 -0
- 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 +27 -21
- package/src/app/components/base/BaseSelect.vue +270 -41
- package/src/app/components/base/BaseSplitPane.vue +75 -4
- package/src/app/components/command/PromptSelect.vue +1 -1
- package/src/app/components/panel/ControlsComponentPropItem.vue +29 -17
- package/src/app/components/panel/ControlsComponentProps.vue +7 -5
- package/src/app/components/panel/ControlsComponentState.vue +7 -5
- package/src/app/components/panel/PaneTabs.vue +6 -6
- package/src/app/components/panel/StatePresets.vue +116 -74
- package/src/app/components/panel/StoryEvent.vue +10 -9
- package/src/app/components/panel/StorySidePanel.vue +2 -1
- package/src/app/components/panel/StorySourceCode.vue +51 -44
- 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 +61 -29
- package/src/app/components/story/StoryVariantGridItem.vue +20 -19
- package/src/app/components/story/StoryVariantSingle.vue +1 -0
- package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +14 -8
- package/src/app/components/story/StoryView.vue +4 -1
- package/src/app/components/toolbar/DevOnlyToolbarOpenInEditor.vue +14 -12
- package/src/app/components/toolbar/ToolbarBackground.vue +22 -23
- package/src/app/components/toolbar/ToolbarNewTab.vue +14 -12
- package/src/app/components/toolbar/ToolbarResponsiveSize.vue +31 -25
- package/src/app/components/toolbar/ToolbarTextDirection.vue +14 -10
- 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/index.ts +0 -17
- package/src/app/sandbox.ts +16 -3
- package/src/app/stores/story.ts +2 -2
- package/src/app/style/main.pcss +18 -44
- 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/icons.generated.ts +2 -2
- 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/scroll.ts +47 -9
- package/src/app/util/state.ts +141 -8
- package/src/app/util/variant.ts +12 -2
- package/dist/app/style/floating.pcss +0 -40
- package/dist/bundled/util/tooltip.js +0 -14
- package/src/app/style/floating.pcss +0 -40
- 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
|
|
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
|
|
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,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, (
|
|
15
|
-
|
|
16
|
-
availableWidth.value =
|
|
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 (
|
|
32
|
-
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, (
|
|
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 =
|
|
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
|
|
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(
|
|
79
|
-
const
|
|
80
|
-
return
|
|
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
|
|
88
|
-
return
|
|
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
|
-
<
|
|
103
|
+
<BaseDropdown
|
|
102
104
|
v-if="visibleChildrenCount < children.size"
|
|
103
105
|
>
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
:
|
|
58
|
-
class="
|
|
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
|
-
<
|
|
65
|
-
|
|
66
|
-
:
|
|
67
|
-
:
|
|
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
|
-
|
|
70
|
-
|
|
272
|
+
<slot
|
|
273
|
+
name="option"
|
|
274
|
+
:label="optionLabel"
|
|
275
|
+
:value="value"
|
|
276
|
+
>
|
|
277
|
+
{{ optionLabel }}
|
|
278
|
+
</slot>
|
|
279
|
+
</div>
|
|
71
280
|
</div>
|
|
72
|
-
</
|
|
73
|
-
</
|
|
74
|
-
</
|
|
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>
|