mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-26 19:34:06 +00:00
feat(diff): Add search and extension filter to diff sidebar (#37068)
Adds a search box and a file-extension filter to the pull request diff sidebar, so reviewers can narrow a large diff down to the files they care about. Both filters apply to the file tree and to the diff itself. The extension menu follows GitHub: extensions sorted alphabetically, dotfiles and extension-less files in their own buckets, and the selection kept in the same `file-filters[]` query parameter, so a filtered view is shareable and survives a reload. The menu can list every extension in a diff, so `createTippy` gains an opt-in `limitSizeToViewport` option that caps a popup to the space left in the viewport and scrolls its content. Popups that do not ask for it are unchanged. Closes https://github.com/go-gitea/gitea/issues/27256 Signed-off-by: silverwind <me@silverwind.io> Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Nicolas <bircni@icloud.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -1,51 +1,140 @@
|
||||
import {diffTreeStoreSetViewed, reactiveDiffTreeStore} from './diff-file.ts';
|
||||
import {countMatchingFiles, diffTreeStoreSetViewed, extensionFilterFromUrl, extensionFilterToUrl, filterDiffTree, getDiffTreeExtensionStats, reactiveDiffTreeStore, type DiffTreeEntry} from './diff-file.ts';
|
||||
|
||||
test('diff-tree', () => {
|
||||
const store = reactiveDiffTreeStore({
|
||||
'TreeRoot': {
|
||||
'FullName': '',
|
||||
'DisplayName': '',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'NameHash': '....',
|
||||
'DiffStatus': '',
|
||||
'FileIcon': '',
|
||||
'Children': [
|
||||
{
|
||||
'FullName': 'dir1',
|
||||
'DisplayName': 'dir1',
|
||||
'EntryMode': 'tree',
|
||||
'IsViewed': false,
|
||||
'NameHash': '....',
|
||||
'DiffStatus': '',
|
||||
'FileIcon': '',
|
||||
'Children': [
|
||||
{
|
||||
'FullName': 'dir1/test.txt',
|
||||
'DisplayName': 'test.txt',
|
||||
'DiffStatus': 'added',
|
||||
'NameHash': '....',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'FileIcon': '',
|
||||
'Children': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'FullName': 'other.txt',
|
||||
'DisplayName': 'other.txt',
|
||||
'NameHash': '........',
|
||||
'DiffStatus': 'added',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'FileIcon': '',
|
||||
'Children': null,
|
||||
},
|
||||
],
|
||||
function file(name: string, oldName: string = ''): DiffTreeEntry {
|
||||
return {
|
||||
FullName: name,
|
||||
OldFullName: oldName,
|
||||
DisplayName: name.split('/').pop()!,
|
||||
DiffStatus: 'added',
|
||||
NameHash: name,
|
||||
EntryMode: '',
|
||||
IsViewed: false,
|
||||
FileIcon: '',
|
||||
Children: null,
|
||||
};
|
||||
}
|
||||
|
||||
function dir(name: string, children: DiffTreeEntry[]): DiffTreeEntry {
|
||||
return {
|
||||
FullName: name,
|
||||
OldFullName: '',
|
||||
DisplayName: name.split('/').pop()!,
|
||||
EntryMode: 'tree',
|
||||
IsViewed: false,
|
||||
NameHash: name,
|
||||
DiffStatus: '',
|
||||
FileIcon: '',
|
||||
Children: children,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(children: DiffTreeEntry[]) {
|
||||
return reactiveDiffTreeStore({
|
||||
TreeRoot: {
|
||||
FullName: '', OldFullName: '', DisplayName: '', EntryMode: 'tree', IsViewed: false,
|
||||
NameHash: 'root', DiffStatus: '', FileIcon: '', Children: children,
|
||||
},
|
||||
}, '', '');
|
||||
}
|
||||
|
||||
function visibleNames(root: DiffTreeEntry | null): string[] {
|
||||
if (!root) return [];
|
||||
const out: string[] = [];
|
||||
const visit = (e: DiffTreeEntry) => {
|
||||
if (e.EntryMode !== 'tree') out.push(e.FullName);
|
||||
for (const c of e.Children ?? []) visit(c);
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
test('diff-tree', () => {
|
||||
const store = makeStore([
|
||||
dir('dir1', [file('dir1/test.txt')]),
|
||||
file('other.txt'),
|
||||
]);
|
||||
diffTreeStoreSetViewed(store, 'dir1/test.txt', true);
|
||||
expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true);
|
||||
expect(store.fullNameMap['dir1'].IsViewed).toBe(true);
|
||||
});
|
||||
|
||||
test('filterDiffTree', () => {
|
||||
const store = makeStore([
|
||||
dir('dir1', [file('dir1/test.txt')]),
|
||||
file('other.ts'),
|
||||
file('other.TS'),
|
||||
]);
|
||||
|
||||
store.filenameFilterQuery = 'TesT';
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['dir1/test.txt']);
|
||||
|
||||
store.filenameFilterQuery = '';
|
||||
store.activeExtensions = ['.ts'];
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['other.ts', 'other.TS']);
|
||||
|
||||
store.activeExtensions = [];
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual([]);
|
||||
|
||||
store.activeExtensions = 'all';
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['dir1/test.txt', 'other.ts', 'other.TS']);
|
||||
});
|
||||
|
||||
test('getDiffTreeExtensionStats', () => {
|
||||
const store = makeStore([
|
||||
dir('dir1', [file('dir1/test.txt'), file('dir1/Makefile'), file('dir1/.gitignore')]),
|
||||
file('.eslintrc.json'), // a dotfile with an extension keeps that extension
|
||||
file('other.ts'),
|
||||
file('other.TXT'), // case-insensitive
|
||||
]);
|
||||
expect(getDiffTreeExtensionStats(store)).toEqual([
|
||||
{ext: '.json', count: 1},
|
||||
{ext: '.ts', count: 1},
|
||||
{ext: '.txt', count: 2},
|
||||
{ext: 'dotfile', count: 1},
|
||||
{ext: '', count: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
test('countMatchingFiles', () => {
|
||||
const store = makeStore([
|
||||
dir('dir1', [file('dir1/new-name.md', 'dir1/old-name.txt')]),
|
||||
file('other.ts'),
|
||||
]);
|
||||
|
||||
expect(countMatchingFiles(store)).toBe(2);
|
||||
|
||||
// search query also matches the pre-rename path
|
||||
store.filenameFilterQuery = 'old-name';
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['dir1/new-name.md']);
|
||||
expect(countMatchingFiles(store)).toBe(1);
|
||||
|
||||
// extension filter only applies to new name
|
||||
store.filenameFilterQuery = '';
|
||||
store.activeExtensions = ['.txt'];
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual([]);
|
||||
expect(countMatchingFiles(store)).toBe(0);
|
||||
store.activeExtensions = ['.md'];
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['dir1/new-name.md']);
|
||||
|
||||
store.activeExtensions = ['.md', '.ts'];
|
||||
expect(countMatchingFiles(store)).toBe(2);
|
||||
});
|
||||
|
||||
test('extensionFilter url round-trip', () => {
|
||||
const known = ['.go', '.ts', 'dotfile', ''];
|
||||
const url = 'http://localhost/owner/repo/pulls/1/files?style=split';
|
||||
const roundTrip = (filter: Parameters<typeof extensionFilterToUrl>[0]) =>
|
||||
extensionFilterFromUrl(new URL(extensionFilterToUrl(filter, url)).search, known);
|
||||
|
||||
expect(extensionFilterToUrl('all', url)).toEqual(url);
|
||||
expect(roundTrip('all')).toEqual('all');
|
||||
expect(roundTrip(['.go', ''])).toEqual(['.go', '']);
|
||||
expect(roundTrip([])).toEqual([]);
|
||||
|
||||
// other query parameters survive, "no extension" gets a stable token
|
||||
expect(extensionFilterToUrl(['.go', ''], url)).toEqual(`${url}&file-filters%5B%5D=.go&file-filters%5B%5D=noextension`);
|
||||
|
||||
// unknown extensions are dropped, selecting every known one is the same as no filter
|
||||
expect(extensionFilterFromUrl('?file-filters[]=.go&file-filters[]=.nope', known)).toEqual(['.go']);
|
||||
expect(extensionFilterFromUrl('?file-filters[]=.go&file-filters[]=.ts&file-filters[]=dotfile&file-filters[]=noextension', known)).toEqual('all');
|
||||
});
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {reactive} from 'vue';
|
||||
import type {Reactive} from 'vue';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {trString} from './i18n.ts';
|
||||
import {basename, extname} from '../utils.ts';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
|
||||
// matches statusFromLetter in services/gitdiff/git_diff_tree.go
|
||||
export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechanged' | 'unmerged' | 'unknown';
|
||||
|
||||
export type DiffTreeEntry = {
|
||||
FullName: string,
|
||||
OldFullName: string,
|
||||
DisplayName: string,
|
||||
NameHash: string,
|
||||
DiffStatus: DiffStatus,
|
||||
@@ -21,6 +26,9 @@ export type DiffFileTreeData = {
|
||||
TreeRoot: DiffTreeEntry,
|
||||
};
|
||||
|
||||
// activeExtensions: 'all' = no filter (every extension passes); string[] = exact set of extensions allowed (empty = nothing passes).
|
||||
type ExtensionFilter = 'all' | string[];
|
||||
|
||||
type DiffFileTree = {
|
||||
folderIcon: string;
|
||||
folderOpenIcon: string;
|
||||
@@ -28,12 +36,34 @@ type DiffFileTree = {
|
||||
fullNameMap: Record<string, DiffTreeEntry>
|
||||
fileTreeIsVisible: boolean;
|
||||
selectedItem: string;
|
||||
filenameFilterQuery: string;
|
||||
activeExtensions: ExtensionFilter;
|
||||
};
|
||||
|
||||
type DiffExtensionStats = {
|
||||
ext: string,
|
||||
count: number,
|
||||
};
|
||||
|
||||
export type DiffExtensionFilterLocale = {
|
||||
filterByFileExtension: string,
|
||||
fileExtensions: string,
|
||||
noFileExtension: string,
|
||||
dotfileExtension: string,
|
||||
allFileExtensions: string,
|
||||
};
|
||||
|
||||
export type DiffFileTreeLocale = DiffExtensionFilterLocale & {
|
||||
filterFiles: string,
|
||||
filterFilesClear: string,
|
||||
};
|
||||
|
||||
let diffTreeStoreReactive: Reactive<DiffFileTree>;
|
||||
export function diffTreeStore() {
|
||||
if (!diffTreeStoreReactive) {
|
||||
diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree!, pageData.FolderIcon!, pageData.FolderOpenIcon!);
|
||||
const knownExtensions = getDiffTreeExtensionStats(diffTreeStoreReactive).map((stat) => stat.ext);
|
||||
diffTreeStoreReactive.activeExtensions = extensionFilterFromUrl(window.location.search, knownExtensions);
|
||||
}
|
||||
return diffTreeStoreReactive;
|
||||
}
|
||||
@@ -58,25 +88,149 @@ function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntr
|
||||
}
|
||||
|
||||
export function reactiveDiffTreeStore(data: DiffFileTreeData, folderIcon: string, folderOpenIcon: string): Reactive<DiffFileTree> {
|
||||
const store = reactive({
|
||||
const store = reactive<DiffFileTree>({
|
||||
diffFileTree: data,
|
||||
folderIcon,
|
||||
folderOpenIcon,
|
||||
fileTreeIsVisible: false,
|
||||
selectedItem: '',
|
||||
filenameFilterQuery: '',
|
||||
activeExtensions: 'all',
|
||||
fullNameMap: {},
|
||||
});
|
||||
fillFullNameMap(store.fullNameMap, data.TreeRoot);
|
||||
return store;
|
||||
}
|
||||
|
||||
export const extDotfile = 'dotfile'; // bucket for ".gitignore" and friends, real extensions always start with a dot
|
||||
|
||||
const urlParamFileFilters = 'file-filters[]'; // same parameter GitHub uses, lists the selected extensions
|
||||
const urlValueNoExtension = 'noextension';
|
||||
|
||||
export function extensionFilterFromUrl(search: string, knownExtensions: string[]): ExtensionFilter {
|
||||
const params = new URLSearchParams(search);
|
||||
if (!params.has(urlParamFileFilters)) return 'all';
|
||||
const extensions = params.getAll(urlParamFileFilters)
|
||||
.filter(Boolean)
|
||||
.map((ext) => ext === urlValueNoExtension ? '' : ext)
|
||||
.filter((ext) => knownExtensions.includes(ext));
|
||||
return extensions.length === knownExtensions.length ? 'all' : extensions;
|
||||
}
|
||||
|
||||
export function extensionFilterToUrl(filter: ExtensionFilter, url: string): string {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.delete(urlParamFileFilters);
|
||||
if (filter !== 'all') {
|
||||
if (!filter.length) parsed.searchParams.append(urlParamFileFilters, '');
|
||||
for (const ext of filter) parsed.searchParams.append(urlParamFileFilters, ext || urlValueNoExtension);
|
||||
}
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (ext) return ext;
|
||||
return basename(filename).startsWith('.') ? extDotfile : '';
|
||||
}
|
||||
|
||||
function extensionRank(ext: string): number {
|
||||
if (!ext) return 2;
|
||||
return ext === extDotfile ? 1 : 0;
|
||||
}
|
||||
|
||||
export function getDiffTreeExtensionStats(store: Reactive<DiffFileTree>): DiffExtensionStats[] {
|
||||
const extensionMap = new Map<string, number>();
|
||||
for (const entry of Object.values(store.fullNameMap)) {
|
||||
if (entry.EntryMode === 'tree' || !entry.FullName) continue;
|
||||
const ext = getFileExtension(entry.FullName);
|
||||
extensionMap.set(ext, (extensionMap.get(ext) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(extensionMap, ([ext, count]) => ({ext, count}))
|
||||
.sort((a, b) => extensionRank(a.ext) - extensionRank(b.ext) || a.ext.localeCompare(b.ext));
|
||||
}
|
||||
|
||||
function buildFilter(store: Reactive<DiffFileTree>) {
|
||||
const query = store.filenameFilterQuery.trim().toLowerCase();
|
||||
const exts = store.activeExtensions === 'all' ? null : new Set(store.activeExtensions);
|
||||
if (!query && !exts) return null;
|
||||
return (newName: string, oldName: string) => {
|
||||
if (query && !newName.toLowerCase().includes(query) && !oldName.toLowerCase().includes(query)) return false;
|
||||
return !exts || exts.has(getFileExtension(newName));
|
||||
};
|
||||
}
|
||||
|
||||
// Children===null marks a file leaf; everything else (incl. the root, which has EntryMode="") is recursed into.
|
||||
export function filterDiffTree(store: Reactive<DiffFileTree>): DiffTreeEntry | null {
|
||||
const matches = buildFilter(store);
|
||||
if (!matches) return store.diffFileTree.TreeRoot;
|
||||
const visit = (entry: DiffTreeEntry): DiffTreeEntry | null => {
|
||||
if (entry.Children === null) return matches(entry.FullName, entry.OldFullName) ? entry : null;
|
||||
const children = entry.Children.map(visit).filter((child): child is DiffTreeEntry => child !== null);
|
||||
if (!children.length) return null;
|
||||
return {...entry, Children: children};
|
||||
};
|
||||
return visit(store.diffFileTree.TreeRoot);
|
||||
}
|
||||
|
||||
export function countMatchingFiles(store: Reactive<DiffFileTree>): number {
|
||||
const matches = buildFilter(store);
|
||||
let totalMatchingFilesCount = 0;
|
||||
for (const entry of Object.values(store.fullNameMap)) {
|
||||
if (entry.EntryMode === 'tree' || !entry.FullName) continue;
|
||||
if (!matches || matches(entry.FullName, entry.OldFullName)) totalMatchingFilesCount++;
|
||||
}
|
||||
return totalMatchingFilesCount;
|
||||
}
|
||||
|
||||
function countEveryFileInDiff(store: Reactive<DiffFileTree>): number {
|
||||
let totalFilesCount = 0;
|
||||
for (const entry of Object.values(store.fullNameMap)) {
|
||||
if (entry.EntryMode !== 'tree' && entry.FullName) totalFilesCount++;
|
||||
}
|
||||
return totalFilesCount;
|
||||
}
|
||||
|
||||
function updateLoadProgress(loadedFiles: number, totalFiles: number) {
|
||||
const el = document.querySelector('#diff-load-progress');
|
||||
if (!el) return;
|
||||
el.textContent = trString(el.getAttribute('data-text-too-many-files')!, loadedFiles, totalFiles);
|
||||
}
|
||||
|
||||
function updateShowMoreButton(matchingBelow: number) {
|
||||
const btn = document.querySelector('#diff-show-more-files');
|
||||
if (!btn) return;
|
||||
if (matchingBelow > 0) {
|
||||
btn.textContent = trString(btn.getAttribute('data-text-matching')!, matchingBelow);
|
||||
} else {
|
||||
btn.textContent = btn.getAttribute('data-text-default')!;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyFiltersToFileBoxes(store: Reactive<DiffFileTree>) {
|
||||
const boxes = document.querySelectorAll<HTMLElement>('#diff-file-boxes .diff-file-box[data-new-filename]');
|
||||
const matches = buildFilter(store);
|
||||
if (!matches) {
|
||||
for (const box of boxes) toggleElem(box, true);
|
||||
toggleElem('#diff-no-matches', false);
|
||||
updateShowMoreButton(0);
|
||||
updateLoadProgress(boxes.length, countEveryFileInDiff(store));
|
||||
return;
|
||||
}
|
||||
let visibleCount = 0;
|
||||
for (const box of boxes) {
|
||||
const matched = matches(box.getAttribute('data-new-filename')!, box.getAttribute('data-old-filename')!);
|
||||
if (matched) visibleCount++;
|
||||
toggleElem(box, matched);
|
||||
}
|
||||
const matchingCount = countMatchingFiles(store);
|
||||
updateShowMoreButton(matchingCount - visibleCount);
|
||||
updateLoadProgress(boxes.length, countEveryFileInDiff(store));
|
||||
toggleElem('#diff-no-matches', matchingCount === 0);
|
||||
}
|
||||
|
||||
function isEntryViewed(entry: DiffTreeEntry): boolean {
|
||||
if (entry.Children) {
|
||||
let count = 0;
|
||||
for (const child of entry.Children) {
|
||||
if (child.IsViewed) count++;
|
||||
}
|
||||
return count === entry.Children.length;
|
||||
return entry.Children.every((child) => child.IsViewed);
|
||||
}
|
||||
return entry.IsViewed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {availableSizeForPlacement} from './tippy.ts';
|
||||
|
||||
test('availableSizeForPlacement', () => {
|
||||
const rect = (values: Partial<DOMRect>) => values as DOMRect;
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1000);
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(800);
|
||||
|
||||
// on the placement axis it is the gap to the edge the popup opens towards, the viewport on the other
|
||||
expect(availableSizeForPlacement(rect({top: 400, bottom: 432}), 'bottom-end', 0)).toEqual({width: 784, height: 560});
|
||||
expect(availableSizeForPlacement(rect({top: 400, bottom: 432}), 'top-end', 0)).toEqual({width: 784, height: 392});
|
||||
expect(availableSizeForPlacement(rect({left: 100, right: 300}), 'right', 0)).toEqual({width: 492, height: 984});
|
||||
expect(availableSizeForPlacement(rect({left: 100, right: 300}), 'left', 0)).toEqual({width: 92, height: 984});
|
||||
|
||||
// the placement offset eats into the space on that axis
|
||||
expect(availableSizeForPlacement(rect({top: 400, bottom: 432}), 'bottom-end', -6).height).toEqual(554);
|
||||
});
|
||||
@@ -7,8 +7,11 @@ import {stripTags} from '../utils.ts';
|
||||
type TippyOpts = {
|
||||
role?: string,
|
||||
theme?: 'default' | 'tooltip' | 'menu' | 'box-with-header' | 'bare',
|
||||
limitSizeToViewport?: {horizontal?: boolean, vertical?: boolean}, // cap to the viewport and scroll the content
|
||||
} & Partial<Props>;
|
||||
|
||||
type PopperModifier = NonNullable<NonNullable<Props['popperOptions']>['modifiers']>[number];
|
||||
|
||||
const visibleInstances = new Set<Instance>();
|
||||
const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
|
||||
|
||||
@@ -20,10 +23,46 @@ function arrowPadding({placement, reference}: {placement: Placement, reference:
|
||||
return Math.max(0, Math.min(3, referenceLength / 2 - 8)); // 8 = half of arrow width
|
||||
}
|
||||
|
||||
const viewportPadding = 8;
|
||||
|
||||
// space left on the side the popup opens towards, the viewport on the other axis
|
||||
export function availableSizeForPlacement(referenceRect: DOMRect, placement: string, offset: number): {width: number, height: number} {
|
||||
const gap = Math.abs(offset) + viewportPadding;
|
||||
const spanWidth = window.innerWidth - viewportPadding * 2;
|
||||
const spanHeight = window.innerHeight - viewportPadding * 2;
|
||||
const side = placement.split('-')[0];
|
||||
if (side === 'top') return {width: spanWidth, height: referenceRect.top - gap};
|
||||
if (side === 'bottom') return {width: spanWidth, height: window.innerHeight - referenceRect.bottom - gap};
|
||||
if (side === 'left') return {width: referenceRect.left - gap, height: spanHeight};
|
||||
return {width: window.innerWidth - referenceRect.right - gap, height: spanHeight};
|
||||
}
|
||||
|
||||
// popper does not constrain size, publish it for the styles. Replaced by floating-ui's "size" on migration
|
||||
function sizeModifier(limit: {horizontal?: boolean, vertical?: boolean}): PopperModifier {
|
||||
return {
|
||||
name: 'tippyLimitSize',
|
||||
enabled: true,
|
||||
phase: 'beforeWrite',
|
||||
requires: ['computeStyles'], // runs after "flip" and "offset", so both are final
|
||||
fn({state}) {
|
||||
const offset = state.modifiersData.offset?.[state.placement];
|
||||
const isVertical = state.placement.startsWith('top') || state.placement.startsWith('bottom');
|
||||
const available = availableSizeForPlacement(
|
||||
state.elements.reference.getBoundingClientRect(),
|
||||
state.placement,
|
||||
(isVertical ? offset?.y : offset?.x) ?? 0,
|
||||
);
|
||||
const {style} = state.elements.popper;
|
||||
if (limit.horizontal) style.setProperty('--tippy-max-width', `${Math.max(0, Math.floor(available.width))}px`);
|
||||
if (limit.vertical) style.setProperty('--tippy-max-height', `${Math.max(0, Math.floor(available.height))}px`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
|
||||
// the callback functions should be destructured from opts,
|
||||
// because we should use our own wrapper functions to handle them, do not let the user override them
|
||||
const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
|
||||
const {onHide, onShow, onDestroy, role, theme, arrow, limitSizeToViewport, ...other} = opts;
|
||||
// CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
|
||||
const resolvedTheme = theme || role || 'default';
|
||||
const resolvedArrow = arrow ?? (resolvedTheme === 'bare' ? false : arrowSvg);
|
||||
@@ -56,7 +95,10 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
|
||||
return onShow?.(instance);
|
||||
},
|
||||
arrow: resolvedArrow,
|
||||
popperOptions: {modifiers: [{name: 'arrow', options: {padding: arrowPadding}}]},
|
||||
popperOptions: {modifiers: [
|
||||
{name: 'arrow', options: {padding: arrowPadding}},
|
||||
...limitSizeToViewport ? [sizeModifier(limitSizeToViewport)] : [],
|
||||
]},
|
||||
// HTML role attribute, ideally the default role would be "popover" but it does not exist
|
||||
role: role || 'menu',
|
||||
theme: resolvedTheme,
|
||||
@@ -68,6 +110,7 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
|
||||
if (instance.props.role === 'menu') {
|
||||
target.setAttribute('aria-haspopup', 'true');
|
||||
}
|
||||
if (limitSizeToViewport) instance.popper.setAttribute('data-tippy-limit-size', '');
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user