Files
Alaister Young ec1029dff0 chore: migrate from clsx + tailwind-merge to shadcn-ui/cn (#49938)
Migrates the repo off `clsx` + `tailwind-merge` to
[shadcn-ui/cn](https://github.com/shadcn-ui/cn). Every app and package
already gets `cn` from `packages/ui`, so the swap happens in that one
helper and flows through to Studio, docs, www, and the rest.

**Changed:**
- `packages/ui` `cn` helper now uses `createCn` from `cn/config`,
keeping the custom `card`/`content` spacing scale so `p-card` still
overrides `p-4`. It has an explicit signature and re-exports
`ClassValue`.
- The four www Launch Week files that imported the `ClassValue` type
from `clsx` now import it from `ui`.
- `blocks/vue` local `lib/utils.ts` re-exports `cn` from the package.
- Comments/README that referenced tailwind-merge.

**Removed:**
- Direct `clsx` and `tailwind-merge` deps from `ui`, `ui-patterns`,
`www`, and `blocks/vue`. `ui-patterns` and `www` declared them without
importing.

**Added:**
- `packages/ui/src/lib/utils/cn.test.ts` covering clsx-style joining,
conflict resolution, the custom spacing scale, and variant handling.

Not migrated: the standalone apps under `examples/`. They're outside the
workspace and mostly on Tailwind v3, which `cn` doesn't support.

Lockfile note: after merging master, the lockfile diff is only the
intended swap (`clsx` and `tailwind-merge` out, `cn@0.2.5` in).
`tailwind-merge` stays in the lockfile as a transitive dep of a
third-party package.

Release-age note: this sat in draft with a temporary
`minimumReleaseAgeExclude` entry for `cn` while `cn` was inside the
workspace's 3-day `minimumReleaseAge` window. That window has closed, so
the exclusion is gone and nothing bypasses the release-age gate.

## To test

- `pnpm install --frozen-lockfile` succeeds with no
`minimumReleaseAgeExclude` entry for `cn`.
- `pnpm --filter ui test` – new `cn.test.ts` passes, including
`cn('p-4', 'p-card')` → `p-card`.
- Typecheck passes for studio, ui, ui-patterns, vue-blocks. www
typecheck panics under tsgo on master already (pre-existing, unrelated);
it passes with the JS `tsc` binary.
- Spot-check Studio locally: class overrides still win in the usual
places (e.g. `CodeEditor` height, `Button` variants with a custom
`className`).

https://claude.ai/code/session_01MkAt16tsPRDTm9oB5Jr8Ub


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Improvements**
* Standardized Tailwind class merging across shared UI utilities while
preserving conditional classes, custom spacing classes, and variant
behavior.
* Updated related components and examples to use the standardized
class-merging utility.

* **Tests**
* Added coverage for conditional class handling, conflicting utility
resolution, custom spacing classes, and variant separation.

* **Documentation**
* Updated usage guidance to reflect the standardized Tailwind
class-merging approach.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-09-07 21:35:06 +08:00

289 lines
9.0 KiB
TypeScript

import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react'
import { merge, noop } from 'lodash'
import { Loader2 } from 'lucide-react'
import type { editor } from 'monaco-editor'
import { RefObject, useEffect, useRef, useState } from 'react'
import { cn } from 'ui'
import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
import {
alignEditor,
BASE_MONACO_EDITOR_OPTIONS,
getEditorValueOrSelection,
} from './CodeEditor.utils'
import { Markdown } from '@/components/interfaces/Markdown'
import { useLatest } from '@/hooks/misc/useLatest'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { formatSql } from '@/lib/formatSql'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
type CodeEditorActions = { enabled: boolean; callback: (value: any) => void }
const DEFAULT_ACTIONS = {
runQuery: { enabled: false, callback: noop },
formatDocument: { enabled: true, callback: noop },
placeholderFill: { enabled: true },
}
export type ValidLanguages =
| 'pgsql'
| 'json'
| 'html'
| 'typescript'
| 'javascript'
| 'css'
| 'csv'
| 'plaintext'
| 'markdown'
interface CodeEditorProps {
id?: string
language: ValidLanguages
autofocus?: boolean
defaultValue?: string
isReadOnly?: boolean
hideLineNumbers?: boolean
className?: string
wrapperClassName?: string
placeholderClassName?: string
loading?: boolean
options?: EditorProps['options']
value?: string
placeholder?: string
/* Determines what actions to add for code editor context menu */
actions?: Partial<{
runQuery: CodeEditorActions
formatDocument: CodeEditorActions
placeholderFill: Omit<CodeEditorActions, 'callback'>
}>
editorRef?: RefObject<editor.IStandaloneCodeEditor | null>
monacoRef?: RefObject<Monaco | null>
onInputChange?: (value?: string) => void
/**
* Fired after CodeEditor's own mount setup runs, so wrappers can register
* additional actions/keybindings on the shared editor instance.
*/
onMount?: OnMount
}
export const CodeEditor = ({
id,
language,
defaultValue,
autofocus = true,
isReadOnly = false,
hideLineNumbers = false,
className,
wrapperClassName,
placeholderClassName,
loading,
options,
value,
placeholder,
actions,
editorRef: editorRefProps,
monacoRef: monacoRefProps,
onInputChange = noop,
onMount: onMountProps,
}: CodeEditorProps) => {
const monaco = useMonaco()
const { data: project } = useSelectedProjectQuery()
const hasValue = useRef<editor.IContextKey<boolean>>(null)
const ref = useRef<editor.IStandaloneCodeEditor>(null)
const editorRef = editorRefProps || ref
const internalMonacoRef = useRef<Monaco | null>(null)
const monacoRef = monacoRefProps || internalMonacoRef
const { runQuery, placeholderFill, formatDocument } = {
...DEFAULT_ACTIONS,
...actions,
}
// Monaco claims Cmd+K as a chord prefix, which swallows the global command menu
// shortcut while the editor is focused. CodeEditor intercepts it for every editor
// (see handleMount) so it behaves the same inside and outside the editor.
const commandMenuHotkeyEnabledRef = useLatest(
useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN)
)
const setCommandMenuOpenRef = useLatest(useSetCommandMenuOpen())
const runQueryCallbackRef = useLatest(runQuery.callback)
const showPlaceholderDefault = placeholder !== undefined && (value ?? '').trim().length === 0
const [showPlaceholder, setShowPlaceholder] = useState(showPlaceholderDefault)
const optionsMerged = merge(
{
...BASE_MONACO_EDITOR_OPTIONS,
domReadOnly: isReadOnly,
readOnly: isReadOnly,
lineNumbers: hideLineNumbers ? 'off' : undefined,
glyphMargin: hideLineNumbers ? false : undefined,
lineNumbersMinChars: hideLineNumbers ? 0 : 4,
folding: hideLineNumbers ? false : undefined,
},
options
)
const handleMount: OnMount = async (editor, monaco) => {
editorRef.current = editor
monacoRef.current = monaco
alignEditor(editor)
hasValue.current = editor.createContextKey('hasValue', false)
hasValue.current.set(value !== undefined && value.trim().length > 0)
setShowPlaceholder(showPlaceholderDefault)
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => {
if (commandMenuHotkeyEnabledRef.current) {
setCommandMenuOpenRef.current(true)
}
})
if (placeholderFill.enabled) {
editor.addCommand(
monaco.KeyCode.Tab,
() => {
editor.executeEdits('source', [
{
// @ts-ignore
identifier: 'add-placeholder',
range: new monaco.Range(1, 1, 1, 1),
text: (placeholder ?? '').split('\n\n').join('\n').replaceAll('&nbsp;', ' '),
},
])
},
'!hasValue'
)
}
if (runQuery.enabled) {
editor.addAction({
id: 'run-query',
label: 'Run Query',
keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
contextMenuGroupId: 'operation',
contextMenuOrder: 0,
run: () => {
if (!editorRef.current) return
runQueryCallbackRef.current(getEditorValueOrSelection(editorRef.current))
},
})
}
const model = editor.getModel()
if (model) {
const position = model.getPositionAt((value ?? '').length)
editor.setPosition(position)
}
// Run last so wrappers can register actions and have the final say on cursor
// position / focus before CodeEditor's own (timeout-deferred) autofocus.
onMountProps?.(editor, monaco)
// auto focus on mount
setTimeout(() => {
if (autofocus) {
if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 })
editor.focus()
}
}, 0)
}
const onChangeContent: OnChange = (value) => {
if (hasValue.current) {
hasValue.current.set((value ?? '').length > 0)
}
setShowPlaceholder(!value)
onInputChange(value)
}
useEffect(() => {
setShowPlaceholder(showPlaceholderDefault)
}, [showPlaceholderDefault])
useEffect(() => {
if (
placeholderFill.enabled &&
editorRef.current !== undefined &&
monacoRef.current !== undefined
) {
const editor = editorRef.current
if (editor == null) return
const monaco = monacoRef.current
if (monaco == null) return
editor.addCommand(
monaco.KeyCode.Tab,
() => {
editor.executeEdits('source', [
{
// @ts-ignore
identifier: 'add-placeholder',
range: new monaco.Range(1, 1, 1, 1),
text: (placeholder ?? ' ')
.split('\n\n')
.join('\n')
.replaceAll('*', '')
.replaceAll('&nbsp;', ''),
},
])
},
'!hasValue'
)
}
}, [placeholder, placeholderFill.enabled])
useEffect(() => {
if (monaco && project && formatDocument.enabled) {
const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', {
async provideDocumentFormattingEdits(model: any) {
const value = model.getValue()
const formatted = formatSql(value)
formatDocument.callback(formatted)
return [{ range: model.getFullModelRange(), text: formatted }]
},
})
return () => formatProvider.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [monaco, project, formatDocument.enabled])
return (
<>
<Editor
path={id}
theme="supabase"
// `h-full` keeps this wrapper filling its container even if a global `.monaco-editor`
// rule flips it to `position: absolute` (which happens after visiting GraphiQL, since it
// injects a second copy of Monaco's CSS onto the shared instance). Without an explicit
// height, an absolutely-positioned wrapper collapses to 0 and Monaco lays out at ~5px.
// Order matters: `h-full` is a default, so a caller-supplied height in `className`
// (e.g. `h-96`) wins via `cn` class merging instead of being clobbered.
className={cn('monaco-editor', 'h-full', className)}
wrapperProps={{ className: wrapperClassName }}
value={value ?? undefined}
language={language}
defaultValue={defaultValue ?? undefined}
loading={loading || <Loader2 className="animate-spin" strokeWidth={2} size={20} />}
options={optionsMerged}
onMount={handleMount}
onChange={onChangeContent}
/>
{placeholder !== undefined && (
<div
className={cn(
'monaco-placeholder absolute top-[5px] left-[57px] text-sm pointer-events-none font-mono',
'[&>div>p]:text-foreground-lighter [&>div>p]:m-0! tracking-tighter',
showPlaceholder ? 'block' : 'hidden',
placeholderClassName
)}
>
<Markdown content={placeholder} />
</div>
)}
</>
)
}