-
Notifications
You must be signed in to change notification settings - Fork 130
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add context / dropdown menu to component library (#1557)
* feat: scaffold basic dropdown * Add dropdown item and divider * feat: extract ScalarFloating primitive * chore: fix tests * Extract resize with target hook * fix: make placement responsive * fix: extra font size to tailwind merge * fix: switch to use MaybeRefOrGetter --------- Co-authored-by: Amrit <[email protected]>
- Loading branch information
Showing
17 changed files
with
369 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
packages/components/src/components/ScalarDropdown/ScalarDropdown.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { mount } from '@vue/test-utils' | ||
import { describe, expect, it } from 'vitest' | ||
|
||
import ScalarDropdown from './ScalarDropdown.vue' | ||
|
||
describe('ScalarIconButton', () => { | ||
it('renders properly', async () => { | ||
const wrapper = mount(ScalarDropdown, { | ||
props: {}, | ||
slots: { | ||
default: `<button>Button</button>`, | ||
}, | ||
}) | ||
|
||
expect(wrapper.exists()).toBeTruthy() | ||
}) | ||
}) |
48 changes: 48 additions & 0 deletions
48
packages/components/src/components/ScalarDropdown/ScalarDropdown.stories.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import { placements } from '@floating-ui/utils' | ||
import type { Meta, StoryObj } from '@storybook/vue3' | ||
|
||
import { ScalarButton } from '../../' | ||
import ScalarDropdown from './ScalarDropdown.vue' | ||
import ScalarDropdownDivider from './ScalarDropdownDivider.vue' | ||
import ScalarDropdownItem from './ScalarDropdownItem.vue' | ||
|
||
const meta = { | ||
component: ScalarDropdown, | ||
tags: ['autodocs'], | ||
argTypes: { | ||
placement: { | ||
control: 'select', | ||
options: placements, | ||
}, | ||
}, | ||
render: (args) => ({ | ||
components: { | ||
ScalarDropdown, | ||
ScalarDropdownItem, | ||
ScalarDropdownDivider, | ||
ScalarButton, | ||
}, | ||
setup() { | ||
return { args } | ||
}, | ||
template: ` | ||
<div class="flex items-center justify-center w-full h-screen"> | ||
<ScalarDropdown v-bind="args"> | ||
<ScalarButton>Click Me</ScalarButton> | ||
<template #items> | ||
<ScalarDropdownItem>An item</ScalarDropdownItem> | ||
<ScalarDropdownItem>Another item</ScalarDropdownItem> | ||
<ScalarDropdownDivider /> | ||
<ScalarDropdownItem>An item with a long label that needs to be truncated</ScalarDropdownItem> | ||
<ScalarDropdownItem disabled>A disabled item</ScalarDropdownItem> | ||
</template> | ||
</ScalarDropdown> | ||
</div> | ||
`, | ||
}), | ||
} satisfies Meta<typeof ScalarDropdown> | ||
|
||
export default meta | ||
type Story = StoryObj<typeof meta> | ||
|
||
export const Base: Story = {} |
30 changes: 30 additions & 0 deletions
30
packages/components/src/components/ScalarDropdown/ScalarDropdown.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
<script setup lang="ts"> | ||
import { Menu, MenuButton, MenuItems } from '@headlessui/vue' | ||
import { type FloatingOptions, ScalarFloating } from '../ScalarFloating' | ||
defineProps<FloatingOptions>() | ||
defineOptions({ inheritAttrs: false }) | ||
</script> | ||
<template> | ||
<Menu> | ||
<ScalarFloating | ||
:placement="placement ?? 'bottom-start'" | ||
:resize="resize"> | ||
<MenuButton as="template"> | ||
<slot /> | ||
</MenuButton> | ||
<template #floating="{ width }"> | ||
<MenuItems | ||
class="relative flex w-56 flex-col p-0.75" | ||
:style="{ width }" | ||
v-bind="$attrs"> | ||
<slot name="items" /> | ||
<div | ||
class="absolute inset-0 -z-1 rounded bg-back-1 shadow-md brightness-lifted" /> | ||
</MenuItems> | ||
</template> | ||
</ScalarFloating> | ||
</Menu> | ||
</template> |
3 changes: 3 additions & 0 deletions
3
packages/components/src/components/ScalarDropdown/ScalarDropdownDivider.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
<template> | ||
<div class="-mx-0.75 my-0.75 h-px bg-border" /> | ||
</template> |
42 changes: 42 additions & 0 deletions
42
packages/components/src/components/ScalarDropdown/ScalarDropdownItem.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
<script setup lang="ts"> | ||
import { MenuItem } from '@headlessui/vue' | ||
import { cva, cx } from '../../cva' | ||
defineProps<{ | ||
disabled?: boolean | ||
}>() | ||
defineEmits<{ | ||
(e: 'click', event: MouseEvent): void | ||
}>() | ||
const variants = cva({ | ||
base: [ | ||
// Layout | ||
'min-w-0 items-center gap-3 rounded px-2.5 py-1.5 text-left', | ||
'first-of-type:mt-0.75 last-of-type:mb-0.75', | ||
// Text / background style | ||
'truncate bg-transparent text-xs font-medium text-fore-2', | ||
// Interaction | ||
'cursor-pointer hover:bg-back-2 hover:text-fore-1', | ||
], | ||
variants: { | ||
disabled: { true: 'pointer-events-none text-fore-3' }, | ||
active: { true: 'bg-back-2 text-fore-1' }, | ||
}, | ||
}) | ||
</script> | ||
<template> | ||
<MenuItem | ||
v-slot="{ active }" | ||
:disabled="disabled"> | ||
<button | ||
class="item" | ||
:class="cx('scalar-dropdown-item', variants({ active, disabled }))" | ||
type="button" | ||
@click="($event) => $emit('click', $event)"> | ||
<slot /> | ||
</button> | ||
</MenuItem> | ||
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default as ScalarDropdown } from './ScalarDropdown.vue' |
14 changes: 14 additions & 0 deletions
14
packages/components/src/components/ScalarFloating/ScalarFloating.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { mount } from '@vue/test-utils' | ||
import { describe, expect, it } from 'vitest' | ||
|
||
import ScalarFloating from './ScalarFloating.vue' | ||
|
||
describe('ScalarIconButton', () => { | ||
it('renders properly', async () => { | ||
const wrapper = mount(ScalarFloating, { | ||
props: {}, | ||
}) | ||
|
||
expect(wrapper.exists()).toBeTruthy() | ||
}) | ||
}) |
38 changes: 38 additions & 0 deletions
38
packages/components/src/components/ScalarFloating/ScalarFloating.stories.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { placements } from '@floating-ui/utils' | ||
import type { Meta, StoryObj } from '@storybook/vue3' | ||
|
||
import ScalarFloating from './ScalarFloating.vue' | ||
|
||
const meta = { | ||
component: ScalarFloating, | ||
tags: ['autodocs'], | ||
argTypes: { | ||
placement: { | ||
control: 'select', | ||
options: placements, | ||
}, | ||
}, | ||
render: (args) => ({ | ||
components: { ScalarFloating }, | ||
setup() { | ||
return { args } | ||
}, | ||
template: ` | ||
<div class="flex items-center justify-center w-full h-screen"> | ||
<ScalarFloating v-bind="args"> | ||
<div class="rounded border bg-back-2 p-1">Target for #floating</div> | ||
<template #floating="{ width }"> | ||
<div class="rounded border shadow bg-back-2 p-1" :style="{ width }"> | ||
Floating | ||
</div> | ||
</template> | ||
</ScalarDropdown> | ||
</div> | ||
`, | ||
}), | ||
} satisfies Meta<typeof ScalarFloating> | ||
|
||
export default meta | ||
type Story = StoryObj<typeof meta> | ||
|
||
export const Base: Story = {} |
47 changes: 47 additions & 0 deletions
47
packages/components/src/components/ScalarFloating/ScalarFloating.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
<script setup lang="ts"> | ||
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue' | ||
import { type Ref, computed, ref } from 'vue' | ||
import type { FloatingOptions } from './types' | ||
import { useResizeWithTarget } from './useResizeWithTarget' | ||
const props = defineProps<FloatingOptions>() | ||
defineOptions({ inheritAttrs: false }) | ||
const floatingRef: Ref<HTMLElement | null> = ref(null) | ||
const wrapperRef: Ref<HTMLElement | null> = ref(null) | ||
/** Fallback to div wrapper if a button element is not provided */ | ||
const targetRef = computed( | ||
() => (wrapperRef.value?.children?.[0] || wrapperRef.value) ?? undefined, | ||
) | ||
const resize = computed(() => props.resize) | ||
const { width: targetWidth } = useResizeWithTarget(targetRef, { | ||
enabled: resize, | ||
}) | ||
const placement = computed(() => props.placement || 'bottom') | ||
const { floatingStyles } = useFloating(targetRef, floatingRef, { | ||
placement, | ||
whileElementsMounted: autoUpdate, | ||
middleware: [offset(5), flip(), shift()], | ||
}) | ||
</script> | ||
<template> | ||
<div | ||
ref="wrapperRef" | ||
:class="{ contents: !!$slots.default }"> | ||
<slot /> | ||
</div> | ||
<div | ||
ref="floatingRef" | ||
class="relative z-context" | ||
:style="floatingStyles" | ||
v-bind="$attrs"> | ||
<slot | ||
name="floating" | ||
:width="targetWidth" /> | ||
</div> | ||
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export { default as ScalarFloating } from './ScalarFloating.vue' | ||
|
||
export type { FloatingOptions } from './types' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import type { Placement } from '@floating-ui/utils' | ||
|
||
export type FloatingOptions = { | ||
/** The placement of the floating element */ | ||
placement?: Placement | ||
/** Whether or not track the target's width */ | ||
resize?: boolean | ||
} |
37 changes: 37 additions & 0 deletions
37
packages/components/src/components/ScalarFloating/useResizeWithTarget.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import { describe, expect, it } from 'vitest' | ||
import { ref } from 'vue' | ||
|
||
import { useResizeWithTarget } from './useResizeWithTarget' | ||
|
||
/** | ||
* Unfortunately we can't test the ResizeObserver with vitest but we can test the hook options | ||
*/ | ||
describe('useResizeWithTarget', () => { | ||
it('returns a width by default', async () => { | ||
const el = ref<Element>() | ||
const { width } = useResizeWithTarget(el) | ||
|
||
expect(width.value).toBe('0px') | ||
}) | ||
|
||
it('returns undefined if enabled is false', async () => { | ||
const el = ref<Element>() | ||
const { width } = useResizeWithTarget(el, { enabled: ref(false) }) | ||
|
||
expect(width.value).toBeUndefined() | ||
}) | ||
|
||
it('is reactive to changing the enabled ref', async () => { | ||
const el = ref<Element>() | ||
const enabled = ref(false) | ||
|
||
const { width } = useResizeWithTarget(el, { enabled }) | ||
expect(width.value).toBeUndefined() | ||
|
||
enabled.value = true | ||
expect(width.value).toBe('0px') | ||
|
||
enabled.value = false | ||
expect(width.value).toBeUndefined() | ||
}) | ||
}) |
35 changes: 35 additions & 0 deletions
35
packages/components/src/components/ScalarFloating/useResizeWithTarget.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { type MaybeRefOrGetter, computed, ref, toValue, watch } from 'vue' | ||
|
||
type ResizeOptions = { | ||
enabled?: MaybeRefOrGetter<boolean> | ||
} | ||
|
||
export function useResizeWithTarget( | ||
target: MaybeRefOrGetter<Element | undefined>, | ||
opts: ResizeOptions = { enabled: ref(true) }, | ||
) { | ||
const targetWidth = ref(0) | ||
const observer = ref<ResizeObserver>() | ||
|
||
if (typeof ResizeObserver !== 'undefined') | ||
observer.value = new ResizeObserver(([entry]) => { | ||
if (!entry) return | ||
targetWidth.value = entry.borderBoxSize[0]?.inlineSize ?? 0 | ||
}) | ||
|
||
watch( | ||
[() => toValue(opts.enabled), () => toValue(target)], | ||
([enabled, element]) => { | ||
if (!element || !observer.value) return | ||
if (enabled) observer.value.observe(element) | ||
else observer.value.disconnect() | ||
}, | ||
{ immediate: true }, | ||
) | ||
|
||
return { | ||
width: computed(() => | ||
toValue(opts.enabled) ? `${targetWidth.value}px` : undefined, | ||
), | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.