diff --git a/packages/eslint-plugin-react-pug/src/index.ts b/packages/eslint-plugin-react-pug/src/index.ts index ebed7b4..3cd776e 100644 --- a/packages/eslint-plugin-react-pug/src/index.ts +++ b/packages/eslint-plugin-react-pug/src/index.ts @@ -12,6 +12,7 @@ import { mapGeneratedRangeToOriginal, offsetToLineColumn, rewriteSegmentedPugRegions, + type BoundaryMappedExpression, type RewrittenPugRegionsResult, type RegionFormattingContext, type StartupjsCssxjsOption, @@ -208,6 +209,38 @@ function containsJsxSyntax(text: string, filename: string): boolean { } } +function unwrapRootExpression(node: any): any { + let current = node; + while ( + current + && typeof current === 'object' + && ( + current.type === 'ParenthesizedExpression' + || current.type === 'TSAsExpression' + || current.type === 'TSTypeAssertion' + || current.type === 'TSNonNullExpression' + ) + ) { + current = current.expression; + } + return current; +} + +function isRootJsxExpression(text: string, filename: string): boolean { + try { + const expr = parse(`const __pug = ${text}\n`, { + sourceType: 'module', + plugins: getExpressionParserPlugins(filename), + createParenthesizedExpressions: true, + errorRecovery: false, + }) as any; + const root = unwrapRootExpression(expr.program.body[0]?.declarations?.[0]?.init); + return root?.type === 'JSXElement' || root?.type === 'JSXFragment'; + } catch { + return false; + } +} + function collectLegacyStyleStatementRanges(text: string, filename: string): InsertionOffsetRange[] { try { const ast = parse(text, { @@ -307,12 +340,30 @@ function normalizeJsxClosingBracketIndent(text: string): string { return lines.join('\n'); } -function formatPugRegionForLint( - expr: string, - baseIndent: string, - formattingContext: RegionFormattingContext, - filename: string, -): { code: string; boundaryMap: number[] } { +function normalizeSyntheticWrapperClosingIndent( + text: string, + containerKind: RegionFormattingContext['containerKind'], +): string { + const lines = text.split('\n'); + if (lines.length < 3) return text; + if (lines[0].trim() !== '(') return text; + if (lines[lines.length - 1].trim() !== ')') return text; + + const firstContentLine = lines + .slice(1, -1) + .find(line => line.trim().length > 0); + if (!firstContentLine) return text; + + const contentIndent = firstContentLine.match(/^[ \t]*/)?.[0] ?? ''; + const shouldAlignWithContent = ( + containerKind === 'conditional-branch' + || containerKind === 'logical-operand' + ); + lines[lines.length - 1] = `${shouldAlignWithContent ? contentIndent : ''})`; + return lines.join('\n'); +} + +function applyFormatterLintPasses(text: string, filename: string): string { const lintConfig: any[] = [{ files: FLAT_LINT_FILES, ...FORMAT_RULE_CONFIG, @@ -325,8 +376,7 @@ function formatPugRegionForLint( } : {}), }]; - const wrapper = createFormattingWrapper(expr, formattingContext.containerKind); - const prettyWrapped = prettier.format(wrapper, { + const pretty = prettier.format(text, { parser: isTypeScriptLikeFilename(filename) ? 'babel-ts' : 'babel', semi: false, singleQuote: true, @@ -334,30 +384,97 @@ function formatPugRegionForLint( trailingComma: 'none', bracketSameLine: false, }); - - const fixedWrapped = formatLinter.verifyAndFix(prettyWrapped, lintConfig, getFormatterLintFilename(filename)).output; - const normalizedWrapped = normalizeJsxClosingBracketIndent(fixedWrapped); - const refixedWrapped = formatLinter.verifyAndFix( - normalizedWrapped, + const fixed = formatLinter.verifyAndFix(pretty, lintConfig, getFormatterLintFilename(filename)).output; + const normalized = normalizeJsxClosingBracketIndent(fixed); + const refixed = formatLinter.verifyAndFix( + normalized, lintConfig, getFormatterLintFilename(filename), ).output; - const finalWrapped = formatLinter.verifyAndFix( - normalizeJsxClosingBracketIndent(refixedWrapped), + + return formatLinter.verifyAndFix( + normalizeJsxClosingBracketIndent(refixed), lintConfig, getFormatterLintFilename(filename), ).output; +} + +function normalizeFormattedExpressionForLint( + text: string, + wrapperLineIndentWidth: number, + formattingContext: RegionFormattingContext, + filename: string, +): { + code: string; + wrapperLineIndentWidth: number; + hasSyntheticWrapperLines?: boolean; +} { + if ( + formattingContext.containerKind !== 'standalone' + && text.includes('\n') + && isRootJsxExpression(text, filename) + ) { + const wrapped = applyFormatterLintPasses(`const __pug = (${text})\n`, filename); + const extracted = extractFormattedExpressionFromWrapper(wrapped, 'variable-init', filename); + if (extracted) { + const normalizedCode = normalizeSyntheticWrapperClosingIndent( + extracted.code, + formattingContext.containerKind, + ); + const lastNewline = extracted.code.lastIndexOf('\n'); + return { + code: normalizedCode, + wrapperLineIndentWidth: extracted.wrapperLineIndentWidth, + hasSyntheticWrapperLines: lastNewline >= 0, + }; + } + } + + return { + code: text, + wrapperLineIndentWidth, + }; +} + +function getSyntheticWrapperLineRanges(text: string): InsertionOffsetRange[] { + const firstNewline = text.indexOf('\n'); + const lastNewline = text.lastIndexOf('\n'); + if (firstNewline < 0 || lastNewline < 0 || firstNewline === lastNewline) return []; + + return [ + { start: 0, end: firstNewline + 1 }, + { start: lastNewline + 1, end: text.length }, + ]; +} + +function formatPugRegionForLint( + expr: string, + baseIndent: string, + formattingContext: RegionFormattingContext, + filename: string, +): BoundaryMappedExpression { + const wrapper = createFormattingWrapper(expr, formattingContext.containerKind); + const finalWrapped = applyFormatterLintPasses(wrapper, filename); const extracted = extractFormattedExpressionFromWrapper(finalWrapped, formattingContext.containerKind, filename); - const body = rebaseFormattedRegion( + const normalized = normalizeFormattedExpressionForLint( extracted?.code ?? expr, - baseIndent, extracted?.wrapperLineIndentWidth ?? 0, + formattingContext, + filename, + ); + const body = rebaseFormattedRegion( + normalized.code, + baseIndent, + normalized.wrapperLineIndentWidth, ); return { code: body, boundaryMap: buildExpressionBoundaryMap(expr, body, filename), + syntheticRanges: normalized.hasSyntheticWrapperLines + ? getSyntheticWrapperLineRanges(body) + : undefined, }; } @@ -424,10 +541,24 @@ function mapLintFix( }; } -function overlapsRangeList(ranges: InsertionOffsetRange[], start: number, end: number): boolean { +function overlapsRangeList( + ranges: InsertionOffsetRange[] | null | undefined, + start: number, + end: number, +): boolean { + if (!ranges || ranges.length === 0) return false; return ranges.some(range => start < range.end && end > range.start); } +function overlapsFormattedSyntheticRegion( + formatted: RewrittenPugRegionsResult | null, + start: number, + end: number, +): boolean { + if (!formatted) return false; + return formatted.regionSegments.some(region => overlapsRangeList(region.syntheticRanges, start, end)); +} + function shouldSuppressOriginalRangeMessage( cached: CachedLintState, message: EslintLintMessage, @@ -474,19 +605,34 @@ function mapLintMessage( if (message.line == null || message.column == null) return message; + const formattedStart = cached.formatted + ? lineColumnToOffset(cached.formatted.code, message.line, message.column) + : null; + const formattedEnd = ( + cached.formatted + && message.endLine != null + && message.endColumn != null + ) + ? lineColumnToOffset(cached.formatted.code, message.endLine, message.endColumn) + : null; + + if (formattedStart != null && overlapsFormattedSyntheticRegion( + cached.formatted, + formattedStart, + Math.max(formattedStart + 1, formattedEnd ?? (formattedStart + 1)), + )) { + return null; + } + const generatedStart = cached.formatted - ? cached.formatted.mapRewrittenOffsetToBase( - lineColumnToOffset(cached.formatted.code, message.line, message.column), - ) + ? cached.formatted.mapRewrittenOffsetToBase(formattedStart!) : lineColumnToOffset(cached.transformed.code, message.line, message.column); if (generatedStart == null) return message; const generatedEnd = (message.endLine != null && message.endColumn != null) ? ( cached.formatted - ? cached.formatted.mapRewrittenOffsetToBase( - lineColumnToOffset(cached.formatted.code, message.endLine, message.endColumn), - ) + ? cached.formatted.mapRewrittenOffsetToBase(formattedEnd!) : lineColumnToOffset(cached.transformed.code, message.endLine, message.endColumn) ) : generatedStart + 1; diff --git a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts index 4a848d3..d9704cd 100644 --- a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts @@ -15,6 +15,10 @@ const reactHooksStubPlugin = { meta: { schema: [] }, create: () => ({}), }, + 'exhaustive-deps': { + meta: { schema: [] }, + create: () => ({}), + }, }, } @@ -89,22 +93,7 @@ describe('eslint --fix integration for react-pug processor', () => { message: message.message, })) )) - expect(allMessages).toEqual([ - { - filePath: 'src/StartupjsUiMdxComponents.js', - ruleId: 'react/jsx-boolean-value', - line: 205, - column: 34, - message: 'Value must be omitted for boolean attribute `value`', - }, - { - filePath: 'src/StartupjsUiMdxComponents.js', - ruleId: 'react/jsx-boolean-value', - line: 257, - column: 27, - message: 'Value must be omitted for boolean attribute `value`', - }, - ]) + expect(allMessages).toEqual([]) const fixedFiles = [ 'src/App.tsx', @@ -113,10 +102,13 @@ describe('eslint --fix integration for react-pug processor', () => { 'src/ModalScreen.tsx', 'src/RootLayout.tsx', 'src/StartupjsUiDialogsReadme.js', + 'src/StartupjsUiDropdown.tsx', 'src/StartupjsUiDraggableReadme.js', 'src/StartupjsLogin.js', 'src/StartupjsUiMdxComponents.js', + 'src/StartupjsUiMultiSelect.tsx', 'src/StartupjsUiPrompt.tsx', + 'src/StartupjsUiTextInput.tsx', 'src/StartupjsUiTypeCell.js', 'src/StartupjsUiWrapInput.tsx', 'src/StartupjsTabThree.js', diff --git a/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts b/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts index 61bd369..8ccc7d2 100644 --- a/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts @@ -13,6 +13,10 @@ const reactHooksStubPlugin = { meta: { schema: [] }, create: () => ({}), }, + 'exhaustive-deps': { + meta: { schema: [] }, + create: () => ({}), + }, }, } @@ -78,19 +82,25 @@ describe('eslint diagnostics for example-unformatted fixture', () => { const startupjsUiDialogsReadme = results.find(result => result.filePath.endsWith('/src/StartupjsUiDialogsReadme.js')) expect(startupjsUiDialogsReadme?.messages.some(message => message.ruleId === 'react/jsx-fragments')).toBe(false) + const startupjsUiDropdown = results.find(result => result.filePath.endsWith('/src/StartupjsUiDropdown.tsx')) + expect(startupjsUiDropdown?.messages.some(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toBe(false) + const startupjsUiDraggableReadme = results.find(result => result.filePath.endsWith('/src/StartupjsUiDraggableReadme.js')) expect(startupjsUiDraggableReadme?.messages.some(message => message.ruleId === 'no-unneeded-ternary')).toBe(false) const startupjsUiTypeCell = results.find(result => result.filePath.endsWith('/src/StartupjsUiTypeCell.js')) expect(startupjsUiTypeCell?.messages.some(message => message.ruleId === '@stylistic/no-multi-spaces')).toBe(false) + const startupjsUiTextInput = results.find(result => result.filePath.endsWith('/src/StartupjsUiTextInput.tsx')) + expect(startupjsUiTextInput?.messages.some(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toBe(false) + const startupjsUiPrompt = results.find(result => result.filePath.endsWith('/src/StartupjsUiPrompt.tsx')) expect(startupjsUiPrompt?.messages.some(message => message.ruleId === '@stylistic/indent')).toBe(false) const startupjsUiMdxComponents = results.find(result => result.filePath.endsWith('/src/StartupjsUiMdxComponents.js')) - expect(startupjsUiMdxComponents?.messages.map(message => message.ruleId)).toEqual([ - 'react/jsx-boolean-value', - 'react/jsx-boolean-value', - ]) + expect(startupjsUiMdxComponents?.messages.some(message => message.ruleId === '@stylistic/indent')).toBe(false) + + const startupjsUiMultiSelect = results.find(result => result.filePath.endsWith('/src/StartupjsUiMultiSelect.tsx')) + expect(startupjsUiMultiSelect?.messages.some(message => message.ruleId === '@stylistic/indent')).toBe(false) }, 30000) }) diff --git a/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts b/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts index a4ee7a9..e5ab2ee 100644 --- a/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts @@ -14,6 +14,10 @@ const reactHooksStubPlugin = { meta: { schema: [] }, create: () => ({}), }, + 'exhaustive-deps': { + meta: { schema: [] }, + create: () => ({}), + }, }, } @@ -39,6 +43,19 @@ function createStartupjsUiStyleEslint(fix: boolean): ESLint { 'react-pug': reactPugPlugin as any, }, rules: { + '@stylistic/jsx-indent': ['error', 2, { + checkAttributes: false, + indentLogicalExpressions: true, + }], + '@stylistic/jsx-wrap-multilines': ['error', { + declaration: 'parens-new-line', + assignment: 'parens-new-line', + return: 'parens-new-line', + arrow: 'ignore', + condition: 'ignore', + logical: 'ignore', + prop: 'ignore', + }], 'react/jsx-boolean-value': 'error', }, processor: 'react-pug/react-pug', @@ -51,10 +68,13 @@ describe('startupjs-ui regressions', () => { it('does not report false processor diagnostics for startupjs-ui repros', async () => { const files = [ resolve(fixtureRoot, 'StartupjsUiDialogsReadme.js'), + resolve(fixtureRoot, 'StartupjsUiDropdown.tsx'), resolve(fixtureRoot, 'StartupjsUiDraggableReadme.js'), resolve(fixtureRoot, 'StartupjsUiTypeCell.js'), + resolve(fixtureRoot, 'StartupjsUiTextInput.tsx'), resolve(fixtureRoot, 'StartupjsUiWrapInput.tsx'), resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), + resolve(fixtureRoot, 'StartupjsUiMultiSelect.tsx'), resolve(fixtureRoot, 'StartupjsUiPrompt.tsx'), ] @@ -69,22 +89,7 @@ describe('startupjs-ui regressions', () => { })) )) - expect(messages).toEqual([ - { - column: 34, - filePath: resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), - line: 205, - message: 'Value must be omitted for boolean attribute `value`', - ruleId: 'react/jsx-boolean-value', - }, - { - column: 27, - filePath: resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), - line: 257, - message: 'Value must be omitted for boolean attribute `value`', - ruleId: 'react/jsx-boolean-value', - }, - ]) + expect(messages).toEqual([]) }) it('still reports jsx-boolean-value for intrinsic boolean attrs inside pug', async () => { @@ -111,11 +116,14 @@ describe('startupjs-ui regressions', () => { it('does not rewrite startupjs-ui repros under eslint --fix', async () => { for (const relativePath of [ 'StartupjsUiDialogsReadme.js', + 'StartupjsUiDropdown.tsx', 'StartupjsUiDraggableReadme.js', 'StartupjsUiMdxComponents.js', 'StartupjsUiPrompt.tsx', + 'StartupjsUiTextInput.tsx', 'StartupjsUiTypeCell.js', 'StartupjsUiWrapInput.tsx', + 'StartupjsUiMultiSelect.tsx', ]) { const filePath = resolve(fixtureRoot, relativePath) const input = readFileSync(filePath, 'utf8') diff --git a/packages/eslint-plugin-react-pug/test/unit/processor.test.ts b/packages/eslint-plugin-react-pug/test/unit/processor.test.ts index 2ce5fec..04434ce 100644 --- a/packages/eslint-plugin-react-pug/test/unit/processor.test.ts +++ b/packages/eslint-plugin-react-pug/test/unit/processor.test.ts @@ -127,6 +127,96 @@ function lintStylisticIndent(code: string, filename = 'file.jsx') { ); } +function lintStartupjsUiStyle(code: string, filename = 'file.jsx') { + const linter = new Linter({ configType: 'flat' }); + return linter.verify( + code, + [{ + files: FLAT_LINT_FILES, + plugins: { + '@stylistic': stylisticPlugin as any, + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + ...(filename.endsWith('.ts') || filename.endsWith('.tsx') + ? { + parser: tsParser, + } + : {}), + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + rules: { + '@stylistic/indent': ['error', 2, { + SwitchCase: 1, + VariableDeclarator: 1, + outerIIFEBody: 1, + MemberExpression: 1, + FunctionDeclaration: { + parameters: 1, + body: 1, + }, + FunctionExpression: { + parameters: 1, + body: 1, + }, + CallExpression: { + arguments: 1, + }, + ArrayExpression: 1, + ObjectExpression: 1, + ImportDeclaration: 1, + flatTernaryExpressions: false, + ignoreComments: false, + ignoredNodes: [ + 'TemplateLiteral *', + 'JSXElement', + 'JSXElement > *', + 'JSXAttribute', + 'JSXIdentifier', + 'JSXNamespacedName', + 'JSXMemberExpression', + 'JSXSpreadAttribute', + 'JSXExpressionContainer', + 'JSXOpeningElement', + 'JSXClosingElement', + 'JSXFragment', + 'JSXOpeningFragment', + 'JSXClosingFragment', + 'JSXText', + 'JSXEmptyExpression', + 'JSXSpreadChild', + ], + offsetTernaryExpressions: true, + }], + '@stylistic/jsx-indent': ['error', 2, { + checkAttributes: false, + indentLogicalExpressions: true, + }], + '@stylistic/jsx-indent-props': ['error', 2], + '@stylistic/jsx-wrap-multilines': ['error', { + declaration: 'parens-new-line', + assignment: 'parens-new-line', + return: 'parens-new-line', + arrow: 'ignore', + condition: 'ignore', + logical: 'ignore', + prop: 'ignore', + }], + '@stylistic/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], + '@stylistic/jsx-closing-bracket-location': ['error', 'tag-aligned'], + '@stylistic/jsx-closing-tag-location': 'error', + '@stylistic/multiline-ternary': ['error', 'always-multiline'], + }, + }], + filename, + ); +} + function offsetToLineColumn(text: string, offset: number) { const before = text.slice(0, offset).split('\n'); return { @@ -307,6 +397,141 @@ describe('eslint-plugin-react-pug processor', () => { expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); }); + it('formats multiline jsx call-argument pug with parenthesized multiline JSX so closing tags stay aligned', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const rows = []', + 'rows.push(pug`', + ' View(', + ' key=index', + ' value=child.props.value', + ' onLayout=onLayoutActive', + ' )=_child', + '`)', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + + expect(code).toContain('rows.push(('); + expect(code).toContain(''); + + const lintMessages = lintStylisticIndent(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + expect(mapped.filter(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toEqual([]); + }); + + it('formats multiline fragment call-argument pug with parenthesized multiline JSX so closing tags stay aligned', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const view = renderWrapper({}, pug`', + ' Div Alpha', + ' Div Beta', + '`)', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + + expect(code).toContain('renderWrapper({}, ('); + expect(code).toContain(''); + + const lintMessages = lintStylisticIndent(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + expect(mapped.filter(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toEqual([]); + }); + + it('formats multiline jsx ternary branches without startupjs-ui indent drift', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const requiredAsterisk = required === true', + ' ? pug`', + " Text.required(aria-hidden=IS_WEB ? true : undefined)= ' *'", + ' `', + ' : null', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + const lintMessages = lintStartupjsUiStyle(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + + expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); + expect(mapped.filter(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toEqual([]); + }); + + it('formats multiline fragment branches inside ternaries without startupjs-ui indent drift', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const view = IS_WEB', + ' ? pug`', + ' MultiSelectInput(', + " part='input'", + ' focused=focused', + ' )', + ' `', + ' : pug`', + ' MultiSelectInput(', + " part='input'", + ' focused=focused', + ' )', + " Drawer.nativeListContent(visible=focused position='bottom')", + ' ScrollView', + ' each option, index in normalizedOptions', + ' = _renderListItem({ item: option, index })', + ' `', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + const lintMessages = lintStartupjsUiStyle(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + + expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); + expect(mapped.filter(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toEqual([]); + }); + + it('formats multiline jsx variable initializers without startupjs-ui indent drift', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const input = pug`', + ' Component(', + " part='wrapper'", + ' focused=focused', + ' )', + '`', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + const lintMessages = lintStartupjsUiStyle(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + + expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); + expect(mapped.filter(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toEqual([]); + }); + + it('formats multiline jsx arrow bodies without startupjs-ui indent drift', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const renderPopoverContent = () => pug`', + ' ScrollView(', + ' ref=refScroll', + ' showsVerticalScrollIndicator=false', + ' )', + ' = renderContent.current', + '`', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + const lintMessages = lintStartupjsUiStyle(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + + expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); + expect(mapped.filter(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toEqual([]); + }); + it('suppresses legacy styl warnings without hiding normal linting', () => { const processor = createReactPugProcessor(); const input = [ diff --git a/packages/react-pug-core/src/language/lintTransform.ts b/packages/react-pug-core/src/language/lintTransform.ts index 27697d7..4e885fd 100644 --- a/packages/react-pug-core/src/language/lintTransform.ts +++ b/packages/react-pug-core/src/language/lintTransform.ts @@ -18,6 +18,7 @@ export interface RewrittenRegionSegment { baseStart: number; baseEnd: number; boundaryMap: number[]; + syntheticRanges: InsertionOffsetRange[]; region: ShadowMappedRegion; formattingContext: RegionFormattingContext; } @@ -39,6 +40,7 @@ export interface SegmentedPugRegionsInput { export interface BoundaryMappedExpression { code: string; boundaryMap: number[]; + syntheticRanges?: InsertionOffsetRange[]; } export interface LintTransformResult extends RewrittenPugRegionsResult { @@ -681,6 +683,10 @@ export function rewriteMappedPugRegions( baseStart: region.shadowStart, baseEnd: region.shadowEnd, boundaryMap: rewritten.boundaryMap, + syntheticRanges: (rewritten.syntheticRanges ?? []).map(range => ({ + start: rewrittenStart + range.start, + end: rewrittenStart + range.end, + })), region, formattingContext: resolveFormattingContext(region.shadowStart, region.shadowEnd), }); @@ -806,6 +812,10 @@ export function rewriteSegmentedPugRegions( baseStart: region.rewrittenStart, baseEnd: region.rewrittenEnd, boundaryMap: rewritten.boundaryMap, + syntheticRanges: (rewritten.syntheticRanges ?? []).map(range => ({ + start: rewrittenStart + range.start, + end: rewrittenStart + range.end, + })), region: region.region, formattingContext: region.formattingContext, }); diff --git a/packages/react-pug-core/test/unit/lintTransform.test.ts b/packages/react-pug-core/test/unit/lintTransform.test.ts index df73dec..2ba68ea 100644 --- a/packages/react-pug-core/test/unit/lintTransform.test.ts +++ b/packages/react-pug-core/test/unit/lintTransform.test.ts @@ -215,6 +215,10 @@ describe('lintTransform', () => { return { code, boundaryMap: buildExpressionBoundaryMap(expr, code, 'file.jsx'), + syntheticRanges: [ + { start: 0, end: 2 }, + { start: code.length - 1, end: code.length }, + ], } }) @@ -225,6 +229,10 @@ describe('lintTransform', () => { const baseOffset = formatted.mapRewrittenOffsetToBase(rewrittenOffset) expect(baseOffset).not.toBeNull() expect(linted.code.slice(baseOffset!, baseOffset! + 'Child'.length)).toBe('Child') + expect(formatted.regionSegments[0].syntheticRanges).toEqual([ + { start: formatted.regionSegments[0].rewrittenStart, end: formatted.regionSegments[0].rewrittenStart + 2 }, + { start: formatted.regionSegments[0].rewrittenEnd - 1, end: formatted.regionSegments[0].rewrittenEnd }, + ]) }) it('exposes mapped synthetic style-call insertion ranges generically', () => { diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDropdown.tsx.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDropdown.tsx.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDropdown.tsx.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt index c99b293..69e487c 100644 --- a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt @@ -1,2 +1 @@ -205:34 error react/jsx-boolean-value Value must be omitted for boolean attribute `value` -257:27 error react/jsx-boolean-value Value must be omitted for boolean attribute `value` +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMultiSelect.tsx.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMultiSelect.tsx.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMultiSelect.tsx.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTextInput.tsx.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTextInput.tsx.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTextInput.tsx.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDropdown.tsx b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDropdown.tsx new file mode 100644 index 0000000..1fa25c1 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDropdown.tsx @@ -0,0 +1,313 @@ +import React, { useState, useRef, useImperativeHandle, useEffect, type ReactNode, type RefObject } from 'react' +import { + Dimensions, + UIManager, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, + type StyleProp, + type ViewStyle +} from 'react-native' +import { pug, observer, $ } from 'startupjs' +import { themed } from '@startupjs-ui/core' +import Drawer from '@startupjs-ui/drawer' +import Popover, { type PopoverRef } from '@startupjs-ui/popover' +import DropdownCaption from './components/Caption' +import DropdownItem from './components/Item' +import { useKeyboard } from './helpers' +import STYLES from './index.cssx.styl' + +export const _PropsJsonSchema = {/* DropdownProps */} + +export interface DropdownProps { + /** Ref to control dropdown programmatically */ + ref?: RefObject + /** Custom styles applied to the dropdown content container */ + style?: StyleProp + /** Custom styles applied to the caption wrapper */ + captionStyle?: StyleProp + /** Custom styles applied to the active item view */ + activeItemStyle?: StyleProp + /** Dropdown caption and items */ + children?: ReactNode + /** Currently selected value @default '' */ + value?: string | number + /** Popover position @default 'bottom' */ + position?: 'top' | 'bottom' | 'left' | 'right' + /** Popover attachment @default 'start' */ + attachment?: 'start' | 'center' | 'end' + /** Fallback placements order */ + placements?: any + /** Drawer items rendering variant @default 'buttons' */ + drawerVariant?: 'list' | 'buttons' | 'pure' + /** Title shown in list drawer variant */ + drawerListTitle?: string + /** Cancel button label in buttons drawer variant @default 'Cancel' */ + drawerCancelLabel?: string + /** Disable caption press */ + disabled?: boolean + /** Enable drawer behavior on small screens @default true */ + hasDrawer?: boolean + /** Show swipe responder zone in drawer */ + showDrawerResponder?: boolean + /** Called when item is selected */ + onChange?: (value: string | number | undefined) => void + /** Called when dropdown is dismissed via overlay/cancel */ + onDismiss?: () => void +} + +export interface DropdownRef { + /** Open dropdown programmatically */ + open: () => void + /** Close dropdown programmatically */ + close: () => void +} + +// TODO: key event change scroll +function Dropdown ({ + style = [], + captionStyle, + activeItemStyle, + children, + value = '', + position = 'bottom', + attachment = 'start', + placements, + drawerVariant = 'buttons', + drawerListTitle = '', + drawerCancelLabel = 'Cancel', + disabled, + hasDrawer = true, + showDrawerResponder, + onChange, + onDismiss, + ref +}: DropdownProps): ReactNode { + const popoverRef = useRef(null) + const refScroll = useRef(null) + const renderContent = useRef([]) + const closeReason = useRef(null) + + const $isShow = $(false) + const [activeInfo, setActiveInfo] = useState(null) + const $layoutWidth = $( + Math.min(Dimensions.get('window').width, Dimensions.get('screen').width) + ) + + const [selectIndexValue] = useKeyboard({ + value, + isShow: $isShow.get(), + renderContent, + onChange: (v: any) => { + closeReason.current = 'select' + onChange && onChange(v) + }, + onChangeShow: v => { handleVisibleChange(v) } + }) + + const isPopover = !hasDrawer || ($layoutWidth.get() > STYLES.media.tablet) + + function handleWidthChange () { + closeReason.current = 'resize' + popoverRef.current?.close?.() + $isShow.set(false) + $layoutWidth.set(Math.min(Dimensions.get('window').width, Dimensions.get('screen').width)) + } + + useEffect(() => { + const listener = Dimensions.addEventListener('change', handleWidthChange) + + return () => { + $isShow.del() + listener?.remove?.() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useImperativeHandle(ref, () => ({ + open: () => { + handleVisibleChange(true) + }, + close: () => { + handleVisibleChange(false, { reason: 'toggle' }) + } + })) + + function handleVisibleChange (nextVisible: boolean, meta: { reason?: typeof closeReason.current } = {}) { + if (typeof meta.reason !== 'undefined') closeReason.current = meta.reason + + if (isPopover) { + if (nextVisible) { + closeReason.current = null + popoverRef.current?.open?.() + $isShow.set(true) + } else { + popoverRef.current?.close?.() + $isShow.set(false) + } + return + } + + if (!nextVisible && closeReason.current === 'dismiss') onDismiss && onDismiss() + $isShow.set(nextVisible) + } + + function onLayoutActive ({ nativeEvent }: any) { + setActiveInfo(nativeEvent.layout) + } + + function onCancel () { + handleVisibleChange(false, { reason: 'dismiss' }) + } + + function onRequestOpen () { + const node = refScroll.current?.getScrollableNode + ? refScroll.current.getScrollableNode() + : refScroll.current + + if (!node) return + + UIManager.measure(node, (x, y, width, curHeight) => { + if (activeInfo && activeInfo.y >= (curHeight - activeInfo.height)) { + refScroll.current?.scrollTo?.({ y: activeInfo.y, animated: false }) + } + }) + } + + let caption: ReactNode = null + let activeLabel = '' + renderContent.current = [] + + React.Children.toArray(children).forEach((child: any, index, arr) => { + if (child?.type === DropdownCaption) { + if (index !== 0) Error('Caption need use first child') + if (child.props.children) { + caption = React.cloneElement(child, { variant: 'custom' }) + } else { + caption = child + } + return + } + + const _child = React.cloneElement(child, { + _variant: child.props.children + ? 'pure' + : (isPopover ? 'popover' : drawerVariant), + _styleActiveItem: activeItemStyle, + _activeValue: value, + _selectIndexValue: selectIndexValue, + _index: caption ? (index - 1) : index, + _childrenLength: caption ? (arr.length - 1) : arr.length, + _onDismissDropdown: () => { handleVisibleChange(false) }, + _onChange: (v: any) => { + closeReason.current = 'select' + onChange && onChange(v) + handleVisibleChange(false) + } + }) + + if (value === child.props.value) { + activeLabel = child.props.label + renderContent.current.push(pug` + View( + key=index + value=child.props.value + onLayout=onLayoutActive + )=_child + `) + } else { + renderContent.current.push(_child) + } + }) + + if (!caption) { + caption = + } else { + caption = React.cloneElement(caption as any, { _activeLabel: activeLabel }) + } + + const _popoverStyle = StyleSheet.flatten(style) + if ((caption as any).props?.variant === 'button' || (caption as any).props?.variant === 'custom') { + ;(_popoverStyle as any).minWidth = 160 + } + + const matchAnchorWidth = !(_popoverStyle as any)?.width && !(_popoverStyle as any)?.minWidth + + if (isPopover) { + const renderPopoverContent = (): ReactNode => pug` + ScrollView( + ref=refScroll + showsVerticalScrollIndicator=false + )= renderContent.current + ` + + const handlePopoverCloseComplete = () => { + $isShow.set(false) + if (closeReason.current !== 'select' && closeReason.current !== 'toggle' && closeReason.current !== 'resize') { + onDismiss && onDismiss() + } + closeReason.current = null + } + + return pug` + Popover( + ref=popoverRef + style=captionStyle + attachmentStyle=_popoverStyle + position=position + attachment=attachment + placements=placements + matchAnchorWidth=matchAnchorWidth + onOpenComplete=onRequestOpen + onCloseComplete=handlePopoverCloseComplete + renderContent=renderPopoverContent + ) + TouchableOpacity( + disabled=disabled + onPress=() => handleVisibleChange(!$isShow.get(), { reason: !$isShow.get() ? null : 'toggle' }) + ) + = caption + ` + } + + return pug` + if caption + TouchableOpacity.caption( + disabled=disabled + onPress=() => handleVisibleChange(!$isShow.get()) + ) + = caption + Drawer( + visible=$isShow.get() + position='bottom' + style={ maxHeight: '100%' } + styleName={ drawerReset: drawerVariant === 'buttons' } + onDismiss=() => handleVisibleChange(false) + onRequestOpen=onRequestOpen + showResponder=showDrawerResponder + ) + View.dropdown(styleName=drawerVariant) + if drawerVariant === 'list' + View.caption(styleName=drawerVariant) + Text.captionText(styleName=drawerVariant)= drawerListTitle + ScrollView.case( + ref=refScroll + showsVerticalScrollIndicator=false + style=_popoverStyle + styleName=drawerVariant + )= renderContent.current + if drawerVariant === 'buttons' + TouchableOpacity(onPress=onCancel) + View.button(styleName=drawerVariant) + Text= drawerCancelLabel + ` +} + +const ObservedDropdown: any = observer(themed('Dropdown', Dropdown)) + +ObservedDropdown.Caption = DropdownCaption +ObservedDropdown.Item = DropdownItem + +export default ObservedDropdown diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js index ad0f4c4..a5a449e 100644 --- a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js @@ -202,7 +202,7 @@ export default { .filter(child => child !== '\n') return pug` - BlockquoteContext.Provider(value=true) + BlockquoteContext.Provider(value) Alert.alert= filteredChildren ` }, @@ -254,12 +254,11 @@ export default { }, pre: ({ children }) => { return pug` - PreContext.Provider(value=true) + PreContext.Provider(value) = children ` }, code: observer(({ children, className, ...props }) => { - // eslint-disable-next-line react-hooks/rules-of-hooks const isBlockCode = useContext(PreContext) if (!isBlockCode) { diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMultiSelect.tsx b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMultiSelect.tsx new file mode 100644 index 0000000..8d73dc8 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMultiSelect.tsx @@ -0,0 +1,398 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, + type ReactNode, + type RefObject +} from 'react' +import { Platform, type StyleProp, type ViewStyle } from 'react-native' +import { pug, observer, useDidUpdate } from 'startupjs' +import { themed } from '@startupjs-ui/core' +import Div from '@startupjs-ui/div' +import Drawer from '@startupjs-ui/drawer' +import Icon from '@startupjs-ui/icon' +import Popover from '@startupjs-ui/popover' +import ScrollView from '@startupjs-ui/scroll-view' +import Span from '@startupjs-ui/span' +import Tag from '@startupjs-ui/tag' +import { faCheck } from '@fortawesome/free-solid-svg-icons/faCheck' +import './index.cssx.styl' + +const IS_WEB = Platform.OS === 'web' + +export default observer(themed('MultiSelect', MultiSelect)) + +export const _PropsJsonSchema = {/* MultiSelectProps */} // used in docs generation + +export interface MultiSelectOption { + label: any + value: any +} + +export interface MultiSelectProps { + /** Custom styles for the Popover anchor wrapper */ + style?: StyleProp + /** Custom styles for the input wrapper */ + inputStyle?: StyleProp + /** Available options (objects with `{ label, value }` or primitives) @default [] */ + options?: (MultiSelectOption | string | number | boolean)[] + /** Selected values @default [] */ + value?: any[] + /** Placeholder text shown when empty @default 'Select' */ + placeholder?: string + /** Disable interactions @default false */ + disabled?: boolean + /** Render non-editable value @default false */ + readonly?: boolean + /** Maximum number of visible tags (extra tags are collapsed) */ + tagLimit?: number + /** Behavior when tags are limited (legacy prop) @default 'hidden' */ + tagLimitVariant?: 'hidden' | 'disabled' + /** Maximum number of selectable tags */ + maxTagCount?: number + /** Custom tag renderer */ + TagComponent?: any + /** Custom input renderer */ + InputComponent?: any + /** Match Popover width to anchor on web @default false */ + hasWidthCaption?: boolean + /** Custom suggestion item renderer */ + renderListItem?: (options: { item: MultiSelectOption, index: number, selected: boolean }) => ReactNode + /** Called when selected values change */ + onChange?: (value: any[]) => void + /** Called when a value is selected */ + onSelect?: (value: any) => void + /** Called when a value is removed */ + onRemove?: (value: any) => void + /** Called when dropdown opens */ + onFocus?: () => void + /** Called when dropdown closes */ + onBlur?: () => void + /** Ref providing imperative `focus()` / `blur()` methods */ + ref?: RefObject + /** Error flag @private */ + _hasError?: boolean +} + +function MultiSelect ({ + style, + inputStyle, + options = [], + value = [], + placeholder = 'Select', + disabled = false, + readonly = false, + tagLimitVariant = 'hidden', + TagComponent = DefaultTag, + InputComponent, + tagLimit, + maxTagCount, + hasWidthCaption = false, + renderListItem, + onChange, + onSelect, + onRemove, + onFocus, + onBlur, + ref, + _hasError, + ...props +}: MultiSelectProps): ReactNode { + const [focused, setFocused] = useState(false) + const isOpenable = !(disabled || readonly) + + const normalizedOptions = useMemo(() => { + return options.map(opt => typeof opt === 'object' && opt !== null + ? opt + : { label: opt, value: opt } + ) + }, [options]) + + const shouldDisableSelection = maxTagCount + ? maxTagCount === value.length + : false + + const focusHandler = useCallback(() => { + if (isOpenable) setFocused(true) + }, [isOpenable]) + + const blurHandler = useCallback(() => { setFocused(false) }, []) + + const handleChangeVisible = (nextVisible: boolean) => { + if (!nextVisible) { + setFocused(false) + return + } + if (!isOpenable) return + setFocused(true) + } + + useDidUpdate(() => { + if (focused) onFocus && onFocus() + else onBlur && onBlur() + }, [focused]) + + useImperativeHandle(ref, () => ({ + focus: focusHandler, + blur: blurHandler + }), [focusHandler, blurHandler]) + + useDidUpdate(() => { + if (focused && !isOpenable) blurHandler() + }, [focused, isOpenable]) + + function _onRemove (_value: any) { + onRemove && onRemove(_value) + onChange && onChange(value.filter(v => v !== _value)) + } + + function _onSelect (_value: any) { + onSelect && onSelect(_value) + onChange && onChange([...value, _value]) + } + + const onItemPress = (itemValue: any, selected: boolean) => (checked: boolean) => { + if (disabled || readonly) return + if (shouldDisableSelection && checked && !selected) return + if (!checked) { + _onRemove(itemValue) + } else { + _onSelect(itemValue) + } + } + + function _renderListItem ({ item, index }: { item: MultiSelectOption, index: number }): ReactNode { + const { label, value: itemValue } = item + const selected = value.includes(itemValue) + const onPress = onItemPress(itemValue, selected) + + return pug` + Div( + key=itemValue + vAlign='center' + disabled=selected ? false : shouldDisableSelection + onPress=() => onPress(!selected) + ) + if renderListItem + = renderListItem({ item, index, selected }) + else + Div.suggestionItem(row) + Span.label= label + Div.check + if selected + Icon(icon=faCheck styleName='checkIcon') + ` + } + + function renderContent (): ReactNode { + return pug` + ScrollView.suggestions-web + each option, index in normalizedOptions + = _renderListItem({ item: option, index }) + ` + } + + return IS_WEB + ? pug` + Popover.popover( + part='root' + ...props + captionStyle=style + visible=focused + openOnAnchorPress=false + matchAnchorWidth=hasWidthCaption + attachment='start' + position='bottom' + onChange=handleChangeVisible + renderContent=renderContent + ) + MultiSelectInput( + part='input' + style=inputStyle + focused=focused + value=value + placeholder=placeholder + tagLimit=tagLimit + tagLimitVariant=tagLimitVariant + options=normalizedOptions + disabled=disabled + readonly=readonly + InputComponent=InputComponent + TagComponent=TagComponent + _hasError=_hasError + onOpen=focusHandler + onHide=blurHandler + ) + ` + : pug` + MultiSelectInput( + part='input' + style=inputStyle + onOpen=focusHandler + onHide=blurHandler + focused=focused + value=value + placeholder=placeholder + tagLimit=tagLimit + tagLimitVariant=tagLimitVariant + options=normalizedOptions + disabled=disabled + readonly=readonly + InputComponent=InputComponent + TagComponent=TagComponent + _hasError=_hasError + ) + Drawer.nativeListContent( + part='root' + visible=focused + position='bottom' + onDismiss=blurHandler + ) + ScrollView.suggestions-native + each option, index in normalizedOptions + = _renderListItem({ item: option, index }) + ` +} + +function MultiSelectInput ({ + style, + value, + placeholder, + options, + disabled, + readonly, + focused, + tagLimit, + tagLimitVariant, + TagComponent, + InputComponent, + onOpen, + onHide, + _hasError +}: { + style?: StyleProp + value: any[] + placeholder?: string + options: MultiSelectOption[] + disabled?: boolean + readonly?: boolean + focused?: boolean + tagLimit?: number + tagLimitVariant?: 'hidden' | 'disabled' + TagComponent?: any + InputComponent?: any + onOpen?: () => void + onHide?: () => void + _hasError?: boolean +}): ReactNode { + const values = tagLimit ? value.slice(0, tagLimit) : value + const hiddenTagsLength = tagLimit + ? value.slice(tagLimit, value.length).length + : 0 + + const EffectiveInputComponent = InputComponent ?? DefaultInput + + return pug` + EffectiveInputComponent( + part='root' + style=style + value=values + placeholder=placeholder + disabled=disabled + focused=focused + readonly=readonly + onOpen=onOpen + onHide=onHide + _hasError=_hasError + ) + each value, index in values + - const record = options.find(r => r.value === value) || {} + - const isLast = index + 1 === values.length + TagComponent( + key=value + index=index + isLast=isLast + record=record + ) + if hiddenTagsLength + Span.ellipsis ... + DefaultTag( + index=0 + record={ label: '+' + hiddenTagsLength } + ) + ` +} + +function DefaultInput ({ + style, + value = [], + placeholder, + disabled, + focused, + readonly, + children, + onOpen, + onHide, + _hasError, + ref +}: { + style?: StyleProp + value?: any[] + placeholder?: string + disabled?: boolean + focused?: boolean + readonly?: boolean + children?: ReactNode + onOpen?: () => void + onHide?: () => void + _hasError?: boolean + ref?: RefObject +}): ReactNode { + useImperativeHandle(ref, () => ({ + focus: () => { onOpen && onOpen() }, + blur: () => { onHide && onHide() } + }), [onOpen, onHide]) + + useEffect(() => { + if (focused && disabled) onHide && onHide() + }, [disabled, focused, onHide]) + + return pug` + if readonly + Span= value.join(', ') + else + Div.input( + style=style + styleName={ disabled, focused, readonly, error: _hasError } + role='button' + aria-disabled=disabled + aria-readonly=readonly + onPress=disabled || readonly ? undefined : onOpen + wrap + row + ) + if !value.length + Span.placeholder= placeholder || '-' + + = children + ` +} + +function DefaultTag ({ + record, + isLast +}: { + record?: any + isLast?: boolean +}): ReactNode { + return pug` + Tag.tag( + styleName={ last: isLast } + size='s' + variant='flat' + color='primary' + )= record?.label + ` +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTextInput.tsx b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTextInput.tsx new file mode 100644 index 0000000..9cc5b11 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTextInput.tsx @@ -0,0 +1,263 @@ +import { + useState, + useMemo, + useRef, + type ReactNode, + type RefObject +} from 'react' +import { + TextInput as RNTextInput, + Platform, + type StyleProp, + type TextStyle, + type ViewStyle, + type TextInputProps +} from 'react-native' +import { pug, observer, useIsomorphicLayoutEffect } from 'startupjs' +import { themed, useColors } from '@startupjs-ui/core' +import Div from '@startupjs-ui/div' +import Icon from '@startupjs-ui/icon' +import Span from '@startupjs-ui/span' +import STYLES from './index.cssx.styl' + +const { + config: { + caretColor, + heights, + paddings + } +} = STYLES + +const IS_WEB = Platform.OS === 'web' +const IS_ANDROID = Platform.OS === 'android' +const ICON_SIZES = { + s: 'm', + m: 'm', + l: 'l' +} + +export default observer(themed('TextInput', TextInput)) + +export const _PropsJsonSchema = {/* TextInputProps */} + +export interface UITextInputProps extends Omit { + /** Ref to access the underlying input */ + ref?: RefObject + /** Custom styles for the wrapper element */ + style?: StyleProp + /** Custom styles for the input element */ + inputStyle?: StyleProp + /** Custom styles for the primary icon */ + iconStyle?: StyleProp + /** Custom styles for the secondary icon */ + secondaryIconStyle?: StyleProp + /** Placeholder text */ + placeholder?: string | number + /** Test identifier */ + testID?: string + /** Input value @default '' */ + value?: string + /** Size preset @default 'm' */ + size?: 'l' | 'm' | 's' + /** Disable input interactions @default false */ + disabled?: boolean + /** Render a non-editable value @default false */ + readonly?: boolean + /** Enable dynamic height based on content @default false */ + resize?: boolean + /** Number of lines to display @default 1 */ + numberOfLines?: number + /** Primary icon component */ + icon?: any + /** Position of the primary icon @default 'left' */ + iconPosition?: 'left' | 'right' + /** Secondary icon component */ + secondaryIcon?: any + /** Primary icon press handler */ + onIconPress?: () => void + /** Secondary icon press handler */ + onSecondaryIconPress?: () => void + /** Focus event handler */ + onFocus?: (...args: any[]) => void + /** Blur event handler */ + onBlur?: (...args: any[]) => void + /** Change text handler */ + onChangeText?: (...args: any[]) => void + /** Custom wrapper renderer @private */ + _renderWrapper?: (options: { style?: StyleProp }, children: ReactNode) => ReactNode + /** Error state flag @private */ + _hasError?: boolean +} + +function TextInput ({ + ref, + style, + placeholder, + value = '', + size = 'm', + disabled = false, + readonly = false, + resize = false, + numberOfLines = 1, + iconPosition = 'left', + icon, + secondaryIcon, + onFocus, + onBlur, + onIconPress, + onSecondaryIconPress, + _renderWrapper, + _hasError, + ...props +}: UITextInputProps): ReactNode { + const [focused, setFocused] = useState(false) + const [currentNumberOfLines, setCurrentNumberOfLines] = useState(numberOfLines) + const fallbackRef = useRef(null) + const inputRef = ref ?? fallbackRef + + const getColor = useColors() + + function handleFocus (...args: any[]) { + onFocus && onFocus(...args) + setFocused(true) + } + function handleBlur (...args: any[]) { + onBlur && onBlur(...args) + setFocused(false) + } + + if (!_renderWrapper) { + _renderWrapper = ({ style }: { style?: StyleProp }, children: ReactNode): ReactNode => pug` + Div(style=style)= children + ` + } + + useIsomorphicLayoutEffect(() => { + if (readonly || !resize) return + const numberOfLinesInValue = value.split('\n').length + if (numberOfLinesInValue >= numberOfLines) { + setCurrentNumberOfLines(numberOfLinesInValue) + } + }, [value, resize, numberOfLines, readonly]) + + if (IS_WEB) { + // repeat mobile behaviour on the web + // TODO + // test mobile device behaviour + + // eslint-disable-next-line react-hooks/rules-of-hooks + useIsomorphicLayoutEffect(() => { + if (readonly) return + if (focused && disabled) { + inputRef.current?.blur() + setFocused(false) + } + }, [disabled, focused, readonly]) + // fix minWidth on web + // ref: https://stackoverflow.com/a/29990524/1930491 + // eslint-disable-next-line react-hooks/rules-of-hooks + useIsomorphicLayoutEffect(() => { + if (readonly) return + // TODO: looks like it's not available anymore on new versions of react-native-web + inputRef.current?.setNativeProps?.({ size: '1' }) + }, [readonly]) + } + + // useDidUpdate(() => { + // if (readonly) return + // if (numberOfLines !== currentNumberOfLines) { + // setCurrentNumberOfLines(numberOfLines) + // } + // }, [numberOfLines, currentNumberOfLines, readonly]) + + const multiline = useMemo(() => { + return resize || numberOfLines > 1 + }, [resize, numberOfLines]) + + const fullHeight = useMemo(() => { + return currentNumberOfLines * (heights[size] as number) + (paddings[size] as number) * 2 + }, [currentNumberOfLines, size]) + + function onLayoutIcon (e: any) { + if (IS_WEB) { + e.nativeEvent.target.childNodes[0].tabIndex = -1 + e.nativeEvent.target.childNodes[0].childNodes[0].tabIndex = -1 + } + } + + const inputExtraProps: Record = {} + if (IS_ANDROID && multiline) inputExtraProps.textAlignVertical = 'top' + + const inputStyleName = [ + size, + { + disabled, + focused, + [`icon-${iconPosition}`]: !!icon, + [`icon-${getOppositePosition(iconPosition)}`]: !!secondaryIcon, + error: _hasError + } + ] + + if (readonly) { + return pug` + Span= value + ` + } + + return _renderWrapper({ + style: [style] + }, pug` + RNTextInput.input-input( + part=['input', { + inputIconLeft: icon && iconPosition === 'left', + inputIconRight: icon && iconPosition === 'right' + }] + ref=inputRef + style={ minHeight: fullHeight } + styleName=inputStyleName + selectionColor=caretColor + placeholder=placeholder + placeholderTextColor=getColor('text-placeholder') + value=value + disabled=IS_WEB ? disabled : undefined + editable=IS_WEB ? undefined : !disabled + multiline=multiline + selectTextOnFocus=false + onFocus=handleFocus + onBlur=handleBlur + ...props + ...inputExtraProps + ) + if icon + Div.input-icon( + focusable=false + onLayout=onLayoutIcon + styleName=[size, iconPosition] + onPress=disabled ? undefined : onIconPress + pointerEvents=onIconPress ? undefined : 'none' + ) + Icon( + part='icon' + icon=icon + size=ICON_SIZES[size] + ) + if secondaryIcon + Div.input-icon( + focusable=false + onLayout=onLayoutIcon + styleName=[size, getOppositePosition(iconPosition)] + onPress=disabled ? undefined : onSecondaryIconPress + pointerEvents=onSecondaryIconPress ? undefined : 'none' + ) + Icon( + part='secondaryIcon' + icon=secondaryIcon + size=ICON_SIZES[size] + ) + `) +} + +function getOppositePosition (position: 'left' | 'right') { + return position === 'left' ? 'right' : 'left' +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiDropdown.tsx b/test/fixtures/example-unformatted/src/StartupjsUiDropdown.tsx new file mode 100644 index 0000000..1fa25c1 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiDropdown.tsx @@ -0,0 +1,313 @@ +import React, { useState, useRef, useImperativeHandle, useEffect, type ReactNode, type RefObject } from 'react' +import { + Dimensions, + UIManager, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, + type StyleProp, + type ViewStyle +} from 'react-native' +import { pug, observer, $ } from 'startupjs' +import { themed } from '@startupjs-ui/core' +import Drawer from '@startupjs-ui/drawer' +import Popover, { type PopoverRef } from '@startupjs-ui/popover' +import DropdownCaption from './components/Caption' +import DropdownItem from './components/Item' +import { useKeyboard } from './helpers' +import STYLES from './index.cssx.styl' + +export const _PropsJsonSchema = {/* DropdownProps */} + +export interface DropdownProps { + /** Ref to control dropdown programmatically */ + ref?: RefObject + /** Custom styles applied to the dropdown content container */ + style?: StyleProp + /** Custom styles applied to the caption wrapper */ + captionStyle?: StyleProp + /** Custom styles applied to the active item view */ + activeItemStyle?: StyleProp + /** Dropdown caption and items */ + children?: ReactNode + /** Currently selected value @default '' */ + value?: string | number + /** Popover position @default 'bottom' */ + position?: 'top' | 'bottom' | 'left' | 'right' + /** Popover attachment @default 'start' */ + attachment?: 'start' | 'center' | 'end' + /** Fallback placements order */ + placements?: any + /** Drawer items rendering variant @default 'buttons' */ + drawerVariant?: 'list' | 'buttons' | 'pure' + /** Title shown in list drawer variant */ + drawerListTitle?: string + /** Cancel button label in buttons drawer variant @default 'Cancel' */ + drawerCancelLabel?: string + /** Disable caption press */ + disabled?: boolean + /** Enable drawer behavior on small screens @default true */ + hasDrawer?: boolean + /** Show swipe responder zone in drawer */ + showDrawerResponder?: boolean + /** Called when item is selected */ + onChange?: (value: string | number | undefined) => void + /** Called when dropdown is dismissed via overlay/cancel */ + onDismiss?: () => void +} + +export interface DropdownRef { + /** Open dropdown programmatically */ + open: () => void + /** Close dropdown programmatically */ + close: () => void +} + +// TODO: key event change scroll +function Dropdown ({ + style = [], + captionStyle, + activeItemStyle, + children, + value = '', + position = 'bottom', + attachment = 'start', + placements, + drawerVariant = 'buttons', + drawerListTitle = '', + drawerCancelLabel = 'Cancel', + disabled, + hasDrawer = true, + showDrawerResponder, + onChange, + onDismiss, + ref +}: DropdownProps): ReactNode { + const popoverRef = useRef(null) + const refScroll = useRef(null) + const renderContent = useRef([]) + const closeReason = useRef(null) + + const $isShow = $(false) + const [activeInfo, setActiveInfo] = useState(null) + const $layoutWidth = $( + Math.min(Dimensions.get('window').width, Dimensions.get('screen').width) + ) + + const [selectIndexValue] = useKeyboard({ + value, + isShow: $isShow.get(), + renderContent, + onChange: (v: any) => { + closeReason.current = 'select' + onChange && onChange(v) + }, + onChangeShow: v => { handleVisibleChange(v) } + }) + + const isPopover = !hasDrawer || ($layoutWidth.get() > STYLES.media.tablet) + + function handleWidthChange () { + closeReason.current = 'resize' + popoverRef.current?.close?.() + $isShow.set(false) + $layoutWidth.set(Math.min(Dimensions.get('window').width, Dimensions.get('screen').width)) + } + + useEffect(() => { + const listener = Dimensions.addEventListener('change', handleWidthChange) + + return () => { + $isShow.del() + listener?.remove?.() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useImperativeHandle(ref, () => ({ + open: () => { + handleVisibleChange(true) + }, + close: () => { + handleVisibleChange(false, { reason: 'toggle' }) + } + })) + + function handleVisibleChange (nextVisible: boolean, meta: { reason?: typeof closeReason.current } = {}) { + if (typeof meta.reason !== 'undefined') closeReason.current = meta.reason + + if (isPopover) { + if (nextVisible) { + closeReason.current = null + popoverRef.current?.open?.() + $isShow.set(true) + } else { + popoverRef.current?.close?.() + $isShow.set(false) + } + return + } + + if (!nextVisible && closeReason.current === 'dismiss') onDismiss && onDismiss() + $isShow.set(nextVisible) + } + + function onLayoutActive ({ nativeEvent }: any) { + setActiveInfo(nativeEvent.layout) + } + + function onCancel () { + handleVisibleChange(false, { reason: 'dismiss' }) + } + + function onRequestOpen () { + const node = refScroll.current?.getScrollableNode + ? refScroll.current.getScrollableNode() + : refScroll.current + + if (!node) return + + UIManager.measure(node, (x, y, width, curHeight) => { + if (activeInfo && activeInfo.y >= (curHeight - activeInfo.height)) { + refScroll.current?.scrollTo?.({ y: activeInfo.y, animated: false }) + } + }) + } + + let caption: ReactNode = null + let activeLabel = '' + renderContent.current = [] + + React.Children.toArray(children).forEach((child: any, index, arr) => { + if (child?.type === DropdownCaption) { + if (index !== 0) Error('Caption need use first child') + if (child.props.children) { + caption = React.cloneElement(child, { variant: 'custom' }) + } else { + caption = child + } + return + } + + const _child = React.cloneElement(child, { + _variant: child.props.children + ? 'pure' + : (isPopover ? 'popover' : drawerVariant), + _styleActiveItem: activeItemStyle, + _activeValue: value, + _selectIndexValue: selectIndexValue, + _index: caption ? (index - 1) : index, + _childrenLength: caption ? (arr.length - 1) : arr.length, + _onDismissDropdown: () => { handleVisibleChange(false) }, + _onChange: (v: any) => { + closeReason.current = 'select' + onChange && onChange(v) + handleVisibleChange(false) + } + }) + + if (value === child.props.value) { + activeLabel = child.props.label + renderContent.current.push(pug` + View( + key=index + value=child.props.value + onLayout=onLayoutActive + )=_child + `) + } else { + renderContent.current.push(_child) + } + }) + + if (!caption) { + caption = + } else { + caption = React.cloneElement(caption as any, { _activeLabel: activeLabel }) + } + + const _popoverStyle = StyleSheet.flatten(style) + if ((caption as any).props?.variant === 'button' || (caption as any).props?.variant === 'custom') { + ;(_popoverStyle as any).minWidth = 160 + } + + const matchAnchorWidth = !(_popoverStyle as any)?.width && !(_popoverStyle as any)?.minWidth + + if (isPopover) { + const renderPopoverContent = (): ReactNode => pug` + ScrollView( + ref=refScroll + showsVerticalScrollIndicator=false + )= renderContent.current + ` + + const handlePopoverCloseComplete = () => { + $isShow.set(false) + if (closeReason.current !== 'select' && closeReason.current !== 'toggle' && closeReason.current !== 'resize') { + onDismiss && onDismiss() + } + closeReason.current = null + } + + return pug` + Popover( + ref=popoverRef + style=captionStyle + attachmentStyle=_popoverStyle + position=position + attachment=attachment + placements=placements + matchAnchorWidth=matchAnchorWidth + onOpenComplete=onRequestOpen + onCloseComplete=handlePopoverCloseComplete + renderContent=renderPopoverContent + ) + TouchableOpacity( + disabled=disabled + onPress=() => handleVisibleChange(!$isShow.get(), { reason: !$isShow.get() ? null : 'toggle' }) + ) + = caption + ` + } + + return pug` + if caption + TouchableOpacity.caption( + disabled=disabled + onPress=() => handleVisibleChange(!$isShow.get()) + ) + = caption + Drawer( + visible=$isShow.get() + position='bottom' + style={ maxHeight: '100%' } + styleName={ drawerReset: drawerVariant === 'buttons' } + onDismiss=() => handleVisibleChange(false) + onRequestOpen=onRequestOpen + showResponder=showDrawerResponder + ) + View.dropdown(styleName=drawerVariant) + if drawerVariant === 'list' + View.caption(styleName=drawerVariant) + Text.captionText(styleName=drawerVariant)= drawerListTitle + ScrollView.case( + ref=refScroll + showsVerticalScrollIndicator=false + style=_popoverStyle + styleName=drawerVariant + )= renderContent.current + if drawerVariant === 'buttons' + TouchableOpacity(onPress=onCancel) + View.button(styleName=drawerVariant) + Text= drawerCancelLabel + ` +} + +const ObservedDropdown: any = observer(themed('Dropdown', Dropdown)) + +ObservedDropdown.Caption = DropdownCaption +ObservedDropdown.Item = DropdownItem + +export default ObservedDropdown diff --git a/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js b/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js index ad0f4c4..a5a449e 100644 --- a/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js +++ b/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js @@ -202,7 +202,7 @@ export default { .filter(child => child !== '\n') return pug` - BlockquoteContext.Provider(value=true) + BlockquoteContext.Provider(value) Alert.alert= filteredChildren ` }, @@ -254,12 +254,11 @@ export default { }, pre: ({ children }) => { return pug` - PreContext.Provider(value=true) + PreContext.Provider(value) = children ` }, code: observer(({ children, className, ...props }) => { - // eslint-disable-next-line react-hooks/rules-of-hooks const isBlockCode = useContext(PreContext) if (!isBlockCode) { diff --git a/test/fixtures/example-unformatted/src/StartupjsUiMultiSelect.tsx b/test/fixtures/example-unformatted/src/StartupjsUiMultiSelect.tsx new file mode 100644 index 0000000..8d73dc8 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiMultiSelect.tsx @@ -0,0 +1,398 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, + type ReactNode, + type RefObject +} from 'react' +import { Platform, type StyleProp, type ViewStyle } from 'react-native' +import { pug, observer, useDidUpdate } from 'startupjs' +import { themed } from '@startupjs-ui/core' +import Div from '@startupjs-ui/div' +import Drawer from '@startupjs-ui/drawer' +import Icon from '@startupjs-ui/icon' +import Popover from '@startupjs-ui/popover' +import ScrollView from '@startupjs-ui/scroll-view' +import Span from '@startupjs-ui/span' +import Tag from '@startupjs-ui/tag' +import { faCheck } from '@fortawesome/free-solid-svg-icons/faCheck' +import './index.cssx.styl' + +const IS_WEB = Platform.OS === 'web' + +export default observer(themed('MultiSelect', MultiSelect)) + +export const _PropsJsonSchema = {/* MultiSelectProps */} // used in docs generation + +export interface MultiSelectOption { + label: any + value: any +} + +export interface MultiSelectProps { + /** Custom styles for the Popover anchor wrapper */ + style?: StyleProp + /** Custom styles for the input wrapper */ + inputStyle?: StyleProp + /** Available options (objects with `{ label, value }` or primitives) @default [] */ + options?: (MultiSelectOption | string | number | boolean)[] + /** Selected values @default [] */ + value?: any[] + /** Placeholder text shown when empty @default 'Select' */ + placeholder?: string + /** Disable interactions @default false */ + disabled?: boolean + /** Render non-editable value @default false */ + readonly?: boolean + /** Maximum number of visible tags (extra tags are collapsed) */ + tagLimit?: number + /** Behavior when tags are limited (legacy prop) @default 'hidden' */ + tagLimitVariant?: 'hidden' | 'disabled' + /** Maximum number of selectable tags */ + maxTagCount?: number + /** Custom tag renderer */ + TagComponent?: any + /** Custom input renderer */ + InputComponent?: any + /** Match Popover width to anchor on web @default false */ + hasWidthCaption?: boolean + /** Custom suggestion item renderer */ + renderListItem?: (options: { item: MultiSelectOption, index: number, selected: boolean }) => ReactNode + /** Called when selected values change */ + onChange?: (value: any[]) => void + /** Called when a value is selected */ + onSelect?: (value: any) => void + /** Called when a value is removed */ + onRemove?: (value: any) => void + /** Called when dropdown opens */ + onFocus?: () => void + /** Called when dropdown closes */ + onBlur?: () => void + /** Ref providing imperative `focus()` / `blur()` methods */ + ref?: RefObject + /** Error flag @private */ + _hasError?: boolean +} + +function MultiSelect ({ + style, + inputStyle, + options = [], + value = [], + placeholder = 'Select', + disabled = false, + readonly = false, + tagLimitVariant = 'hidden', + TagComponent = DefaultTag, + InputComponent, + tagLimit, + maxTagCount, + hasWidthCaption = false, + renderListItem, + onChange, + onSelect, + onRemove, + onFocus, + onBlur, + ref, + _hasError, + ...props +}: MultiSelectProps): ReactNode { + const [focused, setFocused] = useState(false) + const isOpenable = !(disabled || readonly) + + const normalizedOptions = useMemo(() => { + return options.map(opt => typeof opt === 'object' && opt !== null + ? opt + : { label: opt, value: opt } + ) + }, [options]) + + const shouldDisableSelection = maxTagCount + ? maxTagCount === value.length + : false + + const focusHandler = useCallback(() => { + if (isOpenable) setFocused(true) + }, [isOpenable]) + + const blurHandler = useCallback(() => { setFocused(false) }, []) + + const handleChangeVisible = (nextVisible: boolean) => { + if (!nextVisible) { + setFocused(false) + return + } + if (!isOpenable) return + setFocused(true) + } + + useDidUpdate(() => { + if (focused) onFocus && onFocus() + else onBlur && onBlur() + }, [focused]) + + useImperativeHandle(ref, () => ({ + focus: focusHandler, + blur: blurHandler + }), [focusHandler, blurHandler]) + + useDidUpdate(() => { + if (focused && !isOpenable) blurHandler() + }, [focused, isOpenable]) + + function _onRemove (_value: any) { + onRemove && onRemove(_value) + onChange && onChange(value.filter(v => v !== _value)) + } + + function _onSelect (_value: any) { + onSelect && onSelect(_value) + onChange && onChange([...value, _value]) + } + + const onItemPress = (itemValue: any, selected: boolean) => (checked: boolean) => { + if (disabled || readonly) return + if (shouldDisableSelection && checked && !selected) return + if (!checked) { + _onRemove(itemValue) + } else { + _onSelect(itemValue) + } + } + + function _renderListItem ({ item, index }: { item: MultiSelectOption, index: number }): ReactNode { + const { label, value: itemValue } = item + const selected = value.includes(itemValue) + const onPress = onItemPress(itemValue, selected) + + return pug` + Div( + key=itemValue + vAlign='center' + disabled=selected ? false : shouldDisableSelection + onPress=() => onPress(!selected) + ) + if renderListItem + = renderListItem({ item, index, selected }) + else + Div.suggestionItem(row) + Span.label= label + Div.check + if selected + Icon(icon=faCheck styleName='checkIcon') + ` + } + + function renderContent (): ReactNode { + return pug` + ScrollView.suggestions-web + each option, index in normalizedOptions + = _renderListItem({ item: option, index }) + ` + } + + return IS_WEB + ? pug` + Popover.popover( + part='root' + ...props + captionStyle=style + visible=focused + openOnAnchorPress=false + matchAnchorWidth=hasWidthCaption + attachment='start' + position='bottom' + onChange=handleChangeVisible + renderContent=renderContent + ) + MultiSelectInput( + part='input' + style=inputStyle + focused=focused + value=value + placeholder=placeholder + tagLimit=tagLimit + tagLimitVariant=tagLimitVariant + options=normalizedOptions + disabled=disabled + readonly=readonly + InputComponent=InputComponent + TagComponent=TagComponent + _hasError=_hasError + onOpen=focusHandler + onHide=blurHandler + ) + ` + : pug` + MultiSelectInput( + part='input' + style=inputStyle + onOpen=focusHandler + onHide=blurHandler + focused=focused + value=value + placeholder=placeholder + tagLimit=tagLimit + tagLimitVariant=tagLimitVariant + options=normalizedOptions + disabled=disabled + readonly=readonly + InputComponent=InputComponent + TagComponent=TagComponent + _hasError=_hasError + ) + Drawer.nativeListContent( + part='root' + visible=focused + position='bottom' + onDismiss=blurHandler + ) + ScrollView.suggestions-native + each option, index in normalizedOptions + = _renderListItem({ item: option, index }) + ` +} + +function MultiSelectInput ({ + style, + value, + placeholder, + options, + disabled, + readonly, + focused, + tagLimit, + tagLimitVariant, + TagComponent, + InputComponent, + onOpen, + onHide, + _hasError +}: { + style?: StyleProp + value: any[] + placeholder?: string + options: MultiSelectOption[] + disabled?: boolean + readonly?: boolean + focused?: boolean + tagLimit?: number + tagLimitVariant?: 'hidden' | 'disabled' + TagComponent?: any + InputComponent?: any + onOpen?: () => void + onHide?: () => void + _hasError?: boolean +}): ReactNode { + const values = tagLimit ? value.slice(0, tagLimit) : value + const hiddenTagsLength = tagLimit + ? value.slice(tagLimit, value.length).length + : 0 + + const EffectiveInputComponent = InputComponent ?? DefaultInput + + return pug` + EffectiveInputComponent( + part='root' + style=style + value=values + placeholder=placeholder + disabled=disabled + focused=focused + readonly=readonly + onOpen=onOpen + onHide=onHide + _hasError=_hasError + ) + each value, index in values + - const record = options.find(r => r.value === value) || {} + - const isLast = index + 1 === values.length + TagComponent( + key=value + index=index + isLast=isLast + record=record + ) + if hiddenTagsLength + Span.ellipsis ... + DefaultTag( + index=0 + record={ label: '+' + hiddenTagsLength } + ) + ` +} + +function DefaultInput ({ + style, + value = [], + placeholder, + disabled, + focused, + readonly, + children, + onOpen, + onHide, + _hasError, + ref +}: { + style?: StyleProp + value?: any[] + placeholder?: string + disabled?: boolean + focused?: boolean + readonly?: boolean + children?: ReactNode + onOpen?: () => void + onHide?: () => void + _hasError?: boolean + ref?: RefObject +}): ReactNode { + useImperativeHandle(ref, () => ({ + focus: () => { onOpen && onOpen() }, + blur: () => { onHide && onHide() } + }), [onOpen, onHide]) + + useEffect(() => { + if (focused && disabled) onHide && onHide() + }, [disabled, focused, onHide]) + + return pug` + if readonly + Span= value.join(', ') + else + Div.input( + style=style + styleName={ disabled, focused, readonly, error: _hasError } + role='button' + aria-disabled=disabled + aria-readonly=readonly + onPress=disabled || readonly ? undefined : onOpen + wrap + row + ) + if !value.length + Span.placeholder= placeholder || '-' + + = children + ` +} + +function DefaultTag ({ + record, + isLast +}: { + record?: any + isLast?: boolean +}): ReactNode { + return pug` + Tag.tag( + styleName={ last: isLast } + size='s' + variant='flat' + color='primary' + )= record?.label + ` +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiTextInput.tsx b/test/fixtures/example-unformatted/src/StartupjsUiTextInput.tsx new file mode 100644 index 0000000..9cc5b11 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiTextInput.tsx @@ -0,0 +1,263 @@ +import { + useState, + useMemo, + useRef, + type ReactNode, + type RefObject +} from 'react' +import { + TextInput as RNTextInput, + Platform, + type StyleProp, + type TextStyle, + type ViewStyle, + type TextInputProps +} from 'react-native' +import { pug, observer, useIsomorphicLayoutEffect } from 'startupjs' +import { themed, useColors } from '@startupjs-ui/core' +import Div from '@startupjs-ui/div' +import Icon from '@startupjs-ui/icon' +import Span from '@startupjs-ui/span' +import STYLES from './index.cssx.styl' + +const { + config: { + caretColor, + heights, + paddings + } +} = STYLES + +const IS_WEB = Platform.OS === 'web' +const IS_ANDROID = Platform.OS === 'android' +const ICON_SIZES = { + s: 'm', + m: 'm', + l: 'l' +} + +export default observer(themed('TextInput', TextInput)) + +export const _PropsJsonSchema = {/* TextInputProps */} + +export interface UITextInputProps extends Omit { + /** Ref to access the underlying input */ + ref?: RefObject + /** Custom styles for the wrapper element */ + style?: StyleProp + /** Custom styles for the input element */ + inputStyle?: StyleProp + /** Custom styles for the primary icon */ + iconStyle?: StyleProp + /** Custom styles for the secondary icon */ + secondaryIconStyle?: StyleProp + /** Placeholder text */ + placeholder?: string | number + /** Test identifier */ + testID?: string + /** Input value @default '' */ + value?: string + /** Size preset @default 'm' */ + size?: 'l' | 'm' | 's' + /** Disable input interactions @default false */ + disabled?: boolean + /** Render a non-editable value @default false */ + readonly?: boolean + /** Enable dynamic height based on content @default false */ + resize?: boolean + /** Number of lines to display @default 1 */ + numberOfLines?: number + /** Primary icon component */ + icon?: any + /** Position of the primary icon @default 'left' */ + iconPosition?: 'left' | 'right' + /** Secondary icon component */ + secondaryIcon?: any + /** Primary icon press handler */ + onIconPress?: () => void + /** Secondary icon press handler */ + onSecondaryIconPress?: () => void + /** Focus event handler */ + onFocus?: (...args: any[]) => void + /** Blur event handler */ + onBlur?: (...args: any[]) => void + /** Change text handler */ + onChangeText?: (...args: any[]) => void + /** Custom wrapper renderer @private */ + _renderWrapper?: (options: { style?: StyleProp }, children: ReactNode) => ReactNode + /** Error state flag @private */ + _hasError?: boolean +} + +function TextInput ({ + ref, + style, + placeholder, + value = '', + size = 'm', + disabled = false, + readonly = false, + resize = false, + numberOfLines = 1, + iconPosition = 'left', + icon, + secondaryIcon, + onFocus, + onBlur, + onIconPress, + onSecondaryIconPress, + _renderWrapper, + _hasError, + ...props +}: UITextInputProps): ReactNode { + const [focused, setFocused] = useState(false) + const [currentNumberOfLines, setCurrentNumberOfLines] = useState(numberOfLines) + const fallbackRef = useRef(null) + const inputRef = ref ?? fallbackRef + + const getColor = useColors() + + function handleFocus (...args: any[]) { + onFocus && onFocus(...args) + setFocused(true) + } + function handleBlur (...args: any[]) { + onBlur && onBlur(...args) + setFocused(false) + } + + if (!_renderWrapper) { + _renderWrapper = ({ style }: { style?: StyleProp }, children: ReactNode): ReactNode => pug` + Div(style=style)= children + ` + } + + useIsomorphicLayoutEffect(() => { + if (readonly || !resize) return + const numberOfLinesInValue = value.split('\n').length + if (numberOfLinesInValue >= numberOfLines) { + setCurrentNumberOfLines(numberOfLinesInValue) + } + }, [value, resize, numberOfLines, readonly]) + + if (IS_WEB) { + // repeat mobile behaviour on the web + // TODO + // test mobile device behaviour + + // eslint-disable-next-line react-hooks/rules-of-hooks + useIsomorphicLayoutEffect(() => { + if (readonly) return + if (focused && disabled) { + inputRef.current?.blur() + setFocused(false) + } + }, [disabled, focused, readonly]) + // fix minWidth on web + // ref: https://stackoverflow.com/a/29990524/1930491 + // eslint-disable-next-line react-hooks/rules-of-hooks + useIsomorphicLayoutEffect(() => { + if (readonly) return + // TODO: looks like it's not available anymore on new versions of react-native-web + inputRef.current?.setNativeProps?.({ size: '1' }) + }, [readonly]) + } + + // useDidUpdate(() => { + // if (readonly) return + // if (numberOfLines !== currentNumberOfLines) { + // setCurrentNumberOfLines(numberOfLines) + // } + // }, [numberOfLines, currentNumberOfLines, readonly]) + + const multiline = useMemo(() => { + return resize || numberOfLines > 1 + }, [resize, numberOfLines]) + + const fullHeight = useMemo(() => { + return currentNumberOfLines * (heights[size] as number) + (paddings[size] as number) * 2 + }, [currentNumberOfLines, size]) + + function onLayoutIcon (e: any) { + if (IS_WEB) { + e.nativeEvent.target.childNodes[0].tabIndex = -1 + e.nativeEvent.target.childNodes[0].childNodes[0].tabIndex = -1 + } + } + + const inputExtraProps: Record = {} + if (IS_ANDROID && multiline) inputExtraProps.textAlignVertical = 'top' + + const inputStyleName = [ + size, + { + disabled, + focused, + [`icon-${iconPosition}`]: !!icon, + [`icon-${getOppositePosition(iconPosition)}`]: !!secondaryIcon, + error: _hasError + } + ] + + if (readonly) { + return pug` + Span= value + ` + } + + return _renderWrapper({ + style: [style] + }, pug` + RNTextInput.input-input( + part=['input', { + inputIconLeft: icon && iconPosition === 'left', + inputIconRight: icon && iconPosition === 'right' + }] + ref=inputRef + style={ minHeight: fullHeight } + styleName=inputStyleName + selectionColor=caretColor + placeholder=placeholder + placeholderTextColor=getColor('text-placeholder') + value=value + disabled=IS_WEB ? disabled : undefined + editable=IS_WEB ? undefined : !disabled + multiline=multiline + selectTextOnFocus=false + onFocus=handleFocus + onBlur=handleBlur + ...props + ...inputExtraProps + ) + if icon + Div.input-icon( + focusable=false + onLayout=onLayoutIcon + styleName=[size, iconPosition] + onPress=disabled ? undefined : onIconPress + pointerEvents=onIconPress ? undefined : 'none' + ) + Icon( + part='icon' + icon=icon + size=ICON_SIZES[size] + ) + if secondaryIcon + Div.input-icon( + focusable=false + onLayout=onLayoutIcon + styleName=[size, getOppositePosition(iconPosition)] + onPress=disabled ? undefined : onSecondaryIconPress + pointerEvents=onSecondaryIconPress ? undefined : 'none' + ) + Icon( + part='secondaryIcon' + icon=secondaryIcon + size=ICON_SIZES[size] + ) + `) +} + +function getOppositePosition (position: 'left' | 'right') { + return position === 'left' ? 'right' : 'left' +} diff --git a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx index c6dcc7f..11cbe12 100644 --- a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx @@ -60,11 +60,7 @@ const Profile = observer(({ $cat, $event }) => {
) : ( - )} @@ -86,8 +82,8 @@ function renderExpired () { Cat profile link is incorrect or already expired. Your cat meetup - profile link is only valid for a limited period of time. If you - believe this is an error, please contact the cat meetup organizer. + profile link is only valid for a limited period of time. If you believe + this is an error, please contact the cat meetup organizer. diff --git a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx index c6dcc7f..11cbe12 100644 --- a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx @@ -60,11 +60,7 @@ const Profile = observer(({ $cat, $event }) => {
) : ( - )} @@ -86,8 +82,8 @@ function renderExpired () { Cat profile link is incorrect or already expired. Your cat meetup - profile link is only valid for a limited period of time. If you - believe this is an error, please contact the cat meetup organizer. + profile link is only valid for a limited period of time. If you believe + this is an error, please contact the cat meetup organizer.