Skip to content

Commit

Permalink
feat: add context / dropdown menu to component library (#1557)
Browse files Browse the repository at this point in the history
* 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
hwkr and amritk committed May 2, 2024
1 parent 801ad65 commit 3191eae
Show file tree
Hide file tree
Showing 17 changed files with 369 additions and 24 deletions.
5 changes: 3 additions & 2 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,16 @@
],
"module": "./dist/index.js",
"dependencies": {
"@floating-ui/utils": "^0.2.2",
"@floating-ui/vue": "^1.0.2",
"@headlessui/vue": "^1.7.20",
"@scalar/oas-utils": "workspace:*",
"@storybook/test": "^8.0.8",
"@vueuse/core": "^10.9.0",
"class-variance-authority": "^0.7.0",
"cva": "1.0.0-beta.1",
"nanoid": "^5.0.1",
"prismjs": "^1.29.0",
"tailwind-merge": "^2.0.0"
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"@rise8/tailwind-pixel-perfect-preset": "^1.0.1",
Expand Down
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()
})
})
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 = {}
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>
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>
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>
1 change: 1 addition & 0 deletions packages/components/src/components/ScalarDropdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ScalarDropdown } from './ScalarDropdown.vue'
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()
})
})
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 = {}
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>
3 changes: 3 additions & 0 deletions packages/components/src/components/ScalarFloating/index.ts
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'
8 changes: 8 additions & 0 deletions packages/components/src/components/ScalarFloating/types.ts
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
}
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()
})
})
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,
),
}
}
1 change: 1 addition & 0 deletions packages/components/src/cva.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { prefix } from '../postcss.config'
const tw = extendTailwindMerge<typeof prefix>({
extend: {
classGroups: {
'font-size': ['text-xxs'],
// Add the scalar class prefix as a custom class to be deduped by tailwind-merge
[prefix]: [prefix],
},
Expand Down
16 changes: 16 additions & 0 deletions packages/components/src/tailwind/tailwind.theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export const theme = {
sans: 'var(--scalar-font)',
code: 'var(--scalar-font-code)',
},
zIndex: {
'-1': '-1',
'0': '0',
'1': '1',
// Contextual overlays like dropdowns, popovers, tooltips
'context': '1000',
// Full screen overlays / modals
'overlay': '10000',
},
} as const

export const extend = {
Expand All @@ -63,4 +72,11 @@ export const extend = {
'screen-md': '640px',
'screen-lg': '800px',
},
brightness: {
lifted: 'var(--scalar-lifted-brightness)',
backdrop: 'var(--scalar-backdrop-brightness)',
},
spacing: {
px: '1px',
},
} as const

0 comments on commit 3191eae

Please sign in to comment.