diff --git a/packages/headless/package.json b/packages/headless/package.json index 396cf967b8f..0222e703295 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -21,6 +21,10 @@ "import": "./dist/primitives/popover/index.js", "types": "./dist/primitives/popover/index.d.ts" }, + "./select": { + "import": "./dist/primitives/select/index.js", + "types": "./dist/primitives/select/index.d.ts" + }, "./dialog": { "import": "./dist/primitives/dialog/index.js", "types": "./dist/primitives/dialog/index.d.ts" diff --git a/packages/headless/src/primitives/select/README.md b/packages/headless/src/primitives/select/README.md new file mode 100644 index 00000000000..d10edea5930 --- /dev/null +++ b/packages/headless/src/primitives/select/README.md @@ -0,0 +1,163 @@ +# Select + +A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native ``. Defaults to `true`. + */ + alignItemWithTrigger?: boolean; + placement?: Placement; + sideOffset?: number; + children: ReactNode; +} + +function alignSelectedItem(selectedItemRef: RefObject): Middleware { + return { + name: 'alignSelectedItem', + fn({ elements }) { + const selectedEl = selectedItemRef.current; + if (!selectedEl) return {}; + + const floatingRect = elements.floating.getBoundingClientRect(); + const selectedRect = selectedEl.getBoundingClientRect(); + const referenceRect = (elements.reference as HTMLElement).getBoundingClientRect(); + + const itemOffsetInPopup = selectedRect.top - floatingRect.top; + const desiredTop = referenceRect.top - itemOffsetInPopup; + + const viewportHeight = window.innerHeight; + const clampedTop = Math.max(8, Math.min(desiredTop, viewportHeight - floatingRect.height - 8)); + + return { + x: referenceRect.left, + y: clampedTop, + }; + }, + }; +} + +function SelectInner(props: SelectProps) { + const { + items, + alignItemWithTrigger: alignProp = true, + placement: placementProp = 'bottom-start', + sideOffset = 4, + children, + } = props; + + const nodeId = useFloatingNodeId(); + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const [selectedValue, setSelectedValue] = useControllableState( + props.value, + props.defaultValue, + props.onValueChange as ((value: string | undefined) => void) | undefined, + ); + + const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedLabel, setSelectedLabel] = useState(null); + const [activeIndex, setActiveIndex] = useState(null); + + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const arrowRef = useRef(null); + const popupRef = useRef(null); + const valueToLabelRef = useRef>(new Map()); + const selectedItemRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(alignProp ? 0 : sideOffset), + ...(!alignProp ? [flip(), shift({ padding: 5 })] : []), + size({ + apply({ availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxHeight: `${availableHeight}px`, + }); + }, + }), + ...(!alignProp ? [arrow({ element: arrowRef })] : []), + ...(alignProp ? [alignSelectedItem(selectedItemRef)] : []), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const handleSelect = useCallback( + (value: string, index: number) => { + setSelectedValue(value); + setSelectedIndex(index); + setSelectedLabel(valueToLabelRef.current.get(value) ?? value); + setOpen(false); + }, + [setSelectedValue, setOpen], + ); + + const handleTypeaheadMatch = useCallback( + (index: number | null) => { + if (open) { + setActiveIndex(index); + } else if (index !== null) { + setSelectedIndex(index); + } + }, + [open], + ); + + const click = useClick(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext, { role: 'listbox' }); + const listNav = useListNavigation(floatingContext, { + listRef: elementsRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + loop: true, + }); + const typeahead = useTypeahead(floatingContext, { + listRef: labelsRef, + activeIndex, + selectedIndex, + onMatch: handleTypeaheadMatch, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, + dismiss, + role, + listNav, + typeahead, + ]); + + const contextValue = useMemo( + () => ({ + open, + items, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + setActiveIndex, + selectedIndex, + selectedValue, + selectedLabel, + elementsRef, + labelsRef, + popupRef, + arrowRef, + valueToLabelRef, + selectedItemRef, + alignItemWithTrigger: alignProp, + handleSelect, + mounted, + transitionProps, + }), + [ + open, + items, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + setActiveIndex, + selectedIndex, + selectedValue, + selectedLabel, + alignProp, + handleSelect, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +export function SelectRoot(props: SelectProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/select/select-trigger.tsx b/packages/headless/src/primitives/select/select-trigger.tsx new file mode 100644 index 00000000000..2f3fa9711ab --- /dev/null +++ b/packages/headless/src/primitives/select/select-trigger.tsx @@ -0,0 +1,31 @@ +'use client'; + +import React from 'react'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export interface SelectTriggerProps extends ComponentProps<'button'> {} + +export function SelectTrigger(props: SelectTriggerProps) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useSelectContext(); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'select-trigger', + ref: refs.setReference, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/select/select-value.tsx b/packages/headless/src/primitives/select/select-value.tsx new file mode 100644 index 00000000000..d8d7f2b8161 --- /dev/null +++ b/packages/headless/src/primitives/select/select-value.tsx @@ -0,0 +1,44 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import type { SelectItem } from './select-context'; +import { useSelectContext } from './select-context'; + +function resolveLabel( + value: string | undefined, + items: SelectItem[] | undefined, + valueToLabelRef: React.MutableRefObject>, +): string | null { + if (value === undefined) return null; + if (items) { + const item = items.find(i => i.value === value); + if (item) return item.label; + } + const label = valueToLabelRef.current.get(value); + if (label) return label; + return value; +} + +export interface SelectValueProps extends ComponentProps<'span'> { + placeholder?: ReactNode; +} + +export function SelectValue(props: SelectValueProps) { + const { render, placeholder, ...otherProps } = props; + const { selectedValue, selectedLabel, items, valueToLabelRef } = useSelectContext(); + + const displayText = + selectedValue !== undefined ? (selectedLabel ?? resolveLabel(selectedValue, items, valueToLabelRef)) : placeholder; + + const defaultProps = { + 'data-cl-slot': 'select-value', + children: displayText, + }; + + return renderElement({ + defaultTagName: 'span', + render, + props: mergeProps<'span'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/select/select.test.tsx b/packages/headless/src/primitives/select/select.test.tsx new file mode 100644 index 00000000000..26097591ac1 --- /dev/null +++ b/packages/headless/src/primitives/select/select.test.tsx @@ -0,0 +1,704 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { axe } from '../../test-utils/axe'; +import { Select, type SelectItem } from './index'; + +afterEach(() => cleanup()); + +const fruits: SelectItem[] = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, +]; + +function renderSelect(props: Partial> = {}) { + const { children, ...rest } = props as Record; + return render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); +} + +describe('Select', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderSelect(); + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveAttribute('data-cl-slot', 'select-trigger'); + }); + + it('renders value with data-cl-slot', () => { + renderSelect(); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value).toBeInTheDocument(); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderSelect({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + const popup = document.querySelector('[data-cl-slot="select-popup"]'); + const options = document.querySelectorAll('[data-cl-slot="select-option"]'); + + expect(positioner).toBeInTheDocument(); + expect(popup).toBeInTheDocument(); + expect(options).toHaveLength(3); + }); + }); + + describe('items prop and label resolution', () => { + it('shows placeholder when no value is selected', () => { + renderSelect(); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Pick a fruit...'); + }); + + it('resolves label from items before options mount', () => { + renderSelect({ defaultValue: 'banana' }); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Banana'); + }); + + it('resolves label for controlled value from items', () => { + renderSelect({ value: 'cherry' }); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Cherry'); + }); + + it('falls back to raw value when no items provided', () => { + render( + + + + + + + + Banana + + + + , + ); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('banana'); + }); + + it('updates label after selection via option registry', async () => { + const user = userEvent.setup(); + // No items prop — labels only known once options mount + render( + + + + + + + + Special Item + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Special Item')); + + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Special Item'); + }); + }); + + describe('open/close', () => { + it('opens on trigger click', async () => { + const user = userEvent.setup(); + renderSelect(); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(document.querySelector('[data-cl-slot="select-popup"]')).toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderSelect({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderSelect({ onOpenChange }); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('selection', () => { + it('selects option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + renderSelect({ onValueChange }); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + const option = screen.getByText('Banana'); + await user.click(option); + + expect(onValueChange).toHaveBeenCalledWith('banana'); + }); + + it('displays selected label in Value after selection', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Cherry')); + + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toContain('Cherry'); + }); + }); + + describe('keyboard navigation', () => { + it('navigates options with arrow keys', async () => { + const user = userEvent.setup(); + renderSelect(); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + + it('scrolls options into view on arrow key navigation', async () => { + const manyItems = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + const user = userEvent.setup(); + render( + + + + + + +
+ {manyItems.map(({ label, value }) => ( + + {label} + + ))} +
+
+
+
, + ); + + await user.click(screen.getByRole('combobox')); + + // Navigate down through many items to force scrolling + for (let i = 0; i < 15; i++) { + await user.keyboard('{ArrowDown}'); + } + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + + // The active item should be visible within its scroll container + const scrollContainer = activeOption!.closest('div[style]') as HTMLElement; + const optionRect = activeOption!.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + + expect(optionRect.bottom).toBeLessThanOrEqual(containerRect.bottom + 1); + expect(optionRect.top).toBeGreaterThanOrEqual(containerRect.top - 1); + }); + + it('scrolls selected item into view when reopening', async () => { + const manyItems = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + const user = userEvent.setup(); + render( + + + + + + +
+ {manyItems.map(({ label, value }) => ( + + {label} + + ))} +
+
+
+
, + ); + + const trigger = screen.getByRole('combobox'); + + // Open, navigate to item near the bottom, select it + await user.click(trigger); + for (let i = 0; i < 15; i++) { + await user.keyboard('{ArrowDown}'); + } + await user.keyboard('{Enter}'); + + // Reopen — the selected item should be scrolled into view + await user.click(trigger); + + const selectedOption = document.querySelector('[data-cl-slot="select-option"][data-cl-selected]'); + expect(selectedOption).toBeInTheDocument(); + + const scrollContainer = selectedOption!.closest('div[style]') as HTMLElement; + const optionRect = selectedOption!.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + + expect(optionRect.bottom).toBeLessThanOrEqual(containerRect.bottom + 1); + expect(optionRect.top).toBeGreaterThanOrEqual(containerRect.top - 1); + }); + }); + + describe('option state attributes', () => { + it('marks selected option with data-cl-selected', () => { + renderSelect({ defaultValue: 'banana', defaultOpen: true }); + + const options = document.querySelectorAll('[data-cl-slot="select-option"]'); + expect(options[1]).toHaveAttribute('data-cl-selected', ''); + }); + + it('marks active option with data-cl-active', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + }); + + describe('disabled option', () => { + it('renders disabled option with data-cl-disabled', async () => { + const user = userEvent.setup(); + render( + + + + + + + + Apple + + + Banana + + + + , + ); + + await user.click(screen.getByRole('combobox')); + + const disabledOption = screen.getByText('Banana').closest("[data-cl-slot='select-option']"); + expect(disabledOption).toHaveAttribute('data-cl-disabled', ''); + expect(disabledOption).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not select disabled option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + + + + Apple + + + Banana + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Banana')); + + expect(onValueChange).not.toHaveBeenCalledWith('banana'); + }); + }); + + describe('ARIA attributes', () => { + it('options have role=option', () => { + renderSelect({ defaultOpen: true }); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + }); + + it('selected option has aria-selected=true', () => { + renderSelect({ defaultValue: 'apple', defaultOpen: true }); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderSelect(); + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + + const popup = document.querySelector('[data-cl-slot="select-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderSelect({ open: true }); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderSelect({ open: false }); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).not.toBeInTheDocument(); + }); + }); + + describe('alignItemWithTrigger', () => { + it('defaults to true', () => { + // Render without explicitly setting alignItemWithTrigger + render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + // The positioner should render (alignItemWithTrigger doesn't prevent rendering) + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toBeInTheDocument(); + }); + + it('uses standard floating styles when disabled', async () => { + const user = userEvent.setup(); + renderSelect({ alignItemWithTrigger: false }); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]') as HTMLElement; + // Standard Floating UI positioning uses position: absolute with transform + expect(positioner.style.position).toBe('absolute'); + }); + + const manyItems: SelectItem[] = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + it('aligns selected item with trigger vertically', async () => { + render( + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + const trigger = screen.getByRole('combobox'); + const selectedOption = document.querySelector('[data-cl-slot="select-option"][data-cl-selected]'); + expect(selectedOption).toBeInTheDocument(); + + const triggerRect = trigger.getBoundingClientRect(); + const selectedRect = selectedOption!.getBoundingClientRect(); + + // The selected item should be positioned near the trigger's vertical position + expect(Math.abs(selectedRect.top - triggerRect.top)).toBeLessThan(50); + }); + + // Requires real layout engine — getBoundingClientRect returns 0 in happy-dom + it.skip('repositions when ancestor scrolls', async () => { + const user = userEvent.setup(); + render( +
+ + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + +
+
, + ); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]') as HTMLElement; + const initialTop = positioner.getBoundingClientRect().top; + + // Scroll the container + const scrollContainer = screen.getByTestId('scroll-container'); + scrollContainer.scrollTop = 100; + scrollContainer.dispatchEvent(new Event('scroll')); + + // autoUpdate repositions on scroll — wait for the update + await waitFor(() => { + const newTop = positioner.getBoundingClientRect().top; + expect(newTop).not.toBe(initialTop); + }); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + + it('has no violations with a selected value', async () => { + const { container } = render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + }); +}); diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index dc952e6935a..b8a3b38b1d3 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'primitives/tabs/index': 'src/primitives/tabs/index.ts', 'primitives/tooltip/index': 'src/primitives/tooltip/index.ts', 'primitives/popover/index': 'src/primitives/popover/index.ts', + 'primitives/select/index': 'src/primitives/select/index.ts', 'primitives/dialog/index': 'src/primitives/dialog/index.ts', 'utils/index': 'src/utils/index.ts', 'hooks/use-controllable-state': 'src/hooks/use-controllable-state.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd48005d683..422df3d2e3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21592,6 +21592,71 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23